Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

167 linesCodeBlameRaw
1/**
2 * Where files put in pages are kept: behind this small interface, so a
3 * self-hosted g1t can keep them in any S3-compatible store. The managed
4 * service uses R2 (`r2FileStore`); `DOCS_FILES=s3` picks `s3FileStore`
5 * (docs.g1t.sh/guides/self-hosting-architecture/, "Storage").
6 */
7import { sha256Hex, sign } from "./sigv4.ts";
8
9export type StoredFile = { body: ReadableStream; content_type: string; bytes: number; etag: string };
10
11export interface FileStore {
12 put(key: string, body: ReadableStream | ArrayBuffer | Uint8Array, contentType: string): Promise<void>;
13 get(key: string): Promise<StoredFile | null>;
14 delete(key: string): Promise<void>;
15}
16
17export function r2FileStore(bucket: R2Bucket): FileStore {
18 return {
19 async put(key, body, contentType) {
20 await bucket.put(key, body, { httpMetadata: { contentType } });
21 },
22 async get(key) {
23 const object = await bucket.get(key);
24 if (!object) return null;
25 return {
26 body: object.body,
27 content_type: object.httpMetadata?.contentType ?? "application/octet-stream",
28 bytes: object.size,
29 etag: object.httpEtag,
30 };
31 },
32 async delete(key) {
33 await bucket.delete(key);
34 },
35 };
36}
37
38/** An S3-compatible store: the endpoint, the bucket and the keys, from the Worker's settings and secrets. */
39export type S3Config = {
40 /** `https://s3.example.com` (or `http://minio:9000` inside a private network). */
41 endpoint: string;
42 bucket: string;
43 /** `us-east-1` unless the store says otherwise; R2 and MinIO accept `auto`/`us-east-1`. */
44 region: string;
45 access_key_id: string;
46 secret_access_key: string;
47 /** `https://<bucket>.<endpoint host>/<key>` instead of `<endpoint>/<bucket>/<key>`. Path style by default: every compatible store takes it. */
48 virtual_hosted?: boolean;
49};
50
51/** Each part of a key encoded, its slashes kept. */
52function keyPath(key: string): string {
53 return key
54 .split("/")
55 .map((part) => encodeURIComponent(part))
56 .join("/");
57}
58
59export function s3FileStore(config: S3Config, fetcher: typeof fetch = fetch): FileStore {
60 const base = new URL(config.endpoint);
61 const urlOf = (key: string) => {
62 const root = base.pathname.replace(/\/+$/, "");
63 if (config.virtual_hosted) return `${base.protocol}//${config.bucket}.${base.host}${root}/${keyPath(key)}`;
64 return `${base.protocol}//${base.host}${root}/${encodeURIComponent(config.bucket)}/${keyPath(key)}`;
65 };
66 const send = async (method: string, key: string, body: Uint8Array | null, headers: Record<string, string> = {}) => {
67 const payloadHash = await sha256Hex(body ?? new Uint8Array());
68 const signed = await sign({
69 method,
70 url: urlOf(key),
71 headers: { ...headers, "x-amz-content-sha256": payloadHash },
72 payload_hash: payloadHash,
73 region: config.region || "us-east-1",
74 service: "s3",
75 credentials: { access_key_id: config.access_key_id, secret_access_key: config.secret_access_key },
76 });
77 const { host: _host, ...send } = signed.headers;
78 return fetcher(urlOf(key), { method, headers: send, body: body as BodyInit | null });
79 };
80 return {
81 async put(key, body, contentType) {
82 // Signed over its bytes: a page's file is at most DOC_MAX_FILE_BYTES.
83 const bytes =
84 body instanceof ReadableStream ? new Uint8Array(await new Response(body).arrayBuffer()) : body instanceof Uint8Array ? body : new Uint8Array(body);
85 const response = await send("PUT", key, bytes, { "content-type": contentType });
86 if (!response.ok) throw new Error(`The file store refused the file (${response.status}).`);
87 },
88 async get(key) {
89 const response = await send("GET", key, null);
90 if (response.status === 404) return null;
91 if (!response.ok || !response.body) throw new Error(`The file store didn't answer (${response.status}).`);
92 return {
93 body: response.body,
94 content_type: response.headers.get("content-type") ?? "application/octet-stream",
95 bytes: Number(response.headers.get("content-length") ?? "0"),
96 etag: response.headers.get("etag") ?? `"${key}"`,
97 };
98 },
99 async delete(key) {
100 const response = await send("DELETE", key, null);
101 if (!response.ok && response.status !== 404) throw new Error(`The file store didn't delete the file (${response.status}).`);
102 },
103 };
104}
105
106/** What picks the store: the R2 binding, or `DOCS_FILES=s3` and its settings. */
107export type FileStoreEnv = {
108 FILES?: R2Bucket;
109 DOCS_FILES?: string;
110 DOCS_S3_ENDPOINT?: string;
111 DOCS_S3_BUCKET?: string;
112 DOCS_S3_REGION?: string;
113 DOCS_S3_ACCESS_KEY_ID?: string;
114 DOCS_S3_SECRET_ACCESS_KEY?: string;
115 DOCS_S3_VIRTUAL_HOSTED?: string;
116};
117
118/** The store this deployment keeps files in. Throws when it is set up halfway, so a misconfiguration shows at once. */
119export function fileStore(env: FileStoreEnv): FileStore {
120 if ((env.DOCS_FILES ?? "").toLowerCase() === "s3") {
121 const missing = (["DOCS_S3_ENDPOINT", "DOCS_S3_BUCKET", "DOCS_S3_ACCESS_KEY_ID", "DOCS_S3_SECRET_ACCESS_KEY"] as const).filter((k) => !env[k]);
122 if (missing.length) throw new Error(`DOCS_FILES=s3 needs ${missing.join(", ")}.`);
123 return s3FileStore({
124 endpoint: env.DOCS_S3_ENDPOINT!,
125 bucket: env.DOCS_S3_BUCKET!,
126 region: env.DOCS_S3_REGION || "us-east-1",
127 access_key_id: env.DOCS_S3_ACCESS_KEY_ID!,
128 secret_access_key: env.DOCS_S3_SECRET_ACCESS_KEY!,
129 virtual_hosted: env.DOCS_S3_VIRTUAL_HOSTED === "true",
130 });
131 }
132 if (!env.FILES) throw new Error("No file store: bind FILES (R2) or set DOCS_FILES=s3.");
133 return r2FileStore(env.FILES);
134}
135
136/** Types a page's file is served as; anything else is served as a download. */
137const INLINE = new Set([
138 "image/png",
139 "image/jpeg",
140 "image/gif",
141 "image/webp",
142 "image/avif",
143 "video/mp4",
144 "video/webm",
145 "audio/mpeg",
146 "audio/ogg",
147 "audio/wav",
148 "application/pdf",
149]);
150
151/** The type a file is kept and served as: images and media as themselves, SVG and everything else as bytes to download (nothing served can run script). */
152export function servedType(contentType: string): string {
153 const type = String(contentType ?? "")
154 .split(";")[0]!
155 .trim()
156 .toLowerCase();
157 return INLINE.has(type) ? type : "application/octet-stream";
158}
159
160/** A file name safe to keep and to put in a header. */
161export function safeName(name: string): string {
162 const clean = String(name ?? "")
163 .replace(/[\\/:*?"<>|\u0000-\u001f]+/g, "_")
164 .trim()
165 .slice(0, 180);
166 return clean || "file";
167}