merge: M5b — optional YouTube write scope (incremental OAuth)
This commit is contained in:
commit
7c04bc1ee8
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.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 {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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 {},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 & 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">
|
||||||
</p>
|
<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>
|
</Section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 ---
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue