@striae-org/striae 5.0.0 → 5.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/.env.example +5 -2
  2. package/app/components/actions/case-export/download-handlers.ts +6 -7
  3. package/app/components/actions/case-manage.ts +10 -11
  4. package/app/components/actions/generate-pdf.ts +43 -1
  5. package/app/components/actions/image-manage.ts +13 -45
  6. package/app/routes/striae/hooks/use-striae-reset-helpers.ts +4 -0
  7. package/app/routes/striae/striae.tsx +15 -4
  8. package/app/utils/data/operations/case-operations.ts +13 -1
  9. package/app/utils/data/operations/confirmation-summary-operations.ts +38 -1
  10. package/app/utils/data/operations/file-annotation-operations.ts +13 -1
  11. package/package.json +2 -2
  12. package/scripts/deploy-config.sh +149 -6
  13. package/scripts/deploy-pages-secrets.sh +0 -6
  14. package/scripts/deploy-worker-secrets.sh +66 -5
  15. package/scripts/encrypt-r2-backfill.mjs +376 -0
  16. package/worker-configuration.d.ts +13 -7
  17. package/workers/audit-worker/package.json +1 -4
  18. package/workers/audit-worker/src/audit-worker.example.ts +522 -61
  19. package/workers/audit-worker/wrangler.jsonc.example +5 -0
  20. package/workers/data-worker/package.json +1 -4
  21. package/workers/data-worker/src/data-worker.example.ts +280 -2
  22. package/workers/data-worker/src/encryption-utils.ts +145 -1
  23. package/workers/data-worker/wrangler.jsonc.example +4 -0
  24. package/workers/image-worker/package.json +1 -4
  25. package/workers/image-worker/src/encryption-utils.ts +217 -0
  26. package/workers/image-worker/src/image-worker.example.ts +196 -127
  27. package/workers/image-worker/wrangler.jsonc.example +7 -0
  28. package/workers/keys-worker/package.json +1 -4
  29. package/workers/pdf-worker/package.json +1 -4
  30. package/workers/user-worker/package.json +1 -4
package/.env.example CHANGED
@@ -61,6 +61,7 @@ KV_STORE_ID=your_kv_store_id_here
61
61
  # ================================
62
62
  DATA_WORKER_NAME=your_data_worker_name_here
63
63
  DATA_BUCKET_NAME=your_data_bucket_name_here
64
+ FILES_BUCKET_NAME=your_files_bucket_name_here
64
65
  DATA_WORKER_DOMAIN=your_data_worker_domain_here
65
66
  # Auto-generated by scripts/deploy-config.sh when placeholders are detected.
66
67
  MANIFEST_SIGNING_PRIVATE_KEY=your_manifest_signing_private_key_here
@@ -69,6 +70,10 @@ MANIFEST_SIGNING_PUBLIC_KEY=your_manifest_signing_public_key_here
69
70
  EXPORT_ENCRYPTION_PRIVATE_KEY=your_export_encryption_private_key_here
70
71
  EXPORT_ENCRYPTION_KEY_ID=your_export_encryption_key_id_here
71
72
  EXPORT_ENCRYPTION_PUBLIC_KEY=your_export_encryption_public_key_here
73
+ DATA_AT_REST_ENCRYPTION_ENABLED=true
74
+ DATA_AT_REST_ENCRYPTION_PRIVATE_KEY=your_data_at_rest_encryption_private_key_here
75
+ DATA_AT_REST_ENCRYPTION_KEY_ID=your_data_at_rest_encryption_key_id_here
76
+ DATA_AT_REST_ENCRYPTION_PUBLIC_KEY=your_data_at_rest_encryption_public_key_here
72
77
 
73
78
 
74
79
  # ================================
@@ -83,8 +88,6 @@ AUDIT_WORKER_DOMAIN=your_audit_worker_domain_here
83
88
  # ================================
84
89
  IMAGES_WORKER_NAME=your_images_worker_name_here
85
90
  IMAGES_WORKER_DOMAIN=your_images_worker_domain_here
86
- API_TOKEN=your_cloudflare_images_api_token_here
87
- HMAC_KEY=your_cloudflare_images_hmac_key_here
88
91
 
89
92
  # ================================
90
93
  # PDF WORKER ENVIRONMENT VARIABLES
@@ -965,13 +965,12 @@ 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 imageUrl = await getImageUrl(user, fileData, caseNumber, 'Export Package');
969
- if (!imageUrl) return null;
970
-
971
- const response = await fetch(imageUrl);
972
- if (!response.ok) return null;
973
-
974
- return await response.blob();
968
+ const { blob, revoke } = await getImageUrl(user, fileData, caseNumber, 'Export Package');
969
+ try {
970
+ return blob;
971
+ } finally {
972
+ revoke();
973
+ }
975
974
  } catch (error) {
976
975
  console.error('Failed to fetch image blob:', error);
977
976
  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) Delete old case number in user's KV entry
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,13 @@ 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 imageUrl = await getImageUrl(user, fileData, caseNumber, 'Archive Package');
685
-
686
- if (!imageUrl) {
687
- return null;
688
- }
688
+ const { blob, revoke } = await getImageUrl(user, fileData, caseNumber, 'Archive Package');
689
689
 
690
- const response = await fetch(imageUrl);
691
- if (!response.ok) {
692
- return null;
690
+ try {
691
+ return blob;
692
+ } finally {
693
+ revoke();
693
694
  }
694
-
695
- return await response.blob();
696
695
  } catch (error) {
697
696
  console.error('Failed to fetch image for archive package:', error);
698
697
  return null;
@@ -21,6 +21,46 @@ interface GeneratePDFParams {
21
21
  setToastDuration?: (duration: number) => void;
22
22
  }
23
23
 
24
+ const CLEAR_IMAGE_SENTINEL = '/clear.jpg';
25
+
26
+ const blobToDataUrl = async (blob: Blob): Promise<string> => {
27
+ return await new Promise<string>((resolve, reject) => {
28
+ const reader = new FileReader();
29
+ reader.onloadend = () => {
30
+ if (typeof reader.result === 'string') {
31
+ resolve(reader.result);
32
+ return;
33
+ }
34
+
35
+ reject(new Error('Failed to read image blob as data URL'));
36
+ };
37
+ reader.onerror = () => reject(new Error('Failed to convert image for PDF rendering'));
38
+ reader.readAsDataURL(blob);
39
+ });
40
+ };
41
+
42
+ const resolvePdfImageUrl = async (selectedImage: string | undefined): Promise<string | undefined> => {
43
+ if (!selectedImage || selectedImage === CLEAR_IMAGE_SENTINEL) {
44
+ return selectedImage;
45
+ }
46
+
47
+ if (selectedImage.startsWith('data:')) {
48
+ return selectedImage;
49
+ }
50
+
51
+ if (selectedImage.startsWith('blob:')) {
52
+ const imageResponse = await fetch(selectedImage);
53
+ if (!imageResponse.ok) {
54
+ throw new Error('Failed to load selected image for PDF generation');
55
+ }
56
+
57
+ const imageBlob = await imageResponse.blob();
58
+ return await blobToDataUrl(imageBlob);
59
+ }
60
+
61
+ return selectedImage;
62
+ };
63
+
24
64
  export const generatePDF = async ({
25
65
  user,
26
66
  selectedImage,
@@ -61,8 +101,10 @@ export const generatePDF = async ({
61
101
  notesUpdatedFormatted = `${(updatedDate.getMonth() + 1).toString().padStart(2, '0')}/${updatedDate.getDate().toString().padStart(2, '0')}/${updatedDate.getFullYear()}`;
62
102
  }
63
103
 
104
+ const resolvedImageUrl = await resolvePdfImageUrl(selectedImage);
105
+
64
106
  const pdfData = {
65
- imageUrl: selectedImage,
107
+ imageUrl: resolvedImageUrl,
66
108
  filename: selectedFilename,
67
109
  userCompany: userCompany,
68
110
  firstName: userFirstName,
@@ -1,7 +1,4 @@
1
1
  import type { User } from 'firebase/auth';
2
- import {
3
- getAccountHash
4
- } from '~/utils/auth';
5
2
  import { fetchImageApi, uploadImageApi } from '~/utils/api';
6
3
  import { canUploadFile, getCaseData, updateCaseData, deleteFileAnnotations } from '~/utils/data';
7
4
  import type { CaseData, FileData, ImageUploadResponse } from '~/types';
@@ -258,30 +255,15 @@ export const deleteFile = async (
258
255
  }
259
256
  };
260
257
 
261
- const DEFAULT_VARIANT = 'striae';
262
- interface ImageDeliveryConfig {
263
- accountHash: string;
264
- }
265
-
266
- const getImageConfig = async (): Promise<ImageDeliveryConfig> => {
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 (user: User, fileData: FileData, caseNumber: string, accessReason?: string): Promise<{ blob: Blob; url: string; revoke: () => void }> => {
273
259
  const startTime = Date.now();
274
260
  const defaultAccessReason = accessReason || 'Image viewer access';
275
261
 
276
262
  try {
277
- const { accountHash } = await getImageConfig();
278
- const imageDeliveryUrl = `https://imagedelivery.net/${accountHash}/${fileData.id}/${DEFAULT_VARIANT}`;
279
- const encodedImageDeliveryUrl = encodeURIComponent(imageDeliveryUrl);
280
-
281
- const workerResponse = await fetchImageApi(user, `/${encodedImageDeliveryUrl}`, {
263
+ const workerResponse = await fetchImageApi(user, `/${encodeURIComponent(fileData.id)}`, {
282
264
  method: 'GET',
283
265
  headers: {
284
- 'Accept': 'text/plain'
266
+ 'Accept': 'application/octet-stream,image/*'
285
267
  }
286
268
  });
287
269
 
@@ -291,39 +273,25 @@ export const getImageUrl = async (user: User, fileData: FileData, caseNumber: st
291
273
  user,
292
274
  fileData.originalFilename || fileData.id,
293
275
  fileData.id,
294
- 'signed-url',
295
- caseNumber,
296
- 'failure',
297
- Date.now() - startTime,
298
- 'Image URL generation failed',
299
- fileData.originalFilename
300
- );
301
- throw new Error('Failed to get signed image URL');
302
- }
303
-
304
- const signedUrl = await workerResponse.text();
305
- if (!signedUrl.includes('sig=') || !signedUrl.includes('exp=')) {
306
- // Log invalid URL response
307
- await auditService.logFileAccess(
308
- user,
309
- fileData.originalFilename || fileData.id,
310
- fileData.id,
311
- 'signed-url',
276
+ 'direct-url',
312
277
  caseNumber,
313
278
  'failure',
314
279
  Date.now() - startTime,
315
- 'Invalid signed URL returned',
280
+ 'Image retrieval failed',
316
281
  fileData.originalFilename
317
282
  );
318
- throw new Error('Invalid signed URL returned');
283
+ throw new Error('Failed to retrieve image');
319
284
  }
285
+
286
+ const blob = await workerResponse.blob();
287
+ const objectUrl = URL.createObjectURL(blob);
320
288
 
321
289
  // Log successful image access
322
290
  await auditService.logFileAccess(
323
291
  user,
324
292
  fileData.originalFilename || fileData.id,
325
293
  fileData.id,
326
- 'signed-url',
294
+ 'direct-url',
327
295
  caseNumber,
328
296
  'success',
329
297
  Date.now() - startTime,
@@ -331,15 +299,15 @@ export const getImageUrl = async (user: User, fileData: FileData, caseNumber: st
331
299
  fileData.originalFilename
332
300
  );
333
301
 
334
- return signedUrl;
302
+ return { blob, url: objectUrl, revoke: () => URL.revokeObjectURL(objectUrl) };
335
303
  } catch (error) {
336
304
  // Log any unexpected errors if not already logged
337
- if (!(error instanceof Error && error.message.includes('Failed to get signed image URL'))) {
305
+ if (!(error instanceof Error && error.message.includes('Failed to retrieve image'))) {
338
306
  await auditService.logFileAccess(
339
307
  user,
340
308
  fileData.originalFilename || fileData.id,
341
309
  fileData.id,
342
- 'signed-url',
310
+ 'direct-url',
343
311
  caseNumber,
344
312
  'failure',
345
313
  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
 
@@ -574,6 +581,8 @@ export const Striae = ({ user }: StriaePage) => {
574
581
  useEffect(() => {
575
582
  // Cleanup function to clear image when component unmounts
576
583
  return () => {
584
+ currentRevokeRef.current?.();
585
+ currentRevokeRef.current = null;
577
586
  setSelectedImage(undefined);
578
587
  setSelectedFilename(undefined);
579
588
  setError(undefined);
@@ -645,14 +654,16 @@ export const Striae = ({ user }: StriaePage) => {
645
654
 
646
655
  try {
647
656
  setError(undefined);
657
+ currentRevokeRef.current?.();
658
+ currentRevokeRef.current = null;
648
659
  setSelectedImage(undefined);
649
660
  setSelectedFilename(undefined);
650
661
  setImageLoaded(false);
651
662
 
652
- const signedUrl = await getImageUrl(user, file, currentCase);
653
- if (!signedUrl) throw new Error('No URL returned');
663
+ const { url, revoke } = await getImageUrl(user, file, currentCase);
664
+ currentRevokeRef.current = revoke;
654
665
 
655
- setSelectedImage(signedUrl);
666
+ setSelectedImage(url);
656
667
  setSelectedFilename(file.originalFilename);
657
668
  setImageId(file.id);
658
669
  setImageLoaded(true);
@@ -43,7 +43,19 @@ export const getCaseData = async (
43
43
  }
44
44
 
45
45
  if (!response.ok) {
46
- throw new Error(`Failed to fetch case data: ${response.status} ${response.statusText}`);
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
- throw new Error(`Failed to fetch confirmation summary: ${response.status} ${response.statusText}`);
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
- throw new Error(`Failed to fetch file annotations: ${response.status} ${response.statusText}`);
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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@striae-org/striae",
3
- "version": "5.0.0",
3
+ "version": "5.1.0",
4
4
  "private": false,
5
5
  "description": "Striae is a specialized, cloud-native platform designed to streamline forensic firearms identification by providing an intuitive environment for digital comparison image annotation, authenticated confirmations, and automated report generation.",
6
6
  "license": "Apache-2.0",
@@ -106,7 +106,7 @@
106
106
  "deploy-workers:image": "cd workers/image-worker && npm run deploy",
107
107
  "deploy-workers:keys": "cd workers/keys-worker && npm run deploy",
108
108
  "deploy-workers:pdf": "cd workers/pdf-worker && npm run deploy",
109
- "deploy-workers:user": "cd workers/user-worker && npm run deploy"
109
+ "deploy-workers:user": "cd workers/user-worker && npm run deploy"
110
110
  },
111
111
  "dependencies": {
112
112
  "@react-router/cloudflare": "^7.13.2",