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
+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)