Initial federation app — ActivityPub server for Rose-Ash
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 41s
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 41s
Phase 0+1 of AP integration. New 5th Quart microservice: Blueprints: - wellknown: WebFinger, NodeInfo 2.0, host-meta - actors: AP actor profiles (JSON-LD + HTML), outbox, inbox, followers - identity: username selection flow (creates ActorProfile + RSA keypair) - auth: magic link login/logout (ported from blog, self-contained) Services: - Registers SqlFederationService (real impl) for federation domain - Registers real impls for blog, calendar, market, cart - All cross-domain via shared service contracts Templates: - Actor profiles, username selection, platform home - Auth login/check-email (ported from blog) Infrastructure: - Dockerfile + entrypoint.sh (matches other apps) - CI/CD via Gitea Actions - shared/ as git submodule Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
0
bp/identity/__init__.py
Normal file
0
bp/identity/__init__.py
Normal file
108
bp/identity/routes.py
Normal file
108
bp/identity/routes.py
Normal file
@@ -0,0 +1,108 @@
|
||||
"""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("actors.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("actors.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("actors.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
|
||||
Reference in New Issue
Block a user