@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,900 @@
|
|
|
1
|
+
import { User } from 'firebase/auth';
|
|
2
|
+
import { FileData, AllCasesExportData, CaseExportData, ExportOptions } from '~/types';
|
|
3
|
+
import { getImageUrl } from '../image-manage';
|
|
4
|
+
import { generateForensicManifestSecure, calculateSHA256Secure } from '~/utils/SHA256';
|
|
5
|
+
import { signForensicManifest } from '~/utils/data-operations';
|
|
6
|
+
import { ExportFormat, formatDateForFilename, CSV_HEADERS } from './types-constants';
|
|
7
|
+
import { protectExcelWorksheet, addForensicDataWarning } from './metadata-helpers';
|
|
8
|
+
import { generateMetadataRows, generateCSVContent, processFileDataForTabular } from './data-processing';
|
|
9
|
+
import { exportCaseData } from './core-export';
|
|
10
|
+
import { auditService } from '~/services/audit.service';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Generate export filename with embedded ID to prevent collisions
|
|
14
|
+
* Format: {originalFilename}-{id}.{extension}
|
|
15
|
+
* Example: "evidence.jpg" with ID "abc123" becomes "evidence-abc123.jpg"
|
|
16
|
+
*/
|
|
17
|
+
function generateExportFilename(originalFilename: string, id: string): string {
|
|
18
|
+
const lastDotIndex = originalFilename.lastIndexOf('.');
|
|
19
|
+
|
|
20
|
+
if (lastDotIndex === -1) {
|
|
21
|
+
// No extension found
|
|
22
|
+
return `${originalFilename}-${id}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const basename = originalFilename.substring(0, lastDotIndex);
|
|
26
|
+
const extension = originalFilename.substring(lastDotIndex);
|
|
27
|
+
|
|
28
|
+
return `${basename}-${id}${extension}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Download all cases data as JSON file
|
|
33
|
+
*/
|
|
34
|
+
export async function downloadAllCasesAsJSON(user: User, exportData: AllCasesExportData): Promise<void> {
|
|
35
|
+
const startTime = Date.now();
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
// Start audit workflow
|
|
39
|
+
const workflowId = auditService.startWorkflow('all-cases');
|
|
40
|
+
|
|
41
|
+
const dataStr = JSON.stringify(exportData, null, 2);
|
|
42
|
+
|
|
43
|
+
// Calculate hash for integrity verification
|
|
44
|
+
const hash = await calculateSHA256Secure(dataStr);
|
|
45
|
+
|
|
46
|
+
// Create final export with hash included
|
|
47
|
+
const finalExportData = {
|
|
48
|
+
...exportData,
|
|
49
|
+
metadata: {
|
|
50
|
+
...exportData.metadata,
|
|
51
|
+
hash: hash.toUpperCase(),
|
|
52
|
+
integrityNote: 'Verify by recalculating SHA256 of this entire JSON content'
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const finalDataStr = JSON.stringify(finalExportData, null, 2);
|
|
57
|
+
const dataUri = 'data:application/json;charset=utf-8,' + encodeURIComponent(finalDataStr);
|
|
58
|
+
|
|
59
|
+
const exportFileName = `striae-all-cases-export-${formatDateForFilename(new Date())}.json`;
|
|
60
|
+
|
|
61
|
+
const linkElement = document.createElement('a');
|
|
62
|
+
linkElement.setAttribute('href', dataUri);
|
|
63
|
+
linkElement.setAttribute('download', exportFileName);
|
|
64
|
+
linkElement.click();
|
|
65
|
+
|
|
66
|
+
// Log successful export audit event
|
|
67
|
+
const endTime = Date.now();
|
|
68
|
+
await auditService.logCaseExport(
|
|
69
|
+
user,
|
|
70
|
+
'all-cases',
|
|
71
|
+
exportFileName,
|
|
72
|
+
'success',
|
|
73
|
+
[],
|
|
74
|
+
{
|
|
75
|
+
processingTimeMs: endTime - startTime,
|
|
76
|
+
fileSizeBytes: finalDataStr.length,
|
|
77
|
+
validationStepsCompleted: exportData.cases.length,
|
|
78
|
+
validationStepsFailed: exportData.cases.filter(c => c.summary?.exportError).length
|
|
79
|
+
},
|
|
80
|
+
'json',
|
|
81
|
+
false // JSON format is not protected
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
// End audit workflow
|
|
85
|
+
auditService.endWorkflow();
|
|
86
|
+
|
|
87
|
+
} catch (error) {
|
|
88
|
+
console.error('Download failed:', error);
|
|
89
|
+
|
|
90
|
+
// Log failed export audit event
|
|
91
|
+
const endTime = Date.now();
|
|
92
|
+
await auditService.logCaseExport(
|
|
93
|
+
user,
|
|
94
|
+
'all-cases',
|
|
95
|
+
'striae-all-cases-export.json',
|
|
96
|
+
'failure',
|
|
97
|
+
[error instanceof Error ? error.message : 'Unknown error'],
|
|
98
|
+
{
|
|
99
|
+
processingTimeMs: endTime - startTime,
|
|
100
|
+
fileSizeBytes: 0,
|
|
101
|
+
validationStepsCompleted: 0,
|
|
102
|
+
validationStepsFailed: 1
|
|
103
|
+
},
|
|
104
|
+
'json',
|
|
105
|
+
false
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
// End audit workflow
|
|
109
|
+
auditService.endWorkflow();
|
|
110
|
+
|
|
111
|
+
throw new Error('Failed to download all cases export file');
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Download all cases data as Excel file with multiple worksheets
|
|
117
|
+
*/
|
|
118
|
+
export async function downloadAllCasesAsCSV(user: User, exportData: AllCasesExportData, protectForensicData: boolean = true): Promise<void> {
|
|
119
|
+
const startTime = Date.now();
|
|
120
|
+
|
|
121
|
+
try {
|
|
122
|
+
// Start audit workflow
|
|
123
|
+
const workflowId = auditService.startWorkflow('all-cases');
|
|
124
|
+
|
|
125
|
+
// Dynamic import of XLSX to avoid bundle size issues
|
|
126
|
+
const XLSX = await import('xlsx');
|
|
127
|
+
|
|
128
|
+
const workbook = XLSX.utils.book_new();
|
|
129
|
+
let exportPassword: string | undefined;
|
|
130
|
+
|
|
131
|
+
// Create summary worksheet
|
|
132
|
+
const summaryDataRows = [
|
|
133
|
+
['Export Date', new Date().toISOString()],
|
|
134
|
+
['Exported By (Email)', exportData.metadata.exportedBy || 'N/A'],
|
|
135
|
+
['Exported By (UID)', exportData.metadata.exportedByUid || 'N/A'],
|
|
136
|
+
['Exported By (Name)', exportData.metadata.exportedByName || 'N/A'],
|
|
137
|
+
['Exported By (Company)', exportData.metadata.exportedByCompany || 'N/A'],
|
|
138
|
+
['Striae Export Schema Version', '1.0'],
|
|
139
|
+
['Total Cases', exportData.cases.length],
|
|
140
|
+
['Successful Exports', exportData.cases.filter(c => !c.summary?.exportError).length],
|
|
141
|
+
['Failed Exports', exportData.cases.filter(c => c.summary?.exportError).length],
|
|
142
|
+
['Total Files (All Cases)', exportData.metadata.totalFiles],
|
|
143
|
+
['Total Annotations (All Cases)', exportData.metadata.totalAnnotations],
|
|
144
|
+
['Total Confirmations (All Cases)', exportData.metadata.totalConfirmations || 0],
|
|
145
|
+
['Total Confirmations Requested (All Cases)', exportData.metadata.totalConfirmationsRequested || 0]
|
|
146
|
+
];
|
|
147
|
+
|
|
148
|
+
// XLSX files are inherently protected, no hash validation needed
|
|
149
|
+
const summaryData = [
|
|
150
|
+
protectForensicData ? ['CASE DATA - PROTECTED EXPORT'] : ['Striae - All Cases Export Summary'],
|
|
151
|
+
protectForensicData ? ['WARNING: This workbook contains evidence data and is protected from editing.'] : [''],
|
|
152
|
+
[''],
|
|
153
|
+
...summaryDataRows,
|
|
154
|
+
[''],
|
|
155
|
+
['Case Details'],
|
|
156
|
+
[
|
|
157
|
+
'Case Number',
|
|
158
|
+
'Case Created Date',
|
|
159
|
+
'Export Status',
|
|
160
|
+
'Export Date',
|
|
161
|
+
'Exported By (Email)',
|
|
162
|
+
'Exported By (UID)',
|
|
163
|
+
'Exported By (Name)',
|
|
164
|
+
'Exported By (Company)',
|
|
165
|
+
'Schema Version',
|
|
166
|
+
'Total Files',
|
|
167
|
+
'Files with Annotations',
|
|
168
|
+
'Files without Annotations',
|
|
169
|
+
'Total Box Annotations',
|
|
170
|
+
'Files with Confirmations',
|
|
171
|
+
'Files with Confirmations Requested',
|
|
172
|
+
'Last Modified',
|
|
173
|
+
'Earliest Annotation Date',
|
|
174
|
+
'Latest Annotation Date',
|
|
175
|
+
'Export Error'
|
|
176
|
+
],
|
|
177
|
+
...exportData.cases.map(caseData => [
|
|
178
|
+
caseData.metadata.caseNumber,
|
|
179
|
+
caseData.metadata.caseCreatedDate,
|
|
180
|
+
caseData.summary?.exportError ? 'Failed' : 'Success',
|
|
181
|
+
caseData.metadata.exportDate,
|
|
182
|
+
caseData.metadata.exportedBy || 'N/A',
|
|
183
|
+
caseData.metadata.exportedByUid || 'N/A',
|
|
184
|
+
caseData.metadata.exportedByName || 'N/A',
|
|
185
|
+
caseData.metadata.exportedByCompany || 'N/A',
|
|
186
|
+
caseData.metadata.striaeExportSchemaVersion,
|
|
187
|
+
caseData.metadata.totalFiles,
|
|
188
|
+
caseData.summary?.filesWithAnnotations || 0,
|
|
189
|
+
caseData.summary?.filesWithoutAnnotations || 0,
|
|
190
|
+
caseData.summary?.totalBoxAnnotations || 0,
|
|
191
|
+
caseData.summary?.filesWithConfirmations || 0,
|
|
192
|
+
caseData.summary?.filesWithConfirmationsRequested || 0,
|
|
193
|
+
caseData.summary?.lastModified || '',
|
|
194
|
+
caseData.summary?.earliestAnnotationDate || '',
|
|
195
|
+
caseData.summary?.latestAnnotationDate || '',
|
|
196
|
+
caseData.summary?.exportError || ''
|
|
197
|
+
])
|
|
198
|
+
];
|
|
199
|
+
|
|
200
|
+
const summaryWorksheet = XLSX.utils.aoa_to_sheet(summaryData);
|
|
201
|
+
|
|
202
|
+
// Protect summary worksheet if forensic protection is enabled
|
|
203
|
+
if (protectForensicData) {
|
|
204
|
+
exportPassword = protectExcelWorksheet(summaryWorksheet);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
XLSX.utils.book_append_sheet(workbook, summaryWorksheet, 'Summary');
|
|
208
|
+
|
|
209
|
+
// Create a worksheet for each case
|
|
210
|
+
exportData.cases.forEach((caseData, index) => {
|
|
211
|
+
if (caseData.summary?.exportError) {
|
|
212
|
+
// For failed cases, create a simple error sheet
|
|
213
|
+
const errorData = [
|
|
214
|
+
[`Case ${caseData.metadata.caseNumber} - Export Failed`],
|
|
215
|
+
[''],
|
|
216
|
+
['Error:', caseData.summary.exportError],
|
|
217
|
+
['Case Number:', caseData.metadata.caseNumber],
|
|
218
|
+
['Total Files:', caseData.metadata.totalFiles]
|
|
219
|
+
];
|
|
220
|
+
const errorWorksheet = XLSX.utils.aoa_to_sheet(errorData);
|
|
221
|
+
|
|
222
|
+
if (protectForensicData && exportPassword) {
|
|
223
|
+
protectExcelWorksheet(errorWorksheet, exportPassword);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
XLSX.utils.book_append_sheet(workbook, errorWorksheet, `Case_${caseData.metadata.caseNumber}_Error`);
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// For successful cases, create detailed worksheets
|
|
231
|
+
const metadataRows = generateMetadataRows(caseData);
|
|
232
|
+
|
|
233
|
+
// Create case details with headers
|
|
234
|
+
const caseDetailsData = [
|
|
235
|
+
protectForensicData
|
|
236
|
+
? [`CASE DATA - ${caseData.metadata.caseNumber} - PROTECTED`]
|
|
237
|
+
: [`Case ${caseData.metadata.caseNumber} - Detailed Export`],
|
|
238
|
+
protectForensicData ? ['WARNING: This worksheet is protected to maintain data integrity.'] : [''],
|
|
239
|
+
[''],
|
|
240
|
+
...metadataRows.slice(2, -1), // Skip title and "File Details" header
|
|
241
|
+
[''],
|
|
242
|
+
['File Details'],
|
|
243
|
+
CSV_HEADERS
|
|
244
|
+
];
|
|
245
|
+
|
|
246
|
+
// Add file data if available
|
|
247
|
+
if (caseData.files && caseData.files.length > 0) {
|
|
248
|
+
const fileRows: any[][] = [];
|
|
249
|
+
|
|
250
|
+
caseData.files.forEach(fileEntry => {
|
|
251
|
+
const processedRows = processFileDataForTabular(fileEntry);
|
|
252
|
+
fileRows.push(...processedRows);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
caseDetailsData.push(...fileRows);
|
|
256
|
+
} else {
|
|
257
|
+
caseDetailsData.push(['No detailed file data available for this case']);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const caseWorksheet = XLSX.utils.aoa_to_sheet(caseDetailsData);
|
|
261
|
+
|
|
262
|
+
// Protect worksheet if forensic protection is enabled
|
|
263
|
+
if (protectForensicData && exportPassword) {
|
|
264
|
+
protectExcelWorksheet(caseWorksheet, exportPassword);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Clean sheet name for Excel compatibility
|
|
268
|
+
const sheetName = `Case_${caseData.metadata.caseNumber}`.replace(/[\\\/\?\*\[\]]/g, '_').substring(0, 31);
|
|
269
|
+
XLSX.utils.book_append_sheet(workbook, caseWorksheet, sheetName);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
// Set workbook protection if forensic protection is enabled
|
|
273
|
+
if (protectForensicData && exportPassword) {
|
|
274
|
+
workbook.Props = {
|
|
275
|
+
Title: 'Striae Case Export - Protected',
|
|
276
|
+
Subject: 'Case Data Export',
|
|
277
|
+
Author: exportData.metadata.exportedBy || 'Striae',
|
|
278
|
+
Comments: `This workbook contains protected case data. Modification may compromise evidence integrity. Worksheets are password protected.`,
|
|
279
|
+
Company: 'Striae'
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Generate Excel file
|
|
284
|
+
const excelBuffer = XLSX.write(workbook, {
|
|
285
|
+
bookType: 'xlsx',
|
|
286
|
+
type: 'array',
|
|
287
|
+
bookSST: true, // Shared string table for better compression
|
|
288
|
+
cellStyles: true
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
// Create blob and download
|
|
292
|
+
const blob = new Blob([excelBuffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
|
|
293
|
+
const url = window.URL.createObjectURL(blob);
|
|
294
|
+
|
|
295
|
+
const protectionSuffix = protectForensicData ? '-protected' : '';
|
|
296
|
+
const exportFileName = `striae-all-cases-detailed${protectionSuffix}-${formatDateForFilename(new Date())}.xlsx`;
|
|
297
|
+
|
|
298
|
+
const linkElement = document.createElement('a');
|
|
299
|
+
linkElement.href = url;
|
|
300
|
+
linkElement.download = exportFileName;
|
|
301
|
+
linkElement.click();
|
|
302
|
+
|
|
303
|
+
// Clean up
|
|
304
|
+
window.URL.revokeObjectURL(url);
|
|
305
|
+
|
|
306
|
+
const passwordInfo = protectForensicData && exportPassword ? ` (Password: ${exportPassword})` : '';
|
|
307
|
+
|
|
308
|
+
// Log successful export audit event
|
|
309
|
+
const endTime = Date.now();
|
|
310
|
+
await auditService.logCaseExport(
|
|
311
|
+
user,
|
|
312
|
+
'all-cases',
|
|
313
|
+
exportFileName,
|
|
314
|
+
'success',
|
|
315
|
+
[],
|
|
316
|
+
{
|
|
317
|
+
processingTimeMs: endTime - startTime,
|
|
318
|
+
fileSizeBytes: blob.size,
|
|
319
|
+
validationStepsCompleted: exportData.cases.length,
|
|
320
|
+
validationStepsFailed: exportData.cases.filter(c => c.summary?.exportError).length
|
|
321
|
+
},
|
|
322
|
+
'xlsx',
|
|
323
|
+
protectForensicData
|
|
324
|
+
);
|
|
325
|
+
|
|
326
|
+
// End audit workflow
|
|
327
|
+
auditService.endWorkflow();
|
|
328
|
+
|
|
329
|
+
} catch (error) {
|
|
330
|
+
console.error('Excel export failed:', error);
|
|
331
|
+
|
|
332
|
+
// Log failed export audit event
|
|
333
|
+
const endTime = Date.now();
|
|
334
|
+
await auditService.logCaseExport(
|
|
335
|
+
user,
|
|
336
|
+
'all-cases',
|
|
337
|
+
'striae-all-cases-detailed.xlsx',
|
|
338
|
+
'failure',
|
|
339
|
+
[error instanceof Error ? error.message : 'Unknown error'],
|
|
340
|
+
{
|
|
341
|
+
processingTimeMs: endTime - startTime,
|
|
342
|
+
fileSizeBytes: 0,
|
|
343
|
+
validationStepsCompleted: 0,
|
|
344
|
+
validationStepsFailed: 1
|
|
345
|
+
},
|
|
346
|
+
'xlsx',
|
|
347
|
+
protectForensicData
|
|
348
|
+
);
|
|
349
|
+
|
|
350
|
+
// End audit workflow
|
|
351
|
+
auditService.endWorkflow();
|
|
352
|
+
|
|
353
|
+
throw new Error('Failed to export Excel file');
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Download case data as JSON file with forensic protection options
|
|
359
|
+
*/
|
|
360
|
+
export async function downloadCaseAsJSON(
|
|
361
|
+
user: User,
|
|
362
|
+
exportData: CaseExportData,
|
|
363
|
+
options: ExportOptions = { protectForensicData: true }
|
|
364
|
+
): Promise<void> {
|
|
365
|
+
const startTime = Date.now();
|
|
366
|
+
|
|
367
|
+
try {
|
|
368
|
+
// Start audit workflow
|
|
369
|
+
const workflowId = auditService.startWorkflow(exportData.metadata.caseNumber);
|
|
370
|
+
|
|
371
|
+
const jsonContent = await generateJSONContent(exportData, options.includeUserInfo, options.protectForensicData);
|
|
372
|
+
const dataUri = 'data:application/json;charset=utf-8,' + encodeURIComponent(jsonContent);
|
|
373
|
+
|
|
374
|
+
const protectionSuffix = options.protectForensicData ? '-protected' : '';
|
|
375
|
+
const exportFileName = `striae-case-${exportData.metadata.caseNumber}-export${protectionSuffix}-${formatDateForFilename(new Date())}.json`;
|
|
376
|
+
|
|
377
|
+
const linkElement = document.createElement('a');
|
|
378
|
+
linkElement.setAttribute('href', dataUri);
|
|
379
|
+
linkElement.setAttribute('download', exportFileName);
|
|
380
|
+
|
|
381
|
+
if (options.protectForensicData) {
|
|
382
|
+
linkElement.setAttribute('data-forensic-protected', 'true');
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
linkElement.click();
|
|
386
|
+
|
|
387
|
+
// Log successful export audit event
|
|
388
|
+
const endTime = Date.now();
|
|
389
|
+
await auditService.logCaseExport(
|
|
390
|
+
user,
|
|
391
|
+
exportData.metadata.caseNumber,
|
|
392
|
+
exportFileName,
|
|
393
|
+
'success',
|
|
394
|
+
[],
|
|
395
|
+
{
|
|
396
|
+
processingTimeMs: endTime - startTime,
|
|
397
|
+
fileSizeBytes: jsonContent.length,
|
|
398
|
+
validationStepsCompleted: exportData.files?.length || 0,
|
|
399
|
+
validationStepsFailed: 0
|
|
400
|
+
},
|
|
401
|
+
'json',
|
|
402
|
+
options.protectForensicData || false
|
|
403
|
+
);
|
|
404
|
+
|
|
405
|
+
// End audit workflow
|
|
406
|
+
auditService.endWorkflow();
|
|
407
|
+
|
|
408
|
+
} catch (error) {
|
|
409
|
+
console.error('JSON export failed:', error);
|
|
410
|
+
|
|
411
|
+
// Log failed export audit event
|
|
412
|
+
const endTime = Date.now();
|
|
413
|
+
await auditService.logCaseExport(
|
|
414
|
+
user,
|
|
415
|
+
exportData.metadata.caseNumber,
|
|
416
|
+
`striae-case-${exportData.metadata.caseNumber}-export.json`,
|
|
417
|
+
'failure',
|
|
418
|
+
[error instanceof Error ? error.message : 'Unknown error'],
|
|
419
|
+
{
|
|
420
|
+
processingTimeMs: endTime - startTime,
|
|
421
|
+
fileSizeBytes: 0,
|
|
422
|
+
validationStepsCompleted: 0,
|
|
423
|
+
validationStepsFailed: 1
|
|
424
|
+
},
|
|
425
|
+
'json',
|
|
426
|
+
options.protectForensicData || false
|
|
427
|
+
);
|
|
428
|
+
|
|
429
|
+
// End audit workflow
|
|
430
|
+
auditService.endWorkflow();
|
|
431
|
+
|
|
432
|
+
throw new Error('Failed to download JSON export file');
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Download case data as comprehensive CSV file with forensic protection options
|
|
438
|
+
*/
|
|
439
|
+
export async function downloadCaseAsCSV(
|
|
440
|
+
user: User,
|
|
441
|
+
exportData: CaseExportData,
|
|
442
|
+
options: ExportOptions = { protectForensicData: true }
|
|
443
|
+
): Promise<void> {
|
|
444
|
+
const startTime = Date.now();
|
|
445
|
+
|
|
446
|
+
try {
|
|
447
|
+
// Start audit workflow
|
|
448
|
+
const workflowId = auditService.startWorkflow(exportData.metadata.caseNumber);
|
|
449
|
+
|
|
450
|
+
const csvContent = await generateCSVContent(exportData, options.protectForensicData);
|
|
451
|
+
const dataUri = 'data:text/csv;charset=utf-8,' + encodeURIComponent(csvContent);
|
|
452
|
+
|
|
453
|
+
const protectionSuffix = options.protectForensicData ? '-protected' : '';
|
|
454
|
+
const exportFileName = `striae-case-${exportData.metadata.caseNumber}-detailed${protectionSuffix}-${formatDateForFilename(new Date())}.csv`;
|
|
455
|
+
|
|
456
|
+
const linkElement = document.createElement('a');
|
|
457
|
+
linkElement.setAttribute('href', dataUri);
|
|
458
|
+
linkElement.setAttribute('download', exportFileName);
|
|
459
|
+
|
|
460
|
+
if (options.protectForensicData) {
|
|
461
|
+
linkElement.setAttribute('data-forensic-protected', 'true');
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
linkElement.click();
|
|
465
|
+
|
|
466
|
+
// Log successful export audit event
|
|
467
|
+
const endTime = Date.now();
|
|
468
|
+
await auditService.logCaseExport(
|
|
469
|
+
user,
|
|
470
|
+
exportData.metadata.caseNumber,
|
|
471
|
+
exportFileName,
|
|
472
|
+
'success',
|
|
473
|
+
[],
|
|
474
|
+
{
|
|
475
|
+
processingTimeMs: endTime - startTime,
|
|
476
|
+
fileSizeBytes: csvContent.length,
|
|
477
|
+
validationStepsCompleted: exportData.files?.length || 0,
|
|
478
|
+
validationStepsFailed: 0
|
|
479
|
+
},
|
|
480
|
+
'csv',
|
|
481
|
+
options.protectForensicData || false
|
|
482
|
+
);
|
|
483
|
+
|
|
484
|
+
// End audit workflow
|
|
485
|
+
auditService.endWorkflow();
|
|
486
|
+
|
|
487
|
+
} catch (error) {
|
|
488
|
+
console.error('CSV export failed:', error);
|
|
489
|
+
|
|
490
|
+
// Log failed export audit event
|
|
491
|
+
const endTime = Date.now();
|
|
492
|
+
await auditService.logCaseExport(
|
|
493
|
+
user,
|
|
494
|
+
exportData.metadata.caseNumber,
|
|
495
|
+
`striae-case-${exportData.metadata.caseNumber}-detailed.csv`,
|
|
496
|
+
'failure',
|
|
497
|
+
[error instanceof Error ? error.message : 'Unknown error'],
|
|
498
|
+
{
|
|
499
|
+
processingTimeMs: endTime - startTime,
|
|
500
|
+
fileSizeBytes: 0,
|
|
501
|
+
validationStepsCompleted: 0,
|
|
502
|
+
validationStepsFailed: 1
|
|
503
|
+
},
|
|
504
|
+
'csv',
|
|
505
|
+
options.protectForensicData || false
|
|
506
|
+
);
|
|
507
|
+
|
|
508
|
+
// End audit workflow
|
|
509
|
+
auditService.endWorkflow();
|
|
510
|
+
|
|
511
|
+
throw new Error('Failed to export CSV file');
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* Download case data as ZIP file including images with forensic protection options
|
|
517
|
+
*/
|
|
518
|
+
export async function downloadCaseAsZip(
|
|
519
|
+
user: User,
|
|
520
|
+
caseNumber: string,
|
|
521
|
+
format: ExportFormat,
|
|
522
|
+
onProgress?: (progress: number) => void,
|
|
523
|
+
options: ExportOptions = { protectForensicData: true }
|
|
524
|
+
): Promise<void> {
|
|
525
|
+
const startTime = Date.now();
|
|
526
|
+
let manifestSignatureKeyId: string | undefined;
|
|
527
|
+
let manifestSigned = false;
|
|
528
|
+
|
|
529
|
+
try {
|
|
530
|
+
// Start audit workflow
|
|
531
|
+
const workflowId = auditService.startWorkflow(caseNumber);
|
|
532
|
+
|
|
533
|
+
onProgress?.(10);
|
|
534
|
+
|
|
535
|
+
// Get case data
|
|
536
|
+
const exportData = await exportCaseData(user, caseNumber);
|
|
537
|
+
onProgress?.(30);
|
|
538
|
+
|
|
539
|
+
// Create ZIP
|
|
540
|
+
const JSZip = (await import('jszip')).default;
|
|
541
|
+
const zip = new JSZip();
|
|
542
|
+
|
|
543
|
+
// Add data file with forensic protection if enabled
|
|
544
|
+
if (format === 'json') {
|
|
545
|
+
const jsonContent = generateJSONContent(exportData, options.includeUserInfo, options.protectForensicData);
|
|
546
|
+
zip.file(`${caseNumber}_data.json`, jsonContent);
|
|
547
|
+
} else {
|
|
548
|
+
const csvContent = generateCSVContent(exportData, options.protectForensicData);
|
|
549
|
+
zip.file(`${caseNumber}_data.csv`, csvContent);
|
|
550
|
+
}
|
|
551
|
+
onProgress?.(50);
|
|
552
|
+
|
|
553
|
+
// Add images and collect them for manifest generation
|
|
554
|
+
const imageFolder = zip.folder('images');
|
|
555
|
+
const imageFiles: { [filename: string]: Blob } = {};
|
|
556
|
+
if (imageFolder && exportData.files) {
|
|
557
|
+
for (let i = 0; i < exportData.files.length; i++) {
|
|
558
|
+
const file = exportData.files[i];
|
|
559
|
+
try {
|
|
560
|
+
const imageBlob = await fetchImageAsBlob(user, file.fileData, caseNumber);
|
|
561
|
+
if (imageBlob) {
|
|
562
|
+
// Generate export filename with embedded ID to prevent collisions
|
|
563
|
+
const exportFilename = generateExportFilename(file.fileData.originalFilename, file.fileData.id);
|
|
564
|
+
imageFolder.file(exportFilename, imageBlob);
|
|
565
|
+
imageFiles[exportFilename] = imageBlob;
|
|
566
|
+
}
|
|
567
|
+
} catch (error) {
|
|
568
|
+
console.warn(`Failed to fetch image ${file.fileData.originalFilename}:`, error);
|
|
569
|
+
}
|
|
570
|
+
onProgress?.(50 + (i / exportData.files.length) * 30);
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// Add forensic metadata file if protection is enabled
|
|
575
|
+
if (options.protectForensicData) {
|
|
576
|
+
// CRITICAL: Get the content that will be used for hash calculation
|
|
577
|
+
// This MUST match exactly what gets saved in the actual data file
|
|
578
|
+
// So we use the same includeUserInfo setting for both
|
|
579
|
+
const contentForHash = format === 'json'
|
|
580
|
+
? await generateJSONContent(exportData, options.includeUserInfo, false) // Raw content without warnings but same includeUserInfo
|
|
581
|
+
: await generateCSVContent(exportData, false); // Raw content without warnings
|
|
582
|
+
|
|
583
|
+
// Generate comprehensive forensic manifest with individual file hashes using secure SHA256
|
|
584
|
+
const forensicManifest = await generateForensicManifestSecure(contentForHash, imageFiles);
|
|
585
|
+
|
|
586
|
+
// Request server-side signature to prevent tamper-by-rehash attacks
|
|
587
|
+
const signingResult = await signForensicManifest(user, caseNumber, forensicManifest);
|
|
588
|
+
manifestSignatureKeyId = signingResult.signature.keyId;
|
|
589
|
+
manifestSigned = true;
|
|
590
|
+
|
|
591
|
+
const signedForensicManifest = {
|
|
592
|
+
...forensicManifest,
|
|
593
|
+
manifestVersion: signingResult.manifestVersion,
|
|
594
|
+
signature: signingResult.signature
|
|
595
|
+
};
|
|
596
|
+
|
|
597
|
+
// Add dedicated forensic manifest file for validation
|
|
598
|
+
zip.file('FORENSIC_MANIFEST.json', JSON.stringify(signedForensicManifest, null, 2));
|
|
599
|
+
|
|
600
|
+
// Add read-only instruction file
|
|
601
|
+
const instructionContent = `EVIDENCE ARCHIVE - READ ONLY
|
|
602
|
+
|
|
603
|
+
This ZIP archive contains evidence data exported from Striae.
|
|
604
|
+
|
|
605
|
+
IMPORTANT WARNINGS:
|
|
606
|
+
- This archive is intended for READ-ONLY access
|
|
607
|
+
- Do not modify, rename, or delete any files in this archive
|
|
608
|
+
- Any modifications may compromise evidence integrity
|
|
609
|
+
- Maintain proper chain of custody procedures
|
|
610
|
+
|
|
611
|
+
Archive Contents:
|
|
612
|
+
- ${caseNumber}_data.${format}: Complete case data in ${format.toUpperCase()} format
|
|
613
|
+
- images/: Original image files with annotations
|
|
614
|
+
- FORENSIC_MANIFEST.json: File integrity validation manifest
|
|
615
|
+
- README.txt: General information about this export
|
|
616
|
+
|
|
617
|
+
Case Information:
|
|
618
|
+
- Case Number: ${exportData.metadata.caseNumber}
|
|
619
|
+
- Export Date: ${new Date().toISOString()}
|
|
620
|
+
- Exported By: ${exportData.metadata.exportedBy || 'Unknown'}
|
|
621
|
+
- Total Files: ${exportData.metadata.totalFiles}
|
|
622
|
+
- Total Annotations: ${(exportData.summary?.filesWithAnnotations || 0) + (exportData.summary?.totalBoxAnnotations || 0)}
|
|
623
|
+
- Total Confirmations: ${exportData.summary?.filesWithConfirmations || 0}
|
|
624
|
+
- Confirmations Requested: ${exportData.summary?.filesWithConfirmationsRequested || 0}
|
|
625
|
+
|
|
626
|
+
For questions about this export, contact your Striae system administrator.
|
|
627
|
+
`;
|
|
628
|
+
|
|
629
|
+
zip.file('READ_ONLY_INSTRUCTIONS.txt', instructionContent);
|
|
630
|
+
|
|
631
|
+
// Add README
|
|
632
|
+
const readme = generateZipReadme(exportData, options.protectForensicData);
|
|
633
|
+
zip.file('README.txt', readme);
|
|
634
|
+
onProgress?.(85);
|
|
635
|
+
|
|
636
|
+
// Generate ZIP blob
|
|
637
|
+
const zipBlob = await zip.generateAsync({
|
|
638
|
+
type: 'blob',
|
|
639
|
+
compression: 'DEFLATE',
|
|
640
|
+
compressionOptions: { level: 6 }
|
|
641
|
+
});
|
|
642
|
+
onProgress?.(95);
|
|
643
|
+
|
|
644
|
+
// Download
|
|
645
|
+
const url = URL.createObjectURL(zipBlob);
|
|
646
|
+
const protectionSuffix = options.protectForensicData ? '-protected' : '';
|
|
647
|
+
const exportFileName = `striae-case-${caseNumber}-export${protectionSuffix}-${formatDateForFilename(new Date())}.zip`;
|
|
648
|
+
|
|
649
|
+
const linkElement = document.createElement('a');
|
|
650
|
+
linkElement.href = url;
|
|
651
|
+
linkElement.setAttribute('download', exportFileName);
|
|
652
|
+
|
|
653
|
+
if (options.protectForensicData) {
|
|
654
|
+
linkElement.setAttribute('title', 'Evidence archive with forensic protection enabled');
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
linkElement.click();
|
|
658
|
+
|
|
659
|
+
URL.revokeObjectURL(url);
|
|
660
|
+
onProgress?.(100);
|
|
661
|
+
|
|
662
|
+
// Log successful export audit event (forensic protected case)
|
|
663
|
+
const endTime = Date.now();
|
|
664
|
+
await auditService.logCaseExport(
|
|
665
|
+
user,
|
|
666
|
+
caseNumber,
|
|
667
|
+
exportFileName,
|
|
668
|
+
'success',
|
|
669
|
+
[],
|
|
670
|
+
{
|
|
671
|
+
processingTimeMs: endTime - startTime,
|
|
672
|
+
fileSizeBytes: zipBlob.size,
|
|
673
|
+
validationStepsCompleted: exportData.files?.length || 0,
|
|
674
|
+
validationStepsFailed: 0
|
|
675
|
+
},
|
|
676
|
+
'zip',
|
|
677
|
+
options.protectForensicData || false,
|
|
678
|
+
{
|
|
679
|
+
present: true,
|
|
680
|
+
valid: true,
|
|
681
|
+
keyId: manifestSignatureKeyId
|
|
682
|
+
}
|
|
683
|
+
);
|
|
684
|
+
|
|
685
|
+
// End audit workflow
|
|
686
|
+
auditService.endWorkflow();
|
|
687
|
+
|
|
688
|
+
return; // Exit early as we've handled the forensic case
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
// Add README (standard or enhanced for forensic)
|
|
692
|
+
const readme = generateZipReadme(exportData, options.protectForensicData);
|
|
693
|
+
zip.file('README.txt', readme);
|
|
694
|
+
onProgress?.(85);
|
|
695
|
+
|
|
696
|
+
// Generate ZIP blob for non-forensic case
|
|
697
|
+
const zipBlob = await zip.generateAsync({
|
|
698
|
+
type: 'blob',
|
|
699
|
+
compression: 'DEFLATE',
|
|
700
|
+
compressionOptions: { level: 6 }
|
|
701
|
+
});
|
|
702
|
+
onProgress?.(95); // Download
|
|
703
|
+
const url = URL.createObjectURL(zipBlob);
|
|
704
|
+
const protectionSuffix = options.protectForensicData ? '-protected' : '';
|
|
705
|
+
const exportFileName = `striae-case-${caseNumber}-export${protectionSuffix}-${formatDateForFilename(new Date())}.zip`;
|
|
706
|
+
|
|
707
|
+
const linkElement = document.createElement('a');
|
|
708
|
+
linkElement.href = url;
|
|
709
|
+
linkElement.setAttribute('download', exportFileName);
|
|
710
|
+
|
|
711
|
+
if (options.protectForensicData) {
|
|
712
|
+
linkElement.setAttribute('data-forensic-protected', 'true');
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
linkElement.click();
|
|
716
|
+
|
|
717
|
+
URL.revokeObjectURL(url);
|
|
718
|
+
onProgress?.(100);
|
|
719
|
+
|
|
720
|
+
// Log successful export audit event (standard case)
|
|
721
|
+
const endTime = Date.now();
|
|
722
|
+
await auditService.logCaseExport(
|
|
723
|
+
user,
|
|
724
|
+
caseNumber,
|
|
725
|
+
exportFileName,
|
|
726
|
+
'success',
|
|
727
|
+
[],
|
|
728
|
+
{
|
|
729
|
+
processingTimeMs: endTime - startTime,
|
|
730
|
+
fileSizeBytes: zipBlob.size,
|
|
731
|
+
validationStepsCompleted: exportData.files?.length || 0,
|
|
732
|
+
validationStepsFailed: 0
|
|
733
|
+
},
|
|
734
|
+
'zip',
|
|
735
|
+
options.protectForensicData || false
|
|
736
|
+
);
|
|
737
|
+
|
|
738
|
+
// End audit workflow
|
|
739
|
+
auditService.endWorkflow();
|
|
740
|
+
|
|
741
|
+
} catch (error) {
|
|
742
|
+
console.error('ZIP export failed:', error);
|
|
743
|
+
|
|
744
|
+
// Log failed export audit event
|
|
745
|
+
const endTime = Date.now();
|
|
746
|
+
await auditService.logCaseExport(
|
|
747
|
+
user,
|
|
748
|
+
caseNumber,
|
|
749
|
+
`striae-case-${caseNumber}-export.zip`,
|
|
750
|
+
'failure',
|
|
751
|
+
[error instanceof Error ? error.message : 'Unknown error'],
|
|
752
|
+
{
|
|
753
|
+
processingTimeMs: endTime - startTime,
|
|
754
|
+
fileSizeBytes: 0,
|
|
755
|
+
validationStepsCompleted: 0,
|
|
756
|
+
validationStepsFailed: 1
|
|
757
|
+
},
|
|
758
|
+
'zip',
|
|
759
|
+
options.protectForensicData || false,
|
|
760
|
+
options.protectForensicData
|
|
761
|
+
? {
|
|
762
|
+
present: manifestSigned,
|
|
763
|
+
valid: manifestSigned,
|
|
764
|
+
keyId: manifestSignatureKeyId
|
|
765
|
+
}
|
|
766
|
+
: undefined
|
|
767
|
+
);
|
|
768
|
+
|
|
769
|
+
// End audit workflow
|
|
770
|
+
auditService.endWorkflow();
|
|
771
|
+
|
|
772
|
+
throw new Error('Failed to export ZIP file');
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
/**
|
|
777
|
+
* Helper function to fetch image as blob
|
|
778
|
+
*/
|
|
779
|
+
async function fetchImageAsBlob(user: User, fileData: FileData, caseNumber: string): Promise<Blob | null> {
|
|
780
|
+
try {
|
|
781
|
+
const imageUrl = await getImageUrl(user, fileData, caseNumber, 'Export Package');
|
|
782
|
+
if (!imageUrl) return null;
|
|
783
|
+
|
|
784
|
+
const response = await fetch(imageUrl);
|
|
785
|
+
if (!response.ok) return null;
|
|
786
|
+
|
|
787
|
+
return await response.blob();
|
|
788
|
+
} catch (error) {
|
|
789
|
+
console.error('Failed to fetch image blob:', error);
|
|
790
|
+
return null;
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
/**
|
|
795
|
+
* Generate README content for ZIP export with optional forensic protection
|
|
796
|
+
*/
|
|
797
|
+
function generateZipReadme(exportData: CaseExportData, protectForensicData: boolean = true): string {
|
|
798
|
+
const totalFiles = exportData.files?.length || 0;
|
|
799
|
+
const filesWithAnnotations = exportData.summary?.filesWithAnnotations || 0;
|
|
800
|
+
const totalBoxAnnotations = exportData.summary?.totalBoxAnnotations || 0;
|
|
801
|
+
const totalAnnotations = filesWithAnnotations + totalBoxAnnotations;
|
|
802
|
+
const filesWithConfirmations = exportData.summary?.filesWithConfirmations || 0;
|
|
803
|
+
const filesWithConfirmationsRequested = exportData.summary?.filesWithConfirmationsRequested || 0;
|
|
804
|
+
|
|
805
|
+
const baseContent = `Striae Case Export
|
|
806
|
+
==================
|
|
807
|
+
|
|
808
|
+
Case Number: ${exportData.metadata.caseNumber}
|
|
809
|
+
Case Created Date: ${exportData.metadata.caseCreatedDate}
|
|
810
|
+
Export Date: ${exportData.metadata.exportDate}
|
|
811
|
+
Exported By (Email): ${exportData.metadata.exportedBy || 'N/A'}
|
|
812
|
+
Exported By (UID): ${exportData.metadata.exportedByUid || 'N/A'}
|
|
813
|
+
Exported By (Name): ${exportData.metadata.exportedByName || 'N/A'}
|
|
814
|
+
Exported By (Company): ${exportData.metadata.exportedByCompany || 'N/A'}
|
|
815
|
+
Striae Export Schema Version: ${exportData.metadata.striaeExportSchemaVersion}
|
|
816
|
+
|
|
817
|
+
Summary:
|
|
818
|
+
- Total Files: ${totalFiles}
|
|
819
|
+
- Files with Annotations: ${filesWithAnnotations}
|
|
820
|
+
- Files without Annotations: ${totalFiles - filesWithAnnotations}
|
|
821
|
+
- Total Box Annotations: ${totalBoxAnnotations}
|
|
822
|
+
- Total Annotations: ${totalAnnotations}
|
|
823
|
+
- Files with Confirmations: ${filesWithConfirmations}
|
|
824
|
+
- Files with Confirmations Requested: ${filesWithConfirmationsRequested}
|
|
825
|
+
- Earliest Annotation Date: ${exportData.summary?.earliestAnnotationDate || 'N/A'}
|
|
826
|
+
- Latest Annotation Date: ${exportData.summary?.latestAnnotationDate || 'N/A'}
|
|
827
|
+
|
|
828
|
+
Contents:
|
|
829
|
+
- ${exportData.metadata.caseNumber}_data.json/.csv: Case data and annotations
|
|
830
|
+
- images/: Original uploaded images
|
|
831
|
+
- README.txt: This file`;
|
|
832
|
+
|
|
833
|
+
const forensicAddition = `
|
|
834
|
+
- FORENSIC_MANIFEST.json: File integrity validation manifest
|
|
835
|
+
- READ_ONLY_INSTRUCTIONS.txt: Important evidence handling guidelines
|
|
836
|
+
|
|
837
|
+
EVIDENCE NOTICE:
|
|
838
|
+
================
|
|
839
|
+
This export contains evidence data. Any modification may compromise
|
|
840
|
+
evidence integrity and chain of custody. Handle according to your organization's
|
|
841
|
+
forensic procedures and maintain proper documentation.`;
|
|
842
|
+
|
|
843
|
+
const footer = `
|
|
844
|
+
|
|
845
|
+
Generated by Striae - A Firearms Examiner's Comparison Companion
|
|
846
|
+
https://www.striae.org`;
|
|
847
|
+
|
|
848
|
+
return protectForensicData ? baseContent + forensicAddition + footer : baseContent + footer;
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
/**
|
|
852
|
+
* Generate JSON content for case export with forensic protection options
|
|
853
|
+
*/
|
|
854
|
+
async function generateJSONContent(
|
|
855
|
+
exportData: CaseExportData,
|
|
856
|
+
includeUserInfo: boolean = true,
|
|
857
|
+
protectForensicData: boolean = true
|
|
858
|
+
): Promise<string> {
|
|
859
|
+
let jsonData = { ...exportData };
|
|
860
|
+
|
|
861
|
+
// Remove sensitive user info if not included
|
|
862
|
+
if (!includeUserInfo) {
|
|
863
|
+
if (jsonData.metadata.exportedBy) {
|
|
864
|
+
jsonData.metadata.exportedBy = '[User Info Excluded]';
|
|
865
|
+
}
|
|
866
|
+
if (jsonData.metadata.exportedByUid) {
|
|
867
|
+
jsonData.metadata.exportedByUid = '[User Info Excluded]';
|
|
868
|
+
}
|
|
869
|
+
if (jsonData.metadata.exportedByName) {
|
|
870
|
+
jsonData.metadata.exportedByName = '[User Info Excluded]';
|
|
871
|
+
}
|
|
872
|
+
if (jsonData.metadata.exportedByCompany) {
|
|
873
|
+
jsonData.metadata.exportedByCompany = '[User Info Excluded]';
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
const jsonString = JSON.stringify(jsonData, null, 2);
|
|
878
|
+
|
|
879
|
+
// Calculate hash for integrity verification
|
|
880
|
+
const hash = await calculateSHA256Secure(jsonString);
|
|
881
|
+
|
|
882
|
+
// Add hash to metadata
|
|
883
|
+
const finalJsonData = {
|
|
884
|
+
...jsonData,
|
|
885
|
+
metadata: {
|
|
886
|
+
...jsonData.metadata,
|
|
887
|
+
hash: hash.toUpperCase(),
|
|
888
|
+
integrityNote: 'Verify by recalculating SHA256 of this entire JSON content'
|
|
889
|
+
}
|
|
890
|
+
};
|
|
891
|
+
|
|
892
|
+
const finalJsonString = JSON.stringify(finalJsonData, null, 2);
|
|
893
|
+
|
|
894
|
+
// Add forensic protection warning if enabled
|
|
895
|
+
if (protectForensicData) {
|
|
896
|
+
return addForensicDataWarning(finalJsonString, 'json');
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
return finalJsonString;
|
|
900
|
+
}
|