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:
MMD
2026-09-20 20:35:16 +03:30
parent e637d80b76
commit baa32a2652
14 changed files with 796 additions and 172 deletions
+8 -119
View File
@@ -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>
);
@@ -0,0 +1,148 @@
import { useState } from "react";
import { PdfCanvas } from "./PdfCanvas";
import { X, FileText, Image as ImageIcon, ExternalLink } from "lucide-react";
export const isImageFile = (name: string = "") => {
return /\.(jpe?g|png|webp|gif|svg|bmp)$/i.test(name.toLowerCase());
};
export const isPdfFile = (name: string = "") => {
return /\.pdf$/i.test(name.toLowerCase());
};
/**
* آیا مرورگر می‌تواند PDF را درون‌خطی نشان دهد؟
*
* دسکتاپ: بله (نمایشگر بومی) → <iframe> کافی است و بهترین تجربه را دارد.
* شبیه‌سازی موبایل/اندروید: پلاگین PDF وجود ندارد؛ <iframe> به دانلود
* منتهی می‌شود، پس آنجا pdf.js استفاده می‌کنیم.
*/
export function canRenderPdfNatively(): boolean {
if (typeof navigator === "undefined") return true;
const ua = navigator.userAgent || "";
const mobile = /Android|iPhone|iPad|iPod|Mobile/i.test(ua);
// pdfViewerEnabled در Chromium/Firefox وجود دارد و دقیق‌ترین معیار است
const pdfViewer = (navigator as { pdfViewerEnabled?: boolean }).pdfViewerEnabled;
if (typeof pdfViewer === "boolean") return pdfViewer && !mobile;
return !mobile;
}
export interface FilePreviewModalProps {
// آدرس امضاشده‌ی کوتاه‌عمر — چون <img>/<iframe> نمی‌توانند هدر توکن بفرستند
url: string;
name: string;
onClose: () => void;
}
/**
* مودال پیش‌نمایش فایل — مشترک بین لیست پیوست‌ها (FileList) و کارت پیشنهاد
* در داشبورد. تصویر با <img> و PDF با pdf.js روی canvas رسم می‌شود (بدون
* وابستگی به پلاگین PDF مرورگر، که در شبیه‌سازی موبایل وجود ندارد)؛ سایر
* فرمت‌ها فقط دکمه‌ی دانلود دارند.
*/
export function FilePreviewModal({ url, name, onClose }: FilePreviewModalProps) {
return (
<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={onClose}
>
<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(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">{name}</span>
</div>
<div className="flex items-center gap-1.5 shrink-0">
<a
href={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={onClose}
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(name) ? (
<img
src={url}
alt={name}
className="max-h-[75vh] w-auto max-w-full rounded-lg object-contain shadow-xs border border-gray-200 bg-white"
/>
) : isPdfFile(name) ? (
// روی دسکتاپ نمایشگر بومی مرورگر بهترین نتیجه را می‌دهد؛ در
// شبیه‌سازی موبایل پلاگین PDF وجود ندارد، پس آنجا pdf.js روی
// canvas رسم می‌کند. انتخاب در زمان اجرا با تشخیص قابلیت.
canRenderPdfNatively() ? (
<iframe
src={url}
title={name}
className="h-[75vh] w-full rounded-lg border border-gray-200 bg-white shadow-xs"
/>
) : (
<PdfCanvas url={url} className="w-full" />
)
) : (
<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>
</div>
)}
</div>
</div>
</div>
);
}
/**
* هوک مدیریت پیش‌نمایش: بارگذاری blob احراز هویت‌شده + آزادسازی objectURL.
* خروجی برای رندر <FilePreviewModal> آماده است.
*/
/**
* هوک مدیریت پیش‌نمایش: گرفتن URL امضاشده و نگه‌داشتن آن برای مودال.
* URL یک آدرس واقعی است (نه blob) تا دانلودمنیجرها آن را نشکنند؛ پس
* نیازی به آزادسازی objectURL نیست.
*/
export function useFilePreview(load: (id: string, name: string) => Promise<string>) {
const [item, setItem] = useState<{ url: string; name: string } | null>(null);
const [loadingId, setLoadingId] = useState<string | null>(null);
const open = async (id: string, name: string) => {
if (!id) return;
setLoadingId(id);
try {
const url = await load(id, name);
setItem({ url, name });
} finally {
setLoadingId(null);
}
};
const close = () => setItem(null);
return { item, loadingId, open, close };
}
+125
View File
@@ -0,0 +1,125 @@
import { useEffect, useRef, useState } from "react";
/**
* نمایشگر PDF با pdf.js (canvas) به‌جای <iframe>.
*
* چرا: کروم در حالت شبیه‌سازی موبایل (F12) پلاگین PDF درون‌خطی ندارد، پس
* <iframe src="...pdf"> را به‌جای نمایش، دانلود می‌کند — که دانلودمنیجرها
* شکارش می‌کنند و پیش‌نمایش خالی می‌ماند. با رسم روی canvas هیچ ناوبری‌ای
* به آدرس فایل انجام نمی‌شود، پس نه به پلاگین مرورگر وابسته‌ایم و نه چیزی
* برای دانلودمنیجر باقی می‌ماند.
*/
interface PdfCanvasProps {
url: string;
className?: string;
}
export function PdfCanvas({ url, className }: PdfCanvasProps) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [pageCount, setPageCount] = useState(0);
useEffect(() => {
let cancelled = false;
let doc: { destroy: () => void } | null = null;
(async () => {
setLoading(true);
setError(null);
try {
// بارگذاری تنبل: pdf.js فقط وقتی پیش‌نمایش PDF باز می‌شود دانلود می‌شود
const pdfjs = await import("pdfjs-dist");
// فقط workerSrc ست می‌شود — ست‌کردن هم‌زمان workerPort باعث تعارض
// می‌شد و بارگذاری سند در حلقه گیر می‌کرد («در حال بارگذاری…»).
// فایل worker با پسوند .mjs سرو می‌شود و nginx آن را
// text/javascript اعلام می‌کند (وگرنه مرورگر worker را رد می‌کند).
const workerUrl = (await import("pdfjs-dist/build/pdf.worker.min.mjs?url")).default;
pdfjs.GlobalWorkerOptions.workerSrc = workerUrl;
// بایت‌ها را خودمان با fetch می‌گیریم و به pdf.js به‌صورت داده
// می‌دهیم، به‌جای اینکه pdf.js خودش آدرس را بگیرد. دلیل: دانلودمنیجر
// (IDM) درخواست pdf.js به آدرس PDF را می‌رباید و با 204 برمی‌گرداند
// («Unexpected server response (204)»). با داده‌ی درون‌حافظه هیچ
// درخواست قابل‌ربودنی برای دانلودمنیجر باقی نمی‌ماند.
const res = await fetch(url, { credentials: "omit" });
if (!res.ok) throw new Error(`دریافت فایل ناموفق بود (${res.status})`);
const bytes = new Uint8Array(await res.arrayBuffer());
const task = pdfjs.getDocument({ data: bytes });
// اگر worker/شبکه در دسترس نباشد، به‌جای چرخ «در حال بارگذاری…»
// بی‌پایان، خطای قابل‌فهم نشان بده.
const loaded = await Promise.race([
task.promise,
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error("زمان بارگذاری PDF به پایان رسید")), 20000),
),
]);
doc = loaded as unknown as { destroy: () => void };
if (cancelled) return;
setPageCount(loaded.numPages);
// فعلاً صفحه‌ی اول — برای پیش‌نمایشِ سریع کافی است
const page = await loaded.getPage(1);
if (cancelled) return;
const canvas = canvasRef.current;
if (!canvas) return;
// مقیاس بر اساس عرض ظرف، با در نظر گرفتن devicePixelRatio برای وضوح
const containerWidth = canvas.parentElement?.clientWidth || 800;
const baseViewport = page.getViewport({ scale: 1 });
const dpr = window.devicePixelRatio || 1;
const scale = Math.min((containerWidth - 2) / baseViewport.width, 2) * dpr;
const viewport = page.getViewport({ scale });
canvas.width = Math.floor(viewport.width);
canvas.height = Math.floor(viewport.height);
canvas.style.width = `${Math.floor(viewport.width / dpr)}px`;
canvas.style.height = `${Math.floor(viewport.height / dpr)}px`;
const ctx = canvas.getContext("2d");
if (!ctx) return;
await page.render({ canvasContext: ctx, viewport }).promise;
if (!cancelled) setLoading(false);
} catch (err) {
if (!cancelled) {
setLoading(false);
setError(err instanceof Error ? err.message : "نمایش PDF ناموفق بود");
}
}
})();
return () => {
cancelled = true;
try {
doc?.destroy();
} catch {
/* نادیده بگیر */
}
};
}, [url]);
return (
<div className={className}>
{loading && !error && (
<div className="flex items-center justify-center p-8 text-xs text-gray-500">در حال بارگذاری…</div>
)}
{error ? (
<div className="flex flex-col items-center justify-center gap-3 p-8 text-center">
<div className="text-xs text-gray-600">نمایش پیش‌نمایش ممکن نشد: {error}</div>
</div>
) : (
<>
<canvas ref={canvasRef} className="mx-auto block max-w-full rounded-lg border border-gray-200 bg-white shadow-xs" />
{pageCount > 1 && (
<div className="mt-2 text-center text-[11px] text-gray-500">
صفحه ۱ از {pageCount}
</div>
)}
</>
)}
</div>
);
}