Skip to content
111 linesCodeBlameRaw
1/**
2 * The usercontent origin (app/lib/usercontent.ts): repository files and
3 * uploaded avatars, on g1tusercontent.com for g1t.sh. This answers before
4 * anything of the site's runs, and reads no cookie and sets none: nothing
5 * here knows who is asking, only what the address and its token say.
6 */
7import { reposClient } from "@g1t/contracts";
8
9import { MAX_RAW_BYTES, isCommit, parseRawPath, rawHeaders, verifyRaw } from "../app/lib/usercontent";
10
11/** An uploaded avatar, by the SHA-256 of its bytes. */
12export const AVATAR_PATH = /^\/avatars\/([0-9a-f]{64})$/;
13/** The only types identity stores, having checked each image's bytes. */
14const AVATAR_TYPES = new Set(["image/png", "image/jpeg", "image/webp", "image/gif"]);
15
16function plain(status: number, message: string, cache = "no-store"): Response {
17 return new Response(`${message}\n`, {
18 status,
19 headers: {
20 "content-type": "text/plain; charset=utf-8",
21 "cache-control": cache,
22 "x-content-type-options": "nosniff",
23 "content-security-policy": "default-src 'none'; sandbox",
24 },
25 });
26}
27
28/** Answers a request for `path`, the part of its address under the usercontent origin. */
29export async function serveUsercontent(env: Env, ctx: ExecutionContext, request: Request, path: string): Promise<Response> {
30 const method = request.method;
31 if (method !== "GET" && method !== "HEAD") {
32 return new Response("Method not allowed\n", { status: 405, headers: { allow: "GET, HEAD" } });
33 }
34 if (path === "/robots.txt") {
35 return new Response("User-agent: *\nDisallow: /\n", { headers: { "content-type": "text/plain; charset=utf-8", "cache-control": "public, max-age=86400" } });
36 }
37 const avatar = AVATAR_PATH.exec(path);
38 if (avatar) return serveAvatar(env, ctx, method, avatar[1]!, new URL(request.url).origin);
39 const file = parseRawPath(path);
40 if (file) return serveRaw(env, request, method, file);
41 return plain(404, "Not found", "public, max-age=300");
42}
43
44/**
45 * A repository's file. A public repository's to anyone; a private one's
46 * only with a token for this very file (routes/repo/raw.ts makes them).
47 */
48async function serveRaw(env: Env, request: Request, method: string, file: NonNullable<ReturnType<typeof parseRawPath>>): Promise<Response> {
49 const repos = reposClient(env.REPOS);
50 const token = new URL(request.url).searchParams.get("token");
51 let repoId: string | null = null;
52 let isPublic = false;
53 if (token) {
54 if (!env.USERCONTENT_KEY) return plain(404, "Not found");
55 repoId = await verifyRaw(env.USERCONTENT_KEY, file, token);
56 if (!repoId) return plain(403, "This address has expired. Open the file on g1t again for a new one.");
57 } else {
58 // No viewer: only a public repository answers.
59 const found = await repos.get({ namespace: file.owner, name: file.repo }, null).catch(() => null);
60 if (!found?.ok) return plain(404, "There is no such file, or it is not public.", "public, max-age=60");
61 repoId = found.value.id;
62 isPublic = true;
63 }
64 const raw = await repos.rawFile(repoId, file.ref, file.path, MAX_RAW_BYTES).catch(() => null);
65 if (!raw) return plain(404, `There is no such file, or it is over ${MAX_RAW_BYTES / 1024 / 1024} MB. Clone the repository for it.`, "public, max-age=60");
66 const bytes = Uint8Array.from(atob(raw.data), (c) => c.charCodeAt(0));
67 const headers = rawHeaders(file.path, bytes);
68 // A commit's files never change; a branch's or tag's may.
69 const lasting = isCommit(file.ref);
70 headers.set(
71 "cache-control",
72 isPublic ? (lasting ? "public, max-age=31536000, immutable" : "public, max-age=60") : lasting ? "private, max-age=3600" : "private, max-age=60",
73 );
74 if (lasting) headers.set("etag", `"${file.ref}"`);
75 return new Response(method === "HEAD" ? null : bytes, { headers });
76}
77
78/**
79 * An uploaded avatar. Its address is its hash, so it never changes and is
80 * kept for good: each data centre keeps it in its cache after the first
81 * view, and storage is read about once per place, not once per visitor.
82 * It is served as nothing but an image: the stored type, no sniffing, and
83 * a policy that lets nothing in it run.
84 */
85async function serveAvatar(env: Env, ctx: ExecutionContext, method: string, hash: string, origin: string): Promise<Response> {
86 // The Workers runtime's own cache, which the DOM types do not know.
87 const cache = (caches as unknown as { default: Cache }).default;
88 const key = new Request(`${origin}/avatars/${hash}`, { method: "GET" });
89 const cached = await cache.match(key);
90 if (cached) {
91 return method === "HEAD" ? new Response(null, { headers: cached.headers }) : cached;
92 }
93 const { value, metadata } = await env.AVATARS.getWithMetadata<{ contentType?: string }>(hash, {
94 type: "arrayBuffer",
95 cacheTtl: 86400,
96 });
97 const contentType = metadata?.contentType;
98 if (!value || !contentType || !AVATAR_TYPES.has(contentType)) {
99 return plain(404, "Not found", "public, max-age=60");
100 }
101 const headers = {
102 "content-type": contentType,
103 "content-length": String(value.byteLength),
104 "cache-control": "public, max-age=31536000, immutable",
105 "x-content-type-options": "nosniff",
106 "content-security-policy": "default-src 'none'; sandbox",
107 "cross-origin-resource-policy": "cross-origin",
108 };
109 ctx.waitUntil(cache.put(key, new Response(value, { headers })));
110 return new Response(method === "HEAD" ? null : value, { headers });
111}