@striae-org/striae 4.3.4 → 5.1.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 +9 -2
- package/app/components/actions/case-export/download-handlers.ts +66 -11
- package/app/components/actions/case-import/confirmation-import.ts +50 -7
- package/app/components/actions/case-import/confirmation-package.ts +99 -22
- package/app/components/actions/case-import/orchestrator.ts +116 -13
- package/app/components/actions/case-import/validation.ts +171 -7
- package/app/components/actions/case-import/zip-processing.ts +224 -127
- package/app/components/actions/case-manage.ts +74 -15
- package/app/components/actions/confirm-export.ts +32 -3
- package/app/components/actions/generate-pdf.ts +43 -1
- package/app/components/actions/image-manage.ts +13 -45
- package/app/components/navbar/navbar.module.css +0 -10
- package/app/components/navbar/navbar.tsx +0 -22
- package/app/components/sidebar/case-import/case-import.module.css +7 -131
- package/app/components/sidebar/case-import/case-import.tsx +7 -14
- package/app/components/sidebar/case-import/components/CasePreviewSection.tsx +17 -60
- package/app/components/sidebar/case-import/components/ConfirmationDialog.tsx +23 -39
- package/app/components/sidebar/case-import/components/ConfirmationPreviewSection.tsx +5 -45
- package/app/components/sidebar/case-import/components/FileSelector.tsx +5 -6
- package/app/components/sidebar/case-import/hooks/useFilePreview.ts +2 -48
- package/app/components/sidebar/case-import/utils/file-validation.ts +9 -21
- package/app/config-example/config.json +5 -0
- package/app/routes/auth/login.tsx +1 -1
- package/app/routes/striae/hooks/use-striae-reset-helpers.ts +4 -0
- package/app/routes/striae/striae.tsx +15 -4
- package/app/utils/data/operations/case-operations.ts +13 -1
- package/app/utils/data/operations/confirmation-summary-operations.ts +38 -1
- package/app/utils/data/operations/file-annotation-operations.ts +13 -1
- package/app/utils/data/operations/signing-operations.ts +93 -0
- package/app/utils/data/operations/types.ts +6 -0
- package/app/utils/forensics/export-encryption.ts +316 -0
- package/app/utils/forensics/export-verification.ts +1 -409
- package/app/utils/forensics/index.ts +1 -0
- package/app/utils/ui/case-messages.ts +5 -2
- package/package.json +2 -2
- package/scripts/deploy-config.sh +244 -7
- package/scripts/deploy-pages-secrets.sh +0 -6
- package/scripts/deploy-worker-secrets.sh +66 -5
- package/scripts/encrypt-r2-backfill.mjs +376 -0
- package/worker-configuration.d.ts +13 -7
- package/workers/audit-worker/package.json +1 -4
- package/workers/audit-worker/src/audit-worker.example.ts +522 -61
- package/workers/audit-worker/wrangler.jsonc.example +6 -1
- package/workers/data-worker/package.json +1 -4
- package/workers/data-worker/src/data-worker.example.ts +409 -1
- package/workers/data-worker/src/encryption-utils.ts +269 -0
- package/workers/data-worker/worker-configuration.d.ts +1 -1
- package/workers/data-worker/wrangler.jsonc.example +6 -2
- package/workers/image-worker/package.json +1 -4
- package/workers/image-worker/src/encryption-utils.ts +217 -0
- package/workers/image-worker/src/image-worker.example.ts +196 -127
- package/workers/image-worker/wrangler.jsonc.example +8 -1
- package/workers/keys-worker/package.json +1 -4
- package/workers/keys-worker/wrangler.jsonc.example +1 -1
- package/workers/pdf-worker/package.json +1 -4
- package/workers/pdf-worker/wrangler.jsonc.example +1 -1
- package/workers/user-worker/package.json +1 -4
- package/workers/user-worker/wrangler.jsonc.example +1 -1
- package/wrangler.toml.example +1 -1
- package/app/components/public-signing-key-modal/public-signing-key-modal.module.css +0 -287
- package/app/components/public-signing-key-modal/public-signing-key-modal.tsx +0 -470
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
duplicateCaseData,
|
|
13
13
|
deleteFileAnnotations,
|
|
14
14
|
signForensicManifest,
|
|
15
|
+
moveCaseConfirmationSummary,
|
|
15
16
|
removeCaseConfirmationSummary
|
|
16
17
|
} from '~/utils/data';
|
|
17
18
|
import { type CaseData, type ReadOnlyCaseData, type FileData, type AuditTrail, type CaseExportData, type ValidationAuditEntry } from '~/types';
|
|
@@ -22,7 +23,9 @@ import { getImageUrl } from './image-manage';
|
|
|
22
23
|
import {
|
|
23
24
|
calculateSHA256Secure,
|
|
24
25
|
createPublicSigningKeyFileName,
|
|
26
|
+
encryptExportDataWithAllImages,
|
|
25
27
|
generateForensicManifestSecure,
|
|
28
|
+
getCurrentEncryptionPublicKeyDetails,
|
|
26
29
|
getCurrentPublicSigningKeyDetails,
|
|
27
30
|
getVerificationPublicKey,
|
|
28
31
|
} from '~/utils/forensics';
|
|
@@ -390,7 +393,10 @@ export const renameCase = async (
|
|
|
390
393
|
// 4) Delete R2 case data with old case number
|
|
391
394
|
await deleteCaseData(user, oldCaseNumber);
|
|
392
395
|
|
|
393
|
-
// 5)
|
|
396
|
+
// 5) Move confirmation summary metadata to the new case number
|
|
397
|
+
await moveCaseConfirmationSummary(user, oldCaseNumber, newCaseNumber);
|
|
398
|
+
|
|
399
|
+
// 6) Delete old case number in user's KV entry
|
|
394
400
|
await removeUserCase(user, oldCaseNumber);
|
|
395
401
|
|
|
396
402
|
// Log successful case rename under the original case number context
|
|
@@ -679,18 +685,13 @@ const getVerificationPublicSigningKey = (preferredKeyId?: string): { keyId: stri
|
|
|
679
685
|
|
|
680
686
|
const fetchImageAsBlob = async (user: User, fileData: FileData, caseNumber: string): Promise<Blob | null> => {
|
|
681
687
|
try {
|
|
682
|
-
const
|
|
683
|
-
|
|
684
|
-
if (!imageUrl) {
|
|
685
|
-
return null;
|
|
686
|
-
}
|
|
688
|
+
const { blob, revoke } = await getImageUrl(user, fileData, caseNumber, 'Archive Package');
|
|
687
689
|
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
690
|
+
try {
|
|
691
|
+
return blob;
|
|
692
|
+
} finally {
|
|
693
|
+
revoke();
|
|
691
694
|
}
|
|
692
|
-
|
|
693
|
-
return await response.blob();
|
|
694
695
|
} catch (error) {
|
|
695
696
|
console.error('Failed to fetch image for archive package:', error);
|
|
696
697
|
return null;
|
|
@@ -897,8 +898,64 @@ export const archiveCase = async (
|
|
|
897
898
|
auditTrail,
|
|
898
899
|
};
|
|
899
900
|
|
|
900
|
-
|
|
901
|
-
|
|
901
|
+
const auditTrailJson = JSON.stringify(signedAuditTrail, null, 2);
|
|
902
|
+
const auditSignatureJson = JSON.stringify(signedAuditExportPayload, null, 2);
|
|
903
|
+
zip.file('audit/case-audit-trail.json', auditTrailJson);
|
|
904
|
+
zip.file('audit/case-audit-signature.json', auditSignatureJson);
|
|
905
|
+
|
|
906
|
+
const encryptionKeyDetails = getCurrentEncryptionPublicKeyDetails();
|
|
907
|
+
|
|
908
|
+
if (!encryptionKeyDetails.publicKeyPem || !encryptionKeyDetails.keyId) {
|
|
909
|
+
throw new Error(
|
|
910
|
+
'Archive encryption is mandatory. Your Striae instance does not have a configured encryption public key. ' +
|
|
911
|
+
'Please contact your administrator to set up export encryption.'
|
|
912
|
+
);
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
try {
|
|
916
|
+
const filesToEncrypt: Array<{ filename: string; blob: Blob }> = [
|
|
917
|
+
...Object.entries(imageBlobs).map(([filename, blob]) => ({
|
|
918
|
+
filename,
|
|
919
|
+
blob
|
|
920
|
+
})),
|
|
921
|
+
{
|
|
922
|
+
filename: 'audit/case-audit-trail.json',
|
|
923
|
+
blob: new Blob([auditTrailJson], { type: 'application/json' })
|
|
924
|
+
},
|
|
925
|
+
{
|
|
926
|
+
filename: 'audit/case-audit-signature.json',
|
|
927
|
+
blob: new Blob([auditSignatureJson], { type: 'application/json' })
|
|
928
|
+
}
|
|
929
|
+
];
|
|
930
|
+
|
|
931
|
+
const encryptionResult = await encryptExportDataWithAllImages(
|
|
932
|
+
caseJsonContent,
|
|
933
|
+
filesToEncrypt,
|
|
934
|
+
encryptionKeyDetails.publicKeyPem,
|
|
935
|
+
encryptionKeyDetails.keyId
|
|
936
|
+
);
|
|
937
|
+
|
|
938
|
+
zip.file(`${caseNumber}_data.json`, encryptionResult.ciphertext);
|
|
939
|
+
|
|
940
|
+
for (let index = 0; index < filesToEncrypt.length; index += 1) {
|
|
941
|
+
const originalFilename = filesToEncrypt[index].filename;
|
|
942
|
+
const encryptedContent = encryptionResult.encryptedImages[index];
|
|
943
|
+
|
|
944
|
+
if (originalFilename.startsWith('audit/')) {
|
|
945
|
+
zip.file(originalFilename, encryptedContent);
|
|
946
|
+
continue;
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
if (imageFolder) {
|
|
950
|
+
imageFolder.file(originalFilename, encryptedContent);
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
zip.file('ENCRYPTION_MANIFEST.json', JSON.stringify(encryptionResult.encryptionManifest, null, 2));
|
|
955
|
+
} catch (error) {
|
|
956
|
+
console.error('Archive encryption failed:', error);
|
|
957
|
+
throw new Error(`Failed to encrypt archive package: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
958
|
+
}
|
|
902
959
|
|
|
903
960
|
zip.file(
|
|
904
961
|
'README.txt',
|
|
@@ -913,12 +970,14 @@ export const archiveCase = async (
|
|
|
913
970
|
'',
|
|
914
971
|
'Package Contents',
|
|
915
972
|
'- Case data JSON export with all image references',
|
|
916
|
-
'- images/ folder with exported image files',
|
|
973
|
+
'- images/ folder with exported image files (encrypted)',
|
|
917
974
|
'- Full case audit trail export and signed audit metadata',
|
|
918
975
|
'- Forensic manifest with server-side signature',
|
|
976
|
+
'- ENCRYPTION_MANIFEST.json with encryption metadata and encrypted image hashes',
|
|
919
977
|
`- ${publicKeyFileName} for verification`,
|
|
920
978
|
'',
|
|
921
979
|
'This package is intended for read-only review and verification workflows.',
|
|
980
|
+
'This package is encrypted. Only Striae can decrypt and re-import it.',
|
|
922
981
|
].join('\n')
|
|
923
982
|
);
|
|
924
983
|
|
|
@@ -950,7 +1009,7 @@ export const archiveCase = async (
|
|
|
950
1009
|
);
|
|
951
1010
|
|
|
952
1011
|
const downloadUrl = URL.createObjectURL(zipBlob);
|
|
953
|
-
const archiveFileName = `striae-case-${caseNumber}-archive-${formatDateForFilename(new Date())}.zip`;
|
|
1012
|
+
const archiveFileName = `striae-case-${caseNumber}-archive-${formatDateForFilename(new Date())}-encrypted.zip`;
|
|
954
1013
|
const anchor = document.createElement('a');
|
|
955
1014
|
anchor.href = downloadUrl;
|
|
956
1015
|
anchor.download = archiveFileName;
|
|
@@ -3,7 +3,9 @@ import {
|
|
|
3
3
|
calculateSHA256Secure,
|
|
4
4
|
createPublicSigningKeyFileName,
|
|
5
5
|
getCurrentPublicSigningKeyDetails,
|
|
6
|
-
getVerificationPublicKey
|
|
6
|
+
getVerificationPublicKey,
|
|
7
|
+
getCurrentEncryptionPublicKeyDetails,
|
|
8
|
+
encryptExportDataWithAllImages
|
|
7
9
|
} from '~/utils/forensics';
|
|
8
10
|
import { getUserData, getCaseData, updateCaseData, signConfirmationData, upsertFileConfirmationSummary } from '~/utils/data';
|
|
9
11
|
import { type AnnotationData, type ConfirmationData, type CaseConfirmations, type CaseDataWithConfirmations, type ConfirmationImportData } from '~/types';
|
|
@@ -318,8 +320,35 @@ export async function exportConfirmationData(
|
|
|
318
320
|
const zip = new JSZip();
|
|
319
321
|
const normalizedPem = publicKeyPem.endsWith('\n') ? publicKeyPem : `${publicKeyPem}\n`;
|
|
320
322
|
|
|
321
|
-
|
|
323
|
+
const encKeyDetails = getCurrentEncryptionPublicKeyDetails();
|
|
324
|
+
if (!encKeyDetails.publicKeyPem || !encKeyDetails.keyId) {
|
|
325
|
+
throw new Error(
|
|
326
|
+
'Confirmation export encryption is mandatory. Your Striae instance does not have a configured encryption public key. ' +
|
|
327
|
+
'Please contact your administrator to set up export encryption.'
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
let encryptedConfirmationContent: string | Uint8Array;
|
|
332
|
+
let encryptionManifestJson: string;
|
|
333
|
+
|
|
334
|
+
try {
|
|
335
|
+
const encryptionResult = await encryptExportDataWithAllImages(
|
|
336
|
+
finalJsonString,
|
|
337
|
+
[],
|
|
338
|
+
encKeyDetails.publicKeyPem,
|
|
339
|
+
encKeyDetails.keyId
|
|
340
|
+
);
|
|
341
|
+
|
|
342
|
+
encryptedConfirmationContent = encryptionResult.ciphertext;
|
|
343
|
+
encryptionManifestJson = JSON.stringify(encryptionResult.encryptionManifest, null, 2);
|
|
344
|
+
} catch (error) {
|
|
345
|
+
console.error('Confirmation export encryption failed:', error);
|
|
346
|
+
throw new Error(`Failed to encrypt confirmation export: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
zip.file(confirmationFileName, encryptedConfirmationContent);
|
|
322
350
|
zip.file(publicKeyFileName, normalizedPem);
|
|
351
|
+
zip.file('ENCRYPTION_MANIFEST.json', encryptionManifestJson);
|
|
323
352
|
|
|
324
353
|
const zipBlob = await zip.generateAsync({
|
|
325
354
|
type: 'blob',
|
|
@@ -327,7 +356,7 @@ export async function exportConfirmationData(
|
|
|
327
356
|
compressionOptions: { level: 6 }
|
|
328
357
|
});
|
|
329
358
|
|
|
330
|
-
const exportFileName = `confirmation-export-${caseNumber}-${timestampString}.zip`;
|
|
359
|
+
const exportFileName = `confirmation-export-${caseNumber}-${timestampString}-encrypted.zip`;
|
|
331
360
|
|
|
332
361
|
// Create download
|
|
333
362
|
const url = URL.createObjectURL(zipBlob);
|
|
@@ -21,6 +21,46 @@ interface GeneratePDFParams {
|
|
|
21
21
|
setToastDuration?: (duration: number) => void;
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
const CLEAR_IMAGE_SENTINEL = '/clear.jpg';
|
|
25
|
+
|
|
26
|
+
const blobToDataUrl = async (blob: Blob): Promise<string> => {
|
|
27
|
+
return await new Promise<string>((resolve, reject) => {
|
|
28
|
+
const reader = new FileReader();
|
|
29
|
+
reader.onloadend = () => {
|
|
30
|
+
if (typeof reader.result === 'string') {
|
|
31
|
+
resolve(reader.result);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
reject(new Error('Failed to read image blob as data URL'));
|
|
36
|
+
};
|
|
37
|
+
reader.onerror = () => reject(new Error('Failed to convert image for PDF rendering'));
|
|
38
|
+
reader.readAsDataURL(blob);
|
|
39
|
+
});
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const resolvePdfImageUrl = async (selectedImage: string | undefined): Promise<string | undefined> => {
|
|
43
|
+
if (!selectedImage || selectedImage === CLEAR_IMAGE_SENTINEL) {
|
|
44
|
+
return selectedImage;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (selectedImage.startsWith('data:')) {
|
|
48
|
+
return selectedImage;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (selectedImage.startsWith('blob:')) {
|
|
52
|
+
const imageResponse = await fetch(selectedImage);
|
|
53
|
+
if (!imageResponse.ok) {
|
|
54
|
+
throw new Error('Failed to load selected image for PDF generation');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const imageBlob = await imageResponse.blob();
|
|
58
|
+
return await blobToDataUrl(imageBlob);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return selectedImage;
|
|
62
|
+
};
|
|
63
|
+
|
|
24
64
|
export const generatePDF = async ({
|
|
25
65
|
user,
|
|
26
66
|
selectedImage,
|
|
@@ -61,8 +101,10 @@ export const generatePDF = async ({
|
|
|
61
101
|
notesUpdatedFormatted = `${(updatedDate.getMonth() + 1).toString().padStart(2, '0')}/${updatedDate.getDate().toString().padStart(2, '0')}/${updatedDate.getFullYear()}`;
|
|
62
102
|
}
|
|
63
103
|
|
|
104
|
+
const resolvedImageUrl = await resolvePdfImageUrl(selectedImage);
|
|
105
|
+
|
|
64
106
|
const pdfData = {
|
|
65
|
-
imageUrl:
|
|
107
|
+
imageUrl: resolvedImageUrl,
|
|
66
108
|
filename: selectedFilename,
|
|
67
109
|
userCompany: userCompany,
|
|
68
110
|
firstName: userFirstName,
|
|
@@ -1,7 +1,4 @@
|
|
|
1
1
|
import type { User } from 'firebase/auth';
|
|
2
|
-
import {
|
|
3
|
-
getAccountHash
|
|
4
|
-
} from '~/utils/auth';
|
|
5
2
|
import { fetchImageApi, uploadImageApi } from '~/utils/api';
|
|
6
3
|
import { canUploadFile, getCaseData, updateCaseData, deleteFileAnnotations } from '~/utils/data';
|
|
7
4
|
import type { CaseData, FileData, ImageUploadResponse } from '~/types';
|
|
@@ -258,30 +255,15 @@ export const deleteFile = async (
|
|
|
258
255
|
}
|
|
259
256
|
};
|
|
260
257
|
|
|
261
|
-
const
|
|
262
|
-
interface ImageDeliveryConfig {
|
|
263
|
-
accountHash: string;
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
const getImageConfig = async (): Promise<ImageDeliveryConfig> => {
|
|
267
|
-
const accountHash = await getAccountHash();
|
|
268
|
-
return { accountHash };
|
|
269
|
-
};
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
export const getImageUrl = async (user: User, fileData: FileData, caseNumber: string, accessReason?: string): Promise<string> => {
|
|
258
|
+
export const getImageUrl = async (user: User, fileData: FileData, caseNumber: string, accessReason?: string): Promise<{ blob: Blob; url: string; revoke: () => void }> => {
|
|
273
259
|
const startTime = Date.now();
|
|
274
260
|
const defaultAccessReason = accessReason || 'Image viewer access';
|
|
275
261
|
|
|
276
262
|
try {
|
|
277
|
-
const
|
|
278
|
-
const imageDeliveryUrl = `https://imagedelivery.net/${accountHash}/${fileData.id}/${DEFAULT_VARIANT}`;
|
|
279
|
-
const encodedImageDeliveryUrl = encodeURIComponent(imageDeliveryUrl);
|
|
280
|
-
|
|
281
|
-
const workerResponse = await fetchImageApi(user, `/${encodedImageDeliveryUrl}`, {
|
|
263
|
+
const workerResponse = await fetchImageApi(user, `/${encodeURIComponent(fileData.id)}`, {
|
|
282
264
|
method: 'GET',
|
|
283
265
|
headers: {
|
|
284
|
-
'Accept': '
|
|
266
|
+
'Accept': 'application/octet-stream,image/*'
|
|
285
267
|
}
|
|
286
268
|
});
|
|
287
269
|
|
|
@@ -291,39 +273,25 @@ export const getImageUrl = async (user: User, fileData: FileData, caseNumber: st
|
|
|
291
273
|
user,
|
|
292
274
|
fileData.originalFilename || fileData.id,
|
|
293
275
|
fileData.id,
|
|
294
|
-
'
|
|
295
|
-
caseNumber,
|
|
296
|
-
'failure',
|
|
297
|
-
Date.now() - startTime,
|
|
298
|
-
'Image URL generation failed',
|
|
299
|
-
fileData.originalFilename
|
|
300
|
-
);
|
|
301
|
-
throw new Error('Failed to get signed image URL');
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
const signedUrl = await workerResponse.text();
|
|
305
|
-
if (!signedUrl.includes('sig=') || !signedUrl.includes('exp=')) {
|
|
306
|
-
// Log invalid URL response
|
|
307
|
-
await auditService.logFileAccess(
|
|
308
|
-
user,
|
|
309
|
-
fileData.originalFilename || fileData.id,
|
|
310
|
-
fileData.id,
|
|
311
|
-
'signed-url',
|
|
276
|
+
'direct-url',
|
|
312
277
|
caseNumber,
|
|
313
278
|
'failure',
|
|
314
279
|
Date.now() - startTime,
|
|
315
|
-
'
|
|
280
|
+
'Image retrieval failed',
|
|
316
281
|
fileData.originalFilename
|
|
317
282
|
);
|
|
318
|
-
throw new Error('
|
|
283
|
+
throw new Error('Failed to retrieve image');
|
|
319
284
|
}
|
|
285
|
+
|
|
286
|
+
const blob = await workerResponse.blob();
|
|
287
|
+
const objectUrl = URL.createObjectURL(blob);
|
|
320
288
|
|
|
321
289
|
// Log successful image access
|
|
322
290
|
await auditService.logFileAccess(
|
|
323
291
|
user,
|
|
324
292
|
fileData.originalFilename || fileData.id,
|
|
325
293
|
fileData.id,
|
|
326
|
-
'
|
|
294
|
+
'direct-url',
|
|
327
295
|
caseNumber,
|
|
328
296
|
'success',
|
|
329
297
|
Date.now() - startTime,
|
|
@@ -331,15 +299,15 @@ export const getImageUrl = async (user: User, fileData: FileData, caseNumber: st
|
|
|
331
299
|
fileData.originalFilename
|
|
332
300
|
);
|
|
333
301
|
|
|
334
|
-
return
|
|
302
|
+
return { blob, url: objectUrl, revoke: () => URL.revokeObjectURL(objectUrl) };
|
|
335
303
|
} catch (error) {
|
|
336
304
|
// Log any unexpected errors if not already logged
|
|
337
|
-
if (!(error instanceof Error && error.message.includes('Failed to
|
|
305
|
+
if (!(error instanceof Error && error.message.includes('Failed to retrieve image'))) {
|
|
338
306
|
await auditService.logFileAccess(
|
|
339
307
|
user,
|
|
340
308
|
fileData.originalFilename || fileData.id,
|
|
341
309
|
fileData.id,
|
|
342
|
-
'
|
|
310
|
+
'direct-url',
|
|
343
311
|
caseNumber,
|
|
344
312
|
'failure',
|
|
345
313
|
Date.now() - startTime,
|
|
@@ -374,16 +374,6 @@
|
|
|
374
374
|
background: color-mix(in lab, #6c757d 21%, #ffffff);
|
|
375
375
|
}
|
|
376
376
|
|
|
377
|
-
.caseMenuItemKey {
|
|
378
|
-
background: color-mix(in lab, #0d9488 14%, #ffffff);
|
|
379
|
-
color: #0f766e;
|
|
380
|
-
border-color: color-mix(in lab, #0d9488 28%, transparent);
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
.caseMenuItemKey:hover {
|
|
384
|
-
background: color-mix(in lab, #0d9488 20%, #ffffff);
|
|
385
|
-
}
|
|
386
|
-
|
|
387
377
|
.caseMenuItemClearRO {
|
|
388
378
|
background: color-mix(in lab, #fd7e14 16%, #ffffff);
|
|
389
379
|
color: #7c3f00;
|
|
@@ -3,8 +3,6 @@ import styles from './navbar.module.css';
|
|
|
3
3
|
import { SignOut } from '../actions/signout';
|
|
4
4
|
import { ManageProfile } from '../user/manage-profile';
|
|
5
5
|
import { CaseImport } from '../sidebar/case-import/case-import';
|
|
6
|
-
import { PublicSigningKeyModal } from '~/components/public-signing-key-modal/public-signing-key-modal';
|
|
7
|
-
import { getCurrentPublicSigningKeyDetails } from '~/utils/forensics';
|
|
8
6
|
import { AuthContext } from '~/contexts/auth.context';
|
|
9
7
|
import { getUserData } from '~/utils/data';
|
|
10
8
|
import { type ImportResult, type ConfirmationImportResult } from '~/types';
|
|
@@ -68,10 +66,8 @@ export const Navbar = ({
|
|
|
68
66
|
const [userBadgeId, setUserBadgeId] = useState<string>('');
|
|
69
67
|
const [isProfileModalOpen, setIsProfileModalOpen] = useState(false);
|
|
70
68
|
const [isImportModalOpen, setIsImportModalOpen] = useState(false);
|
|
71
|
-
const [isPublicKeyModalOpen, setIsPublicKeyModalOpen] = useState(false);
|
|
72
69
|
const [isCaseMenuOpen, setIsCaseMenuOpen] = useState(false);
|
|
73
70
|
const [isFileMenuOpen, setIsFileMenuOpen] = useState(false);
|
|
74
|
-
const { keyId: publicSigningKeyId, publicKeyPem } = getCurrentPublicSigningKeyDetails();
|
|
75
71
|
const caseMenuRef = useRef<HTMLDivElement>(null);
|
|
76
72
|
const fileMenuRef = useRef<HTMLDivElement>(null);
|
|
77
73
|
|
|
@@ -292,18 +288,6 @@ export const Navbar = ({
|
|
|
292
288
|
Archive Case
|
|
293
289
|
</button>
|
|
294
290
|
)}
|
|
295
|
-
<div className={styles.caseMenuSectionLabel}>Verification</div>
|
|
296
|
-
<button
|
|
297
|
-
type="button"
|
|
298
|
-
role="menuitem"
|
|
299
|
-
className={`${styles.caseMenuItem} ${styles.caseMenuItemKey}`}
|
|
300
|
-
onClick={() => {
|
|
301
|
-
setIsPublicKeyModalOpen(true);
|
|
302
|
-
setIsCaseMenuOpen(false);
|
|
303
|
-
}}
|
|
304
|
-
>
|
|
305
|
-
Verify Exports
|
|
306
|
-
</button>
|
|
307
291
|
{currentCase && (
|
|
308
292
|
<div className={styles.caseMenuCaption}>Case: {currentCase}</div>
|
|
309
293
|
)}
|
|
@@ -423,12 +407,6 @@ export const Navbar = ({
|
|
|
423
407
|
isOpen={isProfileModalOpen}
|
|
424
408
|
onClose={() => setIsProfileModalOpen(false)}
|
|
425
409
|
/>
|
|
426
|
-
<PublicSigningKeyModal
|
|
427
|
-
isOpen={isPublicKeyModalOpen}
|
|
428
|
-
onClose={() => setIsPublicKeyModalOpen(false)}
|
|
429
|
-
publicSigningKeyId={publicSigningKeyId}
|
|
430
|
-
publicKeyPem={publicKeyPem}
|
|
431
|
-
/>
|
|
432
410
|
</>
|
|
433
411
|
);
|
|
434
412
|
};
|
|
@@ -425,6 +425,13 @@
|
|
|
425
425
|
color: var(--textTitle);
|
|
426
426
|
}
|
|
427
427
|
|
|
428
|
+
.previewMessage {
|
|
429
|
+
margin: 0;
|
|
430
|
+
color: var(--textBody);
|
|
431
|
+
font-size: var(--fontSizeBodyS);
|
|
432
|
+
line-height: 1.5;
|
|
433
|
+
}
|
|
434
|
+
|
|
428
435
|
.archivedImportNote {
|
|
429
436
|
margin-bottom: var(--spaceM);
|
|
430
437
|
padding: var(--spaceS) var(--spaceM);
|
|
@@ -447,62 +454,6 @@
|
|
|
447
454
|
font-weight: var(--fontWeightMedium);
|
|
448
455
|
}
|
|
449
456
|
|
|
450
|
-
/* Validation Section - Green/Red Based on Status */
|
|
451
|
-
.validationSection {
|
|
452
|
-
border-radius: var(--spaceXS);
|
|
453
|
-
padding: var(--spaceM);
|
|
454
|
-
margin: var(--spaceM) 0;
|
|
455
|
-
background: color-mix(in lab, var(--primary) 5%, transparent);
|
|
456
|
-
border: 1px solid color-mix(in lab, var(--primary) 15%, transparent);
|
|
457
|
-
}
|
|
458
|
-
|
|
459
|
-
.validationSectionValid {
|
|
460
|
-
background: color-mix(in lab, var(--success) 8%, transparent) !important;
|
|
461
|
-
border: 2px solid color-mix(in lab, var(--success) 25%, transparent) !important;
|
|
462
|
-
box-shadow: 0 2px 6px color-mix(in lab, var(--success) 12%, transparent) !important;
|
|
463
|
-
}
|
|
464
|
-
|
|
465
|
-
.validationSectionInvalid {
|
|
466
|
-
background: color-mix(in lab, var(--error) 8%, transparent) !important;
|
|
467
|
-
border: 2px solid color-mix(in lab, var(--error) 25%, transparent) !important;
|
|
468
|
-
box-shadow: 0 2px 6px color-mix(in lab, var(--error) 12%, transparent) !important;
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
.validationTitle {
|
|
472
|
-
margin: 0 0 var(--spaceM) 0;
|
|
473
|
-
font-size: var(--fontSizeBodyM);
|
|
474
|
-
font-weight: var(--fontWeightMedium);
|
|
475
|
-
color: var(--textTitle);
|
|
476
|
-
}
|
|
477
|
-
|
|
478
|
-
.validationItem {
|
|
479
|
-
display: flex;
|
|
480
|
-
justify-content: space-between;
|
|
481
|
-
align-items: center;
|
|
482
|
-
padding: var(--spaceXS) 0;
|
|
483
|
-
font-size: var(--fontSizeBodyS);
|
|
484
|
-
}
|
|
485
|
-
|
|
486
|
-
.validationLabel {
|
|
487
|
-
font-weight: var(--fontWeightMedium);
|
|
488
|
-
color: var(--textTitle);
|
|
489
|
-
}
|
|
490
|
-
|
|
491
|
-
.validationValue {
|
|
492
|
-
font-family: var(--fontMono);
|
|
493
|
-
font-size: var(--fontSizeBodyXS);
|
|
494
|
-
}
|
|
495
|
-
|
|
496
|
-
.validationSuccess {
|
|
497
|
-
color: var(--success);
|
|
498
|
-
font-weight: var(--fontWeightMedium);
|
|
499
|
-
}
|
|
500
|
-
|
|
501
|
-
.validationError {
|
|
502
|
-
color: var(--error);
|
|
503
|
-
font-weight: var(--fontWeightMedium);
|
|
504
|
-
}
|
|
505
|
-
|
|
506
457
|
.previewLoading {
|
|
507
458
|
text-align: center;
|
|
508
459
|
color: var(--textLight);
|
|
@@ -510,31 +461,6 @@
|
|
|
510
461
|
padding: var(--spaceM);
|
|
511
462
|
}
|
|
512
463
|
|
|
513
|
-
.previewGrid {
|
|
514
|
-
display: grid;
|
|
515
|
-
gap: var(--spaceS);
|
|
516
|
-
}
|
|
517
|
-
|
|
518
|
-
.previewItem {
|
|
519
|
-
display: flex;
|
|
520
|
-
justify-content: space-between;
|
|
521
|
-
align-items: center;
|
|
522
|
-
padding: var(--spaceXS) 0;
|
|
523
|
-
}
|
|
524
|
-
|
|
525
|
-
.previewLabel {
|
|
526
|
-
font-weight: var(--fontWeightMedium);
|
|
527
|
-
color: var(--textBody);
|
|
528
|
-
font-size: var(--fontSizeBodyS);
|
|
529
|
-
}
|
|
530
|
-
|
|
531
|
-
.previewValue {
|
|
532
|
-
color: var(--textTitle);
|
|
533
|
-
font-size: var(--fontSizeBodyS);
|
|
534
|
-
text-align: right;
|
|
535
|
-
font-weight: var(--fontWeightMedium);
|
|
536
|
-
}
|
|
537
|
-
|
|
538
464
|
/* Confirmation Dialog */
|
|
539
465
|
.confirmationOverlay {
|
|
540
466
|
position: fixed;
|
|
@@ -582,45 +508,6 @@
|
|
|
582
508
|
margin: 0 0 var(--spaceL) 0;
|
|
583
509
|
}
|
|
584
510
|
|
|
585
|
-
.confirmationItem {
|
|
586
|
-
display: flex;
|
|
587
|
-
justify-content: space-between;
|
|
588
|
-
align-items: center;
|
|
589
|
-
padding: var(--spaceXS) 0;
|
|
590
|
-
font-size: var(--fontSizeBodyS);
|
|
591
|
-
}
|
|
592
|
-
|
|
593
|
-
.confirmationItem:not(:last-child) {
|
|
594
|
-
border-bottom: 1px solid color-mix(in lab, var(--text) 5%, transparent);
|
|
595
|
-
}
|
|
596
|
-
|
|
597
|
-
.confirmationItemValid {
|
|
598
|
-
background: color-mix(in lab, var(--success) 15%, transparent);
|
|
599
|
-
border-radius: var(--spaceXS);
|
|
600
|
-
padding: var(--spaceS) var(--spaceM);
|
|
601
|
-
margin: var(--spaceXS) calc(-1 * var(--spaceM));
|
|
602
|
-
border: 2px solid color-mix(in lab, var(--success) 35%, transparent);
|
|
603
|
-
box-shadow: 0 2px 4px color-mix(in lab, var(--success) 10%, transparent);
|
|
604
|
-
}
|
|
605
|
-
|
|
606
|
-
.confirmationItemInvalid {
|
|
607
|
-
background: color-mix(in lab, var(--error) 8%, transparent);
|
|
608
|
-
border-radius: var(--spaceXS);
|
|
609
|
-
padding: var(--spaceS) var(--spaceM);
|
|
610
|
-
margin: var(--spaceXS) calc(-1 * var(--spaceM));
|
|
611
|
-
border: 1px solid color-mix(in lab, var(--error) 20%, transparent);
|
|
612
|
-
}
|
|
613
|
-
|
|
614
|
-
.confirmationSuccess {
|
|
615
|
-
color: var(--success);
|
|
616
|
-
font-weight: var(--fontWeightMedium);
|
|
617
|
-
}
|
|
618
|
-
|
|
619
|
-
.confirmationError {
|
|
620
|
-
color: var(--error);
|
|
621
|
-
font-weight: var(--fontWeightMedium);
|
|
622
|
-
}
|
|
623
|
-
|
|
624
511
|
.confirmationButtons {
|
|
625
512
|
display: flex;
|
|
626
513
|
gap: var(--spaceM);
|
|
@@ -642,14 +529,3 @@
|
|
|
642
529
|
.confirmButton:hover {
|
|
643
530
|
box-shadow: 0 2px 6px color-mix(in lab, var(--primary) 30%, transparent);
|
|
644
531
|
}
|
|
645
|
-
|
|
646
|
-
.hashWarning {
|
|
647
|
-
background: color-mix(in lab, var(--error) 10%, transparent);
|
|
648
|
-
border: 1px solid color-mix(in lab, var(--error) 30%, transparent);
|
|
649
|
-
color: var(--error);
|
|
650
|
-
padding: var(--spaceM);
|
|
651
|
-
border-radius: var(--spaceXS);
|
|
652
|
-
margin-top: var(--spaceM);
|
|
653
|
-
font-size: var(--fontSizeBodyS);
|
|
654
|
-
font-weight: var(--fontWeightMedium);
|
|
655
|
-
}
|
|
@@ -4,8 +4,7 @@ import { useOverlayDismiss } from '~/hooks/useOverlayDismiss';
|
|
|
4
4
|
import {
|
|
5
5
|
ARCHIVED_REGULAR_CASE_BLOCK_MESSAGE,
|
|
6
6
|
IMPORT_FILE_TYPE_NOT_ALLOWED,
|
|
7
|
-
IMPORT_FILE_TYPE_NOT_SUPPORTED
|
|
8
|
-
DATA_INTEGRITY_BLOCKED_TAMPERING
|
|
7
|
+
IMPORT_FILE_TYPE_NOT_SUPPORTED
|
|
9
8
|
} from '~/utils/ui';
|
|
10
9
|
import {
|
|
11
10
|
listReadOnlyCases,
|
|
@@ -177,14 +176,14 @@ export const CaseImport = ({
|
|
|
177
176
|
clearMessages();
|
|
178
177
|
|
|
179
178
|
if (!isValidImportFile(file)) {
|
|
180
|
-
setError(
|
|
179
|
+
setError(IMPORT_FILE_TYPE_NOT_ALLOWED);
|
|
181
180
|
clearImportData();
|
|
182
181
|
return;
|
|
183
182
|
}
|
|
184
183
|
|
|
185
184
|
const importType = await resolveImportType(file);
|
|
186
185
|
if (!importType) {
|
|
187
|
-
setError(
|
|
186
|
+
setError(IMPORT_FILE_TYPE_NOT_SUPPORTED);
|
|
188
187
|
clearImportData();
|
|
189
188
|
return;
|
|
190
189
|
}
|
|
@@ -415,13 +414,6 @@ export const CaseImport = ({
|
|
|
415
414
|
{/* Import progress */}
|
|
416
415
|
<ProgressSection importProgress={importProgress} />
|
|
417
416
|
|
|
418
|
-
{/* Hash validation warning */}
|
|
419
|
-
{casePreview?.hashValid === false && (
|
|
420
|
-
<div className={styles.hashWarning}>
|
|
421
|
-
<strong>⚠️ Import Blocked:</strong> {DATA_INTEGRITY_BLOCKED_TAMPERING}
|
|
422
|
-
</div>
|
|
423
|
-
)}
|
|
424
|
-
|
|
425
417
|
{isArchivedRegularCaseImportBlocked && (
|
|
426
418
|
<div className={styles.error}>{ARCHIVED_REGULAR_CASE_BLOCK_MESSAGE}</div>
|
|
427
419
|
)}
|
|
@@ -452,7 +444,7 @@ export const CaseImport = ({
|
|
|
452
444
|
importState.isClearing ||
|
|
453
445
|
importState.isLoadingPreview ||
|
|
454
446
|
(importState.importType === 'case' && isArchivedRegularCaseImportBlocked) ||
|
|
455
|
-
(importState.importType === 'case' && (!casePreview || casePreview.hashValid
|
|
447
|
+
(importState.importType === 'case' && (!casePreview || casePreview.hashValid === false))
|
|
456
448
|
}
|
|
457
449
|
>
|
|
458
450
|
{importState.isImporting ? 'Importing...' :
|
|
@@ -472,15 +464,16 @@ export const CaseImport = ({
|
|
|
472
464
|
<div className={styles.instructions}>
|
|
473
465
|
<h3 className={styles.instructionsTitle}>Case Review Instructions:</h3>
|
|
474
466
|
<ul className={styles.instructionsList}>
|
|
475
|
-
<li>Only ZIP
|
|
467
|
+
<li>Only case ZIP packages exported from Striae are accepted</li>
|
|
476
468
|
<li>Only one case can be reviewed at a time</li>
|
|
477
469
|
<li>Imported cases are read-only and cannot be modified</li>
|
|
470
|
+
<li>Integrity and signature validation are enforced during import</li>
|
|
478
471
|
<li>Importing will automatically replace any existing review case</li>
|
|
479
472
|
</ul>
|
|
480
473
|
<br />
|
|
481
474
|
<h3 className={styles.instructionsTitle}>Confirmation Import Instructions:</h3>
|
|
482
475
|
<ul className={styles.instructionsList}>
|
|
483
|
-
<li>Confirmation imports accept
|
|
476
|
+
<li>Confirmation imports accept only encrypted confirmation ZIP packages exported from Striae</li>
|
|
484
477
|
<li>Only one confirmation file can be imported at a time</li>
|
|
485
478
|
<li>Confirmed images will become read-only and cannot be modified</li>
|
|
486
479
|
<li>If an image has a pre-existing confirmation, it will be skipped</li>
|