Add wiki invite system: create/revoke invites, register via token

This commit is contained in:
brutsalvadi
2026-05-08 09:44:32 +02:00
parent 4dcdedce47
commit fd2d81328f
2 changed files with 135 additions and 2 deletions
+133
View File
@@ -42,6 +42,7 @@ _SESSION_TTL = 30 * 24 * 3600 # 30 days (matches bincio_activity)
_SESSION_COOKIE = "bincio_session" _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"} _ALLOWED_IMAGE_TYPES = {"image/jpeg", "image/png", "image/webp", "image/gif"}
_MAX_IMAGE_BYTES = 10 * 1024 * 1024 # 10 MB _MAX_IMAGE_BYTES = 10 * 1024 * 1024 # 10 MB
@@ -51,6 +52,30 @@ _GIT_DIR = os.environ.get("GIT_DIR")
_git_lock = asyncio.Lock() _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: def _git_env() -> dict:
env = os.environ.copy() env = os.environ.copy()
if _GIT_DIR: if _GIT_DIR:
@@ -328,6 +353,114 @@ async def logout(bincio_session: Optional[str] = Cookie(default=None)) -> JSONRe
return resp 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 ────────────────────────────────────────────────────────────── # ── File helpers ──────────────────────────────────────────────────────────────
def _slug_to_path(slug: str, base: Path) -> Path: def _slug_to_path(slug: str, base: Path) -> Path:
+1 -1
Submodule site updated: dfce744001...aed1da2cc9