Monorepo: consolidate 7 repos into one

Combines shared, blog, market, cart, events, federation, and account
into a single repository. Eliminates submodule sync, sibling model
copying at build time, and per-app CI orchestration.

Changes:
- Remove per-app .git, .gitmodules, .gitea, submodule shared/ dirs
- Remove stale sibling model copies from each app
- Update all 6 Dockerfiles for monorepo build context (root = .)
- Add build directives to docker-compose.yml
- Add single .gitea/workflows/ci.yml with change detection
- Add .dockerignore for monorepo build context
- Create __init__.py for federation and account (cross-app imports)
This commit is contained in:
giles
2026-02-24 19:44:17 +00:00
commit f42042ccb7
895 changed files with 61147 additions and 0 deletions

9
federation/.gitignore vendored Normal file
View File

@@ -0,0 +1,9 @@
__pycache__/
*.pyc
*.pyo
.env
node_modules/
*.egg-info/
dist/
build/
.venv/

50
federation/Dockerfile Normal file
View File

@@ -0,0 +1,50 @@
# 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
# Shared code (replaces submodule)
COPY shared/ ./shared/
# App code
COPY federation/ ./
# Sibling models for cross-domain SQLAlchemy imports
COPY blog/__init__.py ./blog/__init__.py
COPY blog/models/ ./blog/models/
COPY market/__init__.py ./market/__init__.py
COPY market/models/ ./market/models/
COPY cart/__init__.py ./cart/__init__.py
COPY cart/models/ ./cart/models/
COPY events/__init__.py ./events/__init__.py
COPY events/models/ ./events/models/
COPY account/__init__.py ./account/__init__.py
COPY account/models/ ./account/models/
# ---------- Runtime setup ----------
COPY federation/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"]

0
federation/__init__.py Normal file
View File

84
federation/app.py Normal file
View File

@@ -0,0 +1,84 @@
from __future__ import annotations
import path_setup # noqa: F401 # adds shared/ to sys.path
from pathlib import Path
from quart import g, request
from jinja2 import FileSystemLoader, ChoiceLoader
from shared.infrastructure.factory import create_base_app
from shared.services.registry import services
from bp import (
register_identity_bp,
register_social_bp,
register_fragments,
)
async def federation_context() -> dict:
"""Federation app context processor."""
from shared.infrastructure.context import base_context
from shared.services.navigation import get_navigation_tree
from shared.infrastructure.cart_identity import current_cart_identity
from shared.infrastructure.fragments import fetch_fragment
ctx = await base_context()
ctx["nav_tree_html"] = await fetch_fragment(
"blog", "nav-tree",
params={"app_name": "federation", "path": request.path},
)
# Fallback for _nav.html when nav-tree fragment fetch fails
ctx["menu_items"] = await get_navigation_tree(g.s)
# Cart data (consistent with all other apps)
ident = current_cart_identity()
summary = await services.cart.cart_summary(
g.s, user_id=ident["user_id"], session_id=ident["session_id"],
)
ctx["cart_count"] = summary.count + summary.calendar_count + summary.ticket_count
ctx["cart_total"] = float(summary.total + summary.calendar_total + summary.ticket_total)
# Actor profile for logged-in users
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 ---
# Well-known + actors (webfinger, inbox, outbox, etc.) are now handled
# by the shared AP blueprint registered in create_base_app().
app.register_blueprint(register_identity_bp())
app.register_blueprint(register_social_bp())
app.register_blueprint(register_fragments())
# --- home page ---
@app.get("/")
async def home():
from quart import render_template
return await render_template("_types/federation/index.html")
return app
app = create_app()

View File

@@ -0,0 +1,3 @@
from .identity.routes import register as register_identity_bp
from .social.routes import register as register_social_bp
from .fragments import register_fragments

View File

View File

@@ -0,0 +1,232 @@
"""Authentication routes for the federation app.
Owns magic link login/logout + OAuth2 authorization server endpoint.
Account pages (newsletters, widget pages) have moved to the account app.
"""
from __future__ import annotations
import secrets
from datetime import datetime, timezone, timedelta
from quart import (
Blueprint,
request,
render_template,
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.models.oauth_code import OAuthCode
from shared.infrastructure.urls import federation_url, app_url
from shared.infrastructure.cart_identity import current_cart_identity
from shared.events import emit_activity
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"
ALLOWED_CLIENTS = {"blog", "market", "cart", "events", "account"}
def register(url_prefix="/auth"):
auth_bp = Blueprint("auth", __name__, url_prefix=url_prefix)
# --- OAuth2 authorize endpoint -------------------------------------------
@auth_bp.get("/oauth/authorize")
@auth_bp.get("/oauth/authorize/")
async def oauth_authorize():
client_id = request.args.get("client_id", "")
redirect_uri = request.args.get("redirect_uri", "")
state = request.args.get("state", "")
if client_id not in ALLOWED_CLIENTS:
return "Invalid client_id", 400
expected_redirect = app_url(client_id, "/auth/callback")
if redirect_uri != expected_redirect:
return "Invalid redirect_uri", 400
# Not logged in — bounce to magic link login, then back here
if not g.get("user"):
# Preserve the full authorize URL so we return here after login
authorize_path = request.full_path # includes query string
store_login_redirect_target()
return redirect(url_for("auth.login_form", next=authorize_path))
# Logged in — issue authorization code
code = secrets.token_urlsafe(48)
now = datetime.now(timezone.utc)
expires = now + timedelta(minutes=5)
async with get_session() as s:
async with s.begin():
oauth_code = OAuthCode(
code=code,
user_id=g.user.id,
client_id=client_id,
redirect_uri=redirect_uri,
expires_at=expires,
)
s.add(oauth_code)
sep = "&" if "?" in redirect_uri else "?"
return redirect(f"{redirect_uri}{sep}code={code}&state={state}")
# --- Magic link login flow -----------------------------------------------
@auth_bp.get("/login/")
async def login_form():
store_login_redirect_target()
cross_cart_sid = request.args.get("cart_sid")
if cross_cart_sid:
qsession["cart_sid"] = cross_cart_sid
if g.get("user"):
# If there's a pending redirect (e.g. OAuth authorize), follow it
redirect_url = pop_login_redirect_target()
return redirect(redirect_url)
return await render_template("auth/login.html")
@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)
from shared.utils import host_url
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/<token>/")
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_activity(
s,
activity_type="rose:Login",
actor_uri="internal:system",
object_type="Person",
object_data={
"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()
resp = redirect(redirect_url, 303)
resp.set_cookie(
"sso_hint", "1",
domain=".rose-ash.com", max_age=30 * 24 * 3600,
secure=True, samesite="Lax", httponly=True,
)
return resp
@auth_bp.post("/logout/")
async def logout():
qsession.pop(SESSION_USER_KEY, None)
resp = redirect(federation_url("/"))
resp.delete_cookie("sso_hint", domain=".rose-ash.com", path="/")
return resp
@auth_bp.get("/clear/")
async def clear():
"""One-time migration helper: clear all session cookies."""
qsession.clear()
resp = redirect(federation_url("/"))
resp.delete_cookie("blog_session", domain=".rose-ash.com", path="/")
resp.delete_cookie("sso_hint", domain=".rose-ash.com", path="/")
return resp
@auth_bp.get("/sso-logout/")
async def sso_logout():
"""SSO logout: clear federation session + sso_hint, redirect to blog."""
qsession.pop(SESSION_USER_KEY, None)
from shared.infrastructure.urls import blog_url
resp = redirect(blog_url("/"))
resp.delete_cookie("sso_hint", domain=".rose-ash.com", path="/")
return resp
return auth_bp

View File

@@ -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",
]

View File

@@ -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

View File

@@ -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("/")
# 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("/")

View File

@@ -0,0 +1 @@
from .routes import register as register_fragments

View File

@@ -0,0 +1,34 @@
"""Federation app fragment endpoints.
Exposes HTML fragments at ``/internal/fragments/<type>`` for consumption
by other coop apps via the fragment client.
"""
from __future__ import annotations
from quart import Blueprint, Response, request
from shared.infrastructure.fragments import FRAGMENT_HEADER
def register():
bp = Blueprint("fragments", __name__, url_prefix="/internal/fragments")
_handlers: dict[str, object] = {}
@bp.before_request
async def _require_fragment_header():
if not request.headers.get(FRAGMENT_HEADER):
return Response("", status=403)
@bp.get("/<fragment_type>")
async def get_fragment(fragment_type: str):
handler = _handlers.get(fragment_type)
if handler is None:
return Response("", status=200, content_type="text/html")
html = await handler()
return Response(html, status=200, content_type="text/html")
bp._fragment_handlers = _handlers
return bp

View File

View 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("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 '<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

View File

View File

@@ -0,0 +1,499 @@
"""Social fediverse routes: timeline, compose, search, follow, interactions, notifications."""
from __future__ import annotations
import logging
from datetime import datetime
from quart import Blueprint, request, g, redirect, url_for, abort, render_template, Response
from shared.services.registry import services
log = logging.getLogger(__name__)
def _require_actor():
"""Return actor context or abort 403."""
actor = g.get("ctx", {}).get("actor") if hasattr(g, "ctx") else None
if not actor:
actor = getattr(g, "_social_actor", None)
if not actor:
abort(403, "You need to choose a federation username first")
return actor
def register(url_prefix="/social"):
bp = Blueprint("social", __name__, url_prefix=url_prefix)
@bp.before_request
async def load_actor():
"""Load actor profile for authenticated users."""
if g.get("user"):
actor = await services.federation.get_actor_by_user_id(g.s, g.user.id)
g._social_actor = actor
# -- Timeline -------------------------------------------------------------
@bp.get("/")
async def home_timeline():
if not g.get("user"):
return redirect(url_for("auth.login_form"))
actor = _require_actor()
items = await services.federation.get_home_timeline(g.s, actor.id)
return await render_template(
"federation/timeline.html",
items=items,
timeline_type="home",
actor=actor,
)
@bp.get("/timeline")
async def home_timeline_page():
actor = _require_actor()
before_str = request.args.get("before")
before = None
if before_str:
try:
before = datetime.fromisoformat(before_str)
except ValueError:
pass
items = await services.federation.get_home_timeline(
g.s, actor.id, before=before,
)
return await render_template(
"federation/_timeline_items.html",
items=items,
timeline_type="home",
actor=actor,
)
@bp.get("/public")
async def public_timeline():
items = await services.federation.get_public_timeline(g.s)
actor = getattr(g, "_social_actor", None)
return await render_template(
"federation/timeline.html",
items=items,
timeline_type="public",
actor=actor,
)
@bp.get("/public/timeline")
async def public_timeline_page():
before_str = request.args.get("before")
before = None
if before_str:
try:
before = datetime.fromisoformat(before_str)
except ValueError:
pass
items = await services.federation.get_public_timeline(g.s, before=before)
actor = getattr(g, "_social_actor", None)
return await render_template(
"federation/_timeline_items.html",
items=items,
timeline_type="public",
actor=actor,
)
# -- Compose --------------------------------------------------------------
@bp.get("/compose")
async def compose_form():
actor = _require_actor()
reply_to = request.args.get("reply_to")
return await render_template(
"federation/compose.html",
actor=actor,
reply_to=reply_to,
)
@bp.post("/compose")
async def compose_submit():
actor = _require_actor()
form = await request.form
content = form.get("content", "").strip()
if not content:
return redirect(url_for("social.compose_form"))
visibility = form.get("visibility", "public")
in_reply_to = form.get("in_reply_to") or None
await services.federation.create_local_post(
g.s, actor.id,
content=content,
visibility=visibility,
in_reply_to=in_reply_to,
)
return redirect(url_for("social.home_timeline"))
@bp.post("/delete/<int:post_id>")
async def delete_post(post_id: int):
actor = _require_actor()
await services.federation.delete_local_post(g.s, actor.id, post_id)
return redirect(url_for("social.home_timeline"))
# -- Search + Follow ------------------------------------------------------
@bp.get("/search")
async def search():
actor = getattr(g, "_social_actor", None)
query = request.args.get("q", "").strip()
actors = []
total = 0
followed_urls: set[str] = set()
if query:
actors, total = await services.federation.search_actors(g.s, query)
if actor:
following, _ = await services.federation.get_following(
g.s, actor.preferred_username, page=1, per_page=1000,
)
followed_urls = {a.actor_url for a in following}
return await render_template(
"federation/search.html",
query=query,
actors=actors,
total=total,
page=1,
followed_urls=followed_urls,
actor=actor,
)
@bp.get("/search/page")
async def search_page():
actor = getattr(g, "_social_actor", None)
query = request.args.get("q", "").strip()
page = request.args.get("page", 1, type=int)
actors = []
total = 0
followed_urls: set[str] = set()
if query:
actors, total = await services.federation.search_actors(
g.s, query, page=page,
)
if actor:
following, _ = await services.federation.get_following(
g.s, actor.preferred_username, page=1, per_page=1000,
)
followed_urls = {a.actor_url for a in following}
return await render_template(
"federation/_search_results.html",
actors=actors,
total=total,
page=page,
query=query,
followed_urls=followed_urls,
actor=actor,
)
@bp.post("/follow")
async def follow():
actor = _require_actor()
form = await request.form
remote_actor_url = form.get("actor_url", "")
if remote_actor_url:
await services.federation.send_follow(
g.s, actor.preferred_username, remote_actor_url,
)
if request.headers.get("HX-Request"):
return await _actor_card_response(actor, remote_actor_url, is_followed=True)
return redirect(request.referrer or url_for("social.search"))
@bp.post("/unfollow")
async def unfollow():
actor = _require_actor()
form = await request.form
remote_actor_url = form.get("actor_url", "")
if remote_actor_url:
await services.federation.unfollow(
g.s, actor.preferred_username, remote_actor_url,
)
if request.headers.get("HX-Request"):
return await _actor_card_response(actor, remote_actor_url, is_followed=False)
return redirect(request.referrer or url_for("social.search"))
async def _actor_card_response(actor, remote_actor_url, is_followed):
"""Re-render a single actor card after follow/unfollow via HTMX."""
remote_dto = await services.federation.get_or_fetch_remote_actor(
g.s, remote_actor_url,
)
if not remote_dto:
return Response("", status=200)
followed_urls = {remote_actor_url} if is_followed else set()
# Detect list context from referer
referer = request.referrer or ""
if "/followers" in referer:
list_type = "followers"
else:
list_type = "following"
return await render_template(
"federation/_actor_list_items.html",
actors=[remote_dto],
total=0,
page=1,
list_type=list_type,
followed_urls=followed_urls,
actor=actor,
)
# -- Interactions ---------------------------------------------------------
@bp.post("/like")
async def like():
actor = _require_actor()
form = await request.form
object_id = form.get("object_id", "")
author_inbox = form.get("author_inbox", "")
await services.federation.like_post(g.s, actor.id, object_id, author_inbox)
# Return updated buttons for HTMX
return await _interaction_buttons_response(actor, object_id, author_inbox)
@bp.post("/unlike")
async def unlike():
actor = _require_actor()
form = await request.form
object_id = form.get("object_id", "")
author_inbox = form.get("author_inbox", "")
await services.federation.unlike_post(g.s, actor.id, object_id, author_inbox)
return await _interaction_buttons_response(actor, object_id, author_inbox)
@bp.post("/boost")
async def boost():
actor = _require_actor()
form = await request.form
object_id = form.get("object_id", "")
author_inbox = form.get("author_inbox", "")
await services.federation.boost_post(g.s, actor.id, object_id, author_inbox)
return await _interaction_buttons_response(actor, object_id, author_inbox)
@bp.post("/unboost")
async def unboost():
actor = _require_actor()
form = await request.form
object_id = form.get("object_id", "")
author_inbox = form.get("author_inbox", "")
await services.federation.unboost_post(g.s, actor.id, object_id, author_inbox)
return await _interaction_buttons_response(actor, object_id, author_inbox)
async def _interaction_buttons_response(actor, object_id, author_inbox):
"""Re-render interaction buttons after a like/boost action."""
from shared.models.federation import APInteraction, APRemotePost, APActivity
from sqlalchemy import select
from shared.services.federation_impl import SqlFederationService
svc = services.federation
post_type, post_id = await svc._resolve_post(g.s, object_id)
like_count = 0
boost_count = 0
liked_by_me = False
boosted_by_me = False
if post_type:
from sqlalchemy import func as sa_func
like_count = (await g.s.execute(
select(sa_func.count(APInteraction.id)).where(
APInteraction.post_type == post_type,
APInteraction.post_id == post_id,
APInteraction.interaction_type == "like",
)
)).scalar() or 0
boost_count = (await g.s.execute(
select(sa_func.count(APInteraction.id)).where(
APInteraction.post_type == post_type,
APInteraction.post_id == post_id,
APInteraction.interaction_type == "boost",
)
)).scalar() or 0
liked_by_me = bool((await g.s.execute(
select(APInteraction.id).where(
APInteraction.actor_profile_id == actor.id,
APInteraction.post_type == post_type,
APInteraction.post_id == post_id,
APInteraction.interaction_type == "like",
).limit(1)
)).scalar())
boosted_by_me = bool((await g.s.execute(
select(APInteraction.id).where(
APInteraction.actor_profile_id == actor.id,
APInteraction.post_type == post_type,
APInteraction.post_id == post_id,
APInteraction.interaction_type == "boost",
).limit(1)
)).scalar())
return await render_template(
"federation/_interaction_buttons.html",
item_object_id=object_id,
item_author_inbox=author_inbox,
like_count=like_count,
boost_count=boost_count,
liked_by_me=liked_by_me,
boosted_by_me=boosted_by_me,
actor=actor,
)
# -- Following / Followers ------------------------------------------------
@bp.get("/following")
async def following_list():
actor = _require_actor()
actors, total = await services.federation.get_following(
g.s, actor.preferred_username,
)
return await render_template(
"federation/following.html",
actors=actors,
total=total,
page=1,
actor=actor,
)
@bp.get("/following/page")
async def following_list_page():
actor = _require_actor()
page = request.args.get("page", 1, type=int)
actors, total = await services.federation.get_following(
g.s, actor.preferred_username, page=page,
)
return await render_template(
"federation/_actor_list_items.html",
actors=actors,
total=total,
page=page,
list_type="following",
followed_urls=set(),
actor=actor,
)
@bp.get("/followers")
async def followers_list():
actor = _require_actor()
actors, total = await services.federation.get_followers_paginated(
g.s, actor.preferred_username,
)
# Build set of followed actor URLs to show Follow Back vs Unfollow
following, _ = await services.federation.get_following(
g.s, actor.preferred_username, page=1, per_page=1000,
)
followed_urls = {a.actor_url for a in following}
return await render_template(
"federation/followers.html",
actors=actors,
total=total,
page=1,
followed_urls=followed_urls,
actor=actor,
)
@bp.get("/followers/page")
async def followers_list_page():
actor = _require_actor()
page = request.args.get("page", 1, type=int)
actors, total = await services.federation.get_followers_paginated(
g.s, actor.preferred_username, page=page,
)
following, _ = await services.federation.get_following(
g.s, actor.preferred_username, page=1, per_page=1000,
)
followed_urls = {a.actor_url for a in following}
return await render_template(
"federation/_actor_list_items.html",
actors=actors,
total=total,
page=page,
list_type="followers",
followed_urls=followed_urls,
actor=actor,
)
@bp.get("/actor/<int:id>")
async def actor_timeline(id: int):
actor = getattr(g, "_social_actor", None)
# Get remote actor info
from shared.models.federation import RemoteActor
from sqlalchemy import select as sa_select
remote = (
await g.s.execute(
sa_select(RemoteActor).where(RemoteActor.id == id)
)
).scalar_one_or_none()
if not remote:
abort(404)
from shared.services.federation_impl import _remote_actor_to_dto
remote_dto = _remote_actor_to_dto(remote)
items = await services.federation.get_actor_timeline(g.s, id)
# Check if we follow this actor
is_following = False
if actor:
from shared.models.federation import APFollowing
existing = (
await g.s.execute(
sa_select(APFollowing).where(
APFollowing.actor_profile_id == actor.id,
APFollowing.remote_actor_id == id,
)
)
).scalar_one_or_none()
is_following = existing is not None
return await render_template(
"federation/actor_timeline.html",
remote_actor=remote_dto,
items=items,
is_following=is_following,
actor=actor,
)
@bp.get("/actor/<int:id>/timeline")
async def actor_timeline_page(id: int):
actor = getattr(g, "_social_actor", None)
before_str = request.args.get("before")
before = None
if before_str:
try:
before = datetime.fromisoformat(before_str)
except ValueError:
pass
items = await services.federation.get_actor_timeline(
g.s, id, before=before,
)
return await render_template(
"federation/_timeline_items.html",
items=items,
timeline_type="actor",
actor_id=id,
actor=actor,
)
# -- Notifications --------------------------------------------------------
@bp.get("/notifications")
async def notifications():
actor = _require_actor()
items = await services.federation.get_notifications(g.s, actor.id)
await services.federation.mark_notifications_read(g.s, actor.id)
return await render_template(
"federation/notifications.html",
notifications=items,
actor=actor,
)
@bp.get("/notifications/count")
async def notification_count():
actor = getattr(g, "_social_actor", None)
if not actor:
return Response("0", content_type="text/plain")
count = await services.federation.unread_notification_count(g.s, actor.id)
if count > 0:
return Response(
f'<span class="bg-red-500 text-white text-xs rounded-full px-1.5 py-0.5">{count}</span>',
content_type="text/html",
)
return Response("", content_type="text/html")
@bp.post("/notifications/read")
async def mark_read():
actor = _require_actor()
await services.federation.mark_notifications_read(g.s, actor.id)
return redirect(url_for("social.notifications"))
return bp

View File

@@ -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
market_root: /market
market_title: Market
blog_root: /
blog_title: all the news
cart_root: /cart
app_urls:
blog: "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-'

32
federation/entrypoint.sh Executable file
View File

@@ -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}

View File

@@ -0,0 +1,9 @@
"""Re-export federation models from shared.models."""
from shared.models.federation import ( # noqa: F401
ActorProfile,
APActivity,
APFollower,
APInboxItem,
APAnchor,
IPFSPin,
)

9
federation/path_setup.py Normal file
View File

@@ -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)

View File

@@ -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()

View File

@@ -0,0 +1,33 @@
<!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8"></head>
<body style="margin:0;padding:0;background:#f5f5f4;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" style="background:#f5f5f4;padding:40px 0;">
<tr><td align="center">
<table width="480" cellpadding="0" cellspacing="0" style="background:#ffffff;border-radius:12px;border:1px solid #e7e5e4;padding:40px;">
<tr><td>
<h1 style="margin:0 0 8px;font-size:20px;font-weight:600;color:#1c1917;">{{ site_name }}</h1>
<p style="margin:0 0 24px;font-size:15px;color:#57534e;">Sign in to your account</p>
<p style="margin:0 0 24px;font-size:15px;line-height:1.5;color:#44403c;">
Click the button below to sign in. This link will expire in 15&nbsp;minutes.
</p>
<table cellpadding="0" cellspacing="0" style="margin:0 0 24px;"><tr><td style="border-radius:8px;background:#1c1917;">
<a href="{{ link_url }}" target="_blank"
style="display:inline-block;padding:12px 32px;font-size:15px;font-weight:500;color:#ffffff;text-decoration:none;border-radius:8px;">
Sign in
</a>
</td></tr></table>
<p style="margin:0 0 8px;font-size:13px;color:#78716c;">Or copy and paste this link into your browser:</p>
<p style="margin:0 0 24px;font-size:13px;word-break:break-all;">
<a href="{{ link_url }}" style="color:#1c1917;">{{ link_url }}</a>
</p>
<hr style="border:none;border-top:1px solid #e7e5e4;margin:24px 0;">
<p style="margin:0;font-size:12px;color:#a8a29e;">
If you did not request this email, you can safely ignore it.
</p>
</td></tr>
</table>
</td></tr>
</table>
</body>
</html>

View File

@@ -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.

View File

@@ -0,0 +1,3 @@
{% extends '_types/root/_index.html' %}
{% block meta %}{% endblock %}
{% block content %}{% endblock %}

View File

@@ -0,0 +1,52 @@
{% import 'macros/links.html' as links %}
{% macro header_row(oob=False) %}
{% call links.menu_row(id='social-row', oob=oob) %}
<div class="w-full flex flex-row items-center gap-2 flex-wrap">
{% if actor %}
<nav class="flex gap-3 text-sm items-center flex-wrap">
<a href="{{ url_for('social.home_timeline') }}"
class="px-2 py-1 rounded hover:bg-stone-200 {% if request.path == url_for('social.home_timeline') %}font-bold{% endif %}">
Timeline
</a>
<a href="{{ url_for('social.public_timeline') }}"
class="px-2 py-1 rounded hover:bg-stone-200 {% if request.path == url_for('social.public_timeline') %}font-bold{% endif %}">
Public
</a>
<a href="{{ url_for('social.compose_form') }}"
class="px-2 py-1 rounded hover:bg-stone-200 {% if request.path == url_for('social.compose_form') %}font-bold{% endif %}">
Compose
</a>
<a href="{{ url_for('social.following_list') }}"
class="px-2 py-1 rounded hover:bg-stone-200 {% if request.path == url_for('social.following_list') %}font-bold{% endif %}">
Following
</a>
<a href="{{ url_for('social.followers_list') }}"
class="px-2 py-1 rounded hover:bg-stone-200 {% if request.path == url_for('social.followers_list') %}font-bold{% endif %}">
Followers
</a>
<a href="{{ url_for('social.search') }}"
class="px-2 py-1 rounded hover:bg-stone-200 {% if request.path == url_for('social.search') %}font-bold{% endif %}">
Search
</a>
<a href="{{ url_for('social.notifications') }}"
class="px-2 py-1 rounded hover:bg-stone-200 relative {% if request.path == url_for('social.notifications') %}font-bold{% endif %}">
Notifications
<span hx-get="{{ url_for('social.notification_count') }}" hx-trigger="load, every 30s" hx-swap="innerHTML"
class="absolute -top-2 -right-3 text-xs bg-red-500 text-white rounded-full px-1 empty:hidden"></span>
</a>
<a href="{{ url_for('activitypub.actor_profile', username=actor.preferred_username) }}"
class="px-2 py-1 rounded hover:bg-stone-200">
@{{ actor.preferred_username }}
</a>
</nav>
{% else %}
<nav class="flex gap-3 text-sm items-center">
<a href="{{ url_for('identity.choose_username_form') }}"
class="px-2 py-1 rounded hover:bg-stone-200 font-bold">
Choose username
</a>
</nav>
{% endif %}
</div>
{% endcall %}
{% endmacro %}

View File

@@ -0,0 +1,10 @@
{% extends '_types/root/_index.html' %}
{% block meta %}{% endblock %}
{% block root_header_child %}
{% from '_types/root/_n/macros.html' import index_row with context %}
{% call index_row('social-header-child', '_types/social/header/_header.html') %}
{% endcall %}
{% endblock %}
{% block content %}
{% block social_content %}{% endblock %}
{% endblock %}

View File

@@ -0,0 +1,19 @@
{% extends "_types/root/_index.html" %}
{% block meta %}{% endblock %}
{% block title %}Check your email — Rose Ash{% endblock %}
{% block content %}
<div class="py-8 max-w-md mx-auto text-center">
<h1 class="text-2xl font-bold mb-4">Check your email</h1>
<p class="text-stone-600 mb-2">
We sent a sign-in link to <strong>{{ email }}</strong>.
</p>
<p class="text-stone-500 text-sm">
Click the link in the email to sign in. The link expires in 15 minutes.
</p>
{% if email_error %}
<div class="bg-yellow-50 border border-yellow-200 text-yellow-700 p-3 rounded mt-4">
{{ email_error }}
</div>
{% endif %}
</div>
{% endblock %}

View File

@@ -0,0 +1,36 @@
{% extends "_types/root/_index.html" %}
{% block meta %}{% endblock %}
{% block title %}Login — Rose Ash{% endblock %}
{% block content %}
<div class="py-8 max-w-md mx-auto">
<h1 class="text-2xl font-bold mb-6">Sign in</h1>
{% if error %}
<div class="bg-red-50 border border-red-200 text-red-700 p-3 rounded mb-4">
{{ error }}
</div>
{% endif %}
<form method="post" action="{{ url_for('auth.start_login') }}" class="space-y-4">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div>
<label for="email" class="block text-sm font-medium mb-1">Email address</label>
<input
type="email"
name="email"
id="email"
value="{{ email | default('') }}"
required
autofocus
class="w-full border border-stone-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-stone-500"
>
</div>
<button
type="submit"
class="w-full bg-stone-800 text-white py-2 px-4 rounded hover:bg-stone-700 transition"
>
Send magic link
</button>
</form>
</div>
{% endblock %}

View File

@@ -0,0 +1,63 @@
{% for a in actors %}
<article class="bg-white rounded-lg shadow-sm border border-stone-200 p-4 mb-3 flex items-center gap-4"
id="actor-{{ a.actor_url | replace('/', '_') | replace(':', '_') }}">
{% if a.icon_url %}
<img src="{{ a.icon_url }}" alt="" class="w-12 h-12 rounded-full">
{% else %}
<div class="w-12 h-12 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold">
{{ (a.display_name or a.preferred_username)[0] | upper }}
</div>
{% endif %}
<div class="flex-1 min-w-0">
{% if list_type == "following" and a.id %}
<a href="{{ url_for('social.actor_timeline', id=a.id) }}" class="font-semibold text-stone-900 hover:underline">
{{ a.display_name or a.preferred_username }}
</a>
{% else %}
<a href="https://{{ a.domain }}/@{{ a.preferred_username }}" target="_blank" rel="noopener" class="font-semibold text-stone-900 hover:underline">
{{ a.display_name or a.preferred_username }}
</a>
{% endif %}
<div class="text-sm text-stone-500">@{{ a.preferred_username }}@{{ a.domain }}</div>
{% if a.summary %}
<div class="text-sm text-stone-600 mt-1 truncate">{{ a.summary | striptags }}</div>
{% endif %}
</div>
{% if actor %}
<div class="flex-shrink-0">
{% if list_type == "following" or a.actor_url in (followed_urls or []) %}
<form method="post" action="{{ url_for('social.unfollow') }}"
hx-post="{{ url_for('social.unfollow') }}"
hx-target="closest article"
hx-swap="outerHTML">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="actor_url" value="{{ a.actor_url }}">
<button type="submit" class="text-sm border border-stone-300 rounded px-3 py-1 hover:bg-stone-100">
Unfollow
</button>
</form>
{% else %}
<form method="post" action="{{ url_for('social.follow') }}"
hx-post="{{ url_for('social.follow') }}"
hx-target="closest article"
hx-swap="outerHTML">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="actor_url" value="{{ a.actor_url }}">
<button type="submit" class="text-sm bg-stone-800 text-white rounded px-3 py-1 hover:bg-stone-700">
Follow Back
</button>
</form>
{% endif %}
</div>
{% endif %}
</article>
{% endfor %}
{% if actors | length >= 20 %}
<div hx-get="{{ url_for('social.' ~ list_type ~ '_list_page', page=page + 1) }}"
hx-trigger="revealed"
hx-swap="outerHTML">
</div>
{% endif %}

View File

@@ -0,0 +1,61 @@
{% set oid = item.object_id if item is defined and item.object_id is defined else item_object_id | default('') %}
{% set ainbox = item.author_inbox if item is defined and item.author_inbox is defined else item_author_inbox | default('') %}
{% set lcount = item.like_count if item is defined and item.like_count is defined else like_count | default(0) %}
{% set bcount = item.boost_count if item is defined and item.boost_count is defined else boost_count | default(0) %}
{% set liked = item.liked_by_me if item is defined and item.liked_by_me is defined else liked_by_me | default(false) %}
{% set boosted = item.boosted_by_me if item is defined and item.boosted_by_me is defined else boosted_by_me | default(false) %}
<div class="flex items-center gap-4 mt-3 text-sm text-stone-500">
{% if liked %}
<form hx-post="{{ url_for('social.unlike') }}"
hx-target="#interactions-{{ oid | replace('/', '_') | replace(':', '_') }}"
hx-swap="innerHTML">
<input type="hidden" name="object_id" value="{{ oid }}">
<input type="hidden" name="author_inbox" value="{{ ainbox }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="flex items-center gap-1 text-red-500 hover:text-red-600">
<span>&#x2665;</span> {{ lcount }}
</button>
</form>
{% else %}
<form hx-post="{{ url_for('social.like') }}"
hx-target="#interactions-{{ oid | replace('/', '_') | replace(':', '_') }}"
hx-swap="innerHTML">
<input type="hidden" name="object_id" value="{{ oid }}">
<input type="hidden" name="author_inbox" value="{{ ainbox }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="flex items-center gap-1 hover:text-red-500">
<span>&#x2661;</span> {{ lcount }}
</button>
</form>
{% endif %}
{% if boosted %}
<form hx-post="{{ url_for('social.unboost') }}"
hx-target="#interactions-{{ oid | replace('/', '_') | replace(':', '_') }}"
hx-swap="innerHTML">
<input type="hidden" name="object_id" value="{{ oid }}">
<input type="hidden" name="author_inbox" value="{{ ainbox }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="flex items-center gap-1 text-green-600 hover:text-green-700">
<span>&#x21BB;</span> {{ bcount }}
</button>
</form>
{% else %}
<form hx-post="{{ url_for('social.boost') }}"
hx-target="#interactions-{{ oid | replace('/', '_') | replace(':', '_') }}"
hx-swap="innerHTML">
<input type="hidden" name="object_id" value="{{ oid }}">
<input type="hidden" name="author_inbox" value="{{ ainbox }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="flex items-center gap-1 hover:text-green-600">
<span>&#x21BB;</span> {{ bcount }}
</button>
</form>
{% endif %}
{% if oid %}
<a href="{{ url_for('social.compose_form', reply_to=oid) }}"
class="hover:text-stone-700">Reply</a>
{% endif %}
</div>

View File

@@ -0,0 +1,42 @@
<div class="bg-white rounded-lg shadow-sm border border-stone-200 p-4 {{ 'border-l-4 border-l-stone-400' if not notif.read }}">
<div class="flex items-start gap-3">
{% if notif.from_actor_icon %}
<img src="{{ notif.from_actor_icon }}" alt="" class="w-8 h-8 rounded-full">
{% else %}
<div class="w-8 h-8 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold text-xs">
{{ notif.from_actor_name[0] | upper if notif.from_actor_name else '?' }}
</div>
{% endif %}
<div class="flex-1">
<div class="text-sm">
<span class="font-semibold">{{ notif.from_actor_name }}</span>
<span class="text-stone-500">
@{{ notif.from_actor_username }}{% if notif.from_actor_domain %}@{{ notif.from_actor_domain }}{% endif %}
</span>
{% if notif.notification_type == "follow" %}
<span class="text-stone-600">followed you</span>
{% elif notif.notification_type == "like" %}
<span class="text-stone-600">liked your post</span>
{% elif notif.notification_type == "boost" %}
<span class="text-stone-600">boosted your post</span>
{% elif notif.notification_type == "mention" %}
<span class="text-stone-600">mentioned you</span>
{% elif notif.notification_type == "reply" %}
<span class="text-stone-600">replied to your post</span>
{% endif %}
</div>
{% if notif.target_content_preview %}
<div class="text-sm text-stone-500 mt-1 truncate">
{{ notif.target_content_preview }}
</div>
{% endif %}
<div class="text-xs text-stone-400 mt-1">
{{ notif.created_at.strftime('%b %d, %H:%M') }}
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,52 @@
<article class="bg-white rounded-lg shadow-sm border border-stone-200 p-4 mb-4">
{% if item.boosted_by %}
<div class="text-sm text-stone-500 mb-2">
Boosted by {{ item.boosted_by }}
</div>
{% endif %}
<div class="flex items-start gap-3">
{% if item.actor_icon %}
<img src="{{ item.actor_icon }}" alt="" class="w-10 h-10 rounded-full">
{% else %}
<div class="w-10 h-10 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold text-sm">
{{ item.actor_name[0] | upper if item.actor_name else '?' }}
</div>
{% endif %}
<div class="flex-1 min-w-0">
<div class="flex items-baseline gap-2">
<span class="font-semibold text-stone-900">{{ item.actor_name }}</span>
<span class="text-sm text-stone-500">
@{{ item.actor_username }}{% if item.actor_domain %}@{{ item.actor_domain }}{% endif %}
</span>
<span class="text-sm text-stone-400 ml-auto">
{% if item.published %}
{{ item.published.strftime('%b %d, %H:%M') }}
{% endif %}
</span>
</div>
{% if item.summary %}
<details class="mt-2">
<summary class="text-stone-500 cursor-pointer">CW: {{ item.summary }}</summary>
<div class="mt-2 prose prose-sm prose-stone max-w-none">{{ item.content | safe }}</div>
</details>
{% else %}
<div class="mt-2 prose prose-sm prose-stone max-w-none">{{ item.content | safe }}</div>
{% endif %}
{% if item.url and item.post_type == "remote" %}
<a href="{{ item.url }}" target="_blank" rel="noopener" class="text-sm text-stone-400 hover:underline mt-1 inline-block">
original
</a>
{% endif %}
{% if actor %}
<div id="interactions-{{ item.object_id | replace('/', '_') | replace(':', '_') }}">
{% include "federation/_interaction_buttons.html" with context %}
</div>
{% endif %}
</div>
</div>
</article>

View File

@@ -0,0 +1,61 @@
{% for a in actors %}
<article class="bg-white rounded-lg shadow-sm border border-stone-200 p-4 mb-3 flex items-center gap-4"
id="actor-{{ a.actor_url | replace('/', '_') | replace(':', '_') }}">
{% if a.icon_url %}
<img src="{{ a.icon_url }}" alt="" class="w-12 h-12 rounded-full">
{% else %}
<div class="w-12 h-12 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold">
{{ (a.display_name or a.preferred_username)[0] | upper }}
</div>
{% endif %}
<div class="flex-1 min-w-0">
{% if a.id %}
<a href="{{ url_for('social.actor_timeline', id=a.id) }}" class="font-semibold text-stone-900 hover:underline">
{{ a.display_name or a.preferred_username }}
</a>
{% else %}
<span class="font-semibold text-stone-900">{{ a.display_name or a.preferred_username }}</span>
{% endif %}
<div class="text-sm text-stone-500">@{{ a.preferred_username }}@{{ a.domain }}</div>
{% if a.summary %}
<div class="text-sm text-stone-600 mt-1 truncate">{{ a.summary | striptags }}</div>
{% endif %}
</div>
{% if actor %}
<div class="flex-shrink-0">
{% if a.actor_url in (followed_urls or []) %}
<form method="post" action="{{ url_for('social.unfollow') }}"
hx-post="{{ url_for('social.unfollow') }}"
hx-target="closest article"
hx-swap="outerHTML">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="actor_url" value="{{ a.actor_url }}">
<button type="submit" class="text-sm border border-stone-300 rounded px-3 py-1 hover:bg-stone-100">
Unfollow
</button>
</form>
{% else %}
<form method="post" action="{{ url_for('social.follow') }}"
hx-post="{{ url_for('social.follow') }}"
hx-target="closest article"
hx-swap="outerHTML">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="actor_url" value="{{ a.actor_url }}">
<button type="submit" class="text-sm bg-stone-800 text-white rounded px-3 py-1 hover:bg-stone-700">
Follow
</button>
</form>
{% endif %}
</div>
{% endif %}
</article>
{% endfor %}
{% if actors | length >= 20 %}
<div hx-get="{{ url_for('social.search_page', q=query, page=page + 1) }}"
hx-trigger="revealed"
hx-swap="outerHTML">
</div>
{% endif %}

View File

@@ -0,0 +1,18 @@
{% for item in items %}
{% include "federation/_post_card.html" %}
{% endfor %}
{% if items %}
{% set last = items[-1] %}
{% if timeline_type == "actor" %}
<div hx-get="{{ url_for('social.actor_timeline_page', id=actor_id, before=last.published.isoformat()) }}"
hx-trigger="revealed"
hx-swap="outerHTML">
</div>
{% else %}
<div hx-get="{{ url_for('social.' ~ timeline_type ~ '_timeline_page', before=last.published.isoformat()) }}"
hx-trigger="revealed"
hx-swap="outerHTML">
</div>
{% endif %}
{% endif %}

View File

@@ -0,0 +1,27 @@
{% extends "_types/social/index.html" %}
{% block title %}Account — Rose Ash{% endblock %}
{% block social_content %}
<div class="py-8">
<h1 class="text-2xl font-bold mb-4">Account</h1>
<div class="bg-white rounded-lg shadow p-6 mb-6">
<p><strong>Email:</strong> {{ g.user.email }}</p>
{% if actor %}
<p class="mt-2"><strong>Username:</strong> @{{ actor.preferred_username }}</p>
<p class="mt-1">
<a href="{{ url_for('activitypub.actor_profile', username=actor.preferred_username) }}"
class="text-blue-600 hover:underline">
View profile
</a>
</p>
{% else %}
<p class="mt-4">
<a href="{{ url_for('identity.choose_username_form') }}"
class="bg-stone-800 text-white py-2 px-4 rounded hover:bg-stone-700 transition">
Choose a username to start publishing
</a>
</p>
{% endif %}
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,45 @@
<div class="bg-white rounded-lg shadow-sm border border-stone-200 p-4">
<div class="flex items-start gap-4">
{% if result.icon_url %}
<img src="{{ result.icon_url }}" alt="" class="w-16 h-16 rounded-full">
{% else %}
<div class="w-16 h-16 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold text-xl">
{{ result.preferred_username[0] | upper }}
</div>
{% endif %}
<div class="flex-1">
<div class="flex items-baseline gap-2">
<span class="font-bold text-lg">{{ result.display_name or result.preferred_username }}</span>
<span class="text-stone-500">@{{ result.preferred_username }}@{{ result.domain }}</span>
</div>
{% if result.summary %}
<div class="text-sm text-stone-600 mt-1 prose prose-sm prose-stone max-w-none">
{{ result.summary | safe }}
</div>
{% endif %}
{% if actor %}
<div class="mt-3 flex gap-2">
<form method="post" action="{{ url_for('social.follow') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="actor_url" value="{{ result.actor_url }}">
<button type="submit"
class="bg-stone-800 text-white px-4 py-1.5 rounded text-sm hover:bg-stone-700">
Follow
</button>
</form>
<form method="post" action="{{ url_for('social.unfollow') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="actor_url" value="{{ result.actor_url }}">
<button type="submit"
class="border border-stone-300 text-stone-700 px-4 py-1.5 rounded text-sm hover:bg-stone-100">
Unfollow
</button>
</form>
</div>
{% endif %}
</div>
</div>
</div>

View File

@@ -0,0 +1,53 @@
{% extends "_types/social/index.html" %}
{% block title %}{{ remote_actor.display_name or remote_actor.preferred_username }} — Rose Ash{% endblock %}
{% block social_content %}
<div class="bg-white rounded-lg shadow-sm border border-stone-200 p-6 mb-6">
<div class="flex items-center gap-4">
{% if remote_actor.icon_url %}
<img src="{{ remote_actor.icon_url }}" alt="" class="w-16 h-16 rounded-full">
{% else %}
<div class="w-16 h-16 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold text-xl">
{{ (remote_actor.display_name or remote_actor.preferred_username)[0] | upper }}
</div>
{% endif %}
<div class="flex-1">
<h1 class="text-xl font-bold">{{ remote_actor.display_name or remote_actor.preferred_username }}</h1>
<div class="text-stone-500">@{{ remote_actor.preferred_username }}@{{ remote_actor.domain }}</div>
{% if remote_actor.summary %}
<div class="text-sm text-stone-600 mt-2">{{ remote_actor.summary | safe }}</div>
{% endif %}
</div>
{% if actor %}
<div class="flex-shrink-0">
{% if is_following %}
<form method="post" action="{{ url_for('social.unfollow') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="actor_url" value="{{ remote_actor.actor_url }}">
<button type="submit" class="border border-stone-300 rounded px-4 py-2 hover:bg-stone-100">
Unfollow
</button>
</form>
{% else %}
<form method="post" action="{{ url_for('social.follow') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="actor_url" value="{{ remote_actor.actor_url }}">
<button type="submit" class="bg-stone-800 text-white rounded px-4 py-2 hover:bg-stone-700">
Follow
</button>
</form>
{% endif %}
</div>
{% endif %}
</div>
</div>
<div id="timeline">
{% set timeline_type = "actor" %}
{% set actor_id = remote_actor.id %}
{% include "federation/_timeline_items.html" %}
</div>
{% endblock %}

View File

@@ -0,0 +1,54 @@
{% extends "_types/social/index.html" %}
{% block title %}Choose Username — Rose Ash{% endblock %}
{% block social_content %}
<div class="py-8 max-w-md mx-auto">
<h1 class="text-2xl font-bold mb-2">Choose your username</h1>
<p class="text-stone-600 mb-6">
This will be your identity on the fediverse:
<strong>@username@{{ config.get('ap_domain', 'rose-ash.com') }}</strong>
</p>
{% if error %}
<div class="bg-red-50 border border-red-200 text-red-700 p-3 rounded mb-4">
{{ error }}
</div>
{% endif %}
<form method="post" class="space-y-4">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div>
<label for="username" class="block text-sm font-medium mb-1">Username</label>
<div class="flex items-center">
<span class="text-stone-400 mr-1">@</span>
<input
type="text"
name="username"
id="username"
value="{{ username | default('') }}"
pattern="[a-z][a-z0-9_]{2,31}"
minlength="3"
maxlength="32"
required
autocomplete="off"
class="flex-1 border border-stone-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-stone-500"
hx-get="{{ url_for('identity.check_username') }}"
hx-trigger="keyup changed delay:300ms"
hx-target="#username-status"
hx-include="[name='username']"
>
</div>
<div id="username-status" class="text-sm mt-1"></div>
<p class="text-xs text-stone-400 mt-1">
3-32 characters. Lowercase letters, numbers, underscores. Must start with a letter.
</p>
</div>
<button
type="submit"
class="w-full bg-stone-800 text-white py-2 px-4 rounded hover:bg-stone-700 transition"
>
Claim username
</button>
</form>
</div>
{% endblock %}

View File

@@ -0,0 +1,34 @@
{% extends "_types/social/index.html" %}
{% block title %}Compose — Rose Ash{% endblock %}
{% block social_content %}
<h1 class="text-2xl font-bold mb-6">Compose</h1>
<form method="post" action="{{ url_for('social.compose_submit') }}" class="space-y-4">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
{% if reply_to %}
<input type="hidden" name="in_reply_to" value="{{ reply_to }}">
<div class="text-sm text-stone-500">
Replying to <span class="font-mono">{{ reply_to }}</span>
</div>
{% endif %}
<textarea name="content" rows="6" maxlength="5000" required
class="w-full border border-stone-300 rounded-lg p-3 focus:outline-none focus:ring-2 focus:ring-stone-500"
placeholder="What's on your mind?"></textarea>
<div class="flex items-center justify-between">
<select name="visibility" class="border border-stone-300 rounded px-3 py-1.5 text-sm">
<option value="public">Public</option>
<option value="unlisted">Unlisted</option>
<option value="followers">Followers only</option>
</select>
<button type="submit"
class="bg-stone-800 text-white px-6 py-2 rounded hover:bg-stone-700">
Publish
</button>
</div>
</form>
{% endblock %}

View File

@@ -0,0 +1,12 @@
{% extends "_types/social/index.html" %}
{% block title %}Followers — Rose Ash{% endblock %}
{% block social_content %}
<h1 class="text-2xl font-bold mb-6">Followers <span class="text-stone-400 font-normal">({{ total }})</span></h1>
<div id="actor-list">
{% set list_type = "followers" %}
{% include "federation/_actor_list_items.html" %}
</div>
{% endblock %}

View File

@@ -0,0 +1,13 @@
{% extends "_types/social/index.html" %}
{% block title %}Following — Rose Ash{% endblock %}
{% block social_content %}
<h1 class="text-2xl font-bold mb-6">Following <span class="text-stone-400 font-normal">({{ total }})</span></h1>
<div id="actor-list">
{% set list_type = "following" %}
{% set followed_urls = [] %}
{% include "federation/_actor_list_items.html" %}
</div>
{% endblock %}

View File

@@ -0,0 +1,17 @@
{% extends "_types/social/index.html" %}
{% block title %}Notifications — Rose Ash{% endblock %}
{% block social_content %}
<h1 class="text-2xl font-bold mb-6">Notifications</h1>
{% if not notifications %}
<p class="text-stone-500">No notifications yet.</p>
{% endif %}
<div class="space-y-2">
{% for notif in notifications %}
{% include "federation/_notification.html" %}
{% endfor %}
</div>
{% endblock %}

View File

@@ -0,0 +1,32 @@
{% extends "_types/social/index.html" %}
{% block title %}@{{ actor.preferred_username }} — Rose Ash{% endblock %}
{% block social_content %}
<div class="py-8">
<div class="bg-white rounded-lg shadow p-6 mb-6">
<h1 class="text-2xl font-bold">{{ actor.display_name or actor.preferred_username }}</h1>
<p class="text-stone-500">@{{ actor.preferred_username }}@{{ config.get('ap_domain', 'rose-ash.com') }}</p>
{% if actor.summary %}
<p class="mt-2">{{ actor.summary }}</p>
{% endif %}
</div>
<h2 class="text-xl font-bold mb-4">Activities ({{ total }})</h2>
{% if activities %}
<div class="space-y-4">
{% for a in activities %}
<div class="bg-white rounded-lg shadow p-4">
<div class="flex justify-between items-start">
<span class="font-medium">{{ a.activity_type }}</span>
<span class="text-sm text-stone-400">{{ a.published.strftime('%Y-%m-%d %H:%M') if a.published }}</span>
</div>
{% if a.object_type %}
<span class="text-sm text-stone-500">{{ a.object_type }}</span>
{% endif %}
</div>
{% endfor %}
</div>
{% else %}
<p class="text-stone-500">No activities yet.</p>
{% endif %}
</div>
{% endblock %}

View File

@@ -0,0 +1,32 @@
{% extends "_types/social/index.html" %}
{% block title %}Search — Rose Ash{% endblock %}
{% block social_content %}
<h1 class="text-2xl font-bold mb-6">Search</h1>
<form method="get" action="{{ url_for('social.search') }}" class="mb-6"
hx-get="{{ url_for('social.search_page') }}"
hx-target="#search-results"
hx-push-url="{{ url_for('social.search') }}">
<div class="flex gap-2">
<input type="text" name="q" value="{{ query }}"
class="flex-1 border border-stone-300 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-stone-500"
placeholder="Search users or @user@instance.tld">
<button type="submit"
class="bg-stone-800 text-white px-6 py-2 rounded hover:bg-stone-700">
Search
</button>
</div>
</form>
{% if query and total %}
<p class="text-sm text-stone-500 mb-4">{{ total }} result{{ 's' if total != 1 }} for <strong>{{ query }}</strong></p>
{% elif query %}
<p class="text-stone-500 mb-4">No results found for <strong>{{ query }}</strong></p>
{% endif %}
<div id="search-results">
{% include "federation/_search_results.html" %}
</div>
{% endblock %}

View File

@@ -0,0 +1,19 @@
{% extends "_types/social/index.html" %}
{% block title %}{{ "Home" if timeline_type == "home" else "Public" }} Timeline — Rose Ash{% endblock %}
{% block social_content %}
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold">{{ "Home" if timeline_type == "home" else "Public" }} Timeline</h1>
{% if actor %}
<a href="{{ url_for('social.compose_form') }}"
class="bg-stone-800 text-white px-4 py-2 rounded hover:bg-stone-700">
Compose
</a>
{% endif %}
</div>
<div id="timeline">
{% include "federation/_timeline_items.html" %}
</div>
{% endblock %}