diff --git a/.env.example b/.env.example index 1477ce0..a10ed9f 100644 --- a/.env.example +++ b/.env.example @@ -15,3 +15,7 @@ ADMIN_PASSWORD=changeme_admin # CORS: comma-separated list of allowed origins ALLOWED_ORIGINS=https://yourdomain.com + +# Ntfy push notifications (optional — leave blank to disable) +NTFY_URL=https://ntfy.example.com/your-topic +NTFY_TOKEN= diff --git a/backend/app/config.py b/backend/app/config.py index 5f8b3ed..113dbf2 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -1,3 +1,4 @@ +from typing import Optional from pydantic_settings import BaseSettings @@ -9,6 +10,8 @@ class Settings(BaseSettings): admin_username: str admin_password: str allowed_origins: str = "http://localhost" + ntfy_url: Optional[str] = None + ntfy_token: Optional[str] = None class Config: env_file = ".env" diff --git a/backend/app/main.py b/backend/app/main.py index 9d484dd..3ab49c6 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -43,6 +43,8 @@ async def lifespan(app: FastAPI): await init_db() await _seed_admin() logger.info("Bourbonacci started") + from app.utils.notify import notify + await notify("Bourbonacci started", "Service startup complete.", priority="low") yield logger.info("Bourbonacci shutting down") diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py index 0bb7d0d..121e327 100644 --- a/backend/app/routers/admin.py +++ b/backend/app/routers/admin.py @@ -6,6 +6,7 @@ from app.dependencies import get_db, get_current_admin, bearer_scheme from app.models.user import User from app.schemas.user import AdminUserCreate, AdminPasswordReset, AdminUserResponse, Token from app.utils.security import hash_password, create_token +from app.utils.notify import notify router = APIRouter(prefix="/api/admin", tags=["admin"]) @@ -73,6 +74,7 @@ async def disable_user( user.is_disabled = True 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) @@ -106,6 +108,7 @@ async def delete_user( await db.delete(user) 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) diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index 1ce0c76..2f1b1af 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -10,6 +10,7 @@ from app.limiter import limiter from app.models.user import User from app.schemas.user import UserCreate, Token, LoginRequest from app.utils.security import hash_password, verify_password, create_token +from app.utils.notify import notify logger = logging.getLogger("bourbonacci.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) 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)) @@ -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") 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)) diff --git a/backend/app/routers/entries.py b/backend/app/routers/entries.py index 526b65b..108a5e7 100644 --- a/backend/app/routers/entries.py +++ b/backend/app/routers/entries.py @@ -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 import select @@ -33,6 +33,8 @@ def _calc_stats(entries: list[Entry]) -> BottleStats: @router.get("", response_model=list[EntryResponse]) 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), current_user: User = Depends(get_current_user), ): @@ -40,6 +42,8 @@ async def list_entries( select(Entry) .where(Entry.user_id == current_user.id) .order_by(Entry.date.desc(), Entry.created_at.desc()) + .limit(limit) + .offset(offset) ) return result.scalars().all() diff --git a/backend/app/utils/notify.py b/backend/app/utils/notify.py new file mode 100644 index 0000000..8e38ba6 --- /dev/null +++ b/backend/app/utils/notify.py @@ -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) diff --git a/backend/requirements.txt b/backend/requirements.txt index 9b56e94..f9fb2d2 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -10,3 +10,4 @@ python-multipart==0.0.29 pytz==2024.2 email-validator==2.2.0 slowapi==0.1.9 +httpx==0.28.1 diff --git a/docker-compose.yml b/docker-compose.yml index e56179c..c519049 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -21,6 +21,8 @@ services: - ADMIN_USERNAME=${ADMIN_USERNAME} - ADMIN_PASSWORD=${ADMIN_PASSWORD} - ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-http://localhost} + - NTFY_URL=${NTFY_URL:-} + - NTFY_TOKEN=${NTFY_TOKEN:-} depends_on: db: condition: service_healthy diff --git a/nginx/default.conf b/nginx/default.conf index 724ddd2..acfa721 100644 --- a/nginx/default.conf +++ b/nginx/default.conf @@ -2,9 +2,15 @@ server { listen 80; 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; index index.html; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; add_header X-Frame-Options "SAMEORIGIN" always; add_header X-Content-Type-Options "nosniff" always; add_header X-XSS-Protection "1; mode=block" always;