diff --git a/backend/app/auth.py b/backend/app/auth.py index 685a211..b4fe775 100644 --- a/backend/app/auth.py +++ b/backend/app/auth.py @@ -11,11 +11,21 @@ from sqlalchemy.orm import Session from app import email as email_mod from app.config import settings from app.db import get_db -from app.models import Invite, OAuthToken, User +from app.models import DemoWhitelist, Invite, OAuthToken, User +from app.ratelimit import RateLimiter from app.security import encrypt _EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$") +# The single shared demo account's stable identity. The row is created lazily on first +# demo login (see get_or_create_demo_user); these are just its sentinel keys. +DEMO_GOOGLE_SUB = "__demo__" +DEMO_EMAIL = "demo@siftlode.local" + +# Throttle the hidden demo-login endpoint per client IP: enough for a real paste-and-wait, +# stingy enough that the field can't be hammered as an enumeration oracle or for DoS. +_demo_limiter = RateLimiter(max_events=5, window_seconds=60) + # How many recently-used accounts to keep switchable in one browser session. MAX_SESSION_ACCOUNTS = 6 @@ -77,6 +87,45 @@ def is_allowed(db: Session, email: str) -> bool: return email in settings.allowed_email_set or email in settings.admin_email_set +def is_demo_allowed(db: Session, email: str) -> bool: + """Whether this email may enter the shared demo account (no Google sign-in).""" + if not email: + return False + return ( + db.execute( + select(DemoWhitelist).where(DemoWhitelist.email == email.lower()) + ).scalar_one_or_none() + is not None + ) + + +def get_or_create_demo_user(db: Session) -> User: + """The one shared demo user, created on first use. No OAuth token / YouTube scope, so + has_read_scope/has_write_scope are False and every YouTube-touching path is closed to it.""" + user = db.query(User).filter(User.is_demo.is_(True)).one_or_none() + if user is None: + user = User( + google_sub=DEMO_GOOGLE_SUB, + email=DEMO_EMAIL, + display_name="Demo", + role="user", + is_demo=True, + preferences={"language": "en"}, + ) + db.add(user) + db.commit() + return user + + +def _client_ip(request: Request) -> str: + """Best-effort client IP for rate limiting. Behind our reverse proxy (Caddy/NPM) the + real client is the first X-Forwarded-For hop; fall back to the socket peer otherwise.""" + xff = request.headers.get("x-forwarded-for") + if xff: + return xff.split(",")[0].strip() + return request.client.host if request.client else "unknown" + + 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.""" @@ -224,6 +273,24 @@ async def request_access( return {"status": "pending"} +@router.post("/demo") +def demo_login(payload: dict, request: Request, db: Session = Depends(get_db)) -> dict: + """Hidden demo entry: a whitelisted email logs straight into the shared demo account, + no Google OAuth. The login page fires this quietly (debounced) as the user types/pastes, + so there is no visible 'demo login' button. Rate-limited per IP; when blocked it answers + exactly like a non-match (authenticated=false) so the field can't be hammered as an + enumeration oracle. On a match it sets the session and the client reloads into the app.""" + if not _demo_limiter.allow(_client_ip(request)): + return {"authenticated": False} + email = (payload.get("email") or "").strip().lower() + if _EMAIL_RE.match(email) and is_demo_allowed(db, email): + demo = get_or_create_demo_user(db) + request.session["user_id"] = demo.id + log.info("Demo login (key=%s, demo_id=%s)", email, demo.id) + return {"authenticated": True} + return {"authenticated": False} + + def current_user(request: Request, db: Session = Depends(get_db)) -> User: user_id = request.session.get("user_id") if not user_id: @@ -241,9 +308,19 @@ def current_user(request: Request, db: Session = Depends(get_db)) -> User: return user +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 + no token, so has_read_scope/has_write_scope are False); this makes the boundary explicit + where there's no scope check to lean on.""" + if user.is_demo: + raise HTTPException(status_code=403, detail="Not available in the demo account.") + return user + + @router.get("/upgrade") async def upgrade( - request: Request, access: str = "read", user: User = Depends(current_user) + request: Request, access: str = "read", user: User = Depends(require_human) ): """Incremental consent for the onboarding wizard. `access=read` grants YouTube read-only (enough to build the feed); `access=write` additionally grants management diff --git a/backend/app/routes/me.py b/backend/app/routes/me.py index 40fc3f3..df9e6a5 100644 --- a/backend/app/routes/me.py +++ b/backend/app/routes/me.py @@ -77,6 +77,7 @@ def get_me( "display_name": user.display_name, "avatar_url": user.avatar_url, "role": user.role, + "is_demo": user.is_demo, "can_read": has_read_scope(user), "can_write": has_write_scope(user), "pending_invites": pending_invites, diff --git a/backend/app/routes/sync.py b/backend/app/routes/sync.py index 2af709e..8791aed 100644 --- a/backend/app/routes/sync.py +++ b/backend/app/routes/sync.py @@ -3,7 +3,7 @@ from sqlalchemy import and_, case, func, select, update from sqlalchemy.orm import Session from app import quota, state -from app.auth import current_user, has_read_scope +from app.auth import current_user, has_read_scope, require_human from app.db import get_db from app.models import Channel, Subscription, User, Video from app.sync.runner import ( @@ -31,7 +31,7 @@ def _user_channels(db: Session, user: User) -> list[Channel]: @router.post("/subscriptions") def sync_subscriptions( - user: User = Depends(current_user), db: Session = Depends(get_db) + user: User = Depends(require_human), db: Session = Depends(get_db) ) -> dict: if not has_read_scope(user): # No YouTube read grant yet — the onboarding wizard hasn't been completed. @@ -49,7 +49,7 @@ def sync_subscriptions( @router.post("/rss") def sync_rss( - user: User = Depends(current_user), db: Session = Depends(get_db) + user: User = Depends(require_human), db: Session = Depends(get_db) ) -> dict: new = run_rss_poll(db, _user_channels(db, user)) return {"new_videos": new} @@ -57,7 +57,7 @@ def sync_rss( @router.post("/backfill") def sync_backfill( - user: User = Depends(current_user), + user: User = Depends(require_human), db: Session = Depends(get_db), max_channels: int = 25, ) -> dict: @@ -70,7 +70,7 @@ def sync_backfill( @router.post("/enrich") def sync_enrich( - user: User = Depends(current_user), db: Session = Depends(get_db) + user: User = Depends(require_human), db: Session = Depends(get_db) ) -> dict: before = quota.units_used_today(db) with quota.attribute(user.id, "enrich"): @@ -175,7 +175,7 @@ def my_status( @router.post("/deep-all") def deep_all( - user: User = Depends(current_user), + user: User = Depends(require_human), db: Session = Depends(get_db), on: bool = True, ) -> dict: