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; 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() @Injectable()
export class FilesService { export class FilesService {
constructor( constructor(
@@ -21,7 +35,12 @@ export class FilesService {
async upload(input: CreateFileInput): Promise<FileRecord> { async upload(input: CreateFileInput): Promise<FileRecord> {
const { resourceId, resource, code, file } = input; 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); const url = await this.minioService.uploadFile(objectName, file.buffer, file.mimetype);
return this.filesRepository.create({ return this.filesRepository.create({

View File

@@ -65,7 +65,14 @@ export class MinioService {
} }
const url = new URL(trimmed); 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) { if (parts[0] === this.bucket) {
parts.shift(); parts.shift();
} }

View File

@@ -32,6 +32,7 @@ import { isViewable } from "@edr/ui-common";
import { SectionCard } from "./SectionCard"; import { SectionCard } from "./SectionCard";
import { bookingsService } from "@/services/bookings.service"; import { bookingsService } from "@/services/bookings.service";
import { fileViewUrl } from "@/constants/apiConfig";
import { useFileViewer } from "@/hooks/useFileViewer"; import { useFileViewer } from "@/hooks/useFileViewer";
export interface ClearanceReviewSectionProps { export interface ClearanceReviewSectionProps {
@@ -240,7 +241,7 @@ export function ClearanceReviewSection({
<> <>
{isViewable({ {isViewable({
name: doc.file.name, name: doc.file.name,
url: doc.file.url, url: fileViewUrl(doc.file.id),
}) && ( }) && (
<Tooltip label="View"> <Tooltip label="View">
<Box <Box
@@ -249,7 +250,7 @@ export function ClearanceReviewSection({
onClick={() => onClick={() =>
view({ view({
name: doc.file!.name, name: doc.file!.name,
url: doc.file!.url, url: fileViewUrl(doc.file!.id),
}) })
} }
c="edr-green" c="edr-green"
@@ -267,9 +268,7 @@ export function ClearanceReviewSection({
<Tooltip label="Download"> <Tooltip label="Download">
<Box <Box
component="a" component="a"
href={doc.file.url} href={fileViewUrl(doc.file.id, true)}
target="_blank"
rel="noreferrer"
c="edr-green" c="edr-green"
style={{ display: "flex" }} style={{ display: "flex" }}
> >
@@ -456,7 +455,10 @@ function DocReviewCard({
{meta.label} {meta.label}
</Badge> </Badge>
{hasFile && {hasFile &&
isViewable({ name: doc.file!.name, url: doc.file!.url }) && ( isViewable({
name: doc.file!.name,
url: fileViewUrl(doc.file!.id),
}) && (
<Tooltip label="Preview document"> <Tooltip label="Preview document">
<Button <Button
size="compact-xs" size="compact-xs"
@@ -464,7 +466,10 @@ function DocReviewCard({
radius="md" radius="md"
leftSection={<Eye size={13} />} leftSection={<Eye size={13} />}
onClick={() => onClick={() =>
onView({ name: doc.file!.name, url: doc.file!.url }) onView({
name: doc.file!.name,
url: fileViewUrl(doc.file!.id),
})
} }
> >
View View

View File

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

View File

@@ -1,3 +1,15 @@
export const API_BASE_URL = 'https://edrfreightapi.triaplc.com'; export const API_BASE_URL = 'https://edrfreightapi.triaplc.com';
// export const API_BASE_URL = 'http://localhost:3001'; // 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, humanize,
} from "@/components/customers"; } from "@/components/customers";
import { KpiStrip, PageContainer, PageHeader } from "@/components/page"; import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
import { fileViewUrl } from "@/constants/apiConfig";
import { api } from "@/services/api"; import { api } from "@/services/api";
import type { import type {
CompanyProfile, CompanyProfile,
@@ -289,7 +290,7 @@ export default function CustomerDetailPage() {
cell: ({ row }) => ( cell: ({ row }) => (
<ActionIcon <ActionIcon
component="a" component="a"
href={row.original.url ?? "#"} href={fileViewUrl(row.original.id, true)}
variant="subtle" variant="subtle"
color="gray" color="gray"
aria-label="Download" aria-label="Download"

View File

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

View File

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

View File

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

View File

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

View File

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