Repository files and avatars on g1tusercontent.com: raw files, a Raw button, images on file pages and in READMEs
Bytes people supply now come from an origin of their own that never sees g1t.sh's cookies, and nothing served there can run. - Usercontent origin (USERCONTENT_URL): https://g1tusercontent.com on g1t.sh; elsewhere <SITE_URL>/-/usercontent unless set to a host of its own. The web Worker answers it first (workers/usercontent.ts), before anything of the site's runs: no cookie is read or set. A custom domain route for g1tusercontent.com in apps/web/wrangler.jsonc. - Raw files: <usercontent>/<owner>/<repo>/raw/<ref>/<path>, through the repos service's existing raw_file (now in the TS contract as rawFile), up to 10 MB. Images, media and PDFs as themselves, every other text (HTML, SVG source, XML, JavaScript) as text/plain, other bytes as a download; nosniff and default-src 'none'; img-src 'self'; style-src 'unsafe-inline'; sandbox (a PDF without sandbox, which PDF viewers refuse). A commit's file is cached for good; a branch's for a minute. - Public repositories: direct. Private: ?token=<expires>.<repoId>.<hmac>, HMAC-SHA256 with USERCONTENT_KEY over the file (owner, repo, id, ref, path) and its end, an hour or two; never a cookie. - g1t.sh/<owner>/<repo>/raw/<ref>/<path> (routes/repo/raw.ts) checks the viewer can read the repository, resolves the ref to its commit and sends them on, signed for a private repository. Without USERCONTENT_KEY a private file is served from g1t.sh itself under the same policy. - Blob pages: a Raw button; images that were "binary or too large" show from their raw address; other binaries link to it. - Markdown: relative images in a README (and release notes) resolve to the raw file at the commit shown, relative to the document's folder or, with a leading /, the repository's root; never outside the repository. - Avatars: served at <usercontent>/avatars/<sha256>, and only there. - Page policy names an http usercontent origin for installations without HTTPS. - Manifest: USERCONTENT_KEY as the web unit's secret, with setup steps for the zone and the key. Self-host: start.sh makes USERCONTENT_KEY, configs.mjs passes it and USERCONTENT_URL, docker-compose takes USERCONTENT_URL. - Docs: git guide, Raw files; self-hosting settings; docs/SELF_HOSTING.md.
| 80 | 80 | files, not the history; clone for that. A repository with more than 10,000 | |
| 81 | 81 | files or over 24 MB is too large to download this way, so clone it instead. | |
| 82 | 82 | ||
| 83 | + | ## Raw files | |
| 84 | + | ||
| 85 | + | **Raw**, above a file on its page, opens the file as it is, with nothing | |
| 86 | + | around it. Any file is at: | |
| 87 | + | ||
| 88 | + | ```text | |
| 89 | + | https://g1t.sh/<workspace>/<repo>/raw/<branch, tag or commit>/<path> | |
| 90 | + | ``` | |
| 91 | + | ||
| 92 | + | That address sends you on to the commit the branch or tag names now, on | |
| 93 | + | g1t's file host: | |
| 94 | + | ||
| 95 | + | ```text | |
| 96 | + | https://g1tusercontent.com/<workspace>/<repo>/raw/<commit>/<path> | |
| 97 | + | ``` | |
| 98 | + | ||
| 99 | + | g1tusercontent.com is a site of its own so that nothing in a repository | |
| 100 | + | can reach your g1t.sh session: it never receives g1t.sh's cookies, and a | |
| 101 | + | file opened there cannot run script. Images, video, audio and PDFs are | |
| 102 | + | served as themselves; any other text, HTML, SVG source, XML and | |
| 103 | + | JavaScript included, as plain text; anything else as a download. A file | |
| 104 | + | is served up to 10 MB; clone the repository for larger ones. | |
| 105 | + | ||
| 106 | + | - **Public repositories.** The address works for anyone, and an address | |
| 107 | + | at a commit can be kept for good. | |
| 108 | + | - **Private repositories.** The address carries a `token` that g1t.sh | |
| 109 | + | makes for someone who can read the repository. It is good for that one | |
| 110 | + | file for an hour or two; after that, open the file on g1t.sh again for a | |
| 111 | + | new address. Revoking someone's access does not end an address they | |
| 112 | + | already have before then. | |
| 113 | + | ||
| 114 | + | Images in a file's page show from its raw address, and so do pictures in a | |
| 115 | + | README that name a file in the repository (``, | |
| 116 | + | relative to the README's folder, or `/docs/diagram.png` from the | |
| 117 | + | repository's root): they show the file at the commit the page shows. | |
| 118 | + | Uploaded avatars are on the same host, at | |
| 119 | + | `https://g1tusercontent.com/avatars/<sha256>`. | |
| 120 | + | ||
| 83 | 121 | ## Browsing without an account | |
| 84 | 122 | ||
| 85 | 123 | Public projects, Explore, Search and profiles are open to everyone, in the |
| 153 | 153 | | `API_PORT` | `8789` | The port the API and the MCP server are published on | | |
| 154 | 154 | | `API_URL` | `PUBLIC_URL`'s host on `API_PORT` | The address of the API, as people and applications reach it. It is also the OAuth issuer. Set it when the API is behind a proxy, for example `https://api.git.example.com`. | | |
| 155 | 155 | | `MCP_URL` | `API_URL/mcp` | The address of the MCP server. | | |
| 156 | + | | `USERCONTENT_URL` | `PUBLIC_URL/-/usercontent` | Where [raw files](/guides/git/#raw-files) and avatars are served. Set it to a host of its own (another domain, not a subdomain of `PUBLIC_URL`'s, for example `https://files.example.net`) that your proxy sends to the same port as `PUBLIC_URL`, keeping the `Host` header, so a file in a repository can never reach the site's session cookie. Unset, they are served under `PUBLIC_URL`, still as plain text or images that cannot run script. | | |
| 156 | 157 | | `MAILPIT_PORT` | `8025` | The port of the Mailpit inbox | | |
| 157 | 158 | | `MAIL_FROM` | `g1t <noreply@localhost>` | The sender of g1t's email | | |
| 158 | 159 | | `MAIL_URL` | `http://mailpit:8025` | The Mailpit server g1t sends mail through | |
| 80 | 80 | ||
| 81 | 81 | The icon then shows wherever the workspace does, and on its link previews | |
| 82 | 82 | (PNG and JPEG icons only). Each image is served from | |
| 83 | − | `g1t.sh/avatars/<sha256>`, an address named after its contents, so an icon | |
| 83 | + | `g1tusercontent.com/avatars/<sha256>`, an address named after its contents, so an icon | |
| 84 | 84 | that changes gets a new address and nothing shows the old one. | |
| 85 | 85 | ||
| 86 | 86 | You can upload a picture of yourself the same way, under |
| 17 | 17 | ||
| 18 | 18 | import { Checkbox } from "./ui/checkbox"; | |
| 19 | 19 | import { type AlertKind, G1T_MENTION_HREF, type MarkdownRepo, rehypeAlerts, rehypeReferences } from "../lib/markdown-plugins"; | |
| 20 | + | import { imageSource } from "../lib/usercontent"; | |
| 20 | 21 | import { UserCard } from "./user-card"; | |
| 21 | 22 | ||
| 22 | 23 | /** The text inside a React tree, for anchors and copying. */ | |
| ⋯ | |||
| 160 | 161 | source, | |
| 161 | 162 | repo, | |
| 162 | 163 | base, | |
| 164 | + | rawBase, | |
| 163 | 165 | }: { | |
| 164 | 166 | source: string; | |
| 165 | 167 | /** The repository the text belongs to, for its references. */ | |
| 166 | 168 | repo?: MarkdownRepo; | |
| 167 | 169 | /** Where relative links point, e.g. `/acme/web/blob/main/docs` for a file's own folder. */ | |
| 168 | 170 | base?: string; | |
| 171 | + | /** | |
| 172 | + | * Where relative images point: the same folder's raw files, e.g. | |
| 173 | + | * `/acme/web/raw/<commit>/docs`, under `/acme/web/raw/<commit>`. An image | |
| 174 | + | * path starting with `/` is from the repository's root. | |
| 175 | + | */ | |
| 176 | + | rawBase?: string; | |
| 169 | 177 | }) { | |
| 170 | 178 | return ( | |
| 171 | 179 | <div className="prose"> | |
| ⋯ | |||
| 247 | 255 | ) : null; | |
| 248 | 256 | }, | |
| 249 | 257 | img({ src, alt }) { | |
| 250 | − | return <img src={typeof src === "string" ? src : undefined} alt={alt ?? ""} loading="lazy" className="inline max-w-full rounded" />; | |
| 258 | + | const at = typeof src === "string" ? imageSource(src, rawBase) : undefined; | |
| 259 | + | return <img src={at} alt={alt ?? ""} loading="lazy" className="inline max-w-full rounded" />; | |
| 251 | 260 | }, | |
| 252 | 261 | }} | |
| 253 | 262 | > | |
| 64 | 64 | </div> | |
| 65 | 65 | <div className="px-5 py-4"> | |
| 66 | 66 | {release.body.trim() ? ( | |
| 67 | − | <Markdown source={release.body} repo={repo} base={`${base}/blob/${encodeTag(release.tagName)}`} /> | |
| 67 | + | <Markdown | |
| 68 | + | source={release.body} | |
| 69 | + | repo={repo} | |
| 70 | + | base={`${base}/blob/${encodeTag(release.tagName)}`} | |
| 71 | + | rawBase={`${base}/raw/${encodeURIComponent(release.tagName)}`} | |
| 72 | + | /> | |
| 68 | 73 | ) : ( | |
| 69 | 74 | <p className="text-sm text-faint">No notes.</p> | |
| 70 | 75 | )} |
| 15 | 15 | ||
| 16 | 16 | import { AgentSetup } from "./agent-setup"; | |
| 17 | 17 | import { useAddresses } from "../lib/addresses"; | |
| 18 | + | import { isImagePath } from "../lib/usercontent"; | |
| 18 | 19 | import { CloneBox } from "./clone-box"; | |
| 19 | 20 | import { type ChecksSource, CommitChecksBadge } from "./commit-checks"; | |
| 20 | 21 | import { type AboutData, RepoAboutPanel } from "./repo-about"; | |
| ⋯ | |||
| 370 | 371 | <Markdown | |
| 371 | 372 | source={readme.text} | |
| 372 | 373 | repo={{ namespace: repo.namespace, name: repo.name }} | |
| 373 | − | // Relative links in a README point into the repository. | |
| 374 | + | // Relative links in a README point into the repository, | |
| 375 | + | // and its pictures at the files of the commit shown. | |
| 374 | 376 | base={`/${repo.namespace}/${repo.name}/blob/${ref}${path ? `/${path}` : ""}`} | |
| 377 | + | rawBase={`/${repo.namespace}/${repo.name}/raw/${head.hash}${path ? `/${encodePath(path)}` : ""}`} | |
| 375 | 378 | /> | |
| 376 | 379 | ) : ( | |
| 377 | 380 | <pre className="whitespace-pre-wrap text-sm"><code>{readme.text}</code></pre> | |
| ⋯ | |||
| 419 | 422 | const { repo, ref, path, size, text } = blob; | |
| 420 | 423 | const lines = text?.replace(/\n$/, "").split("\n"); | |
| 421 | 424 | const base = `/${repo.namespace}/${repo.name}`; | |
| 425 | + | // The file as it is, on the usercontent origin (routes/repo/raw.ts). | |
| 426 | + | const raw = `${base}/raw/${encodeURIComponent(ref)}/${encodePath(path)}`; | |
| 422 | 427 | const toggle = (label: string, on: boolean, search: string) => ( | |
| 423 | 428 | <Link | |
| 424 | 429 | to={{ search }} | |
| ⋯ | |||
| 445 | 450 | <div className="flex items-center gap-3 border-b border-line bg-surface px-4 py-2.5 text-xs text-muted"> | |
| 446 | 451 | {lines && <span>{lines.length.toLocaleString("en-US")} lines</span>} | |
| 447 | 452 | <span>{size.toLocaleString("en-US")} bytes</span> | |
| 448 | − | {lines && ( | |
| 449 | − | <span className="ml-auto flex rounded-md border border-line p-0.5"> | |
| 450 | − | {toggle("Code", !blame, "")} | |
| 451 | − | {toggle("Blame", Boolean(blame), "?blame=1")} | |
| 452 | − | </span> | |
| 453 | − | )} | |
| 453 | + | <span className="ml-auto flex items-center gap-2"> | |
| 454 | + | {lines && ( | |
| 455 | + | <span className="flex rounded-md border border-line p-0.5"> | |
| 456 | + | {toggle("Code", !blame, "")} | |
| 457 | + | {toggle("Blame", Boolean(blame), "?blame=1")} | |
| 458 | + | </span> | |
| 459 | + | )} | |
| 460 | + | <a href={raw} className="rounded-md border border-line px-2 py-1 transition-colors hover:text-fg"> | |
| 461 | + | Raw | |
| 462 | + | </a> | |
| 463 | + | </span> | |
| 454 | 464 | </div> | |
| 455 | 465 | {blame && lines ? ( | |
| 456 | 466 | <BlameView base={base} path={path} lines={lines} html={blame.lines} blame={blame.blame} /> | |
| 457 | 467 | ) : lines ? ( | |
| 458 | 468 | <CodeLines lines={lines} html={html} marked={marked} /> | |
| 469 | + | ) : isImagePath(path) ? ( | |
| 470 | + | <div className="flex justify-center bg-[repeating-conic-gradient(var(--color-raised)_0_25%,transparent_0_50%)] bg-[length:16px_16px] p-6"> | |
| 471 | + | <img src={raw} alt={path.split("/").pop() ?? path} className="max-h-[70vh] max-w-full" /> | |
| 472 | + | </div> | |
| 459 | 473 | ) : ( | |
| 460 | 474 | <p className="p-6 text-sm text-muted"> | |
| 461 | − | This file is binary or too large to show. | |
| 475 | + | This file is binary or too large to show.{" "} | |
| 476 | + | <a href={raw} className="text-accent hover:underline"> | |
| 477 | + | View it raw | |
| 478 | + | </a> | |
| 479 | + | . | |
| 462 | 480 | </p> | |
| 463 | 481 | )} | |
| 464 | 482 | </div> | |
| 1 | 1 | import { Check, Copy, LoaderCircle, User } from "lucide-react"; | |
| 2 | 2 | import { type ComponentProps, Fragment, type ReactNode, useState } from "react"; | |
| 3 | − | import { Link, type LinkProps, NavLink, useLocation, useNavigation } from "react-router"; | |
| 3 | + | import { Link, type LinkProps, NavLink, useLocation, useNavigation, useRouteLoaderData } from "react-router"; | |
| 4 | 4 | ||
| 5 | + | import { usercontentFrom } from "../../lib/addresses"; | |
| 5 | 6 | import { isWaitingMessage, linkPaths } from "../../lib/compute"; | |
| 6 | 7 | import { type Submission, isPending } from "../../lib/pending"; | |
| 7 | 8 | import { Mark } from "../logo"; | |
| ⋯ | |||
| 278 | 279 | return name === "g1t"; | |
| 279 | 280 | } | |
| 280 | 281 | ||
| 281 | − | /** Where an uploaded avatar is served, from the hash it is stored by. */ | |
| 282 | − | export function avatarUrl(avatar: string): string { | |
| 283 | − | return `/avatars/${avatar}`; | |
| 282 | + | /** | |
| 283 | + | * Where an uploaded avatar is served, from the hash it is stored by: the | |
| 284 | + | * usercontent origin, or the site's own address (which redirects there) | |
| 285 | + | * when it is not known. | |
| 286 | + | */ | |
| 287 | + | export function avatarUrl(avatar: string, usercontent = ""): string { | |
| 288 | + | return `${usercontent}/avatars/${avatar}`; | |
| 284 | 289 | } | |
| 285 | 290 | ||
| 286 | 291 | /** | |
| ⋯ | |||
| 304 | 309 | system?: boolean; | |
| 305 | 310 | }) { | |
| 306 | 311 | const [failed, setFailed] = useState<string | null>(null); | |
| 312 | + | const usercontent = usercontentFrom(useRouteLoaderData("root")); | |
| 307 | 313 | // g1t itself wears its own mark: the pixel 1 on a dark square. | |
| 308 | 314 | if (system || isSystemName(name)) { | |
| 309 | 315 | return ( | |
| ⋯ | |||
| 332 | 338 | if (image && failed !== image) { | |
| 333 | 339 | return ( | |
| 334 | 340 | <img | |
| 335 | − | src={avatarUrl(image)} | |
| 341 | + | src={avatarUrl(image, usercontent)} | |
| 336 | 342 | alt="" | |
| 337 | 343 | aria-hidden="true" | |
| 338 | 344 | width={size} | |
| 3 | 3 | import { isbot } from "isbot"; | |
| 4 | 4 | import { renderToReadableStream } from "react-dom/server"; | |
| 5 | 5 | ||
| 6 | + | import { addresses } from "./lib/addresses.server"; | |
| 6 | 7 | import { NonceContext } from "./lib/nonce"; | |
| 7 | 8 | import { makeNonce, pagePolicy } from "./lib/page-headers"; | |
| 8 | 9 | import { recordHandler } from "./lib/perf.server"; | |
| ⋯ | |||
| 77 | 78 | } | |
| 78 | 79 | ||
| 79 | 80 | responseHeaders.set("Content-Type", "text/html"); | |
| 80 | − | if (nonce) responseHeaders.set("Content-Security-Policy", pagePolicy(nonce)); | |
| 81 | + | if (nonce) responseHeaders.set("Content-Security-Policy", pagePolicy(nonce, addresses().usercontent)); | |
| 81 | 82 | return new Response(body, { | |
| 82 | 83 | headers: responseHeaders, | |
| 83 | 84 | status: responseStatusCode, | |
| 2 | 2 | ||
| 3 | 3 | import { type Addresses, addressesFor } from "./addresses"; | |
| 4 | 4 | ||
| 5 | − | /** This g1t's addresses, from the Worker's settings (SITE_URL, API_URL, MCP_URL, OG_URL). */ | |
| 5 | + | /** This g1t's addresses, from the Worker's settings (SITE_URL, API_URL, MCP_URL, OG_URL, USERCONTENT_URL). */ | |
| 6 | 6 | export function addresses(): Addresses { | |
| 7 | 7 | return addressesFor(env); | |
| 8 | 8 | } |
| 1 | 1 | import assert from "node:assert/strict"; | |
| 2 | 2 | import { test } from "node:test"; | |
| 3 | 3 | ||
| 4 | − | import { HOSTED_ADDRESSES, addressesFor, addressesFrom, cloneUrl } from "./addresses.ts"; | |
| 4 | + | import { HOSTED_ADDRESSES, addressesFor, addressesFrom, cloneUrl, usercontentFrom } from "./addresses.ts"; | |
| 5 | 5 | import { MCP_URL } from "./agent-setup.ts"; | |
| 6 | 6 | import { OG, SITE } from "./meta.ts"; | |
| 7 | 7 | ||
| ⋯ | |||
| 18 | 18 | MCP_URL: "http://localhost:8790/mcp/", | |
| 19 | 19 | OG_URL: "", | |
| 20 | 20 | }), | |
| 21 | − | { site: "http://localhost:8787", api: "http://localhost:8788", mcp: "http://localhost:8790/mcp", og: null }, | |
| 21 | + | { | |
| 22 | + | site: "http://localhost:8787", | |
| 23 | + | api: "http://localhost:8788", | |
| 24 | + | mcp: "http://localhost:8790/mcp", | |
| 25 | + | og: null, | |
| 26 | + | usercontent: "http://localhost:8787/-/usercontent", | |
| 27 | + | }, | |
| 22 | 28 | ); | |
| 23 | 29 | }); | |
| 24 | 30 | ||
| 31 | + | test("files people supply are served from an origin of their own", () => { | |
| 32 | + | assert.equal(addressesFor({}).usercontent, "https://g1tusercontent.com"); | |
| 33 | + | assert.equal(addressesFor({ SITE_URL: "https://git.example.com", USERCONTENT_URL: "https://files.example.net/" }).usercontent, "https://files.example.net"); | |
| 34 | + | assert.equal(addressesFor({ SITE_URL: "https://git.example.com", USERCONTENT_URL: "https://git.example.com" }).usercontent, "https://git.example.com/-/usercontent"); | |
| 35 | + | assert.equal(usercontentFrom(undefined), ""); | |
| 36 | + | assert.equal(usercontentFrom({ addresses: HOSTED_ADDRESSES }), "https://g1tusercontent.com"); | |
| 37 | + | }); | |
| 38 | + | ||
| 25 | 39 | test("pages without root data use g1t.sh's addresses", () => { | |
| 26 | 40 | assert.deepEqual(addressesFrom(undefined), HOSTED_ADDRESSES); | |
| 27 | 41 | const own = { site: "http://localhost:8787", api: "a", mcp: "m", og: null }; | |
| 15 | 15 | mcp: string; | |
| 16 | 16 | /** The social-card image service's origin, or null when there is none. */ | |
| 17 | 17 | og: string | null; | |
| 18 | + | /** | |
| 19 | + | * Where bytes people supplied are served (repository files, avatars), on | |
| 20 | + | * an origin of its own that never sees the site's cookies. Self-hosted | |
| 21 | + | * without a host of its own, a path on the site (`<site>/-/usercontent`). | |
| 22 | + | */ | |
| 23 | + | usercontent: string; | |
| 18 | 24 | }; | |
| 19 | 25 | ||
| 20 | 26 | export const HOSTED_ADDRESSES: Addresses = { | |
| ⋯ | |||
| 22 | 28 | api: "https://api.g1t.sh", | |
| 23 | 29 | mcp: "https://mcp.g1t.sh", | |
| 24 | 30 | og: "https://og.g1t.sh", | |
| 31 | + | usercontent: "https://g1tusercontent.com", | |
| 25 | 32 | }; | |
| 26 | 33 | ||
| 27 | 34 | /** The Worker settings the addresses come from; every one optional. */ | |
| 28 | − | export type AddressSettings = { SITE_URL?: string; API_URL?: string; MCP_URL?: string; OG_URL?: string }; | |
| 35 | + | export type AddressSettings = { SITE_URL?: string; API_URL?: string; MCP_URL?: string; OG_URL?: string; USERCONTENT_URL?: string }; | |
| 29 | 36 | ||
| 30 | 37 | const trim = (value: string) => value.trim().replace(/\/+$/, ""); | |
| 31 | 38 | ||
| 32 | 39 | /** | |
| 33 | 40 | * The addresses from the Worker's settings. An unset or empty setting is | |
| 34 | 41 | * g1t.sh's address, except `OG_URL`: set to an empty string, it means there | |
| 35 | − | * is no card service, and pages carry no image tags. | |
| 42 | + | * is no card service, and pages carry no image tags. `USERCONTENT_URL` | |
| 43 | + | * unset is g1tusercontent.com on g1t.sh, and a path on the site wherever | |
| 44 | + | * `SITE_URL` names another. | |
| 36 | 45 | */ | |
| 37 | 46 | export function addressesFor(settings: AddressSettings): Addresses { | |
| 38 | 47 | const pick = (value: string | undefined, fallback: string) => (value ? trim(value) : "") || fallback; | |
| 48 | + | const site = pick(settings.SITE_URL, HOSTED_ADDRESSES.site); | |
| 49 | + | const ownSite = site === HOSTED_ADDRESSES.site; | |
| 39 | 50 | return { | |
| 40 | − | site: pick(settings.SITE_URL, HOSTED_ADDRESSES.site), | |
| 51 | + | site, | |
| 52 | + | usercontent: usercontentOf(pick(settings.USERCONTENT_URL, ownSite ? HOSTED_ADDRESSES.usercontent : `${site}/-/usercontent`), site), | |
| 41 | 53 | api: pick(settings.API_URL, HOSTED_ADDRESSES.api), | |
| 42 | 54 | mcp: pick(settings.MCP_URL, HOSTED_ADDRESSES.mcp), | |
| 43 | 55 | og: settings.OG_URL === undefined ? HOSTED_ADDRESSES.og : trim(settings.OG_URL) || null, | |
| 44 | 56 | }; | |
| 45 | 57 | } | |
| 46 | 58 | ||
| 59 | + | /** | |
| 60 | + | * Where uploaded avatars and repository files are served, from the root | |
| 61 | + | * loader's data; the site's own paths when it has none (they redirect). | |
| 62 | + | */ | |
| 63 | + | export function usercontentFrom(rootData: unknown): string { | |
| 64 | + | return (rootData as { addresses?: Addresses } | null | undefined)?.addresses?.usercontent ?? ""; | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | /** The site's own origin is never the usercontent origin: a path on it is. */ | |
| 68 | + | function usercontentOf(address: string, site: string): string { | |
| 69 | + | return address === site ? `${site}/-/usercontent` : address; | |
| 70 | + | } | |
| 71 | + | ||
| 47 | 72 | /** The addresses in the root loader's data, or g1t.sh's when it has none. */ | |
| 48 | 73 | export function addressesFrom(rootData: unknown): Addresses { | |
| 49 | 74 | return (rootData as { addresses?: Addresses } | null | undefined)?.addresses ?? HOSTED_ADDRESSES; | |
| 14 | 14 | assert.match(policy, /object-src 'none'/); | |
| 15 | 15 | assert.match(policy, /base-uri 'self'/); | |
| 16 | 16 | // Pictures in a README come from anywhere on HTTPS. | |
| 17 | − | assert.match(policy, /img-src 'self' https: data: blob:/); | |
| 17 | + | assert.match(policy, /img-src 'self' https: data: blob:;/); | |
| 18 | + | // An installation serving its files over plain HTTP names that origin. | |
| 19 | + | assert.match(pagePolicy(nonce, "http://files.local:8787"), /img-src 'self' https: data: blob: http:\/\/files\.local:8787;/); | |
| 20 | + | assert.match(pagePolicy(nonce, "https://g1tusercontent.com"), /img-src 'self' https: data: blob:;/); | |
| 18 | 21 | }); | |
| 19 | 22 | ||
| 20 | 23 | test("every answer gains nosniff and a referrer policy; pages are not framed", () => { |
| 18 | 18 | return btoa(String.fromCharCode(...bytes)); | |
| 19 | 19 | } | |
| 20 | 20 | ||
| 21 | − | /** The Content-Security-Policy of a page rendered with `nonce`. */ | |
| 22 | − | export function pagePolicy(nonce: string): string { | |
| 21 | + | /** | |
| 22 | + | * The Content-Security-Policy of a page rendered with `nonce`. `usercontent` | |
| 23 | + | * is where repository files and avatars are served (lib/usercontent.ts), | |
| 24 | + | * named for an installation that serves them over plain HTTP. | |
| 25 | + | */ | |
| 26 | + | export function pagePolicy(nonce: string, usercontent?: string): string { | |
| 27 | + | const files = usercontent && /^http:/.test(usercontent) ? ` ${new URL(usercontent).origin}` : ""; | |
| 23 | 28 | return [ | |
| 24 | 29 | "default-src 'self'", | |
| 25 | 30 | // Cloudflare's Web Analytics beacon, when the zone turns it on. | |
| 26 | 31 | `script-src 'self' 'nonce-${nonce}' https://static.cloudflareinsights.com`, | |
| 27 | 32 | "style-src 'self' 'unsafe-inline'", | |
| 28 | − | "img-src 'self' https: data: blob:", | |
| 29 | − | "media-src 'self' https:", | |
| 33 | + | `img-src 'self' https: data: blob:${files}`, | |
| 34 | + | `media-src 'self' https:${files}`, | |
| 30 | 35 | "font-src 'self' data:", | |
| 31 | 36 | "connect-src 'self' https:", | |
| 32 | 37 | "frame-src 'none'", |
| 1 | + | import assert from "node:assert/strict"; | |
| 2 | + | import { test } from "node:test"; | |
| 3 | + | ||
| 4 | + | import { | |
| 5 | + | PDF_POLICY, | |
| 6 | + | USERCONTENT_POLICY, | |
| 7 | + | imageSource, | |
| 8 | + | isCommit, | |
| 9 | + | parseRawPath, | |
| 10 | + | rawHeaders, | |
| 11 | + | rawPath, | |
| 12 | + | signRaw, | |
| 13 | + | usercontentPath, | |
| 14 | + | verifyRaw, | |
| 15 | + | } from "./usercontent.ts"; | |
| 16 | + | ||
| 17 | + | const FILE = { owner: "acme", repo: "web", ref: "feat/new", path: "docs/a b.png" }; | |
| 18 | + | ||
| 19 | + | test("a raw file's path keeps a ref with slashes in one segment", () => { | |
| 20 | + | const path = rawPath(FILE); | |
| 21 | + | assert.equal(path, "/acme/web/raw/feat%2Fnew/docs/a%20b.png"); | |
| 22 | + | assert.deepEqual(parseRawPath(path), FILE); | |
| 23 | + | }); | |
| 24 | + | ||
| 25 | + | test("paths that are not a file, or climb out of the repository, are refused", () => { | |
| 26 | + | for (const path of ["/acme/web/raw/main", "/acme/web/blob/main/a.png", "/acme/web/raw/main/../x", "/acme/web/raw/main/a//b", "/acme/web/raw/main/%E0%A4%A"]) { | |
| 27 | + | assert.equal(parseRawPath(path), null, path); | |
| 28 | + | } | |
| 29 | + | }); | |
| 30 | + | ||
| 31 | + | test("usercontent is its own host, or a path on the site", () => { | |
| 32 | + | const hosted = "https://g1tusercontent.com"; | |
| 33 | + | assert.equal(usercontentPath(new URL("https://g1tusercontent.com/acme/web/raw/main/a.png"), hosted), "/acme/web/raw/main/a.png"); | |
| 34 | + | assert.equal(usercontentPath(new URL("https://g1t.sh/acme/web/raw/main/a.png"), hosted), null); | |
| 35 | + | const own = "https://git.example.com/-/usercontent"; | |
| 36 | + | assert.equal(usercontentPath(new URL("http://localhost:8787/-/usercontent/avatars/ab"), own), "/avatars/ab"); | |
| 37 | + | assert.equal(usercontentPath(new URL("http://localhost:8787/-/usercontentx"), own), null); | |
| 38 | + | assert.equal(usercontentPath(new URL("http://localhost:8787/acme/web"), own), null); | |
| 39 | + | }); | |
| 40 | + | ||
| 41 | + | test("a token is good for its file alone, until it ends", async () => { | |
| 42 | + | const now = Date.UTC(2026, 9, 8, 12, 30); | |
| 43 | + | const token = await signRaw("secret", FILE, "repo_1", now); | |
| 44 | + | assert.match(token, /^\d+\.repo_1\.[A-Za-z0-9_-]{43}$/); | |
| 45 | + | // The same within the hour, so a page's addresses are kept by the browser. | |
| 46 | + | assert.equal(await signRaw("secret", FILE, "repo_1", now + 20 * 60_000), token); | |
| 47 | + | assert.equal(await verifyRaw("secret", FILE, token, now), "repo_1"); | |
| 48 | + | assert.equal(await verifyRaw("secret", { ...FILE, owner: "ACME" }, token, now), "repo_1"); | |
| 49 | + | assert.equal(await verifyRaw("secret", { ...FILE, path: "docs/other.png" }, token, now), null); | |
| 50 | + | assert.equal(await verifyRaw("secret", { ...FILE, repo: "api" }, token, now), null); | |
| 51 | + | assert.equal(await verifyRaw("secret", { ...FILE, ref: "main" }, token, now), null); | |
| 52 | + | assert.equal(await verifyRaw("other", FILE, token, now), null); | |
| 53 | + | assert.equal(await verifyRaw("secret", FILE, token.replace("repo_1", "repo_2"), now), null); | |
| 54 | + | assert.equal(await verifyRaw("secret", FILE, token, now + 2 * 3600_000), null); | |
| 55 | + | assert.equal(await verifyRaw("secret", FILE, "nonsense", now), null); | |
| 56 | + | }); | |
| 57 | + | ||
| 58 | + | test("files are served as data that cannot run", () => { | |
| 59 | + | const text = new TextEncoder().encode("<script>alert(1)</script>"); | |
| 60 | + | for (const name of ["index.html", "page.xhtml", "data.xml", "app.js", "README.md"]) { | |
| 61 | + | const headers = rawHeaders(name, text); | |
| 62 | + | assert.equal(headers.get("content-type"), "text/plain; charset=utf-8", name); | |
| 63 | + | assert.equal(headers.get("x-content-type-options"), "nosniff"); | |
| 64 | + | assert.equal(headers.get("content-security-policy"), USERCONTENT_POLICY); | |
| 65 | + | } | |
| 66 | + | assert.equal(rawHeaders("logo.png", new Uint8Array([137, 80, 78, 71])).get("content-type"), "image/png"); | |
| 67 | + | // An SVG shows as an image; opened on its own, its scripts are sandboxed. | |
| 68 | + | const svg = rawHeaders("logo.svg", text); | |
| 69 | + | assert.equal(svg.get("content-type"), "image/svg+xml"); | |
| 70 | + | assert.match(svg.get("content-security-policy")!, /sandbox/); | |
| 71 | + | assert.equal(rawHeaders("paper.pdf", new Uint8Array([37, 80])).get("content-security-policy"), PDF_POLICY); | |
| 72 | + | const binary = rawHeaders("tool.bin", new Uint8Array([0, 1, 2])); | |
| 73 | + | assert.equal(binary.get("content-type"), "application/octet-stream"); | |
| 74 | + | assert.match(binary.get("content-disposition")!, /^attachment; filename="tool.bin"/); | |
| 75 | + | }); | |
| 76 | + | ||
| 77 | + | test("a README's relative pictures are the repository's files at the same commit", () => { | |
| 78 | + | const root = "/acme/web/raw/abc123"; | |
| 79 | + | const docs = `${root}/docs`; | |
| 80 | + | assert.equal(imageSource("logo.png", root), `${root}/logo.png`); | |
| 81 | + | assert.equal(imageSource("./img/a b.png", docs), `${root}/docs/img/a%20b.png`); | |
| 82 | + | assert.equal(imageSource("../logo.png?raw=true", docs), `${root}/logo.png`); | |
| 83 | + | // From the repository's root, as people write them. | |
| 84 | + | assert.equal(imageSource("/assets/x.svg", docs), `${root}/assets/x.svg`); | |
| 85 | + | // Never out of the repository. | |
| 86 | + | assert.equal(imageSource("../../../../other/repo/raw/main/x.png", docs), undefined); | |
| 87 | + | // External pictures, and those with no repository, are as written. | |
| 88 | + | assert.equal(imageSource("https://example.com/x.png", docs), "https://example.com/x.png"); | |
| 89 | + | assert.equal(imageSource("data:image/png;base64,AA", docs), "data:image/png;base64,AA"); | |
| 90 | + | assert.equal(imageSource("logo.png", undefined), "logo.png"); | |
| 91 | + | }); | |
| 92 | + | ||
| 93 | + | test("commits are full hashes", () => { | |
| 94 | + | assert.equal(isCommit("a".repeat(40)), true); | |
| 95 | + | assert.equal(isCommit("main"), false); | |
| 96 | + | }); |
| 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 | |
| 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. */ | |
| 17 | + | export 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. */ | |
| 19 | + | export 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. */ | |
| 22 | + | export 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. */ | |
| 25 | + | const TOKEN_HOURS = 2; | |
| 26 | + | ||
| 27 | + | export type RawFile = { owner: string; repo: string; ref: string; path: string }; | |
| 28 | + | ||
| 29 | + | const 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. */ | |
| 32 | + | export 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. */ | |
| 38 | + | export 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 | + | */ | |
| 57 | + | export 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. */ | |
| 66 | + | export function isCommit(ref: string): boolean { | |
| 67 | + | return /^[0-9a-f]{40}$/.test(ref); | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | const encoder = new TextEncoder(); | |
| 71 | + | ||
| 72 | + | function base64url(bytes: ArrayBuffer): string { | |
| 73 | + | return btoa(String.fromCharCode(...new Uint8Array(bytes))).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | function 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 | + | ||
| 85 | + | function 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. */ | |
| 90 | + | function 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>`. */ | |
| 95 | + | export 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. */ | |
| 103 | + | export 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 | + | ||
| 115 | + | const 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 | + | ||
| 127 | + | const 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 | + | ||
| 139 | + | function 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. */ | |
| 145 | + | export 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. */ | |
| 150 | + | function 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 | + | */ | |
| 160 | + | export 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 | + | */ | |
| 184 | + | export 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 | + | } |
| 142 | 142 | route(":owner/:repo/add-ci", "routes/repo/add-ci.ts"), | |
| 143 | 143 | // A screenshot of a project's production, for its overview. | |
| 144 | 144 | route(":owner/:repo/production.jpg", "routes/repo/production-screenshot.ts"), | |
| 145 | + | // A file as it is, sent on to the usercontent origin (lib/usercontent.ts). | |
| 146 | + | route(":owner/:repo/raw/:ref/*", "routes/repo/raw.ts"), | |
| 145 | 147 | // A project: its overview first, its repository's code under Code. The | |
| 146 | 148 | // 1:1 project of a repository has the repository's name, so every | |
| 147 | 149 | // repository address below keeps working. |
| 1 | + | /** | |
| 2 | + | * A file of a repository as it is, for the Raw button, images on a file's | |
| 3 | + | * page and pictures in a README: `/<owner>/<repo>/raw/<ref>/<path>`. Sends | |
| 4 | + | * the viewer on to the file at the commit the ref names, on the usercontent | |
| 5 | + | * origin (lib/usercontent.ts). A public repository's address is the same | |
| 6 | + | * for everyone; a private one's carries a token for this file alone, made | |
| 7 | + | * here for someone who can read the repository, good for an hour or two. | |
| 8 | + | * Without USERCONTENT_KEY a private file is served from here instead, | |
| 9 | + | * under the same policy. | |
| 10 | + | */ | |
| 11 | + | import { env } from "cloudflare:workers"; | |
| 12 | + | ||
| 13 | + | import type { Route } from "./+types/raw"; | |
| 14 | + | import { addresses } from "../../lib/addresses.server"; | |
| 15 | + | import { repos } from "../../lib/services.server"; | |
| 16 | + | import { getViewer } from "../../lib/session.server"; | |
| 17 | + | import { MAX_RAW_BYTES, isCommit, rawHeaders, rawPath, signRaw } from "../../lib/usercontent"; | |
| 18 | + | ||
| 19 | + | function refused(status: number, message: string): Response { | |
| 20 | + | return new Response(`${message}\n`, { | |
| 21 | + | status, | |
| 22 | + | headers: { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store", "x-content-type-options": "nosniff" }, | |
| 23 | + | }); | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | export async function loader({ params, context }: Route.LoaderArgs) { | |
| 27 | + | const viewer = getViewer(context); | |
| 28 | + | const path = params["*"] ?? ""; | |
| 29 | + | if (!path) return refused(404, "Ask for /<owner>/<repo>/raw/<branch, tag or commit>/<path>."); | |
| 30 | + | const named = { namespace: params.owner, name: params.repo }; | |
| 31 | + | const found = await repos.get(named, viewer).catch(() => null); | |
| 32 | + | if (!found?.ok) return refused(404, "There is no such repository, or you cannot see it."); | |
| 33 | + | const repo = found.value; | |
| 34 | + | // The commit the ref names now, so the file's address never changes. | |
| 35 | + | let commit = isCommit(params.ref) ? params.ref : null; | |
| 36 | + | if (!commit) { | |
| 37 | + | const log = await repos.log(named, viewer, params.ref, 1).catch(() => null); | |
| 38 | + | commit = log?.ok ? (log.value[0]?.hash ?? null) : null; | |
| 39 | + | } | |
| 40 | + | if (!commit) return refused(404, `There is no branch, tag or commit named ${params.ref}.`); | |
| 41 | + | const file = { owner: repo.namespace, repo: repo.name, ref: commit, path }; | |
| 42 | + | const target = `${addresses().usercontent}${rawPath(file)}`; | |
| 43 | + | // Kept briefly when it followed a branch, which moves. | |
| 44 | + | const cache = isCommit(params.ref) ? "private, max-age=86400" : "private, max-age=60"; | |
| 45 | + | if (!repo.isPrivate) return redirect(target, cache); | |
| 46 | + | if (env.USERCONTENT_KEY) { | |
| 47 | + | const token = await signRaw(env.USERCONTENT_KEY, file, repo.id); | |
| 48 | + | return redirect(`${target}?token=${encodeURIComponent(token)}`, "private, max-age=600"); | |
| 49 | + | } | |
| 50 | + | const raw = await repos.rawFile(repo.id, commit, path, MAX_RAW_BYTES).catch(() => null); | |
| 51 | + | if (!raw) return refused(404, `There is no such file, or it is over ${MAX_RAW_BYTES / 1024 / 1024} MB. Clone the repository for it.`); | |
| 52 | + | const bytes = Uint8Array.from(atob(raw.data), (c) => c.charCodeAt(0)); | |
| 53 | + | const headers = rawHeaders(path, bytes); | |
| 54 | + | headers.set("cache-control", "private, max-age=60"); | |
| 55 | + | return new Response(bytes, { headers }); | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | function redirect(location: string, cache: string): Response { | |
| 59 | + | return new Response(null, { status: 302, headers: { location, "cache-control": cache } }); | |
| 60 | + | } |
| 2 | 2 | ||
| 3 | 3 | import { identityClient, isNamespaceShaped } from "@g1t/contracts"; | |
| 4 | 4 | ||
| 5 | + | import { addressesFor } from "../app/lib/addresses"; | |
| 5 | 6 | import { hardenRegistryHeaders } from "../app/lib/content-safety"; | |
| 6 | 7 | import { withSiteHeaders } from "../app/lib/page-headers"; | |
| 7 | 8 | import { finishResponse, withRequestPerf } from "../app/lib/perf.server"; | |
| 8 | 9 | import { goImport } from "../app/lib/go-get"; | |
| 9 | 10 | import { repositoryOfPage, stillPublic } from "../app/lib/public-cache"; | |
| 10 | 11 | import { registryWorkspace, servicePath } from "../app/lib/registry-paths"; | |
| 12 | + | import { usercontentPath } from "../app/lib/usercontent"; | |
| 13 | + | import { serveUsercontent } from "./usercontent"; | |
| 11 | 14 | ||
| 12 | 15 | const requestHandler = createRequestHandler( | |
| 13 | 16 | () => import("virtual:react-router/server-build"), | |
| 14 | 17 | import.meta.env.MODE, | |
| 15 | 18 | ); | |
| 16 | 19 | ||
| 17 | − | /** An uploaded avatar, by the SHA-256 of its bytes. */ | |
| 18 | − | const AVATAR_PATH = /^\/avatars\/([0-9a-f]{64})$/; | |
| 19 | − | /** The only types identity stores, having checked each image's bytes. */ | |
| 20 | − | const AVATAR_TYPES = new Set(["image/png", "image/jpeg", "image/webp", "image/gif"]); | |
| 21 | 20 | const DOCS = "https://docs.g1t.sh"; | |
| 22 | 21 | ||
| 23 | 22 | /** Where the documentation pages that used to live under /docs are now. */ | |
| ⋯ | |||
| 34 | 33 | ||
| 35 | 34 | export default { | |
| 36 | 35 | async fetch(request, env, ctx) { | |
| 36 | + | // Repository files and avatars, on their own origin | |
| 37 | + | // (g1tusercontent.com): answered before anything of the site's runs, | |
| 38 | + | // so nothing there reads or sets the session cookie. | |
| 39 | + | const usercontent = usercontentPath(new URL(request.url), addressesFor(env).usercontent); | |
| 40 | + | if (usercontent !== null) return serveUsercontent(env, ctx, request, usercontent); | |
| 37 | 41 | // Every answer: no sniffing, a referrer of the origin alone, and no | |
| 38 | 42 | // framing of pages (app/lib/page-headers.ts). | |
| 39 | 43 | return withSiteHeaders(await site(request, env, ctx)); | |
| ⋯ | |||
| 63 | 67 | } | |
| 64 | 68 | if (service === "packages") { | |
| 65 | 69 | return proxyPackages(env, request); | |
| 66 | − | } | |
| 67 | − | const avatar = AVATAR_PATH.exec(pathname); | |
| 68 | − | if (avatar) { | |
| 69 | − | return serveAvatar(env, ctx, request, avatar[1]); | |
| 70 | 70 | } | |
| 71 | 71 | // The documentation is its own site. | |
| 72 | 72 | if (pathname === "/docs" || pathname.startsWith("/docs/")) { | |
| ⋯ | |||
| 203 | 203 | const get = request.method === "GET" || request.method === "HEAD"; | |
| 204 | 204 | // 308 keeps a publish a PUT, for the clients that follow it. | |
| 205 | 205 | return new Response(null, { status: get ? 301 : 308, headers: { location: named.under(current) + url.search } }); | |
| 206 | − | } | |
| 207 | − | ||
| 208 | − | /** | |
| 209 | − | * An uploaded avatar. Its address is its hash, so it never changes and is | |
| 210 | − | * kept for good. It is served as nothing but an image: the stored type, | |
| 211 | − | * no sniffing, and a policy that lets nothing in it run. | |
| 212 | − | */ | |
| 213 | − | /** | |
| 214 | − | * An uploaded icon. Its address is its content's hash, so it never changes: | |
| 215 | − | * each data centre keeps it in its cache after the first view, and storage | |
| 216 | − | * is read about once per place, not once per visitor. | |
| 217 | − | */ | |
| 218 | − | async function serveAvatar(env: Env, ctx: ExecutionContext, request: Request, hash: string): Promise<Response> { | |
| 219 | − | const method = request.method; | |
| 220 | − | if (method !== "GET" && method !== "HEAD") { | |
| 221 | − | return new Response("Method not allowed", { status: 405, headers: { allow: "GET, HEAD" } }); | |
| 222 | − | } | |
| 223 | − | // The Workers runtime's own cache, which the DOM types do not know. | |
| 224 | − | const cache = (caches as unknown as { default: Cache }).default; | |
| 225 | − | const key = new Request(new URL(`/avatars/${hash}`, request.url).toString(), { method: "GET" }); | |
| 226 | − | const cached = await cache.match(key); | |
| 227 | − | if (cached) { | |
| 228 | − | return method === "HEAD" ? new Response(null, { headers: cached.headers }) : cached; | |
| 229 | − | } | |
| 230 | − | const { value, metadata } = await env.AVATARS.getWithMetadata<{ contentType?: string }>(hash, { | |
| 231 | − | type: "arrayBuffer", | |
| 232 | − | cacheTtl: 86400, | |
| 233 | − | }); | |
| 234 | − | const contentType = metadata?.contentType; | |
| 235 | − | if (!value || !contentType || !AVATAR_TYPES.has(contentType)) { | |
| 236 | − | return new Response("Not found", { | |
| 237 | − | status: 404, | |
| 238 | − | headers: { "cache-control": "public, max-age=60" }, | |
| 239 | − | }); | |
| 240 | − | } | |
| 241 | − | const headers = { | |
| 242 | − | "content-type": contentType, | |
| 243 | − | "content-length": String(value.byteLength), | |
| 244 | − | "cache-control": "public, max-age=31536000, immutable", | |
| 245 | − | "x-content-type-options": "nosniff", | |
| 246 | − | "content-security-policy": "default-src 'none'; sandbox", | |
| 247 | − | "cross-origin-resource-policy": "cross-origin", | |
| 248 | − | }; | |
| 249 | − | ctx.waitUntil(cache.put(key, new Response(value, { headers }))); | |
| 250 | − | return new Response(method === "HEAD" ? null : value, { headers }); | |
| 251 | 206 | } | |
| 44 | 44 | MCP_URL?: string; | |
| 45 | 45 | /** The social-card image service; an empty string for none. Unset on g1t.sh. */ | |
| 46 | 46 | OG_URL?: string; | |
| 47 | + | /** | |
| 48 | + | * Where repository files and avatars are served (app/lib/usercontent.ts). | |
| 49 | + | * Unset on g1t.sh, which is https://g1tusercontent.com; unset on another | |
| 50 | + | * site, `<SITE_URL>/-/usercontent`. | |
| 51 | + | */ | |
| 52 | + | USERCONTENT_URL?: string; | |
| 53 | + | /** Signs the short-lived addresses of private repositories' files. A secret. */ | |
| 54 | + | USERCONTENT_KEY?: string; | |
| 47 | 55 | } | |
| 48 | 56 | } | |
| 49 | 57 | interface Env extends Cloudflare.Env {} |
| 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 | + | */ | |
| 7 | + | import { reposClient } from "@g1t/contracts"; | |
| 8 | + | ||
| 9 | + | import { MAX_RAW_BYTES, isCommit, parseRawPath, rawHeaders, verifyRaw } from "../app/lib/usercontent"; | |
| 10 | + | ||
| 11 | + | /** An uploaded avatar, by the SHA-256 of its bytes. */ | |
| 12 | + | export const AVATAR_PATH = /^\/avatars\/([0-9a-f]{64})$/; | |
| 13 | + | /** The only types identity stores, having checked each image's bytes. */ | |
| 14 | + | const AVATAR_TYPES = new Set(["image/png", "image/jpeg", "image/webp", "image/gif"]); | |
| 15 | + | ||
| 16 | + | function 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. */ | |
| 29 | + | export 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 | + | */ | |
| 48 | + | async 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 | + | */ | |
| 85 | + | async 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 | + | } |
| 11 | 11 | // alternatives and `apply` changes every Worker's at once. | |
| 12 | 12 | "placement": { "mode": "off" }, | |
| 13 | 13 | "main": "./workers/app.ts", | |
| 14 | − | "routes": [{ "pattern": "g1t.sh", "custom_domain": true }], | |
| 14 | + | // g1tusercontent.com: repository files and avatars, on an origin of | |
| 15 | + | // their own that never sees g1t.sh's cookies (workers/usercontent.ts). | |
| 16 | + | "routes": [ | |
| 17 | + | { "pattern": "g1t.sh", "custom_domain": true }, | |
| 18 | + | { "pattern": "g1tusercontent.com", "custom_domain": true } | |
| 19 | + | ], | |
| 15 | 20 | // The site holds no data of its own; everything goes through services. | |
| 16 | 21 | // GitHub Actions artifacts, as the API keeps them, for download from a run. | |
| 17 | 22 | // Uploaded avatars (g1t-avatars), served at /avatars/<sha256>. Read |
| 19 | 19 | // | |
| 20 | 20 | // Usage: node configs.mjs [outDir] | |
| 21 | 21 | // Environment: PUBLIC_URL, GITSTORE_URL, GITSTORE_SECRET, MAIL_URL, | |
| 22 | − | // ACTIONS_KEY, INTEGRATIONS_KEY, WEBHOOKS_KEY, IDENTITY_KEY, | |
| 22 | + | // ACTIONS_KEY, INTEGRATIONS_KEY, WEBHOOKS_KEY, IDENTITY_KEY, USERCONTENT_KEY, | |
| 23 | + | // USERCONTENT_URL, | |
| 23 | 24 | // PACKAGES_TOKEN_SECRET, S3_ENDPOINT, S3_BUCKET, BACKUP_S3_BUCKET, S3_REGION, | |
| 24 | 25 | // S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY, S3_PUBLIC_ENDPOINT, and optionally | |
| 25 | 26 | // your own GitHub App: GITHUB_APP_ID, GITHUB_APP_SLUG, GITHUB_APP_CLIENT_ID, | |
| ⋯ | |||
| 76 | 77 | ||
| 77 | 78 | /** Sealing keys, by the service that holds each (hosted: Wrangler secrets). */ | |
| 78 | 79 | const SECRETS = { | |
| 80 | + | g1t: "USERCONTENT_KEY", | |
| 79 | 81 | "g1t-actions": "ACTIONS_KEY", | |
| 80 | 82 | "g1t-integrations": "INTEGRATIONS_KEY", | |
| 81 | 83 | "g1t-webhooks": "WEBHOOKS_KEY", | |
| ⋯ | |||
| 240 | 242 | // shows: the site's clone URLs, meta tags and agent setup, the API's | |
| 241 | 243 | // OAuth issuer and MCP server, and identity's mail. No social cards: the | |
| 242 | 244 | // card service (services/og) is not run here. | |
| 243 | − | if (service.web) Object.assign(config.vars, { SITE_URL: PUBLIC_URL, API_URL, MCP_URL, OG_URL: "" }); | |
| 245 | + | // Repository files and avatars: USERCONTENT_URL, a host of its own that | |
| 246 | + | // reaches this same site, or, empty, a path on it (PUBLIC_URL/-/usercontent). | |
| 247 | + | if (service.web) { | |
| 248 | + | Object.assign(config.vars, { SITE_URL: PUBLIC_URL, API_URL, MCP_URL, OG_URL: "", USERCONTENT_URL: (process.env.USERCONTENT_URL ?? "").trim() }); | |
| 249 | + | } | |
| 244 | 250 | if (hosted.name === "g1t-api") Object.assign(config.vars, { SITE_URL: PUBLIC_URL, API_URL, MCP_URL }); | |
| 245 | 251 | if (hosted.name === "g1t-identity") config.vars.SITE_URL = PUBLIC_URL; | |
| 246 | 252 | // Nightly backups' bundles go to a bucket of their own on the same | |
| 30 | 30 | # means PUBLIC_URL's host on API_PORT. MCP_URL, empty, is API_URL/mcp. | |
| 31 | 31 | API_URL: ${API_URL:-} | |
| 32 | 32 | MCP_URL: ${MCP_URL:-} | |
| 33 | + | # Where repository files and avatars are served: a host of its own | |
| 34 | + | # that reaches this container, so they never share the site's | |
| 35 | + | # cookies; empty serves them under PUBLIC_URL/-/usercontent. | |
| 36 | + | USERCONTENT_URL: ${USERCONTENT_URL:-} | |
| 33 | 37 | API_PORT: ${API_PORT:-8789} | |
| 34 | 38 | GITSTORE_URL: http://gitstore:8080 | |
| 35 | 39 | GITSTORE_SECRET_FILE: /secrets/gitstore |
| 28 | 28 | if ! grep -q '^IDENTITY_KEY=' "$KEYS"; then | |
| 29 | 29 | echo "IDENTITY_KEY=$(node -e 'console.log(require("crypto").randomBytes(32).toString("hex"))')" >> "$KEYS" | |
| 30 | 30 | fi | |
| 31 | + | # The site's key, which signs the short-lived addresses of private | |
| 32 | + | # repositories' files. | |
| 33 | + | if ! grep -q '^USERCONTENT_KEY=' "$KEYS"; then | |
| 34 | + | echo "USERCONTENT_KEY=$(node -e 'console.log(require("crypto").randomBytes(32).toString("hex"))')" >> "$KEYS" | |
| 35 | + | fi | |
| 31 | 36 | # The packages service's key, which signs registry tokens. | |
| 32 | 37 | if ! grep -q '^PACKAGES_TOKEN_SECRET=' "$KEYS"; then | |
| 33 | 38 | echo "PACKAGES_TOKEN_SECRET=$(node -e 'console.log(require("crypto").randomBytes(32).toString("hex"))')" >> "$KEYS" |
| 274 | 274 | "kind": "react-router", | |
| 275 | 275 | "worker": "g1t", | |
| 276 | 276 | "stage": "front", | |
| 277 | − | "secrets": [], | |
| 278 | − | "setup": ["The R2 bucket g1t-downloads, for the self-hosted runner's releases: npx wrangler r2 bucket create g1t-downloads"], | |
| 277 | + | // USERCONTENT_KEY signs the short-lived addresses of private | |
| 278 | + | // repositories' files on g1tusercontent.com; without it they are | |
| 279 | + | // served from g1t.sh instead. | |
| 280 | + | "secrets": ["USERCONTENT_KEY"], | |
| 281 | + | "setup": [ | |
| 282 | + | "The R2 bucket g1t-downloads, for the self-hosted runner's releases: npx wrangler r2 bucket create g1t-downloads", | |
| 283 | + | "The zone g1tusercontent.com on the account; the Worker's custom domain on it is made by the deploy", | |
| 284 | + | "The key for private files' addresses: `node -e \"console.log(require('crypto').randomBytes(32).toString('hex'))\" | npx wrangler secret put USERCONTENT_KEY` in apps/web" | |
| 285 | + | ], | |
| 279 | 286 | "self_host": "run" | |
| 280 | 287 | }, | |
| 281 | 288 | "sudo": { |
| 137 | 137 | `MCP_URL` and `OG_URL` (empty: no social card tags). The root loader | |
| 138 | 138 | hands them to the page; `meta.ts`, the clone box, agent setup, the | |
| 139 | 139 | pull request and merge box remotes, the tokens page and the OAuth | |
| 140 | − | consent's `iss` read them. | |
| 140 | + | consent's `iss` read them. `USERCONTENT_URL` (raw files and avatars, | |
| 141 | + | `apps/web/workers/usercontent.ts`): g1tusercontent.com hosted, else | |
| 142 | + | `<SITE_URL>/-/usercontent` unless set to a host of its own; | |
| 143 | + | `USERCONTENT_KEY` (made by `start.sh`) signs private files' addresses. | |
| 141 | 144 | - The API (`apps/api/src/addresses.rs`): `SITE_URL`, `API_URL` (the OAuth | |
| 142 | 145 | issuer) and `MCP_URL` (the protected resource; a path on the API's host | |
| 143 | 146 | self-hosted). |
| 394 | 394 | deleteRelease: (actor, path, id) => call("delete_release", { path, actor, id }), | |
| 395 | 395 | listFiles: (repoId, ref, limit) => call("list_files", { repoId, ref, skipDirs: [], limit }), | |
| 396 | 396 | rawBlobs: (repoId, hashes, maxBytes) => call("raw_blobs", { repoId, hashes, maxBytes }), | |
| 397 | + | rawFile: (repoId, ref, path, maxBytes) => call("raw_file", { repoId, ref, path, maxBytes }), | |
| 397 | 398 | commitFile: (repo, actor, file) => call("commit_file", { repo, actor, ...file }), | |
| 398 | 399 | land: (sourceId, actor, branch) => call("land", { sourceId, actor, branch }), | |
| 399 | 400 | compare: (repoId, viewer, base, head, baseBranch) => call("compare", { repoId, viewer, base, head, baseBranch }), |
| 294 | 294 | /** Blobs' bytes as standard base64, at most 100; `data` is null for one missing or over `maxBytes`. No viewer. */ | |
| 295 | 295 | rawBlobs(repoId: string, hashes: string[], maxBytes: number): Promise<RawBlob[]>; | |
| 296 | 296 | ||
| 297 | + | /** One file's bytes at a branch, tag or commit; null when missing or over `maxBytes`. No viewer: check access first. */ | |
| 298 | + | rawFile(repoId: string, ref: string, path: string, maxBytes: number): Promise<RawFile | null>; | |
| 299 | + | ||
| 297 | 300 | /** | |
| 298 | 301 | * Writes one file on a new branch made from the default branch's head, as | |
| 299 | 302 | * one commit by `actor`, for a change g1t proposes on their behalf (a | |
| ⋯ | |||
| 400 | 403 | /** One blob's bytes, standard base64; null when missing or too large. */ | |
| 401 | 404 | export type RawBlob = { hash: string; size: number; data: string | null }; | |
| 402 | 405 | ||
| 406 | + | /** One file's bytes, standard base64. */ | |
| 407 | + | export type RawFile = { size: number; data: string }; | |
| 408 | + | ||
| 403 | 409 | /** | |
| 404 | 410 | * What came of bringing a pull request up to date with the default branch | |
| 405 | 411 | * without a sandbox. `needs_agent` pushed nothing: the runner's `update` | |