@striae-org/striae 3.0.4
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 +100 -0
- package/LICENSE +190 -0
- package/NOTICE +18 -0
- package/README.md +133 -0
- package/app/components/actions/case-export/core-export.ts +328 -0
- package/app/components/actions/case-export/data-processing.ts +167 -0
- package/app/components/actions/case-export/download-handlers.ts +900 -0
- package/app/components/actions/case-export/index.ts +41 -0
- package/app/components/actions/case-export/metadata-helpers.ts +107 -0
- package/app/components/actions/case-export/types-constants.ts +56 -0
- package/app/components/actions/case-export/validation-utils.ts +25 -0
- package/app/components/actions/case-export.ts +4 -0
- package/app/components/actions/case-import/annotation-import.ts +35 -0
- package/app/components/actions/case-import/confirmation-import.ts +363 -0
- package/app/components/actions/case-import/image-operations.ts +61 -0
- package/app/components/actions/case-import/index.ts +39 -0
- package/app/components/actions/case-import/orchestrator.ts +420 -0
- package/app/components/actions/case-import/storage-operations.ts +270 -0
- package/app/components/actions/case-import/validation.ts +189 -0
- package/app/components/actions/case-import/zip-processing.ts +413 -0
- package/app/components/actions/case-manage.ts +524 -0
- package/app/components/actions/case-review.ts +4 -0
- package/app/components/actions/confirm-export.ts +351 -0
- package/app/components/actions/generate-pdf.ts +210 -0
- package/app/components/actions/image-manage.ts +385 -0
- package/app/components/actions/notes-manage.ts +33 -0
- package/app/components/actions/signout.module.css +15 -0
- package/app/components/actions/signout.tsx +50 -0
- package/app/components/audit/user-audit-viewer.tsx +975 -0
- package/app/components/audit/user-audit.module.css +568 -0
- package/app/components/auth/auth-provider.tsx +78 -0
- package/app/components/auth/mfa-enrollment.module.css +268 -0
- package/app/components/auth/mfa-enrollment.tsx +398 -0
- package/app/components/auth/mfa-verification.module.css +251 -0
- package/app/components/auth/mfa-verification.tsx +295 -0
- package/app/components/button/button.module.css +63 -0
- package/app/components/button/button.tsx +46 -0
- package/app/components/canvas/box-annotations/box-annotations.module.css +170 -0
- package/app/components/canvas/box-annotations/box-annotations.tsx +634 -0
- package/app/components/canvas/canvas.module.css +314 -0
- package/app/components/canvas/canvas.tsx +449 -0
- package/app/components/canvas/confirmation/confirmation.module.css +187 -0
- package/app/components/canvas/confirmation/confirmation.tsx +214 -0
- package/app/components/colors/colors.module.css +59 -0
- package/app/components/colors/colors.tsx +68 -0
- package/app/components/form/base-form.tsx +21 -0
- package/app/components/form/form-button.tsx +28 -0
- package/app/components/form/form-field.tsx +53 -0
- package/app/components/form/form-message.tsx +17 -0
- package/app/components/form/form-toggle.tsx +23 -0
- package/app/components/form/form.module.css +427 -0
- package/app/components/form/index.ts +6 -0
- package/app/components/icon/icon.module.css +3 -0
- package/app/components/icon/icon.tsx +27 -0
- package/app/components/icon/icons.svg +102 -0
- package/app/components/icon/manifest.json +110 -0
- package/app/components/sidebar/case-export/case-export.module.css +386 -0
- package/app/components/sidebar/case-export/case-export.tsx +317 -0
- package/app/components/sidebar/case-import/case-import.module.css +626 -0
- package/app/components/sidebar/case-import/case-import.tsx +404 -0
- package/app/components/sidebar/case-import/components/CasePreviewSection.tsx +72 -0
- package/app/components/sidebar/case-import/components/ConfirmationDialog.tsx +72 -0
- package/app/components/sidebar/case-import/components/ConfirmationPreviewSection.tsx +71 -0
- package/app/components/sidebar/case-import/components/ExistingCaseSection.tsx +40 -0
- package/app/components/sidebar/case-import/components/FileSelector.tsx +161 -0
- package/app/components/sidebar/case-import/components/ProgressSection.tsx +46 -0
- package/app/components/sidebar/case-import/hooks/useFilePreview.ts +101 -0
- package/app/components/sidebar/case-import/hooks/useImportExecution.ts +152 -0
- package/app/components/sidebar/case-import/hooks/useImportState.ts +88 -0
- package/app/components/sidebar/case-import/index.ts +18 -0
- package/app/components/sidebar/case-import/utils/file-validation.ts +43 -0
- package/app/components/sidebar/cases/case-sidebar.tsx +827 -0
- package/app/components/sidebar/cases/cases-modal.module.css +166 -0
- package/app/components/sidebar/cases/cases-modal.tsx +201 -0
- package/app/components/sidebar/cases/cases.module.css +713 -0
- package/app/components/sidebar/files/files-modal.module.css +209 -0
- package/app/components/sidebar/files/files-modal.tsx +239 -0
- package/app/components/sidebar/hash/hash-utility.module.css +366 -0
- package/app/components/sidebar/hash/hash-utility.tsx +982 -0
- package/app/components/sidebar/notes/notes-modal.tsx +51 -0
- package/app/components/sidebar/notes/notes-sidebar.tsx +491 -0
- package/app/components/sidebar/notes/notes.module.css +360 -0
- package/app/components/sidebar/sidebar-container.tsx +149 -0
- package/app/components/sidebar/sidebar.module.css +321 -0
- package/app/components/sidebar/sidebar.tsx +215 -0
- package/app/components/sidebar/upload/image-upload-zone.module.css +123 -0
- package/app/components/sidebar/upload/image-upload-zone.tsx +330 -0
- package/app/components/theme-provider/theme-provider.tsx +131 -0
- package/app/components/theme-provider/theme.ts +155 -0
- package/app/components/toast/toast.module.css +137 -0
- package/app/components/toast/toast.tsx +56 -0
- package/app/components/toolbar/toolbar-color-selector.module.css +171 -0
- package/app/components/toolbar/toolbar-color-selector.tsx +129 -0
- package/app/components/toolbar/toolbar.module.css +42 -0
- package/app/components/toolbar/toolbar.tsx +167 -0
- package/app/components/user/delete-account.module.css +274 -0
- package/app/components/user/delete-account.tsx +471 -0
- package/app/components/user/inactivity-warning.module.css +145 -0
- package/app/components/user/inactivity-warning.tsx +84 -0
- package/app/components/user/manage-profile.module.css +190 -0
- package/app/components/user/manage-profile.tsx +253 -0
- package/app/components/user/mfa-phone-update.tsx +739 -0
- package/app/config-example/admin-service.json +13 -0
- package/app/config-example/config.json +17 -0
- package/app/config-example/firebase.ts +21 -0
- package/app/config-example/inactivity.ts +13 -0
- package/app/config-example/meta-config.json +6 -0
- package/app/contexts/auth.context.ts +12 -0
- package/app/entry.client.tsx +12 -0
- package/app/entry.server.tsx +44 -0
- package/app/hooks/useInactivityTimeout.ts +110 -0
- package/app/root.tsx +170 -0
- package/app/routes/_index.tsx +16 -0
- package/app/routes/auth/emailActionHandler.module.css +232 -0
- package/app/routes/auth/emailActionHandler.tsx +405 -0
- package/app/routes/auth/emailVerification.tsx +120 -0
- package/app/routes/auth/login.module.css +523 -0
- package/app/routes/auth/login.tsx +654 -0
- package/app/routes/auth/passwordReset.module.css +274 -0
- package/app/routes/auth/passwordReset.tsx +154 -0
- package/app/routes/auth/route.ts +16 -0
- package/app/routes/mobile-prevented/mobilePrevented.module.css +47 -0
- package/app/routes/mobile-prevented/mobilePrevented.tsx +26 -0
- package/app/routes/mobile-prevented/route.ts +14 -0
- package/app/routes/striae/striae.module.css +30 -0
- package/app/routes/striae/striae.tsx +417 -0
- package/app/services/audit-export.service.ts +755 -0
- package/app/services/audit.service.ts +1454 -0
- package/app/services/firebase-errors.ts +106 -0
- package/app/services/firebase.ts +15 -0
- package/app/styles/legal-pages.module.css +113 -0
- package/app/styles/root.module.css +146 -0
- package/app/tailwind.css +225 -0
- package/app/types/annotations.ts +45 -0
- package/app/types/audit.ts +301 -0
- package/app/types/case.ts +90 -0
- package/app/types/export.ts +8 -0
- package/app/types/file.ts +30 -0
- package/app/types/import.ts +107 -0
- package/app/types/index.ts +24 -0
- package/app/types/user.ts +38 -0
- package/app/utils/SHA256.ts +461 -0
- package/app/utils/annotation-timestamp.ts +25 -0
- package/app/utils/audit-export-signature.ts +117 -0
- package/app/utils/auth-action-settings.ts +48 -0
- package/app/utils/auth.ts +34 -0
- package/app/utils/batch-operations.ts +135 -0
- package/app/utils/confirmation-signature.ts +193 -0
- package/app/utils/data-operations.ts +871 -0
- package/app/utils/device-detection.ts +5 -0
- package/app/utils/html-sanitizer.ts +80 -0
- package/app/utils/id-generator.ts +36 -0
- package/app/utils/meta.ts +48 -0
- package/app/utils/mfa-phone.ts +97 -0
- package/app/utils/mfa.ts +79 -0
- package/app/utils/password-policy.ts +28 -0
- package/app/utils/permissions.ts +562 -0
- package/app/utils/signature-utils.ts +160 -0
- package/app/utils/style.ts +83 -0
- package/app/utils/version.ts +5 -0
- package/firebase.json +11 -0
- package/functions/[[path]].ts +10 -0
- package/package.json +138 -0
- package/postcss.config.js +6 -0
- package/public/.well-known/publickey.info@striae.org.asc +17 -0
- package/public/.well-known/security.txt +7 -0
- package/public/_headers +28 -0
- package/public/_routes.json +13 -0
- package/public/assets/striae.jpg +0 -0
- package/public/clear.jpg +0 -0
- package/public/favicon.ico +0 -0
- package/public/favicon.svg +9 -0
- package/public/icon-256.png +0 -0
- package/public/icon-512.png +0 -0
- package/public/logo-dark.png +0 -0
- package/public/manifest.json +25 -0
- package/public/oin-badge.png +0 -0
- package/public/shortcut.png +0 -0
- package/public/social-image.png +0 -0
- package/public/striae-ascii.txt +10 -0
- package/scripts/deploy-all.sh +100 -0
- package/scripts/deploy-config.sh +940 -0
- package/scripts/deploy-pages.sh +34 -0
- package/scripts/deploy-worker-secrets.sh +215 -0
- package/scripts/dev.cjs +23 -0
- package/scripts/install-workers.sh +88 -0
- package/scripts/run-eslint.cjs +35 -0
- package/scripts/update-compatibility-dates.cjs +124 -0
- package/scripts/update-markdown-versions.cjs +43 -0
- package/tailwind.config.ts +22 -0
- package/tsconfig.json +33 -0
- package/vite.config.ts +35 -0
- package/worker-configuration.d.ts +7490 -0
- package/workers/audit-worker/package.json +17 -0
- package/workers/audit-worker/src/audit-worker.example.ts +195 -0
- package/workers/audit-worker/worker-configuration.d.ts +7448 -0
- package/workers/audit-worker/wrangler.jsonc.example +29 -0
- package/workers/data-worker/package.json +17 -0
- package/workers/data-worker/src/data-worker.example.ts +267 -0
- package/workers/data-worker/src/signature-utils.ts +79 -0
- package/workers/data-worker/src/signing-payload-utils.ts +290 -0
- package/workers/data-worker/worker-configuration.d.ts +7448 -0
- package/workers/data-worker/wrangler.jsonc.example +30 -0
- package/workers/image-worker/package.json +17 -0
- package/workers/image-worker/src/image-worker.example.ts +180 -0
- package/workers/image-worker/worker-configuration.d.ts +7447 -0
- package/workers/image-worker/wrangler.jsonc.example +22 -0
- package/workers/keys-worker/package.json +17 -0
- package/workers/keys-worker/src/keys.example.ts +66 -0
- package/workers/keys-worker/src/keys.ts +66 -0
- package/workers/keys-worker/worker-configuration.d.ts +7447 -0
- package/workers/keys-worker/wrangler.jsonc.example +22 -0
- package/workers/pdf-worker/package.json +17 -0
- package/workers/pdf-worker/src/format-striae.ts +534 -0
- package/workers/pdf-worker/src/pdf-worker.example.ts +119 -0
- package/workers/pdf-worker/src/report-types.ts +69 -0
- package/workers/pdf-worker/worker-configuration.d.ts +7448 -0
- package/workers/pdf-worker/wrangler.jsonc.example +26 -0
- package/workers/user-worker/package.json +17 -0
- package/workers/user-worker/src/user-worker.example.ts +636 -0
- package/workers/user-worker/worker-configuration.d.ts +7448 -0
- package/workers/user-worker/wrangler.jsonc.example +29 -0
- package/wrangler.toml.example +8 -0
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import paths from '~/config/config.json';
|
|
2
|
+
import { getImageApiKey } from '~/utils/auth';
|
|
3
|
+
import { FileData, ImageUploadResponse } from '~/types';
|
|
4
|
+
|
|
5
|
+
const IMAGE_WORKER_URL = paths.image_worker_url;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Upload image blob to image worker and get file data
|
|
9
|
+
*/
|
|
10
|
+
export async function uploadImageBlob(
|
|
11
|
+
imageBlob: Blob,
|
|
12
|
+
originalFilename: string,
|
|
13
|
+
onProgress?: (filename: string, progress: number) => void
|
|
14
|
+
): Promise<FileData> {
|
|
15
|
+
const imagesApiToken = await getImageApiKey();
|
|
16
|
+
|
|
17
|
+
return new Promise((resolve, reject) => {
|
|
18
|
+
const xhr = new XMLHttpRequest();
|
|
19
|
+
const formData = new FormData();
|
|
20
|
+
|
|
21
|
+
// Create a File object from the blob to preserve the filename
|
|
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
|
+
});
|
|
54
|
+
|
|
55
|
+
xhr.addEventListener('error', () => reject(new Error('Upload failed')));
|
|
56
|
+
|
|
57
|
+
xhr.open('POST', IMAGE_WORKER_URL);
|
|
58
|
+
xhr.setRequestHeader('Authorization', `Bearer ${imagesApiToken}`);
|
|
59
|
+
xhr.send(formData);
|
|
60
|
+
});
|
|
61
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// Barrel export for case import functionality
|
|
2
|
+
// This maintains the same API as the original case-review.ts file
|
|
3
|
+
|
|
4
|
+
// Validation functions
|
|
5
|
+
export {
|
|
6
|
+
validateExporterUid,
|
|
7
|
+
isConfirmationDataFile,
|
|
8
|
+
validateConfirmationHash,
|
|
9
|
+
validateConfirmationSignatureFile,
|
|
10
|
+
validateCaseIntegrity
|
|
11
|
+
} from './validation';
|
|
12
|
+
|
|
13
|
+
// ZIP processing functions
|
|
14
|
+
export {
|
|
15
|
+
previewCaseImport,
|
|
16
|
+
parseImportZip
|
|
17
|
+
} from './zip-processing';
|
|
18
|
+
|
|
19
|
+
// Storage operations
|
|
20
|
+
export {
|
|
21
|
+
checkReadOnlyCaseExists,
|
|
22
|
+
addReadOnlyCaseToUser,
|
|
23
|
+
storeCaseDataInR2,
|
|
24
|
+
listReadOnlyCases,
|
|
25
|
+
removeReadOnlyCase,
|
|
26
|
+
deleteReadOnlyCase
|
|
27
|
+
} from './storage-operations';
|
|
28
|
+
|
|
29
|
+
// Image operations
|
|
30
|
+
export { uploadImageBlob } from './image-operations';
|
|
31
|
+
|
|
32
|
+
// Annotation import
|
|
33
|
+
export { importAnnotations } from './annotation-import';
|
|
34
|
+
|
|
35
|
+
// Confirmation import
|
|
36
|
+
export { importConfirmationData } from './confirmation-import';
|
|
37
|
+
|
|
38
|
+
// Main orchestrator
|
|
39
|
+
export { importCaseForReview } from './orchestrator';
|
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
import { User } from 'firebase/auth';
|
|
2
|
+
import { ImportOptions, ImportResult, ReadOnlyCaseMetadata, FileData } from '~/types';
|
|
3
|
+
import { checkExistingCase } from '../case-manage';
|
|
4
|
+
import {
|
|
5
|
+
extractForensicManifestData,
|
|
6
|
+
SignedForensicManifest,
|
|
7
|
+
validateCaseIntegritySecure as validateForensicIntegrity,
|
|
8
|
+
verifyForensicManifestSignature
|
|
9
|
+
} from '~/utils/SHA256';
|
|
10
|
+
import { deleteFile } from '../image-manage';
|
|
11
|
+
import { parseImportZip } from './zip-processing';
|
|
12
|
+
import {
|
|
13
|
+
checkReadOnlyCaseExists,
|
|
14
|
+
deleteReadOnlyCase,
|
|
15
|
+
storeCaseDataInR2,
|
|
16
|
+
addReadOnlyCaseToUser,
|
|
17
|
+
removeReadOnlyCase,
|
|
18
|
+
listReadOnlyCases
|
|
19
|
+
} from './storage-operations';
|
|
20
|
+
import { uploadImageBlob } from './image-operations';
|
|
21
|
+
import { importAnnotations } from './annotation-import';
|
|
22
|
+
import { auditService } from '~/services/audit.service';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Track the state of an import operation for cleanup purposes
|
|
26
|
+
*/
|
|
27
|
+
interface ImportState {
|
|
28
|
+
uploadedFiles: FileData[];
|
|
29
|
+
caseDataStored: boolean;
|
|
30
|
+
userProfileUpdated: boolean;
|
|
31
|
+
caseNumber: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Clean up partially imported data when an import fails
|
|
36
|
+
*/
|
|
37
|
+
async function cleanupPartialImport(
|
|
38
|
+
user: User,
|
|
39
|
+
state: ImportState,
|
|
40
|
+
onProgress?: (stage: string, progress: number, details?: string) => void
|
|
41
|
+
): Promise<string[]> {
|
|
42
|
+
const cleanupWarnings: string[] = [];
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
onProgress?.('Cleaning up partial import', 0, 'Starting cleanup...');
|
|
46
|
+
|
|
47
|
+
// Step 1: Remove user profile entry if it was added
|
|
48
|
+
if (state.userProfileUpdated) {
|
|
49
|
+
try {
|
|
50
|
+
onProgress?.('Cleaning up partial import', 25, 'Removing user profile entry...');
|
|
51
|
+
const removeSuccess = await removeReadOnlyCase(user, state.caseNumber);
|
|
52
|
+
if (!removeSuccess) {
|
|
53
|
+
cleanupWarnings.push('Failed to remove case from user profile during cleanup');
|
|
54
|
+
}
|
|
55
|
+
} catch (error) {
|
|
56
|
+
cleanupWarnings.push(`Error removing user profile entry: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Step 2: Delete case data from R2 if it was stored
|
|
61
|
+
if (state.caseDataStored) {
|
|
62
|
+
try {
|
|
63
|
+
onProgress?.('Cleaning up partial import', 50, 'Removing case data...');
|
|
64
|
+
// Use the full deleteReadOnlyCase function to remove all R2 data
|
|
65
|
+
const deleteSuccess = await deleteReadOnlyCase(user, state.caseNumber);
|
|
66
|
+
if (!deleteSuccess) {
|
|
67
|
+
cleanupWarnings.push('Failed to remove case data during cleanup');
|
|
68
|
+
}
|
|
69
|
+
} catch (error) {
|
|
70
|
+
cleanupWarnings.push(`Error removing case data: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Step 3: Delete uploaded images
|
|
75
|
+
if (state.uploadedFiles.length > 0) {
|
|
76
|
+
onProgress?.('Cleaning up partial import', 75, `Deleting ${state.uploadedFiles.length} uploaded images...`);
|
|
77
|
+
|
|
78
|
+
const deletePromises = state.uploadedFiles.map(async (file, index) => {
|
|
79
|
+
try {
|
|
80
|
+
await deleteFile(user, state.caseNumber, file.id);
|
|
81
|
+
} catch (error) {
|
|
82
|
+
cleanupWarnings.push(`Failed to delete image ${file.originalFilename}: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Update progress for image deletion
|
|
86
|
+
const progress = 75 + (index / state.uploadedFiles.length) * 25;
|
|
87
|
+
onProgress?.('Cleaning up partial import', progress, `Deleted ${index + 1}/${state.uploadedFiles.length} images`);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
await Promise.all(deletePromises);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
onProgress?.('Cleaning up partial import', 100, 'Cleanup completed');
|
|
94
|
+
|
|
95
|
+
} catch (error) {
|
|
96
|
+
cleanupWarnings.push(`Cleanup process failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return cleanupWarnings;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Main function to import a case for read-only viewing
|
|
104
|
+
*/
|
|
105
|
+
export async function importCaseForReview(
|
|
106
|
+
user: User,
|
|
107
|
+
zipFile: File,
|
|
108
|
+
options: ImportOptions = {},
|
|
109
|
+
onProgress?: (stage: string, progress: number, details?: string) => void
|
|
110
|
+
): Promise<ImportResult> {
|
|
111
|
+
const startTime = Date.now();
|
|
112
|
+
|
|
113
|
+
const result: ImportResult = {
|
|
114
|
+
success: false,
|
|
115
|
+
caseNumber: '',
|
|
116
|
+
isReadOnly: true,
|
|
117
|
+
filesImported: 0,
|
|
118
|
+
annotationsImported: 0,
|
|
119
|
+
errors: [],
|
|
120
|
+
warnings: []
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
// Track import state for cleanup purposes
|
|
124
|
+
const importState: ImportState = {
|
|
125
|
+
uploadedFiles: [],
|
|
126
|
+
caseDataStored: false,
|
|
127
|
+
userProfileUpdated: false,
|
|
128
|
+
caseNumber: ''
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
let hashValidationPassed = false;
|
|
132
|
+
let signatureValidationPassed = false;
|
|
133
|
+
let signatureKeyId: string | undefined;
|
|
134
|
+
let parsedForensicManifest: SignedForensicManifest | undefined;
|
|
135
|
+
|
|
136
|
+
try {
|
|
137
|
+
onProgress?.('Parsing ZIP file', 10, 'Extracting archive contents...');
|
|
138
|
+
|
|
139
|
+
// Step 1: Parse ZIP file
|
|
140
|
+
const { caseData, imageFiles, imageIdMapping, metadata, cleanedContent } = await parseImportZip(zipFile, user);
|
|
141
|
+
parsedForensicManifest = metadata?.forensicManifest as SignedForensicManifest | undefined;
|
|
142
|
+
result.caseNumber = caseData.metadata.caseNumber;
|
|
143
|
+
importState.caseNumber = result.caseNumber;
|
|
144
|
+
|
|
145
|
+
// Start audit workflow
|
|
146
|
+
auditService.startWorkflow(result.caseNumber);
|
|
147
|
+
|
|
148
|
+
// Step 1.1: Clean up any existing read-only cases (only one allowed at a time)
|
|
149
|
+
onProgress?.('Checking existing read-only cases', 12, 'Cleaning up previous imports...');
|
|
150
|
+
try {
|
|
151
|
+
const existingReadOnlyCases = await listReadOnlyCases(user);
|
|
152
|
+
if (existingReadOnlyCases.length > 0) {
|
|
153
|
+
console.log(`Found ${existingReadOnlyCases.length} existing read-only case(s). Cleaning up before new import.`);
|
|
154
|
+
|
|
155
|
+
// Delete all existing read-only cases (data and user references)
|
|
156
|
+
const deletePromises = existingReadOnlyCases.map(async (existingCase: ReadOnlyCaseMetadata) => {
|
|
157
|
+
try {
|
|
158
|
+
await deleteReadOnlyCase(user, existingCase.caseNumber);
|
|
159
|
+
console.log(`Cleaned up existing read-only case: ${existingCase.caseNumber}`);
|
|
160
|
+
} catch (error) {
|
|
161
|
+
console.warn(`Failed to clean up existing read-only case ${existingCase.caseNumber}:`, error);
|
|
162
|
+
// Don't throw here - just warn, as we want to proceed with the new import
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
await Promise.all(deletePromises);
|
|
167
|
+
}
|
|
168
|
+
} catch (error) {
|
|
169
|
+
console.warn('Error during pre-import cleanup of existing read-only cases:', error);
|
|
170
|
+
// Don't fail the import due to cleanup issues
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Step 1.5: Validate hash if forensic metadata exists
|
|
174
|
+
if (parsedForensicManifest && cleanedContent) {
|
|
175
|
+
onProgress?.('Validating comprehensive integrity', 15, 'Checking all file hashes...');
|
|
176
|
+
|
|
177
|
+
const manifestForValidation = extractForensicManifestData(parsedForensicManifest);
|
|
178
|
+
if (!manifestForValidation) {
|
|
179
|
+
throw new Error(
|
|
180
|
+
'Forensic manifest structure is invalid. Import cannot proceed.'
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const signatureResult = await verifyForensicManifestSignature(parsedForensicManifest);
|
|
185
|
+
signatureValidationPassed = signatureResult.isValid;
|
|
186
|
+
signatureKeyId = signatureResult.keyId;
|
|
187
|
+
|
|
188
|
+
if (!signatureResult.isValid) {
|
|
189
|
+
throw new Error(
|
|
190
|
+
`Manifest signature validation failed: ${signatureResult.error || 'Unknown signature error'}. Import cannot proceed.`
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Extract image files for comprehensive validation
|
|
195
|
+
const imageBlobs: { [filename: string]: Blob } = {};
|
|
196
|
+
for (const [filename, blob] of Object.entries(imageFiles)) {
|
|
197
|
+
imageBlobs[filename] = blob;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Perform comprehensive validation
|
|
201
|
+
const validation = await validateForensicIntegrity(
|
|
202
|
+
cleanedContent,
|
|
203
|
+
imageBlobs,
|
|
204
|
+
manifestForValidation
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
if (!validation.isValid) {
|
|
208
|
+
throw new Error(
|
|
209
|
+
`Comprehensive integrity validation failed: ${validation.summary}. ` +
|
|
210
|
+
`Errors: ${validation.errors.join(', ')}. Import cannot proceed.`
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
hashValidationPassed = true;
|
|
215
|
+
onProgress?.(
|
|
216
|
+
'Complete integrity verified',
|
|
217
|
+
18,
|
|
218
|
+
`${validation.summary}. Signature verified${signatureKeyId ? ` (${signatureKeyId})` : ''}`
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
} else {
|
|
222
|
+
// No forensic manifest found - cannot import
|
|
223
|
+
throw new Error(
|
|
224
|
+
'No forensic manifest found in case export. This case export does not support comprehensive ' +
|
|
225
|
+
'integrity validation and cannot be imported. Please re-export the case with forensic protection enabled.'
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
onProgress?.('Validating case data', 20, `Case: ${result.caseNumber}`);
|
|
230
|
+
|
|
231
|
+
// Step 2a: Check if case already exists in user's regular cases (original analyst)
|
|
232
|
+
const existingRegularCase = await checkExistingCase(user, result.caseNumber);
|
|
233
|
+
if (existingRegularCase) {
|
|
234
|
+
throw new Error(`Case "${result.caseNumber}" already exists in your case list. You cannot import a case for review if you were the original analyst.`);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Step 2b: Check if read-only case already exists
|
|
238
|
+
const existingCase = await checkReadOnlyCaseExists(user, result.caseNumber);
|
|
239
|
+
if (existingCase && !options.overwriteExisting) {
|
|
240
|
+
throw new Error(`Read-only case "${result.caseNumber}" already exists. Use overwriteExisting option to replace it.`);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (existingCase) {
|
|
244
|
+
result.warnings?.push('Overwriting existing read-only case');
|
|
245
|
+
|
|
246
|
+
// Step 2c: Clean up existing read-only case data before importing new data
|
|
247
|
+
onProgress?.('Cleaning up existing case', 25, 'Removing existing case data...');
|
|
248
|
+
const cleanupSuccess = await deleteReadOnlyCase(user, result.caseNumber);
|
|
249
|
+
if (!cleanupSuccess) {
|
|
250
|
+
result.warnings?.push('Some existing case data may not have been fully cleaned up');
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
onProgress?.('Uploading images', 30, 'Processing image files...');
|
|
255
|
+
|
|
256
|
+
// Step 3: Upload all image files and create original image ID to new file ID mapping
|
|
257
|
+
const originalImageIdMapping = new Map<string, string>(); // originalImageId -> newFileId
|
|
258
|
+
const importedFiles = [];
|
|
259
|
+
|
|
260
|
+
let uploadedCount = 0;
|
|
261
|
+
const totalImages = Object.keys(imageFiles).length;
|
|
262
|
+
|
|
263
|
+
for (const [exportFilename, blob] of Object.entries(imageFiles)) {
|
|
264
|
+
try {
|
|
265
|
+
// Get the original image ID from the filename
|
|
266
|
+
const originalImageId = imageIdMapping[exportFilename];
|
|
267
|
+
|
|
268
|
+
if (!originalImageId) {
|
|
269
|
+
console.warn(`Could not extract image ID from filename: ${exportFilename}`);
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Find the original file entry to get the actual original filename
|
|
274
|
+
const originalFileEntry = caseData.files.find(f => f.fileData.id === originalImageId);
|
|
275
|
+
const originalFilename = originalFileEntry?.fileData.originalFilename || exportFilename;
|
|
276
|
+
|
|
277
|
+
const fileData = await uploadImageBlob(blob, originalFilename, (fname, progress) => {
|
|
278
|
+
const overallProgress = 30 + (uploadedCount / totalImages) * 40 + (progress / totalImages) * 0.4;
|
|
279
|
+
onProgress?.('Uploading images', overallProgress, `Uploading ${fname}...`);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
// Map original image ID to new file ID
|
|
283
|
+
originalImageIdMapping.set(originalImageId, fileData.id);
|
|
284
|
+
|
|
285
|
+
importedFiles.push(fileData);
|
|
286
|
+
importState.uploadedFiles.push(fileData);
|
|
287
|
+
uploadedCount++;
|
|
288
|
+
|
|
289
|
+
const overallProgress = 30 + (uploadedCount / totalImages) * 40;
|
|
290
|
+
onProgress?.('Uploading images', overallProgress, `Uploaded ${uploadedCount}/${totalImages} files`);
|
|
291
|
+
|
|
292
|
+
} catch (error) {
|
|
293
|
+
result.errors?.push(`Failed to upload ${exportFilename}: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
result.filesImported = importedFiles.length;
|
|
298
|
+
|
|
299
|
+
if (importedFiles.length === 0) {
|
|
300
|
+
throw new Error('No images were successfully uploaded');
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
onProgress?.('Storing case data', 75, 'Creating case structure...');
|
|
304
|
+
|
|
305
|
+
// Step 4: Store case data in R2
|
|
306
|
+
await storeCaseDataInR2(
|
|
307
|
+
user,
|
|
308
|
+
result.caseNumber,
|
|
309
|
+
caseData,
|
|
310
|
+
importedFiles,
|
|
311
|
+
originalImageIdMapping,
|
|
312
|
+
parsedForensicManifest
|
|
313
|
+
);
|
|
314
|
+
importState.caseDataStored = true;
|
|
315
|
+
|
|
316
|
+
onProgress?.('Importing annotations', 85, 'Processing annotations...');
|
|
317
|
+
|
|
318
|
+
// Step 5: Import annotations
|
|
319
|
+
result.annotationsImported = await importAnnotations(user, result.caseNumber, caseData, originalImageIdMapping);
|
|
320
|
+
|
|
321
|
+
onProgress?.('Updating user profile', 95, 'Finalizing import...');
|
|
322
|
+
|
|
323
|
+
// Step 6: Add read-only case to user profile
|
|
324
|
+
const caseMetadata: ReadOnlyCaseMetadata = {
|
|
325
|
+
caseNumber: result.caseNumber,
|
|
326
|
+
importedAt: new Date().toISOString(),
|
|
327
|
+
originalExportDate: caseData.metadata.exportDate,
|
|
328
|
+
originalExportedBy: caseData.metadata.exportedBy || 'Unknown',
|
|
329
|
+
sourceHash: parsedForensicManifest?.manifestHash,
|
|
330
|
+
sourceManifestVersion: parsedForensicManifest?.manifestVersion,
|
|
331
|
+
sourceSignatureKeyId: parsedForensicManifest?.signature?.keyId,
|
|
332
|
+
sourceSignatureValid: signatureValidationPassed,
|
|
333
|
+
isReadOnly: true
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
await addReadOnlyCaseToUser(user, caseMetadata);
|
|
337
|
+
importState.userProfileUpdated = true;
|
|
338
|
+
|
|
339
|
+
onProgress?.('Import complete', 100, 'Case successfully imported for review');
|
|
340
|
+
|
|
341
|
+
result.success = true;
|
|
342
|
+
|
|
343
|
+
// Log successful case import
|
|
344
|
+
const endTime = Date.now();
|
|
345
|
+
await auditService.logCaseImport(
|
|
346
|
+
user,
|
|
347
|
+
result.caseNumber,
|
|
348
|
+
zipFile.name,
|
|
349
|
+
'success',
|
|
350
|
+
hashValidationPassed,
|
|
351
|
+
[],
|
|
352
|
+
undefined, // Don't use for self-confirmation prevention for read-only imports
|
|
353
|
+
{
|
|
354
|
+
processingTimeMs: endTime - startTime,
|
|
355
|
+
fileSizeBytes: zipFile.size,
|
|
356
|
+
validationStepsCompleted: result.filesImported + result.annotationsImported,
|
|
357
|
+
validationStepsFailed: 0
|
|
358
|
+
},
|
|
359
|
+
true, // Exporter UID was validated during zip parsing
|
|
360
|
+
{
|
|
361
|
+
present: !!parsedForensicManifest,
|
|
362
|
+
valid: signatureValidationPassed,
|
|
363
|
+
keyId: signatureKeyId
|
|
364
|
+
}
|
|
365
|
+
);
|
|
366
|
+
|
|
367
|
+
auditService.endWorkflow();
|
|
368
|
+
|
|
369
|
+
return result;
|
|
370
|
+
|
|
371
|
+
} catch (error) {
|
|
372
|
+
console.error('Case import failed:', error);
|
|
373
|
+
result.success = false;
|
|
374
|
+
result.errors?.push(error instanceof Error ? error.message : 'Unknown error occurred during import');
|
|
375
|
+
|
|
376
|
+
// Log failed case import
|
|
377
|
+
const endTime = Date.now();
|
|
378
|
+
await auditService.logCaseImport(
|
|
379
|
+
user,
|
|
380
|
+
result.caseNumber || 'unknown',
|
|
381
|
+
zipFile.name,
|
|
382
|
+
'failure',
|
|
383
|
+
hashValidationPassed, // Use actual hash validation result even for failures
|
|
384
|
+
result.errors || [],
|
|
385
|
+
undefined, // Don't use for self-confirmation prevention for read-only imports
|
|
386
|
+
{
|
|
387
|
+
processingTimeMs: endTime - startTime,
|
|
388
|
+
fileSizeBytes: zipFile.size
|
|
389
|
+
},
|
|
390
|
+
false, // If import failed, exporter UID validation may not have completed
|
|
391
|
+
{
|
|
392
|
+
present: !!parsedForensicManifest,
|
|
393
|
+
valid: signatureValidationPassed,
|
|
394
|
+
keyId: signatureKeyId
|
|
395
|
+
}
|
|
396
|
+
);
|
|
397
|
+
|
|
398
|
+
auditService.endWorkflow();
|
|
399
|
+
|
|
400
|
+
// Cleanup any partially imported data
|
|
401
|
+
if (importState.uploadedFiles.length > 0 || importState.caseDataStored || importState.userProfileUpdated) {
|
|
402
|
+
console.log('Import failed, cleaning up partial data...');
|
|
403
|
+
try {
|
|
404
|
+
const cleanupWarnings = await cleanupPartialImport(user, importState, onProgress);
|
|
405
|
+
if (cleanupWarnings.length > 0) {
|
|
406
|
+
result.warnings?.push(...cleanupWarnings);
|
|
407
|
+
console.warn('Cleanup completed with warnings:', cleanupWarnings);
|
|
408
|
+
} else {
|
|
409
|
+
console.log('Cleanup completed successfully');
|
|
410
|
+
}
|
|
411
|
+
} catch (cleanupError) {
|
|
412
|
+
const cleanupErrorMsg = `Cleanup failed: ${cleanupError instanceof Error ? cleanupError.message : 'Unknown cleanup error'}`;
|
|
413
|
+
result.warnings?.push(cleanupErrorMsg);
|
|
414
|
+
console.error('Cleanup failed:', cleanupError);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
return result;
|
|
419
|
+
}
|
|
420
|
+
}
|