Phase 2 (Orders): - Checkout error/return renders moved directly into route handlers - Removed orphaned test_sx_helpers.py Phase 3 (Federation): - Auth pages use _render_social_auth_page() helper in routes - Choose-username render inlined into identity routes - Timeline/search/follow/interaction renders inlined into social routes using serializers imported from sxc.pages - Added _social_page() to sxc/pages/__init__.py for shared use - Home page renders inline in app.py Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
132 lines
4.6 KiB
Python
132 lines
4.6 KiB
Python
"""Username selection flow.
|
|
|
|
Users must choose a preferred_username before they can publish.
|
|
This creates their ActorProfile with RSA keys.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
|
|
from quart import (
|
|
Blueprint, request, redirect, url_for, g, abort,
|
|
)
|
|
|
|
from shared.services.registry import services
|
|
|
|
|
|
# Username rules: 3-32 chars, lowercase alphanumeric + underscores
|
|
USERNAME_RE = re.compile(r"^[a-z][a-z0-9_]{2,31}$")
|
|
|
|
# Reserved usernames
|
|
RESERVED = frozenset({
|
|
"admin", "administrator", "root", "system", "moderator", "mod",
|
|
"support", "help", "info", "postmaster", "webmaster", "abuse",
|
|
"federation", "activitypub", "api", "static", "media", "assets",
|
|
"well-known", "nodeinfo", "inbox", "outbox", "followers", "following",
|
|
})
|
|
|
|
|
|
async def _render_choose_username(*, actor=None, error="", username=""):
|
|
"""Render choose-username page — replaces sx_components helper."""
|
|
from shared.browser.app.csrf import generate_csrf_token
|
|
from shared.config import config
|
|
from shared.sx.helpers import render_to_sx
|
|
from shared.sx.parser import SxExpr
|
|
from shared.sx.page import get_template_context
|
|
from sxc.pages import _social_page
|
|
from markupsafe import escape
|
|
|
|
ctx = await get_template_context()
|
|
csrf = generate_csrf_token()
|
|
ap_domain = config().get("ap_domain", "rose-ash.com")
|
|
check_url = url_for("identity.check_username")
|
|
|
|
error_sx = await render_to_sx("auth-error-banner", error=error) if error else ""
|
|
content = await render_to_sx(
|
|
"federation-choose-username",
|
|
domain=str(escape(ap_domain)),
|
|
error=SxExpr(error_sx) if error_sx else None,
|
|
csrf=csrf, username=str(escape(username)),
|
|
check_url=check_url,
|
|
)
|
|
return await _social_page(ctx, actor, content=content,
|
|
title="Choose Username \u2014 Rose Ash")
|
|
|
|
|
|
def register(url_prefix="/identity"):
|
|
bp = Blueprint("identity", __name__, url_prefix=url_prefix)
|
|
|
|
@bp.get("/choose-username")
|
|
async def choose_username_form():
|
|
if not g.get("user"):
|
|
return redirect(url_for("auth.login_form"))
|
|
|
|
# Already has a username?
|
|
actor = await services.federation.get_actor_by_user_id(g.s, g.user.id)
|
|
if actor:
|
|
return redirect(url_for("activitypub.actor_profile", username=actor.preferred_username))
|
|
|
|
return await _render_choose_username(actor=actor)
|
|
|
|
@bp.post("/choose-username")
|
|
async def choose_username():
|
|
if not g.get("user"):
|
|
abort(401)
|
|
|
|
# Already has a username?
|
|
existing = await services.federation.get_actor_by_user_id(g.s, g.user.id)
|
|
if existing:
|
|
return redirect(url_for("activitypub.actor_profile", username=existing.preferred_username))
|
|
|
|
form = await request.form
|
|
username = (form.get("username") or "").strip().lower()
|
|
|
|
# Validate format
|
|
error = None
|
|
if not USERNAME_RE.match(username):
|
|
error = (
|
|
"Username must be 3-32 characters, start with a letter, "
|
|
"and contain only lowercase letters, numbers, and underscores."
|
|
)
|
|
elif username in RESERVED:
|
|
error = "This username is reserved."
|
|
elif not await services.federation.username_available(g.s, username):
|
|
error = "This username is already taken."
|
|
|
|
if error:
|
|
return await _render_choose_username(error=error, username=username), 400
|
|
|
|
# Create ActorProfile with RSA keys
|
|
display_name = g.user.name or username
|
|
actor = await services.federation.create_actor(
|
|
g.s, g.user.id, username,
|
|
display_name=display_name,
|
|
)
|
|
|
|
# Redirect to where they were going, or their new profile
|
|
next_url = request.args.get("next")
|
|
if next_url:
|
|
return redirect(next_url)
|
|
return redirect(url_for("activitypub.actor_profile", username=actor.preferred_username))
|
|
|
|
@bp.get("/check-username")
|
|
async def check_username():
|
|
"""HTMX endpoint to check username availability."""
|
|
username = (request.args.get("username") or "").strip().lower()
|
|
|
|
if not username:
|
|
return ""
|
|
|
|
if not USERNAME_RE.match(username):
|
|
return '<span class="text-red-600">Invalid format</span>'
|
|
|
|
if username in RESERVED:
|
|
return '<span class="text-red-600">Reserved</span>'
|
|
|
|
available = await services.federation.username_available(g.s, username)
|
|
if available:
|
|
return '<span class="text-green-600">Available</span>'
|
|
return '<span class="text-red-600">Taken</span>'
|
|
|
|
return bp
|