Refactor file handling to improve URL safety and enhance file viewing capabilities across components

This commit is contained in:
Marshal
2026-06-28 08:19:21 +00:00
parent b424de8436
commit da533a488d
11 changed files with 100 additions and 28 deletions

View File

@@ -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<FileRecord> {
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({

View File

@@ -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();
}

View File

@@ -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),
}) && (
<Tooltip label="View">
<Box
@@ -249,7 +250,7 @@ export function ClearanceReviewSection({
onClick={() =>
view({
name: doc.file!.name,
url: doc.file!.url,
url: fileViewUrl(doc.file!.id),
})
}
c="edr-green"
@@ -267,9 +268,7 @@ export function ClearanceReviewSection({
<Tooltip label="Download">
<Box
component="a"
href={doc.file.url}
target="_blank"
rel="noreferrer"
href={fileViewUrl(doc.file.id, true)}
c="edr-green"
style={{ display: "flex" }}
>
@@ -456,7 +455,10 @@ function DocReviewCard({
{meta.label}
</Badge>
{hasFile &&
isViewable({ name: doc.file!.name, url: doc.file!.url }) && (
isViewable({
name: doc.file!.name,
url: fileViewUrl(doc.file!.id),
}) && (
<Tooltip label="Preview document">
<Button
size="compact-xs"
@@ -464,7 +466,10 @@ function DocReviewCard({
radius="md"
leftSection={<Eye size={13} />}
onClick={() =>
onView({ name: doc.file!.name, url: doc.file!.url })
onView({
name: doc.file!.name,
url: fileViewUrl(doc.file!.id),
})
}
>
View

View File

@@ -34,6 +34,7 @@ import { isViewable } from "@edr/ui-common";
import { SectionCard } from "@/components/bookings/detail/SectionCard";
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
import { contractsService } from "@/services/contracts.service";
import { fileViewUrl } from "@/constants/apiConfig";
import { useContractClearanceMutations } from "@/hooks/contracts/useContracts";
import { useFileViewer } from "@/hooks/useFileViewer";
@@ -261,7 +262,7 @@ export function ContractClearanceReviewSection({
<>
{isViewable({
name: doc.file.name,
url: doc.file.url,
url: fileViewUrl(doc.file.id),
}) && (
<Tooltip label="View">
<Box
@@ -270,7 +271,7 @@ export function ContractClearanceReviewSection({
onClick={() =>
view({
name: doc.file!.name,
url: doc.file!.url,
url: fileViewUrl(doc.file!.id),
})
}
c="edr-green"
@@ -288,9 +289,7 @@ export function ContractClearanceReviewSection({
<Tooltip label="Download">
<Box
component="a"
href={doc.file.url}
target="_blank"
rel="noreferrer"
href={fileViewUrl(doc.file.id, true)}
c="edr-green"
style={{ display: "flex" }}
>
@@ -548,7 +547,10 @@ function DocReviewCard({
{meta.label}
</Badge>
{hasFile &&
isViewable({ name: doc.file!.name, url: doc.file!.url }) && (
isViewable({
name: doc.file!.name,
url: fileViewUrl(doc.file!.id),
}) && (
<Tooltip label="Preview document">
<Button
size="compact-xs"
@@ -556,7 +558,10 @@ function DocReviewCard({
radius="md"
leftSection={<Eye size={13} />}
onClick={() =>
onView({ name: doc.file!.name, url: doc.file!.url })
onView({
name: doc.file!.name,
url: fileViewUrl(doc.file!.id),
})
}
>
View

View File

@@ -1,3 +1,15 @@
export const API_BASE_URL = 'https://edrfreightapi.triaplc.com';
// export const API_BASE_URL = 'http://localhost:3001';
/**
* URL that streams an uploaded file through the API by its UUID. Routes the
* bytes through `GET /api/files/:id` (served from MinIO with backend
* credentials) instead of a presigned MinIO URL — the latter is not reachable
* from the browser and breaks on the minio-js port-443 signature quirk. Serves
* inline for preview by default; pass `download` to force a save dialog.
*/
export function fileViewUrl(fileId: string, download = false): string {
const base = `${API_BASE_URL}/api/files/${fileId}`;
return download ? `${base}?download=1` : base;
}

View File

@@ -42,6 +42,7 @@ import {
humanize,
} from "@/components/customers";
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
import { fileViewUrl } from "@/constants/apiConfig";
import { api } from "@/services/api";
import type {
CompanyProfile,
@@ -289,7 +290,7 @@ export default function CustomerDetailPage() {
cell: ({ row }) => (
<ActionIcon
component="a"
href={row.original.url ?? "#"}
href={fileViewUrl(row.original.id, true)}
variant="subtle"
color="gray"
aria-label="Download"

View File

@@ -23,6 +23,7 @@ import { useMemo, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { api } from "@/services/api";
import { fileViewUrl } from "@/constants/apiConfig";
import type { SubmitBookingResponse } from "@/services/bookings.service";
import type { Freight } from "@edr/types";
@@ -288,14 +289,16 @@ export function DraftBookingView({
action={
isUploaded && !allowReplace ? (
<IconSquare
href={file?.signedUrl ?? file?.url}
href={file ? fileViewUrl(file.id, true) : undefined}
icon={<Download size={16} />}
/>
) : (
<>
{isUploaded && (
<IconSquare
href={file?.signedUrl ?? file?.url}
href={
file ? fileViewUrl(file.id, true) : undefined
}
icon={<Download size={16} />}
/>
)}

View File

@@ -6,6 +6,7 @@ import { useNavigate } from "react-router-dom";
import { isViewable } from "@edr/ui-common";
import { api } from "@/services/api";
import { fileViewUrl } from "@/constants/apiConfig";
import { useFileViewer } from "@/hooks/useFileViewer";
import { paymentsService, type PaymentMethod } from "@/services/payments.service";
import type { Freight } from "@edr/types";
@@ -165,7 +166,7 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking })
<Group gap={6} wrap="nowrap">
{isViewable({
name: file.name,
url: file.signedUrl ?? file.url,
url: fileViewUrl(file.id),
mimeType: file.mimeType,
}) && (
<IconSquare
@@ -173,14 +174,14 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking })
onClick={() =>
view({
name: file.name,
url: file.signedUrl ?? file.url,
url: fileViewUrl(file.id),
mimeType: file.mimeType,
})
}
/>
)}
<IconSquare
href={file.signedUrl ?? file.url}
href={fileViewUrl(file.id, true)}
icon={<Download size={16} />}
/>
</Group>

View File

@@ -1,4 +1,5 @@
import { api } from "@/services/api";
import { fileViewUrl } from "@/constants/apiConfig";
import type { CreateBookingPayload } from "@/services/bookings.service";
import type { Freight } from "@edr/types";
import { zodResolver } from "@hookform/resolvers/zod";
@@ -948,7 +949,11 @@ export default function EditBookingPage() {
<Group gap={6} wrap="nowrap">
{isUploaded && !selected && (
<IconSquare
href={uploadedFile?.signedUrl ?? uploadedFile?.url}
href={
uploadedFile
? fileViewUrl(uploadedFile.id, true)
: undefined
}
icon={<Download size={16} />}
/>
)}

View File

@@ -23,6 +23,7 @@ import type { Freight } from "@edr/types";
import { isViewable } from "@edr/ui-common";
import { IconSquare } from "../BookingDetailPage/components/Documents";
import { fileViewUrl } from "@/constants/apiConfig";
import { useFileViewer } from "@/hooks/useFileViewer";
import { OperationDatePicker } from "./OperationDatePicker";
import type { ClearanceFlowController } from "./useClearanceFlow";
@@ -169,16 +170,25 @@ export function ClearanceFlow({ booking, flow, footer }: ClearanceFlowProps) {
<Group gap={10} wrap="nowrap">
<StatusPill doc={doc} />
{doc.file &&
isViewable({ name: doc.file.name, url: doc.file.url }) && (
isViewable({
name: doc.file.name,
url: fileViewUrl(doc.file.id),
}) && (
<IconSquare
icon={<Eye size={15} />}
onClick={() =>
view({ name: doc.file!.name, url: doc.file!.url })
view({
name: doc.file!.name,
url: fileViewUrl(doc.file!.id),
})
}
/>
)}
{doc.file && (
<IconSquare href={doc.file.url} icon={<Download size={15} />} />
<IconSquare
href={fileViewUrl(doc.file.id, true)}
icon={<Download size={15} />}
/>
)}
{canUpload && doc.reviewStatus !== "APPROVED" && (
<FileButton
@@ -233,7 +243,10 @@ export function ClearanceFlow({ booking, flow, footer }: ClearanceFlowProps) {
{doc.label}
</Text>
{doc.file ? (
<IconSquare href={doc.file.url} icon={<Download size={15} />} />
<IconSquare
href={fileViewUrl(doc.file.id, true)}
icon={<Download size={15} />}
/>
) : (
<Text fz="12px" c="#9AA8B5">
Pending

View File

@@ -3,6 +3,7 @@ import { SmartFileInput } from "@edr/ui-common";
import { CheckCircle2, Download, FileText } from "lucide-react";
import { IconSquare } from "../BookingDetailPage/components/Documents";
import { fileViewUrl } from "@/constants/apiConfig";
import { labelForDocCode } from "./resubmitDocs";
import type { ResubmitFlowController } from "./useResubmitFlow";
@@ -69,7 +70,7 @@ export function ResubmitDocuments({ flow }: { flow: ResubmitFlowController }) {
</Text>
</Group>
<IconSquare
href={file.signedUrl ?? file.url}
href={fileViewUrl(file.id, true)}
icon={<Download size={15} />}
/>
</Group>