The migration, PRD by PRD
The trail of changes since the upstream fork: the milestone azure-migration issues in order, each paired with the code it produced. How Mike went from hosted-Supabase SaaS to a single-tenant Azure deployment.
backend/src/lib/storage.ts375 lines · StorageProvider L19–31
Outline 35 symbols
- StorageProvider interface export
- R2Provider class
- AzureBlobProvider class
- createProvider function
- _provider const
- _initError const
- storageEnabled const export
- requireProvider function
- uploadFile function export
- downloadFile function export
- listFiles function export
- deleteFile function export
- getSignedUrl function export
- storageKey function export
- pdfStorageKey function export
- generatedDocKey function export
- versionStorageKey function export
- storageExtension function
- normalizeDownloadFilename function export
- sanitizeDispositionFilename function export
- encodeRFC5987 function export
- buildContentDisposition function export
1import {
2 S3Client,
3 PutObjectCommand,
4 GetObjectCommand,
5 DeleteObjectCommand,
6 ListObjectsV2Command,
7} from "@aws-sdk/client-s3";
8import { getSignedUrl as awsGetSignedUrl } from "@aws-sdk/s3-request-presigner";
9import { BlobServiceClient, ContainerClient } from "@azure/storage-blob";
10import { DefaultAzureCredential } from "@azure/identity";
11
12// ─── Provider interface ────────────────────────────────────────────────────────
13//
14// Callers import the module-level functions below (uploadFile, downloadFile,
15// deleteFile, getSignedUrl). Those signatures never change regardless of which
16// provider is active. Adding a new provider means implementing this interface
17// and updating createProvider() — nothing else.
18
19export interface StorageProvider {
20 upload(key: string, content: ArrayBuffer, contentType: string): Promise<void>;
21 download(key: string): Promise<ArrayBuffer | null>;
22 /** All object keys under `prefix` (upstream 44e868e listFiles, relocated). */
23 list(prefix: string): Promise<string[]>;
24 remove(key: string): Promise<void>;
25 /** Direct browser URL, or null when the provider delegates to the backend download proxy. */
26 signedUrl(
27 key: string,
28 expiresIn: number,
29 downloadFilename?: string,
30 ): Promise<string | null>;
31}
32
33// ─── Cloudflare R2 provider ───────────────────────────────────────────────────
34
35class R2Provider implements StorageProvider {
36 private readonly bucket: string;
37 // Upstream caches the S3 client at module level (4f33843, "storage
38 // caching"); dev's provider-class structure relocates that cache into the
39 // provider instance. Upstream's requireStorageConfig() throw-on-upload is
40 // already covered (more strongly) by requireProvider() below.
41 private cachedClient?: S3Client;
42
43 constructor() {
44 if (
45 !process.env.R2_ENDPOINT_URL ||
46 !process.env.R2_ACCESS_KEY_ID ||
47 !process.env.R2_SECRET_ACCESS_KEY
48 ) {
49 throw new Error(
50 "R2 storage requires R2_ENDPOINT_URL, R2_ACCESS_KEY_ID, and R2_SECRET_ACCESS_KEY",
51 );
52 }
53 this.bucket = process.env.R2_BUCKET_NAME ?? "mike";
54 }
55
56 private client(): S3Client {
57 if (!this.cachedClient) {
58 this.cachedClient = new S3Client({
59 region: "auto",
60 endpoint: process.env.R2_ENDPOINT_URL!,
61 forcePathStyle: true,
62 credentials: {
63 accessKeyId: process.env.R2_ACCESS_KEY_ID!,
64 secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!,
65 },
66 });
67 }
68 return this.cachedClient;
69 }
70
71 async upload(
72 key: string,
73 content: ArrayBuffer,
74 contentType: string,
75 ): Promise<void> {
76 await this.client().send(
77 new PutObjectCommand({
78 Bucket: this.bucket,
79 Key: key,
80 Body: Buffer.from(content),
81 ContentType: contentType,
82 }),
83 );
84 }
85
86 async download(key: string): Promise<ArrayBuffer | null> {
87 try {
88 const response = await this.client().send(
89 new GetObjectCommand({ Bucket: this.bucket, Key: key }),
90 );
91 if (!response.Body) return null;
92 const bytes = await response.Body.transformToByteArray();
93 return bytes.buffer as ArrayBuffer;
94 } catch {
95 return null;
96 }
97 }
98
99 async list(prefix: string): Promise<string[]> {
100 const keys: string[] = [];
101 let ContinuationToken: string | undefined;
102 do {
103 const response = await this.client().send(
104 new ListObjectsV2Command({
105 Bucket: this.bucket,
106 Prefix: prefix,
107 ContinuationToken,
108 }),
109 );
110 for (const item of response.Contents ?? []) {
111 if (item.Key) keys.push(item.Key);
112 }
113 ContinuationToken = response.NextContinuationToken;
114 } while (ContinuationToken);
115 return keys;
116 }
117
118 async remove(key: string): Promise<void> {
119 await this.client().send(
120 new DeleteObjectCommand({ Bucket: this.bucket, Key: key }),
121 );
122 }
123
124 async signedUrl(
125 key: string,
126 expiresIn: number,
127 downloadFilename?: string,
128 ): Promise<string | null> {
129 try {
130 const responseContentDisposition = downloadFilename
131 ? buildContentDisposition("attachment", downloadFilename)
132 : undefined;
133 const command = new GetObjectCommand({
134 Bucket: this.bucket,
135 Key: key,
136 ResponseContentDisposition: responseContentDisposition,
137 });
138 return await awsGetSignedUrl(this.client(), command, { expiresIn });
139 } catch {
140 return null;
141 }
142 }
143}
144
145// ─── Azure Blob Storage provider ──────────────────────────────────────────────
146//
147// Auth priority:
148// 1. AZURE_STORAGE_CONNECTION_STRING — connection string (local dev / Azurite)
149// 2. AZURE_STORAGE_ACCOUNT_NAME — account name + DefaultAzureCredential
150// (Managed Identity in Container Apps)
151//
152// Container name defaults to "documents"; override with AZURE_STORAGE_CONTAINER_NAME.
153//
154// signedUrl() returns null because Azure deployments use the backend download
155// proxy (GET /download/:token) rather than direct storage URLs. The /url route
156// falls back to buildDownloadUrl() when this returns null.
157
158class AzureBlobProvider implements StorageProvider {
159 private readonly container: ContainerClient;
160
161 constructor() {
162 const connectionString = process.env.AZURE_STORAGE_CONNECTION_STRING;
163 const accountName = process.env.AZURE_STORAGE_ACCOUNT_NAME;
164 const containerName =
165 process.env.AZURE_STORAGE_CONTAINER_NAME ?? "documents";
166
167 let serviceClient: BlobServiceClient;
168 if (connectionString) {
169 serviceClient = BlobServiceClient.fromConnectionString(connectionString);
170 } else if (accountName) {
171 serviceClient = new BlobServiceClient(
172 `https://${accountName}.blob.core.windows.net`,
173 new DefaultAzureCredential(),
174 );
175 } else {
176 throw new Error(
177 "Azure Blob Storage requires AZURE_STORAGE_CONNECTION_STRING or AZURE_STORAGE_ACCOUNT_NAME",
178 );
179 }
180
181 this.container = serviceClient.getContainerClient(containerName);
182 }
183
184 async upload(
185 key: string,
186 content: ArrayBuffer,
187 contentType: string,
188 ): Promise<void> {
189 const blob = this.container.getBlockBlobClient(key);
190 await blob.uploadData(Buffer.from(content), {
191 blobHTTPHeaders: { blobContentType: contentType },
192 });
193 }
194
195 async download(key: string): Promise<ArrayBuffer | null> {
196 try {
197 const buffer = await this.container.getBlobClient(key).downloadToBuffer();
198 return buffer.buffer as ArrayBuffer;
199 } catch {
200 return null;
201 }
202 }
203
204 async list(prefix: string): Promise<string[]> {
205 const keys: string[] = [];
206 for await (const blob of this.container.listBlobsFlat({ prefix })) {
207 keys.push(blob.name);
208 }
209 return keys;
210 }
211
212 async remove(key: string): Promise<void> {
213 await this.container.getBlobClient(key).deleteIfExists();
214 }
215
216 async signedUrl(
217 _key: string,
218 _expiresIn: number,
219 _downloadFilename?: string,
220 ): Promise<string | null> {
221 return null;
222 }
223}
224
225// ─── Factory ──────────────────────────────────────────────────────────────────
226
227function createProvider(): StorageProvider {
228 if (
229 process.env.AZURE_STORAGE_ACCOUNT_NAME ||
230 process.env.AZURE_STORAGE_CONNECTION_STRING
231 ) {
232 return new AzureBlobProvider();
233 }
234 if (
235 process.env.R2_ENDPOINT_URL &&
236 process.env.R2_ACCESS_KEY_ID &&
237 process.env.R2_SECRET_ACCESS_KEY
238 ) {
239 return new R2Provider();
240 }
241 throw new Error(
242 "No storage provider configured. Set AZURE_STORAGE_ACCOUNT_NAME (Azure) " +
243 "or R2_ENDPOINT_URL + R2_ACCESS_KEY_ID + R2_SECRET_ACCESS_KEY (Cloudflare R2).",
244 );
245}
246
247let _provider: StorageProvider | null = null;
248let _initError: Error | null = null;
249try {
250 _provider = createProvider();
251} catch (err) {
252 // Don't crash at startup — auth-only routes (e.g. /health, /auth/*) must
253 // still work when storage is misconfigured. Defer the failure to the first
254 // storage operation so the route returns a clear 500 instead of silently
255 // dropping bytes (the previous behaviour did the latter — uploads "succeeded"
256 // but no blob was written, leaving DB rows orphaned with paths that point
257 // nowhere).
258 _initError = err instanceof Error ? err : new Error(String(err));
259}
260
261export const storageEnabled = _provider !== null;
262
263function requireProvider(op: string): StorageProvider {
264 if (_provider) return _provider;
265 const reason = _initError?.message ?? "no provider configured";
266 throw new Error(`Storage is not configured (cannot ${op}): ${reason}`);
267}
268
269// ─── Public API ───────────────────────────────────────────────────────────────
270//
271// These signatures are the stable contract. Callers never import the provider
272// classes directly. Mutating operations (upload, remove) throw when storage is
273// unconfigured so the failure surfaces immediately. Read operations
274// (download, signedUrl) return null so callers can fall through to "not
275// found" semantics without a 500.
276
277export async function uploadFile(
278 key: string,
279 content: ArrayBuffer,
280 contentType: string,
281): Promise<void> {
282 await requireProvider("upload").upload(key, content, contentType);
283}
284
285export async function downloadFile(key: string): Promise<ArrayBuffer | null> {
286 return _provider?.download(key) ?? null;
287}
288
289// Read operation — returns [] when storage is unconfigured, mirroring
290// upstream 44e868e's `if (!storageEnabled) return []`.
291export async function listFiles(prefix: string): Promise<string[]> {
292 return _provider?.list(prefix) ?? [];
293}
294
295export async function deleteFile(key: string): Promise<void> {
296 await requireProvider("delete").remove(key);
297}
298
299export async function getSignedUrl(
300 key: string,
301 expiresIn = 3600,
302 downloadFilename?: string,
303): Promise<string | null> {
304 return _provider?.signedUrl(key, expiresIn, downloadFilename) ?? null;
305}
306
307// ─── Storage key helpers ──────────────────────────────────────────────────────
308
309export function storageKey(
310 userId: string,
311 docId: string,
312 filename: string,
313): string {
314 return `documents/${userId}/${docId}/source${storageExtension(filename, ".bin")}`;
315}
316
317export function pdfStorageKey(
318 userId: string,
319 docId: string,
320 stem: string,
321): string {
322 return `documents/${userId}/${docId}/${stem}.pdf`;
323}
324
325export function generatedDocKey(
326 userId: string,
327 docId: string,
328 filename: string,
329): string {
330 return `generated/${userId}/${docId}/generated${storageExtension(filename, ".docx")}`;
331}
332
333export function versionStorageKey(
334 userId: string,
335 docId: string,
336 versionSlug: string,
337 filename: string,
338): string {
339 return `documents/${userId}/${docId}/versions/${versionSlug}${storageExtension(filename, ".bin")}`;
340}
341
342function storageExtension(filename: string, fallback: string): string {
343 const lastDot = filename.lastIndexOf(".");
344 if (lastDot < 0) return fallback;
345 const ext = filename.slice(lastDot).toLowerCase();
346 return /^\.[a-z0-9]{1,16}$/.test(ext) ? ext : fallback;
347}
348
349// ─── Content-Disposition helpers ─────────────────────────────────────────────
350
351export function normalizeDownloadFilename(name: string): string {
352 const trimmed = name.trim();
353 const base = trimmed || "download";
354 return base.replace(/[\x00-\x1F\x7F]/g, "_").replace(/[\\/]/g, "_");
355}
356
357export function sanitizeDispositionFilename(name: string): string {
358 return normalizeDownloadFilename(name).replace(/["\\]/g, "_");
359}
360
361export function encodeRFC5987(str: string): string {
362 return encodeURIComponent(str).replace(
363 /['()*]/g,
364 (c) => "%" + c.charCodeAt(0).toString(16).toUpperCase(),
365 );
366}
367
368export function buildContentDisposition(
369 kind: "inline" | "attachment",
370 filename: string,
371): string {
372 const normalized = normalizeDownloadFilename(filename);
373 return `${kind}; filename="${sanitizeDispositionFilename(normalized)}"; filename*=UTF-8''${encodeRFC5987(normalized)}`;
374}
375