Fix MEDIUM findings from 2026-09-24 maintenance review

- Backend retries DB connection at startup (up to 180s) so host reboots
  no longer crash-loop it; add backend and frontend healthchecks
- Docker log rotation (json-file 10m x 3) on all services
- ntfy alerts use X-Real-IP (set by nginx after real_ip resolution)
  instead of the client-controlled first X-Forwarded-For entry
- Frontend build on Node 24 LTS with package-lock.json + npm ci;
  axios 1.20.0, vite 5.4.21
- README: backup/restore/rollback runbook, real-IP proxy trust notes
- Release-Notes/v1.1.md; version 1.1.0

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
derekcandClaude Opus 5.5 committed 2026-09-24 23:44:25 -07:00
1 parent 3170c7f4eb
commit 4130467b22
11 files changed
+1851 -20

No files matched your search

+19
View File
@@ -1,5 +1,7 @@
import asyncio
import logging
import random
import time
from contextlib import asynccontextmanager
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
@@ -67,8 +69,25 @@ async def _add_index_if_missing(conn, index_name: str, table: str, column: str):
raise
async def _wait_for_db(timeout: float = 180, interval: float = 2) -> None:
"""Retry until MySQL accepts connections. On host reboot Docker's restart
policy ignores depends_on, so the backend can start before the DB is up."""
deadline = time.monotonic() + timeout
while True:
try:
async with engine.connect() as conn:
await conn.execute(text("SELECT 1"))
return
except OperationalError as e:
if time.monotonic() >= deadline:
raise
logger.info("Database not ready (%s), retrying in %ss", e.orig, interval)
await asyncio.sleep(interval)
@asynccontextmanager
async def lifespan(app: FastAPI):
await _wait_for_db()
# Create tables on startup (Alembic handles migrations in prod, this is a safety net)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
+2 -1
View File
@@ -9,6 +9,7 @@ from app.auth.jwt import create_admin_token, create_access_token, hash_password
from app.config import get_settings
from app.dependencies import get_db, get_admin_user
from app.models.user import User
from app.utils.client_ip import client_ip
from app.utils.ntfy import notify
router = APIRouter(prefix="/api/admin", tags=["admin"])
@@ -23,7 +24,7 @@ async def admin_login(body: dict, request: Request):
logger.warning("Failed super-admin login attempt for username=%s", username)
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid admin credentials")
token = create_admin_token({"sub": "admin"})
ip = request.headers.get("X-Forwarded-For", request.client.host if request.client else "unknown").split(",")[0].strip()
ip = client_ip(request)
ua = request.headers.get("User-Agent", "unknown")
await notify(
title="Homeschool Dashboard Super Admin Login",
+3 -2
View File
@@ -18,6 +18,7 @@ from app.models.user import User
from app.models.subject import Subject
from app.schemas.auth import LoginRequest, RegisterRequest, TokenResponse
from app.schemas.user import UserOut
from app.utils.client_ip import client_ip
from app.utils.ntfy import notify
router = APIRouter(prefix="/api/auth", tags=["auth"])
@@ -59,7 +60,7 @@ async def register(body: RegisterRequest, response: Response, request: Request,
refresh = create_refresh_token({"sub": str(user.id)})
response.set_cookie(REFRESH_COOKIE, refresh, **COOKIE_OPTS)
ip = request.headers.get("X-Forwarded-For", request.client.host if request.client else "unknown").split(",")[0].strip()
ip = client_ip(request)
ua = request.headers.get("User-Agent", "unknown")
await notify(
title="Homeschool Dashboard New User Registered",
@@ -83,7 +84,7 @@ async def login(body: LoginRequest, response: Response, request: Request, db: As
raise HTTPException(status_code=401, detail="Invalid credentials")
now = datetime.now(timezone.utc).replace(tzinfo=None)
ip = request.headers.get("X-Forwarded-For", request.client.host if request.client else "unknown").split(",")[0].strip()
ip = client_ip(request)
ua = request.headers.get("User-Agent", "unknown")
if user.locked_until and user.locked_until > now:
+10
View File
@@ -0,0 +1,10 @@
from fastapi import Request
def client_ip(request: Request) -> str:
"""Real client IP as resolved by the frontend nginx (real_ip module).
X-Forwarded-For is client-controlled and must not be trusted here; nginx
sets X-Real-IP from $remote_addr after walking past trusted proxies.
"""
return request.headers.get("X-Real-IP") or (request.client.host if request.client else "unknown")