Add wiki invite system: create/revoke invites, register via token
This commit is contained in:
+133
@@ -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
Reference in New Issue
Block a user