diff --git a/.env.example b/.env.example index 5dafc3c..5c796e5 100644 --- a/.env.example +++ b/.env.example @@ -9,6 +9,8 @@ POSTGRES_DB=subfeed APP_PORT=8080 # 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 # Fernet key for encrypting stored OAuth refresh tokens. Generate with: diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml new file mode 100644 index 0000000..8db4df1 --- /dev/null +++ b/.forgejo/workflows/ci.yml @@ -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 the VPS 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 diff --git a/backend/app/auth.py b/backend/app/auth.py index d2f5e2f..c5b086b 100644 --- a/backend/app/auth.py +++ b/backend/app/auth.py @@ -16,12 +16,19 @@ from app.security import encrypt _EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$") -# 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. +# Base sign-in requests only the non-sensitive OpenID scopes (profile/email). These do +# NOT trigger Google's "unverified app" warning and do NOT expire after 7 days, so a new +# 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" -READ_SCOPES = f"openid email profile {WRITE_SCOPE}.readonly" -WRITE_SCOPES = f"openid email profile {WRITE_SCOPE}" +READ_SCOPE = f"{WRITE_SCOPE}.readonly" +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") @@ -33,10 +40,20 @@ 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": 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: """Whether the user's stored grant includes YouTube write (unsubscribe / export).""" tok = user.token @@ -91,7 +108,7 @@ async def login(request: Request): access_type="offline", prompt="select_account", include_granted_scopes="true", - scope=READ_SCOPES, + scope=BASE_SCOPES, ) @@ -145,7 +162,9 @@ async def callback( tok.expiry = ( 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.commit() @@ -193,16 +212,21 @@ def current_user(request: Request, db: Session = Depends(get_db)) -> 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.""" +async def upgrade( + request: Request, access: str = "write", user: User = Depends(current_user) +): + """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( request, settings.oauth_redirect_url, access_type="offline", prompt="consent", include_granted_scopes="true", - scope=WRITE_SCOPES, + scope=scope, ) diff --git a/backend/app/config.py b/backend/app/config.py index b99a66f..91bbb49 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -1,5 +1,9 @@ +from pydantic import model_validator 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): 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" # 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. token_encryption_key: str = "" @@ -77,5 +81,32 @@ class Settings(BaseSettings): def admin_email_set(self) -> set[str]: 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() diff --git a/backend/app/main.py b/backend/app/main.py index 9c4b801..969a909 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -46,8 +46,8 @@ app = FastAPI(title=settings.app_name, lifespan=lifespan) app.add_middleware( SessionMiddleware, secret_key=settings.secret_key, - same_site="lax", - https_only=False, + same_site="lax", # required so the cookie rides the OAuth redirect back from Google + https_only=settings.session_https_only, # Secure flag when served over HTTPS (prod) ) if settings.frontend_origin: diff --git a/backend/app/routes/me.py b/backend/app/routes/me.py index 1da878d..28312e2 100644 --- a/backend/app/routes/me.py +++ b/backend/app/routes/me.py @@ -2,7 +2,7 @@ from fastapi import APIRouter, Depends from sqlalchemy import func, select 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.models import Invite, User @@ -29,6 +29,7 @@ def get_me( "display_name": user.display_name, "avatar_url": user.avatar_url, "role": user.role, + "can_read": has_read_scope(user), "can_write": has_write_scope(user), "pending_invites": pending_invites, "preferences": user.preferences or {}, diff --git a/backend/app/routes/sync.py b/backend/app/routes/sync.py index 55dadfb..ce2d155 100644 --- a/backend/app/routes/sync.py +++ b/backend/app/routes/sync.py @@ -3,7 +3,7 @@ from sqlalchemy import and_, case, func, select, update from sqlalchemy.orm import Session 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.models import Channel, Subscription, User, Video from app.sync.runner import ( @@ -33,6 +33,12 @@ def _user_channels(db: Session, user: User) -> list[Channel]: def sync_subscriptions( user: User = Depends(current_user), db: Session = Depends(get_db) ) -> 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) with quota.attribute(user.id, "sync_subscriptions"): result = import_subscriptions(db, user) diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 0000000..b5ac9de --- /dev/null +++ b/deploy/README.md @@ -0,0 +1,49 @@ +# Deploying Subfeed to the public VPS (the VPS) + +The public test instance runs on the the VPS 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 the VPS + +``` +/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` → the VPS (done via the your DNS provider 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.) diff --git a/deploy/deploy.sh b/deploy/deploy.sh new file mode 100755 index 0000000..e35fc28 --- /dev/null +++ b/deploy/deploy.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# Roll out the latest main branch to the public the VPS instance. +# +# Layout on the VPS: +# /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 diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..469720f --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,76 @@ +# Public VPS (the VPS) 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: diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0f31790..b5d5b59 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -24,6 +24,8 @@ import Feed from "./components/Feed"; import Channels from "./components/Channels"; import Stats from "./components/Stats"; import SettingsPanel from "./components/SettingsPanel"; +import OnboardingWizard from "./components/OnboardingWizard"; +import { shouldAutoOpenOnboarding } from "./lib/onboarding"; import Toaster from "./components/Toaster"; const DEFAULT_FILTERS: FeedFilters = { @@ -61,6 +63,7 @@ export default function App() { const [sidebarLayout, setSidebarLayoutState] = useState(loadLayout); const [page, setPageState] = useState(readPage); const [settingsOpen, setSettingsOpen] = useState(false); + const [wizardOpen, setWizardOpen] = useState(false); function setFilters(next: FeedFilters) { setFiltersState(next); @@ -86,6 +89,13 @@ export default function App() { 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. useEffect(() => { const prefs = meQuery.data?.preferences; @@ -181,8 +191,15 @@ export default function App() { view={view} setView={changeView} onClose={() => setSettingsOpen(false)} + onOpenWizard={() => { + setSettingsOpen(false); + setWizardOpen(true); + }} /> )} + {wizardOpen && meQuery.data && ( + setWizardOpen(false)} /> + )} ); diff --git a/frontend/src/components/Login.tsx b/frontend/src/components/Login.tsx index 31b5774..89845ba 100644 --- a/frontend/src/components/Login.tsx +++ b/frontend/src/components/Login.tsx @@ -35,9 +35,9 @@ export default function Login() { Subfeed

- Your subscriptions, filtered your way. -
- Sign in with your Google account. + A self-hosted, filterable feed of your YouTube subscriptions — sort, tag, and + tune out the noise. Sign in with Google to get started; YouTube access is an + optional, separate step you control.

+ ); } diff --git a/frontend/src/components/OnboardingWizard.tsx b/frontend/src/components/OnboardingWizard.tsx new file mode 100644 index 0000000..0a47b84 --- /dev/null +++ b/frontend/src/components/OnboardingWizard.tsx @@ -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 ( +
+ +

+ Google will show a "Google hasn't verified this app"{" "} + screen — that's expected, because Subfeed hasn't gone through Google's full review. + It's safe to continue: click Advanced →{" "} + Go to Subfeed. You can revoke access anytime from + your{" "} + + Google Account + + . +

+
+ ); +} + +function Dots({ index, total }: { index: number; total: number }) { + return ( +
+ {Array.from({ length: total }).map((_, i) => ( + + ))} +
+ ); +} + +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 ( +
+
+
+ + + {step === "read" && ( + <> +
+ +
+

Connect your YouTube subscriptions

+

+ You're signed in. To build your feed, Subfeed needs{" "} + read-only access to the channels you're + subscribed to on YouTube. It never posts, deletes, or changes anything with this. +

+ +
+ + +
+ + )} + + {step === "write" && ( + <> +
+ +
+

You're connected

+

+ Your feed is ready. Optionally, you can let Subfeed{" "} + unsubscribe 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. +

+ +
+ + +
+ + )} + + {step === "done" && ( + <> +
+ +
+

All set

+

+ Read and unsubscribe access are both granted. You can review or revoke either one + anytime in Settings → Account, or from your Google Account. +

+ + + )} + +
+ +
+
+
+ ); +} diff --git a/frontend/src/components/SettingsPanel.tsx b/frontend/src/components/SettingsPanel.tsx index cb0bdd3..1be2e52 100644 --- a/frontend/src/components/SettingsPanel.tsx +++ b/frontend/src/components/SettingsPanel.tsx @@ -29,6 +29,7 @@ export default function SettingsPanel({ view, setView, onClose, + onOpenWizard, }: { me: Me; theme: ThemePrefs; @@ -36,6 +37,7 @@ export default function SettingsPanel({ view: "grid" | "list"; setView: (v: "grid" | "list") => void; onClose: () => void; + onOpenWizard: () => void; }) { const [tab, setTab] = useState("appearance"); const [closing, setClosing] = useState(false); @@ -115,7 +117,7 @@ export default function SettingsPanel({ )} {t.id === "notifications" && } {t.id === "sync" && } - {t.id === "account" && } + {t.id === "account" && }
))} @@ -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 ( +
+
+
{title}
+

+ {granted ? grantedHint : enableHint} +

+
+ {granted ? ( + + Granted + + ) : ( + + + + )} +
+ ); +} + +function Account({ me, onOpenWizard }: { me: Me; onOpenWizard: () => void }) { return ( <>
@@ -480,34 +521,39 @@ function Account({ me }: { me: Me }) {
{me.role}
-
-
-
-
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 - - ) : ( - - - - )} -
+
+ +
+

+ Sign-in only shares your name and email. YouTube access is granted separately, below — + each is optional and revocable anytime in your Google Account. +

+
+ { + window.location.href = "/auth/upgrade?access=read"; + }} + /> + { + window.location.href = "/auth/upgrade?access=write"; + }} + />
+
{me.role === "admin" && } diff --git a/frontend/src/components/legal/LegalLayout.tsx b/frontend/src/components/legal/LegalLayout.tsx new file mode 100644 index 0000000..a6bb34c --- /dev/null +++ b/frontend/src/components/legal/LegalLayout.tsx @@ -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 ( + + {children} + + ); +} + +export default function LegalLayout({ + title, + children, +}: { + title: string; + children: React.ReactNode; +}) { + return ( +
+
+ + Subfeed + +
+

{title}

+

Last updated: {LAST_UPDATED}

+
{children}
+
+ +
+
+ ); +} + +export function H2({ children }: { children: React.ReactNode }) { + return

{children}

; +} diff --git a/frontend/src/components/legal/PrivacyPolicy.tsx b/frontend/src/components/legal/PrivacyPolicy.tsx new file mode 100644 index 0000000..32dde1f --- /dev/null +++ b/frontend/src/components/legal/PrivacyPolicy.tsx @@ -0,0 +1,111 @@ +import LegalLayout, { CONTACT_EMAIL, H2, LegalLink } from "./LegalLayout"; + +export default function PrivacyPolicy() { + return ( + +

+ 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. +

+ +

What we access

+

Subfeed only accesses data you explicitly authorize, in two separate steps:

+
    +
  • + Sign-in (always): your basic Google + profile — name, email address, profile picture, and Google account identifier — used + solely to create and identify your account. +
  • +
  • + YouTube read (optional, opt-in): with the{" "} + youtube.readonly 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. +
  • +
  • + YouTube manage (optional, opt-in): with + the youtube scope, the ability to unsubscribe from a + channel on YouTube — performed only when you click to do so. +
  • +
+

+ You can use Subfeed with sign-in alone, and grant or decline each YouTube permission + independently. +

+ +

How we use it

+

+ 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. +

+ +

Limited Use disclosure

+

+ Subfeed's use and transfer of information received from Google APIs to any other app + will adhere to the{" "} + + Google API Services User Data Policy + + , 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. +

+ +

Storage & security

+

+ 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. +

+ +

Retention & deletion

+

+ We keep your data while your account is active. You can revoke Subfeed's access to your + Google data at any time at{" "} + + myaccount.google.com/permissions + + ; once revoked, the stored tokens can no longer be used. To have your account and + associated data deleted, email{" "} + + {CONTACT_EMAIL} + + . +

+ +

Cookies

+

+ 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. +

+ +

YouTube & Google

+

+ Subfeed uses YouTube API Services. By using Subfeed you also agree to the{" "} + YouTube Terms of Service, + and your use of Google data is subject to the{" "} + Google Privacy Policy. +

+ +

Changes

+

+ We may update this policy; material changes will be reflected by the "Last updated" date + above. Continued use after a change constitutes acceptance. +

+ +

Contact

+

+ Questions about this policy or your data:{" "} + + {CONTACT_EMAIL} + + . +

+
+ ); +} diff --git a/frontend/src/components/legal/Terms.tsx b/frontend/src/components/legal/Terms.tsx new file mode 100644 index 0000000..ea7f159 --- /dev/null +++ b/frontend/src/components/legal/Terms.tsx @@ -0,0 +1,67 @@ +import LegalLayout, { CONTACT_EMAIL, H2, LegalLink } from "./LegalLayout"; + +export default function Terms() { + return ( + +

+ 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. +

+ +

The service

+

+ 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. +

+ +

Your account & acceptable use

+

+ 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. +

+ +

Third-party services

+

+ Subfeed uses YouTube API Services. By using Subfeed you agree to be bound by the{" "} + YouTube Terms of Service. + Your data is handled as described in our{" "} + Privacy Policy and, for + Google data, the{" "} + Google Privacy Policy. +

+ +

No warranty & limitation of liability

+

+ 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. +

+ +

Governing law

+

+ These Terms are governed by the laws of Hungary and applicable European Union law, + without regard to conflict-of-law rules. +

+ +

Changes

+

+ We may update these Terms; the "Last updated" date above reflects the latest version. + Continued use after a change constitutes acceptance. +

+ +

Contact

+

+ Questions:{" "} + + {CONTACT_EMAIL} + + . +

+
+ ); +} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index fee444c..a0ef056 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_read: boolean; can_write: boolean; pending_invites: number; preferences: Record; diff --git a/frontend/src/lib/onboarding.ts b/frontend/src/lib/onboarding.ts new file mode 100644 index 0000000..b706274 --- /dev/null +++ b/frontend/src/lib/onboarding.ts @@ -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"); +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 4ec3461..9f6e91b 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -3,18 +3,28 @@ import { createRoot } from "react-dom/client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import App from "./App"; import ErrorBoundary from "./components/ErrorBoundary"; +import PrivacyPolicy from "./components/legal/PrivacyPolicy"; +import Terms from "./components/legal/Terms"; import "./index.css"; const queryClient = new QueryClient({ 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) +// so Google's consent screen and reviewers can fetch them directly. Reached by full-page +// navigation (plain ), so a simple pathname switch is enough — no router needed. +const path = window.location.pathname; +const root = + path === "/privacy" ? : + path === "/terms" ? : ( - + ); + +createRoot(document.getElementById("root")!).render( + {root} );