@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
|
@@ -1,9 +1,5 @@
|
|
|
1
1
|
import type { User } from 'firebase/auth';
|
|
2
|
-
import
|
|
3
|
-
import {
|
|
4
|
-
getDataApiKey,
|
|
5
|
-
getUserApiKey
|
|
6
|
-
} from '~/utils/auth';
|
|
2
|
+
import { fetchDataApi } from '~/utils/data-api-client';
|
|
7
3
|
import {
|
|
8
4
|
getUserReadOnlyCases,
|
|
9
5
|
updateUserData,
|
|
@@ -11,7 +7,6 @@ import {
|
|
|
11
7
|
} from '~/utils/permissions';
|
|
12
8
|
import {
|
|
13
9
|
type CaseExportData,
|
|
14
|
-
type ExtendedUserData,
|
|
15
10
|
type FileData,
|
|
16
11
|
type CaseData,
|
|
17
12
|
type ReadOnlyCaseMetadata
|
|
@@ -19,9 +14,6 @@ import {
|
|
|
19
14
|
import { deleteFile } from '../image-manage';
|
|
20
15
|
import { type SignedForensicManifest } from '~/utils/SHA256';
|
|
21
16
|
|
|
22
|
-
const USER_WORKER_URL = paths.user_worker_url;
|
|
23
|
-
const DATA_WORKER_URL = paths.data_worker_url;
|
|
24
|
-
|
|
25
17
|
/**
|
|
26
18
|
* Check if user already has a read-only case with the same number
|
|
27
19
|
*/
|
|
@@ -91,8 +83,6 @@ export async function storeCaseDataInR2(
|
|
|
91
83
|
forensicManifest?: SignedForensicManifest
|
|
92
84
|
): Promise<void> {
|
|
93
85
|
try {
|
|
94
|
-
const apiKey = await getDataApiKey();
|
|
95
|
-
|
|
96
86
|
// Convert the mapping to a plain object for JSON serialization
|
|
97
87
|
const originalImageIds = originalImageIdMapping ?
|
|
98
88
|
Object.fromEntries(originalImageIdMapping) : undefined;
|
|
@@ -122,14 +112,17 @@ export async function storeCaseDataInR2(
|
|
|
122
112
|
};
|
|
123
113
|
|
|
124
114
|
// Store in R2
|
|
125
|
-
const response = await
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
'
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
115
|
+
const response = await fetchDataApi(
|
|
116
|
+
user,
|
|
117
|
+
`/${encodeURIComponent(user.uid)}/${encodeURIComponent(caseNumber)}/data.json`,
|
|
118
|
+
{
|
|
119
|
+
method: 'PUT',
|
|
120
|
+
headers: {
|
|
121
|
+
'Content-Type': 'application/json'
|
|
122
|
+
},
|
|
123
|
+
body: JSON.stringify(r2CaseData)
|
|
124
|
+
}
|
|
125
|
+
);
|
|
133
126
|
|
|
134
127
|
if (!response.ok) {
|
|
135
128
|
throw new Error(`Failed to store case data: ${response.status}`);
|
|
@@ -146,24 +139,7 @@ export async function storeCaseDataInR2(
|
|
|
146
139
|
*/
|
|
147
140
|
export async function listReadOnlyCases(user: User): Promise<ReadOnlyCaseMetadata[]> {
|
|
148
141
|
try {
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
const response = await fetch(`${USER_WORKER_URL}/${encodeURIComponent(user.uid)}`, {
|
|
152
|
-
method: 'GET',
|
|
153
|
-
headers: {
|
|
154
|
-
'Content-Type': 'application/json',
|
|
155
|
-
'X-Custom-Auth-Key': apiKey
|
|
156
|
-
}
|
|
157
|
-
});
|
|
158
|
-
|
|
159
|
-
if (!response.ok) {
|
|
160
|
-
console.error('Failed to fetch user data:', response.status);
|
|
161
|
-
return [];
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
const userData: ExtendedUserData = await response.json();
|
|
165
|
-
|
|
166
|
-
return userData.readOnlyCases || [];
|
|
142
|
+
return await getUserReadOnlyCases(user);
|
|
167
143
|
|
|
168
144
|
} catch (error) {
|
|
169
145
|
console.error('Error listing read-only cases:', error);
|
|
@@ -176,48 +152,20 @@ export async function listReadOnlyCases(user: User): Promise<ReadOnlyCaseMetadat
|
|
|
176
152
|
*/
|
|
177
153
|
export async function removeReadOnlyCase(user: User, caseNumber: string): Promise<boolean> {
|
|
178
154
|
try {
|
|
179
|
-
const
|
|
180
|
-
|
|
181
|
-
// Get current user data
|
|
182
|
-
const response = await fetch(`${USER_WORKER_URL}/${encodeURIComponent(user.uid)}`, {
|
|
183
|
-
method: 'GET',
|
|
184
|
-
headers: {
|
|
185
|
-
'Content-Type': 'application/json',
|
|
186
|
-
'X-Custom-Auth-Key': apiKey
|
|
187
|
-
}
|
|
188
|
-
});
|
|
189
|
-
|
|
190
|
-
if (!response.ok) {
|
|
191
|
-
throw new Error(`Failed to fetch user data: ${response.status}`);
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
const userData: ExtendedUserData = await response.json();
|
|
195
|
-
|
|
196
|
-
if (!userData.readOnlyCases) {
|
|
155
|
+
const currentReadOnlyCases = await getUserReadOnlyCases(user);
|
|
156
|
+
if (!currentReadOnlyCases.length) {
|
|
197
157
|
return false; // Nothing to remove
|
|
198
158
|
}
|
|
199
159
|
|
|
200
160
|
// Remove the case from the list
|
|
201
|
-
const
|
|
202
|
-
userData.readOnlyCases = userData.readOnlyCases.filter(c => c.caseNumber !== caseNumber);
|
|
161
|
+
const nextReadOnlyCases = currentReadOnlyCases.filter(c => c.caseNumber !== caseNumber);
|
|
203
162
|
|
|
204
|
-
if (
|
|
163
|
+
if (nextReadOnlyCases.length === currentReadOnlyCases.length) {
|
|
205
164
|
return false; // Case wasn't found
|
|
206
165
|
}
|
|
207
166
|
|
|
208
167
|
// Update user data
|
|
209
|
-
|
|
210
|
-
method: 'PUT',
|
|
211
|
-
headers: {
|
|
212
|
-
'Content-Type': 'application/json',
|
|
213
|
-
'X-Custom-Auth-Key': apiKey
|
|
214
|
-
},
|
|
215
|
-
body: JSON.stringify(userData)
|
|
216
|
-
});
|
|
217
|
-
|
|
218
|
-
if (!updateResponse.ok) {
|
|
219
|
-
throw new Error(`Failed to update user data: ${updateResponse.status}`);
|
|
220
|
-
}
|
|
168
|
+
await updateUserData(user, { readOnlyCases: nextReadOnlyCases });
|
|
221
169
|
|
|
222
170
|
return true;
|
|
223
171
|
|
|
@@ -232,30 +180,47 @@ export async function removeReadOnlyCase(user: User, caseNumber: string): Promis
|
|
|
232
180
|
*/
|
|
233
181
|
export async function deleteReadOnlyCase(user: User, caseNumber: string): Promise<boolean> {
|
|
234
182
|
try {
|
|
235
|
-
const dataApiKey = await getDataApiKey();
|
|
236
|
-
|
|
237
183
|
// Get case data first to get file IDs for deletion
|
|
238
|
-
const caseResponse = await
|
|
239
|
-
|
|
240
|
-
|
|
184
|
+
const caseResponse = await fetchDataApi(
|
|
185
|
+
user,
|
|
186
|
+
`/${encodeURIComponent(user.uid)}/${encodeURIComponent(caseNumber)}/data.json`,
|
|
187
|
+
{
|
|
188
|
+
method: 'GET'
|
|
189
|
+
}
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
if (caseResponse.status === 404) {
|
|
193
|
+
// No backing data object exists; only remove the case reference from user metadata.
|
|
194
|
+
await removeReadOnlyCase(user, caseNumber);
|
|
195
|
+
return true;
|
|
196
|
+
}
|
|
241
197
|
|
|
242
|
-
if (caseResponse.ok) {
|
|
243
|
-
|
|
198
|
+
if (!caseResponse.ok) {
|
|
199
|
+
throw new Error(`Failed to fetch read-only case data for deletion: ${caseResponse.status}`);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const caseData = await caseResponse.json() as CaseData;
|
|
203
|
+
|
|
204
|
+
// Delete all files using data worker
|
|
205
|
+
if (caseData.files && caseData.files.length > 0) {
|
|
206
|
+
await Promise.all(
|
|
207
|
+
caseData.files.map((file: FileData) =>
|
|
208
|
+
deleteFile(user, caseNumber, file.id, 'Read-only case clearing - API operation')
|
|
209
|
+
)
|
|
210
|
+
);
|
|
211
|
+
}
|
|
244
212
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
);
|
|
213
|
+
// Delete case file using data worker
|
|
214
|
+
const deleteCaseResponse = await fetchDataApi(
|
|
215
|
+
user,
|
|
216
|
+
`/${encodeURIComponent(user.uid)}/${encodeURIComponent(caseNumber)}/data.json`,
|
|
217
|
+
{
|
|
218
|
+
method: 'DELETE'
|
|
252
219
|
}
|
|
220
|
+
);
|
|
253
221
|
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
method: 'DELETE',
|
|
257
|
-
headers: { 'X-Custom-Auth-Key': dataApiKey }
|
|
258
|
-
});
|
|
222
|
+
if (!deleteCaseResponse.ok && deleteCaseResponse.status !== 404) {
|
|
223
|
+
throw new Error(`Failed to delete read-only case data: ${deleteCaseResponse.status}`);
|
|
259
224
|
}
|
|
260
225
|
|
|
261
226
|
// Remove from user's read-only case list (separate from regular cases)
|
|
@@ -1,82 +1,16 @@
|
|
|
1
1
|
import type { User } from 'firebase/auth';
|
|
2
|
-
import
|
|
3
|
-
import { getUserApiKey } from '~/utils/auth';
|
|
2
|
+
import { checkUserExistsApi } from '~/utils/user-api-client';
|
|
4
3
|
import { type CaseExportData, type ConfirmationImportData } from '~/types';
|
|
5
|
-
import {
|
|
4
|
+
import { type ManifestSignatureVerificationResult } from '~/utils/SHA256';
|
|
6
5
|
import { verifyConfirmationSignature } from '~/utils/confirmation-signature';
|
|
7
|
-
|
|
8
|
-
const USER_WORKER_URL = paths.user_worker_url;
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
* Remove forensic warning from content for hash validation (supports both JSON and CSV formats)
|
|
12
|
-
* This function ensures exact match with the content used during export hash generation
|
|
13
|
-
*/
|
|
14
|
-
export function removeForensicWarning(content: string): string {
|
|
15
|
-
// Handle JSON forensic warnings (block comment format)
|
|
16
|
-
// /* CASE DATA WARNING
|
|
17
|
-
// * This file contains evidence data for forensic examination.
|
|
18
|
-
// * Any modification may compromise the integrity of the evidence.
|
|
19
|
-
// * Handle according to your organization's chain of custody procedures.
|
|
20
|
-
// *
|
|
21
|
-
// * File generated: YYYY-MM-DDTHH:mm:ss.sssZ
|
|
22
|
-
// */
|
|
23
|
-
const jsonForensicWarningRegex = /^\/\*\s*CASE\s+DATA\s+WARNING[\s\S]*?\*\/\s*\r?\n*/;
|
|
24
|
-
|
|
25
|
-
// Handle CSV forensic warnings (quoted string format at the beginning of file)
|
|
26
|
-
// CRITICAL: The CSV forensic warning is ONLY the first quoted line, followed by two newlines
|
|
27
|
-
// Format: "CASE DATA WARNING: This file contains evidence data for forensic examination. Any modification may compromise the integrity of the evidence. Handle according to your organization's chain of custody procedures."\n\n
|
|
28
|
-
//
|
|
29
|
-
// After removal, what remains should be the csvWithHash content:
|
|
30
|
-
// # Striae Case Export - Generated: ...
|
|
31
|
-
// # Case: ...
|
|
32
|
-
// # Total Files: ...
|
|
33
|
-
// # SHA256 Hash: ...
|
|
34
|
-
// # Verification: ...
|
|
35
|
-
//
|
|
36
|
-
// [actual CSV data]
|
|
37
|
-
// More robust regex to handle various line endings and exact format from generation
|
|
38
|
-
const csvForensicWarningRegex = /^"CASE DATA WARNING: This file contains evidence data for forensic examination\. Any modification may compromise the integrity of the evidence\. Handle according to your organization's chain of custody procedures\."(?:\r?\n){2}/;
|
|
39
|
-
|
|
40
|
-
let cleaned = content;
|
|
41
|
-
|
|
42
|
-
// Try JSON format first
|
|
43
|
-
if (jsonForensicWarningRegex.test(content)) {
|
|
44
|
-
cleaned = content.replace(jsonForensicWarningRegex, '');
|
|
45
|
-
}
|
|
46
|
-
// Try CSV format with exact pattern match
|
|
47
|
-
else if (csvForensicWarningRegex.test(content)) {
|
|
48
|
-
cleaned = content.replace(csvForensicWarningRegex, '');
|
|
49
|
-
}
|
|
50
|
-
// Fallback: try broader CSV pattern in case of slight format differences
|
|
51
|
-
else if (content.startsWith('"CASE DATA WARNING:')) {
|
|
52
|
-
// Find the end of the first quoted string followed by newlines
|
|
53
|
-
const match = content.match(/^"[^"]*"(?:\r?\n)+/);
|
|
54
|
-
if (match) {
|
|
55
|
-
cleaned = content.substring(match[0].length);
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
// Additional cleanup: remove any leading whitespace that might remain
|
|
60
|
-
// This ensures we match exactly what the generation functions produce with protectForensicData: false
|
|
61
|
-
cleaned = cleaned.replace(/^\s+/, '');
|
|
62
|
-
|
|
63
|
-
return cleaned;
|
|
64
|
-
}
|
|
6
|
+
export { removeForensicWarning, validateConfirmationHash } from '~/utils/export-verification';
|
|
65
7
|
|
|
66
8
|
/**
|
|
67
9
|
* Validate that a user exists in the database by UID and is not the current user
|
|
68
10
|
*/
|
|
69
11
|
export async function validateExporterUid(exporterUid: string, currentUser: User): Promise<{ exists: boolean; isSelf: boolean }> {
|
|
70
12
|
try {
|
|
71
|
-
const
|
|
72
|
-
const response = await fetch(`${USER_WORKER_URL}/${exporterUid}`, {
|
|
73
|
-
method: 'GET',
|
|
74
|
-
headers: {
|
|
75
|
-
'X-Custom-Auth-Key': apiKey
|
|
76
|
-
}
|
|
77
|
-
});
|
|
78
|
-
|
|
79
|
-
const exists = response.status === 200;
|
|
13
|
+
const exists = await checkUserExistsApi(currentUser, exporterUid);
|
|
80
14
|
const isSelf = exporterUid === currentUser.uid;
|
|
81
15
|
|
|
82
16
|
return { exists, isSelf };
|
|
@@ -93,45 +27,6 @@ export function isConfirmationDataFile(filename: string): boolean {
|
|
|
93
27
|
return filename.startsWith('confirmation-data') && filename.endsWith('.json');
|
|
94
28
|
}
|
|
95
29
|
|
|
96
|
-
/**
|
|
97
|
-
* Validate confirmation data file hash
|
|
98
|
-
*/
|
|
99
|
-
export async function validateConfirmationHash(jsonContent: string, expectedHash: string): Promise<boolean> {
|
|
100
|
-
try {
|
|
101
|
-
// Validate input parameters
|
|
102
|
-
if (!expectedHash || typeof expectedHash !== 'string') {
|
|
103
|
-
console.error('validateConfirmationHash: expected hash input is invalid');
|
|
104
|
-
return false;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
// Create data without hash for validation
|
|
108
|
-
const data = JSON.parse(jsonContent);
|
|
109
|
-
const dataWithoutHash = {
|
|
110
|
-
...data,
|
|
111
|
-
metadata: {
|
|
112
|
-
...data.metadata,
|
|
113
|
-
hash: undefined
|
|
114
|
-
}
|
|
115
|
-
};
|
|
116
|
-
delete dataWithoutHash.metadata.hash;
|
|
117
|
-
delete dataWithoutHash.metadata.signature;
|
|
118
|
-
delete dataWithoutHash.metadata.signatureVersion;
|
|
119
|
-
|
|
120
|
-
const contentForHash = JSON.stringify(dataWithoutHash, null, 2);
|
|
121
|
-
const actualHash = await calculateSHA256Secure(contentForHash);
|
|
122
|
-
|
|
123
|
-
if (!actualHash) {
|
|
124
|
-
console.error('validateConfirmationHash: failed to calculate hash');
|
|
125
|
-
return false;
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
return actualHash.toUpperCase() === expectedHash.toUpperCase();
|
|
129
|
-
} catch {
|
|
130
|
-
console.error('validateConfirmationHash: validation failed');
|
|
131
|
-
return false;
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
|
|
135
30
|
/**
|
|
136
31
|
* Validate imported case data integrity (optional verification)
|
|
137
32
|
*/
|
|
@@ -183,7 +78,8 @@ export function validateCaseIntegrity(
|
|
|
183
78
|
* Validate confirmation data file signature.
|
|
184
79
|
*/
|
|
185
80
|
export async function validateConfirmationSignatureFile(
|
|
186
|
-
confirmationData: Partial<ConfirmationImportData
|
|
81
|
+
confirmationData: Partial<ConfirmationImportData>,
|
|
82
|
+
verificationPublicKeyPem?: string
|
|
187
83
|
): Promise<ManifestSignatureVerificationResult> {
|
|
188
|
-
return verifyConfirmationSignature(confirmationData);
|
|
84
|
+
return verifyConfirmationSignature(confirmationData, verificationPublicKeyPem);
|
|
189
85
|
}
|
|
@@ -9,6 +9,41 @@ import {
|
|
|
9
9
|
} from '~/utils/SHA256';
|
|
10
10
|
import { validateExporterUid, removeForensicWarning } from './validation';
|
|
11
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 extractVerificationPublicKeyFromZip(
|
|
31
|
+
zip: {
|
|
32
|
+
files: Record<string, { dir: boolean }>;
|
|
33
|
+
file: (path: string) => { async: (type: 'text') => Promise<string> } | null;
|
|
34
|
+
}
|
|
35
|
+
): Promise<string | undefined> {
|
|
36
|
+
const filePaths = Object.keys(zip.files).filter((path) => !zip.files[path].dir);
|
|
37
|
+
const pemPaths = filePaths.filter((path) => getLeafFileName(path).toLowerCase().endsWith('.pem'));
|
|
38
|
+
const preferredPemPath = selectPreferredPemPath(pemPaths);
|
|
39
|
+
|
|
40
|
+
if (!preferredPemPath) {
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return zip.file(preferredPemPath)?.async('text');
|
|
45
|
+
}
|
|
46
|
+
|
|
12
47
|
/**
|
|
13
48
|
* Extract original image ID from export filename format
|
|
14
49
|
* Format: {originalFilename}-{id}.{extension}
|
|
@@ -51,6 +86,7 @@ export async function previewCaseImport(zipFile: File, currentUser: User): Promi
|
|
|
51
86
|
|
|
52
87
|
try {
|
|
53
88
|
const zip = await JSZip.loadAsync(zipFile);
|
|
89
|
+
const verificationPublicKeyPem = await extractVerificationPublicKeyFromZip(zip);
|
|
54
90
|
|
|
55
91
|
// First, validate hash if forensic metadata exists
|
|
56
92
|
let hashValid: boolean | undefined = undefined;
|
|
@@ -128,7 +164,10 @@ export async function previewCaseImport(zipFile: File, currentUser: User): Promi
|
|
|
128
164
|
}));
|
|
129
165
|
}
|
|
130
166
|
|
|
131
|
-
const signatureResult = await verifyForensicManifestSignature(
|
|
167
|
+
const signatureResult = await verifyForensicManifestSignature(
|
|
168
|
+
forensicManifest,
|
|
169
|
+
verificationPublicKeyPem
|
|
170
|
+
);
|
|
132
171
|
|
|
133
172
|
// Perform comprehensive validation
|
|
134
173
|
const validation = await validateForensicIntegrity(
|
|
@@ -267,12 +306,14 @@ export async function parseImportZip(zipFile: File, currentUser: User): Promise<
|
|
|
267
306
|
imageIdMapping: { [exportFilename: string]: string }; // exportFilename -> originalImageId
|
|
268
307
|
metadata?: Record<string, unknown>;
|
|
269
308
|
cleanedContent?: string; // Add cleaned content for hash validation
|
|
309
|
+
verificationPublicKeyPem?: string;
|
|
270
310
|
}> {
|
|
271
311
|
// Dynamic import of JSZip to avoid bundle size issues
|
|
272
312
|
const JSZip = (await import('jszip')).default;
|
|
273
313
|
|
|
274
314
|
try {
|
|
275
315
|
const zip = await JSZip.loadAsync(zipFile);
|
|
316
|
+
const verificationPublicKeyPem = await extractVerificationPublicKeyFromZip(zip);
|
|
276
317
|
|
|
277
318
|
// Find the main data file (JSON or CSV)
|
|
278
319
|
const dataFiles = Object.keys(zip.files).filter(name =>
|
|
@@ -367,7 +408,8 @@ export async function parseImportZip(zipFile: File, currentUser: User): Promise<
|
|
|
367
408
|
imageFiles,
|
|
368
409
|
imageIdMapping,
|
|
369
410
|
metadata,
|
|
370
|
-
cleanedContent
|
|
411
|
+
cleanedContent,
|
|
412
|
+
verificationPublicKeyPem
|
|
371
413
|
};
|
|
372
414
|
|
|
373
415
|
} catch (error) {
|
|
@@ -15,8 +15,7 @@ import {
|
|
|
15
15
|
} from '~/utils/data-operations';
|
|
16
16
|
import { type CaseData, type ReadOnlyCaseData, type FileData } from '~/types';
|
|
17
17
|
import { auditService } from '~/services/audit';
|
|
18
|
-
import {
|
|
19
|
-
import paths from '~/config/config.json';
|
|
18
|
+
import { fetchImageApi } from '~/utils/image-api-client';
|
|
20
19
|
|
|
21
20
|
/**
|
|
22
21
|
* Delete a file without individual audit logging (for bulk operations)
|
|
@@ -34,34 +33,17 @@ const deleteFileWithoutAudit = async (user: User, caseNumber: string, fileId: st
|
|
|
34
33
|
throw new Error('File not found in case');
|
|
35
34
|
}
|
|
36
35
|
|
|
37
|
-
// Delete
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
const imagesApiToken = await getImageApiKey();
|
|
42
|
-
const imageResponse = await fetch(`${IMAGE_URL}/${fileId}`, {
|
|
43
|
-
method: 'DELETE',
|
|
44
|
-
headers: {
|
|
45
|
-
'Authorization': `Bearer ${imagesApiToken}`
|
|
46
|
-
}
|
|
47
|
-
});
|
|
36
|
+
// Delete image file and fail fast on non-404 failures so case deletion can be retried safely
|
|
37
|
+
const imageResponse = await fetchImageApi(user, `/${encodeURIComponent(fileId)}`, {
|
|
38
|
+
method: 'DELETE'
|
|
39
|
+
});
|
|
48
40
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
throw new Error(`Failed to delete image: ${imageResponse.statusText}`);
|
|
52
|
-
}
|
|
53
|
-
} catch (error) {
|
|
54
|
-
console.warn(`Image deletion warning for ${fileToDelete.originalFilename}:`, error);
|
|
55
|
-
// Continue with data cleanup even if image deletion fails
|
|
41
|
+
if (!imageResponse.ok && imageResponse.status !== 404) {
|
|
42
|
+
throw new Error(`Failed to delete image: ${imageResponse.status} ${imageResponse.statusText}`);
|
|
56
43
|
}
|
|
57
44
|
|
|
58
|
-
// Delete annotation data
|
|
59
|
-
|
|
60
|
-
await deleteFileAnnotations(user, caseNumber, fileId);
|
|
61
|
-
} catch (error) {
|
|
62
|
-
// Annotation file might not exist, continue
|
|
63
|
-
console.warn(`Annotation deletion warning for ${fileToDelete.originalFilename}:`, error);
|
|
64
|
-
}
|
|
45
|
+
// Delete annotation data (404s are handled by deleteFileAnnotations)
|
|
46
|
+
await deleteFileAnnotations(user, caseNumber, fileId);
|
|
65
47
|
|
|
66
48
|
// Update case data to remove file reference
|
|
67
49
|
const updatedData: CaseData = {
|
|
@@ -466,6 +448,12 @@ export const deleteCase = async (user: User, caseNumber: string): Promise<void>
|
|
|
466
448
|
} catch (auditError) {
|
|
467
449
|
console.error('⚠️ Failed to log batch file deletion (continuing with case deletion):', auditError);
|
|
468
450
|
}
|
|
451
|
+
|
|
452
|
+
if (failedFiles.length > 0) {
|
|
453
|
+
throw new Error(
|
|
454
|
+
`Case deletion aborted: failed to delete ${failedFiles.length} file(s): ${failedFiles.map(f => f.originalFilename).join(', ')}`
|
|
455
|
+
);
|
|
456
|
+
}
|
|
469
457
|
}
|
|
470
458
|
|
|
471
459
|
// Remove case from user data first (so user loses access immediately)
|
|
@@ -3,6 +3,11 @@ import { calculateSHA256Secure } from '~/utils/SHA256';
|
|
|
3
3
|
import { getUserData } from '~/utils/permissions';
|
|
4
4
|
import { getCaseData, updateCaseData, signConfirmationData } from '~/utils/data-operations';
|
|
5
5
|
import { type ConfirmationData, type CaseConfirmations, type CaseDataWithConfirmations, type ConfirmationImportData } from '~/types';
|
|
6
|
+
import {
|
|
7
|
+
createPublicSigningKeyFileName,
|
|
8
|
+
getCurrentPublicSigningKeyDetails,
|
|
9
|
+
getVerificationPublicKey
|
|
10
|
+
} from '~/utils/signature-utils';
|
|
6
11
|
import { auditService } from '~/services/audit';
|
|
7
12
|
|
|
8
13
|
/**
|
|
@@ -267,15 +272,8 @@ export async function exportConfirmationData(
|
|
|
267
272
|
}
|
|
268
273
|
};
|
|
269
274
|
|
|
270
|
-
// Convert final data to JSON blob
|
|
271
275
|
const finalJsonString = JSON.stringify(finalExportData, null, 2);
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
// Create download
|
|
275
|
-
const url = URL.createObjectURL(blob);
|
|
276
|
-
const a = document.createElement('a');
|
|
277
|
-
a.href = url;
|
|
278
|
-
|
|
276
|
+
|
|
279
277
|
// Use local timezone for filename timestamp
|
|
280
278
|
const now = new Date();
|
|
281
279
|
const year = now.getFullYear();
|
|
@@ -285,14 +283,47 @@ export async function exportConfirmationData(
|
|
|
285
283
|
const minutes = String(now.getMinutes()).padStart(2, '0');
|
|
286
284
|
const seconds = String(now.getSeconds()).padStart(2, '0');
|
|
287
285
|
const timestampString = `${year}${month}${day}-${hours}${minutes}${seconds}`;
|
|
286
|
+
|
|
287
|
+
const confirmationFileName = `confirmation-data-${caseNumber}-${timestampString}.json`;
|
|
288
|
+
|
|
289
|
+
const keyFromSignature = getVerificationPublicKey(signingResult.signature.keyId);
|
|
290
|
+
const currentKey = getCurrentPublicSigningKeyDetails();
|
|
291
|
+
const publicKeyPem = keyFromSignature ?? currentKey.publicKeyPem;
|
|
292
|
+
const publicKeyFileName = createPublicSigningKeyFileName(
|
|
293
|
+
keyFromSignature ? signingResult.signature.keyId : currentKey.keyId
|
|
294
|
+
);
|
|
295
|
+
|
|
296
|
+
if (!publicKeyPem || publicKeyPem.trim().length === 0) {
|
|
297
|
+
throw new Error('No public signing key is configured for confirmation export packaging.');
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const JSZip = (await import('jszip')).default;
|
|
301
|
+
const zip = new JSZip();
|
|
302
|
+
const normalizedPem = publicKeyPem.endsWith('\n') ? publicKeyPem : `${publicKeyPem}\n`;
|
|
303
|
+
|
|
304
|
+
zip.file(confirmationFileName, finalJsonString);
|
|
305
|
+
zip.file(publicKeyFileName, normalizedPem);
|
|
306
|
+
|
|
307
|
+
const zipBlob = await zip.generateAsync({
|
|
308
|
+
type: 'blob',
|
|
309
|
+
compression: 'DEFLATE',
|
|
310
|
+
compressionOptions: { level: 6 }
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
const exportFileName = `confirmation-export-${caseNumber}-${timestampString}.zip`;
|
|
314
|
+
|
|
315
|
+
// Create download
|
|
316
|
+
const url = URL.createObjectURL(zipBlob);
|
|
317
|
+
const a = document.createElement('a');
|
|
318
|
+
a.href = url;
|
|
288
319
|
|
|
289
|
-
a.download =
|
|
320
|
+
a.download = exportFileName;
|
|
290
321
|
document.body.appendChild(a);
|
|
291
322
|
a.click();
|
|
292
323
|
document.body.removeChild(a);
|
|
293
324
|
URL.revokeObjectURL(url);
|
|
294
325
|
|
|
295
|
-
console.log(`Confirmation
|
|
326
|
+
console.log(`Confirmation export ZIP generated for case ${caseNumber}`);
|
|
296
327
|
|
|
297
328
|
// Log successful confirmation export
|
|
298
329
|
const endTime = Date.now();
|
|
@@ -300,14 +331,14 @@ export async function exportConfirmationData(
|
|
|
300
331
|
await auditService.logConfirmationExport(
|
|
301
332
|
user,
|
|
302
333
|
caseNumber,
|
|
303
|
-
|
|
334
|
+
exportFileName,
|
|
304
335
|
confirmationCount,
|
|
305
336
|
'success',
|
|
306
337
|
[],
|
|
307
338
|
undefined, // Original examiner UID not available here
|
|
308
339
|
{
|
|
309
340
|
processingTimeMs: endTime - startTime,
|
|
310
|
-
fileSizeBytes:
|
|
341
|
+
fileSizeBytes: zipBlob.size,
|
|
311
342
|
validationStepsCompleted: confirmationCount,
|
|
312
343
|
validationStepsFailed: 0
|
|
313
344
|
},
|
|
@@ -328,7 +359,7 @@ export async function exportConfirmationData(
|
|
|
328
359
|
await auditService.logConfirmationExport(
|
|
329
360
|
user,
|
|
330
361
|
caseNumber,
|
|
331
|
-
`confirmation-
|
|
362
|
+
`confirmation-export-${caseNumber}-error.zip`,
|
|
332
363
|
0,
|
|
333
364
|
'failure',
|
|
334
365
|
[error instanceof Error ? error.message : 'Unknown error'],
|
|
@@ -1,8 +1,7 @@
|
|
|
1
|
-
import paths from '~/config/config.json';
|
|
2
1
|
import { type AnnotationData } from '~/types/annotations';
|
|
3
2
|
import { auditService } from '~/services/audit';
|
|
4
|
-
import { getPdfApiKey } from '~/utils/auth';
|
|
5
3
|
import type { User } from 'firebase/auth';
|
|
4
|
+
import { fetchPdfApi } from '~/utils/pdf-api-client';
|
|
6
5
|
|
|
7
6
|
interface GeneratePDFParams {
|
|
8
7
|
user: User;
|
|
@@ -75,13 +74,10 @@ export const generatePDF = async ({
|
|
|
75
74
|
data: pdfData,
|
|
76
75
|
};
|
|
77
76
|
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
const response = await fetch(paths.pdf_worker_url, {
|
|
77
|
+
const response = await fetchPdfApi(user, '/', {
|
|
81
78
|
method: 'POST',
|
|
82
79
|
headers: {
|
|
83
|
-
'Content-Type': 'application/json'
|
|
84
|
-
'X-Custom-Auth-Key': pdfAuthKey,
|
|
80
|
+
'Content-Type': 'application/json'
|
|
85
81
|
},
|
|
86
82
|
body: JSON.stringify(pdfRequest)
|
|
87
83
|
});
|