Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t.sh
156 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, PDF_POLICY, USERCONTENT_POLICY, 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/** A workspace's custom emoji, by the SHA-256 of its bytes: kept by chat under `emoji/<hash>` in the same namespace. */
14export const EMOJI_PATH = /^\/emoji\/([0-9a-f]{64})$/;
15/** A project's uploaded production image, by the SHA-256 of its bytes: kept by projects under `production/<hash>` in the same namespace. */
16export const PRODUCTION_IMAGE_PATH = /^\/production-images\/([0-9a-f]{64})$/;
17/**
18 * An image put in an issue's, a pull request's or a comment's Markdown, by its
19 * random id (256 bits, so its address is its permission): kept by work under
20 * `attachments/<id>` in the same namespace (services/work/src/attachments.rs).
21 */
22export const ATTACHMENT_PATH = /^\/attachments\/([0-9a-f]{64})$/;
23/** A file put in a Docs page, by its random key: kept by the artifacts service (services/artifacts). */
24export const DOCS_FILE_PATH = /^\/docs-files\/([0-9a-f]{64})$/;
25/** The only types identity stores, having checked each image's bytes. */
26const AVATAR_TYPES = new Set(["image/png", "image/jpeg", "image/webp", "image/gif"]);
27
28function plain(status: number, message: string, cache = "no-store"): Response {
29 return new Response(`${message}\n`, {
30 status,
31 headers: {
32 "content-type": "text/plain; charset=utf-8",
33 "cache-control": cache,
34 "x-content-type-options": "nosniff",
35 "content-security-policy": "default-src 'none'; sandbox",
36 },
37 });
38}
39
40/** Answers a request for `path`, the part of its address under the usercontent origin. */
41export async function serveUsercontent(env: Env, ctx: ExecutionContext, request: Request, path: string): Promise<Response> {
42 const method = request.method;
43 if (method !== "GET" && method !== "HEAD") {
44 return new Response("Method not allowed\n", { status: 405, headers: { allow: "GET, HEAD" } });
45 }
46 if (path === "/robots.txt") {
47 return new Response("User-agent: *\nDisallow: /\n", { headers: { "content-type": "text/plain; charset=utf-8", "cache-control": "public, max-age=86400" } });
48 }
49 const avatar = AVATAR_PATH.exec(path);
50 if (avatar) return serveAvatar(env, ctx, method, avatar[1]!, new URL(request.url).origin);
51 const emoji = EMOJI_PATH.exec(path);
52 if (emoji) return serveAvatar(env, ctx, method, `emoji/${emoji[1]!}`, new URL(request.url).origin);
53 const production = PRODUCTION_IMAGE_PATH.exec(path);
54 if (production) return serveAvatar(env, ctx, method, `production/${production[1]!}`, new URL(request.url).origin);
55 const attachment = ATTACHMENT_PATH.exec(path);
56 if (attachment) return serveAvatar(env, ctx, method, `attachments/${attachment[1]!}`, new URL(request.url).origin);
57 const docsFile = DOCS_FILE_PATH.exec(path);
58 if (docsFile) return serveDocsFile(env, method, docsFile[1]!);
59 const file = parseRawPath(path);
60 if (file) return serveRaw(env, request, method, file);
61 return plain(404, "Not found", "public, max-age=300");
62}
63
64/**
65 * A repository's file. A public repository's to anyone; a private one's
66 * only with a token for this very file (routes/repo/raw.ts makes them).
67 */
68async function serveRaw(env: Env, request: Request, method: string, file: NonNullable<ReturnType<typeof parseRawPath>>): Promise<Response> {
69 const repos = reposClient(env.REPOS);
70 const token = new URL(request.url).searchParams.get("token");
71 let repoId: string | null = null;
72 let isPublic = false;
73 if (token) {
74 if (!env.USERCONTENT_KEY) return plain(404, "Not found");
75 repoId = await verifyRaw(env.USERCONTENT_KEY, file, token);
76 if (!repoId) return plain(403, "This address has expired. Open the file on g1t again for a new one.");
77 } else {
78 // No viewer: only a public repository answers.
79 const found = await repos.get({ namespace: file.owner, name: file.repo }, null).catch(() => null);
80 if (!found?.ok) return plain(404, "There is no such file, or it is not public.", "public, max-age=60");
81 repoId = found.value.id;
82 isPublic = true;
83 }
84 const raw = await repos.rawFile(repoId, file.ref, file.path, MAX_RAW_BYTES).catch(() => null);
85 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");
86 const bytes = Uint8Array.from(atob(raw.data), (c) => c.charCodeAt(0));
87 const headers = rawHeaders(file.path, bytes);
88 // A commit's files never change; a branch's or tag's may.
89 const lasting = isCommit(file.ref);
90 headers.set(
91 "cache-control",
92 isPublic ? (lasting ? "public, max-age=31536000, immutable" : "public, max-age=60") : lasting ? "private, max-age=3600" : "private, max-age=60",
93 );
94 if (lasting) headers.set("etag", `"${file.ref}"`);
95 return new Response(method === "HEAD" ? null : bytes, { headers });
96}
97
98/**
99 * An uploaded avatar, a custom emoji (`key` is then `emoji/<hash>`), a
100 * production image (`production/<hash>`) or an attachment
101 * (`attachments/<id>`, at the same path). Its
102 * address is its hash, so it never changes and is
103 * kept for good: each data centre keeps it in its cache after the first
104 * view, and storage is read about once per place, not once per visitor.
105 * It is served as nothing but an image: the stored type, no sniffing, and
106 * a policy that lets nothing in it run.
107 */
108async function serveAvatar(env: Env, ctx: ExecutionContext, method: string, key: string, origin: string): Promise<Response> {
109 // The Workers runtime's own cache, which the DOM types do not know.
110 const cache = (caches as unknown as { default: Cache }).default;
111 const path = key.startsWith("emoji/") || key.startsWith("attachments/") ? key : key.startsWith("production/") ? `production-images/${key.slice("production/".length)}` : `avatars/${key}`;
112 const cacheKey = new Request(`${origin}/${path}`, { method: "GET" });
113 const cached = await cache.match(cacheKey);
114 if (cached) {
115 return method === "HEAD" ? new Response(null, { headers: cached.headers }) : cached;
116 }
117 const { value, metadata } = await env.AVATARS.getWithMetadata<{ contentType?: string }>(key, {
118 type: "arrayBuffer",
119 cacheTtl: 86400,
120 });
121 const contentType = metadata?.contentType;
122 if (!value || !contentType || !AVATAR_TYPES.has(contentType)) {
123 return plain(404, "Not found", "public, max-age=60");
124 }
125 const headers = {
126 "content-type": contentType,
127 "content-length": String(value.byteLength),
128 "cache-control": "public, max-age=31536000, immutable",
129 "x-content-type-options": "nosniff",
130 "content-security-policy": "default-src 'none'; sandbox",
131 "cross-origin-resource-policy": "cross-origin",
132 };
133 ctx.waitUntil(cache.put(cacheKey, new Response(value, { headers })));
134 return new Response(method === "HEAD" ? null : value, { headers });
135}
136
137/**
138 * A file put in a Docs page. Its key is 256 random bits the artifacts service
139 * made, so the address is the permission, as a shared link is; the docs
140 * service serves images, media and PDFs as themselves and everything else
141 * as a download, and nothing here can run script.
142 */
143async function serveDocsFile(env: Env, method: string, key: string): Promise<Response> {
144 let answer: Response;
145 try {
146 answer = await env.ARTIFACTS.fetch(`https://docs/files/${key}`, { method });
147 } catch {
148 return plain(503, "Docs didn't answer");
149 }
150 if (!answer.ok) return plain(answer.status === 404 ? 404 : 502, "Not found", "public, max-age=60");
151 const headers = new Headers(answer.headers);
152 headers.set("x-content-type-options", "nosniff");
153 headers.set("content-security-policy", headers.get("content-type") === "application/pdf" ? PDF_POLICY : USERCONTENT_POLICY);
154 headers.set("cross-origin-resource-policy", "cross-origin");
155 return new Response(method === "HEAD" ? null : answer.body, { status: 200, headers });
156}