Skip to content
151 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/SELF_HOSTING.md, "Files in Docs pages").
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 = body instanceof ReadableStream ? new Uint8Array(await new Response(body).arrayBuffer()) : body instanceof Uint8Array ? body : new Uint8Array(body);
84 const response = await send("PUT", key, bytes, { "content-type": contentType });
85 if (!response.ok) throw new Error(`The file store refused the file (${response.status}).`);
86 },
87 async get(key) {
88 const response = await send("GET", key, null);
89 if (response.status === 404) return null;
90 if (!response.ok || !response.body) throw new Error(`The file store didn't answer (${response.status}).`);
91 return {
92 body: response.body,
93 content_type: response.headers.get("content-type") ?? "application/octet-stream",
94 bytes: Number(response.headers.get("content-length") ?? "0"),
95 etag: response.headers.get("etag") ?? `"${key}"`,
96 };
97 },
98 async delete(key) {
99 const response = await send("DELETE", key, null);
100 if (!response.ok && response.status !== 404) throw new Error(`The file store didn't delete the file (${response.status}).`);
101 },
102 };
103}
104
105/** What picks the store: the R2 binding, or `DOCS_FILES=s3` and its settings. */
106export type FileStoreEnv = {
107 FILES?: R2Bucket;
108 DOCS_FILES?: string;
109 DOCS_S3_ENDPOINT?: string;
110 DOCS_S3_BUCKET?: string;
111 DOCS_S3_REGION?: string;
112 DOCS_S3_ACCESS_KEY_ID?: string;
113 DOCS_S3_SECRET_ACCESS_KEY?: string;
114 DOCS_S3_VIRTUAL_HOSTED?: string;
115};
116
117/** The store this deployment keeps files in. Throws when it is set up halfway, so a misconfiguration shows at once. */
118export function fileStore(env: FileStoreEnv): FileStore {
119 if ((env.DOCS_FILES ?? "").toLowerCase() === "s3") {
120 const missing = (["DOCS_S3_ENDPOINT", "DOCS_S3_BUCKET", "DOCS_S3_ACCESS_KEY_ID", "DOCS_S3_SECRET_ACCESS_KEY"] as const).filter((k) => !env[k]);
121 if (missing.length) throw new Error(`DOCS_FILES=s3 needs ${missing.join(", ")}.`);
122 return s3FileStore({
123 endpoint: env.DOCS_S3_ENDPOINT!,
124 bucket: env.DOCS_S3_BUCKET!,
125 region: env.DOCS_S3_REGION || "us-east-1",
126 access_key_id: env.DOCS_S3_ACCESS_KEY_ID!,
127 secret_access_key: env.DOCS_S3_SECRET_ACCESS_KEY!,
128 virtual_hosted: env.DOCS_S3_VIRTUAL_HOSTED === "true",
129 });
130 }
131 if (!env.FILES) throw new Error("No file store: bind FILES (R2) or set DOCS_FILES=s3.");
132 return r2FileStore(env.FILES);
133}
134
135/** Types a page's file is served as; anything else is served as a download. */
136const INLINE = new Set(["image/png", "image/jpeg", "image/gif", "image/webp", "image/avif", "video/mp4", "video/webm", "audio/mpeg", "audio/ogg", "audio/wav", "application/pdf"]);
137
138/** 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). */
139export function servedType(contentType: string): string {
140 const type = String(contentType ?? "").split(";")[0]!.trim().toLowerCase();
141 return INLINE.has(type) ? type : "application/octet-stream";
142}
143
144/** A file name safe to keep and to put in a header. */
145export function safeName(name: string): string {
146 const clean = String(name ?? "")
147 .replace(/[\\/:*?"<>|\u0000-\u001f]+/g, "_")
148 .trim()
149 .slice(0, 180);
150 return clean || "file";
151}