Merge feat/persistent-sessions-accounts: 30-day sliding sessions, several accounts at once, mail rendered with its own styles
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
54203d596e
12 files changed
+484
-51
No files matched your search
@@ -0,0 +1,42 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
accountCookieName,
|
||||||
|
accountTokens,
|
||||||
|
parseCookies,
|
||||||
|
shouldRenew,
|
||||||
|
RENEW_AFTER_SECONDS,
|
||||||
|
} from "./accounts.js";
|
||||||
|
|
||||||
|
const ID_A = "22222222-2222-2222-2222-222222222222";
|
||||||
|
const ID_B = "33333333-3333-3333-3333-333333333333";
|
||||||
|
|
||||||
|
describe("account cookies", () => {
|
||||||
|
it("names the cookie after the mailbox", () => {
|
||||||
|
expect(accountCookieName(ID_A)).toBe(`lm_acct_${ID_A}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a value that contains '='", () => {
|
||||||
|
expect(parseCookies("a=b=c; d=e")).toEqual({ a: "b=c", d: "e" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("collects every signed-in account and nothing else", () => {
|
||||||
|
const header = `lm_user_session=x; lm_acct_${ID_A}=tokA; lm_acct_${ID_B}=tokB; lm_acct_notauuid=evil; other=1`;
|
||||||
|
expect(accountTokens(header)).toEqual([
|
||||||
|
{ id: ID_A, token: "tokA" },
|
||||||
|
{ id: ID_B, token: "tokB" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns an empty list when there is no cookie header", () => {
|
||||||
|
expect(accountTokens(undefined)).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("shouldRenew", () => {
|
||||||
|
it("leaves a fresh token alone", () => {
|
||||||
|
expect(shouldRenew(1000, 1000 + RENEW_AFTER_SECONDS - 1)).toBe(false);
|
||||||
|
});
|
||||||
|
it("replaces a token a day old", () => {
|
||||||
|
expect(shouldRenew(1000, 1000 + RENEW_AFTER_SECONDS)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
/**
|
||||||
|
* Several webmail accounts signed in at once, and a session that stays alive
|
||||||
|
* for as long as it is used.
|
||||||
|
*
|
||||||
|
* Each signed-in account keeps its own cookie, lm_acct_<mailbox id>, next to
|
||||||
|
* lm_user_session. lm_user_session stays the *active* account: every existing
|
||||||
|
* route, the Next middleware and the protocols service keep reading that one
|
||||||
|
* cookie and need no change. Switching accounts is copying one of the
|
||||||
|
* lm_acct_ tokens into lm_user_session.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** A webmail session lasts this long without use, and is renewed while in use. */
|
||||||
|
export const USER_SESSION_TTL_SECONDS = 30 * 24 * 3600;
|
||||||
|
/** The console is the sensitive surface: it keeps the short, fixed lifetime. */
|
||||||
|
export const ADMIN_SESSION_TTL_SECONDS = 8 * 3600;
|
||||||
|
/** A token is replaced once it is older than this, so a busy day costs one write. */
|
||||||
|
export const RENEW_AFTER_SECONDS = 24 * 3600;
|
||||||
|
/** How long a replaced token keeps working, for requests already in flight. */
|
||||||
|
export const REPLACED_GRACE_SECONDS = 120;
|
||||||
|
|
||||||
|
const ACCOUNT_COOKIE_PREFIX = "lm_acct_";
|
||||||
|
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||||
|
/** More than this and the Cookie header risks the proxies' 8 KB limit. */
|
||||||
|
export const MAX_ACCOUNTS = 6;
|
||||||
|
|
||||||
|
export function accountCookieName(mailboxId: string): string {
|
||||||
|
return `${ACCOUNT_COOKIE_PREFIX}${mailboxId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseCookies(header: string | undefined): Record<string, string> {
|
||||||
|
const out: Record<string, string> = {};
|
||||||
|
if (!header) return out;
|
||||||
|
for (const part of header.split(";")) {
|
||||||
|
const trimmed = part.trim();
|
||||||
|
const eq = trimmed.indexOf("=");
|
||||||
|
// sliced rather than split so a value containing "=" survives intact.
|
||||||
|
if (eq > 0) out[trimmed.slice(0, eq)] = trimmed.slice(eq + 1);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The account cookies a browser sent, as {mailbox id, token}. Names that are not a UUID are ignored. */
|
||||||
|
export function accountTokens(header: string | undefined): { id: string; token: string }[] {
|
||||||
|
const found: { id: string; token: string }[] = [];
|
||||||
|
for (const [name, token] of Object.entries(parseCookies(header))) {
|
||||||
|
if (!name.startsWith(ACCOUNT_COOKIE_PREFIX) || !token) continue;
|
||||||
|
const id = name.slice(ACCOUNT_COOKIE_PREFIX.length);
|
||||||
|
if (UUID.test(id)) found.push({ id: id.toLowerCase(), token });
|
||||||
|
}
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether a token has aged enough to be replaced with a fresh one. */
|
||||||
|
export function shouldRenew(iat: number, nowSeconds: number): boolean {
|
||||||
|
return nowSeconds - iat >= RENEW_AFTER_SECONDS;
|
||||||
|
}
|
||||||
@@ -662,9 +662,23 @@ export async function revokeOtherSessions(mailboxId: string, currentTokenHash: s
|
|||||||
return rows.length;
|
return rows.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ends a session after `graceSeconds`. Renewal uses a short grace so requests
|
||||||
|
* already in flight with the old token do not fail; sign-out uses none.
|
||||||
|
*/
|
||||||
|
export async function retireSession(tokenHash: string, graceSeconds: number): Promise<void> {
|
||||||
|
await query(
|
||||||
|
`UPDATE web_sessions SET revoked_at = NOW() + ($2 || ' seconds')::interval
|
||||||
|
WHERE refresh_token_hash = $1 AND revoked_at IS NULL`,
|
||||||
|
[tokenHash, String(graceSeconds)],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export async function isSessionRevoked(tokenHash: string): Promise<boolean> {
|
export async function isSessionRevoked(tokenHash: string): Promise<boolean> {
|
||||||
const row = await queryOne<{ revoked: boolean }>(
|
const row = await queryOne<{ revoked: boolean }>(
|
||||||
`SELECT (revoked_at IS NOT NULL) AS revoked FROM web_sessions WHERE refresh_token_hash = $1`,
|
// A revoked_at in the future is a replaced token still inside its grace
|
||||||
|
// period (see retireSession), so it only counts once that moment passes.
|
||||||
|
`SELECT (revoked_at IS NOT NULL AND revoked_at <= NOW()) AS revoked FROM web_sessions WHERE refresh_token_hash = $1`,
|
||||||
[tokenHash],
|
[tokenHash],
|
||||||
);
|
);
|
||||||
// An unknown hash is treated as live: tokens issued before this table was
|
// An unknown hash is treated as live: tokens issued before this table was
|
||||||
|
|||||||
+162
-9
@@ -14,12 +14,28 @@ import * as repo from "./repository.js";
|
|||||||
import { parsePageParams, paged } from "./pagination.js";
|
import { parsePageParams, paged } from "./pagination.js";
|
||||||
import { checkAvatar, MAX_AVATAR_BYTES } from "./imageValidation.js";
|
import { checkAvatar, MAX_AVATAR_BYTES } from "./imageValidation.js";
|
||||||
import { checkUser, checkServer, overallStatus } from "./diagnostics.js";
|
import { checkUser, checkServer, overallStatus } from "./diagnostics.js";
|
||||||
|
import {
|
||||||
|
USER_SESSION_TTL_SECONDS,
|
||||||
|
ADMIN_SESSION_TTL_SECONDS,
|
||||||
|
REPLACED_GRACE_SECONDS,
|
||||||
|
accountCookieName,
|
||||||
|
accountTokens,
|
||||||
|
parseCookies,
|
||||||
|
shouldRenew,
|
||||||
|
} from "./accounts.js";
|
||||||
|
|
||||||
const CHALLENGE_TTL_SECONDS = 300;
|
const CHALLENGE_TTL_SECONDS = 300;
|
||||||
// Long enough to scan a code and type one back, short enough that a token
|
// Long enough to scan a code and type one back, short enough that a token
|
||||||
// granting enrolment is not left lying around.
|
// granting enrolment is not left lying around.
|
||||||
const ENROLMENT_TTL_SECONDS = 900;
|
const ENROLMENT_TTL_SECONDS = 900;
|
||||||
const SESSION_TTL_SECONDS = 28800;
|
|
||||||
|
function sessionTtlFor(surface: Surface): number {
|
||||||
|
return surface === "admin" ? ADMIN_SESSION_TTL_SECONDS : USER_SESSION_TTL_SECONDS;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sha256(token: string): string {
|
||||||
|
return crypto.createHash("sha256").update(token).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
/** Cookie scoped to the whole origin: the API lives under /api, so a cookie
|
/** Cookie scoped to the whole origin: the API lives under /api, so a cookie
|
||||||
* pinned to /user or /admin would never be sent with the requests that need
|
* pinned to /user or /admin would never be sent with the requests that need
|
||||||
@@ -132,6 +148,10 @@ async function route(req: IncomingMessage, res: ServerResponse, url: string): Pr
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (session && url.startsWith("/api/v1/user/")) {
|
||||||
|
await renewIfAged(req, res, session, token);
|
||||||
|
}
|
||||||
|
|
||||||
if (url === "/api/v1/auth/user/login" && method === "POST") return login(req, res, "user");
|
if (url === "/api/v1/auth/user/login" && method === "POST") return login(req, res, "user");
|
||||||
if (url === "/api/v1/auth/admin/login" && method === "POST") return login(req, res, "admin");
|
if (url === "/api/v1/auth/admin/login" && method === "POST") return login(req, res, "admin");
|
||||||
if (url === "/api/v1/auth/mfa/verify" && method === "POST") return mfaVerify(req, res);
|
if (url === "/api/v1/auth/mfa/verify" && method === "POST") return mfaVerify(req, res);
|
||||||
@@ -139,7 +159,9 @@ async function route(req: IncomingMessage, res: ServerResponse, url: string): Pr
|
|||||||
// session yet - obtaining one is the whole point - so the admin guard above
|
// session yet - obtaining one is the whole point - so the admin guard above
|
||||||
// must not stand in front of it.
|
// must not stand in front of it.
|
||||||
if (url === "/api/v1/auth/admin/step-up" && method === "POST") return adminStepUp(req, res);
|
if (url === "/api/v1/auth/admin/step-up" && method === "POST") return adminStepUp(req, res);
|
||||||
if (url === "/api/v1/auth/logout" && method === "POST") return logout(res);
|
if (url === "/api/v1/auth/logout" && method === "POST") return logout(req, res);
|
||||||
|
if (url === "/api/v1/auth/accounts" && method === "GET") return listAccounts(req, res);
|
||||||
|
if (url === "/api/v1/auth/switch" && method === "POST") return switchAccount(req, res);
|
||||||
|
|
||||||
if (url === "/api/v1/user/me" && method === "GET") return me(res, session!);
|
if (url === "/api/v1/user/me" && method === "GET") return me(res, session!);
|
||||||
if (url === "/api/v1/user/locale" && method === "PUT") return updateLocale(req, res, session!);
|
if (url === "/api/v1/user/locale" && method === "PUT") return updateLocale(req, res, session!);
|
||||||
@@ -554,12 +576,17 @@ async function issueSession(
|
|||||||
mailbox.id,
|
mailbox.id,
|
||||||
granted,
|
granted,
|
||||||
crypto.createHash("sha256").update(token).digest("hex"),
|
crypto.createHash("sha256").update(token).digest("hex"),
|
||||||
new Date(Date.now() + SESSION_TTL_SECONDS * 1000),
|
new Date(Date.now() + sessionTtlFor(granted) * 1000),
|
||||||
clientIp(req),
|
clientIp(req),
|
||||||
req.headers["user-agent"] ?? null,
|
req.headers["user-agent"] ?? null,
|
||||||
);
|
);
|
||||||
|
|
||||||
cookies.push(sessionCookie(cookieNameFor(granted), token, SESSION_TTL_SECONDS, isSecureRequest()));
|
cookies.push(sessionCookie(cookieNameFor(granted), token, sessionTtlFor(granted), isSecureRequest()));
|
||||||
|
// The webmail session is also filed under the account, so this sign-in
|
||||||
|
// joins the others instead of replacing them.
|
||||||
|
if (granted === "user") {
|
||||||
|
cookies.push(sessionCookie(accountCookieName(mailbox.id), token, sessionTtlFor(granted), isSecureRequest()));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
res.setHeader("Set-Cookie", cookies);
|
res.setHeader("Set-Cookie", cookies);
|
||||||
@@ -609,12 +636,138 @@ async function mfaVerify(req: IncomingMessage, res: ServerResponse): Promise<voi
|
|||||||
await issueSession(req, res, mailbox, payload.surface, false, true);
|
await issueSession(req, res, mailbox, payload.surface, false, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
function logout(res: ServerResponse): void {
|
/**
|
||||||
|
* The accounts signed in on this browser whose sessions are still good, with
|
||||||
|
* the active one first-class so the switcher can mark it.
|
||||||
|
*/
|
||||||
|
async function liveAccounts(req: IncomingMessage): Promise<{ id: string; email: string; token: string }[]> {
|
||||||
|
const live: { id: string; email: string; token: string }[] = [];
|
||||||
|
for (const { id, token } of accountTokens(req.headers.cookie)) {
|
||||||
|
const payload = verifyJwt(token);
|
||||||
|
if (!payload || !isSurfaceAuthorized(payload, "user") || payload.sub.toLowerCase() !== id) continue;
|
||||||
|
if (await repo.isSessionRevoked(sha256(token))) continue;
|
||||||
|
live.push({ id, email: payload.email, token });
|
||||||
|
}
|
||||||
|
return live.sort((a, b) => a.email.localeCompare(b.email));
|
||||||
|
}
|
||||||
|
|
||||||
|
function activeUserToken(req: IncomingMessage): string {
|
||||||
|
return parseCookies(req.headers.cookie)["lm_user_session"] ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listAccounts(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
||||||
|
const active = verifyJwt(activeUserToken(req));
|
||||||
|
const accounts = (await liveAccounts(req)).map(({ id, email }) => ({
|
||||||
|
id,
|
||||||
|
email,
|
||||||
|
active: active?.sub.toLowerCase() === id,
|
||||||
|
}));
|
||||||
|
sendJson(res, 200, { accounts });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function switchAccount(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
||||||
|
const body = await parseJsonBody(req);
|
||||||
|
const id = typeof body?.id === "string" ? body.id.toLowerCase() : "";
|
||||||
|
const target = (await liveAccounts(req)).find((account) => account.id === id);
|
||||||
|
if (!target) {
|
||||||
|
return sendJson(res, 404, { error: "NOT_FOUND", message: "That account is not signed in on this browser" });
|
||||||
|
}
|
||||||
res.setHeader("Set-Cookie", [
|
res.setHeader("Set-Cookie", [
|
||||||
sessionCookie("lm_user_session", "", 0, isSecureRequest()),
|
sessionCookie("lm_user_session", target.token, USER_SESSION_TTL_SECONDS, isSecureRequest()),
|
||||||
sessionCookie("lm_admin_session", "", 0, isSecureRequest()),
|
]);
|
||||||
] as unknown as string);
|
sendJson(res, 200, { ok: true, email: target.email });
|
||||||
sendJson(res, 200, { ok: true });
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Signs out the active account. The others stay signed in, and one of them
|
||||||
|
* becomes active, so leaving an account does not mean typing every password
|
||||||
|
* again. { "all": true } signs out everything on this browser.
|
||||||
|
*/
|
||||||
|
async function logout(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
||||||
|
const body = await parseJsonBody(req);
|
||||||
|
const everything = body?.all === true;
|
||||||
|
const secure = isSecureRequest();
|
||||||
|
const clear = (name: string) => sessionCookie(name, "", 0, secure);
|
||||||
|
const cookies: string[] = [clear("lm_admin_session")];
|
||||||
|
|
||||||
|
const activeToken = activeUserToken(req);
|
||||||
|
const active = activeToken ? verifyJwt(activeToken) : null;
|
||||||
|
const signedIn = accountTokens(req.headers.cookie);
|
||||||
|
|
||||||
|
const toEnd = everything ? signedIn.map((a) => a.token) : [];
|
||||||
|
if (activeToken) toEnd.push(activeToken);
|
||||||
|
for (const token of toEnd) await repo.retireSession(sha256(token), 0);
|
||||||
|
|
||||||
|
if (everything) {
|
||||||
|
for (const { id } of signedIn) cookies.push(clear(accountCookieName(id)));
|
||||||
|
} else if (active) {
|
||||||
|
cookies.push(clear(accountCookieName(active.sub)));
|
||||||
|
}
|
||||||
|
|
||||||
|
const others = everything
|
||||||
|
? []
|
||||||
|
: (await liveAccounts(req)).filter((account) => account.id !== active?.sub.toLowerCase());
|
||||||
|
// One Set-Cookie per name: the next account takes over lm_user_session, or
|
||||||
|
// the cookie is cleared when nobody is left.
|
||||||
|
cookies.push(
|
||||||
|
others.length > 0
|
||||||
|
? sessionCookie("lm_user_session", others[0].token, USER_SESSION_TTL_SECONDS, secure)
|
||||||
|
: clear("lm_user_session"),
|
||||||
|
);
|
||||||
|
const remaining = others.map(({ id, email }) => ({ id, email }));
|
||||||
|
|
||||||
|
res.setHeader("Set-Cookie", cookies);
|
||||||
|
sendJson(res, 200, { ok: true, remaining });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keeps a webmail session alive while it is being used.
|
||||||
|
*
|
||||||
|
* A token carries its own expiry and cannot be extended, so once it has aged
|
||||||
|
* a day it is swapped for a fresh one. The old one is retired after a short
|
||||||
|
* grace rather than at once, so a request already on its way with it does not
|
||||||
|
* come back 401. The console is never renewed: it stays at its fixed lifetime.
|
||||||
|
*/
|
||||||
|
async function renewIfAged(
|
||||||
|
req: IncomingMessage,
|
||||||
|
res: ServerResponse,
|
||||||
|
session: SessionTokenPayload,
|
||||||
|
token: string,
|
||||||
|
): Promise<void> {
|
||||||
|
if (session.purpose !== "session" || session.surface !== "user") return;
|
||||||
|
// Only the cookie is renewed; a bearer token has nowhere to receive a new one.
|
||||||
|
if (activeUserToken(req) !== token) return;
|
||||||
|
if (!shouldRenew(session.iat, Math.floor(Date.now() / 1000))) return;
|
||||||
|
|
||||||
|
const fresh = createJwt(
|
||||||
|
{
|
||||||
|
sub: session.sub,
|
||||||
|
email: session.email,
|
||||||
|
role: session.role,
|
||||||
|
domainId: session.domainId,
|
||||||
|
surface: "user",
|
||||||
|
aud: "lambdamail:user",
|
||||||
|
mfaSatisfied: session.mfaSatisfied,
|
||||||
|
...(session.mfaSatisfiedAt !== undefined ? { mfaSatisfiedAt: session.mfaSatisfiedAt } : {}),
|
||||||
|
purpose: "session",
|
||||||
|
},
|
||||||
|
USER_SESSION_TTL_SECONDS,
|
||||||
|
);
|
||||||
|
await repo.recordSession(
|
||||||
|
session.sub,
|
||||||
|
"user",
|
||||||
|
sha256(fresh),
|
||||||
|
new Date(Date.now() + USER_SESSION_TTL_SECONDS * 1000),
|
||||||
|
clientIp(req),
|
||||||
|
req.headers["user-agent"] ?? null,
|
||||||
|
);
|
||||||
|
await repo.retireSession(sha256(token), REPLACED_GRACE_SECONDS);
|
||||||
|
|
||||||
|
const secure = isSecureRequest();
|
||||||
|
res.setHeader("Set-Cookie", [
|
||||||
|
sessionCookie("lm_user_session", fresh, USER_SESSION_TTL_SECONDS, secure),
|
||||||
|
sessionCookie(accountCookieName(session.sub), fresh, USER_SESSION_TTL_SECONDS, secure),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------- user area
|
// -------------------------------------------------------------- user area
|
||||||
|
|||||||
@@ -533,5 +533,13 @@
|
|||||||
"zoom": "Zoom",
|
"zoom": "Zoom",
|
||||||
"dragToPosition": "Drag to position",
|
"dragToPosition": "Drag to position",
|
||||||
"notAnImage": "That file could not be read as an image."
|
"notAnImage": "That file could not be read as an image."
|
||||||
|
},
|
||||||
|
"accounts": {
|
||||||
|
"switchAccount": "Switch account",
|
||||||
|
"addAccount": "Add another account",
|
||||||
|
"signOutOne": "Sign out of {email}",
|
||||||
|
"signOutAll": "Sign out of all accounts",
|
||||||
|
"active": "Active",
|
||||||
|
"settings": "Account settings"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -533,5 +533,13 @@
|
|||||||
"zoom": "Zoom",
|
"zoom": "Zoom",
|
||||||
"dragToPosition": "Arrastre para posicionar",
|
"dragToPosition": "Arrastre para posicionar",
|
||||||
"notAnImage": "No se pudo leer ese archivo como imagen."
|
"notAnImage": "No se pudo leer ese archivo como imagen."
|
||||||
|
},
|
||||||
|
"accounts": {
|
||||||
|
"switchAccount": "Cambiar de cuenta",
|
||||||
|
"addAccount": "Añadir otra cuenta",
|
||||||
|
"signOutOne": "Cerrar sesión de {email}",
|
||||||
|
"signOutAll": "Cerrar sesión en todas las cuentas",
|
||||||
|
"active": "Activa",
|
||||||
|
"settings": "Ajustes de la cuenta"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -533,5 +533,13 @@
|
|||||||
"zoom": "Zoom",
|
"zoom": "Zoom",
|
||||||
"dragToPosition": "Arraste para posicionar",
|
"dragToPosition": "Arraste para posicionar",
|
||||||
"notAnImage": "Não foi possível ler esse arquivo como imagem."
|
"notAnImage": "Não foi possível ler esse arquivo como imagem."
|
||||||
|
},
|
||||||
|
"accounts": {
|
||||||
|
"switchAccount": "Trocar de conta",
|
||||||
|
"addAccount": "Adicionar outra conta",
|
||||||
|
"signOutOne": "Sair de {email}",
|
||||||
|
"signOutAll": "Sair de todas as contas",
|
||||||
|
"active": "Ativa",
|
||||||
|
"settings": "Configurações da conta"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -27,7 +27,7 @@ import { LanguageSwitcher } from "../../../i18n/LanguageSwitcher";
|
|||||||
import { useAccount, isAdminRole } from "../../../lib/useAccount";
|
import { useAccount, isAdminRole } from "../../../lib/useAccount";
|
||||||
import { useFolders } from "../../../lib/useFolders";
|
import { useFolders } from "../../../lib/useFolders";
|
||||||
import { folderBadge, customFolders } from "../../../lib/mailCounts";
|
import { folderBadge, customFolders } from "../../../lib/mailCounts";
|
||||||
import { Avatar } from "../../../components/Avatar";
|
import { AccountSwitcher } from "../../../components/AccountSwitcher";
|
||||||
|
|
||||||
export default function UserWebmailLayout({ children }: { children: React.ReactNode }) {
|
export default function UserWebmailLayout({ children }: { children: React.ReactNode }) {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
@@ -274,36 +274,7 @@ export default function UserWebmailLayout({ children }: { children: React.ReactN
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Link
|
<AccountSwitcher onNavigate={() => setMobileOpen(false)} />
|
||||||
href="/user/settings"
|
|
||||||
onClick={() => setMobileOpen(false)}
|
|
||||||
className="flex items-center gap-2.5 rounded-xl bg-dark-panel p-2.5 shadow-edge transition-colors hover:bg-dark-card"
|
|
||||||
>
|
|
||||||
<Avatar address={account?.email} size={30} className="bg-indigo-900 text-indigo-300 shadow-[inset_0_0_0_1px_#5d5294]" />
|
|
||||||
<span className="min-w-0 flex-1">
|
|
||||||
<span
|
|
||||||
className="block truncate text-[12.5px] leading-tight"
|
|
||||||
title={account?.email ?? undefined}
|
|
||||||
>
|
|
||||||
{account?.email ?? t("common.loading")}
|
|
||||||
</span>
|
|
||||||
{/* Reports what is actually configured. The old fixed "2FA
|
|
||||||
active" reassured accounts that had no second factor at all. */}
|
|
||||||
<span className="mt-0.5 flex items-center gap-1 text-[10.5px] text-slate-400">
|
|
||||||
{account?.mfa_enrolled ? (
|
|
||||||
<>
|
|
||||||
<ShieldCheck className="h-3 w-3 flex-none text-indigo-500" />
|
|
||||||
{t("settings.mfaEnabled")}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<ShieldAlert className="h-3 w-3 flex-none text-amber-400" />
|
|
||||||
{t("settings.mfaDisabled")}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<LanguageSwitcher />
|
<LanguageSwitcher />
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Check, ChevronsUpDown, LogOut, Plus, Settings } from "lucide-react";
|
||||||
|
import { useTranslations } from "../i18n/provider";
|
||||||
|
import { Avatar } from "./Avatar";
|
||||||
|
|
||||||
|
interface SignedInAccount {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
active: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The webmail accounts signed in on this browser, and the way to move between
|
||||||
|
* them without signing out. The auth service keeps one session per account;
|
||||||
|
* switching only changes which one is active.
|
||||||
|
*/
|
||||||
|
export function AccountSwitcher({ onNavigate }: { onNavigate?: () => void }) {
|
||||||
|
const t = useTranslations();
|
||||||
|
const [accounts, setAccounts] = useState<SignedInAccount[]>([]);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const root = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const load = useCallback(() => {
|
||||||
|
fetch("/api/v1/auth/accounts")
|
||||||
|
.then((res) => (res.ok ? res.json() : { accounts: [] }))
|
||||||
|
.then((data) => setAccounts(data.accounts ?? []))
|
||||||
|
.catch(() => undefined);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(load, [load]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const close = (event: MouseEvent | KeyboardEvent) => {
|
||||||
|
if (event instanceof KeyboardEvent) {
|
||||||
|
if (event.key === "Escape") setOpen(false);
|
||||||
|
} else if (root.current && !root.current.contains(event.target as Node)) {
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener("mousedown", close);
|
||||||
|
document.addEventListener("keydown", close);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mousedown", close);
|
||||||
|
document.removeEventListener("keydown", close);
|
||||||
|
};
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const active = accounts.find((a) => a.active);
|
||||||
|
|
||||||
|
// A reload rather than a client-side update: every screen holds the previous
|
||||||
|
// account's mail in state, and none of it should survive the switch.
|
||||||
|
const switchTo = async (id: string) => {
|
||||||
|
const res = await fetch("/api/v1/auth/switch", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ id }),
|
||||||
|
});
|
||||||
|
if (res.ok) window.location.href = "/user/mail/inbox";
|
||||||
|
};
|
||||||
|
|
||||||
|
const signOut = async (all: boolean) => {
|
||||||
|
const res = await fetch("/api/v1/auth/logout", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ all }),
|
||||||
|
}).catch(() => null);
|
||||||
|
const data = res?.ok ? await res.json().catch(() => ({})) : {};
|
||||||
|
window.location.href = data.remaining?.length ? "/user/mail/inbox" : "/user/login";
|
||||||
|
};
|
||||||
|
|
||||||
|
const item =
|
||||||
|
"flex w-full items-center gap-2.5 rounded-lg px-2.5 py-2 text-left text-[13px] transition-colors hover:bg-white/[0.07]";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={root} className="relative">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-expanded={open}
|
||||||
|
title={t("accounts.switchAccount")}
|
||||||
|
className="flex w-full items-center gap-2.5 rounded-xl bg-dark-panel p-2.5 text-left shadow-edge transition-colors hover:bg-dark-card"
|
||||||
|
>
|
||||||
|
<Avatar address={active?.email} size={30} className="bg-indigo-900 text-indigo-300 shadow-[inset_0_0_0_1px_#5d5294]" />
|
||||||
|
<span className="min-w-0 flex-1 truncate text-[12.5px]">{active?.email ?? t("common.loading")}</span>
|
||||||
|
<ChevronsUpDown className="h-3.5 w-3.5 flex-none text-slate-400" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div
|
||||||
|
role="menu"
|
||||||
|
className="absolute inset-x-0 bottom-full z-30 mb-2 rounded-xl bg-dark-panel p-1.5 shadow-edge ring-1 ring-white/10"
|
||||||
|
>
|
||||||
|
{accounts.map((account) => (
|
||||||
|
<button
|
||||||
|
key={account.id}
|
||||||
|
role="menuitem"
|
||||||
|
type="button"
|
||||||
|
onClick={() => (account.active ? setOpen(false) : void switchTo(account.id))}
|
||||||
|
className={item}
|
||||||
|
>
|
||||||
|
<Avatar address={account.email} size={24} className="bg-indigo-900 text-indigo-300" />
|
||||||
|
<span className="min-w-0 flex-1 truncate">{account.email}</span>
|
||||||
|
{account.active && <Check className="h-4 w-4 flex-none text-indigo-400" aria-label={t("accounts.active")} />}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<Link href="/user/login?add=1" role="menuitem" className={item}>
|
||||||
|
<Plus className="h-4 w-4 flex-none text-slate-400" />
|
||||||
|
{t("accounts.addAccount")}
|
||||||
|
</Link>
|
||||||
|
<div className="my-1 h-px bg-white/10" />
|
||||||
|
<Link href="/user/settings" role="menuitem" onClick={onNavigate} className={item}>
|
||||||
|
<Settings className="h-4 w-4 flex-none text-slate-400" />
|
||||||
|
{t("accounts.settings")}
|
||||||
|
</Link>
|
||||||
|
<button type="button" role="menuitem" onClick={() => void signOut(false)} className={item}>
|
||||||
|
<LogOut className="h-4 w-4 flex-none text-slate-400" />
|
||||||
|
<span className="min-w-0 truncate">{t("accounts.signOutOne", { email: active?.email ?? "" })}</span>
|
||||||
|
</button>
|
||||||
|
{accounts.length > 1 && (
|
||||||
|
<button type="button" role="menuitem" onClick={() => void signOut(true)} className={`${item} text-rose-400`}>
|
||||||
|
<LogOut className="h-4 w-4 flex-none" />
|
||||||
|
{t("accounts.signOutAll")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -55,9 +55,9 @@ export function buildReaderDocument(bodyHtml: string): string {
|
|||||||
<style>
|
<style>
|
||||||
html { background:#ffffff; }
|
html { background:#ffffff; }
|
||||||
body {
|
body {
|
||||||
margin:0; padding:20px; background:#ffffff; color:#1e293b;
|
margin:0; padding:24px 28px; background:#ffffff; color:#1d1d1f;
|
||||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||||
font-size:14px; line-height:1.6;
|
font-size:15px; line-height:1.55; -webkit-font-smoothing:antialiased;
|
||||||
/* Mail is written against desktop widths. Without these a newsletter
|
/* Mail is written against desktop widths. Without these a newsletter
|
||||||
pushes a horizontal scrollbar across its own content. */
|
pushes a horizontal scrollbar across its own content. */
|
||||||
overflow-wrap:anywhere; word-break:break-word;
|
overflow-wrap:anywhere; word-break:break-word;
|
||||||
@@ -65,8 +65,9 @@ export function buildReaderDocument(bodyHtml: string): string {
|
|||||||
img, video, table, pre { max-width:100% !important; }
|
img, video, table, pre { max-width:100% !important; }
|
||||||
img { height:auto; }
|
img { height:auto; }
|
||||||
table { border-collapse:collapse; }
|
table { border-collapse:collapse; }
|
||||||
/* A layout table with a fixed pixel width is the usual overflow culprit. */
|
/* A layout table keeps the width its author gave it - forcing "auto" is what
|
||||||
table[width] { width:auto !important; max-width:100% !important; }
|
collapsed designed messages into a narrow column. max-width above is what
|
||||||
|
stops one from overflowing a small screen. */
|
||||||
blockquote {
|
blockquote {
|
||||||
margin:0 0 0 4px; padding-left:12px;
|
margin:0 0 0 4px; padding-left:12px;
|
||||||
border-left:3px solid #cbd5e1; color:#475569;
|
border-left:3px solid #cbd5e1; color:#475569;
|
||||||
|
|||||||
@@ -21,6 +21,31 @@ import DOMPurify from "dompurify";
|
|||||||
*/
|
*/
|
||||||
const ALLOWED_DATA_IMAGE = /^data:image\/(?:png|gif|jpe?g|webp|bmp|x-icon|vnd\.microsoft\.icon);/i;
|
const ALLOWED_DATA_IMAGE = /^data:image\/(?:png|gif|jpe?g|webp|bmp|x-icon|vnd\.microsoft\.icon);/i;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a message's own stylesheet may keep.
|
||||||
|
*
|
||||||
|
* Stylesheets are where nearly every designed message keeps its look: columns,
|
||||||
|
* fonts, colours, the rules that reflow it on a phone. Dropping them is what
|
||||||
|
* made mail look like unstyled text. They are safe to keep because the reader
|
||||||
|
* frame is sandboxed with an opaque origin - a rule can only restyle the
|
||||||
|
* message it came in, never the app around it.
|
||||||
|
*
|
||||||
|
* What is still removed is what fetches or runs something: @import pulls in a
|
||||||
|
* stylesheet the reader never saw, and the legacy script-in-CSS forms are
|
||||||
|
* refused outright. Remote url() references are held back like any other
|
||||||
|
* remote image, by blockRemoteImages.
|
||||||
|
*/
|
||||||
|
export function scrubCss(css: string): string {
|
||||||
|
return css
|
||||||
|
.replace(/@import\b[^;]*;?/gi, "")
|
||||||
|
.replace(/@charset\b[^;]*;?/gi, "")
|
||||||
|
.replace(/expression\s*\(/gi, "blocked(")
|
||||||
|
.replace(/behaviou?r\s*:/gi, "blocked:")
|
||||||
|
.replace(/-moz-binding\s*:/gi, "blocked:")
|
||||||
|
.replace(/(?:java|vb)script\s*:/gi, "blocked:")
|
||||||
|
.replace(/<\/?style/gi, "");
|
||||||
|
}
|
||||||
|
|
||||||
let hookInstalled = false;
|
let hookInstalled = false;
|
||||||
|
|
||||||
function installDataUriHook(): void {
|
function installDataUriHook(): void {
|
||||||
@@ -30,6 +55,11 @@ function installDataUriHook(): void {
|
|||||||
data.keepAttr = false;
|
data.keepAttr = false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
DOMPurify.addHook("uponSanitizeElement", (node, data) => {
|
||||||
|
if (data.tagName === "style" && node.textContent) {
|
||||||
|
node.textContent = scrubCss(node.textContent);
|
||||||
|
}
|
||||||
|
});
|
||||||
hookInstalled = true;
|
hookInstalled = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,19 +79,23 @@ export function sanitizeEmailHtml(rawHtml: string): string {
|
|||||||
ALLOWED_TAGS: [
|
ALLOWED_TAGS: [
|
||||||
"a", "b", "blockquote", "br", "caption", "code", "div", "em", "h1", "h2", "h3",
|
"a", "b", "blockquote", "br", "caption", "code", "div", "em", "h1", "h2", "h3",
|
||||||
"h4", "h5", "h6", "hr", "i", "img", "li", "ol", "p", "pre", "span", "strong",
|
"h4", "h5", "h6", "hr", "i", "img", "li", "ol", "p", "pre", "span", "strong",
|
||||||
"table", "tbody", "td", "tfoot", "th", "thead", "tr", "u", "font", "sub", "sup",
|
"table", "tbody", "td", "tfoot", "th", "thead", "tr", "u", "font", "sub", "sup", "style", "center",
|
||||||
],
|
],
|
||||||
ALLOWED_ATTR: [
|
ALLOWED_ATTR: [
|
||||||
"align", "alt", "bgcolor", "border", "cellpadding", "cellspacing", "cite",
|
"align", "alt", "bgcolor", "border", "cellpadding", "cellspacing", "cite",
|
||||||
"background", "class", "color", "colspan", "dir", "height", "href", "lang", "rowspan",
|
"background", "class", "color", "colspan", "dir", "height", "href", "lang", "rowspan",
|
||||||
"src", "style", "title", "width", "target", "rel", "data-blocked-src",
|
"src", "style", "title", "width", "target", "rel", "data-blocked-src",
|
||||||
],
|
],
|
||||||
// style, link, meta and base are gone: a stylesheet in a message can cover
|
// link, meta and base are gone: <link> fetches a stylesheet from outside,
|
||||||
// the rest of the frame, and <base> rewrites every relative URL in it.
|
// and <base> rewrites every relative URL in the message. <style> stays,
|
||||||
|
// scrubbed (see scrubCss), because a message's look lives in it.
|
||||||
// id goes too, so a message cannot collide with the host document's ids.
|
// id goes too, so a message cannot collide with the host document's ids.
|
||||||
FORBID_TAGS: ["script", "iframe", "object", "embed", "form", "input", "button", "style", "link", "meta", "base"],
|
FORBID_TAGS: ["script", "iframe", "object", "embed", "form", "input", "button", "link", "meta", "base"],
|
||||||
FORBID_ATTR: ["srcset", "formaction", "ping", "id"],
|
FORBID_ATTR: ["srcset", "formaction", "ping", "id"],
|
||||||
ALLOW_DATA_ATTR: false,
|
ALLOW_DATA_ATTR: false,
|
||||||
|
// A message's <style> sits in its <head>. Without this DOMPurify discards
|
||||||
|
// everything before the first body element, stylesheet included.
|
||||||
|
FORCE_BODY: true,
|
||||||
// data: is how an anchor smuggles a document onto this origin, so only the
|
// data: is how an anchor smuggles a document onto this origin, so only the
|
||||||
// raster image types are let through - which is how this app delivers a
|
// raster image types are let through - which is how this app delivers a
|
||||||
// message's own inline parts, and rejecting them stripped the src from
|
// message's own inline parts, and rejecting them stripped the src from
|
||||||
|
|||||||
@@ -31,9 +31,13 @@ describe("email HTML sanitizer", () => {
|
|||||||
expect(sanitizeEmailHtml(`<a href="https://example.test/x">x</a>`)).toContain("https://example.test/x");
|
expect(sanitizeEmailHtml(`<a href="https://example.test/x">x</a>`)).toContain("https://example.test/x");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("removes forms, styles and base tags", () => {
|
it("removes forms and base tags, and keeps a scrubbed stylesheet", () => {
|
||||||
expect(sanitizeEmailHtml(`<form action="http://evil.test"><input name="p"></form>`)).not.toContain("<form");
|
expect(sanitizeEmailHtml(`<form action="http://evil.test"><input name="p"></form>`)).not.toContain("<form");
|
||||||
expect(sanitizeEmailHtml(`<style>body{display:none}</style><p>x</p>`)).not.toContain("<style");
|
const out = sanitizeEmailHtml(`<style>@import url(https://evil.test/x.css);.a{color:red}p{behavior:url(x)}</style><p class="a">x</p>`);
|
||||||
|
expect(out).toContain("<style");
|
||||||
|
expect(out).toContain("color:red");
|
||||||
|
expect(out).not.toContain("@import");
|
||||||
|
expect(out).not.toMatch(/behaviou?r\s*:/);
|
||||||
expect(sanitizeEmailHtml(`<base href="http://evil.test/"><p>x</p>`)).not.toContain("<base");
|
expect(sanitizeEmailHtml(`<base href="http://evil.test/"><p>x</p>`)).not.toContain("<base");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user