- Invite flow: admin issue/revoke on join requests, public single-use claim links (hash-only tokens, 7-day expiry, no state reveal), claim creates the Member account and marks the request accepted - Gallery: admin status page plus resync endpoint; sync refreshes thumbnails whose content changed; tools/gallery contract and checker - Docs: public content page, admin-only operator runbook, api.md invite/gallery sections, all routes in the live API docs, docs reachability gate test - Screenshots cover the new pages; version 1.0.18
94 lines
2.9 KiB
Python
94 lines
2.9 KiB
Python
# retoor <retoor@molodetz.nl>
|
|
import hashlib
|
|
import secrets
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
from molodetz.database.core import db, generate_uid, now_iso, parse_iso
|
|
|
|
|
|
def hash_token(token):
|
|
return hashlib.sha256(token.encode()).hexdigest()
|
|
|
|
|
|
def suggest_username(name):
|
|
cleaned = "".join(char.lower() if char.isalnum() else "_" for char in (name or "").strip())
|
|
cleaned = cleaned.strip("_")
|
|
if len(cleaned) < 3:
|
|
return ""
|
|
return cleaned[:32]
|
|
|
|
|
|
def open_invite_for_join(join_request_uid):
|
|
if "member_invites" not in db.tables:
|
|
return None
|
|
rows = list(
|
|
db["member_invites"].find(
|
|
join_request_uid=join_request_uid,
|
|
used_at=None,
|
|
revoked_at=None,
|
|
order_by=["-created_at"],
|
|
)
|
|
)
|
|
stamp = datetime.now(timezone.utc)
|
|
for row in rows:
|
|
expires = parse_iso(row.get("expires_at"))
|
|
if expires is not None and expires > stamp:
|
|
return row
|
|
return None
|
|
|
|
|
|
def create_invite(join_request_uid, email, username, created_by, days=7):
|
|
if "member_invites" in db.tables:
|
|
stamp = now_iso()
|
|
for row in db["member_invites"].find(join_request_uid=join_request_uid, used_at=None, revoked_at=None):
|
|
db["member_invites"].update(
|
|
{"id": row["id"], "revoked_at": stamp, "revoked_by": created_by},
|
|
["id"],
|
|
)
|
|
token = secrets.token_urlsafe(32)
|
|
expires = datetime.now(timezone.utc) + timedelta(days=max(1, days))
|
|
row = {
|
|
"uid": generate_uid(),
|
|
"token_hash": hash_token(token),
|
|
"join_request_uid": join_request_uid,
|
|
"email": (email or "").lower(),
|
|
"username": username or "",
|
|
"created_by": created_by,
|
|
"created_at": now_iso(),
|
|
"expires_at": expires.isoformat(),
|
|
"used_at": None,
|
|
"used_by": None,
|
|
"revoked_at": None,
|
|
"revoked_by": None,
|
|
}
|
|
db["member_invites"].insert(row)
|
|
return row, token
|
|
|
|
|
|
def get_invite_by_token(token):
|
|
if not token or "member_invites" not in db.tables:
|
|
return None
|
|
return db["member_invites"].find_one(token_hash=hash_token(token))
|
|
|
|
|
|
def invite_is_live(row):
|
|
if not row or row.get("used_at") or row.get("revoked_at"):
|
|
return False
|
|
expires = parse_iso(row.get("expires_at"))
|
|
return expires is not None and expires > datetime.now(timezone.utc)
|
|
|
|
|
|
def claim_invite(uid, user_uid):
|
|
db["member_invites"].update({"uid": uid, "used_at": now_iso(), "used_by": user_uid}, ["uid"])
|
|
|
|
|
|
def revoke_open_invites(join_request_uid, actor):
|
|
if "member_invites" not in db.tables:
|
|
return 0
|
|
stamp = now_iso()
|
|
count = 0
|
|
for row in db["member_invites"].find(join_request_uid=join_request_uid, used_at=None, revoked_at=None):
|
|
db["member_invites"].update({"id": row["id"], "revoked_at": stamp, "revoked_by": actor}, ["id"])
|
|
count += 1
|
|
return count
|