import { isTakenDown } from "~/lib/account-disable"; import { env } from "cloudflare:workers"; import { first, run } from "./db"; import { randomId } from "./crypto"; import { enforceRateLimit } from "./limits"; import { canAdministerAccount } from "./repos"; import { viewerFromSessionCookie } from "./auth"; import { userContentOriginFor } from "./usercontent-origin"; const SERVE_PATH = /^\/avatars\/([a-z0-9]+-[a-z0-9]+)$/; const UPLOAD_PATH = "/settings/avatar"; const BY_NAME_PATH = /^\/avatars\/u\/([^/]+)$/; const BY_NAME_TTL_SECONDS = 300; const COMMON_SIZES = [40, 64, 80, 160]; const MAX_AVATAR_BYTES = 1024 * 1024; const MIN_SIZE = 16; const MAX_SIZE = 460; const DEFAULT_SIZE = 160; const ALLOWED_TYPES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]); const IMMUTABLE = "public, max-age=31536000, immutable"; function text(status: number, message: string): Response { return new Response(message, { status, headers: { "content-type": "text/plain" } }); } function clampSize(raw: string | null): number { const parsed = Number(raw ?? DEFAULT_SIZE); if (!Number.isFinite(parsed)) return DEFAULT_SIZE; return Math.min(MAX_SIZE, Math.max(MIN_SIZE, Math.round(parsed))); } /** Serves avatars resized by the Images binding; keys are content-versioned, so responses cache forever. */ async function serveAvatar(request: Request, key: string, url: URL): Promise { // Keys start with the account id; a taken-down account's avatar stops serving even from the colo cache. if (await avatarTakenDown(key)) return text(404, "Not found"); const size = clampSize(url.searchParams.get("s")); const cacheKey = new Request(`${url.origin}/avatars/${key}?s=${size}`); const cache = await caches.open("avatars"); // Keys are content-versioned, so the key and size identify the bytes. Browsers revalidate every load (so a // takedown applies at once) and get a bodiless 304 while the picture is unchanged. const etag = `"${key}-${size}"`; const browserHeaders = { etag, "cache-control": "no-cache" }; if ( request.headers .get("if-none-match") ?.split(",") .some((tag) => tag.trim().replace(/^W\//, "") === etag) ) { return new Response(null, { status: 304, headers: browserHeaders }); } const hit = await cache.match(cacheKey); if (hit) return withHeaders(hit, browserHeaders); const object = await env.BUCKET.get(`avatars/${key}`); if (!object) return text(404, "Not found"); const transformed = await env.IMAGES.input(object.body) .transform({ width: size, height: size, fit: "cover" }) .output({ format: "image/webp" }); const response = transformed.response(); const out = new Response(response.body, { headers: { "content-type": "image/webp", "cache-control": IMMUTABLE, "x-content-type-options": "nosniff" }, }); if (request.method === "GET") await cache.put(cacheKey, out.clone()); return withHeaders(out, browserHeaders); } function withHeaders(response: Response, headers: Record): Response { const out = new Response(response.body, response); for (const [name, value] of Object.entries(headers)) out.headers.set(name, value); return out; } const TAKEDOWN_CACHE_MS = 60_000; const TAKEDOWN_CACHE_MAX = 5000; const takedowns = new Map(); /** * Per-isolate, 60 s: avatars are hot, so the takedown check must not cost a D1 read per image. A takedown shows up * within a minute; a failed lookup refuses (fails closed) and is not cached. */ export async function avatarTakenDown(key: string): Promise { const dash = key.indexOf("-"); // Every key is `-`; anything else can't be attributed, so it is refused. if (dash <= 0) return true; const accountId = key.slice(0, dash); const hit = takedowns.get(accountId); if (hit && Date.now() - hit.at < TAKEDOWN_CACHE_MS) return hit.down; let down: boolean; try { const account = await first<{ disabled_at: number | null; disabled_reason: string | null }>( "SELECT disabled_at, disabled_reason FROM accounts WHERE id = ?", accountId, ); down = account !== null && isTakenDown(account); } catch (error) { console.warn("Avatar takedown check failed", accountId, error); return true; } takedowns.delete(accountId); takedowns.set(accountId, { down, at: Date.now() }); if (takedowns.size > TAKEDOWN_CACHE_MAX) { const oldest = takedowns.keys().next().value; if (oldest !== undefined) takedowns.delete(oldest); } return down; } function byNameCacheKey(origin: string, name: string, size: number): Request { return new Request(`${origin}/avatars/u/${encodeURIComponent(name.toLowerCase())}?s=${size}`); } /** Stable per-account avatar URL so every Avatar can load a picture by name; 404 means "use the generated lava lamp". */ async function serveByName(request: Request, name: string, url: URL): Promise { const size = clampSize(url.searchParams.get("s")); const cache = await caches.open("avatars"); const cacheKey = byNameCacheKey(url.origin, name, size); const hit = await cache.match(cacheKey); if (hit) return hit; const account = await first<{ avatar_key: string | null }>( `SELECT avatar_key FROM accounts WHERE name = ? AND NOT (disabled_at IS NOT NULL AND disabled_reason = 'admin')`, name, ); // The edge copy below is purged on upload; browsers revalidate so a new picture shows on the next load. const headers = { "cache-control": "no-cache" }; const response = account?.avatar_key ? new Response(null, { status: 302, headers: { ...headers, location: `/avatars/${account.avatar_key}?s=${size}` } }) : new Response(null, { status: 404, headers }); if (request.method === "GET") { const cached = response.clone(); const edge = new Response(cached.body, cached); edge.headers.set("cache-control", `public, max-age=${BY_NAME_TTL_SECONDS}`); await cache.put(cacheKey, edge); } return response; } async function uploadAvatar(request: Request, url: URL): Promise { if (request.headers.get("x-gitfor-csrf") !== "1") return text(403, "Missing CSRF header"); const viewer = await viewerFromSessionCookie(request); if (!viewer) return text(401, "Sign in to upload an avatar"); await enforceRateLimit("WRITE_LIMITER", `user:${viewer.id}`); const accountName = url.searchParams.get("account") ?? viewer.name; const account = await first<{ id: string; avatar_key: string | null }>( "SELECT id, avatar_key FROM accounts WHERE name = ?", accountName, ); if (!account || !(await canAdministerAccount(account.id, viewer))) return text(403, "Forbidden"); const contentType = request.headers.get("content-type") ?? ""; const size = Number(request.headers.get("content-length") ?? "0"); if (!ALLOWED_TYPES.has(contentType)) return text(415, "Upload a PNG, JPEG, GIF or WebP image"); if (!request.body || !Number.isFinite(size) || size <= 0 || size > MAX_AVATAR_BYTES) { return text(413, "Avatars must be at most 1 MB"); } const bytes = new Uint8Array(await request.arrayBuffer()); if (bytes.byteLength > MAX_AVATAR_BYTES) return text(413, "Avatars must be at most 1 MB"); const info = await env.IMAGES.info(new Blob([bytes]).stream()).catch(() => null); if (!info || !("format" in info) || !ALLOWED_TYPES.has(info.format)) return text(415, "That file is not a supported image"); const key = `${account.id}-${randomId(8)}`; await env.BUCKET.put(`avatars/${key}`, bytes, { httpMetadata: { contentType } }); await run("UPDATE accounts SET avatar_key = ? WHERE id = ?", key, account.id); if (account.avatar_key) await env.BUCKET.delete(`avatars/${account.avatar_key}`); const cache = await caches.open("avatars"); const origins = [url.origin, userContentOriginFor(url)].filter((origin): origin is string => Boolean(origin)); await Promise.all( origins.flatMap((origin) => COMMON_SIZES.map((size) => cache.delete(byNameCacheKey(origin, accountName, size)))), ); return Response.json({ url: `/avatars/${key}` }); } export async function handleAvatarRoutes(request: Request, url: URL): Promise { if (url.pathname === UPLOAD_PATH && request.method === "POST") return uploadAvatar(request, url); const byName = BY_NAME_PATH.exec(url.pathname); if (byName && (request.method === "GET" || request.method === "HEAD")) { return serveByName(request, decodeURIComponent(byName[1]), url); } const match = SERVE_PATH.exec(url.pathname); if (match && (request.method === "GET" || request.method === "HEAD")) return serveAvatar(request, match[1], url); return null; }