@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
package/.env.example
CHANGED
|
@@ -47,7 +47,6 @@ PAGES_CUSTOM_DOMAIN=your_custom_domain_here
|
|
|
47
47
|
KEYS_WORKER_NAME=your_keys_worker_name_here
|
|
48
48
|
KEYS_WORKER_DOMAIN=your_keys_worker_domain_here
|
|
49
49
|
KEYS_AUTH=your_custom_keys_auth_token_here
|
|
50
|
-
ACCOUNT_HASH=your_cloudflare_images_account_hash_here
|
|
51
50
|
|
|
52
51
|
# ================================
|
|
53
52
|
# USER WORKER ENVIRONMENT VARIABLES
|
|
@@ -61,6 +60,7 @@ KV_STORE_ID=your_kv_store_id_here
|
|
|
61
60
|
# ================================
|
|
62
61
|
DATA_WORKER_NAME=your_data_worker_name_here
|
|
63
62
|
DATA_BUCKET_NAME=your_data_bucket_name_here
|
|
63
|
+
FILES_BUCKET_NAME=your_files_bucket_name_here
|
|
64
64
|
DATA_WORKER_DOMAIN=your_data_worker_domain_here
|
|
65
65
|
# Auto-generated by scripts/deploy-config.sh when placeholders are detected.
|
|
66
66
|
MANIFEST_SIGNING_PRIVATE_KEY=your_manifest_signing_private_key_here
|
|
@@ -69,6 +69,9 @@ MANIFEST_SIGNING_PUBLIC_KEY=your_manifest_signing_public_key_here
|
|
|
69
69
|
EXPORT_ENCRYPTION_PRIVATE_KEY=your_export_encryption_private_key_here
|
|
70
70
|
EXPORT_ENCRYPTION_KEY_ID=your_export_encryption_key_id_here
|
|
71
71
|
EXPORT_ENCRYPTION_PUBLIC_KEY=your_export_encryption_public_key_here
|
|
72
|
+
DATA_AT_REST_ENCRYPTION_PRIVATE_KEY=your_data_at_rest_encryption_private_key_here
|
|
73
|
+
DATA_AT_REST_ENCRYPTION_KEY_ID=your_data_at_rest_encryption_key_id_here
|
|
74
|
+
DATA_AT_REST_ENCRYPTION_PUBLIC_KEY=your_data_at_rest_encryption_public_key_here
|
|
72
75
|
|
|
73
76
|
|
|
74
77
|
# ================================
|
|
@@ -83,8 +86,9 @@ AUDIT_WORKER_DOMAIN=your_audit_worker_domain_here
|
|
|
83
86
|
# ================================
|
|
84
87
|
IMAGES_WORKER_NAME=your_images_worker_name_here
|
|
85
88
|
IMAGES_WORKER_DOMAIN=your_images_worker_domain_here
|
|
86
|
-
|
|
87
|
-
|
|
89
|
+
IMAGE_SIGNED_URL_SECRET=your_image_signed_url_secret_here
|
|
90
|
+
# Optional: defaults to 3600 and max is 86400.
|
|
91
|
+
IMAGE_SIGNED_URL_TTL_SECONDS=3600
|
|
88
92
|
|
|
89
93
|
# ================================
|
|
90
94
|
# PDF WORKER ENVIRONMENT VARIABLES
|
|
@@ -965,13 +965,29 @@ For questions about this export, contact your Striae system administrator.
|
|
|
965
965
|
*/
|
|
966
966
|
async function fetchImageAsBlob(user: User, fileData: FileData, caseNumber: string): Promise<Blob | null> {
|
|
967
967
|
try {
|
|
968
|
-
const
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
968
|
+
const imageAccess = await getImageUrl(user, fileData, caseNumber, 'Export Package');
|
|
969
|
+
const { blob, revoke, url } = imageAccess;
|
|
970
|
+
|
|
971
|
+
if (!blob) {
|
|
972
|
+
const signedResponse = await fetch(url, {
|
|
973
|
+
method: 'GET',
|
|
974
|
+
headers: {
|
|
975
|
+
'Accept': 'application/octet-stream,image/*'
|
|
976
|
+
}
|
|
977
|
+
});
|
|
978
|
+
|
|
979
|
+
if (!signedResponse.ok) {
|
|
980
|
+
throw new Error(`Signed URL fetch failed with status ${signedResponse.status}`);
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
return await signedResponse.blob();
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
try {
|
|
987
|
+
return blob;
|
|
988
|
+
} finally {
|
|
989
|
+
revoke();
|
|
990
|
+
}
|
|
975
991
|
} catch (error) {
|
|
976
992
|
console.error('Failed to fetch image blob:', error);
|
|
977
993
|
return null;
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
duplicateCaseData,
|
|
13
13
|
deleteFileAnnotations,
|
|
14
14
|
signForensicManifest,
|
|
15
|
+
moveCaseConfirmationSummary,
|
|
15
16
|
removeCaseConfirmationSummary
|
|
16
17
|
} from '~/utils/data';
|
|
17
18
|
import { type CaseData, type ReadOnlyCaseData, type FileData, type AuditTrail, type CaseExportData, type ValidationAuditEntry } from '~/types';
|
|
@@ -392,7 +393,10 @@ export const renameCase = async (
|
|
|
392
393
|
// 4) Delete R2 case data with old case number
|
|
393
394
|
await deleteCaseData(user, oldCaseNumber);
|
|
394
395
|
|
|
395
|
-
// 5)
|
|
396
|
+
// 5) Move confirmation summary metadata to the new case number
|
|
397
|
+
await moveCaseConfirmationSummary(user, oldCaseNumber, newCaseNumber);
|
|
398
|
+
|
|
399
|
+
// 6) Delete old case number in user's KV entry
|
|
396
400
|
await removeUserCase(user, oldCaseNumber);
|
|
397
401
|
|
|
398
402
|
// Log successful case rename under the original case number context
|
|
@@ -681,18 +685,29 @@ const getVerificationPublicSigningKey = (preferredKeyId?: string): { keyId: stri
|
|
|
681
685
|
|
|
682
686
|
const fetchImageAsBlob = async (user: User, fileData: FileData, caseNumber: string): Promise<Blob | null> => {
|
|
683
687
|
try {
|
|
684
|
-
const
|
|
688
|
+
const imageAccess = await getImageUrl(user, fileData, caseNumber, 'Archive Package');
|
|
689
|
+
const { blob, revoke, url } = imageAccess;
|
|
690
|
+
|
|
691
|
+
if (!blob) {
|
|
692
|
+
const signedResponse = await fetch(url, {
|
|
693
|
+
method: 'GET',
|
|
694
|
+
headers: {
|
|
695
|
+
'Accept': 'application/octet-stream,image/*'
|
|
696
|
+
}
|
|
697
|
+
});
|
|
685
698
|
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
699
|
+
if (!signedResponse.ok) {
|
|
700
|
+
throw new Error(`Signed URL fetch failed with status ${signedResponse.status}`);
|
|
701
|
+
}
|
|
689
702
|
|
|
690
|
-
|
|
691
|
-
if (!response.ok) {
|
|
692
|
-
return null;
|
|
703
|
+
return await signedResponse.blob();
|
|
693
704
|
}
|
|
694
705
|
|
|
695
|
-
|
|
706
|
+
try {
|
|
707
|
+
return blob;
|
|
708
|
+
} finally {
|
|
709
|
+
revoke();
|
|
710
|
+
}
|
|
696
711
|
} catch (error) {
|
|
697
712
|
console.error('Failed to fetch image for archive package:', error);
|
|
698
713
|
return null;
|
|
@@ -6,6 +6,7 @@ import { fetchPdfApi } from '~/utils/api';
|
|
|
6
6
|
interface GeneratePDFParams {
|
|
7
7
|
user: User;
|
|
8
8
|
selectedImage: string | undefined;
|
|
9
|
+
sourceImageId?: string;
|
|
9
10
|
selectedFilename: string | undefined;
|
|
10
11
|
userCompany: string;
|
|
11
12
|
userFirstName: string;
|
|
@@ -21,9 +22,54 @@ interface GeneratePDFParams {
|
|
|
21
22
|
setToastDuration?: (duration: number) => void;
|
|
22
23
|
}
|
|
23
24
|
|
|
25
|
+
const CLEAR_IMAGE_SENTINEL = '/clear.jpg';
|
|
26
|
+
|
|
27
|
+
const blobToDataUrl = async (blob: Blob): Promise<string> => {
|
|
28
|
+
return await new Promise<string>((resolve, reject) => {
|
|
29
|
+
const reader = new FileReader();
|
|
30
|
+
reader.onloadend = () => {
|
|
31
|
+
if (typeof reader.result === 'string') {
|
|
32
|
+
resolve(reader.result);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
reject(new Error('Failed to read image blob as data URL'));
|
|
37
|
+
};
|
|
38
|
+
reader.onerror = () => reject(new Error('Failed to convert image for PDF rendering'));
|
|
39
|
+
reader.readAsDataURL(blob);
|
|
40
|
+
});
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const resolvePdfImageUrl = async (selectedImage: string | undefined): Promise<string | undefined> => {
|
|
44
|
+
if (!selectedImage || selectedImage === CLEAR_IMAGE_SENTINEL) {
|
|
45
|
+
return selectedImage;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (selectedImage.startsWith('/')) {
|
|
49
|
+
return new URL(selectedImage, window.location.origin).toString();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (selectedImage.startsWith('data:')) {
|
|
53
|
+
return selectedImage;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (selectedImage.startsWith('blob:')) {
|
|
57
|
+
const imageResponse = await fetch(selectedImage);
|
|
58
|
+
if (!imageResponse.ok) {
|
|
59
|
+
throw new Error('Failed to load selected image for PDF generation');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const imageBlob = await imageResponse.blob();
|
|
63
|
+
return await blobToDataUrl(imageBlob);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return selectedImage;
|
|
67
|
+
};
|
|
68
|
+
|
|
24
69
|
export const generatePDF = async ({
|
|
25
70
|
user,
|
|
26
71
|
selectedImage,
|
|
72
|
+
sourceImageId,
|
|
27
73
|
selectedFilename,
|
|
28
74
|
userCompany,
|
|
29
75
|
userFirstName,
|
|
@@ -61,8 +107,10 @@ export const generatePDF = async ({
|
|
|
61
107
|
notesUpdatedFormatted = `${(updatedDate.getMonth() + 1).toString().padStart(2, '0')}/${updatedDate.getDate().toString().padStart(2, '0')}/${updatedDate.getFullYear()}`;
|
|
62
108
|
}
|
|
63
109
|
|
|
110
|
+
const resolvedImageUrl = await resolvePdfImageUrl(selectedImage);
|
|
111
|
+
|
|
64
112
|
const pdfData = {
|
|
65
|
-
imageUrl:
|
|
113
|
+
imageUrl: resolvedImageUrl,
|
|
66
114
|
filename: selectedFilename,
|
|
67
115
|
userCompany: userCompany,
|
|
68
116
|
firstName: userFirstName,
|
|
@@ -145,7 +193,7 @@ export const generatePDF = async ({
|
|
|
145
193
|
processingTime,
|
|
146
194
|
blob.size,
|
|
147
195
|
[],
|
|
148
|
-
|
|
196
|
+
sourceImageId, // Source file ID
|
|
149
197
|
selectedFilename // Source original filename
|
|
150
198
|
);
|
|
151
199
|
} catch (auditError) {
|
|
@@ -173,7 +221,7 @@ export const generatePDF = async ({
|
|
|
173
221
|
processingTime,
|
|
174
222
|
0, // No file size for failed generation
|
|
175
223
|
[errorText || 'PDF generation failed'],
|
|
176
|
-
|
|
224
|
+
sourceImageId, // Source file ID
|
|
177
225
|
selectedFilename // Source original filename
|
|
178
226
|
);
|
|
179
227
|
} catch (auditError) {
|
|
@@ -200,7 +248,7 @@ export const generatePDF = async ({
|
|
|
200
248
|
processingTime,
|
|
201
249
|
0, // No file size for failed generation
|
|
202
250
|
[error instanceof Error ? error.message : 'Unknown error generating PDF'],
|
|
203
|
-
|
|
251
|
+
sourceImageId, // Source file ID
|
|
204
252
|
selectedFilename // Source original filename
|
|
205
253
|
);
|
|
206
254
|
} catch (auditError) {
|
|
@@ -1,10 +1,7 @@
|
|
|
1
1
|
import type { User } from 'firebase/auth';
|
|
2
|
-
import {
|
|
3
|
-
getAccountHash
|
|
4
|
-
} from '~/utils/auth';
|
|
5
|
-
import { fetchImageApi, uploadImageApi } from '~/utils/api';
|
|
2
|
+
import { createSignedImageUrlApi, fetchImageApi, uploadImageApi } from '~/utils/api';
|
|
6
3
|
import { canUploadFile, getCaseData, updateCaseData, deleteFileAnnotations } from '~/utils/data';
|
|
7
|
-
import type { CaseData, FileData, ImageUploadResponse } from '~/types';
|
|
4
|
+
import type { CaseData, FileData, ImageAccessResult, ImageUploadResponse } from '~/types';
|
|
8
5
|
import { auditService } from '~/services/audit';
|
|
9
6
|
|
|
10
7
|
export interface DeleteFileResult {
|
|
@@ -258,88 +255,91 @@ export const deleteFile = async (
|
|
|
258
255
|
}
|
|
259
256
|
};
|
|
260
257
|
|
|
261
|
-
const
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
const accountHash = await getAccountHash();
|
|
268
|
-
return { accountHash };
|
|
269
|
-
};
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
export const getImageUrl = async (user: User, fileData: FileData, caseNumber: string, accessReason?: string): Promise<string> => {
|
|
258
|
+
export const getImageUrl = async (
|
|
259
|
+
user: User,
|
|
260
|
+
fileData: FileData,
|
|
261
|
+
caseNumber: string,
|
|
262
|
+
accessReason?: string
|
|
263
|
+
): Promise<ImageAccessResult> => {
|
|
273
264
|
const startTime = Date.now();
|
|
274
265
|
const defaultAccessReason = accessReason || 'Image viewer access';
|
|
275
|
-
|
|
266
|
+
|
|
276
267
|
try {
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
const encodedImageDeliveryUrl = encodeURIComponent(imageDeliveryUrl);
|
|
268
|
+
try {
|
|
269
|
+
const signedUrlResponse = await createSignedImageUrlApi(user, fileData.id);
|
|
280
270
|
|
|
281
|
-
const workerResponse = await fetchImageApi(user, `/${encodedImageDeliveryUrl}`, {
|
|
282
|
-
method: 'GET',
|
|
283
|
-
headers: {
|
|
284
|
-
'Accept': 'text/plain'
|
|
285
|
-
}
|
|
286
|
-
});
|
|
287
|
-
|
|
288
|
-
if (!workerResponse.ok) {
|
|
289
|
-
// Log failed image access
|
|
290
271
|
await auditService.logFileAccess(
|
|
291
272
|
user,
|
|
292
273
|
fileData.originalFilename || fileData.id,
|
|
293
274
|
fileData.id,
|
|
294
275
|
'signed-url',
|
|
295
276
|
caseNumber,
|
|
296
|
-
'
|
|
277
|
+
'success',
|
|
297
278
|
Date.now() - startTime,
|
|
298
|
-
|
|
279
|
+
defaultAccessReason,
|
|
299
280
|
fileData.originalFilename
|
|
300
281
|
);
|
|
301
|
-
|
|
282
|
+
|
|
283
|
+
return {
|
|
284
|
+
url: signedUrlResponse.result.url,
|
|
285
|
+
revoke: () => {},
|
|
286
|
+
urlType: 'signed',
|
|
287
|
+
expiresAt: signedUrlResponse.result.expiresAt
|
|
288
|
+
};
|
|
289
|
+
} catch {
|
|
290
|
+
// Fallback to direct blob retrieval during migration.
|
|
302
291
|
}
|
|
303
|
-
|
|
304
|
-
const
|
|
305
|
-
|
|
306
|
-
|
|
292
|
+
|
|
293
|
+
const workerResponse = await fetchImageApi(user, `/${encodeURIComponent(fileData.id)}`, {
|
|
294
|
+
method: 'GET',
|
|
295
|
+
headers: {
|
|
296
|
+
'Accept': 'application/octet-stream,image/*'
|
|
297
|
+
}
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
if (!workerResponse.ok) {
|
|
307
301
|
await auditService.logFileAccess(
|
|
308
302
|
user,
|
|
309
303
|
fileData.originalFilename || fileData.id,
|
|
310
304
|
fileData.id,
|
|
311
|
-
'
|
|
305
|
+
'direct-url',
|
|
312
306
|
caseNumber,
|
|
313
307
|
'failure',
|
|
314
308
|
Date.now() - startTime,
|
|
315
|
-
'
|
|
309
|
+
'Image retrieval failed',
|
|
316
310
|
fileData.originalFilename
|
|
317
311
|
);
|
|
318
|
-
throw new Error('
|
|
312
|
+
throw new Error('Failed to retrieve image');
|
|
319
313
|
}
|
|
320
|
-
|
|
321
|
-
|
|
314
|
+
|
|
315
|
+
const blob = await workerResponse.blob();
|
|
316
|
+
const objectUrl = URL.createObjectURL(blob);
|
|
317
|
+
|
|
322
318
|
await auditService.logFileAccess(
|
|
323
319
|
user,
|
|
324
320
|
fileData.originalFilename || fileData.id,
|
|
325
321
|
fileData.id,
|
|
326
|
-
'
|
|
322
|
+
'direct-url',
|
|
327
323
|
caseNumber,
|
|
328
324
|
'success',
|
|
329
325
|
Date.now() - startTime,
|
|
330
326
|
defaultAccessReason,
|
|
331
327
|
fileData.originalFilename
|
|
332
328
|
);
|
|
333
|
-
|
|
334
|
-
return
|
|
329
|
+
|
|
330
|
+
return {
|
|
331
|
+
blob,
|
|
332
|
+
url: objectUrl,
|
|
333
|
+
revoke: () => URL.revokeObjectURL(objectUrl),
|
|
334
|
+
urlType: 'blob'
|
|
335
|
+
};
|
|
335
336
|
} catch (error) {
|
|
336
|
-
|
|
337
|
-
if (!(error instanceof Error && error.message.includes('Failed to get signed image URL'))) {
|
|
337
|
+
if (!(error instanceof Error && error.message.includes('Failed to retrieve image'))) {
|
|
338
338
|
await auditService.logFileAccess(
|
|
339
339
|
user,
|
|
340
340
|
fileData.originalFilename || fileData.id,
|
|
341
341
|
fileData.id,
|
|
342
|
-
'
|
|
342
|
+
'direct-url',
|
|
343
343
|
caseNumber,
|
|
344
344
|
'failure',
|
|
345
345
|
Date.now() - startTime,
|
|
@@ -26,6 +26,7 @@ interface UseStriaeResetHelpersProps {
|
|
|
26
26
|
setShowNotes: Dispatch<SetStateAction<boolean>>;
|
|
27
27
|
setIsAuditTrailOpen: Dispatch<SetStateAction<boolean>>;
|
|
28
28
|
setIsRenameCaseModalOpen: Dispatch<SetStateAction<boolean>>;
|
|
29
|
+
onRevokeImage?: () => void;
|
|
29
30
|
}
|
|
30
31
|
|
|
31
32
|
export const useStriaeResetHelpers = ({
|
|
@@ -45,8 +46,10 @@ export const useStriaeResetHelpers = ({
|
|
|
45
46
|
setShowNotes,
|
|
46
47
|
setIsAuditTrailOpen,
|
|
47
48
|
setIsRenameCaseModalOpen,
|
|
49
|
+
onRevokeImage,
|
|
48
50
|
}: UseStriaeResetHelpersProps) => {
|
|
49
51
|
const clearSelectedImageState = useCallback(() => {
|
|
52
|
+
onRevokeImage?.();
|
|
50
53
|
setSelectedImage('/clear.jpg');
|
|
51
54
|
setSelectedFilename(undefined);
|
|
52
55
|
setImageId(undefined);
|
|
@@ -54,6 +57,7 @@ export const useStriaeResetHelpers = ({
|
|
|
54
57
|
setError(undefined);
|
|
55
58
|
setImageLoaded(false);
|
|
56
59
|
}, [
|
|
60
|
+
onRevokeImage,
|
|
57
61
|
setSelectedImage,
|
|
58
62
|
setSelectedFilename,
|
|
59
63
|
setImageId,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { User } from 'firebase/auth';
|
|
2
|
-
import { useState, useEffect } from 'react';
|
|
2
|
+
import { useState, useEffect, useRef, useCallback } from 'react';
|
|
3
3
|
import { SidebarContainer } from '~/components/sidebar/sidebar-container';
|
|
4
4
|
import { Navbar } from '~/components/navbar/navbar';
|
|
5
5
|
import { RenameCaseModal } from '~/components/navbar/case-modals/rename-case-modal';
|
|
@@ -47,6 +47,7 @@ export const Striae = ({ user }: StriaePage) => {
|
|
|
47
47
|
const [imageId, setImageId] = useState<string>();
|
|
48
48
|
const [error, setError] = useState<string>();
|
|
49
49
|
const [imageLoaded, setImageLoaded] = useState(false);
|
|
50
|
+
const currentRevokeRef = useRef<(() => void) | null>(null);
|
|
50
51
|
|
|
51
52
|
// User states
|
|
52
53
|
const [userCompany, setUserCompany] = useState<string>('');
|
|
@@ -98,6 +99,11 @@ export const Striae = ({ user }: StriaePage) => {
|
|
|
98
99
|
archiveReason?: string;
|
|
99
100
|
}>({ archived: false });
|
|
100
101
|
|
|
102
|
+
const handleRevokeImage = useCallback(() => {
|
|
103
|
+
currentRevokeRef.current?.();
|
|
104
|
+
currentRevokeRef.current = null;
|
|
105
|
+
}, []);
|
|
106
|
+
|
|
101
107
|
const {
|
|
102
108
|
clearSelectedImageState,
|
|
103
109
|
clearCaseContextState,
|
|
@@ -119,6 +125,7 @@ export const Striae = ({ user }: StriaePage) => {
|
|
|
119
125
|
setShowNotes,
|
|
120
126
|
setIsAuditTrailOpen,
|
|
121
127
|
setIsRenameCaseModalOpen,
|
|
128
|
+
onRevokeImage: handleRevokeImage,
|
|
122
129
|
});
|
|
123
130
|
|
|
124
131
|
|
|
@@ -234,6 +241,7 @@ export const Striae = ({ user }: StriaePage) => {
|
|
|
234
241
|
await generatePDF({
|
|
235
242
|
user,
|
|
236
243
|
selectedImage,
|
|
244
|
+
sourceImageId: imageId,
|
|
237
245
|
selectedFilename,
|
|
238
246
|
userCompany,
|
|
239
247
|
userFirstName,
|
|
@@ -574,6 +582,8 @@ export const Striae = ({ user }: StriaePage) => {
|
|
|
574
582
|
useEffect(() => {
|
|
575
583
|
// Cleanup function to clear image when component unmounts
|
|
576
584
|
return () => {
|
|
585
|
+
currentRevokeRef.current?.();
|
|
586
|
+
currentRevokeRef.current = null;
|
|
577
587
|
setSelectedImage(undefined);
|
|
578
588
|
setSelectedFilename(undefined);
|
|
579
589
|
setError(undefined);
|
|
@@ -645,14 +655,16 @@ export const Striae = ({ user }: StriaePage) => {
|
|
|
645
655
|
|
|
646
656
|
try {
|
|
647
657
|
setError(undefined);
|
|
658
|
+
currentRevokeRef.current?.();
|
|
659
|
+
currentRevokeRef.current = null;
|
|
648
660
|
setSelectedImage(undefined);
|
|
649
661
|
setSelectedFilename(undefined);
|
|
650
662
|
setImageLoaded(false);
|
|
651
663
|
|
|
652
|
-
const
|
|
653
|
-
|
|
664
|
+
const { url, revoke } = await getImageUrl(user, file, currentCase);
|
|
665
|
+
currentRevokeRef.current = revoke;
|
|
654
666
|
|
|
655
|
-
setSelectedImage(
|
|
667
|
+
setSelectedImage(url);
|
|
656
668
|
setSelectedFilename(file.originalFilename);
|
|
657
669
|
setImageId(file.id);
|
|
658
670
|
setImageLoaded(true);
|
package/app/types/file.ts
CHANGED
|
@@ -12,8 +12,6 @@ export interface FileUploadResponse {
|
|
|
12
12
|
id: string;
|
|
13
13
|
filename: string;
|
|
14
14
|
uploaded: string;
|
|
15
|
-
requireSignedURLs: boolean;
|
|
16
|
-
variants: string[];
|
|
17
15
|
};
|
|
18
16
|
errors: Array<{
|
|
19
17
|
code: number;
|
|
@@ -27,4 +25,22 @@ export interface ImageUploadResponse {
|
|
|
27
25
|
result: FileUploadResponse['result'];
|
|
28
26
|
errors: FileUploadResponse['errors'];
|
|
29
27
|
messages: FileUploadResponse['messages'];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface SignedImageUrlResponse {
|
|
31
|
+
success: boolean;
|
|
32
|
+
result: {
|
|
33
|
+
fileId: string;
|
|
34
|
+
url: string;
|
|
35
|
+
expiresAt: string;
|
|
36
|
+
expiresInSeconds: number;
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface ImageAccessResult {
|
|
41
|
+
url: string;
|
|
42
|
+
revoke: () => void;
|
|
43
|
+
blob?: Blob;
|
|
44
|
+
urlType: 'signed' | 'blob';
|
|
45
|
+
expiresAt?: string;
|
|
30
46
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { User } from 'firebase/auth';
|
|
2
|
-
import { type ImageUploadResponse } from '~/types';
|
|
2
|
+
import { type ImageUploadResponse, type SignedImageUrlResponse } from '~/types';
|
|
3
3
|
|
|
4
4
|
const IMAGE_API_BASE = '/api/image';
|
|
5
5
|
|
|
@@ -93,6 +93,54 @@ function parseUploadResponse(payload: string): ImageUploadResponse {
|
|
|
93
93
|
return parsed;
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
+
function parseSignedUrlResponse(payload: string): SignedImageUrlResponse {
|
|
97
|
+
const parsed = JSON.parse(payload) as SignedImageUrlResponse;
|
|
98
|
+
if (!parsed.success || !parsed.result?.url || !parsed.result?.fileId || !parsed.result?.expiresAt) {
|
|
99
|
+
throw new Error('Signed URL response is invalid');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return parsed;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export async function createSignedImageUrlApi(
|
|
106
|
+
user: User,
|
|
107
|
+
fileId: string,
|
|
108
|
+
expiresInSeconds?: number
|
|
109
|
+
): Promise<SignedImageUrlResponse> {
|
|
110
|
+
const response = await fetchImageApi(user, `/${encodeURIComponent(fileId)}/signed-url`, {
|
|
111
|
+
method: 'POST',
|
|
112
|
+
headers: {
|
|
113
|
+
'Content-Type': 'application/json',
|
|
114
|
+
'Accept': 'application/json'
|
|
115
|
+
},
|
|
116
|
+
body: JSON.stringify(
|
|
117
|
+
typeof expiresInSeconds === 'number'
|
|
118
|
+
? { expiresInSeconds }
|
|
119
|
+
: {}
|
|
120
|
+
)
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
if (!response.ok) {
|
|
124
|
+
throw new Error(`Signed URL request failed with status ${response.status}`);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const parsed = parseSignedUrlResponse(await response.text());
|
|
128
|
+
const rawUrl = parsed.result.url;
|
|
129
|
+
let normalizedUrl = rawUrl;
|
|
130
|
+
|
|
131
|
+
if (rawUrl.startsWith('/')) {
|
|
132
|
+
normalizedUrl = new URL(rawUrl, window.location.origin).toString();
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
...parsed,
|
|
137
|
+
result: {
|
|
138
|
+
...parsed.result,
|
|
139
|
+
url: normalizedUrl
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
96
144
|
export async function uploadImageApi(
|
|
97
145
|
user: User,
|
|
98
146
|
file: File,
|
|
@@ -43,7 +43,19 @@ export const getCaseData = async (
|
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
if (!response.ok) {
|
|
46
|
-
|
|
46
|
+
let errorDetails = '';
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
const errorPayload = await response.json() as { error?: unknown };
|
|
50
|
+
if (typeof errorPayload?.error === 'string' && errorPayload.error.trim().length > 0) {
|
|
51
|
+
errorDetails = errorPayload.error.trim();
|
|
52
|
+
}
|
|
53
|
+
} catch {
|
|
54
|
+
// Ignore parse errors and fall back to status text only.
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const baseMessage = `Failed to fetch case data: ${response.status} ${response.statusText}`;
|
|
58
|
+
throw new Error(errorDetails ? `${baseMessage} - ${errorDetails}` : baseMessage);
|
|
47
59
|
}
|
|
48
60
|
|
|
49
61
|
const caseData = await response.json() as CaseData;
|
|
@@ -86,7 +86,19 @@ export const getConfirmationSummaryDocument = async (
|
|
|
86
86
|
});
|
|
87
87
|
|
|
88
88
|
if (!response.ok) {
|
|
89
|
-
|
|
89
|
+
let errorDetails = '';
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
const errorPayload = await response.json() as { error?: unknown };
|
|
93
|
+
if (typeof errorPayload?.error === 'string' && errorPayload.error.trim().length > 0) {
|
|
94
|
+
errorDetails = errorPayload.error.trim();
|
|
95
|
+
}
|
|
96
|
+
} catch {
|
|
97
|
+
// Ignore parse errors and fall back to status text only.
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const baseMessage = `Failed to fetch confirmation summary: ${response.status} ${response.statusText}`;
|
|
101
|
+
throw new Error(errorDetails ? `${baseMessage} - ${errorDetails}` : baseMessage);
|
|
90
102
|
}
|
|
91
103
|
|
|
92
104
|
const payload = await response.json().catch(() => null) as unknown;
|
|
@@ -299,3 +311,28 @@ export const removeCaseConfirmationSummary = async (
|
|
|
299
311
|
|
|
300
312
|
await saveConfirmationSummaryDocument(user, summary);
|
|
301
313
|
};
|
|
314
|
+
|
|
315
|
+
export const moveCaseConfirmationSummary = async (
|
|
316
|
+
user: User,
|
|
317
|
+
fromCaseNumber: string,
|
|
318
|
+
toCaseNumber: string
|
|
319
|
+
): Promise<void> => {
|
|
320
|
+
if (fromCaseNumber === toCaseNumber) {
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const summary = await getConfirmationSummaryDocument(user);
|
|
325
|
+
const existingCaseSummary = summary.cases[fromCaseNumber];
|
|
326
|
+
if (!existingCaseSummary) {
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
delete summary.cases[fromCaseNumber];
|
|
331
|
+
summary.cases[toCaseNumber] = {
|
|
332
|
+
...existingCaseSummary,
|
|
333
|
+
updatedAt: getIsoNow(),
|
|
334
|
+
};
|
|
335
|
+
summary.updatedAt = getIsoNow();
|
|
336
|
+
|
|
337
|
+
await saveConfirmationSummaryDocument(user, summary);
|
|
338
|
+
};
|
|
@@ -42,7 +42,19 @@ export const getFileAnnotations = async (
|
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
if (!response.ok) {
|
|
45
|
-
|
|
45
|
+
let errorDetails = '';
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
const errorPayload = await response.json() as { error?: unknown };
|
|
49
|
+
if (typeof errorPayload?.error === 'string' && errorPayload.error.trim().length > 0) {
|
|
50
|
+
errorDetails = errorPayload.error.trim();
|
|
51
|
+
}
|
|
52
|
+
} catch {
|
|
53
|
+
// Ignore parse errors and fall back to status text only.
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const baseMessage = `Failed to fetch file annotations: ${response.status} ${response.statusText}`;
|
|
57
|
+
throw new Error(errorDetails ? `${baseMessage} - ${errorDetails}` : baseMessage);
|
|
46
58
|
}
|
|
47
59
|
|
|
48
60
|
return await response.json() as AnnotationData;
|
|
@@ -82,12 +82,13 @@ export const onRequest = async ({ request, env }: ImageProxyContext): Promise<Re
|
|
|
82
82
|
});
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
+
const requestUrl = new URL(request.url);
|
|
86
|
+
|
|
85
87
|
const identity = await verifyFirebaseIdentityFromRequest(request, env);
|
|
86
88
|
if (!identity) {
|
|
87
89
|
return textResponse('Unauthorized', 401);
|
|
88
90
|
}
|
|
89
91
|
|
|
90
|
-
const requestUrl = new URL(request.url);
|
|
91
92
|
const proxyPathResult = extractProxyPath(requestUrl);
|
|
92
93
|
if (!proxyPathResult.ok) {
|
|
93
94
|
return proxyPathResult.reason === 'bad-encoding'
|