@striae-org/striae 3.2.2 → 4.0.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.
- package/.env.example +1 -1
- package/app/components/actions/case-export/core-export.ts +5 -2
- package/app/components/actions/case-export/download-handlers.ts +51 -3
- package/app/components/actions/case-import/confirmation-import.ts +65 -40
- package/app/components/actions/case-import/confirmation-package.ts +86 -0
- package/app/components/actions/case-import/image-operations.ts +20 -49
- package/app/components/actions/case-import/index.ts +1 -0
- package/app/components/actions/case-import/orchestrator.ts +13 -3
- package/app/components/actions/case-import/storage-operations.ts +54 -89
- package/app/components/actions/case-import/validation.ts +7 -111
- package/app/components/actions/case-import/zip-processing.ts +44 -2
- package/app/components/actions/case-manage.ts +15 -27
- package/app/components/actions/confirm-export.ts +44 -13
- package/app/components/actions/generate-pdf.ts +3 -7
- package/app/components/actions/image-manage.ts +63 -129
- package/app/components/button/button.module.css +12 -8
- package/app/components/form/form-button.tsx +1 -1
- package/app/components/form/form.module.css +9 -0
- package/app/components/public-signing-key-modal/public-signing-key-modal.module.css +163 -49
- package/app/components/public-signing-key-modal/public-signing-key-modal.tsx +365 -88
- package/app/components/sidebar/case-export/case-export.tsx +13 -60
- package/app/components/sidebar/case-import/case-import.tsx +18 -6
- package/app/components/sidebar/case-import/hooks/useFilePreview.ts +6 -4
- package/app/components/sidebar/case-import/utils/file-validation.ts +57 -2
- package/app/components/sidebar/cases/case-sidebar.tsx +122 -52
- package/app/components/sidebar/cases/cases.module.css +101 -18
- package/app/components/sidebar/notes/notes.module.css +33 -13
- package/app/components/sidebar/sidebar.module.css +0 -2
- package/app/components/user/delete-account.tsx +7 -7
- package/app/components/user/manage-profile.tsx +1 -1
- package/app/components/user/mfa-phone-update.tsx +15 -12
- package/app/config-example/config.json +2 -8
- package/app/hooks/useInactivityTimeout.ts +2 -5
- package/app/root.tsx +96 -65
- package/app/routes/auth/login.tsx +132 -11
- package/app/routes/auth/route.ts +4 -3
- package/app/routes/striae/striae.tsx +4 -8
- package/app/services/audit/audit-api-client.ts +40 -0
- package/app/services/audit/audit-worker-client.ts +14 -17
- package/app/styles/root.module.css +13 -101
- package/app/tailwind.css +9 -2
- package/app/utils/SHA256.ts +5 -1
- package/app/utils/auth.ts +5 -32
- package/app/utils/confirmation-signature.ts +5 -1
- package/app/utils/data-api-client.ts +43 -0
- package/app/utils/data-operations.ts +59 -75
- package/app/utils/export-verification.ts +353 -0
- package/app/utils/image-api-client.ts +130 -0
- package/app/utils/pdf-api-client.ts +43 -0
- package/app/utils/permissions.ts +10 -23
- package/app/utils/signature-utils.ts +74 -4
- package/app/utils/user-api-client.ts +90 -0
- package/functions/api/_shared/firebase-auth.ts +255 -0
- package/functions/api/audit/[[path]].ts +150 -0
- package/functions/api/data/[[path]].ts +141 -0
- package/functions/api/image/[[path]].ts +127 -0
- package/functions/api/pdf/[[path]].ts +110 -0
- package/functions/api/user/[[path]].ts +196 -0
- package/package.json +8 -4
- package/public/favicon.ico +0 -0
- package/public/icon-256.png +0 -0
- package/public/icon-512.png +0 -0
- package/public/manifest.json +39 -0
- package/public/shortcut.png +0 -0
- package/public/social-image.png +0 -0
- package/react-router.config.ts +5 -0
- package/scripts/deploy-all.sh +22 -8
- package/scripts/deploy-config.sh +143 -148
- package/scripts/deploy-pages-secrets.sh +231 -0
- package/scripts/deploy-worker-secrets.sh +1 -1
- package/workers/audit-worker/wrangler.jsonc.example +1 -8
- package/workers/data-worker/wrangler.jsonc.example +1 -8
- package/workers/image-worker/wrangler.jsonc.example +1 -8
- package/workers/keys-worker/wrangler.jsonc.example +2 -9
- package/workers/pdf-worker/scripts/generate-assets.js +94 -0
- package/workers/pdf-worker/src/assets/icon-256.png +0 -0
- package/workers/pdf-worker/wrangler.jsonc.example +1 -8
- package/workers/user-worker/src/user-worker.example.ts +121 -41
- package/workers/user-worker/wrangler.jsonc.example +1 -8
- package/wrangler.toml.example +1 -1
- package/app/styles/legal-pages.module.css +0 -113
- package/public/favicon.svg +0 -9
package/.env.example
CHANGED
|
@@ -64,7 +64,7 @@ DATA_BUCKET_NAME=your_data_bucket_name_here
|
|
|
64
64
|
DATA_WORKER_DOMAIN=your_data_worker_domain_here
|
|
65
65
|
# Auto-generated by scripts/deploy-config.sh when placeholders are detected.
|
|
66
66
|
MANIFEST_SIGNING_PRIVATE_KEY=your_manifest_signing_private_key_here
|
|
67
|
-
MANIFEST_SIGNING_KEY_ID=
|
|
67
|
+
MANIFEST_SIGNING_KEY_ID=your_manifest_signing_key_id_here
|
|
68
68
|
MANIFEST_SIGNING_PUBLIC_KEY=your_manifest_signing_public_key_here
|
|
69
69
|
|
|
70
70
|
# ================================
|
|
@@ -183,7 +183,8 @@ export async function exportAllCases(
|
|
|
183
183
|
export async function exportCaseData(
|
|
184
184
|
user: User,
|
|
185
185
|
caseNumber: string,
|
|
186
|
-
options: ExportOptions = {}
|
|
186
|
+
options: ExportOptions = {},
|
|
187
|
+
onProgress?: (current: number, total: number, label: string) => void
|
|
187
188
|
): Promise<CaseExportData> {
|
|
188
189
|
// NOTE: startTime and fileName tracking moved to download handlers
|
|
189
190
|
|
|
@@ -225,7 +226,8 @@ export async function exportCaseData(
|
|
|
225
226
|
let earliestAnnotationDate: string | undefined;
|
|
226
227
|
let latestAnnotationDate: string | undefined;
|
|
227
228
|
|
|
228
|
-
for (
|
|
229
|
+
for (let fileIndex = 0; fileIndex < files.length; fileIndex++) {
|
|
230
|
+
const file = files[fileIndex];
|
|
229
231
|
let annotations: AnnotationData | undefined;
|
|
230
232
|
let hasAnnotations = false;
|
|
231
233
|
|
|
@@ -287,6 +289,7 @@ export async function exportCaseData(
|
|
|
287
289
|
annotations,
|
|
288
290
|
hasAnnotations
|
|
289
291
|
});
|
|
292
|
+
onProgress?.(fileIndex + 1, files.length, `Loading file ${fileIndex + 1} of ${files.length}`);
|
|
290
293
|
}
|
|
291
294
|
|
|
292
295
|
// Build export data
|
|
@@ -4,6 +4,11 @@ import { type FileData, type AllCasesExportData, type CaseExportData, type Expor
|
|
|
4
4
|
import { getImageUrl } from '../image-manage';
|
|
5
5
|
import { generateForensicManifestSecure, calculateSHA256Secure } from '~/utils/SHA256';
|
|
6
6
|
import { signForensicManifest } from '~/utils/data-operations';
|
|
7
|
+
import {
|
|
8
|
+
createPublicSigningKeyFileName,
|
|
9
|
+
getCurrentPublicSigningKeyDetails,
|
|
10
|
+
getVerificationPublicKey
|
|
11
|
+
} from '~/utils/signature-utils';
|
|
7
12
|
import { type ExportFormat, formatDateForFilename, CSV_HEADERS } from './types-constants';
|
|
8
13
|
import { protectExcelWorksheet, addForensicDataWarning } from './metadata-helpers';
|
|
9
14
|
import { generateMetadataRows, generateCSVContent, processFileDataForTabular, sanitizeTabularMatrix } from './data-processing';
|
|
@@ -115,6 +120,30 @@ function generateExportFilename(originalFilename: string, id: string): string {
|
|
|
115
120
|
return `${basename}-${id}${extension}`;
|
|
116
121
|
}
|
|
117
122
|
|
|
123
|
+
function addPublicSigningKeyPemToZip(
|
|
124
|
+
zip: { file: (path: string, data: string) => unknown },
|
|
125
|
+
preferredKeyId?: string
|
|
126
|
+
): string {
|
|
127
|
+
const preferredPublicKey =
|
|
128
|
+
typeof preferredKeyId === 'string' && preferredKeyId.trim().length > 0
|
|
129
|
+
? getVerificationPublicKey(preferredKeyId)
|
|
130
|
+
: null;
|
|
131
|
+
|
|
132
|
+
const currentKey = getCurrentPublicSigningKeyDetails();
|
|
133
|
+
const keyId = preferredPublicKey ? preferredKeyId ?? null : currentKey.keyId;
|
|
134
|
+
const publicKeyPem = preferredPublicKey ?? currentKey.publicKeyPem;
|
|
135
|
+
|
|
136
|
+
if (!publicKeyPem || publicKeyPem.trim().length === 0) {
|
|
137
|
+
throw new Error('No public signing key is configured for ZIP export packaging.');
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const publicKeyFileName = createPublicSigningKeyFileName(keyId);
|
|
141
|
+
const normalizedPem = publicKeyPem.endsWith('\n') ? publicKeyPem : `${publicKeyPem}\n`;
|
|
142
|
+
zip.file(publicKeyFileName, normalizedPem);
|
|
143
|
+
|
|
144
|
+
return publicKeyFileName;
|
|
145
|
+
}
|
|
146
|
+
|
|
118
147
|
/**
|
|
119
148
|
* Download all cases data as JSON file
|
|
120
149
|
*/
|
|
@@ -609,6 +638,7 @@ export async function downloadCaseAsZip(
|
|
|
609
638
|
const startTime = Date.now();
|
|
610
639
|
let manifestSignatureKeyId: string | undefined;
|
|
611
640
|
let manifestSigned = false;
|
|
641
|
+
let publicKeyFileName: string | undefined;
|
|
612
642
|
|
|
613
643
|
try {
|
|
614
644
|
// Start audit workflow
|
|
@@ -672,6 +702,8 @@ export async function downloadCaseAsZip(
|
|
|
672
702
|
manifestSignatureKeyId = signingResult.signature.keyId;
|
|
673
703
|
manifestSigned = true;
|
|
674
704
|
|
|
705
|
+
publicKeyFileName = addPublicSigningKeyPemToZip(zip, signingResult.signature.keyId);
|
|
706
|
+
|
|
675
707
|
const signedForensicManifest = {
|
|
676
708
|
...forensicManifest,
|
|
677
709
|
manifestVersion: signingResult.manifestVersion,
|
|
@@ -696,6 +728,7 @@ Archive Contents:
|
|
|
696
728
|
- ${caseNumber}_data.${format}: Complete case data in ${format.toUpperCase()} format
|
|
697
729
|
- images/: Original image files with annotations
|
|
698
730
|
- FORENSIC_MANIFEST.json: File integrity validation manifest
|
|
731
|
+
- ${publicKeyFileName}: Public signing key PEM for verification
|
|
699
732
|
- README.txt: General information about this export
|
|
700
733
|
|
|
701
734
|
Case Information:
|
|
@@ -713,7 +746,11 @@ For questions about this export, contact your Striae system administrator.
|
|
|
713
746
|
zip.file('READ_ONLY_INSTRUCTIONS.txt', instructionContent);
|
|
714
747
|
|
|
715
748
|
// Add README
|
|
716
|
-
const readme = generateZipReadme(
|
|
749
|
+
const readme = generateZipReadme(
|
|
750
|
+
exportData,
|
|
751
|
+
options.protectForensicData,
|
|
752
|
+
publicKeyFileName
|
|
753
|
+
);
|
|
717
754
|
zip.file('README.txt', readme);
|
|
718
755
|
onProgress?.(85);
|
|
719
756
|
|
|
@@ -772,8 +809,14 @@ For questions about this export, contact your Striae system administrator.
|
|
|
772
809
|
return; // Exit early as we've handled the forensic case
|
|
773
810
|
}
|
|
774
811
|
|
|
812
|
+
publicKeyFileName = addPublicSigningKeyPemToZip(zip);
|
|
813
|
+
|
|
775
814
|
// Add README (standard or enhanced for forensic)
|
|
776
|
-
const readme = generateZipReadme(
|
|
815
|
+
const readme = generateZipReadme(
|
|
816
|
+
exportData,
|
|
817
|
+
options.protectForensicData,
|
|
818
|
+
publicKeyFileName
|
|
819
|
+
);
|
|
777
820
|
zip.file('README.txt', readme);
|
|
778
821
|
onProgress?.(85);
|
|
779
822
|
|
|
@@ -878,7 +921,11 @@ async function fetchImageAsBlob(user: User, fileData: FileData, caseNumber: stri
|
|
|
878
921
|
/**
|
|
879
922
|
* Generate README content for ZIP export with optional forensic protection
|
|
880
923
|
*/
|
|
881
|
-
function generateZipReadme(
|
|
924
|
+
function generateZipReadme(
|
|
925
|
+
exportData: CaseExportData,
|
|
926
|
+
protectForensicData: boolean = true,
|
|
927
|
+
publicKeyFileName: string = createPublicSigningKeyFileName()
|
|
928
|
+
): string {
|
|
882
929
|
const totalFiles = exportData.files?.length || 0;
|
|
883
930
|
const filesWithAnnotations = exportData.summary?.filesWithAnnotations || 0;
|
|
884
931
|
const totalBoxAnnotations = exportData.summary?.totalBoxAnnotations || 0;
|
|
@@ -912,6 +959,7 @@ Summary:
|
|
|
912
959
|
Contents:
|
|
913
960
|
- ${exportData.metadata.caseNumber}_data.json/.csv: Case data and annotations
|
|
914
961
|
- images/: Original uploaded images
|
|
962
|
+
- ${publicKeyFileName}: Public signing key PEM for verification
|
|
915
963
|
- README.txt: This file`;
|
|
916
964
|
|
|
917
965
|
const forensicAddition = `
|
|
@@ -1,13 +1,11 @@
|
|
|
1
1
|
import type { User } from 'firebase/auth';
|
|
2
|
-
import
|
|
3
|
-
import { getDataApiKey } from '~/utils/auth';
|
|
2
|
+
import { fetchDataApi } from '~/utils/data-api-client';
|
|
4
3
|
import { type ConfirmationImportResult, type ConfirmationImportData } from '~/types';
|
|
5
4
|
import { checkExistingCase } from '../case-manage';
|
|
5
|
+
import { extractConfirmationImportPackage } from './confirmation-package';
|
|
6
6
|
import { validateExporterUid, validateConfirmationHash, validateConfirmationSignatureFile } from './validation';
|
|
7
7
|
import { auditService } from '~/services/audit';
|
|
8
8
|
|
|
9
|
-
const DATA_WORKER_URL = paths.data_worker_url;
|
|
10
|
-
|
|
11
9
|
interface CaseDataFile {
|
|
12
10
|
id: string;
|
|
13
11
|
originalFilename?: string;
|
|
@@ -36,6 +34,8 @@ export async function importConfirmationData(
|
|
|
36
34
|
let signatureValid = false;
|
|
37
35
|
let signaturePresent = false;
|
|
38
36
|
let signatureKeyId: string | undefined;
|
|
37
|
+
let confirmationDataForAudit: ConfirmationImportData | null = null;
|
|
38
|
+
let confirmationJsonFileNameForAudit = confirmationFile.name;
|
|
39
39
|
|
|
40
40
|
const result: ConfirmationImportResult = {
|
|
41
41
|
success: false,
|
|
@@ -47,11 +47,17 @@ export async function importConfirmationData(
|
|
|
47
47
|
};
|
|
48
48
|
|
|
49
49
|
try {
|
|
50
|
-
onProgress?.('Reading confirmation file', 10, 'Loading
|
|
50
|
+
onProgress?.('Reading confirmation file', 10, 'Loading confirmation package...');
|
|
51
|
+
|
|
52
|
+
const {
|
|
53
|
+
confirmationData,
|
|
54
|
+
confirmationJsonContent,
|
|
55
|
+
verificationPublicKeyPem,
|
|
56
|
+
confirmationFileName
|
|
57
|
+
} = await extractConfirmationImportPackage(confirmationFile);
|
|
51
58
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
const confirmationData: ConfirmationImportData = JSON.parse(fileContent);
|
|
59
|
+
confirmationDataForAudit = confirmationData;
|
|
60
|
+
confirmationJsonFileNameForAudit = confirmationFileName;
|
|
55
61
|
result.caseNumber = confirmationData.metadata.caseNumber;
|
|
56
62
|
|
|
57
63
|
// Start audit workflow
|
|
@@ -60,14 +66,17 @@ export async function importConfirmationData(
|
|
|
60
66
|
onProgress?.('Validating hash', 20, 'Verifying data integrity...');
|
|
61
67
|
|
|
62
68
|
// Validate hash
|
|
63
|
-
hashValid = await validateConfirmationHash(
|
|
69
|
+
hashValid = await validateConfirmationHash(confirmationJsonContent, confirmationData.metadata.hash);
|
|
64
70
|
if (!hashValid) {
|
|
65
71
|
throw new Error('Confirmation data hash validation failed. The file may have been tampered with or corrupted.');
|
|
66
72
|
}
|
|
67
73
|
|
|
68
74
|
onProgress?.('Validating signature', 30, 'Verifying signed confirmation metadata...');
|
|
69
75
|
|
|
70
|
-
const signatureResult = await validateConfirmationSignatureFile(
|
|
76
|
+
const signatureResult = await validateConfirmationSignatureFile(
|
|
77
|
+
confirmationData,
|
|
78
|
+
verificationPublicKeyPem
|
|
79
|
+
);
|
|
71
80
|
signaturePresent = !!confirmationData.metadata.signature;
|
|
72
81
|
signatureValid = signatureResult.isValid;
|
|
73
82
|
signatureKeyId = signatureResult.keyId;
|
|
@@ -101,13 +110,13 @@ export async function importConfirmationData(
|
|
|
101
110
|
onProgress?.('Processing confirmations', 60, 'Validating timestamps and updating annotations...');
|
|
102
111
|
|
|
103
112
|
// Get case data to find image IDs
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
'
|
|
113
|
+
const caseResponse = await fetchDataApi(
|
|
114
|
+
user,
|
|
115
|
+
`/${encodeURIComponent(user.uid)}/${encodeURIComponent(result.caseNumber)}/data.json`,
|
|
116
|
+
{
|
|
117
|
+
method: 'GET'
|
|
109
118
|
}
|
|
110
|
-
|
|
119
|
+
);
|
|
111
120
|
|
|
112
121
|
if (!caseResponse.ok) {
|
|
113
122
|
throw new Error(`Failed to fetch case data: ${caseResponse.status}`);
|
|
@@ -147,12 +156,13 @@ export async function importConfirmationData(
|
|
|
147
156
|
const displayFilename = currentFile?.originalFilename || currentImageId;
|
|
148
157
|
|
|
149
158
|
// Get current annotation data for this image
|
|
150
|
-
const annotationResponse = await
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
159
|
+
const annotationResponse = await fetchDataApi(
|
|
160
|
+
user,
|
|
161
|
+
`/${encodeURIComponent(user.uid)}/${encodeURIComponent(result.caseNumber)}/${encodeURIComponent(currentImageId)}/data.json`,
|
|
162
|
+
{
|
|
163
|
+
method: 'GET'
|
|
154
164
|
}
|
|
155
|
-
|
|
165
|
+
);
|
|
156
166
|
|
|
157
167
|
let annotationData: AnnotationImportData = {};
|
|
158
168
|
if (annotationResponse.ok) {
|
|
@@ -204,14 +214,17 @@ export async function importConfirmationData(
|
|
|
204
214
|
};
|
|
205
215
|
|
|
206
216
|
// Save updated annotation data
|
|
207
|
-
const saveResponse = await
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
'
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
217
|
+
const saveResponse = await fetchDataApi(
|
|
218
|
+
user,
|
|
219
|
+
`/${encodeURIComponent(user.uid)}/${encodeURIComponent(result.caseNumber)}/${encodeURIComponent(currentImageId)}/data.json`,
|
|
220
|
+
{
|
|
221
|
+
method: 'PUT',
|
|
222
|
+
headers: {
|
|
223
|
+
'Content-Type': 'application/json'
|
|
224
|
+
},
|
|
225
|
+
body: JSON.stringify(updatedAnnotationData)
|
|
226
|
+
}
|
|
227
|
+
);
|
|
215
228
|
|
|
216
229
|
if (saveResponse.ok) {
|
|
217
230
|
result.imagesUpdated++;
|
|
@@ -276,7 +289,7 @@ export async function importConfirmationData(
|
|
|
276
289
|
await auditService.logConfirmationImport(
|
|
277
290
|
user,
|
|
278
291
|
result.caseNumber,
|
|
279
|
-
|
|
292
|
+
confirmationJsonFileNameForAudit,
|
|
280
293
|
result.success ? (result.errors && result.errors.length > 0 ? 'warning' : 'success') : 'failure',
|
|
281
294
|
hashValid,
|
|
282
295
|
result.confirmationsImported, // Successfully imported confirmations
|
|
@@ -318,19 +331,31 @@ export async function importConfirmationData(
|
|
|
318
331
|
let signatureValidForAudit = signatureValid;
|
|
319
332
|
let signatureKeyIdForAudit = signatureKeyId;
|
|
320
333
|
|
|
334
|
+
const auditConfirmationData = confirmationDataForAudit;
|
|
335
|
+
|
|
321
336
|
// First, try to extract basic metadata for audit purposes (if file is parseable)
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
if (confirmationData.metadata?.signature) {
|
|
337
|
+
if (auditConfirmationData) {
|
|
338
|
+
reviewingExaminerUidForAudit = auditConfirmationData.metadata?.exportedByUid;
|
|
339
|
+
totalConfirmationsForAudit = auditConfirmationData.metadata?.totalConfirmations || 0;
|
|
340
|
+
if (auditConfirmationData.metadata?.signature) {
|
|
327
341
|
signaturePresentForAudit = true;
|
|
328
|
-
signatureKeyIdForAudit =
|
|
342
|
+
signatureKeyIdForAudit = auditConfirmationData.metadata.signature.keyId;
|
|
343
|
+
}
|
|
344
|
+
} else {
|
|
345
|
+
try {
|
|
346
|
+
const extracted = await extractConfirmationImportPackage(confirmationFile);
|
|
347
|
+
reviewingExaminerUidForAudit = extracted.confirmationData.metadata?.exportedByUid;
|
|
348
|
+
totalConfirmationsForAudit = extracted.confirmationData.metadata?.totalConfirmations || 0;
|
|
349
|
+
confirmationJsonFileNameForAudit = extracted.confirmationFileName;
|
|
350
|
+
if (extracted.confirmationData.metadata?.signature) {
|
|
351
|
+
signaturePresentForAudit = true;
|
|
352
|
+
signatureKeyIdForAudit = extracted.confirmationData.metadata.signature.keyId;
|
|
353
|
+
}
|
|
354
|
+
} catch {
|
|
355
|
+
// If we can't parse the file, keep undefined/default values
|
|
329
356
|
}
|
|
330
|
-
} catch {
|
|
331
|
-
// If we can't parse the file, keep undefined/default values
|
|
332
357
|
}
|
|
333
|
-
|
|
358
|
+
|
|
334
359
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
|
335
360
|
if (errorMessage.includes('hash validation failed')) {
|
|
336
361
|
// Hash failed - only flag file integrity, don't affect other validations
|
|
@@ -352,7 +377,7 @@ export async function importConfirmationData(
|
|
|
352
377
|
await auditService.logConfirmationImport(
|
|
353
378
|
user,
|
|
354
379
|
result.caseNumber || 'unknown',
|
|
355
|
-
|
|
380
|
+
confirmationJsonFileNameForAudit,
|
|
356
381
|
'failure',
|
|
357
382
|
hashValidForAudit,
|
|
358
383
|
0, // No confirmations successfully imported for failures
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { type ConfirmationImportData } from '~/types';
|
|
2
|
+
|
|
3
|
+
const CONFIRMATION_EXPORT_FILE_REGEX = /^confirmation-data-.*\.json$/i;
|
|
4
|
+
|
|
5
|
+
export interface ConfirmationImportPackage {
|
|
6
|
+
confirmationData: ConfirmationImportData;
|
|
7
|
+
confirmationJsonContent: string;
|
|
8
|
+
verificationPublicKeyPem?: string;
|
|
9
|
+
confirmationFileName: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function getLeafFileName(path: string): string {
|
|
13
|
+
const segments = path.split('/').filter(Boolean);
|
|
14
|
+
return segments.length > 0 ? segments[segments.length - 1] : path;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function selectPreferredPemPath(pemPaths: string[]): string | undefined {
|
|
18
|
+
if (pemPaths.length === 0) {
|
|
19
|
+
return undefined;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const sortedPaths = [...pemPaths].sort((left, right) => left.localeCompare(right));
|
|
23
|
+
const preferred = sortedPaths.find((path) =>
|
|
24
|
+
/^striae-public-signing-key.*\.pem$/i.test(getLeafFileName(path))
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
return preferred ?? sortedPaths[0];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function extractConfirmationPackageFromZip(file: File): Promise<ConfirmationImportPackage> {
|
|
31
|
+
const JSZip = (await import('jszip')).default;
|
|
32
|
+
const zip = await JSZip.loadAsync(file);
|
|
33
|
+
const fileEntries = Object.keys(zip.files).filter((path) => !zip.files[path].dir);
|
|
34
|
+
|
|
35
|
+
const confirmationPaths = fileEntries.filter((path) =>
|
|
36
|
+
CONFIRMATION_EXPORT_FILE_REGEX.test(getLeafFileName(path))
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
if (confirmationPaths.length !== 1) {
|
|
40
|
+
throw new Error('Confirmation ZIP must contain exactly one confirmation-data JSON file.');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const confirmationPath = confirmationPaths[0];
|
|
44
|
+
const confirmationJsonContent = await zip.file(confirmationPath)?.async('text');
|
|
45
|
+
if (!confirmationJsonContent) {
|
|
46
|
+
throw new Error('Failed to read confirmation JSON from ZIP package.');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const confirmationData = JSON.parse(confirmationJsonContent) as ConfirmationImportData;
|
|
50
|
+
|
|
51
|
+
const pemPaths = fileEntries.filter((path) => getLeafFileName(path).toLowerCase().endsWith('.pem'));
|
|
52
|
+
const preferredPemPath = selectPreferredPemPath(pemPaths);
|
|
53
|
+
|
|
54
|
+
let verificationPublicKeyPem: string | undefined;
|
|
55
|
+
if (preferredPemPath) {
|
|
56
|
+
verificationPublicKeyPem = await zip.file(preferredPemPath)?.async('text');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
confirmationData,
|
|
61
|
+
confirmationJsonContent,
|
|
62
|
+
verificationPublicKeyPem,
|
|
63
|
+
confirmationFileName: getLeafFileName(confirmationPath)
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function extractConfirmationImportPackage(file: File): Promise<ConfirmationImportPackage> {
|
|
68
|
+
const lowerName = file.name.toLowerCase();
|
|
69
|
+
|
|
70
|
+
if (lowerName.endsWith('.json')) {
|
|
71
|
+
const confirmationJsonContent = await file.text();
|
|
72
|
+
const confirmationData = JSON.parse(confirmationJsonContent) as ConfirmationImportData;
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
confirmationData,
|
|
76
|
+
confirmationJsonContent,
|
|
77
|
+
confirmationFileName: file.name
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (lowerName.endsWith('.zip')) {
|
|
82
|
+
return extractConfirmationPackageFromZip(file);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
throw new Error('Unsupported confirmation import file type. Use a confirmation JSON or confirmation ZIP file.');
|
|
86
|
+
}
|
|
@@ -1,61 +1,32 @@
|
|
|
1
|
-
import
|
|
2
|
-
import {
|
|
3
|
-
import { type FileData
|
|
4
|
-
|
|
5
|
-
const IMAGE_WORKER_URL = paths.image_worker_url;
|
|
1
|
+
import type { User } from 'firebase/auth';
|
|
2
|
+
import { uploadImageApi } from '~/utils/image-api-client';
|
|
3
|
+
import { type FileData } from '~/types';
|
|
6
4
|
|
|
7
5
|
/**
|
|
8
6
|
* Upload image blob to image worker and get file data
|
|
9
7
|
*/
|
|
10
8
|
export async function uploadImageBlob(
|
|
9
|
+
user: User,
|
|
11
10
|
imageBlob: Blob,
|
|
12
11
|
originalFilename: string,
|
|
13
12
|
onProgress?: (filename: string, progress: number) => void
|
|
14
13
|
): Promise<FileData> {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
const file = new File([imageBlob], originalFilename, { type: imageBlob.type });
|
|
23
|
-
formData.append('file', file);
|
|
24
|
-
|
|
25
|
-
xhr.upload.addEventListener('progress', (event) => {
|
|
26
|
-
if (event.lengthComputable && onProgress) {
|
|
27
|
-
const progress = Math.round((event.loaded / event.total) * 100);
|
|
28
|
-
onProgress(originalFilename, progress);
|
|
29
|
-
}
|
|
30
|
-
});
|
|
31
|
-
|
|
32
|
-
xhr.addEventListener('load', async () => {
|
|
33
|
-
if (xhr.status === 200) {
|
|
34
|
-
try {
|
|
35
|
-
const imageData = JSON.parse(xhr.responseText) as ImageUploadResponse;
|
|
36
|
-
if (!imageData.success) {
|
|
37
|
-
throw new Error(`Upload failed: ${imageData.errors?.join(', ') || 'Unknown error'}`);
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
const fileData: FileData = {
|
|
41
|
-
id: imageData.result.id,
|
|
42
|
-
originalFilename: originalFilename,
|
|
43
|
-
uploadedAt: new Date().toISOString()
|
|
44
|
-
};
|
|
45
|
-
|
|
46
|
-
resolve(fileData);
|
|
47
|
-
} catch (error) {
|
|
48
|
-
reject(error);
|
|
49
|
-
}
|
|
50
|
-
} else {
|
|
51
|
-
reject(new Error(`Upload failed with status ${xhr.status}`));
|
|
52
|
-
}
|
|
53
|
-
});
|
|
14
|
+
// Create a File object from the blob to preserve the filename
|
|
15
|
+
const file = new File([imageBlob], originalFilename, { type: imageBlob.type });
|
|
16
|
+
const imageData = await uploadImageApi(user, file, (progress) => {
|
|
17
|
+
if (onProgress) {
|
|
18
|
+
onProgress(originalFilename, progress);
|
|
19
|
+
}
|
|
20
|
+
});
|
|
54
21
|
|
|
55
|
-
|
|
22
|
+
const uploadedImageId = imageData.result?.id;
|
|
23
|
+
if (!uploadedImageId) {
|
|
24
|
+
throw new Error('Upload failed: missing image identifier');
|
|
25
|
+
}
|
|
56
26
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
27
|
+
return {
|
|
28
|
+
id: uploadedImageId,
|
|
29
|
+
originalFilename,
|
|
30
|
+
uploadedAt: new Date().toISOString()
|
|
31
|
+
};
|
|
61
32
|
}
|
|
@@ -34,6 +34,7 @@ export { importAnnotations } from './annotation-import';
|
|
|
34
34
|
|
|
35
35
|
// Confirmation import
|
|
36
36
|
export { importConfirmationData } from './confirmation-import';
|
|
37
|
+
export { extractConfirmationImportPackage } from './confirmation-package';
|
|
37
38
|
|
|
38
39
|
// Main orchestrator
|
|
39
40
|
export { importCaseForReview } from './orchestrator';
|
|
@@ -137,7 +137,14 @@ export async function importCaseForReview(
|
|
|
137
137
|
onProgress?.('Parsing ZIP file', 10, 'Extracting archive contents...');
|
|
138
138
|
|
|
139
139
|
// Step 1: Parse ZIP file
|
|
140
|
-
const {
|
|
140
|
+
const {
|
|
141
|
+
caseData,
|
|
142
|
+
imageFiles,
|
|
143
|
+
imageIdMapping,
|
|
144
|
+
metadata,
|
|
145
|
+
cleanedContent,
|
|
146
|
+
verificationPublicKeyPem
|
|
147
|
+
} = await parseImportZip(zipFile, user);
|
|
141
148
|
parsedForensicManifest = metadata?.forensicManifest as SignedForensicManifest | undefined;
|
|
142
149
|
result.caseNumber = caseData.metadata.caseNumber;
|
|
143
150
|
importState.caseNumber = result.caseNumber;
|
|
@@ -181,7 +188,10 @@ export async function importCaseForReview(
|
|
|
181
188
|
);
|
|
182
189
|
}
|
|
183
190
|
|
|
184
|
-
const signatureResult = await verifyForensicManifestSignature(
|
|
191
|
+
const signatureResult = await verifyForensicManifestSignature(
|
|
192
|
+
parsedForensicManifest,
|
|
193
|
+
verificationPublicKeyPem
|
|
194
|
+
);
|
|
185
195
|
signatureValidationPassed = signatureResult.isValid;
|
|
186
196
|
signatureKeyId = signatureResult.keyId;
|
|
187
197
|
|
|
@@ -273,7 +283,7 @@ export async function importCaseForReview(
|
|
|
273
283
|
const originalFileEntry = caseData.files.find(f => f.fileData.id === originalImageId);
|
|
274
284
|
const originalFilename = originalFileEntry?.fileData.originalFilename || exportFilename;
|
|
275
285
|
|
|
276
|
-
const fileData = await uploadImageBlob(blob, originalFilename, (fname, progress) => {
|
|
286
|
+
const fileData = await uploadImageBlob(user, blob, originalFilename, (fname, progress) => {
|
|
277
287
|
const overallProgress = 30 + (uploadedCount / totalImages) * 40 + (progress / totalImages) * 0.4;
|
|
278
288
|
onProgress?.('Uploading images', overallProgress, `Uploading ${fname}...`);
|
|
279
289
|
});
|