Files
yolkbook/backend/schemas.py
derekc 37f19a83ed Implement security hardening across frontend, backend, and infrastructure
- nginx: add X-Content-Type-Options, X-Frame-Options, X-XSS-Protection,
  and Referrer-Policy headers on all responses; rate limit /api/auth/login
  to 5 req/min per IP (burst 3) to prevent brute force
- frontend: add escHtml() utility to api.js; use it on all notes fields
  across dashboard, log, history, flock, and budget pages to prevent XSS
- log.js: fix broken loadRecent() call referencing removed #recent-body
  element; replaced with loadHistory() from history.js
- schemas.py: raise minimum password length from 6 to 10 characters
- admin.py: add audit logging for password reset, disable, delete, and
  impersonate actions; fix impersonate to use named admin param for logging
- main.py: add startup env validation — exits with clear error if any
  required env var is missing; configure structured logging to stdout
- docker-compose.yml: add log rotation (10 MB / 3 files) to all services

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 23:55:08 -07:00

173 lines
5.8 KiB
Python

from datetime import date, datetime
from decimal import Decimal
from typing import Optional
from pydantic import BaseModel, Field
# ── Auth ──────────────────────────────────────────────────────────────────────
class LoginRequest(BaseModel):
username: str
password: str
class TokenResponse(BaseModel):
access_token: str
token_type: str = "bearer"
class ChangePasswordRequest(BaseModel):
current_password: str
new_password: str = Field(min_length=10)
class ResetPasswordRequest(BaseModel):
new_password: str = Field(min_length=10)
class TimezoneUpdate(BaseModel):
timezone: str = Field(min_length=1, max_length=64)
# ── Users ─────────────────────────────────────────────────────────────────────
class UserCreate(BaseModel):
username: str = Field(min_length=2, max_length=64)
password: str = Field(min_length=10)
class UserOut(BaseModel):
id: int
username: str
is_admin: bool
is_disabled: bool
timezone: str
created_at: datetime
model_config = {"from_attributes": True}
# ── Egg Collections ───────────────────────────────────────────────────────────
class EggCollectionCreate(BaseModel):
date: date
eggs: int = Field(ge=0)
notes: Optional[str] = None
class EggCollectionUpdate(BaseModel):
date: Optional[date] = None
eggs: Optional[int] = Field(default=None, ge=0)
notes: Optional[str] = None
class EggCollectionOut(BaseModel):
id: int
date: date
eggs: int
notes: Optional[str]
created_at: datetime
model_config = {"from_attributes": True}
# ── Flock History ─────────────────────────────────────────────────────────────
class FlockHistoryCreate(BaseModel):
date: date
chicken_count: int = Field(ge=0)
notes: Optional[str] = None
class FlockHistoryUpdate(BaseModel):
date: Optional[date] = None
chicken_count: Optional[int] = Field(default=None, ge=0)
notes: Optional[str] = None
class FlockHistoryOut(BaseModel):
id: int
date: date
chicken_count: int
notes: Optional[str]
created_at: datetime
model_config = {"from_attributes": True}
# ── Feed Purchases ────────────────────────────────────────────────────────────
class FeedPurchaseCreate(BaseModel):
date: date
bags: Decimal = Field(gt=0, decimal_places=2)
price_per_bag: Decimal = Field(gt=0, decimal_places=2)
notes: Optional[str] = None
class FeedPurchaseUpdate(BaseModel):
date: Optional[date] = None
bags: Optional[Decimal] = Field(default=None, gt=0, decimal_places=2)
price_per_bag: Optional[Decimal] = Field(default=None, gt=0, decimal_places=2)
notes: Optional[str] = None
class FeedPurchaseOut(BaseModel):
id: int
date: date
bags: Decimal
price_per_bag: Decimal
notes: Optional[str]
created_at: datetime
model_config = {"from_attributes": True}
# ── Other Purchases ───────────────────────────────────────────────────────────
class OtherPurchaseCreate(BaseModel):
date: date
total: Decimal = Field(gt=0, decimal_places=2)
notes: Optional[str] = None
class OtherPurchaseUpdate(BaseModel):
date: Optional[date] = None
total: Optional[Decimal] = Field(default=None, gt=0, decimal_places=2)
notes: Optional[str] = None
class OtherPurchaseOut(BaseModel):
id: int
date: date
total: Decimal
notes: Optional[str]
created_at: datetime
model_config = {"from_attributes": True}
# ── Stats ─────────────────────────────────────────────────────────────────────
class MonthlySummary(BaseModel):
year: int
month: int
month_label: str
total_eggs: int
days_logged: int
avg_eggs_per_day: Optional[float]
flock_at_month_end: Optional[int]
avg_eggs_per_hen_per_day: Optional[float]
feed_cost: Optional[Decimal]
other_cost: Optional[Decimal]
cost_per_egg: Optional[Decimal]
cost_per_dozen: Optional[Decimal]
class DashboardStats(BaseModel):
current_flock: Optional[int]
total_eggs_alltime: int
total_eggs_30d: int
total_eggs_7d: int
avg_eggs_per_day_30d: Optional[float]
avg_eggs_per_hen_day_30d: Optional[float]
days_tracked: int
class BudgetStats(BaseModel):
total_feed_cost: Optional[Decimal]
total_feed_cost_30d: Optional[Decimal]
total_other_cost: Optional[Decimal]
total_other_cost_30d: Optional[Decimal]
total_eggs_alltime: int
total_eggs_30d: int
cost_per_egg: Optional[Decimal]
cost_per_dozen: Optional[Decimal]
cost_per_egg_30d: Optional[Decimal]
cost_per_dozen_30d: Optional[Decimal]