@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.
- package/.env.example +7 -3
- package/app/components/actions/case-export/download-handlers.ts +23 -7
- package/app/components/actions/case-manage.ts +24 -9
- package/app/components/actions/generate-pdf.ts +52 -4
- package/app/components/actions/image-manage.ts +48 -48
- package/app/routes/striae/hooks/use-striae-reset-helpers.ts +4 -0
- package/app/routes/striae/striae.tsx +16 -4
- package/app/types/file.ts +18 -2
- package/app/utils/api/image-api-client.ts +49 -1
- package/app/utils/data/operations/case-operations.ts +13 -1
- package/app/utils/data/operations/confirmation-summary-operations.ts +38 -1
- package/app/utils/data/operations/file-annotation-operations.ts +13 -1
- package/functions/api/image/[[path]].ts +2 -1
- package/package.json +2 -2
- package/scripts/deploy-config.sh +191 -20
- package/scripts/deploy-pages-secrets.sh +0 -6
- package/scripts/deploy-worker-secrets.sh +67 -6
- package/worker-configuration.d.ts +15 -7
- package/workers/audit-worker/package.json +1 -4
- package/workers/audit-worker/src/audit-worker.example.ts +522 -61
- package/workers/audit-worker/wrangler.jsonc.example +5 -0
- package/workers/data-worker/package.json +1 -4
- package/workers/data-worker/src/data-worker.example.ts +280 -2
- package/workers/data-worker/src/encryption-utils.ts +145 -1
- package/workers/data-worker/wrangler.jsonc.example +3 -1
- package/workers/image-worker/package.json +1 -4
- package/workers/image-worker/src/encryption-utils.ts +217 -0
- package/workers/image-worker/src/image-worker.example.ts +449 -129
- package/workers/image-worker/worker-configuration.d.ts +3 -2
- package/workers/image-worker/wrangler.jsonc.example +7 -0
- package/workers/keys-worker/package.json +1 -4
- package/workers/pdf-worker/package.json +1 -4
- 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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
42
|
-
|
|
43
|
-
{
|
|
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
|
-
|
|
47
|
-
const authHeader = request.headers.get(
|
|
48
|
-
const expectedToken = `Bearer ${env.
|
|
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
|
|
309
|
+
return createJsonResponse({ error: 'Unauthorized' }, 403);
|
|
58
310
|
}
|
|
59
311
|
|
|
312
|
+
requireEncryptionUploadConfig(env);
|
|
313
|
+
|
|
60
314
|
const formData = await request.formData();
|
|
61
|
-
const
|
|
315
|
+
const fileValue = formData.get('file');
|
|
316
|
+
if (!(fileValue instanceof Blob)) {
|
|
317
|
+
return createJsonResponse({ error: 'Missing file upload payload' }, 400);
|
|
318
|
+
}
|
|
62
319
|
|
|
63
|
-
|
|
64
|
-
|
|
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
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
|
362
|
+
return createJsonResponse({ error: 'Unauthorized' }, 403);
|
|
84
363
|
}
|
|
85
364
|
|
|
86
|
-
const
|
|
87
|
-
|
|
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
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
136
|
-
return
|
|
137
|
-
headers: corsHeaders
|
|
138
|
-
});
|
|
375
|
+
await env.STRIAE_FILES.delete(fileId);
|
|
376
|
+
return createJsonResponse({ success: true });
|
|
139
377
|
}
|
|
140
378
|
|
|
141
|
-
async function
|
|
379
|
+
async function handleSignedUrlMinting(request: Request, env: Env, fileId: string): Promise<Response> {
|
|
142
380
|
if (!hasValidToken(request, env)) {
|
|
143
|
-
return
|
|
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
|
-
|
|
147
|
-
const
|
|
148
|
-
if (
|
|
149
|
-
|
|
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
|
-
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
return
|
|
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
|
-
|
|
168
|
-
|
|
450
|
+
const envelope = extractEnvelope(file);
|
|
451
|
+
if (!envelope) {
|
|
452
|
+
return createJsonResponse({ error: 'Missing data-at-rest envelope metadata' }, 500);
|
|
169
453
|
}
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
|
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
|
|
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:
|
|
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 {}
|
|
@@ -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": {
|