Revert "revert: foto de perfil e BIMI, para restaurar o servico"

Repoe as duas funcionalidades. A reversao foi uma precaucao enquanto o site
estava em baixo, e acabou por ser ela a provar que a culpa nao era delas: o
502 continuou depois de reverter.

A avaria era o servico web nao declarar porta nenhuma, o que levava o Coolify
a gerar um servico Traefik apontado ao porto 80. Fica corrigida no commit
anterior, com expose 3000.

As migracoes 0012 e 0013 nunca sairam do repositorio nem da base de dados, por
isso o codigo reposto encontra as tabelas que espera.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Lucas RayanandClaude Opus 5 committed 2026-09-07 22:29:12 -03:00
1 parent e93fa19bdd
commit c59548e311
26 files changed
+1899 -21

No files matched your search

+58
View File
@@ -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" });
});
});
+62
View File
@@ -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 };
}
+60
View File
@@ -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<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
/** Records an administrative action. PLAN.md section 14.3 requires every
+58
View File
@@ -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("<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 () => {
const res = await get(`/api/v1/admin/mailboxes/44444444-4444-4444-4444-444444444444/aliases`, admin);
expect(res.status).toBe(404);
+120
View File
@@ -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<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> {
const raw = await readBody(req);
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
async function login(req: IncomingMessage, res: ServerResponse, surface: "user" | "admin"): Promise<void> {