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
+2 -1
View File
@@ -57,7 +57,7 @@ NOTIFICATION_TYPES = {
"system.maintenance": "Maintenance mode changed",
}
CRAWLER_PRIVATE_PREFIXES = ("/notifications", "/admin", "/profile")
CRAWLER_PRIVATE_PREFIXES = ("/notifications", "/admin", "/profile", "/invite")
CRAWLER_MARKERS = (
"bot",
@@ -94,6 +94,7 @@ SETTINGS_DEFAULTS = {
"rate_limit_window_seconds": "60",
"session_max_age_days": "7",
"session_remember_days": "30",
"invite_expiry_days": "7",
"terms_version": "1",
"friendly_404": "1",
"audit_retention_days": "365",
+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
+5 -1
View File
@@ -6,7 +6,7 @@ GROUP = {
"title": "Admin",
"description": "Administrators only. Writing and publishing posts, join requests, services, trash, settings.",
"endpoints": [
endpoint("GET", "/admin", "Overview", auth="admin", sample={"post_count": 5, "open_joins": 0, "user_count": 1, "deleted_count": 0}),
endpoint("GET", "/admin", "Overview", auth="admin", sample={"post_count": 5, "open_joins": 0, "user_count": 1, "deleted_count": 0, "flyer_count": 4, "meme_count": 38}),
endpoint("GET", "/admin/posts", "Posts", auth="admin", sample={"posts": []}),
endpoint("GET", "/admin/posts/new", "New post", auth="admin", sample={}),
endpoint(
@@ -32,6 +32,10 @@ GROUP = {
endpoint("GET", "/admin/joins", "Join requests", auth="admin", sample={"requests": []}),
endpoint("POST", "/admin/joins/{uid}/status", "Join request status", auth="admin", fields=[field("uid", required=True, location="path"), field("status", enum=["open", "contacted", "accepted", "declined"])], probe_path="/admin/joins/unknown/status", probe_body={"status": "open"}),
endpoint("POST", "/admin/joins/{uid}/check", "Check repo link", "Fetches the title through the guarded outbound client.", auth="admin", fields=[field("uid", required=True, location="path")], probe_path="/admin/joins/unknown/check"),
endpoint("POST", "/admin/joins/{uid}/invite", "Issue invite", "Issues a single-use claim link, shown once. Revokes the previous open invite.", auth="admin", fields=[field("uid", required=True, location="path")], sample={"ok": True, "redirect": "/admin/joins", "data": {"claim_url": "https://example.com/invite/...", "expires_at": "..."}}, probe_path="/admin/joins/unknown/invite"),
endpoint("POST", "/admin/joins/{uid}/invite/revoke", "Revoke invite", "Revokes the open invite without touching the request.", auth="admin", fields=[field("uid", required=True, location="path")], sample={"ok": True, "redirect": "/admin/joins", "data": {"revoked": 1}}, probe_path="/admin/joins/unknown/invite/revoke"),
endpoint("GET", "/admin/gallery", "Gallery status", "Live flyer and meme counts plus catalogued files missing from disk.", auth="admin", sample={"flyers": 4, "memes": 38, "missing": []}),
endpoint("POST", "/admin/gallery/resync", "Resync gallery", "Re-reads sources, refreshes changed thumbnails, retires removed entries.", auth="admin", sample={"ok": True, "redirect": "/admin/gallery", "data": {"synced": 42, "flyers": 4, "memes": 38, "missing": []}}),
endpoint("GET", "/admin/services", "Services", auth="admin", sample={"services": []}),
endpoint("GET", "/admin/services/{name}", "Service", auth="admin", fields=[field("name", required=True, location="path", example="backup")], probe_path="/admin/services/backup"),
endpoint("POST", "/admin/services/{name}/command", "Service command", auth="admin", fields=[field("name", required=True, location="path"), field("verb", enum=["start", "stop", "run", "clear"])], probe_path="/admin/services/backup/command", probe_body={"verb": "noop"}),
+26
View File
@@ -21,5 +21,31 @@ GROUP = {
sample={"ok": True, "redirect": "/join?ok=1", "data": {"uid": "0190..."}},
probe_body={"name": "Probe", "contact": "probe@example.com"},
),
endpoint(
"GET",
"/invite/{token}",
"Invite claim form",
"Shows the claim form for a live invite; unknown, used and expired links answer 404 with the same message.",
fields=[field("token", required=True, location="path", example="unknown-token")],
sample={"valid": True, "username": "ada", "email": "ada@example.com", "email_locked": True},
probe_path="/invite/unknown-token",
),
endpoint(
"POST",
"/invite/{token}",
"Claim invite",
"Creates the Member account, logs in and marks the join request accepted. Single use.",
fields=[
field("token", required=True, location="path", example="unknown-token"),
field("username", required=True, example="ada"),
field("email", required=True, example="ada@example.com"),
field("password", required=True, example="correct horse battery staple"),
field("password_confirm", required=True, example="correct horse battery staple"),
field("terms", required=True, type="boolean", example=True),
],
sample={"ok": True, "redirect": "/", "data": {"username": "ada"}},
probe_path="/invite/unknown-token",
probe_body={"username": "Probe", "email": "probe@example.com", "password": "probe-pass-1", "password_confirm": "probe-pass-1", "terms": True},
),
],
}
+2
View File
@@ -18,7 +18,9 @@ DOCS_PAGES = [
{"slug": "join", "title": "Joining", "section": "Start here", "file": "join.md"},
{"slug": "legal", "title": "Terms and privacy", "section": "Start here", "file": "legal.md"},
{"slug": "api", "title": "API and authentication", "section": "Building with the API", "file": "api.md"},
{"slug": "content", "title": "Galleries and content", "section": "Start here", "file": "content.md"},
{"slug": "admin", "title": "Admin and services", "section": "Admin", "file": "admin.md", "admin": True},
{"slug": "operator", "title": "Operator runbook", "section": "Admin", "file": "operator.md", "admin": True},
{"slug": "backups", "title": "Backups and maintenance", "section": "Admin", "file": "backups.md", "admin": True},
]
+6 -1
View File
@@ -99,7 +99,8 @@ def sync_gallery():
seen_hashes.append(phash)
thumb_directory = existing.get("thumb_directory") if existing else None
thumb_name = existing.get("thumb_name") if existing else None
if not thumb_directory or not blob_path(thumb_directory, thumb_name).exists():
changed = not existing or existing.get("phash") != str(phash)
if changed or not thumb_directory or not blob_path(thumb_directory, thumb_name).exists():
thumb_directory, thumb_name = write_blob(
existing["uid"] if existing else generate_uid(), "webp", _thumbnail_bytes(image)
)
@@ -108,6 +109,10 @@ def sync_gallery():
return synced
def missing_sources():
return [filename for filename, _, _ in GALLERY if not (config.MEDIA_SOURCE_DIR / filename).exists()]
def gallery_items(kind):
items = []
for row in media_by_kind(kind):
+20
View File
@@ -36,6 +36,7 @@ from molodetz.routers import (
flyers,
health,
home,
invite,
join,
legacy,
memes,
@@ -145,6 +146,7 @@ app.include_router(flyers.router, prefix="/flyers")
app.include_router(memes.router, prefix="/memes")
app.include_router(people.router, prefix="/people")
app.include_router(join.router, prefix="/join")
app.include_router(invite.router, prefix="/invite")
app.include_router(terms.router, prefix="/terms")
app.include_router(privacy.router, prefix="/privacy")
app.include_router(health.router, prefix="/health")
@@ -439,4 +441,22 @@ async def validation_handler(request: Request, exc: RequestValidationError):
{"errors": messages, "form": echoed, "submitted": False, "seo": {"title": "Join", "robots": "index,follow"}},
status_code=400,
)
if request.url.path.startswith("/invite/"):
from molodetz.database import get_invite_by_token
form = await request.form()
echoed = {key: value for key, value in form.items() if isinstance(value, str) and not key.startswith("password")}
invite = get_invite_by_token(request.url.path.rsplit("/", 1)[-1])
return templates.TemplateResponse(
request,
"invite.html",
{
"valid": True,
"errors": messages,
"form": echoed,
"email_locked": bool(invite and invite.get("email")),
"seo": {"title": "Accept invite", "robots": "noindex,nofollow"},
},
status_code=400,
)
return RedirectResponse(safe_next(request.headers.get("referer", "").replace(str(request.base_url).rstrip("/"), ""), "/"), status_code=303)
+15
View File
@@ -90,6 +90,21 @@ class JoinStatusForm(FormModel):
return value
class InviteClaimForm(FormModel):
username: str = Field(min_length=3, max_length=32)
email: str = Field(min_length=3, max_length=255)
password: str = Field(min_length=6, max_length=128)
password_confirm: str = Field(min_length=6, max_length=128)
terms: bool = False
@field_validator("username")
@classmethod
def check_username(cls, value):
if not re.fullmatch(r"[A-Za-z0-9_-]{3,32}", value or ""):
raise ValueError("Use 3 to 32 letters, digits, _ or -")
return value
class RoleForm(FormModel):
role: str
+2 -1
View File
@@ -1,9 +1,10 @@
# retoor <retoor@molodetz.nl>
from molodetz.routers.admin import audit, backups, joins, posts, services, settings, stats, trash, users
from molodetz.routers.admin import audit, backups, gallery, joins, posts, services, settings, stats, trash, users
from molodetz.routers.admin.index import router
router.include_router(posts.router)
router.include_router(joins.router)
router.include_router(gallery.router)
router.include_router(services.router)
router.include_router(trash.router)
router.include_router(settings.router)
+38
View File
@@ -0,0 +1,38 @@
# retoor <retoor@molodetz.nl>
from fastapi import APIRouter, Request
from molodetz.database import count_media
from molodetz.gallery import missing_sources, sync_gallery
from molodetz.responses import action_result, respond
from molodetz.routers.admin._shared import admin_seo
from molodetz.schemas.admin import GalleryStatusOut
from molodetz.utils.audit import record
from molodetz.utils.auth import require_admin
router = APIRouter()
def _status():
return {
"flyers": count_media("flyer"),
"memes": count_media("meme"),
"missing": missing_sources(),
}
@router.get("/gallery")
async def gallery_status(request: Request):
require_admin(request)
ctx = dict(_status())
ctx["seo"] = admin_seo(request, "Gallery")
return respond(request, "admin/gallery.html", ctx, model=GalleryStatusOut)
@router.post("/gallery/resync")
async def gallery_resync(request: Request):
user = require_admin(request)
synced = sync_gallery()
data = dict(_status())
data["synced"] = synced
record(request, "gallery.resync", payload=data, user=user)
return action_result(request, "/admin/gallery", data=data)
+3 -1
View File
@@ -2,7 +2,7 @@
from fastapi import APIRouter, Request
from molodetz.constants import SOFT_DELETE_TABLES
from molodetz.database import all_posts, count_deleted, count_open_join_requests, count_users
from molodetz.database import all_posts, count_deleted, count_media, count_open_join_requests, count_users
from molodetz.responses import respond
from molodetz.routers.admin._shared import admin_seo
from molodetz.schemas.admin import AdminIndexOut
@@ -19,6 +19,8 @@ async def admin_index(request: Request):
"open_joins": count_open_join_requests(),
"user_count": count_users(),
"deleted_count": sum(count_deleted(name) for name in SOFT_DELETE_TABLES),
"flyer_count": count_media("flyer"),
"meme_count": count_media("meme"),
"seo": admin_seo(request, "Admin"),
}
return respond(request, "admin/index.html", ctx, model=AdminIndexOut)
+73 -4
View File
@@ -1,23 +1,47 @@
# retoor <retoor@molodetz.nl>
from fastapi import APIRouter, Request
from fastapi import APIRouter, HTTPException, Request
from molodetz.database import get_join_request, list_join_requests, update_join_request
from molodetz.database import (
create_invite,
get_int_setting,
get_join_request,
list_join_requests,
open_invite_for_join,
revoke_open_invites,
suggest_username,
update_join_request,
)
from molodetz.dependencies import json_or_form
from molodetz.link_check import check_repo_link
from molodetz.models import JoinStatusForm
from molodetz.responses import action_result, respond
from molodetz.responses import action_result, respond, wants_json
from molodetz.routers.admin._shared import admin_seo
from molodetz.schemas.joins import JoinListOut
from molodetz.templating import templates
from molodetz.utils.audit import record
from molodetz.utils.auth import not_found, require_admin
router = APIRouter()
def _invite_contact_email(contact):
contact = (contact or "").strip()
if "@" in contact and " " not in contact and "." in contact.split("@")[-1]:
return contact.lower()
return ""
@router.get("/joins")
async def joins_index(request: Request):
require_admin(request)
return respond(request, "admin/joins.html", {"requests": list_join_requests(), "seo": admin_seo(request, "Join requests")}, model=JoinListOut)
rows = []
for row in list_join_requests():
item = dict(row)
invite = open_invite_for_join(row["uid"])
item["invite_open"] = invite is not None
item["invite_expires_at"] = invite["expires_at"] if invite else None
rows.append(item)
return respond(request, "admin/joins.html", {"requests": rows, "seo": admin_seo(request, "Join requests")}, model=JoinListOut)
@router.post("/joins/{uid}/status")
@@ -41,3 +65,48 @@ async def joins_check(request: Request, uid: str):
result = await check_repo_link(row)
record(request, "join.request.check", payload=result, targets=[("join_request", uid)], user=user)
return action_result(request, "/admin/joins", data=result)
@router.post("/joins/{uid}/invite")
async def joins_invite(request: Request, uid: str):
user = require_admin(request)
row = get_join_request(uid)
if row is None:
not_found("Join request not found")
if row.get("status") == "declined":
raise HTTPException(409, "Cannot invite a declined request")
days = get_int_setting("invite_expiry_days", 7)
invite, token = create_invite(
uid,
_invite_contact_email(row.get("contact")),
suggest_username(row.get("name")),
user["uid"],
days=days,
)
claim_url = str(request.base_url).rstrip("/") + f"/invite/{token}"
record(
request,
"join.invite.issued",
payload={"expires_at": invite["expires_at"]},
targets=[("join_request", uid)],
user=user,
)
data = {"claim_url": claim_url, "expires_at": invite["expires_at"]}
if wants_json(request):
return action_result(request, "/admin/joins", data=data)
return templates.TemplateResponse(
request,
"admin/invite_issued.html",
{"join": row, "claim_url": claim_url, "expires_at": invite["expires_at"], "seo": admin_seo(request, "Invite issued")},
)
@router.post("/joins/{uid}/invite/revoke")
async def joins_invite_revoke(request: Request, uid: str):
user = require_admin(request)
row = get_join_request(uid)
if row is None:
not_found("Join request not found")
revoked = revoke_open_invites(uid, user["uid"])
record(request, "join.invite.revoked", payload={"revoked": revoked}, targets=[("join_request", uid)], user=user)
return action_result(request, "/admin/joins", data={"revoked": revoked})
+128
View File
@@ -0,0 +1,128 @@
# retoor <retoor@molodetz.nl>
import re
from fastapi import APIRouter, Request
from molodetz.constants import ROLE_MEMBER
from molodetz.database import (
claim_invite,
create_session,
create_user,
get_int_setting,
get_invite_by_token,
get_user_by_email,
get_user_by_username,
invite_is_live,
update_join_request,
)
from molodetz.dependencies import json_or_form
from molodetz.models import InviteClaimForm
from molodetz.responses import action_result, json_error, respond, wants_json
from molodetz.schemas.joins import InvitePageOut
from molodetz.seo import base_seo_context
from molodetz.templating import templates
from molodetz.utils.audit import record
from molodetz.utils.auth import hash_password_async
from molodetz.utils.request_meta import is_secure_request
router = APIRouter()
EMAIL_PATTERN = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
INVALID_MESSAGE = "This invite link is invalid or has expired."
def _seo(request):
return base_seo_context(request, "Accept invite", robots="noindex,nofollow")
def _invalid(request):
record(request, "invite.claim.invalid", result="denied")
if wants_json(request):
return json_error(404, INVALID_MESSAGE)
return templates.TemplateResponse(
request,
"invite.html",
{"valid": False, "errors": [INVALID_MESSAGE], "form": {}, "email_locked": False, "seo": _seo(request)},
status_code=404,
)
def _form_errors(data, invite):
errors = []
email = (data.email or "").lower()
if not EMAIL_PATTERN.match(email):
errors.append("Enter a valid email address.")
elif invite.get("email") and email != invite["email"]:
errors.append("This invite was issued for a different email address.")
if get_user_by_username(data.username):
errors.append("That name is already taken.")
if get_user_by_email(email):
errors.append("That email address is already in use.")
if data.password != data.password_confirm:
errors.append("The passwords do not match.")
if not data.terms:
errors.append("Accept the house rules to continue.")
return errors
@router.get("/{token}")
async def invite_page(request: Request, token: str):
invite = get_invite_by_token(token)
if not invite_is_live(invite):
return _invalid(request)
ctx = {
"valid": True,
"username": invite.get("username") or "",
"email": invite.get("email") or "",
"errors": [],
"form": {"username": invite.get("username") or "", "email": invite.get("email") or ""},
"email_locked": bool(invite.get("email")),
"seo": _seo(request),
}
return respond(request, "invite.html", ctx, model=InvitePageOut)
@router.post("/{token}")
async def invite_claim(request: Request, token: str):
invite = get_invite_by_token(token)
if not invite_is_live(invite):
return _invalid(request)
data = await json_or_form(request, InviteClaimForm)
errors = _form_errors(data, invite)
if errors:
record(request, "invite.claim.rejected", result="denied", payload={"errors": len(errors)})
ctx = {
"valid": True,
"errors": errors,
"form": {"username": data.username, "email": data.email},
"email_locked": bool(invite.get("email")),
"seo": _seo(request),
}
if wants_json(request):
return json_error(400, errors[0], errors=errors)
return templates.TemplateResponse(request, "invite.html", ctx, status_code=400)
email = data.email.lower()
password_hash = await hash_password_async(data.password)
user = create_user(data.username, email, password_hash, role=ROLE_MEMBER)
claim_invite(invite["uid"], user["uid"])
update_join_request(invite["join_request_uid"], status="accepted")
record(
request,
"invite.claimed",
targets=[("user", user["uid"]), ("join_request", invite["join_request_uid"])],
user=user,
)
days = get_int_setting("session_max_age_days", 7)
max_age = max(1, days) * 86400
session_token = create_session(user["uid"], max_age)
response = action_result(request, "/", data={"username": user["username"]})
response.set_cookie(
key="session",
value=session_token,
max_age=max_age,
httponly=True,
samesite="lax",
secure=is_secure_request(request),
)
return response
+8
View File
@@ -10,6 +10,14 @@ class AdminIndexOut(OutModel):
open_joins: int = 0
user_count: int = 0
deleted_count: int = 0
flyer_count: int = 0
meme_count: int = 0
class GalleryStatusOut(OutModel):
flyers: int = 0
memes: int = 0
missing: list[str] = []
class AdminPostsOut(OutModel):
+9
View File
@@ -16,7 +16,16 @@ class JoinRequestOut(OutModel):
link_title: str | None = None
link_status: str | None = None
created_at: str | None = None
invite_open: bool = False
invite_expires_at: str | None = None
class JoinListOut(OutModel):
requests: list[JoinRequestOut] = []
class InvitePageOut(OutModel):
valid: bool = False
username: str = ""
email: str = ""
email_locked: bool = False
+18
View File
@@ -0,0 +1,18 @@
<!-- retoor <retoor@molodetz.nl> -->
{% extends "admin/base_admin.html" %}
{% block admin_content %}
<h1>Gallery</h1>
<div class="stat-cards">
<div class="stat-card"><span class="value">{{ flyers }}</span><span class="name">Flyers</span></div>
<div class="stat-card"><span class="value">{{ memes }}</span><span class="name">Memes</span></div>
</div>
{% if missing %}
<div class="notice error" role="alert"><p>Missing source files:</p><ul>{% for filename in missing %}<li><code>{{ filename }}</code></li>{% endfor %}</ul></div>
{% else %}
<p class="muted">All catalogued source files are present.</p>
{% endif %}
<form method="post" action="/admin/gallery/resync">
<div class="actions"><button class="button" type="submit" data-loading>Resync gallery</button></div>
</form>
<p class="muted">Resync re-reads the source files, refreshes changed thumbnails and retires removed entries. New works arrive through deploy first.</p>
{% endblock %}
+3 -1
View File
@@ -7,6 +7,8 @@
<div class="stat-card"><span class="value">{{ open_joins }}</span><span class="name">Open join requests</span></div>
<div class="stat-card"><span class="value">{{ user_count }}</span><span class="name">Accounts</span></div>
<div class="stat-card"><span class="value">{{ deleted_count }}</span><span class="name">In trash</span></div>
<div class="stat-card"><span class="value">{{ flyer_count }}</span><span class="name">Flyers</span></div>
<div class="stat-card"><span class="value">{{ meme_count }}</span><span class="name">Memes</span></div>
</div>
<div class="actions"><a class="button" href="/admin/posts/new">New post</a><a class="button ghost" href="/admin/joins">View join requests</a></div>
<div class="actions"><a class="button" href="/admin/posts/new">New post</a><a class="button ghost" href="/admin/joins">View join requests</a><a class="button ghost" href="/admin/gallery">Gallery</a></div>
{% endblock %}
@@ -0,0 +1,9 @@
<!-- retoor <retoor@molodetz.nl> -->
{% extends "admin/base_admin.html" %}
{% block admin_content %}
<h1>Invite issued</h1>
<p>Send this link to <strong>{{ join.name }}</strong> ({{ join.contact }}). It is shown once and expires {{ local_dt(expires_at) }}.</p>
<div class="notice"><p><code>{{ claim_url }}</code></p></div>
<p class="muted">Issuing a new invite revokes this one. The request flips to accepted when the link is claimed.</p>
<p><a class="button ghost" href="/admin/joins">Back to join requests</a></p>
{% endblock %}
+3 -1
View File
@@ -12,7 +12,7 @@
<td>{{ item.contact }}</td>
<td>{% if item.repo_url %}<a href="{{ item.repo_url }}" rel="nofollow noopener" target="_blank">{{ item.repo_url }}</a>{% if item.link_status %}<br><span class="muted">{{ item.link_status }}{% if item.link_title %}: {{ item.link_title }}{% endif %}</span>{% endif %}{% else %}<span class="muted">none</span>{% endif %}</td>
<td>{{ item.message }}</td>
<td><span class="status {{ item.status }}">{{ item.status }}</span></td>
<td><span class="status {{ item.status }}">{{ item.status }}</span>{% if item.invite_open %}<br><span class="muted">invite until {{ local_dt(item.invite_expires_at) }}</span>{% endif %}</td>
<td>{{ local_dt(item.created_at) }}</td>
<td class="actions">
<form method="post" action="/admin/joins/{{ item.uid }}/status">
@@ -20,6 +20,8 @@
<button class="button ghost small" type="submit">Set</button>
</form>
{% if item.repo_url %}<form method="post" action="/admin/joins/{{ item.uid }}/check"><button class="button ghost small" type="submit" data-loading>Check link</button></form>{% endif %}
{% if item.invite_open %}<form method="post" action="/admin/joins/{{ item.uid }}/invite/revoke"><button class="button ghost small" type="submit">Revoke invite</button></form>
{% elif item.status != 'declined' %}<form method="post" action="/admin/joins/{{ item.uid }}/invite"><button class="button ghost small" type="submit" data-loading>Issue invite</button></form>{% endif %}
</td>
</tr>
{% else %}
+8
View File
@@ -23,6 +23,14 @@ curl -H 'Accept: application/json' https://molodetz.nl/roll
Errors have the shape `{"error": {"status": 404, "message": "..."}}`. Validation errors return `422` with `{"error": "validation", "fields": [...], "messages": [...]}`.
## Invites
Membership starts from a join request plus an admin-issued invite. Admins call `POST /admin/joins/{uid}/invite` (returns `claim_url` and `expires_at` in `data`) and `POST /admin/joins/{uid}/invite/revoke`. The public claim is `GET` and `POST /invite/{token}` with `username`, `email`, `password`, `password_confirm` and `terms`. Claim links are single use and every dead link answers 404 with the same message.
## Gallery
`GET /admin/gallery` reports live flyer and meme counts plus catalogued source files missing from disk. `POST /admin/gallery/resync` re-reads the sources and retires removed entries. See [Galleries and content](/docs/content) for how publishing works.
## Old paths
The Dutch paths from before (`/rol`, `/standaard`, `/mensen`, `/binnen`, `/voorwaarden`) answer with a 301 to their English replacement. Query strings are kept.
+26
View File
@@ -0,0 +1,26 @@
# Galleries and content
The flyers and the memes are curated galleries. Every work is a source file plus a catalogue entry with its caption. Nothing is uploaded through the site: new works arrive with a deploy, and the database reconciles at boot.
## Flyers
Flyers live at [/flyers](/flyers). New flyers are 1080 by 1350 (portrait). The first flyer in the catalogue is the featured one on the home page.
## Memes
Memes live at [/memes](/memes). New memes are 1080 by 1080 (square). Some older memes are 1280 by 720 (landscape) from the previous generation; they stay as they are.
## How publishing works
1. The source file lands in the media directory and the catalogue entry (filename, kind, caption) lands in the code, both through deploy.
2. At boot the sync reads every catalogued source file and upserts the database row: position, dimensions, perceptual hash and a generated webp thumbnail.
3. Near-duplicates are skipped: a work whose image hash is within a small distance of an already synced work is logged and left out, so an accidental double never shows twice.
4. Removed works are retired, never hard-deleted: their rows are soft-deleted and disappear from the galleries.
An administrator can also press *Resync* under *Admin, Gallery* after a deploy. Resync re-reads the sources, refreshes thumbnails whose content changed, reports missing source files and retires removed entries. It never invents catalogue entries.
## Freshness
Public pages are cached for seconds to minutes (landing, settings, sitemap each have their own short TTL), so a fresh deploy can take a moment to show everywhere. The exact TTLs are operator detail; see the operator runbook under *Admin*.
Old Dutch paths (`/rol`, `/mensen`, and friends) redirect with 301 to their English replacements; see [API and authentication](/docs/api).
+54
View File
@@ -0,0 +1,54 @@
# Operator runbook
This page is only visible to administrators. It covers the flows that move people and content through the system: invites, gallery updates, trash, caches and the environment rules.
## Member invites
Join requests end in membership through an invite link. The full flow:
1. Read the request under *Admin, Join requests*. `Check link` fetches the linked work once and stores the outcome on the request.
2. Move the status to `contacted` while talking, `declined` to refuse. A declined request cannot receive an invite.
3. Press *Issue invite*. The claim link is shown once on the confirmation page: copy it and send it to the person yourself. There is no email sending.
4. The person opens the link, picks a name and password and accepts the house rules. Claiming creates a Member account, logs them in and flips the request to `accepted`.
Rules the code enforces:
- One open invite per request. Issuing a new one revokes the previous.
- Single use, 7 day expiry by default (`invite_expiry_days` setting).
- Only a hash of the token is stored; a database read never yields a usable link.
- Unknown, used, revoked and expired links all answer identically (404, same message), so links cannot be probed for state.
- If the contact looked like an email address, the claim must use that same address. Handle-style contacts leave the email free for the claimer to fill in.
- `Revoke invite` kills the open link without touching the request.
API: `POST /admin/joins/{uid}/invite` returns `{"claim_url", "expires_at"}` in `data` (JSON only; the HTML form renders the one-time page instead). `POST /admin/joins/{uid}/invite/revoke` returns `{"revoked": n}`. Public claim is `GET` and `POST /invite/{token}`.
## Gallery updates
Source files plus catalogue entries arrive through deploy. After deploy, press *Resync* under *Admin, Gallery* (or `POST /admin/gallery/resync`). The status page shows live flyer and meme counts and lists catalogued files missing from disk. Resync refreshes thumbnails whose content changed and retires removed entries. Boot runs the same sync, so a production rebuild never needs the button.
## Trash
Soft-deleted rows carry a `deleted_at` stamp (UTC ISO). Restore and purge match the exact stamp across every soft-delete table, so one trash event restores or purges as a unit. Purge is permanent; there is no undo.
## Sessions and suspension
Login resolution order is session cookie, `X-API-KEY`, Bearer, Basic; see [API and authentication](/docs/api). Sessions live 7 days, 30 with *remember me* (`session_max_age_days`, `session_remember_days`). A suspended account (`suspended_until` in the future) is refused on every mutating route outside `/auth` with 403. Users whose `terms_version` lags behind the setting are sent to `/terms` on mutating routes (redirect for browsers, 403 with a redirect target for JSON) until they accept.
## Caches
Short TTLs everywhere; defaults below, environment variables may override per deploy:
| Cache | Default |
|-------|---------|
| landing page | 30 s |
| settings | 60 s |
| version | 1 s |
| unread counts | 10 s |
| sitemap | 3600 s |
| auth user lookups | 300 s |
Docs prose is additionally cached in the process for its lifetime: doc text changes need a staging restart or a production rebuild to show.
## Environments and data
All runtime data lives under `./data/[env]` (database, blobs, backups, logs). Never anything outside it. Production data is holy: copies flow production to staging only, never back. Staging runs the dev server, so file changes show without restart except for the process-lifetime caches above. Production is a baked image and needs a rebuild for code, prose and media changes.
+24
View File
@@ -0,0 +1,24 @@
<!-- retoor <retoor@molodetz.nl> -->
{% extends "base.html" %}
{% block content %}
<header class="article-header">
<span class="label">Invite</span>
<h1>Accept your invite</h1>
</header>
{% if not valid %}
<div class="notice error" role="alert"><ul>{% for error in errors %}<li>{{ error }}</li>{% endfor %}</ul></div>
<p><a class="button ghost" href="/">Back home</a></p>
{% else %}
{% if errors %}
<div class="notice error" role="alert"><ul>{% for error in errors %}<li>{{ error }}</li>{% endfor %}</ul></div>
{% endif %}
<form class="form" method="post">
<div class="field"><label for="username">Name</label><input id="username" type="text" name="username" required minlength="3" maxlength="32" pattern="[A-Za-z0-9_-]{3,32}" value="{{ (form or {}).username or '' }}" autocomplete="username"><p class="hint">3 to 32 letters, digits, _ or -.</p></div>
<div class="field"><label for="email">Email</label><input id="email" type="email" name="email" required maxlength="255" value="{{ (form or {}).email or '' }}" autocomplete="email"{% if email_locked %} readonly{% endif %}>{% if email_locked %}<p class="hint">This invite was issued for this address.</p>{% endif %}</div>
<div class="field"><label for="password">Password</label><input id="password" type="password" name="password" required minlength="6" maxlength="128" autocomplete="new-password"></div>
<div class="field"><label for="password_confirm">Password again</label><input id="password_confirm" type="password" name="password_confirm" required minlength="6" maxlength="128" autocomplete="new-password"></div>
<label class="check"><input type="checkbox" name="terms" value="true"> I accept the <a href="/terms" target="_blank" rel="noopener">house rules</a></label>
<div class="actions"><button class="button" type="submit" data-loading>Create my account</button></div>
</form>
{% endif %}
{% endblock %}