mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Refactor file handling to improve URL safety and enhance file viewing capabilities across components
This commit is contained in:
@@ -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({
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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} />}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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} />}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user