feat(avatar): foto de perfil com editor de recorte circular
Carregar, enquadrar num circulo com zoom e arrasto, e guardar. A foto aparece na barra lateral, na lista de mensagens e no leitor, caindo para as iniciais quando nao ha nenhuma. A geometria vai em funcoes puras com testes, porque e onde os erros moram: a escala minima cobre o quadrado em vez de caber nele (caber deixa um canto vazio e o ficheiro exportado leva uma cunha transparente), e o deslocamento e preso a saliencia da imagem para que nenhum arrasto consiga trazer uma borda para dentro do circulo. Uma dimensao NaN -- o que uma imagem que nao descodificou reporta -- passava pelo guarda `<= 0`, porque NaN falha qualquer comparacao, e propagava-se ate apagar o editor. O recorte e exportado a partir dos pixeis da origem e nao da pre-visualizacao, por isso a foto guardada e tao nitida quanto o original permite em vez de ficar limitada ao tamanho do editor no ecra. Do lado do servidor o tipo e decidido pelos bytes, nunca pelo Content-Type nem pelo nome do ficheiro, que sao ambos escolhidos por quem carrega. SVG e recusado apesar de ser imagem: e um documento que pode levar script e seria servido de volta a partir da nossa propria origem. A leitura exige sessao, verificada na propria rota porque o guarda de prefixo so cobre /user/ e /admin/. Um endpoint aberto indexado por endereco responde "esta caixa existe" a quem quer que pergunte, para todos os enderecos que quiser experimentar. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
82b4c8ce17
commit
fcfee6a3f9
17 files changed
+978
-16
No files matched your search
@@ -0,0 +1 @@
|
|||||||
|
DROP TABLE IF EXISTS mailbox_avatars;
|
||||||
@@ -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()
|
||||||
|
);
|
||||||
@@ -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("<!doctype html><script>alert(1)</script>"))).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('<svg xmlns="http://www.w3.org/2000/svg"><script/></svg>'))).toBeNull();
|
||||||
|
expect(sniffImage(Buffer.from('<?xml version="1.0"?><svg/>'))).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" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import crypto from "node:crypto";
|
||||||
import { query, queryOne } from "./db.js";
|
import { query, queryOne } from "./db.js";
|
||||||
import { verifyPassword, hashPassword, encryptSecret, decryptSecret } from "./crypto.js";
|
import { verifyPassword, hashPassword, encryptSecret, decryptSecret } from "./crypto.js";
|
||||||
import { verifyTotpCode } from "./totp.js";
|
import { verifyTotpCode } from "./totp.js";
|
||||||
@@ -716,6 +717,65 @@ export async function adminRemoveTotp(scope: AdminScope, mailboxId: string): Pro
|
|||||||
return true;
|
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<string> {
|
||||||
|
// 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<StoredAvatar | null> {
|
||||||
|
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<StoredAvatar | null> {
|
||||||
|
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<boolean> {
|
||||||
|
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
|
// ------------------------------------------------------------- audit log
|
||||||
|
|
||||||
/** Records an administrative action. PLAN.md section 14.3 requires every
|
/** Records an administrative action. PLAN.md section 14.3 requires every
|
||||||
|
|||||||
@@ -927,6 +927,64 @@ describeDb("auth API against a real database", () => {
|
|||||||
expect(still).toHaveLength(1);
|
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("<svg xmlns=\"http://www.w3.org/2000/svg\"><script>alert(1)</script></svg>"),
|
||||||
|
});
|
||||||
|
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 () => {
|
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);
|
const res = await get(`/api/v1/admin/mailboxes/44444444-4444-4444-4444-444444444444/aliases`, admin);
|
||||||
expect(res.status).toBe(404);
|
expect(res.status).toBe(404);
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
} from "./surfaceAccess.js";
|
} from "./surfaceAccess.js";
|
||||||
import * as repo from "./repository.js";
|
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 { checkUser, checkServer, overallStatus } from "./diagnostics.js";
|
import { checkUser, checkServer, overallStatus } from "./diagnostics.js";
|
||||||
|
|
||||||
const CHALLENGE_TTL_SECONDS = 300;
|
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/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!);
|
||||||
|
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 === "GET") return userGetPreferences(res, session!);
|
||||||
if (url === "/api/v1/user/preferences" && method === "POST") return userUpdatePreferences(req, 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!);
|
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<Buffer> {
|
||||||
|
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<Record<string, unknown> | null> {
|
async function parseJsonBody(req: IncomingMessage): Promise<Record<string, unknown> | null> {
|
||||||
const raw = await readBody(req);
|
const raw = await readBody(req);
|
||||||
try {
|
try {
|
||||||
@@ -238,6 +269,95 @@ async function parseJsonBody(req: IncomingMessage): Promise<Record<string, unkno
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ avatars
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stores the photo the editor produced.
|
||||||
|
*
|
||||||
|
* The body is the image itself rather than a multipart form or a base64 field:
|
||||||
|
* the editor already has the exact bytes from a canvas, and the other two
|
||||||
|
* shapes only make them bigger on the way here.
|
||||||
|
*/
|
||||||
|
async function userSaveAvatar(
|
||||||
|
req: IncomingMessage, res: ServerResponse, session: SessionTokenPayload,
|
||||||
|
): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
await repo.deleteAvatar(session.sub);
|
||||||
|
await repo.recordAudit(session.sub, clientIp(req), "user.avatar_cleared", "mailbox", session.sub, {});
|
||||||
|
sendJson(res, 200, { ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------------- login
|
// ------------------------------------------------------------------- login
|
||||||
|
|
||||||
async function login(req: IncomingMessage, res: ServerResponse, surface: "user" | "admin"): Promise<void> {
|
async function login(req: IncomingMessage, res: ServerResponse, surface: "user" | "admin"): Promise<void> {
|
||||||
|
|||||||
@@ -428,5 +428,15 @@
|
|||||||
"user": "User",
|
"user": "User",
|
||||||
"domainAdmin": "Domain admin",
|
"domainAdmin": "Domain admin",
|
||||||
"superAdmin": "Super 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."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -428,5 +428,15 @@
|
|||||||
"user": "Usuario",
|
"user": "Usuario",
|
||||||
"domainAdmin": "Admin del dominio",
|
"domainAdmin": "Admin del dominio",
|
||||||
"superAdmin": "Super admin"
|
"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."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -428,5 +428,15 @@
|
|||||||
"user": "Utilizador",
|
"user": "Utilizador",
|
||||||
"domainAdmin": "Admin do dominio",
|
"domainAdmin": "Admin do dominio",
|
||||||
"superAdmin": "Super admin"
|
"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."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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 { initialsFor } from "../../../lib/initials";
|
import { Avatar } from "../../../components/Avatar";
|
||||||
|
|
||||||
export default function UserWebmailLayout({ children }: { children: React.ReactNode }) {
|
export default function UserWebmailLayout({ children }: { children: React.ReactNode }) {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
@@ -279,9 +279,7 @@ export default function UserWebmailLayout({ children }: { children: React.ReactN
|
|||||||
onClick={() => setMobileOpen(false)}
|
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"
|
className="flex items-center gap-2.5 rounded-xl bg-dark-panel p-2.5 shadow-edge transition-colors hover:bg-dark-card"
|
||||||
>
|
>
|
||||||
<span className="flex h-[30px] w-[30px] flex-none items-center justify-center rounded-full bg-indigo-900 text-[12px] uppercase text-indigo-300 shadow-[inset_0_0_0_1px_#5d5294]">
|
<Avatar address={account?.email} size={30} className="bg-indigo-900 text-indigo-300 shadow-[inset_0_0_0_1px_#5d5294]" />
|
||||||
{initialsFor(account?.email)}
|
|
||||||
</span>
|
|
||||||
<span className="min-w-0 flex-1">
|
<span className="min-w-0 flex-1">
|
||||||
<span
|
<span
|
||||||
className="block truncate text-[12.5px] leading-tight"
|
className="block truncate text-[12.5px] leading-tight"
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import { sanitizeEmailHtml, blockRemoteImages, unblockRemoteImages } from "../..
|
|||||||
import { isSenderTrusted, trustSender, revokeSender } from "../../../../../lib/remoteImages";
|
import { isSenderTrusted, trustSender, revokeSender } from "../../../../../lib/remoteImages";
|
||||||
import { resolveInlineImages, buildReaderDocument, type InlineImages } from "../../../../../lib/emailBody";
|
import { resolveInlineImages, buildReaderDocument, type InlineImages } from "../../../../../lib/emailBody";
|
||||||
import { Button } from "../../../../../components/ui/Button";
|
import { Button } from "../../../../../components/ui/Button";
|
||||||
|
import { Avatar } from "../../../../../components/Avatar";
|
||||||
|
|
||||||
interface MessageSummary {
|
interface MessageSummary {
|
||||||
uid: number;
|
uid: number;
|
||||||
@@ -457,16 +458,16 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
|
|||||||
data-active={isSelected}
|
data-active={isSelected}
|
||||||
className="lm-row w-full text-left"
|
className="lm-row w-full text-left"
|
||||||
>
|
>
|
||||||
<span
|
<Avatar
|
||||||
className={`flex h-[34px] w-[34px] flex-none items-center justify-center rounded-full text-xs ${
|
address={msg.sender_address}
|
||||||
|
initials={initials(msg.from_display_name, msg.sender_address)}
|
||||||
|
size={38}
|
||||||
|
className={
|
||||||
msg.seen
|
msg.seen
|
||||||
? "bg-dark-card text-slate-400"
|
? "bg-dark-card text-slate-400"
|
||||||
: "bg-indigo-900 text-indigo-300 shadow-[inset_0_0_0_1px_#5d5294]"
|
: "bg-indigo-900 text-indigo-300 shadow-[inset_0_0_0_1px_#5d5294]"
|
||||||
}`}
|
}
|
||||||
aria-hidden="true"
|
/>
|
||||||
>
|
|
||||||
{initials(msg.from_display_name, msg.sender_address)}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<span className="flex min-w-0 flex-1 flex-col gap-[3px]">
|
<span className="flex min-w-0 flex-1 flex-col gap-[3px]">
|
||||||
<span className="flex items-baseline gap-2">
|
<span className="flex items-baseline gap-2">
|
||||||
@@ -652,9 +653,12 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
|
|||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<div className="flex h-[38px] w-[38px] flex-none items-center justify-center rounded-full bg-indigo-900 text-[13px] text-indigo-300 shadow-[inset_0_0_0_1px_#5d5294]">
|
<Avatar
|
||||||
{initials(selectedRow?.from_display_name ?? "", selected.from)}
|
address={selectedRow?.sender_address ?? selected.from}
|
||||||
</div>
|
initials={initials(selectedRow?.from_display_name ?? "", selected.from)}
|
||||||
|
size={38}
|
||||||
|
className="bg-indigo-900 text-indigo-300 shadow-[inset_0_0_0_1px_#5d5294]"
|
||||||
|
/>
|
||||||
<div className="flex min-w-0 flex-1 flex-col gap-[3px]">
|
<div className="flex min-w-0 flex-1 flex-col gap-[3px]">
|
||||||
<div className="flex flex-wrap items-baseline gap-x-2.5 gap-y-1">
|
<div className="flex flex-wrap items-baseline gap-x-2.5 gap-y-1">
|
||||||
<span className="break-all text-sm text-slate-100">{selected.from}</span>
|
<span className="break-all text-sm text-slate-100">{selected.from}</span>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
Plus,
|
Plus,
|
||||||
Lock,
|
Lock,
|
||||||
Languages,
|
Languages,
|
||||||
|
UserRound,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useTranslations } from "../../../../i18n/provider";
|
import { useTranslations } from "../../../../i18n/provider";
|
||||||
import { Badge } from "../../../../components/ui/Badge";
|
import { Badge } from "../../../../components/ui/Badge";
|
||||||
@@ -24,6 +25,8 @@ import { LanguageSwitcher } from "../../../../i18n/LanguageSwitcher";
|
|||||||
import { signatureToHtml, sanitizeSignature } from "../../../../lib/signature";
|
import { signatureToHtml, sanitizeSignature } from "../../../../lib/signature";
|
||||||
import { buildSieve, parseSieve, type Rule, type RuleField, type RuleCondition, type RuleAction } from "../../../../lib/rules";
|
import { buildSieve, parseSieve, type Rule, type RuleField, type RuleCondition, type RuleAction } from "../../../../lib/rules";
|
||||||
import { useFolders } from "../../../../lib/useFolders";
|
import { useFolders } from "../../../../lib/useFolders";
|
||||||
|
import { AvatarEditor } from "../../../../components/AvatarEditor";
|
||||||
|
import { useAccount } from "../../../../lib/useAccount";
|
||||||
import { moveTargets } from "../../../../lib/mailCounts";
|
import { moveTargets } from "../../../../lib/mailCounts";
|
||||||
|
|
||||||
interface SieveRule {
|
interface SieveRule {
|
||||||
@@ -51,11 +54,20 @@ interface WebSession {
|
|||||||
current: boolean;
|
current: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
type SettingsTab = "security" | "filters" | "vacation" | "signature" | "language";
|
type SettingsTab = "profile" | "security" | "filters" | "vacation" | "signature" | "language";
|
||||||
|
|
||||||
export default function UserSettingsPage() {
|
export default function UserSettingsPage() {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
const [activeTab, setActiveTab] = useState<SettingsTab>("security");
|
const [activeTab, setActiveTab] = useState<SettingsTab>("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 [mfaEnabled, setMfaEnabled] = useState(false);
|
||||||
const [qrSecret, setQrSecret] = useState<string | null>(null);
|
const [qrSecret, setQrSecret] = useState<string | null>(null);
|
||||||
@@ -285,6 +297,7 @@ export default function UserSettingsPage() {
|
|||||||
separated by everything else. The same panels now sit behind a tab strip,
|
separated by everything else. The same panels now sit behind a tab strip,
|
||||||
in the order the redesign groups them. */
|
in the order the redesign groups them. */
|
||||||
const tabs = [
|
const tabs = [
|
||||||
|
{ id: "profile" as const, label: t("avatar.title"), icon: UserRound },
|
||||||
{ id: "security" as const, label: t("settings.security"), icon: Shield },
|
{ id: "security" as const, label: t("settings.security"), icon: Shield },
|
||||||
{ id: "filters" as const, label: t("settings.sieveTitle"), icon: Filter },
|
{ id: "filters" as const, label: t("settings.sieveTitle"), icon: Filter },
|
||||||
{ id: "vacation" as const, label: t("settings.vacationTitle"), icon: Palmtree },
|
{ id: "vacation" as const, label: t("settings.vacationTitle"), icon: Palmtree },
|
||||||
@@ -336,6 +349,21 @@ export default function UserSettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{activeTab === "profile" && (
|
||||||
|
<section className={panel}>
|
||||||
|
<h2 className="text-[17px] font-medium leading-tight text-slate-100">{t("avatar.title")}</h2>
|
||||||
|
<p className="text-[12.5px] leading-relaxed text-slate-400">{t("avatar.explainer")}</p>
|
||||||
|
<AvatarEditor
|
||||||
|
src={avatarSrc}
|
||||||
|
onSaved={() => {
|
||||||
|
// 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);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
{activeTab === "security" && (
|
{activeTab === "security" && (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
{/* 2FA TOTP SECTION */}
|
{/* 2FA TOTP SECTION */}
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<span
|
||||||
|
className={`relative inline-flex flex-none items-center justify-center overflow-hidden rounded-full uppercase ${className}`}
|
||||||
|
style={{ width: size, height: size, fontSize: Math.max(9, Math.round(size * 0.38)) }}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{!loaded && <span>{initials ?? initialsFor(address)}</span>}
|
||||||
|
{src && !failed && (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img
|
||||||
|
src={src}
|
||||||
|
alt=""
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
onLoad={() => setLoaded(true)}
|
||||||
|
onError={() => setFailed(true)}
|
||||||
|
className={`absolute inset-0 h-full w-full object-cover ${loaded ? "" : "opacity-0"}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<HTMLImageElement | null>(null);
|
||||||
|
const [natural, setNatural] = useState<Size>({ width: 0, height: 0 });
|
||||||
|
const [transform, setTransform] = useState<Transform>({ scale: 1, offsetX: 0, offsetY: 0 });
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||||
|
const fileRef = useRef<HTMLInputElement | null>(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<HTMLCanvasElement>) => {
|
||||||
|
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<HTMLCanvasElement>) => {
|
||||||
|
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<Blob | null>((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 (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{!image ? (
|
||||||
|
<div className="flex flex-wrap items-center gap-4">
|
||||||
|
{/* What is stored today, shown as it will appear elsewhere. */}
|
||||||
|
<span className="flex h-[88px] w-[88px] flex-none items-center justify-center overflow-hidden rounded-full bg-dark-card shadow-edge">
|
||||||
|
{src ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img src={src} alt="" className="h-full w-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<span className="text-[11px] text-slate-500">{t("avatar.none")}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Button variant="secondary" size="sm" onClick={() => fileRef.current?.click()} disabled={busy}>
|
||||||
|
<Upload className="h-3.5 w-3.5" />
|
||||||
|
<span>{src ? t("avatar.replace") : t("avatar.choose")}</span>
|
||||||
|
</Button>
|
||||||
|
{src && (
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => void remove()} disabled={busy}>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
<span>{t("common.delete")}</span>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center gap-3">
|
||||||
|
<div
|
||||||
|
className="relative overflow-hidden rounded-full shadow-edge"
|
||||||
|
style={{ width: VIEWPORT, height: VIEWPORT }}
|
||||||
|
>
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
width={VIEWPORT}
|
||||||
|
height={VIEWPORT}
|
||||||
|
onPointerDown={onPointerDown}
|
||||||
|
onPointerMove={onPointerMove}
|
||||||
|
onPointerUp={endDrag}
|
||||||
|
onPointerCancel={endDrag}
|
||||||
|
className="block cursor-grab touch-none active:cursor-grabbing"
|
||||||
|
aria-label={t("avatar.dragToPosition")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex w-full max-w-[288px] items-center gap-2.5">
|
||||||
|
<ZoomOut className="h-4 w-4 flex-none text-slate-400" />
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={range.min}
|
||||||
|
max={range.max}
|
||||||
|
step={(range.max - range.min) / 200 || 0.01}
|
||||||
|
value={transform.scale}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
<ZoomIn className="h-4 w-4 flex-none text-slate-400" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-[11.5px] text-slate-500">{t("avatar.dragToPosition")}</p>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center justify-center gap-2">
|
||||||
|
<Button variant="secondary" size="sm" onClick={() => setImage(null)} disabled={busy}>
|
||||||
|
{t("common.cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button variant="primary" size="sm" onClick={() => void save()} disabled={busy}>
|
||||||
|
{busy ? t("common.loading") : t("common.save")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-xl bg-rose-900/60 px-3.5 py-2.5 text-[12.5px] text-rose-200 shadow-edge">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<input
|
||||||
|
ref={fileRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/png,image/jpeg,image/webp"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => {
|
||||||
|
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 = "";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in new issue
Block a user