diff --git a/backend/main.py b/backend/main.py index ea7ebc4..487a0b6 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,5 +1,6 @@ import os import logging +import sys from contextlib import asynccontextmanager from fastapi import FastAPI @@ -12,8 +13,21 @@ from auth import hash_password from routers import eggs, flock, feed, stats, other from routers import auth_router, admin +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)s %(name)s: %(message)s", + stream=sys.stdout, +) logger = logging.getLogger("yolkbook") +_REQUIRED_ENV = ["ADMIN_USERNAME", "ADMIN_PASSWORD", "JWT_SECRET", "DATABASE_URL"] + +def _validate_env(): + missing = [k for k in _REQUIRED_ENV if not os.environ.get(k)] + if missing: + logger.critical("Missing required environment variables: %s", ", ".join(missing)) + sys.exit(1) + def _seed_admin(): """Create or update the admin user from environment variables. @@ -68,6 +82,7 @@ def _run_migrations(): @asynccontextmanager async def lifespan(app: FastAPI): + _validate_env() Base.metadata.create_all(bind=engine) _run_migrations() _seed_admin() diff --git a/backend/routers/admin.py b/backend/routers/admin.py index c945077..f7d336e 100644 --- a/backend/routers/admin.py +++ b/backend/routers/admin.py @@ -1,3 +1,5 @@ +import logging + from fastapi import APIRouter, Depends, HTTPException from sqlalchemy import select from sqlalchemy.orm import Session @@ -8,6 +10,7 @@ from schemas import UserCreate, UserOut, ResetPasswordRequest, TokenResponse from auth import hash_password, create_access_token, get_current_admin, get_current_user router = APIRouter(prefix="/api/admin", tags=["admin"]) +logger = logging.getLogger("yolkbook") @router.get("/users", response_model=list[UserOut]) @@ -50,6 +53,7 @@ def reset_password( raise HTTPException(status_code=404, detail="User not found") user.hashed_password = hash_password(body.new_password) db.commit() + logger.warning("Admin '%s' reset password for user '%s' (id=%d).", current_admin.username, user.username, user.id) return {"detail": f"Password reset for {user.username}"} @@ -66,6 +70,7 @@ def disable_user( raise HTTPException(status_code=400, detail="Cannot disable your own account") user.is_disabled = True db.commit() + logger.warning("Admin '%s' disabled user '%s' (id=%d).", current_admin.username, user.username, user.id) return {"detail": f"User {user.username} disabled"} @@ -94,6 +99,7 @@ def delete_user( raise HTTPException(status_code=404, detail="User not found") if user.id == current_admin.id: raise HTTPException(status_code=400, detail="Cannot delete your own account") + logger.warning("Admin '%s' deleted user '%s' (id=%d).", current_admin.username, user.username, user.id) db.delete(user) db.commit() @@ -101,11 +107,12 @@ def delete_user( @router.post("/users/{user_id}/impersonate", response_model=TokenResponse) def impersonate_user( user_id: int, - _: User = Depends(get_current_admin), + current_admin: User = Depends(get_current_admin), db: Session = Depends(get_db), ): user = db.get(User, user_id) if not user: raise HTTPException(status_code=404, detail="User not found") token = create_access_token(user.id, user.username, user.is_admin, user.timezone) + logger.warning("Admin '%s' (id=%d) is impersonating user '%s' (id=%d).", current_admin.username, current_admin.id, user.username, user.id) return TokenResponse(access_token=token) diff --git a/backend/schemas.py b/backend/schemas.py index 193b4a5..f318672 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -16,10 +16,10 @@ class TokenResponse(BaseModel): class ChangePasswordRequest(BaseModel): current_password: str - new_password: str = Field(min_length=6) + new_password: str = Field(min_length=10) class ResetPasswordRequest(BaseModel): - new_password: str = Field(min_length=6) + new_password: str = Field(min_length=10) class TimezoneUpdate(BaseModel): timezone: str = Field(min_length=1, max_length=64) @@ -29,7 +29,7 @@ class TimezoneUpdate(BaseModel): class UserCreate(BaseModel): username: str = Field(min_length=2, max_length=64) - password: str = Field(min_length=6) + password: str = Field(min_length=10) class UserOut(BaseModel): id: int diff --git a/docker-compose.yml b/docker-compose.yml index e918891..bd5e5b9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,6 +5,11 @@ services: image: mysql:8.0 restart: unless-stopped env_file: .env + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" environment: MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD} MYSQL_DATABASE: ${MYSQL_DATABASE} @@ -27,6 +32,11 @@ services: build: ./backend restart: unless-stopped env_file: .env + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" environment: DATABASE_URL: mysql+pymysql://${MYSQL_USER}:${MYSQL_PASSWORD}@db/${MYSQL_DATABASE} ADMIN_USERNAME: ${ADMIN_USERNAME} @@ -42,6 +52,11 @@ services: nginx: image: nginx:alpine restart: unless-stopped + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" ports: - "8056:80" volumes: diff --git a/nginx/html/js/api.js b/nginx/html/js/api.js index 7fffef9..9c09125 100644 --- a/nginx/html/js/api.js +++ b/nginx/html/js/api.js @@ -66,6 +66,17 @@ function fmtMoneyFull(val) { return '$' + Number(val).toFixed(4); } +// Escape HTML special characters to prevent XSS when rendering user content +function escHtml(str) { + if (!str) return ''; + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + // Highlight the nav link that matches the current page function highlightNav() { const path = window.location.pathname.replace(/\.html$/, '').replace(/\/$/, '') || '/'; diff --git a/nginx/html/js/budget.js b/nginx/html/js/budget.js index 4ceb039..2b05f5a 100644 --- a/nginx/html/js/budget.js +++ b/nginx/html/js/budget.js @@ -59,7 +59,7 @@ function renderTable() {