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",