Compare commits

..

4 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
3 changed files with 0 additions and 115 deletions

View File

@@ -221,11 +221,6 @@ class FederationService(Protocol):
self, session: AsyncSession, username: str, self, session: AsyncSession, username: str,
) -> list[APFollowerDTO]: ... ) -> list[APFollowerDTO]: ...
async def get_followers_paginated(
self, session: AsyncSession, username: str,
page: int = 1, per_page: int = 20,
) -> tuple[list[RemoteActorDTO], int]: ...
async def add_follower( async def add_follower(
self, session: AsyncSession, username: str, self, session: AsyncSession, username: str,
follower_acct: str, follower_inbox: str, follower_actor_url: str, follower_acct: str, follower_inbox: str, follower_actor_url: str,
@@ -288,11 +283,6 @@ class FederationService(Protocol):
before: datetime | None = None, limit: int = 20, before: datetime | None = None, limit: int = 20,
) -> list[TimelineItemDTO]: ... ) -> list[TimelineItemDTO]: ...
async def get_actor_timeline(
self, session: AsyncSession, remote_actor_id: int,
before: datetime | None = None, limit: int = 20,
) -> list[TimelineItemDTO]: ...
# -- Local posts ---------------------------------------------------------- # -- Local posts ----------------------------------------------------------
async def create_local_post( async def create_local_post(
self, session: AsyncSession, actor_profile_id: int, self, session: AsyncSession, actor_profile_id: int,

View File

@@ -376,65 +376,6 @@ class SqlFederationService:
) )
return result.rowcount > 0 return result.rowcount > 0
async def get_followers_paginated(
self, session: AsyncSession, username: str,
page: int = 1, per_page: int = 20,
) -> tuple[list[RemoteActorDTO], int]:
actor = (
await session.execute(
select(ActorProfile).where(ActorProfile.preferred_username == username)
)
).scalar_one_or_none()
if actor is None:
return [], 0
total = (
await session.execute(
select(func.count(APFollower.id)).where(
APFollower.actor_profile_id == actor.id,
)
)
).scalar() or 0
offset = (page - 1) * per_page
followers = (
await session.execute(
select(APFollower)
.where(APFollower.actor_profile_id == actor.id)
.order_by(APFollower.created_at.desc())
.limit(per_page)
.offset(offset)
)
).scalars().all()
results: list[RemoteActorDTO] = []
for f in followers:
# Try to resolve from cached remote actors first
remote = (
await session.execute(
select(RemoteActor).where(
RemoteActor.actor_url == f.follower_actor_url,
)
)
).scalar_one_or_none()
if remote:
results.append(_remote_actor_to_dto(remote))
else:
# Synthesise a minimal DTO from follower data
from urllib.parse import urlparse
domain = urlparse(f.follower_actor_url).netloc
results.append(RemoteActorDTO(
id=0,
actor_url=f.follower_actor_url,
inbox_url=f.follower_inbox,
preferred_username=f.follower_acct.split("@")[0] if "@" in f.follower_acct else f.follower_acct,
domain=domain,
display_name=None,
summary=None,
icon_url=None,
))
return results, total
# -- Remote actors -------------------------------------------------------- # -- Remote actors --------------------------------------------------------
async def get_or_fetch_remote_actor( async def get_or_fetch_remote_actor(
@@ -1025,46 +966,6 @@ class SqlFederationService:
)) ))
return items return items
async def get_actor_timeline(
self, session: AsyncSession, remote_actor_id: int,
before: datetime | None = None, limit: int = 20,
) -> list[TimelineItemDTO]:
remote_actor = (
await session.execute(
select(RemoteActor).where(RemoteActor.id == remote_actor_id)
)
).scalar_one_or_none()
if not remote_actor:
return []
q = (
select(APRemotePost)
.where(APRemotePost.remote_actor_id == remote_actor_id)
)
if before:
q = q.where(APRemotePost.published < before)
q = q.order_by(APRemotePost.published.desc()).limit(limit)
posts = (await session.execute(q)).scalars().all()
return [
TimelineItemDTO(
id=f"remote:{p.id}",
post_type="remote",
content=p.content or "",
published=p.published,
actor_name=remote_actor.display_name or remote_actor.preferred_username,
actor_username=remote_actor.preferred_username,
object_id=p.object_id,
summary=p.summary,
url=p.url,
actor_domain=remote_actor.domain,
actor_icon=remote_actor.icon_url,
actor_url=remote_actor.actor_url,
author_inbox=remote_actor.inbox_url,
)
for p in posts
]
# -- Local posts ---------------------------------------------------------- # -- Local posts ----------------------------------------------------------
async def create_local_post( async def create_local_post(

View File

@@ -239,9 +239,6 @@ class StubFederationService:
async def get_following(self, session, username, page=1, per_page=20): async def get_following(self, session, username, page=1, per_page=20):
return [], 0 return [], 0
async def get_followers_paginated(self, session, username, page=1, per_page=20):
return [], 0
async def accept_follow_response(self, session, local_username, remote_actor_url): async def accept_follow_response(self, session, local_username, remote_actor_url):
pass pass
@@ -263,9 +260,6 @@ class StubFederationService:
async def get_public_timeline(self, session, before=None, limit=20): async def get_public_timeline(self, session, before=None, limit=20):
return [] return []
async def get_actor_timeline(self, session, remote_actor_id, before=None, limit=20):
return []
async def create_local_post(self, session, actor_profile_id, content, visibility="public", in_reply_to=None): async def create_local_post(self, session, actor_profile_id, content, visibility="public", in_reply_to=None):
raise RuntimeError("FederationService not available") raise RuntimeError("FederationService not available")