diff --git a/migrations/0012_mailbox_avatars.down.sql b/migrations/0012_mailbox_avatars.down.sql new file mode 100644 index 0000000..11c93e1 --- /dev/null +++ b/migrations/0012_mailbox_avatars.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS mailbox_avatars; diff --git a/migrations/0012_mailbox_avatars.up.sql b/migrations/0012_mailbox_avatars.up.sql new file mode 100644 index 0000000..3d9c709 --- /dev/null +++ b/migrations/0012_mailbox_avatars.up.sql @@ -0,0 +1,17 @@ +-- The profile photo a mailbox shows inside LambdaMail. +-- +-- Stored in the database rather than the blob spool: an avatar is small, +-- there is at most one per mailbox, and it is read on nearly every screen - +-- so it belongs where the mailbox row already is, and is deleted with it. +-- +-- Not to be confused with BIMI, which is one logo for a whole domain and is +-- the only thing an external mail client ever renders. +CREATE TABLE IF NOT EXISTS mailbox_avatars ( + mailbox_id UUID PRIMARY KEY REFERENCES mailboxes(id) ON DELETE CASCADE, + content_type VARCHAR(32) NOT NULL CHECK (content_type IN ('image/png','image/jpeg','image/webp')), + bytes BYTEA NOT NULL, + -- Lets a conditional request answer 304 instead of resending the image on + -- every screen that shows the person. + etag CHAR(64) NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); diff --git a/services/auth/src/imageValidation.test.ts b/services/auth/src/imageValidation.test.ts new file mode 100644 index 0000000..19fdd87 --- /dev/null +++ b/services/auth/src/imageValidation.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect } from "vitest"; +import { sniffImage, checkAvatar, MAX_AVATAR_BYTES } from "./imageValidation.js"; + +const png = () => Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 0]); +const jpeg = () => Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0, 0]); +const webp = () => Buffer.concat([Buffer.from("RIFF"), Buffer.from([0, 0, 0, 0]), Buffer.from("WEBPVP8 ")]); + +describe("sniffImage", () => { + it("identifies the formats the editor produces", () => { + expect(sniffImage(png())?.contentType).toBe("image/png"); + expect(sniffImage(jpeg())?.contentType).toBe("image/jpeg"); + expect(sniffImage(webp())?.contentType).toBe("image/webp"); + }); + + // The type comes from the bytes. A client that says "image/png" over an HTML + // document must not get an HTML document stored and served from our origin. + it("refuses anything that is not one of them, whatever it claims to be", () => { + expect(sniffImage(Buffer.from(""))).toBeNull(); + expect(sniffImage(Buffer.from("GIF89a"))).toBeNull(); + expect(sniffImage(Buffer.from("%PDF-1.7"))).toBeNull(); + }); + + // SVG is an executable document, and it would be served back from our own + // origin - so it is refused even though it is an image. + it("refuses SVG", () => { + expect(sniffImage(Buffer.from(''))).toBeNull(); + expect(sniffImage(Buffer.from(''))).toBeNull(); + }); + + it("does not read past the end of a short buffer", () => { + expect(() => sniffImage(Buffer.from([0x89]))).not.toThrow(); + expect(sniffImage(Buffer.alloc(0))).toBeNull(); + // The first four bytes of a WEBP without the rest is not a WEBP. + expect(sniffImage(Buffer.from("RIFF"))).toBeNull(); + }); +}); + +describe("checkAvatar", () => { + it("accepts a real image", () => { + const res = checkAvatar(png()); + expect(res.ok).toBe(true); + expect(res.contentType).toBe("image/png"); + }); + + it("refuses an empty upload", () => { + expect(checkAvatar(Buffer.alloc(0))).toMatchObject({ ok: false, reason: "EMPTY" }); + }); + + // On size before contents: an enormous upload should not be sniffed first. + it("refuses one that is too big", () => { + const huge = Buffer.concat([png(), Buffer.alloc(MAX_AVATAR_BYTES)]); + expect(checkAvatar(huge)).toMatchObject({ ok: false, reason: "TOO_LARGE" }); + }); + + it("refuses a file that is not an image", () => { + expect(checkAvatar(Buffer.from("just text"))).toMatchObject({ ok: false, reason: "UNSUPPORTED_TYPE" }); + }); +}); diff --git a/services/auth/src/imageValidation.ts b/services/auth/src/imageValidation.ts new file mode 100644 index 0000000..c965fca --- /dev/null +++ b/services/auth/src/imageValidation.ts @@ -0,0 +1,62 @@ +/** + * What an uploaded avatar is allowed to be. + * + * The type is decided from the bytes, never from the Content-Type the client + * sent or the name of the file: both are chosen by whoever is uploading, and a + * stored image is later served back to browsers. + */ + +export const MAX_AVATAR_BYTES = 512 * 1024; + +export type AvatarType = "image/png" | "image/jpeg" | "image/webp"; + +export interface Sniffed { + contentType: AvatarType; +} + +const startsWith = (buf: Buffer, sig: number[], offset = 0): boolean => + buf.length >= offset + sig.length && sig.every((b, i) => buf[offset + i] === b); + +/** + * Identifies an image from its leading bytes. + * + * SVG is deliberately absent. It is a document that can carry script and + * external references, and serving one back from our own origin would hand an + * uploader a foothold there; the editor produces a raster image anyway. + */ +export function sniffImage(buf: Buffer): Sniffed | null { + // \x89PNG\r\n\x1a\n + if (startsWith(buf, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) { + return { contentType: "image/png" }; + } + // JPEG: FF D8 FF + if (startsWith(buf, [0xff, 0xd8, 0xff])) { + return { contentType: "image/jpeg" }; + } + // RIFF....WEBP + if (startsWith(buf, [0x52, 0x49, 0x46, 0x46]) && startsWith(buf, [0x57, 0x45, 0x42, 0x50], 8)) { + return { contentType: "image/webp" }; + } + return null; +} + +export type AvatarRejection = "TOO_LARGE" | "EMPTY" | "UNSUPPORTED_TYPE"; + +export interface AvatarCheck { + ok: boolean; + contentType?: AvatarType; + reason?: AvatarRejection; +} + +/** Decides whether these bytes may be stored and later served back. */ +export function checkAvatar(buf: Buffer): AvatarCheck { + if (buf.length === 0) return { ok: false, reason: "EMPTY" }; + // Checked before sniffing, so an enormous upload is refused on its size + // rather than on its contents. + if (buf.length > MAX_AVATAR_BYTES) return { ok: false, reason: "TOO_LARGE" }; + + const sniffed = sniffImage(buf); + if (!sniffed) return { ok: false, reason: "UNSUPPORTED_TYPE" }; + + return { ok: true, contentType: sniffed.contentType }; +} diff --git a/services/auth/src/repository.ts b/services/auth/src/repository.ts index 499f631..7e7cf80 100644 --- a/services/auth/src/repository.ts +++ b/services/auth/src/repository.ts @@ -1,3 +1,4 @@ +import crypto from "node:crypto"; import { query, queryOne } from "./db.js"; import { verifyPassword, hashPassword, encryptSecret, decryptSecret } from "./crypto.js"; import { verifyTotpCode } from "./totp.js"; @@ -716,6 +717,65 @@ export async function adminRemoveTotp(scope: AdminScope, mailboxId: string): Pro return true; } +// ------------------------------------------------------------- avatars + +export interface StoredAvatar { + contentType: string; + bytes: Buffer; + etag: string; +} + +/** Replaces a mailbox's profile photo. */ +export async function saveAvatar( + mailboxId: string, + contentType: string, + bytes: Buffer, +): Promise { + // The digest is of the image itself, so an unchanged photo keeps its tag and + // the browsers that already hold it are not made to fetch it again. + const etag = crypto.createHash("sha256").update(bytes).digest("hex"); + await query( + `INSERT INTO mailbox_avatars (mailbox_id, content_type, bytes, etag, updated_at) + VALUES ($1, $2, $3, $4, NOW()) + ON CONFLICT (mailbox_id) DO UPDATE + SET content_type = EXCLUDED.content_type, + bytes = EXCLUDED.bytes, + etag = EXCLUDED.etag, + updated_at = NOW()`, + [mailboxId, contentType, bytes, etag], + ); + return etag; +} + +export async function loadAvatar(mailboxId: string): Promise { + const row = await queryOne<{ content_type: string; bytes: Buffer; etag: string }>( + `SELECT content_type, bytes, etag FROM mailbox_avatars WHERE mailbox_id = $1`, + [mailboxId], + ); + if (!row) return null; + return { contentType: row.content_type, bytes: row.bytes, etag: row.etag }; +} + +/** Looks up an avatar by address, for showing a sender in the message list. */ +export async function loadAvatarByAddress(address: string): Promise { + const row = await queryOne<{ content_type: string; bytes: Buffer; etag: string }>( + `SELECT a.content_type, a.bytes, a.etag + FROM mailbox_avatars a JOIN mailboxes m ON m.id = a.mailbox_id + WHERE lower(m.email_address) = lower($1)`, + [address], + ); + if (!row) return null; + return { contentType: row.content_type, bytes: row.bytes, etag: row.etag }; +} + +export async function deleteAvatar(mailboxId: string): Promise { + const rows = await query<{ mailbox_id: string }>( + `DELETE FROM mailbox_avatars WHERE mailbox_id = $1 RETURNING mailbox_id`, + [mailboxId], + ); + return rows.length === 1; +} + // ------------------------------------------------------------- audit log /** Records an administrative action. PLAN.md section 14.3 requires every diff --git a/services/auth/src/router.test.ts b/services/auth/src/router.test.ts index e5eb972..b67dfba 100644 --- a/services/auth/src/router.test.ts +++ b/services/auth/src/router.test.ts @@ -927,6 +927,64 @@ describeDb("auth API against a real database", () => { expect(still).toHaveLength(1); }); + it("stores a profile photo and serves it back with a cache tag", async () => { + const token = (await post("/api/v1/auth/user/login", { email: userEmail(), password: PASSWORD })) + .body.token as unknown as string; + + const png = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2, 3, 4]); + const put = await fetch(`${base}/api/v1/user/avatar`, { + method: "PUT", + headers: { Authorization: `Bearer ${token}`, "Content-Type": "image/png" }, + body: png, + }); + expect(put.status).toBe(200); + + const got = await fetch(`${base}/api/v1/avatar?address=${encodeURIComponent(userEmail())}`, { + headers: { Authorization: `Bearer ${token}` }, + }); + expect(got.status).toBe(200); + expect(got.headers.get("content-type")).toBe("image/png"); + expect(Buffer.from(await got.arrayBuffer())).toEqual(png); + + // A photo appears beside every message from that person; without the + // conditional answer it would be re-sent on every one of them. + const etag = got.headers.get("etag")!; + const again = await fetch(`${base}/api/v1/avatar?address=${encodeURIComponent(userEmail())}`, { + headers: { Authorization: `Bearer ${token}`, "If-None-Match": etag }, + }); + expect(again.status).toBe(304); + }, 60000); + + // The stored type is decided from the bytes. A caller that labels an HTML + // document as a PNG must not get it stored and served from our own origin. + it("refuses an upload that is not really an image", async () => { + const token = (await post("/api/v1/auth/user/login", { email: userEmail(), password: PASSWORD })) + .body.token as unknown as string; + + const res = await fetch(`${base}/api/v1/user/avatar`, { + method: "PUT", + headers: { Authorization: `Bearer ${token}`, "Content-Type": "image/png" }, + body: Buffer.from(""), + }); + expect(res.status).toBe(400); + }, 60000); + + // An open avatar endpoint would answer "this address exists" to anyone + // who asked, for every address they cared to try. + it("does not serve photos to a caller with no session", async () => { + const res = await fetch(`${base}/api/v1/avatar?address=${encodeURIComponent(userEmail())}`); + expect(res.status).toBe(401); + }); + + it("answers 404 for an address with no photo", async () => { + const token = (await post("/api/v1/auth/user/login", { email: userEmail(), password: PASSWORD })) + .body.token as unknown as string; + const res = await fetch(`${base}/api/v1/avatar?address=nobody@nowhere.invalid`, { + headers: { Authorization: `Bearer ${token}` }, + }); + expect(res.status).toBe(404); + }, 60000); + it("answers 404 for a user that does not exist rather than leaking the difference", async () => { const res = await get(`/api/v1/admin/mailboxes/44444444-4444-4444-4444-444444444444/aliases`, admin); expect(res.status).toBe(404); diff --git a/services/auth/src/router.ts b/services/auth/src/router.ts index 1515da2..2c5f71b 100644 --- a/services/auth/src/router.ts +++ b/services/auth/src/router.ts @@ -12,6 +12,7 @@ import { } from "./surfaceAccess.js"; import * as repo from "./repository.js"; import { parsePageParams, paged } from "./pagination.js"; +import { checkAvatar, MAX_AVATAR_BYTES } from "./imageValidation.js"; import { checkUser, checkServer, overallStatus } from "./diagnostics.js"; const CHALLENGE_TTL_SECONDS = 300; @@ -142,6 +143,12 @@ async function route(req: IncomingMessage, res: ServerResponse, url: string): Pr 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/avatar" && method === "PUT") return userSaveAvatar(req, res, session!); + if (url === "/api/v1/user/avatar" && method === "DELETE") return userDeleteAvatar(req, res, session!); + if (url.split("?")[0] === "/api/v1/avatar" && method === "GET") { + const wanted = new URL(req.url || "/", "http://localhost").searchParams.get("address") || ""; + return getAvatar(req, res, session, wanted); + } if (url === "/api/v1/user/preferences" && method === "GET") return userGetPreferences(res, session!); if (url === "/api/v1/user/preferences" && method === "POST") return userUpdatePreferences(req, res, session!); if (url === "/api/v1/user/sieve" && method === "GET") return userGetSieve(res, session!); @@ -229,6 +236,30 @@ async function route(req: IncomingMessage, res: ServerResponse, url: string): Pr } } +/** + * Reads a binary body, refusing anything over the cap as it arrives. + * + * Kept separate from readBody, which concatenates into a string: an image + * turned into a UTF-8 string and back is no longer the same bytes. + */ +function readBinaryBody(req: IncomingMessage, maxBytes: number): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + let size = 0; + req.on("data", (chunk: Buffer) => { + size += chunk.length; + if (size > maxBytes) { + reject(new Error("PAYLOAD_TOO_LARGE")); + req.destroy(); + return; + } + chunks.push(chunk); + }); + req.on("end", () => resolve(Buffer.concat(chunks))); + req.on("error", reject); + }); +} + async function parseJsonBody(req: IncomingMessage): Promise | null> { const raw = await readBody(req); try { @@ -238,6 +269,95 @@ async function parseJsonBody(req: IncomingMessage): Promise { + let body: Buffer; + try { + body = await readBinaryBody(req, MAX_AVATAR_BYTES + 1); + } catch { + return sendJson(res, 413, { error: "TOO_LARGE", message: "The image is too large" }); + } + + const check = checkAvatar(body); + if (!check.ok) { + const status = check.reason === "TOO_LARGE" ? 413 : 400; + return sendJson(res, status, { + error: check.reason, + message: + check.reason === "TOO_LARGE" + ? "The image is too large" + : "The upload is not a PNG, JPEG or WebP image", + }); + } + + const etag = await repo.saveAvatar(session.sub, check.contentType!, body); + await repo.recordAudit(session.sub, clientIp(req), "user.avatar_set", "mailbox", session.sub, { + content_type: check.contentType, + bytes: body.length, + }); + sendJson(res, 200, { ok: true, etag }); +} + +/** + * Serves a mailbox's photo, by address. + * + * Behind a session on purpose, and checked here rather than by the prefix + * guard, which only covers /user/ and /admin/. An open endpoint keyed by + * address answers "this mailbox exists" to anyone willing to ask, for every + * address they care to try. + */ +async function getAvatar( + req: IncomingMessage, res: ServerResponse, session: SessionTokenPayload | null, address: string, +): Promise { + if (!session) { + return sendJson(res, 401, { error: "UNAUTHORIZED", message: "A signed-in session is required" }); + } + + const avatar = address ? await repo.loadAvatarByAddress(address) : null; + if (!avatar) { + // 404 rather than a placeholder: the caller decides what to draw instead, + // and initials are drawn client-side already. + return sendJson(res, 404, { error: "NOT_FOUND", message: "No photo" }); + } + + // A photo appears beside every message from that person, so re-sending it + // each time is the difference between one request and a hundred. + if (req.headers["if-none-match"] === `"${avatar.etag}"`) { + res.statusCode = 304; + res.end(); + return; + } + + res.statusCode = 200; + res.setHeader("Content-Type", avatar.contentType); + res.setHeader("ETag", `"${avatar.etag}"`); + // Private: this is served to a signed-in reader, and a shared cache must not + // hand one person's photo to the next request that comes along. + res.setHeader("Cache-Control", "private, max-age=300"); + // The bytes were sniffed on the way in, but a browser that ignores the type + // and sniffs for itself must not be allowed to find something else. + res.setHeader("X-Content-Type-Options", "nosniff"); + res.end(avatar.bytes); +} + +async function userDeleteAvatar( + req: IncomingMessage, res: ServerResponse, session: SessionTokenPayload, +): Promise { + await repo.deleteAvatar(session.sub); + await repo.recordAudit(session.sub, clientIp(req), "user.avatar_cleared", "mailbox", session.sub, {}); + sendJson(res, 200, { ok: true }); +} + // ------------------------------------------------------------------- login async function login(req: IncomingMessage, res: ServerResponse, surface: "user" | "admin"): Promise { diff --git a/services/webmail/messages/en.json b/services/webmail/messages/en.json index 7e228fb..65c2285 100644 --- a/services/webmail/messages/en.json +++ b/services/webmail/messages/en.json @@ -428,5 +428,15 @@ "user": "User", "domainAdmin": "Domain admin", "superAdmin": "Super admin" + }, + "avatar": { + "title": "Profile photo", + "explainer": "This photo appears inside LambdaMail. External services like Gmail and Outlook do not read it: for those, see BIMI on the domains page.", + "none": "No photo", + "choose": "Choose a photo", + "replace": "Replace photo", + "zoom": "Zoom", + "dragToPosition": "Drag to position", + "notAnImage": "That file could not be read as an image." } } diff --git a/services/webmail/messages/es.json b/services/webmail/messages/es.json index 8f05d7f..92c65b4 100644 --- a/services/webmail/messages/es.json +++ b/services/webmail/messages/es.json @@ -428,5 +428,15 @@ "user": "Usuario", "domainAdmin": "Admin del dominio", "superAdmin": "Super admin" + }, + "avatar": { + "title": "Foto de perfil", + "explainer": "Esta foto aparece dentro de LambdaMail. Servicios externos como Gmail y Outlook no la leen: para eso, vea BIMI en la pagina de dominios.", + "none": "Sin foto", + "choose": "Elegir foto", + "replace": "Cambiar foto", + "zoom": "Zoom", + "dragToPosition": "Arrastre para posicionar", + "notAnImage": "No se pudo leer ese archivo como imagen." } } diff --git a/services/webmail/messages/pt-BR.json b/services/webmail/messages/pt-BR.json index 7bdcf5b..eee2cc5 100644 --- a/services/webmail/messages/pt-BR.json +++ b/services/webmail/messages/pt-BR.json @@ -428,5 +428,15 @@ "user": "Utilizador", "domainAdmin": "Admin do dominio", "superAdmin": "Super admin" + }, + "avatar": { + "title": "Foto de perfil", + "explainer": "Esta foto aparece dentro do LambdaMail. Servicos externos como Gmail e Outlook nao a leem: para eles, veja BIMI na pagina de dominios.", + "none": "Sem foto", + "choose": "Escolher foto", + "replace": "Trocar foto", + "zoom": "Zoom", + "dragToPosition": "Arraste para posicionar", + "notAnImage": "Nao foi possivel ler esse ficheiro como imagem." } } diff --git a/services/webmail/src/app/(user)/user/layout.tsx b/services/webmail/src/app/(user)/user/layout.tsx index 8a08a74..87e8cad 100644 --- a/services/webmail/src/app/(user)/user/layout.tsx +++ b/services/webmail/src/app/(user)/user/layout.tsx @@ -27,7 +27,7 @@ import { LanguageSwitcher } from "../../../i18n/LanguageSwitcher"; import { useAccount, isAdminRole } from "../../../lib/useAccount"; import { useFolders } from "../../../lib/useFolders"; import { folderBadge, customFolders } from "../../../lib/mailCounts"; -import { initialsFor } from "../../../lib/initials"; +import { Avatar } from "../../../components/Avatar"; export default function UserWebmailLayout({ children }: { children: React.ReactNode }) { const t = useTranslations(); @@ -279,9 +279,7 @@ export default function UserWebmailLayout({ children }: { children: React.ReactN 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" > - - {initialsFor(account?.email)} - + - + } + /> @@ -652,9 +653,12 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
-
- {initials(selectedRow?.from_display_name ?? "", selected.from)} -
+
{selected.from} diff --git a/services/webmail/src/app/(user)/user/settings/page.tsx b/services/webmail/src/app/(user)/user/settings/page.tsx index 1999441..a7e2085 100644 --- a/services/webmail/src/app/(user)/user/settings/page.tsx +++ b/services/webmail/src/app/(user)/user/settings/page.tsx @@ -15,6 +15,7 @@ import { Plus, Lock, Languages, + UserRound, } from "lucide-react"; import { useTranslations } from "../../../../i18n/provider"; import { Badge } from "../../../../components/ui/Badge"; @@ -24,6 +25,8 @@ import { LanguageSwitcher } from "../../../../i18n/LanguageSwitcher"; import { signatureToHtml, sanitizeSignature } from "../../../../lib/signature"; import { buildSieve, parseSieve, type Rule, type RuleField, type RuleCondition, type RuleAction } from "../../../../lib/rules"; import { useFolders } from "../../../../lib/useFolders"; +import { AvatarEditor } from "../../../../components/AvatarEditor"; +import { useAccount } from "../../../../lib/useAccount"; import { moveTargets } from "../../../../lib/mailCounts"; interface SieveRule { @@ -51,11 +54,20 @@ interface WebSession { current: boolean; } -type SettingsTab = "security" | "filters" | "vacation" | "signature" | "language"; +type SettingsTab = "profile" | "security" | "filters" | "vacation" | "signature" | "language"; export default function UserSettingsPage() { const t = useTranslations(); - const [activeTab, setActiveTab] = useState("security"); + const [activeTab, setActiveTab] = useState("profile"); + // Bumped after a save so the browser refetches instead of showing the photo + // it already holds under the same URL. + const [avatarVersion, setAvatarVersion] = useState(0); + const account = useAccount("user"); + // Null until the account loads, so the editor is not told "no photo" before + // it is known whether there is one. + const avatarSrc = account?.email + ? `/api/v1/avatar?address=${encodeURIComponent(account.email)}&v=${avatarVersion}` + : null; const [mfaEnabled, setMfaEnabled] = useState(false); const [qrSecret, setQrSecret] = useState(null); @@ -285,6 +297,7 @@ export default function UserSettingsPage() { separated by everything else. The same panels now sit behind a tab strip, in the order the redesign groups them. */ const tabs = [ + { id: "profile" as const, label: t("avatar.title"), icon: UserRound }, { id: "security" as const, label: t("settings.security"), icon: Shield }, { id: "filters" as const, label: t("settings.sieveTitle"), icon: Filter }, { id: "vacation" as const, label: t("settings.vacationTitle"), icon: Palmtree }, @@ -336,6 +349,21 @@ export default function UserSettingsPage() {
)} + {activeTab === "profile" && ( +
+

{t("avatar.title")}

+

{t("avatar.explainer")}

+ { + // The query string changes so the browser fetches the new + // photo instead of the one it already has under this URL. + setAvatarVersion((v) => v + 1); + }} + /> +
+ )} + {activeTab === "security" && (
{/* 2FA TOTP SECTION */} diff --git a/services/webmail/src/components/Avatar.tsx b/services/webmail/src/components/Avatar.tsx new file mode 100644 index 0000000..f4b4590 --- /dev/null +++ b/services/webmail/src/components/Avatar.tsx @@ -0,0 +1,54 @@ +"use client"; + +import React, { useState } from "react"; +import { initialsFor } from "../lib/initials"; + +interface AvatarProps { + address: string | null | undefined; + /** Rendered size in pixels; the square the circle is cut from. */ + size: number; + className?: string; + /** + * What to show while there is no photo. Passed in where the caller already + * has a better answer than the address - a display name, usually - so the + * fallback does not get worse than what it replaced. + */ + initials?: string; +} + +/** + * Someone's photo, falling back to their initials. + * + * The endpoint answers 404 when there is no photo, which is most people most + * of the time, so the initials are what is rendered first and the image only + * replaces them once it has actually loaded. Doing it the other way round + * shows an empty circle on every row until each request comes back. + */ +export function Avatar({ address, size, className = "", initials }: AvatarProps) { + const [loaded, setLoaded] = useState(false); + const [failed, setFailed] = useState(false); + + const src = address ? `/api/v1/avatar?address=${encodeURIComponent(address)}` : null; + + return ( + + ); +} diff --git a/services/webmail/src/components/AvatarEditor.tsx b/services/webmail/src/components/AvatarEditor.tsx new file mode 100644 index 0000000..84cb421 --- /dev/null +++ b/services/webmail/src/components/AvatarEditor.tsx @@ -0,0 +1,254 @@ +"use client"; + +import React, { useCallback, useEffect, useRef, useState } from "react"; +import { Upload, ZoomIn, ZoomOut, Trash2 } from "lucide-react"; +import { Button } from "./ui/Button"; +import { useTranslations } from "../i18n/provider"; +import { + clampTransform, + initialTransform, + scaleRange, + sourceRect, + type Size, + type Transform, +} from "../lib/avatarCrop"; + +/** The frame is square; the mask over it is what makes the photo a circle. */ +const VIEWPORT = 288; +/** What gets stored. Larger than any place it is shown, so it stays sharp. */ +const EXPORT_SIZE = 512; + +interface AvatarEditorProps { + /** Current photo, if there is one. */ + src: string | null; + onSaved: () => void; +} + +export function AvatarEditor({ src, onSaved }: AvatarEditorProps) { + const t = useTranslations(); + + const [image, setImage] = useState(null); + const [natural, setNatural] = useState({ width: 0, height: 0 }); + const [transform, setTransform] = useState({ scale: 1, offsetX: 0, offsetY: 0 }); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + const canvasRef = useRef(null); + const fileRef = useRef(null); + const dragRef = useRef<{ x: number; y: number; from: Transform } | null>(null); + + /** Draws the current transform into the preview. */ + const paint = useCallback(() => { + const canvas = canvasRef.current; + if (!canvas || !image) return; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + ctx.clearRect(0, 0, VIEWPORT, VIEWPORT); + const r = sourceRect(natural, VIEWPORT, transform); + ctx.drawImage(image, r.x, r.y, r.width, r.height, 0, 0, VIEWPORT, VIEWPORT); + }, [image, natural, transform]); + + useEffect(() => { + paint(); + }, [paint]); + + const openFile = (file: File) => { + setError(null); + const url = URL.createObjectURL(file); + const img = new Image(); + img.onload = () => { + const size = { width: img.naturalWidth, height: img.naturalHeight }; + setImage(img); + setNatural(size); + setTransform(initialTransform(size, VIEWPORT)); + // The bitmap is decoded and held; the object URL has done its job and + // would otherwise keep the file alive for the life of the page. + URL.revokeObjectURL(url); + }; + img.onerror = () => { + URL.revokeObjectURL(url); + setError(t("avatar.notAnImage")); + }; + img.src = url; + }; + + const onPointerDown = (e: React.PointerEvent) => { + if (!image) return; + (e.target as HTMLElement).setPointerCapture(e.pointerId); + dragRef.current = { x: e.clientX, y: e.clientY, from: transform }; + }; + + const onPointerMove = (e: React.PointerEvent) => { + const drag = dragRef.current; + if (!drag || !image) return; + setTransform( + clampTransform(natural, VIEWPORT, { + scale: drag.from.scale, + offsetX: drag.from.offsetX + (e.clientX - drag.x), + offsetY: drag.from.offsetY + (e.clientY - drag.y), + }), + ); + }; + + const endDrag = () => { + dragRef.current = null; + }; + + const zoomTo = (scale: number) => { + if (!image) return; + setTransform((prev) => clampTransform(natural, VIEWPORT, { ...prev, scale })); + }; + + const save = async () => { + if (!image) return; + setBusy(true); + setError(null); + try { + // Exported from the source pixels rather than from the preview, so the + // stored photo is as sharp as the original allows instead of being + // limited to the size of the editor on screen. + const out = document.createElement("canvas"); + out.width = EXPORT_SIZE; + out.height = EXPORT_SIZE; + const ctx = out.getContext("2d"); + if (!ctx) throw new Error(); + const r = sourceRect(natural, VIEWPORT, transform); + ctx.drawImage(image, r.x, r.y, r.width, r.height, 0, 0, EXPORT_SIZE, EXPORT_SIZE); + + const blob = await new Promise((resolve) => + // The frame is always fully covered, so there is no transparency to + // preserve and JPEG is a fraction of the size. + out.toBlob((b) => resolve(b), "image/jpeg", 0.9), + ); + if (!blob) throw new Error(); + + const res = await fetch("/api/v1/user/avatar", { + method: "PUT", + headers: { "Content-Type": "image/jpeg" }, + body: blob, + }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(data.message); + } + setImage(null); + onSaved(); + } catch (err) { + setError(err instanceof Error && err.message ? err.message : t("errors.serverError")); + } finally { + setBusy(false); + } + }; + + const remove = async () => { + setBusy(true); + setError(null); + try { + const res = await fetch("/api/v1/user/avatar", { method: "DELETE" }); + if (!res.ok) throw new Error(); + onSaved(); + } catch { + setError(t("errors.serverError")); + } finally { + setBusy(false); + } + }; + + const range = scaleRange(natural, VIEWPORT); + + return ( +
+ {!image ? ( +
+ {/* What is stored today, shown as it will appear elsewhere. */} + + {src ? ( + // eslint-disable-next-line @next/next/no-img-element + + ) : ( + {t("avatar.none")} + )} + + +
+ + {src && ( + + )} +
+
+ ) : ( +
+
+ +
+ +
+ + zoomTo(Number(e.target.value))} + aria-label={t("avatar.zoom")} + className="h-1 flex-1 cursor-pointer appearance-none rounded-full bg-dark-card accent-indigo-500" + /> + +
+ +

{t("avatar.dragToPosition")}

+ +
+ + +
+
+ )} + + {error && ( +
+ {error} +
+ )} + + { + const file = e.target.files?.[0]; + if (file) openFile(file); + // Cleared so choosing the same file twice in a row still fires. + e.target.value = ""; + }} + /> +
+ ); +} diff --git a/services/webmail/src/lib/avatarCrop.test.ts b/services/webmail/src/lib/avatarCrop.test.ts new file mode 100644 index 0000000..f4b9edd --- /dev/null +++ b/services/webmail/src/lib/avatarCrop.test.ts @@ -0,0 +1,109 @@ +import { describe, it, expect } from "vitest"; +import { + minimumScale, + scaleRange, + clampTransform, + initialTransform, + sourceRect, +} from "./avatarCrop"; + +const VIEWPORT = 320; + +describe("minimumScale", () => { + // Cover, not contain. Scaling a wide photo to fit its width leaves the top + // and bottom of the circle empty. + it("covers the frame on the short side of a wide image", () => { + expect(minimumScale({ width: 800, height: 400 }, VIEWPORT)).toBeCloseTo(320 / 400); + }); + + it("covers the frame on the short side of a tall image", () => { + expect(minimumScale({ width: 400, height: 800 }, VIEWPORT)).toBeCloseTo(320 / 400); + }); + + it("scales a small image up rather than leaving it small", () => { + expect(minimumScale({ width: 100, height: 100 }, VIEWPORT)).toBeCloseTo(3.2); + }); + + it.each([ + [{ width: 0, height: 100 }], + [{ width: 100, height: 0 }], + [{ width: Number.NaN, height: 100 }], + ])("survives a degenerate image %o", (image) => { + expect(Number.isFinite(minimumScale(image, VIEWPORT))).toBe(true); + }); +}); + +describe("clampTransform", () => { + const image = { width: 800, height: 600 }; + + it("refuses to zoom below covering the frame", () => { + const t = clampTransform(image, VIEWPORT, { scale: 0.01, offsetX: 0, offsetY: 0 }); + expect(t.scale).toBeCloseTo(minimumScale(image, VIEWPORT)); + }); + + // Without this, dragging far enough pulls the edge of the photo into the + // circle and the export carries a transparent wedge. + it("stops the image at its own edge", () => { + const scale = minimumScale(image, VIEWPORT); + const t = clampTransform(image, VIEWPORT, { scale, offsetX: 99999, offsetY: 99999 }); + + const overhangX = (image.width * scale - VIEWPORT) / 2; + expect(t.offsetX).toBeCloseTo(overhangX); + // At the covering scale the short side has no slack at all, so it cannot + // move: any movement there would expose a gap. + expect(t.offsetY).toBeCloseTo(0); + }); + + it("allows movement on both axes once zoomed in", () => { + const scale = minimumScale(image, VIEWPORT) * 2; + const t = clampTransform(image, VIEWPORT, { scale, offsetX: 99999, offsetY: 99999 }); + expect(t.offsetX).toBeGreaterThan(0); + expect(t.offsetY).toBeGreaterThan(0); + }); + + it("caps the zoom so the photo cannot be magnified into mush", () => { + const { max } = scaleRange(image, VIEWPORT); + expect(clampTransform(image, VIEWPORT, { scale: 9999, offsetX: 0, offsetY: 0 }).scale).toBeCloseTo(max); + }); +}); + +describe("sourceRect", () => { + const image = { width: 800, height: 600 }; + + it("is always square, because the frame is", () => { + const r = sourceRect(image, VIEWPORT, { scale: 1.2, offsetX: 30, offsetY: -20 }); + expect(r.width).toBeCloseTo(r.height); + }); + + // At the covering scale the whole short side is used: nothing of the height + // is cropped away, and the crop is the full 600px of it. + it("takes the whole short side at the covering scale", () => { + const r = sourceRect(image, VIEWPORT, initialTransform(image, VIEWPORT)); + expect(r.height).toBeCloseTo(600); + expect(r.y).toBeCloseTo(0); + // Centred horizontally: (800 - 600) / 2. + expect(r.x).toBeCloseTo(100); + }); + + it("never reads outside the image", () => { + for (const wanted of [ + { scale: 0.001, offsetX: -9999, offsetY: -9999 }, + { scale: 9999, offsetX: 9999, offsetY: 9999 }, + { scale: 1, offsetX: 0, offsetY: 9999 }, + ]) { + const r = sourceRect(image, VIEWPORT, wanted); + expect(r.x).toBeGreaterThanOrEqual(-0.001); + expect(r.y).toBeGreaterThanOrEqual(-0.001); + expect(r.x + r.width).toBeLessThanOrEqual(image.width + 0.001); + expect(r.y + r.height).toBeLessThanOrEqual(image.height + 0.001); + } + }); + + // Dragging the image right shows what is further left in the source. + it("moves the crop opposite to the drag", () => { + const scale = minimumScale(image, VIEWPORT) * 2; + const left = sourceRect(image, VIEWPORT, { scale, offsetX: 40, offsetY: 0 }); + const centre = sourceRect(image, VIEWPORT, { scale, offsetX: 0, offsetY: 0 }); + expect(left.x).toBeLessThan(centre.x); + }); +}); diff --git a/services/webmail/src/lib/avatarCrop.ts b/services/webmail/src/lib/avatarCrop.ts new file mode 100644 index 0000000..b5268ef --- /dev/null +++ b/services/webmail/src/lib/avatarCrop.ts @@ -0,0 +1,109 @@ +/** + * The geometry behind the circular photo editor. + * + * The editor shows a fixed square viewport with a circular mask; the image sits + * behind it, scaled and dragged. Everything here works in viewport pixels and + * is deliberately free of DOM and canvas, so the awkward parts - the smallest + * scale that still covers the circle, and clamping so no gap can be dragged + * into view - are testable on their own. + */ + +export interface Size { + width: number; + height: number; +} + +/** Where the image sits behind the viewport, and how big it is drawn. */ +export interface Transform { + /** Multiplier applied to the image's natural size. */ + scale: number; + /** Image centre, relative to the viewport centre, in viewport pixels. */ + offsetX: number; + offsetY: number; +} + +/** + * The smallest scale at which the image still covers the whole viewport. + * + * Below this a corner of the frame is empty, and the exported photo would carry + * a transparent wedge - so this is the floor for the zoom control, not merely a + * suggestion. + */ +export function minimumScale(image: Size, viewport: number): number { + // Finite, not merely positive: NaN fails every comparison, so `<= 0` lets it + // through and it then propagates into the transform and blanks the editor. + // A NaN dimension is what an image that failed to decode reports. + if ( + !Number.isFinite(image.width) || !Number.isFinite(image.height) || !Number.isFinite(viewport) || + image.width <= 0 || image.height <= 0 || viewport <= 0 + ) { + return 1; + } + // Cover, not contain: the larger of the two ratios. + return Math.max(viewport / image.width, viewport / image.height); +} + +/** The zoom control's range: from covering the frame to four times that. */ +export function scaleRange(image: Size, viewport: number): { min: number; max: number } { + const min = minimumScale(image, viewport); + return { min, max: min * 4 }; +} + +/** + * Holds the image over the viewport, whatever was asked for. + * + * A drag or a zoom out can otherwise pull an edge inside the frame. Clamping + * the offset to the overhang keeps the frame covered without fighting the + * gesture: the image stops at its edge instead of springing back. + */ +export function clampTransform(image: Size, viewport: number, wanted: Transform): Transform { + const { min, max } = scaleRange(image, viewport); + const scale = Math.min(Math.max(wanted.scale, min), max); + + // Half of what sticks out past the viewport on each axis. + const overhangX = Math.max(0, (image.width * scale - viewport) / 2); + const overhangY = Math.max(0, (image.height * scale - viewport) / 2); + + return { + scale, + offsetX: Math.min(Math.max(wanted.offsetX, -overhangX), overhangX), + offsetY: Math.min(Math.max(wanted.offsetY, -overhangY), overhangY), + }; +} + +/** The transform that centres the image at the smallest covering scale. */ +export function initialTransform(image: Size, viewport: number): Transform { + return { scale: minimumScale(image, viewport), offsetX: 0, offsetY: 0 }; +} + +/** + * The rectangle of the *source* image that the viewport is showing. + * + * This is what the export draws. Working back to source pixels rather than + * scaling the preview means the saved photo is as sharp as the original + * allows, instead of being limited to the size of the editor on screen. + */ +export interface SourceRect { + x: number; + y: number; + width: number; + height: number; +} + +export function sourceRect(image: Size, viewport: number, transform: Transform): SourceRect { + const t = clampTransform(image, viewport, transform); + // One viewport pixel is this many source pixels. + const perPixel = 1 / t.scale; + const size = viewport * perPixel; + + // The viewport centre, expressed in source coordinates. + const centreX = image.width / 2 - t.offsetX * perPixel; + const centreY = image.height / 2 - t.offsetY * perPixel; + + return { + x: centreX - size / 2, + y: centreY - size / 2, + width: size, + height: size, + }; +}