180 lines · 8.4 KB
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<Response> {
  // 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.