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