Skip to content

Commit

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.

syntaqxcommitted Parentb2a14afBrowse files
28 files+674−870/28 viewed
+38−0
8080 files, not the history; clone for that. A repository with more than 10,000
8181 files or over 24 MB is too large to download this way, so clone it instead.
8282
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 (`![Diagram](docs/diagram.png)`,
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+
83121 ## Browsing without an account
84122
85123 Public projects, Explore, Search and profiles are open to everyone, in the
+1−0
153153 | `API_PORT` | `8789` | The port the API and the MCP server are published on |
154154 | `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`. |
155155 | `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. |
156157 | `MAILPIT_PORT` | `8025` | The port of the Mailpit inbox |
157158 | `MAIL_FROM` | `g1t <noreply@localhost>` | The sender of g1t's email |
158159 | `MAIL_URL` | `http://mailpit:8025` | The Mailpit server g1t sends mail through |
+1−1
8080
8181 The icon then shows wherever the workspace does, and on its link previews
8282 (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
8484 that changes gets a new address and nothing shows the old one.
8585
8686 You can upload a picture of yourself the same way, under
+10−1
1717
1818 import { Checkbox } from "./ui/checkbox";
1919 import { type AlertKind, G1T_MENTION_HREF, type MarkdownRepo, rehypeAlerts, rehypeReferences } from "../lib/markdown-plugins";
20+import { imageSource } from "../lib/usercontent";
2021 import { UserCard } from "./user-card";
2122
2223 /** The text inside a React tree, for anchors and copying. */
160161 source,
161162 repo,
162163 base,
164+ rawBase,
163165 }: {
164166 source: string;
165167 /** The repository the text belongs to, for its references. */
166168 repo?: MarkdownRepo;
167169 /** Where relative links point, e.g. `/acme/web/blob/main/docs` for a file's own folder. */
168170 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;
169177 }) {
170178 return (
171179 <div className="prose">
247255 ) : null;
248256 },
249257 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" />;
251260 },
252261 }}
253262 >
+6−1
6464 </div>
6565 <div className="px-5 py-4">
6666 {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+ />
6873 ) : (
6974 <p className="text-sm text-faint">No notes.</p>
7075 )}
+26−8
1515
1616 import { AgentSetup } from "./agent-setup";
1717 import { useAddresses } from "../lib/addresses";
18+import { isImagePath } from "../lib/usercontent";
1819 import { CloneBox } from "./clone-box";
1920 import { type ChecksSource, CommitChecksBadge } from "./commit-checks";
2021 import { type AboutData, RepoAboutPanel } from "./repo-about";
370371 <Markdown
371372 source={readme.text}
372373 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.
374376 base={`/${repo.namespace}/${repo.name}/blob/${ref}${path ? `/${path}` : ""}`}
377+ rawBase={`/${repo.namespace}/${repo.name}/raw/${head.hash}${path ? `/${encodePath(path)}` : ""}`}
375378 />
376379 ) : (
377380 <pre className="whitespace-pre-wrap text-sm"><code>{readme.text}</code></pre>
419422 const { repo, ref, path, size, text } = blob;
420423 const lines = text?.replace(/\n$/, "").split("\n");
421424 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)}`;
422427 const toggle = (label: string, on: boolean, search: string) => (
423428 <Link
424429 to={{ search }}
445450 <div className="flex items-center gap-3 border-b border-line bg-surface px-4 py-2.5 text-xs text-muted">
446451 {lines && <span>{lines.length.toLocaleString("en-US")} lines</span>}
447452 <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>
454464 </div>
455465 {blame && lines ? (
456466 <BlameView base={base} path={path} lines={lines} html={blame.lines} blame={blame.blame} />
457467 ) : lines ? (
458468 <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>
459473 ) : (
460474 <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+ .
462480 </p>
463481 )}
464482 </div>
+11−5
11 import { Check, Copy, LoaderCircle, User } from "lucide-react";
22 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";
44
5+import { usercontentFrom } from "../../lib/addresses";
56 import { isWaitingMessage, linkPaths } from "../../lib/compute";
67 import { type Submission, isPending } from "../../lib/pending";
78 import { Mark } from "../logo";
278279 return name === "g1t";
279280 }
280281
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}`;
284289 }
285290
286291 /**
304309 system?: boolean;
305310 }) {
306311 const [failed, setFailed] = useState<string | null>(null);
312+ const usercontent = usercontentFrom(useRouteLoaderData("root"));
307313 // g1t itself wears its own mark: the pixel 1 on a dark square.
308314 if (system || isSystemName(name)) {
309315 return (
332338 if (image && failed !== image) {
333339 return (
334340 <img
335− src={avatarUrl(image)}
341+ src={avatarUrl(image, usercontent)}
336342 alt=""
337343 aria-hidden="true"
338344 width={size}
+2−1
33 import { isbot } from "isbot";
44 import { renderToReadableStream } from "react-dom/server";
55
6+import { addresses } from "./lib/addresses.server";
67 import { NonceContext } from "./lib/nonce";
78 import { makeNonce, pagePolicy } from "./lib/page-headers";
89 import { recordHandler } from "./lib/perf.server";
7778 }
7879
7980 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));
8182 return new Response(body, {
8283 headers: responseHeaders,
8384 status: responseStatusCode,
+1−1
22
33 import { type Addresses, addressesFor } from "./addresses";
44
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). */
66 export function addresses(): Addresses {
77 return addressesFor(env);
88 }
+16−2
11 import assert from "node:assert/strict";
22 import { test } from "node:test";
33
4−import { HOSTED_ADDRESSES, addressesFor, addressesFrom, cloneUrl } from "./addresses.ts";
4+import { HOSTED_ADDRESSES, addressesFor, addressesFrom, cloneUrl, usercontentFrom } from "./addresses.ts";
55 import { MCP_URL } from "./agent-setup.ts";
66 import { OG, SITE } from "./meta.ts";
77
1818 MCP_URL: "http://localhost:8790/mcp/",
1919 OG_URL: "",
2020 }),
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+ },
2228 );
2329 });
2430
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+
2539 test("pages without root data use g1t.sh's addresses", () => {
2640 assert.deepEqual(addressesFrom(undefined), HOSTED_ADDRESSES);
2741 const own = { site: "http://localhost:8787", api: "a", mcp: "m", og: null };
+28−3
1515 mcp: string;
1616 /** The social-card image service's origin, or null when there is none. */
1717 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;
1824 };
1925
2026 export const HOSTED_ADDRESSES: Addresses = {
2228 api: "https://api.g1t.sh",
2329 mcp: "https://mcp.g1t.sh",
2430 og: "https://og.g1t.sh",
31+ usercontent: "https://g1tusercontent.com",
2532 };
2633
2734 /** 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 };
2936
3037 const trim = (value: string) => value.trim().replace(/\/+$/, "");
3138
3239 /**
3340 * The addresses from the Worker's settings. An unset or empty setting is
3441 * 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.
3645 */
3746 export function addressesFor(settings: AddressSettings): Addresses {
3847 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;
3950 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),
4153 api: pick(settings.API_URL, HOSTED_ADDRESSES.api),
4254 mcp: pick(settings.MCP_URL, HOSTED_ADDRESSES.mcp),
4355 og: settings.OG_URL === undefined ? HOSTED_ADDRESSES.og : trim(settings.OG_URL) || null,
4456 };
4557 }
4658
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+
4772 /** The addresses in the root loader's data, or g1t.sh's when it has none. */
4873 export function addressesFrom(rootData: unknown): Addresses {
4974 return (rootData as { addresses?: Addresses } | null | undefined)?.addresses ?? HOSTED_ADDRESSES;
+4−1
1414 assert.match(policy, /object-src 'none'/);
1515 assert.match(policy, /base-uri 'self'/);
1616 // 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:;/);
1821 });
1922
2023 test("every answer gains nosniff and a referrer policy; pages are not framed", () => {
+9−4
1818 return btoa(String.fromCharCode(...bytes));
1919 }
2020
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}` : "";
2328 return [
2429 "default-src 'self'",
2530 // Cloudflare's Web Analytics beacon, when the zone turns it on.
2631 `script-src 'self' 'nonce-${nonce}' https://static.cloudflareinsights.com`,
2732 "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}`,
3035 "font-src 'self' data:",
3136 "connect-src 'self' https:",
3237 "frame-src 'none'",
+96−0
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+});
+193−0
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+}
+2−0
142142 route(":owner/:repo/add-ci", "routes/repo/add-ci.ts"),
143143 // A screenshot of a project's production, for its overview.
144144 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"),
145147 // A project: its overview first, its repository's code under Code. The
146148 // 1:1 project of a repository has the repository's name, so every
147149 // repository address below keeps working.
+60−0
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+}
+8−53
22
33 import { identityClient, isNamespaceShaped } from "@g1t/contracts";
44
5+import { addressesFor } from "../app/lib/addresses";
56 import { hardenRegistryHeaders } from "../app/lib/content-safety";
67 import { withSiteHeaders } from "../app/lib/page-headers";
78 import { finishResponse, withRequestPerf } from "../app/lib/perf.server";
89 import { goImport } from "../app/lib/go-get";
910 import { repositoryOfPage, stillPublic } from "../app/lib/public-cache";
1011 import { registryWorkspace, servicePath } from "../app/lib/registry-paths";
12+import { usercontentPath } from "../app/lib/usercontent";
13+import { serveUsercontent } from "./usercontent";
1114
1215 const requestHandler = createRequestHandler(
1316 () => import("virtual:react-router/server-build"),
1417 import.meta.env.MODE,
1518 );
1619
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"]);
2120 const DOCS = "https://docs.g1t.sh";
2221
2322 /** Where the documentation pages that used to live under /docs are now. */
3433
3534 export default {
3635 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);
3741 // Every answer: no sniffing, a referrer of the origin alone, and no
3842 // framing of pages (app/lib/page-headers.ts).
3943 return withSiteHeaders(await site(request, env, ctx));
6367 }
6468 if (service === "packages") {
6569 return proxyPackages(env, request);
66− }
67− const avatar = AVATAR_PATH.exec(pathname);
68− if (avatar) {
69− return serveAvatar(env, ctx, request, avatar[1]);
7070 }
7171 // The documentation is its own site.
7272 if (pathname === "/docs" || pathname.startsWith("/docs/")) {
203203 const get = request.method === "GET" || request.method === "HEAD";
204204 // 308 keeps a publish a PUT, for the clients that follow it.
205205 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 });
251206 }
+8−0
4444 MCP_URL?: string;
4545 /** The social-card image service; an empty string for none. Unset on g1t.sh. */
4646 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;
4755 }
4856 }
4957 interface Env extends Cloudflare.Env {}
+111−0
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+}
+6−1
1111 // alternatives and `apply` changes every Worker's at once.
1212 "placement": { "mode": "off" },
1313 "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+ ],
1520 // The site holds no data of its own; everything goes through services.
1621 // GitHub Actions artifacts, as the API keeps them, for download from a run.
1722 // Uploaded avatars (g1t-avatars), served at /avatars/<sha256>. Read
+8−2
1919 //
2020 // Usage: node configs.mjs [outDir]
2121 // 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,
2324 // PACKAGES_TOKEN_SECRET, S3_ENDPOINT, S3_BUCKET, BACKUP_S3_BUCKET, S3_REGION,
2425 // S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY, S3_PUBLIC_ENDPOINT, and optionally
2526 // your own GitHub App: GITHUB_APP_ID, GITHUB_APP_SLUG, GITHUB_APP_CLIENT_ID,
7677
7778 /** Sealing keys, by the service that holds each (hosted: Wrangler secrets). */
7879 const SECRETS = {
80+ g1t: "USERCONTENT_KEY",
7981 "g1t-actions": "ACTIONS_KEY",
8082 "g1t-integrations": "INTEGRATIONS_KEY",
8183 "g1t-webhooks": "WEBHOOKS_KEY",
240242 // shows: the site's clone URLs, meta tags and agent setup, the API's
241243 // OAuth issuer and MCP server, and identity's mail. No social cards: the
242244 // 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+ }
244250 if (hosted.name === "g1t-api") Object.assign(config.vars, { SITE_URL: PUBLIC_URL, API_URL, MCP_URL });
245251 if (hosted.name === "g1t-identity") config.vars.SITE_URL = PUBLIC_URL;
246252 // Nightly backups' bundles go to a bucket of their own on the same
+4−0
3030 # means PUBLIC_URL's host on API_PORT. MCP_URL, empty, is API_URL/mcp.
3131 API_URL: ${API_URL:-}
3232 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:-}
3337 API_PORT: ${API_PORT:-8789}
3438 GITSTORE_URL: http://gitstore:8080
3539 GITSTORE_SECRET_FILE: /secrets/gitstore
+5−0
2828 if ! grep -q '^IDENTITY_KEY=' "$KEYS"; then
2929 echo "IDENTITY_KEY=$(node -e 'console.log(require("crypto").randomBytes(32).toString("hex"))')" >> "$KEYS"
3030 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
3136 # The packages service's key, which signs registry tokens.
3237 if ! grep -q '^PACKAGES_TOKEN_SECRET=' "$KEYS"; then
3338 echo "PACKAGES_TOKEN_SECRET=$(node -e 'console.log(require("crypto").randomBytes(32).toString("hex"))')" >> "$KEYS"
+9−2
274274 "kind": "react-router",
275275 "worker": "g1t",
276276 "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+ ],
279286 "self_host": "run"
280287 },
281288 "sudo": {
+4−1
137137 `MCP_URL` and `OG_URL` (empty: no social card tags). The root loader
138138 hands them to the page; `meta.ts`, the clone box, agent setup, the
139139 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.
141144 - The API (`apps/api/src/addresses.rs`): `SITE_URL`, `API_URL` (the OAuth
142145 issuer) and `MCP_URL` (the protected resource; a path on the API's host
143146 self-hosted).
+1−0
394394 deleteRelease: (actor, path, id) => call("delete_release", { path, actor, id }),
395395 listFiles: (repoId, ref, limit) => call("list_files", { repoId, ref, skipDirs: [], limit }),
396396 rawBlobs: (repoId, hashes, maxBytes) => call("raw_blobs", { repoId, hashes, maxBytes }),
397+ rawFile: (repoId, ref, path, maxBytes) => call("raw_file", { repoId, ref, path, maxBytes }),
397398 commitFile: (repo, actor, file) => call("commit_file", { repo, actor, ...file }),
398399 land: (sourceId, actor, branch) => call("land", { sourceId, actor, branch }),
399400 compare: (repoId, viewer, base, head, baseBranch) => call("compare", { repoId, viewer, base, head, baseBranch }),
+6−0
294294 /** Blobs' bytes as standard base64, at most 100; `data` is null for one missing or over `maxBytes`. No viewer. */
295295 rawBlobs(repoId: string, hashes: string[], maxBytes: number): Promise<RawBlob[]>;
296296
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+
297300 /**
298301 * Writes one file on a new branch made from the default branch's head, as
299302 * one commit by `actor`, for a change g1t proposes on their behalf (a
400403 /** One blob's bytes, standard base64; null when missing or too large. */
401404 export type RawBlob = { hash: string; size: number; data: string | null };
402405
406+/** One file's bytes, standard base64. */
407+export type RawFile = { size: number; data: string };
408+
403409 /**
404410 * What came of bringing a pull request up to date with the default branch
405411 * without a sandbox. `needs_agent` pushed nothing: the runner's `update`