commit 3a5209e96c305a94ddcde06176dc0f8a9210313c Author: npeter83 Date: Thu Jun 11 01:01:37 2026 +0200 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 diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..6c63979 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,14 @@ +.git +.gitignore +.env +*.env.local +**/__pycache__/ +**/*.pyc +.venv/ +venv/ +node_modules/ +frontend/dist/ +backups/ +*.dump +README.md +docs/ diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..f428ca2 --- /dev/null +++ b/.env.example @@ -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=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= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..38f6d4f --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..85e288e --- /dev/null +++ b/README.md @@ -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-.dump (Windows: scripts\backup.ps1) +./scripts/restore.sh backups/ # 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. diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..2f85256 --- /dev/null +++ b/backend/Dockerfile @@ -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"] diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100644 index 0000000..adce95b --- /dev/null +++ b/backend/alembic.ini @@ -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 diff --git a/backend/alembic/env.py b/backend/alembic/env.py new file mode 100644 index 0000000..095c8cd --- /dev/null +++ b/backend/alembic/env.py @@ -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() diff --git a/backend/alembic/script.py.mako b/backend/alembic/script.py.mako new file mode 100644 index 0000000..590f5b3 --- /dev/null +++ b/backend/alembic/script.py.mako @@ -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"} diff --git a/backend/alembic/versions/0001_initial.py b/backend/alembic/versions/0001_initial.py new file mode 100644 index 0000000..3db52a6 --- /dev/null +++ b/backend/alembic/versions/0001_initial.py @@ -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") diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/auth.py b/backend/app/auth.py new file mode 100644 index 0000000..ee4e159 --- /dev/null +++ b/backend/app/auth.py @@ -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, + } diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..25284b5 --- /dev/null +++ b/backend/app/config.py @@ -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() diff --git a/backend/app/db.py b/backend/app/db.py new file mode 100644 index 0000000..0e79de9 --- /dev/null +++ b/backend/app/db.py @@ -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() diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..17122a2 --- /dev/null +++ b/backend/app/main.py @@ -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") diff --git a/backend/app/models.py b/backend/app/models.py new file mode 100644 index 0000000..92e8b91 --- /dev/null +++ b/backend/app/models.py @@ -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") diff --git a/backend/app/routes/__init__.py b/backend/app/routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/routes/health.py b/backend/app/routes/health.py new file mode 100644 index 0000000..cc874c9 --- /dev/null +++ b/backend/app/routes/health.py @@ -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"} diff --git a/backend/app/security.py b/backend/app/security.py new file mode 100644 index 0000000..00d2fc6 --- /dev/null +++ b/backend/app/security.py @@ -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() diff --git a/backend/app/static/assets/.gitkeep b/backend/app/static/assets/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/static/index.html b/backend/app/static/index.html new file mode 100644 index 0000000..2600164 --- /dev/null +++ b/backend/app/static/index.html @@ -0,0 +1,58 @@ + + + + + + Subfeed + + + +
+ +

A te feliratkozásaid, a te szűrőid szerint.
Jelentkezz be a Google-fiókoddal.

+ + + Bejelentkezés Google-fiókkal + +
Csak meghívott fiókok léphetnek be.
+
+ + diff --git a/backend/entrypoint.sh b/backend/entrypoint.sh new file mode 100644 index 0000000..5d0809a --- /dev/null +++ b/backend/entrypoint.sh @@ -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 diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..3d74bde --- /dev/null +++ b/backend/requirements.txt @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..ddc15d9 --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/scripts/backup.ps1 b/scripts/backup.ps1 new file mode 100644 index 0000000..cf1903c --- /dev/null +++ b/scripts/backup.ps1 @@ -0,0 +1,10 @@ +# Dumps the Subfeed Postgres database to backups\subfeed-.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" diff --git a/scripts/backup.sh b/scripts/backup.sh new file mode 100644 index 0000000..e232c24 --- /dev/null +++ b/scripts/backup.sh @@ -0,0 +1,11 @@ +#!/bin/sh +# Dumps the Subfeed Postgres database to backups/subfeed-.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" diff --git a/scripts/restore.ps1 b/scripts/restore.ps1 new file mode 100644 index 0000000..f643fbc --- /dev/null +++ b/scripts/restore.ps1 @@ -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" diff --git a/scripts/restore.sh b/scripts/restore.sh new file mode 100644 index 0000000..73165d7 --- /dev/null +++ b/scripts/restore.sh @@ -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 "; 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"