A font filename carries family, weight range and subset, so its bytes never change -- re-fetching 133 KB on every reload to stay honest about a file that cannot go stale is the wrong trade. Code stays no-store: the editor is edited while it is running. *.woff2 declared binary so autocrlf can never rewrite one.
95 lines
3.1 KiB
Python
95 lines
3.1 KiB
Python
"""Static serving for the app and for module JS.
|
|
|
|
The resolution is a pure function returning a `Served` value, so the allowlist and
|
|
the traversal guard are testable without aiohttp or a running ComfyUI.
|
|
"""
|
|
|
|
import mimetypes
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
from typing import FrozenSet
|
|
|
|
# The editor iterates fast; a cached stale module is worse than a re-read.
|
|
NO_STORE = {"Cache-Control": "no-store, max-age=0"}
|
|
|
|
# Fonts are the exception: the bytes for a given filename never change (family,
|
|
# weight range and subset are all in the name), and they are by far the largest
|
|
# thing served. Re-fetching 133 KB on every reload to stay honest about a file
|
|
# that cannot go stale is a bad trade. Replacing a face means a new filename.
|
|
IMMUTABLE = {"Cache-Control": "public, max-age=31536000, immutable"}
|
|
IMMUTABLE_SUFFIXES = frozenset({".woff2"})
|
|
|
|
# Some platforms guess text/plain for .js. With ES modules that is fatal — the
|
|
# browser refuses the module outright — so every type we care about is explicit.
|
|
EXPLICIT_TYPES = {
|
|
".js": "text/javascript",
|
|
".css": "text/css",
|
|
".html": "text/html",
|
|
".svg": "image/svg+xml",
|
|
".woff2": "font/woff2",
|
|
".json": "application/json",
|
|
}
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Served:
|
|
status: int
|
|
body: bytes = b""
|
|
content_type: str = "text/plain"
|
|
headers: dict = field(default_factory=lambda: dict(NO_STORE))
|
|
|
|
|
|
def headers_for(path: Path) -> dict:
|
|
if path.suffix.lower() in IMMUTABLE_SUFFIXES:
|
|
return dict(IMMUTABLE)
|
|
return dict(NO_STORE)
|
|
|
|
|
|
def content_type_for(path: Path) -> str:
|
|
suffix = path.suffix.lower()
|
|
if suffix in EXPLICIT_TYPES:
|
|
return EXPLICIT_TYPES[suffix]
|
|
return mimetypes.guess_type(str(path))[0] or "application/octet-stream"
|
|
|
|
|
|
def resolve(root: Path, rel: str, allowed: FrozenSet[str]) -> Path:
|
|
"""Path for `rel` under `root`, or raise.
|
|
|
|
ValueError -> the request tried to leave the root (403)
|
|
LookupError -> nothing to serve here (404)
|
|
"""
|
|
root = root.resolve()
|
|
cleaned = (rel or "").lstrip("/")
|
|
if "\x00" in cleaned:
|
|
raise ValueError("null byte in path")
|
|
try:
|
|
target = (root / cleaned).resolve()
|
|
except (OSError, ValueError) as exc: # malformed path, symlink loop
|
|
raise ValueError("unresolvable path") from exc
|
|
|
|
# The guard is on the RESOLVED path, so `..` and symlinks are both covered.
|
|
if target != root and root not in target.parents:
|
|
raise ValueError("outside root")
|
|
|
|
if target.is_dir():
|
|
if ".html" not in allowed:
|
|
raise LookupError("directory")
|
|
target = target / "index.html"
|
|
|
|
if target.suffix.lower() not in allowed:
|
|
raise LookupError("extension not allowed")
|
|
if not target.is_file():
|
|
raise LookupError("not a file")
|
|
return target
|
|
|
|
|
|
def serve(root: Path, rel: str, allowed: FrozenSet[str]) -> Served:
|
|
try:
|
|
target = resolve(root, rel, allowed)
|
|
except ValueError:
|
|
return Served(403)
|
|
except LookupError:
|
|
return Served(404)
|
|
return Served(200, target.read_bytes(), content_type_for(target),
|
|
headers_for(target))
|