diff --git a/VERSION b/VERSION index a04c2de..934e07c 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.22.1 \ No newline at end of file +0.22.2 \ No newline at end of file diff --git a/backend/app/auth.py b/backend/app/auth.py index f7f6041..29eafa4 100644 --- a/backend/app/auth.py +++ b/backend/app/auth.py @@ -719,6 +719,18 @@ def current_user(request: Request, db: Session = Depends(get_db)) -> 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: """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 diff --git a/backend/app/main.py b/backend/app/main.py index d3fb78f..721712c 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -112,6 +112,20 @@ async def setup_gate(request, call_next): 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) @@ -134,6 +148,13 @@ 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 @@ -166,5 +187,7 @@ async def spa_fallback(full_path: str) -> FileResponse: if full_path: candidate = (STATIC_DIR / full_path).resolve() 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) diff --git a/backend/app/routes/me.py b/backend/app/routes/me.py index dca46a9..a810f89 100644 --- a/backend/app/routes/me.py +++ b/backend/app/routes/me.py @@ -9,6 +9,7 @@ from app.auth import ( has_read_scope, has_write_scope, is_allowed, + optional_current_user, purge_user, ) from app.db import get_db @@ -67,8 +68,13 @@ def switch_account( @router.get("") 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: + # 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 if user.role == "admin": pending_invites = ( @@ -80,6 +86,7 @@ def get_me( or 0 ) return { + "authenticated": True, "id": user.id, "email": user.email, "display_name": user.display_name, diff --git a/frontend/index.html b/frontend/index.html index fef8afa..c7ed055 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -5,6 +5,16 @@ Siftlode + + + +
diff --git a/frontend/public/robots.txt b/frontend/public/robots.txt new file mode 100644 index 0000000..43d4e0c --- /dev/null +++ b/frontend/public/robots.txt @@ -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/ diff --git a/frontend/public/welcome/channels.png b/frontend/public/welcome/channels.png deleted file mode 100644 index 20391e4..0000000 Binary files a/frontend/public/welcome/channels.png and /dev/null differ diff --git a/frontend/public/welcome/channels.webp b/frontend/public/welcome/channels.webp new file mode 100644 index 0000000..404a67d Binary files /dev/null and b/frontend/public/welcome/channels.webp differ diff --git a/frontend/public/welcome/feed.png b/frontend/public/welcome/feed.png deleted file mode 100644 index 26de65d..0000000 Binary files a/frontend/public/welcome/feed.png and /dev/null differ diff --git a/frontend/public/welcome/feed.webp b/frontend/public/welcome/feed.webp new file mode 100644 index 0000000..33db8a0 Binary files /dev/null and b/frontend/public/welcome/feed.webp differ diff --git a/frontend/public/welcome/playlists.png b/frontend/public/welcome/playlists.png deleted file mode 100644 index 6792c4d..0000000 Binary files a/frontend/public/welcome/playlists.png and /dev/null differ diff --git a/frontend/public/welcome/playlists.webp b/frontend/public/welcome/playlists.webp new file mode 100644 index 0000000..9512288 Binary files /dev/null and b/frontend/public/welcome/playlists.webp differ diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2a86416..c6a473e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4,7 +4,6 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; import { api, getActiveAccount, - HttpError, setActiveAccount, setUnauthorizedHandler, type FeedFilters, @@ -451,8 +450,9 @@ export default function App() { }, [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 - // `me` exists), reload to the login page at once (option 2 — 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. + // `me` is a user object), reload to the login page at once (also covers any click that hits + // 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(() => { setUnauthorizedHandler(() => { if (queryClient.getQueryData(["me"])) { @@ -646,14 +646,15 @@ export default function App() { return (
{t("common.loading")}
); - if (meQuery.error instanceof HttpError && meQuery.error.status === 401) - return ; + // /api/me answers 200 always now (null body = logged out), so a thrown error here is a real + // network/5xx failure, and no data means "not signed in" → the public landing. if (meQuery.error) return (
{t("common.somethingWrong")}
); + if (!meQuery.data) return ; return (
diff --git a/frontend/src/components/Welcome.tsx b/frontend/src/components/Welcome.tsx index f21de8e..56b3ce1 100644 --- a/frontend/src/components/Welcome.tsx +++ b/frontend/src/components/Welcome.tsx @@ -59,7 +59,7 @@ export default function Welcome() { {/* App preview */} void) | null = null; export function setUnauthorizedHandler(fn: (() => void) | null): void { onUnauthorized = fn; @@ -755,7 +756,13 @@ export interface AdminDownloadQuota { } export const api = { - me: (): Promise => 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 => { + const r = await req("/api/me"); + return r && r.authenticated ? (r as Me) : null; + }, accounts: (): Promise => req("/api/me/accounts"), deleteAccount: (): Promise<{ deleted: boolean }> => req("/api/me/account", { method: "DELETE" }), diff --git a/frontend/src/lib/releaseNotes.ts b/frontend/src/lib/releaseNotes.ts index 6a8625a..58d2f82 100644 --- a/frontend/src/lib/releaseNotes.ts +++ b/frontend/src/lib/releaseNotes.ts @@ -14,6 +14,17 @@ export interface 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", date: "2026-07-04",