Pin versions, add resource limits, and harden config

- Pin all Docker image tags (mysql 8.0.40, python 3.12.13-slim, node 20.20.1-alpine, nginx 1.29.6-alpine)
- Pin all frontend npm dependencies to exact versions (remove ^ ranges)
- Add mem_limit and cpus resource limits to all three containers
- Add non-root appuser to backend Dockerfile
- Migrate JWT from python-jose to PyJWT
- Remove default admin_password in config.py — must be explicitly set in .env
- Add DOCS_ENABLED flag to config and .env.example (default false)
- Add indexes on session_date, is_active, event_type in session models
- Add limit/offset pagination to all log endpoints

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-22 00:01:32 -07:00
parent 3022bc328b
commit 663b506868
9 changed files with 45 additions and 21 deletions

View File

@@ -14,6 +14,9 @@ REFRESH_TOKEN_EXPIRE_DAYS=30
# Comma-separated allowed CORS origins (no trailing slash) # Comma-separated allowed CORS origins (no trailing slash)
CORS_ORIGINS=http://localhost:8054 CORS_ORIGINS=http://localhost:8054
# Super admin credentials (server-level access) # Super admin credentials — REQUIRED, no defaults ship with the service
ADMIN_USERNAME=admin ADMIN_USERNAME=admin
ADMIN_PASSWORD=change_me_admin_password ADMIN_PASSWORD=change_me_strong_password_here
# Set to true only for local development (exposes /api/docs, /api/redoc)
DOCS_ENABLED=false

View File

@@ -1,4 +1,4 @@
FROM python:3.12-slim FROM python:3.12.13-slim
WORKDIR /app WORKDIR /app
@@ -11,6 +11,11 @@ RUN pip install --no-cache-dir -r requirements.txt
COPY . . COPY . .
RUN adduser --disabled-password --gecos '' --uid 1000 appuser \
&& chown -R appuser /app
USER appuser
EXPOSE 8000 EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

View File

@@ -1,7 +1,8 @@
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from typing import Any from typing import Any
from jose import JWTError, jwt import jwt
from jwt import PyJWTError
from passlib.context import CryptContext from passlib.context import CryptContext
from app.config import get_settings from app.config import get_settings
@@ -43,5 +44,5 @@ def create_refresh_token(data: dict[str, Any]) -> str:
def decode_token(token: str) -> dict[str, Any]: def decode_token(token: str) -> dict[str, Any]:
try: try:
return jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm]) return jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm])
except JWTError: except PyJWTError:
raise ValueError("Invalid or expired token") raise ValueError("Invalid or expired token")

View File

@@ -10,7 +10,8 @@ class Settings(BaseSettings):
refresh_token_expire_days: int = 30 refresh_token_expire_days: int = 30
cors_origins: str = "http://localhost:8054" cors_origins: str = "http://localhost:8054"
admin_username: str = "admin" admin_username: str = "admin"
admin_password: str = "change_me_admin_password" admin_password: str # no default — must be explicitly set in .env
docs_enabled: bool = False
@property @property
def cors_origins_list(self) -> list[str]: def cors_origins_list(self) -> list[str]:

View File

@@ -21,8 +21,8 @@ class DailySession(TimestampMixin, Base):
template_id: Mapped[int | None] = mapped_column( template_id: Mapped[int | None] = mapped_column(
ForeignKey("schedule_templates.id", ondelete="SET NULL"), nullable=True ForeignKey("schedule_templates.id", ondelete="SET NULL"), nullable=True
) )
session_date: Mapped[date] = mapped_column(Date, nullable=False) session_date: Mapped[date] = mapped_column(Date, nullable=False, index=True)
is_active: Mapped[bool] = mapped_column(default=True) is_active: Mapped[bool] = mapped_column(default=True, index=True)
current_block_id: Mapped[int | None] = mapped_column( current_block_id: Mapped[int | None] = mapped_column(
ForeignKey("schedule_blocks.id", ondelete="SET NULL"), nullable=True ForeignKey("schedule_blocks.id", ondelete="SET NULL"), nullable=True
) )
@@ -48,7 +48,7 @@ class TimerEvent(Base):
block_id: Mapped[int | None] = mapped_column( block_id: Mapped[int | None] = mapped_column(
ForeignKey("schedule_blocks.id", ondelete="SET NULL"), nullable=True ForeignKey("schedule_blocks.id", ondelete="SET NULL"), nullable=True
) )
event_type: Mapped[str] = mapped_column(String(20), nullable=False) event_type: Mapped[str] = mapped_column(String(20), nullable=False, index=True)
occurred_at: Mapped[datetime] = mapped_column(DateTime, default=func.now(), server_default=func.now()) occurred_at: Mapped[datetime] = mapped_column(DateTime, default=func.now(), server_default=func.now())
session: Mapped["DailySession"] = relationship("DailySession", back_populates="timer_events") session: Mapped["DailySession"] = relationship("DailySession", back_populates="timer_events")

View File

@@ -24,6 +24,8 @@ async def get_timeline(
log_date: date | None = None, log_date: date | None = None,
date_from: date | None = None, date_from: date | None = None,
date_to: date | None = None, date_to: date | None = None,
limit: int = 500,
offset: int = 0,
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
@@ -51,6 +53,7 @@ async def get_timeline(
if date_to: if date_to:
query = query.where(DailySession.session_date <= date_to) query = query.where(DailySession.session_date <= date_to)
query = query.limit(limit).offset(offset)
result = await db.execute(query) result = await db.execute(query)
events = result.scalars().all() events = result.scalars().all()
@@ -133,6 +136,8 @@ async def get_strike_events(
log_date: date | None = None, log_date: date | None = None,
date_from: date | None = None, date_from: date | None = None,
date_to: date | None = None, date_to: date | None = None,
limit: int = 500,
offset: int = 0,
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
@@ -152,6 +157,7 @@ async def get_strike_events(
if date_to: if date_to:
query = query.where(func.date(StrikeEvent.occurred_at) <= date_to) query = query.where(func.date(StrikeEvent.occurred_at) <= date_to)
query = query.limit(limit).offset(offset)
result = await db.execute(query) result = await db.execute(query)
events = result.scalars().all() events = result.scalars().all()
return [ return [
@@ -192,6 +198,8 @@ async def list_logs(
log_date: date | None = None, log_date: date | None = None,
date_from: date | None = None, date_from: date | None = None,
date_to: date | None = None, date_to: date | None = None,
limit: int = 500,
offset: int = 0,
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
@@ -210,7 +218,7 @@ async def list_logs(
if date_to: if date_to:
query = query.where(ActivityLog.log_date <= date_to) query = query.where(ActivityLog.log_date <= date_to)
result = await db.execute(query) result = await db.execute(query.limit(limit).offset(offset))
return result.scalars().all() return result.scalars().all()

View File

@@ -1,6 +1,6 @@
services: services:
db: db:
image: mysql:8.0 image: mysql:8.0.40
container_name: homeschool_db container_name: homeschool_db
restart: unless-stopped restart: unless-stopped
environment: environment:
@@ -17,6 +17,8 @@ services:
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 5 retries: 5
mem_limit: 512m
cpus: 1.0
backend: backend:
build: ./backend build: ./backend
@@ -29,13 +31,15 @@ services:
ACCESS_TOKEN_EXPIRE_MINUTES: ${ACCESS_TOKEN_EXPIRE_MINUTES:-30} ACCESS_TOKEN_EXPIRE_MINUTES: ${ACCESS_TOKEN_EXPIRE_MINUTES:-30}
REFRESH_TOKEN_EXPIRE_DAYS: ${REFRESH_TOKEN_EXPIRE_DAYS:-30} REFRESH_TOKEN_EXPIRE_DAYS: ${REFRESH_TOKEN_EXPIRE_DAYS:-30}
CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost:8057} CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost:8057}
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin} ADMIN_USERNAME: ${ADMIN_USERNAME}
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-change_me_admin_password} ADMIN_PASSWORD: ${ADMIN_PASSWORD}
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
networks: networks:
- homeschool_net - homeschool_net
mem_limit: 512m
cpus: 1.0
frontend: frontend:
build: ./frontend build: ./frontend
@@ -47,6 +51,8 @@ services:
- backend - backend
networks: networks:
- homeschool_net - homeschool_net
mem_limit: 128m
cpus: 0.5
networks: networks:
homeschool_net: homeschool_net:

View File

@@ -1,5 +1,5 @@
# Stage 1: Build Vue.js app # Stage 1: Build Vue.js app
FROM node:20-alpine AS builder FROM node:20.20.1-alpine AS builder
WORKDIR /app WORKDIR /app
@@ -10,7 +10,7 @@ COPY . .
RUN npm run build RUN npm run build
# Stage 2: Serve with nginx # Stage 2: Serve with nginx
FROM nginx:alpine FROM nginx:1.29.6-alpine
COPY --from=builder /app/dist /usr/share/nginx/html COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf COPY nginx.conf /etc/nginx/conf.d/default.conf

View File

@@ -8,13 +8,13 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"axios": "^1.7.7", "axios": "1.7.7",
"pinia": "^2.2.4", "pinia": "2.2.4",
"vue": "^3.5.12", "vue": "3.5.12",
"vue-router": "^4.4.5" "vue-router": "4.4.5"
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-vue": "^5.1.4", "@vitejs/plugin-vue": "5.1.4",
"vite": "^5.4.10" "vite": "5.4.10"
} }
} }