Compare commits
17 Commits
8850a0106a
...
widget-pha
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bccfff0c69 | ||
|
|
9a8b556c13 | ||
|
|
a626dd849d | ||
|
|
d0b1edea7a | ||
|
|
eec750a699 | ||
|
|
fd163b577f | ||
|
|
3bde451ce9 | ||
|
|
798fe56165 | ||
|
|
18410c4b16 | ||
|
|
a28add8640 | ||
|
|
68941b97f6 | ||
|
|
1d83a339b6 | ||
|
|
24432cd52a | ||
|
|
9a1a4996bc | ||
|
|
1832c53980 | ||
|
|
9db739e56d | ||
|
|
dd7a99e8b7 |
138
alembic/versions/l2j0g6h8i9_add_fediverse_tables.py
Normal file
138
alembic/versions/l2j0g6h8i9_add_fediverse_tables.py
Normal 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")
|
||||||
@@ -70,7 +70,7 @@
|
|||||||
type="text"
|
type="text"
|
||||||
name="title"
|
name="title"
|
||||||
value=""
|
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
|
class="w-full text-[36px] font-bold bg-transparent border-none outline-none
|
||||||
placeholder:text-stone-300 mb-[8px] leading-tight"
|
placeholder:text-stone-300 mb-[8px] leading-tight"
|
||||||
>
|
>
|
||||||
@@ -101,7 +101,7 @@
|
|||||||
type="submit"
|
type="submit"
|
||||||
class="px-[20px] py-[6px] bg-stone-700 text-white text-[14px] rounded-[8px]
|
class="px-[20px] py-[6px] bg-stone-700 text-white text-[14px] rounded-[8px]
|
||||||
hover:bg-stone-800 transition-colors cursor-pointer"
|
hover:bg-stone-800 transition-colors cursor-pointer"
|
||||||
>Create Post</button>
|
>{{ 'Create Page' if is_page else 'Create Post' }}</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,15 @@
|
|||||||
{% endcall %}
|
{% endcall %}
|
||||||
</div>
|
</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 #}
|
{# Admin link #}
|
||||||
{% if g.rights.admin %}
|
{% if g.rights.admin %}
|
||||||
{% from 'macros/admin_nav.html' import admin_nav_item %}
|
{% from 'macros/admin_nav.html' import admin_nav_item %}
|
||||||
|
|||||||
@@ -22,6 +22,14 @@
|
|||||||
{% endcall %}
|
{% endcall %}
|
||||||
</div>
|
</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 #}
|
{# Admin link #}
|
||||||
{% if g.rights.admin %}
|
{% if g.rights.admin %}
|
||||||
|
|
||||||
|
|||||||
@@ -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">
|
<article class="relative">
|
||||||
{# ❤️ like button - always visible in top right of article #}
|
{# Draft indicator + edit link (shown for both posts and pages) #}
|
||||||
{% 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 #}
|
|
||||||
{% if post.status == "draft" %}
|
{% if post.status == "draft" %}
|
||||||
<div class="flex items-center justify-center gap-2 mb-3">
|
<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>
|
<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>
|
</div>
|
||||||
{% endif %}
|
{% 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 %}
|
{% if post.custom_excerpt %}
|
||||||
<div class="w-full text-center italic text-3xl p-2">
|
<div class="w-full text-center italic text-3xl p-2">
|
||||||
{{post.custom_excerpt|safe}}
|
{{post.custom_excerpt|safe}}
|
||||||
@@ -44,6 +45,8 @@
|
|||||||
<div class="hidden md:block">
|
<div class="hidden md:block">
|
||||||
{% include '_types/blog/_card/at_bar.html' %}
|
{% include '_types/blog/_card/at_bar.html' %}
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% if post.feature_image %}
|
{% if post.feature_image %}
|
||||||
<div class="mb-3 flex justify-center">
|
<div class="mb-3 flex justify-center">
|
||||||
<img
|
<img
|
||||||
|
|||||||
@@ -77,7 +77,7 @@
|
|||||||
type="text"
|
type="text"
|
||||||
name="title"
|
name="title"
|
||||||
value="{{ ghost_post.title if ghost_post else '' }}"
|
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
|
class="w-full text-[36px] font-bold bg-transparent border-none outline-none
|
||||||
placeholder:text-stone-300 mb-[8px] leading-tight"
|
placeholder:text-stone-300 mb-[8px] leading-tight"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{# ── Post Settings Form ── #}
|
{# ── Post/Page Settings Form ── #}
|
||||||
{% set gp = ghost_post or {} %}
|
{% set gp = ghost_post or {} %}
|
||||||
|
{% set _is_page = post.is_page if post else False %}
|
||||||
|
|
||||||
{% macro field_label(text, field_for=None) %}
|
{% macro field_label(text, field_for=None) %}
|
||||||
<label {% if field_for %}for="{{ field_for }}"{% endif %}
|
<label {% if field_for %}for="{{ field_for }}"{% endif %}
|
||||||
@@ -68,7 +69,7 @@
|
|||||||
{% call section('General', open=True) %}
|
{% call section('General', open=True) %}
|
||||||
<div>
|
<div>
|
||||||
{{ field_label('Slug', 'settings-slug') }}
|
{{ 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>
|
||||||
<div>
|
<div>
|
||||||
{{ field_label('Published at', 'settings-published_at') }}
|
{{ field_label('Published at', 'settings-published_at') }}
|
||||||
@@ -83,7 +84,7 @@
|
|||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
{{ checkbox_input('featured', gp.featured, 'Featured post') }}
|
{{ checkbox_input('featured', gp.featured, 'Featured page' if _is_page else 'Featured post') }}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
{{ field_label('Visibility', 'settings-visibility') }}
|
{{ field_label('Visibility', 'settings-visibility') }}
|
||||||
@@ -176,7 +177,7 @@
|
|||||||
{% call section('Advanced') %}
|
{% call section('Advanced') %}
|
||||||
<div>
|
<div>
|
||||||
{{ field_label('Custom template', 'settings-custom_template') }}
|
{{ 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>
|
</div>
|
||||||
{% endcall %}
|
{% endcall %}
|
||||||
|
|
||||||
|
|||||||
@@ -187,3 +187,68 @@ class APAnchorDTO:
|
|||||||
ots_proof_cid: str | None = None
|
ots_proof_cid: str | None = None
|
||||||
confirmed_at: datetime | None = None
|
confirmed_at: datetime | None = None
|
||||||
bitcoin_txid: str | 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
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ from .dtos import (
|
|||||||
ActorProfileDTO,
|
ActorProfileDTO,
|
||||||
APActivityDTO,
|
APActivityDTO,
|
||||||
APFollowerDTO,
|
APFollowerDTO,
|
||||||
|
RemoteActorDTO,
|
||||||
|
RemotePostDTO,
|
||||||
|
TimelineItemDTO,
|
||||||
|
NotificationDTO,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -227,5 +231,103 @@ class FederationService(Protocol):
|
|||||||
self, session: AsyncSession, username: str, follower_acct: str,
|
self, session: AsyncSession, username: str, follower_acct: str,
|
||||||
) -> bool: ...
|
) -> 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 ----------------------------------------------------------------
|
# -- Stats ----------------------------------------------------------------
|
||||||
async def get_stats(self, session: AsyncSession) -> dict: ...
|
async def get_stats(self, session: AsyncSession) -> dict: ...
|
||||||
|
|||||||
@@ -6,3 +6,5 @@ def register_shared_handlers():
|
|||||||
import shared.events.handlers.container_handlers # noqa: F401
|
import shared.events.handlers.container_handlers # noqa: F401
|
||||||
import shared.events.handlers.login_handlers # noqa: F401
|
import shared.events.handlers.login_handlers # noqa: F401
|
||||||
import shared.events.handlers.order_handlers # noqa: F401
|
import shared.events.handlers.order_handlers # noqa: F401
|
||||||
|
# federation_handlers removed — publication is now inline at write sites
|
||||||
|
import shared.events.handlers.ap_delivery_handler # noqa: F401
|
||||||
|
|||||||
170
events/handlers/ap_delivery_handler.py
Normal file
170
events/handlers/ap_delivery_handler.py
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
"""Deliver AP activities to remote followers.
|
||||||
|
|
||||||
|
On ``federation.activity_created`` → load activity + actor + followers →
|
||||||
|
sign with HTTP Signatures → POST to each follower inbox.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from shared.events.bus import register_handler, DomainEvent
|
||||||
|
from shared.models.federation import ActorProfile, APActivity, APFollower
|
||||||
|
from shared.services.registry import services
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
AP_CONTENT_TYPE = "application/activity+json"
|
||||||
|
DELIVERY_TIMEOUT = 15 # seconds per request
|
||||||
|
|
||||||
|
|
||||||
|
def _build_activity_json(activity: APActivity, actor: ActorProfile, domain: str) -> dict:
|
||||||
|
"""Build the full AP activity JSON-LD for delivery."""
|
||||||
|
username = actor.preferred_username
|
||||||
|
actor_url = f"https://{domain}/users/{username}"
|
||||||
|
|
||||||
|
obj = dict(activity.object_data or {})
|
||||||
|
|
||||||
|
# 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",
|
||||||
|
"https://w3id.org/security/v1",
|
||||||
|
],
|
||||||
|
"id": activity.activity_id,
|
||||||
|
"type": activity.activity_type,
|
||||||
|
"actor": actor_url,
|
||||||
|
"published": activity.published.isoformat() if activity.published else None,
|
||||||
|
"to": ["https://www.w3.org/ns/activitystreams#Public"],
|
||||||
|
"cc": [f"{actor_url}/followers"],
|
||||||
|
"object": obj,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _deliver_to_inbox(
|
||||||
|
client: httpx.AsyncClient,
|
||||||
|
inbox_url: str,
|
||||||
|
body: dict,
|
||||||
|
actor: ActorProfile,
|
||||||
|
domain: str,
|
||||||
|
) -> 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(
|
||||||
|
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
|
||||||
|
|
||||||
|
try:
|
||||||
|
resp = await client.post(
|
||||||
|
inbox_url,
|
||||||
|
content=body_bytes,
|
||||||
|
headers=headers,
|
||||||
|
timeout=DELIVERY_TIMEOUT,
|
||||||
|
)
|
||||||
|
if resp.status_code < 300:
|
||||||
|
log.info("Delivered to %s → %d", inbox_url, resp.status_code)
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
log.warning("Delivery to %s → %d: %s", inbox_url, resp.status_code, resp.text[:200])
|
||||||
|
return False
|
||||||
|
except Exception:
|
||||||
|
log.exception("Delivery failed for %s", inbox_url)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def on_activity_created(event: DomainEvent, session: AsyncSession) -> None:
|
||||||
|
"""Deliver a newly created activity to all followers."""
|
||||||
|
import os
|
||||||
|
|
||||||
|
if not services.has("federation"):
|
||||||
|
return
|
||||||
|
|
||||||
|
payload = event.payload
|
||||||
|
activity_id_uri = payload.get("activity_id")
|
||||||
|
if not activity_id_uri:
|
||||||
|
return
|
||||||
|
|
||||||
|
domain = os.getenv("AP_DOMAIN", "rose-ash.com")
|
||||||
|
|
||||||
|
# Load the activity
|
||||||
|
activity = (
|
||||||
|
await session.execute(
|
||||||
|
select(APActivity).where(APActivity.activity_id == activity_id_uri)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if not activity:
|
||||||
|
log.warning("Activity not found: %s", activity_id_uri)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Load actor with private key
|
||||||
|
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 activity %s", activity_id_uri)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Load followers
|
||||||
|
followers = (
|
||||||
|
await session.execute(
|
||||||
|
select(APFollower).where(APFollower.actor_profile_id == actor.id)
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
|
||||||
|
if not followers:
|
||||||
|
log.debug("No followers to deliver to for %s", activity_id_uri)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Build activity JSON
|
||||||
|
activity_json = _build_activity_json(activity, actor, domain)
|
||||||
|
|
||||||
|
# Deliver to each follower inbox
|
||||||
|
# Deduplicate inboxes (multiple followers might share a shared inbox)
|
||||||
|
inboxes = {f.follower_inbox for f in followers if f.follower_inbox}
|
||||||
|
|
||||||
|
log.info(
|
||||||
|
"Delivering %s to %d inbox(es) for @%s",
|
||||||
|
activity.activity_type, len(inboxes), actor.preferred_username,
|
||||||
|
)
|
||||||
|
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
for inbox_url in inboxes:
|
||||||
|
await _deliver_to_inbox(client, inbox_url, activity_json, actor, domain)
|
||||||
|
|
||||||
|
|
||||||
|
register_handler("federation.activity_created", on_activity_created)
|
||||||
8
events/handlers/federation_handlers.py
Normal file
8
events/handlers/federation_handlers.py
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
"""Federation event handlers — REMOVED.
|
||||||
|
|
||||||
|
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.
|
||||||
|
"""
|
||||||
@@ -29,4 +29,5 @@ from .container_relation import ContainerRelation
|
|||||||
from .menu_node import MenuNode
|
from .menu_node import MenuNode
|
||||||
from .federation import (
|
from .federation import (
|
||||||
ActorProfile, APActivity, APFollower, APInboxItem, APAnchor, IPFSPin,
|
ActorProfile, APActivity, APFollower, APInboxItem, APAnchor, IPFSPin,
|
||||||
|
RemoteActor, APFollowing, APRemotePost, APLocalPost, APInteraction, APNotification,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -193,3 +193,207 @@ class IPFSPin(Base):
|
|||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
return f"<IPFSPin {self.id} {self.ipfs_cid[:16]}...>"
|
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"),
|
||||||
|
)
|
||||||
|
|||||||
@@ -376,10 +376,18 @@ class SqlCalendarService:
|
|||||||
async def adopt_entries_for_user(
|
async def adopt_entries_for_user(
|
||||||
self, session: AsyncSession, user_id: int, session_id: str,
|
self, session: AsyncSession, user_id: int, session_id: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Adopt anonymous calendar entries for a logged-in user."""
|
"""Adopt anonymous calendar entries for a logged-in user.
|
||||||
|
|
||||||
|
Only deletes stale *pending* entries for the user — confirmed/ordered
|
||||||
|
entries must be preserved.
|
||||||
|
"""
|
||||||
await session.execute(
|
await session.execute(
|
||||||
update(CalendarEntry)
|
update(CalendarEntry)
|
||||||
.where(CalendarEntry.deleted_at.is_(None), CalendarEntry.user_id == user_id)
|
.where(
|
||||||
|
CalendarEntry.deleted_at.is_(None),
|
||||||
|
CalendarEntry.user_id == user_id,
|
||||||
|
CalendarEntry.state == "pending",
|
||||||
|
)
|
||||||
.values(deleted_at=func.now())
|
.values(deleted_at=func.now())
|
||||||
)
|
)
|
||||||
cal_result = await session.execute(
|
cal_result = await session.execute(
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
82
services/federation_publish.py
Normal file
82
services/federation_publish.py
Normal 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)
|
||||||
@@ -227,5 +227,65 @@ class StubFederationService:
|
|||||||
async def remove_follower(self, session, username, follower_acct):
|
async def remove_follower(self, session, username, follower_acct):
|
||||||
return False
|
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):
|
async def get_stats(self, session):
|
||||||
return {"actors": 0, "activities": 0, "followers": 0}
|
return {"actors": 0, "activities": 0, "followers": 0}
|
||||||
|
|||||||
236
utils/anchoring.py
Normal file
236
utils/anchoring.py
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
"""Merkle tree construction and OpenTimestamps anchoring.
|
||||||
|
|
||||||
|
Ported from ~/art-dag/activity-pub/anchoring.py.
|
||||||
|
Builds a SHA256 merkle tree from activity IDs, submits the root to
|
||||||
|
OpenTimestamps for Bitcoin timestamping, and stores the tree + proof on IPFS.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from sqlalchemy import select, func
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from shared.models.federation import APActivity, APAnchor
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
OTS_SERVERS = [
|
||||||
|
"https://a.pool.opentimestamps.org",
|
||||||
|
"https://b.pool.opentimestamps.org",
|
||||||
|
"https://a.pool.eternitywall.com",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _sha256(data: str | bytes) -> str:
|
||||||
|
"""SHA256 hex digest."""
|
||||||
|
if isinstance(data, str):
|
||||||
|
data = data.encode()
|
||||||
|
return hashlib.sha256(data).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def build_merkle_tree(items: list[str]) -> dict:
|
||||||
|
"""Build a SHA256 merkle tree from a list of strings (activity IDs).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
{
|
||||||
|
"root": hex_str,
|
||||||
|
"leaves": [hex_str, ...],
|
||||||
|
"levels": [[hex_str, ...], ...], # bottom-up
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
if not items:
|
||||||
|
raise ValueError("Cannot build merkle tree from empty list")
|
||||||
|
|
||||||
|
# Sort for deterministic ordering
|
||||||
|
items = sorted(items)
|
||||||
|
|
||||||
|
# Leaf hashes
|
||||||
|
leaves = [_sha256(item) for item in items]
|
||||||
|
levels = [leaves[:]]
|
||||||
|
|
||||||
|
current = leaves[:]
|
||||||
|
while len(current) > 1:
|
||||||
|
next_level = []
|
||||||
|
for i in range(0, len(current), 2):
|
||||||
|
left = current[i]
|
||||||
|
right = current[i + 1] if i + 1 < len(current) else left
|
||||||
|
combined = _sha256(left + right)
|
||||||
|
next_level.append(combined)
|
||||||
|
levels.append(next_level)
|
||||||
|
current = next_level
|
||||||
|
|
||||||
|
return {
|
||||||
|
"root": current[0],
|
||||||
|
"leaves": leaves,
|
||||||
|
"levels": levels,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_merkle_proof(tree: dict, item: str) -> list[dict] | None:
|
||||||
|
"""Generate a proof-of-membership for an item.
|
||||||
|
|
||||||
|
Returns a list of {sibling: hex, position: "left"|"right"} dicts,
|
||||||
|
or None if the item is not in the tree.
|
||||||
|
"""
|
||||||
|
item_hash = _sha256(item)
|
||||||
|
leaves = tree["leaves"]
|
||||||
|
|
||||||
|
try:
|
||||||
|
idx = leaves.index(item_hash)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
proof = []
|
||||||
|
for level in tree["levels"][:-1]: # skip root level
|
||||||
|
if idx % 2 == 0:
|
||||||
|
sibling_idx = idx + 1
|
||||||
|
position = "right"
|
||||||
|
else:
|
||||||
|
sibling_idx = idx - 1
|
||||||
|
position = "left"
|
||||||
|
|
||||||
|
if sibling_idx < len(level):
|
||||||
|
proof.append({"sibling": level[sibling_idx], "position": position})
|
||||||
|
else:
|
||||||
|
proof.append({"sibling": level[idx], "position": position})
|
||||||
|
|
||||||
|
idx = idx // 2
|
||||||
|
|
||||||
|
return proof
|
||||||
|
|
||||||
|
|
||||||
|
def verify_merkle_proof(item: str, proof: list[dict], root: str) -> bool:
|
||||||
|
"""Verify a merkle proof against a root hash."""
|
||||||
|
current = _sha256(item)
|
||||||
|
for step in proof:
|
||||||
|
sibling = step["sibling"]
|
||||||
|
if step["position"] == "right":
|
||||||
|
current = _sha256(current + sibling)
|
||||||
|
else:
|
||||||
|
current = _sha256(sibling + current)
|
||||||
|
return current == root
|
||||||
|
|
||||||
|
|
||||||
|
async def submit_to_opentimestamps(merkle_root: str) -> bytes | None:
|
||||||
|
"""Submit a hash to OpenTimestamps. Returns the (incomplete) OTS proof bytes."""
|
||||||
|
root_bytes = bytes.fromhex(merkle_root)
|
||||||
|
|
||||||
|
for server in OTS_SERVERS:
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=30) as client:
|
||||||
|
resp = await client.post(
|
||||||
|
f"{server}/digest",
|
||||||
|
content=root_bytes,
|
||||||
|
headers={"Content-Type": "application/x-opentimestamps"},
|
||||||
|
)
|
||||||
|
if resp.status_code == 200:
|
||||||
|
log.info("OTS proof obtained from %s", server)
|
||||||
|
return resp.content
|
||||||
|
except Exception:
|
||||||
|
log.debug("OTS server %s failed", server, exc_info=True)
|
||||||
|
continue
|
||||||
|
|
||||||
|
log.warning("All OTS servers failed for root %s", merkle_root)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def upgrade_ots_proof(proof_bytes: bytes) -> tuple[bytes, bool]:
|
||||||
|
"""Try to upgrade an incomplete OTS proof to a Bitcoin-confirmed one.
|
||||||
|
|
||||||
|
Returns (proof_bytes, is_confirmed). The proof_bytes may be updated.
|
||||||
|
"""
|
||||||
|
# OpenTimestamps upgrade is done via the `ots` CLI or their calendar API.
|
||||||
|
# For now, return the proof as-is with is_confirmed=False.
|
||||||
|
# TODO: Implement calendar-based upgrade polling.
|
||||||
|
return proof_bytes, False
|
||||||
|
|
||||||
|
|
||||||
|
async def create_anchor(
|
||||||
|
session: AsyncSession,
|
||||||
|
batch_size: int = 100,
|
||||||
|
) -> APAnchor | None:
|
||||||
|
"""Anchor a batch of un-anchored activities.
|
||||||
|
|
||||||
|
1. Select activities without an anchor_id
|
||||||
|
2. Build merkle tree from their activity_ids
|
||||||
|
3. Store tree on IPFS
|
||||||
|
4. Submit root to OpenTimestamps
|
||||||
|
5. Store OTS proof on IPFS
|
||||||
|
6. Create APAnchor record
|
||||||
|
7. Link activities to anchor
|
||||||
|
"""
|
||||||
|
# Find un-anchored activities
|
||||||
|
result = await session.execute(
|
||||||
|
select(APActivity)
|
||||||
|
.where(
|
||||||
|
APActivity.anchor_id.is_(None),
|
||||||
|
APActivity.is_local == True, # noqa: E712
|
||||||
|
)
|
||||||
|
.order_by(APActivity.created_at.asc())
|
||||||
|
.limit(batch_size)
|
||||||
|
)
|
||||||
|
activities = result.scalars().all()
|
||||||
|
|
||||||
|
if not activities:
|
||||||
|
log.debug("No un-anchored activities to process")
|
||||||
|
return None
|
||||||
|
|
||||||
|
activity_ids = [a.activity_id for a in activities]
|
||||||
|
log.info("Anchoring %d activities", len(activity_ids))
|
||||||
|
|
||||||
|
# Build merkle tree
|
||||||
|
tree = build_merkle_tree(activity_ids)
|
||||||
|
merkle_root = tree["root"]
|
||||||
|
|
||||||
|
# Store tree on IPFS
|
||||||
|
tree_cid = None
|
||||||
|
ots_proof_cid = None
|
||||||
|
try:
|
||||||
|
from shared.utils.ipfs_client import add_json, add_bytes, is_available
|
||||||
|
if await is_available():
|
||||||
|
tree_cid = await add_json({
|
||||||
|
"root": merkle_root,
|
||||||
|
"leaves": tree["leaves"],
|
||||||
|
"activity_ids": activity_ids,
|
||||||
|
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
})
|
||||||
|
log.info("Merkle tree stored on IPFS: %s", tree_cid)
|
||||||
|
except Exception:
|
||||||
|
log.exception("IPFS tree storage failed")
|
||||||
|
|
||||||
|
# Submit to OpenTimestamps
|
||||||
|
ots_proof = await submit_to_opentimestamps(merkle_root)
|
||||||
|
if ots_proof:
|
||||||
|
try:
|
||||||
|
from shared.utils.ipfs_client import add_bytes, is_available
|
||||||
|
if await is_available():
|
||||||
|
ots_proof_cid = await add_bytes(ots_proof)
|
||||||
|
log.info("OTS proof stored on IPFS: %s", ots_proof_cid)
|
||||||
|
except Exception:
|
||||||
|
log.exception("IPFS OTS proof storage failed")
|
||||||
|
|
||||||
|
# Create anchor record
|
||||||
|
anchor = APAnchor(
|
||||||
|
merkle_root=merkle_root,
|
||||||
|
tree_ipfs_cid=tree_cid,
|
||||||
|
ots_proof_cid=ots_proof_cid,
|
||||||
|
activity_count=len(activities),
|
||||||
|
)
|
||||||
|
session.add(anchor)
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
# Link activities to anchor
|
||||||
|
for a in activities:
|
||||||
|
a.anchor_id = anchor.id
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
log.info(
|
||||||
|
"Anchor created: root=%s, activities=%d, tree_cid=%s",
|
||||||
|
merkle_root, len(activities), tree_cid,
|
||||||
|
)
|
||||||
|
|
||||||
|
return anchor
|
||||||
68
utils/webfinger.py
Normal file
68
utils/webfinger.py
Normal 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
|
||||||
Reference in New Issue
Block a user