- 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
129 lines
4.4 KiB
Python
129 lines
4.4 KiB
Python
# 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
|