@striae-org/striae 4.3.4 → 5.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.
- package/.env.example +4 -0
- package/app/components/actions/case-export/download-handlers.ts +60 -4
- package/app/components/actions/case-import/confirmation-import.ts +50 -7
- package/app/components/actions/case-import/confirmation-package.ts +99 -22
- package/app/components/actions/case-import/orchestrator.ts +116 -13
- package/app/components/actions/case-import/validation.ts +171 -7
- package/app/components/actions/case-import/zip-processing.ts +224 -127
- package/app/components/actions/case-manage.ts +64 -4
- package/app/components/actions/confirm-export.ts +32 -3
- package/app/components/navbar/navbar.module.css +0 -10
- package/app/components/navbar/navbar.tsx +0 -22
- package/app/components/sidebar/case-import/case-import.module.css +7 -131
- package/app/components/sidebar/case-import/case-import.tsx +7 -14
- package/app/components/sidebar/case-import/components/CasePreviewSection.tsx +17 -60
- package/app/components/sidebar/case-import/components/ConfirmationDialog.tsx +23 -39
- package/app/components/sidebar/case-import/components/ConfirmationPreviewSection.tsx +5 -45
- package/app/components/sidebar/case-import/components/FileSelector.tsx +5 -6
- package/app/components/sidebar/case-import/hooks/useFilePreview.ts +2 -48
- package/app/components/sidebar/case-import/utils/file-validation.ts +9 -21
- package/app/config-example/config.json +5 -0
- package/app/routes/auth/login.tsx +1 -1
- package/app/utils/data/operations/signing-operations.ts +93 -0
- package/app/utils/data/operations/types.ts +6 -0
- package/app/utils/forensics/export-encryption.ts +316 -0
- package/app/utils/forensics/export-verification.ts +1 -409
- package/app/utils/forensics/index.ts +1 -0
- package/app/utils/ui/case-messages.ts +5 -2
- package/package.json +1 -1
- package/scripts/deploy-config.sh +97 -3
- package/scripts/deploy-worker-secrets.sh +1 -1
- package/workers/audit-worker/wrangler.jsonc.example +1 -1
- package/workers/data-worker/src/data-worker.example.ts +130 -0
- package/workers/data-worker/src/encryption-utils.ts +125 -0
- package/workers/data-worker/worker-configuration.d.ts +1 -1
- package/workers/data-worker/wrangler.jsonc.example +2 -2
- package/workers/image-worker/wrangler.jsonc.example +1 -1
- package/workers/keys-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
- package/app/components/public-signing-key-modal/public-signing-key-modal.module.css +0 -287
- package/app/components/public-signing-key-modal/public-signing-key-modal.tsx +0 -470
|
@@ -2,21 +2,185 @@ import type { User } from 'firebase/auth';
|
|
|
2
2
|
import { checkUserExistsApi } from '~/utils/api';
|
|
3
3
|
import { type CaseExportData, type ConfirmationImportData } from '~/types';
|
|
4
4
|
import { type ManifestSignatureVerificationResult, verifyConfirmationSignature } from '~/utils/forensics';
|
|
5
|
+
import { checkExistingCase } from '../case-manage';
|
|
5
6
|
export { removeForensicWarning, validateConfirmationHash } from '~/utils/forensics';
|
|
6
7
|
|
|
8
|
+
const REDACTED_UID_VALUES = new Set([
|
|
9
|
+
'[user info excluded]',
|
|
10
|
+
'n/a',
|
|
11
|
+
'na',
|
|
12
|
+
'unknown',
|
|
13
|
+
'null',
|
|
14
|
+
'undefined'
|
|
15
|
+
]);
|
|
16
|
+
|
|
17
|
+
function toNonEmptyString(value: unknown): string | null {
|
|
18
|
+
if (typeof value !== 'string') {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const trimmed = value.trim();
|
|
23
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function normalizeExporterUid(value: unknown): string | null {
|
|
27
|
+
const candidate = toNonEmptyString(value);
|
|
28
|
+
if (!candidate) {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (REDACTED_UID_VALUES.has(candidate.toLowerCase())) {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return candidate;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function resolveExporterUid(caseData: CaseExportData, parsedData: unknown): string | null {
|
|
40
|
+
const root = (parsedData && typeof parsedData === 'object')
|
|
41
|
+
? (parsedData as Record<string, unknown>)
|
|
42
|
+
: {};
|
|
43
|
+
const metadata = (root.metadata && typeof root.metadata === 'object')
|
|
44
|
+
? (root.metadata as Record<string, unknown>)
|
|
45
|
+
: {};
|
|
46
|
+
|
|
47
|
+
const candidates: unknown[] = [
|
|
48
|
+
caseData.metadata.exportedByUid,
|
|
49
|
+
caseData.metadata.archivedBy,
|
|
50
|
+
metadata.exportedByUid,
|
|
51
|
+
metadata.exportedByUID,
|
|
52
|
+
metadata.exporterUid,
|
|
53
|
+
metadata.exporterUID,
|
|
54
|
+
metadata.archivedBy,
|
|
55
|
+
metadata.archivedByUid,
|
|
56
|
+
metadata.archivedByUID,
|
|
57
|
+
metadata.userUid,
|
|
58
|
+
metadata.userUID,
|
|
59
|
+
root.exportedByUid,
|
|
60
|
+
root.exportedByUID,
|
|
61
|
+
root.exporterUid,
|
|
62
|
+
root.exporterUID,
|
|
63
|
+
root.archivedBy,
|
|
64
|
+
root.archivedByUid,
|
|
65
|
+
root.archivedByUID,
|
|
66
|
+
root.userUid,
|
|
67
|
+
root.userUID
|
|
68
|
+
];
|
|
69
|
+
|
|
70
|
+
for (const candidate of candidates) {
|
|
71
|
+
const resolved = normalizeExporterUid(candidate);
|
|
72
|
+
if (resolved) {
|
|
73
|
+
return resolved;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
|
|
7
80
|
/**
|
|
8
81
|
* Validate that a user exists in the database by UID and is not the current user
|
|
9
82
|
*/
|
|
10
83
|
export async function validateExporterUid(exporterUid: string, currentUser: User): Promise<{ exists: boolean; isSelf: boolean }> {
|
|
84
|
+
const exists = await checkUserExistsApi(currentUser, exporterUid);
|
|
85
|
+
const isSelf = exporterUid === currentUser.uid;
|
|
86
|
+
|
|
87
|
+
return { exists, isSelf };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function isArchivedExportData(parsedData: unknown): boolean {
|
|
91
|
+
if (!parsedData || typeof parsedData !== 'object') {
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const root = parsedData as Record<string, unknown>;
|
|
96
|
+
|
|
97
|
+
if (root.archived === true) {
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (typeof root.archivedAt === 'string' && root.archivedAt.trim().length > 0) {
|
|
102
|
+
return true;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const metadata = root.metadata;
|
|
106
|
+
if (!metadata || typeof metadata !== 'object') {
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const metadataRecord = metadata as Record<string, unknown>;
|
|
111
|
+
|
|
112
|
+
if (metadataRecord.archived === true) {
|
|
113
|
+
return true;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (typeof metadataRecord.archivedAt === 'string' && metadataRecord.archivedAt.trim().length > 0) {
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export async function validateCaseExporterUidForImport(
|
|
124
|
+
caseData: CaseExportData,
|
|
125
|
+
currentUser: User,
|
|
126
|
+
parsedData: unknown = caseData
|
|
127
|
+
): Promise<{
|
|
128
|
+
exists: boolean;
|
|
129
|
+
isSelf: boolean;
|
|
130
|
+
isArchivedExport: boolean;
|
|
131
|
+
allowArchivedSelfImport: boolean;
|
|
132
|
+
}> {
|
|
133
|
+
const isArchivedExport = isArchivedExportData(parsedData);
|
|
134
|
+
const exportedByUid = resolveExporterUid(caseData, parsedData);
|
|
135
|
+
|
|
136
|
+
if (!exportedByUid) {
|
|
137
|
+
if (isArchivedExport) {
|
|
138
|
+
// Some legacy or privacy-sanitized archived exports may not retain exporter UID fields.
|
|
139
|
+
// Archived import safety is still enforced by integrity/signature checks and regular-case conflict checks.
|
|
140
|
+
return {
|
|
141
|
+
exists: true,
|
|
142
|
+
isSelf: false,
|
|
143
|
+
isArchivedExport: true,
|
|
144
|
+
allowArchivedSelfImport: true
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
throw new Error(
|
|
149
|
+
'Case export is missing usable exporter UID information. This case cannot be imported.'
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
let validation: { exists: boolean; isSelf: boolean };
|
|
11
154
|
try {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
console.error('Error validating exporter UID:', error);
|
|
18
|
-
return { exists: false, isSelf: false };
|
|
155
|
+
validation = await validateExporterUid(exportedByUid, currentUser);
|
|
156
|
+
} catch {
|
|
157
|
+
throw new Error(
|
|
158
|
+
'Unable to validate exporter identity right now. Please retry the import.'
|
|
159
|
+
);
|
|
19
160
|
}
|
|
161
|
+
|
|
162
|
+
if (!validation.exists) {
|
|
163
|
+
throw new Error('The original exporter is not a valid Striae user. This case cannot be imported.');
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
let allowArchivedSelfImport = false;
|
|
167
|
+
|
|
168
|
+
if (isArchivedExport) {
|
|
169
|
+
const existingRegularCase = await checkExistingCase(currentUser, caseData.metadata.caseNumber);
|
|
170
|
+
allowArchivedSelfImport = existingRegularCase === null;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (validation.isSelf && !allowArchivedSelfImport) {
|
|
174
|
+
throw new Error(
|
|
175
|
+
'You cannot import a case that you originally exported unless it is an archived case that has already been deleted from your regular case list.'
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return {
|
|
180
|
+
...validation,
|
|
181
|
+
isArchivedExport,
|
|
182
|
+
allowArchivedSelfImport
|
|
183
|
+
};
|
|
20
184
|
}
|
|
21
185
|
|
|
22
186
|
/**
|
|
@@ -1,62 +1,15 @@
|
|
|
1
1
|
import type { User } from 'firebase/auth';
|
|
2
2
|
import { type CaseExportData, type CaseImportPreview } from '~/types';
|
|
3
|
-
import { getCaseData } from '~/utils/data';
|
|
4
3
|
import { validateCaseNumber } from '../case-manage';
|
|
5
4
|
import {
|
|
6
5
|
type SignedForensicManifest,
|
|
7
6
|
verifyCasePackageIntegrity
|
|
8
7
|
} from '~/utils/forensics';
|
|
9
|
-
import {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
const root = parsedData as Record<string, unknown>;
|
|
17
|
-
|
|
18
|
-
if (root.archived === true) {
|
|
19
|
-
return true;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
if (typeof root.archivedAt === 'string' && root.archivedAt.trim().length > 0) {
|
|
23
|
-
return true;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
const metadata = root.metadata;
|
|
27
|
-
if (!metadata || typeof metadata !== 'object') {
|
|
28
|
-
return false;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
const metadataRecord = metadata as Record<string, unknown>;
|
|
32
|
-
|
|
33
|
-
if (metadataRecord.archived === true) {
|
|
34
|
-
return true;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
if (typeof metadataRecord.archivedAt === 'string' && metadataRecord.archivedAt.trim().length > 0) {
|
|
38
|
-
return true;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
return false;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
async function allowSelfImportForArchivedCase(
|
|
45
|
-
currentUser: User,
|
|
46
|
-
caseNumber: string,
|
|
47
|
-
parsedData: unknown
|
|
48
|
-
): Promise<boolean> {
|
|
49
|
-
if (isArchivedExportData(parsedData)) {
|
|
50
|
-
return true;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
try {
|
|
54
|
-
const existingCase = await getCaseData(currentUser, caseNumber);
|
|
55
|
-
return existingCase?.archived === true;
|
|
56
|
-
} catch {
|
|
57
|
-
return false;
|
|
58
|
-
}
|
|
59
|
-
}
|
|
8
|
+
import {
|
|
9
|
+
isArchivedExportData,
|
|
10
|
+
removeForensicWarning,
|
|
11
|
+
validateCaseExporterUidForImport
|
|
12
|
+
} from './validation';
|
|
60
13
|
|
|
61
14
|
function getLeafFileName(path: string): string {
|
|
62
15
|
const segments = path.split('/').filter(Boolean);
|
|
@@ -93,6 +46,27 @@ async function extractVerificationPublicKeyFromZip(
|
|
|
93
46
|
return zip.file(preferredPemPath)?.async('text');
|
|
94
47
|
}
|
|
95
48
|
|
|
49
|
+
/**
|
|
50
|
+
* Safe conversion of Uint8Array to base64url without spread operator stack overflow
|
|
51
|
+
* For large arrays, uses chunking approach to avoid "Maximum call stack size exceeded"
|
|
52
|
+
*/
|
|
53
|
+
function uint8ArrayToBase64Url(data: Uint8Array): string {
|
|
54
|
+
const chunkSize = 8192;
|
|
55
|
+
let binaryString = '';
|
|
56
|
+
|
|
57
|
+
for (let i = 0; i < data.length; i += chunkSize) {
|
|
58
|
+
const chunk = data.subarray(i, Math.min(i + chunkSize, data.length));
|
|
59
|
+
for (let j = 0; j < chunk.length; j += 1) {
|
|
60
|
+
binaryString += String.fromCharCode(chunk[j]);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return btoa(binaryString)
|
|
65
|
+
.replace(/\+/g, '-')
|
|
66
|
+
.replace(/\//g, '_')
|
|
67
|
+
.replace(/=+$/g, '');
|
|
68
|
+
}
|
|
69
|
+
|
|
96
70
|
/**
|
|
97
71
|
* Extract original image ID from export filename format
|
|
98
72
|
* Format: {originalFilename}-{id}.{extension}
|
|
@@ -139,6 +113,57 @@ export async function previewCaseImport(zipFile: File, currentUser: User): Promi
|
|
|
139
113
|
const zip = await JSZip.loadAsync(zipFile);
|
|
140
114
|
const verificationPublicKeyPem = await extractVerificationPublicKeyFromZip(zip);
|
|
141
115
|
|
|
116
|
+
// Check if export is encrypted
|
|
117
|
+
const encryptionManifestFile = zip.file('ENCRYPTION_MANIFEST.json');
|
|
118
|
+
if (encryptionManifestFile) {
|
|
119
|
+
// For encrypted exports, we can't read the plaintext data to extract case info
|
|
120
|
+
// Return an encrypted preview that requires decryption during import
|
|
121
|
+
try {
|
|
122
|
+
const manifestContent = await encryptionManifestFile.async('text');
|
|
123
|
+
JSON.parse(manifestContent); // Validate it's valid JSON
|
|
124
|
+
|
|
125
|
+
// Count image files
|
|
126
|
+
let totalFiles = 0;
|
|
127
|
+
const imagesFolder = zip.folder('images');
|
|
128
|
+
if (imagesFolder) {
|
|
129
|
+
for (const [, file] of Object.entries(imagesFolder.files)) {
|
|
130
|
+
if (!file.dir && file.name.includes('/')) {
|
|
131
|
+
totalFiles++;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const hasForensicManifest = zip.file('FORENSIC_MANIFEST.json') !== null;
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
caseNumber: 'ENCRYPTED',
|
|
140
|
+
archived: false,
|
|
141
|
+
exportedBy: null,
|
|
142
|
+
exportedByName: null,
|
|
143
|
+
exportedByCompany: null,
|
|
144
|
+
exportedByBadgeId: null,
|
|
145
|
+
exportDate: new Date().toISOString(),
|
|
146
|
+
totalFiles,
|
|
147
|
+
hasAnnotations: false,
|
|
148
|
+
validationSummary: 'Export is encrypted. Integrity validation will occur during import.',
|
|
149
|
+
hashValid: undefined,
|
|
150
|
+
hashError: undefined,
|
|
151
|
+
validationDetails: {
|
|
152
|
+
hasForensicManifest,
|
|
153
|
+
dataValid: undefined,
|
|
154
|
+
manifestValid: undefined,
|
|
155
|
+
signatureValid: undefined,
|
|
156
|
+
validationSummary: 'Encrypted export — integrity validation deferred to import stage',
|
|
157
|
+
integrityErrors: []
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
} catch (error) {
|
|
161
|
+
throw new Error(
|
|
162
|
+
`Encrypted export detected but encryption manifest is invalid: ${error instanceof Error ? error.message : 'Unknown error'}`
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
142
167
|
// First, validate hash if forensic metadata exists
|
|
143
168
|
let hashValid: boolean | undefined = undefined;
|
|
144
169
|
let hashError: string | undefined = undefined;
|
|
@@ -295,26 +320,9 @@ export async function previewCaseImport(zipFile: File, currentUser: User): Promi
|
|
|
295
320
|
throw new Error(`Invalid case number format: ${caseData.metadata.caseNumber}`);
|
|
296
321
|
}
|
|
297
322
|
|
|
298
|
-
const isArchivedExport =
|
|
299
|
-
currentUser,
|
|
300
|
-
caseData.metadata.caseNumber,
|
|
301
|
-
parsedCaseData
|
|
302
|
-
);
|
|
323
|
+
const isArchivedExport = isArchivedExportData(parsedCaseData);
|
|
303
324
|
|
|
304
|
-
|
|
305
|
-
if (caseData.metadata.exportedByUid) {
|
|
306
|
-
const validation = await validateExporterUid(caseData.metadata.exportedByUid, currentUser);
|
|
307
|
-
|
|
308
|
-
if (!validation.exists) {
|
|
309
|
-
throw new Error(`The original exporter is not a valid Striae user. This case cannot be imported.`);
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
if (validation.isSelf && !isArchivedExport) {
|
|
313
|
-
throw new Error(`You cannot import a case that you originally exported. Original analysts cannot review their own cases.`);
|
|
314
|
-
}
|
|
315
|
-
} else {
|
|
316
|
-
throw new Error('Case export missing exporter UID information. This case cannot be imported.');
|
|
317
|
-
}
|
|
325
|
+
await validateCaseExporterUidForImport(caseData, currentUser, parsedCaseData);
|
|
318
326
|
|
|
319
327
|
// Count image files
|
|
320
328
|
let totalFiles = 0;
|
|
@@ -365,6 +373,10 @@ export async function parseImportZip(zipFile: File, currentUser: User): Promise<
|
|
|
365
373
|
metadata?: Record<string, unknown>;
|
|
366
374
|
cleanedContent?: string; // Add cleaned content for hash validation
|
|
367
375
|
verificationPublicKeyPem?: string;
|
|
376
|
+
encryptionManifest?: Record<string, unknown>; // Optional: decryption metadata
|
|
377
|
+
encryptedDataBase64?: string; // Optional: encrypted data file content (base64url)
|
|
378
|
+
encryptedImages?: { [filename: string]: string }; // Optional: encrypted image files (filename -> base64url)
|
|
379
|
+
isEncrypted?: boolean;
|
|
368
380
|
}> {
|
|
369
381
|
// Dynamic import of JSZip to avoid bundle size issues
|
|
370
382
|
const JSZip = (await import('jszip')).default;
|
|
@@ -389,82 +401,163 @@ export async function parseImportZip(zipFile: File, currentUser: User): Promise<
|
|
|
389
401
|
const dataFileName = dataFiles[0];
|
|
390
402
|
const isJsonFormat = dataFileName.endsWith('.json');
|
|
391
403
|
|
|
392
|
-
//
|
|
404
|
+
// Check for encryption manifest first
|
|
405
|
+
const encryptionManifestFile = zip.file('ENCRYPTION_MANIFEST.json');
|
|
406
|
+
let encryptionManifest: Record<string, unknown> | undefined;
|
|
407
|
+
let encryptedDataBase64: string | undefined;
|
|
408
|
+
const encryptedImages: { [filename: string]: string } = {};
|
|
409
|
+
let isEncrypted = false;
|
|
410
|
+
|
|
411
|
+
// Initialize variables before if-else to ensure scope
|
|
393
412
|
let caseData: CaseExportData;
|
|
394
413
|
let parsedCaseData: unknown;
|
|
395
414
|
let cleanedContent: string = '';
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
415
|
+
|
|
416
|
+
if (encryptionManifestFile) {
|
|
417
|
+
try {
|
|
418
|
+
const manifestContent = await encryptionManifestFile.async('text');
|
|
419
|
+
encryptionManifest = JSON.parse(manifestContent) as Record<string, unknown>;
|
|
420
|
+
isEncrypted = true;
|
|
421
|
+
|
|
422
|
+
// Extract the encrypted data file
|
|
423
|
+
const dataContent = await zip.file(dataFileName)?.async('uint8array');
|
|
424
|
+
if (!dataContent) {
|
|
425
|
+
throw new Error('Failed to read encrypted data file from ZIP');
|
|
426
|
+
}
|
|
427
|
+
// Convert to base64url for transmission to worker (chunked to avoid stack overflow)
|
|
428
|
+
encryptedDataBase64 = uint8ArrayToBase64Url(dataContent);
|
|
429
|
+
|
|
430
|
+
// Extract encrypted files referenced by encrypted export payloads
|
|
431
|
+
const encryptedImagePromises: Promise<[string, string]>[] = [];
|
|
432
|
+
|
|
433
|
+
const fileList = Object.keys(zip.files);
|
|
434
|
+
for (const filePath of fileList) {
|
|
435
|
+
const isImageFile = filePath.startsWith('images/') && filePath !== 'images/';
|
|
436
|
+
const isBundledAuditFile =
|
|
437
|
+
filePath === 'audit/case-audit-trail.json' ||
|
|
438
|
+
filePath === 'audit/case-audit-signature.json';
|
|
439
|
+
|
|
440
|
+
if ((!isImageFile && !isBundledAuditFile) || filePath.endsWith('/')) {
|
|
441
|
+
continue;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
const file = zip.files[filePath];
|
|
445
|
+
if (!file || file.dir) {
|
|
446
|
+
continue;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
const filename = isImageFile ? filePath.replace(/^images\//, '') : filePath;
|
|
450
|
+
|
|
451
|
+
encryptedImagePromises.push((async () => {
|
|
452
|
+
try {
|
|
453
|
+
const encryptedBlob = await file.async('uint8array');
|
|
454
|
+
// Convert to base64url (chunked to avoid stack overflow)
|
|
455
|
+
const encryptedBase64Url = uint8ArrayToBase64Url(encryptedBlob);
|
|
456
|
+
return [filename, encryptedBase64Url] as [string, string];
|
|
457
|
+
} catch (err) {
|
|
458
|
+
throw new Error(`Failed to extract encrypted image ${filename}: ${err instanceof Error ? err.message : 'Unknown error'}`);
|
|
459
|
+
}
|
|
460
|
+
})());
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// Wait for all image conversions
|
|
464
|
+
const encryptedImageResults = await Promise.all(encryptedImagePromises);
|
|
465
|
+
for (const [filename, data] of encryptedImageResults) {
|
|
466
|
+
encryptedImages[filename] = data;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// For encrypted exports, data file will be processed after decryption
|
|
470
|
+
// Set placeholder values that will be replaced after decryption
|
|
471
|
+
caseData = { metadata: { caseNumber: 'ENCRYPTED' } } as CaseExportData;
|
|
472
|
+
parsedCaseData = caseData;
|
|
473
|
+
cleanedContent = '';
|
|
474
|
+
|
|
475
|
+
} catch (error) {
|
|
476
|
+
throw new Error(`Failed to process encrypted export: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
400
477
|
}
|
|
401
|
-
|
|
402
|
-
// Handle forensic protection warnings in JSON
|
|
403
|
-
cleanedContent = removeForensicWarning(dataContent);
|
|
404
|
-
parsedCaseData = JSON.parse(cleanedContent) as unknown;
|
|
405
|
-
caseData = parsedCaseData as CaseExportData;
|
|
406
478
|
} else {
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
479
|
+
// Standard unencrypted extract and parse case data
|
|
480
|
+
if (isJsonFormat) {
|
|
481
|
+
const dataContent = await zip.file(dataFileName)?.async('text');
|
|
482
|
+
if (!dataContent) {
|
|
483
|
+
throw new Error('Failed to read data file from ZIP');
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// Handle forensic protection warnings in JSON
|
|
487
|
+
cleanedContent = removeForensicWarning(dataContent);
|
|
488
|
+
parsedCaseData = JSON.parse(cleanedContent) as unknown;
|
|
489
|
+
caseData = parsedCaseData as CaseExportData;
|
|
490
|
+
} else {
|
|
491
|
+
throw new Error('CSV import not yet supported. Please use JSON format.');
|
|
492
|
+
}
|
|
413
493
|
}
|
|
414
494
|
|
|
415
|
-
|
|
416
|
-
|
|
495
|
+
// Validate case data structure only for unencrypted exports
|
|
496
|
+
// (encrypted exports will be validated after decryption in orchestrator)
|
|
497
|
+
if (!isEncrypted) {
|
|
498
|
+
if (!caseData.metadata?.caseNumber) {
|
|
499
|
+
throw new Error('Invalid case data: missing case number');
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
if (!validateCaseNumber(caseData.metadata.caseNumber)) {
|
|
503
|
+
throw new Error(`Invalid case number format: ${caseData.metadata.caseNumber}`);
|
|
504
|
+
}
|
|
417
505
|
}
|
|
418
506
|
|
|
419
|
-
const isArchivedExport =
|
|
420
|
-
currentUser,
|
|
421
|
-
caseData.metadata.caseNumber,
|
|
422
|
-
parsedCaseData
|
|
423
|
-
);
|
|
507
|
+
const isArchivedExport = isArchivedExportData(parsedCaseData);
|
|
424
508
|
|
|
425
|
-
// Validate exporter UID exists in user database and is not current user
|
|
426
|
-
if (
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
if (!validation.exists) {
|
|
430
|
-
throw new Error(`The original exporter is not a valid Striae user. This case cannot be imported.`);
|
|
431
|
-
}
|
|
432
|
-
|
|
433
|
-
if (validation.isSelf && !isArchivedExport) {
|
|
434
|
-
throw new Error(`You cannot import a case that you originally exported. Original analysts cannot review their own cases.`);
|
|
435
|
-
}
|
|
436
|
-
} else {
|
|
437
|
-
throw new Error('Case export missing exporter UID information. This case cannot be imported.');
|
|
509
|
+
// Validate exporter UID exists in user database and is not current user (skip for encrypted)
|
|
510
|
+
if (!isEncrypted) {
|
|
511
|
+
await validateCaseExporterUidForImport(caseData, currentUser, parsedCaseData);
|
|
438
512
|
}
|
|
439
513
|
|
|
440
|
-
// Extract image files and create ID mapping
|
|
514
|
+
// Extract image files and create ID mapping - iterate through zip.files directly
|
|
441
515
|
const imageFiles: { [filename: string]: Blob } = {};
|
|
442
516
|
const imageIdMapping: { [exportFilename: string]: string } = {};
|
|
443
|
-
const imagesFolder = zip.folder('images');
|
|
444
517
|
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
const blob = await file.async('blob');
|
|
453
|
-
imageFiles[exportFilename] = blob;
|
|
454
|
-
|
|
455
|
-
// Extract original image ID from filename
|
|
456
|
-
const originalImageId = extractImageIdFromFilename(exportFilename);
|
|
457
|
-
if (originalImageId) {
|
|
458
|
-
imageIdMapping[exportFilename] = originalImageId;
|
|
459
|
-
}
|
|
518
|
+
const imageExtractionPromises: Promise<void>[] = [];
|
|
519
|
+
|
|
520
|
+
const fileListForImages = Object.keys(zip.files);
|
|
521
|
+
for (const filePath of fileListForImages) {
|
|
522
|
+
// Only process files in the images folder
|
|
523
|
+
if (!filePath.startsWith('images/') || filePath === 'images/' || filePath.endsWith('/')) {
|
|
524
|
+
continue;
|
|
460
525
|
}
|
|
526
|
+
|
|
527
|
+
const file = zip.files[filePath];
|
|
528
|
+
if (!file || file.dir) {
|
|
529
|
+
continue;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
imageExtractionPromises.push((async () => {
|
|
533
|
+
try {
|
|
534
|
+
const exportFilename = filePath.replace(/^images\//, '');
|
|
535
|
+
const blob = await file.async('blob');
|
|
536
|
+
imageFiles[exportFilename] = blob;
|
|
537
|
+
|
|
538
|
+
// Extract original image ID from filename
|
|
539
|
+
const originalImageId = extractImageIdFromFilename(exportFilename);
|
|
540
|
+
if (originalImageId) {
|
|
541
|
+
imageIdMapping[exportFilename] = originalImageId;
|
|
542
|
+
}
|
|
543
|
+
} catch (err) {
|
|
544
|
+
console.error(`Failed to extract image ${filePath}:`, err);
|
|
545
|
+
}
|
|
546
|
+
})());
|
|
461
547
|
}
|
|
462
548
|
|
|
549
|
+
// Wait for all image extractions to complete
|
|
550
|
+
await Promise.all(imageExtractionPromises);
|
|
551
|
+
|
|
463
552
|
// Extract forensic manifest if present
|
|
464
553
|
let metadata: Record<string, unknown> | undefined;
|
|
465
554
|
const manifestFile = zip.file('FORENSIC_MANIFEST.json');
|
|
466
|
-
const auditTrailContent =
|
|
467
|
-
|
|
555
|
+
const auditTrailContent = isEncrypted
|
|
556
|
+
? undefined
|
|
557
|
+
: await zip.file('audit/case-audit-trail.json')?.async('text');
|
|
558
|
+
const auditSignatureContent = isEncrypted
|
|
559
|
+
? undefined
|
|
560
|
+
: await zip.file('audit/case-audit-signature.json')?.async('text');
|
|
468
561
|
|
|
469
562
|
if (manifestFile) {
|
|
470
563
|
const manifestContent = await manifestFile.async('text');
|
|
@@ -482,7 +575,11 @@ export async function parseImportZip(zipFile: File, currentUser: User): Promise<
|
|
|
482
575
|
},
|
|
483
576
|
metadata,
|
|
484
577
|
cleanedContent,
|
|
485
|
-
verificationPublicKeyPem
|
|
578
|
+
verificationPublicKeyPem,
|
|
579
|
+
encryptionManifest,
|
|
580
|
+
encryptedDataBase64,
|
|
581
|
+
encryptedImages: Object.keys(encryptedImages).length > 0 ? encryptedImages : undefined,
|
|
582
|
+
isEncrypted
|
|
486
583
|
};
|
|
487
584
|
|
|
488
585
|
} catch (error) {
|