This repository has been archived on 2026-02-24. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
shared/services/federation_impl.py
giles 8850a0106a Add federation/ActivityPub models, contracts, and services
Phase 0+1 of ActivityPub integration:
- 6 ORM models (ActorProfile, APActivity, APFollower, APInboxItem, APAnchor, IPFSPin)
- FederationService protocol + SqlFederationService implementation + stub
- 4 DTOs (ActorProfileDTO, APActivityDTO, APFollowerDTO, APAnchorDTO)
- Registry slot for federation service
- Alembic migration for federation tables
- IPFS async client (httpx-based)
- HTTP Signatures (RSA-2048 sign/verify)
- login_url() now uses AUTH_APP env var for flexible auth routing

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 15:10:08 +00:00

305 lines
10 KiB
Python

"""SQL-backed FederationService implementation.
Queries ``shared.models.federation`` — only this module may read/write
federation-domain tables on behalf of other domains.
"""
from __future__ import annotations
import os
import uuid
from datetime import datetime, timezone
from sqlalchemy import select, func, delete
from sqlalchemy.ext.asyncio import AsyncSession
from shared.models.federation import ActorProfile, APActivity, APFollower
from shared.contracts.dtos import ActorProfileDTO, APActivityDTO, APFollowerDTO
def _domain() -> str:
return os.getenv("AP_DOMAIN", "rose-ash.com")
def _actor_to_dto(actor: ActorProfile) -> ActorProfileDTO:
domain = _domain()
username = actor.preferred_username
return ActorProfileDTO(
id=actor.id,
user_id=actor.user_id,
preferred_username=username,
public_key_pem=actor.public_key_pem,
display_name=actor.display_name,
summary=actor.summary,
inbox_url=f"https://{domain}/users/{username}/inbox",
outbox_url=f"https://{domain}/users/{username}/outbox",
created_at=actor.created_at,
)
def _activity_to_dto(a: APActivity) -> APActivityDTO:
return APActivityDTO(
id=a.id,
activity_id=a.activity_id,
activity_type=a.activity_type,
actor_profile_id=a.actor_profile_id,
object_type=a.object_type,
object_data=a.object_data,
published=a.published,
is_local=a.is_local,
source_type=a.source_type,
source_id=a.source_id,
ipfs_cid=a.ipfs_cid,
)
def _follower_to_dto(f: APFollower) -> APFollowerDTO:
return APFollowerDTO(
id=f.id,
actor_profile_id=f.actor_profile_id,
follower_acct=f.follower_acct,
follower_inbox=f.follower_inbox,
follower_actor_url=f.follower_actor_url,
created_at=f.created_at,
)
class SqlFederationService:
# -- Actor management -----------------------------------------------------
async def get_actor_by_username(
self, session: AsyncSession, username: str,
) -> ActorProfileDTO | None:
actor = (
await session.execute(
select(ActorProfile).where(ActorProfile.preferred_username == username)
)
).scalar_one_or_none()
return _actor_to_dto(actor) if actor else None
async def get_actor_by_user_id(
self, session: AsyncSession, user_id: int,
) -> ActorProfileDTO | None:
actor = (
await session.execute(
select(ActorProfile).where(ActorProfile.user_id == user_id)
)
).scalar_one_or_none()
return _actor_to_dto(actor) if actor else None
async def create_actor(
self, session: AsyncSession, user_id: int, preferred_username: str,
display_name: str | None = None, summary: str | None = None,
) -> ActorProfileDTO:
from shared.utils.http_signatures import generate_rsa_keypair
private_pem, public_pem = generate_rsa_keypair()
actor = ActorProfile(
user_id=user_id,
preferred_username=preferred_username,
display_name=display_name,
summary=summary,
public_key_pem=public_pem,
private_key_pem=private_pem,
)
session.add(actor)
await session.flush()
return _actor_to_dto(actor)
async def username_available(
self, session: AsyncSession, username: str,
) -> bool:
count = (
await session.execute(
select(func.count(ActorProfile.id)).where(
ActorProfile.preferred_username == username
)
)
).scalar() or 0
return count == 0
# -- Publishing -----------------------------------------------------------
async def publish_activity(
self, session: AsyncSession, *,
actor_user_id: int,
activity_type: str,
object_type: str,
object_data: dict,
source_type: str | None = None,
source_id: int | None = None,
) -> APActivityDTO:
# Look up actor
actor = (
await session.execute(
select(ActorProfile).where(ActorProfile.user_id == actor_user_id)
)
).scalar_one_or_none()
if actor is None:
raise ValueError(f"No ActorProfile for user_id={actor_user_id}")
domain = _domain()
username = actor.preferred_username
activity_uri = f"https://{domain}/users/{username}/activities/{uuid.uuid4()}"
now = datetime.now(timezone.utc)
activity = APActivity(
activity_id=activity_uri,
activity_type=activity_type,
actor_profile_id=actor.id,
object_type=object_type,
object_data=object_data,
published=now,
is_local=True,
source_type=source_type,
source_id=source_id,
)
session.add(activity)
await session.flush()
# Emit domain event for downstream processing (IPFS storage, delivery)
from shared.events import emit_event
await emit_event(
session,
"federation.activity_created",
"APActivity",
activity.id,
{
"activity_id": activity.activity_id,
"activity_type": activity_type,
"actor_username": username,
"object_type": object_type,
},
)
return _activity_to_dto(activity)
# -- Queries --------------------------------------------------------------
async def get_activity(
self, session: AsyncSession, activity_id: str,
) -> APActivityDTO | None:
a = (
await session.execute(
select(APActivity).where(APActivity.activity_id == activity_id)
)
).scalar_one_or_none()
return _activity_to_dto(a) if a else None
async def get_outbox(
self, session: AsyncSession, username: str,
page: int = 1, per_page: int = 20,
) -> tuple[list[APActivityDTO], 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(APActivity.id)).where(
APActivity.actor_profile_id == actor.id,
APActivity.is_local == True, # noqa: E712
)
)
).scalar() or 0
offset = (page - 1) * per_page
result = await session.execute(
select(APActivity)
.where(
APActivity.actor_profile_id == actor.id,
APActivity.is_local == True, # noqa: E712
)
.order_by(APActivity.published.desc())
.limit(per_page)
.offset(offset)
)
return [_activity_to_dto(a) for a in result.scalars().all()], total
async def get_activity_for_source(
self, session: AsyncSession, source_type: str, source_id: int,
) -> APActivityDTO | None:
a = (
await session.execute(
select(APActivity).where(
APActivity.source_type == source_type,
APActivity.source_id == source_id,
).order_by(APActivity.created_at.desc())
)
).scalar_one_or_none()
return _activity_to_dto(a) if a else None
# -- Followers ------------------------------------------------------------
async def get_followers(
self, session: AsyncSession, username: str,
) -> list[APFollowerDTO]:
actor = (
await session.execute(
select(ActorProfile).where(ActorProfile.preferred_username == username)
)
).scalar_one_or_none()
if actor is None:
return []
result = await session.execute(
select(APFollower).where(APFollower.actor_profile_id == actor.id)
)
return [_follower_to_dto(f) for f in result.scalars().all()]
async def add_follower(
self, session: AsyncSession, username: str,
follower_acct: str, follower_inbox: str, follower_actor_url: str,
follower_public_key: str | None = None,
) -> APFollowerDTO:
actor = (
await session.execute(
select(ActorProfile).where(ActorProfile.preferred_username == username)
)
).scalar_one_or_none()
if actor is None:
raise ValueError(f"Actor not found: {username}")
follower = APFollower(
actor_profile_id=actor.id,
follower_acct=follower_acct,
follower_inbox=follower_inbox,
follower_actor_url=follower_actor_url,
follower_public_key=follower_public_key,
)
session.add(follower)
await session.flush()
return _follower_to_dto(follower)
async def remove_follower(
self, session: AsyncSession, username: str, follower_acct: str,
) -> bool:
actor = (
await session.execute(
select(ActorProfile).where(ActorProfile.preferred_username == username)
)
).scalar_one_or_none()
if actor is None:
return False
result = await session.execute(
delete(APFollower).where(
APFollower.actor_profile_id == actor.id,
APFollower.follower_acct == follower_acct,
)
)
return result.rowcount > 0
# -- Stats ----------------------------------------------------------------
async def get_stats(self, session: AsyncSession) -> dict:
actors = (await session.execute(select(func.count(ActorProfile.id)))).scalar() or 0
activities = (await session.execute(select(func.count(APActivity.id)))).scalar() or 0
followers = (await session.execute(select(func.count(APFollower.id)))).scalar() or 0
return {"actors": actors, "activities": activities, "followers": followers}