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:
commit
3a5209e96c
27 changed files with 775 additions and 0 deletions
14
.dockerignore
Normal file
14
.dockerignore
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
.git
|
||||
.gitignore
|
||||
.env
|
||||
*.env.local
|
||||
**/__pycache__/
|
||||
**/*.pyc
|
||||
.venv/
|
||||
venv/
|
||||
node_modules/
|
||||
frontend/dist/
|
||||
backups/
|
||||
*.dump
|
||||
README.md
|
||||
docs/
|
||||
30
.env.example
Normal file
30
.env.example
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
# ---- Copy this file to .env and fill in the values ----
|
||||
|
||||
# Postgres (used by docker-compose for the db service and the DATABASE_URL)
|
||||
POSTGRES_USER=subfeed
|
||||
POSTGRES_PASSWORD=change-this-password
|
||||
POSTGRES_DB=subfeed
|
||||
|
||||
# Host port the app is exposed on (http://localhost:<APP_PORT>)
|
||||
APP_PORT=8080
|
||||
|
||||
# Session signing key. Generate with: python -c "import secrets;print(secrets.token_urlsafe(48))"
|
||||
SECRET_KEY=change-me-session-key
|
||||
|
||||
# Fernet key for encrypting stored OAuth refresh tokens. Generate with:
|
||||
# python -c "import base64,os;print(base64.urlsafe_b64encode(os.urandom(32)).decode())"
|
||||
TOKEN_ENCRYPTION_KEY=change-me-fernet-key
|
||||
|
||||
# Google OAuth client (Google Cloud Console -> APIs & Services -> Credentials -> OAuth client ID, type "Web application").
|
||||
# Authorized redirect URI must match OAUTH_REDIRECT_URL exactly.
|
||||
GOOGLE_CLIENT_ID=
|
||||
GOOGLE_CLIENT_SECRET=
|
||||
OAUTH_REDIRECT_URL=http://localhost:8080/auth/callback
|
||||
|
||||
# Invite list: only these Google account emails may sign in (comma-separated).
|
||||
ALLOWED_EMAILS=
|
||||
# Admin emails (subset of the above) get the admin role.
|
||||
ADMIN_EMAILS=
|
||||
|
||||
# Optional: origin of a separately-served frontend dev server (enables CORS). Leave empty in production.
|
||||
FRONTEND_ORIGIN=
|
||||
28
.gitignore
vendored
Normal file
28
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
# Secrets / local config
|
||||
.env
|
||||
*.env.local
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.venv/
|
||||
venv/
|
||||
.mypy_cache/
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
|
||||
# Node / frontend
|
||||
node_modules/
|
||||
frontend/dist/
|
||||
*.log
|
||||
|
||||
# Data / dumps
|
||||
*.dump
|
||||
*.sql.gz
|
||||
backups/
|
||||
|
||||
# OS / editor
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
.idea/
|
||||
.vscode/
|
||||
67
README.md
Normal file
67
README.md
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
# Subfeed
|
||||
|
||||
Self-hosted, multi-user web app for browsing **your own YouTube subscriptions** the way you
|
||||
actually want: precise filtering and sorting (by language, topic, length, age, watch state…),
|
||||
a fast local-first feed, and one-click playback that opens the real youtube.com — so your
|
||||
browser's ad blocking and SponsorBlock keep working exactly as before.
|
||||
|
||||
Each user signs in with their own Google account (invite-only) and sees only their own
|
||||
subscriptions. All the expensive data (channels, videos, metadata) is fetched from YouTube
|
||||
once and stored locally, so filtering/searching/sorting are instant and don't burn API quota.
|
||||
|
||||
> Status: early development. Milestone **M1** (foundation) is in place: docker-compose stack,
|
||||
> FastAPI backend, Google OAuth login with an email invite-list, and encrypted token storage.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Docker + Docker Compose
|
||||
- A Google Cloud project with an OAuth client (see below)
|
||||
|
||||
## Quick start
|
||||
|
||||
1. Copy the env template and generate secrets:
|
||||
|
||||
```sh
|
||||
cp .env.example .env
|
||||
python -c "import secrets;print('SECRET_KEY=',secrets.token_urlsafe(48))"
|
||||
python -c "import base64,os;print('TOKEN_ENCRYPTION_KEY=',base64.urlsafe_b64encode(os.urandom(32)).decode())"
|
||||
```
|
||||
|
||||
Paste the generated values into `.env`.
|
||||
|
||||
2. Create a Google OAuth client (Google Cloud Console → APIs & Services):
|
||||
- Enable the **YouTube Data API v3**.
|
||||
- OAuth consent screen: **External**, publishing status **Testing**, and add every invited
|
||||
Google account as a **Test user** (up to 100 — no app verification needed at this scale).
|
||||
- Create credentials → **OAuth client ID** → type **Web application**.
|
||||
- Authorized redirect URI: `http://localhost:8080/auth/callback`
|
||||
(must match `OAUTH_REDIRECT_URL` in `.env`).
|
||||
- Put the client ID/secret into `.env`, and list invited emails in `ALLOWED_EMAILS`.
|
||||
|
||||
3. Start it:
|
||||
|
||||
```sh
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
Open http://localhost:8080 and sign in. Database migrations run automatically on startup.
|
||||
|
||||
## Backup & moving to another machine
|
||||
|
||||
All data lives in the `pgdata` Postgres volume — moving to another host (e.g. a Proxmox Linux
|
||||
server) does **not** require re-fetching from YouTube. Copy your `.env` (keep the same
|
||||
`TOKEN_ENCRYPTION_KEY` and Google client so stored tokens stay valid), then:
|
||||
|
||||
```sh
|
||||
./scripts/backup.sh # -> backups/subfeed-<timestamp>.dump (Windows: scripts\backup.ps1)
|
||||
./scripts/restore.sh backups/<file> # on the new host after `docker compose up`
|
||||
```
|
||||
|
||||
## Tech
|
||||
|
||||
FastAPI + PostgreSQL backend, React + Vite frontend (added in a later milestone), packaged with
|
||||
Docker Compose.
|
||||
|
||||
## Note
|
||||
|
||||
This project is developed with AI assistance.
|
||||
19
backend/Dockerfile
Normal file
19
backend/Dockerfile
Normal 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
37
backend/alembic.ini
Normal 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
50
backend/alembic/env.py
Normal 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()
|
||||
24
backend/alembic/script.py.mako
Normal file
24
backend/alembic/script.py.mako
Normal 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"}
|
||||
64
backend/alembic/versions/0001_initial.py
Normal file
64
backend/alembic/versions/0001_initial.py
Normal 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
0
backend/app/__init__.py
Normal file
108
backend/app/auth.py
Normal file
108
backend/app/auth.py
Normal 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
36
backend/app/config.py
Normal 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
22
backend/app/db.py
Normal 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
40
backend/app/main.py
Normal 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
43
backend/app/models.py
Normal 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")
|
||||
0
backend/app/routes/__init__.py
Normal file
0
backend/app/routes/__init__.py
Normal file
13
backend/app/routes/health.py
Normal file
13
backend/app/routes/health.py
Normal 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
25
backend/app/security.py
Normal 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()
|
||||
0
backend/app/static/assets/.gitkeep
Normal file
0
backend/app/static/assets/.gitkeep
Normal file
58
backend/app/static/index.html
Normal file
58
backend/app/static/index.html
Normal 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
8
backend/entrypoint.sh
Normal 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
11
backend/requirements.txt
Normal 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
|
||||
37
docker-compose.yml
Normal file
37
docker-compose.yml
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
services:
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
POSTGRES_USER: ${POSTGRES_USER:-subfeed}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-subfeed}
|
||||
POSTGRES_DB: ${POSTGRES_DB:-subfeed}
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-subfeed} -d ${POSTGRES_DB:-subfeed}"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
restart: unless-stopped
|
||||
|
||||
api:
|
||||
build:
|
||||
context: ./backend
|
||||
env_file: .env
|
||||
environment:
|
||||
DATABASE_URL: postgresql+psycopg://${POSTGRES_USER:-subfeed}:${POSTGRES_PASSWORD:-subfeed}@db:5432/${POSTGRES_DB:-subfeed}
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "${APP_PORT:-8080}:8000"
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; exit(0 if urllib.request.urlopen('http://localhost:8000/healthz').status == 200 else 1)"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 25s
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
10
scripts/backup.ps1
Normal file
10
scripts/backup.ps1
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
# Dumps the Subfeed Postgres database to backups\subfeed-<timestamp>.dump (custom format).
|
||||
# Run from the project root: .\scripts\backup.ps1
|
||||
$ErrorActionPreference = "Stop"
|
||||
$user = if ($env:POSTGRES_USER) { $env:POSTGRES_USER } else { "subfeed" }
|
||||
$dbname = if ($env:POSTGRES_DB) { $env:POSTGRES_DB } else { "subfeed" }
|
||||
New-Item -ItemType Directory -Force -Path "backups" | Out-Null
|
||||
$ts = Get-Date -Format "yyyyMMdd-HHmmss"
|
||||
$out = "backups\subfeed-$ts.dump"
|
||||
docker compose exec -T db pg_dump -U $user -Fc $dbname | Set-Content -NoNewline -Encoding Byte $out
|
||||
Write-Output "Wrote $out"
|
||||
11
scripts/backup.sh
Normal file
11
scripts/backup.sh
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
#!/bin/sh
|
||||
# Dumps the Subfeed Postgres database to backups/subfeed-<timestamp>.dump (custom format).
|
||||
# Run from the project root: ./scripts/backup.sh
|
||||
set -e
|
||||
USER_NAME="${POSTGRES_USER:-subfeed}"
|
||||
DB_NAME="${POSTGRES_DB:-subfeed}"
|
||||
mkdir -p backups
|
||||
TS=$(date +%Y%m%d-%H%M%S)
|
||||
OUT="backups/subfeed-$TS.dump"
|
||||
docker compose exec -T db pg_dump -U "$USER_NAME" -Fc "$DB_NAME" > "$OUT"
|
||||
echo "Wrote $OUT"
|
||||
9
scripts/restore.ps1
Normal file
9
scripts/restore.ps1
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
# Restores a Subfeed Postgres dump created by backup.ps1 into the running db container.
|
||||
# Usage: .\scripts\restore.ps1 backups\subfeed-YYYYMMDD-HHMMSS.dump
|
||||
param([Parameter(Mandatory = $true)][string]$DumpFile)
|
||||
$ErrorActionPreference = "Stop"
|
||||
$user = if ($env:POSTGRES_USER) { $env:POSTGRES_USER } else { "subfeed" }
|
||||
$dbname = if ($env:POSTGRES_DB) { $env:POSTGRES_DB } else { "subfeed" }
|
||||
if (-not (Test-Path $DumpFile)) { throw "Dump file not found: $DumpFile" }
|
||||
Get-Content -Encoding Byte $DumpFile | docker compose exec -T db pg_restore -U $user -d $dbname --clean --if-exists
|
||||
Write-Output "Restored $DumpFile"
|
||||
11
scripts/restore.sh
Normal file
11
scripts/restore.sh
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
#!/bin/sh
|
||||
# Restores a Subfeed Postgres dump created by backup.sh into the running db container.
|
||||
# Usage: ./scripts/restore.sh backups/subfeed-YYYYMMDD-HHMMSS.dump
|
||||
set -e
|
||||
FILE="$1"
|
||||
if [ -z "$FILE" ]; then echo "Usage: restore.sh <dumpfile>"; exit 1; fi
|
||||
if [ ! -f "$FILE" ]; then echo "Dump file not found: $FILE"; exit 1; fi
|
||||
USER_NAME="${POSTGRES_USER:-subfeed}"
|
||||
DB_NAME="${POSTGRES_DB:-subfeed}"
|
||||
docker compose exec -T db pg_restore -U "$USER_NAME" -d "$DB_NAME" --clean --if-exists < "$FILE"
|
||||
echo "Restored $FILE"
|
||||
Loading…
Add table
Add a link
Reference in a new issue