@striae-org/striae 3.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +100 -0
- package/LICENSE +190 -0
- package/NOTICE +18 -0
- package/README.md +133 -0
- package/app/components/actions/case-export/core-export.ts +328 -0
- package/app/components/actions/case-export/data-processing.ts +167 -0
- package/app/components/actions/case-export/download-handlers.ts +900 -0
- package/app/components/actions/case-export/index.ts +41 -0
- package/app/components/actions/case-export/metadata-helpers.ts +107 -0
- package/app/components/actions/case-export/types-constants.ts +56 -0
- package/app/components/actions/case-export/validation-utils.ts +25 -0
- package/app/components/actions/case-export.ts +4 -0
- package/app/components/actions/case-import/annotation-import.ts +35 -0
- package/app/components/actions/case-import/confirmation-import.ts +363 -0
- package/app/components/actions/case-import/image-operations.ts +61 -0
- package/app/components/actions/case-import/index.ts +39 -0
- package/app/components/actions/case-import/orchestrator.ts +420 -0
- package/app/components/actions/case-import/storage-operations.ts +270 -0
- package/app/components/actions/case-import/validation.ts +189 -0
- package/app/components/actions/case-import/zip-processing.ts +413 -0
- package/app/components/actions/case-manage.ts +524 -0
- package/app/components/actions/case-review.ts +4 -0
- package/app/components/actions/confirm-export.ts +351 -0
- package/app/components/actions/generate-pdf.ts +210 -0
- package/app/components/actions/image-manage.ts +385 -0
- package/app/components/actions/notes-manage.ts +33 -0
- package/app/components/actions/signout.module.css +15 -0
- package/app/components/actions/signout.tsx +50 -0
- package/app/components/audit/user-audit-viewer.tsx +975 -0
- package/app/components/audit/user-audit.module.css +568 -0
- package/app/components/auth/auth-provider.tsx +78 -0
- package/app/components/auth/mfa-enrollment.module.css +268 -0
- package/app/components/auth/mfa-enrollment.tsx +398 -0
- package/app/components/auth/mfa-verification.module.css +251 -0
- package/app/components/auth/mfa-verification.tsx +295 -0
- package/app/components/button/button.module.css +63 -0
- package/app/components/button/button.tsx +46 -0
- package/app/components/canvas/box-annotations/box-annotations.module.css +170 -0
- package/app/components/canvas/box-annotations/box-annotations.tsx +634 -0
- package/app/components/canvas/canvas.module.css +314 -0
- package/app/components/canvas/canvas.tsx +449 -0
- package/app/components/canvas/confirmation/confirmation.module.css +187 -0
- package/app/components/canvas/confirmation/confirmation.tsx +214 -0
- package/app/components/colors/colors.module.css +59 -0
- package/app/components/colors/colors.tsx +68 -0
- package/app/components/form/base-form.tsx +21 -0
- package/app/components/form/form-button.tsx +28 -0
- package/app/components/form/form-field.tsx +53 -0
- package/app/components/form/form-message.tsx +17 -0
- package/app/components/form/form-toggle.tsx +23 -0
- package/app/components/form/form.module.css +427 -0
- package/app/components/form/index.ts +6 -0
- package/app/components/icon/icon.module.css +3 -0
- package/app/components/icon/icon.tsx +27 -0
- package/app/components/icon/icons.svg +102 -0
- package/app/components/icon/manifest.json +110 -0
- package/app/components/sidebar/case-export/case-export.module.css +386 -0
- package/app/components/sidebar/case-export/case-export.tsx +317 -0
- package/app/components/sidebar/case-import/case-import.module.css +626 -0
- package/app/components/sidebar/case-import/case-import.tsx +404 -0
- package/app/components/sidebar/case-import/components/CasePreviewSection.tsx +72 -0
- package/app/components/sidebar/case-import/components/ConfirmationDialog.tsx +72 -0
- package/app/components/sidebar/case-import/components/ConfirmationPreviewSection.tsx +71 -0
- package/app/components/sidebar/case-import/components/ExistingCaseSection.tsx +40 -0
- package/app/components/sidebar/case-import/components/FileSelector.tsx +161 -0
- package/app/components/sidebar/case-import/components/ProgressSection.tsx +46 -0
- package/app/components/sidebar/case-import/hooks/useFilePreview.ts +101 -0
- package/app/components/sidebar/case-import/hooks/useImportExecution.ts +152 -0
- package/app/components/sidebar/case-import/hooks/useImportState.ts +88 -0
- package/app/components/sidebar/case-import/index.ts +18 -0
- package/app/components/sidebar/case-import/utils/file-validation.ts +43 -0
- package/app/components/sidebar/cases/case-sidebar.tsx +827 -0
- package/app/components/sidebar/cases/cases-modal.module.css +166 -0
- package/app/components/sidebar/cases/cases-modal.tsx +201 -0
- package/app/components/sidebar/cases/cases.module.css +713 -0
- package/app/components/sidebar/files/files-modal.module.css +209 -0
- package/app/components/sidebar/files/files-modal.tsx +239 -0
- package/app/components/sidebar/hash/hash-utility.module.css +366 -0
- package/app/components/sidebar/hash/hash-utility.tsx +982 -0
- package/app/components/sidebar/notes/notes-modal.tsx +51 -0
- package/app/components/sidebar/notes/notes-sidebar.tsx +491 -0
- package/app/components/sidebar/notes/notes.module.css +360 -0
- package/app/components/sidebar/sidebar-container.tsx +149 -0
- package/app/components/sidebar/sidebar.module.css +321 -0
- package/app/components/sidebar/sidebar.tsx +215 -0
- package/app/components/sidebar/upload/image-upload-zone.module.css +123 -0
- package/app/components/sidebar/upload/image-upload-zone.tsx +330 -0
- package/app/components/theme-provider/theme-provider.tsx +131 -0
- package/app/components/theme-provider/theme.ts +155 -0
- package/app/components/toast/toast.module.css +137 -0
- package/app/components/toast/toast.tsx +56 -0
- package/app/components/toolbar/toolbar-color-selector.module.css +171 -0
- package/app/components/toolbar/toolbar-color-selector.tsx +129 -0
- package/app/components/toolbar/toolbar.module.css +42 -0
- package/app/components/toolbar/toolbar.tsx +167 -0
- package/app/components/user/delete-account.module.css +274 -0
- package/app/components/user/delete-account.tsx +471 -0
- package/app/components/user/inactivity-warning.module.css +145 -0
- package/app/components/user/inactivity-warning.tsx +84 -0
- package/app/components/user/manage-profile.module.css +190 -0
- package/app/components/user/manage-profile.tsx +253 -0
- package/app/components/user/mfa-phone-update.tsx +739 -0
- package/app/config-example/admin-service.json +13 -0
- package/app/config-example/config.json +17 -0
- package/app/config-example/firebase.ts +21 -0
- package/app/config-example/inactivity.ts +13 -0
- package/app/config-example/meta-config.json +6 -0
- package/app/contexts/auth.context.ts +12 -0
- package/app/entry.client.tsx +12 -0
- package/app/entry.server.tsx +44 -0
- package/app/hooks/useInactivityTimeout.ts +110 -0
- package/app/root.tsx +170 -0
- package/app/routes/_index.tsx +16 -0
- package/app/routes/auth/emailActionHandler.module.css +232 -0
- package/app/routes/auth/emailActionHandler.tsx +405 -0
- package/app/routes/auth/emailVerification.tsx +120 -0
- package/app/routes/auth/login.module.css +523 -0
- package/app/routes/auth/login.tsx +654 -0
- package/app/routes/auth/passwordReset.module.css +274 -0
- package/app/routes/auth/passwordReset.tsx +154 -0
- package/app/routes/auth/route.ts +16 -0
- package/app/routes/mobile-prevented/mobilePrevented.module.css +47 -0
- package/app/routes/mobile-prevented/mobilePrevented.tsx +26 -0
- package/app/routes/mobile-prevented/route.ts +14 -0
- package/app/routes/striae/striae.module.css +30 -0
- package/app/routes/striae/striae.tsx +417 -0
- package/app/services/audit-export.service.ts +755 -0
- package/app/services/audit.service.ts +1454 -0
- package/app/services/firebase-errors.ts +106 -0
- package/app/services/firebase.ts +15 -0
- package/app/styles/legal-pages.module.css +113 -0
- package/app/styles/root.module.css +146 -0
- package/app/tailwind.css +225 -0
- package/app/types/annotations.ts +45 -0
- package/app/types/audit.ts +301 -0
- package/app/types/case.ts +90 -0
- package/app/types/export.ts +8 -0
- package/app/types/file.ts +30 -0
- package/app/types/import.ts +107 -0
- package/app/types/index.ts +24 -0
- package/app/types/user.ts +38 -0
- package/app/utils/SHA256.ts +461 -0
- package/app/utils/annotation-timestamp.ts +25 -0
- package/app/utils/audit-export-signature.ts +117 -0
- package/app/utils/auth-action-settings.ts +48 -0
- package/app/utils/auth.ts +34 -0
- package/app/utils/batch-operations.ts +135 -0
- package/app/utils/confirmation-signature.ts +193 -0
- package/app/utils/data-operations.ts +871 -0
- package/app/utils/device-detection.ts +5 -0
- package/app/utils/html-sanitizer.ts +80 -0
- package/app/utils/id-generator.ts +36 -0
- package/app/utils/meta.ts +48 -0
- package/app/utils/mfa-phone.ts +97 -0
- package/app/utils/mfa.ts +79 -0
- package/app/utils/password-policy.ts +28 -0
- package/app/utils/permissions.ts +562 -0
- package/app/utils/signature-utils.ts +160 -0
- package/app/utils/style.ts +83 -0
- package/app/utils/version.ts +5 -0
- package/firebase.json +11 -0
- package/functions/[[path]].ts +10 -0
- package/package.json +138 -0
- package/postcss.config.js +6 -0
- package/public/.well-known/publickey.info@striae.org.asc +17 -0
- package/public/.well-known/security.txt +7 -0
- package/public/_headers +28 -0
- package/public/_routes.json +13 -0
- package/public/assets/striae.jpg +0 -0
- package/public/clear.jpg +0 -0
- package/public/favicon.ico +0 -0
- package/public/favicon.svg +9 -0
- package/public/icon-256.png +0 -0
- package/public/icon-512.png +0 -0
- package/public/logo-dark.png +0 -0
- package/public/manifest.json +25 -0
- package/public/oin-badge.png +0 -0
- package/public/shortcut.png +0 -0
- package/public/social-image.png +0 -0
- package/public/striae-ascii.txt +10 -0
- package/scripts/deploy-all.sh +100 -0
- package/scripts/deploy-config.sh +940 -0
- package/scripts/deploy-pages.sh +34 -0
- package/scripts/deploy-worker-secrets.sh +215 -0
- package/scripts/dev.cjs +23 -0
- package/scripts/install-workers.sh +88 -0
- package/scripts/run-eslint.cjs +35 -0
- package/scripts/update-compatibility-dates.cjs +124 -0
- package/scripts/update-markdown-versions.cjs +43 -0
- package/tailwind.config.ts +22 -0
- package/tsconfig.json +33 -0
- package/vite.config.ts +35 -0
- package/worker-configuration.d.ts +7490 -0
- package/workers/audit-worker/package.json +17 -0
- package/workers/audit-worker/src/audit-worker.example.ts +195 -0
- package/workers/audit-worker/worker-configuration.d.ts +7448 -0
- package/workers/audit-worker/wrangler.jsonc.example +29 -0
- package/workers/data-worker/package.json +17 -0
- package/workers/data-worker/src/data-worker.example.ts +267 -0
- package/workers/data-worker/src/signature-utils.ts +79 -0
- package/workers/data-worker/src/signing-payload-utils.ts +290 -0
- package/workers/data-worker/worker-configuration.d.ts +7448 -0
- package/workers/data-worker/wrangler.jsonc.example +30 -0
- package/workers/image-worker/package.json +17 -0
- package/workers/image-worker/src/image-worker.example.ts +180 -0
- package/workers/image-worker/worker-configuration.d.ts +7447 -0
- package/workers/image-worker/wrangler.jsonc.example +22 -0
- package/workers/keys-worker/package.json +17 -0
- package/workers/keys-worker/src/keys.example.ts +66 -0
- package/workers/keys-worker/src/keys.ts +66 -0
- package/workers/keys-worker/worker-configuration.d.ts +7447 -0
- package/workers/keys-worker/wrangler.jsonc.example +22 -0
- package/workers/pdf-worker/package.json +17 -0
- package/workers/pdf-worker/src/format-striae.ts +534 -0
- package/workers/pdf-worker/src/pdf-worker.example.ts +119 -0
- package/workers/pdf-worker/src/report-types.ts +69 -0
- package/workers/pdf-worker/worker-configuration.d.ts +7448 -0
- package/workers/pdf-worker/wrangler.jsonc.example +26 -0
- package/workers/user-worker/package.json +17 -0
- package/workers/user-worker/src/user-worker.example.ts +636 -0
- package/workers/user-worker/worker-configuration.d.ts +7448 -0
- package/workers/user-worker/wrangler.jsonc.example +29 -0
- package/wrangler.toml.example +8 -0
|
@@ -0,0 +1,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,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
|
+
}
|