@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,61 @@
1
+ import paths from '~/config/config.json';
2
+ import { getImageApiKey } from '~/utils/auth';
3
+ import { FileData, ImageUploadResponse } from '~/types';
4
+
5
+ const IMAGE_WORKER_URL = paths.image_worker_url;
6
+
7
+ /**
8
+ * Upload image blob to image worker and get file data
9
+ */
10
+ export async function uploadImageBlob(
11
+ imageBlob: Blob,
12
+ originalFilename: string,
13
+ onProgress?: (filename: string, progress: number) => void
14
+ ): Promise<FileData> {
15
+ const imagesApiToken = await getImageApiKey();
16
+
17
+ return new Promise((resolve, reject) => {
18
+ const xhr = new XMLHttpRequest();
19
+ const formData = new FormData();
20
+
21
+ // Create a File object from the blob to preserve the filename
22
+ const file = new File([imageBlob], originalFilename, { type: imageBlob.type });
23
+ formData.append('file', file);
24
+
25
+ xhr.upload.addEventListener('progress', (event) => {
26
+ if (event.lengthComputable && onProgress) {
27
+ const progress = Math.round((event.loaded / event.total) * 100);
28
+ onProgress(originalFilename, progress);
29
+ }
30
+ });
31
+
32
+ xhr.addEventListener('load', async () => {
33
+ if (xhr.status === 200) {
34
+ try {
35
+ const imageData = JSON.parse(xhr.responseText) as ImageUploadResponse;
36
+ if (!imageData.success) {
37
+ throw new Error(`Upload failed: ${imageData.errors?.join(', ') || 'Unknown error'}`);
38
+ }
39
+
40
+ const fileData: FileData = {
41
+ id: imageData.result.id,
42
+ originalFilename: originalFilename,
43
+ uploadedAt: new Date().toISOString()
44
+ };
45
+
46
+ resolve(fileData);
47
+ } catch (error) {
48
+ reject(error);
49
+ }
50
+ } else {
51
+ reject(new Error(`Upload failed with status ${xhr.status}`));
52
+ }
53
+ });
54
+
55
+ xhr.addEventListener('error', () => reject(new Error('Upload failed')));
56
+
57
+ xhr.open('POST', IMAGE_WORKER_URL);
58
+ xhr.setRequestHeader('Authorization', `Bearer ${imagesApiToken}`);
59
+ xhr.send(formData);
60
+ });
61
+ }
@@ -0,0 +1,39 @@
1
+ // Barrel export for case import functionality
2
+ // This maintains the same API as the original case-review.ts file
3
+
4
+ // Validation functions
5
+ export {
6
+ validateExporterUid,
7
+ isConfirmationDataFile,
8
+ validateConfirmationHash,
9
+ validateConfirmationSignatureFile,
10
+ validateCaseIntegrity
11
+ } from './validation';
12
+
13
+ // ZIP processing functions
14
+ export {
15
+ previewCaseImport,
16
+ parseImportZip
17
+ } from './zip-processing';
18
+
19
+ // Storage operations
20
+ export {
21
+ checkReadOnlyCaseExists,
22
+ addReadOnlyCaseToUser,
23
+ storeCaseDataInR2,
24
+ listReadOnlyCases,
25
+ removeReadOnlyCase,
26
+ deleteReadOnlyCase
27
+ } from './storage-operations';
28
+
29
+ // Image operations
30
+ export { uploadImageBlob } from './image-operations';
31
+
32
+ // Annotation import
33
+ export { importAnnotations } from './annotation-import';
34
+
35
+ // Confirmation import
36
+ export { importConfirmationData } from './confirmation-import';
37
+
38
+ // Main orchestrator
39
+ export { importCaseForReview } from './orchestrator';
@@ -0,0 +1,420 @@
1
+ import { User } from 'firebase/auth';
2
+ import { ImportOptions, ImportResult, ReadOnlyCaseMetadata, FileData } from '~/types';
3
+ import { checkExistingCase } from '../case-manage';
4
+ import {
5
+ extractForensicManifestData,
6
+ SignedForensicManifest,
7
+ validateCaseIntegritySecure as validateForensicIntegrity,
8
+ verifyForensicManifestSignature
9
+ } from '~/utils/SHA256';
10
+ import { deleteFile } from '../image-manage';
11
+ import { parseImportZip } from './zip-processing';
12
+ import {
13
+ checkReadOnlyCaseExists,
14
+ deleteReadOnlyCase,
15
+ storeCaseDataInR2,
16
+ addReadOnlyCaseToUser,
17
+ removeReadOnlyCase,
18
+ listReadOnlyCases
19
+ } from './storage-operations';
20
+ import { uploadImageBlob } from './image-operations';
21
+ import { importAnnotations } from './annotation-import';
22
+ import { auditService } from '~/services/audit.service';
23
+
24
+ /**
25
+ * Track the state of an import operation for cleanup purposes
26
+ */
27
+ interface ImportState {
28
+ uploadedFiles: FileData[];
29
+ caseDataStored: boolean;
30
+ userProfileUpdated: boolean;
31
+ caseNumber: string;
32
+ }
33
+
34
+ /**
35
+ * Clean up partially imported data when an import fails
36
+ */
37
+ async function cleanupPartialImport(
38
+ user: User,
39
+ state: ImportState,
40
+ onProgress?: (stage: string, progress: number, details?: string) => void
41
+ ): Promise<string[]> {
42
+ const cleanupWarnings: string[] = [];
43
+
44
+ try {
45
+ onProgress?.('Cleaning up partial import', 0, 'Starting cleanup...');
46
+
47
+ // Step 1: Remove user profile entry if it was added
48
+ if (state.userProfileUpdated) {
49
+ try {
50
+ onProgress?.('Cleaning up partial import', 25, 'Removing user profile entry...');
51
+ const removeSuccess = await removeReadOnlyCase(user, state.caseNumber);
52
+ if (!removeSuccess) {
53
+ cleanupWarnings.push('Failed to remove case from user profile during cleanup');
54
+ }
55
+ } catch (error) {
56
+ cleanupWarnings.push(`Error removing user profile entry: ${error instanceof Error ? error.message : 'Unknown error'}`);
57
+ }
58
+ }
59
+
60
+ // Step 2: Delete case data from R2 if it was stored
61
+ if (state.caseDataStored) {
62
+ try {
63
+ onProgress?.('Cleaning up partial import', 50, 'Removing case data...');
64
+ // Use the full deleteReadOnlyCase function to remove all R2 data
65
+ const deleteSuccess = await deleteReadOnlyCase(user, state.caseNumber);
66
+ if (!deleteSuccess) {
67
+ cleanupWarnings.push('Failed to remove case data during cleanup');
68
+ }
69
+ } catch (error) {
70
+ cleanupWarnings.push(`Error removing case data: ${error instanceof Error ? error.message : 'Unknown error'}`);
71
+ }
72
+ }
73
+
74
+ // Step 3: Delete uploaded images
75
+ if (state.uploadedFiles.length > 0) {
76
+ onProgress?.('Cleaning up partial import', 75, `Deleting ${state.uploadedFiles.length} uploaded images...`);
77
+
78
+ const deletePromises = state.uploadedFiles.map(async (file, index) => {
79
+ try {
80
+ await deleteFile(user, state.caseNumber, file.id);
81
+ } catch (error) {
82
+ cleanupWarnings.push(`Failed to delete image ${file.originalFilename}: ${error instanceof Error ? error.message : 'Unknown error'}`);
83
+ }
84
+
85
+ // Update progress for image deletion
86
+ const progress = 75 + (index / state.uploadedFiles.length) * 25;
87
+ onProgress?.('Cleaning up partial import', progress, `Deleted ${index + 1}/${state.uploadedFiles.length} images`);
88
+ });
89
+
90
+ await Promise.all(deletePromises);
91
+ }
92
+
93
+ onProgress?.('Cleaning up partial import', 100, 'Cleanup completed');
94
+
95
+ } catch (error) {
96
+ cleanupWarnings.push(`Cleanup process failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
97
+ }
98
+
99
+ return cleanupWarnings;
100
+ }
101
+
102
+ /**
103
+ * Main function to import a case for read-only viewing
104
+ */
105
+ export async function importCaseForReview(
106
+ user: User,
107
+ zipFile: File,
108
+ options: ImportOptions = {},
109
+ onProgress?: (stage: string, progress: number, details?: string) => void
110
+ ): Promise<ImportResult> {
111
+ const startTime = Date.now();
112
+
113
+ const result: ImportResult = {
114
+ success: false,
115
+ caseNumber: '',
116
+ isReadOnly: true,
117
+ filesImported: 0,
118
+ annotationsImported: 0,
119
+ errors: [],
120
+ warnings: []
121
+ };
122
+
123
+ // Track import state for cleanup purposes
124
+ const importState: ImportState = {
125
+ uploadedFiles: [],
126
+ caseDataStored: false,
127
+ userProfileUpdated: false,
128
+ caseNumber: ''
129
+ };
130
+
131
+ let hashValidationPassed = false;
132
+ let signatureValidationPassed = false;
133
+ let signatureKeyId: string | undefined;
134
+ let parsedForensicManifest: SignedForensicManifest | undefined;
135
+
136
+ try {
137
+ onProgress?.('Parsing ZIP file', 10, 'Extracting archive contents...');
138
+
139
+ // Step 1: Parse ZIP file
140
+ const { caseData, imageFiles, imageIdMapping, metadata, cleanedContent } = await parseImportZip(zipFile, user);
141
+ parsedForensicManifest = metadata?.forensicManifest as SignedForensicManifest | undefined;
142
+ result.caseNumber = caseData.metadata.caseNumber;
143
+ importState.caseNumber = result.caseNumber;
144
+
145
+ // Start audit workflow
146
+ auditService.startWorkflow(result.caseNumber);
147
+
148
+ // Step 1.1: Clean up any existing read-only cases (only one allowed at a time)
149
+ onProgress?.('Checking existing read-only cases', 12, 'Cleaning up previous imports...');
150
+ try {
151
+ const existingReadOnlyCases = await listReadOnlyCases(user);
152
+ if (existingReadOnlyCases.length > 0) {
153
+ console.log(`Found ${existingReadOnlyCases.length} existing read-only case(s). Cleaning up before new import.`);
154
+
155
+ // Delete all existing read-only cases (data and user references)
156
+ const deletePromises = existingReadOnlyCases.map(async (existingCase: ReadOnlyCaseMetadata) => {
157
+ try {
158
+ await deleteReadOnlyCase(user, existingCase.caseNumber);
159
+ console.log(`Cleaned up existing read-only case: ${existingCase.caseNumber}`);
160
+ } catch (error) {
161
+ console.warn(`Failed to clean up existing read-only case ${existingCase.caseNumber}:`, error);
162
+ // Don't throw here - just warn, as we want to proceed with the new import
163
+ }
164
+ });
165
+
166
+ await Promise.all(deletePromises);
167
+ }
168
+ } catch (error) {
169
+ console.warn('Error during pre-import cleanup of existing read-only cases:', error);
170
+ // Don't fail the import due to cleanup issues
171
+ }
172
+
173
+ // Step 1.5: Validate hash if forensic metadata exists
174
+ if (parsedForensicManifest && cleanedContent) {
175
+ onProgress?.('Validating comprehensive integrity', 15, 'Checking all file hashes...');
176
+
177
+ const manifestForValidation = extractForensicManifestData(parsedForensicManifest);
178
+ if (!manifestForValidation) {
179
+ throw new Error(
180
+ 'Forensic manifest structure is invalid. Import cannot proceed.'
181
+ );
182
+ }
183
+
184
+ const signatureResult = await verifyForensicManifestSignature(parsedForensicManifest);
185
+ signatureValidationPassed = signatureResult.isValid;
186
+ signatureKeyId = signatureResult.keyId;
187
+
188
+ if (!signatureResult.isValid) {
189
+ throw new Error(
190
+ `Manifest signature validation failed: ${signatureResult.error || 'Unknown signature error'}. Import cannot proceed.`
191
+ );
192
+ }
193
+
194
+ // Extract image files for comprehensive validation
195
+ const imageBlobs: { [filename: string]: Blob } = {};
196
+ for (const [filename, blob] of Object.entries(imageFiles)) {
197
+ imageBlobs[filename] = blob;
198
+ }
199
+
200
+ // Perform comprehensive validation
201
+ const validation = await validateForensicIntegrity(
202
+ cleanedContent,
203
+ imageBlobs,
204
+ manifestForValidation
205
+ );
206
+
207
+ if (!validation.isValid) {
208
+ throw new Error(
209
+ `Comprehensive integrity validation failed: ${validation.summary}. ` +
210
+ `Errors: ${validation.errors.join(', ')}. Import cannot proceed.`
211
+ );
212
+ }
213
+
214
+ hashValidationPassed = true;
215
+ onProgress?.(
216
+ 'Complete integrity verified',
217
+ 18,
218
+ `${validation.summary}. Signature verified${signatureKeyId ? ` (${signatureKeyId})` : ''}`
219
+ );
220
+
221
+ } else {
222
+ // No forensic manifest found - cannot import
223
+ throw new Error(
224
+ 'No forensic manifest found in case export. This case export does not support comprehensive ' +
225
+ 'integrity validation and cannot be imported. Please re-export the case with forensic protection enabled.'
226
+ );
227
+ }
228
+
229
+ onProgress?.('Validating case data', 20, `Case: ${result.caseNumber}`);
230
+
231
+ // Step 2a: Check if case already exists in user's regular cases (original analyst)
232
+ const existingRegularCase = await checkExistingCase(user, result.caseNumber);
233
+ if (existingRegularCase) {
234
+ 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.`);
235
+ }
236
+
237
+ // Step 2b: Check if read-only case already exists
238
+ const existingCase = await checkReadOnlyCaseExists(user, result.caseNumber);
239
+ if (existingCase && !options.overwriteExisting) {
240
+ throw new Error(`Read-only case "${result.caseNumber}" already exists. Use overwriteExisting option to replace it.`);
241
+ }
242
+
243
+ if (existingCase) {
244
+ result.warnings?.push('Overwriting existing read-only case');
245
+
246
+ // Step 2c: Clean up existing read-only case data before importing new data
247
+ onProgress?.('Cleaning up existing case', 25, 'Removing existing case data...');
248
+ const cleanupSuccess = await deleteReadOnlyCase(user, result.caseNumber);
249
+ if (!cleanupSuccess) {
250
+ result.warnings?.push('Some existing case data may not have been fully cleaned up');
251
+ }
252
+ }
253
+
254
+ onProgress?.('Uploading images', 30, 'Processing image files...');
255
+
256
+ // Step 3: Upload all image files and create original image ID to new file ID mapping
257
+ const originalImageIdMapping = new Map<string, string>(); // originalImageId -> newFileId
258
+ const importedFiles = [];
259
+
260
+ let uploadedCount = 0;
261
+ const totalImages = Object.keys(imageFiles).length;
262
+
263
+ for (const [exportFilename, blob] of Object.entries(imageFiles)) {
264
+ try {
265
+ // Get the original image ID from the filename
266
+ const originalImageId = imageIdMapping[exportFilename];
267
+
268
+ if (!originalImageId) {
269
+ console.warn(`Could not extract image ID from filename: ${exportFilename}`);
270
+ continue;
271
+ }
272
+
273
+ // Find the original file entry to get the actual original filename
274
+ const originalFileEntry = caseData.files.find(f => f.fileData.id === originalImageId);
275
+ const originalFilename = originalFileEntry?.fileData.originalFilename || exportFilename;
276
+
277
+ const fileData = await uploadImageBlob(blob, originalFilename, (fname, progress) => {
278
+ const overallProgress = 30 + (uploadedCount / totalImages) * 40 + (progress / totalImages) * 0.4;
279
+ onProgress?.('Uploading images', overallProgress, `Uploading ${fname}...`);
280
+ });
281
+
282
+ // Map original image ID to new file ID
283
+ originalImageIdMapping.set(originalImageId, fileData.id);
284
+
285
+ importedFiles.push(fileData);
286
+ importState.uploadedFiles.push(fileData);
287
+ uploadedCount++;
288
+
289
+ const overallProgress = 30 + (uploadedCount / totalImages) * 40;
290
+ onProgress?.('Uploading images', overallProgress, `Uploaded ${uploadedCount}/${totalImages} files`);
291
+
292
+ } catch (error) {
293
+ result.errors?.push(`Failed to upload ${exportFilename}: ${error instanceof Error ? error.message : 'Unknown error'}`);
294
+ }
295
+ }
296
+
297
+ result.filesImported = importedFiles.length;
298
+
299
+ if (importedFiles.length === 0) {
300
+ throw new Error('No images were successfully uploaded');
301
+ }
302
+
303
+ onProgress?.('Storing case data', 75, 'Creating case structure...');
304
+
305
+ // Step 4: Store case data in R2
306
+ await storeCaseDataInR2(
307
+ user,
308
+ result.caseNumber,
309
+ caseData,
310
+ importedFiles,
311
+ originalImageIdMapping,
312
+ parsedForensicManifest
313
+ );
314
+ importState.caseDataStored = true;
315
+
316
+ onProgress?.('Importing annotations', 85, 'Processing annotations...');
317
+
318
+ // Step 5: Import annotations
319
+ result.annotationsImported = await importAnnotations(user, result.caseNumber, caseData, originalImageIdMapping);
320
+
321
+ onProgress?.('Updating user profile', 95, 'Finalizing import...');
322
+
323
+ // Step 6: Add read-only case to user profile
324
+ const caseMetadata: ReadOnlyCaseMetadata = {
325
+ caseNumber: result.caseNumber,
326
+ importedAt: new Date().toISOString(),
327
+ originalExportDate: caseData.metadata.exportDate,
328
+ originalExportedBy: caseData.metadata.exportedBy || 'Unknown',
329
+ sourceHash: parsedForensicManifest?.manifestHash,
330
+ sourceManifestVersion: parsedForensicManifest?.manifestVersion,
331
+ sourceSignatureKeyId: parsedForensicManifest?.signature?.keyId,
332
+ sourceSignatureValid: signatureValidationPassed,
333
+ isReadOnly: true
334
+ };
335
+
336
+ await addReadOnlyCaseToUser(user, caseMetadata);
337
+ importState.userProfileUpdated = true;
338
+
339
+ onProgress?.('Import complete', 100, 'Case successfully imported for review');
340
+
341
+ result.success = true;
342
+
343
+ // Log successful case import
344
+ const endTime = Date.now();
345
+ await auditService.logCaseImport(
346
+ user,
347
+ result.caseNumber,
348
+ zipFile.name,
349
+ 'success',
350
+ hashValidationPassed,
351
+ [],
352
+ undefined, // Don't use for self-confirmation prevention for read-only imports
353
+ {
354
+ processingTimeMs: endTime - startTime,
355
+ fileSizeBytes: zipFile.size,
356
+ validationStepsCompleted: result.filesImported + result.annotationsImported,
357
+ validationStepsFailed: 0
358
+ },
359
+ true, // Exporter UID was validated during zip parsing
360
+ {
361
+ present: !!parsedForensicManifest,
362
+ valid: signatureValidationPassed,
363
+ keyId: signatureKeyId
364
+ }
365
+ );
366
+
367
+ auditService.endWorkflow();
368
+
369
+ return result;
370
+
371
+ } catch (error) {
372
+ console.error('Case import failed:', error);
373
+ result.success = false;
374
+ result.errors?.push(error instanceof Error ? error.message : 'Unknown error occurred during import');
375
+
376
+ // Log failed case import
377
+ const endTime = Date.now();
378
+ await auditService.logCaseImport(
379
+ user,
380
+ result.caseNumber || 'unknown',
381
+ zipFile.name,
382
+ 'failure',
383
+ hashValidationPassed, // Use actual hash validation result even for failures
384
+ result.errors || [],
385
+ undefined, // Don't use for self-confirmation prevention for read-only imports
386
+ {
387
+ processingTimeMs: endTime - startTime,
388
+ fileSizeBytes: zipFile.size
389
+ },
390
+ false, // If import failed, exporter UID validation may not have completed
391
+ {
392
+ present: !!parsedForensicManifest,
393
+ valid: signatureValidationPassed,
394
+ keyId: signatureKeyId
395
+ }
396
+ );
397
+
398
+ auditService.endWorkflow();
399
+
400
+ // Cleanup any partially imported data
401
+ if (importState.uploadedFiles.length > 0 || importState.caseDataStored || importState.userProfileUpdated) {
402
+ console.log('Import failed, cleaning up partial data...');
403
+ try {
404
+ const cleanupWarnings = await cleanupPartialImport(user, importState, onProgress);
405
+ if (cleanupWarnings.length > 0) {
406
+ result.warnings?.push(...cleanupWarnings);
407
+ console.warn('Cleanup completed with warnings:', cleanupWarnings);
408
+ } else {
409
+ console.log('Cleanup completed successfully');
410
+ }
411
+ } catch (cleanupError) {
412
+ const cleanupErrorMsg = `Cleanup failed: ${cleanupError instanceof Error ? cleanupError.message : 'Unknown cleanup error'}`;
413
+ result.warnings?.push(cleanupErrorMsg);
414
+ console.error('Cleanup failed:', cleanupError);
415
+ }
416
+ }
417
+
418
+ return result;
419
+ }
420
+ }