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:
2026-10-06 02:56:08 +02:00
parent e512556703
commit 9fb4d65787
36 changed files with 1147 additions and 19 deletions
+16
View File
@@ -20,6 +20,15 @@ from molodetz.database.core import (
table_exists,
)
from molodetz.database.init_db import init_db
from molodetz.database.invites import (
claim_invite,
create_invite,
get_invite_by_token,
invite_is_live,
open_invite_for_join,
revoke_open_invites,
suggest_username,
)
from molodetz.database.joins import (
count_open_join_requests,
get_join_request,
@@ -97,6 +106,8 @@ __all__ = [
"count_open_join_requests",
"count_published",
"count_users",
"claim_invite",
"create_invite",
"create_session",
"create_user",
"daily_totals",
@@ -110,6 +121,7 @@ __all__ = [
"generate_uid",
"get_backup",
"get_int_setting",
"get_invite_by_token",
"get_join_request",
"get_post",
"get_post_by_slug",
@@ -128,6 +140,7 @@ __all__ = [
"insert_notification",
"insert_post",
"invalidate_admins_cache",
"invite_is_live",
"list_audit",
"list_backups",
"list_deleted",
@@ -138,6 +151,7 @@ __all__ = [
"mark_all_read",
"media_by_kind",
"now_iso",
"open_invite_for_join",
"paginate",
"parse_iso",
"pending_backups",
@@ -152,11 +166,13 @@ __all__ = [
"resolve_by_slug",
"restore",
"restore_event",
"revoke_open_invites",
"set_role",
"set_setting",
"shard_path",
"soft_delete",
"soft_delete_in",
"suggest_username",
"sync_local_cache",
"table_exists",
"top_paths",
+17
View File
@@ -67,6 +67,20 @@ TABLE_EXAMPLES = {
"deleted_at": None,
"deleted_by": None,
},
"member_invites": {
"uid": "x",
"token_hash": "x",
"join_request_uid": "x",
"email": "",
"username": "",
"created_by": "x",
"created_at": now_iso(),
"expires_at": now_iso(),
"used_at": None,
"used_by": None,
"revoked_at": None,
"revoked_by": None,
},
"media_items": {
"uid": "x",
"filename": "x.jpg",
@@ -200,6 +214,9 @@ def init_db():
_index("CREATE INDEX IF NOT EXISTS idx_posts_author ON posts(user_uid, created_at)")
_index("CREATE INDEX IF NOT EXISTS idx_posts_trash ON posts(deleted_at) WHERE deleted_at IS NOT NULL")
_index("CREATE UNIQUE INDEX IF NOT EXISTS idx_member_invites_uid ON member_invites(uid)")
_index("CREATE UNIQUE INDEX IF NOT EXISTS idx_member_invites_token ON member_invites(token_hash)")
_index("CREATE INDEX IF NOT EXISTS idx_member_invites_join ON member_invites(join_request_uid, created_at)")
_index("CREATE UNIQUE INDEX IF NOT EXISTS idx_join_requests_uid ON join_requests(uid)")
_index("CREATE INDEX IF NOT EXISTS idx_join_requests_status ON join_requests(status, created_at)")
_index("CREATE INDEX IF NOT EXISTS idx_join_requests_trash ON join_requests(deleted_at) WHERE deleted_at IS NOT NULL")
+93
View File
@@ -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