Merge onboarding wizard, scope split, hardening, legal pages, and deploy setup
This commit is contained in:
commit
cbd0234a8d
20 changed files with 808 additions and 52 deletions
|
|
@ -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
35
.forgejo/workflows/ci.yml
Normal 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
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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 {},
|
||||||
|
|
|
||||||
|
|
@ -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
49
deploy/README.md
Normal 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
20
deploy/deploy.sh
Executable 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
76
docker-compose.prod.yml
Normal 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:
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
176
frontend/src/components/OnboardingWizard.tsx
Normal file
176
frontend/src/components/OnboardingWizard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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 & 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 />}
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
47
frontend/src/components/legal/LegalLayout.tsx
Normal file
47
frontend/src/components/legal/LegalLayout.tsx
Normal 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>;
|
||||||
|
}
|
||||||
111
frontend/src/components/legal/PrivacyPolicy.tsx
Normal file
111
frontend/src/components/legal/PrivacyPolicy.tsx
Normal 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 & 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 & 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 & 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
67
frontend/src/components/legal/Terms.tsx
Normal file
67
frontend/src/components/legal/Terms.tsx
Normal 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 & 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 & 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>;
|
||||||
|
|
|
||||||
33
frontend/src/lib/onboarding.ts
Normal file
33
frontend/src/lib/onboarding.ts
Normal 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");
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue