diff --git a/bincio/serve/db.py b/bincio/serve/db.py index 96f450e..051e3b2 100644 --- a/bincio/serve/db.py +++ b/bincio/serve/db.py @@ -23,11 +23,13 @@ import bcrypt _SCHEMA = """ CREATE TABLE IF NOT EXISTS users ( - handle TEXT PRIMARY KEY, - display_name TEXT NOT NULL DEFAULT '', - password_hash TEXT NOT NULL, - is_admin INTEGER NOT NULL DEFAULT 0, - created_at INTEGER NOT NULL + handle TEXT PRIMARY KEY, + display_name TEXT NOT NULL DEFAULT '', + password_hash TEXT NOT NULL, + is_admin INTEGER NOT NULL DEFAULT 0, + wiki_access INTEGER NOT NULL DEFAULT 1, + activity_access INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS sessions ( @@ -38,11 +40,12 @@ CREATE TABLE IF NOT EXISTS sessions ( ); CREATE TABLE IF NOT EXISTS invites ( - code TEXT PRIMARY KEY, - created_by TEXT NOT NULL REFERENCES users(handle) ON DELETE CASCADE, - used_by TEXT REFERENCES users(handle) ON DELETE SET NULL, - created_at INTEGER NOT NULL, - used_at INTEGER + code TEXT PRIMARY KEY, + created_by TEXT NOT NULL REFERENCES users(handle) ON DELETE CASCADE, + used_by TEXT REFERENCES users(handle) ON DELETE SET NULL, + created_at INTEGER NOT NULL, + used_at INTEGER, + grants_activity INTEGER NOT NULL DEFAULT 0 ); CREATE TABLE IF NOT EXISTS reset_codes ( @@ -81,19 +84,22 @@ _RESET_CODE_TTL_S = 24 * 3600 # 24 hours @dataclass class User: - handle: str - display_name: str - is_admin: bool - created_at: int + handle: str + display_name: str + is_admin: bool + wiki_access: bool + activity_access: bool + created_at: int @dataclass class Invite: - code: str - created_by: str - used_by: Optional[str] - created_at: int - used_at: Optional[int] + code: str + created_by: str + used_by: Optional[str] + created_at: int + used_at: Optional[int] + grants_activity: bool = False @property def used(self) -> bool: @@ -121,16 +127,20 @@ def create_user( display_name: str, password: str, is_admin: bool = False, + wiki_access: bool = True, + activity_access: bool = False, ) -> User: password_hash = bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode() now = int(time.time()) db.execute( - "INSERT INTO users (handle, display_name, password_hash, is_admin, created_at) " - "VALUES (?, ?, ?, ?, ?)", - (handle, display_name, password_hash, int(is_admin), now), + "INSERT INTO users (handle, display_name, password_hash, is_admin, " + "wiki_access, activity_access, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)", + (handle, display_name, password_hash, int(is_admin), + int(wiki_access), int(activity_access), now), ) db.commit() - return User(handle=handle, display_name=display_name, is_admin=is_admin, created_at=now) + return User(handle=handle, display_name=display_name, is_admin=is_admin, + wiki_access=wiki_access, activity_access=activity_access, created_at=now) def get_user(db: sqlite3.Connection, handle: str) -> Optional[User]: @@ -141,6 +151,8 @@ def get_user(db: sqlite3.Connection, handle: str) -> Optional[User]: handle=row["handle"], display_name=row["display_name"], is_admin=bool(row["is_admin"]), + wiki_access=bool(row["wiki_access"]), + activity_access=bool(row["activity_access"]), created_at=row["created_at"], ) @@ -158,6 +170,8 @@ def authenticate(db: sqlite3.Connection, handle: str, password: str) -> Optional handle=row["handle"], display_name=row["display_name"], is_admin=bool(row["is_admin"]), + wiki_access=bool(row["wiki_access"]), + activity_access=bool(row["activity_access"]), created_at=row["created_at"], ) @@ -172,7 +186,9 @@ def change_password(db: sqlite3.Connection, handle: str, new_password: str) -> N def list_users(db: sqlite3.Connection) -> list[User]: rows = db.execute("SELECT * FROM users ORDER BY created_at").fetchall() return [User(handle=r["handle"], display_name=r["display_name"], - is_admin=bool(r["is_admin"]), created_at=r["created_at"]) for r in rows] + is_admin=bool(r["is_admin"]), wiki_access=bool(r["wiki_access"]), + activity_access=bool(r["activity_access"]), + created_at=r["created_at"]) for r in rows] def delete_user(db: sqlite3.Connection, handle: str) -> None: @@ -213,6 +229,16 @@ def count_users(db: sqlite3.Connection) -> int: return row[0] if row else 0 +def count_wiki_users(db: sqlite3.Connection) -> int: + row = db.execute("SELECT COUNT(*) FROM users WHERE wiki_access = 1").fetchone() + return row[0] if row else 0 + + +def count_activity_users(db: sqlite3.Connection) -> int: + row = db.execute("SELECT COUNT(*) FROM users WHERE activity_access = 1").fetchone() + return row[0] if row else 0 + + # ── Settings ────────────────────────────────────────────────────────────────── def get_setting(db: sqlite3.Connection, key: str) -> Optional[str]: @@ -247,7 +273,8 @@ def create_session(db: sqlite3.Connection, handle: str) -> str: def get_session(db: sqlite3.Connection, token: str) -> Optional[User]: """Return the User owning this session, or None if expired/invalid.""" row = db.execute( - "SELECT s.handle, s.expires_at, u.display_name, u.is_admin, u.created_at " + "SELECT s.handle, s.expires_at, u.display_name, u.is_admin, " + "u.wiki_access, u.activity_access, u.created_at " "FROM sessions s JOIN users u ON s.handle = u.handle " "WHERE s.token = ?", (token,), @@ -261,6 +288,8 @@ def get_session(db: sqlite3.Connection, token: str) -> Optional[User]: handle=row["handle"], display_name=row["display_name"], is_admin=bool(row["is_admin"]), + wiki_access=bool(row["wiki_access"]), + activity_access=bool(row["activity_access"]), created_at=row["created_at"], ) @@ -281,10 +310,18 @@ def purge_expired_sessions(db: sqlite3.Connection) -> int: _MAX_USER_INVITES = 3 # regular users; admins are unlimited -def create_invite(db: sqlite3.Connection, created_by: str) -> str: - """Generate an invite code. Raises ValueError if the user has hit their limit.""" +def create_invite( + db: sqlite3.Connection, + created_by: str, + grants_activity: bool = False, +) -> str: + """Generate an invite code. Raises ValueError if limits are exceeded.""" user = get_user(db, created_by) - if user and not user.is_admin: + if not user: + raise ValueError("Unknown user") + if grants_activity and not user.activity_access and not user.is_admin: + raise ValueError("Cannot grant activity access you don't have") + if not user.is_admin: count = db.execute( "SELECT COUNT(*) FROM invites WHERE created_by = ?", (created_by,) ).fetchone()[0] @@ -293,8 +330,9 @@ def create_invite(db: sqlite3.Connection, created_by: str) -> str: code = secrets.token_urlsafe(_INVITE_LENGTH)[:_INVITE_LENGTH].upper() db.execute( - "INSERT INTO invites (code, created_by, created_at) VALUES (?, ?, ?)", - (code, created_by, int(time.time())), + "INSERT INTO invites (code, created_by, created_at, grants_activity) " + "VALUES (?, ?, ?, ?)", + (code, created_by, int(time.time()), int(grants_activity)), ) db.commit() return code @@ -327,6 +365,7 @@ def list_invites(db: sqlite3.Connection, handle: str) -> list[Invite]: used_by=r["used_by"], created_at=r["created_at"], used_at=r["used_at"], + grants_activity=bool(r["grants_activity"]), ) for r in rows ] @@ -342,6 +381,7 @@ def get_invite(db: sqlite3.Connection, code: str) -> Optional[Invite]: used_by=row["used_by"], created_at=row["created_at"], used_at=row["used_at"], + grants_activity=bool(row["grants_activity"]), ) diff --git a/bincio/serve/server.py b/bincio/serve/server.py index 7f99f43..c8c6739 100644 --- a/bincio/serve/server.py +++ b/bincio/serve/server.py @@ -10,6 +10,7 @@ from __future__ import annotations import json import logging +import os import re import secrets import shutil @@ -31,6 +32,8 @@ from fastapi.responses import JSONResponse from bincio.serve.db import ( User, authenticate, + count_activity_users, + count_wiki_users, create_invite, create_session, count_users, @@ -228,8 +231,9 @@ app.add_middleware( _VALID_HANDLE = re.compile(r'^[a-z0-9][a-z0-9_-]{0,29}$') from bincio.edit.ops import VALID_ACTIVITY_ID as _VALID_ACTIVITY_ID -_SESSION_COOKIE = "bincio_session" -_COOKIE_MAX_AGE = 30 * 86400 # 30 days +_SESSION_COOKIE = "bincio_session" +_COOKIE_MAX_AGE = 30 * 86400 # 30 days +_SESSION_DOMAIN = os.environ.get("SESSION_DOMAIN") or None # e.g. ".bincio.org" in production def _check_id(activity_id: str) -> str: @@ -302,7 +306,7 @@ def _require_auth( def _set_session_cookie(response: Response, token: str) -> None: - response.set_cookie( + kwargs: dict = dict( key=_SESSION_COOKIE, value=token, max_age=_COOKIE_MAX_AGE, @@ -310,6 +314,9 @@ def _set_session_cookie(response: Response, token: str) -> None: samesite="lax", secure=False, # nginx/caddy handles TLS termination ) + if _SESSION_DOMAIN: + kwargs["domain"] = _SESSION_DOMAIN + response.set_cookie(**kwargs) # ── Image upload constants ──────────────────────────────────────────────────── @@ -432,12 +439,14 @@ def _trigger_rebuild(handle: str) -> None: async def me(bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse: user = _current_user(bincio_session) if not user: - raise HTTPException(404, "Not authenticated") + raise HTTPException(401, "Not authenticated") store_orig = get_setting(_get_db(), "store_originals") return JSONResponse({ "handle": user.handle, "display_name": user.display_name, "is_admin": user.is_admin, + "wiki_access": user.wiki_access, + "activity_access": user.activity_access, "store_originals_default": store_orig != "false", "dem_configured": bool(dem_url), }) @@ -786,7 +795,13 @@ async def login( raise HTTPException(401, "Invalid credentials") token = create_session(_get_db(), handle) - resp = JSONResponse({"ok": True, "handle": user.handle, "display_name": user.display_name}) + resp = JSONResponse({ + "ok": True, + "handle": user.handle, + "display_name": user.display_name, + "wiki_access": user.wiki_access, + "activity_access": user.activity_access, + }) _set_session_cookie(resp, token) return resp @@ -796,7 +811,10 @@ async def logout(bincio_session: Optional[str] = Cookie(default=None)) -> JSONRe if bincio_session: delete_session(_get_db(), bincio_session) resp = JSONResponse({"ok": True}) - resp.delete_cookie(_SESSION_COOKIE) + kwargs: dict = dict(key=_SESSION_COOKIE) + if _SESSION_DOMAIN: + kwargs["domain"] = _SESSION_DOMAIN + resp.delete_cookie(**kwargs) return resp @@ -883,13 +901,22 @@ async def register( 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})") + db = _get_db() + max_wiki_val = get_setting(db, "max_wiki_users") or get_setting(db, "max_users") + if max_wiki_val is not None: + limit = int(max_wiki_val) + if limit > 0 and count_wiki_users(db) >= limit: + raise HTTPException(403, f"This instance has reached its wiki user limit ({limit})") - create_user(_get_db(), handle, display, password, is_admin=False) + if invite.grants_activity: + max_act_val = get_setting(db, "max_activity_users") + if max_act_val is not None: + limit = int(max_act_val) + if limit > 0 and count_activity_users(db) >= limit: + raise HTTPException(403, f"This instance has reached its activity user limit ({limit})") + + create_user(_get_db(), handle, display, password, is_admin=False, + wiki_access=True, activity_access=invite.grants_activity) use_invite(_get_db(), code, handle) # Create per-user directories @@ -930,17 +957,25 @@ async def get_invites(bincio_session: Optional[str] = Cookie(default=None)) -> J "used_by": i.used_by, "created_at": i.created_at, "used_at": i.used_at, + "grants_activity": i.grants_activity, } for i in invites]) +class CreateInviteRequest(BaseModel): + grants_activity: bool = Field(default=False) + + @app.post("/api/invites") -async def post_invite(bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse: +async def post_invite( + body: CreateInviteRequest = CreateInviteRequest(), + bincio_session: Optional[str] = Cookie(default=None), +) -> JSONResponse: user = _require_user(bincio_session) try: - code = create_invite(_get_db(), user.handle) + code = create_invite(_get_db(), user.handle, grants_activity=body.grants_activity) except ValueError as e: raise HTTPException(400, str(e)) - return JSONResponse({"ok": True, "code": code}) + return JSONResponse({"ok": True, "code": code, "grants_activity": body.grants_activity}) # ── Admin ───────────────────────────────────────────────────────────────────── diff --git a/scripts/dev_test.py b/scripts/dev_test.py index dc5d236..3f43e56 100755 --- a/scripts/dev_test.py +++ b/scripts/dev_test.py @@ -18,6 +18,7 @@ URL: http://localhost:4321 """ import argparse +import os import platform import resource import shutil @@ -58,13 +59,15 @@ def init_instance() -> None: if get_user(db, "dave"): warn("user 'dave' already exists — skipping") else: - create_user(db, "dave", "Dave", PASSWORD, is_admin=True) + create_user(db, "dave", "Dave", PASSWORD, is_admin=True, + wiki_access=True, activity_access=True) ok("admin user 'dave' created") if get_user(db, "brut"): warn("user 'brut' already exists — skipping") else: - create_user(db, "brut", "Brut", PASSWORD, is_admin=False) + create_user(db, "brut", "Brut", PASSWORD, is_admin=False, + wiki_access=True, activity_access=True) ok("user 'brut' created") for handle in ("dave", "brut"): @@ -146,10 +149,11 @@ def start_dev(mobile: bool = False) -> None: section("Starting bincio dev") print() print(" \033[1mCredentials\033[0m") - print(f" dave / {PASSWORD} (admin)") - print(f" brut / {PASSWORD}") + print(f" dave / {PASSWORD} (admin, wiki + activity)") + print(f" brut / {PASSWORD} (wiki + activity)") print() - print(" \033[1mURL\033[0m http://localhost:4321") + print(" \033[1mURL\033[0m http://localhost:4321") + print(f" \033[1mShared DB\033[0m {DATA_DIR / 'instance.db'}") print() print(" Press Ctrl+C to stop.\n") @@ -157,8 +161,13 @@ def start_dev(mobile: bool = False) -> None: if mobile: cmd += ["--api-host", "0.0.0.0"] + env = os.environ.copy() + # Show the wiki link in the nav during local dev (wiki typically lands on 4322 + # when activity already holds 4321). Override with WIKI_DEV_URL if needed. + env.setdefault("PUBLIC_WIKI_URL", os.environ.get("WIKI_DEV_URL", "http://localhost:4322")) + try: - subprocess.run(cmd, cwd=PROJECT_DIR) + subprocess.run(cmd, cwd=PROJECT_DIR, env=env) except KeyboardInterrupt: pass diff --git a/site/src/layouts/Base.astro b/site/src/layouts/Base.astro index 64e6f03..2d49be8 100644 --- a/site/src/layouts/Base.astro +++ b/site/src/layouts/Base.astro @@ -9,7 +9,8 @@ interface Props { public?: boolean; } const { title = 'BincioActivity', description = 'Your personal activity stats', public: isPublicPage = false } = Astro.props; -const editUrl = import.meta.env.PUBLIC_EDIT_URL ?? ''; +const editUrl = import.meta.env.PUBLIC_EDIT_URL ?? ''; +const wikiUrl = import.meta.env.PUBLIC_WIKI_URL ?? ''; // Edit UI is enabled when PUBLIC_EDIT_URL is set (single-user bincio-edit mode) // OR when PUBLIC_EDIT_ENABLED=true (multi-user VPS mode — API proxied at /api/). const editEnabled = editUrl !== '' || import.meta.env.PUBLIC_EDIT_ENABLED === 'true'; @@ -217,6 +218,10 @@ try { Convert )} About + {wikiUrl && ( + + )} )} @@ -561,6 +566,12 @@ try { } } catch (_) {} + // Wiki link: show only for users who have wiki access + if (user.wiki_access) { + const wikiEl = document.getElementById('nav-wiki'); + if (wikiEl) wikiEl.style.display = ''; + } + // Admin: show admin link and poll for active jobs if (user.is_admin) { const adminLink = document.getElementById('nav-admin'); diff --git a/site/src/pages/login/index.astro b/site/src/pages/login/index.astro index 2779abf..2203718 100644 --- a/site/src/pages/login/index.astro +++ b/site/src/pages/login/index.astro @@ -1,6 +1,7 @@ --- import Base from '../../layouts/Base.astro'; -const editUrl = import.meta.env.PUBLIC_EDIT_URL ?? ''; +const editUrl = import.meta.env.PUBLIC_EDIT_URL ?? ''; +const wikiUrl = import.meta.env.PUBLIC_WIKI_URL ?? ''; ---
@@ -9,7 +10,7 @@ const editUrl = import.meta.env.PUBLIC_EDIT_URL ?? '';

Sign in

-
+