@striae-org/striae 5.0.0 → 5.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/.env.example +7 -3
  2. package/app/components/actions/case-export/download-handlers.ts +23 -7
  3. package/app/components/actions/case-manage.ts +24 -9
  4. package/app/components/actions/generate-pdf.ts +52 -4
  5. package/app/components/actions/image-manage.ts +48 -48
  6. package/app/routes/striae/hooks/use-striae-reset-helpers.ts +4 -0
  7. package/app/routes/striae/striae.tsx +16 -4
  8. package/app/types/file.ts +18 -2
  9. package/app/utils/api/image-api-client.ts +49 -1
  10. package/app/utils/data/operations/case-operations.ts +13 -1
  11. package/app/utils/data/operations/confirmation-summary-operations.ts +38 -1
  12. package/app/utils/data/operations/file-annotation-operations.ts +13 -1
  13. package/functions/api/image/[[path]].ts +2 -1
  14. package/package.json +2 -2
  15. package/scripts/deploy-config.sh +191 -20
  16. package/scripts/deploy-pages-secrets.sh +0 -6
  17. package/scripts/deploy-worker-secrets.sh +67 -6
  18. package/worker-configuration.d.ts +15 -7
  19. package/workers/audit-worker/package.json +1 -4
  20. package/workers/audit-worker/src/audit-worker.example.ts +522 -61
  21. package/workers/audit-worker/wrangler.jsonc.example +5 -0
  22. package/workers/data-worker/package.json +1 -4
  23. package/workers/data-worker/src/data-worker.example.ts +280 -2
  24. package/workers/data-worker/src/encryption-utils.ts +145 -1
  25. package/workers/data-worker/wrangler.jsonc.example +3 -1
  26. package/workers/image-worker/package.json +1 -4
  27. package/workers/image-worker/src/encryption-utils.ts +217 -0
  28. package/workers/image-worker/src/image-worker.example.ts +449 -129
  29. package/workers/image-worker/worker-configuration.d.ts +3 -2
  30. package/workers/image-worker/wrangler.jsonc.example +7 -0
  31. package/workers/keys-worker/package.json +1 -4
  32. package/workers/pdf-worker/package.json +1 -4
  33. package/workers/user-worker/package.json +1 -4
@@ -1,179 +1,478 @@
1
+ import {
2
+ decryptBinaryFromStorage,
3
+ encryptBinaryForStorage,
4
+ type DataAtRestEnvelope
5
+ } from './encryption-utils';
6
+
1
7
  interface Env {
2
- API_TOKEN: string;
3
- ACCOUNT_ID: string;
4
- HMAC_KEY: string;
8
+ IMAGES_API_TOKEN: string;
9
+ STRIAE_FILES: R2Bucket;
10
+ DATA_AT_REST_ENCRYPTION_PRIVATE_KEY: string;
11
+ DATA_AT_REST_ENCRYPTION_PUBLIC_KEY: string;
12
+ DATA_AT_REST_ENCRYPTION_KEY_ID: string;
13
+ IMAGE_SIGNED_URL_SECRET?: string;
14
+ IMAGE_SIGNED_URL_TTL_SECONDS?: string;
15
+ }
16
+
17
+ interface UploadResult {
18
+ id: string;
19
+ filename: string;
20
+ uploaded: string;
5
21
  }
6
22
 
7
- interface CloudflareImagesResponse {
23
+ interface UploadResponse {
24
+ success: boolean;
25
+ errors: Array<{ code: number; message: string }>;
26
+ messages: string[];
27
+ result: UploadResult;
28
+ }
29
+
30
+ interface SuccessResponse {
8
31
  success: boolean;
9
- errors?: Array<{
10
- code: number;
11
- message: string;
12
- }>;
13
- messages?: string[];
14
- result?: {
15
- id: string;
16
- filename: string;
17
- uploaded: string;
18
- requireSignedURLs: boolean;
19
- variants: string[];
20
- };
21
32
  }
22
33
 
23
34
  interface ErrorResponse {
24
35
  error: string;
25
36
  }
26
37
 
27
- type APIResponse = CloudflareImagesResponse | ErrorResponse | string;
38
+ interface SignedUrlResult {
39
+ fileId: string;
40
+ url: string;
41
+ expiresAt: string;
42
+ expiresInSeconds: number;
43
+ }
44
+
45
+ interface SignedUrlResponse {
46
+ success: boolean;
47
+ result: SignedUrlResult;
48
+ }
49
+
50
+ type APIResponse = UploadResponse | SuccessResponse | ErrorResponse | SignedUrlResponse;
28
51
 
29
- const API_BASE = "https://api.cloudflare.com/client/v4/accounts";
52
+ interface SignedAccessPayload {
53
+ fileId: string;
54
+ iat: number;
55
+ exp: number;
56
+ nonce: string;
57
+ }
58
+
59
+ const DEFAULT_SIGNED_URL_TTL_SECONDS = 3600;
60
+ const MAX_SIGNED_URL_TTL_SECONDS = 86400;
30
61
 
31
- /**
32
- * CORS headers to allow requests from the Striae app
33
- */
34
62
  const corsHeaders: Record<string, string> = {
35
63
  'Access-Control-Allow-Origin': 'PAGES_CUSTOM_DOMAIN',
36
64
  'Access-Control-Allow-Methods': 'GET, POST, DELETE, OPTIONS',
37
- 'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-Custom-Auth-Key',
38
- 'Content-Type': 'application/json'
65
+ 'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-Custom-Auth-Key'
39
66
  };
40
67
 
41
- const createResponse = (data: APIResponse, status: number = 200): Response => new Response(
42
- typeof data === 'string' ? data : JSON.stringify(data),
43
- { status, headers: corsHeaders }
68
+ const createJsonResponse = (data: APIResponse, status: number = 200): Response => new Response(
69
+ JSON.stringify(data),
70
+ {
71
+ status,
72
+ headers: {
73
+ ...corsHeaders,
74
+ 'Content-Type': 'application/json'
75
+ }
76
+ }
44
77
  );
45
78
 
46
- const hasValidToken = (request: Request, env: Env): boolean => {
47
- const authHeader = request.headers.get("Authorization");
48
- const expectedToken = `Bearer ${env.API_TOKEN}`;
79
+ function hasValidToken(request: Request, env: Env): boolean {
80
+ const authHeader = request.headers.get('Authorization');
81
+ const expectedToken = `Bearer ${env.IMAGES_API_TOKEN}`;
49
82
  return authHeader === expectedToken;
50
- };
83
+ }
84
+
85
+ function requireEncryptionUploadConfig(env: Env): void {
86
+ if (!env.DATA_AT_REST_ENCRYPTION_PUBLIC_KEY || !env.DATA_AT_REST_ENCRYPTION_KEY_ID) {
87
+ throw new Error('Data-at-rest encryption is not configured for image uploads');
88
+ }
89
+ }
90
+
91
+ function requireEncryptionRetrievalConfig(env: Env): void {
92
+ if (!env.DATA_AT_REST_ENCRYPTION_PRIVATE_KEY) {
93
+ throw new Error('Data-at-rest decryption is not configured for image retrieval');
94
+ }
95
+ }
96
+
97
+ function parseFileId(pathname: string): string | null {
98
+ const encodedFileId = pathname.startsWith('/') ? pathname.slice(1) : pathname;
99
+ if (!encodedFileId) {
100
+ return null;
101
+ }
102
+
103
+ let decodedFileId = '';
104
+ try {
105
+ decodedFileId = decodeURIComponent(encodedFileId);
106
+ } catch {
107
+ return null;
108
+ }
109
+
110
+ if (!decodedFileId || decodedFileId.includes('/')) {
111
+ return null;
112
+ }
113
+
114
+ return decodedFileId;
115
+ }
116
+
117
+ function parsePathSegments(pathname: string): string[] | null {
118
+ const normalized = pathname.startsWith('/') ? pathname.slice(1) : pathname;
119
+ if (!normalized) {
120
+ return [];
121
+ }
122
+
123
+ const rawSegments = normalized.split('/');
124
+ const decodedSegments: string[] = [];
125
+
126
+ for (const segment of rawSegments) {
127
+ if (!segment) {
128
+ return null;
129
+ }
130
+
131
+ let decoded = '';
132
+ try {
133
+ decoded = decodeURIComponent(segment);
134
+ } catch {
135
+ return null;
136
+ }
137
+
138
+ if (!decoded || decoded.includes('/')) {
139
+ return null;
140
+ }
141
+
142
+ decodedSegments.push(decoded);
143
+ }
144
+
145
+ return decodedSegments;
146
+ }
147
+
148
+ function base64UrlEncode(input: ArrayBuffer | Uint8Array): string {
149
+ const bytes = input instanceof Uint8Array ? input : new Uint8Array(input);
150
+ let binary = '';
151
+ for (let i = 0; i < bytes.length; i += 1) {
152
+ binary += String.fromCharCode(bytes[i]);
153
+ }
154
+
155
+ return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
156
+ }
157
+
158
+ function base64UrlDecode(input: string): Uint8Array | null {
159
+ if (!input || /[^A-Za-z0-9_-]/.test(input)) {
160
+ return null;
161
+ }
162
+
163
+ const paddingLength = (4 - (input.length % 4)) % 4;
164
+ const padded = input.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat(paddingLength);
165
+
166
+ try {
167
+ const binary = atob(padded);
168
+ const bytes = new Uint8Array(binary.length);
169
+ for (let i = 0; i < binary.length; i += 1) {
170
+ bytes[i] = binary.charCodeAt(i);
171
+ }
172
+
173
+ return bytes;
174
+ } catch {
175
+ return null;
176
+ }
177
+ }
178
+
179
+ function normalizeSignedUrlTtlSeconds(requestedTtlSeconds: unknown, env: Env): number {
180
+ const defaultFromEnv = Number.parseInt(env.IMAGE_SIGNED_URL_TTL_SECONDS ?? '', 10);
181
+ const fallbackTtl = Number.isFinite(defaultFromEnv) && defaultFromEnv > 0
182
+ ? defaultFromEnv
183
+ : DEFAULT_SIGNED_URL_TTL_SECONDS;
184
+ const requested = typeof requestedTtlSeconds === 'number' ? requestedTtlSeconds : fallbackTtl;
185
+ const normalized = Math.floor(requested);
186
+
187
+ if (!Number.isFinite(normalized) || normalized <= 0) {
188
+ return fallbackTtl;
189
+ }
190
+
191
+ return Math.min(normalized, MAX_SIGNED_URL_TTL_SECONDS);
192
+ }
193
+
194
+ function requireSignedUrlConfig(env: Env): void {
195
+ const resolvedSecret = (env.IMAGE_SIGNED_URL_SECRET || env.IMAGES_API_TOKEN || '').trim();
196
+ if (resolvedSecret.length === 0) {
197
+ throw new Error('Signed URL configuration is missing');
198
+ }
199
+ }
200
+
201
+ async function getSignedUrlHmacKey(env: Env): Promise<CryptoKey> {
202
+ const resolvedSecret = (env.IMAGE_SIGNED_URL_SECRET || env.IMAGES_API_TOKEN || '').trim();
203
+ const keyBytes = new TextEncoder().encode(resolvedSecret);
204
+ return crypto.subtle.importKey('raw', keyBytes, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign', 'verify']);
205
+ }
206
+
207
+ async function signSignedAccessPayload(payload: SignedAccessPayload, env: Env): Promise<string> {
208
+ const payloadJson = JSON.stringify(payload);
209
+ const payloadBase64Url = base64UrlEncode(new TextEncoder().encode(payloadJson));
210
+ const hmacKey = await getSignedUrlHmacKey(env);
211
+ const signature = await crypto.subtle.sign('HMAC', hmacKey, new TextEncoder().encode(payloadBase64Url));
212
+ const signatureBase64Url = base64UrlEncode(signature);
213
+ return `${payloadBase64Url}.${signatureBase64Url}`;
214
+ }
215
+
216
+ async function verifySignedAccessToken(token: string, fileId: string, env: Env): Promise<boolean> {
217
+ const tokenParts = token.split('.');
218
+ if (tokenParts.length !== 2) {
219
+ return false;
220
+ }
221
+
222
+ const [payloadBase64Url, signatureBase64Url] = tokenParts;
223
+ if (!payloadBase64Url || !signatureBase64Url) {
224
+ return false;
225
+ }
226
+
227
+ const signatureBytes = base64UrlDecode(signatureBase64Url);
228
+ if (!signatureBytes) {
229
+ return false;
230
+ }
231
+
232
+ const signatureBuffer = new Uint8Array(signatureBytes).buffer;
233
+
234
+ const hmacKey = await getSignedUrlHmacKey(env);
235
+ const signatureValid = await crypto.subtle.verify(
236
+ 'HMAC',
237
+ hmacKey,
238
+ signatureBuffer,
239
+ new TextEncoder().encode(payloadBase64Url)
240
+ );
241
+ if (!signatureValid) {
242
+ return false;
243
+ }
244
+
245
+ const payloadBytes = base64UrlDecode(payloadBase64Url);
246
+ if (!payloadBytes) {
247
+ return false;
248
+ }
249
+
250
+ let payload: SignedAccessPayload;
251
+ try {
252
+ payload = JSON.parse(new TextDecoder().decode(payloadBytes)) as SignedAccessPayload;
253
+ } catch {
254
+ return false;
255
+ }
256
+
257
+ if (payload.fileId !== fileId) {
258
+ return false;
259
+ }
260
+
261
+ const nowEpochSeconds = Math.floor(Date.now() / 1000);
262
+ if (!Number.isInteger(payload.exp) || payload.exp <= nowEpochSeconds) {
263
+ return false;
264
+ }
265
+
266
+ if (!Number.isInteger(payload.iat) || payload.iat > nowEpochSeconds + 300) {
267
+ return false;
268
+ }
269
+
270
+ return typeof payload.nonce === 'string' && payload.nonce.length > 0;
271
+ }
272
+
273
+ function extractEnvelope(file: R2ObjectBody): DataAtRestEnvelope | null {
274
+ const metadata = file.customMetadata;
275
+ if (!metadata) {
276
+ return null;
277
+ }
278
+
279
+ const { algorithm, encryptionVersion, keyId, dataIv, wrappedKey } = metadata;
280
+ if (
281
+ typeof algorithm !== 'string' ||
282
+ typeof encryptionVersion !== 'string' ||
283
+ typeof keyId !== 'string' ||
284
+ typeof dataIv !== 'string' ||
285
+ typeof wrappedKey !== 'string'
286
+ ) {
287
+ return null;
288
+ }
289
+
290
+ return {
291
+ algorithm,
292
+ encryptionVersion,
293
+ keyId,
294
+ dataIv,
295
+ wrappedKey
296
+ };
297
+ }
298
+
299
+ function deriveFileKind(contentType: string): string {
300
+ if (contentType.startsWith('image/')) {
301
+ return 'image';
302
+ }
303
+
304
+ return 'file';
305
+ }
51
306
 
52
- /**
53
- * Handle image upload requests
54
- */
55
307
  async function handleImageUpload(request: Request, env: Env): Promise<Response> {
56
308
  if (!hasValidToken(request, env)) {
57
- return createResponse({ error: 'Unauthorized' }, 403);
309
+ return createJsonResponse({ error: 'Unauthorized' }, 403);
58
310
  }
59
311
 
312
+ requireEncryptionUploadConfig(env);
313
+
60
314
  const formData = await request.formData();
61
- const endpoint = `${API_BASE}/${env.ACCOUNT_ID}/images/v1`;
315
+ const fileValue = formData.get('file');
316
+ if (!(fileValue instanceof Blob)) {
317
+ return createJsonResponse({ error: 'Missing file upload payload' }, 400);
318
+ }
62
319
 
63
- // Add requireSignedURLs to form data
64
- formData.append('requireSignedURLs', 'true');
320
+ const fileBlob = fileValue;
321
+ const uploadedAt = new Date().toISOString();
322
+ const filename = fileValue instanceof File && fileValue.name ? fileValue.name : 'upload.bin';
323
+ const contentType = fileBlob.type || 'application/octet-stream';
324
+ const fileId = crypto.randomUUID().replace(/-/g, '');
325
+ const plaintextBytes = await fileBlob.arrayBuffer();
65
326
 
66
- const response = await fetch(endpoint, {
67
- method: 'POST',
68
- headers: {
69
- 'Authorization': `Bearer ${env.API_TOKEN}`,
70
- },
71
- body: formData
327
+ const encryptedPayload = await encryptBinaryForStorage(
328
+ plaintextBytes,
329
+ env.DATA_AT_REST_ENCRYPTION_PUBLIC_KEY,
330
+ env.DATA_AT_REST_ENCRYPTION_KEY_ID
331
+ );
332
+
333
+ await env.STRIAE_FILES.put(fileId, encryptedPayload.ciphertext, {
334
+ customMetadata: {
335
+ algorithm: encryptedPayload.envelope.algorithm,
336
+ encryptionVersion: encryptedPayload.envelope.encryptionVersion,
337
+ keyId: encryptedPayload.envelope.keyId,
338
+ dataIv: encryptedPayload.envelope.dataIv,
339
+ wrappedKey: encryptedPayload.envelope.wrappedKey,
340
+ contentType,
341
+ originalFilename: filename,
342
+ byteLength: String(fileBlob.size),
343
+ createdAt: uploadedAt,
344
+ fileKind: deriveFileKind(contentType)
345
+ }
346
+ });
347
+
348
+ return createJsonResponse({
349
+ success: true,
350
+ errors: [],
351
+ messages: [],
352
+ result: {
353
+ id: fileId,
354
+ filename,
355
+ uploaded: uploadedAt
356
+ }
72
357
  });
73
-
74
- const data: CloudflareImagesResponse = await response.json();
75
- return createResponse(data, response.status);
76
358
  }
77
359
 
78
- /**
79
- * Handle image delete requests
80
- */
81
360
  async function handleImageDelete(request: Request, env: Env): Promise<Response> {
82
361
  if (!hasValidToken(request, env)) {
83
- return createResponse({ error: 'Unauthorized' }, 403);
362
+ return createJsonResponse({ error: 'Unauthorized' }, 403);
84
363
  }
85
364
 
86
- const url = new URL(request.url);
87
- const imageId = url.pathname.split('/').pop();
88
-
89
- if (!imageId) {
90
- return createResponse({ error: 'Image ID is required' }, 400);
365
+ const fileId = parseFileId(new URL(request.url).pathname);
366
+ if (!fileId) {
367
+ return createJsonResponse({ error: 'Image ID is required' }, 400);
91
368
  }
92
-
93
- const endpoint = `${API_BASE}/${env.ACCOUNT_ID}/images/v1/${imageId}`;
94
- const response = await fetch(endpoint, {
95
- method: 'DELETE',
96
- headers: {
97
- 'Authorization': `Bearer ${env.API_TOKEN}`,
98
- }
99
- });
100
-
101
- const data: CloudflareImagesResponse = await response.json();
102
- return createResponse(data, response.status);
103
- }
104
-
105
- /**
106
- * Handle Signed URL generation
107
- */
108
- const EXPIRATION = 60 * 60; // 1 hour
109
-
110
- const bufferToHex = (buffer: ArrayBuffer): string =>
111
- [...new Uint8Array(buffer)].map(x => x.toString(16).padStart(2, '0')).join('');
112
-
113
- async function generateSignedUrl(url: URL, env: Env): Promise<Response> {
114
- const encoder = new TextEncoder();
115
- const secretKeyData = encoder.encode(env.HMAC_KEY);
116
- const key = await crypto.subtle.importKey(
117
- 'raw',
118
- secretKeyData,
119
- { name: 'HMAC', hash: 'SHA-256' },
120
- false,
121
- ['sign']
122
- );
123
-
124
- // Add expiration
125
- const expiry = Math.floor(Date.now() / 1000) + EXPIRATION;
126
- url.searchParams.set('exp', expiry.toString());
127
369
 
128
- const stringToSign = url.pathname + '?' + url.searchParams.toString();
129
- const mac = await crypto.subtle.sign('HMAC', key, encoder.encode(stringToSign));
130
- const sig = bufferToHex(mac);
131
-
132
- // Add signature
133
- url.searchParams.set('sig', sig);
370
+ const existing = await env.STRIAE_FILES.head(fileId);
371
+ if (!existing) {
372
+ return createJsonResponse({ error: 'File not found' }, 404);
373
+ }
134
374
 
135
- // Return the modified URL with signature and expiration
136
- return new Response(url.toString(), {
137
- headers: corsHeaders
138
- });
375
+ await env.STRIAE_FILES.delete(fileId);
376
+ return createJsonResponse({ success: true });
139
377
  }
140
378
 
141
- async function handleImageServing(request: Request, env: Env): Promise<Response> {
379
+ async function handleSignedUrlMinting(request: Request, env: Env, fileId: string): Promise<Response> {
142
380
  if (!hasValidToken(request, env)) {
143
- return createResponse({ error: 'Unauthorized' }, 403);
381
+ return createJsonResponse({ error: 'Unauthorized' }, 403);
382
+ }
383
+
384
+ requireSignedUrlConfig(env);
385
+
386
+ const existing = await env.STRIAE_FILES.head(fileId);
387
+ if (!existing) {
388
+ return createJsonResponse({ error: 'File not found' }, 404);
144
389
  }
145
390
 
146
- const url = new URL(request.url);
147
- const encodedPath = url.pathname.slice(1);
148
- if (!encodedPath) {
149
- return createResponse({ error: 'Image delivery URL is required' }, 400);
391
+ let requestedExpiresInSeconds: number | undefined;
392
+ const contentType = request.headers.get('Content-Type') || '';
393
+ if (contentType.includes('application/json')) {
394
+ const requestBody = await request.json().catch(() => null) as { expiresInSeconds?: number } | null;
395
+ if (requestBody && typeof requestBody.expiresInSeconds === 'number') {
396
+ requestedExpiresInSeconds = requestBody.expiresInSeconds;
397
+ }
150
398
  }
151
399
 
152
- let decodedPath: string;
400
+ const nowEpochSeconds = Math.floor(Date.now() / 1000);
401
+ const ttlSeconds = normalizeSignedUrlTtlSeconds(requestedExpiresInSeconds, env);
402
+ const payload: SignedAccessPayload = {
403
+ fileId,
404
+ iat: nowEpochSeconds,
405
+ exp: nowEpochSeconds + ttlSeconds,
406
+ nonce: crypto.randomUUID().replace(/-/g, '')
407
+ };
153
408
 
154
- try {
155
- decodedPath = decodeURIComponent(encodedPath);
156
- } catch {
157
- return createResponse({ error: 'Image delivery URL must be URL-encoded' }, 400);
409
+ const signedToken = await signSignedAccessPayload(payload, env);
410
+ const signedPath = `/${encodeURIComponent(fileId)}?st=${encodeURIComponent(signedToken)}`;
411
+ const signedUrl = new URL(signedPath, request.url).toString();
412
+
413
+ return createJsonResponse({
414
+ success: true,
415
+ result: {
416
+ fileId,
417
+ url: signedUrl,
418
+ expiresAt: new Date(payload.exp * 1000).toISOString(),
419
+ expiresInSeconds: ttlSeconds
420
+ }
421
+ });
422
+ }
423
+
424
+ async function handleImageServing(request: Request, env: Env, fileId: string): Promise<Response> {
425
+ const requestUrl = new URL(request.url);
426
+ const hasSignedToken = requestUrl.searchParams.has('st');
427
+ const signedToken = requestUrl.searchParams.get('st');
428
+ if (hasSignedToken) {
429
+ requireSignedUrlConfig(env);
430
+
431
+ if (!signedToken || signedToken.trim().length === 0) {
432
+ return createJsonResponse({ error: 'Invalid or expired signed URL token' }, 403);
433
+ }
434
+
435
+ const tokenValid = await verifySignedAccessToken(signedToken, fileId, env);
436
+ if (!tokenValid) {
437
+ return createJsonResponse({ error: 'Invalid or expired signed URL token' }, 403);
438
+ }
439
+ } else if (!hasValidToken(request, env)) {
440
+ return createJsonResponse({ error: 'Unauthorized' }, 403);
158
441
  }
159
442
 
160
- let imageDeliveryURL: URL;
161
- try {
162
- imageDeliveryURL = new URL(decodedPath);
163
- } catch {
164
- return createResponse({ error: 'Image delivery URL is invalid' }, 400);
443
+ requireEncryptionRetrievalConfig(env);
444
+
445
+ const file = await env.STRIAE_FILES.get(fileId);
446
+ if (!file) {
447
+ return createJsonResponse({ error: 'File not found' }, 404);
165
448
  }
166
449
 
167
- if (imageDeliveryURL.protocol !== 'https:' || imageDeliveryURL.hostname !== 'imagedelivery.net') {
168
- return createResponse({ error: 'Image delivery URL must target imagedelivery.net over HTTPS' }, 400);
450
+ const envelope = extractEnvelope(file);
451
+ if (!envelope) {
452
+ return createJsonResponse({ error: 'Missing data-at-rest envelope metadata' }, 500);
169
453
  }
170
-
171
- return generateSignedUrl(imageDeliveryURL, env);
454
+
455
+ const encryptedData = await file.arrayBuffer();
456
+ const plaintext = await decryptBinaryFromStorage(
457
+ encryptedData,
458
+ envelope,
459
+ env.DATA_AT_REST_ENCRYPTION_PRIVATE_KEY
460
+ );
461
+
462
+ const contentType = file.customMetadata?.contentType || 'application/octet-stream';
463
+ const filename = file.customMetadata?.originalFilename || fileId;
464
+
465
+ return new Response(plaintext, {
466
+ status: 200,
467
+ headers: {
468
+ ...corsHeaders,
469
+ 'Cache-Control': 'no-store',
470
+ 'Content-Type': contentType,
471
+ 'Content-Disposition': `inline; filename="${filename.replace(/"/g, '')}"`
472
+ }
473
+ });
172
474
  }
173
475
 
174
- /**
175
- * Main worker functions
176
- */
177
476
  export default {
178
477
  async fetch(request: Request, env: Env): Promise<Response> {
179
478
  if (request.method === 'OPTIONS') {
@@ -181,19 +480,40 @@ export default {
181
480
  }
182
481
 
183
482
  try {
483
+ const requestUrl = new URL(request.url);
484
+ const pathSegments = parsePathSegments(requestUrl.pathname);
485
+ if (!pathSegments) {
486
+ return createJsonResponse({ error: 'Invalid image path encoding' }, 400);
487
+ }
488
+
184
489
  switch (request.method) {
185
- case 'POST':
186
- return handleImageUpload(request, env);
187
- case 'GET':
188
- return handleImageServing(request, env);
490
+ case 'POST': {
491
+ if (pathSegments.length === 0) {
492
+ return handleImageUpload(request, env);
493
+ }
494
+
495
+ if (pathSegments.length === 2 && pathSegments[1] === 'signed-url') {
496
+ return handleSignedUrlMinting(request, env, pathSegments[0]);
497
+ }
498
+
499
+ return createJsonResponse({ error: 'Not found' }, 404);
500
+ }
501
+ case 'GET': {
502
+ const fileId = pathSegments.length === 1 ? pathSegments[0] : null;
503
+ if (!fileId) {
504
+ return createJsonResponse({ error: 'Image ID is required' }, 400);
505
+ }
506
+
507
+ return handleImageServing(request, env, fileId);
508
+ }
189
509
  case 'DELETE':
190
510
  return handleImageDelete(request, env);
191
511
  default:
192
- return createResponse({ error: 'Method not allowed' }, 405);
512
+ return createJsonResponse({ error: 'Method not allowed' }, 405);
193
513
  }
194
514
  } catch (error) {
195
515
  const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';
196
- return createResponse({ error: errorMessage }, 500);
516
+ return createJsonResponse({ error: errorMessage }, 500);
197
517
  }
198
518
  }
199
519
  };
@@ -1,8 +1,9 @@
1
1
  /* eslint-disable */
2
- // Generated by Wrangler by running `wrangler types` (hash: 869ac3b4ce0f52ba3b2e0bc70c49089e)
3
- // Runtime types generated with workerd@1.20250823.0 2026-03-20 nodejs_compat
2
+ // Generated by Wrangler by running `wrangler types` (hash: 031f1b22e4c77b10fe83d4eace0f6b21)
3
+ // Runtime types generated with workerd@1.20250823.0 2026-03-24 nodejs_compat
4
4
  declare namespace Cloudflare {
5
5
  interface Env {
6
+ STRIAE_FILES: R2Bucket;
6
7
  }
7
8
  }
8
9
  interface Env extends Cloudflare.Env {}
@@ -11,5 +11,12 @@
11
11
  "enabled": true
12
12
  },
13
13
 
14
+ "r2_buckets": [
15
+ {
16
+ "binding": "STRIAE_FILES",
17
+ "bucket_name": "FILES_BUCKET_NAME"
18
+ }
19
+ ],
20
+
14
21
  "placement": { "mode": "smart" }
15
22
  }
@@ -5,13 +5,10 @@
5
5
  "scripts": {
6
6
  "deploy": "wrangler deploy",
7
7
  "dev": "wrangler dev",
8
- "start": "wrangler dev",
9
- "test": "vitest"
8
+ "start": "wrangler dev"
10
9
  },
11
10
  "devDependencies": {
12
11
  "@cloudflare/puppeteer": "^1.0.6",
13
- "@cloudflare/vitest-pool-workers": "^0.13.0",
14
- "vitest": "~4.1.0",
15
12
  "wrangler": "^4.76.0"
16
13
  },
17
14
  "overrides": {