297 lines
13 KiB
Python
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
|