Skip to content
193 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Merge g1tusercontent.com: registry answers run nothing in a browser, the site's pages run only their own scripts, repository files and avatars on their own origin, raw files rate limited per address1/**
2 * Files people supply, served from an origin of their own: a repository's
3 * files and uploaded avatars at `USERCONTENT_URL` (g1tusercontent.com on
4 * g1t.sh). The site's session cookie is never sent there, and nothing
5 * served there can run script.
6 *
7 * <usercontent>/<owner>/<repo>/raw/<ref>/<path> a file at a branch, tag or commit
8 * <usercontent>/avatars/<sha256> an uploaded avatar
9 *
10 * A public repository's files are there for anyone. A private one's carry
11 * `?token=`, a signature the site makes for someone who can read the
12 * repository (routes/repo/raw.ts), good for one file for an hour or two.
13 * No Workers imports, so it can be tested under Node.
14 */
15
16/** What every file served there runs under: nothing runs, images and inline styles of its own only. */
17export const USERCONTENT_POLICY = "default-src 'none'; img-src 'self'; style-src 'unsafe-inline'; sandbox";
18/** A PDF: the same, but not sandboxed, which browsers' PDF viewers refuse to open under. */
19export const PDF_POLICY = "default-src 'none'; img-src 'self'; style-src 'unsafe-inline'; object-src 'none'; base-uri 'none'; form-action 'none'";
20
21/** The largest file served, in bytes. */
22export const MAX_RAW_BYTES = 10 * 1024 * 1024;
23
24/** A signed address lasts until the end of the next whole hour, so a page's addresses stay the same for an hour. */
25const TOKEN_HOURS = 2;
26
27export type RawFile = { owner: string; repo: string; ref: string; path: string };
28
29const segment = (value: string) => encodeURIComponent(value);
30
31/** `/<owner>/<repo>/raw/<ref>/<path>`, each part encoded; a ref's slashes too, so it stays one segment. */
32export function rawPath(file: RawFile): string {
33 const path = file.path.split("/").filter(Boolean).map(segment).join("/");
34 return `/${segment(file.owner)}/${segment(file.repo)}/raw/${segment(file.ref)}/${path}`;
35}
36
37/** The parts of a raw file's path, decoded; null for any other path. */
38export function parseRawPath(pathname: string): RawFile | null {
39 const parts = pathname.split("/").slice(1);
40 if (parts.length < 5 || parts[2] !== "raw") return null;
41 try {
42 const [owner, repo, , ref, ...rest] = parts.map(decodeURIComponent);
43 const path = rest.join("/");
44 if (!owner || !repo || !ref || !path || rest.some((part) => !part || part === "." || part === "..")) return null;
45 return { owner, repo, ref, path };
46 } catch {
47 return null;
48 }
49}
50
51/**
52 * The part of `url` under the usercontent address `base`, or null when it
53 * is not there: on its own host, any path; as a path on the site
54 * (`<site>/-/usercontent`), what follows that path, whatever the host the
55 * request came in on (a proxy may change it).
56 */
57export function usercontentPath(url: URL, base: string): string | null {
58 const at = new URL(base);
59 const prefix = at.pathname.replace(/\/+$/, "");
60 if (!prefix) return url.host === at.host ? url.pathname : null;
61 if (url.pathname === prefix || url.pathname.startsWith(`${prefix}/`)) return url.pathname.slice(prefix.length) || "/";
62 return null;
63}
64
65/** Whether a ref names a commit, whose files never change. */
66export function isCommit(ref: string): boolean {
67 return /^[0-9a-f]{40}$/.test(ref);
68}
69
70const encoder = new TextEncoder();
71
72function base64url(bytes: ArrayBuffer): string {
73 return btoa(String.fromCharCode(...new Uint8Array(bytes))).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
74}
75
76function fromBase64url(text: string): Uint8Array<ArrayBuffer> | null {
77 try {
78 const plain = atob(text.replace(/-/g, "+").replace(/_/g, "/"));
79 return Uint8Array.from(plain, (c) => c.charCodeAt(0));
80 } catch {
81 return null;
82 }
83}
84
85function hmacKey(secret: string, use: KeyUsage): Promise<CryptoKey> {
86 return crypto.subtle.importKey("raw", encoder.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, [use]);
87}
88
89/** What a token signs: the file, by the repository's path and id, and when it ends. */
90function signed(file: RawFile, repoId: string, expires: number): Uint8Array<ArrayBuffer> {
91 return encoder.encode(["raw", file.owner.toLowerCase(), file.repo.toLowerCase(), repoId, file.ref, file.path, String(expires)].join("\n"));
92}
93
94/** A token for one file of a private repository: `<expires>.<repoId>.<signature>`. */
95export async function signRaw(secret: string, file: RawFile, repoId: string, nowMs = Date.now()): Promise<string> {
96 const hour = 3600;
97 const expires = (Math.floor(nowMs / 1000 / hour) + TOKEN_HOURS) * hour;
98 const signature = await crypto.subtle.sign("HMAC", await hmacKey(secret, "sign"), signed(file, repoId, expires));
99 return `${expires}.${repoId}.${base64url(signature)}`;
100}
101
102/** The repository id a token is good for, when it is for this file and has not ended; else null. */
103export async function verifyRaw(secret: string, file: RawFile, token: string, nowMs = Date.now()): Promise<string | null> {
104 const match = /^(\d{1,12})\.([A-Za-z0-9_-]{1,64})\.([A-Za-z0-9_-]{43})$/.exec(token);
105 if (!match) return null;
106 const [, at, repoId, signature] = match;
107 const expires = Number(at);
108 if (expires * 1000 <= nowMs) return null;
109 const bytes = fromBase64url(signature!);
110 if (!bytes) return null;
111 const ok = await crypto.subtle.verify("HMAC", await hmacKey(secret, "verify"), bytes, signed(file, repoId!, expires));
112 return ok ? repoId! : null;
113}
114
115const IMAGES: Record<string, string> = {
116 png: "image/png",
117 jpg: "image/jpeg",
118 jpeg: "image/jpeg",
119 gif: "image/gif",
120 webp: "image/webp",
121 avif: "image/avif",
122 ico: "image/x-icon",
123 bmp: "image/bmp",
124 svg: "image/svg+xml",
125};
126
127const MEDIA: Record<string, string> = {
128 mp4: "video/mp4",
129 webm: "video/webm",
130 mov: "video/quicktime",
131 mp3: "audio/mpeg",
132 ogg: "audio/ogg",
133 wav: "audio/wav",
134 woff: "font/woff",
135 woff2: "font/woff2",
136 pdf: "application/pdf",
137};
138
139function extension(path: string): string {
140 const name = path.split("/").pop() ?? "";
141 return name.includes(".") ? name.split(".").pop()!.toLowerCase() : "";
142}
143
144/** Whether a file shows as an image in a page, by its name. */
145export function isImagePath(path: string): boolean {
146 return extension(path) in IMAGES;
147}
148
149/** Whether the bytes look like text: no NUL in the first 8,000. */
150function looksLikeText(bytes: Uint8Array): boolean {
151 return !bytes.subarray(0, 8000).includes(0);
152}
153
154/**
155 * The headers a file is served with. Images, media and PDFs as
156 * themselves; any other text (HTML, SVG's script, XML, JavaScript
157 * included) as plain text; anything else as bytes to save. Never sniffed,
158 * and nothing in it runs.
159 */
160export function rawHeaders(path: string, bytes: Uint8Array): Headers {
161 const ext = extension(path);
162 const type = IMAGES[ext] ?? MEDIA[ext] ?? (looksLikeText(bytes) ? "text/plain; charset=utf-8" : "application/octet-stream");
163 const headers = new Headers({
164 "content-type": type,
165 "content-length": String(bytes.byteLength),
166 "x-content-type-options": "nosniff",
167 "content-security-policy": type === "application/pdf" ? PDF_POLICY : USERCONTENT_POLICY,
168 "cross-origin-resource-policy": "cross-origin",
169 "referrer-policy": "no-referrer",
170 });
171 if (type === "application/octet-stream") {
172 const name = path.split("/").pop() ?? "file";
173 headers.set("content-disposition", `attachment; filename="${name.replace(/[^\x20-\x7e]|["\\%;]/g, "_")}"; filename*=UTF-8''${encodeURIComponent(name)}`);
174 }
175 return headers;
176}
177
178/**
179 * An image's address: an external one as written; a relative one as the
180 * repository's raw file at the same commit, or nothing when it climbs out
181 * of the repository. `rawBase` is the document's folder under
182 * `/<owner>/<repo>/raw/<ref>`.
183 */
184export function imageSource(src: string, rawBase: string | undefined): string | undefined {
185 if (/^[a-z][a-z0-9+.-]*:/i.test(src) || src.startsWith("//") || !rawBase || src.startsWith("#")) return src;
186 const root = /^\/[^/]+\/[^/]+\/raw\/[^/]+/.exec(rawBase)?.[0];
187 if (!root) return src;
188 const path = src.split(/[?#]/)[0]!;
189 if (!path) return undefined;
190 const from = path.startsWith("/") ? `${root}/` : `${rawBase.replace(/\/+$/, "")}/`;
191 const resolved = new URL(path.replace(/^\/+/, ""), `https://g1t.invalid${from}`).pathname;
192 return resolved.startsWith(`${root}/`) ? resolved : undefined;
193}

This file's history is long; its oldest lines are credited to the oldest commit read.