@striae-org/striae 5.3.0 → 5.3.2

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 (31) hide show
  1. package/.env.example +3 -0
  2. package/app/components/actions/case-export/core-export.ts +3 -0
  3. package/app/components/actions/case-export/download-handlers.ts +1 -1
  4. package/app/components/actions/case-import/confirmation-import.ts +62 -22
  5. package/app/components/actions/case-import/confirmation-package.ts +68 -1
  6. package/app/components/actions/case-import/index.ts +1 -1
  7. package/app/components/actions/case-import/orchestrator.ts +78 -53
  8. package/app/components/actions/case-import/zip-processing.ts +157 -407
  9. package/app/components/actions/generate-pdf.ts +22 -0
  10. package/app/components/navbar/case-modals/export-case-modal.module.css +27 -0
  11. package/app/components/navbar/case-modals/export-case-modal.tsx +132 -0
  12. package/app/components/navbar/case-modals/export-confirmations-modal.module.css +24 -0
  13. package/app/components/navbar/case-modals/export-confirmations-modal.tsx +108 -0
  14. package/app/components/sidebar/case-import/components/CasePreviewSection.tsx +1 -9
  15. package/app/components/sidebar/case-import/components/ConfirmationPreviewSection.tsx +36 -5
  16. package/app/components/sidebar/case-import/hooks/useFilePreview.ts +5 -9
  17. package/app/components/sidebar/case-import/index.ts +1 -4
  18. package/app/routes/auth/login.tsx +22 -103
  19. package/app/routes/striae/striae.tsx +77 -13
  20. package/app/types/case.ts +1 -0
  21. package/app/types/export.ts +1 -0
  22. package/app/types/import.ts +10 -0
  23. package/functions/api/image/[[path]].ts +19 -3
  24. package/package.json +1 -1
  25. package/workers/audit-worker/wrangler.jsonc.example +1 -1
  26. package/workers/data-worker/wrangler.jsonc.example +1 -1
  27. package/workers/image-worker/src/image-worker.example.ts +36 -2
  28. package/workers/image-worker/wrangler.jsonc.example +1 -1
  29. package/workers/pdf-worker/wrangler.jsonc.example +1 -1
  30. package/workers/user-worker/wrangler.jsonc.example +1 -1
  31. package/wrangler.toml.example +1 -1
@@ -1,15 +1,8 @@
1
1
  import type { User } from 'firebase/auth';
2
2
  import { type CaseExportData, type CaseImportPreview } from '~/types';
3
- import { validateCaseNumber } from '../case-manage';
4
- import {
5
- type SignedForensicManifest,
6
- verifyCasePackageIntegrity
7
- } from '~/utils/forensics';
8
- import {
9
- isArchivedExportData,
10
- removeForensicWarning,
11
- validateCaseExporterUidForImport
12
- } from './validation';
3
+ import type { EncryptionManifest } from '~/utils/forensics/export-encryption';
4
+ import { decryptExportBatch } from '~/utils/data/operations/signing-operations';
5
+ import { isArchivedExportData } from './validation';
13
6
 
14
7
  function getLeafFileName(path: string): string {
15
8
  const segments = path.split('/').filter(Boolean);
@@ -103,80 +96,19 @@ function extractImageIdFromFilename(exportFilename: string): string | null {
103
96
  return filenameWithoutExt.substring(lastHyphenIndex + 1);
104
97
  }
105
98
 
106
- interface ReadmeCaseInfo {
107
- caseNumber: string | null;
108
- exportedBy: string | null;
109
- exportedByName: string | null;
110
- exportedByCompany: string | null;
111
- exportedByBadgeId: string | null;
112
- exportDate: string | null;
113
- caseCreatedDate: string | null;
114
- totalFiles: number;
115
- isArchived: boolean;
116
- }
117
-
118
- /**
119
- * Parse case metadata from a README.txt included in a case package ZIP.
120
- * Handles both standard export format and archived package format.
121
- * Falls back gracefully when fields are absent or README is missing.
122
- */
123
- function parseReadmeCaseInfo(readme: string | null, fallbackTotalFiles: number): ReadmeCaseInfo {
124
- const result: ReadmeCaseInfo = {
125
- caseNumber: null,
126
- exportedBy: null,
127
- exportedByName: null,
128
- exportedByCompany: null,
129
- exportedByBadgeId: null,
130
- exportDate: null,
131
- caseCreatedDate: null,
132
- totalFiles: fallbackTotalFiles,
133
- isArchived: false
134
- };
135
-
136
- if (!readme) return result;
137
-
138
- const isArchived = readme.trimStart().startsWith('Striae Archived Case Package');
139
- result.isArchived = isArchived;
140
-
141
- const field = (key: string): string | null => {
142
- const regex = new RegExp(`^${key}:\\s*(.+)$`, 'm');
143
- const match = readme.match(regex);
144
- if (!match) return null;
145
- const value = match[1].trim();
146
- return value === 'N/A' || value === '' ? null : value;
147
- };
148
-
149
- result.caseNumber = field('Case Number');
150
-
151
- if (isArchived) {
152
- result.exportDate = field('Archived At');
153
- const archivedBy = field('Archived By');
154
- if (archivedBy) {
155
- // Format: "Name (email)" or just "Name" — extract name and optional email
156
- const parenMatch = archivedBy.match(/^(.+?)\s*\(([^)]+)\)\s*$/);
157
- if (parenMatch) {
158
- result.exportedByName = parenMatch[1].trim() || null;
159
- result.exportedBy = parenMatch[2].trim() || null;
160
- } else {
161
- result.exportedByName = archivedBy;
162
- }
163
- }
164
- } else {
165
- result.exportDate = field('Export Date');
166
- result.caseCreatedDate = field('Case Created Date');
167
- result.exportedBy = field('Exported By \\(Email\\)');
168
- result.exportedByName = field('Exported By \\(Name\\)');
169
- result.exportedByCompany = field('Exported By \\(Company\\)');
170
- result.exportedByBadgeId = field('Exported By \\(Badge\\/ID\\)');
171
-
172
- const totalFilesStr = field('- Total Files');
173
- if (totalFilesStr !== null) {
174
- const parsed = parseInt(totalFilesStr, 10);
175
- if (!isNaN(parsed)) result.totalFiles = parsed;
176
- }
99
+ function isEncryptionManifest(value: unknown): value is EncryptionManifest {
100
+ if (!value || typeof value !== 'object') {
101
+ return false;
177
102
  }
178
-
179
- return result;
103
+ const candidate = value as Partial<EncryptionManifest>;
104
+ return (
105
+ typeof candidate.encryptionVersion === 'string' &&
106
+ typeof candidate.algorithm === 'string' &&
107
+ typeof candidate.keyId === 'string' &&
108
+ typeof candidate.wrappedKey === 'string' &&
109
+ typeof candidate.dataIv === 'string' &&
110
+ Array.isArray(candidate.encryptedImages)
111
+ );
180
112
  }
181
113
 
182
114
  /**
@@ -187,251 +119,135 @@ export async function previewCaseImport(zipFile: File, currentUser: User): Promi
187
119
 
188
120
  try {
189
121
  const zip = await JSZip.loadAsync(zipFile);
190
- const verificationPublicKeyPem = await extractVerificationPublicKeyFromZip(zip);
191
-
122
+
192
123
  // Check if export is encrypted
193
124
  const encryptionManifestFile = zip.file('ENCRYPTION_MANIFEST.json');
194
125
  if (encryptionManifestFile) {
126
+ let parsedManifest: unknown;
195
127
  try {
196
128
  const manifestContent = await encryptionManifestFile.async('text');
197
- JSON.parse(manifestContent); // Validate it's valid JSON
198
-
199
- // Count image files
200
- let totalFiles = 0;
201
- const imagesFolder = zip.folder('images');
202
- if (imagesFolder) {
203
- for (const [, file] of Object.entries(imagesFolder.files)) {
204
- if (!file.dir && file.name.includes('/')) {
205
- totalFiles++;
206
- }
207
- }
208
- }
209
-
210
- const hasForensicManifest = zip.file('FORENSIC_MANIFEST.json') !== null;
211
-
212
- // Read README.txt to surface case metadata without decrypting
213
- const readmeFile = zip.file('README.txt');
214
- const readmeContent = readmeFile ? await readmeFile.async('text') : null;
215
- const readmeMeta = parseReadmeCaseInfo(readmeContent, totalFiles);
216
-
217
- return {
218
- caseNumber: readmeMeta.caseNumber ?? 'ENCRYPTED',
219
- archived: readmeMeta.isArchived,
220
- exportedBy: readmeMeta.exportedBy,
221
- exportedByName: readmeMeta.exportedByName,
222
- exportedByCompany: readmeMeta.exportedByCompany,
223
- exportedByBadgeId: readmeMeta.exportedByBadgeId,
224
- exportDate: readmeMeta.exportDate ?? new Date().toISOString(),
225
- totalFiles: readmeMeta.totalFiles,
226
- caseCreatedDate: readmeMeta.caseCreatedDate ?? undefined,
227
- hasAnnotations: false,
228
- validationSummary: 'Export is encrypted. Integrity validation will occur during import.',
229
- hashValid: undefined,
230
- hashError: undefined,
231
- validationDetails: {
232
- hasForensicManifest,
233
- dataValid: undefined,
234
- manifestValid: undefined,
235
- signatureValid: undefined,
236
- validationSummary: 'Encrypted export — integrity validation deferred to import stage',
237
- integrityErrors: []
238
- }
239
- };
129
+ parsedManifest = JSON.parse(manifestContent);
240
130
  } catch (error) {
241
131
  throw new Error(
242
132
  `Encrypted export detected but encryption manifest is invalid: ${error instanceof Error ? error.message : 'Unknown error'}`
243
133
  );
244
134
  }
245
- }
246
-
247
- // First, validate hash if forensic metadata exists
248
- let hashValid: boolean | undefined = undefined;
249
- let hashError: string | undefined = undefined;
250
- let validationDetails: CaseImportPreview['validationDetails'];
251
-
252
- // Find the main data file (JSON or CSV)
253
- const dataFiles = Object.keys(zip.files).filter(name =>
254
- name.endsWith('_data.json') || name.endsWith('_data.csv')
255
- );
256
-
257
- if (dataFiles.length === 0) {
258
- throw new Error('No valid data file found in ZIP archive');
259
- }
260
-
261
- if (dataFiles.length > 1) {
262
- throw new Error('Multiple data files found in ZIP archive');
263
- }
264
-
265
- const dataFileName = dataFiles[0];
266
- const isJsonFormat = dataFileName.endsWith('.json');
267
-
268
- if (!isJsonFormat) {
269
- throw new Error('CSV import not yet supported. Please use JSON format.');
270
- }
271
-
272
- // Extract and parse case data
273
- const dataContent = await zip.file(dataFileName)?.async('text');
274
- if (!dataContent) {
275
- throw new Error('Failed to read data file from ZIP');
276
- }
277
-
278
- // Handle forensic protection warnings in JSON
279
- const cleanedContent = removeForensicWarning(dataContent);
280
-
281
- // Validate forensic manifest integrity
282
- const manifestFile = zip.file('FORENSIC_MANIFEST.json');
283
-
284
- if (manifestFile) {
285
- try {
286
- let forensicManifest: SignedForensicManifest | null = null;
287
-
288
- // Get forensic manifest from dedicated file
289
- const manifestContent = await manifestFile.async('text');
290
- forensicManifest = JSON.parse(manifestContent) as SignedForensicManifest;
291
-
292
- if (forensicManifest) {
293
- // Extract image files for comprehensive validation
294
- const imageFiles: { [filename: string]: Blob } = {};
295
- const imagesFolder = zip.folder('images');
296
- if (imagesFolder) {
297
- await Promise.all(Object.keys(imagesFolder.files).map(async (path) => {
298
- if (path.startsWith('images/') && !path.endsWith('/')) {
299
- const filename = path.replace('images/', '');
300
- const file = zip.file(path);
301
- if (file) {
302
- const blob = await file.async('blob');
303
- imageFiles[filename] = blob;
304
- }
305
- }
306
- }));
307
- }
308
135
 
309
- const casePackageResult = await verifyCasePackageIntegrity({
310
- cleanedContent,
311
- imageFiles,
312
- forensicManifest,
313
- verificationPublicKeyPem,
314
- bundledAuditFiles: {
315
- auditTrailContent: await zip.file('audit/case-audit-trail.json')?.async('text'),
316
- auditSignatureContent: await zip.file('audit/case-audit-signature.json')?.async('text')
317
- }
318
- });
136
+ if (!isEncryptionManifest(parsedManifest)) {
137
+ throw new Error('Encrypted export manifest is missing required fields.');
138
+ }
319
139
 
320
- const signatureResult = casePackageResult.signatureResult;
321
- const validation = casePackageResult.integrityResult;
322
- const bundledAuditVerification = casePackageResult.bundledAuditVerification;
140
+ const encryptionManifest = parsedManifest;
323
141
 
324
- hashValid = casePackageResult.isValid;
142
+ // Find the encrypted data file
143
+ const encDataFiles = Object.keys(zip.files).filter(name => /_data\.json$/i.test(name));
144
+ if (encDataFiles.length === 0) {
145
+ throw new Error('No data file found in encrypted case ZIP archive.');
146
+ }
147
+ if (encDataFiles.length > 1) {
148
+ throw new Error('Multiple data files found in encrypted case ZIP archive. The archive may be corrupt or tampered.');
149
+ }
325
150
 
326
- if (!hashValid) {
327
- const errorParts: string[] = [];
328
- if (!signatureResult.isValid) {
329
- errorParts.push('Signature validation failed.');
330
- }
331
- if (!validation.isValid) {
332
- errorParts.push('Integrity validation failed.');
333
- }
334
- if (bundledAuditVerification) {
335
- errorParts.push(bundledAuditVerification.message);
336
- }
337
- hashError = errorParts.length > 0 ? errorParts.join(' ') : 'Validation failed.';
338
- }
151
+ const encDataFileName = encDataFiles[0];
152
+ const encryptedDataBytes = await zip.file(encDataFileName)?.async('uint8array');
153
+ if (!encryptedDataBytes) {
154
+ throw new Error('Failed to read encrypted data file from ZIP archive.');
155
+ }
339
156
 
340
- const integrityErrors = [...validation.errors];
341
- if (!signatureResult.isValid) {
342
- integrityErrors.push(`Signature validation failed: ${signatureResult.error || 'Unknown signature error'}`);
343
- }
344
- if (bundledAuditVerification) {
345
- integrityErrors.push(bundledAuditVerification.message);
346
- }
157
+ const encryptedDataBase64 = uint8ArrayToBase64Url(encryptedDataBytes);
347
158
 
348
- validationDetails = {
349
- hasForensicManifest: true,
350
- dataValid: validation.dataValid,
351
- imageValidation: validation.imageValidation,
352
- manifestValid: validation.manifestValid,
353
- signatureValid: signatureResult.isValid,
354
- signatureKeyId: signatureResult.keyId,
355
- signatureError: signatureResult.error,
356
- validationSummary: validation.summary,
357
- integrityErrors
358
- };
359
-
360
- } else {
361
- // No forensic manifest found - cannot validate
362
- hashValid = false;
363
- hashError = 'Validation failed.';
364
-
365
- validationDetails = {
366
- hasForensicManifest: false,
367
- dataValid: false,
368
- validationSummary: 'No forensic manifest found - comprehensive validation not available',
369
- integrityErrors: ['Export does not contain forensic manifest required for validation']
370
- };
159
+ // Decrypt data only (no images) to obtain preview metadata
160
+ let decryptedCaseData: CaseExportData;
161
+ try {
162
+ const decryptResult = await decryptExportBatch(
163
+ currentUser,
164
+ encryptionManifest,
165
+ encryptedDataBase64,
166
+ {}
167
+ );
168
+ decryptedCaseData = JSON.parse(decryptResult.plaintext) as CaseExportData;
169
+ } catch (error) {
170
+ throw new Error(
171
+ `Failed to decrypt export for preview: ${error instanceof Error ? error.message : 'Unknown error'}`
172
+ );
173
+ }
174
+
175
+ if (!decryptedCaseData.metadata?.caseNumber) {
176
+ throw new Error('Decrypted export data is missing required case number.');
177
+ }
178
+
179
+ // Validate that the data file name matches the decrypted case number
180
+ const encDataFileLeaf = encDataFileName.split('/').filter(Boolean).pop()?.toLowerCase() ?? '';
181
+ const expectedEncDataFile = `${decryptedCaseData.metadata.caseNumber.toLowerCase()}_data.json`;
182
+ if (encDataFileLeaf !== expectedEncDataFile) {
183
+ throw new Error(
184
+ `Data file name does not match case number. ` +
185
+ `Expected "${expectedEncDataFile}", found "${encDataFileLeaf}". ` +
186
+ 'The archive may be corrupt or tampered.'
187
+ );
188
+ }
189
+
190
+ // Prefer totalFiles from decrypted metadata; fall back to counting image entries
191
+ let totalFiles = decryptedCaseData.metadata.totalFiles ?? 0;
192
+ if (!totalFiles) {
193
+ const imagesFolder = zip.folder('images');
194
+ if (imagesFolder) {
195
+ for (const [, file] of Object.entries(imagesFolder.files)) {
196
+ if (!file.dir && file.name.includes('/')) {
197
+ totalFiles++;
198
+ }
199
+ }
371
200
  }
372
- } catch {
373
- hashError = 'Validation failed.';
374
- hashValid = false;
375
-
376
- validationDetails = {
377
- hasForensicManifest: true,
378
- validationSummary: 'Validation failed due to metadata parsing error',
379
- integrityErrors: [hashError]
380
- };
381
201
  }
382
- } else {
383
- // No forensic manifest found
384
- validationDetails = {
385
- hasForensicManifest: false,
386
- validationSummary: 'No forensic manifest found - integrity cannot be verified',
387
- integrityErrors: []
388
- };
389
- }
390
-
391
- const parsedCaseData = JSON.parse(cleanedContent) as unknown;
392
- const caseData: CaseExportData = parsedCaseData as CaseExportData;
393
-
394
- // Validate case data structure
395
- if (!caseData.metadata?.caseNumber) {
396
- throw new Error('Invalid case data: missing case number');
397
- }
398
-
399
- if (!validateCaseNumber(caseData.metadata.caseNumber)) {
400
- throw new Error(`Invalid case number format: ${caseData.metadata.caseNumber}`);
401
- }
402
-
403
- const isArchivedExport = isArchivedExportData(parsedCaseData);
404
202
 
405
- await validateCaseExporterUidForImport(caseData, currentUser, parsedCaseData);
406
-
407
- // Count image files
408
- let totalFiles = 0;
409
- const imagesFolder = zip.folder('images');
410
- if (imagesFolder) {
411
- for (const [, file] of Object.entries(imagesFolder.files)) {
412
- if (!file.dir && file.name.includes('/')) {
413
- totalFiles++;
203
+ const hasForensicManifest = zip.file('FORENSIC_MANIFEST.json') !== null;
204
+ const isArchivedExport = isArchivedExportData(decryptedCaseData);
205
+ const hasAnnotations = decryptedCaseData.files.some(f => f.hasAnnotations);
206
+
207
+ // Designated reviewer check — must run before returning preview data
208
+ const designatedReviewerEmail = decryptedCaseData.metadata.designatedReviewerEmail;
209
+ if (designatedReviewerEmail) {
210
+ if (!currentUser.email) {
211
+ throw new Error(
212
+ 'Unable to verify reviewer designation: your account email is unavailable.'
213
+ );
214
+ }
215
+ if (designatedReviewerEmail.toLowerCase() !== currentUser.email.toLowerCase()) {
216
+ throw new Error(
217
+ 'This case package is designated for a specific reviewer. You are not authorized to import this case.'
218
+ );
414
219
  }
415
220
  }
221
+
222
+ return {
223
+ caseNumber: decryptedCaseData.metadata.caseNumber,
224
+ archived: isArchivedExport,
225
+ exportedBy: decryptedCaseData.metadata.exportedBy,
226
+ exportedByName: decryptedCaseData.metadata.exportedByName || null,
227
+ exportedByCompany: decryptedCaseData.metadata.exportedByCompany || null,
228
+ exportedByBadgeId: decryptedCaseData.metadata.exportedByBadgeId ?? null,
229
+ exportDate: decryptedCaseData.metadata.exportDate,
230
+ totalFiles,
231
+ caseCreatedDate: decryptedCaseData.metadata.caseCreatedDate ?? undefined,
232
+ hasAnnotations,
233
+ validationSummary: 'Export decrypted successfully. Full integrity validation will occur during import.',
234
+ hashValid: undefined,
235
+ hashError: undefined,
236
+ validationDetails: {
237
+ hasForensicManifest,
238
+ dataValid: undefined,
239
+ manifestValid: undefined,
240
+ signatureValid: undefined,
241
+ validationSummary: 'Encrypted export — integrity validation deferred to import stage',
242
+ integrityErrors: []
243
+ }
244
+ };
416
245
  }
417
-
418
- return {
419
- caseNumber: caseData.metadata.caseNumber,
420
- archived: isArchivedExport,
421
- exportedBy: caseData.metadata.exportedBy || null,
422
- exportedByName: caseData.metadata.exportedByName || null,
423
- exportedByCompany: caseData.metadata.exportedByCompany || null,
424
- exportedByBadgeId: caseData.metadata.exportedByBadgeId || null,
425
- exportDate: caseData.metadata.exportDate,
426
- totalFiles,
427
- caseCreatedDate: caseData.metadata.caseCreatedDate,
428
- hasAnnotations: false, // We'll need to determine this during parsing if needed
429
- validationSummary: hashValid ? 'Validation passed' : 'Validation failed',
430
- hashValid,
431
- hashError,
432
- validationDetails
433
- };
434
-
246
+
247
+ throw new Error(
248
+ 'This case package is not encrypted. Only encrypted case packages exported from Striae can be imported.'
249
+ );
250
+
435
251
  } catch (error) {
436
252
  console.error('Error previewing case import:', error);
437
253
  throw new Error(`Failed to preview case: ${error instanceof Error ? error.message : 'Unknown error'}`);
@@ -441,9 +257,8 @@ export async function previewCaseImport(zipFile: File, currentUser: User): Promi
441
257
  /**
442
258
  * Parse and validate ZIP file contents for case import
443
259
  */
444
- export async function parseImportZip(zipFile: File, currentUser: User): Promise<{
260
+ export async function parseImportZip(zipFile: File): Promise<{
445
261
  caseData: CaseExportData;
446
- imageFiles: { [filename: string]: Blob };
447
262
  imageIdMapping: { [exportFilename: string]: string }; // exportFilename -> originalImageId
448
263
  isArchivedExport: boolean;
449
264
  bundledAuditFiles?: {
@@ -457,6 +272,7 @@ export async function parseImportZip(zipFile: File, currentUser: User): Promise<
457
272
  encryptedDataBase64?: string; // Optional: encrypted data file content (base64url)
458
273
  encryptedImages?: { [filename: string]: string }; // Optional: encrypted image files (filename -> base64url)
459
274
  isEncrypted?: boolean;
275
+ dataFileName?: string; // The encrypted data file name (leaf), for post-decrypt case number validation
460
276
  }> {
461
277
  // Dynamic import of JSZip to avoid bundle size issues
462
278
  const JSZip = (await import('jszip')).default;
@@ -479,25 +295,28 @@ export async function parseImportZip(zipFile: File, currentUser: User): Promise<
479
295
  }
480
296
 
481
297
  const dataFileName = dataFiles[0];
482
- const isJsonFormat = dataFileName.endsWith('.json');
483
-
484
- // Check for encryption manifest first
298
+
299
+ // Only encrypted case packages are supported
485
300
  const encryptionManifestFile = zip.file('ENCRYPTION_MANIFEST.json');
486
- let encryptionManifest: Record<string, unknown> | undefined;
487
- let encryptedDataBase64: string | undefined;
301
+ if (!encryptionManifestFile) {
302
+ throw new Error(
303
+ 'This case package is not encrypted. Only encrypted case packages exported from Striae can be imported.'
304
+ );
305
+ }
306
+
307
+ let encryptionManifest: Record<string, unknown>;
308
+ let encryptedDataBase64: string;
488
309
  const encryptedImages: { [filename: string]: string } = {};
489
- let isEncrypted = false;
310
+ const imageIdMapping: { [exportFilename: string]: string } = {};
311
+ const isEncrypted = true;
490
312
 
491
- // Initialize variables before if-else to ensure scope
492
313
  let caseData: CaseExportData;
493
314
  let parsedCaseData: unknown;
494
- let cleanedContent: string = '';
315
+ const cleanedContent = '';
495
316
 
496
- if (encryptionManifestFile) {
497
- try {
498
- const manifestContent = await encryptionManifestFile.async('text');
499
- encryptionManifest = JSON.parse(manifestContent) as Record<string, unknown>;
500
- isEncrypted = true;
317
+ try {
318
+ const manifestContent = await encryptionManifestFile.async('text');
319
+ encryptionManifest = JSON.parse(manifestContent) as Record<string, unknown>;
501
320
 
502
321
  // Extract the encrypted data file
503
322
  const dataContent = await zip.file(dataFileName)?.async('uint8array');
@@ -527,7 +346,14 @@ export async function parseImportZip(zipFile: File, currentUser: User): Promise<
527
346
  }
528
347
 
529
348
  const filename = isImageFile ? filePath.replace(/^images\//, '') : filePath;
530
-
349
+
350
+ if (isImageFile) {
351
+ const originalImageId = extractImageIdFromFilename(filename);
352
+ if (originalImageId) {
353
+ imageIdMapping[filename] = originalImageId;
354
+ }
355
+ }
356
+
531
357
  encryptedImagePromises.push((async () => {
532
358
  try {
533
359
  const encryptedBlob = await file.async('uint8array');
@@ -548,96 +374,20 @@ export async function parseImportZip(zipFile: File, currentUser: User): Promise<
548
374
 
549
375
  // For encrypted exports, data file will be processed after decryption
550
376
  // Set placeholder values that will be replaced after decryption
551
- caseData = { metadata: { caseNumber: 'ENCRYPTED' } } as CaseExportData;
552
- parsedCaseData = caseData;
553
- cleanedContent = '';
554
-
555
- } catch (error) {
556
- throw new Error(`Failed to process encrypted export: ${error instanceof Error ? error.message : 'Unknown error'}`);
557
- }
558
- } else {
559
- // Standard unencrypted extract and parse case data
560
- if (isJsonFormat) {
561
- const dataContent = await zip.file(dataFileName)?.async('text');
562
- if (!dataContent) {
563
- throw new Error('Failed to read data file from ZIP');
564
- }
565
-
566
- // Handle forensic protection warnings in JSON
567
- cleanedContent = removeForensicWarning(dataContent);
568
- parsedCaseData = JSON.parse(cleanedContent) as unknown;
569
- caseData = parsedCaseData as CaseExportData;
570
- } else {
571
- throw new Error('CSV import not yet supported. Please use JSON format.');
572
- }
377
+ caseData = { metadata: { caseNumber: 'ENCRYPTED' } } as CaseExportData;
378
+ parsedCaseData = caseData;
379
+ } catch (error) {
380
+ throw new Error(`Failed to process encrypted export: ${error instanceof Error ? error.message : 'Unknown error'}`);
573
381
  }
574
-
575
- // Validate case data structure only for unencrypted exports
576
- // (encrypted exports will be validated after decryption in orchestrator)
577
- if (!isEncrypted) {
578
- if (!caseData.metadata?.caseNumber) {
579
- throw new Error('Invalid case data: missing case number');
580
- }
581
382
 
582
- if (!validateCaseNumber(caseData.metadata.caseNumber)) {
583
- throw new Error(`Invalid case number format: ${caseData.metadata.caseNumber}`);
584
- }
585
- }
586
-
587
383
  const isArchivedExport = isArchivedExportData(parsedCaseData);
588
384
 
589
- // Validate exporter UID exists in user database and is not current user (skip for encrypted)
590
- if (!isEncrypted) {
591
- await validateCaseExporterUidForImport(caseData, currentUser, parsedCaseData);
592
- }
593
-
594
- // Extract image files and create ID mapping - iterate through zip.files directly
595
- const imageFiles: { [filename: string]: Blob } = {};
596
- const imageIdMapping: { [exportFilename: string]: string } = {};
597
-
598
- const imageExtractionPromises: Promise<void>[] = [];
599
-
600
- const fileListForImages = Object.keys(zip.files);
601
- for (const filePath of fileListForImages) {
602
- // Only process files in the images folder
603
- if (!filePath.startsWith('images/') || filePath === 'images/' || filePath.endsWith('/')) {
604
- continue;
605
- }
606
-
607
- const file = zip.files[filePath];
608
- if (!file || file.dir) {
609
- continue;
610
- }
611
-
612
- imageExtractionPromises.push((async () => {
613
- try {
614
- const exportFilename = filePath.replace(/^images\//, '');
615
- const blob = await file.async('blob');
616
- imageFiles[exportFilename] = blob;
617
-
618
- // Extract original image ID from filename
619
- const originalImageId = extractImageIdFromFilename(exportFilename);
620
- if (originalImageId) {
621
- imageIdMapping[exportFilename] = originalImageId;
622
- }
623
- } catch (err) {
624
- console.error(`Failed to extract image ${filePath}:`, err);
625
- }
626
- })());
627
- }
628
-
629
- // Wait for all image extractions to complete
630
- await Promise.all(imageExtractionPromises);
631
-
632
385
  // Extract forensic manifest if present
633
386
  let metadata: Record<string, unknown> | undefined;
634
387
  const manifestFile = zip.file('FORENSIC_MANIFEST.json');
635
- const auditTrailContent = isEncrypted
636
- ? undefined
637
- : await zip.file('audit/case-audit-trail.json')?.async('text');
638
- const auditSignatureContent = isEncrypted
639
- ? undefined
640
- : await zip.file('audit/case-audit-signature.json')?.async('text');
388
+ // Audit trail files are encrypted — decrypted by the orchestrator
389
+ const auditTrailContent: string | undefined = undefined;
390
+ const auditSignatureContent: string | undefined = undefined;
641
391
 
642
392
  if (manifestFile) {
643
393
  const manifestContent = await manifestFile.async('text');
@@ -646,7 +396,6 @@ export async function parseImportZip(zipFile: File, currentUser: User): Promise<
646
396
 
647
397
  return {
648
398
  caseData,
649
- imageFiles,
650
399
  imageIdMapping,
651
400
  isArchivedExport,
652
401
  bundledAuditFiles: {
@@ -659,7 +408,8 @@ export async function parseImportZip(zipFile: File, currentUser: User): Promise<
659
408
  encryptionManifest,
660
409
  encryptedDataBase64,
661
410
  encryptedImages: Object.keys(encryptedImages).length > 0 ? encryptedImages : undefined,
662
- isEncrypted
411
+ isEncrypted,
412
+ dataFileName
663
413
  };
664
414
 
665
415
  } catch (error) {