Files
kaledonya-clips/intake/app/site_api.py
T

297 lines
13 KiB
Python

"""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/<name>: 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'<meta property="{k}" content="{html.escape(v, quote=True)}">\n' for k, v in tags)
return meta + '<meta name="twitter:card" content="player">\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('<meta name="theme-color" content="#3e4637">',
f'<meta name="theme-color" content="{random_color()}">', 1)
return HTMLResponse(html_page.replace("</head>", tags + "</head>", 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