Route outbound Follow through EventProcessor for retry
send_follow now emits a Follow activity via emit_activity() instead of inline HTTP POST. New ap_follow_handler delivers to the remote inbox; EventProcessor retries on failure. Wildcard delivery handler skips Follow type to avoid duplicate broadcast. Also add /social/ index page to per-app social blueprint. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
33
shared/browser/templates/social/index.html
Normal file
33
shared/browser/templates/social/index.html
Normal file
@@ -0,0 +1,33 @@
|
||||
{% extends "_types/social_lite/index.html" %}
|
||||
|
||||
{% block title %}Social — Rose Ash{% endblock %}
|
||||
|
||||
{% block social_content %}
|
||||
<h1 class="text-2xl font-bold mb-6">Social</h1>
|
||||
|
||||
{% if actor %}
|
||||
<div class="space-y-3">
|
||||
<a href="{{ url_for('ap_social.search') }}" class="block bg-white rounded-lg shadow-sm border border-stone-200 p-4 hover:bg-stone-50">
|
||||
<div class="font-semibold">Search</div>
|
||||
<div class="text-sm text-stone-500">Find and follow accounts on the fediverse</div>
|
||||
</a>
|
||||
<a href="{{ url_for('ap_social.following_list') }}" class="block bg-white rounded-lg shadow-sm border border-stone-200 p-4 hover:bg-stone-50">
|
||||
<div class="font-semibold">Following</div>
|
||||
<div class="text-sm text-stone-500">Accounts you follow</div>
|
||||
</a>
|
||||
<a href="{{ url_for('ap_social.followers_list') }}" class="block bg-white rounded-lg shadow-sm border border-stone-200 p-4 hover:bg-stone-50">
|
||||
<div class="font-semibold">Followers</div>
|
||||
<div class="text-sm text-stone-500">Accounts following you here</div>
|
||||
</a>
|
||||
<a href="{{ federation_url('/social/') }}" class="block bg-white rounded-lg shadow-sm border border-stone-200 p-4 hover:bg-stone-50">
|
||||
<div class="font-semibold">Hub</div>
|
||||
<div class="text-sm text-stone-500">Full social experience — timeline, compose, notifications</div>
|
||||
</a>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-stone-500">
|
||||
<a href="{{ url_for('ap_social.search') }}" class="underline">Search</a> for accounts on the fediverse, or visit the
|
||||
<a href="{{ federation_url('/social/') }}" class="underline">Hub</a> to get started.
|
||||
</p>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -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
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
96
shared/events/handlers/ap_follow_handler.py
Normal file
96
shared/events/handlers/ap_follow_handler.py
Normal file
@@ -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)
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user