@striae-org/striae 5.1.0 → 5.2.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.
- package/.env.example +22 -2
- package/app/components/actions/case-export/download-handlers.ts +18 -1
- package/app/components/actions/case-manage.ts +17 -1
- package/app/components/actions/generate-pdf.ts +9 -3
- package/app/components/actions/image-manage.ts +43 -11
- package/app/routes/striae/striae.tsx +1 -0
- package/app/types/file.ts +18 -2
- package/app/utils/api/image-api-client.ts +49 -1
- package/app/utils/data/permissions.ts +4 -2
- package/functions/api/image/[[path]].ts +2 -1
- package/package.json +4 -4
- package/scripts/deploy-config/modules/env-utils.sh +322 -0
- package/scripts/deploy-config/modules/keys.sh +404 -0
- package/scripts/deploy-config/modules/prompt.sh +372 -0
- package/scripts/deploy-config/modules/scaffolding.sh +336 -0
- package/scripts/deploy-config/modules/validation.sh +365 -0
- package/scripts/deploy-config.sh +59 -1556
- package/scripts/deploy-worker-secrets.sh +101 -6
- package/worker-configuration.d.ts +9 -4
- package/workers/audit-worker/package.json +1 -1
- package/workers/audit-worker/src/audit-worker.example.ts +188 -6
- package/workers/audit-worker/wrangler.jsonc.example +1 -1
- package/workers/data-worker/package.json +1 -1
- package/workers/data-worker/src/data-worker.example.ts +344 -32
- package/workers/data-worker/wrangler.jsonc.example +2 -4
- package/workers/image-worker/package.json +1 -1
- package/workers/image-worker/src/image-worker.example.ts +456 -20
- package/workers/image-worker/worker-configuration.d.ts +3 -2
- package/workers/image-worker/wrangler.jsonc.example +1 -1
- package/workers/keys-worker/package.json +1 -1
- package/workers/keys-worker/wrangler.jsonc.example +1 -1
- package/workers/pdf-worker/package.json +1 -1
- package/workers/pdf-worker/src/pdf-worker.example.ts +0 -1
- package/workers/pdf-worker/wrangler.jsonc.example +1 -5
- package/workers/user-worker/package.json +17 -17
- package/workers/user-worker/src/encryption-utils.ts +244 -0
- package/workers/user-worker/src/user-worker.example.ts +333 -31
- package/workers/user-worker/wrangler.jsonc.example +1 -1
- package/wrangler.toml.example +1 -1
- package/scripts/encrypt-r2-backfill.mjs +0 -376
|
@@ -7,17 +7,31 @@ import {
|
|
|
7
7
|
interface Env {
|
|
8
8
|
IMAGES_API_TOKEN: string;
|
|
9
9
|
STRIAE_FILES: R2Bucket;
|
|
10
|
-
DATA_AT_REST_ENCRYPTION_PRIVATE_KEY
|
|
10
|
+
DATA_AT_REST_ENCRYPTION_PRIVATE_KEY?: string;
|
|
11
11
|
DATA_AT_REST_ENCRYPTION_PUBLIC_KEY: string;
|
|
12
12
|
DATA_AT_REST_ENCRYPTION_KEY_ID: string;
|
|
13
|
+
DATA_AT_REST_ENCRYPTION_KEYS_JSON?: string;
|
|
14
|
+
DATA_AT_REST_ENCRYPTION_ACTIVE_KEY_ID?: string;
|
|
15
|
+
IMAGE_SIGNED_URL_SECRET?: string;
|
|
16
|
+
IMAGE_SIGNED_URL_TTL_SECONDS?: string;
|
|
13
17
|
}
|
|
14
18
|
|
|
19
|
+
interface KeyRegistryPayload {
|
|
20
|
+
activeKeyId?: unknown;
|
|
21
|
+
keys?: unknown;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface PrivateKeyRegistry {
|
|
25
|
+
activeKeyId: string | null;
|
|
26
|
+
keys: Record<string, string>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
type DecryptionTelemetryOutcome = 'primary-hit' | 'fallback-hit' | 'all-failed';
|
|
30
|
+
|
|
15
31
|
interface UploadResult {
|
|
16
32
|
id: string;
|
|
17
33
|
filename: string;
|
|
18
34
|
uploaded: string;
|
|
19
|
-
requireSignedURLs: boolean;
|
|
20
|
-
variants: string[];
|
|
21
35
|
}
|
|
22
36
|
|
|
23
37
|
interface UploadResponse {
|
|
@@ -35,7 +49,29 @@ interface ErrorResponse {
|
|
|
35
49
|
error: string;
|
|
36
50
|
}
|
|
37
51
|
|
|
38
|
-
|
|
52
|
+
interface SignedUrlResult {
|
|
53
|
+
fileId: string;
|
|
54
|
+
url: string;
|
|
55
|
+
expiresAt: string;
|
|
56
|
+
expiresInSeconds: number;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
interface SignedUrlResponse {
|
|
60
|
+
success: boolean;
|
|
61
|
+
result: SignedUrlResult;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
type APIResponse = UploadResponse | SuccessResponse | ErrorResponse | SignedUrlResponse;
|
|
65
|
+
|
|
66
|
+
interface SignedAccessPayload {
|
|
67
|
+
fileId: string;
|
|
68
|
+
iat: number;
|
|
69
|
+
exp: number;
|
|
70
|
+
nonce: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const DEFAULT_SIGNED_URL_TTL_SECONDS = 3600;
|
|
74
|
+
const MAX_SIGNED_URL_TTL_SECONDS = 86400;
|
|
39
75
|
|
|
40
76
|
const corsHeaders: Record<string, string> = {
|
|
41
77
|
'Access-Control-Allow-Origin': 'PAGES_CUSTOM_DOMAIN',
|
|
@@ -67,11 +103,182 @@ function requireEncryptionUploadConfig(env: Env): void {
|
|
|
67
103
|
}
|
|
68
104
|
|
|
69
105
|
function requireEncryptionRetrievalConfig(env: Env): void {
|
|
70
|
-
|
|
71
|
-
|
|
106
|
+
const hasLegacyPrivateKey = typeof env.DATA_AT_REST_ENCRYPTION_PRIVATE_KEY === 'string' && env.DATA_AT_REST_ENCRYPTION_PRIVATE_KEY.trim().length > 0;
|
|
107
|
+
const hasRegistry = typeof env.DATA_AT_REST_ENCRYPTION_KEYS_JSON === 'string' && env.DATA_AT_REST_ENCRYPTION_KEYS_JSON.trim().length > 0;
|
|
108
|
+
|
|
109
|
+
if (!hasLegacyPrivateKey && !hasRegistry) {
|
|
110
|
+
throw new Error('Data-at-rest decryption registry is not configured for image retrieval');
|
|
72
111
|
}
|
|
73
112
|
}
|
|
74
113
|
|
|
114
|
+
function normalizePrivateKeyPem(rawValue: string): string {
|
|
115
|
+
return rawValue.trim().replace(/^['"]|['"]$/g, '').replace(/\\n/g, '\n');
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function getNonEmptyString(value: unknown): string | null {
|
|
119
|
+
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function parseDataAtRestPrivateKeyRegistry(env: Env): PrivateKeyRegistry {
|
|
123
|
+
const keys: Record<string, string> = {};
|
|
124
|
+
const configuredActiveKeyId = getNonEmptyString(env.DATA_AT_REST_ENCRYPTION_ACTIVE_KEY_ID);
|
|
125
|
+
const registryJson = getNonEmptyString(env.DATA_AT_REST_ENCRYPTION_KEYS_JSON);
|
|
126
|
+
|
|
127
|
+
if (registryJson) {
|
|
128
|
+
let parsedRegistry: unknown;
|
|
129
|
+
try {
|
|
130
|
+
parsedRegistry = JSON.parse(registryJson) as unknown;
|
|
131
|
+
} catch {
|
|
132
|
+
throw new Error('DATA_AT_REST_ENCRYPTION_KEYS_JSON is not valid JSON');
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (!parsedRegistry || typeof parsedRegistry !== 'object') {
|
|
136
|
+
throw new Error('DATA_AT_REST_ENCRYPTION_KEYS_JSON must be an object');
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const payload = parsedRegistry as KeyRegistryPayload;
|
|
140
|
+
if (!payload.keys || typeof payload.keys !== 'object') {
|
|
141
|
+
throw new Error('DATA_AT_REST_ENCRYPTION_KEYS_JSON must include a keys object');
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
for (const [keyId, pemValue] of Object.entries(payload.keys as Record<string, unknown>)) {
|
|
145
|
+
const normalizedKeyId = getNonEmptyString(keyId);
|
|
146
|
+
const normalizedPem = getNonEmptyString(pemValue);
|
|
147
|
+
if (!normalizedKeyId || !normalizedPem) {
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
keys[normalizedKeyId] = normalizePrivateKeyPem(normalizedPem);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const payloadActiveKeyId = getNonEmptyString(payload.activeKeyId);
|
|
155
|
+
const resolvedActiveKeyId = configuredActiveKeyId ?? payloadActiveKeyId;
|
|
156
|
+
|
|
157
|
+
if (Object.keys(keys).length === 0) {
|
|
158
|
+
throw new Error('DATA_AT_REST_ENCRYPTION_KEYS_JSON does not contain any usable keys');
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (resolvedActiveKeyId && !keys[resolvedActiveKeyId]) {
|
|
162
|
+
throw new Error('DATA_AT_REST active key ID is not present in DATA_AT_REST_ENCRYPTION_KEYS_JSON');
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
activeKeyId: resolvedActiveKeyId ?? null,
|
|
167
|
+
keys
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const legacyKeyId = getNonEmptyString(env.DATA_AT_REST_ENCRYPTION_KEY_ID);
|
|
172
|
+
const legacyPrivateKey = getNonEmptyString(env.DATA_AT_REST_ENCRYPTION_PRIVATE_KEY);
|
|
173
|
+
if (!legacyKeyId || !legacyPrivateKey) {
|
|
174
|
+
throw new Error('Data-at-rest decryption key registry is not configured');
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
keys[legacyKeyId] = normalizePrivateKeyPem(legacyPrivateKey);
|
|
178
|
+
|
|
179
|
+
return {
|
|
180
|
+
activeKeyId: configuredActiveKeyId ?? legacyKeyId,
|
|
181
|
+
keys
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function buildPrivateKeyCandidates(
|
|
186
|
+
recordKeyId: string,
|
|
187
|
+
registry: PrivateKeyRegistry
|
|
188
|
+
): Array<{ keyId: string; privateKeyPem: string }> {
|
|
189
|
+
const candidates: Array<{ keyId: string; privateKeyPem: string }> = [];
|
|
190
|
+
const seen = new Set<string>();
|
|
191
|
+
|
|
192
|
+
const appendCandidate = (candidateKeyId: string | null): void => {
|
|
193
|
+
if (!candidateKeyId || seen.has(candidateKeyId)) {
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const privateKeyPem = registry.keys[candidateKeyId];
|
|
198
|
+
if (!privateKeyPem) {
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
seen.add(candidateKeyId);
|
|
203
|
+
candidates.push({ keyId: candidateKeyId, privateKeyPem });
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
appendCandidate(getNonEmptyString(recordKeyId));
|
|
207
|
+
appendCandidate(registry.activeKeyId);
|
|
208
|
+
|
|
209
|
+
for (const keyId of Object.keys(registry.keys)) {
|
|
210
|
+
appendCandidate(keyId);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return candidates;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function logFileDecryptionTelemetry(input: {
|
|
217
|
+
recordKeyId: string;
|
|
218
|
+
selectedKeyId: string | null;
|
|
219
|
+
attemptCount: number;
|
|
220
|
+
outcome: DecryptionTelemetryOutcome;
|
|
221
|
+
reason?: string;
|
|
222
|
+
}): void {
|
|
223
|
+
const details = {
|
|
224
|
+
scope: 'file-at-rest',
|
|
225
|
+
recordKeyId: input.recordKeyId,
|
|
226
|
+
selectedKeyId: input.selectedKeyId,
|
|
227
|
+
attemptCount: input.attemptCount,
|
|
228
|
+
fallbackUsed: input.outcome === 'fallback-hit',
|
|
229
|
+
outcome: input.outcome,
|
|
230
|
+
reason: input.reason ?? null
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
if (input.outcome === 'all-failed') {
|
|
234
|
+
console.warn('Key registry decryption failed', details);
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
console.info('Key registry decryption resolved', details);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async function decryptBinaryWithRegistry(
|
|
242
|
+
ciphertext: ArrayBuffer,
|
|
243
|
+
envelope: DataAtRestEnvelope,
|
|
244
|
+
env: Env
|
|
245
|
+
): Promise<ArrayBuffer> {
|
|
246
|
+
const keyRegistry = parseDataAtRestPrivateKeyRegistry(env);
|
|
247
|
+
const candidates = buildPrivateKeyCandidates(envelope.keyId, keyRegistry);
|
|
248
|
+
const primaryKeyId = candidates[0]?.keyId ?? null;
|
|
249
|
+
let lastError: unknown;
|
|
250
|
+
|
|
251
|
+
for (let index = 0; index < candidates.length; index += 1) {
|
|
252
|
+
const candidate = candidates[index];
|
|
253
|
+
try {
|
|
254
|
+
const plaintext = await decryptBinaryFromStorage(ciphertext, envelope, candidate.privateKeyPem);
|
|
255
|
+
logFileDecryptionTelemetry({
|
|
256
|
+
recordKeyId: envelope.keyId,
|
|
257
|
+
selectedKeyId: candidate.keyId,
|
|
258
|
+
attemptCount: index + 1,
|
|
259
|
+
outcome: candidate.keyId === primaryKeyId ? 'primary-hit' : 'fallback-hit'
|
|
260
|
+
});
|
|
261
|
+
return plaintext;
|
|
262
|
+
} catch (error) {
|
|
263
|
+
lastError = error;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
logFileDecryptionTelemetry({
|
|
268
|
+
recordKeyId: envelope.keyId,
|
|
269
|
+
selectedKeyId: null,
|
|
270
|
+
attemptCount: candidates.length,
|
|
271
|
+
outcome: 'all-failed',
|
|
272
|
+
reason: lastError instanceof Error ? lastError.message : 'unknown decryption error'
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
throw new Error(
|
|
276
|
+
`Failed to decrypt stored file after ${candidates.length} key attempt(s): ${
|
|
277
|
+
lastError instanceof Error ? lastError.message : 'unknown decryption error'
|
|
278
|
+
}`
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
|
|
75
282
|
function parseFileId(pathname: string): string | null {
|
|
76
283
|
const encodedFileId = pathname.startsWith('/') ? pathname.slice(1) : pathname;
|
|
77
284
|
if (!encodedFileId) {
|
|
@@ -92,6 +299,162 @@ function parseFileId(pathname: string): string | null {
|
|
|
92
299
|
return decodedFileId;
|
|
93
300
|
}
|
|
94
301
|
|
|
302
|
+
function parsePathSegments(pathname: string): string[] | null {
|
|
303
|
+
const normalized = pathname.startsWith('/') ? pathname.slice(1) : pathname;
|
|
304
|
+
if (!normalized) {
|
|
305
|
+
return [];
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const rawSegments = normalized.split('/');
|
|
309
|
+
const decodedSegments: string[] = [];
|
|
310
|
+
|
|
311
|
+
for (const segment of rawSegments) {
|
|
312
|
+
if (!segment) {
|
|
313
|
+
return null;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
let decoded = '';
|
|
317
|
+
try {
|
|
318
|
+
decoded = decodeURIComponent(segment);
|
|
319
|
+
} catch {
|
|
320
|
+
return null;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
if (!decoded || decoded.includes('/')) {
|
|
324
|
+
return null;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
decodedSegments.push(decoded);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
return decodedSegments;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function base64UrlEncode(input: ArrayBuffer | Uint8Array): string {
|
|
334
|
+
const bytes = input instanceof Uint8Array ? input : new Uint8Array(input);
|
|
335
|
+
let binary = '';
|
|
336
|
+
for (let i = 0; i < bytes.length; i += 1) {
|
|
337
|
+
binary += String.fromCharCode(bytes[i]);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function base64UrlDecode(input: string): Uint8Array | null {
|
|
344
|
+
if (!input || /[^A-Za-z0-9_-]/.test(input)) {
|
|
345
|
+
return null;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const paddingLength = (4 - (input.length % 4)) % 4;
|
|
349
|
+
const padded = input.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat(paddingLength);
|
|
350
|
+
|
|
351
|
+
try {
|
|
352
|
+
const binary = atob(padded);
|
|
353
|
+
const bytes = new Uint8Array(binary.length);
|
|
354
|
+
for (let i = 0; i < binary.length; i += 1) {
|
|
355
|
+
bytes[i] = binary.charCodeAt(i);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
return bytes;
|
|
359
|
+
} catch {
|
|
360
|
+
return null;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function normalizeSignedUrlTtlSeconds(requestedTtlSeconds: unknown, env: Env): number {
|
|
365
|
+
const defaultFromEnv = Number.parseInt(env.IMAGE_SIGNED_URL_TTL_SECONDS ?? '', 10);
|
|
366
|
+
const fallbackTtl = Number.isFinite(defaultFromEnv) && defaultFromEnv > 0
|
|
367
|
+
? defaultFromEnv
|
|
368
|
+
: DEFAULT_SIGNED_URL_TTL_SECONDS;
|
|
369
|
+
const requested = typeof requestedTtlSeconds === 'number' ? requestedTtlSeconds : fallbackTtl;
|
|
370
|
+
const normalized = Math.floor(requested);
|
|
371
|
+
|
|
372
|
+
if (!Number.isFinite(normalized) || normalized <= 0) {
|
|
373
|
+
return fallbackTtl;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
return Math.min(normalized, MAX_SIGNED_URL_TTL_SECONDS);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function requireSignedUrlConfig(env: Env): void {
|
|
380
|
+
const resolvedSecret = (env.IMAGE_SIGNED_URL_SECRET || env.IMAGES_API_TOKEN || '').trim();
|
|
381
|
+
if (resolvedSecret.length === 0) {
|
|
382
|
+
throw new Error('Signed URL configuration is missing');
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
async function getSignedUrlHmacKey(env: Env): Promise<CryptoKey> {
|
|
387
|
+
const resolvedSecret = (env.IMAGE_SIGNED_URL_SECRET || env.IMAGES_API_TOKEN || '').trim();
|
|
388
|
+
const keyBytes = new TextEncoder().encode(resolvedSecret);
|
|
389
|
+
return crypto.subtle.importKey('raw', keyBytes, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign', 'verify']);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
async function signSignedAccessPayload(payload: SignedAccessPayload, env: Env): Promise<string> {
|
|
393
|
+
const payloadJson = JSON.stringify(payload);
|
|
394
|
+
const payloadBase64Url = base64UrlEncode(new TextEncoder().encode(payloadJson));
|
|
395
|
+
const hmacKey = await getSignedUrlHmacKey(env);
|
|
396
|
+
const signature = await crypto.subtle.sign('HMAC', hmacKey, new TextEncoder().encode(payloadBase64Url));
|
|
397
|
+
const signatureBase64Url = base64UrlEncode(signature);
|
|
398
|
+
return `${payloadBase64Url}.${signatureBase64Url}`;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
async function verifySignedAccessToken(token: string, fileId: string, env: Env): Promise<boolean> {
|
|
402
|
+
const tokenParts = token.split('.');
|
|
403
|
+
if (tokenParts.length !== 2) {
|
|
404
|
+
return false;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
const [payloadBase64Url, signatureBase64Url] = tokenParts;
|
|
408
|
+
if (!payloadBase64Url || !signatureBase64Url) {
|
|
409
|
+
return false;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
const signatureBytes = base64UrlDecode(signatureBase64Url);
|
|
413
|
+
if (!signatureBytes) {
|
|
414
|
+
return false;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
const signatureBuffer = new Uint8Array(signatureBytes).buffer;
|
|
418
|
+
|
|
419
|
+
const hmacKey = await getSignedUrlHmacKey(env);
|
|
420
|
+
const signatureValid = await crypto.subtle.verify(
|
|
421
|
+
'HMAC',
|
|
422
|
+
hmacKey,
|
|
423
|
+
signatureBuffer,
|
|
424
|
+
new TextEncoder().encode(payloadBase64Url)
|
|
425
|
+
);
|
|
426
|
+
if (!signatureValid) {
|
|
427
|
+
return false;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const payloadBytes = base64UrlDecode(payloadBase64Url);
|
|
431
|
+
if (!payloadBytes) {
|
|
432
|
+
return false;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
let payload: SignedAccessPayload;
|
|
436
|
+
try {
|
|
437
|
+
payload = JSON.parse(new TextDecoder().decode(payloadBytes)) as SignedAccessPayload;
|
|
438
|
+
} catch {
|
|
439
|
+
return false;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
if (payload.fileId !== fileId) {
|
|
443
|
+
return false;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
const nowEpochSeconds = Math.floor(Date.now() / 1000);
|
|
447
|
+
if (!Number.isInteger(payload.exp) || payload.exp <= nowEpochSeconds) {
|
|
448
|
+
return false;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
if (!Number.isInteger(payload.iat) || payload.iat > nowEpochSeconds + 300) {
|
|
452
|
+
return false;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
return typeof payload.nonce === 'string' && payload.nonce.length > 0;
|
|
456
|
+
}
|
|
457
|
+
|
|
95
458
|
function extractEnvelope(file: R2ObjectBody): DataAtRestEnvelope | null {
|
|
96
459
|
const metadata = file.customMetadata;
|
|
97
460
|
if (!metadata) {
|
|
@@ -174,9 +537,7 @@ async function handleImageUpload(request: Request, env: Env): Promise<Response>
|
|
|
174
537
|
result: {
|
|
175
538
|
id: fileId,
|
|
176
539
|
filename,
|
|
177
|
-
uploaded: uploadedAt
|
|
178
|
-
requireSignedURLs: false,
|
|
179
|
-
variants: []
|
|
540
|
+
uploaded: uploadedAt
|
|
180
541
|
}
|
|
181
542
|
});
|
|
182
543
|
}
|
|
@@ -200,18 +561,72 @@ async function handleImageDelete(request: Request, env: Env): Promise<Response>
|
|
|
200
561
|
return createJsonResponse({ success: true });
|
|
201
562
|
}
|
|
202
563
|
|
|
203
|
-
async function
|
|
564
|
+
async function handleSignedUrlMinting(request: Request, env: Env, fileId: string): Promise<Response> {
|
|
204
565
|
if (!hasValidToken(request, env)) {
|
|
205
566
|
return createJsonResponse({ error: 'Unauthorized' }, 403);
|
|
206
567
|
}
|
|
207
568
|
|
|
208
|
-
|
|
569
|
+
requireSignedUrlConfig(env);
|
|
209
570
|
|
|
210
|
-
const
|
|
211
|
-
if (!
|
|
212
|
-
return createJsonResponse({ error: '
|
|
571
|
+
const existing = await env.STRIAE_FILES.head(fileId);
|
|
572
|
+
if (!existing) {
|
|
573
|
+
return createJsonResponse({ error: 'File not found' }, 404);
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
let requestedExpiresInSeconds: number | undefined;
|
|
577
|
+
const contentType = request.headers.get('Content-Type') || '';
|
|
578
|
+
if (contentType.includes('application/json')) {
|
|
579
|
+
const requestBody = await request.json().catch(() => null) as { expiresInSeconds?: number } | null;
|
|
580
|
+
if (requestBody && typeof requestBody.expiresInSeconds === 'number') {
|
|
581
|
+
requestedExpiresInSeconds = requestBody.expiresInSeconds;
|
|
582
|
+
}
|
|
213
583
|
}
|
|
214
584
|
|
|
585
|
+
const nowEpochSeconds = Math.floor(Date.now() / 1000);
|
|
586
|
+
const ttlSeconds = normalizeSignedUrlTtlSeconds(requestedExpiresInSeconds, env);
|
|
587
|
+
const payload: SignedAccessPayload = {
|
|
588
|
+
fileId,
|
|
589
|
+
iat: nowEpochSeconds,
|
|
590
|
+
exp: nowEpochSeconds + ttlSeconds,
|
|
591
|
+
nonce: crypto.randomUUID().replace(/-/g, '')
|
|
592
|
+
};
|
|
593
|
+
|
|
594
|
+
const signedToken = await signSignedAccessPayload(payload, env);
|
|
595
|
+
const signedPath = `/${encodeURIComponent(fileId)}?st=${encodeURIComponent(signedToken)}`;
|
|
596
|
+
const signedUrl = new URL(signedPath, request.url).toString();
|
|
597
|
+
|
|
598
|
+
return createJsonResponse({
|
|
599
|
+
success: true,
|
|
600
|
+
result: {
|
|
601
|
+
fileId,
|
|
602
|
+
url: signedUrl,
|
|
603
|
+
expiresAt: new Date(payload.exp * 1000).toISOString(),
|
|
604
|
+
expiresInSeconds: ttlSeconds
|
|
605
|
+
}
|
|
606
|
+
});
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
async function handleImageServing(request: Request, env: Env, fileId: string): Promise<Response> {
|
|
610
|
+
const requestUrl = new URL(request.url);
|
|
611
|
+
const hasSignedToken = requestUrl.searchParams.has('st');
|
|
612
|
+
const signedToken = requestUrl.searchParams.get('st');
|
|
613
|
+
if (hasSignedToken) {
|
|
614
|
+
requireSignedUrlConfig(env);
|
|
615
|
+
|
|
616
|
+
if (!signedToken || signedToken.trim().length === 0) {
|
|
617
|
+
return createJsonResponse({ error: 'Invalid or expired signed URL token' }, 403);
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
const tokenValid = await verifySignedAccessToken(signedToken, fileId, env);
|
|
621
|
+
if (!tokenValid) {
|
|
622
|
+
return createJsonResponse({ error: 'Invalid or expired signed URL token' }, 403);
|
|
623
|
+
}
|
|
624
|
+
} else if (!hasValidToken(request, env)) {
|
|
625
|
+
return createJsonResponse({ error: 'Unauthorized' }, 403);
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
requireEncryptionRetrievalConfig(env);
|
|
629
|
+
|
|
215
630
|
const file = await env.STRIAE_FILES.get(fileId);
|
|
216
631
|
if (!file) {
|
|
217
632
|
return createJsonResponse({ error: 'File not found' }, 404);
|
|
@@ -223,10 +638,10 @@ async function handleImageServing(request: Request, env: Env): Promise<Response>
|
|
|
223
638
|
}
|
|
224
639
|
|
|
225
640
|
const encryptedData = await file.arrayBuffer();
|
|
226
|
-
const plaintext = await
|
|
641
|
+
const plaintext = await decryptBinaryWithRegistry(
|
|
227
642
|
encryptedData,
|
|
228
643
|
envelope,
|
|
229
|
-
env
|
|
644
|
+
env
|
|
230
645
|
);
|
|
231
646
|
|
|
232
647
|
const contentType = file.customMetadata?.contentType || 'application/octet-stream';
|
|
@@ -250,11 +665,32 @@ export default {
|
|
|
250
665
|
}
|
|
251
666
|
|
|
252
667
|
try {
|
|
668
|
+
const requestUrl = new URL(request.url);
|
|
669
|
+
const pathSegments = parsePathSegments(requestUrl.pathname);
|
|
670
|
+
if (!pathSegments) {
|
|
671
|
+
return createJsonResponse({ error: 'Invalid image path encoding' }, 400);
|
|
672
|
+
}
|
|
673
|
+
|
|
253
674
|
switch (request.method) {
|
|
254
|
-
case 'POST':
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
675
|
+
case 'POST': {
|
|
676
|
+
if (pathSegments.length === 0) {
|
|
677
|
+
return handleImageUpload(request, env);
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
if (pathSegments.length === 2 && pathSegments[1] === 'signed-url') {
|
|
681
|
+
return handleSignedUrlMinting(request, env, pathSegments[0]);
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
return createJsonResponse({ error: 'Not found' }, 404);
|
|
685
|
+
}
|
|
686
|
+
case 'GET': {
|
|
687
|
+
const fileId = pathSegments.length === 1 ? pathSegments[0] : null;
|
|
688
|
+
if (!fileId) {
|
|
689
|
+
return createJsonResponse({ error: 'Image ID is required' }, 400);
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
return handleImageServing(request, env, fileId);
|
|
693
|
+
}
|
|
258
694
|
case 'DELETE':
|
|
259
695
|
return handleImageDelete(request, env);
|
|
260
696
|
default:
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/* eslint-disable */
|
|
2
|
-
// Generated by Wrangler by running `wrangler types` (hash:
|
|
3
|
-
// Runtime types generated with workerd@1.20250823.0 2026-03-
|
|
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 {}
|
|
@@ -2,15 +2,11 @@
|
|
|
2
2
|
"name": "PDF_WORKER_NAME",
|
|
3
3
|
"account_id": "ACCOUNT_ID",
|
|
4
4
|
"main": "src/pdf-worker.ts",
|
|
5
|
-
"compatibility_date": "2026-03-
|
|
5
|
+
"compatibility_date": "2026-03-25",
|
|
6
6
|
"compatibility_flags": [
|
|
7
7
|
"nodejs_compat"
|
|
8
8
|
],
|
|
9
9
|
|
|
10
|
-
"browser": {
|
|
11
|
-
"binding": "BROWSER"
|
|
12
|
-
},
|
|
13
|
-
|
|
14
10
|
"observability": {
|
|
15
11
|
"enabled": true
|
|
16
12
|
},
|
|
@@ -1,18 +1,18 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
}
|
|
2
|
+
"name": "user-worker",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"scripts": {
|
|
6
|
+
"deploy": "wrangler deploy",
|
|
7
|
+
"dev": "wrangler dev",
|
|
8
|
+
"start": "wrangler dev"
|
|
9
|
+
},
|
|
10
|
+
"devDependencies": {
|
|
11
|
+
"@cloudflare/puppeteer": "^1.0.6",
|
|
12
|
+
"wrangler": "^4.77.0"
|
|
13
|
+
},
|
|
14
|
+
"overrides": {
|
|
15
|
+
"undici": "7.24.1",
|
|
16
|
+
"yauzl": "3.2.1"
|
|
17
|
+
}
|
|
18
|
+
}
|