merge: M5b — optional YouTube write scope (incremental OAuth)
This commit is contained in:
commit
c8a50472f3
8 changed files with 152 additions and 11 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {},
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -152,6 +152,7 @@ export default function App() {
|
|||
<Feed filters={filters} setFilters={setFilters} view={view} />
|
||||
) : (
|
||||
<Channels
|
||||
canWrite={meQuery.data!.can_write}
|
||||
onViewChannel={(id, name) => {
|
||||
setFilters({ ...filters, channelId: id, channelName: name, show: "all" });
|
||||
setPage("feed");
|
||||
|
|
|
|||
|
|
@ -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 ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -449,10 +449,34 @@ function Account({ me }: { me: Me }) {
|
|||
<div className="text-xs text-muted capitalize">{me.role}</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted leading-relaxed">
|
||||
Playlist editing & YouTube export, and the admin approval queue, arrive in the next
|
||||
phases of this milestone.
|
||||
<div className="mt-1 pt-3 border-t border-border">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium">Playlist editing & 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>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ export interface Me {
|
|||
display_name: string | null;
|
||||
avatar_url: string | null;
|
||||
role: string;
|
||||
can_write: boolean;
|
||||
preferences: Record<string, any>;
|
||||
}
|
||||
|
||||
|
|
@ -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 ---
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue