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:
@@ -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",
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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