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

14
.dockerignore Normal file
View 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
View 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
View 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
View 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
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

37
docker-compose.yml Normal file
View 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
View 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
View 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
View 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
View 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"