2026-06-11 04:26:18 +02:00
|
|
|
import logging
|
2026-06-12 01:43:07 +02:00
|
|
|
import re
|
2026-06-11 01:01:37 +02:00
|
|
|
from datetime import datetime, timezone
|
|
|
|
|
|
|
|
|
|
from authlib.integrations.starlette_client import OAuth, OAuthError
|
2026-06-12 01:43:07 +02:00
|
|
|
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request
|
2026-06-11 01:01:37 +02:00
|
|
|
from fastapi.responses import JSONResponse, RedirectResponse
|
2026-06-12 01:43:07 +02:00
|
|
|
from sqlalchemy import select
|
2026-06-11 01:01:37 +02:00
|
|
|
from sqlalchemy.orm import Session
|
|
|
|
|
|
2026-06-12 01:43:07 +02:00
|
|
|
from app import email as email_mod
|
2026-06-11 01:01:37 +02:00
|
|
|
from app.config import settings
|
|
|
|
|
from app.db import get_db
|
2026-06-12 01:43:07 +02:00
|
|
|
from app.models import Invite, OAuthToken, User
|
2026-06-11 01:01:37 +02:00
|
|
|
from app.security import encrypt
|
|
|
|
|
|
2026-06-12 01:43:07 +02:00
|
|
|
_EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
|
|
|
|
|
|
2026-06-13 23:56:34 +02:00
|
|
|
# Base sign-in requests only the non-sensitive OpenID scopes (profile/email). These do
|
|
|
|
|
# NOT trigger Google's "unverified app" warning and do NOT expire after 7 days, so a new
|
|
|
|
|
# user gets a clean, familiar consent. YouTube access is granted later, one step at a
|
|
|
|
|
# time, by the onboarding wizard via /auth/upgrade (incremental authorization):
|
|
|
|
|
# - read -> youtube.readonly (read subscriptions, build the feed)
|
|
|
|
|
# - write -> youtube (full: unsubscribe / playlist export)
|
|
|
|
|
# Both YouTube scopes are "sensitive"; the wizard explains the Google warning before
|
|
|
|
|
# sending the user there, so the extra permission never feels like a surprise.
|
2026-06-11 23:27:11 +02:00
|
|
|
WRITE_SCOPE = "https://www.googleapis.com/auth/youtube"
|
2026-06-13 23:56:34 +02:00
|
|
|
READ_SCOPE = f"{WRITE_SCOPE}.readonly"
|
|
|
|
|
BASE_SCOPES = "openid email profile"
|
|
|
|
|
READ_SCOPES = f"{BASE_SCOPES} {READ_SCOPE}"
|
|
|
|
|
WRITE_SCOPES = f"{BASE_SCOPES} {READ_SCOPE} {WRITE_SCOPE}"
|
2026-06-11 01:01:37 +02:00
|
|
|
|
2026-06-11 04:26:18 +02:00
|
|
|
log = logging.getLogger("subfeed.auth")
|
|
|
|
|
|
2026-06-11 01:01:37 +02:00
|
|
|
router = APIRouter(prefix="/auth", tags=["auth"])
|
|
|
|
|
|
|
|
|
|
oauth = OAuth()
|
|
|
|
|
oauth.register(
|
|
|
|
|
name="google",
|
|
|
|
|
client_id=settings.google_client_id,
|
|
|
|
|
client_secret=settings.google_client_secret,
|
|
|
|
|
server_metadata_url="https://accounts.google.com/.well-known/openid-configuration",
|
2026-06-13 23:56:34 +02:00
|
|
|
client_kwargs={"scope": BASE_SCOPES},
|
2026-06-11 01:01:37 +02:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-06-13 23:56:34 +02:00
|
|
|
def has_read_scope(user: User) -> bool:
|
|
|
|
|
"""Whether the user's stored grant lets us read their YouTube data (build the feed).
|
|
|
|
|
The full write scope is a superset, so it implies read."""
|
|
|
|
|
tok = user.token
|
|
|
|
|
if tok is None or not tok.scopes:
|
|
|
|
|
return False
|
|
|
|
|
granted = tok.scopes.split()
|
|
|
|
|
return READ_SCOPE in granted or WRITE_SCOPE in granted
|
|
|
|
|
|
|
|
|
|
|
2026-06-11 23:27:11 +02:00
|
|
|
def has_write_scope(user: User) -> bool:
|
|
|
|
|
"""Whether the user's stored grant includes YouTube write (unsubscribe / export)."""
|
|
|
|
|
tok = user.token
|
|
|
|
|
if tok is None or not tok.scopes:
|
|
|
|
|
return False
|
|
|
|
|
return WRITE_SCOPE in tok.scopes.split()
|
|
|
|
|
|
|
|
|
|
|
2026-06-12 01:43:07 +02:00
|
|
|
def is_allowed(db: Session, email: str) -> bool:
|
|
|
|
|
"""May this email sign in? An approved Invite is the source of truth; the env
|
|
|
|
|
ALLOWED_EMAILS/ADMIN_EMAILS remain a bootstrap fallback (e.g. a fresh DB)."""
|
|
|
|
|
if not email:
|
|
|
|
|
return False
|
|
|
|
|
email = email.lower()
|
|
|
|
|
inv = db.execute(select(Invite).where(Invite.email == email)).scalar_one_or_none()
|
|
|
|
|
if inv is not None:
|
|
|
|
|
return inv.status == "approved"
|
|
|
|
|
return email in settings.allowed_email_set or email in settings.admin_email_set
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def upsert_pending_invite(db: Session, email: str) -> Invite | None:
|
|
|
|
|
"""Idempotently record an access request. Returns the Invite if a *new* pending row
|
|
|
|
|
was created (so the caller can notify admins), else None for already-known emails."""
|
|
|
|
|
email = (email or "").lower()
|
|
|
|
|
if not _EMAIL_RE.match(email):
|
|
|
|
|
return None
|
|
|
|
|
inv = db.execute(select(Invite).where(Invite.email == email)).scalar_one_or_none()
|
|
|
|
|
if inv is not None:
|
|
|
|
|
# Let a previously-denied person ask again; don't disturb approved/pending rows.
|
|
|
|
|
if inv.status == "denied":
|
|
|
|
|
inv.status = "pending"
|
|
|
|
|
inv.requested_at = datetime.now(timezone.utc)
|
|
|
|
|
inv.decided_at = None
|
|
|
|
|
inv.decided_by = None
|
|
|
|
|
db.commit()
|
|
|
|
|
return inv
|
|
|
|
|
return None
|
|
|
|
|
inv = Invite(email=email, status="pending")
|
|
|
|
|
db.add(inv)
|
|
|
|
|
db.commit()
|
|
|
|
|
return inv
|
|
|
|
|
|
|
|
|
|
|
2026-06-11 01:01:37 +02:00
|
|
|
@router.get("/login")
|
|
|
|
|
async def login(request: Request):
|
fix: address reader-UI feedback (shorts, search, tags, login, UX)
- Shorts: confirm via youtube.com/shorts/<id> probe (SOCS cookie bypasses the
consent redirect) instead of a 60s heuristic; concurrent probing, shorts_probed
flag, scheduled refinement (migration 0005)
- Search: match title + channel name only (descriptions caused noisy results)
- Faceted tag filtering: AND across categories (language AND topic narrows),
OR within a category; any/all toggle applies to topics
- Language detection: majority vote over individual titles (fixes misdetections
like multipoleguy -> English; drops bogus Polish/Romanian)
- Login: drop forced consent so returning sign-in is quick (select_account)
- Feed cards: clickable channel name (opens channel), persistent saved badge,
undo toast on hide, Hidden view to restore; tag chips show counts in tooltip
2026-06-11 03:07:49 +02:00
|
|
|
# access_type=offline ensures a refresh_token on first authorization. We avoid
|
|
|
|
|
# prompt=consent so returning users get a quick sign-in; the stored refresh token
|
|
|
|
|
# is kept when Google doesn't re-issue one (see the callback).
|
2026-06-11 01:14:06 +02:00
|
|
|
return await oauth.google.authorize_redirect(
|
|
|
|
|
request,
|
|
|
|
|
settings.oauth_redirect_url,
|
|
|
|
|
access_type="offline",
|
fix: address reader-UI feedback (shorts, search, tags, login, UX)
- Shorts: confirm via youtube.com/shorts/<id> probe (SOCS cookie bypasses the
consent redirect) instead of a 60s heuristic; concurrent probing, shorts_probed
flag, scheduled refinement (migration 0005)
- Search: match title + channel name only (descriptions caused noisy results)
- Faceted tag filtering: AND across categories (language AND topic narrows),
OR within a category; any/all toggle applies to topics
- Language detection: majority vote over individual titles (fixes misdetections
like multipoleguy -> English; drops bogus Polish/Romanian)
- Login: drop forced consent so returning sign-in is quick (select_account)
- Feed cards: clickable channel name (opens channel), persistent saved badge,
undo toast on hide, Hidden view to restore; tag chips show counts in tooltip
2026-06-11 03:07:49 +02:00
|
|
|
prompt="select_account",
|
2026-06-11 01:14:06 +02:00
|
|
|
include_granted_scopes="true",
|
2026-06-13 23:56:34 +02:00
|
|
|
scope=BASE_SCOPES,
|
2026-06-11 01:14:06 +02:00
|
|
|
)
|
2026-06-11 01:01:37 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/callback")
|
2026-06-12 01:43:07 +02:00
|
|
|
async def callback(
|
|
|
|
|
request: Request,
|
|
|
|
|
background: BackgroundTasks,
|
|
|
|
|
db: Session = Depends(get_db),
|
|
|
|
|
):
|
2026-06-11 01:01:37 +02:00
|
|
|
try:
|
|
|
|
|
token = await oauth.google.authorize_access_token(request)
|
|
|
|
|
except OAuthError as exc:
|
|
|
|
|
raise HTTPException(status_code=400, detail=f"OAuth error: {exc.error}")
|
|
|
|
|
|
|
|
|
|
userinfo = token.get("userinfo")
|
|
|
|
|
if not userinfo or not userinfo.get("sub"):
|
|
|
|
|
raise HTTPException(status_code=400, detail="No user info returned by Google")
|
|
|
|
|
|
|
|
|
|
email = (userinfo.get("email") or "").lower()
|
2026-06-12 01:43:07 +02:00
|
|
|
if not is_allowed(db, email):
|
|
|
|
|
log.warning("Login denied (not approved): %s", email or "<no email>")
|
|
|
|
|
# A denied Google login doubles as an access request: record it for the admin and
|
|
|
|
|
# land the user on a friendly "request received" screen instead of a raw 403.
|
|
|
|
|
if email:
|
|
|
|
|
inv = upsert_pending_invite(db, email)
|
|
|
|
|
if inv is not None and settings.admin_email_set:
|
|
|
|
|
background.add_task(
|
|
|
|
|
email_mod.send_admin_new_request,
|
|
|
|
|
sorted(settings.admin_email_set),
|
|
|
|
|
email,
|
|
|
|
|
)
|
|
|
|
|
return RedirectResponse(url="/?access=requested")
|
|
|
|
|
return RedirectResponse(url="/?access=denied")
|
2026-06-11 01:01:37 +02:00
|
|
|
|
|
|
|
|
user = db.query(User).filter(User.google_sub == userinfo["sub"]).one_or_none()
|
|
|
|
|
if user is None:
|
|
|
|
|
user = User(google_sub=userinfo["sub"], email=email)
|
|
|
|
|
db.add(user)
|
|
|
|
|
user.email = email
|
|
|
|
|
user.display_name = userinfo.get("name")
|
|
|
|
|
user.avatar_url = userinfo.get("picture")
|
|
|
|
|
user.role = "admin" if email in settings.admin_email_set else "user"
|
|
|
|
|
db.flush()
|
|
|
|
|
|
|
|
|
|
tok = user.token or OAuthToken(user=user)
|
|
|
|
|
# Google only returns a refresh_token on (re)consent; keep the previous one otherwise.
|
|
|
|
|
if token.get("refresh_token"):
|
|
|
|
|
tok.refresh_token_enc = encrypt(token["refresh_token"])
|
|
|
|
|
tok.access_token = token.get("access_token")
|
|
|
|
|
expires_at = token.get("expires_at")
|
|
|
|
|
tok.expiry = (
|
|
|
|
|
datetime.fromtimestamp(expires_at, tz=timezone.utc) if expires_at else None
|
|
|
|
|
)
|
2026-06-13 23:56:34 +02:00
|
|
|
# include_granted_scopes=true means Google returns the union of all scopes the user
|
|
|
|
|
# has ever granted this app, so this correctly reflects read/write upgrades too.
|
|
|
|
|
tok.scopes = token.get("scope") or BASE_SCOPES
|
2026-06-11 01:01:37 +02:00
|
|
|
db.add(tok)
|
|
|
|
|
db.commit()
|
|
|
|
|
|
|
|
|
|
request.session["user_id"] = user.id
|
2026-06-11 04:26:18 +02:00
|
|
|
log.info("Login: %s (id=%s, role=%s)", email, user.id, user.role)
|
2026-06-11 01:01:37 +02:00
|
|
|
return RedirectResponse(url="/")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/logout")
|
|
|
|
|
async def logout(request: Request):
|
|
|
|
|
request.session.clear()
|
|
|
|
|
return JSONResponse({"ok": True})
|
|
|
|
|
|
|
|
|
|
|
2026-06-12 01:43:07 +02:00
|
|
|
@router.post("/request-access")
|
|
|
|
|
async def request_access(
|
|
|
|
|
payload: dict,
|
|
|
|
|
background: BackgroundTasks,
|
|
|
|
|
db: Session = Depends(get_db),
|
|
|
|
|
) -> dict:
|
|
|
|
|
"""Public: ask for access. Idempotent — repeat requests for the same email don't
|
|
|
|
|
duplicate or re-spam admins (upsert returns None for an already-pending row)."""
|
|
|
|
|
email = (payload.get("email") or "").strip().lower()
|
|
|
|
|
if not _EMAIL_RE.match(email):
|
|
|
|
|
raise HTTPException(status_code=400, detail="Enter a valid email address.")
|
|
|
|
|
if is_allowed(db, email):
|
|
|
|
|
return {"status": "approved"} # already allowed — just sign in
|
|
|
|
|
inv = upsert_pending_invite(db, email)
|
|
|
|
|
if inv is not None and settings.admin_email_set:
|
|
|
|
|
background.add_task(
|
|
|
|
|
email_mod.send_admin_new_request, sorted(settings.admin_email_set), email
|
|
|
|
|
)
|
|
|
|
|
return {"status": "pending"}
|
|
|
|
|
|
|
|
|
|
|
2026-06-11 01:01:37 +02:00
|
|
|
def current_user(request: Request, db: Session = Depends(get_db)) -> User:
|
|
|
|
|
user_id = request.session.get("user_id")
|
|
|
|
|
if not user_id:
|
|
|
|
|
raise HTTPException(status_code=401, detail="Not authenticated")
|
|
|
|
|
user = db.get(User, user_id)
|
|
|
|
|
if user is None:
|
|
|
|
|
request.session.clear()
|
|
|
|
|
raise HTTPException(status_code=401, detail="Not authenticated")
|
|
|
|
|
return user
|
|
|
|
|
|
|
|
|
|
|
2026-06-11 23:27:11 +02:00
|
|
|
@router.get("/upgrade")
|
2026-06-13 23:56:34 +02:00
|
|
|
async def upgrade(
|
2026-06-14 05:59:34 +02:00
|
|
|
request: Request, access: str = "read", user: User = Depends(current_user)
|
2026-06-13 23:56:34 +02:00
|
|
|
):
|
|
|
|
|
"""Incremental consent for the onboarding wizard. `access=read` grants YouTube
|
|
|
|
|
read-only (enough to build the feed); `access=write` additionally grants management
|
|
|
|
|
(unsubscribe). The shared callback stores whatever Google returns, so `can_read` /
|
2026-06-14 05:59:34 +02:00
|
|
|
`can_write` flip on once granted. Anything other than an explicit `write` defaults to
|
|
|
|
|
the least-privileged read scope."""
|
|
|
|
|
scope = WRITE_SCOPES if access == "write" else READ_SCOPES
|
2026-06-11 23:27:11 +02:00
|
|
|
return await oauth.google.authorize_redirect(
|
|
|
|
|
request,
|
|
|
|
|
settings.oauth_redirect_url,
|
|
|
|
|
access_type="offline",
|
|
|
|
|
prompt="consent",
|
|
|
|
|
include_granted_scopes="true",
|
2026-06-13 23:56:34 +02:00
|
|
|
scope=scope,
|
2026-06-11 23:27:11 +02:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-06-11 01:01:37 +02:00
|
|
|
@router.get("/me")
|
|
|
|
|
async def me(user: User = Depends(current_user)) -> dict:
|
|
|
|
|
return {
|
|
|
|
|
"id": user.id,
|
|
|
|
|
"email": user.email,
|
|
|
|
|
"display_name": user.display_name,
|
|
|
|
|
"avatar_url": user.avatar_url,
|
|
|
|
|
"role": user.role,
|
|
|
|
|
}
|