@striae-org/striae 5.1.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 +3 -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/functions/api/image/[[path]].ts +2 -1
- package/package.json +1 -1
- package/scripts/deploy-config.sh +75 -47
- package/scripts/deploy-worker-secrets.sh +2 -2
- package/worker-configuration.d.ts +5 -3
- package/workers/data-worker/wrangler.jsonc.example +1 -3
- package/workers/image-worker/src/image-worker.example.ts +266 -15
- package/workers/image-worker/worker-configuration.d.ts +3 -2
- package/scripts/encrypt-r2-backfill.mjs +0 -376
|
@@ -10,14 +10,14 @@ interface Env {
|
|
|
10
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
|
+
IMAGE_SIGNED_URL_SECRET?: string;
|
|
14
|
+
IMAGE_SIGNED_URL_TTL_SECONDS?: string;
|
|
13
15
|
}
|
|
14
16
|
|
|
15
17
|
interface UploadResult {
|
|
16
18
|
id: string;
|
|
17
19
|
filename: string;
|
|
18
20
|
uploaded: string;
|
|
19
|
-
requireSignedURLs: boolean;
|
|
20
|
-
variants: string[];
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
interface UploadResponse {
|
|
@@ -35,7 +35,29 @@ interface ErrorResponse {
|
|
|
35
35
|
error: string;
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
|
|
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;
|
|
51
|
+
|
|
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;
|
|
39
61
|
|
|
40
62
|
const corsHeaders: Record<string, string> = {
|
|
41
63
|
'Access-Control-Allow-Origin': 'PAGES_CUSTOM_DOMAIN',
|
|
@@ -92,6 +114,162 @@ function parseFileId(pathname: string): string | null {
|
|
|
92
114
|
return decodedFileId;
|
|
93
115
|
}
|
|
94
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
|
+
|
|
95
273
|
function extractEnvelope(file: R2ObjectBody): DataAtRestEnvelope | null {
|
|
96
274
|
const metadata = file.customMetadata;
|
|
97
275
|
if (!metadata) {
|
|
@@ -174,9 +352,7 @@ async function handleImageUpload(request: Request, env: Env): Promise<Response>
|
|
|
174
352
|
result: {
|
|
175
353
|
id: fileId,
|
|
176
354
|
filename,
|
|
177
|
-
uploaded: uploadedAt
|
|
178
|
-
requireSignedURLs: false,
|
|
179
|
-
variants: []
|
|
355
|
+
uploaded: uploadedAt
|
|
180
356
|
}
|
|
181
357
|
});
|
|
182
358
|
}
|
|
@@ -200,18 +376,72 @@ async function handleImageDelete(request: Request, env: Env): Promise<Response>
|
|
|
200
376
|
return createJsonResponse({ success: true });
|
|
201
377
|
}
|
|
202
378
|
|
|
203
|
-
async function
|
|
379
|
+
async function handleSignedUrlMinting(request: Request, env: Env, fileId: string): Promise<Response> {
|
|
204
380
|
if (!hasValidToken(request, env)) {
|
|
205
381
|
return createJsonResponse({ error: 'Unauthorized' }, 403);
|
|
206
382
|
}
|
|
207
383
|
|
|
208
|
-
|
|
384
|
+
requireSignedUrlConfig(env);
|
|
209
385
|
|
|
210
|
-
const
|
|
211
|
-
if (!
|
|
212
|
-
return createJsonResponse({ error: '
|
|
386
|
+
const existing = await env.STRIAE_FILES.head(fileId);
|
|
387
|
+
if (!existing) {
|
|
388
|
+
return createJsonResponse({ error: 'File not found' }, 404);
|
|
389
|
+
}
|
|
390
|
+
|
|
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
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
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
|
+
};
|
|
408
|
+
|
|
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);
|
|
213
441
|
}
|
|
214
442
|
|
|
443
|
+
requireEncryptionRetrievalConfig(env);
|
|
444
|
+
|
|
215
445
|
const file = await env.STRIAE_FILES.get(fileId);
|
|
216
446
|
if (!file) {
|
|
217
447
|
return createJsonResponse({ error: 'File not found' }, 404);
|
|
@@ -250,11 +480,32 @@ export default {
|
|
|
250
480
|
}
|
|
251
481
|
|
|
252
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
|
+
|
|
253
489
|
switch (request.method) {
|
|
254
|
-
case 'POST':
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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
|
+
}
|
|
258
509
|
case 'DELETE':
|
|
259
510
|
return handleImageDelete(request, env);
|
|
260
511
|
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 {}
|