From da533a488df2aaaffb18e399f644046d46af5eb5 Mon Sep 17 00:00:00 2001 From: Marshal Date: Sun, 28 Jun 2026 08:19:21 +0000 Subject: [PATCH] Refactor file handling to improve URL safety and enhance file viewing capabilities across components --- .../src/modules/files/files.service.ts | 21 ++++++++++++++++++- .../src/modules/minio/minio.service.ts | 9 +++++++- .../detail/ClearanceReviewSection.tsx | 19 ++++++++++------- .../ContractClearanceReviewSection.tsx | 19 ++++++++++------- .../backoffice/src/constants/apiConfig.ts | 12 +++++++++++ .../pages/customers/CustomerDetailPage.tsx | 3 ++- .../BookingDetailPage/DraftBookingView.tsx | 7 +++++-- .../BookingDetailPage/ReadonlyBookingView.tsx | 7 ++++--- .../src/pages/bookings/EditBookingPage.tsx | 7 ++++++- .../bookings/clearance/ClearanceFlow.tsx | 21 +++++++++++++++---- .../bookings/resubmit/ResubmitDocuments.tsx | 3 ++- 11 files changed, 100 insertions(+), 28 deletions(-) diff --git a/apps/edr-freight-api/src/modules/files/files.service.ts b/apps/edr-freight-api/src/modules/files/files.service.ts index 31cf87dd7..4bf8362f9 100644 --- a/apps/edr-freight-api/src/modules/files/files.service.ts +++ b/apps/edr-freight-api/src/modules/files/files.service.ts @@ -12,6 +12,20 @@ export interface CreateFileInput { file: Express.Multer.File; } +/** + * Make a filename safe to use as a MinIO object-key segment: collapse runs of + * spaces/unsafe characters to a single underscore while keeping the dot before + * the extension. Prevents percent-encoding mismatches between the stored URL + * and the actual object key. + */ +function sanitizeObjectName(name: string): string { + return name + .normalize("NFKD") + .replace(/[^\w.\-]+/g, "_") + .replace(/_{2,}/g, "_") + .replace(/^_+|_+$/g, ""); +} + @Injectable() export class FilesService { constructor( @@ -21,7 +35,12 @@ export class FilesService { async upload(input: CreateFileInput): Promise { const { resourceId, resource, code, file } = input; - const objectName = `${resource}/${resourceId}/${Date.now()}_${file.originalname}`; + // Keep the object key URL-safe so it survives the round-trip through the + // stored URL (spaces/unicode in the original name would otherwise be + // percent-encoded in the URL and no longer match the MinIO key). The + // human-readable name is preserved separately on the record below. + const safeName = sanitizeObjectName(file.originalname); + const objectName = `${resource}/${resourceId}/${Date.now()}_${safeName}`; const url = await this.minioService.uploadFile(objectName, file.buffer, file.mimetype); return this.filesRepository.create({ diff --git a/apps/edr-freight-api/src/modules/minio/minio.service.ts b/apps/edr-freight-api/src/modules/minio/minio.service.ts index 9f7a5e65d..086da89f9 100644 --- a/apps/edr-freight-api/src/modules/minio/minio.service.ts +++ b/apps/edr-freight-api/src/modules/minio/minio.service.ts @@ -65,7 +65,14 @@ export class MinioService { } const url = new URL(trimmed); - const parts = url.pathname.split("/").filter(Boolean); + // `url.pathname` percent-encodes the object key (e.g. a space becomes + // "%20"), but MinIO stores the key with its literal characters. Decode each + // segment so the recovered key matches what was uploaded — otherwise a file + // whose name had spaces/unicode 404s with "specified key does not exist". + const parts = url.pathname + .split("/") + .filter(Boolean) + .map((segment) => decodeURIComponent(segment)); if (parts[0] === this.bucket) { parts.shift(); } diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/ClearanceReviewSection.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/ClearanceReviewSection.tsx index 8f8ec104d..eb39c2523 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/ClearanceReviewSection.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/ClearanceReviewSection.tsx @@ -32,6 +32,7 @@ import { isViewable } from "@edr/ui-common"; import { SectionCard } from "./SectionCard"; import { bookingsService } from "@/services/bookings.service"; +import { fileViewUrl } from "@/constants/apiConfig"; import { useFileViewer } from "@/hooks/useFileViewer"; export interface ClearanceReviewSectionProps { @@ -240,7 +241,7 @@ export function ClearanceReviewSection({ <> {isViewable({ name: doc.file.name, - url: doc.file.url, + url: fileViewUrl(doc.file.id), }) && ( view({ name: doc.file!.name, - url: doc.file!.url, + url: fileViewUrl(doc.file!.id), }) } c="edr-green" @@ -267,9 +268,7 @@ export function ClearanceReviewSection({ @@ -456,7 +455,10 @@ function DocReviewCard({ {meta.label} {hasFile && - isViewable({ name: doc.file!.name, url: doc.file!.url }) && ( + isViewable({ + name: doc.file!.name, + url: fileViewUrl(doc.file!.id), + }) && (