A clip and an editor range can come out as a silent GIF (long side 320/480/640, 12 fps, at most 30 s, one palette per GIF); one over 30 MB is not kept. A shared picture is served inline under the preview's sandbox, and a GIF's link card is the GIF itself up to 15 MB, the still for Facebook's crawler.
118 lines
4.8 KiB
Python
118 lines
4.8 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"
|
|
|
|
# Every preview served inline — the library's viewer and a share link's picture — goes with these.
|
|
# `sandbox` leaves the answer no script and an opaque origin, whatever the file turns out to hold (a
|
|
# "gif" that is really a page); `frame-ancestors 'self'` lets the app's viewer frame a PDF (every
|
|
# other answer keeps the app-wide 'none', main.py), with X-Frame-Options saying the same for old
|
|
# engines.
|
|
SANDBOX_HEADERS: dict[str, str] = {
|
|
"Content-Security-Policy": "sandbox; frame-ancestors 'self'",
|
|
"X-Frame-Options": "SAMEORIGIN",
|
|
}
|
|
|
|
# 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
|