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
+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}")