Compare commits
48 Commits
v0.1.6
...
ea9fed4409
| Author | SHA1 | Date | |
|---|---|---|---|
| ea9fed4409 | |||
| 57cf5650fb | |||
| b88a187749 | |||
| b569efe715 | |||
| 17b57fb9b5 | |||
| 4d7117b123 | |||
| bb7db58848 | |||
| 6e1e1357fb | |||
| 00ec90fb03 | |||
| 997047fdbd | |||
| 902d38cc76 | |||
| f461f633b3 | |||
| 9355f20616 | |||
| d1ce277ff5 | |||
| 4cb8efb6ce | |||
| 7171a1791a | |||
| f08d11034d | |||
| 9f9c4e769b | |||
| ca949691e4 | |||
| 62c9bf2e4e | |||
| 485c407edb | |||
| fad95f0550 | |||
| c1c8a37d0d | |||
| 757c6d14ec | |||
| 88fd1bfbdc | |||
| 90ea438764 | |||
| f00514850b | |||
| 48fded426f | |||
| 5288696795 | |||
| d5f75b4b57 | |||
| 0519bed66c | |||
| 2ee0452fa8 | |||
| 73a68860c1 | |||
| df6ff4c1e8 | |||
| e11b0a6609 | |||
| 4ef1315671 | |||
| 7424d1dc54 | |||
| db6a451788 | |||
| aec768b5a0 | |||
| 90a0d91b39 | |||
| 7ccc18e85c | |||
| b557bd9d46 | |||
| d80cfd0431 | |||
| 432f39cbb4 | |||
| 2c509cbf88 | |||
| 52614d406a | |||
| 1949fce620 | |||
| 699258f830 |
32
.gitea/workflows/ci.yml
Normal file
32
.gitea/workflows/ci.yml
Normal 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
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,2 +1,3 @@
|
|||||||
/target
|
/target
|
||||||
Cargo.lock
|
Cargo.lock
|
||||||
|
docs/
|
||||||
|
|||||||
302
CHANGELOG.md
Normal file
302
CHANGELOG.md
Normal file
@@ -0,0 +1,302 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
## [0.5.1] — 2026-10-26
|
||||||
|
|
||||||
|
- build: axum-only activitypub_federation, rustls-ring only — drops actix-web and aws-lc from consumers' builds
|
||||||
|
- k-ap installs the rustls `ring` CryptoProvider as the process default (once) when an `ApFederationConfig` is built or a lookup/backfill client is created. Hosts that want a different provider, or that call `CryptoProvider::install_default().expect(..)`, must install theirs before constructing `ApFederationConfig`. Hosts building their own reqwest client with `rustls-no-provider` before k-ap must install a provider themselves.
|
||||||
|
|
||||||
|
## [0.5.0] — 2026-07-25
|
||||||
|
|
||||||
|
### Breaking changes
|
||||||
|
|
||||||
|
- `broadcast_create_note` renamed to `broadcast_create`, `broadcast_update_note` renamed to `broadcast_update`
|
||||||
|
- `LocalObject` is now a named struct with addressing fields: `to`, `cc`, `bto`, `bcc`
|
||||||
|
- `ApContentReader::get_local_objects_page` returns `Vec<LocalObject>` instead of `Vec<(Url, Value, DateTime)>`
|
||||||
|
- `Error` is now a thiserror enum with `NotFound` / `BadRequest` / `Unauthorized` / `Forbidden` / `Internal` variants
|
||||||
|
- Repository traits split into sub-traits — `FollowRepository` is now a supertrait of `FollowerWriter`, `FollowerReader`, `FollowingWriter`, `FollowingReader`, `FollowMigration`; `ActorRepository` is a supertrait of `KeypairRepository`, `RemoteActorCache`, `AnnounceRepository`. Existing consumers implementing the supertrait continue to work unchanged.
|
||||||
|
- `ActorRepository::save_local_actor_keypair` now takes a `Keypair` named struct instead of two strings
|
||||||
|
- `NoteType` re-export removed
|
||||||
|
- Internal modules (`activities`, `actors`, `handlers`, `data`, `content`, `error`, `user`, `federation`) are now `pub(crate)`
|
||||||
|
- `get_following_outbox_url` removed (dead code)
|
||||||
|
|
||||||
|
### New features
|
||||||
|
|
||||||
|
- `UrlScheme` trait + `DefaultUrlScheme` — configurable URL patterns via `.url_scheme(Arc::new(MyScheme))` on the builder
|
||||||
|
- `broadcast_raw_to_followers` — send arbitrary AP activity JSON to all accepted followers
|
||||||
|
- `on_unknown_activity` hook on `ApObjectHandler` — handle custom AP extensions (EmojiReact, Question, Flag, etc.)
|
||||||
|
- `DbActor::object_id()` convenience method
|
||||||
|
- `RemoteActor::from_ap_person()`, `RemoteActor::placeholder()` constructors
|
||||||
|
- Mock builder framework: `MockFollowRepoBuilder`, `MockActorRepoBuilder`, `MockBlocklistRepoBuilder`, `MockActivityRepoBuilder`, `MockUserRepoBuilder`, `MockContentReaderBuilder`, `MockObjectHandlerBuilder`, `MockEventPublisherBuilder`
|
||||||
|
- `Keypair` named struct (was anonymous tuple)
|
||||||
|
- `LocalObject` named struct with addressing (was anonymous tuple)
|
||||||
|
- `FollowMigration::migrate_follower_actor` has a default no-op implementation
|
||||||
|
- `actor_handler`, `followers_handler`, `following_handler` re-exported for custom router construction
|
||||||
|
- Constants: `AP_CONTENT_TYPE`, `AP_CONTEXT`, `INBOX_BODY_LIMIT`
|
||||||
|
|
||||||
|
### Bug fixes / security
|
||||||
|
|
||||||
|
- Fix SSRF bypass via IPv6-mapped IPv4 addresses
|
||||||
|
- Block additional reserved IP ranges (TEST-NET, benchmarking, reserved, IPv6 documentation)
|
||||||
|
- `verify_attributed_to` now rejects missing `attributedTo` and handles array form
|
||||||
|
- Signature failures return 401 instead of 500
|
||||||
|
- Remove `.expect()` panics from outbox handler
|
||||||
|
- Unknown activity types accepted gracefully instead of returning 500
|
||||||
|
|
||||||
|
### Internal improvements
|
||||||
|
|
||||||
|
- thiserror enum for `Error` type (41 boilerplate `.map_err` calls eliminated)
|
||||||
|
- File organization: `handlers/`, `actors/`, `service/` with `types.rs` per module
|
||||||
|
- `mock_repo!` macro replaces 980 lines of hand-written mocks
|
||||||
|
- Small focused functions and descriptive variable names throughout
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.4.6] — 2026-07-16
|
||||||
|
|
||||||
|
### New features
|
||||||
|
|
||||||
|
- `ActivityPubServiceBuilder::nodeinfo_services(inbound, outbound)` — forward NodeInfo services config through the builder
|
||||||
|
- `ActivityPubServiceBuilder::nodeinfo_metadata(value)` — forward arbitrary NodeInfo metadata through the builder
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.4.5] — 2026-07-16
|
||||||
|
|
||||||
|
### Bug fixes
|
||||||
|
|
||||||
|
- Remove invalid `$schema` field from NodeInfo 2.0 response — the field is not allowed by the NodeInfo 2.0 schema (`additionalProperties: false`)
|
||||||
|
- Add missing required `services` and `metadata` fields to NodeInfo 2.0 response
|
||||||
|
|
||||||
|
### New features
|
||||||
|
|
||||||
|
- `FederationData::with_nodeinfo_services(inbound, outbound)` — configure NodeInfo `services.inbound`/`services.outbound`
|
||||||
|
- `FederationData::with_nodeinfo_metadata(value)` — set arbitrary NodeInfo `metadata`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.4.4] — 2026-07-11
|
||||||
|
|
||||||
|
### Bug fixes
|
||||||
|
|
||||||
|
- Actor cache TTL now treats `fetched_at: None` as always-stale instead of always-fresh. Consumers who never populated `fetched_at` were silently getting no cache invalidation.
|
||||||
|
|
||||||
|
### Other improvements
|
||||||
|
|
||||||
|
- Structured tracing on key code paths: inbound activity dispatch (activity ID, source domain), outbound delivery (inbox count, retry attempts), actor cache lookups (hit/miss, staleness).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.4.3] — 2026-07-11
|
||||||
|
|
||||||
|
### Bug fixes
|
||||||
|
|
||||||
|
- NodeInfo 2.0 response now includes the required `$schema` field (`http://nodeinfo.diaspora.software/ns/schema/2.0#`). Validators like browser.pub rejected the previous response for missing it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.4.2] — 2026-07-10
|
||||||
|
|
||||||
|
### New features
|
||||||
|
|
||||||
|
- `FederationEvent::OutboundFollowAccepted { local_user_id, remote_actor_url, outbox_url }` — emitted when a remote actor accepts our outbound follow request. Consumers should call `import_remote_outbox(outbox_url, actor_url)` to backfill the remote user's content. Without this event, outbound follow acceptance was silent and consumers had no hook to trigger backfill.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.4.1] — 2026-06-30
|
||||||
|
|
||||||
|
### Bug fixes
|
||||||
|
|
||||||
|
- `unblock_actor` now sends an `Undo(Block)` activity to the remote actor's inbox before removing them from the local blocklist, making unblocking AP-compliant.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [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
|
||||||
709
Cargo.lock
generated
709
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
12
Cargo.toml
12
Cargo.toml
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "k-ap"
|
name = "k-ap"
|
||||||
version = "0.1.6"
|
version = "0.5.1"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
description = "Generic ActivityPub protocol layer"
|
description = "Generic ActivityPub protocol layer"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
@@ -14,10 +14,14 @@ serde_json = "1.0"
|
|||||||
uuid = { version = "1.0", features = ["v4", "v5", "serde"] }
|
uuid = { version = "1.0", features = ["v4", "v5", "serde"] }
|
||||||
chrono = { version = "0.4", features = ["serde"] }
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
anyhow = "1.0"
|
anyhow = "1.0"
|
||||||
|
thiserror = "2"
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
async-trait = "0.1"
|
async-trait = "0.1.92"
|
||||||
axum = { version = "0.8", features = ["macros"] }
|
axum = { version = "0.8", features = ["macros"] }
|
||||||
reqwest = { version = "0.13", features = ["json"] }
|
reqwest = { version = "0.13", default-features = false, features = ["json", "charset", "http2", "system-proxy", "rustls-no-provider"] }
|
||||||
|
rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12", "logging"] }
|
||||||
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 = { version = "0.7.0-beta.11", default-features = false, features = ["axum"] }
|
||||||
|
zeroize = { version = "1", features = ["derive"] }
|
||||||
|
paste = "1"
|
||||||
|
|||||||
32
Makefile
Normal file
32
Makefile
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
.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
|
||||||
|
|
||||||
|
# Publish to the gitea registry.
|
||||||
|
publish: check
|
||||||
|
cargo publish --registry gitea
|
||||||
|
|
||||||
|
.PHONY: check fmt fmt-check clippy test fix publish
|
||||||
375
README.md
375
README.md
@@ -8,41 +8,77 @@ Not domain-specific — no opinions about what your content type looks like.
|
|||||||
|
|
||||||
## Add as dependency
|
## Add as dependency
|
||||||
|
|
||||||
|
Via the private Gitea registry (recommended):
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
[dependencies]
|
[dependencies]
|
||||||
k-ap = { git = "https://git.gabrielkaszewski.dev/GKaszewski/k-ap.git", tag = "v0.1.0" }
|
k-ap = { version = "0.5.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.5.0" }
|
||||||
```
|
```
|
||||||
|
|
||||||
## What you implement
|
## What you implement
|
||||||
|
|
||||||
Three traits wire your data layer into `k-ap`:
|
Seven supertrait facades 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
|
```rust
|
||||||
// Your database layer for follows, keypairs, remote actors, blocks
|
// Activity deduplication — idempotency for inbound deliveries
|
||||||
impl FederationRepository for MyFederationRepo { ... }
|
impl ActivityRepository for MyDb { ... }
|
||||||
|
|
||||||
// Your user lookup (id, username, bio, avatar)
|
// Follower / following graph + account migration (5 sub-traits)
|
||||||
impl ApUserRepository for MyUserRepo { ... }
|
impl FollowRepository for MyDb { ... }
|
||||||
|
|
||||||
// Dispatch incoming AP objects to the right handler
|
// Local keypairs, remote actor cache, boost (Announce) tracking (3 sub-traits)
|
||||||
impl ApObjectHandler for MyObjectHandler { ... }
|
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 { ... }
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`FollowRepository` and `ActorRepository` are composed from fine-grained sub-traits (`FollowerWriter`, `FollowerReader`, `FollowingWriter`, `FollowingReader`, `FollowMigration`, `KeypairRepository`, `RemoteActorCache`, `AnnounceRepository`). Implement the supertrait and Rust auto-derives it, or implement sub-traits individually for more control.
|
||||||
|
|
||||||
## Wire up the service
|
## Wire up the service
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
use k_ap::{ActivityPubService, FederationRepository, ApUserRepository, ApObjectHandler};
|
use std::sync::Arc;
|
||||||
|
use k_ap::ActivityPubService;
|
||||||
|
|
||||||
let service = ActivityPubService::builder(
|
let db = Arc::new(MyDb::new(...));
|
||||||
Arc::new(my_federation_repo),
|
|
||||||
Arc::new(my_user_repo),
|
let service = ActivityPubService::builder("https://example.com")
|
||||||
Arc::new(my_object_handler),
|
.activity_repo(db.clone())
|
||||||
"https://example.com",
|
.follow_repo(db.clone())
|
||||||
)
|
.actor_repo(db.clone())
|
||||||
.allow_registration(true)
|
.blocklist_repo(db.clone())
|
||||||
.software_name("my-app")
|
.user_repo(db.clone())
|
||||||
.build()
|
.content_reader(db.clone())
|
||||||
.await?;
|
.object_handler(db.clone())
|
||||||
|
.allow_registration(false)
|
||||||
|
.software_name("my-app")
|
||||||
|
// .url_scheme(Arc::new(MyScheme)) // optional: customize URL patterns
|
||||||
|
.build()
|
||||||
|
.await?;
|
||||||
|
|
||||||
// Mount the AP routes onto your axum router
|
// Mount the AP routes onto your axum router
|
||||||
let router = Router::new().merge(service.router());
|
let router = Router::new().merge(service.router());
|
||||||
@@ -50,50 +86,299 @@ let router = Router::new().merge(service.router());
|
|||||||
|
|
||||||
## What the service handles for you
|
## What the service handles for you
|
||||||
|
|
||||||
- **Actor** — `GET /users/:id` serves the AP Person object with public key
|
`service.router()` registers only the routes k-ap fully owns:
|
||||||
- **Inbox** — `POST /users/:id/inbox` + `POST /inbox` (shared), verifies HTTP signatures, dispatches to your `ApObjectHandler`
|
|
||||||
- **Outbox** — `GET /users/:id/outbox` with OrderedCollection pagination
|
|
||||||
- **Followers / Following** — `GET /users/:id/followers` and `/following`
|
|
||||||
- **WebFinger** — `GET /.well-known/webfinger`
|
|
||||||
- **NodeInfo** — `GET /.well-known/nodeinfo` + `GET /nodeinfo/2.1`
|
|
||||||
|
|
||||||
## Broadcast from your domain layer
|
| 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
|
```rust
|
||||||
// Fan out a new note to all accepted followers
|
// In your axum actor handler — serve AP JSON or UI JSON based on Accept header
|
||||||
service.broadcast_create_note(user_id, ¬e_json).await?;
|
async fn actor_handler(Path(username): Path<String>, headers: HeaderMap, ...) {
|
||||||
service.broadcast_update_note(user_id, ¬e_json).await?;
|
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(user_id, note_json, ApVisibility::Public, mentioned).await?;
|
||||||
|
|
||||||
|
// Followers only — to: [followers], cc: []; delivered to followers + mentioned
|
||||||
|
service.broadcast_create(user_id, note_json, ApVisibility::FollowersOnly, vec![]).await?;
|
||||||
|
|
||||||
|
// Private — no delivery at all; library returns immediately
|
||||||
|
service.broadcast_create(user_id, note_json, ApVisibility::Private, vec![]).await?;
|
||||||
|
|
||||||
|
service.broadcast_update(user_id, note_json, ApVisibility::Public, vec![]).await?;
|
||||||
|
service.broadcast_delete_to_followers(user_id, ap_id).await?;
|
||||||
|
|
||||||
// Announce / Undo Announce
|
// Announce / Undo Announce
|
||||||
service.broadcast_announce_to_followers(user_id, object_ap_id).await?;
|
service.broadcast_announce_to_followers(user_id, object_ap_id).await?;
|
||||||
service.broadcast_undo_announce_to_followers(user_id, object_ap_id, object_url).await?;
|
service.broadcast_undo_announce_to_followers(user_id, object_ap_id).await?;
|
||||||
|
|
||||||
// Like / Unlike to a remote inbox
|
// Like / Unlike to a single remote inbox
|
||||||
service.broadcast_like_to_inbox(user_id, object_ap_id, inbox_url).await?;
|
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?;
|
service.broadcast_undo_like_to_inbox(user_id, object_ap_id, inbox_url).await?;
|
||||||
|
|
||||||
// Follow / Unfollow / Accept / Reject
|
// Actor profile update to all followers
|
||||||
service.follow(local_user_id, remote_actor_url, handle).await?;
|
service.broadcast_actor_update(user_id).await?;
|
||||||
service.unfollow(local_user_id, remote_actor_url).await?;
|
|
||||||
service.accept_follower(local_user_id, remote_actor_url).await?;
|
// Account migration — sends Move to all followers; set alsoKnownAs first
|
||||||
service.reject_follower(local_user_id, remote_actor_url).await?;
|
service.broadcast_move(user_id, new_actor_url).await?;
|
||||||
|
|
||||||
|
// Send arbitrary AP activity JSON to all followers (escape hatch for custom types)
|
||||||
|
service.broadcast_raw_to_followers(user_id, activity_json).await?;
|
||||||
```
|
```
|
||||||
|
|
||||||
## Project-specific ports
|
## Follow management
|
||||||
|
|
||||||
`k-ap` does not define port traits tied to your domain (e.g. `OutboundFederationPort`, `ActivityPubRepository<Thought>`). Those belong in your adapter layer and are wired up there. See `crates/adapters/activitypub/src/port.rs` in `thoughts` for a reference implementation.
|
```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
|
## Key public types
|
||||||
|
|
||||||
| Type | Description |
|
| Type | Description |
|
||||||
|------|-------------|
|
|------|-------------|
|
||||||
| `ActivityPubService` | Central service — build once, share via `Arc` |
|
| `ActivityPubService` | Central service — build once, share via `Arc` |
|
||||||
| `FederationData` | Request-scoped data passed through the federation layer |
|
| `ActivityRepository` | Trait: activity ID deduplication (2 methods) |
|
||||||
| `FederationRepository` | Trait: follows, keypairs, remote actors, blocks |
|
| `FollowRepository` | Supertrait: follower/following graph + migration (19 methods via 5 sub-traits) |
|
||||||
| `ApUserRepository` | Trait: user lookup by id / username |
|
|  `FollowerWriter` | Sub-trait: add/remove/update follower records (4 methods) |
|
||||||
| `ApObjectHandler` | Trait: dispatch incoming AP objects |
|
|  `FollowerReader` | Sub-trait: query followers, counts, pages (7 methods) |
|
||||||
| `RemoteActor` | A federated actor record |
|
|  `FollowingWriter` | Sub-trait: add/remove/update following records (4 methods) |
|
||||||
| `Follower` / `FollowerStatus` | Follower with pending/accepted/rejected state |
|
|  `FollowingReader` | Sub-trait: query following, counts, pages (3 methods) |
|
||||||
|
|  `FollowMigration` | Sub-trait: account migration (1 method, has default no-op) |
|
||||||
|
| `ActorRepository` | Supertrait: keypairs, remote actor cache, announce tracking (7 methods via 3 sub-traits) |
|
||||||
|
|  `KeypairRepository` | Sub-trait: local actor keypair storage (2 methods) |
|
||||||
|
|  `RemoteActorCache` | Sub-trait: remote actor upsert/lookup (2 methods) |
|
||||||
|
|  `AnnounceRepository` | Sub-trait: boost tracking (3 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 (11 methods, 2 with defaults) |
|
||||||
|
| `UrlScheme` / `DefaultUrlScheme` | Trait + default impl: configurable URL patterns for actors and activities |
|
||||||
|
| `ApVisibility` | `Public` / `FollowersOnly` / `Private` |
|
||||||
|
| `ApActorType` | `Person` / `Service` / `Application` / `Organization` / `Group` |
|
||||||
|
| `LocalObject` | Outbox object with addressing fields (`to`, `cc`, `bto`, `bcc`) |
|
||||||
|
| `Keypair` | Named struct for actor signing keypair |
|
||||||
|
| `FederationEvent` | `DeliveryRequested` / `DeliveryFailed` / `BackfillRequested` / `OutboundFollowAccepted` |
|
||||||
|
| `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 |
|
| `ApUser` | AP-serializable local user |
|
||||||
| `ApFederationConfig` | Wraps the `activitypub_federation` config |
|
| `ApFederationConfig` | Wraps the `activitypub_federation` config |
|
||||||
| `Error` | AP-layer error type |
|
| `Error` | thiserror enum: `NotFound` / `BadRequest` / `Unauthorized` / `Forbidden` / `Internal` |
|
||||||
|
|
||||||
|
## Custom URL schemes
|
||||||
|
|
||||||
|
By default k-ap uses `/users/{uuid}` paths. Implement the `UrlScheme` trait to use custom patterns:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use k_ap::{UrlScheme, DefaultUrlScheme};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
struct MyScheme;
|
||||||
|
|
||||||
|
impl UrlScheme for MyScheme {
|
||||||
|
fn actor_url(&self, base_url: &str, user_id: uuid::Uuid) -> anyhow::Result<Url> {
|
||||||
|
// e.g. https://example.com/@username instead of /users/{uuid}
|
||||||
|
todo!()
|
||||||
|
}
|
||||||
|
fn inbox_url(&self, actor_url: &Url) -> anyhow::Result<Url> { todo!() }
|
||||||
|
fn shared_inbox_url(&self, base_url: &str) -> Option<Url> { todo!() }
|
||||||
|
fn outbox_url(&self, actor_url: &Url) -> anyhow::Result<Url> { todo!() }
|
||||||
|
fn followers_url(&self, actor_url: &Url) -> anyhow::Result<Url> { todo!() }
|
||||||
|
fn following_url(&self, actor_url: &Url) -> anyhow::Result<Url> { todo!() }
|
||||||
|
fn activity_url(&self, base_url: &str) -> anyhow::Result<Url> { todo!() }
|
||||||
|
fn extract_user_id(&self, url: &Url) -> Option<uuid::Uuid> { todo!() }
|
||||||
|
}
|
||||||
|
|
||||||
|
let service = ActivityPubService::builder("https://example.com")
|
||||||
|
.url_scheme(Arc::new(MyScheme))
|
||||||
|
// ...repos...
|
||||||
|
.build()
|
||||||
|
.await?;
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
k-ap ships mock builders for all traits (not behind `#[cfg(test)]` so downstream crates can use them):
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use k_ap::testing::*;
|
||||||
|
|
||||||
|
let follow_repo = MockFollowRepoBuilder::new()
|
||||||
|
.on_add_follower(|id, url, status, _| Ok(()))
|
||||||
|
.on_count_accepted_followers(|_| Ok(42))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let actor_repo = MockActorRepoBuilder::new().build();
|
||||||
|
let blocklist_repo = MockBlocklistRepoBuilder::new().build();
|
||||||
|
let activity_repo = MockActivityRepoBuilder::new().build();
|
||||||
|
let user_repo = MockUserRepoBuilder::new().build();
|
||||||
|
let content_reader = MockContentReaderBuilder::new().build();
|
||||||
|
let object_handler = MockObjectHandlerBuilder::new().build();
|
||||||
|
let event_publisher = MockEventPublisherBuilder::new().build();
|
||||||
|
```
|
||||||
|
|
||||||
|
Every unset method defaults to `Ok(Default::default())`.
|
||||||
|
|
||||||
|
## Local development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make check # fmt --check + clippy -D warnings + tests (use before committing)
|
||||||
|
make fmt # apply rustfmt
|
||||||
|
make fix # fmt + clippy --fix
|
||||||
|
```
|
||||||
|
|||||||
@@ -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),
|
|
||||||
}
|
|
||||||
81
src/activities/accept.rs
Normal file
81
src/activities/accept.rs
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
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(
|
||||||
|
"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 = data
|
||||||
|
.url_scheme
|
||||||
|
.extract_user_id(self.object.actor.inner())
|
||||||
|
.ok_or_else(|| Error::bad_request("invalid actor URL in Follow"))?;
|
||||||
|
let remote_actor_url = self.actor.inner().as_str().to_string();
|
||||||
|
data.follow_repo
|
||||||
|
.update_following_status(local_user_id, &remote_actor_url, FollowingStatus::Accepted)
|
||||||
|
.await?;
|
||||||
|
tracing::info!(remote_actor = %remote_actor_url, "follow accepted by remote");
|
||||||
|
|
||||||
|
if let Some(publisher) = &data.event_publisher {
|
||||||
|
let outbox_url = data
|
||||||
|
.actor_repo
|
||||||
|
.get_remote_actor(&remote_actor_url)
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
.and_then(|actor| actor.outbox_url);
|
||||||
|
if let Err(error) = publisher
|
||||||
|
.publish(crate::data::FederationEvent::OutboundFollowAccepted {
|
||||||
|
local_user_id,
|
||||||
|
remote_actor_url,
|
||||||
|
outbox_url,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
tracing::warn!(%error, "failed to publish OutboundFollowAccepted event");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
57
src/activities/add.rs
Normal file
57
src/activities/add.rs
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
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, extract_object_ap_id, verify_attributed_to};
|
||||||
|
|
||||||
|
#[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> {
|
||||||
|
verify_attributed_to(&self.object, self.actor.inner(), "Add")
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = extract_object_ap_id(&self.object, &self.id);
|
||||||
|
let actor_url = self.actor.inner().clone();
|
||||||
|
data.object_handler
|
||||||
|
.on_create(&ap_id, &actor_url, self.object)
|
||||||
|
.await?;
|
||||||
|
tracing::info!(actor = %actor_url, "received Add activity");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
81
src/activities/announce.rs
Normal file
81
src/activities/announce.rs
Normal 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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
76
src/activities/block.rs
Normal file
76
src/activities/block.rs
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
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) = data.url_scheme.extract_user_id(&self.object) {
|
||||||
|
if let Err(error) = data
|
||||||
|
.follow_repo
|
||||||
|
.remove_following(local_user_id, actor_url)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
tracing::debug!(%error, "following already removed");
|
||||||
|
}
|
||||||
|
if let Err(error) = data
|
||||||
|
.follow_repo
|
||||||
|
.remove_follower(local_user_id, actor_url)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
tracing::debug!(%error, "follower already removed");
|
||||||
|
}
|
||||||
|
if let Err(error) = data
|
||||||
|
.blocklist_repo
|
||||||
|
.add_blocked_actor(local_user_id, actor_url)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
tracing::warn!(%error, "failed to record block");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tracing::info!(actor = %actor_url, "received block — removed relationships, recorded in blocklist");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
62
src/activities/create.rs
Normal file
62
src/activities/create.rs
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
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, extract_object_ap_id, verify_attributed_to,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[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> {
|
||||||
|
verify_attributed_to(&self.object, self.actor.inner(), "Create")
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = extract_object_ap_id(&self.object, &self.id);
|
||||||
|
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?;
|
||||||
|
tracing::info!(actor = %actor_url, "received create activity");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
91
src/activities/delete.rs
Normal file
91
src/activities/delete.rs
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
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(
|
||||||
|
"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?;
|
||||||
|
tracing::info!(actor = %actor_url, "received Delete(actor) — remote account deleted");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
data.object_handler
|
||||||
|
.on_delete(&object_url, &actor_url)
|
||||||
|
.await?;
|
||||||
|
tracing::info!(object = %object_url, "received Delete(note)");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
94
src/activities/follow.rs
Normal file
94
src/activities/follow.rs
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
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("invalid follow target URL"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if target_domain == data.domain {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
if let Some(uuid) = data.url_scheme.extract_user_id(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("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) = data.url_scheme.extract_user_id(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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
134
src/activities/helpers.rs
Normal file
134
src/activities/helpers.rs
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
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> {
|
||||||
|
let domain = actor.host_str().unwrap_or("");
|
||||||
|
tracing::info!(activity_id = %id, source_domain = domain, "processing inbound activity");
|
||||||
|
if already_processed(id, data).await {
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
if data.blocklist_repo.is_domain_blocked(domain).await? {
|
||||||
|
tracing::info!(actor = %actor, "ignoring activity from blocked domain");
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
Ok(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn verify_attributed_to(
|
||||||
|
object: &serde_json::Value,
|
||||||
|
actor: &Url,
|
||||||
|
activity_name: &str,
|
||||||
|
) -> Result<(), Error> {
|
||||||
|
let attributed_to = object.get("attributedTo").ok_or_else(|| {
|
||||||
|
Error::bad_request(format!("{activity_name} object missing attributedTo"))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let actor_urls: Vec<&str> = if let Some(url_str) = attributed_to.as_str() {
|
||||||
|
vec![url_str]
|
||||||
|
} else if let Some(array) = attributed_to.as_array() {
|
||||||
|
array
|
||||||
|
.iter()
|
||||||
|
.filter_map(|entry| {
|
||||||
|
entry
|
||||||
|
.as_str()
|
||||||
|
.or_else(|| entry.get("id").and_then(|id| id.as_str()))
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
} else {
|
||||||
|
return Err(Error::bad_request(format!(
|
||||||
|
"{activity_name} object has invalid attributedTo",
|
||||||
|
)));
|
||||||
|
};
|
||||||
|
|
||||||
|
let matches_actor = actor_urls
|
||||||
|
.iter()
|
||||||
|
.any(|url_str| Url::parse(url_str).as_ref() == Ok(actor));
|
||||||
|
|
||||||
|
if !matches_actor {
|
||||||
|
return Err(Error::bad_request(format!(
|
||||||
|
"{activity_name} actor does not match object attributedTo",
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn extract_object_ap_id(object: &serde_json::Value, fallback: &Url) -> Url {
|
||||||
|
object
|
||||||
|
.get("id")
|
||||||
|
.and_then(|value| value.as_str())
|
||||||
|
.and_then(|id_str| Url::parse(id_str).ok())
|
||||||
|
.unwrap_or_else(|| fallback.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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(|tags| tags.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) = data.url_scheme.extract_user_id(&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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
64
src/activities/like.rs
Normal file
64
src/activities/like.rs
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
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?;
|
||||||
|
tracing::info!(actor = %self.actor.inner(), object = %self.object, "received like");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
60
src/activities/mod.rs
Normal file
60
src/activities/mod.rs
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
mod accept;
|
||||||
|
mod add;
|
||||||
|
pub(crate) mod announce;
|
||||||
|
pub(crate) mod block;
|
||||||
|
mod create;
|
||||||
|
mod delete;
|
||||||
|
mod follow;
|
||||||
|
pub(crate) mod helpers;
|
||||||
|
pub(crate) mod like;
|
||||||
|
mod move_act;
|
||||||
|
mod reject;
|
||||||
|
mod undo;
|
||||||
|
mod update;
|
||||||
|
|
||||||
|
pub use accept::AcceptActivity;
|
||||||
|
pub use add::AddActivity;
|
||||||
|
pub use announce::AnnounceActivity;
|
||||||
|
pub use block::BlockActivity;
|
||||||
|
pub use create::CreateActivity;
|
||||||
|
pub use delete::DeleteActivity;
|
||||||
|
pub use follow::FollowActivity;
|
||||||
|
pub use like::LikeActivity;
|
||||||
|
pub use move_act::MoveActivity;
|
||||||
|
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
141
src/activities/move_act.rs
Normal 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("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?;
|
||||||
|
let old_url = self.object.as_str();
|
||||||
|
if !target.also_known_as.iter().any(|a| a == old_url) {
|
||||||
|
return Err(Error::bad_request(
|
||||||
|
"Move target alsoKnownAs does not reference old actor",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let affected = data
|
||||||
|
.follow_repo
|
||||||
|
.migrate_follower_actor(old_url, self.target.as_str())
|
||||||
|
.await?;
|
||||||
|
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 {
|
||||||
|
if let Err(e) = send_refollow(
|
||||||
|
*local_user_id,
|
||||||
|
&target_url,
|
||||||
|
&target_inbox,
|
||||||
|
&base_url,
|
||||||
|
&data_clone,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
tracing::warn!(
|
||||||
|
error = %e,
|
||||||
|
%local_user_id,
|
||||||
|
"Move: re-follow failed"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
actor = %self.actor.inner(),
|
||||||
|
target = %self.target,
|
||||||
|
affected = affected_count,
|
||||||
|
"received Move — migrated follower relationships, re-follows spawned"
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn send_refollow(
|
||||||
|
local_user_id: uuid::Uuid,
|
||||||
|
new_target_url: &Url,
|
||||||
|
new_target_inbox: &Url,
|
||||||
|
base_url: &str,
|
||||||
|
data: &Data<FederationData>,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let local_actor = crate::actors::get_local_actor(local_user_id, data).await?;
|
||||||
|
let follow_id = data.url_scheme.activity_url(base_url)?;
|
||||||
|
|
||||||
|
let follow = FollowActivity {
|
||||||
|
id: follow_id,
|
||||||
|
kind: Default::default(),
|
||||||
|
actor: ObjectId::from(local_actor.ap_id.clone()),
|
||||||
|
object: ObjectId::from(new_target_url.clone()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let sends = SendActivityTask::prepare(
|
||||||
|
&WithContext::new_default(follow),
|
||||||
|
&local_actor,
|
||||||
|
vec![new_target_inbox.clone()],
|
||||||
|
data,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
for send in sends {
|
||||||
|
if let Err(e) = send.sign_and_send(data).await {
|
||||||
|
tracing::warn!(
|
||||||
|
error = %e,
|
||||||
|
%local_user_id,
|
||||||
|
"Move: re-follow delivery failed"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
57
src/activities/reject.rs
Normal file
57
src/activities/reject.rs
Normal 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(
|
||||||
|
"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) = data.url_scheme.extract_user_id(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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
181
src/activities/undo.rs
Normal file
181
src/activities/undo.rs
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
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(
|
||||||
|
"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(|type_value| type_value.as_str())
|
||||||
|
.unwrap_or("");
|
||||||
|
match obj_type {
|
||||||
|
"Follow" => handle_undo_follow(self.actor.inner(), &self.object, data).await?,
|
||||||
|
"Add" => handle_undo_add(self.actor.inner(), &self.object, data).await?,
|
||||||
|
"Like" => handle_undo_like(self.actor.inner(), &self.object, data).await?,
|
||||||
|
"Announce" => handle_undo_announce(self.actor.inner(), &self.object, data).await?,
|
||||||
|
"Block" => handle_undo_block(self.actor.inner(), &self.object, data).await?,
|
||||||
|
other => {
|
||||||
|
tracing::debug!(kind = %other, "ignoring Undo of unknown activity type");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_undo_follow(
|
||||||
|
actor: &Url,
|
||||||
|
object: &serde_json::Value,
|
||||||
|
data: &Data<FederationData>,
|
||||||
|
) -> Result<(), Error> {
|
||||||
|
if let Some(obj_url) = object.get("object").and_then(|inner| inner.as_str())
|
||||||
|
&& let Ok(url) = Url::parse(obj_url)
|
||||||
|
&& let Some(user_id) = data.url_scheme.extract_user_id(&url)
|
||||||
|
{
|
||||||
|
data.follow_repo
|
||||||
|
.remove_follower(user_id, actor.as_str())
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
data.object_handler.on_actor_removed(actor).await?;
|
||||||
|
tracing::info!(actor = %actor, "unfollowed");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_undo_add(
|
||||||
|
actor: &Url,
|
||||||
|
object: &serde_json::Value,
|
||||||
|
data: &Data<FederationData>,
|
||||||
|
) -> Result<(), Error> {
|
||||||
|
let ap_id_str = object
|
||||||
|
.get("object")
|
||||||
|
.and_then(|inner| inner.get("id"))
|
||||||
|
.and_then(|id| id.as_str())
|
||||||
|
.or_else(|| 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, actor).await?;
|
||||||
|
tracing::info!(ap_id = %ap_id_str, "undo Add (watchlist remove)");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_undo_like(
|
||||||
|
actor: &Url,
|
||||||
|
object: &serde_json::Value,
|
||||||
|
data: &Data<FederationData>,
|
||||||
|
) -> Result<(), Error> {
|
||||||
|
if let Some(obj_url_str) = object.get("object").and_then(|inner| inner.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, actor)
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|e| tracing::warn!(error = %e, "failed to process unlike"));
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::info!(actor = %actor, "received Undo(Like)");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_undo_announce(
|
||||||
|
actor: &Url,
|
||||||
|
object: &serde_json::Value,
|
||||||
|
data: &Data<FederationData>,
|
||||||
|
) -> Result<(), Error> {
|
||||||
|
// Remove the boost record so announce counts stay accurate.
|
||||||
|
let activity_id = object.get("id").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
|
let object_url_str = 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, actor.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, actor)
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|e| {
|
||||||
|
tracing::warn!(error = %e, "failed to process Undo(Announce)");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::info!(actor = %actor, "received Undo(Announce)");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_undo_block(
|
||||||
|
actor: &Url,
|
||||||
|
object: &serde_json::Value,
|
||||||
|
data: &Data<FederationData>,
|
||||||
|
) -> Result<(), Error> {
|
||||||
|
if let Some(obj_url) = object.get("object").and_then(|inner| inner.as_str())
|
||||||
|
&& let Ok(url) = Url::parse(obj_url)
|
||||||
|
&& let Some(user_id) = data.url_scheme.extract_user_id(&url)
|
||||||
|
&& let Err(error) = data
|
||||||
|
.blocklist_repo
|
||||||
|
.remove_blocked_actor(user_id, actor.as_str())
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
tracing::debug!(%error, "block record already removed");
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
actor = %actor,
|
||||||
|
"received Undo(Block) — removed from blocklist"
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
58
src/activities/update.rs
Normal file
58
src/activities/update.rs
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
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, extract_object_ap_id, verify_attributed_to,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[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> {
|
||||||
|
verify_attributed_to(&self.object, self.actor.inner(), "Update")
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = extract_object_ap_id(&self.object, &self.id);
|
||||||
|
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?;
|
||||||
|
tracing::info!(actor = %actor_url, "received update activity");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
use activitypub_federation::{
|
|
||||||
axum::json::FederationJson, config::Data, protocol::context::WithContext, traits::Object,
|
|
||||||
};
|
|
||||||
use axum::extract::Path;
|
|
||||||
|
|
||||||
use crate::actors::{Person, get_local_actor};
|
|
||||||
use crate::data::FederationData;
|
|
||||||
use crate::error::Error;
|
|
||||||
|
|
||||||
pub async fn actor_handler(
|
|
||||||
Path(username): Path<String>,
|
|
||||||
data: Data<FederationData>,
|
|
||||||
) -> Result<FederationJson<WithContext<Person>>, Error> {
|
|
||||||
let ap_user = data
|
|
||||||
.user_repo
|
|
||||||
.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 person = db_actor.into_json(&data).await?;
|
|
||||||
|
|
||||||
Ok(FederationJson(WithContext::new_default(person)))
|
|
||||||
}
|
|
||||||
410
src/actors.rs
410
src/actors.rs
@@ -1,410 +0,0 @@
|
|||||||
use activitypub_federation::{
|
|
||||||
config::Data,
|
|
||||||
fetch::object_id::ObjectId,
|
|
||||||
http_signatures::generate_actor_keypair,
|
|
||||||
protocol::{public_key::PublicKey, verification::verify_domains_match},
|
|
||||||
traits::{Actor, Object},
|
|
||||||
};
|
|
||||||
use chrono::{DateTime, Utc};
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use url::Url;
|
|
||||||
|
|
||||||
use crate::data::FederationData;
|
|
||||||
use crate::error::Error;
|
|
||||||
use crate::repository::RemoteActor;
|
|
||||||
use crate::user::ApProfileField;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct DbActor {
|
|
||||||
pub user_id: uuid::Uuid,
|
|
||||||
pub username: String,
|
|
||||||
pub display_name: Option<String>,
|
|
||||||
pub public_key_pem: String,
|
|
||||||
pub private_key_pem: Option<String>,
|
|
||||||
pub inbox_url: Url,
|
|
||||||
pub shared_inbox_url: Option<Url>,
|
|
||||||
pub outbox_url: Url,
|
|
||||||
pub followers_url: Url,
|
|
||||||
pub following_url: Url,
|
|
||||||
pub ap_id: Url,
|
|
||||||
pub last_refreshed_at: DateTime<Utc>,
|
|
||||||
pub bio: Option<String>,
|
|
||||||
pub avatar_url: Option<Url>,
|
|
||||||
pub banner_url: Option<Url>,
|
|
||||||
pub also_known_as: Option<String>,
|
|
||||||
pub profile_url: Option<Url>,
|
|
||||||
pub attachment: Vec<ApProfileField>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
|
||||||
pub struct ApImageObject {
|
|
||||||
#[serde(rename = "type")]
|
|
||||||
pub kind: String,
|
|
||||||
pub url: Url,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct Endpoints {
|
|
||||||
pub shared_inbox: Url,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct ProfileFieldObject {
|
|
||||||
#[serde(rename = "type")]
|
|
||||||
pub kind: String,
|
|
||||||
pub name: String,
|
|
||||||
pub value: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Accepts any AP actor type on inbound JSON; always serializes as "Person" for local actors.
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub enum ApActorType {
|
|
||||||
Person,
|
|
||||||
Service,
|
|
||||||
Application,
|
|
||||||
Organization,
|
|
||||||
Group,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for ApActorType {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::Person
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct Person {
|
|
||||||
#[serde(rename = "type")]
|
|
||||||
kind: ApActorType,
|
|
||||||
id: ObjectId<DbActor>,
|
|
||||||
#[serde(default)]
|
|
||||||
preferred_username: String,
|
|
||||||
inbox: Url,
|
|
||||||
#[serde(default)]
|
|
||||||
outbox: Option<Url>,
|
|
||||||
#[serde(default)]
|
|
||||||
followers: Option<Url>,
|
|
||||||
#[serde(default)]
|
|
||||||
following: Option<Url>,
|
|
||||||
pub public_key: PublicKey,
|
|
||||||
#[serde(default)]
|
|
||||||
name: Option<String>,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
summary: Option<String>,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
icon: Option<ApImageObject>,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
url: Option<Url>,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
discoverable: Option<bool>,
|
|
||||||
#[serde(default)]
|
|
||||||
manually_approves_followers: bool,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none", default)]
|
|
||||||
updated: Option<DateTime<Utc>>,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
endpoints: Option<Endpoints>,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
image: Option<ApImageObject>,
|
|
||||||
#[serde(rename = "alsoKnownAs", skip_serializing_if = "Vec::is_empty", default)]
|
|
||||||
also_known_as: Vec<String>,
|
|
||||||
#[serde(skip_serializing_if = "Vec::is_empty", default)]
|
|
||||||
attachment: Vec<ProfileFieldObject>,
|
|
||||||
}
|
|
||||||
|
|
||||||
struct ActorUrls {
|
|
||||||
ap_id: Url,
|
|
||||||
inbox_url: Url,
|
|
||||||
shared_inbox_url: Option<Url>,
|
|
||||||
outbox_url: Url,
|
|
||||||
followers_url: Url,
|
|
||||||
following_url: Url,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ActorUrls {
|
|
||||||
fn build(base_url: &str, user_id: uuid::Uuid) -> Self {
|
|
||||||
let ap_id = crate::urls::actor_url(base_url, user_id);
|
|
||||||
Self {
|
|
||||||
inbox_url: Url::parse(&format!("{}/inbox", &ap_id)).expect("valid url"),
|
|
||||||
shared_inbox_url: Url::parse(&format!("{}/inbox", base_url)).ok(),
|
|
||||||
outbox_url: Url::parse(&format!("{}/outbox", &ap_id)).expect("valid url"),
|
|
||||||
followers_url: Url::parse(&format!("{}/followers", &ap_id)).expect("valid url"),
|
|
||||||
following_url: Url::parse(&format!("{}/following", &ap_id)).expect("valid url"),
|
|
||||||
ap_id,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn get_local_actor(
|
|
||||||
user_id: uuid::Uuid,
|
|
||||||
data: &Data<FederationData>,
|
|
||||||
) -> Result<DbActor, Error> {
|
|
||||||
let user = data
|
|
||||||
.user_repo
|
|
||||||
.find_by_id(user_id)
|
|
||||||
.await
|
|
||||||
.map_err(Error::from)?
|
|
||||||
.ok_or_else(|| Error::not_found(anyhow::anyhow!("user not found: {}", user_id)))?;
|
|
||||||
|
|
||||||
let (public_key, private_key) = match data
|
|
||||||
.federation_repo
|
|
||||||
.get_local_actor_keypair(user_id)
|
|
||||||
.await?
|
|
||||||
{
|
|
||||||
Some(kp) => kp,
|
|
||||||
None => {
|
|
||||||
let kp = generate_actor_keypair()?;
|
|
||||||
data.federation_repo
|
|
||||||
.save_local_actor_keypair(user_id, kp.public_key.clone(), kp.private_key.clone())
|
|
||||||
.await?;
|
|
||||||
(kp.public_key, kp.private_key)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let ActorUrls {
|
|
||||||
ap_id,
|
|
||||||
inbox_url,
|
|
||||||
shared_inbox_url,
|
|
||||||
outbox_url,
|
|
||||||
followers_url,
|
|
||||||
following_url,
|
|
||||||
} = ActorUrls::build(&data.base_url, user_id);
|
|
||||||
|
|
||||||
Ok(DbActor {
|
|
||||||
user_id,
|
|
||||||
username: user.username,
|
|
||||||
display_name: None,
|
|
||||||
public_key_pem: public_key,
|
|
||||||
private_key_pem: Some(private_key),
|
|
||||||
inbox_url,
|
|
||||||
shared_inbox_url,
|
|
||||||
outbox_url,
|
|
||||||
followers_url,
|
|
||||||
following_url,
|
|
||||||
ap_id,
|
|
||||||
last_refreshed_at: Utc::now(),
|
|
||||||
bio: user.bio,
|
|
||||||
avatar_url: user.avatar_url,
|
|
||||||
banner_url: user.banner_url,
|
|
||||||
also_known_as: user.also_known_as,
|
|
||||||
profile_url: user.profile_url,
|
|
||||||
attachment: user.attachment,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
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]
|
|
||||||
impl Object for DbActor {
|
|
||||||
type DataType = FederationData;
|
|
||||||
type Kind = Person;
|
|
||||||
type Error = Error;
|
|
||||||
|
|
||||||
fn id(&self) -> &Url {
|
|
||||||
&self.ap_id
|
|
||||||
}
|
|
||||||
|
|
||||||
fn last_refreshed_at(&self) -> Option<DateTime<Utc>> {
|
|
||||||
Some(self.last_refreshed_at)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn read_from_id(
|
|
||||||
object_id: Url,
|
|
||||||
data: &Data<Self::DataType>,
|
|
||||||
) -> Result<Option<Self>, Self::Error> {
|
|
||||||
let user_id = match crate::urls::extract_user_id_from_url(&object_id) {
|
|
||||||
Some(id) => id,
|
|
||||||
None => return Ok(None),
|
|
||||||
};
|
|
||||||
let user = match data.user_repo.find_by_id(user_id).await {
|
|
||||||
Ok(Some(u)) => u,
|
|
||||||
_ => return Ok(None),
|
|
||||||
};
|
|
||||||
|
|
||||||
let keypair = data
|
|
||||||
.federation_repo
|
|
||||||
.get_local_actor_keypair(user_id)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
let (public_key, private_key) = match keypair {
|
|
||||||
Some(kp) => (kp.0, Some(kp.1)),
|
|
||||||
None => return Ok(None),
|
|
||||||
};
|
|
||||||
|
|
||||||
let ActorUrls {
|
|
||||||
ap_id,
|
|
||||||
inbox_url,
|
|
||||||
shared_inbox_url,
|
|
||||||
outbox_url,
|
|
||||||
followers_url,
|
|
||||||
following_url,
|
|
||||||
} = ActorUrls::build(&data.base_url, user_id);
|
|
||||||
|
|
||||||
Ok(Some(DbActor {
|
|
||||||
user_id,
|
|
||||||
username: user.username,
|
|
||||||
display_name: None,
|
|
||||||
public_key_pem: public_key,
|
|
||||||
private_key_pem: private_key,
|
|
||||||
inbox_url,
|
|
||||||
shared_inbox_url,
|
|
||||||
outbox_url,
|
|
||||||
followers_url,
|
|
||||||
following_url,
|
|
||||||
ap_id,
|
|
||||||
last_refreshed_at: Utc::now(),
|
|
||||||
bio: user.bio,
|
|
||||||
avatar_url: user.avatar_url,
|
|
||||||
banner_url: user.banner_url,
|
|
||||||
also_known_as: user.also_known_as,
|
|
||||||
profile_url: user.profile_url,
|
|
||||||
attachment: user.attachment,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn into_json(self, data: &Data<Self::DataType>) -> Result<Self::Kind, Self::Error> {
|
|
||||||
let public_key = PublicKey {
|
|
||||||
id: format!("{}#main-key", &self.ap_id),
|
|
||||||
owner: self.ap_id.clone(),
|
|
||||||
public_key_pem: self.public_key_pem.clone(),
|
|
||||||
};
|
|
||||||
|
|
||||||
let icon = self.avatar_url.map(|url| ApImageObject {
|
|
||||||
kind: "Image".to_string(),
|
|
||||||
url,
|
|
||||||
});
|
|
||||||
let image = self.banner_url.map(|url| ApImageObject {
|
|
||||||
kind: "Image".to_string(),
|
|
||||||
url,
|
|
||||||
});
|
|
||||||
let profile_url = self.profile_url;
|
|
||||||
let also_known_as: Vec<String> = self.also_known_as.into_iter().collect();
|
|
||||||
let attachment: Vec<ProfileFieldObject> = self
|
|
||||||
.attachment
|
|
||||||
.into_iter()
|
|
||||||
.map(|f| ProfileFieldObject {
|
|
||||||
kind: "PropertyValue".to_string(),
|
|
||||||
name: f.name,
|
|
||||||
value: f.value,
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let shared_inbox =
|
|
||||||
Url::parse(&format!("{}/inbox", data.base_url)).expect("base_url is always valid");
|
|
||||||
|
|
||||||
Ok(Person {
|
|
||||||
kind: Default::default(),
|
|
||||||
id: self.ap_id.clone().into(),
|
|
||||||
preferred_username: self.username.clone(),
|
|
||||||
inbox: self.inbox_url.clone(),
|
|
||||||
outbox: Some(self.outbox_url.clone()),
|
|
||||||
followers: Some(self.followers_url.clone()),
|
|
||||||
following: Some(self.following_url.clone()),
|
|
||||||
public_key,
|
|
||||||
name: Some(self.username.clone()),
|
|
||||||
summary: self.bio.clone(),
|
|
||||||
icon,
|
|
||||||
url: profile_url,
|
|
||||||
discoverable: Some(true),
|
|
||||||
manually_approves_followers: true,
|
|
||||||
updated: Some(self.last_refreshed_at),
|
|
||||||
endpoints: Some(Endpoints { shared_inbox }),
|
|
||||||
image,
|
|
||||||
also_known_as,
|
|
||||||
attachment,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn verify(
|
|
||||||
json: &Self::Kind,
|
|
||||||
expected_domain: &Url,
|
|
||||||
_data: &Data<Self::DataType>,
|
|
||||||
) -> Result<(), Self::Error> {
|
|
||||||
if verify_domains_match(json.id.inner(), expected_domain).is_ok() {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
if apex_domain(json.id.inner()) == apex_domain(expected_domain) {
|
|
||||||
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> {
|
|
||||||
let shared_inbox_url = json.endpoints.as_ref().map(|e| e.shared_inbox.to_string());
|
|
||||||
let actor = RemoteActor {
|
|
||||||
url: json.id.inner().to_string(),
|
|
||||||
handle: json.preferred_username.clone(),
|
|
||||||
inbox_url: json.inbox.to_string(),
|
|
||||||
shared_inbox_url,
|
|
||||||
display_name: json.name.clone(),
|
|
||||||
avatar_url: json.icon.as_ref().map(|i| i.url.to_string()),
|
|
||||||
outbox_url: json.outbox.as_ref().map(|u| u.to_string()),
|
|
||||||
};
|
|
||||||
data.federation_repo.upsert_remote_actor(actor).await?;
|
|
||||||
|
|
||||||
let url_str = json.id.inner().to_string();
|
|
||||||
let user_id = uuid::Uuid::new_v5(&uuid::Uuid::NAMESPACE_URL, url_str.as_bytes());
|
|
||||||
let ap_id = json.id.inner().clone();
|
|
||||||
let inbox_url = json.inbox.clone();
|
|
||||||
let shared_inbox_url = json
|
|
||||||
.endpoints
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|e| Url::parse(e.shared_inbox.as_str()).ok());
|
|
||||||
let fallback = |suffix: &str| {
|
|
||||||
Url::parse(&format!("{}{}", ap_id, suffix)).unwrap_or_else(|_| ap_id.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 {
|
|
||||||
user_id,
|
|
||||||
username: json.preferred_username.clone(),
|
|
||||||
display_name: json.name.clone(),
|
|
||||||
public_key_pem: json.public_key.public_key_pem,
|
|
||||||
private_key_pem: None,
|
|
||||||
inbox_url,
|
|
||||||
shared_inbox_url,
|
|
||||||
outbox_url,
|
|
||||||
followers_url,
|
|
||||||
following_url,
|
|
||||||
ap_id,
|
|
||||||
last_refreshed_at: Utc::now(),
|
|
||||||
bio: json.summary.clone(),
|
|
||||||
avatar_url: json.icon.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(),
|
|
||||||
profile_url: json.url.clone(),
|
|
||||||
attachment: json
|
|
||||||
.attachment
|
|
||||||
.iter()
|
|
||||||
.map(|f| crate::user::ApProfileField {
|
|
||||||
name: f.name.clone(),
|
|
||||||
value: f.value.clone(),
|
|
||||||
})
|
|
||||||
.collect(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Actor for DbActor {
|
|
||||||
fn public_key_pem(&self) -> &str {
|
|
||||||
&self.public_key_pem
|
|
||||||
}
|
|
||||||
|
|
||||||
fn private_key_pem(&self) -> Option<String> {
|
|
||||||
self.private_key_pem.clone()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn inbox(&self) -> Url {
|
|
||||||
self.inbox_url.clone()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
#[path = "tests/actors.rs"]
|
|
||||||
mod tests;
|
|
||||||
121
src/actors/mod.rs
Normal file
121
src/actors/mod.rs
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
mod person;
|
||||||
|
mod types;
|
||||||
|
|
||||||
|
pub use types::{DbActor, Person};
|
||||||
|
|
||||||
|
use activitypub_federation::{
|
||||||
|
config::Data, http_signatures::generate_actor_keypair, traits::Actor,
|
||||||
|
};
|
||||||
|
use chrono::Utc;
|
||||||
|
use url::Url;
|
||||||
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
|
use crate::data::FederationData;
|
||||||
|
use crate::error::Error;
|
||||||
|
|
||||||
|
use types::ActorUrls;
|
||||||
|
|
||||||
|
pub async fn get_local_actor(
|
||||||
|
user_id: uuid::Uuid,
|
||||||
|
data: &Data<FederationData>,
|
||||||
|
) -> Result<DbActor, Error> {
|
||||||
|
build_local_actor(
|
||||||
|
user_id,
|
||||||
|
&data.base_url,
|
||||||
|
data.user_repo.as_ref(),
|
||||||
|
data.actor_repo.as_ref(),
|
||||||
|
data.url_scheme.as_ref(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|error| Error::not_found(error.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a local actor's `DbActor` from repository data. Generates a keypair
|
||||||
|
/// if one doesn't exist yet. Usable outside of a `FederationData` context
|
||||||
|
/// (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,
|
||||||
|
url_scheme: &dyn crate::url_scheme::UrlScheme,
|
||||||
|
) -> anyhow::Result<DbActor> {
|
||||||
|
let user = user_repo
|
||||||
|
.find_by_id(user_id)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("user not found: {}", user_id))?;
|
||||||
|
|
||||||
|
let keypair = match actor_repo.get_local_actor_keypair(user_id).await? {
|
||||||
|
Some(existing) => existing,
|
||||||
|
None => {
|
||||||
|
let generated = generate_actor_keypair()?;
|
||||||
|
let keypair = crate::repository::Keypair {
|
||||||
|
public_key: generated.public_key,
|
||||||
|
private_key: generated.private_key.clone(),
|
||||||
|
};
|
||||||
|
let private_zeroized = Zeroizing::new(generated.private_key);
|
||||||
|
actor_repo
|
||||||
|
.save_local_actor_keypair(user_id, keypair.clone())
|
||||||
|
.await?;
|
||||||
|
drop(private_zeroized);
|
||||||
|
keypair
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let ActorUrls {
|
||||||
|
ap_id,
|
||||||
|
inbox_url,
|
||||||
|
shared_inbox_url,
|
||||||
|
outbox_url,
|
||||||
|
followers_url,
|
||||||
|
following_url,
|
||||||
|
} = ActorUrls::build(base_url, user_id, url_scheme)?;
|
||||||
|
|
||||||
|
Ok(DbActor {
|
||||||
|
user_id,
|
||||||
|
username: user.username,
|
||||||
|
display_name: user.display_name,
|
||||||
|
public_key_pem: keypair.public_key,
|
||||||
|
private_key_pem: Some(keypair.private_key),
|
||||||
|
inbox_url,
|
||||||
|
shared_inbox_url,
|
||||||
|
outbox_url,
|
||||||
|
followers_url,
|
||||||
|
following_url,
|
||||||
|
ap_id,
|
||||||
|
last_refreshed_at: Utc::now(),
|
||||||
|
bio: user.bio,
|
||||||
|
avatar_url: user.avatar_url,
|
||||||
|
banner_url: user.banner_url,
|
||||||
|
also_known_as: user.also_known_as,
|
||||||
|
profile_url: user.profile_url,
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Actor for DbActor {
|
||||||
|
fn public_key_pem(&self) -> &str {
|
||||||
|
&self.public_key_pem
|
||||||
|
}
|
||||||
|
|
||||||
|
fn private_key_pem(&self) -> Option<String> {
|
||||||
|
self.private_key_pem.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn inbox(&self) -> Url {
|
||||||
|
self.inbox_url.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "tests/actors.rs"]
|
||||||
|
mod tests;
|
||||||
197
src/actors/person.rs
Normal file
197
src/actors/person.rs
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
use activitypub_federation::{
|
||||||
|
config::Data,
|
||||||
|
protocol::{public_key::PublicKey, verification::verify_domains_match},
|
||||||
|
traits::Object,
|
||||||
|
};
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use url::Url;
|
||||||
|
|
||||||
|
use crate::data::FederationData;
|
||||||
|
use crate::error::Error;
|
||||||
|
use crate::repository::RemoteActor;
|
||||||
|
|
||||||
|
use super::types::{ApImageObject, DbActor, Endpoints, Person, ProfileFieldObject};
|
||||||
|
use super::{apex_domain, build_local_actor};
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl Object for DbActor {
|
||||||
|
type DataType = FederationData;
|
||||||
|
type Kind = Person;
|
||||||
|
type Error = Error;
|
||||||
|
|
||||||
|
fn id(&self) -> &Url {
|
||||||
|
&self.ap_id
|
||||||
|
}
|
||||||
|
|
||||||
|
fn last_refreshed_at(&self) -> Option<DateTime<Utc>> {
|
||||||
|
Some(self.last_refreshed_at)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn read_from_id(
|
||||||
|
object_id: Url,
|
||||||
|
data: &Data<Self::DataType>,
|
||||||
|
) -> Result<Option<Self>, Self::Error> {
|
||||||
|
let user_id = match data.url_scheme.extract_user_id(&object_id) {
|
||||||
|
Some(id) => id,
|
||||||
|
None => return Ok(None),
|
||||||
|
};
|
||||||
|
if data
|
||||||
|
.actor_repo
|
||||||
|
.get_local_actor_keypair(user_id)
|
||||||
|
.await?
|
||||||
|
.is_none()
|
||||||
|
{
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
match build_local_actor(
|
||||||
|
user_id,
|
||||||
|
&data.base_url,
|
||||||
|
data.user_repo.as_ref(),
|
||||||
|
data.actor_repo.as_ref(),
|
||||||
|
data.url_scheme.as_ref(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(actor) => Ok(Some(actor)),
|
||||||
|
Err(_) => Ok(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn into_json(self, data: &Data<Self::DataType>) -> Result<Self::Kind, Self::Error> {
|
||||||
|
let public_key = PublicKey {
|
||||||
|
id: format!("{}#main-key", self.ap_id),
|
||||||
|
owner: self.ap_id.clone(),
|
||||||
|
public_key_pem: self.public_key_pem.clone(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let icon = self.avatar_url.map(|url| ApImageObject {
|
||||||
|
kind: "Image".to_string(),
|
||||||
|
url,
|
||||||
|
});
|
||||||
|
let image = self.banner_url.map(|url| ApImageObject {
|
||||||
|
kind: "Image".to_string(),
|
||||||
|
url,
|
||||||
|
});
|
||||||
|
let also_known_as = self.also_known_as;
|
||||||
|
let attachment: Vec<ProfileFieldObject> = self
|
||||||
|
.attachment
|
||||||
|
.into_iter()
|
||||||
|
.map(|field| ProfileFieldObject {
|
||||||
|
kind: "PropertyValue".to_string(),
|
||||||
|
name: field.name,
|
||||||
|
value: field.value,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let shared_inbox = data
|
||||||
|
.url_scheme
|
||||||
|
.shared_inbox_url(&data.base_url)
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("invalid base_url for shared inbox"))?;
|
||||||
|
|
||||||
|
Ok(Person {
|
||||||
|
kind: self.actor_type,
|
||||||
|
id: self.ap_id.clone().into(),
|
||||||
|
preferred_username: self.username.clone(),
|
||||||
|
inbox: self.inbox_url.clone(),
|
||||||
|
outbox: Some(self.outbox_url.clone()),
|
||||||
|
followers: Some(self.followers_url.clone()),
|
||||||
|
following: Some(self.following_url.clone()),
|
||||||
|
public_key,
|
||||||
|
name: self.display_name.or_else(|| Some(self.username.clone())),
|
||||||
|
summary: self.bio.clone(),
|
||||||
|
icon,
|
||||||
|
url: self.profile_url,
|
||||||
|
discoverable: Some(self.discoverable),
|
||||||
|
manually_approves_followers: self.manually_approves_followers,
|
||||||
|
updated: Some(self.last_refreshed_at),
|
||||||
|
endpoints: Some(Endpoints { shared_inbox }),
|
||||||
|
image,
|
||||||
|
also_known_as,
|
||||||
|
attachment,
|
||||||
|
featured: self.featured_url,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn verify(
|
||||||
|
json: &Self::Kind,
|
||||||
|
expected_domain: &Url,
|
||||||
|
_data: &Data<Self::DataType>,
|
||||||
|
) -> Result<(), Self::Error> {
|
||||||
|
if verify_domains_match(json.id.inner(), expected_domain).is_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> {
|
||||||
|
tracing::debug!(
|
||||||
|
actor_id = %json.id.inner(),
|
||||||
|
username = %json.preferred_username,
|
||||||
|
"ingesting remote actor"
|
||||||
|
);
|
||||||
|
|
||||||
|
let cached_actor = RemoteActor::from_ap_person(&json);
|
||||||
|
data.actor_repo.upsert_remote_actor(cached_actor).await?;
|
||||||
|
|
||||||
|
let url_str = json.id.inner().to_string();
|
||||||
|
let user_id = uuid::Uuid::new_v5(&uuid::Uuid::NAMESPACE_URL, url_str.as_bytes());
|
||||||
|
let ap_id = json.id.inner().clone();
|
||||||
|
let inbox_url = json.inbox.clone();
|
||||||
|
let shared_inbox_url = json
|
||||||
|
.endpoints
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|endpoints| Url::parse(endpoints.shared_inbox.as_str()).ok());
|
||||||
|
let fallback = |suffix: &str| {
|
||||||
|
Url::parse(&format!("{}{}", ap_id, suffix)).unwrap_or_else(|_| ap_id.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 {
|
||||||
|
user_id,
|
||||||
|
username: json.preferred_username.clone(),
|
||||||
|
display_name: json.name.clone(),
|
||||||
|
public_key_pem: json.public_key.public_key_pem,
|
||||||
|
private_key_pem: None,
|
||||||
|
inbox_url,
|
||||||
|
shared_inbox_url,
|
||||||
|
outbox_url,
|
||||||
|
followers_url,
|
||||||
|
following_url,
|
||||||
|
ap_id,
|
||||||
|
last_refreshed_at: Utc::now(),
|
||||||
|
bio: json.summary.clone(),
|
||||||
|
avatar_url: json.icon.as_ref().map(|icon| icon.url.clone()),
|
||||||
|
banner_url: json.image.as_ref().map(|image| image.url.clone()),
|
||||||
|
also_known_as: json.also_known_as,
|
||||||
|
profile_url: json.url.clone(),
|
||||||
|
attachment: json
|
||||||
|
.attachment
|
||||||
|
.iter()
|
||||||
|
.map(|field| crate::user::ApProfileField {
|
||||||
|
name: field.name.clone(),
|
||||||
|
value: field.value.clone(),
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
manually_approves_followers: json.manually_approves_followers,
|
||||||
|
discoverable: json.discoverable.unwrap_or(false),
|
||||||
|
actor_type: json.kind,
|
||||||
|
featured_url: json.featured,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
143
src/actors/tests/actors.rs
Normal file
143
src/actors/tests/actors.rs
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
use super::types::{ApImageObject, Endpoints};
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
// ── Person AP JSON serialization ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn person_serializes_with_enriched_fields() {
|
||||||
|
let 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: Some("https://example.com/users/1/outbox".parse().unwrap()),
|
||||||
|
followers: Some("https://example.com/users/1/followers".parse().unwrap()),
|
||||||
|
following: Some("https://example.com/users/1/following".parse().unwrap()),
|
||||||
|
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: Some("Alice".to_string()),
|
||||||
|
summary: Some("Bio text".to_string()),
|
||||||
|
icon: Some(ApImageObject {
|
||||||
|
kind: "Image".to_string(),
|
||||||
|
url: "https://example.com/images/avatars/1".parse().unwrap(),
|
||||||
|
}),
|
||||||
|
url: Some("https://example.com/u/alice".parse().unwrap()),
|
||||||
|
discoverable: Some(true),
|
||||||
|
manually_approves_followers: true,
|
||||||
|
updated: Some(chrono::Utc::now()),
|
||||||
|
endpoints: Some(Endpoints {
|
||||||
|
shared_inbox: "https://example.com/inbox".parse().unwrap(),
|
||||||
|
}),
|
||||||
|
image: None,
|
||||||
|
also_known_as: vec![],
|
||||||
|
attachment: vec![],
|
||||||
|
featured: Some("https://example.com/users/1/featured".parse().unwrap()),
|
||||||
|
};
|
||||||
|
let json = serde_json::to_value(&person).unwrap();
|
||||||
|
assert_eq!(json["discoverable"], true);
|
||||||
|
assert_eq!(json["summary"], "Bio text");
|
||||||
|
assert_eq!(json["icon"]["type"], "Image");
|
||||||
|
assert_eq!(json["manuallyApprovesFollowers"], true);
|
||||||
|
assert!(json.get("updated").is_some());
|
||||||
|
assert!(json.get("endpoints").is_some());
|
||||||
|
assert_eq!(
|
||||||
|
json["endpoints"]["sharedInbox"],
|
||||||
|
"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,
|
||||||
|
}
|
||||||
|
}
|
||||||
131
src/actors/types.rs
Normal file
131
src/actors/types.rs
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
use activitypub_federation::fetch::object_id::ObjectId;
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use url::Url;
|
||||||
|
|
||||||
|
use crate::url_scheme::UrlScheme;
|
||||||
|
use crate::user::{ApActorType, ApProfileField};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct DbActor {
|
||||||
|
pub user_id: uuid::Uuid,
|
||||||
|
pub username: String,
|
||||||
|
pub display_name: Option<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 inbox_url: Url,
|
||||||
|
pub shared_inbox_url: Option<Url>,
|
||||||
|
pub outbox_url: Url,
|
||||||
|
pub followers_url: Url,
|
||||||
|
pub following_url: Url,
|
||||||
|
pub ap_id: Url,
|
||||||
|
pub last_refreshed_at: DateTime<Utc>,
|
||||||
|
pub bio: Option<String>,
|
||||||
|
pub avatar_url: Option<Url>,
|
||||||
|
pub banner_url: Option<Url>,
|
||||||
|
pub also_known_as: Vec<String>,
|
||||||
|
pub profile_url: Option<Url>,
|
||||||
|
pub attachment: Vec<ApProfileField>,
|
||||||
|
pub manually_approves_followers: bool,
|
||||||
|
pub discoverable: bool,
|
||||||
|
pub actor_type: ApActorType,
|
||||||
|
pub featured_url: Option<Url>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DbActor {
|
||||||
|
pub fn object_id(&self) -> ObjectId<Self> {
|
||||||
|
ObjectId::from(self.ap_id.clone())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) struct ActorUrls {
|
||||||
|
pub(super) ap_id: Url,
|
||||||
|
pub(super) inbox_url: Url,
|
||||||
|
pub(super) shared_inbox_url: Option<Url>,
|
||||||
|
pub(super) outbox_url: Url,
|
||||||
|
pub(super) followers_url: Url,
|
||||||
|
pub(super) following_url: Url,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ActorUrls {
|
||||||
|
pub(super) fn build(
|
||||||
|
base_url: &str,
|
||||||
|
user_id: uuid::Uuid,
|
||||||
|
url_scheme: &dyn UrlScheme,
|
||||||
|
) -> anyhow::Result<Self> {
|
||||||
|
let ap_id = url_scheme.actor_url(base_url, user_id)?;
|
||||||
|
Ok(Self {
|
||||||
|
inbox_url: url_scheme.inbox_url(&ap_id)?,
|
||||||
|
shared_inbox_url: url_scheme.shared_inbox_url(base_url),
|
||||||
|
outbox_url: url_scheme.outbox_url(&ap_id)?,
|
||||||
|
followers_url: url_scheme.followers_url(&ap_id)?,
|
||||||
|
following_url: url_scheme.following_url(&ap_id)?,
|
||||||
|
ap_id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
|
pub struct ApImageObject {
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
pub kind: String,
|
||||||
|
pub url: Url,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct Endpoints {
|
||||||
|
pub shared_inbox: Url,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ProfileFieldObject {
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
pub kind: String,
|
||||||
|
pub name: String,
|
||||||
|
pub value: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct Person {
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
pub(crate) kind: ApActorType,
|
||||||
|
pub(crate) id: ObjectId<DbActor>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub(crate) preferred_username: String,
|
||||||
|
pub(crate) inbox: Url,
|
||||||
|
#[serde(default)]
|
||||||
|
pub(crate) outbox: Option<Url>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub(crate) followers: Option<Url>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub(crate) following: Option<Url>,
|
||||||
|
pub public_key: activitypub_federation::protocol::public_key::PublicKey,
|
||||||
|
#[serde(default)]
|
||||||
|
pub(crate) name: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub(crate) summary: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub(crate) icon: Option<ApImageObject>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub(crate) url: Option<Url>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub(crate) discoverable: Option<bool>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub(crate) manually_approves_followers: bool,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none", default)]
|
||||||
|
pub(crate) updated: Option<DateTime<Utc>>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub(crate) endpoints: Option<Endpoints>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub(crate) image: Option<ApImageObject>,
|
||||||
|
#[serde(rename = "alsoKnownAs", skip_serializing_if = "Vec::is_empty", default)]
|
||||||
|
pub(crate) also_known_as: Vec<String>,
|
||||||
|
#[serde(skip_serializing_if = "Vec::is_empty", default)]
|
||||||
|
pub(crate) attachment: Vec<ProfileFieldObject>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub(crate) featured: Option<Url>,
|
||||||
|
}
|
||||||
146
src/content.rs
146
src/content.rs
@@ -2,25 +2,69 @@ use async_trait::async_trait;
|
|||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use url::Url;
|
use url::Url;
|
||||||
|
|
||||||
#[async_trait]
|
#[derive(Debug, Clone)]
|
||||||
pub trait ApObjectHandler: Send + Sync {
|
pub struct LocalObject {
|
||||||
/// Returns (ap_id, serialized object) for all local content owned by this user.
|
pub ap_id: Url,
|
||||||
/// Used by outbox (count) and backfill (delivery). Must only return locally-authored content.
|
pub object: serde_json::Value,
|
||||||
async fn get_local_objects_for_user(
|
pub published_at: DateTime<Utc>,
|
||||||
&self,
|
pub to: Vec<String>,
|
||||||
user_id: uuid::Uuid,
|
pub cc: Vec<String>,
|
||||||
) -> anyhow::Result<Vec<(Url, serde_json::Value)>>;
|
pub bto: Vec<String>,
|
||||||
|
pub bcc: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
/// Returns up to `limit` objects ordered newest-first, published before `before`.
|
/// Read side — the library queries this when sending content outward.
|
||||||
/// Returns (ap_id, object_json, published_at).
|
/// Implement on the same struct as [`ApObjectHandler`] if you prefer a single
|
||||||
|
/// database type.
|
||||||
|
#[async_trait]
|
||||||
|
pub trait ApContentReader: Send + Sync {
|
||||||
|
/// Newest-first page of locally-authored objects for `user_id`, published
|
||||||
|
/// strictly before `before` (pass `None` for the first page).
|
||||||
|
///
|
||||||
|
/// Used by the outbox endpoint and by backfill when a new follower is
|
||||||
|
/// accepted. Implementations MUST:
|
||||||
|
/// - Return objects in descending `published_at` order.
|
||||||
|
/// - Exclude deleted and draft content.
|
||||||
|
/// - 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,
|
||||||
before: Option<DateTime<Utc>>,
|
before: Option<DateTime<Utc>>,
|
||||||
limit: usize,
|
limit: usize,
|
||||||
) -> anyhow::Result<Vec<(Url, serde_json::Value, DateTime<Utc>)>>;
|
) -> anyhow::Result<Vec<LocalObject>>;
|
||||||
|
|
||||||
/// 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 +72,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,26 +83,51 @@ 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::AnnounceRepository::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,
|
||||||
@@ -63,6 +135,26 @@ pub trait ApObjectHandler: Send + Sync {
|
|||||||
actor_url: &Url,
|
actor_url: &Url,
|
||||||
) -> anyhow::Result<()>;
|
) -> anyhow::Result<()>;
|
||||||
|
|
||||||
/// Total number of locally-authored posts across all users.
|
/// An inbound activity with an unrecognized type was received.
|
||||||
async fn count_local_posts(&self) -> anyhow::Result<u64>;
|
///
|
||||||
|
/// Override this to handle custom ActivityPub extensions (EmojiReact,
|
||||||
|
/// Question, Flag, etc.) that k-ap doesn't process natively.
|
||||||
|
/// The raw JSON and the sender's actor URL are provided.
|
||||||
|
///
|
||||||
|
/// **Note:** The default `router()` inbox handler gracefully accepts unknown
|
||||||
|
/// activity types but cannot dispatch to this method due to upstream library
|
||||||
|
/// constraints (the raw body is consumed during signature verification).
|
||||||
|
/// To fully handle unknown activities, build a custom inbox handler that
|
||||||
|
/// pre-parses the body before passing to `receive_activity`.
|
||||||
|
///
|
||||||
|
/// Default is a no-op — unknown activities are silently accepted.
|
||||||
|
async fn on_unknown_activity(
|
||||||
|
&self,
|
||||||
|
activity_type: &str,
|
||||||
|
activity: serde_json::Value,
|
||||||
|
actor_url: &Url,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let _ = (activity_type, activity, actor_url);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
101
src/data.rs
101
src/data.rs
@@ -1,38 +1,99 @@
|
|||||||
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::url_scheme::UrlScheme;
|
||||||
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,
|
||||||
|
},
|
||||||
|
/// A remote actor accepted our outbound follow request.
|
||||||
|
/// Call `ActivityPubService::import_remote_outbox(outbox_url, actor_url)` to backfill.
|
||||||
|
OutboundFollowAccepted {
|
||||||
|
local_user_id: uuid::Uuid,
|
||||||
|
remote_actor_url: String,
|
||||||
|
outbox_url: Option<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,
|
||||||
|
pub(crate) url_scheme: Arc<dyn UrlScheme>,
|
||||||
|
pub(crate) nodeinfo_services_inbound: Vec<String>,
|
||||||
|
pub(crate) nodeinfo_services_outbound: Vec<String>,
|
||||||
|
pub(crate) nodeinfo_metadata: serde_json::Value,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl FederationData {
|
impl FederationData {
|
||||||
pub fn new(
|
#[allow(clippy::too_many_arguments)]
|
||||||
federation_repo: Arc<dyn FederationRepository>,
|
pub(crate) fn new(
|
||||||
|
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,
|
||||||
|
url_scheme: Arc<dyn UrlScheme>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
let domain = base_url
|
let domain = base_url
|
||||||
.trim_start_matches("https://")
|
.trim_start_matches("https://")
|
||||||
@@ -42,14 +103,34 @@ 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,
|
||||||
|
url_scheme,
|
||||||
|
nodeinfo_services_inbound: vec![],
|
||||||
|
nodeinfo_services_outbound: vec![],
|
||||||
|
nodeinfo_metadata: serde_json::json!({}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn with_nodeinfo_services(mut self, inbound: Vec<String>, outbound: Vec<String>) -> Self {
|
||||||
|
self.nodeinfo_services_inbound = inbound;
|
||||||
|
self.nodeinfo_services_outbound = outbound;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_nodeinfo_metadata(mut self, metadata: serde_json::Value) -> Self {
|
||||||
|
self.nodeinfo_metadata = metadata;
|
||||||
|
self
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
81
src/error.rs
81
src/error.rs
@@ -1,47 +1,76 @@
|
|||||||
use std::fmt::{Display, Formatter};
|
|
||||||
|
|
||||||
use axum::http::StatusCode;
|
use axum::http::StatusCode;
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
pub struct Error(pub(crate) anyhow::Error, pub(crate) StatusCode);
|
pub enum Error {
|
||||||
|
#[error("not found: {0}")]
|
||||||
|
NotFound(String),
|
||||||
|
|
||||||
|
#[error("bad request: {0}")]
|
||||||
|
BadRequest(String),
|
||||||
|
|
||||||
|
#[error("unauthorized: {0}")]
|
||||||
|
Unauthorized(String),
|
||||||
|
|
||||||
|
#[error("forbidden: {0}")]
|
||||||
|
Forbidden(String),
|
||||||
|
|
||||||
|
#[error(transparent)]
|
||||||
|
Internal(#[from] anyhow::Error),
|
||||||
|
}
|
||||||
|
|
||||||
impl Error {
|
impl Error {
|
||||||
pub fn not_found(e: impl Into<anyhow::Error>) -> Self {
|
pub fn not_found(message: impl Into<String>) -> Self {
|
||||||
Self(e.into(), StatusCode::NOT_FOUND)
|
Self::NotFound(message.into())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn bad_request(e: impl Into<anyhow::Error>) -> Self {
|
pub fn bad_request(message: impl Into<String>) -> Self {
|
||||||
Self(e.into(), StatusCode::BAD_REQUEST)
|
Self::BadRequest(message.into())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn unauthorized(message: impl Into<String>) -> Self {
|
||||||
|
Self::Unauthorized(message.into())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn forbidden(message: impl Into<String>) -> Self {
|
||||||
|
Self::Forbidden(message.into())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Display for Error {
|
impl From<activitypub_federation::error::Error> for Error {
|
||||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
fn from(error: activitypub_federation::error::Error) -> Self {
|
||||||
std::fmt::Display::fmt(&self.0, f)
|
use activitypub_federation::error::Error as FedError;
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<T> From<T> for Error
|
match &error {
|
||||||
where
|
FedError::ActivitySignatureInvalid | FedError::ActivityBodyDigestInvalid => {
|
||||||
T: Into<anyhow::Error>,
|
Self::Unauthorized(error.to_string())
|
||||||
{
|
}
|
||||||
fn from(t: T) -> Self {
|
_ => Self::Internal(error.into()),
|
||||||
Error(t.into(), StatusCode::INTERNAL_SERVER_ERROR)
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 = match &self {
|
||||||
|
Error::NotFound(_) => StatusCode::NOT_FOUND,
|
||||||
|
Error::BadRequest(_) => StatusCode::BAD_REQUEST,
|
||||||
|
Error::Unauthorized(_) => StatusCode::UNAUTHORIZED,
|
||||||
|
Error::Forbidden(_) => StatusCode::FORBIDDEN,
|
||||||
|
Error::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
};
|
||||||
|
|
||||||
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, status = status.as_u16(), "federation error");
|
||||||
} else {
|
} else {
|
||||||
tracing::debug!(error = %self.0, status = status.as_u16(), "federation response");
|
tracing::debug!(error = %self, status = status.as_u16(), "federation client error");
|
||||||
}
|
}
|
||||||
let body = if status.is_server_error() {
|
|
||||||
"internal server error".to_string()
|
let body = match status {
|
||||||
} else {
|
StatusCode::NOT_FOUND => "not found",
|
||||||
self.0.to_string()
|
StatusCode::BAD_REQUEST => "bad request",
|
||||||
|
StatusCode::UNAUTHORIZED => "unauthorized",
|
||||||
|
StatusCode::FORBIDDEN => "forbidden",
|
||||||
|
_ => "internal server error",
|
||||||
};
|
};
|
||||||
(status, body).into_response()
|
(status, body).into_response()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,25 @@ 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> {
|
||||||
|
crate::tls::ensure_crypto_provider();
|
||||||
let config = if debug {
|
let config = if debug {
|
||||||
FederationConfig::builder()
|
FederationConfig::builder()
|
||||||
.domain(&data.domain)
|
.domain(&data.domain)
|
||||||
@@ -29,21 +48,29 @@ 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))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn inner(&self) -> &FederationConfig<FederationData> {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
|
||||||
pub fn to_request_data(&self) -> Data<FederationData> {
|
pub fn to_request_data(&self) -> Data<FederationData> {
|
||||||
self.0.to_request_data()
|
self.inner().to_request_data()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn middleware(&self) -> FederationMiddleware<FederationData> {
|
pub fn middleware(&self) -> FederationMiddleware<FederationData> {
|
||||||
FederationMiddleware::new(self.0.clone())
|
FederationMiddleware::new(self.inner().clone())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,105 +0,0 @@
|
|||||||
use activitypub_federation::{axum::json::FederationJson, config::Data};
|
|
||||||
use axum::extract::{Path, Query};
|
|
||||||
use serde::Deserialize;
|
|
||||||
use serde_json::json;
|
|
||||||
|
|
||||||
use crate::data::FederationData;
|
|
||||||
use crate::error::Error;
|
|
||||||
use crate::urls::AP_PAGE_SIZE;
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
pub struct PageQuery {
|
|
||||||
page: Option<u32>,
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn collection_handler(
|
|
||||||
user_id_str: &str,
|
|
||||||
query: PageQuery,
|
|
||||||
data: Data<FederationData>,
|
|
||||||
collection_type: &str,
|
|
||||||
) -> Result<FederationJson<serde_json::Value>, Error> {
|
|
||||||
let user_id = uuid::Uuid::parse_str(user_id_str)
|
|
||||||
.map_err(|_| Error::bad_request(anyhow::anyhow!("invalid user id")))?;
|
|
||||||
|
|
||||||
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 collection_id = format!(
|
|
||||||
"{}/users/{}/{}",
|
|
||||||
data.base_url, user_id_str, collection_type
|
|
||||||
);
|
|
||||||
|
|
||||||
let total = match collection_type {
|
|
||||||
"followers" => data.federation_repo.count_followers(user_id).await,
|
|
||||||
_ => data.federation_repo.count_following(user_id).await,
|
|
||||||
}
|
|
||||||
.map_err(Error::from)?;
|
|
||||||
|
|
||||||
if let Some(page) = query.page {
|
|
||||||
let page = page.max(1);
|
|
||||||
let offset = (page.saturating_sub(1) as usize) * AP_PAGE_SIZE;
|
|
||||||
|
|
||||||
let items: Vec<String> = match collection_type {
|
|
||||||
"followers" => data
|
|
||||||
.federation_repo
|
|
||||||
.get_followers_page(user_id, offset as u32, AP_PAGE_SIZE)
|
|
||||||
.await
|
|
||||||
.map_err(Error::from)?
|
|
||||||
.into_iter()
|
|
||||||
.map(|f| f.actor.url)
|
|
||||||
.collect(),
|
|
||||||
_ => data
|
|
||||||
.federation_repo
|
|
||||||
.get_following_page(user_id, offset as u32, AP_PAGE_SIZE)
|
|
||||||
.await
|
|
||||||
.map_err(Error::from)?
|
|
||||||
.into_iter()
|
|
||||||
.map(|a| a.url)
|
|
||||||
.collect(),
|
|
||||||
};
|
|
||||||
|
|
||||||
let has_next = offset + items.len() < total;
|
|
||||||
|
|
||||||
let mut obj = json!({
|
|
||||||
"@context": crate::urls::AP_CONTEXT,
|
|
||||||
"type": "OrderedCollectionPage",
|
|
||||||
"id": format!("{}?page={}", collection_id, page),
|
|
||||||
"partOf": collection_id,
|
|
||||||
"totalItems": total,
|
|
||||||
"orderedItems": items,
|
|
||||||
});
|
|
||||||
|
|
||||||
if has_next {
|
|
||||||
obj["next"] = json!(format!("{}?page={}", collection_id, page + 1));
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(FederationJson(obj))
|
|
||||||
} else {
|
|
||||||
Ok(FederationJson(json!({
|
|
||||||
"@context": crate::urls::AP_CONTEXT,
|
|
||||||
"type": "OrderedCollection",
|
|
||||||
"id": collection_id,
|
|
||||||
"totalItems": total,
|
|
||||||
"first": format!("{}?page=1", collection_id),
|
|
||||||
})))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn followers_handler(
|
|
||||||
Path(user_id_str): Path<String>,
|
|
||||||
Query(query): Query<PageQuery>,
|
|
||||||
data: Data<FederationData>,
|
|
||||||
) -> Result<FederationJson<serde_json::Value>, Error> {
|
|
||||||
collection_handler(&user_id_str, query, data, "followers").await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn following_handler(
|
|
||||||
Path(user_id_str): Path<String>,
|
|
||||||
Query(query): Query<PageQuery>,
|
|
||||||
data: Data<FederationData>,
|
|
||||||
) -> Result<FederationJson<serde_json::Value>, Error> {
|
|
||||||
collection_handler(&user_id_str, query, data, "following").await
|
|
||||||
}
|
|
||||||
24
src/handlers/actor.rs
Normal file
24
src/handlers/actor.rs
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
use activitypub_federation::{
|
||||||
|
axum::json::FederationJson, config::Data, protocol::context::WithContext, traits::Object,
|
||||||
|
};
|
||||||
|
use axum::extract::Path;
|
||||||
|
|
||||||
|
use crate::actors::{Person, get_local_actor};
|
||||||
|
use crate::data::FederationData;
|
||||||
|
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(
|
||||||
|
Path(user_id_str): Path<String>,
|
||||||
|
data: Data<FederationData>,
|
||||||
|
) -> Result<FederationJson<WithContext<Person>>, Error> {
|
||||||
|
let user_id =
|
||||||
|
uuid::Uuid::parse_str(&user_id_str).map_err(|_| Error::not_found("user not found"))?;
|
||||||
|
|
||||||
|
let db_actor = get_local_actor(user_id, &data).await?;
|
||||||
|
let person = db_actor.into_json(&data).await?;
|
||||||
|
|
||||||
|
Ok(FederationJson(WithContext::new(person, actor_ap_context())))
|
||||||
|
}
|
||||||
37
src/handlers/featured.rs
Normal file
37
src/handlers/featured.rs
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
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("user not found"))?;
|
||||||
|
|
||||||
|
data.user_repo
|
||||||
|
.find_by_id(user_id)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| Error::not_found("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?;
|
||||||
|
|
||||||
|
Ok(FederationJson(json!({
|
||||||
|
"@context": AP_CONTEXT,
|
||||||
|
"type": "OrderedCollection",
|
||||||
|
"id": featured_url,
|
||||||
|
"totalItems": items.len(),
|
||||||
|
"orderedItems": items.iter().map(|url| url.as_str()).collect::<Vec<_>>(),
|
||||||
|
})))
|
||||||
|
}
|
||||||
89
src/handlers/followers.rs
Normal file
89
src/handlers/followers.rs
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
use activitypub_federation::{axum::json::FederationJson, config::Data};
|
||||||
|
use axum::extract::{Path, Query};
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
use crate::data::FederationData;
|
||||||
|
use crate::error::Error;
|
||||||
|
use crate::service::collections::serialize_ordered_collection;
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct PageQuery {
|
||||||
|
page: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn collection_handler(
|
||||||
|
user_id_str: &str,
|
||||||
|
query: PageQuery,
|
||||||
|
data: Data<FederationData>,
|
||||||
|
collection_type: &str,
|
||||||
|
) -> Result<FederationJson<serde_json::Value>, Error> {
|
||||||
|
let user_id =
|
||||||
|
uuid::Uuid::parse_str(user_id_str).map_err(|_| Error::bad_request("invalid user id"))?;
|
||||||
|
|
||||||
|
data.user_repo
|
||||||
|
.find_by_id(user_id)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| Error::not_found("user not found"))?;
|
||||||
|
|
||||||
|
let actor_url = data
|
||||||
|
.url_scheme
|
||||||
|
.actor_url(&data.base_url, user_id)
|
||||||
|
.map_err(Error::from)?;
|
||||||
|
let collection_url = match collection_type {
|
||||||
|
"followers" => data.url_scheme.followers_url(&actor_url),
|
||||||
|
_ => data.url_scheme.following_url(&actor_url),
|
||||||
|
}
|
||||||
|
.map_err(Error::from)?
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let total = match collection_type {
|
||||||
|
"followers" => data.follow_repo.count_followers(user_id).await,
|
||||||
|
_ => data.follow_repo.count_following(user_id).await,
|
||||||
|
}
|
||||||
|
.map_err(Error::from)?;
|
||||||
|
|
||||||
|
let items_fn = |offset: u32, limit: usize| {
|
||||||
|
let data = data.clone();
|
||||||
|
async move {
|
||||||
|
Ok(match collection_type {
|
||||||
|
"followers" => data
|
||||||
|
.follow_repo
|
||||||
|
.get_followers_page(user_id, offset, limit)
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.map(|follower| follower.actor.url)
|
||||||
|
.collect(),
|
||||||
|
_ => data
|
||||||
|
.follow_repo
|
||||||
|
.get_following_page(user_id, offset, limit)
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.map(|actor| actor.url)
|
||||||
|
.collect(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let json_str = serialize_ordered_collection(&collection_url, total, query.page, items_fn)
|
||||||
|
.await
|
||||||
|
.map_err(Error::from)?;
|
||||||
|
let value: serde_json::Value =
|
||||||
|
serde_json::from_str(&json_str).map_err(|e| Error::from(anyhow::anyhow!(e)))?;
|
||||||
|
Ok(FederationJson(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn followers_handler(
|
||||||
|
Path(user_id_str): Path<String>,
|
||||||
|
Query(query): Query<PageQuery>,
|
||||||
|
data: Data<FederationData>,
|
||||||
|
) -> Result<FederationJson<serde_json::Value>, Error> {
|
||||||
|
collection_handler(&user_id_str, query, data, "followers").await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn following_handler(
|
||||||
|
Path(user_id_str): Path<String>,
|
||||||
|
Query(query): Query<PageQuery>,
|
||||||
|
data: Data<FederationData>,
|
||||||
|
) -> Result<FederationJson<serde_json::Value>, Error> {
|
||||||
|
collection_handler(&user_id_str, query, data, "following").await
|
||||||
|
}
|
||||||
35
src/handlers/inbox.rs
Normal file
35
src/handlers/inbox.rs
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
use activitypub_federation::{
|
||||||
|
axum::inbox::{ActivityData, receive_activity},
|
||||||
|
config::Data,
|
||||||
|
protocol::context::WithContext,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::activities::InboxActivities;
|
||||||
|
use crate::actors::DbActor;
|
||||||
|
use crate::data::FederationData;
|
||||||
|
use crate::error::Error;
|
||||||
|
|
||||||
|
pub async fn inbox_handler(
|
||||||
|
data: Data<FederationData>,
|
||||||
|
activity_data: ActivityData,
|
||||||
|
) -> Result<(), Error> {
|
||||||
|
let result = receive_activity::<WithContext<InboxActivities>, DbActor, FederationData>(
|
||||||
|
activity_data,
|
||||||
|
&data,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(()) => Ok(()),
|
||||||
|
Err(Error::Internal(ref inner)) if is_unknown_activity_error(inner) => {
|
||||||
|
tracing::debug!(error = %inner, "unknown activity type, accepted without processing");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Err(error) => Err(error),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_unknown_activity_error(error: &anyhow::Error) -> bool {
|
||||||
|
let message = error.to_string();
|
||||||
|
message.contains("unknown variant") || message.contains("does not match any variant")
|
||||||
|
}
|
||||||
7
src/handlers/mod.rs
Normal file
7
src/handlers/mod.rs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
pub mod actor;
|
||||||
|
pub mod featured;
|
||||||
|
pub mod followers;
|
||||||
|
pub mod inbox;
|
||||||
|
pub mod nodeinfo;
|
||||||
|
pub mod outbox;
|
||||||
|
pub mod webfinger;
|
||||||
@@ -36,14 +36,22 @@ pub struct NodeInfoUsers {
|
|||||||
pub total: usize,
|
pub total: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct NodeInfoServices {
|
||||||
|
pub inbound: Vec<String>,
|
||||||
|
pub outbound: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct NodeInfo {
|
pub struct NodeInfo {
|
||||||
pub version: String,
|
pub version: String,
|
||||||
pub software: NodeInfoSoftware,
|
pub software: NodeInfoSoftware,
|
||||||
pub protocols: Vec<String>,
|
pub protocols: Vec<String>,
|
||||||
pub usage: NodeInfoUsage,
|
pub services: NodeInfoServices,
|
||||||
pub open_registrations: bool,
|
pub open_registrations: bool,
|
||||||
|
pub usage: NodeInfoUsage,
|
||||||
|
pub metadata: serde_json::Value,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn nodeinfo_well_known_handler(
|
pub async fn nodeinfo_well_known_handler(
|
||||||
@@ -60,7 +68,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(),
|
||||||
@@ -69,11 +77,16 @@ pub async fn nodeinfo_handler(data: Data<FederationData>) -> Result<Json<NodeInf
|
|||||||
version: env!("CARGO_PKG_VERSION").to_string(),
|
version: env!("CARGO_PKG_VERSION").to_string(),
|
||||||
},
|
},
|
||||||
protocols: vec!["activitypub".to_string()],
|
protocols: vec!["activitypub".to_string()],
|
||||||
|
services: NodeInfoServices {
|
||||||
|
inbound: data.nodeinfo_services_inbound.clone(),
|
||||||
|
outbound: data.nodeinfo_services_outbound.clone(),
|
||||||
|
},
|
||||||
|
open_registrations: data.allow_registration,
|
||||||
usage: NodeInfoUsage {
|
usage: NodeInfoUsage {
|
||||||
users: NodeInfoUsers { total: user_count },
|
users: NodeInfoUsers { total: user_count },
|
||||||
local_posts,
|
local_posts,
|
||||||
},
|
},
|
||||||
open_registrations: data.allow_registration,
|
metadata: data.nodeinfo_metadata.clone(),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
162
src/handlers/outbox.rs
Normal file
162
src/handlers/outbox.rs
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
use axum::extract::{Path, Query};
|
||||||
|
use axum::response::IntoResponse;
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use url::Url;
|
||||||
|
|
||||||
|
use activitypub_federation::{
|
||||||
|
config::Data, fetch::object_id::ObjectId, kinds::activity::CreateType,
|
||||||
|
protocol::context::WithContext,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
activities::CreateActivity, content::LocalObject, data::FederationData, error::Error,
|
||||||
|
urls::AP_PAGE_SIZE,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct OutboxQuery {
|
||||||
|
page: Option<bool>,
|
||||||
|
before: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct OrderedCollection {
|
||||||
|
#[serde(rename = "@context")]
|
||||||
|
context: String,
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
kind: String,
|
||||||
|
id: String,
|
||||||
|
total_items: u64,
|
||||||
|
first: String,
|
||||||
|
last: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct OrderedCollectionPage {
|
||||||
|
#[serde(rename = "@context")]
|
||||||
|
context: String,
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
kind: String,
|
||||||
|
id: String,
|
||||||
|
part_of: String,
|
||||||
|
total_items: u64,
|
||||||
|
ordered_items: Vec<serde_json::Value>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
next: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn outbox_handler(
|
||||||
|
Path(user_id_str): Path<String>,
|
||||||
|
Query(query): Query<OutboxQuery>,
|
||||||
|
data: Data<FederationData>,
|
||||||
|
) -> Result<axum::response::Response, Error> {
|
||||||
|
let uuid =
|
||||||
|
uuid::Uuid::parse_str(&user_id_str).map_err(|_| Error::bad_request("invalid user id"))?;
|
||||||
|
|
||||||
|
data.user_repo
|
||||||
|
.find_by_id(uuid)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| Error::not_found("user not found"))?;
|
||||||
|
|
||||||
|
let actor_url = data.url_scheme.actor_url(&data.base_url, uuid)?;
|
||||||
|
let outbox_url = data.url_scheme.outbox_url(&actor_url)?.to_string();
|
||||||
|
let total = data.content_reader.count_local_posts().await?;
|
||||||
|
|
||||||
|
if query.page.unwrap_or(false) {
|
||||||
|
build_outbox_page(uuid, &query, &outbox_url, total, &data).await
|
||||||
|
} else {
|
||||||
|
build_outbox_collection(&outbox_url, total)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn build_outbox_page(
|
||||||
|
user_id: uuid::Uuid,
|
||||||
|
query: &OutboxQuery,
|
||||||
|
outbox_url: &str,
|
||||||
|
total: u64,
|
||||||
|
data: &Data<FederationData>,
|
||||||
|
) -> Result<axum::response::Response, Error> {
|
||||||
|
let before: Option<DateTime<Utc>> = query.before.as_deref().and_then(|s| s.parse().ok());
|
||||||
|
let items = data
|
||||||
|
.content_reader
|
||||||
|
.get_local_objects_page(user_id, before, AP_PAGE_SIZE)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let actor_url: Url = data
|
||||||
|
.url_scheme
|
||||||
|
.actor_url(&data.base_url, user_id)
|
||||||
|
.map_err(|error| Error::bad_request(format!("invalid base_url: {error}")))?;
|
||||||
|
|
||||||
|
let has_more = items.len() == AP_PAGE_SIZE;
|
||||||
|
let oldest_timestamp = items.last().map(|item| item.published_at);
|
||||||
|
let ordered_items = wrap_items_as_create_activities(&items, &actor_url)?;
|
||||||
|
|
||||||
|
let page_id = match &query.before {
|
||||||
|
Some(before) => format!("{}?page=true&before={}", outbox_url, before),
|
||||||
|
None => format!("{}?page=true", outbox_url),
|
||||||
|
};
|
||||||
|
|
||||||
|
let next = if has_more {
|
||||||
|
oldest_timestamp.map(|timestamp| {
|
||||||
|
let formatted = timestamp.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
|
||||||
|
format!("{}?page=true&before={}", outbox_url, formatted)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(axum::Json(OrderedCollectionPage {
|
||||||
|
context: crate::urls::AP_CONTEXT.to_string(),
|
||||||
|
kind: "OrderedCollectionPage".to_string(),
|
||||||
|
id: page_id,
|
||||||
|
part_of: outbox_url.to_string(),
|
||||||
|
total_items: total,
|
||||||
|
ordered_items,
|
||||||
|
next,
|
||||||
|
})
|
||||||
|
.into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_outbox_collection(
|
||||||
|
outbox_url: &str,
|
||||||
|
total: u64,
|
||||||
|
) -> Result<axum::response::Response, Error> {
|
||||||
|
Ok(axum::Json(OrderedCollection {
|
||||||
|
context: crate::urls::AP_CONTEXT.to_string(),
|
||||||
|
kind: "OrderedCollection".to_string(),
|
||||||
|
id: outbox_url.to_string(),
|
||||||
|
total_items: total,
|
||||||
|
first: format!("{}?page=true", outbox_url),
|
||||||
|
last: format!("{}?page=true&before=1970-01-01T00:00:00.000Z", outbox_url),
|
||||||
|
})
|
||||||
|
.into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn wrap_items_as_create_activities(
|
||||||
|
items: &[LocalObject],
|
||||||
|
actor_url: &Url,
|
||||||
|
) -> Result<Vec<serde_json::Value>, Error> {
|
||||||
|
items
|
||||||
|
.iter()
|
||||||
|
.map(|item| {
|
||||||
|
let create_id = Url::parse(&format!("{}/activity", item.ap_id))
|
||||||
|
.map_err(|error| anyhow::anyhow!(error))?;
|
||||||
|
|
||||||
|
let activity = WithContext::new_default(CreateActivity {
|
||||||
|
id: create_id,
|
||||||
|
kind: CreateType::default(),
|
||||||
|
actor: ObjectId::from(actor_url.clone()),
|
||||||
|
object: item.object.clone(),
|
||||||
|
to: item.to.clone(),
|
||||||
|
cc: item.cc.clone(),
|
||||||
|
bto: vec![],
|
||||||
|
bcc: vec![],
|
||||||
|
});
|
||||||
|
|
||||||
|
serde_json::to_value(activity).map_err(|error| anyhow::anyhow!(error).into())
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
@@ -25,16 +25,25 @@ fn nodeinfo_serializes_camel_case() {
|
|||||||
version: "0.1.0".to_string(),
|
version: "0.1.0".to_string(),
|
||||||
},
|
},
|
||||||
protocols: vec!["activitypub".to_string()],
|
protocols: vec!["activitypub".to_string()],
|
||||||
|
services: NodeInfoServices {
|
||||||
|
inbound: vec![],
|
||||||
|
outbound: vec![],
|
||||||
|
},
|
||||||
|
open_registrations: false,
|
||||||
usage: NodeInfoUsage {
|
usage: NodeInfoUsage {
|
||||||
users: NodeInfoUsers { total: 3 },
|
users: NodeInfoUsers { total: 3 },
|
||||||
local_posts: 42,
|
local_posts: 42,
|
||||||
},
|
},
|
||||||
open_registrations: false,
|
metadata: serde_json::json!({}),
|
||||||
};
|
};
|
||||||
let json = serde_json::to_value(&doc).unwrap();
|
let json = serde_json::to_value(&doc).unwrap();
|
||||||
|
assert!(json.get("$schema").is_none());
|
||||||
assert_eq!(json["version"], "2.0");
|
assert_eq!(json["version"], "2.0");
|
||||||
assert_eq!(json["software"]["name"], "my-app");
|
assert_eq!(json["software"]["name"], "my-app");
|
||||||
assert_eq!(json["usage"]["users"]["total"], 3);
|
assert_eq!(json["usage"]["users"]["total"], 3);
|
||||||
assert_eq!(json["usage"]["localPosts"], 42);
|
assert_eq!(json["usage"]["localPosts"], 42);
|
||||||
assert_eq!(json["openRegistrations"], false);
|
assert_eq!(json["openRegistrations"], false);
|
||||||
|
assert_eq!(json["services"]["inbound"], serde_json::json!([]));
|
||||||
|
assert_eq!(json["services"]["outbound"], serde_json::json!([]));
|
||||||
|
assert_eq!(json["metadata"], serde_json::json!({}));
|
||||||
}
|
}
|
||||||
68
src/handlers/webfinger.rs
Normal file
68
src/handlers/webfinger.rs
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
use activitypub_federation::{config::Data, fetch::webfinger::extract_webfinger_name};
|
||||||
|
use axum::{
|
||||||
|
extract::Query,
|
||||||
|
http::header,
|
||||||
|
response::{IntoResponse, Response},
|
||||||
|
};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::data::FederationData;
|
||||||
|
use crate::error::Error;
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct WebfingerQuery {
|
||||||
|
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(
|
||||||
|
Query(query): Query<WebfingerQuery>,
|
||||||
|
data: Data<FederationData>,
|
||||||
|
) -> Result<Response, Error> {
|
||||||
|
let name = extract_webfinger_name(&query.resource, &data)?;
|
||||||
|
|
||||||
|
let user = data
|
||||||
|
.user_repo
|
||||||
|
.find_by_username(name)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| Error::not_found("user not found"))?;
|
||||||
|
|
||||||
|
let ap_id = data.url_scheme.actor_url(&data.base_url, user.id)?;
|
||||||
|
let acct_uri = format!("acct:{}@{}", user.username, data.domain);
|
||||||
|
|
||||||
|
let response = 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(crate::urls::AP_CONTENT_TYPE.to_string()),
|
||||||
|
href: Some(ap_id.to_string()),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
let body = serde_json::to_string(&response).map_err(|error| anyhow::anyhow!(error))?;
|
||||||
|
Ok(([(header::CONTENT_TYPE, "application/jrd+json")], body).into_response())
|
||||||
|
}
|
||||||
18
src/inbox.rs
18
src/inbox.rs
@@ -1,18 +0,0 @@
|
|||||||
use activitypub_federation::{
|
|
||||||
axum::inbox::{ActivityData, receive_activity},
|
|
||||||
config::Data,
|
|
||||||
protocol::context::WithContext,
|
|
||||||
};
|
|
||||||
|
|
||||||
use crate::activities::InboxActivities;
|
|
||||||
use crate::actors::DbActor;
|
|
||||||
use crate::data::FederationData;
|
|
||||||
use crate::error::Error;
|
|
||||||
|
|
||||||
pub async fn inbox_handler(
|
|
||||||
data: Data<FederationData>,
|
|
||||||
activity_data: ActivityData,
|
|
||||||
) -> Result<(), Error> {
|
|
||||||
receive_activity::<WithContext<InboxActivities>, DbActor, FederationData>(activity_data, &data)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
58
src/lib.rs
58
src/lib.rs
@@ -1,28 +1,48 @@
|
|||||||
pub mod activities;
|
pub(crate) mod activities;
|
||||||
pub mod actor_handler;
|
pub(crate) mod actors;
|
||||||
pub mod actors;
|
pub(crate) mod content;
|
||||||
pub mod content;
|
pub(crate) mod data;
|
||||||
pub mod data;
|
pub(crate) mod error;
|
||||||
pub mod error;
|
pub(crate) mod federation;
|
||||||
pub mod federation;
|
pub(crate) mod handlers;
|
||||||
pub mod followers_handler;
|
|
||||||
pub mod inbox;
|
|
||||||
pub mod nodeinfo;
|
|
||||||
pub mod outbox;
|
|
||||||
pub mod repository;
|
pub mod repository;
|
||||||
|
pub(crate) mod security;
|
||||||
pub mod service;
|
pub mod service;
|
||||||
|
/// Mock builders for testing. Not behind `#[cfg(test)]` so downstream crates
|
||||||
|
/// can use them in their own test suites.
|
||||||
|
pub mod testing;
|
||||||
|
pub(crate) mod tls;
|
||||||
|
pub(crate) mod url_scheme;
|
||||||
pub(crate) mod urls;
|
pub(crate) mod urls;
|
||||||
pub mod user;
|
pub(crate) mod user;
|
||||||
pub mod webfinger;
|
|
||||||
|
|
||||||
pub use urls::AS_PUBLIC;
|
pub use content::{ApContentReader, ApObjectHandler, LocalObject};
|
||||||
pub use activitypub_federation::kinds::object::NoteType;
|
pub use data::{EventPublisher, FederationData, FederationEvent};
|
||||||
pub use content::ApObjectHandler;
|
|
||||||
pub use data::FederationData;
|
|
||||||
pub use error::Error;
|
pub use error::Error;
|
||||||
pub use federation::ApFederationConfig;
|
pub use federation::ApFederationConfig;
|
||||||
|
pub use handlers::actor::actor_handler;
|
||||||
|
pub use handlers::followers::{followers_handler, following_handler};
|
||||||
pub use repository::{
|
pub use repository::{
|
||||||
BlockedDomain, FederationRepository, Follower, FollowerStatus, FollowingStatus, RemoteActor,
|
ActivityRepository, ActorBlocklist, ActorRepository, AnnounceRepository, BlockedDomain,
|
||||||
|
BlocklistRepository, DomainBlocklist, FollowMigration, FollowRepository, Follower,
|
||||||
|
FollowerReader, FollowerStatus, FollowerWriter, FollowingReader, FollowingStatus,
|
||||||
|
FollowingWriter, Keypair, KeypairRepository, RemoteActor, RemoteActorCache,
|
||||||
};
|
};
|
||||||
pub use service::ActivityPubService;
|
pub use service::ActivityPubService;
|
||||||
pub use user::{ApProfileField, ApUser, ApUserRepository, LookedUpActor};
|
pub use url_scheme::{DefaultUrlScheme, UrlScheme};
|
||||||
|
pub use urls::{AP_CONTENT_TYPE, AP_CONTEXT, AS_PUBLIC, INBOX_BODY_LIMIT};
|
||||||
|
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;
|
||||||
|
|||||||
138
src/outbox.rs
138
src/outbox.rs
@@ -1,138 +0,0 @@
|
|||||||
use axum::extract::{Path, Query};
|
|
||||||
use axum::response::IntoResponse;
|
|
||||||
use chrono::{DateTime, Utc};
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use url::Url;
|
|
||||||
|
|
||||||
use activitypub_federation::{
|
|
||||||
config::Data, fetch::object_id::ObjectId, kinds::activity::CreateType,
|
|
||||||
protocol::context::WithContext,
|
|
||||||
};
|
|
||||||
|
|
||||||
use crate::{activities::CreateActivity, data::FederationData, error::Error, urls::AP_PAGE_SIZE};
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
pub struct OutboxQuery {
|
|
||||||
page: Option<bool>,
|
|
||||||
before: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct OrderedCollection {
|
|
||||||
#[serde(rename = "@context")]
|
|
||||||
context: String,
|
|
||||||
#[serde(rename = "type")]
|
|
||||||
kind: String,
|
|
||||||
id: String,
|
|
||||||
total_items: u64,
|
|
||||||
first: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct OrderedCollectionPage {
|
|
||||||
#[serde(rename = "@context")]
|
|
||||||
context: String,
|
|
||||||
#[serde(rename = "type")]
|
|
||||||
kind: String,
|
|
||||||
id: String,
|
|
||||||
part_of: String,
|
|
||||||
ordered_items: Vec<serde_json::Value>,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
next: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn outbox_handler(
|
|
||||||
Path(user_id_str): Path<String>,
|
|
||||||
Query(query): Query<OutboxQuery>,
|
|
||||||
data: Data<FederationData>,
|
|
||||||
) -> Result<axum::response::Response, Error> {
|
|
||||||
let uuid = uuid::Uuid::parse_str(&user_id_str)
|
|
||||||
.map_err(|_| Error::bad_request(anyhow::anyhow!("invalid user id")))?;
|
|
||||||
|
|
||||||
data.user_repo
|
|
||||||
.find_by_id(uuid)
|
|
||||||
.await
|
|
||||||
.map_err(Error::from)?
|
|
||||||
.ok_or_else(|| Error::not_found(anyhow::anyhow!("user not found")))?;
|
|
||||||
|
|
||||||
let outbox_url = format!("{}/users/{}/outbox", data.base_url, user_id_str);
|
|
||||||
|
|
||||||
if query.page.unwrap_or(false) {
|
|
||||||
let before: Option<DateTime<Utc>> = query.before.as_deref().and_then(|s| s.parse().ok());
|
|
||||||
|
|
||||||
let items = data
|
|
||||||
.object_handler
|
|
||||||
.get_local_objects_page(uuid, before, AP_PAGE_SIZE)
|
|
||||||
.await
|
|
||||||
.map_err(|e| Error::from(anyhow::anyhow!("{}", e)))?;
|
|
||||||
|
|
||||||
let actor_url: Url = format!("{}/users/{}", data.base_url, user_id_str)
|
|
||||||
.parse()
|
|
||||||
.expect("valid url");
|
|
||||||
|
|
||||||
let has_more = items.len() == AP_PAGE_SIZE;
|
|
||||||
let oldest_ts = items.last().map(|(_, _, ts)| *ts);
|
|
||||||
|
|
||||||
let followers_url = format!("{}/followers", actor_url);
|
|
||||||
let ordered_items: Vec<serde_json::Value> = items
|
|
||||||
.into_iter()
|
|
||||||
.map(|(ap_id, object, _)| {
|
|
||||||
let create_id = Url::parse(&format!("{}/activity", ap_id)).expect("valid url");
|
|
||||||
serde_json::to_value(WithContext::new_default(CreateActivity {
|
|
||||||
id: create_id,
|
|
||||||
kind: CreateType::default(),
|
|
||||||
actor: ObjectId::from(actor_url.clone()),
|
|
||||||
object,
|
|
||||||
to: vec![crate::urls::AS_PUBLIC.to_string()],
|
|
||||||
cc: vec![followers_url.clone()],
|
|
||||||
bto: vec![],
|
|
||||||
bcc: vec![],
|
|
||||||
}))
|
|
||||||
.expect("serializable")
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let page_id = match &query.before {
|
|
||||||
Some(b) => format!("{}?page=true&before={}", outbox_url, b),
|
|
||||||
None => format!("{}?page=true", outbox_url),
|
|
||||||
};
|
|
||||||
|
|
||||||
let next = if has_more {
|
|
||||||
oldest_ts.map(|ts| {
|
|
||||||
// Use RFC 3339 with Z suffix (no + sign) to avoid percent-encoding
|
|
||||||
let ts_str = ts.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
|
|
||||||
format!("{}?page=true&before={}", outbox_url, ts_str)
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(axum::Json(OrderedCollectionPage {
|
|
||||||
context: crate::urls::AP_CONTEXT.to_string(),
|
|
||||||
kind: "OrderedCollectionPage".to_string(),
|
|
||||||
id: page_id,
|
|
||||||
part_of: outbox_url,
|
|
||||||
ordered_items,
|
|
||||||
next,
|
|
||||||
})
|
|
||||||
.into_response())
|
|
||||||
} 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 {
|
|
||||||
context: crate::urls::AP_CONTEXT.to_string(),
|
|
||||||
kind: "OrderedCollection".to_string(),
|
|
||||||
id: outbox_url.clone(),
|
|
||||||
total_items: total,
|
|
||||||
first: format!("{}?page=true", outbox_url),
|
|
||||||
})
|
|
||||||
.into_response())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,134 +0,0 @@
|
|||||||
use anyhow::Result;
|
|
||||||
use async_trait::async_trait;
|
|
||||||
|
|
||||||
#[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>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[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,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
pub trait FederationRepository: Send + Sync {
|
|
||||||
async fn add_follower(
|
|
||||||
&self,
|
|
||||||
local_user_id: uuid::Uuid,
|
|
||||||
remote_actor_url: &str,
|
|
||||||
status: FollowerStatus,
|
|
||||||
follow_activity_id: &str,
|
|
||||||
) -> Result<()>;
|
|
||||||
async fn get_follower_follow_activity_id(
|
|
||||||
&self,
|
|
||||||
local_user_id: uuid::Uuid,
|
|
||||||
remote_actor_url: &str,
|
|
||||||
) -> Result<Option<String>>;
|
|
||||||
async fn remove_follower(
|
|
||||||
&self,
|
|
||||||
local_user_id: uuid::Uuid,
|
|
||||||
remote_actor_url: &str,
|
|
||||||
) -> Result<()>;
|
|
||||||
async fn get_followers(&self, local_user_id: uuid::Uuid) -> Result<Vec<Follower>>;
|
|
||||||
async fn get_followers_page(
|
|
||||||
&self,
|
|
||||||
local_user_id: uuid::Uuid,
|
|
||||||
offset: u32,
|
|
||||||
limit: usize,
|
|
||||||
) -> Result<Vec<Follower>>;
|
|
||||||
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(
|
|
||||||
&self,
|
|
||||||
local_user_id: uuid::Uuid,
|
|
||||||
remote_actor_url: &str,
|
|
||||||
status: FollowerStatus,
|
|
||||||
) -> Result<()>;
|
|
||||||
async fn add_following(
|
|
||||||
&self,
|
|
||||||
local_user_id: uuid::Uuid,
|
|
||||||
actor: RemoteActor,
|
|
||||||
follow_activity_id: &str,
|
|
||||||
) -> Result<()>;
|
|
||||||
async fn get_follow_activity_id(
|
|
||||||
&self,
|
|
||||||
local_user_id: uuid::Uuid,
|
|
||||||
remote_actor_url: &str,
|
|
||||||
) -> Result<Option<String>>;
|
|
||||||
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 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(
|
|
||||||
&self,
|
|
||||||
local_user_id: uuid::Uuid,
|
|
||||||
remote_actor_url: &str,
|
|
||||||
status: FollowingStatus,
|
|
||||||
) -> Result<()>;
|
|
||||||
async fn get_following_outbox_url(
|
|
||||||
&self,
|
|
||||||
local_user_id: uuid::Uuid,
|
|
||||||
remote_actor_url: &str,
|
|
||||||
) -> Result<Option<String>>;
|
|
||||||
async fn add_announce(
|
|
||||||
&self,
|
|
||||||
activity_id: &str,
|
|
||||||
object_url: &str,
|
|
||||||
actor_url: &str,
|
|
||||||
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>;
|
|
||||||
}
|
|
||||||
11
src/repository/activity.rs
Normal file
11
src/repository/activity.rs
Normal 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<()>;
|
||||||
|
}
|
||||||
5
src/repository/actor.rs
Normal file
5
src/repository/actor.rs
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
use super::{AnnounceRepository, KeypairRepository, RemoteActorCache};
|
||||||
|
|
||||||
|
/// Manages local actor keypairs, remote actor cache, and Announce tracking.
|
||||||
|
pub trait ActorRepository: KeypairRepository + RemoteActorCache + AnnounceRepository {}
|
||||||
|
impl<T: KeypairRepository + RemoteActorCache + AnnounceRepository> ActorRepository for T {}
|
||||||
10
src/repository/actor_blocklist.rs
Normal file
10
src/repository/actor_blocklist.rs
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
use anyhow::Result;
|
||||||
|
use async_trait::async_trait;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait ActorBlocklist: Send + Sync {
|
||||||
|
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>;
|
||||||
|
}
|
||||||
17
src/repository/announce.rs
Normal file
17
src/repository/announce.rs
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
use anyhow::Result;
|
||||||
|
use async_trait::async_trait;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait AnnounceRepository: Send + Sync {
|
||||||
|
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>;
|
||||||
|
}
|
||||||
5
src/repository/blocklist.rs
Normal file
5
src/repository/blocklist.rs
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
use super::{ActorBlocklist, DomainBlocklist};
|
||||||
|
|
||||||
|
/// Domain and actor-level blocklists.
|
||||||
|
pub trait BlocklistRepository: DomainBlocklist + ActorBlocklist {}
|
||||||
|
impl<T: DomainBlocklist + ActorBlocklist> BlocklistRepository for T {}
|
||||||
12
src/repository/domain_blocklist.rs
Normal file
12
src/repository/domain_blocklist.rs
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
use anyhow::Result;
|
||||||
|
use async_trait::async_trait;
|
||||||
|
|
||||||
|
use super::types::BlockedDomain;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait DomainBlocklist: Send + Sync {
|
||||||
|
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>;
|
||||||
|
}
|
||||||
11
src/repository/follow.rs
Normal file
11
src/repository/follow.rs
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
use super::{FollowMigration, FollowerReader, FollowerWriter, FollowingReader, FollowingWriter};
|
||||||
|
|
||||||
|
/// Manages follower/following relationships and account migration.
|
||||||
|
pub trait FollowRepository:
|
||||||
|
FollowerWriter + FollowerReader + FollowingWriter + FollowingReader + FollowMigration
|
||||||
|
{
|
||||||
|
}
|
||||||
|
impl<T: FollowerWriter + FollowerReader + FollowingWriter + FollowingReader + FollowMigration>
|
||||||
|
FollowRepository for T
|
||||||
|
{
|
||||||
|
}
|
||||||
26
src/repository/follow_migration.rs
Normal file
26
src/repository/follow_migration.rs
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
use anyhow::Result;
|
||||||
|
use async_trait::async_trait;
|
||||||
|
|
||||||
|
/// Handles account migration by remapping follower records from one actor URL
|
||||||
|
/// to another.
|
||||||
|
///
|
||||||
|
/// Used by:
|
||||||
|
/// - `activities/move_act.rs` (Move activity processing)
|
||||||
|
///
|
||||||
|
/// Most implementations can use the provided default no-op if account
|
||||||
|
/// migration is not supported.
|
||||||
|
#[async_trait]
|
||||||
|
pub trait FollowMigration: Send + Sync {
|
||||||
|
/// Migrate all follower records from `old_actor_url` to `new_actor_url`.
|
||||||
|
/// Returns local user IDs that need a re-follow sent.
|
||||||
|
///
|
||||||
|
/// The default implementation is a no-op returning an empty list, suitable
|
||||||
|
/// for deployments that do not support account migration.
|
||||||
|
async fn migrate_follower_actor(
|
||||||
|
&self,
|
||||||
|
_old_actor_url: &str,
|
||||||
|
_new_actor_url: &str,
|
||||||
|
) -> Result<Vec<uuid::Uuid>> {
|
||||||
|
Ok(vec![])
|
||||||
|
}
|
||||||
|
}
|
||||||
38
src/repository/follower_reader.rs
Normal file
38
src/repository/follower_reader.rs
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
use anyhow::Result;
|
||||||
|
use async_trait::async_trait;
|
||||||
|
|
||||||
|
use super::types::{Follower, RemoteActor};
|
||||||
|
|
||||||
|
/// Read-only view of follower relationships.
|
||||||
|
///
|
||||||
|
/// Used by:
|
||||||
|
/// - `ActivityPubService::accepted_follower_inboxes` (via `get_accepted_follower_inboxes`)
|
||||||
|
/// - `service/collections.rs` (via `count_followers`, `get_followers_page`)
|
||||||
|
/// - `handlers/followers.rs` (via `count_followers`, `get_followers_page`)
|
||||||
|
/// - `service/broadcast.rs` (via `get_accepted_follower_inboxes`)
|
||||||
|
#[async_trait]
|
||||||
|
pub trait FollowerReader: Send + Sync {
|
||||||
|
async fn get_followers(&self, local_user_id: uuid::Uuid) -> Result<Vec<Follower>>;
|
||||||
|
async fn get_followers_page(
|
||||||
|
&self,
|
||||||
|
local_user_id: uuid::Uuid,
|
||||||
|
offset: u32,
|
||||||
|
limit: usize,
|
||||||
|
) -> Result<Vec<Follower>>;
|
||||||
|
async fn count_followers(&self, local_user_id: uuid::Uuid) -> Result<usize>;
|
||||||
|
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>>;
|
||||||
|
}
|
||||||
36
src/repository/follower_writer.rs
Normal file
36
src/repository/follower_writer.rs
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
use anyhow::Result;
|
||||||
|
use async_trait::async_trait;
|
||||||
|
|
||||||
|
use super::types::FollowerStatus;
|
||||||
|
|
||||||
|
/// Write operations for follower relationships.
|
||||||
|
///
|
||||||
|
/// Used by:
|
||||||
|
/// - inbox handlers (Accept/Follow/Undo processing)
|
||||||
|
/// - `service/lookup.rs` (via `update_follower_status`, `remove_follower`)
|
||||||
|
#[async_trait]
|
||||||
|
pub trait FollowerWriter: Send + Sync {
|
||||||
|
async fn add_follower(
|
||||||
|
&self,
|
||||||
|
local_user_id: uuid::Uuid,
|
||||||
|
remote_actor_url: &str,
|
||||||
|
status: FollowerStatus,
|
||||||
|
follow_activity_id: &str,
|
||||||
|
) -> Result<()>;
|
||||||
|
async fn get_follower_follow_activity_id(
|
||||||
|
&self,
|
||||||
|
local_user_id: uuid::Uuid,
|
||||||
|
remote_actor_url: &str,
|
||||||
|
) -> Result<Option<String>>;
|
||||||
|
async fn remove_follower(
|
||||||
|
&self,
|
||||||
|
local_user_id: uuid::Uuid,
|
||||||
|
remote_actor_url: &str,
|
||||||
|
) -> Result<()>;
|
||||||
|
async fn update_follower_status(
|
||||||
|
&self,
|
||||||
|
local_user_id: uuid::Uuid,
|
||||||
|
remote_actor_url: &str,
|
||||||
|
status: FollowerStatus,
|
||||||
|
) -> Result<()>;
|
||||||
|
}
|
||||||
21
src/repository/following_reader.rs
Normal file
21
src/repository/following_reader.rs
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
use anyhow::Result;
|
||||||
|
use async_trait::async_trait;
|
||||||
|
|
||||||
|
use super::types::RemoteActor;
|
||||||
|
|
||||||
|
/// Read-only view of following relationships (accounts this user follows).
|
||||||
|
///
|
||||||
|
/// Used by:
|
||||||
|
/// - `service/collections.rs` (via `count_following`, `get_following_page`)
|
||||||
|
/// - `handlers/followers.rs` (via `count_following`, `get_following_page`)
|
||||||
|
#[async_trait]
|
||||||
|
pub trait FollowingReader: Send + Sync {
|
||||||
|
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>;
|
||||||
|
}
|
||||||
30
src/repository/following_writer.rs
Normal file
30
src/repository/following_writer.rs
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
use anyhow::Result;
|
||||||
|
use async_trait::async_trait;
|
||||||
|
|
||||||
|
use super::types::{FollowingStatus, RemoteActor};
|
||||||
|
|
||||||
|
/// Write operations for following relationships (outbound follows).
|
||||||
|
///
|
||||||
|
/// Used by:
|
||||||
|
/// - `service/follow.rs` (follow/unfollow/accept processing)
|
||||||
|
#[async_trait]
|
||||||
|
pub trait FollowingWriter: Send + Sync {
|
||||||
|
async fn add_following(
|
||||||
|
&self,
|
||||||
|
local_user_id: uuid::Uuid,
|
||||||
|
actor: RemoteActor,
|
||||||
|
follow_activity_id: &str,
|
||||||
|
) -> Result<()>;
|
||||||
|
async fn get_follow_activity_id(
|
||||||
|
&self,
|
||||||
|
local_user_id: uuid::Uuid,
|
||||||
|
remote_actor_url: &str,
|
||||||
|
) -> Result<Option<String>>;
|
||||||
|
async fn remove_following(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<()>;
|
||||||
|
async fn update_following_status(
|
||||||
|
&self,
|
||||||
|
local_user_id: uuid::Uuid,
|
||||||
|
remote_actor_url: &str,
|
||||||
|
status: FollowingStatus,
|
||||||
|
) -> Result<()>;
|
||||||
|
}
|
||||||
10
src/repository/keypair.rs
Normal file
10
src/repository/keypair.rs
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
use anyhow::Result;
|
||||||
|
use async_trait::async_trait;
|
||||||
|
|
||||||
|
use super::types::Keypair;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait KeypairRepository: Send + Sync {
|
||||||
|
async fn get_local_actor_keypair(&self, user_id: uuid::Uuid) -> Result<Option<Keypair>>;
|
||||||
|
async fn save_local_actor_keypair(&self, user_id: uuid::Uuid, keypair: Keypair) -> Result<()>;
|
||||||
|
}
|
||||||
31
src/repository/mod.rs
Normal file
31
src/repository/mod.rs
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
mod activity;
|
||||||
|
mod actor;
|
||||||
|
mod actor_blocklist;
|
||||||
|
mod announce;
|
||||||
|
mod blocklist;
|
||||||
|
mod domain_blocklist;
|
||||||
|
mod follow;
|
||||||
|
mod follow_migration;
|
||||||
|
mod follower_reader;
|
||||||
|
mod follower_writer;
|
||||||
|
mod following_reader;
|
||||||
|
mod following_writer;
|
||||||
|
mod keypair;
|
||||||
|
mod remote_actor_cache;
|
||||||
|
mod types;
|
||||||
|
|
||||||
|
pub use activity::ActivityRepository;
|
||||||
|
pub use actor::ActorRepository;
|
||||||
|
pub use actor_blocklist::ActorBlocklist;
|
||||||
|
pub use announce::AnnounceRepository;
|
||||||
|
pub use blocklist::BlocklistRepository;
|
||||||
|
pub use domain_blocklist::DomainBlocklist;
|
||||||
|
pub use follow::FollowRepository;
|
||||||
|
pub use follow_migration::FollowMigration;
|
||||||
|
pub use follower_reader::FollowerReader;
|
||||||
|
pub use follower_writer::FollowerWriter;
|
||||||
|
pub use following_reader::FollowingReader;
|
||||||
|
pub use following_writer::FollowingWriter;
|
||||||
|
pub use keypair::KeypairRepository;
|
||||||
|
pub use remote_actor_cache::RemoteActorCache;
|
||||||
|
pub use types::{BlockedDomain, Follower, FollowerStatus, FollowingStatus, Keypair, RemoteActor};
|
||||||
10
src/repository/remote_actor_cache.rs
Normal file
10
src/repository/remote_actor_cache.rs
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
use anyhow::Result;
|
||||||
|
use async_trait::async_trait;
|
||||||
|
|
||||||
|
use super::types::RemoteActor;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait RemoteActorCache: Send + Sync {
|
||||||
|
async fn upsert_remote_actor(&self, actor: RemoteActor) -> Result<()>;
|
||||||
|
async fn get_remote_actor(&self, actor_url: &str) -> Result<Option<RemoteActor>>;
|
||||||
|
}
|
||||||
121
src/repository/types.rs
Normal file
121
src/repository/types.rs
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
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>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&crate::actors::DbActor> for RemoteActor {
|
||||||
|
fn from(actor: &crate::actors::DbActor) -> Self {
|
||||||
|
Self {
|
||||||
|
url: actor.ap_id.to_string(),
|
||||||
|
handle: format!(
|
||||||
|
"{}@{}",
|
||||||
|
actor.username,
|
||||||
|
actor.ap_id.host_str().unwrap_or("")
|
||||||
|
),
|
||||||
|
inbox_url: actor.inbox_url.to_string(),
|
||||||
|
shared_inbox_url: actor.shared_inbox_url.as_ref().map(|url| url.to_string()),
|
||||||
|
display_name: actor
|
||||||
|
.display_name
|
||||||
|
.clone()
|
||||||
|
.or_else(|| Some(actor.username.clone())),
|
||||||
|
avatar_url: actor.avatar_url.as_ref().map(|url| url.to_string()),
|
||||||
|
outbox_url: Some(actor.outbox_url.to_string()),
|
||||||
|
bio: actor.bio.clone(),
|
||||||
|
banner_url: actor.banner_url.as_ref().map(|url| url.to_string()),
|
||||||
|
followers_url: Some(actor.followers_url.to_string()),
|
||||||
|
following_url: Some(actor.following_url.to_string()),
|
||||||
|
also_known_as: actor.also_known_as.clone(),
|
||||||
|
fetched_at: Some(Utc::now()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RemoteActor {
|
||||||
|
pub fn from_ap_person(person: &crate::actors::Person) -> Self {
|
||||||
|
Self {
|
||||||
|
url: person.id.inner().to_string(),
|
||||||
|
handle: person.preferred_username.clone(),
|
||||||
|
inbox_url: person.inbox.to_string(),
|
||||||
|
shared_inbox_url: person
|
||||||
|
.endpoints
|
||||||
|
.as_ref()
|
||||||
|
.map(|endpoints| endpoints.shared_inbox.to_string()),
|
||||||
|
display_name: person.name.clone(),
|
||||||
|
avatar_url: person.icon.as_ref().map(|icon| icon.url.to_string()),
|
||||||
|
outbox_url: person.outbox.as_ref().map(|url| url.to_string()),
|
||||||
|
bio: person.summary.clone(),
|
||||||
|
banner_url: person.image.as_ref().map(|image| image.url.to_string()),
|
||||||
|
followers_url: person.followers.as_ref().map(|url| url.to_string()),
|
||||||
|
following_url: person.following.as_ref().map(|url| url.to_string()),
|
||||||
|
also_known_as: person.also_known_as.clone(),
|
||||||
|
fetched_at: Some(Utc::now()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn placeholder(actor_url: String) -> Self {
|
||||||
|
Self {
|
||||||
|
handle: actor_url.clone(),
|
||||||
|
inbox_url: actor_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,
|
||||||
|
url: actor_url,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Keypair {
|
||||||
|
pub public_key: String,
|
||||||
|
pub private_key: String,
|
||||||
|
}
|
||||||
70
src/security/mod.rs
Normal file
70
src/security/mod.rs
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
use std::net::{IpAddr, Ipv4Addr};
|
||||||
|
|
||||||
|
use url::Url;
|
||||||
|
|
||||||
|
fn is_ipv4_private(v4: Ipv4Addr) -> bool {
|
||||||
|
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) // CGNAT 100.64.0.0/10
|
||||||
|
|| v4.octets()[0] == 0 // 0.0.0.0/8 "this network"
|
||||||
|
|| (v4.octets()[0] == 192 && v4.octets()[1] == 0 && v4.octets()[2] == 2) // TEST-NET-1
|
||||||
|
|| (v4.octets()[0] == 198 && v4.octets()[1] == 51 && v4.octets()[2] == 100) // TEST-NET-2
|
||||||
|
|| (v4.octets()[0] == 203 && v4.octets()[1] == 0 && v4.octets()[2] == 113) // TEST-NET-3
|
||||||
|
|| (v4.octets()[0] == 198 && (v4.octets()[1] & 0xFE) == 18) // benchmarking 198.18.0.0/15
|
||||||
|
|| v4.octets()[0] >= 240 // reserved 240.0.0.0/4
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_ip_private(ip: IpAddr) -> bool {
|
||||||
|
match ip {
|
||||||
|
IpAddr::V4(v4) => is_ipv4_private(v4),
|
||||||
|
IpAddr::V6(v6) => {
|
||||||
|
if let Some(mapped_v4) = v6.to_ipv4_mapped() {
|
||||||
|
return is_ipv4_private(mapped_v4);
|
||||||
|
}
|
||||||
|
|
||||||
|
v6.is_loopback()
|
||||||
|
|| v6.is_unspecified()
|
||||||
|
|| (v6.segments()[0] & 0xfe00) == 0xfc00 // ULA fc00::/7
|
||||||
|
|| (v6.segments()[0] & 0xffc0) == 0xfe80 // link-local fe80::/10
|
||||||
|
|| (v6.segments()[0] == 0x2001 && v6.segments()[1] == 0x0db8) // documentation 2001:db8::/32
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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)]
|
||||||
|
#[path = "tests.rs"]
|
||||||
|
mod tests;
|
||||||
115
src/security/tests.rs
Normal file
115
src/security/tests.rs
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
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 rejects_ipv4_test_net() {
|
||||||
|
assert!(is_ip_private("192.0.2.1".parse().unwrap()));
|
||||||
|
assert!(is_ip_private("198.51.100.1".parse().unwrap()));
|
||||||
|
assert!(is_ip_private("203.0.113.1".parse().unwrap()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_ipv4_benchmarking() {
|
||||||
|
assert!(is_ip_private("198.18.0.1".parse().unwrap()));
|
||||||
|
assert!(is_ip_private("198.19.255.255".parse().unwrap()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_ipv4_reserved() {
|
||||||
|
assert!(is_ip_private("240.0.0.1".parse().unwrap()));
|
||||||
|
assert!(is_ip_private("255.255.255.254".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 rejects_ipv6_documentation() {
|
||||||
|
assert!(is_ip_private("2001:db8::1".parse().unwrap()));
|
||||||
|
assert!(is_ip_private("2001:db8:ffff::1".parse().unwrap()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_ipv6_mapped_private_ipv4() {
|
||||||
|
assert!(is_ip_private("::ffff:10.0.0.1".parse().unwrap()));
|
||||||
|
assert!(is_ip_private("::ffff:127.0.0.1".parse().unwrap()));
|
||||||
|
assert!(is_ip_private("::ffff:192.168.1.1".parse().unwrap()));
|
||||||
|
assert!(is_ip_private("::ffff:172.16.0.1".parse().unwrap()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn allows_ipv6_mapped_public_ipv4() {
|
||||||
|
assert!(!is_ip_private("::ffff:8.8.8.8".parse().unwrap()));
|
||||||
|
assert!(!is_ip_private("::ffff:1.1.1.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()));
|
||||||
|
}
|
||||||
1566
src/service.rs
1566
src/service.rs
File diff suppressed because it is too large
Load Diff
264
src/service/backfill.rs
Normal file
264
src/service/backfill.rs
Normal file
@@ -0,0 +1,264 @@
|
|||||||
|
use activitypub_federation::{activity_sending::SendActivityTask, 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?;
|
||||||
|
crate::tls::ensure_crypto_provider();
|
||||||
|
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", crate::urls::AP_CONTENT_TYPE)
|
||||||
|
.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(¤t_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(¤t_url)
|
||||||
|
.header("Accept", crate::urls::AP_CONTENT_TYPE)
|
||||||
|
.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 [`crate::data::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?;
|
||||||
|
let inbox = Url::parse(&follower_inbox_url)?;
|
||||||
|
|
||||||
|
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;
|
||||||
|
before = page.last().map(|item| item.published_at);
|
||||||
|
|
||||||
|
for item in &page {
|
||||||
|
let create_id = Url::parse(&format!(
|
||||||
|
"{}/activities/create/{}",
|
||||||
|
base_url,
|
||||||
|
uuid::Uuid::new_v5(&uuid::Uuid::NAMESPACE_URL, item.ap_id.as_str().as_bytes())
|
||||||
|
))?;
|
||||||
|
let create = CreateActivity {
|
||||||
|
id: create_id,
|
||||||
|
kind: Default::default(),
|
||||||
|
actor: local_actor.object_id(),
|
||||||
|
object: item.object.clone(),
|
||||||
|
to: item.to.clone(),
|
||||||
|
cc: item.cc.clone(),
|
||||||
|
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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
425
src/service/broadcast.rs
Normal file
425
src/service/broadcast.rs
Normal file
@@ -0,0 +1,425 @@
|
|||||||
|
use activitypub_federation::{protocol::context::WithContext, traits::Object};
|
||||||
|
use url::Url;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
activities::{
|
||||||
|
AddActivity, AnnounceActivity, CreateActivity, DeleteActivity, MoveActivity, UndoActivity,
|
||||||
|
UpdateActivity,
|
||||||
|
},
|
||||||
|
actors::{DbActor, get_local_actor},
|
||||||
|
data::FederationData,
|
||||||
|
user::ApVisibility,
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::ActivityPubService;
|
||||||
|
use super::types::{AddRef, AddRefObject, AnnounceRef, LikeRef, TombstoneRef};
|
||||||
|
|
||||||
|
// Re-export so existing `crate::service::broadcast::{Addressing, visibility_addressing}` paths keep working.
|
||||||
|
#[allow(unused_imports)]
|
||||||
|
pub(crate) use super::types::Addressing;
|
||||||
|
pub(crate) use super::types::visibility_addressing;
|
||||||
|
|
||||||
|
fn deterministic_activity_id(
|
||||||
|
base_url: &str,
|
||||||
|
prefix: &str,
|
||||||
|
user_id: uuid::Uuid,
|
||||||
|
object_url: &Url,
|
||||||
|
) -> anyhow::Result<Url> {
|
||||||
|
let namespace_input = format!("{}/{}", user_id, object_url);
|
||||||
|
let deterministic_id =
|
||||||
|
uuid::Uuid::new_v5(&uuid::Uuid::NAMESPACE_URL, namespace_input.as_bytes());
|
||||||
|
Ok(Url::parse(&format!(
|
||||||
|
"{}/activities/{}/{}",
|
||||||
|
base_url, prefix, deterministic_id
|
||||||
|
))?)
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ActivityPubService {
|
||||||
|
pub async fn broadcast_announce_to_followers(
|
||||||
|
&self,
|
||||||
|
local_user_id: uuid::Uuid,
|
||||||
|
object_ap_id: Url,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let announce_id =
|
||||||
|
deterministic_activity_id(&self.base_url, "announce", local_user_id, &object_ap_id)?;
|
||||||
|
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: local_actor.object_id(),
|
||||||
|
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()],
|
||||||
|
};
|
||||||
|
|
||||||
|
self.send_activity(&data, &local_actor, inboxes, announce)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn broadcast_undo_announce_to_followers(
|
||||||
|
&self,
|
||||||
|
local_user_id: uuid::Uuid,
|
||||||
|
object_ap_id: Url,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let announce_id =
|
||||||
|
deterministic_activity_id(&self.base_url, "announce", local_user_id, &object_ap_id)?;
|
||||||
|
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: data.url_scheme.activity_url(&self.base_url)?,
|
||||||
|
kind: Default::default(),
|
||||||
|
actor: local_actor.object_id(),
|
||||||
|
object: serde_json::to_value(AnnounceRef {
|
||||||
|
kind: "Announce",
|
||||||
|
id: announce_id.to_string(),
|
||||||
|
actor: local_actor.ap_id.to_string(),
|
||||||
|
object: object_ap_id.to_string(),
|
||||||
|
})?,
|
||||||
|
};
|
||||||
|
|
||||||
|
self.send_activity(&data, &local_actor, inboxes, undo).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn broadcast_like_to_inbox(
|
||||||
|
&self,
|
||||||
|
liker_user_id: uuid::Uuid,
|
||||||
|
object_ap_id: Url,
|
||||||
|
author_inbox_url: Url,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let data = self.federation_config.to_request_data();
|
||||||
|
let local_actor = get_local_actor(liker_user_id, &data).await?;
|
||||||
|
let like_id =
|
||||||
|
deterministic_activity_id(&self.base_url, "like", liker_user_id, &object_ap_id)?;
|
||||||
|
|
||||||
|
let like = crate::activities::LikeActivity {
|
||||||
|
id: like_id,
|
||||||
|
kind: Default::default(),
|
||||||
|
actor: local_actor.object_id(),
|
||||||
|
object: object_ap_id,
|
||||||
|
};
|
||||||
|
|
||||||
|
self.send_activity(&data, &local_actor, vec![author_inbox_url], like)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn broadcast_undo_like_to_inbox(
|
||||||
|
&self,
|
||||||
|
liker_user_id: uuid::Uuid,
|
||||||
|
object_ap_id: Url,
|
||||||
|
author_inbox_url: Url,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let data = self.federation_config.to_request_data();
|
||||||
|
let local_actor = get_local_actor(liker_user_id, &data).await?;
|
||||||
|
let like_id =
|
||||||
|
deterministic_activity_id(&self.base_url, "like", liker_user_id, &object_ap_id)?;
|
||||||
|
|
||||||
|
let undo = UndoActivity {
|
||||||
|
id: data.url_scheme.activity_url(&self.base_url)?,
|
||||||
|
kind: Default::default(),
|
||||||
|
actor: local_actor.object_id(),
|
||||||
|
object: serde_json::to_value(LikeRef {
|
||||||
|
kind: "Like",
|
||||||
|
id: like_id.to_string(),
|
||||||
|
actor: local_actor.ap_id.to_string(),
|
||||||
|
object: object_ap_id.to_string(),
|
||||||
|
})?,
|
||||||
|
};
|
||||||
|
|
||||||
|
self.send_activity(&data, &local_actor, vec![author_inbox_url], undo)
|
||||||
|
.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: data.url_scheme.activity_url(&self.base_url)?,
|
||||||
|
kind: Default::default(),
|
||||||
|
actor: local_actor.object_id(),
|
||||||
|
object: serde_json::to_value(TombstoneRef {
|
||||||
|
kind: "Tombstone",
|
||||||
|
id: ap_id.to_string(),
|
||||||
|
})?,
|
||||||
|
to: vec![crate::urls::AS_PUBLIC.to_string()],
|
||||||
|
cc: vec![local_actor.followers_url.to_string()],
|
||||||
|
};
|
||||||
|
|
||||||
|
self.send_activity(&data, &local_actor, inboxes, delete)
|
||||||
|
.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: local_actor.object_id(),
|
||||||
|
object,
|
||||||
|
to: vec![crate::urls::AS_PUBLIC.to_string()],
|
||||||
|
cc: vec![local_actor.followers_url.to_string()],
|
||||||
|
};
|
||||||
|
|
||||||
|
self.send_activity(&data, &local_actor, inboxes, add).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn broadcast_undo_add_to_followers(
|
||||||
|
&self,
|
||||||
|
local_user_id: uuid::Uuid,
|
||||||
|
object_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: data.url_scheme.activity_url(&self.base_url)?,
|
||||||
|
kind: Default::default(),
|
||||||
|
actor: local_actor.object_id(),
|
||||||
|
object: serde_json::to_value(AddRef {
|
||||||
|
kind: "Add",
|
||||||
|
id: object_ap_id.to_string(),
|
||||||
|
object: AddRefObject {
|
||||||
|
id: object_ap_id.to_string(),
|
||||||
|
},
|
||||||
|
})?,
|
||||||
|
};
|
||||||
|
|
||||||
|
self.send_activity(&data, &local_actor, inboxes, undo).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve the local actor, gather follower + mentioned inboxes, and compute
|
||||||
|
/// `to`/`cc` addressing. Returns `None` when visibility is `Private` or there
|
||||||
|
/// are no inboxes to deliver to.
|
||||||
|
async fn prepare_addressed_broadcast(
|
||||||
|
&self,
|
||||||
|
local_user_id: uuid::Uuid,
|
||||||
|
visibility: ApVisibility,
|
||||||
|
mentioned_inboxes: Vec<Url>,
|
||||||
|
) -> anyhow::Result<
|
||||||
|
Option<(
|
||||||
|
activitypub_federation::config::Data<FederationData>,
|
||||||
|
DbActor,
|
||||||
|
Vec<Url>,
|
||||||
|
Addressing,
|
||||||
|
)>,
|
||||||
|
> {
|
||||||
|
if visibility == ApVisibility::Private {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let data = self.federation_config.to_request_data();
|
||||||
|
let local_actor = get_local_actor(local_user_id, &data).await?;
|
||||||
|
let follower_inboxes = data
|
||||||
|
.follow_repo
|
||||||
|
.get_accepted_follower_inboxes(local_user_id)
|
||||||
|
.await?;
|
||||||
|
let inboxes = merge_inboxes(follower_inboxes, mentioned_inboxes);
|
||||||
|
if inboxes.is_empty() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let addressing = visibility_addressing(visibility, &local_actor.followers_url);
|
||||||
|
Ok(Some((data, local_actor, inboxes, addressing)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fan out a Create activity to accepted followers and any explicitly
|
||||||
|
/// mentioned actors.
|
||||||
|
///
|
||||||
|
/// `visibility` controls `to`/`cc` addressing:
|
||||||
|
/// - `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 object 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(
|
||||||
|
&self,
|
||||||
|
local_user_id: uuid::Uuid,
|
||||||
|
object: serde_json::Value,
|
||||||
|
visibility: ApVisibility,
|
||||||
|
mentioned_inboxes: Vec<Url>,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let Some((data, local_actor, inboxes, addressing)) = self
|
||||||
|
.prepare_addressed_broadcast(local_user_id, visibility, mentioned_inboxes)
|
||||||
|
.await?
|
||||||
|
else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
|
let object_id_str = object["id"].as_str().unwrap_or("");
|
||||||
|
let create_id = Url::parse(&format!(
|
||||||
|
"{}/activities/create/{}",
|
||||||
|
self.base_url,
|
||||||
|
uuid::Uuid::new_v5(&uuid::Uuid::NAMESPACE_URL, object_id_str.as_bytes())
|
||||||
|
))?;
|
||||||
|
let create = CreateActivity {
|
||||||
|
id: create_id,
|
||||||
|
kind: Default::default(),
|
||||||
|
actor: local_actor.object_id(),
|
||||||
|
object,
|
||||||
|
to: addressing.to,
|
||||||
|
cc: addressing.cc,
|
||||||
|
bto: vec![],
|
||||||
|
bcc: vec![],
|
||||||
|
};
|
||||||
|
|
||||||
|
self.send_activity(&data, &local_actor, inboxes, create)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fan out an Update activity to accepted followers and mentioned actors.
|
||||||
|
/// See [`ActivityPubService::broadcast_create`] for `mentioned_inboxes` semantics.
|
||||||
|
pub async fn broadcast_update(
|
||||||
|
&self,
|
||||||
|
local_user_id: uuid::Uuid,
|
||||||
|
object: serde_json::Value,
|
||||||
|
visibility: ApVisibility,
|
||||||
|
mentioned_inboxes: Vec<Url>,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let Some((data, local_actor, inboxes, addressing)) = self
|
||||||
|
.prepare_addressed_broadcast(local_user_id, visibility, mentioned_inboxes)
|
||||||
|
.await?
|
||||||
|
else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
|
let update = UpdateActivity {
|
||||||
|
id: data.url_scheme.activity_url(&self.base_url)?,
|
||||||
|
kind: Default::default(),
|
||||||
|
actor: local_actor.object_id(),
|
||||||
|
object,
|
||||||
|
to: addressing.to,
|
||||||
|
cc: addressing.cc,
|
||||||
|
};
|
||||||
|
|
||||||
|
self.send_activity(&data, &local_actor, inboxes, update)
|
||||||
|
.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?;
|
||||||
|
|
||||||
|
let person = local_actor.clone().into_json(&data).await?;
|
||||||
|
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: local_actor.object_id(),
|
||||||
|
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");
|
||||||
|
self.send_activity(&data, &local_actor, inboxes, update)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn broadcast_move(
|
||||||
|
&self,
|
||||||
|
user_id: uuid::Uuid,
|
||||||
|
new_actor_url: Url,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let data = self.federation_config.to_request_data();
|
||||||
|
let local_actor = get_local_actor(user_id, &data).await?;
|
||||||
|
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: data.url_scheme.activity_url(&self.base_url)?,
|
||||||
|
kind: Default::default(),
|
||||||
|
actor: local_actor.object_id(),
|
||||||
|
object: local_actor.ap_id.clone(),
|
||||||
|
target: new_actor_url.clone(),
|
||||||
|
};
|
||||||
|
|
||||||
|
self.send_activity(&data, &local_actor, inboxes, move_activity)
|
||||||
|
.await?;
|
||||||
|
tracing::info!(%user_id, target = %new_actor_url, "broadcast_move: dispatched");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
/// Broadcast a pre-built activity to all accepted followers.
|
||||||
|
///
|
||||||
|
/// This is the low-level escape hatch for custom activity types that
|
||||||
|
/// k-ap doesn't have a dedicated method for. The `activity` JSON must
|
||||||
|
/// be a complete AP activity with `id`, `type`, `actor`, etc. already set.
|
||||||
|
/// k-ap wraps it in `@context` and handles signing + delivery.
|
||||||
|
pub async fn broadcast_raw_to_followers(
|
||||||
|
&self,
|
||||||
|
local_user_id: uuid::Uuid,
|
||||||
|
activity: 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(());
|
||||||
|
};
|
||||||
|
|
||||||
|
self.send_raw_activity(&data, &local_actor, inboxes, activity)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn merge_inboxes(follower_inboxes: Vec<String>, mentioned_inboxes: Vec<Url>) -> Vec<Url> {
|
||||||
|
let mut seen = std::collections::HashSet::new();
|
||||||
|
let mut inboxes: Vec<Url> = follower_inboxes
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|inbox_str| Url::parse(&inbox_str).ok())
|
||||||
|
.filter(|url| seen.insert(url.to_string()))
|
||||||
|
.collect();
|
||||||
|
for inbox in mentioned_inboxes {
|
||||||
|
if seen.insert(inbox.to_string()) {
|
||||||
|
inboxes.push(inbox);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
inboxes
|
||||||
|
}
|
||||||
219
src/service/builder.rs
Normal file
219
src/service/builder.rs
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
content::{ApContentReader, ApObjectHandler},
|
||||||
|
data::FederationData,
|
||||||
|
federation::ApFederationConfig,
|
||||||
|
repository::{ActivityRepository, ActorRepository, BlocklistRepository, FollowRepository},
|
||||||
|
url_scheme::{DefaultUrlScheme, UrlScheme},
|
||||||
|
user::ApUserRepository,
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
ACTOR_CACHE_TTL_SECS, ActivityPubService, DELIVERY_INITIAL_DELAY_SECS, DELIVERY_MAX_ATTEMPTS,
|
||||||
|
};
|
||||||
|
|
||||||
|
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,
|
||||||
|
url_scheme: Option<Arc<dyn UrlScheme>>,
|
||||||
|
nodeinfo_services_inbound: Vec<String>,
|
||||||
|
nodeinfo_services_outbound: Vec<String>,
|
||||||
|
nodeinfo_metadata: serde_json::Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ActivityPubServiceBuilder {
|
||||||
|
pub(super) fn new(base_url: String) -> Self {
|
||||||
|
Self {
|
||||||
|
activity_repo: None,
|
||||||
|
follow_repo: None,
|
||||||
|
actor_repo: None,
|
||||||
|
blocklist_repo: None,
|
||||||
|
user_repo: None,
|
||||||
|
content_reader: None,
|
||||||
|
object_handler: None,
|
||||||
|
base_url,
|
||||||
|
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,
|
||||||
|
url_scheme: None,
|
||||||
|
nodeinfo_services_inbound: vec![],
|
||||||
|
nodeinfo_services_outbound: vec![],
|
||||||
|
nodeinfo_metadata: serde_json::json!({}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn activity_repo(mut self, activity_repo: Arc<dyn ActivityRepository>) -> Self {
|
||||||
|
self.activity_repo = Some(activity_repo);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
pub fn follow_repo(mut self, follow_repo: Arc<dyn FollowRepository>) -> Self {
|
||||||
|
self.follow_repo = Some(follow_repo);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
pub fn actor_repo(mut self, actor_repo: Arc<dyn ActorRepository>) -> Self {
|
||||||
|
self.actor_repo = Some(actor_repo);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
pub fn blocklist_repo(mut self, blocklist_repo: Arc<dyn BlocklistRepository>) -> Self {
|
||||||
|
self.blocklist_repo = Some(blocklist_repo);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
pub fn user_repo(mut self, user_repo: Arc<dyn ApUserRepository>) -> Self {
|
||||||
|
self.user_repo = Some(user_repo);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
pub fn content_reader(mut self, content_reader: Arc<dyn ApContentReader>) -> Self {
|
||||||
|
self.content_reader = Some(content_reader);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
pub fn object_handler(mut self, object_handler: Arc<dyn ApObjectHandler>) -> Self {
|
||||||
|
self.object_handler = Some(object_handler);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
pub fn allow_registration(mut self, allow_registration: bool) -> Self {
|
||||||
|
self.allow_registration = allow_registration;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
pub fn software_name(mut self, software_name: impl Into<String>) -> Self {
|
||||||
|
self.software_name = software_name.into();
|
||||||
|
self
|
||||||
|
}
|
||||||
|
pub fn debug(mut self, debug: bool) -> Self {
|
||||||
|
self.debug = debug;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
pub fn event_publisher(
|
||||||
|
mut self,
|
||||||
|
event_publisher: Arc<dyn crate::data::EventPublisher>,
|
||||||
|
) -> Self {
|
||||||
|
self.event_publisher = Some(event_publisher);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
pub fn delivery_max_attempts(mut self, delivery_max_attempts: u32) -> Self {
|
||||||
|
self.delivery_max_attempts = delivery_max_attempts;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
pub fn delivery_initial_delay_secs(mut self, delivery_initial_delay_secs: u64) -> Self {
|
||||||
|
self.delivery_initial_delay_secs = delivery_initial_delay_secs;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn actor_cache_ttl_secs(mut self, actor_cache_ttl_secs: u64) -> Self {
|
||||||
|
self.actor_cache_ttl_secs = actor_cache_ttl_secs;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn nodeinfo_services(mut self, inbound: Vec<String>, outbound: Vec<String>) -> Self {
|
||||||
|
self.nodeinfo_services_inbound = inbound;
|
||||||
|
self.nodeinfo_services_outbound = outbound;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn nodeinfo_metadata(mut self, metadata: serde_json::Value) -> Self {
|
||||||
|
self.nodeinfo_metadata = metadata;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Override the default `/users/{uuid}` URL scheme. Consumers with custom
|
||||||
|
/// actor paths should implement [`UrlScheme`] and pass it here.
|
||||||
|
pub fn url_scheme(mut self, url_scheme: Arc<dyn UrlScheme>) -> Self {
|
||||||
|
self.url_scheme = Some(url_scheme);
|
||||||
|
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, signed_fetch_actor_id: uuid::Uuid) -> Self {
|
||||||
|
self.signed_fetch_actor_id = Some(signed_fetch_actor_id);
|
||||||
|
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 url_scheme = self
|
||||||
|
.url_scheme
|
||||||
|
.unwrap_or_else(|| Arc::new(DefaultUrlScheme));
|
||||||
|
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),
|
||||||
|
url_scheme,
|
||||||
|
)
|
||||||
|
.with_nodeinfo_services(
|
||||||
|
self.nodeinfo_services_inbound,
|
||||||
|
self.nodeinfo_services_outbound,
|
||||||
|
)
|
||||||
|
.with_nodeinfo_metadata(self.nodeinfo_metadata);
|
||||||
|
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(),
|
||||||
|
data.url_scheme.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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
107
src/service/collections.rs
Normal file
107
src/service/collections.rs
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
use activitypub_federation::{protocol::context::WithContext, traits::Object};
|
||||||
|
|
||||||
|
use crate::actors::get_local_actor;
|
||||||
|
|
||||||
|
use super::ActivityPubService;
|
||||||
|
|
||||||
|
impl ActivityPubService {
|
||||||
|
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?;
|
||||||
|
let person = actor.into_json(&data).await?;
|
||||||
|
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> {
|
||||||
|
let data = self.federation_config.to_request_data();
|
||||||
|
let actor_url = data.url_scheme.actor_url(&self.base_url, user_id)?;
|
||||||
|
let collection_url = data.url_scheme.followers_url(&actor_url)?.to_string();
|
||||||
|
let total = data.follow_repo.count_followers(user_id).await?;
|
||||||
|
let items_fn = |offset: u32, limit: usize| {
|
||||||
|
let data = data.clone();
|
||||||
|
async move {
|
||||||
|
Ok(data
|
||||||
|
.follow_repo
|
||||||
|
.get_followers_page(user_id, offset, limit)
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.map(|follower| follower.actor.url)
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
};
|
||||||
|
serialize_ordered_collection(&collection_url, total, page, items_fn).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn following_collection_json(
|
||||||
|
&self,
|
||||||
|
user_id: uuid::Uuid,
|
||||||
|
page: Option<u32>,
|
||||||
|
) -> anyhow::Result<String> {
|
||||||
|
let data = self.federation_config.to_request_data();
|
||||||
|
let actor_url = data.url_scheme.actor_url(&self.base_url, user_id)?;
|
||||||
|
let collection_url = data.url_scheme.following_url(&actor_url)?.to_string();
|
||||||
|
let total = data.follow_repo.count_following(user_id).await?;
|
||||||
|
let items_fn = |offset: u32, limit: usize| {
|
||||||
|
let data = data.clone();
|
||||||
|
async move {
|
||||||
|
Ok(data
|
||||||
|
.follow_repo
|
||||||
|
.get_following_page(user_id, offset, limit)
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.map(|actor| actor.url)
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
};
|
||||||
|
serialize_ordered_collection(&collection_url, total, page, items_fn).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn serialize_ordered_collection<F, Fut>(
|
||||||
|
collection_url: &str,
|
||||||
|
total: usize,
|
||||||
|
page: Option<u32>,
|
||||||
|
fetch_items: F,
|
||||||
|
) -> anyhow::Result<String>
|
||||||
|
where
|
||||||
|
F: FnOnce(u32, usize) -> Fut,
|
||||||
|
Fut: std::future::Future<Output = anyhow::Result<Vec<String>>>,
|
||||||
|
{
|
||||||
|
use crate::urls::{AP_CONTEXT, AP_PAGE_SIZE};
|
||||||
|
|
||||||
|
let json = if let Some(page_number) = page {
|
||||||
|
let page_number = page_number.max(1);
|
||||||
|
let offset = (page_number.saturating_sub(1) as usize) * AP_PAGE_SIZE;
|
||||||
|
let items = fetch_items(offset as u32, AP_PAGE_SIZE).await?;
|
||||||
|
let has_next = offset + items.len() < total;
|
||||||
|
let mut obj = serde_json::json!({
|
||||||
|
"@context": AP_CONTEXT,
|
||||||
|
"type": "OrderedCollectionPage",
|
||||||
|
"id": format!("{}?page={}", collection_url, page_number),
|
||||||
|
"partOf": collection_url,
|
||||||
|
"totalItems": total,
|
||||||
|
"orderedItems": items,
|
||||||
|
});
|
||||||
|
if has_next {
|
||||||
|
obj["next"] = serde_json::json!(format!("{}?page={}", collection_url, page_number + 1));
|
||||||
|
}
|
||||||
|
obj
|
||||||
|
} else {
|
||||||
|
serde_json::json!({
|
||||||
|
"@context": AP_CONTEXT,
|
||||||
|
"type": "OrderedCollection",
|
||||||
|
"id": collection_url,
|
||||||
|
"totalItems": total,
|
||||||
|
"first": format!("{}?page=1", collection_url),
|
||||||
|
})
|
||||||
|
};
|
||||||
|
Ok(serde_json::to_string(&json)?)
|
||||||
|
}
|
||||||
232
src/service/delivery.rs
Normal file
232
src/service/delivery.rs
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
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> {
|
||||||
|
tracing::info!(
|
||||||
|
inbox_count = sends.len(),
|
||||||
|
max_attempts,
|
||||||
|
"starting outbound delivery"
|
||||||
|
);
|
||||||
|
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 {
|
||||||
|
/// Dispatch pre-built `SendActivityTask`s via the event publisher (if configured)
|
||||||
|
/// or by spawning a background retry loop.
|
||||||
|
fn dispatch_sends(
|
||||||
|
&self,
|
||||||
|
data: &activitypub_federation::config::Data<FederationData>,
|
||||||
|
local_actor: &DbActor,
|
||||||
|
inboxes: Vec<Url>,
|
||||||
|
sends: Vec<SendActivityTask>,
|
||||||
|
activity_json: serde_json::Value,
|
||||||
|
) {
|
||||||
|
if let Some(publisher) = data.event_publisher.as_ref() {
|
||||||
|
let publisher = publisher.clone();
|
||||||
|
let signing_actor_id = local_actor.user_id;
|
||||||
|
let activity = activity_json;
|
||||||
|
tokio::spawn(async move {
|
||||||
|
for inbox in inboxes {
|
||||||
|
let event = FederationEvent::DeliveryRequested {
|
||||||
|
inbox,
|
||||||
|
activity: activity.clone(),
|
||||||
|
signing_actor_id,
|
||||||
|
};
|
||||||
|
if let Err(error) = publisher.publish(event).await {
|
||||||
|
tracing::warn!(%error, "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");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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?;
|
||||||
|
|
||||||
|
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 Err(error) = publisher
|
||||||
|
.publish(FederationEvent::DeliveryFailed {
|
||||||
|
inbox,
|
||||||
|
activity,
|
||||||
|
signing_actor_id,
|
||||||
|
error: error_msg.clone(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
tracing::warn!(%error, "failed to publish DeliveryFailed event");
|
||||||
|
}
|
||||||
|
Err(anyhow::anyhow!("delivery failed: {}", error_msg))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn send_activity<A>(
|
||||||
|
&self,
|
||||||
|
data: &activitypub_federation::config::Data<FederationData>,
|
||||||
|
local_actor: &DbActor,
|
||||||
|
inboxes: Vec<Url>,
|
||||||
|
activity: A,
|
||||||
|
) -> anyhow::Result<()>
|
||||||
|
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?;
|
||||||
|
self.dispatch_sends(data, local_actor, inboxes, sends, activity_json);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn send_raw_activity(
|
||||||
|
&self,
|
||||||
|
data: &activitypub_federation::config::Data<FederationData>,
|
||||||
|
local_actor: &DbActor,
|
||||||
|
inboxes: Vec<Url>,
|
||||||
|
activity: serde_json::Value,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let id = activity
|
||||||
|
.get("id")
|
||||||
|
.and_then(|value| value.as_str())
|
||||||
|
.and_then(|id_str| Url::parse(id_str).ok())
|
||||||
|
.unwrap_or_else(|| local_actor.ap_id.clone());
|
||||||
|
let actor_url = activity
|
||||||
|
.get("actor")
|
||||||
|
.and_then(|value| value.as_str())
|
||||||
|
.and_then(|actor_str| Url::parse(actor_str).ok())
|
||||||
|
.unwrap_or_else(|| local_actor.ap_id.clone());
|
||||||
|
|
||||||
|
let raw = RawActivity {
|
||||||
|
id,
|
||||||
|
actor_url,
|
||||||
|
value: activity.clone(),
|
||||||
|
};
|
||||||
|
let sends = SendActivityTask::prepare(&raw, local_actor, inboxes.clone(), data).await?;
|
||||||
|
self.dispatch_sends(data, local_actor, inboxes, sends, activity);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
72
src/service/fetch.rs
Normal file
72
src/service/fetch.rs
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
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?;
|
||||||
|
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_else(|| {
|
||||||
|
tracing::debug!(actor_url, "fetched_at is None, treating as stale — consider populating fetched_at in get_remote_actor()");
|
||||||
|
false
|
||||||
|
});
|
||||||
|
tracing::debug!(
|
||||||
|
actor_url,
|
||||||
|
cache_hit = true,
|
||||||
|
fresh = is_fresh,
|
||||||
|
"actor cache lookup"
|
||||||
|
);
|
||||||
|
if is_fresh {
|
||||||
|
return Ok(cached);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
tracing::debug!(actor_url, cache_hit = false, "actor cache lookup");
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
446
src/service/follow.rs
Normal file
446
src/service/follow.rs
Normal file
@@ -0,0 +1,446 @@
|
|||||||
|
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},
|
||||||
|
};
|
||||||
|
|
||||||
|
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?;
|
||||||
|
let follow_id = data.url_scheme.activity_url(&self.base_url)?;
|
||||||
|
let follow_id_str = follow_id.to_string();
|
||||||
|
let remote = RemoteActor::from(&remote_actor);
|
||||||
|
|
||||||
|
// 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: local_actor.object_id(),
|
||||||
|
object: ObjectId::from(remote_actor.ap_id.clone()),
|
||||||
|
};
|
||||||
|
|
||||||
|
self.send_activity(&data, &local_actor, vec![remote_actor.inbox()], follow)
|
||||||
|
.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?;
|
||||||
|
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(|| {
|
||||||
|
data.url_scheme
|
||||||
|
.activity_url(&self.base_url)
|
||||||
|
.unwrap_or_else(|_| remote_ap_id.clone())
|
||||||
|
});
|
||||||
|
|
||||||
|
let follow = FollowActivity {
|
||||||
|
id: follow_id,
|
||||||
|
kind: Default::default(),
|
||||||
|
actor: local_actor.object_id(),
|
||||||
|
object: ObjectId::from(remote_ap_id),
|
||||||
|
};
|
||||||
|
let undo = UndoActivity {
|
||||||
|
id: data.url_scheme.activity_url(&self.base_url)?,
|
||||||
|
kind: Default::default(),
|
||||||
|
actor: local_actor.object_id(),
|
||||||
|
object: serde_json::to_value(&follow)?,
|
||||||
|
};
|
||||||
|
|
||||||
|
self.send_activity(&data, &local_actor, vec![inbox], undo)
|
||||||
|
.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?;
|
||||||
|
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: local_actor.object_id(),
|
||||||
|
};
|
||||||
|
let accept = AcceptActivity {
|
||||||
|
id: data.url_scheme.activity_url(&self.base_url)?,
|
||||||
|
kind: Default::default(),
|
||||||
|
actor: local_actor.object_id(),
|
||||||
|
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)?;
|
||||||
|
self.send_activity(&data, &local_actor, vec![inbox], accept)
|
||||||
|
.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?;
|
||||||
|
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: data.url_scheme.activity_url(&self.base_url)?,
|
||||||
|
kind: Default::default(),
|
||||||
|
actor: ObjectId::from(Url::parse(remote_actor_url)?),
|
||||||
|
object: local_actor.object_id(),
|
||||||
|
};
|
||||||
|
let reject = RejectActivity {
|
||||||
|
id: data.url_scheme.activity_url(&self.base_url)?,
|
||||||
|
kind: Default::default(),
|
||||||
|
actor: local_actor.object_id(),
|
||||||
|
object: follow,
|
||||||
|
};
|
||||||
|
|
||||||
|
let inbox = Url::parse(&remote_actor.inbox_url)?;
|
||||||
|
self.send_activity(&data, &local_actor, vec![inbox], reject)
|
||||||
|
.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(|follower| follower.status == FollowerStatus::Accepted)
|
||||||
|
.map(|follower| follower.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?;
|
||||||
|
if let Err(error) = data
|
||||||
|
.follow_repo
|
||||||
|
.remove_follower(local_user_id, actor_url)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
tracing::debug!(%error, "follower already removed");
|
||||||
|
}
|
||||||
|
if let Err(error) = data
|
||||||
|
.follow_repo
|
||||||
|
.remove_following(local_user_id, actor_url)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
tracing::debug!(%error, "following already removed");
|
||||||
|
}
|
||||||
|
|
||||||
|
let local_actor = get_local_actor(local_user_id, &data).await?;
|
||||||
|
if let Ok(Some(remote_actor)) = data.actor_repo.get_remote_actor(actor_url).await {
|
||||||
|
let block = crate::activities::BlockActivity {
|
||||||
|
id: data.url_scheme.activity_url(&self.base_url)?,
|
||||||
|
kind: Default::default(),
|
||||||
|
actor: local_actor.object_id(),
|
||||||
|
object: Url::parse(actor_url)?,
|
||||||
|
};
|
||||||
|
|
||||||
|
let inbox = Url::parse(&remote_actor.inbox_url)?;
|
||||||
|
self.send_activity(&data, &local_actor, vec![inbox], block)
|
||||||
|
.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?;
|
||||||
|
|
||||||
|
let local_actor = get_local_actor(local_user_id, &data).await?;
|
||||||
|
if let Ok(Some(remote_actor)) = data.actor_repo.get_remote_actor(actor_url).await {
|
||||||
|
let block = crate::activities::BlockActivity {
|
||||||
|
id: data.url_scheme.activity_url(&self.base_url)?,
|
||||||
|
kind: Default::default(),
|
||||||
|
actor: local_actor.object_id(),
|
||||||
|
object: Url::parse(actor_url)?,
|
||||||
|
};
|
||||||
|
let undo = UndoActivity {
|
||||||
|
id: data.url_scheme.activity_url(&self.base_url)?,
|
||||||
|
kind: Default::default(),
|
||||||
|
actor: local_actor.object_id(),
|
||||||
|
object: serde_json::to_value(&block)?,
|
||||||
|
};
|
||||||
|
|
||||||
|
let inbox = Url::parse(&remote_actor.inbox_url)?;
|
||||||
|
self.send_activity(&data, &local_actor, vec![inbox], undo)
|
||||||
|
.await?;
|
||||||
|
tracing::info!(actor = %actor_url, "sent Undo(Block)");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
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(cached)) => cached,
|
||||||
|
_ => RemoteActor::placeholder(url),
|
||||||
|
};
|
||||||
|
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 = data
|
||||||
|
.url_scheme
|
||||||
|
.actor_url(&self.base_url, local_user_id)?
|
||||||
|
.to_string();
|
||||||
|
let target_actor_url = data.url_scheme.actor_url(&self.base_url, target.id)?;
|
||||||
|
let follow_id = data.url_scheme.activity_url(&self.base_url)?.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: data.url_scheme.inbox_url(&target_actor_url)?.to_string(),
|
||||||
|
shared_inbox_url: None,
|
||||||
|
display_name: target.display_name.or(Some(target.username)),
|
||||||
|
avatar_url: target.avatar_url.as_ref().map(|url| url.to_string()),
|
||||||
|
outbox_url: Some(data.url_scheme.outbox_url(&target_actor_url)?.to_string()),
|
||||||
|
bio: target.bio,
|
||||||
|
banner_url: target.banner_url.as_ref().map(|url| url.to_string()),
|
||||||
|
followers_url: Some(
|
||||||
|
data.url_scheme
|
||||||
|
.followers_url(&target_actor_url)?
|
||||||
|
.to_string(),
|
||||||
|
),
|
||||||
|
following_url: Some(
|
||||||
|
data.url_scheme
|
||||||
|
.following_url(&target_actor_url)?
|
||||||
|
.to_string(),
|
||||||
|
),
|
||||||
|
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 = data
|
||||||
|
.url_scheme
|
||||||
|
.extract_user_id(&target_url)
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("invalid local actor URL: {}", target_actor_url))?;
|
||||||
|
let local_actor_url = data
|
||||||
|
.url_scheme
|
||||||
|
.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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
153
src/service/lookup.rs
Normal file
153
src/service/lookup.rs
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
use url::Url;
|
||||||
|
|
||||||
|
use crate::{actors::DbActor, data::FederationData, repository::BlockedDomain};
|
||||||
|
|
||||||
|
use super::ActivityPubService;
|
||||||
|
|
||||||
|
struct ParsedHandle<'a> {
|
||||||
|
username: &'a str,
|
||||||
|
domain: &'a str,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_handle(handle: &str) -> anyhow::Result<ParsedHandle<'_>> {
|
||||||
|
let normalized = handle.trim_start_matches('@');
|
||||||
|
let separator_index = normalized
|
||||||
|
.rfind('@')
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("handle must be user@domain"))?;
|
||||||
|
Ok(ParsedHandle {
|
||||||
|
username: &normalized[..separator_index],
|
||||||
|
domain: &normalized[separator_index + 1..],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn webfinger_url(handle: &ParsedHandle<'_>) -> String {
|
||||||
|
format!(
|
||||||
|
"https://{}/.well-known/webfinger?resource=acct:{}@{}",
|
||||||
|
handle.domain, handle.username, handle.domain
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fetch_webfinger(url: &str) -> anyhow::Result<serde_json::Value> {
|
||||||
|
let parsed = Url::parse(url)?;
|
||||||
|
crate::security::validate_url(&parsed).await?;
|
||||||
|
crate::tls::ensure_crypto_provider();
|
||||||
|
Ok(reqwest::Client::new()
|
||||||
|
.get(url)
|
||||||
|
.header("Accept", "application/jrd+json, application/json")
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.json()
|
||||||
|
.await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_actor_href(webfinger: &serde_json::Value) -> anyhow::Result<String> {
|
||||||
|
webfinger["links"]
|
||||||
|
.as_array()
|
||||||
|
.and_then(|links| {
|
||||||
|
links.iter().find(|link| {
|
||||||
|
link["rel"].as_str() == Some("self")
|
||||||
|
&& link["type"].as_str() == Some(crate::urls::AP_CONTENT_TYPE)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.and_then(|link| link["href"].as_str())
|
||||||
|
.map(|href| href.to_owned())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("no self link in WebFinger response"))
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ActivityPubService {
|
||||||
|
// ── Pass-through wrappers ───────────────────────────────────────────
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── WebFinger / actor resolution ────────────────────────────────────
|
||||||
|
|
||||||
|
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(|error| tracing::warn!(handle, %error, "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(super) async fn webfinger_https(
|
||||||
|
&self,
|
||||||
|
handle: &str,
|
||||||
|
data: &activitypub_federation::config::Data<FederationData>,
|
||||||
|
) -> anyhow::Result<DbActor> {
|
||||||
|
let parsed_handle = parse_handle(handle)?;
|
||||||
|
let url = webfinger_url(&parsed_handle);
|
||||||
|
tracing::debug!(handle, webfinger_url = %url, "resolving webfinger");
|
||||||
|
|
||||||
|
let webfinger_response = fetch_webfinger(&url).await?;
|
||||||
|
let actor_href = extract_actor_href(&webfinger_response)?;
|
||||||
|
|
||||||
|
tracing::debug!(handle, actor_href, "webfinger resolved, fetching actor");
|
||||||
|
let actor: DbActor =
|
||||||
|
activitypub_federation::fetch::object_id::ObjectId::from(Url::parse(&actor_href)?)
|
||||||
|
.dereference(data)
|
||||||
|
.await?;
|
||||||
|
Ok(actor)
|
||||||
|
}
|
||||||
|
}
|
||||||
119
src/service/mod.rs
Normal file
119
src/service/mod.rs
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
use url::Url;
|
||||||
|
|
||||||
|
use axum::{Router, extract::DefaultBodyLimit, routing::get, routing::post};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
actors::{DbActor, get_local_actor},
|
||||||
|
data::FederationData,
|
||||||
|
federation::ApFederationConfig,
|
||||||
|
handlers::{
|
||||||
|
featured::featured_handler,
|
||||||
|
inbox::inbox_handler,
|
||||||
|
nodeinfo::{nodeinfo_handler, nodeinfo_well_known_handler},
|
||||||
|
outbox::outbox_handler,
|
||||||
|
webfinger::webfinger_handler,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
mod backfill;
|
||||||
|
pub(crate) mod broadcast;
|
||||||
|
mod builder;
|
||||||
|
pub(crate) mod collections;
|
||||||
|
pub(super) mod delivery;
|
||||||
|
mod fetch;
|
||||||
|
mod follow;
|
||||||
|
mod lookup;
|
||||||
|
pub(crate) mod types;
|
||||||
|
|
||||||
|
pub use builder::ActivityPubServiceBuilder;
|
||||||
|
|
||||||
|
/// 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,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ActivityPubService {
|
||||||
|
pub fn builder(base_url: impl Into<String>) -> ActivityPubServiceBuilder {
|
||||||
|
ActivityPubServiceBuilder::new(base_url.into())
|
||||||
|
}
|
||||||
|
|
||||||
|
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(crate::urls::INBOX_BODY_LIMIT)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/users/{id}/inbox",
|
||||||
|
post(inbox_handler).layer(DefaultBodyLimit::max(crate::urls::INBOX_BODY_LIMIT)),
|
||||||
|
)
|
||||||
|
.route("/users/{id}/outbox", get(outbox_handler))
|
||||||
|
.route("/users/{id}/featured", get(featured_handler))
|
||||||
|
.layer(self.federation_config.middleware())
|
||||||
|
}
|
||||||
|
|
||||||
|
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?;
|
||||||
|
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(|inbox_str| {
|
||||||
|
Url::parse(&inbox_str).map_err(|e| tracing::warn!(inbox = %inbox_str, error = %e, "skipping unparseable inbox URL")).ok()
|
||||||
|
}).collect();
|
||||||
|
if inboxes.is_empty() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
Ok(Some((local_actor, inboxes)))
|
||||||
|
}
|
||||||
|
}
|
||||||
63
src/service/types.rs
Normal file
63
src/service/types.rs
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
use url::Url;
|
||||||
|
|
||||||
|
use crate::user::ApVisibility;
|
||||||
|
|
||||||
|
pub(crate) struct Addressing {
|
||||||
|
pub to: Vec<String>,
|
||||||
|
pub cc: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn visibility_addressing(visibility: ApVisibility, followers_url: &Url) -> Addressing {
|
||||||
|
match visibility {
|
||||||
|
ApVisibility::Public => Addressing {
|
||||||
|
to: vec![crate::urls::AS_PUBLIC.to_string()],
|
||||||
|
cc: vec![followers_url.to_string()],
|
||||||
|
},
|
||||||
|
ApVisibility::FollowersOnly => Addressing {
|
||||||
|
to: vec![followers_url.to_string()],
|
||||||
|
cc: vec![],
|
||||||
|
},
|
||||||
|
ApVisibility::Private => Addressing {
|
||||||
|
to: vec![],
|
||||||
|
cc: vec![],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Serialize)]
|
||||||
|
pub(super) struct AnnounceRef {
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
pub kind: &'static str,
|
||||||
|
pub id: String,
|
||||||
|
pub actor: String,
|
||||||
|
pub object: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Serialize)]
|
||||||
|
pub(super) struct LikeRef {
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
pub kind: &'static str,
|
||||||
|
pub id: String,
|
||||||
|
pub actor: String,
|
||||||
|
pub object: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Serialize)]
|
||||||
|
pub(super) struct TombstoneRef {
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
pub kind: &'static str,
|
||||||
|
pub id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Serialize)]
|
||||||
|
pub(super) struct AddRef {
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
pub kind: &'static str,
|
||||||
|
pub id: String,
|
||||||
|
pub object: AddRefObject,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Serialize)]
|
||||||
|
pub(super) struct AddRefObject {
|
||||||
|
pub id: String,
|
||||||
|
}
|
||||||
312
src/testing.rs
Normal file
312
src/testing.rs
Normal file
@@ -0,0 +1,312 @@
|
|||||||
|
//! Mock builders for testing all k-ap traits.
|
||||||
|
//!
|
||||||
|
//! **Not behind `#[cfg(test)]`** so downstream consumers (e.g. movies-diary)
|
||||||
|
//! can use these mocks in their own test suites.
|
||||||
|
//!
|
||||||
|
//! # Usage
|
||||||
|
//!
|
||||||
|
//! ```ignore
|
||||||
|
//! let follow_repo = MockFollowRepoBuilder::new()
|
||||||
|
//! .on_add_follower(|id, url, status, _| {
|
||||||
|
//! // custom assertion / tracking
|
||||||
|
//! Ok(())
|
||||||
|
//! })
|
||||||
|
//! .build();
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
|
||||||
|
use url::Url;
|
||||||
|
|
||||||
|
use crate::content::{ApContentReader, ApObjectHandler, LocalObject};
|
||||||
|
use crate::data::EventPublisher;
|
||||||
|
use crate::data::FederationEvent;
|
||||||
|
use crate::repository::{
|
||||||
|
ActivityRepository, ActorBlocklist, AnnounceRepository, BlockedDomain, DomainBlocklist,
|
||||||
|
FollowMigration, Follower, FollowerReader, FollowerStatus, FollowerWriter, FollowingReader,
|
||||||
|
FollowingStatus, FollowingWriter, Keypair, KeypairRepository, RemoteActor, RemoteActorCache,
|
||||||
|
};
|
||||||
|
use crate::user::{ApUser, ApUserRepository};
|
||||||
|
|
||||||
|
/// Generate a mock struct + builder + trait impls from a compact spec.
|
||||||
|
///
|
||||||
|
/// Each method stores a `Box<dyn Fn(…) -> anyhow::Result<Ret>>` closure.
|
||||||
|
/// The builder defaults every unset method to `Ok(Default::default())`.
|
||||||
|
macro_rules! mock_repo {
|
||||||
|
(
|
||||||
|
$mock:ident, $builder:ident {
|
||||||
|
$(
|
||||||
|
trait $trait_name:ident {
|
||||||
|
$(
|
||||||
|
fn $method:ident( $( $pname:ident : $pty:ty ),* $(,)? ) -> $ret:ty;
|
||||||
|
)*
|
||||||
|
}
|
||||||
|
)*
|
||||||
|
}
|
||||||
|
) => {
|
||||||
|
pub struct $mock {
|
||||||
|
$($(
|
||||||
|
$method: Box<dyn Fn($($pty),*) -> anyhow::Result<$ret> + Send + Sync>,
|
||||||
|
)*)*
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct $builder {
|
||||||
|
$($(
|
||||||
|
$method: Option<Box<dyn Fn($($pty),*) -> anyhow::Result<$ret> + Send + Sync>>,
|
||||||
|
)*)*
|
||||||
|
}
|
||||||
|
|
||||||
|
impl $builder {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
$($(
|
||||||
|
$method: None,
|
||||||
|
)*)*
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
paste::paste! {
|
||||||
|
$($(
|
||||||
|
pub fn [<on_ $method>](
|
||||||
|
mut self,
|
||||||
|
f: impl Fn($($pty),*) -> anyhow::Result<$ret> + Send + Sync + 'static,
|
||||||
|
) -> Self {
|
||||||
|
self.$method = Some(Box::new(f));
|
||||||
|
self
|
||||||
|
}
|
||||||
|
)*)*
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn build(self) -> Arc<$mock> {
|
||||||
|
Arc::new($mock {
|
||||||
|
$($(
|
||||||
|
$method: self.$method.unwrap_or_else(||
|
||||||
|
Box::new(|$(_: $pty),*| Ok(Default::default()))
|
||||||
|
),
|
||||||
|
)*)*
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for $builder {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$(
|
||||||
|
#[async_trait]
|
||||||
|
impl $trait_name for $mock {
|
||||||
|
$(
|
||||||
|
async fn $method(&self, $($pname: $pty),*) -> anyhow::Result<$ret> {
|
||||||
|
(self.$method)($($pname),*)
|
||||||
|
}
|
||||||
|
)*
|
||||||
|
}
|
||||||
|
)*
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
// MockFollowRepo
|
||||||
|
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
|
||||||
|
mock_repo! {
|
||||||
|
MockFollowRepo, MockFollowRepoBuilder {
|
||||||
|
trait FollowerWriter {
|
||||||
|
fn add_follower(
|
||||||
|
local_user_id: uuid::Uuid,
|
||||||
|
remote_actor_url: &str,
|
||||||
|
status: FollowerStatus,
|
||||||
|
follow_activity_id: &str
|
||||||
|
) -> ();
|
||||||
|
fn get_follower_follow_activity_id(
|
||||||
|
local_user_id: uuid::Uuid,
|
||||||
|
remote_actor_url: &str
|
||||||
|
) -> Option<String>;
|
||||||
|
fn remove_follower(
|
||||||
|
local_user_id: uuid::Uuid,
|
||||||
|
remote_actor_url: &str
|
||||||
|
) -> ();
|
||||||
|
fn update_follower_status(
|
||||||
|
local_user_id: uuid::Uuid,
|
||||||
|
remote_actor_url: &str,
|
||||||
|
status: FollowerStatus
|
||||||
|
) -> ();
|
||||||
|
}
|
||||||
|
trait FollowerReader {
|
||||||
|
fn get_followers(local_user_id: uuid::Uuid) -> Vec<Follower>;
|
||||||
|
fn get_followers_page(
|
||||||
|
local_user_id: uuid::Uuid,
|
||||||
|
offset: u32,
|
||||||
|
limit: usize
|
||||||
|
) -> Vec<Follower>;
|
||||||
|
fn count_followers(local_user_id: uuid::Uuid) -> usize;
|
||||||
|
fn get_pending_followers(local_user_id: uuid::Uuid) -> Vec<RemoteActor>;
|
||||||
|
fn get_accepted_follower_inboxes(local_user_id: uuid::Uuid) -> Vec<String>;
|
||||||
|
fn count_accepted_followers(local_user_id: uuid::Uuid) -> usize;
|
||||||
|
fn get_accepted_followers_page(
|
||||||
|
local_user_id: uuid::Uuid,
|
||||||
|
offset: u32,
|
||||||
|
limit: usize
|
||||||
|
) -> Vec<RemoteActor>;
|
||||||
|
}
|
||||||
|
trait FollowingWriter {
|
||||||
|
fn add_following(
|
||||||
|
local_user_id: uuid::Uuid,
|
||||||
|
actor: RemoteActor,
|
||||||
|
follow_activity_id: &str
|
||||||
|
) -> ();
|
||||||
|
fn get_follow_activity_id(
|
||||||
|
local_user_id: uuid::Uuid,
|
||||||
|
remote_actor_url: &str
|
||||||
|
) -> Option<String>;
|
||||||
|
fn remove_following(
|
||||||
|
local_user_id: uuid::Uuid,
|
||||||
|
actor_url: &str
|
||||||
|
) -> ();
|
||||||
|
fn update_following_status(
|
||||||
|
local_user_id: uuid::Uuid,
|
||||||
|
remote_actor_url: &str,
|
||||||
|
status: FollowingStatus
|
||||||
|
) -> ();
|
||||||
|
}
|
||||||
|
trait FollowingReader {
|
||||||
|
fn get_following(local_user_id: uuid::Uuid) -> Vec<RemoteActor>;
|
||||||
|
fn get_following_page(
|
||||||
|
local_user_id: uuid::Uuid,
|
||||||
|
offset: u32,
|
||||||
|
limit: usize
|
||||||
|
) -> Vec<RemoteActor>;
|
||||||
|
fn count_following(local_user_id: uuid::Uuid) -> usize;
|
||||||
|
}
|
||||||
|
trait FollowMigration {
|
||||||
|
fn migrate_follower_actor(
|
||||||
|
old_actor_url: &str,
|
||||||
|
new_actor_url: &str
|
||||||
|
) -> Vec<uuid::Uuid>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
// MockActorRepo
|
||||||
|
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
|
||||||
|
mock_repo! {
|
||||||
|
MockActorRepo, MockActorRepoBuilder {
|
||||||
|
trait KeypairRepository {
|
||||||
|
fn get_local_actor_keypair(user_id: uuid::Uuid) -> Option<Keypair>;
|
||||||
|
fn save_local_actor_keypair(user_id: uuid::Uuid, keypair: Keypair) -> ();
|
||||||
|
}
|
||||||
|
trait RemoteActorCache {
|
||||||
|
fn upsert_remote_actor(actor: RemoteActor) -> ();
|
||||||
|
fn get_remote_actor(actor_url: &str) -> Option<RemoteActor>;
|
||||||
|
}
|
||||||
|
trait AnnounceRepository {
|
||||||
|
fn add_announce(
|
||||||
|
activity_id: &str,
|
||||||
|
object_url: &str,
|
||||||
|
actor_url: &str,
|
||||||
|
announced_at: DateTime<Utc>
|
||||||
|
) -> ();
|
||||||
|
fn remove_announce(activity_id: &str, actor_url: &str) -> ();
|
||||||
|
fn count_announces(object_url: &str) -> usize;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
// MockBlocklistRepo
|
||||||
|
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
|
||||||
|
mock_repo! {
|
||||||
|
MockBlocklistRepo, MockBlocklistRepoBuilder {
|
||||||
|
trait DomainBlocklist {
|
||||||
|
fn add_blocked_domain(domain: &str, reason: Option<&str>) -> ();
|
||||||
|
fn remove_blocked_domain(domain: &str) -> ();
|
||||||
|
fn get_blocked_domains() -> Vec<BlockedDomain>;
|
||||||
|
fn is_domain_blocked(domain: &str) -> bool;
|
||||||
|
}
|
||||||
|
trait ActorBlocklist {
|
||||||
|
fn add_blocked_actor(local_user_id: uuid::Uuid, actor_url: &str) -> ();
|
||||||
|
fn remove_blocked_actor(local_user_id: uuid::Uuid, actor_url: &str) -> ();
|
||||||
|
fn get_blocked_actors(local_user_id: uuid::Uuid) -> Vec<String>;
|
||||||
|
fn is_actor_blocked(local_user_id: uuid::Uuid, actor_url: &str) -> bool;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
// MockActivityRepo
|
||||||
|
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
|
||||||
|
mock_repo! {
|
||||||
|
MockActivityRepo, MockActivityRepoBuilder {
|
||||||
|
trait ActivityRepository {
|
||||||
|
fn is_activity_processed(activity_id: &str) -> bool;
|
||||||
|
fn mark_activity_processed(activity_id: &str) -> ();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mock_repo! {
|
||||||
|
MockUserRepo, MockUserRepoBuilder {
|
||||||
|
trait ApUserRepository {
|
||||||
|
fn find_by_id(id: uuid::Uuid) -> Option<ApUser>;
|
||||||
|
fn find_by_username(username: &str) -> Option<ApUser>;
|
||||||
|
fn count_users() -> usize;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mock_repo! {
|
||||||
|
MockContentReader, MockContentReaderBuilder {
|
||||||
|
trait ApContentReader {
|
||||||
|
fn get_local_objects_page(
|
||||||
|
user_id: uuid::Uuid,
|
||||||
|
before: Option<DateTime<Utc>>,
|
||||||
|
limit: usize
|
||||||
|
) -> Vec<LocalObject>;
|
||||||
|
fn count_local_posts() -> u64;
|
||||||
|
fn get_featured_objects(user_id: uuid::Uuid) -> Vec<Url>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mock_repo! {
|
||||||
|
MockObjectHandler, MockObjectHandlerBuilder {
|
||||||
|
trait ApObjectHandler {
|
||||||
|
fn on_create(ap_id: &Url, actor_url: &Url, object: serde_json::Value) -> ();
|
||||||
|
fn on_update(ap_id: &Url, actor_url: &Url, object: serde_json::Value) -> ();
|
||||||
|
fn on_delete(ap_id: &Url, actor_url: &Url) -> ();
|
||||||
|
fn on_actor_removed(actor_url: &Url) -> ();
|
||||||
|
fn on_like(object_url: &Url, actor_url: &Url) -> ();
|
||||||
|
fn on_unlike(object_url: &Url, actor_url: &Url) -> ();
|
||||||
|
fn on_announce_received(object_url: &Url, actor_url: &Url) -> ();
|
||||||
|
fn on_announce_removed(object_url: &Url, actor_url: &Url) -> ();
|
||||||
|
fn on_announce_of_remote(object_url: &Url, actor_url: &Url) -> ();
|
||||||
|
fn on_mention(
|
||||||
|
thought_ap_id: &Url,
|
||||||
|
mentioned_user_uuid: uuid::Uuid,
|
||||||
|
actor_url: &Url
|
||||||
|
) -> ();
|
||||||
|
fn on_unknown_activity(
|
||||||
|
activity_type: &str,
|
||||||
|
activity: serde_json::Value,
|
||||||
|
actor_url: &Url
|
||||||
|
) -> ();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mock_repo! {
|
||||||
|
MockEventPublisher, MockEventPublisherBuilder {
|
||||||
|
trait EventPublisher {
|
||||||
|
fn publish(event: FederationEvent) -> ();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
962
src/tests/activities.rs
Normal file
962
src/tests/activities.rs
Normal file
@@ -0,0 +1,962 @@
|
|||||||
|
/// Business-logic tests for activity receive() implementations.
|
||||||
|
///
|
||||||
|
/// These tests exercise each activity handler with mock builders,
|
||||||
|
/// verifying the correct callbacks fire and the correct repo mutations happen.
|
||||||
|
/// Activities that require outbound HTTP (Follow -> dereference actor) are tested
|
||||||
|
/// only for their early-return paths; the happy path requires a real HTTP stack.
|
||||||
|
use std::collections::HashSet;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use activitypub_federation::{config::FederationConfig, fetch::object_id::ObjectId};
|
||||||
|
use tokio::sync::Mutex;
|
||||||
|
use url::Url;
|
||||||
|
|
||||||
|
use crate::activities::announce::AnnounceType;
|
||||||
|
use crate::activities::block::BlockType;
|
||||||
|
use crate::activities::like::LikeType;
|
||||||
|
use crate::activities::{
|
||||||
|
AcceptActivity, AddActivity, AnnounceActivity, BlockActivity, CreateActivity, DeleteActivity,
|
||||||
|
FollowActivity, LikeActivity, RejectActivity, UndoActivity, UpdateActivity,
|
||||||
|
};
|
||||||
|
use crate::data::FederationData;
|
||||||
|
use crate::repository::FollowingStatus;
|
||||||
|
use crate::testing::{
|
||||||
|
MockActivityRepoBuilder, MockActorRepoBuilder, MockBlocklistRepoBuilder,
|
||||||
|
MockContentReaderBuilder, MockFollowRepoBuilder, MockObjectHandlerBuilder, MockUserRepoBuilder,
|
||||||
|
};
|
||||||
|
use crate::user::{ApActorType, ApUser};
|
||||||
|
|
||||||
|
// ── Mock-builder-based tracking ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn make_user(id: uuid::Uuid, username: &str) -> ApUser {
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tracking vectors for object handler callbacks.
|
||||||
|
#[derive(Default)]
|
||||||
|
struct HandlerTracking {
|
||||||
|
creates: Arc<Mutex<Vec<(Url, Url)>>>,
|
||||||
|
updates: Arc<Mutex<Vec<(Url, Url)>>>,
|
||||||
|
deletes: Arc<Mutex<Vec<(Url, Url)>>>,
|
||||||
|
actors_removed: Arc<Mutex<Vec<Url>>>,
|
||||||
|
likes: Arc<Mutex<Vec<(Url, Url)>>>,
|
||||||
|
unlikes: Arc<Mutex<Vec<(Url, Url)>>>,
|
||||||
|
announces_received: Arc<Mutex<Vec<(Url, Url)>>>,
|
||||||
|
announces_removed: Arc<Mutex<Vec<(Url, Url)>>>,
|
||||||
|
announces_of_remote: Arc<Mutex<Vec<(Url, Url)>>>,
|
||||||
|
mentions: Arc<Mutex<Vec<(Url, uuid::Uuid)>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HandlerTracking {
|
||||||
|
fn build_handler(&self) -> Arc<crate::testing::MockObjectHandler> {
|
||||||
|
let creates = self.creates.clone();
|
||||||
|
let updates = self.updates.clone();
|
||||||
|
let deletes = self.deletes.clone();
|
||||||
|
let actors_removed = self.actors_removed.clone();
|
||||||
|
let likes = self.likes.clone();
|
||||||
|
let unlikes = self.unlikes.clone();
|
||||||
|
let announces_received = self.announces_received.clone();
|
||||||
|
let announces_removed = self.announces_removed.clone();
|
||||||
|
let announces_of_remote = self.announces_of_remote.clone();
|
||||||
|
let mentions = self.mentions.clone();
|
||||||
|
|
||||||
|
MockObjectHandlerBuilder::new()
|
||||||
|
.on_on_create(move |ap_id, actor_url, _| {
|
||||||
|
creates
|
||||||
|
.try_lock()
|
||||||
|
.unwrap()
|
||||||
|
.push((ap_id.clone(), actor_url.clone()));
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.on_on_update(move |ap_id, actor_url, _| {
|
||||||
|
updates
|
||||||
|
.try_lock()
|
||||||
|
.unwrap()
|
||||||
|
.push((ap_id.clone(), actor_url.clone()));
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.on_on_delete(move |ap_id, actor_url| {
|
||||||
|
deletes
|
||||||
|
.try_lock()
|
||||||
|
.unwrap()
|
||||||
|
.push((ap_id.clone(), actor_url.clone()));
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.on_on_actor_removed(move |actor_url| {
|
||||||
|
actors_removed.try_lock().unwrap().push(actor_url.clone());
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.on_on_like(move |object_url, actor_url| {
|
||||||
|
likes
|
||||||
|
.try_lock()
|
||||||
|
.unwrap()
|
||||||
|
.push((object_url.clone(), actor_url.clone()));
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.on_on_unlike(move |object_url, actor_url| {
|
||||||
|
unlikes
|
||||||
|
.try_lock()
|
||||||
|
.unwrap()
|
||||||
|
.push((object_url.clone(), actor_url.clone()));
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.on_on_announce_received(move |object_url, actor_url| {
|
||||||
|
announces_received
|
||||||
|
.try_lock()
|
||||||
|
.unwrap()
|
||||||
|
.push((object_url.clone(), actor_url.clone()));
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.on_on_announce_removed(move |object_url, actor_url| {
|
||||||
|
announces_removed
|
||||||
|
.try_lock()
|
||||||
|
.unwrap()
|
||||||
|
.push((object_url.clone(), actor_url.clone()));
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.on_on_announce_of_remote(move |object_url, actor_url| {
|
||||||
|
announces_of_remote
|
||||||
|
.try_lock()
|
||||||
|
.unwrap()
|
||||||
|
.push((object_url.clone(), actor_url.clone()));
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.on_on_mention(move |ap_id, user_id, _| {
|
||||||
|
mentions.try_lock().unwrap().push((ap_id.clone(), user_id));
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Test helpers ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const LOCAL_DOMAIN: &str = "example.com";
|
||||||
|
const BASE_URL: &str = "https://example.com";
|
||||||
|
const REMOTE_ACTOR: &str = "https://remote.example/users/bob";
|
||||||
|
|
||||||
|
fn local_user_id() -> uuid::Uuid {
|
||||||
|
uuid::Uuid::new_v5(&uuid::Uuid::NAMESPACE_URL, b"alice")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn local_actor_url() -> Url {
|
||||||
|
format!("{}/users/{}", BASE_URL, local_user_id())
|
||||||
|
.parse()
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remote_actor_url() -> Url {
|
||||||
|
REMOTE_ACTOR.parse().unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn activity_url(path: &str) -> Url {
|
||||||
|
format!("https://remote.example{}", path).parse().unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn local_note_url() -> Url {
|
||||||
|
format!("{}/notes/1", BASE_URL).parse().unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remote_note_url() -> Url {
|
||||||
|
"https://other.example/notes/99".parse().unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shared tracking vectors used by closures in mock builders.
|
||||||
|
struct Tracking {
|
||||||
|
added_followers: Arc<Mutex<Vec<(uuid::Uuid, String, crate::repository::FollowerStatus)>>>,
|
||||||
|
removed_followers: Arc<Mutex<Vec<(uuid::Uuid, String)>>>,
|
||||||
|
removed_following: Arc<Mutex<Vec<(uuid::Uuid, String)>>>,
|
||||||
|
following_status_updates: Arc<Mutex<Vec<(uuid::Uuid, String, FollowingStatus)>>>,
|
||||||
|
added_announces: Arc<Mutex<Vec<String>>>,
|
||||||
|
removed_announces: Arc<Mutex<Vec<String>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TestSetup {
|
||||||
|
tracking: Tracking,
|
||||||
|
handler: HandlerTracking,
|
||||||
|
config: FederationConfig<FederationData>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn setup_with_blocklist(
|
||||||
|
blocklist: Arc<crate::testing::MockBlocklistRepo>,
|
||||||
|
local_user_id: uuid::Uuid,
|
||||||
|
) -> TestSetup {
|
||||||
|
let added_followers = Arc::new(Mutex::new(vec![]));
|
||||||
|
let removed_followers = Arc::new(Mutex::new(vec![]));
|
||||||
|
let removed_following = Arc::new(Mutex::new(vec![]));
|
||||||
|
let following_status_updates = Arc::new(Mutex::new(vec![]));
|
||||||
|
let added_announces = Arc::new(Mutex::new(vec![]));
|
||||||
|
let removed_announces = Arc::new(Mutex::new(vec![]));
|
||||||
|
|
||||||
|
let af = added_followers.clone();
|
||||||
|
let rf = removed_followers.clone();
|
||||||
|
let rfw = removed_following.clone();
|
||||||
|
let fsu = following_status_updates.clone();
|
||||||
|
let follow_repo = MockFollowRepoBuilder::new()
|
||||||
|
.on_add_follower(move |id, url, status, _| {
|
||||||
|
af.try_lock().unwrap().push((id, url.to_string(), status));
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.on_remove_follower(move |id, url| {
|
||||||
|
rf.try_lock().unwrap().push((id, url.to_string()));
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.on_remove_following(move |id, url| {
|
||||||
|
rfw.try_lock().unwrap().push((id, url.to_string()));
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.on_update_following_status(move |id, url, status| {
|
||||||
|
fsu.try_lock().unwrap().push((id, url.to_string(), status));
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let aa = added_announces.clone();
|
||||||
|
let ra = removed_announces.clone();
|
||||||
|
let actor_repo = MockActorRepoBuilder::new()
|
||||||
|
.on_add_announce(move |activity_id, _, _, _| {
|
||||||
|
aa.try_lock().unwrap().push(activity_id.to_string());
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.on_remove_announce(move |activity_id, _| {
|
||||||
|
ra.try_lock().unwrap().push(activity_id.to_string());
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.build();
|
||||||
|
|
||||||
|
// Activity repo with real dedup tracking
|
||||||
|
let processed = Arc::new(Mutex::new(HashSet::<String>::new()));
|
||||||
|
let p1 = processed.clone();
|
||||||
|
let p2 = processed.clone();
|
||||||
|
let activity_repo = MockActivityRepoBuilder::new()
|
||||||
|
.on_is_activity_processed(move |id| Ok(p1.try_lock().unwrap().contains(id)))
|
||||||
|
.on_mark_activity_processed(move |id| {
|
||||||
|
p2.try_lock().unwrap().insert(id.to_string());
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let handler = HandlerTracking::default();
|
||||||
|
|
||||||
|
let user = make_user(local_user_id, "alice");
|
||||||
|
let user_repo = MockUserRepoBuilder::new()
|
||||||
|
.on_find_by_id(move |id| {
|
||||||
|
if id == local_user_id {
|
||||||
|
Ok(Some(user.clone()))
|
||||||
|
} else {
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let data = FederationData::new(
|
||||||
|
activity_repo,
|
||||||
|
follow_repo,
|
||||||
|
actor_repo,
|
||||||
|
blocklist,
|
||||||
|
user_repo,
|
||||||
|
MockContentReaderBuilder::new().build(),
|
||||||
|
handler.build_handler(),
|
||||||
|
BASE_URL.to_string(),
|
||||||
|
false,
|
||||||
|
"test".to_string(),
|
||||||
|
None,
|
||||||
|
std::time::Duration::from_secs(24 * 60 * 60),
|
||||||
|
Arc::new(crate::url_scheme::DefaultUrlScheme),
|
||||||
|
);
|
||||||
|
|
||||||
|
crate::tls::ensure_crypto_provider();
|
||||||
|
let config = FederationConfig::builder()
|
||||||
|
.domain(LOCAL_DOMAIN)
|
||||||
|
.app_data(data)
|
||||||
|
.debug(true)
|
||||||
|
.build()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
TestSetup {
|
||||||
|
tracking: Tracking {
|
||||||
|
added_followers,
|
||||||
|
removed_followers,
|
||||||
|
removed_following,
|
||||||
|
following_status_updates,
|
||||||
|
added_announces,
|
||||||
|
removed_announces,
|
||||||
|
},
|
||||||
|
handler,
|
||||||
|
config,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn setup(local_user_id: uuid::Uuid) -> TestSetup {
|
||||||
|
setup_with_blocklist(MockBlocklistRepoBuilder::new().build(), local_user_id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── AcceptActivity tests ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn accept_updates_following_status_to_accepted() {
|
||||||
|
use activitypub_federation::kinds::activity::AcceptType;
|
||||||
|
|
||||||
|
let local_id = local_user_id();
|
||||||
|
let s = setup(local_id).await;
|
||||||
|
let data = s.config.to_request_data();
|
||||||
|
|
||||||
|
let follow = FollowActivity {
|
||||||
|
id: activity_url("/follow/1"),
|
||||||
|
kind: Default::default(),
|
||||||
|
actor: ObjectId::from(local_actor_url()),
|
||||||
|
object: ObjectId::from(remote_actor_url()),
|
||||||
|
};
|
||||||
|
let accept = AcceptActivity {
|
||||||
|
id: activity_url("/accept/1"),
|
||||||
|
kind: AcceptType::default(),
|
||||||
|
actor: ObjectId::from(remote_actor_url()),
|
||||||
|
object: follow,
|
||||||
|
};
|
||||||
|
|
||||||
|
use activitypub_federation::traits::Activity;
|
||||||
|
accept.receive(&data).await.unwrap();
|
||||||
|
|
||||||
|
let updates = s.tracking.following_status_updates.lock().await;
|
||||||
|
assert_eq!(updates.len(), 1);
|
||||||
|
assert_eq!(updates[0].0, local_id);
|
||||||
|
assert_eq!(updates[0].1, REMOTE_ACTOR);
|
||||||
|
assert!(matches!(updates[0].2, FollowingStatus::Accepted));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── RejectActivity tests ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn reject_removes_following() {
|
||||||
|
use activitypub_federation::kinds::activity::RejectType;
|
||||||
|
|
||||||
|
let local_id = local_user_id();
|
||||||
|
let s = setup(local_id).await;
|
||||||
|
let data = s.config.to_request_data();
|
||||||
|
|
||||||
|
let follow = FollowActivity {
|
||||||
|
id: activity_url("/follow/1"),
|
||||||
|
kind: Default::default(),
|
||||||
|
actor: ObjectId::from(local_actor_url()),
|
||||||
|
object: ObjectId::from(remote_actor_url()),
|
||||||
|
};
|
||||||
|
let reject = RejectActivity {
|
||||||
|
id: activity_url("/reject/1"),
|
||||||
|
kind: RejectType::default(),
|
||||||
|
actor: ObjectId::from(remote_actor_url()),
|
||||||
|
object: follow,
|
||||||
|
};
|
||||||
|
|
||||||
|
use activitypub_federation::traits::Activity;
|
||||||
|
reject.receive(&data).await.unwrap();
|
||||||
|
|
||||||
|
let removed = s.tracking.removed_following.lock().await;
|
||||||
|
assert_eq!(removed.len(), 1);
|
||||||
|
assert_eq!(removed[0].0, local_id);
|
||||||
|
assert_eq!(removed[0].1, REMOTE_ACTOR);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── UndoActivity tests ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn undo_follow_removes_follower_and_cleans_content() {
|
||||||
|
let local_id = local_user_id();
|
||||||
|
let s = setup(local_id).await;
|
||||||
|
let data = s.config.to_request_data();
|
||||||
|
|
||||||
|
let undo = UndoActivity {
|
||||||
|
id: activity_url("/undo/1"),
|
||||||
|
kind: Default::default(),
|
||||||
|
actor: ObjectId::from(remote_actor_url()),
|
||||||
|
object: serde_json::json!({
|
||||||
|
"type": "Follow",
|
||||||
|
"id": "https://remote.example/follow/1",
|
||||||
|
"actor": REMOTE_ACTOR,
|
||||||
|
"object": local_actor_url().as_str(),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
use activitypub_federation::traits::Activity;
|
||||||
|
undo.receive(&data).await.unwrap();
|
||||||
|
|
||||||
|
let removed = s.tracking.removed_followers.lock().await;
|
||||||
|
assert_eq!(removed.len(), 1, "follower should be removed");
|
||||||
|
assert_eq!(removed[0].0, local_id);
|
||||||
|
|
||||||
|
let cleaned = s.handler.actors_removed.lock().await;
|
||||||
|
assert_eq!(cleaned.len(), 1, "on_actor_removed should be called");
|
||||||
|
assert_eq!(cleaned[0], remote_actor_url());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn undo_like_calls_on_unlike_for_local_object() {
|
||||||
|
let local_id = local_user_id();
|
||||||
|
let s = setup(local_id).await;
|
||||||
|
let data = s.config.to_request_data();
|
||||||
|
|
||||||
|
let undo = UndoActivity {
|
||||||
|
id: activity_url("/undo/2"),
|
||||||
|
kind: Default::default(),
|
||||||
|
actor: ObjectId::from(remote_actor_url()),
|
||||||
|
object: serde_json::json!({
|
||||||
|
"type": "Like",
|
||||||
|
"id": "https://remote.example/like/1",
|
||||||
|
"actor": REMOTE_ACTOR,
|
||||||
|
"object": local_note_url().as_str(),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
use activitypub_federation::traits::Activity;
|
||||||
|
undo.receive(&data).await.unwrap();
|
||||||
|
|
||||||
|
let unlikes = s.handler.unlikes.lock().await;
|
||||||
|
assert_eq!(unlikes.len(), 1);
|
||||||
|
assert_eq!(unlikes[0].0, local_note_url());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn undo_like_ignores_remote_object() {
|
||||||
|
let local_id = local_user_id();
|
||||||
|
let s = setup(local_id).await;
|
||||||
|
let data = s.config.to_request_data();
|
||||||
|
|
||||||
|
let undo = UndoActivity {
|
||||||
|
id: activity_url("/undo/3"),
|
||||||
|
kind: Default::default(),
|
||||||
|
actor: ObjectId::from(remote_actor_url()),
|
||||||
|
object: serde_json::json!({
|
||||||
|
"type": "Like",
|
||||||
|
"id": "https://remote.example/like/2",
|
||||||
|
"actor": REMOTE_ACTOR,
|
||||||
|
"object": remote_note_url().as_str(),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
use activitypub_federation::traits::Activity;
|
||||||
|
undo.receive(&data).await.unwrap();
|
||||||
|
|
||||||
|
let unlikes = s.handler.unlikes.lock().await;
|
||||||
|
assert!(
|
||||||
|
unlikes.is_empty(),
|
||||||
|
"remote object Like should not trigger on_unlike"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn undo_announce_removes_record_and_notifies() {
|
||||||
|
let local_id = local_user_id();
|
||||||
|
let s = setup(local_id).await;
|
||||||
|
let data = s.config.to_request_data();
|
||||||
|
|
||||||
|
let undo = UndoActivity {
|
||||||
|
id: activity_url("/undo/4"),
|
||||||
|
kind: Default::default(),
|
||||||
|
actor: ObjectId::from(remote_actor_url()),
|
||||||
|
object: serde_json::json!({
|
||||||
|
"type": "Announce",
|
||||||
|
"id": "https://remote.example/announce/1",
|
||||||
|
"actor": REMOTE_ACTOR,
|
||||||
|
"object": local_note_url().as_str(),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
use activitypub_federation::traits::Activity;
|
||||||
|
undo.receive(&data).await.unwrap();
|
||||||
|
|
||||||
|
let removed = s.tracking.removed_announces.lock().await;
|
||||||
|
assert_eq!(removed.len(), 1);
|
||||||
|
assert_eq!(removed[0], "https://remote.example/announce/1");
|
||||||
|
|
||||||
|
let notified = s.handler.announces_removed.lock().await;
|
||||||
|
assert_eq!(notified.len(), 1);
|
||||||
|
assert_eq!(notified[0].0, local_note_url());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn undo_announce_ignores_remote_object() {
|
||||||
|
let local_id = local_user_id();
|
||||||
|
let s = setup(local_id).await;
|
||||||
|
let data = s.config.to_request_data();
|
||||||
|
|
||||||
|
let undo = UndoActivity {
|
||||||
|
id: activity_url("/undo/5"),
|
||||||
|
kind: Default::default(),
|
||||||
|
actor: ObjectId::from(remote_actor_url()),
|
||||||
|
object: serde_json::json!({
|
||||||
|
"type": "Announce",
|
||||||
|
"id": "https://remote.example/announce/2",
|
||||||
|
"actor": REMOTE_ACTOR,
|
||||||
|
"object": remote_note_url().as_str(),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
use activitypub_federation::traits::Activity;
|
||||||
|
undo.receive(&data).await.unwrap();
|
||||||
|
|
||||||
|
// remove_announce should still be called (clean up the record)
|
||||||
|
let removed = s.tracking.removed_announces.lock().await;
|
||||||
|
assert_eq!(
|
||||||
|
removed.len(),
|
||||||
|
1,
|
||||||
|
"announce record should be removed regardless"
|
||||||
|
);
|
||||||
|
|
||||||
|
// but on_announce_removed should NOT fire for non-local objects
|
||||||
|
let notified = s.handler.announces_removed.lock().await;
|
||||||
|
assert!(
|
||||||
|
notified.is_empty(),
|
||||||
|
"on_announce_removed should not fire for remote-hosted objects"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── CreateActivity tests ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn create_uses_object_id_not_activity_id() {
|
||||||
|
let local_id = local_user_id();
|
||||||
|
let s = setup(local_id).await;
|
||||||
|
let data = s.config.to_request_data();
|
||||||
|
|
||||||
|
let object_id = "https://remote.example/notes/42";
|
||||||
|
let create = CreateActivity {
|
||||||
|
id: activity_url("/create/99"),
|
||||||
|
kind: Default::default(),
|
||||||
|
actor: ObjectId::from(remote_actor_url()),
|
||||||
|
object: serde_json::json!({
|
||||||
|
"type": "Note",
|
||||||
|
"id": object_id,
|
||||||
|
"content": "Hello world",
|
||||||
|
"attributedTo": REMOTE_ACTOR,
|
||||||
|
}),
|
||||||
|
to: vec![],
|
||||||
|
cc: vec![],
|
||||||
|
bto: vec![],
|
||||||
|
bcc: vec![],
|
||||||
|
};
|
||||||
|
|
||||||
|
use activitypub_federation::traits::Activity;
|
||||||
|
create.receive(&data).await.unwrap();
|
||||||
|
|
||||||
|
let creates = s.handler.creates.lock().await;
|
||||||
|
assert_eq!(creates.len(), 1);
|
||||||
|
assert_eq!(
|
||||||
|
creates[0].0.as_str(),
|
||||||
|
object_id,
|
||||||
|
"on_create should receive the OBJECT id, not the Create activity id"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn create_with_mention_fires_on_mention() {
|
||||||
|
let local_id = local_user_id();
|
||||||
|
let s = setup(local_id).await;
|
||||||
|
let data = s.config.to_request_data();
|
||||||
|
|
||||||
|
let note_id = "https://remote.example/notes/mention-test";
|
||||||
|
let local_user_url = local_actor_url();
|
||||||
|
let create = CreateActivity {
|
||||||
|
id: activity_url("/create/mention"),
|
||||||
|
kind: Default::default(),
|
||||||
|
actor: ObjectId::from(remote_actor_url()),
|
||||||
|
object: serde_json::json!({
|
||||||
|
"type": "Note",
|
||||||
|
"id": note_id,
|
||||||
|
"content": "Hey @alice!",
|
||||||
|
"attributedTo": REMOTE_ACTOR,
|
||||||
|
"tag": [{"type": "Mention", "href": local_user_url.as_str()}],
|
||||||
|
}),
|
||||||
|
to: vec![],
|
||||||
|
cc: vec![],
|
||||||
|
bto: vec![],
|
||||||
|
bcc: vec![],
|
||||||
|
};
|
||||||
|
|
||||||
|
use activitypub_federation::traits::Activity;
|
||||||
|
create.receive(&data).await.unwrap();
|
||||||
|
|
||||||
|
let mentions = s.handler.mentions.lock().await;
|
||||||
|
assert_eq!(
|
||||||
|
mentions.len(),
|
||||||
|
1,
|
||||||
|
"on_mention should fire for the local user"
|
||||||
|
);
|
||||||
|
assert_eq!(mentions[0].1, local_id);
|
||||||
|
|
||||||
|
let creates = s.handler.creates.lock().await;
|
||||||
|
assert_eq!(creates.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── UpdateActivity tests ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn update_uses_object_id() {
|
||||||
|
let local_id = local_user_id();
|
||||||
|
let s = setup(local_id).await;
|
||||||
|
let data = s.config.to_request_data();
|
||||||
|
|
||||||
|
let object_id = "https://remote.example/notes/42";
|
||||||
|
let update = UpdateActivity {
|
||||||
|
id: activity_url("/update/1"),
|
||||||
|
kind: Default::default(),
|
||||||
|
actor: ObjectId::from(remote_actor_url()),
|
||||||
|
object: serde_json::json!({"type": "Note", "id": object_id, "content": "Edited"}),
|
||||||
|
to: vec![],
|
||||||
|
cc: vec![],
|
||||||
|
};
|
||||||
|
|
||||||
|
use activitypub_federation::traits::Activity;
|
||||||
|
update.receive(&data).await.unwrap();
|
||||||
|
|
||||||
|
let updates = s.handler.updates.lock().await;
|
||||||
|
assert_eq!(updates.len(), 1);
|
||||||
|
assert_eq!(updates[0].0.as_str(), object_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── DeleteActivity tests ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn delete_object_calls_on_delete() {
|
||||||
|
let local_id = local_user_id();
|
||||||
|
let s = setup(local_id).await;
|
||||||
|
let data = s.config.to_request_data();
|
||||||
|
|
||||||
|
let note_id = "https://remote.example/notes/to-delete";
|
||||||
|
let delete = DeleteActivity {
|
||||||
|
id: activity_url("/delete/1"),
|
||||||
|
kind: Default::default(),
|
||||||
|
actor: ObjectId::from(remote_actor_url()),
|
||||||
|
object: serde_json::json!({"type": "Tombstone", "id": note_id}),
|
||||||
|
to: vec![],
|
||||||
|
cc: vec![],
|
||||||
|
};
|
||||||
|
|
||||||
|
use activitypub_federation::traits::Activity;
|
||||||
|
delete.receive(&data).await.unwrap();
|
||||||
|
|
||||||
|
let deletes = s.handler.deletes.lock().await;
|
||||||
|
assert_eq!(deletes.len(), 1);
|
||||||
|
assert_eq!(deletes[0].0.as_str(), note_id);
|
||||||
|
|
||||||
|
let actor_removed = s.handler.actors_removed.lock().await;
|
||||||
|
assert!(
|
||||||
|
actor_removed.is_empty(),
|
||||||
|
"on_actor_removed should NOT fire for note deletion"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn delete_actor_calls_on_actor_removed() {
|
||||||
|
let local_id = local_user_id();
|
||||||
|
let s = setup(local_id).await;
|
||||||
|
let data = s.config.to_request_data();
|
||||||
|
|
||||||
|
let delete = DeleteActivity {
|
||||||
|
id: activity_url("/delete/actor"),
|
||||||
|
kind: Default::default(),
|
||||||
|
actor: ObjectId::from(remote_actor_url()),
|
||||||
|
object: serde_json::json!(REMOTE_ACTOR),
|
||||||
|
to: vec![],
|
||||||
|
cc: vec![],
|
||||||
|
};
|
||||||
|
|
||||||
|
use activitypub_federation::traits::Activity;
|
||||||
|
delete.receive(&data).await.unwrap();
|
||||||
|
|
||||||
|
let actor_removed = s.handler.actors_removed.lock().await;
|
||||||
|
assert_eq!(actor_removed.len(), 1);
|
||||||
|
assert_eq!(actor_removed[0], remote_actor_url());
|
||||||
|
|
||||||
|
let deletes = s.handler.deletes.lock().await;
|
||||||
|
assert!(
|
||||||
|
deletes.is_empty(),
|
||||||
|
"on_delete should NOT fire for actor deletion"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── AnnounceActivity tests ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn announce_local_object_records_and_notifies() {
|
||||||
|
let local_id = local_user_id();
|
||||||
|
let s = setup(local_id).await;
|
||||||
|
let data = s.config.to_request_data();
|
||||||
|
|
||||||
|
let announce = AnnounceActivity {
|
||||||
|
id: activity_url("/announce/1"),
|
||||||
|
kind: AnnounceType,
|
||||||
|
actor: ObjectId::from(remote_actor_url()),
|
||||||
|
object: local_note_url(),
|
||||||
|
published: None,
|
||||||
|
to: vec![],
|
||||||
|
cc: vec![],
|
||||||
|
};
|
||||||
|
|
||||||
|
use activitypub_federation::traits::Activity;
|
||||||
|
announce.receive(&data).await.unwrap();
|
||||||
|
|
||||||
|
let added = s.tracking.added_announces.lock().await;
|
||||||
|
assert_eq!(added.len(), 1, "announce record should be created");
|
||||||
|
|
||||||
|
let notified = s.handler.announces_received.lock().await;
|
||||||
|
assert_eq!(notified.len(), 1);
|
||||||
|
assert_eq!(notified[0].0, local_note_url());
|
||||||
|
|
||||||
|
let remote = s.handler.announces_of_remote.lock().await;
|
||||||
|
assert!(
|
||||||
|
remote.is_empty(),
|
||||||
|
"on_announce_of_remote should NOT fire for local objects"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn announce_remote_object_calls_on_announce_of_remote() {
|
||||||
|
let local_id = local_user_id();
|
||||||
|
let s = setup(local_id).await;
|
||||||
|
let data = s.config.to_request_data();
|
||||||
|
|
||||||
|
let announce = AnnounceActivity {
|
||||||
|
id: activity_url("/announce/2"),
|
||||||
|
kind: AnnounceType,
|
||||||
|
actor: ObjectId::from(remote_actor_url()),
|
||||||
|
object: remote_note_url(),
|
||||||
|
published: None,
|
||||||
|
to: vec![],
|
||||||
|
cc: vec![],
|
||||||
|
};
|
||||||
|
|
||||||
|
use activitypub_federation::traits::Activity;
|
||||||
|
announce.receive(&data).await.unwrap();
|
||||||
|
|
||||||
|
let remote = s.handler.announces_of_remote.lock().await;
|
||||||
|
assert_eq!(remote.len(), 1);
|
||||||
|
assert_eq!(remote[0].0, remote_note_url());
|
||||||
|
|
||||||
|
let local = s.handler.announces_received.lock().await;
|
||||||
|
assert!(
|
||||||
|
local.is_empty(),
|
||||||
|
"on_announce_received should NOT fire for remote objects"
|
||||||
|
);
|
||||||
|
|
||||||
|
let added = s.tracking.added_announces.lock().await;
|
||||||
|
assert!(
|
||||||
|
added.is_empty(),
|
||||||
|
"announce record should NOT be created for remote objects"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── LikeActivity tests ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn like_local_object_calls_on_like() {
|
||||||
|
let local_id = local_user_id();
|
||||||
|
let s = setup(local_id).await;
|
||||||
|
let data = s.config.to_request_data();
|
||||||
|
|
||||||
|
let like = LikeActivity {
|
||||||
|
id: activity_url("/like/1"),
|
||||||
|
kind: LikeType,
|
||||||
|
actor: ObjectId::from(remote_actor_url()),
|
||||||
|
object: local_note_url(),
|
||||||
|
};
|
||||||
|
|
||||||
|
use activitypub_federation::traits::Activity;
|
||||||
|
like.receive(&data).await.unwrap();
|
||||||
|
|
||||||
|
let likes = s.handler.likes.lock().await;
|
||||||
|
assert_eq!(likes.len(), 1);
|
||||||
|
assert_eq!(likes[0].0, local_note_url());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn like_remote_object_is_ignored() {
|
||||||
|
let local_id = local_user_id();
|
||||||
|
let s = setup(local_id).await;
|
||||||
|
let data = s.config.to_request_data();
|
||||||
|
|
||||||
|
let like = LikeActivity {
|
||||||
|
id: activity_url("/like/2"),
|
||||||
|
kind: LikeType,
|
||||||
|
actor: ObjectId::from(remote_actor_url()),
|
||||||
|
object: remote_note_url(),
|
||||||
|
};
|
||||||
|
|
||||||
|
use activitypub_federation::traits::Activity;
|
||||||
|
like.receive(&data).await.unwrap();
|
||||||
|
|
||||||
|
let likes = s.handler.likes.lock().await;
|
||||||
|
assert!(
|
||||||
|
likes.is_empty(),
|
||||||
|
"remote object Like should be silently ignored"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── AddActivity tests ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn add_uses_object_id_not_activity_id() {
|
||||||
|
let local_id = local_user_id();
|
||||||
|
let s = setup(local_id).await;
|
||||||
|
let data = s.config.to_request_data();
|
||||||
|
|
||||||
|
let object_id = "https://remote.example/watchlist/item/5";
|
||||||
|
let add = AddActivity {
|
||||||
|
id: activity_url("/add/99"),
|
||||||
|
kind: Default::default(),
|
||||||
|
actor: ObjectId::from(remote_actor_url()),
|
||||||
|
object: serde_json::json!({
|
||||||
|
"type": "Movie",
|
||||||
|
"id": object_id,
|
||||||
|
"name": "Some Film",
|
||||||
|
"attributedTo": REMOTE_ACTOR,
|
||||||
|
}),
|
||||||
|
to: vec![],
|
||||||
|
cc: vec![],
|
||||||
|
};
|
||||||
|
|
||||||
|
use activitypub_federation::traits::Activity;
|
||||||
|
add.receive(&data).await.unwrap();
|
||||||
|
|
||||||
|
let creates = s.handler.creates.lock().await;
|
||||||
|
assert_eq!(creates.len(), 1);
|
||||||
|
assert_eq!(
|
||||||
|
creates[0].0.as_str(),
|
||||||
|
object_id,
|
||||||
|
"on_create should use the OBJECT id, not the Add activity id"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── BlockActivity tests ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn block_removes_follow_relationships() {
|
||||||
|
let local_id = local_user_id();
|
||||||
|
let s = setup(local_id).await;
|
||||||
|
let data = s.config.to_request_data();
|
||||||
|
|
||||||
|
let block = BlockActivity {
|
||||||
|
id: activity_url("/block/1"),
|
||||||
|
kind: BlockType,
|
||||||
|
actor: ObjectId::from(remote_actor_url()),
|
||||||
|
object: local_actor_url(),
|
||||||
|
};
|
||||||
|
|
||||||
|
use activitypub_federation::traits::Activity;
|
||||||
|
block.receive(&data).await.unwrap();
|
||||||
|
|
||||||
|
let removed_following = s.tracking.removed_following.lock().await;
|
||||||
|
assert_eq!(
|
||||||
|
removed_following.len(),
|
||||||
|
1,
|
||||||
|
"following should be removed on Block"
|
||||||
|
);
|
||||||
|
assert_eq!(removed_following[0].0, local_id);
|
||||||
|
|
||||||
|
let removed_followers = s.tracking.removed_followers.lock().await;
|
||||||
|
assert_eq!(
|
||||||
|
removed_followers.len(),
|
||||||
|
1,
|
||||||
|
"follower should be removed on Block"
|
||||||
|
);
|
||||||
|
assert_eq!(removed_followers[0].0, local_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Domain / actor blocking ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn activity_from_blocked_domain_is_skipped() {
|
||||||
|
let local_id = local_user_id();
|
||||||
|
let blocklist = MockBlocklistRepoBuilder::new()
|
||||||
|
.on_is_domain_blocked(|domain| Ok(domain == "remote.example"))
|
||||||
|
.build();
|
||||||
|
let s = setup_with_blocklist(blocklist, local_id).await;
|
||||||
|
let data = s.config.to_request_data();
|
||||||
|
|
||||||
|
let create = CreateActivity {
|
||||||
|
id: activity_url("/create/blocked"),
|
||||||
|
kind: Default::default(),
|
||||||
|
actor: ObjectId::from(remote_actor_url()),
|
||||||
|
object: serde_json::json!({"type": "Note", "id": "https://remote.example/notes/1"}),
|
||||||
|
to: vec![],
|
||||||
|
cc: vec![],
|
||||||
|
bto: vec![],
|
||||||
|
bcc: vec![],
|
||||||
|
};
|
||||||
|
|
||||||
|
use activitypub_federation::traits::Activity;
|
||||||
|
create.receive(&data).await.unwrap();
|
||||||
|
|
||||||
|
let creates = s.handler.creates.lock().await;
|
||||||
|
assert!(
|
||||||
|
creates.is_empty(),
|
||||||
|
"activity from blocked domain must be skipped"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn follow_from_blocked_actor_is_skipped_before_http() {
|
||||||
|
let local_id = local_user_id();
|
||||||
|
let expected_actor = REMOTE_ACTOR.to_string();
|
||||||
|
let blocklist = MockBlocklistRepoBuilder::new()
|
||||||
|
.on_is_actor_blocked(move |uid, url| Ok(uid == local_id && url == expected_actor))
|
||||||
|
.build();
|
||||||
|
let s = setup_with_blocklist(blocklist, local_id).await;
|
||||||
|
let data = s.config.to_request_data();
|
||||||
|
|
||||||
|
let follow = FollowActivity {
|
||||||
|
id: activity_url("/follow/blocked"),
|
||||||
|
kind: Default::default(),
|
||||||
|
actor: ObjectId::from(remote_actor_url()),
|
||||||
|
object: ObjectId::from(local_actor_url()),
|
||||||
|
};
|
||||||
|
|
||||||
|
use activitypub_federation::traits::Activity;
|
||||||
|
follow.receive(&data).await.unwrap();
|
||||||
|
|
||||||
|
let added = s.tracking.added_followers.lock().await;
|
||||||
|
assert!(
|
||||||
|
added.is_empty(),
|
||||||
|
"blocked actor follow must be silently discarded"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Idempotency ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn duplicate_activity_id_is_skipped() {
|
||||||
|
let local_id = local_user_id();
|
||||||
|
let s = setup(local_id).await;
|
||||||
|
let data = s.config.to_request_data();
|
||||||
|
|
||||||
|
let make_create = || CreateActivity {
|
||||||
|
id: activity_url("/create/dedup"),
|
||||||
|
kind: Default::default(),
|
||||||
|
actor: ObjectId::from(remote_actor_url()),
|
||||||
|
object: serde_json::json!({"type": "Note", "id": "https://remote.example/notes/dedup"}),
|
||||||
|
to: vec![],
|
||||||
|
cc: vec![],
|
||||||
|
bto: vec![],
|
||||||
|
bcc: vec![],
|
||||||
|
};
|
||||||
|
|
||||||
|
use activitypub_federation::traits::Activity;
|
||||||
|
make_create().receive(&data).await.unwrap();
|
||||||
|
make_create().receive(&data).await.unwrap(); // duplicate
|
||||||
|
|
||||||
|
let creates = s.handler.creates.lock().await;
|
||||||
|
assert_eq!(creates.len(), 1, "duplicate delivery must be deduplicated");
|
||||||
|
}
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn person_serializes_with_enriched_fields() {
|
|
||||||
let 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: "https://example.com/users/1/outbox".parse().unwrap(),
|
|
||||||
followers: "https://example.com/users/1/followers".parse().unwrap(),
|
|
||||||
following: "https://example.com/users/1/following".parse().unwrap(),
|
|
||||||
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: Some("Alice".to_string()),
|
|
||||||
summary: Some("Bio text".to_string()),
|
|
||||||
icon: Some(ApImageObject {
|
|
||||||
kind: "Image".to_string(),
|
|
||||||
url: "https://example.com/images/avatars/1".parse().unwrap(),
|
|
||||||
}),
|
|
||||||
url: Some("https://example.com/u/alice".parse().unwrap()),
|
|
||||||
discoverable: Some(true),
|
|
||||||
manually_approves_followers: true,
|
|
||||||
updated: Some(Utc::now()),
|
|
||||||
endpoints: Some(Endpoints {
|
|
||||||
shared_inbox: "https://example.com/inbox".parse().unwrap(),
|
|
||||||
}),
|
|
||||||
image: None,
|
|
||||||
also_known_as: vec![],
|
|
||||||
attachment: vec![],
|
|
||||||
};
|
|
||||||
let json = serde_json::to_value(&person).unwrap();
|
|
||||||
assert_eq!(json["discoverable"], true);
|
|
||||||
assert_eq!(json["summary"], "Bio text");
|
|
||||||
assert_eq!(json["icon"]["type"], "Image");
|
|
||||||
assert_eq!(json["manuallyApprovesFollowers"], true);
|
|
||||||
assert!(json.get("updated").is_some());
|
|
||||||
assert!(json.get("endpoints").is_some());
|
|
||||||
assert_eq!(
|
|
||||||
json["endpoints"]["sharedInbox"],
|
|
||||||
"https://example.com/inbox"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
57
src/tests/broadcast.rs
Normal file
57
src/tests/broadcast.rs
Normal 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 addressing = visibility_addressing(ApVisibility::Public, &followers_url());
|
||||||
|
assert_eq!(addressing.to, vec![AS_PUBLIC.to_string()]);
|
||||||
|
assert_eq!(addressing.cc, vec![followers_url().to_string()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn followers_only_visibility_addresses_followers_only() {
|
||||||
|
let addressing = visibility_addressing(ApVisibility::FollowersOnly, &followers_url());
|
||||||
|
assert_eq!(addressing.to, vec![followers_url().to_string()]);
|
||||||
|
assert!(
|
||||||
|
addressing.cc.is_empty(),
|
||||||
|
"FollowersOnly must not include AS_PUBLIC in cc"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn followers_only_excludes_as_public() {
|
||||||
|
let addressing = visibility_addressing(ApVisibility::FollowersOnly, &followers_url());
|
||||||
|
assert!(
|
||||||
|
!addressing.to.contains(&AS_PUBLIC.to_string()),
|
||||||
|
"FollowersOnly must not include AS_PUBLIC in to"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!addressing.cc.contains(&AS_PUBLIC.to_string()),
|
||||||
|
"FollowersOnly must not include AS_PUBLIC in cc"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn private_visibility_produces_empty_addressing() {
|
||||||
|
let addressing = visibility_addressing(ApVisibility::Private, &followers_url());
|
||||||
|
assert!(addressing.to.is_empty());
|
||||||
|
assert!(addressing.cc.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn public_and_followers_only_differ_in_to() {
|
||||||
|
let public = visibility_addressing(ApVisibility::Public, &followers_url());
|
||||||
|
let followers_only = visibility_addressing(ApVisibility::FollowersOnly, &followers_url());
|
||||||
|
assert_ne!(
|
||||||
|
public.to, followers_only.to,
|
||||||
|
"Public and FollowersOnly must produce different to fields"
|
||||||
|
);
|
||||||
|
}
|
||||||
201
src/tests/integration.rs
Normal file
201
src/tests/integration.rs
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
// src/tests/integration.rs
|
||||||
|
/// Integration tests with mock builders.
|
||||||
|
use std::collections::HashSet;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use tokio::sync::Mutex;
|
||||||
|
use url::Url;
|
||||||
|
|
||||||
|
use crate::data::FederationData;
|
||||||
|
use crate::testing::{
|
||||||
|
MockActivityRepoBuilder, MockActorRepoBuilder, MockBlocklistRepoBuilder,
|
||||||
|
MockContentReaderBuilder, MockFollowRepoBuilder, MockObjectHandlerBuilder, MockUserRepoBuilder,
|
||||||
|
};
|
||||||
|
use crate::user::{ApActorType, ApUser};
|
||||||
|
|
||||||
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn make_user(id: uuid::Uuid, username: &str) -> ApUser {
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_user_repo(id: uuid::Uuid, username: &str) -> Arc<crate::testing::MockUserRepo> {
|
||||||
|
let user = make_user(id, username);
|
||||||
|
let uname = username.to_string();
|
||||||
|
let user2 = user.clone();
|
||||||
|
MockUserRepoBuilder::new()
|
||||||
|
.on_find_by_id(move |qid| {
|
||||||
|
if qid == id {
|
||||||
|
Ok(Some(user.clone()))
|
||||||
|
} else {
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.on_find_by_username(move |name| {
|
||||||
|
if name == uname {
|
||||||
|
Ok(Some(user2.clone()))
|
||||||
|
} else {
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helper ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn make_data(
|
||||||
|
blocklist_repo: Option<Arc<crate::testing::MockBlocklistRepo>>,
|
||||||
|
user_repo: Arc<crate::testing::MockUserRepo>,
|
||||||
|
handler: Arc<crate::testing::MockObjectHandler>,
|
||||||
|
) -> FederationData {
|
||||||
|
// Activity repo with real dedup tracking
|
||||||
|
let processed = Arc::new(Mutex::new(HashSet::<String>::new()));
|
||||||
|
let p1 = processed.clone();
|
||||||
|
let p2 = processed.clone();
|
||||||
|
let activity_repo = MockActivityRepoBuilder::new()
|
||||||
|
.on_is_activity_processed(move |id| Ok(p1.try_lock().unwrap().contains(id)))
|
||||||
|
.on_mark_activity_processed(move |id| {
|
||||||
|
p2.try_lock().unwrap().insert(id.to_string());
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.build();
|
||||||
|
|
||||||
|
FederationData::new(
|
||||||
|
activity_repo,
|
||||||
|
MockFollowRepoBuilder::new().build(),
|
||||||
|
MockActorRepoBuilder::new().build(),
|
||||||
|
blocklist_repo.unwrap_or_else(|| MockBlocklistRepoBuilder::new().build()),
|
||||||
|
user_repo,
|
||||||
|
MockContentReaderBuilder::new().build(),
|
||||||
|
handler,
|
||||||
|
"https://example.com".to_string(),
|
||||||
|
false,
|
||||||
|
"test".to_string(),
|
||||||
|
None,
|
||||||
|
std::time::Duration::from_secs(24 * 60 * 60),
|
||||||
|
Arc::new(crate::url_scheme::DefaultUrlScheme),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn check_guards_idempotency() {
|
||||||
|
use crate::activities::helpers::check_guards;
|
||||||
|
use activitypub_federation::config::FederationConfig;
|
||||||
|
|
||||||
|
let data_inner = make_data(
|
||||||
|
None,
|
||||||
|
build_user_repo(uuid::Uuid::new_v4(), "alice"),
|
||||||
|
MockObjectHandlerBuilder::new().build(),
|
||||||
|
);
|
||||||
|
crate::tls::ensure_crypto_provider();
|
||||||
|
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 = MockBlocklistRepoBuilder::new()
|
||||||
|
.on_is_domain_blocked(|domain| Ok(domain == "spam.example"))
|
||||||
|
.build();
|
||||||
|
let data_inner = make_data(
|
||||||
|
Some(blocklist),
|
||||||
|
build_user_repo(uuid::Uuid::new_v4(), "alice"),
|
||||||
|
MockObjectHandlerBuilder::new().build(),
|
||||||
|
);
|
||||||
|
crate::tls::ensure_crypto_provider();
|
||||||
|
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 mentions: Arc<Mutex<Vec<(Url, uuid::Uuid)>>> = Arc::new(Mutex::new(vec![]));
|
||||||
|
let m = mentions.clone();
|
||||||
|
let handler = MockObjectHandlerBuilder::new()
|
||||||
|
.on_on_mention(move |ap_id, user_id, _| {
|
||||||
|
m.try_lock().unwrap().push((ap_id.clone(), user_id));
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.build();
|
||||||
|
let data_inner = make_data(None, build_user_repo(local_user_id, "alice"), handler);
|
||||||
|
crate::tls::ensure_crypto_provider();
|
||||||
|
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 = mentions.lock().await;
|
||||||
|
assert_eq!(mentions.len(), 1);
|
||||||
|
assert_eq!(mentions[0].0, ap_id);
|
||||||
|
assert_eq!(mentions[0].1, local_user_id);
|
||||||
|
}
|
||||||
@@ -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");
|
|
||||||
}
|
|
||||||
|
|||||||
30
src/tls.rs
Normal file
30
src/tls.rs
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
//! rustls crypto provider setup.
|
||||||
|
//!
|
||||||
|
//! k-ap compiles rustls with the `ring` provider only (no aws-lc). reqwest 0.13
|
||||||
|
//! does not auto-select a provider in that configuration and panics
|
||||||
|
//! ("No provider set") on client construction, so we install ring as the
|
||||||
|
//! process default before any HTTP client is built. Idempotent; a provider
|
||||||
|
//! already installed by the host application wins.
|
||||||
|
|
||||||
|
pub(crate) fn ensure_crypto_provider() {
|
||||||
|
static ONCE: std::sync::Once = std::sync::Once::new();
|
||||||
|
ONCE.call_once(|| {
|
||||||
|
// Err means the application already installed a provider; keep theirs.
|
||||||
|
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn installs_provider_and_client_builds() {
|
||||||
|
ensure_crypto_provider();
|
||||||
|
ensure_crypto_provider();
|
||||||
|
assert!(rustls::crypto::CryptoProvider::get_default().is_some());
|
||||||
|
reqwest::Client::builder()
|
||||||
|
.build()
|
||||||
|
.expect("reqwest client builds with ring-only rustls");
|
||||||
|
}
|
||||||
|
}
|
||||||
63
src/url_scheme.rs
Normal file
63
src/url_scheme.rs
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
use url::Url;
|
||||||
|
|
||||||
|
/// Defines how ActivityPub URLs are constructed for local actors.
|
||||||
|
///
|
||||||
|
/// Implement this trait to use custom URL patterns (e.g. `/@username`
|
||||||
|
/// instead of `/users/{uuid}`). The default implementation
|
||||||
|
/// [`DefaultUrlScheme`] preserves the original `/users/{uuid}` layout.
|
||||||
|
pub trait UrlScheme: Send + Sync {
|
||||||
|
fn actor_url(&self, base_url: &str, user_id: uuid::Uuid) -> anyhow::Result<Url>;
|
||||||
|
fn inbox_url(&self, actor_url: &Url) -> anyhow::Result<Url>;
|
||||||
|
fn shared_inbox_url(&self, base_url: &str) -> Option<Url>;
|
||||||
|
fn outbox_url(&self, actor_url: &Url) -> anyhow::Result<Url>;
|
||||||
|
fn followers_url(&self, actor_url: &Url) -> anyhow::Result<Url>;
|
||||||
|
fn following_url(&self, actor_url: &Url) -> anyhow::Result<Url>;
|
||||||
|
fn activity_url(&self, base_url: &str) -> anyhow::Result<Url>;
|
||||||
|
fn extract_user_id(&self, url: &Url) -> Option<uuid::Uuid>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Default URL scheme: `/users/{uuid}` with sub-paths for inbox, outbox, etc.
|
||||||
|
pub struct DefaultUrlScheme;
|
||||||
|
|
||||||
|
impl UrlScheme for DefaultUrlScheme {
|
||||||
|
fn actor_url(&self, base_url: &str, user_id: uuid::Uuid) -> anyhow::Result<Url> {
|
||||||
|
Url::parse(&format!("{}/users/{}", base_url, user_id))
|
||||||
|
.map_err(|error| anyhow::anyhow!("invalid base_url: {error}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn inbox_url(&self, actor_url: &Url) -> anyhow::Result<Url> {
|
||||||
|
Url::parse(&format!("{}/inbox", actor_url))
|
||||||
|
.map_err(|error| anyhow::anyhow!("invalid actor_url: {error}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn shared_inbox_url(&self, base_url: &str) -> Option<Url> {
|
||||||
|
Url::parse(&format!("{}/inbox", base_url)).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn outbox_url(&self, actor_url: &Url) -> anyhow::Result<Url> {
|
||||||
|
Url::parse(&format!("{}/outbox", actor_url))
|
||||||
|
.map_err(|error| anyhow::anyhow!("invalid actor_url: {error}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn followers_url(&self, actor_url: &Url) -> anyhow::Result<Url> {
|
||||||
|
Url::parse(&format!("{}/followers", actor_url))
|
||||||
|
.map_err(|error| anyhow::anyhow!("invalid actor_url: {error}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn following_url(&self, actor_url: &Url) -> anyhow::Result<Url> {
|
||||||
|
Url::parse(&format!("{}/following", actor_url))
|
||||||
|
.map_err(|error| anyhow::anyhow!("invalid actor_url: {error}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn activity_url(&self, base_url: &str) -> anyhow::Result<Url> {
|
||||||
|
Url::parse(&format!("{}/activities/{}", base_url, uuid::Uuid::new_v4()))
|
||||||
|
.map_err(|error| anyhow::anyhow!("invalid base_url: {error}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_user_id(&self, url: &Url) -> Option<uuid::Uuid> {
|
||||||
|
let path = url.path();
|
||||||
|
path.strip_prefix("/users/")
|
||||||
|
.and_then(|s| s.split('/').next())
|
||||||
|
.and_then(|s| uuid::Uuid::parse_str(s).ok())
|
||||||
|
}
|
||||||
|
}
|
||||||
46
src/urls.rs
46
src/urls.rs
@@ -1,33 +1,23 @@
|
|||||||
use url::Url;
|
|
||||||
|
|
||||||
use crate::error::Error;
|
|
||||||
|
|
||||||
pub const AS_PUBLIC: &str = "https://www.w3.org/ns/activitystreams#Public";
|
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_CONTENT_TYPE: &str = "application/activity+json";
|
||||||
pub const AP_PAGE_SIZE: usize = 20;
|
pub const AP_PAGE_SIZE: usize = 20;
|
||||||
|
pub const INBOX_BODY_LIMIT: usize = 1024 * 1024;
|
||||||
|
|
||||||
pub fn extract_user_id_from_url(url: &Url) -> Option<uuid::Uuid> {
|
/// Returns the `@context` array for actor AP JSON.
|
||||||
let path = url.path();
|
/// Includes the W3C security vocabulary (needed for `publicKey` resolution)
|
||||||
path.strip_prefix("/users/")
|
/// and common Mastodon/Toot extensions (`discoverable`, `featured`, etc.).
|
||||||
.and_then(|s| s.split('/').next())
|
/// Activities use `WithContext::new_default` (plain AS context) — only actor
|
||||||
.and_then(|s| uuid::Uuid::parse_str(s).ok())
|
/// JSON needs the security vocab.
|
||||||
}
|
pub fn actor_ap_context() -> serde_json::Value {
|
||||||
|
serde_json::json!([
|
||||||
pub fn activity_url(base_url: &str) -> Result<Url, Error> {
|
"https://www.w3.org/ns/activitystreams",
|
||||||
Url::parse(&format!("{}/activities/{}", base_url, uuid::Uuid::new_v4()))
|
"https://w3id.org/security/v1",
|
||||||
.map_err(|e| Error::bad_request(anyhow::anyhow!(e)))
|
{
|
||||||
}
|
"manuallyApprovesFollowers": "as:manuallyApprovesFollowers",
|
||||||
|
"toot": "http://joinmastodon.org/ns#",
|
||||||
pub fn actor_url(base_url: &str, user_id: uuid::Uuid) -> Url {
|
"discoverable": "toot:discoverable",
|
||||||
Url::parse(&format!("{}/users/{}", base_url, user_id))
|
"featured": {"@id": "toot:featured", "@type": "@id"}
|
||||||
.expect("base_url is always a valid URL prefix")
|
}
|
||||||
}
|
])
|
||||||
|
|
||||||
/// Extract the username segment from a /users/:username URL.
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn extract_username_from_url(url: &Url) -> Option<String> {
|
|
||||||
url.path()
|
|
||||||
.strip_prefix("/users/")
|
|
||||||
.and_then(|s| s.split('/').next())
|
|
||||||
.map(|s| s.to_string())
|
|
||||||
}
|
}
|
||||||
|
|||||||
44
src/user.rs
44
src/user.rs
@@ -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,6 +8,33 @@ 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`].
|
/// 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.
|
/// Fetched via a signed HTTP request so strict instances (e.g. Threads) return full data.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -20,7 +48,7 @@ pub struct LookedUpActor {
|
|||||||
pub outbox_url: Option<Url>,
|
pub outbox_url: Option<Url>,
|
||||||
pub followers_url: Option<Url>,
|
pub followers_url: Option<Url>,
|
||||||
pub following_url: Option<Url>,
|
pub following_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>,
|
||||||
}
|
}
|
||||||
@@ -29,12 +57,24 @@ pub struct LookedUpActor {
|
|||||||
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]
|
||||||
|
|||||||
@@ -1,38 +0,0 @@
|
|||||||
use activitypub_federation::{
|
|
||||||
config::Data,
|
|
||||||
fetch::webfinger::{Webfinger, build_webfinger_response, extract_webfinger_name},
|
|
||||||
};
|
|
||||||
use axum::{
|
|
||||||
extract::Query,
|
|
||||||
http::header,
|
|
||||||
response::{IntoResponse, Response},
|
|
||||||
};
|
|
||||||
use serde::Deserialize;
|
|
||||||
|
|
||||||
use crate::data::FederationData;
|
|
||||||
use crate::error::Error;
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
pub struct WebfingerQuery {
|
|
||||||
resource: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn webfinger_handler(
|
|
||||||
Query(query): Query<WebfingerQuery>,
|
|
||||||
data: Data<FederationData>,
|
|
||||||
) -> Result<Response, Error> {
|
|
||||||
let name = extract_webfinger_name(&query.resource, &data)?;
|
|
||||||
|
|
||||||
let user = data
|
|
||||||
.user_repo
|
|
||||||
.find_by_username(name)
|
|
||||||
.await
|
|
||||||
.map_err(Error::from)?
|
|
||||||
.ok_or_else(|| Error::not_found(anyhow::anyhow!("user not found")))?;
|
|
||||||
|
|
||||||
let ap_id = crate::urls::actor_url(&data.base_url, user.id);
|
|
||||||
|
|
||||||
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)))?;
|
|
||||||
Ok(([(header::CONTENT_TYPE, "application/jrd+json")], body).into_response())
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user