Files
siftlode/backend/tests/test_gif_share.py
T
peter c221452a00 feat(downloads): a clip or an edit can be a GIF, shared as a picture
A clip and an editor range can come out as a silent GIF (long side
320/480/640, 12 fps, at most 30 s, one palette per GIF); one over 30 MB
is not kept. A shared picture is served inline under the preview's
sandbox, and a GIF's link card is the GIF itself up to 15 MB, the still
for Facebook's crawler.
2026-11-11 01:50:55 +01:00

138 lines
5.8 KiB
Python

"""A shared GIF (or any shared picture): served inline as itself on the public page, and its link
preview card. Teams moves a GIF `og:image` up to 15 MB; Messenger never showed one and showed a JPEG
(measured 2026-11-09/11), so Facebook's crawler and a GIF too big to move get the still. DB-lane."""
import pytest
from app import main
from app.config import settings
from app.downloads import og
from app.downloads import probe as probemod
from app.downloads.models import DownloadJob, MediaAsset
_PAGE = """<!doctype html><html><head>
<!--OG:START--><meta property="og:title" content="Siftlode" /><!--OG:END-->
</head><body><script src="/assets/watch.js"></script></body></html>"""
_GIF_PROBE = {
"streams": [{"codec_type": "video", "codec_name": "gif", "width": 300, "height": 170}],
"format": {"format_name": "gif"},
}
_GIF = b"GIF89a" + (300).to_bytes(2, "little") + (170).to_bytes(2, "little") + b"\x00" * 16
_TEAMS = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) SkypeUriPreview Preview/0.5 skype-url-preview@microsoft.com"
_FACEBOOK = "facebookexternalhit/1.1 (+http://www.facebook.com/externalhit_uatext.php)"
@pytest.fixture
def pages(tmp_path, monkeypatch):
watch = tmp_path / "watch.html"
watch.write_text(_PAGE, encoding="utf-8")
monkeypatch.setattr(main, "WATCH_HTML", watch)
@pytest.fixture
def shared(as_user, make_user, tmp_path, monkeypatch, upload_headers):
"""Uploads `content` as `name` (ffprobe reading it as `data`) and shares it."""
monkeypatch.setattr(settings, "files_root", str(tmp_path / "media"))
owner = as_user(make_user())
def share(name: str, content: bytes, data: dict | None, **link: object) -> dict:
monkeypatch.setattr(probemod, "probe_json", lambda _p: data)
job = owner.post(
"/api/files/upload", content=content, headers=upload_headers(name, display_name=name)
)
assert job.status_code == 200, job.text
made = owner.post(f"/api/downloads/{job.json()['id']}/links", json=link)
assert made.status_code == 200, made.text
return {**made.json(), "job_id": job.json()["id"]}
return share
def _asset(db, made) -> MediaAsset:
return db.get(MediaAsset, db.get(DownloadJob, made["job_id"]).asset_id)
def test_a_shared_gif_is_served_inline_as_a_gif_and_can_run_nothing(shared, client):
made = shared("wave.gif", _GIF, _GIF_PROBE, allow_download=False)
r = client.get(f"/api/public/watch/{made['token']}/file")
assert r.status_code == 200
assert r.headers["content-type"] == "image/gif"
assert r.headers["content-disposition"].startswith("inline")
# A "gif" whose bytes are a page must not run on this login-free origin.
assert r.headers["x-content-type-options"] == "nosniff"
assert "sandbox" in r.headers["content-security-policy"]
assert r.content == _GIF
def test_a_gif_link_that_allows_download_hands_it_over_as_a_file(shared, client):
made = shared("wave.gif", _GIF, _GIF_PROBE, allow_download=True)
r = client.get(f"/api/public/watch/{made['token']}/file")
assert r.headers["content-type"] == "image/gif"
assert r.headers["content-disposition"].startswith("attachment")
def test_any_other_file_is_still_only_a_download(shared, client):
made = shared("notes.html", b"<script>alert(1)</script>", None)
r = client.get(f"/api/public/watch/{made['token']}/file")
assert r.headers["content-type"] == "application/octet-stream"
assert r.headers["content-disposition"].startswith("attachment")
def test_the_page_is_told_a_picture_is_a_picture(shared, client):
made = shared("wave.gif", _GIF, _GIF_PROBE)
meta = client.get(f"/api/public/watch/{made['token']}").json()
assert meta["preview"] == "image"
other = shared("backup.tar.gz", b"x", None)
assert client.get(f"/api/public/watch/{other['token']}").json()["preview"] is None
def _og(text: str, prop: str) -> str | None:
marker = f'property="{prop}" content="'
if marker not in text:
return None
return text.split(marker, 1)[1].split('"', 1)[0]
def test_a_gif_link_previews_as_the_moving_gif_itself(pages, shared, client):
made = shared("wave.gif", _GIF, _GIF_PROBE)
page = client.get(f"/file/{made['token']}", headers={"User-Agent": _TEAMS}).text
assert _og(page, "og:image") == (f"{settings.app_base}/api/public/watch/{made['token']}/file")
assert _og(page, "og:image:type") == "image/gif"
# Its size from its own header: a card wants it up front.
assert (_og(page, "og:image:width"), _og(page, "og:image:height")) == ("300", "170")
def test_facebooks_crawler_gets_the_still_of_a_gif(pages, shared, client, db):
made = shared("wave.gif", _GIF, _GIF_PROBE)
asset = _asset(db, made)
asset.poster_path = "wave.jpg"
db.commit()
page = client.get(f"/file/{made['token']}", headers={"User-Agent": _FACEBOOK}).text
assert _og(page, "og:image") == (
f"{settings.app_base}/api/public/watch/{made['token']}/poster.jpg"
)
assert _og(page, "og:image:type") == "image/jpeg"
def test_a_gif_too_big_to_move_previews_as_its_still(pages, shared, client, db):
made = shared("wave.gif", _GIF, _GIF_PROBE)
asset = _asset(db, made)
asset.poster_path = "wave.jpg"
asset.size_bytes = 15 * 1024 * 1024 + 1
db.commit()
page = client.get(f"/file/{made['token']}", headers={"User-Agent": _TEAMS}).text
assert _og(page, "og:image").endswith("/poster.jpg")
def test_a_password_gif_link_shows_nothing_of_it(pages, shared, client):
made = shared("wave.gif", _GIF, _GIF_PROBE, password="pw")
page = client.get(f"/file/{made['token']}", headers={"User-Agent": _TEAMS}).text
assert _og(page, "og:image") is None
def test_the_card_builder_takes_no_agent_as_a_browser(shared, db):
made = shared("wave.gif", _GIF, _GIF_PROBE)
assert 'content="image/gif"' in og._watch_tags(made["token"], db)