"""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, render_template, 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", }) 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_template("federation/choose_username.html") @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_template( "federation/choose_username.html", 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