@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,41 @@
1
+ // Re-export all case export functionality from the modular structure
2
+ // This maintains backward compatibility with existing imports
3
+
4
+ // Types and constants
5
+ export type { ExportFormat } from './types-constants';
6
+ export { CSV_HEADERS, formatDateForFilename } from './types-constants';
7
+
8
+ // Metadata and protection helpers
9
+ export {
10
+ getUserExportMetadata,
11
+ addForensicDataWarning,
12
+ generateRandomPassword,
13
+ protectExcelWorksheet
14
+ } from './metadata-helpers';
15
+
16
+ // Data processing functions
17
+ export {
18
+ generateMetadataRows,
19
+ processFileDataForTabular,
20
+ generateCSVContent
21
+ } from './data-processing';
22
+
23
+ // Core export functions
24
+ export {
25
+ exportAllCases,
26
+ exportCaseData
27
+ } from './core-export';
28
+
29
+ // Download handlers
30
+ export {
31
+ downloadAllCasesAsJSON,
32
+ downloadAllCasesAsCSV,
33
+ downloadCaseAsJSON,
34
+ downloadCaseAsCSV,
35
+ downloadCaseAsZip
36
+ } from './download-handlers';
37
+
38
+ // Validation utilities
39
+ export {
40
+ validateCaseNumberForExport
41
+ } from './validation-utils';
@@ -0,0 +1,107 @@
1
+ import { User } from 'firebase/auth';
2
+ import { getUserData } from '~/utils/permissions';
3
+
4
+ /**
5
+ * Helper function to get user export metadata
6
+ */
7
+ export async function getUserExportMetadata(user: User) {
8
+ try {
9
+ const userData = await getUserData(user);
10
+ if (userData) {
11
+ return {
12
+ exportedBy: user.email,
13
+ exportedByUid: userData.uid,
14
+ exportedByName: `${userData.firstName} ${userData.lastName}`.trim(),
15
+ exportedByCompany: userData.company
16
+ };
17
+ }
18
+ } catch (error) {
19
+ console.warn('Failed to fetch user data for export metadata:', error);
20
+ }
21
+
22
+ // Fallback to basic user data if getUserData fails
23
+ return {
24
+ exportedBy: user.email,
25
+ exportedByUid: user.uid,
26
+ exportedByName: user.displayName || 'N/A',
27
+ exportedByCompany: 'N/A'
28
+ };
29
+ }
30
+
31
+ /**
32
+ * Add data protection warning to content
33
+ */
34
+ export function addForensicDataWarning(content: string, format: 'csv' | 'json'): string {
35
+ const warning = format === 'csv'
36
+ ? `"CASE DATA WARNING: This file contains evidence data for forensic examination. Any modification may compromise the integrity of the evidence. Handle according to your organization's chain of custody procedures."\n\n`
37
+ : `/* CASE DATA WARNING
38
+ * This file contains evidence data for forensic examination.
39
+ * Any modification may compromise the integrity of the evidence.
40
+ * Handle according to your organization's chain of custody procedures.
41
+ *
42
+ * File generated: ${new Date().toISOString()}
43
+ */\n\n`;
44
+
45
+ return warning + content;
46
+ }
47
+
48
+ /**
49
+ * Generate a secure random password for Excel protection
50
+ */
51
+ export function generateRandomPassword(): string {
52
+ const charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*';
53
+ const length = 16;
54
+ let password = '';
55
+
56
+ // Ensure we have at least one of each type
57
+ password += 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'[Math.floor(Math.random() * 26)]; // Uppercase
58
+ password += 'abcdefghijklmnopqrstuvwxyz'[Math.floor(Math.random() * 26)]; // Lowercase
59
+ password += '0123456789'[Math.floor(Math.random() * 10)]; // Number
60
+ password += '!@#$%^&*'[Math.floor(Math.random() * 8)]; // Special char
61
+
62
+ // Fill remaining length with random characters
63
+ for (let i = password.length; i < length; i++) {
64
+ password += charset[Math.floor(Math.random() * charset.length)];
65
+ }
66
+
67
+ // Shuffle the password to randomize character positions
68
+ return password.split('').sort(() => Math.random() - 0.5).join('');
69
+ }
70
+
71
+ /**
72
+ * Protect Excel worksheet from editing
73
+ */
74
+ export function protectExcelWorksheet(worksheet: any, sheetPassword?: string): string {
75
+ // Generate random password if none provided
76
+ const password = sheetPassword || generateRandomPassword();
77
+
78
+ // Set worksheet protection
79
+ worksheet['!protect'] = {
80
+ password: password,
81
+ selectLockedCells: true,
82
+ selectUnlockedCells: true,
83
+ formatCells: false,
84
+ formatColumns: false,
85
+ formatRows: false,
86
+ insertColumns: false,
87
+ insertRows: false,
88
+ insertHyperlinks: false,
89
+ deleteColumns: false,
90
+ deleteRows: false,
91
+ sort: false,
92
+ autoFilter: false,
93
+ pivotTables: false,
94
+ objects: false,
95
+ scenarios: false
96
+ };
97
+
98
+ // Lock all cells by default
99
+ if (!worksheet['!cols']) worksheet['!cols'] = [];
100
+ if (!worksheet['!rows']) worksheet['!rows'] = [];
101
+
102
+ // Add protection metadata
103
+ worksheet['!margins'] = { left: 0.7, right: 0.7, top: 0.75, bottom: 0.75, header: 0.3, footer: 0.3 };
104
+
105
+ // Return the password for inclusion in metadata
106
+ return password;
107
+ }
@@ -0,0 +1,56 @@
1
+ export type ExportFormat = 'json' | 'csv';
2
+
3
+ // Shared CSV headers for all tabular exports
4
+ export const CSV_HEADERS = [
5
+ 'File ID',
6
+ 'Original Filename',
7
+ 'Upload Date',
8
+ 'Has Annotations',
9
+ 'Left Case',
10
+ 'Right Case',
11
+ 'Left Item',
12
+ 'Right Item',
13
+ 'Case Font Color',
14
+ 'Class Type',
15
+ 'Custom Class',
16
+ 'Class Note',
17
+ 'Index Type',
18
+ 'Index Number',
19
+ 'Index Color',
20
+ 'Support Level',
21
+ 'Has Subclass',
22
+ 'Include Confirmation',
23
+ 'Confirmation Status',
24
+ 'Confirming Examiner Name',
25
+ 'Confirming Examiner Badge ID',
26
+ 'Confirming Examiner Email',
27
+ 'Confirming Examiner Company',
28
+ 'Confirmation ID',
29
+ 'Confirmation Timestamp',
30
+ 'Confirmation Date (ISO)',
31
+ 'Total Box Annotations',
32
+ 'Box ID',
33
+ 'Box X',
34
+ 'Box Y',
35
+ 'Box Width',
36
+ 'Box Height',
37
+ 'Box Color',
38
+ 'Box Label',
39
+ 'Box Timestamp',
40
+ 'Additional Notes',
41
+ 'Last Updated'
42
+ ];
43
+
44
+ /**
45
+ * Helper function to format timestamp for filename using user's local timezone
46
+ */
47
+ export function formatDateForFilename(date: Date): string {
48
+ // Generate timestamp in local timezone: YYYYMMDD-HHMMSS
49
+ const year = date.getFullYear();
50
+ const month = String(date.getMonth() + 1).padStart(2, '0');
51
+ const day = String(date.getDate()).padStart(2, '0');
52
+ const hours = String(date.getHours()).padStart(2, '0');
53
+ const minutes = String(date.getMinutes()).padStart(2, '0');
54
+ const seconds = String(date.getSeconds()).padStart(2, '0');
55
+ return `${year}${month}${day}-${hours}${minutes}${seconds}`;
56
+ }
@@ -0,0 +1,25 @@
1
+ import { validateCaseNumber } from '../case-manage';
2
+
3
+ /**
4
+ * Validate case number format for export (includes file system checks)
5
+ */
6
+ export function validateCaseNumberForExport(caseNumber: string): { isValid: boolean; error?: string } {
7
+ if (!caseNumber || !caseNumber.trim()) {
8
+ return { isValid: false, error: 'Case number is required' };
9
+ }
10
+
11
+ const trimmed = caseNumber.trim();
12
+
13
+ // Use the main validation function first
14
+ if (!validateCaseNumber(trimmed)) {
15
+ return { isValid: false, error: 'Invalid case number format (only letters, numbers, and hyphens allowed, max 25 characters)' };
16
+ }
17
+
18
+ // Additional file system validation for export
19
+ const invalidChars = /[<>:"/\\|?*]/;
20
+ if (invalidChars.test(trimmed)) {
21
+ return { isValid: false, error: 'Case number contains invalid characters for file export' };
22
+ }
23
+
24
+ return { isValid: true };
25
+ }
@@ -0,0 +1,4 @@
1
+ // Re-export all case export functionality from the modular structure
2
+ // This maintains backward compatibility while improving maintainability
3
+ export * from './case-export/index';
4
+
@@ -0,0 +1,35 @@
1
+ import { User } from 'firebase/auth';
2
+ import { CaseExportData } from '~/types';
3
+ import { saveNotes } from '../notes-manage';
4
+
5
+ /**
6
+ * Import annotations for all files in the case
7
+ */
8
+ export async function importAnnotations(
9
+ user: User,
10
+ caseNumber: string,
11
+ caseData: CaseExportData,
12
+ originalImageIdMapping: Map<string, string> // originalImageId -> newFileId
13
+ ): Promise<number> {
14
+ let annotationsImported = 0;
15
+
16
+ try {
17
+ for (const fileEntry of caseData.files) {
18
+ if (fileEntry.annotations && fileEntry.hasAnnotations) {
19
+ const originalImageId = fileEntry.fileData.id;
20
+ const newFileId = originalImageIdMapping.get(originalImageId);
21
+ if (newFileId) {
22
+ // Save annotations using the existing notes management system
23
+ // Bypass access validation for read-only case imports
24
+ await saveNotes(user, caseNumber, newFileId, fileEntry.annotations, { skipValidation: true });
25
+ annotationsImported++;
26
+ }
27
+ }
28
+ }
29
+ } catch (error) {
30
+ console.error('Error importing annotations:', error);
31
+ throw error;
32
+ }
33
+
34
+ return annotationsImported;
35
+ }
@@ -0,0 +1,363 @@
1
+ import { User } from 'firebase/auth';
2
+ import paths from '~/config/config.json';
3
+ import { getDataApiKey } from '~/utils/auth';
4
+ import { ConfirmationImportResult, ConfirmationImportData } from '~/types';
5
+ import { checkExistingCase } from '../case-manage';
6
+ import { validateExporterUid, validateConfirmationHash, validateConfirmationSignatureFile } from './validation';
7
+ import { auditService } from '~/services/audit.service';
8
+
9
+ const DATA_WORKER_URL = paths.data_worker_url;
10
+
11
+ /**
12
+ * Import confirmation data from JSON file
13
+ */
14
+ export async function importConfirmationData(
15
+ user: User,
16
+ confirmationFile: File,
17
+ onProgress?: (stage: string, progress: number, details?: string) => void
18
+ ): Promise<ConfirmationImportResult> {
19
+ const startTime = Date.now();
20
+ let hashValid = false;
21
+ let signatureValid = false;
22
+ let signaturePresent = false;
23
+ let signatureKeyId: string | undefined;
24
+
25
+ const result: ConfirmationImportResult = {
26
+ success: false,
27
+ caseNumber: '',
28
+ confirmationsImported: 0,
29
+ imagesUpdated: 0,
30
+ errors: [],
31
+ warnings: []
32
+ };
33
+
34
+ try {
35
+ onProgress?.('Reading confirmation file', 10, 'Loading JSON data...');
36
+
37
+ // Read and parse the JSON file
38
+ const fileContent = await confirmationFile.text();
39
+ const confirmationData: ConfirmationImportData = JSON.parse(fileContent);
40
+ result.caseNumber = confirmationData.metadata.caseNumber;
41
+
42
+ // Start audit workflow
43
+ auditService.startWorkflow(result.caseNumber);
44
+
45
+ onProgress?.('Validating hash', 20, 'Verifying data integrity...');
46
+
47
+ // Validate hash
48
+ hashValid = await validateConfirmationHash(fileContent, confirmationData.metadata.hash);
49
+ if (!hashValid) {
50
+ throw new Error('Confirmation data hash validation failed. The file may have been tampered with or corrupted.');
51
+ }
52
+
53
+ onProgress?.('Validating signature', 30, 'Verifying signed confirmation metadata...');
54
+
55
+ const signatureResult = await validateConfirmationSignatureFile(confirmationData);
56
+ signaturePresent = !!confirmationData.metadata.signature;
57
+ signatureValid = signatureResult.isValid;
58
+ signatureKeyId = signatureResult.keyId;
59
+ if (!signatureResult.isValid) {
60
+ throw new Error(
61
+ `Confirmation signature validation failed: ${signatureResult.error || 'Unknown signature error'}`
62
+ );
63
+ }
64
+
65
+ onProgress?.('Validating exporter', 40, 'Checking exporter credentials...');
66
+
67
+ // Validate exporter UID exists and is not current user
68
+ const validation = await validateExporterUid(confirmationData.metadata.exportedByUid, user);
69
+
70
+ if (!validation.exists) {
71
+ throw new Error(`Reviewer does not exist in the user database.`);
72
+ }
73
+
74
+ if (validation.isSelf) {
75
+ throw new Error('You cannot import confirmation data that you exported yourself.');
76
+ }
77
+
78
+ onProgress?.('Validating case', 50, 'Checking case exists...');
79
+
80
+ // Check if case exists in user's regular cases
81
+ const caseExists = await checkExistingCase(user, result.caseNumber);
82
+ if (!caseExists) {
83
+ throw new Error(`Case "${result.caseNumber}" does not exist in your case list. You can only import confirmations for your own cases.`);
84
+ }
85
+
86
+ onProgress?.('Processing confirmations', 60, 'Validating timestamps and updating annotations...');
87
+
88
+ // Get case data to find image IDs
89
+ const apiKey = await getDataApiKey();
90
+ const caseResponse = await fetch(`${DATA_WORKER_URL}/${encodeURIComponent(user.uid)}/${encodeURIComponent(result.caseNumber)}/data.json`, {
91
+ method: 'GET',
92
+ headers: {
93
+ 'X-Custom-Auth-Key': apiKey
94
+ }
95
+ });
96
+
97
+ if (!caseResponse.ok) {
98
+ throw new Error(`Failed to fetch case data: ${caseResponse.status}`);
99
+ }
100
+
101
+ const caseData = await caseResponse.json() as any; // Using any for flexibility with originalImageIds
102
+
103
+ // Build mapping from original image IDs to current image IDs
104
+ const imageIdMapping = new Map<string, string>();
105
+
106
+ // If the case has originalImageIds mapping (from read-only import), use that
107
+ if (caseData.originalImageIds) {
108
+ for (const [originalId, currentId] of Object.entries(caseData.originalImageIds)) {
109
+ imageIdMapping.set(originalId, currentId as string);
110
+ }
111
+ } else {
112
+ // For regular cases, assume original IDs match current IDs
113
+ for (const file of caseData.files) {
114
+ imageIdMapping.set(file.id, file.id);
115
+ }
116
+ }
117
+
118
+ let processedCount = 0;
119
+ const totalConfirmations = Object.keys(confirmationData.confirmations).length;
120
+
121
+ // Process each confirmation
122
+ for (const [originalImageId, confirmations] of Object.entries(confirmationData.confirmations)) {
123
+ const currentImageId = imageIdMapping.get(originalImageId);
124
+
125
+ if (!currentImageId) {
126
+ result.warnings?.push(`Could not find image with original ID: ${originalImageId}`);
127
+ continue;
128
+ }
129
+
130
+ // Get the original filename for user-friendly messages
131
+ const currentFile = caseData.files.find((file: any) => file.id === currentImageId);
132
+ const displayFilename = currentFile?.originalFilename || currentImageId;
133
+
134
+ // Get current annotation data for this image
135
+ const annotationResponse = await fetch(`${DATA_WORKER_URL}/${encodeURIComponent(user.uid)}/${encodeURIComponent(result.caseNumber)}/${encodeURIComponent(currentImageId)}/data.json`, {
136
+ method: 'GET',
137
+ headers: {
138
+ 'X-Custom-Auth-Key': apiKey
139
+ }
140
+ });
141
+
142
+ let annotationData = {};
143
+ if (annotationResponse.ok) {
144
+ annotationData = await annotationResponse.json();
145
+ }
146
+
147
+ // Check if confirmation data already exists
148
+ if ((annotationData as any).confirmationData) {
149
+ result.warnings?.push(`Image ${displayFilename} already has confirmation data - skipping`);
150
+ continue;
151
+ }
152
+
153
+ // Validate that annotations haven't been modified after original export
154
+ const importedConfirmationData = confirmations.length > 0 ? confirmations[0] : null;
155
+ if (importedConfirmationData && confirmationData.metadata.originalExportCreatedAt && (annotationData as any).updatedAt) {
156
+ const originalExportDate = new Date(confirmationData.metadata.originalExportCreatedAt);
157
+ const annotationUpdatedAt = new Date((annotationData as any).updatedAt);
158
+
159
+ if (annotationUpdatedAt > originalExportDate) {
160
+ // Format timestamps in user's timezone
161
+ const formattedExportDate = originalExportDate.toLocaleString();
162
+ const formattedUpdatedDate = annotationUpdatedAt.toLocaleString();
163
+
164
+ result.errors?.push(
165
+ `Cannot import confirmation for image "${displayFilename}" (${importedConfirmationData.confirmationId}). ` +
166
+ `The annotations were last modified at ${formattedUpdatedDate} which is after ` +
167
+ `the original case export date of ${formattedExportDate}. ` +
168
+ `Confirmations can only be imported for images that haven't been modified since the original export.`
169
+ );
170
+ continue; // Skip this image and continue with others
171
+ }
172
+ } else if (importedConfirmationData && !confirmationData.metadata.originalExportCreatedAt) {
173
+ // Block legacy confirmation data without forensic linking
174
+ result.errors?.push(
175
+ `Cannot import confirmation for image "${displayFilename}" (${importedConfirmationData.confirmationId}). ` +
176
+ `This confirmation data lacks forensic timestamp linking and cannot be validated. ` +
177
+ `Only confirmation exports with complete forensic metadata are accepted.`
178
+ );
179
+ continue; // Skip this image and continue with others
180
+ }
181
+
182
+ // Set confirmationData from the imported confirmations (use the first/most recent one)
183
+ const updatedAnnotationData = {
184
+ ...annotationData,
185
+ // Ensure includeConfirmation remains true (original analyst requested confirmation)
186
+ includeConfirmation: true,
187
+ // Set the confirmation data from import (single object, no array needed)
188
+ confirmationData: importedConfirmationData
189
+ };
190
+
191
+ // Save updated annotation data
192
+ const saveResponse = await fetch(`${DATA_WORKER_URL}/${encodeURIComponent(user.uid)}/${encodeURIComponent(result.caseNumber)}/${encodeURIComponent(currentImageId)}/data.json`, {
193
+ method: 'PUT',
194
+ headers: {
195
+ 'Content-Type': 'application/json',
196
+ 'X-Custom-Auth-Key': apiKey
197
+ },
198
+ body: JSON.stringify(updatedAnnotationData)
199
+ });
200
+
201
+ if (saveResponse.ok) {
202
+ result.imagesUpdated++;
203
+ result.confirmationsImported += confirmations.length;
204
+
205
+ // Audit log successful confirmation import
206
+ try {
207
+ await auditService.logAnnotationEdit(
208
+ user,
209
+ `${result.caseNumber}-${currentImageId}`,
210
+ annotationData, // Previous state (without confirmation)
211
+ updatedAnnotationData, // New state (with confirmation)
212
+ result.caseNumber,
213
+ 'confirmation-import',
214
+ currentImageId,
215
+ displayFilename
216
+ );
217
+ } catch (auditError) {
218
+ console.error('Failed to log confirmation import audit:', auditError);
219
+ }
220
+ } else {
221
+ result.warnings?.push(`Failed to update image ${displayFilename}: ${saveResponse.status}`);
222
+
223
+ // Audit log failed confirmation import
224
+ try {
225
+ await auditService.logAnnotationEdit(
226
+ user,
227
+ `${result.caseNumber}-${currentImageId}`,
228
+ annotationData, // Previous state
229
+ null, // Failed save
230
+ result.caseNumber,
231
+ 'confirmation-import',
232
+ currentImageId,
233
+ displayFilename
234
+ );
235
+ } catch (auditError) {
236
+ console.error('Failed to log failed confirmation import audit:', auditError);
237
+ }
238
+ }
239
+
240
+ processedCount++;
241
+ const progress = 60 + (processedCount / totalConfirmations) * 35;
242
+ onProgress?.('Processing confirmations', progress, `Updated ${result.imagesUpdated} images...`);
243
+ }
244
+
245
+ const blockedCount = (result.errors?.length || 0);
246
+ const successMessage = blockedCount > 0
247
+ ? `Imported ${result.confirmationsImported} confirmations, ${blockedCount} blocked`
248
+ : `Successfully imported ${result.confirmationsImported} confirmations`;
249
+
250
+ onProgress?.('Import complete', 100, successMessage);
251
+
252
+ // If there were errors (blocked confirmations), include that in the result message
253
+ if (result.errors && result.errors.length > 0) {
254
+ result.success = result.confirmationsImported > 0; // Success if at least one confirmation was imported
255
+ } else {
256
+ result.success = true;
257
+ }
258
+
259
+ // Log confirmation import audit event
260
+ const endTime = Date.now();
261
+ await auditService.logConfirmationImport(
262
+ user,
263
+ result.caseNumber,
264
+ confirmationFile.name,
265
+ result.success ? (result.errors && result.errors.length > 0 ? 'warning' : 'success') : 'failure',
266
+ hashValid,
267
+ result.confirmationsImported, // Successfully imported confirmations
268
+ result.errors || [],
269
+ confirmationData.metadata.exportedByUid,
270
+ {
271
+ processingTimeMs: endTime - startTime,
272
+ fileSizeBytes: confirmationFile.size,
273
+ validationStepsCompleted: result.confirmationsImported, // Successfully imported
274
+ validationStepsFailed: result.errors ? result.errors.length : 0
275
+ },
276
+ true, // exporterUidValidated - true for successful imports
277
+ confirmationData.metadata.totalConfirmations, // Total confirmations in file
278
+ {
279
+ present: signaturePresent,
280
+ valid: signatureValid,
281
+ keyId: signatureKeyId
282
+ }
283
+ );
284
+
285
+ auditService.endWorkflow();
286
+
287
+ return result;
288
+
289
+ } catch (error) {
290
+ console.error('Confirmation import failed:', error);
291
+ result.success = false;
292
+ result.errors?.push(error instanceof Error ? error.message : 'Unknown error occurred during confirmation import');
293
+
294
+ // Log failed confirmation import
295
+ const endTime = Date.now();
296
+
297
+ // Determine what validation failed based on error message - each check is independent
298
+ let hashValidForAudit = hashValid;
299
+ let exporterUidValidatedForAudit = true;
300
+ let reviewingExaminerUidForAudit: string | undefined = undefined;
301
+ let totalConfirmationsForAudit = 0; // Default to 0 for failed imports
302
+ let signaturePresentForAudit = signaturePresent;
303
+ let signatureValidForAudit = signatureValid;
304
+ let signatureKeyIdForAudit = signatureKeyId;
305
+
306
+ // First, try to extract basic metadata for audit purposes (if file is parseable)
307
+ try {
308
+ const confirmationData: any = JSON.parse(await confirmationFile.text());
309
+ reviewingExaminerUidForAudit = confirmationData.metadata?.exportedByUid;
310
+ totalConfirmationsForAudit = confirmationData.metadata?.totalConfirmations || 0;
311
+ if (confirmationData.metadata?.signature) {
312
+ signaturePresentForAudit = true;
313
+ signatureKeyIdForAudit = confirmationData.metadata.signature.keyId;
314
+ }
315
+ } catch {
316
+ // If we can't parse the file, keep undefined/default values
317
+ }
318
+
319
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error';
320
+ if (errorMessage.includes('hash validation failed')) {
321
+ // Hash failed - only flag file integrity, don't affect other validations
322
+ hashValidForAudit = false;
323
+ // We still pass reviewingExaminerUid if we could extract it for audit purposes
324
+ // exporterUidValidatedForAudit stays true - we didn't test this validation
325
+ } else if (errorMessage.includes('signature validation failed') || errorMessage.includes('Missing confirmation signature')) {
326
+ signatureValidForAudit = false;
327
+ } else if (errorMessage.includes('does not exist in the user database')) {
328
+ // Exporter UID validation failed - only flag this check
329
+ exporterUidValidatedForAudit = false;
330
+ // Hash validation would have passed to get this far, so hashValidForAudit stays true
331
+ // We still pass reviewingExaminerUid even though validation failed (for audit trail)
332
+ } else if (errorMessage.includes('cannot import confirmation data that you exported yourself')) {
333
+ // Self-confirmation attempt - all validations technically passed except the self-check
334
+ // reviewingExaminerUidForAudit already extracted above
335
+ }
336
+
337
+ await auditService.logConfirmationImport(
338
+ user,
339
+ result.caseNumber || 'unknown',
340
+ confirmationFile.name,
341
+ 'failure',
342
+ hashValidForAudit,
343
+ 0, // No confirmations successfully imported for failures
344
+ result.errors || [],
345
+ reviewingExaminerUidForAudit,
346
+ {
347
+ processingTimeMs: endTime - startTime,
348
+ fileSizeBytes: confirmationFile.size
349
+ },
350
+ exporterUidValidatedForAudit,
351
+ totalConfirmationsForAudit, // Total confirmations in file (when extractable)
352
+ {
353
+ present: signaturePresentForAudit,
354
+ valid: signatureValidForAudit,
355
+ keyId: signatureKeyIdForAudit
356
+ }
357
+ );
358
+
359
+ auditService.endWorkflow();
360
+
361
+ return result;
362
+ }
363
+ }