A shared /watch/{token} link is a client-rendered SPA, so a social crawler only
saw the generic index.html — a blank link card. The server now injects per-video
Open Graph / Twitter tags (title, channel, thumbnail) into the served HTML for
that route, so links unfurl richly in Messenger and other chat apps; real
browsers ignore the extra tags and hydrate the page as usual. Password /
expired / invalid links fall back to the generic card with no metadata leak.
Also shortens the generic site description used for search engines and link
previews.
210 lines
8.2 KiB
Python
210 lines
8.2 KiB
Python
import logging
|
|
import sys
|
|
from contextlib import asynccontextmanager
|
|
from pathlib import Path
|
|
|
|
from fastapi import FastAPI, HTTPException
|
|
|
|
# When started via uvicorn --log-config the "siftlode" logger is already configured
|
|
# (see log_config.json). This block is a timestamped fallback for other entrypoints
|
|
# (tests, scripts) so our logs are never silently dropped.
|
|
_siftlode_logger = logging.getLogger("siftlode")
|
|
if not _siftlode_logger.handlers:
|
|
_handler = logging.StreamHandler(sys.stdout)
|
|
_handler.setFormatter(
|
|
logging.Formatter("%(asctime)s %(levelname)-5s [%(name)s] %(message)s")
|
|
)
|
|
_siftlode_logger.addHandler(_handler)
|
|
_siftlode_logger.setLevel(logging.INFO)
|
|
_siftlode_logger.propagate = False
|
|
|
|
log = logging.getLogger("siftlode.app")
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
|
|
from fastapi.staticfiles import StaticFiles
|
|
from starlette.middleware.sessions import SessionMiddleware
|
|
|
|
from app import auth, state
|
|
from app.config import settings
|
|
from app.db import SessionLocal
|
|
from app.routes import (
|
|
admin,
|
|
channels,
|
|
config as config_routes,
|
|
downloads,
|
|
feed,
|
|
health,
|
|
me,
|
|
messages,
|
|
notifications,
|
|
playlists,
|
|
plex as plex_routes,
|
|
public as public_routes,
|
|
quota,
|
|
saved_views,
|
|
scheduler as scheduler_routes,
|
|
search as search_routes,
|
|
setup as setup_routes,
|
|
sync,
|
|
tags,
|
|
version,
|
|
)
|
|
from app.scheduler import shutdown_scheduler, start_scheduler
|
|
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(app: FastAPI):
|
|
log.info("Siftlode starting up")
|
|
# First-run: if the instance isn't configured yet, mint a fresh one-time setup token and print
|
|
# the wizard URL so the operator can open it (the app runs in setup mode until they finish).
|
|
with SessionLocal() as db:
|
|
if not state.is_configured(db):
|
|
token = state.rotate_setup_token(db)
|
|
url = f"{settings.app_base}/setup?token={token}"
|
|
log.warning(
|
|
"FIRST-RUN SETUP REQUIRED — this instance isn't configured yet.\n"
|
|
" Open the install wizard (internal access only):\n %s",
|
|
url,
|
|
)
|
|
start_scheduler()
|
|
try:
|
|
yield
|
|
finally:
|
|
log.info("Siftlode shutting down")
|
|
shutdown_scheduler()
|
|
|
|
|
|
app = FastAPI(title=settings.app_name, lifespan=lifespan)
|
|
|
|
app.add_middleware(
|
|
SessionMiddleware,
|
|
secret_key=settings.secret_key,
|
|
same_site="lax", # required so the cookie rides the OAuth redirect back from Google
|
|
https_only=settings.session_https_only, # Secure flag when served over HTTPS (prod)
|
|
)
|
|
|
|
if settings.frontend_origin:
|
|
app.add_middleware(
|
|
CORSMiddleware,
|
|
allow_origins=[settings.frontend_origin],
|
|
allow_credentials=True,
|
|
allow_methods=["*"],
|
|
allow_headers=["*"],
|
|
)
|
|
|
|
# Paths reachable while the instance is unconfigured (setup mode). Everything else under /api or
|
|
# /auth is locked until the wizard finishes; static/SPA always loads so the wizard page can render.
|
|
_SETUP_OPEN_PREFIXES = ("/api/setup", "/api/version")
|
|
|
|
|
|
@app.middleware("http")
|
|
async def setup_gate(request, call_next):
|
|
"""Lock the app to the install wizard until it's configured. Skips the DB entirely once setup
|
|
completed in this process (the cached fast-path), so there's no steady-state overhead."""
|
|
if not state.setup_complete():
|
|
path = request.url.path
|
|
if path.startswith(("/api/", "/auth/")) and not path.startswith(_SETUP_OPEN_PREFIXES):
|
|
with SessionLocal() as db:
|
|
configured = state.is_configured_cached(db)
|
|
if not configured:
|
|
return JSONResponse(
|
|
{"detail": "This instance isn't set up yet."}, status_code=503
|
|
)
|
|
return await call_next(request)
|
|
|
|
|
|
@app.middleware("http")
|
|
async def static_cache_headers(request, call_next):
|
|
"""Long-cache the content-hashed SPA bundles. Vite hashes their filename, so a given
|
|
/assets URL never changes content and a browser can hold it forever — this is what makes
|
|
repeat loads instant and clears the Lighthouse "efficient cache lifetimes" audit. index.html
|
|
stays no-cache (set on its own FileResponse) so a deploy is picked up at once; other
|
|
SPA-root static files (welcome images, favicon, robots.txt) get a moderate TTL in the SPA
|
|
fallback below."""
|
|
response = await call_next(request)
|
|
if request.url.path.startswith("/assets/"):
|
|
response.headers["Cache-Control"] = "public, max-age=31536000, immutable"
|
|
return response
|
|
|
|
|
|
app.include_router(health.router)
|
|
app.include_router(auth.router)
|
|
app.include_router(sync.router)
|
|
app.include_router(tags.router)
|
|
app.include_router(feed.router)
|
|
app.include_router(search_routes.router)
|
|
app.include_router(me.router)
|
|
app.include_router(notifications.router)
|
|
app.include_router(messages.router)
|
|
app.include_router(channels.router)
|
|
app.include_router(playlists.router)
|
|
app.include_router(saved_views.router)
|
|
app.include_router(plex_routes.router)
|
|
app.include_router(downloads.router)
|
|
app.include_router(downloads.admin_router)
|
|
app.include_router(public_routes.router)
|
|
app.include_router(admin.router)
|
|
app.include_router(config_routes.router)
|
|
app.include_router(scheduler_routes.router)
|
|
app.include_router(quota.router)
|
|
app.include_router(version.router)
|
|
app.include_router(setup_routes.router)
|
|
|
|
# Ensure modern image types resolve to the right Content-Type when FileResponse guesses from the
|
|
# filename (the runtime's mimetypes db doesn't always know .webp → it'd fall back to octet-stream).
|
|
import mimetypes
|
|
|
|
mimetypes.add_type("image/webp", ".webp")
|
|
mimetypes.add_type("image/avif", ".avif")
|
|
|
|
# The built SPA (populated by the Docker frontend build stage).
|
|
STATIC_DIR = Path(__file__).parent / "static_spa"
|
|
# index.html is unhashed and references the content-hashed /assets bundles, so it MUST NOT be
|
|
# heuristically cached — otherwise, after a deploy, a browser keeps serving the old index.html
|
|
# (pointing at the previous bundle) and runs stale code until a hard refresh. `no-cache` lets it
|
|
# stay cached but forces revalidation (cheap 304 via the FileResponse ETag) on every load. The
|
|
# hashed bundles under /assets can cache forever (a new build changes their filename).
|
|
INDEX_HTML = STATIC_DIR / "index.html"
|
|
INDEX_HEADERS = {"Cache-Control": "no-cache"}
|
|
app.mount(
|
|
"/assets",
|
|
StaticFiles(directory=STATIC_DIR / "assets", check_dir=False),
|
|
name="assets",
|
|
)
|
|
|
|
|
|
@app.get("/")
|
|
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.
|
|
if full_path.startswith(("api/", "auth/", "healthz", "assets/")):
|
|
raise HTTPException(status_code=404)
|
|
# Serve real files that live at the SPA root (Vite copies public/ there — e.g. the landing
|
|
# screenshots under /welcome/, favicon). /assets is already mounted above; everything else
|
|
# that isn't a real file is a client-side route → index.html. Guard against path traversal.
|
|
if full_path:
|
|
candidate = (STATIC_DIR / full_path).resolve()
|
|
if candidate.is_file() and STATIC_DIR.resolve() in candidate.parents:
|
|
# 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"})
|
|
return FileResponse(INDEX_HTML, headers=INDEX_HEADERS)
|