@striae-org/striae 3.2.2 → 3.3.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.
Files changed (37) hide show
  1. package/app/components/actions/case-export/download-handlers.ts +51 -3
  2. package/app/components/actions/case-import/confirmation-import.ts +41 -17
  3. package/app/components/actions/case-import/confirmation-package.ts +86 -0
  4. package/app/components/actions/case-import/index.ts +1 -0
  5. package/app/components/actions/case-import/orchestrator.ts +12 -2
  6. package/app/components/actions/case-import/validation.ts +5 -98
  7. package/app/components/actions/case-import/zip-processing.ts +44 -2
  8. package/app/components/actions/confirm-export.ts +44 -13
  9. package/app/components/form/form-button.tsx +1 -1
  10. package/app/components/form/form.module.css +9 -0
  11. package/app/components/public-signing-key-modal/public-signing-key-modal.module.css +163 -49
  12. package/app/components/public-signing-key-modal/public-signing-key-modal.tsx +365 -88
  13. package/app/components/sidebar/case-export/case-export.tsx +2 -54
  14. package/app/components/sidebar/case-import/case-import.tsx +18 -6
  15. package/app/components/sidebar/case-import/hooks/useFilePreview.ts +6 -4
  16. package/app/components/sidebar/case-import/utils/file-validation.ts +57 -2
  17. package/app/components/sidebar/cases/case-sidebar.tsx +101 -46
  18. package/app/components/sidebar/cases/cases.module.css +101 -18
  19. package/app/components/sidebar/notes/notes.module.css +33 -13
  20. package/app/components/user/manage-profile.tsx +1 -1
  21. package/app/components/user/mfa-phone-update.tsx +15 -12
  22. package/app/root.tsx +2 -2
  23. package/app/routes/auth/login.tsx +129 -6
  24. package/app/utils/SHA256.ts +5 -1
  25. package/app/utils/confirmation-signature.ts +5 -1
  26. package/app/utils/export-verification.ts +353 -0
  27. package/app/utils/signature-utils.ts +74 -4
  28. package/package.json +7 -4
  29. package/public/favicon.ico +0 -0
  30. package/public/icon-256.png +0 -0
  31. package/public/icon-512.png +0 -0
  32. package/public/manifest.json +39 -0
  33. package/public/shortcut.png +0 -0
  34. package/public/social-image.png +0 -0
  35. package/react-router.config.ts +5 -0
  36. package/workers/pdf-worker/scripts/generate-assets.js +94 -0
  37. package/public/favicon.svg +0 -9
@@ -1,5 +1,5 @@
1
- import { useState, useEffect } from 'react';
2
- import { Link, useSearchParams } from 'react-router';
1
+ import { useState, useEffect, useRef } from 'react';
2
+ import { Link, useSearchParams, type MetaFunction } from 'react-router';
3
3
  import { auth } from '~/services/firebase';
4
4
  import {
5
5
  signInWithEmailAndPassword,
@@ -18,6 +18,7 @@ import { EmailActionHandler } from '~/routes/auth/emailActionHandler';
18
18
  import { handleAuthError } from '~/services/firebase/errors';
19
19
  import { MFAVerification } from '~/components/auth/mfa-verification';
20
20
  import { MFAEnrollment } from '~/components/auth/mfa-enrollment';
21
+ import { Toast } from '~/components/toast/toast';
21
22
  import { Icon } from '~/components/icon/icon';
22
23
  import styles from './login.module.css';
23
24
  import { Striae } from '~/routes/striae/striae';
@@ -28,23 +29,123 @@ import { evaluatePasswordPolicy } from '~/utils/password-policy';
28
29
  import { buildActionCodeSettings } from '~/utils/auth-action-settings';
29
30
  import { userHasMFA } from '~/utils/mfa';
30
31
 
31
- export const meta = () => {
32
- const titleText = 'Striae | Welcome to Striae';
33
- const description = 'Login to your Striae account to access your projects and data';
32
+ const APP_CANONICAL_ORIGIN = 'https://app.striae.org';
33
+ const SOCIAL_IMAGE_PATH = '/social-image.png';
34
+ const SOCIAL_IMAGE_ALT = 'Striae forensic annotation and comparison workspace';
35
+ const LOGIN_PATH_ALIASES = new Set(['/auth', '/auth/', '/auth/login', '/auth/login/']);
36
+
37
+ type AuthMetaContent = {
38
+ title: string;
39
+ description: string;
40
+ robots: string;
41
+ };
42
+
43
+ const getCanonicalPath = (pathname: string): string => {
44
+ if (!pathname || LOGIN_PATH_ALIASES.has(pathname)) {
45
+ return '/';
46
+ }
47
+
48
+ return pathname.startsWith('/') ? pathname : `/${pathname}`;
49
+ };
50
+
51
+ const getAuthMetaContent = (mode: string | null, hasActionCode: boolean): AuthMetaContent => {
52
+ if (!mode && !hasActionCode) {
53
+ return {
54
+ title: 'Striae | Secure Login for Firearms Examiners',
55
+ description: 'Sign in to Striae to access your forensic annotation workspace, case files, and comparison tools.',
56
+ robots: 'index,follow,max-image-preview:large,max-snippet:-1,max-video-preview:-1',
57
+ };
58
+ }
59
+
60
+ if (mode === 'resetPassword') {
61
+ return {
62
+ title: 'Striae | Reset Your Password',
63
+ description: 'Use this secure page to reset your Striae account password and restore access to your workspace.',
64
+ robots: 'noindex,nofollow,noarchive',
65
+ };
66
+ }
67
+
68
+ if (mode === 'verifyEmail') {
69
+ return {
70
+ title: 'Striae | Verify Your Email Address',
71
+ description: 'Confirm your email address to complete Striae account activation and continue securely.',
72
+ robots: 'noindex,nofollow,noarchive',
73
+ };
74
+ }
75
+
76
+ if (mode === 'recoverEmail') {
77
+ return {
78
+ title: 'Striae | Recover Email Access',
79
+ description: 'Complete your Striae account email recovery steps securely.',
80
+ robots: 'noindex,nofollow,noarchive',
81
+ };
82
+ }
83
+
84
+ return {
85
+ title: 'Striae | Account Action',
86
+ description: 'Complete your Striae account action securely.',
87
+ robots: 'noindex,nofollow,noarchive',
88
+ };
89
+ };
90
+
91
+ export const meta: MetaFunction = ({ location }) => {
92
+ const searchParams = new URLSearchParams(location.search);
93
+ const mode = searchParams.get('mode');
94
+ const hasActionCode = Boolean(searchParams.get('oobCode'));
95
+
96
+ const canonicalPath = getCanonicalPath(location.pathname);
97
+ const canonicalHref = `${APP_CANONICAL_ORIGIN}${canonicalPath}`;
98
+ const socialImageHref = `${APP_CANONICAL_ORIGIN}${SOCIAL_IMAGE_PATH}`;
99
+ const { title, description, robots } = getAuthMetaContent(mode, hasActionCode);
34
100
 
35
101
  return [
36
- { title: titleText },
102
+ { title },
37
103
  { name: 'description', content: description },
104
+ { name: 'robots', content: robots },
105
+ { property: 'og:site_name', content: 'Striae' },
106
+ { property: 'og:type', content: 'website' },
107
+ { property: 'og:url', content: canonicalHref },
108
+ { property: 'og:title', content: title },
109
+ { property: 'og:description', content: description },
110
+ { property: 'og:image', content: socialImageHref },
111
+ { property: 'og:image:secure_url', content: socialImageHref },
112
+ { property: 'og:image:alt', content: SOCIAL_IMAGE_ALT },
113
+ { name: 'twitter:card', content: 'summary_large_image' },
114
+ { name: 'twitter:title', content: title },
115
+ { name: 'twitter:description', content: description },
116
+ { name: 'twitter:image', content: socialImageHref },
117
+ { name: 'twitter:image:alt', content: SOCIAL_IMAGE_ALT },
118
+ { tagName: 'link', rel: 'canonical', href: canonicalHref },
38
119
  ];
39
120
  };
40
121
 
41
122
  const SUPPORTED_EMAIL_ACTION_MODES = new Set(['resetPassword', 'verifyEmail', 'recoverEmail']);
42
123
 
124
+ const getUserFirstName = (user: User): string => {
125
+ const displayName = user.displayName?.trim();
126
+ if (displayName) {
127
+ const [firstName] = displayName.split(/\s+/);
128
+ if (firstName) {
129
+ return firstName;
130
+ }
131
+ }
132
+
133
+ const emailPrefix = user.email?.split('@')[0]?.trim();
134
+ if (emailPrefix) {
135
+ return emailPrefix;
136
+ }
137
+
138
+ return 'User';
139
+ };
140
+
43
141
  export const Login = () => {
44
142
  const [searchParams] = useSearchParams();
143
+ const shouldShowWelcomeToastRef = useRef(false);
45
144
 
46
145
  const [error, setError] = useState('');
47
146
  const [success, setSuccess] = useState('');
147
+ const [welcomeToastMessage, setWelcomeToastMessage] = useState('');
148
+ const [isWelcomeToastVisible, setIsWelcomeToastVisible] = useState(false);
48
149
  const [isLogin, setIsLogin] = useState(true);
49
150
  const [isLoading, setIsLoading] = useState(false);
50
151
  const [isCheckingUser, setIsCheckingUser] = useState(false);
@@ -180,6 +281,12 @@ export const Login = () => {
180
281
 
181
282
  console.log("User signed in:", currentUser.email);
182
283
  setShowMfaEnrollment(false);
284
+
285
+ if (shouldShowWelcomeToastRef.current) {
286
+ setWelcomeToastMessage(`Welcome to Striae, ${getUserFirstName(currentUser)}!`);
287
+ setIsWelcomeToastVisible(true);
288
+ shouldShowWelcomeToastRef.current = false;
289
+ }
183
290
 
184
291
  // Log successful login audit
185
292
  try {
@@ -198,6 +305,8 @@ export const Login = () => {
198
305
  setUser(null);
199
306
  setShowMfaEnrollment(false);
200
307
  setIsCheckingUser(false);
308
+ setIsWelcomeToastVisible(false);
309
+ shouldShowWelcomeToastRef.current = false;
201
310
  }
202
311
  });
203
312
 
@@ -339,6 +448,7 @@ export const Login = () => {
339
448
  // Don't sign out - let user stay logged in but unverified to see verification screen
340
449
  } else {
341
450
  // Login
451
+ shouldShowWelcomeToastRef.current = true;
342
452
  try {
343
453
  await signInWithEmailAndPassword(auth, email, password);
344
454
  } catch (loginError: unknown) {
@@ -356,10 +466,12 @@ export const Login = () => {
356
466
  setIsLoading(false);
357
467
  return;
358
468
  }
469
+ shouldShowWelcomeToastRef.current = false;
359
470
  throw loginError; // Re-throw non-MFA errors
360
471
  }
361
472
  }
362
473
  } catch (err) {
474
+ shouldShowWelcomeToastRef.current = false;
363
475
  const { message } = handleAuthError(err);
364
476
  setError(message);
365
477
 
@@ -408,6 +520,8 @@ export const Login = () => {
408
520
  setShowMfaEnrollment(false);
409
521
  setShowMfaVerification(false);
410
522
  setMfaResolver(null);
523
+ setIsWelcomeToastVisible(false);
524
+ shouldShowWelcomeToastRef.current = false;
411
525
  } catch (err) {
412
526
  console.error('Sign out error:', err);
413
527
  }
@@ -648,6 +762,15 @@ export const Login = () => {
648
762
  mandatory={true}
649
763
  />
650
764
  )}
765
+
766
+ {!shouldHandleEmailAction && (
767
+ <Toast
768
+ message={welcomeToastMessage}
769
+ type="success"
770
+ isVisible={isWelcomeToastVisible}
771
+ onClose={() => setIsWelcomeToastVisible(false)}
772
+ />
773
+ )}
651
774
 
652
775
  </>
653
776
  );
@@ -120,7 +120,8 @@ export function createManifestSigningPayload(
120
120
  * Verify manifest signature using configured public key(s).
121
121
  */
122
122
  export async function verifyForensicManifestSignature(
123
- manifest: Partial<SignedForensicManifest>
123
+ manifest: Partial<SignedForensicManifest>,
124
+ verificationPublicKeyPem?: string
124
125
  ): Promise<ManifestSignatureVerificationResult> {
125
126
  if (!manifest.signature) {
126
127
  return {
@@ -158,6 +159,9 @@ export async function verifyForensicManifestSignature(
158
159
  noVerificationKeyPrefix: 'No verification key configured for key ID',
159
160
  invalidPublicKeyError: 'Manifest signature verification failed: invalid public key',
160
161
  verificationFailedError: 'Manifest signature verification failed'
162
+ },
163
+ {
164
+ verificationPublicKeyPem
161
165
  }
162
166
  );
163
167
  }
@@ -148,7 +148,8 @@ export function createConfirmationSigningPayload(
148
148
  }
149
149
 
150
150
  export async function verifyConfirmationSignature(
151
- confirmationData: Partial<ConfirmationImportData>
151
+ confirmationData: Partial<ConfirmationImportData>,
152
+ verificationPublicKeyPem?: string
152
153
  ): Promise<ManifestSignatureVerificationResult> {
153
154
  const signature = confirmationData.metadata?.signature as ForensicManifestSignature | undefined;
154
155
  const signatureVersion = confirmationData.metadata?.signatureVersion;
@@ -188,6 +189,9 @@ export async function verifyConfirmationSignature(
188
189
  noVerificationKeyPrefix: 'No verification key configured for key ID',
189
190
  invalidPublicKeyError: 'Confirmation signature verification failed: invalid public key',
190
191
  verificationFailedError: 'Confirmation signature verification failed'
192
+ },
193
+ {
194
+ verificationPublicKeyPem
191
195
  }
192
196
  );
193
197
  }
@@ -0,0 +1,353 @@
1
+ import { type ConfirmationImportData } from '~/types';
2
+ import {
3
+ extractForensicManifestData,
4
+ type SignedForensicManifest,
5
+ calculateSHA256Secure,
6
+ validateCaseIntegritySecure,
7
+ verifyForensicManifestSignature
8
+ } from './SHA256';
9
+ import { verifyConfirmationSignature } from './confirmation-signature';
10
+
11
+ export interface ExportVerificationResult {
12
+ isValid: boolean;
13
+ message: string;
14
+ exportType?: 'case-zip' | 'confirmation';
15
+ }
16
+
17
+ const CASE_EXPORT_FILE_REGEX = /_data\.(json|csv)$/i;
18
+ const CONFIRMATION_EXPORT_FILE_REGEX = /^confirmation-data-.*\.json$/i;
19
+
20
+ function createVerificationResult(
21
+ isValid: boolean,
22
+ message: string,
23
+ exportType?: ExportVerificationResult['exportType']
24
+ ): ExportVerificationResult {
25
+ return {
26
+ isValid,
27
+ message,
28
+ exportType
29
+ };
30
+ }
31
+
32
+ function getSignatureFailureMessage(
33
+ error: string | undefined,
34
+ targetLabel: 'export ZIP' | 'confirmation file'
35
+ ): string {
36
+ if (error?.includes('invalid public key')) {
37
+ return 'The selected PEM file is not a valid public key.';
38
+ }
39
+
40
+ if (error?.includes('Unsupported')) {
41
+ return `This ${targetLabel} uses an unsupported signature format.`;
42
+ }
43
+
44
+ if (error?.includes('Missing')) {
45
+ return `This ${targetLabel} is missing required signature information.`;
46
+ }
47
+
48
+ return `The ${targetLabel} signature did not verify with the selected public key.`;
49
+ }
50
+
51
+ function isConfirmationImportCandidate(candidate: unknown): candidate is Partial<ConfirmationImportData> {
52
+ if (!candidate || typeof candidate !== 'object') {
53
+ return false;
54
+ }
55
+
56
+ const confirmationCandidate = candidate as Partial<ConfirmationImportData>;
57
+ return (
58
+ !!confirmationCandidate.metadata &&
59
+ typeof confirmationCandidate.metadata.hash === 'string' &&
60
+ !!confirmationCandidate.confirmations &&
61
+ typeof confirmationCandidate.confirmations === 'object'
62
+ );
63
+ }
64
+
65
+ /**
66
+ * Remove forensic warning from content for hash validation.
67
+ * Supports the warning formats added to JSON and CSV case exports.
68
+ */
69
+ export function removeForensicWarning(content: string): string {
70
+ const jsonForensicWarningRegex = /^\/\*\s*CASE\s+DATA\s+WARNING[\s\S]*?\*\/\s*\r?\n*/;
71
+ 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}/;
72
+
73
+ let cleaned = content;
74
+
75
+ if (jsonForensicWarningRegex.test(content)) {
76
+ cleaned = content.replace(jsonForensicWarningRegex, '');
77
+ } else if (csvForensicWarningRegex.test(content)) {
78
+ cleaned = content.replace(csvForensicWarningRegex, '');
79
+ } else if (content.startsWith('"CASE DATA WARNING:')) {
80
+ const match = content.match(/^"[^"]*"(?:\r?\n)+/);
81
+ if (match) {
82
+ cleaned = content.substring(match[0].length);
83
+ }
84
+ }
85
+
86
+ return cleaned.replace(/^\s+/, '');
87
+ }
88
+
89
+ /**
90
+ * Validate the stored confirmation hash without exposing expected/actual values.
91
+ */
92
+ export async function validateConfirmationHash(jsonContent: string, expectedHash: string): Promise<boolean> {
93
+ try {
94
+ if (!expectedHash || typeof expectedHash !== 'string') {
95
+ return false;
96
+ }
97
+
98
+ const data = JSON.parse(jsonContent);
99
+ const dataWithoutHash = {
100
+ ...data,
101
+ metadata: {
102
+ ...data.metadata,
103
+ hash: undefined
104
+ }
105
+ };
106
+
107
+ delete dataWithoutHash.metadata.hash;
108
+ delete dataWithoutHash.metadata.signature;
109
+ delete dataWithoutHash.metadata.signatureVersion;
110
+
111
+ const contentForHash = JSON.stringify(dataWithoutHash, null, 2);
112
+ const actualHash = await calculateSHA256Secure(contentForHash);
113
+
114
+ return actualHash.toUpperCase() === expectedHash.toUpperCase();
115
+ } catch {
116
+ return false;
117
+ }
118
+ }
119
+
120
+ async function verifyCaseZipExport(
121
+ file: File,
122
+ verificationPublicKeyPem: string
123
+ ): Promise<ExportVerificationResult> {
124
+ const JSZip = (await import('jszip')).default;
125
+
126
+ try {
127
+ const zip = await JSZip.loadAsync(file);
128
+ const dataFiles = Object.keys(zip.files).filter((name) => CASE_EXPORT_FILE_REGEX.test(name));
129
+
130
+ if (dataFiles.length !== 1) {
131
+ return createVerificationResult(
132
+ false,
133
+ 'The ZIP file must contain exactly one case export data file.',
134
+ 'case-zip'
135
+ );
136
+ }
137
+
138
+ const dataContent = await zip.file(dataFiles[0])?.async('text');
139
+ if (!dataContent) {
140
+ return createVerificationResult(false, 'The ZIP data file could not be read.', 'case-zip');
141
+ }
142
+
143
+ const manifestContent = await zip.file('FORENSIC_MANIFEST.json')?.async('text');
144
+ if (!manifestContent) {
145
+ return createVerificationResult(
146
+ false,
147
+ 'The ZIP file does not contain FORENSIC_MANIFEST.json.',
148
+ 'case-zip'
149
+ );
150
+ }
151
+
152
+ const forensicManifest = JSON.parse(manifestContent) as SignedForensicManifest;
153
+ const manifestData = extractForensicManifestData(forensicManifest);
154
+
155
+ if (!manifestData) {
156
+ return createVerificationResult(false, 'The forensic manifest is malformed.', 'case-zip');
157
+ }
158
+
159
+ const cleanedContent = removeForensicWarning(dataContent);
160
+ const imageFiles: Record<string, Blob> = {};
161
+
162
+ await Promise.all(
163
+ Object.keys(zip.files).map(async (path) => {
164
+ if (!path.startsWith('images/') || path.endsWith('/')) {
165
+ return;
166
+ }
167
+
168
+ const zipEntry = zip.file(path);
169
+ if (!zipEntry) {
170
+ return;
171
+ }
172
+
173
+ imageFiles[path.replace('images/', '')] = await zipEntry.async('blob');
174
+ })
175
+ );
176
+
177
+ const signatureResult = await verifyForensicManifestSignature(forensicManifest, verificationPublicKeyPem);
178
+ const integrityResult = await validateCaseIntegritySecure(cleanedContent, imageFiles, manifestData);
179
+
180
+ if (signatureResult.isValid && integrityResult.isValid) {
181
+ return createVerificationResult(
182
+ true,
183
+ 'The export ZIP passed signature and integrity verification.',
184
+ 'case-zip'
185
+ );
186
+ }
187
+
188
+ if (!signatureResult.isValid && !integrityResult.isValid) {
189
+ return createVerificationResult(
190
+ false,
191
+ 'The export ZIP failed signature and integrity verification.',
192
+ 'case-zip'
193
+ );
194
+ }
195
+
196
+ if (!signatureResult.isValid) {
197
+ return createVerificationResult(
198
+ false,
199
+ getSignatureFailureMessage(signatureResult.error, 'export ZIP'),
200
+ 'case-zip'
201
+ );
202
+ }
203
+
204
+ return createVerificationResult(false, 'The export ZIP failed integrity verification.', 'case-zip');
205
+ } catch {
206
+ return createVerificationResult(
207
+ false,
208
+ 'The ZIP file could not be read as a supported Striae export.',
209
+ 'case-zip'
210
+ );
211
+ }
212
+ }
213
+
214
+ async function verifyConfirmationExport(
215
+ file: File,
216
+ verificationPublicKeyPem: string
217
+ ): Promise<ExportVerificationResult> {
218
+ try {
219
+ const fileContent = await file.text();
220
+ return verifyConfirmationContent(fileContent, verificationPublicKeyPem);
221
+ } catch {
222
+ return createVerificationResult(
223
+ false,
224
+ 'The JSON file could not be read as a supported Striae confirmation export.',
225
+ 'confirmation'
226
+ );
227
+ }
228
+ }
229
+
230
+ async function verifyConfirmationContent(
231
+ fileContent: string,
232
+ verificationPublicKeyPem: string
233
+ ): Promise<ExportVerificationResult> {
234
+ try {
235
+ const parsedContent = JSON.parse(fileContent) as unknown;
236
+
237
+ if (!isConfirmationImportCandidate(parsedContent)) {
238
+ return createVerificationResult(
239
+ false,
240
+ 'The JSON file is not a supported Striae confirmation export.',
241
+ 'confirmation'
242
+ );
243
+ }
244
+
245
+ const confirmationData = parsedContent as Partial<ConfirmationImportData>;
246
+ const hashValid = await validateConfirmationHash(fileContent, confirmationData.metadata!.hash);
247
+ const signatureResult = await verifyConfirmationSignature(confirmationData, verificationPublicKeyPem);
248
+
249
+ if (hashValid && signatureResult.isValid) {
250
+ return createVerificationResult(
251
+ true,
252
+ 'The confirmation file passed signature and integrity verification.',
253
+ 'confirmation'
254
+ );
255
+ }
256
+
257
+ if (!signatureResult.isValid && signatureResult.error === 'Confirmation content is malformed') {
258
+ return createVerificationResult(
259
+ false,
260
+ 'The JSON file is not a supported Striae confirmation export.',
261
+ 'confirmation'
262
+ );
263
+ }
264
+
265
+ if (!hashValid && !signatureResult.isValid) {
266
+ return createVerificationResult(
267
+ false,
268
+ 'The confirmation file failed signature and integrity verification.',
269
+ 'confirmation'
270
+ );
271
+ }
272
+
273
+ if (!signatureResult.isValid) {
274
+ return createVerificationResult(
275
+ false,
276
+ getSignatureFailureMessage(signatureResult.error, 'confirmation file'),
277
+ 'confirmation'
278
+ );
279
+ }
280
+
281
+ return createVerificationResult(
282
+ false,
283
+ 'The confirmation file failed integrity verification.',
284
+ 'confirmation'
285
+ );
286
+ } catch {
287
+ return createVerificationResult(
288
+ false,
289
+ 'The confirmation content could not be read as a supported Striae confirmation export.',
290
+ 'confirmation'
291
+ );
292
+ }
293
+ }
294
+
295
+ async function verifyConfirmationZipExport(
296
+ file: File,
297
+ verificationPublicKeyPem: string
298
+ ): Promise<ExportVerificationResult> {
299
+ const JSZip = (await import('jszip')).default;
300
+
301
+ try {
302
+ const zip = await JSZip.loadAsync(file);
303
+ const confirmationFiles = Object.keys(zip.files).filter((name) => CONFIRMATION_EXPORT_FILE_REGEX.test(name));
304
+
305
+ if (confirmationFiles.length !== 1) {
306
+ return createVerificationResult(
307
+ false,
308
+ 'The ZIP file is not a supported Striae confirmation export package.'
309
+ );
310
+ }
311
+
312
+ const confirmationContent = await zip.file(confirmationFiles[0])?.async('text');
313
+ if (!confirmationContent) {
314
+ return createVerificationResult(
315
+ false,
316
+ 'The confirmation JSON file inside the ZIP could not be read.',
317
+ 'confirmation'
318
+ );
319
+ }
320
+
321
+ return verifyConfirmationContent(confirmationContent, verificationPublicKeyPem);
322
+ } catch {
323
+ return createVerificationResult(
324
+ false,
325
+ 'The ZIP file could not be read as a supported Striae export.'
326
+ );
327
+ }
328
+ }
329
+
330
+ export async function verifyExportFile(
331
+ file: File,
332
+ verificationPublicKeyPem: string
333
+ ): Promise<ExportVerificationResult> {
334
+ const lowerName = file.name.toLowerCase();
335
+
336
+ if (lowerName.endsWith('.zip')) {
337
+ const confirmationZipResult = await verifyConfirmationZipExport(file, verificationPublicKeyPem);
338
+ if (confirmationZipResult.exportType === 'confirmation' || confirmationZipResult.isValid) {
339
+ return confirmationZipResult;
340
+ }
341
+
342
+ return verifyCaseZipExport(file, verificationPublicKeyPem);
343
+ }
344
+
345
+ if (lowerName.endsWith('.json')) {
346
+ return verifyConfirmationExport(file, verificationPublicKeyPem);
347
+ }
348
+
349
+ return createVerificationResult(
350
+ false,
351
+ 'Select a confirmation JSON/ZIP file or a case export ZIP file.'
352
+ );
353
+ }