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:
@@ -227,10 +227,16 @@ async def unhandled_error_handler(_request: Request, exc: Exception):
|
||||
# pm, users) keep them — the global dependency guarantees nothing is open
|
||||
# by omission.
|
||||
from app.routers.auth import router as _auth_router # noqa: E402
|
||||
# The files router enforces its own access check per route: a browser cannot
|
||||
# attach an Authorization header to <iframe>/<img> loads, so the two file GET
|
||||
# routes accept a short-lived signed ?t= token as well as a Bearer session.
|
||||
from app.routers.files import router as _files_router # noqa: E402
|
||||
|
||||
for router in ROUTERS:
|
||||
if router is _auth_router:
|
||||
app.include_router(router, prefix=API_V1_PREFIX)
|
||||
elif router is _files_router:
|
||||
app.include_router(router, prefix=API_V1_PREFIX)
|
||||
else:
|
||||
app.include_router(router, prefix=API_V1_PREFIX, dependencies=[Depends(get_current_user)])
|
||||
|
||||
|
||||
@@ -17,14 +17,15 @@ import os
|
||||
import uuid
|
||||
from urllib.parse import quote
|
||||
|
||||
from fastapi import APIRouter, Depends, File, Response, UploadFile
|
||||
from fastapi import APIRouter, Depends, File, Request, Response, UploadFile
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth import require_role
|
||||
from app.auth import UserContext, get_current_user, get_optional_user, require_role
|
||||
from app.db.engine import get_db
|
||||
from app.models.procurement import Quote
|
||||
from app.services import file_service
|
||||
from app.utils.errors import NotFoundError, ValidationError
|
||||
from app.utils.errors import NotFoundError, UnauthorizedError, ValidationError
|
||||
from app.utils.preview_token import make_preview_token, verify_preview_token
|
||||
|
||||
router = APIRouter(prefix="/quotes", tags=["files"])
|
||||
|
||||
@@ -41,13 +42,17 @@ ALLOWED_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp", ".gif", ".pdf"}
|
||||
MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024
|
||||
|
||||
|
||||
def _content_disposition(file_name: str) -> str:
|
||||
def _content_disposition(file_name: str, inline: bool = False) -> str:
|
||||
"""Header-safe Content-Disposition for non-ASCII (e.g. Persian) filenames.
|
||||
|
||||
HTTP headers are encoded as latin-1, so a Persian name raises
|
||||
UnicodeEncodeError and kills the response. Per RFC 6266 / RFC 5987 the
|
||||
UTF-8 name goes percent-encoded in ``filename*``, with an ASCII fallback
|
||||
in ``filename`` for old clients.
|
||||
|
||||
``inline=True`` makes the browser render the file (PDF in a viewer,
|
||||
image in the tab) instead of downloading it — required by the preview
|
||||
modal, whose <iframe> would otherwise trigger a download.
|
||||
"""
|
||||
raw = file_name.encode("ascii", "ignore").decode("ascii")
|
||||
# keep the fallback header-safe: no quotes, no control characters
|
||||
@@ -56,9 +61,37 @@ def _content_disposition(file_name: str) -> str:
|
||||
stem = fallback[: -len(ext)].strip() if ext and fallback.lower().endswith(ext.lower()) else fallback
|
||||
if not stem or not any(ch.isalnum() for ch in stem):
|
||||
# nothing meaningful survived ASCII-folding (e.g. a pure-Persian
|
||||
# name): use a generic attachment name that keeps the extension
|
||||
fallback = f"attachment{ext}" if ext else "attachment"
|
||||
return f"attachment; filename=\"{fallback}\"; filename*=UTF-8''{quote(file_name)}"
|
||||
# name): use a generic name that keeps the extension
|
||||
fallback = f"{'preview' if inline else 'attachment'}{ext}" if ext else (
|
||||
"preview" if inline else "attachment"
|
||||
)
|
||||
kind = "inline" if inline else "attachment"
|
||||
return f"{kind}; filename=\"{fallback}\"; filename*=UTF-8''{quote(file_name)}"
|
||||
|
||||
|
||||
async def _require_file_access(
|
||||
request: Request,
|
||||
token: str | None,
|
||||
quote_id: uuid.UUID,
|
||||
kind: str,
|
||||
attachment_id: uuid.UUID,
|
||||
db: AsyncSession,
|
||||
) -> None:
|
||||
"""Authorise a file read by session header OR a signed ``?t=`` token.
|
||||
|
||||
These two GET routes sit outside the global Bearer dependency because a
|
||||
browser cannot attach an Authorization header to <iframe>/<img> loads.
|
||||
A caller is accepted when it presents either a valid Casdoor session
|
||||
(resolved by get_optional_user) or a signed token scoped to exactly
|
||||
this file, which expires within minutes.
|
||||
"""
|
||||
if request is not None and request.headers.get("authorization"):
|
||||
user = await get_optional_user(request, db)
|
||||
if user is not None and user.role is not None:
|
||||
return
|
||||
if token and verify_preview_token(token, quote_id, kind, attachment_id):
|
||||
return
|
||||
raise UnauthorizedError("دسترسی به این فایل مجاز نیست — ابتدا وارد شوید")
|
||||
|
||||
|
||||
async def _quote_or_404(db: AsyncSession, quote_id: uuid.UUID) -> Quote:
|
||||
@@ -92,7 +125,7 @@ async def _attachment_or_404(
|
||||
# Invoice — قابل بارگذاری در هر وضعیتی (از صفحهی ویرایش)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.post("/{quote_id}/invoice", status_code=201)
|
||||
@router.post("/{quote_id}/invoice", status_code=201, dependencies=[Depends(get_current_user)])
|
||||
async def upload_invoice(quote_id: uuid.UUID, file: UploadFile = File(...), db: AsyncSession = Depends(get_db)):
|
||||
await _quote_or_404(db, quote_id)
|
||||
mime = _validate_upload(file)
|
||||
@@ -103,29 +136,67 @@ async def upload_invoice(quote_id: uuid.UUID, file: UploadFile = File(...), db:
|
||||
return {"data": stored.to_dict()}
|
||||
|
||||
|
||||
@router.get("/{quote_id}/invoice")
|
||||
@router.get("/{quote_id}/invoice", dependencies=[Depends(get_current_user)])
|
||||
async def list_invoices(quote_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
|
||||
await _quote_or_404(db, quote_id)
|
||||
attachments = await file_service.list_for(db, quote_id, "invoice")
|
||||
return {"data": [a.to_dict() for a in attachments]}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Preview URL — a real, short-lived URL the browser can load in an
|
||||
# <iframe>/<img> (which cannot send an Authorization header). Using a real
|
||||
# URL instead of a blob: avoids download managers that hijack blob URLs and
|
||||
# leave 0-byte files behind.
|
||||
# ---------------------------------------------------------------------------
|
||||
@router.get("/{quote_id}/preview-url/{kind}/{attachment_id}")
|
||||
async def preview_url(
|
||||
quote_id: uuid.UUID,
|
||||
kind: str,
|
||||
attachment_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_ctx: UserContext = Depends(get_current_user),
|
||||
):
|
||||
if kind not in ("invoice", "proforma"):
|
||||
raise ValidationError("نوع پیوست نامعتبر است", field="kind")
|
||||
await _quote_or_404(db, quote_id)
|
||||
await _attachment_or_404(db, quote_id, attachment_id, kind)
|
||||
token = make_preview_token(quote_id, kind, attachment_id)
|
||||
# مسیر نسبی برگردانده میشود؛ فرانت با BASE_URL خودش کاملش میکند
|
||||
return {
|
||||
"data": {
|
||||
"url": f"/quotes/{quote_id}/{kind}/{attachment_id}?disposition=inline&t={token}",
|
||||
"token": token,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{quote_id}/invoice/{attachment_id}")
|
||||
async def download_invoice(quote_id: uuid.UUID, attachment_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
|
||||
async def download_invoice(
|
||||
quote_id: uuid.UUID,
|
||||
attachment_id: uuid.UUID,
|
||||
disposition: str = "attachment",
|
||||
t: str | None = None,
|
||||
request: Request = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
await _quote_or_404(db, quote_id)
|
||||
attachment = await _attachment_or_404(db, quote_id, attachment_id, "invoice")
|
||||
await _require_file_access(request, t, quote_id, "invoice", attachment_id, db)
|
||||
content = await file_service.read_bytes(attachment)
|
||||
return Response(
|
||||
content=content,
|
||||
media_type=attachment.mime_type,
|
||||
headers={
|
||||
"Content-Disposition": _content_disposition(attachment.file_name),
|
||||
"Content-Disposition": _content_disposition(
|
||||
attachment.file_name, inline=disposition == "inline"
|
||||
),
|
||||
"X-File-Id": str(attachment.id),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{quote_id}/invoice/{attachment_id}", dependencies=[Depends(_attachment_deleter)])
|
||||
@router.delete("/{quote_id}/invoice/{attachment_id}", dependencies=[Depends(_attachment_deleter), Depends(get_current_user)])
|
||||
async def delete_invoice(quote_id: uuid.UUID, attachment_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
|
||||
await _quote_or_404(db, quote_id)
|
||||
attachment = await _attachment_or_404(db, quote_id, attachment_id, "invoice")
|
||||
@@ -137,7 +208,7 @@ async def delete_invoice(quote_id: uuid.UUID, attachment_id: uuid.UUID, db: Asyn
|
||||
# Proforma — no status restriction
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.post("/{quote_id}/proforma", status_code=201)
|
||||
@router.post("/{quote_id}/proforma", status_code=201, dependencies=[Depends(get_current_user)])
|
||||
async def upload_proforma(quote_id: uuid.UUID, file: UploadFile = File(...), db: AsyncSession = Depends(get_db)):
|
||||
await _quote_or_404(db, quote_id)
|
||||
mime = _validate_upload(file)
|
||||
@@ -148,7 +219,7 @@ async def upload_proforma(quote_id: uuid.UUID, file: UploadFile = File(...), db:
|
||||
return {"data": stored.to_dict()}
|
||||
|
||||
|
||||
@router.get("/{quote_id}/proforma")
|
||||
@router.get("/{quote_id}/proforma", dependencies=[Depends(get_current_user)])
|
||||
async def list_proformas(quote_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
|
||||
await _quote_or_404(db, quote_id)
|
||||
attachments = await file_service.list_for(db, quote_id, "proforma")
|
||||
@@ -156,21 +227,31 @@ async def list_proformas(quote_id: uuid.UUID, db: AsyncSession = Depends(get_db)
|
||||
|
||||
|
||||
@router.get("/{quote_id}/proforma/{attachment_id}")
|
||||
async def download_proforma(quote_id: uuid.UUID, attachment_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
|
||||
async def download_proforma(
|
||||
quote_id: uuid.UUID,
|
||||
attachment_id: uuid.UUID,
|
||||
disposition: str = "attachment",
|
||||
t: str | None = None,
|
||||
request: Request = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
await _quote_or_404(db, quote_id)
|
||||
attachment = await _attachment_or_404(db, quote_id, attachment_id, "proforma")
|
||||
await _require_file_access(request, t, quote_id, "proforma", attachment_id, db)
|
||||
content = await file_service.read_bytes(attachment)
|
||||
return Response(
|
||||
content=content,
|
||||
media_type=attachment.mime_type,
|
||||
headers={
|
||||
"Content-Disposition": _content_disposition(attachment.file_name),
|
||||
"Content-Disposition": _content_disposition(
|
||||
attachment.file_name, inline=disposition == "inline"
|
||||
),
|
||||
"X-File-Id": str(attachment.id),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{quote_id}/proforma/{attachment_id}", dependencies=[Depends(_attachment_deleter)])
|
||||
@router.delete("/{quote_id}/proforma/{attachment_id}", dependencies=[Depends(_attachment_deleter), Depends(get_current_user)])
|
||||
async def delete_proforma(quote_id: uuid.UUID, attachment_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
|
||||
await _quote_or_404(db, quote_id)
|
||||
attachment = await _attachment_or_404(db, quote_id, attachment_id, "proforma")
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Ephemeral, URL-safe file-preview tokens.
|
||||
|
||||
Why this exists
|
||||
---------------
|
||||
File downloads require an ``Authorization: Bearer`` header, but a browser
|
||||
cannot attach headers to the requests made by ``<iframe src>`` / ``<img
|
||||
src>`` — which is what the preview modal uses. The previous workaround
|
||||
fetched the bytes with ``fetch`` and rendered a local ``blob:`` URL, but
|
||||
download managers (IDM et al.) hook ``blob:`` URLs, fail to resolve them
|
||||
outside the originating document, and hand the browser a 0-byte file under
|
||||
a bare UUID name — so the preview broke and a junk download appeared.
|
||||
|
||||
Instead we mint a short-lived HMAC token scoped to a single attachment and
|
||||
put it in the query string, giving the iframe a *real* URL that also
|
||||
carries its own authorization. The token is not a session credential: it
|
||||
authorises exactly one (quote_id, kind, attachment_id) triple and expires
|
||||
in minutes.
|
||||
|
||||
Format: ``<expiry-unix>.<hex-hmac>`` over ``"{quote_id}:{kind}:{attachment_id}:{expiry}"``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import time
|
||||
import uuid
|
||||
|
||||
from app.config import settings
|
||||
|
||||
# پیشنمایش کوتاهعمر است — فقط برای باز شدن مودال، نه اشتراکگذاری لینک
|
||||
DEFAULT_TTL_SECONDS = 300
|
||||
|
||||
|
||||
def _secret() -> bytes:
|
||||
"""Key for the HMAC. Reuses existing secrets so no new config is needed."""
|
||||
base = (
|
||||
settings.CASDOOR_CLIENT_SECRET
|
||||
or settings.MINIO_SECRET_KEY
|
||||
or "procurement-preview-fallback"
|
||||
)
|
||||
return base.encode("utf-8")
|
||||
|
||||
|
||||
def _sign(quote_id: uuid.UUID, kind: str, attachment_id: uuid.UUID, expiry: int) -> str:
|
||||
msg = f"{quote_id}:{kind}:{attachment_id}:{expiry}".encode("utf-8")
|
||||
return hmac.new(_secret(), msg, hashlib.sha256).hexdigest()
|
||||
|
||||
|
||||
def make_preview_token(
|
||||
quote_id: uuid.UUID, kind: str, attachment_id: uuid.UUID, ttl: int = DEFAULT_TTL_SECONDS
|
||||
) -> str:
|
||||
expiry = int(time.time()) + ttl
|
||||
return f"{expiry}.{_sign(quote_id, kind, attachment_id, expiry)}"
|
||||
|
||||
|
||||
def verify_preview_token(
|
||||
token: str, quote_id: uuid.UUID, kind: str, attachment_id: uuid.UUID
|
||||
) -> bool:
|
||||
"""True when the token is well-formed, unexpired and matches this file."""
|
||||
if not token or "." not in token:
|
||||
return False
|
||||
expiry_raw, signature = token.split(".", 1)
|
||||
try:
|
||||
expiry = int(expiry_raw)
|
||||
except ValueError:
|
||||
return False
|
||||
if expiry < int(time.time()):
|
||||
return False
|
||||
expected = _sign(quote_id, kind, attachment_id, expiry)
|
||||
return hmac.compare_digest(expected, signature)
|
||||
Reference in New Issue
Block a user