Compare commits

...

2 Commits

Author SHA1 Message Date
giles
d697709f60 Tech debt cleanup: fix session.py, remove stale references, update docs
- db/session.py: fix indentation (2→4 space), pool_size=0 (unlimited),
  remove "ned to look at this" typo
- Remove glue.models from alembic env.py import list
- Update shared __init__.py, menu_item.py docstring, calendar_impl.py,
  handlers/__init__.py to remove glue terminology
- Remove federation_handlers.py tombstone file
- Remove TODO comments (replace with explanatory comments)
- Rewrite README.md to reflect current architecture
- Update anchoring.py TODO to plain comment

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 15:11:31 +00:00
giles
04f7c5e85c Add fediverse social features: followers/following lists, actor timelines
Adds get_followers_paginated and get_actor_timeline to FederationService
protocol + SQL implementation + stubs. Includes accumulated federation
changes: models, DTOs, delivery handler, webfinger, inline publishing,
widget nav templates, and migration.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 13:41:58 +00:00
21 changed files with 1974 additions and 81 deletions

View File

@@ -1,6 +1,6 @@
# Shared # Shared
Shared infrastructure, models, templates, and configuration used by all four Rose Ash microservices (blog, market, cart, events). Included as a git submodule in each app. Shared infrastructure, models, contracts, services, and templates used by all five Rose Ash microservices (blog, market, cart, events, federation). Included as a git submodule in each app.
## Structure ## Structure
@@ -8,20 +8,41 @@ Shared infrastructure, models, templates, and configuration used by all four Ros
shared/ shared/
db/ db/
base.py # SQLAlchemy declarative Base base.py # SQLAlchemy declarative Base
session.py # Async session factory (get_session) session.py # Async session factory (get_session, register_db)
models/ # Shared domain models models/ # Canonical domain models
user.py # User user.py # User
magic_link.py # MagicLink (auth tokens) magic_link.py # MagicLink (auth tokens)
domain_event.py # DomainEvent (transactional outbox) domain_event.py # DomainEvent (transactional outbox)
kv.py # KeyValue (key-value store) kv.py # KeyValue (key-value store)
menu_item.py # MenuItem menu_item.py # MenuItem (deprecated — use MenuNode)
menu_node.py # MenuNode (navigation tree)
container_relation.py # ContainerRelation (parent-child content)
ghost_membership_entities.py # GhostNewsletter, UserNewsletter ghost_membership_entities.py # GhostNewsletter, UserNewsletter
federation.py # ActorProfile, APActivity, APFollower, APFollowing,
# RemoteActor, APRemotePost, APLocalPost,
# APInteraction, APNotification, APAnchor, IPFSPin
contracts/
dtos.py # Frozen dataclasses for cross-domain data transfer
protocols.py # Service protocols (Blog, Calendar, Market, Cart, Federation)
widgets.py # Widget types (NavWidget, CardWidget, AccountPageWidget)
services/
registry.py # Typed singleton: services.blog, .calendar, .market, .cart, .federation
blog_impl.py # SqlBlogService
calendar_impl.py # SqlCalendarService
market_impl.py # SqlMarketService
cart_impl.py # SqlCartService
federation_impl.py # SqlFederationService
federation_publish.py # try_publish() — inline AP publication helper
stubs.py # No-op stubs for absent domains
navigation.py # get_navigation_tree()
relationships.py # attach_child, get_children, detach_child
widget_registry.py # Widget registry singleton
widgets/ # Per-domain widget registration
infrastructure/ infrastructure/
factory.py # create_base_app() — Quart app factory factory.py # create_base_app() — Quart app factory
cart_identity.py # current_cart_identity() (user_id or session_id) cart_identity.py # current_cart_identity() (user_id or session_id)
cart_loader.py # Cart data loader for context processors cart_loader.py # Cart data loader for context processors
context.py # Jinja2 context processors context.py # Jinja2 context processors
internal_api.py # Inter-app HTTP client (get/post via httpx)
jinja_setup.py # Jinja2 template environment setup jinja_setup.py # Jinja2 template environment setup
urls.py # URL helpers (coop_url, market_url, etc.) urls.py # URL helpers (coop_url, market_url, etc.)
user_loader.py # Load current user from session user_loader.py # Load current user from session
@@ -29,32 +50,36 @@ shared/
events/ events/
bus.py # emit_event(), register_handler() bus.py # emit_event(), register_handler()
processor.py # EventProcessor (polls domain_events, runs handlers) processor.py # EventProcessor (polls domain_events, runs handlers)
browser/app/ handlers/ # Shared event handlers
csrf.py # CSRF protection container_handlers.py # Navigation rebuild on attach/detach
errors.py # Error handlers login_handlers.py # Cart/entry adoption on login
middleware.py # Request/response middleware order_handlers.py # Order lifecycle events
redis_cacher.py # Tag-based Redis page caching ap_delivery_handler.py # AP activity delivery to follower inboxes
authz.py # Authorization helpers utils/
filters/ # Jinja2 template filters (currency, truncate, etc.) __init__.py
utils/ # HTMX helpers, UTC time, parsing calendar_helpers.py # Calendar period/entry utilities
payments/sumup.py # SumUp checkout API integration http_signatures.py # RSA keypair generation, HTTP signature signing/verification
browser/templates/ # ~300 Jinja2 templates shared across all apps ipfs_client.py # Async IPFS client (add_bytes, add_json, pin_cid)
config.py # YAML config loader anchoring.py # Merkle trees + OpenTimestamps Bitcoin anchoring
webfinger.py # WebFinger actor resolution
browser/
app/ # Middleware, CSRF, errors, Redis caching, authz, filters
templates/ # ~300 Jinja2 templates shared across all apps
containers.py # ContainerType, container_filter, content_filter helpers containers.py # ContainerType, container_filter, content_filter helpers
config.py # YAML config loader
log_config/setup.py # Logging configuration (JSON formatter) log_config/setup.py # Logging configuration (JSON formatter)
utils.py # host_url and other shared utilities
static/ # Shared static assets (CSS, JS, images, FontAwesome) static/ # Shared static assets (CSS, JS, images, FontAwesome)
editor/ # Koenig (Ghost) rich text editor build editor/ # Koenig (Ghost) rich text editor build
alembic/ # Database migrations (25 versions) alembic/ # Database migrations
env.py # Imports models from all apps (with try/except guards)
versions/ # Migration files — single head: j0h8e4f6g7
``` ```
## Key Patterns ## Key Patterns
- **App factory:** All apps call `create_base_app()` which sets up DB sessions, CSRF, error handling, event processing, logging, and the glue handler registry. - **App factory:** All apps call `create_base_app()` which sets up DB sessions, CSRF, error handling, event processing, logging, widget registration, and domain service wiring.
- **Service contracts:** Cross-domain communication via typed Protocols + frozen DTO dataclasses. Apps call `services.calendar.method()`, never import models from other domains.
- **Service registry:** Typed singleton (`services.blog`, `.calendar`, `.market`, `.cart`, `.federation`). Apps wire their own domain + stubs for others via `register_domain_services()`.
- **Event bus:** `emit_event()` writes to `domain_events` table in the caller's transaction. `EventProcessor` polls and dispatches to registered handlers. - **Event bus:** `emit_event()` writes to `domain_events` table in the caller's transaction. `EventProcessor` polls and dispatches to registered handlers.
- **Inter-app HTTP:** `internal_api.get/post("cart", "/internal/cart/summary")` for cross-app reads. URLs resolved from `app-config.yaml`. - **Widget registry:** Domain services register widgets (nav, card, account); templates consume via `widgets.container_nav`, `widgets.container_cards`.
- **Cart identity:** `current_cart_identity()` returns `{"user_id": int|None, "session_id": str|None}` from the request session. - **Cart identity:** `current_cart_identity()` returns `{"user_id": int|None, "session_id": str|None}` from the request session.
## Alembic Migrations ## Alembic Migrations
@@ -62,8 +87,5 @@ shared/
All apps share one PostgreSQL database. Migrations are managed here and run from the blog app's entrypoint (other apps skip migrations on startup). All apps share one PostgreSQL database. Migrations are managed here and run from the blog app's entrypoint (other apps skip migrations on startup).
```bash ```bash
# From any app directory (shared/ must be on sys.path)
alembic -c shared/alembic.ini upgrade head alembic -c shared/alembic.ini upgrade head
``` ```
Current head: `j0h8e4f6g7` (drop cross-domain FK constraints).

View File

@@ -1 +1 @@
# shared package — extracted from blog/shared_lib/ # shared package — infrastructure, models, contracts, and services

View File

@@ -19,7 +19,7 @@ from shared.db.base import Base
# Import ALL models so Base.metadata sees every table # Import ALL models so Base.metadata sees every table
import shared.models # noqa: F401 User, KV, MagicLink, MenuItem, Ghost* import shared.models # noqa: F401 User, KV, MagicLink, MenuItem, Ghost*
for _mod in ("blog.models", "market.models", "cart.models", "events.models", "federation.models", "glue.models"): for _mod in ("blog.models", "market.models", "cart.models", "events.models", "federation.models"):
try: try:
__import__(_mod) __import__(_mod)
except ImportError: except ImportError:

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

@@ -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 %}

View File

@@ -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 %}

View File

@@ -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

View File

@@ -22,6 +22,10 @@ from .dtos import (
ActorProfileDTO, ActorProfileDTO,
APActivityDTO, APActivityDTO,
APFollowerDTO, APFollowerDTO,
RemoteActorDTO,
RemotePostDTO,
TimelineItemDTO,
NotificationDTO,
) )
@@ -217,6 +221,11 @@ 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,
@@ -227,5 +236,108 @@ 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]: ...
async def get_actor_timeline(
self, session: AsyncSession, remote_actor_id: int,
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: ...

View File

@@ -15,7 +15,7 @@ _engine = create_async_engine(
future=True, future=True,
echo=False, echo=False,
pool_pre_ping=True, pool_pre_ping=True,
pool_size=-1 # ned to look at this!!! pool_size=0, # 0 = unlimited (NullPool equivalent for asyncpg)
) )
_Session = async_sessionmaker( _Session = async_sessionmaker(
@@ -34,43 +34,42 @@ async def get_session():
await sess.close() await sess.close()
def register_db(app: Quart): def register_db(app: Quart):
@app.before_request @app.before_request
async def open_session(): async def open_session():
g.s = _Session() g.s = _Session()
g.tx = await g.s.begin() g.tx = await g.s.begin()
g.had_error = False g.had_error = False
@app.after_request @app.after_request
async def maybe_commit(response): async def maybe_commit(response):
# Runs BEFORE bytes are sent. # Runs BEFORE bytes are sent.
if not g.had_error and 200 <= response.status_code < 400: if not g.had_error and 200 <= response.status_code < 400:
try: try:
if hasattr(g, "tx"): if hasattr(g, "tx"):
await g.tx.commit() await g.tx.commit()
except Exception as e: except Exception as e:
print(f'commit failed {e}') print(f'commit failed {e}')
if hasattr(g, "tx"): if hasattr(g, "tx"):
await g.tx.rollback() await g.tx.rollback()
from quart import make_response from quart import make_response
return await make_response("Commit failed", 500) return await make_response("Commit failed", 500)
return response return response
@app.teardown_request @app.teardown_request
async def finish(exc): async def finish(exc):
try: try:
# If an exception occurred OR we didn't commit (still in txn), roll back. # If an exception occurred OR we didn't commit (still in txn), roll back.
if hasattr(g, "s"): if hasattr(g, "s"):
if exc is not None or g.s.in_transaction(): if exc is not None or g.s.in_transaction():
if hasattr(g, "tx"): if hasattr(g, "tx"):
await g.tx.rollback() await g.tx.rollback()
finally: finally:
if hasattr(g, "s"): if hasattr(g, "s"):
await g.s.close() await g.s.close()
@app.errorhandler(Exception) @app.errorhandler(Exception)
async def mark_error(e): async def mark_error(e):
g.had_error = True g.had_error = True
raise raise

View File

@@ -1,4 +1,4 @@
"""Shared event handlers (replaces glue.setup.register_glue_handlers).""" """Shared event handlers."""
def register_shared_handlers(): def register_shared_handlers():
@@ -6,5 +6,4 @@ 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 import shared.events.handlers.ap_delivery_handler # noqa: F401

View File

@@ -38,7 +38,8 @@ def _build_activity_json(activity: APActivity, actor: ActorProfile, domain: str)
obj.setdefault("type", "Tombstone") obj.setdefault("type", "Tombstone")
else: else:
# Create/Update: full object with attribution # Create/Update: full object with attribution
obj["id"] = object_id # 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("type", activity.object_type)
obj.setdefault("attributedTo", actor_url) obj.setdefault("attributedTo", actor_url)
obj.setdefault("published", activity.published.isoformat() if activity.published else None) obj.setdefault("published", activity.published.isoformat() if activity.published else None)

View File

@@ -1,8 +0,0 @@
"""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.
"""

View File

@@ -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,
) )

View File

@@ -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"),
)

View File

@@ -6,7 +6,7 @@ from shared.db.base import Base
class MenuItem(Base): class MenuItem(Base):
"""Deprecated — kept so the table isn't dropped. Use glue.models.MenuNode.""" """Deprecated — kept so the table isn't dropped. Use shared.models.menu_node.MenuNode."""
__tablename__ = "menu_items" __tablename__ = "menu_items"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)

View File

@@ -371,7 +371,7 @@ class SqlCalendarService:
entries_by_post.setdefault(post_id, []).append(_entry_to_dto(entry)) entries_by_post.setdefault(post_id, []).append(_entry_to_dto(entry))
return entries_by_post return entries_by_post
# -- writes (absorb glue lifecycle) --------------------------------------- # -- writes ---------------------------------------------------------------
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,

File diff suppressed because it is too large Load Diff

View File

@@ -8,6 +8,7 @@ which creates the APActivity in the same DB transaction. AP delivery
from __future__ import annotations from __future__ import annotations
import logging import logging
import os
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -48,10 +49,20 @@ async def try_publish(
if existing: if existing:
if activity_type == "Create" and existing.activity_type != "Delete": if activity_type == "Create" and existing.activity_type != "Delete":
return # already published (allow re-Create after Delete/unpublish) 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": if activity_type == "Delete" and existing.activity_type == "Delete":
return # already deleted return # already deleted
elif activity_type == "Delete": elif activity_type in ("Delete", "Update"):
return # never published, nothing to delete 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: try:
await services.federation.publish_activity( await services.federation.publish_activity(

View File

@@ -227,5 +227,71 @@ 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 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):
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 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):
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}

View File

@@ -145,7 +145,7 @@ async def upgrade_ots_proof(proof_bytes: bytes) -> tuple[bytes, bool]:
""" """
# OpenTimestamps upgrade is done via the `ots` CLI or their calendar API. # OpenTimestamps upgrade is done via the `ots` CLI or their calendar API.
# For now, return the proof as-is with is_confirmed=False. # For now, return the proof as-is with is_confirmed=False.
# TODO: Implement calendar-based upgrade polling. # Calendar-based upgrade polling not yet implemented.
return proof_bytes, False return proof_bytes, False

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