diff --git a/.env.example b/.env.example index 8ce7545..4f3850a 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,5 @@ # Bind directly to the Tailscale address in production. +# All password/token values may contain special characters like @ : / # %. BIND_IP=100.103.83.12 POSTGRES_DB=time_track POSTGRES_USER=time_track diff --git a/README.md b/README.md index 16992fb..ad87a5e 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ A self-hosted, single-user combined timesheet server for `time_track` manual int cp .env.example .env # Generate three distinct high-entropy values for POSTGRES_PASSWORD, ADMIN_PASSWORD, # SYNC_API_TOKEN, and SESSION_SECRET. Keep .env private. +# Values containing special characters (for example @ or :) are safe. docker compose up --build -d ``` diff --git a/app/database.py b/app/database.py index 8c47951..1ef373e 100644 --- a/app/database.py +++ b/app/database.py @@ -1,10 +1,30 @@ import os from sqlalchemy import create_engine +from sqlalchemy.engine import URL from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker -DATABASE_URL = os.environ.get("DATABASE_URL", "sqlite:///./time_track.db") -engine = create_engine(DATABASE_URL, pool_pre_ping=True) + +def build_database_url() -> URL | str: + """Prefer DATABASE_URL; otherwise build a safely escaped URL from parts. + + Passwords are passed as URL components (not string interpolation), so + characters like '@', ':', '/', '#' and '%' cannot corrupt the hostname. + """ + database_url = os.environ.get("DATABASE_URL") + if database_url: + return database_url + return URL.create( + "postgresql+psycopg", + username=os.environ.get("POSTGRES_USER", "time_track"), + password=os.environ.get("POSTGRES_PASSWORD", ""), + host=os.environ.get("POSTGRES_HOST", "localhost"), + port=int(os.environ.get("POSTGRES_PORT", "5432")), + database=os.environ.get("POSTGRES_DB", "time_track"), + ) + + +engine = create_engine(build_database_url(), pool_pre_ping=True) SessionLocal = sessionmaker(bind=engine, autocommit=False, autoflush=False) diff --git a/app/main.py b/app/main.py index 262d95e..6086462 100644 --- a/app/main.py +++ b/app/main.py @@ -1,6 +1,8 @@ +import logging import os import secrets import shutil +import time from datetime import datetime from pathlib import Path from typing import Annotated @@ -12,6 +14,7 @@ from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.templating import Jinja2Templates from pypdf import PdfReader from sqlalchemy import select +from sqlalchemy.exc import OperationalError from sqlalchemy.orm import Session from starlette.middleware.sessions import SessionMiddleware @@ -34,7 +37,18 @@ templates = Jinja2Templates(directory=str(Path(__file__).parent / "templates")) def startup() -> None: if not ADMIN_PASSWORD or not SYNC_API_TOKEN: raise RuntimeError("ADMIN_PASSWORD and SYNC_API_TOKEN must be configured") - Base.metadata.create_all(bind=engine) + # Postgres may briefly restart or lag behind its health check at deploy time. + for attempt in range(1, 11): + try: + Base.metadata.create_all(bind=engine) + break + except OperationalError as exc: + if attempt == 10: + raise + logging.getLogger("uvicorn.error").warning( + "Database not ready (attempt %s/10): %s", attempt, exc.orig + ) + time.sleep(2) UPLOAD_DIR.mkdir(parents=True, exist_ok=True) diff --git a/docker-compose.yml b/docker-compose.yml index 4d78548..4441603 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -17,7 +17,11 @@ services: app: build: . environment: - DATABASE_URL: postgresql+psycopg://${POSTGRES_USER:-time_track}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-time_track} + POSTGRES_HOST: postgres + POSTGRES_PORT: "5432" + POSTGRES_USER: ${POSTGRES_USER:-time_track} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env} + POSTGRES_DB: ${POSTGRES_DB:-time_track} ADMIN_PASSWORD: ${ADMIN_PASSWORD:?Set ADMIN_PASSWORD in .env} SYNC_API_TOKEN: ${SYNC_API_TOKEN:?Set SYNC_API_TOKEN in .env} SESSION_SECRET: ${SESSION_SECRET:?Set SESSION_SECRET in .env}