Compare commits

..

172 Commits

Author SHA1 Message Date
8b3dfffd3b feat: followers/following links on remote profile; render remote post content as HTML
Some checks failed
lint / lint (push) Has been cancelled
test / unit (push) Has been cancelled
test / integration (push) Has been cancelled
lint / lint (pull_request) Failing after 9m39s
test / unit (pull_request) Failing after 10m48s
test / integration (pull_request) Failing after 16m54s
2026-05-15 00:04:54 +02:00
0b4c8c6c40 fix(frontend): render bio HTML properly instead of as escaped text
Some checks failed
lint / lint (push) Has been cancelled
test / unit (push) Has been cancelled
test / integration (push) Has been cancelled
lint / lint (pull_request) Failing after 9m24s
test / unit (pull_request) Failing after 12m17s
test / integration (pull_request) Failing after 17m10s
2026-05-15 00:01:13 +02:00
fcfc1750fc fix: truncate remote actor username to VARCHAR(32); fix outbox URL by following 'first' link
Some checks failed
lint / lint (push) Has been cancelled
test / unit (push) Has been cancelled
test / integration (push) Has been cancelled
lint / lint (pull_request) Failing after 9m33s
test / unit (pull_request) Failing after 10m46s
test / integration (pull_request) Failing after 16m54s
2026-05-14 23:53:33 +02:00
4d2d56c8ae fix(nats): revert to consumer.messages() — fetch() defaults no_wait:true which skips empty queues
Some checks failed
lint / lint (push) Has been cancelled
test / unit (push) Has been cancelled
test / integration (push) Has been cancelled
lint / lint (pull_request) Failing after 9m21s
test / unit (pull_request) Failing after 10m57s
test / integration (pull_request) Failing after 16m44s
2026-05-14 23:34:51 +02:00
a4377fe209 fix(nats): remove filter_subject from consumer config
Some checks failed
test / unit (push) Has been cancelled
test / integration (push) Has been cancelled
lint / lint (push) Has been cancelled
lint / lint (pull_request) Failing after 9m20s
test / unit (pull_request) Failing after 10m40s
test / integration (pull_request) Failing after 17m47s
2026-05-14 23:29:45 +02:00
16892007a3 fix(nats): use fetch().expires(30s) instead of messages() — without expires NATS returns empty immediately
Some checks failed
lint / lint (push) Has been cancelled
test / unit (push) Has been cancelled
test / integration (push) Has been cancelled
test / integration (pull_request) Has been cancelled
test / unit (pull_request) Has been cancelled
lint / lint (pull_request) Has been cancelled
2026-05-14 23:25:01 +02:00
40ed9b1ad8 fix(nats): delete+recreate stream when retention policy is incompatible
Some checks failed
lint / lint (push) Has been cancelled
test / unit (push) Has been cancelled
test / integration (push) Has been cancelled
lint / lint (pull_request) Has been cancelled
test / unit (pull_request) Has been cancelled
test / integration (pull_request) Has been cancelled
2026-05-14 23:19:41 +02:00
0caca58c1c fix(nats): align with movies-diary — Limits retention, single wildcard subject, filter_subject on consumer, prefixed publish
Some checks failed
lint / lint (push) Has been cancelled
test / unit (push) Has been cancelled
test / integration (push) Has been cancelled
lint / lint (pull_request) Has been cancelled
test / unit (pull_request) Has been cancelled
test / integration (pull_request) Has been cancelled
2026-05-14 23:17:57 +02:00
55c55424b5 chore: bump async-nats 0.38 → 0.48 to match movies-diary
Some checks failed
lint / lint (push) Has been cancelled
test / unit (push) Has been cancelled
test / integration (push) Has been cancelled
lint / lint (pull_request) Failing after 9m15s
test / unit (pull_request) Failing after 11m16s
test / integration (pull_request) Failing after 17m40s
2026-05-14 23:12:53 +02:00
9bda23f187 fix(nats): delete old push consumer before creating pull consumer
Some checks failed
lint / lint (push) Has been cancelled
test / unit (push) Has been cancelled
test / integration (push) Has been cancelled
lint / lint (pull_request) Failing after 9m10s
test / unit (pull_request) Failing after 10m54s
test / integration (pull_request) Failing after 17m11s
2026-05-14 23:07:05 +02:00
17d2a186e1 fix(nats): switch from push to pull consumer — pull is reliable, push had deliver_subject issues
Some checks failed
lint / lint (push) Has been cancelled
test / unit (push) Has been cancelled
test / integration (push) Has been cancelled
lint / lint (pull_request) Failing after 9m35s
test / unit (pull_request) Failing after 11m38s
test / integration (pull_request) Failing after 17m2s
2026-05-14 23:04:03 +02:00
7bbc702e85 debug: add INFO logging to ensure_stream and remote_actor_posts_handler
Some checks failed
lint / lint (push) Has been cancelled
test / unit (push) Has been cancelled
test / integration (push) Has been cancelled
lint / lint (pull_request) Failing after 9m42s
test / unit (pull_request) Failing after 10m52s
test / integration (pull_request) Failing after 17m20s
2026-05-14 22:59:12 +02:00
612b7f069b fix(frontend): profile fields — grid layout caps name col at 5rem, value gets remaining space
Some checks failed
lint / lint (push) Has been cancelled
test / unit (push) Has been cancelled
test / integration (push) Has been cancelled
lint / lint (pull_request) Failing after 9m22s
test / unit (pull_request) Failing after 11m23s
test / integration (pull_request) Failing after 17m2s
2026-05-14 22:51:07 +02:00
199fe91801 fix(frontend): profile fields table — overflow-x-auto, break-all values, styled links
Some checks failed
lint / lint (push) Has been cancelled
test / unit (push) Has been cancelled
test / integration (push) Has been cancelled
lint / lint (pull_request) Failing after 9m12s
test / unit (pull_request) Failing after 11m0s
test / integration (pull_request) Failing after 17m17s
2026-05-14 22:47:42 +02:00
f3df2061e1 fix(frontend): truncate long handles in remote user profile and card
Some checks failed
lint / lint (push) Has been cancelled
test / unit (push) Has been cancelled
test / integration (push) Has been cancelled
lint / lint (pull_request) Failing after 9m8s
test / unit (pull_request) Failing after 10m43s
test / integration (pull_request) Failing after 16m52s
2026-05-14 22:45:06 +02:00
df7fcf5096 fix: add federation.> to NATS stream subjects; update stream on startup; truncate long profile URLs
Some checks failed
lint / lint (push) Has been cancelled
test / unit (push) Has been cancelled
test / integration (push) Has been cancelled
lint / lint (pull_request) Failing after 9m48s
test / unit (pull_request) Failing after 10m52s
test / integration (pull_request) Failing after 16m50s
2026-05-14 22:43:43 +02:00
072d06cb46 fix(frontend): middleware rewrites remote actor URLs to avoid Next.js file-extension routing issue
Some checks failed
lint / lint (push) Has been cancelled
test / unit (push) Has been cancelled
test / integration (push) Has been cancelled
lint / lint (pull_request) Failing after 31s
test / unit (pull_request) Failing after 11m18s
test / integration (pull_request) Failing after 18m1s
2026-05-14 22:40:21 +02:00
4ce239fc87 fix(frontend): encode handle in URL to avoid Next.js routing issues with @ chars
Some checks failed
lint / lint (push) Has been cancelled
test / unit (push) Has been cancelled
test / integration (push) Has been cancelled
lint / lint (pull_request) Failing after 9m21s
test / unit (pull_request) Failing after 10m59s
test / integration (pull_request) Failing after 12s
2026-05-14 22:36:24 +02:00
72813d7c9b fix(frontend): remote user card link needs leading @ in handle URL
Some checks failed
lint / lint (push) Has been cancelled
test / unit (push) Has been cancelled
test / integration (push) Has been cancelled
lint / lint (pull_request) Failing after 9m22s
test / unit (pull_request) Failing after 10m46s
test / integration (pull_request) Failing after 17m9s
2026-05-14 22:30:59 +02:00
a472ae08fb feat(frontend): link remote user card avatar/name to profile page
Some checks failed
lint / lint (push) Has been cancelled
test / unit (push) Has been cancelled
test / integration (push) Has been cancelled
lint / lint (pull_request) Failing after 9m42s
test / unit (pull_request) Failing after 11m13s
test / integration (pull_request) Failing after 17m23s
2026-05-14 22:29:35 +02:00
8ef7c93970 feat(frontend): remote actor profile page with bio, fields, and posts
Some checks failed
lint / lint (push) Has been cancelled
test / unit (push) Has been cancelled
test / integration (push) Has been cancelled
lint / lint (pull_request) Failing after 10m5s
test / unit (pull_request) Failing after 10m51s
test / integration (pull_request) Failing after 17m1s
2026-05-14 22:25:53 +02:00
dc3afeca26 feat(worker): handle FetchRemoteActorPosts — fetch and store remote outbox notes 2026-05-14 22:23:20 +02:00
f3c3637ade feat(presentation): remote actor posts endpoint + extended RemoteActorResponse 2026-05-14 22:19:23 +02:00
00b369c6ad feat(bootstrap): add ap_repo to AppState 2026-05-14 22:16:20 +02:00
8c931c9b98 feat(event-payload): add FetchRemoteActorPosts to uniqueness test 2026-05-14 22:15:33 +02:00
1ddb6a3954 feat(activitypub-base): impl fetch_outbox_page; populate all RemoteActor fields in lookup_actor 2026-05-14 22:13:39 +02:00
70fc4fbcd0 feat(domain): RemoteActor fields, RemoteNote, FetchRemoteActorPosts event, fetch_outbox_page port 2026-05-14 22:08:26 +02:00
cbfaeb95ac docs: remote actor profile implementation plan 2026-05-14 22:04:11 +02:00
2e64e196b5 docs: remote actor profile design spec 2026-05-14 21:58:09 +02:00
ed6996e350 fix(activitypub-base): populate also_known_as, profile_url, attachment from fetched actor
Some checks failed
lint / lint (push) Has been cancelled
test / unit (push) Has been cancelled
test / integration (push) Has been cancelled
lint / lint (pull_request) Failing after 10m0s
test / unit (pull_request) Failing after 11m10s
test / integration (pull_request) Failing after 17m19s
2026-05-14 21:47:34 +02:00
0c4df36b95 fix(activitypub-base): populate avatar_url, bio, banner from fetched actor JSON
Some checks failed
lint / lint (push) Has been cancelled
test / unit (push) Has been cancelled
test / integration (push) Has been cancelled
lint / lint (pull_request) Failing after 9m46s
test / unit (pull_request) Failing after 11m36s
test / integration (pull_request) Failing after 17m47s
2026-05-14 21:45:55 +02:00
908789e639 fix: content negotiation for followers/following — resolve AP router conflict
Some checks failed
lint / lint (push) Has been cancelled
test / unit (push) Has been cancelled
test / integration (push) Has been cancelled
lint / lint (pull_request) Failing after 9m28s
test / unit (pull_request) Failing after 11m39s
test / integration (pull_request) Failing after 17m5s
2026-05-14 21:42:38 +02:00
fc3b4146a5 refactor(frontend): update API client to match cleaned REST routes 2026-05-14 21:34:26 +02:00
e64404cf40 refactor(routes): clean RESTful route table; content negotiation at /users/{username} 2026-05-14 21:31:02 +02:00
fbc02bc2f8 refactor(social): unified follow handler; remove federation handler module 2026-05-14 21:28:33 +02:00
d1f72c8308 refactor(users): content negotiation at GET /users/{username}; move lookup_handler; rename get_me_following 2026-05-14 21:25:49 +02:00
abc5f2b936 refactor(api): notification state changes use PATCH with JSON body 2026-05-14 21:05:30 +02:00
d5a116e483 feat(activitypub-base): impl actor_json port; return full user@domain handle from lookup 2026-05-14 20:47:41 +02:00
57110f3b75 feat(domain): add actor_json to FederationActionPort 2026-05-14 20:46:10 +02:00
812cf7b140 docs: REST API cleanup implementation plan 2026-05-14 20:44:31 +02:00
93967e53a2 docs: REST API cleanup design spec 2026-05-14 20:38:05 +02:00
dbd891d60d fix(activitypub-base): lookup_actor fetches WebFinger via HTTPS directly
Some checks failed
lint / lint (push) Has been cancelled
test / unit (push) Has been cancelled
test / integration (push) Has been cancelled
lint / lint (pull_request) Failing after 9m40s
test / unit (pull_request) Successful in 16m33s
test / integration (pull_request) Failing after 17m33s
2026-05-14 20:25:14 +02:00
baf8b57b6d fix(activitypub-base): strip leading @ from handle before WebFinger lookup 2026-05-14 20:16:00 +02:00
a7a331858d feat(frontend): remote actor lookup and follow from search page 2026-05-14 20:09:49 +02:00
31487882e0 feat(presentation): /federation/lookup and /federation/follow endpoints 2026-05-14 20:06:55 +02:00
a08bb3d698 feat(bootstrap): wire ActivityPubService as FederationActionPort in AppState 2026-05-14 20:03:49 +02:00
1d50b54227 fix(activitypub-base): use username as display_name in lookup_actor 2026-05-14 20:02:01 +02:00
fce819be7f feat(activitypub-base): impl FederationActionPort for ActivityPubService 2026-05-14 19:59:19 +02:00
0e45707d7e fix(postgres): persist and read avatar_url in remote_actor adapter 2026-05-14 19:57:13 +02:00
82f8772104 feat(domain): FederationActionPort trait + avatar_url on RemoteActor 2026-05-14 19:55:10 +02:00
8eb59bfac6 docs: remote actor search & follow implementation plan 2026-05-14 19:52:29 +02:00
62970d519a docs: remote actor search & follow spec 2026-05-14 19:48:34 +02:00
8602614e7c fix(ap): visibility-aware addressing — correct to/cc outbound, parse inbound to/cc
Some checks failed
lint / lint (push) Has been cancelled
test / unit (push) Has been cancelled
test / integration (push) Has been cancelled
lint / lint (pull_request) Failing after 9m25s
test / unit (pull_request) Successful in 16m39s
test / integration (pull_request) Failing after 17m35s
2026-05-14 19:34:43 +02:00
a5ea97bbaa fix: visibility-aware feeds — owner sees all, followers see followers-only, home feed includes non-public posts
Some checks failed
lint / lint (push) Has been cancelled
test / unit (push) Has been cancelled
test / integration (push) Has been cancelled
lint / lint (pull_request) Failing after 9m44s
test / unit (pull_request) Successful in 16m12s
test / integration (pull_request) Failing after 16m59s
2026-05-14 18:18:42 +02:00
fcbd132a78 fix: registration — parse AuthResponse correctly, auto-login after successful registration
Some checks failed
lint / lint (push) Has been cancelled
test / unit (push) Has been cancelled
test / integration (push) Has been cancelled
lint / lint (pull_request) Failing after 9m38s
test / unit (pull_request) Successful in 16m14s
test / integration (pull_request) Failing after 17m16s
2026-05-14 18:13:57 +02:00
e61e5b4cec fix: profile friends section shows profile owner's following list, not viewer's
Some checks failed
lint / lint (push) Has been cancelled
test / integration (push) Has been cancelled
test / unit (push) Has been cancelled
lint / lint (pull_request) Failing after 9m46s
test / unit (pull_request) Successful in 16m32s
test / integration (pull_request) Failing after 17m49s
2026-05-14 18:08:50 +02:00
171cfe4373 fix: follow/block handlers accept username string — was parsing as UUID
Some checks failed
lint / lint (push) Has been cancelled
test / unit (push) Has been cancelled
test / integration (push) Has been cancelled
lint / lint (pull_request) Failing after 9m38s
test / unit (pull_request) Successful in 16m13s
test / integration (pull_request) Failing after 17m31s
2026-05-14 18:03:16 +02:00
24bfda8458 feat: extract and save hashtags on thought creation
Some checks failed
lint / lint (push) Has been cancelled
test / unit (push) Has been cancelled
test / integration (push) Has been cancelled
lint / lint (pull_request) Failing after 9m18s
test / unit (pull_request) Successful in 16m4s
test / integration (pull_request) Failing after 17m16s
2026-05-14 18:01:07 +02:00
5c9acdecc1 fix(postgres): get_thread uses recursive CTE — fetches all nested replies not just direct ones
Some checks failed
lint / lint (push) Has been cancelled
test / integration (push) Has been cancelled
test / unit (push) Has been cancelled
lint / lint (pull_request) Failing after 9m18s
test / unit (pull_request) Successful in 16m9s
test / integration (pull_request) Failing after 17m5s
2026-05-14 17:56:03 +02:00
255ff549a4 fix: getThoughtThread parses flat array and builds nested tree — backend returns Vec not nested object
Some checks failed
lint / lint (push) Has been cancelled
test / unit (push) Has been cancelled
test / integration (push) Has been cancelled
lint / lint (pull_request) Failing after 9m51s
test / unit (pull_request) Successful in 16m52s
test / integration (pull_request) Failing after 17m6s
2026-05-14 17:52:52 +02:00
c67371231e fix(thoughts): thought_to_json uses camelCase — POST/GET responses now match ThoughtSchema
Some checks failed
lint / lint (push) Has been cancelled
test / unit (push) Has been cancelled
test / integration (push) Has been cancelled
lint / lint (pull_request) Failing after 9m33s
test / unit (pull_request) Successful in 16m22s
test / integration (pull_request) Failing after 17m18s
2026-05-14 17:49:22 +02:00
68261c4b2b fix: include own thoughts in home feed
Some checks failed
lint / lint (push) Has been cancelled
test / unit (push) Has been cancelled
test / integration (push) Has been cancelled
lint / lint (pull_request) Failing after 9m27s
test / unit (pull_request) Successful in 16m33s
test / integration (pull_request) Failing after 17m15s
2026-05-14 17:47:13 +02:00
057ed3ccbf fix: joinedAt nullable in UserSchema, guard null in profile page 2026-05-14 17:45:01 +02:00
8ef3a300bc fix(users): return camelCase from GET /users list — UserSummary was snake_case
Some checks failed
lint / lint (push) Has been cancelled
test / unit (push) Has been cancelled
test / integration (push) Has been cancelled
lint / lint (pull_request) Failing after 9m34s
test / unit (pull_request) Successful in 16m5s
test / integration (pull_request) Failing after 18m6s
2026-05-14 17:44:59 +02:00
c8c430fe7f fix: getUserProfile calls /users/{username}/profile to avoid AP route conflict
Some checks failed
lint / lint (push) Has been cancelled
test / unit (push) Has been cancelled
test / integration (push) Has been cancelled
lint / lint (pull_request) Failing after 9m13s
test / unit (pull_request) Successful in 15m54s
test / integration (pull_request) Failing after 18m39s
2026-05-14 17:39:20 +02:00
ec0e24db8d fix: add GET /users/{username}/profile REST endpoint — AP actor owns /users/{username} 2026-05-14 17:39:19 +02:00
7cb6b94b08 fix: per_page not perPage in Zod schemas — raw serde_json keys are snake_case
Some checks failed
lint / lint (push) Has been cancelled
test / unit (push) Has been cancelled
test / integration (push) Has been cancelled
lint / lint (pull_request) Failing after 9m36s
test / unit (pull_request) Successful in 17m6s
test / integration (pull_request) Failing after 17m0s
2026-05-14 17:32:39 +02:00
b95cebc799 fix: await searchParams and params for Next.js 15 async API, compute totalPages in all-users page
Some checks failed
lint / lint (push) Has been cancelled
test / unit (push) Has been cancelled
test / integration (push) Has been cancelled
lint / lint (pull_request) Failing after 9m41s
test / unit (pull_request) Successful in 16m33s
test / integration (pull_request) Failing after 17m3s
2026-05-14 17:28:35 +02:00
12adddaa16 fix(nats): use explicit subject prefixes — WorkQueue retention disallows > wildcard
Some checks failed
lint / lint (push) Has been cancelled
test / unit (push) Has been cancelled
test / integration (push) Has been cancelled
lint / lint (pull_request) Failing after 9m9s
test / unit (pull_request) Successful in 16m19s
test / integration (pull_request) Failing after 17m5s
2026-05-14 17:20:21 +02:00
44385adb6b feat: update frontend to work with v2 backend — camelCase, new endpoints, nested author
Some checks failed
lint / lint (push) Has been cancelled
test / unit (push) Has been cancelled
test / integration (push) Has been cancelled
lint / lint (pull_request) Failing after 9m38s
test / unit (pull_request) Successful in 16m2s
test / integration (pull_request) Failing after 17m2s
2026-05-14 17:14:27 +02:00
7110f30e16 fix: top-friends returns usernames not UUIDs
Some checks failed
lint / lint (push) Has been cancelled
test / unit (push) Has been cancelled
test / integration (push) Has been cancelled
lint / lint (pull_request) Failing after 9m40s
test / unit (pull_request) Successful in 16m13s
test / integration (pull_request) Failing after 17m12s
2026-05-14 17:08:12 +02:00
aadd876994 feat: camelCase JSON responses, isFollowedByViewer, customCss, GET /users/me/following-list
Some checks failed
lint / lint (push) Has been cancelled
test / unit (push) Has been cancelled
test / integration (push) Has been cancelled
lint / lint (pull_request) Failing after 9m15s
test / unit (pull_request) Successful in 16m3s
test / integration (pull_request) Failing after 17m19s
2026-05-14 17:04:42 +02:00
d3b7ecad15 fix(ap): add url field to Note, handle Delete(actor) and Tombstone objects
Some checks failed
lint / lint (push) Has been cancelled
test / unit (push) Has been cancelled
test / integration (push) Has been cancelled
lint / lint (pull_request) Failing after 9m12s
test / unit (pull_request) Successful in 15m52s
test / integration (pull_request) Failing after 17m10s
2026-05-14 16:47:17 +02:00
458feebcdd feat(nats): migrate to JetStream — at-least-once delivery with durable consumer
Some checks failed
lint / lint (push) Has been cancelled
test / unit (push) Has been cancelled
test / integration (push) Has been cancelled
lint / lint (pull_request) Failing after 9m25s
test / unit (pull_request) Successful in 15m53s
test / integration (pull_request) Failing after 16m42s
2026-05-14 16:41:17 +02:00
550865bad4 fix: resolve all clippy warnings — redundant closures, dead code, collapsible_if, needless borrow
Some checks failed
lint / lint (push) Has been cancelled
test / unit (push) Has been cancelled
test / integration (push) Has been cancelled
lint / lint (pull_request) Failing after 9m25s
test / unit (pull_request) Successful in 16m57s
test / integration (pull_request) Failing after 17m29s
2026-05-14 16:33:34 +02:00
10c4a66de5 Refactor handlers and OpenAPI documentation for improved readability and consistency
Some checks failed
lint / lint (push) Has been cancelled
test / unit (push) Has been cancelled
test / integration (push) Has been cancelled
lint / lint (pull_request) Failing after 6m49s
test / unit (pull_request) Successful in 16m24s
test / integration (pull_request) Failing after 17m7s
- Reorganized imports in health, notifications, social, thoughts, and users handlers for clarity.
- Updated function signatures in handlers to improve readability by aligning parameters.
- Enhanced JSON response formatting in notifications and thoughts handlers.
- Improved error handling in user-related functions.
- Refactored OpenAPI documentation to maintain consistent formatting and structure.
- Cleaned up unnecessary code and comments across various files.
- Ensured consistent use of `Arc` for shared state in AppState and WorkerHandlers.
2026-05-14 16:28:57 +02:00
004bfb427b feat: implement merge readiness plan to close gaps between v2 and v1
Some checks failed
lint / lint (push) Has been cancelled
test / unit (push) Has been cancelled
test / integration (push) Has been cancelled
lint / lint (pull_request) Failing after 5m8s
test / unit (pull_request) Successful in 16m18s
test / integration (pull_request) Failing after 16m59s
- Task 1: Fix feed response hydration by adding `to_thought_response` helper and updating feed handlers to return full `ThoughtResponse`.
- Task 2: Wire follower/following REST routes for user feeds.
- Task 3: Add user listing and count endpoints, including `GET /users` and `GET /users/count`.
- Task 4: Implement popular tags feature with `GET /tags/popular`.
- Task 5: Enhance configuration with HOST, CORS_ORIGINS, and optional rate limiting using tower-governor.
2026-05-14 16:28:18 +02:00
e6f4a6256f refactor(application): fix 4 code smells — validate username input, extract ownership guard and dedup helpers
Some checks failed
lint / lint (push) Has been cancelled
test / unit (push) Has been cancelled
test / integration (push) Has been cancelled
lint / lint (pull_request) Failing after 5m12s
test / unit (pull_request) Successful in 16m23s
test / integration (pull_request) Failing after 17m26s
2026-05-14 16:27:03 +02:00
dd7beb7ab4 chore: add compose.yml for local dev (postgres + nats)
Some checks failed
lint / lint (push) Has been cancelled
test / unit (push) Has been cancelled
test / integration (push) Has been cancelled
lint / lint (pull_request) Failing after 5m9s
test / unit (pull_request) Successful in 16m14s
test / integration (pull_request) Failing after 17m3s
2026-05-14 16:22:16 +02:00
ddd9b17ed7 test(application): fill unit test gaps — api_keys, profile, auth, thoughts, social
Some checks failed
lint / lint (push) Has been cancelled
test / unit (push) Has been cancelled
test / integration (push) Has been cancelled
lint / lint (pull_request) Failing after 5m7s
test / unit (pull_request) Successful in 16m2s
test / integration (pull_request) Failing after 16m57s
2026-05-14 16:19:35 +02:00
d50c13a2db refactor: wrap direct port calls behind use cases — notifications, search, popular_tags
Some checks failed
lint / lint (push) Has been cancelled
test / unit (push) Has been cancelled
test / integration (push) Has been cancelled
lint / lint (pull_request) Failing after 5m7s
test / unit (pull_request) Successful in 15m51s
test / integration (pull_request) Failing after 17m3s
2026-05-14 16:13:34 +02:00
004f3cd4d2 fix(arch): move AP router assembly to bootstrap — presentation no longer depends on activitypub-base 2026-05-14 16:09:21 +02:00
970f5a1644 fix: move user_feed to FeedRepository — proper counts and viewer flags for user timelines 2026-05-14 16:06:38 +02:00
ecba9267cf fix: compute liked_by_viewer/boosted_by_viewer from DB — viewer_id was ignored in all feed queries 2026-05-14 16:03:55 +02:00
4890501512 chore: add deploy.sh — build amd64 image and push to registry
Some checks failed
lint / lint (push) Has been cancelled
test / integration (push) Has been cancelled
test / unit (push) Has been cancelled
lint / lint (pull_request) Failing after 5m4s
test / unit (pull_request) Successful in 16m39s
test / integration (pull_request) Failing after 16m56s
2026-05-14 15:46:53 +02:00
cc9658975f fix: tag feed returns full FeedEntry with author and counts
Some checks failed
lint / lint (push) Has been cancelled
test / unit (push) Has been cancelled
test / integration (push) Has been cancelled
lint / lint (pull_request) Failing after 5m2s
test / unit (pull_request) Successful in 16m3s
test / integration (pull_request) Failing after 16m59s
2026-05-14 15:43:02 +02:00
38b4774a63 feat(bootstrap): configurable HOST, CORS_ORIGINS, and optional rate limiting
Some checks failed
lint / lint (push) Has been cancelled
test / unit (push) Has been cancelled
test / integration (push) Has been cancelled
lint / lint (pull_request) Failing after 5m3s
test / unit (pull_request) Successful in 16m6s
test / integration (pull_request) Failing after 17m45s
2026-05-14 15:37:38 +02:00
9b47779e63 feat: GET /tags/popular — top tags by usage count 2026-05-14 15:34:40 +02:00
eb7dbb0aee feat: GET /users (search/list) and GET /users/count 2026-05-14 15:34:37 +02:00
a2cc4fba21 feat(presentation): wire GET /users/{username}/follower-list and /following-list 2026-05-14 15:34:33 +02:00
6eba91e699 fix(presentation): hydrate feed responses with full ThoughtResponse — remove UUID-only stubs 2026-05-14 15:31:44 +02:00
cf94b0ba6c ci(test): split into unit (no DB) and integration (postgres) jobs
Some checks failed
lint / lint (push) Has been cancelled
test / unit (push) Has been cancelled
test / integration (push) Has been cancelled
lint / lint (pull_request) Failing after 5m9s
test / unit (pull_request) Successful in 15m59s
test / integration (pull_request) Failing after 16m59s
2026-05-14 15:17:59 +02:00
057f10cb69 chore: Dockerfile, README, LICENSE, .env.example, CI workflows (lint/test/deploy)
Some checks failed
lint / lint (push) Has been cancelled
test / test (push) Has been cancelled
lint / lint (pull_request) Failing after 5m3s
test / test (pull_request) Failing after 18m48s
2026-05-14 15:15:18 +02:00
ed744046f4 refactor(activitypub-base): eliminate double get_local_actor — extract thought_note_json, remove dead broadcast_to_followers methods 2026-05-14 14:36:54 +02:00
931894d77a refactor(activitypub-base): extract accepted_follower_inboxes helper — eliminate 7x duplicated filtering 2026-05-14 14:23:38 +02:00
2485869af6 fix(activitypub-base): deterministic announce IDs so Undo(Announce) can reference original activity 2026-05-14 14:16:48 +02:00
b0b3c6a59b feat: BoostRemoved → Undo(Announce) fan-out via OutboundFederationPort 2026-05-14 14:10:11 +02:00
eaf079069f fix(application): gate federation broadcast on Public/Unlisted visibility only 2026-05-14 14:03:31 +02:00
a37c877172 chore(worker): remove stale dependencies after business logic migration 2026-05-14 13:59:45 +02:00
904916d4c1 refactor(worker): thin handlers + factory — move all business logic to application services 2026-05-14 13:57:14 +02:00
057fc29abc fix(activitypub-base): validate update Note id URL, add updated field to Update Notes 2026-05-14 13:50:05 +02:00
1fa8389a69 feat(activitypub-base): Announce broadcast + impl OutboundFederationPort for ActivityPubService 2026-05-14 13:46:31 +02:00
83e87e644b test(application): document user-not-found silent-skip and locality guard in FederationEventService 2026-05-14 13:44:13 +02:00
13282fc88e feat(application): FederationEventService — content fan-out business logic 2026-05-14 13:40:10 +02:00
10605bbf2f test(application): add self_boost_creates_no_notification test 2026-05-14 13:37:29 +02:00
2d742bdbe3 feat(application): NotificationEventService — move notification business logic out of worker 2026-05-14 13:35:20 +02:00
925856f6b8 feat(domain): OutboundFederationPort — thin AP broadcast abstraction 2026-05-14 13:32:23 +02:00
114d9f9558 fix(activitypub): re-export NoteType from activitypub-base, drop direct activitypub_federation dep 2026-05-14 12:51:47 +02:00
69b55058ce feat(worker): use EventConsumerAdapter<NatsMessageSource> — transport-agnostic consuming 2026-05-14 12:37:16 +02:00
e995b29be1 refactor(nats): replace NatsEventConsumer with NatsMessageSource implementing MessageSource 2026-05-14 12:36:45 +02:00
c202eded05 feat(event-transport): MessageSource trait + EventConsumerAdapter for transport-agnostic consuming 2026-05-14 12:34:45 +02:00
f9ca5836fb refactor: rename event-publisher → event-transport 2026-05-14 12:30:58 +02:00
7963278189 docs: event-transport rename + consumer abstraction plan 2026-05-14 12:29:28 +02:00
ff82764eb0 feat(bootstrap): wire EventPublisherAdapter<NatsTransport> — transport-agnostic event publishing 2026-05-14 12:24:15 +02:00
cfc8c19175 refactor(nats): strip NatsEventPublisher, add NatsTransport implementing Transport 2026-05-14 12:22:11 +02:00
a684c922e0 feat(event-publisher): Transport trait + EventPublisherAdapter for transport-agnostic event routing 2026-05-14 12:20:29 +02:00
42d3dbd251 docs: event-publisher transport abstraction plan 2026-05-14 12:18:41 +02:00
c072ee95cd refactor(presentation): pure HTTP library — remove concrete adapter deps and binary 2026-05-14 12:07:18 +02:00
0c7a6fe9be feat(bootstrap): composition root with Config + factory.rs 2026-05-14 12:05:00 +02:00
53185efe5e docs: bootstrap factory implementation plan 2026-05-14 12:00:20 +02:00
1866eef770 feat(presentation): OpenAPI docs at /docs (Swagger) and /scalar 2026-05-14 11:41:12 +02:00
137d1a0c6a feat(presentation): add utoipa::path annotations to all handlers 2026-05-14 11:34:02 +02:00
4f990afe5e feat(api-types): add utoipa ToSchema and IntoParams derives 2026-05-14 11:30:11 +02:00
fb8c75af72 docs: OpenAPI documentation implementation plan 2026-05-14 11:27:43 +02:00
2524440fe4 feat(presentation): GET /health endpoint 2026-05-14 11:21:58 +02:00
6082766935 feat(presentation): GET /users/me, GET /users/{username}/thoughts, GET /tags/{name} 2026-05-14 11:20:48 +02:00
e408a53136 docs: v1 parity gaps implementation plan 2026-05-14 11:19:29 +02:00
68fe8624cd feat(worker): Reply notification when ThoughtCreated has in_reply_to_id 2026-05-14 11:08:30 +02:00
1127a5946f feat: UserUnblocked + UserRegistered events, fix unblock_user and register signatures 2026-05-14 11:06:36 +02:00
f0b87311e3 docs: audit gap fixes implementation plan 2026-05-14 11:03:36 +02:00
ea14035062 fix: wire PgActivityPubRepository into ThoughtsObjectHandler — closes activitypub→postgres violation 2026-05-14 10:59:43 +02:00
4ae3af8086 refactor(activitypub): ThoughtsObjectHandler uses ActivityPubRepository port, drops postgres dep 2026-05-14 10:58:25 +02:00
e0b0a71f1d feat(postgres): PgActivityPubRepository implementing ActivityPubRepository port 2026-05-14 10:55:58 +02:00
5f8e96b9be feat(domain): ActivityPubRepository port with federation vocabulary 2026-05-14 10:52:35 +02:00
54bd1c193b docs: ActivityPubRepository port implementation plan 2026-05-14 10:50:06 +02:00
e0a27c99a4 feat(presentation): ActivityPub routes — WebFinger, NodeInfo, inbox, outbox 2026-05-14 10:28:22 +02:00
2080fec347 feat(activitypub): ThoughtNote AP object and ThoughtsObjectHandler 2026-05-14 10:23:35 +02:00
21b6a04f97 feat(postgres-federation): FederationRepository and ApUserRepository 2026-05-14 10:19:57 +02:00
ebc612a311 feat(activitypub-base): copy from movies-diary with username-based actor URLs 2026-05-14 10:15:34 +02:00
c9b389a00c docs: v2 Plan 4 federation implementation plan 2026-05-14 10:10:36 +02:00
3318635da6 feat(presentation): NatsEventPublisher with no-op fallback when NATS_URL unset 2026-05-14 10:00:39 +02:00
2e702c64cc feat(worker): consumer loop binary connecting NATS to handlers 2026-05-14 09:58:33 +02:00
2cee884fe1 feat(worker): NotificationHandler and FederationHandler stub 2026-05-14 09:58:31 +02:00
a0893b1c69 feat(nats): NatsEventPublisher and NatsEventConsumer with payload conversion 2026-05-14 09:54:50 +02:00
57232705fe feat(event-payload): serializable NATS event payload types 2026-05-14 09:48:58 +02:00
02de6b6f83 docs: v2 Plan 3 events+worker implementation plan 2026-05-14 09:45:57 +02:00
b599047d98 feat(presentation): wire SearchPort, /search returns thoughts + users 2026-05-14 09:38:02 +02:00
4eeaea2a14 feat(postgres): upgrade FeedRepository search from ILIKE to pg_trgm 2026-05-14 09:28:02 +02:00
ebf0aaab58 feat(postgres-search): PgSearchRepository using pg_trgm 2026-05-14 09:26:36 +02:00
a3534317de feat(domain): SearchPort trait with thought and user search 2026-05-14 09:23:22 +02:00
6e5d0de636 feat(postgres): pg_trgm extension and GIN search indexes 2026-05-14 09:21:59 +02:00
bfe6db2215 docs: v2 Plan 2 search implementation plan 2026-05-14 09:19:52 +02:00
f75e796faf fix: align follow event with accepted state; redact internal error details 2026-05-14 04:10:27 +02:00
c5d262c68f feat(presentation): routes and main — Plan 1 complete 2026-05-14 04:06:17 +02:00
38106ecdb6 feat(presentation): all handlers 2026-05-14 04:00:04 +02:00
fb39ea2469 feat(presentation): state, errors, extractors, auth and user handlers 2026-05-14 03:56:42 +02:00
adc2102927 feat(application): all use cases 2026-05-14 03:52:36 +02:00
134ecdcfb4 feat(api-types): request and response DTOs 2026-05-14 03:48:16 +02:00
2b428b2b0a feat(auth): JWT AuthService and Argon2 PasswordHasher 2026-05-14 03:47:08 +02:00
69608cfc75 feat(postgres): Tag, ApiKey, TopFriend, Notification, RemoteActor, Feed repos 2026-05-14 03:45:11 +02:00
02ce3a49b4 feat(postgres): LikeRepository, BoostRepository 2026-05-14 03:40:15 +02:00
1dab9ffbfb feat(postgres): FollowRepository, BlockRepository 2026-05-14 03:38:20 +02:00
9dd04541ac feat(postgres): ThoughtRepository 2026-05-14 03:35:39 +02:00
fe9655ee96 feat(postgres): UserRepository 2026-05-14 03:32:56 +02:00
62ee73e302 feat(postgres): initial migrations 2026-05-14 03:26:42 +02:00
80b656341d feat(domain): ports, events, test helpers 2026-05-14 03:23:42 +02:00
4b8d1027c1 feat(domain): models 2026-05-14 03:18:49 +02:00
94a3f414e4 feat(domain): errors and value objects 2026-05-14 03:16:22 +02:00
63a7001165 chore: scaffold v2 workspace 2026-05-14 03:07:45 +02:00
321571aae9 docs: v2 Plan 1 implementation plan (core) 2026-05-14 03:03:02 +02:00
9d6e3298f1 docs: clarify presentation layer is REST-only 2026-05-14 01:09:07 +02:00
6fd9a76e68 docs: v2 architecture rewrite design spec 2026-05-14 01:08:13 +02:00
468 changed files with 39602 additions and 28694 deletions

View File

@@ -1,9 +0,0 @@
[registry]
default = "gitea"
[registries.gitea]
index = "sparse+https://git.gabrielkaszewski.dev/api/packages/GKaszewski/cargo/" # Sparse index
# index = "https://git.gabrielkaszewski.dev/GKaszewski/_cargo-index.git" # Git
[net]
git-fetch-with-cli = true

View File

@@ -1,18 +0,0 @@
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"if": "Bash(git commit*)",
"command": "cargo fmt --all 2>&1 && cargo clippy --workspace 2>&1 || echo '{\"continue\": false, \"stopReason\": \"cargo fmt or clippy failed — fix before committing\"}'",
"timeout": 120,
"statusMessage": "Running cargo fmt + clippy..."
}
]
}
]
}
}

27
.env Normal file
View File

@@ -0,0 +1,27 @@
POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres
POSTGRES_DB=thoughts
HOST=0.0.0.0
PORT=8000
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/thoughts"
JWT_SECRET=secret
# Public base URL — used for ActivityPub actor URLs and canonical links
BASE_URL=http://localhost:8000
# CORS — comma-separated allowed origins, or * for permissive (default: *)
CORS_ORIGINS=*
# CORS_ORIGINS=https://your-nextjs-app.example.com
# Rate limiting — max requests per minute per IP (disabled by default)
# RATE_LIMIT=60
ALLOW_REGISTRATION=true # set to false to disable new sign-ups
RUST_ENV=development # set to "production" to disable AP debug mode
# NATS event bus (optional — federation and notifications still work without it,
# but events will not be delivered to the worker)
NATS_URL=nats://localhost:4222
# Logging
RUST_LOG=info

View File

@@ -9,7 +9,7 @@ BASE_URL=http://localhost:3000
# Optional # Optional
HOST=0.0.0.0 HOST=0.0.0.0
PORT=8000 PORT=3000
# CORS — comma-separated allowed origins, or * for permissive (default: *) # CORS — comma-separated allowed origins, or * for permissive (default: *)
CORS_ORIGINS=* CORS_ORIGINS=*
@@ -24,21 +24,5 @@ RUST_ENV=development # set to "production" to disable AP debug mode
# but events will not be delivered to the worker) # but events will not be delivered to the worker)
# NATS_URL=nats://localhost:4222 # NATS_URL=nats://localhost:4222
# Media storage — local filesystem (default) or S3/MinIO
STORAGE_BACKEND=local
STORAGE_PATH=./media # required when STORAGE_BACKEND=local
# STORAGE_PREFIX= # optional key prefix
# S3/MinIO (set STORAGE_BACKEND=s3 to use)
# S3_ENDPOINT=http://localhost:9000
# S3_ACCESS_KEY_ID=minioadmin
# S3_SECRET_ACCESS_KEY=minioadmin
# S3_BUCKET=thoughts
# S3_REGION=us-east-1
# Upload limits (optional, defaults shown)
# UPLOAD_MAX_BYTES=5242880
# UPLOAD_ALLOWED_TYPES=image/jpeg,image/png,image/gif,image/webp,image/avif
# Logging # Logging
RUST_LOG=info RUST_LOG=info

View File

@@ -1,11 +1,52 @@
name: deploy name: deploy
on: on:
workflow_dispatch: push:
branches: [master]
tags: ["v*"]
env:
REGISTRY: git.gabrielkaszewski.dev
IMAGE: git.gabrielkaszewski.dev/gkaszewski/thoughts
jobs: jobs:
deploy: build-and-push:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Log in to registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Docker metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.IMAGE }}
tags: |
type=ref,event=branch
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=registry,ref=${{ env.IMAGE }}:buildcache
cache-to: type=registry,ref=${{ env.IMAGE }}:buildcache,mode=max
deploy:
needs: build-and-push
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/master'
steps: steps:
- name: Deploy via SSH - name: Deploy via SSH
uses: appleboy/ssh-action@v1 uses: appleboy/ssh-action@v1
@@ -14,6 +55,5 @@ jobs:
username: ${{ secrets.DEPLOY_USER }} username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.DEPLOY_KEY }} key: ${{ secrets.DEPLOY_KEY }}
script: | script: |
docker pull registry.gabrielkaszewski.dev/thoughts:latest docker pull ${{ env.IMAGE }}:latest
docker pull registry.gabrielkaszewski.dev/thoughts-frontend:latest
docker compose -f /opt/thoughts/docker-compose.yml up -d docker compose -f /opt/thoughts/docker-compose.yml up -d

View File

@@ -21,3 +21,32 @@ jobs:
--exclude postgres-federation \ --exclude postgres-federation \
--exclude postgres-search --exclude postgres-search
# Integration tests — require a real PostgreSQL instance.
# These test that the SQL queries in the adapter crates are correct.
integration:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_PASSWORD: postgres
POSTGRES_DB: thoughts_test
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
env:
DATABASE_URL: postgres://postgres:postgres@localhost:5432/thoughts_test
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- name: integration tests
run: |
cargo test \
-p postgres \
-p postgres-federation \
-p postgres-search

3
.gitignore vendored
View File

@@ -1,6 +1,3 @@
.env .env
/.superpowers/
/target /target
/docs/superpowers/
/media

View File

@@ -1,164 +0,0 @@
# Architecture
Hexagonal (ports & adapters) architecture. Dependencies point inward — adapters implement domain ports, application orchestrates use cases, presentation handles HTTP.
## Crate dependency graph
```mermaid
graph TD
subgraph Entry Points
bootstrap["bootstrap<br/><small>HTTP server, DI wiring</small>"]
worker["worker<br/><small>background job consumer</small>"]
end
subgraph Interface Layer
presentation["presentation<br/><small>axum handlers, extractors, AppState</small>"]
api_types["api-types<br/><small>DTOs, OpenAPI</small>"]
end
subgraph Application Layer
application["application<br/><small>use cases, FederationEventService</small>"]
end
subgraph Domain Layer
domain["domain<br/><small>models, value objects, events, port traits</small>"]
end
subgraph Adapters
postgres["postgres<br/><small>UserRepo, ThoughtRepo, LikeRepo,<br/>BoostRepo, FollowRepo, BlockRepo,<br/>TagRepo, FeedRepo, FederationContentRepo, ...</small>"]
activitypub["activitypub<br/><small>FederationActionPort,<br/>FederationBroadcastPort,<br/>FederationSchedulerPort<br/>(wraps k-ap)</small>"]
postgres_fed["postgres-federation<br/><small>k-ap DB traits</small>"]
postgres_search["postgres-search<br/><small>SearchPort</small>"]
auth["auth<br/><small>AuthService, ApiKeyService</small>"]
nats["nats<br/><small>EventPublisher, EventConsumer</small>"]
storage["storage<br/><small>MediaStore</small>"]
event_transport["event-transport<br/><small>event delivery</small>"]
event_payload["event-payload<br/><small>event serialization</small>"]
end
bootstrap --> presentation
bootstrap --> application
bootstrap --> postgres
bootstrap --> postgres_fed
bootstrap --> postgres_search
bootstrap --> activitypub
bootstrap --> auth
bootstrap --> nats
bootstrap --> storage
bootstrap --> event_transport
bootstrap --> event_payload
worker --> application
worker --> activitypub
worker --> postgres
worker --> postgres_fed
worker --> nats
worker --> event_transport
worker --> event_payload
presentation --> application
presentation --> api_types
presentation --> domain
application --> domain
postgres --> domain
activitypub --> domain
postgres_fed -.-> domain
postgres_search --> domain
postgres_search --> postgres
auth --> domain
nats --> domain
storage --> domain
event_transport --> domain
event_payload --> domain
```
## Domain ports
```mermaid
classDiagram
class domain {
<<core>>
}
namespace Data Ports {
class UserRepository {
<<trait>>
find_by_id()
find_by_username()
save()
update_profile()
}
class ThoughtRepository {
<<trait>>
save()
find_by_id()
delete()
update_content()
}
class LikeRepository { <<trait>> }
class BoostRepository { <<trait>> }
class FollowRepository { <<trait>> }
class BlockRepository { <<trait>> }
class TagRepository { <<trait>> }
class FeedRepository { <<trait>> }
class NotificationRepository { <<trait>> }
class EngagementRepository { <<trait>> }
class SearchPort { <<trait>> }
}
namespace Federation Ports {
class FederationContentRepository {
<<trait>>
outbox_entries_for_actor()
find_remote_actor_id()
intern_remote_actor()
accept_note()
retract_note()
}
class FederationBroadcastPort {
<<trait>>
broadcast_create()
broadcast_delete()
broadcast_update()
broadcast_announce()
broadcast_like()
}
class FederationActionPort {
<<supertrait>>
}
class FederationLookupPort { <<trait>> }
class FederationFollowPort { <<trait>> }
class FederationFollowRequestPort { <<trait>> }
class FederationFetchPort { <<trait>> }
class FederationBlockPort { <<trait>> }
class FederationSchedulerPort { <<trait>> }
}
namespace Infra Ports {
class EventPublisher { <<trait>> }
class EventConsumer { <<trait>> }
class AuthService { <<trait>> }
class PasswordHasher { <<trait>> }
class MediaStore { <<trait>> }
}
FederationActionPort --|> FederationLookupPort
FederationActionPort --|> FederationFollowPort
FederationActionPort --|> FederationFollowRequestPort
FederationActionPort --|> FederationFetchPort
FederationActionPort --|> FederationBlockPort
```
## Dependency rule
```
bootstrap/worker ──► presentation ──► application ──► domain ◄── adapters
```
- **domain** — zero framework deps, pure business logic, defines all port traits
- **application** — orchestrates use cases, depends only on domain
- **presentation** — HTTP handlers (axum), depends on domain + application
- **adapters** — implement domain ports, depend inward on domain only
- **bootstrap/worker** — composition roots, wire adapters into ports

544
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -9,12 +9,12 @@ members = [
"crates/adapters/postgres", "crates/adapters/postgres",
"crates/adapters/postgres-search", "crates/adapters/postgres-search",
"crates/adapters/postgres-federation", "crates/adapters/postgres-federation",
"crates/adapters/activitypub-base",
"crates/adapters/activitypub", "crates/adapters/activitypub",
"crates/adapters/auth", "crates/adapters/auth",
"crates/adapters/nats", "crates/adapters/nats",
"crates/adapters/event-payload", "crates/adapters/event-payload",
"crates/adapters/event-transport", "crates/adapters/event-transport",
"crates/adapters/storage",
] ]
resolver = "2" resolver = "2"
@@ -30,10 +30,9 @@ async-trait = "0.1"
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"] }
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres", "uuid", "chrono", "macros"] } sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres", "uuid", "chrono", "macros"] }
axum = { version = "0.8", features = ["macros", "multipart"] } axum = { version = "0.8", features = ["macros"] }
tower-http = { version = "0.6", features = ["cors", "trace"] } tower-http = { version = "0.6", features = ["cors", "trace"] }
futures = "0.3" futures = "0.3"
bytes = "1.0"
dotenvy = "0.15" dotenvy = "0.15"
async-nats = "0.48" async-nats = "0.48"
async-stream = "0.3" async-stream = "0.3"
@@ -47,9 +46,9 @@ api-types = { path = "crates/api-types" }
postgres = { path = "crates/adapters/postgres" } postgres = { path = "crates/adapters/postgres" }
postgres-search = { path = "crates/adapters/postgres-search" } postgres-search = { path = "crates/adapters/postgres-search" }
postgres-federation = { path = "crates/adapters/postgres-federation" } postgres-federation = { path = "crates/adapters/postgres-federation" }
activitypub-base = { path = "crates/adapters/activitypub-base" }
activitypub = { path = "crates/adapters/activitypub" } activitypub = { path = "crates/adapters/activitypub" }
auth = { path = "crates/adapters/auth" } auth = { path = "crates/adapters/auth" }
nats = { path = "crates/adapters/nats" } nats = { path = "crates/adapters/nats" }
event-payload = { path = "crates/adapters/event-payload" } event-payload = { path = "crates/adapters/event-payload" }
event-transport = { path = "crates/adapters/event-transport" } event-transport = { path = "crates/adapters/event-transport" }
storage = { path = "crates/adapters/storage" }

View File

@@ -4,11 +4,10 @@ FROM rust:slim-bookworm AS builder
WORKDIR /build WORKDIR /build
# Cache dependency compilation separately from source # Cache dependency compilation separately from source
COPY .cargo/ .cargo/
COPY Cargo.toml Cargo.lock ./ COPY Cargo.toml Cargo.lock ./
COPY crates/adapters/activitypub/Cargo.toml crates/adapters/activitypub/Cargo.toml COPY crates/adapters/activitypub/Cargo.toml crates/adapters/activitypub/Cargo.toml
COPY crates/adapters/activitypub-base/Cargo.toml crates/adapters/activitypub-base/Cargo.toml
COPY crates/adapters/auth/Cargo.toml crates/adapters/auth/Cargo.toml COPY crates/adapters/auth/Cargo.toml crates/adapters/auth/Cargo.toml
COPY crates/adapters/storage/Cargo.toml crates/adapters/storage/Cargo.toml
COPY crates/adapters/event-payload/Cargo.toml crates/adapters/event-payload/Cargo.toml COPY crates/adapters/event-payload/Cargo.toml crates/adapters/event-payload/Cargo.toml
COPY crates/adapters/event-transport/Cargo.toml crates/adapters/event-transport/Cargo.toml COPY crates/adapters/event-transport/Cargo.toml crates/adapters/event-transport/Cargo.toml
COPY crates/adapters/nats/Cargo.toml crates/adapters/nats/Cargo.toml COPY crates/adapters/nats/Cargo.toml crates/adapters/nats/Cargo.toml
@@ -36,7 +35,7 @@ RUN cargo fetch
# Now copy real source and build # Now copy real source and build
COPY crates ./crates COPY crates ./crates
RUN cargo build --release -p bootstrap -p worker --features storage/s3 RUN cargo build --release -p bootstrap -p worker
# ----- runtime ----- # ----- runtime -----
FROM debian:bookworm-slim FROM debian:bookworm-slim
@@ -52,7 +51,7 @@ WORKDIR /app
COPY --from=builder /build/target/release/thoughts ./thoughts COPY --from=builder /build/target/release/thoughts ./thoughts
COPY --from=builder /build/target/release/thoughts-worker ./thoughts-worker COPY --from=builder /build/target/release/thoughts-worker ./thoughts-worker
EXPOSE 8000 EXPOSE 3000
ENV RUST_LOG=info ENV RUST_LOG=info

View File

@@ -1,48 +0,0 @@
.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 full test suite (requires DATABASE_URL).
test:
cargo test
# Unit tests only — no database required.
test-unit:
cargo test -p domain -p application -p api-types -p activitypub
# Integration tests only — requires DATABASE_URL.
test-integration:
cargo test -p postgres -p postgres-federation -p postgres-search -p presentation
# Apply fmt + clippy auto-fixes in one shot.
fix:
cargo fmt
cargo clippy --fix --allow-dirty --allow-staged
# Start infra (Postgres + NATS) for local development.
dev-infra:
docker compose up postgres nats -d
# Stop infra.
dev-infra-down:
docker compose down
# Full Docker stack.
up:
docker compose up --build
.PHONY: check fmt fmt-check clippy test test-unit test-integration fix dev-infra dev-infra-down up

208
README.md
View File

@@ -6,56 +6,14 @@ A self-hosted microblogging server with full ActivityPub federation. Write short
- Short-form posts (thoughts) with replies, boosts, and likes - Short-form posts (thoughts) with replies, boosts, and likes
- Full ActivityPub federation — follow/unfollow remote actors, accept/reject followers, federated content broadcast as `Note` objects, paginated outbox, NodeInfo discovery, WebFinger, shared inbox, actor profile sync - Full ActivityPub federation — follow/unfollow remote actors, accept/reject followers, federated content broadcast as `Note` objects, paginated outbox, NodeInfo discovery, WebFinger, shared inbox, actor profile sync
- **Remote actor discovery** — search by `@user@instance` handle, view full remote profiles (bio, banner, profile fields, posts, followers, following tabs), follow from within the UI
- **Worker-backed remote caches** — remote posts and follower/following lists are fetched by the NATS worker and cached locally; profiles populate on first visit and refresh in the background
- Content negotiation at `GET /users/{username}` — serves ActivityPub actor JSON or REST profile based on `Accept` header
- Federation moderation — per-instance domain blocking, per-user actor blocking with `Block` activity delivery, delivery filter excludes blocked actors and blocked-domain inboxes - Federation moderation — per-instance domain blocking, per-user actor blocking with `Block` activity delivery, delivery filter excludes blocked actors and blocked-domain inboxes
- Async event fan-out via NATS JetStream — notifications and AP delivery run in a separate worker process; pull consumer with 1-hour TTL caching - Async event fan-out via NATS — notifications and AP delivery run in a separate worker process
- JWT authentication (Bearer token) with API key support for third-party clients - JWT authentication (Bearer token)
- OpenAPI documentation at `/docs` (Swagger UI) and `/scalar` (Scalar) - OpenAPI documentation at `/docs` (Swagger UI) and `/scalar` (Scalar)
- Full-text search over thoughts and users via PostgreSQL trigram indexes - Full-text search over thoughts and users via PostgreSQL trigram indexes
- **Profile fields** — up to 4 custom key/value fields (Website, Pronouns, etc.), federated as AP `PropertyValue` attachment - Top friends — pin up to 5 users as highlighted contacts
- **Custom CSS** — per-user stylesheet applied to their profile page - API keys for third-party client access
- **Visibility levels** — public, followers-only, unlisted, and direct posts
- **Content warnings** — optional CW label and sensitive flag on posts
- **Feed controls** — sort by newest, oldest, most liked, most boosted, or most discussed; filter to originals only, replies only, local only, or hide sensitive
- **Popular tags** — trending hashtag discovery
- Top friends — pin up to 8 users as highlighted contacts
- Account migration — set `alsoKnownAs` for Fediverse actor moves
- Home feed, public feed, and per-user thought timelines - Home feed, public feed, and per-user thought timelines
- Rate limiting and registration control
## Federation
Thoughts implements the [ActivityPub](https://www.w3.org/TR/activitypub/) protocol, making it compatible with Mastodon, Misskey, Pleroma, and other Fediverse software.
### Fediverse endpoints
| Endpoint | Description |
|---|---|
| `GET /.well-known/webfinger` | WebFinger discovery (`?resource=acct:user@host`) |
| `GET /.well-known/nodeinfo` | NodeInfo pointer |
| `GET /nodeinfo/2.0` | NodeInfo 2.0 — software metadata |
| `GET /users/{username}` | Actor profile (content-negotiated: JSON-LD or REST) |
| `GET /users/{username}/outbox` | Paginated outbox of `Note` activities |
| `POST /users/{username}/inbox` | Per-actor inbox |
| `POST /inbox` | Shared inbox for bulk delivery |
### Federation flow
1. A remote user follows `@you@yourinstance.com` → Mastodon sends a `Follow` activity to `/users/you/inbox`
2. Thoughts accepts and delivers an `Accept` back to the remote actor's inbox
3. When you post, Thoughts fans out a `Create(Note)` activity to all remote followers via the NATS worker
4. Remote posts from people you follow are fetched, cached, and shown in your home feed
### Without NATS
Federation still works without NATS — activities are processed in-process synchronously. The worker is required for async fan-out delivery to remote servers at scale. See [Environment Variables](#environment-variables).
### Instance moderation
- **Domain blocks** — block an entire instance; no activities are delivered to or accepted from blocked domains
- **Actor blocks** — block individual remote actors; a `Block` activity is delivered and they are filtered from all feeds
## Architecture ## Architecture
@@ -70,104 +28,36 @@ bootstrap — binary: thoughts (API server)
worker — binary: thoughts-worker (event consumer — notifications, AP fan-out) worker — binary: thoughts-worker (event consumer — notifications, AP fan-out)
adapters/ adapters/
auth — JWT issuance and validation, Argon2 password hashing auth — JWT issuance and validation, Argon2 password hashing
storage — object storage adapter (local filesystem + S3/MinIO) implementing the MediaStore port
postgres — PostgreSQL repositories for all domain entities postgres — PostgreSQL repositories for all domain entities
postgres-search — PostgreSQL trigram full-text search postgres-search — PostgreSQL trigram full-text search
postgres-federation — PostgreSQL-backed federation repository postgres-federation — PostgreSQL-backed federation repository
k-ap (external) — generic AP protocol layer (ActivityPubService, actor management, inbox/outbox routing, follower tracking, WebFinger, NodeInfo, HTTP signatures) activitypub-base — core ActivityPub protocol types, ActivityPubService, federation middleware
activitypub — project-specific AP wiring (ThoughtsObjectHandler, inbox/outbox) activitypub — project-specific AP wiring (ThoughtsObjectHandler, inbox/outbox)
nats — NATS transport implementing Transport + MessageSource ports nats — NATS transport implementing Transport + MessageSource ports
event-payload — shared event serialization DTOs event-payload — shared event serialization DTOs
event-transport — Transport trait + EventPublisherAdapter / MessageSource + EventConsumerAdapter event-transport — Transport trait + EventPublisherAdapter / MessageSource + EventConsumerAdapter
``` ```
The `domain` and `application` crates have zero concrete adapter dependencies. All I/O goes through `&dyn Port` traits, keeping business logic fully testable with in-memory fakes.
## Media Storage
Users can upload avatar and banner images via `PUT /users/me/avatar` and `PUT /users/me/banner` (multipart/form-data). Uploaded images are served at `GET /media/*path` (public, no auth required). Set `STORAGE_BACKEND` to configure the backend.
## Prerequisites ## Prerequisites
- Rust stable (1.80+) - Rust stable (1.80+)
- PostgreSQL 15+ - PostgreSQL 15+
- NATS with JetStream (optional — see [Without NATS](#without-nats)) - NATS (optional — federation and notifications still work without it, events queue in-process)
- Docker & Docker Compose (for the easiest local setup)
### Private cargo registry
The `k-ap` crate (ActivityPub protocol library) is hosted on a private Gitea registry configured in `.cargo/config.toml`. To build the project you need read access to `git.gabrielkaszewski.dev`. If you're contributing and don't have access, open an issue and I'll sort it out.
## Environment Variables ## Environment Variables
Copy `.env.example` to `.env` and fill in your values. Copy `.env.example` to `.env` and fill in your values:
### Required ```env
DATABASE_URL=postgres://postgres:password@localhost:5432/thoughts
| Variable | Description | JWT_SECRET=change-me
|---|---| BASE_URL=http://localhost:3000
| `DATABASE_URL` | PostgreSQL connection string | NATS_URL=nats://localhost:4222 # optional
| `JWT_SECRET` | Secret used to sign JWT tokens — use a long random string in production |
| `BASE_URL` | Public URL of the API server — used for ActivityPub actor URLs and canonical links |
### Optional
| Variable | Default | Description |
|---|---|---|
| `HOST` | `0.0.0.0` | Interface to bind |
| `PORT` | `8000` | Port to listen on |
| `NATS_URL` | — | NATS connection string. If unset, a no-op publisher is used and events are not delivered to the worker |
| `CORS_ORIGINS` | `*` | Comma-separated allowed origins for CORS, e.g. `https://app.example.com` |
| `RATE_LIMIT` | disabled | Max requests per minute per IP |
| `ALLOW_REGISTRATION` | `true` | Set to `false` to close sign-ups |
| `RUST_ENV` | `development` | Set to `production` to disable ActivityPub debug logging |
| `RUST_LOG` | `info` | Log level filter (`error`, `warn`, `info`, `debug`, `trace`) |
| `STORAGE_BACKEND` | `local` | Storage backend: `local` or `s3` |
| `STORAGE_PATH` | — | Local filesystem path for media (required when `STORAGE_BACKEND=local`) |
| `STORAGE_PREFIX` | — | Optional key prefix for all stored objects |
| `S3_ENDPOINT` | — | S3/MinIO endpoint URL (required when `STORAGE_BACKEND=s3`) |
| `S3_ACCESS_KEY_ID` | — | S3 access key (required when `STORAGE_BACKEND=s3`) |
| `S3_SECRET_ACCESS_KEY` | — | S3 secret key (required when `STORAGE_BACKEND=s3`) |
| `S3_BUCKET` | — | S3 bucket name (required when `STORAGE_BACKEND=s3`) |
| `S3_REGION` | `us-east-1` | S3 region |
| `UPLOAD_MAX_BYTES` | `5242880` | Max upload size in bytes (default 5 MiB) |
| `UPLOAD_ALLOWED_TYPES` | `image/jpeg,image/png,image/gif,image/webp,image/avif` | Comma-separated allowed MIME types |
### Frontend environment
Copy `thoughts-frontend/.env.example` to `thoughts-frontend/.env.local` and adjust:
| Variable | Description |
|---|---|
| `NEXT_PUBLIC_API_URL` | API URL for client-side (browser) requests, e.g. `http://localhost:8000` |
| `NEXT_PUBLIC_SERVER_SIDE_API_URL` | API URL for SSR requests — same as above locally, or `http://api:8000` inside Docker |
| `NEXT_PUBLIC_FEDIVERSE_DOMAIN` | (Optional) Domain shown on profile fediverse handles, e.g. `yourinstance.example.com` |
## Run
### Local development (recommended)
Start only the infrastructure containers (Postgres + NATS), then run the Rust backend and Next.js frontend natively for fast iteration:
```bash
# 1. Start Postgres + NATS
make dev-infra
# 2. Copy and fill in env files
cp .env.example .env
cp thoughts-frontend/.env.example thoughts-frontend/.env.local
# 3. API server (runs migrations automatically on startup)
cargo run -p bootstrap
# 4. Event worker (separate terminal, optional)
cargo run -p worker
# 5. Frontend (separate terminal)
cd thoughts-frontend && bun install && bun dev
``` ```
### Bare metal See `.env.example` for all available options.
## Run
```bash ```bash
# API server (runs migrations automatically on startup) # API server (runs migrations automatically on startup)
@@ -182,20 +72,14 @@ Both processes share the same PostgreSQL database. The worker is optional but re
## Test ## Test
```bash ```bash
# Unit tests only — no database required # Unit tests — no database required
make test-unit cargo test -p application
# Integration tests — requires DATABASE_URL pointing to a running PostgreSQL # Full workspace (requires DATABASE_URL pointing to a running PostgreSQL)
make test-integration cargo test --workspace
# Everything (unit + integration)
make test
# Full check suite: fmt + clippy + tests
make check
``` ```
`make test-unit` runs domain, application, api-types, and activitypub tests using in-memory fakes — the fastest feedback loop for business logic. `make test-integration` runs the adapter crates against a live PostgreSQL. The `application` crate contains unit tests for all event services and use cases backed by in-memory fakes from `domain`'s `test-helpers` feature. These are the fastest feedback loop for business logic.
## API ## API
@@ -203,29 +87,22 @@ All REST endpoints are under the root path. Authentication uses `Authorization:
Interactive API documentation is available at runtime: Interactive API documentation is available at runtime:
- **Swagger UI** — `http://localhost:8000/docs` - **Swagger UI** — `http://localhost:3000/docs`
- **Scalar** — `http://localhost:8000/scalar` - **Scalar** — `http://localhost:3000/scalar`
## Frontend
The Next.js frontend lives in `thoughts-frontend/`. See [Frontend environment](#frontend-environment) for required env vars, or follow the [local development](#local-development-recommended) steps above.
## Docker ## Docker
The backend image contains both `thoughts` (API server) and `thoughts-worker` (event processor). Run them as separate containers: The image contains both `thoughts` (API server) and `thoughts-worker` (event processor). Run them as separate containers:
```bash ```bash
docker build -t thoughts . docker build -t thoughts .
# API server # API server
docker run -p 8000:8000 \ docker run -p 3000:3000 \
-e DATABASE_URL=postgres://postgres:password@db:5432/thoughts \ -e DATABASE_URL=postgres://postgres:password@db:5432/thoughts \
-e JWT_SECRET=change-me \ -e JWT_SECRET=change-me \
-e BASE_URL=https://yourdomain.example.com \ -e BASE_URL=https://yourdomain.example.com \
-e NATS_URL=nats://nats:4222 \ -e NATS_URL=nats://nats:4222 \
-e STORAGE_BACKEND=local \
-e STORAGE_PATH=/data/media \
-v media_vol:/data/media \
thoughts thoughts
# Event worker (same image, different entrypoint) # Event worker (same image, different entrypoint)
@@ -235,45 +112,8 @@ docker run \
-e NATS_URL=nats://nats:4222 \ -e NATS_URL=nats://nats:4222 \
--entrypoint ./thoughts-worker \ --entrypoint ./thoughts-worker \
thoughts thoughts
# Frontend
docker build -t thoughts-frontend \
--build-arg NEXT_PUBLIC_API_URL=https://api.yourdomain.example.com \
--build-arg NEXT_PUBLIC_SERVER_SIDE_API_URL=http://thoughts:8000 \
thoughts-frontend/
docker run -p 3000:3000 thoughts-frontend
``` ```
### Full Docker stack
`compose.yml` spins up the full stack: PostgreSQL, NATS (with JetStream and monitoring on port 8222), the API server, the event worker, and the frontend.
```bash
make up # or: docker compose up --build
```
Services:
| Service | Port | Description |
|---|---|---|
| `postgres` | 5432 | PostgreSQL 16 |
| `nats` | 4222 / 8222 | NATS with JetStream; 8222 is the monitoring endpoint |
| `api` | 8000 | Thoughts API server |
| `worker` | — | Event worker (no exposed port) |
| `frontend` | 3000 | Next.js frontend |
## Contributing
Contributions are welcome. A few guidelines:
- **Run tests before opening a PR.** At minimum: `make test-unit` (no database needed). For adapter changes: `make test-integration` with a live database. `make check` runs the full suite (fmt + clippy + tests).
- **Keep the hexagonal boundary.** `domain` and `application` must not import any adapter crate. Use `&dyn Port` traits for all I/O.
- **No ORM.** The project uses raw `sqlx`. Keep it that way.
- **ActivityPub changes** — test against a live Mastodon instance if possible, or use the AP debug logs (`RUST_ENV=development`).
- **Small, focused PRs** are easier to review than large ones.
For significant changes, open an issue first to discuss the approach.
## License ## License
MIT License. See [LICENSE](LICENSE). MIT License. See [LICENSE](LICENSE).

View File

@@ -1,6 +1,6 @@
services: services:
database: database:
image: postgres:16-alpine image: postgres:15-alpine
container_name: thoughts-db container_name: thoughts-db
restart: unless-stopped restart: unless-stopped
environment: environment:
@@ -17,21 +17,19 @@ services:
networks: networks:
- internal - internal
api: backend:
container_name: thoughts-api container_name: thoughts-backend
image: registry.gabrielkaszewski.dev/thoughts:latest image: thoughts-backend:latest
restart: unless-stopped restart: unless-stopped
environment: environment:
RUST_LOG: info - RUST_LOG=info
RUST_ENV: production - RUST_BACKTRACE=1
DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@database/${POSTGRES_DB} - DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@database/${POSTGRES_DB}
HOST: 0.0.0.0 - HOST=0.0.0.0
PORT: 8000 - PORT=8000
JWT_SECRET: ${JWT_SECRET} - PREFORK=1
BASE_URL: ${BASE_URL} - AUTH_SECRET=${AUTH_SECRET}
NATS_URL: nats://k_nats:4222 - BASE_URL=https://thoughts.gabrielkaszewski.dev
CORS_ORIGINS: ${CORS_ORIGINS:-*}
ALLOW_REGISTRATION: ${ALLOW_REGISTRATION:-false}
depends_on: depends_on:
database: database:
condition: service_healthy condition: service_healthy
@@ -42,59 +40,34 @@ services:
retries: 5 retries: 5
networks: networks:
- internal - internal
- shared-services
- traefik
labels:
- "traefik.enable=true"
- "traefik.docker.network=traefik"
# Original API subdomain — keep for backwards compat and direct API access
- "traefik.http.routers.thoughts-api.rule=Host(`api.thoughts.gabrielkaszewski.dev`)"
- "traefik.http.routers.thoughts-api.entrypoints=web,websecure"
- "traefik.http.routers.thoughts-api.tls.certresolver=letsencrypt"
- "traefik.http.routers.thoughts-api.service=thoughts-api"
- "traefik.http.services.thoughts-api.loadbalancer.server.port=8000"
# Federation routes on the main domain — higher priority than the frontend catch-all
- "traefik.http.routers.thoughts-federation.rule=Host(`thoughts.gabrielkaszewski.dev`) && (PathPrefix(`/.well-known`) || PathPrefix(`/nodeinfo`) || Path(`/inbox`) || (Method(`POST`) && PathPrefix(`/users/`)))"
- "traefik.http.routers.thoughts-federation.entrypoints=web,websecure"
- "traefik.http.routers.thoughts-federation.tls.certresolver=letsencrypt"
- "traefik.http.routers.thoughts-federation.service=thoughts-api"
- "traefik.http.routers.thoughts-federation.priority=1000"
worker:
container_name: thoughts-worker
image: registry.gabrielkaszewski.dev/thoughts:latest
entrypoint: ["./thoughts-worker"]
restart: unless-stopped
environment:
RUST_LOG: info
DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@database/${POSTGRES_DB}
BASE_URL: ${BASE_URL}
NATS_URL: nats://k_nats:4222
depends_on:
database:
condition: service_healthy
networks:
- internal
- shared-services
frontend: frontend:
container_name: thoughts-frontend container_name: thoughts-frontend
image: registry.gabrielkaszewski.dev/thoughts-frontend:latest image: thoughts-frontend:latest
restart: unless-stopped restart: unless-stopped
environment:
NEXT_PUBLIC_SERVER_SIDE_API_URL: http://api:8000
NEXT_PUBLIC_API_URL: https://api.thoughts.gabrielkaszewski.dev
NEXT_PUBLIC_FEDIVERSE_DOMAIN: thoughts.gabrielkaszewski.dev
PORT: 3000
HOSTNAME: 0.0.0.0
depends_on: depends_on:
api: - backend
condition: service_healthy
healthcheck: healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000"] test: ["CMD", "curl", "-f", "http://localhost:3000"]
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 5 retries: 5
environment:
- NEXT_PUBLIC_SERVER_SIDE_API_URL=http://proxy/api
- PORT=3000
- HOSTNAME=0.0.0.0
networks:
- internal
proxy:
container_name: thoughts-proxy
image: custom-proxy:latest
restart: unless-stopped
depends_on:
frontend:
condition: service_healthy
backend:
condition: service_healthy
networks: networks:
- internal - internal
- traefik - traefik
@@ -105,16 +78,14 @@ services:
- "traefik.http.routers.thoughts.entrypoints=web,websecure" - "traefik.http.routers.thoughts.entrypoints=web,websecure"
- "traefik.http.routers.thoughts.tls.certresolver=letsencrypt" - "traefik.http.routers.thoughts.tls.certresolver=letsencrypt"
- "traefik.http.routers.thoughts.service=thoughts" - "traefik.http.routers.thoughts.service=thoughts"
- "traefik.http.services.thoughts.loadbalancer.server.port=3000" - "traefik.http.services.thoughts.loadbalancer.server.port=80"
volumes: volumes:
postgres_data: postgres_data:
driver: local driver: local
networks: networks:
shared-services:
external: true
traefik: traefik:
name: traefik
external: true external: true
internal: internal:
driver: bridge driver: bridge

View File

@@ -22,52 +22,5 @@ services:
- "8222:8222" # monitoring endpoint - "8222:8222" # monitoring endpoint
command: ["--jetstream", "--http_port", "8222"] command: ["--jetstream", "--http_port", "8222"]
api:
build: .
ports:
- "8000:8000"
environment:
DATABASE_URL: postgres://postgres:postgres@postgres:5432/thoughts
JWT_SECRET: change-me-in-production
BASE_URL: http://localhost:8000
PORT: 8000
NATS_URL: nats://nats:4222
RUST_LOG: info
STORAGE_BACKEND: local
STORAGE_PATH: /data/media
volumes:
- media_data:/data/media
depends_on:
postgres:
condition: service_healthy
nats:
condition: service_started
worker:
build: .
entrypoint: ["./thoughts-worker"]
environment:
DATABASE_URL: postgres://postgres:postgres@postgres:5432/thoughts
BASE_URL: http://localhost:8000
NATS_URL: nats://nats:4222
RUST_LOG: info
depends_on:
postgres:
condition: service_healthy
nats:
condition: service_started
frontend:
build:
context: ./thoughts-frontend
args:
NEXT_PUBLIC_API_URL: http://localhost:8000
NEXT_PUBLIC_SERVER_SIDE_API_URL: http://api:8000
ports:
- "3000:3000"
depends_on:
- api
volumes: volumes:
postgres_data: postgres_data:
media_data:

View File

@@ -0,0 +1,21 @@
[package]
name = "activitypub-base"
version = "0.1.0"
edition = "2024"
[dependencies]
tokio = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
uuid = { workspace = true }
chrono = { workspace = true }
anyhow = { workspace = true }
tracing = { workspace = true }
async-trait = { workspace = true }
axum = { workspace = true }
reqwest = { workspace = true }
url = { workspace = true }
domain = { workspace = true }
activitypub_federation = "0.7.0-beta.11"
enum_delegate = "0.2"

View File

@@ -0,0 +1,645 @@
use activitypub_federation::{
config::Data,
fetch::object_id::ObjectId,
kinds::activity::{
AcceptType, CreateType, DeleteType, FollowType, RejectType, UndoType, UpdateType,
},
traits::Activity,
};
use serde::{Deserialize, Serialize};
use url::Url;
#[derive(Clone, Default, Debug, Serialize, Deserialize)]
#[serde(rename = "Announce")]
pub struct AnnounceType;
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> {
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> {
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> {
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)");
}
}
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>,
}
#[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> {
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.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> {
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> {
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.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> {
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 {
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?;
tracing::info!(actor = %self.actor.inner(), object = %self.object, "received announce");
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> {
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> {
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(());
}
// They blocked us — remove them from our following list
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;
}
tracing::info!(actor = %self.actor.inner(), "received block");
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),
}

View File

@@ -0,0 +1,25 @@
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)))
}

View File

@@ -0,0 +1,334 @@
use activitypub_federation::{
config::Data,
fetch::object_id::ObjectId,
http_signatures::generate_actor_keypair,
kinds::actor::PersonType,
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 public_key_pem: String,
pub private_key_pem: Option<String>,
pub inbox_url: 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,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Person {
#[serde(rename = "type")]
kind: PersonType,
id: ObjectId<DbActor>,
preferred_username: String,
inbox: Url,
outbox: Url,
followers: Url,
following: Url,
public_key: PublicKey,
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>,
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>,
}
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 ap_id = crate::urls::actor_url(&data.base_url, user_id);
let inbox_url = Url::parse(&format!("{}/inbox", &ap_id)).expect("valid inbox url");
let outbox_url = Url::parse(&format!("{}/outbox", &ap_id)).expect("valid outbox url");
let followers_url = Url::parse(&format!("{}/followers", &ap_id)).expect("valid followers url");
let following_url = Url::parse(&format!("{}/following", &ap_id)).expect("valid following url");
Ok(DbActor {
user_id,
username: user.username,
public_key_pem: public_key,
private_key_pem: Some(private_key),
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_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 ap_id = crate::urls::actor_url(&data.base_url, user_id);
let inbox_url = Url::parse(&format!("{}/inbox", &ap_id)).expect("valid url");
let outbox_url = Url::parse(&format!("{}/outbox", &ap_id)).expect("valid url");
let followers_url = Url::parse(&format!("{}/followers", &ap_id)).expect("valid url");
let following_url = Url::parse(&format!("{}/following", &ap_id)).expect("valid url");
Ok(Some(DbActor {
user_id,
username: user.username,
public_key_pem: public_key,
private_key_pem: private_key,
inbox_url,
outbox_url,
followers_url,
following_url,
ap_id,
last_refreshed_at: Utc::now(),
bio: None,
avatar_url: None,
banner_url: None,
also_known_as: None,
profile_url: None,
attachment: vec![],
}))
}
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: self.outbox_url.clone(),
followers: self.followers_url.clone(),
following: 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> {
verify_domains_match(json.id.inner(), expected_domain)?;
Ok(())
}
async fn from_json(json: Self::Kind, data: &Data<Self::DataType>) -> Result<Self, Self::Error> {
let actor = RemoteActor {
url: json.id.inner().to_string(),
handle: json.preferred_username.clone(),
inbox_url: json.inbox.to_string(),
shared_inbox_url: None,
display_name: json.name.clone(),
avatar_url: json.icon.as_ref().map(|i| i.url.to_string()),
outbox_url: Some(json.outbox.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 outbox_url = json.outbox.clone();
let followers_url = json.followers.clone();
let following_url = json.following.clone();
Ok(DbActor {
user_id,
username: json.preferred_username.clone(),
public_key_pem: json.public_key.public_key_pem,
private_key_pem: None,
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;

View File

@@ -0,0 +1,47 @@
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use url::Url;
#[async_trait]
pub trait ApObjectHandler: Send + Sync {
/// Returns (ap_id, serialized object) for all local content owned by this user.
/// Used by outbox (count) and backfill (delivery). Must only return locally-authored content.
async fn get_local_objects_for_user(
&self,
user_id: uuid::Uuid,
) -> anyhow::Result<Vec<(Url, serde_json::Value)>>;
/// Returns up to `limit` objects ordered newest-first, published before `before`.
/// Returns (ap_id, object_json, published_at).
async fn get_local_objects_page(
&self,
user_id: uuid::Uuid,
before: Option<DateTime<Utc>>,
limit: usize,
) -> anyhow::Result<Vec<(Url, serde_json::Value, DateTime<Utc>)>>;
/// Incoming Create activity — persist remote content.
async fn on_create(
&self,
ap_id: &Url,
actor_url: &Url,
object: serde_json::Value,
) -> anyhow::Result<()>;
/// Incoming Update activity — update existing remote content.
async fn on_update(
&self,
ap_id: &Url,
actor_url: &Url,
object: serde_json::Value,
) -> anyhow::Result<()>;
/// Incoming Delete activity — remove specific remote content.
async fn on_delete(&self, ap_id: &Url, actor_url: &Url) -> anyhow::Result<()>;
/// Actor unfollowed/was removed — clean up all their remote content.
async fn on_actor_removed(&self, actor_url: &Url) -> anyhow::Result<()>;
/// Total number of locally-authored posts across all users.
async fn count_local_posts(&self) -> anyhow::Result<u64>;
}

View File

@@ -0,0 +1,49 @@
use std::sync::Arc;
use crate::content::ApObjectHandler;
use crate::repository::FederationRepository;
use crate::user::ApUserRepository;
use domain::ports::EventPublisher;
#[derive(Clone)]
pub struct FederationData {
pub(crate) federation_repo: Arc<dyn FederationRepository>,
pub(crate) user_repo: Arc<dyn ApUserRepository>,
pub(crate) object_handler: Arc<dyn ApObjectHandler>,
pub(crate) base_url: String,
pub(crate) domain: String,
pub(crate) allow_registration: bool,
pub(crate) software_name: String,
#[allow(dead_code)]
pub(crate) event_publisher: Option<Arc<dyn EventPublisher>>,
}
impl FederationData {
pub fn new(
federation_repo: Arc<dyn FederationRepository>,
user_repo: Arc<dyn ApUserRepository>,
object_handler: Arc<dyn ApObjectHandler>,
base_url: String,
allow_registration: bool,
software_name: String,
event_publisher: Option<Arc<dyn EventPublisher>>,
) -> Self {
let domain = base_url
.trim_start_matches("https://")
.trim_start_matches("http://")
.split('/')
.next()
.unwrap_or("")
.to_string();
Self {
federation_repo,
user_repo,
object_handler,
base_url,
domain,
allow_registration,
software_name,
event_publisher,
}
}
}

View File

@@ -0,0 +1,48 @@
use std::fmt::{Display, Formatter};
use axum::http::StatusCode;
#[derive(Debug)]
pub struct Error(pub(crate) anyhow::Error, pub(crate) StatusCode);
impl Error {
pub fn not_found(e: impl Into<anyhow::Error>) -> Self {
Self(e.into(), StatusCode::NOT_FOUND)
}
pub fn bad_request(e: impl Into<anyhow::Error>) -> Self {
Self(e.into(), StatusCode::BAD_REQUEST)
}
}
impl Display for Error {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
std::fmt::Display::fmt(&self.0, f)
}
}
impl<T> From<T> for Error
where
T: Into<anyhow::Error>,
{
fn from(t: T) -> Self {
Error(t.into(), StatusCode::INTERNAL_SERVER_ERROR)
}
}
impl axum::response::IntoResponse for Error {
fn into_response(self) -> axum::response::Response {
let status = self.1;
if status.is_server_error() {
tracing::error!(error = %self.0, status = status.as_u16(), "federation error");
} else {
tracing::debug!(error = %self.0, status = status.as_u16(), "federation response");
}
let body = if status.is_server_error() {
"internal server error".to_string()
} else {
self.0.to_string()
};
(status, body).into_response()
}
}

View File

@@ -0,0 +1,50 @@
use activitypub_federation::config::{Data, FederationConfig, FederationMiddleware, UrlVerifier};
use activitypub_federation::error::Error as FedError;
use url::Url;
use crate::data::FederationData;
#[derive(Clone)]
struct PermissiveVerifier;
#[async_trait::async_trait]
impl UrlVerifier for PermissiveVerifier {
async fn verify(&self, _url: &Url) -> Result<(), FedError> {
Ok(())
}
}
#[derive(Clone)]
pub struct ApFederationConfig(pub FederationConfig<FederationData>);
impl ApFederationConfig {
pub async fn new(data: FederationData, debug: bool) -> anyhow::Result<Self> {
let config = if debug {
FederationConfig::builder()
.domain(&data.domain)
.app_data(data)
.debug(true)
.http_signature_compat(true)
.url_verifier(Box::new(PermissiveVerifier))
.build()
.await?
} else {
FederationConfig::builder()
.domain(&data.domain)
.app_data(data)
.debug(false)
.http_signature_compat(true)
.build()
.await?
};
Ok(Self(config))
}
pub fn to_request_data(&self) -> Data<FederationData> {
self.0.to_request_data()
}
pub fn middleware(&self) -> FederationMiddleware<FederationData> {
FederationMiddleware::new(self.0.clone())
}
}

View File

@@ -0,0 +1,130 @@
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;
const PAGE_SIZE: usize = 20;
#[derive(Deserialize)]
pub struct PageQuery {
page: Option<u32>,
}
pub async fn followers_handler(
Path(user_id_str): Path<String>,
Query(query): Query<PageQuery>,
data: Data<FederationData>,
) -> 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/{}/followers", data.base_url, user_id_str);
let total = data
.federation_repo
.count_followers(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) * PAGE_SIZE;
let followers = data
.federation_repo
.get_followers_page(user_id, offset as u32, PAGE_SIZE)
.await
.map_err(Error::from)?;
let has_next = offset + followers.len() < total;
let items: Vec<String> = followers.into_iter().map(|f| f.actor.url).collect();
let mut obj = json!({
"@context": "https://www.w3.org/ns/activitystreams",
"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": "https://www.w3.org/ns/activitystreams",
"type": "OrderedCollection",
"id": collection_id,
"totalItems": total,
"first": format!("{}?page=1", collection_id),
})))
}
}
pub async fn following_handler(
Path(user_id_str): Path<String>,
Query(query): Query<PageQuery>,
data: Data<FederationData>,
) -> 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/{}/following", data.base_url, user_id_str);
let total = 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) * PAGE_SIZE;
let following = data
.federation_repo
.get_following_page(user_id, offset as u32, PAGE_SIZE)
.await
.map_err(Error::from)?;
let has_next = offset + following.len() < total;
let items: Vec<String> = following.into_iter().map(|a| a.url).collect();
let mut obj = json!({
"@context": "https://www.w3.org/ns/activitystreams",
"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": "https://www.w3.org/ns/activitystreams",
"type": "OrderedCollection",
"id": collection_id,
"totalItems": total,
"first": format!("{}?page=1", collection_id),
})))
}
}

View File

@@ -0,0 +1,18 @@
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
}

View File

@@ -0,0 +1,28 @@
pub mod activities;
pub mod actor_handler;
pub mod actors;
pub mod content;
pub mod data;
pub mod error;
pub mod federation;
pub mod followers_handler;
pub mod inbox;
pub mod nodeinfo;
pub mod outbox;
pub mod repository;
pub mod service;
pub(crate) mod urls;
pub use urls::AS_PUBLIC;
pub mod user;
pub mod webfinger;
pub use activitypub_federation::kinds::object::NoteType;
pub use content::ApObjectHandler;
pub use data::FederationData;
pub use error::Error;
pub use federation::ApFederationConfig;
pub use repository::{
BlockedDomain, FederationRepository, Follower, FollowerStatus, FollowingStatus, RemoteActor,
};
pub use service::ActivityPubService;
pub use user::{ApProfileField, ApUser, ApUserRepository};

View File

@@ -0,0 +1,80 @@
use activitypub_federation::config::Data;
use axum::Json;
use serde::Serialize;
use crate::data::FederationData;
use crate::error::Error;
#[derive(Serialize)]
pub struct NodeInfoWellKnown {
pub links: Vec<NodeInfoLink>,
}
#[derive(Serialize)]
pub struct NodeInfoLink {
pub rel: String,
pub href: String,
}
#[derive(Serialize)]
pub struct NodeInfoSoftware {
pub name: String,
pub version: String,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct NodeInfoUsage {
pub users: NodeInfoUsers,
pub local_posts: u64,
}
#[derive(Serialize)]
pub struct NodeInfoUsers {
pub total: usize,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct NodeInfo {
pub version: String,
pub software: NodeInfoSoftware,
pub protocols: Vec<String>,
pub usage: NodeInfoUsage,
pub open_registrations: bool,
}
pub async fn nodeinfo_well_known_handler(
data: Data<FederationData>,
) -> Result<Json<NodeInfoWellKnown>, Error> {
let href = format!("{}/nodeinfo/2.0", data.base_url);
Ok(Json(NodeInfoWellKnown {
links: vec![NodeInfoLink {
rel: "http://nodeinfo.diaspora.software/ns/schema/2.0".to_string(),
href,
}],
}))
}
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 local_posts = data.object_handler.count_local_posts().await.unwrap_or(0);
Ok(Json(NodeInfo {
version: "2.0".to_string(),
software: NodeInfoSoftware {
name: data.software_name.clone(),
version: env!("CARGO_PKG_VERSION").to_string(),
},
protocols: vec!["activitypub".to_string()],
usage: NodeInfoUsage {
users: NodeInfoUsers { total: user_count },
local_posts,
},
open_registrations: data.allow_registration,
}))
}
#[cfg(test)]
#[path = "tests/nodeinfo.rs"]
mod tests;

View File

@@ -0,0 +1,138 @@
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};
const PAGE_SIZE: usize = 20;
#[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, 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() == 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()],
}))
.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: "https://www.w3.org/ns/activitystreams".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: "https://www.w3.org/ns/activitystreams".to_string(),
kind: "OrderedCollection".to_string(),
id: outbox_url.clone(),
total_items: total,
first: format!("{}?page=true", outbox_url),
})
.into_response())
}
}

View File

@@ -0,0 +1,134 @@
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>;
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,49 @@
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"
);
}

View File

@@ -0,0 +1,40 @@
use super::*;
#[test]
fn nodeinfo_well_known_serializes_correctly() {
let doc = NodeInfoWellKnown {
links: vec![NodeInfoLink {
rel: "http://nodeinfo.diaspora.software/ns/schema/2.0".to_string(),
href: "https://example.com/nodeinfo/2.0".to_string(),
}],
};
let json = serde_json::to_value(&doc).unwrap();
assert_eq!(
json["links"][0]["rel"],
"http://nodeinfo.diaspora.software/ns/schema/2.0"
);
assert_eq!(json["links"][0]["href"], "https://example.com/nodeinfo/2.0");
}
#[test]
fn nodeinfo_serializes_camel_case() {
let doc = NodeInfo {
version: "2.0".to_string(),
software: NodeInfoSoftware {
name: "my-app".to_string(),
version: "0.1.0".to_string(),
},
protocols: vec!["activitypub".to_string()],
usage: NodeInfoUsage {
users: NodeInfoUsers { total: 3 },
local_posts: 42,
},
open_registrations: false,
};
let json = serde_json::to_value(&doc).unwrap();
assert_eq!(json["version"], "2.0");
assert_eq!(json["software"]["name"], "my-app");
assert_eq!(json["usage"]["users"]["total"], 3);
assert_eq!(json["usage"]["localPosts"], 42);
assert_eq!(json["openRegistrations"], false);
}

View File

@@ -0,0 +1,51 @@
fn _assert_impl_federation_action_port()
where
crate::service::ActivityPubService: domain::ports::FederationActionPort,
{
}
use super::*;
use crate::repository::{Follower, FollowerStatus, RemoteActor};
fn make_follower(inbox: &str, shared: Option<&str>) -> Follower {
Follower {
actor: RemoteActor {
url: format!("https://remote/{}", inbox),
handle: "user".to_string(),
inbox_url: inbox.to_string(),
shared_inbox_url: shared.map(|s| s.to_string()),
display_name: None,
avatar_url: None,
outbox_url: None,
},
status: FollowerStatus::Accepted,
}
}
#[test]
fn collect_inboxes_deduplicates_shared() {
let followers = vec![
make_follower(
"https://mastodon.social/users/a/inbox",
Some("https://mastodon.social/inbox"),
),
make_follower(
"https://mastodon.social/users/b/inbox",
Some("https://mastodon.social/inbox"),
),
make_follower("https://other.instance/users/c/inbox", None),
];
let inboxes = collect_inboxes(&followers);
assert_eq!(inboxes.len(), 2);
let strs: Vec<_> = inboxes.iter().map(|u| u.as_str()).collect();
assert!(strs.contains(&"https://mastodon.social/inbox"));
assert!(strs.contains(&"https://other.instance/users/c/inbox"));
}
#[test]
fn collect_inboxes_falls_back_to_individual_inbox() {
let followers = vec![make_follower("https://example.com/users/x/inbox", None)];
let inboxes = collect_inboxes(&followers);
assert_eq!(inboxes.len(), 1);
assert_eq!(inboxes[0].as_str(), "https://example.com/users/x/inbox");
}

View File

@@ -0,0 +1,31 @@
use url::Url;
use crate::error::Error;
pub const AS_PUBLIC: &str = "https://www.w3.org/ns/activitystreams#Public";
pub fn extract_user_id_from_url(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())
}
pub fn activity_url(base_url: &str) -> Result<Url, Error> {
Url::parse(&format!("{}/activities/{}", base_url, uuid::Uuid::new_v4()))
.map_err(|e| Error::bad_request(anyhow::anyhow!(e)))
}
pub fn actor_url(base_url: &str, user_id: uuid::Uuid) -> Url {
Url::parse(&format!("{}/users/{}", base_url, user_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())
}

View File

@@ -0,0 +1,27 @@
use async_trait::async_trait;
use url::Url;
#[derive(Debug, Clone)]
pub struct ApProfileField {
pub name: String,
pub value: String,
}
#[derive(Debug, Clone)]
pub struct ApUser {
pub id: uuid::Uuid,
pub username: String,
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>,
}
#[async_trait]
pub trait ApUserRepository: Send + Sync {
async fn find_by_id(&self, id: uuid::Uuid) -> anyhow::Result<Option<ApUser>>;
async fn find_by_username(&self, username: &str) -> anyhow::Result<Option<ApUser>>;
async fn count_users(&self) -> anyhow::Result<usize>;
}

View File

@@ -0,0 +1,38 @@
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())
}

View File

@@ -4,7 +4,7 @@ version = "0.1.0"
edition = "2021" edition = "2021"
[dependencies] [dependencies]
k-ap = { version = "0.4.0", registry = "gitea" } activitypub-base = { workspace = true }
domain = { workspace = true } domain = { workspace = true }
url = { workspace = true } url = { workspace = true }
serde = { workspace = true } serde = { workspace = true }
@@ -14,7 +14,3 @@ chrono = { workspace = true }
uuid = { workspace = true } uuid = { workspace = true }
async-trait = { workspace = true } async-trait = { workspace = true }
tracing = { workspace = true } tracing = { workspace = true }
reqwest = { workspace = true }
futures = { workspace = true }
tokio = { workspace = true }
axum = { workspace = true }

View File

@@ -1,52 +1,66 @@
use anyhow::{anyhow, Result}; use anyhow::{anyhow, Result};
use async_trait::async_trait; use async_trait::async_trait;
const USERS_PATH_PREFIX: &str = "/users/";
const THOUGHTS_PATH_PREFIX: &str = "/thoughts/";
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use std::sync::Arc; use std::sync::Arc;
use url::Url; use url::Url;
use crate::note::{ThoughtNote, ThoughtNoteInput}; use crate::note::ThoughtNote;
use crate::port::{AcceptNoteInput, ActivityPubRepository};
use crate::urls::ThoughtsUrls; use crate::urls::ThoughtsUrls;
use domain::ports::{BoostRepository, EventPublisher, LikeRepository, TagRepository}; use activitypub_base::ApObjectHandler;
use domain::ports::ActivityPubRepository;
use domain::value_objects::UserId; use domain::value_objects::UserId;
use k_ap::{ApContentReader, ApObjectHandler};
pub struct ThoughtsObjectHandler { pub struct ThoughtsObjectHandler {
repo: Arc<dyn ActivityPubRepository>, repo: Arc<dyn ActivityPubRepository>,
urls: ThoughtsUrls, urls: ThoughtsUrls,
event_publisher: Option<Arc<dyn EventPublisher>>,
tag_repo: Arc<dyn TagRepository>,
likes: Arc<dyn LikeRepository>,
boosts: Arc<dyn BoostRepository>,
} }
impl ThoughtsObjectHandler { impl ThoughtsObjectHandler {
pub fn new( pub fn new(repo: Arc<dyn ActivityPubRepository>, base_url: &str) -> Self {
repo: Arc<dyn ActivityPubRepository>,
base_url: &str,
event_publisher: Option<Arc<dyn EventPublisher>>,
tag_repo: Arc<dyn TagRepository>,
likes: Arc<dyn LikeRepository>,
boosts: Arc<dyn BoostRepository>,
) -> Self {
Self { Self {
repo, repo,
urls: ThoughtsUrls::new(base_url), urls: ThoughtsUrls::new(base_url),
event_publisher,
tag_repo,
likes,
boosts,
} }
} }
} }
// ── ApContentReader ───────────────────────────────────────────────────────────
#[async_trait] #[async_trait]
impl ApContentReader for ThoughtsObjectHandler { impl ApObjectHandler for ThoughtsObjectHandler {
async fn get_local_objects_for_user(
&self,
user_id: uuid::Uuid,
) -> Result<Vec<(Url, serde_json::Value)>> {
let uid = UserId::from_uuid(user_id);
let entries = self
.repo
.outbox_entries_for_actor(&uid)
.await
.map_err(|e| anyhow!("{e}"))?;
entries
.into_iter()
.map(|e| {
let note_url = self.urls.thought_url(e.thought.id.as_uuid());
let actor_url = self.urls.user_url(e.author_username.as_str());
let followers = self.urls.user_followers(e.author_username.as_str());
let in_reply_to = e
.thought
.in_reply_to_id
.map(|id| self.urls.thought_url(id.as_uuid()));
let note = ThoughtNote::new_public(
note_url.clone(),
actor_url,
e.thought.content.as_str().to_owned(),
e.thought.created_at,
in_reply_to,
e.thought.sensitive,
e.thought.content_warning,
followers,
);
Ok((note_url, serde_json::to_value(&note)?))
})
.collect()
}
async fn get_local_objects_page( async fn get_local_objects_page(
&self, &self,
user_id: uuid::Uuid, user_id: uuid::Uuid,
@@ -64,59 +78,41 @@ impl ApContentReader for ThoughtsObjectHandler {
.map(|e| { .map(|e| {
let created_at = e.thought.created_at; let created_at = e.thought.created_at;
let note_url = self.urls.thought_url(e.thought.id.as_uuid()); let note_url = self.urls.thought_url(e.thought.id.as_uuid());
let actor_url = self.urls.user_url(&user_id.to_string()); let actor_url = self.urls.user_url(e.author_username.as_str());
let followers = self.urls.user_followers(&user_id.to_string()); let followers = self.urls.user_followers(e.author_username.as_str());
let in_reply_to = e let in_reply_to = e
.thought .thought
.in_reply_to_id .in_reply_to_id
.map(|id| self.urls.thought_url(id.as_uuid())); .map(|id| self.urls.thought_url(id.as_uuid()));
let note = ThoughtNote::new_public(ThoughtNoteInput { let note = ThoughtNote::new_public(
id: note_url.clone(), note_url.clone(),
actor_url, actor_url,
content: e.thought.content.as_str().to_owned(), e.thought.content.as_str().to_owned(),
published: created_at, created_at,
in_reply_to, in_reply_to,
sensitive: e.thought.sensitive, e.thought.sensitive,
summary: e.thought.content_warning, e.thought.content_warning,
followers_url: followers, followers,
}); );
Ok((note_url, serde_json::to_value(&note)?, created_at)) Ok((note_url, serde_json::to_value(&note)?, created_at))
}) })
.collect() .collect()
} }
async fn count_local_posts(&self) -> Result<u64> {
self.repo
.count_local_notes()
.await
.map_err(|e| anyhow!("{e}"))
}
}
// ── ApObjectHandler ───────────────────────────────────────────────────────────
#[async_trait]
impl ApObjectHandler for ThoughtsObjectHandler {
async fn on_create( async fn on_create(
&self, &self,
ap_id: &Url, ap_id: &Url,
actor_url: &Url, actor_url: &Url,
object: serde_json::Value, object: serde_json::Value,
) -> Result<()> { ) -> Result<()> {
let Some((note, note_extensions)) = ThoughtNote::try_from_ap(object) else { let note: ThoughtNote = serde_json::from_value(object)?;
tracing::debug!(ap_id = %ap_id, "on_create: skipping non-Note object");
return Ok(());
};
let author_id = self let author_id = self
.repo .repo
.intern_remote_actor(actor_url.as_str()) .intern_remote_actor(actor_url)
.await .await
.map_err(|e| anyhow!("{e}"))?; .map_err(|e| anyhow!("{e}"))?;
let _ = self
.repo
.sync_remote_actor_to_user(actor_url.as_str())
.await;
// Derive visibility from AP addressing conventions.
let as_public = "https://www.w3.org/ns/activitystreams#Public"; let as_public = "https://www.w3.org/ns/activitystreams#Public";
let in_to = note.to.iter().any(|s| s == as_public); let in_to = note.to.iter().any(|s| s == as_public);
let in_cc = note.cc.iter().any(|s| s == as_public); let in_cc = note.cc.iter().any(|s| s == as_public);
@@ -133,362 +129,51 @@ impl ApObjectHandler for ThoughtsObjectHandler {
"direct" "direct"
}; };
let thought_id = self self.repo
.repo .accept_note(
.accept_note(AcceptNoteInput { ap_id,
ap_id: ap_id.as_str(), &author_id,
author_id: &author_id, &note.content,
content: &note.content, note.published,
published: note.published, note.sensitive,
sensitive: note.sensitive, note.summary,
content_warning: note.summary,
visibility, visibility,
in_reply_to: note.in_reply_to.as_ref().map(|u| u.as_str()), )
note_extensions,
})
.await .await
.map_err(|e| anyhow!("{e}"))?; .map_err(|e| anyhow!("{e}"))
let hashtag_names: Vec<String> = note
.tag
.iter()
.filter(|t| t.get("type").and_then(|v| v.as_str()) == Some("Hashtag"))
.filter_map(|t| t.get("name").and_then(|v| v.as_str()))
.map(|name| name.trim_start_matches('#').to_lowercase())
.filter(|name| !name.is_empty())
.collect();
for name in hashtag_names {
if let Ok(tag) = self.tag_repo.find_or_create(&name).await {
let _ = self.tag_repo.attach_to_thought(&thought_id, tag.id).await;
}
}
let base_url = url::Url::parse(&self.urls.base_url)
.ok()
.and_then(|u| u.host_str().map(|h| h.to_string()))
.unwrap_or_default();
for tag in &note.tag {
if tag.get("type").and_then(|t| t.as_str()) != Some("Mention") {
continue;
}
let href = match tag.get("href").and_then(|h| h.as_str()) {
Some(h) => h,
None => continue,
};
let href_url = match url::Url::parse(href) {
Ok(u) => u,
Err(_) => continue,
};
if href_url.host_str().unwrap_or("") != base_url {
continue;
}
let user_uuid = href_url
.path()
.strip_prefix(USERS_PATH_PREFIX)
.and_then(|s| s.split('/').next())
.and_then(|s| uuid::Uuid::parse_str(s).ok());
if let Some(uuid) = user_uuid {
self.on_mention(ap_id, uuid, actor_url)
.await
.unwrap_or_else(|e| {
tracing::warn!(error = %e, "failed to process mention notification");
});
}
}
Ok(())
} }
async fn on_update( async fn on_update(
&self, &self,
ap_id: &Url, ap_id: &Url,
actor_url: &Url, _actor_url: &Url,
object: serde_json::Value, object: serde_json::Value,
) -> Result<()> { ) -> Result<()> {
let obj_type = object.get("type").and_then(|v| v.as_str()).unwrap_or(""); let note: ThoughtNote = serde_json::from_value(object)?;
match obj_type {
"Note" | "Article" | "Page" => {
let Some((note, note_extensions)) = ThoughtNote::try_from_ap(object) else {
return Ok(());
};
self.repo self.repo
.apply_note_update(ap_id.as_str(), &note.content, note_extensions) .apply_note_update(ap_id, &note.content)
.await .await
.map_err(|e| anyhow!("{e}")) .map_err(|e| anyhow!("{e}"))
} }
"Person" | "Service" | "Application" | "Group" | "Organization" => {
let display_name = object.get("name").and_then(|v| v.as_str());
let avatar_url = object
.get("icon")
.and_then(|v| v.get("url"))
.and_then(|v| v.as_str());
self.repo
.update_remote_actor_display(
&self
.repo
.find_remote_actor_id(actor_url.as_str())
.await
.map_err(|e| anyhow!("{e}"))?
.ok_or_else(|| anyhow!("unknown actor"))?,
display_name,
avatar_url,
)
.await
.map_err(|e| anyhow!("{e}"))?;
let _ = self
.repo
.sync_remote_actor_to_user(actor_url.as_str())
.await;
Ok(())
}
_ => {
tracing::debug!(ap_id = %ap_id, obj_type, "on_update: skipping");
Ok(())
}
}
}
async fn on_delete(&self, ap_id: &Url, _actor_url: &Url) -> Result<()> { async fn on_delete(&self, ap_id: &Url, _actor_url: &Url) -> Result<()> {
self.repo self.repo
.retract_note(ap_id.as_str()) .retract_note(ap_id)
.await .await
.map_err(|e| anyhow!("{e}")) .map_err(|e| anyhow!("{e}"))
} }
async fn on_actor_removed(&self, actor_url: &Url) -> Result<()> { async fn on_actor_removed(&self, actor_url: &Url) -> Result<()> {
self.repo self.repo
.retract_actor_notes(actor_url.as_str()) .retract_actor_notes(actor_url)
.await .await
.map_err(|e| anyhow!("{e}")) .map_err(|e| anyhow!("{e}"))
} }
async fn on_like(&self, object_url: &Url, actor_url: &Url) -> Result<()> { async fn count_local_posts(&self) -> Result<u64> {
let thought_uuid = object_url self.repo
.path() .count_local_notes()
.strip_prefix(THOUGHTS_PATH_PREFIX)
.and_then(|s| s.split('/').next())
.and_then(|s| uuid::Uuid::parse_str(s).ok());
let thought_uuid = match thought_uuid {
Some(u) => u,
None => {
tracing::debug!(object = %object_url, "on_like: not a local thought URL, skipping");
return Ok(());
}
};
let actor_user_id = self
.repo
.find_remote_actor_id(actor_url.as_str())
.await .await
.map_err(|e| anyhow!("{e}"))?; .map_err(|e| anyhow!("{e}"))
let actor_user_id = match actor_user_id {
Some(id) => id,
None => {
tracing::debug!(actor = %actor_url, "on_like: remote actor not interned, skipping");
return Ok(());
}
};
let thought_id = domain::value_objects::ThoughtId::from_uuid(thought_uuid);
let like_id = domain::value_objects::LikeId::new();
let like = domain::models::social::Like {
id: like_id.clone(),
user_id: actor_user_id.clone(),
thought_id: thought_id.clone(),
ap_id: Some(object_url.to_string()),
created_at: Utc::now(),
};
let _ = self.likes.save(&like).await;
if let Some(ep) = &self.event_publisher {
ep.publish(&domain::events::DomainEvent::LikeAdded {
like_id,
user_id: actor_user_id,
thought_id,
})
.await
.map_err(|e| anyhow!("{e}"))?;
}
Ok(())
}
async fn on_unlike(&self, object_url: &url::Url, actor_url: &url::Url) -> anyhow::Result<()> {
let thought_uuid = object_url
.path()
.strip_prefix(THOUGHTS_PATH_PREFIX)
.and_then(|s| s.split('/').next())
.and_then(|s| uuid::Uuid::parse_str(s).ok());
let thought_uuid = match thought_uuid {
Some(u) => u,
None => {
tracing::debug!(object = %object_url, "on_unlike: not a local thought URL, skipping");
return Ok(());
}
};
let actor_user_id = self
.repo
.find_remote_actor_id(actor_url.as_str())
.await
.map_err(|e| anyhow!("{e}"))?;
let actor_user_id = match actor_user_id {
Some(id) => id,
None => {
tracing::debug!(actor = %actor_url, "on_unlike: remote actor not interned, skipping");
return Ok(());
}
};
let thought_id = domain::value_objects::ThoughtId::from_uuid(thought_uuid);
let _ = self.likes.delete(&actor_user_id, &thought_id).await;
if let Some(ep) = &self.event_publisher {
ep.publish(&domain::events::DomainEvent::LikeRemoved {
user_id: actor_user_id,
thought_id,
})
.await
.map_err(|e| anyhow!("{e}"))?;
}
Ok(())
}
async fn on_mention(
&self,
thought_ap_id: &url::Url,
mentioned_user_uuid: uuid::Uuid,
actor_url: &url::Url,
) -> anyhow::Result<()> {
let author_user_id = match self
.repo
.find_remote_actor_id(actor_url.as_str())
.await
.map_err(|e| anyhow!("{e}"))?
{
Some(id) => id,
None => return Ok(()),
};
let thought_uuid = thought_ap_id
.path()
.strip_prefix(THOUGHTS_PATH_PREFIX)
.and_then(|s| s.split('/').next())
.and_then(|s| uuid::Uuid::parse_str(s).ok());
let thought_uuid = match thought_uuid {
Some(u) => u,
None => return Ok(()),
};
if let Some(ep) = &self.event_publisher {
ep.publish(&domain::events::DomainEvent::MentionReceived {
thought_id: domain::value_objects::ThoughtId::from_uuid(thought_uuid),
mentioned_user_id: domain::value_objects::UserId::from_uuid(mentioned_user_uuid),
author_user_id,
})
.await
.map_err(|e| anyhow!("{e}"))?;
}
Ok(())
}
async fn on_announce_received(&self, object_url: &Url, actor_url: &Url) -> Result<()> {
let thought_uuid = object_url
.path()
.strip_prefix(THOUGHTS_PATH_PREFIX)
.and_then(|s| s.split('/').next())
.and_then(|s| uuid::Uuid::parse_str(s).ok());
let thought_uuid = match thought_uuid {
Some(u) => u,
None => return Ok(()),
};
let actor_user_id = self
.repo
.find_remote_actor_id(actor_url.as_str())
.await
.map_err(|e| anyhow!("{e}"))?;
let actor_user_id = match actor_user_id {
Some(id) => id,
None => return Ok(()),
};
let thought_id = domain::value_objects::ThoughtId::from_uuid(thought_uuid);
let boost_id = domain::value_objects::BoostId::new();
let boost = domain::models::social::Boost {
id: boost_id.clone(),
user_id: actor_user_id.clone(),
thought_id: thought_id.clone(),
ap_id: Some(object_url.to_string()),
created_at: Utc::now(),
};
let _ = self.boosts.save(&boost).await;
if let Some(ep) = &self.event_publisher {
ep.publish(&domain::events::DomainEvent::BoostAdded {
boost_id,
user_id: actor_user_id,
thought_id,
})
.await
.map_err(|e| anyhow!("{e}"))?;
}
Ok(())
}
async fn on_announce_removed(&self, object_url: &Url, actor_url: &Url) -> Result<()> {
let thought_uuid = object_url
.path()
.strip_prefix(THOUGHTS_PATH_PREFIX)
.and_then(|s| s.split('/').next())
.and_then(|s| uuid::Uuid::parse_str(s).ok());
let thought_uuid = match thought_uuid {
Some(u) => u,
None => return Ok(()),
};
let actor_user_id = self
.repo
.find_remote_actor_id(actor_url.as_str())
.await
.map_err(|e| anyhow!("{e}"))?;
let actor_user_id = match actor_user_id {
Some(id) => id,
None => return Ok(()),
};
let thought_id = domain::value_objects::ThoughtId::from_uuid(thought_uuid);
let _ = self.boosts.delete(&actor_user_id, &thought_id).await;
if let Some(ep) = &self.event_publisher {
ep.publish(&domain::events::DomainEvent::BoostRemoved {
user_id: actor_user_id,
thought_id,
})
.await
.map_err(|e| anyhow!("{e}"))?;
}
Ok(())
}
async fn on_announce_of_remote(&self, _object_url: &Url, _actor_url: &Url) -> Result<()> {
Ok(())
} }
} }

View File

@@ -1,62 +1,7 @@
pub mod handler; pub mod handler;
pub mod note; pub mod note;
pub mod port;
pub mod service;
pub mod urls; pub mod urls;
pub const INSTANCE_ACTOR_ID: uuid::Uuid =
uuid::Uuid::from_bytes([0, 0, 0, 0, 0, 0, 0x40, 0, 0x80, 0, 0, 0, 0, 0, 0, 0]);
pub use handler::ThoughtsObjectHandler; pub use handler::ThoughtsObjectHandler;
pub use note::ThoughtNote; pub use note::ThoughtNote;
pub use port::{
AcceptNoteInput, ActivityPubRepository, ActorApUrls, OutboundFederationPort, OutboxEntry,
};
pub use service::ApFederationAdapter;
pub use urls::ThoughtsUrls; pub use urls::ThoughtsUrls;
use domain::ports::RemoteActorConnectionRepository;
use k_ap::ActivityPubService;
use std::sync::Arc;
pub struct ApServiceConfig {
pub base_url: String,
pub activity_repo: Arc<dyn k_ap::ActivityRepository>,
pub follow_repo: Arc<dyn k_ap::FollowRepository>,
pub actor_repo: Arc<dyn k_ap::ActorRepository>,
pub blocklist_repo: Arc<dyn k_ap::BlocklistRepository>,
pub user_repo: Arc<dyn k_ap::ApUserRepository>,
pub ap_handler: Arc<ThoughtsObjectHandler>,
pub connections_repo: Arc<dyn RemoteActorConnectionRepository>,
pub event_publisher: Option<Arc<dyn k_ap::data::EventPublisher>>,
pub allow_registration: bool,
pub debug: bool,
}
pub async fn build_ap_service(
cfg: ApServiceConfig,
) -> (Arc<ActivityPubService>, Arc<ApFederationAdapter>) {
let mut builder = ActivityPubService::builder(cfg.base_url)
.activity_repo(cfg.activity_repo)
.follow_repo(cfg.follow_repo)
.actor_repo(cfg.actor_repo)
.blocklist_repo(cfg.blocklist_repo)
.user_repo(cfg.user_repo)
.content_reader(cfg.ap_handler.clone())
.object_handler(cfg.ap_handler)
.allow_registration(cfg.allow_registration)
.software_name("thoughts")
.debug(cfg.debug)
.signed_fetch_actor_id(INSTANCE_ACTOR_ID);
if let Some(publisher) = cfg.event_publisher {
builder = builder.event_publisher(publisher);
}
let raw = Arc::new(
builder
.build()
.await
.expect("Failed to build ActivityPubService"),
);
let adapter = Arc::new(ApFederationAdapter::new(raw.clone(), cfg.connections_repo));
(raw, adapter)
}

View File

@@ -0,0 +1,78 @@
use activitypub_base::NoteType;
use activitypub_base::AS_PUBLIC;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use url::Url;
/// AP Note representing a Thought.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ThoughtNote {
#[serde(rename = "type")]
pub kind: NoteType,
pub id: Url,
pub url: Url, // Mastodon uses this as the clickable link
pub attributed_to: Url,
pub content: String,
pub published: DateTime<Utc>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub to: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub cc: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub in_reply_to: Option<Url>,
pub sensitive: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub summary: Option<String>,
}
impl ThoughtNote {
#[allow(clippy::too_many_arguments)]
pub fn new_public(
id: Url,
actor_url: Url,
content: String,
published: DateTime<Utc>,
in_reply_to: Option<Url>,
sensitive: bool,
summary: Option<String>,
followers_url: Url,
) -> Self {
Self {
kind: Default::default(),
url: id.clone(),
id,
attributed_to: actor_url,
content,
published,
to: vec![AS_PUBLIC.to_string()],
cc: vec![followers_url.to_string()],
in_reply_to,
sensitive,
summary,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn note_serializes_with_public_audience() {
let note = ThoughtNote::new_public(
"https://example.com/thoughts/1".parse().unwrap(),
"https://example.com/users/alice".parse().unwrap(),
"Hello world".to_string(),
chrono::Utc::now(),
None,
false,
None,
"https://example.com/users/alice/followers".parse().unwrap(),
);
let json = serde_json::to_string(&note).unwrap();
assert!(json.contains(AS_PUBLIC));
assert!(json.contains("Hello world"));
assert!(json.contains("\"url\""));
}
}

View File

@@ -1,110 +0,0 @@
use chrono::{DateTime, Utc};
use k_ap::NoteType;
use k_ap::AS_PUBLIC;
use serde::{Deserialize, Serialize};
use url::Url;
const STANDARD_NOTE_FIELDS: &[&str] = &[
"type",
"id",
"attributedTo",
"content",
"published",
"to",
"cc",
"inReplyTo",
"sensitive",
"summary",
"tag",
"url",
"@context",
"mediaType",
];
pub fn extract_extensions(obj: &serde_json::Value) -> Option<serde_json::Value> {
let extensions: serde_json::Map<String, serde_json::Value> = obj
.as_object()?
.iter()
.filter(|(k, _)| !STANDARD_NOTE_FIELDS.contains(&k.as_str()))
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
if extensions.is_empty() {
None
} else {
Some(serde_json::Value::Object(extensions))
}
}
/// AP Note representing a Thought.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ThoughtNote {
#[serde(rename = "type")]
pub kind: NoteType,
pub id: Url,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub url: Option<Url>,
pub attributed_to: Url,
pub content: String,
pub published: DateTime<Utc>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub to: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub cc: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub in_reply_to: Option<Url>,
#[serde(default)]
pub sensitive: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub summary: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub tag: Vec<serde_json::Value>,
}
pub struct ThoughtNoteInput {
pub id: Url,
pub actor_url: Url,
pub content: String,
pub published: DateTime<Utc>,
pub in_reply_to: Option<Url>,
pub sensitive: bool,
pub summary: Option<String>,
pub followers_url: Url,
}
impl ThoughtNote {
/// Returns `(note, extensions)` if `value` is a Note object, `None` otherwise.
pub fn try_from_ap(mut value: serde_json::Value) -> Option<(Self, Option<serde_json::Value>)> {
let obj_type = value.get("type").and_then(|v| v.as_str());
if !matches!(obj_type, Some("Note" | "Article" | "Page")) {
return None;
}
let extensions = extract_extensions(&value);
if let Some(obj) = value.as_object_mut() {
obj.insert("type".to_string(), serde_json::json!("Note"));
}
serde_json::from_value(value)
.ok()
.map(|note| (note, extensions))
}
pub fn new_public(p: ThoughtNoteInput) -> Self {
Self {
kind: Default::default(),
url: Some(p.id.clone()),
id: p.id,
attributed_to: p.actor_url,
content: p.content,
published: p.published,
to: vec![AS_PUBLIC.to_string()],
cc: vec![p.followers_url.to_string()],
in_reply_to: p.in_reply_to,
sensitive: p.sensitive,
summary: p.summary,
tag: Vec::new(),
}
}
}
#[cfg(test)]
mod tests;

View File

@@ -1,69 +0,0 @@
use super::*;
#[test]
fn extract_extensions_picks_up_non_standard_fields() {
let obj = serde_json::json!({
"type": "Note",
"id": "https://example.com/notes/1",
"content": "hello",
"published": "2025-01-01T00:00:00Z",
"movieTitle": "Dune",
"rating": 5,
"posterUrl": "https://example.com/poster.jpg"
});
let ext = extract_extensions(&obj).unwrap();
assert_eq!(ext["movieTitle"], "Dune");
assert_eq!(ext["rating"], 5);
assert_eq!(ext["posterUrl"], "https://example.com/poster.jpg");
assert!(ext.get("type").is_none());
assert!(ext.get("content").is_none());
assert!(ext.get("id").is_none());
}
#[test]
fn extract_extensions_returns_none_for_standard_only_note() {
let obj = serde_json::json!({
"type": "Note",
"content": "hello",
"published": "2025-01-01T00:00:00Z",
"to": ["https://www.w3.org/ns/activitystreams#Public"],
"tag": []
});
assert!(extract_extensions(&obj).is_none());
}
#[test]
fn extract_extensions_returns_none_for_non_object() {
let obj = serde_json::json!("not an object");
assert!(extract_extensions(&obj).is_none());
}
#[test]
fn try_from_ap_returns_none_for_person() {
let person = serde_json::json!({ "type": "Person", "id": "https://example.com/users/1" });
assert!(ThoughtNote::try_from_ap(person).is_none());
}
#[test]
fn try_from_ap_returns_none_for_missing_type() {
let obj = serde_json::json!({ "content": "hello" });
assert!(ThoughtNote::try_from_ap(obj).is_none());
}
#[test]
fn note_serializes_with_public_audience() {
let note = ThoughtNote::new_public(super::ThoughtNoteInput {
id: "https://example.com/thoughts/1".parse().unwrap(),
actor_url: "https://example.com/users/alice".parse().unwrap(),
content: "Hello world".to_string(),
published: chrono::Utc::now(),
in_reply_to: None,
sensitive: false,
summary: None,
followers_url: "https://example.com/users/alice/followers".parse().unwrap(),
});
let json = serde_json::to_string(&note).unwrap();
assert!(json.contains(AS_PUBLIC));
assert!(json.contains("Hello world"));
assert!(json.contains("\"url\""));
}

View File

@@ -1,5 +0,0 @@
pub use domain::ports::{
AcceptNoteInput, ActorFederationUrls as ActorApUrls,
FederationBroadcastPort as OutboundFederationPort,
FederationContentRepository as ActivityPubRepository, OutboxEntry,
};

View File

@@ -1,916 +0,0 @@
use std::sync::Arc;
use async_trait::async_trait;
use k_ap::ActivityPubService;
use domain::{
errors::DomainError,
models::remote_actor::RemoteActor as DomainRemoteActor,
ports::{
FederationFetchPort, FederationFollowPort, FederationFollowRequestPort,
FederationLookupPort, FederationSchedulerPort, RemoteActorConnectionRepository,
},
value_objects::UserId,
};
const HTTP_FETCH_TIMEOUT_SECS: u64 = 30;
const BATCH_FETCH_SLEEP_MS: u64 = 100;
// ── Helpers ───────────────────────────────────────────────────────────────────
fn content_to_html(text: &str) -> String {
let escaped = text
.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&#39;");
let paragraphs: Vec<&str> = escaped.split('\n').filter(|s| !s.is_empty()).collect();
if paragraphs.is_empty() {
format!("<p>{}</p>", escaped)
} else {
paragraphs
.iter()
.map(|p| format!("<p>{}</p>", p))
.collect::<Vec<_>>()
.join("")
}
}
fn build_note_json(
thought: &domain::models::thought::Thought,
local_actor_ap_id: &str,
local_actor_followers_url: &str,
base_url: &str,
in_reply_to_url: Option<&str>,
) -> serde_json::Value {
let ap_id = format!("{}/thoughts/{}", base_url, thought.id);
let (to, cc) = match thought.visibility {
domain::models::thought::Visibility::Public => (
vec![k_ap::AS_PUBLIC.to_string()],
vec![local_actor_followers_url.to_string()],
),
domain::models::thought::Visibility::Unlisted => (
vec![local_actor_followers_url.to_string()],
vec![k_ap::AS_PUBLIC.to_string()],
),
domain::models::thought::Visibility::Followers => {
(vec![local_actor_followers_url.to_string()], vec![])
}
domain::models::thought::Visibility::Direct => (vec![], vec![]),
};
let mut note = serde_json::json!({
"type": "Note",
"id": ap_id,
"url": ap_id,
"attributedTo": local_actor_ap_id,
"content": content_to_html(thought.content.as_str()),
"published": thought.created_at.to_rfc3339(),
"to": to,
"cc": cc,
"sensitive": thought.sensitive,
});
if let Some(ref cw) = thought.content_warning {
note["summary"] = serde_json::json!(cw);
}
if let Some(reply_url) = in_reply_to_url {
note["inReplyTo"] = serde_json::json!(reply_url);
}
if let Some(updated_at) = thought.updated_at {
note["updated"] = serde_json::json!(updated_at.to_rfc3339());
}
let hashtags = domain::hashtag::extract(thought.content.as_str());
if !hashtags.is_empty() {
let ap_tags: Vec<serde_json::Value> = hashtags
.iter()
.map(|h| {
serde_json::json!({
"type": "Hashtag",
"name": h.ap_name,
"href": format!("{}/{}", base_url, h.url_slug),
})
})
.collect();
note["tag"] = serde_json::json!(ap_tags);
}
if let Some(ref mood) = thought.mood {
note["mood"] = serde_json::json!(mood);
}
if let Some(ref ext) = thought.note_extensions {
if let Some(obj) = ext.as_object() {
for (k, v) in obj {
note.as_object_mut().unwrap().entry(k).or_insert(v.clone());
}
}
}
note
}
fn thought_to_ap_visibility(v: &domain::models::thought::Visibility) -> k_ap::ApVisibility {
match v {
domain::models::thought::Visibility::Public => k_ap::ApVisibility::Public,
domain::models::thought::Visibility::Unlisted => k_ap::ApVisibility::Public,
domain::models::thought::Visibility::Followers => k_ap::ApVisibility::FollowersOnly,
domain::models::thought::Visibility::Direct => k_ap::ApVisibility::Private,
}
}
fn k_ap_actor_to_domain(a: k_ap::RemoteActor) -> DomainRemoteActor {
DomainRemoteActor {
url: a.url,
handle: a.handle,
display_name: a.display_name,
avatar_url: a.avatar_url,
outbox_url: a.outbox_url,
last_fetched_at: a.fetched_at.unwrap_or_else(chrono::Utc::now),
bio: a.bio,
banner_url: a.banner_url,
also_known_as: a.also_known_as,
followers_url: a.followers_url,
following_url: a.following_url,
inbox_url: Some(a.inbox_url),
shared_inbox_url: a.shared_inbox_url,
attachment: vec![],
}
}
async fn resolve_actor_profiles_from_urls(
urls: Vec<String>,
) -> Vec<domain::models::actor_connection_summary::ActorConnectionSummary> {
use futures::future;
async fn fetch_one(
url: String,
) -> Option<domain::models::actor_connection_summary::ActorConnectionSummary> {
let resp: serde_json::Value = tokio::time::timeout(
std::time::Duration::from_secs(5),
reqwest::Client::new()
.get(&url)
.header("Accept", "application/activity+json")
.send(),
)
.await
.ok()?
.ok()?
.json()
.await
.ok()?;
let ap_url = resp["id"].as_str()?.to_string();
let preferred_username = resp["preferredUsername"].as_str().unwrap_or("").to_string();
let domain_str = url::Url::parse(&ap_url)
.ok()
.and_then(|u| u.host_str().map(|s| s.to_string()))
.unwrap_or_default();
let handle = format!("{}@{}", preferred_username, domain_str);
let display_name = resp["name"].as_str().map(|s| s.to_string());
let avatar_url = resp["icon"]["url"].as_str().map(|s| s.to_string());
Some(
domain::models::actor_connection_summary::ActorConnectionSummary {
url: ap_url,
handle,
display_name,
avatar_url,
},
)
}
let futs: Vec<_> = urls.into_iter().map(fetch_one).collect();
let results = future::join_all(futs).await;
results
.into_iter()
.filter_map(|r| {
if r.is_none() {
tracing::warn!("failed to resolve actor profile (timeout or parse error)");
}
r
})
.collect()
}
async fn webfinger_resolve_actor_url(handle: &str) -> anyhow::Result<String> {
let normalized = handle.trim_start_matches('@');
let at = normalized
.rfind('@')
.ok_or_else(|| anyhow::anyhow!("handle must be user@domain"))?;
let (user, domain_str) = (&normalized[..at], &normalized[at + 1..]);
let wf_url = format!(
"https://{}/.well-known/webfinger?resource=acct:{}@{}",
domain_str, user, domain_str
);
let wf: serde_json::Value = reqwest::Client::new()
.get(&wf_url)
.header("Accept", "application/jrd+json, application/json")
.send()
.await?
.json()
.await?;
let self_href = wf["links"]
.as_array()
.and_then(|links| {
links.iter().find(|l| {
l["rel"].as_str() == Some("self")
&& l["type"].as_str().is_some_and(|t| {
t == "application/activity+json" || t.starts_with("application/ld+json")
})
})
})
.and_then(|l| l["href"].as_str())
.ok_or_else(|| anyhow::anyhow!("no self link in WebFinger response"))?
.to_owned();
Ok(self_href)
}
// ── ApFederationAdapter ───────────────────────────────────────────────────────
/// Wraps `k_ap::ActivityPubService` together with the `RemoteActorConnectionRepository`
/// (which k-ap doesn't own), and implements all domain federation port traits.
#[derive(Clone)]
pub struct ApFederationAdapter {
pub(crate) inner: Arc<ActivityPubService>,
pub(crate) connections_repo: Arc<dyn RemoteActorConnectionRepository>,
}
impl ApFederationAdapter {
pub fn new(
inner: Arc<ActivityPubService>,
connections_repo: Arc<dyn RemoteActorConnectionRepository>,
) -> Self {
Self {
inner,
connections_repo,
}
}
pub fn router<S>(&self) -> axum::Router<S>
where
S: Clone + Send + Sync + 'static,
{
self.inner.router()
}
fn base_url(&self) -> &str {
self.inner.base_url()
}
fn actor_ap_id(&self, user_uuid: uuid::Uuid) -> String {
format!("{}/users/{}", self.base_url(), user_uuid)
}
fn actor_followers_url(&self, user_uuid: uuid::Uuid) -> String {
format!("{}/followers", self.actor_ap_id(user_uuid))
}
}
// ── OutboundFederationPort ────────────────────────────────────────────────────
#[async_trait]
impl crate::port::OutboundFederationPort for ApFederationAdapter {
async fn broadcast_create(
&self,
author_user_id: &UserId,
thought: &domain::models::thought::Thought,
_author_username: &str,
in_reply_to_url: Option<&str>,
) -> Result<(), DomainError> {
let user_uuid = author_user_id.as_uuid();
let ap_id = self.actor_ap_id(user_uuid);
let followers_url = self.actor_followers_url(user_uuid);
let note = build_note_json(
thought,
&ap_id,
&followers_url,
self.base_url(),
in_reply_to_url,
);
self.inner
.broadcast_create_note(
user_uuid,
note,
thought_to_ap_visibility(&thought.visibility),
vec![],
)
.await
.map_err(|e| DomainError::Internal(e.to_string()))
}
async fn broadcast_delete(
&self,
author_user_id: &UserId,
thought_ap_id: &str,
) -> Result<(), DomainError> {
let ap_id =
url::Url::parse(thought_ap_id).map_err(|e| DomainError::Internal(e.to_string()))?;
self.inner
.broadcast_delete_to_followers(author_user_id.as_uuid(), ap_id)
.await
.map_err(|e| DomainError::Internal(e.to_string()))
}
async fn broadcast_update(
&self,
author_user_id: &UserId,
thought: &domain::models::thought::Thought,
_author_username: &str,
in_reply_to_url: Option<&str>,
) -> Result<(), DomainError> {
let user_uuid = author_user_id.as_uuid();
let ap_id = self.actor_ap_id(user_uuid);
let followers_url = self.actor_followers_url(user_uuid);
let note = build_note_json(
thought,
&ap_id,
&followers_url,
self.base_url(),
in_reply_to_url,
);
self.inner
.broadcast_update_note(
user_uuid,
note,
thought_to_ap_visibility(&thought.visibility),
vec![],
)
.await
.map_err(|e| DomainError::Internal(e.to_string()))
}
async fn broadcast_announce(
&self,
booster_user_id: &UserId,
object_ap_id: &str,
) -> Result<(), DomainError> {
let ap_id =
url::Url::parse(object_ap_id).map_err(|e| DomainError::Internal(e.to_string()))?;
self.inner
.broadcast_announce_to_followers(booster_user_id.as_uuid(), ap_id)
.await
.map_err(|e| DomainError::Internal(e.to_string()))
}
async fn broadcast_undo_announce(
&self,
booster_user_id: &UserId,
object_ap_id: &str,
) -> Result<(), DomainError> {
let ap_id =
url::Url::parse(object_ap_id).map_err(|e| DomainError::Internal(e.to_string()))?;
self.inner
.broadcast_undo_announce_to_followers(booster_user_id.as_uuid(), ap_id)
.await
.map_err(|e| DomainError::Internal(e.to_string()))
}
async fn broadcast_like(
&self,
liker_user_id: &UserId,
object_ap_id: &str,
author_inbox_url: &str,
) -> Result<(), DomainError> {
let object =
url::Url::parse(object_ap_id).map_err(|e| DomainError::Internal(e.to_string()))?;
let inbox =
url::Url::parse(author_inbox_url).map_err(|e| DomainError::Internal(e.to_string()))?;
self.inner
.broadcast_like_to_inbox(liker_user_id.as_uuid(), object, inbox)
.await
.map_err(|e| DomainError::Internal(e.to_string()))
}
async fn broadcast_undo_like(
&self,
liker_user_id: &UserId,
object_ap_id: &str,
author_inbox_url: &str,
) -> Result<(), DomainError> {
let object =
url::Url::parse(object_ap_id).map_err(|e| DomainError::Internal(e.to_string()))?;
let inbox =
url::Url::parse(author_inbox_url).map_err(|e| DomainError::Internal(e.to_string()))?;
self.inner
.broadcast_undo_like_to_inbox(liker_user_id.as_uuid(), object, inbox)
.await
.map_err(|e| DomainError::Internal(e.to_string()))
}
async fn broadcast_actor_update(&self, user_id: &UserId) -> Result<(), DomainError> {
self.inner
.broadcast_actor_update(user_id.as_uuid())
.await
.map_err(|e| DomainError::Internal(e.to_string()))
}
}
// ── FederationSchedulerPort ───────────────────────────────────────────────────
#[async_trait]
impl FederationSchedulerPort for ApFederationAdapter {
async fn schedule_actor_posts_fetch(
&self,
actor_ap_url: &str,
outbox_url: &str,
) -> Result<(), DomainError> {
let service = self.inner.clone();
let actor = actor_ap_url.to_string();
let outbox = outbox_url.to_string();
tokio::spawn(async move {
if let Err(e) = service.import_remote_outbox(&outbox, &actor).await {
tracing::warn!(actor = %actor, error = %e, "posts backfill failed");
}
});
Ok(())
}
async fn schedule_connections_fetch(
&self,
actor_ap_url: &str,
collection_url: &str,
connection_type: &str,
_page: u32,
) -> Result<(), DomainError> {
let actor = actor_ap_url.to_string();
let collection = collection_url.to_string();
let conn_type = connection_type.to_string();
let connections_repo = self.connections_repo.clone();
tokio::spawn(async move {
let client = match reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(HTTP_FETCH_TIMEOUT_SECS))
.build()
{
Ok(c) => c,
Err(e) => {
tracing::warn!(error = %e, "connections fetch: failed to build client");
return;
}
};
let mut all_urls: Vec<String> = Vec::new();
let mut current_url: Option<String> = Some(collection.clone());
const MAX_ACTORS: usize = 500;
while let Some(url) = current_url.take() {
let val: serde_json::Value = match client
.get(&url)
.header("Accept", "application/activity+json, application/ld+json")
.send()
.await
{
Ok(r) => match r.json().await {
Ok(v) => v,
Err(e) => {
tracing::warn!(error = %e, url = %url, "connections: parse error");
break;
}
},
Err(e) => {
tracing::warn!(error = %e, url = %url, "connections: HTTP error");
break;
}
};
if val["type"].as_str() == Some("OrderedCollection") {
current_url = val["first"].as_str().map(|s| s.to_string());
continue;
}
let empty = vec![];
let items = val["orderedItems"].as_array().unwrap_or(&empty);
for item in items {
let actor_url = item.as_str().or_else(|| item["id"].as_str()).unwrap_or("");
if !actor_url.is_empty() {
all_urls.push(actor_url.to_string());
}
}
if all_urls.len() >= MAX_ACTORS {
break;
}
current_url = val["next"].as_str().map(|s| s.to_string());
if current_url.is_some() {
tokio::time::sleep(std::time::Duration::from_millis(BATCH_FETCH_SLEEP_MS))
.await;
}
}
if all_urls.is_empty() {
tracing::debug!(
actor = %actor,
connection_type = %conn_type,
"connections: empty collection"
);
return;
}
const PAGE_SIZE: usize = 20;
for (idx, chunk) in all_urls.chunks(PAGE_SIZE).enumerate() {
let page_num = (idx + 1) as u32;
let resolved = resolve_actor_profiles_from_urls(chunk.to_vec()).await;
if let Err(e) = connections_repo
.upsert_connections(&actor, &conn_type, page_num, &resolved)
.await
{
tracing::warn!(error = %e, "connections: upsert failed");
}
}
tracing::debug!(
actor = %actor,
connection_type = %conn_type,
count = all_urls.len(),
"connections fetch complete"
);
});
Ok(())
}
}
// ── FederationLookupPort ──────────────────────────────────────────────────────
#[async_trait]
impl FederationLookupPort for ApFederationAdapter {
async fn lookup_actor(&self, handle: &str) -> Result<DomainRemoteActor, DomainError> {
let actor = self
.inner
.lookup_actor_by_handle(handle)
.await
.map_err(|e| DomainError::ExternalService(e.to_string()))?;
Ok(DomainRemoteActor {
url: actor.ap_url.to_string(),
handle: actor.handle,
display_name: actor.display_name,
avatar_url: actor.avatar_url.as_ref().map(|u| u.to_string()),
outbox_url: actor.outbox_url.as_ref().map(|u| u.to_string()),
last_fetched_at: chrono::Utc::now(),
bio: actor.bio,
banner_url: actor.banner_url.as_ref().map(|u| u.to_string()),
also_known_as: actor
.also_known_as
.into_iter()
.map(|u| u.to_string())
.collect(),
followers_url: actor.followers_url.as_ref().map(|u| u.to_string()),
following_url: actor.following_url.as_ref().map(|u| u.to_string()),
inbox_url: None,
shared_inbox_url: None,
attachment: actor
.attachment
.into_iter()
.map(|f| (f.name, f.value))
.collect(),
})
}
async fn actor_json(&self, user_id: &UserId) -> Result<String, DomainError> {
self.inner
.actor_json(&user_id.as_uuid().to_string())
.await
.map_err(|e| DomainError::ExternalService(e.to_string()))
}
async fn followers_collection_json(
&self,
user_id: &UserId,
page: Option<u32>,
) -> Result<String, DomainError> {
self.inner
.followers_collection_json(user_id.as_uuid(), page)
.await
.map_err(|e| DomainError::ExternalService(e.to_string()))
}
async fn following_collection_json(
&self,
user_id: &UserId,
page: Option<u32>,
) -> Result<String, DomainError> {
self.inner
.following_collection_json(user_id.as_uuid(), page)
.await
.map_err(|e| DomainError::ExternalService(e.to_string()))
}
}
// ── FederationFetchPort ───────────────────────────────────────────────────────
#[async_trait]
impl FederationFetchPort for ApFederationAdapter {
async fn fetch_outbox_page(
&self,
outbox_url: &str,
page: u32,
) -> Result<Vec<domain::models::remote_note::RemoteNote>, DomainError> {
use chrono::DateTime;
let client = reqwest::Client::new();
let base: serde_json::Value = client
.get(outbox_url)
.header("Accept", "application/activity+json, application/ld+json")
.send()
.await
.map_err(|e| DomainError::ExternalService(e.to_string()))?
.json()
.await
.map_err(|e| DomainError::ExternalService(e.to_string()))?;
let first_url = base["first"]
.as_str()
.map(|s| s.to_string())
.unwrap_or_else(|| format!("{}?page=1", outbox_url));
let mut current_url = first_url;
let mut hops = 0u32;
let target_page = page.max(1);
let max_hops = 10u32;
let resp: serde_json::Value = loop {
let page_resp: serde_json::Value = client
.get(&current_url)
.header("Accept", "application/activity+json, application/ld+json")
.send()
.await
.map_err(|e| DomainError::ExternalService(e.to_string()))?
.json()
.await
.map_err(|e| DomainError::ExternalService(e.to_string()))?;
hops += 1;
if hops >= target_page || hops >= max_hops {
break page_resp;
}
match page_resp["next"].as_str() {
Some(next) => current_url = next.to_string(),
None => break page_resp,
}
};
let empty = vec![];
let items = resp["orderedItems"].as_array().unwrap_or(&empty);
let notes = items
.iter()
.filter_map(|item| {
let note = if item["type"].as_str() == Some("Create") {
&item["object"]
} else if item["type"].as_str() == Some("Note") {
item
} else {
return None;
};
let to = note["to"].as_array()?;
let is_public = to
.iter()
.any(|t| t.as_str() == Some("https://www.w3.org/ns/activitystreams#Public"));
if !is_public {
return None;
}
let published = DateTime::parse_from_rfc3339(note["published"].as_str()?)
.ok()?
.with_timezone(&chrono::Utc);
let text = note["content"].as_str().unwrap_or("").to_string();
let has_attachments = note["attachment"]
.as_array()
.map(|a| !a.is_empty())
.unwrap_or(false);
let content = if has_attachments {
let notice =
"<p class=\"media-notice\">📎 Media attachment — not supported</p>";
if text.is_empty() {
notice.to_string()
} else {
format!("{text}{notice}")
}
} else {
text
};
Some(domain::models::remote_note::RemoteNote {
ap_id: note["id"].as_str()?.to_string(),
content,
published,
sensitive: note["sensitive"].as_bool().unwrap_or(false),
content_warning: note["summary"].as_str().map(|s| s.to_string()),
})
})
.collect();
Ok(notes)
}
async fn fetch_actor_urls_from_collection(
&self,
collection_url: &str,
) -> Result<Vec<String>, DomainError> {
let client = reqwest::Client::new();
let base: serde_json::Value = client
.get(collection_url)
.header("Accept", "application/activity+json, application/ld+json")
.send()
.await
.map_err(|e| DomainError::ExternalService(e.to_string()))?
.json()
.await
.map_err(|e| DomainError::ExternalService(e.to_string()))?;
let page = if base["orderedItems"].is_null() {
if let Some(first_url) = base["first"].as_str() {
client
.get(first_url)
.header("Accept", "application/activity+json, application/ld+json")
.send()
.await
.map_err(|e| DomainError::ExternalService(e.to_string()))?
.json()
.await
.map_err(|e| DomainError::ExternalService(e.to_string()))?
} else {
base
}
} else {
base
};
let empty = vec![];
let items = page["orderedItems"].as_array().unwrap_or(&empty);
Ok(items
.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect())
}
async fn resolve_actor_profiles(
&self,
urls: Vec<String>,
) -> Vec<domain::models::actor_connection_summary::ActorConnectionSummary> {
resolve_actor_profiles_from_urls(urls).await
}
}
// ── FederationFollowPort ──────────────────────────────────────────────────────
#[async_trait]
impl FederationFollowPort for ApFederationAdapter {
async fn follow_remote(&self, local_user_id: &UserId, handle: &str) -> Result<(), DomainError> {
self.inner
.follow(local_user_id.as_uuid(), handle)
.await
.map_err(|e| DomainError::ExternalService(e.to_string()))
}
async fn unfollow_remote(
&self,
local_user_id: &UserId,
handle: &str,
) -> Result<(), DomainError> {
let actor_url = webfinger_resolve_actor_url(handle)
.await
.map_err(|e| DomainError::ExternalService(e.to_string()))?;
self.inner
.unfollow(local_user_id.as_uuid(), &actor_url)
.await
.map_err(|e| DomainError::ExternalService(e.to_string()))
}
async fn get_remote_following(
&self,
user_id: &UserId,
) -> Result<Vec<DomainRemoteActor>, DomainError> {
self.inner
.get_following(user_id.as_uuid())
.await
.map(|v| v.into_iter().map(k_ap_actor_to_domain).collect())
.map_err(|e| DomainError::ExternalService(e.to_string()))
}
async fn broadcast_move(
&self,
user_id: &UserId,
new_actor_url: url::Url,
) -> Result<(), DomainError> {
self.inner
.broadcast_move(user_id.as_uuid(), new_actor_url)
.await
.map_err(|e| DomainError::Internal(e.to_string()))
}
}
// ── FederationFollowRequestPort ───────────────────────────────────────────────
#[async_trait]
impl FederationFollowRequestPort for ApFederationAdapter {
async fn get_pending_followers(
&self,
user_id: &UserId,
) -> Result<Vec<DomainRemoteActor>, DomainError> {
self.inner
.get_pending_followers(user_id.as_uuid())
.await
.map(|v| v.into_iter().map(k_ap_actor_to_domain).collect())
.map_err(|e| DomainError::ExternalService(e.to_string()))
}
async fn accept_follow_request(
&self,
user_id: &UserId,
actor_url: &str,
) -> Result<(), DomainError> {
self.inner
.accept_follower(user_id.as_uuid(), actor_url)
.await
.map_err(|e| DomainError::ExternalService(e.to_string()))
}
async fn reject_follow_request(
&self,
user_id: &UserId,
actor_url: &str,
) -> Result<(), DomainError> {
self.inner
.reject_follower(user_id.as_uuid(), actor_url)
.await
.map_err(|e| DomainError::ExternalService(e.to_string()))
}
async fn get_remote_followers(
&self,
user_id: &UserId,
) -> Result<Vec<DomainRemoteActor>, DomainError> {
self.inner
.get_accepted_followers(user_id.as_uuid())
.await
.map(|v| v.into_iter().map(k_ap_actor_to_domain).collect())
.map_err(|e| DomainError::ExternalService(e.to_string()))
}
async fn remove_remote_follower(
&self,
user_id: &UserId,
actor_url: &str,
) -> Result<(), DomainError> {
self.inner
.remove_follower(user_id.as_uuid(), actor_url)
.await
.map_err(|e| DomainError::ExternalService(e.to_string()))
}
async fn mark_follower_accepted(
&self,
user_id: &UserId,
actor_url: &str,
) -> Result<(), DomainError> {
self.inner
.mark_follower_accepted(user_id.as_uuid(), actor_url)
.await
.map_err(|e| DomainError::Internal(e.to_string()))
}
async fn mark_follower_rejected(
&self,
user_id: &UserId,
actor_url: &str,
) -> Result<(), DomainError> {
self.inner
.mark_follower_rejected(user_id.as_uuid(), actor_url)
.await
.map_err(|e| DomainError::Internal(e.to_string()))
}
}
// ── FederationBlockPort ──────────────────────────────────────────────────────
#[async_trait]
impl domain::ports::FederationBlockPort for ApFederationAdapter {
async fn block_remote(&self, local_user_id: &UserId, handle: &str) -> Result<(), DomainError> {
let actor_url = webfinger_resolve_actor_url(handle)
.await
.map_err(|e| DomainError::ExternalService(e.to_string()))?;
self.inner
.block_actor(local_user_id.as_uuid(), &actor_url)
.await
.map_err(|e| DomainError::ExternalService(e.to_string()))
}
async fn unblock_remote(
&self,
local_user_id: &UserId,
handle: &str,
) -> Result<(), DomainError> {
let actor_url = webfinger_resolve_actor_url(handle)
.await
.map_err(|e| DomainError::ExternalService(e.to_string()))?;
self.inner
.unblock_actor(local_user_id.as_uuid(), &actor_url)
.await
.map_err(|e| DomainError::ExternalService(e.to_string()))
}
}
// FederationActionPort is a blanket supertrait; no explicit impl needed.

View File

@@ -0,0 +1,57 @@
use url::Url;
pub struct ThoughtsUrls {
pub base_url: String,
}
impl ThoughtsUrls {
pub fn new(base_url: &str) -> Self {
Self {
base_url: base_url.trim_end_matches('/').to_string(),
}
}
pub fn user_url(&self, username: &str) -> Url {
Url::parse(&format!("{}/users/{}", self.base_url, username)).expect("valid URL")
}
pub fn thought_url(&self, thought_id: uuid::Uuid) -> Url {
Url::parse(&format!("{}/thoughts/{}", self.base_url, thought_id)).expect("valid URL")
}
pub fn user_inbox(&self, username: &str) -> Url {
Url::parse(&format!("{}/users/{}/inbox", self.base_url, username)).expect("valid URL")
}
pub fn user_outbox(&self, username: &str) -> Url {
Url::parse(&format!("{}/users/{}/outbox", self.base_url, username)).expect("valid URL")
}
pub fn user_followers(&self, username: &str) -> Url {
Url::parse(&format!("{}/users/{}/followers", self.base_url, username)).expect("valid URL")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn user_url_format() {
let urls = ThoughtsUrls::new("https://example.com");
assert_eq!(
urls.user_url("alice").as_str(),
"https://example.com/users/alice"
);
}
#[test]
fn thought_url_format() {
let urls = ThoughtsUrls::new("https://example.com");
let id = uuid::Uuid::nil();
assert!(urls
.thought_url(id)
.as_str()
.starts_with("https://example.com/thoughts/"));
}
}

View File

@@ -1,36 +0,0 @@
use url::Url;
pub struct ThoughtsUrls {
pub base_url: String,
}
impl ThoughtsUrls {
pub fn new(base_url: &str) -> Self {
Self {
base_url: base_url.trim_end_matches('/').to_string(),
}
}
pub fn user_url(&self, id: &str) -> Url {
Url::parse(&format!("{}/users/{}", self.base_url, id)).expect("valid URL")
}
pub fn thought_url(&self, thought_id: uuid::Uuid) -> Url {
Url::parse(&format!("{}/thoughts/{}", self.base_url, thought_id)).expect("valid URL")
}
pub fn user_inbox(&self, id: &str) -> Url {
Url::parse(&format!("{}/users/{}/inbox", self.base_url, id)).expect("valid URL")
}
pub fn user_outbox(&self, id: &str) -> Url {
Url::parse(&format!("{}/users/{}/outbox", self.base_url, id)).expect("valid URL")
}
pub fn user_followers(&self, id: &str) -> Url {
Url::parse(&format!("{}/users/{}/followers", self.base_url, id)).expect("valid URL")
}
}
#[cfg(test)]
mod tests;

View File

@@ -1,20 +0,0 @@
use super::*;
#[test]
fn user_url_format() {
let urls = ThoughtsUrls::new("https://example.com");
assert_eq!(
urls.user_url("alice").as_str(),
"https://example.com/users/alice"
);
}
#[test]
fn thought_url_format() {
let urls = ThoughtsUrls::new("https://example.com");
let id = uuid::Uuid::nil();
assert!(urls
.thought_url(id)
.as_str()
.starts_with("https://example.com/thoughts/"));
}

View File

@@ -13,7 +13,4 @@ tokio = { workspace = true }
serde = { workspace = true } serde = { workspace = true }
jsonwebtoken = "9" jsonwebtoken = "9"
argon2 = "0.5" argon2 = "0.5"
bcrypt = "0.15"
rand = "0.8" rand = "0.8"
sha2 = "0.10"
hex = "0.4"

View File

@@ -1,33 +0,0 @@
use async_trait::async_trait;
use domain::{
errors::DomainError,
ports::{ApiKeyRepository, ApiKeyService},
value_objects::UserId,
};
use sha2::{Digest, Sha256};
use std::sync::Arc;
pub struct ApiKeyServiceImpl {
repo: Arc<dyn ApiKeyRepository>,
}
impl ApiKeyServiceImpl {
pub fn new(repo: Arc<dyn ApiKeyRepository>) -> Self {
Self { repo }
}
fn hash(raw: &str) -> String {
hex::encode(Sha256::digest(raw.as_bytes()))
}
}
#[async_trait]
impl ApiKeyService for ApiKeyServiceImpl {
async fn validate_key(&self, raw_key: &str) -> Result<Option<UserId>, DomainError> {
let hash = Self::hash(raw_key);
Ok(self.repo.find_by_hash(&hash).await?.map(|k| k.user_id))
}
}
#[cfg(test)]
mod tests;

View File

@@ -1,61 +0,0 @@
use super::*;
use async_trait::async_trait;
use chrono::Utc;
use domain::{
errors::DomainError,
models::api_key::ApiKey,
ports::ApiKeyRepository,
value_objects::{ApiKeyId, UserId},
};
use std::sync::{Arc, Mutex};
struct FakeApiKeyRepo(Mutex<Vec<ApiKey>>);
#[async_trait]
impl ApiKeyRepository for FakeApiKeyRepo {
async fn save(&self, key: &ApiKey) -> Result<(), DomainError> {
self.0.lock().unwrap().push(key.clone());
Ok(())
}
async fn find_by_hash(&self, hash: &str) -> Result<Option<ApiKey>, DomainError> {
Ok(self
.0
.lock()
.unwrap()
.iter()
.find(|k| k.key_hash == hash)
.cloned())
}
async fn list_for_user(&self, _uid: &UserId) -> Result<Vec<ApiKey>, DomainError> {
Ok(vec![])
}
async fn delete(&self, _id: &ApiKeyId, _uid: &UserId) -> Result<(), DomainError> {
Ok(())
}
}
#[tokio::test]
async fn validate_known_key_returns_user_id() {
let uid = UserId::new();
let raw = "super-secret-key";
let hash = ApiKeyServiceImpl::hash(raw);
let key = ApiKey {
id: ApiKeyId::new(),
user_id: uid.clone(),
key_hash: hash,
name: "test".into(),
created_at: Utc::now(),
};
let repo = Arc::new(FakeApiKeyRepo(Mutex::new(vec![key])));
let svc = ApiKeyServiceImpl::new(repo);
let result = svc.validate_key(raw).await.unwrap();
assert_eq!(result.unwrap().as_uuid(), uid.as_uuid());
}
#[tokio::test]
async fn validate_unknown_key_returns_none() {
let repo = Arc::new(FakeApiKeyRepo(Mutex::new(vec![])));
let svc = ApiKeyServiceImpl::new(repo);
let result = svc.validate_key("unknown-key").await.unwrap();
assert!(result.is_none());
}

View File

@@ -1,5 +1,3 @@
mod api_key_service;
use async_trait::async_trait; use async_trait::async_trait;
use chrono::{Duration, Utc}; use chrono::{Duration, Utc};
use domain::{ use domain::{
@@ -10,8 +8,6 @@ use domain::{
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation}; use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
pub use api_key_service::ApiKeyServiceImpl;
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize)]
struct Claims { struct Claims {
sub: String, sub: String,
@@ -80,10 +76,6 @@ impl PasswordHasher for Argon2PasswordHasher {
} }
async fn verify(&self, plain: &str, hash: &PasswordHash) -> Result<bool, DomainError> { async fn verify(&self, plain: &str, hash: &PasswordHash) -> Result<bool, DomainError> {
if hash.0.starts_with("$2") {
return bcrypt::verify(plain, &hash.0)
.map_err(|e| DomainError::Internal(e.to_string()));
}
use argon2::{password_hash::PasswordHash as ArgonHash, Argon2, PasswordVerifier}; use argon2::{password_hash::PasswordHash as ArgonHash, Argon2, PasswordVerifier};
let parsed = ArgonHash::new(&hash.0).map_err(|e| DomainError::Internal(e.to_string()))?; let parsed = ArgonHash::new(&hash.0).map_err(|e| DomainError::Internal(e.to_string()))?;
Ok(Argon2::default() Ok(Argon2::default()
@@ -93,4 +85,31 @@ impl PasswordHasher for Argon2PasswordHasher {
} }
#[cfg(test)] #[cfg(test)]
mod tests; mod tests {
use super::*;
use domain::ports::AuthService;
#[test]
fn generate_and_validate_token() {
let svc = JwtAuthService::new("secret".into(), 3600);
let id = UserId::new();
let tok = svc.generate_token(&id).unwrap();
let parsed = svc.validate_token(&tok.token).unwrap();
assert_eq!(parsed.as_uuid(), id.as_uuid());
}
#[test]
fn invalid_token_returns_unauthorized() {
let svc = JwtAuthService::new("secret".into(), 3600);
let err = svc.validate_token("not.a.token").unwrap_err();
assert!(matches!(err, DomainError::Unauthorized));
}
#[tokio::test]
async fn hash_and_verify() {
let hasher = Argon2PasswordHasher;
let hash = hasher.hash("mypassword").await.unwrap();
assert!(hasher.verify("mypassword", &hash).await.unwrap());
assert!(!hasher.verify("wrongpassword", &hash).await.unwrap());
}
}

View File

@@ -1,26 +0,0 @@
use super::*;
use domain::ports::AuthService;
#[test]
fn generate_and_validate_token() {
let svc = JwtAuthService::new("a-secret-that-is-at-least-32-bytes!!".into(), 3600);
let id = UserId::new();
let tok = svc.generate_token(&id).unwrap();
let parsed = svc.validate_token(&tok.token).unwrap();
assert_eq!(parsed.as_uuid(), id.as_uuid());
}
#[test]
fn invalid_token_returns_unauthorized() {
let svc = JwtAuthService::new("a-secret-that-is-at-least-32-bytes!!".into(), 3600);
let err = svc.validate_token("not.a.token").unwrap_err();
assert!(matches!(err, DomainError::Unauthorized));
}
#[tokio::test]
async fn hash_and_verify() {
let hasher = Argon2PasswordHasher;
let hash = hasher.hash("mypassword").await.unwrap();
assert!(hasher.verify("mypassword", &hash).await.unwrap());
assert!(!hasher.verify("wrongpassword", &hash).await.unwrap());
}

View File

@@ -68,34 +68,9 @@ pub enum EventPayload {
UserRegistered { UserRegistered {
user_id: String, user_id: String,
}, },
ProfileUpdated { FetchRemoteActorPosts {
user_id: String, actor_ap_url: String,
}, outbox_url: String,
RemoteFollowAccepted {
local_user_id: String,
remote_actor_url: String,
},
RemoteFollowRejected {
local_user_id: String,
remote_actor_url: String,
},
ActorMoved {
user_id: String,
new_actor_url: String,
},
MentionReceived {
thought_id: String,
mentioned_user_id: String,
author_user_id: String,
},
FederationDeliveryRequested {
inbox: String,
activity: serde_json::Value,
signing_actor_id: String,
},
FederationBackfillRequested {
owner_user_id: String,
follower_inbox_url: String,
}, },
} }
@@ -117,13 +92,7 @@ impl EventPayload {
Self::UserBlocked { .. } => "users.blocked", Self::UserBlocked { .. } => "users.blocked",
Self::UserUnblocked { .. } => "users.unblocked", Self::UserUnblocked { .. } => "users.unblocked",
Self::UserRegistered { .. } => "users.registered", Self::UserRegistered { .. } => "users.registered",
Self::ProfileUpdated { .. } => "users.profile_updated", Self::FetchRemoteActorPosts { .. } => "federation.fetch_outbox",
Self::RemoteFollowAccepted { .. } => "federation.follow.accepted",
Self::RemoteFollowRejected { .. } => "federation.follow.rejected",
Self::ActorMoved { .. } => "federation.actor.moved",
Self::MentionReceived { .. } => "mentions.received",
Self::FederationDeliveryRequested { .. } => "federation.delivery.requested",
Self::FederationBackfillRequested { .. } => "federation.backfill.requested",
} }
} }
} }
@@ -233,38 +202,12 @@ impl From<&DomainEvent> for EventPayload {
DomainEvent::UserRegistered { user_id } => Self::UserRegistered { DomainEvent::UserRegistered { user_id } => Self::UserRegistered {
user_id: user_id.to_string(), user_id: user_id.to_string(),
}, },
DomainEvent::ProfileUpdated { user_id } => Self::ProfileUpdated { DomainEvent::FetchRemoteActorPosts {
user_id: user_id.to_string(), actor_ap_url,
}, outbox_url,
DomainEvent::RemoteFollowAccepted { } => Self::FetchRemoteActorPosts {
local_user_id, actor_ap_url: actor_ap_url.clone(),
remote_actor_url, outbox_url: outbox_url.clone(),
} => Self::RemoteFollowAccepted {
local_user_id: local_user_id.to_string(),
remote_actor_url: remote_actor_url.clone(),
},
DomainEvent::RemoteFollowRejected {
local_user_id,
remote_actor_url,
} => Self::RemoteFollowRejected {
local_user_id: local_user_id.to_string(),
remote_actor_url: remote_actor_url.clone(),
},
DomainEvent::ActorMoved {
user_id,
new_actor_url,
} => Self::ActorMoved {
user_id: user_id.to_string(),
new_actor_url: new_actor_url.clone(),
},
DomainEvent::MentionReceived {
thought_id,
mentioned_user_id,
author_user_id,
} => Self::MentionReceived {
thought_id: thought_id.to_string(),
mentioned_user_id: mentioned_user_id.to_string(),
author_user_id: author_user_id.to_string(),
}, },
} }
} }
@@ -384,51 +327,106 @@ impl TryFrom<EventPayload> for DomainEvent {
EventPayload::UserRegistered { user_id } => DomainEvent::UserRegistered { EventPayload::UserRegistered { user_id } => DomainEvent::UserRegistered {
user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?), user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?),
}, },
EventPayload::ProfileUpdated { user_id } => DomainEvent::ProfileUpdated { EventPayload::FetchRemoteActorPosts {
user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?), actor_ap_url,
outbox_url,
} => DomainEvent::FetchRemoteActorPosts {
actor_ap_url,
outbox_url,
}, },
EventPayload::RemoteFollowAccepted {
local_user_id,
remote_actor_url,
} => DomainEvent::RemoteFollowAccepted {
local_user_id: UserId::from_uuid(parse_uuid(&local_user_id, "local_user_id")?),
remote_actor_url,
},
EventPayload::RemoteFollowRejected {
local_user_id,
remote_actor_url,
} => DomainEvent::RemoteFollowRejected {
local_user_id: UserId::from_uuid(parse_uuid(&local_user_id, "local_user_id")?),
remote_actor_url,
},
EventPayload::ActorMoved {
user_id,
new_actor_url,
} => DomainEvent::ActorMoved {
user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?),
new_actor_url,
},
EventPayload::MentionReceived {
thought_id,
mentioned_user_id,
author_user_id,
} => DomainEvent::MentionReceived {
thought_id: ThoughtId::from_uuid(parse_uuid(&thought_id, "thought_id")?),
mentioned_user_id: UserId::from_uuid(parse_uuid(
&mentioned_user_id,
"mentioned_user_id",
)?),
author_user_id: UserId::from_uuid(parse_uuid(&author_user_id, "author_user_id")?),
},
EventPayload::FederationDeliveryRequested { .. }
| EventPayload::FederationBackfillRequested { .. } => {
return Err(DomainError::Internal(
"federation infrastructure event — not a domain event".into(),
));
}
}) })
} }
} }
#[cfg(test)] #[cfg(test)]
mod tests; mod tests {
use super::*;
#[test]
fn thought_created_roundtrip() {
let p = EventPayload::ThoughtCreated {
thought_id: "abc".into(),
user_id: "def".into(),
in_reply_to_id: None,
};
let json = serde_json::to_string(&p).unwrap();
let back: EventPayload = serde_json::from_str(&json).unwrap();
assert_eq!(back.subject(), "thoughts.created");
}
#[test]
fn all_subjects_are_unique() {
let samples: &[EventPayload] = &[
EventPayload::ThoughtCreated {
thought_id: "a".into(),
user_id: "b".into(),
in_reply_to_id: None,
},
EventPayload::ThoughtDeleted {
thought_id: "a".into(),
user_id: "b".into(),
},
EventPayload::ThoughtUpdated {
thought_id: "a".into(),
user_id: "b".into(),
},
EventPayload::LikeAdded {
like_id: "a".into(),
user_id: "b".into(),
thought_id: "c".into(),
},
EventPayload::LikeRemoved {
user_id: "b".into(),
thought_id: "c".into(),
},
EventPayload::BoostAdded {
boost_id: "a".into(),
user_id: "b".into(),
thought_id: "c".into(),
},
EventPayload::BoostRemoved {
user_id: "b".into(),
thought_id: "c".into(),
},
EventPayload::FollowRequested {
follower_id: "a".into(),
following_id: "b".into(),
},
EventPayload::FollowAccepted {
follower_id: "a".into(),
following_id: "b".into(),
},
EventPayload::FollowRejected {
follower_id: "a".into(),
following_id: "b".into(),
},
EventPayload::Unfollowed {
follower_id: "a".into(),
following_id: "b".into(),
},
EventPayload::UserBlocked {
blocker_id: "a".into(),
blocked_id: "b".into(),
},
EventPayload::UserUnblocked {
blocker_id: "a".into(),
blocked_id: "b".into(),
},
EventPayload::UserRegistered {
user_id: "a".into(),
},
EventPayload::FetchRemoteActorPosts {
actor_ap_url: "https://mastodon.social/users/alice".into(),
outbox_url: "https://mastodon.social/users/alice/outbox".into(),
},
];
let mut subjects: Vec<&str> = samples.iter().map(|p| p.subject()).collect();
subjects.sort();
subjects.dedup();
assert_eq!(
subjects.len(),
samples.len(),
"each event must have a unique subject"
);
}
}

View File

@@ -1,85 +0,0 @@
use super::*;
#[test]
fn thought_created_roundtrip() {
let p = EventPayload::ThoughtCreated {
thought_id: "abc".into(),
user_id: "def".into(),
in_reply_to_id: None,
};
let json = serde_json::to_string(&p).unwrap();
let back: EventPayload = serde_json::from_str(&json).unwrap();
assert_eq!(back.subject(), "thoughts.created");
}
#[test]
fn all_subjects_are_unique() {
let samples: &[EventPayload] = &[
EventPayload::ThoughtCreated {
thought_id: "a".into(),
user_id: "b".into(),
in_reply_to_id: None,
},
EventPayload::ThoughtDeleted {
thought_id: "a".into(),
user_id: "b".into(),
},
EventPayload::ThoughtUpdated {
thought_id: "a".into(),
user_id: "b".into(),
},
EventPayload::LikeAdded {
like_id: "a".into(),
user_id: "b".into(),
thought_id: "c".into(),
},
EventPayload::LikeRemoved {
user_id: "b".into(),
thought_id: "c".into(),
},
EventPayload::BoostAdded {
boost_id: "a".into(),
user_id: "b".into(),
thought_id: "c".into(),
},
EventPayload::BoostRemoved {
user_id: "b".into(),
thought_id: "c".into(),
},
EventPayload::FollowRequested {
follower_id: "a".into(),
following_id: "b".into(),
},
EventPayload::FollowAccepted {
follower_id: "a".into(),
following_id: "b".into(),
},
EventPayload::FollowRejected {
follower_id: "a".into(),
following_id: "b".into(),
},
EventPayload::Unfollowed {
follower_id: "a".into(),
following_id: "b".into(),
},
EventPayload::UserBlocked {
blocker_id: "a".into(),
blocked_id: "b".into(),
},
EventPayload::UserUnblocked {
blocker_id: "a".into(),
blocked_id: "b".into(),
},
EventPayload::UserRegistered {
user_id: "a".into(),
},
];
let mut subjects: Vec<&str> = samples.iter().map(|p| p.subject()).collect();
subjects.sort();
subjects.dedup();
assert_eq!(
subjects.len(),
samples.len(),
"each event must have a unique subject"
);
}

View File

@@ -48,7 +48,6 @@ impl<T: Transport> EventPublisher for EventPublisherAdapter<T> {
pub struct RawMessage { pub struct RawMessage {
pub subject: String, pub subject: String,
pub payload: Vec<u8>, pub payload: Vec<u8>,
pub delivery_count: u64,
pub ack: Box<dyn Fn() + Send + Sync>, pub ack: Box<dyn Fn() + Send + Sync>,
pub nack: Box<dyn Fn() + Send + Sync>, pub nack: Box<dyn Fn() + Send + Sync>,
} }
@@ -84,22 +83,19 @@ impl<S: MessageSource> EventConsumer for EventConsumerAdapter<S> {
let payload = match serde_json::from_slice::<EventPayload>(&msg.payload) { let payload = match serde_json::from_slice::<EventPayload>(&msg.payload) {
Ok(p) => p, Ok(p) => p,
Err(e) => { Err(e) => {
tracing::warn!("failed to deserialize event payload — acking to prevent orphan: {e}"); tracing::warn!("failed to deserialize event payload: {e}");
(msg.ack)();
return None; return None;
} }
}; };
let event = match DomainEvent::try_from(payload) { let event = match DomainEvent::try_from(payload) {
Ok(e) => e, Ok(e) => e,
Err(e) => { Err(e) => {
tracing::warn!("unknown or malformed event type — acking to prevent orphan: {e}"); tracing::warn!("unknown event type: {e}");
(msg.ack)();
return None; return None;
} }
}; };
Some(Ok(EventEnvelope { Some(Ok(EventEnvelope {
event, event,
delivery_count: msg.delivery_count,
ack: msg.ack, ack: msg.ack,
nack: msg.nack, nack: msg.nack,
})) }))
@@ -110,4 +106,125 @@ impl<S: MessageSource> EventConsumer for EventConsumerAdapter<S> {
} }
#[cfg(test)] #[cfg(test)]
mod tests; mod tests {
use super::*;
use async_trait::async_trait;
use domain::value_objects::{ThoughtId, UserId};
use std::sync::{Arc, Mutex};
struct SpyTransport {
calls: Arc<Mutex<Vec<(String, Vec<u8>)>>>,
}
impl SpyTransport {
fn new() -> (Self, Arc<Mutex<Vec<(String, Vec<u8>)>>>) {
let calls = Arc::new(Mutex::new(vec![]));
(
Self {
calls: calls.clone(),
},
calls,
)
}
}
#[async_trait]
impl Transport for SpyTransport {
async fn publish_bytes(&self, subject: &str, bytes: &[u8]) -> Result<(), DomainError> {
self.calls
.lock()
.unwrap()
.push((subject.to_string(), bytes.to_vec()));
Ok(())
}
}
#[tokio::test]
async fn thought_created_routes_to_correct_subject() {
let (spy, calls) = SpyTransport::new();
let publisher = EventPublisherAdapter::new(spy);
publisher
.publish(&DomainEvent::ThoughtCreated {
thought_id: ThoughtId::new(),
user_id: UserId::new(),
in_reply_to_id: None,
})
.await
.unwrap();
let calls = calls.lock().unwrap();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].0, "thoughts.created");
}
#[tokio::test]
async fn serialized_payload_is_valid_json() {
let (spy, calls) = SpyTransport::new();
let publisher = EventPublisherAdapter::new(spy);
publisher
.publish(&DomainEvent::UserBlocked {
blocker_id: UserId::new(),
blocked_id: UserId::new(),
})
.await
.unwrap();
let bytes = calls.lock().unwrap()[0].1.clone();
let json: serde_json::Value = serde_json::from_slice(&bytes).expect("valid JSON");
assert_eq!(json["type"], "UserBlocked");
}
#[tokio::test]
async fn consumer_adapter_deserializes_and_yields_event() {
use domain::value_objects::ThoughtId;
use futures::StreamExt;
let event = DomainEvent::ThoughtCreated {
thought_id: ThoughtId::new(),
user_id: UserId::new(),
in_reply_to_id: None,
};
let payload = EventPayload::from(&event);
let bytes = serde_json::to_vec(&payload).unwrap();
struct OneMessageSource {
bytes: Vec<u8>,
}
#[async_trait::async_trait]
impl MessageSource for OneMessageSource {
fn messages(&self) -> futures::stream::BoxStream<'_, Result<RawMessage, DomainError>> {
let msg = RawMessage {
subject: "thoughts.created".to_string(),
payload: self.bytes.clone(),
ack: Box::new(|| {}),
nack: Box::new(|| {}),
};
Box::pin(futures::stream::once(async { Ok(msg) }))
}
}
let adapter = EventConsumerAdapter::new(OneMessageSource { bytes });
let mut stream = adapter.consume();
let envelope = stream.next().await.unwrap().unwrap();
assert!(matches!(envelope.event, DomainEvent::ThoughtCreated { .. }));
}
#[tokio::test]
async fn consumer_adapter_skips_invalid_payloads() {
use futures::StreamExt;
struct BadMessageSource;
#[async_trait::async_trait]
impl MessageSource for BadMessageSource {
fn messages(&self) -> futures::stream::BoxStream<'_, Result<RawMessage, DomainError>> {
let msg = RawMessage {
subject: "bad".to_string(),
payload: b"not valid json".to_vec(),
ack: Box::new(|| {}),
nack: Box::new(|| {}),
};
Box::pin(futures::stream::once(async { Ok(msg) }))
}
}
let adapter = EventConsumerAdapter::new(BadMessageSource);
let mut stream = adapter.consume();
assert!(stream.next().await.is_none());
}
}

View File

@@ -1,124 +0,0 @@
use super::*;
use async_trait::async_trait;
use domain::value_objects::{ThoughtId, UserId};
use std::sync::{Arc, Mutex};
type CallLog = Arc<Mutex<Vec<(String, Vec<u8>)>>>;
struct SpyTransport {
calls: CallLog,
}
impl SpyTransport {
fn new() -> (Self, CallLog) {
let calls = Arc::new(Mutex::new(vec![]));
(
Self {
calls: calls.clone(),
},
calls,
)
}
}
#[async_trait]
impl Transport for SpyTransport {
async fn publish_bytes(&self, subject: &str, bytes: &[u8]) -> Result<(), DomainError> {
self.calls
.lock()
.unwrap()
.push((subject.to_string(), bytes.to_vec()));
Ok(())
}
}
#[tokio::test]
async fn thought_created_routes_to_correct_subject() {
let (spy, calls) = SpyTransport::new();
let publisher = EventPublisherAdapter::new(spy);
publisher
.publish(&DomainEvent::ThoughtCreated {
thought_id: ThoughtId::new(),
user_id: UserId::new(),
in_reply_to_id: None,
})
.await
.unwrap();
let calls = calls.lock().unwrap();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].0, "thoughts.created");
}
#[tokio::test]
async fn serialized_payload_is_valid_json() {
let (spy, calls) = SpyTransport::new();
let publisher = EventPublisherAdapter::new(spy);
publisher
.publish(&DomainEvent::UserBlocked {
blocker_id: UserId::new(),
blocked_id: UserId::new(),
})
.await
.unwrap();
let bytes = calls.lock().unwrap()[0].1.clone();
let json: serde_json::Value = serde_json::from_slice(&bytes).expect("valid JSON");
assert_eq!(json["type"], "UserBlocked");
}
#[tokio::test]
async fn consumer_adapter_deserializes_and_yields_event() {
use domain::value_objects::ThoughtId;
use futures::StreamExt;
let event = DomainEvent::ThoughtCreated {
thought_id: ThoughtId::new(),
user_id: UserId::new(),
in_reply_to_id: None,
};
let payload = EventPayload::from(&event);
let bytes = serde_json::to_vec(&payload).unwrap();
struct OneMessageSource {
bytes: Vec<u8>,
}
#[async_trait::async_trait]
impl MessageSource for OneMessageSource {
fn messages(&self) -> futures::stream::BoxStream<'_, Result<RawMessage, DomainError>> {
let msg = RawMessage {
subject: "thoughts.created".to_string(),
payload: self.bytes.clone(),
delivery_count: 1,
ack: Box::new(|| {}),
nack: Box::new(|| {}),
};
Box::pin(futures::stream::once(async { Ok(msg) }))
}
}
let adapter = EventConsumerAdapter::new(OneMessageSource { bytes });
let mut stream = adapter.consume();
let envelope = stream.next().await.unwrap().unwrap();
assert!(matches!(envelope.event, DomainEvent::ThoughtCreated { .. }));
}
#[tokio::test]
async fn consumer_adapter_skips_invalid_payloads() {
use futures::StreamExt;
struct BadMessageSource;
#[async_trait::async_trait]
impl MessageSource for BadMessageSource {
fn messages(&self) -> futures::stream::BoxStream<'_, Result<RawMessage, DomainError>> {
let msg = RawMessage {
subject: "bad".to_string(),
payload: b"not valid json".to_vec(),
delivery_count: 1,
ack: Box::new(|| {}),
nack: Box::new(|| {}),
};
Box::pin(futures::stream::once(async { Ok(msg) }))
}
}
let adapter = EventConsumerAdapter::new(BadMessageSource);
let mut stream = adapter.consume();
assert!(stream.next().await.is_none());
}

View File

@@ -10,13 +10,6 @@ const STREAM_SUBJECT: &str = "thoughts-events.>";
const CONSUMER_NAME: &str = "worker"; const CONSUMER_NAME: &str = "worker";
const MAX_MESSAGES: i64 = 100_000; const MAX_MESSAGES: i64 = 100_000;
/// Maximum NATS delivery attempts before a message is considered exhausted.
pub const CONSUMER_MAX_DELIVER: i64 = 5;
/// How long NATS waits for an ack before redelivering.
const CONSUMER_ACK_WAIT_SECS: u64 = 30;
/// Timeout for spawned ack/nack async tasks.
const ACK_TASK_TIMEOUT_SECS: u64 = 5;
fn stream_config() -> StreamConfig { fn stream_config() -> StreamConfig {
StreamConfig { StreamConfig {
name: STREAM_NAME.to_string(), name: STREAM_NAME.to_string(),
@@ -128,10 +121,6 @@ impl MessageSource for NatsMessageSource {
CONSUMER_NAME, CONSUMER_NAME,
jetstream::consumer::pull::Config { jetstream::consumer::pull::Config {
durable_name: Some(CONSUMER_NAME.to_string()), durable_name: Some(CONSUMER_NAME.to_string()),
deliver_policy: jetstream::consumer::DeliverPolicy::New,
ack_policy: jetstream::consumer::AckPolicy::Explicit,
ack_wait: std::time::Duration::from_secs(CONSUMER_ACK_WAIT_SECS),
max_deliver: CONSUMER_MAX_DELIVER,
// No filter_subject — consume everything from the stream. // No filter_subject — consume everything from the stream.
// filter_subject matching the stream's own wildcard can be // filter_subject matching the stream's own wildcard can be
// inconsistent across NATS server versions. // inconsistent across NATS server versions.
@@ -175,48 +164,25 @@ impl MessageSource for NatsMessageSource {
let subject = msg.subject.to_string(); let subject = msg.subject.to_string();
let payload = msg.payload.to_vec(); let payload = msg.payload.to_vec();
let delivery_count = msg
.info()
.map(|info| info.delivered.max(0) as u64)
.unwrap_or(1);
let msg = Arc::new(msg); let msg = Arc::new(msg);
let msg_nack = Arc::clone(&msg); let msg_nack = Arc::clone(&msg);
let raw = RawMessage { let raw = RawMessage {
subject, subject,
payload, payload,
delivery_count,
ack: Box::new(move || { ack: Box::new(move || {
let m = Arc::clone(&msg); let m = Arc::clone(&msg);
tokio::spawn(async move { tokio::spawn(async move {
let result = tokio::time::timeout( if let Err(e) = m.ack().await {
std::time::Duration::from_secs(ACK_TASK_TIMEOUT_SECS), tracing::warn!("NATS ack failed: {e}");
m.ack(),
)
.await;
match result {
Ok(Ok(())) => {}
Ok(Err(e)) => tracing::warn!("NATS ack failed: {e}"),
Err(_) => tracing::warn!(
"NATS ack timed out after {ACK_TASK_TIMEOUT_SECS}s"
),
} }
}); });
}), }),
nack: Box::new(move || { nack: Box::new(move || {
let m = Arc::clone(&msg_nack); let m = Arc::clone(&msg_nack);
tokio::spawn(async move { tokio::spawn(async move {
let result = tokio::time::timeout( if let Err(e) = m.ack_with(AckKind::Nak(None)).await {
std::time::Duration::from_secs(ACK_TASK_TIMEOUT_SECS), tracing::warn!("NATS nak failed: {e}");
m.ack_with(AckKind::Nak(None)),
)
.await;
match result {
Ok(Ok(())) => {}
Ok(Err(e)) => tracing::warn!("NATS nack failed: {e}"),
Err(_) => tracing::warn!(
"NATS nack timed out after {ACK_TASK_TIMEOUT_SECS}s"
),
} }
}); });
}), }),
@@ -240,4 +206,46 @@ impl MessageSource for NatsMessageSource {
} }
#[cfg(test)] #[cfg(test)]
mod tests; mod tests {
use super::*;
use domain::{
events::DomainEvent,
value_objects::{LikeId, ThoughtId, UserId},
};
use event_payload::EventPayload;
#[test]
fn payload_from_domain_event_has_correct_subject() {
let event = DomainEvent::ThoughtCreated {
thought_id: ThoughtId::new(),
user_id: UserId::new(),
in_reply_to_id: None,
};
let payload = EventPayload::from(&event);
assert_eq!(payload.subject(), "thoughts.created");
}
#[test]
fn domain_event_roundtrip_via_payload() {
let uid = UserId::new();
let tid = ThoughtId::new();
let event = DomainEvent::LikeAdded {
like_id: LikeId::new(),
user_id: uid.clone(),
thought_id: tid.clone(),
};
let payload = EventPayload::from(&event);
let back = DomainEvent::try_from(payload).unwrap();
if let DomainEvent::LikeAdded {
user_id,
thought_id,
..
} = back
{
assert_eq!(user_id, uid);
assert_eq!(thought_id, tid);
} else {
panic!("wrong variant");
}
}
}

View File

@@ -1,40 +0,0 @@
use domain::{
events::DomainEvent,
value_objects::{LikeId, ThoughtId, UserId},
};
use event_payload::EventPayload;
#[test]
fn payload_from_domain_event_has_correct_subject() {
let event = DomainEvent::ThoughtCreated {
thought_id: ThoughtId::new(),
user_id: UserId::new(),
in_reply_to_id: None,
};
let payload = EventPayload::from(&event);
assert_eq!(payload.subject(), "thoughts.created");
}
#[test]
fn domain_event_roundtrip_via_payload() {
let uid = UserId::new();
let tid = ThoughtId::new();
let event = DomainEvent::LikeAdded {
like_id: LikeId::new(),
user_id: uid.clone(),
thought_id: tid.clone(),
};
let payload = EventPayload::from(&event);
let back = DomainEvent::try_from(payload).unwrap();
if let DomainEvent::LikeAdded {
user_id,
thought_id,
..
} = back
{
assert_eq!(user_id, uid);
assert_eq!(thought_id, tid);
} else {
panic!("wrong variant");
}
}

View File

@@ -4,7 +4,7 @@ version = "0.1.0"
edition = "2021" edition = "2021"
[dependencies] [dependencies]
k-ap = { version = "0.4.0", registry = "gitea" } activitypub-base = { workspace = true }
sqlx = { workspace = true } sqlx = { workspace = true }
uuid = { workspace = true } uuid = { workspace = true }
chrono = { workspace = true } chrono = { workspace = true }
@@ -12,7 +12,6 @@ tracing = { workspace = true }
async-trait = { workspace = true } async-trait = { workspace = true }
anyhow = { workspace = true } anyhow = { workspace = true }
url = { workspace = true } url = { workspace = true }
serde_json = { workspace = true }
[dev-dependencies] [dev-dependencies]
tokio = { workspace = true, features = ["full"] } tokio = { workspace = true, features = ["full"] }

View File

@@ -1 +0,0 @@
../postgres/migrations

File diff suppressed because it is too large Load Diff

View File

@@ -5,12 +5,10 @@ edition = "2021"
[dependencies] [dependencies]
domain = { workspace = true } domain = { workspace = true }
postgres = { workspace = true }
sqlx = { workspace = true } sqlx = { workspace = true }
uuid = { workspace = true } uuid = { workspace = true }
chrono = { workspace = true } chrono = { workspace = true }
async-trait = { workspace = true } async-trait = { workspace = true }
serde_json = { workspace = true }
[dev-dependencies] [dev-dependencies]
tokio = { workspace = true, features = ["full"] } tokio = { workspace = true, features = ["full"] }

View File

@@ -1,17 +1,16 @@
use async_trait::async_trait; use async_trait::async_trait;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use domain::models::thought::Visibility;
use domain::{ use domain::{
errors::DomainError, errors::DomainError,
models::{ models::{
feed::{FeedEntry, PageParams, Paginated}, feed::{FeedEntry, PageParams, Paginated},
thought::{Thought, Visibility}, thought::Thought,
user::User, user::User,
}, },
ports::SearchPort, ports::SearchPort,
value_objects::{Content, ThoughtId, UserId}, value_objects::{Content, Email, PasswordHash, ThoughtId, UserId, Username},
}; };
use postgres::user::USER_SELECT;
use sqlx::PgPool; use sqlx::PgPool;
pub struct PgSearchRepository { pub struct PgSearchRepository {
@@ -29,79 +28,141 @@ struct FeedRow {
t_user_id: uuid::Uuid, t_user_id: uuid::Uuid,
content: String, content: String,
in_reply_to_id: Option<uuid::Uuid>, in_reply_to_id: Option<uuid::Uuid>,
in_reply_to_url: Option<String>,
t_ap_id: Option<String>,
visibility: String, visibility: String,
content_warning: Option<String>, content_warning: Option<String>,
sensitive: bool, sensitive: bool,
t_local: bool, t_local: bool,
thought_created_at: DateTime<Utc>, thought_created_at: DateTime<Utc>,
thought_updated_at: Option<DateTime<Utc>>, updated_at: Option<DateTime<Utc>>,
note_extensions: Option<serde_json::Value>, author_id: uuid::Uuid,
mood: Option<String>, username: String,
#[sqlx(flatten)] email: String,
author: postgres::user::UserRow, password_hash: String,
display_name: Option<String>,
bio: Option<String>,
avatar_url: Option<String>,
header_url: Option<String>,
custom_css: Option<String>,
author_local: bool,
u_ap_id: Option<String>,
inbox_url: Option<String>,
public_key: Option<String>,
private_key: Option<String>,
author_created_at: DateTime<Utc>,
author_updated_at: DateTime<Utc>,
like_count: i64, like_count: i64,
boost_count: i64, boost_count: i64,
reply_count: i64, reply_count: i64,
liked_by_viewer: bool,
boosted_by_viewer: bool,
} }
fn feed_select(viewer: Option<uuid::Uuid>) -> String { const FEED_SELECT: &str = "
let viewer_checks = match viewer { SELECT
Some(uid) => format!( t.id AS thought_id, t.user_id AS t_user_id, t.content,
"EXISTS(SELECT 1 FROM likes WHERE user_id='{uid}' AND thought_id=t.id) AS liked_by_viewer,\n\ t.in_reply_to_id, t.in_reply_to_url, t.ap_id AS t_ap_id,
EXISTS(SELECT 1 FROM boosts WHERE user_id='{uid}' AND thought_id=t.id) AS boosted_by_viewer" t.visibility, t.content_warning, t.sensitive, t.local AS t_local,
), t.created_at AS thought_created_at, t.updated_at,
None => "false AS liked_by_viewer, false AS boosted_by_viewer".to_string(), u.id AS author_id, u.username, u.email, u.password_hash,
}; u.display_name, u.bio, u.avatar_url, u.header_url, u.custom_css,
format!( u.local AS author_local, u.ap_id AS u_ap_id, u.inbox_url,
"\n SELECT\n\ u.public_key, u.private_key,
t.id AS thought_id, t.user_id AS t_user_id, t.content,\n\ u.created_at AS author_created_at, u.updated_at AS author_updated_at,
t.in_reply_to_id,\n\ (SELECT COUNT(*) FROM likes l WHERE l.thought_id=t.id) AS like_count,
t.visibility, t.content_warning, t.sensitive, t.local AS t_local,\n\ (SELECT COUNT(*) FROM boosts b WHERE b.thought_id=t.id) AS boost_count,
t.created_at AS thought_created_at, t.updated_at AS thought_updated_at, t.note_extensions, t.mood,\n\ (SELECT COUNT(*) FROM thoughts r WHERE r.in_reply_to_id=t.id) AS reply_count
u.id, u.username, u.email, u.password_hash,\n\ FROM thoughts t JOIN users u ON u.id=t.user_id";
u.display_name, u.bio, u.avatar_url, u.header_url, u.custom_css, u.profile_fields, u.custom_moods,\n\
u.local,\n\
u.created_at, u.updated_at,\n\
(SELECT COUNT(*) FROM likes l WHERE l.thought_id=t.id) AS like_count,\n\
(SELECT COUNT(*) FROM boosts b WHERE b.thought_id=t.id) AS boost_count,\n\
(SELECT COUNT(*) FROM thoughts r WHERE r.in_reply_to_id=t.id) AS reply_count,\n\
{viewer_checks}\n\
FROM thoughts t JOIN users u ON u.id=t.user_id"
)
}
fn row_to_entry(r: FeedRow, viewer: Option<uuid::Uuid>) -> Result<FeedEntry, DomainError> { fn row_to_entry(r: FeedRow) -> FeedEntry {
let thought = Thought { let thought = Thought {
id: ThoughtId::from_uuid(r.thought_id), id: ThoughtId::from_uuid(r.thought_id),
user_id: UserId::from_uuid(r.t_user_id), user_id: UserId::from_uuid(r.t_user_id),
content: Content::new_remote(r.content), content: Content::new_remote(r.content),
in_reply_to_id: r.in_reply_to_id.map(ThoughtId::from_uuid), in_reply_to_id: r.in_reply_to_id.map(ThoughtId::from_uuid),
visibility: Visibility::from_db_str(&r.visibility)?, in_reply_to_url: r.in_reply_to_url,
ap_id: r.t_ap_id,
visibility: Visibility::from_db_str(&r.visibility),
content_warning: r.content_warning, content_warning: r.content_warning,
sensitive: r.sensitive, sensitive: r.sensitive,
local: r.t_local, local: r.t_local,
created_at: r.thought_created_at, created_at: r.thought_created_at,
updated_at: r.thought_updated_at, updated_at: r.updated_at,
note_extensions: r.note_extensions,
mood: r.mood,
}; };
let author = User::from(r.author); let author = User {
Ok(FeedEntry { id: UserId::from_uuid(r.author_id),
username: Username::from_trusted(r.username),
email: Email::from_trusted(r.email),
password_hash: PasswordHash(r.password_hash),
display_name: r.display_name,
bio: r.bio,
avatar_url: r.avatar_url,
header_url: r.header_url,
custom_css: r.custom_css,
local: r.author_local,
ap_id: r.u_ap_id,
inbox_url: r.inbox_url,
public_key: r.public_key,
private_key: r.private_key,
created_at: r.author_created_at,
updated_at: r.author_updated_at,
};
FeedEntry {
thought, thought,
author, author,
stats: domain::models::feed::EngagementStats {
like_count: r.like_count, like_count: r.like_count,
boost_count: r.boost_count, boost_count: r.boost_count,
reply_count: r.reply_count, reply_count: r.reply_count,
}, liked_by_viewer: false,
viewer: viewer.map(|_| domain::models::feed::ViewerContext { boosted_by_viewer: false,
liked: r.liked_by_viewer,
boosted: r.boosted_by_viewer,
}),
})
} }
}
#[derive(sqlx::FromRow)]
struct UserRow {
id: uuid::Uuid,
username: String,
email: String,
password_hash: String,
display_name: Option<String>,
bio: Option<String>,
avatar_url: Option<String>,
header_url: Option<String>,
custom_css: Option<String>,
local: bool,
ap_id: Option<String>,
inbox_url: Option<String>,
public_key: Option<String>,
private_key: Option<String>,
created_at: DateTime<Utc>,
updated_at: DateTime<Utc>,
}
impl From<UserRow> for User {
fn from(r: UserRow) -> Self {
User {
id: UserId::from_uuid(r.id),
username: Username::from_trusted(r.username),
email: Email::from_trusted(r.email),
password_hash: PasswordHash(r.password_hash),
display_name: r.display_name,
bio: r.bio,
avatar_url: r.avatar_url,
header_url: r.header_url,
custom_css: r.custom_css,
local: r.local,
ap_id: r.ap_id,
inbox_url: r.inbox_url,
public_key: r.public_key,
private_key: r.private_key,
created_at: r.created_at,
updated_at: r.updated_at,
}
}
}
const USER_SELECT: &str =
"SELECT id,username,email,password_hash,display_name,bio,avatar_url,header_url,\
custom_css,local,ap_id,inbox_url,public_key,private_key,created_at,updated_at FROM users";
#[async_trait] #[async_trait]
impl SearchPort for PgSearchRepository { impl SearchPort for PgSearchRepository {
@@ -109,11 +170,8 @@ impl SearchPort for PgSearchRepository {
&self, &self,
query: &str, query: &str,
page: &PageParams, page: &PageParams,
viewer_id: Option<&UserId>, _viewer_id: Option<&UserId>,
) -> Result<Paginated<FeedEntry>, DomainError> { ) -> Result<Paginated<FeedEntry>, DomainError> {
let viewer = viewer_id.map(|v| v.as_uuid());
let select = feed_select(viewer);
let total: i64 = sqlx::query_scalar( let total: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM thoughts t "SELECT COUNT(*) FROM thoughts t
WHERE t.content % $1 AND t.visibility='public'", WHERE t.content % $1 AND t.visibility='public'",
@@ -124,7 +182,7 @@ impl SearchPort for PgSearchRepository {
.map_err(|e| DomainError::Internal(e.to_string()))?; .map_err(|e| DomainError::Internal(e.to_string()))?;
let sql = format!( let sql = format!(
"{select} "{FEED_SELECT}
WHERE t.content % $1 AND t.visibility='public' WHERE t.content % $1 AND t.visibility='public'
ORDER BY similarity(t.content, $1) DESC ORDER BY similarity(t.content, $1) DESC
LIMIT $2 OFFSET $3" LIMIT $2 OFFSET $3"
@@ -138,10 +196,7 @@ impl SearchPort for PgSearchRepository {
.map_err(|e| DomainError::Internal(e.to_string()))?; .map_err(|e| DomainError::Internal(e.to_string()))?;
Ok(Paginated { Ok(Paginated {
items: rows items: rows.into_iter().map(row_to_entry).collect(),
.into_iter()
.map(|r| row_to_entry(r, viewer))
.collect::<Result<Vec<_>, _>>()?,
total, total,
page: page.page, page: page.page,
per_page: page.per_page, per_page: page.per_page,
@@ -168,7 +223,7 @@ impl SearchPort for PgSearchRepository {
ORDER BY similarity(username || ' ' || COALESCE(display_name,''), $1) DESC ORDER BY similarity(username || ' ' || COALESCE(display_name,''), $1) DESC
LIMIT $2 OFFSET $3" LIMIT $2 OFFSET $3"
); );
let rows = sqlx::query_as::<_, postgres::user::UserRow>(&sql) let rows = sqlx::query_as::<_, UserRow>(&sql)
.bind(query) .bind(query)
.bind(page.limit()) .bind(page.limit())
.bind(page.offset()) .bind(page.offset())
@@ -186,4 +241,105 @@ impl SearchPort for PgSearchRepository {
} }
#[cfg(test)] #[cfg(test)]
mod tests; mod tests {
use super::*;
use domain::{
models::{
thought::{Thought, Visibility},
user::User,
},
ports::{SearchPort, ThoughtRepository, UserRepository},
value_objects::*,
};
async fn seed_thought(pool: &sqlx::PgPool, username: &str, content: &str) -> (User, Thought) {
use postgres::{thought::PgThoughtRepository, user::PgUserRepository};
let urepo = PgUserRepository::new(pool.clone());
let trepo = PgThoughtRepository::new(pool.clone());
let u = User::new_local(
UserId::new(),
Username::new(username).unwrap(),
Email::new(format!("{username}@ex.com")).unwrap(),
PasswordHash("h".into()),
);
urepo.save(&u).await.unwrap();
let t = Thought::new_local(
ThoughtId::new(),
u.id.clone(),
Content::new_local(content).unwrap(),
None,
Visibility::Public,
None,
false,
);
trepo.save(&t).await.unwrap();
(u, t)
}
#[sqlx::test(migrations = "../postgres/migrations")]
async fn search_thoughts_finds_by_keyword(pool: sqlx::PgPool) {
seed_thought(&pool, "alice", "hello world").await;
seed_thought(&pool, "bob", "goodbye universe").await;
let repo = PgSearchRepository::new(pool);
let result = repo
.search_thoughts(
"hello world",
&PageParams {
page: 1,
per_page: 20,
},
None,
)
.await
.unwrap();
assert_eq!(result.total, 1);
assert_eq!(result.items[0].thought.content.as_str(), "hello world");
}
#[sqlx::test(migrations = "../postgres/migrations")]
async fn search_users_finds_by_username(pool: sqlx::PgPool) {
use postgres::user::PgUserRepository;
let urepo = PgUserRepository::new(pool.clone());
let alice = User::new_local(
UserId::new(),
Username::new("alice_search").unwrap(),
Email::new("alice@ex.com").unwrap(),
PasswordHash("h".into()),
);
urepo.save(&alice).await.unwrap();
let repo = PgSearchRepository::new(pool);
let result = repo
.search_users(
"alice",
&PageParams {
page: 1,
per_page: 20,
},
)
.await
.unwrap();
assert!(!result.items.is_empty());
assert!(result
.items
.iter()
.any(|u| u.username.as_str() == "alice_search"));
}
#[sqlx::test(migrations = "../postgres/migrations")]
async fn search_thoughts_returns_empty_for_no_match(pool: sqlx::PgPool) {
seed_thought(&pool, "alice", "hello world").await;
let repo = PgSearchRepository::new(pool);
let result = repo
.search_thoughts(
"zzzzzzzzz",
&PageParams {
page: 1,
per_page: 20,
},
None,
)
.await
.unwrap();
assert_eq!(result.total, 0);
}
}

View File

@@ -1,164 +0,0 @@
use super::*;
use domain::{
models::{
thought::{NewThought, Thought, Visibility},
user::User,
},
ports::{SearchPort, ThoughtRepository, UserWriter},
value_objects::{Content, Email, PasswordHash, ThoughtId, UserId, Username},
};
async fn seed_thought(pool: &sqlx::PgPool, username: &str, content: &str) -> (User, Thought) {
use postgres::{thought::PgThoughtRepository, user::PgUserRepository};
let urepo = PgUserRepository::new(pool.clone());
let trepo = PgThoughtRepository::new(pool.clone());
let u = User::new_local(
UserId::new(),
Username::new(username).unwrap(),
Email::new(format!("{username}@ex.com")).unwrap(),
PasswordHash("h".into()),
);
urepo.save(&u).await.unwrap();
let t = Thought::new_local(NewThought {
id: ThoughtId::new(),
user_id: u.id.clone(),
content: Content::new_local(content).unwrap(),
in_reply_to_id: None,
visibility: Visibility::Public,
content_warning: None,
sensitive: false,
mood: None,
});
trepo.save(&t).await.unwrap();
(u, t)
}
#[sqlx::test(migrations = "../postgres/migrations")]
async fn search_thoughts_finds_by_keyword(pool: sqlx::PgPool) {
seed_thought(&pool, "alice", "hello world").await;
seed_thought(&pool, "bob", "goodbye universe").await;
let repo = PgSearchRepository::new(pool);
let result = repo
.search_thoughts(
"hello world",
&PageParams {
page: 1,
per_page: 20,
},
None,
)
.await
.unwrap();
assert_eq!(result.total, 1);
assert_eq!(result.items[0].thought.content.as_str(), "hello world");
}
#[sqlx::test(migrations = "../postgres/migrations")]
async fn search_users_finds_by_username(pool: sqlx::PgPool) {
use postgres::user::PgUserRepository;
let urepo = PgUserRepository::new(pool.clone());
let alice = User::new_local(
UserId::new(),
Username::new("alice_search").unwrap(),
Email::new("alice@ex.com").unwrap(),
PasswordHash("h".into()),
);
urepo.save(&alice).await.unwrap();
let repo = PgSearchRepository::new(pool);
let result = repo
.search_users(
"alice",
&PageParams {
page: 1,
per_page: 20,
},
)
.await
.unwrap();
assert!(!result.items.is_empty());
assert!(result
.items
.iter()
.any(|u| u.username.as_str() == "alice_search"));
}
#[sqlx::test(migrations = "../postgres/migrations")]
async fn search_thoughts_returns_empty_for_no_match(pool: sqlx::PgPool) {
seed_thought(&pool, "alice", "hello world").await;
let repo = PgSearchRepository::new(pool);
let result = repo
.search_thoughts(
"zzzzzzzzz",
&PageParams {
page: 1,
per_page: 20,
},
None,
)
.await
.unwrap();
assert_eq!(result.total, 0);
}
#[sqlx::test(migrations = "../postgres/migrations")]
async fn search_thoughts_viewer_context(pool: sqlx::PgPool) {
use domain::models::social::Like;
use domain::ports::LikeRepository;
use domain::value_objects::LikeId;
use postgres::like::PgLikeRepository;
let (alice, thought) = seed_thought(&pool, "alice", "hello world").await;
// alice likes her own thought
let like_repo = PgLikeRepository::new(pool.clone());
like_repo
.save(&Like {
id: LikeId::new(),
user_id: alice.id.clone(),
thought_id: thought.id.clone(),
ap_id: None,
created_at: chrono::Utc::now(),
})
.await
.unwrap();
let repo = PgSearchRepository::new(pool);
// with viewer — should see liked = true
let authed = repo
.search_thoughts(
"hello",
&PageParams {
page: 1,
per_page: 20,
},
Some(&alice.id),
)
.await
.unwrap();
assert_eq!(authed.items.len(), 1);
let ctx = authed.items[0]
.viewer
.as_ref()
.expect("viewer context present");
assert!(ctx.liked, "alice should see the thought as liked");
assert!(!ctx.boosted);
// without viewer — viewer should be None
let anon = repo
.search_thoughts(
"hello",
&PageParams {
page: 1,
per_page: 20,
},
None,
)
.await
.unwrap();
assert_eq!(anon.items.len(), 1);
assert!(
anon.items[0].viewer.is_none(),
"anonymous request has no viewer context"
);
}

View File

@@ -5,11 +5,8 @@ edition = "2021"
[dependencies] [dependencies]
domain = { workspace = true } domain = { workspace = true }
activitypub = { workspace = true }
event-payload = { workspace = true }
sqlx = { workspace = true } sqlx = { workspace = true }
uuid = { workspace = true } uuid = { workspace = true }
serde_json = { workspace = true }
chrono = { workspace = true } chrono = { workspace = true }
async-trait = { workspace = true } async-trait = { workspace = true }
thiserror = { workspace = true } thiserror = { workspace = true }

View File

@@ -1,13 +0,0 @@
CREATE TABLE remote_actor_connections (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
actor_url TEXT NOT NULL,
connection_type TEXT NOT NULL,
page INT NOT NULL,
connected_actor_url TEXT NOT NULL,
connected_handle TEXT NOT NULL,
connected_display_name TEXT,
connected_avatar_url TEXT,
fetched_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(actor_url, connection_type, page, connected_actor_url)
);
CREATE INDEX ON remote_actor_connections(actor_url, connection_type, page, fetched_at);

View File

@@ -1,3 +0,0 @@
-- Remote ActivityPub posts can exceed 128 characters.
-- The 128-char limit is enforced at the application layer for local posts only.
ALTER TABLE thoughts ALTER COLUMN content TYPE TEXT;

View File

@@ -1 +0,0 @@
ALTER TABLE notifications RENAME COLUMN "type" TO notification_type;

View File

@@ -1,15 +0,0 @@
CREATE TABLE failed_events (
id UUID NOT NULL DEFAULT gen_random_uuid(),
event_type TEXT NOT NULL,
payload JSONB NOT NULL,
failed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
retry_at TIMESTAMPTZ NOT NULL,
retry_count INT NOT NULL DEFAULT 0,
last_error TEXT NOT NULL,
CONSTRAINT failed_events_pkey PRIMARY KEY (id)
);
CREATE INDEX failed_events_due_idx
ON failed_events (retry_at)
WHERE retry_count < 3;

View File

@@ -1,11 +0,0 @@
-- Change in_reply_to_id FK from RESTRICT (default) to SET NULL.
-- Previously, deleting a thought that had replies raised a FK violation.
-- With SET NULL, deleting a thought orphans its replies (they survive but
-- lose their parent reference), which is the correct semantic for a
-- threaded social app.
ALTER TABLE thoughts
DROP CONSTRAINT IF EXISTS thoughts_in_reply_to_id_fkey;
ALTER TABLE thoughts
ADD CONSTRAINT thoughts_in_reply_to_id_fkey
FOREIGN KEY (in_reply_to_id) REFERENCES thoughts(id) ON DELETE SET NULL;

View File

@@ -1,10 +0,0 @@
CREATE TABLE outbox_events (
seq BIGSERIAL PRIMARY KEY,
aggregate_id UUID NOT NULL,
event_type TEXT NOT NULL,
payload JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
delivered BOOLEAN NOT NULL DEFAULT false,
delivered_at TIMESTAMPTZ
);
CREATE INDEX outbox_events_pending_idx ON outbox_events (seq) WHERE delivered = false;

View File

@@ -1 +0,0 @@
ALTER TABLE thoughts ADD COLUMN note_extensions JSONB;

View File

@@ -1 +0,0 @@
ALTER TABLE users ADD COLUMN IF NOT EXISTS also_known_as TEXT;

View File

@@ -1,7 +0,0 @@
CREATE TABLE IF NOT EXISTS federation_processed_activities (
activity_id TEXT PRIMARY KEY,
processed_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_fed_processed_activities_at
ON federation_processed_activities(processed_at);

View File

@@ -1,6 +0,0 @@
ALTER TABLE remote_actors
ADD COLUMN IF NOT EXISTS bio TEXT,
ADD COLUMN IF NOT EXISTS banner_url TEXT,
ADD COLUMN IF NOT EXISTS followers_url TEXT,
ADD COLUMN IF NOT EXISTS following_url TEXT,
ADD COLUMN IF NOT EXISTS also_known_as TEXT[];

View File

@@ -1,10 +0,0 @@
-- Indexes for feed engagement counts and sorting.
-- likes and boosts are joined/counted per thought on every feed query.
-- thoughts(in_reply_to_id) is scanned for reply_count.
CREATE INDEX IF NOT EXISTS idx_likes_thought_id ON likes(thought_id);
CREATE INDEX IF NOT EXISTS idx_boosts_thought_id ON boosts(thought_id);
CREATE INDEX IF NOT EXISTS idx_thoughts_in_reply_to_id ON thoughts(in_reply_to_id) WHERE in_reply_to_id IS NOT NULL;
-- Viewer-context lookups: "did I like/boost this?"
CREATE INDEX IF NOT EXISTS idx_likes_user_thought ON likes(user_id, thought_id);
CREATE INDEX IF NOT EXISTS idx_boosts_user_thought ON boosts(user_id, thought_id);

View File

@@ -1 +0,0 @@
ALTER TABLE users ALTER COLUMN username TYPE VARCHAR(255);

View File

@@ -1,2 +0,0 @@
ALTER TABLE federation_following
ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'accepted';

View File

@@ -1 +0,0 @@
ALTER TABLE remote_actors ADD COLUMN IF NOT EXISTS attachment JSONB DEFAULT '[]'::jsonb;

View File

@@ -1 +0,0 @@
ALTER TABLE users ADD COLUMN IF NOT EXISTS profile_fields JSONB DEFAULT '[]'::jsonb;

View File

@@ -1,2 +0,0 @@
ALTER TABLE thoughts ADD COLUMN IF NOT EXISTS mood VARCHAR(64);
ALTER TABLE users ADD COLUMN IF NOT EXISTS custom_moods JSONB DEFAULT '[]'::jsonb;

View File

@@ -1,10 +0,0 @@
INSERT INTO users (id, username, email, password_hash, display_name, bio)
VALUES (
'00000000-0000-4000-8000-000000000000',
'instance',
'noreply@instance.invalid',
'!service-actor-no-login',
NULL,
NULL
)
ON CONFLICT (id) DO NOTHING;

View File

@@ -0,0 +1,298 @@
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use sqlx::PgPool;
use url::Url;
use domain::{
errors::DomainError,
models::thought::{Thought, Visibility},
ports::{ActivityPubRepository, OutboxEntry},
value_objects::{Content, ThoughtId, UserId, Username},
};
pub struct PgActivityPubRepository {
pool: PgPool,
}
impl PgActivityPubRepository {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
}
#[async_trait]
impl ActivityPubRepository for PgActivityPubRepository {
async fn outbox_entries_for_actor(
&self,
user_id: &UserId,
) -> Result<Vec<OutboxEntry>, DomainError> {
#[derive(sqlx::FromRow)]
struct Row {
id: uuid::Uuid,
user_id: uuid::Uuid,
content: String,
created_at: DateTime<Utc>,
in_reply_to_id: Option<uuid::Uuid>,
content_warning: Option<String>,
sensitive: bool,
username: String,
updated_at: Option<DateTime<Utc>>,
}
sqlx::query_as::<_, Row>(
"SELECT t.id, t.user_id, t.content, t.created_at, t.in_reply_to_id, t.content_warning, t.sensitive, u.username, t.updated_at
FROM thoughts t JOIN users u ON u.id=t.user_id
WHERE t.user_id=$1 AND t.local=true AND t.visibility='public'
ORDER BY t.created_at DESC",
)
.bind(user_id.as_uuid())
.fetch_all(&self.pool)
.await
.map_err(|e| DomainError::Internal(e.to_string()))
.map(|rows| {
rows.into_iter()
.map(|r| OutboxEntry {
thought: Thought {
id: ThoughtId::from_uuid(r.id),
user_id: UserId::from_uuid(r.user_id),
content: Content::new_remote(r.content),
in_reply_to_id: r.in_reply_to_id.map(ThoughtId::from_uuid),
in_reply_to_url: None,
ap_id: None,
visibility: Visibility::Public,
content_warning: r.content_warning,
sensitive: r.sensitive,
local: true,
created_at: r.created_at,
updated_at: r.updated_at,
},
author_username: Username::from_trusted(r.username),
})
.collect()
})
}
async fn outbox_page_for_actor(
&self,
user_id: &UserId,
before: Option<DateTime<Utc>>,
limit: usize,
) -> Result<Vec<OutboxEntry>, DomainError> {
#[derive(sqlx::FromRow)]
struct Row {
id: uuid::Uuid,
user_id: uuid::Uuid,
content: String,
created_at: DateTime<Utc>,
in_reply_to_id: Option<uuid::Uuid>,
content_warning: Option<String>,
sensitive: bool,
username: String,
updated_at: Option<DateTime<Utc>>,
}
let rows = if let Some(before) = before {
sqlx::query_as::<_, Row>(
"SELECT t.id, t.user_id, t.content, t.created_at, t.in_reply_to_id, t.content_warning, t.sensitive, u.username, t.updated_at
FROM thoughts t JOIN users u ON u.id=t.user_id
WHERE t.user_id=$1 AND t.local=true AND t.visibility='public' AND t.created_at < $2
ORDER BY t.created_at DESC LIMIT $3",
)
.bind(user_id.as_uuid())
.bind(before)
.bind(limit as i64)
.fetch_all(&self.pool)
.await
} else {
sqlx::query_as::<_, Row>(
"SELECT t.id, t.user_id, t.content, t.created_at, t.in_reply_to_id, t.content_warning, t.sensitive, u.username, t.updated_at
FROM thoughts t JOIN users u ON u.id=t.user_id
WHERE t.user_id=$1 AND t.local=true AND t.visibility='public'
ORDER BY t.created_at DESC LIMIT $2",
)
.bind(user_id.as_uuid())
.bind(limit as i64)
.fetch_all(&self.pool)
.await
}
.map_err(|e| DomainError::Internal(e.to_string()))?;
Ok(rows
.into_iter()
.map(|r| OutboxEntry {
thought: Thought {
id: ThoughtId::from_uuid(r.id),
user_id: UserId::from_uuid(r.user_id),
content: Content::new_remote(r.content),
in_reply_to_id: r.in_reply_to_id.map(ThoughtId::from_uuid),
in_reply_to_url: None,
ap_id: None,
visibility: Visibility::Public,
content_warning: r.content_warning,
sensitive: r.sensitive,
local: true,
created_at: r.created_at,
updated_at: r.updated_at,
},
author_username: Username::from_trusted(r.username),
})
.collect())
}
async fn find_remote_actor_id(
&self,
actor_ap_url: &Url,
) -> Result<Option<UserId>, DomainError> {
sqlx::query_scalar::<_, uuid::Uuid>("SELECT id FROM users WHERE ap_id=$1")
.bind(actor_ap_url.as_str())
.fetch_optional(&self.pool)
.await
.map_err(|e| DomainError::Internal(e.to_string()))
.map(|o| o.map(UserId::from_uuid))
}
async fn intern_remote_actor(&self, actor_ap_url: &Url) -> Result<UserId, DomainError> {
if let Some(id) = self.find_remote_actor_id(actor_ap_url).await? {
return Ok(id);
}
let new_id = uuid::Uuid::new_v4();
let raw = actor_ap_url
.path()
.trim_start_matches('/')
.replace('/', "_");
// username column is VARCHAR(32); truncate long paths (e.g. UUID-based actor URLs)
let handle = if raw.len() <= 32 {
raw
} else {
format!("remote_{}", &new_id.to_string()[..13])
};
sqlx::query(
"INSERT INTO users(id,username,email,password_hash,local,ap_id,created_at,updated_at)
VALUES($1,$2,$3,'',false,$4,NOW(),NOW()) ON CONFLICT(ap_id) DO NOTHING",
)
.bind(new_id)
.bind(&handle)
.bind(format!("{}@remote", new_id))
.bind(actor_ap_url.as_str())
.execute(&self.pool)
.await
.map_err(|e| DomainError::Internal(e.to_string()))?;
// Re-fetch to get whichever id won the race
self.find_remote_actor_id(actor_ap_url)
.await?
.ok_or_else(|| {
DomainError::Internal(
"intern_remote_actor: insert succeeded but row not found".into(),
)
})
}
async fn accept_note(
&self,
ap_id: &Url,
author_id: &UserId,
content: &str,
published: DateTime<Utc>,
sensitive: bool,
content_warning: Option<String>,
visibility: &str,
) -> Result<(), DomainError> {
let capped: String = content.chars().take(500).collect();
sqlx::query(
"INSERT INTO thoughts(id,user_id,content,ap_id,visibility,sensitive,local,content_warning,created_at)
VALUES($1,$2,$3,$4,$8,$5,false,$6,$7) ON CONFLICT(ap_id) DO NOTHING",
)
.bind(uuid::Uuid::new_v4())
.bind(author_id.as_uuid())
.bind(&capped)
.bind(ap_id.as_str())
.bind(sensitive)
.bind(content_warning)
.bind(published)
.bind(visibility)
.execute(&self.pool)
.await
.map_err(|e| DomainError::Internal(e.to_string()))
.map(|_| ())
}
async fn apply_note_update(&self, ap_id: &Url, new_content: &str) -> Result<(), DomainError> {
let capped: String = new_content.chars().take(500).collect();
sqlx::query(
"UPDATE thoughts SET content=$2,updated_at=NOW() WHERE ap_id=$1 AND local=false",
)
.bind(ap_id.as_str())
.bind(&capped)
.execute(&self.pool)
.await
.map_err(|e| DomainError::Internal(e.to_string()))
.map(|_| ())
}
async fn retract_note(&self, ap_id: &Url) -> Result<(), DomainError> {
sqlx::query("DELETE FROM thoughts WHERE ap_id=$1 AND local=false")
.bind(ap_id.as_str())
.execute(&self.pool)
.await
.map_err(|e| DomainError::Internal(e.to_string()))
.map(|_| ())
}
async fn retract_actor_notes(&self, actor_ap_url: &Url) -> Result<(), DomainError> {
sqlx::query(
"DELETE FROM thoughts WHERE local=false AND user_id=(SELECT id FROM users WHERE ap_id=$1)",
)
.bind(actor_ap_url.as_str())
.execute(&self.pool)
.await
.map_err(|e| DomainError::Internal(e.to_string()))
.map(|_| ())
}
async fn count_local_notes(&self) -> Result<u64, DomainError> {
let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM thoughts WHERE local=true")
.fetch_one(&self.pool)
.await
.map_err(|e| DomainError::Internal(e.to_string()))?;
Ok(n as u64)
}
}
#[cfg(test)]
mod tests {
use super::*;
use domain::ports::ActivityPubRepository;
#[sqlx::test(migrations = "./migrations")]
async fn intern_remote_actor_is_idempotent(pool: sqlx::PgPool) {
let repo = PgActivityPubRepository::new(pool);
let url = url::Url::parse("https://mastodon.social/users/alice").unwrap();
let id1 = repo.intern_remote_actor(&url).await.unwrap();
let id2 = repo.intern_remote_actor(&url).await.unwrap();
assert_eq!(id1, id2);
}
#[sqlx::test(migrations = "./migrations")]
async fn accept_and_retract_note(pool: sqlx::PgPool) {
let repo = PgActivityPubRepository::new(pool);
let actor_url = url::Url::parse("https://remote.example/users/bob").unwrap();
let ap_id = url::Url::parse("https://remote.example/notes/1").unwrap();
let author = repo.intern_remote_actor(&actor_url).await.unwrap();
repo.accept_note(
&ap_id,
&author,
"hello from remote",
chrono::Utc::now(),
false,
None,
"public",
)
.await
.unwrap();
repo.retract_note(&ap_id).await.unwrap();
}
#[sqlx::test(migrations = "./migrations")]
async fn count_local_notes_excludes_remote(pool: sqlx::PgPool) {
let repo = PgActivityPubRepository::new(pool);
assert_eq!(repo.count_local_notes().await.unwrap(), 0);
}
}

View File

@@ -1,363 +0,0 @@
use crate::db_error::IntoDbResult;
use async_trait::async_trait;
const MAX_REMOTE_CONTENT_CHARS: usize = 5000;
const THOUGHTS_PATH_PREFIX: &str = "/thoughts/";
use chrono::{DateTime, Utc};
use sqlx::PgPool;
use activitypub::{AcceptNoteInput, ActivityPubRepository, ActorApUrls, OutboxEntry};
use domain::{
errors::DomainError,
models::thought::{Thought, Visibility},
value_objects::{Content, ThoughtId, UserId, Username},
};
#[derive(sqlx::FromRow)]
struct OutboxRow {
id: uuid::Uuid,
user_id: uuid::Uuid,
content: String,
created_at: DateTime<Utc>,
in_reply_to_id: Option<uuid::Uuid>,
content_warning: Option<String>,
sensitive: bool,
username: String,
updated_at: Option<DateTime<Utc>>,
}
impl OutboxRow {
fn into_entry(self) -> OutboxEntry {
OutboxEntry {
thought: Thought {
id: ThoughtId::from_uuid(self.id),
user_id: UserId::from_uuid(self.user_id),
content: Content::new_remote(self.content),
in_reply_to_id: self.in_reply_to_id.map(ThoughtId::from_uuid),
visibility: Visibility::Public,
content_warning: self.content_warning,
sensitive: self.sensitive,
local: true,
created_at: self.created_at,
updated_at: self.updated_at,
note_extensions: None,
mood: None,
},
author_username: Username::from_trusted(self.username),
}
}
}
pub struct PgActivityPubRepository {
pool: PgPool,
}
impl PgActivityPubRepository {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
}
#[async_trait]
impl ActivityPubRepository for PgActivityPubRepository {
async fn outbox_entries_for_actor(
&self,
user_id: &UserId,
) -> Result<Vec<OutboxEntry>, DomainError> {
sqlx::query_as::<_, OutboxRow>(
"SELECT t.id, t.user_id, t.content, t.created_at, t.in_reply_to_id, t.content_warning, t.sensitive, u.username, t.updated_at
FROM thoughts t JOIN users u ON u.id=t.user_id
WHERE t.user_id=$1 AND t.local=true AND t.visibility='public'
ORDER BY t.created_at DESC",
)
.bind(user_id.as_uuid())
.fetch_all(&self.pool)
.await
.into_domain()
.map(|rows| rows.into_iter().map(OutboxRow::into_entry).collect())
}
async fn outbox_page_for_actor(
&self,
user_id: &UserId,
before: Option<DateTime<Utc>>,
limit: usize,
) -> Result<Vec<OutboxEntry>, DomainError> {
let rows = if let Some(before) = before {
sqlx::query_as::<_, OutboxRow>(
"SELECT t.id, t.user_id, t.content, t.created_at, t.in_reply_to_id, t.content_warning, t.sensitive, u.username, t.updated_at
FROM thoughts t JOIN users u ON u.id=t.user_id
WHERE t.user_id=$1 AND t.local=true AND t.visibility='public' AND t.created_at < $2
ORDER BY t.created_at DESC LIMIT $3",
)
.bind(user_id.as_uuid())
.bind(before)
.bind(limit as i64)
.fetch_all(&self.pool)
.await
} else {
sqlx::query_as::<_, OutboxRow>(
"SELECT t.id, t.user_id, t.content, t.created_at, t.in_reply_to_id, t.content_warning, t.sensitive, u.username, t.updated_at
FROM thoughts t JOIN users u ON u.id=t.user_id
WHERE t.user_id=$1 AND t.local=true AND t.visibility='public'
ORDER BY t.created_at DESC LIMIT $2",
)
.bind(user_id.as_uuid())
.bind(limit as i64)
.fetch_all(&self.pool)
.await
}
.into_domain()?;
Ok(rows.into_iter().map(OutboxRow::into_entry).collect())
}
async fn find_remote_actor_id(
&self,
actor_ap_url: &str,
) -> Result<Option<UserId>, DomainError> {
sqlx::query_scalar::<_, uuid::Uuid>("SELECT id FROM users WHERE ap_id=$1")
.bind(actor_ap_url)
.fetch_optional(&self.pool)
.await
.into_domain()
.map(|o| o.map(UserId::from_uuid))
}
async fn intern_remote_actor(&self, actor_ap_url: &str) -> Result<UserId, DomainError> {
if let Some(id) = self.find_remote_actor_id(actor_ap_url).await? {
return Ok(id);
}
let new_id = uuid::Uuid::new_v4();
let parsed = url::Url::parse(actor_ap_url).ok();
let domain_str = parsed
.as_ref()
.and_then(|u| u.host_str().map(|s| s.to_string()))
.unwrap_or_default();
let last_seg = parsed
.and_then(|u| {
u.path_segments()
.and_then(|mut s| s.next_back().map(|s| s.to_string()))
})
.unwrap_or_default();
let handle = if last_seg.is_empty() || domain_str.is_empty() {
format!("r_{}", &new_id.to_string()[..13])
} else {
let candidate = format!("{}@{}", last_seg, domain_str);
if candidate.len() <= 255 {
candidate
} else {
format!("r_{}", &new_id.to_string()[..13])
}
};
let result = sqlx::query(
"INSERT INTO users(id,username,email,password_hash,local,ap_id,created_at,updated_at)
VALUES($1,$2,$3,'',false,$4,NOW(),NOW()) ON CONFLICT(ap_id) DO NOTHING",
)
.bind(new_id)
.bind(&handle)
.bind(format!("{}@remote", new_id))
.bind(actor_ap_url)
.execute(&self.pool)
.await;
if result.is_err() {
let fallback = format!("r_{}", &new_id.to_string()[..13]);
let new_id2 = uuid::Uuid::new_v4();
sqlx::query(
"INSERT INTO users(id,username,email,password_hash,local,ap_id,created_at,updated_at)
VALUES($1,$2,$3,'',false,$4,NOW(),NOW()) ON CONFLICT(ap_id) DO NOTHING",
)
.bind(new_id2)
.bind(&fallback)
.bind(format!("{}@remote", new_id2))
.bind(actor_ap_url)
.execute(&self.pool)
.await
.into_domain()?;
}
self.find_remote_actor_id(actor_ap_url)
.await?
.ok_or_else(|| {
DomainError::Internal(
"intern_remote_actor: insert succeeded but row not found".into(),
)
})
}
async fn update_remote_actor_display(
&self,
user_id: &UserId,
display_name: Option<&str>,
avatar_url: Option<&str>,
) -> Result<(), DomainError> {
sqlx::query(
"UPDATE users SET display_name=$1, avatar_url=$2, updated_at=NOW()
WHERE id=$3 AND local=false",
)
.bind(display_name)
.bind(avatar_url)
.bind(user_id.as_uuid())
.execute(&self.pool)
.await
.into_domain()
.map(|_| ())
}
async fn accept_note(&self, input: AcceptNoteInput<'_>) -> Result<ThoughtId, DomainError> {
let AcceptNoteInput {
ap_id,
author_id,
content,
published,
sensitive,
content_warning,
visibility,
in_reply_to,
note_extensions,
} = input;
let capped: String = content.chars().take(MAX_REMOTE_CONTENT_CHARS).collect();
let (in_reply_to_id, in_reply_to_url) = match in_reply_to {
Some(url) => {
// Fast path: local thought URL contains the UUID directly.
let local_uuid = url::Url::parse(url).ok().and_then(|u| {
u.path()
.strip_prefix(THOUGHTS_PATH_PREFIX)
.and_then(|s| s.split('/').next())
.and_then(|s| uuid::Uuid::parse_str(s).ok())
});
// Slow path: remote parent — look up by ap_id so remote-to-remote
// replies are threaded correctly in the feed.
let resolved = if local_uuid.is_some() {
local_uuid
} else {
sqlx::query_scalar::<_, uuid::Uuid>("SELECT id FROM thoughts WHERE ap_id=$1")
.bind(url)
.fetch_optional(&self.pool)
.await
.into_domain()?
};
(resolved, Some(url.to_string()))
}
None => (None, None),
};
sqlx::query(
"INSERT INTO thoughts(id,user_id,content,ap_id,visibility,sensitive,local,content_warning,created_at,in_reply_to_id,in_reply_to_url,note_extensions)
VALUES($1,$2,$3,$4,$8,$5,false,$6,$7,$9,$10,$11) ON CONFLICT(ap_id) DO NOTHING",
)
.bind(uuid::Uuid::new_v4())
.bind(author_id.as_uuid())
.bind(&capped)
.bind(ap_id)
.bind(sensitive)
.bind(content_warning)
.bind(published)
.bind(visibility)
.bind(in_reply_to_id)
.bind(&in_reply_to_url)
.bind(note_extensions)
.execute(&self.pool)
.await
.into_domain()?;
// SELECT the id — works whether the INSERT was a no-op or not (idempotent).
let row: (uuid::Uuid,) = sqlx::query_as("SELECT id FROM thoughts WHERE ap_id=$1")
.bind(ap_id)
.fetch_one(&self.pool)
.await
.into_domain()?;
Ok(ThoughtId::from_uuid(row.0))
}
async fn apply_note_update(
&self,
ap_id: &str,
new_content: &str,
note_extensions: Option<serde_json::Value>,
) -> Result<(), DomainError> {
let capped: String = new_content.chars().take(MAX_REMOTE_CONTENT_CHARS).collect();
sqlx::query(
"UPDATE thoughts SET content=$2,note_extensions=$3,updated_at=NOW() WHERE ap_id=$1 AND local=false",
)
.bind(ap_id)
.bind(&capped)
.bind(&note_extensions)
.execute(&self.pool)
.await
.into_domain()
.map(|_| ())
}
async fn retract_note(&self, ap_id: &str) -> Result<(), DomainError> {
sqlx::query("DELETE FROM thoughts WHERE ap_id=$1 AND local=false")
.bind(ap_id)
.execute(&self.pool)
.await
.into_domain()
.map(|_| ())
}
async fn retract_actor_notes(&self, actor_ap_url: &str) -> Result<(), DomainError> {
sqlx::query(
"DELETE FROM thoughts WHERE local=false AND user_id=(SELECT id FROM users WHERE ap_id=$1)",
)
.bind(actor_ap_url)
.execute(&self.pool)
.await
.into_domain()
.map(|_| ())
}
async fn count_local_notes(&self) -> Result<u64, DomainError> {
let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM thoughts WHERE local=true")
.fetch_one(&self.pool)
.await
.into_domain()?;
Ok(n as u64)
}
async fn get_thought_ap_id(
&self,
thought_id: &ThoughtId,
) -> Result<Option<String>, DomainError> {
sqlx::query_scalar::<_, String>(
"SELECT ap_id FROM thoughts WHERE id = $1 AND ap_id IS NOT NULL",
)
.bind(thought_id.as_uuid())
.fetch_optional(&self.pool)
.await
.into_domain()
}
async fn get_actor_ap_urls(
&self,
user_id: &UserId,
) -> Result<Option<ActorApUrls>, DomainError> {
sqlx::query_as::<_, (String, String)>(
"SELECT ap_id, inbox_url FROM users \
WHERE id = $1 AND ap_id IS NOT NULL AND inbox_url IS NOT NULL",
)
.bind(user_id.as_uuid())
.fetch_optional(&self.pool)
.await
.into_domain()
.map(|opt| opt.map(|(ap_id, inbox_url)| ActorApUrls { ap_id, inbox_url }))
}
async fn sync_remote_actor_to_user(&self, actor_ap_url: &str) -> Result<(), DomainError> {
sqlx::query(
"UPDATE users SET display_name = ra.display_name, avatar_url = ra.avatar_url, updated_at = NOW()
FROM remote_actors ra
WHERE users.ap_id = ra.url AND users.ap_id = $1 AND users.local = false",
)
.bind(actor_ap_url)
.execute(&self.pool)
.await
.into_domain()
.map(|_| ())
}
}
#[cfg(test)]
mod tests;

View File

@@ -1,70 +0,0 @@
use super::*;
use activitypub::{AcceptNoteInput, ActivityPubRepository};
#[sqlx::test(migrations = "./migrations")]
async fn intern_remote_actor_is_idempotent(pool: sqlx::PgPool) {
let repo = PgActivityPubRepository::new(pool);
let url = "https://mastodon.social/users/alice";
let id1 = repo.intern_remote_actor(url).await.unwrap();
let id2 = repo.intern_remote_actor(url).await.unwrap();
assert_eq!(id1, id2);
}
#[sqlx::test(migrations = "./migrations")]
async fn accept_and_retract_note(pool: sqlx::PgPool) {
let repo = PgActivityPubRepository::new(pool);
let actor_url = "https://remote.example/users/bob";
let ap_id = "https://remote.example/notes/1";
let author = repo.intern_remote_actor(actor_url).await.unwrap();
repo.accept_note(AcceptNoteInput {
ap_id,
author_id: &author,
content: "hello from remote",
published: chrono::Utc::now(),
sensitive: false,
content_warning: None,
visibility: "public",
in_reply_to: None,
note_extensions: None,
})
.await
.unwrap();
repo.retract_note(ap_id).await.unwrap();
}
#[sqlx::test(migrations = "./migrations")]
async fn count_local_notes_excludes_remote(pool: sqlx::PgPool) {
let repo = PgActivityPubRepository::new(pool);
assert_eq!(repo.count_local_notes().await.unwrap(), 0);
}
#[sqlx::test(migrations = "./migrations")]
async fn accept_note_returns_thought_id(pool: sqlx::PgPool) {
let repo = PgActivityPubRepository::new(pool.clone());
let actor_user_id = repo
.intern_remote_actor("https://remote.example/users/alice")
.await
.unwrap();
let thought_id = repo
.accept_note(AcceptNoteInput {
ap_id: "https://remote.example/notes/1",
author_id: &actor_user_id,
content: "Hello #rust world",
published: chrono::Utc::now(),
sensitive: false,
content_warning: None,
visibility: "public",
in_reply_to: None,
note_extensions: None,
})
.await
.unwrap();
let row: (uuid::Uuid,) = sqlx::query_as("SELECT id FROM thoughts WHERE ap_id=$1")
.bind("https://remote.example/notes/1")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(thought_id.as_uuid(), row.0);
}

View File

@@ -0,0 +1,141 @@
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use domain::{
errors::DomainError,
models::api_key::ApiKey,
ports::ApiKeyRepository,
value_objects::{ApiKeyId, UserId},
};
use sqlx::PgPool;
pub struct PgApiKeyRepository {
pool: PgPool,
}
impl PgApiKeyRepository {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
}
#[async_trait]
impl ApiKeyRepository for PgApiKeyRepository {
async fn save(&self, k: &ApiKey) -> Result<(), DomainError> {
sqlx::query(
"INSERT INTO api_keys(id,user_id,key_hash,name,created_at) VALUES($1,$2,$3,$4,$5)",
)
.bind(k.id.as_uuid())
.bind(k.user_id.as_uuid())
.bind(&k.key_hash)
.bind(&k.name)
.bind(k.created_at)
.execute(&self.pool)
.await
.map_err(|e| DomainError::Internal(e.to_string()))
.map(|_| ())
}
async fn find_by_hash(&self, hash: &str) -> Result<Option<ApiKey>, DomainError> {
#[derive(sqlx::FromRow)]
struct Row {
id: uuid::Uuid,
user_id: uuid::Uuid,
key_hash: String,
name: String,
created_at: DateTime<Utc>,
}
sqlx::query_as::<_, Row>(
"SELECT id,user_id,key_hash,name,created_at FROM api_keys WHERE key_hash=$1",
)
.bind(hash)
.fetch_optional(&self.pool)
.await
.map_err(|e| DomainError::Internal(e.to_string()))
.map(|o| {
o.map(|r| ApiKey {
id: ApiKeyId::from_uuid(r.id),
user_id: UserId::from_uuid(r.user_id),
key_hash: r.key_hash,
name: r.name,
created_at: r.created_at,
})
})
}
async fn list_for_user(&self, user_id: &UserId) -> Result<Vec<ApiKey>, DomainError> {
#[derive(sqlx::FromRow)]
struct Row {
id: uuid::Uuid,
user_id: uuid::Uuid,
key_hash: String,
name: String,
created_at: DateTime<Utc>,
}
sqlx::query_as::<_, Row>("SELECT id,user_id,key_hash,name,created_at FROM api_keys WHERE user_id=$1 ORDER BY created_at DESC")
.bind(user_id.as_uuid()).fetch_all(&self.pool).await
.map_err(|e| DomainError::Internal(e.to_string()))
.map(|rows| rows.into_iter().map(|r| ApiKey { id: ApiKeyId::from_uuid(r.id), user_id: UserId::from_uuid(r.user_id), key_hash: r.key_hash, name: r.name, created_at: r.created_at }).collect())
}
async fn delete(&self, id: &ApiKeyId, user_id: &UserId) -> Result<(), DomainError> {
sqlx::query("DELETE FROM api_keys WHERE id=$1 AND user_id=$2")
.bind(id.as_uuid())
.bind(user_id.as_uuid())
.execute(&self.pool)
.await
.map_err(|e| DomainError::Internal(e.to_string()))
.map(|_| ())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::user::PgUserRepository;
use chrono::Utc;
use domain::ports::UserRepository;
use domain::{models::user::User, value_objects::*};
async fn seed_user(pool: &sqlx::PgPool) -> User {
let repo = PgUserRepository::new(pool.clone());
let u = User::new_local(
UserId::new(),
Username::new("alice").unwrap(),
Email::new("alice@ex.com").unwrap(),
PasswordHash("h".into()),
);
repo.save(&u).await.unwrap();
u
}
#[sqlx::test(migrations = "./migrations")]
async fn save_and_find_by_hash(pool: sqlx::PgPool) {
let user = seed_user(&pool).await;
let repo = PgApiKeyRepository::new(pool);
let key = ApiKey {
id: ApiKeyId::new(),
user_id: user.id.clone(),
key_hash: "abc123".into(),
name: "test".into(),
created_at: Utc::now(),
};
repo.save(&key).await.unwrap();
let found = repo.find_by_hash("abc123").await.unwrap().unwrap();
assert_eq!(found.name, "test");
}
#[sqlx::test(migrations = "./migrations")]
async fn delete_key(pool: sqlx::PgPool) {
let user = seed_user(&pool).await;
let repo = PgApiKeyRepository::new(pool);
let key = ApiKey {
id: ApiKeyId::new(),
user_id: user.id.clone(),
key_hash: "def456".into(),
name: "key2".into(),
created_at: Utc::now(),
};
repo.save(&key).await.unwrap();
repo.delete(&key.id, &user.id).await.unwrap();
assert!(repo.find_by_hash("def456").await.unwrap().is_none());
}
}

View File

@@ -1,89 +0,0 @@
use crate::db_error::IntoDbResult;
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use domain::{
errors::DomainError,
models::api_key::ApiKey,
ports::ApiKeyRepository,
value_objects::{ApiKeyId, UserId},
};
use sqlx::PgPool;
#[derive(sqlx::FromRow)]
struct ApiKeyRow {
id: uuid::Uuid,
user_id: uuid::Uuid,
key_hash: String,
name: String,
created_at: DateTime<Utc>,
}
impl ApiKeyRow {
fn into_domain(self) -> ApiKey {
ApiKey {
id: ApiKeyId::from_uuid(self.id),
user_id: UserId::from_uuid(self.user_id),
key_hash: self.key_hash,
name: self.name,
created_at: self.created_at,
}
}
}
pub struct PgApiKeyRepository {
pool: PgPool,
}
impl PgApiKeyRepository {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
}
#[async_trait]
impl ApiKeyRepository for PgApiKeyRepository {
async fn save(&self, k: &ApiKey) -> Result<(), DomainError> {
sqlx::query(
"INSERT INTO api_keys(id,user_id,key_hash,name,created_at) VALUES($1,$2,$3,$4,$5)",
)
.bind(k.id.as_uuid())
.bind(k.user_id.as_uuid())
.bind(&k.key_hash)
.bind(&k.name)
.bind(k.created_at)
.execute(&self.pool)
.await
.into_domain()
.map(|_| ())
}
async fn find_by_hash(&self, hash: &str) -> Result<Option<ApiKey>, DomainError> {
sqlx::query_as::<_, ApiKeyRow>(
"SELECT id,user_id,key_hash,name,created_at FROM api_keys WHERE key_hash=$1",
)
.bind(hash)
.fetch_optional(&self.pool)
.await
.into_domain()
.map(|o| o.map(ApiKeyRow::into_domain))
}
async fn list_for_user(&self, user_id: &UserId) -> Result<Vec<ApiKey>, DomainError> {
sqlx::query_as::<_, ApiKeyRow>("SELECT id,user_id,key_hash,name,created_at FROM api_keys WHERE user_id=$1 ORDER BY created_at DESC")
.bind(user_id.as_uuid()).fetch_all(&self.pool).await
.into_domain()
.map(|rows| rows.into_iter().map(ApiKeyRow::into_domain).collect())
}
async fn delete(&self, id: &ApiKeyId, user_id: &UserId) -> Result<(), DomainError> {
sqlx::query("DELETE FROM api_keys WHERE id=$1 AND user_id=$2")
.bind(id.as_uuid())
.bind(user_id.as_uuid())
.execute(&self.pool)
.await
.into_domain()
.map(|_| ())
}
}
#[cfg(test)]
mod tests;

View File

@@ -1,49 +0,0 @@
use super::*;
use crate::user::PgUserRepository;
use chrono::Utc;
use domain::ports::UserWriter;
use domain::{models::user::User, value_objects::*};
async fn seed_user(pool: &sqlx::PgPool) -> User {
let repo = PgUserRepository::new(pool.clone());
let u = User::new_local(
UserId::new(),
Username::new("alice").unwrap(),
Email::new("alice@ex.com").unwrap(),
PasswordHash("h".into()),
);
repo.save(&u).await.unwrap();
u
}
#[sqlx::test(migrations = "./migrations")]
async fn save_and_find_by_hash(pool: sqlx::PgPool) {
let user = seed_user(&pool).await;
let repo = PgApiKeyRepository::new(pool);
let key = ApiKey {
id: ApiKeyId::new(),
user_id: user.id.clone(),
key_hash: "abc123".into(),
name: "test".into(),
created_at: Utc::now(),
};
repo.save(&key).await.unwrap();
let found = repo.find_by_hash("abc123").await.unwrap().unwrap();
assert_eq!(found.name, "test");
}
#[sqlx::test(migrations = "./migrations")]
async fn delete_key(pool: sqlx::PgPool) {
let user = seed_user(&pool).await;
let repo = PgApiKeyRepository::new(pool);
let key = ApiKey {
id: ApiKeyId::new(),
user_id: user.id.clone(),
key_hash: "def456".into(),
name: "key2".into(),
created_at: Utc::now(),
};
repo.save(&key).await.unwrap();
repo.delete(&key.id, &user.id).await.unwrap();
assert!(repo.find_by_hash("def456").await.unwrap().is_none());
}

View File

@@ -0,0 +1,102 @@
use async_trait::async_trait;
use domain::{
errors::DomainError, models::social::Block, ports::BlockRepository, value_objects::UserId,
};
use sqlx::PgPool;
pub struct PgBlockRepository {
pool: PgPool,
}
impl PgBlockRepository {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
}
#[async_trait]
impl BlockRepository for PgBlockRepository {
async fn save(&self, b: &Block) -> Result<(), DomainError> {
sqlx::query(
"INSERT INTO blocks(blocker_id,blocked_id,created_at) VALUES($1,$2,$3) ON CONFLICT DO NOTHING"
)
.bind(b.blocker_id.as_uuid())
.bind(b.blocked_id.as_uuid())
.bind(b.created_at)
.execute(&self.pool)
.await
.map_err(|e| DomainError::Internal(e.to_string()))
.map(|_| ())
}
async fn delete(&self, blocker_id: &UserId, blocked_id: &UserId) -> Result<(), DomainError> {
sqlx::query("DELETE FROM blocks WHERE blocker_id=$1 AND blocked_id=$2")
.bind(blocker_id.as_uuid())
.bind(blocked_id.as_uuid())
.execute(&self.pool)
.await
.map_err(|e| DomainError::Internal(e.to_string()))
.map(|_| ())
}
async fn exists(&self, blocker_id: &UserId, blocked_id: &UserId) -> Result<bool, DomainError> {
let count: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM blocks WHERE blocker_id=$1 AND blocked_id=$2")
.bind(blocker_id.as_uuid())
.bind(blocked_id.as_uuid())
.fetch_one(&self.pool)
.await
.map_err(|e| DomainError::Internal(e.to_string()))?;
Ok(count > 0)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::user::PgUserRepository;
use chrono::Utc;
use domain::ports::UserRepository;
use domain::{models::user::User, value_objects::*};
async fn seed_user(pool: &sqlx::PgPool, username: &str, email: &str) -> User {
let repo = PgUserRepository::new(pool.clone());
let u = User::new_local(
UserId::new(),
Username::new(username).unwrap(),
Email::new(email).unwrap(),
PasswordHash("h".into()),
);
repo.save(&u).await.unwrap();
u
}
#[sqlx::test(migrations = "./migrations")]
async fn block_exists(pool: sqlx::PgPool) {
let alice = seed_user(&pool, "alice", "alice@ex.com").await;
let bob = seed_user(&pool, "bob", "bob@ex.com").await;
let repo = PgBlockRepository::new(pool);
let block = Block {
blocker_id: alice.id.clone(),
blocked_id: bob.id.clone(),
created_at: Utc::now(),
};
repo.save(&block).await.unwrap();
assert!(repo.exists(&alice.id, &bob.id).await.unwrap());
assert!(!repo.exists(&bob.id, &alice.id).await.unwrap());
}
#[sqlx::test(migrations = "./migrations")]
async fn unblock(pool: sqlx::PgPool) {
let alice = seed_user(&pool, "alice", "alice@ex.com").await;
let bob = seed_user(&pool, "bob", "bob@ex.com").await;
let repo = PgBlockRepository::new(pool);
let block = Block {
blocker_id: alice.id.clone(),
blocked_id: bob.id.clone(),
created_at: Utc::now(),
};
repo.save(&block).await.unwrap();
repo.delete(&alice.id, &bob.id).await.unwrap();
assert!(!repo.exists(&alice.id, &bob.id).await.unwrap());
}
}

View File

@@ -1,55 +0,0 @@
use crate::db_error::IntoDbResult;
use async_trait::async_trait;
use domain::{
errors::DomainError, models::social::Block, ports::BlockRepository, value_objects::UserId,
};
use sqlx::PgPool;
pub struct PgBlockRepository {
pool: PgPool,
}
impl PgBlockRepository {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
}
#[async_trait]
impl BlockRepository for PgBlockRepository {
async fn save(&self, b: &Block) -> Result<(), DomainError> {
sqlx::query(
"INSERT INTO blocks(blocker_id,blocked_id,created_at) VALUES($1,$2,$3) ON CONFLICT DO NOTHING"
)
.bind(b.blocker_id.as_uuid())
.bind(b.blocked_id.as_uuid())
.bind(b.created_at)
.execute(&self.pool)
.await
.into_domain()
.map(|_| ())
}
async fn delete(&self, blocker_id: &UserId, blocked_id: &UserId) -> Result<(), DomainError> {
sqlx::query("DELETE FROM blocks WHERE blocker_id=$1 AND blocked_id=$2")
.bind(blocker_id.as_uuid())
.bind(blocked_id.as_uuid())
.execute(&self.pool)
.await
.into_domain()
.map(|_| ())
}
async fn exists(&self, blocker_id: &UserId, blocked_id: &UserId) -> Result<bool, DomainError> {
let count: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM blocks WHERE blocker_id=$1 AND blocked_id=$2")
.bind(blocker_id.as_uuid())
.bind(blocked_id.as_uuid())
.fetch_one(&self.pool)
.await
.into_domain()?;
Ok(count > 0)
}
}
#[cfg(test)]
mod tests;

View File

@@ -1,33 +0,0 @@
use super::*;
use crate::test_helpers::seed_user;
use chrono::Utc;
#[sqlx::test(migrations = "./migrations")]
async fn block_exists(pool: sqlx::PgPool) {
let alice = seed_user(&pool, "alice", "alice@ex.com").await;
let bob = seed_user(&pool, "bob", "bob@ex.com").await;
let repo = PgBlockRepository::new(pool);
let block = Block {
blocker_id: alice.id.clone(),
blocked_id: bob.id.clone(),
created_at: Utc::now(),
};
repo.save(&block).await.unwrap();
assert!(repo.exists(&alice.id, &bob.id).await.unwrap());
assert!(!repo.exists(&bob.id, &alice.id).await.unwrap());
}
#[sqlx::test(migrations = "./migrations")]
async fn unblock(pool: sqlx::PgPool) {
let alice = seed_user(&pool, "alice", "alice@ex.com").await;
let bob = seed_user(&pool, "bob", "bob@ex.com").await;
let repo = PgBlockRepository::new(pool);
let block = Block {
blocker_id: alice.id.clone(),
blocked_id: bob.id.clone(),
created_at: Utc::now(),
};
repo.save(&block).await.unwrap();
repo.delete(&alice.id, &bob.id).await.unwrap();
assert!(!repo.exists(&alice.id, &bob.id).await.unwrap());
}

View File

@@ -0,0 +1,139 @@
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use domain::{
errors::DomainError,
models::social::Boost,
ports::BoostRepository,
value_objects::{BoostId, ThoughtId, UserId},
};
use sqlx::PgPool;
pub struct PgBoostRepository {
pool: PgPool,
}
impl PgBoostRepository {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
}
#[async_trait]
impl BoostRepository for PgBoostRepository {
async fn save(&self, b: &Boost) -> Result<(), DomainError> {
sqlx::query(
"INSERT INTO boosts(id,user_id,thought_id,ap_id,created_at) VALUES($1,$2,$3,$4,$5) ON CONFLICT(user_id,thought_id) DO NOTHING"
)
.bind(b.id.as_uuid()).bind(b.user_id.as_uuid()).bind(b.thought_id.as_uuid()).bind(&b.ap_id).bind(b.created_at)
.execute(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string())).map(|_| ())
}
async fn delete(&self, user_id: &UserId, thought_id: &ThoughtId) -> Result<(), DomainError> {
let r = sqlx::query("DELETE FROM boosts WHERE user_id=$1 AND thought_id=$2")
.bind(user_id.as_uuid())
.bind(thought_id.as_uuid())
.execute(&self.pool)
.await
.map_err(|e| DomainError::Internal(e.to_string()))?;
if r.rows_affected() == 0 {
return Err(DomainError::NotFound);
}
Ok(())
}
async fn find(
&self,
user_id: &UserId,
thought_id: &ThoughtId,
) -> Result<Option<Boost>, DomainError> {
#[derive(sqlx::FromRow)]
struct Row {
id: uuid::Uuid,
user_id: uuid::Uuid,
thought_id: uuid::Uuid,
ap_id: Option<String>,
created_at: DateTime<Utc>,
}
sqlx::query_as::<_, Row>("SELECT id,user_id,thought_id,ap_id,created_at FROM boosts WHERE user_id=$1 AND thought_id=$2")
.bind(user_id.as_uuid()).bind(thought_id.as_uuid())
.fetch_optional(&self.pool).await
.map_err(|e| DomainError::Internal(e.to_string()))
.map(|o| o.map(|r| Boost { id: BoostId::from_uuid(r.id), user_id: UserId::from_uuid(r.user_id), thought_id: ThoughtId::from_uuid(r.thought_id), ap_id: r.ap_id, created_at: r.created_at }))
}
async fn count_for_thought(&self, thought_id: &ThoughtId) -> Result<i64, DomainError> {
sqlx::query_scalar("SELECT COUNT(*) FROM boosts WHERE thought_id=$1")
.bind(thought_id.as_uuid())
.fetch_one(&self.pool)
.await
.map_err(|e| DomainError::Internal(e.to_string()))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{thought::PgThoughtRepository, user::PgUserRepository};
use chrono::Utc;
use domain::ports::{ThoughtRepository, UserRepository};
use domain::{
models::{
thought::{Thought, Visibility},
user::User,
},
value_objects::*,
};
async fn seed(pool: &sqlx::PgPool) -> (User, Thought) {
let urepo = PgUserRepository::new(pool.clone());
let trepo = PgThoughtRepository::new(pool.clone());
let u = User::new_local(
UserId::new(),
Username::new("alice").unwrap(),
Email::new("alice@ex.com").unwrap(),
PasswordHash("h".into()),
);
urepo.save(&u).await.unwrap();
let t = Thought::new_local(
ThoughtId::new(),
u.id.clone(),
Content::new_local("hi").unwrap(),
None,
Visibility::Public,
None,
false,
);
trepo.save(&t).await.unwrap();
(u, t)
}
#[sqlx::test(migrations = "./migrations")]
async fn boost_and_count(pool: sqlx::PgPool) {
let (user, thought) = seed(&pool).await;
let repo = PgBoostRepository::new(pool);
let boost = Boost {
id: BoostId::new(),
user_id: user.id.clone(),
thought_id: thought.id.clone(),
ap_id: None,
created_at: Utc::now(),
};
repo.save(&boost).await.unwrap();
assert_eq!(repo.count_for_thought(&thought.id).await.unwrap(), 1);
}
#[sqlx::test(migrations = "./migrations")]
async fn unboost(pool: sqlx::PgPool) {
let (user, thought) = seed(&pool).await;
let repo = PgBoostRepository::new(pool);
let boost = Boost {
id: BoostId::new(),
user_id: user.id.clone(),
thought_id: thought.id.clone(),
ap_id: None,
created_at: Utc::now(),
};
repo.save(&boost).await.unwrap();
repo.delete(&user.id, &thought.id).await.unwrap();
assert_eq!(repo.count_for_thought(&thought.id).await.unwrap(), 0);
}
}

View File

@@ -1,74 +0,0 @@
use crate::db_error::IntoDbResult;
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use domain::{
errors::DomainError,
models::social::Boost,
ports::BoostRepository,
value_objects::{BoostId, ThoughtId, UserId},
};
use sqlx::PgPool;
pub struct PgBoostRepository {
pool: PgPool,
}
impl PgBoostRepository {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
}
#[async_trait]
impl BoostRepository for PgBoostRepository {
async fn save(&self, b: &Boost) -> Result<(), DomainError> {
sqlx::query(
"INSERT INTO boosts(id,user_id,thought_id,ap_id,created_at) VALUES($1,$2,$3,$4,$5) ON CONFLICT(user_id,thought_id) DO NOTHING"
)
.bind(b.id.as_uuid()).bind(b.user_id.as_uuid()).bind(b.thought_id.as_uuid()).bind(&b.ap_id).bind(b.created_at)
.execute(&self.pool).await.into_domain().map(|_| ())
}
async fn delete(&self, user_id: &UserId, thought_id: &ThoughtId) -> Result<(), DomainError> {
let r = sqlx::query("DELETE FROM boosts WHERE user_id=$1 AND thought_id=$2")
.bind(user_id.as_uuid())
.bind(thought_id.as_uuid())
.execute(&self.pool)
.await
.into_domain()?;
if r.rows_affected() == 0 {
return Err(DomainError::NotFound);
}
Ok(())
}
async fn find(
&self,
user_id: &UserId,
thought_id: &ThoughtId,
) -> Result<Option<Boost>, DomainError> {
#[derive(sqlx::FromRow)]
struct Row {
id: uuid::Uuid,
user_id: uuid::Uuid,
thought_id: uuid::Uuid,
ap_id: Option<String>,
created_at: DateTime<Utc>,
}
sqlx::query_as::<_, Row>("SELECT id,user_id,thought_id,ap_id,created_at FROM boosts WHERE user_id=$1 AND thought_id=$2")
.bind(user_id.as_uuid()).bind(thought_id.as_uuid())
.fetch_optional(&self.pool).await
.into_domain()
.map(|o| o.map(|r| Boost { id: BoostId::from_uuid(r.id), user_id: UserId::from_uuid(r.user_id), thought_id: ThoughtId::from_uuid(r.thought_id), ap_id: r.ap_id, created_at: r.created_at }))
}
async fn count_for_thought(&self, thought_id: &ThoughtId) -> Result<i64, DomainError> {
sqlx::query_scalar("SELECT COUNT(*) FROM boosts WHERE thought_id=$1")
.bind(thought_id.as_uuid())
.fetch_one(&self.pool)
.await
.into_domain()
}
}
#[cfg(test)]
mod tests;

View File

@@ -1,34 +0,0 @@
use super::*;
use crate::test_helpers::seed_user_and_thought;
use chrono::Utc;
#[sqlx::test(migrations = "./migrations")]
async fn boost_and_count(pool: sqlx::PgPool) {
let (user, thought) = seed_user_and_thought(&pool).await;
let repo = PgBoostRepository::new(pool);
let boost = Boost {
id: BoostId::new(),
user_id: user.id.clone(),
thought_id: thought.id.clone(),
ap_id: None,
created_at: Utc::now(),
};
repo.save(&boost).await.unwrap();
assert_eq!(repo.count_for_thought(&thought.id).await.unwrap(), 1);
}
#[sqlx::test(migrations = "./migrations")]
async fn unboost(pool: sqlx::PgPool) {
let (user, thought) = seed_user_and_thought(&pool).await;
let repo = PgBoostRepository::new(pool);
let boost = Boost {
id: BoostId::new(),
user_id: user.id.clone(),
thought_id: thought.id.clone(),
ap_id: None,
created_at: Utc::now(),
};
repo.save(&boost).await.unwrap();
repo.delete(&user.id, &thought.id).await.unwrap();
assert_eq!(repo.count_for_thought(&thought.id).await.unwrap(), 0);
}

View File

@@ -1,8 +0,0 @@
pub const STATUS_ACCEPTED: &str = "accepted";
pub const STATUS_PENDING: &str = "pending";
pub const STATUS_REJECTED: &str = "rejected";
pub const VIS_PUBLIC: &str = "public";
pub const VIS_UNLISTED: &str = "unlisted";
pub const VIS_FOLLOWERS: &str = "followers";
pub const VIS_DIRECT: &str = "direct";

View File

@@ -1,20 +0,0 @@
use domain::errors::DomainError;
pub(crate) trait IntoDbResult<T> {
fn into_domain(self) -> Result<T, DomainError>;
}
impl<T> IntoDbResult<T> for Result<T, sqlx::Error> {
fn into_domain(self) -> Result<T, DomainError> {
self.map_err(|e| {
if let sqlx::Error::Database(ref db) = e {
if db.code().as_deref() == Some("23505") {
return DomainError::Conflict(
db.constraint().unwrap_or("conflict").to_string(),
);
}
}
DomainError::Internal(e.to_string())
})
}
}

View File

@@ -1,83 +0,0 @@
use crate::db_error::IntoDbResult;
use async_trait::async_trait;
use domain::{
errors::DomainError,
models::feed::{EngagementStats, ViewerContext},
ports::EngagementRepository,
value_objects::{ThoughtId, UserId},
};
use sqlx::PgPool;
use std::collections::HashMap;
pub struct PgEngagementRepository {
pool: PgPool,
}
impl PgEngagementRepository {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
}
#[async_trait]
impl EngagementRepository for PgEngagementRepository {
async fn get_for_thoughts(
&self,
thought_ids: &[ThoughtId],
viewer_id: Option<&UserId>,
) -> Result<HashMap<ThoughtId, (EngagementStats, Option<ViewerContext>)>, DomainError> {
if thought_ids.is_empty() {
return Ok(HashMap::new());
}
#[derive(sqlx::FromRow)]
struct Row {
thought_id: uuid::Uuid,
like_count: i64,
boost_count: i64,
reply_count: i64,
liked_by_viewer: bool,
boosted_by_viewer: bool,
}
let ids: Vec<uuid::Uuid> = thought_ids.iter().map(|t| t.as_uuid()).collect();
let viewer_uuid: Option<uuid::Uuid> = viewer_id.map(|v| v.as_uuid());
let rows = sqlx::query_as::<_, Row>(
"SELECT
t.id AS thought_id,
COUNT(DISTINCT l.user_id) AS like_count,
COUNT(DISTINCT b.user_id) AS boost_count,
COUNT(DISTINCT r.id) AS reply_count,
COALESCE(BOOL_OR(l.user_id = $2), false) AS liked_by_viewer,
COALESCE(BOOL_OR(b.user_id = $2), false) AS boosted_by_viewer
FROM thoughts t
LEFT JOIN likes l ON l.thought_id = t.id
LEFT JOIN boosts b ON b.thought_id = t.id
LEFT JOIN thoughts r ON r.in_reply_to_id = t.id
WHERE t.id = ANY($1)
GROUP BY t.id",
)
.bind(&ids[..])
.bind(viewer_uuid)
.fetch_all(&self.pool)
.await
.into_domain()?;
let mut result = HashMap::new();
for row in rows {
let tid = ThoughtId::from_uuid(row.thought_id);
let stats = EngagementStats {
like_count: row.like_count,
boost_count: row.boost_count,
reply_count: row.reply_count,
};
let viewer = viewer_id.map(|_| ViewerContext {
liked: row.liked_by_viewer,
boosted: row.boosted_by_viewer,
});
result.insert(tid, (stats, viewer));
}
Ok(result)
}
}

View File

@@ -1,105 +0,0 @@
use chrono::{DateTime, Utc};
use sqlx::PgPool;
/// How many times a failed event is retried by the DLQ processor.
pub const DLQ_MAX_RETRIES: i32 = 3;
/// Quarantine period for the first DLQ retry (seconds). Doubles each retry.
pub const DLQ_INITIAL_BACKOFF_SECS: i64 = 300; // 5 minutes
/// How often the DLQ processor polls for due retries (seconds).
pub const DLQ_POLL_INTERVAL_SECS: u64 = 60;
#[derive(sqlx::FromRow)]
pub struct FailedEvent {
pub id: uuid::Uuid,
pub event_type: String,
pub payload: serde_json::Value,
pub failed_at: DateTime<Utc>,
pub retry_at: DateTime<Utc>,
pub retry_count: i32,
pub last_error: String,
}
pub struct PgFailedEventStore {
pool: PgPool,
}
impl PgFailedEventStore {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
/// Insert a newly exhausted event into the DLQ.
pub async fn insert(
&self,
event_type: &str,
payload: &serde_json::Value,
last_error: &str,
) -> Result<(), sqlx::Error> {
let retry_at = Utc::now() + chrono::Duration::seconds(DLQ_INITIAL_BACKOFF_SECS);
sqlx::query(
"INSERT INTO failed_events \
(event_type, payload, retry_at, last_error) \
VALUES ($1, $2, $3, $4)",
)
.bind(event_type)
.bind(payload)
.bind(retry_at)
.bind(last_error)
.execute(&self.pool)
.await?;
Ok(())
}
/// Fetch all events due for retry (retry_at <= now, retry_count < DLQ_MAX_RETRIES).
pub async fn poll_due(&self) -> Result<Vec<FailedEvent>, sqlx::Error> {
sqlx::query_as::<_, FailedEvent>(
"SELECT id, event_type, payload, failed_at, retry_at, retry_count, last_error \
FROM failed_events \
WHERE retry_at <= now() AND retry_count < $1 \
ORDER BY retry_at \
LIMIT 100",
)
.bind(DLQ_MAX_RETRIES)
.fetch_all(&self.pool)
.await
}
/// Advance a row after a republish attempt using exponential backoff.
/// next_retry = now + initial * 2^retry_count
pub async fn advance(&self, id: uuid::Uuid, error: Option<&str>) -> Result<(), sqlx::Error> {
let current: i32 =
sqlx::query_scalar("SELECT retry_count FROM failed_events WHERE id = $1")
.bind(id)
.fetch_one(&self.pool)
.await?;
let new_count = current + 1;
let backoff_secs = DLQ_INITIAL_BACKOFF_SECS * (1_i64 << new_count.min(10));
let retry_at = Utc::now() + chrono::Duration::seconds(backoff_secs);
let last_error = error.unwrap_or("republish succeeded");
sqlx::query(
"UPDATE failed_events \
SET retry_count = $1, retry_at = $2, last_error = $3 \
WHERE id = $4",
)
.bind(new_count)
.bind(retry_at)
.bind(last_error)
.bind(id)
.execute(&self.pool)
.await?;
Ok(())
}
/// Park a permanently failed event (retry_count >= DLQ_MAX_RETRIES).
pub async fn park_permanently(&self, id: uuid::Uuid) -> Result<(), sqlx::Error> {
let far_future = Utc::now() + chrono::Duration::days(365);
sqlx::query("UPDATE failed_events SET retry_at = $1 WHERE id = $2")
.bind(far_future)
.bind(id)
.execute(&self.pool)
.await?;
Ok(())
}
}

View File

@@ -0,0 +1,392 @@
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use domain::models::thought::Visibility;
use domain::{
errors::DomainError,
models::{
feed::{FeedEntry, PageParams, Paginated},
thought::Thought,
user::User,
},
ports::FeedRepository,
value_objects::{Content, Email, PasswordHash, ThoughtId, UserId, Username},
};
use sqlx::PgPool;
pub struct PgFeedRepository {
pool: PgPool,
}
impl PgFeedRepository {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
}
#[derive(sqlx::FromRow)]
struct FeedRow {
thought_id: uuid::Uuid,
t_user_id: uuid::Uuid,
content: String,
in_reply_to_id: Option<uuid::Uuid>,
in_reply_to_url: Option<String>,
t_ap_id: Option<String>,
visibility: String,
content_warning: Option<String>,
sensitive: bool,
t_local: bool,
thought_created_at: DateTime<Utc>,
updated_at: Option<DateTime<Utc>>,
author_id: uuid::Uuid,
username: String,
email: String,
password_hash: String,
display_name: Option<String>,
bio: Option<String>,
avatar_url: Option<String>,
header_url: Option<String>,
custom_css: Option<String>,
author_local: bool,
u_ap_id: Option<String>,
inbox_url: Option<String>,
public_key: Option<String>,
private_key: Option<String>,
author_created_at: DateTime<Utc>,
author_updated_at: DateTime<Utc>,
like_count: i64,
boost_count: i64,
reply_count: i64,
liked_by_viewer: bool,
boosted_by_viewer: bool,
}
fn feed_select(viewer: Option<uuid::Uuid>) -> String {
let viewer_checks = match viewer {
Some(uid) => format!(
"EXISTS(SELECT 1 FROM likes WHERE user_id='{uid}' AND thought_id=t.id) AS liked_by_viewer,
EXISTS(SELECT 1 FROM boosts WHERE user_id='{uid}' AND thought_id=t.id) AS boosted_by_viewer"
),
None => "false AS liked_by_viewer, false AS boosted_by_viewer".to_string(),
};
format!(
"
SELECT
t.id AS thought_id, t.user_id AS t_user_id, t.content,
t.in_reply_to_id, t.in_reply_to_url, t.ap_id AS t_ap_id,
t.visibility, t.content_warning, t.sensitive, t.local AS t_local,
t.created_at AS thought_created_at, t.updated_at,
u.id AS author_id, u.username, u.email, u.password_hash,
u.display_name, u.bio, u.avatar_url, u.header_url, u.custom_css,
u.local AS author_local, u.ap_id AS u_ap_id, u.inbox_url,
u.public_key, u.private_key,
u.created_at AS author_created_at, u.updated_at AS author_updated_at,
(SELECT COUNT(*) FROM likes l WHERE l.thought_id=t.id) AS like_count,
(SELECT COUNT(*) FROM boosts b WHERE b.thought_id=t.id) AS boost_count,
(SELECT COUNT(*) FROM thoughts r WHERE r.in_reply_to_id=t.id) AS reply_count,
{viewer_checks}
FROM thoughts t JOIN users u ON u.id=t.user_id"
)
}
fn row_to_entry(r: FeedRow) -> FeedEntry {
let thought = Thought {
id: ThoughtId::from_uuid(r.thought_id),
user_id: UserId::from_uuid(r.t_user_id),
content: Content::new_remote(r.content),
in_reply_to_id: r.in_reply_to_id.map(ThoughtId::from_uuid),
in_reply_to_url: r.in_reply_to_url,
ap_id: r.t_ap_id,
visibility: Visibility::from_db_str(&r.visibility),
content_warning: r.content_warning,
sensitive: r.sensitive,
local: r.t_local,
created_at: r.thought_created_at,
updated_at: r.updated_at,
};
let author = User {
id: UserId::from_uuid(r.author_id),
username: Username::from_trusted(r.username),
email: Email::from_trusted(r.email),
password_hash: PasswordHash(r.password_hash),
display_name: r.display_name,
bio: r.bio,
avatar_url: r.avatar_url,
header_url: r.header_url,
custom_css: r.custom_css,
local: r.author_local,
ap_id: r.u_ap_id,
inbox_url: r.inbox_url,
public_key: r.public_key,
private_key: r.private_key,
created_at: r.author_created_at,
updated_at: r.author_updated_at,
};
FeedEntry {
thought,
author,
like_count: r.like_count,
boost_count: r.boost_count,
reply_count: r.reply_count,
liked_by_viewer: r.liked_by_viewer,
boosted_by_viewer: r.boosted_by_viewer,
}
}
#[async_trait]
impl FeedRepository for PgFeedRepository {
async fn home_feed(
&self,
following_ids: &[UserId],
page: &PageParams,
viewer_id: Option<&UserId>,
) -> Result<Paginated<FeedEntry>, DomainError> {
let ids: Vec<uuid::Uuid> = following_ids.iter().map(|id| id.as_uuid()).collect();
let viewer = viewer_id.map(|v| v.as_uuid());
let total: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM thoughts t WHERE t.user_id=ANY($1) AND t.visibility != 'direct'",
)
.bind(&ids)
.fetch_one(&self.pool)
.await
.map_err(|e| DomainError::Internal(e.to_string()))?;
let sel = feed_select(viewer);
let sql = format!("{sel} WHERE t.user_id=ANY($1) AND t.visibility != 'direct' ORDER BY t.created_at DESC LIMIT $2 OFFSET $3");
let rows = sqlx::query_as::<_, FeedRow>(&sql)
.bind(&ids)
.bind(page.limit())
.bind(page.offset())
.fetch_all(&self.pool)
.await
.map_err(|e| DomainError::Internal(e.to_string()))?;
Ok(Paginated {
items: rows.into_iter().map(row_to_entry).collect(),
total,
page: page.page,
per_page: page.per_page,
})
}
async fn public_feed(
&self,
page: &PageParams,
viewer_id: Option<&UserId>,
) -> Result<Paginated<FeedEntry>, DomainError> {
let viewer = viewer_id.map(|v| v.as_uuid());
let total: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM thoughts t WHERE t.local=true AND t.visibility='public'",
)
.fetch_one(&self.pool)
.await
.map_err(|e| DomainError::Internal(e.to_string()))?;
let sel = feed_select(viewer);
let sql = format!("{sel} WHERE t.local=true AND t.visibility='public' ORDER BY t.created_at DESC LIMIT $1 OFFSET $2");
let rows = sqlx::query_as::<_, FeedRow>(&sql)
.bind(page.limit())
.bind(page.offset())
.fetch_all(&self.pool)
.await
.map_err(|e| DomainError::Internal(e.to_string()))?;
Ok(Paginated {
items: rows.into_iter().map(row_to_entry).collect(),
total,
page: page.page,
per_page: page.per_page,
})
}
async fn search(
&self,
query: &str,
page: &PageParams,
viewer_id: Option<&UserId>,
) -> Result<Paginated<FeedEntry>, DomainError> {
let viewer = viewer_id.map(|v| v.as_uuid());
let total: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM thoughts t WHERE t.content % $1 AND t.visibility='public'",
)
.bind(query)
.fetch_one(&self.pool)
.await
.map_err(|e| DomainError::Internal(e.to_string()))?;
let sel = feed_select(viewer);
let sql = format!("{sel} WHERE t.content % $1 AND t.visibility='public' ORDER BY similarity(t.content, $1) DESC LIMIT $2 OFFSET $3");
let rows = sqlx::query_as::<_, FeedRow>(&sql)
.bind(query)
.bind(page.limit())
.bind(page.offset())
.fetch_all(&self.pool)
.await
.map_err(|e| DomainError::Internal(e.to_string()))?;
Ok(Paginated {
items: rows.into_iter().map(row_to_entry).collect(),
total,
page: page.page,
per_page: page.per_page,
})
}
async fn tag_feed(
&self,
tag_name: &str,
page: &PageParams,
viewer_id: Option<&UserId>,
) -> Result<Paginated<FeedEntry>, DomainError> {
let viewer = viewer_id.map(|v| v.as_uuid());
let total: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM thoughts t
JOIN thought_tags tt ON tt.thought_id = t.id
JOIN tags tg ON tg.id = tt.tag_id
WHERE tg.name = $1 AND t.visibility = 'public'",
)
.bind(tag_name)
.fetch_one(&self.pool)
.await
.map_err(|e| DomainError::Internal(e.to_string()))?;
let sel = feed_select(viewer);
let sql = format!(
"{sel}
JOIN thought_tags tt ON tt.thought_id = t.id
JOIN tags tg ON tg.id = tt.tag_id
WHERE tg.name = $1 AND t.visibility = 'public'
ORDER BY t.created_at DESC LIMIT $2 OFFSET $3"
);
let rows = sqlx::query_as::<_, FeedRow>(&sql)
.bind(tag_name)
.bind(page.limit())
.bind(page.offset())
.fetch_all(&self.pool)
.await
.map_err(|e| DomainError::Internal(e.to_string()))?;
Ok(Paginated {
items: rows.into_iter().map(row_to_entry).collect(),
total,
page: page.page,
per_page: page.per_page,
})
}
async fn user_feed(
&self,
user_id: &UserId,
page: &PageParams,
viewer_id: Option<&UserId>,
) -> Result<Paginated<FeedEntry>, DomainError> {
let viewer = viewer_id.map(|v| v.as_uuid());
let uid = user_id.as_uuid();
// Use nil UUID for unauthenticated viewers — won't match owner or follower checks.
let viewer_uuid = viewer.unwrap_or(uuid::Uuid::nil());
let total: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM thoughts t WHERE t.user_id = $1 AND ($2::uuid = $1 OR (t.visibility != 'direct' AND (t.visibility IN ('public', 'unlisted') OR (t.visibility = 'followers' AND EXISTS(SELECT 1 FROM follows WHERE follower_id = $2 AND following_id = $1 AND state = 'accepted')))))",
)
.bind(uid)
.bind(viewer_uuid)
.fetch_one(&self.pool)
.await
.map_err(|e| DomainError::Internal(e.to_string()))?;
let sel = feed_select(viewer);
let sql = format!("{sel} WHERE t.user_id = $1 AND ($4::uuid = $1 OR (t.visibility != 'direct' AND (t.visibility IN ('public', 'unlisted') OR (t.visibility = 'followers' AND EXISTS(SELECT 1 FROM follows WHERE follower_id = $4 AND following_id = $1 AND state = 'accepted'))))) ORDER BY t.created_at DESC LIMIT $2 OFFSET $3");
let rows = sqlx::query_as::<_, FeedRow>(&sql)
.bind(uid)
.bind(page.limit())
.bind(page.offset())
.bind(viewer_uuid)
.fetch_all(&self.pool)
.await
.map_err(|e| DomainError::Internal(e.to_string()))?;
Ok(Paginated {
items: rows.into_iter().map(row_to_entry).collect(),
total,
page: page.page,
per_page: page.per_page,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{thought::PgThoughtRepository, user::PgUserRepository};
use domain::{
models::{
thought::{Thought, Visibility},
user::User,
},
ports::{ThoughtRepository, UserRepository},
value_objects::*,
};
async fn seed(pool: &sqlx::PgPool, username: &str, content: &str) -> (User, Thought) {
let urepo = PgUserRepository::new(pool.clone());
let trepo = PgThoughtRepository::new(pool.clone());
let u = User::new_local(
UserId::new(),
Username::new(username).unwrap(),
Email::new(format!("{username}@ex.com")).unwrap(),
PasswordHash("h".into()),
);
urepo.save(&u).await.unwrap();
let t = Thought::new_local(
ThoughtId::new(),
u.id.clone(),
Content::new_local(content).unwrap(),
None,
Visibility::Public,
None,
false,
);
trepo.save(&t).await.unwrap();
(u, t)
}
#[sqlx::test(migrations = "./migrations")]
async fn public_feed_returns_local_thoughts(pool: sqlx::PgPool) {
let (_, _) = seed(&pool, "alice", "hello").await;
let repo = PgFeedRepository::new(pool);
let result = repo
.public_feed(
&PageParams {
page: 1,
per_page: 20,
},
None,
)
.await
.unwrap();
assert_eq!(result.total, 1);
assert_eq!(result.items[0].thought.content.as_str(), "hello");
}
#[sqlx::test(migrations = "./migrations")]
async fn search_returns_matching_thoughts(pool: sqlx::PgPool) {
let (_, _) = seed(&pool, "alice", "hello world").await;
let (_, _) = seed(&pool, "bob", "goodbye world").await;
let repo = PgFeedRepository::new(pool);
let result = repo
.search(
"hello world",
&PageParams {
page: 1,
per_page: 20,
},
None,
)
.await
.unwrap();
assert!(result.total >= 1);
assert!(result
.items
.iter()
.any(|e| e.thought.content.as_str() == "hello world"));
}
}

View File

@@ -1,420 +0,0 @@
use crate::db_error::IntoDbResult;
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use domain::{
errors::DomainError,
models::{
feed::{FeedEntry, Paginated},
thought::{Thought, Visibility},
user::User,
},
ports::{FeedOptions, FeedRepository, FeedRequest, FeedScope, FeedSort},
value_objects::{Content, ThoughtId, UserId},
};
use sqlx::PgPool;
pub struct PgFeedRepository {
pool: PgPool,
}
impl PgFeedRepository {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
}
#[derive(sqlx::FromRow)]
struct FeedRow {
thought_id: uuid::Uuid,
t_user_id: uuid::Uuid,
content: String,
in_reply_to_id: Option<uuid::Uuid>,
visibility: String,
content_warning: Option<String>,
sensitive: bool,
t_local: bool,
thought_created_at: DateTime<Utc>,
thought_updated_at: Option<DateTime<Utc>>,
note_extensions: Option<serde_json::Value>,
mood: Option<String>,
#[sqlx(flatten)]
author: crate::user::UserRow,
like_count: i64,
boost_count: i64,
reply_count: i64,
liked_by_viewer: bool,
boosted_by_viewer: bool,
}
fn row_to_entry(r: FeedRow, viewer: Option<uuid::Uuid>) -> Result<FeedEntry, DomainError> {
let thought = Thought {
id: ThoughtId::from_uuid(r.thought_id),
user_id: UserId::from_uuid(r.t_user_id),
content: Content::new_remote(r.content),
in_reply_to_id: r.in_reply_to_id.map(ThoughtId::from_uuid),
visibility: Visibility::from_db_str(&r.visibility)?,
content_warning: r.content_warning,
sensitive: r.sensitive,
local: r.t_local,
created_at: r.thought_created_at,
updated_at: r.thought_updated_at,
note_extensions: r.note_extensions,
mood: r.mood,
};
let author = User::from(r.author);
Ok(FeedEntry {
thought,
author,
stats: domain::models::feed::EngagementStats {
like_count: r.like_count,
boost_count: r.boost_count,
reply_count: r.reply_count,
},
viewer: viewer.map(|_| domain::models::feed::ViewerContext {
liked: r.liked_by_viewer,
boosted: r.boosted_by_viewer,
}),
})
}
struct FeedSqlBuilder<'a> {
options: &'a FeedOptions,
scope: &'a FeedScope,
viewer: Option<uuid::Uuid>,
}
impl<'a> FeedSqlBuilder<'a> {
fn new(options: &'a FeedOptions, scope: &'a FeedScope, viewer: Option<uuid::Uuid>) -> Self {
Self {
options,
scope,
viewer,
}
}
fn select(&self, viewer_param: &str) -> String {
let (viewer_cols, viewer_joins) = match self.viewer {
Some(_) => (
"(lv.thought_id IS NOT NULL) AS liked_by_viewer,
(bv.thought_id IS NOT NULL) AS boosted_by_viewer".to_string(),
format!(
"LEFT JOIN (SELECT thought_id FROM likes WHERE user_id={viewer_param}) lv ON lv.thought_id = t.id
LEFT JOIN (SELECT thought_id FROM boosts WHERE user_id={viewer_param}) bv ON bv.thought_id = t.id"
),
),
None => (
"false AS liked_by_viewer, false AS boosted_by_viewer".to_string(),
String::new(),
),
};
format!(
"
SELECT
t.id AS thought_id, t.user_id AS t_user_id, t.content,
t.in_reply_to_id,
t.visibility, t.content_warning, t.sensitive, t.local AS t_local,
t.created_at AS thought_created_at, t.updated_at AS thought_updated_at,
t.note_extensions, t.mood,
u.id,
CASE WHEN NOT u.local AND ra.handle IS NOT NULL AND ra.handle != ''
THEN '@' || ra.handle ||
CASE WHEN ra.handle NOT LIKE '%@%'
THEN '@' || SPLIT_PART(ra.url, '/', 3)
ELSE '' END
ELSE u.username END AS username,
u.email, u.password_hash,
COALESCE(ra.display_name, u.display_name) AS display_name,
u.bio,
COALESCE(ra.avatar_url, u.avatar_url) AS avatar_url,
u.header_url, u.custom_css, u.profile_fields, u.custom_moods,
u.local,
u.created_at, u.updated_at,
COALESCE(l_agg.cnt, 0) AS like_count,
COALESCE(b_agg.cnt, 0) AS boost_count,
COALESCE(r_agg.cnt, 0) AS reply_count,
{viewer_cols}
FROM thoughts t
JOIN users u ON u.id=t.user_id
LEFT JOIN remote_actors ra ON u.ap_id = ra.url
LEFT JOIN (SELECT thought_id, COUNT(*) AS cnt FROM likes GROUP BY thought_id) l_agg ON l_agg.thought_id = t.id
LEFT JOIN (SELECT thought_id, COUNT(*) AS cnt FROM boosts GROUP BY thought_id) b_agg ON b_agg.thought_id = t.id
LEFT JOIN (SELECT in_reply_to_id, COUNT(*) AS cnt FROM thoughts WHERE in_reply_to_id IS NOT NULL GROUP BY in_reply_to_id) r_agg ON r_agg.in_reply_to_id = t.id
{viewer_joins}"
)
}
fn fed_clause(&self, viewer_param: &str) -> String {
match self.viewer {
Some(_) => format!(
" OR t.user_id IN (
SELECT u2.id FROM users u2
JOIN federation_following ff ON u2.ap_id = ff.remote_actor_url
WHERE ff.local_user_id = {viewer_param}
)"
),
None => String::new(),
}
}
fn filter_sql(&self) -> String {
let f = &self.options.filter;
let mut s = String::new();
if f.originals_only {
s += " AND t.in_reply_to_id IS NULL";
}
if f.replies_only {
s += " AND t.in_reply_to_id IS NOT NULL";
}
if f.local_only {
s += " AND t.local = true";
}
if f.hide_sensitive {
s += " AND t.sensitive = false";
}
s
}
fn order_sql(&self) -> &'static str {
if matches!(self.scope, FeedScope::Search { .. }) {
return "ORDER BY similarity(t.content, $1) DESC";
}
match &self.options.sort {
FeedSort::Newest => "ORDER BY t.created_at DESC",
FeedSort::Oldest => "ORDER BY t.created_at ASC",
FeedSort::MostLiked => "ORDER BY like_count DESC, t.created_at DESC",
FeedSort::MostBoosted => "ORDER BY boost_count DESC, t.created_at DESC",
FeedSort::MostDiscussed => "ORDER BY reply_count DESC, t.created_at DESC",
}
}
fn public(&self) -> (String, String) {
let filter = self.filter_sql();
let order = self.order_sql();
let count = format!(
"SELECT COUNT(*) FROM thoughts t WHERE t.local=true AND t.visibility='public'{}",
filter
);
let data = format!(
"{} WHERE t.local=true AND t.visibility='public'{} {} LIMIT $1 OFFSET $2",
self.select("$3"),
filter,
order
);
(count, data)
}
fn home(&self) -> (String, String) {
let filter = self.filter_sql();
let order = self.order_sql();
let count = format!(
"SELECT COUNT(*) FROM thoughts t WHERE (t.user_id=ANY($1){}) AND t.visibility != 'direct'{}",
self.fed_clause("$2"), filter
);
let data =
format!(
"{} WHERE (t.user_id=ANY($1){}) AND t.visibility != 'direct'{} {} LIMIT $2 OFFSET $3",
self.select("$4"), self.fed_clause("$4"), filter, order
);
(count, data)
}
fn search(&self) -> (String, String) {
let filter = self.filter_sql();
let order = self.order_sql();
let count = format!(
"SELECT COUNT(*) FROM thoughts t WHERE t.content % $1 AND t.visibility='public'{}",
filter
);
let data = format!(
"{} WHERE t.content % $1 AND t.visibility='public'{} {} LIMIT $2 OFFSET $3",
self.select("$4"),
filter,
order
);
(count, data)
}
fn tag(&self) -> (String, String) {
let filter = self.filter_sql();
let order = self.order_sql();
let count = format!(
"SELECT COUNT(*) FROM thoughts t
JOIN thought_tags tt ON tt.thought_id = t.id
JOIN tags tg ON tg.id = tt.tag_id
WHERE tg.name = $1 AND t.visibility = 'public'{}",
filter
);
let data = format!(
"{}
JOIN thought_tags tt ON tt.thought_id = t.id
JOIN tags tg ON tg.id = tt.tag_id
WHERE tg.name = $1 AND t.visibility = 'public'{} {} LIMIT $2 OFFSET $3",
self.select("$4"),
filter,
order
);
(count, data)
}
fn user(&self) -> (String, String) {
let filter = self.filter_sql();
let order = self.order_sql();
let count = format!(
"SELECT COUNT(*) FROM thoughts t WHERE t.user_id = $1 AND ($2::uuid = $1 OR (t.visibility != 'direct' AND (t.visibility IN ('public', 'unlisted') OR (t.visibility = 'followers' AND EXISTS(SELECT 1 FROM follows WHERE follower_id = $2 AND following_id = $1 AND state = 'accepted'))))){}",
filter
);
let data = format!(
"{} WHERE t.user_id = $1 AND ($4::uuid = $1 OR (t.visibility != 'direct' AND (t.visibility IN ('public', 'unlisted') OR (t.visibility = 'followers' AND EXISTS(SELECT 1 FROM follows WHERE follower_id = $4 AND following_id = $1 AND state = 'accepted'))))){} {} LIMIT $2 OFFSET $3",
self.select("$4"), filter, order
);
(count, data)
}
}
#[async_trait]
impl FeedRepository for PgFeedRepository {
async fn query(&self, req: &FeedRequest) -> Result<Paginated<FeedEntry>, DomainError> {
let viewer = req.query.viewer_id.as_ref().map(|v| v.as_uuid());
let page = &req.query.page;
let builder = FeedSqlBuilder::new(&req.options, &req.query.scope, viewer);
let viewer_uuid = viewer.unwrap_or(uuid::Uuid::nil());
match &req.query.scope {
FeedScope::Home { following_ids } => {
let ids: Vec<uuid::Uuid> = following_ids.iter().map(|id| id.as_uuid()).collect();
let (count_sql, data_sql) = builder.home();
let total: i64 = sqlx::query_scalar(&count_sql)
.bind(&ids)
.bind(viewer_uuid)
.fetch_one(&self.pool)
.await
.into_domain()?;
let rows = sqlx::query_as::<_, FeedRow>(&data_sql)
.bind(&ids)
.bind(page.limit())
.bind(page.offset())
.bind(viewer_uuid)
.fetch_all(&self.pool)
.await
.into_domain()?;
Ok(Paginated {
items: rows
.into_iter()
.map(|r| row_to_entry(r, viewer))
.collect::<Result<Vec<_>, _>>()?,
total,
page: page.page,
per_page: page.per_page,
})
}
FeedScope::Public => {
let (count_sql, data_sql) = builder.public();
let total: i64 = sqlx::query_scalar(&count_sql)
.fetch_one(&self.pool)
.await
.into_domain()?;
let rows = sqlx::query_as::<_, FeedRow>(&data_sql)
.bind(page.limit())
.bind(page.offset())
.bind(viewer_uuid)
.fetch_all(&self.pool)
.await
.into_domain()?;
Ok(Paginated {
items: rows
.into_iter()
.map(|r| row_to_entry(r, viewer))
.collect::<Result<Vec<_>, _>>()?,
total,
page: page.page,
per_page: page.per_page,
})
}
FeedScope::Search { query } => {
let (count_sql, data_sql) = builder.search();
let total: i64 = sqlx::query_scalar(&count_sql)
.bind(query)
.fetch_one(&self.pool)
.await
.into_domain()?;
let rows = sqlx::query_as::<_, FeedRow>(&data_sql)
.bind(query)
.bind(page.limit())
.bind(page.offset())
.bind(viewer_uuid)
.fetch_all(&self.pool)
.await
.into_domain()?;
Ok(Paginated {
items: rows
.into_iter()
.map(|r| row_to_entry(r, viewer))
.collect::<Result<Vec<_>, _>>()?,
total,
page: page.page,
per_page: page.per_page,
})
}
FeedScope::Tag { tag_name } => {
let (count_sql, data_sql) = builder.tag();
let total: i64 = sqlx::query_scalar(&count_sql)
.bind(tag_name)
.fetch_one(&self.pool)
.await
.into_domain()?;
let rows = sqlx::query_as::<_, FeedRow>(&data_sql)
.bind(tag_name)
.bind(page.limit())
.bind(page.offset())
.bind(viewer_uuid)
.fetch_all(&self.pool)
.await
.into_domain()?;
Ok(Paginated {
items: rows
.into_iter()
.map(|r| row_to_entry(r, viewer))
.collect::<Result<Vec<_>, _>>()?,
total,
page: page.page,
per_page: page.per_page,
})
}
FeedScope::User { user_id } => {
let uid = user_id.as_uuid();
let (count_sql, data_sql) = builder.user();
let total: i64 = sqlx::query_scalar(&count_sql)
.bind(uid)
.bind(viewer_uuid)
.fetch_one(&self.pool)
.await
.into_domain()?;
let rows = sqlx::query_as::<_, FeedRow>(&data_sql)
.bind(uid)
.bind(page.limit())
.bind(page.offset())
.bind(viewer_uuid)
.fetch_all(&self.pool)
.await
.into_domain()?;
Ok(Paginated {
items: rows
.into_iter()
.map(|r| row_to_entry(r, viewer))
.collect::<Result<Vec<_>, _>>()?,
total,
page: page.page,
per_page: page.per_page,
})
}
}
}
}
#[cfg(test)]
mod tests;

Some files were not shown because too many files have changed in this diff Show More