@striae-org/striae 3.0.4
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 +100 -0
- package/LICENSE +190 -0
- package/NOTICE +18 -0
- package/README.md +133 -0
- package/app/components/actions/case-export/core-export.ts +328 -0
- package/app/components/actions/case-export/data-processing.ts +167 -0
- package/app/components/actions/case-export/download-handlers.ts +900 -0
- package/app/components/actions/case-export/index.ts +41 -0
- package/app/components/actions/case-export/metadata-helpers.ts +107 -0
- package/app/components/actions/case-export/types-constants.ts +56 -0
- package/app/components/actions/case-export/validation-utils.ts +25 -0
- package/app/components/actions/case-export.ts +4 -0
- package/app/components/actions/case-import/annotation-import.ts +35 -0
- package/app/components/actions/case-import/confirmation-import.ts +363 -0
- package/app/components/actions/case-import/image-operations.ts +61 -0
- package/app/components/actions/case-import/index.ts +39 -0
- package/app/components/actions/case-import/orchestrator.ts +420 -0
- package/app/components/actions/case-import/storage-operations.ts +270 -0
- package/app/components/actions/case-import/validation.ts +189 -0
- package/app/components/actions/case-import/zip-processing.ts +413 -0
- package/app/components/actions/case-manage.ts +524 -0
- package/app/components/actions/case-review.ts +4 -0
- package/app/components/actions/confirm-export.ts +351 -0
- package/app/components/actions/generate-pdf.ts +210 -0
- package/app/components/actions/image-manage.ts +385 -0
- package/app/components/actions/notes-manage.ts +33 -0
- package/app/components/actions/signout.module.css +15 -0
- package/app/components/actions/signout.tsx +50 -0
- package/app/components/audit/user-audit-viewer.tsx +975 -0
- package/app/components/audit/user-audit.module.css +568 -0
- package/app/components/auth/auth-provider.tsx +78 -0
- package/app/components/auth/mfa-enrollment.module.css +268 -0
- package/app/components/auth/mfa-enrollment.tsx +398 -0
- package/app/components/auth/mfa-verification.module.css +251 -0
- package/app/components/auth/mfa-verification.tsx +295 -0
- package/app/components/button/button.module.css +63 -0
- package/app/components/button/button.tsx +46 -0
- package/app/components/canvas/box-annotations/box-annotations.module.css +170 -0
- package/app/components/canvas/box-annotations/box-annotations.tsx +634 -0
- package/app/components/canvas/canvas.module.css +314 -0
- package/app/components/canvas/canvas.tsx +449 -0
- package/app/components/canvas/confirmation/confirmation.module.css +187 -0
- package/app/components/canvas/confirmation/confirmation.tsx +214 -0
- package/app/components/colors/colors.module.css +59 -0
- package/app/components/colors/colors.tsx +68 -0
- package/app/components/form/base-form.tsx +21 -0
- package/app/components/form/form-button.tsx +28 -0
- package/app/components/form/form-field.tsx +53 -0
- package/app/components/form/form-message.tsx +17 -0
- package/app/components/form/form-toggle.tsx +23 -0
- package/app/components/form/form.module.css +427 -0
- package/app/components/form/index.ts +6 -0
- package/app/components/icon/icon.module.css +3 -0
- package/app/components/icon/icon.tsx +27 -0
- package/app/components/icon/icons.svg +102 -0
- package/app/components/icon/manifest.json +110 -0
- package/app/components/sidebar/case-export/case-export.module.css +386 -0
- package/app/components/sidebar/case-export/case-export.tsx +317 -0
- package/app/components/sidebar/case-import/case-import.module.css +626 -0
- package/app/components/sidebar/case-import/case-import.tsx +404 -0
- package/app/components/sidebar/case-import/components/CasePreviewSection.tsx +72 -0
- package/app/components/sidebar/case-import/components/ConfirmationDialog.tsx +72 -0
- package/app/components/sidebar/case-import/components/ConfirmationPreviewSection.tsx +71 -0
- package/app/components/sidebar/case-import/components/ExistingCaseSection.tsx +40 -0
- package/app/components/sidebar/case-import/components/FileSelector.tsx +161 -0
- package/app/components/sidebar/case-import/components/ProgressSection.tsx +46 -0
- package/app/components/sidebar/case-import/hooks/useFilePreview.ts +101 -0
- package/app/components/sidebar/case-import/hooks/useImportExecution.ts +152 -0
- package/app/components/sidebar/case-import/hooks/useImportState.ts +88 -0
- package/app/components/sidebar/case-import/index.ts +18 -0
- package/app/components/sidebar/case-import/utils/file-validation.ts +43 -0
- package/app/components/sidebar/cases/case-sidebar.tsx +827 -0
- package/app/components/sidebar/cases/cases-modal.module.css +166 -0
- package/app/components/sidebar/cases/cases-modal.tsx +201 -0
- package/app/components/sidebar/cases/cases.module.css +713 -0
- package/app/components/sidebar/files/files-modal.module.css +209 -0
- package/app/components/sidebar/files/files-modal.tsx +239 -0
- package/app/components/sidebar/hash/hash-utility.module.css +366 -0
- package/app/components/sidebar/hash/hash-utility.tsx +982 -0
- package/app/components/sidebar/notes/notes-modal.tsx +51 -0
- package/app/components/sidebar/notes/notes-sidebar.tsx +491 -0
- package/app/components/sidebar/notes/notes.module.css +360 -0
- package/app/components/sidebar/sidebar-container.tsx +149 -0
- package/app/components/sidebar/sidebar.module.css +321 -0
- package/app/components/sidebar/sidebar.tsx +215 -0
- package/app/components/sidebar/upload/image-upload-zone.module.css +123 -0
- package/app/components/sidebar/upload/image-upload-zone.tsx +330 -0
- package/app/components/theme-provider/theme-provider.tsx +131 -0
- package/app/components/theme-provider/theme.ts +155 -0
- package/app/components/toast/toast.module.css +137 -0
- package/app/components/toast/toast.tsx +56 -0
- package/app/components/toolbar/toolbar-color-selector.module.css +171 -0
- package/app/components/toolbar/toolbar-color-selector.tsx +129 -0
- package/app/components/toolbar/toolbar.module.css +42 -0
- package/app/components/toolbar/toolbar.tsx +167 -0
- package/app/components/user/delete-account.module.css +274 -0
- package/app/components/user/delete-account.tsx +471 -0
- package/app/components/user/inactivity-warning.module.css +145 -0
- package/app/components/user/inactivity-warning.tsx +84 -0
- package/app/components/user/manage-profile.module.css +190 -0
- package/app/components/user/manage-profile.tsx +253 -0
- package/app/components/user/mfa-phone-update.tsx +739 -0
- package/app/config-example/admin-service.json +13 -0
- package/app/config-example/config.json +17 -0
- package/app/config-example/firebase.ts +21 -0
- package/app/config-example/inactivity.ts +13 -0
- package/app/config-example/meta-config.json +6 -0
- package/app/contexts/auth.context.ts +12 -0
- package/app/entry.client.tsx +12 -0
- package/app/entry.server.tsx +44 -0
- package/app/hooks/useInactivityTimeout.ts +110 -0
- package/app/root.tsx +170 -0
- package/app/routes/_index.tsx +16 -0
- package/app/routes/auth/emailActionHandler.module.css +232 -0
- package/app/routes/auth/emailActionHandler.tsx +405 -0
- package/app/routes/auth/emailVerification.tsx +120 -0
- package/app/routes/auth/login.module.css +523 -0
- package/app/routes/auth/login.tsx +654 -0
- package/app/routes/auth/passwordReset.module.css +274 -0
- package/app/routes/auth/passwordReset.tsx +154 -0
- package/app/routes/auth/route.ts +16 -0
- package/app/routes/mobile-prevented/mobilePrevented.module.css +47 -0
- package/app/routes/mobile-prevented/mobilePrevented.tsx +26 -0
- package/app/routes/mobile-prevented/route.ts +14 -0
- package/app/routes/striae/striae.module.css +30 -0
- package/app/routes/striae/striae.tsx +417 -0
- package/app/services/audit-export.service.ts +755 -0
- package/app/services/audit.service.ts +1454 -0
- package/app/services/firebase-errors.ts +106 -0
- package/app/services/firebase.ts +15 -0
- package/app/styles/legal-pages.module.css +113 -0
- package/app/styles/root.module.css +146 -0
- package/app/tailwind.css +225 -0
- package/app/types/annotations.ts +45 -0
- package/app/types/audit.ts +301 -0
- package/app/types/case.ts +90 -0
- package/app/types/export.ts +8 -0
- package/app/types/file.ts +30 -0
- package/app/types/import.ts +107 -0
- package/app/types/index.ts +24 -0
- package/app/types/user.ts +38 -0
- package/app/utils/SHA256.ts +461 -0
- package/app/utils/annotation-timestamp.ts +25 -0
- package/app/utils/audit-export-signature.ts +117 -0
- package/app/utils/auth-action-settings.ts +48 -0
- package/app/utils/auth.ts +34 -0
- package/app/utils/batch-operations.ts +135 -0
- package/app/utils/confirmation-signature.ts +193 -0
- package/app/utils/data-operations.ts +871 -0
- package/app/utils/device-detection.ts +5 -0
- package/app/utils/html-sanitizer.ts +80 -0
- package/app/utils/id-generator.ts +36 -0
- package/app/utils/meta.ts +48 -0
- package/app/utils/mfa-phone.ts +97 -0
- package/app/utils/mfa.ts +79 -0
- package/app/utils/password-policy.ts +28 -0
- package/app/utils/permissions.ts +562 -0
- package/app/utils/signature-utils.ts +160 -0
- package/app/utils/style.ts +83 -0
- package/app/utils/version.ts +5 -0
- package/firebase.json +11 -0
- package/functions/[[path]].ts +10 -0
- package/package.json +138 -0
- package/postcss.config.js +6 -0
- package/public/.well-known/publickey.info@striae.org.asc +17 -0
- package/public/.well-known/security.txt +7 -0
- package/public/_headers +28 -0
- package/public/_routes.json +13 -0
- package/public/assets/striae.jpg +0 -0
- package/public/clear.jpg +0 -0
- package/public/favicon.ico +0 -0
- package/public/favicon.svg +9 -0
- package/public/icon-256.png +0 -0
- package/public/icon-512.png +0 -0
- package/public/logo-dark.png +0 -0
- package/public/manifest.json +25 -0
- package/public/oin-badge.png +0 -0
- package/public/shortcut.png +0 -0
- package/public/social-image.png +0 -0
- package/public/striae-ascii.txt +10 -0
- package/scripts/deploy-all.sh +100 -0
- package/scripts/deploy-config.sh +940 -0
- package/scripts/deploy-pages.sh +34 -0
- package/scripts/deploy-worker-secrets.sh +215 -0
- package/scripts/dev.cjs +23 -0
- package/scripts/install-workers.sh +88 -0
- package/scripts/run-eslint.cjs +35 -0
- package/scripts/update-compatibility-dates.cjs +124 -0
- package/scripts/update-markdown-versions.cjs +43 -0
- package/tailwind.config.ts +22 -0
- package/tsconfig.json +33 -0
- package/vite.config.ts +35 -0
- package/worker-configuration.d.ts +7490 -0
- package/workers/audit-worker/package.json +17 -0
- package/workers/audit-worker/src/audit-worker.example.ts +195 -0
- package/workers/audit-worker/worker-configuration.d.ts +7448 -0
- package/workers/audit-worker/wrangler.jsonc.example +29 -0
- package/workers/data-worker/package.json +17 -0
- package/workers/data-worker/src/data-worker.example.ts +267 -0
- package/workers/data-worker/src/signature-utils.ts +79 -0
- package/workers/data-worker/src/signing-payload-utils.ts +290 -0
- package/workers/data-worker/worker-configuration.d.ts +7448 -0
- package/workers/data-worker/wrangler.jsonc.example +30 -0
- package/workers/image-worker/package.json +17 -0
- package/workers/image-worker/src/image-worker.example.ts +180 -0
- package/workers/image-worker/worker-configuration.d.ts +7447 -0
- package/workers/image-worker/wrangler.jsonc.example +22 -0
- package/workers/keys-worker/package.json +17 -0
- package/workers/keys-worker/src/keys.example.ts +66 -0
- package/workers/keys-worker/src/keys.ts +66 -0
- package/workers/keys-worker/worker-configuration.d.ts +7447 -0
- package/workers/keys-worker/wrangler.jsonc.example +22 -0
- package/workers/pdf-worker/package.json +17 -0
- package/workers/pdf-worker/src/format-striae.ts +534 -0
- package/workers/pdf-worker/src/pdf-worker.example.ts +119 -0
- package/workers/pdf-worker/src/report-types.ts +69 -0
- package/workers/pdf-worker/worker-configuration.d.ts +7448 -0
- package/workers/pdf-worker/wrangler.jsonc.example +26 -0
- package/workers/user-worker/package.json +17 -0
- package/workers/user-worker/src/user-worker.example.ts +636 -0
- package/workers/user-worker/worker-configuration.d.ts +7448 -0
- package/workers/user-worker/wrangler.jsonc.example +29 -0
- package/wrangler.toml.example +8 -0
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
import { User } from 'firebase/auth';
|
|
2
|
+
import { CaseExportData, CaseImportPreview } from '~/types';
|
|
3
|
+
import { validateCaseNumber } from '../case-manage';
|
|
4
|
+
import {
|
|
5
|
+
extractForensicManifestData,
|
|
6
|
+
SignedForensicManifest,
|
|
7
|
+
validateCaseIntegritySecure as validateForensicIntegrity,
|
|
8
|
+
verifyForensicManifestSignature
|
|
9
|
+
} from '~/utils/SHA256';
|
|
10
|
+
import { validateExporterUid, removeForensicWarning } from './validation';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Extract original image ID from export filename format
|
|
14
|
+
* Format: {originalFilename}-{id}.{extension}
|
|
15
|
+
* Example: "evidence-2b365c5e-0559-4d6a-564f-d40bf1770101.jpg" returns "2b365c5e-0559-4d6a-564f-d40bf1770101"
|
|
16
|
+
*
|
|
17
|
+
* Since IDs can contain hyphens (like UUIDs), we need to find the hyphen that separates
|
|
18
|
+
* the original filename from the ID. We do this by looking for UUID patterns or taking
|
|
19
|
+
* a reasonable portion from the end.
|
|
20
|
+
*/
|
|
21
|
+
function extractImageIdFromFilename(exportFilename: string): string | null {
|
|
22
|
+
// Remove extension first
|
|
23
|
+
const lastDotIndex = exportFilename.lastIndexOf('.');
|
|
24
|
+
const filenameWithoutExt = lastDotIndex === -1 ? exportFilename : exportFilename.substring(0, lastDotIndex);
|
|
25
|
+
|
|
26
|
+
// UUID pattern: 8-4-4-4-12 (36 chars including hyphens)
|
|
27
|
+
// Look for a pattern that matches this at the end
|
|
28
|
+
const uuidPattern = /^(.+)-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i;
|
|
29
|
+
const match = filenameWithoutExt.match(uuidPattern);
|
|
30
|
+
|
|
31
|
+
if (match) {
|
|
32
|
+
return match[2]; // Return the UUID part
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Fallback: if not a UUID, assume the ID is everything after the last hyphen
|
|
36
|
+
// This maintains backward compatibility with non-UUID IDs
|
|
37
|
+
const lastHyphenIndex = filenameWithoutExt.lastIndexOf('-');
|
|
38
|
+
|
|
39
|
+
if (lastHyphenIndex === -1 || lastHyphenIndex === filenameWithoutExt.length - 1) {
|
|
40
|
+
return null; // No hyphen found or hyphen is at the end
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return filenameWithoutExt.substring(lastHyphenIndex + 1);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Reconstruct original filename from export filename
|
|
48
|
+
* Format: {originalFilename}-{id}.{extension} → {originalFilename}.{extension}
|
|
49
|
+
* Example: "evidence-2b365c5e-0559-4d6a-564f-d40bf1770101.jpg" returns "evidence.jpg"
|
|
50
|
+
*/
|
|
51
|
+
function reconstructOriginalFilename(exportFilename: string): string {
|
|
52
|
+
const lastDotIndex = exportFilename.lastIndexOf('.');
|
|
53
|
+
const extension = lastDotIndex === -1 ? '' : exportFilename.substring(lastDotIndex);
|
|
54
|
+
const filenameWithoutExt = lastDotIndex === -1 ? exportFilename : exportFilename.substring(0, lastDotIndex);
|
|
55
|
+
|
|
56
|
+
// UUID pattern: 8-4-4-4-12 (36 chars including hyphens)
|
|
57
|
+
const uuidPattern = /^(.+)-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i;
|
|
58
|
+
const match = filenameWithoutExt.match(uuidPattern);
|
|
59
|
+
|
|
60
|
+
if (match) {
|
|
61
|
+
return match[1] + extension; // Return the original filename part + extension
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Fallback: remove everything after the last hyphen
|
|
65
|
+
const lastHyphenIndex = filenameWithoutExt.lastIndexOf('-');
|
|
66
|
+
|
|
67
|
+
if (lastHyphenIndex === -1) {
|
|
68
|
+
return exportFilename; // No hyphen found, return as-is (backward compatibility)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const originalBasename = filenameWithoutExt.substring(0, lastHyphenIndex);
|
|
72
|
+
return originalBasename + extension;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Preview case information from ZIP file without importing
|
|
77
|
+
*/
|
|
78
|
+
export async function previewCaseImport(zipFile: File, currentUser: User): Promise<CaseImportPreview> {
|
|
79
|
+
const JSZip = (await import('jszip')).default;
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
const zip = await JSZip.loadAsync(zipFile);
|
|
83
|
+
|
|
84
|
+
// First, validate hash if forensic metadata exists
|
|
85
|
+
let hashValid: boolean | undefined = undefined;
|
|
86
|
+
let hashError: string | undefined = undefined;
|
|
87
|
+
let expectedHash: string | undefined = undefined;
|
|
88
|
+
let actualHash: string | undefined = undefined;
|
|
89
|
+
let validationDetails: CaseImportPreview['validationDetails'];
|
|
90
|
+
|
|
91
|
+
// Find the main data file (JSON or CSV)
|
|
92
|
+
const dataFiles = Object.keys(zip.files).filter(name =>
|
|
93
|
+
name.endsWith('_data.json') || name.endsWith('_data.csv')
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
if (dataFiles.length === 0) {
|
|
97
|
+
throw new Error('No valid data file found in ZIP archive');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (dataFiles.length > 1) {
|
|
101
|
+
throw new Error('Multiple data files found in ZIP archive');
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const dataFileName = dataFiles[0];
|
|
105
|
+
const isJsonFormat = dataFileName.endsWith('.json');
|
|
106
|
+
|
|
107
|
+
if (!isJsonFormat) {
|
|
108
|
+
throw new Error('CSV import not yet supported. Please use JSON format.');
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Extract and parse case data
|
|
112
|
+
const dataContent = await zip.file(dataFileName)?.async('text');
|
|
113
|
+
if (!dataContent) {
|
|
114
|
+
throw new Error('Failed to read data file from ZIP');
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Handle forensic protection warnings in JSON
|
|
118
|
+
const cleanedContent = removeForensicWarning(dataContent);
|
|
119
|
+
|
|
120
|
+
// Validate forensic manifest integrity
|
|
121
|
+
const manifestFile = zip.file('FORENSIC_MANIFEST.json');
|
|
122
|
+
|
|
123
|
+
if (manifestFile) {
|
|
124
|
+
try {
|
|
125
|
+
let forensicManifest: SignedForensicManifest | null = null;
|
|
126
|
+
|
|
127
|
+
// Get forensic manifest from dedicated file
|
|
128
|
+
const manifestContent = await manifestFile.async('text');
|
|
129
|
+
forensicManifest = JSON.parse(manifestContent) as SignedForensicManifest;
|
|
130
|
+
|
|
131
|
+
if (forensicManifest) {
|
|
132
|
+
const manifestForValidation = extractForensicManifestData(forensicManifest);
|
|
133
|
+
if (!manifestForValidation) {
|
|
134
|
+
hashValid = false;
|
|
135
|
+
hashError = 'Forensic manifest format is invalid or incomplete.';
|
|
136
|
+
|
|
137
|
+
validationDetails = {
|
|
138
|
+
hasForensicManifest: true,
|
|
139
|
+
dataValid: false,
|
|
140
|
+
manifestValid: false,
|
|
141
|
+
signatureValid: false,
|
|
142
|
+
validationSummary: 'Manifest schema validation failed',
|
|
143
|
+
integrityErrors: [hashError]
|
|
144
|
+
};
|
|
145
|
+
} else {
|
|
146
|
+
expectedHash = manifestForValidation.manifestHash;
|
|
147
|
+
|
|
148
|
+
// Extract image files for comprehensive validation
|
|
149
|
+
const imageFiles: { [filename: string]: Blob } = {};
|
|
150
|
+
const imagesFolder = zip.folder('images');
|
|
151
|
+
if (imagesFolder) {
|
|
152
|
+
await Promise.all(Object.keys(imagesFolder.files).map(async (path) => {
|
|
153
|
+
if (path.startsWith('images/') && !path.endsWith('/')) {
|
|
154
|
+
const filename = path.replace('images/', '');
|
|
155
|
+
const file = zip.file(path);
|
|
156
|
+
if (file) {
|
|
157
|
+
const blob = await file.async('blob');
|
|
158
|
+
imageFiles[filename] = blob;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const signatureResult = await verifyForensicManifestSignature(forensicManifest);
|
|
165
|
+
|
|
166
|
+
// Perform comprehensive validation
|
|
167
|
+
const validation = await validateForensicIntegrity(
|
|
168
|
+
cleanedContent,
|
|
169
|
+
imageFiles,
|
|
170
|
+
manifestForValidation
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
hashValid = validation.isValid && signatureResult.isValid;
|
|
174
|
+
actualHash = validation.manifestValid ? expectedHash : 'validation_failed';
|
|
175
|
+
|
|
176
|
+
if (!hashValid) {
|
|
177
|
+
const errorParts: string[] = [];
|
|
178
|
+
if (!signatureResult.isValid) {
|
|
179
|
+
errorParts.push(`Signature validation failed: ${signatureResult.error}`);
|
|
180
|
+
}
|
|
181
|
+
if (!validation.isValid) {
|
|
182
|
+
errorParts.push(`Comprehensive validation failed: ${validation.summary}. Errors: ${validation.errors.join(', ')}`);
|
|
183
|
+
}
|
|
184
|
+
hashError = errorParts.join(' ');
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Capture detailed validation information
|
|
188
|
+
const integrityErrors = [...validation.errors];
|
|
189
|
+
if (!signatureResult.isValid) {
|
|
190
|
+
integrityErrors.push(`Signature validation failed: ${signatureResult.error || 'Unknown signature error'}`);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
validationDetails = {
|
|
194
|
+
hasForensicManifest: true,
|
|
195
|
+
dataValid: validation.dataValid,
|
|
196
|
+
imageValidation: validation.imageValidation,
|
|
197
|
+
manifestValid: validation.manifestValid,
|
|
198
|
+
signatureValid: signatureResult.isValid,
|
|
199
|
+
signatureKeyId: signatureResult.keyId,
|
|
200
|
+
signatureError: signatureResult.error,
|
|
201
|
+
validationSummary: validation.summary,
|
|
202
|
+
integrityErrors
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
} else {
|
|
207
|
+
// No forensic manifest found - cannot validate
|
|
208
|
+
hashValid = false;
|
|
209
|
+
hashError = 'No forensic manifest found. This case export does not support comprehensive integrity validation.';
|
|
210
|
+
|
|
211
|
+
validationDetails = {
|
|
212
|
+
hasForensicManifest: false,
|
|
213
|
+
dataValid: false,
|
|
214
|
+
validationSummary: 'No forensic manifest found - comprehensive validation not available',
|
|
215
|
+
integrityErrors: ['Export does not contain forensic manifest required for validation']
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
} catch (error) {
|
|
219
|
+
hashError = `Failed to validate forensic metadata: ${error instanceof Error ? error.message : 'Unknown error'}`;
|
|
220
|
+
hashValid = false;
|
|
221
|
+
|
|
222
|
+
validationDetails = {
|
|
223
|
+
hasForensicManifest: true,
|
|
224
|
+
validationSummary: 'Validation failed due to metadata parsing error',
|
|
225
|
+
integrityErrors: [hashError]
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
} else {
|
|
229
|
+
// No forensic manifest found
|
|
230
|
+
validationDetails = {
|
|
231
|
+
hasForensicManifest: false,
|
|
232
|
+
validationSummary: 'No forensic manifest found - integrity cannot be verified',
|
|
233
|
+
integrityErrors: []
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const caseData: CaseExportData = JSON.parse(cleanedContent);
|
|
238
|
+
|
|
239
|
+
// Validate case data structure
|
|
240
|
+
if (!caseData.metadata?.caseNumber) {
|
|
241
|
+
throw new Error('Invalid case data: missing case number');
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (!validateCaseNumber(caseData.metadata.caseNumber)) {
|
|
245
|
+
throw new Error(`Invalid case number format: ${caseData.metadata.caseNumber}`);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Validate exporter UID exists in user database and is not current user
|
|
249
|
+
if (caseData.metadata.exportedByUid) {
|
|
250
|
+
const validation = await validateExporterUid(caseData.metadata.exportedByUid, currentUser);
|
|
251
|
+
|
|
252
|
+
if (!validation.exists) {
|
|
253
|
+
throw new Error(`The original exporter is not a valid Striae user. This case cannot be imported.`);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (validation.isSelf) {
|
|
257
|
+
throw new Error(`You cannot import a case that you originally exported. Original analysts cannot review their own cases.`);
|
|
258
|
+
}
|
|
259
|
+
} else {
|
|
260
|
+
throw new Error('Case export missing exporter UID information. This case cannot be imported.');
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Count image files
|
|
264
|
+
let totalFiles = 0;
|
|
265
|
+
const imagesFolder = zip.folder('images');
|
|
266
|
+
if (imagesFolder) {
|
|
267
|
+
for (const [, file] of Object.entries(imagesFolder.files)) {
|
|
268
|
+
if (!file.dir && file.name.includes('/')) {
|
|
269
|
+
totalFiles++;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
return {
|
|
275
|
+
caseNumber: caseData.metadata.caseNumber,
|
|
276
|
+
exportedBy: caseData.metadata.exportedBy || null,
|
|
277
|
+
exportedByName: caseData.metadata.exportedByName || null,
|
|
278
|
+
exportedByCompany: caseData.metadata.exportedByCompany || null,
|
|
279
|
+
exportDate: caseData.metadata.exportDate,
|
|
280
|
+
totalFiles,
|
|
281
|
+
caseCreatedDate: caseData.metadata.caseCreatedDate,
|
|
282
|
+
hasAnnotations: false, // We'll need to determine this during parsing if needed
|
|
283
|
+
validationSummary: hashValid ? 'Validation successful' : (hashError || 'Validation failed'),
|
|
284
|
+
hashValid,
|
|
285
|
+
hashError,
|
|
286
|
+
expectedHash,
|
|
287
|
+
actualHash,
|
|
288
|
+
validationDetails
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
} catch (error) {
|
|
292
|
+
console.error('Error previewing case import:', error);
|
|
293
|
+
throw new Error(`Failed to preview case: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Parse and validate ZIP file contents for case import
|
|
299
|
+
*/
|
|
300
|
+
export async function parseImportZip(zipFile: File, currentUser: User): Promise<{
|
|
301
|
+
caseData: CaseExportData;
|
|
302
|
+
imageFiles: { [filename: string]: Blob };
|
|
303
|
+
imageIdMapping: { [exportFilename: string]: string }; // exportFilename -> originalImageId
|
|
304
|
+
metadata?: any;
|
|
305
|
+
cleanedContent?: string; // Add cleaned content for hash validation
|
|
306
|
+
}> {
|
|
307
|
+
// Dynamic import of JSZip to avoid bundle size issues
|
|
308
|
+
const JSZip = (await import('jszip')).default;
|
|
309
|
+
|
|
310
|
+
try {
|
|
311
|
+
const zip = await JSZip.loadAsync(zipFile);
|
|
312
|
+
|
|
313
|
+
// Find the main data file (JSON or CSV)
|
|
314
|
+
const dataFiles = Object.keys(zip.files).filter(name =>
|
|
315
|
+
name.endsWith('_data.json') || name.endsWith('_data.csv')
|
|
316
|
+
);
|
|
317
|
+
|
|
318
|
+
if (dataFiles.length === 0) {
|
|
319
|
+
throw new Error('No valid data file found in ZIP archive');
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
if (dataFiles.length > 1) {
|
|
323
|
+
throw new Error('Multiple data files found in ZIP archive');
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const dataFileName = dataFiles[0];
|
|
327
|
+
const isJsonFormat = dataFileName.endsWith('.json');
|
|
328
|
+
|
|
329
|
+
// Extract and parse case data
|
|
330
|
+
let caseData: CaseExportData;
|
|
331
|
+
let cleanedContent: string = '';
|
|
332
|
+
if (isJsonFormat) {
|
|
333
|
+
const dataContent = await zip.file(dataFileName)?.async('text');
|
|
334
|
+
if (!dataContent) {
|
|
335
|
+
throw new Error('Failed to read data file from ZIP');
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// Handle forensic protection warnings in JSON
|
|
339
|
+
cleanedContent = removeForensicWarning(dataContent);
|
|
340
|
+
caseData = JSON.parse(cleanedContent);
|
|
341
|
+
} else {
|
|
342
|
+
throw new Error('CSV import not yet supported. Please use JSON format.');
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// Validate case data structure
|
|
346
|
+
if (!caseData.metadata?.caseNumber) {
|
|
347
|
+
throw new Error('Invalid case data: missing case number');
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
if (!validateCaseNumber(caseData.metadata.caseNumber)) {
|
|
351
|
+
throw new Error(`Invalid case number format: ${caseData.metadata.caseNumber}`);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// Validate exporter UID exists in user database and is not current user
|
|
355
|
+
if (caseData.metadata.exportedByUid) {
|
|
356
|
+
const validation = await validateExporterUid(caseData.metadata.exportedByUid, currentUser);
|
|
357
|
+
|
|
358
|
+
if (!validation.exists) {
|
|
359
|
+
throw new Error(`The original exporter is not a valid Striae user. This case cannot be imported.`);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
if (validation.isSelf) {
|
|
363
|
+
throw new Error(`You cannot import a case that you originally exported. Original analysts cannot review their own cases.`);
|
|
364
|
+
}
|
|
365
|
+
} else {
|
|
366
|
+
throw new Error('Case export missing exporter UID information. This case cannot be imported.');
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// Extract image files and create ID mapping
|
|
370
|
+
const imageFiles: { [filename: string]: Blob } = {};
|
|
371
|
+
const imageIdMapping: { [exportFilename: string]: string } = {};
|
|
372
|
+
const imagesFolder = zip.folder('images');
|
|
373
|
+
|
|
374
|
+
if (imagesFolder) {
|
|
375
|
+
for (const [, file] of Object.entries(imagesFolder.files)) {
|
|
376
|
+
if (!file.dir && file.name.includes('/')) {
|
|
377
|
+
const exportFilename = file.name.split('/').pop();
|
|
378
|
+
if (exportFilename) {
|
|
379
|
+
const blob = await file.async('blob');
|
|
380
|
+
imageFiles[exportFilename] = blob;
|
|
381
|
+
|
|
382
|
+
// Extract original image ID from filename
|
|
383
|
+
const originalImageId = extractImageIdFromFilename(exportFilename);
|
|
384
|
+
if (originalImageId) {
|
|
385
|
+
imageIdMapping[exportFilename] = originalImageId;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// Extract forensic manifest if present
|
|
393
|
+
let metadata: any = undefined;
|
|
394
|
+
const manifestFile = zip.file('FORENSIC_MANIFEST.json');
|
|
395
|
+
|
|
396
|
+
if (manifestFile) {
|
|
397
|
+
const manifestContent = await manifestFile.async('text');
|
|
398
|
+
metadata = { forensicManifest: JSON.parse(manifestContent) };
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
return {
|
|
402
|
+
caseData,
|
|
403
|
+
imageFiles,
|
|
404
|
+
imageIdMapping,
|
|
405
|
+
metadata,
|
|
406
|
+
cleanedContent
|
|
407
|
+
};
|
|
408
|
+
|
|
409
|
+
} catch (error) {
|
|
410
|
+
console.error('Error parsing ZIP file:', error);
|
|
411
|
+
throw new Error(`Failed to parse ZIP file: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
412
|
+
}
|
|
413
|
+
}
|