@striae-org/striae 5.0.0 → 5.1.0

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 (30) hide show
  1. package/.env.example +5 -2
  2. package/app/components/actions/case-export/download-handlers.ts +6 -7
  3. package/app/components/actions/case-manage.ts +10 -11
  4. package/app/components/actions/generate-pdf.ts +43 -1
  5. package/app/components/actions/image-manage.ts +13 -45
  6. package/app/routes/striae/hooks/use-striae-reset-helpers.ts +4 -0
  7. package/app/routes/striae/striae.tsx +15 -4
  8. package/app/utils/data/operations/case-operations.ts +13 -1
  9. package/app/utils/data/operations/confirmation-summary-operations.ts +38 -1
  10. package/app/utils/data/operations/file-annotation-operations.ts +13 -1
  11. package/package.json +2 -2
  12. package/scripts/deploy-config.sh +149 -6
  13. package/scripts/deploy-pages-secrets.sh +0 -6
  14. package/scripts/deploy-worker-secrets.sh +66 -5
  15. package/scripts/encrypt-r2-backfill.mjs +376 -0
  16. package/worker-configuration.d.ts +13 -7
  17. package/workers/audit-worker/package.json +1 -4
  18. package/workers/audit-worker/src/audit-worker.example.ts +522 -61
  19. package/workers/audit-worker/wrangler.jsonc.example +5 -0
  20. package/workers/data-worker/package.json +1 -4
  21. package/workers/data-worker/src/data-worker.example.ts +280 -2
  22. package/workers/data-worker/src/encryption-utils.ts +145 -1
  23. package/workers/data-worker/wrangler.jsonc.example +4 -0
  24. package/workers/image-worker/package.json +1 -4
  25. package/workers/image-worker/src/encryption-utils.ts +217 -0
  26. package/workers/image-worker/src/image-worker.example.ts +196 -127
  27. package/workers/image-worker/wrangler.jsonc.example +7 -0
  28. package/workers/keys-worker/package.json +1 -4
  29. package/workers/pdf-worker/package.json +1 -4
  30. package/workers/user-worker/package.json +1 -4
@@ -0,0 +1,217 @@
1
+ export function base64UrlDecode(value: string): Uint8Array {
2
+ const normalized = value.replace(/-/g, '+').replace(/_/g, '/');
3
+ const padding = '='.repeat((4 - (normalized.length % 4)) % 4);
4
+ const decoded = atob(normalized + padding);
5
+ const bytes = new Uint8Array(decoded.length);
6
+
7
+ for (let i = 0; i < decoded.length; i += 1) {
8
+ bytes[i] = decoded.charCodeAt(i);
9
+ }
10
+
11
+ return bytes;
12
+ }
13
+
14
+ export function base64UrlEncode(value: Uint8Array): string {
15
+ let binary = '';
16
+ const chunkSize = 8192;
17
+
18
+ for (let i = 0; i < value.length; i += chunkSize) {
19
+ const chunk = value.subarray(i, Math.min(i + chunkSize, value.length));
20
+ for (let j = 0; j < chunk.length; j += 1) {
21
+ binary += String.fromCharCode(chunk[j]);
22
+ }
23
+ }
24
+
25
+ return btoa(binary)
26
+ .replace(/\+/g, '-')
27
+ .replace(/\//g, '_')
28
+ .replace(/=+$/g, '');
29
+ }
30
+
31
+ const DATA_AT_REST_ENCRYPTION_ALGORITHM = 'RSA-OAEP-AES-256-GCM';
32
+ const DATA_AT_REST_ENCRYPTION_VERSION = '1.0';
33
+
34
+ export interface DataAtRestEnvelope {
35
+ algorithm: string;
36
+ encryptionVersion: string;
37
+ keyId: string;
38
+ dataIv: string;
39
+ wrappedKey: string;
40
+ }
41
+
42
+ interface EncryptBinaryAtRestResult {
43
+ ciphertext: Uint8Array;
44
+ envelope: DataAtRestEnvelope;
45
+ }
46
+
47
+ function parseSpkiPublicKey(publicKey: string): ArrayBuffer {
48
+ const normalizedKey = publicKey
49
+ .trim()
50
+ .replace(/^['"]|['"]$/g, '')
51
+ .replace(/\\n/g, '\n');
52
+
53
+ const pemBody = normalizedKey
54
+ .replace('-----BEGIN PUBLIC KEY-----', '')
55
+ .replace('-----END PUBLIC KEY-----', '')
56
+ .replace(/\s+/g, '');
57
+
58
+ if (!pemBody) {
59
+ throw new Error('Encryption public key is invalid');
60
+ }
61
+
62
+ const binary = atob(pemBody);
63
+ const bytes = new Uint8Array(binary.length);
64
+
65
+ for (let index = 0; index < binary.length; index += 1) {
66
+ bytes[index] = binary.charCodeAt(index);
67
+ }
68
+
69
+ return bytes.buffer;
70
+ }
71
+
72
+ function parsePkcs8PrivateKey(privateKey: string): ArrayBuffer {
73
+ const normalizedKey = privateKey
74
+ .trim()
75
+ .replace(/^['"]|['"]$/g, '')
76
+ .replace(/\\n/g, '\n');
77
+
78
+ const pemBody = normalizedKey
79
+ .replace('-----BEGIN PRIVATE KEY-----', '')
80
+ .replace('-----END PRIVATE KEY-----', '')
81
+ .replace(/\s+/g, '');
82
+
83
+ if (!pemBody) {
84
+ throw new Error('Encryption private key is invalid');
85
+ }
86
+
87
+ const binary = atob(pemBody);
88
+ const bytes = new Uint8Array(binary.length);
89
+
90
+ for (let index = 0; index < binary.length; index += 1) {
91
+ bytes[index] = binary.charCodeAt(index);
92
+ }
93
+
94
+ return bytes.buffer;
95
+ }
96
+
97
+ async function importRsaOaepPrivateKey(privateKeyPem: string): Promise<CryptoKey> {
98
+ return crypto.subtle.importKey(
99
+ 'pkcs8',
100
+ parsePkcs8PrivateKey(privateKeyPem),
101
+ {
102
+ name: 'RSA-OAEP',
103
+ hash: 'SHA-256'
104
+ },
105
+ false,
106
+ ['decrypt']
107
+ );
108
+ }
109
+
110
+ async function importRsaOaepPublicKey(publicKeyPem: string): Promise<CryptoKey> {
111
+ return crypto.subtle.importKey(
112
+ 'spki',
113
+ parseSpkiPublicKey(publicKeyPem),
114
+ {
115
+ name: 'RSA-OAEP',
116
+ hash: 'SHA-256'
117
+ },
118
+ false,
119
+ ['encrypt']
120
+ );
121
+ }
122
+
123
+ async function createAesGcmKey(usages: KeyUsage[]): Promise<CryptoKey> {
124
+ return crypto.subtle.generateKey(
125
+ {
126
+ name: 'AES-GCM',
127
+ length: 256
128
+ },
129
+ true,
130
+ usages
131
+ ) as Promise<CryptoKey>;
132
+ }
133
+
134
+ async function wrapAesKey(aesKey: CryptoKey, publicKeyPem: string): Promise<string> {
135
+ const rsaPublicKey = await importRsaOaepPublicKey(publicKeyPem);
136
+ const rawAesKey = await crypto.subtle.exportKey('raw', aesKey);
137
+ const wrappedKey = await crypto.subtle.encrypt(
138
+ { name: 'RSA-OAEP' },
139
+ rsaPublicKey,
140
+ rawAesKey as BufferSource
141
+ );
142
+
143
+ return base64UrlEncode(new Uint8Array(wrappedKey));
144
+ }
145
+
146
+ async function unwrapAesKey(wrappedKeyBase64: string, privateKeyPem: string): Promise<CryptoKey> {
147
+ const rsaPrivateKey = await importRsaOaepPrivateKey(privateKeyPem);
148
+ const wrappedKeyBytes = base64UrlDecode(wrappedKeyBase64);
149
+
150
+ const rawAesKey = await crypto.subtle.decrypt(
151
+ { name: 'RSA-OAEP' },
152
+ rsaPrivateKey,
153
+ wrappedKeyBytes as BufferSource
154
+ );
155
+
156
+ return crypto.subtle.importKey(
157
+ 'raw',
158
+ rawAesKey,
159
+ { name: 'AES-GCM' },
160
+ false,
161
+ ['encrypt', 'decrypt']
162
+ );
163
+ }
164
+
165
+ export function validateEnvelope(envelope: DataAtRestEnvelope): void {
166
+ if (envelope.algorithm !== DATA_AT_REST_ENCRYPTION_ALGORITHM) {
167
+ throw new Error('Unsupported data-at-rest encryption algorithm');
168
+ }
169
+
170
+ if (envelope.encryptionVersion !== DATA_AT_REST_ENCRYPTION_VERSION) {
171
+ throw new Error('Unsupported data-at-rest encryption version');
172
+ }
173
+ }
174
+
175
+ export async function encryptBinaryForStorage(
176
+ plaintextBytes: ArrayBuffer,
177
+ publicKeyPem: string,
178
+ keyId: string
179
+ ): Promise<EncryptBinaryAtRestResult> {
180
+ const aesKey = await createAesGcmKey(['encrypt', 'decrypt']);
181
+ const wrappedKey = await wrapAesKey(aesKey, publicKeyPem);
182
+ const iv = crypto.getRandomValues(new Uint8Array(12));
183
+
184
+ const encryptedBuffer = await crypto.subtle.encrypt(
185
+ { name: 'AES-GCM', iv: iv as BufferSource },
186
+ aesKey,
187
+ plaintextBytes as BufferSource
188
+ );
189
+
190
+ return {
191
+ ciphertext: new Uint8Array(encryptedBuffer),
192
+ envelope: {
193
+ algorithm: DATA_AT_REST_ENCRYPTION_ALGORITHM,
194
+ encryptionVersion: DATA_AT_REST_ENCRYPTION_VERSION,
195
+ keyId,
196
+ dataIv: base64UrlEncode(iv),
197
+ wrappedKey
198
+ }
199
+ };
200
+ }
201
+
202
+ export async function decryptBinaryFromStorage(
203
+ ciphertext: ArrayBuffer,
204
+ envelope: DataAtRestEnvelope,
205
+ privateKeyPem: string
206
+ ): Promise<ArrayBuffer> {
207
+ validateEnvelope(envelope);
208
+
209
+ const aesKey = await unwrapAesKey(envelope.wrappedKey, privateKeyPem);
210
+ const iv = base64UrlDecode(envelope.dataIv);
211
+
212
+ return crypto.subtle.decrypt(
213
+ { name: 'AES-GCM', iv: iv as BufferSource },
214
+ aesKey,
215
+ ciphertext as BufferSource
216
+ );
217
+ }
@@ -1,179 +1,248 @@
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
+ }
14
+
15
+ interface UploadResult {
16
+ id: string;
17
+ filename: string;
18
+ uploaded: string;
19
+ requireSignedURLs: boolean;
20
+ variants: 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
+ type APIResponse = UploadResponse | SuccessResponse | ErrorResponse;
28
39
 
29
- const API_BASE = "https://api.cloudflare.com/client/v4/accounts";
30
-
31
- /**
32
- * CORS headers to allow requests from the Striae app
33
- */
34
40
  const corsHeaders: Record<string, string> = {
35
41
  'Access-Control-Allow-Origin': 'PAGES_CUSTOM_DOMAIN',
36
42
  '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'
43
+ 'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-Custom-Auth-Key'
39
44
  };
40
45
 
41
- const createResponse = (data: APIResponse, status: number = 200): Response => new Response(
42
- typeof data === 'string' ? data : JSON.stringify(data),
43
- { status, headers: corsHeaders }
46
+ const createJsonResponse = (data: APIResponse, status: number = 200): Response => new Response(
47
+ JSON.stringify(data),
48
+ {
49
+ status,
50
+ headers: {
51
+ ...corsHeaders,
52
+ 'Content-Type': 'application/json'
53
+ }
54
+ }
44
55
  );
45
56
 
46
- const hasValidToken = (request: Request, env: Env): boolean => {
47
- const authHeader = request.headers.get("Authorization");
48
- const expectedToken = `Bearer ${env.API_TOKEN}`;
57
+ function hasValidToken(request: Request, env: Env): boolean {
58
+ const authHeader = request.headers.get('Authorization');
59
+ const expectedToken = `Bearer ${env.IMAGES_API_TOKEN}`;
49
60
  return authHeader === expectedToken;
50
- };
61
+ }
51
62
 
52
- /**
53
- * Handle image upload requests
54
- */
55
- async function handleImageUpload(request: Request, env: Env): Promise<Response> {
56
- if (!hasValidToken(request, env)) {
57
- return createResponse({ error: 'Unauthorized' }, 403);
63
+ function requireEncryptionUploadConfig(env: Env): void {
64
+ if (!env.DATA_AT_REST_ENCRYPTION_PUBLIC_KEY || !env.DATA_AT_REST_ENCRYPTION_KEY_ID) {
65
+ throw new Error('Data-at-rest encryption is not configured for image uploads');
58
66
  }
67
+ }
59
68
 
60
- const formData = await request.formData();
61
- const endpoint = `${API_BASE}/${env.ACCOUNT_ID}/images/v1`;
69
+ function requireEncryptionRetrievalConfig(env: Env): void {
70
+ if (!env.DATA_AT_REST_ENCRYPTION_PRIVATE_KEY) {
71
+ throw new Error('Data-at-rest decryption is not configured for image retrieval');
72
+ }
73
+ }
62
74
 
63
- // Add requireSignedURLs to form data
64
- formData.append('requireSignedURLs', 'true');
75
+ function parseFileId(pathname: string): string | null {
76
+ const encodedFileId = pathname.startsWith('/') ? pathname.slice(1) : pathname;
77
+ if (!encodedFileId) {
78
+ return null;
79
+ }
65
80
 
66
- const response = await fetch(endpoint, {
67
- method: 'POST',
68
- headers: {
69
- 'Authorization': `Bearer ${env.API_TOKEN}`,
70
- },
71
- body: formData
72
- });
73
-
74
- const data: CloudflareImagesResponse = await response.json();
75
- return createResponse(data, response.status);
81
+ let decodedFileId = '';
82
+ try {
83
+ decodedFileId = decodeURIComponent(encodedFileId);
84
+ } catch {
85
+ return null;
86
+ }
87
+
88
+ if (!decodedFileId || decodedFileId.includes('/')) {
89
+ return null;
90
+ }
91
+
92
+ return decodedFileId;
76
93
  }
77
94
 
78
- /**
79
- * Handle image delete requests
80
- */
81
- async function handleImageDelete(request: Request, env: Env): Promise<Response> {
82
- if (!hasValidToken(request, env)) {
83
- return createResponse({ error: 'Unauthorized' }, 403);
95
+ function extractEnvelope(file: R2ObjectBody): DataAtRestEnvelope | null {
96
+ const metadata = file.customMetadata;
97
+ if (!metadata) {
98
+ return null;
84
99
  }
85
100
 
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);
101
+ const { algorithm, encryptionVersion, keyId, dataIv, wrappedKey } = metadata;
102
+ if (
103
+ typeof algorithm !== 'string' ||
104
+ typeof encryptionVersion !== 'string' ||
105
+ typeof keyId !== 'string' ||
106
+ typeof dataIv !== 'string' ||
107
+ typeof wrappedKey !== 'string'
108
+ ) {
109
+ return null;
91
110
  }
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);
111
+
112
+ return {
113
+ algorithm,
114
+ encryptionVersion,
115
+ keyId,
116
+ dataIv,
117
+ wrappedKey
118
+ };
103
119
  }
104
120
 
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
- );
121
+ function deriveFileKind(contentType: string): string {
122
+ if (contentType.startsWith('image/')) {
123
+ return 'image';
124
+ }
125
+
126
+ return 'file';
127
+ }
128
+
129
+ async function handleImageUpload(request: Request, env: Env): Promise<Response> {
130
+ if (!hasValidToken(request, env)) {
131
+ return createJsonResponse({ error: 'Unauthorized' }, 403);
132
+ }
133
+
134
+ requireEncryptionUploadConfig(env);
135
+
136
+ const formData = await request.formData();
137
+ const fileValue = formData.get('file');
138
+ if (!(fileValue instanceof Blob)) {
139
+ return createJsonResponse({ error: 'Missing file upload payload' }, 400);
140
+ }
123
141
 
124
- // Add expiration
125
- const expiry = Math.floor(Date.now() / 1000) + EXPIRATION;
126
- url.searchParams.set('exp', expiry.toString());
142
+ const fileBlob = fileValue;
143
+ const uploadedAt = new Date().toISOString();
144
+ const filename = fileValue instanceof File && fileValue.name ? fileValue.name : 'upload.bin';
145
+ const contentType = fileBlob.type || 'application/octet-stream';
146
+ const fileId = crypto.randomUUID().replace(/-/g, '');
147
+ const plaintextBytes = await fileBlob.arrayBuffer();
127
148
 
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);
149
+ const encryptedPayload = await encryptBinaryForStorage(
150
+ plaintextBytes,
151
+ env.DATA_AT_REST_ENCRYPTION_PUBLIC_KEY,
152
+ env.DATA_AT_REST_ENCRYPTION_KEY_ID
153
+ );
131
154
 
132
- // Add signature
133
- url.searchParams.set('sig', sig);
155
+ await env.STRIAE_FILES.put(fileId, encryptedPayload.ciphertext, {
156
+ customMetadata: {
157
+ algorithm: encryptedPayload.envelope.algorithm,
158
+ encryptionVersion: encryptedPayload.envelope.encryptionVersion,
159
+ keyId: encryptedPayload.envelope.keyId,
160
+ dataIv: encryptedPayload.envelope.dataIv,
161
+ wrappedKey: encryptedPayload.envelope.wrappedKey,
162
+ contentType,
163
+ originalFilename: filename,
164
+ byteLength: String(fileBlob.size),
165
+ createdAt: uploadedAt,
166
+ fileKind: deriveFileKind(contentType)
167
+ }
168
+ });
134
169
 
135
- // Return the modified URL with signature and expiration
136
- return new Response(url.toString(), {
137
- headers: corsHeaders
170
+ return createJsonResponse({
171
+ success: true,
172
+ errors: [],
173
+ messages: [],
174
+ result: {
175
+ id: fileId,
176
+ filename,
177
+ uploaded: uploadedAt,
178
+ requireSignedURLs: false,
179
+ variants: []
180
+ }
138
181
  });
139
182
  }
140
183
 
141
- async function handleImageServing(request: Request, env: Env): Promise<Response> {
184
+ async function handleImageDelete(request: Request, env: Env): Promise<Response> {
142
185
  if (!hasValidToken(request, env)) {
143
- return createResponse({ error: 'Unauthorized' }, 403);
186
+ return createJsonResponse({ error: 'Unauthorized' }, 403);
187
+ }
188
+
189
+ const fileId = parseFileId(new URL(request.url).pathname);
190
+ if (!fileId) {
191
+ return createJsonResponse({ error: 'Image ID is required' }, 400);
144
192
  }
145
193
 
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);
194
+ const existing = await env.STRIAE_FILES.head(fileId);
195
+ if (!existing) {
196
+ return createJsonResponse({ error: 'File not found' }, 404);
150
197
  }
151
198
 
152
- let decodedPath: string;
199
+ await env.STRIAE_FILES.delete(fileId);
200
+ return createJsonResponse({ success: true });
201
+ }
153
202
 
154
- try {
155
- decodedPath = decodeURIComponent(encodedPath);
156
- } catch {
157
- return createResponse({ error: 'Image delivery URL must be URL-encoded' }, 400);
203
+ async function handleImageServing(request: Request, env: Env): Promise<Response> {
204
+ if (!hasValidToken(request, env)) {
205
+ return createJsonResponse({ error: 'Unauthorized' }, 403);
158
206
  }
159
207
 
160
- let imageDeliveryURL: URL;
161
- try {
162
- imageDeliveryURL = new URL(decodedPath);
163
- } catch {
164
- return createResponse({ error: 'Image delivery URL is invalid' }, 400);
208
+ requireEncryptionRetrievalConfig(env);
209
+
210
+ const fileId = parseFileId(new URL(request.url).pathname);
211
+ if (!fileId) {
212
+ return createJsonResponse({ error: 'Image ID is required' }, 400);
165
213
  }
166
214
 
167
- if (imageDeliveryURL.protocol !== 'https:' || imageDeliveryURL.hostname !== 'imagedelivery.net') {
168
- return createResponse({ error: 'Image delivery URL must target imagedelivery.net over HTTPS' }, 400);
215
+ const file = await env.STRIAE_FILES.get(fileId);
216
+ if (!file) {
217
+ return createJsonResponse({ error: 'File not found' }, 404);
169
218
  }
170
-
171
- return generateSignedUrl(imageDeliveryURL, env);
219
+
220
+ const envelope = extractEnvelope(file);
221
+ if (!envelope) {
222
+ return createJsonResponse({ error: 'Missing data-at-rest envelope metadata' }, 500);
223
+ }
224
+
225
+ const encryptedData = await file.arrayBuffer();
226
+ const plaintext = await decryptBinaryFromStorage(
227
+ encryptedData,
228
+ envelope,
229
+ env.DATA_AT_REST_ENCRYPTION_PRIVATE_KEY
230
+ );
231
+
232
+ const contentType = file.customMetadata?.contentType || 'application/octet-stream';
233
+ const filename = file.customMetadata?.originalFilename || fileId;
234
+
235
+ return new Response(plaintext, {
236
+ status: 200,
237
+ headers: {
238
+ ...corsHeaders,
239
+ 'Cache-Control': 'no-store',
240
+ 'Content-Type': contentType,
241
+ 'Content-Disposition': `inline; filename="${filename.replace(/"/g, '')}"`
242
+ }
243
+ });
172
244
  }
173
245
 
174
- /**
175
- * Main worker functions
176
- */
177
246
  export default {
178
247
  async fetch(request: Request, env: Env): Promise<Response> {
179
248
  if (request.method === 'OPTIONS') {
@@ -189,11 +258,11 @@ export default {
189
258
  case 'DELETE':
190
259
  return handleImageDelete(request, env);
191
260
  default:
192
- return createResponse({ error: 'Method not allowed' }, 405);
261
+ return createJsonResponse({ error: 'Method not allowed' }, 405);
193
262
  }
194
263
  } catch (error) {
195
264
  const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';
196
- return createResponse({ error: errorMessage }, 500);
265
+ return createJsonResponse({ error: errorMessage }, 500);
197
266
  }
198
267
  }
199
268
  };
@@ -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": {
@@ -6,13 +6,10 @@
6
6
  "generate:assets": "node scripts/generate-assets.js",
7
7
  "deploy": "wrangler deploy",
8
8
  "dev": "wrangler dev",
9
- "start": "wrangler dev",
10
- "test": "vitest"
9
+ "start": "wrangler dev"
11
10
  },
12
11
  "devDependencies": {
13
12
  "@cloudflare/puppeteer": "^1.0.6",
14
- "@cloudflare/vitest-pool-workers": "^0.13.0",
15
- "vitest": "~4.1.0",
16
13
  "wrangler": "^4.76.0"
17
14
  },
18
15
  "overrides": {
@@ -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": {