@striae-org/striae 5.3.0 → 5.3.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/app/components/actions/case-export/core-export.ts +3 -0
- package/app/components/actions/case-export/download-handlers.ts +1 -1
- package/app/components/actions/case-import/confirmation-import.ts +62 -22
- package/app/components/actions/case-import/confirmation-package.ts +68 -1
- package/app/components/actions/case-import/index.ts +1 -1
- package/app/components/actions/case-import/orchestrator.ts +78 -53
- package/app/components/actions/case-import/zip-processing.ts +157 -407
- package/app/components/navbar/case-modals/export-case-modal.module.css +27 -0
- package/app/components/navbar/case-modals/export-case-modal.tsx +132 -0
- package/app/components/navbar/case-modals/export-confirmations-modal.module.css +24 -0
- package/app/components/navbar/case-modals/export-confirmations-modal.tsx +108 -0
- package/app/components/sidebar/case-import/components/CasePreviewSection.tsx +1 -9
- package/app/components/sidebar/case-import/components/ConfirmationPreviewSection.tsx +36 -5
- package/app/components/sidebar/case-import/hooks/useFilePreview.ts +5 -9
- package/app/components/sidebar/case-import/index.ts +1 -4
- package/app/routes/auth/login.tsx +22 -103
- package/app/routes/striae/striae.tsx +77 -13
- package/app/types/case.ts +1 -0
- package/app/types/export.ts +1 -0
- package/app/types/import.ts +10 -0
- package/package.json +1 -1
- package/workers/audit-worker/wrangler.jsonc.example +1 -1
- package/workers/data-worker/wrangler.jsonc.example +1 -1
- package/workers/image-worker/wrangler.jsonc.example +1 -1
- package/workers/pdf-worker/wrangler.jsonc.example +1 -1
- package/workers/user-worker/wrangler.jsonc.example +1 -1
- 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 {
|
|
4
|
-
import {
|
|
5
|
-
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
|
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
|
-
|
|
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);
|
|
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
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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
|
-
|
|
321
|
-
const validation = casePackageResult.integrityResult;
|
|
322
|
-
const bundledAuditVerification = casePackageResult.bundledAuditVerification;
|
|
140
|
+
const encryptionManifest = parsedManifest;
|
|
323
141
|
|
|
324
|
-
|
|
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
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
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
|
-
|
|
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
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
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
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
if (!
|
|
413
|
-
|
|
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
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
487
|
-
|
|
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
|
-
|
|
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
|
-
|
|
315
|
+
const cleanedContent = '';
|
|
495
316
|
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
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
|
-
|
|
552
|
-
|
|
553
|
-
|
|
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
|
-
|
|
636
|
-
|
|
637
|
-
|
|
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) {
|