@striae-org/striae 5.2.1 → 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/.env.example +2 -10
- package/README.md +5 -46
- package/app/components/actions/case-export/core-export.ts +5 -174
- package/app/components/actions/case-export/download-handlers.ts +84 -751
- package/app/components/actions/case-export/index.ts +6 -30
- package/app/components/actions/case-export/metadata-helpers.ts +0 -78
- package/app/components/actions/case-export/types-constants.ts +0 -43
- package/app/components/actions/case-import/confirmation-import.ts +75 -36
- 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 +160 -330
- package/app/components/actions/generate-pdf.ts +3 -2
- package/app/components/audit/user-audit-viewer.tsx +0 -19
- package/app/components/audit/viewer/audit-viewer-header.tsx +0 -33
- package/app/components/navbar/case-modals/archive-case-modal.tsx +1 -1
- 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/navbar/navbar.tsx +1 -1
- package/app/components/sidebar/case-import/case-import.module.css +35 -0
- package/app/components/sidebar/case-import/components/CasePreviewSection.tsx +51 -3
- package/app/components/sidebar/case-import/components/ConfirmationDialog.tsx +2 -4
- 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/components/sidebar/notes/class-details-shared.ts +2 -2
- package/app/components/toast/toast.module.css +36 -0
- package/app/components/toast/toast.tsx +6 -2
- package/app/components/user/manage-profile.tsx +4 -3
- package/app/config-example/config.json +1 -2
- package/app/root.tsx +0 -7
- package/app/routes/_index.tsx +1 -1
- package/app/routes/auth/login.example.tsx +22 -103
- package/app/routes/auth/login.tsx +22 -103
- package/app/routes/auth/route.ts +1 -1
- package/app/routes/striae/striae.tsx +117 -59
- package/app/services/firebase/index.ts +0 -3
- package/app/types/case.ts +1 -0
- package/app/types/export.ts +2 -2
- package/app/types/import.ts +10 -0
- package/app/utils/auth/index.ts +0 -1
- package/app/utils/data/permissions.ts +3 -2
- package/package.json +9 -16
- package/public/_headers +0 -4
- package/public/_routes.json +0 -1
- package/worker-configuration.d.ts +20 -17
- package/workers/audit-worker/src/audit-worker.example.ts +9 -806
- package/workers/audit-worker/src/config.ts +7 -0
- package/workers/audit-worker/src/crypto/data-at-rest.ts +410 -0
- package/workers/audit-worker/src/handlers/audit-routes.ts +125 -0
- package/workers/audit-worker/src/storage/audit-storage.ts +99 -0
- package/workers/audit-worker/src/types.ts +56 -0
- package/workers/audit-worker/worker-configuration.d.ts +1 -1
- package/workers/audit-worker/wrangler.jsonc.example +1 -1
- package/workers/data-worker/src/config.ts +11 -0
- package/workers/data-worker/src/data-worker.example.ts +21 -942
- package/workers/data-worker/src/handlers/decrypt-export.ts +118 -0
- package/workers/data-worker/src/handlers/signing.ts +174 -0
- package/workers/data-worker/src/handlers/storage-routes.ts +129 -0
- package/workers/data-worker/src/registry/key-registry.ts +368 -0
- package/workers/data-worker/src/types.ts +46 -0
- package/workers/data-worker/worker-configuration.d.ts +1 -1
- package/workers/data-worker/wrangler.jsonc.example +1 -1
- package/workers/image-worker/worker-configuration.d.ts +1 -1
- package/workers/image-worker/wrangler.jsonc.example +1 -1
- package/workers/pdf-worker/worker-configuration.d.ts +2 -3
- package/workers/pdf-worker/wrangler.jsonc.example +1 -1
- package/workers/user-worker/src/auth.ts +30 -0
- package/workers/user-worker/src/cleanup/account-deletion.ts +337 -0
- package/workers/user-worker/src/config.ts +4 -0
- package/workers/user-worker/src/encryption-utils.ts +25 -0
- package/workers/user-worker/src/firebase/admin.ts +152 -0
- package/workers/user-worker/src/handlers/user-routes.ts +242 -0
- package/workers/user-worker/src/registry/user-kv.ts +172 -0
- package/workers/user-worker/src/storage/user-records.ts +34 -0
- package/workers/user-worker/src/types.ts +106 -0
- package/workers/user-worker/src/user-worker.example.ts +18 -964
- package/workers/user-worker/worker-configuration.d.ts +4 -2
- package/workers/user-worker/wrangler.jsonc.example +12 -1
- package/wrangler.toml.example +1 -1
- package/app/components/actions/case-export/data-processing.ts +0 -223
- package/app/components/sidebar/case-export/case-export.module.css +0 -418
- package/app/components/sidebar/case-export/case-export.tsx +0 -310
- package/app/types/exceljs-bare.d.ts +0 -9
- package/app/utils/auth/auth.ts +0 -11
- package/public/.well-known/security.txt +0 -6
- package/public/favicon.ico +0 -0
- package/public/icon-256.png +0 -0
- package/public/icon-512.png +0 -0
- package/public/manifest.json +0 -39
- package/public/shortcut.png +0 -0
- package/public/social-image.png +0 -0
- package/public/vendor/exceljs.LICENSE +0 -22
- package/public/vendor/exceljs.bare.min.js +0 -45
- package/scripts/deploy-all.sh +0 -166
- package/scripts/deploy-config/modules/env-utils.sh +0 -322
- package/scripts/deploy-config/modules/keys.sh +0 -404
- package/scripts/deploy-config/modules/prompt.sh +0 -372
- package/scripts/deploy-config/modules/scaffolding.sh +0 -344
- package/scripts/deploy-config/modules/validation.sh +0 -365
- package/scripts/deploy-config.sh +0 -236
- package/scripts/deploy-pages-secrets.sh +0 -231
- package/scripts/deploy-pages.sh +0 -34
- package/scripts/deploy-primershear-emails.sh +0 -167
- package/scripts/deploy-worker-secrets.sh +0 -374
- package/scripts/dev.cjs +0 -23
- package/scripts/install-workers.sh +0 -88
- package/scripts/run-eslint.cjs +0 -43
- package/scripts/update-compatibility-dates.cjs +0 -124
- package/scripts/update-markdown-versions.cjs +0 -43
- package/workers/keys-worker/package.json +0 -18
- package/workers/keys-worker/src/keys.example.ts +0 -67
- package/workers/keys-worker/src/keys.ts +0 -67
- package/workers/keys-worker/worker-configuration.d.ts +0 -7447
- package/workers/keys-worker/wrangler.jsonc.example +0 -15
|
@@ -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,6 +96,21 @@ function extractImageIdFromFilename(exportFilename: string): string | null {
|
|
|
103
96
|
return filenameWithoutExt.substring(lastHyphenIndex + 1);
|
|
104
97
|
}
|
|
105
98
|
|
|
99
|
+
function isEncryptionManifest(value: unknown): value is EncryptionManifest {
|
|
100
|
+
if (!value || typeof value !== 'object') {
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
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
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
106
114
|
/**
|
|
107
115
|
* Preview case information from ZIP file without importing
|
|
108
116
|
*/
|
|
@@ -111,247 +119,135 @@ export async function previewCaseImport(zipFile: File, currentUser: User): Promi
|
|
|
111
119
|
|
|
112
120
|
try {
|
|
113
121
|
const zip = await JSZip.loadAsync(zipFile);
|
|
114
|
-
|
|
115
|
-
|
|
122
|
+
|
|
116
123
|
// Check if export is encrypted
|
|
117
124
|
const encryptionManifestFile = zip.file('ENCRYPTION_MANIFEST.json');
|
|
118
125
|
if (encryptionManifestFile) {
|
|
119
|
-
|
|
120
|
-
// Return an encrypted preview that requires decryption during import
|
|
126
|
+
let parsedManifest: unknown;
|
|
121
127
|
try {
|
|
122
128
|
const manifestContent = await encryptionManifestFile.async('text');
|
|
123
|
-
JSON.parse(manifestContent);
|
|
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
|
-
};
|
|
129
|
+
parsedManifest = JSON.parse(manifestContent);
|
|
160
130
|
} catch (error) {
|
|
161
131
|
throw new Error(
|
|
162
132
|
`Encrypted export detected but encryption manifest is invalid: ${error instanceof Error ? error.message : 'Unknown error'}`
|
|
163
133
|
);
|
|
164
134
|
}
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
// First, validate hash if forensic metadata exists
|
|
168
|
-
let hashValid: boolean | undefined = undefined;
|
|
169
|
-
let hashError: string | undefined = undefined;
|
|
170
|
-
let validationDetails: CaseImportPreview['validationDetails'];
|
|
171
|
-
|
|
172
|
-
// Find the main data file (JSON or CSV)
|
|
173
|
-
const dataFiles = Object.keys(zip.files).filter(name =>
|
|
174
|
-
name.endsWith('_data.json') || name.endsWith('_data.csv')
|
|
175
|
-
);
|
|
176
|
-
|
|
177
|
-
if (dataFiles.length === 0) {
|
|
178
|
-
throw new Error('No valid data file found in ZIP archive');
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
if (dataFiles.length > 1) {
|
|
182
|
-
throw new Error('Multiple data files found in ZIP archive');
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
const dataFileName = dataFiles[0];
|
|
186
|
-
const isJsonFormat = dataFileName.endsWith('.json');
|
|
187
|
-
|
|
188
|
-
if (!isJsonFormat) {
|
|
189
|
-
throw new Error('CSV import not yet supported. Please use JSON format.');
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
// Extract and parse case data
|
|
193
|
-
const dataContent = await zip.file(dataFileName)?.async('text');
|
|
194
|
-
if (!dataContent) {
|
|
195
|
-
throw new Error('Failed to read data file from ZIP');
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
// Handle forensic protection warnings in JSON
|
|
199
|
-
const cleanedContent = removeForensicWarning(dataContent);
|
|
200
|
-
|
|
201
|
-
// Validate forensic manifest integrity
|
|
202
|
-
const manifestFile = zip.file('FORENSIC_MANIFEST.json');
|
|
203
|
-
|
|
204
|
-
if (manifestFile) {
|
|
205
|
-
try {
|
|
206
|
-
let forensicManifest: SignedForensicManifest | null = null;
|
|
207
|
-
|
|
208
|
-
// Get forensic manifest from dedicated file
|
|
209
|
-
const manifestContent = await manifestFile.async('text');
|
|
210
|
-
forensicManifest = JSON.parse(manifestContent) as SignedForensicManifest;
|
|
211
|
-
|
|
212
|
-
if (forensicManifest) {
|
|
213
|
-
// Extract image files for comprehensive validation
|
|
214
|
-
const imageFiles: { [filename: string]: Blob } = {};
|
|
215
|
-
const imagesFolder = zip.folder('images');
|
|
216
|
-
if (imagesFolder) {
|
|
217
|
-
await Promise.all(Object.keys(imagesFolder.files).map(async (path) => {
|
|
218
|
-
if (path.startsWith('images/') && !path.endsWith('/')) {
|
|
219
|
-
const filename = path.replace('images/', '');
|
|
220
|
-
const file = zip.file(path);
|
|
221
|
-
if (file) {
|
|
222
|
-
const blob = await file.async('blob');
|
|
223
|
-
imageFiles[filename] = blob;
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
|
-
}));
|
|
227
|
-
}
|
|
228
135
|
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
forensicManifest,
|
|
233
|
-
verificationPublicKeyPem,
|
|
234
|
-
bundledAuditFiles: {
|
|
235
|
-
auditTrailContent: await zip.file('audit/case-audit-trail.json')?.async('text'),
|
|
236
|
-
auditSignatureContent: await zip.file('audit/case-audit-signature.json')?.async('text')
|
|
237
|
-
}
|
|
238
|
-
});
|
|
136
|
+
if (!isEncryptionManifest(parsedManifest)) {
|
|
137
|
+
throw new Error('Encrypted export manifest is missing required fields.');
|
|
138
|
+
}
|
|
239
139
|
|
|
240
|
-
|
|
241
|
-
const validation = casePackageResult.integrityResult;
|
|
242
|
-
const bundledAuditVerification = casePackageResult.bundledAuditVerification;
|
|
140
|
+
const encryptionManifest = parsedManifest;
|
|
243
141
|
|
|
244
|
-
|
|
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
|
+
}
|
|
245
150
|
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
if (!validation.isValid) {
|
|
252
|
-
errorParts.push('Integrity validation failed.');
|
|
253
|
-
}
|
|
254
|
-
if (bundledAuditVerification) {
|
|
255
|
-
errorParts.push(bundledAuditVerification.message);
|
|
256
|
-
}
|
|
257
|
-
hashError = errorParts.length > 0 ? errorParts.join(' ') : 'Validation failed.';
|
|
258
|
-
}
|
|
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
|
+
}
|
|
259
156
|
|
|
260
|
-
|
|
261
|
-
if (!signatureResult.isValid) {
|
|
262
|
-
integrityErrors.push(`Signature validation failed: ${signatureResult.error || 'Unknown signature error'}`);
|
|
263
|
-
}
|
|
264
|
-
if (bundledAuditVerification) {
|
|
265
|
-
integrityErrors.push(bundledAuditVerification.message);
|
|
266
|
-
}
|
|
157
|
+
const encryptedDataBase64 = uint8ArrayToBase64Url(encryptedDataBytes);
|
|
267
158
|
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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
|
+
}
|
|
291
200
|
}
|
|
292
|
-
} catch {
|
|
293
|
-
hashError = 'Validation failed.';
|
|
294
|
-
hashValid = false;
|
|
295
|
-
|
|
296
|
-
validationDetails = {
|
|
297
|
-
hasForensicManifest: true,
|
|
298
|
-
validationSummary: 'Validation failed due to metadata parsing error',
|
|
299
|
-
integrityErrors: [hashError]
|
|
300
|
-
};
|
|
301
201
|
}
|
|
302
|
-
} else {
|
|
303
|
-
// No forensic manifest found
|
|
304
|
-
validationDetails = {
|
|
305
|
-
hasForensicManifest: false,
|
|
306
|
-
validationSummary: 'No forensic manifest found - integrity cannot be verified',
|
|
307
|
-
integrityErrors: []
|
|
308
|
-
};
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
const parsedCaseData = JSON.parse(cleanedContent) as unknown;
|
|
312
|
-
const caseData: CaseExportData = parsedCaseData as CaseExportData;
|
|
313
|
-
|
|
314
|
-
// Validate case data structure
|
|
315
|
-
if (!caseData.metadata?.caseNumber) {
|
|
316
|
-
throw new Error('Invalid case data: missing case number');
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
if (!validateCaseNumber(caseData.metadata.caseNumber)) {
|
|
320
|
-
throw new Error(`Invalid case number format: ${caseData.metadata.caseNumber}`);
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
const isArchivedExport = isArchivedExportData(parsedCaseData);
|
|
324
202
|
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
if (!
|
|
333
|
-
|
|
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
|
+
);
|
|
334
219
|
}
|
|
335
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
|
+
};
|
|
336
245
|
}
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
exportedByName: caseData.metadata.exportedByName || null,
|
|
343
|
-
exportedByCompany: caseData.metadata.exportedByCompany || null,
|
|
344
|
-
exportedByBadgeId: caseData.metadata.exportedByBadgeId || null,
|
|
345
|
-
exportDate: caseData.metadata.exportDate,
|
|
346
|
-
totalFiles,
|
|
347
|
-
caseCreatedDate: caseData.metadata.caseCreatedDate,
|
|
348
|
-
hasAnnotations: false, // We'll need to determine this during parsing if needed
|
|
349
|
-
validationSummary: hashValid ? 'Validation passed' : 'Validation failed',
|
|
350
|
-
hashValid,
|
|
351
|
-
hashError,
|
|
352
|
-
validationDetails
|
|
353
|
-
};
|
|
354
|
-
|
|
246
|
+
|
|
247
|
+
throw new Error(
|
|
248
|
+
'This case package is not encrypted. Only encrypted case packages exported from Striae can be imported.'
|
|
249
|
+
);
|
|
250
|
+
|
|
355
251
|
} catch (error) {
|
|
356
252
|
console.error('Error previewing case import:', error);
|
|
357
253
|
throw new Error(`Failed to preview case: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
@@ -361,9 +257,8 @@ export async function previewCaseImport(zipFile: File, currentUser: User): Promi
|
|
|
361
257
|
/**
|
|
362
258
|
* Parse and validate ZIP file contents for case import
|
|
363
259
|
*/
|
|
364
|
-
export async function parseImportZip(zipFile: File
|
|
260
|
+
export async function parseImportZip(zipFile: File): Promise<{
|
|
365
261
|
caseData: CaseExportData;
|
|
366
|
-
imageFiles: { [filename: string]: Blob };
|
|
367
262
|
imageIdMapping: { [exportFilename: string]: string }; // exportFilename -> originalImageId
|
|
368
263
|
isArchivedExport: boolean;
|
|
369
264
|
bundledAuditFiles?: {
|
|
@@ -377,6 +272,7 @@ export async function parseImportZip(zipFile: File, currentUser: User): Promise<
|
|
|
377
272
|
encryptedDataBase64?: string; // Optional: encrypted data file content (base64url)
|
|
378
273
|
encryptedImages?: { [filename: string]: string }; // Optional: encrypted image files (filename -> base64url)
|
|
379
274
|
isEncrypted?: boolean;
|
|
275
|
+
dataFileName?: string; // The encrypted data file name (leaf), for post-decrypt case number validation
|
|
380
276
|
}> {
|
|
381
277
|
// Dynamic import of JSZip to avoid bundle size issues
|
|
382
278
|
const JSZip = (await import('jszip')).default;
|
|
@@ -399,25 +295,28 @@ export async function parseImportZip(zipFile: File, currentUser: User): Promise<
|
|
|
399
295
|
}
|
|
400
296
|
|
|
401
297
|
const dataFileName = dataFiles[0];
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
// Check for encryption manifest first
|
|
298
|
+
|
|
299
|
+
// Only encrypted case packages are supported
|
|
405
300
|
const encryptionManifestFile = zip.file('ENCRYPTION_MANIFEST.json');
|
|
406
|
-
|
|
407
|
-
|
|
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;
|
|
408
309
|
const encryptedImages: { [filename: string]: string } = {};
|
|
409
|
-
|
|
310
|
+
const imageIdMapping: { [exportFilename: string]: string } = {};
|
|
311
|
+
const isEncrypted = true;
|
|
410
312
|
|
|
411
|
-
// Initialize variables before if-else to ensure scope
|
|
412
313
|
let caseData: CaseExportData;
|
|
413
314
|
let parsedCaseData: unknown;
|
|
414
|
-
|
|
315
|
+
const cleanedContent = '';
|
|
415
316
|
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
encryptionManifest = JSON.parse(manifestContent) as Record<string, unknown>;
|
|
420
|
-
isEncrypted = true;
|
|
317
|
+
try {
|
|
318
|
+
const manifestContent = await encryptionManifestFile.async('text');
|
|
319
|
+
encryptionManifest = JSON.parse(manifestContent) as Record<string, unknown>;
|
|
421
320
|
|
|
422
321
|
// Extract the encrypted data file
|
|
423
322
|
const dataContent = await zip.file(dataFileName)?.async('uint8array');
|
|
@@ -447,7 +346,14 @@ export async function parseImportZip(zipFile: File, currentUser: User): Promise<
|
|
|
447
346
|
}
|
|
448
347
|
|
|
449
348
|
const filename = isImageFile ? filePath.replace(/^images\//, '') : filePath;
|
|
450
|
-
|
|
349
|
+
|
|
350
|
+
if (isImageFile) {
|
|
351
|
+
const originalImageId = extractImageIdFromFilename(filename);
|
|
352
|
+
if (originalImageId) {
|
|
353
|
+
imageIdMapping[filename] = originalImageId;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
451
357
|
encryptedImagePromises.push((async () => {
|
|
452
358
|
try {
|
|
453
359
|
const encryptedBlob = await file.async('uint8array');
|
|
@@ -468,96 +374,20 @@ export async function parseImportZip(zipFile: File, currentUser: User): Promise<
|
|
|
468
374
|
|
|
469
375
|
// For encrypted exports, data file will be processed after decryption
|
|
470
376
|
// Set placeholder values that will be replaced after decryption
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
} catch (error) {
|
|
476
|
-
throw new Error(`Failed to process encrypted export: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
477
|
-
}
|
|
478
|
-
} else {
|
|
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
|
-
}
|
|
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'}`);
|
|
493
381
|
}
|
|
494
|
-
|
|
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
382
|
|
|
502
|
-
if (!validateCaseNumber(caseData.metadata.caseNumber)) {
|
|
503
|
-
throw new Error(`Invalid case number format: ${caseData.metadata.caseNumber}`);
|
|
504
|
-
}
|
|
505
|
-
}
|
|
506
|
-
|
|
507
383
|
const isArchivedExport = isArchivedExportData(parsedCaseData);
|
|
508
384
|
|
|
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);
|
|
512
|
-
}
|
|
513
|
-
|
|
514
|
-
// Extract image files and create ID mapping - iterate through zip.files directly
|
|
515
|
-
const imageFiles: { [filename: string]: Blob } = {};
|
|
516
|
-
const imageIdMapping: { [exportFilename: string]: string } = {};
|
|
517
|
-
|
|
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;
|
|
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
|
-
})());
|
|
547
|
-
}
|
|
548
|
-
|
|
549
|
-
// Wait for all image extractions to complete
|
|
550
|
-
await Promise.all(imageExtractionPromises);
|
|
551
|
-
|
|
552
385
|
// Extract forensic manifest if present
|
|
553
386
|
let metadata: Record<string, unknown> | undefined;
|
|
554
387
|
const manifestFile = zip.file('FORENSIC_MANIFEST.json');
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
const auditSignatureContent = isEncrypted
|
|
559
|
-
? undefined
|
|
560
|
-
: 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;
|
|
561
391
|
|
|
562
392
|
if (manifestFile) {
|
|
563
393
|
const manifestContent = await manifestFile.async('text');
|
|
@@ -566,7 +396,6 @@ export async function parseImportZip(zipFile: File, currentUser: User): Promise<
|
|
|
566
396
|
|
|
567
397
|
return {
|
|
568
398
|
caseData,
|
|
569
|
-
imageFiles,
|
|
570
399
|
imageIdMapping,
|
|
571
400
|
isArchivedExport,
|
|
572
401
|
bundledAuditFiles: {
|
|
@@ -579,7 +408,8 @@ export async function parseImportZip(zipFile: File, currentUser: User): Promise<
|
|
|
579
408
|
encryptionManifest,
|
|
580
409
|
encryptedDataBase64,
|
|
581
410
|
encryptedImages: Object.keys(encryptedImages).length > 0 ? encryptedImages : undefined,
|
|
582
|
-
isEncrypted
|
|
411
|
+
isEncrypted,
|
|
412
|
+
dataFileName
|
|
583
413
|
};
|
|
584
414
|
|
|
585
415
|
} catch (error) {
|
|
@@ -2,6 +2,7 @@ import { type AnnotationData } from '~/types/annotations';
|
|
|
2
2
|
import { auditService } from '~/services/audit';
|
|
3
3
|
import type { User } from 'firebase/auth';
|
|
4
4
|
import { fetchPdfApi } from '~/utils/api';
|
|
5
|
+
import type { ToastType } from '~/components/toast/toast';
|
|
5
6
|
|
|
6
7
|
interface GeneratePDFParams {
|
|
7
8
|
user: User;
|
|
@@ -16,7 +17,7 @@ interface GeneratePDFParams {
|
|
|
16
17
|
annotationData: AnnotationData | null;
|
|
17
18
|
activeAnnotations: Set<string>;
|
|
18
19
|
setIsGeneratingPDF: (isGenerating: boolean) => void;
|
|
19
|
-
setToastType: (type:
|
|
20
|
+
setToastType: (type: ToastType) => void;
|
|
20
21
|
setToastMessage: (message: string) => void;
|
|
21
22
|
setShowToast: (show: boolean) => void;
|
|
22
23
|
setToastDuration?: (duration: number) => void;
|
|
@@ -90,7 +91,7 @@ export const generatePDF = async ({
|
|
|
90
91
|
const startTime = Date.now();
|
|
91
92
|
|
|
92
93
|
// Show generating toast immediately with duration 0 (stays until manually closed or completion)
|
|
93
|
-
setToastType('
|
|
94
|
+
setToastType('loading');
|
|
94
95
|
setToastMessage('Generating PDF report... This may take up to a minute.');
|
|
95
96
|
if (setToastDuration) setToastDuration(0);
|
|
96
97
|
setShowToast(true);
|
|
@@ -9,7 +9,6 @@ import { AuditEntriesList } from './viewer/audit-entries-list';
|
|
|
9
9
|
import { summarizeAuditEntries } from './viewer/audit-viewer-utils';
|
|
10
10
|
import { useAuditViewerData } from './viewer/use-audit-viewer-data';
|
|
11
11
|
import { useAuditViewerFilters } from './viewer/use-audit-viewer-filters';
|
|
12
|
-
import { useAuditViewerExport } from './viewer/use-audit-viewer-export';
|
|
13
12
|
import styles from './user-audit.module.css';
|
|
14
13
|
|
|
15
14
|
interface UserAuditViewerProps {
|
|
@@ -56,8 +55,6 @@ export const UserAuditViewer = ({ isOpen, onClose, caseNumber, title }: UserAudi
|
|
|
56
55
|
userData,
|
|
57
56
|
loading,
|
|
58
57
|
error,
|
|
59
|
-
setError,
|
|
60
|
-
auditTrail,
|
|
61
58
|
isArchivedReadOnlyCase,
|
|
62
59
|
bundledAuditWarning,
|
|
63
60
|
loadAuditData
|
|
@@ -73,18 +70,6 @@ export const UserAuditViewer = ({ isOpen, onClose, caseNumber, title }: UserAudi
|
|
|
73
70
|
const filteredEntries = useMemo(() => getFilteredEntries(auditEntries), [auditEntries, getFilteredEntries]);
|
|
74
71
|
const auditSummary = useMemo(() => summarizeAuditEntries(auditEntries), [auditEntries]);
|
|
75
72
|
|
|
76
|
-
const {
|
|
77
|
-
handleExportCSV,
|
|
78
|
-
handleExportJSON,
|
|
79
|
-
handleGenerateReport
|
|
80
|
-
} = useAuditViewerExport({
|
|
81
|
-
user,
|
|
82
|
-
effectiveCaseNumber,
|
|
83
|
-
filteredEntries,
|
|
84
|
-
auditTrail,
|
|
85
|
-
setError
|
|
86
|
-
});
|
|
87
|
-
|
|
88
73
|
const {
|
|
89
74
|
requestClose,
|
|
90
75
|
overlayProps
|
|
@@ -106,10 +91,6 @@ export const UserAuditViewer = ({ isOpen, onClose, caseNumber, title }: UserAudi
|
|
|
106
91
|
<div className={styles.modal}>
|
|
107
92
|
<AuditViewerHeader
|
|
108
93
|
title={title || (effectiveCaseNumber ? `Audit Trail - Case ${effectiveCaseNumber}` : 'My Audit Trail')}
|
|
109
|
-
hasEntries={auditEntries.length > 0}
|
|
110
|
-
onExportCSV={handleExportCSV}
|
|
111
|
-
onExportJSON={handleExportJSON}
|
|
112
|
-
onGenerateReport={handleGenerateReport}
|
|
113
94
|
onClose={requestClose}
|
|
114
95
|
/>
|
|
115
96
|
|