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:
@@ -13,6 +13,7 @@ import { fmtDate, fmtDateTime, fmtNum } from "../../lib/formatters";
|
||||
import { groupCommas } from "../../lib/numutil";
|
||||
import { FlowDiagram, type FlowRefs } from "./FlowDiagram";
|
||||
import { itemProjects } from "../../lib/quoteUtils";
|
||||
import { FilePreviewModal, useFilePreview } from "../../components/FilePreviewModal";
|
||||
|
||||
export interface QuoteDetailProps {
|
||||
store: Store;
|
||||
@@ -25,6 +26,15 @@ export function QuoteDetail({ store, q }: QuoteDetailProps) {
|
||||
const [flowRefs] = useState<FlowRefs>(() => ({ items: new Map(), termsheets: new Map() }));
|
||||
const [bump, setBump] = useState(0);
|
||||
|
||||
// پیشنمایش پیوستها: کلیک روی نام فایل، فایل را نشان میدهد (نه دانلود).
|
||||
// بایتها با توکن گرفته میشوند چون <img>/<iframe> نمیتوانند هدر بفرستند.
|
||||
// کلید با پیشوند نوع پیوست ساخته میشود تا نوعش گم نشود.
|
||||
const preview = useFilePreview((key, name) => {
|
||||
const [kind, id] = key.split(":") as ["invoice" | "proforma", string];
|
||||
void name;
|
||||
return api.files.previewUrl(q.id, kind, id);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
// بعد از نشستن layout (فونت/تصویر) خطوط دوباره رسم شوند
|
||||
const t = setTimeout(() => setBump((b) => b + 1), 350);
|
||||
@@ -144,31 +154,35 @@ export function QuoteDetail({ store, q }: QuoteDetailProps) {
|
||||
attachments.proformas.forEach((pf, i) => {
|
||||
if (!pf.id) return;
|
||||
attachParts.push(
|
||||
<button
|
||||
key={"pf" + i}
|
||||
type="button"
|
||||
className="detail-link"
|
||||
title="دانلود پیشفاکتور"
|
||||
onClick={() => api.files.download(q.id, "proforma", pf.id!, pf.name)}
|
||||
>
|
||||
<Paperclip className="me-1 inline h-3 w-3" />
|
||||
پیشفاکتور: {pf.name}
|
||||
</button>,
|
||||
<span key={"pf" + i} className="inline-flex items-center">
|
||||
<button
|
||||
type="button"
|
||||
className="detail-link"
|
||||
title="مشاهده پیشفاکتور"
|
||||
disabled={preview.loadingId === `proforma:${pf.id}`}
|
||||
onClick={() => void preview.open(`proforma:${pf.id}`, pf.name)}
|
||||
>
|
||||
<Paperclip className="me-1 inline h-3 w-3" />
|
||||
پیشفاکتور: {pf.name}
|
||||
</button>
|
||||
</span>,
|
||||
);
|
||||
});
|
||||
attachments.invoices.forEach((inv, i) => {
|
||||
if (!inv.id) return;
|
||||
attachParts.push(
|
||||
<button
|
||||
key={"inv" + i}
|
||||
type="button"
|
||||
className="detail-link"
|
||||
title="دانلود فاکتور"
|
||||
onClick={() => api.files.download(q.id, "invoice", inv.id!, inv.name)}
|
||||
>
|
||||
<Paperclip className="me-1 inline h-3 w-3" />
|
||||
فاکتور اصلی: {inv.name}
|
||||
</button>,
|
||||
<span key={"inv" + i} className="inline-flex items-center">
|
||||
<button
|
||||
type="button"
|
||||
className="detail-link"
|
||||
title="مشاهده فاکتور"
|
||||
disabled={preview.loadingId === `invoice:${inv.id}`}
|
||||
onClick={() => void preview.open(`invoice:${inv.id}`, inv.name)}
|
||||
>
|
||||
<Paperclip className="me-1 inline h-3 w-3" />
|
||||
فاکتور اصلی: {inv.name}
|
||||
</button>
|
||||
</span>,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -251,6 +265,9 @@ export function QuoteDetail({ store, q }: QuoteDetailProps) {
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{preview.item && (
|
||||
<FilePreviewModal url={preview.item.url} name={preview.item.name} onClose={preview.close} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user