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

+34
View File
@@ -0,0 +1,34 @@
# Copy to .env and fill in. Never commit .env.
# Which front door to run: "caddy" (open ports 80/443, automatic HTTPS) or "tunnel" (Cloudflare Tunnel)
COMPOSE_PROFILES=caddy
# Your domain, without https://. With Caddy, point its DNS A/AAAA record at this machine first.
SITE_DOMAIN=clips.example.com
# Two long random strings. Make each with: openssl rand -hex 32
POSTGRESQL_PASSWORD=
CORE_SECRET=
# --- optional ---
# Name shown in the tab, the logo and Discord embeds. Its first word is the big word on the login screen.
SITE_NAME=kaledonya clips
# Shown in the upload dialog and status bar: "Sending to <SERVER_NAME>", "connected · <SERVER_NAME>"
SERVER_NAME=the server
# Where the browser sends uploads. Leave unset to use https://SITE_DOMAIN (right for Caddy).
# Behind a Cloudflare Tunnel set this to another address, because Cloudflare refuses uploads over 100 MB (see README).
#UPLOAD_URL=https://upload.example.com
# Shown when an upload can't connect at all, e.g. "Connect to the VPN to upload."
#UPLOAD_HINT=Can't reach the upload server.
# How long clips live before Zipline deletes them (Zipline duration format: 30d, 90d, ...)
#CLIP_LIFETIME=90d
# CPU cap for intake (uploads, .mkv remuxing, Discord copies). Lower it if the machine runs hot.
#INTAKE_CPUS=4
# How often Zipline makes library thumbnails for new uploads
#THUMBNAIL_INTERVAL=5m
# Only for COMPOSE_PROFILES=tunnel: the token from Cloudflare Zero Trust -> Networks -> Tunnels
#TUNNEL_TOKEN=
+1
View File
@@ -0,0 +1 @@
* text=auto eol=lf
+5
View File
@@ -0,0 +1,5 @@
.env
data/
__pycache__/
.pytest_cache/
.venv/
+24
View File
@@ -0,0 +1,24 @@
# The site, watch pages and uploads go to intake; everything else (the clips themselves, /dashboard, /api) to Zipline.
{$SITE_DOMAIN} {
@intake {
path / /v/* /_site/*
}
@upload {
method POST
path /upload
}
handle @intake {
reverse_proxy intake:8000 {
# intake trusts this header for the login rate limit; only Cloudflare may set it
header_up -Cf-Connecting-Ip
}
}
handle @upload {
reverse_proxy intake:8000 {
header_up -Cf-Connecting-Ip
}
}
handle {
reverse_proxy zipline:3000
}
}
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 esefxdz
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+223
View File
@@ -0,0 +1,223 @@
<h1 align="center">kaledonya clips</h1>
<p align="center">
<b>Share game clips that actually play inside Discord.</b><br>
A small self-hosted clip site on top of <a href="https://github.com/diced/zipline">Zipline</a>, dressed like Steam in 2003.
</p>
<p align="center">
<img alt="MIT license" src="https://img.shields.io/badge/license-MIT-c4b550?style=flat-square">
<img alt="Python 3.12" src="https://img.shields.io/badge/python-3.12-3e4637?style=flat-square&logo=python&logoColor=white">
<img alt="FastAPI" src="https://img.shields.io/badge/FastAPI-backend-3e4637?style=flat-square&logo=fastapi&logoColor=white">
<img alt="Docker Compose" src="https://img.shields.io/badge/docker-compose-3e4637?style=flat-square&logo=docker&logoColor=white">
<img alt="Built on Zipline 4.8" src="https://img.shields.io/badge/built%20on-Zipline%204.8-3e4637?style=flat-square">
<img alt="No build step" src="https://img.shields.io/badge/frontend-no%20build%20step-3e4637?style=flat-square">
</p>
<p align="center">
<img src="docs/upload.gif" width="540" alt="Uploading two clips: send, prepare for Discord with a live percentage, then the links appear with Copy buttons">
</p>
## Why
Discord only shows a video player for a linked file it can download in about **8 seconds**. From a home
connection that's around 30 MB, while a few minutes of 1080p is hundreds. Post a raw link to a big clip and your
friends get a grey box.
So for every big clip, the server makes a small 720p copy that is only used for Discord's player, and it holds
the link back until that copy is ready (Discord caches whatever it sees first). The original stays untouched and
plays at full quality on the site.
<table>
<tr>
<td width="50%"><img src="docs/library.png" alt="Library: a grid of clips with thumbnails, views and days left"><br><sub><b>Library:</b> only your own clips, with thumbnails, views and how long each one has left</sub></td>
<td width="50%"><img src="docs/watch.png" alt="Watch page: the clip in a player with Copy link, Download, Rename and Delete"><br><sub><b>Watch page:</b> the <code>/v/</code> link you share, which gets a proper player in Discord and Telegram</sub></td>
</tr>
<tr>
<td width="50%"><img src="docs/login.png" alt="Login screen styled like an old Steam window"><br><sub><b>Login:</b> invite only, with your own Zipline accounts</sub></td>
<td width="50%"><img src="docs/upload-done.png" alt="Upload finished: two links with Copy buttons"><br><sub><b>Done:</b> links only show up once they'll play in Discord</sub></td>
</tr>
</table>
## What you get
- **Library** of your own clips (each account only sees its own), with thumbnails, rename and delete
- **Watch page** at `/v/<name>`: the link you share. Discord, Telegram and others get a proper video embed
- **Uploads** by drag and drop, up to 5 GB, as a three-step install dialog:
*Send → Prepare for Discord → Link ready*, with real progress and time left at each step
- **Clean names:** spaces become dashes, a taken name gets `-2`, `-3`, ...
- **`.mkv` → `.mp4`** automatically (stream copy, no quality loss), so OBS recordings play in browsers and Discord
- **Clips delete themselves** after 90 days (configurable)
- **Invite only:** you make the accounts, nobody can sign up
- The raw file is still at `https://<domain>/<name>` and Zipline's own dashboard at `/dashboard`
## How it fits together
```
browser / Discord
|
Caddy (or a Cloudflare Tunnel)
|-- / /v/* /_site/* POST /upload --> intake
'-- everything else (clips, /dashboard, /api) --> Zipline --> Postgres
intake = this repo (Python/FastAPI + plain HTML/CSS/JS): the site, uploads, ffmpeg remux and Discord copies
Zipline = stores the files, users, expiry and thumbnails
```
intake never stores passwords. Logging in checks the username and password with Zipline and keeps that user's
Zipline token in an HttpOnly cookie, so every request runs as that user.
## Requirements
- A Linux machine with Docker and Docker Compose v2
- A domain (or subdomain) you control
- Either open ports 80 and 443 (option A) or a free Cloudflare account (option B)
- CPU for the Discord copies: a 3-minute 1080p clip takes about a minute on 4 threads of a laptop i7
## Setup
```bash
git clone https://git.userevolt.app/esef/kaledonya-clips.git
cd kaledonya-clips
cp .env.example .env
```
Edit `.env`: set `SITE_DOMAIN`, and fill `POSTGRESQL_PASSWORD` and `CORE_SECRET` with two different random
strings (`openssl rand -hex 32` makes one). The rest is optional and explained in the file.
### Option A: Caddy (open ports, automatic HTTPS)
1. Point your domain's DNS (A/AAAA record) at the machine and open ports 80 and 443.
2. Keep `COMPOSE_PROFILES=caddy` in `.env`.
3. `docker compose up -d --build`
Caddy gets a certificate for the domain by itself. Uploads go to the same address, so there's nothing else to set.
### Option B: Cloudflare Tunnel (no open ports)
1. In Cloudflare Zero Trust → Networks → Tunnels, create a tunnel and copy its token into `TUNNEL_TOKEN`.
Set `COMPOSE_PROFILES=tunnel`.
2. Add two public hostname routes for your domain, **in this order** (the first match wins):
1. path `^/($|v/|_site/)` → `http://intake:8000`
2. no path → `http://zipline:3000`
3. **Uploads can't go through Cloudflare:** it refuses request bodies over 100 MB. Give intake a second address
that doesn't go through Cloudflare and set `UPLOAD_URL` to it. That can be a VPN such as Tailscale
(`tailscale serve --bg --https=10000 http://127.0.0.1:3020` with a `ports: ["127.0.0.1:3020:8000"]` line added to
`intake`), or a separate subdomain with its own reverse proxy. Set `UPLOAD_HINT` to tell people what to do when
they can't reach it, e.g. `Connect to Tailscale to upload.` Watching and sharing clips works for everyone either way.
4. In Cloudflare → Caching → Cache Rules, add a rule for the domain that **bypasses the cache**. Cloudflare
caches anything ending in `.mp4`, which otherwise makes deleted or renamed clips stick around.
5. `docker compose up -d --build`
### First account and friends
1. Open `https://<domain>/dashboard`. Zipline asks you to create the first account; it becomes the admin.
2. Make accounts for friends in the Zipline dashboard's user admin.
3. Everyone logs in at `https://<domain>/` with their Zipline username and password.
## Using it
- Drag clips onto the page (or use Upload). When the dialog says **Link ready**, copy the link and post it.
- Share the `/v/<name>` link, not the raw file. That's the one with the Discord player.
- If you posted a link to Discord before it said ready and got no player, Discord has cached the broken embed.
Post it again with `?1` on the end.
- Clips over about 6⅓ minutes get no Discord copy: at that length a ~22 MB file would look too bad to watch.
They still play on the site, and Discord shows a plain link.
### Uploading from a script or ShareX
`POST https://<domain>/upload` with the file in a form field named `file` and your Zipline token (copy it in the
Zipline dashboard's settings) in the `Authorization` header:
```bash
curl -H "Authorization: $ZIPLINE_TOKEN" -F "file=@my clip.mkv" https://clips.example.com/upload
# {"url":"https://clips.example.com/my-clip.mp4","name":"my-clip.mp4","remuxed":true}
```
With option B this has to go to `UPLOAD_URL`, not through Cloudflare.
## Settings
All in `.env`; see `.env.example` for the full list.
| Setting | Default | What it does |
|---|---|---|
| `SITE_DOMAIN` | required | Your domain, without `https://` |
| `SITE_NAME` | `kaledonya clips` | Name in the tab, logo and embeds; its first word is the big login-screen word |
| `SERVER_NAME` | `the server` | "Sending to …" and the status bar |
| `UPLOAD_URL` | `https://SITE_DOMAIN` | Where browsers send uploads |
| `UPLOAD_HINT` | `Can't reach the upload server.` | Shown when an upload can't connect |
| `CLIP_LIFETIME` | `90d` | When clips delete themselves |
| `INTAKE_CPUS` | `4` | CPU cap for uploads and Discord copies. Lower it if the machine runs hot |
| `THUMBNAIL_INTERVAL` | `5m` | How often Zipline makes library thumbnails for new uploads |
The Discord copy sizes are at the top of `intake/app/previews.py`: up to 4 minutes → 720p in ~22 MB, longer →
480p in ~18 MB. If your upload speed is well above 4 MB/s you can raise them.
## Things to know
- **Links are public.** Anyone with a link can watch the clip, and names are readable words. Rename a clip to
something unguessable if it shouldn't be found.
- Discord copies are made one at a time in the background, at low CPU priority. Each one takes about a third of
the clip's length on 4 threads.
- Clips, the database and Caddy's certificates are stored in `./data`. intake's scratch space (uploads in
progress, `.mkv` conversion, Discord copies) is the `intake-work` Docker volume. To back up the database:
`docker compose exec zipline-db pg_dump -U zipline zipline | gzip > zipline.sql.gz`.
- Converting a `.mkv` briefly needs about 3× the file size free for the `intake-work` volume.
- Library thumbnails are made by Zipline every few minutes (`THUMBNAIL_INTERVAL`), so a new upload shows a
plain tile until then.
## Updating
```bash
cd kaledonya-clips
git pull
docker compose up -d --build
```
Your `.env`, `./data` and the `intake-work` volume are left alone. Compare `.env.example` with your `.env` for new settings.
## Troubleshooting
| Problem | Fix |
|---|---|
| A link shows no player in Discord | It was posted before the Discord copy was ready, and Discord cached the broken embed. Post it again with `?1` on the end. Wait for **Link ready** next time. |
| Long clips never get a player | Clips over about 6⅓ minutes get no copy on purpose. They play on the site, and Discord shows a plain link. |
| Uploads fail at 100 MB (option B) | That's Cloudflare's limit. Set `UPLOAD_URL` to an address that doesn't go through Cloudflare (see Option B, step 3). |
| Upload says "Can't reach the upload server" | The browser can't reach `UPLOAD_URL`. With a separate upload address (option B), opening `<UPLOAD_URL>/health` in that browser should show `{"ok":true}`. |
| Library tiles stay blank | Zipline makes thumbnails every `THUMBNAIL_INTERVAL` (5 min by default). |
| Caddy has no certificate / browser warns | The domain's DNS must point at this machine and ports 80 and 443 must be reachable. `docker compose logs caddy` says why. |
| A deleted or renamed clip still shows (option B) | Cloudflare cached it. Add the cache-bypass rule from Option B, step 4. |
| Login says "Too many tries" | 5 wrong passwords from one IP for one username in 10 minutes. Wait 10 minutes. |
| Something else | `docker compose logs intake` and `docker compose logs zipline` usually say what went wrong. |
## Security notes
Worth knowing before you hand out accounts:
- **The login cookie holds the user's Zipline API token** (HttpOnly, Secure, SameSite=Lax, 30 days). Logging out
deletes the cookie but doesn't change the token. If you think a token leaked, reset it in the Zipline dashboard.
- **Links are public by design.** Anyone with a clip's link can watch it, and an account can find out whether a
name is taken (an upload gets `-2`, a rename says it's used).
- **Login attempts are limited per IP and username:** 5 wrong passwords in 10 minutes and that pair has to wait.
The IP comes from `CF-Connecting-IP` or `X-Forwarded-For`. The Caddyfile strips the Cloudflare header and Caddy
replaces `X-Forwarded-For`, so neither can be faked. If you expose intake's port some other way (a VPN for
uploads, say), anyone who can reach it can fake those headers, so only expose it to people you trust.
- The no-login routes (`/v/<name>`, thumbnails) run ffmpeg/ffprobe on existing clips. At most two run at
once, and results are cached.
- intake runs as an unprivileged user, and ffmpeg runs at low CPU priority.
## Development
```bash
cd intake
python -m venv .venv && .venv/bin/pip install -r requirements.txt pytest
.venv/bin/python -m pytest -q
```
The tests use a fake Zipline, so they don't need Docker. The few that need `ffmpeg` are skipped without it.
## Credits and license
Built on [Zipline](https://github.com/diced/zipline) by diced. The look is a nod to Steam's 2003 client.
MIT licensed, see [LICENSE](LICENSE).
+105
View File
@@ -0,0 +1,105 @@
name: kaledonya-clips
# Copy .env.example to .env, fill it in, then: docker compose up -d
# Data lives under ./data (database, clips, Caddy's certificates); intake's scratch space is the intake-work volume.
services:
zipline-db:
image: postgres:16
restart: unless-stopped
environment:
POSTGRES_USER: zipline
POSTGRES_PASSWORD: ${POSTGRESQL_PASSWORD:?missing in .env}
POSTGRES_DB: zipline
volumes:
- ./data/pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD", "pg_isready", "-U", "zipline"]
interval: 10s
timeout: 5s
retries: 5
zipline:
image: ghcr.io/diced/zipline:4.8.0
restart: unless-stopped
environment:
DATABASE_URL: postgres://zipline:${POSTGRESQL_PASSWORD}@zipline-db:5432/zipline
CORE_SECRET: ${CORE_SECRET:?missing in .env}
CORE_RETURN_HTTPS_URLS: "true"
CORE_DEFAULT_DOMAIN: ${SITE_DOMAIN:?missing in .env}
CORE_TRUST_PROXY: "true"
FILES_ROUTE: / # clips live at https://<domain>/<name>
FILES_DEFAULT_FORMAT: name
FILES_MAX_FILE_SIZE: 5gb
FILES_DEFAULT_EXPIRATION: ${CLIP_LIFETIME:-90d}
FEATURES_USER_REGISTRATION: "false" # invite only: the admin makes accounts in the dashboard
INVITES_ENABLED: "false"
TASKS_THUMBNAILS_INTERVAL: ${THUMBNAIL_INTERVAL:-5m} # library thumbnails for new uploads (Zipline default: 30m)
depends_on:
zipline-db:
condition: service_healthy
volumes:
- ./data/uploads:/zipline/uploads
- ./data/public:/zipline/public
- ./data/themes:/zipline/themes
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://0.0.0.0:3000/api/healthcheck"]
interval: 15s
timeout: 2s
retries: 2
intake:
build: ./intake
restart: unless-stopped
# uploads, .mkv remuxing and Discord copies; ffmpeg uses up to 4 threads at low priority
cpus: ${INTAKE_CPUS:-4}
environment:
ZIPLINE_URL: http://zipline:3000
MAX_BYTES: "5368709120"
DELETES_AT: ${CLIP_LIFETIME:-90d}
SITE_ORIGIN: https://${SITE_DOMAIN}
UPLOAD_URL: ${UPLOAD_URL:-https://${SITE_DOMAIN}}
SITE_NAME: ${SITE_NAME:-kaledonya clips}
SERVER_NAME: ${SERVER_NAME:-the server}
UPLOAD_HINT: ${UPLOAD_HINT:-Can't reach the upload server.}
volumes:
- intake-work:/work # uploads in progress, .mkv remuxing, Discord copies (a named volume, so uid 1000 can write)
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health')"]
interval: 30s
timeout: 5s
retries: 3
depends_on:
- zipline
# Option A (default): Caddy on ports 80/443 with an automatic HTTPS certificate for SITE_DOMAIN.
caddy:
image: caddy:2
restart: unless-stopped
profiles: ["caddy"]
ports:
- "80:80"
- "443:443"
environment:
SITE_DOMAIN: ${SITE_DOMAIN}
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- ./data/caddy:/data
depends_on:
- intake
- zipline
# Option B: a Cloudflare Tunnel instead of open ports (see README, "Cloudflare Tunnel").
cloudflared:
image: cloudflare/cloudflared:2026.9.3
restart: unless-stopped
profiles: ["tunnel"]
command: tunnel --no-autoupdate run
environment:
TUNNEL_TOKEN: ${TUNNEL_TOKEN:-}
depends_on:
- intake
- zipline
volumes:
intake-work:
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 422 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 788 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 655 KiB

+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