#!/usr/bin/env python3 import base64 import hashlib import os import re import secrets import string import subprocess import time from pathlib import Path from threading import Lock import magic from flask import Flask, Response, jsonify, request, send_file, send_from_directory app = Flask(__name__, static_folder=None) app.config["MAX_CONTENT_LENGTH"] = 25 * 1000 * 1000 # 25 MB upload cap UPLOAD_DIR = Path(__file__).parent / "uploads" UPLOAD_DIR.mkdir(mode=0o755, exist_ok=True) ALLOWED_MIME = { "image/png": "png", "image/jpeg": "jpg", "image/gif": "gif", "image/webp": "webp", "image/bmp": "bmp", } _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"), Path.home() / ".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(UPLOAD_DIR, "nyan.png") @app.route("/uploads/") def uploads(filename: str): if Path(filename).suffix.lower() == ".svg": return Response("not found", status=404) return send_from_directory(UPLOAD_DIR, filename) @app.route("/upload", methods=["POST"]) def upload(): if "image" not in request.files: return jsonify({"error": "no file provided"}), 400 f = request.files["image"] if not f.filename: return jsonify({"error": "empty filename"}), 400 data = f.read() if not data: return jsonify({"error": "empty file"}), 400 mime = magic.from_buffer(data, mime=True) if mime not in ALLOWED_MIME: return jsonify({"error": f"invalid file type: {mime}"}), 400 ext = ALLOWED_MIME[mime] basename = re.sub(r"[^a-zA-Z0-9_-]", "_", Path(f.filename).stem)[:64] or "image" epoch = int(time.time()) rand = "".join(secrets.choice(string.ascii_lowercase + string.digits) for _ in range(16)) filename = f"{basename}_{epoch}-{rand}.{ext}" (UPLOAD_DIR / filename).write_bytes(data) return jsonify({"filename": filename, "url": f"uploads/{filename}"}) @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)