Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

230 lines · 10.9 KBRaw History
1import { Hono } from "hono";
2import type { Env } from "../env.js";
3import { requireAccount, type AppContext, type AppVars } from "../auth/principal.js";
4import type { Account } from "../lib/directory.js";
5import { AVATAR_SIZES, MAX_AVATAR_BYTES, makeAvatarImages, type AvatarSize } from "../lib/image.js";
6import { 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 */
21export 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. */
26export 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
32export 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. */
35export 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. */
40export 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
48export 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). */
51export 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
61const objectKey = (accountId: string, version: number, size: AvatarSize) => `avatars/${accountId}/${version}/${size}.webp`;
62
63const SHORT = "public, max-age=600, stale-while-revalidate=86400";
64const IMMUTABLE = "public, max-age=86400, immutable";
65/** What the Cache API keeps: short, because a purge only reaches the colo it runs in. */
66const EDGE = "public, max-age=600";
67
68const edgeCache = (): Cache | null => (typeof caches === "undefined" ? null : (caches as unknown as { default: Cache }).default);
69
70function 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
74const 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). */
77export 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. */
85async 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. */
93export 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. */
103export 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. */
109async 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
137avatars.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. */
178function 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
187async 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
198function 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
202avatars.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
209avatars.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
216avatars.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
224avatars.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});