Skip to content
57 linesCodeBlameRaw
1import { env } from "cloudflare:workers";
2
3import type { Route } from "./+types/downloads-runner";
4
5/**
6 * Releases: `g1t.sh/downloads/<tool>/<version>/<file>`, served from the
7 * g1t-downloads R2 bucket. `runner` is the self-hosted runner
8 * (scripts/runner-release.mjs), `cli` the g1t CLI (scripts/cli-release.mjs),
9 * `desktop` the desktop app (scripts/desktop-release.mjs), `mobile` the
10 * phone app's Android APK (scripts/mobile-release.mjs). `latest/<file>`
11 * is the newest release's, as its `latest.json` names it; the runner's
12 * `latest.json` and `latest.json.sig` are what runners check before
13 * updating, and the desktop app's `latest.json` names each platform's
14 * installer with its signature, as the app's updater reads it.
15 */
16const TOOLS = new Set(["runner", "cli", "desktop", "mobile"]);
17const TYPES: Record<string, string> = {
18 json: "application/json",
19 sig: "text/plain; charset=utf-8",
20 txt: "text/plain; charset=utf-8",
21 apk: "application/vnd.android.package-archive",
22};
23/** Typed files that are still downloaded, not shown: an APK is installed from the phone's downloads. */
24const SAVED = new Set(["apk"]);
25
26async function object(key: string): Promise<R2ObjectBody | null> {
27 const bucket = env.DOWNLOADS;
28 return bucket ? bucket.get(key) : null;
29}
30
31export async function loader({ params }: Route.LoaderArgs) {
32 const tool = params.tool ?? "";
33 const path = (params["*"] ?? "").replace(/^\/+/, "");
34 if (!TOOLS.has(tool) || !path || path.includes("..") || !/^[A-Za-z0-9._/-]+$/.test(path)) {
35 throw new Response("Not found\n", { status: 404 });
36 }
37 let key = `${tool}/${path}`;
38 // `latest/<file>`: the file of the newest release.
39 if (path.startsWith("latest/")) {
40 const manifest = await object(`${tool}/latest.json`);
41 if (!manifest) throw new Response("No release yet\n", { status: 404 });
42 const { version } = (await manifest.json()) as { version: string };
43 key = `${tool}/${version}/${path.slice("latest/".length)}`;
44 }
45 const found = await object(key);
46 if (!found) throw new Response("Not found\n", { status: 404 });
47 const name = key.split("/").pop() ?? "g1t-runner";
48 const extension = name.includes(".") ? name.split(".").pop()! : "";
49 const headers = new Headers({
50 "content-type": TYPES[extension] ?? "application/octet-stream",
51 etag: found.httpEtag,
52 // A version's files never change; what `latest` points to does.
53 "cache-control": path.startsWith("latest") ? "public, max-age=300" : "public, max-age=31536000, immutable",
54 });
55 if (!TYPES[extension] || SAVED.has(extension)) headers.set("content-disposition", `attachment; filename="${name}"`);
56 return new Response(found.body, { headers });
57}