merge: M5b — optional YouTube write scope (incremental OAuth)

This commit is contained in:
npeter83 2026-06-11 23:27:17 +02:00
commit 7c04bc1ee8
8 changed files with 152 additions and 11 deletions

View file

@ -11,9 +11,12 @@ from app.db import get_db
from app.models import OAuthToken, User from app.models import OAuthToken, User
from app.security import encrypt from app.security import encrypt
# Single YouTube scope that allows reading subscriptions/playlists AND writing # YouTube's full scope (read + write: unsubscribe, playlist export) and its read-only
# (unsubscribe, playlist export). openid/email/profile give us the account identity. # counterpart. Default login asks for read-only; write is an explicit opt-in via
SCOPES = "openid email profile https://www.googleapis.com/auth/youtube" # /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") log = logging.getLogger("subfeed.auth")
@ -25,10 +28,18 @@ oauth.register(
client_id=settings.google_client_id, client_id=settings.google_client_id,
client_secret=settings.google_client_secret, client_secret=settings.google_client_secret,
server_metadata_url="https://accounts.google.com/.well-known/openid-configuration", 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") @router.get("/login")
async def login(request: Request): async def login(request: Request):
# access_type=offline ensures a refresh_token on first authorization. We avoid # access_type=offline ensures a refresh_token on first authorization. We avoid
@ -40,6 +51,7 @@ async def login(request: Request):
access_type="offline", access_type="offline",
prompt="select_account", prompt="select_account",
include_granted_scopes="true", include_granted_scopes="true",
scope=READ_SCOPES,
) )
@ -80,7 +92,7 @@ async def callback(request: Request, db: Session = Depends(get_db)):
tok.expiry = ( tok.expiry = (
datetime.fromtimestamp(expires_at, tz=timezone.utc) if expires_at else None 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.add(tok)
db.commit() db.commit()
@ -106,6 +118,20 @@ def current_user(request: Request, db: Session = Depends(get_db)) -> User:
return 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") @router.get("/me")
async def me(user: User = Depends(current_user)) -> dict: async def me(user: User = Depends(current_user)) -> dict:
return { return {

View file

@ -5,9 +5,10 @@ from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import and_, func, select from sqlalchemy import and_, func, select
from sqlalchemy.orm import Session 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.db import get_db
from app.models import Channel, ChannelTag, Subscription, Tag, User, Video from app.models import Channel, ChannelTag, Subscription, Tag, User, Video
from app.youtube.client import YouTubeClient, YouTubeError
router = APIRouter(prefix="/api/channels", tags=["channels"]) 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") @router.post("/{channel_id}/tags")
def attach_tag( def attach_tag(
channel_id: str, channel_id: str,

View file

@ -1,7 +1,7 @@
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session 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.db import get_db
from app.models import User from app.models import User
@ -16,6 +16,7 @@ def get_me(user: User = Depends(current_user)) -> dict:
"display_name": user.display_name, "display_name": user.display_name,
"avatar_url": user.avatar_url, "avatar_url": user.avatar_url,
"role": user.role, "role": user.role,
"can_write": has_write_scope(user),
"preferences": user.preferences or {}, "preferences": user.preferences or {},
} }

View file

@ -151,6 +151,25 @@ class YouTubeClient:
params["pageToken"] = page_token params["pageToken"] = page_token
return self._get("playlistItems", params) 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]: def get_videos(self, video_ids: list[str]) -> list[dict]:
items: list[dict] = [] items: list[dict] = []
for batch in _chunks(video_ids, 50): for batch in _chunks(video_ids, 50):

View file

@ -152,6 +152,7 @@ export default function App() {
<Feed filters={filters} setFilters={setFilters} view={view} /> <Feed filters={filters} setFilters={setFilters} view={view} />
) : ( ) : (
<Channels <Channels
canWrite={meQuery.data!.can_write}
onViewChannel={(id, name) => { onViewChannel={(id, name) => {
setFilters({ ...filters, channelId: id, channelName: name, show: "all" }); setFilters({ ...filters, channelId: id, channelName: name, show: "all" });
setPage("feed"); setPage("feed");

View file

@ -9,6 +9,7 @@ import {
Plus, Plus,
RefreshCw, RefreshCw,
Search, Search,
UserMinus,
X, X,
} from "lucide-react"; } from "lucide-react";
import { api, type ManagedChannel, type Tag } from "../lib/api"; import { api, type ManagedChannel, type Tag } from "../lib/api";
@ -17,8 +18,10 @@ import { notify } from "../lib/notifications";
import Tooltip from "./Tooltip"; import Tooltip from "./Tooltip";
export default function Channels({ export default function Channels({
canWrite,
onViewChannel, onViewChannel,
}: { }: {
canWrite: boolean;
onViewChannel: (id: string, name: string) => void; onViewChannel: (id: string, name: string) => void;
}) { }) {
const qc = useQueryClient(); const qc = useQueryClient();
@ -70,6 +73,15 @@ export default function Channels({
mutationFn: (id: number) => api.deleteTag(id), mutationFn: (id: number) => api.deleteTag(id),
onSuccess: () => invalidate(), 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({ const deepAll = useMutation({
mutationFn: () => api.deepAll(true), mutationFn: () => api.deepAll(true),
onSuccess: (r: { updated?: number }) => { onSuccess: (r: { updated?: number }) => {
@ -214,6 +226,15 @@ export default function Channels({
key={c.id} key={c.id}
c={c} c={c}
userTags={userTags} 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")} onView={() => onViewChannel(c.id, c.title ?? "This channel")}
onPriority={(d) => patch.mutate({ id: c.id, body: { priority: c.priority + d } })} onPriority={(d) => patch.mutate({ id: c.id, body: { priority: c.priority + d } })}
onHide={() => patch.mutate({ id: c.id, body: { hidden: !c.hidden } })} 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({ function ChannelRow({
c, c,
userTags, userTags,
canWrite,
onUnsubscribe,
onView, onView,
onPriority, onPriority,
onHide, onHide,
@ -276,6 +299,8 @@ function ChannelRow({
}: { }: {
c: ManagedChannel; c: ManagedChannel;
userTags: Tag[]; userTags: Tag[];
canWrite: boolean;
onUnsubscribe: () => void;
onView: () => void; onView: () => void;
onPriority: (delta: number) => void; onPriority: (delta: number) => void;
onHide: () => void; onHide: () => void;
@ -373,6 +398,18 @@ function ChannelRow({
{c.hidden ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />} {c.hidden ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button> </button>
</Tooltip> </Tooltip>
{canWrite && (
<Tooltip hint="Unsubscribe from this channel on YouTube (changes your real account). Read-only mode hides this — use Hide instead.">
<button
onClick={onUnsubscribe}
className="text-muted hover:text-red-400 shrink-0"
aria-label="Unsubscribe on YouTube"
>
<UserMinus className="w-4 h-4" />
</button>
</Tooltip>
)}
</div> </div>
); );
} }

View file

@ -449,10 +449,34 @@ function Account({ me }: { me: Me }) {
<div className="text-xs text-muted capitalize">{me.role}</div> <div className="text-xs text-muted capitalize">{me.role}</div>
</div> </div>
</div> </div>
<p className="text-xs text-muted leading-relaxed"> <div className="mt-1 pt-3 border-t border-border">
Playlist editing &amp; YouTube export, and the admin approval queue, arrive in the next <div className="flex items-start justify-between gap-3">
phases of this milestone. <div className="min-w-0">
<div className="text-sm font-medium">Playlist editing &amp; YouTube export</div>
<p className="text-xs text-muted leading-relaxed mt-0.5">
{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."}
</p> </p>
</div>
{me.can_write ? (
<span className="shrink-0 text-[11px] px-2 py-1 rounded-full border border-accent/40 text-accent">
Enabled
</span>
) : (
<Tooltip hint="Redirects to Google to grant YouTube write access (unsubscribe / export). You can keep using Subfeed read-only if you skip it.">
<button
onClick={() => {
window.location.href = "/auth/upgrade";
}}
className="shrink-0 glass-card glass-hover px-3 py-1.5 rounded-xl text-sm transition"
>
Enable
</button>
</Tooltip>
)}
</div>
</div>
</Section> </Section>
); );
} }

View file

@ -6,6 +6,7 @@ export interface Me {
display_name: string | null; display_name: string | null;
avatar_url: string | null; avatar_url: string | null;
role: string; role: string;
can_write: boolean;
preferences: Record<string, any>; preferences: Record<string, any>;
} }
@ -198,6 +199,8 @@ export const api = {
req(`/api/channels/${id}/tags`, { method: "POST", body: JSON.stringify({ tag_id: tagId }) }), req(`/api/channels/${id}/tags`, { method: "POST", body: JSON.stringify({ tag_id: tagId }) }),
detachChannelTag: (id: string, tagId: number) => detachChannelTag: (id: string, tagId: number) =>
req(`/api/channels/${id}/tags/${tagId}`, { method: "DELETE" }), 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" }), syncSubscriptions: () => req("/api/sync/subscriptions", { method: "POST" }),
// --- user tags --- // --- user tags ---