diff --git a/.env.example b/.env.example index 74da7fb..92c838e 100644 --- a/.env.example +++ b/.env.example @@ -14,6 +14,7 @@ HISTORY_LOOKBACK_DAYS=30 LIBRARYSYNC_JWT_ACCESS_TOKEN_MINUTES=60 LIBRARYSYNC_JWT_ALGORITHM=HS256 LIBRARYSYNC_ALLOW_REGISTRATION=true +LIBRARYSYNC_MAX_USERS=1 LIBRARYSYNC_GZIP_ENABLED=true LIBRARYSYNC_GZIP_MIN_SIZE=500 # Enable or disable dashboard statistics (set to false to disable) diff --git a/README.md b/README.md index b63d958..47f0cf4 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,7 @@ All defaults below are from `.env.example`. - `LIBRARYSYNC_JWT_ACCESS_TOKEN_MINUTES` (default `60`): access token lifetime. - `LIBRARYSYNC_JWT_ALGORITHM` (default `HS256`): JWT signing algorithm. - `LIBRARYSYNC_ALLOW_REGISTRATION` (default `true`): enable `/api/auth/register`. +- `LIBRARYSYNC_MAX_USERS` (default `1`): max number of registered users (`-1` for unlimited). ### OAuth - `TRAKT_CLIENT_ID` (default `your_trakt_client_id`): Trakt OAuth app client ID. diff --git a/backend/src/librarysync/api/routes_auth.py b/backend/src/librarysync/api/routes_auth.py index 8bcdc0d..b68ff36 100644 --- a/backend/src/librarysync/api/routes_auth.py +++ b/backend/src/librarysync/api/routes_auth.py @@ -1,7 +1,7 @@ from fastapi import APIRouter, Depends, HTTPException, status from fastapi.responses import JSONResponse from pydantic import BaseModel -from sqlalchemy import select +from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from librarysync.api.deps import get_current_user, get_db @@ -35,6 +35,13 @@ def _normalize_username(username: str) -> str: async def register(payload: LoginRequest, db: AsyncSession = Depends(get_db)) -> UserOut: if not settings.allow_registration: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Registration disabled") + if settings.max_users >= 0: + result = await db.execute(select(func.count(User.id))) + user_count = result.scalar_one() + if user_count >= settings.max_users: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, detail="User limit reached" + ) username = _normalize_username(payload.username) result = await db.execute(select(User).where(User.username == username)) existing = result.scalar_one_or_none() diff --git a/backend/src/librarysync/config.py b/backend/src/librarysync/config.py index 40c897e..be1b3d4 100644 --- a/backend/src/librarysync/config.py +++ b/backend/src/librarysync/config.py @@ -24,6 +24,7 @@ class Settings: jwt_access_token_minutes: int jwt_algorithm: str allow_registration: bool + max_users: int gzip_enabled: bool gzip_min_size: int trakt_rate_limit_per_minute: int @@ -59,6 +60,7 @@ def load_settings() -> Settings: allow_registration=( (_get_env("LIBRARYSYNC_ALLOW_REGISTRATION", "true") or "true").lower() == "true" ), + max_users=int(_get_env("LIBRARYSYNC_MAX_USERS", "1") or "1"), gzip_enabled=( (_get_env("LIBRARYSYNC_GZIP_ENABLED", "true") or "true").lower() == "true" ), diff --git a/backend/src/librarysync/main.py b/backend/src/librarysync/main.py index d55d23e..8a12e6f 100644 --- a/backend/src/librarysync/main.py +++ b/backend/src/librarysync/main.py @@ -6,6 +6,8 @@ from fastapi.responses import FileResponse from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession from librarysync.api import ( routes_activity, @@ -19,7 +21,7 @@ routes_settings, routes_watchlist, ) -from librarysync.api.deps import get_optional_user +from librarysync.api.deps import get_db, get_optional_user from librarysync.config import settings from librarysync.db.migrate import run_migrations from librarysync.db.models import User @@ -216,7 +218,13 @@ async def index( async def login( request: Request, current_user: User | None = Depends(get_optional_user), + db: AsyncSession = Depends(get_db), ): + registration_full = False + if settings.allow_registration and settings.max_users >= 0: + result = await db.execute(select(func.count(User.id))) + user_count = result.scalar_one() + registration_full = user_count >= settings.max_users return _render_page( request, "login.html", @@ -224,6 +232,8 @@ async def login( active_page="login", guest_only=True, allow_registration=settings.allow_registration, + registration_full=registration_full, + max_users=settings.max_users, current_user=current_user, ) diff --git a/backend/src/librarysync/templates/login.html b/backend/src/librarysync/templates/login.html index 25c5569..9408909 100644 --- a/backend/src/librarysync/templates/login.html +++ b/backend/src/librarysync/templates/login.html @@ -56,7 +56,7 @@
+ This server has reached its user limit. +
+- Registration is disabled on this server. Ask an admin to create your account. + Registration is disabled on this server.