Merge onboarding wizard, scope split, hardening, legal pages, and deploy setup
Some checks failed
CI / frontend (push) Failing after 2m16s
CI / backend (push) Failing after 1m19s

This commit is contained in:
npeter83 2026-06-14 01:39:43 +02:00
commit cbd0234a8d
20 changed files with 808 additions and 52 deletions

View file

@ -9,6 +9,8 @@ POSTGRES_DB=subfeed
APP_PORT=8080 APP_PORT=8080
# Session signing key. Generate with: python -c "import secrets;print(secrets.token_urlsafe(48))" # Session signing key. Generate with: python -c "import secrets;print(secrets.token_urlsafe(48))"
# In production (an https OAUTH_REDIRECT_URL) the app refuses to start with this placeholder
# or any key shorter than 32 chars — a known signing key would let anyone forge a session.
SECRET_KEY=change-me-session-key SECRET_KEY=change-me-session-key
# Fernet key for encrypting stored OAuth refresh tokens. Generate with: # Fernet key for encrypting stored OAuth refresh tokens. Generate with:

35
.forgejo/workflows/ci.yml Normal file
View file

@ -0,0 +1,35 @@
name: CI
# Lightweight validation on every push to main: type-check + build the frontend and
# byte-compile the backend. The image build and rollout happen on the asgard host
# (see deploy/README.md); this just catches breakage early.
on:
push:
branches: [main]
workflow_dispatch:
jobs:
frontend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
- run: npm ci
working-directory: frontend
- run: npx tsc --noEmit
working-directory: frontend
- run: npm run build
working-directory: frontend
backend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install -r requirements.txt
working-directory: backend
- run: python -m compileall -q app
working-directory: backend

View file

@ -16,12 +16,19 @@ from app.security import encrypt
_EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$") _EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
# YouTube's full scope (read + write: unsubscribe, playlist export) and its read-only # Base sign-in requests only the non-sensitive OpenID scopes (profile/email). These do
# counterpart. Default login asks for read-only; write is an explicit opt-in via # NOT trigger Google's "unverified app" warning and do NOT expire after 7 days, so a new
# /auth/upgrade (incremental consent), so friends who won't grant write can still browse. # user gets a clean, familiar consent. YouTube access is granted later, one step at a
# time, by the onboarding wizard via /auth/upgrade (incremental authorization):
# - read -> youtube.readonly (read subscriptions, build the feed)
# - write -> youtube (full: unsubscribe / playlist export)
# Both YouTube scopes are "sensitive"; the wizard explains the Google warning before
# sending the user there, so the extra permission never feels like a surprise.
WRITE_SCOPE = "https://www.googleapis.com/auth/youtube" WRITE_SCOPE = "https://www.googleapis.com/auth/youtube"
READ_SCOPES = f"openid email profile {WRITE_SCOPE}.readonly" READ_SCOPE = f"{WRITE_SCOPE}.readonly"
WRITE_SCOPES = f"openid email profile {WRITE_SCOPE}" BASE_SCOPES = "openid email profile"
READ_SCOPES = f"{BASE_SCOPES} {READ_SCOPE}"
WRITE_SCOPES = f"{BASE_SCOPES} {READ_SCOPE} {WRITE_SCOPE}"
log = logging.getLogger("subfeed.auth") log = logging.getLogger("subfeed.auth")
@ -33,10 +40,20 @@ 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": READ_SCOPES}, client_kwargs={"scope": BASE_SCOPES},
) )
def has_read_scope(user: User) -> bool:
"""Whether the user's stored grant lets us read their YouTube data (build the feed).
The full write scope is a superset, so it implies read."""
tok = user.token
if tok is None or not tok.scopes:
return False
granted = tok.scopes.split()
return READ_SCOPE in granted or WRITE_SCOPE in granted
def has_write_scope(user: User) -> bool: def has_write_scope(user: User) -> bool:
"""Whether the user's stored grant includes YouTube write (unsubscribe / export).""" """Whether the user's stored grant includes YouTube write (unsubscribe / export)."""
tok = user.token tok = user.token
@ -91,7 +108,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, scope=BASE_SCOPES,
) )
@ -145,7 +162,9 @@ async def callback(
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 READ_SCOPES # include_granted_scopes=true means Google returns the union of all scopes the user
# has ever granted this app, so this correctly reflects read/write upgrades too.
tok.scopes = token.get("scope") or BASE_SCOPES
db.add(tok) db.add(tok)
db.commit() db.commit()
@ -193,16 +212,21 @@ def current_user(request: Request, db: Session = Depends(get_db)) -> User:
@router.get("/upgrade") @router.get("/upgrade")
async def upgrade(request: Request, user: User = Depends(current_user)): async def upgrade(
"""Incremental consent: re-authorize with the full YouTube (write) scope. The shared request: Request, access: str = "write", user: User = Depends(current_user)
callback stores the new scope set, so `can_write` flips on once Google grants it.""" ):
"""Incremental consent for the onboarding wizard. `access=read` grants YouTube
read-only (enough to build the feed); `access=write` additionally grants management
(unsubscribe). The shared callback stores whatever Google returns, so `can_read` /
`can_write` flip on once granted."""
scope = READ_SCOPES if access == "read" else WRITE_SCOPES
return await oauth.google.authorize_redirect( return await oauth.google.authorize_redirect(
request, request,
settings.oauth_redirect_url, settings.oauth_redirect_url,
access_type="offline", access_type="offline",
prompt="consent", prompt="consent",
include_granted_scopes="true", include_granted_scopes="true",
scope=WRITE_SCOPES, scope=scope,
) )

View file

@ -1,5 +1,9 @@
from pydantic import model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic_settings import BaseSettings, SettingsConfigDict
# Shipped placeholder; production must override it. Used by the startup guard below.
_DEFAULT_SECRET_KEY = "change-me-session-key"
class Settings(BaseSettings): class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", extra="ignore") model_config = SettingsConfigDict(env_file=".env", extra="ignore")
@ -9,7 +13,7 @@ class Settings(BaseSettings):
database_url: str = "postgresql+psycopg://subfeed:subfeed@db:5432/subfeed" database_url: str = "postgresql+psycopg://subfeed:subfeed@db:5432/subfeed"
# Session cookie signing key. # Session cookie signing key.
secret_key: str = "change-me-session-key" secret_key: str = _DEFAULT_SECRET_KEY
# Fernet key (urlsafe base64, 32 bytes) for encrypting stored refresh tokens. # Fernet key (urlsafe base64, 32 bytes) for encrypting stored refresh tokens.
token_encryption_key: str = "" token_encryption_key: str = ""
@ -77,5 +81,32 @@ class Settings(BaseSettings):
def admin_email_set(self) -> set[str]: def admin_email_set(self) -> set[str]:
return {e.strip().lower() for e in self.admin_emails.split(",") if e.strip()} return {e.strip().lower() for e in self.admin_emails.split(",") if e.strip()}
@property
def session_https_only(self) -> bool:
"""Mark the session cookie Secure when we're served over HTTPS. We treat an
https OAUTH_REDIRECT_URL as the signal for 'real deployment' vs local dev."""
return self.oauth_redirect_url.lower().startswith("https://")
@model_validator(mode="after")
def _enforce_production_secrets(self) -> "Settings":
"""Fail fast rather than boot a public instance with the shipped placeholder
signing key (which would let anyone forge an admin session) or without token
encryption. Local dev (http redirect URL) keeps the convenient defaults."""
if not self.session_https_only:
return self # local/dev: allow the placeholder defaults
if self.secret_key == _DEFAULT_SECRET_KEY or len(self.secret_key) < 32:
raise ValueError(
"SECRET_KEY must be a unique random value of at least 32 chars in "
"production (an https OAUTH_REDIRECT_URL was detected). Generate one "
'with: python -c "import secrets; print(secrets.token_urlsafe(48))"'
)
if not self.token_encryption_key:
raise ValueError(
"TOKEN_ENCRYPTION_KEY must be set in production so refresh tokens are "
"encrypted at rest. Generate one with: "
'python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"'
)
return self
settings = Settings() settings = Settings()

View file

@ -46,8 +46,8 @@ app = FastAPI(title=settings.app_name, lifespan=lifespan)
app.add_middleware( app.add_middleware(
SessionMiddleware, SessionMiddleware,
secret_key=settings.secret_key, secret_key=settings.secret_key,
same_site="lax", same_site="lax", # required so the cookie rides the OAuth redirect back from Google
https_only=False, https_only=settings.session_https_only, # Secure flag when served over HTTPS (prod)
) )
if settings.frontend_origin: if settings.frontend_origin:

View file

@ -2,7 +2,7 @@ from fastapi import APIRouter, Depends
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.auth import current_user, has_write_scope from app.auth import current_user, has_read_scope, has_write_scope
from app.db import get_db from app.db import get_db
from app.models import Invite, User from app.models import Invite, User
@ -29,6 +29,7 @@ def get_me(
"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_read": has_read_scope(user),
"can_write": has_write_scope(user), "can_write": has_write_scope(user),
"pending_invites": pending_invites, "pending_invites": pending_invites,
"preferences": user.preferences or {}, "preferences": user.preferences or {},

View file

@ -3,7 +3,7 @@ from sqlalchemy import and_, case, func, select, update
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app import quota, state from app import quota, state
from app.auth import current_user from app.auth import current_user, has_read_scope
from app.db import get_db from app.db import get_db
from app.models import Channel, Subscription, User, Video from app.models import Channel, Subscription, User, Video
from app.sync.runner import ( from app.sync.runner import (
@ -33,6 +33,12 @@ def _user_channels(db: Session, user: User) -> list[Channel]:
def sync_subscriptions( def sync_subscriptions(
user: User = Depends(current_user), db: Session = Depends(get_db) user: User = Depends(current_user), db: Session = Depends(get_db)
) -> dict: ) -> dict:
if not has_read_scope(user):
# No YouTube read grant yet — the onboarding wizard hasn't been completed.
raise HTTPException(
status_code=403,
detail="Connect your YouTube account (read access) to import subscriptions.",
)
before = quota.units_used_today(db) before = quota.units_used_today(db)
with quota.attribute(user.id, "sync_subscriptions"): with quota.attribute(user.id, "sync_subscriptions"):
result = import_subscriptions(db, user) result = import_subscriptions(db, user)

49
deploy/README.md Normal file
View file

@ -0,0 +1,49 @@
# Deploying Subfeed to the public VPS (asgard)
The public test instance runs on the asgard VPS at **https://subfeed.b1fr0st.eu**, behind
Caddy (which terminates TLS and reverse-proxies to the app on `127.0.0.1:8080`).
## Layout on asgard
```
/srv/subfeed/
src/ git clone of this repo (build context + compose file)
.env secrets, mode 0600 — NEVER committed
```
The hardened compose is [`docker-compose.prod.yml`](../docker-compose.prod.yml): Postgres is
never published, the app binds to localhost only, and every service is memory/CPU-capped for
the small VPS.
## First-time setup
1. DNS `A`/`AAAA` for `subfeed` → asgard (done via the porkbun CLI).
2. Caddy vhost block `subfeed.b1fr0st.eu { reverse_proxy 127.0.0.1:8080 }` (+ the shared
security-headers snippet), then reload Caddy.
3. `git clone` this repo to `/srv/subfeed/src`.
4. Create `/srv/subfeed/.env` (mode 0600) with the required keys — see
[`.env.example`](../.env.example). Generate the secrets:
- `SECRET_KEY`: `python -c "import secrets; print(secrets.token_urlsafe(48))"`
- `TOKEN_ENCRYPTION_KEY`: `python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"`
- `POSTGRES_PASSWORD`: any long random string.
- `OAUTH_REDIRECT_URL=https://subfeed.b1fr0st.eu/auth/callback`
- `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` / `YOUTUBE_API_KEY` from Google Cloud Console.
- `ALLOWED_EMAILS` / `ADMIN_EMAILS` = the invited Google accounts.
> The app refuses to start in production (an `https` redirect URL) if `SECRET_KEY` is the
> placeholder/too short or `TOKEN_ENCRYPTION_KEY` is missing — by design.
## Rolling out a new version
```bash
/srv/subfeed/src/deploy/deploy.sh
```
It pulls `main`, rebuilds the image on the host, and restarts the stack (migrations run
automatically via the entrypoint's `alembic upgrade head`).
## CI
`.forgejo/workflows/ci.yml` type-checks/builds the frontend and byte-compiles the backend on
every push to `main`. It does not auto-deploy; rollout is the script above. (A future
improvement: a Forgejo-triggered or watchtower-based auto-rollout.)

20
deploy/deploy.sh Executable file
View file

@ -0,0 +1,20 @@
#!/usr/bin/env bash
# Roll out the latest main branch to the public asgard instance.
#
# Layout on asgard:
# /srv/subfeed/src git clone of this repo (build context + compose file)
# /srv/subfeed/.env secrets, mode 0600 (never in git)
#
# Build runs on the host Docker daemon, so it isn't constrained by the CI runner's memory.
set -euo pipefail
ROOT=/srv/subfeed
cd "$ROOT/src"
git fetch --quiet origin
git checkout --quiet main
git pull --ff-only --quiet
docker compose -f docker-compose.prod.yml --env-file "$ROOT/.env" up -d --build
docker image prune -f >/dev/null || true
docker compose -f docker-compose.prod.yml --env-file "$ROOT/.env" ps

76
docker-compose.prod.yml Normal file
View file

@ -0,0 +1,76 @@
# Public VPS (asgard) deployment — hardened, single-host, behind Caddy.
#
# Differs from docker-compose.server.yml (the home/LXC stack): the database is NEVER
# published, the app binds to 127.0.0.1 only (Caddy terminates TLS and reverse-proxies it),
# there is no apparmor:unconfined (this is a bare VPS, not Docker-in-LXC), and every service
# has a hard memory/CPU cap so the stack can't exhaust the small VPS.
#
# Secrets live OUTSIDE the repo, in an env file on the host. Deploy with:
# docker compose -f docker-compose.prod.yml --env-file /srv/subfeed/.env up -d --build
#
# Required keys in that env file: POSTGRES_PASSWORD, SECRET_KEY (>=32 chars),
# TOKEN_ENCRYPTION_KEY (Fernet), GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET,
# OAUTH_REDIRECT_URL=https://subfeed.b1fr0st.eu/auth/callback, ALLOWED_EMAILS, ADMIN_EMAILS.
services:
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: ${POSTGRES_USER:-subfeed}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD must be set in the env file}
POSTGRES_DB: ${POSTGRES_DB:-subfeed}
volumes:
- subfeed_pgdata:/var/lib/postgresql/data
networks: [internal]
mem_limit: 512m
cpus: 0.75
security_opt:
- no-new-privileges:true
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-subfeed} -d ${POSTGRES_DB:-subfeed}"]
interval: 10s
timeout: 5s
retries: 12
restart: unless-stopped
api:
build:
context: .
dockerfile: Dockerfile
image: subfeed-api:local
env_file:
- /srv/subfeed/.env
environment:
DATABASE_URL: postgresql+psycopg://${POSTGRES_USER:-subfeed}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-subfeed}
# The public instance owns the background scheduler (single writer).
SCHEDULER_ENABLED: "true"
depends_on:
db:
condition: service_healthy
networks: [internal]
ports:
# Localhost only — Caddy (host network) reverse-proxies 443 -> here. Never public.
- "127.0.0.1:8080:8000"
mem_limit: 768m
cpus: 1.0
pids_limit: 256
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
read_only: true
tmpfs:
- /tmp
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://localhost:8000/healthz').status==200 else 1)"]
interval: 15s
timeout: 5s
retries: 5
start_period: 30s
restart: unless-stopped
volumes:
subfeed_pgdata:
networks:
internal:

View file

@ -24,6 +24,8 @@ import Feed from "./components/Feed";
import Channels from "./components/Channels"; import Channels from "./components/Channels";
import Stats from "./components/Stats"; import Stats from "./components/Stats";
import SettingsPanel from "./components/SettingsPanel"; import SettingsPanel from "./components/SettingsPanel";
import OnboardingWizard from "./components/OnboardingWizard";
import { shouldAutoOpenOnboarding } from "./lib/onboarding";
import Toaster from "./components/Toaster"; import Toaster from "./components/Toaster";
const DEFAULT_FILTERS: FeedFilters = { const DEFAULT_FILTERS: FeedFilters = {
@ -61,6 +63,7 @@ export default function App() {
const [sidebarLayout, setSidebarLayoutState] = useState<SidebarLayout>(loadLayout); const [sidebarLayout, setSidebarLayoutState] = useState<SidebarLayout>(loadLayout);
const [page, setPageState] = useState<Page>(readPage); const [page, setPageState] = useState<Page>(readPage);
const [settingsOpen, setSettingsOpen] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false);
const [wizardOpen, setWizardOpen] = useState(false);
function setFilters(next: FeedFilters) { function setFilters(next: FeedFilters) {
setFiltersState(next); setFiltersState(next);
@ -86,6 +89,13 @@ export default function App() {
const meQuery = useQuery({ queryKey: ["me"], queryFn: api.me }); const meQuery = useQuery({ queryKey: ["me"], queryFn: api.me });
// First-login onboarding: prompt the user to connect YouTube (and resume the flow after
// each consent redirect). Derived from granted scopes + storage flags so it's stable
// across the full-page OAuth round-trip; dismissible and reopenable from Settings.
useEffect(() => {
if (meQuery.data && shouldAutoOpenOnboarding(meQuery.data)) setWizardOpen(true);
}, [meQuery.data?.id, meQuery.data?.can_read, meQuery.data?.can_write]);
// On login, adopt server-stored preferences. // On login, adopt server-stored preferences.
useEffect(() => { useEffect(() => {
const prefs = meQuery.data?.preferences; const prefs = meQuery.data?.preferences;
@ -181,8 +191,15 @@ export default function App() {
view={view} view={view}
setView={changeView} setView={changeView}
onClose={() => setSettingsOpen(false)} onClose={() => setSettingsOpen(false)}
onOpenWizard={() => {
setSettingsOpen(false);
setWizardOpen(true);
}}
/> />
)} )}
{wizardOpen && meQuery.data && (
<OnboardingWizard me={meQuery.data} onClose={() => setWizardOpen(false)} />
)}
<Toaster /> <Toaster />
</div> </div>
); );

View file

@ -35,9 +35,9 @@ export default function Login() {
Sub<span className="text-accent">feed</span> Sub<span className="text-accent">feed</span>
</div> </div>
<p className="text-muted my-5 leading-relaxed"> <p className="text-muted my-5 leading-relaxed">
Your subscriptions, filtered your way. A self-hosted, filterable feed of your YouTube subscriptions sort, tag, and
<br /> tune out the noise. Sign in with Google to get started; YouTube access is an
Sign in with your Google account. optional, separate step you control.
</p> </p>
<a <a
href="/auth/login" href="/auth/login"
@ -87,6 +87,10 @@ export default function Login() {
)} )}
</div> </div>
</div> </div>
<footer className="mt-5 flex gap-4 text-xs text-muted">
<a href="/privacy" className="hover:text-fg transition">Privacy Policy</a>
<a href="/terms" className="hover:text-fg transition">Terms of Service</a>
</footer>
</div> </div>
); );
} }

View file

@ -0,0 +1,176 @@
import { useEffect } from "react";
import { AlertTriangle, Check, ShieldCheck, X, Youtube } from "lucide-react";
import type { Me } from "../lib/api";
import { beginGrant, endOnboarding } from "../lib/onboarding";
// A first-login wizard that walks the user through granting YouTube access one scope at a
// time, each with a plain explanation and an up-front heads-up about Google's "unverified
// app" screen. Sign-in itself only asks for name/email (no warning); YouTube access is
// always an explicit, informed opt-in here.
//
// The visible step is derived from what's already granted, so the wizard resumes correctly
// after each redirect: no read -> "read", read but no write -> "write", both -> "done".
// A small reusable heads-up so the Google consent warning never feels like a surprise.
function ConsentHeadsUp() {
return (
<div className="flex gap-2.5 rounded-xl border border-amber-400/30 bg-amber-400/5 p-3 text-left">
<AlertTriangle className="w-4 h-4 shrink-0 mt-0.5 text-amber-400" />
<p className="text-xs leading-relaxed text-fg/80">
Google will show a <span className="font-medium">"Google hasn't verified this app"</span>{" "}
screen that's expected, because Subfeed hasn't gone through Google's full review.
It's safe to continue: click <span className="font-medium">Advanced</span> {" "}
<span className="font-medium">Go to Subfeed</span>. You can revoke access anytime from
your{" "}
<a
href="https://myaccount.google.com/permissions"
target="_blank"
rel="noreferrer"
className="text-accent hover:underline"
>
Google Account
</a>
.
</p>
</div>
);
}
function Dots({ index, total }: { index: number; total: number }) {
return (
<div className="flex justify-center gap-1.5">
{Array.from({ length: total }).map((_, i) => (
<span
key={i}
className={`h-1.5 rounded-full transition-all ${
i === index ? "w-5 bg-accent" : "w-1.5 bg-border"
}`}
/>
))}
</div>
);
}
export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: () => void }) {
// Closing without finishing the essential read step suppresses future auto-popups
// (it stays reachable from Settings). Once read is granted, closing is just "done".
const close = () => {
endOnboarding(!me.can_read);
onClose();
};
useEffect(() => {
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") close();
}
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [me.can_read]);
const step = !me.can_read ? "read" : !me.can_write ? "write" : "done";
const stepIndex = step === "read" ? 0 : step === "write" ? 1 : 2;
return (
<div className="fixed inset-0 z-50 grid place-items-center p-4">
<div className="absolute inset-0 bg-black/50 animate-[overlayIn_0.2s_ease]" onClick={close} />
<div className="glass relative w-[min(94vw,460px)] rounded-2xl p-7 text-center animate-[panelIn_0.26s_cubic-bezier(0.22,1,0.36,1)]">
<button
onClick={close}
className="absolute top-3 right-3 p-2 rounded-lg text-muted hover:text-fg hover:bg-card/60 transition"
aria-label="Close"
>
<X className="w-4 h-4" />
</button>
{step === "read" && (
<>
<div className="mx-auto mb-4 grid place-items-center w-12 h-12 rounded-2xl bg-accent/15 text-accent">
<Youtube className="w-6 h-6" />
</div>
<h2 className="text-lg font-semibold">Connect your YouTube subscriptions</h2>
<p className="text-sm text-muted leading-relaxed mt-2 mb-4">
You're signed in. To build your feed, Subfeed needs{" "}
<span className="text-fg/90">read-only</span> access to the channels you're
subscribed to on YouTube. It never posts, deletes, or changes anything with this.
</p>
<ConsentHeadsUp />
<div className="mt-5 flex flex-col gap-2">
<button
onClick={() => beginGrant("read")}
className="inline-flex items-center justify-center gap-2 px-5 py-3 rounded-xl font-semibold bg-accent text-accent-fg hover:opacity-90 transition"
>
<Youtube className="w-4 h-4" /> Connect (read-only)
</button>
<button
onClick={close}
className="px-5 py-2 rounded-xl text-sm text-muted hover:text-fg transition"
>
Skip for now
</button>
</div>
</>
)}
{step === "write" && (
<>
<div className="mx-auto mb-4 grid place-items-center w-12 h-12 rounded-2xl bg-accent/15 text-accent">
<Check className="w-6 h-6" />
</div>
<h2 className="text-lg font-semibold">You're connected</h2>
<p className="text-sm text-muted leading-relaxed mt-2 mb-4">
Your feed is ready. Optionally, you can let Subfeed{" "}
<span className="text-fg/90">unsubscribe</span> from channels on YouTube for you
this needs an extra write permission. Skip it to stay read-only; you can always
enable it later in Settings.
</p>
<ConsentHeadsUp />
<div className="mt-5 flex flex-col gap-2">
<button
onClick={() => beginGrant("write")}
className="inline-flex items-center justify-center gap-2 px-5 py-3 rounded-xl font-semibold bg-accent text-accent-fg hover:opacity-90 transition"
>
<ShieldCheck className="w-4 h-4" /> Enable unsubscribe
</button>
<button
onClick={() => {
endOnboarding(false);
onClose();
}}
className="px-5 py-2 rounded-xl text-sm text-muted hover:text-fg transition"
>
No thanks keep it read-only
</button>
</div>
</>
)}
{step === "done" && (
<>
<div className="mx-auto mb-4 grid place-items-center w-12 h-12 rounded-2xl bg-accent/15 text-accent">
<Check className="w-6 h-6" />
</div>
<h2 className="text-lg font-semibold">All set</h2>
<p className="text-sm text-muted leading-relaxed mt-2 mb-5">
Read and unsubscribe access are both granted. You can review or revoke either one
anytime in Settings Account, or from your Google Account.
</p>
<button
onClick={() => {
endOnboarding(false);
onClose();
}}
className="inline-flex items-center justify-center gap-2 px-5 py-3 rounded-xl font-semibold bg-accent text-accent-fg hover:opacity-90 transition w-full"
>
Done
</button>
</>
)}
<div className="mt-6">
<Dots index={stepIndex} total={3} />
</div>
</div>
</div>
);
}

View file

@ -29,6 +29,7 @@ export default function SettingsPanel({
view, view,
setView, setView,
onClose, onClose,
onOpenWizard,
}: { }: {
me: Me; me: Me;
theme: ThemePrefs; theme: ThemePrefs;
@ -36,6 +37,7 @@ export default function SettingsPanel({
view: "grid" | "list"; view: "grid" | "list";
setView: (v: "grid" | "list") => void; setView: (v: "grid" | "list") => void;
onClose: () => void; onClose: () => void;
onOpenWizard: () => void;
}) { }) {
const [tab, setTab] = useState<TabId>("appearance"); const [tab, setTab] = useState<TabId>("appearance");
const [closing, setClosing] = useState(false); const [closing, setClosing] = useState(false);
@ -115,7 +117,7 @@ export default function SettingsPanel({
)} )}
{t.id === "notifications" && <Notifications />} {t.id === "notifications" && <Notifications />}
{t.id === "sync" && <Sync me={me} />} {t.id === "sync" && <Sync me={me} />}
{t.id === "account" && <Account me={me} />} {t.id === "account" && <Account me={me} onOpenWizard={onOpenWizard} />}
</div> </div>
))} ))}
</div> </div>
@ -464,7 +466,46 @@ function Sync({ me }: { me: Me }) {
); );
} }
function Account({ me }: { me: Me }) { function AccessRow({
title,
granted,
grantedHint,
enableHint,
onEnable,
}: {
title: string;
granted: boolean;
grantedHint: string;
enableHint: string;
onEnable: () => void;
}) {
return (
<div className="flex items-start justify-between gap-3 py-2">
<div className="min-w-0">
<div className="text-sm font-medium">{title}</div>
<p className="text-xs text-muted leading-relaxed mt-0.5">
{granted ? grantedHint : enableHint}
</p>
</div>
{granted ? (
<span className="shrink-0 text-[11px] px-2 py-1 rounded-full border border-accent/40 text-accent">
Granted
</span>
) : (
<Tooltip hint="Redirects to Google to grant the permission. Google shows an 'unverified app' notice — that's expected; click Advanced → Go to Subfeed.">
<button
onClick={onEnable}
className="shrink-0 glass-card glass-hover px-3 py-1.5 rounded-xl text-sm transition"
>
Enable
</button>
</Tooltip>
)}
</div>
);
}
function Account({ me, onOpenWizard }: { me: Me; onOpenWizard: () => void }) {
return ( return (
<> <>
<Section title="Account"> <Section title="Account">
@ -480,34 +521,39 @@ 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>
<div className="mt-1 pt-3 border-t border-border"> </Section>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0"> <Section title="YouTube access">
<div className="text-sm font-medium">Playlist editing &amp; YouTube export</div> <p className="text-xs text-muted leading-relaxed mb-1">
<p className="text-xs text-muted leading-relaxed mt-0.5"> Sign-in only shares your name and email. YouTube access is granted separately, below
{me.can_write each is optional and revocable anytime in your Google Account.
? "Granted. You can unsubscribe from channels on YouTube (and export playlists once that ships). Subfeed only writes when you ask it to." </p>
: "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."} <div className="divide-y divide-border">
</p> <AccessRow
</div> title="Read subscriptions (your feed)"
{me.can_write ? ( granted={me.can_read}
<span className="shrink-0 text-[11px] px-2 py-1 rounded-full border border-accent/40 text-accent"> grantedHint="Granted. Subfeed reads the channels you follow to build your feed. It never changes your YouTube account with this."
Enabled enableHint="Read-only access to your subscriptions — required to build your feed. Subfeed can't modify anything with it."
</span> onEnable={() => {
) : ( window.location.href = "/auth/upgrade?access=read";
<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={() => { <AccessRow
window.location.href = "/auth/upgrade"; title="Unsubscribe (write)"
}} granted={me.can_write}
className="shrink-0 glass-card glass-hover px-3 py-1.5 rounded-xl text-sm transition" grantedHint="Granted. You can unsubscribe from channels on YouTube from inside Subfeed. It only writes when you ask it to."
> enableHint="Lets Subfeed unsubscribe from channels on YouTube for you. Optional — skip it to stay read-only."
Enable onEnable={() => {
</button> window.location.href = "/auth/upgrade?access=write";
</Tooltip> }}
)} />
</div>
</div> </div>
<button
onClick={onOpenWizard}
className="mt-2 text-sm text-accent hover:underline"
>
Walk me through setup
</button>
</Section> </Section>
{me.role === "admin" && <AdminInvites />} {me.role === "admin" && <AdminInvites />}
</> </>

View file

@ -0,0 +1,47 @@
// Shared shell for the public, login-free legal pages (/privacy, /terms). These must be
// reachable unauthenticated — Google's OAuth consent screen links to them and reviewers
// fetch them directly — so they render outside the authenticated App tree (see main.tsx).
export const CONTACT_EMAIL = "b1fr0stmailops@gmail.com";
export const LAST_UPDATED = "June 14, 2026";
export function LegalLink({ href, children }: { href: string; children: React.ReactNode }) {
return (
<a href={href} target="_blank" rel="noreferrer" className="text-accent hover:underline">
{children}
</a>
);
}
export default function LegalLayout({
title,
children,
}: {
title: string;
children: React.ReactNode;
}) {
return (
<div className="min-h-screen bg-bg text-fg">
<div className="mx-auto w-[min(92vw,720px)] py-12">
<a href="/" className="inline-block text-2xl font-bold tracking-tight mb-8">
Sub<span className="text-accent">feed</span>
</a>
<div className="glass rounded-2xl p-8">
<h1 className="text-2xl font-semibold">{title}</h1>
<p className="text-xs text-muted mt-1 mb-6">Last updated: {LAST_UPDATED}</p>
<div className="space-y-5 text-sm leading-relaxed text-fg/90">{children}</div>
</div>
<footer className="mt-6 flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted">
<a href="/" className="hover:text-fg transition">Home</a>
<a href="/privacy" className="hover:text-fg transition">Privacy Policy</a>
<a href="/terms" className="hover:text-fg transition">Terms of Service</a>
<a href={`mailto:${CONTACT_EMAIL}`} className="hover:text-fg transition">Contact</a>
</footer>
</div>
</div>
);
}
export function H2({ children }: { children: React.ReactNode }) {
return <h2 className="text-base font-semibold text-fg pt-2">{children}</h2>;
}

View file

@ -0,0 +1,111 @@
import LegalLayout, { CONTACT_EMAIL, H2, LegalLink } from "./LegalLayout";
export default function PrivacyPolicy() {
return (
<LegalLayout title="Privacy Policy">
<p>
Subfeed is a personal, non-commercial project operated by an individual in Hungary
("we", "us"). It lets you view and organize your own YouTube subscriptions as a
filterable feed. This policy explains what data Subfeed accesses, how it is used, and
the choices you have. By using Subfeed you agree to this policy.
</p>
<H2>What we access</H2>
<p>Subfeed only accesses data you explicitly authorize, in two separate steps:</p>
<ul className="list-disc pl-5 space-y-1">
<li>
<span className="text-fg font-medium">Sign-in (always):</span> your basic Google
profile name, email address, profile picture, and Google account identifier used
solely to create and identify your account.
</li>
<li>
<span className="text-fg font-medium">YouTube read (optional, opt-in):</span> with the{" "}
<code className="text-xs">youtube.readonly</code> scope, your list of YouTube
subscriptions and the public metadata of those channels and their videos (titles,
thumbnails, durations, view counts, publish dates), used to build your feed.
</li>
<li>
<span className="text-fg font-medium">YouTube manage (optional, opt-in):</span> with
the <code className="text-xs">youtube</code> scope, the ability to unsubscribe from a
channel on YouTube performed only when you click to do so.
</li>
</ul>
<p>
You can use Subfeed with sign-in alone, and grant or decline each YouTube permission
independently.
</p>
<H2>How we use it</H2>
<p>
Your data is used exclusively to provide Subfeed's features to you: building and
displaying your personalized subscription feed, and carrying out actions (such as
unsubscribing) that you initiate. We do not use it for advertising, profiling, or any
purpose unrelated to the app.
</p>
<H2>Limited Use disclosure</H2>
<p>
Subfeed's use and transfer of information received from Google APIs to any other app
will adhere to the{" "}
<LegalLink href="https://developers.google.com/terms/api-services-user-data-policy">
Google API Services User Data Policy
</LegalLink>
, including the Limited Use requirements. We do not sell your data, do not transfer it
to third parties (except as needed to operate the app, e.g. the hosting infrastructure
we control), and do not use it for advertising.
</p>
<H2>Storage &amp; security</H2>
<p>
Data is stored in a private PostgreSQL database on a server we control, accessible only
to the operator. Google OAuth refresh tokens are encrypted at rest. Access is restricted
to the emails explicitly invited to the app. No system is perfectly secure, but we take
reasonable measures to protect your data.
</p>
<H2>Retention &amp; deletion</H2>
<p>
We keep your data while your account is active. You can revoke Subfeed's access to your
Google data at any time at{" "}
<LegalLink href="https://myaccount.google.com/permissions">
myaccount.google.com/permissions
</LegalLink>
; once revoked, the stored tokens can no longer be used. To have your account and
associated data deleted, email{" "}
<a href={`mailto:${CONTACT_EMAIL}`} className="text-accent hover:underline">
{CONTACT_EMAIL}
</a>
.
</p>
<H2>Cookies</H2>
<p>
Subfeed sets a single, signed session cookie to keep you logged in. It is not used for
tracking or advertising, and there are no third-party analytics cookies.
</p>
<H2>YouTube &amp; Google</H2>
<p>
Subfeed uses YouTube API Services. By using Subfeed you also agree to the{" "}
<LegalLink href="https://www.youtube.com/t/terms">YouTube Terms of Service</LegalLink>,
and your use of Google data is subject to the{" "}
<LegalLink href="https://policies.google.com/privacy">Google Privacy Policy</LegalLink>.
</p>
<H2>Changes</H2>
<p>
We may update this policy; material changes will be reflected by the "Last updated" date
above. Continued use after a change constitutes acceptance.
</p>
<H2>Contact</H2>
<p>
Questions about this policy or your data:{" "}
<a href={`mailto:${CONTACT_EMAIL}`} className="text-accent hover:underline">
{CONTACT_EMAIL}
</a>
.
</p>
</LegalLayout>
);
}

View file

@ -0,0 +1,67 @@
import LegalLayout, { CONTACT_EMAIL, H2, LegalLink } from "./LegalLayout";
export default function Terms() {
return (
<LegalLayout title="Terms of Service">
<p>
Subfeed is a personal, non-commercial project operated by an individual in Hungary. It
provides a filterable view of your own YouTube subscriptions. By accessing or using
Subfeed, you agree to these Terms. If you do not agree, do not use the service.
</p>
<H2>The service</H2>
<p>
Subfeed is offered on an invite-only basis, free of charge, and "as is", without
warranties of any kind. It is a hobby project: features may change, and the service may
be slowed, interrupted, or discontinued at any time without notice.
</p>
<H2>Your account &amp; acceptable use</H2>
<p>
You sign in with your Google account and may only access Subfeed with an email that has
been invited. You agree to use the service lawfully, not to abuse, disrupt, probe, or
overload it, and not to attempt to access other users' data. We may suspend or revoke
access at our discretion, for example in case of misuse.
</p>
<H2>Third-party services</H2>
<p>
Subfeed uses YouTube API Services. By using Subfeed you agree to be bound by the{" "}
<LegalLink href="https://www.youtube.com/t/terms">YouTube Terms of Service</LegalLink>.
Your data is handled as described in our{" "}
<a href="/privacy" className="text-accent hover:underline">Privacy Policy</a> and, for
Google data, the{" "}
<LegalLink href="https://policies.google.com/privacy">Google Privacy Policy</LegalLink>.
</p>
<H2>No warranty &amp; limitation of liability</H2>
<p>
To the maximum extent permitted by law, the service is provided without warranty, and
the operator is not liable for any indirect, incidental, or consequential damages, or
for any loss of data, arising from your use of Subfeed. Your sole remedy if you are
dissatisfied is to stop using the service and revoke its access to your account.
</p>
<H2>Governing law</H2>
<p>
These Terms are governed by the laws of Hungary and applicable European Union law,
without regard to conflict-of-law rules.
</p>
<H2>Changes</H2>
<p>
We may update these Terms; the "Last updated" date above reflects the latest version.
Continued use after a change constitutes acceptance.
</p>
<H2>Contact</H2>
<p>
Questions:{" "}
<a href={`mailto:${CONTACT_EMAIL}`} className="text-accent hover:underline">
{CONTACT_EMAIL}
</a>
.
</p>
</LegalLayout>
);
}

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_read: boolean;
can_write: boolean; can_write: boolean;
pending_invites: number; pending_invites: number;
preferences: Record<string, any>; preferences: Record<string, any>;

View file

@ -0,0 +1,33 @@
// Onboarding wizard visibility, kept stable across the OAuth redirect round-trip.
//
// Granting a YouTube scope navigates away to Google's consent screen and back, which
// fully reloads the SPA — so we can't hold the wizard's progress in React state. Instead
// we derive the current step from the granted scopes (me.can_read / me.can_write) and use
// two storage flags to decide whether to auto-open:
//
// ONBOARD_ACTIVE (sessionStorage) — set while the user is mid-flow (e.g. just clicked
// "Connect"); makes the wizard reopen after the
// redirect so they land on the next step.
// ONBOARD_DISMISSED (localStorage) — set when the user skips the essential read step;
// suppresses the auto-popup on future logins (they
// can still reopen it from Settings → Account).
export const ONBOARD_ACTIVE = "subfeed.onboarding.active";
export const ONBOARD_DISMISSED = "subfeed.onboarding.dismissed";
export function shouldAutoOpenOnboarding(me: { can_read: boolean }): boolean {
if (sessionStorage.getItem(ONBOARD_ACTIVE)) return true;
return !me.can_read && !localStorage.getItem(ONBOARD_DISMISSED);
}
/** Mark a grant as in progress, then send the user to Google's consent screen. */
export function beginGrant(access: "read" | "write"): void {
sessionStorage.setItem(ONBOARD_ACTIVE, "1");
window.location.href = `/auth/upgrade?access=${access}`;
}
/** Leave the wizard. `dismiss` suppresses the future auto-popup (used when read is skipped). */
export function endOnboarding(dismiss: boolean): void {
sessionStorage.removeItem(ONBOARD_ACTIVE);
if (dismiss) localStorage.setItem(ONBOARD_DISMISSED, "1");
}

View file

@ -3,18 +3,28 @@ import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import App from "./App"; import App from "./App";
import ErrorBoundary from "./components/ErrorBoundary"; import ErrorBoundary from "./components/ErrorBoundary";
import PrivacyPolicy from "./components/legal/PrivacyPolicy";
import Terms from "./components/legal/Terms";
import "./index.css"; import "./index.css";
const queryClient = new QueryClient({ const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false, staleTime: 30_000, refetchOnWindowFocus: false } }, defaultOptions: { queries: { retry: false, staleTime: 30_000, refetchOnWindowFocus: false } },
}); });
createRoot(document.getElementById("root")!).render( // Public, login-free legal pages. They live outside the authenticated App tree (no /api/me)
<React.StrictMode> // so Google's consent screen and reviewers can fetch them directly. Reached by full-page
// navigation (plain <a href>), so a simple pathname switch is enough — no router needed.
const path = window.location.pathname;
const root =
path === "/privacy" ? <PrivacyPolicy /> :
path === "/terms" ? <Terms /> : (
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<ErrorBoundary> <ErrorBoundary>
<App /> <App />
</ErrorBoundary> </ErrorBoundary>
</QueryClientProvider> </QueryClientProvider>
</React.StrictMode> );
createRoot(document.getElementById("root")!).render(
<React.StrictMode>{root}</React.StrictMode>
); );