Member invites, gallery resync, content and operator docs
- 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
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
# 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
|
||||
Reference in New Issue
Block a user