Add HSTS, gzip, Ntfy notifications, and paginate entries endpoint

- nginx: add HSTS (max-age=31536000) and gzip for text/css/js/json
- entries: add limit/offset query params (default 200, max 1000)
- Ntfy: wire NTFY_URL/NTFY_TOKEN through env; notify on startup, new
  registration, admin login, user disable, and user delete
- httpx==0.28.1 added for async Ntfy HTTP calls

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-25 01:12:59 -07:00
parent 2c45e599c0
commit e08e031ad0
10 changed files with 49 additions and 1 deletions
+4
View File
@@ -15,3 +15,7 @@ ADMIN_PASSWORD=changeme_admin
# CORS: comma-separated list of allowed origins # CORS: comma-separated list of allowed origins
ALLOWED_ORIGINS=https://yourdomain.com ALLOWED_ORIGINS=https://yourdomain.com
# Ntfy push notifications (optional — leave blank to disable)
NTFY_URL=https://ntfy.example.com/your-topic
NTFY_TOKEN=
+3
View File
@@ -1,3 +1,4 @@
from typing import Optional
from pydantic_settings import BaseSettings from pydantic_settings import BaseSettings
@@ -9,6 +10,8 @@ class Settings(BaseSettings):
admin_username: str admin_username: str
admin_password: str admin_password: str
allowed_origins: str = "http://localhost" allowed_origins: str = "http://localhost"
ntfy_url: Optional[str] = None
ntfy_token: Optional[str] = None
class Config: class Config:
env_file = ".env" env_file = ".env"
+2
View File
@@ -43,6 +43,8 @@ async def lifespan(app: FastAPI):
await init_db() await init_db()
await _seed_admin() await _seed_admin()
logger.info("Bourbonacci started") logger.info("Bourbonacci started")
from app.utils.notify import notify
await notify("Bourbonacci started", "Service startup complete.", priority="low")
yield yield
logger.info("Bourbonacci shutting down") logger.info("Bourbonacci shutting down")
+3
View File
@@ -6,6 +6,7 @@ from app.dependencies import get_db, get_current_admin, bearer_scheme
from app.models.user import User from app.models.user import User
from app.schemas.user import AdminUserCreate, AdminPasswordReset, AdminUserResponse, Token from app.schemas.user import AdminUserCreate, AdminPasswordReset, AdminUserResponse, Token
from app.utils.security import hash_password, create_token from app.utils.security import hash_password, create_token
from app.utils.notify import notify
router = APIRouter(prefix="/api/admin", tags=["admin"]) router = APIRouter(prefix="/api/admin", tags=["admin"])
@@ -73,6 +74,7 @@ async def disable_user(
user.is_disabled = True user.is_disabled = True
await db.commit() await db.commit()
await notify("User disabled", f"Account disabled: {user.email} (by admin {current_admin.email})", priority="default")
@router.post("/users/{user_id}/enable", status_code=status.HTTP_204_NO_CONTENT) @router.post("/users/{user_id}/enable", status_code=status.HTTP_204_NO_CONTENT)
@@ -106,6 +108,7 @@ async def delete_user(
await db.delete(user) await db.delete(user)
await db.commit() await db.commit()
await notify("User deleted", f"Account deleted: {user.email} (by admin {current_admin.email})", priority="default")
@router.post("/users/{user_id}/impersonate", response_model=Token) @router.post("/users/{user_id}/impersonate", response_model=Token)
+4
View File
@@ -10,6 +10,7 @@ from app.limiter import limiter
from app.models.user import User from app.models.user import User
from app.schemas.user import UserCreate, Token, LoginRequest from app.schemas.user import UserCreate, Token, LoginRequest
from app.utils.security import hash_password, verify_password, create_token from app.utils.security import hash_password, verify_password, create_token
from app.utils.notify import notify
logger = logging.getLogger("bourbonacci.auth") logger = logging.getLogger("bourbonacci.auth")
router = APIRouter(prefix="/api/auth", tags=["auth"]) router = APIRouter(prefix="/api/auth", tags=["auth"])
@@ -36,6 +37,7 @@ async def register(request: Request, body: UserCreate, db: AsyncSession = Depend
await db.refresh(user) await db.refresh(user)
logger.info("New user registered: %s", body.email) logger.info("New user registered: %s", body.email)
await notify("New registration", f"User registered: {body.email}", priority="default")
return Token(access_token=create_token(user.id)) return Token(access_token=create_token(user.id))
@@ -54,4 +56,6 @@ async def login(request: Request, body: LoginRequest, db: AsyncSession = Depends
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Account disabled") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Account disabled")
logger.info("Login: %s (id=%s)", body.email, user.id) logger.info("Login: %s (id=%s)", body.email, user.id)
if user.is_admin:
await notify("Admin login", f"Admin login: {body.email}", priority="high")
return Token(access_token=create_token(user.id)) return Token(access_token=create_token(user.id))
+5 -1
View File
@@ -1,4 +1,4 @@
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select from sqlalchemy import select
@@ -33,6 +33,8 @@ def _calc_stats(entries: list[Entry]) -> BottleStats:
@router.get("", response_model=list[EntryResponse]) @router.get("", response_model=list[EntryResponse])
async def list_entries( async def list_entries(
limit: int = Query(default=200, ge=1, le=1000),
offset: int = Query(default=0, ge=0),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
): ):
@@ -40,6 +42,8 @@ async def list_entries(
select(Entry) select(Entry)
.where(Entry.user_id == current_user.id) .where(Entry.user_id == current_user.id)
.order_by(Entry.date.desc(), Entry.created_at.desc()) .order_by(Entry.date.desc(), Entry.created_at.desc())
.limit(limit)
.offset(offset)
) )
return result.scalars().all() return result.scalars().all()
+19
View File
@@ -0,0 +1,19 @@
import logging
import httpx
from app.config import settings
logger = logging.getLogger("bourbonacci.notify")
async def notify(title: str, message: str, priority: str = "default") -> None:
if not settings.ntfy_url:
return
headers = {"Title": title, "Priority": priority}
if settings.ntfy_token:
headers["Authorization"] = f"Bearer {settings.ntfy_token}"
try:
async with httpx.AsyncClient(timeout=5) as client:
await client.post(settings.ntfy_url, content=message, headers=headers)
except Exception as exc:
logger.warning("Ntfy notification failed: %s", exc)
+1
View File
@@ -10,3 +10,4 @@ python-multipart==0.0.29
pytz==2024.2 pytz==2024.2
email-validator==2.2.0 email-validator==2.2.0
slowapi==0.1.9 slowapi==0.1.9
httpx==0.28.1
+2
View File
@@ -21,6 +21,8 @@ services:
- ADMIN_USERNAME=${ADMIN_USERNAME} - ADMIN_USERNAME=${ADMIN_USERNAME}
- ADMIN_PASSWORD=${ADMIN_PASSWORD} - ADMIN_PASSWORD=${ADMIN_PASSWORD}
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-http://localhost} - ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-http://localhost}
- NTFY_URL=${NTFY_URL:-}
- NTFY_TOKEN=${NTFY_TOKEN:-}
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
+6
View File
@@ -2,9 +2,15 @@ server {
listen 80; listen 80;
server_tokens off; server_tokens off;
gzip on;
gzip_types text/plain text/css application/json application/javascript text/javascript;
gzip_min_length 1024;
gzip_vary on;
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.html; index index.html;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Frame-Options "SAMEORIGIN" always; add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always; add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always; add_header X-XSS-Protection "1; mode=block" always;