kaledonya clips: a Discord-friendly clip site on top of Zipline

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
esefandClaude Opus 5.5 committed 2026-10-07 20:46:48 +03:00
commit e6f8905377
34 files changed
+2917

No files matched your search

+15
View File
@@ -0,0 +1,15 @@
FROM python:3.12-slim
RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /srv
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY pytest.ini .
COPY app ./app
COPY site ./site
COPY tests ./tests
# run as an ordinary user (uid 1000); /work must be writable by it
RUN useradd --uid 1000 --no-create-home --shell /usr/sbin/nologin app && mkdir /work && chown app:app /work
USER app
ENV WORK_DIR=/work TMPDIR=/work
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
View File
Whitespace-only changes.
+15
View File
@@ -0,0 +1,15 @@
"""JSON error responses shared by /upload and the site API."""
import httpx
from fastapi.responses import JSONResponse
def error(status: int, message: str) -> JSONResponse:
return JSONResponse({"error": message}, status_code=status)
def zipline_message(r: httpx.Response) -> str:
try:
body = r.json()
return str(body.get("error") or body.get("message") or body)
except ValueError:
return r.text[:200] or f"Zipline returned {r.status_code}"
+3
View File
@@ -0,0 +1,3 @@
from .server import create_app
app = create_app()
+38
View File
@@ -0,0 +1,38 @@
"""Size and a still frame of a clip, read straight from Zipline with ffprobe/ffmpeg, for Discord's link preview."""
import json
import subprocess
import threading
from functools import lru_cache
# /v/ and /_site/thumb need no login: cap how many ffmpeg/ffprobe processes they can start at once
SLOTS = threading.BoundedSemaphore(2)
@lru_cache(maxsize=256)
def video_size(url: str, length: str) -> tuple[int, int] | None:
"""(width, height) of the first video stream; `length` is only part of the cache key (a reused name)."""
cmd = ["ffprobe", "-v", "error", "-select_streams", "v:0",
"-show_entries", "stream=width,height", "-of", "json", url]
try:
with SLOTS:
result = subprocess.run(cmd, capture_output=True, timeout=10)
stream = json.loads(result.stdout)["streams"][0]
return int(stream["width"]), int(stream["height"])
except (OSError, subprocess.TimeoutExpired, ValueError, KeyError, IndexError):
return None
@lru_cache(maxsize=64)
def still_jpeg(url: str, length: str) -> bytes | None:
"""One frame (1 s in, or the first one for very short clips) as a JPEG at most 720 px tall."""
for seek in ("1", "0"):
cmd = ["ffmpeg", "-v", "error", "-ss", seek, "-i", url, "-frames:v", "1",
"-vf", "scale=-2:'min(720,ih)'", "-f", "image2", "-c:v", "mjpeg", "pipe:1"]
try:
with SLOTS:
result = subprocess.run(cmd, capture_output=True, timeout=30)
except (OSError, subprocess.TimeoutExpired):
return None
if result.returncode == 0 and result.stdout:
return result.stdout
return None
+25
View File
@@ -0,0 +1,25 @@
"""Filename rules for uploads: spaces become dashes, duplicates get -2, -3, ..."""
import os
import re
from collections.abc import Callable
def clean_name(filename: str) -> str:
"""Keep only the base name and turn every run of whitespace into one dash."""
base = filename.replace("\\", "/").rsplit("/", 1)[-1].strip()
base = re.sub(r"\s+", "-", base)
return base or "upload"
def split_name(name: str) -> tuple[str, str]:
"""('clip', '.mp4'); a name with no extension gives ('README', '')."""
return os.path.splitext(name)
def next_free_name(stem: str, ext: str, exists: Callable[[str], bool], limit: int = 1000) -> str:
"""First of stem+ext, stem-2+ext, stem-3+ext, ... that does not exist yet."""
for n in range(1, limit + 1):
name = f"{stem}{ext}" if n == 1 else f"{stem}-{n}{ext}"
if not exists(name):
return name
raise RuntimeError(f"no free name for {stem}{ext} after {limit} tries")
+216
View File
@@ -0,0 +1,216 @@
"""Small Discord copies of big clips.
Discord's crawler gives a linked video about 8 seconds to download and a home upload link of ~4 MB/s means a clip
over ~30 MB never gets a player. For those, the /v/ page points Discord at a 720p copy that fits in ~22 MB
(clips over 4 minutes: 480p in ~18 MB, so they download with room to spare);
the original stays untouched. Copies are made one at a time in the background and live in WORK_DIR/previews.
"""
import hashlib
import json
import logging
import queue
import re
import shutil
import subprocess
import threading
import time
from pathlib import Path
from urllib.parse import quote
log = logging.getLogger("uvicorn.error") # uvicorn only prints its own loggers
TARGET_BYTES = 22 * 1000 * 1000
LONG_SECONDS = 240 # longer clips get a smaller, 480p copy
LONG_TARGET_BYTES = 18 * 1000 * 1000
AUDIO_KBPS = 128
MAX_VIDEO_KBPS = 4000
MIN_VIDEO_KBPS = 250 # below this a clip is too long to be worth watching in Discord; it gets no copy
KEEP_DAYS = 95 # clips delete after 90 days
KEY_RE = re.compile(r"^[0-9a-f]{24}$")
def target_bytes(duration: float) -> int:
return LONG_TARGET_BYTES if duration > LONG_SECONDS else TARGET_BYTES
def video_kbps(duration: float) -> int | None:
"""Video bitrate that makes `duration` seconds land near target_bytes(), or None when it can't sensibly fit."""
if duration <= 0:
return None
kbps = min(MAX_VIDEO_KBPS, int(target_bytes(duration) * 8 / 1000 / duration) - AUDIO_KBPS)
return kbps if kbps >= MIN_VIDEO_KBPS else None
def encode_cmd(src: str, dst: Path, kbps: int, duration: float) -> list[str]:
height = 480 if duration > LONG_SECONDS else 720
return [
"ffmpeg", "-v", "error", "-y", "-threads", "4", "-i", src,
"-map", "0:v:0", "-map", "0:a:0?", # OBS clips carry a second audio track; Discord plays one
"-vf", f"scale=-2:'min({height},ih)'", "-fpsmax", "30",
"-c:v", "libx264", "-preset", "veryfast", "-pix_fmt", "yuv420p",
"-b:v", f"{kbps}k", "-maxrate", f"{kbps * 6 // 5}k", "-bufsize", f"{kbps * 2}k",
"-c:a", "aac", "-b:a", f"{AUDIO_KBPS}k", "-ac", "2",
"-movflags", "+faststart", "-progress", "pipe:1", "-nostats", str(dst),
]
def follow_progress(lines, duration: float, report) -> None:
"""Turn ffmpeg's `-progress` lines into fractions done (0..1) for the site's upload dialog."""
for line in lines:
key, _, value = line.strip().partition("=")
if key == "out_time_us" and value.isdigit() and duration > 0:
report(min(1.0, round(int(value) / 1e6 / duration, 3)))
def duration_of(src: str) -> float:
cmd = ["ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "json", src]
try:
out = subprocess.run(cmd, capture_output=True, timeout=30).stdout
return float(json.loads(out)["format"]["duration"])
except (OSError, subprocess.TimeoutExpired, ValueError, KeyError):
return 0.0
def niced(cmd: list[str]) -> list[str]:
"""Run at low CPU priority where `nice` exists, so uploads and everything else on the box come first."""
return ["nice", "-n", "10", *cmd] if shutil.which("nice") else cmd
class Previews:
def __init__(self, directory: Path, zipline_url: str, over_bytes: int):
self.dir = directory
self.zipline_url = zipline_url.rstrip("/")
self.over_bytes = over_bytes
self.jobs: queue.Queue[tuple[str, int]] = queue.Queue()
self.pending: set[str] = set()
self.lock = threading.Lock()
self.worker: threading.Thread | None = None
self.current: str | None = None # key being encoded right now, and how far along it is
self.progress = 0.0
@staticmethod
def key(name: str, length: int) -> str:
# the size is part of the key so a deleted clip's name reused for another video never gets the old copy
return hashlib.sha256(f"{name}\0{length}".encode()).hexdigest()[:24]
def wanted(self, length: int) -> bool:
return length > self.over_bytes
def file(self, key: str) -> Path | None:
path = self.dir / f"{key}.mp4"
return path if KEY_RE.match(key) and path.is_file() else None
def ready(self, name: str, length: int) -> str | None:
"""The key of a finished copy, or None (asking for one in the background if it's missing)."""
key = self.key(name, length)
if self.file(key):
return key
self.request(name, length)
return None
def status(self, name: str, length: int) -> dict:
"""For the site's upload dialog. state: "small" (plays as is), "ready" (copy made), "none" (too long, no
copy) or "making" (Discord would still get the big original), with how far the encode is and whether
it's still waiting behind another clip."""
done = lambda state: {"state": state, "progress": 1.0, "queued": False}
if not self.wanted(length):
return done("small")
key = self.key(name, length)
if self.file(key):
return done("ready")
if (self.dir / f"{key}.failed").exists():
return done("none")
self.request(name, length)
with self.lock:
now = self.current == key
return {"state": "making", "progress": self.progress if now else 0.0, "queued": not now}
def renamed(self, old: str, new: str, length: int) -> None:
"""Carry a clip's copy over to its new name; without it a renamed clip's link sends Discord the big original."""
if not self.wanted(length):
return
old_key, new_key = self.key(old, length), self.key(new, length)
for ext in (".mp4", ".failed"):
try:
(self.dir / f"{old_key}{ext}").replace(self.dir / f"{new_key}{ext}")
except OSError:
pass
self.request(new, length) # still being made under the old name: start the new one now, not on the first view
def request(self, name: str, length: int) -> None:
key = self.key(name, length)
if not self.wanted(length) or self.file(key) or (self.dir / f"{key}.failed").exists():
return
with self.lock:
if key in self.pending:
return
self.pending.add(key)
self.jobs.put((name, length))
if self.worker is None or not self.worker.is_alive():
self.worker = threading.Thread(target=self.run, name="previews", daemon=True)
self.worker.start()
def run(self) -> None:
while True:
try:
name, length = self.jobs.get(timeout=60)
except queue.Empty:
with self.lock:
if self.jobs.empty():
self.worker = None
return
continue
key = self.key(name, length)
with self.lock:
self.current, self.progress = key, 0.0
try:
self.prune()
self.make(name, key)
except Exception: # one bad clip must not kill the worker
log.exception("preview for %s failed", name)
finally:
with self.lock:
self.pending.discard(key)
self.current = None
def make(self, name: str, key: str) -> None:
self.dir.mkdir(parents=True, exist_ok=True)
src = f"{self.zipline_url}/{quote(name)}"
duration = duration_of(src)
kbps = video_kbps(duration)
tmp, dst = self.dir / f"{key}.part.mp4", self.dir / f"{key}.mp4"
ok = False
if kbps:
started = time.monotonic()
try:
proc = subprocess.Popen(niced(encode_cmd(src, tmp, kbps, duration)), stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL, text=True)
watchdog = threading.Timer(3 * 3600, proc.kill)
watchdog.start()
try:
follow_progress(proc.stdout, duration, self.set_progress)
code = proc.wait()
finally:
watchdog.cancel()
ok = code == 0 and tmp.is_file() and tmp.stat().st_size < TARGET_BYTES * 13 // 10
except OSError:
ok = False
log.info("preview %s for %s: %s in %.0fs", key, name, "ok" if ok else "failed", time.monotonic() - started)
if ok:
tmp.replace(dst)
else:
tmp.unlink(missing_ok=True)
(self.dir / f"{key}.failed").touch() # don't burn CPU on it again
def set_progress(self, fraction: float) -> None:
with self.lock:
self.progress = fraction
def prune(self) -> None:
cutoff = time.time() - KEEP_DAYS * 86400
for path in self.dir.glob("*"):
try:
if path.stat().st_mtime < cutoff:
path.unlink()
except OSError:
pass
+19
View File
@@ -0,0 +1,19 @@
"""Stream-copy an .mkv into an .mp4 so Discord can play it inline (no re-encode)."""
import subprocess
from pathlib import Path
def remux_to_mp4(src: Path, dst: Path, timeout: int = 1800) -> bool:
cmd = [
"ffmpeg", "-v", "error", "-y", "-i", str(src),
"-map", "0:v", "-map", "0:a?", "-c", "copy",
"-movflags", "+faststart", str(dst),
]
try:
result = subprocess.run(cmd, capture_output=True, timeout=timeout)
ok = result.returncode == 0 and dst.exists() and dst.stat().st_size > 0
except (OSError, subprocess.TimeoutExpired):
ok = False
if not ok:
dst.unlink(missing_ok=True)
return ok
+178
View File
@@ -0,0 +1,178 @@
"""intake: the upload door in front of Zipline (rename, remux .mkv, pick a free name, forward)."""
import asyncio
import http.cookiejar
import mimetypes
import os
import shutil
import tempfile
from dataclasses import dataclass, field
from pathlib import Path
from urllib.parse import quote
import httpx
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from fastapi.staticfiles import StaticFiles
from starlette.datastructures import UploadFile
from .errors import error, zipline_message
from .names import clean_name, next_free_name, split_name
from .previews import Previews
from .remux import remux_to_mp4
from .site_api import SITE_DIR, build_site_router
from .site_auth import LoginLimiter, TicketStore
FORM_SLACK = 1024 * 1024 # room for the multipart headers around the file
UPLOAD_TRIES = 3
@dataclass
class Settings:
zipline_url: str = "http://zipline:3000"
max_bytes: int = 5 * 1024**3
deletes_at: str = "90d"
work_dir: Path = field(default_factory=lambda: Path(tempfile.gettempdir()))
site_origin: str = "http://localhost:3020"
upload_url: str = "http://localhost:3020"
site_name: str = "kaledonya clips" # its first word is the big logo word on the login screen
server_name: str = "the server" # shown as "Sending to <server_name>"
upload_hint: str = "Can't reach the upload server." # shown when an upload can't connect at all
preview_over: int = 25 * 1000 * 1000 # bigger clips get a small copy for Discord (see previews.py)
@classmethod
def from_env(cls) -> "Settings":
return cls(
zipline_url=os.environ.get("ZIPLINE_URL", cls.zipline_url),
max_bytes=int(os.environ.get("MAX_BYTES", cls.max_bytes)),
deletes_at=os.environ.get("DELETES_AT", cls.deletes_at),
work_dir=Path(os.environ.get("WORK_DIR", tempfile.gettempdir())),
site_origin=os.environ.get("SITE_ORIGIN", cls.site_origin),
upload_url=os.environ.get("UPLOAD_URL", cls.upload_url),
site_name=os.environ.get("SITE_NAME", cls.site_name),
server_name=os.environ.get("SERVER_NAME", cls.server_name),
upload_hint=os.environ.get("UPLOAD_HINT", cls.upload_hint),
preview_over=int(os.environ.get("PREVIEW_OVER", cls.preview_over)),
)
def create_app(settings: Settings | None = None, transport: httpx.BaseTransport | None = None) -> FastAPI:
settings = settings or Settings.from_env()
app = FastAPI(title="zipline-intake")
# the site (SITE_ORIGIN) uploads straight to this service at UPLOAD_URL, which may be another hostname
app.add_middleware(CORSMiddleware, allow_origins=[settings.site_origin],
allow_methods=["POST"], allow_headers=["x-upload-ticket"])
client = httpx.Client(
base_url=settings.zipline_url,
transport=transport,
timeout=httpx.Timeout(60.0, read=900.0, write=None),
)
# never keep cookies: a Zipline login session must not ride along on anyone else's request
client.cookies.jar.set_policy(http.cookiejar.DefaultCookiePolicy(allowed_domains=[]))
too_big = lambda: error(413, f"file is larger than the {settings.max_bytes / 1024**3:g} GB limit")
def exists(name: str) -> bool:
# 302: Zipline sends text and password-protected files to /view/<name>, but the name is taken
return client.head("/" + quote(name)).status_code in (200, 302)
tickets, limiter = TicketStore(), LoginLimiter()
previews = Previews(settings.work_dir / "previews", settings.zipline_url, settings.preview_over)
app.state.tickets, app.state.limiter, app.state.previews = tickets, limiter, previews
app.include_router(build_site_router(client, exists, tickets, limiter, settings, previews))
app.mount("/_site/static", StaticFiles(directory=SITE_DIR), name="site-static")
@app.middleware("http")
async def no_store_site_api(request: Request, call_next):
# Cloudflare caches anything ending in .mp4, even /_site/api/files/clip.mp4: never let it keep these
response = await call_next(request)
if request.url.path.startswith("/_site/api/"):
response.headers["cache-control"] = "no-store, private"
return response
def forward(body, stem: str, ext: str, token: str, content_type: str, remuxed: bool) -> JSONResponse:
taken: set[str] = set()
for _ in range(UPLOAD_TRIES):
try:
name = next_free_name(stem, ext, lambda n: n in taken or exists(n))
except httpx.HTTPError as e:
return error(502, f"could not reach Zipline: {e}")
except RuntimeError:
return error(409, "could not get a free filename, try again")
headers = {
"authorization": token,
"x-zipline-format": "name",
"x-zipline-filename": quote(name[: len(name) - len(ext)] if ext else name),
"x-zipline-original-name": "true",
"x-zipline-deletes-at": settings.deletes_at,
}
if ext:
headers["x-zipline-file-extension"] = ext
body.seek(0)
try:
r = client.post("/api/upload", headers=headers, files={"file": (name, body, content_type)})
except httpx.HTTPError as e:
return error(502, f"could not reach Zipline: {e}")
if r.status_code == 200:
# build the link ourselves: a '#' or '?' left raw in Zipline's url would cut it off
url = settings.site_origin + "/" + quote(name)
if content_type.startswith("video/"):
previews.request(name, body.seek(0, os.SEEK_END))
return JSONResponse({"url": url, "name": name, "remuxed": remuxed})
if "already exists" in r.text:
taken.add(name)
continue
return error(r.status_code if r.status_code < 500 else 502, zipline_message(r))
return error(409, "could not get a free filename, try again")
def process(up: UploadFile, token: str) -> JSONResponse:
stem, ext = split_name(clean_name(up.filename or "upload"))
content_type = up.content_type or "application/octet-stream"
if content_type == "application/octet-stream":
# curl/ShareX don't say what the file is; Discord only plays video/mp4 etc. inline
content_type = mimetypes.guess_type(stem + ext)[0] or content_type
with tempfile.TemporaryDirectory(dir=settings.work_dir) as tmp:
body, remuxed = up.file, False
if ext.lower() == ".mkv":
src, dst = Path(tmp) / "in.mkv", Path(tmp) / "out.mp4"
up.file.seek(0)
with src.open("wb") as f:
shutil.copyfileobj(up.file, f, 1024 * 1024)
if remux_to_mp4(src, dst):
src.unlink()
body, ext, content_type, remuxed = dst.open("rb"), ".mp4", "video/mp4", True
try:
return forward(body, stem, ext, token, content_type, remuxed)
finally:
if remuxed:
body.close()
@app.get("/health")
def health():
return {"ok": True}
@app.post("/upload")
async def upload(request: Request):
token = request.headers.get("authorization")
if not token:
ticket = request.headers.get("x-upload-ticket")
if ticket:
token = tickets.redeem(ticket)
if not token:
return error(401, "upload ticket expired, try again")
if not token:
return error(401, "missing Authorization header (your Zipline token)")
length = request.headers.get("content-length", "")
if length.isdigit() and int(length) > settings.max_bytes + FORM_SLACK:
return too_big()
form = await request.form(max_files=1, max_fields=10)
try:
up = form.get("file")
if not isinstance(up, UploadFile):
return error(400, "send the file in a form field named 'file'")
if up.size is not None and up.size > settings.max_bytes:
return too_big()
return await asyncio.to_thread(process, up, token)
finally:
await form.close()
return app
+296
View File
@@ -0,0 +1,296 @@
"""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
+64
View File
@@ -0,0 +1,64 @@
"""Login rate limit and one-time upload tickets for the clips site."""
import secrets
import time
from collections import defaultdict, deque
from collections.abc import Callable
class LoginLimiter:
"""At most `limit` failed logins per key (an IP) within `window` seconds."""
def __init__(self, limit: int = 5, window: float = 600, now: Callable[[], float] = time.monotonic):
self.limit, self.window, self.now = limit, window, now
self.fails: dict[str, deque] = defaultdict(deque)
self.swept = now()
def _recent(self, key: str) -> deque:
q, cutoff = self.fails[key], self.now() - self.window
while q and q[0] <= cutoff:
q.popleft()
return q
def blocked(self, key: str) -> bool:
if key not in self.fails: # don't grow the table for every key that's merely checked
return False
return len(self._recent(key)) >= self.limit
def fail(self, key: str) -> None:
self._sweep()
self._recent(key).append(self.now())
def _sweep(self) -> None:
# forget keys whose strikes all expired, so guesses with made-up usernames can't grow the table forever
if self.now() - self.swept < self.window:
return
self.swept = self.now()
for key in [k for k in self.fails if not self._recent(k)]:
del self.fails[key]
def reset(self, key: str) -> None:
self.fails.pop(key, None)
class TicketStore:
"""One-time tickets that stand in for a Zipline token on /upload, valid `ttl` seconds."""
def __init__(self, ttl: float = 3600, now: Callable[[], float] = time.monotonic):
self.ttl, self.now = ttl, now
self.tickets: dict[str, tuple[str, float]] = {}
def _prune(self) -> None:
now = self.now()
for ticket in [t for t, (_, expires) in self.tickets.items() if expires <= now]:
del self.tickets[ticket]
def issue(self, token: str) -> str:
self._prune()
ticket = secrets.token_urlsafe(32)
self.tickets[ticket] = (token, self.now() + self.ttl)
return ticket
def redeem(self, ticket: str) -> str | None:
self._prune()
entry = self.tickets.pop(ticket, None)
return entry[0] if entry else None
+3
View File
@@ -0,0 +1,3 @@
[pytest]
pythonpath = .
testpaths = tests
+6
View File
@@ -0,0 +1,6 @@
fastapi==0.142.2
starlette==1.7.0
pydantic==2.13.5
uvicorn==0.54.0
python-multipart==0.0.32
httpx==0.28.1
+526
View File
@@ -0,0 +1,526 @@
'use strict';
// Streamable-style library, watch page and uploads in a 2003 Steam skin.
// Talks to intake's /_site/api; uploads go straight to intake's UPLOAD_URL (cfg.uploadUrl).
const app = document.getElementById('app');
const dialogs = document.getElementById('dialogs');
const BLOCK = 12; // one 4px gap + one 8px block, like Steam's VGUI ProgressBar
let cfg = { uploadUrl: '', publicUrl: '', uploadHint: '' };
// SITE_NAME and SERVER_NAME, filled into index.html by intake
const SITE = document.title, BRAND = SITE.split(' ')[0], SERVER = document.documentElement.dataset.server;
let me = null;
const state = { files: [], page: 0, pages: 1, total: null };
function h(tag, attrs = {}, ...kids) {
const el = document.createElement(tag);
for (const [k, v] of Object.entries(attrs)) {
if (k.startsWith('on')) el.addEventListener(k.slice(2), v);
else if (k === 'class') el.className = v;
else if (k === 'style') el.style.cssText = v;
else if (k === 'value') el.value = v;
else if (v !== false && v != null) el.setAttribute(k, v === true ? '' : v);
}
for (const kid of kids.flat(Infinity)) if (kid != null && kid !== false) el.append(kid.nodeType ? kid : String(kid));
return el;
}
class LoggedOut extends Error {}
async function api(path, opts = {}) {
// no-store: answers differ per user, so never reuse one from the browser cache
const init = { method: opts.method || 'GET', credentials: 'same-origin', cache: 'no-store', headers: {} };
if (opts.body !== undefined) { init.headers['content-type'] = 'application/json'; init.body = JSON.stringify(opts.body); }
let r;
try { r = await fetch('/_site/api' + path, init); } catch { throw new Error(`Lost connection to ${SERVER}.`); }
let data = {};
try { data = await r.json(); } catch {}
if (r.status === 401 && path !== '/login') throw new LoggedOut();
if (!r.ok) throw new Error(data.error || 'Error ' + r.status);
return data;
}
const fmtSize = (b) => { const u = ['B', 'KB', 'MB', 'GB']; let i = 0; while (b >= 1024 && i < 3) { b /= 1024; i++; } return (i ? b.toFixed(1) : b) + ' ' + u[i]; };
const fmtDate = (s) => new Date(s).toLocaleDateString(undefined, { day: 'numeric', month: 'short', year: 'numeric' });
const daysLeft = (s) => (s ? Math.max(0, Math.ceil((new Date(s) - Date.now()) / 86400000)) : null);
const plural = (n, w) => `${n} ${w}${n === 1 ? '' : 's'}`;
const media = (path) => cfg.publicUrl + path;
// the link to paste in Discord: /v/<name> gives Discord a player (and a small copy for big clips)
const isVideo = (name) => /\.(mp4|mov|m4v|webm)$/i.test(name);
const shareLink = (name, raw) => isVideo(name) ? cfg.publicUrl + '/v/' + encodeURIComponent(name) : raw;
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
// the old Steam block bar: whole blocks only, added one at a time until they catch up with the real progress
function sbar() {
const bar = h('div', { class: 'sbar' }, h('div', { class: 'fill' }));
bar.shown = 0; bar.target = 0; bar.tick = null;
return bar;
}
function setBar(bar, frac) {
const total = Math.max(1, Math.floor(bar.clientWidth / BLOCK));
bar.target = Math.floor(total * Math.min(1, Math.max(0, frac)));
if (bar.target < bar.shown) bar.shown = bar.target; // a new file starts again from empty
bar.firstChild.style.width = bar.shown * BLOCK + 'px';
if (!bar.tick) bar.tick = setInterval(() => {
if (bar.shown < bar.target) bar.shown++;
else { clearInterval(bar.tick); bar.tick = null; }
bar.firstChild.style.width = bar.shown * BLOCK + 'px';
}, 30);
}
const barFull = (bar) => new Promise((r) => { const t = setInterval(() => { if (!bar.tick) { clearInterval(t); r(); } }, 30); });
function dialog(title, body, { clear = false } = {}) {
const box = h('div', { class: 'dlg raised' }, h('div', { class: 'title' }, title), h('div', { class: 'body' }, body));
const ov = h('div', { class: 'overlay' + (clear ? ' clear' : '') }, box);
dialogs.append(ov);
return { box, close: () => ov.remove() };
}
function shake(box) { box.classList.remove('shake'); void box.offsetWidth; box.classList.add('shake'); }
function alertBox(title, msg) {
return new Promise((res) => {
const ok = h('button', { class: 'btn y', onclick: () => { d.close(); res(); } }, 'OK');
const d = dialog(title, [h('div', { class: 'msg' }, msg), h('div', { class: 'row' }, ok)]);
ok.focus();
});
}
function confirmBox(title, msg, yes) {
return new Promise((res) => {
const d = dialog(title, [h('div', { class: 'msg' }, msg), h('div', { class: 'row' },
h('button', { class: 'btn', onclick: () => { d.close(); res(false); } }, 'Cancel'),
h('button', { class: 'btn y', onclick: () => { d.close(); res(true); } }, yes))]);
});
}
// check(value) resolves to an error message, or null to close the box
function promptBox(title, label, value, check) {
return new Promise((res) => {
const input = h('input', { class: 'field', value });
const err = h('div', { class: 'err' });
const go = async () => {
const e = await check(input.value.trim());
if (e) { err.textContent = e; shake(d.box); } else { d.close(); res(true); }
};
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') go();
if (e.key === 'Escape') { d.close(); res(false); }
});
const d = dialog(title, [h('label', {}, label), input, err, h('div', { class: 'row' },
h('button', { class: 'btn', onclick: () => { d.close(); res(false); } }, 'Cancel'),
h('button', { class: 'btn y', onclick: go }, 'OK'))]);
input.focus(); input.select();
});
}
function toast(msg) { const t = h('div', { class: 'toast raised' }, msg); document.body.append(t); setTimeout(() => t.remove(), 1600); }
// Discord plays a big clip only from its small copy and caches whatever it sees first: on the clip page, a link
// whose copy is still being made shows "preparing for Discord... 64%" instead, with Copy off
async function holdForDiscord(name, el, show) {
if (!isVideo(name)) return show(true);
show(false, 'checking Discord...');
for (let i = 0; i < 3600; i++) {
let s = { state: 'small' };
try { s = await api('/discord/' + encodeURIComponent(name)); } catch {}
if (s.state !== 'making') return show(true, s.state === 'none' ? "Too long for Discord's player: the link opens the clip, but won't play in the chat." : '');
show(false, 'preparing for Discord... ' + discordNote(s));
await wait(1000);
if (!el.isConnected) return; // left the page
}
show(true);
}
async function copy(text) {
try { await navigator.clipboard.writeText(text); toast('Link copied'); }
catch { await promptBox('Copy link', 'Copy this link:', text, async () => null); }
}
// static markup only (h() can't make SVG elements)
function svg(markup) { const t = document.createElement('template'); t.innerHTML = markup; return t.content.firstChild; }
// full-screen login; the window buttons are only for the look
function loginDialog() {
return new Promise((res) => {
const user = h('input', { class: 'field', id: 'lu', autocomplete: 'username' });
const pass = h('input', { class: 'field', id: 'lp', type: 'password', autocomplete: 'current-password' });
const err = h('div', { class: 'err' });
const bar = sbar();
const conn = h('div', { style: 'display:none' }, h('div', {}, `Connecting to ${SERVER}...`), bar);
const form = h('form', { onsubmit: async (e) => {
e.preventDefault();
err.textContent = '';
form.style.display = 'none'; conn.style.display = '';
bar.shown = 0; setBar(bar, 0.6);
try {
await api('/login', { method: 'POST', body: { username: user.value.trim(), password: pass.value } });
setBar(bar, 1); await barFull(bar);
screen.remove(); res();
} catch (x) {
conn.style.display = 'none'; form.style.display = '';
err.textContent = x.message; shake(win); pass.select();
}
} }, h('label', { for: 'lu' }, 'Account name'), user, h('label', { for: 'lp' }, 'Password'), pass, err,
h('div', { class: 'row' }, h('button', { class: 'btn y', type: 'submit' }, 'Log in')));
const win = h('div', { class: 'win' },
h('div', { class: 'title' }, h('i', {}, BRAND + ' · Login'), h('u', {}, '_'), h('u', {}, '□'), h('u', {}, '×')),
h('div', { class: 'body' },
h('div', { class: 'brand' },
svg('<svg viewBox="0 0 34 34" aria-hidden="true"><rect width="34" height="34" fill="#c4b550"/><path d="M12 8 L27 17 L12 26Z" fill="#2b3125"/></svg>'),
h('strong', {}, SITE)),
form, conn));
const screen = h('div', { class: 'login' },
h('header', {}, h('b', {}, BRAND), h('div', { class: 'sp' }),
h('span', {}, `Connected to ${SERVER}`), h('span', { class: 'hide' }, 'Invite only'), h('span', {}, 'v1.0')),
h('div', { class: 'mark-word', 'aria-hidden': 'true' }, BRAND),
svg('<svg class="mark" viewBox="0 0 100 100" aria-hidden="true"><rect x="4" y="4" width="92" height="92"/><path d="M38 28 L74 50 L38 72Z"/></svg>'),
h('main', {}, win));
dialogs.append(screen);
user.focus();
});
}
function shell(content, sideOn) {
if (!me) {
// a visitor from a shared link: the clip, the look, and a way in for account holders
const top = h('div', { class: 'topbar' },
h('a', { class: 'logo', href: '/' }, h('i'), SITE),
h('div', { class: 'user' }, h('button', { class: 'btn', onclick: async () => { await loginDialog(); boot(); } }, 'Log in')));
app.replaceChildren(h('div', { class: 'shell' }, top,
h('div', { class: 'main' }, h('div', { class: 'content' }, h('div', { class: 'wrap' }, content))),
h('div', { class: 'statusbar' }, h('span', {}, 'connected · ' + SERVER), h('span', {}, 'guest'))));
return;
}
const side = h('div', { class: 'side' },
h('div', { class: 'item' + (sideOn === 'all' ? ' on' : ''), onclick: () => go('/') },
'▣ All videos', state.total != null ? ` (${state.total})` : ''),
h('div', { class: 'lbl' }, 'STORAGE'),
h('div', { class: 'small' }, fmtSize(me.storageUsed || 0) + ' used'));
const menu = h('div', { class: 'menu raised', style: 'display:none' }, h('div', { onclick: logout }, 'Log out'));
const top = h('div', { class: 'topbar' },
h('button', { class: 'btn burger', onclick: () => side.classList.toggle('open') }, '☰'),
h('a', { class: 'logo', href: '/', onclick: (e) => { e.preventDefault(); go('/'); } }, h('i'), SITE),
h('div', { class: 'user' }, h('button', { class: 'btn', onclick: () => { menu.style.display = menu.style.display === 'none' ? '' : 'none'; } }, me.username + ' ▾'), menu));
app.replaceChildren(h('div', { class: 'shell' }, top,
h('div', { class: 'main' }, side, h('div', { class: 'content' }, h('div', { class: 'wrap' }, content))),
h('div', { class: 'statusbar' }, h('span', {}, 'connected · ' + SERVER), h('span', {}, me.username))));
}
async function loadPage() {
const d = await api('/files?page=' + (state.page + 1));
state.page++; state.pages = d.pages || 1; state.total = d.total;
state.files.push(...d.files);
}
function card(f) {
const left = daysLeft(f.deletesAt);
return h('div', { class: 'card raised', onclick: () => go('/v/' + encodeURIComponent(f.name)) },
h('div', { class: 'thumb sunken', style: f.thumbnail ? `background-image:url("${media(f.thumbnail)}")` : '' }, f.thumbnail ? '' : '▶'),
h('div', { class: 't', title: f.name }, f.name),
h('div', { class: 'm' }, [fmtDate(f.createdAt), plural(f.views, 'view'), left != null ? left + 'd left' : null].filter(Boolean).join(' · ')));
}
function preload(files, progress) {
const urls = files.map((f) => f.thumbnail).filter(Boolean).slice(0, 12);
if (!urls.length) { progress(1); return Promise.resolve(); }
let n = 0;
const one = (u) => new Promise((r) => {
const img = new Image();
img.onload = img.onerror = () => { progress(++n / urls.length); r(); };
img.src = media(u);
});
return Promise.race([Promise.all(urls.map(one)), wait(4000)]);
}
async function libraryView(progress) {
state.files = []; state.page = 0;
await loadPage();
if (progress) await preload(state.files, progress);
renderLibrary();
}
function renderLibrary() {
const picker = h('input', { type: 'file', multiple: true, style: 'display:none', onchange: () => { const f = [...picker.files]; picker.value = ''; uploadFiles(f); } });
const more = state.page < state.pages ? h('div', { class: 'more' }, h('button', { class: 'btn', onclick: async (e) => {
e.target.disabled = true;
try { await loadPage(); renderLibrary(); } catch (x) { handleError(x); }
} }, 'Load more')) : null;
const body = state.files.length
? [h('div', { class: 'grid' }, state.files.map(card)), more]
: h('div', { class: 'empty raised', onclick: () => picker.click() },
h('div', { class: 'icon sunken' }, '⬆'), h('div', { class: 'big' }, 'Select videos to upload'), h('div', { class: 'dim' }, 'Or drag and drop video files'));
shell([picker,
h('div', { class: 'uploadbar raised' }, h('button', { class: 'btn y', onclick: () => picker.click() }, '⬆ Upload video'),
h('div', { class: 'hint sunken' }, 'or drop files anywhere · max 5 GB · .mkv becomes .mp4')),
h('h2', { class: 'count' }, 'All videos ', h('span', {}, `(${state.total ?? 0})`)), body], 'all');
}
async function watchView(name) {
let f = null;
if (me) {
try { f = await api('/files/' + encodeURIComponent(name)); }
catch (e) { if (e instanceof LoggedOut || !/doesn't exist/.test(e.message)) throw e; }
}
// not logged in, or someone else's clip: everyone may watch a shared link
if (!f) return publicWatchView(await api('/public/' + encodeURIComponent(name)));
if (state.total == null) { try { state.total = (await api('/files?page=1')).total; } catch {} }
const left = daysLeft(f.deletesAt);
const link = media(f.url);
const share = shareLink(f.name, link);
const copyBtn = h('button', { class: 'btn y', onclick: () => copy(share) }, '⧉ Copy link');
const shown = h('div', { class: 'link sunken' }, share), note = h('div', { class: 'small' });
const player = (f.type || '').startsWith('image/')
? h('img', { class: 'sunken', src: link, alt: f.name })
: h('video', { class: 'sunken', src: link, controls: true, preload: 'metadata', poster: f.thumbnail ? media(f.thumbnail) : false });
shell([
h('button', { class: 'btn back', onclick: () => go('/') }, '◂ All videos'),
h('div', { class: 'watch' }, player,
h('h1', {}, f.name),
h('div', { class: 'info' }, [fmtDate(f.createdAt), plural(f.views, 'view'), fmtSize(f.size),
left != null ? `deletes in ${plural(left, 'day')}` : null].filter(Boolean).join(' · ')),
h('div', { class: 'actions' },
copyBtn,
h('a', { class: 'btn', href: link + '?download=true', download: f.name }, '⬇ Download'),
h('button', { class: 'btn', onclick: () => rename(f) }, '✎ Rename'),
h('button', { class: 'btn right', onclick: () => remove(f) }, '✖ Delete')),
shown, note)], null);
holdForDiscord(f.name, shown, (ready, msg = '') => {
copyBtn.disabled = !ready; shown.textContent = ready ? share : msg; note.textContent = ready ? msg : '';
});
}
function publicWatchView(f) {
const link = media(f.url);
const share = shareLink(f.name, link);
const player = (f.type || '').startsWith('image/')
? h('img', { class: 'sunken', src: link, alt: f.name })
: h('video', { class: 'sunken', src: link, controls: true, preload: 'metadata', poster: f.thumbnail ? media(f.thumbnail) : false });
shell([
me ? h('button', { class: 'btn back', onclick: () => go('/') }, '◂ All videos') : null,
h('div', { class: 'watch' }, player,
h('h1', {}, f.name),
h('div', { class: 'info' }, fmtSize(f.size)),
h('div', { class: 'actions' },
h('button', { class: 'btn y', onclick: () => copy(share) }, '⧉ Copy link'),
h('a', { class: 'btn', href: link + '?download=true', download: f.name }, '⬇ Download')),
h('div', { class: 'link sunken' }, share))], null);
}
async function rename(f) {
const ext = (f.name.match(/\.[^.]+$/) || [''])[0];
let renamed = null;
const ok = await promptBox('Rename', 'New name', f.name, async (v) => {
if (!v) return 'Type a name.';
if (ext && !v.toLowerCase().endsWith(ext.toLowerCase())) v += ext;
if (v === f.name) return null;
try { renamed = (await api('/files/' + encodeURIComponent(f.id), { method: 'PATCH', body: { name: v } })).name; return null; }
catch (e) { if (e instanceof LoggedOut) { location.reload(); return null; } return e.message; }
});
if (ok && renamed) go('/v/' + encodeURIComponent(renamed), true);
}
async function remove(f) {
if (!(await confirmBox('Delete clip', `Delete ${f.name}? The link stops working.`, 'Delete'))) return;
try { await api('/files/' + encodeURIComponent(f.id), { method: 'DELETE' }); toast('Deleted'); go('/'); }
catch (e) { handleError(e); }
}
function send(file, ticket, onProgress, onSent) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
const form = new FormData();
form.append('file', file, file.name);
const t0 = Date.now();
let sentAny = false;
xhr.open('POST', ticket.uploadUrl + '/upload');
xhr.setRequestHeader('x-upload-ticket', ticket.ticket);
xhr.upload.onprogress = (e) => {
if (!e.lengthComputable) return;
sentAny = sentAny || e.loaded > 0;
const rate = e.loaded / Math.max((Date.now() - t0) / 1000, 0.001);
const left = rate > 0 ? Math.round((e.total - e.loaded) / rate) : 0;
onProgress(xhr, e.loaded / e.total, `Sending to ${SERVER} · ${fmtSize(e.loaded)} / ${fmtSize(e.total)} · ${fmtSize(rate)}/s · ${left}s left`);
};
xhr.upload.onload = onSent;
xhr.onload = () => {
let data = {};
try { data = JSON.parse(xhr.responseText); } catch {}
if (xhr.status === 200) resolve(data); else reject(new Error(data.error || `Upload failed (${xhr.status})`));
};
xhr.onerror = () => reject(new Error(sentAny ? `Lost connection to ${SERVER}.` : cfg.uploadHint));
xhr.onabort = () => reject(new Error('Cancelled'));
onProgress(xhr, 0, `Connecting to ${SERVER}...`);
xhr.send(form);
});
}
// what /discord says about a clip, worded for the dialog
function discordNote(s) {
if (s.state === 'small') return 'plays as is';
if (s.state === 'ready') return 'ready';
if (s.state === 'none') return "too long for Discord's player";
if (s.queued) return 'waiting for another clip...';
return s.progress >= 0.99 ? 'finishing...' : Math.round(s.progress * 100) + '%';
}
const fmtLeft = (s) => s < 60 ? `~${Math.max(1, Math.round(s))} s` : `~${Math.round(s / 60)} min`;
const linkName = (r) => r.name + (r.remuxed ? ' (converted to .mp4)'
: /\.mkv$/i.test(r.name) ? " (couldn't convert to .mp4, kept as .mkv: Discord may not play it inline)" : '');
// the upload dialog walks through three steps, like Steam installing a game:
// send each file to the server, wait for the small Discord copies, then hand out the links.
// Discord caches whatever it sees first, so a link is only shown once its clip will play there.
async function uploadFiles(files) {
if (!files.length) return;
const bar = sbar(), line = h('div', { class: 'small' }), sub = h('div', { class: 'small' }), extra = h('div');
const steps = ['Send to ' + SERVER, 'Prepare for Discord', 'Link ready'].map((label) => {
const mark = h('i', {}, '·'), right = h('span', { class: 'r' });
const row = h('div', {}, mark, h('span', {}, label), right);
row.set = (state, text = '') => { row.className = state; mark.textContent = { done: '✓', now: '▸' }[state] || '·'; right.textContent = text; };
return row;
});
let xhr = null, cancelled = false, failure = null;
const btn = h('button', { class: 'btn', onclick: () => { cancelled = true; if (xhr) xhr.abort(); } }, 'Cancel');
const d = dialog('Uploading', [h('div', { class: 'steps' }, steps), line, bar, sub, extra, h('div', { class: 'row' }, btn)]);
d.box.classList.add('install');
const title = d.box.querySelector('.title');
const done = [];
// step 1: send the files, one at a time (keeps the server's CPU and upload link calm)
for (let i = 0; i < files.length && !cancelled; i++) {
const file = files[i];
title.textContent = 'Uploading · ' + file.name;
steps[0].set('now', files.length > 1 ? `${i + 1} of ${files.length}` : fmtSize(file.size));
setBar(bar, 0);
sub.textContent = /\.mkv$/i.test(file.name) ? 'then: converting .mkv to .mp4' : '';
btn.disabled = false;
try {
const ticket = await api('/upload-ticket', { method: 'POST' });
done.push(await send(file, ticket,
(x, frac, text) => { xhr = x; setBar(bar, frac); line.textContent = text; },
() => { setBar(bar, 1); line.textContent = `Processing on ${SERVER}...`; btn.disabled = true; }));
} catch (e) {
if (!cancelled) failure = `${file.name}: ${e instanceof LoggedOut ? 'You were logged out. Log in again.' : e.message}`;
break;
}
}
if (!done.length) {
d.close();
if (failure) await alertBox('Upload failed', failure);
return go(location.pathname, true);
}
steps[0].set('done', done.length > 1 ? plural(done.length, 'clip') : fmtSize(files[0].size));
// step 2: wait until Discord can play every clip (closing early is fine: the copies get made anyway)
await barFull(bar);
title.textContent = 'Preparing for Discord';
steps[1].set('now');
sub.textContent = '';
setBar(bar, 0);
line.textContent = 'Discord only waits ~8 s for a video, so big clips get a small 720p copy just for it. Your original stays full quality.';
let closed = false;
btn.textContent = 'Close'; btn.disabled = false;
btn.onclick = () => { closed = true; d.close(); };
const perClip = done.map((r) => h('div', { class: 'clip' }, h('span', {}, r.name), h('span', { class: 'r' })));
if (done.length > 1) extra.replaceChildren(h('div', { class: 'clips sunken' }, perClip));
const status = done.map(() => ({ state: 'making', progress: 0, queued: true }));
let first = null;
for (;;) {
await Promise.all(done.map(async (r, i) => {
if (status[i].state !== 'making') return;
if (!isVideo(r.name)) { status[i] = { state: 'small', progress: 1, queued: false }; return; }
try { status[i] = await api('/discord/' + encodeURIComponent(r.name)); }
catch { status[i] = { state: 'small', progress: 1, queued: false }; } // can't ask: don't hold the link forever
}));
if (closed) return go(location.pathname, true);
const making = status.filter((s) => s.state === 'making');
const frac = status.reduce((a, s) => a + (s.state === 'making' ? s.progress : 1), 0) / status.length;
status.forEach((s, i) => { perClip[i].lastChild.textContent = discordNote(s); });
setBar(bar, frac);
if (!making.length) break;
if (making.every((s) => s.queued)) steps[1].set('now', 'waiting for another clip...');
else {
if (!first && frac > 0) first = { t: Date.now(), frac };
const rate = first && frac > first.frac ? (frac - first.frac) / (Date.now() - first.t) : 0;
steps[1].set('now', Math.round(frac * 100) + '%' + (rate ? ' · ' + fmtLeft((1 - frac) / rate / 1000) : ''));
}
await wait(1000);
if (closed) return go(location.pathname, true);
}
const allSmall = status.every((s) => s.state === 'small');
steps[1].set('done', allSmall ? 'plays as is' : '100%');
await barFull(bar);
// step 3: the links, Copy lit up
steps[2].set('done');
title.textContent = done.length > 1 ? `Ready · ${plural(done.length, 'clip')}` : 'Ready · ' + done[0].name;
line.textContent = done.length > 1 ? 'Paste these in Discord: they play right in the chat.' : 'Paste this in Discord: it plays right in the chat.';
bar.remove();
extra.replaceChildren(...done.map((r, i) => {
const share = shareLink(r.name, r.url);
return h('div', { class: 'linkrow' },
done.length > 1 || r.remuxed || /\.mkv$/i.test(r.name) ? h('div', { class: 'small' }, linkName(r)) : null,
h('div', { style: 'display:flex;gap:6px' },
h('input', { class: 'field', value: share, readonly: true, onclick: (e) => e.target.select() }),
h('button', { class: 'btn y', onclick: () => copy(share) }, 'Copy')),
status[i].state === 'none' ? h('div', { class: 'small' }, "Too long for Discord's player: the link opens the clip, but won't play in the chat.") : null);
}));
if (status.some((s) => s.state === 'none') && done.length === 1) line.textContent = 'Your link:';
d.box.classList.add('ready');
await new Promise((res) => { btn.onclick = () => { d.close(); res(); }; });
if (failure) await alertBox('Upload failed', failure);
go(location.pathname, true);
}
function go(path, replace = false) {
if (path !== location.pathname) history[replace ? 'replaceState' : 'pushState']({}, '', path);
route().catch(handleError);
}
async function route(progress) {
const m = location.pathname.match(/^\/v\/(.+)$/);
if (m) {
let name;
try { name = decodeURIComponent(m[1]); } catch { throw new Error("This clip doesn't exist (or isn't yours)."); }
await watchView(name);
if (progress) progress(1);
}
else await libraryView(progress);
}
async function handleError(e) {
if (e instanceof LoggedOut) {
await loginDialog();
me = await api('/me');
return route().catch(handleError);
}
await alertBox('Error', e.message);
if (location.pathname !== '/') go('/', true);
}
async function logout() { await api('/logout', { method: 'POST' }).catch(() => {}); location.reload(); }
// the loading dialog moves on real steps: config+session, library list, thumbnails
async function boot() {
const bar = sbar(), status = h('div', { class: 'small' }, `Connecting to ${SERVER}...`);
const d = dialog(SITE, [h('div', { class: 'small' }, 'Loading...'), bar, status], { clear: true });
const step = (frac, text) => { setBar(bar, frac); status.textContent = text; };
try {
step(0.1, 'Checking your session...');
cfg = await api('/config');
try { me = await api('/me'); }
catch (e) { if (!(e instanceof LoggedOut) || !location.pathname.startsWith('/v/')) throw e; me = null; }
step(0.4, 'Loading your videos...');
await route((frac) => step(0.5 + frac * 0.5, 'Loading thumbnails...'));
step(1, 'Done');
await barFull(bar);
await wait(250);
d.close();
} catch (e) {
d.close();
if (e instanceof LoggedOut) { await loginDialog(); return boot(); }
if (location.pathname !== '/') {
// a clip that's gone (or someone else's): say so, then show the library
await alertBox(SITE, e.message);
history.replaceState({}, '', '/');
return boot();
}
dialog(SITE, [h('div', { class: 'msg' }, e.message),
h('div', { class: 'row' }, h('button', { class: 'btn y', onclick: () => location.reload() }, 'Retry'))], { clear: true });
}
}
let dragDepth = 0;
const hasFiles = (e) => e.dataTransfer && [...e.dataTransfer.types].includes('Files');
document.addEventListener('dragenter', (e) => { if (!me || !hasFiles(e)) return; e.preventDefault(); dragDepth++; document.body.classList.add('dropping'); });
document.addEventListener('dragleave', () => { if (--dragDepth <= 0) { dragDepth = 0; document.body.classList.remove('dropping'); } });
document.addEventListener('dragover', (e) => { if (hasFiles(e)) e.preventDefault(); });
document.addEventListener('drop', (e) => {
if (!hasFiles(e)) return;
e.preventDefault(); dragDepth = 0; document.body.classList.remove('dropping');
if (me && e.dataTransfer.files.length) uploadFiles([...e.dataTransfer.files]);
});
document.addEventListener('click', (e) => {
document.querySelectorAll('.menu').forEach((m) => { if (!m.parentElement.contains(e.target)) m.style.display = 'none'; });
});
window.addEventListener('popstate', () => route().catch(handleError));
boot();
+15
View File
@@ -0,0 +1,15 @@
<!doctype html>
<html lang="en" data-server="{{server}}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#3e4637">
<title>{{name}}</title>
<link rel="stylesheet" href="/_site/static/style.css?v={{v}}">
<script src="/_site/static/app.js?v={{v}}" defer></script>
</head>
<body>
<div id="app"></div>
<div id="dialogs"></div>
</body>
</html>
+135
View File
@@ -0,0 +1,135 @@
/* clips site: 2003 Steam / gamedate.org skin on a Streamable layout */
:root{--body-bg:#3e4637;--light:#4c5844;--lighter:#5a6a50;--bl:#899281;--bd:#292d23;--text:#d8ded3;--dim:#b8c4ad;
--yel:#c4b550;--link:#b8a040;--sel:#96892d;--well:#2f3529}
*{box-sizing:border-box;margin:0;padding:0}
html,body,#app{height:100%}
body{background:var(--body-bg);color:var(--text);font:13px "Trebuchet MS",Verdana,sans-serif}
::selection{background:var(--sel);color:#fff}
a{color:var(--link)}
button,input{font:inherit;color:inherit}
.raised{border:1px solid;border-color:var(--bl) var(--bd) var(--bd) var(--bl)}
.sunken{border:1px solid;border-color:var(--bd) var(--bl) var(--bl) var(--bd)}
.btn{background:var(--light);color:var(--text);padding:3px 12px;cursor:pointer;border:1px solid;border-color:var(--bl) var(--bd) var(--bd) var(--bl);text-decoration:none;display:inline-block}
.btn:hover{background:var(--lighter)}
.btn:active{border-color:var(--bd) var(--bl) var(--bl) var(--bd)}
.btn.y{background:var(--yel);color:#2b2a10;font-weight:bold;border-color:#e6da8a #6f6620 #6f6620 #e6da8a}
.btn.y:hover{background:#d2c35c}
.btn:disabled{opacity:.5;cursor:default}
input.field{background:var(--well);border:1px solid;border-color:var(--bd) var(--bl) var(--bl) var(--bd);padding:4px 6px;width:100%}
input.field:focus{outline:1px solid var(--yel)}
.shell{display:flex;flex-direction:column;height:100%}
.topbar{display:flex;align-items:center;gap:8px;padding:4px 8px;background:var(--light);border-bottom:1px solid var(--bd)}
.logo{display:flex;align-items:center;gap:7px;color:#fff;font-weight:bold;font-size:15px;text-decoration:none}
.logo i{width:16px;height:16px;background:var(--yel);border:1px solid;border-color:#e6da8a #6f6620 #6f6620 #e6da8a}
.user{margin-left:auto;position:relative}
.menu{position:absolute;right:0;top:100%;margin-top:2px;background:var(--light);min-width:140px;z-index:20}
.menu div{padding:4px 10px;cursor:pointer}
.menu div:hover{background:var(--sel);color:#fff}
.burger{display:none}
.main{display:flex;flex:1;min-height:0}
.side{width:200px;flex:none;background:var(--light);border-right:1px solid var(--bd);padding:8px}
.side .item{padding:4px 8px;cursor:pointer}
.side .item.on{background:var(--lighter);color:var(--yel)}
.side .lbl{margin-top:16px;font-size:10px;letter-spacing:1px;color:var(--dim)}
.side .small{font-size:11px;color:var(--dim);margin-top:2px}
.content{flex:1;overflow:auto;padding:12px 16px}
.wrap{max-width:1100px;margin:0 auto}
.wrap:has(.grid){max-width:1500px} /* the library gets more width than the watch page */
.uploadbar{display:flex;gap:8px;align-items:center;background:var(--light);padding:8px}
.uploadbar .hint{flex:1;background:var(--well);color:var(--dim);padding:4px 8px}
h2.count{color:var(--yel);font-size:15px;margin:14px 0 8px;font-weight:normal}
h2.count span{color:var(--dim)}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:10px} /* 4 across on a laptop, 5 on 1080p */
.card{background:var(--light);padding:5px;cursor:pointer}
.card:hover{background:var(--lighter)}
.thumb{aspect-ratio:16/9;background:var(--well) center/cover no-repeat;display:flex;align-items:center;justify-content:center;color:var(--dim);font-size:22px}
.card .t{margin-top:4px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.card .m{font-size:11px;color:var(--dim)}
.more{text-align:center;margin:14px 0}
.empty{background:var(--light);min-height:320px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:6px;cursor:pointer}
.empty .icon{width:72px;height:72px;background:var(--well);display:flex;align-items:center;justify-content:center;font-size:32px;color:var(--yel)}
.empty .big{font-size:16px}
.empty .dim{color:var(--dim)}
.dropping .content{outline:2px dashed var(--yel);outline-offset:-6px}
.back{margin-bottom:10px}
.watch video,.watch img{width:100%;max-height:70vh;background:#000;display:block;object-fit:contain}
.watch h1{color:var(--yel);font-size:18px;font-weight:normal;margin-top:10px;word-break:break-all}
.watch .info{color:var(--dim);font-size:12px;margin-top:2px}
.watch .actions{display:flex;gap:6px;margin-top:10px;flex-wrap:wrap}
.watch .actions .right{margin-left:auto}
.watch .link{margin-top:8px;background:var(--well);color:var(--dim);padding:4px 8px;font-size:12px;word-break:break-all}
.statusbar{display:flex;justify-content:space-between;background:var(--light);border-top:1px solid var(--bd);font-size:10px;color:var(--dim);padding:2px 8px}
.overlay{position:fixed;inset:0;background:#0006;display:flex;align-items:center;justify-content:center;z-index:50;padding:16px}
.overlay.clear{background:var(--body-bg)}
.dlg{background:var(--light);width:360px;max-width:100%}
.dlg .title{background:var(--lighter);color:#fff;font-size:11px;font-weight:bold;letter-spacing:1px;padding:5px 8px;border-bottom:1px solid var(--bd);text-transform:uppercase;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.dlg .body{padding:12px}
.dlg .row{display:flex;gap:6px;justify-content:flex-end;margin-top:12px}
.dlg label{display:block;font-size:11px;color:var(--dim);margin:8px 0 3px}
.dlg .msg{font-size:12px;line-height:1.5;word-break:break-word}
.dlg .err{color:#e8b7a0;font-size:12px;margin-top:8px;min-height:1em}
.dlg .small{font-size:10px;color:var(--dim)}
/* Steam 2004 progress bar (VGUI ProgressBar): 8px maize blocks, 4px gaps, 4px inset, lit-from-below well */
.sbar{height:24px;padding:4px 0;margin:6px 0;overflow:hidden;background:var(--body-bg);
border:1px solid;border-color:#282e22 #808080 #808080 #282e22}
.sbar .fill{height:100%;width:0;background:repeating-linear-gradient(90deg,transparent 0 4px,var(--yel) 4px 12px)}
/* upload dialog as a 3-step install: done steps ticked, the current one lit yellow */
.dlg.install{width:420px}
.steps{margin:0 0 10px;font-size:12px}
.steps div{display:flex;gap:8px;align-items:baseline;padding:3px 0;color:#8a947f}
.steps div i{font-style:normal;width:14px;text-align:center}
.steps div .r{margin-left:auto;font-size:11px}
.steps div.done{color:var(--text)}
.steps div.done i{color:var(--yel)}
.steps div.now{color:var(--yel);font-weight:bold}
.steps div.now .r{font-weight:normal}
.install .clips{background:var(--well);margin-top:8px;padding:4px 8px;font-size:11px;max-height:120px;overflow:auto}
.install .clip{display:flex;gap:8px;color:var(--dim);padding:1px 0}
.install .clip span:first-child{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.install .linkrow{margin-top:8px}
.install.ready .steps{border-bottom:1px solid var(--bd);box-shadow:0 1px 0 var(--bl);padding-bottom:8px}
.install .row{margin-top:10px}
@keyframes shake{0%,100%{transform:translateX(0)}20%,60%{transform:translateX(-6px)}40%,80%{transform:translateX(6px)}}
.shake{animation:shake .35s}
/* login screen */
.login{--bg:#4c5844;--panel:#3e4637;--hi:#889180;--lo:#2b3125;--dim:#a4ad9b;--mark:#445040;
position:fixed;inset:0;z-index:50;display:flex;flex-direction:column;overflow:hidden;
background:var(--bg) radial-gradient(rgba(0,0,0,.12) 1px,transparent 1px) 0 0/3px 3px;font:12px/1.4 Tahoma,Verdana,sans-serif}
@media (prefers-color-scheme:dark){.login{--bg:#434e3c;--mark:#3b4636}}
.login header{background:var(--panel);display:flex;align-items:center;gap:6px;padding:4px 10px;border-bottom:1px solid var(--lo);box-shadow:0 1px 0 var(--hi);position:relative;z-index:2}
.login header b{font-size:13px;letter-spacing:.5px}
.login header span{color:var(--dim);padding:1px 8px}
.login .sp{flex:1}
.login .mark{position:absolute;right:-90px;bottom:-60px;width:620px;max-width:90vw;fill:var(--mark);pointer-events:none}
.login .mark path{fill:var(--bg)}
.login .mark-word{position:absolute;left:36px;top:70px;font:bold clamp(48px,9vw,120px)/1 Tahoma,Verdana,sans-serif;color:var(--mark);letter-spacing:-2px;pointer-events:none;user-select:none}
.login main{flex:1;display:flex;align-items:center;justify-content:center;padding:20px;position:relative}
.login .win{width:340px;max-width:100%;background:var(--panel);position:relative;z-index:2;border:1px solid;border-color:var(--hi) var(--lo) var(--lo) var(--hi)}
.login .title{background:var(--lo);display:flex;align-items:center;padding:3px 4px 3px 8px;font-weight:bold}
.login .title i{flex:1;font-style:normal}
.login .title u{width:16px;height:14px;margin-left:2px;background:var(--panel);text-decoration:none;text-align:center;font:bold 10px/12px Tahoma}
.login .body{padding:14px 16px 16px}
.login .brand{display:flex;align-items:center;gap:10px;margin-bottom:14px}
.login .brand svg{width:34px;height:34px;flex:none}
.login .brand strong{font-size:22px;letter-spacing:-.5px}
.login label{display:block;margin:10px 0 3px;color:var(--dim)}
.login input.field{background:var(--lo);font:13px Tahoma,Verdana,sans-serif;padding:5px 6px;border-color:var(--lo) var(--hi) var(--hi) var(--lo)}
.login input.field:focus{outline:1px dotted var(--yel);outline-offset:1px}
.login .err{color:var(--yel);margin-top:10px;min-height:16px}
.login .row{display:flex;justify-content:flex-end;margin-top:14px}
.login .btn.y{font:bold 12px Tahoma,Verdana,sans-serif;padding:5px 22px}
.login .sbar{margin-top:10px}
@media (max-width:560px){.login .hide{display:none}.login .mark-word{top:50px}}
.toast{position:fixed;bottom:24px;left:50%;transform:translateX(-50%);background:var(--light);padding:6px 14px;z-index:60}
@media (max-width:760px){
.burger{display:inline-block}
.side{position:fixed;top:33px;bottom:0;left:0;z-index:30;display:none}
.side.open{display:block}
.grid{grid-template-columns:repeat(2,1fr)}
.content{padding:8px}
.uploadbar .hint{display:none}
}
@media (max-width:440px){.grid{grid-template-columns:1fr}}
+48
View File
@@ -0,0 +1,48 @@
import pytest
from app.names import clean_name, next_free_name, split_name
@pytest.mark.parametrize(
"raw, expected",
[
("2026-10-01 21-33-07.mkv", "2026-10-01-21-33-07.mkv"),
("my clip\tfinal.mp4", "my-clip-final.mp4"),
(" padded.mp4 ", "padded.mp4"),
("C:\\Users\\alice\\Videos\\a b.mp4", "a-b.mp4"),
("dir/sub/x y.png", "x-y.png"),
("çılgın an #1.mp4", "çılgın-an-#1.mp4"),
("", "upload"),
(" ", "upload"),
],
)
def test_clean_name(raw, expected):
assert clean_name(raw) == expected
def test_split_name():
assert split_name("clip.mp4") == ("clip", ".mp4")
assert split_name("a.b.mkv") == ("a.b", ".mkv")
def test_split_name_edge_cases():
assert split_name("README") == ("README", "")
assert split_name(".mp4") == (".mp4", "")
def test_next_free_name_free():
assert next_free_name("clip", ".mp4", lambda n: False) == "clip.mp4"
def test_next_free_name_suffixes():
taken = {"clip.mp4", "clip-2.mp4"}
assert next_free_name("clip", ".mp4", taken.__contains__) == "clip-3.mp4"
def test_next_free_name_no_extension():
assert next_free_name("README", "", {"README"}.__contains__) == "README-2"
def test_next_free_name_gives_up():
with pytest.raises(RuntimeError):
next_free_name("x", ".mp4", lambda n: True, limit=5)
+161
View File
@@ -0,0 +1,161 @@
import httpx
import pytest
from fastapi.testclient import TestClient
from app.previews import (
LONG_SECONDS,
Previews,
encode_cmd,
follow_progress,
niced,
target_bytes,
video_kbps,
)
from app.server import Settings, create_app
from tests.test_server import FakeZipline as UploadZipline
from tests.test_site_api import FakeZipline, login, mkfile
def test_bitrate_fits_the_target():
for seconds in (30, 70, 300):
kbps = video_kbps(seconds)
assert (kbps + 128) * 1000 / 8 * seconds <= target_bytes(seconds)
assert video_kbps(10) == 4000 # short clips are capped, not blown up
assert video_kbps(3600) is None # an hour can't fit in 22 MB at any watchable quality
assert video_kbps(0) is None
def test_long_clips_get_a_smaller_file():
assert target_bytes(LONG_SECONDS) == 22_000_000
assert target_bytes(LONG_SECONDS + 1) == 18_000_000
def test_encode_keeps_one_audio_track_and_720p_up_to_four_minutes():
cmd = encode_cmd("http://zipline:3000/a.mp4", "out.mp4", 600, LONG_SECONDS)
assert cmd[cmd.index("-map") + 1:cmd.index("-map") + 4] == ["0:v:0", "-map", "0:a:0?"]
assert "scale=-2:'min(720,ih)'" in cmd and "+faststart" in cmd
assert "scale=-2:'min(480,ih)'" in encode_cmd("x", "y", 600, LONG_SECONDS + 1)
def test_encode_runs_at_low_priority_when_nice_exists(monkeypatch):
monkeypatch.setattr("app.previews.shutil.which", lambda name: "/usr/bin/nice")
assert niced(["ffmpeg", "-i", "x"]) == ["nice", "-n", "10", "ffmpeg", "-i", "x"]
monkeypatch.setattr("app.previews.shutil.which", lambda name: None)
assert niced(["ffmpeg"]) == ["ffmpeg"]
def test_small_clips_get_no_copy(tmp_path, monkeypatch):
p = Previews(tmp_path, "http://zipline:3000", over_bytes=1000)
monkeypatch.setattr(p, "make", lambda *a: pytest.fail("should not encode"))
p.request("small.mp4", 999)
assert p.jobs.empty() and not p.pending
def test_failed_clip_is_not_retried(tmp_path):
p = Previews(tmp_path, "http://zipline:3000", over_bytes=10)
(tmp_path / f"{Previews.key('big.mp4', 50)}.failed").touch()
p.request("big.mp4", 50)
assert p.jobs.empty()
def test_key_changes_with_size_and_rejects_paths(tmp_path):
assert Previews.key("a.mp4", 1) != Previews.key("a.mp4", 2)
p = Previews(tmp_path, "http://zipline:3000", over_bytes=10)
assert p.file("../secret") is None
@pytest.fixture
def setup(tmp_path, monkeypatch):
zl = FakeZipline()
zl.files["tok-alice"].append(mkfile("e9", "big.mp4", size=5000))
asked = []
monkeypatch.setattr(Previews, "request", lambda self, name, length: asked.append((name, length)))
monkeypatch.setattr("app.site_api.video_size", lambda url, length: (1280, 720))
settings = Settings(zipline_url="http://zipline:3000", site_origin="https://clips.example.com",
upload_url="https://upload.example.com", max_bytes=10_000, work_dir=tmp_path, preview_over=1000)
client = TestClient(create_app(settings, transport=httpx.MockTransport(zl.handler)), base_url="https://testserver")
return client, asked, tmp_path / "previews"
def test_big_clip_without_copy_links_the_original_and_asks_for_one(setup):
client, asked, _ = setup
r = client.get("/v/big.mp4")
assert '<meta property="og:video" content="https://clips.example.com/big.mp4">' in r.text
assert asked == [("big.mp4", 5000)]
def test_big_clip_with_copy_links_the_copy(setup):
client, asked, folder = setup
key = Previews.key("big.mp4", 5000)
folder.mkdir()
(folder / f"{key}.mp4").write_bytes(b"0123456789")
r = client.get("/v/big.mp4")
assert f'<meta property="og:video" content="https://clips.example.com/_site/preview/{key}.mp4">' in r.text
assert '<meta property="og:video:width" content="1280">' in r.text
assert asked == []
got = client.get(f"/_site/preview/{key}.mp4", headers={"range": "bytes=2-5"})
assert got.status_code == 206 and got.content == b"2345"
assert got.headers["content-type"] == "video/mp4"
assert client.get("/_site/preview/" + "0" * 24 + ".mp4").status_code == 404
def test_upload_of_a_video_asks_for_a_copy(tmp_path, monkeypatch):
asked = []
monkeypatch.setattr(Previews, "request", lambda self, name, length: asked.append((name, length)))
settings = Settings(zipline_url="http://zipline:3000", site_origin="https://clips.example.com",
upload_url="https://upload.example.com", max_bytes=10_000, work_dir=tmp_path, preview_over=1000)
client = TestClient(create_app(settings, transport=httpx.MockTransport(UploadZipline().handler)))
for name, kind in (("clip.mp4", "video/mp4"), ("notes.zip", "application/zip")):
r = client.post("/upload", headers={"authorization": "tok"}, files={"file": (name, b"x" * 1500, kind)})
assert r.status_code == 200
assert asked == [("clip.mp4", 1500)]
def test_rename_carries_the_copy_over(setup):
client, _, folder = setup
folder.mkdir()
(folder / f"{Previews.key('big.mp4', 5000)}.mp4").write_bytes(b"copy")
login(client)
assert client.patch("/_site/api/files/e9", json={"name": "renamed.mp4"}).json() == {"name": "renamed.mp4"}
assert (folder / f"{Previews.key('renamed.mp4', 5000)}.mp4").read_bytes() == b"copy"
assert not (folder / f"{Previews.key('big.mp4', 5000)}.mp4").exists()
r = client.get("/v/renamed.mp4")
assert f"/_site/preview/{Previews.key('renamed.mp4', 5000)}.mp4" in r.text
def test_discord_status_tells_the_site_when_the_link_is_safe(setup):
client, asked, folder = setup
get = lambda name: client.get(f"/_site/api/discord/{name}").json()
assert get("clutch.mp4") == {"state": "small", "progress": 1.0, "queued": False} # plays as is
assert get("big.mp4") == {"state": "making", "progress": 0.0, "queued": True}
assert asked == [("big.mp4", 5000)]
folder.mkdir()
key = Previews.key("big.mp4", 5000)
(folder / f"{key}.failed").touch()
assert get("big.mp4")["state"] == "none"
(folder / f"{key}.mp4").write_bytes(b"copy")
assert get("big.mp4") == {"state": "ready", "progress": 1.0, "queued": False}
assert get("nope.mp4")["state"] == "small"
def test_status_shows_the_encode_progress_and_the_queue(tmp_path):
p = Previews(tmp_path, "http://zipline:3000", over_bytes=10)
p.worker = type("Busy", (), {"is_alive": lambda self: True})() # a running worker: request() only queues
p.request("a.mp4", 50)
p.request("b.mp4", 50)
p.current, p.progress = Previews.key("a.mp4", 50), 0.42
assert p.status("a.mp4", 50) == {"state": "making", "progress": 0.42, "queued": False}
assert p.status("b.mp4", 50) == {"state": "making", "progress": 0.0, "queued": True}
def test_follow_progress_reads_ffmpeg_progress_lines():
seen = []
lines = ["frame=10", "out_time_us=5000000", "progress=continue", "out_time_us=N/A",
"out_time_us=9000000", "out_time_us=12000000", "progress=end"]
follow_progress(lines, 10.0, seen.append)
assert seen == [0.5, 0.9, 1.0]
def test_encode_reports_progress():
cmd = encode_cmd("x", "y", 600, 30)
assert cmd[cmd.index("-progress") + 1] == "pipe:1" and "-nostats" in cmd
+40
View File
@@ -0,0 +1,40 @@
import shutil
import subprocess
import pytest
from app.remux import remux_to_mp4
needs_ffmpeg = pytest.mark.skipif(shutil.which("ffmpeg") is None, reason="ffmpeg not installed")
def make_mkv(path):
subprocess.run(
["ffmpeg", "-v", "error", "-y",
"-f", "lavfi", "-i", "testsrc=size=320x240:rate=30",
"-f", "lavfi", "-i", "sine=frequency=440",
"-t", "1", "-c:v", "libx264", "-c:a", "aac", str(path)],
check=True,
)
@needs_ffmpeg
def test_remux_good_mkv(tmp_path):
src, dst = tmp_path / "in.mkv", tmp_path / "out.mp4"
make_mkv(src)
assert remux_to_mp4(src, dst) is True
assert dst.read_bytes()[4:8] == b"ftyp"
def test_remux_garbage_returns_false(tmp_path):
src, dst = tmp_path / "in.mkv", tmp_path / "out.mp4"
src.write_bytes(b"not a video at all")
assert remux_to_mp4(src, dst) is False
assert not dst.exists()
def test_remux_missing_ffmpeg_returns_false(tmp_path, monkeypatch):
monkeypatch.setenv("PATH", "")
src, dst = tmp_path / "in.mkv", tmp_path / "out.mp4"
src.write_bytes(b"x")
assert remux_to_mp4(src, dst) is False
+253
View File
@@ -0,0 +1,253 @@
import shutil
import subprocess
from urllib.parse import quote, unquote
import httpx
import pytest
from fastapi.testclient import TestClient
from app.server import Settings, create_app
class FakeZipline:
def __init__(self):
self.names = set() # names Zipline already has
self.race = set() # free at HEAD time, taken by the time we upload
self.fail_with = None # force this status on /api/upload
self.raw_urls = False # return the url unencoded, like a careless server would
self.head_fails = False # HEAD raises, as if Zipline went away mid-request
self.uploads = []
def handler(self, request: httpx.Request) -> httpx.Response:
if request.method == "HEAD" and self.head_fails:
raise httpx.ConnectError("down")
if request.method == "HEAD":
name = unquote(request.url.path[1:])
if name in self.names:
# Zipline redirects text files (and password-protected ones) to /view/<name>
return httpx.Response(302 if name.endswith(".txt") else 200)
return httpx.Response(404)
if request.url.path == "/api/upload":
if self.fail_with:
return httpx.Response(self.fail_with, json={"error": "boom"})
stem = unquote(request.headers["x-zipline-filename"])
name = stem + request.headers.get("x-zipline-file-extension", "")
if name in self.names or name in self.race:
self.race.discard(name)
self.names.add(name)
return httpx.Response(400, json={"error": "file with the same name already exists"})
self.names.add(name)
self.uploads.append({"name": name, "headers": dict(request.headers), "body": request.read()})
return httpx.Response(200, json={"files": [{
"id": "1", "name": name, "type": "video/mp4",
"url": f"https://clips.example.com/{name if self.raw_urls else quote(name)}",
}]})
return httpx.Response(404)
@pytest.fixture
def zl():
return FakeZipline()
@pytest.fixture
def client(zl, tmp_path):
settings = Settings(zipline_url="http://zipline:3000", site_origin="https://clips.example.com",
upload_url="https://upload.example.com", max_bytes=1000, deletes_at="90d", work_dir=tmp_path)
return TestClient(create_app(settings, transport=httpx.MockTransport(zl.handler)))
AUTH = {"Authorization": "tok"}
def up(client, name, data=b"x" * 10, headers=AUTH):
return client.post("/upload", files={"file": (name, data, "application/octet-stream")}, headers=headers)
def test_health(client):
assert client.get("/health").json() == {"ok": True}
def test_missing_token_is_401(client, zl):
r = up(client, "a.mp4", headers={})
assert r.status_code == 401
assert zl.uploads == []
def test_missing_file_field_is_400(client):
r = client.post("/upload", data={"nope": "1"}, headers=AUTH)
assert r.status_code == 400
def test_spaces_become_dashes_and_headers(client, zl):
r = up(client, "my clip 1.mp4")
assert r.status_code == 200
assert r.json() == {"url": "https://clips.example.com/my-clip-1.mp4", "name": "my-clip-1.mp4", "remuxed": False}
h = zl.uploads[0]["headers"]
assert h["authorization"] == "tok"
assert h["x-zipline-format"] == "name"
assert h["x-zipline-filename"] == "my-clip-1"
assert h["x-zipline-file-extension"] == ".mp4"
assert h["x-zipline-deletes-at"] == "90d"
assert h["x-zipline-original-name"] == "true"
assert zl.uploads[0]["body"].count(b"x" * 10) == 1
def test_duplicate_gets_suffix(client, zl):
zl.names |= {"clip.mp4", "clip-2.mp4"}
assert up(client, "clip.mp4").json()["name"] == "clip-3.mp4"
def test_taken_text_file_redirect_counts_as_taken(client, zl):
zl.names |= {"n.txt", "n-2.txt", "n-3.txt"}
r = up(client, "n.txt")
assert r.status_code == 200
assert r.json()["name"] == "n-4.txt"
def test_race_retries_next_name(client, zl):
zl.race.add("clip.mp4")
r = up(client, "clip.mp4")
assert r.status_code == 200
assert r.json()["name"] == "clip-2.mp4"
def test_unicode_and_special_chars(client, zl):
r = up(client, "çılgın an #1.mp4")
assert r.status_code == 200
assert r.json()["name"] == "çılgın-an-#1.mp4"
assert zl.uploads[0]["headers"]["x-zipline-filename"] == quote("çılgın-an-#1")
def test_no_extension(client, zl):
r = up(client, "README")
assert r.json()["name"] == "README"
assert "x-zipline-file-extension" not in zl.uploads[0]["headers"]
def test_exact_limit_ok(client):
assert up(client, "a.bin", data=b"x" * 1000).status_code == 200
def test_one_byte_over_is_413(client, zl):
r = up(client, "a.bin", data=b"x" * 1001)
assert r.status_code == 413
assert r.json()["error"] == f"file is larger than the {1000 / 1024**3:g} GB limit"
assert zl.uploads == []
def test_huge_content_length_rejected_early(client, zl):
r = client.post("/upload", content=b"x", headers={**AUTH, "content-length": str(10**12),
"content-type": "multipart/form-data; boundary=b"})
assert r.status_code == 413
def test_zipline_error_is_502_and_cleans_up(client, zl, tmp_path):
zl.fail_with = 500
r = up(client, "a b.mkv", data=b"garbage")
assert r.status_code == 502
assert list(tmp_path.iterdir()) == []
def test_zipline_4xx_passed_through(client, zl):
zl.fail_with = 401
assert up(client, "a.mp4").status_code == 401
def test_bad_mkv_kept_as_mkv(client, zl):
r = up(client, "a b.mkv", data=b"not a video")
assert r.status_code == 200
assert r.json() == {"url": "https://clips.example.com/a-b.mkv", "name": "a-b.mkv", "remuxed": False}
@pytest.mark.skipif(shutil.which("ffmpeg") is None, reason="ffmpeg not installed")
def test_good_mkv_becomes_mp4(zl, tmp_path):
src = tmp_path / "src.mkv"
subprocess.run(["ffmpeg", "-v", "error", "-y", "-f", "lavfi", "-i", "testsrc=size=320x240:rate=30",
"-t", "1", "-c:v", "libx264", str(src)], check=True)
work = tmp_path / "work"
work.mkdir()
settings = Settings(zipline_url="http://zipline:3000", site_origin="https://clips.example.com",
upload_url="https://upload.example.com", max_bytes=10**8, deletes_at="90d", work_dir=work)
c = TestClient(create_app(settings, transport=httpx.MockTransport(zl.handler)))
r = up(c, "my clip.mkv", data=src.read_bytes())
assert r.json()["name"] == "my-clip.mp4"
assert r.json()["remuxed"] is True
assert zl.uploads[0]["headers"]["x-zipline-file-extension"] == ".mp4"
assert list(work.iterdir()) == []
def test_generic_content_type_is_guessed_from_extension(client, zl):
# curl/ShareX send application/octet-stream; Discord only plays video/mp4 inline
assert up(client, "clip.mp4").status_code == 200
assert b"Content-Type: video/mp4" in zl.uploads[0]["body"]
def test_unknown_extension_stays_octet_stream(client, zl):
assert up(client, "data.zzz9").status_code == 200
assert b"Content-Type: application/octet-stream" in zl.uploads[0]["body"]
def test_upload_with_ticket(client, zl):
ticket = client.app.state.tickets.issue("tok-from-ticket")
r = up(client, "t.mp4", headers={"x-upload-ticket": ticket})
assert r.status_code == 200
assert zl.uploads[0]["headers"]["authorization"] == "tok-from-ticket"
def test_ticket_single_use(client, zl):
ticket = client.app.state.tickets.issue("tok")
assert up(client, "a.mp4", headers={"x-upload-ticket": ticket}).status_code == 200
r = up(client, "b.mp4", headers={"x-upload-ticket": ticket})
assert r.status_code == 401
assert r.json() == {"error": "upload ticket expired, try again"}
assert len(zl.uploads) == 1
def test_bad_ticket_is_401(client, zl):
assert up(client, "a.mp4", headers={"x-upload-ticket": "made-up"}).status_code == 401
assert zl.uploads == []
def test_cors_preflight_for_site_origin(client):
r = client.options("/upload", headers={
"origin": "https://clips.example.com",
"access-control-request-method": "POST",
"access-control-request-headers": "x-upload-ticket",
})
assert r.status_code == 200
assert r.headers["access-control-allow-origin"] == "https://clips.example.com"
assert "x-upload-ticket" in r.headers["access-control-allow-headers"].lower()
def test_cors_refuses_other_origin(client):
r = client.options("/upload", headers={
"origin": "https://evil.example",
"access-control-request-method": "POST",
})
assert "access-control-allow-origin" not in r.headers
def test_cors_headers_on_error_responses(client):
r = up(client, "a.bin", data=b"x" * 1001, headers={**AUTH, "origin": "https://clips.example.com"})
assert r.status_code == 413
assert r.headers["access-control-allow-origin"] == "https://clips.example.com"
def test_link_is_built_from_the_name_not_ziplines_url(client, zl):
# a '#' left unencoded in Zipline's url would cut the shared link off
zl.raw_urls = True
r = up(client, "çılgın an #1.mp4")
assert r.json()["url"] == "https://clips.example.com/" + quote("çılgın-an-#1.mp4")
def test_zipline_down_during_name_check_is_502(client, zl):
zl.head_fails = True
r = up(client, "a.mp4")
assert r.status_code == 502
def test_no_free_name_is_409(client, zl, monkeypatch):
from app import server
monkeypatch.setattr(server, "next_free_name", lambda *a, **k: (_ for _ in ()).throw(RuntimeError("full")))
assert up(client, "a.mp4").status_code == 409
+382
View File
@@ -0,0 +1,382 @@
import json
import math
import re
from urllib.parse import quote, unquote
import httpx
import pytest
from fastapi.testclient import TestClient
from app.server import Settings, create_app
def mkfile(fid, name, size=100, thumb=True):
return {
"id": fid, "name": name, "originalName": name, "size": size, "type": "video/mp4", "views": 3,
"createdAt": "2026-10-01T16:46:15.378Z", "deletesAt": "2026-12-30T16:46:15.375Z",
"thumbnail": {"path": f".thumbnail.{fid}.jpg"} if thumb else None, "tags": [], "url": "/" + name,
}
class FakeZipline:
def __init__(self):
self.users = {"alice": ("pw", "tok-alice"), "bob": ("pw2", "tok-bob")}
self.files = {
"tok-alice": [mkfile("e1", "clutch.mp4"), mkfile("e2", "çılgın-#1.mp4", thumb=False)],
"tok-bob": [mkfile("c1", "friend.mp4")],
}
self.down = False
self.calls = []
def owner(self, tok):
return next((name for name, (_, t) in self.users.items() if t == tok), None)
def handler(self, request: httpx.Request) -> httpx.Response:
if self.down:
raise httpx.ConnectError("down")
path, method = request.url.path, request.method
self.calls.append((method, path, request.headers.get("authorization"), request.headers.get("cookie")))
if path == "/api/auth/login":
body = json.loads(request.content)
user = self.users.get(body["username"])
if not user or user[0] != body["password"]:
return httpx.Response(400, json={"error": "E1044: Invalid username or password"})
return httpx.Response(
200, json={"user": {"id": "u-" + body["username"], "username": body["username"]}},
headers={"set-cookie": f"zipline_session=sess-{body['username']}; Max-Age=1209600; Path=/; HttpOnly; Secure; SameSite=Lax"},
)
if path == "/api/user/token":
cookie = request.headers.get("cookie") or ""
for name, (_, tok) in self.users.items():
if f"zipline_session=sess-{name}" in cookie:
return httpx.Response(200, json={"token": tok})
return httpx.Response(401, json={"error": "E2001: Invalid token"})
if method == "HEAD":
name = unquote(path[1:])
f = next((f for files in self.files.values() for f in files if f["name"] == name), None)
if f is None:
return httpx.Response(404)
return httpx.Response(200, headers={"content-type": f["type"], "content-length": str(f["size"])})
tok = request.headers.get("authorization")
who = self.owner(tok)
if who is None:
return httpx.Response(401, json={"error": "E2001: Invalid token"})
mine = self.files.setdefault(tok, [])
if path == "/api/user":
return httpx.Response(200, json={"user": {"id": "u-" + who, "username": who, "role": "USER"}})
if path == "/api/user/stats":
return httpx.Response(200, json={"storageUsed": sum(f["size"] for f in mine)})
if path == "/api/user/files":
page = int(request.url.params.get("page", "1"))
per = int(request.url.params.get("perpage", "15"))
chunk = mine[(page - 1) * per: page * per]
return httpx.Response(200, json={"page": chunk, "total": len(mine), "pages": max(1, math.ceil(len(mine) / per))})
if path.startswith("/api/user/files/"):
ident = unquote(path[len("/api/user/files/"):])
f = next((f for f in mine if ident in (f["id"], f["name"])), None)
if f is None:
return httpx.Response(404, json={"error": "E4000: File not found"})
if method == "GET":
return httpx.Response(200, json=f)
if method == "PATCH":
f["name"] = json.loads(request.content)["name"]
return httpx.Response(200, json=f)
if method == "DELETE":
mine.remove(f)
return httpx.Response(200, json=f)
return httpx.Response(404, json={"error": "not found"})
@pytest.fixture
def zl():
return FakeZipline()
@pytest.fixture
def client(zl, tmp_path):
settings = Settings(zipline_url="http://zipline:3000", site_origin="https://clips.example.com",
upload_url="https://upload.example.com", max_bytes=1000, deletes_at="90d", work_dir=tmp_path)
return TestClient(create_app(settings, transport=httpx.MockTransport(zl.handler)), base_url="https://testserver")
def login(client, user="alice", pw="pw", ip="1.2.3.4"):
return client.post("/_site/api/login", json={"username": user, "password": pw}, headers={"cf-connecting-ip": ip})
def test_pages_serve_the_site(client):
for path in ("/", "/v/clutch.mp4", "/v/%C3%A7%C4%B1lg%C4%B1n-%231.mp4"):
r = client.get(path)
assert r.status_code == 200
assert "text/html" in r.headers["content-type"]
assert "/_site/static/app.js" in r.text
assert client.get("/_site/static/style.css").status_code == 200
@pytest.fixture
def fake_media(monkeypatch):
seen = []
monkeypatch.setattr("app.site_api.video_size", lambda url, length: seen.append(url) or (1920, 1080))
monkeypatch.setattr("app.site_api.still_jpeg", lambda url, length: seen.append(url) or b"jpeg-bytes")
return seen
def test_watch_page_has_discord_player_tags(client, fake_media):
# Discord gives up on big raw links; the /v/ page hands it the player URL, size and a still instead
r = client.get("/v/%C3%A7%C4%B1lg%C4%B1n-%231.mp4")
clip = "https://clips.example.com/%C3%A7%C4%B1lg%C4%B1n-%231.mp4"
assert f'<meta property="og:video" content="{clip}">' in r.text
assert '<meta property="og:video:type" content="video/mp4">' in r.text
assert '<meta property="og:video:width" content="1920">' in r.text
assert '<meta property="og:video:height" content="1080">' in r.text
assert ('<meta property="og:image" content="https://clips.example.com/_site/thumb/'
'%C3%A7%C4%B1lg%C4%B1n-%231.mp4">') in r.text
assert fake_media == ["http://zipline:3000/%C3%A7%C4%B1lg%C4%B1n-%231.mp4"]
def test_shared_clip_gets_a_random_stripe_color(client, fake_media):
colors = {re.search(r'name="theme-color" content="(#[0-9a-f]{6})"', client.get("/v/clutch.mp4").text).group(1)
for _ in range(5)}
assert len(colors) > 1 and "#3e4637" not in colors
assert 'name="theme-color" content="#3e4637"' in client.get("/").text
def test_watch_page_without_clip_has_no_player_tags(client, fake_media):
for path in ("/v/nope.mp4", '/v/"><script>alert(1)</script>'):
r = client.get(path)
assert r.status_code == 200
assert "og:video" not in r.text and "<script>alert" not in r.text
assert fake_media == []
def test_title_is_escaped(zl, client, fake_media):
zl.files["tok-alice"].append(mkfile("e3", 'a"><b>.mp4'))
r = client.get("/v/" + quote('a"><b>.mp4'))
assert '<meta property="og:title" content="a&quot;&gt;&lt;b&gt;.mp4">' in r.text
assert '"><b>' not in r.text
def test_thumbnail(client, fake_media):
r = client.get("/_site/thumb/clutch.mp4")
assert r.status_code == 200 and r.headers["content-type"] == "image/jpeg"
assert r.content == b"jpeg-bytes"
assert client.get("/_site/thumb/nope.mp4").status_code == 404
def test_public_file_needs_no_login(client):
r = client.get("/_site/api/public/clutch.mp4")
assert r.status_code == 200
assert r.json() == {"name": "clutch.mp4", "type": "video/mp4", "size": 100, "url": "/clutch.mp4",
"thumbnail": "/_site/thumb/clutch.mp4"}
assert r.headers["cache-control"] == "no-store, private"
assert client.get("/_site/api/public/nope.mp4").status_code == 404
def test_public_file_when_zipline_is_down(zl, client):
zl.down = True
assert client.get("/_site/api/public/clutch.mp4").status_code == 502
def test_config(client):
assert client.get("/_site/api/config").json() == {
"uploadUrl": "https://upload.example.com", "publicUrl": "https://clips.example.com",
"uploadHint": "Can't reach the upload server."}
def test_site_and_server_names_reach_the_page(zl, tmp_path, fake_media):
settings = Settings(zipline_url="http://zipline:3000", site_origin="https://clips.example.com",
site_name='my <clips>', server_name='box"1', work_dir=tmp_path)
client = TestClient(create_app(settings, transport=httpx.MockTransport(zl.handler)), base_url="https://testserver")
page = client.get("/").text
assert "<title>my &lt;clips&gt;</title>" in page
assert 'data-server="box&quot;1"' in page
assert '<meta property="og:site_name" content="my &lt;clips&gt;">' in client.get("/v/clutch.mp4").text
def test_login_sets_cookie(client):
r = login(client)
assert r.status_code == 200
assert r.json() == {"username": "alice"}
sc = r.headers["set-cookie"]
assert "ys=tok-alice" in sc
for flag in ("HttpOnly", "Secure", "SameSite=lax", "Max-Age=2592000", "Path=/"):
assert flag in sc
def test_login_wrong_password(client):
r = login(client, pw="nope")
assert r.status_code == 401
assert r.json() == {"error": "Wrong username or password."}
assert "ys=" not in r.headers.get("set-cookie", "")
def test_login_rate_limit_per_ip(client):
for _ in range(5):
assert login(client, pw="nope").status_code == 401
r = login(client)
assert r.status_code == 429
assert r.json() == {"error": "Too many tries, wait a few minutes."}
assert login(client, ip="9.9.9.9").status_code == 200
def test_login_zipline_down(client, zl):
zl.down = True
assert login(client).status_code == 502
def test_two_logins_get_own_tokens(client):
login(client, "alice", "pw")
r = login(client, "bob", "pw2")
assert "ys=tok-bob" in r.headers["set-cookie"]
assert client.get("/_site/api/me").json()["username"] == "bob"
def test_me_requires_login(client):
assert client.get("/_site/api/me").status_code == 401
def test_me(client):
login(client)
assert client.get("/_site/api/me").json() == {"username": "alice", "storageUsed": 200}
def test_revoked_token_clears_cookie(client):
client.cookies.set("ys", "revoked", domain="testserver")
r = client.get("/_site/api/files")
assert r.status_code == 401
assert 'ys=""' in r.headers["set-cookie"] or "ys=;" in r.headers["set-cookie"]
assert "Max-Age=0" in r.headers["set-cookie"]
def test_logout(client):
login(client)
r = client.post("/_site/api/logout")
assert r.json() == {"ok": True}
assert "Max-Age=0" in r.headers["set-cookie"]
assert client.get("/_site/api/me").status_code == 401
def test_files_list(client, zl):
login(client)
d = client.get("/_site/api/files").json()
assert d["total"] == 2 and d["pages"] == 1
first = d["files"][0]
assert first == {
"id": "e1", "name": "clutch.mp4", "size": 100, "type": "video/mp4", "views": 3,
"createdAt": "2026-10-01T16:46:15.378Z", "deletesAt": "2026-12-30T16:46:15.375Z",
"url": "/clutch.mp4", "thumbnail": "/raw/.thumbnail.e1.jpg",
}
assert d["files"][1]["url"] == "/" + quote("çılgın-#1.mp4")
assert d["files"][1]["thumbnail"] is None
sent = [c for c in zl.calls if c[1] == "/api/user/files"][0]
assert sent[2] == "tok-alice"
def test_files_paging(client, zl):
zl.files["tok-alice"] = [mkfile(f"f{i}", f"c{i}.mp4") for i in range(30)]
login(client)
p1 = client.get("/_site/api/files?page=1").json()
p2 = client.get("/_site/api/files?page=2").json()
assert len(p1["files"]) == 24 and p1["pages"] == 2
assert len(p2["files"]) == 6
def test_friend_cannot_see_alice_file(client):
login(client, "bob", "pw2")
r = client.get("/_site/api/files/e1")
assert r.status_code == 404
assert r.json() == {"error": "This clip doesn't exist (or isn't yours)."}
def test_file_by_unicode_name(client):
login(client)
r = client.get("/_site/api/files/" + quote("çılgın-#1.mp4"))
assert r.status_code == 200
assert r.json()["id"] == "e2"
def test_rename(client, zl):
login(client)
r = client.patch("/_site/api/files/e1", json={"name": "best clip ever.mp4"})
assert r.json() == {"name": "best-clip-ever.mp4"}
assert zl.files["tok-alice"][0]["name"] == "best-clip-ever.mp4"
def test_rename_strips_path(client, zl):
login(client)
assert client.patch("/_site/api/files/e1", json={"name": "../x y.mp4"}).json() == {"name": "x-y.mp4"}
def test_rename_taken(client, zl):
login(client)
r = client.patch("/_site/api/files/e1", json={"name": "friend.mp4"})
assert r.status_code == 409
assert r.json() == {"error": "That name is already used."}
assert not [c for c in zl.calls if c[0] == "PATCH"]
def test_delete(client, zl):
login(client)
assert client.delete("/_site/api/files/e1").json() == {"ok": True}
assert [f["id"] for f in zl.files["tok-alice"]] == ["e2"]
def test_files_zipline_down(client, zl):
login(client)
zl.down = True
r = client.get("/_site/api/files")
assert r.status_code == 502
assert r.json() == {"error": "Zipline isn't reachable right now."}
def test_upload_ticket(client):
assert client.post("/_site/api/upload-ticket").status_code == 401
login(client)
d = client.post("/_site/api/upload-ticket").json()
assert d["uploadUrl"] == "https://upload.example.com"
assert client.app.state.tickets.redeem(d["ticket"]) == "tok-alice"
def test_assets_are_versioned_and_page_not_cached(client):
# Cloudflare keeps .js/.css in browsers for 4 hours; a content hash in the URL makes updates show up at once
r = client.get("/")
assert r.headers["cache-control"] == "no-store"
assert client.get("/v/clip.mp4").headers["cache-control"] == "no-store"
assert "/_site/static/app.js?v=" in r.text
assert "/_site/static/style.css?v=" in r.text
assert "{{v}}" not in r.text
def test_api_answers_are_never_cached(client):
# Cloudflare caches anything ending in .mp4 by extension; a cached clip lookup would leak between users
for r in (client.get("/_site/api/files/clutch.mp4"), login(client), client.get("/_site/api/files/clutch.mp4"),
client.get("/_site/api/files/nope.mp4"), client.get("/_site/api/me"), client.get("/_site/api/config")):
assert r.headers.get("cache-control") == "no-store, private"
def test_login_limit_uses_forwarded_for_without_cloudflare(client):
# over the Tailscale address there's no CF-Connecting-IP; tailscale serve passes the tailnet IP in X-Forwarded-For
def tailnet_login(ip, pw):
return client.post("/_site/api/login", json={"username": "alice", "password": pw},
headers={"x-forwarded-for": ip})
for _ in range(5):
tailnet_login("100.64.0.5", "nope")
assert tailnet_login("100.64.0.5", "pw").status_code == 429
assert tailnet_login("100.64.0.9", "pw").status_code == 200
def test_other_account_login_doesnt_reset_the_limit(client):
# bob guessing alice's password can't clear their strikes by logging in as themselves
for _ in range(4):
login(client, "alice", "nope")
assert login(client, "bob", "pw2").status_code == 200
login(client, "alice", "nope")
assert login(client, "alice", "pw").status_code == 429
def test_empty_or_nested_file_id_is_404(client, zl):
login(client)
before = len(zl.calls)
for r in (client.get("/_site/api/files/"), client.delete("/_site/api/files/a%2Fb"),
client.patch("/_site/api/files/x/transaction", json={"name": "y.mp4"})):
assert r.status_code == 404
assert len(zl.calls) == before
+66
View File
@@ -0,0 +1,66 @@
from app.site_auth import LoginLimiter, TicketStore
class Clock:
def __init__(self):
self.t = 1000.0
def __call__(self):
return self.t
def test_limiter_blocks_after_limit():
lim = LoginLimiter(limit=3, window=60, now=Clock())
for _ in range(3):
assert not lim.blocked("ip")
lim.fail("ip")
assert lim.blocked("ip")
assert not lim.blocked("other-ip")
def test_limiter_forgets_expired_keys():
# made-up usernames make a new key per guess; once their strikes expire they're dropped
clock = Clock()
lim = LoginLimiter(limit=5, window=60, now=clock)
for i in range(100):
lim.fail(f"ip|user{i}")
clock.t += 61
lim.fail("ip|someone")
assert list(lim.fails) == ["ip|someone"]
def test_limiter_window_slides():
clock = Clock()
lim = LoginLimiter(limit=2, window=60, now=clock)
lim.fail("ip")
lim.fail("ip")
assert lim.blocked("ip")
clock.t += 61
assert not lim.blocked("ip")
def test_limiter_reset():
lim = LoginLimiter(limit=1, window=60, now=Clock())
lim.fail("ip")
lim.reset("ip")
assert not lim.blocked("ip")
def test_ticket_redeems_once():
store = TicketStore(ttl=60, now=Clock())
t = store.issue("tok")
assert len(t) >= 32
assert store.redeem(t) == "tok"
assert store.redeem(t) is None
def test_ticket_expires():
clock = Clock()
store = TicketStore(ttl=60, now=clock)
t = store.issue("tok")
clock.t += 61
assert store.redeem(t) is None
def test_unknown_ticket():
assert TicketStore().redeem("nope") is None