"""What a library file previews as — the single home of the preview allow-list. A generic file (`media_kind == "file"`; video and audio have their players) previews by its stored `container`, the upload's cleaned extension, and ONLY through this table: an image is shown as an image, a PDF in the browser's own viewer, and a text format as plain text. Anything else has no preview. The served Content-Type comes from here too, never from a guess on the name, and HTML / SVG sit in the TEXT list on purpose: rendered on this origin, an uploaded page would run with the reader's session. The route adds `CSP: sandbox` on top, so even a mislabelled file (a "png" that is really a page) gets no script and no origin. The serializer hands the kind to the client (`asset.preview`), so the library and the server can never disagree on which files open a preview. """ import codecs from typing import Literal from app.downloads import mediakind PreviewKind = Literal["image", "pdf", "text"] IMAGE_TYPES: dict[str, str] = { "png": "image/png", "jpg": "image/jpeg", "jpeg": "image/jpeg", "gif": "image/gif", "webp": "image/webp", "avif": "image/avif", "bmp": "image/bmp", } PDF_TYPE = "application/pdf" # Read as text: notes, data, subtitles, config and source. HTML and SVG belong here (see above). TEXT_EXTS: frozenset[str] = frozenset( { "txt", "text", "md", "markdown", "log", "csv", "tsv", "json", "jsonl", "ndjson", "xml", "yaml", "yml", "toml", "ini", "conf", "cfg", "properties", "env", "srt", "vtt", "ass", "ssa", "sub", "nfo", "cue", "m3u", "m3u8", "html", "htm", "xhtml", "svg", "css", "scss", "less", "js", "mjs", "cjs", "ts", "tsx", "jsx", "py", "rb", "php", "pl", "lua", "go", "rs", "java", "kt", "kts", "swift", "c", "h", "cc", "cpp", "hpp", "cs", "sh", "bash", "zsh", "fish", "ps1", "bat", "cmd", "sql", "r", "dart", "diff", "patch", "tex", "rst", "adoc", "gradle", "dockerfile", } ) # fmt: skip TEXT_TYPE = "text/plain; charset=utf-8" # A text preview is the file's head: enough to read, never a whole log loaded into a tab. TEXT_PREVIEW_BYTES = 1 << 20 # A NUL byte in the first this-many bytes means the "text" is not text. BINARY_SNIFF_BYTES = 8192 def kind(media_kind: str | None, container: str | None) -> PreviewKind | None: """The preview a library file gets, or None (a media file plays instead; others download).""" if media_kind != mediakind.FILE: return None ext = (container or "").lower() if ext in IMAGE_TYPES: return "image" if ext == "pdf": return "pdf" if ext in TEXT_EXTS: return "text" return None def media_type(preview: PreviewKind, container: str | None) -> str: """The Content-Type a preview is served with: from this table, never guessed from the name.""" if preview == "image": return IMAGE_TYPES[(container or "").lower()] if preview == "pdf": return PDF_TYPE return TEXT_TYPE # A text that is not UTF-8 is read as Central European Windows: what an old Hungarian subtitle or # note is written in (its ő and ű are not in Latin-1). FALLBACK_ENCODING = "cp1250" _BOMS = ( (codecs.BOM_UTF8, "utf-8-sig"), (codecs.BOM_UTF16_LE, "utf-16"), (codecs.BOM_UTF16_BE, "utf-16"), ) def looks_binary(head: bytes) -> bool: """Whether the start of a "text" file is binary after all (a NUL in it).""" return b"\x00" in head[:BINARY_SNIFF_BYTES] def text_utf8(head: bytes) -> bytes | None: """A text file's head as UTF-8, the one charset the preview is served in; None when it is not text. A byte-order mark names its encoding (UTF-16 is full of NULs, so it is read before the binary check); else UTF-8 is kept as it is — a character cut at the size cap included, the reader shows that one as a replacement mark — and anything else is read as `FALLBACK_ENCODING`.""" for bom, encoding in _BOMS: if head.startswith(bom): return head.decode(encoding, errors="replace").encode() if looks_binary(head): return None try: head.decode("utf-8") except UnicodeDecodeError as e: # silent-ok: not UTF-8 is an answer — read it as the fallback if not (e.reason == "unexpected end of data" and e.end == len(head)): return head.decode(FALLBACK_ENCODING, errors="replace").encode() return head