merge: Lighthouse audit fixes — landing perf, SEO, console (v0.22.2)
This commit is contained in:
commit
2ff517fb74
16 changed files with 92 additions and 14 deletions
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
||||||
0.22.1
|
0.22.2
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
7
frontend/public/robots.txt
Normal file
7
frontend/public/robots.txt
Normal 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 |
BIN
frontend/public/welcome/channels.webp
Normal file
BIN
frontend/public/welcome/channels.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 65 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.6 MiB |
BIN
frontend/public/welcome/feed.webp
Normal file
BIN
frontend/public/welcome/feed.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 144 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 440 KiB |
BIN
frontend/public/welcome/playlists.webp
Normal file
BIN
frontend/public/welcome/playlists.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 76 KiB |
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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" }),
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue