"""The clips site: its pages plus a small API that talks to Zipline with the user's token from a cookie.""" import colorsys import hashlib import html import logging import random import re from collections.abc import Callable from pathlib import Path from urllib.parse import quote import httpx from fastapi import APIRouter, Request from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, Response from pydantic import BaseModel from .errors import error, zipline_message from .media import still_jpeg, video_size from .names import clean_name from .previews import Previews from .site_auth import LoginLimiter, TicketStore log = logging.getLogger("uvicorn.error") COOKIE = "ys" COOKIE_AGE = 30 * 24 * 3600 PER_PAGE = 24 SITE_DIR = Path(__file__).resolve().parent.parent / "site" SESSION_RE = re.compile(r"zipline_session=([^;]+)") class Login(BaseModel): username: str password: str class Rename(BaseModel): name: str def client_ip(request: Request) -> str: # Cloudflare sets CF-Connecting-IP; over Tailscale, tailscale serve sets X-Forwarded-For forwarded = request.headers.get("x-forwarded-for", "").split(",")[0].strip() return request.headers.get("cf-connecting-ip") or forwarded or (request.client.host if request.client else "?") def file_view(f: dict) -> dict: thumb = (f.get("thumbnail") or {}).get("path") return { "id": f["id"], "name": f["name"], "size": f.get("size", 0), "type": f.get("type", ""), "views": f.get("views", 0), "createdAt": f.get("createdAt"), "deletesAt": f.get("deletesAt"), "url": "/" + quote(f["name"]), "thumbnail": "/raw/" + quote(thumb) if thumb else None, } def preview_tags(name: str, origin: str, video: str, size: tuple[int, int] | None, site_name: str) -> str: """Open Graph tags for /v/: Discord's player, a still and a title. `video` is the clip itself, or for a big clip its small copy (Discord gives up on files it can't download in ~8 s).""" tags = [("og:type", "video.other"), ("og:site_name", site_name), ("og:title", name), ("og:image", f"{origin}/_site/thumb/{quote(name)}"), ("og:video", video), ("og:video:url", video), ("og:video:secure_url", video), ("og:video:type", "video/mp4")] if size: tags += [("og:video:width", str(size[0])), ("og:video:height", str(size[1]))] meta = "".join(f'\n' for k, v in tags) return meta + '\n' def random_color() -> str: """A bright color for the stripe Discord draws beside the embed (it reads the page's theme-color).""" r, g, b = colorsys.hls_to_rgb(random.random(), 0.55, 0.75) return "#%02x%02x%02x" % (round(r * 255), round(g * 255), round(b * 255)) def site_page(site_name: str, server_name: str) -> str: """index.html with {{v}} replaced by a hash of the assets, so browsers never keep a stale app.js/style.css, and {{name}}/{{server}} by the configured names (app.js reads them before /config loads).""" digest = hashlib.sha256() for name in ("app.js", "style.css"): digest.update((SITE_DIR / name).read_bytes()) return ((SITE_DIR / "index.html").read_text(encoding="utf-8").replace("{{v}}", digest.hexdigest()[:10]) .replace("{{name}}", html.escape(site_name)).replace("{{server}}", html.escape(server_name, quote=True))) def build_site_router(client: httpx.Client, exists: Callable[[str], bool], tickets: TicketStore, limiter: LoginLimiter, settings, previews: Previews) -> APIRouter: r = APIRouter() page = site_page(settings.site_name, settings.server_name) def logged_out() -> JSONResponse: resp = error(401, "not logged in") resp.delete_cookie(COOKIE, path="/", secure=True, httponly=True, samesite="lax") return resp def call(method: str, path: str, token: str, **kw) -> httpx.Response | None: try: return client.request(method, path, headers={"authorization": token}, **kw) except httpx.HTTPError: return None def relay(resp: httpx.Response | None, ok: Callable[[dict], dict]): if resp is None: return error(502, "Zipline isn't reachable right now.") if resp.status_code == 401: return logged_out() if resp.status_code == 404: return error(404, "This clip doesn't exist (or isn't yours).") if resp.status_code >= 400: return error(resp.status_code if resp.status_code < 500 else 502, zipline_message(resp)) return ok(resp.json()) def file_path(ident: str) -> str: return "/api/user/files/" + quote(ident, safe="") def bad_ident(ident: str) -> bool: # an empty or nested id would reach other Zipline routes (the list, /transaction, ...) return not ident or "/" in ident no_clip = lambda: error(404, "This clip doesn't exist (or isn't yours).") @r.get("/", include_in_schema=False) def index(): return HTMLResponse(page, headers={"cache-control": "no-store"}) def video_head(name: str) -> httpx.Response | None: """Zipline's answer for the raw clip when it's a video, else None.""" try: resp = client.head("/" + quote(name)) except httpx.HTTPError: return None ok = resp.status_code == 200 and resp.headers.get("content-type", "").startswith("video/") return resp if ok else None def raw_url(name: str) -> str: return settings.zipline_url.rstrip("/") + "/" + quote(name) @r.get("/v/{name:path}", include_in_schema=False) def watch(name: str, request: Request): head = video_head(name) if name else None if head is None: return HTMLResponse(page, headers={"cache-control": "no-store"}) length = head.headers.get("content-length", "") key = previews.ready(name, int(length)) if length.isdigit() else None if key: video, size = f"{settings.site_origin}/_site/preview/{key}.mp4", video_size(str(previews.file(key)), key) else: video, size = f"{settings.site_origin}/{quote(name)}", video_size(raw_url(name), length) log.info("/v/%s -> %s for %s", name, "copy" if key else "original", request.headers.get("user-agent", "?")) tags = preview_tags(name, settings.site_origin, video, size, settings.site_name) html_page = page.replace('', f'', 1) return HTMLResponse(html_page.replace("", tags + "", 1), headers={"cache-control": "no-store"}) @r.get("/_site/api/discord/{name:path}") def discord_state(name: str): # the upload dialog holds a clip's link back while this says "making": Discord caches whatever it sees first head = video_head(name) if name else None length = head.headers.get("content-length", "") if head is not None else "" return previews.status(name, int(length) if length.isdigit() else 0) @r.get("/_site/thumb/{name:path}", include_in_schema=False) def thumb(name: str): head = video_head(name) if name else None jpeg = head and still_jpeg(raw_url(name), head.headers.get("content-length", "")) if not jpeg: return error(404, "no preview for this clip") return Response(jpeg, media_type="image/jpeg", headers={"cache-control": "public, max-age=3600"}) @r.get("/_site/preview/{key}.mp4", include_in_schema=False) def preview(key: str): path = previews.file(key) if path is None: return error(404, "no such preview") return FileResponse(path, media_type="video/mp4") @r.get("/_site/api/config") def config(): return {"uploadUrl": settings.upload_url, "publicUrl": settings.site_origin, "uploadHint": settings.upload_hint} @r.get("/_site/api/public/{name:path}") def public_file(name: str): """What a visitor from a shared link may see: the raw file is public already, this only names it.""" try: head = client.head("/" + quote(name)) if name else None except httpx.HTTPError: return error(502, "Zipline isn't reachable right now.") if head is None or head.status_code != 200: return error(404, "This clip doesn't exist (anymore).") kind = head.headers.get("content-type", "").split(";")[0] length = head.headers.get("content-length", "0") return {"name": name, "type": kind, "size": int(length) if length.isdigit() else 0, "url": "/" + quote(name), "thumbnail": "/_site/thumb/" + quote(name) if kind.startswith("video/") else None} @r.post("/_site/api/login") def login(body: Login, request: Request): # strikes count per IP *and* account, so logging in as yourself doesn't clear guesses at someone else ip = client_ip(request) + "|" + body.username.strip().lower() if limiter.blocked(ip): return error(429, "Too many tries, wait a few minutes.") try: r1 = client.post("/api/auth/login", json={"username": body.username, "password": body.password}) if r1.status_code != 200: if r1.status_code >= 500: return error(502, "Zipline isn't reachable right now.") limiter.fail(ip) return error(401, "Wrong username or password.") match = SESSION_RE.search(r1.headers.get("set-cookie", "")) if not match: return error(502, "Zipline didn't start a session.") r2 = client.get("/api/user/token", headers={"cookie": f"zipline_session={match.group(1)}"}) except httpx.HTTPError: return error(502, "Zipline isn't reachable right now.") finally: client.cookies.clear() # never let one user's Zipline session ride along on someone else's request if r2.status_code != 200: return error(502, "Zipline didn't hand out a token.") limiter.reset(ip) resp = JSONResponse({"username": r1.json()["user"]["username"]}) resp.set_cookie(COOKIE, r2.json()["token"], max_age=COOKIE_AGE, path="/", httponly=True, secure=True, samesite="lax") return resp @r.post("/_site/api/logout") def logout(): resp = JSONResponse({"ok": True}) resp.delete_cookie(COOKIE, path="/", secure=True, httponly=True, samesite="lax") return resp @r.get("/_site/api/me") def me(request: Request): token = request.cookies.get(COOKIE) if not token: return logged_out() result = relay(call("GET", "/api/user", token), lambda d: {"username": d["user"]["username"]}) if not isinstance(result, dict): return result stats = call("GET", "/api/user/stats", token) ok = stats is not None and stats.status_code == 200 return {**result, "storageUsed": stats.json().get("storageUsed", 0) if ok else 0} @r.get("/_site/api/files") def files(request: Request, page: int = 1): token = request.cookies.get(COOKIE) if not token: return logged_out() params = {"page": max(page, 1), "perpage": PER_PAGE, "sortBy": "createdAt", "order": "desc"} return relay(call("GET", "/api/user/files", token, params=params), lambda d: { "files": [file_view(f) for f in d.get("page", [])], "total": d.get("total", 0), "pages": d.get("pages", 1), }) @r.get("/_site/api/files/{ident:path}") def file(ident: str, request: Request): token = request.cookies.get(COOKIE) if not token: return logged_out() if bad_ident(ident): return no_clip() return relay(call("GET", file_path(ident), token), file_view) @r.patch("/_site/api/files/{ident:path}") def rename(ident: str, body: Rename, request: Request): token = request.cookies.get(COOKIE) if not token: return logged_out() if bad_ident(ident): return no_clip() new = clean_name(body.name) try: if exists(new): return error(409, "That name is already used.") except httpx.HTTPError: return error(502, "Zipline isn't reachable right now.") before = call("GET", file_path(ident), token) resp = call("PATCH", file_path(ident), token, json={"name": new}) if before is not None and before.status_code == 200 and resp is not None and resp.status_code == 200: was = before.json() previews.renamed(was["name"], resp.json().get("name", new), int(was.get("size") or 0)) return relay(resp, lambda d: {"name": d.get("name", new)}) @r.delete("/_site/api/files/{ident:path}") def delete(ident: str, request: Request): token = request.cookies.get(COOKIE) if not token: return logged_out() if bad_ident(ident): return no_clip() return relay(call("DELETE", file_path(ident), token), lambda d: {"ok": True}) @r.post("/_site/api/upload-ticket") def upload_ticket(request: Request): token = request.cookies.get(COOKIE) if not token: return logged_out() return {"ticket": tickets.issue(token), "uploadUrl": settings.upload_url} return r