feat(files): preview attachments via short-lived signed URLs
Downloads require an Authorization header, which <iframe>/<img> cannot
send. The previous blob: workaround broke under download managers (IDM
hooks blob URLs, fails to resolve them, and leaves a 0-byte file) and in
Chrome's mobile emulation, which has no inline PDF plugin.
Backend:
- preview_token.py: HMAC-signed token bound to one (quote, kind, file)
with a 5-minute TTL, keyed off an existing secret
- GET /quotes/{id}/preview-url/{kind}/{attachment} mints the URL
- the two file GET routes accept a Bearer session OR the signed ?t= token
(they leave the global auth dep, so the other six file routes now carry
it explicitly — nothing is open by omission)
Frontend:
- api.files.previewUrl() replaces the blob helper
- shared FilePreviewModal + useFilePreview; FileList and the dashboard
quote card both use it
- PDFs render with the browser's native viewer on desktop and pdf.js on
canvas where no inline viewer exists (mobile); pdf.js is fed the bytes
in memory so no PDF URL is exposed for a download manager to intercept
- removed the per-attachment download buttons on the dashboard card and
the modal header
Ops:
- nginx: explicit MIME types (.mjs as text/javascript) — a module worker
served as octet-stream is refused and crashed the page
- frontend service bypasses the nginx entrypoint, whose apk manifest call
hangs when the package mirror is unreachable (502s)
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Eye, Download, Trash2, X, FileText, Image as ImageIcon, ExternalLink } from "lucide-react";
|
||||
import { Eye, Download, Trash2, FileText, Image as ImageIcon } from "lucide-react";
|
||||
import type { Attachment } from "../lib/types";
|
||||
import { FilePreviewModal, isImageFile, isPdfFile } from "./FilePreviewModal";
|
||||
|
||||
export interface FileListProps {
|
||||
files: Attachment[];
|
||||
@@ -18,14 +19,6 @@ export interface FileListProps {
|
||||
label?: string;
|
||||
}
|
||||
|
||||
const isImageFile = (name: string = "") => {
|
||||
return /\.(jpe?g|png|webp|gif|svg|bmp)$/i.test(name.toLowerCase());
|
||||
};
|
||||
|
||||
const isPdfFile = (name: string = "") => {
|
||||
return /\.pdf$/i.test(name.toLowerCase());
|
||||
};
|
||||
|
||||
// مدیریت چند فاکتور: آپلود مستقیم، پیشنمایش آنلاین تصویر/PDF و دانلود
|
||||
export function FileList({
|
||||
files,
|
||||
@@ -39,21 +32,6 @@ export function FileList({
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [previewItem, setPreviewItem] = useState<{ url: string; name: string } | null>(null);
|
||||
const [loadingPreview, setLoadingPreview] = useState<string | null>(null);
|
||||
// آخرین objectURL ساختهشده — برای آزادسازی مطمئن (جلوگیری از نشت حافظه)
|
||||
const previewUrlRef = useRef<string | null>(null);
|
||||
|
||||
const revokePreviewUrl = () => {
|
||||
if (previewUrlRef.current) {
|
||||
try {
|
||||
URL.revokeObjectURL(previewUrlRef.current);
|
||||
} catch {
|
||||
/* آدرس نامعتبر — نادیده بگیر */
|
||||
}
|
||||
previewUrlRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => revokePreviewUrl, []);
|
||||
|
||||
const remove = (i: number) => {
|
||||
const removed = files[i];
|
||||
@@ -76,7 +54,8 @@ export function FileList({
|
||||
if (added.length) onChange([...files, ...added]);
|
||||
};
|
||||
|
||||
// پیشنمایش: بایتها را با توکن میگیریم و به شکل objectURL نشان میدهیم
|
||||
// پیشنمایش: یک URL امضاشدهی کوتاهعمر میگیریم (نه blob) تا
|
||||
// دانلودمنیجرها آن را شکار نکنند و <iframe>/<img> درست بارگذاری شود.
|
||||
const handleOpenPreview = async (inv: Attachment) => {
|
||||
if (!loadPreview) return;
|
||||
if (!inv.id) {
|
||||
@@ -86,8 +65,6 @@ export function FileList({
|
||||
setLoadingPreview(inv.id);
|
||||
try {
|
||||
const url = await loadPreview(inv);
|
||||
revokePreviewUrl();
|
||||
previewUrlRef.current = url;
|
||||
setPreviewItem({ url, name: inv.name });
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "بارگذاری پیشنمایش ناموفق بود");
|
||||
@@ -96,10 +73,7 @@ export function FileList({
|
||||
}
|
||||
};
|
||||
|
||||
const closePreview = () => {
|
||||
revokePreviewUrl();
|
||||
setPreviewItem(null);
|
||||
};
|
||||
const closePreview = () => setPreviewItem(null);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
@@ -201,94 +175,9 @@ export function FileList({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* پنجره مودال پیشنمایش (Preview Modal) */}
|
||||
{/* مودال پیشنمایش — کامپوننت مشترک با کارت پیشنهاد در داشبورد */}
|
||||
{previewItem && (
|
||||
<div
|
||||
className="fixed inset-0 z-[80] flex items-center justify-center bg-black/60 p-3 sm:p-5 backdrop-blur-xs animate-in fade-in duration-200"
|
||||
onClick={closePreview}
|
||||
>
|
||||
<div
|
||||
className="flex flex-col w-full max-w-4xl max-h-[90vh] rounded-2xl border border-gray-200 bg-white shadow-2xl overflow-hidden"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* هدر پیشنمایش */}
|
||||
<div className="flex items-center justify-between border-b border-gray-100 px-4 py-3 bg-white">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{isImageFile(previewItem.name) ? (
|
||||
<ImageIcon className="h-4 w-4 shrink-0 text-blue-600" />
|
||||
) : (
|
||||
<FileText className="h-4 w-4 shrink-0 text-rose-600" />
|
||||
)}
|
||||
<span className="text-xs sm:text-sm font-bold text-gray-900 truncate">
|
||||
{previewItem.name}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
<a
|
||||
href={previewItem.url}
|
||||
download={previewItem.name}
|
||||
className="inline-flex items-center gap-1 rounded-lg border border-gray-200 bg-white px-2.5 py-1 text-xs font-semibold text-gray-700 hover:bg-gray-50 hover:text-gray-900 transition-colors shadow-2xs"
|
||||
title="دانلود فاکتور"
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
<span className="hidden xs:inline">دانلود فاکتور</span>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href={previewItem.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="flex h-7 w-7 items-center justify-center rounded-lg border border-gray-200 text-gray-600 hover:bg-gray-50 hover:text-gray-900 transition-colors shadow-2xs"
|
||||
title="باز کردن در برگه جدید"
|
||||
>
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
</a>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={closePreview}
|
||||
className="flex h-7 w-7 items-center justify-center rounded-lg text-gray-400 hover:bg-gray-100 hover:text-gray-700 transition-colors ms-1"
|
||||
title="بستن"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* بدنه نمایش فایل */}
|
||||
<div className="flex-1 overflow-auto p-3 sm:p-4 bg-gray-50/70 flex items-center justify-center min-h-[300px]">
|
||||
{isImageFile(previewItem.name) ? (
|
||||
<img
|
||||
src={previewItem.url}
|
||||
alt={previewItem.name}
|
||||
className="max-h-[75vh] w-auto max-w-full rounded-lg object-contain shadow-xs border border-gray-200 bg-white"
|
||||
/>
|
||||
) : isPdfFile(previewItem.name) ? (
|
||||
<iframe
|
||||
src={previewItem.url}
|
||||
title={previewItem.name}
|
||||
className="h-[75vh] w-full rounded-lg border border-gray-200 bg-white shadow-xs"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center gap-3 p-8 text-center bg-white rounded-xl border border-gray-200 shadow-xs">
|
||||
<FileText className="h-12 w-12 text-gray-400" />
|
||||
<div className="text-xs text-gray-600">
|
||||
امکان نمایش پیشنمایش زنده برای این فرمت فایل وجود ندارد.
|
||||
</div>
|
||||
<a
|
||||
href={previewItem.url}
|
||||
download={previewItem.name}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg bg-blue-600 px-3.5 py-1.5 text-xs font-semibold text-white hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
دانلود فایل
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<FilePreviewModal url={previewItem.url} name={previewItem.name} onClose={closePreview} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user