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:
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user