feat: M1 foundation — compose stack, FastAPI, Google OAuth, encrypted tokens

- docker-compose with Postgres 16 + slim Python API image
- FastAPI app with session middleware, health endpoint, static login page
- Google OAuth (Authlib) with email invite-list whitelist; admin role support
- User + OAuthToken models; refresh tokens encrypted at rest (Fernet)
- Alembic migrations, run automatically on container startup
- Postgres backup/restore scripts for portability between machines
This commit is contained in:
npeter83 2026-06-11 01:01:37 +02:00
commit 3a5209e96c
27 changed files with 775 additions and 0 deletions

19
backend/Dockerfile Normal file
View file

@ -0,0 +1,19 @@
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1
WORKDIR /app
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .
RUN adduser --disabled-password --gecos "" appuser && chown -R appuser /app
USER appuser
EXPOSE 8000
CMD ["sh", "entrypoint.sh"]

37
backend/alembic.ini Normal file
View file

@ -0,0 +1,37 @@
[alembic]
script_location = alembic
prepend_sys_path = .
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARNING
handlers = console
qualname =
[logger_sqlalchemy]
level = WARNING
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

50
backend/alembic/env.py Normal file
View file

@ -0,0 +1,50 @@
from logging.config import fileConfig
from alembic import context
from sqlalchemy import engine_from_config, pool
from app.config import settings
from app.db import Base
import app.models # noqa: F401 (import so models register on Base.metadata)
config = context.config
config.set_main_option("sqlalchemy.url", settings.database_url)
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
def run_migrations_offline() -> None:
context.configure(
url=settings.database_url,
target_metadata=target_metadata,
literal_binds=True,
compare_type=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(
connection=connection,
target_metadata=target_metadata,
compare_type=True,
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View file

@ -0,0 +1,24 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View file

@ -0,0 +1,64 @@
"""initial: users and oauth_tokens
Revision ID: 0001_initial
Revises:
Create Date: 2026-06-11
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "0001_initial"
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"users",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("google_sub", sa.String(length=64), nullable=False),
sa.Column("email", sa.String(length=320), nullable=False),
sa.Column("display_name", sa.String(length=255), nullable=True),
sa.Column("avatar_url", sa.String(length=1024), nullable=True),
sa.Column("role", sa.String(length=16), nullable=False, server_default="user"),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
)
op.create_index("ix_users_google_sub", "users", ["google_sub"], unique=True)
op.create_index("ix_users_email", "users", ["email"], unique=True)
op.create_table(
"oauth_tokens",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column(
"user_id",
sa.Integer(),
sa.ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
unique=True,
),
sa.Column("refresh_token_enc", sa.String(), nullable=True),
sa.Column("access_token", sa.String(), nullable=True),
sa.Column("expiry", sa.DateTime(timezone=True), nullable=True),
sa.Column("scopes", sa.String(), nullable=True),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
)
def downgrade() -> None:
op.drop_table("oauth_tokens")
op.drop_index("ix_users_email", table_name="users")
op.drop_index("ix_users_google_sub", table_name="users")
op.drop_table("users")

0
backend/app/__init__.py Normal file
View file

108
backend/app/auth.py Normal file
View file

@ -0,0 +1,108 @@
from datetime import datetime, timezone
from authlib.integrations.starlette_client import OAuth, OAuthError
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import JSONResponse, RedirectResponse
from sqlalchemy.orm import Session
from app.config import settings
from app.db import get_db
from app.models import OAuthToken, User
from app.security import encrypt
# Single YouTube scope that allows reading subscriptions/playlists AND writing
# (unsubscribe, playlist export). openid/email/profile give us the account identity.
SCOPES = "openid email profile https://www.googleapis.com/auth/youtube"
router = APIRouter(prefix="/auth", tags=["auth"])
oauth = OAuth()
oauth.register(
name="google",
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": SCOPES,
# access_type=offline + prompt=consent ensures we receive a refresh_token.
"access_type": "offline",
"prompt": "consent",
},
)
@router.get("/login")
async def login(request: Request):
return await oauth.google.authorize_redirect(request, settings.oauth_redirect_url)
@router.get("/callback")
async def callback(request: Request, db: Session = Depends(get_db)):
try:
token = await oauth.google.authorize_access_token(request)
except OAuthError as exc:
raise HTTPException(status_code=400, detail=f"OAuth error: {exc.error}")
userinfo = token.get("userinfo")
if not userinfo or not userinfo.get("sub"):
raise HTTPException(status_code=400, detail="No user info returned by Google")
email = (userinfo.get("email") or "").lower()
if not email or email not in settings.allowed_email_set:
raise HTTPException(
status_code=403, detail="This Google account is not on the invite list."
)
user = db.query(User).filter(User.google_sub == userinfo["sub"]).one_or_none()
if user is None:
user = User(google_sub=userinfo["sub"], email=email)
db.add(user)
user.email = email
user.display_name = userinfo.get("name")
user.avatar_url = userinfo.get("picture")
user.role = "admin" if email in settings.admin_email_set else "user"
db.flush()
tok = user.token or OAuthToken(user=user)
# Google only returns a refresh_token on (re)consent; keep the previous one otherwise.
if token.get("refresh_token"):
tok.refresh_token_enc = encrypt(token["refresh_token"])
tok.access_token = token.get("access_token")
expires_at = token.get("expires_at")
tok.expiry = (
datetime.fromtimestamp(expires_at, tz=timezone.utc) if expires_at else None
)
tok.scopes = token.get("scope") or SCOPES
db.add(tok)
db.commit()
request.session["user_id"] = user.id
return RedirectResponse(url="/")
@router.post("/logout")
async def logout(request: Request):
request.session.clear()
return JSONResponse({"ok": True})
def current_user(request: Request, db: Session = Depends(get_db)) -> User:
user_id = request.session.get("user_id")
if not user_id:
raise HTTPException(status_code=401, detail="Not authenticated")
user = db.get(User, user_id)
if user is None:
request.session.clear()
raise HTTPException(status_code=401, detail="Not authenticated")
return user
@router.get("/me")
async def me(user: User = Depends(current_user)) -> dict:
return {
"id": user.id,
"email": user.email,
"display_name": user.display_name,
"avatar_url": user.avatar_url,
"role": user.role,
}

36
backend/app/config.py Normal file
View file

@ -0,0 +1,36 @@
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
app_name: str = "Subfeed"
database_url: str = "postgresql+psycopg://subfeed:subfeed@db:5432/subfeed"
# Session cookie signing key.
secret_key: str = "change-me-session-key"
# Fernet key (urlsafe base64, 32 bytes) for encrypting stored refresh tokens.
token_encryption_key: str = ""
google_client_id: str = ""
google_client_secret: str = ""
oauth_redirect_url: str = "http://localhost:8080/auth/callback"
# Comma-separated invite list / admin list of Google account emails.
allowed_emails: str = ""
admin_emails: str = ""
# Origin of a separately served frontend dev server (enables CORS). Empty in production.
frontend_origin: str = ""
@property
def allowed_email_set(self) -> set[str]:
return {e.strip().lower() for e in self.allowed_emails.split(",") if e.strip()}
@property
def admin_email_set(self) -> set[str]:
return {e.strip().lower() for e in self.admin_emails.split(",") if e.strip()}
settings = Settings()

22
backend/app/db.py Normal file
View file

@ -0,0 +1,22 @@
from collections.abc import Iterator
from sqlalchemy import create_engine
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
from app.config import settings
class Base(DeclarativeBase):
pass
engine = create_engine(settings.database_url, pool_pre_ping=True, future=True)
SessionLocal = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)
def get_db() -> Iterator[Session]:
db = SessionLocal()
try:
yield db
finally:
db.close()

40
backend/app/main.py Normal file
View file

@ -0,0 +1,40 @@
from pathlib import Path
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from starlette.middleware.sessions import SessionMiddleware
from app import auth
from app.config import settings
from app.routes import health
app = FastAPI(title=settings.app_name)
app.add_middleware(
SessionMiddleware,
secret_key=settings.secret_key,
same_site="lax",
https_only=False,
)
if settings.frontend_origin:
app.add_middleware(
CORSMiddleware,
allow_origins=[settings.frontend_origin],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(health.router)
app.include_router(auth.router)
STATIC_DIR = Path(__file__).parent / "static"
app.mount("/assets", StaticFiles(directory=STATIC_DIR / "assets"), name="assets")
@app.get("/")
async def index() -> FileResponse:
return FileResponse(STATIC_DIR / "index.html")

43
backend/app/models.py Normal file
View file

@ -0,0 +1,43 @@
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, String, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db import Base
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
google_sub: Mapped[str] = mapped_column(String(64), unique=True, index=True)
email: Mapped[str] = mapped_column(String(320), unique=True, index=True)
display_name: Mapped[str | None] = mapped_column(String(255))
avatar_url: Mapped[str | None] = mapped_column(String(1024))
role: Mapped[str] = mapped_column(String(16), default="user", server_default="user")
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
token: Mapped["OAuthToken | None"] = relationship(
back_populates="user", uselist=False, cascade="all, delete-orphan"
)
class OAuthToken(Base):
__tablename__ = "oauth_tokens"
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), unique=True
)
# Refresh token encrypted at rest (Fernet). Access token is short-lived and low risk.
refresh_token_enc: Mapped[str | None] = mapped_column(String)
access_token: Mapped[str | None] = mapped_column(String)
expiry: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
scopes: Mapped[str | None] = mapped_column(String)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
user: Mapped["User"] = relationship(back_populates="token")

View file

View file

@ -0,0 +1,13 @@
from fastapi import APIRouter, Depends
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.db import get_db
router = APIRouter(tags=["health"])
@router.get("/healthz")
def healthz(db: Session = Depends(get_db)) -> dict:
db.execute(text("SELECT 1"))
return {"status": "ok", "db": "ok"}

25
backend/app/security.py Normal file
View file

@ -0,0 +1,25 @@
from cryptography.fernet import Fernet
from app.config import settings
_fernet: Fernet | None = (
Fernet(settings.token_encryption_key.encode()) if settings.token_encryption_key else None
)
def _require_fernet() -> Fernet:
if _fernet is None:
raise RuntimeError("TOKEN_ENCRYPTION_KEY is not configured")
return _fernet
def encrypt(value: str | None) -> str | None:
if value is None:
return None
return _require_fernet().encrypt(value.encode()).decode()
def decrypt(value: str | None) -> str | None:
if value is None:
return None
return _require_fernet().decrypt(value.encode()).decode()

View file

View file

@ -0,0 +1,58 @@
<!doctype html>
<html lang="hu">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Subfeed</title>
<style>
:root { color-scheme: dark; }
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
display: grid;
place-items: center;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
color: #f4f4f5;
background: radial-gradient(1200px 800px at 20% 10%, #1f2a44 0%, #0b0f1a 55%, #07090f 100%);
}
.card {
width: min(92vw, 420px);
padding: 40px 36px;
border-radius: 20px;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.12);
backdrop-filter: blur(16px);
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.45);
text-align: center;
}
.logo { font-size: 28px; font-weight: 700; letter-spacing: -0.02em; }
.logo span { color: #ff4d4f; }
p { color: #a1a1aa; line-height: 1.5; margin: 14px 0 28px; }
a.btn {
display: inline-flex; align-items: center; gap: 10px;
padding: 12px 20px; border-radius: 12px; text-decoration: none;
font-weight: 600; color: #0b0f1a; background: #f4f4f5;
transition: transform 0.12s ease, box-shadow 0.12s ease;
}
a.btn:hover { transform: translateY(-1px); box-shadow: 0 8px 24px rgba(0,0,0,0.35); }
.foot { margin-top: 24px; font-size: 12px; color: #71717a; }
</style>
</head>
<body>
<div class="card">
<div class="logo">Sub<span>feed</span></div>
<p>A te feliratkozásaid, a te szűrőid szerint.<br />Jelentkezz be a Google-fiókoddal.</p>
<a class="btn" href="/auth/login">
<svg width="18" height="18" viewBox="0 0 48 48" aria-hidden="true">
<path fill="#EA4335" d="M24 9.5c3.5 0 6.6 1.2 9.1 3.6l6.8-6.8C35.9 2.4 30.4 0 24 0 14.6 0 6.4 5.4 2.5 13.3l7.9 6.1C12.3 13.2 17.7 9.5 24 9.5z"/>
<path fill="#4285F4" d="M46.1 24.6c0-1.6-.1-3.1-.4-4.6H24v9.1h12.4c-.5 2.9-2.1 5.3-4.6 6.9l7.1 5.5c4.2-3.9 6.2-9.6 6.2-16.9z"/>
<path fill="#FBBC05" d="M10.4 28.6c-.5-1.4-.8-2.9-.8-4.6s.3-3.2.8-4.6l-7.9-6.1C.9 16.5 0 20.1 0 24s.9 7.5 2.5 10.7l7.9-6.1z"/>
<path fill="#34A853" d="M24 48c6.4 0 11.9-2.1 15.9-5.8l-7.1-5.5c-2 1.3-4.5 2.1-8.8 2.1-6.3 0-11.7-3.7-13.6-9.9l-7.9 6.1C6.4 42.6 14.6 48 24 48z"/>
</svg>
Bejelentkezés Google-fiókkal
</a>
<div class="foot">Csak meghívott fiókok léphetnek be.</div>
</div>
</body>
</html>

8
backend/entrypoint.sh Normal file
View file

@ -0,0 +1,8 @@
#!/bin/sh
set -e
echo "Applying database migrations..."
alembic upgrade head
echo "Starting Subfeed API..."
exec uvicorn app.main:app --host 0.0.0.0 --port 8000

11
backend/requirements.txt Normal file
View file

@ -0,0 +1,11 @@
fastapi>=0.115,<1.0
uvicorn[standard]>=0.30,<1.0
sqlalchemy>=2.0,<2.1
psycopg[binary]>=3.2,<4.0
alembic>=1.13,<2.0
pydantic>=2.7,<3.0
pydantic-settings>=2.3,<3.0
authlib>=1.3,<2.0
httpx>=0.27,<1.0
itsdangerous>=2.1,<3.0
cryptography>=42,<46