Compare commits

...

15 Commits

Author SHA1 Message Date
giles
bccfff0c69 Add fediverse social tables, protocols, and implementations
6 new ORM models (remote actors, following, remote posts, local posts,
interactions, notifications), 20 new FederationService methods with
SQL implementations and stubs, WebFinger client, and Alembic migration.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 11:56:33 +00:00
giles
9a8b556c13 Fix duplicate AP posts + stable object IDs
- Stable object ID per source (Post#123 always gets the same id)
  instead of deriving from activity UUID
- Dedup Update activities (Ghost fires duplicate webhooks)
- Use setdefault for object id in delivery handler

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 10:14:40 +00:00
giles
a626dd849d Fix AP Delete: Tombstone id must match original Create object id
Mastodon ignored Delete activities because the Tombstone id was the
post URL, not the object id from the original Create activity. Now
looks up the existing Create activity and uses its object id.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 09:25:30 +00:00
giles
d0b1edea7a Add container_nav widget rendering to day and entry nav templates
Events app day view and entry detail nav now render registered
container_nav widgets (e.g. market links) alongside existing entries/posts.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 09:13:44 +00:00
giles
eec750a699 Fix AP object id: must be on actor's domain (Mastodon origin check)
Mastodon verifies the object id domain matches the actor domain.
Using the post URL (coop.rose-ash.com) as object id caused silent
rejection. Now always uses {activity_id}/object on federation domain.
Also adds to/cc on object for visibility determination.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 08:52:56 +00:00
giles
fd163b577f Inline federation publication + fix AP delivery
- Replace async federation_handlers with inline try_publish() at write sites
- Fix ap_delivery_handler: urlparse for signature path/host, @context array
  with security vocab, Delete/Tombstone object handling
- Fix federation_impl: @context array for IPFS, .limit(1) + upsert follower

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 08:24:29 +00:00
giles
3bde451ce9 Inline federation publication, remove async handlers
Federation activities are now created at write time via try_publish()
instead of relying on async event handlers. Fixes race condition where
multiple EventProcessors could consume post.published events in apps
that couldn't meaningfully process them.

AP delivery (federation.activity_created → inbox POST) stays async.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 07:54:14 +00:00
giles
798fe56165 Fix MultipleResultsFound crash in get_activity_for_source
- Use .scalars().first() + LIMIT 1 instead of scalar_one_or_none()
  which crashes when multiple activities exist for the same source
- Allow re-Create after Delete (re-publish after unpublish)
- Add missing on_post_unpublished handler to root shared copy
- Sync add_follower upsert fix to root shared

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 23:49:19 +00:00
giles
18410c4b16 Add unpublish (Delete) support + improve object IDs
- on_post_unpublished handler sends Delete/Tombstone activity
- Create/Update objects use post URL as id (for Delete reference)
- Delete objects use Tombstone type

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 23:26:56 +00:00
giles
a28add8640 Add WARNING-level logging to federation publish handler
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 23:13:00 +00:00
giles
68941b97f6 Fix sign_request call in AP delivery handler
Parse inbox URL into path+host instead of passing url= which doesn't exist.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 22:52:30 +00:00
giles
1d83a339b6 Upsert followers in add_follower to prevent IntegrityError
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 22:43:20 +00:00
giles
24432cd52a Page-aware labels in blog_new template
- "Post title"→"Page title", "Create Post"→"Create Page" when is_page

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 22:31:58 +00:00
giles
9a1a4996bc Use "Page" labels instead of "Post" when editing pages
- Edit: placeholder "Page title..." vs "Post title..."
- Settings: slug placeholder, featured checkbox, custom template
  all say "page" when is_page is true

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 22:13:50 +00:00
giles
1832c53980 Skip blog chrome (like, tags, authors, excerpt) for pages
Pages are container/landing pages, not blog posts. Hide the like
button, tags/authors bar, and excerpt when post.is_page is true.
Feature image and content still render for both.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 22:10:39 +00:00
18 changed files with 1920 additions and 198 deletions

View File

@@ -0,0 +1,138 @@
"""add fediverse social tables
Revision ID: l2j0g6h8i9
Revises: k1i9f5g7h8
Create Date: 2026-02-22
Creates:
- ap_remote_actors — cached profiles of remote actors
- ap_following — outbound follows (local → remote)
- ap_remote_posts — ingested posts from remote actors
- ap_local_posts — native posts composed in federation UI
- ap_interactions — likes and boosts
- ap_notifications — follow/like/boost/mention/reply notifications
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import JSONB
revision = "l2j0g6h8i9"
down_revision = "k1i9f5g7h8"
branch_labels = None
depends_on = None
def upgrade() -> None:
# -- ap_remote_actors --
op.create_table(
"ap_remote_actors",
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
sa.Column("actor_url", sa.String(512), unique=True, nullable=False),
sa.Column("inbox_url", sa.String(512), nullable=False),
sa.Column("shared_inbox_url", sa.String(512), nullable=True),
sa.Column("preferred_username", sa.String(255), nullable=False),
sa.Column("display_name", sa.String(255), nullable=True),
sa.Column("summary", sa.Text, nullable=True),
sa.Column("icon_url", sa.String(512), nullable=True),
sa.Column("public_key_pem", sa.Text, nullable=True),
sa.Column("domain", sa.String(255), nullable=False),
sa.Column("fetched_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
)
op.create_index("ix_ap_remote_actor_url", "ap_remote_actors", ["actor_url"], unique=True)
op.create_index("ix_ap_remote_actor_domain", "ap_remote_actors", ["domain"])
# -- ap_following --
op.create_table(
"ap_following",
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
sa.Column("actor_profile_id", sa.Integer, sa.ForeignKey("ap_actor_profiles.id", ondelete="CASCADE"), nullable=False),
sa.Column("remote_actor_id", sa.Integer, sa.ForeignKey("ap_remote_actors.id", ondelete="CASCADE"), nullable=False),
sa.Column("state", sa.String(20), nullable=False, server_default="pending"),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("accepted_at", sa.DateTime(timezone=True), nullable=True),
sa.UniqueConstraint("actor_profile_id", "remote_actor_id", name="uq_following"),
)
op.create_index("ix_ap_following_actor", "ap_following", ["actor_profile_id"])
op.create_index("ix_ap_following_remote", "ap_following", ["remote_actor_id"])
# -- ap_remote_posts --
op.create_table(
"ap_remote_posts",
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
sa.Column("remote_actor_id", sa.Integer, sa.ForeignKey("ap_remote_actors.id", ondelete="CASCADE"), nullable=False),
sa.Column("activity_id", sa.String(512), unique=True, nullable=False),
sa.Column("object_id", sa.String(512), unique=True, nullable=False),
sa.Column("object_type", sa.String(64), nullable=False, server_default="Note"),
sa.Column("content", sa.Text, nullable=True),
sa.Column("summary", sa.Text, nullable=True),
sa.Column("url", sa.String(512), nullable=True),
sa.Column("attachment_data", JSONB, nullable=True),
sa.Column("tag_data", JSONB, nullable=True),
sa.Column("in_reply_to", sa.String(512), nullable=True),
sa.Column("conversation", sa.String(512), nullable=True),
sa.Column("published", sa.DateTime(timezone=True), nullable=True),
sa.Column("fetched_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
)
op.create_index("ix_ap_remote_post_actor", "ap_remote_posts", ["remote_actor_id"])
op.create_index("ix_ap_remote_post_published", "ap_remote_posts", ["published"])
op.create_index("ix_ap_remote_post_object", "ap_remote_posts", ["object_id"], unique=True)
# -- ap_local_posts --
op.create_table(
"ap_local_posts",
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
sa.Column("actor_profile_id", sa.Integer, sa.ForeignKey("ap_actor_profiles.id", ondelete="CASCADE"), nullable=False),
sa.Column("content", sa.Text, nullable=False),
sa.Column("visibility", sa.String(20), nullable=False, server_default="public"),
sa.Column("in_reply_to", sa.String(512), nullable=True),
sa.Column("published", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
)
op.create_index("ix_ap_local_post_actor", "ap_local_posts", ["actor_profile_id"])
op.create_index("ix_ap_local_post_published", "ap_local_posts", ["published"])
# -- ap_interactions --
op.create_table(
"ap_interactions",
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
sa.Column("actor_profile_id", sa.Integer, sa.ForeignKey("ap_actor_profiles.id", ondelete="CASCADE"), nullable=True),
sa.Column("remote_actor_id", sa.Integer, sa.ForeignKey("ap_remote_actors.id", ondelete="CASCADE"), nullable=True),
sa.Column("post_type", sa.String(20), nullable=False),
sa.Column("post_id", sa.Integer, nullable=False),
sa.Column("interaction_type", sa.String(20), nullable=False),
sa.Column("activity_id", sa.String(512), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
)
op.create_index("ix_ap_interaction_post", "ap_interactions", ["post_type", "post_id"])
op.create_index("ix_ap_interaction_actor", "ap_interactions", ["actor_profile_id"])
op.create_index("ix_ap_interaction_remote", "ap_interactions", ["remote_actor_id"])
# -- ap_notifications --
op.create_table(
"ap_notifications",
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
sa.Column("actor_profile_id", sa.Integer, sa.ForeignKey("ap_actor_profiles.id", ondelete="CASCADE"), nullable=False),
sa.Column("notification_type", sa.String(20), nullable=False),
sa.Column("from_remote_actor_id", sa.Integer, sa.ForeignKey("ap_remote_actors.id", ondelete="SET NULL"), nullable=True),
sa.Column("from_actor_profile_id", sa.Integer, sa.ForeignKey("ap_actor_profiles.id", ondelete="SET NULL"), nullable=True),
sa.Column("target_activity_id", sa.Integer, sa.ForeignKey("ap_activities.id", ondelete="SET NULL"), nullable=True),
sa.Column("target_remote_post_id", sa.Integer, sa.ForeignKey("ap_remote_posts.id", ondelete="SET NULL"), nullable=True),
sa.Column("read", sa.Boolean, nullable=False, server_default="false"),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
)
op.create_index("ix_ap_notification_actor", "ap_notifications", ["actor_profile_id"])
op.create_index("ix_ap_notification_read", "ap_notifications", ["actor_profile_id", "read"])
op.create_index("ix_ap_notification_created", "ap_notifications", ["created_at"])
def downgrade() -> None:
op.drop_table("ap_notifications")
op.drop_table("ap_interactions")
op.drop_table("ap_local_posts")
op.drop_table("ap_remote_posts")
op.drop_table("ap_following")
op.drop_table("ap_remote_actors")

View File

@@ -70,7 +70,7 @@
type="text"
name="title"
value=""
placeholder="Post title..."
placeholder="{{ 'Page title...' if is_page else 'Post title...' }}"
class="w-full text-[36px] font-bold bg-transparent border-none outline-none
placeholder:text-stone-300 mb-[8px] leading-tight"
>
@@ -101,7 +101,7 @@
type="submit"
class="px-[20px] py-[6px] bg-stone-700 text-white text-[14px] rounded-[8px]
hover:bg-stone-800 transition-colors cursor-pointer"
>Create Post</button>
>{{ 'Create Page' if is_page else 'Create Post' }}</button>
</div>
</form>

View File

@@ -25,6 +25,15 @@
{% endcall %}
</div>
{# Container nav widgets (market links, etc.) #}
{% if container_nav_widgets %}
{% for wdata in container_nav_widgets %}
{% with ctx=wdata.ctx %}
{% include wdata.widget.template with context %}
{% endwith %}
{% endfor %}
{% endif %}
{# Admin link #}
{% if g.rights.admin %}
{% from 'macros/admin_nav.html' import admin_nav_item %}

View File

@@ -22,6 +22,14 @@
{% endcall %}
</div>
{% if container_nav_widgets %}
{% for wdata in container_nav_widgets %}
{% with ctx=wdata.ctx %}
{% include wdata.widget.template with context %}
{% endwith %}
{% endfor %}
{% endif %}
{# Admin link #}
{% if g.rights.admin %}

View File

@@ -1,17 +1,6 @@
{# Main panel fragment for HTMX navigation - post article content #}
{# Main panel fragment for HTMX navigation - post/page article content #}
<article class="relative">
{# ❤️ like button - always visible in top right of article #}
{% if g.user %}
<div class="absolute top-2 right-2 z-10 text-8xl md:text-6xl">
{% set slug = post.slug %}
{% set liked = post.is_liked or False %}
{% set like_url = url_for('blog.post.like_toggle', slug=slug)|host %}
{% set item_type = 'post' %}
{% include "_types/browse/like/button.html" %}
</div>
{% endif %}
{# Draft indicator + edit link #}
{# Draft indicator + edit link (shown for both posts and pages) #}
{% if post.status == "draft" %}
<div class="flex items-center justify-center gap-2 mb-3">
<span class="inline-block px-3 py-1 rounded-full text-sm font-semibold bg-amber-100 text-amber-800">Draft</span>
@@ -36,6 +25,18 @@
</div>
{% endif %}
{% if not post.is_page %}
{# ── Blog post chrome: like button, excerpt, tags/authors ── #}
{% if g.user %}
<div class="absolute top-2 right-2 z-10 text-8xl md:text-6xl">
{% set slug = post.slug %}
{% set liked = post.is_liked or False %}
{% set like_url = url_for('blog.post.like_toggle', slug=slug)|host %}
{% set item_type = 'post' %}
{% include "_types/browse/like/button.html" %}
</div>
{% endif %}
{% if post.custom_excerpt %}
<div class="w-full text-center italic text-3xl p-2">
{{post.custom_excerpt|safe}}
@@ -44,6 +45,8 @@
<div class="hidden md:block">
{% include '_types/blog/_card/at_bar.html' %}
</div>
{% endif %}
{% if post.feature_image %}
<div class="mb-3 flex justify-center">
<img

View File

@@ -77,7 +77,7 @@
type="text"
name="title"
value="{{ ghost_post.title if ghost_post else '' }}"
placeholder="Post title..."
placeholder="{{ 'Page title...' if post and post.is_page else 'Post title...' }}"
class="w-full text-[36px] font-bold bg-transparent border-none outline-none
placeholder:text-stone-300 mb-[8px] leading-tight"
>

View File

@@ -1,5 +1,6 @@
{# ── Post Settings Form ── #}
{# ── Post/Page Settings Form ── #}
{% set gp = ghost_post or {} %}
{% set _is_page = post.is_page if post else False %}
{% macro field_label(text, field_for=None) %}
<label {% if field_for %}for="{{ field_for }}"{% endif %}
@@ -68,7 +69,7 @@
{% call section('General', open=True) %}
<div>
{{ field_label('Slug', 'settings-slug') }}
{{ text_input('slug', gp.slug or '', 'post-slug') }}
{{ text_input('slug', gp.slug or '', 'page-slug' if _is_page else 'post-slug') }}
</div>
<div>
{{ field_label('Published at', 'settings-published_at') }}
@@ -83,7 +84,7 @@
>
</div>
<div>
{{ checkbox_input('featured', gp.featured, 'Featured post') }}
{{ checkbox_input('featured', gp.featured, 'Featured page' if _is_page else 'Featured post') }}
</div>
<div>
{{ field_label('Visibility', 'settings-visibility') }}
@@ -176,7 +177,7 @@
{% call section('Advanced') %}
<div>
{{ field_label('Custom template', 'settings-custom_template') }}
{{ text_input('custom_template', gp.custom_template or '', 'custom-post.hbs') }}
{{ text_input('custom_template', gp.custom_template or '', 'custom-page.hbs' if _is_page else 'custom-post.hbs') }}
</div>
{% endcall %}

View File

@@ -187,3 +187,68 @@ class APAnchorDTO:
ots_proof_cid: str | None = None
confirmed_at: datetime | None = None
bitcoin_txid: str | None = None
@dataclass(frozen=True, slots=True)
class RemoteActorDTO:
id: int
actor_url: str
inbox_url: str
preferred_username: str
domain: str
display_name: str | None = None
summary: str | None = None
icon_url: str | None = None
shared_inbox_url: str | None = None
public_key_pem: str | None = None
@dataclass(frozen=True, slots=True)
class RemotePostDTO:
id: int
remote_actor_id: int
object_id: str
content: str
summary: str | None = None
url: str | None = None
attachments: list[dict] = field(default_factory=list)
tags: list[dict] = field(default_factory=list)
published: datetime | None = None
actor: RemoteActorDTO | None = None
@dataclass(frozen=True, slots=True)
class TimelineItemDTO:
id: str # composite key for cursor pagination
post_type: str # "local" | "remote" | "boost"
content: str # HTML
published: datetime
actor_name: str
actor_username: str
object_id: str | None = None
summary: str | None = None
url: str | None = None
attachments: list[dict] = field(default_factory=list)
tags: list[dict] = field(default_factory=list)
actor_domain: str | None = None # None = local
actor_icon: str | None = None
actor_url: str | None = None
boosted_by: str | None = None
like_count: int = 0
boost_count: int = 0
liked_by_me: bool = False
boosted_by_me: bool = False
author_inbox: str | None = None
@dataclass(frozen=True, slots=True)
class NotificationDTO:
id: int
notification_type: str # follow/like/boost/mention/reply
from_actor_name: str
from_actor_username: str
created_at: datetime
read: bool
from_actor_domain: str | None = None
from_actor_icon: str | None = None
target_content_preview: str | None = None

View File

@@ -22,6 +22,10 @@ from .dtos import (
ActorProfileDTO,
APActivityDTO,
APFollowerDTO,
RemoteActorDTO,
RemotePostDTO,
TimelineItemDTO,
NotificationDTO,
)
@@ -227,5 +231,103 @@ class FederationService(Protocol):
self, session: AsyncSession, username: str, follower_acct: str,
) -> bool: ...
# -- Remote actors --------------------------------------------------------
async def get_or_fetch_remote_actor(
self, session: AsyncSession, actor_url: str,
) -> RemoteActorDTO | None: ...
async def search_remote_actor(
self, session: AsyncSession, acct: str,
) -> RemoteActorDTO | None: ...
# -- Following (outbound) -------------------------------------------------
async def send_follow(
self, session: AsyncSession, local_username: str, remote_actor_url: str,
) -> None: ...
async def get_following(
self, session: AsyncSession, username: str,
page: int = 1, per_page: int = 20,
) -> tuple[list[RemoteActorDTO], int]: ...
async def accept_follow_response(
self, session: AsyncSession, local_username: str, remote_actor_url: str,
) -> None: ...
async def unfollow(
self, session: AsyncSession, local_username: str, remote_actor_url: str,
) -> None: ...
# -- Remote posts ---------------------------------------------------------
async def ingest_remote_post(
self, session: AsyncSession, remote_actor_id: int,
activity_json: dict, object_json: dict,
) -> None: ...
async def delete_remote_post(
self, session: AsyncSession, object_id: str,
) -> None: ...
async def get_remote_post(
self, session: AsyncSession, object_id: str,
) -> RemotePostDTO | None: ...
# -- Timelines ------------------------------------------------------------
async def get_home_timeline(
self, session: AsyncSession, actor_profile_id: int,
before: datetime | None = None, limit: int = 20,
) -> list[TimelineItemDTO]: ...
async def get_public_timeline(
self, session: AsyncSession,
before: datetime | None = None, limit: int = 20,
) -> list[TimelineItemDTO]: ...
# -- Local posts ----------------------------------------------------------
async def create_local_post(
self, session: AsyncSession, actor_profile_id: int,
content: str, visibility: str = "public",
in_reply_to: str | None = None,
) -> int: ...
async def delete_local_post(
self, session: AsyncSession, actor_profile_id: int, post_id: int,
) -> None: ...
# -- Interactions ---------------------------------------------------------
async def like_post(
self, session: AsyncSession, actor_profile_id: int,
object_id: str, author_inbox: str,
) -> None: ...
async def unlike_post(
self, session: AsyncSession, actor_profile_id: int,
object_id: str, author_inbox: str,
) -> None: ...
async def boost_post(
self, session: AsyncSession, actor_profile_id: int,
object_id: str, author_inbox: str,
) -> None: ...
async def unboost_post(
self, session: AsyncSession, actor_profile_id: int,
object_id: str, author_inbox: str,
) -> None: ...
# -- Notifications --------------------------------------------------------
async def get_notifications(
self, session: AsyncSession, actor_profile_id: int,
before: datetime | None = None, limit: int = 20,
) -> list[NotificationDTO]: ...
async def unread_notification_count(
self, session: AsyncSession, actor_profile_id: int,
) -> int: ...
async def mark_notifications_read(
self, session: AsyncSession, actor_profile_id: int,
) -> None: ...
# -- Stats ----------------------------------------------------------------
async def get_stats(self, session: AsyncSession) -> dict: ...

View File

@@ -6,5 +6,5 @@ def register_shared_handlers():
import shared.events.handlers.container_handlers # noqa: F401
import shared.events.handlers.login_handlers # noqa: F401
import shared.events.handlers.order_handlers # noqa: F401
import shared.events.handlers.federation_handlers # noqa: F401
# federation_handlers removed — publication is now inline at write sites
import shared.events.handlers.ap_delivery_handler # noqa: F401

View File

@@ -27,13 +27,30 @@ def _build_activity_json(activity: APActivity, actor: ActorProfile, domain: str)
actor_url = f"https://{domain}/users/{username}"
obj = dict(activity.object_data or {})
obj.setdefault("id", activity.activity_id + "/object")
obj.setdefault("type", activity.object_type)
obj.setdefault("attributedTo", actor_url)
obj.setdefault("published", activity.published.isoformat() if activity.published else None)
# Object id MUST be on the actor's domain (Mastodon origin check).
# The post URL (e.g. coop.rose-ash.com/slug/) goes in "url" only.
object_id = activity.activity_id + "/object"
if activity.activity_type == "Delete":
# Delete: object is a Tombstone with just id + type
obj.setdefault("id", object_id)
obj.setdefault("type", "Tombstone")
else:
# Create/Update: full object with attribution
# Prefer stable id from object_data (set by try_publish), fall back to activity-derived
obj.setdefault("id", object_id)
obj.setdefault("type", activity.object_type)
obj.setdefault("attributedTo", actor_url)
obj.setdefault("published", activity.published.isoformat() if activity.published else None)
obj.setdefault("to", ["https://www.w3.org/ns/activitystreams#Public"])
obj.setdefault("cc", [f"{actor_url}/followers"])
return {
"@context": "https://www.w3.org/ns/activitystreams",
"@context": [
"https://www.w3.org/ns/activitystreams",
"https://w3id.org/security/v1",
],
"id": activity.activity_id,
"type": activity.activity_type,
"actor": actor_url,
@@ -53,17 +70,20 @@ async def _deliver_to_inbox(
) -> bool:
"""POST signed activity to a single inbox. Returns True on success."""
from shared.utils.http_signatures import sign_request
from urllib.parse import urlparse
import json
body_bytes = json.dumps(body).encode()
key_id = f"https://{domain}/users/{actor.preferred_username}#main-key"
parsed = urlparse(inbox_url)
headers = sign_request(
method="POST",
url=inbox_url,
body=body_bytes,
private_key_pem=actor.private_key_pem,
key_id=key_id,
method="POST",
path=parsed.path,
host=parsed.netloc,
body=body_bytes,
)
headers["Content-Type"] = AP_CONTENT_TYPE

View File

@@ -1,167 +1,8 @@
"""Federation event handlers — publish domain content as AP activities.
"""Federation event handlers — REMOVED.
Listens for content events and calls services.federation.publish_activity()
to create AP activities. Each handler checks:
1. services.has("federation") — skip if federation not wired
2. The content has a user_id — skip anonymous/system content
3. The user has an ActorProfile — skip users who haven't chosen a username
Federation publication is now inline at the write site (ghost_sync, entries,
market routes) via shared.services.federation_publish.try_publish().
AP delivery (federation.activity_created → inbox POST) remains async via
ap_delivery_handler.
"""
from __future__ import annotations
import logging
from sqlalchemy.ext.asyncio import AsyncSession
from shared.events.bus import register_handler, DomainEvent
from shared.services.registry import services
log = logging.getLogger(__name__)
async def _try_publish(
session: AsyncSession,
*,
user_id: int | None,
activity_type: str,
object_type: str,
object_data: dict,
source_type: str,
source_id: int,
) -> None:
"""Publish an AP activity if federation is available and user has a profile."""
if not services.has("federation"):
return
if not user_id:
return
# Check user has an ActorProfile (chose a username)
actor = await services.federation.get_actor_by_user_id(session, user_id)
if not actor:
return
# Don't re-publish if we already have an activity for this source
existing = await services.federation.get_activity_for_source(
session, source_type, source_id,
)
if existing and activity_type == "Create":
return # Already published
try:
await services.federation.publish_activity(
session,
actor_user_id=user_id,
activity_type=activity_type,
object_type=object_type,
object_data=object_data,
source_type=source_type,
source_id=source_id,
)
log.info(
"Published %s/%s for %s#%d by user %d",
activity_type, object_type, source_type, source_id, user_id,
)
except Exception:
log.exception("Failed to publish activity for %s#%d", source_type, source_id)
# -- Post published/updated (from Ghost webhook sync) -------------------------
async def on_post_published(event: DomainEvent, session: AsyncSession) -> None:
p = event.payload
await _try_publish(
session,
user_id=p.get("user_id"),
activity_type="Create",
object_type="Article",
object_data={
"name": p.get("title", ""),
"content": p.get("excerpt", ""),
"url": p.get("url", ""),
},
source_type="Post",
source_id=event.aggregate_id,
)
async def on_post_updated(event: DomainEvent, session: AsyncSession) -> None:
p = event.payload
await _try_publish(
session,
user_id=p.get("user_id"),
activity_type="Update",
object_type="Article",
object_data={
"name": p.get("title", ""),
"content": p.get("excerpt", ""),
"url": p.get("url", ""),
},
source_type="Post",
source_id=event.aggregate_id,
)
# -- Calendar entry created/updated -------------------------------------------
async def on_calendar_entry_created(event: DomainEvent, session: AsyncSession) -> None:
p = event.payload
await _try_publish(
session,
user_id=p.get("user_id"),
activity_type="Create",
object_type="Event",
object_data={
"name": p.get("title", ""),
"startTime": p.get("start_time", ""),
"endTime": p.get("end_time", ""),
"url": p.get("url", ""),
},
source_type="CalendarEntry",
source_id=event.aggregate_id,
)
async def on_calendar_entry_updated(event: DomainEvent, session: AsyncSession) -> None:
p = event.payload
await _try_publish(
session,
user_id=p.get("user_id"),
activity_type="Update",
object_type="Event",
object_data={
"name": p.get("title", ""),
"startTime": p.get("start_time", ""),
"endTime": p.get("end_time", ""),
"url": p.get("url", ""),
},
source_type="CalendarEntry",
source_id=event.aggregate_id,
)
# -- Product listed/updated ---------------------------------------------------
async def on_product_listed(event: DomainEvent, session: AsyncSession) -> None:
p = event.payload
await _try_publish(
session,
user_id=p.get("user_id"),
activity_type="Create",
object_type="Object",
object_data={
"name": p.get("title", ""),
"summary": p.get("description", ""),
"url": p.get("url", ""),
},
source_type="Product",
source_id=event.aggregate_id,
)
# -- Registration --------------------------------------------------------------
register_handler("post.published", on_post_published)
register_handler("post.updated", on_post_updated)
register_handler("calendar_entry.created", on_calendar_entry_created)
register_handler("calendar_entry.updated", on_calendar_entry_updated)
register_handler("product.listed", on_product_listed)

View File

@@ -29,4 +29,5 @@ from .container_relation import ContainerRelation
from .menu_node import MenuNode
from .federation import (
ActorProfile, APActivity, APFollower, APInboxItem, APAnchor, IPFSPin,
RemoteActor, APFollowing, APRemotePost, APLocalPost, APInteraction, APNotification,
)

View File

@@ -193,3 +193,207 @@ class IPFSPin(Base):
def __repr__(self) -> str:
return f"<IPFSPin {self.id} {self.ipfs_cid[:16]}...>"
class RemoteActor(Base):
"""Cached profile of a remote actor we interact with."""
__tablename__ = "ap_remote_actors"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
actor_url: Mapped[str] = mapped_column(String(512), unique=True, nullable=False)
inbox_url: Mapped[str] = mapped_column(String(512), nullable=False)
shared_inbox_url: Mapped[str | None] = mapped_column(String(512), nullable=True)
preferred_username: Mapped[str] = mapped_column(String(255), nullable=False)
display_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
summary: Mapped[str | None] = mapped_column(Text, nullable=True)
icon_url: Mapped[str | None] = mapped_column(String(512), nullable=True)
public_key_pem: Mapped[str | None] = mapped_column(Text, nullable=True)
domain: Mapped[str] = mapped_column(String(255), nullable=False)
fetched_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(),
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(),
)
__table_args__ = (
Index("ix_ap_remote_actor_url", "actor_url", unique=True),
Index("ix_ap_remote_actor_domain", "domain"),
)
def __repr__(self) -> str:
return f"<RemoteActor {self.id} {self.preferred_username}@{self.domain}>"
class APFollowing(Base):
"""Outbound follow: local actor → remote actor."""
__tablename__ = "ap_following"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
actor_profile_id: Mapped[int] = mapped_column(
Integer, ForeignKey("ap_actor_profiles.id", ondelete="CASCADE"), nullable=False,
)
remote_actor_id: Mapped[int] = mapped_column(
Integer, ForeignKey("ap_remote_actors.id", ondelete="CASCADE"), nullable=False,
)
state: Mapped[str] = mapped_column(
String(20), nullable=False, default="pending", server_default="pending",
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(),
)
accepted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
# Relationships
actor_profile = relationship("ActorProfile")
remote_actor = relationship("RemoteActor")
__table_args__ = (
UniqueConstraint("actor_profile_id", "remote_actor_id", name="uq_following"),
Index("ix_ap_following_actor", "actor_profile_id"),
Index("ix_ap_following_remote", "remote_actor_id"),
)
def __repr__(self) -> str:
return f"<APFollowing {self.id} [{self.state}]>"
class APRemotePost(Base):
"""A federated post ingested from a remote actor."""
__tablename__ = "ap_remote_posts"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
remote_actor_id: Mapped[int] = mapped_column(
Integer, ForeignKey("ap_remote_actors.id", ondelete="CASCADE"), nullable=False,
)
activity_id: Mapped[str] = mapped_column(String(512), unique=True, nullable=False)
object_id: Mapped[str] = mapped_column(String(512), unique=True, nullable=False)
object_type: Mapped[str] = mapped_column(String(64), nullable=False, default="Note")
content: Mapped[str | None] = mapped_column(Text, nullable=True)
summary: Mapped[str | None] = mapped_column(Text, nullable=True)
url: Mapped[str | None] = mapped_column(String(512), nullable=True)
attachment_data: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
tag_data: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
in_reply_to: Mapped[str | None] = mapped_column(String(512), nullable=True)
conversation: Mapped[str | None] = mapped_column(String(512), nullable=True)
published: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
fetched_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(),
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(),
)
# Relationships
remote_actor = relationship("RemoteActor")
__table_args__ = (
Index("ix_ap_remote_post_actor", "remote_actor_id"),
Index("ix_ap_remote_post_published", "published"),
Index("ix_ap_remote_post_object", "object_id", unique=True),
)
def __repr__(self) -> str:
return f"<APRemotePost {self.id} {self.object_type}>"
class APLocalPost(Base):
"""A native post composed in the federation UI."""
__tablename__ = "ap_local_posts"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
actor_profile_id: Mapped[int] = mapped_column(
Integer, ForeignKey("ap_actor_profiles.id", ondelete="CASCADE"), nullable=False,
)
content: Mapped[str] = mapped_column(Text, nullable=False)
visibility: Mapped[str] = mapped_column(
String(20), nullable=False, default="public", server_default="public",
)
in_reply_to: Mapped[str | None] = mapped_column(String(512), nullable=True)
published: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(),
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(),
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now(),
)
# Relationships
actor_profile = relationship("ActorProfile")
__table_args__ = (
Index("ix_ap_local_post_actor", "actor_profile_id"),
Index("ix_ap_local_post_published", "published"),
)
def __repr__(self) -> str:
return f"<APLocalPost {self.id}>"
class APInteraction(Base):
"""Like or boost (local or remote)."""
__tablename__ = "ap_interactions"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
actor_profile_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("ap_actor_profiles.id", ondelete="CASCADE"), nullable=True,
)
remote_actor_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("ap_remote_actors.id", ondelete="CASCADE"), nullable=True,
)
post_type: Mapped[str] = mapped_column(String(20), nullable=False) # local/remote
post_id: Mapped[int] = mapped_column(Integer, nullable=False)
interaction_type: Mapped[str] = mapped_column(String(20), nullable=False) # like/boost
activity_id: Mapped[str | None] = mapped_column(String(512), nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(),
)
__table_args__ = (
Index("ix_ap_interaction_post", "post_type", "post_id"),
Index("ix_ap_interaction_actor", "actor_profile_id"),
Index("ix_ap_interaction_remote", "remote_actor_id"),
)
def __repr__(self) -> str:
return f"<APInteraction {self.id} {self.interaction_type}>"
class APNotification(Base):
"""Notification for a local actor."""
__tablename__ = "ap_notifications"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
actor_profile_id: Mapped[int] = mapped_column(
Integer, ForeignKey("ap_actor_profiles.id", ondelete="CASCADE"), nullable=False,
)
notification_type: Mapped[str] = mapped_column(String(20), nullable=False)
from_remote_actor_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("ap_remote_actors.id", ondelete="SET NULL"), nullable=True,
)
from_actor_profile_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("ap_actor_profiles.id", ondelete="SET NULL"), nullable=True,
)
target_activity_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("ap_activities.id", ondelete="SET NULL"), nullable=True,
)
target_remote_post_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("ap_remote_posts.id", ondelete="SET NULL"), nullable=True,
)
read: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false")
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(),
)
# Relationships
actor_profile = relationship("ActorProfile", foreign_keys=[actor_profile_id])
from_remote_actor = relationship("RemoteActor")
from_actor_profile = relationship("ActorProfile", foreign_keys=[from_actor_profile_id])
__table_args__ = (
Index("ix_ap_notification_actor", "actor_profile_id"),
Index("ix_ap_notification_read", "actor_profile_id", "read"),
Index("ix_ap_notification_created", "created_at"),
)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,82 @@
"""Inline federation publication — called at write time, not via async handler.
Replaces the old pattern where emit_event("post.published") → async handler →
publish_activity(). Now the originating service calls try_publish() directly,
which creates the APActivity in the same DB transaction. AP delivery
(federation.activity_created → inbox POST) stays async.
"""
from __future__ import annotations
import logging
import os
from sqlalchemy.ext.asyncio import AsyncSession
from shared.services.registry import services
log = logging.getLogger(__name__)
async def try_publish(
session: AsyncSession,
*,
user_id: int | None,
activity_type: str,
object_type: str,
object_data: dict,
source_type: str,
source_id: int,
) -> None:
"""Publish an AP activity if federation is available and user has a profile.
Safe to call from any app — returns silently if federation isn't wired
or the user has no actor profile.
"""
if not services.has("federation"):
return
if not user_id:
return
actor = await services.federation.get_actor_by_user_id(session, user_id)
if not actor:
return
# Dedup: don't re-Create if already published, don't re-Delete if already deleted
existing = await services.federation.get_activity_for_source(
session, source_type, source_id,
)
if existing:
if activity_type == "Create" and existing.activity_type != "Delete":
return # already published (allow re-Create after Delete/unpublish)
if activity_type == "Update" and existing.activity_type == "Update":
return # already updated (Ghost fires duplicate webhooks)
if activity_type == "Delete" and existing.activity_type == "Delete":
return # already deleted
elif activity_type in ("Delete", "Update"):
return # never published, nothing to delete/update
# Stable object ID: same source always gets the same object id so
# Mastodon treats Create/Update/Delete as the same post.
domain = os.getenv("AP_DOMAIN", "rose-ash.com")
object_data["id"] = (
f"https://{domain}/users/{actor.preferred_username}"
f"/objects/{source_type.lower()}/{source_id}"
)
try:
await services.federation.publish_activity(
session,
actor_user_id=user_id,
activity_type=activity_type,
object_type=object_type,
object_data=object_data,
source_type=source_type,
source_id=source_id,
)
log.info(
"Published %s/%s for %s#%d by user %d",
activity_type, object_type, source_type, source_id, user_id,
)
except Exception:
log.exception("Failed to publish activity for %s#%d", source_type, source_id)

View File

@@ -227,5 +227,65 @@ class StubFederationService:
async def remove_follower(self, session, username, follower_acct):
return False
async def get_or_fetch_remote_actor(self, session, actor_url):
return None
async def search_remote_actor(self, session, acct):
return None
async def send_follow(self, session, local_username, remote_actor_url):
raise RuntimeError("FederationService not available")
async def get_following(self, session, username, page=1, per_page=20):
return [], 0
async def accept_follow_response(self, session, local_username, remote_actor_url):
pass
async def unfollow(self, session, local_username, remote_actor_url):
pass
async def ingest_remote_post(self, session, remote_actor_id, activity_json, object_json):
pass
async def delete_remote_post(self, session, object_id):
pass
async def get_remote_post(self, session, object_id):
return None
async def get_home_timeline(self, session, actor_profile_id, before=None, limit=20):
return []
async def get_public_timeline(self, session, before=None, limit=20):
return []
async def create_local_post(self, session, actor_profile_id, content, visibility="public", in_reply_to=None):
raise RuntimeError("FederationService not available")
async def delete_local_post(self, session, actor_profile_id, post_id):
raise RuntimeError("FederationService not available")
async def like_post(self, session, actor_profile_id, object_id, author_inbox):
pass
async def unlike_post(self, session, actor_profile_id, object_id, author_inbox):
pass
async def boost_post(self, session, actor_profile_id, object_id, author_inbox):
pass
async def unboost_post(self, session, actor_profile_id, object_id, author_inbox):
pass
async def get_notifications(self, session, actor_profile_id, before=None, limit=20):
return []
async def unread_notification_count(self, session, actor_profile_id):
return 0
async def mark_notifications_read(self, session, actor_profile_id):
pass
async def get_stats(self, session):
return {"actors": 0, "activities": 0, "followers": 0}

68
utils/webfinger.py Normal file
View File

@@ -0,0 +1,68 @@
"""WebFinger client for resolving remote AP actor profiles."""
from __future__ import annotations
import logging
import httpx
log = logging.getLogger(__name__)
AP_CONTENT_TYPE = "application/activity+json"
async def resolve_actor(acct: str) -> dict | None:
"""Resolve user@domain to actor JSON via WebFinger + actor fetch.
Args:
acct: Handle in the form ``user@domain`` (no leading ``@``).
Returns:
Actor JSON-LD dict, or None if resolution fails.
"""
acct = acct.lstrip("@")
if "@" not in acct:
return None
_, domain = acct.rsplit("@", 1)
webfinger_url = f"https://{domain}/.well-known/webfinger"
try:
async with httpx.AsyncClient(timeout=10, follow_redirects=True) as client:
# Step 1: WebFinger lookup
resp = await client.get(
webfinger_url,
params={"resource": f"acct:{acct}"},
headers={"Accept": "application/jrd+json, application/json"},
)
if resp.status_code != 200:
log.debug("WebFinger %s returned %d", webfinger_url, resp.status_code)
return None
data = resp.json()
# Find self link with AP content type
actor_url = None
for link in data.get("links", []):
if link.get("rel") == "self" and link.get("type") in (
AP_CONTENT_TYPE,
"application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"",
):
actor_url = link.get("href")
break
if not actor_url:
log.debug("No AP self link in WebFinger response for %s", acct)
return None
# Step 2: Fetch actor JSON
resp = await client.get(
actor_url,
headers={"Accept": AP_CONTENT_TYPE},
)
if resp.status_code == 200:
return resp.json()
log.debug("Actor fetch %s returned %d", actor_url, resp.status_code)
except Exception:
log.exception("WebFinger resolution failed for %s", acct)
return None