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:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user