Settings › Security grows three working sections instead of a note telling people to use Stalwart's own portal. Stalwart moved this API between releases, so ihasmail speaks both: 0.16+ has the x:AccountPassword singleton and x:AppPassword registry objects over JMAP, while 0.15.x has the /api/account/auth REST endpoint. Which one answers the probe is the only reliable way to tell them apart, and the result is cached per session. The built-in `user` role already grants sysAccountPassword* and sysAppPassword*, so no administrator setup is needed. Two problems are worth calling out, because both would bite a user hard: Stalwart validates the credentials already on the account when 2FA is turned on and never checks the new secret, so an authenticator that was mistyped or out of step would lock someone out of their mailbox at the next sign-in. We verify a code against the new secret ourselves first (RFC 6238, tested against the spec's vectors) and only then ask the server to store anything. Every proxied call re-authenticates with the credential sealed into the session, and from the moment 2FA is on Stalwart wants a fresh TOTP code with it — which we cannot produce between requests. Turning 2FA on would therefore sign the user out of the browser they just turned it on in. App passwords authenticate without a second factor, so the session is moved onto one minted for this browser, and the session cookie is re-sealed with it. The order matters: it is minted while the old credential still works, and revoked again if enabling then fails. Password changes re-seal this session too and drop the others, whose sealed copies of the old password would fail on their next call. The mock now enforces what a real server does — current password, password policy, a TOTP code on every request once 2FA is on, app passwords exempt — so the whole flow is exercised in tests rather than only by hand.
621 lines
23 KiB
TypeScript
621 lines
23 KiB
TypeScript
import { Hono } from "hono";
|
|
import type { Context, MiddlewareHandler } from "hono";
|
|
import { getCookie, setCookie, deleteCookie } from "hono/cookie";
|
|
import { getConnInfo } from "@hono/node-server/conninfo";
|
|
import { config } from "./config.js";
|
|
import { SessionStore, type LiveSession } from "./sessions.js";
|
|
import { RateLimiter } from "./ratelimit.js";
|
|
import {
|
|
UpstreamError,
|
|
absoluteUpstream,
|
|
expandTemplate,
|
|
fetchUpstreamSession,
|
|
forgetUpstreamSession,
|
|
getAccountLocale,
|
|
getUpstreamSession,
|
|
localizeSession,
|
|
} from "./upstream.js";
|
|
import {
|
|
AccountError,
|
|
assertEnrolmentCode,
|
|
beginOtpEnrolment,
|
|
changePassword,
|
|
createAppPassword,
|
|
disableOtp,
|
|
enableOtp,
|
|
forgetBackend,
|
|
getState,
|
|
revokeAppPassword,
|
|
} from "./account.js";
|
|
import { imageProxyHandler } from "./imageproxy.js";
|
|
import { staticHandler } from "./static.js";
|
|
|
|
type Env = { Variables: { session: LiveSession } };
|
|
|
|
export const sessions = new SessionStore(config.sessionFile);
|
|
const loginLimiter = new RateLimiter(config.loginRateLimit, 15 * 60_000);
|
|
/**
|
|
* Credential changes verify the current password upstream, and Stalwart's
|
|
* fail2ban counts those failures against the *caller's* IP — which for a proxy
|
|
* is shared by every user. Keep our own lid on it so one person guessing
|
|
* cannot get the whole deployment banned.
|
|
*/
|
|
const accountLimiter = new RateLimiter(10, 15 * 60_000);
|
|
|
|
const HOP_BY_HOP = new Set([
|
|
"connection",
|
|
"keep-alive",
|
|
"proxy-authenticate",
|
|
"proxy-authorization",
|
|
"te",
|
|
"trailer",
|
|
"transfer-encoding",
|
|
"upgrade",
|
|
"content-encoding",
|
|
"content-length",
|
|
]);
|
|
|
|
export function clientIp(c: Context): string {
|
|
if (config.trustProxy) {
|
|
const xff = c.req.header("x-forwarded-for");
|
|
if (xff) return xff.split(",")[0]!.trim();
|
|
const realIp = c.req.header("x-real-ip");
|
|
if (realIp) return realIp.trim();
|
|
}
|
|
try {
|
|
return getConnInfo(c).remote.address ?? "unknown";
|
|
} catch {
|
|
return "unknown";
|
|
}
|
|
}
|
|
|
|
function isSecureRequest(c: Context): boolean {
|
|
if (config.secureCookies === "1" || config.secureCookies === "true") return true;
|
|
if (config.secureCookies === "0" || config.secureCookies === "false") return false;
|
|
if (config.trustProxy) {
|
|
const proto = c.req.header("x-forwarded-proto");
|
|
if (proto) return proto.split(",")[0]!.trim() === "https";
|
|
}
|
|
return new URL(c.req.url).protocol === "https:";
|
|
}
|
|
|
|
/** Security headers for every response. */
|
|
const securityHeaders: MiddlewareHandler = async (c, next) => {
|
|
await next();
|
|
const h = c.res.headers;
|
|
h.set("X-Content-Type-Options", "nosniff");
|
|
h.set("X-Frame-Options", "DENY");
|
|
h.set("Referrer-Policy", "no-referrer");
|
|
h.set("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=(), usb=()");
|
|
h.set("Cross-Origin-Opener-Policy", "same-origin");
|
|
if (!h.has("Cache-Control")) h.set("Cache-Control", "no-store");
|
|
if (isSecureRequest(c)) h.set("Strict-Transport-Security", "max-age=31536000; includeSubDomains");
|
|
};
|
|
|
|
/** CSRF: require our custom header on all API calls; reject cross-site fetches. */
|
|
const csrfGuard: MiddlewareHandler = async (c, next) => {
|
|
const site = c.req.header("sec-fetch-site");
|
|
if (site && site !== "same-origin" && site !== "none") {
|
|
return c.json({ error: "cross_site_request" }, 403);
|
|
}
|
|
if (c.req.method !== "GET" && c.req.method !== "HEAD") {
|
|
if (c.req.header("x-requested-with") !== "ihasmail") {
|
|
return c.json({ error: "missing_csrf_header" }, 403);
|
|
}
|
|
}
|
|
await next();
|
|
};
|
|
|
|
const requireSession: MiddlewareHandler<Env> = async (c, next) => {
|
|
const cookie = getCookie(c, config.cookieName);
|
|
const session = sessions.resolve(cookie);
|
|
if (!session) {
|
|
return c.json({ error: "unauthenticated" }, 401);
|
|
}
|
|
c.set("session", session);
|
|
await next();
|
|
};
|
|
|
|
function setSessionCookie(c: Context, value: string, remember: boolean) {
|
|
setCookie(c, config.cookieName, value, {
|
|
httpOnly: true,
|
|
sameSite: "Lax",
|
|
secure: isSecureRequest(c),
|
|
path: "/",
|
|
...(remember ? { maxAge: config.sessionRememberTtl } : {}),
|
|
});
|
|
}
|
|
|
|
function upstreamFailure(c: Context, err: unknown) {
|
|
if (err instanceof UpstreamError) {
|
|
return c.json({ error: err.status === 401 ? "invalid_credentials" : "upstream_error", message: err.message }, err.status as 401 | 502);
|
|
}
|
|
const name = (err as Error)?.name ?? "";
|
|
if (name === "TimeoutError" || name === "AbortError") {
|
|
return c.json({ error: "upstream_timeout", message: "The mail server did not respond in time" }, 504);
|
|
}
|
|
console.error("[ihasmail] upstream failure:", err);
|
|
return c.json({ error: "upstream_error", message: "Could not reach the mail server" }, 502);
|
|
}
|
|
|
|
export function createApp(): Hono<Env> {
|
|
const app = new Hono<Env>();
|
|
app.use("*", securityHeaders);
|
|
|
|
const api = new Hono<Env>();
|
|
api.use("*", csrfGuard);
|
|
|
|
api.get("/health", (c) => c.json({ ok: true, name: config.appName, version: "2.0.0" }));
|
|
|
|
api.get("/config", (c) =>
|
|
c.json({
|
|
appName: config.appName,
|
|
imageProxy: config.imageProxy,
|
|
maxUploadBytes: config.maxUploadBytes,
|
|
}),
|
|
);
|
|
|
|
// ---------- Auth ----------
|
|
api.post("/auth/login", async (c) => {
|
|
const ip = clientIp(c);
|
|
let body: { username?: string; password?: string; totp?: string; remember?: boolean };
|
|
try {
|
|
body = await c.req.json();
|
|
} catch {
|
|
return c.json({ error: "bad_request" }, 400);
|
|
}
|
|
const username = (body.username ?? "").trim();
|
|
const password = body.password ?? "";
|
|
const totp = (body.totp ?? "").trim();
|
|
if (!username || !password) return c.json({ error: "missing_credentials" }, 400);
|
|
if (username.length > 320 || password.length > 1024) return c.json({ error: "bad_request" }, 400);
|
|
|
|
const limitKey = `${ip}|${username.toLowerCase()}`;
|
|
if (!loginLimiter.check(limitKey) || !loginLimiter.check(ip)) {
|
|
c.header("Retry-After", String(loginLimiter.retryAfterSeconds(limitKey)));
|
|
return c.json({ error: "rate_limited", message: "Too many login attempts. Please wait and try again." }, 429);
|
|
}
|
|
|
|
// Stalwart accepts TOTP codes appended to the password as "password$123456".
|
|
const effectivePassword = totp ? `${password}$${totp}` : password;
|
|
const authorization = `Basic ${Buffer.from(`${username}:${effectivePassword}`, "utf8").toString("base64")}`;
|
|
try {
|
|
const upstream = await fetchUpstreamSession(authorization);
|
|
loginLimiter.reset(limitKey);
|
|
const { cookie, session } = sessions.create({
|
|
username,
|
|
password: effectivePassword,
|
|
remember: Boolean(body.remember),
|
|
userAgent: c.req.header("user-agent") ?? "",
|
|
ip,
|
|
});
|
|
setSessionCookie(c, cookie, session.remember);
|
|
const locale = await getAccountLocale(session.id, session.authorization, upstream);
|
|
return c.json(localizeSession(upstream, sessionExtras(session, locale)));
|
|
} catch (err) {
|
|
return upstreamFailure(c, err);
|
|
}
|
|
});
|
|
|
|
api.get("/auth/session", requireSession, async (c) => {
|
|
const session = c.get("session");
|
|
try {
|
|
const upstream = await getUpstreamSession(session.id, session.authorization, c.req.query("refresh") === "1");
|
|
const locale = await getAccountLocale(session.id, session.authorization, upstream);
|
|
return c.json(localizeSession(upstream, sessionExtras(session, locale)));
|
|
} catch (err) {
|
|
if (err instanceof UpstreamError && err.status === 401) {
|
|
sessions.destroy(session.id);
|
|
deleteCookie(c, config.cookieName, { path: "/" });
|
|
}
|
|
return upstreamFailure(c, err);
|
|
}
|
|
});
|
|
|
|
api.post("/auth/logout", async (c) => {
|
|
const cookie = getCookie(c, config.cookieName);
|
|
const session = sessions.resolve(cookie);
|
|
if (session) {
|
|
sessions.destroy(session.id);
|
|
forgetUpstreamSession(session.id);
|
|
}
|
|
deleteCookie(c, config.cookieName, { path: "/" });
|
|
return c.json({ ok: true });
|
|
});
|
|
|
|
api.get("/auth/sessions", requireSession, (c) => {
|
|
const session = c.get("session");
|
|
return c.json({ current: session.id, sessions: sessions.listForUser(session.username) });
|
|
});
|
|
|
|
api.post("/auth/sessions/revoke-others", requireSession, (c) => {
|
|
const session = c.get("session");
|
|
const n = sessions.destroyAllForUser(session.username, session.id);
|
|
return c.json({ revoked: n });
|
|
});
|
|
|
|
// ---------- Self-service credentials ----------
|
|
/**
|
|
* Password, app passwords and 2FA. These live on the server rather than in
|
|
* the browser because the pre-0.16 API is REST rather than JMAP (the browser
|
|
* only ever sees /api/jmap), and because changing a credential means
|
|
* re-sealing the session cookie that holds it.
|
|
*/
|
|
const accountCtx = async (c: Context<Env>) => {
|
|
const session = c.get("session");
|
|
const upstream = await getUpstreamSession(session.id, session.authorization);
|
|
return { authorization: session.authorization, session: upstream, username: session.username };
|
|
};
|
|
|
|
const accountFailure = (c: Context, err: unknown) => {
|
|
if (err instanceof AccountError) {
|
|
return c.json({ error: err.code, message: err.message }, err.status as 400);
|
|
}
|
|
return upstreamFailure(c, err);
|
|
};
|
|
|
|
/** Guard the endpoints that check a password against brute-forcing. */
|
|
const guarded = (c: Context<Env>): Response | null => {
|
|
const key = `account|${c.get("session").username.toLowerCase()}`;
|
|
if (accountLimiter.check(key)) return null;
|
|
c.header("Retry-After", String(accountLimiter.retryAfterSeconds(key)));
|
|
return c.json({ error: "rate_limited", message: "Too many attempts. Please wait and try again." }, 429);
|
|
};
|
|
|
|
api.get("/account/security", requireSession, async (c) => {
|
|
const session = c.get("session");
|
|
try {
|
|
return c.json(await getState(session.id, await accountCtx(c)));
|
|
} catch (err) {
|
|
return accountFailure(c, err);
|
|
}
|
|
});
|
|
|
|
api.post("/account/password", requireSession, async (c) => {
|
|
const limited = guarded(c);
|
|
if (limited) return limited;
|
|
const session = c.get("session");
|
|
const body = await readJson<{ current?: string; next?: string; otpCode?: string }>(c);
|
|
if (!body) return c.json({ error: "bad_request" }, 400);
|
|
const current = body.current ?? "";
|
|
const next = body.next ?? "";
|
|
if (!current || !next) return c.json({ error: "missing_fields", message: "Both passwords are required." }, 400);
|
|
if (next.length > 1024) return c.json({ error: "bad_request" }, 400);
|
|
if (next === current) {
|
|
return c.json({ error: "unchanged", message: "The new password matches the old one." }, 400);
|
|
}
|
|
try {
|
|
await changePassword(session.id, await accountCtx(c), { current, next, otpCode: body.otpCode?.trim() || undefined });
|
|
} catch (err) {
|
|
return accountFailure(c, err);
|
|
}
|
|
// The old password is now dead: re-seal this session with the new one and
|
|
// drop the others, whose sealed copies would fail on their next call.
|
|
const otpCode = body.otpCode?.trim();
|
|
sessions.reseal(getCookie(c, config.cookieName), otpCode ? `${next}$${otpCode}` : next);
|
|
forgetUpstreamSession(session.id);
|
|
const revoked = sessions.destroyAllForUser(session.username, session.id);
|
|
return c.json({ ok: true, revokedSessions: revoked });
|
|
});
|
|
|
|
api.get("/account/app-passwords", requireSession, async (c) => {
|
|
const session = c.get("session");
|
|
try {
|
|
const state = await getState(session.id, await accountCtx(c));
|
|
return c.json({ appPasswords: state.appPasswords, keyedByName: state.appPasswordsKeyedByName });
|
|
} catch (err) {
|
|
return accountFailure(c, err);
|
|
}
|
|
});
|
|
|
|
api.post("/account/app-passwords", requireSession, async (c) => {
|
|
const session = c.get("session");
|
|
const body = await readJson<{ description?: string }>(c);
|
|
if (!body) return c.json({ error: "bad_request" }, 400);
|
|
const description = (body.description ?? "").trim().slice(0, 120);
|
|
if (!description) return c.json({ error: "missing_fields", message: "Give the app password a name." }, 400);
|
|
try {
|
|
return c.json(await createAppPassword(session.id, await accountCtx(c), { description }));
|
|
} catch (err) {
|
|
return accountFailure(c, err);
|
|
}
|
|
});
|
|
|
|
api.post("/account/app-passwords/revoke", requireSession, async (c) => {
|
|
const session = c.get("session");
|
|
const body = await readJson<{ id?: string }>(c);
|
|
if (!body?.id) return c.json({ error: "bad_request" }, 400);
|
|
try {
|
|
await revokeAppPassword(session.id, await accountCtx(c), body.id);
|
|
return c.json({ ok: true });
|
|
} catch (err) {
|
|
return accountFailure(c, err);
|
|
}
|
|
});
|
|
|
|
api.post("/account/2fa/begin", requireSession, async (c) => {
|
|
try {
|
|
// Nothing is stored yet; the client hands the URL back to confirm.
|
|
return c.json(beginOtpEnrolment(await accountCtx(c)));
|
|
} catch (err) {
|
|
return accountFailure(c, err);
|
|
}
|
|
});
|
|
|
|
api.post("/account/2fa/enable", requireSession, async (c) => {
|
|
const limited = guarded(c);
|
|
if (limited) return limited;
|
|
const session = c.get("session");
|
|
const body = await readJson<{ url?: string; code?: string; current?: string }>(c);
|
|
if (!body?.url || !body.code || !body.current) return c.json({ error: "bad_request" }, 400);
|
|
const ctx = await accountCtx(c);
|
|
const code = body.code.trim();
|
|
/*
|
|
* Every proxied call re-authenticates with the stored password, and once
|
|
* 2FA is on the server wants a fresh TOTP code alongside it — which we
|
|
* cannot produce between requests. An app password authenticates without
|
|
* one, so the session moves onto a dedicated app password rather than
|
|
* being signed out the moment 2FA is switched on.
|
|
*
|
|
* Order matters: mint it while the current credential still works, since
|
|
* the moment 2FA is enabled this session can no longer authenticate at all.
|
|
*/
|
|
try {
|
|
assertEnrolmentCode(body.url, code);
|
|
} catch (err) {
|
|
return accountFailure(c, err);
|
|
}
|
|
let app: { id: string; secret: string } | null = null;
|
|
try {
|
|
app = await createAppPassword(session.id, ctx, { description: appPasswordName(c) });
|
|
} catch (err) {
|
|
// Out of app-password quota, say. 2FA is still worth having; the user
|
|
// just has to sign in again afterwards.
|
|
console.warn("[ihasmail] could not mint a session app password:", (err as Error).message);
|
|
}
|
|
try {
|
|
await enableOtp(session.id, ctx, { url: body.url, code, current: body.current });
|
|
} catch (err) {
|
|
if (app) {
|
|
// Don't leave a credential behind for a change that never happened.
|
|
await revokeAppPassword(session.id, ctx, app.id).catch(() => {});
|
|
}
|
|
return accountFailure(c, err);
|
|
}
|
|
let sessionKept = false;
|
|
if (app) {
|
|
sessionKept = sessions.reseal(getCookie(c, config.cookieName), app.secret);
|
|
if (sessionKept) forgetUpstreamSession(session.id);
|
|
}
|
|
// Other sessions still hold the bare password and will be refused.
|
|
const revoked = sessions.destroyAllForUser(session.username, session.id);
|
|
return c.json({ ok: true, sessionKept, revokedSessions: revoked });
|
|
});
|
|
|
|
api.post("/account/2fa/disable", requireSession, async (c) => {
|
|
const limited = guarded(c);
|
|
if (limited) return limited;
|
|
const session = c.get("session");
|
|
const body = await readJson<{ current?: string; code?: string }>(c);
|
|
if (!body?.current || !body.code) return c.json({ error: "bad_request" }, 400);
|
|
try {
|
|
await disableOtp(session.id, await accountCtx(c), { current: body.current, code: body.code.trim() });
|
|
} catch (err) {
|
|
return accountFailure(c, err);
|
|
}
|
|
// This session may be running on the app password minted when 2FA went on;
|
|
// the plain password works again now, so put it back.
|
|
sessions.reseal(getCookie(c, config.cookieName), body.current);
|
|
forgetUpstreamSession(session.id);
|
|
forgetBackend(session.id);
|
|
return c.json({ ok: true });
|
|
});
|
|
|
|
// ---------- JMAP API proxy ----------
|
|
api.post("/jmap", requireSession, async (c) => {
|
|
const session = c.get("session");
|
|
const ct = c.req.header("content-type") ?? "";
|
|
if (!ct.toLowerCase().startsWith("application/json")) {
|
|
return c.json({ error: "unsupported_media_type" }, 415);
|
|
}
|
|
try {
|
|
const upstream = await getUpstreamSession(session.id, session.authorization);
|
|
const res = await fetch(absoluteUpstream(upstream.apiUrl), {
|
|
method: "POST",
|
|
headers: {
|
|
authorization: session.authorization,
|
|
"content-type": "application/json",
|
|
accept: "application/json",
|
|
},
|
|
body: c.req.raw.body,
|
|
duplex: "half",
|
|
signal: AbortSignal.timeout(config.upstreamTimeout),
|
|
});
|
|
if (res.status === 401) {
|
|
sessions.destroy(session.id);
|
|
forgetUpstreamSession(session.id);
|
|
deleteCookie(c, config.cookieName, { path: "/" });
|
|
return c.json({ error: "unauthenticated" }, 401);
|
|
}
|
|
return passthrough(res);
|
|
} catch (err) {
|
|
return upstreamFailure(c, err);
|
|
}
|
|
});
|
|
|
|
// ---------- Blob upload ----------
|
|
api.post("/upload/:accountId", requireSession, async (c) => {
|
|
const session = c.get("session");
|
|
const accountId = c.req.param("accountId");
|
|
const len = Number(c.req.header("content-length") ?? "0");
|
|
if (len > config.maxUploadBytes) return c.json({ error: "too_large" }, 413);
|
|
try {
|
|
const upstream = await getUpstreamSession(session.id, session.authorization);
|
|
const url = absoluteUpstream(expandTemplate(upstream.uploadUrl, { accountId }));
|
|
const res = await fetch(url, {
|
|
method: "POST",
|
|
headers: {
|
|
authorization: session.authorization,
|
|
"content-type": c.req.header("content-type") ?? "application/octet-stream",
|
|
accept: "application/json",
|
|
},
|
|
body: c.req.raw.body,
|
|
duplex: "half",
|
|
signal: AbortSignal.timeout(Math.max(config.upstreamTimeout, 5 * 60_000)),
|
|
});
|
|
return passthrough(res);
|
|
} catch (err) {
|
|
return upstreamFailure(c, err);
|
|
}
|
|
});
|
|
|
|
// ---------- Blob download ----------
|
|
api.get("/blob/:accountId/:blobId/:name", requireSession, async (c) => {
|
|
const session = c.get("session");
|
|
const { accountId, blobId, name } = c.req.param();
|
|
const accept = c.req.query("accept") ?? "application/octet-stream";
|
|
const inline = c.req.query("inline") === "1";
|
|
try {
|
|
const upstream = await getUpstreamSession(session.id, session.authorization);
|
|
const url = absoluteUpstream(expandTemplate(upstream.downloadUrl, { accountId, blobId, name, type: accept }));
|
|
const res = await fetch(url, {
|
|
headers: { authorization: session.authorization },
|
|
signal: AbortSignal.timeout(Math.max(config.upstreamTimeout, 5 * 60_000)),
|
|
});
|
|
if (!res.ok) return c.json({ error: "not_found" }, res.status === 404 ? 404 : 502);
|
|
const headers = new Headers();
|
|
const type = sanitizeContentType(res.headers.get("content-type") ?? accept);
|
|
headers.set("Content-Type", type);
|
|
const cl = res.headers.get("content-length");
|
|
if (cl) headers.set("Content-Length", cl);
|
|
const safeInline = inline && isInlineSafe(type);
|
|
headers.set(
|
|
"Content-Disposition",
|
|
`${safeInline ? "inline" : "attachment"}; filename*=UTF-8''${encodeURIComponent(name)}`,
|
|
);
|
|
headers.set("X-Content-Type-Options", "nosniff");
|
|
// Sandbox everything except the browser's built-in PDF viewer (which needs scripts to render).
|
|
if (!(safeInline && type === "application/pdf")) {
|
|
headers.set("Content-Security-Policy", "sandbox; default-src 'none'; style-src 'unsafe-inline'; img-src data:");
|
|
}
|
|
headers.set("Cache-Control", "private, max-age=3600");
|
|
return new Response(res.body, { status: 200, headers });
|
|
} catch (err) {
|
|
return upstreamFailure(c, err);
|
|
}
|
|
});
|
|
|
|
// ---------- Push (Server-Sent Events) ----------
|
|
api.get("/events", requireSession, async (c) => {
|
|
const session = c.get("session");
|
|
const types = c.req.query("types") ?? "*";
|
|
const closeafter = c.req.query("closeafter") ?? "no";
|
|
const ping = c.req.query("ping") ?? "30";
|
|
try {
|
|
const upstream = await getUpstreamSession(session.id, session.authorization);
|
|
const url = absoluteUpstream(expandTemplate(upstream.eventSourceUrl, { types, closeafter, ping }));
|
|
const controller = new AbortController();
|
|
c.req.raw.signal.addEventListener("abort", () => controller.abort());
|
|
const res = await fetch(url, {
|
|
headers: { authorization: session.authorization, accept: "text/event-stream" },
|
|
signal: controller.signal,
|
|
});
|
|
if (!res.ok || !res.body) return c.json({ error: "upstream_error" }, 502);
|
|
const headers = new Headers({
|
|
"Content-Type": "text/event-stream",
|
|
"Cache-Control": "no-cache, no-transform",
|
|
Connection: "keep-alive",
|
|
"X-Accel-Buffering": "no",
|
|
});
|
|
return new Response(res.body, { status: 200, headers });
|
|
} catch (err) {
|
|
return upstreamFailure(c, err);
|
|
}
|
|
});
|
|
|
|
// ---------- Remote image privacy proxy ----------
|
|
api.get("/image", requireSession, imageProxyHandler);
|
|
|
|
api.notFound((c) => c.json({ error: "not_found" }, 404));
|
|
api.onError((err, c) => {
|
|
console.error("[ihasmail] api error:", err);
|
|
return c.json({ error: "internal_error" }, 500);
|
|
});
|
|
|
|
app.route("/api", api);
|
|
|
|
// ---------- Static SPA ----------
|
|
app.get("*", staticHandler(config.staticDir));
|
|
return app;
|
|
}
|
|
|
|
async function readJson<T>(c: Context): Promise<T | null> {
|
|
try {
|
|
return (await c.req.json()) as T;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/** Name the app password after the browser it will live in. */
|
|
function appPasswordName(c: Context): string {
|
|
const ua = c.req.header("user-agent") ?? "";
|
|
const browser = /Firefox\//.test(ua) ? "Firefox" : /Edg\//.test(ua) ? "Edge" : /Chrome\//.test(ua) ? "Chrome" : /Safari\//.test(ua) ? "Safari" : "browser";
|
|
return `${config.appName} (${browser})`;
|
|
}
|
|
|
|
function sessionExtras(session: LiveSession, userLocale: string | null = null) {
|
|
return {
|
|
ihasmail: {
|
|
appName: config.appName,
|
|
imageProxy: config.imageProxy,
|
|
maxUploadBytes: config.maxUploadBytes,
|
|
sessionId: session.id,
|
|
loginName: session.username,
|
|
remember: session.remember,
|
|
/** Locale configured for the account in Stalwart's directory, if readable. */
|
|
userLocale,
|
|
},
|
|
};
|
|
}
|
|
|
|
function passthrough(res: Response): Response {
|
|
const headers = new Headers();
|
|
res.headers.forEach((v, k) => {
|
|
if (!HOP_BY_HOP.has(k.toLowerCase())) headers.set(k, v);
|
|
});
|
|
if (!headers.has("content-type")) headers.set("content-type", "application/json");
|
|
headers.set("Cache-Control", "no-store");
|
|
return new Response(res.body, { status: res.status, headers });
|
|
}
|
|
|
|
function sanitizeContentType(ct: string): string {
|
|
const lower = ct.split(";")[0]!.trim().toLowerCase();
|
|
// Never let the browser render HTML/SVG/XML/JS served from the blob endpoint.
|
|
if (
|
|
lower === "text/html" ||
|
|
lower === "application/xhtml+xml" ||
|
|
lower === "image/svg+xml" ||
|
|
lower.includes("javascript") ||
|
|
lower === "text/xml" ||
|
|
lower === "application/xml"
|
|
) {
|
|
return "application/octet-stream";
|
|
}
|
|
if (lower.startsWith("text/")) return `${lower}; charset=utf-8`;
|
|
return lower || "application/octet-stream";
|
|
}
|
|
|
|
function isInlineSafe(type: string): boolean {
|
|
const t = type.split(";")[0]!.trim();
|
|
return (
|
|
(t.startsWith("image/") && t !== "image/svg+xml") ||
|
|
t.startsWith("video/") ||
|
|
t.startsWith("audio/") ||
|
|
t === "application/pdf" ||
|
|
t === "text/plain" ||
|
|
t === "text/calendar" ||
|
|
t === "text/vcard"
|
|
);
|
|
}
|