feat(share): a link's address says what it opens

A video stays on /watch/, anything else is given out on /file/ and a folder on /folder/. The token
is the whole key, so every path opens every link and the links already sent keep working. The new
paths are kept out of search indexes like /watch/, boot the player alone, and the unfurl card calls a
file a file.
This commit is contained in:
2026-11-04 19:10:09 +01:00
parent 2bdf1ec76f
commit 4c09a22add
12 changed files with 233 additions and 26 deletions
+1 -1
View File
@@ -74,7 +74,7 @@ only along a declared edge and writes them only through that unit's service; cor
cannot (an ORM write on another unit's loaded row, SQL in `text()`, a commit by `begin()`) is review's to catch, listed in
[`backend/app/README.md`](backend/app/README.md) with the conventions.
Frontend, by folder under `src/`: `main.tsx` provider tree → `shell/` (`App`, header, nav rail, palette,
boot; a shared `/watch` link boots from its own entry, `watch.html` → `watchMain.tsx` → `watch/`, held
boot; a share link (`/watch`, `/file`, `/folder`) boots from its own entry, `watch.html` → `watchMain.tsx` → `watch/`, held
app-free by a contract test) → **`modules/<page>/`** (one folder per page: the page, its rail, its store, its tests,
and its `module.ts` definition — label, icon, gate, the lazy page and rail, its store — collected in nav order by
`features/navigation/moduleRegistry`, which also mounts every store at the root; a new page is its folder, its id in
+16 -2
View File
@@ -127,14 +127,28 @@ def check_grant(token: str, grant: str | None, password_version: int) -> bool:
# --- serialization -------------------------------------------------------------------------
def owner_view(link: DownloadLink, base_url: str = "", token: str | None = None) -> dict:
def share_path(db: Session, link: DownloadLink, token: str) -> str:
"""The address a link is given out on: its path says what it opens. Only a word for the reader,
the token is the whole key: every prefix of `main.SHARE_PREFIXES` opens every link, so the links
already sent on /watch/ work for good. A file still being made reads as the video it mostly is."""
from app.downloads import mediakind
if link.folder_id is not None:
return f"/folder/{token}"
job = db.get(DownloadJob, link.job_id)
asset = db.get(MediaAsset, job.asset_id) if job and job.asset_id else None
kind = asset.media_kind if asset else None
return f"/file/{token}" if kind and kind != mediakind.VIDEO else f"/watch/{token}"
def owner_view(db: Session, link: DownloadLink, token: str | None = None) -> dict:
"""What the file owner sees when managing their links. `token` is the one just made (it is in
hand even where no copy is kept); else it is read back, and `url` is None when it cannot be."""
token = token or recover(link)
return {
"id": link.id,
"token": token,
"url": f"{base_url}/watch/{token}" if token else None,
"url": share_path(db, link, token) if token else None,
"allow_download": link.allow_download,
"has_password": link.password_hash is not None,
"expires_at": link.expires_at.isoformat() if link.expires_at else None,
+11 -6
View File
@@ -1,4 +1,4 @@
"""Server-rendered Open Graph tags for the public /watch/{token} page.
"""Server-rendered Open Graph tags for the public page of a share link (/watch/, /file/, /folder/).
The watch page is a client-side SPA, so a social crawler (facebookexternalhit, Twitterbot, …) that
fetches /watch/{token} would otherwise only see the generic index.html — a blank/greyed link card.
@@ -76,16 +76,21 @@ def _watch_tags(token: str, db: Session) -> str | None:
link = linksmod.find(db, token)
if link is None or linksmod.is_expired(link):
return None
url = f"{settings.app_base}/watch/{token}"
url = f"{settings.app_base}{linksmod.share_path(db, link, token)}"
folder = link.folder_id is not None
# What the card calls it, as its address does (`links.share_path`).
video = "/watch/" in url
if link.password_hash:
# Password-gated: don't leak the title/thumbnail in the unfurl.
return "\n ".join(
[
"<title>Siftlode</title>",
_tag("og:title", "Password-protected " + ("folder" if folder else "video")),
_tag(
"og:title",
"Password-protected " + ("folder" if folder else "video" if video else "file"),
),
_tag("og:description", "Shared via Siftlode"),
_tag("og:type", "website" if folder else "video.other"),
_tag("og:type", "video.other" if video else "website"),
_tag("og:url", url),
_tag("og:site_name", "Siftlode"),
]
@@ -96,7 +101,7 @@ def _watch_tags(token: str, db: Session) -> str | None:
asset = db.get(MediaAsset, job.asset_id) if job and job.asset_id else None
if job is None or asset is None or asset.status != "ready":
return None
title = (job.display_name or asset.title or "Video").strip()
title = (job.display_name or asset.title or ("Video" if video else "File")).strip()
channel = (job.display_uploader or asset.uploader or "").strip()
# Pick the link-preview image. Facebook's scraper REJECTS our own poster ("Corrupted Image" in the
# Sharing Debugger): the poster is an ffmpeg-produced JPEG whose non-standard marker order FB's
@@ -118,7 +123,7 @@ def _watch_tags(token: str, db: Session) -> str | None:
f"<title>{html.escape(title)}</title>",
_tag("og:title", title),
_tag("og:description", channel or "Shared via Siftlode"),
_tag("og:type", "video.other"),
_tag("og:type", "video.other" if video else "website"),
_tag("og:url", url),
_tag("og:site_name", "Siftlode"),
_tag("twitter:title", title, attr="name"),
+10 -4
View File
@@ -368,6 +368,10 @@ INDEX_HTML = STATIC_DIR / "index.html"
# A public share link's own page: the player alone, without the app's bundle (frontend/watch.html).
WATCH_HTML = STATIC_DIR / "watch.html"
INDEX_HEADERS = {"Cache-Control": "no-cache"}
# The paths a share link opens on, by what it opens (`links.share_path`). Any of them opens any link.
# A new one also goes in frontend/public/robots.txt, the Vite dev entry and WatchPage's token parsing
# (watchEntry.contract.test.ts).
SHARE_PREFIXES = ("watch/", "file/", "folder/")
app.mount(
"/assets",
StaticFiles(directory=STATIC_DIR / "assets", check_dir=False),
@@ -381,8 +385,10 @@ async def index() -> FileResponse:
@app.get("/watch/{token}")
async def watch_page(token: str):
"""Serve the public player page for a share link, with per-video Open Graph tags injected so the
@app.get("/file/{token}")
@app.get("/folder/{token}")
async def share_page(token: str):
"""Serve the public page of a share link, with per-link Open Graph tags injected so the
link unfurls richly (title/channel/thumbnail) in Messenger/social crawlers. A real browser
ignores the extra tags and boots WatchPage; invalid/expired/password links fall back to the
generic card."""
@@ -409,8 +415,8 @@ async def spa_fallback(full_path: str) -> FileResponse:
# Real SPA-root assets (welcome images, favicon, robots.txt) rarely change and have
# stable names, so a moderate cache is safe and satisfies the cache-lifetime audit.
return FileResponse(candidate, headers={"Cache-Control": "public, max-age=604800"})
# A /watch/ path the route above does not match (a trailing segment, no token) is still the
# A share path the route above does not match (a trailing segment, no token) is still the
# player's: WatchPage reads the token off the path and says when a link is gone.
if full_path.startswith("watch/"):
if full_path.startswith(SHARE_PREFIXES):
return FileResponse(WATCH_HTML, headers=INDEX_HEADERS)
return FileResponse(INDEX_HTML, headers=INDEX_HEADERS)
+5 -5
View File
@@ -611,7 +611,7 @@ def list_links(
job_id: int, user: User = Depends(current_user), db: Session = Depends(get_db)
) -> list[dict]:
own_job(db, user, job_id)
return [linksmod.owner_view(link) for link in queries.links_to(db, job_id=job_id)]
return [linksmod.owner_view(db, link) for link in queries.links_to(db, job_id=job_id)]
class CreateLinkIn(BaseModel):
@@ -653,7 +653,7 @@ def create_link(
raise refused(409, "share_unfinished")
link, token = _new_link(db, user, body, job_id=job_id)
db.commit()
return linksmod.owner_view(link, token=token)
return linksmod.owner_view(db, link, token=token)
def _shareable_job(db: Session, user: User, job_id: int) -> "DownloadJob":
@@ -684,7 +684,7 @@ def list_folder_links(
folder_id: int, user: User = Depends(current_user), db: Session = Depends(get_db)
) -> list[dict]:
own_folder(db, user, folder_id)
return [linksmod.owner_view(link) for link in queries.links_to(db, folder_id=folder_id)]
return [linksmod.owner_view(db, link) for link in queries.links_to(db, folder_id=folder_id)]
@router.post("/folders/{folder_id}/links")
@@ -697,7 +697,7 @@ def create_folder_link(
_shareable_folder(db, user, folder_id)
link, token = _new_link(db, user, body, folder_id=folder_id)
db.commit()
return linksmod.owner_view(link, token=token)
return linksmod.owner_view(db, link, token=token)
class UpdateLinkIn(BaseModel):
@@ -724,7 +724,7 @@ def update_link(
sent=frozenset(body.model_fields_set),
)
db.commit()
return linksmod.owner_view(link)
return linksmod.owner_view(db, link)
@router.delete("/links/{link_id}")
+137
View File
@@ -0,0 +1,137 @@
"""A share link's address says what it opens: /watch/ a video, /file/ anything else, /folder/ a folder.
Only a word for the reader: the token is the key, so every path opens every link, and the links
already sent out on /watch/ keep working for good. DB-lane.
"""
import pytest
from cryptography.fernet import Fernet
from app import main, security
from app.config import settings
from app.downloads import og
from app.downloads import probe as probemod
_PAGE = """<!doctype html><html><head>
<!--OG:START--><meta property="og:title" content="Siftlode" /><!--OG:END-->
</head><body><script src="/assets/{entry}.js"></script></body></html>"""
_VIDEO = {
"streams": [
{"codec_type": "video", "codec_name": "h264", "pix_fmt": "yuv420p"},
{"codec_type": "audio", "codec_name": "aac"},
],
"format": {"format_name": "mov,mp4", "duration": "5"},
}
_AUDIO = {
"streams": [{"codec_type": "audio", "codec_name": "aac"}],
"format": {"format_name": "mov,mp4", "duration": "5"},
}
@pytest.fixture
def pages(tmp_path, monkeypatch):
index, watch = tmp_path / "index.html", tmp_path / "watch.html"
index.write_text(_PAGE.format(entry="app"), encoding="utf-8")
watch.write_text(_PAGE.format(entry="watch"), encoding="utf-8")
monkeypatch.setattr(main, "INDEX_HTML", index)
monkeypatch.setattr(main, "WATCH_HTML", watch)
@pytest.fixture
def key(monkeypatch):
"""A link's address is read back from its encrypted token."""
monkeypatch.setattr(security, "_fernet", Fernet(Fernet.generate_key()))
@pytest.fixture
def shared(as_user, make_user, tmp_path, monkeypatch, upload_headers):
"""Uploads a file ffprobe reads as `data` (None: it cannot read it) and shares it."""
monkeypatch.setattr(settings, "files_root", str(tmp_path / "media"))
owner = as_user(make_user())
def share(name: str, data: dict | None, **link: object) -> dict:
monkeypatch.setattr(probemod, "probe_json", lambda _p: data)
job = owner.post(
"/api/files/upload",
content=b"some bytes",
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()
share.owner = owner # type: ignore[attr-defined]
return share
@pytest.mark.parametrize(
("name", "data", "prefix"),
[
("clip.mp4", _VIDEO, "/watch/"),
("song.m4a", _AUDIO, "/file/"),
("backup.tar.gz", None, "/file/"),
],
)
def test_a_link_is_given_out_on_the_path_of_what_it_opens(shared, name, data, prefix):
made = shared(name, data)
assert made["url"] == f"{prefix}{made['token']}"
def test_a_folder_link_is_given_out_on_folder(shared, key):
owner = shared.owner
folder = owner.post("/api/files/folders", json={"name": "Friends"}).json()
made = owner.post(f"/api/downloads/folders/{folder['id']}/links", json={}).json()
assert made["url"] == f"/folder/{made['token']}"
listed = owner.get(f"/api/downloads/folders/{folder['id']}/links").json()
assert [entry["url"] for entry in listed] == [made["url"]]
def test_the_list_gives_the_same_address_as_the_making(shared, key):
made = shared("backup.tar.gz", None)
owner = shared.owner
files = owner.get("/api/files").json()
job_id = next(f["id"] for f in files if f["display_name"] == "backup.tar.gz")
listed = owner.get(f"/api/downloads/{job_id}/links").json()
assert [entry["url"] for entry in listed] == [made["url"]]
@pytest.mark.parametrize("path", ["watch", "file", "folder"])
@pytest.mark.parametrize(
("name", "data"), [("clip.mp4", _VIDEO), ("backup.tar.gz", None)], ids=["video", "file"]
)
def test_every_path_opens_every_link(pages, shared, client, path, name, data):
"""The links already sent on /watch/ open whatever they open, for good."""
token = shared(name, data)["token"]
r = client.get(f"/{path}/{token}")
assert r.status_code == 200
assert "/assets/watch.js" in r.text and "/assets/app.js" not in r.text
assert f'content="{name}"' in r.text
@pytest.mark.parametrize("path", ["/file/no-such-token", "/folder/no-such-token", "/file/a/b"])
def test_a_new_path_without_a_live_link_still_gets_the_player(pages, client, path):
r = client.get(path)
assert r.status_code == 200
assert "/assets/watch.js" in r.text and "/assets/app.js" not in r.text
def test_a_card_names_a_file_as_a_file(shared, db):
made = shared("backup.tar.gz", None)
tags = og._watch_tags(made["token"], db)
assert f'{made["url"]}"' in tags
assert "video.other" not in tags
locked = shared("notes.tar.gz", None, password="pw")
hidden = og._watch_tags(locked["token"], db)
assert "Password-protected file" in hidden and "notes" not in hidden
assert "video.other" not in hidden
def test_a_card_of_a_video_stays_a_video(shared, db):
made = shared("clip.mp4", _VIDEO)
tags = og._watch_tags(made["token"], db)
assert f"/watch/{made['token']}" in tags and "video.other" in tags
locked = shared("other.mp4", _VIDEO, password="pw")
assert "Password-protected video" in og._watch_tags(locked["token"], db)
+4 -2
View File
@@ -1,14 +1,16 @@
# Siftlode is mostly a private, login-gated app; only the public landing and legal
# pages are worth indexing. Allow crawling but keep bots out of the API surface.
# Share links (/watch/) are unguessable capability URLs: kept out of search indexes,
# Share links (/watch/, /file/, /folder/) are unguessable capability URLs: kept out of search indexes,
# but link-preview crawlers are allowed below so shared links unfurl in chat apps.
User-agent: *
Allow: /
Disallow: /api/
Disallow: /auth/
Disallow: /watch/
Disallow: /file/
Disallow: /folder/
# Link-preview / unfurl bots — allowed to fetch /watch/ so shared links render a rich
# Link-preview / unfurl bots — allowed to fetch share links so shared links render a rich
# card (title/channel/thumbnail). They don't build a search index, so this doesn't
# expose share links to search engines.
User-agent: facebookexternalhit
+1 -1
View File
@@ -145,7 +145,7 @@ export interface ShareLink {
// Null when the server cannot show this link's address again (no encrypted copy its key reads): the
// link still opens for whoever has it; to share it anew, make a new link.
token: string | null;
url: string | null; // path like "/watch/<token>" — prepend window.location.origin to share
url: string | null; // path like "/watch/<token>" or "/file/<token>" — prepend window.location.origin to share
allow_download: boolean;
has_password: boolean;
expires_at: string | null;
+14
View File
@@ -24,6 +24,20 @@ afterEach(() => {
localStorage.clear();
});
describe("WatchPage share paths", () => {
it.each(["/watch/tok-p", "/file/tok-p", "/folder/tok-p", "/file/tok-p/extra"])(
"reads the token off %s",
async (path) => {
window.history.replaceState({}, "", path);
mockMeta({ needs_password: false, audio: false, file_url: "/f.mp4", title: "A clip" });
render(<WatchPage />);
await waitFor(() =>
expect(fetch).toHaveBeenCalledWith("/api/public/watch/tok-p", expect.anything()),
);
},
);
});
describe("WatchPage background play", () => {
it("hands its <video> to the background-play hook, so Brave iOS keeps it playing", async () => {
window.history.replaceState({}, "", "/watch/tok-bg");
+8 -2
View File
@@ -28,7 +28,7 @@ import { SURFACE } from "../components/ui/surfaces";
import { IconLink, LinkButton } from "../components/ui/LinkButton";
import { T, lang } from "./watchStrings";
// Standalone, login-free page for a shared /watch/<token> link: one file, or a folder's list of them.
// Standalone, login-free page for a share link (/watch/, /file/, /folder/): one file, or a folder's list.
// Its own entry (watchMain.tsx, watch.html): no auth, no providers, none of the app's bundle. Uses
// plain fetch and a tiny self-contained i18n dict (`watchStrings`: the public viewer has no app
// language preference).
@@ -83,8 +83,14 @@ async function notFoundState(r: Response): Promise<"preparing" | "gone"> {
return body?.detail?.reason === "video_preparing" ? "preparing" : "gone";
}
/** The token of a share path: /watch/ for a video, /file/ or /folder/ for the rest, any of them for
* any link (`main.SHARE_PREFIXES`). */
function shareToken(path: string): string {
return /^\/(?:watch|file|folder)\/([^/?#]+)/.exec(path)?.[1] ?? "";
}
export default function WatchPage() {
const token = window.location.pathname.split("/watch/")[1]?.split(/[/?#]/)[0] ?? "";
const token = shareToken(window.location.pathname);
// Seeded from the URL rather than corrected by the effect below: with no token there is nothing
// to load, and "loading" was committed and painted before the effect said so.
const [status, setStatus] = useState<
+23
View File
@@ -99,6 +99,29 @@ function chain(seen: Map<string, string | null>, mod: string): string {
return path.join(" → ");
}
describe("the paths a share link opens on", () => {
// The server's list: each of the places below that has to know them follows it.
const main = readFileSync(join(SRC, "..", "..", "backend", "app", "main.py"), "utf8");
const prefixes = [
...(/SHARE_PREFIXES = \(([^)]*)\)/.exec(main)?.[1] ?? "").matchAll(/"(\w+)\/"/g),
].map((m) => m[1]);
it("are read off the server", () => {
expect(prefixes).toEqual(["watch", "file", "folder"]);
});
it("are all kept out of search indexes", () => {
const robots = readFileSync(join(SRC, "..", "public", "robots.txt"), "utf8");
for (const p of prefixes) expect(robots).toContain(`Disallow: /${p}/`);
});
it("all boot the player alone in `vite dev` and all give up their token", () => {
const vite = readFileSync(join(SRC, "..", "vite.config.ts"), "utf8");
const page = readFileSync(join(SRC, "watch", "WatchPage.tsx"), "utf8");
for (const src of [vite, page]) expect(src).toContain(`(?:${prefixes.join("|")})`);
});
});
describe("the /watch entry", () => {
it("loads nothing the app alone may load", () => {
const seen = reach(join(SRC, "watchMain.tsx"));
+3 -3
View File
@@ -32,18 +32,18 @@ const proxy = {
"/healthz": proxyOpts,
};
// Two HTML entries: the app (index.html) and a public /watch/<token> link (watch.html), which loads
// Two HTML entries: the app (index.html) and a public share link (watch.html), which loads
// only the player page. In a build the backend picks the file by path (`main.py`); this does the
// same for `vite dev`, whose history fallback would otherwise hand /watch/… the app.
const watchEntry = (): Plugin => ({
name: "siftlode-watch-entry",
configureServer(server) {
server.middlewares.use((req, _res, next) => {
if (req.url?.startsWith("/watch/")) req.url = "/watch.html";
if (req.url && /^\/(?:watch|file|folder)\//.test(req.url)) req.url = "/watch.html";
next();
});
},
// The server answers every /watch/ link with the built watch.html, so a build without it would
// The server answers every share link with the built watch.html, so a build without it would
// ship a 500 for each one, and nothing downstream reads `dist` before prod does. Fail it here —
// after Vite's own HTML plugin has emitted the pages (`enforce: "post"`), or none is there yet.
enforce: "post",