Fix review findings and remove Uncategorized from sidebar

Backend: cache Google access tokens (drop dead access_token_expires_at,
migration 0007), handle MeTube cleared/canceled events by URL, return
email from /auth/status only when authenticated, move Google base URLs
into settings, run container as non-root.

Frontend: include local feed filters in the query key, remove dead
Saved page and unused assets, drop stale CategoryNav props and classes,
send Content-Type only with a body, remove Uncategorized from sidebar.
This commit is contained in:
vrubelroman 2026-09-17 17:22:22 +00:00
parent 6c704cac97
commit fde9a439df
25 changed files with 420 additions and 174 deletions

View file

@ -1,6 +1,7 @@
import logging
import os
from datetime import datetime, timezone
import threading
from datetime import datetime, timedelta, timezone
# Google frequently echoes back a scope string that's a superset/reordering
# of what we requested (e.g. we ask for "youtube", Google's token response
@ -34,10 +35,15 @@ SCOPES = [
"https://www.googleapis.com/auth/userinfo.profile",
]
AUTH_URI = "https://accounts.google.com/o/oauth2/auth"
TOKEN_URI = "https://oauth2.googleapis.com/token"
USERINFO_URI = "https://www.googleapis.com/oauth2/v3/userinfo"
REVOKE_URI = "https://oauth2.googleapis.com/revoke"
# Refresh proactively: never hand out a token that could die mid-request.
_ACCESS_TOKEN_REFRESH_BUFFER_SECONDS = 60
# Module-level access-token cache. Google access tokens live ~1h; previously
# every get_credentials() call (sync, unsubscribe, ...) paid for a full token
# exchange with Google. The lock keeps concurrent callers sharing one refresh
# instead of racing each other to the token endpoint.
_credentials_lock = threading.Lock()
_cached_credentials: Credentials | None = None
class OAuthNotConnected(Exception):
@ -49,8 +55,8 @@ def _client_config() -> dict:
"web": {
"client_id": settings.google_client_id,
"client_secret": settings.google_client_secret,
"auth_uri": AUTH_URI,
"token_uri": TOKEN_URI,
"auth_uri": settings.google_auth_uri,
"token_uri": settings.google_token_uri,
"redirect_uris": [settings.google_redirect_uri],
}
}
@ -83,7 +89,7 @@ def exchange_code(code: str, state: str) -> Credentials:
def fetch_userinfo(access_token: str) -> dict:
response = httpx.get(
USERINFO_URI,
settings.google_userinfo_uri,
headers={"Authorization": f"Bearer {access_token}"},
timeout=settings.metube_request_timeout_seconds,
)
@ -93,16 +99,14 @@ def fetch_userinfo(access_token: str) -> dict:
def revoke_token(token: str) -> None:
try:
httpx.post(REVOKE_URI, params={"token": token}, timeout=10)
httpx.post(settings.google_revoke_uri, params={"token": token}, timeout=10)
except Exception:
logger.warning("Failed to revoke Google token", exc_info=True)
def store_credentials(db: Session, google_email: str, credentials: Credentials) -> None:
global _cached_credentials
encrypted = encrypt_token(credentials.refresh_token)
expires_at = credentials.expiry
if expires_at is not None and expires_at.tzinfo is None:
expires_at = expires_at.replace(tzinfo=timezone.utc)
row = db.get(OAuthCredentials, SINGLETON_ID)
if row is None:
@ -111,15 +115,22 @@ def store_credentials(db: Session, google_email: str, credentials: Credentials)
else:
row.google_email = google_email
row.encrypted_refresh_token = encrypted
row.access_token_expires_at = expires_at
db.commit()
# The exchanged credentials carry a fresh access token -- reuse them so
# the immediately following syncs don't pay for a second Google request.
with _credentials_lock:
_cached_credentials = credentials
def clear_credentials(db: Session) -> None:
global _cached_credentials
row = db.get(OAuthCredentials, SINGLETON_ID)
if row is not None:
db.delete(row)
db.commit()
with _credentials_lock:
_cached_credentials = None
def is_connected(db: Session) -> bool:
@ -131,26 +142,48 @@ def get_connected_email(db: Session) -> str | None:
return row.google_email if row else None
def _token_missing_or_expiring(credentials: Credentials) -> bool:
if not credentials.token:
return True
expiry = credentials.expiry
if expiry is None:
# Unknown expiry -- be conservative and refresh.
return True
if expiry.tzinfo is None:
# google-auth reports expiry as a naive UTC datetime.
expiry = expiry.replace(tzinfo=timezone.utc)
return expiry <= datetime.now(timezone.utc) + timedelta(seconds=_ACCESS_TOKEN_REFRESH_BUFFER_SECONDS)
def get_credentials(db: Session) -> Credentials:
global _cached_credentials
row = db.get(OAuthCredentials, SINGLETON_ID)
if row is None:
raise OAuthNotConnected("Google account is not connected")
refresh_token = decrypt_token(row.encrypted_refresh_token)
credentials = Credentials(
token=None,
refresh_token=refresh_token,
token_uri=TOKEN_URI,
client_id=settings.google_client_id,
client_secret=settings.google_client_secret,
scopes=SCOPES,
)
credentials.refresh(GoogleAuthRequest())
expires_at = credentials.expiry
if expires_at is not None and expires_at.tzinfo is None:
expires_at = expires_at.replace(tzinfo=timezone.utc)
row.access_token_expires_at = expires_at
db.commit()
with _credentials_lock:
credentials = _cached_credentials
if credentials is None or credentials.refresh_token != refresh_token:
# No cached token yet, or the account was reconnected with a new
# refresh token. The refresh below does the initial exchange.
credentials = Credentials(
token=None,
refresh_token=refresh_token,
token_uri=settings.google_token_uri,
client_id=settings.google_client_id,
client_secret=settings.google_client_secret,
scopes=SCOPES,
)
_cached_credentials = credentials
return credentials
if _token_missing_or_expiring(credentials):
try:
credentials.refresh(GoogleAuthRequest())
except Exception:
# Don't keep a broken cached object behind.
_cached_credentials = None
raise
return credentials