@striae-org/striae 5.3.0 → 5.3.2

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 (31) hide show
  1. package/.env.example +3 -0
  2. package/app/components/actions/case-export/core-export.ts +3 -0
  3. package/app/components/actions/case-export/download-handlers.ts +1 -1
  4. package/app/components/actions/case-import/confirmation-import.ts +62 -22
  5. package/app/components/actions/case-import/confirmation-package.ts +68 -1
  6. package/app/components/actions/case-import/index.ts +1 -1
  7. package/app/components/actions/case-import/orchestrator.ts +78 -53
  8. package/app/components/actions/case-import/zip-processing.ts +157 -407
  9. package/app/components/actions/generate-pdf.ts +22 -0
  10. package/app/components/navbar/case-modals/export-case-modal.module.css +27 -0
  11. package/app/components/navbar/case-modals/export-case-modal.tsx +132 -0
  12. package/app/components/navbar/case-modals/export-confirmations-modal.module.css +24 -0
  13. package/app/components/navbar/case-modals/export-confirmations-modal.tsx +108 -0
  14. package/app/components/sidebar/case-import/components/CasePreviewSection.tsx +1 -9
  15. package/app/components/sidebar/case-import/components/ConfirmationPreviewSection.tsx +36 -5
  16. package/app/components/sidebar/case-import/hooks/useFilePreview.ts +5 -9
  17. package/app/components/sidebar/case-import/index.ts +1 -4
  18. package/app/routes/auth/login.tsx +22 -103
  19. package/app/routes/striae/striae.tsx +77 -13
  20. package/app/types/case.ts +1 -0
  21. package/app/types/export.ts +1 -0
  22. package/app/types/import.ts +10 -0
  23. package/functions/api/image/[[path]].ts +19 -3
  24. package/package.json +1 -1
  25. package/workers/audit-worker/wrangler.jsonc.example +1 -1
  26. package/workers/data-worker/wrangler.jsonc.example +1 -1
  27. package/workers/image-worker/src/image-worker.example.ts +36 -2
  28. package/workers/image-worker/wrangler.jsonc.example +1 -1
  29. package/workers/pdf-worker/wrangler.jsonc.example +1 -1
  30. package/workers/user-worker/wrangler.jsonc.example +1 -1
  31. package/wrangler.toml.example +1 -1
@@ -5,6 +5,8 @@ import { Navbar } from '~/components/navbar/navbar';
5
5
  import { RenameCaseModal } from '~/components/navbar/case-modals/rename-case-modal';
6
6
  import { ArchiveCaseModal } from '~/components/navbar/case-modals/archive-case-modal';
7
7
  import { OpenCaseModal } from '~/components/navbar/case-modals/open-case-modal';
8
+ import { ExportCaseModal } from '~/components/navbar/case-modals/export-case-modal';
9
+ import { ExportConfirmationsModal } from '~/components/navbar/case-modals/export-confirmations-modal';
8
10
  import { Toolbar } from '~/components/toolbar/toolbar';
9
11
  import { Canvas } from '~/components/canvas/canvas';
10
12
  import { Toast, type ToastType } from '~/components/toast/toast';
@@ -20,7 +22,7 @@ import { fetchUserApi } from '~/utils/api';
20
22
  import { type AnnotationData, type FileData } from '~/types';
21
23
  import { validateCaseNumber, renameCase, deleteCase, checkExistingCase, createNewCase, archiveCase, getCaseArchiveDetails } from '~/components/actions/case-manage';
22
24
  import { checkReadOnlyCaseExists, deleteReadOnlyCase } from '~/components/actions/case-review';
23
- import { canCreateCase } from '~/utils/data';
25
+ import { canCreateCase, getCaseConfirmationSummary } from '~/utils/data';
24
26
  import {
25
27
  resolveEarliestAnnotationTimestamp,
26
28
  CREATE_READ_ONLY_CASE_EXISTS_ERROR,
@@ -91,6 +93,14 @@ export const Striae = ({ user }: StriaePage) => {
91
93
  const [isOpeningCase, setIsOpeningCase] = useState(false);
92
94
  const [openCaseHelperText, setOpenCaseHelperText] = useState('');
93
95
  const [isArchiveCaseModalOpen, setIsArchiveCaseModalOpen] = useState(false);
96
+ const [isExportCaseModalOpen, setIsExportCaseModalOpen] = useState(false);
97
+ const [isExportingCase, setIsExportingCase] = useState(false);
98
+ const [isExportConfirmationsModalOpen, setIsExportConfirmationsModalOpen] = useState(false);
99
+ const [isExportingConfirmations, setIsExportingConfirmations] = useState(false);
100
+ const [exportConfirmationStats, setExportConfirmationStats] = useState<{
101
+ confirmedCount: number;
102
+ unconfirmedCount: number;
103
+ } | null>(null);
94
104
  const [archiveDetails, setArchiveDetails] = useState<{
95
105
  archived: boolean;
96
106
  archivedAt?: string;
@@ -276,6 +286,7 @@ export const Striae = ({ user }: StriaePage) => {
276
286
 
277
287
  const handleExport = async (
278
288
  exportCaseNumber: string,
289
+ designatedReviewerEmail?: string,
279
290
  onProgress?: (progress: number, label: string) => void
280
291
  ) => {
281
292
  if (!exportCaseNumber) {
@@ -288,15 +299,20 @@ export const Striae = ({ user }: StriaePage) => {
288
299
  try {
289
300
  const caseExportActions = await loadCaseExportActions();
290
301
 
291
- await caseExportActions.downloadCaseAsZip(user, exportCaseNumber, (progress) => {
292
- const roundedProgress = Math.round(progress);
293
- const label = getExportProgressLabel(progress);
294
- setToastType('loading');
295
- setToastMessage(`Exporting case ${exportCaseNumber}... ${label} (${roundedProgress}%)`);
296
- setToastDuration(0);
297
- setShowToast(true);
298
- onProgress?.(roundedProgress, label);
299
- });
302
+ await caseExportActions.downloadCaseAsZip(
303
+ user,
304
+ exportCaseNumber,
305
+ (progress) => {
306
+ const roundedProgress = Math.round(progress);
307
+ const label = getExportProgressLabel(progress);
308
+ setToastType('loading');
309
+ setToastMessage(`Exporting case ${exportCaseNumber}... ${label} (${roundedProgress}%)`);
310
+ setToastDuration(0);
311
+ setShowToast(true);
312
+ onProgress?.(roundedProgress, label);
313
+ },
314
+ { designatedReviewerEmail }
315
+ );
300
316
 
301
317
  showNotification(`Case ${exportCaseNumber} exported successfully.`, 'success');
302
318
  } catch (error) {
@@ -304,16 +320,47 @@ export const Striae = ({ user }: StriaePage) => {
304
320
  }
305
321
  };
306
322
 
323
+ const handleExportCaseModalSubmit = async (designatedReviewerEmail: string | undefined) => {
324
+ setIsExportingCase(true);
325
+ setIsExportCaseModalOpen(false);
326
+ try {
327
+ await handleExport(currentCase || '', designatedReviewerEmail);
328
+ } finally {
329
+ setIsExportingCase(false);
330
+ }
331
+ };
332
+
333
+ const handleOpenExportConfirmationsModal = async () => {
334
+ if (!currentCase || !user) return;
335
+
336
+ try {
337
+ const summary = await getCaseConfirmationSummary(user, currentCase);
338
+ const filesById = summary?.filesById ?? {};
339
+ const values = Object.values(filesById);
340
+ const confirmedCount = values.filter((f) => f.includeConfirmation && f.isConfirmed).length;
341
+ const unconfirmedCount = values.filter((f) => f.includeConfirmation && !f.isConfirmed).length;
342
+ setExportConfirmationStats({ confirmedCount, unconfirmedCount });
343
+ } catch {
344
+ setExportConfirmationStats({ confirmedCount: 0, unconfirmedCount: 0 });
345
+ }
346
+
347
+ setIsExportConfirmationsModalOpen(true);
348
+ };
349
+
307
350
  const handleExportConfirmations = async () => {
308
351
  if (!currentCase || !user) return;
309
352
 
353
+ setIsExportingConfirmations(true);
310
354
  showNotification(`Exporting confirmations for case ${currentCase}...`, 'loading', 0);
311
355
 
312
356
  try {
313
357
  await exportConfirmationData(user, currentCase);
358
+ setIsExportConfirmationsModalOpen(false);
314
359
  showNotification(`Confirmations for case ${currentCase} exported successfully.`, 'success');
315
360
  } catch (e) {
316
361
  showNotification(e instanceof Error ? e.message : 'Confirmation export failed. Please try again.', 'error');
362
+ } finally {
363
+ setIsExportingConfirmations(false);
317
364
  }
318
365
  };
319
366
 
@@ -764,9 +811,9 @@ export const Striae = ({ user }: StriaePage) => {
764
811
  onOpenListAllCases={() => setIsListCasesModalOpen(true)}
765
812
  onOpenCaseExport={() => {
766
813
  if (isReadOnlyCase) {
767
- void handleExportConfirmations();
814
+ void handleOpenExportConfirmationsModal();
768
815
  } else {
769
- void handleExport(currentCase || '');
816
+ setIsExportCaseModalOpen(true);
770
817
  }
771
818
  }}
772
819
  onOpenAuditTrail={() => setIsAuditTrailOpen(true)}
@@ -791,7 +838,7 @@ export const Striae = ({ user }: StriaePage) => {
791
838
  onOpenCase={() => {
792
839
  void handleOpenCaseModal();
793
840
  }}
794
- onOpenCaseExport={() => void handleExportConfirmations()}
841
+ onOpenCaseExport={() => void handleOpenExportConfirmationsModal()}
795
842
  imageId={imageId}
796
843
  currentCase={currentCase}
797
844
  imageLoaded={imageLoaded}
@@ -904,6 +951,23 @@ export const Striae = ({ user }: StriaePage) => {
904
951
  onClose={() => setIsArchiveCaseModalOpen(false)}
905
952
  onSubmit={handleArchiveCaseSubmit}
906
953
  />
954
+ <ExportCaseModal
955
+ isOpen={isExportCaseModalOpen}
956
+ caseNumber={currentCase || ''}
957
+ currentUserEmail={user.email ?? undefined}
958
+ isSubmitting={isExportingCase}
959
+ onClose={() => setIsExportCaseModalOpen(false)}
960
+ onSubmit={handleExportCaseModalSubmit}
961
+ />
962
+ <ExportConfirmationsModal
963
+ isOpen={isExportConfirmationsModalOpen}
964
+ caseNumber={currentCase || ''}
965
+ confirmedCount={exportConfirmationStats?.confirmedCount ?? 0}
966
+ unconfirmedCount={exportConfirmationStats?.unconfirmedCount ?? 0}
967
+ isSubmitting={isExportingConfirmations}
968
+ onClose={() => setIsExportConfirmationsModalOpen(false)}
969
+ onConfirm={() => void handleExportConfirmations()}
970
+ />
907
971
  <Toast
908
972
  message={toastMessage}
909
973
  type={toastType}
package/app/types/case.ts CHANGED
@@ -50,6 +50,7 @@ export interface CaseExportData {
50
50
  exportedByName: string;
51
51
  exportedByCompany: string;
52
52
  exportedByBadgeId?: string;
53
+ designatedReviewerEmail?: string;
53
54
  striaeExportSchemaVersion: string;
54
55
  totalFiles: number;
55
56
  };
@@ -4,4 +4,5 @@ export interface ExportOptions {
4
4
  includeMetadata?: boolean;
5
5
  includeUserInfo?: boolean;
6
6
  protectForensicData?: boolean;
7
+ designatedReviewerEmail?: string;
7
8
  }
@@ -78,6 +78,16 @@ export interface ConfirmationImportData {
78
78
  };
79
79
  }
80
80
 
81
+ export interface ConfirmationImportPreview {
82
+ caseNumber: string;
83
+ exportedBy: string;
84
+ exportedByName: string;
85
+ exportedByCompany: string;
86
+ exportedByBadgeId?: string;
87
+ exportDate: string;
88
+ totalConfirmations: number;
89
+ }
90
+
81
91
  export interface CaseImportPreview {
82
92
  caseNumber: string;
83
93
  archived?: boolean;
@@ -67,6 +67,14 @@ function resolveImageWorkerToken(env: Env): string {
67
67
  return typeof env.IMAGES_API_TOKEN === 'string' ? env.IMAGES_API_TOKEN.trim() : '';
68
68
  }
69
69
 
70
+ const BASE64URL_SEGMENT = /^[A-Za-z0-9_-]+$/;
71
+
72
+ function looksLikeSignedToken(value: string): boolean {
73
+ const parts = value.split('.');
74
+ if (parts.length !== 2) return false;
75
+ return parts.every(part => part.length > 0 && BASE64URL_SEGMENT.test(part));
76
+ }
77
+
70
78
  export const onRequest = async ({ request, env }: ImageProxyContext): Promise<Response> => {
71
79
  if (!SUPPORTED_METHODS.has(request.method)) {
72
80
  return textResponse('Method not allowed', 405);
@@ -84,9 +92,17 @@ export const onRequest = async ({ request, env }: ImageProxyContext): Promise<Re
84
92
 
85
93
  const requestUrl = new URL(request.url);
86
94
 
87
- const identity = await verifyFirebaseIdentityFromRequest(request, env);
88
- if (!identity) {
89
- return textResponse('Unauthorized', 401);
95
+ const signedToken = requestUrl.searchParams.get('st');
96
+ const isSignedTokenRequest =
97
+ request.method === 'GET' &&
98
+ signedToken !== null &&
99
+ looksLikeSignedToken(signedToken);
100
+
101
+ if (!isSignedTokenRequest) {
102
+ const identity = await verifyFirebaseIdentityFromRequest(request, env);
103
+ if (!identity) {
104
+ return textResponse('Unauthorized', 401);
105
+ }
90
106
  }
91
107
 
92
108
  const proxyPathResult = extractProxyPath(requestUrl);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@striae-org/striae",
3
- "version": "5.3.0",
3
+ "version": "5.3.2",
4
4
  "private": false,
5
5
  "description": "Striae is a specialized, cloud-native platform designed to streamline forensic firearms identification by providing an intuitive environment for digital comparison image annotation, authenticated confirmations, and automated report generation.",
6
6
  "license": "Apache-2.0",
@@ -7,7 +7,7 @@
7
7
  "name": "AUDIT_WORKER_NAME",
8
8
  "account_id": "ACCOUNT_ID",
9
9
  "main": "src/audit-worker.ts",
10
- "compatibility_date": "2026-03-26",
10
+ "compatibility_date": "2026-03-29",
11
11
  "compatibility_flags": [
12
12
  "nodejs_compat"
13
13
  ],
@@ -5,7 +5,7 @@
5
5
  "name": "DATA_WORKER_NAME",
6
6
  "account_id": "ACCOUNT_ID",
7
7
  "main": "src/data-worker.ts",
8
- "compatibility_date": "2026-03-26",
8
+ "compatibility_date": "2026-03-29",
9
9
  "compatibility_flags": [
10
10
  "nodejs_compat"
11
11
  ],
@@ -14,6 +14,7 @@ interface Env {
14
14
  DATA_AT_REST_ENCRYPTION_ACTIVE_KEY_ID?: string;
15
15
  IMAGE_SIGNED_URL_SECRET?: string;
16
16
  IMAGE_SIGNED_URL_TTL_SECONDS?: string;
17
+ IMAGE_SIGNED_URL_BASE_URL?: string;
17
18
  }
18
19
 
19
20
  interface KeyRegistryPayload {
@@ -383,6 +384,25 @@ function requireSignedUrlConfig(env: Env): void {
383
384
  }
384
385
  }
385
386
 
387
+ function parseSignedUrlBaseUrl(raw: string): string {
388
+ let parsed: URL;
389
+ try {
390
+ parsed = new URL(raw.trim());
391
+ } catch {
392
+ throw new Error(`IMAGE_SIGNED_URL_BASE_URL is not a valid absolute URL: "${raw}"`);
393
+ }
394
+
395
+ if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
396
+ throw new Error(`IMAGE_SIGNED_URL_BASE_URL must use http or https, got: "${parsed.protocol}"`);
397
+ }
398
+
399
+ if (parsed.search || parsed.hash) {
400
+ throw new Error(`IMAGE_SIGNED_URL_BASE_URL must not include a query string or fragment: "${raw}"`);
401
+ }
402
+
403
+ return `${parsed.origin}${parsed.pathname}`.replace(/\/+$/, '');
404
+ }
405
+
386
406
  async function getSignedUrlHmacKey(env: Env): Promise<CryptoKey> {
387
407
  const resolvedSecret = (env.IMAGE_SIGNED_URL_SECRET || env.IMAGES_API_TOKEN || '').trim();
388
408
  const keyBytes = new TextEncoder().encode(resolvedSecret);
@@ -592,8 +612,22 @@ async function handleSignedUrlMinting(request: Request, env: Env, fileId: string
592
612
  };
593
613
 
594
614
  const signedToken = await signSignedAccessPayload(payload, env);
595
- const signedPath = `/${encodeURIComponent(fileId)}?st=${encodeURIComponent(signedToken)}`;
596
- const signedUrl = new URL(signedPath, request.url).toString();
615
+
616
+ let baseUrl: string;
617
+ if (env.IMAGE_SIGNED_URL_BASE_URL) {
618
+ try {
619
+ baseUrl = parseSignedUrlBaseUrl(env.IMAGE_SIGNED_URL_BASE_URL);
620
+ } catch (error) {
621
+ console.error('Invalid IMAGE_SIGNED_URL_BASE_URL configuration', {
622
+ reason: error instanceof Error ? error.message : String(error)
623
+ });
624
+ return createJsonResponse({ error: 'Signed URL base URL is misconfigured' }, 500);
625
+ }
626
+ } else {
627
+ baseUrl = new URL(request.url).origin;
628
+ }
629
+
630
+ const signedUrl = `${baseUrl}/${encodeURIComponent(fileId)}?st=${encodeURIComponent(signedToken)}`;
597
631
 
598
632
  return createJsonResponse({
599
633
  success: true,
@@ -2,7 +2,7 @@
2
2
  "name": "IMAGES_WORKER_NAME",
3
3
  "account_id": "ACCOUNT_ID",
4
4
  "main": "src/image-worker.ts",
5
- "compatibility_date": "2026-03-26",
5
+ "compatibility_date": "2026-03-29",
6
6
  "compatibility_flags": [
7
7
  "nodejs_compat"
8
8
  ],
@@ -2,7 +2,7 @@
2
2
  "name": "PDF_WORKER_NAME",
3
3
  "account_id": "ACCOUNT_ID",
4
4
  "main": "src/pdf-worker.ts",
5
- "compatibility_date": "2026-03-26",
5
+ "compatibility_date": "2026-03-29",
6
6
  "compatibility_flags": [
7
7
  "nodejs_compat"
8
8
  ],
@@ -2,7 +2,7 @@
2
2
  "name": "USER_WORKER_NAME",
3
3
  "account_id": "ACCOUNT_ID",
4
4
  "main": "src/user-worker.ts",
5
- "compatibility_date": "2026-03-26",
5
+ "compatibility_date": "2026-03-29",
6
6
  "compatibility_flags": [
7
7
  "nodejs_compat"
8
8
  ],
@@ -1,6 +1,6 @@
1
1
  #:schema node_modules/wrangler/config-schema.json
2
2
  name = "PAGES_PROJECT_NAME"
3
- compatibility_date = "2026-03-26"
3
+ compatibility_date = "2026-03-29"
4
4
  compatibility_flags = ["nodejs_compat"]
5
5
  pages_build_output_dir = "./build/client"
6
6