import mimetypes import os import posixpath from datetime import datetime, timezone from pathlib import Path from typing import Generator, Optional, Tuple from urllib.parse import quote import jwt from bson import ObjectId from fastapi import FastAPI, Header, HTTPException, Request, Response from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse, StreamingResponse from pymongo import MongoClient def _env_str(name: str, default: str = "") -> str: v = os.environ.get(name) return v.strip() if isinstance(v, str) and v.strip() else default JWT_SECRET = _env_str("JWT_SECRET") MONGO_URI = _env_str("MONGO_URI") VIDEO_FILES_DIR = Path(_env_str("VIDEO_FILES_DIR", str(Path.cwd() / "public" / "video"))).resolve() COOKIE_MAX_AGE_SECONDS = int(_env_str("VIDEO_SESSION_MAX_AGE_SECONDS", "28800")) # 8h COOKIE_SAMESITE = _env_str("VIDEO_COOKIE_SAMESITE", "Lax") # Lax|Strict|None def _parse_bearer(authorization: Optional[str]) -> str: raw = (authorization or "").strip() if raw.lower().startswith("bearer "): return raw[7:].strip() return "" def _is_safe_relative_file(p: str) -> bool: if not isinstance(p, str): return False s = p.strip() if not s or "\x00" in s: return False if s.startswith("/") or s.startswith("\\"): return False norm = posixpath.normpath(s.replace("\\", "/")) if norm == ".." or norm.startswith("../"): return False return True def _guess_content_type(file_path: Path) -> str: ctype, _ = mimetypes.guess_type(str(file_path)) return ctype or "application/octet-stream" def _parse_range(range_header: str, file_size: int) -> Optional[Tuple[int, int]]: # Only supports a single bytes range: "bytes=start-end" raw = (range_header or "").strip() if not raw: return None if not raw.lower().startswith("bytes="): return None spec = raw[6:].strip() if "," in spec: return None if "-" not in spec: return None start_s, end_s = spec.split("-", 1) start = int(start_s) if start_s else 0 end = int(end_s) if end_s else (file_size - 1) if start < 0 or end < 0 or start > end or start >= file_size: return None end = min(end, file_size - 1) return start, end def _file_iter(path: Path, start: int, end: int, chunk_size: int = 1024 * 1024) -> Generator[bytes, None, None]: with path.open("rb") as f: f.seek(start) remaining = end - start + 1 while remaining > 0: n = min(chunk_size, remaining) data = f.read(n) if not data: break remaining -= len(data) yield data def _get_token(req: Request, authorization: Optional[str]) -> str: # Prefer cookie (for