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.
138 lines
5.8 KiB
Python
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)
|