commit 41e967097571fc9951eedd5c784007d049aa7c8c Author: giles Date: Sat Feb 21 15:11:52 2026 +0000 Initial federation app — ActivityPub server for Rose-Ash 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 diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..28450d5 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,82 @@ +name: Build and Deploy + +on: + push: + branches: [main, decoupling] + +env: + REGISTRY: registry.rose-ash.com:5000 + IMAGE: federation + REPO_DIR: /root/rose-ash/federation + COOP_DIR: /root/coop + +jobs: + build-and-deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install tools + run: | + apt-get update && apt-get install -y --no-install-recommends openssh-client + + - name: Set up SSH + env: + SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }} + DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }} + run: | + mkdir -p ~/.ssh + echo "$SSH_KEY" > ~/.ssh/id_rsa + chmod 600 ~/.ssh/id_rsa + ssh-keyscan -H "$DEPLOY_HOST" >> ~/.ssh/known_hosts 2>/dev/null || true + + - name: Pull latest code on server + env: + DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }} + run: | + ssh "root@$DEPLOY_HOST" " + cd ${{ env.REPO_DIR }} + git fetch origin ${{ github.ref_name }} + git reset --hard origin/${{ github.ref_name }} + git submodule update --init --recursive + # Clean ALL sibling dirs (including stale self-copies from previous runs) + for sibling in blog market cart events federation; do + rm -rf \$sibling + done + # Copy non-self sibling models for cross-domain imports + for sibling in blog market cart events federation; do + [ \"\$sibling\" = \"${{ env.IMAGE }}\" ] && continue + repo=/root/rose-ash/\$sibling + if [ -d \$repo/.git ]; then + git -C \$repo fetch origin ${{ github.ref_name }} 2>/dev/null || true + mkdir -p \$sibling + git -C \$repo archive origin/${{ github.ref_name }} -- __init__.py models/ 2>/dev/null | tar -x -C \$sibling/ || true + fi + done + " + + - name: Build and push image + env: + DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }} + run: | + ssh "root@$DEPLOY_HOST" " + cd ${{ env.REPO_DIR }} + docker build --build-arg CACHEBUST=\$(date +%s) \ + -t ${{ env.REGISTRY }}/${{ env.IMAGE }}:latest \ + -t ${{ env.REGISTRY }}/${{ env.IMAGE }}:${{ github.sha }} . + docker push ${{ env.REGISTRY }}/${{ env.IMAGE }}:latest + docker push ${{ env.REGISTRY }}/${{ env.IMAGE }}:${{ github.sha }} + " + + - name: Deploy stack + env: + DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }} + run: | + ssh "root@$DEPLOY_HOST" " + cd ${{ env.COOP_DIR }} + source .env + docker stack deploy -c docker-compose.yml coop + echo 'Waiting for services to update...' + sleep 10 + docker stack services coop + " diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..87d616e --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +__pycache__/ +*.pyc +*.pyo +.env +node_modules/ +*.egg-info/ +dist/ +build/ +.venv/ diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..b509b5a --- /dev/null +++ b/.gitmodules @@ -0,0 +1,4 @@ +[submodule "shared"] + path = shared + url = https://git.rose-ash.com/coop/shared.git + branch = decoupling diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..5898021 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,34 @@ +# syntax=docker/dockerfile:1 + +# ---------- Python application ---------- +FROM python:3.11-slim AS base + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PYTHONPATH=/app \ + PIP_NO_CACHE_DIR=1 \ + APP_PORT=8000 \ + APP_MODULE=app:app + +WORKDIR /app + +# Install system deps + psql client +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ + postgresql-client \ + && rm -rf /var/lib/apt/lists/* + +COPY shared/requirements.txt ./requirements.txt +RUN pip install -r requirements.txt + +COPY . . + +# ---------- Runtime setup ---------- +COPY entrypoint.sh /usr/local/bin/entrypoint.sh +RUN chmod +x /usr/local/bin/entrypoint.sh + +RUN useradd -m -u 10001 appuser && chown -R appuser:appuser /app +USER appuser + +EXPOSE ${APP_PORT} +ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] diff --git a/app.py b/app.py new file mode 100644 index 0000000..7fbc64f --- /dev/null +++ b/app.py @@ -0,0 +1,67 @@ +from __future__ import annotations +import path_setup # noqa: F401 # adds shared_lib to sys.path +from pathlib import Path + +from quart import g +from jinja2 import FileSystemLoader, ChoiceLoader + +from shared.infrastructure.factory import create_base_app +from shared.services.registry import services + +from bp import ( + register_wellknown_bp, + register_actors_bp, + register_identity_bp, + register_auth_bp, +) + + +async def federation_context() -> dict: + """Federation app context processor.""" + from shared.infrastructure.context import base_context + + ctx = await base_context() + + # If user is logged in, check for ActorProfile + if g.get("user"): + actor = await services.federation.get_actor_by_user_id(g.s, g.user.id) + ctx["actor"] = actor + else: + ctx["actor"] = None + + return ctx + + +def create_app() -> "Quart": + from services import register_domain_services + + app = create_base_app( + "federation", + context_fn=federation_context, + domain_services_fn=register_domain_services, + ) + + # App-specific templates override shared templates + app_templates = str(Path(__file__).resolve().parent / "templates") + app.jinja_loader = ChoiceLoader([ + FileSystemLoader(app_templates), + app.jinja_loader, + ]) + + # --- blueprints --- + app.register_blueprint(register_wellknown_bp()) + app.register_blueprint(register_actors_bp()) + app.register_blueprint(register_identity_bp()) + app.register_blueprint(register_auth_bp()) + + # --- home page --- + @app.get("/") + async def home(): + from quart import render_template + stats = await services.federation.get_stats(g.s) + return await render_template("federation/home.html", stats=stats) + + return app + + +app = create_app() diff --git a/bp/__init__.py b/bp/__init__.py new file mode 100644 index 0000000..9500840 --- /dev/null +++ b/bp/__init__.py @@ -0,0 +1,4 @@ +from .wellknown.routes import register as register_wellknown_bp +from .actors.routes import register as register_actors_bp +from .identity.routes import register as register_identity_bp +from .auth.routes import register as register_auth_bp diff --git a/bp/actors/__init__.py b/bp/actors/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bp/actors/routes.py b/bp/actors/routes.py new file mode 100644 index 0000000..b6e4f44 --- /dev/null +++ b/bp/actors/routes.py @@ -0,0 +1,209 @@ +"""ActivityPub actor endpoints: profiles, outbox, inbox. + +Ported from ~/art-dag/activity-pub/app/routers/users.py. +""" +from __future__ import annotations + +import json +import os + +from quart import Blueprint, request, abort, Response, g, render_template + +from shared.services.registry import services +from shared.models.federation import APInboxItem + + +def _domain() -> str: + return os.getenv("AP_DOMAIN", "rose-ash.com") + + +def register(url_prefix="/users"): + bp = Blueprint("actors", __name__, url_prefix=url_prefix) + + @bp.get("/") + async def profile(username: str): + actor = await services.federation.get_actor_by_username(g.s, username) + if not actor: + abort(404) + + domain = _domain() + accept = request.headers.get("accept", "") + + # AP JSON-LD response + if "application/activity+json" in accept or "application/ld+json" in accept: + actor_json = { + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1", + ], + "type": "Person", + "id": f"https://{domain}/users/{username}", + "name": actor.display_name or username, + "preferredUsername": username, + "summary": actor.summary or "", + "inbox": f"https://{domain}/users/{username}/inbox", + "outbox": f"https://{domain}/users/{username}/outbox", + "followers": f"https://{domain}/users/{username}/followers", + "following": f"https://{domain}/users/{username}/following", + "publicKey": { + "id": f"https://{domain}/users/{username}#main-key", + "owner": f"https://{domain}/users/{username}", + "publicKeyPem": actor.public_key_pem, + }, + "url": f"https://{domain}/users/{username}", + } + return Response( + response=json.dumps(actor_json), + content_type="application/activity+json", + ) + + # HTML profile page + activities, total = await services.federation.get_outbox( + g.s, username, page=1, per_page=20, + ) + return await render_template( + "federation/profile.html", + actor=actor, + activities=activities, + total=total, + ) + + @bp.get("//outbox") + async def outbox(username: str): + actor = await services.federation.get_actor_by_username(g.s, username) + if not actor: + abort(404) + + domain = _domain() + actor_id = f"https://{domain}/users/{username}" + page_param = request.args.get("page") + + if not page_param: + _, total = await services.federation.get_outbox(g.s, username, page=1, per_page=1) + return Response( + response=json.dumps({ + "@context": "https://www.w3.org/ns/activitystreams", + "type": "OrderedCollection", + "id": f"{actor_id}/outbox", + "totalItems": total, + "first": f"{actor_id}/outbox?page=1", + }), + content_type="application/activity+json", + ) + + page_num = int(page_param) + activities, total = await services.federation.get_outbox( + g.s, username, page=page_num, per_page=20, + ) + + items = [] + for a in activities: + items.append({ + "@context": "https://www.w3.org/ns/activitystreams", + "type": a.activity_type, + "id": a.activity_id, + "actor": actor_id, + "published": a.published.isoformat() if a.published else None, + "object": { + "type": a.object_type, + **(a.object_data or {}), + }, + }) + + return Response( + response=json.dumps({ + "@context": "https://www.w3.org/ns/activitystreams", + "type": "OrderedCollectionPage", + "id": f"{actor_id}/outbox?page={page_num}", + "partOf": f"{actor_id}/outbox", + "totalItems": total, + "orderedItems": items, + }), + content_type="application/activity+json", + ) + + @bp.post("//inbox") + async def inbox(username: str): + actor = await services.federation.get_actor_by_username(g.s, username) + if not actor: + abort(404) + + body = await request.get_json() + if not body: + abort(400, "Invalid JSON") + + # Store raw inbox item for async processing + from shared.models.federation import ActorProfile + from sqlalchemy import select + actor_row = ( + await g.s.execute( + select(ActorProfile).where( + ActorProfile.preferred_username == username + ) + ) + ).scalar_one() + + item = APInboxItem( + actor_profile_id=actor_row.id, + raw_json=body, + activity_type=body.get("type"), + from_actor=body.get("actor"), + ) + g.s.add(item) + await g.s.flush() + + # Emit domain event for processing + from shared.events import emit_event + await emit_event( + g.s, + "federation.inbox_received", + "APInboxItem", + item.id, + { + "actor_username": username, + "activity_type": body.get("type"), + "from_actor": body.get("actor"), + }, + ) + + return Response(status=202) + + @bp.get("//followers") + async def followers(username: str): + actor = await services.federation.get_actor_by_username(g.s, username) + if not actor: + abort(404) + + domain = _domain() + follower_list = await services.federation.get_followers(g.s, username) + + return Response( + response=json.dumps({ + "@context": "https://www.w3.org/ns/activitystreams", + "type": "OrderedCollection", + "id": f"https://{domain}/users/{username}/followers", + "totalItems": len(follower_list), + "orderedItems": [f.follower_actor_url for f in follower_list], + }), + content_type="application/activity+json", + ) + + @bp.get("//following") + async def following(username: str): + actor = await services.federation.get_actor_by_username(g.s, username) + if not actor: + abort(404) + + domain = _domain() + return Response( + response=json.dumps({ + "@context": "https://www.w3.org/ns/activitystreams", + "type": "OrderedCollection", + "id": f"https://{domain}/users/{username}/following", + "totalItems": 0, + "orderedItems": [], + }), + content_type="application/activity+json", + ) + + return bp diff --git a/bp/auth/__init__.py b/bp/auth/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bp/auth/routes.py b/bp/auth/routes.py new file mode 100644 index 0000000..c851976 --- /dev/null +++ b/bp/auth/routes.py @@ -0,0 +1,168 @@ +"""Authentication routes for the federation app. + +Ported from blog/bp/auth/routes.py — owns magic link login/logout. +Simplified: no Ghost sync, no newsletter management (those stay in blog). +""" +from __future__ import annotations + +from datetime import datetime, timezone + +from quart import ( + Blueprint, + request, + render_template, + make_response, + redirect, + url_for, + session as qsession, + g, + current_app, +) +from sqlalchemy import select +from sqlalchemy.exc import SQLAlchemyError + +from shared.db.session import get_session +from shared.models import User +from shared.config import config +from shared.utils import host_url +from shared.infrastructure.urls import federation_url +from shared.infrastructure.cart_identity import current_cart_identity +from shared.events import emit_event +from shared.services.registry import services + +from .services import ( + pop_login_redirect_target, + store_login_redirect_target, + send_magic_email, + find_or_create_user, + create_magic_link, + validate_magic_link, + validate_email, +) + +SESSION_USER_KEY = "uid" + + +def register(url_prefix="/auth"): + auth_bp = Blueprint("auth", __name__, url_prefix=url_prefix) + + @auth_bp.get("/login/") + async def login_form(): + store_login_redirect_target() + if g.get("user"): + return redirect(federation_url("/")) + return await render_template("auth/login.html") + + @auth_bp.get("/account/") + async def account(): + if not g.get("user"): + return redirect(host_url(url_for("auth.login_form"))) + + # Check if user has an ActorProfile + actor = await services.federation.get_actor_by_user_id(g.s, g.user.id) + return await render_template( + "federation/account.html", + actor=actor, + ) + + @auth_bp.post("/start/") + async def start_login(): + form = await request.form + email_input = form.get("email") or "" + + is_valid, email = validate_email(email_input) + if not is_valid: + return ( + await render_template( + "auth/login.html", + error="Please enter a valid email address.", + email=email_input, + ), + 400, + ) + + user = await find_or_create_user(g.s, email) + token, expires = await create_magic_link(g.s, user.id) + + magic_url = host_url(url_for("auth.magic", token=token)) + + email_error = None + try: + await send_magic_email(email, magic_url) + except Exception as e: + current_app.logger.error("EMAIL SEND FAILED: %r", e) + email_error = ( + "We couldn't send the email automatically. " + "Please try again in a moment." + ) + + return await render_template( + "auth/check_email.html", + email=email, + email_error=email_error, + ) + + @auth_bp.get("/magic//") + async def magic(token: str): + now = datetime.now(timezone.utc) + user_id: int | None = None + + try: + async with get_session() as s: + async with s.begin(): + user, error = await validate_magic_link(s, token) + + if error: + return ( + await render_template("auth/login.html", error=error), + 400, + ) + user_id = user.id + + except Exception: + return ( + await render_template( + "auth/login.html", + error="Could not sign you in right now. Please try again.", + ), + 502, + ) + + assert user_id is not None + + ident = current_cart_identity() + anon_session_id = ident.get("session_id") + + try: + async with get_session() as s: + async with s.begin(): + u2 = await s.get(User, user_id) + if u2: + u2.last_login_at = now + if anon_session_id: + await emit_event( + s, + "user.logged_in", + "user", + user_id, + { + "user_id": user_id, + "session_id": anon_session_id, + }, + ) + except SQLAlchemyError: + current_app.logger.exception( + "[auth] non-fatal DB update for user_id=%s", user_id + ) + + qsession[SESSION_USER_KEY] = user_id + + redirect_url = pop_login_redirect_target() + return redirect(redirect_url, 303) + + @auth_bp.post("/logout/") + async def logout(): + qsession.pop(SESSION_USER_KEY, None) + return redirect(federation_url("/")) + + return auth_bp diff --git a/bp/auth/services/__init__.py b/bp/auth/services/__init__.py new file mode 100644 index 0000000..648f87d --- /dev/null +++ b/bp/auth/services/__init__.py @@ -0,0 +1,24 @@ +from .login_redirect import pop_login_redirect_target, store_login_redirect_target +from .auth_operations import ( + get_app_host, + get_app_root, + send_magic_email, + load_user_by_id, + find_or_create_user, + create_magic_link, + validate_magic_link, + validate_email, +) + +__all__ = [ + "pop_login_redirect_target", + "store_login_redirect_target", + "get_app_host", + "get_app_root", + "send_magic_email", + "load_user_by_id", + "find_or_create_user", + "create_magic_link", + "validate_magic_link", + "validate_email", +] diff --git a/bp/auth/services/auth_operations.py b/bp/auth/services/auth_operations.py new file mode 100644 index 0000000..d9f4487 --- /dev/null +++ b/bp/auth/services/auth_operations.py @@ -0,0 +1,157 @@ +"""Auth operations for the federation app. + +Copied from blog/bp/auth/services/auth_operations.py to avoid cross-app +import chains. The logic is identical — shared models, shared config. +""" +from __future__ import annotations + +import os +import secrets +from datetime import datetime, timedelta, timezone +from typing import Optional, Tuple + +from quart import current_app, render_template, request, g +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from shared.models import User, MagicLink +from shared.config import config + + +def get_app_host() -> str: + host = ( + config().get("host") or os.getenv("APP_HOST") or "http://localhost:8000" + ).rstrip("/") + return host + + +def get_app_root() -> str: + root = (g.root).rstrip("/") + return root + + +async def send_magic_email(to_email: str, link_url: str) -> None: + host = os.getenv("SMTP_HOST") + port = int(os.getenv("SMTP_PORT") or "587") + username = os.getenv("SMTP_USER") + password = os.getenv("SMTP_PASS") + mail_from = os.getenv("MAIL_FROM") or "no-reply@example.com" + + site_name = config().get("title", "Rose Ash") + subject = f"Your sign-in link \u2014 {site_name}" + + tpl_vars = dict(site_name=site_name, link_url=link_url) + text_body = await render_template("_email/magic_link.txt", **tpl_vars) + html_body = await render_template("_email/magic_link.html", **tpl_vars) + + if not host or not username or not password: + current_app.logger.warning( + "SMTP not configured. Printing magic link to console for %s: %s", + to_email, + link_url, + ) + print(f"[DEV] Magic link for {to_email}: {link_url}") + return + + import aiosmtplib + from email.message import EmailMessage + + msg = EmailMessage() + msg["From"] = mail_from + msg["To"] = to_email + msg["Subject"] = subject + msg.set_content(text_body) + msg.add_alternative(html_body, subtype="html") + + is_secure = port == 465 + if is_secure: + smtp = aiosmtplib.SMTP( + hostname=host, port=port, use_tls=True, + username=username, password=password, + ) + else: + smtp = aiosmtplib.SMTP( + hostname=host, port=port, start_tls=True, + username=username, password=password, + ) + + async with smtp: + await smtp.send_message(msg) + + +async def load_user_by_id(session: AsyncSession, user_id: int) -> Optional[User]: + stmt = ( + select(User) + .options(selectinload(User.labels)) + .where(User.id == user_id) + ) + result = await session.execute(stmt) + return result.scalar_one_or_none() + + +async def find_or_create_user(session: AsyncSession, email: str) -> User: + result = await session.execute(select(User).where(User.email == email)) + user = result.scalar_one_or_none() + + if user is None: + user = User(email=email) + session.add(user) + await session.flush() + + return user + + +async def create_magic_link( + session: AsyncSession, + user_id: int, + purpose: str = "signin", + expires_minutes: int = 15, +) -> Tuple[str, datetime]: + token = secrets.token_urlsafe(32) + expires = datetime.now(timezone.utc) + timedelta(minutes=expires_minutes) + + ml = MagicLink( + token=token, + user_id=user_id, + purpose=purpose, + expires_at=expires, + ip=request.headers.get("x-forwarded-for", request.remote_addr), + user_agent=request.headers.get("user-agent"), + ) + session.add(ml) + + return token, expires + + +async def validate_magic_link( + session: AsyncSession, + token: str, +) -> Tuple[Optional[User], Optional[str]]: + now = datetime.now(timezone.utc) + + ml = await session.scalar( + select(MagicLink) + .where(MagicLink.token == token) + .with_for_update() + ) + + if not ml or ml.purpose != "signin": + return None, "Invalid or expired link." + + if ml.used_at or ml.expires_at < now: + return None, "This link has expired. Please request a new one." + + user = await session.get(User, ml.user_id) + if not user: + return None, "User not found." + + ml.used_at = now + return user, None + + +def validate_email(email: str) -> Tuple[bool, str]: + email = email.strip().lower() + if not email or "@" not in email: + return False, email + return True, email diff --git a/bp/auth/services/login_redirect.py b/bp/auth/services/login_redirect.py new file mode 100644 index 0000000..acf6df7 --- /dev/null +++ b/bp/auth/services/login_redirect.py @@ -0,0 +1,45 @@ +from urllib.parse import urlparse +from quart import session + +from shared.infrastructure.urls import federation_url + + +LOGIN_REDIRECT_SESSION_KEY = "login_redirect_to" + + +def store_login_redirect_target() -> None: + from quart import request + + target = request.args.get("next") + if not target: + ref = request.referrer or "" + try: + parsed = urlparse(ref) + target = parsed.path or "" + except Exception: + target = "" + + if not target: + return + + # Accept both relative paths and absolute URLs (cross-app redirects) + if target.startswith("http://") or target.startswith("https://"): + session[LOGIN_REDIRECT_SESSION_KEY] = target + elif target.startswith("/") and not target.startswith("//"): + session[LOGIN_REDIRECT_SESSION_KEY] = target + + +def pop_login_redirect_target() -> str: + path = session.pop(LOGIN_REDIRECT_SESSION_KEY, None) + if not path or not isinstance(path, str): + return federation_url("/auth/") + + # Absolute URL: return as-is (cross-app redirect) + if path.startswith("http://") or path.startswith("https://"): + return path + + # Relative path: must start with / and not // + if path.startswith("/") and not path.startswith("//"): + return federation_url(path) + + return federation_url("/auth/") diff --git a/bp/identity/__init__.py b/bp/identity/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bp/identity/routes.py b/bp/identity/routes.py new file mode 100644 index 0000000..673ea0c --- /dev/null +++ b/bp/identity/routes.py @@ -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 '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 diff --git a/bp/wellknown/__init__.py b/bp/wellknown/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bp/wellknown/routes.py b/bp/wellknown/routes.py new file mode 100644 index 0000000..f36e32b --- /dev/null +++ b/bp/wellknown/routes.py @@ -0,0 +1,114 @@ +"""Well-known federation endpoints: WebFinger, NodeInfo, host-meta. + +Ported from ~/art-dag/activity-pub/app/routers/federation.py. +""" +from __future__ import annotations + +import os + +from quart import Blueprint, request, abort, Response, g + +from shared.services.registry import services + + +def _domain() -> str: + return os.getenv("AP_DOMAIN", "rose-ash.com") + + +def register(url_prefix=""): + bp = Blueprint("wellknown", __name__, url_prefix=url_prefix) + + @bp.get("/.well-known/webfinger") + async def webfinger(): + resource = request.args.get("resource", "") + if not resource.startswith("acct:"): + abort(400, "Invalid resource format") + + parts = resource[5:].split("@") + if len(parts) != 2: + abort(400, "Invalid resource format") + + username, domain = parts + if domain != _domain(): + abort(404, "User not on this server") + + actor = await services.federation.get_actor_by_username(g.s, username) + if not actor: + abort(404, "User not found") + + domain = _domain() + return Response( + response=__import__("json").dumps({ + "subject": resource, + "aliases": [f"https://{domain}/users/{username}"], + "links": [ + { + "rel": "self", + "type": "application/activity+json", + "href": f"https://{domain}/users/{username}", + }, + { + "rel": "http://webfinger.net/rel/profile-page", + "type": "text/html", + "href": f"https://{domain}/users/{username}", + }, + ], + }), + content_type="application/jrd+json", + ) + + @bp.get("/.well-known/nodeinfo") + async def nodeinfo_index(): + domain = _domain() + return Response( + response=__import__("json").dumps({ + "links": [ + { + "rel": "http://nodeinfo.diaspora.software/ns/schema/2.0", + "href": f"https://{domain}/nodeinfo/2.0", + } + ] + }), + content_type="application/json", + ) + + @bp.get("/nodeinfo/2.0") + async def nodeinfo(): + stats = await services.federation.get_stats(g.s) + return Response( + response=__import__("json").dumps({ + "version": "2.0", + "software": { + "name": "rose-ash", + "version": "1.0.0", + }, + "protocols": ["activitypub"], + "usage": { + "users": { + "total": stats.get("actors", 0), + "activeMonth": stats.get("actors", 0), + }, + "localPosts": stats.get("activities", 0), + }, + "openRegistrations": False, + "metadata": { + "nodeName": "Rose Ash", + "nodeDescription": "Cooperative platform with ActivityPub federation", + }, + }), + content_type="application/json", + ) + + @bp.get("/.well-known/host-meta") + async def host_meta(): + domain = _domain() + xml = ( + '\n' + '\n' + f' \n' + '' + ) + return Response(response=xml, content_type="application/xrd+xml") + + return bp diff --git a/config/app-config.yaml b/config/app-config.yaml new file mode 100644 index 0000000..278134e --- /dev/null +++ b/config/app-config.yaml @@ -0,0 +1,84 @@ +# App-wide settings +base_host: "wholesale.suma.coop" +base_login: https://wholesale.suma.coop/customer/account/login/ +base_url: https://wholesale.suma.coop/ +title: Rose Ash +coop_root: /market +coop_title: Market +blog_root: / +blog_title: all the news +cart_root: /cart +app_urls: + coop: "http://localhost:8000" + market: "http://localhost:8001" + cart: "http://localhost:8002" + events: "http://localhost:8003" + federation: "http://localhost:8004" +cache: + fs_root: _snapshot # <- absolute path to your snapshot dir +categories: + allow: + Basics: basics + Branded Goods: branded-goods + Chilled: chilled + Frozen: frozen + Non-foods: non-foods + Supplements: supplements + Christmas: christmas +slugs: + skip: + - "" + - customer + - account + - checkout + - wishlist + - sales + - contact + - privacy-policy + - terms-and-conditions + - delivery + - catalogsearch + - quickorder + - apply + - search + - static + - media +section-titles: + - ingredients + - allergy information + - allergens + - nutritional information + - nutrition + - storage + - directions + - preparation + - serving suggestions + - origin + - country of origin + - recycling + - general information + - additional information + - a note about prices + +blacklist: + category: + - branded-goods/alcoholic-drinks + - branded-goods/beers + - branded-goods/wines + - branded-goods/ciders + product: + - list-price-suma-current-suma-price-list-each-bk012-2-html + - ---just-lem-just-wholefoods-jelly-crystals-lemon-12-x-85g-vf067-2-html + product-details: + - General Information + - A Note About Prices + +# SumUp payment settings (fill these in for live usage) +sumup: + merchant_code: "ME4J6100" + currency: "GBP" + # Name of the environment variable that holds your SumUp API key + api_key_env: "SUMUP_API_KEY" + webhook_secret: "CHANGE_ME_TO_A_LONG_RANDOM_STRING" + checkout_reference_prefix: 'dev-' + diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100755 index 0000000..05d9e3d --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Optional: wait for Postgres to be reachable +if [[ -n "${DATABASE_HOST:-}" && -n "${DATABASE_PORT:-}" ]]; then + echo "Waiting for Postgres at ${DATABASE_HOST}:${DATABASE_PORT}..." + for i in {1..60}; do + (echo > /dev/tcp/${DATABASE_HOST}/${DATABASE_PORT}) >/dev/null 2>&1 && break || true + sleep 1 + done +fi + +# Federation can optionally run migrations (set RUN_MIGRATIONS=true) +if [[ "${RUN_MIGRATIONS:-}" == "true" ]]; then + echo "Running Alembic migrations..." + (cd shared && alembic upgrade head) +fi + +# Clear Redis page cache on deploy +if [[ -n "${REDIS_URL:-}" && "${REDIS_URL}" != "no" ]]; then + echo "Flushing Redis cache..." + python3 -c " +import redis, os +r = redis.from_url(os.environ['REDIS_URL']) +r.flushall() +print('Redis cache cleared.') +" || echo "Redis flush failed (non-fatal), continuing..." +fi + +# Start the app +echo "Starting Hypercorn (${APP_MODULE:-app:app})..." +PYTHONUNBUFFERED=1 exec hypercorn "${APP_MODULE:-app:app}" --bind 0.0.0.0:${PORT:-8000} diff --git a/models/__init__.py b/models/__init__.py new file mode 100644 index 0000000..7d27499 --- /dev/null +++ b/models/__init__.py @@ -0,0 +1,9 @@ +"""Re-export federation models from shared.models.""" +from shared.models.federation import ( # noqa: F401 + ActorProfile, + APActivity, + APFollower, + APInboxItem, + APAnchor, + IPFSPin, +) diff --git a/path_setup.py b/path_setup.py new file mode 100644 index 0000000..c7166f7 --- /dev/null +++ b/path_setup.py @@ -0,0 +1,9 @@ +import sys +import os + +_app_dir = os.path.dirname(os.path.abspath(__file__)) +_project_root = os.path.dirname(_app_dir) + +for _p in (_project_root, _app_dir): + if _p not in sys.path: + sys.path.insert(0, _p) diff --git a/services/__init__.py b/services/__init__.py new file mode 100644 index 0000000..e6794e2 --- /dev/null +++ b/services/__init__.py @@ -0,0 +1,27 @@ +"""Federation app service registration.""" +from __future__ import annotations + + +def register_domain_services() -> None: + """Register services for the federation app. + + Federation owns: ActorProfile, APActivity, APFollower, APInboxItem, + APAnchor, IPFSPin. + Standard deployment registers all services as real DB impls (shared DB). + """ + from shared.services.registry import services + from shared.services.federation_impl import SqlFederationService + from shared.services.blog_impl import SqlBlogService + from shared.services.calendar_impl import SqlCalendarService + from shared.services.market_impl import SqlMarketService + from shared.services.cart_impl import SqlCartService + + services.federation = SqlFederationService() + if not services.has("blog"): + services.blog = SqlBlogService() + if not services.has("calendar"): + services.calendar = SqlCalendarService() + if not services.has("market"): + services.market = SqlMarketService() + if not services.has("cart"): + services.cart = SqlCartService() diff --git a/shared b/shared new file mode 160000 index 0000000..8850a01 --- /dev/null +++ b/shared @@ -0,0 +1 @@ +Subproject commit 8850a0106a51acb55d5c7b84dd45b0b012b6333e diff --git a/templates/_email/magic_link.html b/templates/_email/magic_link.html new file mode 100644 index 0000000..3c1eac6 --- /dev/null +++ b/templates/_email/magic_link.html @@ -0,0 +1,33 @@ + + + + + + +
+ + +
+

{{ site_name }}

+

Sign in to your account

+

+ Click the button below to sign in. This link will expire in 15 minutes. +

+
+ + Sign in + +
+

Or copy and paste this link into your browser:

+

+ {{ link_url }} +

+
+

+ If you did not request this email, you can safely ignore it. +

+
+
+ + diff --git a/templates/_email/magic_link.txt b/templates/_email/magic_link.txt new file mode 100644 index 0000000..28a2efb --- /dev/null +++ b/templates/_email/magic_link.txt @@ -0,0 +1,8 @@ +Hello, + +Click this link to sign in: +{{ link_url }} + +This link will expire in 15 minutes. + +If you did not request this, you can ignore this email. diff --git a/templates/auth/check_email.html b/templates/auth/check_email.html new file mode 100644 index 0000000..0a5c0d6 --- /dev/null +++ b/templates/auth/check_email.html @@ -0,0 +1,18 @@ +{% extends "federation/base.html" %} +{% block title %}Check your email — Rose Ash{% endblock %} +{% block content %} +
+

Check your email

+

+ We sent a sign-in link to {{ email }}. +

+

+ Click the link in the email to sign in. The link expires in 15 minutes. +

+ {% if email_error %} +
+ {{ email_error }} +
+ {% endif %} +
+{% endblock %} diff --git a/templates/auth/login.html b/templates/auth/login.html new file mode 100644 index 0000000..87176dc --- /dev/null +++ b/templates/auth/login.html @@ -0,0 +1,34 @@ +{% extends "federation/base.html" %} +{% block title %}Login — Rose Ash{% endblock %} +{% block content %} +
+

Sign in

+ + {% if error %} +
+ {{ error }} +
+ {% endif %} + +
+
+ + +
+ +
+
+{% endblock %} diff --git a/templates/federation/account.html b/templates/federation/account.html new file mode 100644 index 0000000..f541bc3 --- /dev/null +++ b/templates/federation/account.html @@ -0,0 +1,27 @@ +{% extends "federation/base.html" %} +{% block title %}Account — Rose Ash{% endblock %} +{% block content %} +
+

Account

+ +
+

Email: {{ g.user.email }}

+ {% if actor %} +

Username: @{{ actor.preferred_username }}

+

+ + View profile + +

+ {% else %} +

+ + Choose a username to start publishing + +

+ {% endif %} +
+
+{% endblock %} diff --git a/templates/federation/base.html b/templates/federation/base.html new file mode 100644 index 0000000..6eca233 --- /dev/null +++ b/templates/federation/base.html @@ -0,0 +1,35 @@ + + + + + + {% block title %}Rose Ash{% endblock %} + + + + +
+ {% block content %}{% endblock %} +
+ + diff --git a/templates/federation/choose_username.html b/templates/federation/choose_username.html new file mode 100644 index 0000000..61c89c7 --- /dev/null +++ b/templates/federation/choose_username.html @@ -0,0 +1,53 @@ +{% extends "federation/base.html" %} +{% block title %}Choose Username — Rose Ash{% endblock %} +{% block content %} +
+

Choose your username

+

+ This will be your identity on the fediverse: + @username@{{ config.get('ap_domain', 'rose-ash.com') }} +

+ + {% if error %} +
+ {{ error }} +
+ {% endif %} + +
+
+ +
+ @ + +
+
+

+ 3-32 characters. Lowercase letters, numbers, underscores. Must start with a letter. +

+
+ + +
+
+{% endblock %} diff --git a/templates/federation/home.html b/templates/federation/home.html new file mode 100644 index 0000000..6e7f217 --- /dev/null +++ b/templates/federation/home.html @@ -0,0 +1,23 @@ +{% extends "federation/base.html" %} +{% block title %}Rose Ash — Federation{% endblock %} +{% block content %} +
+

Rose Ash

+

Cooperative platform with ActivityPub federation.

+ +
+
+
{{ stats.actors }}
+
Actors
+
+
+
{{ stats.activities }}
+
Activities
+
+
+
{{ stats.followers }}
+
Followers
+
+
+
+{% endblock %} diff --git a/templates/federation/profile.html b/templates/federation/profile.html new file mode 100644 index 0000000..7da00fb --- /dev/null +++ b/templates/federation/profile.html @@ -0,0 +1,32 @@ +{% extends "federation/base.html" %} +{% block title %}@{{ actor.preferred_username }} — Rose Ash{% endblock %} +{% block content %} +
+
+

{{ actor.display_name or actor.preferred_username }}

+

@{{ actor.preferred_username }}@{{ config.get('ap_domain', 'rose-ash.com') }}

+ {% if actor.summary %} +

{{ actor.summary }}

+ {% endif %} +
+ +

Activities ({{ total }})

+ {% if activities %} +
+ {% for a in activities %} +
+
+ {{ a.activity_type }} + {{ a.published.strftime('%Y-%m-%d %H:%M') if a.published }} +
+ {% if a.object_type %} + {{ a.object_type }} + {% endif %} +
+ {% endfor %} +
+ {% else %} +

No activities yet.

+ {% endif %} +
+{% endblock %}