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,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