From fd2d81328f8121c3cf84cb0adad63a680ab6f979 Mon Sep 17 00:00:00 2001 From: brutsalvadi Date: Fri, 8 May 2026 09:44:32 +0200 Subject: [PATCH] Add wiki invite system: create/revoke invites, register via token --- edit/server.py | 135 ++++++++++++++++++++++++++++++++++++++++++++++++- site | 2 +- 2 files changed, 135 insertions(+), 2 deletions(-) diff --git a/edit/server.py b/edit/server.py index 9def2d2..d440d13 100644 --- a/edit/server.py +++ b/edit/server.py @@ -41,7 +41,8 @@ _SESSION_DOMAIN = os.environ.get("SESSION_DOMAIN") or None _SESSION_TTL = 30 * 24 * 3600 # 30 days (matches bincio_activity) _SESSION_COOKIE = "bincio_session" -_SAFE_SLUG = re.compile(r"^[a-zA-Z0-9_][a-zA-Z0-9_\-/]*$") +_SAFE_SLUG = re.compile(r"^[a-zA-Z0-9_][a-zA-Z0-9_\-/]*$") +_SAFE_HANDLE = re.compile(r"^[a-z][a-z0-9_-]{1,19}$") _ALLOWED_IMAGE_TYPES = {"image/jpeg", "image/png", "image/webp", "image/gif"} _MAX_IMAGE_BYTES = 10 * 1024 * 1024 # 10 MB @@ -51,6 +52,30 @@ _GIT_DIR = os.environ.get("GIT_DIR") _git_lock = asyncio.Lock() +def _migrate_db() -> None: + """Add grants_wiki column to invites table if it doesn't exist yet.""" + if not _SHARED_DB_PATH.exists(): + return + try: + con = sqlite3.connect(_SHARED_DB_PATH, check_same_thread=False) + con.execute("PRAGMA journal_mode=WAL") + has_table = con.execute( + "SELECT 1 FROM sqlite_master WHERE type='table' AND name='invites'" + ).fetchone() + if has_table: + cols = {row[1] for row in con.execute("PRAGMA table_info(invites)")} + if "grants_wiki" not in cols: + con.execute( + "ALTER TABLE invites ADD COLUMN grants_wiki INTEGER NOT NULL DEFAULT 0" + ) + con.commit() + con.close() + except Exception as e: + print(f"[db] migration failed: {e}", flush=True) + +_migrate_db() + + def _git_env() -> dict: env = os.environ.copy() if _GIT_DIR: @@ -328,6 +353,114 @@ async def logout(bincio_session: Optional[str] = Cookie(default=None)) -> JSONRe return resp +# ── Invite endpoints ───────────────────────────────────────────────────────── + +@app.get("/api/invites") +async def list_invites(user: User = Depends(require_auth)) -> JSONResponse: + with _db() as con: + rows = con.execute( + "SELECT code, created_at, used_by, used_at FROM invites " + "WHERE created_by = ? ORDER BY created_at DESC", + (user.handle,), + ).fetchall() + return JSONResponse({"invites": [ + {"code": r["code"], "created_at": r["created_at"], + "used_by": r["used_by"], "used_at": r["used_at"]} + for r in rows + ]}) + + +@app.post("/api/invites") +async def create_invite(user: User = Depends(require_auth)) -> JSONResponse: + code = secrets.token_urlsafe(16) + with _db() as con: + con.execute( + "INSERT INTO invites (code, created_by, created_at, grants_wiki, grants_activity) " + "VALUES (?, ?, ?, 1, 0)", + (code, user.handle, int(time.time())), + ) + con.commit() + return JSONResponse({"code": code}) + + +@app.delete("/api/invites/{code}") +async def revoke_invite(code: str, user: User = Depends(require_auth)) -> JSONResponse: + with _db() as con: + row = con.execute( + "SELECT created_by, used_by FROM invites WHERE code = ?", (code,) + ).fetchone() + if not row: + raise HTTPException(404, "Invito non trovato") + if row["created_by"] != user.handle: + raise HTTPException(403, "Non autorizzato") + if row["used_by"]: + raise HTTPException(409, "Invito già utilizzato, non revocabile") + con.execute("DELETE FROM invites WHERE code = ?", (code,)) + con.commit() + return JSONResponse({"ok": True}) + + +class RegisterBody(BaseModel): + code: str + handle: str + display_name: str + password: str + + +@app.post("/api/auth/register") +async def register(body: RegisterBody) -> JSONResponse: + handle = body.handle.strip().lower() + if not _SAFE_HANDLE.match(handle): + raise HTTPException(400, "Handle non valido (2-20 caratteri: lettere minuscole, numeri, _ o -)") + if len(body.password) < 8: + raise HTTPException(400, "Password troppo corta (minimo 8 caratteri)") + display_name = body.display_name.strip() or handle + now = int(time.time()) + + with _db() as con: + invite = con.execute( + "SELECT used_by, grants_wiki FROM invites WHERE code = ?", (body.code,) + ).fetchone() + if not invite: + raise HTTPException(400, "Codice invito non valido") + if invite["used_by"]: + raise HTTPException(400, "Codice invito già utilizzato") + if not invite["grants_wiki"]: + raise HTTPException(403, "Questo invito non dà accesso al wiki") + if con.execute("SELECT 1 FROM users WHERE handle = ?", (handle,)).fetchone(): + raise HTTPException(409, "Handle già in uso, scegline un altro") + + pw_hash = bcrypt.hashpw(body.password.encode(), bcrypt.gensalt()).decode() + con.execute( + "INSERT INTO users (handle, display_name, password_hash, is_admin, created_at, wiki_access, activity_access) " + "VALUES (?, ?, ?, 0, ?, 1, 0)", + (handle, display_name, pw_hash, now), + ) + con.execute( + "UPDATE invites SET used_by = ?, used_at = ? WHERE code = ?", + (handle, now, body.code), + ) + con.commit() + + token = secrets.token_hex(32) + with _db() as con: + con.execute( + "INSERT INTO sessions (token, handle, created_at, expires_at) VALUES (?, ?, ?, ?)", + (token, handle, now, now + _SESSION_TTL), + ) + con.commit() + + resp = JSONResponse({"handle": handle, "display_name": display_name}) + kwargs: dict = dict( + key=_SESSION_COOKIE, value=token, + httponly=True, samesite="lax", max_age=_SESSION_TTL, + ) + if _SESSION_DOMAIN: + kwargs["domain"] = _SESSION_DOMAIN + resp.set_cookie(**kwargs) + return resp + + # ── File helpers ────────────────────────────────────────────────────────────── def _slug_to_path(slug: str, base: Path) -> Path: diff --git a/site b/site index dfce744..aed1da2 160000 --- a/site +++ b/site @@ -1 +1 @@ -Subproject commit dfce7440014c07b63a956b50a7ff4fcc5117aab0 +Subproject commit aed1da2cc918cad55930f6f2f626cc2a69b14a87