@striae-org/striae 5.4.3 → 5.4.5
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/app/utils/auth/auth-action-settings.ts +1 -1
- package/package.json +6 -6
- package/scripts/enable-totp-mfa.mjs +2 -2
- package/workers/audit-worker/wrangler.jsonc.example +1 -1
- package/workers/data-worker/wrangler.jsonc.example +1 -1
- package/workers/image-worker/src/auth.ts +7 -0
- package/workers/image-worker/src/handlers/delete-image.ts +26 -0
- package/workers/image-worker/src/handlers/mint-signed-url.ts +83 -0
- package/workers/image-worker/src/handlers/serve-image.ts +65 -0
- package/workers/image-worker/src/handlers/upload-image.ts +62 -0
- package/workers/image-worker/src/image-worker.example.ts +3 -707
- package/workers/image-worker/src/router.ts +53 -0
- package/workers/image-worker/src/security/key-registry.ts +193 -0
- package/workers/image-worker/src/security/signed-url.ts +163 -0
- package/workers/image-worker/src/types.ts +68 -0
- package/workers/image-worker/src/utils/content-disposition.ts +33 -0
- package/workers/image-worker/src/utils/path-utils.ts +50 -0
- package/workers/image-worker/src/utils/storage-metadata.ts +27 -0
- package/workers/image-worker/wrangler.jsonc.example +1 -1
- package/workers/pdf-worker/wrangler.jsonc.example +1 -1
- package/workers/user-worker/src/handlers/user-routes.ts +23 -34
- package/workers/user-worker/src/user-worker.example.ts +17 -23
- package/workers/user-worker/wrangler.jsonc.example +1 -1
- package/wrangler.toml.example +1 -1
|
@@ -1,78 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
encryptBinaryForStorage,
|
|
4
|
-
type DataAtRestEnvelope
|
|
5
|
-
} from './encryption-utils';
|
|
6
|
-
|
|
7
|
-
interface Env {
|
|
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
|
-
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;
|
|
17
|
-
IMAGE_SIGNED_URL_BASE_URL?: string;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
interface KeyRegistryPayload {
|
|
21
|
-
activeKeyId?: unknown;
|
|
22
|
-
keys?: unknown;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
interface PrivateKeyRegistry {
|
|
26
|
-
activeKeyId: string | null;
|
|
27
|
-
keys: Record<string, string>;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
type DecryptionTelemetryOutcome = 'primary-hit' | 'fallback-hit' | 'all-failed';
|
|
31
|
-
|
|
32
|
-
interface UploadResult {
|
|
33
|
-
id: string;
|
|
34
|
-
filename: string;
|
|
35
|
-
uploaded: string;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
interface UploadResponse {
|
|
39
|
-
success: boolean;
|
|
40
|
-
errors: Array<{ code: number; message: string }>;
|
|
41
|
-
messages: string[];
|
|
42
|
-
result: UploadResult;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
interface SuccessResponse {
|
|
46
|
-
success: boolean;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
interface ErrorResponse {
|
|
50
|
-
error: string;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
interface SignedUrlResult {
|
|
54
|
-
fileId: string;
|
|
55
|
-
url: string;
|
|
56
|
-
expiresAt: string;
|
|
57
|
-
expiresInSeconds: number;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
interface SignedUrlResponse {
|
|
61
|
-
success: boolean;
|
|
62
|
-
result: SignedUrlResult;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
type APIResponse = UploadResponse | SuccessResponse | ErrorResponse | SignedUrlResponse;
|
|
66
|
-
|
|
67
|
-
interface SignedAccessPayload {
|
|
68
|
-
fileId: string;
|
|
69
|
-
iat: number;
|
|
70
|
-
exp: number;
|
|
71
|
-
nonce: string;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
const DEFAULT_SIGNED_URL_TTL_SECONDS = 3600;
|
|
75
|
-
const MAX_SIGNED_URL_TTL_SECONDS = 86400;
|
|
1
|
+
import { routeImageWorkerRequest } from './router';
|
|
2
|
+
import type { APIResponse, Env } from './types';
|
|
76
3
|
|
|
77
4
|
const corsHeaders: Record<string, string> = {
|
|
78
5
|
'Access-Control-Allow-Origin': 'PAGES_CUSTOM_DOMAIN',
|
|
@@ -91,607 +18,6 @@ const createJsonResponse = (data: APIResponse, status: number = 200): Response =
|
|
|
91
18
|
}
|
|
92
19
|
);
|
|
93
20
|
|
|
94
|
-
function hasValidToken(request: Request, env: Env): boolean {
|
|
95
|
-
const authHeader = request.headers.get('Authorization');
|
|
96
|
-
const expectedToken = `Bearer ${env.IMAGES_API_TOKEN}`;
|
|
97
|
-
return authHeader === expectedToken;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
function requireEncryptionUploadConfig(env: Env): void {
|
|
101
|
-
if (!env.DATA_AT_REST_ENCRYPTION_PUBLIC_KEY || !env.DATA_AT_REST_ENCRYPTION_KEY_ID) {
|
|
102
|
-
throw new Error('Data-at-rest encryption is not configured for image uploads');
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
function requireEncryptionRetrievalConfig(env: Env): void {
|
|
107
|
-
const hasLegacyPrivateKey = typeof env.DATA_AT_REST_ENCRYPTION_PRIVATE_KEY === 'string' && env.DATA_AT_REST_ENCRYPTION_PRIVATE_KEY.trim().length > 0;
|
|
108
|
-
const hasRegistry = typeof env.DATA_AT_REST_ENCRYPTION_KEYS_JSON === 'string' && env.DATA_AT_REST_ENCRYPTION_KEYS_JSON.trim().length > 0;
|
|
109
|
-
|
|
110
|
-
if (!hasLegacyPrivateKey && !hasRegistry) {
|
|
111
|
-
throw new Error('Data-at-rest decryption registry is not configured for image retrieval');
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
function normalizePrivateKeyPem(rawValue: string): string {
|
|
116
|
-
return rawValue.trim().replace(/^['"]|['"]$/g, '').replace(/\\n/g, '\n');
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
function getNonEmptyString(value: unknown): string | null {
|
|
120
|
-
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
function parseDataAtRestPrivateKeyRegistry(env: Env): PrivateKeyRegistry {
|
|
124
|
-
const keys: Record<string, string> = {};
|
|
125
|
-
const configuredActiveKeyId = getNonEmptyString(env.DATA_AT_REST_ENCRYPTION_ACTIVE_KEY_ID);
|
|
126
|
-
const registryJson = getNonEmptyString(env.DATA_AT_REST_ENCRYPTION_KEYS_JSON);
|
|
127
|
-
|
|
128
|
-
if (registryJson) {
|
|
129
|
-
let parsedRegistry: unknown;
|
|
130
|
-
try {
|
|
131
|
-
parsedRegistry = JSON.parse(registryJson) as unknown;
|
|
132
|
-
} catch {
|
|
133
|
-
throw new Error('DATA_AT_REST_ENCRYPTION_KEYS_JSON is not valid JSON');
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
if (!parsedRegistry || typeof parsedRegistry !== 'object') {
|
|
137
|
-
throw new Error('DATA_AT_REST_ENCRYPTION_KEYS_JSON must be an object');
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
const payload = parsedRegistry as KeyRegistryPayload;
|
|
141
|
-
if (!payload.keys || typeof payload.keys !== 'object') {
|
|
142
|
-
throw new Error('DATA_AT_REST_ENCRYPTION_KEYS_JSON must include a keys object');
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
for (const [keyId, pemValue] of Object.entries(payload.keys as Record<string, unknown>)) {
|
|
146
|
-
const normalizedKeyId = getNonEmptyString(keyId);
|
|
147
|
-
const normalizedPem = getNonEmptyString(pemValue);
|
|
148
|
-
if (!normalizedKeyId || !normalizedPem) {
|
|
149
|
-
continue;
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
keys[normalizedKeyId] = normalizePrivateKeyPem(normalizedPem);
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
const payloadActiveKeyId = getNonEmptyString(payload.activeKeyId);
|
|
156
|
-
const resolvedActiveKeyId = configuredActiveKeyId ?? payloadActiveKeyId;
|
|
157
|
-
|
|
158
|
-
if (Object.keys(keys).length === 0) {
|
|
159
|
-
throw new Error('DATA_AT_REST_ENCRYPTION_KEYS_JSON does not contain any usable keys');
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
if (resolvedActiveKeyId && !keys[resolvedActiveKeyId]) {
|
|
163
|
-
throw new Error('DATA_AT_REST active key ID is not present in DATA_AT_REST_ENCRYPTION_KEYS_JSON');
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
return {
|
|
167
|
-
activeKeyId: resolvedActiveKeyId ?? null,
|
|
168
|
-
keys
|
|
169
|
-
};
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
const legacyKeyId = getNonEmptyString(env.DATA_AT_REST_ENCRYPTION_KEY_ID);
|
|
173
|
-
const legacyPrivateKey = getNonEmptyString(env.DATA_AT_REST_ENCRYPTION_PRIVATE_KEY);
|
|
174
|
-
if (!legacyKeyId || !legacyPrivateKey) {
|
|
175
|
-
throw new Error('Data-at-rest decryption key registry is not configured');
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
keys[legacyKeyId] = normalizePrivateKeyPem(legacyPrivateKey);
|
|
179
|
-
|
|
180
|
-
return {
|
|
181
|
-
activeKeyId: configuredActiveKeyId ?? legacyKeyId,
|
|
182
|
-
keys
|
|
183
|
-
};
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
function buildPrivateKeyCandidates(
|
|
187
|
-
recordKeyId: string,
|
|
188
|
-
registry: PrivateKeyRegistry
|
|
189
|
-
): Array<{ keyId: string; privateKeyPem: string }> {
|
|
190
|
-
const candidates: Array<{ keyId: string; privateKeyPem: string }> = [];
|
|
191
|
-
const seen = new Set<string>();
|
|
192
|
-
|
|
193
|
-
const appendCandidate = (candidateKeyId: string | null): void => {
|
|
194
|
-
if (!candidateKeyId || seen.has(candidateKeyId)) {
|
|
195
|
-
return;
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
const privateKeyPem = registry.keys[candidateKeyId];
|
|
199
|
-
if (!privateKeyPem) {
|
|
200
|
-
return;
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
seen.add(candidateKeyId);
|
|
204
|
-
candidates.push({ keyId: candidateKeyId, privateKeyPem });
|
|
205
|
-
};
|
|
206
|
-
|
|
207
|
-
appendCandidate(getNonEmptyString(recordKeyId));
|
|
208
|
-
appendCandidate(registry.activeKeyId);
|
|
209
|
-
|
|
210
|
-
for (const keyId of Object.keys(registry.keys)) {
|
|
211
|
-
appendCandidate(keyId);
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
return candidates;
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
function logFileDecryptionTelemetry(input: {
|
|
218
|
-
recordKeyId: string;
|
|
219
|
-
selectedKeyId: string | null;
|
|
220
|
-
attemptCount: number;
|
|
221
|
-
outcome: DecryptionTelemetryOutcome;
|
|
222
|
-
reason?: string;
|
|
223
|
-
}): void {
|
|
224
|
-
const details = {
|
|
225
|
-
scope: 'file-at-rest',
|
|
226
|
-
recordKeyId: input.recordKeyId,
|
|
227
|
-
selectedKeyId: input.selectedKeyId,
|
|
228
|
-
attemptCount: input.attemptCount,
|
|
229
|
-
fallbackUsed: input.outcome === 'fallback-hit',
|
|
230
|
-
outcome: input.outcome,
|
|
231
|
-
reason: input.reason ?? null
|
|
232
|
-
};
|
|
233
|
-
|
|
234
|
-
if (input.outcome === 'all-failed') {
|
|
235
|
-
console.warn('Key registry decryption failed', details);
|
|
236
|
-
return;
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
console.info('Key registry decryption resolved', details);
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
async function decryptBinaryWithRegistry(
|
|
243
|
-
ciphertext: ArrayBuffer,
|
|
244
|
-
envelope: DataAtRestEnvelope,
|
|
245
|
-
env: Env
|
|
246
|
-
): Promise<ArrayBuffer> {
|
|
247
|
-
const keyRegistry = parseDataAtRestPrivateKeyRegistry(env);
|
|
248
|
-
const candidates = buildPrivateKeyCandidates(envelope.keyId, keyRegistry);
|
|
249
|
-
const primaryKeyId = candidates[0]?.keyId ?? null;
|
|
250
|
-
let lastError: unknown;
|
|
251
|
-
|
|
252
|
-
for (let index = 0; index < candidates.length; index += 1) {
|
|
253
|
-
const candidate = candidates[index];
|
|
254
|
-
try {
|
|
255
|
-
const plaintext = await decryptBinaryFromStorage(ciphertext, envelope, candidate.privateKeyPem);
|
|
256
|
-
logFileDecryptionTelemetry({
|
|
257
|
-
recordKeyId: envelope.keyId,
|
|
258
|
-
selectedKeyId: candidate.keyId,
|
|
259
|
-
attemptCount: index + 1,
|
|
260
|
-
outcome: candidate.keyId === primaryKeyId ? 'primary-hit' : 'fallback-hit'
|
|
261
|
-
});
|
|
262
|
-
return plaintext;
|
|
263
|
-
} catch (error) {
|
|
264
|
-
lastError = error;
|
|
265
|
-
}
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
logFileDecryptionTelemetry({
|
|
269
|
-
recordKeyId: envelope.keyId,
|
|
270
|
-
selectedKeyId: null,
|
|
271
|
-
attemptCount: candidates.length,
|
|
272
|
-
outcome: 'all-failed',
|
|
273
|
-
reason: lastError instanceof Error ? lastError.message : 'unknown decryption error'
|
|
274
|
-
});
|
|
275
|
-
|
|
276
|
-
throw new Error(
|
|
277
|
-
`Failed to decrypt stored file after ${candidates.length} key attempt(s): ${
|
|
278
|
-
lastError instanceof Error ? lastError.message : 'unknown decryption error'
|
|
279
|
-
}`
|
|
280
|
-
);
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
function parseFileId(pathname: string): string | null {
|
|
284
|
-
const encodedFileId = pathname.startsWith('/') ? pathname.slice(1) : pathname;
|
|
285
|
-
if (!encodedFileId) {
|
|
286
|
-
return null;
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
let decodedFileId = '';
|
|
290
|
-
try {
|
|
291
|
-
decodedFileId = decodeURIComponent(encodedFileId);
|
|
292
|
-
} catch {
|
|
293
|
-
return null;
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
if (!decodedFileId || decodedFileId.includes('/')) {
|
|
297
|
-
return null;
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
return decodedFileId;
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
function parsePathSegments(pathname: string): string[] | null {
|
|
304
|
-
const normalized = pathname.startsWith('/') ? pathname.slice(1) : pathname;
|
|
305
|
-
if (!normalized) {
|
|
306
|
-
return [];
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
const rawSegments = normalized.split('/');
|
|
310
|
-
const decodedSegments: string[] = [];
|
|
311
|
-
|
|
312
|
-
for (const segment of rawSegments) {
|
|
313
|
-
if (!segment) {
|
|
314
|
-
return null;
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
let decoded = '';
|
|
318
|
-
try {
|
|
319
|
-
decoded = decodeURIComponent(segment);
|
|
320
|
-
} catch {
|
|
321
|
-
return null;
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
if (!decoded || decoded.includes('/')) {
|
|
325
|
-
return null;
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
decodedSegments.push(decoded);
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
return decodedSegments;
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
function base64UrlEncode(input: ArrayBuffer | Uint8Array): string {
|
|
335
|
-
const bytes = input instanceof Uint8Array ? input : new Uint8Array(input);
|
|
336
|
-
let binary = '';
|
|
337
|
-
for (let i = 0; i < bytes.length; i += 1) {
|
|
338
|
-
binary += String.fromCharCode(bytes[i]);
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
function base64UrlDecode(input: string): Uint8Array | null {
|
|
345
|
-
if (!input || /[^A-Za-z0-9_-]/.test(input)) {
|
|
346
|
-
return null;
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
const paddingLength = (4 - (input.length % 4)) % 4;
|
|
350
|
-
const padded = input.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat(paddingLength);
|
|
351
|
-
|
|
352
|
-
try {
|
|
353
|
-
const binary = atob(padded);
|
|
354
|
-
const bytes = new Uint8Array(binary.length);
|
|
355
|
-
for (let i = 0; i < binary.length; i += 1) {
|
|
356
|
-
bytes[i] = binary.charCodeAt(i);
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
return bytes;
|
|
360
|
-
} catch {
|
|
361
|
-
return null;
|
|
362
|
-
}
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
function normalizeSignedUrlTtlSeconds(requestedTtlSeconds: unknown, env: Env): number {
|
|
366
|
-
const defaultFromEnv = Number.parseInt(env.IMAGE_SIGNED_URL_TTL_SECONDS ?? '', 10);
|
|
367
|
-
const fallbackTtl = Number.isFinite(defaultFromEnv) && defaultFromEnv > 0
|
|
368
|
-
? defaultFromEnv
|
|
369
|
-
: DEFAULT_SIGNED_URL_TTL_SECONDS;
|
|
370
|
-
const requested = typeof requestedTtlSeconds === 'number' ? requestedTtlSeconds : fallbackTtl;
|
|
371
|
-
const normalized = Math.floor(requested);
|
|
372
|
-
|
|
373
|
-
if (!Number.isFinite(normalized) || normalized <= 0) {
|
|
374
|
-
return fallbackTtl;
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
return Math.min(normalized, MAX_SIGNED_URL_TTL_SECONDS);
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
function requireSignedUrlConfig(env: Env): void {
|
|
381
|
-
const resolvedSecret = (env.IMAGE_SIGNED_URL_SECRET || env.IMAGES_API_TOKEN || '').trim();
|
|
382
|
-
if (resolvedSecret.length === 0) {
|
|
383
|
-
throw new Error('Signed URL configuration is missing');
|
|
384
|
-
}
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
function parseSignedUrlBaseUrl(raw: string): string {
|
|
388
|
-
let parsed: URL;
|
|
389
|
-
try {
|
|
390
|
-
parsed = new URL(raw.trim());
|
|
391
|
-
} catch {
|
|
392
|
-
throw new Error(`IMAGE_SIGNED_URL_BASE_URL is not a valid absolute URL: "${raw}"`);
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
|
|
396
|
-
throw new Error(`IMAGE_SIGNED_URL_BASE_URL must use http or https, got: "${parsed.protocol}"`);
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
if (parsed.search || parsed.hash) {
|
|
400
|
-
throw new Error(`IMAGE_SIGNED_URL_BASE_URL must not include a query string or fragment: "${raw}"`);
|
|
401
|
-
}
|
|
402
|
-
|
|
403
|
-
return `${parsed.origin}${parsed.pathname}`.replace(/\/+$/, '');
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
async function getSignedUrlHmacKey(env: Env): Promise<CryptoKey> {
|
|
407
|
-
const resolvedSecret = (env.IMAGE_SIGNED_URL_SECRET || env.IMAGES_API_TOKEN || '').trim();
|
|
408
|
-
const keyBytes = new TextEncoder().encode(resolvedSecret);
|
|
409
|
-
return crypto.subtle.importKey('raw', keyBytes, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign', 'verify']);
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
async function signSignedAccessPayload(payload: SignedAccessPayload, env: Env): Promise<string> {
|
|
413
|
-
const payloadJson = JSON.stringify(payload);
|
|
414
|
-
const payloadBase64Url = base64UrlEncode(new TextEncoder().encode(payloadJson));
|
|
415
|
-
const hmacKey = await getSignedUrlHmacKey(env);
|
|
416
|
-
const signature = await crypto.subtle.sign('HMAC', hmacKey, new TextEncoder().encode(payloadBase64Url));
|
|
417
|
-
const signatureBase64Url = base64UrlEncode(signature);
|
|
418
|
-
return `${payloadBase64Url}.${signatureBase64Url}`;
|
|
419
|
-
}
|
|
420
|
-
|
|
421
|
-
async function verifySignedAccessToken(token: string, fileId: string, env: Env): Promise<boolean> {
|
|
422
|
-
const tokenParts = token.split('.');
|
|
423
|
-
if (tokenParts.length !== 2) {
|
|
424
|
-
return false;
|
|
425
|
-
}
|
|
426
|
-
|
|
427
|
-
const [payloadBase64Url, signatureBase64Url] = tokenParts;
|
|
428
|
-
if (!payloadBase64Url || !signatureBase64Url) {
|
|
429
|
-
return false;
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
const signatureBytes = base64UrlDecode(signatureBase64Url);
|
|
433
|
-
if (!signatureBytes) {
|
|
434
|
-
return false;
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
const signatureBuffer = new Uint8Array(signatureBytes).buffer;
|
|
438
|
-
|
|
439
|
-
const hmacKey = await getSignedUrlHmacKey(env);
|
|
440
|
-
const signatureValid = await crypto.subtle.verify(
|
|
441
|
-
'HMAC',
|
|
442
|
-
hmacKey,
|
|
443
|
-
signatureBuffer,
|
|
444
|
-
new TextEncoder().encode(payloadBase64Url)
|
|
445
|
-
);
|
|
446
|
-
if (!signatureValid) {
|
|
447
|
-
return false;
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
const payloadBytes = base64UrlDecode(payloadBase64Url);
|
|
451
|
-
if (!payloadBytes) {
|
|
452
|
-
return false;
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
let payload: SignedAccessPayload;
|
|
456
|
-
try {
|
|
457
|
-
payload = JSON.parse(new TextDecoder().decode(payloadBytes)) as SignedAccessPayload;
|
|
458
|
-
} catch {
|
|
459
|
-
return false;
|
|
460
|
-
}
|
|
461
|
-
|
|
462
|
-
if (payload.fileId !== fileId) {
|
|
463
|
-
return false;
|
|
464
|
-
}
|
|
465
|
-
|
|
466
|
-
const nowEpochSeconds = Math.floor(Date.now() / 1000);
|
|
467
|
-
if (!Number.isInteger(payload.exp) || payload.exp <= nowEpochSeconds) {
|
|
468
|
-
return false;
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
if (!Number.isInteger(payload.iat) || payload.iat > nowEpochSeconds + 300) {
|
|
472
|
-
return false;
|
|
473
|
-
}
|
|
474
|
-
|
|
475
|
-
return typeof payload.nonce === 'string' && payload.nonce.length > 0;
|
|
476
|
-
}
|
|
477
|
-
|
|
478
|
-
function extractEnvelope(file: R2ObjectBody): DataAtRestEnvelope | null {
|
|
479
|
-
const metadata = file.customMetadata;
|
|
480
|
-
if (!metadata) {
|
|
481
|
-
return null;
|
|
482
|
-
}
|
|
483
|
-
|
|
484
|
-
const { algorithm, encryptionVersion, keyId, dataIv, wrappedKey } = metadata;
|
|
485
|
-
if (
|
|
486
|
-
typeof algorithm !== 'string' ||
|
|
487
|
-
typeof encryptionVersion !== 'string' ||
|
|
488
|
-
typeof keyId !== 'string' ||
|
|
489
|
-
typeof dataIv !== 'string' ||
|
|
490
|
-
typeof wrappedKey !== 'string'
|
|
491
|
-
) {
|
|
492
|
-
return null;
|
|
493
|
-
}
|
|
494
|
-
|
|
495
|
-
return {
|
|
496
|
-
algorithm,
|
|
497
|
-
encryptionVersion,
|
|
498
|
-
keyId,
|
|
499
|
-
dataIv,
|
|
500
|
-
wrappedKey
|
|
501
|
-
};
|
|
502
|
-
}
|
|
503
|
-
|
|
504
|
-
function deriveFileKind(contentType: string): string {
|
|
505
|
-
if (contentType.startsWith('image/')) {
|
|
506
|
-
return 'image';
|
|
507
|
-
}
|
|
508
|
-
|
|
509
|
-
return 'file';
|
|
510
|
-
}
|
|
511
|
-
|
|
512
|
-
async function handleImageUpload(request: Request, env: Env): Promise<Response> {
|
|
513
|
-
if (!hasValidToken(request, env)) {
|
|
514
|
-
return createJsonResponse({ error: 'Unauthorized' }, 403);
|
|
515
|
-
}
|
|
516
|
-
|
|
517
|
-
requireEncryptionUploadConfig(env);
|
|
518
|
-
|
|
519
|
-
const formData = await request.formData();
|
|
520
|
-
const fileValue = formData.get('file');
|
|
521
|
-
if (!(fileValue instanceof Blob)) {
|
|
522
|
-
return createJsonResponse({ error: 'Missing file upload payload' }, 400);
|
|
523
|
-
}
|
|
524
|
-
|
|
525
|
-
const fileBlob = fileValue;
|
|
526
|
-
const uploadedAt = new Date().toISOString();
|
|
527
|
-
const filename = fileValue instanceof File && fileValue.name ? fileValue.name : 'upload.bin';
|
|
528
|
-
const contentType = fileBlob.type || 'application/octet-stream';
|
|
529
|
-
const fileId = crypto.randomUUID().replace(/-/g, '');
|
|
530
|
-
const plaintextBytes = await fileBlob.arrayBuffer();
|
|
531
|
-
|
|
532
|
-
const encryptedPayload = await encryptBinaryForStorage(
|
|
533
|
-
plaintextBytes,
|
|
534
|
-
env.DATA_AT_REST_ENCRYPTION_PUBLIC_KEY,
|
|
535
|
-
env.DATA_AT_REST_ENCRYPTION_KEY_ID
|
|
536
|
-
);
|
|
537
|
-
|
|
538
|
-
await env.STRIAE_FILES.put(fileId, encryptedPayload.ciphertext, {
|
|
539
|
-
customMetadata: {
|
|
540
|
-
algorithm: encryptedPayload.envelope.algorithm,
|
|
541
|
-
encryptionVersion: encryptedPayload.envelope.encryptionVersion,
|
|
542
|
-
keyId: encryptedPayload.envelope.keyId,
|
|
543
|
-
dataIv: encryptedPayload.envelope.dataIv,
|
|
544
|
-
wrappedKey: encryptedPayload.envelope.wrappedKey,
|
|
545
|
-
contentType,
|
|
546
|
-
originalFilename: filename,
|
|
547
|
-
byteLength: String(fileBlob.size),
|
|
548
|
-
createdAt: uploadedAt,
|
|
549
|
-
fileKind: deriveFileKind(contentType)
|
|
550
|
-
}
|
|
551
|
-
});
|
|
552
|
-
|
|
553
|
-
return createJsonResponse({
|
|
554
|
-
success: true,
|
|
555
|
-
errors: [],
|
|
556
|
-
messages: [],
|
|
557
|
-
result: {
|
|
558
|
-
id: fileId,
|
|
559
|
-
filename,
|
|
560
|
-
uploaded: uploadedAt
|
|
561
|
-
}
|
|
562
|
-
});
|
|
563
|
-
}
|
|
564
|
-
|
|
565
|
-
async function handleImageDelete(request: Request, env: Env): Promise<Response> {
|
|
566
|
-
if (!hasValidToken(request, env)) {
|
|
567
|
-
return createJsonResponse({ error: 'Unauthorized' }, 403);
|
|
568
|
-
}
|
|
569
|
-
|
|
570
|
-
const fileId = parseFileId(new URL(request.url).pathname);
|
|
571
|
-
if (!fileId) {
|
|
572
|
-
return createJsonResponse({ error: 'Image ID is required' }, 400);
|
|
573
|
-
}
|
|
574
|
-
|
|
575
|
-
const existing = await env.STRIAE_FILES.head(fileId);
|
|
576
|
-
if (!existing) {
|
|
577
|
-
return createJsonResponse({ error: 'File not found' }, 404);
|
|
578
|
-
}
|
|
579
|
-
|
|
580
|
-
await env.STRIAE_FILES.delete(fileId);
|
|
581
|
-
return createJsonResponse({ success: true });
|
|
582
|
-
}
|
|
583
|
-
|
|
584
|
-
async function handleSignedUrlMinting(request: Request, env: Env, fileId: string): Promise<Response> {
|
|
585
|
-
if (!hasValidToken(request, env)) {
|
|
586
|
-
return createJsonResponse({ error: 'Unauthorized' }, 403);
|
|
587
|
-
}
|
|
588
|
-
|
|
589
|
-
requireSignedUrlConfig(env);
|
|
590
|
-
|
|
591
|
-
const existing = await env.STRIAE_FILES.head(fileId);
|
|
592
|
-
if (!existing) {
|
|
593
|
-
return createJsonResponse({ error: 'File not found' }, 404);
|
|
594
|
-
}
|
|
595
|
-
|
|
596
|
-
let requestedExpiresInSeconds: number | undefined;
|
|
597
|
-
const contentType = request.headers.get('Content-Type') || '';
|
|
598
|
-
if (contentType.includes('application/json')) {
|
|
599
|
-
const requestBody = await request.json().catch(() => null) as { expiresInSeconds?: number } | null;
|
|
600
|
-
if (requestBody && typeof requestBody.expiresInSeconds === 'number') {
|
|
601
|
-
requestedExpiresInSeconds = requestBody.expiresInSeconds;
|
|
602
|
-
}
|
|
603
|
-
}
|
|
604
|
-
|
|
605
|
-
const nowEpochSeconds = Math.floor(Date.now() / 1000);
|
|
606
|
-
const ttlSeconds = normalizeSignedUrlTtlSeconds(requestedExpiresInSeconds, env);
|
|
607
|
-
const payload: SignedAccessPayload = {
|
|
608
|
-
fileId,
|
|
609
|
-
iat: nowEpochSeconds,
|
|
610
|
-
exp: nowEpochSeconds + ttlSeconds,
|
|
611
|
-
nonce: crypto.randomUUID().replace(/-/g, '')
|
|
612
|
-
};
|
|
613
|
-
|
|
614
|
-
const signedToken = await signSignedAccessPayload(payload, env);
|
|
615
|
-
|
|
616
|
-
let baseUrl: string;
|
|
617
|
-
if (env.IMAGE_SIGNED_URL_BASE_URL) {
|
|
618
|
-
try {
|
|
619
|
-
baseUrl = parseSignedUrlBaseUrl(env.IMAGE_SIGNED_URL_BASE_URL);
|
|
620
|
-
} catch (error) {
|
|
621
|
-
console.error('Invalid IMAGE_SIGNED_URL_BASE_URL configuration', {
|
|
622
|
-
reason: error instanceof Error ? error.message : String(error)
|
|
623
|
-
});
|
|
624
|
-
return createJsonResponse({ error: 'Signed URL base URL is misconfigured' }, 500);
|
|
625
|
-
}
|
|
626
|
-
} else {
|
|
627
|
-
baseUrl = new URL(request.url).origin;
|
|
628
|
-
}
|
|
629
|
-
|
|
630
|
-
const signedUrl = `${baseUrl}/${encodeURIComponent(fileId)}?st=${encodeURIComponent(signedToken)}`;
|
|
631
|
-
|
|
632
|
-
return createJsonResponse({
|
|
633
|
-
success: true,
|
|
634
|
-
result: {
|
|
635
|
-
fileId,
|
|
636
|
-
url: signedUrl,
|
|
637
|
-
expiresAt: new Date(payload.exp * 1000).toISOString(),
|
|
638
|
-
expiresInSeconds: ttlSeconds
|
|
639
|
-
}
|
|
640
|
-
});
|
|
641
|
-
}
|
|
642
|
-
|
|
643
|
-
async function handleImageServing(request: Request, env: Env, fileId: string): Promise<Response> {
|
|
644
|
-
const requestUrl = new URL(request.url);
|
|
645
|
-
const hasSignedToken = requestUrl.searchParams.has('st');
|
|
646
|
-
const signedToken = requestUrl.searchParams.get('st');
|
|
647
|
-
if (hasSignedToken) {
|
|
648
|
-
requireSignedUrlConfig(env);
|
|
649
|
-
|
|
650
|
-
if (!signedToken || signedToken.trim().length === 0) {
|
|
651
|
-
return createJsonResponse({ error: 'Invalid or expired signed URL token' }, 403);
|
|
652
|
-
}
|
|
653
|
-
|
|
654
|
-
const tokenValid = await verifySignedAccessToken(signedToken, fileId, env);
|
|
655
|
-
if (!tokenValid) {
|
|
656
|
-
return createJsonResponse({ error: 'Invalid or expired signed URL token' }, 403);
|
|
657
|
-
}
|
|
658
|
-
} else if (!hasValidToken(request, env)) {
|
|
659
|
-
return createJsonResponse({ error: 'Unauthorized' }, 403);
|
|
660
|
-
}
|
|
661
|
-
|
|
662
|
-
requireEncryptionRetrievalConfig(env);
|
|
663
|
-
|
|
664
|
-
const file = await env.STRIAE_FILES.get(fileId);
|
|
665
|
-
if (!file) {
|
|
666
|
-
return createJsonResponse({ error: 'File not found' }, 404);
|
|
667
|
-
}
|
|
668
|
-
|
|
669
|
-
const envelope = extractEnvelope(file);
|
|
670
|
-
if (!envelope) {
|
|
671
|
-
return createJsonResponse({ error: 'Missing data-at-rest envelope metadata' }, 500);
|
|
672
|
-
}
|
|
673
|
-
|
|
674
|
-
const encryptedData = await file.arrayBuffer();
|
|
675
|
-
const plaintext = await decryptBinaryWithRegistry(
|
|
676
|
-
encryptedData,
|
|
677
|
-
envelope,
|
|
678
|
-
env
|
|
679
|
-
);
|
|
680
|
-
|
|
681
|
-
const contentType = file.customMetadata?.contentType || 'application/octet-stream';
|
|
682
|
-
const filename = file.customMetadata?.originalFilename || fileId;
|
|
683
|
-
|
|
684
|
-
return new Response(plaintext, {
|
|
685
|
-
status: 200,
|
|
686
|
-
headers: {
|
|
687
|
-
...corsHeaders,
|
|
688
|
-
'Cache-Control': 'no-store',
|
|
689
|
-
'Content-Type': contentType,
|
|
690
|
-
'Content-Disposition': `inline; filename="${filename.replace(/"/g, '')}"`
|
|
691
|
-
}
|
|
692
|
-
});
|
|
693
|
-
}
|
|
694
|
-
|
|
695
21
|
export default {
|
|
696
22
|
async fetch(request: Request, env: Env): Promise<Response> {
|
|
697
23
|
if (request.method === 'OPTIONS') {
|
|
@@ -699,37 +25,7 @@ export default {
|
|
|
699
25
|
}
|
|
700
26
|
|
|
701
27
|
try {
|
|
702
|
-
|
|
703
|
-
const pathSegments = parsePathSegments(requestUrl.pathname);
|
|
704
|
-
if (!pathSegments) {
|
|
705
|
-
return createJsonResponse({ error: 'Invalid image path encoding' }, 400);
|
|
706
|
-
}
|
|
707
|
-
|
|
708
|
-
switch (request.method) {
|
|
709
|
-
case 'POST': {
|
|
710
|
-
if (pathSegments.length === 0) {
|
|
711
|
-
return handleImageUpload(request, env);
|
|
712
|
-
}
|
|
713
|
-
|
|
714
|
-
if (pathSegments.length === 2 && pathSegments[1] === 'signed-url') {
|
|
715
|
-
return handleSignedUrlMinting(request, env, pathSegments[0]);
|
|
716
|
-
}
|
|
717
|
-
|
|
718
|
-
return createJsonResponse({ error: 'Not found' }, 404);
|
|
719
|
-
}
|
|
720
|
-
case 'GET': {
|
|
721
|
-
const fileId = pathSegments.length === 1 ? pathSegments[0] : null;
|
|
722
|
-
if (!fileId) {
|
|
723
|
-
return createJsonResponse({ error: 'Image ID is required' }, 400);
|
|
724
|
-
}
|
|
725
|
-
|
|
726
|
-
return handleImageServing(request, env, fileId);
|
|
727
|
-
}
|
|
728
|
-
case 'DELETE':
|
|
729
|
-
return handleImageDelete(request, env);
|
|
730
|
-
default:
|
|
731
|
-
return createJsonResponse({ error: 'Method not allowed' }, 405);
|
|
732
|
-
}
|
|
28
|
+
return await routeImageWorkerRequest(request, env, createJsonResponse, corsHeaders);
|
|
733
29
|
} catch (error) {
|
|
734
30
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';
|
|
735
31
|
return createJsonResponse({ error: errorMessage }, 500);
|