"""What a file in a session directory actually is.
Five extension tables used to answer this question, and they disagreed: one in
``content_store``, one in ``content_publish``'s schema, and three in the
webclient. A ``.md`` report came back "unknown" and got a download link, while
``mimetypes`` — which reads the host's ``/etc/mime.types`` — served a
TypeScript ``.ts`` file as ``video/mp2t`` because the extension is shared with
MPEG transport streams.
Detection order:
1. A strong binary signature in the file's opening kilobytes. Text cannot
start with the PNG or RIFF magic, so the signature wins over the extension:
a file saved under the wrong name still reaches the right viewer.
2. The extension table, which answers everything without a telltale header.
3. For a file with no usable extension: does it look like text?
4. Otherwise unknown.
The values in ``CONTENT_TYPES`` are what ``content_publish`` accepts and what
the webclient branches on; adding one means adding a viewer branch there too.
"""
from __future__ import annotations
from pathlib import Path
CONTENT_TYPES = (
"stl",
"model",
"html",
"svg",
"pdf",
"image",
"video",
"audio",
"text",
"unknown",
)
# Enough for every signature below, including a Matroska track entry.
_SAMPLE_BYTES = 4096
# (content type, MIME). `stl` and `model` share one viewer: the file's own
# extension decides which three.js loader opens it.
_EXTENSION_TYPES: dict[str, tuple[str, str]] = {
# 3D models
".stl": ("stl", "model/stl"),
".glb": ("model", "model/gltf-binary"),
".gltf": ("model", "model/gltf+json"),
# Documents
".html": ("html", "text/html"),
".htm": ("html", "text/html"),
".svg": ("svg", "image/svg+xml"),
".pdf": ("pdf", "application/pdf"),
# Images
".png": ("image", "image/png"),
".jpg": ("image", "image/jpeg"),
".jpeg": ("image", "image/jpeg"),
".gif": ("image", "image/gif"),
".webp": ("image", "image/webp"),
".bmp": ("image", "image/bmp"),
".avif": ("image", "image/avif"),
".ico": ("image", "image/x-icon"),
# Video
".mp4": ("video", "video/mp4"),
".webm": ("video", "video/webm"),
".mov": ("video", "video/quicktime"),
".mkv": ("video", "video/x-matroska"),
".m4v": ("video", "video/x-m4v"),
".ogv": ("video", "video/ogg"),
# Audio
".mp3": ("audio", "audio/mpeg"),
".wav": ("audio", "audio/wav"),
".ogg": ("audio", "audio/ogg"),
".oga": ("audio", "audio/ogg"),
".opus": ("audio", "audio/ogg"),
".m4a": ("audio", "audio/mp4"),
".flac": ("audio", "audio/flac"),
".aac": ("audio", "audio/aac"),
".weba": ("audio", "audio/webm"),
# Text and source. `.ts` belongs here, not to video: without an MPEG
# transport-stream signature a .ts file is TypeScript.
".txt": ("text", "text/plain"),
".text": ("text", "text/plain"),
".log": ("text", "text/plain"),
".md": ("text", "text/markdown"),
".markdown": ("text", "text/markdown"),
".csv": ("text", "text/csv"),
".tsv": ("text", "text/tab-separated-values"),
".json": ("text", "application/json"),
".jsonl": ("text", "application/json"),
".ndjson": ("text", "application/json"),
".yaml": ("text", "application/yaml"),
".yml": ("text", "application/yaml"),
".xml": ("text", "text/xml"),
".ini": ("text", "text/plain"),
".cfg": ("text", "text/plain"),
".conf": ("text", "text/plain"),
".toml": ("text", "text/plain"),
".py": ("text", "text/x-python"),
".js": ("text", "text/javascript"),
".mjs": ("text", "text/javascript"),
".cjs": ("text", "text/javascript"),
".ts": ("text", "text/plain"),
".tsx": ("text", "text/plain"),
".jsx": ("text", "text/javascript"),
".css": ("text", "text/css"),
".scss": ("text", "text/plain"),
".sass": ("text", "text/plain"),
".less": ("text", "text/plain"),
".sh": ("text", "text/x-sh"),
".bash": ("text", "text/x-sh"),
".zsh": ("text", "text/x-sh"),
".sql": ("text", "text/plain"),
".scad": ("text", "text/plain"),
".srt": ("text", "text/plain"),
".vtt": ("text", "text/vtt"),
".diff": ("text", "text/plain"),
".patch": ("text", "text/plain"),
".tex": ("text", "text/plain"),
".c": ("text", "text/x-csrc"),
".h": ("text", "text/x-chdr"),
".cpp": ("text", "text/x-c++src"),
".hpp": ("text", "text/x-c++hdr"),
".java": ("text", "text/x-java"),
".go": ("text", "text/plain"),
".rs": ("text", "text/plain"),
".rb": ("text", "text/plain"),
".php": ("text", "text/plain"),
".kt": ("text", "text/plain"),
".swift": ("text", "text/plain"),
# Opaque containers: no viewer, but the MIME still matters for the download
# and keeps the text heuristic from calling a small archive "text".
".zip": ("unknown", "application/zip"),
".tar": ("unknown", "application/x-tar"),
".gz": ("unknown", "application/gzip"),
".tgz": ("unknown", "application/gzip"),
".7z": ("unknown", "application/x-7z-compressed"),
".rar": ("unknown", "application/vnd.rar"),
".docx": ("unknown", "application/vnd.openxmlformats-officedocument.wordprocessingml.document"),
".xlsx": ("unknown", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"),
".pptx": ("unknown", "application/vnd.openxmlformats-officedocument.presentationml.presentation"),
".odt": ("unknown", "application/vnd.oasis.opendocument.text"),
".ods": ("unknown", "application/vnd.oasis.opendocument.spreadsheet"),
".rtf": ("unknown", "application/rtf"),
".epub": ("unknown", "application/epub+zip"),
}
# MIME families a browser can render itself. `video/` and `audio/` used to be
# missing here, so every media file was sent as an attachment and the player
# got a download instead of a stream.
_INLINE_PREFIXES = ("image/", "audio/", "video/", "text/")
_INLINE_MIMES = frozenset({"application/pdf", "application/json"})
_VIDEO_CODECS = (b"V_VP8", b"V_VP9", b"V_AV1", b"V_MPEG4", b"V_MPEGH", b"V_THEORA")
_AUDIO_CODECS = (b"A_OPUS", b"A_VORBIS", b"A_AAC", b"A_FLAC", b"A_MPEG")
def _read_head(path: str | Path | None) -> bytes:
if path is None:
return b""
try:
with Path(path).open("rb") as handle:
return handle.read(_SAMPLE_BYTES)
except OSError:
return b""
def _mpeg_audio_frame(head: bytes) -> bool:
"""An MP3 without an ID3 tag starts with a frame header, not with a magic.
The check is deliberately narrow — reserved layer and invalid bitrate
are rejected — so ordinary binary data does not read as audio.
"""
if len(head) < 4 or head[0] != 0xFF or (head[1] & 0xE0) != 0xE0:
return False
layer = (head[1] >> 1) & 0x03
bitrate = (head[2] >> 4) & 0x0F
return layer != 0 and bitrate != 0x0F
def _mpeg_transport_stream(head: bytes) -> bool:
"""MPEG-TS: 188-byte packets, each starting with 0x47."""
if len(head) < 377:
return False
return head[0] == 0x47 and head[188] == 0x47 and head[376] == 0x47
def _matroska(head: bytes) -> tuple[str, str] | None:
"""WebM/MKV, audio or video by the tracks it declares."""
is_webm = b"webm" in head[:64]
if any(codec in head for codec in _VIDEO_CODECS):
return "video", "video/webm" if is_webm else "video/x-matroska"
if any(codec in head for codec in _AUDIO_CODECS):
return "audio", "audio/webm" if is_webm else "audio/x-matroska"
return None
def _signature(head: bytes) -> tuple[str, str] | None:
"""A binary format the opening bytes give away, or None."""
if not head:
return None
if head.startswith(b"%PDF-"):
return "pdf", "application/pdf"
if head.startswith(b"\x89PNG\r\n\x1a\n"):
return "image", "image/png"
if head.startswith(b"\xff\xd8\xff"):
return "image", "image/jpeg"
if head.startswith((b"GIF87a", b"GIF89a")):
return "image", "image/gif"
if head.startswith(b"RIFF") and len(head) >= 12:
kind = head[8:12]
if kind == b"WEBP":
return "image", "image/webp"
if kind == b"WAVE":
return "audio", "audio/wav"
if kind == b"AVI ":
return "video", "video/x-msvideo"
if head.startswith(b"OggS"):
return ("video", "video/ogg") if b"theora" in head[:1024] else ("audio", "audio/ogg")
if head.startswith(b"fLaC"):
return "audio", "audio/flac"
if head.startswith(b"ID3"):
return "audio", "audio/mpeg"
if _mpeg_audio_frame(head):
return "audio", "audio/mpeg"
if head.startswith(b"\x1a\x45\xdf\xa3"):
return _matroska(head)
if len(head) >= 12 and head[4:8] == b"ftyp":
brand = head[8:12]
if brand in {b"M4A ", b"M4B ", b"M4P ", b"mp4a"}:
return "audio", "audio/mp4"
if brand in {b"avif", b"avis"}:
return "image", "image/avif"
return "video", "video/mp4"
if head.startswith(b"glTF"):
return "model", "model/gltf-binary"
if _mpeg_transport_stream(head):
return "video", "video/mp2t"
return None
def _looks_like_text(head: bytes) -> bool:
"""True when the sample decodes as text rather than as arbitrary bytes."""
if not head or b"\x00" in head:
return False
try:
text = head.decode("utf-8")
except UnicodeDecodeError:
# A UTF-8 sequence cut at the sample boundary, or a legacy encoding;
# latin-1 always decodes, and the printable ratio still separates
# prose from compressed data.
text = head.decode("latin-1")
if not text:
return False
printable = sum(1 for ch in text if ch.isprintable() or ch in "\n\r\t\f\v")
return printable / len(text) >= 0.9
def detect(filename: str, path: str | Path | None = None) -> tuple[str, str]:
"""Return ``(content_type, mime)`` for a file.
``path`` is read for its signature; without it only the name is used.
"""
suffix = Path(filename).suffix.lower()
head = _read_head(path)
found = _signature(head)
if found is not None:
return found
known = _EXTENSION_TYPES.get(suffix)
if known is not None:
return known
if _looks_like_text(head):
return "text", "text/plain"
return "unknown", "application/octet-stream"
def content_type_of(filename: str, path: str | Path | None = None) -> str:
return detect(filename, path)[0]
def mime_of(filename: str, path: str | Path | None = None) -> str:
return detect(filename, path)[1]
def normalize_content_type(value: str | None) -> str | None:
"""An explicitly requested type, or None when the caller should detect.
A model naming a type we do not have a viewer for is not an error; it is a
missing hint, so detection answers instead of the value being stored as is.
"""
if not value:
return None
value = value.strip().lower()
if value in CONTENT_TYPES and value != "unknown":
return value
return None
def is_inline_mime(mime: str) -> bool:
"""Whether a browser can show this response in place instead of saving it."""
return mime.startswith(_INLINE_PREFIXES) or mime in _INLINE_MIMES