45 Commits

Author SHA1 Message Date
d1ce277ff5 chore: bump to 0.4.0, update changelog
All checks were successful
CI / fmt (push) Successful in 21s
CI / clippy (push) Successful in 2m51s
CI / test (push) Successful in 3m49s
2026-05-30 02:50:46 +02:00
4cb8efb6ce feat: SSRF protection — block private IP ranges on outgoing requests
Some checks failed
CI / fmt (push) Successful in 23s
CI / test (push) Has been cancelled
CI / clippy (push) Has been cancelled
SsrfVerifier rejects private/reserved IPs (loopback, RFC1918, link-local,
CGNAT, ULA) on all federation fetches. Raw reqwest calls in webfinger and
backfill also validated. Debug mode bypasses via PermissiveVerifier.

Closes #4
2026-05-30 02:48:35 +02:00
7171a1791a feat: actor cache TTL with staleness-aware re-fetch
Adds fetched_at to RemoteActor, configurable TTL via builder
(.actor_cache_ttl_secs, default 24h), and get_or_refresh_remote_actor
helper that re-fetches stale actors from origin.

Closes #3
2026-05-30 02:46:54 +02:00
f08d11034d feat: expose signed_fetch for authorized-fetch / Secure Mode
Builder: .signed_fetch_actor_id(uuid) sets instance-level signing actor.
Service: .signed_fetch(&url) performs a signed GET returning raw JSON.

Closes #2
2026-05-30 02:43:51 +02:00
9f9c4e769b fix: persist inbound Block to blocklist, clear on Undo(Block)
Closes #1
2026-05-30 02:39:14 +02:00
ca949691e4 ci: add Gitea CI workflow (fmt, clippy, tests)
All checks were successful
CI / fmt (push) Successful in 18s
CI / clippy (push) Successful in 2m37s
CI / test (push) Successful in 3m43s
2026-05-30 02:26:54 +02:00
62c9bf2e4e fix: add missing RemoteActor fields in get_blocked_actors fallback 2026-05-29 04:04:08 +02:00
485c407edb feat(RemoteActor): add bio, banner_url, followers_url, following_url, also_known_as fields
Bump to 0.3.1. These fields are available on DbActor at follow/ingest
time but were discarded when constructing RemoteActor. Now populated
in from_json and follow(), so consuming repos can store and return
rich actor profiles without extra queries.
2026-05-29 04:03:23 +02:00
fad95f0550 docs: update README with Gitea registry installation 2026-05-29 03:36:22 +02:00
c1c8a37d0d fix: remove actor/followers/following routes from router()
These paths need content negotiation in real apps (AP JSON vs UI JSON).
k-ap can't serve the UI half, so the consuming app owns the route and
calls actor_json/followers_collection_json/following_collection_json
to produce the AP response.

The route conflict caused a panic when thoughts mounted its own
/users/{username}/... routes alongside service.router().

router() now registers only what k-ap fully owns:
- POST /inbox, POST /users/{id}/inbox (signature verification)
- GET /users/{id}/outbox
- GET /users/{id}/featured
- GET /.well-known/webfinger, nodeinfo, /nodeinfo/2.0
2026-05-29 03:28:17 +02:00
757c6d14ec fix 2026-05-29 03:20:21 +02:00
88fd1bfbdc docs: add CHANGELOG for v0.3.0 2026-05-29 02:49:23 +02:00
90ea438764 docs: update README to reflect current API state
- also_known_as: Vec<String> (was Option<String>)
- broadcast_create_note/update_note: add mentioned_inboxes param + example
- EventPublisher: add BackfillRequested variant to match/example
- Add run_backfill_for_follower and import_remote_outbox sections
- Add featured collection section (get_featured_objects override)
- Expand routes table to include /featured endpoint
- Add count_accepted_followers + get_accepted_followers_page to follow section
- FederationEvent table: add BackfillRequested
- ApObjectHandler/ApContentReader: note which methods have defaults
- Inbound section: mention Undo(Announce) and Move improvements
2026-05-29 02:46:16 +02:00
f00514850b test: add 31 meaningful unit tests for business logic
Activity receive() tests (src/tests/activities.rs):
- Accept: updates following_status to Accepted with correct user/actor
- Reject: removes following with correct user/actor
- Undo(Follow): removes follower + calls on_actor_removed
- Undo(Like): calls on_unlike for local objects; ignores remote objects
- Undo(Announce): removes announce record + calls on_announce_removed for local;
                  removes record but skips notification for remote objects
- Create: uses object["id"] not activity id; mention fires on_mention + on_create
- Update: uses object["id"]
- Delete(object): calls on_delete; does NOT call on_actor_removed
- Delete(actor): calls on_actor_removed; does NOT call on_delete
- Announce(local): records announce + calls on_announce_received
- Announce(remote): calls on_announce_of_remote; does NOT record announce
- Like(local): calls on_like
- Like(remote): silently ignored
- Add: uses object["id"] not activity id
- Block: removes both following and follower
- Domain block: activity skipped before any processing
- Actor block: Follow skipped before HTTP dereference (SSRF fix)
- Idempotency: duplicate delivery skipped

Actor serialization tests (src/tests/actors.rs):
- actor_type=Service serializes as "Service"
- discoverable=false serializes
- also_known_as serializes as JSON array (all aliases, not just first)
- optional fields omitted when None
- featured URL serialized when set

Visibility addressing tests (src/tests/broadcast.rs):
- Public: to=[AS_PUBLIC], cc=[followers]
- FollowersOnly: to=[followers], cc=[] — AS_PUBLIC absent
- Private: both empty
2026-05-29 02:44:23 +02:00
48fded426f fix: AP protocol correctness gaps
Undo(Announce): now removes announce record from ActorRepository and
  calls ApObjectHandler::on_announce_removed (default no-op, override
  to decrement boost counts). Announce counts no longer drift.

Undo(Block): now logged at info level instead of silently ignored.
  No automatic relationship restoration (spec doesn't require it).

AddActivity: now uses object["id"] as the stable ap_id (same as
  CreateActivity), falling back to activity id only if object has no
  id field. Fixes keying watchlist/collection items by the wrong id.

Featured collection: GET /users/{id}/featured now served by the router.
  ApContentReader::get_featured_objects() has a default empty-list impl
  — override to expose pinned posts without any breaking changes.
2026-05-29 02:29:38 +02:00
5288696795 fix: pre-release improvements — scale, correctness, API clarity
#1  count_accepted_followers / get_accepted_followers_page: new DB-side
    methods on FollowRepository — no more loading all followers into memory
    to count or page them.

#2  Move re-follows are now non-blocking: tokio::spawn instead of
    awaiting each sign_and_send inside receive() — inbox handler no longer
    stalls for slow remote servers during account migration.

#3  Remove get_local_objects_for_user from ApContentReader (dead code).
    Backfill and outbox both use the paginated get_local_objects_page.

#6  Rename backfill_outbox → import_remote_outbox with a clear doc
    explaining the direction (import FROM a remote server, not to a follower).

#7  also_known_as: Option<String> → Vec<String> on ApUser, LookedUpActor,
    and DbActor. from_json now stores all aliases; move_act.rs checks all.

Mentions: broadcast_create_note / broadcast_update_note now accept
    mentioned_inboxes: Vec<Url> — delivery goes to followers + mentioned
    actors who aren't already followers. Deduplication is done before
    sending. Pass vec![] if note has no external mentions.

Docs: ApObjectHandler and ApContentReader now have complete doc comments
    with contracts, idempotency guidance, and error-handling semantics.
2026-05-29 02:19:39 +02:00
d5f75b4b57 feat: route backfill through EventPublisher; add run_backfill_for_follower 2026-05-29 02:05:03 +02:00
0519bed66c docs: update README to v0.3.0; add docs/ to .gitignore 2026-05-29 01:56:03 +02:00
2ee0452fa8 chore: add Makefile with check/fmt/clippy/test/fix targets; apply rustfmt 2026-05-29 01:53:56 +02:00
73a68860c1 style: clippy fixes and linter formatting 2026-05-29 01:52:14 +02:00
df6ff4c1e8 refactor!: CQRS repository split — v0.3.0
FederationRepository (34 methods) → 4 focused traits:
  ActivityRepository  (2)  — idempotency tracking
  FollowRepository    (18) — follower/following graph + migration
  ActorRepository     (6)  — keypairs, remote actor cache, announce tracking
  BlocklistRepository (8)  — domain + actor blocklists

ApObjectHandler (10 methods) → 2 traits:
  ApContentReader  (3) — get_local_objects_for_user/page, count_local_posts
  ApObjectHandler  (9) — all inbox callbacks (on_create, on_mention, etc.)

Builder changes from positional args to named setters:
  ActivityPubService::builder(base_url)
    .activity_repo(arc)
    .follow_repo(arc)
    .actor_repo(arc)
    .blocklist_repo(arc)
    .user_repo(arc)
    .content_reader(arc)
    .object_handler(arc)
    .build()

No behaviour changes.
2026-05-29 01:47:23 +02:00
e11b0a6609 feat: add ApVisibility (Public/FollowersOnly/Private) to broadcast_create_note and broadcast_update_note 2026-05-29 01:15:26 +02:00
4ef1315671 feat: add discoverable field to ApUser — no longer hard-coded to true 2026-05-29 01:11:55 +02:00
7424d1dc54 fix: address 3 PARTIAL plan items
#15 @context security vocab: actor JSON now uses actor_ap_context()
     which includes W3C security vocab + Mastodon toot extensions
     (manuallyApprovesFollowers, discoverable, featured).
     Applied to actor_handler, actor_json(), broadcast_actor_update().
     Activity JSON keeps plain AS context (no security vocab needed).

#17 HTTP Digest (documented, no code change): production mode
     (debug=false) REQUIRES Digest header on inbound POSTs via
     require_digest() in the non-compat normalization config.
     Added doc comment to ApFederationConfig::new() to clarify.

#26 Integration tests: 3 new tokio tests in src/tests/integration.rs
     using in-memory trait stubs. Tests cover:
     - check_guards idempotency (duplicate activity rejected)
     - check_guards domain block (blocked domain skipped)
     - extract_and_dispatch_mentions (on_mention called for local actor)
2026-05-29 01:00:45 +02:00
db6a451788 fix: address remaining 3 NOT DONE plan items
#18 featured collection: add featured_url to ApUser/DbActor/Person;
     serialized as featured field in AP JSON when set by consumer.

#19 Tombstone in Delete: broadcast_delete_to_followers now sends
     {"type":"Tombstone","id":"..."} instead of bare URL string.

#21 Backfill pagination: run_backfill uses get_local_objects_page
     with cursor-based loop — avoids loading all posts into memory;
     delivers newest-to-oldest in BATCH_SIZE chunks.
2026-05-29 00:52:37 +02:00
aec768b5a0 refactor(service): split into delivery/broadcast/follow/backfill submodules; remove dead content_to_html 2026-05-29 00:28:48 +02:00
90a0d91b39 refactor(activities): split into per-activity files with check_guards DRY helper 2026-05-29 00:17:31 +02:00
7ccc18e85c feat: production hardening — security, scale, protocol, DX
Breaking changes to FederationRepository, ApObjectHandler, ApUser:

FederationRepository:
- add is_activity_processed / mark_activity_processed (inbox idempotency)
- add get_accepted_follower_inboxes (DB-side dedup/filtering, replaces in-memory load-all)

ApObjectHandler:
- add on_announce_of_remote (cross-server boosts, previously silently dropped)

ApUser:
- add manually_approves_followers: bool
- add actor_type: ApActorType (was hardcoded Person)

Security:
- block check before actor HTTP fetch in Follow (prevents SSRF on blocked actors)
- 4xx responses use generic "not found"/"bad request" (no internal leak)
- 1 MB DefaultBodyLimit on inbox routes
- zeroize private key after generation

Delivery:
- all broadcasts are now non-blocking (tokio::spawn fallback, or EventPublisher queue)
- EventPublisher redesigned with typed FederationEvent enum (DeliveryRequested/DeliveryFailed)
- new deliver_to_inbox() public method for queue consumers
- configurable delivery_max_attempts and delivery_initial_delay_secs via builder
- Follow saved as Pending BEFORE delivery (race condition fix)

Router:
- GET /users/{id} (actor), GET /users/{id}/followers, GET /users/{id}/following now mounted

Protocol:
- mention extraction from Create/Update tag arrays → on_mention() dispatched
- WebFinger: add aliases field (acct: URI + AP actor URL)
- outbox: add last link, use count_local_posts for totalItems
- idempotency guard added to every inbound activity receive()
- actor serializes display_name and configurable actor_type/manually_approves_followers

Bump: 0.1.10 → 0.2.0
2026-05-28 23:35:41 +02:00
b557bd9d46 docs: update README to v0.1.10 — add new methods, fix signatures, document Move handling 2026-05-28 02:50:05 +02:00
d80cfd0431 feat: add mark_follower_accepted/rejected to ActivityPubService 2026-05-28 02:44:05 +02:00
432f39cbb4 feat: add broadcast_move to ActivityPubService; bump to v0.1.9 2026-05-28 01:41:13 +02:00
2c509cbf88 feat: implement MoveActivity::receive with record migration and re-follow 2026-05-28 01:37:50 +02:00
52614d406a feat(repository): add migrate_follower_actor to FederationRepository 2026-05-28 01:33:13 +02:00
1949fce620 fix: accept follow for migrated actor URLs via UUID lookup 2026-05-28 00:30:58 +02:00
699258f830 feat: add targeted tracing logs for actor lookup and verification 2026-05-27 22:55:39 +02:00
9412a9739a fix: allow www. apex equivalence in actor domain verification
Threads serves actors at threads.net but their id field uses www.threads.net.
Extract apex_domain() helper and fall back to apex comparison when the
strict verify_domains_match check fails.
2026-05-27 22:49:30 +02:00
13111c10b9 chore: bump version to 0.1.5 2026-05-27 22:37:55 +02:00
2e3b6d5cd4 fix: accept optional outbox/followers/following and any AP actor type
Person struct now deserializes gracefully when outbox, followers, or
following are absent (Threads omits them for some actors). Accepts
Service/Application/Organization/Group in addition to Person.
manually_approves_followers defaults to false when absent.
2026-05-27 22:37:49 +02:00
bc857b2c08 feat: signed actor lookup and display_name on DbActor
Add display_name field to DbActor, populated from AP Person.name in
from_json. Expose LookedUpActor type and lookup_actor_by_handle method
on ActivityPubService — uses the existing signed webfinger_https path
so strict instances (Threads, etc.) return full actor data.
2026-05-27 22:21:58 +02:00
7901b29f7c fix(actors): populate profile fields in read_from_id 2026-05-24 00:32:00 +02:00
Gabriel
a604e1bd40 docs: add README 2026-05-17 23:16:13 +02:00
Gabriel
f5374ec861 feat: add followers/following collection json methods 2026-05-17 22:58:30 +02:00
Gabriel
cc30582a1c feat: add broadcast_create_note, broadcast_update_note, base_url() accessor 2026-05-17 22:56:57 +02:00
Gabriel
f8dc20c026 gitignore 2026-05-17 22:54:03 +02:00
Gabriel
630cffe33f feat: k-ap public API, no ap_ports 2026-05-17 22:31:23 +02:00
55 changed files with 9625 additions and 2475 deletions

32
.gitea/workflows/ci.yml Normal file
View File

@@ -0,0 +1,32 @@
name: CI
on:
push:
branches: [master]
pull_request:
jobs:
fmt:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt
- run: cargo fmt --check
clippy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy
- run: cargo clippy -- -D warnings
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- run: cargo test

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
/target
Cargo.lock
docs/

192
CHANGELOG.md Normal file
View File

@@ -0,0 +1,192 @@
# Changelog
## [0.4.0] — 2026-05-30
### Breaking changes
**`RemoteActor` has a new required field `fetched_at: Option<DateTime<Utc>>`** — set to `Some(Utc::now())` when fetched from a remote instance, or `None` for locally-constructed actors. Consumers must add this column to their `upsert_remote_actor` / `get_remote_actor` implementations.
**`ApFederationConfig::new()` signature changed** — now takes an additional `signing_actor: Option<&DbActor>` parameter. Internal to consumers using `ApFederationConfig` directly; builder users are unaffected.
**`FederationData::new()` takes an additional `actor_cache_ttl: Duration` parameter** — only affects consumers constructing `FederationData` directly (e.g. tests).
---
### New features
**Signed fetch for authorized-fetch / Secure Mode** — set `.signed_fetch_actor_id(uuid)` on the builder to sign all outgoing GET requests with that actor's keypair. Call `service.signed_fetch(&url)` to fetch any remote AP resource with signatures.
**Actor cache TTL**`fetched_at` is now tracked on `RemoteActor`. Configure staleness via `.actor_cache_ttl_secs(secs)` (default: 24h). Use `get_or_refresh_remote_actor(actor_url)` for TTL-aware lookups that re-fetch stale actors from origin.
**SSRF protection** — all outgoing HTTP requests (federation fetches, WebFinger, backfill) now validate resolved IPs against private/reserved ranges (127/8, 10/8, 172.16/12, 192.168/16, 169.254/16, CGNAT 100.64/10, ::1, fc00::/7, fe80::/10). Debug mode bypasses this check.
---
### Bug fixes
**Inbound `Block` now persists to `BlocklistRepository`**`BlockActivity::receive()` calls `add_blocked_actor()` after removing follower/following relationships. `Undo(Block)` clears the record via `remove_blocked_actor()`.
---
## [0.3.1] — 2026-05-29
### Breaking changes
**`RemoteActor` has five new required fields** — struct literals must include them:
| Field | Type | Description |
|-------|------|-------------|
| `bio` | `Option<String>` | Actor biography/summary |
| `banner_url` | `Option<String>` | Banner/header image URL |
| `followers_url` | `Option<String>` | AP followers collection URL |
| `following_url` | `Option<String>` | AP following collection URL |
| `also_known_as` | `Vec<String>` | Account aliases (for Move verification) |
These are populated automatically when k-ap fetches a remote actor (via `from_json`) and when the local `follow()` method constructs a `RemoteActor` from the fetched `DbActor`. Consuming applications only need to add the new fields to their `upsert_remote_actor` / `get_remote_actor` SQL and any custom `RemoteActor` construction sites.
---
## [0.3.0] — 2026-05-28
### Breaking changes
**Builder API** — the service builder no longer takes positional arguments. All repos are named setters:
```rust
// Before (0.2.x)
ActivityPubService::builder(repo, user_repo, handler, "https://example.com")
// After (0.3.0)
ActivityPubService::builder("https://example.com")
.activity_repo(db.clone())
.follow_repo(db.clone())
.actor_repo(db.clone())
.blocklist_repo(db.clone())
.user_repo(db.clone())
.content_reader(db.clone())
.object_handler(db.clone())
.build()
.await?
```
**`FederationRepository` split into 4 focused traits** — implement each independently or all on one struct:
| Old | New |
|-----|-----|
| `FederationRepository` (34 methods) | `ActivityRepository` (2) |
| | `FollowRepository` (18) |
| | `ActorRepository` (6) |
| | `BlocklistRepository` (8) |
**`ApObjectHandler` split into read/write traits:**
| Old | New |
|-----|-----|
| `ApObjectHandler::get_local_objects_for_user` | removed (dead code) |
| `ApObjectHandler::get_local_objects_page` | `ApContentReader::get_local_objects_page` |
| `ApObjectHandler::count_local_posts` | `ApContentReader::count_local_posts` |
| `ApObjectHandler` (callbacks) | `ApObjectHandler` (9 callbacks, unchanged) |
`ApContentReader` also has a new optional method with a default empty impl:
- `get_featured_objects(user_id) -> Vec<Url>` — override to expose pinned posts
**`broadcast_create_note` and `broadcast_update_note` — new `mentioned_inboxes` parameter:**
```rust
// Before
service.broadcast_create_note(user_id, note_json, ApVisibility::Public).await?;
// After — pass inboxes of mentioned non-followers, or vec![] if none
service.broadcast_create_note(user_id, note_json, ApVisibility::Public, mentioned_inboxes).await?;
```
**`ApUser::also_known_as` changed from `Option<String>` to `Vec<String>`** — stores all aliases, not just the first.
**`LookedUpActor::also_known_as` changed from `Option<String>` to `Vec<String>`.**
**`backfill_outbox` renamed to `import_remote_outbox`** — clarifies direction: imports content FROM a remote server into your instance, distinct from `run_backfill_for_follower` which sends your content TO a new follower.
**`FollowRepository` — two new required methods:**
- `count_accepted_followers(user_id) -> usize` — DB-side count, replaces loading all followers into memory
- `get_accepted_followers_page(user_id, offset, limit) -> Vec<RemoteActor>` — DB-side paginated listing
**`ActorRepository` — one new required method:**
- `remove_announce(activity_id, actor_url)` — called when `Undo(Announce)` is received
---
### New features
**`ApVisibility`** — controls `to`/`cc` addressing and delivery scope for Create/Update:
- `Public``to: [AS_PUBLIC], cc: [followers]`
- `FollowersOnly``to: [followers], cc: []`
- `Private` — no delivery at all
**Mention delivery**`broadcast_create_note` and `broadcast_update_note` accept `mentioned_inboxes: Vec<Url>`. Delivery goes to followers + mentioned non-followers, deduplicated.
**`ApUser::discoverable: bool`** — serialized as `discoverable` in actor JSON (was hard-coded `true`).
**`ApUser::actor_type: ApActorType`** — serialized as the AP actor type (was hard-coded `Person`).
**`ApUser::featured_url: Option<Url>`** — serialized as `featured` in actor JSON. The library serves `GET /users/{id}/featured` automatically.
**`GET /users/{id}/featured` route** — serves an `OrderedCollection` of pinned posts via `ApContentReader::get_featured_objects`. Default is an empty collection.
**`router()` no longer registers `GET /users/{id}`, `/followers`, or `/following`** — these paths need content negotiation (AP JSON vs UI JSON) which k-ap can't do. Your application owns those routes and calls `actor_json`, `followers_collection_json`, `following_collection_json` to produce the AP response. See README for the pattern.
**`FederationEvent::BackfillRequested`** — when an `EventPublisher` is configured, `accept_follower` publishes this event instead of spawning an in-process task. Process it by calling `run_backfill_for_follower`.
**`run_backfill_for_follower(owner_user_id, follower_inbox_url)`** — public method for workers processing `BackfillRequested` events.
**`ApObjectHandler::on_announce_removed`** — called when `Undo(Announce)` is received for a locally-authored object. Default is a no-op — override to update boost counts.
**`ApObjectHandler::on_announce_of_remote`** — called when a remote actor boosts a non-local object. Default is a no-op.
**`count_accepted_followers` / `get_accepted_followers_page`** — service methods now use DB-side queries via the new `FollowRepository` methods instead of loading all followers into memory.
---
### Bug fixes
**`AddActivity` used activity ID instead of object ID** — `on_create` was receiving the Add activity's `id` instead of `object["id"]`. Now matches `CreateActivity` behaviour.
**`Undo(Announce)` was silently ignored** — now removes the announce record from `ActorRepository` and calls `on_announce_removed`. Announce counts no longer drift.
**`Move` re-follows blocked the inbox handler** — re-follow HTTP requests are now spawned in the background so the inbox handler returns immediately.
**`alsoKnownAs` truncated to first alias** — `from_json` now stores all aliases from the incoming actor JSON.
**`Move` alsoKnownAs check now verifies all aliases** — previously only checked the first one.
**Block check before actor HTTP fetch**`FollowActivity::receive` now checks per-actor blocks before calling `dereference()`, preventing SSRF from blocked actors.
---
### Other improvements
- 1 MB `DefaultBodyLimit` on inbox routes — prevents memory exhaustion from oversized payloads
- 4xx error responses now use generic messages — internal details are only logged, never sent to clients
- Actor JSON includes W3C security vocabulary in `@context` — required for public key resolution by strict implementations
- WebFinger response includes `aliases` field
- Outbox `last` link added to root `OrderedCollection`
- Outbox count uses `count_local_posts()` — no longer loads all objects to count them
- Backfill uses cursor-based `get_local_objects_page` — no longer loads all posts into memory
- All inbound activities are deduplicated by activity `id` via `ActivityRepository`
- Mentions extracted from `tag` arrays in `Create`/`Update` and dispatched via `on_mention`
- Cross-server `Announce` dispatched via `on_announce_of_remote` instead of silently dropped
- `discoverable`, `actor_type`, `featured_url`, `manually_approves_followers` all configurable per-user
- `delivery_max_attempts` and `delivery_initial_delay_secs` configurable via builder
- `Makefile` with `check`, `fmt`, `clippy`, `test`, `fix` targets
---
## [0.1.10] — 2024 (previous release)
Initial public extraction from `thoughts` and `movies-diary`.
- `FederationRepository`, `ApUserRepository`, `ApObjectHandler` — single-struct trait interface
- Inbound: Follow, Accept, Reject, Undo, Create, Update, Delete, Announce, Like, Add, Block, Move
- Outbound broadcasts: Create, Update, Delete, Announce, Like, Move, actor update
- WebFinger, NodeInfo 2.0, shared inbox, follower/following collections
- Signed WebFinger resolution for actor lookup
- Account migration (Move) with alsoKnownAs verification and re-follow

3286
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "k-ap" name = "k-ap"
version = "0.1.0" version = "0.4.0"
edition = "2024" edition = "2024"
description = "Generic ActivityPub protocol layer" description = "Generic ActivityPub protocol layer"
license = "MIT" license = "MIT"
@@ -21,3 +21,4 @@ reqwest = { version = "0.13", features = ["json"] }
url = { version = "2", features = ["serde"] } url = { version = "2", features = ["serde"] }
enum_delegate = "0.2" enum_delegate = "0.2"
activitypub_federation = "0.7.0-beta.11" activitypub_federation = "0.7.0-beta.11"
zeroize = { version = "1", features = ["derive"] }

28
Makefile Normal file
View File

@@ -0,0 +1,28 @@
.DEFAULT_GOAL := check
# Run the full local check suite — same order as CI would.
check: fmt-check clippy test
@echo "✅ All checks passed"
# Apply rustfmt to all files.
fmt:
cargo fmt
# Check formatting without modifying files (CI-safe).
fmt-check:
cargo fmt --check
# Run Clippy and treat warnings as errors.
clippy:
cargo clippy -- -D warnings
# Run the test suite.
test:
cargo test
# Apply fmt + clippy auto-fixes in one shot.
fix:
cargo fmt
cargo clippy --fix --allow-dirty --allow-staged
.PHONY: check fmt fmt-check clippy test fix

313
README.md Normal file
View File

@@ -0,0 +1,313 @@
# k-ap
Generic ActivityPub protocol layer for Rust services. Extracted from the `thoughts` and `movies-diary` projects.
Wraps [`activitypub_federation`](https://crates.io/crates/activitypub_federation) and provides the plumbing that every AP-enabled service needs: actor management, inbox/outbox routing, follower tracking, WebFinger, NodeInfo, and HTTP signature handling.
Not domain-specific — no opinions about what your content type looks like.
## Add as dependency
Via the private Gitea registry (recommended):
```toml
[dependencies]
k-ap = { version = "0.3.0", registry = "gitea" }
```
Configure the registry in `.cargo/config.toml`:
```toml
[registries.gitea]
index = "sparse+https://git.gabrielkaszewski.dev/api/packages/GKaszewski/cargo/"
```
Or via git if you don't have registry access:
```toml
[dependencies]
k-ap = { git = "https://git.gabrielkaszewski.dev/GKaszewski/k-ap.git", tag = "v0.3.0" }
```
## What you implement
Seven focused traits wire your data layer into `k-ap`. Implement them all on a single database struct by cloning the `Arc`, or use separate structs for different backends.
```rust
// Activity deduplication — idempotency for inbound deliveries
impl ActivityRepository for MyDb { ... }
// Follower / following graph + account migration
impl FollowRepository for MyDb { ... }
// Local keypairs, remote actor cache, boost (Announce) tracking
impl ActorRepository for MyDb { ... }
// Domain and per-user actor blocklists
impl BlocklistRepository for MyDb { ... }
// User lookup by id / username
impl ApUserRepository for MyDb { ... }
// Read side — provides local content to the library (outbox, backfill, featured)
impl ApContentReader for MyDb { ... }
// Write side — called when the inbox receives AP activities
impl ApObjectHandler for MyDb { ... }
```
## Wire up the service
```rust
use std::sync::Arc;
use k_ap::ActivityPubService;
let db = Arc::new(MyDb::new(...));
let service = ActivityPubService::builder("https://example.com")
.activity_repo(db.clone())
.follow_repo(db.clone())
.actor_repo(db.clone())
.blocklist_repo(db.clone())
.user_repo(db.clone())
.content_reader(db.clone())
.object_handler(db.clone())
.allow_registration(false)
.software_name("my-app")
.build()
.await?;
// Mount the AP routes onto your axum router
let router = Router::new().merge(service.router());
```
## What the service handles for you
`service.router()` registers only the routes k-ap fully owns:
| Route | Description |
|-------|-------------|
| `POST /inbox` | Shared inbox — HTTP signature verification + dispatch, 1 MB limit |
| `POST /users/{id}/inbox` | Per-user inbox — same |
| `GET /users/{id}/outbox` | Cursor-based `OrderedCollection` |
| `GET /users/{id}/featured` | Pinned posts `OrderedCollection` |
| `GET /.well-known/webfinger` | JRD with `aliases` field |
| `GET /.well-known/nodeinfo` | NodeInfo well-known redirect |
| `GET /nodeinfo/2.0` | NodeInfo 2.0 |
**Not registered by `router()`:** `GET /users/{id}`, `GET /users/{id}/followers`, `GET /users/{id}/following`.
These paths are dual-purpose in real applications — they must serve both AP JSON (`application/activity+json`) and the app's own UI JSON (content negotiation). k-ap can't do the UI half, so your application owns the route and calls k-ap's helper methods to produce the AP response:
```rust
// In your axum actor handler — serve AP JSON or UI JSON based on Accept header
async fn actor_handler(Path(username): Path<String>, headers: HeaderMap, ...) {
if wants_ap_json(&headers) {
let json = service.actor_json(&user.id.to_string()).await?;
return ap_json_response(json);
}
// ... serve UI response
}
// Similarly for followers/following:
let json = service.followers_collection_json(user_id, page).await?;
let json = service.following_collection_json(user_id, page).await?;
```
## ApUser fields
Your `ApUserRepository` returns `ApUser`. All fields control how the actor is serialized:
```rust
ApUser {
id: uuid,
username: String,
display_name: Option<String>,
bio: Option<String>,
avatar_url: Option<Url>,
banner_url: Option<Url>,
also_known_as: Vec<String>, // all known aliases, for account migration
profile_url: Option<Url>,
featured_url: Option<Url>, // pinned posts collection URL
attachment: Vec<ApProfileField>, // profile metadata (PropertyValue)
manually_approves_followers: bool, // controls manuallyApprovesFollowers in AP JSON
discoverable: bool, // controls discoverable in AP JSON
actor_type: ApActorType, // Person / Service / Application / Organization / Group
}
```
## Broadcast with visibility
```rust
use k_ap::ApVisibility;
// Resolve the inboxes of any mentioned non-followers first, then pass them in.
// The library delivers to followers + mentioned actors, deduplicated.
let mentioned = vec![
service.lookup_actor_by_handle("@bob@mastodon.social").await?.outbox_url.unwrap(),
// ...or resolve inbox URLs directly
];
// Public — to: [AS_PUBLIC], cc: [followers]; delivered to followers + mentioned
service.broadcast_create_note(user_id, note_json, ApVisibility::Public, mentioned).await?;
// Followers only — to: [followers], cc: []; delivered to followers + mentioned
service.broadcast_create_note(user_id, note_json, ApVisibility::FollowersOnly, vec![]).await?;
// Private — no delivery at all; library returns immediately
service.broadcast_create_note(user_id, note_json, ApVisibility::Private, vec![]).await?;
service.broadcast_update_note(user_id, note_json, ApVisibility::Public, vec![]).await?;
service.broadcast_delete_to_followers(user_id, ap_id).await?;
// Announce / Undo Announce
service.broadcast_announce_to_followers(user_id, object_ap_id).await?;
service.broadcast_undo_announce_to_followers(user_id, object_ap_id).await?;
// Like / Unlike to a single remote inbox
service.broadcast_like_to_inbox(user_id, object_ap_id, inbox_url).await?;
service.broadcast_undo_like_to_inbox(user_id, object_ap_id, inbox_url).await?;
// Actor profile update to all followers
service.broadcast_actor_update(user_id).await?;
// Account migration — sends Move to all followers; set alsoKnownAs first
service.broadcast_move(user_id, new_actor_url).await?;
```
## Follow management
```rust
// Outbound follows (resolves handle via signed WebFinger request)
service.follow(local_user_id, "@user@remote.example").await?;
service.unfollow(local_user_id, remote_actor_url).await?;
// Inbound follow requests — full flow (DB update + AP delivery + backfill)
service.accept_follower(local_user_id, remote_actor_url).await?;
service.reject_follower(local_user_id, remote_actor_url).await?;
// Inbound follow requests — DB only (no AP delivery)
// Use when delivering Accept/Reject from a separate worker process
service.mark_follower_accepted(local_user_id, remote_actor_url).await?;
service.mark_follower_rejected(local_user_id, remote_actor_url).await?;
// Querying followers (DB-side filtering — efficient for large accounts)
let count: usize = service.count_accepted_followers(user_id).await?;
let page: Vec<RemoteActor> = service.get_accepted_followers_page(user_id, 0, 20).await?;
```
## Async delivery and backfill via EventPublisher
By default, outbound delivery and backfill run in the same process via `tokio::spawn`.
Implement `EventPublisher` to route them through your job queue so workers can process them separately:
```rust
impl EventPublisher for MyQueue {
async fn publish(&self, event: FederationEvent) -> anyhow::Result<()> {
match event {
FederationEvent::DeliveryRequested { inbox, activity, signing_actor_id } => {
// Persist and enqueue; your worker calls deliver_to_inbox
self.enqueue_delivery(inbox, activity, signing_actor_id).await?;
}
FederationEvent::DeliveryFailed { inbox, error, .. } => {
tracing::error!(%inbox, %error, "permanent delivery failure");
}
FederationEvent::BackfillRequested { owner_user_id, follower_inbox_url } => {
// Enqueue; your worker calls run_backfill_for_follower
self.enqueue_backfill(owner_user_id, follower_inbox_url).await?;
}
}
Ok(())
}
}
// Worker: execute a delivery task from the queue
service.deliver_to_inbox(inbox, activity_json, signing_actor_id).await?;
// Worker: send local content to a new follower's inbox
service.run_backfill_for_follower(owner_user_id, follower_inbox_url).await?;
```
## Remote outbox import
Import a remote actor's post history (e.g. after a local user follows them):
```rust
// Fetches pages from outbox_url and calls ApObjectHandler::on_create for each.
// Distinct from run_backfill_for_follower which sends YOUR content TO a follower.
service.import_remote_outbox(outbox_url, actor_url).await?;
```
## Actor lookup
```rust
// Resolve a handle via WebFinger using a signed HTTP request.
// Works with strict instances (e.g. Threads) that require HTTP signatures.
let actor: LookedUpActor = service.lookup_actor_by_handle("@user@remote.example").await?;
```
## Pinned posts (featured collection)
Override `get_featured_objects` in your `ApContentReader` to expose pinned posts.
The library serves them at `GET /users/{id}/featured` automatically. Default is empty.
```rust
impl ApContentReader for MyDb {
async fn get_featured_objects(&self, user_id: uuid::Uuid) -> anyhow::Result<Vec<Url>> {
Ok(self.get_pinned_post_urls(user_id).await?)
}
// ...other methods
}
```
Set `featured_url` in `ApUser` to point to the endpoint — the library includes it in actor JSON:
```rust
ApUser {
featured_url: Some("https://example.com/users/{id}/featured".parse()?),
// ...
}
```
## Inbound activity handling
Handled out of the box:
`Follow`, `Accept`, `Reject`, `Undo` (Follow, Like, Announce, Add, Block), `Create`, `Update`, `Delete`, `Announce`, `Like`, `Add`, `Block`, `Move`
- All activities are deduplicated by `id` — safe against retried deliveries.
- Mentions are extracted from `tag` arrays and dispatched via `ApObjectHandler::on_mention`.
- `Undo(Announce)` removes the boost record from `ActorRepository` and calls `on_announce_removed`.
- `Move` verifies all `alsoKnownAs` aliases on the target, migrates follower records, and re-follows in a background task (non-blocking).
- Actor types accepted: `Person`, `Service`, `Application`, `Organization`, `Group`.
## Key public types
| Type | Description |
|------|-------------|
| `ActivityPubService` | Central service — build once, share via `Arc` |
| `ActivityRepository` | Trait: activity ID deduplication (2 methods) |
| `FollowRepository` | Trait: follower/following graph + migration (18 methods) |
| `ActorRepository` | Trait: keypairs, remote actor cache, announce tracking (6 methods) |
| `BlocklistRepository` | Trait: domain and actor blocklists (8 methods) |
| `ApUserRepository` | Trait: user lookup (3 methods) |
| `ApContentReader` | Trait: outbox/backfill/featured content (3 methods, 1 with default) |
| `ApObjectHandler` | Trait: inbound activity callbacks (9 methods, 2 with defaults) |
| `ApVisibility` | `Public` / `FollowersOnly` / `Private` |
| `ApActorType` | `Person` / `Service` / `Application` / `Organization` / `Group` |
| `FederationEvent` | `DeliveryRequested` / `DeliveryFailed` / `BackfillRequested` |
| `EventPublisher` | Trait: hook for job queue integration |
| `LookedUpActor` | Resolved remote actor from `lookup_actor_by_handle` |
| `RemoteActor` | Cached federated actor record |
| `Follower` / `FollowerStatus` | Follower with `Pending`/`Accepted`/`Rejected` state |
| `ApUser` | AP-serializable local user |
| `ApFederationConfig` | Wraps the `activitypub_federation` config |
| `Error` | AP-layer error type |
## Local development
```bash
make check # fmt --check + clippy -D warnings + tests (use before committing)
make fmt # apply rustfmt
make fix # fmt + clippy --fix
```

View File

@@ -1,871 +0,0 @@
use activitypub_federation::{
config::Data,
fetch::object_id::ObjectId,
kinds::activity::{
AcceptType, CreateType, DeleteType, FollowType, RejectType, UndoType, UpdateType,
},
protocol::verification::verify_domains_match,
traits::Activity,
};
use serde::{Deserialize, Serialize};
use url::Url;
#[derive(Clone, Default, Debug, Serialize, Deserialize)]
#[serde(rename = "Announce")]
pub struct AnnounceType;
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename = "Like")]
pub struct LikeType;
impl Default for LikeType {
fn default() -> Self {
Self
}
}
use crate::actors::DbActor;
use crate::data::FederationData;
use crate::error::Error;
use crate::repository::{FollowerStatus, FollowingStatus};
// --- Follow ---
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct FollowActivity {
pub(crate) id: Url,
#[serde(rename = "type", default)]
pub(crate) kind: FollowType,
pub(crate) actor: ObjectId<DbActor>,
pub(crate) object: ObjectId<DbActor>,
}
#[async_trait::async_trait]
impl Activity for FollowActivity {
type DataType = FederationData;
type Error = Error;
fn id(&self) -> &Url {
&self.id
}
fn actor(&self) -> &Url {
self.actor.inner()
}
async fn verify(&self, data: &Data<Self::DataType>) -> Result<(), Self::Error> {
let target_url = self.object.inner();
let target_domain = match (target_url.host_str(), target_url.port()) {
(Some(host), Some(port)) => format!("{}:{}", host, port),
(Some(host), None) => host.to_string(),
_ => {
return Err(Error::bad_request(anyhow::anyhow!(
"invalid follow target URL"
)));
}
};
if target_domain != data.domain {
return Err(Error::bad_request(anyhow::anyhow!(
"follow target is not a local actor"
)));
}
Ok(())
}
async fn receive(self, data: &Data<Self::DataType>) -> Result<(), Self::Error> {
let domain = self.actor().host_str().unwrap_or("");
if data.federation_repo.is_domain_blocked(domain).await? {
tracing::info!(actor = %self.actor(), "ignoring activity from blocked domain");
return Ok(());
}
let _follower = self.actor.dereference(data).await?;
let local_actor = self.object.dereference(data).await?;
if data
.federation_repo
.is_actor_blocked(local_actor.user_id, self.actor.inner().as_str())
.await?
{
tracing::info!(actor = %self.actor.inner(), "ignoring follow from blocked actor");
return Ok(());
}
data.federation_repo
.add_follower(
local_actor.user_id,
self.actor.inner().as_str(),
FollowerStatus::Pending,
self.id.as_str(),
)
.await?;
tracing::info!(
follower = %self.actor.inner(),
local_user = %local_actor.user_id,
"follow request pending approval"
);
Ok(())
}
}
// --- Accept ---
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AcceptActivity {
pub(crate) id: Url,
#[serde(rename = "type", default)]
pub(crate) kind: AcceptType,
pub(crate) actor: ObjectId<DbActor>,
pub(crate) object: FollowActivity,
}
#[async_trait::async_trait]
impl Activity for AcceptActivity {
type DataType = FederationData;
type Error = Error;
fn id(&self) -> &Url {
&self.id
}
fn actor(&self) -> &Url {
self.actor.inner()
}
async fn verify(&self, _data: &Data<Self::DataType>) -> Result<(), Self::Error> {
if self.actor.inner() != self.object.object.inner() {
return Err(Error::bad_request(anyhow::anyhow!(
"Accept actor does not match Follow target"
)));
}
Ok(())
}
async fn receive(self, data: &Data<Self::DataType>) -> Result<(), Self::Error> {
let domain = self.actor().host_str().unwrap_or("");
if data.federation_repo.is_domain_blocked(domain).await? {
tracing::info!(actor = %self.actor(), "ignoring activity from blocked domain");
return Ok(());
}
let local_user_id = crate::urls::extract_user_id_from_url(self.object.actor.inner())
.ok_or_else(|| Error::bad_request(anyhow::anyhow!("invalid actor URL in Follow")))?;
data.federation_repo
.update_following_status(
local_user_id,
self.actor.inner().as_str(),
FollowingStatus::Accepted,
)
.await?;
tracing::info!(remote_actor = %self.actor.inner(), "follow accepted by remote");
Ok(())
}
}
// --- Reject ---
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct RejectActivity {
pub(crate) id: Url,
#[serde(rename = "type", default)]
pub(crate) kind: RejectType,
pub(crate) actor: ObjectId<DbActor>,
pub(crate) object: FollowActivity,
}
#[async_trait::async_trait]
impl Activity for RejectActivity {
type DataType = FederationData;
type Error = Error;
fn id(&self) -> &Url {
&self.id
}
fn actor(&self) -> &Url {
self.actor.inner()
}
async fn verify(&self, _data: &Data<Self::DataType>) -> Result<(), Self::Error> {
if self.actor.inner() != self.object.object.inner() {
return Err(Error::bad_request(anyhow::anyhow!(
"Reject actor does not match Follow target"
)));
}
Ok(())
}
async fn receive(self, data: &Data<Self::DataType>) -> Result<(), Self::Error> {
let domain = self.actor().host_str().unwrap_or("");
if data.federation_repo.is_domain_blocked(domain).await? {
tracing::info!(actor = %self.actor(), "ignoring activity from blocked domain");
return Ok(());
}
if let Some(user_id) = crate::urls::extract_user_id_from_url(self.object.actor.inner()) {
data.federation_repo
.remove_following(user_id, self.actor.inner().as_str())
.await?;
}
tracing::info!(actor = %self.actor.inner(), "follow rejected");
Ok(())
}
}
// --- Undo ---
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct UndoActivity {
pub(crate) id: Url,
#[serde(rename = "type", default)]
pub(crate) kind: UndoType,
pub(crate) actor: ObjectId<DbActor>,
pub(crate) object: serde_json::Value,
}
#[async_trait::async_trait]
impl Activity for UndoActivity {
type DataType = FederationData;
type Error = Error;
fn id(&self) -> &Url {
&self.id
}
fn actor(&self) -> &Url {
self.actor.inner()
}
async fn verify(&self, _data: &Data<Self::DataType>) -> Result<(), Self::Error> {
// The actor undoing must be the same as the actor in the wrapped activity.
if let Some(inner_actor) = self.object.get("actor").and_then(|v| v.as_str())
&& inner_actor != self.actor.inner().as_str()
{
return Err(Error::bad_request(anyhow::anyhow!(
"Undo actor does not match inner activity actor"
)));
}
Ok(())
}
async fn receive(self, data: &Data<Self::DataType>) -> Result<(), Self::Error> {
let domain = self.actor().host_str().unwrap_or("");
if data.federation_repo.is_domain_blocked(domain).await? {
tracing::info!(actor = %self.actor(), "ignoring Undo from blocked domain");
return Ok(());
}
let obj_type = self
.object
.get("type")
.and_then(|t| t.as_str())
.unwrap_or("");
match obj_type {
"Follow" => {
if let Some(obj_url) = self.object.get("object").and_then(|o| o.as_str())
&& let Ok(url) = Url::parse(obj_url)
&& let Some(user_id) = crate::urls::extract_user_id_from_url(&url)
{
data.federation_repo
.remove_follower(user_id, self.actor.inner().as_str())
.await?;
}
data.object_handler
.on_actor_removed(self.actor.inner())
.await
.map_err(|e| Error::from(anyhow::anyhow!(e)))?;
tracing::info!(actor = %self.actor.inner(), "unfollowed");
}
"Add" => {
let ap_id_str = self
.object
.get("object")
.and_then(|o| o.get("id"))
.and_then(|id| id.as_str())
.or_else(|| self.object.get("id").and_then(|id| id.as_str()));
if let Some(ap_id_str) = ap_id_str
&& let Ok(ap_id) = Url::parse(ap_id_str)
{
data.object_handler
.on_delete(&ap_id, self.actor.inner())
.await
.map_err(|e| Error::from(anyhow::anyhow!(e)))?;
tracing::info!(ap_id = %ap_id_str, "undo Add (watchlist remove)");
}
}
"Like" => {
if let Some(obj_url_str) = self.object.get("object").and_then(|o| o.as_str())
&& let Ok(obj_url) = Url::parse(obj_url_str)
&& obj_url.host_str().unwrap_or("") == data.domain
{
data.object_handler
.on_unlike(&obj_url, self.actor.inner())
.await
.unwrap_or_else(|e| {
tracing::warn!(error = %e, "failed to process unlike");
});
}
tracing::info!(actor = %self.actor.inner(), "received Undo(Like)");
}
other => {
tracing::debug!(kind = %other, "ignoring Undo of unknown activity type");
}
}
Ok(())
}
}
// --- Create ---
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CreateActivity {
pub(crate) id: Url,
#[serde(rename = "type", default)]
pub(crate) kind: CreateType,
pub(crate) actor: ObjectId<DbActor>,
pub(crate) object: serde_json::Value,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub(crate) to: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub(crate) cc: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub(crate) bto: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub(crate) bcc: Vec<String>,
}
#[async_trait::async_trait]
impl Activity for CreateActivity {
type DataType = FederationData;
type Error = Error;
fn id(&self) -> &Url {
&self.id
}
fn actor(&self) -> &Url {
self.actor.inner()
}
async fn verify(&self, _data: &Data<Self::DataType>) -> Result<(), Self::Error> {
if let Some(attributed_to) = self.object.get("attributedTo").and_then(|v| v.as_str())
&& let Ok(attributed_url) = Url::parse(attributed_to)
&& &attributed_url != self.actor.inner()
{
return Err(Error::bad_request(anyhow::anyhow!(
"Create actor does not match object attributedTo"
)));
}
Ok(())
}
async fn receive(self, data: &Data<Self::DataType>) -> Result<(), Self::Error> {
let domain = self.actor().host_str().unwrap_or("");
if data.federation_repo.is_domain_blocked(domain).await? {
tracing::info!(actor = %self.actor(), "ignoring activity from blocked domain");
return Ok(());
}
// Use the Note's own id, not the Create activity id (which ends in /activity).
// Delete activities reference the Note id, so they must match.
let ap_id = self
.object
.get("id")
.and_then(|v| v.as_str())
.and_then(|s| Url::parse(s).ok())
.unwrap_or_else(|| self.id.clone());
let actor_url = self.actor.inner().clone();
data.object_handler
.on_create(&ap_id, &actor_url, self.object)
.await
.map_err(|e| Error::from(anyhow::anyhow!(e)))?;
tracing::info!(actor = %actor_url, "received create activity");
Ok(())
}
}
// --- Delete ---
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DeleteActivity {
pub(crate) id: Url,
#[serde(rename = "type", default)]
pub(crate) kind: DeleteType,
pub(crate) actor: ObjectId<DbActor>,
pub(crate) object: serde_json::Value,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub(crate) to: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub(crate) cc: Vec<String>,
}
#[async_trait::async_trait]
impl Activity for DeleteActivity {
type DataType = FederationData;
type Error = Error;
fn id(&self) -> &Url {
&self.id
}
fn actor(&self) -> &Url {
self.actor.inner()
}
async fn verify(&self, _data: &Data<Self::DataType>) -> Result<(), Self::Error> {
let actor_domain = self.actor.inner().host_str().unwrap_or("");
let object_domain = match &self.object {
serde_json::Value::String(s) => Url::parse(s)
.ok()
.and_then(|u| u.host_str().map(|h| h.to_string()))
.unwrap_or_default(),
serde_json::Value::Object(o) => o
.get("id")
.and_then(|v| v.as_str())
.and_then(|s| Url::parse(s).ok())
.and_then(|u| u.host_str().map(|h| h.to_string()))
.unwrap_or_default(),
_ => String::new(),
};
if !object_domain.is_empty() && actor_domain != object_domain {
return Err(Error::bad_request(anyhow::anyhow!(
"Delete actor domain does not match object domain"
)));
}
Ok(())
}
async fn receive(self, data: &Data<Self::DataType>) -> Result<(), Self::Error> {
let domain = self.actor().host_str().unwrap_or("");
if data.federation_repo.is_domain_blocked(domain).await? {
tracing::info!(actor = %self.actor(), "ignoring activity from blocked domain");
return Ok(());
}
let actor_url = self.actor.inner().clone();
// Extract object URL — handles plain string and Tombstone {"id":"...","type":"Tombstone"}
let object_url_str = match &self.object {
serde_json::Value::String(s) => s.clone(),
serde_json::Value::Object(o) => o
.get("id")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.unwrap_or_default(),
_ => String::new(),
};
let Ok(object_url) = Url::parse(&object_url_str) else {
tracing::warn!(actor = %actor_url, "Delete activity has unparseable object, ignoring");
return Ok(());
};
// Actor self-deletion: Mastodon sends Delete(actor_url) when an account is deleted.
if object_url == *self.actor.inner() {
data.object_handler
.on_actor_removed(&actor_url)
.await
.map_err(|e| Error::from(anyhow::anyhow!(e)))?;
tracing::info!(actor = %actor_url, "received Delete(actor) — remote account deleted");
return Ok(());
}
// Normal note deletion.
data.object_handler
.on_delete(&object_url, &actor_url)
.await
.map_err(|e| Error::from(anyhow::anyhow!(e)))?;
tracing::info!(object = %object_url, "received Delete(note)");
Ok(())
}
}
// --- Update ---
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct UpdateActivity {
pub(crate) id: Url,
#[serde(rename = "type", default)]
pub(crate) kind: UpdateType,
pub(crate) actor: ObjectId<DbActor>,
pub(crate) object: serde_json::Value,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub(crate) to: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub(crate) cc: Vec<String>,
}
#[async_trait::async_trait]
impl Activity for UpdateActivity {
type DataType = FederationData;
type Error = Error;
fn id(&self) -> &Url {
&self.id
}
fn actor(&self) -> &Url {
self.actor.inner()
}
async fn verify(&self, _data: &Data<Self::DataType>) -> Result<(), Self::Error> {
if let Some(attributed_to) = self.object.get("attributedTo").and_then(|v| v.as_str())
&& let Ok(attributed_url) = Url::parse(attributed_to)
&& &attributed_url != self.actor.inner()
{
return Err(Error::bad_request(anyhow::anyhow!(
"Update actor does not match object attributedTo"
)));
}
Ok(())
}
async fn receive(self, data: &Data<Self::DataType>) -> Result<(), Self::Error> {
let domain = self.actor().host_str().unwrap_or("");
if data.federation_repo.is_domain_blocked(domain).await? {
tracing::info!(actor = %self.actor(), "ignoring activity from blocked domain");
return Ok(());
}
let ap_id = self
.object
.get("id")
.and_then(|v| v.as_str())
.and_then(|s| Url::parse(s).ok())
.unwrap_or_else(|| self.id.clone());
let actor_url = self.actor.inner().clone();
data.object_handler
.on_update(&ap_id, &actor_url, self.object)
.await
.map_err(|e| Error::from(anyhow::anyhow!(e)))?;
tracing::info!(actor = %actor_url, "received update activity");
Ok(())
}
}
// --- Announce ---
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AnnounceActivity {
pub(crate) id: Url,
#[serde(rename = "type", default)]
pub(crate) kind: AnnounceType,
pub(crate) actor: ObjectId<DbActor>,
pub(crate) object: Url,
pub(crate) published: Option<chrono::DateTime<chrono::Utc>>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub(crate) to: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub(crate) cc: Vec<String>,
}
#[async_trait::async_trait]
impl Activity for AnnounceActivity {
type DataType = FederationData;
type Error = Error;
fn id(&self) -> &Url {
&self.id
}
fn actor(&self) -> &Url {
self.actor.inner()
}
async fn verify(&self, _data: &Data<Self::DataType>) -> Result<(), Self::Error> {
verify_domains_match(&self.id, self.actor.inner())?;
Ok(())
}
async fn receive(self, data: &Data<Self::DataType>) -> Result<(), Self::Error> {
let domain = self.actor().host_str().unwrap_or("");
if data.federation_repo.is_domain_blocked(domain).await? {
tracing::info!(actor = %self.actor(), "ignoring activity from blocked domain");
return Ok(());
}
let object_domain = self.object.host_str().unwrap_or("");
if object_domain != data.domain {
tracing::debug!(
actor = %self.actor.inner(),
object = %self.object,
"received Announce of non-local object — skipped (cross-server boost not supported)"
);
return Ok(());
}
data.federation_repo
.add_announce(
self.id.as_str(),
self.object.as_str(),
self.actor.inner().as_str(),
self.published.unwrap_or_else(chrono::Utc::now),
)
.await?;
data.object_handler
.on_announce_received(&self.object, self.actor.inner())
.await
.unwrap_or_else(|e| {
tracing::warn!(error = %e, "failed to process announce notification");
});
tracing::info!(actor = %self.actor.inner(), object = %self.object, "received announce");
Ok(())
}
}
// --- Like ---
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct LikeActivity {
pub id: Url,
#[serde(rename = "type")]
pub kind: LikeType,
pub actor: ObjectId<DbActor>,
pub object: Url,
}
#[async_trait::async_trait]
impl Activity for LikeActivity {
type DataType = FederationData;
type Error = crate::error::Error;
fn id(&self) -> &Url {
&self.id
}
fn actor(&self) -> &Url {
self.actor.inner()
}
async fn verify(&self, _data: &Data<Self::DataType>) -> Result<(), Self::Error> {
verify_domains_match(&self.id, self.actor.inner())?;
Ok(())
}
async fn receive(self, data: &Data<Self::DataType>) -> Result<(), Self::Error> {
let domain = self.actor().host_str().unwrap_or("");
if data.federation_repo.is_domain_blocked(domain).await? {
tracing::info!(actor = %self.actor(), "ignoring Like from blocked domain");
return Ok(());
}
// Only process if the liked object is on our instance.
if self.object.host_str().unwrap_or("") != data.domain {
return Ok(());
}
data.object_handler
.on_like(&self.object, self.actor.inner())
.await
.map_err(|e| crate::error::Error::from(anyhow::anyhow!(e)))?;
tracing::info!(actor = %self.actor.inner(), object = %self.object, "received like");
Ok(())
}
}
// --- Add ---
#[derive(Clone, Default, Debug, Serialize, Deserialize)]
#[serde(rename = "Add")]
pub struct AddType;
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AddActivity {
pub(crate) id: Url,
#[serde(rename = "type", default)]
pub(crate) kind: AddType,
pub(crate) actor: ObjectId<DbActor>,
pub(crate) object: serde_json::Value,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub(crate) to: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub(crate) cc: Vec<String>,
}
#[async_trait::async_trait]
impl Activity for AddActivity {
type DataType = FederationData;
type Error = Error;
fn id(&self) -> &Url {
&self.id
}
fn actor(&self) -> &Url {
self.actor.inner()
}
async fn verify(&self, _data: &Data<Self::DataType>) -> Result<(), Self::Error> {
if let Some(attributed_to) = self.object.get("attributedTo").and_then(|v| v.as_str())
&& let Ok(attributed_url) = Url::parse(attributed_to)
&& &attributed_url != self.actor.inner()
{
return Err(Error::bad_request(anyhow::anyhow!(
"Add actor does not match object attributedTo"
)));
}
Ok(())
}
async fn receive(self, data: &Data<Self::DataType>) -> Result<(), Self::Error> {
let domain = self.actor().host_str().unwrap_or("");
if data.federation_repo.is_domain_blocked(domain).await? {
tracing::info!(actor = %self.actor(), "ignoring Add from blocked domain");
return Ok(());
}
let ap_id = self.id.clone();
let actor_url = self.actor.inner().clone();
data.object_handler
.on_create(&ap_id, &actor_url, self.object)
.await
.map_err(|e| Error::from(anyhow::anyhow!(e)))?;
tracing::info!(actor = %actor_url, "received Add activity");
Ok(())
}
}
// --- Block ---
#[derive(Clone, Default, Debug, Serialize, Deserialize)]
#[serde(rename = "Block")]
pub struct BlockType;
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct BlockActivity {
pub(crate) id: Url,
#[serde(rename = "type", default)]
pub(crate) kind: BlockType,
pub(crate) actor: ObjectId<DbActor>,
pub(crate) object: Url,
}
#[async_trait::async_trait]
impl Activity for BlockActivity {
type DataType = FederationData;
type Error = Error;
fn id(&self) -> &Url {
&self.id
}
fn actor(&self) -> &Url {
self.actor.inner()
}
async fn verify(&self, _data: &Data<Self::DataType>) -> Result<(), Self::Error> {
verify_domains_match(&self.id, self.actor.inner())?;
Ok(())
}
async fn receive(self, data: &Data<Self::DataType>) -> Result<(), Self::Error> {
let domain = self.actor().host_str().unwrap_or("");
if data.federation_repo.is_domain_blocked(domain).await? {
tracing::info!(actor = %self.actor(), "ignoring activity from blocked domain");
return Ok(());
}
if let Some(local_user_id) = crate::urls::extract_user_id_from_url(&self.object) {
let _ = data
.federation_repo
.remove_following(local_user_id, self.actor.inner().as_str())
.await;
let _ = data
.federation_repo
.remove_follower(local_user_id, self.actor.inner().as_str())
.await;
}
tracing::info!(actor = %self.actor.inner(), "received block — removed following and follower");
Ok(())
}
}
// --- Move (account migration) ---
#[derive(Clone, Default, Debug, Serialize, Deserialize)]
#[serde(rename = "Move")]
pub struct MoveType;
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct MoveActivity {
pub(crate) id: Url,
#[serde(rename = "type", default)]
pub(crate) kind: MoveType,
pub(crate) actor: ObjectId<DbActor>,
pub(crate) object: Url,
pub(crate) target: Url,
}
#[async_trait::async_trait]
impl Activity for MoveActivity {
type DataType = FederationData;
type Error = Error;
fn id(&self) -> &Url {
&self.id
}
fn actor(&self) -> &Url {
self.actor.inner()
}
async fn verify(&self, _data: &Data<Self::DataType>) -> Result<(), Self::Error> {
if &self.object != self.actor.inner() {
return Err(Error::bad_request(anyhow::anyhow!(
"Move object must be the actor itself"
)));
}
Ok(())
}
async fn receive(self, data: &Data<Self::DataType>) -> Result<(), Self::Error> {
let domain = self.actor().host_str().unwrap_or("");
if data.federation_repo.is_domain_blocked(domain).await? {
return Ok(());
}
tracing::info!(
actor = %self.actor.inner(),
target = %self.target,
"received Move (account migration) — target noted"
);
Ok(())
}
}
// --- Inbox dispatch enum ---
#[derive(Debug, Deserialize, Serialize)]
#[serde(tag = "type")]
#[enum_delegate::implement(Activity)]
pub enum InboxActivities {
#[serde(rename = "Follow")]
Follow(FollowActivity),
#[serde(rename = "Accept")]
Accept(AcceptActivity),
#[serde(rename = "Reject")]
Reject(RejectActivity),
#[serde(rename = "Undo")]
Undo(UndoActivity),
#[serde(rename = "Create")]
Create(CreateActivity),
#[serde(rename = "Delete")]
Delete(DeleteActivity),
#[serde(rename = "Update")]
Update(UpdateActivity),
#[serde(rename = "Announce")]
Announce(AnnounceActivity),
#[serde(rename = "Add")]
Add(AddActivity),
#[serde(rename = "Block")]
Block(BlockActivity),
#[serde(rename = "Like")]
Like(LikeActivity),
#[serde(rename = "Move")]
Move(MoveActivity),
}

62
src/activities/accept.rs Normal file
View File

@@ -0,0 +1,62 @@
use activitypub_federation::{
config::Data, fetch::object_id::ObjectId, kinds::activity::AcceptType, traits::Activity,
};
use serde::{Deserialize, Serialize};
use url::Url;
use crate::actors::DbActor;
use crate::data::FederationData;
use crate::error::Error;
use crate::repository::FollowingStatus;
use super::follow::FollowActivity;
use super::helpers::check_guards;
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AcceptActivity {
pub(crate) id: Url,
#[serde(rename = "type", default)]
pub(crate) kind: AcceptType,
pub(crate) actor: ObjectId<DbActor>,
pub(crate) object: FollowActivity,
}
#[async_trait::async_trait]
impl Activity for AcceptActivity {
type DataType = FederationData;
type Error = Error;
fn id(&self) -> &Url {
&self.id
}
fn actor(&self) -> &Url {
self.actor.inner()
}
async fn verify(&self, _data: &Data<Self::DataType>) -> Result<(), Self::Error> {
if self.actor.inner() != self.object.object.inner() {
return Err(Error::bad_request(anyhow::anyhow!(
"Accept actor does not match Follow target"
)));
}
Ok(())
}
async fn receive(self, data: &Data<Self::DataType>) -> Result<(), Self::Error> {
if check_guards(&self.id, self.actor.inner(), data).await? {
return Ok(());
}
let local_user_id = crate::urls::extract_user_id_from_url(self.object.actor.inner())
.ok_or_else(|| Error::bad_request(anyhow::anyhow!("invalid actor URL in Follow")))?;
data.follow_repo
.update_following_status(
local_user_id,
self.actor.inner().as_str(),
FollowingStatus::Accepted,
)
.await?;
tracing::info!(remote_actor = %self.actor.inner(), "follow accepted by remote");
Ok(())
}
}

73
src/activities/add.rs Normal file
View File

@@ -0,0 +1,73 @@
use activitypub_federation::{config::Data, fetch::object_id::ObjectId, traits::Activity};
use serde::{Deserialize, Serialize};
use url::Url;
use crate::actors::DbActor;
use crate::data::FederationData;
use crate::error::Error;
use super::helpers::check_guards;
#[derive(Clone, Default, Debug, Serialize, Deserialize)]
#[serde(rename = "Add")]
pub struct AddType;
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AddActivity {
pub(crate) id: Url,
#[serde(rename = "type", default)]
pub(crate) kind: AddType,
pub(crate) actor: ObjectId<DbActor>,
pub(crate) object: serde_json::Value,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub(crate) to: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub(crate) cc: Vec<String>,
}
#[async_trait::async_trait]
impl Activity for AddActivity {
type DataType = FederationData;
type Error = Error;
fn id(&self) -> &Url {
&self.id
}
fn actor(&self) -> &Url {
self.actor.inner()
}
async fn verify(&self, _data: &Data<Self::DataType>) -> Result<(), Self::Error> {
if let Some(attributed_to) = self.object.get("attributedTo").and_then(|v| v.as_str())
&& let Ok(attributed_url) = Url::parse(attributed_to)
&& &attributed_url != self.actor.inner()
{
return Err(Error::bad_request(anyhow::anyhow!(
"Add actor does not match object attributedTo"
)));
}
Ok(())
}
async fn receive(self, data: &Data<Self::DataType>) -> Result<(), Self::Error> {
if check_guards(&self.id, self.actor.inner(), data).await? {
return Ok(());
}
// Use the object's own id as the stable AP identifier, falling back to
// the activity id only if the object has no id field.
let ap_id = self
.object
.get("id")
.and_then(|v| v.as_str())
.and_then(|s| Url::parse(s).ok())
.unwrap_or_else(|| self.id.clone());
let actor_url = self.actor.inner().clone();
data.object_handler
.on_create(&ap_id, &actor_url, self.object)
.await
.map_err(|e| Error::from(anyhow::anyhow!(e)))?;
tracing::info!(actor = %actor_url, "received Add activity");
Ok(())
}
}

View File

@@ -0,0 +1,81 @@
use activitypub_federation::{
config::Data, fetch::object_id::ObjectId, protocol::verification::verify_domains_match,
traits::Activity,
};
use serde::{Deserialize, Serialize};
use url::Url;
use crate::actors::DbActor;
use crate::data::FederationData;
use crate::error::Error;
use super::helpers::check_guards;
#[derive(Clone, Default, Debug, Serialize, Deserialize)]
#[serde(rename = "Announce")]
pub struct AnnounceType;
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AnnounceActivity {
pub(crate) id: Url,
#[serde(rename = "type", default)]
pub(crate) kind: AnnounceType,
pub(crate) actor: ObjectId<DbActor>,
pub(crate) object: Url,
pub(crate) published: Option<chrono::DateTime<chrono::Utc>>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub(crate) to: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub(crate) cc: Vec<String>,
}
#[async_trait::async_trait]
impl Activity for AnnounceActivity {
type DataType = FederationData;
type Error = Error;
fn id(&self) -> &Url {
&self.id
}
fn actor(&self) -> &Url {
self.actor.inner()
}
async fn verify(&self, _data: &Data<Self::DataType>) -> Result<(), Self::Error> {
verify_domains_match(&self.id, self.actor.inner())?;
Ok(())
}
async fn receive(self, data: &Data<Self::DataType>) -> Result<(), Self::Error> {
if check_guards(&self.id, self.actor.inner(), data).await? {
return Ok(());
}
if self.object.host_str().unwrap_or("") != data.domain {
data.object_handler
.on_announce_of_remote(&self.object, self.actor.inner())
.await
.unwrap_or_else(
|e| tracing::warn!(error = %e, "failed to process cross-server announce"),
);
tracing::debug!(actor = %self.actor.inner(), object = %self.object, "received Announce of non-local object");
return Ok(());
}
data.actor_repo
.add_announce(
self.id.as_str(),
self.object.as_str(),
self.actor.inner().as_str(),
self.published.unwrap_or_else(chrono::Utc::now),
)
.await?;
data.object_handler
.on_announce_received(&self.object, self.actor.inner())
.await
.unwrap_or_else(
|e| tracing::warn!(error = %e, "failed to process announce notification"),
);
tracing::info!(actor = %self.actor.inner(), object = %self.object, "received announce");
Ok(())
}
}

67
src/activities/block.rs Normal file
View File

@@ -0,0 +1,67 @@
use activitypub_federation::{
config::Data, fetch::object_id::ObjectId, protocol::verification::verify_domains_match,
traits::Activity,
};
use serde::{Deserialize, Serialize};
use url::Url;
use crate::actors::DbActor;
use crate::data::FederationData;
use crate::error::Error;
use super::helpers::check_guards;
#[derive(Clone, Default, Debug, Serialize, Deserialize)]
#[serde(rename = "Block")]
pub struct BlockType;
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct BlockActivity {
pub(crate) id: Url,
#[serde(rename = "type", default)]
pub(crate) kind: BlockType,
pub(crate) actor: ObjectId<DbActor>,
pub(crate) object: Url,
}
#[async_trait::async_trait]
impl Activity for BlockActivity {
type DataType = FederationData;
type Error = Error;
fn id(&self) -> &Url {
&self.id
}
fn actor(&self) -> &Url {
self.actor.inner()
}
async fn verify(&self, _data: &Data<Self::DataType>) -> Result<(), Self::Error> {
verify_domains_match(&self.id, self.actor.inner())?;
Ok(())
}
async fn receive(self, data: &Data<Self::DataType>) -> Result<(), Self::Error> {
if check_guards(&self.id, self.actor.inner(), data).await? {
return Ok(());
}
let actor_url = self.actor.inner().as_str();
if let Some(local_user_id) = crate::urls::extract_user_id_from_url(&self.object) {
let _ = data
.follow_repo
.remove_following(local_user_id, actor_url)
.await;
let _ = data
.follow_repo
.remove_follower(local_user_id, actor_url)
.await;
let _ = data
.blocklist_repo
.add_blocked_actor(local_user_id, actor_url)
.await;
}
tracing::info!(actor = %actor_url, "received block — removed relationships, recorded in blocklist");
Ok(())
}
}

74
src/activities/create.rs Normal file
View File

@@ -0,0 +1,74 @@
use activitypub_federation::{
config::Data, fetch::object_id::ObjectId, kinds::activity::CreateType, traits::Activity,
};
use serde::{Deserialize, Serialize};
use url::Url;
use crate::actors::DbActor;
use crate::data::FederationData;
use crate::error::Error;
use super::helpers::{check_guards, extract_and_dispatch_mentions};
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CreateActivity {
pub(crate) id: Url,
#[serde(rename = "type", default)]
pub(crate) kind: CreateType,
pub(crate) actor: ObjectId<DbActor>,
pub(crate) object: serde_json::Value,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub(crate) to: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub(crate) cc: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub(crate) bto: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub(crate) bcc: Vec<String>,
}
#[async_trait::async_trait]
impl Activity for CreateActivity {
type DataType = FederationData;
type Error = Error;
fn id(&self) -> &Url {
&self.id
}
fn actor(&self) -> &Url {
self.actor.inner()
}
async fn verify(&self, _data: &Data<Self::DataType>) -> Result<(), Self::Error> {
if let Some(attributed_to) = self.object.get("attributedTo").and_then(|v| v.as_str())
&& let Ok(attributed_url) = Url::parse(attributed_to)
&& &attributed_url != self.actor.inner()
{
return Err(Error::bad_request(anyhow::anyhow!(
"Create actor does not match object attributedTo"
)));
}
Ok(())
}
async fn receive(self, data: &Data<Self::DataType>) -> Result<(), Self::Error> {
if check_guards(&self.id, self.actor.inner(), data).await? {
return Ok(());
}
let ap_id = self
.object
.get("id")
.and_then(|v| v.as_str())
.and_then(|s| Url::parse(s).ok())
.unwrap_or_else(|| self.id.clone());
let actor_url = self.actor.inner().clone();
extract_and_dispatch_mentions(&ap_id, &actor_url, &self.object, data).await;
data.object_handler
.on_create(&ap_id, &actor_url, self.object)
.await
.map_err(|e| Error::from(anyhow::anyhow!(e)))?;
tracing::info!(actor = %actor_url, "received create activity");
Ok(())
}
}

95
src/activities/delete.rs Normal file
View File

@@ -0,0 +1,95 @@
use activitypub_federation::{
config::Data, fetch::object_id::ObjectId, kinds::activity::DeleteType, traits::Activity,
};
use serde::{Deserialize, Serialize};
use url::Url;
use crate::actors::DbActor;
use crate::data::FederationData;
use crate::error::Error;
use super::helpers::check_guards;
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DeleteActivity {
pub(crate) id: Url,
#[serde(rename = "type", default)]
pub(crate) kind: DeleteType,
pub(crate) actor: ObjectId<DbActor>,
pub(crate) object: serde_json::Value,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub(crate) to: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub(crate) cc: Vec<String>,
}
#[async_trait::async_trait]
impl Activity for DeleteActivity {
type DataType = FederationData;
type Error = Error;
fn id(&self) -> &Url {
&self.id
}
fn actor(&self) -> &Url {
self.actor.inner()
}
async fn verify(&self, _data: &Data<Self::DataType>) -> Result<(), Self::Error> {
let actor_domain = self.actor.inner().host_str().unwrap_or("");
let object_domain = match &self.object {
serde_json::Value::String(s) => Url::parse(s)
.ok()
.and_then(|u| u.host_str().map(|h| h.to_string()))
.unwrap_or_default(),
serde_json::Value::Object(o) => o
.get("id")
.and_then(|v| v.as_str())
.and_then(|s| Url::parse(s).ok())
.and_then(|u| u.host_str().map(|h| h.to_string()))
.unwrap_or_default(),
_ => String::new(),
};
if !object_domain.is_empty() && actor_domain != object_domain {
return Err(Error::bad_request(anyhow::anyhow!(
"Delete actor domain does not match object domain"
)));
}
Ok(())
}
async fn receive(self, data: &Data<Self::DataType>) -> Result<(), Self::Error> {
if check_guards(&self.id, self.actor.inner(), data).await? {
return Ok(());
}
let actor_url = self.actor.inner().clone();
let object_url_str = match &self.object {
serde_json::Value::String(s) => s.clone(),
serde_json::Value::Object(o) => o
.get("id")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.unwrap_or_default(),
_ => String::new(),
};
let Ok(object_url) = Url::parse(&object_url_str) else {
tracing::warn!(actor = %actor_url, "Delete has unparseable object, ignoring");
return Ok(());
};
if object_url == *self.actor.inner() {
data.object_handler
.on_actor_removed(&actor_url)
.await
.map_err(|e| Error::from(anyhow::anyhow!(e)))?;
tracing::info!(actor = %actor_url, "received Delete(actor) — remote account deleted");
return Ok(());
}
data.object_handler
.on_delete(&object_url, &actor_url)
.await
.map_err(|e| Error::from(anyhow::anyhow!(e)))?;
tracing::info!(object = %object_url, "received Delete(note)");
Ok(())
}
}

98
src/activities/follow.rs Normal file
View File

@@ -0,0 +1,98 @@
use activitypub_federation::{
config::Data, fetch::object_id::ObjectId, kinds::activity::FollowType, traits::Activity,
};
use serde::{Deserialize, Serialize};
use url::Url;
use crate::actors::DbActor;
use crate::data::FederationData;
use crate::error::Error;
use crate::repository::FollowerStatus;
use super::helpers::check_guards;
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct FollowActivity {
pub(crate) id: Url,
#[serde(rename = "type", default)]
pub(crate) kind: FollowType,
pub(crate) actor: ObjectId<DbActor>,
pub(crate) object: ObjectId<DbActor>,
}
#[async_trait::async_trait]
impl Activity for FollowActivity {
type DataType = FederationData;
type Error = Error;
fn id(&self) -> &Url {
&self.id
}
fn actor(&self) -> &Url {
self.actor.inner()
}
async fn verify(&self, data: &Data<Self::DataType>) -> Result<(), Self::Error> {
let target_url = self.object.inner();
let target_domain = match (target_url.host_str(), target_url.port()) {
(Some(host), Some(port)) => format!("{}:{}", host, port),
(Some(host), None) => host.to_string(),
_ => {
return Err(Error::bad_request(anyhow::anyhow!(
"invalid follow target URL"
)));
}
};
if target_domain == data.domain {
return Ok(());
}
if let Some(uuid) = crate::urls::extract_user_id_from_url(target_url)
&& data
.user_repo
.find_by_id(uuid)
.await
.ok()
.flatten()
.is_some()
{
tracing::debug!(target = %target_url, "accepting follow for migrated actor URL");
return Ok(());
}
Err(Error::bad_request(anyhow::anyhow!(
"follow target is not a local actor"
)))
}
async fn receive(self, data: &Data<Self::DataType>) -> Result<(), Self::Error> {
if check_guards(&self.id, self.actor.inner(), data).await? {
return Ok(());
}
// Actor block checked BEFORE any outbound HTTP fetch.
if let Some(target_user_id) = crate::urls::extract_user_id_from_url(self.object.inner())
&& data
.blocklist_repo
.is_actor_blocked(target_user_id, self.actor.inner().as_str())
.await?
{
tracing::info!(actor = %self.actor.inner(), "ignoring follow from blocked actor");
return Ok(());
}
let _follower = self.actor.dereference(data).await?;
let local_actor = self.object.dereference(data).await?;
data.follow_repo
.add_follower(
local_actor.user_id,
self.actor.inner().as_str(),
FollowerStatus::Pending,
self.id.as_str(),
)
.await?;
tracing::info!(
follower = %self.actor.inner(),
local_user = %local_actor.user_id,
"follow request pending approval"
);
Ok(())
}
}

86
src/activities/helpers.rs Normal file
View File

@@ -0,0 +1,86 @@
use activitypub_federation::config::Data;
use url::Url;
use crate::data::FederationData;
use crate::error::Error;
/// Returns `true` if the activity was already processed.
/// Marks it processed before returning `false`.
/// On repo error, skips the check rather than silently dropping the activity.
pub(crate) async fn already_processed(activity_id: &Url, data: &Data<FederationData>) -> bool {
let id = activity_id.as_str();
match data.activity_repo.is_activity_processed(id).await {
Ok(true) => {
tracing::debug!(activity_id = id, "duplicate activity, skipping");
true
}
Ok(false) => {
if let Err(e) = data.activity_repo.mark_activity_processed(id).await {
tracing::warn!(activity_id = id, error = %e, "failed to mark activity processed");
}
false
}
Err(e) => {
tracing::warn!(error = %e, "idempotency check failed, processing anyway");
false
}
}
}
/// Returns `true` when the activity should be skipped:
/// already processed, or the sender's domain is blocked.
/// Call this at the top of every `receive()` impl.
pub(crate) async fn check_guards(
id: &Url,
actor: &Url,
data: &Data<FederationData>,
) -> Result<bool, Error> {
if already_processed(id, data).await {
return Ok(true);
}
let domain = actor.host_str().unwrap_or("");
if data.blocklist_repo.is_domain_blocked(domain).await? {
tracing::info!(actor = %actor, "ignoring activity from blocked domain");
return Ok(true);
}
Ok(false)
}
/// Parse `object["tag"]` for `Mention` entries and notify each tagged local user.
/// Failures are logged and never propagated — a broken mention must not fail the activity.
pub(crate) async fn extract_and_dispatch_mentions(
ap_id: &Url,
actor_url: &Url,
object: &serde_json::Value,
data: &Data<FederationData>,
) {
let Some(tags) = object.get("tag").and_then(|t| t.as_array()) else {
return;
};
for tag in tags {
if tag.get("type").and_then(|v| v.as_str()) != Some("Mention") {
continue;
}
let Some(href) = tag.get("href").and_then(|v| v.as_str()) else {
continue;
};
let Ok(href_url) = Url::parse(href) else {
continue;
};
let Some(mentioned_user_id) = crate::urls::extract_user_id_from_url(&href_url) else {
continue;
};
if let Err(e) = data
.object_handler
.on_mention(ap_id, mentioned_user_id, actor_url)
.await
{
tracing::warn!(
ap_id = %ap_id,
mentioned_user = %mentioned_user_id,
error = %e,
"failed to dispatch mention notification"
);
}
}
}

65
src/activities/like.rs Normal file
View File

@@ -0,0 +1,65 @@
use activitypub_federation::{
config::Data, fetch::object_id::ObjectId, protocol::verification::verify_domains_match,
traits::Activity,
};
use serde::{Deserialize, Serialize};
use url::Url;
use crate::actors::DbActor;
use crate::data::FederationData;
use crate::error::Error;
use super::helpers::check_guards;
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename = "Like")]
pub struct LikeType;
impl Default for LikeType {
fn default() -> Self {
Self
}
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct LikeActivity {
pub id: Url,
#[serde(rename = "type")]
pub kind: LikeType,
pub actor: ObjectId<DbActor>,
pub object: Url,
}
#[async_trait::async_trait]
impl Activity for LikeActivity {
type DataType = FederationData;
type Error = Error;
fn id(&self) -> &Url {
&self.id
}
fn actor(&self) -> &Url {
self.actor.inner()
}
async fn verify(&self, _data: &Data<Self::DataType>) -> Result<(), Self::Error> {
verify_domains_match(&self.id, self.actor.inner())?;
Ok(())
}
async fn receive(self, data: &Data<Self::DataType>) -> Result<(), Self::Error> {
if check_guards(&self.id, self.actor.inner(), data).await? {
return Ok(());
}
if self.object.host_str().unwrap_or("") != data.domain {
return Ok(());
}
data.object_handler
.on_like(&self.object, self.actor.inner())
.await
.map_err(|e| Error::from(anyhow::anyhow!(e)))?;
tracing::info!(actor = %self.actor.inner(), object = %self.object, "received like");
Ok(())
}
}

60
src/activities/mod.rs Normal file
View File

@@ -0,0 +1,60 @@
mod accept;
mod add;
mod announce;
mod block;
mod create;
mod delete;
mod follow;
pub(crate) mod helpers;
mod like;
mod move_act;
mod reject;
mod undo;
mod update;
pub use accept::AcceptActivity;
pub use add::{AddActivity, AddType};
pub use announce::{AnnounceActivity, AnnounceType};
pub use block::{BlockActivity, BlockType};
pub use create::CreateActivity;
pub use delete::DeleteActivity;
pub use follow::FollowActivity;
pub use like::{LikeActivity, LikeType};
pub use move_act::{MoveActivity, MoveType};
pub use reject::RejectActivity;
pub use undo::UndoActivity;
pub use update::UpdateActivity;
use activitypub_federation::config::Data;
use serde::{Deserialize, Serialize};
use url::Url;
#[derive(Debug, Deserialize, Serialize)]
#[serde(tag = "type")]
#[enum_delegate::implement(activitypub_federation::traits::Activity)]
pub enum InboxActivities {
#[serde(rename = "Follow")]
Follow(FollowActivity),
#[serde(rename = "Accept")]
Accept(AcceptActivity),
#[serde(rename = "Reject")]
Reject(RejectActivity),
#[serde(rename = "Undo")]
Undo(UndoActivity),
#[serde(rename = "Create")]
Create(CreateActivity),
#[serde(rename = "Delete")]
Delete(DeleteActivity),
#[serde(rename = "Update")]
Update(UpdateActivity),
#[serde(rename = "Announce")]
Announce(AnnounceActivity),
#[serde(rename = "Add")]
Add(AddActivity),
#[serde(rename = "Block")]
Block(BlockActivity),
#[serde(rename = "Like")]
Like(LikeActivity),
#[serde(rename = "Move")]
Move(MoveActivity),
}

141
src/activities/move_act.rs Normal file
View File

@@ -0,0 +1,141 @@
use activitypub_federation::{
activity_sending::SendActivityTask, config::Data, fetch::object_id::ObjectId,
protocol::context::WithContext, traits::Activity,
};
use serde::{Deserialize, Serialize};
use url::Url;
use crate::actors::DbActor;
use crate::data::FederationData;
use crate::error::Error;
use super::follow::FollowActivity;
use super::helpers::check_guards;
#[derive(Clone, Default, Debug, Serialize, Deserialize)]
#[serde(rename = "Move")]
pub struct MoveType;
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct MoveActivity {
pub(crate) id: Url,
#[serde(rename = "type", default)]
pub(crate) kind: MoveType,
pub(crate) actor: ObjectId<DbActor>,
pub(crate) object: Url,
pub(crate) target: Url,
}
#[async_trait::async_trait]
impl Activity for MoveActivity {
type DataType = FederationData;
type Error = Error;
fn id(&self) -> &Url {
&self.id
}
fn actor(&self) -> &Url {
self.actor.inner()
}
async fn verify(&self, _data: &Data<Self::DataType>) -> Result<(), Self::Error> {
if &self.object != self.actor.inner() {
return Err(Error::bad_request(anyhow::anyhow!(
"Move object must be the actor itself"
)));
}
Ok(())
}
async fn receive(self, data: &Data<Self::DataType>) -> Result<(), Self::Error> {
if check_guards(&self.id, self.actor.inner(), data).await? {
return Ok(());
}
let target = ObjectId::<DbActor>::from(self.target.clone())
.dereference(data)
.await
.map_err(|e| Error::from(anyhow::anyhow!("{e}")))?;
// Verify the new actor claims the old identity via alsoKnownAs.
// The spec allows multiple aliases; check all of them.
let old_url = self.object.as_str();
if !target.also_known_as.iter().any(|a| a == old_url) {
return Err(Error::bad_request(anyhow::anyhow!(
"Move target alsoKnownAs does not reference old actor"
)));
}
let affected = data
.follow_repo
.migrate_follower_actor(old_url, self.target.as_str())
.await
.map_err(|e| Error::from(anyhow::anyhow!("{e}")))?;
let affected_count = affected.len();
// Spawn re-follows in the background — do NOT await them inside receive()
// to avoid blocking the inbox handler while making outbound HTTP requests.
let target_inbox = target.inbox_url.clone();
let target_url = self.target.clone();
let base_url = data.base_url.clone();
let data_clone = data.clone();
tokio::spawn(async move {
for local_user_id in &affected {
let local_actor =
match crate::actors::get_local_actor(*local_user_id, &data_clone).await {
Ok(a) => a,
Err(e) => {
tracing::warn!(
error = %e,
%local_user_id,
"Move: failed to load local actor"
);
continue;
}
};
let follow_id = match crate::urls::activity_url(&base_url) {
Ok(u) => u,
Err(e) => {
tracing::warn!(error = %e, "Move: failed to generate follow activity URL");
continue;
}
};
let follow = FollowActivity {
id: follow_id,
kind: Default::default(),
actor: ObjectId::from(local_actor.ap_id.clone()),
object: ObjectId::from(target_url.clone()),
};
let sends = match SendActivityTask::prepare(
&WithContext::new_default(follow),
&local_actor,
vec![target_inbox.clone()],
&data_clone,
)
.await
{
Ok(s) => s,
Err(e) => {
tracing::warn!(error = %e, "Move: failed to prepare re-follow");
continue;
}
};
for send in sends {
if let Err(e) = send.sign_and_send(&data_clone).await {
tracing::warn!(
error = %e,
%local_user_id,
"Move: re-follow delivery failed"
);
}
}
}
});
tracing::info!(
actor = %self.actor.inner(),
target = %self.target,
affected = affected_count,
"received Move — migrated follower relationships, re-follows spawned"
);
Ok(())
}
}

57
src/activities/reject.rs Normal file
View File

@@ -0,0 +1,57 @@
use activitypub_federation::{
config::Data, fetch::object_id::ObjectId, kinds::activity::RejectType, traits::Activity,
};
use serde::{Deserialize, Serialize};
use url::Url;
use crate::actors::DbActor;
use crate::data::FederationData;
use crate::error::Error;
use super::follow::FollowActivity;
use super::helpers::check_guards;
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct RejectActivity {
pub(crate) id: Url,
#[serde(rename = "type", default)]
pub(crate) kind: RejectType,
pub(crate) actor: ObjectId<DbActor>,
pub(crate) object: FollowActivity,
}
#[async_trait::async_trait]
impl Activity for RejectActivity {
type DataType = FederationData;
type Error = Error;
fn id(&self) -> &Url {
&self.id
}
fn actor(&self) -> &Url {
self.actor.inner()
}
async fn verify(&self, _data: &Data<Self::DataType>) -> Result<(), Self::Error> {
if self.actor.inner() != self.object.object.inner() {
return Err(Error::bad_request(anyhow::anyhow!(
"Reject actor does not match Follow target"
)));
}
Ok(())
}
async fn receive(self, data: &Data<Self::DataType>) -> Result<(), Self::Error> {
if check_guards(&self.id, self.actor.inner(), data).await? {
return Ok(());
}
if let Some(user_id) = crate::urls::extract_user_id_from_url(self.object.actor.inner()) {
data.follow_repo
.remove_following(user_id, self.actor.inner().as_str())
.await?;
}
tracing::info!(actor = %self.actor.inner(), "follow rejected");
Ok(())
}
}

151
src/activities/undo.rs Normal file
View File

@@ -0,0 +1,151 @@
use activitypub_federation::{
config::Data, fetch::object_id::ObjectId, kinds::activity::UndoType, traits::Activity,
};
use serde::{Deserialize, Serialize};
use url::Url;
use crate::actors::DbActor;
use crate::data::FederationData;
use crate::error::Error;
use super::helpers::check_guards;
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct UndoActivity {
pub(crate) id: Url,
#[serde(rename = "type", default)]
pub(crate) kind: UndoType,
pub(crate) actor: ObjectId<DbActor>,
pub(crate) object: serde_json::Value,
}
#[async_trait::async_trait]
impl Activity for UndoActivity {
type DataType = FederationData;
type Error = Error;
fn id(&self) -> &Url {
&self.id
}
fn actor(&self) -> &Url {
self.actor.inner()
}
async fn verify(&self, _data: &Data<Self::DataType>) -> Result<(), Self::Error> {
if let Some(inner_actor) = self.object.get("actor").and_then(|v| v.as_str())
&& inner_actor != self.actor.inner().as_str()
{
return Err(Error::bad_request(anyhow::anyhow!(
"Undo actor does not match inner activity actor"
)));
}
Ok(())
}
async fn receive(self, data: &Data<Self::DataType>) -> Result<(), Self::Error> {
if check_guards(&self.id, self.actor.inner(), data).await? {
return Ok(());
}
let obj_type = self
.object
.get("type")
.and_then(|t| t.as_str())
.unwrap_or("");
match obj_type {
"Follow" => {
if let Some(obj_url) = self.object.get("object").and_then(|o| o.as_str())
&& let Ok(url) = Url::parse(obj_url)
&& let Some(user_id) = crate::urls::extract_user_id_from_url(&url)
{
data.follow_repo
.remove_follower(user_id, self.actor.inner().as_str())
.await?;
}
data.object_handler
.on_actor_removed(self.actor.inner())
.await
.map_err(|e| Error::from(anyhow::anyhow!(e)))?;
tracing::info!(actor = %self.actor.inner(), "unfollowed");
}
"Add" => {
let ap_id_str = self
.object
.get("object")
.and_then(|o| o.get("id"))
.and_then(|id| id.as_str())
.or_else(|| self.object.get("id").and_then(|id| id.as_str()));
if let Some(ap_id_str) = ap_id_str
&& let Ok(ap_id) = Url::parse(ap_id_str)
{
data.object_handler
.on_delete(&ap_id, self.actor.inner())
.await
.map_err(|e| Error::from(anyhow::anyhow!(e)))?;
tracing::info!(ap_id = %ap_id_str, "undo Add (watchlist remove)");
}
}
"Like" => {
if let Some(obj_url_str) = self.object.get("object").and_then(|o| o.as_str())
&& let Ok(obj_url) = Url::parse(obj_url_str)
&& obj_url.host_str().unwrap_or("") == data.domain
{
data.object_handler
.on_unlike(&obj_url, self.actor.inner())
.await
.unwrap_or_else(|e| tracing::warn!(error = %e, "failed to process unlike"));
}
tracing::info!(actor = %self.actor.inner(), "received Undo(Like)");
}
"Announce" => {
// Remove the boost record so announce counts stay accurate.
let activity_id = self.object.get("id").and_then(|v| v.as_str()).unwrap_or("");
let object_url_str = self
.object
.get("object")
.and_then(|v| v.as_str())
.unwrap_or("");
if !activity_id.is_empty()
&& let Err(e) = data
.actor_repo
.remove_announce(activity_id, self.actor.inner().as_str())
.await
{
tracing::warn!(error = %e, activity_id, "failed to remove announce record");
}
if let Ok(obj_url) = Url::parse(object_url_str)
&& obj_url.host_str().unwrap_or("") == data.domain
{
data.object_handler
.on_announce_removed(&obj_url, self.actor.inner())
.await
.unwrap_or_else(|e| {
tracing::warn!(error = %e, "failed to process Undo(Announce)");
});
}
tracing::info!(actor = %self.actor.inner(), "received Undo(Announce)");
}
"Block" => {
if let Some(obj_url) = self.object.get("object").and_then(|o| o.as_str())
&& let Ok(url) = Url::parse(obj_url)
&& let Some(user_id) = crate::urls::extract_user_id_from_url(&url)
{
let _ = data
.blocklist_repo
.remove_blocked_actor(user_id, self.actor.inner().as_str())
.await;
}
tracing::info!(
actor = %self.actor.inner(),
"received Undo(Block) — removed from blocklist"
);
}
other => {
tracing::debug!(kind = %other, "ignoring Undo of unknown activity type");
}
}
Ok(())
}
}

70
src/activities/update.rs Normal file
View File

@@ -0,0 +1,70 @@
use activitypub_federation::{
config::Data, fetch::object_id::ObjectId, kinds::activity::UpdateType, traits::Activity,
};
use serde::{Deserialize, Serialize};
use url::Url;
use crate::actors::DbActor;
use crate::data::FederationData;
use crate::error::Error;
use super::helpers::{check_guards, extract_and_dispatch_mentions};
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct UpdateActivity {
pub(crate) id: Url,
#[serde(rename = "type", default)]
pub(crate) kind: UpdateType,
pub(crate) actor: ObjectId<DbActor>,
pub(crate) object: serde_json::Value,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub(crate) to: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub(crate) cc: Vec<String>,
}
#[async_trait::async_trait]
impl Activity for UpdateActivity {
type DataType = FederationData;
type Error = Error;
fn id(&self) -> &Url {
&self.id
}
fn actor(&self) -> &Url {
self.actor.inner()
}
async fn verify(&self, _data: &Data<Self::DataType>) -> Result<(), Self::Error> {
if let Some(attributed_to) = self.object.get("attributedTo").and_then(|v| v.as_str())
&& let Ok(attributed_url) = Url::parse(attributed_to)
&& &attributed_url != self.actor.inner()
{
return Err(Error::bad_request(anyhow::anyhow!(
"Update actor does not match object attributedTo"
)));
}
Ok(())
}
async fn receive(self, data: &Data<Self::DataType>) -> Result<(), Self::Error> {
if check_guards(&self.id, self.actor.inner(), data).await? {
return Ok(());
}
let ap_id = self
.object
.get("id")
.and_then(|v| v.as_str())
.and_then(|s| Url::parse(s).ok())
.unwrap_or_else(|| self.id.clone());
let actor_url = self.actor.inner().clone();
extract_and_dispatch_mentions(&ap_id, &actor_url, &self.object, data).await;
data.object_handler
.on_update(&ap_id, &actor_url, self.object)
.await
.map_err(|e| Error::from(anyhow::anyhow!(e)))?;
tracing::info!(actor = %actor_url, "received update activity");
Ok(())
}
}

View File

@@ -6,20 +6,19 @@ use axum::extract::Path;
use crate::actors::{Person, get_local_actor}; use crate::actors::{Person, get_local_actor};
use crate::data::FederationData; use crate::data::FederationData;
use crate::error::Error; use crate::error::Error;
use crate::urls::actor_ap_context;
/// Serves the AP actor JSON for a local user.
/// The path parameter is the user's UUID (matching the canonical actor URL).
pub async fn actor_handler( pub async fn actor_handler(
Path(username): Path<String>, Path(user_id_str): Path<String>,
data: Data<FederationData>, data: Data<FederationData>,
) -> Result<FederationJson<WithContext<Person>>, Error> { ) -> Result<FederationJson<WithContext<Person>>, Error> {
let ap_user = data let user_id = uuid::Uuid::parse_str(&user_id_str)
.user_repo .map_err(|_| Error::not_found(anyhow::anyhow!("user not found")))?;
.find_by_username(&username)
.await
.map_err(Error::from)?
.ok_or_else(|| Error::bad_request(anyhow::anyhow!("user not found")))?;
let db_actor = get_local_actor(ap_user.id, &data).await?; let db_actor = get_local_actor(user_id, &data).await?;
let person = db_actor.into_json(&data).await?; let person = db_actor.into_json(&data).await?;
Ok(FederationJson(WithContext::new_default(person))) Ok(FederationJson(WithContext::new(person, actor_ap_context())))
} }

View File

@@ -2,24 +2,27 @@ use activitypub_federation::{
config::Data, config::Data,
fetch::object_id::ObjectId, fetch::object_id::ObjectId,
http_signatures::generate_actor_keypair, http_signatures::generate_actor_keypair,
kinds::actor::PersonType,
protocol::{public_key::PublicKey, verification::verify_domains_match}, protocol::{public_key::PublicKey, verification::verify_domains_match},
traits::{Actor, Object}, traits::{Actor, Object},
}; };
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use url::Url; use url::Url;
use zeroize::Zeroizing;
use crate::data::FederationData; use crate::data::FederationData;
use crate::error::Error; use crate::error::Error;
use crate::repository::RemoteActor; use crate::repository::RemoteActor;
use crate::user::ApProfileField; use crate::user::{ApActorType, ApProfileField};
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct DbActor { pub struct DbActor {
pub user_id: uuid::Uuid, pub user_id: uuid::Uuid,
pub username: String, pub username: String,
pub display_name: Option<String>,
pub public_key_pem: String, pub public_key_pem: String,
/// Private key PEM. Only populated for local actors during signing.
/// Cleared automatically when `DbActor` is dropped.
pub private_key_pem: Option<String>, pub private_key_pem: Option<String>,
pub inbox_url: Url, pub inbox_url: Url,
pub shared_inbox_url: Option<Url>, pub shared_inbox_url: Option<Url>,
@@ -31,9 +34,13 @@ pub struct DbActor {
pub bio: Option<String>, pub bio: Option<String>,
pub avatar_url: Option<Url>, pub avatar_url: Option<Url>,
pub banner_url: Option<Url>, pub banner_url: Option<Url>,
pub also_known_as: Option<String>, pub also_known_as: Vec<String>,
pub profile_url: Option<Url>, pub profile_url: Option<Url>,
pub attachment: Vec<ApProfileField>, pub attachment: Vec<ApProfileField>,
pub manually_approves_followers: bool,
pub discoverable: bool,
pub actor_type: ApActorType,
pub featured_url: Option<Url>,
} }
#[derive(Debug, Clone, Deserialize, Serialize)] #[derive(Debug, Clone, Deserialize, Serialize)]
@@ -61,14 +68,19 @@ pub struct ProfileFieldObject {
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct Person { pub struct Person {
#[serde(rename = "type")] #[serde(rename = "type")]
kind: PersonType, kind: ApActorType,
id: ObjectId<DbActor>, id: ObjectId<DbActor>,
#[serde(default)]
preferred_username: String, preferred_username: String,
inbox: Url, inbox: Url,
outbox: Url, #[serde(default)]
followers: Url, outbox: Option<Url>,
following: Url, #[serde(default)]
public_key: PublicKey, followers: Option<Url>,
#[serde(default)]
following: Option<Url>,
pub public_key: PublicKey,
#[serde(default)]
name: Option<String>, name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
summary: Option<String>, summary: Option<String>,
@@ -78,6 +90,7 @@ pub struct Person {
url: Option<Url>, url: Option<Url>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
discoverable: Option<bool>, discoverable: Option<bool>,
#[serde(default)]
manually_approves_followers: bool, manually_approves_followers: bool,
#[serde(skip_serializing_if = "Option::is_none", default)] #[serde(skip_serializing_if = "Option::is_none", default)]
updated: Option<DateTime<Utc>>, updated: Option<DateTime<Utc>>,
@@ -89,6 +102,8 @@ pub struct Person {
also_known_as: Vec<String>, also_known_as: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty", default)] #[serde(skip_serializing_if = "Vec::is_empty", default)]
attachment: Vec<ProfileFieldObject>, attachment: Vec<ProfileFieldObject>,
#[serde(skip_serializing_if = "Option::is_none")]
featured: Option<Url>,
} }
struct ActorUrls { struct ActorUrls {
@@ -118,24 +133,43 @@ pub async fn get_local_actor(
user_id: uuid::Uuid, user_id: uuid::Uuid,
data: &Data<FederationData>, data: &Data<FederationData>,
) -> Result<DbActor, Error> { ) -> Result<DbActor, Error> {
let user = data build_local_actor(
.user_repo user_id,
.find_by_id(user_id) &data.base_url,
.await data.user_repo.as_ref(),
.map_err(Error::from)? data.actor_repo.as_ref(),
.ok_or_else(|| Error::not_found(anyhow::anyhow!("user not found: {}", user_id)))?; )
.await
.map_err(|e| Error::not_found(anyhow::anyhow!("{e}")))
}
let (public_key, private_key) = match data /// Build a local actor's `DbActor` from repository data. Generates a keypair
.federation_repo /// if one doesn't exist yet. Usable outside of a `FederationData` context
.get_local_actor_keypair(user_id) /// (e.g. during service construction).
pub async fn build_local_actor(
user_id: uuid::Uuid,
base_url: &str,
user_repo: &dyn crate::user::ApUserRepository,
actor_repo: &dyn crate::repository::ActorRepository,
) -> anyhow::Result<DbActor> {
let user = user_repo
.find_by_id(user_id)
.await? .await?
{ .ok_or_else(|| anyhow::anyhow!("user not found: {}", user_id))?;
let (public_key, private_key) = match actor_repo.get_local_actor_keypair(user_id).await? {
Some(kp) => kp, Some(kp) => kp,
None => { None => {
let kp = generate_actor_keypair()?; let kp = generate_actor_keypair()?;
data.federation_repo let private_zeroized = Zeroizing::new(kp.private_key.clone());
.save_local_actor_keypair(user_id, kp.public_key.clone(), kp.private_key.clone()) actor_repo
.save_local_actor_keypair(
user_id,
kp.public_key.clone(),
private_zeroized.clone().to_string(),
)
.await?; .await?;
drop(private_zeroized);
(kp.public_key, kp.private_key) (kp.public_key, kp.private_key)
} }
}; };
@@ -147,11 +181,12 @@ pub async fn get_local_actor(
outbox_url, outbox_url,
followers_url, followers_url,
following_url, following_url,
} = ActorUrls::build(&data.base_url, user_id); } = ActorUrls::build(base_url, user_id);
Ok(DbActor { Ok(DbActor {
user_id, user_id,
username: user.username, username: user.username,
display_name: user.display_name,
public_key_pem: public_key, public_key_pem: public_key,
private_key_pem: Some(private_key), private_key_pem: Some(private_key),
inbox_url, inbox_url,
@@ -167,9 +202,18 @@ pub async fn get_local_actor(
also_known_as: user.also_known_as, also_known_as: user.also_known_as,
profile_url: user.profile_url, profile_url: user.profile_url,
attachment: user.attachment, attachment: user.attachment,
manually_approves_followers: user.manually_approves_followers,
discoverable: user.discoverable,
actor_type: user.actor_type,
featured_url: user.featured_url,
}) })
} }
fn apex_domain(url: &Url) -> String {
let host = url.host_str().unwrap_or("");
host.strip_prefix("www.").unwrap_or(host).to_owned()
}
#[async_trait::async_trait] #[async_trait::async_trait]
impl Object for DbActor { impl Object for DbActor {
type DataType = FederationData; type DataType = FederationData;
@@ -197,10 +241,7 @@ impl Object for DbActor {
_ => return Ok(None), _ => return Ok(None),
}; };
let keypair = data let keypair = data.actor_repo.get_local_actor_keypair(user_id).await?;
.federation_repo
.get_local_actor_keypair(user_id)
.await?;
let (public_key, private_key) = match keypair { let (public_key, private_key) = match keypair {
Some(kp) => (kp.0, Some(kp.1)), Some(kp) => (kp.0, Some(kp.1)),
@@ -218,7 +259,8 @@ impl Object for DbActor {
Ok(Some(DbActor { Ok(Some(DbActor {
user_id, user_id,
username: user.username, username: user.username.clone(),
display_name: user.display_name,
public_key_pem: public_key, public_key_pem: public_key,
private_key_pem: private_key, private_key_pem: private_key,
inbox_url, inbox_url,
@@ -228,12 +270,16 @@ impl Object for DbActor {
following_url, following_url,
ap_id, ap_id,
last_refreshed_at: Utc::now(), last_refreshed_at: Utc::now(),
bio: None, bio: user.bio,
avatar_url: None, avatar_url: user.avatar_url,
banner_url: None, banner_url: user.banner_url,
also_known_as: None, also_known_as: user.also_known_as,
profile_url: None, profile_url: user.profile_url,
attachment: vec![], attachment: user.attachment,
manually_approves_followers: user.manually_approves_followers,
discoverable: user.discoverable,
actor_type: user.actor_type,
featured_url: user.featured_url,
})) }))
} }
@@ -252,8 +298,7 @@ impl Object for DbActor {
kind: "Image".to_string(), kind: "Image".to_string(),
url, url,
}); });
let profile_url = self.profile_url; let also_known_as = self.also_known_as;
let also_known_as: Vec<String> = self.also_known_as.into_iter().collect();
let attachment: Vec<ProfileFieldObject> = self let attachment: Vec<ProfileFieldObject> = self
.attachment .attachment
.into_iter() .into_iter()
@@ -268,25 +313,26 @@ impl Object for DbActor {
Url::parse(&format!("{}/inbox", data.base_url)).expect("base_url is always valid"); Url::parse(&format!("{}/inbox", data.base_url)).expect("base_url is always valid");
Ok(Person { Ok(Person {
kind: Default::default(), kind: self.actor_type,
id: self.ap_id.clone().into(), id: self.ap_id.clone().into(),
preferred_username: self.username.clone(), preferred_username: self.username.clone(),
inbox: self.inbox_url.clone(), inbox: self.inbox_url.clone(),
outbox: self.outbox_url.clone(), outbox: Some(self.outbox_url.clone()),
followers: self.followers_url.clone(), followers: Some(self.followers_url.clone()),
following: self.following_url.clone(), following: Some(self.following_url.clone()),
public_key, public_key,
name: Some(self.username.clone()), name: self.display_name.or_else(|| Some(self.username.clone())),
summary: self.bio.clone(), summary: self.bio.clone(),
icon, icon,
url: profile_url, url: self.profile_url,
discoverable: Some(true), discoverable: Some(self.discoverable),
manually_approves_followers: true, manually_approves_followers: self.manually_approves_followers,
updated: Some(self.last_refreshed_at), updated: Some(self.last_refreshed_at),
endpoints: Some(Endpoints { shared_inbox }), endpoints: Some(Endpoints { shared_inbox }),
image, image,
also_known_as, also_known_as,
attachment, attachment,
featured: self.featured_url,
}) })
} }
@@ -295,11 +341,26 @@ impl Object for DbActor {
expected_domain: &Url, expected_domain: &Url,
_data: &Data<Self::DataType>, _data: &Data<Self::DataType>,
) -> Result<(), Self::Error> { ) -> Result<(), Self::Error> {
verify_domains_match(json.id.inner(), expected_domain)?; if verify_domains_match(json.id.inner(), expected_domain).is_ok() {
Ok(()) return Ok(());
}
if apex_domain(json.id.inner()) == apex_domain(expected_domain) {
tracing::debug!(
actor_id = %json.id.inner(),
expected = %expected_domain,
"domain verified via www-apex equivalence"
);
return Ok(());
}
verify_domains_match(json.id.inner(), expected_domain).map_err(Error::from)
} }
async fn from_json(json: Self::Kind, data: &Data<Self::DataType>) -> Result<Self, Self::Error> { async fn from_json(json: Self::Kind, data: &Data<Self::DataType>) -> Result<Self, Self::Error> {
tracing::debug!(
actor_id = %json.id.inner(),
username = %json.preferred_username,
"ingesting remote actor"
);
let shared_inbox_url = json.endpoints.as_ref().map(|e| e.shared_inbox.to_string()); let shared_inbox_url = json.endpoints.as_ref().map(|e| e.shared_inbox.to_string());
let actor = RemoteActor { let actor = RemoteActor {
url: json.id.inner().to_string(), url: json.id.inner().to_string(),
@@ -308,9 +369,15 @@ impl Object for DbActor {
shared_inbox_url, shared_inbox_url,
display_name: json.name.clone(), display_name: json.name.clone(),
avatar_url: json.icon.as_ref().map(|i| i.url.to_string()), avatar_url: json.icon.as_ref().map(|i| i.url.to_string()),
outbox_url: Some(json.outbox.to_string()), outbox_url: json.outbox.as_ref().map(|u| u.to_string()),
bio: json.summary.clone(),
banner_url: json.image.as_ref().map(|i| i.url.to_string()),
followers_url: json.followers.as_ref().map(|u| u.to_string()),
following_url: json.following.as_ref().map(|u| u.to_string()),
also_known_as: json.also_known_as.clone(),
fetched_at: Some(Utc::now()),
}; };
data.federation_repo.upsert_remote_actor(actor).await?; data.actor_repo.upsert_remote_actor(actor).await?;
let url_str = json.id.inner().to_string(); let url_str = json.id.inner().to_string();
let user_id = uuid::Uuid::new_v5(&uuid::Uuid::NAMESPACE_URL, url_str.as_bytes()); let user_id = uuid::Uuid::new_v5(&uuid::Uuid::NAMESPACE_URL, url_str.as_bytes());
@@ -320,13 +387,23 @@ impl Object for DbActor {
.endpoints .endpoints
.as_ref() .as_ref()
.and_then(|e| Url::parse(e.shared_inbox.as_str()).ok()); .and_then(|e| Url::parse(e.shared_inbox.as_str()).ok());
let outbox_url = json.outbox.clone(); let fallback = |suffix: &str| {
let followers_url = json.followers.clone(); Url::parse(&format!("{}{}", ap_id, suffix)).unwrap_or_else(|_| ap_id.clone())
let following_url = json.following.clone(); };
let outbox_url = json.outbox.clone().unwrap_or_else(|| fallback("/outbox"));
let followers_url = json
.followers
.clone()
.unwrap_or_else(|| fallback("/followers"));
let following_url = json
.following
.clone()
.unwrap_or_else(|| fallback("/following"));
Ok(DbActor { Ok(DbActor {
user_id, user_id,
username: json.preferred_username.clone(), username: json.preferred_username.clone(),
display_name: json.name.clone(),
public_key_pem: json.public_key.public_key_pem, public_key_pem: json.public_key.public_key_pem,
private_key_pem: None, private_key_pem: None,
inbox_url, inbox_url,
@@ -339,7 +416,7 @@ impl Object for DbActor {
bio: json.summary.clone(), bio: json.summary.clone(),
avatar_url: json.icon.as_ref().map(|i| i.url.clone()), avatar_url: json.icon.as_ref().map(|i| i.url.clone()),
banner_url: json.image.as_ref().map(|i| i.url.clone()), banner_url: json.image.as_ref().map(|i| i.url.clone()),
also_known_as: json.also_known_as.into_iter().next(), also_known_as: json.also_known_as,
profile_url: json.url.clone(), profile_url: json.url.clone(),
attachment: json attachment: json
.attachment .attachment
@@ -349,6 +426,10 @@ impl Object for DbActor {
value: f.value.clone(), value: f.value.clone(),
}) })
.collect(), .collect(),
manually_approves_followers: json.manually_approves_followers,
discoverable: json.discoverable.unwrap_or(false),
actor_type: json.kind,
featured_url: json.featured,
}) })
} }
} }

View File

@@ -2,17 +2,20 @@ use async_trait::async_trait;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use url::Url; use url::Url;
/// Read side — the library queries this when sending content outward.
/// Implement on the same struct as [`ApObjectHandler`] if you prefer a single
/// database type.
#[async_trait] #[async_trait]
pub trait ApObjectHandler: Send + Sync { pub trait ApContentReader: Send + Sync {
/// Returns (ap_id, serialized object) for all local content owned by this user. /// Newest-first page of locally-authored objects for `user_id`, published
/// Used by outbox (count) and backfill (delivery). Must only return locally-authored content. /// strictly before `before` (pass `None` for the first page).
async fn get_local_objects_for_user( /// Returns `(ap_id, object_json, published_at)` tuples.
&self, ///
user_id: uuid::Uuid, /// Used by the outbox endpoint and by backfill when a new follower is
) -> anyhow::Result<Vec<(Url, serde_json::Value)>>; /// accepted. Implementations MUST:
/// - Return objects in descending `published_at` order.
/// Returns up to `limit` objects ordered newest-first, published before `before`. /// - Exclude deleted and draft content.
/// Returns (ap_id, object_json, published_at). /// - Be consistent across pages (no duplicates, no gaps).
async fn get_local_objects_page( async fn get_local_objects_page(
&self, &self,
user_id: uuid::Uuid, user_id: uuid::Uuid,
@@ -20,7 +23,38 @@ pub trait ApObjectHandler: Send + Sync {
limit: usize, limit: usize,
) -> anyhow::Result<Vec<(Url, serde_json::Value, DateTime<Utc>)>>; ) -> anyhow::Result<Vec<(Url, serde_json::Value, DateTime<Utc>)>>;
/// Incoming Create activity — persist remote content. /// Total locally-authored posts across all users. Used by NodeInfo.
async fn count_local_posts(&self) -> anyhow::Result<u64>;
/// AP URLs of pinned (featured) objects for this user, in display order.
///
/// Served at `GET /users/{id}/featured` as an `OrderedCollection`.
/// Mastodon and Pleroma follow this link from the actor's `featured` field.
///
/// Defaults to an empty list — override to expose pinned posts.
async fn get_featured_objects(&self, user_id: uuid::Uuid) -> anyhow::Result<Vec<url::Url>> {
let _ = user_id;
Ok(vec![])
}
}
/// Write side — the library calls these when processing inbound AP activities.
///
/// All methods are called after HTTP signature verification has passed.
/// Returning `Err` propagates a 500 back to the remote server, which will
/// trigger a retry from well-behaved implementations. Return `Ok(())` to
/// silently accept an activity you don't want to act on.
///
/// **Idempotency:** Methods may be called more than once for the same activity
/// (e.g. under a race during duplicate delivery). Implementations should be
/// idempotent — prefer upsert over insert.
#[async_trait]
pub trait ApObjectHandler: Send + Sync {
/// A remote actor published new content.
///
/// `ap_id` is the stable URL of the object (e.g. the Note URL, not the
/// Create activity URL). Store or index the `object` JSON as appropriate
/// for your domain.
async fn on_create( async fn on_create(
&self, &self,
ap_id: &Url, ap_id: &Url,
@@ -28,7 +62,10 @@ pub trait ApObjectHandler: Send + Sync {
object: serde_json::Value, object: serde_json::Value,
) -> anyhow::Result<()>; ) -> anyhow::Result<()>;
/// Incoming Update activity — update existing remote content. /// A remote actor edited existing content.
///
/// `ap_id` matches a previously received `on_create` call. Update the
/// stored object.
async fn on_update( async fn on_update(
&self, &self,
ap_id: &Url, ap_id: &Url,
@@ -36,33 +73,55 @@ pub trait ApObjectHandler: Send + Sync {
object: serde_json::Value, object: serde_json::Value,
) -> anyhow::Result<()>; ) -> anyhow::Result<()>;
/// Incoming Delete activity — remove specific remote content. /// A remote actor deleted an object previously delivered via `on_create`.
async fn on_delete(&self, ap_id: &Url, actor_url: &Url) -> anyhow::Result<()>; async fn on_delete(&self, ap_id: &Url, actor_url: &Url) -> anyhow::Result<()>;
/// Actor unfollowed/was removed — clean up all their remote content. /// A remote actor was deleted or has unfollowed all local users.
///
/// Remove all content and state associated with `actor_url` from local
/// storage. Called for `Delete(actor)` and for `Undo(Follow)`.
async fn on_actor_removed(&self, actor_url: &Url) -> anyhow::Result<()>; async fn on_actor_removed(&self, actor_url: &Url) -> anyhow::Result<()>;
/// Called when a remote actor likes a local thought. /// A remote actor liked a locally-authored object.
/// `object_url` is the AP URL of the liked note (e.g. `{base}/thoughts/{uuid}`).
/// `actor_url` is the AP URL of the remote actor who sent the Like.
async fn on_like(&self, object_url: &Url, actor_url: &Url) -> anyhow::Result<()>; async fn on_like(&self, object_url: &Url, actor_url: &Url) -> anyhow::Result<()>;
/// Called when a remote actor boosts (Announce) a local thought. /// A remote actor removed their like from a locally-authored object.
/// `object_url` is the AP URL of the announced note.
/// `actor_url` is the AP URL of the remote actor who sent the Announce.
async fn on_announce_received(&self, object_url: &Url, actor_url: &Url) -> anyhow::Result<()>;
/// Called when a remote actor removes a Like from a local thought.
async fn on_unlike(&self, object_url: &Url, actor_url: &Url) -> anyhow::Result<()>; async fn on_unlike(&self, object_url: &Url, actor_url: &Url) -> anyhow::Result<()>;
/// Called when an inbound Note tags a local user with a Mention. /// A remote actor boosted (Announced) a **locally-authored** object.
///
/// `object_url` is your local object's AP URL. The boost count is tracked
/// separately in [`crate::repository::ActorRepository::count_announces`].
async fn on_announce_received(&self, object_url: &Url, actor_url: &Url) -> anyhow::Result<()>;
/// A remote actor removed their boost (`Undo(Announce)`) of a locally-authored
/// object. Use this to decrement boost counts or update UI.
///
/// Has a default no-op implementation — override to handle undone boosts.
async fn on_announce_removed(&self, object_url: &Url, actor_url: &Url) -> anyhow::Result<()> {
let _ = (object_url, actor_url);
Ok(())
}
/// A remote actor boosted an object hosted on a **different server**.
///
/// Use this to surface cross-server boosts in local feeds. Called instead
/// of `on_announce_received` when the announced object URL is external.
/// Failures are logged and swallowed — they do not fail the activity.
async fn on_announce_of_remote(&self, object_url: &Url, actor_url: &Url) -> anyhow::Result<()>;
/// A local user was tagged (Mentioned) in an inbound Create or Update.
///
/// Called for every `{"type":"Mention","href":"<local-actor-url>"}` tag
/// found in inbound activities. Use this to send in-app notifications.
/// The note content is also delivered independently via `on_create`.
///
/// Failures are logged and swallowed — a broken notification must not
/// cause the activity to be rejected.
async fn on_mention( async fn on_mention(
&self, &self,
thought_ap_id: &Url, thought_ap_id: &Url,
mentioned_user_uuid: uuid::Uuid, mentioned_user_uuid: uuid::Uuid,
actor_url: &Url, actor_url: &Url,
) -> anyhow::Result<()>; ) -> anyhow::Result<()>;
/// Total number of locally-authored posts across all users.
async fn count_local_posts(&self) -> anyhow::Result<u64>;
} }

View File

@@ -1,38 +1,86 @@
use std::sync::Arc; use std::sync::Arc;
use crate::content::ApObjectHandler; use crate::content::{ApContentReader, ApObjectHandler};
use crate::repository::FederationRepository; use crate::repository::{
ActivityRepository, ActorRepository, BlocklistRepository, FollowRepository,
};
use crate::user::ApUserRepository; use crate::user::ApUserRepository;
/// Minimal event-publishing abstraction — project-specific implementations /// Typed event emitted by the federation layer.
/// are wired in by the consuming crate via `FederationData::new`. ///
/// **Delivery:** When an [`EventPublisher`] is configured, outbound activities
/// are published as [`FederationEvent::DeliveryRequested`] instead of being sent
/// directly. Process them by calling
/// [`crate::service::ActivityPubService::deliver_to_inbox`].
///
/// **Backfill:** When a follower is accepted and an [`EventPublisher`] is
/// configured, [`FederationEvent::BackfillRequested`] is published instead of
/// spawning an in-process task. Process it by calling
/// [`crate::service::ActivityPubService::run_backfill_for_follower`].
///
/// Without a publisher, both fall back to `tokio::spawn`.
#[derive(Debug, Clone)]
pub enum FederationEvent {
/// An outbound activity must be delivered to `inbox`.
/// Call `ActivityPubService::deliver_to_inbox(inbox, activity, signing_actor_id)`.
DeliveryRequested {
inbox: url::Url,
activity: serde_json::Value,
signing_actor_id: uuid::Uuid,
},
/// Delivery to `inbox` failed permanently after all in-process retries.
DeliveryFailed {
inbox: url::Url,
activity: serde_json::Value,
signing_actor_id: uuid::Uuid,
error: String,
},
/// A new follower was accepted and their inbox needs backfilling.
/// Call `ActivityPubService::run_backfill_for_follower(owner_user_id, follower_inbox_url)`.
BackfillRequested {
owner_user_id: uuid::Uuid,
follower_inbox_url: String,
},
}
/// Receives typed federation events.
#[async_trait::async_trait] #[async_trait::async_trait]
pub trait EventPublisher: Send + Sync { pub trait EventPublisher: Send + Sync {
async fn publish(&self, event: &str) -> anyhow::Result<()>; async fn publish(&self, event: FederationEvent) -> anyhow::Result<()>;
} }
#[derive(Clone)] #[derive(Clone)]
pub struct FederationData { pub struct FederationData {
pub(crate) federation_repo: Arc<dyn FederationRepository>, pub(crate) activity_repo: Arc<dyn ActivityRepository>,
pub(crate) follow_repo: Arc<dyn FollowRepository>,
pub(crate) actor_repo: Arc<dyn ActorRepository>,
pub(crate) blocklist_repo: Arc<dyn BlocklistRepository>,
pub(crate) user_repo: Arc<dyn ApUserRepository>, pub(crate) user_repo: Arc<dyn ApUserRepository>,
pub(crate) content_reader: Arc<dyn ApContentReader>,
pub(crate) object_handler: Arc<dyn ApObjectHandler>, pub(crate) object_handler: Arc<dyn ApObjectHandler>,
pub(crate) base_url: String, pub(crate) base_url: String,
pub(crate) domain: String, pub(crate) domain: String,
pub(crate) allow_registration: bool, pub(crate) allow_registration: bool,
pub(crate) software_name: String, pub(crate) software_name: String,
#[allow(dead_code)]
pub(crate) event_publisher: Option<Arc<dyn EventPublisher>>, pub(crate) event_publisher: Option<Arc<dyn EventPublisher>>,
pub(crate) actor_cache_ttl: std::time::Duration,
} }
impl FederationData { impl FederationData {
#[allow(clippy::too_many_arguments)]
pub fn new( pub fn new(
federation_repo: Arc<dyn FederationRepository>, activity_repo: Arc<dyn ActivityRepository>,
follow_repo: Arc<dyn FollowRepository>,
actor_repo: Arc<dyn ActorRepository>,
blocklist_repo: Arc<dyn BlocklistRepository>,
user_repo: Arc<dyn ApUserRepository>, user_repo: Arc<dyn ApUserRepository>,
content_reader: Arc<dyn ApContentReader>,
object_handler: Arc<dyn ApObjectHandler>, object_handler: Arc<dyn ApObjectHandler>,
base_url: String, base_url: String,
allow_registration: bool, allow_registration: bool,
software_name: String, software_name: String,
event_publisher: Option<Arc<dyn EventPublisher>>, event_publisher: Option<Arc<dyn EventPublisher>>,
actor_cache_ttl: std::time::Duration,
) -> Self { ) -> Self {
let domain = base_url let domain = base_url
.trim_start_matches("https://") .trim_start_matches("https://")
@@ -42,14 +90,19 @@ impl FederationData {
.unwrap_or("") .unwrap_or("")
.to_string(); .to_string();
Self { Self {
federation_repo, activity_repo,
follow_repo,
actor_repo,
blocklist_repo,
user_repo, user_repo,
content_reader,
object_handler, object_handler,
base_url, base_url,
domain, domain,
allow_registration, allow_registration,
software_name, software_name,
event_publisher, event_publisher,
actor_cache_ttl,
} }
} }
} }

View File

@@ -33,15 +33,18 @@ where
impl axum::response::IntoResponse for Error { impl axum::response::IntoResponse for Error {
fn into_response(self) -> axum::response::Response { fn into_response(self) -> axum::response::Response {
let status = self.1; let status = self.1;
// Always log the real error internally; never expose it to the client.
if status.is_server_error() { if status.is_server_error() {
tracing::error!(error = %self.0, status = status.as_u16(), "federation error"); tracing::error!(error = %self.0, status = status.as_u16(), "federation error");
} else { } else {
tracing::debug!(error = %self.0, status = status.as_u16(), "federation response"); tracing::debug!(error = %self.0, status = status.as_u16(), "federation client error");
} }
let body = if status.is_server_error() { let body = match status {
"internal server error".to_string() StatusCode::NOT_FOUND => "not found",
} else { StatusCode::BAD_REQUEST => "bad request",
self.0.to_string() StatusCode::UNAUTHORIZED => "unauthorized",
StatusCode::FORBIDDEN => "forbidden",
_ => "internal server error",
}; };
(status, body).into_response() (status, body).into_response()
} }

42
src/featured_handler.rs Normal file
View File

@@ -0,0 +1,42 @@
use activitypub_federation::{axum::json::FederationJson, config::Data};
use axum::extract::Path;
use serde_json::json;
use crate::data::FederationData;
use crate::error::Error;
use crate::urls::AP_CONTEXT;
/// Serves the `featured` (pinned posts) `OrderedCollection` for a local user.
///
/// Remote servers follow the `featured` link from the actor JSON and expect
/// an `OrderedCollection` whose `orderedItems` are the AP URLs of pinned objects.
/// The handler calls [`ApContentReader::get_featured_objects`] — override that
/// method to expose your pinned posts.
pub async fn featured_handler(
Path(user_id_str): Path<String>,
data: Data<FederationData>,
) -> Result<FederationJson<serde_json::Value>, Error> {
let user_id = uuid::Uuid::parse_str(&user_id_str)
.map_err(|_| Error::not_found(anyhow::anyhow!("user not found")))?;
data.user_repo
.find_by_id(user_id)
.await
.map_err(Error::from)?
.ok_or_else(|| Error::not_found(anyhow::anyhow!("user not found")))?;
let featured_url = format!("{}/users/{}/featured", data.base_url, user_id_str);
let items = data
.content_reader
.get_featured_objects(user_id)
.await
.map_err(|e| Error::from(anyhow::anyhow!("{}", e)))?;
Ok(FederationJson(json!({
"@context": AP_CONTEXT,
"type": "OrderedCollection",
"id": featured_url,
"totalItems": items.len(),
"orderedItems": items.iter().map(|u| u.as_str()).collect::<Vec<_>>(),
})))
}

View File

@@ -2,6 +2,7 @@ use activitypub_federation::config::{Data, FederationConfig, FederationMiddlewar
use activitypub_federation::error::Error as FedError; use activitypub_federation::error::Error as FedError;
use url::Url; use url::Url;
use crate::actors::DbActor;
use crate::data::FederationData; use crate::data::FederationData;
#[derive(Clone)] #[derive(Clone)]
@@ -18,7 +19,24 @@ impl UrlVerifier for PermissiveVerifier {
pub struct ApFederationConfig(pub FederationConfig<FederationData>); pub struct ApFederationConfig(pub FederationConfig<FederationData>);
impl ApFederationConfig { impl ApFederationConfig {
pub async fn new(data: FederationData, debug: bool) -> anyhow::Result<Self> { /// Create a new federation config.
///
/// **HTTP signature / Digest behavior:**
/// - Production (`debug = false`): strict normalization + **requires `Digest` header** on every
/// inbound POST. All major AP implementations (Mastodon, Pleroma, Pixelfed) include it.
/// - Debug (`debug = true`): relaxes Digest requirement, disables signature verification,
/// and accepts any URL. **Never use in production.**
///
/// Outbound signing always uses Mastodon compat mode regardless of this flag.
///
/// When `signing_actor` is provided, all outgoing fetch requests (GETs) are
/// signed with that actor's keypair — required for instances with
/// authorized-fetch / Secure Mode enabled.
pub async fn new(
data: FederationData,
debug: bool,
signing_actor: Option<&DbActor>,
) -> anyhow::Result<Self> {
let config = if debug { let config = if debug {
FederationConfig::builder() FederationConfig::builder()
.domain(&data.domain) .domain(&data.domain)
@@ -29,12 +47,16 @@ impl ApFederationConfig {
.build() .build()
.await? .await?
} else { } else {
FederationConfig::builder() let mut builder = FederationConfig::builder();
builder
.domain(&data.domain) .domain(&data.domain)
.url_verifier(Box::new(crate::security::SsrfVerifier))
.app_data(data) .app_data(data)
.debug(false) .debug(false);
.build() if let Some(actor) = signing_actor {
.await? builder.signed_fetch_actor(actor);
}
builder.build().await?
}; };
Ok(Self(config)) Ok(Self(config))
} }

View File

@@ -33,8 +33,8 @@ async fn collection_handler(
); );
let total = match collection_type { let total = match collection_type {
"followers" => data.federation_repo.count_followers(user_id).await, "followers" => data.follow_repo.count_followers(user_id).await,
_ => data.federation_repo.count_following(user_id).await, _ => data.follow_repo.count_following(user_id).await,
} }
.map_err(Error::from)?; .map_err(Error::from)?;
@@ -44,7 +44,7 @@ async fn collection_handler(
let items: Vec<String> = match collection_type { let items: Vec<String> = match collection_type {
"followers" => data "followers" => data
.federation_repo .follow_repo
.get_followers_page(user_id, offset as u32, AP_PAGE_SIZE) .get_followers_page(user_id, offset as u32, AP_PAGE_SIZE)
.await .await
.map_err(Error::from)? .map_err(Error::from)?
@@ -52,7 +52,7 @@ async fn collection_handler(
.map(|f| f.actor.url) .map(|f| f.actor.url)
.collect(), .collect(),
_ => data _ => data
.federation_repo .follow_repo
.get_following_page(user_id, offset as u32, AP_PAGE_SIZE) .get_following_page(user_id, offset as u32, AP_PAGE_SIZE)
.await .await
.map_err(Error::from)? .map_err(Error::from)?

View File

@@ -9,6 +9,11 @@ use crate::actors::DbActor;
use crate::data::FederationData; use crate::data::FederationData;
use crate::error::Error; use crate::error::Error;
/// Idempotency is enforced inside each activity's `receive()` implementation
/// via `FederationRepository::is_activity_processed` /
/// `mark_activity_processed`. HTTP signature verification and JSON-LD
/// processing are handled by `activitypub_federation` middleware before this
/// handler is reached.
pub async fn inbox_handler( pub async fn inbox_handler(
data: Data<FederationData>, data: Data<FederationData>,
activity_data: ActivityData, activity_data: ActivityData,

View File

@@ -1 +1,45 @@
// placeholder — filled in Task 4 pub mod activities;
pub mod actor_handler;
pub mod actors;
pub mod content;
pub mod data;
pub mod error;
pub mod featured_handler;
pub mod federation;
pub mod followers_handler;
pub mod inbox;
pub mod nodeinfo;
pub mod outbox;
pub mod repository;
pub(crate) mod security;
pub mod service;
pub(crate) mod urls;
pub mod user;
pub mod webfinger;
pub use activitypub_federation::kinds::object::NoteType;
pub use content::{ApContentReader, ApObjectHandler};
pub use data::{EventPublisher, FederationData, FederationEvent};
pub use error::Error;
pub use federation::ApFederationConfig;
pub use repository::{
ActivityRepository, ActorRepository, BlockedDomain, BlocklistRepository, FollowRepository,
Follower, FollowerStatus, FollowingStatus, RemoteActor,
};
pub use service::ActivityPubService;
pub use urls::AS_PUBLIC;
pub use user::{
ApActorType, ApProfileField, ApUser, ApUserRepository, ApVisibility, LookedUpActor,
};
#[cfg(test)]
#[path = "tests/integration.rs"]
mod integration_tests;
#[cfg(test)]
#[path = "tests/activities.rs"]
mod activity_tests;
#[cfg(test)]
#[path = "tests/broadcast.rs"]
mod broadcast_tests;

View File

@@ -60,7 +60,7 @@ pub async fn nodeinfo_well_known_handler(
pub async fn nodeinfo_handler(data: Data<FederationData>) -> Result<Json<NodeInfo>, Error> { pub async fn nodeinfo_handler(data: Data<FederationData>) -> Result<Json<NodeInfo>, Error> {
let user_count = data.user_repo.count_users().await.unwrap_or(0); let user_count = data.user_repo.count_users().await.unwrap_or(0);
let local_posts = data.object_handler.count_local_posts().await.unwrap_or(0); let local_posts = data.content_reader.count_local_posts().await.unwrap_or(0);
Ok(Json(NodeInfo { Ok(Json(NodeInfo {
version: "2.0".to_string(), version: "2.0".to_string(),

View File

@@ -27,6 +27,7 @@ pub struct OrderedCollection {
id: String, id: String,
total_items: u64, total_items: u64,
first: String, first: String,
last: String,
} }
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize)]
@@ -38,6 +39,7 @@ pub struct OrderedCollectionPage {
kind: String, kind: String,
id: String, id: String,
part_of: String, part_of: String,
total_items: u64,
ordered_items: Vec<serde_json::Value>, ordered_items: Vec<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
next: Option<String>, next: Option<String>,
@@ -59,11 +61,21 @@ pub async fn outbox_handler(
let outbox_url = format!("{}/users/{}/outbox", data.base_url, user_id_str); let outbox_url = format!("{}/users/{}/outbox", data.base_url, user_id_str);
// Total count — uses count_local_posts for an aggregated count. For a
// per-user count we use the page length on the first page as an upper bound
// if count_local_posts returns 0. In practice this trait method is called
// infrequently (only on the root collection endpoint).
let total = data
.content_reader
.count_local_posts()
.await
.map_err(|e| Error::from(anyhow::anyhow!("{}", e)))?;
if query.page.unwrap_or(false) { if query.page.unwrap_or(false) {
let before: Option<DateTime<Utc>> = query.before.as_deref().and_then(|s| s.parse().ok()); let before: Option<DateTime<Utc>> = query.before.as_deref().and_then(|s| s.parse().ok());
let items = data let items = data
.object_handler .content_reader
.get_local_objects_page(uuid, before, AP_PAGE_SIZE) .get_local_objects_page(uuid, before, AP_PAGE_SIZE)
.await .await
.map_err(|e| Error::from(anyhow::anyhow!("{}", e)))?; .map_err(|e| Error::from(anyhow::anyhow!("{}", e)))?;
@@ -114,24 +126,19 @@ pub async fn outbox_handler(
kind: "OrderedCollectionPage".to_string(), kind: "OrderedCollectionPage".to_string(),
id: page_id, id: page_id,
part_of: outbox_url, part_of: outbox_url,
total_items: total,
ordered_items, ordered_items,
next, next,
}) })
.into_response()) .into_response())
} else { } else {
let total = data
.object_handler
.get_local_objects_for_user(uuid)
.await
.map_err(|e| Error::from(anyhow::anyhow!("{}", e)))?
.len() as u64;
Ok(axum::Json(OrderedCollection { Ok(axum::Json(OrderedCollection {
context: crate::urls::AP_CONTEXT.to_string(), context: crate::urls::AP_CONTEXT.to_string(),
kind: "OrderedCollection".to_string(), kind: "OrderedCollection".to_string(),
id: outbox_url.clone(), id: outbox_url.clone(),
total_items: total, total_items: total,
first: format!("{}?page=true", outbox_url), first: format!("{}?page=true", outbox_url),
last: format!("{}?page=true&before=1970-01-01T00:00:00.000Z", outbox_url),
}) })
.into_response()) .into_response())
} }

View File

@@ -0,0 +1,11 @@
use anyhow::Result;
use async_trait::async_trait;
/// Tracks which inbound AP activity IDs have already been processed.
/// Prevents duplicate handling when remote servers retry delivery.
/// Implementations should enforce a UNIQUE constraint on stored IDs.
#[async_trait]
pub trait ActivityRepository: Send + Sync {
async fn is_activity_processed(&self, activity_id: &str) -> Result<bool>;
async fn mark_activity_processed(&self, activity_id: &str) -> Result<()>;
}

37
src/repository/actor.rs Normal file
View File

@@ -0,0 +1,37 @@
use anyhow::Result;
use async_trait::async_trait;
use super::RemoteActor;
/// Manages local actor keypairs, remote actor cache, and Announce tracking.
#[async_trait]
pub trait ActorRepository: Send + Sync {
// ── Local keypairs ──────────────────────────────────────────────────────
async fn get_local_actor_keypair(
&self,
user_id: uuid::Uuid,
) -> Result<Option<(String, String)>>;
async fn save_local_actor_keypair(
&self,
user_id: uuid::Uuid,
public_key: String,
private_key: String,
) -> Result<()>;
// ── Remote actor cache ──────────────────────────────────────────────────
async fn upsert_remote_actor(&self, actor: RemoteActor) -> Result<()>;
async fn get_remote_actor(&self, actor_url: &str) -> Result<Option<RemoteActor>>;
// ── Boost (Announce) tracking ───────────────────────────────────────────
async fn add_announce(
&self,
activity_id: &str,
object_url: &str,
actor_url: &str,
announced_at: chrono::DateTime<chrono::Utc>,
) -> Result<()>;
/// Remove a boost record when a remote actor sends `Undo(Announce)`.
/// Implementations should match by `activity_id` and `actor_url`.
async fn remove_announce(&self, activity_id: &str, actor_url: &str) -> Result<()>;
async fn count_announces(&self, object_url: &str) -> Result<usize>;
}

View File

@@ -0,0 +1,20 @@
use anyhow::Result;
use async_trait::async_trait;
use super::BlockedDomain;
/// Domain and actor-level blocklists.
#[async_trait]
pub trait BlocklistRepository: Send + Sync {
// ── Domain blocklist ────────────────────────────────────────────────────
async fn add_blocked_domain(&self, domain: &str, reason: Option<&str>) -> Result<()>;
async fn remove_blocked_domain(&self, domain: &str) -> Result<()>;
async fn get_blocked_domains(&self) -> Result<Vec<BlockedDomain>>;
async fn is_domain_blocked(&self, domain: &str) -> Result<bool>;
// ── Per-user actor blocklist ────────────────────────────────────────────
async fn add_blocked_actor(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<()>;
async fn remove_blocked_actor(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<()>;
async fn get_blocked_actors(&self, local_user_id: uuid::Uuid) -> Result<Vec<String>>;
async fn is_actor_blocked(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<bool>;
}

View File

@@ -1,45 +1,12 @@
use anyhow::Result; use anyhow::Result;
use async_trait::async_trait; use async_trait::async_trait;
#[derive(Debug, Clone, PartialEq, Eq)] use super::{Follower, FollowerStatus, FollowingStatus, RemoteActor};
pub enum FollowerStatus {
Pending,
Accepted,
Rejected,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FollowingStatus {
Pending,
Accepted,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RemoteActor {
pub url: String,
pub handle: String,
pub inbox_url: String,
pub shared_inbox_url: Option<String>,
pub display_name: Option<String>,
pub avatar_url: Option<String>,
pub outbox_url: Option<String>,
}
#[derive(Debug, Clone)]
pub struct Follower {
pub actor: RemoteActor,
pub status: FollowerStatus,
}
#[derive(Debug, Clone)]
pub struct BlockedDomain {
pub domain: String,
pub reason: Option<String>,
pub blocked_at: String,
}
/// Manages follower/following relationships and account migration.
#[async_trait] #[async_trait]
pub trait FederationRepository: Send + Sync { pub trait FollowRepository: Send + Sync {
// ── Inbound followers ───────────────────────────────────────────────────
async fn add_follower( async fn add_follower(
&self, &self,
local_user_id: uuid::Uuid, local_user_id: uuid::Uuid,
@@ -65,18 +32,29 @@ pub trait FederationRepository: Send + Sync {
limit: usize, limit: usize,
) -> Result<Vec<Follower>>; ) -> Result<Vec<Follower>>;
async fn count_followers(&self, local_user_id: uuid::Uuid) -> Result<usize>; async fn count_followers(&self, local_user_id: uuid::Uuid) -> Result<usize>;
async fn get_following_page(
&self,
local_user_id: uuid::Uuid,
offset: u32,
limit: usize,
) -> Result<Vec<RemoteActor>>;
async fn update_follower_status( async fn update_follower_status(
&self, &self,
local_user_id: uuid::Uuid, local_user_id: uuid::Uuid,
remote_actor_url: &str, remote_actor_url: &str,
status: FollowerStatus, status: FollowerStatus,
) -> Result<()>; ) -> Result<()>;
async fn get_pending_followers(&self, local_user_id: uuid::Uuid) -> Result<Vec<RemoteActor>>;
/// Return deduplicated inbox URLs (shared_inbox preferred) for accepted
/// followers, excluding blocked actors/domains. DB-side filtering.
async fn get_accepted_follower_inboxes(&self, local_user_id: uuid::Uuid)
-> Result<Vec<String>>;
/// Count of accepted followers only. More efficient than loading all followers
/// and filtering in application memory.
async fn count_accepted_followers(&self, local_user_id: uuid::Uuid) -> Result<usize>;
/// Accepted followers page for display purposes. `offset` is 0-based.
async fn get_accepted_followers_page(
&self,
local_user_id: uuid::Uuid,
offset: u32,
limit: usize,
) -> Result<Vec<RemoteActor>>;
// ── Outbound following ──────────────────────────────────────────────────
async fn add_following( async fn add_following(
&self, &self,
local_user_id: uuid::Uuid, local_user_id: uuid::Uuid,
@@ -90,20 +68,13 @@ pub trait FederationRepository: Send + Sync {
) -> Result<Option<String>>; ) -> Result<Option<String>>;
async fn remove_following(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<()>; async fn remove_following(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<()>;
async fn get_following(&self, local_user_id: uuid::Uuid) -> Result<Vec<RemoteActor>>; async fn get_following(&self, local_user_id: uuid::Uuid) -> Result<Vec<RemoteActor>>;
async fn get_following_page(
&self,
local_user_id: uuid::Uuid,
offset: u32,
limit: usize,
) -> Result<Vec<RemoteActor>>;
async fn count_following(&self, local_user_id: uuid::Uuid) -> Result<usize>; async fn count_following(&self, local_user_id: uuid::Uuid) -> Result<usize>;
async fn upsert_remote_actor(&self, actor: RemoteActor) -> Result<()>;
async fn get_remote_actor(&self, actor_url: &str) -> Result<Option<RemoteActor>>;
async fn get_local_actor_keypair(
&self,
user_id: uuid::Uuid,
) -> Result<Option<(String, String)>>;
async fn save_local_actor_keypair(
&self,
user_id: uuid::Uuid,
public_key: String,
private_key: String,
) -> Result<()>;
async fn get_pending_followers(&self, local_user_id: uuid::Uuid) -> Result<Vec<RemoteActor>>;
async fn update_following_status( async fn update_following_status(
&self, &self,
local_user_id: uuid::Uuid, local_user_id: uuid::Uuid,
@@ -115,20 +86,13 @@ pub trait FederationRepository: Send + Sync {
local_user_id: uuid::Uuid, local_user_id: uuid::Uuid,
remote_actor_url: &str, remote_actor_url: &str,
) -> Result<Option<String>>; ) -> Result<Option<String>>;
async fn add_announce(
// ── Account migration ───────────────────────────────────────────────────
/// Migrate all follower records from `old_actor_url` to `new_actor_url`.
/// Returns local user IDs that need a re-follow sent.
async fn migrate_follower_actor(
&self, &self,
activity_id: &str, old_actor_url: &str,
object_url: &str, new_actor_url: &str,
actor_url: &str, ) -> Result<Vec<uuid::Uuid>>;
announced_at: chrono::DateTime<chrono::Utc>,
) -> Result<()>;
async fn count_announces(&self, object_url: &str) -> Result<usize>;
async fn add_blocked_domain(&self, domain: &str, reason: Option<&str>) -> Result<()>;
async fn remove_blocked_domain(&self, domain: &str) -> Result<()>;
async fn get_blocked_domains(&self) -> Result<Vec<BlockedDomain>>;
async fn is_domain_blocked(&self, domain: &str) -> Result<bool>;
async fn add_blocked_actor(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<()>;
async fn remove_blocked_actor(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<()>;
async fn get_blocked_actors(&self, local_user_id: uuid::Uuid) -> Result<Vec<String>>;
async fn is_actor_blocked(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<bool>;
} }

57
src/repository/mod.rs Normal file
View File

@@ -0,0 +1,57 @@
mod activity;
mod actor;
mod blocklist;
mod follow;
pub use activity::ActivityRepository;
pub use actor::ActorRepository;
pub use blocklist::BlocklistRepository;
pub use follow::FollowRepository;
use chrono::{DateTime, Utc};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FollowerStatus {
Pending,
Accepted,
Rejected,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FollowingStatus {
Pending,
Accepted,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RemoteActor {
pub url: String,
pub handle: String,
pub inbox_url: String,
pub shared_inbox_url: Option<String>,
pub display_name: Option<String>,
pub avatar_url: Option<String>,
pub outbox_url: Option<String>,
pub bio: Option<String>,
pub banner_url: Option<String>,
pub followers_url: Option<String>,
pub following_url: Option<String>,
pub also_known_as: Vec<String>,
/// When this actor was last fetched from the origin instance.
/// `None` means unknown — treated as always-fresh to avoid
/// breaking existing consumers that don't populate this field.
pub fetched_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone)]
pub struct Follower {
pub actor: RemoteActor,
pub status: FollowerStatus,
}
#[derive(Debug, Clone)]
pub struct BlockedDomain {
pub domain: String,
pub reason: Option<String>,
pub blocked_at: String,
}

132
src/security.rs Normal file
View File

@@ -0,0 +1,132 @@
use std::net::IpAddr;
use url::Url;
fn is_ip_private(ip: IpAddr) -> bool {
match ip {
IpAddr::V4(v4) => {
v4.is_loopback()
|| v4.is_private()
|| v4.is_link_local()
|| v4.is_broadcast()
|| v4.is_unspecified()
|| v4.octets()[0] == 100 && (v4.octets()[1] & 0xC0) == 64 // 100.64.0.0/10
}
IpAddr::V6(v6) => {
v6.is_loopback()
|| v6.is_unspecified()
|| (v6.segments()[0] & 0xfe00) == 0xfc00 // fc00::/7 (ULA)
|| (v6.segments()[0] & 0xffc0) == 0xfe80 // fe80::/10 (link-local)
}
}
}
/// Resolve a URL's hostname and reject private/reserved IP ranges.
pub(crate) async fn validate_url(url: &Url) -> anyhow::Result<()> {
let host = url
.host_str()
.ok_or_else(|| anyhow::anyhow!("URL has no host: {url}"))?;
let port = url.port_or_known_default().unwrap_or(443);
let addr = format!("{host}:{port}");
let resolved = tokio::net::lookup_host(&addr).await?;
for ip in resolved {
if is_ip_private(ip.ip()) {
anyhow::bail!("SSRF blocked: {url} resolves to private IP {}", ip.ip());
}
}
Ok(())
}
#[derive(Clone)]
pub(crate) struct SsrfVerifier;
#[async_trait::async_trait]
impl activitypub_federation::config::UrlVerifier for SsrfVerifier {
async fn verify(&self, url: &Url) -> Result<(), activitypub_federation::error::Error> {
validate_url(url).await.map_err(|_| {
activitypub_federation::error::Error::UrlVerificationError(
"URL resolves to a private/reserved IP range",
)
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rejects_ipv4_loopback() {
assert!(is_ip_private("127.0.0.1".parse().unwrap()));
assert!(is_ip_private("127.255.255.255".parse().unwrap()));
}
#[test]
fn rejects_ipv4_private_10() {
assert!(is_ip_private("10.0.0.1".parse().unwrap()));
assert!(is_ip_private("10.255.255.255".parse().unwrap()));
}
#[test]
fn rejects_ipv4_private_172() {
assert!(is_ip_private("172.16.0.1".parse().unwrap()));
assert!(is_ip_private("172.31.255.255".parse().unwrap()));
}
#[test]
fn rejects_ipv4_private_192() {
assert!(is_ip_private("192.168.0.1".parse().unwrap()));
assert!(is_ip_private("192.168.255.255".parse().unwrap()));
}
#[test]
fn rejects_ipv4_link_local() {
assert!(is_ip_private("169.254.0.1".parse().unwrap()));
assert!(is_ip_private("169.254.255.255".parse().unwrap()));
}
#[test]
fn rejects_ipv4_unspecified() {
assert!(is_ip_private("0.0.0.0".parse().unwrap()));
}
#[test]
fn rejects_ipv4_cgnat() {
assert!(is_ip_private("100.64.0.1".parse().unwrap()));
assert!(is_ip_private("100.127.255.255".parse().unwrap()));
}
#[test]
fn allows_public_ipv4() {
assert!(!is_ip_private("8.8.8.8".parse().unwrap()));
assert!(!is_ip_private("1.1.1.1".parse().unwrap()));
assert!(!is_ip_private("93.184.216.34".parse().unwrap()));
}
#[test]
fn rejects_ipv6_loopback() {
assert!(is_ip_private("::1".parse().unwrap()));
}
#[test]
fn rejects_ipv6_unspecified() {
assert!(is_ip_private("::".parse().unwrap()));
}
#[test]
fn rejects_ipv6_ula() {
assert!(is_ip_private("fc00::1".parse().unwrap()));
assert!(is_ip_private("fd12:3456::1".parse().unwrap()));
}
#[test]
fn rejects_ipv6_link_local() {
assert!(is_ip_private("fe80::1".parse().unwrap()));
}
#[test]
fn allows_public_ipv6() {
assert!(!is_ip_private("2001:4860:4860::8888".parse().unwrap()));
assert!(!is_ip_private("2606:4700::1111".parse().unwrap()));
}
}

File diff suppressed because it is too large Load Diff

262
src/service/backfill.rs Normal file
View File

@@ -0,0 +1,262 @@
use activitypub_federation::{
activity_sending::SendActivityTask, fetch::object_id::ObjectId, protocol::context::WithContext,
};
use url::Url;
use crate::{activities::CreateActivity, actors::get_local_actor, federation::ApFederationConfig};
use super::{ActivityPubService, delivery::send_with_retry};
impl ActivityPubService {
/// Fetch a remote actor's outbox and import its content into the local instance.
///
/// This is for importing a **remote actor's history** — for example, when you want
/// to surface an account's past posts after a local user follows them. It fetches
/// pages from `outbox_url` and calls `ApObjectHandler::on_create` for each item.
///
/// This is distinct from [`ActivityPubService::run_backfill_for_follower`], which
/// sends **your** local content to a new follower's inbox.
pub async fn import_remote_outbox(
&self,
outbox_url: &str,
actor_url: &str,
) -> anyhow::Result<()> {
let outbox_parsed = url::Url::parse(outbox_url)?;
crate::security::validate_url(&outbox_parsed).await?;
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(
super::HTTP_FETCH_TIMEOUT_SECS,
))
.build()?;
let data = self.federation_config.to_request_data();
let actor = url::Url::parse(actor_url)?;
let root: serde_json::Value = client
.get(outbox_url)
.header("Accept", "application/activity+json")
.send()
.await?
.json()
.await?;
let first = match root.get("first").and_then(|v| v.as_str()) {
Some(url) => url.to_string(),
None => {
tracing::debug!(outbox = %outbox_url, "outbox has no first page");
return Ok(());
}
};
let mut current_url = first;
let mut visited = std::collections::HashSet::new();
loop {
if !visited.insert(current_url.clone()) {
tracing::warn!(url = %current_url, "backfill: loop detected, stopping");
break;
}
if let Ok(page_url) = url::Url::parse(&current_url)
&& let Err(e) = crate::security::validate_url(&page_url).await
{
tracing::warn!(url = %current_url, error = %e, "backfill: SSRF check failed");
break;
}
let page: serde_json::Value = match client
.get(&current_url)
.header("Accept", "application/activity+json")
.send()
.await
{
Ok(resp) => match resp.json().await {
Ok(v) => v,
Err(e) => {
tracing::error!(error = %e, "backfill: failed to parse page JSON");
break;
}
},
Err(e) => {
tracing::error!(error = %e, "backfill: HTTP request failed");
break;
}
};
if let Some(items) = page.get("orderedItems").and_then(|v| v.as_array()) {
for item in items {
let activity_type = item.get("type").and_then(|v| v.as_str()).unwrap_or("");
if activity_type != "Create" && activity_type != "Add" {
continue;
}
let Some(object) = item.get("object").filter(|o| o.is_object()).cloned() else {
continue;
};
let Some(ap_id) = object
.get("id")
.and_then(|v| v.as_str())
.and_then(|s| url::Url::parse(s).ok())
else {
continue;
};
if let Err(e) = data.object_handler.on_create(&ap_id, &actor, object).await {
tracing::warn!(ap_id = %ap_id, error = %e, "backfill: failed to process item");
}
}
}
match page.get("next").and_then(|v| v.as_str()) {
Some(next) => current_url = next.to_string(),
None => break,
}
}
tracing::info!(outbox = %outbox_url, pages = visited.len(), "backfill complete");
Ok(())
}
/// Route backfill through [`EventPublisher`] (if configured) or fall back
/// to a fire-and-forget `tokio::spawn`.
///
/// When `EventPublisher` is set, a [`FederationEvent::BackfillRequested`]
/// event is published so the consumer's job queue can process it — allowing
/// backfill to run in a separate worker process rather than in the API server.
/// The worker calls [`ActivityPubService::run_backfill_for_follower`] to execute.
///
/// `pub(crate)` so `service::follow` can call it from `accept_follower`.
pub(crate) fn spawn_backfill(&self, owner_user_id: uuid::Uuid, follower_inbox_url: String) {
let data = self.federation_config.to_request_data();
if let Some(publisher) = data.event_publisher.as_ref() {
let publisher = publisher.clone();
let event = crate::data::FederationEvent::BackfillRequested {
owner_user_id,
follower_inbox_url,
};
tokio::spawn(async move {
if let Err(e) = publisher.publish(event).await {
tracing::warn!(error = %e, "failed to enqueue BackfillRequested event");
}
});
} else {
let config = self.federation_config.clone();
let base_url = self.base_url.clone();
let max_attempts = self.delivery_max_attempts;
let initial_delay = self.delivery_initial_delay_secs;
tokio::spawn(async move {
if let Err(e) = ActivityPubService::run_backfill(
config,
base_url,
owner_user_id,
follower_inbox_url,
max_attempts,
initial_delay,
)
.await
{
tracing::warn!(error = %e, "backfill: task failed");
}
});
}
}
/// Execute backfill for a single follower inbox. Call this from a job-queue
/// consumer that received a [`FederationEvent::BackfillRequested`] event.
///
/// Sends all of `owner_user_id`'s locally-authored content to `follower_inbox_url`,
/// oldest-to-newest, with a small sleep between batches to avoid overwhelming
/// the remote server.
pub async fn run_backfill_for_follower(
&self,
owner_user_id: uuid::Uuid,
follower_inbox_url: String,
) -> anyhow::Result<()> {
ActivityPubService::run_backfill(
self.federation_config.clone(),
self.base_url.clone(),
owner_user_id,
follower_inbox_url,
self.delivery_max_attempts,
self.delivery_initial_delay_secs,
)
.await
}
async fn run_backfill(
config: ApFederationConfig,
base_url: String,
owner_user_id: uuid::Uuid,
follower_inbox_url: String,
max_attempts: u32,
initial_delay: u64,
) -> anyhow::Result<()> {
const BATCH_SIZE: usize = 20;
let data = config.to_request_data();
let local_actor = get_local_actor(owner_user_id, &data)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
let inbox = Url::parse(&follower_inbox_url)?;
// Cursor-based pagination via get_local_objects_page (newest-first).
// Avoids loading the entire post history into memory at once.
let mut before: Option<chrono::DateTime<chrono::Utc>> = None;
let (mut success_count, mut failure_count, mut total) = (0usize, 0usize, 0usize);
loop {
let page = data
.content_reader
.get_local_objects_page(owner_user_id, before, BATCH_SIZE)
.await?;
if page.is_empty() {
break;
}
let is_last_page = page.len() < BATCH_SIZE;
// Advance cursor to the oldest timestamp in this page.
before = page.last().map(|(_, _, ts)| *ts);
for (ap_id, object_json, _ts) in &page {
let create_id = Url::parse(&format!(
"{}/activities/create/{}",
base_url,
uuid::Uuid::new_v5(&uuid::Uuid::NAMESPACE_URL, ap_id.as_str().as_bytes())
))?;
let create = CreateActivity {
id: create_id,
kind: Default::default(),
actor: ObjectId::from(local_actor.ap_id.clone()),
object: object_json.clone(),
to: vec![],
cc: vec![],
bto: vec![],
bcc: vec![],
};
let sends = SendActivityTask::prepare(
&WithContext::new_default(create),
&local_actor,
vec![inbox.clone()],
&data,
)
.await?;
total += 1;
if send_with_retry(sends, &data, max_attempts, initial_delay)
.await
.is_empty()
{
success_count += 1;
} else {
failure_count += 1;
}
}
if is_last_page {
break;
}
tokio::time::sleep(std::time::Duration::from_millis(
super::BATCH_FETCH_SLEEP_MS,
))
.await;
}
tracing::info!(
user_id = %owner_user_id,
follower = %follower_inbox_url,
sent = success_count,
failed = failure_count,
total = total,
"backfill complete"
);
Ok(())
}
}

434
src/service/broadcast.rs Normal file
View File

@@ -0,0 +1,434 @@
use activitypub_federation::{
fetch::object_id::ObjectId, protocol::context::WithContext, traits::Object,
};
use url::Url;
use crate::{
activities::{
AddActivity, AnnounceActivity, CreateActivity, DeleteActivity, MoveActivity, UndoActivity,
UpdateActivity,
},
actors::get_local_actor,
urls::activity_url,
user::ApVisibility,
};
use super::ActivityPubService;
impl ActivityPubService {
pub async fn broadcast_announce_to_followers(
&self,
local_user_id: uuid::Uuid,
object_ap_id: url::Url,
) -> anyhow::Result<()> {
let announce_id = url::Url::parse(&format!(
"{}/activities/announce/{}",
self.base_url,
uuid::Uuid::new_v5(
&uuid::Uuid::NAMESPACE_URL,
format!("{}/{}", local_user_id, object_ap_id).as_bytes()
),
))
.map_err(|e| anyhow::anyhow!("{e}"))?;
let data = self.federation_config.to_request_data();
let Some((local_actor, inboxes)) =
self.accepted_follower_inboxes(&data, local_user_id).await?
else {
return Ok(());
};
let announce = AnnounceActivity {
id: announce_id,
kind: Default::default(),
actor: ObjectId::from(local_actor.ap_id.clone()),
object: object_ap_id,
published: Some(chrono::Utc::now()),
to: vec![crate::urls::AS_PUBLIC.to_string()],
cc: vec![local_actor.followers_url.to_string()],
};
let (json, sends, inboxes) = self
.prepare_broadcast(&data, &local_actor, inboxes, announce)
.await?;
self.dispatch_deliveries(&data, &local_actor, inboxes, sends, json)
.await
}
pub async fn broadcast_undo_announce_to_followers(
&self,
local_user_id: uuid::Uuid,
object_ap_id: url::Url,
) -> anyhow::Result<()> {
let announce_id = url::Url::parse(&format!(
"{}/activities/announce/{}",
self.base_url,
uuid::Uuid::new_v5(
&uuid::Uuid::NAMESPACE_URL,
format!("{}/{}", local_user_id, object_ap_id).as_bytes()
),
))
.map_err(|e| anyhow::anyhow!("{e}"))?;
let undo_id = activity_url(&self.base_url).map_err(|e| anyhow::anyhow!("{e}"))?;
let data = self.federation_config.to_request_data();
let Some((local_actor, inboxes)) =
self.accepted_follower_inboxes(&data, local_user_id).await?
else {
return Ok(());
};
let undo = UndoActivity {
id: undo_id,
kind: Default::default(),
actor: ObjectId::from(local_actor.ap_id.clone()),
object: serde_json::json!({"type":"Announce","id":announce_id.to_string(),"actor":local_actor.ap_id.to_string(),"object":object_ap_id.to_string()}),
};
let (json, sends, inboxes) = self
.prepare_broadcast(&data, &local_actor, inboxes, undo)
.await?;
self.dispatch_deliveries(&data, &local_actor, inboxes, sends, json)
.await
}
pub async fn broadcast_like_to_inbox(
&self,
liker_user_id: uuid::Uuid,
object_ap_id: url::Url,
author_inbox_url: url::Url,
) -> anyhow::Result<()> {
let data = self.federation_config.to_request_data();
let local_actor = get_local_actor(liker_user_id, &data)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
let like_id = url::Url::parse(&format!(
"{}/activities/like/{}",
self.base_url,
uuid::Uuid::new_v5(
&uuid::Uuid::NAMESPACE_URL,
format!("{}/{}", liker_user_id, object_ap_id).as_bytes()
),
))?;
let like = crate::activities::LikeActivity {
id: like_id,
kind: Default::default(),
actor: ObjectId::from(local_actor.ap_id.clone()),
object: object_ap_id,
};
let (json, sends, inboxes) = self
.prepare_broadcast(&data, &local_actor, vec![author_inbox_url], like)
.await?;
self.dispatch_deliveries(&data, &local_actor, inboxes, sends, json)
.await
}
pub async fn broadcast_undo_like_to_inbox(
&self,
liker_user_id: uuid::Uuid,
object_ap_id: url::Url,
author_inbox_url: url::Url,
) -> anyhow::Result<()> {
let data = self.federation_config.to_request_data();
let local_actor = get_local_actor(liker_user_id, &data)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
let like_id = url::Url::parse(&format!(
"{}/activities/like/{}",
self.base_url,
uuid::Uuid::new_v5(
&uuid::Uuid::NAMESPACE_URL,
format!("{}/{}", liker_user_id, object_ap_id).as_bytes()
),
))?;
let undo_id = activity_url(&self.base_url).map_err(|e| anyhow::anyhow!("{e}"))?;
let undo = UndoActivity {
id: undo_id,
kind: Default::default(),
actor: ObjectId::from(local_actor.ap_id.clone()),
object: serde_json::json!({"type":"Like","id":like_id.to_string(),"actor":local_actor.ap_id.to_string(),"object":object_ap_id.to_string()}),
};
let (json, sends, inboxes) = self
.prepare_broadcast(&data, &local_actor, vec![author_inbox_url], undo)
.await?;
self.dispatch_deliveries(&data, &local_actor, inboxes, sends, json)
.await
}
pub async fn broadcast_delete_to_followers(
&self,
local_user_id: uuid::Uuid,
ap_id: Url,
) -> anyhow::Result<()> {
let data = self.federation_config.to_request_data();
let Some((local_actor, inboxes)) =
self.accepted_follower_inboxes(&data, local_user_id).await?
else {
return Ok(());
};
let delete = DeleteActivity {
id: activity_url(&self.base_url).map_err(|e| anyhow::anyhow!("{e}"))?,
kind: Default::default(),
actor: ObjectId::from(local_actor.ap_id.clone()),
object: serde_json::json!({"type": "Tombstone", "id": ap_id.to_string()}),
to: vec![crate::urls::AS_PUBLIC.to_string()],
cc: vec![local_actor.followers_url.to_string()],
};
let (json, sends, inboxes) = self
.prepare_broadcast(&data, &local_actor, inboxes, delete)
.await?;
self.dispatch_deliveries(&data, &local_actor, inboxes, sends, json)
.await
}
pub async fn broadcast_add_to_followers(
&self,
local_user_id: uuid::Uuid,
ap_id: Url,
object: serde_json::Value,
) -> anyhow::Result<()> {
let data = self.federation_config.to_request_data();
let Some((local_actor, inboxes)) =
self.accepted_follower_inboxes(&data, local_user_id).await?
else {
return Ok(());
};
let add = AddActivity {
id: ap_id,
kind: Default::default(),
actor: ObjectId::from(local_actor.ap_id.clone()),
object,
to: vec![crate::urls::AS_PUBLIC.to_string()],
cc: vec![local_actor.followers_url.to_string()],
};
let (json, sends, inboxes) = self
.prepare_broadcast(&data, &local_actor, inboxes, add)
.await?;
self.dispatch_deliveries(&data, &local_actor, inboxes, sends, json)
.await
}
pub async fn broadcast_undo_add_to_followers(
&self,
local_user_id: uuid::Uuid,
watchlist_entry_ap_id: Url,
) -> anyhow::Result<()> {
let data = self.federation_config.to_request_data();
let Some((local_actor, inboxes)) =
self.accepted_follower_inboxes(&data, local_user_id).await?
else {
return Ok(());
};
let undo = UndoActivity {
id: activity_url(&self.base_url).map_err(|e| anyhow::anyhow!("{e}"))?,
kind: Default::default(),
actor: ObjectId::from(local_actor.ap_id.clone()),
object: serde_json::json!({"type":"Add","id":watchlist_entry_ap_id.as_str(),"object":{"id":watchlist_entry_ap_id.as_str()}}),
};
let (json, sends, inboxes) = self
.prepare_broadcast(&data, &local_actor, inboxes, undo)
.await?;
self.dispatch_deliveries(&data, &local_actor, inboxes, sends, json)
.await
}
/// Fan out a Create(Note) activity to accepted followers and any explicitly
/// mentioned actors.
///
/// `visibility` controls `to`/`cc` addressing and whether the note is public:
/// - `Public` / `FollowersOnly`: delivered to followers + `mentioned_inboxes`
/// - `Private`: returns immediately — no delivery to anyone
///
/// `mentioned_inboxes` should contain the inbox URLs of remote actors
/// explicitly tagged in the note who are not already followers. Resolve them
/// via [`ActivityPubService::lookup_actor_by_handle`] before calling. Pass an
/// empty `Vec` if there are no external mentions.
pub async fn broadcast_create_note(
&self,
local_user_id: uuid::Uuid,
note: serde_json::Value,
visibility: ApVisibility,
mentioned_inboxes: Vec<Url>,
) -> anyhow::Result<()> {
if visibility == ApVisibility::Private {
return Ok(());
}
let data = self.federation_config.to_request_data();
let local_actor = crate::actors::get_local_actor(local_user_id, &data)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
// Merge follower inboxes with explicitly mentioned actor inboxes,
// deduplicating by string to avoid delivering the same inbox twice.
let follower_inboxes = data
.follow_repo
.get_accepted_follower_inboxes(local_user_id)
.await?;
let mut seen = std::collections::HashSet::new();
let mut inboxes: Vec<Url> = follower_inboxes
.into_iter()
.filter_map(|s| Url::parse(&s).ok())
.filter(|u| seen.insert(u.to_string()))
.collect();
for inbox in mentioned_inboxes {
if seen.insert(inbox.to_string()) {
inboxes.push(inbox);
}
}
if inboxes.is_empty() {
return Ok(());
}
let note_id_str = note["id"].as_str().unwrap_or("");
let create_id = Url::parse(&format!(
"{}/activities/create/{}",
self.base_url,
uuid::Uuid::new_v5(&uuid::Uuid::NAMESPACE_URL, note_id_str.as_bytes())
))
.map_err(|e| anyhow::anyhow!("{e}"))?;
let (to, cc) = visibility_addressing(visibility, &local_actor.followers_url);
let create = CreateActivity {
id: create_id,
kind: Default::default(),
actor: ObjectId::from(local_actor.ap_id.clone()),
object: note,
to,
cc,
bto: vec![],
bcc: vec![],
};
let (json, sends, inboxes) = self
.prepare_broadcast(&data, &local_actor, inboxes, create)
.await?;
self.dispatch_deliveries(&data, &local_actor, inboxes, sends, json)
.await
}
/// Fan out an Update(Note) activity to accepted followers and mentioned actors.
/// See [`broadcast_create_note`] for `mentioned_inboxes` semantics.
pub async fn broadcast_update_note(
&self,
local_user_id: uuid::Uuid,
note: serde_json::Value,
visibility: ApVisibility,
mentioned_inboxes: Vec<Url>,
) -> anyhow::Result<()> {
if visibility == ApVisibility::Private {
return Ok(());
}
let data = self.federation_config.to_request_data();
let local_actor = crate::actors::get_local_actor(local_user_id, &data)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
let follower_inboxes = data
.follow_repo
.get_accepted_follower_inboxes(local_user_id)
.await?;
let mut seen = std::collections::HashSet::new();
let mut inboxes: Vec<Url> = follower_inboxes
.into_iter()
.filter_map(|s| Url::parse(&s).ok())
.filter(|u| seen.insert(u.to_string()))
.collect();
for inbox in mentioned_inboxes {
if seen.insert(inbox.to_string()) {
inboxes.push(inbox);
}
}
if inboxes.is_empty() {
return Ok(());
}
let (to, cc) = visibility_addressing(visibility, &local_actor.followers_url);
let update = crate::activities::UpdateActivity {
id: activity_url(&self.base_url).map_err(|e| anyhow::anyhow!("{e}"))?,
kind: Default::default(),
actor: ObjectId::from(local_actor.ap_id.clone()),
object: note,
to,
cc,
};
let (json, sends, inboxes) = self
.prepare_broadcast(&data, &local_actor, inboxes, update)
.await?;
self.dispatch_deliveries(&data, &local_actor, inboxes, sends, json)
.await
}
pub async fn broadcast_actor_update(&self, user_id: uuid::Uuid) -> anyhow::Result<()> {
let data = self.federation_config.to_request_data();
let local_actor = get_local_actor(user_id, &data)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
let person = local_actor
.clone()
.into_json(&data)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
let person_json =
serde_json::to_value(WithContext::new(person, crate::urls::actor_ap_context()))?;
let update_id = Url::parse(&format!(
"{}/activities/update/{}",
self.base_url,
uuid::Uuid::new_v4()
))?;
let update = UpdateActivity {
id: update_id,
kind: Default::default(),
actor: ObjectId::from(local_actor.ap_id.clone()),
object: person_json,
to: vec![crate::urls::AS_PUBLIC.to_string()],
cc: vec![local_actor.followers_url.to_string()],
};
let Some((_, inboxes)) = self.accepted_follower_inboxes(&data, user_id).await? else {
tracing::info!(%user_id, "no accepted followers, skipping actor update broadcast");
return Ok(());
};
tracing::info!(%user_id, inbox_count = inboxes.len(), "broadcasting actor update");
let (json, sends, inboxes) = self
.prepare_broadcast(&data, &local_actor, inboxes, update)
.await?;
self.dispatch_deliveries(&data, &local_actor, inboxes, sends, json)
.await
}
pub async fn broadcast_move(
&self,
user_id: uuid::Uuid,
new_actor_url: url::Url,
) -> anyhow::Result<()> {
let data = self.federation_config.to_request_data();
let local_actor = get_local_actor(user_id, &data)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
let Some((_, inboxes)) = self.accepted_follower_inboxes(&data, user_id).await? else {
tracing::info!(%user_id, "broadcast_move: no accepted followers");
return Ok(());
};
let move_activity = MoveActivity {
id: activity_url(&self.base_url).map_err(|e| anyhow::anyhow!("{e}"))?,
kind: Default::default(),
actor: ObjectId::from(local_actor.ap_id.clone()),
object: local_actor.ap_id.clone(),
target: new_actor_url.clone(),
};
let (json, sends, inboxes) = self
.prepare_broadcast(&data, &local_actor, inboxes, move_activity)
.await?;
self.dispatch_deliveries(&data, &local_actor, inboxes, sends, json)
.await?;
tracing::info!(%user_id, target = %new_actor_url, "broadcast_move: dispatched");
Ok(())
}
}
/// Returns `(to, cc)` addressing for the given visibility.
/// `Private` is handled before calling this (early return in broadcast methods).
pub(crate) fn visibility_addressing(
visibility: ApVisibility,
followers_url: &Url,
) -> (Vec<String>, Vec<String>) {
match visibility {
ApVisibility::Public => (
vec![crate::urls::AS_PUBLIC.to_string()],
vec![followers_url.to_string()],
),
ApVisibility::FollowersOnly => (vec![followers_url.to_string()], vec![]),
ApVisibility::Private => (vec![], vec![]),
}
}

194
src/service/delivery.rs Normal file
View File

@@ -0,0 +1,194 @@
use std::fmt::Debug;
use activitypub_federation::{activity_sending::SendActivityTask, traits::Activity};
use serde::Serialize;
use url::Url;
use crate::actors::{DbActor, get_local_actor};
use crate::data::{FederationData, FederationEvent};
use crate::error::Error;
use super::ActivityPubService;
pub(crate) async fn send_with_retry(
sends: Vec<SendActivityTask>,
data: &activitypub_federation::config::Data<FederationData>,
max_attempts: u32,
initial_delay_secs: u64,
) -> Vec<anyhow::Error> {
let mut failures = vec![];
for send in sends {
let mut delay = std::time::Duration::from_secs(initial_delay_secs);
for attempt in 1..=max_attempts {
match send.clone().sign_and_send(data).await {
Ok(()) => break,
Err(e) if attempt < max_attempts => {
tracing::warn!(attempt, error = %e, "delivery failed, retrying");
tokio::time::sleep(delay).await;
delay *= 2;
}
Err(e) => {
tracing::error!(attempt, error = %e, "delivery failed permanently");
failures.push(anyhow::anyhow!(e));
}
}
}
}
failures
}
/// Wraps a pre-serialized AP activity JSON for re-signing via `SendActivityTask::prepare`.
/// Used by `deliver_to_inbox` when a consumer re-presents a persisted queue item.
#[derive(Debug)]
struct RawActivity {
id: Url,
actor_url: Url,
value: serde_json::Value,
}
impl Serialize for RawActivity {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
self.value.serialize(s)
}
}
#[async_trait::async_trait]
impl Activity for RawActivity {
type DataType = FederationData;
type Error = Error;
fn id(&self) -> &Url {
&self.id
}
fn actor(&self) -> &Url {
&self.actor_url
}
async fn verify(
&self,
_data: &activitypub_federation::config::Data<Self::DataType>,
) -> Result<(), Self::Error> {
Ok(())
}
async fn receive(
self,
_data: &activitypub_federation::config::Data<Self::DataType>,
) -> Result<(), Self::Error> {
Ok(())
}
}
impl ActivityPubService {
/// Route deliveries to the EventPublisher (one DeliveryRequested event per inbox)
/// or fall back to a fire-and-forget tokio::spawn.
/// `pub(crate)` so sibling modules (broadcast.rs, follow.rs) can call it on `self`.
pub(crate) async fn dispatch_deliveries(
&self,
data: &activitypub_federation::config::Data<FederationData>,
local_actor: &DbActor,
inboxes: Vec<Url>,
sends: Vec<SendActivityTask>,
activity_json: serde_json::Value,
) -> anyhow::Result<()> {
if let Some(publisher) = data.event_publisher.as_ref() {
for inbox in inboxes {
let event = FederationEvent::DeliveryRequested {
inbox,
activity: activity_json.clone(),
signing_actor_id: local_actor.user_id,
};
if let Err(e) = publisher.publish(event).await {
tracing::warn!(error = %e, "failed to enqueue DeliveryRequested event");
}
}
} else {
let data = data.clone();
let max_attempts = self.delivery_max_attempts;
let initial_delay = self.delivery_initial_delay_secs;
tokio::spawn(async move {
let failures = send_with_retry(sends, &data, max_attempts, initial_delay).await;
if !failures.is_empty() {
tracing::warn!(count = failures.len(), "some deliveries failed permanently");
}
});
}
Ok(())
}
/// Deliver a single outbound activity to `inbox`.
/// Call from a job-queue consumer processing a `FederationEvent::DeliveryRequested` event.
pub async fn deliver_to_inbox(
&self,
inbox: url::Url,
activity: serde_json::Value,
signing_actor_id: uuid::Uuid,
) -> anyhow::Result<()> {
let data = self.federation_config.to_request_data();
let actor = get_local_actor(signing_actor_id, &data)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
let id = activity
.get("id")
.and_then(|v| v.as_str())
.and_then(|s| Url::parse(s).ok())
.unwrap_or_else(|| actor.ap_id.clone());
let actor_url = activity
.get("actor")
.and_then(|v| v.as_str())
.and_then(|s| Url::parse(s).ok())
.unwrap_or_else(|| actor.ap_id.clone());
let raw = RawActivity {
id,
actor_url,
value: activity.clone(),
};
let sends = SendActivityTask::prepare(&raw, &actor, vec![inbox.clone()], &data).await?;
let failures = send_with_retry(
sends,
&data,
self.delivery_max_attempts,
self.delivery_initial_delay_secs,
)
.await;
if failures.is_empty() {
return Ok(());
}
let error_msg = failures
.iter()
.map(|e| e.to_string())
.collect::<Vec<_>>()
.join("; ");
if let Some(publisher) = data.event_publisher.as_ref() {
let _ = publisher
.publish(FederationEvent::DeliveryFailed {
inbox,
activity,
signing_actor_id,
error: error_msg.clone(),
})
.await;
}
Err(anyhow::anyhow!("delivery failed: {}", error_msg))
}
/// Serialize `activity` to JSON and prepare `SendActivityTask` objects.
/// Returns `(activity_json, sends, inboxes)` so both dispatch paths have what they need.
/// `pub(super)` — visible to all child modules of `service` (broadcast.rs, follow.rs, etc.).
pub(super) async fn prepare_broadcast<A>(
&self,
data: &activitypub_federation::config::Data<FederationData>,
local_actor: &DbActor,
inboxes: Vec<Url>,
activity: A,
) -> anyhow::Result<(serde_json::Value, Vec<SendActivityTask>, Vec<Url>)>
where
A: Activity + Serialize + Debug + Send + Sync,
{
let with_ctx =
activitypub_federation::protocol::context::WithContext::new_default(activity);
let activity_json = serde_json::to_value(&with_ctx)?;
let sends =
SendActivityTask::prepare(&with_ctx, local_actor, inboxes.clone(), data).await?;
Ok((activity_json, sends, inboxes))
}
}

62
src/service/fetch.rs Normal file
View File

@@ -0,0 +1,62 @@
use activitypub_federation::fetch::object_id::ObjectId;
use url::Url;
use crate::actors::DbActor;
use crate::repository::RemoteActor;
use super::ActivityPubService;
impl ActivityPubService {
/// Fetch a remote ActivityPub resource with HTTP Signatures.
///
/// Requires `signed_fetch_actor_id` to have been set on the builder.
/// Returns the raw JSON value of the remote resource.
pub async fn signed_fetch(&self, url: &Url) -> anyhow::Result<serde_json::Value> {
let data = self.federation_config.to_request_data();
let res = activitypub_federation::fetch::fetch_object_http::<
crate::data::FederationData,
serde_json::Value,
>(url, &data)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
Ok(res.object)
}
/// Get a cached remote actor, re-fetching from origin if stale.
///
/// Returns `None` if the actor has never been seen. Staleness is
/// determined by `actor_cache_ttl_secs` (builder config).
pub async fn get_or_refresh_remote_actor(
&self,
actor_url: &str,
) -> anyhow::Result<Option<RemoteActor>> {
let data = self.federation_config.to_request_data();
let cached = data.actor_repo.get_remote_actor(actor_url).await?;
if let Some(ref actor) = cached {
let is_fresh = actor
.fetched_at
.map(|t| {
let age = chrono::Utc::now().signed_duration_since(t);
age < chrono::Duration::from_std(data.actor_cache_ttl).unwrap_or_default()
})
.unwrap_or(true);
if is_fresh {
return Ok(cached);
}
}
let url = match Url::parse(actor_url) {
Ok(u) => u,
Err(_) => return Ok(cached),
};
match ObjectId::<DbActor>::from(url)
.dereference_forced(&data)
.await
{
Ok(_) => Ok(data.actor_repo.get_remote_actor(actor_url).await?),
Err(e) => {
tracing::warn!(actor_url, error = %e, "re-fetch failed, using stale cache");
Ok(cached)
}
}
}
}

443
src/service/follow.rs Normal file
View File

@@ -0,0 +1,443 @@
use activitypub_federation::{fetch::object_id::ObjectId, traits::Actor};
use url::Url;
use crate::{
activities::{AcceptActivity, FollowActivity, RejectActivity, UndoActivity},
actors::get_local_actor,
data::FederationData,
repository::{FollowerStatus, FollowingStatus, RemoteActor},
urls::activity_url,
};
use super::ActivityPubService;
impl ActivityPubService {
pub async fn follow(&self, local_user_id: uuid::Uuid, handle: &str) -> anyhow::Result<()> {
let data = self.federation_config.to_request_data();
let normalized = handle.trim_start_matches('@');
let parts: Vec<&str> = normalized.splitn(2, '@').collect();
if parts.len() == 2 && parts[1] == data.domain {
return self.follow_local(local_user_id, parts[0], &data).await;
}
let remote_actor = self.webfinger_https(handle, &data).await?;
let local_actor = get_local_actor(local_user_id, &data)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
let follow_id = activity_url(&self.base_url).map_err(|e| anyhow::anyhow!("{e}"))?;
let follow_id_str = follow_id.to_string();
let remote = RemoteActor {
url: remote_actor.ap_id.to_string(),
handle: format!(
"{}@{}",
remote_actor.username,
remote_actor.ap_id.host_str().unwrap_or("")
),
inbox_url: remote_actor.inbox_url.to_string(),
shared_inbox_url: remote_actor
.shared_inbox_url
.as_ref()
.map(|u| u.to_string()),
display_name: remote_actor
.display_name
.clone()
.or_else(|| Some(remote_actor.username.clone())),
avatar_url: remote_actor.avatar_url.as_ref().map(|u| u.to_string()),
outbox_url: Some(remote_actor.outbox_url.to_string()),
bio: remote_actor.bio.clone(),
banner_url: remote_actor.banner_url.as_ref().map(|u| u.to_string()),
followers_url: Some(remote_actor.followers_url.to_string()),
following_url: Some(remote_actor.following_url.to_string()),
also_known_as: remote_actor.also_known_as.clone(),
fetched_at: Some(chrono::Utc::now()),
};
// Save BEFORE delivering — prevents lost state on process restart.
data.follow_repo
.add_following(local_user_id, remote, &follow_id_str)
.await?;
let follow = FollowActivity {
id: Url::parse(&follow_id_str)?,
kind: Default::default(),
actor: ObjectId::from(local_actor.ap_id.clone()),
object: ObjectId::from(remote_actor.ap_id.clone()),
};
let (json, sends, inboxes) = self
.prepare_broadcast(&data, &local_actor, vec![remote_actor.inbox()], follow)
.await?;
self.dispatch_deliveries(&data, &local_actor, inboxes, sends, json)
.await
}
pub async fn unfollow(
&self,
local_user_id: uuid::Uuid,
actor_url_str: &str,
) -> anyhow::Result<()> {
let data = self.federation_config.to_request_data();
if actor_url_str.starts_with(&self.base_url) {
return self
.unfollow_local(local_user_id, actor_url_str, &data)
.await;
}
let remote = data
.actor_repo
.get_remote_actor(actor_url_str)
.await?
.ok_or_else(|| anyhow::anyhow!("remote actor not found: {}", actor_url_str))?;
let local_actor = get_local_actor(local_user_id, &data)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
let remote_ap_id = Url::parse(actor_url_str)?;
let inbox = Url::parse(&remote.inbox_url)?;
let follow_id = data
.follow_repo
.get_follow_activity_id(local_user_id, actor_url_str)
.await?
.and_then(|id| Url::parse(&id).ok())
.unwrap_or_else(|| {
activity_url(&self.base_url).unwrap_or_else(|_| remote_ap_id.clone())
});
let follow = FollowActivity {
id: follow_id,
kind: Default::default(),
actor: ObjectId::from(local_actor.ap_id.clone()),
object: ObjectId::from(remote_ap_id),
};
let undo = UndoActivity {
id: activity_url(&self.base_url).map_err(|e| anyhow::anyhow!("{e}"))?,
kind: Default::default(),
actor: ObjectId::from(local_actor.ap_id.clone()),
object: serde_json::to_value(&follow).map_err(|e| anyhow::anyhow!("{e}"))?,
};
let (json, sends, inboxes) = self
.prepare_broadcast(&data, &local_actor, vec![inbox], undo)
.await?;
self.dispatch_deliveries(&data, &local_actor, inboxes, sends, json)
.await?;
data.follow_repo
.remove_following(local_user_id, actor_url_str)
.await?;
data.object_handler
.on_actor_removed(&Url::parse(actor_url_str)?)
.await?;
Ok(())
}
pub async fn accept_follower(
&self,
local_user_id: uuid::Uuid,
remote_actor_url: &str,
) -> anyhow::Result<()> {
let data = self.federation_config.to_request_data();
let local_actor = get_local_actor(local_user_id, &data)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
let remote_actor = data
.actor_repo
.get_remote_actor(remote_actor_url)
.await?
.ok_or_else(|| anyhow::anyhow!("remote actor not found"))?;
let follow_id_str = data
.follow_repo
.get_follower_follow_activity_id(local_user_id, remote_actor_url)
.await?
.ok_or_else(|| {
anyhow::anyhow!("follow activity id not found for {}", remote_actor_url)
})?;
let follow = FollowActivity {
id: Url::parse(&follow_id_str)?,
kind: Default::default(),
actor: ObjectId::from(Url::parse(remote_actor_url)?),
object: ObjectId::from(local_actor.ap_id.clone()),
};
let accept = AcceptActivity {
id: activity_url(&self.base_url).map_err(|e| anyhow::anyhow!("{e}"))?,
kind: Default::default(),
actor: ObjectId::from(local_actor.ap_id.clone()),
object: follow,
};
data.follow_repo
.update_follower_status(local_user_id, remote_actor_url, FollowerStatus::Accepted)
.await?;
let inbox = Url::parse(&remote_actor.inbox_url)?;
let (json, sends, inboxes) = self
.prepare_broadcast(&data, &local_actor, vec![inbox], accept)
.await?;
self.dispatch_deliveries(&data, &local_actor, inboxes, sends, json)
.await?;
let target_inbox = remote_actor
.shared_inbox_url
.clone()
.unwrap_or_else(|| remote_actor.inbox_url.clone());
self.spawn_backfill(local_user_id, target_inbox);
Ok(())
}
pub async fn reject_follower(
&self,
local_user_id: uuid::Uuid,
remote_actor_url: &str,
) -> anyhow::Result<()> {
let data = self.federation_config.to_request_data();
let local_actor = get_local_actor(local_user_id, &data)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
let remote_actor = data
.actor_repo
.get_remote_actor(remote_actor_url)
.await?
.ok_or_else(|| anyhow::anyhow!("remote actor not found"))?;
let follow = FollowActivity {
id: activity_url(&self.base_url).map_err(|e| anyhow::anyhow!("{e}"))?,
kind: Default::default(),
actor: ObjectId::from(Url::parse(remote_actor_url)?),
object: ObjectId::from(local_actor.ap_id.clone()),
};
let reject = RejectActivity {
id: activity_url(&self.base_url).map_err(|e| anyhow::anyhow!("{e}"))?,
kind: Default::default(),
actor: ObjectId::from(local_actor.ap_id.clone()),
object: follow,
};
let inbox = Url::parse(&remote_actor.inbox_url)?;
let (json, sends, inboxes) = self
.prepare_broadcast(&data, &local_actor, vec![inbox], reject)
.await?;
self.dispatch_deliveries(&data, &local_actor, inboxes, sends, json)
.await?;
data.follow_repo
.remove_follower(local_user_id, remote_actor_url)
.await?;
Ok(())
}
pub async fn get_pending_followers(
&self,
local_user_id: uuid::Uuid,
) -> anyhow::Result<Vec<RemoteActor>> {
let data = self.federation_config.to_request_data();
data.follow_repo.get_pending_followers(local_user_id).await
}
/// Returns one page of accepted followers. Prefer this over `get_accepted_followers`
/// for large accounts — the DB does the filtering rather than loading everything.
pub async fn get_accepted_followers_page(
&self,
local_user_id: uuid::Uuid,
offset: u32,
limit: usize,
) -> anyhow::Result<Vec<RemoteActor>> {
let data = self.federation_config.to_request_data();
data.follow_repo
.get_accepted_followers_page(local_user_id, offset, limit)
.await
}
/// Returns ALL accepted followers. For large accounts use `get_accepted_followers_page`.
pub async fn get_accepted_followers(
&self,
local_user_id: uuid::Uuid,
) -> anyhow::Result<Vec<RemoteActor>> {
let data = self.federation_config.to_request_data();
Ok(data
.follow_repo
.get_followers(local_user_id)
.await?
.into_iter()
.filter(|f| f.status == FollowerStatus::Accepted)
.map(|f| f.actor)
.collect())
}
/// Count of accepted followers — DB-side query, no in-memory filtering.
pub async fn count_accepted_followers(
&self,
local_user_id: uuid::Uuid,
) -> anyhow::Result<usize> {
let data = self.federation_config.to_request_data();
data.follow_repo
.count_accepted_followers(local_user_id)
.await
}
pub async fn get_following(
&self,
local_user_id: uuid::Uuid,
) -> anyhow::Result<Vec<RemoteActor>> {
let data = self.federation_config.to_request_data();
data.follow_repo.get_following(local_user_id).await
}
pub async fn count_following(&self, local_user_id: uuid::Uuid) -> anyhow::Result<usize> {
let data = self.federation_config.to_request_data();
data.follow_repo.count_following(local_user_id).await
}
pub async fn remove_follower(
&self,
local_user_id: uuid::Uuid,
actor_url: &str,
) -> anyhow::Result<()> {
let data = self.federation_config.to_request_data();
data.follow_repo
.remove_follower(local_user_id, actor_url)
.await
}
pub async fn block_actor(
&self,
local_user_id: uuid::Uuid,
actor_url: &str,
) -> anyhow::Result<()> {
let data = self.federation_config.to_request_data();
data.blocklist_repo
.add_blocked_actor(local_user_id, actor_url)
.await?;
let _ = data
.follow_repo
.remove_follower(local_user_id, actor_url)
.await;
let _ = data
.follow_repo
.remove_following(local_user_id, actor_url)
.await;
let local_actor = get_local_actor(local_user_id, &data)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
if let Ok(Some(remote_actor)) = data.actor_repo.get_remote_actor(actor_url).await {
let block = crate::activities::BlockActivity {
id: activity_url(&self.base_url).map_err(|e| anyhow::anyhow!("{e}"))?,
kind: Default::default(),
actor: ObjectId::from(local_actor.ap_id.clone()),
object: Url::parse(actor_url)?,
};
let inbox = Url::parse(&remote_actor.inbox_url)?;
let (json, sends, inboxes) = self
.prepare_broadcast(&data, &local_actor, vec![inbox], block)
.await?;
self.dispatch_deliveries(&data, &local_actor, inboxes, sends, json)
.await?;
}
Ok(())
}
pub async fn unblock_actor(
&self,
local_user_id: uuid::Uuid,
actor_url: &str,
) -> anyhow::Result<()> {
let data = self.federation_config.to_request_data();
data.blocklist_repo
.remove_blocked_actor(local_user_id, actor_url)
.await
}
pub async fn get_blocked_actors(
&self,
local_user_id: uuid::Uuid,
) -> anyhow::Result<Vec<RemoteActor>> {
let data = self.federation_config.to_request_data();
let actor_urls = data
.blocklist_repo
.get_blocked_actors(local_user_id)
.await?;
let mut actors = Vec::new();
for url in actor_urls {
let actor = match data.actor_repo.get_remote_actor(&url).await {
Ok(Some(a)) => a,
_ => RemoteActor {
url: url.clone(),
handle: url.clone(),
inbox_url: url.clone(),
shared_inbox_url: None,
display_name: None,
avatar_url: None,
outbox_url: None,
bio: None,
banner_url: None,
followers_url: None,
following_url: None,
also_known_as: vec![],
fetched_at: None,
},
};
actors.push(actor);
}
Ok(actors)
}
pub(super) async fn follow_local(
&self,
local_user_id: uuid::Uuid,
target_username: &str,
data: &activitypub_federation::config::Data<FederationData>,
) -> anyhow::Result<()> {
let target = data
.user_repo
.find_by_username(target_username)
.await?
.ok_or_else(|| anyhow::anyhow!("user not found: {}", target_username))?;
if target.id == local_user_id {
return Err(anyhow::anyhow!("cannot follow yourself"));
}
let follower_actor_url = crate::urls::actor_url(&self.base_url, local_user_id).to_string();
let target_actor_url = crate::urls::actor_url(&self.base_url, target.id);
let follow_id = activity_url(&self.base_url)
.map_err(|e| anyhow::anyhow!("{e}"))?
.to_string();
data.follow_repo
.add_follower(
target.id,
&follower_actor_url,
FollowerStatus::Accepted,
&follow_id,
)
.await?;
let target_as_remote = RemoteActor {
url: target_actor_url.to_string(),
handle: format!("{}@{}", target.username, data.domain),
inbox_url: format!("{}/inbox", target_actor_url),
shared_inbox_url: None,
display_name: target.display_name.or(Some(target.username)),
avatar_url: target.avatar_url.as_ref().map(|u| u.to_string()),
outbox_url: Some(format!("{}/outbox", target_actor_url)),
bio: target.bio,
banner_url: target.banner_url.as_ref().map(|u| u.to_string()),
followers_url: Some(format!("{}/followers", target_actor_url)),
following_url: Some(format!("{}/following", target_actor_url)),
also_known_as: target.also_known_as,
fetched_at: None,
};
data.follow_repo
.add_following(local_user_id, target_as_remote, &follow_id)
.await?;
data.follow_repo
.update_following_status(
local_user_id,
target_actor_url.as_ref(),
FollowingStatus::Accepted,
)
.await?;
tracing::info!(follower = %local_user_id, followee = %target.id, "local follow");
Ok(())
}
pub(super) async fn unfollow_local(
&self,
local_user_id: uuid::Uuid,
target_actor_url: &str,
data: &activitypub_federation::config::Data<FederationData>,
) -> anyhow::Result<()> {
let target_url = Url::parse(target_actor_url)?;
let target_user_id = crate::urls::extract_user_id_from_url(&target_url)
.ok_or_else(|| anyhow::anyhow!("invalid local actor URL: {}", target_actor_url))?;
let local_actor_url = crate::urls::actor_url(&self.base_url, local_user_id).to_string();
data.follow_repo
.remove_follower(target_user_id, &local_actor_url)
.await?;
data.follow_repo
.remove_following(local_user_id, target_actor_url)
.await?;
tracing::info!(follower = %local_user_id, followee = %target_user_id, "local unfollow");
Ok(())
}
}

487
src/service/mod.rs Normal file
View File

@@ -0,0 +1,487 @@
use std::sync::Arc;
use activitypub_federation::{protocol::context::WithContext, traits::Object};
use axum::{Router, extract::DefaultBodyLimit, routing::get, routing::post};
use url::Url;
use crate::{
actors::{DbActor, get_local_actor},
content::{ApContentReader, ApObjectHandler},
data::FederationData,
featured_handler::featured_handler,
federation::ApFederationConfig,
inbox::inbox_handler,
nodeinfo::{nodeinfo_handler, nodeinfo_well_known_handler},
outbox::outbox_handler,
repository::{
ActivityRepository, ActorRepository, BlockedDomain, BlocklistRepository, FollowRepository,
},
user::ApUserRepository,
webfinger::webfinger_handler,
};
mod backfill;
pub(crate) mod broadcast;
pub(super) mod delivery;
mod fetch;
mod follow;
/// Default max delivery retries per inbox (used as the builder default).
pub const DELIVERY_MAX_ATTEMPTS: u32 = 3;
/// Default initial retry backoff in seconds; doubles each attempt.
pub const DELIVERY_INITIAL_DELAY_SECS: u64 = 1;
/// HTTP timeout when fetching remote AP resources.
pub const HTTP_FETCH_TIMEOUT_SECS: u64 = 30;
/// Sleep between backfill send batches.
pub const BATCH_FETCH_SLEEP_MS: u64 = 100;
/// Default actor cache TTL in seconds (24 hours).
pub const ACTOR_CACHE_TTL_SECS: u64 = 24 * 60 * 60;
#[derive(Clone)]
pub struct ActivityPubService {
pub(super) federation_config: ApFederationConfig,
pub(super) base_url: String,
pub(super) delivery_max_attempts: u32,
pub(super) delivery_initial_delay_secs: u64,
}
pub struct ActivityPubServiceBuilder {
activity_repo: Option<Arc<dyn ActivityRepository>>,
follow_repo: Option<Arc<dyn FollowRepository>>,
actor_repo: Option<Arc<dyn ActorRepository>>,
blocklist_repo: Option<Arc<dyn BlocklistRepository>>,
user_repo: Option<Arc<dyn ApUserRepository>>,
content_reader: Option<Arc<dyn ApContentReader>>,
object_handler: Option<Arc<dyn ApObjectHandler>>,
base_url: String,
allow_registration: bool,
software_name: String,
debug: bool,
event_publisher: Option<Arc<dyn crate::data::EventPublisher>>,
delivery_max_attempts: u32,
delivery_initial_delay_secs: u64,
signed_fetch_actor_id: Option<uuid::Uuid>,
actor_cache_ttl_secs: u64,
}
impl ActivityPubServiceBuilder {
pub fn activity_repo(mut self, v: Arc<dyn ActivityRepository>) -> Self {
self.activity_repo = Some(v);
self
}
pub fn follow_repo(mut self, v: Arc<dyn FollowRepository>) -> Self {
self.follow_repo = Some(v);
self
}
pub fn actor_repo(mut self, v: Arc<dyn ActorRepository>) -> Self {
self.actor_repo = Some(v);
self
}
pub fn blocklist_repo(mut self, v: Arc<dyn BlocklistRepository>) -> Self {
self.blocklist_repo = Some(v);
self
}
pub fn user_repo(mut self, v: Arc<dyn ApUserRepository>) -> Self {
self.user_repo = Some(v);
self
}
pub fn content_reader(mut self, v: Arc<dyn ApContentReader>) -> Self {
self.content_reader = Some(v);
self
}
pub fn object_handler(mut self, v: Arc<dyn ApObjectHandler>) -> Self {
self.object_handler = Some(v);
self
}
pub fn allow_registration(mut self, v: bool) -> Self {
self.allow_registration = v;
self
}
pub fn software_name(mut self, v: impl Into<String>) -> Self {
self.software_name = v.into();
self
}
pub fn debug(mut self, v: bool) -> Self {
self.debug = v;
self
}
pub fn event_publisher(mut self, v: Arc<dyn crate::data::EventPublisher>) -> Self {
self.event_publisher = Some(v);
self
}
pub fn delivery_max_attempts(mut self, v: u32) -> Self {
self.delivery_max_attempts = v;
self
}
pub fn delivery_initial_delay_secs(mut self, v: u64) -> Self {
self.delivery_initial_delay_secs = v;
self
}
/// How long cached remote actors are considered fresh (seconds, default 24h).
/// After this duration, the next access re-fetches the actor from origin.
pub fn actor_cache_ttl_secs(mut self, v: u64) -> Self {
self.actor_cache_ttl_secs = v;
self
}
/// Set a local actor whose keypair signs all outgoing fetch requests
/// (HTTP Signature on GETs). Required for federating with instances
/// that enforce authorized-fetch / Secure Mode.
pub fn signed_fetch_actor_id(mut self, v: uuid::Uuid) -> Self {
self.signed_fetch_actor_id = Some(v);
self
}
pub async fn build(self) -> anyhow::Result<ActivityPubService> {
let activity_repo = self
.activity_repo
.ok_or_else(|| anyhow::anyhow!("activity_repo required — call .activity_repo(arc)"))?;
let follow_repo = self
.follow_repo
.ok_or_else(|| anyhow::anyhow!("follow_repo required — call .follow_repo(arc)"))?;
let actor_repo = self
.actor_repo
.ok_or_else(|| anyhow::anyhow!("actor_repo required — call .actor_repo(arc)"))?;
let blocklist_repo = self.blocklist_repo.ok_or_else(|| {
anyhow::anyhow!("blocklist_repo required — call .blocklist_repo(arc)")
})?;
let user_repo = self
.user_repo
.ok_or_else(|| anyhow::anyhow!("user_repo required — call .user_repo(arc)"))?;
let content_reader = self.content_reader.ok_or_else(|| {
anyhow::anyhow!("content_reader required — call .content_reader(arc)")
})?;
let object_handler = self.object_handler.ok_or_else(|| {
anyhow::anyhow!("object_handler required — call .object_handler(arc)")
})?;
let data = FederationData::new(
activity_repo,
follow_repo,
actor_repo.clone(),
blocklist_repo,
user_repo.clone(),
content_reader,
object_handler,
self.base_url.clone(),
self.allow_registration,
self.software_name,
self.event_publisher,
std::time::Duration::from_secs(self.actor_cache_ttl_secs),
);
let signing_actor = if let Some(uid) = self.signed_fetch_actor_id {
let actor = crate::actors::build_local_actor(
uid,
&self.base_url,
user_repo.as_ref(),
actor_repo.as_ref(),
)
.await?;
Some(actor)
} else {
None
};
let federation_config =
ApFederationConfig::new(data, self.debug, signing_actor.as_ref()).await?;
Ok(ActivityPubService {
federation_config,
base_url: self.base_url,
delivery_max_attempts: self.delivery_max_attempts,
delivery_initial_delay_secs: self.delivery_initial_delay_secs,
})
}
}
impl ActivityPubService {
pub fn builder(base_url: impl Into<String>) -> ActivityPubServiceBuilder {
ActivityPubServiceBuilder {
activity_repo: None,
follow_repo: None,
actor_repo: None,
blocklist_repo: None,
user_repo: None,
content_reader: None,
object_handler: None,
base_url: base_url.into(),
allow_registration: false,
software_name: String::new(),
debug: false,
event_publisher: None,
delivery_max_attempts: DELIVERY_MAX_ATTEMPTS,
delivery_initial_delay_secs: DELIVERY_INITIAL_DELAY_SECS,
signed_fetch_actor_id: None,
actor_cache_ttl_secs: ACTOR_CACHE_TTL_SECS,
}
}
pub fn federation_config(&self) -> &ApFederationConfig {
&self.federation_config
}
pub fn request_data(&self) -> activitypub_federation::config::Data<FederationData> {
self.federation_config.to_request_data()
}
pub fn base_url(&self) -> &str {
&self.base_url
}
/// Returns the ActivityPub router.
///
/// Registers only routes that k-ap fully owns:
/// - `POST /inbox` + `POST /users/{id}/inbox` — signature verification + dispatch (1 MB limit)
/// - `GET /users/{id}/outbox` — cursor-paginated OrderedCollection
/// - `GET /users/{id}/featured` — pinned posts OrderedCollection
/// - `GET /.well-known/webfinger`, `GET /.well-known/nodeinfo`, `GET /nodeinfo/2.0`
///
/// **Not registered:** `GET /users/{id}`, `GET /users/{id}/followers`,
/// `GET /users/{id}/following`. Real applications need those paths to serve
/// both AP JSON and their own UI JSON (content negotiation), so they must own
/// the route. Call `actor_json`, `followers_collection_json`, and
/// `following_collection_json` from your own handler to produce the AP response.
pub fn router<S>(&self) -> Router<S>
where
S: Clone + Send + Sync + 'static,
{
Router::new()
.route("/.well-known/nodeinfo", get(nodeinfo_well_known_handler))
.route("/nodeinfo/2.0", get(nodeinfo_handler))
.route("/.well-known/webfinger", get(webfinger_handler))
.route(
"/inbox",
post(inbox_handler).layer(DefaultBodyLimit::max(1024 * 1024)),
)
.route(
"/users/{id}/inbox",
post(inbox_handler).layer(DefaultBodyLimit::max(1024 * 1024)),
)
.route("/users/{id}/outbox", get(outbox_handler))
.route("/users/{id}/featured", get(featured_handler))
.layer(self.federation_config.middleware())
}
pub async fn actor_json(&self, user_id_str: &str) -> anyhow::Result<String> {
let uuid = uuid::Uuid::parse_str(user_id_str)?;
let data = self.federation_config.to_request_data();
let actor = get_local_actor(uuid, &data)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
let person = actor
.into_json(&data)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
Ok(serde_json::to_string(&WithContext::new(
person,
crate::urls::actor_ap_context(),
))?)
}
pub async fn followers_collection_json(
&self,
user_id: uuid::Uuid,
page: Option<u32>,
) -> anyhow::Result<String> {
const AP_CONTEXT: &str = "https://www.w3.org/ns/activitystreams";
const PAGE_SIZE: usize = 20;
let data = self.federation_config.to_request_data();
let collection_id = format!("{}/users/{}/followers", self.base_url, user_id);
let total = data.follow_repo.count_followers(user_id).await?;
let obj = if let Some(p) = page {
let p = p.max(1);
let offset = (p.saturating_sub(1) as usize) * PAGE_SIZE;
let followers = data
.follow_repo
.get_followers_page(user_id, offset as u32, PAGE_SIZE)
.await?;
let has_next = offset + followers.len() < total;
let items: Vec<String> = followers.into_iter().map(|f| f.actor.url).collect();
let mut obj = serde_json::json!({"@context":AP_CONTEXT,"type":"OrderedCollectionPage","id":format!("{}?page={}",collection_id,p),"partOf":collection_id,"totalItems":total,"orderedItems":items});
if has_next {
obj["next"] = serde_json::json!(format!("{}?page={}", collection_id, p + 1));
}
obj
} else {
serde_json::json!({"@context":AP_CONTEXT,"type":"OrderedCollection","id":collection_id,"totalItems":total,"first":format!("{}?page=1",collection_id)})
};
Ok(serde_json::to_string(&obj)?)
}
pub async fn following_collection_json(
&self,
user_id: uuid::Uuid,
page: Option<u32>,
) -> anyhow::Result<String> {
const AP_CONTEXT: &str = "https://www.w3.org/ns/activitystreams";
const PAGE_SIZE: usize = 20;
let data = self.federation_config.to_request_data();
let collection_id = format!("{}/users/{}/following", self.base_url, user_id);
let total = data.follow_repo.count_following(user_id).await?;
let obj = if let Some(p) = page {
let p = p.max(1);
let offset = (p.saturating_sub(1) as usize) * PAGE_SIZE;
let following = data
.follow_repo
.get_following_page(user_id, offset as u32, PAGE_SIZE)
.await?;
let has_next = offset + following.len() < total;
let items: Vec<String> = following.into_iter().map(|a| a.url).collect();
let mut obj = serde_json::json!({"@context":AP_CONTEXT,"type":"OrderedCollectionPage","id":format!("{}?page={}",collection_id,p),"partOf":collection_id,"totalItems":total,"orderedItems":items});
if has_next {
obj["next"] = serde_json::json!(format!("{}?page={}", collection_id, p + 1));
}
obj
} else {
serde_json::json!({"@context":AP_CONTEXT,"type":"OrderedCollection","id":collection_id,"totalItems":total,"first":format!("{}?page=1",collection_id)})
};
Ok(serde_json::to_string(&obj)?)
}
pub async fn mark_follower_accepted(
&self,
user_id: uuid::Uuid,
actor_url: &str,
) -> anyhow::Result<()> {
let data = self.federation_config.to_request_data();
data.follow_repo
.update_follower_status(
user_id,
actor_url,
crate::repository::FollowerStatus::Accepted,
)
.await
.map_err(|e| anyhow::anyhow!("{e}"))
}
pub async fn mark_follower_rejected(
&self,
user_id: uuid::Uuid,
actor_url: &str,
) -> anyhow::Result<()> {
let data = self.federation_config.to_request_data();
data.follow_repo
.remove_follower(user_id, actor_url)
.await
.map_err(|e| anyhow::anyhow!("{e}"))
}
pub async fn lookup_actor_by_handle(
&self,
handle: &str,
) -> anyhow::Result<crate::user::LookedUpActor> {
tracing::info!(handle, "looking up remote actor");
let data = self.federation_config.to_request_data();
let actor = self
.webfinger_https(handle, &data)
.await
.inspect_err(|e| tracing::warn!(handle, error = %e, "actor lookup failed"))?;
let domain = actor.ap_id.host_str().unwrap_or("").to_string();
tracing::info!(handle = format!("{}@{}", actor.username, domain), ap_url = %actor.ap_id, "remote actor resolved");
Ok(crate::user::LookedUpActor {
handle: format!("{}@{}", actor.username, domain),
display_name: actor.display_name,
bio: actor.bio,
avatar_url: actor.avatar_url,
banner_url: actor.banner_url,
ap_url: actor.ap_id,
outbox_url: Some(actor.outbox_url),
followers_url: Some(actor.followers_url),
following_url: Some(actor.following_url),
also_known_as: actor.also_known_as,
profile_url: actor.profile_url,
attachment: actor.attachment,
})
}
pub async fn add_blocked_domain(
&self,
domain: &str,
reason: Option<&str>,
) -> anyhow::Result<()> {
let data = self.federation_config.to_request_data();
data.blocklist_repo.add_blocked_domain(domain, reason).await
}
pub async fn remove_blocked_domain(&self, domain: &str) -> anyhow::Result<()> {
let data = self.federation_config.to_request_data();
data.blocklist_repo.remove_blocked_domain(domain).await
}
pub async fn get_blocked_domains(&self) -> anyhow::Result<Vec<BlockedDomain>> {
let data = self.federation_config.to_request_data();
data.blocklist_repo.get_blocked_domains().await
}
// ── Private helpers (accessible to child modules via Rust's privacy rules) ─
async fn accepted_follower_inboxes(
&self,
data: &activitypub_federation::config::Data<FederationData>,
local_user_id: uuid::Uuid,
) -> anyhow::Result<Option<(DbActor, Vec<Url>)>> {
let local_actor = get_local_actor(local_user_id, data)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
let inbox_strs = data
.follow_repo
.get_accepted_follower_inboxes(local_user_id)
.await?;
if inbox_strs.is_empty() {
return Ok(None);
}
let inboxes: Vec<Url> = inbox_strs.into_iter().filter_map(|s| {
Url::parse(&s).map_err(|e| tracing::warn!(inbox = %s, error = %e, "skipping unparseable inbox URL")).ok()
}).collect();
if inboxes.is_empty() {
return Ok(None);
}
Ok(Some((local_actor, inboxes)))
}
async fn webfinger_https(
&self,
handle: &str,
data: &activitypub_federation::config::Data<FederationData>,
) -> anyhow::Result<DbActor> {
let normalized = handle.trim_start_matches('@');
let at = normalized
.rfind('@')
.ok_or_else(|| anyhow::anyhow!("handle must be user@domain"))?;
let (user, domain_str) = (&normalized[..at], &normalized[at + 1..]);
let wf_url = format!(
"https://{}/.well-known/webfinger?resource=acct:{}@{}",
domain_str, user, domain_str
);
tracing::debug!(handle, wf_url, "resolving webfinger");
let wf_parsed = Url::parse(&wf_url)?;
crate::security::validate_url(&wf_parsed).await?;
let wf: serde_json::Value = reqwest::Client::new()
.get(&wf_url)
.header("Accept", "application/jrd+json, application/json")
.send()
.await?
.json()
.await?;
let self_href = wf["links"]
.as_array()
.and_then(|links| {
links.iter().find(|l| {
l["rel"].as_str() == Some("self")
&& l["type"].as_str() == Some("application/activity+json")
})
})
.and_then(|l| l["href"].as_str())
.ok_or_else(|| anyhow::anyhow!("no self link in WebFinger response"))?
.to_owned();
tracing::debug!(handle, self_href, "webfinger resolved, fetching actor");
let actor: DbActor =
activitypub_federation::fetch::object_id::ObjectId::from(url::Url::parse(&self_href)?)
.dereference(data)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
Ok(actor)
}
}
#[cfg(test)]
mod tests {
// Inbox deduplication and broadcast filtering are now tested via repository
// integration tests in the consuming crate. See get_accepted_follower_inboxes.
}

1214
src/tests/activities.rs Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,7 @@
use super::*; use super::*;
// ── Person AP JSON serialization ──────────────────────────────────────────────
#[test] #[test]
fn person_serializes_with_enriched_fields() { fn person_serializes_with_enriched_fields() {
let person = Person { let person = Person {
@@ -10,10 +12,10 @@ fn person_serializes_with_enriched_fields() {
.into(), .into(),
preferred_username: "alice".to_string(), preferred_username: "alice".to_string(),
inbox: "https://example.com/users/1/inbox".parse().unwrap(), inbox: "https://example.com/users/1/inbox".parse().unwrap(),
outbox: "https://example.com/users/1/outbox".parse().unwrap(), outbox: Some("https://example.com/users/1/outbox".parse().unwrap()),
followers: "https://example.com/users/1/followers".parse().unwrap(), followers: Some("https://example.com/users/1/followers".parse().unwrap()),
following: "https://example.com/users/1/following".parse().unwrap(), following: Some("https://example.com/users/1/following".parse().unwrap()),
public_key: PublicKey { public_key: activitypub_federation::protocol::public_key::PublicKey {
id: "https://example.com/users/1#main-key".to_string(), id: "https://example.com/users/1#main-key".to_string(),
owner: "https://example.com/users/1".parse().unwrap(), owner: "https://example.com/users/1".parse().unwrap(),
public_key_pem: "pem".to_string(), public_key_pem: "pem".to_string(),
@@ -27,13 +29,14 @@ fn person_serializes_with_enriched_fields() {
url: Some("https://example.com/u/alice".parse().unwrap()), url: Some("https://example.com/u/alice".parse().unwrap()),
discoverable: Some(true), discoverable: Some(true),
manually_approves_followers: true, manually_approves_followers: true,
updated: Some(Utc::now()), updated: Some(chrono::Utc::now()),
endpoints: Some(Endpoints { endpoints: Some(Endpoints {
shared_inbox: "https://example.com/inbox".parse().unwrap(), shared_inbox: "https://example.com/inbox".parse().unwrap(),
}), }),
image: None, image: None,
also_known_as: vec![], also_known_as: vec![],
attachment: vec![], attachment: vec![],
featured: Some("https://example.com/users/1/featured".parse().unwrap()),
}; };
let json = serde_json::to_value(&person).unwrap(); let json = serde_json::to_value(&person).unwrap();
assert_eq!(json["discoverable"], true); assert_eq!(json["discoverable"], true);
@@ -46,4 +49,94 @@ fn person_serializes_with_enriched_fields() {
json["endpoints"]["sharedInbox"], json["endpoints"]["sharedInbox"],
"https://example.com/inbox" "https://example.com/inbox"
); );
assert_eq!(json["featured"], "https://example.com/users/1/featured");
}
#[test]
fn person_actor_type_service_serializes_correctly() {
let mut person = minimal_person();
person.kind = crate::user::ApActorType::Service;
let json = serde_json::to_value(&person).unwrap();
assert_eq!(json["type"], "Service");
}
#[test]
fn person_discoverable_false_serializes() {
let mut person = minimal_person();
person.discoverable = Some(false);
let json = serde_json::to_value(&person).unwrap();
assert_eq!(json["discoverable"], false);
}
#[test]
fn person_also_known_as_serializes_as_array() {
let mut person = minimal_person();
person.also_known_as = vec![
"https://old.example/users/alice".to_string(),
"https://other.example/users/alice".to_string(),
];
let json = serde_json::to_value(&person).unwrap();
assert!(
json["alsoKnownAs"].is_array(),
"alsoKnownAs must serialize as a JSON array"
);
assert_eq!(json["alsoKnownAs"].as_array().unwrap().len(), 2);
}
#[test]
fn person_omits_optional_fields_when_none() {
let person = minimal_person();
let json = serde_json::to_value(&person).unwrap();
assert!(
json.get("summary").is_none(),
"null summary should be omitted"
);
assert!(json.get("icon").is_none(), "null icon should be omitted");
assert!(
json.get("featured").is_none(),
"null featured should be omitted"
);
assert!(json.get("url").is_none(), "null url should be omitted");
}
#[test]
fn person_featured_omitted_when_none() {
let mut person = minimal_person();
person.featured = None;
let json = serde_json::to_value(&person).unwrap();
assert!(json.get("featured").is_none());
}
// ── helper ────────────────────────────────────────────────────────────────────
fn minimal_person() -> Person {
Person {
kind: Default::default(),
id: "https://example.com/users/1"
.parse::<url::Url>()
.unwrap()
.into(),
preferred_username: "alice".to_string(),
inbox: "https://example.com/users/1/inbox".parse().unwrap(),
outbox: None,
followers: None,
following: None,
public_key: activitypub_federation::protocol::public_key::PublicKey {
id: "https://example.com/users/1#main-key".to_string(),
owner: "https://example.com/users/1".parse().unwrap(),
public_key_pem: "pem".to_string(),
},
name: None,
summary: None,
icon: None,
url: None,
discoverable: None,
manually_approves_followers: false,
updated: None,
endpoints: None,
image: None,
also_known_as: vec![],
attachment: vec![],
featured: None,
}
} }

57
src/tests/broadcast.rs Normal file
View File

@@ -0,0 +1,57 @@
/// Tests for broadcast addressing logic (visibility → to/cc fields).
use url::Url;
use crate::service::broadcast::visibility_addressing;
use crate::urls::AS_PUBLIC;
use crate::user::ApVisibility;
fn followers_url() -> Url {
"https://example.com/users/alice/followers".parse().unwrap()
}
#[test]
fn public_visibility_addresses_public_and_followers() {
let (to, cc) = visibility_addressing(ApVisibility::Public, &followers_url());
assert_eq!(to, vec![AS_PUBLIC.to_string()]);
assert_eq!(cc, vec![followers_url().to_string()]);
}
#[test]
fn followers_only_visibility_addresses_followers_only() {
let (to, cc) = visibility_addressing(ApVisibility::FollowersOnly, &followers_url());
assert_eq!(to, vec![followers_url().to_string()]);
assert!(
cc.is_empty(),
"FollowersOnly must not include AS_PUBLIC in cc"
);
}
#[test]
fn followers_only_excludes_as_public() {
let (to, cc) = visibility_addressing(ApVisibility::FollowersOnly, &followers_url());
assert!(
!to.contains(&AS_PUBLIC.to_string()),
"FollowersOnly must not include AS_PUBLIC in to"
);
assert!(
!cc.contains(&AS_PUBLIC.to_string()),
"FollowersOnly must not include AS_PUBLIC in cc"
);
}
#[test]
fn private_visibility_produces_empty_addressing() {
let (to, cc) = visibility_addressing(ApVisibility::Private, &followers_url());
assert!(to.is_empty());
assert!(cc.is_empty());
}
#[test]
fn public_and_followers_only_differ_in_to() {
let (pub_to, _) = visibility_addressing(ApVisibility::Public, &followers_url());
let (fo_to, _) = visibility_addressing(ApVisibility::FollowersOnly, &followers_url());
assert_ne!(
pub_to, fo_to,
"Public and FollowersOnly must produce different to fields"
);
}

495
src/tests/integration.rs Normal file
View File

@@ -0,0 +1,495 @@
// src/tests/integration.rs
/// Integration tests with in-memory trait stubs.
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use tokio::sync::Mutex;
use url::Url;
use crate::content::{ApContentReader, ApObjectHandler};
use crate::data::FederationData;
use crate::repository::{
ActivityRepository, ActorRepository, BlockedDomain, BlocklistRepository, FollowRepository,
Follower, FollowerStatus, FollowingStatus, RemoteActor,
};
use crate::user::{ApActorType, ApUser, ApUserRepository};
// ── ActivityRepository ────────────────────────────────────────────────────────
#[derive(Default)]
struct MemActivityRepo {
processed: Mutex<HashSet<String>>,
}
#[async_trait]
impl ActivityRepository for MemActivityRepo {
async fn is_activity_processed(&self, id: &str) -> anyhow::Result<bool> {
Ok(self.processed.lock().await.contains(id))
}
async fn mark_activity_processed(&self, id: &str) -> anyhow::Result<()> {
self.processed.lock().await.insert(id.to_string());
Ok(())
}
}
// ── FollowRepository ──────────────────────────────────────────────────────────
#[derive(Default)]
struct MemFollowRepo;
#[async_trait]
impl FollowRepository for MemFollowRepo {
async fn add_follower(
&self,
_: uuid::Uuid,
_: &str,
_: FollowerStatus,
_: &str,
) -> anyhow::Result<()> {
Ok(())
}
async fn get_follower_follow_activity_id(
&self,
_: uuid::Uuid,
_: &str,
) -> anyhow::Result<Option<String>> {
Ok(None)
}
async fn remove_follower(&self, _: uuid::Uuid, _: &str) -> anyhow::Result<()> {
Ok(())
}
async fn get_followers(&self, _: uuid::Uuid) -> anyhow::Result<Vec<Follower>> {
Ok(vec![])
}
async fn get_followers_page(
&self,
_: uuid::Uuid,
_: u32,
_: usize,
) -> anyhow::Result<Vec<Follower>> {
Ok(vec![])
}
async fn count_followers(&self, _: uuid::Uuid) -> anyhow::Result<usize> {
Ok(0)
}
async fn update_follower_status(
&self,
_: uuid::Uuid,
_: &str,
_: FollowerStatus,
) -> anyhow::Result<()> {
Ok(())
}
async fn get_pending_followers(&self, _: uuid::Uuid) -> anyhow::Result<Vec<RemoteActor>> {
Ok(vec![])
}
async fn get_accepted_follower_inboxes(&self, _: uuid::Uuid) -> anyhow::Result<Vec<String>> {
Ok(vec![])
}
async fn count_accepted_followers(&self, _: uuid::Uuid) -> anyhow::Result<usize> {
Ok(0)
}
async fn get_accepted_followers_page(
&self,
_: uuid::Uuid,
_: u32,
_: usize,
) -> anyhow::Result<Vec<RemoteActor>> {
Ok(vec![])
}
async fn add_following(&self, _: uuid::Uuid, _: RemoteActor, _: &str) -> anyhow::Result<()> {
Ok(())
}
async fn get_follow_activity_id(
&self,
_: uuid::Uuid,
_: &str,
) -> anyhow::Result<Option<String>> {
Ok(None)
}
async fn remove_following(&self, _: uuid::Uuid, _: &str) -> anyhow::Result<()> {
Ok(())
}
async fn get_following(&self, _: uuid::Uuid) -> anyhow::Result<Vec<RemoteActor>> {
Ok(vec![])
}
async fn get_following_page(
&self,
_: uuid::Uuid,
_: u32,
_: usize,
) -> anyhow::Result<Vec<RemoteActor>> {
Ok(vec![])
}
async fn count_following(&self, _: uuid::Uuid) -> anyhow::Result<usize> {
Ok(0)
}
async fn update_following_status(
&self,
_: uuid::Uuid,
_: &str,
_: FollowingStatus,
) -> anyhow::Result<()> {
Ok(())
}
async fn get_following_outbox_url(
&self,
_: uuid::Uuid,
_: &str,
) -> anyhow::Result<Option<String>> {
Ok(None)
}
async fn migrate_follower_actor(&self, _: &str, _: &str) -> anyhow::Result<Vec<uuid::Uuid>> {
Ok(vec![])
}
}
// ── ActorRepository ───────────────────────────────────────────────────────────
#[derive(Default)]
struct MemActorRepo;
#[async_trait]
impl ActorRepository for MemActorRepo {
async fn get_local_actor_keypair(
&self,
_: uuid::Uuid,
) -> anyhow::Result<Option<(String, String)>> {
Ok(None)
}
async fn save_local_actor_keypair(
&self,
_: uuid::Uuid,
_: String,
_: String,
) -> anyhow::Result<()> {
Ok(())
}
async fn upsert_remote_actor(&self, _: RemoteActor) -> anyhow::Result<()> {
Ok(())
}
async fn get_remote_actor(&self, _: &str) -> anyhow::Result<Option<RemoteActor>> {
Ok(None)
}
async fn add_announce(
&self,
_: &str,
_: &str,
_: &str,
_: DateTime<Utc>,
) -> anyhow::Result<()> {
Ok(())
}
async fn remove_announce(&self, _: &str, _: &str) -> anyhow::Result<()> {
Ok(())
}
async fn count_announces(&self, _: &str) -> anyhow::Result<usize> {
Ok(0)
}
}
// ── BlocklistRepository ───────────────────────────────────────────────────────
struct MemBlocklistRepo {
blocked_domains: Mutex<HashSet<String>>,
}
impl MemBlocklistRepo {
fn with_blocked_domains(domains: impl IntoIterator<Item = String>) -> Self {
Self {
blocked_domains: Mutex::new(domains.into_iter().collect()),
}
}
}
impl Default for MemBlocklistRepo {
fn default() -> Self {
Self {
blocked_domains: Mutex::new(HashSet::new()),
}
}
}
#[async_trait]
impl BlocklistRepository for MemBlocklistRepo {
async fn add_blocked_domain(&self, domain: &str, _: Option<&str>) -> anyhow::Result<()> {
self.blocked_domains.lock().await.insert(domain.to_string());
Ok(())
}
async fn remove_blocked_domain(&self, domain: &str) -> anyhow::Result<()> {
self.blocked_domains.lock().await.remove(domain);
Ok(())
}
async fn get_blocked_domains(&self) -> anyhow::Result<Vec<BlockedDomain>> {
Ok(vec![])
}
async fn is_domain_blocked(&self, domain: &str) -> anyhow::Result<bool> {
Ok(self.blocked_domains.lock().await.contains(domain))
}
async fn add_blocked_actor(&self, _: uuid::Uuid, _: &str) -> anyhow::Result<()> {
Ok(())
}
async fn remove_blocked_actor(&self, _: uuid::Uuid, _: &str) -> anyhow::Result<()> {
Ok(())
}
async fn get_blocked_actors(&self, _: uuid::Uuid) -> anyhow::Result<Vec<String>> {
Ok(vec![])
}
async fn is_actor_blocked(&self, _: uuid::Uuid, _: &str) -> anyhow::Result<bool> {
Ok(false)
}
}
// ── ApUserRepository ──────────────────────────────────────────────────────────
struct MemUserRepo {
users: HashMap<uuid::Uuid, ApUser>,
}
impl MemUserRepo {
fn with_user(id: uuid::Uuid, username: &str) -> Self {
let mut users = HashMap::new();
users.insert(
id,
ApUser {
id,
username: username.to_string(),
display_name: None,
bio: None,
avatar_url: None,
banner_url: None,
also_known_as: vec![],
profile_url: None,
attachment: vec![],
manually_approves_followers: true,
discoverable: true,
actor_type: ApActorType::Person,
featured_url: None,
},
);
Self { users }
}
}
#[async_trait]
impl ApUserRepository for MemUserRepo {
async fn find_by_id(&self, id: uuid::Uuid) -> anyhow::Result<Option<ApUser>> {
Ok(self.users.get(&id).cloned())
}
async fn find_by_username(&self, username: &str) -> anyhow::Result<Option<ApUser>> {
Ok(self
.users
.values()
.find(|u| u.username == username)
.cloned())
}
async fn count_users(&self) -> anyhow::Result<usize> {
Ok(self.users.len())
}
}
// ── ApContentReader ───────────────────────────────────────────────────────────
#[derive(Default)]
struct MemContentReader;
#[async_trait]
impl ApContentReader for MemContentReader {
async fn get_local_objects_page(
&self,
_: uuid::Uuid,
_: Option<DateTime<Utc>>,
_: usize,
) -> anyhow::Result<Vec<(Url, serde_json::Value, DateTime<Utc>)>> {
Ok(vec![])
}
async fn count_local_posts(&self) -> anyhow::Result<u64> {
Ok(0)
}
}
// ── ApObjectHandler ───────────────────────────────────────────────────────────
#[derive(Default)]
struct MemHandler {
creates: Mutex<Vec<Url>>,
mentions: Mutex<Vec<(Url, uuid::Uuid)>>,
}
#[async_trait]
impl ApObjectHandler for MemHandler {
async fn on_create(&self, ap_id: &Url, _: &Url, _: serde_json::Value) -> anyhow::Result<()> {
self.creates.lock().await.push(ap_id.clone());
Ok(())
}
async fn on_update(&self, _: &Url, _: &Url, _: serde_json::Value) -> anyhow::Result<()> {
Ok(())
}
async fn on_delete(&self, _: &Url, _: &Url) -> anyhow::Result<()> {
Ok(())
}
async fn on_actor_removed(&self, _: &Url) -> anyhow::Result<()> {
Ok(())
}
async fn on_like(&self, _: &Url, _: &Url) -> anyhow::Result<()> {
Ok(())
}
async fn on_unlike(&self, _: &Url, _: &Url) -> anyhow::Result<()> {
Ok(())
}
async fn on_announce_received(&self, _: &Url, _: &Url) -> anyhow::Result<()> {
Ok(())
}
async fn on_announce_of_remote(&self, _: &Url, _: &Url) -> anyhow::Result<()> {
Ok(())
}
async fn on_mention(&self, ap_id: &Url, user_id: uuid::Uuid, _: &Url) -> anyhow::Result<()> {
self.mentions.lock().await.push((ap_id.clone(), user_id));
Ok(())
}
}
// ── Helper ────────────────────────────────────────────────────────────────────
fn make_data(
activity_repo: Arc<MemActivityRepo>,
follow_repo: Arc<MemFollowRepo>,
actor_repo: Arc<MemActorRepo>,
blocklist_repo: Arc<MemBlocklistRepo>,
user_repo: Arc<MemUserRepo>,
content_reader: Arc<MemContentReader>,
handler: Arc<MemHandler>,
) -> FederationData {
FederationData::new(
activity_repo,
follow_repo,
actor_repo,
blocklist_repo,
user_repo,
content_reader,
handler,
"https://example.com".to_string(),
false,
"test".to_string(),
None,
std::time::Duration::from_secs(24 * 60 * 60),
)
}
// ── Tests ─────────────────────────────────────────────────────────────────────
#[tokio::test]
async fn check_guards_idempotency() {
use crate::activities::helpers::check_guards;
use activitypub_federation::config::FederationConfig;
let activity_repo = Arc::new(MemActivityRepo::default());
let data_inner = make_data(
activity_repo,
Arc::new(MemFollowRepo),
Arc::new(MemActorRepo),
Arc::new(MemBlocklistRepo::default()),
Arc::new(MemUserRepo::with_user(uuid::Uuid::new_v4(), "alice")),
Arc::new(MemContentReader),
Arc::new(MemHandler::default()),
);
let config = FederationConfig::builder()
.domain("example.com")
.app_data(data_inner)
.debug(true)
.build()
.await
.unwrap();
let data = config.to_request_data();
let activity_id: Url = "https://remote.example/activities/abc123".parse().unwrap();
let actor: Url = "https://remote.example/users/bob".parse().unwrap();
let skip = check_guards(&activity_id, &actor, &data).await.unwrap();
assert!(!skip, "first delivery should not be skipped");
let skip = check_guards(&activity_id, &actor, &data).await.unwrap();
assert!(skip, "duplicate delivery should be skipped");
let other_id: Url = "https://remote.example/activities/xyz999".parse().unwrap();
let skip = check_guards(&other_id, &actor, &data).await.unwrap();
assert!(!skip, "different activity should not be skipped");
}
#[tokio::test]
async fn check_guards_blocks_domain() {
use crate::activities::helpers::check_guards;
use activitypub_federation::config::FederationConfig;
let blocklist_repo = Arc::new(MemBlocklistRepo::with_blocked_domains([
"spam.example".to_string()
]));
let data_inner = make_data(
Arc::new(MemActivityRepo::default()),
Arc::new(MemFollowRepo),
Arc::new(MemActorRepo),
blocklist_repo,
Arc::new(MemUserRepo::with_user(uuid::Uuid::new_v4(), "alice")),
Arc::new(MemContentReader),
Arc::new(MemHandler::default()),
);
let config = FederationConfig::builder()
.domain("example.com")
.app_data(data_inner)
.debug(true)
.build()
.await
.unwrap();
let data = config.to_request_data();
let activity_id: Url = "https://spam.example/activities/1".parse().unwrap();
let actor: Url = "https://spam.example/users/evil".parse().unwrap();
let skip = check_guards(&activity_id, &actor, &data).await.unwrap();
assert!(skip, "activity from blocked domain should be skipped");
}
#[tokio::test]
async fn extract_and_dispatch_mentions_notifies_local_users() {
use crate::activities::helpers::extract_and_dispatch_mentions;
use activitypub_federation::config::FederationConfig;
let local_user_id = uuid::Uuid::new_v4();
let handler = Arc::new(MemHandler::default());
let data_inner = make_data(
Arc::new(MemActivityRepo::default()),
Arc::new(MemFollowRepo),
Arc::new(MemActorRepo),
Arc::new(MemBlocklistRepo::default()),
Arc::new(MemUserRepo::with_user(local_user_id, "alice")),
Arc::new(MemContentReader),
handler.clone(),
);
let config = FederationConfig::builder()
.domain("example.com")
.app_data(data_inner)
.debug(true)
.build()
.await
.unwrap();
let data = config.to_request_data();
let ap_id: Url = "https://remote.example/notes/1".parse().unwrap();
let actor_url: Url = "https://remote.example/users/bob".parse().unwrap();
let local_user_url = format!("https://example.com/users/{}", local_user_id);
let object = serde_json::json!({
"type": "Note",
"id": ap_id.as_str(),
"content": "Hello @alice",
"tag": [{"type": "Mention", "href": local_user_url}]
});
extract_and_dispatch_mentions(&ap_id, &actor_url, &object, &data).await;
let mentions = handler.mentions.lock().await;
assert_eq!(mentions.len(), 1);
assert_eq!(mentions[0].0, ap_id);
assert_eq!(mentions[0].1, local_user_id);
}

View File

@@ -1,45 +1,3 @@
use super::*; // Inbox deduplication (shared_inbox preference, blocked-actor/domain filtering)
use crate::repository::{Follower, FollowerStatus, RemoteActor}; // is now enforced by the repository implementation via `get_accepted_follower_inboxes`.
// Integration tests for broadcast delivery live in the consuming crate's test suite.
fn make_follower(inbox: &str, shared: Option<&str>) -> Follower {
Follower {
actor: RemoteActor {
url: format!("https://remote/{}", inbox),
handle: "user".to_string(),
inbox_url: inbox.to_string(),
shared_inbox_url: shared.map(|s| s.to_string()),
display_name: None,
avatar_url: None,
outbox_url: None,
},
status: FollowerStatus::Accepted,
}
}
#[test]
fn collect_inboxes_deduplicates_shared() {
let followers = vec![
make_follower(
"https://mastodon.social/users/a/inbox",
Some("https://mastodon.social/inbox"),
),
make_follower(
"https://mastodon.social/users/b/inbox",
Some("https://mastodon.social/inbox"),
),
make_follower("https://other.instance/users/c/inbox", None),
];
let inboxes = collect_inboxes(&followers);
assert_eq!(inboxes.len(), 2);
let strs: Vec<_> = inboxes.iter().map(|u| u.as_str()).collect();
assert!(strs.contains(&"https://mastodon.social/inbox"));
assert!(strs.contains(&"https://other.instance/users/c/inbox"));
}
#[test]
fn collect_inboxes_falls_back_to_individual_inbox() {
let followers = vec![make_follower("https://example.com/users/x/inbox", None)];
let inboxes = collect_inboxes(&followers);
assert_eq!(inboxes.len(), 1);
assert_eq!(inboxes[0].as_str(), "https://example.com/users/x/inbox");
}

View File

@@ -6,6 +6,24 @@ pub const AS_PUBLIC: &str = "https://www.w3.org/ns/activitystreams#Public";
pub const AP_CONTEXT: &str = "https://www.w3.org/ns/activitystreams"; pub const AP_CONTEXT: &str = "https://www.w3.org/ns/activitystreams";
pub const AP_PAGE_SIZE: usize = 20; pub const AP_PAGE_SIZE: usize = 20;
/// Returns the `@context` array for actor AP JSON.
/// Includes the W3C security vocabulary (needed for `publicKey` resolution)
/// and common Mastodon/Toot extensions (`discoverable`, `featured`, etc.).
/// Activities use `WithContext::new_default` (plain AS context) — only actor
/// JSON needs the security vocab.
pub fn actor_ap_context() -> serde_json::Value {
serde_json::json!([
"https://www.w3.org/ns/activitystreams",
"https://w3id.org/security/v1",
{
"manuallyApprovesFollowers": "as:manuallyApprovesFollowers",
"toot": "http://joinmastodon.org/ns#",
"discoverable": "toot:discoverable",
"featured": {"@id": "toot:featured", "@type": "@id"}
}
])
}
pub fn extract_user_id_from_url(url: &Url) -> Option<uuid::Uuid> { pub fn extract_user_id_from_url(url: &Url) -> Option<uuid::Uuid> {
let path = url.path(); let path = url.path();
path.strip_prefix("/users/") path.strip_prefix("/users/")

View File

@@ -1,4 +1,5 @@
use async_trait::async_trait; use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use url::Url; use url::Url;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -7,16 +8,73 @@ pub struct ApProfileField {
pub value: String, pub value: String,
} }
/// Visibility of a federated post.
///
/// Controls the `to`/`cc` addressing fields of outbound Create/Update activities
/// and whether the library fans the activity out to followers at all.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ApVisibility {
/// `to: [AS_PUBLIC], cc: [followers]` — fully public, indexable by search engines.
#[default]
Public,
/// `to: [followers], cc: []` — only followers receive it, not indexed publicly.
FollowersOnly,
/// No federation delivery. The library returns immediately without sending anything.
/// Use when the post should exist only on the local instance.
Private,
}
/// Actor type for AP serialization. Defaults to `Person`.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub enum ApActorType {
#[default]
Person,
Service,
Application,
Organization,
Group,
}
/// Resolved actor data returned by [`crate::service::ActivityPubService::lookup_actor_by_handle`].
/// Fetched via a signed HTTP request so strict instances (e.g. Threads) return full data.
#[derive(Debug, Clone)]
pub struct LookedUpActor {
pub handle: String,
pub display_name: Option<String>,
pub bio: Option<String>,
pub avatar_url: Option<Url>,
pub banner_url: Option<Url>,
pub ap_url: Url,
pub outbox_url: Option<Url>,
pub followers_url: Option<Url>,
pub following_url: Option<Url>,
pub also_known_as: Vec<String>,
pub profile_url: Option<Url>,
pub attachment: Vec<ApProfileField>,
}
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct ApUser { pub struct ApUser {
pub id: uuid::Uuid, pub id: uuid::Uuid,
pub username: String, pub username: String,
pub display_name: Option<String>,
pub bio: Option<String>, pub bio: Option<String>,
pub avatar_url: Option<Url>, pub avatar_url: Option<Url>,
pub banner_url: Option<Url>, pub banner_url: Option<Url>,
pub also_known_as: Option<String>, pub also_known_as: Vec<String>,
pub profile_url: Option<Url>, pub profile_url: Option<Url>,
pub attachment: Vec<ApProfileField>, pub attachment: Vec<ApProfileField>,
/// If true, incoming Follow requests must be manually approved before the
/// actor is listed as `manuallyApprovesFollowers=true` in AP JSON.
pub manually_approves_followers: bool,
/// Whether this actor should appear in AP directory listings.
/// Serialized as `discoverable` in actor JSON. Defaults to `true`.
pub discoverable: bool,
/// AP actor type serialized in the actor JSON. Defaults to `Person`.
pub actor_type: ApActorType,
/// URL of the `featured` (pinned posts) collection. Set to expose a pinned
/// posts collection in the actor JSON, compatible with Mastodon/Pleroma.
pub featured_url: Option<Url>,
} }
#[async_trait] #[async_trait]

View File

@@ -1,13 +1,10 @@
use activitypub_federation::{ use activitypub_federation::{config::Data, fetch::webfinger::extract_webfinger_name};
config::Data,
fetch::webfinger::{Webfinger, build_webfinger_response, extract_webfinger_name},
};
use axum::{ use axum::{
extract::Query, extract::Query,
http::header, http::header,
response::{IntoResponse, Response}, response::{IntoResponse, Response},
}; };
use serde::Deserialize; use serde::{Deserialize, Serialize};
use crate::data::FederationData; use crate::data::FederationData;
use crate::error::Error; use crate::error::Error;
@@ -17,6 +14,23 @@ pub struct WebfingerQuery {
resource: String, resource: String,
} }
#[derive(Serialize)]
struct WebfingerLink {
rel: String,
#[serde(rename = "type", skip_serializing_if = "Option::is_none")]
kind: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
href: Option<String>,
}
#[derive(Serialize)]
struct WebfingerResponse {
subject: String,
/// Canonical URIs for the same account (acct: URI + AP actor URL).
aliases: Vec<String>,
links: Vec<WebfingerLink>,
}
pub async fn webfinger_handler( pub async fn webfinger_handler(
Query(query): Query<WebfingerQuery>, Query(query): Query<WebfingerQuery>,
data: Data<FederationData>, data: Data<FederationData>,
@@ -31,8 +45,25 @@ pub async fn webfinger_handler(
.ok_or_else(|| Error::not_found(anyhow::anyhow!("user not found")))?; .ok_or_else(|| Error::not_found(anyhow::anyhow!("user not found")))?;
let ap_id = crate::urls::actor_url(&data.base_url, user.id); let ap_id = crate::urls::actor_url(&data.base_url, user.id);
let acct_uri = format!("acct:{}@{}", user.username, data.domain);
let wf = WebfingerResponse {
subject: query.resource.clone(),
aliases: vec![acct_uri, ap_id.to_string()],
links: vec![
WebfingerLink {
rel: "http://webfinger.net/rel/profile-page".to_string(),
kind: Some("text/html".to_string()),
href: Some(ap_id.to_string()),
},
WebfingerLink {
rel: "self".to_string(),
kind: Some("application/activity+json".to_string()),
href: Some(ap_id.to_string()),
},
],
};
let wf: Webfinger = build_webfinger_response(query.resource, ap_id);
let body = serde_json::to_string(&wf).map_err(|e| Error::from(anyhow::anyhow!(e)))?; let body = serde_json::to_string(&wf).map_err(|e| Error::from(anyhow::anyhow!(e)))?;
Ok(([(header::CONTENT_TYPE, "application/jrd+json")], body).into_response()) Ok(([(header::CONTENT_TYPE, "application/jrd+json")], body).into_response())
} }