@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.
Files changed (223) hide show
  1. package/.env.example +100 -0
  2. package/LICENSE +190 -0
  3. package/NOTICE +18 -0
  4. package/README.md +133 -0
  5. package/app/components/actions/case-export/core-export.ts +328 -0
  6. package/app/components/actions/case-export/data-processing.ts +167 -0
  7. package/app/components/actions/case-export/download-handlers.ts +900 -0
  8. package/app/components/actions/case-export/index.ts +41 -0
  9. package/app/components/actions/case-export/metadata-helpers.ts +107 -0
  10. package/app/components/actions/case-export/types-constants.ts +56 -0
  11. package/app/components/actions/case-export/validation-utils.ts +25 -0
  12. package/app/components/actions/case-export.ts +4 -0
  13. package/app/components/actions/case-import/annotation-import.ts +35 -0
  14. package/app/components/actions/case-import/confirmation-import.ts +363 -0
  15. package/app/components/actions/case-import/image-operations.ts +61 -0
  16. package/app/components/actions/case-import/index.ts +39 -0
  17. package/app/components/actions/case-import/orchestrator.ts +420 -0
  18. package/app/components/actions/case-import/storage-operations.ts +270 -0
  19. package/app/components/actions/case-import/validation.ts +189 -0
  20. package/app/components/actions/case-import/zip-processing.ts +413 -0
  21. package/app/components/actions/case-manage.ts +524 -0
  22. package/app/components/actions/case-review.ts +4 -0
  23. package/app/components/actions/confirm-export.ts +351 -0
  24. package/app/components/actions/generate-pdf.ts +210 -0
  25. package/app/components/actions/image-manage.ts +385 -0
  26. package/app/components/actions/notes-manage.ts +33 -0
  27. package/app/components/actions/signout.module.css +15 -0
  28. package/app/components/actions/signout.tsx +50 -0
  29. package/app/components/audit/user-audit-viewer.tsx +975 -0
  30. package/app/components/audit/user-audit.module.css +568 -0
  31. package/app/components/auth/auth-provider.tsx +78 -0
  32. package/app/components/auth/mfa-enrollment.module.css +268 -0
  33. package/app/components/auth/mfa-enrollment.tsx +398 -0
  34. package/app/components/auth/mfa-verification.module.css +251 -0
  35. package/app/components/auth/mfa-verification.tsx +295 -0
  36. package/app/components/button/button.module.css +63 -0
  37. package/app/components/button/button.tsx +46 -0
  38. package/app/components/canvas/box-annotations/box-annotations.module.css +170 -0
  39. package/app/components/canvas/box-annotations/box-annotations.tsx +634 -0
  40. package/app/components/canvas/canvas.module.css +314 -0
  41. package/app/components/canvas/canvas.tsx +449 -0
  42. package/app/components/canvas/confirmation/confirmation.module.css +187 -0
  43. package/app/components/canvas/confirmation/confirmation.tsx +214 -0
  44. package/app/components/colors/colors.module.css +59 -0
  45. package/app/components/colors/colors.tsx +68 -0
  46. package/app/components/form/base-form.tsx +21 -0
  47. package/app/components/form/form-button.tsx +28 -0
  48. package/app/components/form/form-field.tsx +53 -0
  49. package/app/components/form/form-message.tsx +17 -0
  50. package/app/components/form/form-toggle.tsx +23 -0
  51. package/app/components/form/form.module.css +427 -0
  52. package/app/components/form/index.ts +6 -0
  53. package/app/components/icon/icon.module.css +3 -0
  54. package/app/components/icon/icon.tsx +27 -0
  55. package/app/components/icon/icons.svg +102 -0
  56. package/app/components/icon/manifest.json +110 -0
  57. package/app/components/sidebar/case-export/case-export.module.css +386 -0
  58. package/app/components/sidebar/case-export/case-export.tsx +317 -0
  59. package/app/components/sidebar/case-import/case-import.module.css +626 -0
  60. package/app/components/sidebar/case-import/case-import.tsx +404 -0
  61. package/app/components/sidebar/case-import/components/CasePreviewSection.tsx +72 -0
  62. package/app/components/sidebar/case-import/components/ConfirmationDialog.tsx +72 -0
  63. package/app/components/sidebar/case-import/components/ConfirmationPreviewSection.tsx +71 -0
  64. package/app/components/sidebar/case-import/components/ExistingCaseSection.tsx +40 -0
  65. package/app/components/sidebar/case-import/components/FileSelector.tsx +161 -0
  66. package/app/components/sidebar/case-import/components/ProgressSection.tsx +46 -0
  67. package/app/components/sidebar/case-import/hooks/useFilePreview.ts +101 -0
  68. package/app/components/sidebar/case-import/hooks/useImportExecution.ts +152 -0
  69. package/app/components/sidebar/case-import/hooks/useImportState.ts +88 -0
  70. package/app/components/sidebar/case-import/index.ts +18 -0
  71. package/app/components/sidebar/case-import/utils/file-validation.ts +43 -0
  72. package/app/components/sidebar/cases/case-sidebar.tsx +827 -0
  73. package/app/components/sidebar/cases/cases-modal.module.css +166 -0
  74. package/app/components/sidebar/cases/cases-modal.tsx +201 -0
  75. package/app/components/sidebar/cases/cases.module.css +713 -0
  76. package/app/components/sidebar/files/files-modal.module.css +209 -0
  77. package/app/components/sidebar/files/files-modal.tsx +239 -0
  78. package/app/components/sidebar/hash/hash-utility.module.css +366 -0
  79. package/app/components/sidebar/hash/hash-utility.tsx +982 -0
  80. package/app/components/sidebar/notes/notes-modal.tsx +51 -0
  81. package/app/components/sidebar/notes/notes-sidebar.tsx +491 -0
  82. package/app/components/sidebar/notes/notes.module.css +360 -0
  83. package/app/components/sidebar/sidebar-container.tsx +149 -0
  84. package/app/components/sidebar/sidebar.module.css +321 -0
  85. package/app/components/sidebar/sidebar.tsx +215 -0
  86. package/app/components/sidebar/upload/image-upload-zone.module.css +123 -0
  87. package/app/components/sidebar/upload/image-upload-zone.tsx +330 -0
  88. package/app/components/theme-provider/theme-provider.tsx +131 -0
  89. package/app/components/theme-provider/theme.ts +155 -0
  90. package/app/components/toast/toast.module.css +137 -0
  91. package/app/components/toast/toast.tsx +56 -0
  92. package/app/components/toolbar/toolbar-color-selector.module.css +171 -0
  93. package/app/components/toolbar/toolbar-color-selector.tsx +129 -0
  94. package/app/components/toolbar/toolbar.module.css +42 -0
  95. package/app/components/toolbar/toolbar.tsx +167 -0
  96. package/app/components/user/delete-account.module.css +274 -0
  97. package/app/components/user/delete-account.tsx +471 -0
  98. package/app/components/user/inactivity-warning.module.css +145 -0
  99. package/app/components/user/inactivity-warning.tsx +84 -0
  100. package/app/components/user/manage-profile.module.css +190 -0
  101. package/app/components/user/manage-profile.tsx +253 -0
  102. package/app/components/user/mfa-phone-update.tsx +739 -0
  103. package/app/config-example/admin-service.json +13 -0
  104. package/app/config-example/config.json +17 -0
  105. package/app/config-example/firebase.ts +21 -0
  106. package/app/config-example/inactivity.ts +13 -0
  107. package/app/config-example/meta-config.json +6 -0
  108. package/app/contexts/auth.context.ts +12 -0
  109. package/app/entry.client.tsx +12 -0
  110. package/app/entry.server.tsx +44 -0
  111. package/app/hooks/useInactivityTimeout.ts +110 -0
  112. package/app/root.tsx +170 -0
  113. package/app/routes/_index.tsx +16 -0
  114. package/app/routes/auth/emailActionHandler.module.css +232 -0
  115. package/app/routes/auth/emailActionHandler.tsx +405 -0
  116. package/app/routes/auth/emailVerification.tsx +120 -0
  117. package/app/routes/auth/login.module.css +523 -0
  118. package/app/routes/auth/login.tsx +654 -0
  119. package/app/routes/auth/passwordReset.module.css +274 -0
  120. package/app/routes/auth/passwordReset.tsx +154 -0
  121. package/app/routes/auth/route.ts +16 -0
  122. package/app/routes/mobile-prevented/mobilePrevented.module.css +47 -0
  123. package/app/routes/mobile-prevented/mobilePrevented.tsx +26 -0
  124. package/app/routes/mobile-prevented/route.ts +14 -0
  125. package/app/routes/striae/striae.module.css +30 -0
  126. package/app/routes/striae/striae.tsx +417 -0
  127. package/app/services/audit-export.service.ts +755 -0
  128. package/app/services/audit.service.ts +1454 -0
  129. package/app/services/firebase-errors.ts +106 -0
  130. package/app/services/firebase.ts +15 -0
  131. package/app/styles/legal-pages.module.css +113 -0
  132. package/app/styles/root.module.css +146 -0
  133. package/app/tailwind.css +225 -0
  134. package/app/types/annotations.ts +45 -0
  135. package/app/types/audit.ts +301 -0
  136. package/app/types/case.ts +90 -0
  137. package/app/types/export.ts +8 -0
  138. package/app/types/file.ts +30 -0
  139. package/app/types/import.ts +107 -0
  140. package/app/types/index.ts +24 -0
  141. package/app/types/user.ts +38 -0
  142. package/app/utils/SHA256.ts +461 -0
  143. package/app/utils/annotation-timestamp.ts +25 -0
  144. package/app/utils/audit-export-signature.ts +117 -0
  145. package/app/utils/auth-action-settings.ts +48 -0
  146. package/app/utils/auth.ts +34 -0
  147. package/app/utils/batch-operations.ts +135 -0
  148. package/app/utils/confirmation-signature.ts +193 -0
  149. package/app/utils/data-operations.ts +871 -0
  150. package/app/utils/device-detection.ts +5 -0
  151. package/app/utils/html-sanitizer.ts +80 -0
  152. package/app/utils/id-generator.ts +36 -0
  153. package/app/utils/meta.ts +48 -0
  154. package/app/utils/mfa-phone.ts +97 -0
  155. package/app/utils/mfa.ts +79 -0
  156. package/app/utils/password-policy.ts +28 -0
  157. package/app/utils/permissions.ts +562 -0
  158. package/app/utils/signature-utils.ts +160 -0
  159. package/app/utils/style.ts +83 -0
  160. package/app/utils/version.ts +5 -0
  161. package/firebase.json +11 -0
  162. package/functions/[[path]].ts +10 -0
  163. package/package.json +138 -0
  164. package/postcss.config.js +6 -0
  165. package/public/.well-known/publickey.info@striae.org.asc +17 -0
  166. package/public/.well-known/security.txt +7 -0
  167. package/public/_headers +28 -0
  168. package/public/_routes.json +13 -0
  169. package/public/assets/striae.jpg +0 -0
  170. package/public/clear.jpg +0 -0
  171. package/public/favicon.ico +0 -0
  172. package/public/favicon.svg +9 -0
  173. package/public/icon-256.png +0 -0
  174. package/public/icon-512.png +0 -0
  175. package/public/logo-dark.png +0 -0
  176. package/public/manifest.json +25 -0
  177. package/public/oin-badge.png +0 -0
  178. package/public/shortcut.png +0 -0
  179. package/public/social-image.png +0 -0
  180. package/public/striae-ascii.txt +10 -0
  181. package/scripts/deploy-all.sh +100 -0
  182. package/scripts/deploy-config.sh +940 -0
  183. package/scripts/deploy-pages.sh +34 -0
  184. package/scripts/deploy-worker-secrets.sh +215 -0
  185. package/scripts/dev.cjs +23 -0
  186. package/scripts/install-workers.sh +88 -0
  187. package/scripts/run-eslint.cjs +35 -0
  188. package/scripts/update-compatibility-dates.cjs +124 -0
  189. package/scripts/update-markdown-versions.cjs +43 -0
  190. package/tailwind.config.ts +22 -0
  191. package/tsconfig.json +33 -0
  192. package/vite.config.ts +35 -0
  193. package/worker-configuration.d.ts +7490 -0
  194. package/workers/audit-worker/package.json +17 -0
  195. package/workers/audit-worker/src/audit-worker.example.ts +195 -0
  196. package/workers/audit-worker/worker-configuration.d.ts +7448 -0
  197. package/workers/audit-worker/wrangler.jsonc.example +29 -0
  198. package/workers/data-worker/package.json +17 -0
  199. package/workers/data-worker/src/data-worker.example.ts +267 -0
  200. package/workers/data-worker/src/signature-utils.ts +79 -0
  201. package/workers/data-worker/src/signing-payload-utils.ts +290 -0
  202. package/workers/data-worker/worker-configuration.d.ts +7448 -0
  203. package/workers/data-worker/wrangler.jsonc.example +30 -0
  204. package/workers/image-worker/package.json +17 -0
  205. package/workers/image-worker/src/image-worker.example.ts +180 -0
  206. package/workers/image-worker/worker-configuration.d.ts +7447 -0
  207. package/workers/image-worker/wrangler.jsonc.example +22 -0
  208. package/workers/keys-worker/package.json +17 -0
  209. package/workers/keys-worker/src/keys.example.ts +66 -0
  210. package/workers/keys-worker/src/keys.ts +66 -0
  211. package/workers/keys-worker/worker-configuration.d.ts +7447 -0
  212. package/workers/keys-worker/wrangler.jsonc.example +22 -0
  213. package/workers/pdf-worker/package.json +17 -0
  214. package/workers/pdf-worker/src/format-striae.ts +534 -0
  215. package/workers/pdf-worker/src/pdf-worker.example.ts +119 -0
  216. package/workers/pdf-worker/src/report-types.ts +69 -0
  217. package/workers/pdf-worker/worker-configuration.d.ts +7448 -0
  218. package/workers/pdf-worker/wrangler.jsonc.example +26 -0
  219. package/workers/user-worker/package.json +17 -0
  220. package/workers/user-worker/src/user-worker.example.ts +636 -0
  221. package/workers/user-worker/worker-configuration.d.ts +7448 -0
  222. package/workers/user-worker/wrangler.jsonc.example +29 -0
  223. 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
+ }