From 4765db89de289e59881ec9a4e35f046641445332 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 13 Jun 2026 23:56:34 +0200 Subject: [PATCH 1/4] feat(auth): split base sign-in from YouTube scopes for incremental onboarding Base login now requests only openid/email/profile (non-sensitive), so a new user gets a clean Google consent with no "unverified app" warning and no 7-day refresh token expiry. YouTube read (youtube.readonly) and write (youtube) are granted later by the onboarding wizard via a parameterized /auth/upgrade?access=read|write. Security fixes folded in from the baseline audit: - config: refuse to boot in production (https OAUTH_REDIRECT_URL) with the placeholder/short SECRET_KEY or a missing TOKEN_ENCRYPTION_KEY, closing a session-forgery / admin-impersonation hole. - main: mark the session cookie Secure when served over HTTPS. - me: expose can_read; sync/subscriptions returns a friendly 403 (not a 500) until YouTube read access is granted. --- .env.example | 2 ++ backend/app/auth.py | 48 ++++++++++++++++++++++++++++---------- backend/app/config.py | 33 +++++++++++++++++++++++++- backend/app/main.py | 4 ++-- backend/app/routes/me.py | 3 ++- backend/app/routes/sync.py | 8 ++++++- 6 files changed, 81 insertions(+), 17 deletions(-) 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/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) From e0980487aff0672673dc6dc957eaeefc068b33f0 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 14 Jun 2026 01:11:29 +0200 Subject: [PATCH 2/4] feat(onboarding): first-login wizard for incremental YouTube consent After the clean name/email sign-in, a wizard walks the user through granting YouTube read (then optionally write) one step at a time, each with a plain rationale and an up-front heads-up about Google's "unverified app" screen. The visible step is derived from the granted scopes (can_read/can_write) so the flow resumes correctly across the full-page consent redirect; it's dismissible and reopenable from Settings -> Account, which now lists read and write as separate, individually-grantable access rows. --- frontend/src/App.tsx | 17 ++ frontend/src/components/OnboardingWizard.tsx | 176 +++++++++++++++++++ frontend/src/components/SettingsPanel.tsx | 104 ++++++++--- frontend/src/lib/api.ts | 1 + frontend/src/lib/onboarding.ts | 33 ++++ 5 files changed, 302 insertions(+), 29 deletions(-) create mode 100644 frontend/src/components/OnboardingWizard.tsx create mode 100644 frontend/src/lib/onboarding.ts 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/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/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"); +} From 6a71e3d943057db2a23cfbc64ee3771ce3e8d977 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 14 Jun 2026 01:29:44 +0200 Subject: [PATCH 3/4] feat(legal): public privacy policy, terms, and homepage for OAuth verification Adds login-free /privacy and /terms pages (rendered outside the authenticated tree via a pathname switch in main.tsx) carrying the Google API Services Limited Use disclosure, YouTube ToS / Google Privacy links, and contact + data-deletion info. The sign-in screen now describes the app and links to both, satisfying Google's homepage + privacy-policy requirements for the OAuth consent screen. --- frontend/src/components/Login.tsx | 10 +- frontend/src/components/legal/LegalLayout.tsx | 47 ++++++++ .../src/components/legal/PrivacyPolicy.tsx | 111 ++++++++++++++++++ frontend/src/components/legal/Terms.tsx | 67 +++++++++++ frontend/src/main.tsx | 16 ++- 5 files changed, 245 insertions(+), 6 deletions(-) create mode 100644 frontend/src/components/legal/LegalLayout.tsx create mode 100644 frontend/src/components/legal/PrivacyPolicy.tsx create mode 100644 frontend/src/components/legal/Terms.tsx 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/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/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} ); From a322f87cafc558fb24015f8e28709fbfc5170798 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 14 Jun 2026 01:39:43 +0200 Subject: [PATCH 4/4] chore(deploy): hardened prod compose, rollout script, and CI for the server docker-compose.prod.yml targets the server: Postgres is never published, the app binds to 127.0.0.1 (Caddy proxies it), and every service has a memory/CPU cap plus no-new-privileges / cap_drop / read-only rootfs. deploy/deploy.sh rolls out main with a host-side build (migrations run via the entrypoint). CI type-checks and builds the frontend and byte-compiles the backend on every push to main. --- .forgejo/workflows/ci.yml | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 .forgejo/workflows/ci.yml 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