Monorepo: consolidate 7 repos into one
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m5s

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

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