From 683b7d9c1bdf90bef3ca67117dbcbe79d581bb2b Mon Sep 17 00:00:00 2001 From: Davide Scaini Date: Fri, 10 Apr 2026 12:35:34 +0200 Subject: [PATCH] limit max number of users --- bincio/serve/cli.py | 18 +++++++++++++++++- bincio/serve/db.py | 27 +++++++++++++++++++++++++++ bincio/serve/init_cmd.py | 12 ++++++++++-- bincio/serve/server.py | 8 ++++++++ 4 files changed, 62 insertions(+), 3 deletions(-) diff --git a/bincio/serve/cli.py b/bincio/serve/cli.py index 207a97b..085ce0b 100644 --- a/bincio/serve/cli.py +++ b/bincio/serve/cli.py @@ -18,8 +18,10 @@ console = Console() @click.option("--port", default=4041, help="Bind port (default: 4041)") @click.option("--strava-client-id", default=None, envvar="STRAVA_CLIENT_ID", help="Strava OAuth client ID (enables per-user Strava sync)") @click.option("--strava-client-secret", default=None, envvar="STRAVA_CLIENT_SECRET", help="Strava OAuth client secret") +@click.option("--max-users", default=None, type=int, help="Override max users for this instance (0 = unlimited; updates the DB setting)") def serve(data_dir: str, site_dir: Optional[str], host: str, port: int, - strava_client_id: Optional[str], strava_client_secret: Optional[str]) -> None: + strava_client_id: Optional[str], strava_client_secret: Optional[str], + max_users: Optional[int]) -> None: """Start the bincio multi-user application server. Handles auth, user management, and write operations. @@ -29,6 +31,7 @@ def serve(data_dir: str, site_dir: Optional[str], host: str, port: int, """ import uvicorn import bincio.serve.server as srv + from bincio.serve.db import open_db, set_setting, get_setting dd = Path(data_dir).expanduser().resolve() if not (dd / "instance.db").exists(): @@ -36,6 +39,11 @@ def serve(data_dir: str, site_dir: Optional[str], host: str, port: int, f"No instance.db found in {dd}. Run `bincio init --data-dir {dd}` first." ) + if max_users is not None: + db = open_db(dd) + set_setting(db, "max_users", str(max_users)) + db.close() + srv.data_dir = dd if site_dir: srv.site_dir = Path(site_dir).expanduser().resolve() @@ -44,11 +52,19 @@ def serve(data_dir: str, site_dir: Optional[str], host: str, port: int, if strava_client_secret: srv.strava_client_secret = strava_client_secret + db = open_db(dd) + current_limit = get_setting(db, "max_users") + db.close() + console.print(f"[bold]bincio serve[/bold]") console.print(f" Data: [cyan]{dd}[/cyan]") if srv.site_dir: console.print(f" Site: [cyan]{srv.site_dir}[/cyan]") console.print(f" URL: [cyan]http://{host}:{port}[/cyan]") + if current_limit and int(current_limit) > 0: + console.print(f" Users: [yellow]max {current_limit}[/yellow]") + else: + console.print(f" Users: [dim]unlimited[/dim]") console.print() uvicorn.run(srv.app, host=host, port=port, log_level="info") diff --git a/bincio/serve/db.py b/bincio/serve/db.py index 16d3b7f..d939ad4 100644 --- a/bincio/serve/db.py +++ b/bincio/serve/db.py @@ -45,6 +45,11 @@ CREATE TABLE IF NOT EXISTS invites ( used_at INTEGER ); +CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL +); + CREATE INDEX IF NOT EXISTS sessions_handle ON sessions(handle); CREATE INDEX IF NOT EXISTS invites_created_by ON invites(created_by); """ @@ -149,6 +154,28 @@ def delete_user(db: sqlite3.Connection, handle: str) -> None: db.commit() +def count_users(db: sqlite3.Connection) -> int: + """Return the total number of registered users.""" + row = db.execute("SELECT COUNT(*) FROM users").fetchone() + return row[0] if row else 0 + + +# ── Settings ────────────────────────────────────────────────────────────────── + +def get_setting(db: sqlite3.Connection, key: str) -> Optional[str]: + row = db.execute("SELECT value FROM settings WHERE key = ?", (key,)).fetchone() + return row["value"] if row else None + + +def set_setting(db: sqlite3.Connection, key: str, value: str) -> None: + db.execute( + "INSERT INTO settings (key, value) VALUES (?, ?) " + "ON CONFLICT(key) DO UPDATE SET value = excluded.value", + (key, value), + ) + db.commit() + + # ── Sessions ────────────────────────────────────────────────────────────────── def create_session(db: sqlite3.Connection, handle: str) -> str: diff --git a/bincio/serve/init_cmd.py b/bincio/serve/init_cmd.py index 3f5365a..5122857 100644 --- a/bincio/serve/init_cmd.py +++ b/bincio/serve/init_cmd.py @@ -17,13 +17,14 @@ console = Console() @click.option("--password", required=True, hide_input=True, confirmation_prompt=True, help="Admin password") @click.option("--display-name", default="", help="Admin display name (defaults to handle)") @click.option("--name", default="", help="Instance name shown in the feed") -def init(data_dir: str, handle: str, password: str, display_name: str, name: str) -> None: +@click.option("--max-users", default=0, type=int, help="Maximum number of users allowed (0 = unlimited)") +def init(data_dir: str, handle: str, password: str, display_name: str, name: str, max_users: int) -> None: """Bootstrap a fresh bincio multi-user instance. Creates the SQLite database, the admin user, the per-user data directory, and prints a first invite code. Safe to re-run — skips steps already done. """ - from bincio.serve.db import create_invite, create_user, get_user, open_db + from bincio.serve.db import create_invite, create_user, get_user, open_db, set_setting dd = Path(data_dir).expanduser().resolve() dd.mkdir(parents=True, exist_ok=True) @@ -67,6 +68,13 @@ def init(data_dir: str, handle: str, password: str, display_name: str, name: str else: console.print(" [yellow]·[/yellow] root index.json already exists — skipping") + # ── User limit ──────────────────────────────────────────────────────────── + if max_users > 0: + set_setting(db, "max_users", str(max_users)) + console.print(f" [green]✓[/green] user limit set to {max_users}") + else: + console.print(" [dim]·[/dim] no user limit (unlimited)") + # ── First invite code ───────────────────────────────────────────────────── code = create_invite(db, handle) diff --git a/bincio/serve/server.py b/bincio/serve/server.py index b2a01e3..3e6e202 100644 --- a/bincio/serve/server.py +++ b/bincio/serve/server.py @@ -25,10 +25,12 @@ from bincio.serve.db import ( authenticate, create_invite, create_session, + count_users, create_user, delete_session, get_invite, get_session, + get_setting, get_user, list_invites, list_users, @@ -221,6 +223,12 @@ async def register(request: Request) -> JSONResponse: if get_user(_get_db(), handle): raise HTTPException(409, "Handle already taken") + max_users_val = get_setting(_get_db(), "max_users") + if max_users_val is not None: + limit = int(max_users_val) + if limit > 0 and count_users(_get_db()) >= limit: + raise HTTPException(403, f"This instance has reached its user limit ({limit})") + create_user(_get_db(), handle, display, password, is_admin=False) use_invite(_get_db(), code, handle)