#!/usr/bin/env python3 import base64 import hashlib import os import subprocess import time from pathlib import Path from threading import Lock from flask import Flask, Response, jsonify, request, send_file, send_from_directory app = Flask(__name__, static_folder=None) _CSP = ( "default-src 'self'; " "base-uri 'self'; " "frame-ancestors 'none'; " "object-src 'none'; " "script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; " "style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; " "img-src 'self' data: http: https:; " "font-src 'self' data:; " "connect-src 'self'; " "form-action 'self'" ) @app.after_request def add_security_headers(resp: Response) -> Response: resp.headers.setdefault("Content-Security-Policy", _CSP) resp.headers.setdefault("X-Content-Type-Options", "nosniff") resp.headers.setdefault("X-Frame-Options", "DENY") resp.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin") resp.headers.setdefault("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=()") return resp # build list of allowed font directories _FONT_DIRS: list[Path] = [ Path("/usr/share/fonts"), Path("/usr/local/share/fonts"), ] _FONTS_CACHE_TTL_SECONDS = 300 _fonts_cache_lock = Lock() _fonts_cache_until = 0.0 _fonts_cache_payload: list[dict[str, str]] = [] _fonts_cache_paths_by_token: dict[str, Path] = {} def _allowed_font_dirs() -> list[str]: """return resolved, existent font dirs with trailing separator.""" out = [] for d in _FONT_DIRS: try: out.append(str(d.resolve(strict=True)) + os.sep) except (OSError, RuntimeError): pass return out def _font_token_for_path(path: Path) -> str: digest = hashlib.sha256(str(path).encode("utf-8")).digest() token = base64.urlsafe_b64encode(digest[:18]).decode("ascii") return token.rstrip("=") def _build_fonts_payload() -> tuple[list[dict[str, str]], dict[str, Path]] | None: try: result = subprocess.run( ["fc-list", "--format=%{family}|%{style}|%{file}\n"], capture_output=True, text=True, shell=False, timeout=10, check=False, ) except (FileNotFoundError, subprocess.TimeoutExpired): return None if result.returncode != 0: return None if not result.stdout.strip(): return [], {} allowed_dirs = _allowed_font_dirs() def style_score(style: str) -> int: s = style.strip().lower() if s in ("regular", "roman", "book", "text"): return 0 if s == "bold": return 1 if "italic" in s or "oblique" in s: return 2 return 3 best: dict[str, dict[str, str | int]] = {} for line in result.stdout.splitlines(): parts = line.split("|", 2) if len(parts) < 3: continue family = parts[0].split(",")[0].strip() if not family: continue style = parts[1].split(",")[0].strip() file_path = parts[2].strip() try: real = Path(file_path).resolve(strict=True) except (OSError, RuntimeError): continue real_str = str(real) + os.sep if not any(real_str.startswith(d) for d in allowed_dirs): continue score = style_score(style) if family not in best or score < int(best[family]["score"]): best[family] = {"file": str(real), "score": score} fmt_map = {"ttf": "truetype", "otf": "opentype", "woff": "woff", "woff2": "woff2"} token_map: dict[str, Path] = {} fonts_list = [] for family, entry in best.items(): file_str = str(entry["file"]) real = Path(file_str) token = _font_token_for_path(real) token_map[token] = real fonts_list.append( { "family": family, "file": token, "format": fmt_map.get(real.suffix.lstrip(".").lower(), "truetype"), } ) fonts_list.sort(key=lambda x: x["family"].casefold()) return fonts_list, token_map def _get_fonts_cache() -> list[dict[str, str]]: global _fonts_cache_until, _fonts_cache_payload, _fonts_cache_paths_by_token now = time.monotonic() with _fonts_cache_lock: if _fonts_cache_payload and now < _fonts_cache_until: return _fonts_cache_payload built = _build_fonts_payload() if built is not None: payload, token_map = built _fonts_cache_payload = payload _fonts_cache_paths_by_token = token_map _fonts_cache_until = now + _FONTS_CACHE_TTL_SECONDS return _fonts_cache_payload if not _fonts_cache_payload: _fonts_cache_payload = [] _fonts_cache_paths_by_token = {} _fonts_cache_until = now + 15 return _fonts_cache_payload def _font_path_from_token(token: str) -> Path | None: _get_fonts_cache() with _fonts_cache_lock: return _fonts_cache_paths_by_token.get(token) def _font_path_from_legacy_param(encoded: str) -> Path | None: try: file_str = base64.b64decode(encoded, validate=True).decode("utf-8") except Exception: return None if "\x00" in file_str: return None try: return Path(file_str).resolve(strict=True) except (OSError, RuntimeError): return None @app.route("/") def index(): return send_from_directory(app.root_path, "index.html") @app.route("/favicon.svg") def favicon_svg(): return send_from_directory(app.root_path, "favicon.svg") @app.route("/nyan.png") def nyan_png(): return send_from_directory(app.root_path, "nyan.png") @app.route("/upload", methods=["POST"]) def upload(): return jsonify( { "error": "server-side uploads are disabled; images stay in browser local storage" } ), 410 @app.route("/fonts") def fonts(): fonts_list = _get_fonts_cache() resp = jsonify(fonts_list) resp.headers["Cache-Control"] = f"public, max-age={_FONTS_CACHE_TTL_SECONDS}" return resp @app.route("/font") def font(): token = request.args.get("f", "") if not token: return Response("missing parameter", status=400) path = _font_path_from_token(token) if path is None: path = _font_path_from_legacy_param(token) if path is None: return Response("font not found", status=404) try: real = path.resolve(strict=True) except (OSError, RuntimeError): return Response("font not found", status=404) # path traversal guard: real path must be under an allowed font dir real_str = str(real) + os.sep if not any(real_str.startswith(d) for d in _allowed_font_dirs()): return Response("access denied", status=403) mime_map = { "ttf": "font/ttf", "otf": "font/otf", "woff": "font/woff", "woff2": "font/woff2", } mime = mime_map.get(real.suffix.lstrip(".").lower(), "application/octet-stream") return send_file(real, mimetype=mime, max_age=31536000, conditional=True) if __name__ == "__main__": app.run(host="0.0.0.0", port=3000)