@striae-org/striae 5.2.1 → 5.3.0

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 (105) hide show
  1. package/.env.example +2 -10
  2. package/README.md +5 -46
  3. package/app/components/actions/case-export/core-export.ts +2 -174
  4. package/app/components/actions/case-export/download-handlers.ts +83 -750
  5. package/app/components/actions/case-export/index.ts +6 -30
  6. package/app/components/actions/case-export/metadata-helpers.ts +0 -78
  7. package/app/components/actions/case-export/types-constants.ts +0 -43
  8. package/app/components/actions/case-import/confirmation-import.ts +13 -14
  9. package/app/components/actions/case-import/zip-processing.ts +92 -12
  10. package/app/components/actions/generate-pdf.ts +3 -2
  11. package/app/components/audit/user-audit-viewer.tsx +0 -19
  12. package/app/components/audit/viewer/audit-viewer-header.tsx +0 -33
  13. package/app/components/navbar/case-modals/archive-case-modal.tsx +1 -1
  14. package/app/components/navbar/navbar.tsx +1 -1
  15. package/app/components/sidebar/case-import/case-import.module.css +35 -0
  16. package/app/components/sidebar/case-import/components/CasePreviewSection.tsx +59 -3
  17. package/app/components/sidebar/case-import/components/ConfirmationDialog.tsx +2 -4
  18. package/app/components/sidebar/case-import/components/ConfirmationPreviewSection.tsx +1 -1
  19. package/app/components/sidebar/notes/class-details-shared.ts +2 -2
  20. package/app/components/toast/toast.module.css +36 -0
  21. package/app/components/toast/toast.tsx +6 -2
  22. package/app/components/user/manage-profile.tsx +4 -3
  23. package/app/config-example/config.json +1 -2
  24. package/app/root.tsx +0 -7
  25. package/app/routes/_index.tsx +1 -1
  26. package/app/routes/auth/login.example.tsx +22 -103
  27. package/app/routes/auth/route.ts +1 -1
  28. package/app/routes/striae/striae.tsx +53 -59
  29. package/app/services/firebase/index.ts +0 -3
  30. package/app/types/export.ts +1 -2
  31. package/app/utils/auth/index.ts +0 -1
  32. package/app/utils/data/permissions.ts +3 -2
  33. package/package.json +9 -16
  34. package/public/_headers +0 -4
  35. package/public/_routes.json +0 -1
  36. package/worker-configuration.d.ts +20 -17
  37. package/workers/audit-worker/src/audit-worker.example.ts +9 -806
  38. package/workers/audit-worker/src/config.ts +7 -0
  39. package/workers/audit-worker/src/crypto/data-at-rest.ts +410 -0
  40. package/workers/audit-worker/src/handlers/audit-routes.ts +125 -0
  41. package/workers/audit-worker/src/storage/audit-storage.ts +99 -0
  42. package/workers/audit-worker/src/types.ts +56 -0
  43. package/workers/audit-worker/worker-configuration.d.ts +1 -1
  44. package/workers/audit-worker/wrangler.jsonc.example +1 -1
  45. package/workers/data-worker/src/config.ts +11 -0
  46. package/workers/data-worker/src/data-worker.example.ts +21 -942
  47. package/workers/data-worker/src/handlers/decrypt-export.ts +118 -0
  48. package/workers/data-worker/src/handlers/signing.ts +174 -0
  49. package/workers/data-worker/src/handlers/storage-routes.ts +129 -0
  50. package/workers/data-worker/src/registry/key-registry.ts +368 -0
  51. package/workers/data-worker/src/types.ts +46 -0
  52. package/workers/data-worker/worker-configuration.d.ts +1 -1
  53. package/workers/data-worker/wrangler.jsonc.example +1 -1
  54. package/workers/image-worker/worker-configuration.d.ts +1 -1
  55. package/workers/image-worker/wrangler.jsonc.example +1 -1
  56. package/workers/pdf-worker/worker-configuration.d.ts +2 -3
  57. package/workers/pdf-worker/wrangler.jsonc.example +1 -1
  58. package/workers/user-worker/src/auth.ts +30 -0
  59. package/workers/user-worker/src/cleanup/account-deletion.ts +337 -0
  60. package/workers/user-worker/src/config.ts +4 -0
  61. package/workers/user-worker/src/encryption-utils.ts +25 -0
  62. package/workers/user-worker/src/firebase/admin.ts +152 -0
  63. package/workers/user-worker/src/handlers/user-routes.ts +242 -0
  64. package/workers/user-worker/src/registry/user-kv.ts +172 -0
  65. package/workers/user-worker/src/storage/user-records.ts +34 -0
  66. package/workers/user-worker/src/types.ts +106 -0
  67. package/workers/user-worker/src/user-worker.example.ts +18 -964
  68. package/workers/user-worker/worker-configuration.d.ts +4 -2
  69. package/workers/user-worker/wrangler.jsonc.example +12 -1
  70. package/wrangler.toml.example +1 -1
  71. package/app/components/actions/case-export/data-processing.ts +0 -223
  72. package/app/components/sidebar/case-export/case-export.module.css +0 -418
  73. package/app/components/sidebar/case-export/case-export.tsx +0 -310
  74. package/app/types/exceljs-bare.d.ts +0 -9
  75. package/app/utils/auth/auth.ts +0 -11
  76. package/public/.well-known/security.txt +0 -6
  77. package/public/favicon.ico +0 -0
  78. package/public/icon-256.png +0 -0
  79. package/public/icon-512.png +0 -0
  80. package/public/manifest.json +0 -39
  81. package/public/shortcut.png +0 -0
  82. package/public/social-image.png +0 -0
  83. package/public/vendor/exceljs.LICENSE +0 -22
  84. package/public/vendor/exceljs.bare.min.js +0 -45
  85. package/scripts/deploy-all.sh +0 -166
  86. package/scripts/deploy-config/modules/env-utils.sh +0 -322
  87. package/scripts/deploy-config/modules/keys.sh +0 -404
  88. package/scripts/deploy-config/modules/prompt.sh +0 -372
  89. package/scripts/deploy-config/modules/scaffolding.sh +0 -344
  90. package/scripts/deploy-config/modules/validation.sh +0 -365
  91. package/scripts/deploy-config.sh +0 -236
  92. package/scripts/deploy-pages-secrets.sh +0 -231
  93. package/scripts/deploy-pages.sh +0 -34
  94. package/scripts/deploy-primershear-emails.sh +0 -167
  95. package/scripts/deploy-worker-secrets.sh +0 -374
  96. package/scripts/dev.cjs +0 -23
  97. package/scripts/install-workers.sh +0 -88
  98. package/scripts/run-eslint.cjs +0 -43
  99. package/scripts/update-compatibility-dates.cjs +0 -124
  100. package/scripts/update-markdown-versions.cjs +0 -43
  101. package/workers/keys-worker/package.json +0 -18
  102. package/workers/keys-worker/src/keys.example.ts +0 -67
  103. package/workers/keys-worker/src/keys.ts +0 -67
  104. package/workers/keys-worker/worker-configuration.d.ts +0 -7447
  105. package/workers/keys-worker/wrangler.jsonc.example +0 -15
@@ -1,6 +1,5 @@
1
1
  import type { User } from 'firebase/auth';
2
- import type * as ExcelJSModule from 'exceljs';
3
- import { type FileData, type AllCasesExportData, type CaseExportData, type ExportOptions } from '~/types';
2
+ import { type FileData, type CaseExportData, type ExportOptions } from '~/types';
4
3
  import { getImageUrl } from '../image-manage';
5
4
  import {
6
5
  generateForensicManifestSecure,
@@ -12,98 +11,11 @@ import {
12
11
  encryptExportDataWithAllImages
13
12
  } from '~/utils/forensics';
14
13
  import { signForensicManifest } from '~/utils/data';
15
- import { type ExportFormat, formatDateForFilename, CSV_HEADERS } from './types-constants';
16
- import { protectExcelWorksheet, addForensicDataWarning } from './metadata-helpers';
17
- import { generateMetadataRows, generateCSVContent, processFileDataForTabular, sanitizeTabularMatrix } from './data-processing';
14
+ import { formatDateForFilename } from './types-constants';
15
+ import { addForensicDataWarning } from './metadata-helpers';
18
16
  import { exportCaseData } from './core-export';
19
17
  import { auditService } from '~/services/audit';
20
18
 
21
- type TabularRow = Array<string | number | boolean | null | undefined>;
22
- type ExcelJsBrowserBundle = typeof ExcelJSModule;
23
-
24
- const EXCELJS_BROWSER_BUNDLE_SRC = '/vendor/exceljs.bare.min.js';
25
- let excelJsBundlePromise: Promise<ExcelJsBrowserBundle> | null = null;
26
-
27
- async function loadExcelJsBrowserBundle(): Promise<ExcelJsBrowserBundle> {
28
- if (typeof window === 'undefined') {
29
- throw new Error('Excel export is only available in a browser context.');
30
- }
31
-
32
- if (window.ExcelJS?.Workbook) {
33
- return window.ExcelJS;
34
- }
35
-
36
- if (!excelJsBundlePromise) {
37
- excelJsBundlePromise = new Promise((resolve, reject) => {
38
- const resolveFromWindow = () => {
39
- if (window.ExcelJS?.Workbook) {
40
- resolve(window.ExcelJS);
41
- return;
42
- }
43
-
44
- excelJsBundlePromise = null;
45
- reject(new Error('ExcelJS bundle loaded but Workbook API is unavailable.'));
46
- };
47
-
48
- const failLoad = () => {
49
- excelJsBundlePromise = null;
50
- reject(new Error('Failed to load ExcelJS browser bundle.'));
51
- };
52
-
53
- const existingScript = document.querySelector<HTMLScriptElement>('script[data-exceljs-bundle="true"]');
54
-
55
- if (existingScript) {
56
- if (existingScript.dataset.loaded === 'true') {
57
- resolveFromWindow();
58
- return;
59
- }
60
-
61
- existingScript.addEventListener('load', resolveFromWindow, { once: true });
62
- existingScript.addEventListener('error', failLoad, { once: true });
63
- return;
64
- }
65
-
66
- const script = document.createElement('script');
67
- script.src = EXCELJS_BROWSER_BUNDLE_SRC;
68
- script.async = true;
69
- script.dataset.exceljsBundle = 'true';
70
- script.addEventListener(
71
- 'load',
72
- () => {
73
- script.dataset.loaded = 'true';
74
- resolveFromWindow();
75
- },
76
- { once: true }
77
- );
78
- script.addEventListener('error', failLoad, { once: true });
79
- document.head.appendChild(script);
80
- });
81
- }
82
-
83
- return excelJsBundlePromise;
84
- }
85
-
86
- function sanitizeWorksheetName(name: string): string {
87
- const cleaned = name.replace(/[\\/?*:\x5B\x5D]/g, '_').trim();
88
- const normalized = cleaned.length > 0 ? cleaned : 'Sheet';
89
- return normalized.substring(0, 31);
90
- }
91
-
92
- function createUniqueWorksheetName(existingNames: Set<string>, desiredName: string): string {
93
- const baseName = sanitizeWorksheetName(desiredName);
94
- let candidate = baseName;
95
- let suffix = 1;
96
-
97
- while (existingNames.has(candidate)) {
98
- const suffixText = `_${suffix}`;
99
- candidate = `${baseName.substring(0, 31 - suffixText.length)}${suffixText}`;
100
- suffix += 1;
101
- }
102
-
103
- existingNames.add(candidate);
104
- return candidate;
105
- }
106
-
107
19
  /**
108
20
  * Generate export filename with embedded ID to prevent collisions
109
21
  * Format: {originalFilename}-{id}.{extension}
@@ -147,504 +59,20 @@ function addPublicSigningKeyPemToZip(
147
59
  return publicKeyFileName;
148
60
  }
149
61
 
150
- /**
151
- * Download all cases data as JSON file
152
- */
153
- export async function downloadAllCasesAsJSON(user: User, exportData: AllCasesExportData): Promise<void> {
154
- const startTime = Date.now();
155
-
156
- try {
157
- // Start audit workflow
158
- auditService.startWorkflow('all-cases');
159
-
160
- const dataStr = JSON.stringify(exportData, null, 2);
161
-
162
- // Calculate hash for integrity verification
163
- const hash = await calculateSHA256Secure(dataStr);
164
-
165
- // Create final export with hash included
166
- const finalExportData = {
167
- ...exportData,
168
- metadata: {
169
- ...exportData.metadata,
170
- hash: hash.toUpperCase(),
171
- integrityNote: 'Verify by recalculating SHA256 of this entire JSON content'
172
- }
173
- };
174
-
175
- const finalDataStr = JSON.stringify(finalExportData, null, 2);
176
- const dataUri = 'data:application/json;charset=utf-8,' + encodeURIComponent(finalDataStr);
177
-
178
- const exportFileName = `striae-all-cases-export-${formatDateForFilename(new Date())}.json`;
179
-
180
- const linkElement = document.createElement('a');
181
- linkElement.setAttribute('href', dataUri);
182
- linkElement.setAttribute('download', exportFileName);
183
- linkElement.click();
184
-
185
- // Log successful export audit event
186
- const endTime = Date.now();
187
- await auditService.logCaseExport(
188
- user,
189
- 'all-cases',
190
- exportFileName,
191
- 'success',
192
- [],
193
- {
194
- processingTimeMs: endTime - startTime,
195
- fileSizeBytes: finalDataStr.length,
196
- validationStepsCompleted: exportData.cases.length,
197
- validationStepsFailed: exportData.cases.filter(c => c.summary?.exportError).length
198
- },
199
- 'json',
200
- false // JSON format is not protected
201
- );
202
-
203
- // End audit workflow
204
- auditService.endWorkflow();
205
-
206
- } catch (error) {
207
- console.error('Download failed:', error);
208
-
209
- // Log failed export audit event
210
- const endTime = Date.now();
211
- await auditService.logCaseExport(
212
- user,
213
- 'all-cases',
214
- 'striae-all-cases-export.json',
215
- 'failure',
216
- [error instanceof Error ? error.message : 'Unknown error'],
217
- {
218
- processingTimeMs: endTime - startTime,
219
- fileSizeBytes: 0,
220
- validationStepsCompleted: 0,
221
- validationStepsFailed: 1
222
- },
223
- 'json',
224
- false
225
- );
226
-
227
- // End audit workflow
228
- auditService.endWorkflow();
229
-
230
- throw new Error('Failed to download all cases export file');
231
- }
232
- }
233
-
234
- /**
235
- * Download all cases data as Excel file with multiple worksheets
236
- */
237
- export async function downloadAllCasesAsCSV(user: User, exportData: AllCasesExportData, protectForensicData: boolean = true): Promise<void> {
238
- const startTime = Date.now();
239
-
240
- try {
241
- // Start audit workflow
242
- auditService.startWorkflow('all-cases');
243
-
244
- const ExcelJS = await loadExcelJsBrowserBundle();
245
-
246
- const workbook = new ExcelJS.Workbook();
247
- workbook.creator = exportData.metadata.exportedBy || 'Striae';
248
- workbook.lastModifiedBy = exportData.metadata.exportedBy || 'Striae';
249
- workbook.created = new Date();
250
- workbook.modified = new Date();
251
-
252
- const existingWorksheetNames = new Set<string>();
253
-
254
- const appendRowsToWorksheet = (worksheet: { addRow: (row: TabularRow) => unknown }, rows: TabularRow[]) => {
255
- rows.forEach((row) => {
256
- worksheet.addRow(row);
257
- });
258
- };
259
-
260
- let exportPassword: string | undefined;
261
-
262
- // Create summary worksheet
263
- const summaryDataRows = [
264
- ['Export Date', new Date().toISOString()],
265
- ['Exported By (Email)', exportData.metadata.exportedBy || 'N/A'],
266
- ['Exported By (UID)', exportData.metadata.exportedByUid || 'N/A'],
267
- ['Exported By (Name)', exportData.metadata.exportedByName || 'N/A'],
268
- ['Exported By (Company)', exportData.metadata.exportedByCompany || 'N/A'],
269
- ['Exported By (Badge/ID)', exportData.metadata.exportedByBadgeId || 'N/A'],
270
- ['Striae Export Schema Version', '1.0'],
271
- ['Total Cases', exportData.cases.length],
272
- ['Successful Exports', exportData.cases.filter(c => !c.summary?.exportError).length],
273
- ['Failed Exports', exportData.cases.filter(c => c.summary?.exportError).length],
274
- ['Total Files (All Cases)', exportData.metadata.totalFiles],
275
- ['Total Annotations (All Cases)', exportData.metadata.totalAnnotations],
276
- ['Total Confirmations (All Cases)', exportData.metadata.totalConfirmations || 0],
277
- ['Total Confirmations Requested (All Cases)', exportData.metadata.totalConfirmationsRequested || 0]
278
- ];
279
-
280
- // XLSX files are inherently protected, no hash validation needed
281
- const summaryData = sanitizeTabularMatrix([
282
- protectForensicData ? ['CASE DATA - PROTECTED EXPORT'] : ['Striae - All Cases Export Summary'],
283
- protectForensicData ? ['WARNING: This workbook contains evidence data and is protected from editing.'] : [''],
284
- [''],
285
- ...summaryDataRows,
286
- [''],
287
- ['Case Details'],
288
- [
289
- 'Case Number',
290
- 'Case Created Date',
291
- 'Export Status',
292
- 'Export Date',
293
- 'Exported By (Email)',
294
- 'Exported By (UID)',
295
- 'Exported By (Name)',
296
- 'Exported By (Company)',
297
- 'Exported By (Badge/ID)',
298
- 'Schema Version',
299
- 'Total Files',
300
- 'Files with Annotations',
301
- 'Files without Annotations',
302
- 'Total Box Annotations',
303
- 'Files with Confirmations',
304
- 'Files with Confirmations Requested',
305
- 'Last Modified',
306
- 'Earliest Annotation Date',
307
- 'Latest Annotation Date',
308
- 'Export Error'
309
- ],
310
- ...exportData.cases.map(caseData => [
311
- caseData.metadata.caseNumber,
312
- caseData.metadata.caseCreatedDate,
313
- caseData.summary?.exportError ? 'Failed' : 'Success',
314
- caseData.metadata.exportDate,
315
- caseData.metadata.exportedBy || 'N/A',
316
- caseData.metadata.exportedByUid || 'N/A',
317
- caseData.metadata.exportedByName || 'N/A',
318
- caseData.metadata.exportedByCompany || 'N/A',
319
- caseData.metadata.exportedByBadgeId || 'N/A',
320
- caseData.metadata.striaeExportSchemaVersion,
321
- caseData.metadata.totalFiles,
322
- caseData.summary?.filesWithAnnotations || 0,
323
- caseData.summary?.filesWithoutAnnotations || 0,
324
- caseData.summary?.totalBoxAnnotations || 0,
325
- caseData.summary?.filesWithConfirmations || 0,
326
- caseData.summary?.filesWithConfirmationsRequested || 0,
327
- caseData.summary?.lastModified || '',
328
- caseData.summary?.earliestAnnotationDate || '',
329
- caseData.summary?.latestAnnotationDate || '',
330
- caseData.summary?.exportError || ''
331
- ])
332
- ]);
333
-
334
- const summaryWorksheetName = createUniqueWorksheetName(existingWorksheetNames, 'Summary');
335
- const summaryWorksheet = workbook.addWorksheet(summaryWorksheetName);
336
- appendRowsToWorksheet(summaryWorksheet, summaryData);
337
-
338
- // Protect summary worksheet if forensic protection is enabled
339
- if (protectForensicData) {
340
- exportPassword = await protectExcelWorksheet(summaryWorksheet);
341
- }
342
-
343
- // Create a worksheet for each case
344
- for (const caseData of exportData.cases) {
345
- if (caseData.summary?.exportError) {
346
- // For failed cases, create a simple error sheet
347
- const errorData = sanitizeTabularMatrix([
348
- [`Case ${caseData.metadata.caseNumber} - Export Failed`],
349
- [''],
350
- ['Error:', caseData.summary.exportError],
351
- ['Case Number:', caseData.metadata.caseNumber],
352
- ['Total Files:', caseData.metadata.totalFiles]
353
- ]);
354
- const errorSheetName = createUniqueWorksheetName(existingWorksheetNames, `Case_${caseData.metadata.caseNumber}_Error`);
355
- const errorWorksheet = workbook.addWorksheet(errorSheetName);
356
- appendRowsToWorksheet(errorWorksheet, errorData);
357
-
358
- if (protectForensicData && exportPassword) {
359
- await protectExcelWorksheet(errorWorksheet, exportPassword);
360
- }
361
-
362
- continue;
363
- }
364
-
365
- // For successful cases, create detailed worksheets
366
- const metadataRows = generateMetadataRows(caseData);
367
-
368
- // Create case details with headers
369
- const caseDetailsData: Array<Array<string | number | boolean | null | undefined>> = [
370
- protectForensicData
371
- ? [`CASE DATA - ${caseData.metadata.caseNumber} - PROTECTED`]
372
- : [`Case ${caseData.metadata.caseNumber} - Detailed Export`],
373
- protectForensicData ? ['WARNING: This worksheet is protected to maintain data integrity.'] : [''],
374
- [''],
375
- ...metadataRows.slice(2, -1), // Skip title and "File Details" header
376
- [''],
377
- ['File Details'],
378
- CSV_HEADERS
379
- ];
380
-
381
- // Add file data if available
382
- if (caseData.files && caseData.files.length > 0) {
383
- const fileRows: Array<Array<string | number | boolean | null | undefined>> = [];
384
-
385
- caseData.files.forEach(fileEntry => {
386
- const processedRows = processFileDataForTabular(fileEntry);
387
- fileRows.push(...processedRows);
388
- });
389
-
390
- caseDetailsData.push(...fileRows);
391
- } else {
392
- caseDetailsData.push(['No detailed file data available for this case']);
393
- }
394
-
395
- const sanitizedCaseDetailsData = sanitizeTabularMatrix(caseDetailsData);
396
-
397
- // Clean sheet name for Excel compatibility and uniqueness
398
- const sheetName = createUniqueWorksheetName(existingWorksheetNames, `Case_${caseData.metadata.caseNumber}`);
399
- const caseWorksheet = workbook.addWorksheet(sheetName);
400
- appendRowsToWorksheet(caseWorksheet, sanitizedCaseDetailsData);
401
-
402
- // Protect worksheet if forensic protection is enabled
403
- if (protectForensicData && exportPassword) {
404
- await protectExcelWorksheet(caseWorksheet, exportPassword);
405
- }
406
-
407
- }
408
-
409
- // Generate Excel file
410
- const excelBuffer = await workbook.xlsx.writeBuffer();
411
-
412
- // Create blob and download
413
- const blob = new Blob([excelBuffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
414
- const url = window.URL.createObjectURL(blob);
415
-
416
- const protectionSuffix = protectForensicData ? '-protected' : '';
417
- const exportFileName = `striae-all-cases-detailed${protectionSuffix}-${formatDateForFilename(new Date())}.xlsx`;
418
-
419
- const linkElement = document.createElement('a');
420
- linkElement.href = url;
421
- linkElement.download = exportFileName;
422
- linkElement.click();
423
-
424
- // Clean up
425
- window.URL.revokeObjectURL(url);
426
-
427
- // Log successful export audit event
428
- const endTime = Date.now();
429
- await auditService.logCaseExport(
430
- user,
431
- 'all-cases',
432
- exportFileName,
433
- 'success',
434
- [],
435
- {
436
- processingTimeMs: endTime - startTime,
437
- fileSizeBytes: blob.size,
438
- validationStepsCompleted: exportData.cases.length,
439
- validationStepsFailed: exportData.cases.filter(c => c.summary?.exportError).length
440
- },
441
- 'xlsx',
442
- protectForensicData
443
- );
444
-
445
- // End audit workflow
446
- auditService.endWorkflow();
447
-
448
- } catch (error) {
449
- console.error('Excel export failed:', error);
450
-
451
- // Log failed export audit event
452
- const endTime = Date.now();
453
- await auditService.logCaseExport(
454
- user,
455
- 'all-cases',
456
- 'striae-all-cases-detailed.xlsx',
457
- 'failure',
458
- [error instanceof Error ? error.message : 'Unknown error'],
459
- {
460
- processingTimeMs: endTime - startTime,
461
- fileSizeBytes: 0,
462
- validationStepsCompleted: 0,
463
- validationStepsFailed: 1
464
- },
465
- 'xlsx',
466
- protectForensicData
467
- );
468
-
469
- // End audit workflow
470
- auditService.endWorkflow();
471
-
472
- throw new Error('Failed to export Excel file');
473
- }
474
- }
475
-
476
- /**
477
- * Download case data as JSON file with forensic protection options
478
- */
479
- export async function downloadCaseAsJSON(
480
- user: User,
481
- exportData: CaseExportData,
482
- options: ExportOptions = { protectForensicData: true }
483
- ): Promise<void> {
484
- const startTime = Date.now();
485
-
486
- try {
487
- // Start audit workflow
488
- auditService.startWorkflow(exportData.metadata.caseNumber);
489
-
490
- const jsonContent = await generateJSONContent(exportData, options.includeUserInfo, options.protectForensicData);
491
- const dataUri = 'data:application/json;charset=utf-8,' + encodeURIComponent(jsonContent);
492
-
493
- const protectionSuffix = options.protectForensicData ? '-protected' : '';
494
- const exportFileName = `striae-case-${exportData.metadata.caseNumber}-export${protectionSuffix}-${formatDateForFilename(new Date())}.json`;
495
-
496
- const linkElement = document.createElement('a');
497
- linkElement.setAttribute('href', dataUri);
498
- linkElement.setAttribute('download', exportFileName);
499
-
500
- if (options.protectForensicData) {
501
- linkElement.setAttribute('data-forensic-protected', 'true');
502
- }
503
-
504
- linkElement.click();
505
-
506
- // Log successful export audit event
507
- const endTime = Date.now();
508
- await auditService.logCaseExport(
509
- user,
510
- exportData.metadata.caseNumber,
511
- exportFileName,
512
- 'success',
513
- [],
514
- {
515
- processingTimeMs: endTime - startTime,
516
- fileSizeBytes: jsonContent.length,
517
- validationStepsCompleted: exportData.files?.length || 0,
518
- validationStepsFailed: 0
519
- },
520
- 'json',
521
- options.protectForensicData || false
522
- );
523
-
524
- // End audit workflow
525
- auditService.endWorkflow();
526
-
527
- } catch (error) {
528
- console.error('JSON export failed:', error);
529
-
530
- // Log failed export audit event
531
- const endTime = Date.now();
532
- await auditService.logCaseExport(
533
- user,
534
- exportData.metadata.caseNumber,
535
- `striae-case-${exportData.metadata.caseNumber}-export.json`,
536
- 'failure',
537
- [error instanceof Error ? error.message : 'Unknown error'],
538
- {
539
- processingTimeMs: endTime - startTime,
540
- fileSizeBytes: 0,
541
- validationStepsCompleted: 0,
542
- validationStepsFailed: 1
543
- },
544
- 'json',
545
- options.protectForensicData || false
546
- );
547
-
548
- // End audit workflow
549
- auditService.endWorkflow();
550
-
551
- throw new Error('Failed to download JSON export file');
552
- }
553
- }
554
-
555
- /**
556
- * Download case data as comprehensive CSV file with forensic protection options
557
- */
558
- export async function downloadCaseAsCSV(
559
- user: User,
560
- exportData: CaseExportData,
561
- options: ExportOptions = { protectForensicData: true }
562
- ): Promise<void> {
563
- const startTime = Date.now();
564
-
565
- try {
566
- // Start audit workflow
567
- auditService.startWorkflow(exportData.metadata.caseNumber);
568
-
569
- const csvContent = await generateCSVContent(exportData, options.protectForensicData);
570
- const dataUri = 'data:text/csv;charset=utf-8,' + encodeURIComponent(csvContent);
571
-
572
- const protectionSuffix = options.protectForensicData ? '-protected' : '';
573
- const exportFileName = `striae-case-${exportData.metadata.caseNumber}-detailed${protectionSuffix}-${formatDateForFilename(new Date())}.csv`;
574
-
575
- const linkElement = document.createElement('a');
576
- linkElement.setAttribute('href', dataUri);
577
- linkElement.setAttribute('download', exportFileName);
578
-
579
- if (options.protectForensicData) {
580
- linkElement.setAttribute('data-forensic-protected', 'true');
581
- }
582
-
583
- linkElement.click();
584
-
585
- // Log successful export audit event
586
- const endTime = Date.now();
587
- await auditService.logCaseExport(
588
- user,
589
- exportData.metadata.caseNumber,
590
- exportFileName,
591
- 'success',
592
- [],
593
- {
594
- processingTimeMs: endTime - startTime,
595
- fileSizeBytes: csvContent.length,
596
- validationStepsCompleted: exportData.files?.length || 0,
597
- validationStepsFailed: 0
598
- },
599
- 'csv',
600
- options.protectForensicData || false
601
- );
602
-
603
- // End audit workflow
604
- auditService.endWorkflow();
605
-
606
- } catch (error) {
607
- console.error('CSV export failed:', error);
608
-
609
- // Log failed export audit event
610
- const endTime = Date.now();
611
- await auditService.logCaseExport(
612
- user,
613
- exportData.metadata.caseNumber,
614
- `striae-case-${exportData.metadata.caseNumber}-detailed.csv`,
615
- 'failure',
616
- [error instanceof Error ? error.message : 'Unknown error'],
617
- {
618
- processingTimeMs: endTime - startTime,
619
- fileSizeBytes: 0,
620
- validationStepsCompleted: 0,
621
- validationStepsFailed: 1
622
- },
623
- 'csv',
624
- options.protectForensicData || false
625
- );
626
-
627
- // End audit workflow
628
- auditService.endWorkflow();
629
-
630
- throw new Error('Failed to export CSV file');
631
- }
632
- }
633
-
634
62
  /**
635
63
  * Download case data as ZIP file including images with forensic protection options
636
64
  */
637
65
  export async function downloadCaseAsZip(
638
66
  user: User,
639
67
  caseNumber: string,
640
- format: ExportFormat,
641
68
  onProgress?: (progress: number) => void,
642
- options: ExportOptions = { protectForensicData: true }
69
+ options: ExportOptions = {}
643
70
  ): Promise<void> {
644
71
  const startTime = Date.now();
645
72
  let manifestSignatureKeyId: string | undefined;
646
73
  let manifestSigned = false;
647
74
  let publicKeyFileName: string | undefined;
75
+ const protectForensicData = true;
648
76
 
649
77
  try {
650
78
  // Start audit workflow
@@ -660,14 +88,8 @@ export async function downloadCaseAsZip(
660
88
  const JSZip = (await import('jszip')).default;
661
89
  const zip = new JSZip();
662
90
 
663
- // Add data file with forensic protection if enabled
664
- if (format === 'json') {
665
- const jsonContent = generateJSONContent(exportData, options.includeUserInfo, options.protectForensicData);
666
- zip.file(`${caseNumber}_data.json`, jsonContent);
667
- } else {
668
- const csvContent = generateCSVContent(exportData, options.protectForensicData);
669
- zip.file(`${caseNumber}_data.csv`, csvContent);
670
- }
91
+ const jsonContent = await generateJSONContent(exportData, options.includeUserInfo, protectForensicData);
92
+ zip.file(`${caseNumber}_data.json`, jsonContent);
671
93
  onProgress?.(50);
672
94
 
673
95
  // Add images and collect them for manifest generation
@@ -691,86 +113,66 @@ export async function downloadCaseAsZip(
691
113
  }
692
114
  }
693
115
 
694
- // Add forensic metadata file if protection is enabled
695
- if (options.protectForensicData) {
696
- // CRITICAL: Get the content that will be used for hash calculation
697
- // This MUST match exactly what gets saved in the actual data file
698
- // So we use the same includeUserInfo setting for both
699
- const contentForHash = format === 'json'
700
- ? await generateJSONContent(exportData, options.includeUserInfo, false) // Raw content without warnings but same includeUserInfo
701
- : await generateCSVContent(exportData, false); // Raw content without warnings
116
+ // CRITICAL: Get the content that will be used for hash calculation.
117
+ // This must match the exported package content before encryption.
118
+ const contentForHash = await generateJSONContent(exportData, options.includeUserInfo, false);
702
119
 
703
- // Generate comprehensive forensic manifest with individual file hashes using secure SHA256
704
- const forensicManifest = await generateForensicManifestSecure(contentForHash, imageFiles);
120
+ const forensicManifest = await generateForensicManifestSecure(contentForHash, imageFiles);
705
121
 
706
- // Request server-side signature to prevent tamper-by-rehash attacks
707
- const signingResult = await signForensicManifest(user, caseNumber, forensicManifest);
708
- manifestSignatureKeyId = signingResult.signature.keyId;
709
- manifestSigned = true;
122
+ const signingResult = await signForensicManifest(user, caseNumber, forensicManifest);
123
+ manifestSignatureKeyId = signingResult.signature.keyId;
124
+ manifestSigned = true;
710
125
 
711
- publicKeyFileName = addPublicSigningKeyPemToZip(zip, signingResult.signature.keyId);
126
+ publicKeyFileName = addPublicSigningKeyPemToZip(zip, signingResult.signature.keyId);
712
127
 
713
- const signedForensicManifest = {
714
- ...forensicManifest,
715
- manifestVersion: signingResult.manifestVersion,
716
- signature: signingResult.signature
717
- };
718
-
719
- // Add dedicated forensic manifest file for validation
720
- zip.file('FORENSIC_MANIFEST.json', JSON.stringify(signedForensicManifest, null, 2));
721
-
722
- // Export encryption is mandatory
723
- const encKeyDetails = getCurrentEncryptionPublicKeyDetails();
724
-
725
- if (!encKeyDetails.publicKeyPem || !encKeyDetails.keyId) {
726
- throw new Error(
727
- 'Export encryption is mandatory. Your Striae instance does not have a configured encryption public key. ' +
728
- 'Please contact your administrator to set up export encryption.'
729
- );
730
- }
731
-
732
- let encryptionManifestJson: string | null = null;
733
- const isEncrypted = true;
734
-
735
- try {
736
- // Build image blobs array from the collected imageFiles
737
- const imagesToEncrypt = Object.entries(imageFiles).map(([filename, blob]) => ({
738
- filename,
739
- blob
740
- }));
128
+ const signedForensicManifest = {
129
+ ...forensicManifest,
130
+ manifestVersion: signingResult.manifestVersion,
131
+ signature: signingResult.signature
132
+ };
741
133
 
742
- // Encrypt data file and all images with shared AES key
743
- const encryptionResult = await encryptExportDataWithAllImages(
744
- contentForHash,
745
- imagesToEncrypt,
746
- encKeyDetails.publicKeyPem,
747
- encKeyDetails.keyId
748
- );
134
+ zip.file('FORENSIC_MANIFEST.json', JSON.stringify(signedForensicManifest, null, 2));
749
135
 
750
- // Replace data file with encrypted ciphertext
751
- zip.file(`${caseNumber}_data.${format}`, encryptionResult.ciphertext);
136
+ const encKeyDetails = getCurrentEncryptionPublicKeyDetails();
752
137
 
753
- // Replace images in the ZIP with encrypted versions
754
- if (imageFolder && encryptionResult.encryptedImages.length > 0) {
755
- for (let i = 0; i < imagesToEncrypt.length; i++) {
756
- const originalFilename = imagesToEncrypt[i].filename;
757
- // Remove the original file and add encrypted version
758
- imageFolder.file(originalFilename, encryptionResult.encryptedImages[i]);
759
- }
760
- }
138
+ if (!encKeyDetails.publicKeyPem || !encKeyDetails.keyId) {
139
+ throw new Error(
140
+ 'Export encryption is mandatory. Your Striae instance does not have a configured encryption public key. ' +
141
+ 'Please contact your administrator to set up export encryption.'
142
+ );
143
+ }
761
144
 
762
- // Add encryption manifest
763
- encryptionManifestJson = JSON.stringify(encryptionResult.encryptionManifest, null, 2);
764
- zip.file('ENCRYPTION_MANIFEST.json', encryptionManifestJson);
145
+ try {
146
+ const imagesToEncrypt = Object.entries(imageFiles).map(([filename, blob]) => ({
147
+ filename,
148
+ blob
149
+ }));
150
+
151
+ const encryptionResult = await encryptExportDataWithAllImages(
152
+ contentForHash,
153
+ imagesToEncrypt,
154
+ encKeyDetails.publicKeyPem,
155
+ encKeyDetails.keyId
156
+ );
765
157
 
766
- onProgress?.(80);
767
- } catch (error) {
768
- console.error('Export encryption failed:', error);
769
- throw new Error(`Failed to encrypt export: ${error instanceof Error ? error.message : 'Unknown error'}`);
158
+ zip.file(`${caseNumber}_data.json`, encryptionResult.ciphertext);
159
+
160
+ if (imageFolder && encryptionResult.encryptedImages.length > 0) {
161
+ for (let i = 0; i < imagesToEncrypt.length; i++) {
162
+ const originalFilename = imagesToEncrypt[i].filename;
163
+ imageFolder.file(originalFilename, encryptionResult.encryptedImages[i]);
164
+ }
770
165
  }
771
-
772
- // Add read-only instruction file
773
- const instructionContent = `EVIDENCE ARCHIVE - READ ONLY
166
+
167
+ zip.file('ENCRYPTION_MANIFEST.json', JSON.stringify(encryptionResult.encryptionManifest, null, 2));
168
+
169
+ onProgress?.(80);
170
+ } catch (error) {
171
+ console.error('Export encryption failed:', error);
172
+ throw new Error(`Failed to encrypt export: ${error instanceof Error ? error.message : 'Unknown error'}`);
173
+ }
174
+
175
+ const instructionContent = `EVIDENCE ARCHIVE - READ ONLY
774
176
 
775
177
  This ZIP archive contains evidence data exported from Striae.
776
178
 
@@ -779,13 +181,13 @@ IMPORTANT WARNINGS:
779
181
  - Do not modify, rename, or delete any files in this archive
780
182
  - Any modifications may compromise evidence integrity
781
183
  - Maintain proper chain of custody procedures
782
- ${isEncrypted ? '- This archive is encrypted. Only Striae can decrypt and re-import it.\n' : ''}
184
+ - This archive is encrypted. Only Striae can decrypt and re-import it.
783
185
 
784
186
  Archive Contents:
785
- - ${caseNumber}_data.${format}: Complete case data in ${format.toUpperCase()} format${isEncrypted ? ' (encrypted)' : ''}
786
- - images/: Image files with annotations${isEncrypted ? ' (encrypted)' : ''}
187
+ - ${caseNumber}_data.json: Complete case data manifest (encrypted)
188
+ - images/: Image files with annotations (encrypted)
787
189
  - FORENSIC_MANIFEST.json: File integrity validation manifest
788
- ${isEncrypted ? '- ENCRYPTION_MANIFEST.json: Encryption metadata and encrypted file hashes\n' : ''}
190
+ - ENCRYPTION_MANIFEST.json: Encryption metadata and encrypted file hashes
789
191
  - ${publicKeyFileName}: Public signing key PEM for verification
790
192
  - README.txt: General information about this export
791
193
 
@@ -797,107 +199,35 @@ Case Information:
797
199
  - Total Annotations: ${(exportData.summary?.filesWithAnnotations || 0) + (exportData.summary?.totalBoxAnnotations || 0)}
798
200
  - Total Confirmations: ${exportData.summary?.filesWithConfirmations || 0}
799
201
  - Confirmations Requested: ${exportData.summary?.filesWithConfirmationsRequested || 0}
800
- ${isEncrypted ? `- Encryption Status: ENCRYPTED (key ID: ${encKeyDetails.keyId})\n` : ''}
202
+ - Encryption Status: ENCRYPTED (key ID: ${encKeyDetails.keyId})
801
203
 
802
204
  For questions about this export, contact your Striae system administrator.
803
205
  `;
804
-
805
- zip.file('READ_ONLY_INSTRUCTIONS.txt', instructionContent);
806
-
807
- // Add README
808
- const readme = generateZipReadme(
809
- exportData,
810
- options.protectForensicData,
811
- publicKeyFileName
812
- );
813
- zip.file('README.txt', readme);
814
- onProgress?.(85);
815
-
816
- // Generate ZIP blob
817
- const zipBlob = await zip.generateAsync({
818
- type: 'blob',
819
- compression: 'DEFLATE',
820
- compressionOptions: { level: 6 }
821
- });
822
- onProgress?.(95);
823
-
824
- // Download
825
- const url = URL.createObjectURL(zipBlob);
826
- const protectionSuffix = options.protectForensicData ? '-protected' : '';
827
- const encryptedSuffix = isEncrypted ? '-encrypted' : '';
828
- const exportFileName = `striae-case-${caseNumber}-export${protectionSuffix}${encryptedSuffix}-${formatDateForFilename(new Date())}.zip`;
829
-
830
- const linkElement = document.createElement('a');
831
- linkElement.href = url;
832
- linkElement.setAttribute('download', exportFileName);
833
-
834
- if (options.protectForensicData) {
835
- linkElement.setAttribute('title', 'Evidence archive with forensic protection enabled');
836
- }
837
-
838
- linkElement.click();
839
-
840
- URL.revokeObjectURL(url);
841
- onProgress?.(100);
842
-
843
- // Log successful export audit event (forensic protected case)
844
- const endTime = Date.now();
845
- await auditService.logCaseExport(
846
- user,
847
- caseNumber,
848
- exportFileName,
849
- 'success',
850
- [],
851
- {
852
- processingTimeMs: endTime - startTime,
853
- fileSizeBytes: zipBlob.size,
854
- validationStepsCompleted: exportData.files?.length || 0,
855
- validationStepsFailed: 0
856
- },
857
- 'zip',
858
- options.protectForensicData || false,
859
- {
860
- present: true,
861
- valid: true,
862
- keyId: manifestSignatureKeyId
863
- }
864
- );
865
-
866
- // End audit workflow
867
- auditService.endWorkflow();
868
-
869
- return; // Exit early as we've handled the forensic case
870
- }
871
206
 
872
- publicKeyFileName = addPublicSigningKeyPemToZip(zip);
207
+ zip.file('READ_ONLY_INSTRUCTIONS.txt', instructionContent);
873
208
 
874
- // Add README (standard or enhanced for forensic)
875
209
  const readme = generateZipReadme(
876
210
  exportData,
877
- options.protectForensicData,
211
+ protectForensicData,
878
212
  publicKeyFileName
879
213
  );
880
214
  zip.file('README.txt', readme);
881
215
  onProgress?.(85);
882
216
 
883
- // Generate ZIP blob for non-forensic case
884
217
  const zipBlob = await zip.generateAsync({
885
218
  type: 'blob',
886
219
  compression: 'DEFLATE',
887
220
  compressionOptions: { level: 6 }
888
221
  });
889
- onProgress?.(95); // Download
222
+ onProgress?.(95);
223
+
890
224
  const url = URL.createObjectURL(zipBlob);
891
- const protectionSuffix = options.protectForensicData ? '-protected' : '';
892
- const exportFileName = `striae-case-${caseNumber}-export${protectionSuffix}-${formatDateForFilename(new Date())}.zip`;
225
+ const exportFileName = `striae-case-${caseNumber}-encrypted-package-${formatDateForFilename(new Date())}.zip`;
893
226
 
894
227
  const linkElement = document.createElement('a');
895
228
  linkElement.href = url;
896
229
  linkElement.setAttribute('download', exportFileName);
897
-
898
- if (options.protectForensicData) {
899
- linkElement.setAttribute('data-forensic-protected', 'true');
900
- }
230
+ linkElement.setAttribute('title', 'Encrypted Striae case package');
901
231
 
902
232
  linkElement.click();
903
233
 
@@ -919,7 +249,12 @@ For questions about this export, contact your Striae system administrator.
919
249
  validationStepsFailed: 0
920
250
  },
921
251
  'zip',
922
- options.protectForensicData || false
252
+ protectForensicData,
253
+ {
254
+ present: true,
255
+ valid: true,
256
+ keyId: manifestSignatureKeyId
257
+ }
923
258
  );
924
259
 
925
260
  // End audit workflow
@@ -943,20 +278,18 @@ For questions about this export, contact your Striae system administrator.
943
278
  validationStepsFailed: 1
944
279
  },
945
280
  'zip',
946
- options.protectForensicData || false,
947
- options.protectForensicData
948
- ? {
949
- present: manifestSigned,
950
- valid: manifestSigned,
951
- keyId: manifestSignatureKeyId
952
- }
953
- : undefined
281
+ protectForensicData,
282
+ {
283
+ present: manifestSigned,
284
+ valid: manifestSigned,
285
+ keyId: manifestSignatureKeyId
286
+ }
954
287
  );
955
288
 
956
289
  // End audit workflow
957
290
  auditService.endWorkflow();
958
291
 
959
- throw new Error('Failed to export ZIP file');
292
+ throw new Error('Failed to export encrypted case package');
960
293
  }
961
294
  }
962
295
 
@@ -1034,8 +367,8 @@ Summary:
1034
367
  - Latest Annotation Date: ${exportData.summary?.latestAnnotationDate || 'N/A'}
1035
368
 
1036
369
  Contents:
1037
- - ${exportData.metadata.caseNumber}_data.json/.csv: Case data and annotations
1038
- - images/: Original uploaded images
370
+ - ${exportData.metadata.caseNumber}_data.json: Encrypted case data and annotations
371
+ - images/: Encrypted uploaded images
1039
372
  - ${publicKeyFileName}: Public signing key PEM for verification
1040
373
  - README.txt: This file`;
1041
374