"""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 sx_call from shared.sx.page import get_template_context from sxc.pages.utils 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 = sx_call("auth-error-banner", error=error) if error else "" content = sx_call( "federation-choose-username", domain=str(escape(ap_domain)), error=error_sx or 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 'Invalid format' if username in RESERVED: return 'Reserved' available = await services.federation.username_available(g.s, username) if available: return 'Available' return 'Taken' return bp