From d02b540c606984a3aa350081687b55933ec83e91 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Thu, 11 Jun 2026 23:27:11 +0200 Subject: [PATCH] feat(m5b): optional YouTube write scope via incremental OAuth Default login now requests read-only (youtube.readonly); write (unsubscribe, later playlist export) is an explicit opt-in. - auth.py: split READ_SCOPES / WRITE_SCOPES; new GET /auth/upgrade (incremental consent, prompt=consent); has_write_scope() helper - /api/me exposes can_write - youtube/client.py: delete_subscription (50 units, OAuth-only) - DELETE /api/channels/{id}/subscription, gated on write scope (403 otherwise) - UI: Settings - Account 'Playlist editing & YouTube export' enable button; per-channel 'Unsubscribe on YouTube' (with confirm) shown only when can_write Browser-facing; develop/test locally until the public HTTPS login lands. Needs a one-time Console step: add youtube.readonly to the OAuth consent screen scopes. --- backend/app/auth.py | 36 +++++++++++++++++++--- backend/app/routes/channels.py | 32 +++++++++++++++++++- backend/app/routes/me.py | 3 +- backend/app/youtube/client.py | 19 ++++++++++++ frontend/src/App.tsx | 1 + frontend/src/components/Channels.tsx | 37 +++++++++++++++++++++++ frontend/src/components/SettingsPanel.tsx | 32 +++++++++++++++++--- frontend/src/lib/api.ts | 3 ++ 8 files changed, 152 insertions(+), 11 deletions(-) diff --git a/backend/app/auth.py b/backend/app/auth.py index 1466c72..b765d9c 100644 --- a/backend/app/auth.py +++ b/backend/app/auth.py @@ -11,9 +11,12 @@ from app.db import get_db from app.models import OAuthToken, User from app.security import encrypt -# Single YouTube scope that allows reading subscriptions/playlists AND writing -# (unsubscribe, playlist export). openid/email/profile give us the account identity. -SCOPES = "openid email profile https://www.googleapis.com/auth/youtube" +# YouTube's full scope (read + write: unsubscribe, playlist export) and its read-only +# counterpart. Default login asks for read-only; write is an explicit opt-in via +# /auth/upgrade (incremental consent), so friends who won't grant write can still browse. +WRITE_SCOPE = "https://www.googleapis.com/auth/youtube" +READ_SCOPES = f"openid email profile {WRITE_SCOPE}.readonly" +WRITE_SCOPES = f"openid email profile {WRITE_SCOPE}" log = logging.getLogger("subfeed.auth") @@ -25,10 +28,18 @@ oauth.register( client_id=settings.google_client_id, client_secret=settings.google_client_secret, server_metadata_url="https://accounts.google.com/.well-known/openid-configuration", - client_kwargs={"scope": SCOPES}, + client_kwargs={"scope": READ_SCOPES}, ) +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() + + @router.get("/login") async def login(request: Request): # access_type=offline ensures a refresh_token on first authorization. We avoid @@ -40,6 +51,7 @@ async def login(request: Request): access_type="offline", prompt="select_account", include_granted_scopes="true", + scope=READ_SCOPES, ) @@ -80,7 +92,7 @@ async def callback(request: Request, db: Session = Depends(get_db)): tok.expiry = ( datetime.fromtimestamp(expires_at, tz=timezone.utc) if expires_at else None ) - tok.scopes = token.get("scope") or SCOPES + tok.scopes = token.get("scope") or READ_SCOPES db.add(tok) db.commit() @@ -106,6 +118,20 @@ def current_user(request: Request, db: Session = Depends(get_db)) -> User: return user +@router.get("/upgrade") +async def upgrade(request: Request, user: User = Depends(current_user)): + """Incremental consent: re-authorize with the full YouTube (write) scope. The shared + callback stores the new scope set, so `can_write` flips on once Google grants it.""" + return await oauth.google.authorize_redirect( + request, + settings.oauth_redirect_url, + access_type="offline", + prompt="consent", + include_granted_scopes="true", + scope=WRITE_SCOPES, + ) + + @router.get("/me") async def me(user: User = Depends(current_user)) -> dict: return { diff --git a/backend/app/routes/channels.py b/backend/app/routes/channels.py index 871bbff..b2338ce 100644 --- a/backend/app/routes/channels.py +++ b/backend/app/routes/channels.py @@ -5,9 +5,10 @@ from fastapi import APIRouter, Depends, HTTPException from sqlalchemy import and_, func, select from sqlalchemy.orm import Session -from app.auth import current_user +from app.auth import current_user, has_write_scope from app.db import get_db from app.models import Channel, ChannelTag, Subscription, Tag, User, Video +from app.youtube.client import YouTubeClient, YouTubeError router = APIRouter(prefix="/api/channels", tags=["channels"]) @@ -101,6 +102,35 @@ def update_channel( } +@router.delete("/{channel_id}/subscription") +def unsubscribe( + channel_id: str, + user: User = Depends(current_user), + db: Session = Depends(get_db), +) -> dict: + """Unsubscribe from this channel on YouTube and drop the local subscription. Gated + behind the optional write scope; read-only users get a 403 and should Hide instead.""" + if not has_write_scope(user): + raise HTTPException( + status_code=403, + detail="Enable playlist editing in Settings to unsubscribe on YouTube.", + ) + sub = _user_subscription(db, user, channel_id) + if not sub.yt_subscription_id: + raise HTTPException( + status_code=400, + detail="No YouTube subscription id on record — sync subscriptions first.", + ) + try: + with YouTubeClient(db, user) as yt: + yt.delete_subscription(sub.yt_subscription_id) + except YouTubeError as exc: + raise HTTPException(status_code=502, detail=f"YouTube unsubscribe failed: {exc}") + db.delete(sub) + db.commit() + return {"unsubscribed": channel_id} + + @router.post("/{channel_id}/tags") def attach_tag( channel_id: str, diff --git a/backend/app/routes/me.py b/backend/app/routes/me.py index 9919aed..ef532c7 100644 --- a/backend/app/routes/me.py +++ b/backend/app/routes/me.py @@ -1,7 +1,7 @@ from fastapi import APIRouter, Depends from sqlalchemy.orm import Session -from app.auth import current_user +from app.auth import current_user, has_write_scope from app.db import get_db from app.models import User @@ -16,6 +16,7 @@ def get_me(user: User = Depends(current_user)) -> dict: "display_name": user.display_name, "avatar_url": user.avatar_url, "role": user.role, + "can_write": has_write_scope(user), "preferences": user.preferences or {}, } diff --git a/backend/app/youtube/client.py b/backend/app/youtube/client.py index 274861c..62f2125 100644 --- a/backend/app/youtube/client.py +++ b/backend/app/youtube/client.py @@ -151,6 +151,25 @@ class YouTubeClient: params["pageToken"] = page_token return self._get("playlistItems", params) + def delete_subscription(self, subscription_id: str) -> None: + """Unsubscribe on YouTube. Requires the write scope and the user's OAuth token + (never the public API key). subscriptions.delete costs 50 quota units.""" + resp = self._http.delete( + f"{API_BASE}/subscriptions", + params={"id": subscription_id}, + headers={"Authorization": f"Bearer {self._access_token()}"}, + ) + quota.record_usage(self.db, 50) + if resp.status_code not in (200, 204): + log.warning( + "YouTube subscriptions.delete -> %s: %s", + resp.status_code, + resp.text[:200], + ) + raise YouTubeError( + f"DELETE subscriptions -> {resp.status_code}: {resp.text[:300]}" + ) + def get_videos(self, video_ids: list[str]) -> list[dict]: items: list[dict] = [] for batch in _chunks(video_ids, 50): diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3569a9f..806586d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -152,6 +152,7 @@ export default function App() { ) : ( { setFilters({ ...filters, channelId: id, channelName: name, show: "all" }); setPage("feed"); diff --git a/frontend/src/components/Channels.tsx b/frontend/src/components/Channels.tsx index e0681ed..a9cb47b 100644 --- a/frontend/src/components/Channels.tsx +++ b/frontend/src/components/Channels.tsx @@ -9,6 +9,7 @@ import { Plus, RefreshCw, Search, + UserMinus, X, } from "lucide-react"; import { api, type ManagedChannel, type Tag } from "../lib/api"; @@ -17,8 +18,10 @@ import { notify } from "../lib/notifications"; import Tooltip from "./Tooltip"; export default function Channels({ + canWrite, onViewChannel, }: { + canWrite: boolean; onViewChannel: (id: string, name: string) => void; }) { const qc = useQueryClient(); @@ -70,6 +73,15 @@ export default function Channels({ mutationFn: (id: number) => api.deleteTag(id), onSuccess: () => invalidate(), }); + const unsubscribe = useMutation({ + mutationFn: (id: string) => api.unsubscribeChannel(id), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["channels"] }); + qc.invalidateQueries({ queryKey: ["my-status"] }); + notify({ level: "success", message: "Unsubscribed on YouTube" }); + }, + onError: () => notify({ level: "error", message: "Unsubscribe failed" }), + }); const deepAll = useMutation({ mutationFn: () => api.deepAll(true), onSuccess: (r: { updated?: number }) => { @@ -214,6 +226,15 @@ export default function Channels({ key={c.id} c={c} userTags={userTags} + canWrite={canWrite} + onUnsubscribe={() => { + if ( + window.confirm( + `Unsubscribe from "${c.title ?? c.id}" on YouTube? This changes your real YouTube account. To just remove it from your feed, hide it instead.` + ) + ) + unsubscribe.mutate(c.id); + }} onView={() => onViewChannel(c.id, c.title ?? "This channel")} onPriority={(d) => patch.mutate({ id: c.id, body: { priority: c.priority + d } })} onHide={() => patch.mutate({ id: c.id, body: { hidden: !c.hidden } })} @@ -268,6 +289,8 @@ function SyncBadge({ ok, label, hint }: { ok: boolean; label: string; hint?: str function ChannelRow({ c, userTags, + canWrite, + onUnsubscribe, onView, onPriority, onHide, @@ -276,6 +299,8 @@ function ChannelRow({ }: { c: ManagedChannel; userTags: Tag[]; + canWrite: boolean; + onUnsubscribe: () => void; onView: () => void; onPriority: (delta: number) => void; onHide: () => void; @@ -373,6 +398,18 @@ function ChannelRow({ {c.hidden ? : } + + {canWrite && ( + + + + )} ); } diff --git a/frontend/src/components/SettingsPanel.tsx b/frontend/src/components/SettingsPanel.tsx index eb53106..7ce8db8 100644 --- a/frontend/src/components/SettingsPanel.tsx +++ b/frontend/src/components/SettingsPanel.tsx @@ -449,10 +449,34 @@ function Account({ me }: { me: Me }) {
{me.role}
-

- Playlist editing & YouTube export, and the admin approval queue, arrive in the next - phases of this milestone. -

+
+
+
+
Playlist editing & YouTube export
+

+ {me.can_write + ? "Granted. You can unsubscribe from channels on YouTube (and export playlists once that ships). Subfeed only writes when you ask it to." + : "Subfeed is read-only by default — it can browse your subscriptions but not change your YouTube account. Enable this to unsubscribe from channels (and, later, export playlists). You'll re-consent with Google."} +

+
+ {me.can_write ? ( + + Enabled + + ) : ( + + + + )} +
+
); } diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 022a594..4aafc50 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -6,6 +6,7 @@ export interface Me { display_name: string | null; avatar_url: string | null; role: string; + can_write: boolean; preferences: Record; } @@ -198,6 +199,8 @@ export const api = { req(`/api/channels/${id}/tags`, { method: "POST", body: JSON.stringify({ tag_id: tagId }) }), detachChannelTag: (id: string, tagId: number) => req(`/api/channels/${id}/tags/${tagId}`, { method: "DELETE" }), + unsubscribeChannel: (id: string) => + req(`/api/channels/${id}/subscription`, { method: "DELETE" }), syncSubscriptions: () => req("/api/sync/subscriptions", { method: "POST" }), // --- user tags ---