- GET /api/files/{id}/preview: an allow-list on the stored extension (downloads/preview.py) picks
image / PDF / text; served inline under `CSP: sandbox; frame-ancestors 'self'`, the type from the
table; HTML and SVG arrive as plain text; a text is its first 1 MiB (X-Preview-Truncated), sent
as UTF-8 whatever it was written in (BOM, UTF-8, else cp1250); a NUL means binary: 415
- the list says which preview each file gets (asset.preview); FE/BE contract on kinds and header
- the viewer (shared dialog shell): ← / → through the list's such files in its order, Save, open in
a tab, the file's menu; one stage height for every kind; a no-preview card offers the file
- the name, the card's picture and the menu's Preview open it; an image up to 8 MB is its own
thumbnail
108 lines
4.3 KiB
Python
108 lines
4.3 KiB
Python
"""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
|