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 { const size = clampSize(url.searchParams.get("s")); const cacheKey = new Request(`${url.origin}/avatars/${key}?s=${size}`); const cache = await caches.open("avatars"); const hit = await cache.match(cacheKey); if (hit) return hit; 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 out; } 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 initials". */ 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 = ?", name); const headers = { "cache-control": `public, max-age=${BY_NAME_TTL_SECONDS}` }; 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") await cache.put(cacheKey, response.clone()); 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; }