Compare commits
2 Commits
widget-pha
...
d697709f60
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d697709f60 | ||
|
|
04f7c5e85c |
72
README.md
72
README.md
@@ -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).
|
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
# shared package — extracted from blog/shared_lib/
|
# shared package — infrastructure, models, contracts, and services
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
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")
|
||||||
@@ -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 %}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -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: ...
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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.
|
|
||||||
"""
|
|
||||||
@@ -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"),
|
||||||
|
)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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(
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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
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