Fix DATABASE_URL corruption from special characters in POSTGRES_PASSWORD

This commit is contained in:
2026-09-14 14:06:52 +02:00
parent 25eb7f50e6
commit 925f3111df
5 changed files with 44 additions and 4 deletions
+1
View File
@@ -1,4 +1,5 @@
# Bind directly to the Tailscale address in production. # Bind directly to the Tailscale address in production.
# All password/token values may contain special characters like @ : / # %.
BIND_IP=100.103.83.12 BIND_IP=100.103.83.12
POSTGRES_DB=time_track POSTGRES_DB=time_track
POSTGRES_USER=time_track POSTGRES_USER=time_track
+1
View File
@@ -16,6 +16,7 @@ A self-hosted, single-user combined timesheet server for `time_track` manual int
cp .env.example .env cp .env.example .env
# Generate three distinct high-entropy values for POSTGRES_PASSWORD, ADMIN_PASSWORD, # Generate three distinct high-entropy values for POSTGRES_PASSWORD, ADMIN_PASSWORD,
# SYNC_API_TOKEN, and SESSION_SECRET. Keep .env private. # SYNC_API_TOKEN, and SESSION_SECRET. Keep .env private.
# Values containing special characters (for example @ or :) are safe.
docker compose up --build -d docker compose up --build -d
``` ```
+22 -2
View File
@@ -1,10 +1,30 @@
import os import os
from sqlalchemy import create_engine from sqlalchemy import create_engine
from sqlalchemy.engine import URL
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker 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) SessionLocal = sessionmaker(bind=engine, autocommit=False, autoflush=False)
+15 -1
View File
@@ -1,6 +1,8 @@
import logging
import os import os
import secrets import secrets
import shutil import shutil
import time
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Annotated from typing import Annotated
@@ -12,6 +14,7 @@ from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from pypdf import PdfReader from pypdf import PdfReader
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.exc import OperationalError
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from starlette.middleware.sessions import SessionMiddleware from starlette.middleware.sessions import SessionMiddleware
@@ -34,7 +37,18 @@ templates = Jinja2Templates(directory=str(Path(__file__).parent / "templates"))
def startup() -> None: def startup() -> None:
if not ADMIN_PASSWORD or not SYNC_API_TOKEN: if not ADMIN_PASSWORD or not SYNC_API_TOKEN:
raise RuntimeError("ADMIN_PASSWORD and SYNC_API_TOKEN must be configured") 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) UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
+5 -1
View File
@@ -17,7 +17,11 @@ services:
app: app:
build: . build: .
environment: 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} ADMIN_PASSWORD: ${ADMIN_PASSWORD:?Set ADMIN_PASSWORD in .env}
SYNC_API_TOKEN: ${SYNC_API_TOKEN:?Set SYNC_API_TOKEN in .env} SYNC_API_TOKEN: ${SYNC_API_TOKEN:?Set SYNC_API_TOKEN in .env}
SESSION_SECRET: ${SESSION_SECRET:?Set SESSION_SECRET in .env} SESSION_SECRET: ${SESSION_SECRET:?Set SESSION_SECRET in .env}