diff --git a/shared/browser/templates/social/index.html b/shared/browser/templates/social/index.html new file mode 100644 index 0000000..4d32685 --- /dev/null +++ b/shared/browser/templates/social/index.html @@ -0,0 +1,33 @@ +{% extends "_types/social_lite/index.html" %} + +{% block title %}Social — Rose Ash{% endblock %} + +{% block social_content %} +

Social

+ +{% if actor %} +
+ +
Search
+
Find and follow accounts on the fediverse
+
+ +
Following
+
Accounts you follow
+
+ +
Followers
+
Accounts following you here
+
+ +
Hub
+
Full social experience — timeline, compose, notifications
+
+
+{% else %} +

+ Search for accounts on the fediverse, or visit the + Hub to get started. +

+{% endif %} +{% endblock %} diff --git a/shared/events/handlers/__init__.py b/shared/events/handlers/__init__.py index 9f6a845..a9ccaf8 100644 --- a/shared/events/handlers/__init__.py +++ b/shared/events/handlers/__init__.py @@ -7,4 +7,5 @@ def register_shared_handlers(): import shared.events.handlers.login_handlers # noqa: F401 import shared.events.handlers.order_handlers # noqa: F401 import shared.events.handlers.ap_delivery_handler # noqa: F401 + import shared.events.handlers.ap_follow_handler # noqa: F401 import shared.events.handlers.external_delivery_handler # noqa: F401 diff --git a/shared/events/handlers/ap_delivery_handler.py b/shared/events/handlers/ap_delivery_handler.py index 7a175bf..5fbaf70 100644 --- a/shared/events/handlers/ap_delivery_handler.py +++ b/shared/events/handlers/ap_delivery_handler.py @@ -146,6 +146,9 @@ async def on_any_activity(activity: APActivity, session: AsyncSession) -> None: return if not services.has("federation"): return + # Follow activities are delivered by ap_follow_handler, not broadcast + if activity.activity_type == "Follow": + return # Load actor with private key actor = ( diff --git a/shared/events/handlers/ap_follow_handler.py b/shared/events/handlers/ap_follow_handler.py new file mode 100644 index 0000000..35b44d0 --- /dev/null +++ b/shared/events/handlers/ap_follow_handler.py @@ -0,0 +1,96 @@ +"""Deliver outbound Follow activities to remote inboxes. + +When send_follow() emits a Follow activity via emit_activity(), this +handler picks it up and POSTs the signed Follow to the remote actor's +inbox. On failure the EventProcessor retries automatically. + +object_data layout: + { + "target_inbox": "https://remote.example/inbox", + "target_actor_url": "https://remote.example/users/alice", + "following_id": 42, # APFollowing row id + } +""" +from __future__ import annotations + +import json +import logging +import os +from urllib.parse import urlparse + +import httpx +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select + +from shared.events.bus import register_activity_handler +from shared.models.federation import ActorProfile, APActivity + +log = logging.getLogger(__name__) + +DELIVERY_TIMEOUT = 15 + + +async def on_follow_activity(activity: APActivity, session: AsyncSession) -> None: + """Deliver a Follow activity to the remote actor's inbox.""" + if activity.visibility != "public": + return + if activity.actor_profile_id is None: + return + + obj = activity.object_data or {} + target_inbox = obj.get("target_inbox") + target_actor_url = obj.get("target_actor_url") + if not target_inbox or not target_actor_url: + log.warning("Follow activity %s missing target_inbox or target_actor_url", activity.id) + return + + actor = ( + await session.execute( + select(ActorProfile).where(ActorProfile.id == activity.actor_profile_id) + ) + ).scalar_one_or_none() + if not actor or not actor.private_key_pem: + log.warning("Actor not found or missing key for Follow activity %s", activity.id) + return + + # Build the Follow activity JSON + from shared.infrastructure.activitypub import _ap_domain + origin_app = activity.origin_app or "federation" + domain = _ap_domain(origin_app) + actor_url = f"https://{domain}/users/{actor.preferred_username}" + + follow_json = { + "@context": "https://www.w3.org/ns/activitystreams", + "id": activity.activity_id, + "type": "Follow", + "actor": actor_url, + "object": target_actor_url, + } + + # Sign and deliver + from shared.utils.http_signatures import sign_request + + body_bytes = json.dumps(follow_json).encode() + parsed = urlparse(target_inbox) + headers = sign_request( + private_key_pem=actor.private_key_pem, + key_id=f"{actor_url}#main-key", + method="POST", + path=parsed.path, + host=parsed.netloc, + body=body_bytes, + ) + headers["Content-Type"] = "application/activity+json" + + async with httpx.AsyncClient(timeout=DELIVERY_TIMEOUT) as client: + resp = await client.post(target_inbox, content=body_bytes, headers=headers) + + if resp.status_code >= 300: + raise RuntimeError( + f"Follow delivery to {target_inbox} failed: {resp.status_code} {resp.text[:200]}" + ) + + log.info("Follow delivered to %s → %d", target_inbox, resp.status_code) + + +register_activity_handler("Follow", on_follow_activity) diff --git a/shared/infrastructure/ap_social.py b/shared/infrastructure/ap_social.py index 795c6e8..767934a 100644 --- a/shared/infrastructure/ap_social.py +++ b/shared/infrastructure/ap_social.py @@ -31,6 +31,16 @@ def create_ap_social_blueprint(app_name: str) -> Blueprint: abort(403, "You need to choose a federation username first") return actor + # -- Index ---------------------------------------------------------------- + + @bp.get("/") + async def index(): + actor = getattr(g, "_social_actor", None) + return await render_template( + "social/index.html", + actor=actor, + ) + # -- Search --------------------------------------------------------------- @bp.get("/search") diff --git a/shared/services/federation_impl.py b/shared/services/federation_impl.py index 46e7948..3ce2077 100644 --- a/shared/services/federation_impl.py +++ b/shared/services/federation_impl.py @@ -686,42 +686,21 @@ class SqlFederationService: session.add(follow) await session.flush() - # Send Follow activity - domain = _domain() - actor_url = f"https://{domain}/users/{local_username}" - follow_id = f"{actor_url}/activities/{uuid.uuid4()}" - - follow_activity = { - "@context": "https://www.w3.org/ns/activitystreams", - "id": follow_id, - "type": "Follow", - "actor": actor_url, - "object": remote_actor_url, - } - - import json - import httpx - from shared.utils.http_signatures import sign_request - from urllib.parse import urlparse - - body_bytes = json.dumps(follow_activity).encode() - parsed = urlparse(remote.inbox_url) - headers = sign_request( - private_key_pem=actor.private_key_pem, - key_id=f"{actor_url}#main-key", - method="POST", - path=parsed.path, - host=parsed.netloc, - body=body_bytes, + # Emit Follow activity — EventProcessor delivers with retries + from shared.events.bus import emit_activity + await emit_activity( + session, + activity_type="Follow", + actor_uri=f"https://{_domain()}/users/{local_username}", + object_type="Actor", + object_data={ + "target_inbox": remote.inbox_url, + "target_actor_url": remote_actor_url, + "following_id": follow.id, + }, + visibility="public", + actor_profile_id=actor.id, ) - headers["Content-Type"] = "application/activity+json" - - try: - async with httpx.AsyncClient(timeout=15) as client: - await client.post(remote.inbox_url, content=body_bytes, headers=headers) - except Exception: - import logging - logging.getLogger(__name__).exception("Failed to send Follow to %s", remote.inbox_url) async def get_following( self, session: AsyncSession, username: str,