merge: Lighthouse audit fixes — landing perf, SEO, console (v0.22.2)

This commit is contained in:
npeter83 2026-07-04 18:19:08 +02:00
commit 2ff517fb74
16 changed files with 92 additions and 14 deletions

View file

@ -1 +1 @@
0.22.1 0.22.2

View file

@ -719,6 +719,18 @@ def current_user(request: Request, db: Session = Depends(get_db)) -> User:
return user return user
def optional_current_user(
request: Request, db: Session = Depends(get_db)
) -> User | None:
"""Like `current_user` but returns None instead of raising 401 when there's no valid session.
Used by the bootstrap /api/me probe so the logged-out landing doesn't log a failed request to
the browser console (a non-2xx fetch is a console error no matter how the app handles it)."""
try:
return current_user(request, db)
except HTTPException:
return None
def require_human(user: User = Depends(current_user)) -> User: def require_human(user: User = Depends(current_user)) -> User:
"""Reject the shared demo account from actions that need a real Google/YouTube identity """Reject the shared demo account from actions that need a real Google/YouTube identity
or spend the shared quota. Most YouTube paths are already closed to it implicitly (it has or spend the shared quota. Most YouTube paths are already closed to it implicitly (it has

View file

@ -112,6 +112,20 @@ async def setup_gate(request, call_next):
return await call_next(request) 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(health.router)
app.include_router(auth.router) app.include_router(auth.router)
app.include_router(sync.router) app.include_router(sync.router)
@ -134,6 +148,13 @@ app.include_router(quota.router)
app.include_router(version.router) app.include_router(version.router)
app.include_router(setup_routes.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). # The built SPA (populated by the Docker frontend build stage).
STATIC_DIR = Path(__file__).parent / "static_spa" STATIC_DIR = Path(__file__).parent / "static_spa"
# index.html is unhashed and references the content-hashed /assets bundles, so it MUST NOT be # index.html is unhashed and references the content-hashed /assets bundles, so it MUST NOT be
@ -166,5 +187,7 @@ async def spa_fallback(full_path: str) -> FileResponse:
if full_path: if full_path:
candidate = (STATIC_DIR / full_path).resolve() candidate = (STATIC_DIR / full_path).resolve()
if candidate.is_file() and STATIC_DIR.resolve() in candidate.parents: if candidate.is_file() and STATIC_DIR.resolve() in candidate.parents:
return FileResponse(candidate) # 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) return FileResponse(INDEX_HTML, headers=INDEX_HEADERS)

View file

@ -9,6 +9,7 @@ from app.auth import (
has_read_scope, has_read_scope,
has_write_scope, has_write_scope,
is_allowed, is_allowed,
optional_current_user,
purge_user, purge_user,
) )
from app.db import get_db from app.db import get_db
@ -67,8 +68,13 @@ def switch_account(
@router.get("") @router.get("")
def get_me( def get_me(
user: User = Depends(current_user), db: Session = Depends(get_db) user: User | None = Depends(optional_current_user), db: Session = Depends(get_db)
) -> dict: ) -> dict:
# The app's bootstrap probe: return 200 with `authenticated: False` when logged out (rather
# than 401) so the public landing never logs a failed /api/me to the browser console. Other
# protected endpoints still 401, so mid-session expiry is still caught by the global handler.
if user is None:
return {"authenticated": False}
pending_invites = 0 pending_invites = 0
if user.role == "admin": if user.role == "admin":
pending_invites = ( pending_invites = (
@ -80,6 +86,7 @@ def get_me(
or 0 or 0
) )
return { return {
"authenticated": True,
"id": user.id, "id": user.id,
"email": user.email, "email": user.email,
"display_name": user.display_name, "display_name": user.display_name,

View file

@ -5,6 +5,16 @@
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>Siftlode</title> <title>Siftlode</title>
<meta
name="description"
content="Siftlode is a self-hosted, multi-user reader for your YouTube subscriptions — filter, sort, tag and search your feed, build playlists that sync both ways, and watch in a clean, ad-free interface."
/>
<meta property="og:title" content="Siftlode" />
<meta
property="og:description"
content="A self-hosted, multi-user reader for your YouTube subscriptions — filter, sort, tag and search, build two-way-syncing playlists, and watch ad-free."
/>
<meta property="og:type" content="website" />
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View file

@ -0,0 +1,7 @@
# 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.
User-agent: *
Allow: /
Disallow: /api/
Disallow: /auth/
Disallow: /watch/

Binary file not shown.

Before

Width:  |  Height:  |  Size: 251 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 440 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

View file

@ -4,7 +4,6 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
import { import {
api, api,
getActiveAccount, getActiveAccount,
HttpError,
setActiveAccount, setActiveAccount,
setUnauthorizedHandler, setUnauthorizedHandler,
type FeedFilters, type FeedFilters,
@ -451,8 +450,9 @@ export default function App() {
}, [meQuery.data?.id]); // eslint-disable-line react-hooks/exhaustive-deps }, [meQuery.data?.id]); // eslint-disable-line react-hooks/exhaustive-deps
// Any request that 401s means the session ended server-side. If we were signed in (cached // Any request that 401s means the session ended server-side. If we were signed in (cached
// `me` exists), reload to the login page at once (option 2 — also covers any click that hits // `me` is a user object), reload to the login page at once (also covers any click that hits
// the API). Guarded on cached `me` so the public login page's own /api/me 401 can't loop. // the API). Guarded on a cached user so a stray 401 while logged out (cached `me` is null)
// can't loop the public landing.
useEffect(() => { useEffect(() => {
setUnauthorizedHandler(() => { setUnauthorizedHandler(() => {
if (queryClient.getQueryData(["me"])) { if (queryClient.getQueryData(["me"])) {
@ -646,14 +646,15 @@ export default function App() {
return ( return (
<div className="min-h-screen grid place-items-center text-muted">{t("common.loading")}</div> <div className="min-h-screen grid place-items-center text-muted">{t("common.loading")}</div>
); );
if (meQuery.error instanceof HttpError && meQuery.error.status === 401) // /api/me answers 200 always now (null body = logged out), so a thrown error here is a real
return <Welcome />; // network/5xx failure, and no data means "not signed in" → the public landing.
if (meQuery.error) if (meQuery.error)
return ( return (
<div className="min-h-screen grid place-items-center text-muted"> <div className="min-h-screen grid place-items-center text-muted">
{t("common.somethingWrong")} {t("common.somethingWrong")}
</div> </div>
); );
if (!meQuery.data) return <Welcome />;
return ( return (
<div className="h-screen flex bg-bg text-fg"> <div className="h-screen flex bg-bg text-fg">

View file

@ -59,7 +59,7 @@ export default function Welcome() {
{/* App preview */} {/* App preview */}
<Preview <Preview
src="/welcome/feed.png" src="/welcome/feed.webp"
label={t("welcome.preview.feed")} label={t("welcome.preview.feed")}
className="aspect-video" className="aspect-video"
onOpen={open} onOpen={open}
@ -101,13 +101,13 @@ export default function Welcome() {
{/* Secondary showcase — smaller thumbnails; click to view full size. */} {/* Secondary showcase — smaller thumbnails; click to view full size. */}
<section className="mt-12 flex flex-wrap justify-center gap-4"> <section className="mt-12 flex flex-wrap justify-center gap-4">
<Preview <Preview
src="/welcome/channels.png" src="/welcome/channels.webp"
label={t("welcome.preview.channels")} label={t("welcome.preview.channels")}
className="w-full sm:w-72 aspect-[4/3]" className="w-full sm:w-72 aspect-[4/3]"
onOpen={open} onOpen={open}
/> />
<Preview <Preview
src="/welcome/playlists.png" src="/welcome/playlists.webp"
label={t("welcome.preview.playlists")} label={t("welcome.preview.playlists")}
className="w-full sm:w-72 aspect-[4/3]" className="w-full sm:w-72 aspect-[4/3]"
onOpen={open} onOpen={open}

View file

@ -253,8 +253,9 @@ interface ReqConfig {
// Set by the app shell. Invoked whenever any request returns 401 so a session that ended // Set by the app shell. Invoked whenever any request returns 401 so a session that ended
// server-side (account suspended/deleted while the tab was open) can drop the user to the login // server-side (account suspended/deleted while the tab was open) can drop the user to the login
// page. The handler itself guards against firing when we were never signed in (public pages // page. The handler itself guards against firing when we were never signed in, so it's safe to
// legitimately 401 on /api/me), so it's safe to call for every 401. // call for every 401. (The bootstrap /api/me probe no longer 401s — it returns 200 with a null
// user when logged out — but other protected endpoints still do.)
let onUnauthorized: (() => void) | null = null; let onUnauthorized: (() => void) | null = null;
export function setUnauthorizedHandler(fn: (() => void) | null): void { export function setUnauthorizedHandler(fn: (() => void) | null): void {
onUnauthorized = fn; onUnauthorized = fn;
@ -755,7 +756,13 @@ export interface AdminDownloadQuota {
} }
export const api = { export const api = {
me: (): Promise<Me> => req("/api/me"), // Bootstrap probe: 200 always. Returns the user when signed in, or null when logged out
// (the endpoint answers `{authenticated:false}` rather than 401, so the public landing never
// logs a failed request to the console). React Query treats the null as data, not an error.
me: async (): Promise<Me | null> => {
const r = await req("/api/me");
return r && r.authenticated ? (r as Me) : null;
},
accounts: (): Promise<Account[]> => req("/api/me/accounts"), accounts: (): Promise<Account[]> => req("/api/me/accounts"),
deleteAccount: (): Promise<{ deleted: boolean }> => deleteAccount: (): Promise<{ deleted: boolean }> =>
req("/api/me/account", { method: "DELETE" }), req("/api/me/account", { method: "DELETE" }),

View file

@ -14,6 +14,17 @@ export interface ReleaseEntry {
} }
export const RELEASE_NOTES: ReleaseEntry[] = [ export const RELEASE_NOTES: ReleaseEntry[] = [
{
version: "0.22.2",
date: "2026-07-04",
summary: "A faster, tidier sign-in page.",
fixes: [
"The landing page loads much faster — its preview screenshots are now served in a modern, far smaller image format, and static assets are cached so repeat visits are near-instant.",
],
chores: [
"Search-engine metadata (page description, robots.txt) and a cleaner console on the public page.",
],
},
{ {
version: "0.22.1", version: "0.22.1",
date: "2026-07-04", date: "2026-07-04",