| 1 | import { Hono } from "hono"; |
| 2 | import type { Env } from "../env.js"; |
| 3 | import { requireAccount, type AppContext, type AppVars } from "../auth/principal.js"; |
| 4 | import type { Account } from "../lib/directory.js"; |
| 5 | import { AVATAR_SIZES, MAX_AVATAR_BYTES, makeAvatarImages, type AvatarSize } from "../lib/image.js"; |
| 6 | import { HttpError, isValidName } from "../lib/util.js"; |
| 7 | |
| 8 | /** |
| 9 | * Profile pictures for people, organizations and agents. |
| 10 | * |
| 11 | * GET /api/avatars/:login?s=64|128|256[&v=<version>] the picture (WebP) or generated initials (SVG) |
| 12 | * PUT /api/v3/user/avatar raw image body (Diffinery extension) |
| 13 | * DELETE /api/v3/user/avatar |
| 14 | * PUT /api/v3/orgs/:org/avatar org owners |
| 15 | * DELETE /api/v3/orgs/:org/avatar |
| 16 | * |
| 17 | * Uploads become square WebPs in R2 at avatars/<account id>/<version>/<size>.webp, and |
| 18 | * accounts.avatar_version points at the current set (NULL: initials). Every upload gets a new |
| 19 | * version, so `?v=` URLs are immutable; the old version's files are deleted. |
| 20 | */ |
| 21 | export const avatars = new Hono<{ Bindings: Env; Variables: AppVars }>(); |
| 22 | |
| 23 | // ---------------------------------------------------------------- helpers shared with the web UI |
| 24 | |
| 25 | /** Stable hue for a login's initials avatar. */ |
| 26 | export function avatarHue(login: string): number { |
| 27 | let h = 0; |
| 28 | for (let i = 0; i < login.length; i++) h = (h * 31 + login.charCodeAt(i)) >>> 0; |
| 29 | return h % 360; |
| 30 | } |
| 31 | |
| 32 | export const avatarInitial = (login: string): string => (login.replace(/[^A-Za-z0-9]/g, "")[0] ?? "?").toUpperCase(); |
| 33 | |
| 34 | /** The stored size for a requested pixel size: the smallest one at least that big. */ |
| 35 | export function avatarSize(px: number): AvatarSize { |
| 36 | return AVATAR_SIZES.find((s) => px <= s) ?? AVATAR_SIZES[AVATAR_SIZES.length - 1]!; |
| 37 | } |
| 38 | |
| 39 | /** Path of a login's avatar image; pass the account's avatar_version so a new upload shows at once. */ |
| 40 | export function avatarPath(login: string, size?: number, version?: number | null): string { |
| 41 | const q = new URLSearchParams(); |
| 42 | if (size && size !== 128) q.set("s", String(avatarSize(size))); |
| 43 | if (version) q.set("v", String(version)); |
| 44 | const qs = q.toString(); |
| 45 | return `/api/avatars/${encodeURIComponent(login)}${qs ? `?${qs}` : ""}`; |
| 46 | } |
| 47 | |
| 48 | export const avatarUrl = (c: AppContext, login: string, version?: number | null): string => `${c.env.PUBLIC_URL.replace(/\/$/, "")}${avatarPath(login, undefined, version)}`; |
| 49 | |
| 50 | /** The initials avatar as a square SVG, matching the `.av` look (font size as at 20, 40 and 96 CSS px). */ |
| 51 | export function initialsSvg(login: string, size: AvatarSize): string { |
| 52 | const font = size <= 64 ? 55 : size <= 128 ? 45 : 42; |
| 53 | return ( |
| 54 | `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 100 100">` + |
| 55 | `<rect width="100" height="100" fill="hsl(${avatarHue(login)},55%,55%)"/>` + |
| 56 | `<text x="50" y="50" dy=".35em" text-anchor="middle" fill="#fff" font-family="-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue','Noto Sans',Arial,sans-serif" font-size="${font}" font-weight="700">${avatarInitial(login)}</text>` + |
| 57 | `</svg>` |
| 58 | ); |
| 59 | } |
| 60 | |
| 61 | const objectKey = (accountId: string, version: number, size: AvatarSize) => `avatars/${accountId}/${version}/${size}.webp`; |
| 62 | |
| 63 | const SHORT = "public, max-age=600, stale-while-revalidate=86400"; |
| 64 | const IMMUTABLE = "public, max-age=86400, immutable"; |
| 65 | /** What the Cache API keeps: short, because a purge only reaches the colo it runs in. */ |
| 66 | const EDGE = "public, max-age=600"; |
| 67 | |
| 68 | const edgeCache = (): Cache | null => (typeof caches === "undefined" ? null : (caches as unknown as { default: Cache }).default); |
| 69 | |
| 70 | function cacheKey(origin: string, login: string, size: AvatarSize, version: number | null): Request { |
| 71 | return new Request(`${origin}/api/avatars/${encodeURIComponent(login)}?s=${size}${version ? `&v=${version}` : ""}`); |
| 72 | } |
| 73 | |
| 74 | const origin = (c: AppContext) => new URL(c.req.url).origin; |
| 75 | |
| 76 | /** Forget cached copies of a login's avatar (every size, without `v` and with each given version). */ |
| 77 | export async function purgeAvatarCache(c: AppContext, login: string, versions: Array<number | null | undefined>): Promise<void> { |
| 78 | const cache = edgeCache(); |
| 79 | if (!cache) return; |
| 80 | const vs = [null, ...new Set(versions.filter((v): v is number => !!v))]; |
| 81 | await Promise.all(AVATAR_SIZES.flatMap((s) => vs.map((v) => cache.delete(cacheKey(origin(c), login, s, v)).catch(() => false)))); |
| 82 | } |
| 83 | |
| 84 | /** Delete every stored file of an account's avatars except the version to keep. */ |
| 85 | async function deleteStored(env: Env, accountId: string, keep: number | null): Promise<void> { |
| 86 | const prefix = `avatars/${accountId}/`; |
| 87 | const listed = await env.BUCKET.list({ prefix, limit: 1000 }); |
| 88 | const stale = listed.objects.map((o) => o.key).filter((k) => keep === null || !k.startsWith(`${prefix}${keep}/`)); |
| 89 | if (stale.length) await env.BUCKET.delete(stale); |
| 90 | } |
| 91 | |
| 92 | /** Store a new picture for an account (a user, an org or an agent) and return its version. */ |
| 93 | export async function setAvatar(c: AppContext, account: Account, bytes: Uint8Array): Promise<number> { |
| 94 | const images = await makeAvatarImages(bytes); |
| 95 | const version = Math.max((account.avatar_version ?? 0) + 1, Math.floor(Date.now() / 1000)); |
| 96 | await Promise.all([...images].map(([size, webp]) => c.env.BUCKET.put(objectKey(account.id, version, size), webp, { httpMetadata: { contentType: "image/webp", cacheControl: IMMUTABLE } }))); |
| 97 | await c.get("dir").setAvatarVersion(account.id, version); |
| 98 | await Promise.all([purgeAvatarCache(c, account.login, [account.avatar_version, version]), deleteStored(c.env, account.id, version)]); |
| 99 | return version; |
| 100 | } |
| 101 | |
| 102 | /** Go back to initials. */ |
| 103 | export async function removeAvatar(c: AppContext, account: Account): Promise<void> { |
| 104 | await c.get("dir").setAvatarVersion(account.id, null); |
| 105 | await Promise.all([purgeAvatarCache(c, account.login, [account.avatar_version]), deleteStored(c.env, account.id, null)]); |
| 106 | } |
| 107 | |
| 108 | /** Read a request body, refusing more than `limit` bytes without buffering them. */ |
| 109 | async function readLimited(req: Request, limit: number): Promise<Uint8Array> { |
| 110 | const tooBig = () => new HttpError(413, "That picture is larger than 5 MB. Use a smaller file."); |
| 111 | if (Number(req.headers.get("content-length") ?? 0) > limit) throw tooBig(); |
| 112 | if (!req.body) throw new HttpError(422, "Send the picture as the request body."); |
| 113 | const parts: Uint8Array[] = []; |
| 114 | let total = 0; |
| 115 | const reader = req.body.getReader(); |
| 116 | for (;;) { |
| 117 | const { done, value } = await reader.read(); |
| 118 | if (done) break; |
| 119 | total += value.byteLength; |
| 120 | if (total > limit) { |
| 121 | await reader.cancel().catch(() => {}); |
| 122 | throw tooBig(); |
| 123 | } |
| 124 | parts.push(value); |
| 125 | } |
| 126 | const out = new Uint8Array(total); |
| 127 | let at = 0; |
| 128 | for (const p of parts) { |
| 129 | out.set(p, at); |
| 130 | at += p.byteLength; |
| 131 | } |
| 132 | return out; |
| 133 | } |
| 134 | |
| 135 | // ---------------------------------------------------------------- GET /api/avatars/:login |
| 136 | |
| 137 | avatars.get("/api/avatars/:login", async (c) => { |
| 138 | const login = c.req.param("login"); |
| 139 | const size = avatarSize(Number(c.req.query("s")) || 128); |
| 140 | const v = /^\d{1,15}$/.test(c.req.query("v") ?? "") ? Number(c.req.query("v")) : null; |
| 141 | const cache = edgeCache(); |
| 142 | const key = cacheKey(origin(c), login, size, v); |
| 143 | const hit = await cache?.match(key); |
| 144 | if (hit) { |
| 145 | const res = new Response(hit.body, hit); |
| 146 | res.headers.set("cache-control", v ? IMMUTABLE : SHORT); |
| 147 | return res; |
| 148 | } |
| 149 | |
| 150 | // Unknown logins get initials too, so the route does not tell who exists. |
| 151 | const account = isValidName(login) ? await c.get("dir").accountByLogin(login) : null; |
| 152 | const current = account?.avatar_version ?? null; |
| 153 | const obj = account && current ? await c.env.BUCKET.get(objectKey(account.id, current, size)) : null; |
| 154 | const headers = new Headers({ "x-content-type-options": "nosniff" }); |
| 155 | let body: ArrayBuffer | string; |
| 156 | if (obj) { |
| 157 | body = await obj.arrayBuffer(); |
| 158 | headers.set("content-type", "image/webp"); |
| 159 | } else { |
| 160 | body = initialsSvg(login, size); |
| 161 | headers.set("content-type", "image/svg+xml; charset=utf-8"); |
| 162 | headers.set("content-security-policy", "default-src 'none'; style-src 'unsafe-inline'"); |
| 163 | } |
| 164 | // A `v` that is not the current version (an old link, or a guess) is served but neither cached here nor marked immutable. |
| 165 | const versioned = v !== null && v === current; |
| 166 | if (v === null || versioned) { |
| 167 | const stored = new Response(body, { headers }); |
| 168 | stored.headers.set("cache-control", EDGE); |
| 169 | c.executionCtx.waitUntil(cache?.put(key, stored).catch(() => {}) ?? Promise.resolve()); |
| 170 | } |
| 171 | headers.set("cache-control", versioned ? IMMUTABLE : SHORT); |
| 172 | return new Response(body, { headers }); |
| 173 | }); |
| 174 | |
| 175 | // ---------------------------------------------------------------- REST |
| 176 | |
| 177 | /** Only full-account credentials change a profile: browser sessions, or tokens with write or admin scope for all repositories. */ |
| 178 | function requireProfileWrite(c: AppContext): Account { |
| 179 | const me = requireAccount(c); |
| 180 | const p = c.get("principal"); |
| 181 | if (p.type === "account" && p.via === "token" && (p.repoIds || !p.scopes.some((s) => s === "write" || s === "admin"))) { |
| 182 | throw new HttpError(403, "changing a profile picture needs a write or admin token that is not limited to repositories"); |
| 183 | } |
| 184 | return me; |
| 185 | } |
| 186 | |
| 187 | async function orgForAvatar(c: AppContext, me: Account): Promise<Account> { |
| 188 | const dir = c.get("dir"); |
| 189 | const org = await dir.accountByLogin(c.req.param("org") ?? ""); |
| 190 | if (!org || org.kind !== "org") throw new HttpError(404, "not found"); |
| 191 | const role = await dir.orgRole(org.id, me.id); |
| 192 | if (!role && !me.site_admin) throw new HttpError(404, "not found"); |
| 193 | // Like the organization's name, its picture is the owners' to change (the web settings page agrees). |
| 194 | if (role !== "owner" && !me.site_admin) throw new HttpError(403, "only organization owners can change its picture"); |
| 195 | return org; |
| 196 | } |
| 197 | |
| 198 | function audit(c: AppContext, me: Account, action: "avatar.update" | "avatar.remove", target: Account): void { |
| 199 | c.executionCtx.waitUntil(c.get("dir").audit({ actorId: me.id, actorLogin: me.login, action, target: target.login, detail: { kind: target.kind, via: "api" } })); |
| 200 | } |
| 201 | |
| 202 | avatars.put("/api/v3/user/avatar", async (c) => { |
| 203 | const me = requireProfileWrite(c); |
| 204 | const version = await setAvatar(c, me, await readLimited(c.req.raw, MAX_AVATAR_BYTES)); |
| 205 | audit(c, me, "avatar.update", me); |
| 206 | return c.json({ avatar_url: avatarUrl(c, me.login, version) }); |
| 207 | }); |
| 208 | |
| 209 | avatars.delete("/api/v3/user/avatar", async (c) => { |
| 210 | const me = requireProfileWrite(c); |
| 211 | await removeAvatar(c, me); |
| 212 | audit(c, me, "avatar.remove", me); |
| 213 | return c.json({ avatar_url: avatarUrl(c, me.login) }); |
| 214 | }); |
| 215 | |
| 216 | avatars.put("/api/v3/orgs/:org/avatar", async (c) => { |
| 217 | const me = requireProfileWrite(c); |
| 218 | const org = await orgForAvatar(c, me); |
| 219 | const version = await setAvatar(c, org, await readLimited(c.req.raw, MAX_AVATAR_BYTES)); |
| 220 | audit(c, me, "avatar.update", org); |
| 221 | return c.json({ avatar_url: avatarUrl(c, org.login, version) }); |
| 222 | }); |
| 223 | |
| 224 | avatars.delete("/api/v3/orgs/:org/avatar", async (c) => { |
| 225 | const me = requireProfileWrite(c); |
| 226 | const org = await orgForAvatar(c, me); |
| 227 | await removeAvatar(c, org); |
| 228 | audit(c, me, "avatar.remove", org); |
| 229 | return c.json({ avatar_url: avatarUrl(c, org.login) }); |
| 230 | }); |