@striae-org/striae 3.2.0 → 3.2.2

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 (109) hide show
  1. package/README.md +3 -32
  2. package/app/components/actions/case-export/core-export.ts +2 -2
  3. package/app/components/actions/case-export/data-processing.ts +65 -10
  4. package/app/components/actions/case-export/download-handlers.ts +130 -44
  5. package/app/components/actions/case-export/metadata-helpers.ts +32 -14
  6. package/app/components/actions/case-import/annotation-import.ts +2 -2
  7. package/app/components/actions/case-import/confirmation-import.ts +3 -3
  8. package/app/components/actions/case-import/image-operations.ts +1 -1
  9. package/app/components/actions/case-import/orchestrator.ts +4 -4
  10. package/app/components/actions/case-import/storage-operations.ts +7 -7
  11. package/app/components/actions/case-import/validation.ts +3 -3
  12. package/app/components/actions/case-import/zip-processing.ts +3 -3
  13. package/app/components/actions/case-manage.ts +3 -3
  14. package/app/components/actions/confirm-export.ts +3 -3
  15. package/app/components/actions/generate-pdf.ts +3 -3
  16. package/app/components/actions/image-manage.ts +3 -3
  17. package/app/components/actions/notes-manage.ts +3 -3
  18. package/app/components/actions/signout.tsx +1 -1
  19. package/app/components/audit/user-audit-viewer.tsx +2 -3
  20. package/app/components/auth/auth-provider.tsx +2 -2
  21. package/app/components/auth/mfa-enrollment.tsx +3 -3
  22. package/app/components/auth/mfa-verification.tsx +4 -4
  23. package/app/components/canvas/box-annotations/box-annotations.tsx +2 -2
  24. package/app/components/canvas/canvas.tsx +1 -1
  25. package/app/components/canvas/confirmation/confirmation.tsx +1 -1
  26. package/app/components/form/base-form.tsx +1 -1
  27. package/app/components/sidebar/case-export/case-export.tsx +15 -15
  28. package/app/components/sidebar/case-import/case-import.tsx +2 -2
  29. package/app/components/sidebar/case-import/components/CasePreviewSection.tsx +1 -1
  30. package/app/components/sidebar/case-import/components/ConfirmationDialog.tsx +1 -1
  31. package/app/components/sidebar/case-import/hooks/useFilePreview.ts +3 -3
  32. package/app/components/sidebar/case-import/hooks/useImportExecution.ts +2 -2
  33. package/app/components/sidebar/cases/case-sidebar.tsx +27 -19
  34. package/app/components/sidebar/cases/cases-modal.tsx +1 -1
  35. package/app/components/sidebar/files/files-modal.tsx +3 -2
  36. package/app/components/sidebar/notes/notes-sidebar.tsx +3 -3
  37. package/app/components/sidebar/sidebar-container.tsx +5 -4
  38. package/app/components/sidebar/sidebar.tsx +2 -2
  39. package/app/components/sidebar/upload/image-upload-zone.tsx +2 -2
  40. package/app/components/theme-provider/theme-provider.tsx +1 -1
  41. package/app/components/user/delete-account.tsx +1 -1
  42. package/app/components/user/manage-profile.tsx +2 -2
  43. package/app/components/user/mfa-phone-update.tsx +2 -2
  44. package/app/contexts/auth.context.ts +1 -1
  45. package/app/entry.client.tsx +12 -12
  46. package/app/entry.server.tsx +4 -4
  47. package/app/hooks/useInactivityTimeout.ts +1 -1
  48. package/app/root.tsx +3 -3
  49. package/app/routes/auth/emailActionHandler.tsx +3 -3
  50. package/app/routes/auth/emailVerification.tsx +3 -3
  51. package/app/routes/auth/login.tsx +6 -6
  52. package/app/routes/auth/passwordReset.tsx +3 -3
  53. package/app/routes/auth/route.ts +1 -1
  54. package/app/routes/striae/striae.tsx +2 -2
  55. package/app/services/audit/audit-console-logger.ts +46 -0
  56. package/app/services/audit/audit-export-csv.ts +126 -0
  57. package/app/services/audit/audit-export-report.ts +174 -0
  58. package/app/services/audit/audit-export-signing.ts +85 -0
  59. package/app/services/audit/audit-export.service.ts +334 -0
  60. package/app/services/audit/audit-file-type.ts +13 -0
  61. package/app/services/audit/audit-query-helpers.ts +88 -0
  62. package/app/services/audit/audit-worker-client.ts +95 -0
  63. package/app/services/audit/audit.service.ts +990 -0
  64. package/app/services/audit/builders/audit-entry-builder.ts +32 -0
  65. package/app/services/audit/builders/audit-event-builders-annotation.ts +150 -0
  66. package/app/services/audit/builders/audit-event-builders-case-file.ts +249 -0
  67. package/app/services/audit/builders/audit-event-builders-user-security.ts +449 -0
  68. package/app/services/audit/builders/audit-event-builders-workflow.ts +272 -0
  69. package/app/services/audit/builders/index.ts +40 -0
  70. package/app/services/audit/index.ts +2 -0
  71. package/app/types/case.ts +2 -2
  72. package/app/types/exceljs-bare.d.ts +9 -0
  73. package/app/types/user.ts +1 -1
  74. package/app/utils/audit-export-signature.ts +2 -2
  75. package/app/utils/confirmation-signature.ts +3 -3
  76. package/app/utils/data-operations.ts +5 -5
  77. package/app/utils/mfa-phone.ts +1 -1
  78. package/app/utils/mfa.ts +1 -1
  79. package/app/utils/permissions.ts +2 -2
  80. package/functions/[[path]].ts +2 -2
  81. package/package.json +34 -20
  82. package/public/vendor/exceljs.LICENSE +22 -0
  83. package/public/vendor/exceljs.bare.min.js +45 -0
  84. package/scripts/deploy-all.sh +52 -0
  85. package/scripts/deploy-config.sh +282 -1
  86. package/tsconfig.json +18 -8
  87. package/vite.config.ts +6 -22
  88. package/worker-configuration.d.ts +4435 -562
  89. package/workers/audit-worker/package.json +8 -4
  90. package/workers/audit-worker/wrangler.jsonc.example +1 -1
  91. package/workers/data-worker/package.json +8 -4
  92. package/workers/data-worker/src/data-worker.example.ts +3 -3
  93. package/workers/data-worker/wrangler.jsonc.example +1 -1
  94. package/workers/image-worker/package.json +8 -4
  95. package/workers/image-worker/wrangler.jsonc.example +1 -1
  96. package/workers/keys-worker/package.json +8 -4
  97. package/workers/keys-worker/wrangler.jsonc.example +1 -1
  98. package/workers/pdf-worker/package.json +8 -4
  99. package/workers/pdf-worker/src/{generated-assets.ts → assets/generated-assets.ts} +117 -117
  100. package/workers/pdf-worker/src/{format-striae.ts → formats/format-striae.ts} +535 -535
  101. package/workers/pdf-worker/src/pdf-worker.example.ts +1 -1
  102. package/workers/pdf-worker/wrangler.jsonc.example +1 -1
  103. package/workers/user-worker/package.json +8 -4
  104. package/workers/user-worker/wrangler.jsonc.example +1 -1
  105. package/wrangler.toml.example +1 -1
  106. package/app/services/audit-export.service.ts +0 -755
  107. package/app/services/audit.service.ts +0 -1474
  108. /package/app/services/{firebase-errors.ts → firebase/errors.ts} +0 -0
  109. /package/app/services/{firebase.ts → firebase/index.ts} +0 -0
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @striae-org/striae
2
2
 
3
- Striae is a cloud-native forensic annotation application for firearms identification, built with Remix and Cloudflare Workers.
3
+ Striae is a cloud-native forensic annotation application for firearms identification, built with React Router and Cloudflare Workers.
4
4
 
5
5
  This npm package publishes the Striae application source and deployment scaffolding for teams that run their own Striae environment.
6
6
 
@@ -40,7 +40,7 @@ npm i @striae-org/striae
40
40
  cp -R node_modules/@striae-org/striae/. .
41
41
  ```
42
42
 
43
- 3) Reinstall using Striae's own package.json (includes dev deps like wrangler/remix)
43
+ 3) Reinstall using Striae's own package.json (includes dev deps like wrangler/react-router)
44
44
 
45
45
  ```bash
46
46
  rm -rf node_modules package-lock.json
@@ -62,41 +62,12 @@ cp -f app/config-example/admin-service.json app/config/admin-service.json
62
62
  npx wrangler login
63
63
  ```
64
64
 
65
- 6) Run guided config + full deployment
65
+ 7) Run guided config + full deployment
66
66
 
67
67
  ```bash
68
68
  npm run deploy:all
69
69
  ```
70
70
 
71
- ## Publish To npmjs And GitHub Packages
72
-
73
- 1) Verify auth for both registries
74
-
75
- ```bash
76
- npm whoami --registry=https://registry.npmjs.org/
77
- npm whoami --registry=https://npm.pkg.github.com/
78
- ```
79
-
80
- 2) Publish current version to npmjs (public)
81
-
82
- ```bash
83
- npm run publish:npm
84
- ```
85
-
86
- 3) Publish the same version to GitHub Packages
87
-
88
- ```bash
89
- npm run publish:github
90
- ```
91
-
92
- 4) Optional dry-runs before a real release
93
-
94
- ```bash
95
- npm run publish:npm:dry-run
96
- npm run publish:github:dry-run
97
- npm run publish:all:dry-run
98
- ```
99
-
100
71
  ## NPM Package Content Policy
101
72
 
102
73
  This package intentionally includes only non-sensitive defaults and runtime source needed for setup.
@@ -1,5 +1,5 @@
1
- import { User } from 'firebase/auth';
2
- import { AnnotationData, CaseExportData, AllCasesExportData, ExportOptions } from '~/types';
1
+ import type { User } from 'firebase/auth';
2
+ import { type AnnotationData, type CaseExportData, type AllCasesExportData, type ExportOptions } from '~/types';
3
3
  import { fetchFiles } from '../image-manage';
4
4
  import { getNotes } from '../notes-manage';
5
5
  import { checkExistingCase, validateCaseNumber, listCases } from '../case-manage';
@@ -1,13 +1,68 @@
1
- import { CaseExportData } from '~/types';
1
+ import { type CaseExportData } from '~/types';
2
2
  import { calculateSHA256Secure } from '~/utils/SHA256';
3
3
  import { CSV_HEADERS } from './types-constants';
4
4
  import { addForensicDataWarning } from './metadata-helpers';
5
5
 
6
+ export type TabularCell = string | number | boolean | null;
7
+
8
+ const MAX_SPREADSHEET_CELL_LENGTH = 32767;
9
+ const DANGEROUS_FORMULA_PREFIX_PATTERN = /^\s*[=+\-@]/;
10
+
11
+ function stripUnsafeControlChars(input: string): string {
12
+ let output = '';
13
+
14
+ for (let index = 0; index < input.length; index += 1) {
15
+ const code = input.charCodeAt(index);
16
+ const isControlChar = code <= 0x1f || code === 0x7f;
17
+ const isAllowedWhitespace = code === 0x09 || code === 0x0a || code === 0x0d;
18
+
19
+ if (!isControlChar || isAllowedWhitespace) {
20
+ output += input[index];
21
+ }
22
+ }
23
+
24
+ return output;
25
+ }
26
+
27
+ /**
28
+ * Sanitize cell values before CSV/XLSX export.
29
+ * - Strips non-printable control characters (except tab/newline/carriage return)
30
+ * - Prevents formula injection when files are opened in spreadsheet tools
31
+ * - Caps content length to Excel's per-cell limit
32
+ */
33
+ export function sanitizeTabularCell(value: unknown): TabularCell {
34
+ if (value === null || value === undefined) {
35
+ return '';
36
+ }
37
+
38
+ if (typeof value === 'number' || typeof value === 'boolean') {
39
+ return value;
40
+ }
41
+
42
+ let normalized = stripUnsafeControlChars(String(value));
43
+
44
+ if (normalized.length > MAX_SPREADSHEET_CELL_LENGTH) {
45
+ normalized = normalized.slice(0, MAX_SPREADSHEET_CELL_LENGTH);
46
+ }
47
+
48
+ if (DANGEROUS_FORMULA_PREFIX_PATTERN.test(normalized)) {
49
+ return `'${normalized}`;
50
+ }
51
+
52
+ return normalized;
53
+ }
54
+
55
+ export function sanitizeTabularMatrix(
56
+ rows: Array<Array<string | number | boolean | null | undefined>>
57
+ ): TabularCell[][] {
58
+ return rows.map((row) => row.map((cell) => sanitizeTabularCell(cell)));
59
+ }
60
+
6
61
  /**
7
62
  * Generate metadata rows for tabular format
8
63
  */
9
- export function generateMetadataRows(exportData: CaseExportData): string[][] {
10
- return [
64
+ export function generateMetadataRows(exportData: CaseExportData): TabularCell[][] {
65
+ return sanitizeTabularMatrix([
11
66
  ['Case Export Report'],
12
67
  [''],
13
68
  ['Case Number', exportData.metadata.caseNumber],
@@ -31,13 +86,13 @@ export function generateMetadataRows(exportData: CaseExportData): string[][] {
31
86
  ['Latest Annotation Date', exportData.summary?.latestAnnotationDate || 'N/A'],
32
87
  [''],
33
88
  ['File Details']
34
- ];
89
+ ]);
35
90
  }
36
91
 
37
92
  /**
38
93
  * Process file data for tabular format (CSV/Excel)
39
94
  */
40
- export function processFileDataForTabular(fileEntry: CaseExportData['files'][0]): string[][] {
95
+ export function processFileDataForTabular(fileEntry: CaseExportData['files'][0]): TabularCell[][] {
41
96
  // Full file data for the first row (excluding Additional Notes and Last Updated)
42
97
  const fullFileData = [
43
98
  fileEntry.fileData.id,
@@ -83,7 +138,7 @@ export function processFileDataForTabular(fileEntry: CaseExportData['files'][0])
83
138
  const emptyFileData = Array(fileDataColumnCount).fill('');
84
139
  const emptyAdditionalData = Array(additionalDataColumnCount).fill('');
85
140
 
86
- const rows: string[][] = [];
141
+ const rows: Array<Array<string | number | boolean | null | undefined>> = [];
87
142
 
88
143
  // If there are box annotations, create a row for each one
89
144
  if (fileEntry.annotations?.boxAnnotations && fileEntry.annotations.boxAnnotations.length > 0) {
@@ -120,7 +175,7 @@ export function processFileDataForTabular(fileEntry: CaseExportData['files'][0])
120
175
  ]);
121
176
  }
122
177
 
123
- return rows;
178
+ return sanitizeTabularMatrix(rows);
124
179
  }
125
180
 
126
181
  /**
@@ -131,16 +186,16 @@ export async function generateCSVContent(exportData: CaseExportData, protectFore
131
186
  const metadataRows = generateMetadataRows(exportData);
132
187
 
133
188
  // File data rows
134
- const fileRows: string[][] = [];
189
+ const fileRows: TabularCell[][] = [];
135
190
  exportData.files.forEach(fileEntry => {
136
191
  const processedRows = processFileDataForTabular(fileEntry);
137
192
  fileRows.push(...processedRows);
138
193
  });
139
194
 
140
195
  // Combine data rows for hash calculation (excluding header comments)
141
- const dataRows = [
196
+ const dataRows: TabularCell[][] = [
142
197
  ...metadataRows,
143
- CSV_HEADERS,
198
+ ...sanitizeTabularMatrix([CSV_HEADERS]),
144
199
  ...fileRows
145
200
  ];
146
201
 
@@ -1,13 +1,100 @@
1
- import { User } from 'firebase/auth';
2
- import { FileData, AllCasesExportData, CaseExportData, ExportOptions } from '~/types';
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';
3
4
  import { getImageUrl } from '../image-manage';
4
5
  import { generateForensicManifestSecure, calculateSHA256Secure } from '~/utils/SHA256';
5
6
  import { signForensicManifest } from '~/utils/data-operations';
6
- import { ExportFormat, formatDateForFilename, CSV_HEADERS } from './types-constants';
7
+ import { type ExportFormat, formatDateForFilename, CSV_HEADERS } from './types-constants';
7
8
  import { protectExcelWorksheet, addForensicDataWarning } from './metadata-helpers';
8
- import { generateMetadataRows, generateCSVContent, processFileDataForTabular } from './data-processing';
9
+ import { generateMetadataRows, generateCSVContent, processFileDataForTabular, sanitizeTabularMatrix } from './data-processing';
9
10
  import { exportCaseData } from './core-export';
10
- import { auditService } from '~/services/audit.service';
11
+ import { auditService } from '~/services/audit';
12
+
13
+ type TabularRow = Array<string | number | boolean | null | undefined>;
14
+ type ExcelJsBrowserBundle = typeof ExcelJSModule;
15
+
16
+ const EXCELJS_BROWSER_BUNDLE_SRC = '/vendor/exceljs.bare.min.js';
17
+ let excelJsBundlePromise: Promise<ExcelJsBrowserBundle> | null = null;
18
+
19
+ async function loadExcelJsBrowserBundle(): Promise<ExcelJsBrowserBundle> {
20
+ if (typeof window === 'undefined') {
21
+ throw new Error('Excel export is only available in a browser context.');
22
+ }
23
+
24
+ if (window.ExcelJS?.Workbook) {
25
+ return window.ExcelJS;
26
+ }
27
+
28
+ if (!excelJsBundlePromise) {
29
+ excelJsBundlePromise = new Promise((resolve, reject) => {
30
+ const resolveFromWindow = () => {
31
+ if (window.ExcelJS?.Workbook) {
32
+ resolve(window.ExcelJS);
33
+ return;
34
+ }
35
+
36
+ excelJsBundlePromise = null;
37
+ reject(new Error('ExcelJS bundle loaded but Workbook API is unavailable.'));
38
+ };
39
+
40
+ const failLoad = () => {
41
+ excelJsBundlePromise = null;
42
+ reject(new Error('Failed to load ExcelJS browser bundle.'));
43
+ };
44
+
45
+ const existingScript = document.querySelector<HTMLScriptElement>('script[data-exceljs-bundle="true"]');
46
+
47
+ if (existingScript) {
48
+ if (existingScript.dataset.loaded === 'true') {
49
+ resolveFromWindow();
50
+ return;
51
+ }
52
+
53
+ existingScript.addEventListener('load', resolveFromWindow, { once: true });
54
+ existingScript.addEventListener('error', failLoad, { once: true });
55
+ return;
56
+ }
57
+
58
+ const script = document.createElement('script');
59
+ script.src = EXCELJS_BROWSER_BUNDLE_SRC;
60
+ script.async = true;
61
+ script.dataset.exceljsBundle = 'true';
62
+ script.addEventListener(
63
+ 'load',
64
+ () => {
65
+ script.dataset.loaded = 'true';
66
+ resolveFromWindow();
67
+ },
68
+ { once: true }
69
+ );
70
+ script.addEventListener('error', failLoad, { once: true });
71
+ document.head.appendChild(script);
72
+ });
73
+ }
74
+
75
+ return excelJsBundlePromise;
76
+ }
77
+
78
+ function sanitizeWorksheetName(name: string): string {
79
+ const cleaned = name.replace(/[\\/?*:\x5B\x5D]/g, '_').trim();
80
+ const normalized = cleaned.length > 0 ? cleaned : 'Sheet';
81
+ return normalized.substring(0, 31);
82
+ }
83
+
84
+ function createUniqueWorksheetName(existingNames: Set<string>, desiredName: string): string {
85
+ const baseName = sanitizeWorksheetName(desiredName);
86
+ let candidate = baseName;
87
+ let suffix = 1;
88
+
89
+ while (existingNames.has(candidate)) {
90
+ const suffixText = `_${suffix}`;
91
+ candidate = `${baseName.substring(0, 31 - suffixText.length)}${suffixText}`;
92
+ suffix += 1;
93
+ }
94
+
95
+ existingNames.add(candidate);
96
+ return candidate;
97
+ }
11
98
 
12
99
  /**
13
100
  * Generate export filename with embedded ID to prevent collisions
@@ -122,10 +209,22 @@ export async function downloadAllCasesAsCSV(user: User, exportData: AllCasesExpo
122
209
  // Start audit workflow
123
210
  auditService.startWorkflow('all-cases');
124
211
 
125
- // Dynamic import of XLSX to avoid bundle size issues
126
- const XLSX = await import('xlsx');
212
+ const ExcelJS = await loadExcelJsBrowserBundle();
127
213
 
128
- const workbook = XLSX.utils.book_new();
214
+ const workbook = new ExcelJS.Workbook();
215
+ workbook.creator = exportData.metadata.exportedBy || 'Striae';
216
+ workbook.lastModifiedBy = exportData.metadata.exportedBy || 'Striae';
217
+ workbook.created = new Date();
218
+ workbook.modified = new Date();
219
+
220
+ const existingWorksheetNames = new Set<string>();
221
+
222
+ const appendRowsToWorksheet = (worksheet: { addRow: (row: TabularRow) => unknown }, rows: TabularRow[]) => {
223
+ rows.forEach((row) => {
224
+ worksheet.addRow(row);
225
+ });
226
+ };
227
+
129
228
  let exportPassword: string | undefined;
130
229
 
131
230
  // Create summary worksheet
@@ -146,7 +245,7 @@ export async function downloadAllCasesAsCSV(user: User, exportData: AllCasesExpo
146
245
  ];
147
246
 
148
247
  // XLSX files are inherently protected, no hash validation needed
149
- const summaryData = [
248
+ const summaryData = sanitizeTabularMatrix([
150
249
  protectForensicData ? ['CASE DATA - PROTECTED EXPORT'] : ['Striae - All Cases Export Summary'],
151
250
  protectForensicData ? ['WARNING: This workbook contains evidence data and is protected from editing.'] : [''],
152
251
  [''],
@@ -195,36 +294,37 @@ export async function downloadAllCasesAsCSV(user: User, exportData: AllCasesExpo
195
294
  caseData.summary?.latestAnnotationDate || '',
196
295
  caseData.summary?.exportError || ''
197
296
  ])
198
- ];
297
+ ]);
199
298
 
200
- const summaryWorksheet = XLSX.utils.aoa_to_sheet(summaryData);
299
+ const summaryWorksheetName = createUniqueWorksheetName(existingWorksheetNames, 'Summary');
300
+ const summaryWorksheet = workbook.addWorksheet(summaryWorksheetName);
301
+ appendRowsToWorksheet(summaryWorksheet, summaryData);
201
302
 
202
303
  // Protect summary worksheet if forensic protection is enabled
203
304
  if (protectForensicData) {
204
- exportPassword = protectExcelWorksheet(summaryWorksheet);
305
+ exportPassword = await protectExcelWorksheet(summaryWorksheet);
205
306
  }
206
-
207
- XLSX.utils.book_append_sheet(workbook, summaryWorksheet, 'Summary');
208
307
 
209
308
  // Create a worksheet for each case
210
- exportData.cases.forEach((caseData) => {
309
+ for (const caseData of exportData.cases) {
211
310
  if (caseData.summary?.exportError) {
212
311
  // For failed cases, create a simple error sheet
213
- const errorData = [
312
+ const errorData = sanitizeTabularMatrix([
214
313
  [`Case ${caseData.metadata.caseNumber} - Export Failed`],
215
314
  [''],
216
315
  ['Error:', caseData.summary.exportError],
217
316
  ['Case Number:', caseData.metadata.caseNumber],
218
317
  ['Total Files:', caseData.metadata.totalFiles]
219
- ];
220
- const errorWorksheet = XLSX.utils.aoa_to_sheet(errorData);
318
+ ]);
319
+ const errorSheetName = createUniqueWorksheetName(existingWorksheetNames, `Case_${caseData.metadata.caseNumber}_Error`);
320
+ const errorWorksheet = workbook.addWorksheet(errorSheetName);
321
+ appendRowsToWorksheet(errorWorksheet, errorData);
221
322
 
222
323
  if (protectForensicData && exportPassword) {
223
- protectExcelWorksheet(errorWorksheet, exportPassword);
324
+ await protectExcelWorksheet(errorWorksheet, exportPassword);
224
325
  }
225
-
226
- XLSX.utils.book_append_sheet(workbook, errorWorksheet, `Case_${caseData.metadata.caseNumber}_Error`);
227
- return;
326
+
327
+ continue;
228
328
  }
229
329
 
230
330
  // For successful cases, create detailed worksheets
@@ -257,36 +357,22 @@ export async function downloadAllCasesAsCSV(user: User, exportData: AllCasesExpo
257
357
  caseDetailsData.push(['No detailed file data available for this case']);
258
358
  }
259
359
 
260
- const caseWorksheet = XLSX.utils.aoa_to_sheet(caseDetailsData);
360
+ const sanitizedCaseDetailsData = sanitizeTabularMatrix(caseDetailsData);
361
+
362
+ // Clean sheet name for Excel compatibility and uniqueness
363
+ const sheetName = createUniqueWorksheetName(existingWorksheetNames, `Case_${caseData.metadata.caseNumber}`);
364
+ const caseWorksheet = workbook.addWorksheet(sheetName);
365
+ appendRowsToWorksheet(caseWorksheet, sanitizedCaseDetailsData);
261
366
 
262
367
  // Protect worksheet if forensic protection is enabled
263
368
  if (protectForensicData && exportPassword) {
264
- protectExcelWorksheet(caseWorksheet, exportPassword);
369
+ await protectExcelWorksheet(caseWorksheet, exportPassword);
265
370
  }
266
-
267
- // Clean sheet name for Excel compatibility
268
- const sheetName = `Case_${caseData.metadata.caseNumber}`.replace(/[\\/?*\x5B\x5D]/g, '_').substring(0, 31);
269
- XLSX.utils.book_append_sheet(workbook, caseWorksheet, sheetName);
270
- });
271
371
 
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
372
  }
282
373
 
283
374
  // 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
- });
375
+ const excelBuffer = await workbook.xlsx.writeBuffer();
290
376
 
291
377
  // Create blob and download
292
378
  const blob = new Blob([excelBuffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
@@ -1,4 +1,4 @@
1
- import { User } from 'firebase/auth';
1
+ import type { User } from 'firebase/auth';
2
2
  import { getUserData } from '~/utils/permissions';
3
3
 
4
4
  /**
@@ -68,16 +68,38 @@ export function generateRandomPassword(): string {
68
68
  return password.split('').sort(() => Math.random() - 0.5).join('');
69
69
  }
70
70
 
71
+ type WorksheetProtectionOptions = {
72
+ selectLockedCells: boolean;
73
+ selectUnlockedCells: boolean;
74
+ formatCells: boolean;
75
+ formatColumns: boolean;
76
+ formatRows: boolean;
77
+ insertColumns: boolean;
78
+ insertRows: boolean;
79
+ insertHyperlinks: boolean;
80
+ deleteColumns: boolean;
81
+ deleteRows: boolean;
82
+ sort: boolean;
83
+ autoFilter: boolean;
84
+ pivotTables: boolean;
85
+ objects: boolean;
86
+ scenarios: boolean;
87
+ spinCount: number;
88
+ };
89
+
90
+ type ProtectableWorksheet = {
91
+ protect: (password: string, options: Record<string, unknown>) => Promise<unknown> | unknown;
92
+ };
93
+
71
94
  /**
72
95
  * Protect Excel worksheet from editing
73
96
  */
74
- export function protectExcelWorksheet(worksheet: Record<string, unknown>, sheetPassword?: string): string {
97
+ export async function protectExcelWorksheet(worksheet: ProtectableWorksheet, sheetPassword?: string): Promise<string> {
75
98
  // Generate random password if none provided
76
99
  const password = sheetPassword || generateRandomPassword();
77
-
78
- // Set worksheet protection
79
- worksheet['!protect'] = {
80
- password: password,
100
+
101
+ const protectionOptions: WorksheetProtectionOptions = {
102
+ // Keep read-only defaults and prevent structural edits.
81
103
  selectLockedCells: true,
82
104
  selectUnlockedCells: true,
83
105
  formatCells: false,
@@ -92,15 +114,11 @@ export function protectExcelWorksheet(worksheet: Record<string, unknown>, sheetP
92
114
  autoFilter: false,
93
115
  pivotTables: false,
94
116
  objects: false,
95
- scenarios: false
117
+ scenarios: false,
118
+ spinCount: 100000
96
119
  };
97
-
98
- // Lock all cells by default
99
- if (!worksheet['!cols']) worksheet['!cols'] = [];
100
- if (!worksheet['!rows']) worksheet['!rows'] = [];
101
-
102
- // Add protection metadata
103
- worksheet['!margins'] = { left: 0.7, right: 0.7, top: 0.75, bottom: 0.75, header: 0.3, footer: 0.3 };
120
+
121
+ await Promise.resolve(worksheet.protect(password, protectionOptions as Record<string, unknown>));
104
122
 
105
123
  // Return the password for inclusion in metadata
106
124
  return password;
@@ -1,5 +1,5 @@
1
- import { User } from 'firebase/auth';
2
- import { CaseExportData } from '~/types';
1
+ import type { User } from 'firebase/auth';
2
+ import { type CaseExportData } from '~/types';
3
3
  import { saveNotes } from '../notes-manage';
4
4
 
5
5
  /**
@@ -1,10 +1,10 @@
1
- import { User } from 'firebase/auth';
1
+ import type { User } from 'firebase/auth';
2
2
  import paths from '~/config/config.json';
3
3
  import { getDataApiKey } from '~/utils/auth';
4
- import { ConfirmationImportResult, ConfirmationImportData } from '~/types';
4
+ import { type ConfirmationImportResult, type ConfirmationImportData } from '~/types';
5
5
  import { checkExistingCase } from '../case-manage';
6
6
  import { validateExporterUid, validateConfirmationHash, validateConfirmationSignatureFile } from './validation';
7
- import { auditService } from '~/services/audit.service';
7
+ import { auditService } from '~/services/audit';
8
8
 
9
9
  const DATA_WORKER_URL = paths.data_worker_url;
10
10
 
@@ -1,6 +1,6 @@
1
1
  import paths from '~/config/config.json';
2
2
  import { getImageApiKey } from '~/utils/auth';
3
- import { FileData, ImageUploadResponse } from '~/types';
3
+ import { type FileData, type ImageUploadResponse } from '~/types';
4
4
 
5
5
  const IMAGE_WORKER_URL = paths.image_worker_url;
6
6
 
@@ -1,9 +1,9 @@
1
- import { User } from 'firebase/auth';
2
- import { ImportOptions, ImportResult, ReadOnlyCaseMetadata, FileData } from '~/types';
1
+ import type { User } from 'firebase/auth';
2
+ import { type ImportOptions, type ImportResult, type ReadOnlyCaseMetadata, type FileData } from '~/types';
3
3
  import { checkExistingCase } from '../case-manage';
4
4
  import {
5
5
  extractForensicManifestData,
6
- SignedForensicManifest,
6
+ type SignedForensicManifest,
7
7
  validateCaseIntegritySecure as validateForensicIntegrity,
8
8
  verifyForensicManifestSignature
9
9
  } from '~/utils/SHA256';
@@ -19,7 +19,7 @@ import {
19
19
  } from './storage-operations';
20
20
  import { uploadImageBlob } from './image-operations';
21
21
  import { importAnnotations } from './annotation-import';
22
- import { auditService } from '~/services/audit.service';
22
+ import { auditService } from '~/services/audit';
23
23
 
24
24
  /**
25
25
  * Track the state of an import operation for cleanup purposes
@@ -1,4 +1,4 @@
1
- import { User } from 'firebase/auth';
1
+ import type { User } from 'firebase/auth';
2
2
  import paths from '~/config/config.json';
3
3
  import {
4
4
  getDataApiKey,
@@ -10,14 +10,14 @@ import {
10
10
  validateUserSession
11
11
  } from '~/utils/permissions';
12
12
  import {
13
- CaseExportData,
14
- ExtendedUserData,
15
- FileData,
16
- CaseData,
17
- ReadOnlyCaseMetadata
13
+ type CaseExportData,
14
+ type ExtendedUserData,
15
+ type FileData,
16
+ type CaseData,
17
+ type ReadOnlyCaseMetadata
18
18
  } from '~/types';
19
19
  import { deleteFile } from '../image-manage';
20
- import { SignedForensicManifest } from '~/utils/SHA256';
20
+ import { type SignedForensicManifest } from '~/utils/SHA256';
21
21
 
22
22
  const USER_WORKER_URL = paths.user_worker_url;
23
23
  const DATA_WORKER_URL = paths.data_worker_url;
@@ -1,8 +1,8 @@
1
- import { User } from 'firebase/auth';
1
+ import type { User } from 'firebase/auth';
2
2
  import paths from '~/config/config.json';
3
3
  import { getUserApiKey } from '~/utils/auth';
4
- import { CaseExportData, ConfirmationImportData } from '~/types';
5
- import { calculateSHA256Secure, ManifestSignatureVerificationResult } from '~/utils/SHA256';
4
+ import { type CaseExportData, type ConfirmationImportData } from '~/types';
5
+ import { calculateSHA256Secure, type ManifestSignatureVerificationResult } from '~/utils/SHA256';
6
6
  import { verifyConfirmationSignature } from '~/utils/confirmation-signature';
7
7
 
8
8
  const USER_WORKER_URL = paths.user_worker_url;
@@ -1,9 +1,9 @@
1
- import { User } from 'firebase/auth';
2
- import { CaseExportData, CaseImportPreview } from '~/types';
1
+ import type { User } from 'firebase/auth';
2
+ import { type CaseExportData, type CaseImportPreview } from '~/types';
3
3
  import { validateCaseNumber } from '../case-manage';
4
4
  import {
5
5
  extractForensicManifestData,
6
- SignedForensicManifest,
6
+ type SignedForensicManifest,
7
7
  validateCaseIntegritySecure as validateForensicIntegrity,
8
8
  verifyForensicManifestSignature
9
9
  } from '~/utils/SHA256';
@@ -1,4 +1,4 @@
1
- import { User } from 'firebase/auth';
1
+ import type { User } from 'firebase/auth';
2
2
  import {
3
3
  canCreateCase,
4
4
  getUserCases,
@@ -13,8 +13,8 @@ import {
13
13
  duplicateCaseData,
14
14
  deleteFileAnnotations
15
15
  } from '~/utils/data-operations';
16
- import { CaseData, ReadOnlyCaseData, FileData } from '~/types';
17
- import { auditService } from '~/services/audit.service';
16
+ import { type CaseData, type ReadOnlyCaseData, type FileData } from '~/types';
17
+ import { auditService } from '~/services/audit';
18
18
  import { getImageApiKey } from '~/utils/auth';
19
19
  import paths from '~/config/config.json';
20
20
 
@@ -1,9 +1,9 @@
1
- import { User } from 'firebase/auth';
1
+ import type { User } from 'firebase/auth';
2
2
  import { calculateSHA256Secure } from '~/utils/SHA256';
3
3
  import { getUserData } from '~/utils/permissions';
4
4
  import { getCaseData, updateCaseData, signConfirmationData } from '~/utils/data-operations';
5
- import { ConfirmationData, CaseConfirmations, CaseDataWithConfirmations, ConfirmationImportData } from '~/types';
6
- import { auditService } from '~/services/audit.service';
5
+ import { type ConfirmationData, type CaseConfirmations, type CaseDataWithConfirmations, type ConfirmationImportData } from '~/types';
6
+ import { auditService } from '~/services/audit';
7
7
 
8
8
  /**
9
9
  * Store a confirmation for a specific image, linked to the original image ID
@@ -1,8 +1,8 @@
1
1
  import paths from '~/config/config.json';
2
- import { AnnotationData } from '~/types/annotations';
3
- import { auditService } from '~/services/audit.service';
2
+ import { type AnnotationData } from '~/types/annotations';
3
+ import { auditService } from '~/services/audit';
4
4
  import { getPdfApiKey } from '~/utils/auth';
5
- import { User } from 'firebase/auth';
5
+ import type { User } from 'firebase/auth';
6
6
 
7
7
  interface GeneratePDFParams {
8
8
  user: User;