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:
commit
e6f8905377
34 files changed
+2917
No files matched your search
@@ -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=
|
||||
@@ -0,0 +1 @@
|
||||
* text=auto eol=lf
|
||||
@@ -0,0 +1,5 @@
|
||||
.env
|
||||
data/
|
||||
__pycache__/
|
||||
.pytest_cache/
|
||||
.venv/
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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:
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 422 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 36 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 788 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 655 KiB |
@@ -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"]
|
||||
Whitespace-only changes.
@@ -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}"
|
||||
@@ -0,0 +1,3 @@
|
||||
from .server import create_app
|
||||
|
||||
app = create_app()
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
[pytest]
|
||||
pythonpath = .
|
||||
testpaths = tests
|
||||
@@ -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
|
||||
@@ -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();
|
||||
@@ -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>
|
||||
@@ -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}}
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"><b>.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 <clips></title>" in page
|
||||
assert 'data-server="box"1"' in page
|
||||
assert '<meta property="og:site_name" content="my <clips>">' 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
|
||||
@@ -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
|
||||
Reference in new issue
Block a user