@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.
- package/.env.example +3 -0
- package/app/components/actions/case-export/core-export.ts +3 -0
- package/app/components/actions/case-export/download-handlers.ts +1 -1
- package/app/components/actions/case-import/confirmation-import.ts +62 -22
- package/app/components/actions/case-import/confirmation-package.ts +68 -1
- package/app/components/actions/case-import/index.ts +1 -1
- package/app/components/actions/case-import/orchestrator.ts +78 -53
- package/app/components/actions/case-import/zip-processing.ts +157 -407
- package/app/components/actions/generate-pdf.ts +22 -0
- package/app/components/navbar/case-modals/export-case-modal.module.css +27 -0
- package/app/components/navbar/case-modals/export-case-modal.tsx +132 -0
- package/app/components/navbar/case-modals/export-confirmations-modal.module.css +24 -0
- package/app/components/navbar/case-modals/export-confirmations-modal.tsx +108 -0
- package/app/components/sidebar/case-import/components/CasePreviewSection.tsx +1 -9
- package/app/components/sidebar/case-import/components/ConfirmationPreviewSection.tsx +36 -5
- package/app/components/sidebar/case-import/hooks/useFilePreview.ts +5 -9
- package/app/components/sidebar/case-import/index.ts +1 -4
- package/app/routes/auth/login.tsx +22 -103
- package/app/routes/striae/striae.tsx +77 -13
- package/app/types/case.ts +1 -0
- package/app/types/export.ts +1 -0
- package/app/types/import.ts +10 -0
- package/functions/api/image/[[path]].ts +19 -3
- package/package.json +1 -1
- package/workers/audit-worker/wrangler.jsonc.example +1 -1
- package/workers/data-worker/wrangler.jsonc.example +1 -1
- package/workers/image-worker/src/image-worker.example.ts +36 -2
- package/workers/image-worker/wrangler.jsonc.example +1 -1
- package/workers/pdf-worker/wrangler.jsonc.example +1 -1
- package/workers/user-worker/wrangler.jsonc.example +1 -1
- 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(
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
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
|
|
814
|
+
void handleOpenExportConfirmationsModal();
|
|
768
815
|
} else {
|
|
769
|
-
|
|
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
|
|
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
package/app/types/export.ts
CHANGED
package/app/types/import.ts
CHANGED
|
@@ -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
|
|
88
|
-
|
|
89
|
-
|
|
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.
|
|
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",
|
|
@@ -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
|
-
|
|
596
|
-
|
|
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,
|
package/wrangler.toml.example
CHANGED