@striae-org/striae 4.0.3 → 4.2.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 +8 -0
- package/app/components/actions/case-export/core-export.ts +14 -8
- package/app/components/actions/case-export/data-processing.ts +1 -0
- package/app/components/actions/case-export/download-handlers.ts +7 -0
- package/app/components/actions/case-export/metadata-helpers.ts +2 -1
- package/app/components/actions/case-import/confirmation-import.ts +12 -2
- package/app/components/actions/case-import/orchestrator.ts +78 -32
- package/app/components/actions/case-import/storage-operations.ts +97 -8
- package/app/components/actions/case-import/zip-processing.ts +159 -86
- package/app/components/actions/case-manage.ts +430 -8
- package/app/components/actions/confirm-export.ts +13 -4
- package/app/components/actions/generate-pdf.ts +10 -2
- package/app/components/actions/image-manage.ts +77 -44
- package/app/components/audit/user-audit-viewer.tsx +137 -945
- package/app/components/audit/user-audit.module.css +41 -0
- package/app/components/audit/viewer/audit-activity-summary.tsx +52 -0
- package/app/components/audit/viewer/audit-entries-list.tsx +207 -0
- package/app/components/audit/viewer/audit-filters-panel.tsx +307 -0
- package/app/components/audit/viewer/audit-user-info-card.tsx +44 -0
- package/app/components/audit/viewer/audit-viewer-header.tsx +55 -0
- package/app/components/audit/viewer/audit-viewer-utils.ts +123 -0
- package/app/components/audit/viewer/types.ts +1 -0
- package/app/components/audit/viewer/use-audit-viewer-data.ts +186 -0
- package/app/components/audit/viewer/use-audit-viewer-export.ts +176 -0
- package/app/components/audit/viewer/use-audit-viewer-filters.ts +141 -0
- package/app/components/auth/mfa-enrollment.module.css +13 -5
- package/app/components/auth/mfa-verification.module.css +13 -5
- package/app/components/canvas/box-annotations/box-annotations.module.css +22 -18
- package/app/components/canvas/box-annotations/box-annotations.tsx +15 -0
- package/app/components/canvas/canvas.module.css +64 -54
- package/app/components/canvas/canvas.tsx +17 -16
- package/app/components/canvas/confirmation/confirmation.module.css +1 -0
- package/app/components/canvas/confirmation/confirmation.tsx +17 -47
- package/app/components/navbar/case-modals/archive-case-modal.module.css +110 -0
- package/app/components/navbar/case-modals/archive-case-modal.tsx +129 -0
- package/app/components/navbar/case-modals/open-case-modal.module.css +81 -0
- package/app/components/navbar/case-modals/open-case-modal.tsx +120 -0
- package/app/components/navbar/case-modals/rename-case-modal.module.css +81 -0
- package/app/components/navbar/case-modals/rename-case-modal.tsx +107 -0
- package/app/components/navbar/navbar.module.css +447 -0
- package/app/components/navbar/navbar.tsx +377 -0
- package/app/components/public-signing-key-modal/public-signing-key-modal.module.css +2 -0
- package/app/components/public-signing-key-modal/public-signing-key-modal.tsx +21 -51
- package/app/components/sidebar/case-export/case-export.module.css +1 -0
- package/app/components/sidebar/case-export/case-export.tsx +14 -77
- package/app/components/sidebar/case-import/case-import.module.css +25 -0
- package/app/components/sidebar/case-import/case-import.tsx +64 -40
- package/app/components/sidebar/case-import/components/CasePreviewSection.tsx +20 -1
- package/app/components/sidebar/case-import/components/ConfirmationDialog.tsx +15 -0
- package/app/components/sidebar/cases/case-sidebar.tsx +25 -519
- package/app/components/sidebar/cases/cases-modal.module.css +45 -9
- package/app/components/sidebar/cases/cases-modal.tsx +16 -16
- package/app/components/sidebar/cases/cases.module.css +62 -21
- package/app/components/sidebar/files/files-modal.module.css +46 -10
- package/app/components/sidebar/files/files-modal.tsx +22 -23
- package/app/components/sidebar/notes/notes-editor-modal.module.css +49 -0
- package/app/components/sidebar/notes/notes-editor-modal.tsx +66 -0
- package/app/components/sidebar/notes/notes-modal.tsx +18 -17
- package/app/components/sidebar/notes/notes-sidebar.tsx +199 -113
- package/app/components/sidebar/notes/notes.module.css +155 -0
- package/app/components/sidebar/sidebar-container.tsx +15 -28
- package/app/components/sidebar/sidebar.module.css +7 -71
- package/app/components/sidebar/sidebar.tsx +24 -125
- package/app/components/sidebar/upload/image-upload-zone.module.css +13 -13
- package/app/components/toast/toast.module.css +2 -1
- package/app/components/toast/toast.tsx +16 -11
- package/app/components/user/delete-account.tsx +10 -31
- package/app/components/user/inactivity-warning.module.css +9 -6
- package/app/components/user/inactivity-warning.tsx +15 -2
- package/app/components/user/manage-profile.module.css +2 -0
- package/app/components/user/manage-profile.tsx +108 -40
- package/app/hooks/useOverlayDismiss.ts +116 -0
- package/app/routes/auth/login.example.tsx +19 -8
- package/app/routes/auth/login.tsx +785 -774
- package/app/routes/auth/passwordReset.module.css +23 -13
- package/app/routes/striae/striae.module.css +10 -3
- package/app/routes/striae/striae.tsx +477 -31
- package/app/routes.ts +7 -0
- package/app/services/audit/audit-export-csv.ts +2 -0
- package/app/services/audit/audit.service.ts +202 -32
- package/app/services/audit/builders/audit-entry-builder.ts +2 -1
- package/app/services/audit/builders/audit-event-builders-case-file.ts +43 -0
- package/app/services/audit/builders/audit-event-builders-user-security.ts +4 -2
- package/app/services/audit/builders/audit-event-builders-workflow.ts +8 -0
- package/app/services/audit/builders/index.ts +1 -0
- package/app/types/audit.ts +5 -2
- package/app/types/case.ts +29 -0
- package/app/types/import.ts +3 -0
- package/app/types/user.ts +1 -0
- package/app/utils/data/permissions.ts +17 -1
- package/app/utils/forensics/audit-export-signature.ts +5 -1
- package/app/utils/forensics/confirmation-signature.ts +3 -0
- package/app/utils/forensics/export-verification.ts +497 -22
- package/functions/api/pdf/[[path]].ts +32 -1
- package/load-context.ts +9 -0
- package/package.json +6 -2
- package/primershear.emails.example +6 -0
- package/scripts/deploy-pages-secrets.sh +6 -0
- package/scripts/deploy-primershear-emails.sh +167 -0
- package/worker-configuration.d.ts +7493 -7491
- package/workers/audit-worker/worker-configuration.d.ts +7448 -11323
- package/workers/audit-worker/wrangler.jsonc.example +1 -1
- package/workers/data-worker/worker-configuration.d.ts +7448 -11323
- package/workers/data-worker/wrangler.jsonc.example +1 -1
- package/workers/image-worker/worker-configuration.d.ts +7447 -11322
- package/workers/image-worker/wrangler.jsonc.example +1 -1
- package/workers/keys-worker/worker-configuration.d.ts +7447 -11322
- package/workers/keys-worker/wrangler.jsonc.example +1 -1
- package/workers/pdf-worker/src/formats/format-striae.ts +8 -7
- package/workers/pdf-worker/src/pdf-worker.example.ts +3 -0
- package/workers/pdf-worker/src/report-types.ts +3 -0
- package/workers/pdf-worker/worker-configuration.d.ts +7448 -11323
- package/workers/pdf-worker/wrangler.jsonc.example +1 -1
- package/workers/user-worker/src/user-worker.example.ts +6 -1
- package/workers/user-worker/worker-configuration.d.ts +7448 -11323
- package/workers/user-worker/wrangler.jsonc.example +1 -1
- package/wrangler.toml.example +1 -1
- package/public/.well-known/keybase.txt +0 -56
package/.env.example
CHANGED
|
@@ -101,3 +101,11 @@ BROWSER_API_TOKEN=your_cloudflare_browser_rendering_api_token_here
|
|
|
101
101
|
# □ Configure KV binding in user-worker/wrangler.jsonc
|
|
102
102
|
# □ Configure R2 binding in data-worker/wrangler.jsonc
|
|
103
103
|
# □ Configure R2 binding in audit-worker/wrangler.jsonc
|
|
104
|
+
|
|
105
|
+
# ================================
|
|
106
|
+
# PRIMERSHEAR PDF FORMAT
|
|
107
|
+
# ================================
|
|
108
|
+
# Comma-separated list of email addresses that will receive the primershear PDF format.
|
|
109
|
+
# Leave empty to disable the feature. Never commit this value to source control.
|
|
110
|
+
# Example: PRIMERSHEAR_EMAILS=analyst@org.com,user2@org.com
|
|
111
|
+
PRIMERSHEAR_EMAILS=
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import type { User } from 'firebase/auth';
|
|
2
2
|
import { type AnnotationData, type CaseExportData, type AllCasesExportData, type ExportOptions } from '~/types';
|
|
3
|
+
import { getCaseData } from '~/utils/data';
|
|
3
4
|
import { fetchFiles } from '../image-manage';
|
|
4
5
|
import { getNotes } from '../notes-manage';
|
|
5
|
-
import {
|
|
6
|
+
import { validateCaseNumber, listCases } from '../case-manage';
|
|
6
7
|
import { getUserExportMetadata } from './metadata-helpers';
|
|
7
8
|
|
|
8
9
|
/**
|
|
@@ -104,9 +105,9 @@ export async function exportAllCases(
|
|
|
104
105
|
// Get case creation date even for failed exports
|
|
105
106
|
let caseCreatedDate = new Date().toISOString(); // fallback
|
|
106
107
|
try {
|
|
107
|
-
const
|
|
108
|
-
if (
|
|
109
|
-
caseCreatedDate =
|
|
108
|
+
const caseData = await getCaseData(user, caseNumber);
|
|
109
|
+
if (caseData?.createdAt) {
|
|
110
|
+
caseCreatedDate = caseData.createdAt;
|
|
110
111
|
}
|
|
111
112
|
} catch {
|
|
112
113
|
// Use fallback date if case lookup fails
|
|
@@ -200,9 +201,9 @@ export async function exportCaseData(
|
|
|
200
201
|
throw new Error('Invalid case number format');
|
|
201
202
|
}
|
|
202
203
|
|
|
203
|
-
// Check if case exists
|
|
204
|
-
const
|
|
205
|
-
if (!
|
|
204
|
+
// Check if case exists and is accessible (supports regular and read-only/archived cases)
|
|
205
|
+
const caseData = await getCaseData(user, caseNumber);
|
|
206
|
+
if (!caseData) {
|
|
206
207
|
throw new Error(`Case "${caseNumber}" does not exist`);
|
|
207
208
|
}
|
|
208
209
|
|
|
@@ -296,7 +297,12 @@ export async function exportCaseData(
|
|
|
296
297
|
const exportData: CaseExportData = {
|
|
297
298
|
metadata: {
|
|
298
299
|
caseNumber,
|
|
299
|
-
caseCreatedDate:
|
|
300
|
+
caseCreatedDate: caseData.createdAt,
|
|
301
|
+
archived: caseData.archived,
|
|
302
|
+
archivedAt: caseData.archivedAt,
|
|
303
|
+
archivedBy: caseData.archivedBy,
|
|
304
|
+
archivedByDisplay: caseData.archivedByDisplay,
|
|
305
|
+
archiveReason: caseData.archiveReason,
|
|
300
306
|
exportDate: new Date().toISOString(),
|
|
301
307
|
...userMetadata,
|
|
302
308
|
striaeExportSchemaVersion: '1.0',
|
|
@@ -72,6 +72,7 @@ export function generateMetadataRows(exportData: CaseExportData): TabularCell[][
|
|
|
72
72
|
['Exported By (UID)', exportData.metadata.exportedByUid || 'N/A'],
|
|
73
73
|
['Exported By (Name)', exportData.metadata.exportedByName || 'N/A'],
|
|
74
74
|
['Exported By (Company)', exportData.metadata.exportedByCompany || 'N/A'],
|
|
75
|
+
['Exported By (Badge/ID)', exportData.metadata.exportedByBadgeId || 'N/A'],
|
|
75
76
|
['Striae Export Schema Version', exportData.metadata.striaeExportSchemaVersion],
|
|
76
77
|
['Total Files', exportData.metadata.totalFiles.toString()],
|
|
77
78
|
[''],
|
|
@@ -264,6 +264,7 @@ export async function downloadAllCasesAsCSV(user: User, exportData: AllCasesExpo
|
|
|
264
264
|
['Exported By (UID)', exportData.metadata.exportedByUid || 'N/A'],
|
|
265
265
|
['Exported By (Name)', exportData.metadata.exportedByName || 'N/A'],
|
|
266
266
|
['Exported By (Company)', exportData.metadata.exportedByCompany || 'N/A'],
|
|
267
|
+
['Exported By (Badge/ID)', exportData.metadata.exportedByBadgeId || 'N/A'],
|
|
267
268
|
['Striae Export Schema Version', '1.0'],
|
|
268
269
|
['Total Cases', exportData.cases.length],
|
|
269
270
|
['Successful Exports', exportData.cases.filter(c => !c.summary?.exportError).length],
|
|
@@ -291,6 +292,7 @@ export async function downloadAllCasesAsCSV(user: User, exportData: AllCasesExpo
|
|
|
291
292
|
'Exported By (UID)',
|
|
292
293
|
'Exported By (Name)',
|
|
293
294
|
'Exported By (Company)',
|
|
295
|
+
'Exported By (Badge/ID)',
|
|
294
296
|
'Schema Version',
|
|
295
297
|
'Total Files',
|
|
296
298
|
'Files with Annotations',
|
|
@@ -312,6 +314,7 @@ export async function downloadAllCasesAsCSV(user: User, exportData: AllCasesExpo
|
|
|
312
314
|
caseData.metadata.exportedByUid || 'N/A',
|
|
313
315
|
caseData.metadata.exportedByName || 'N/A',
|
|
314
316
|
caseData.metadata.exportedByCompany || 'N/A',
|
|
317
|
+
caseData.metadata.exportedByBadgeId || 'N/A',
|
|
315
318
|
caseData.metadata.striaeExportSchemaVersion,
|
|
316
319
|
caseData.metadata.totalFiles,
|
|
317
320
|
caseData.summary?.filesWithAnnotations || 0,
|
|
@@ -944,6 +947,7 @@ Exported By (Email): ${exportData.metadata.exportedBy || 'N/A'}
|
|
|
944
947
|
Exported By (UID): ${exportData.metadata.exportedByUid || 'N/A'}
|
|
945
948
|
Exported By (Name): ${exportData.metadata.exportedByName || 'N/A'}
|
|
946
949
|
Exported By (Company): ${exportData.metadata.exportedByCompany || 'N/A'}
|
|
950
|
+
Exported By (Badge/ID): ${exportData.metadata.exportedByBadgeId || 'N/A'}
|
|
947
951
|
Striae Export Schema Version: ${exportData.metadata.striaeExportSchemaVersion}
|
|
948
952
|
|
|
949
953
|
Summary:
|
|
@@ -1005,6 +1009,9 @@ async function generateJSONContent(
|
|
|
1005
1009
|
if (jsonData.metadata.exportedByCompany) {
|
|
1006
1010
|
jsonData.metadata.exportedByCompany = '[User Info Excluded]';
|
|
1007
1011
|
}
|
|
1012
|
+
if (jsonData.metadata.exportedByBadgeId) {
|
|
1013
|
+
jsonData.metadata.exportedByBadgeId = '[User Info Excluded]';
|
|
1014
|
+
}
|
|
1008
1015
|
}
|
|
1009
1016
|
|
|
1010
1017
|
const jsonString = JSON.stringify(jsonData, null, 2);
|
|
@@ -12,7 +12,8 @@ export async function getUserExportMetadata(user: User) {
|
|
|
12
12
|
exportedBy: user.email,
|
|
13
13
|
exportedByUid: userData.uid,
|
|
14
14
|
exportedByName: `${userData.firstName} ${userData.lastName}`.trim(),
|
|
15
|
-
exportedByCompany: userData.company
|
|
15
|
+
exportedByCompany: userData.company,
|
|
16
|
+
...(userData.badgeId ? { exportedByBadgeId: userData.badgeId } : {})
|
|
16
17
|
};
|
|
17
18
|
}
|
|
18
19
|
} catch (error) {
|
|
@@ -14,6 +14,7 @@ interface CaseDataFile {
|
|
|
14
14
|
interface CaseDataResponse {
|
|
15
15
|
files?: CaseDataFile[];
|
|
16
16
|
originalImageIds?: Record<string, string>;
|
|
17
|
+
archived?: boolean;
|
|
17
18
|
}
|
|
18
19
|
|
|
19
20
|
type AnnotationImportData = Record<string, unknown> & {
|
|
@@ -123,6 +124,10 @@ export async function importConfirmationData(
|
|
|
123
124
|
}
|
|
124
125
|
|
|
125
126
|
const caseData = await caseResponse.json() as CaseDataResponse;
|
|
127
|
+
|
|
128
|
+
if (caseData.archived) {
|
|
129
|
+
throw new Error('Cannot import confirmations into an archived case.');
|
|
130
|
+
}
|
|
126
131
|
|
|
127
132
|
// Build mapping from original image IDs to current image IDs
|
|
128
133
|
const imageIdMapping = new Map<string, string>();
|
|
@@ -307,7 +312,8 @@ export async function importConfirmationData(
|
|
|
307
312
|
present: signaturePresent,
|
|
308
313
|
valid: signatureValid,
|
|
309
314
|
keyId: signatureKeyId
|
|
310
|
-
}
|
|
315
|
+
},
|
|
316
|
+
confirmationData.metadata.exportedByBadgeId // Reviewer's badge/ID number
|
|
311
317
|
);
|
|
312
318
|
|
|
313
319
|
auditService.endWorkflow();
|
|
@@ -326,6 +332,7 @@ export async function importConfirmationData(
|
|
|
326
332
|
let hashValidForAudit = hashValid;
|
|
327
333
|
let exporterUidValidatedForAudit = true;
|
|
328
334
|
let reviewingExaminerUidForAudit: string | undefined = undefined;
|
|
335
|
+
let reviewerBadgeIdForAudit: string | undefined = undefined;
|
|
329
336
|
let totalConfirmationsForAudit = 0; // Default to 0 for failed imports
|
|
330
337
|
let signaturePresentForAudit = signaturePresent;
|
|
331
338
|
let signatureValidForAudit = signatureValid;
|
|
@@ -336,6 +343,7 @@ export async function importConfirmationData(
|
|
|
336
343
|
// First, try to extract basic metadata for audit purposes (if file is parseable)
|
|
337
344
|
if (auditConfirmationData) {
|
|
338
345
|
reviewingExaminerUidForAudit = auditConfirmationData.metadata?.exportedByUid;
|
|
346
|
+
reviewerBadgeIdForAudit = auditConfirmationData.metadata?.exportedByBadgeId;
|
|
339
347
|
totalConfirmationsForAudit = auditConfirmationData.metadata?.totalConfirmations || 0;
|
|
340
348
|
if (auditConfirmationData.metadata?.signature) {
|
|
341
349
|
signaturePresentForAudit = true;
|
|
@@ -345,6 +353,7 @@ export async function importConfirmationData(
|
|
|
345
353
|
try {
|
|
346
354
|
const extracted = await extractConfirmationImportPackage(confirmationFile);
|
|
347
355
|
reviewingExaminerUidForAudit = extracted.confirmationData.metadata?.exportedByUid;
|
|
356
|
+
reviewerBadgeIdForAudit = extracted.confirmationData.metadata?.exportedByBadgeId;
|
|
348
357
|
totalConfirmationsForAudit = extracted.confirmationData.metadata?.totalConfirmations || 0;
|
|
349
358
|
confirmationJsonFileNameForAudit = extracted.confirmationFileName;
|
|
350
359
|
if (extracted.confirmationData.metadata?.signature) {
|
|
@@ -393,7 +402,8 @@ export async function importConfirmationData(
|
|
|
393
402
|
present: signaturePresentForAudit,
|
|
394
403
|
valid: signatureValidForAudit,
|
|
395
404
|
keyId: signatureKeyIdForAudit
|
|
396
|
-
}
|
|
405
|
+
},
|
|
406
|
+
reviewerBadgeIdForAudit // Reviewer's badge/ID number (when extractable)
|
|
397
407
|
);
|
|
398
408
|
|
|
399
409
|
auditService.endWorkflow();
|
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
import type { User } from 'firebase/auth';
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
type ImportOptions,
|
|
4
|
+
type ImportResult,
|
|
5
|
+
type ReadOnlyCaseMetadata,
|
|
6
|
+
type FileData,
|
|
7
|
+
type BundledAuditTrailData,
|
|
8
|
+
type ValidationAuditEntry
|
|
9
|
+
} from '~/types';
|
|
3
10
|
import { checkExistingCase } from '../case-manage';
|
|
4
11
|
import {
|
|
5
|
-
extractForensicManifestData,
|
|
6
12
|
type SignedForensicManifest,
|
|
7
|
-
|
|
8
|
-
verifyForensicManifestSignature
|
|
13
|
+
verifyCasePackageIntegrity
|
|
9
14
|
} from '~/utils/forensics';
|
|
10
15
|
import { deleteFile } from '../image-manage';
|
|
11
16
|
import { parseImportZip } from './zip-processing';
|
|
@@ -31,6 +36,48 @@ interface ImportState {
|
|
|
31
36
|
caseNumber: string;
|
|
32
37
|
}
|
|
33
38
|
|
|
39
|
+
interface BundledAuditTrailFile {
|
|
40
|
+
metadata?: {
|
|
41
|
+
exportTimestamp?: string;
|
|
42
|
+
totalEntries?: number;
|
|
43
|
+
};
|
|
44
|
+
auditTrail?: {
|
|
45
|
+
entries?: ValidationAuditEntry[];
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function extractBundledAuditTrailData(
|
|
50
|
+
bundledAuditFiles: {
|
|
51
|
+
auditTrailContent?: string;
|
|
52
|
+
auditSignatureContent?: string;
|
|
53
|
+
} | undefined
|
|
54
|
+
): BundledAuditTrailData | undefined {
|
|
55
|
+
if (!bundledAuditFiles?.auditTrailContent) {
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
const parsed = JSON.parse(bundledAuditFiles.auditTrailContent) as BundledAuditTrailFile;
|
|
61
|
+
const entries = parsed.auditTrail?.entries;
|
|
62
|
+
|
|
63
|
+
if (!Array.isArray(entries)) {
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
source: 'archive-bundle',
|
|
69
|
+
importedAt: new Date().toISOString(),
|
|
70
|
+
exportTimestamp: parsed.metadata?.exportTimestamp,
|
|
71
|
+
totalEntries: typeof parsed.metadata?.totalEntries === 'number'
|
|
72
|
+
? parsed.metadata.totalEntries
|
|
73
|
+
: entries.length,
|
|
74
|
+
entries
|
|
75
|
+
};
|
|
76
|
+
} catch {
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
34
81
|
/**
|
|
35
82
|
* Clean up partially imported data when an import fails
|
|
36
83
|
*/
|
|
@@ -141,6 +188,8 @@ export async function importCaseForReview(
|
|
|
141
188
|
caseData,
|
|
142
189
|
imageFiles,
|
|
143
190
|
imageIdMapping,
|
|
191
|
+
isArchivedExport,
|
|
192
|
+
bundledAuditFiles,
|
|
144
193
|
metadata,
|
|
145
194
|
cleanedContent,
|
|
146
195
|
verificationPublicKeyPem
|
|
@@ -181,40 +230,35 @@ export async function importCaseForReview(
|
|
|
181
230
|
if (parsedForensicManifest && cleanedContent) {
|
|
182
231
|
onProgress?.('Validating comprehensive integrity', 15, 'Checking all file hashes...');
|
|
183
232
|
|
|
184
|
-
const
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
'Forensic manifest structure is invalid. Import cannot proceed.'
|
|
188
|
-
);
|
|
233
|
+
const imageBlobs: { [filename: string]: Blob } = {};
|
|
234
|
+
for (const [filename, blob] of Object.entries(imageFiles)) {
|
|
235
|
+
imageBlobs[filename] = blob;
|
|
189
236
|
}
|
|
190
237
|
|
|
191
|
-
const
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
238
|
+
const casePackageResult = await verifyCasePackageIntegrity({
|
|
239
|
+
cleanedContent,
|
|
240
|
+
imageFiles: imageBlobs,
|
|
241
|
+
forensicManifest: parsedForensicManifest,
|
|
242
|
+
verificationPublicKeyPem,
|
|
243
|
+
bundledAuditFiles
|
|
244
|
+
});
|
|
197
245
|
|
|
198
|
-
|
|
246
|
+
signatureValidationPassed = casePackageResult.signatureResult.isValid;
|
|
247
|
+
signatureKeyId = casePackageResult.signatureResult.keyId;
|
|
248
|
+
|
|
249
|
+
if (!casePackageResult.signatureResult.isValid) {
|
|
199
250
|
throw new Error(
|
|
200
251
|
'Manifest signature validation failed. Import cannot proceed.'
|
|
201
252
|
);
|
|
202
253
|
}
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
254
|
+
|
|
255
|
+
if (casePackageResult.bundledAuditVerification) {
|
|
256
|
+
throw new Error(
|
|
257
|
+
`${casePackageResult.bundledAuditVerification.message} Import cannot proceed.`
|
|
258
|
+
);
|
|
208
259
|
}
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
const validation = await validateForensicIntegrity(
|
|
212
|
-
cleanedContent,
|
|
213
|
-
imageBlobs,
|
|
214
|
-
manifestForValidation
|
|
215
|
-
);
|
|
216
|
-
|
|
217
|
-
if (!validation.isValid) {
|
|
260
|
+
|
|
261
|
+
if (!casePackageResult.integrityResult.isValid) {
|
|
218
262
|
throw new Error(
|
|
219
263
|
'Comprehensive integrity validation failed. Import cannot proceed.'
|
|
220
264
|
);
|
|
@@ -239,7 +283,7 @@ export async function importCaseForReview(
|
|
|
239
283
|
|
|
240
284
|
// Step 2a: Check if case already exists in user's regular cases (original analyst)
|
|
241
285
|
const existingRegularCase = await checkExistingCase(user, result.caseNumber);
|
|
242
|
-
if (existingRegularCase) {
|
|
286
|
+
if (existingRegularCase && !isArchivedExport) {
|
|
243
287
|
throw new Error(`Case "${result.caseNumber}" already exists in your case list. You cannot import a case for review if you were the original analyst.`);
|
|
244
288
|
}
|
|
245
289
|
|
|
@@ -318,7 +362,9 @@ export async function importCaseForReview(
|
|
|
318
362
|
caseData,
|
|
319
363
|
importedFiles,
|
|
320
364
|
originalImageIdMapping,
|
|
321
|
-
parsedForensicManifest
|
|
365
|
+
parsedForensicManifest,
|
|
366
|
+
isArchivedExport,
|
|
367
|
+
isArchivedExport ? extractBundledAuditTrailData(bundledAuditFiles) : undefined
|
|
322
368
|
);
|
|
323
369
|
importState.caseDataStored = true;
|
|
324
370
|
|
|
@@ -9,7 +9,8 @@ import {
|
|
|
9
9
|
type CaseExportData,
|
|
10
10
|
type FileData,
|
|
11
11
|
type CaseData,
|
|
12
|
-
type ReadOnlyCaseMetadata
|
|
12
|
+
type ReadOnlyCaseMetadata,
|
|
13
|
+
type BundledAuditTrailData
|
|
13
14
|
} from '~/types';
|
|
14
15
|
import { deleteFile } from '../image-manage';
|
|
15
16
|
import { type SignedForensicManifest } from '~/utils/forensics';
|
|
@@ -80,7 +81,9 @@ export async function storeCaseDataInR2(
|
|
|
80
81
|
caseData: CaseExportData,
|
|
81
82
|
importedFiles: FileData[],
|
|
82
83
|
originalImageIdMapping?: Map<string, string>,
|
|
83
|
-
forensicManifest?: SignedForensicManifest
|
|
84
|
+
forensicManifest?: SignedForensicManifest,
|
|
85
|
+
isArchivedExport?: boolean,
|
|
86
|
+
bundledAuditTrail?: BundledAuditTrailData
|
|
84
87
|
): Promise<void> {
|
|
85
88
|
try {
|
|
86
89
|
// Convert the mapping to a plain object for JSON serialization
|
|
@@ -94,6 +97,8 @@ export async function storeCaseDataInR2(
|
|
|
94
97
|
manifestHash: forensicManifest.manifestHash,
|
|
95
98
|
signature: forensicManifest.signature
|
|
96
99
|
} : undefined;
|
|
100
|
+
|
|
101
|
+
const archived = isArchivedExport === true || caseData.metadata.archived === true;
|
|
97
102
|
|
|
98
103
|
// Create the case data structure that matches normal cases
|
|
99
104
|
const r2CaseData = {
|
|
@@ -102,7 +107,15 @@ export async function storeCaseDataInR2(
|
|
|
102
107
|
files: importedFiles,
|
|
103
108
|
// Add read-only metadata
|
|
104
109
|
isReadOnly: true,
|
|
110
|
+
...(archived && {
|
|
111
|
+
archived: true,
|
|
112
|
+
archivedAt: caseData.metadata.archivedAt,
|
|
113
|
+
archivedBy: caseData.metadata.archivedBy,
|
|
114
|
+
archivedByDisplay: caseData.metadata.archivedByDisplay,
|
|
115
|
+
archiveReason: caseData.metadata.archiveReason,
|
|
116
|
+
}),
|
|
105
117
|
importedAt: new Date().toISOString(),
|
|
118
|
+
...(bundledAuditTrail && { bundledAuditTrail }),
|
|
106
119
|
// Add original image ID mapping for confirmation linking
|
|
107
120
|
originalImageIds: originalImageIds,
|
|
108
121
|
// Add forensic manifest timestamp if available for confirmation exports
|
|
@@ -179,6 +192,20 @@ export async function removeReadOnlyCase(user: User, caseNumber: string): Promis
|
|
|
179
192
|
* Completely delete a read-only case including all associated data (R2, Images, user references)
|
|
180
193
|
*/
|
|
181
194
|
export async function deleteReadOnlyCase(user: User, caseNumber: string): Promise<boolean> {
|
|
195
|
+
const isBenignCleanupError = (reason: unknown): boolean => {
|
|
196
|
+
if (!(reason instanceof Error)) {
|
|
197
|
+
return false;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const normalizedMessage = reason.message.toLowerCase();
|
|
201
|
+
return (
|
|
202
|
+
normalizedMessage.includes('404') ||
|
|
203
|
+
normalizedMessage.includes('not found')
|
|
204
|
+
);
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
let caseDataDeleteHadFailure = false;
|
|
208
|
+
|
|
182
209
|
try {
|
|
183
210
|
// Get case data first to get file IDs for deletion
|
|
184
211
|
const caseResponse = await fetchDataApi(
|
|
@@ -201,13 +228,48 @@ export async function deleteReadOnlyCase(user: User, caseNumber: string): Promis
|
|
|
201
228
|
|
|
202
229
|
const caseData = await caseResponse.json() as CaseData;
|
|
203
230
|
|
|
204
|
-
// Delete all files using data worker
|
|
231
|
+
// Delete all files using data worker (best-effort, keep going on individual failures)
|
|
205
232
|
if (caseData.files && caseData.files.length > 0) {
|
|
206
|
-
await Promise.
|
|
233
|
+
const deleteResults = await Promise.allSettled(
|
|
207
234
|
caseData.files.map((file: FileData) =>
|
|
208
|
-
deleteFile(
|
|
235
|
+
deleteFile(
|
|
236
|
+
user,
|
|
237
|
+
caseNumber,
|
|
238
|
+
file.id,
|
|
239
|
+
'Read-only case clearing - API operation',
|
|
240
|
+
{
|
|
241
|
+
skipValidation: true,
|
|
242
|
+
skipCaseDataUpdate: true,
|
|
243
|
+
suppressAudit: true
|
|
244
|
+
}
|
|
245
|
+
)
|
|
209
246
|
)
|
|
210
247
|
);
|
|
248
|
+
|
|
249
|
+
const failedDeletes = deleteResults.filter(
|
|
250
|
+
(result): result is PromiseRejectedResult =>
|
|
251
|
+
result.status === 'rejected' && !isBenignCleanupError(result.reason)
|
|
252
|
+
);
|
|
253
|
+
|
|
254
|
+
const benignNotFoundDeletes = deleteResults.filter(
|
|
255
|
+
(result): result is PromiseRejectedResult =>
|
|
256
|
+
result.status === 'rejected' && isBenignCleanupError(result.reason)
|
|
257
|
+
);
|
|
258
|
+
|
|
259
|
+
if (failedDeletes.length > 0) {
|
|
260
|
+
caseDataDeleteHadFailure = true;
|
|
261
|
+
console.warn(
|
|
262
|
+
`Partial read-only file cleanup for case ${caseNumber}: ` +
|
|
263
|
+
`${failedDeletes.length}/${caseData.files.length} file deletions failed.`
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (benignNotFoundDeletes.length > 0) {
|
|
268
|
+
console.info(
|
|
269
|
+
`Read-only cleanup for case ${caseNumber}: ` +
|
|
270
|
+
`${benignNotFoundDeletes.length} file deletions were already missing (404/not found) and treated as successful cleanup.`
|
|
271
|
+
);
|
|
272
|
+
}
|
|
211
273
|
}
|
|
212
274
|
|
|
213
275
|
// Delete case file using data worker
|
|
@@ -220,16 +282,43 @@ export async function deleteReadOnlyCase(user: User, caseNumber: string): Promis
|
|
|
220
282
|
);
|
|
221
283
|
|
|
222
284
|
if (!deleteCaseResponse.ok && deleteCaseResponse.status !== 404) {
|
|
223
|
-
|
|
285
|
+
caseDataDeleteHadFailure = true;
|
|
286
|
+
console.error(`Failed to delete read-only case data: ${deleteCaseResponse.status}`);
|
|
224
287
|
}
|
|
225
288
|
|
|
226
|
-
// Remove from user's read-only case list (separate from regular cases)
|
|
227
|
-
|
|
289
|
+
// Remove from user's read-only case list (separate from regular cases).
|
|
290
|
+
// This is the source of truth for import modal visibility and should be attempted even when storage cleanup is partial.
|
|
291
|
+
const removedFromMetadata = await removeReadOnlyCase(user, caseNumber);
|
|
292
|
+
|
|
293
|
+
if (!removedFromMetadata) {
|
|
294
|
+
return false;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
if (caseDataDeleteHadFailure) {
|
|
298
|
+
console.warn(
|
|
299
|
+
`Read-only case ${caseNumber} removed from metadata with partial storage cleanup failures.`
|
|
300
|
+
);
|
|
301
|
+
}
|
|
228
302
|
|
|
229
303
|
return true;
|
|
230
304
|
|
|
231
305
|
} catch (error) {
|
|
232
306
|
console.error('Error deleting read-only case:', error);
|
|
307
|
+
|
|
308
|
+
// Fallback: still try to clear read-only metadata so stale entries do not persist in the UI.
|
|
309
|
+
try {
|
|
310
|
+
const removedFromMetadata = await removeReadOnlyCase(user, caseNumber);
|
|
311
|
+
if (removedFromMetadata) {
|
|
312
|
+
console.warn(
|
|
313
|
+
`Read-only case ${caseNumber} removed from metadata during error fallback. ` +
|
|
314
|
+
'Some backing storage may require manual cleanup.'
|
|
315
|
+
);
|
|
316
|
+
return true;
|
|
317
|
+
}
|
|
318
|
+
} catch (removeError) {
|
|
319
|
+
console.error('Error removing read-only case metadata during fallback cleanup:', removeError);
|
|
320
|
+
}
|
|
321
|
+
|
|
233
322
|
return false;
|
|
234
323
|
}
|
|
235
324
|
}
|