diff --git a/backend/app/downloads/og.py b/backend/app/downloads/og.py new file mode 100644 index 0000000..60a9590 --- /dev/null +++ b/backend/app/downloads/og.py @@ -0,0 +1,85 @@ +"""Server-rendered Open Graph tags for the public /watch/{token} page. + +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. +This injects per-video OG/Twitter tags into the served HTML so a shared link unfurls with the +video's title, channel and thumbnail. Real browsers ignore the extra tags and hydrate the SPA as +usual. Password-protected / expired / invalid links fall back to the generic card (no metadata leak). +""" +import html +import re +from pathlib import Path + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.config import settings +from app.downloads import links as linksmod +from app.models import DownloadJob, DownloadLink, MediaAsset + +_OG_BLOCK = re.compile(r".*?", re.DOTALL) + + +def _tag(prop: str, content: str, attr: str = "property") -> str: + return f'' + + +def _watch_tags(token: str, db: Session) -> str | None: + """Build the OG/Twitter meta block for a watch token, or None to keep the generic card.""" + link = db.execute( + select(DownloadLink).where(DownloadLink.token == token) + ).scalar_one_or_none() + if link is None or linksmod.is_expired(link): + return None + url = f"{settings.app_base}/watch/{token}" + if link.password_hash: + # Password-gated: don't leak the title/thumbnail in the unfurl. + return "\n ".join( + [ + "Siftlode", + _tag("og:title", "Password-protected video"), + _tag("og:description", "Shared via Siftlode"), + _tag("og:type", "video.other"), + _tag("og:url", url), + _tag("og:site_name", "Siftlode"), + ] + ) + job = db.get(DownloadJob, link.job_id) + 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() + channel = (job.display_uploader or asset.uploader or "").strip() + image = asset.thumbnail_url # already an absolute (youtube/fb) URL, or None + tags = [ + f"{html.escape(title)}", + _tag("og:title", title), + _tag("og:description", channel or "Shared via Siftlode"), + _tag("og:type", "video.other"), + _tag("og:url", url), + _tag("og:site_name", "Siftlode"), + _tag("twitter:title", title, attr="name"), + ] + if channel: + tags.append(_tag("twitter:description", channel, attr="name")) + if image: + # A real thumbnail → a large image card; without one, a plain (text) card is served. + tags.append(_tag("og:image", image)) + tags.append(_tag("twitter:card", "summary_large_image", attr="name")) + tags.append(_tag("twitter:image", image, attr="name")) + else: + tags.append(_tag("twitter:card", "summary", attr="name")) + return "\n ".join(tags) + + +def render_watch_html(token: str, index_html: Path, db: Session) -> str | None: + """Return index.html with the OG block replaced for this watch token, or None to serve the + generic page unchanged (invalid/expired/not-ready link, or a template without the markers).""" + tags = _watch_tags(token, db) + if tags is None: + return None + text = index_html.read_text(encoding="utf-8") + if "" not in text: + return None + # A function replacement avoids re.sub interpreting backslashes/group refs in `tags`. + return _OG_BLOCK.sub(lambda _m: tags, text, count=1) diff --git a/backend/app/main.py b/backend/app/main.py index bea87ee..772888a 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -20,7 +20,7 @@ if not _siftlode_logger.handlers: log = logging.getLogger("siftlode.app") from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import FileResponse, JSONResponse +from fastapi.responses import FileResponse, HTMLResponse, JSONResponse from fastapi.staticfiles import StaticFiles from starlette.middleware.sessions import SessionMiddleware @@ -178,6 +178,21 @@ async def index() -> FileResponse: return FileResponse(INDEX_HTML, headers=INDEX_HEADERS) +@app.get("/watch/{token}") +async def watch_page(token: str): + """Serve the SPA for a public share link, but with per-video Open Graph tags injected so the + link unfurls richly (title/channel/thumbnail) in Messenger/social crawlers. A real browser + ignores the extra tags and hydrates WatchPage as usual; invalid/expired/password links fall + back to the generic card.""" + from app.downloads import og + + with SessionLocal() as db: + rendered = og.render_watch_html(token, INDEX_HTML, db) + if rendered is None: + return FileResponse(INDEX_HTML, headers=INDEX_HEADERS) + return HTMLResponse(rendered, headers=INDEX_HEADERS) + + @app.get("/{full_path:path}") async def spa_fallback(full_path: str) -> FileResponse: # Client-side routes fall back to index.html; real API/asset paths are matched above. diff --git a/frontend/index.html b/frontend/index.html index c7ed055..39d73cb 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -4,17 +4,21 @@ + + Siftlode +