"""Preview HTTP blueprint: routing, caching and response building. All conversion work is delegated to the mediapreview package (worker pool, OnlyOffice integration, classification); this module only wires it into Sanic with auth, etag negotiation and the in-memory response cache. """ import asyncio import re import urllib.parse from pathlib import PurePosixPath from urllib.parse import unquote from wsgiref.handlers import format_date_time import httpx from mediapreview import CachedPreview, PreviewCache, is_previewable_path from mediapreview.formats import OFFICE_PREVIEW_SUFFIXES from mediapreview.formats import expected_backend as _expected_preview_backend from mediapreview.office import onlyoffice_error_short_text from mediapreview.pool import ( PREVIEW_TIMEOUT, PreviewError, PreviewPoolClosedError, PreviewTimeoutError, generate_office_preview, run_preview, ) from sanic import Blueprint, empty, raw, redirect from sanic.exceptions import NotFound from sanic.log import logger from cista import auth, config, sharefs, watching from cista.fileio import fuid from cista.util.filename import sanitize bp = Blueprint("preview", url_prefix="/preview") # Global preview cache instance _preview_cache = PreviewCache(capacity=500) def _shorten_error(detail: str) -> str: """Shorten an upstream backend error for the single-line access log. Backend errors arrive verbatim from ffmpeg/pyvips/pymupdf and often carry an '[Errno N]' prefix, the input file path, and multi-line library noise — all redundant with the URL already in the log line. Keep the first line, drop the bracket prefix, and cut at the first ': ' separator. """ lines = detail.splitlines() if not lines: return "" first_line = re.sub(r"^\[[^\]]*\]\s*", "", lines[0].strip()) return first_line.split(": ", 1)[0] @bp.on_request async def verify_preview(request): """Verify access to preview routes.""" await auth.verify(request) @bp.get("/") async def preview(req, path): """Preview a file""" maxsize = int(req.args.get("px", 1024)) maxzoom = float(req.args.get("zoom", 2.0)) quality = int(req.args.get("q", 60)) share_token = auth.request_share_token(req) if share_token is not None: rel, _real_rel, filepath, is_root = sharefs.resolve_virtual_path( share_token, path ) if is_root: raise NotFound from None else: rel = PurePosixPath(sanitize(unquote(path))) filepath = config.config.path / rel try: stat = filepath.lstat() except FileNotFoundError: raise NotFound from None if not is_previewable_path(filepath): return empty(415) etag = config.derived_secret( "preview", rel, stat.st_mtime_ns, quality, maxsize, maxzoom ).hex() if req.headers.if_none_match == etag: # The client has it cached, respond 304 Not Modified return empty(304, headers={"etag": etag}) # Check in-memory cache first (includes headers) cached = _preview_cache.get(etag) if cached is not None: logger.debug(f"Preview cache hit: {rel}") return raw(cached.body, headers=cached.headers) # Generate preview try: if filepath.suffix.lower() in OFFICE_PREVIEW_SUFFIXES: img, preview_resp = await asyncio.wait_for( generate_office_preview(filepath, quality, maxsize, maxzoom), timeout=PREVIEW_TIMEOUT, ) else: img, preview_resp = await asyncio.wait_for( run_preview(filepath, quality, maxsize, maxzoom), timeout=PREVIEW_TIMEOUT, ) except TimeoutError: req.ctx.log_extra = f"{_expected_preview_backend(filepath)} timeout" return empty(503) except PreviewTimeoutError as e: req.ctx.log_extra = ( f"{(e.backend or _expected_preview_backend(filepath))} timeout" ) return empty(503) except httpx.HTTPStatusError: req.ctx.log_extra = "onlyoffice N/A" return empty(503) except httpx.RequestError: req.ctx.log_extra = "onlyoffice N/A" return empty(503) except RuntimeError as e: detail = str(e) if detail.startswith("OnlyOffice"): req.ctx.log_extra = onlyoffice_error_short_text(detail) return empty(503) raise except PreviewPoolClosedError: # Server is shutting down; not an error, just a cancelled preview. req.ctx.log_extra = "preview cancelled" return empty(503) except PreviewError as e: detail = str(e) if detail == "preview worker error" and e.stderr: captured = e.stderr.strip() if captured: detail = captured.splitlines()[0] # The worker already logged the failure (with traceback where the # error occurred) — annotate the access log instead of re-logging, # with a shortened reason. In dev mode, print the full error too. backend = e.backend or _expected_preview_backend(filepath) if req.app.debug: full = detail if e.stderr and e.stderr.strip() not in detail: full = f"{detail}\n{e.stderr.strip()}" logger.warning("[%s] preview failed: %s", backend, full.strip()) short = _shorten_error(detail) req.ctx.log_extra = f"{backend}: {short}" if short else backend return empty(422) except asyncio.CancelledError: # Server shutdown or client disconnect: the connection is being torn # down, so responding is impossible — just annotate the access log. req.ctx.log_extra = "preview cancelled" raise except Exception: logger.exception("Unhandled preview error for %s", filepath) return empty(500) if preview_resp and preview_resp.backend: if preview_resp.timings: timing_detail = "/".join( str(round(value)) for value in preview_resp.timings ) req.ctx.log_extra = f"{preview_resp.backend} {timing_detail} ➛" else: req.ctx.log_extra = preview_resp.backend if not img: # Preview generation failed, redirect to the file itself return redirect(f"/files/{path}", status=303) # Store aspect ratio if the worker returned dimensions if preview_resp and preview_resp.width and preview_resp.height: ar = round(preview_resp.height / preview_resp.width, 2) fuid_str = fuid(stat) watching.notify_ar(fuid_str, ar) # Build headers and cache the full response preview_mime = ( preview_resp.mime if preview_resp is not None and preview_resp.mime is not None else "image/avif" ) savename = PurePosixPath(filepath.name).with_suffix(".avif") headers = { "etag": etag, "last-modified": format_date_time(stat.st_mtime), "cache-control": "max-age=604800, immutable" + ("" if config.config.public else ", private"), "content-type": preview_mime, "content-disposition": f"inline; filename*=UTF-8''{urllib.parse.quote(savename.as_posix())}", } _preview_cache.set(etag, CachedPreview(headers=headers, body=img)) return raw(img, headers=headers)