@striae-org/striae 3.2.2 → 4.0.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 (82) hide show
  1. package/.env.example +1 -1
  2. package/app/components/actions/case-export/core-export.ts +5 -2
  3. package/app/components/actions/case-export/download-handlers.ts +51 -3
  4. package/app/components/actions/case-import/confirmation-import.ts +65 -40
  5. package/app/components/actions/case-import/confirmation-package.ts +86 -0
  6. package/app/components/actions/case-import/image-operations.ts +20 -49
  7. package/app/components/actions/case-import/index.ts +1 -0
  8. package/app/components/actions/case-import/orchestrator.ts +13 -3
  9. package/app/components/actions/case-import/storage-operations.ts +54 -89
  10. package/app/components/actions/case-import/validation.ts +7 -111
  11. package/app/components/actions/case-import/zip-processing.ts +44 -2
  12. package/app/components/actions/case-manage.ts +15 -27
  13. package/app/components/actions/confirm-export.ts +44 -13
  14. package/app/components/actions/generate-pdf.ts +3 -7
  15. package/app/components/actions/image-manage.ts +63 -129
  16. package/app/components/button/button.module.css +12 -8
  17. package/app/components/form/form-button.tsx +1 -1
  18. package/app/components/form/form.module.css +9 -0
  19. package/app/components/public-signing-key-modal/public-signing-key-modal.module.css +163 -49
  20. package/app/components/public-signing-key-modal/public-signing-key-modal.tsx +365 -88
  21. package/app/components/sidebar/case-export/case-export.tsx +13 -60
  22. package/app/components/sidebar/case-import/case-import.tsx +18 -6
  23. package/app/components/sidebar/case-import/hooks/useFilePreview.ts +6 -4
  24. package/app/components/sidebar/case-import/utils/file-validation.ts +57 -2
  25. package/app/components/sidebar/cases/case-sidebar.tsx +122 -52
  26. package/app/components/sidebar/cases/cases.module.css +101 -18
  27. package/app/components/sidebar/notes/notes.module.css +33 -13
  28. package/app/components/sidebar/sidebar.module.css +0 -2
  29. package/app/components/user/delete-account.tsx +7 -7
  30. package/app/components/user/manage-profile.tsx +1 -1
  31. package/app/components/user/mfa-phone-update.tsx +15 -12
  32. package/app/config-example/config.json +2 -8
  33. package/app/hooks/useInactivityTimeout.ts +2 -5
  34. package/app/root.tsx +96 -65
  35. package/app/routes/auth/login.tsx +132 -11
  36. package/app/routes/auth/route.ts +4 -3
  37. package/app/routes/striae/striae.tsx +4 -8
  38. package/app/services/audit/audit-api-client.ts +40 -0
  39. package/app/services/audit/audit-worker-client.ts +14 -17
  40. package/app/styles/root.module.css +13 -101
  41. package/app/tailwind.css +9 -2
  42. package/app/utils/SHA256.ts +5 -1
  43. package/app/utils/auth.ts +5 -32
  44. package/app/utils/confirmation-signature.ts +5 -1
  45. package/app/utils/data-api-client.ts +43 -0
  46. package/app/utils/data-operations.ts +59 -75
  47. package/app/utils/export-verification.ts +353 -0
  48. package/app/utils/image-api-client.ts +130 -0
  49. package/app/utils/pdf-api-client.ts +43 -0
  50. package/app/utils/permissions.ts +10 -23
  51. package/app/utils/signature-utils.ts +74 -4
  52. package/app/utils/user-api-client.ts +90 -0
  53. package/functions/api/_shared/firebase-auth.ts +255 -0
  54. package/functions/api/audit/[[path]].ts +150 -0
  55. package/functions/api/data/[[path]].ts +141 -0
  56. package/functions/api/image/[[path]].ts +127 -0
  57. package/functions/api/pdf/[[path]].ts +110 -0
  58. package/functions/api/user/[[path]].ts +196 -0
  59. package/package.json +8 -4
  60. package/public/favicon.ico +0 -0
  61. package/public/icon-256.png +0 -0
  62. package/public/icon-512.png +0 -0
  63. package/public/manifest.json +39 -0
  64. package/public/shortcut.png +0 -0
  65. package/public/social-image.png +0 -0
  66. package/react-router.config.ts +5 -0
  67. package/scripts/deploy-all.sh +22 -8
  68. package/scripts/deploy-config.sh +143 -148
  69. package/scripts/deploy-pages-secrets.sh +231 -0
  70. package/scripts/deploy-worker-secrets.sh +1 -1
  71. package/workers/audit-worker/wrangler.jsonc.example +1 -8
  72. package/workers/data-worker/wrangler.jsonc.example +1 -8
  73. package/workers/image-worker/wrangler.jsonc.example +1 -8
  74. package/workers/keys-worker/wrangler.jsonc.example +2 -9
  75. package/workers/pdf-worker/scripts/generate-assets.js +94 -0
  76. package/workers/pdf-worker/src/assets/icon-256.png +0 -0
  77. package/workers/pdf-worker/wrangler.jsonc.example +1 -8
  78. package/workers/user-worker/src/user-worker.example.ts +121 -41
  79. package/workers/user-worker/wrangler.jsonc.example +1 -8
  80. package/wrangler.toml.example +1 -1
  81. package/app/styles/legal-pages.module.css +0 -113
  82. package/public/favicon.svg +0 -9
package/.env.example CHANGED
@@ -64,7 +64,7 @@ DATA_BUCKET_NAME=your_data_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
67
- MANIFEST_SIGNING_KEY_ID=forensic-signing-key-v1
67
+ MANIFEST_SIGNING_KEY_ID=your_manifest_signing_key_id_here
68
68
  MANIFEST_SIGNING_PUBLIC_KEY=your_manifest_signing_public_key_here
69
69
 
70
70
  # ================================
@@ -183,7 +183,8 @@ export async function exportAllCases(
183
183
  export async function exportCaseData(
184
184
  user: User,
185
185
  caseNumber: string,
186
- options: ExportOptions = {}
186
+ options: ExportOptions = {},
187
+ onProgress?: (current: number, total: number, label: string) => void
187
188
  ): Promise<CaseExportData> {
188
189
  // NOTE: startTime and fileName tracking moved to download handlers
189
190
 
@@ -225,7 +226,8 @@ export async function exportCaseData(
225
226
  let earliestAnnotationDate: string | undefined;
226
227
  let latestAnnotationDate: string | undefined;
227
228
 
228
- for (const file of files) {
229
+ for (let fileIndex = 0; fileIndex < files.length; fileIndex++) {
230
+ const file = files[fileIndex];
229
231
  let annotations: AnnotationData | undefined;
230
232
  let hasAnnotations = false;
231
233
 
@@ -287,6 +289,7 @@ export async function exportCaseData(
287
289
  annotations,
288
290
  hasAnnotations
289
291
  });
292
+ onProgress?.(fileIndex + 1, files.length, `Loading file ${fileIndex + 1} of ${files.length}`);
290
293
  }
291
294
 
292
295
  // Build export data
@@ -4,6 +4,11 @@ import { type FileData, type AllCasesExportData, type CaseExportData, type Expor
4
4
  import { getImageUrl } from '../image-manage';
5
5
  import { generateForensicManifestSecure, calculateSHA256Secure } from '~/utils/SHA256';
6
6
  import { signForensicManifest } from '~/utils/data-operations';
7
+ import {
8
+ createPublicSigningKeyFileName,
9
+ getCurrentPublicSigningKeyDetails,
10
+ getVerificationPublicKey
11
+ } from '~/utils/signature-utils';
7
12
  import { type ExportFormat, formatDateForFilename, CSV_HEADERS } from './types-constants';
8
13
  import { protectExcelWorksheet, addForensicDataWarning } from './metadata-helpers';
9
14
  import { generateMetadataRows, generateCSVContent, processFileDataForTabular, sanitizeTabularMatrix } from './data-processing';
@@ -115,6 +120,30 @@ function generateExportFilename(originalFilename: string, id: string): string {
115
120
  return `${basename}-${id}${extension}`;
116
121
  }
117
122
 
123
+ function addPublicSigningKeyPemToZip(
124
+ zip: { file: (path: string, data: string) => unknown },
125
+ preferredKeyId?: string
126
+ ): string {
127
+ const preferredPublicKey =
128
+ typeof preferredKeyId === 'string' && preferredKeyId.trim().length > 0
129
+ ? getVerificationPublicKey(preferredKeyId)
130
+ : null;
131
+
132
+ const currentKey = getCurrentPublicSigningKeyDetails();
133
+ const keyId = preferredPublicKey ? preferredKeyId ?? null : currentKey.keyId;
134
+ const publicKeyPem = preferredPublicKey ?? currentKey.publicKeyPem;
135
+
136
+ if (!publicKeyPem || publicKeyPem.trim().length === 0) {
137
+ throw new Error('No public signing key is configured for ZIP export packaging.');
138
+ }
139
+
140
+ const publicKeyFileName = createPublicSigningKeyFileName(keyId);
141
+ const normalizedPem = publicKeyPem.endsWith('\n') ? publicKeyPem : `${publicKeyPem}\n`;
142
+ zip.file(publicKeyFileName, normalizedPem);
143
+
144
+ return publicKeyFileName;
145
+ }
146
+
118
147
  /**
119
148
  * Download all cases data as JSON file
120
149
  */
@@ -609,6 +638,7 @@ export async function downloadCaseAsZip(
609
638
  const startTime = Date.now();
610
639
  let manifestSignatureKeyId: string | undefined;
611
640
  let manifestSigned = false;
641
+ let publicKeyFileName: string | undefined;
612
642
 
613
643
  try {
614
644
  // Start audit workflow
@@ -672,6 +702,8 @@ export async function downloadCaseAsZip(
672
702
  manifestSignatureKeyId = signingResult.signature.keyId;
673
703
  manifestSigned = true;
674
704
 
705
+ publicKeyFileName = addPublicSigningKeyPemToZip(zip, signingResult.signature.keyId);
706
+
675
707
  const signedForensicManifest = {
676
708
  ...forensicManifest,
677
709
  manifestVersion: signingResult.manifestVersion,
@@ -696,6 +728,7 @@ Archive Contents:
696
728
  - ${caseNumber}_data.${format}: Complete case data in ${format.toUpperCase()} format
697
729
  - images/: Original image files with annotations
698
730
  - FORENSIC_MANIFEST.json: File integrity validation manifest
731
+ - ${publicKeyFileName}: Public signing key PEM for verification
699
732
  - README.txt: General information about this export
700
733
 
701
734
  Case Information:
@@ -713,7 +746,11 @@ For questions about this export, contact your Striae system administrator.
713
746
  zip.file('READ_ONLY_INSTRUCTIONS.txt', instructionContent);
714
747
 
715
748
  // Add README
716
- const readme = generateZipReadme(exportData, options.protectForensicData);
749
+ const readme = generateZipReadme(
750
+ exportData,
751
+ options.protectForensicData,
752
+ publicKeyFileName
753
+ );
717
754
  zip.file('README.txt', readme);
718
755
  onProgress?.(85);
719
756
 
@@ -772,8 +809,14 @@ For questions about this export, contact your Striae system administrator.
772
809
  return; // Exit early as we've handled the forensic case
773
810
  }
774
811
 
812
+ publicKeyFileName = addPublicSigningKeyPemToZip(zip);
813
+
775
814
  // Add README (standard or enhanced for forensic)
776
- const readme = generateZipReadme(exportData, options.protectForensicData);
815
+ const readme = generateZipReadme(
816
+ exportData,
817
+ options.protectForensicData,
818
+ publicKeyFileName
819
+ );
777
820
  zip.file('README.txt', readme);
778
821
  onProgress?.(85);
779
822
 
@@ -878,7 +921,11 @@ async function fetchImageAsBlob(user: User, fileData: FileData, caseNumber: stri
878
921
  /**
879
922
  * Generate README content for ZIP export with optional forensic protection
880
923
  */
881
- function generateZipReadme(exportData: CaseExportData, protectForensicData: boolean = true): string {
924
+ function generateZipReadme(
925
+ exportData: CaseExportData,
926
+ protectForensicData: boolean = true,
927
+ publicKeyFileName: string = createPublicSigningKeyFileName()
928
+ ): string {
882
929
  const totalFiles = exportData.files?.length || 0;
883
930
  const filesWithAnnotations = exportData.summary?.filesWithAnnotations || 0;
884
931
  const totalBoxAnnotations = exportData.summary?.totalBoxAnnotations || 0;
@@ -912,6 +959,7 @@ Summary:
912
959
  Contents:
913
960
  - ${exportData.metadata.caseNumber}_data.json/.csv: Case data and annotations
914
961
  - images/: Original uploaded images
962
+ - ${publicKeyFileName}: Public signing key PEM for verification
915
963
  - README.txt: This file`;
916
964
 
917
965
  const forensicAddition = `
@@ -1,13 +1,11 @@
1
1
  import type { User } from 'firebase/auth';
2
- import paths from '~/config/config.json';
3
- import { getDataApiKey } from '~/utils/auth';
2
+ import { fetchDataApi } from '~/utils/data-api-client';
4
3
  import { type ConfirmationImportResult, type ConfirmationImportData } from '~/types';
5
4
  import { checkExistingCase } from '../case-manage';
5
+ import { extractConfirmationImportPackage } from './confirmation-package';
6
6
  import { validateExporterUid, validateConfirmationHash, validateConfirmationSignatureFile } from './validation';
7
7
  import { auditService } from '~/services/audit';
8
8
 
9
- const DATA_WORKER_URL = paths.data_worker_url;
10
-
11
9
  interface CaseDataFile {
12
10
  id: string;
13
11
  originalFilename?: string;
@@ -36,6 +34,8 @@ export async function importConfirmationData(
36
34
  let signatureValid = false;
37
35
  let signaturePresent = false;
38
36
  let signatureKeyId: string | undefined;
37
+ let confirmationDataForAudit: ConfirmationImportData | null = null;
38
+ let confirmationJsonFileNameForAudit = confirmationFile.name;
39
39
 
40
40
  const result: ConfirmationImportResult = {
41
41
  success: false,
@@ -47,11 +47,17 @@ export async function importConfirmationData(
47
47
  };
48
48
 
49
49
  try {
50
- onProgress?.('Reading confirmation file', 10, 'Loading JSON data...');
50
+ onProgress?.('Reading confirmation file', 10, 'Loading confirmation package...');
51
+
52
+ const {
53
+ confirmationData,
54
+ confirmationJsonContent,
55
+ verificationPublicKeyPem,
56
+ confirmationFileName
57
+ } = await extractConfirmationImportPackage(confirmationFile);
51
58
 
52
- // Read and parse the JSON file
53
- const fileContent = await confirmationFile.text();
54
- const confirmationData: ConfirmationImportData = JSON.parse(fileContent);
59
+ confirmationDataForAudit = confirmationData;
60
+ confirmationJsonFileNameForAudit = confirmationFileName;
55
61
  result.caseNumber = confirmationData.metadata.caseNumber;
56
62
 
57
63
  // Start audit workflow
@@ -60,14 +66,17 @@ export async function importConfirmationData(
60
66
  onProgress?.('Validating hash', 20, 'Verifying data integrity...');
61
67
 
62
68
  // Validate hash
63
- hashValid = await validateConfirmationHash(fileContent, confirmationData.metadata.hash);
69
+ hashValid = await validateConfirmationHash(confirmationJsonContent, confirmationData.metadata.hash);
64
70
  if (!hashValid) {
65
71
  throw new Error('Confirmation data hash validation failed. The file may have been tampered with or corrupted.');
66
72
  }
67
73
 
68
74
  onProgress?.('Validating signature', 30, 'Verifying signed confirmation metadata...');
69
75
 
70
- const signatureResult = await validateConfirmationSignatureFile(confirmationData);
76
+ const signatureResult = await validateConfirmationSignatureFile(
77
+ confirmationData,
78
+ verificationPublicKeyPem
79
+ );
71
80
  signaturePresent = !!confirmationData.metadata.signature;
72
81
  signatureValid = signatureResult.isValid;
73
82
  signatureKeyId = signatureResult.keyId;
@@ -101,13 +110,13 @@ export async function importConfirmationData(
101
110
  onProgress?.('Processing confirmations', 60, 'Validating timestamps and updating annotations...');
102
111
 
103
112
  // Get case data to find image IDs
104
- const apiKey = await getDataApiKey();
105
- const caseResponse = await fetch(`${DATA_WORKER_URL}/${encodeURIComponent(user.uid)}/${encodeURIComponent(result.caseNumber)}/data.json`, {
106
- method: 'GET',
107
- headers: {
108
- 'X-Custom-Auth-Key': apiKey
113
+ const caseResponse = await fetchDataApi(
114
+ user,
115
+ `/${encodeURIComponent(user.uid)}/${encodeURIComponent(result.caseNumber)}/data.json`,
116
+ {
117
+ method: 'GET'
109
118
  }
110
- });
119
+ );
111
120
 
112
121
  if (!caseResponse.ok) {
113
122
  throw new Error(`Failed to fetch case data: ${caseResponse.status}`);
@@ -147,12 +156,13 @@ export async function importConfirmationData(
147
156
  const displayFilename = currentFile?.originalFilename || currentImageId;
148
157
 
149
158
  // Get current annotation data for this image
150
- const annotationResponse = await fetch(`${DATA_WORKER_URL}/${encodeURIComponent(user.uid)}/${encodeURIComponent(result.caseNumber)}/${encodeURIComponent(currentImageId)}/data.json`, {
151
- method: 'GET',
152
- headers: {
153
- 'X-Custom-Auth-Key': apiKey
159
+ const annotationResponse = await fetchDataApi(
160
+ user,
161
+ `/${encodeURIComponent(user.uid)}/${encodeURIComponent(result.caseNumber)}/${encodeURIComponent(currentImageId)}/data.json`,
162
+ {
163
+ method: 'GET'
154
164
  }
155
- });
165
+ );
156
166
 
157
167
  let annotationData: AnnotationImportData = {};
158
168
  if (annotationResponse.ok) {
@@ -204,14 +214,17 @@ export async function importConfirmationData(
204
214
  };
205
215
 
206
216
  // Save updated annotation data
207
- const saveResponse = await fetch(`${DATA_WORKER_URL}/${encodeURIComponent(user.uid)}/${encodeURIComponent(result.caseNumber)}/${encodeURIComponent(currentImageId)}/data.json`, {
208
- method: 'PUT',
209
- headers: {
210
- 'Content-Type': 'application/json',
211
- 'X-Custom-Auth-Key': apiKey
212
- },
213
- body: JSON.stringify(updatedAnnotationData)
214
- });
217
+ const saveResponse = await fetchDataApi(
218
+ user,
219
+ `/${encodeURIComponent(user.uid)}/${encodeURIComponent(result.caseNumber)}/${encodeURIComponent(currentImageId)}/data.json`,
220
+ {
221
+ method: 'PUT',
222
+ headers: {
223
+ 'Content-Type': 'application/json'
224
+ },
225
+ body: JSON.stringify(updatedAnnotationData)
226
+ }
227
+ );
215
228
 
216
229
  if (saveResponse.ok) {
217
230
  result.imagesUpdated++;
@@ -276,7 +289,7 @@ export async function importConfirmationData(
276
289
  await auditService.logConfirmationImport(
277
290
  user,
278
291
  result.caseNumber,
279
- confirmationFile.name,
292
+ confirmationJsonFileNameForAudit,
280
293
  result.success ? (result.errors && result.errors.length > 0 ? 'warning' : 'success') : 'failure',
281
294
  hashValid,
282
295
  result.confirmationsImported, // Successfully imported confirmations
@@ -318,19 +331,31 @@ export async function importConfirmationData(
318
331
  let signatureValidForAudit = signatureValid;
319
332
  let signatureKeyIdForAudit = signatureKeyId;
320
333
 
334
+ const auditConfirmationData = confirmationDataForAudit;
335
+
321
336
  // First, try to extract basic metadata for audit purposes (if file is parseable)
322
- try {
323
- const confirmationData = JSON.parse(await confirmationFile.text()) as ConfirmationImportData;
324
- reviewingExaminerUidForAudit = confirmationData.metadata?.exportedByUid;
325
- totalConfirmationsForAudit = confirmationData.metadata?.totalConfirmations || 0;
326
- if (confirmationData.metadata?.signature) {
337
+ if (auditConfirmationData) {
338
+ reviewingExaminerUidForAudit = auditConfirmationData.metadata?.exportedByUid;
339
+ totalConfirmationsForAudit = auditConfirmationData.metadata?.totalConfirmations || 0;
340
+ if (auditConfirmationData.metadata?.signature) {
327
341
  signaturePresentForAudit = true;
328
- signatureKeyIdForAudit = confirmationData.metadata.signature.keyId;
342
+ signatureKeyIdForAudit = auditConfirmationData.metadata.signature.keyId;
343
+ }
344
+ } else {
345
+ try {
346
+ const extracted = await extractConfirmationImportPackage(confirmationFile);
347
+ reviewingExaminerUidForAudit = extracted.confirmationData.metadata?.exportedByUid;
348
+ totalConfirmationsForAudit = extracted.confirmationData.metadata?.totalConfirmations || 0;
349
+ confirmationJsonFileNameForAudit = extracted.confirmationFileName;
350
+ if (extracted.confirmationData.metadata?.signature) {
351
+ signaturePresentForAudit = true;
352
+ signatureKeyIdForAudit = extracted.confirmationData.metadata.signature.keyId;
353
+ }
354
+ } catch {
355
+ // If we can't parse the file, keep undefined/default values
329
356
  }
330
- } catch {
331
- // If we can't parse the file, keep undefined/default values
332
357
  }
333
-
358
+
334
359
  const errorMessage = error instanceof Error ? error.message : 'Unknown error';
335
360
  if (errorMessage.includes('hash validation failed')) {
336
361
  // Hash failed - only flag file integrity, don't affect other validations
@@ -352,7 +377,7 @@ export async function importConfirmationData(
352
377
  await auditService.logConfirmationImport(
353
378
  user,
354
379
  result.caseNumber || 'unknown',
355
- confirmationFile.name,
380
+ confirmationJsonFileNameForAudit,
356
381
  'failure',
357
382
  hashValidForAudit,
358
383
  0, // No confirmations successfully imported for failures
@@ -0,0 +1,86 @@
1
+ import { type ConfirmationImportData } from '~/types';
2
+
3
+ const CONFIRMATION_EXPORT_FILE_REGEX = /^confirmation-data-.*\.json$/i;
4
+
5
+ export interface ConfirmationImportPackage {
6
+ confirmationData: ConfirmationImportData;
7
+ confirmationJsonContent: string;
8
+ verificationPublicKeyPem?: string;
9
+ confirmationFileName: string;
10
+ }
11
+
12
+ function getLeafFileName(path: string): string {
13
+ const segments = path.split('/').filter(Boolean);
14
+ return segments.length > 0 ? segments[segments.length - 1] : path;
15
+ }
16
+
17
+ function selectPreferredPemPath(pemPaths: string[]): string | undefined {
18
+ if (pemPaths.length === 0) {
19
+ return undefined;
20
+ }
21
+
22
+ const sortedPaths = [...pemPaths].sort((left, right) => left.localeCompare(right));
23
+ const preferred = sortedPaths.find((path) =>
24
+ /^striae-public-signing-key.*\.pem$/i.test(getLeafFileName(path))
25
+ );
26
+
27
+ return preferred ?? sortedPaths[0];
28
+ }
29
+
30
+ async function extractConfirmationPackageFromZip(file: File): Promise<ConfirmationImportPackage> {
31
+ const JSZip = (await import('jszip')).default;
32
+ const zip = await JSZip.loadAsync(file);
33
+ const fileEntries = Object.keys(zip.files).filter((path) => !zip.files[path].dir);
34
+
35
+ const confirmationPaths = fileEntries.filter((path) =>
36
+ CONFIRMATION_EXPORT_FILE_REGEX.test(getLeafFileName(path))
37
+ );
38
+
39
+ if (confirmationPaths.length !== 1) {
40
+ throw new Error('Confirmation ZIP must contain exactly one confirmation-data JSON file.');
41
+ }
42
+
43
+ const confirmationPath = confirmationPaths[0];
44
+ const confirmationJsonContent = await zip.file(confirmationPath)?.async('text');
45
+ if (!confirmationJsonContent) {
46
+ throw new Error('Failed to read confirmation JSON from ZIP package.');
47
+ }
48
+
49
+ const confirmationData = JSON.parse(confirmationJsonContent) as ConfirmationImportData;
50
+
51
+ const pemPaths = fileEntries.filter((path) => getLeafFileName(path).toLowerCase().endsWith('.pem'));
52
+ const preferredPemPath = selectPreferredPemPath(pemPaths);
53
+
54
+ let verificationPublicKeyPem: string | undefined;
55
+ if (preferredPemPath) {
56
+ verificationPublicKeyPem = await zip.file(preferredPemPath)?.async('text');
57
+ }
58
+
59
+ return {
60
+ confirmationData,
61
+ confirmationJsonContent,
62
+ verificationPublicKeyPem,
63
+ confirmationFileName: getLeafFileName(confirmationPath)
64
+ };
65
+ }
66
+
67
+ export async function extractConfirmationImportPackage(file: File): Promise<ConfirmationImportPackage> {
68
+ const lowerName = file.name.toLowerCase();
69
+
70
+ if (lowerName.endsWith('.json')) {
71
+ const confirmationJsonContent = await file.text();
72
+ const confirmationData = JSON.parse(confirmationJsonContent) as ConfirmationImportData;
73
+
74
+ return {
75
+ confirmationData,
76
+ confirmationJsonContent,
77
+ confirmationFileName: file.name
78
+ };
79
+ }
80
+
81
+ if (lowerName.endsWith('.zip')) {
82
+ return extractConfirmationPackageFromZip(file);
83
+ }
84
+
85
+ throw new Error('Unsupported confirmation import file type. Use a confirmation JSON or confirmation ZIP file.');
86
+ }
@@ -1,61 +1,32 @@
1
- import paths from '~/config/config.json';
2
- import { getImageApiKey } from '~/utils/auth';
3
- import { type FileData, type ImageUploadResponse } from '~/types';
4
-
5
- const IMAGE_WORKER_URL = paths.image_worker_url;
1
+ import type { User } from 'firebase/auth';
2
+ import { uploadImageApi } from '~/utils/image-api-client';
3
+ import { type FileData } from '~/types';
6
4
 
7
5
  /**
8
6
  * Upload image blob to image worker and get file data
9
7
  */
10
8
  export async function uploadImageBlob(
9
+ user: User,
11
10
  imageBlob: Blob,
12
11
  originalFilename: string,
13
12
  onProgress?: (filename: string, progress: number) => void
14
13
  ): Promise<FileData> {
15
- const imagesApiToken = await getImageApiKey();
16
-
17
- return new Promise((resolve, reject) => {
18
- const xhr = new XMLHttpRequest();
19
- const formData = new FormData();
20
-
21
- // Create a File object from the blob to preserve the filename
22
- const file = new File([imageBlob], originalFilename, { type: imageBlob.type });
23
- formData.append('file', file);
24
-
25
- xhr.upload.addEventListener('progress', (event) => {
26
- if (event.lengthComputable && onProgress) {
27
- const progress = Math.round((event.loaded / event.total) * 100);
28
- onProgress(originalFilename, progress);
29
- }
30
- });
31
-
32
- xhr.addEventListener('load', async () => {
33
- if (xhr.status === 200) {
34
- try {
35
- const imageData = JSON.parse(xhr.responseText) as ImageUploadResponse;
36
- if (!imageData.success) {
37
- throw new Error(`Upload failed: ${imageData.errors?.join(', ') || 'Unknown error'}`);
38
- }
39
-
40
- const fileData: FileData = {
41
- id: imageData.result.id,
42
- originalFilename: originalFilename,
43
- uploadedAt: new Date().toISOString()
44
- };
45
-
46
- resolve(fileData);
47
- } catch (error) {
48
- reject(error);
49
- }
50
- } else {
51
- reject(new Error(`Upload failed with status ${xhr.status}`));
52
- }
53
- });
14
+ // Create a File object from the blob to preserve the filename
15
+ const file = new File([imageBlob], originalFilename, { type: imageBlob.type });
16
+ const imageData = await uploadImageApi(user, file, (progress) => {
17
+ if (onProgress) {
18
+ onProgress(originalFilename, progress);
19
+ }
20
+ });
54
21
 
55
- xhr.addEventListener('error', () => reject(new Error('Upload failed')));
22
+ const uploadedImageId = imageData.result?.id;
23
+ if (!uploadedImageId) {
24
+ throw new Error('Upload failed: missing image identifier');
25
+ }
56
26
 
57
- xhr.open('POST', IMAGE_WORKER_URL);
58
- xhr.setRequestHeader('Authorization', `Bearer ${imagesApiToken}`);
59
- xhr.send(formData);
60
- });
27
+ return {
28
+ id: uploadedImageId,
29
+ originalFilename,
30
+ uploadedAt: new Date().toISOString()
31
+ };
61
32
  }
@@ -34,6 +34,7 @@ export { importAnnotations } from './annotation-import';
34
34
 
35
35
  // Confirmation import
36
36
  export { importConfirmationData } from './confirmation-import';
37
+ export { extractConfirmationImportPackage } from './confirmation-package';
37
38
 
38
39
  // Main orchestrator
39
40
  export { importCaseForReview } from './orchestrator';
@@ -137,7 +137,14 @@ export async function importCaseForReview(
137
137
  onProgress?.('Parsing ZIP file', 10, 'Extracting archive contents...');
138
138
 
139
139
  // Step 1: Parse ZIP file
140
- const { caseData, imageFiles, imageIdMapping, metadata, cleanedContent } = await parseImportZip(zipFile, user);
140
+ const {
141
+ caseData,
142
+ imageFiles,
143
+ imageIdMapping,
144
+ metadata,
145
+ cleanedContent,
146
+ verificationPublicKeyPem
147
+ } = await parseImportZip(zipFile, user);
141
148
  parsedForensicManifest = metadata?.forensicManifest as SignedForensicManifest | undefined;
142
149
  result.caseNumber = caseData.metadata.caseNumber;
143
150
  importState.caseNumber = result.caseNumber;
@@ -181,7 +188,10 @@ export async function importCaseForReview(
181
188
  );
182
189
  }
183
190
 
184
- const signatureResult = await verifyForensicManifestSignature(parsedForensicManifest);
191
+ const signatureResult = await verifyForensicManifestSignature(
192
+ parsedForensicManifest,
193
+ verificationPublicKeyPem
194
+ );
185
195
  signatureValidationPassed = signatureResult.isValid;
186
196
  signatureKeyId = signatureResult.keyId;
187
197
 
@@ -273,7 +283,7 @@ export async function importCaseForReview(
273
283
  const originalFileEntry = caseData.files.find(f => f.fileData.id === originalImageId);
274
284
  const originalFilename = originalFileEntry?.fileData.originalFilename || exportFilename;
275
285
 
276
- const fileData = await uploadImageBlob(blob, originalFilename, (fname, progress) => {
286
+ const fileData = await uploadImageBlob(user, blob, originalFilename, (fname, progress) => {
277
287
  const overallProgress = 30 + (uploadedCount / totalImages) * 40 + (progress / totalImages) * 0.4;
278
288
  onProgress?.('Uploading images', overallProgress, `Uploading ${fname}...`);
279
289
  });