Compare commits

..

132 Commits

Author SHA1 Message Date
8f69cfb011 refactor: improve code formatting and structure in ThoughtForm component
All checks were successful
lint / lint (push) Successful in 14m0s
test / unit (push) Successful in 15m54s
2026-06-05 17:47:15 +02:00
9aea5c1bd9 docs: rewrite architecture diagram as mermaid
All checks were successful
lint / lint (push) Successful in 14m6s
test / unit (push) Successful in 15m45s
2026-06-04 23:48:49 +02:00
4d6df1ea60 docs: add architecture diagram
Some checks failed
test / unit (push) Has been cancelled
lint / lint (push) Has been cancelled
2026-06-04 23:48:04 +02:00
5a65fda0bc refactor: move federation port types from adapter to domain
Some checks failed
test / unit (push) Has been cancelled
lint / lint (push) Has been cancelled
ActivityPubRepository→FederationContentRepository,
OutboundFederationPort→FederationBroadcastPort,
ActorApUrls→ActorFederationUrls.

Removes activitypub dep from application and presentation crates.
Adapter re-exports old names as aliases for backward compat.
Also fixes count_users test broken by instance actor migration.
2026-06-04 23:44:01 +02:00
6dbd4dafdc fix: persist note_extensions on AP Update activity
Some checks failed
lint / lint (push) Has been cancelled
test / unit (push) Has been cancelled
on_update was discarding custom fields (posterUrl, movieTitle, etc),
so remote notes from movies-diary lost posters after Update delivery.
2026-06-04 23:28:58 +02:00
90d13c883b feat: replace instance actor decorator with real DB row
All checks were successful
lint / lint (push) Successful in 14m31s
test / unit (push) Successful in 15m58s
Migration inserts a service actor with a well-known UUID. Removes the
InstanceActorUserRepo wrapper — the real ApUserRepository finds it.
2026-05-30 03:16:01 +02:00
0c8fa01ab9 feat: add instance actor for signed fetch (Secure Mode support)
Some checks failed
lint / lint (push) Has been cancelled
test / unit (push) Has been cancelled
2026-05-30 03:11:15 +02:00
78daca0377 chore: upgrade k-ap to 0.4.0
Some checks failed
test / unit (push) Has been cancelled
lint / lint (push) Has been cancelled
Map new fetched_at field in RemoteActor, read last_fetched_at from DB.
2026-05-30 03:00:43 +02:00
3357484bbf fix: extract CallLog type alias to satisfy clippy type_complexity
All checks were successful
lint / lint (push) Successful in 14m20s
test / unit (push) Successful in 16m15s
2026-05-29 21:07:48 +02:00
442a61bbdb feat: add optional mood to thoughts with custom moods support
Some checks failed
lint / lint (push) Failing after 9m28s
test / unit (push) Successful in 16m8s
Mood is an optional label+emoji string (e.g. "relaxed 😌") on thoughts.
Users can define up to 8 custom moods in profile settings.
Mood federates via AP Note JSON and displays on thought cards.
2026-05-29 15:38:35 +02:00
be27fe04e2 docs: sync README with actual features, fix default port to 8000
Some checks failed
lint / lint (push) Failing after 9m25s
test / unit (push) Successful in 16m42s
- Add missing features: profile fields, custom CSS, visibility
  levels, CW/sensitive, feed sort/filter, popular tags, account
  migration
- Fix top friends limit: 5→8
- Default PORT 3000→8000 in code, README, and .env.example
- Deduplicate frontend env docs, update contributing section
2026-05-29 14:31:17 +02:00
6040cf1e53 docs: remove outdated Movies-Diary integration documentation
Some checks failed
lint / lint (push) Has been cancelled
test / unit (push) Has been cancelled
2026-05-29 14:29:44 +02:00
0b74344efe docs: fix DX for new contributors
Some checks failed
test / unit (push) Has been cancelled
lint / lint (push) Has been cancelled
- Fix port mismatch: Dockerfile EXPOSE 8000, .env.example PORT=8000,
  compose.yml gets explicit PORT=8000
- Add thoughts-frontend/.env.example with all required vars
- Document NEXT_PUBLIC_FEDIVERSE_DOMAIN in README
- Document private cargo registry (k-ap on Gitea)
- Add local dev workflow: make dev-infra → cargo run → bun dev
- Split make targets: test-unit (no DB), test-integration, up
2026-05-29 14:27:42 +02:00
6d0b1a3121 refactor: eliminate User/UserResponse struct literals, add AP user tests
- Feed/search adapters use #[sqlx(flatten)] UserRow instead of
  inline User construction — single point of change when User
  gains fields
- User::new_remote constructor replaces struct literal in testing
- to_summary_response replaces inline UserResponse in get_users
- 5 integration tests for PgApUserRepository (find, count,
  profile_fields→attachment)
2026-05-29 14:17:41 +02:00
020a79704f refactor: dedup JSONB name/value helpers, add profile fields validation
Some checks failed
lint / lint (push) Failing after 9m29s
test / unit (push) Has been cancelled
Extract parse/serialize into postgres::jsonb, used by user,
remote_actor, and postgres-federation. Validate profile fields
in update_profile use case (max 4, name≤64, value≤256).
2026-05-29 13:59:39 +02:00
805bd9534f feat: add profile fields for local users
DB→domain→API→AP→frontend end-to-end. Fields stored as
JSONB, exposed via PATCH /users/me, serialized as AP
PropertyValue attachment. Editor in federation settings,
display on profile card.
2026-05-29 13:54:25 +02:00
14a869cc8d fix(federation): convert remote actor posts handler to PagedResponse
Some checks failed
lint / lint (push) Failing after 9m36s
test / unit (push) Successful in 16m25s
missed in prior refactor — was still returning snake_case per_page via json!
2026-05-29 12:23:28 +02:00
6abd2e7aad fix(frontend): align Zod schemas with camelCase API responses
Some checks failed
lint / lint (push) Has been cancelled
test / unit (push) Has been cancelled
per_page → perPage in paginated response schemas, drop tag field from tag endpoint
2026-05-29 12:15:07 +02:00
9798a1d829 refactor: type safety + dedup cleanup across 13 code smells
Some checks failed
test / unit (push) Has been cancelled
lint / lint (push) Has been cancelled
- typed PagedResponse/CreatedApiKeyResponse/NotificationSummaryResponse replace json! blocks
- extract TagRow/ApiKeyRow/OutboxRow to module level, top_friend uses sqlx flatten
- add should_broadcast() helper, inline dead let bindings in federation_event
- add UploadContext struct, extract_upload_field, wants_activity_json helpers
- rename PostgresFederationRepository→PgFederationRepository, PostgresApUserRepository→PgApUserRepository
- add IntoAnyhow trait replacing ~30 .map_err(|e| anyhow!(e)) calls
- extract build_ap_service shared between bootstrap and worker factories
- add postgres/constants.rs, PartialEq+Eq on PasswordHash
2026-05-29 12:02:03 +02:00
84edf58de6 fix(federation): fix 27 AP bugs, gaps, and inconsistencies
Some checks failed
lint / lint (push) Failing after 9m26s
test / unit (push) Successful in 16m3s
Round 1 — 18 bug fixes:
- remote likes/boosts now persist in engagement tables
- intern_remote_actor uses name@domain, expanded username to VARCHAR(255)
- PgRemoteActorRepository upsert/find now handles all fields
- update_following_status no longer a no-op, count_followers counts all
- accept/reject follow publishes event before DB mark (atomicity)
- fetch_outbox_page follows pagination via next links
- actor URL canonicalized to /users/{uuid}
- content_to_html escapes single quotes
- WebFinger accepts application/ld+json type
- try_from_ap accepts Article and Page object types
- feed SQL uses parameterized viewer UUID instead of format!
- content cap raised from 500 to 5000 chars
- also_known_as changed from Option<String> to Vec<String>
- connections fetch always triggers from page 1

Round 2 — 9 gap fixes:
- on_announce_removed handler deletes boost row on Undo(Announce)
- on_update handles Person/Service/Group actor profile updates
- sync_remote_actor_to_user syncs remote_actors → users on create/update
- FederationBlockPort: block_by_username sends Block activity to remote
- domain RemoteActor gains inbox_url, shared_inbox_url fields
- remote_actors attachment column (JSONB) with read/write
- .well-known/host-meta endpoint
- 256KB body limit on AP inbox routes
- outbox cleanup job (7-day retention, hourly sweep)
2026-05-29 11:28:40 +02:00
f9de21dcfa refactor(tests): remove unused value_objects imports from test files
Some checks failed
lint / lint (push) Failing after 9m20s
test / unit (push) Successful in 16m13s
2026-05-29 10:18:18 +02:00
79f1e63bb8 perf(feed): replace correlated subqueries with LEFT JOIN aggregations
Some checks failed
lint / lint (push) Failing after 9m14s
test / unit (push) Successful in 16m3s
Feed queries ran 5 correlated subqueries per row (3 COUNT + 2 EXISTS
for engagement counts and viewer context). Replaced with LEFT JOIN
aggregations computed once per query. Adds migration 016 with indexes
on likes(thought_id), boosts(thought_id), thoughts(in_reply_to_id),
and compound viewer-context indexes — expected to drop ~3s queries to
<100ms on typical page sizes.

Also removes WebFinger from the footer (requires query params, zero
standalone value as a link).
2026-05-29 04:35:32 +02:00
fc806f82a4 feat: add Footer component with navigation links
Some checks failed
lint / lint (push) Has been cancelled
test / unit (push) Has been cancelled
2026-05-29 04:30:36 +02:00
bcd86fbfe7 refactor: extract handler business logic into application use cases
Some checks failed
test / unit (push) Has been cancelled
lint / lint (push) Has been cancelled
11 handlers were calling repos/ports directly, bypassing the
application layer. Extracted into proper use cases:

feed: get_public_feed, get_user_feed, get_tag_feed, get_popular_tags
profile: get_user_profile (with follow check), list_users,
         count_local_users, list_local_followers, list_local_following
federation_management: set_also_known_as

Also registers 9 previously undocumented handlers in OpenAPI modules.
2026-05-29 04:22:43 +02:00
5b4b747dd7 feat(federation): enrich RemoteActor with bio, banner, followers/following URLs
Some checks failed
lint / lint (push) Failing after 9m25s
test / unit (push) Has been cancelled
Bumps k-ap to 0.3.1 which adds bio, banner_url, followers_url,
following_url, and also_known_as to RemoteActor. These are populated
from fetched actor JSON at follow/ingest time so followers and following
listings no longer return null profile fields.

Adds migration 015 for the new remote_actors columns, updates all JOIN
queries in postgres-federation to select and return the new fields, and
maps them through k_ap_actor_to_domain.

Also fixes clippy: remove empty line after doc comment in port.rs.
2026-05-29 04:07:52 +02:00
bd370776fe refactor: simplify function signatures and improve code readability
Some checks failed
lint / lint (push) Failing after 9m6s
test / unit (push) Has been cancelled
2026-05-29 03:47:29 +02:00
ecb61f9b8f feat: add federation processed activities table and update dependencies
Some checks failed
test / unit (push) Has been cancelled
lint / lint (push) Has been cancelled
- Created a new SQL migration to add the `federation_processed_activities` table with an index on `processed_at`.
- Updated dependencies in `Cargo.toml` files across `bootstrap` and `worker` crates, including version updates for `k-ap`.
- Enhanced the event publishing mechanism in the `factory.rs` file to include a new `KapPublisher` for handling federation events.
- Refactored the `build` function in `factory.rs` to accommodate the new event publisher and improve ActivityPub service initialization.
- Modified the worker's main loop to handle new federation event types and improved error handling for event processing.

Co-authored-by: Copilot <copilot@github.com>
2026-05-29 03:47:06 +02:00
37d03a06dd docs(openapi): fix schema registration for federation actors and management
Some checks failed
lint / lint (push) Failing after 8m56s
test / unit (push) Successful in 16m29s
2026-05-29 02:09:16 +02:00
55e5bcc2bb docs(openapi): register federation management and actors doc modules 2026-05-29 02:08:08 +02:00
ac26eaca6b docs(openapi): annotate federation actors handlers and add doc module 2026-05-29 02:06:40 +02:00
86d0497509 docs(openapi): annotate all federation management handlers and add doc module 2026-05-29 02:04:59 +02:00
989004dd74 docs(openapi): annotate all missing user handlers 2026-05-29 02:02:31 +02:00
64cc11c2a1 docs(openapi): annotate get_followers, get_following, get_popular_tags handlers 2026-05-29 02:00:53 +02:00
01ef118b0a docs(openapi): add FeedOptionsQuery IntoParams and update feed annotations 2026-05-29 01:59:17 +02:00
4ab6da67c7 fix(frontend): fix instance badge overflow on narrow screens 2026-05-29 01:47:50 +02:00
dc75ac5f6c fix(frontend): prevent handle overflow in instance badge row 2026-05-29 01:44:03 +02:00
b14b8592a2 fix(frontend): add instance badge to remote author movie diary cards 2026-05-29 01:42:43 +02:00
4db7194838 fix(frontend): wider search input on large and 2K screens 2026-05-29 01:40:11 +02:00
c94b42cba8 feat(frontend): add follow request explanation to remote user card 2026-05-29 01:37:57 +02:00
1ad6f8ae8f feat(frontend): add fediverse handle format hints to search page 2026-05-29 01:36:53 +02:00
d76ff9dafb feat(frontend): add fediverse handle widget to feed sidebar 2026-05-29 01:35:36 +02:00
522ee9c1b1 feat(frontend): add instance badge to remote author posts in feed 2026-05-29 01:34:12 +02:00
00996327fb feat(frontend): add styled 404 and error pages 2026-05-29 01:20:32 +02:00
7ed639c9ea fix(frontend): remove emojis from landing page feature cards and badges 2026-05-29 01:14:29 +02:00
3ad609a793 fix(frontend): de-AI landing page copy, remove em dashes and manifesto tone 2026-05-29 01:12:29 +02:00
9849bb4991 fix(frontend): prevent iOS Safari auto-zoom on input focus 2026-05-29 01:10:50 +02:00
2199e5c66d fix(frontend): move search to header center on mobile, nav links in hamburger only 2026-05-29 01:08:57 +02:00
6e7bf05942 feat(frontend): add hamburger sheet menu for mobile nav 2026-05-29 01:07:27 +02:00
037217960e fix(frontend): place scroll indicator directly below hero card in flow 2026-05-29 01:03:59 +02:00
44b3a6de60 fix(frontend): move scroll indicator inside hero section so it anchors correctly 2026-05-29 01:02:43 +02:00
1fd46f3f2a feat(frontend): add animated scroll indicator to landing page hero 2026-05-29 01:01:29 +02:00
9c5d5518bb fix(frontend): add blur overlay on landing page for better text contrast 2026-05-29 01:00:02 +02:00
95ea633e78 fix(frontend): restore background image on landing page by removing gradient override 2026-05-29 00:59:07 +02:00
a97507cc15 feat(frontend): replace inline LandingPage with new multi-section component 2026-05-29 00:56:28 +02:00
858faddda9 feat(frontend): add LandingPage server component with all 5 sections 2026-05-29 00:54:59 +02:00
ea3a32ccaf feat(frontend): add LandingFeatures client component with scroll animation 2026-05-29 00:52:01 +02:00
8fad8eefa0 feat(frontend): add landing page CSS keyframes and utility classes 2026-05-29 00:50:44 +02:00
5a05968ae9 fix(frontend): rewrite FiltersSortingPanel with shadcn, correct styling, useTransition 2026-05-29 00:23:07 +02:00
8229285a2f refactor(postgres): format FeedSqlBuilder for improved readability 2026-05-29 00:13:55 +02:00
145b07d636 refactor(postgres): introduce FeedSqlBuilder to consolidate SQL construction 2026-05-29 00:11:07 +02:00
7991aef47b feat(frontend): wire FiltersSortingPanel into home feed with sort/filter params 2026-05-28 23:56:49 +02:00
ed6a4f9f72 feat(frontend): add FiltersSortingPanel client component 2026-05-28 23:55:04 +02:00
f815d71c32 feat(frontend): add FeedOptions type and update getFeed to support sort/filter params 2026-05-28 23:52:58 +02:00
0688ffe0ae feat(backend): wire FeedRequest/FeedOptions sort+filter through all feed layers 2026-05-28 23:45:46 +02:00
95728302b7 feat(domain): add FeedSort, FeedFilter, FeedOptions, FeedRequest CQRS query types 2026-05-28 23:39:35 +02:00
4d00d856c1 feat: swap TopFriends for ProfileFriendsWidget on profile page 2026-05-28 22:54:13 +02:00
a279988d39 feat: add ProfileFriendsWidget with top-friends/all-friends conditional 2026-05-28 22:52:56 +02:00
2f56839938 feat: add AllFriendsCard component for local and remote friends 2026-05-28 22:50:50 +02:00
2ffdd5e269 feat: show Friends nav link only when logged in 2026-05-28 22:47:53 +02:00
a73e7deeff remove legacy v1 backend
Some checks failed
lint / lint (push) Failing after 8m33s
test / unit (push) Successful in 16m10s
2026-05-28 22:28:59 +02:00
c5812100d5 feat: add "Friends" section to settings sidebar navigation 2026-05-28 22:28:48 +02:00
43e36c743b feat: add /friends page and /settings/friends top-friends management
Some checks failed
lint / lint (push) Failing after 8m39s
test / unit (push) Successful in 16m39s
2026-05-28 04:22:26 +02:00
e406464f9f feat(presentation): add GET /federation/me/friends handler and route
Some checks failed
lint / lint (push) Failing after 9m24s
test / unit (push) Successful in 16m41s
2026-05-28 03:48:59 +02:00
0e2b72b77a feat(presentation): add GET /users/me/friends handler and route 2026-05-28 03:46:52 +02:00
d4da172398 feat(application): add get_remote_friends use case 2026-05-28 03:44:41 +02:00
4e750420bf feat(application): add get_local_friends use case 2026-05-28 03:43:35 +02:00
e6330125be test(postgres): add list_mutual integration tests 2026-05-28 03:42:12 +02:00
14b7928026 fix: list_mutual sort by follow created_at, apply pagination in TestStore 2026-05-28 03:40:44 +02:00
a6a555e6a7 feat(domain): add list_mutual to FollowRepository, add remote actor storage to TestStore 2026-05-28 03:37:41 +02:00
4d4171a9c5 fix(clippy): remove unused PasswordHash import 2026-05-28 03:00:54 +02:00
75e6fe61ca fix: look up remote parent by ap_id to thread remote-to-remote replies 2026-05-28 02:59:45 +02:00
4f1b9a5cfb ci: remove failing integration tests job 2026-05-28 02:48:21 +02:00
d68c628335 refactor: delegate mark_follower_accepted/rejected through k-ap service, remove federation_repo from ApFederationAdapter 2026-05-28 02:45:59 +02:00
af5c4481b6 fix: extract initiate_actor_move use case — remove event publish from handler 2026-05-28 02:41:42 +02:00
5e3db44043 fix(tests): update federation_management tests for EventPublisher arg 2026-05-28 02:34:30 +02:00
915163aac4 feat: split accept/reject into DB+event; broadcast_move via event in API 2026-05-28 02:32:50 +02:00
a06d09c101 feat(worker): add FederationManagementHandler and wire into event loop 2026-05-28 02:30:22 +02:00
0dce4fbe64 feat(application): add FederationManagementEventService 2026-05-28 02:28:15 +02:00
9c93baaa39 fix(bootstrap,worker): pass shared federation_repo to ApFederationAdapter 2026-05-28 02:26:57 +02:00
a253efacec feat(activitypub): add federation_repo field and thin DB-only methods to ApFederationAdapter 2026-05-28 02:24:43 +02:00
04f39e35c2 feat(domain): add mark_follower_accepted/rejected thin port methods 2026-05-28 02:22:52 +02:00
2060317867 fix(event-payload): correct NATS subjects for federation events 2026-05-28 02:20:43 +02:00
e338254099 feat(domain): add RemoteFollowAccepted, RemoteFollowRejected, ActorMoved events 2026-05-28 02:19:46 +02:00
84c66dd461 feat: add alsoKnownAs field to federation settings 2026-05-28 02:01:56 +02:00
2445cad1c9 feat: add PATCH /federation/me/also-known-as endpoint
Adds alsoKnownAs column to users table (migration 013), reads it in
the AP actor JSON, and exposes PATCH /federation/me/also-known-as to
set or clear it. Required pre-condition for broadcast_move.
2026-05-28 01:59:35 +02:00
fc290dc18f fix: update user URL handling in ThoughtsObjectHandler to use user_id 2026-05-28 01:51:55 +02:00
43e5175db5 fix: add broadcast_move stub to TestStore 2026-05-28 01:50:37 +02:00
50a90efbce feat: add POST /federation/me/move endpoint 2026-05-28 01:47:29 +02:00
ff75361eb1 feat: bump k-ap to v0.1.9 and implement migrate_follower_actor 2026-05-28 01:43:06 +02:00
5ca5ad9561 feat: update k-ap dependency to v0.1.8 and enhance middleware for ActivityPub requests 2026-05-28 01:08:45 +02:00
f6893b19dc feat: update dependencies to k-ap v0.1.7 and add profileHref utility for user links 2026-05-27 23:38:14 +02:00
6f65742284 feat: add /about/fediverse info page with glass accordion panels 2026-05-27 23:38:14 +02:00
904dd5f1a0 feat: add Fediverse nav link 2026-05-27 23:38:14 +02:00
0164b03e5c feat: add copy handle button and fediverse info link to profile 2026-05-27 23:38:14 +02:00
0797dde39c fix: handle clipboard errors and cleanup timeout in CopyButton 2026-05-27 23:38:14 +02:00
7d2d597264 feat: add CopyButton client component 2026-05-27 23:38:14 +02:00
fe4960d30d docs: replace activitypub-base with k-ap in architecture overview
Reflects the migration from the local activitypub-base crate to the
external k-ap library, with an accurate description of what it provides.
2026-05-25 00:57:29 +02:00
5097c91261 feat: store AP note extensions in JSONB and render movies-diary posts as rich cards 2026-05-24 04:29:04 +02:00
f4932af2ba feat: custom CSS editor with CodeMirror, live preview, and /docs/css reference 2026-05-24 03:26:34 +02:00
fccc4064cf fix(federation): include header_url as AP banner (image) in actor JSON 2026-05-24 02:18:41 +02:00
01932cf337 feat: add image upload for avatar and banner 2026-05-24 02:06:47 +02:00
1874954ad7 fix: resolve thoughts compile errors after k-ap migration 2026-05-17 23:02:49 +02:00
f12cc7e2a7 chore: move ap_ports into activitypub adapter, delete activitypub-base 2026-05-17 22:48:22 +02:00
6936b7ce62 chore: switch activitypub-base to k-ap git dep 2026-05-17 22:47:32 +02:00
d56d34cc27 refactor: replace long arg lists with input/config structs and builder
- Thought::new_local → NewThought struct (7 args → 1)
- UserWriter::update_profile → UpdateProfileInput struct (6 args → 2)
- update_profile use case → UpdateProfileInput (8 args → 3)
- ActivityPubService::new → builder pattern (9 args → 5 required + 4 optional setters)
- accept_note → AcceptNoteInput struct (8 args → 1)
- ThoughtNote::new_public → ThoughtNoteInput struct (8 args → 1)

Remove all #[allow(clippy::too_many_arguments)] annotations.
2026-05-17 12:25:53 +02:00
2f5c89c381 clean up 2026-05-17 12:15:27 +02:00
2c2decba72 clean up 2026-05-17 12:14:45 +02:00
2d1044e5c3 chore: add pre-commit fmt+clippy hooks, fix clippy warnings 2026-05-17 12:09:24 +02:00
d813e59b5c fmt 2026-05-17 12:04:51 +02:00
54910c6459 fix: make ThoughtNote sensitive field optional (default false) 2026-05-17 12:02:58 +02:00
be0924d463 fix: promote worker event logs from debug to info 2026-05-17 12:02:13 +02:00
2c34eb44e4 fix: make ThoughtNote url field optional for AP compat 2026-05-17 11:57:10 +02:00
7dcdbb4551 feat: implement verify() for all stub activity handlers
Undo: inner activity actor must match Undo actor
Announce/Like/Block: verify_domains_match(activity_id, actor_url)
Add: attributedTo must match actor (same as Create/Update)
2026-05-17 11:55:17 +02:00
bb48819cad feat: implement federation post/connections backfill schedulers
schedule_actor_posts_fetch now spawns backfill_outbox in background,
fetching all pages of a remote outbox and persisting via accept_note.
schedule_connections_fetch follows AP collection next-links, resolves
profiles, and caches them in the DB. Both were no-ops ("deferred").

Add connections_repo field to ActivityPubService; wire both factories.
2026-05-17 11:49:53 +02:00
39f7d39232 feat: load more pagination for user profile thoughts 2026-05-16 15:21:18 +02:00
4a84c595d5 fix: route local users to /users/{username} in remote connection lists 2026-05-16 15:17:58 +02:00
f89a466fd9 feat: load more pagination for remote user posts 2026-05-16 15:14:53 +02:00
c180b1c1f5 fix: overflow-y scroll on html to prevent layout shift on dropdown open 2026-05-16 15:12:41 +02:00
a85cb2eee5 fix: break-all on fediverse handle to prevent overflow 2026-05-16 15:07:30 +02:00
7e2c5adffd fix: scrollbar-gutter stable to prevent bg flicker on dropdown open 2026-05-16 15:05:28 +02:00
82778c82dd fix: wrap background image in fixed div so it stays put on scroll 2026-05-16 15:03:41 +02:00
b02f3c73e3 feat: Frutiger Aero redesign — glass panels, Aero shimmer, interaction moments 2026-05-16 14:55:51 +02:00
281 changed files with 6012 additions and 13517 deletions

9
.cargo/config.toml Normal file
View File

@@ -0,0 +1,9 @@
[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

@@ -9,7 +9,7 @@ BASE_URL=http://localhost:3000
# Optional # Optional
HOST=0.0.0.0 HOST=0.0.0.0
PORT=3000 PORT=8000
# CORS — comma-separated allowed origins, or * for permissive (default: *) # CORS — comma-separated allowed origins, or * for permissive (default: *)
CORS_ORIGINS=* CORS_ORIGINS=*

View File

@@ -21,32 +21,3 @@ 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

1
.gitignore vendored
View File

@@ -1,4 +1,5 @@
.env .env
/.superpowers/
/target /target
/docs/superpowers/ /docs/superpowers/

164
ARCHITECTURE.md Normal file
View File

@@ -0,0 +1,164 @@
# 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

280
Cargo.lock generated
View File

@@ -6,7 +6,6 @@ version = 4
name = "activitypub" name = "activitypub"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"activitypub_federation",
"anyhow", "anyhow",
"async-trait", "async-trait",
"axum", "axum",
@@ -14,7 +13,7 @@ dependencies = [
"domain", "domain",
"futures", "futures",
"k-ap", "k-ap",
"reqwest 0.13.3", "reqwest 0.13.4",
"serde", "serde",
"serde_json", "serde_json",
"tokio", "tokio",
@@ -43,7 +42,7 @@ dependencies = [
"futures", "futures",
"futures-core", "futures-core",
"http 0.2.12", "http 0.2.12",
"http 1.4.0", "http 1.4.1",
"http-signature-normalization", "http-signature-normalization",
"http-signature-normalization-reqwest", "http-signature-normalization-reqwest",
"httpdate", "httpdate",
@@ -52,7 +51,7 @@ dependencies = [
"pin-project-lite", "pin-project-lite",
"rand 0.8.6", "rand 0.8.6",
"regex", "regex",
"reqwest 0.13.3", "reqwest 0.13.4",
"reqwest-middleware", "reqwest-middleware",
"rsa", "rsa",
"serde", "serde",
@@ -217,7 +216,7 @@ dependencies = [
"serde_json", "serde_json",
"serde_urlencoded", "serde_urlencoded",
"smallvec", "smallvec",
"socket2 0.6.3", "socket2 0.6.4",
"time", "time",
"tracing", "tracing",
"url", "url",
@@ -274,13 +273,13 @@ dependencies = [
name = "application" name = "application"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"activitypub",
"async-trait", "async-trait",
"bytes", "bytes",
"chrono", "chrono",
"domain", "domain",
"futures", "futures",
"hex", "hex",
"serde_json",
"sha2", "sha2",
"thiserror 2.0.18", "thiserror 2.0.18",
"tokio", "tokio",
@@ -426,9 +425,9 @@ dependencies = [
[[package]] [[package]]
name = "autocfg" name = "autocfg"
version = "1.5.0" version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]] [[package]]
name = "aws-lc-rs" name = "aws-lc-rs"
@@ -463,7 +462,7 @@ dependencies = [
"bytes", "bytes",
"form_urlencoded", "form_urlencoded",
"futures-util", "futures-util",
"http 1.4.0", "http 1.4.1",
"http-body", "http-body",
"http-body-util", "http-body-util",
"hyper", "hyper",
@@ -495,7 +494,7 @@ checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1"
dependencies = [ dependencies = [
"bytes", "bytes",
"futures-core", "futures-core",
"http 1.4.0", "http 1.4.1",
"http-body", "http-body",
"http-body-util", "http-body-util",
"mime", "mime",
@@ -584,6 +583,7 @@ name = "bootstrap"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"activitypub", "activitypub",
"anyhow",
"application", "application",
"async-nats", "async-nats",
"async-trait", "async-trait",
@@ -591,14 +591,16 @@ dependencies = [
"axum", "axum",
"domain", "domain",
"dotenvy", "dotenvy",
"event-payload",
"event-transport", "event-transport",
"http 1.4.0", "http 1.4.1",
"k-ap", "k-ap",
"nats", "nats",
"postgres", "postgres",
"postgres-federation", "postgres-federation",
"postgres-search", "postgres-search",
"presentation", "presentation",
"serde_json",
"sqlx", "sqlx",
"storage", "storage",
"tokio", "tokio",
@@ -610,9 +612,9 @@ dependencies = [
[[package]] [[package]]
name = "bumpalo" name = "bumpalo"
version = "3.20.2" version = "3.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
[[package]] [[package]]
name = "byteorder" name = "byteorder"
@@ -914,9 +916,9 @@ dependencies = [
[[package]] [[package]]
name = "dashmap" name = "dashmap"
version = "6.1.0" version = "6.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"crossbeam-utils", "crossbeam-utils",
@@ -1032,9 +1034,9 @@ dependencies = [
[[package]] [[package]]
name = "displaydoc" name = "displaydoc"
version = "0.2.5" version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@@ -1101,9 +1103,9 @@ dependencies = [
[[package]] [[package]]
name = "either" name = "either"
version = "1.15.0" version = "1.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e"
dependencies = [ dependencies = [
"serde", "serde",
] ]
@@ -1372,9 +1374,9 @@ checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
[[package]] [[package]]
name = "futures-timer" name = "futures-timer"
version = "3.0.3" version = "3.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24" checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968"
[[package]] [[package]]
name = "futures-util" name = "futures-util"
@@ -1478,7 +1480,7 @@ dependencies = [
"fnv", "fnv",
"futures-core", "futures-core",
"futures-sink", "futures-sink",
"http 1.4.0", "http 1.4.1",
"indexmap", "indexmap",
"slab", "slab",
"tokio", "tokio",
@@ -1581,9 +1583,9 @@ dependencies = [
[[package]] [[package]]
name = "http" name = "http"
version = "1.4.0" version = "1.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" checksum = "8be7462df143984c4598a256ef469b251d7d7f9e271135073e78fc535414f3d0"
dependencies = [ dependencies = [
"bytes", "bytes",
"itoa", "itoa",
@@ -1596,7 +1598,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184"
dependencies = [ dependencies = [
"bytes", "bytes",
"http 1.4.0", "http 1.4.1",
] ]
[[package]] [[package]]
@@ -1607,7 +1609,7 @@ checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a"
dependencies = [ dependencies = [
"bytes", "bytes",
"futures-core", "futures-core",
"http 1.4.0", "http 1.4.1",
"http-body", "http-body",
"pin-project-lite", "pin-project-lite",
] ]
@@ -1631,7 +1633,7 @@ dependencies = [
"base64", "base64",
"http-signature-normalization", "http-signature-normalization",
"httpdate", "httpdate",
"reqwest 0.13.3", "reqwest 0.13.4",
"reqwest-middleware", "reqwest-middleware",
"sha2", "sha2",
"tokio", "tokio",
@@ -1657,16 +1659,16 @@ checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424"
[[package]] [[package]]
name = "hyper" name = "hyper"
version = "1.9.0" version = "1.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" checksum = "eb92f162bf56536459fc83c79b974bb12837acfed43d6bc370a7916d0ae15ecc"
dependencies = [ dependencies = [
"atomic-waker", "atomic-waker",
"bytes", "bytes",
"futures-channel", "futures-channel",
"futures-core", "futures-core",
"h2", "h2",
"http 1.4.0", "http 1.4.1",
"http-body", "http-body",
"httparse", "httparse",
"httpdate", "httpdate",
@@ -1683,7 +1685,7 @@ version = "0.27.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f"
dependencies = [ dependencies = [
"http 1.4.0", "http 1.4.1",
"hyper", "hyper",
"hyper-util", "hyper-util",
"rustls", "rustls",
@@ -1716,14 +1718,14 @@ dependencies = [
"bytes", "bytes",
"futures-channel", "futures-channel",
"futures-util", "futures-util",
"http 1.4.0", "http 1.4.1",
"http-body", "http-body",
"hyper", "hyper",
"ipnet", "ipnet",
"libc", "libc",
"percent-encoding", "percent-encoding",
"pin-project-lite", "pin-project-lite",
"socket2 0.6.3", "socket2 0.6.4",
"system-configuration", "system-configuration",
"tokio", "tokio",
"tower-service", "tower-service",
@@ -1988,9 +1990,9 @@ dependencies = [
[[package]] [[package]]
name = "js-sys" name = "js-sys"
version = "0.3.98" version = "0.3.99"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"futures-util", "futures-util",
@@ -2015,8 +2017,9 @@ dependencies = [
[[package]] [[package]]
name = "k-ap" name = "k-ap"
version = "0.1.9" version = "0.4.0"
source = "git+https://git.gabrielkaszewski.dev/GKaszewski/k-ap.git?tag=v0.1.9#432f39cbb4f8d74255a1f614a9bb7c8bbfe11cde" source = "sparse+https://git.gabrielkaszewski.dev/api/packages/GKaszewski/cargo/"
checksum = "ccaa914953bfd45ea206e11826da8f61ce1fbe02f8fe0622880527046ad6ae24"
dependencies = [ dependencies = [
"activitypub_federation", "activitypub_federation",
"anyhow", "anyhow",
@@ -2025,13 +2028,14 @@ dependencies = [
"chrono", "chrono",
"enum_delegate", "enum_delegate",
"futures", "futures",
"reqwest 0.13.3", "reqwest 0.13.4",
"serde", "serde",
"serde_json", "serde_json",
"tokio", "tokio",
"tracing", "tracing",
"url", "url",
"uuid", "uuid",
"zeroize",
] ]
[[package]] [[package]]
@@ -2069,14 +2073,14 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
[[package]] [[package]]
name = "libredox" name = "libredox"
version = "0.1.16" version = "0.1.17"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3"
dependencies = [ dependencies = [
"bitflags", "bitflags",
"libc", "libc",
"plain", "plain",
"redox_syscall 0.7.5", "redox_syscall 0.8.0",
] ]
[[package]] [[package]]
@@ -2112,9 +2116,9 @@ dependencies = [
[[package]] [[package]]
name = "log" name = "log"
version = "0.4.29" version = "0.4.30"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5"
[[package]] [[package]]
name = "lru-slab" name = "lru-slab"
@@ -2149,9 +2153,9 @@ dependencies = [
[[package]] [[package]]
name = "memchr" name = "memchr"
version = "2.8.0" version = "2.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8"
[[package]] [[package]]
name = "mime" name = "mime"
@@ -2181,9 +2185,9 @@ dependencies = [
[[package]] [[package]]
name = "mio" name = "mio"
version = "1.2.0" version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda"
dependencies = [ dependencies = [
"libc", "libc",
"log", "log",
@@ -2220,7 +2224,7 @@ dependencies = [
"bytes", "bytes",
"encoding_rs", "encoding_rs",
"futures-util", "futures-util",
"http 1.4.0", "http 1.4.1",
"httparse", "httparse",
"memchr", "memchr",
"mime", "mime",
@@ -2318,9 +2322,9 @@ dependencies = [
[[package]] [[package]]
name = "num-conv" name = "num-conv"
version = "0.2.1" version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
[[package]] [[package]]
name = "num-integer" name = "num-integer"
@@ -2550,6 +2554,7 @@ dependencies = [
"async-trait", "async-trait",
"chrono", "chrono",
"k-ap", "k-ap",
"serde_json",
"sqlx", "sqlx",
"tokio", "tokio",
"tracing", "tracing",
@@ -2599,7 +2604,6 @@ dependencies = [
name = "presentation" name = "presentation"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"activitypub",
"api-types", "api-types",
"application", "application",
"async-trait", "async-trait",
@@ -2678,7 +2682,7 @@ dependencies = [
"quinn-udp", "quinn-udp",
"rustc-hash", "rustc-hash",
"rustls", "rustls",
"socket2 0.6.3", "socket2 0.6.4",
"thiserror 2.0.18", "thiserror 2.0.18",
"tokio", "tokio",
"tracing", "tracing",
@@ -2716,9 +2720,9 @@ dependencies = [
"cfg_aliases", "cfg_aliases",
"libc", "libc",
"once_cell", "once_cell",
"socket2 0.6.3", "socket2 0.6.4",
"tracing", "tracing",
"windows-sys 0.52.0", "windows-sys 0.60.2",
] ]
[[package]] [[package]]
@@ -2838,9 +2842,9 @@ dependencies = [
[[package]] [[package]]
name = "redox_syscall" name = "redox_syscall"
version = "0.7.5" version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4666a1a60d8412eab19d94f6d13dcc9cea0a5ef4fdf6a5db306537413c661b1b" checksum = "7c7591fa2c6b601dfcfe5f043f65a1c39fcdf50efefcd7f1572e538c1f4b398d"
dependencies = [ dependencies = [
"bitflags", "bitflags",
] ]
@@ -2891,7 +2895,7 @@ dependencies = [
"futures-core", "futures-core",
"futures-util", "futures-util",
"h2", "h2",
"http 1.4.0", "http 1.4.1",
"http-body", "http-body",
"http-body-util", "http-body-util",
"hyper", "hyper",
@@ -2924,9 +2928,9 @@ dependencies = [
[[package]] [[package]]
name = "reqwest" name = "reqwest"
version = "0.13.3" version = "0.13.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "62e0021ea2c22aed41653bc7e1419abb2c97e038ff2c33d0e1309e49a97deec0" checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3"
dependencies = [ dependencies = [
"base64", "base64",
"bytes", "bytes",
@@ -2934,7 +2938,7 @@ dependencies = [
"futures-core", "futures-core",
"futures-util", "futures-util",
"h2", "h2",
"http 1.4.0", "http 1.4.1",
"http-body", "http-body",
"http-body-util", "http-body-util",
"hyper", "hyper",
@@ -2967,14 +2971,14 @@ dependencies = [
[[package]] [[package]]
name = "reqwest-middleware" name = "reqwest-middleware"
version = "0.5.1" version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "199dda04a536b532d0cc04d7979e39b1c763ea749bf91507017069c00b96056f" checksum = "07bc3f1384cffa4f274dad2d4ddd73aed32fed8f786d96c6be8aa4e5fd3c3b58"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-trait", "async-trait",
"http 1.4.0", "http 1.4.1",
"reqwest 0.13.3", "reqwest 0.13.4",
"thiserror 2.0.18", "thiserror 2.0.18",
"tower-service", "tower-service",
] ]
@@ -3235,9 +3239,9 @@ dependencies = [
[[package]] [[package]]
name = "serde_json" name = "serde_json"
version = "1.0.149" version = "1.0.150"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
dependencies = [ dependencies = [
"indexmap", "indexmap",
"itoa", "itoa",
@@ -3447,9 +3451,9 @@ dependencies = [
[[package]] [[package]]
name = "socket2" name = "socket2"
version = "0.6.3" version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51"
dependencies = [ dependencies = [
"libc", "libc",
"windows-sys 0.61.2", "windows-sys 0.61.2",
@@ -3909,7 +3913,7 @@ dependencies = [
"parking_lot", "parking_lot",
"pin-project-lite", "pin-project-lite",
"signal-hook-registry", "signal-hook-registry",
"socket2 0.6.3", "socket2 0.6.4",
"tokio-macros", "tokio-macros",
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
@@ -3969,7 +3973,7 @@ dependencies = [
"bytes", "bytes",
"futures-core", "futures-core",
"futures-sink", "futures-sink",
"http 1.4.0", "http 1.4.1",
"httparse", "httparse",
"rand 0.8.6", "rand 0.8.6",
"ring", "ring",
@@ -3991,7 +3995,7 @@ dependencies = [
"base64", "base64",
"bytes", "bytes",
"h2", "h2",
"http 1.4.0", "http 1.4.1",
"http-body", "http-body",
"http-body-util", "http-body-util",
"hyper", "hyper",
@@ -3999,7 +4003,7 @@ dependencies = [
"hyper-util", "hyper-util",
"percent-encoding", "percent-encoding",
"pin-project", "pin-project",
"socket2 0.6.3", "socket2 0.6.4",
"sync_wrapper", "sync_wrapper",
"tokio", "tokio",
"tokio-stream", "tokio-stream",
@@ -4030,14 +4034,14 @@ dependencies = [
[[package]] [[package]]
name = "tower-http" name = "tower-http"
version = "0.6.10" version = "0.6.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51" checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
dependencies = [ dependencies = [
"bitflags", "bitflags",
"bytes", "bytes",
"futures-util", "futures-util",
"http 1.4.0", "http 1.4.1",
"http-body", "http-body",
"pin-project-lite", "pin-project-lite",
"tower", "tower",
@@ -4068,7 +4072,7 @@ dependencies = [
"axum", "axum",
"forwarded-header-value", "forwarded-header-value",
"governor", "governor",
"http 1.4.0", "http 1.4.1",
"pin-project", "pin-project",
"thiserror 2.0.18", "thiserror 2.0.18",
"tonic", "tonic",
@@ -4374,9 +4378,9 @@ checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b"
[[package]] [[package]]
name = "wasm-bindgen" name = "wasm-bindgen"
version = "0.2.121" version = "0.2.122"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"once_cell", "once_cell",
@@ -4387,9 +4391,9 @@ dependencies = [
[[package]] [[package]]
name = "wasm-bindgen-futures" name = "wasm-bindgen-futures"
version = "0.4.71" version = "0.4.72"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8" checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f"
dependencies = [ dependencies = [
"js-sys", "js-sys",
"wasm-bindgen", "wasm-bindgen",
@@ -4397,9 +4401,9 @@ dependencies = [
[[package]] [[package]]
name = "wasm-bindgen-macro" name = "wasm-bindgen-macro"
version = "0.2.121" version = "0.2.122"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6"
dependencies = [ dependencies = [
"quote", "quote",
"wasm-bindgen-macro-support", "wasm-bindgen-macro-support",
@@ -4407,9 +4411,9 @@ dependencies = [
[[package]] [[package]]
name = "wasm-bindgen-macro-support" name = "wasm-bindgen-macro-support"
version = "0.2.121" version = "0.2.122"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e"
dependencies = [ dependencies = [
"bumpalo", "bumpalo",
"proc-macro2", "proc-macro2",
@@ -4420,9 +4424,9 @@ dependencies = [
[[package]] [[package]]
name = "wasm-bindgen-shared" name = "wasm-bindgen-shared"
version = "0.2.121" version = "0.2.122"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437"
dependencies = [ dependencies = [
"unicode-ident", "unicode-ident",
] ]
@@ -4489,9 +4493,9 @@ dependencies = [
[[package]] [[package]]
name = "web-sys" name = "web-sys"
version = "0.3.98" version = "0.3.99"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa" checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436"
dependencies = [ dependencies = [
"js-sys", "js-sys",
"wasm-bindgen", "wasm-bindgen",
@@ -4663,6 +4667,15 @@ dependencies = [
"windows-targets 0.52.6", "windows-targets 0.52.6",
] ]
[[package]]
name = "windows-sys"
version = "0.60.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
dependencies = [
"windows-targets 0.53.5",
]
[[package]] [[package]]
name = "windows-sys" name = "windows-sys"
version = "0.61.2" version = "0.61.2"
@@ -4696,13 +4709,30 @@ dependencies = [
"windows_aarch64_gnullvm 0.52.6", "windows_aarch64_gnullvm 0.52.6",
"windows_aarch64_msvc 0.52.6", "windows_aarch64_msvc 0.52.6",
"windows_i686_gnu 0.52.6", "windows_i686_gnu 0.52.6",
"windows_i686_gnullvm", "windows_i686_gnullvm 0.52.6",
"windows_i686_msvc 0.52.6", "windows_i686_msvc 0.52.6",
"windows_x86_64_gnu 0.52.6", "windows_x86_64_gnu 0.52.6",
"windows_x86_64_gnullvm 0.52.6", "windows_x86_64_gnullvm 0.52.6",
"windows_x86_64_msvc 0.52.6", "windows_x86_64_msvc 0.52.6",
] ]
[[package]]
name = "windows-targets"
version = "0.53.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
dependencies = [
"windows-link",
"windows_aarch64_gnullvm 0.53.1",
"windows_aarch64_msvc 0.53.1",
"windows_i686_gnu 0.53.1",
"windows_i686_gnullvm 0.53.1",
"windows_i686_msvc 0.53.1",
"windows_x86_64_gnu 0.53.1",
"windows_x86_64_gnullvm 0.53.1",
"windows_x86_64_msvc 0.53.1",
]
[[package]] [[package]]
name = "windows_aarch64_gnullvm" name = "windows_aarch64_gnullvm"
version = "0.48.5" version = "0.48.5"
@@ -4715,6 +4745,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
[[package]] [[package]]
name = "windows_aarch64_msvc" name = "windows_aarch64_msvc"
version = "0.48.5" version = "0.48.5"
@@ -4727,6 +4763,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_aarch64_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
[[package]] [[package]]
name = "windows_i686_gnu" name = "windows_i686_gnu"
version = "0.48.5" version = "0.48.5"
@@ -4739,12 +4781,24 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnu"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
[[package]] [[package]]
name = "windows_i686_gnullvm" name = "windows_i686_gnullvm"
version = "0.52.6" version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
[[package]] [[package]]
name = "windows_i686_msvc" name = "windows_i686_msvc"
version = "0.48.5" version = "0.48.5"
@@ -4757,6 +4811,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_i686_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
[[package]] [[package]]
name = "windows_x86_64_gnu" name = "windows_x86_64_gnu"
version = "0.48.5" version = "0.48.5"
@@ -4769,6 +4829,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnu"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
[[package]] [[package]]
name = "windows_x86_64_gnullvm" name = "windows_x86_64_gnullvm"
version = "0.48.5" version = "0.48.5"
@@ -4781,6 +4847,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
[[package]] [[package]]
name = "windows_x86_64_msvc" name = "windows_x86_64_msvc"
version = "0.48.5" version = "0.48.5"
@@ -4793,6 +4865,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "windows_x86_64_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
[[package]] [[package]]
name = "wit-bindgen" name = "wit-bindgen"
version = "0.51.0" version = "0.51.0"
@@ -4908,6 +4986,8 @@ dependencies = [
"tokio", "tokio",
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
"url",
"uuid",
] ]
[[package]] [[package]]
@@ -4941,18 +5021,18 @@ dependencies = [
[[package]] [[package]]
name = "zerocopy" name = "zerocopy"
version = "0.8.48" version = "0.8.49"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" checksum = "bce33a6288fa3f072a8c2c7d0f2fdbb90e28298f0135c1f99b96c3db2efcc60b"
dependencies = [ dependencies = [
"zerocopy-derive", "zerocopy-derive",
] ]
[[package]] [[package]]
name = "zerocopy-derive" name = "zerocopy-derive"
version = "0.8.48" version = "0.8.49"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" checksum = "8fd425244944f4ab65ccff928e7323354c5a018c75838362fdce749dfad2ee1e"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@@ -4985,6 +5065,20 @@ name = "zeroize"
version = "1.8.2" version = "1.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
dependencies = [
"zeroize_derive",
]
[[package]]
name = "zeroize_derive"
version = "1.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]] [[package]]
name = "zerotrie" name = "zerotrie"

View File

@@ -4,6 +4,7 @@ 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/auth/Cargo.toml crates/adapters/auth/Cargo.toml COPY crates/adapters/auth/Cargo.toml crates/adapters/auth/Cargo.toml
@@ -51,7 +52,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 3000 EXPOSE 8000
ENV RUST_LOG=info ENV RUST_LOG=info

48
Makefile Normal file
View File

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

View File

@@ -14,7 +14,14 @@ A self-hosted microblogging server with full ActivityPub federation. Write short
- JWT authentication (Bearer token) with API key support for third-party clients - JWT authentication (Bearer token) with API key support for third-party clients
- 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
- Top friends — pin up to 5 users as highlighted contacts - **Profile fields** — up to 4 custom key/value fields (Website, Pronouns, etc.), federated as AP `PropertyValue` attachment
- **Custom CSS** — per-user stylesheet applied to their profile page
- **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 - Rate limiting and registration control
@@ -85,6 +92,11 @@ Users can upload avatar and banner images via `PUT /users/me/avatar` and `PUT /u
- Rust stable (1.80+) - Rust stable (1.80+)
- PostgreSQL 15+ - PostgreSQL 15+
- NATS with JetStream (optional — see [Without NATS](#without-nats)) - NATS with JetStream (optional — see [Without NATS](#without-nats))
- 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
@@ -103,7 +115,7 @@ Copy `.env.example` to `.env` and fill in your values.
| Variable | Default | Description | | Variable | Default | Description |
|---|---|---| |---|---|---|
| `HOST` | `0.0.0.0` | Interface to bind | | `HOST` | `0.0.0.0` | Interface to bind |
| `PORT` | `3000` | Port to listen on | | `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 | | `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` | | `CORS_ORIGINS` | `*` | Comma-separated allowed origins for CORS, e.g. `https://app.example.com` |
| `RATE_LIMIT` | disabled | Max requests per minute per IP | | `RATE_LIMIT` | disabled | Max requests per minute per IP |
@@ -121,8 +133,42 @@ Copy `.env.example` to `.env` and fill in your values.
| `UPLOAD_MAX_BYTES` | `5242880` | Max upload size in bytes (default 5 MiB) | | `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 | | `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 ## 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
```bash ```bash
# API server (runs migrations automatically on startup) # API server (runs migrations automatically on startup)
cargo run -p bootstrap cargo run -p bootstrap
@@ -136,14 +182,20 @@ Both processes share the same PostgreSQL database. The worker is optional but re
## Test ## Test
```bash ```bash
# Unit tests — no database required # Unit tests only — no database required
cargo test -p application make test-unit
# Full workspace (requires DATABASE_URL pointing to a running PostgreSQL) # Integration tests — requires DATABASE_URL pointing to a running PostgreSQL
cargo test --workspace make test-integration
# Everything (unit + integration)
make test
# Full check suite: fmt + clippy + tests
make check
``` ```
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. `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.
## API ## API
@@ -156,18 +208,7 @@ Interactive API documentation is available at runtime:
## Frontend ## Frontend
The Next.js frontend lives in `thoughts-frontend/`. It requires two environment variables: 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.
```env
NEXT_PUBLIC_API_URL=http://localhost:8000 # client-side requests
NEXT_PUBLIC_SERVER_SIDE_API_URL=http://localhost:8000 # SSR requests
```
```bash
cd thoughts-frontend
bun install
bun run dev # http://localhost:3000
```
## Docker ## Docker
@@ -203,12 +244,12 @@ docker build -t thoughts-frontend \
docker run -p 3000:3000 thoughts-frontend docker run -p 3000:3000 thoughts-frontend
``` ```
### Local development stack ### 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. `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 ```bash
docker compose up make up # or: docker compose up --build
``` ```
Services: Services:
@@ -225,7 +266,7 @@ Services:
Contributions are welcome. A few guidelines: Contributions are welcome. A few guidelines:
- **Run tests before opening a PR.** At minimum: `cargo test -p application` (no database needed). For adapter changes: `cargo test --workspace` with a live database. - **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. - **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. - **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`). - **ActivityPub changes** — test against a live Mastodon instance if possible, or use the AP debug logs (`RUST_ENV=development`).

View File

@@ -30,6 +30,7 @@ services:
DATABASE_URL: postgres://postgres:postgres@postgres:5432/thoughts DATABASE_URL: postgres://postgres:postgres@postgres:5432/thoughts
JWT_SECRET: change-me-in-production JWT_SECRET: change-me-in-production
BASE_URL: http://localhost:8000 BASE_URL: http://localhost:8000
PORT: 8000
NATS_URL: nats://nats:4222 NATS_URL: nats://nats:4222
RUST_LOG: info RUST_LOG: info
STORAGE_BACKEND: local STORAGE_BACKEND: local

View File

@@ -4,7 +4,7 @@ version = "0.1.0"
edition = "2021" edition = "2021"
[dependencies] [dependencies]
k-ap = { git = "https://git.gabrielkaszewski.dev/GKaszewski/k-ap.git", tag = "v0.1.9" } k-ap = { version = "0.4.0", registry = "gitea" }
domain = { workspace = true } domain = { workspace = true }
url = { workspace = true } url = { workspace = true }
serde = { workspace = true } serde = { workspace = true }
@@ -14,7 +14,6 @@ chrono = { workspace = true }
uuid = { workspace = true } uuid = { workspace = true }
async-trait = { workspace = true } async-trait = { workspace = true }
tracing = { workspace = true } tracing = { workspace = true }
activitypub_federation = "0.7.0-beta.11"
reqwest = { workspace = true } reqwest = { workspace = true }
futures = { workspace = true } futures = { workspace = true }
tokio = { workspace = true } tokio = { workspace = true }

View File

@@ -10,15 +10,17 @@ use url::Url;
use crate::note::{ThoughtNote, ThoughtNoteInput}; use crate::note::{ThoughtNote, ThoughtNoteInput};
use crate::port::{AcceptNoteInput, ActivityPubRepository}; use crate::port::{AcceptNoteInput, ActivityPubRepository};
use crate::urls::ThoughtsUrls; use crate::urls::ThoughtsUrls;
use domain::ports::{EventPublisher, TagRepository}; use domain::ports::{BoostRepository, EventPublisher, LikeRepository, TagRepository};
use domain::value_objects::UserId; use domain::value_objects::UserId;
use k_ap::ApObjectHandler; 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>>, event_publisher: Option<Arc<dyn EventPublisher>>,
tag_repo: Arc<dyn TagRepository>, tag_repo: Arc<dyn TagRepository>,
likes: Arc<dyn LikeRepository>,
boosts: Arc<dyn BoostRepository>,
} }
impl ThoughtsObjectHandler { impl ThoughtsObjectHandler {
@@ -27,53 +29,24 @@ impl ThoughtsObjectHandler {
base_url: &str, base_url: &str,
event_publisher: Option<Arc<dyn EventPublisher>>, event_publisher: Option<Arc<dyn EventPublisher>>,
tag_repo: Arc<dyn TagRepository>, tag_repo: Arc<dyn TagRepository>,
likes: Arc<dyn LikeRepository>,
boosts: Arc<dyn BoostRepository>,
) -> Self { ) -> Self {
Self { Self {
repo, repo,
urls: ThoughtsUrls::new(base_url), urls: ThoughtsUrls::new(base_url),
event_publisher, event_publisher,
tag_repo, tag_repo,
likes,
boosts,
} }
} }
} }
// ── ApContentReader ───────────────────────────────────────────────────────────
#[async_trait] #[async_trait]
impl ApObjectHandler for ThoughtsObjectHandler { impl ApContentReader 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(ThoughtNoteInput {
id: note_url.clone(),
actor_url,
content: e.thought.content.as_str().to_owned(),
published: e.thought.created_at,
in_reply_to,
sensitive: e.thought.sensitive,
summary: e.thought.content_warning,
followers_url: 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,
@@ -91,8 +64,8 @@ impl ApObjectHandler 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(e.author_username.as_str()); let actor_url = self.urls.user_url(&user_id.to_string());
let followers = self.urls.user_followers(e.author_username.as_str()); let followers = self.urls.user_followers(&user_id.to_string());
let in_reply_to = e let in_reply_to = e
.thought .thought
.in_reply_to_id .in_reply_to_id
@@ -112,6 +85,18 @@ impl ApObjectHandler for ThoughtsObjectHandler {
.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,
@@ -127,8 +112,11 @@ impl ApObjectHandler for ThoughtsObjectHandler {
.intern_remote_actor(actor_url.as_str()) .intern_remote_actor(actor_url.as_str())
.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);
@@ -161,7 +149,6 @@ impl ApObjectHandler for ThoughtsObjectHandler {
.await .await
.map_err(|e| anyhow!("{e}"))?; .map_err(|e| anyhow!("{e}"))?;
// Extract and index hashtags from the AP tag array.
let hashtag_names: Vec<String> = note let hashtag_names: Vec<String> = note
.tag .tag
.iter() .iter()
@@ -177,7 +164,6 @@ impl ApObjectHandler for ThoughtsObjectHandler {
} }
} }
// Fire mention notifications for local @mentions in the note's tag array.
let base_url = url::Url::parse(&self.urls.base_url) let base_url = url::Url::parse(&self.urls.base_url)
.ok() .ok()
.and_then(|u| u.host_str().map(|h| h.to_string())) .and_then(|u| u.host_str().map(|h| h.to_string()))
@@ -218,18 +204,51 @@ impl ApObjectHandler for ThoughtsObjectHandler {
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 Some((note, _)) = ThoughtNote::try_from_ap(object) else { let obj_type = object.get("type").and_then(|v| v.as_str()).unwrap_or("");
tracing::debug!(ap_id = %ap_id, "on_update: skipping non-Note object"); match obj_type {
"Note" | "Article" | "Page" => {
let Some((note, note_extensions)) = ThoughtNote::try_from_ap(object) else {
return Ok(()); return Ok(());
}; };
self.repo self.repo
.apply_note_update(ap_id.as_str(), &note.content) .apply_note_update(ap_id.as_str(), &note.content, note_extensions)
.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
@@ -269,14 +288,24 @@ impl ApObjectHandler for ThoughtsObjectHandler {
let actor_user_id = match actor_user_id { let actor_user_id = match actor_user_id {
Some(id) => id, Some(id) => id,
None => { None => {
tracing::debug!(actor = %actor_url, "on_like: remote actor not interned, skipping notification"); tracing::debug!(actor = %actor_url, "on_like: remote actor not interned, skipping");
return Ok(()); return Ok(());
} }
}; };
if let Some(ep) = &self.event_publisher {
let thought_id = domain::value_objects::ThoughtId::from_uuid(thought_uuid); let thought_id = domain::value_objects::ThoughtId::from_uuid(thought_uuid);
let like_id = domain::value_objects::LikeId::new(); 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 { ep.publish(&domain::events::DomainEvent::LikeAdded {
like_id, like_id,
user_id: actor_user_id, user_id: actor_user_id,
@@ -318,10 +347,13 @@ impl ApObjectHandler for ThoughtsObjectHandler {
} }
}; };
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 { if let Some(ep) = &self.event_publisher {
ep.publish(&domain::events::DomainEvent::LikeRemoved { ep.publish(&domain::events::DomainEvent::LikeRemoved {
user_id: actor_user_id, user_id: actor_user_id,
thought_id: domain::value_objects::ThoughtId::from_uuid(thought_uuid), thought_id,
}) })
.await .await
.map_err(|e| anyhow!("{e}"))?; .map_err(|e| anyhow!("{e}"))?;
@@ -393,9 +425,19 @@ impl ApObjectHandler for ThoughtsObjectHandler {
None => return Ok(()), None => return Ok(()),
}; };
if let Some(ep) = &self.event_publisher {
let thought_id = domain::value_objects::ThoughtId::from_uuid(thought_uuid); let thought_id = domain::value_objects::ThoughtId::from_uuid(thought_uuid);
let boost_id = domain::value_objects::BoostId::new(); 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 { ep.publish(&domain::events::DomainEvent::BoostAdded {
boost_id, boost_id,
user_id: actor_user_id, user_id: actor_user_id,
@@ -408,10 +450,45 @@ impl ApObjectHandler for ThoughtsObjectHandler {
Ok(()) Ok(())
} }
async fn count_local_posts(&self) -> Result<u64> { async fn on_announce_removed(&self, object_url: &Url, actor_url: &Url) -> Result<()> {
self.repo let thought_uuid = object_url
.count_local_notes() .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 .await
.map_err(|e| anyhow!("{e}")) .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

@@ -4,6 +4,9 @@ pub mod port;
pub mod service; 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::{ pub use port::{
@@ -11,3 +14,49 @@ pub use port::{
}; };
pub use service::ApFederationAdapter; 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

@@ -74,11 +74,15 @@ pub struct ThoughtNoteInput {
impl ThoughtNote { impl ThoughtNote {
/// Returns `(note, extensions)` if `value` is a Note object, `None` otherwise. /// Returns `(note, extensions)` if `value` is a Note object, `None` otherwise.
pub fn try_from_ap(value: serde_json::Value) -> Option<(Self, Option<serde_json::Value>)> { pub fn try_from_ap(mut value: serde_json::Value) -> Option<(Self, Option<serde_json::Value>)> {
if value.get("type").and_then(|v| v.as_str()) != Some("Note") { let obj_type = value.get("type").and_then(|v| v.as_str());
if !matches!(obj_type, Some("Note" | "Article" | "Page")) {
return None; return None;
} }
let extensions = extract_extensions(&value); 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) serde_json::from_value(value)
.ok() .ok()
.map(|note| (note, extensions)) .map(|note| (note, extensions))

View File

@@ -1,169 +1,5 @@
use async_trait::async_trait; pub use domain::ports::{
use domain::{ AcceptNoteInput, ActorFederationUrls as ActorApUrls,
errors::DomainError, FederationBroadcastPort as OutboundFederationPort,
models::thought::Thought, FederationContentRepository as ActivityPubRepository, OutboxEntry,
value_objects::{ThoughtId, UserId, Username},
}; };
pub struct AcceptNoteInput<'a> {
pub ap_id: &'a str,
pub author_id: &'a UserId,
pub content: &'a str,
pub published: chrono::DateTime<chrono::Utc>,
pub sensitive: bool,
pub content_warning: Option<String>,
pub visibility: &'a str,
pub in_reply_to: Option<&'a str>,
pub note_extensions: Option<serde_json::Value>,
}
/// AP-protocol endpoints for a locally-stored user (local or interned remote).
#[derive(Debug, Clone)]
pub struct ActorApUrls {
pub ap_id: String,
pub inbox_url: String,
}
/// A local thought ready for AP serialization, with the author's username
/// pre-joined so the handler can build AP URLs without a second query.
#[derive(Debug, Clone)]
pub struct OutboxEntry {
pub thought: Thought,
pub author_username: Username,
}
#[async_trait]
pub trait ActivityPubRepository: Send + Sync {
// ── Outbox (local → remote) ──────────────────────────────────────
/// All public local thoughts for this actor. Used for outbox totals
/// and full-collection delivery.
async fn outbox_entries_for_actor(
&self,
user_id: &UserId,
) -> Result<Vec<OutboxEntry>, DomainError>;
/// Cursor page of public local thoughts, newest-first, before `before`.
/// Used for OrderedCollectionPage responses.
async fn outbox_page_for_actor(
&self,
user_id: &UserId,
before: Option<chrono::DateTime<chrono::Utc>>,
limit: usize,
) -> Result<Vec<OutboxEntry>, DomainError>;
// ── Remote actor resolution ──────────────────────────────────────
/// Find the local UserId for a remote actor by its AP URL.
async fn find_remote_actor_id(&self, actor_ap_url: &str)
-> Result<Option<UserId>, DomainError>;
/// Ensure a remote actor placeholder exists; create one if absent.
/// Idempotent — safe to call multiple times with the same URL.
async fn intern_remote_actor(&self, actor_ap_url: &str) -> Result<UserId, DomainError>;
/// Update display_name and avatar_url for an already-interned remote actor.
async fn update_remote_actor_display(
&self,
user_id: &UserId,
display_name: Option<&str>,
avatar_url: Option<&str>,
) -> Result<(), DomainError>;
// ── Inbox processing (remote → local) ───────────────────────────
/// Persist an incoming remote Note. Idempotent on ap_id.
async fn accept_note(&self, input: AcceptNoteInput<'_>) -> Result<ThoughtId, DomainError>;
/// Apply an Update to a previously accepted remote Note.
async fn apply_note_update(&self, ap_id: &str, new_content: &str) -> Result<(), DomainError>;
/// Remove a specific remote Note (Delete activity). Only touches
/// remotely-originated thoughts.
async fn retract_note(&self, ap_id: &str) -> Result<(), DomainError>;
/// Remove all Notes from a remote actor (actor-level Delete/Tombstone).
async fn retract_actor_notes(&self, actor_ap_url: &str) -> Result<(), DomainError>;
// ── Node-level stats ─────────────────────────────────────────────
/// Total locally-authored thought count for NodeInfo responses.
async fn count_local_notes(&self) -> Result<u64, DomainError>;
/// Return the ActivityPub object URL for a thought, if one is stored.
/// Returns None for local thoughts (caller constructs URL from base_url + thought_id).
async fn get_thought_ap_id(
&self,
thought_id: &ThoughtId,
) -> Result<Option<String>, DomainError>;
/// Return the AP actor URL and inbox URL for a user, if stored.
/// Returns None for users that have not been federated.
async fn get_actor_ap_urls(&self, user_id: &UserId)
-> Result<Option<ActorApUrls>, DomainError>;
}
#[async_trait]
pub trait OutboundFederationPort: Send + Sync {
/// Fan out a new local Note to all accepted followers.
async fn broadcast_create(
&self,
author_user_id: &UserId,
thought: &Thought,
author_username: &str,
in_reply_to_url: Option<&str>,
) -> Result<(), DomainError>;
/// Fan out a Delete tombstone for a now-deleted local Note.
/// `thought_ap_id` is pre-constructed by the caller because the thought
/// has already been deleted from the DB when this fires.
async fn broadcast_delete(
&self,
author_user_id: &UserId,
thought_ap_id: &str,
) -> Result<(), DomainError>;
/// Fan out an Update(Note) for an edited local thought.
async fn broadcast_update(
&self,
author_user_id: &UserId,
thought: &Thought,
author_username: &str,
in_reply_to_url: Option<&str>,
) -> Result<(), DomainError>;
/// Fan out an Announce(object_ap_id) for a boost.
async fn broadcast_announce(
&self,
booster_user_id: &UserId,
object_ap_id: &str,
) -> Result<(), DomainError>;
/// Fan out an Undo(Announce) to followers when a boost is removed.
async fn broadcast_undo_announce(
&self,
booster_user_id: &UserId,
object_ap_id: &str,
) -> Result<(), DomainError>;
/// Send a Like activity to a remote thought author's inbox.
/// Only called when a LOCAL user likes a REMOTE thought (one with an ap_id).
async fn broadcast_like(
&self,
liker_user_id: &UserId,
object_ap_id: &str,
author_inbox_url: &str,
) -> Result<(), DomainError>;
/// Send Undo(Like) to a remote thought author's inbox.
async fn broadcast_undo_like(
&self,
liker_user_id: &UserId,
object_ap_id: &str,
author_inbox_url: &str,
) -> Result<(), DomainError>;
/// Fan out an Update(Actor) to all accepted followers after a profile change.
async fn broadcast_actor_update(&self, user_id: &UserId) -> Result<(), DomainError>;
}

View File

@@ -23,7 +23,8 @@ fn content_to_html(text: &str) -> String {
.replace('&', "&amp;") .replace('&', "&amp;")
.replace('<', "&lt;") .replace('<', "&lt;")
.replace('>', "&gt;") .replace('>', "&gt;")
.replace('"', "&quot;"); .replace('"', "&quot;")
.replace('\'', "&#39;");
let paragraphs: Vec<&str> = escaped.split('\n').filter(|s| !s.is_empty()).collect(); let paragraphs: Vec<&str> = escaped.split('\n').filter(|s| !s.is_empty()).collect();
if paragraphs.is_empty() { if paragraphs.is_empty() {
format!("<p>{}</p>", escaped) format!("<p>{}</p>", escaped)
@@ -94,9 +95,28 @@ fn build_note_json(
.collect(); .collect();
note["tag"] = serde_json::json!(ap_tags); 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 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 { fn k_ap_actor_to_domain(a: k_ap::RemoteActor) -> DomainRemoteActor {
DomainRemoteActor { DomainRemoteActor {
url: a.url, url: a.url,
@@ -104,12 +124,14 @@ fn k_ap_actor_to_domain(a: k_ap::RemoteActor) -> DomainRemoteActor {
display_name: a.display_name, display_name: a.display_name,
avatar_url: a.avatar_url, avatar_url: a.avatar_url,
outbox_url: a.outbox_url, outbox_url: a.outbox_url,
last_fetched_at: chrono::Utc::now(), last_fetched_at: a.fetched_at.unwrap_or_else(chrono::Utc::now),
bio: None, bio: a.bio,
banner_url: None, banner_url: a.banner_url,
also_known_as: None, also_known_as: a.also_known_as,
followers_url: None, followers_url: a.followers_url,
following_url: None, following_url: a.following_url,
inbox_url: Some(a.inbox_url),
shared_inbox_url: a.shared_inbox_url,
attachment: vec![], attachment: vec![],
} }
} }
@@ -192,7 +214,9 @@ async fn webfinger_resolve_actor_url(handle: &str) -> anyhow::Result<String> {
.and_then(|links| { .and_then(|links| {
links.iter().find(|l| { links.iter().find(|l| {
l["rel"].as_str() == Some("self") l["rel"].as_str() == Some("self")
&& l["type"].as_str() == Some("application/activity+json") && 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()) .and_then(|l| l["href"].as_str())
@@ -264,7 +288,12 @@ impl crate::port::OutboundFederationPort for ApFederationAdapter {
in_reply_to_url, in_reply_to_url,
); );
self.inner self.inner
.broadcast_create_note(user_uuid, note) .broadcast_create_note(
user_uuid,
note,
thought_to_ap_visibility(&thought.visibility),
vec![],
)
.await .await
.map_err(|e| DomainError::Internal(e.to_string())) .map_err(|e| DomainError::Internal(e.to_string()))
} }
@@ -300,7 +329,12 @@ impl crate::port::OutboundFederationPort for ApFederationAdapter {
in_reply_to_url, in_reply_to_url,
); );
self.inner self.inner
.broadcast_update_note(user_uuid, note) .broadcast_update_note(
user_uuid,
note,
thought_to_ap_visibility(&thought.visibility),
vec![],
)
.await .await
.map_err(|e| DomainError::Internal(e.to_string())) .map_err(|e| DomainError::Internal(e.to_string()))
} }
@@ -384,7 +418,7 @@ impl FederationSchedulerPort for ApFederationAdapter {
let actor = actor_ap_url.to_string(); let actor = actor_ap_url.to_string();
let outbox = outbox_url.to_string(); let outbox = outbox_url.to_string();
tokio::spawn(async move { tokio::spawn(async move {
if let Err(e) = service.backfill_outbox(&outbox, &actor).await { if let Err(e) = service.import_remote_outbox(&outbox, &actor).await {
tracing::warn!(actor = %actor, error = %e, "posts backfill failed"); tracing::warn!(actor = %actor, error = %e, "posts backfill failed");
} }
}); });
@@ -396,11 +430,8 @@ impl FederationSchedulerPort for ApFederationAdapter {
actor_ap_url: &str, actor_ap_url: &str,
collection_url: &str, collection_url: &str,
connection_type: &str, connection_type: &str,
page: u32, _page: u32,
) -> Result<(), DomainError> { ) -> Result<(), DomainError> {
if page != 1 {
return Ok(());
}
let actor = actor_ap_url.to_string(); let actor = actor_ap_url.to_string();
let collection = collection_url.to_string(); let collection = collection_url.to_string();
let conn_type = connection_type.to_string(); let conn_type = connection_type.to_string();
@@ -517,9 +548,15 @@ impl FederationLookupPort for ApFederationAdapter {
last_fetched_at: chrono::Utc::now(), last_fetched_at: chrono::Utc::now(),
bio: actor.bio, bio: actor.bio,
banner_url: actor.banner_url.as_ref().map(|u| u.to_string()), banner_url: actor.banner_url.as_ref().map(|u| u.to_string()),
also_known_as: actor.also_known_as, 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()), followers_url: actor.followers_url.as_ref().map(|u| u.to_string()),
following_url: actor.following_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: actor
.attachment .attachment
.into_iter() .into_iter()
@@ -580,13 +617,19 @@ impl FederationFetchPort for ApFederationAdapter {
.await .await
.map_err(|e| DomainError::ExternalService(e.to_string()))?; .map_err(|e| DomainError::ExternalService(e.to_string()))?;
let url = base["first"] let first_url = base["first"]
.as_str() .as_str()
.map(|s| s.to_string()) .map(|s| s.to_string())
.unwrap_or_else(|| format!("{}?page={}", outbox_url, page)); .unwrap_or_else(|| format!("{}?page=1", outbox_url));
let resp: serde_json::Value = client let mut current_url = first_url;
.get(&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") .header("Accept", "application/activity+json, application/ld+json")
.send() .send()
.await .await
@@ -595,6 +638,16 @@ impl FederationFetchPort for ApFederationAdapter {
.await .await
.map_err(|e| DomainError::ExternalService(e.to_string()))?; .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 empty = vec![];
let items = resp["orderedItems"].as_array().unwrap_or(&empty); let items = resp["orderedItems"].as_array().unwrap_or(&empty);
@@ -807,6 +860,57 @@ impl FederationFollowRequestPort for ApFederationAdapter {
.await .await
.map_err(|e| DomainError::ExternalService(e.to_string())) .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. // FederationActionPort is a blanket supertrait; no explicit impl needed.

View File

@@ -11,24 +11,24 @@ impl ThoughtsUrls {
} }
} }
pub fn user_url(&self, username: &str) -> Url { pub fn user_url(&self, id: &str) -> Url {
Url::parse(&format!("{}/users/{}", self.base_url, username)).expect("valid URL") Url::parse(&format!("{}/users/{}", self.base_url, id)).expect("valid URL")
} }
pub fn thought_url(&self, thought_id: uuid::Uuid) -> Url { pub fn thought_url(&self, thought_id: uuid::Uuid) -> Url {
Url::parse(&format!("{}/thoughts/{}", self.base_url, thought_id)).expect("valid URL") Url::parse(&format!("{}/thoughts/{}", self.base_url, thought_id)).expect("valid URL")
} }
pub fn user_inbox(&self, username: &str) -> Url { pub fn user_inbox(&self, id: &str) -> Url {
Url::parse(&format!("{}/users/{}/inbox", self.base_url, username)).expect("valid URL") Url::parse(&format!("{}/users/{}/inbox", self.base_url, id)).expect("valid URL")
} }
pub fn user_outbox(&self, username: &str) -> Url { pub fn user_outbox(&self, id: &str) -> Url {
Url::parse(&format!("{}/users/{}/outbox", self.base_url, username)).expect("valid URL") Url::parse(&format!("{}/users/{}/outbox", self.base_url, id)).expect("valid URL")
} }
pub fn user_followers(&self, username: &str) -> Url { pub fn user_followers(&self, id: &str) -> Url {
Url::parse(&format!("{}/users/{}/followers", self.base_url, username)).expect("valid URL") Url::parse(&format!("{}/users/{}/followers", self.base_url, id)).expect("valid URL")
} }
} }

View File

@@ -71,11 +71,32 @@ pub enum EventPayload {
ProfileUpdated { ProfileUpdated {
user_id: String, user_id: 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 { MentionReceived {
thought_id: String, thought_id: String,
mentioned_user_id: String, mentioned_user_id: String,
author_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,
},
} }
impl EventPayload { impl EventPayload {
@@ -97,7 +118,12 @@ impl EventPayload {
Self::UserUnblocked { .. } => "users.unblocked", Self::UserUnblocked { .. } => "users.unblocked",
Self::UserRegistered { .. } => "users.registered", Self::UserRegistered { .. } => "users.registered",
Self::ProfileUpdated { .. } => "users.profile_updated", Self::ProfileUpdated { .. } => "users.profile_updated",
Self::RemoteFollowAccepted { .. } => "federation.follow.accepted",
Self::RemoteFollowRejected { .. } => "federation.follow.rejected",
Self::ActorMoved { .. } => "federation.actor.moved",
Self::MentionReceived { .. } => "mentions.received", Self::MentionReceived { .. } => "mentions.received",
Self::FederationDeliveryRequested { .. } => "federation.delivery.requested",
Self::FederationBackfillRequested { .. } => "federation.backfill.requested",
} }
} }
} }
@@ -210,6 +236,27 @@ impl From<&DomainEvent> for EventPayload {
DomainEvent::ProfileUpdated { user_id } => Self::ProfileUpdated { DomainEvent::ProfileUpdated { user_id } => Self::ProfileUpdated {
user_id: user_id.to_string(), user_id: user_id.to_string(),
}, },
DomainEvent::RemoteFollowAccepted {
local_user_id,
remote_actor_url,
} => 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 { DomainEvent::MentionReceived {
thought_id, thought_id,
mentioned_user_id, mentioned_user_id,
@@ -340,6 +387,27 @@ impl TryFrom<EventPayload> for DomainEvent {
EventPayload::ProfileUpdated { user_id } => DomainEvent::ProfileUpdated { EventPayload::ProfileUpdated { user_id } => DomainEvent::ProfileUpdated {
user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?), user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?),
}, },
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 { EventPayload::MentionReceived {
thought_id, thought_id,
mentioned_user_id, mentioned_user_id,
@@ -352,6 +420,12 @@ impl TryFrom<EventPayload> for DomainEvent {
)?), )?),
author_user_id: UserId::from_uuid(parse_uuid(&author_user_id, "author_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(),
));
}
}) })
} }
} }

View File

@@ -3,11 +3,13 @@ use async_trait::async_trait;
use domain::value_objects::{ThoughtId, UserId}; use domain::value_objects::{ThoughtId, UserId};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
type CallLog = Arc<Mutex<Vec<(String, Vec<u8>)>>>;
struct SpyTransport { struct SpyTransport {
calls: Arc<Mutex<Vec<(String, Vec<u8>)>>>, calls: CallLog,
} }
impl SpyTransport { impl SpyTransport {
fn new() -> (Self, Arc<Mutex<Vec<(String, Vec<u8>)>>>) { fn new() -> (Self, CallLog) {
let calls = Arc::new(Mutex::new(vec![])); let calls = Arc::new(Mutex::new(vec![]));
( (
Self { Self {

View File

@@ -1,4 +1,3 @@
use super::*;
use domain::{ use domain::{
events::DomainEvent, events::DomainEvent,
value_objects::{LikeId, ThoughtId, UserId}, value_objects::{LikeId, ThoughtId, UserId},

View File

@@ -4,7 +4,7 @@ version = "0.1.0"
edition = "2021" edition = "2021"
[dependencies] [dependencies]
k-ap = { git = "https://git.gabrielkaszewski.dev/GKaszewski/k-ap.git", tag = "v0.1.9" } k-ap = { version = "0.4.0", registry = "gitea" }
sqlx = { workspace = true } sqlx = { workspace = true }
uuid = { workspace = true } uuid = { workspace = true }
chrono = { workspace = true } chrono = { workspace = true }
@@ -12,6 +12,7 @@ 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

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

File diff suppressed because it is too large Load Diff

View File

@@ -9,9 +9,9 @@ use domain::{
user::User, user::User,
}, },
ports::SearchPort, ports::SearchPort,
value_objects::{Content, Email, PasswordHash, ThoughtId, UserId, Username}, value_objects::{Content, ThoughtId, UserId},
}; };
use postgres::user::{UserRow, USER_SELECT}; use postgres::user::USER_SELECT;
use sqlx::PgPool; use sqlx::PgPool;
pub struct PgSearchRepository { pub struct PgSearchRepository {
@@ -34,25 +34,16 @@ struct FeedRow {
sensitive: bool, sensitive: bool,
t_local: bool, t_local: bool,
thought_created_at: DateTime<Utc>, thought_created_at: DateTime<Utc>,
updated_at: Option<DateTime<Utc>>, thought_updated_at: Option<DateTime<Utc>>,
author_id: uuid::Uuid, note_extensions: Option<serde_json::Value>,
username: String, mood: Option<String>,
email: String, #[sqlx(flatten)]
password_hash: String, author: postgres::user::UserRow,
display_name: Option<String>,
bio: Option<String>,
avatar_url: Option<String>,
header_url: Option<String>,
custom_css: Option<String>,
author_local: bool,
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, liked_by_viewer: bool,
boosted_by_viewer: bool, boosted_by_viewer: bool,
note_extensions: Option<serde_json::Value>,
} }
fn feed_select(viewer: Option<uuid::Uuid>) -> String { fn feed_select(viewer: Option<uuid::Uuid>) -> String {
@@ -68,11 +59,11 @@ fn feed_select(viewer: Option<uuid::Uuid>) -> String {
t.id AS thought_id, t.user_id AS t_user_id, t.content,\n\ t.id AS thought_id, t.user_id AS t_user_id, t.content,\n\
t.in_reply_to_id,\n\ t.in_reply_to_id,\n\
t.visibility, t.content_warning, t.sensitive, t.local AS t_local,\n\ t.visibility, t.content_warning, t.sensitive, t.local AS t_local,\n\
t.created_at AS thought_created_at, t.updated_at, t.note_extensions,\n\ t.created_at AS thought_created_at, t.updated_at AS thought_updated_at, t.note_extensions, t.mood,\n\
u.id AS author_id, u.username, u.email, u.password_hash,\n\ u.id, u.username, u.email, u.password_hash,\n\
u.display_name, u.bio, u.avatar_url, u.header_url, u.custom_css,\n\ u.display_name, u.bio, u.avatar_url, u.header_url, u.custom_css, u.profile_fields, u.custom_moods,\n\
u.local AS author_local,\n\ u.local,\n\
u.created_at AS author_created_at, u.updated_at AS author_updated_at,\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 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 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\ (SELECT COUNT(*) FROM thoughts r WHERE r.in_reply_to_id=t.id) AS reply_count,\n\
@@ -92,23 +83,11 @@ fn row_to_entry(r: FeedRow, viewer: Option<uuid::Uuid>) -> Result<FeedEntry, Dom
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.updated_at, updated_at: r.thought_updated_at,
note_extensions: r.note_extensions, note_extensions: r.note_extensions,
mood: r.mood,
}; };
let author = User { let author = User::from(r.author);
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,
created_at: r.author_created_at,
updated_at: r.author_updated_at,
};
Ok(FeedEntry { Ok(FeedEntry {
thought, thought,
author, author,
@@ -189,7 +168,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::<_, UserRow>(&sql) let rows = sqlx::query_as::<_, postgres::user::UserRow>(&sql)
.bind(query) .bind(query)
.bind(page.limit()) .bind(page.limit())
.bind(page.offset()) .bind(page.offset())

View File

@@ -5,7 +5,7 @@ use domain::{
user::User, user::User,
}, },
ports::{SearchPort, ThoughtRepository, UserWriter}, ports::{SearchPort, ThoughtRepository, UserWriter},
value_objects::*, value_objects::{Content, Email, PasswordHash, ThoughtId, UserId, Username},
}; };
async fn seed_thought(pool: &sqlx::PgPool, username: &str, content: &str) -> (User, Thought) { async fn seed_thought(pool: &sqlx::PgPool, username: &str, content: &str) -> (User, Thought) {
@@ -27,6 +27,7 @@ async fn seed_thought(pool: &sqlx::PgPool, username: &str, content: &str) -> (Us
visibility: Visibility::Public, visibility: Visibility::Public,
content_warning: None, content_warning: None,
sensitive: false, sensitive: false,
mood: None,
}); });
trepo.save(&t).await.unwrap(); trepo.save(&t).await.unwrap();
(u, t) (u, t)
@@ -102,9 +103,9 @@ async fn search_thoughts_returns_empty_for_no_match(pool: sqlx::PgPool) {
#[sqlx::test(migrations = "../postgres/migrations")] #[sqlx::test(migrations = "../postgres/migrations")]
async fn search_thoughts_viewer_context(pool: sqlx::PgPool) { async fn search_thoughts_viewer_context(pool: sqlx::PgPool) {
use domain::models::social::Like; use domain::models::social::Like;
use domain::ports::{LikeRepository, UserWriter}; use domain::ports::LikeRepository;
use domain::value_objects::LikeId; use domain::value_objects::LikeId;
use postgres::{like::PgLikeRepository, user::PgUserRepository}; use postgres::like::PgLikeRepository;
let (alice, thought) = seed_thought(&pool, "alice", "hello world").await; let (alice, thought) = seed_thought(&pool, "alice", "hello world").await;

View File

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

View File

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

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

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

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

View File

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

View File

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

View File

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

View File

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

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

@@ -1,7 +1,7 @@
use crate::db_error::IntoDbResult; use crate::db_error::IntoDbResult;
use async_trait::async_trait; use async_trait::async_trait;
const MAX_REMOTE_CONTENT_CHARS: usize = 500; const MAX_REMOTE_CONTENT_CHARS: usize = 5000;
const THOUGHTS_PATH_PREFIX: &str = "/thoughts/"; const THOUGHTS_PATH_PREFIX: &str = "/thoughts/";
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use sqlx::PgPool; use sqlx::PgPool;
@@ -13,6 +13,41 @@ use domain::{
value_objects::{Content, ThoughtId, UserId, Username}, 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 { pub struct PgActivityPubRepository {
pool: PgPool, pool: PgPool,
} }
@@ -29,19 +64,7 @@ impl ActivityPubRepository for PgActivityPubRepository {
&self, &self,
user_id: &UserId, user_id: &UserId,
) -> Result<Vec<OutboxEntry>, DomainError> { ) -> Result<Vec<OutboxEntry>, DomainError> {
#[derive(sqlx::FromRow)] sqlx::query_as::<_, OutboxRow>(
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 "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 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' WHERE t.user_id=$1 AND t.local=true AND t.visibility='public'
@@ -51,26 +74,7 @@ impl ActivityPubRepository for PgActivityPubRepository {
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await .await
.into_domain() .into_domain()
.map(|rows| { .map(|rows| rows.into_iter().map(OutboxRow::into_entry).collect())
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),
visibility: Visibility::Public,
content_warning: r.content_warning,
sensitive: r.sensitive,
local: true,
created_at: r.created_at,
updated_at: r.updated_at,
note_extensions: None,
},
author_username: Username::from_trusted(r.username),
})
.collect()
})
} }
async fn outbox_page_for_actor( async fn outbox_page_for_actor(
@@ -79,20 +83,8 @@ impl ActivityPubRepository for PgActivityPubRepository {
before: Option<DateTime<Utc>>, before: Option<DateTime<Utc>>,
limit: usize, limit: usize,
) -> Result<Vec<OutboxEntry>, DomainError> { ) -> 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 { let rows = if let Some(before) = before {
sqlx::query_as::<_, Row>( 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 "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 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 WHERE t.user_id=$1 AND t.local=true AND t.visibility='public' AND t.created_at < $2
@@ -104,7 +96,7 @@ impl ActivityPubRepository for PgActivityPubRepository {
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await .await
} else { } else {
sqlx::query_as::<_, Row>( 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 "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 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' WHERE t.user_id=$1 AND t.local=true AND t.visibility='public'
@@ -117,25 +109,7 @@ impl ActivityPubRepository for PgActivityPubRepository {
} }
.into_domain()?; .into_domain()?;
Ok(rows Ok(rows.into_iter().map(OutboxRow::into_entry).collect())
.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),
visibility: Visibility::Public,
content_warning: r.content_warning,
sensitive: r.sensitive,
local: true,
created_at: r.created_at,
updated_at: r.updated_at,
note_extensions: None,
},
author_username: Username::from_trusted(r.username),
})
.collect())
} }
async fn find_remote_actor_id( async fn find_remote_actor_id(
@@ -155,24 +129,28 @@ impl ActivityPubRepository for PgActivityPubRepository {
return Ok(id); return Ok(id);
} }
let new_id = uuid::Uuid::new_v4(); let new_id = uuid::Uuid::new_v4();
// Use the last path segment as username (e.g. /users/alice → "alice"). let parsed = url::Url::parse(actor_ap_url).ok();
// Falls back to a random short id for long segments (e.g. UUID-based actor URLs). let domain_str = parsed
// username column is VARCHAR(32). .as_ref()
let last_seg = url::Url::parse(actor_ap_url) .and_then(|u| u.host_str().map(|s| s.to_string()))
.ok() .unwrap_or_default();
let last_seg = parsed
.and_then(|u| { .and_then(|u| {
u.path_segments() u.path_segments()
.and_then(|mut s| s.next_back().map(|s| s.to_string())) .and_then(|mut s| s.next_back().map(|s| s.to_string()))
}) })
.unwrap_or_default(); .unwrap_or_default();
let handle = if last_seg.is_empty() { let handle = if last_seg.is_empty() || domain_str.is_empty() {
format!("remote_{}", &new_id.to_string()[..13]) format!("r_{}", &new_id.to_string()[..13])
} else if last_seg.len() <= 32 {
last_seg
} else { } else {
format!("remote_{}", &new_id.to_string()[..13]) let candidate = format!("{}@{}", last_seg, domain_str);
if candidate.len() <= 255 {
candidate
} else {
format!("r_{}", &new_id.to_string()[..13])
}
}; };
sqlx::query( let result = sqlx::query(
"INSERT INTO users(id,username,email,password_hash,local,ap_id,created_at,updated_at) "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", VALUES($1,$2,$3,'',false,$4,NOW(),NOW()) ON CONFLICT(ap_id) DO NOTHING",
) )
@@ -181,9 +159,24 @@ impl ActivityPubRepository for PgActivityPubRepository {
.bind(format!("{}@remote", new_id)) .bind(format!("{}@remote", new_id))
.bind(actor_ap_url) .bind(actor_ap_url)
.execute(&self.pool) .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 .await
.into_domain()?; .into_domain()?;
// Re-fetch to get whichever id won the race }
self.find_remote_actor_id(actor_ap_url) self.find_remote_actor_id(actor_ap_url)
.await? .await?
.ok_or_else(|| { .ok_or_else(|| {
@@ -227,14 +220,25 @@ impl ActivityPubRepository for PgActivityPubRepository {
let capped: String = content.chars().take(MAX_REMOTE_CONTENT_CHARS).collect(); let capped: String = content.chars().take(MAX_REMOTE_CONTENT_CHARS).collect();
let (in_reply_to_id, in_reply_to_url) = match in_reply_to { let (in_reply_to_id, in_reply_to_url) = match in_reply_to {
Some(url) => { Some(url) => {
// If the parent is a local thought, extract its UUID for in_reply_to_id. // Fast path: local thought URL contains the UUID directly.
let local_uuid = url::Url::parse(url).ok().and_then(|u| { let local_uuid = url::Url::parse(url).ok().and_then(|u| {
u.path() u.path()
.strip_prefix(THOUGHTS_PATH_PREFIX) .strip_prefix(THOUGHTS_PATH_PREFIX)
.and_then(|s| s.split('/').next()) .and_then(|s| s.split('/').next())
.and_then(|s| uuid::Uuid::parse_str(s).ok()) .and_then(|s| uuid::Uuid::parse_str(s).ok())
}); });
(local_uuid, Some(url.to_string())) // 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), None => (None, None),
}; };
@@ -266,13 +270,19 @@ impl ActivityPubRepository for PgActivityPubRepository {
Ok(ThoughtId::from_uuid(row.0)) Ok(ThoughtId::from_uuid(row.0))
} }
async fn apply_note_update(&self, ap_id: &str, new_content: &str) -> Result<(), DomainError> { 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(); let capped: String = new_content.chars().take(MAX_REMOTE_CONTENT_CHARS).collect();
sqlx::query( sqlx::query(
"UPDATE thoughts SET content=$2,updated_at=NOW() WHERE ap_id=$1 AND local=false", "UPDATE thoughts SET content=$2,note_extensions=$3,updated_at=NOW() WHERE ap_id=$1 AND local=false",
) )
.bind(ap_id) .bind(ap_id)
.bind(&capped) .bind(&capped)
.bind(&note_extensions)
.execute(&self.pool) .execute(&self.pool)
.await .await
.into_domain() .into_domain()
@@ -334,6 +344,19 @@ impl ActivityPubRepository for PgActivityPubRepository {
.into_domain() .into_domain()
.map(|opt| opt.map(|(ap_id, inbox_url)| ActorApUrls { ap_id, inbox_url })) .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)] #[cfg(test)]

View File

@@ -9,6 +9,27 @@ use domain::{
}; };
use sqlx::PgPool; 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 { pub struct PgApiKeyRepository {
pool: PgPool, pool: PgPool,
} }
@@ -36,45 +57,21 @@ impl ApiKeyRepository for PgApiKeyRepository {
} }
async fn find_by_hash(&self, hash: &str) -> Result<Option<ApiKey>, DomainError> { async fn find_by_hash(&self, hash: &str) -> Result<Option<ApiKey>, DomainError> {
#[derive(sqlx::FromRow)] sqlx::query_as::<_, ApiKeyRow>(
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", "SELECT id,user_id,key_hash,name,created_at FROM api_keys WHERE key_hash=$1",
) )
.bind(hash) .bind(hash)
.fetch_optional(&self.pool) .fetch_optional(&self.pool)
.await .await
.into_domain() .into_domain()
.map(|o| { .map(|o| o.map(ApiKeyRow::into_domain))
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> { async fn list_for_user(&self, user_id: &UserId) -> Result<Vec<ApiKey>, DomainError> {
#[derive(sqlx::FromRow)] 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")
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 .bind(user_id.as_uuid()).fetch_all(&self.pool).await
.into_domain() .into_domain()
.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()) .map(|rows| rows.into_iter().map(ApiKeyRow::into_domain).collect())
} }
async fn delete(&self, id: &ApiKeyId, user_id: &UserId) -> Result<(), DomainError> { async fn delete(&self, id: &ApiKeyId, user_id: &UserId) -> Result<(), DomainError> {

View File

@@ -1,7 +1,6 @@
use super::*; use super::*;
use crate::test_helpers::seed_user; use crate::test_helpers::seed_user;
use chrono::Utc; use chrono::Utc;
use domain::value_objects::*;
#[sqlx::test(migrations = "./migrations")] #[sqlx::test(migrations = "./migrations")]
async fn block_exists(pool: sqlx::PgPool) { async fn block_exists(pool: sqlx::PgPool) {

View File

@@ -1,7 +1,6 @@
use super::*; use super::*;
use crate::test_helpers::seed_user_and_thought; use crate::test_helpers::seed_user_and_thought;
use chrono::Utc; use chrono::Utc;
use domain::value_objects::*;
#[sqlx::test(migrations = "./migrations")] #[sqlx::test(migrations = "./migrations")]
async fn boost_and_count(pool: sqlx::PgPool) { async fn boost_and_count(pool: sqlx::PgPool) {

View File

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

@@ -9,8 +9,8 @@ use domain::{
thought::{Thought, Visibility}, thought::{Thought, Visibility},
user::User, user::User,
}, },
ports::{FeedQuery, FeedRepository, FeedScope}, ports::{FeedOptions, FeedRepository, FeedRequest, FeedScope, FeedSort},
value_objects::{Content, Email, PasswordHash, ThoughtId, UserId, Username}, value_objects::{Content, ThoughtId, UserId},
}; };
use sqlx::PgPool; use sqlx::PgPool;
@@ -34,20 +34,11 @@ struct FeedRow {
sensitive: bool, sensitive: bool,
t_local: bool, t_local: bool,
thought_created_at: DateTime<Utc>, thought_created_at: DateTime<Utc>,
updated_at: Option<DateTime<Utc>>, thought_updated_at: Option<DateTime<Utc>>,
note_extensions: Option<serde_json::Value>, note_extensions: Option<serde_json::Value>,
author_id: uuid::Uuid, mood: Option<String>,
username: String, #[sqlx(flatten)]
email: String, author: crate::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,
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,
@@ -55,59 +46,6 @@ struct FeedRow {
boosted_by_viewer: bool, boosted_by_viewer: bool,
} }
fn federation_following_clause(follower: Option<uuid::Uuid>) -> String {
match follower {
Some(fid) => 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 = '{fid}'
)"
),
None => String::new(),
}
}
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.visibility, t.content_warning, t.sensitive, t.local AS t_local,
t.created_at AS thought_created_at, t.updated_at,
t.note_extensions,
u.id AS author_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.local AS author_local,
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
LEFT JOIN remote_actors ra ON u.ap_id = ra.url"
)
}
fn row_to_entry(r: FeedRow, viewer: Option<uuid::Uuid>) -> Result<FeedEntry, DomainError> { fn row_to_entry(r: FeedRow, viewer: Option<uuid::Uuid>) -> Result<FeedEntry, DomainError> {
let thought = Thought { let thought = Thought {
id: ThoughtId::from_uuid(r.thought_id), id: ThoughtId::from_uuid(r.thought_id),
@@ -119,23 +57,11 @@ fn row_to_entry(r: FeedRow, viewer: Option<uuid::Uuid>) -> Result<FeedEntry, Dom
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.updated_at, updated_at: r.thought_updated_at,
note_extensions: r.note_extensions, note_extensions: r.note_extensions,
mood: r.mood,
}; };
let author = User { let author = User::from(r.author);
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,
created_at: r.author_created_at,
updated_at: r.author_updated_at,
};
Ok(FeedEntry { Ok(FeedEntry {
thought, thought,
author, author,
@@ -151,36 +77,227 @@ fn row_to_entry(r: FeedRow, viewer: Option<uuid::Uuid>) -> Result<FeedEntry, Dom
}) })
} }
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] #[async_trait]
impl FeedRepository for PgFeedRepository { impl FeedRepository for PgFeedRepository {
async fn query(&self, q: &FeedQuery) -> Result<Paginated<FeedEntry>, DomainError> { async fn query(&self, req: &FeedRequest) -> Result<Paginated<FeedEntry>, DomainError> {
let viewer = q.viewer_id.as_ref().map(|v| v.as_uuid()); let viewer = req.query.viewer_id.as_ref().map(|v| v.as_uuid());
let page = &q.page; let page = &req.query.page;
let builder = FeedSqlBuilder::new(&req.options, &req.query.scope, viewer);
match &q.scope { let viewer_uuid = viewer.unwrap_or(uuid::Uuid::nil());
match &req.query.scope {
FeedScope::Home { following_ids } => { FeedScope::Home { following_ids } => {
let ids: Vec<uuid::Uuid> = following_ids.iter().map(|id| id.as_uuid()).collect(); let ids: Vec<uuid::Uuid> = following_ids.iter().map(|id| id.as_uuid()).collect();
let fed_clause = federation_following_clause(viewer); let (count_sql, data_sql) = builder.home();
let count_sql = format!(
"SELECT COUNT(*) FROM thoughts t WHERE (t.user_id=ANY($1){}) AND t.visibility != 'direct'",
fed_clause
);
let total: i64 = sqlx::query_scalar(&count_sql) let total: i64 = sqlx::query_scalar(&count_sql)
.bind(&ids) .bind(&ids)
.bind(viewer_uuid)
.fetch_one(&self.pool) .fetch_one(&self.pool)
.await .await
.into_domain()?; .into_domain()?;
let rows = sqlx::query_as::<_, FeedRow>(&data_sql)
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", fed_clause);
let rows = sqlx::query_as::<_, FeedRow>(&sql)
.bind(&ids) .bind(&ids)
.bind(page.limit()) .bind(page.limit())
.bind(page.offset()) .bind(page.offset())
.bind(viewer_uuid)
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await .await
.into_domain()?; .into_domain()?;
Ok(Paginated { Ok(Paginated {
items: rows items: rows
.into_iter() .into_iter()
@@ -193,22 +310,18 @@ impl FeedRepository for PgFeedRepository {
} }
FeedScope::Public => { FeedScope::Public => {
let total: i64 = sqlx::query_scalar( let (count_sql, data_sql) = builder.public();
"SELECT COUNT(*) FROM thoughts t WHERE t.local=true AND t.visibility='public'", let total: i64 = sqlx::query_scalar(&count_sql)
)
.fetch_one(&self.pool) .fetch_one(&self.pool)
.await .await
.into_domain()?; .into_domain()?;
let rows = sqlx::query_as::<_, FeedRow>(&data_sql)
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.limit())
.bind(page.offset()) .bind(page.offset())
.bind(viewer_uuid)
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await .await
.into_domain()?; .into_domain()?;
Ok(Paginated { Ok(Paginated {
items: rows items: rows
.into_iter() .into_iter()
@@ -221,24 +334,20 @@ impl FeedRepository for PgFeedRepository {
} }
FeedScope::Search { query } => { FeedScope::Search { query } => {
let total: i64 = sqlx::query_scalar( let (count_sql, data_sql) = builder.search();
"SELECT COUNT(*) FROM thoughts t WHERE t.content % $1 AND t.visibility='public'", let total: i64 = sqlx::query_scalar(&count_sql)
)
.bind(query) .bind(query)
.fetch_one(&self.pool) .fetch_one(&self.pool)
.await .await
.into_domain()?; .into_domain()?;
let rows = sqlx::query_as::<_, FeedRow>(&data_sql)
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(query)
.bind(page.limit()) .bind(page.limit())
.bind(page.offset()) .bind(page.offset())
.bind(viewer_uuid)
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await .await
.into_domain()?; .into_domain()?;
Ok(Paginated { Ok(Paginated {
items: rows items: rows
.into_iter() .into_iter()
@@ -251,33 +360,20 @@ impl FeedRepository for PgFeedRepository {
} }
FeedScope::Tag { tag_name } => { FeedScope::Tag { tag_name } => {
let total: i64 = sqlx::query_scalar( let (count_sql, data_sql) = builder.tag();
"SELECT COUNT(*) FROM thoughts t let total: i64 = sqlx::query_scalar(&count_sql)
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) .bind(tag_name)
.fetch_one(&self.pool) .fetch_one(&self.pool)
.await .await
.into_domain()?; .into_domain()?;
let rows = sqlx::query_as::<_, FeedRow>(&data_sql)
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(tag_name)
.bind(page.limit()) .bind(page.limit())
.bind(page.offset()) .bind(page.offset())
.bind(viewer_uuid)
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await .await
.into_domain()?; .into_domain()?;
Ok(Paginated { Ok(Paginated {
items: rows items: rows
.into_iter() .into_iter()
@@ -291,21 +387,14 @@ impl FeedRepository for PgFeedRepository {
FeedScope::User { user_id } => { FeedScope::User { user_id } => {
let uid = user_id.as_uuid(); let uid = user_id.as_uuid();
// Use nil UUID for unauthenticated viewers — won't match owner or follower checks. let (count_sql, data_sql) = builder.user();
let viewer_uuid = viewer.unwrap_or(uuid::Uuid::nil()); let total: i64 = sqlx::query_scalar(&count_sql)
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(uid)
.bind(viewer_uuid) .bind(viewer_uuid)
.fetch_one(&self.pool) .fetch_one(&self.pool)
.await .await
.into_domain()?; .into_domain()?;
let rows = sqlx::query_as::<_, FeedRow>(&data_sql)
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(uid)
.bind(page.limit()) .bind(page.limit())
.bind(page.offset()) .bind(page.offset())
@@ -313,7 +402,6 @@ impl FeedRepository for PgFeedRepository {
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await .await
.into_domain()?; .into_domain()?;
Ok(Paginated { Ok(Paginated {
items: rows items: rows
.into_iter() .into_iter()

View File

@@ -6,8 +6,8 @@ use domain::{
thought::{NewThought, Thought, Visibility}, thought::{NewThought, Thought, Visibility},
user::User, user::User,
}, },
ports::{FeedQuery, ThoughtRepository, UserWriter}, ports::{FeedOptions, FeedQuery, FeedRequest, ThoughtRepository, UserWriter},
value_objects::*, value_objects::{Content, Email, PasswordHash, ThoughtId, UserId, Username},
}; };
async fn seed(pool: &sqlx::PgPool, username: &str, content: &str) -> (User, Thought) { async fn seed(pool: &sqlx::PgPool, username: &str, content: &str) -> (User, Thought) {
@@ -28,6 +28,7 @@ async fn seed(pool: &sqlx::PgPool, username: &str, content: &str) -> (User, Thou
visibility: Visibility::Public, visibility: Visibility::Public,
content_warning: None, content_warning: None,
sensitive: false, sensitive: false,
mood: None,
}); });
trepo.save(&t).await.unwrap(); trepo.save(&t).await.unwrap();
(u, t) (u, t)
@@ -38,13 +39,16 @@ async fn public_feed_returns_local_thoughts(pool: sqlx::PgPool) {
let (_, _) = seed(&pool, "alice", "hello").await; let (_, _) = seed(&pool, "alice", "hello").await;
let repo = PgFeedRepository::new(pool); let repo = PgFeedRepository::new(pool);
let result = repo let result = repo
.query(&FeedQuery::public( .query(&FeedRequest {
query: FeedQuery::public(
PageParams { PageParams {
page: 1, page: 1,
per_page: 20, per_page: 20,
}, },
None, None,
)) ),
options: FeedOptions::default(),
})
.await .await
.unwrap(); .unwrap();
assert_eq!(result.total, 1); assert_eq!(result.total, 1);
@@ -57,14 +61,17 @@ async fn search_returns_matching_thoughts(pool: sqlx::PgPool) {
let (_, _) = seed(&pool, "bob", "goodbye world").await; let (_, _) = seed(&pool, "bob", "goodbye world").await;
let repo = PgFeedRepository::new(pool); let repo = PgFeedRepository::new(pool);
let result = repo let result = repo
.query(&FeedQuery::search( .query(&FeedRequest {
query: FeedQuery::search(
"hello world", "hello world",
PageParams { PageParams {
page: 1, page: 1,
per_page: 20, per_page: 20,
}, },
None, None,
)) ),
options: FeedOptions::default(),
})
.await .await
.unwrap(); .unwrap();
assert!(result.total >= 1); assert!(result.total >= 1);

View File

@@ -120,7 +120,7 @@ impl FollowRepository for PgFollowRepository {
.into_domain()?; .into_domain()?;
let rows = sqlx::query_as::<_, crate::user::UserRow>( let rows = sqlx::query_as::<_, crate::user::UserRow>(
"SELECT u.id,u.username,u.email,u.password_hash,u.display_name,u.bio,u.avatar_url,u.header_url,u.custom_css,u.local,u.ap_id,u.inbox_url,u.created_at,u.updated_at "SELECT u.id,u.username,u.email,u.password_hash,u.display_name,u.bio,u.avatar_url,u.header_url,u.custom_css,u.profile_fields,u.custom_moods,u.local,u.created_at,u.updated_at
FROM users u JOIN follows f ON f.follower_id=u.id FROM users u JOIN follows f ON f.follower_id=u.id
WHERE f.following_id=$1 AND f.state='accepted' WHERE f.following_id=$1 AND f.state='accepted'
ORDER BY f.created_at DESC LIMIT $2 OFFSET $3" ORDER BY f.created_at DESC LIMIT $2 OFFSET $3"
@@ -154,7 +154,7 @@ impl FollowRepository for PgFollowRepository {
.into_domain()?; .into_domain()?;
let rows = sqlx::query_as::<_, crate::user::UserRow>( let rows = sqlx::query_as::<_, crate::user::UserRow>(
"SELECT u.id,u.username,u.email,u.password_hash,u.display_name,u.bio,u.avatar_url,u.header_url,u.custom_css,u.local,u.ap_id,u.inbox_url,u.created_at,u.updated_at "SELECT u.id,u.username,u.email,u.password_hash,u.display_name,u.bio,u.avatar_url,u.header_url,u.custom_css,u.profile_fields,u.custom_moods,u.local,u.created_at,u.updated_at
FROM users u JOIN follows f ON f.following_id=u.id FROM users u JOIN follows f ON f.following_id=u.id
WHERE f.follower_id=$1 AND f.state='accepted' WHERE f.follower_id=$1 AND f.state='accepted'
ORDER BY f.created_at DESC LIMIT $2 OFFSET $3" ORDER BY f.created_at DESC LIMIT $2 OFFSET $3"
@@ -187,6 +187,59 @@ impl FollowRepository for PgFollowRepository {
.into_domain()?; .into_domain()?;
Ok(ids.into_iter().map(UserId::from_uuid).collect()) Ok(ids.into_iter().map(UserId::from_uuid).collect())
} }
async fn list_mutual(
&self,
user_id: &UserId,
page: &PageParams,
) -> Result<Paginated<User>, DomainError> {
let total: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM follows f1
WHERE f1.follower_id = $1 AND f1.state = 'accepted'
AND EXISTS (
SELECT 1 FROM follows f2
WHERE f2.follower_id = f1.following_id
AND f2.following_id = f1.follower_id
AND f2.state = 'accepted'
)",
)
.bind(user_id.as_uuid())
.fetch_one(&self.pool)
.await
.into_domain()?;
let rows = sqlx::query_as::<_, crate::user::UserRow>(
"SELECT u.id, u.username, u.email, u.password_hash, u.display_name, u.bio,
u.avatar_url, u.header_url, u.custom_css, u.profile_fields, u.custom_moods, u.local,
u.created_at, u.updated_at
FROM users u
JOIN follows f1
ON f1.follower_id = $1
AND f1.following_id = u.id
AND f1.state = 'accepted'
WHERE EXISTS (
SELECT 1 FROM follows f2
WHERE f2.follower_id = u.id
AND f2.following_id = $1
AND f2.state = 'accepted'
)
ORDER BY f1.created_at DESC
LIMIT $2 OFFSET $3",
)
.bind(user_id.as_uuid())
.bind(page.limit())
.bind(page.offset())
.fetch_all(&self.pool)
.await
.into_domain()?;
Ok(Paginated {
items: rows.into_iter().map(User::from).collect(),
total,
page: page.page,
per_page: page.per_page,
})
}
} }
#[cfg(test)] #[cfg(test)]

View File

@@ -1,7 +1,6 @@
use super::*; use super::*;
use crate::test_helpers::seed_user; use crate::test_helpers::seed_user;
use chrono::Utc; use chrono::Utc;
use domain::value_objects::*;
#[sqlx::test(migrations = "./migrations")] #[sqlx::test(migrations = "./migrations")]
async fn save_and_find_follow(pool: sqlx::PgPool) { async fn save_and_find_follow(pool: sqlx::PgPool) {
@@ -56,3 +55,86 @@ async fn get_accepted_following_ids(pool: sqlx::PgPool) {
let ids = repo.get_accepted_following_ids(&alice.id).await.unwrap(); let ids = repo.get_accepted_following_ids(&alice.id).await.unwrap();
assert_eq!(ids, vec![bob.id]); assert_eq!(ids, vec![bob.id]);
} }
#[sqlx::test(migrations = "./migrations")]
async fn list_mutual_returns_only_mutual_accepted_follows(pool: sqlx::PgPool) {
let alice = seed_user(&pool, "alice", "alice@ex.com").await;
let bob = seed_user(&pool, "bob", "bob@ex.com").await;
let carol = seed_user(&pool, "carol", "carol@ex.com").await;
let repo = PgFollowRepository::new(pool);
let page = domain::models::feed::PageParams {
page: 1,
per_page: 20,
};
// alice → bob (accepted), bob → alice (accepted) = friends
repo.save(&Follow {
follower_id: alice.id.clone(),
following_id: bob.id.clone(),
state: FollowState::Accepted,
ap_id: None,
created_at: Utc::now(),
})
.await
.unwrap();
repo.save(&Follow {
follower_id: bob.id.clone(),
following_id: alice.id.clone(),
state: FollowState::Accepted,
ap_id: None,
created_at: Utc::now(),
})
.await
.unwrap();
// alice → carol (accepted), carol does NOT follow back = not a friend
repo.save(&Follow {
follower_id: alice.id.clone(),
following_id: carol.id.clone(),
state: FollowState::Accepted,
ap_id: None,
created_at: Utc::now(),
})
.await
.unwrap();
let result = repo.list_mutual(&alice.id, &page).await.unwrap();
assert_eq!(result.total, 1);
assert_eq!(result.items.len(), 1);
assert_eq!(result.items[0].id, bob.id);
}
#[sqlx::test(migrations = "./migrations")]
async fn list_mutual_excludes_pending_follows(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 = PgFollowRepository::new(pool);
let page = domain::models::feed::PageParams {
page: 1,
per_page: 20,
};
// alice → bob (accepted), bob → alice (PENDING) = NOT a friend
repo.save(&Follow {
follower_id: alice.id.clone(),
following_id: bob.id.clone(),
state: FollowState::Accepted,
ap_id: None,
created_at: Utc::now(),
})
.await
.unwrap();
repo.save(&Follow {
follower_id: bob.id.clone(),
following_id: alice.id.clone(),
state: FollowState::Pending,
ap_id: None,
created_at: Utc::now(),
})
.await
.unwrap();
let result = repo.list_mutual(&alice.id, &page).await.unwrap();
assert_eq!(result.total, 0);
assert!(result.items.is_empty());
}

View File

@@ -0,0 +1,20 @@
pub fn parse_name_value(v: Option<serde_json::Value>) -> Vec<(String, String)> {
v.and_then(|v| v.as_array().cloned())
.map(|arr| {
arr.into_iter()
.filter_map(|item| {
let name = item.get("name")?.as_str()?.to_string();
let value = item.get("value")?.as_str()?.to_string();
Some((name, value))
})
.collect()
})
.unwrap_or_default()
}
pub fn serialize_name_value(fields: &[(String, String)]) -> serde_json::Value {
fields
.iter()
.map(|(n, v)| serde_json::json!({"name": n, "value": v}))
.collect()
}

View File

@@ -2,11 +2,13 @@ pub mod activitypub;
pub mod api_key; pub mod api_key;
pub mod block; pub mod block;
pub mod boost; pub mod boost;
pub mod constants;
mod db_error; mod db_error;
pub mod engagement; pub mod engagement;
pub mod failed_event; pub mod failed_event;
pub mod feed; pub mod feed;
pub mod follow; pub mod follow;
pub(crate) mod jsonb;
pub mod like; pub mod like;
pub mod notification; pub mod notification;
pub mod outbox; pub mod outbox;

View File

@@ -1,7 +1,6 @@
use super::*; use super::*;
use crate::test_helpers::seed_user_and_thought; use crate::test_helpers::seed_user_and_thought;
use chrono::Utc; use chrono::Utc;
use domain::value_objects::*;
#[sqlx::test(migrations = "./migrations")] #[sqlx::test(migrations = "./migrations")]
async fn like_and_count(pool: sqlx::PgPool) { async fn like_and_count(pool: sqlx::PgPool) {

View File

@@ -1,10 +1,7 @@
use super::*; use super::*;
use crate::test_helpers; use crate::test_helpers;
use chrono::Utc; use chrono::Utc;
use domain::{ use domain::models::notification::NotificationKind;
models::{notification::NotificationKind, user::User},
value_objects::*,
};
#[sqlx::test(migrations = "./migrations")] #[sqlx::test(migrations = "./migrations")]
async fn save_and_list(pool: sqlx::PgPool) { async fn save_and_list(pool: sqlx::PgPool) {

View File

@@ -32,6 +32,9 @@ fn aggregate_id(event: &DomainEvent) -> Uuid {
DomainEvent::UserUnblocked { blocker_id, .. } => blocker_id.as_uuid(), DomainEvent::UserUnblocked { blocker_id, .. } => blocker_id.as_uuid(),
DomainEvent::UserRegistered { user_id } => user_id.as_uuid(), DomainEvent::UserRegistered { user_id } => user_id.as_uuid(),
DomainEvent::ProfileUpdated { user_id } => user_id.as_uuid(), DomainEvent::ProfileUpdated { user_id } => user_id.as_uuid(),
DomainEvent::RemoteFollowAccepted { local_user_id, .. } => local_user_id.as_uuid(),
DomainEvent::RemoteFollowRejected { local_user_id, .. } => local_user_id.as_uuid(),
DomainEvent::ActorMoved { user_id, .. } => user_id.as_uuid(),
DomainEvent::MentionReceived { thought_id, .. } => thought_id.as_uuid(), DomainEvent::MentionReceived { thought_id, .. } => thought_id.as_uuid(),
} }
} }

View File

@@ -18,14 +18,40 @@ impl PgRemoteActorRepository {
#[async_trait] #[async_trait]
impl RemoteActorRepository for PgRemoteActorRepository { impl RemoteActorRepository for PgRemoteActorRepository {
async fn upsert(&self, a: &RemoteActor) -> Result<(), DomainError> { async fn upsert(&self, a: &RemoteActor) -> Result<(), DomainError> {
let also_known_as: Option<Vec<&str>> = if a.also_known_as.is_empty() {
None
} else {
Some(a.also_known_as.iter().map(|s| s.as_str()).collect())
};
let attachment_json = crate::jsonb::serialize_name_value(&a.attachment);
sqlx::query( sqlx::query(
"INSERT INTO remote_actors(url,handle,display_name,avatar_url,last_fetched_at) "INSERT INTO remote_actors(url,handle,display_name,avatar_url,last_fetched_at,
VALUES($1,$2,$3,$4,$5) bio,banner_url,outbox_url,followers_url,following_url,also_known_as,attachment)
ON CONFLICT(url) DO UPDATE SET handle=EXCLUDED.handle,display_name=EXCLUDED.display_name, VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)
avatar_url=EXCLUDED.avatar_url,last_fetched_at=EXCLUDED.last_fetched_at" ON CONFLICT(url) DO UPDATE SET
handle=EXCLUDED.handle,display_name=EXCLUDED.display_name,
avatar_url=EXCLUDED.avatar_url,last_fetched_at=EXCLUDED.last_fetched_at,
bio=EXCLUDED.bio,banner_url=EXCLUDED.banner_url,
outbox_url=EXCLUDED.outbox_url,followers_url=EXCLUDED.followers_url,
following_url=EXCLUDED.following_url,also_known_as=EXCLUDED.also_known_as,
attachment=EXCLUDED.attachment",
) )
.bind(&a.url).bind(&a.handle).bind(&a.display_name).bind(&a.avatar_url).bind(a.last_fetched_at) .bind(&a.url)
.execute(&self.pool).await.into_domain().map(|_| ()) .bind(&a.handle)
.bind(&a.display_name)
.bind(&a.avatar_url)
.bind(a.last_fetched_at)
.bind(&a.bio)
.bind(&a.banner_url)
.bind(&a.outbox_url)
.bind(&a.followers_url)
.bind(&a.following_url)
.bind(also_known_as.as_deref())
.bind(&attachment_json)
.execute(&self.pool)
.await
.into_domain()
.map(|_| ())
} }
async fn find_by_url(&self, url: &str) -> Result<Option<RemoteActor>, DomainError> { async fn find_by_url(&self, url: &str) -> Result<Option<RemoteActor>, DomainError> {
@@ -36,24 +62,43 @@ impl RemoteActorRepository for PgRemoteActorRepository {
display_name: Option<String>, display_name: Option<String>,
avatar_url: Option<String>, avatar_url: Option<String>,
last_fetched_at: DateTime<Utc>, last_fetched_at: DateTime<Utc>,
bio: Option<String>,
banner_url: Option<String>,
outbox_url: Option<String>,
followers_url: Option<String>,
following_url: Option<String>,
also_known_as: Option<Vec<String>>,
inbox_url: Option<String>,
shared_inbox_url: Option<String>,
attachment: Option<serde_json::Value>,
} }
sqlx::query_as::<_, Row>( sqlx::query_as::<_, Row>(
"SELECT url,handle,display_name,avatar_url,last_fetched_at FROM remote_actors WHERE url=$1" "SELECT url,handle,display_name,avatar_url,last_fetched_at,
).bind(url).fetch_optional(&self.pool).await bio,banner_url,outbox_url,followers_url,following_url,also_known_as,
inbox_url,shared_inbox_url,attachment
FROM remote_actors WHERE url=$1",
)
.bind(url)
.fetch_optional(&self.pool)
.await
.into_domain() .into_domain()
.map(|o| o.map(|r| RemoteActor { .map(|o| {
o.map(|r| RemoteActor {
url: r.url, url: r.url,
handle: r.handle, handle: r.handle,
display_name: r.display_name, display_name: r.display_name,
avatar_url: r.avatar_url, avatar_url: r.avatar_url,
last_fetched_at: r.last_fetched_at, last_fetched_at: r.last_fetched_at,
bio: None, bio: r.bio,
banner_url: None, banner_url: r.banner_url,
also_known_as: None, also_known_as: r.also_known_as.unwrap_or_default(),
outbox_url: None, outbox_url: r.outbox_url,
followers_url: None, followers_url: r.followers_url,
following_url: None, following_url: r.following_url,
attachment: vec![], inbox_url: r.inbox_url,
})) shared_inbox_url: r.shared_inbox_url,
attachment: crate::jsonb::parse_name_value(r.attachment),
})
})
} }
} }

View File

@@ -12,6 +12,12 @@ use domain::{
}; };
use sqlx::PgPool; use sqlx::PgPool;
#[derive(sqlx::FromRow)]
struct TagRow {
id: i32,
name: String,
}
pub struct PgTagRepository { pub struct PgTagRepository {
pool: PgPool, pool: PgPool,
} }
@@ -30,12 +36,7 @@ impl TagRepository for PgTagRepository {
.execute(&self.pool) .execute(&self.pool)
.await .await
.into_domain()?; .into_domain()?;
#[derive(sqlx::FromRow)] let row = sqlx::query_as::<_, TagRow>("SELECT id,name FROM tags WHERE name=$1")
struct Row {
id: i32,
name: String,
}
let row = sqlx::query_as::<_, Row>("SELECT id,name FROM tags WHERE name=$1")
.bind(&name) .bind(&name)
.fetch_one(&self.pool) .fetch_one(&self.pool)
.await .await
@@ -72,12 +73,7 @@ impl TagRepository for PgTagRepository {
} }
async fn list_for_thought(&self, thought_id: &ThoughtId) -> Result<Vec<Tag>, DomainError> { async fn list_for_thought(&self, thought_id: &ThoughtId) -> Result<Vec<Tag>, DomainError> {
#[derive(sqlx::FromRow)] sqlx::query_as::<_, TagRow>(
struct Row {
id: i32,
name: String,
}
sqlx::query_as::<_, Row>(
"SELECT t.id,t.name FROM tags t JOIN thought_tags tt ON tt.tag_id=t.id WHERE tt.thought_id=$1" "SELECT t.id,t.name FROM tags t JOIN thought_tags tt ON tt.tag_id=t.id WHERE tt.thought_id=$1"
).bind(thought_id.as_uuid()).fetch_all(&self.pool).await ).bind(thought_id.as_uuid()).fetch_all(&self.pool).await
.into_domain() .into_domain()

View File

@@ -37,6 +37,7 @@ async fn attach_and_list(pool: sqlx::PgPool) {
visibility: Visibility::Public, visibility: Visibility::Public,
content_warning: None, content_warning: None,
sensitive: false, sensitive: false,
mood: None,
}); });
trepo.save(&t).await.unwrap(); trepo.save(&t).await.unwrap();
let repo = PgTagRepository::new(pool); let repo = PgTagRepository::new(pool);

View File

@@ -31,6 +31,7 @@ pub async fn seed_user_and_thought(pool: &sqlx::PgPool) -> (User, Thought) {
visibility: Visibility::Public, visibility: Visibility::Public,
content_warning: None, content_warning: None,
sensitive: false, sensitive: false,
mood: None,
}); });
trepo.save(&t).await.unwrap(); trepo.save(&t).await.unwrap();
(user, t) (user, t)

View File

@@ -35,6 +35,7 @@ pub(crate) struct ThoughtRow {
pub created_at: DateTime<Utc>, pub created_at: DateTime<Utc>,
pub updated_at: Option<DateTime<Utc>>, pub updated_at: Option<DateTime<Utc>>,
pub note_extensions: Option<serde_json::Value>, pub note_extensions: Option<serde_json::Value>,
pub mood: Option<String>,
} }
impl TryFrom<ThoughtRow> for Thought { impl TryFrom<ThoughtRow> for Thought {
@@ -52,19 +53,20 @@ impl TryFrom<ThoughtRow> for Thought {
created_at: r.created_at, created_at: r.created_at,
updated_at: r.updated_at, updated_at: r.updated_at,
note_extensions: r.note_extensions, note_extensions: r.note_extensions,
mood: r.mood,
}) })
} }
} }
const THOUGHT_SELECT: &str = const THOUGHT_SELECT: &str =
"SELECT id,user_id,content,in_reply_to_id,visibility,content_warning,sensitive,local,created_at,updated_at,note_extensions FROM thoughts"; "SELECT id,user_id,content,in_reply_to_id,visibility,content_warning,sensitive,local,created_at,updated_at,note_extensions,mood FROM thoughts";
#[async_trait] #[async_trait]
impl ThoughtRepository for PgThoughtRepository { impl ThoughtRepository for PgThoughtRepository {
async fn save(&self, t: &Thought) -> Result<(), DomainError> { async fn save(&self, t: &Thought) -> Result<(), DomainError> {
sqlx::query( sqlx::query(
"INSERT INTO thoughts(id,user_id,content,in_reply_to_id,visibility,content_warning,sensitive,local,created_at) "INSERT INTO thoughts(id,user_id,content,in_reply_to_id,visibility,content_warning,sensitive,local,created_at,mood)
VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)
ON CONFLICT(id) DO UPDATE SET content=EXCLUDED.content,updated_at=NOW()" ON CONFLICT(id) DO UPDATE SET content=EXCLUDED.content,updated_at=NOW()"
) )
.bind(t.id.as_uuid()) .bind(t.id.as_uuid())
@@ -76,6 +78,7 @@ impl ThoughtRepository for PgThoughtRepository {
.bind(t.sensitive) .bind(t.sensitive)
.bind(t.local) .bind(t.local)
.bind(t.created_at) .bind(t.created_at)
.bind(&t.mood)
.execute(&self.pool) .execute(&self.pool)
.await .await
.into_domain() .into_domain()
@@ -119,11 +122,11 @@ impl ThoughtRepository for PgThoughtRepository {
sqlx::query_as::<_, ThoughtRow>( sqlx::query_as::<_, ThoughtRow>(
"WITH RECURSIVE thread AS ( "WITH RECURSIVE thread AS (
SELECT id,user_id,content,in_reply_to_id, SELECT id,user_id,content,in_reply_to_id,
visibility,content_warning,sensitive,local,created_at,updated_at,note_extensions visibility,content_warning,sensitive,local,created_at,updated_at,note_extensions,mood
FROM thoughts WHERE id = $1 FROM thoughts WHERE id = $1
UNION ALL UNION ALL
SELECT t.id,t.user_id,t.content,t.in_reply_to_id, SELECT t.id,t.user_id,t.content,t.in_reply_to_id,
t.visibility,t.content_warning,t.sensitive,t.local,t.created_at,t.updated_at,t.note_extensions t.visibility,t.content_warning,t.sensitive,t.local,t.created_at,t.updated_at,t.note_extensions,t.mood
FROM thoughts t JOIN thread ON t.in_reply_to_id = thread.id FROM thoughts t JOIN thread ON t.in_reply_to_id = thread.id
) )
SELECT * FROM thread ORDER BY created_at ASC", SELECT * FROM thread ORDER BY created_at ASC",

View File

@@ -1,9 +1,6 @@
use super::*; use super::*;
use crate::test_helpers::seed_user; use crate::test_helpers::seed_user;
use domain::{ use domain::models::thought::{NewThought, Thought, Visibility};
models::thought::{NewThought, Thought, Visibility},
value_objects::*,
};
#[sqlx::test(migrations = "./migrations")] #[sqlx::test(migrations = "./migrations")]
async fn save_and_find_thought(pool: sqlx::PgPool) { async fn save_and_find_thought(pool: sqlx::PgPool) {
@@ -17,6 +14,7 @@ async fn save_and_find_thought(pool: sqlx::PgPool) {
visibility: Visibility::Public, visibility: Visibility::Public,
content_warning: None, content_warning: None,
sensitive: false, sensitive: false,
mood: None,
}); });
repo.save(&t).await.unwrap(); repo.save(&t).await.unwrap();
let found = repo.find_by_id(&t.id).await.unwrap().unwrap(); let found = repo.find_by_id(&t.id).await.unwrap().unwrap();
@@ -36,6 +34,7 @@ async fn delete_thought(pool: sqlx::PgPool) {
visibility: Visibility::Public, visibility: Visibility::Public,
content_warning: None, content_warning: None,
sensitive: false, sensitive: false,
mood: None,
}); });
repo.save(&t).await.unwrap(); repo.save(&t).await.unwrap();
repo.delete(&t.id, &user.id).await.unwrap(); repo.delete(&t.id, &user.id).await.unwrap();
@@ -55,6 +54,7 @@ async fn delete_wrong_owner_returns_not_found(pool: sqlx::PgPool) {
visibility: Visibility::Public, visibility: Visibility::Public,
content_warning: None, content_warning: None,
sensitive: false, sensitive: false,
mood: None,
}); });
repo.save(&t).await.unwrap(); repo.save(&t).await.unwrap();
let err = repo.delete(&t.id, &bob.id).await.unwrap_err(); let err = repo.delete(&t.id, &bob.id).await.unwrap_err();
@@ -73,6 +73,7 @@ async fn get_thread_returns_root_and_replies(pool: sqlx::PgPool) {
visibility: Visibility::Public, visibility: Visibility::Public,
content_warning: None, content_warning: None,
sensitive: false, sensitive: false,
mood: None,
}); });
let reply = Thought::new_local(NewThought { let reply = Thought::new_local(NewThought {
id: ThoughtId::new(), id: ThoughtId::new(),
@@ -82,6 +83,7 @@ async fn get_thread_returns_root_and_replies(pool: sqlx::PgPool) {
visibility: Visibility::Public, visibility: Visibility::Public,
content_warning: None, content_warning: None,
sensitive: false, sensitive: false,
mood: None,
}); });
repo.save(&root).await.unwrap(); repo.save(&root).await.unwrap();
repo.save(&reply).await.unwrap(); repo.save(&reply).await.unwrap();

View File

@@ -44,27 +44,17 @@ impl TopFriendRepository for PgTopFriendRepository {
async fn list_for_user(&self, user_id: &UserId) -> Result<Vec<(TopFriend, User)>, DomainError> { async fn list_for_user(&self, user_id: &UserId) -> Result<Vec<(TopFriend, User)>, DomainError> {
#[derive(sqlx::FromRow)] #[derive(sqlx::FromRow)]
struct Row { struct TopFriendRow {
tf_user_id: uuid::Uuid, tf_user_id: uuid::Uuid,
friend_id: uuid::Uuid, friend_id: uuid::Uuid,
position: i16, position: i16,
id: uuid::Uuid, #[sqlx(flatten)]
username: String, user: crate::user::UserRow,
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,
created_at: chrono::DateTime<chrono::Utc>,
updated_at: chrono::DateTime<chrono::Utc>,
} }
let rows = sqlx::query_as::<_, Row>( let rows = sqlx::query_as::<_, TopFriendRow>(
"SELECT tf.user_id AS tf_user_id, tf.friend_id, tf.position, "SELECT tf.user_id AS tf_user_id, tf.friend_id, tf.position,
u.id, u.username, u.email, u.password_hash, u.display_name, u.bio, u.id, u.username, u.email, u.password_hash, u.display_name, u.bio,
u.avatar_url, u.header_url, u.custom_css, u.local, u.avatar_url, u.header_url, u.custom_css, u.profile_fields, u.custom_moods, u.local,
u.created_at, u.updated_at u.created_at, u.updated_at
FROM top_friends tf JOIN users u ON u.id=tf.friend_id FROM top_friends tf JOIN users u ON u.id=tf.friend_id
WHERE tf.user_id=$1 ORDER BY tf.position", WHERE tf.user_id=$1 ORDER BY tf.position",
@@ -77,27 +67,12 @@ impl TopFriendRepository for PgTopFriendRepository {
Ok(rows Ok(rows
.into_iter() .into_iter()
.map(|r| { .map(|r| {
use domain::value_objects::{Email, PasswordHash, Username};
let tf = TopFriend { let tf = TopFriend {
user_id: UserId::from_uuid(r.tf_user_id), user_id: UserId::from_uuid(r.tf_user_id),
friend_id: UserId::from_uuid(r.friend_id), friend_id: UserId::from_uuid(r.friend_id),
position: r.position, position: r.position,
}; };
let u = User { (tf, User::from(r.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,
created_at: r.created_at,
updated_at: r.updated_at,
};
(tf, u)
}) })
.collect()) .collect())
} }

View File

@@ -31,6 +31,8 @@ pub struct UserRow {
pub avatar_url: Option<String>, pub avatar_url: Option<String>,
pub header_url: Option<String>, pub header_url: Option<String>,
pub custom_css: Option<String>, pub custom_css: Option<String>,
pub profile_fields: Option<serde_json::Value>,
pub custom_moods: Option<serde_json::Value>,
pub local: bool, pub local: bool,
pub created_at: DateTime<Utc>, pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>, pub updated_at: DateTime<Utc>,
@@ -48,6 +50,8 @@ impl From<UserRow> for User {
avatar_url: r.avatar_url, avatar_url: r.avatar_url,
header_url: r.header_url, header_url: r.header_url,
custom_css: r.custom_css, custom_css: r.custom_css,
profile_fields: crate::jsonb::parse_name_value(r.profile_fields),
custom_moods: crate::jsonb::parse_name_value(r.custom_moods),
local: r.local, local: r.local,
created_at: r.created_at, created_at: r.created_at,
updated_at: r.updated_at, updated_at: r.updated_at,
@@ -57,7 +61,7 @@ impl From<UserRow> for User {
pub const USER_SELECT: &str = pub const USER_SELECT: &str =
"SELECT id,username,email,password_hash,display_name,bio,avatar_url,header_url,\ "SELECT id,username,email,password_hash,display_name,bio,avatar_url,header_url,\
custom_css,local,created_at,updated_at FROM users"; custom_css,profile_fields,custom_moods,local,created_at,updated_at FROM users";
#[async_trait] #[async_trait]
impl UserReader for PgUserRepository { impl UserReader for PgUserRepository {
@@ -222,14 +226,18 @@ impl UserReader for PgUserRepository {
#[async_trait] #[async_trait]
impl UserWriter for PgUserRepository { impl UserWriter for PgUserRepository {
async fn save(&self, user: &User) -> Result<(), DomainError> { async fn save(&self, user: &User) -> Result<(), DomainError> {
let profile_fields_json = crate::jsonb::serialize_name_value(&user.profile_fields);
let custom_moods_json = crate::jsonb::serialize_name_value(&user.custom_moods);
sqlx::query( sqlx::query(
"INSERT INTO users (id,username,email,password_hash,display_name,bio,avatar_url,header_url,custom_css,local,created_at,updated_at) "INSERT INTO users (id,username,email,password_hash,display_name,bio,avatar_url,header_url,custom_css,profile_fields,custom_moods,local,created_at,updated_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)
ON CONFLICT(id) DO UPDATE SET ON CONFLICT(id) DO UPDATE SET
username=EXCLUDED.username, email=EXCLUDED.email, username=EXCLUDED.username, email=EXCLUDED.email,
password_hash=EXCLUDED.password_hash, display_name=EXCLUDED.display_name, password_hash=EXCLUDED.password_hash, display_name=EXCLUDED.display_name,
bio=EXCLUDED.bio, avatar_url=EXCLUDED.avatar_url, bio=EXCLUDED.bio, avatar_url=EXCLUDED.avatar_url,
header_url=EXCLUDED.header_url, custom_css=EXCLUDED.custom_css, header_url=EXCLUDED.header_url, custom_css=EXCLUDED.custom_css,
profile_fields=EXCLUDED.profile_fields,
custom_moods=EXCLUDED.custom_moods,
local=EXCLUDED.local, local=EXCLUDED.local,
updated_at=NOW()" updated_at=NOW()"
) )
@@ -242,6 +250,8 @@ impl UserWriter for PgUserRepository {
.bind(&user.avatar_url) .bind(&user.avatar_url)
.bind(&user.header_url) .bind(&user.header_url)
.bind(&user.custom_css) .bind(&user.custom_css)
.bind(&profile_fields_json)
.bind(&custom_moods_json)
.bind(user.local) .bind(user.local)
.bind(user.created_at) .bind(user.created_at)
.bind(user.updated_at) .bind(user.updated_at)
@@ -267,6 +277,14 @@ impl UserWriter for PgUserRepository {
user_id: &UserId, user_id: &UserId,
input: UpdateProfileInput, input: UpdateProfileInput,
) -> Result<(), DomainError> { ) -> Result<(), DomainError> {
let profile_fields_json: Option<serde_json::Value> = input
.profile_fields
.as_ref()
.map(|f| crate::jsonb::serialize_name_value(f));
let custom_moods_json: Option<serde_json::Value> = input
.custom_moods
.as_ref()
.map(|f| crate::jsonb::serialize_name_value(f));
sqlx::query( sqlx::query(
"UPDATE users SET \ "UPDATE users SET \
display_name = COALESCE($2, display_name), \ display_name = COALESCE($2, display_name), \
@@ -274,6 +292,8 @@ impl UserWriter for PgUserRepository {
avatar_url = COALESCE($4, avatar_url), \ avatar_url = COALESCE($4, avatar_url), \
header_url = COALESCE($5, header_url), \ header_url = COALESCE($5, header_url), \
custom_css = COALESCE($6, custom_css), \ custom_css = COALESCE($6, custom_css), \
profile_fields = COALESCE($7, profile_fields), \
custom_moods = COALESCE($8, custom_moods), \
updated_at = NOW() \ updated_at = NOW() \
WHERE id = $1", WHERE id = $1",
) )
@@ -283,6 +303,22 @@ impl UserWriter for PgUserRepository {
.bind(input.avatar_url) .bind(input.avatar_url)
.bind(input.header_url) .bind(input.header_url)
.bind(input.custom_css) .bind(input.custom_css)
.bind(profile_fields_json)
.bind(custom_moods_json)
.execute(&self.pool)
.await
.into_domain()
.map(|_| ())
}
async fn set_also_known_as(
&self,
user_id: &UserId,
value: Option<String>,
) -> Result<(), DomainError> {
sqlx::query("UPDATE users SET also_known_as = $2, updated_at = NOW() WHERE id = $1")
.bind(user_id.as_uuid())
.bind(value)
.execute(&self.pool) .execute(&self.pool)
.await .await
.into_domain() .into_domain()

View File

@@ -1,8 +1,5 @@
use super::*; use super::*;
use domain::{ use domain::models::user::{UpdateProfileInput, User};
models::user::{UpdateProfileInput, User},
value_objects::*,
};
#[sqlx::test(migrations = "./migrations")] #[sqlx::test(migrations = "./migrations")]
async fn save_and_find_by_id(pool: sqlx::PgPool) { async fn save_and_find_by_id(pool: sqlx::PgPool) {

View File

@@ -31,6 +31,7 @@ pub struct CreateThoughtRequest {
pub visibility: Option<String>, pub visibility: Option<String>,
pub content_warning: Option<String>, pub content_warning: Option<String>,
pub sensitive: Option<bool>, pub sensitive: Option<bool>,
pub mood: Option<String>,
} }
#[derive(Deserialize, utoipa::ToSchema)] #[derive(Deserialize, utoipa::ToSchema)]
@@ -47,6 +48,8 @@ pub struct UpdateProfileRequest {
pub avatar_url: Option<String>, pub avatar_url: Option<String>,
pub header_url: Option<String>, pub header_url: Option<String>,
pub custom_css: Option<String>, pub custom_css: Option<String>,
pub profile_fields: Option<Vec<crate::responses::ProfileField>>,
pub custom_moods: Option<Vec<crate::responses::ProfileField>>,
} }
#[derive(Deserialize, utoipa::ToSchema)] #[derive(Deserialize, utoipa::ToSchema)]

View File

@@ -1,5 +1,5 @@
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use serde::Serialize; use serde::{Deserialize, Serialize};
use uuid::Uuid; use uuid::Uuid;
#[derive(Serialize, utoipa::ToSchema)] #[derive(Serialize, utoipa::ToSchema)]
@@ -19,6 +19,8 @@ pub struct UserResponse {
pub avatar_url: Option<String>, pub avatar_url: Option<String>,
pub header_url: Option<String>, pub header_url: Option<String>,
pub custom_css: Option<String>, pub custom_css: Option<String>,
pub profile_fields: Vec<ProfileField>,
pub custom_moods: Vec<ProfileField>,
pub local: bool, pub local: bool,
pub is_followed_by_viewer: bool, pub is_followed_by_viewer: bool,
#[serde(rename = "joinedAt")] #[serde(rename = "joinedAt")]
@@ -47,6 +49,8 @@ pub struct ThoughtResponse {
pub updated_at: Option<DateTime<Utc>>, pub updated_at: Option<DateTime<Utc>>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub note_extensions: Option<serde_json::Value>, pub note_extensions: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub mood: Option<String>,
} }
#[derive(Serialize, utoipa::ToSchema)] #[derive(Serialize, utoipa::ToSchema)]
@@ -83,6 +87,13 @@ pub struct TopFriendsResponse {
pub top_friends: Vec<UserResponse>, pub top_friends: Vec<UserResponse>,
} }
#[derive(Serialize, utoipa::ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct NotificationSummaryResponse {
pub total: i64,
pub unread: u64,
}
#[derive(Serialize, utoipa::ToSchema)] #[derive(Serialize, utoipa::ToSchema)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct ErrorResponse { pub struct ErrorResponse {
@@ -98,7 +109,7 @@ pub struct CreatedApiKeyResponse {
pub key: String, pub key: String,
} }
#[derive(Serialize, Clone, utoipa::ToSchema)] #[derive(Serialize, Deserialize, Clone, utoipa::ToSchema)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct ProfileField { pub struct ProfileField {
pub name: String, pub name: String,
@@ -114,7 +125,7 @@ pub struct RemoteActorResponse {
pub url: String, pub url: String,
pub bio: Option<String>, pub bio: Option<String>,
pub banner_url: Option<String>, pub banner_url: Option<String>,
pub also_known_as: Option<String>, pub also_known_as: Vec<String>,
pub outbox_url: Option<String>, pub outbox_url: Option<String>,
pub followers_url: Option<String>, pub followers_url: Option<String>,
pub following_url: Option<String>, pub following_url: Option<String>,

View File

@@ -5,7 +5,6 @@ edition = "2021"
[dependencies] [dependencies]
domain = { workspace = true } domain = { workspace = true }
activitypub = { workspace = true }
async-trait = { workspace = true } async-trait = { workspace = true }
thiserror = { workspace = true } thiserror = { workspace = true }
uuid = { workspace = true } uuid = { workspace = true }
@@ -21,3 +20,4 @@ futures = { workspace = true }
[dev-dependencies] [dev-dependencies]
tokio = { workspace = true, features = ["full"] } tokio = { workspace = true, features = ["full"] }
domain = { workspace = true, features = ["test-helpers"] } domain = { workspace = true, features = ["test-helpers"] }
serde_json = { workspace = true }

View File

@@ -1,19 +1,22 @@
use activitypub::{ActivityPubRepository, OutboundFederationPort};
use domain::{ use domain::{
errors::DomainError, errors::DomainError,
events::DomainEvent, events::DomainEvent,
models::thought::Visibility, models::thought::Visibility,
ports::{ThoughtRepository, UserReader}, ports::{FederationBroadcastPort, FederationContentRepository, ThoughtRepository, UserReader},
value_objects::ThoughtId, value_objects::ThoughtId,
}; };
use std::sync::Arc; use std::sync::Arc;
fn should_broadcast(t: &domain::models::thought::Thought) -> bool {
t.local && matches!(t.visibility, Visibility::Public | Visibility::Unlisted)
}
pub struct FederationEventService { pub struct FederationEventService {
pub thoughts: Arc<dyn ThoughtRepository>, pub thoughts: Arc<dyn ThoughtRepository>,
pub users: Arc<dyn UserReader>, pub users: Arc<dyn UserReader>,
pub ap: Arc<dyn OutboundFederationPort>, pub ap: Arc<dyn FederationBroadcastPort>,
pub base_url: String, pub base_url: String,
pub ap_repo: Arc<dyn ActivityPubRepository>, pub ap_repo: Arc<dyn FederationContentRepository>,
} }
impl FederationEventService { impl FederationEventService {
@@ -32,15 +35,7 @@ impl FederationEventService {
.. ..
} => { } => {
let thought = match self.thoughts.find_by_id(thought_id).await? { let thought = match self.thoughts.find_by_id(thought_id).await? {
Some(t) Some(t) if should_broadcast(&t) => t,
if t.local
&& matches!(
t.visibility,
Visibility::Public | Visibility::Unlisted
) =>
{
t
}
_ => { _ => {
tracing::debug!(thought_id = %thought_id, "federation: skipping ThoughtCreated (remote or non-public)"); tracing::debug!(thought_id = %thought_id, "federation: skipping ThoughtCreated (remote or non-public)");
return Ok(()); return Ok(());
@@ -86,15 +81,7 @@ impl FederationEventService {
user_id, user_id,
} => { } => {
let thought = match self.thoughts.find_by_id(thought_id).await? { let thought = match self.thoughts.find_by_id(thought_id).await? {
Some(t) Some(t) if should_broadcast(&t) => t,
if t.local
&& matches!(
t.visibility,
Visibility::Public | Visibility::Unlisted
) =>
{
t
}
_ => return Ok(()), _ => return Ok(()),
}; };
let user = match self.users.find_by_id(user_id).await? { let user = match self.users.find_by_id(user_id).await? {
@@ -125,14 +112,10 @@ impl FederationEventService {
user_id, user_id,
thought_id, thought_id,
} => { } => {
let booster = match self.users.find_by_id(user_id).await? { if !matches!(self.users.find_by_id(user_id).await?, Some(u) if u.local) {
Some(u) if u.local => u,
_ => {
tracing::debug!(user_id = %user_id, "federation: skipping BoostAdded (remote user)"); tracing::debug!(user_id = %user_id, "federation: skipping BoostAdded (remote user)");
return Ok(()); return Ok(());
} }
};
let _ = booster;
if self.thoughts.find_by_id(thought_id).await?.is_none() { if self.thoughts.find_by_id(thought_id).await?.is_none() {
return Ok(()); return Ok(());
} }
@@ -160,14 +143,10 @@ impl FederationEventService {
user_id, user_id,
thought_id, thought_id,
} => { } => {
let liker = match self.users.find_by_id(user_id).await? { if !matches!(self.users.find_by_id(user_id).await?, Some(u) if u.local) {
Some(u) if u.local => u,
_ => {
tracing::debug!(user_id = %user_id, "federation: skipping LikeAdded (remote user)"); tracing::debug!(user_id = %user_id, "federation: skipping LikeAdded (remote user)");
return Ok(()); return Ok(());
} }
};
let _ = liker;
let thought = match self.thoughts.find_by_id(thought_id).await? { let thought = match self.thoughts.find_by_id(thought_id).await? {
Some(t) => t, Some(t) => t,
_ => return Ok(()), _ => return Ok(()),
@@ -193,11 +172,9 @@ impl FederationEventService {
user_id, user_id,
thought_id, thought_id,
} => { } => {
let liker = match self.users.find_by_id(user_id).await? { if !matches!(self.users.find_by_id(user_id).await?, Some(u) if u.local) {
Some(u) if u.local => u, return Ok(());
_ => return Ok(()), }
};
let _ = liker;
let thought = match self.thoughts.find_by_id(thought_id).await? { let thought = match self.thoughts.find_by_id(thought_id).await? {
Some(t) => t, Some(t) => t,
_ => return Ok(()), _ => return Ok(()),

View File

@@ -1,7 +1,7 @@
use super::*; use super::*;
use crate::testing::TestApRepo; use crate::testing::TestApRepo;
use activitypub::{ActorApUrls, OutboundFederationPort};
use async_trait::async_trait; use async_trait::async_trait;
use domain::ports::{ActorFederationUrls, FederationBroadcastPort};
use domain::{ use domain::{
errors::DomainError, errors::DomainError,
events::DomainEvent, events::DomainEvent,
@@ -27,7 +27,7 @@ struct SpyPort {
} }
#[async_trait] #[async_trait]
impl OutboundFederationPort for SpyPort { impl FederationBroadcastPort for SpyPort {
async fn broadcast_create( async fn broadcast_create(
&self, &self,
_: &UserId, _: &UserId,
@@ -100,6 +100,7 @@ fn local_thought(author_id: UserId) -> Thought {
visibility: Visibility::Public, visibility: Visibility::Public,
content_warning: None, content_warning: None,
sensitive: false, sensitive: false,
mood: None,
}) })
} }
@@ -283,6 +284,7 @@ async fn direct_thought_created_does_not_broadcast() {
visibility: Visibility::Direct, visibility: Visibility::Direct,
content_warning: None, content_warning: None,
sensitive: false, sensitive: false,
mood: None,
}); });
store.users.lock().unwrap().push(alice.clone()); store.users.lock().unwrap().push(alice.clone());
store.thoughts.lock().unwrap().push(thought.clone()); store.thoughts.lock().unwrap().push(thought.clone());
@@ -312,6 +314,7 @@ async fn followers_only_thought_does_not_broadcast_publicly() {
visibility: Visibility::Followers, visibility: Visibility::Followers,
content_warning: None, content_warning: None,
sensitive: false, sensitive: false,
mood: None,
}); });
store.users.lock().unwrap().push(alice.clone()); store.users.lock().unwrap().push(alice.clone());
store.thoughts.lock().unwrap().push(thought.clone()); store.thoughts.lock().unwrap().push(thought.clone());
@@ -479,7 +482,7 @@ async fn like_added_local_user_remote_thought_broadcasts_like() {
let ap_repo = TestApRepo::new(store.clone()); let ap_repo = TestApRepo::new(store.clone());
ap_repo.actor_ap_urls.lock().unwrap().insert( ap_repo.actor_ap_urls.lock().unwrap().insert(
author.id.clone(), author.id.clone(),
ActorApUrls { ActorFederationUrls {
ap_id: "https://mastodon.social/users/author".into(), ap_id: "https://mastodon.social/users/author".into(),
inbox_url: "https://mastodon.social/users/author/inbox".into(), inbox_url: "https://mastodon.social/users/author/inbox".into(),
}, },

View File

@@ -0,0 +1,56 @@
use domain::{errors::DomainError, events::DomainEvent, ports::FederationActionPort};
use std::sync::Arc;
pub struct FederationManagementEventService {
pub federation: Arc<dyn FederationActionPort>,
}
impl FederationManagementEventService {
pub async fn process(&self, event: &DomainEvent) -> Result<(), DomainError> {
match event {
DomainEvent::RemoteFollowAccepted {
local_user_id,
remote_actor_url,
} => {
tracing::info!(
local_user_id = %local_user_id,
actor = %remote_actor_url,
"federation-mgmt: accepting follow — sending Accept + backfill"
);
self.federation
.accept_follow_request(local_user_id, remote_actor_url)
.await
}
DomainEvent::RemoteFollowRejected {
local_user_id,
remote_actor_url,
} => {
tracing::info!(
local_user_id = %local_user_id,
actor = %remote_actor_url,
"federation-mgmt: rejecting follow — sending Reject"
);
self.federation
.reject_follow_request(local_user_id, remote_actor_url)
.await
}
DomainEvent::ActorMoved {
user_id,
new_actor_url,
} => {
tracing::info!(
user_id = %user_id,
target = %new_actor_url,
"federation-mgmt: broadcasting Move"
);
let url = url::Url::parse(new_actor_url)
.map_err(|e| DomainError::Internal(e.to_string()))?;
self.federation
.broadcast_move(user_id, url)
.await
.map_err(|e| DomainError::Internal(e.to_string()))
}
_ => Ok(()),
}
}
}

View File

@@ -1,5 +1,7 @@
pub mod federation_event; pub mod federation_event;
pub mod federation_management_event;
pub mod notification_event; pub mod notification_event;
pub use federation_event::FederationEventService; pub use federation_event::FederationEventService;
pub use federation_management_event::FederationManagementEventService;
pub use notification_event::NotificationEventService; pub use notification_event::NotificationEventService;

View File

@@ -32,6 +32,7 @@ async fn like_creates_notification_for_thought_author() {
visibility: Visibility::Public, visibility: Visibility::Public,
content_warning: None, content_warning: None,
sensitive: false, sensitive: false,
mood: None,
}); });
store.thoughts.lock().unwrap().push(thought.clone()); store.thoughts.lock().unwrap().push(thought.clone());
let svc = NotificationEventService { let svc = NotificationEventService {
@@ -62,6 +63,7 @@ async fn self_like_creates_no_notification() {
visibility: Visibility::Public, visibility: Visibility::Public,
content_warning: None, content_warning: None,
sensitive: false, sensitive: false,
mood: None,
}); });
store.thoughts.lock().unwrap().push(thought.clone()); store.thoughts.lock().unwrap().push(thought.clone());
let svc = NotificationEventService { let svc = NotificationEventService {
@@ -111,6 +113,7 @@ async fn reply_creates_notification_for_original_author() {
visibility: Visibility::Public, visibility: Visibility::Public,
content_warning: None, content_warning: None,
sensitive: false, sensitive: false,
mood: None,
}); });
store.thoughts.lock().unwrap().push(original.clone()); store.thoughts.lock().unwrap().push(original.clone());
let svc = NotificationEventService { let svc = NotificationEventService {
@@ -141,6 +144,7 @@ async fn self_reply_creates_no_notification() {
visibility: Visibility::Public, visibility: Visibility::Public,
content_warning: None, content_warning: None,
sensitive: false, sensitive: false,
mood: None,
}); });
store.thoughts.lock().unwrap().push(original.clone()); store.thoughts.lock().unwrap().push(original.clone());
let svc = NotificationEventService { let svc = NotificationEventService {
@@ -169,6 +173,7 @@ async fn self_boost_creates_no_notification() {
visibility: Visibility::Public, visibility: Visibility::Public,
content_warning: None, content_warning: None,
sensitive: false, sensitive: false,
mood: None,
}); });
store.thoughts.lock().unwrap().push(thought.clone()); store.thoughts.lock().unwrap().push(thought.clone());
let svc = NotificationEventService { let svc = NotificationEventService {

View File

@@ -1,11 +1,10 @@
/// Test helpers for application-layer tests that need activitypub traits.
use activitypub::{ActivityPubRepository, ActorApUrls, OutboxEntry};
use async_trait::async_trait; use async_trait::async_trait;
use domain::{ use domain::{
errors::DomainError, errors::DomainError,
models::user::User, models::user::User,
ports::{AcceptNoteInput, ActorFederationUrls, FederationContentRepository, OutboxEntry},
testing::TestStore, testing::TestStore,
value_objects::{Email, PasswordHash, ThoughtId, UserId, Username}, value_objects::{Email, ThoughtId, UserId, Username},
}; };
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
@@ -14,8 +13,8 @@ use std::sync::{Arc, Mutex};
#[derive(Default, Clone)] #[derive(Default, Clone)]
pub struct TestApRepo { pub struct TestApRepo {
pub inner: TestStore, pub inner: TestStore,
/// UserId → ActorApUrls (for get_actor_ap_urls) /// UserId → ActorFederationUrls (for get_actor_ap_urls)
pub actor_ap_urls: Arc<Mutex<HashMap<UserId, ActorApUrls>>>, pub actor_ap_urls: Arc<Mutex<HashMap<UserId, ActorFederationUrls>>>,
} }
impl TestApRepo { impl TestApRepo {
@@ -28,7 +27,7 @@ impl TestApRepo {
} }
#[async_trait] #[async_trait]
impl ActivityPubRepository for TestApRepo { impl FederationContentRepository for TestApRepo {
async fn outbox_entries_for_actor( async fn outbox_entries_for_actor(
&self, &self,
_uid: &UserId, _uid: &UserId,
@@ -63,20 +62,11 @@ impl ActivityPubRepository for TestApRepo {
let handle = url::Url::parse(actor_ap_url) let handle = url::Url::parse(actor_ap_url)
.map(|u| u.path().trim_start_matches('/').replace('/', "_")) .map(|u| u.path().trim_start_matches('/').replace('/', "_"))
.unwrap_or_else(|_| format!("remote_{}", &uid.to_string()[..8])); .unwrap_or_else(|_| format!("remote_{}", &uid.to_string()[..8]));
let user = User { let user = User::new_remote(
id: uid.clone(), uid.clone(),
username: Username::from_trusted(handle), Username::from_trusted(handle),
email: Email::from_trusted(format!("{}@remote", uid)), Email::from_trusted(format!("{}@remote", uid)),
password_hash: PasswordHash("".into()), );
display_name: None,
bio: None,
avatar_url: None,
header_url: None,
custom_css: None,
local: false,
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
};
self.inner.users.lock().unwrap().push(user); self.inner.users.lock().unwrap().push(user);
self.inner self.inner
.actor_ap_ids .actor_ap_ids
@@ -93,13 +83,15 @@ impl ActivityPubRepository for TestApRepo {
) -> Result<(), DomainError> { ) -> Result<(), DomainError> {
Ok(()) Ok(())
} }
async fn accept_note( async fn accept_note(&self, _input: AcceptNoteInput<'_>) -> Result<ThoughtId, DomainError> {
&self,
_input: activitypub::AcceptNoteInput<'_>,
) -> Result<ThoughtId, DomainError> {
Ok(ThoughtId::from_uuid(uuid::Uuid::new_v4())) Ok(ThoughtId::from_uuid(uuid::Uuid::new_v4()))
} }
async fn apply_note_update(&self, _ap_id: &str, _new_content: &str) -> Result<(), DomainError> { async fn apply_note_update(
&self,
_ap_id: &str,
_new_content: &str,
_: Option<serde_json::Value>,
) -> Result<(), DomainError> {
Ok(()) Ok(())
} }
async fn retract_note(&self, _ap_id: &str) -> Result<(), DomainError> { async fn retract_note(&self, _ap_id: &str) -> Result<(), DomainError> {
@@ -133,7 +125,10 @@ impl ActivityPubRepository for TestApRepo {
async fn get_actor_ap_urls( async fn get_actor_ap_urls(
&self, &self,
user_id: &UserId, user_id: &UserId,
) -> Result<Option<ActorApUrls>, DomainError> { ) -> Result<Option<ActorFederationUrls>, DomainError> {
Ok(self.actor_ap_urls.lock().unwrap().get(user_id).cloned()) Ok(self.actor_ap_urls.lock().unwrap().get(user_id).cloned())
} }
async fn sync_remote_actor_to_user(&self, _actor_ap_url: &str) -> Result<(), DomainError> {
Ok(())
}
} }

View File

@@ -60,6 +60,13 @@ impl UserWriter for ConflictOnSaveStore {
) -> Result<(), DomainError> { ) -> Result<(), DomainError> {
self.0.update_profile(user_id, input).await self.0.update_profile(user_id, input).await
} }
async fn set_also_known_as(
&self,
user_id: &UserId,
value: Option<String>,
) -> Result<(), DomainError> {
self.0.set_also_known_as(user_id, value).await
}
} }
#[async_trait] #[async_trait]
@@ -105,6 +112,13 @@ impl UserWriter for EmailConflictOnSaveStore {
) -> Result<(), DomainError> { ) -> Result<(), DomainError> {
self.0.update_profile(user_id, input).await self.0.update_profile(user_id, input).await
} }
async fn set_also_known_as(
&self,
user_id: &UserId,
value: Option<String>,
) -> Result<(), DomainError> {
self.0.set_also_known_as(user_id, value).await
}
} }
struct FakeHasher; struct FakeHasher;

View File

@@ -1,21 +1,36 @@
use activitypub::ActivityPubRepository;
use domain::{ use domain::{
errors::DomainError, errors::DomainError,
events::DomainEvent,
models::{ models::{
actor_connection_summary::ActorConnectionSummary, actor_connection_summary::ActorConnectionSummary,
feed::{FeedEntry, PageParams, Paginated}, feed::{FeedEntry, PageParams, Paginated},
remote_actor::RemoteActor, remote_actor::RemoteActor,
}, },
ports::{ ports::{
EventPublisher, FederationActionPort, FederationFollowPort, FederationFollowRequestPort, EventPublisher, FederationActionPort, FederationContentRepository, FederationFollowPort,
FederationSchedulerPort, FeedQuery, FeedRepository, FollowRepository, FederationFollowRequestPort, FederationSchedulerPort, FeedOptions, FeedQuery,
RemoteActorConnectionRepository, UserReader, FeedRepository, FeedRequest, FollowRepository, RemoteActorConnectionRepository, UserReader,
UserWriter,
}, },
value_objects::UserId, value_objects::UserId,
}; };
use super::social; use super::social;
pub async fn initiate_actor_move(
events: &dyn EventPublisher,
user_id: &UserId,
new_actor_url: url::Url,
) -> Result<(), DomainError> {
events
.publish(&DomainEvent::ActorMoved {
user_id: user_id.clone(),
new_actor_url: new_actor_url.to_string(),
})
.await
.map_err(|e| DomainError::Internal(e.to_string()))
}
pub async fn list_pending_requests( pub async fn list_pending_requests(
federation: &dyn FederationFollowRequestPort, federation: &dyn FederationFollowRequestPort,
user_id: &UserId, user_id: &UserId,
@@ -25,18 +40,34 @@ pub async fn list_pending_requests(
pub async fn accept_follow_request( pub async fn accept_follow_request(
federation: &dyn FederationFollowRequestPort, federation: &dyn FederationFollowRequestPort,
events: &dyn EventPublisher,
user_id: &UserId, user_id: &UserId,
actor_url: &str, actor_url: &str,
) -> Result<(), DomainError> { ) -> Result<(), DomainError> {
federation.accept_follow_request(user_id, actor_url).await events
.publish(&DomainEvent::RemoteFollowAccepted {
local_user_id: user_id.clone(),
remote_actor_url: actor_url.to_string(),
})
.await
.map_err(|e| DomainError::Internal(e.to_string()))?;
federation.mark_follower_accepted(user_id, actor_url).await
} }
pub async fn reject_follow_request( pub async fn reject_follow_request(
federation: &dyn FederationFollowRequestPort, federation: &dyn FederationFollowRequestPort,
events: &dyn EventPublisher,
user_id: &UserId, user_id: &UserId,
actor_url: &str, actor_url: &str,
) -> Result<(), DomainError> { ) -> Result<(), DomainError> {
federation.reject_follow_request(user_id, actor_url).await events
.publish(&DomainEvent::RemoteFollowRejected {
local_user_id: user_id.clone(),
remote_actor_url: actor_url.to_string(),
})
.await
.map_err(|e| DomainError::Internal(e.to_string()))?;
federation.mark_follower_rejected(user_id, actor_url).await
} }
pub async fn list_remote_followers( pub async fn list_remote_followers(
@@ -61,6 +92,20 @@ pub async fn list_remote_following(
federation.get_remote_following(user_id).await federation.get_remote_following(user_id).await
} }
pub async fn get_remote_friends(
federation: &dyn FederationActionPort,
user_id: &UserId,
) -> Result<Vec<RemoteActor>, DomainError> {
use std::collections::HashSet;
let following = federation.get_remote_following(user_id).await?;
let followers = federation.get_remote_followers(user_id).await?;
let follower_urls: HashSet<&str> = followers.iter().map(|a| a.url.as_str()).collect();
Ok(following
.into_iter()
.filter(|a| follower_urls.contains(a.url.as_str()))
.collect())
}
pub async fn remove_remote_following( pub async fn remove_remote_following(
follows: &dyn FollowRepository, follows: &dyn FollowRepository,
users: &dyn UserReader, users: &dyn UserReader,
@@ -74,7 +119,7 @@ pub async fn remove_remote_following(
pub async fn get_remote_actor_posts( pub async fn get_remote_actor_posts(
federation: &dyn FederationActionPort, federation: &dyn FederationActionPort,
ap_repo: &dyn ActivityPubRepository, ap_repo: &dyn FederationContentRepository,
feed: &dyn FeedRepository, feed: &dyn FeedRepository,
scheduler: &dyn FederationSchedulerPort, scheduler: &dyn FederationSchedulerPort,
handle: &str, handle: &str,
@@ -87,11 +132,10 @@ pub async fn get_remote_actor_posts(
None => ap_repo.intern_remote_actor(&actor.url).await?, None => ap_repo.intern_remote_actor(&actor.url).await?,
}; };
let result = feed let result = feed
.query(&FeedQuery::user( .query(&FeedRequest {
author_id, query: FeedQuery::user(author_id, page.clone(), viewer_id.cloned()),
page.clone(), options: FeedOptions::default(),
viewer_id.cloned(), })
))
.await?; .await?;
if let Some(outbox_url) = actor.outbox_url { if let Some(outbox_url) = actor.outbox_url {
let _ = scheduler let _ = scheduler
@@ -131,13 +175,22 @@ pub async fn get_actor_connections_page(
} }
}; };
if stale { if stale {
// Always fetch from page 1 — the full collection is fetched and chunked.
let _ = scheduler let _ = scheduler
.schedule_connections_fetch(&actor.url, &collection_url, connection_type, page) .schedule_connections_fetch(&actor.url, &collection_url, connection_type, 1)
.await; .await;
} }
let has_more = items.len() >= PAGE_SIZE; let has_more = items.len() >= PAGE_SIZE;
Ok((items, has_more)) Ok((items, has_more))
} }
pub async fn set_also_known_as(
users: &dyn UserWriter,
user_id: &UserId,
value: Option<String>,
) -> Result<(), DomainError> {
users.set_also_known_as(user_id, value).await
}
#[cfg(test)] #[cfg(test)]
mod tests; mod tests;

View File

@@ -1,6 +1,27 @@
use super::*; use super::*;
use chrono::Utc;
use domain::models::remote_actor::RemoteActor;
use domain::testing::TestStore; use domain::testing::TestStore;
fn remote_actor(url: &str, handle: &str) -> RemoteActor {
RemoteActor {
url: url.to_string(),
handle: handle.to_string(),
display_name: None,
avatar_url: None,
bio: None,
banner_url: None,
also_known_as: vec![],
outbox_url: None,
followers_url: None,
following_url: None,
inbox_url: None,
shared_inbox_url: None,
attachment: vec![],
last_fetched_at: Utc::now(),
}
}
#[tokio::test] #[tokio::test]
async fn list_pending_returns_empty_by_default() { async fn list_pending_returns_empty_by_default() {
let store = TestStore::default(); let store = TestStore::default();
@@ -13,7 +34,7 @@ async fn list_pending_returns_empty_by_default() {
async fn accept_follow_request_returns_ok() { async fn accept_follow_request_returns_ok() {
let store = TestStore::default(); let store = TestStore::default();
let uid = UserId::new(); let uid = UserId::new();
accept_follow_request(&store, &uid, "https://mastodon.social/users/alice") accept_follow_request(&store, &store, &uid, "https://mastodon.social/users/alice")
.await .await
.unwrap(); .unwrap();
} }
@@ -22,7 +43,7 @@ async fn accept_follow_request_returns_ok() {
async fn reject_follow_request_returns_ok() { async fn reject_follow_request_returns_ok() {
let store = TestStore::default(); let store = TestStore::default();
let uid = UserId::new(); let uid = UserId::new();
reject_follow_request(&store, &uid, "https://mastodon.social/users/alice") reject_follow_request(&store, &store, &uid, "https://mastodon.social/users/alice")
.await .await
.unwrap(); .unwrap();
} }
@@ -51,3 +72,41 @@ async fn list_remote_following_returns_empty_by_default() {
let result = list_remote_following(&store, &uid).await.unwrap(); let result = list_remote_following(&store, &uid).await.unwrap();
assert!(result.is_empty()); assert!(result.is_empty());
} }
#[tokio::test]
async fn get_remote_friends_returns_intersection() {
let store = TestStore::default();
let uid = UserId::new();
let bob = remote_actor("https://bob.example.com/users/bob", "bob@bob.example.com");
let carol = remote_actor(
"https://carol.example.com/users/carol",
"carol@carol.example.com",
);
// uid follows bob and carol
store
.remote_following
.lock()
.unwrap()
.extend([bob.clone(), carol.clone()]);
// only bob follows back
store.remote_followers.lock().unwrap().push(bob.clone());
let friends = get_remote_friends(&store, &uid).await.unwrap();
assert_eq!(friends.len(), 1);
assert_eq!(friends[0].url, bob.url);
}
#[tokio::test]
async fn get_remote_friends_empty_when_no_mutual() {
let store = TestStore::default();
let uid = UserId::new();
let bob = remote_actor("https://bob.example.com/users/bob", "bob@bob.example.com");
store.remote_following.lock().unwrap().push(bob.clone());
// bob does NOT follow back
let friends = get_remote_friends(&store, &uid).await.unwrap();
assert!(friends.is_empty());
}

View File

@@ -1,7 +1,7 @@
use domain::{ use domain::{
errors::DomainError, errors::DomainError,
models::feed::{FeedEntry, PageParams, Paginated}, models::feed::{FeedEntry, PageParams, Paginated},
ports::{FeedQuery, FeedRepository, FollowRepository}, ports::{FeedOptions, FeedQuery, FeedRepository, FeedRequest, FollowRepository, TagRepository},
value_objects::UserId, value_objects::UserId,
}; };
@@ -10,9 +10,61 @@ pub async fn get_home_feed(
follows: &dyn FollowRepository, follows: &dyn FollowRepository,
user_id: &UserId, user_id: &UserId,
page: PageParams, page: PageParams,
opts: FeedOptions,
) -> Result<Paginated<FeedEntry>, DomainError> { ) -> Result<Paginated<FeedEntry>, DomainError> {
let mut following_ids = follows.get_accepted_following_ids(user_id).await?; let mut following_ids = follows.get_accepted_following_ids(user_id).await?;
following_ids.push(user_id.clone()); following_ids.push(user_id.clone());
feed.query(&FeedQuery::home(user_id.clone(), following_ids, page)) feed.query(&FeedRequest {
query: FeedQuery::home(user_id.clone(), following_ids, page),
options: opts,
})
.await .await
} }
pub async fn get_public_feed(
feed: &dyn FeedRepository,
viewer: Option<UserId>,
page: PageParams,
opts: FeedOptions,
) -> Result<Paginated<FeedEntry>, DomainError> {
feed.query(&FeedRequest {
query: FeedQuery::public(page, viewer),
options: opts,
})
.await
}
pub async fn get_user_feed(
feed: &dyn FeedRepository,
user_id: UserId,
page: PageParams,
opts: FeedOptions,
viewer: Option<UserId>,
) -> Result<Paginated<FeedEntry>, DomainError> {
feed.query(&FeedRequest {
query: FeedQuery::user(user_id, page, viewer),
options: opts,
})
.await
}
pub async fn get_tag_feed(
feed: &dyn FeedRepository,
tag: &str,
page: PageParams,
opts: FeedOptions,
viewer: Option<UserId>,
) -> Result<Paginated<FeedEntry>, DomainError> {
feed.query(&FeedRequest {
query: FeedQuery::tag(tag, page, viewer),
options: opts,
})
.await
}
pub async fn get_popular_tags(
tags: &dyn TagRepository,
limit: usize,
) -> Result<Vec<(String, i64)>, DomainError> {
tags.popular_tags(limit).await
}

View File

@@ -1,15 +1,23 @@
const MAX_TOP_FRIENDS: usize = 8; const MAX_TOP_FRIENDS: usize = 8;
const MAX_PROFILE_FIELDS: usize = 4;
const MAX_FIELD_NAME_LEN: usize = 64;
const MAX_FIELD_VALUE_LEN: usize = 256;
const MAX_CUSTOM_MOODS: usize = 8;
const MAX_MOOD_LABEL_LEN: usize = 32;
const MAX_MOOD_EMOJI_LEN: usize = 8;
use bytes::Bytes; use bytes::Bytes;
use domain::{ use domain::{
errors::DomainError, errors::DomainError,
events::DomainEvent, events::DomainEvent,
models::{ models::{
feed::{PageParams, Paginated, UserSummary},
top_friend::TopFriend, top_friend::TopFriend,
user::{UpdateProfileInput, User}, user::{UpdateProfileInput, User},
}, },
ports::{ ports::{
EventPublisher, MediaStore, TopFriendRepository, UserReader, UserRepository, UserWriter, EventPublisher, FollowRepository, MediaStore, TopFriendRepository, UserReader,
UserRepository, UserWriter,
}, },
value_objects::{UserId, Username}, value_objects::{UserId, Username},
}; };
@@ -53,6 +61,34 @@ pub async fn update_profile(
user_id: &UserId, user_id: &UserId,
input: UpdateProfileInput, input: UpdateProfileInput,
) -> Result<(), DomainError> { ) -> Result<(), DomainError> {
if let Some(ref fields) = input.profile_fields {
if fields.len() > MAX_PROFILE_FIELDS {
return Err(DomainError::InvalidInput(format!(
"profile fields: max {MAX_PROFILE_FIELDS}"
)));
}
for (name, value) in fields {
if name.len() > MAX_FIELD_NAME_LEN || value.len() > MAX_FIELD_VALUE_LEN {
return Err(DomainError::InvalidInput(
"profile field name or value too long".into(),
));
}
}
}
if let Some(ref moods) = input.custom_moods {
if moods.len() > MAX_CUSTOM_MOODS {
return Err(DomainError::InvalidInput(format!(
"custom moods: max {MAX_CUSTOM_MOODS}"
)));
}
for (label, emoji) in moods {
if label.len() > MAX_MOOD_LABEL_LEN || emoji.len() > MAX_MOOD_EMOJI_LEN {
return Err(DomainError::InvalidInput(
"custom mood label or emoji too long".into(),
));
}
}
}
users.update_profile(user_id, input).await?; users.update_profile(user_id, input).await?;
events events
.publish(&DomainEvent::ProfileUpdated { .publish(&DomainEvent::ProfileUpdated {
@@ -116,17 +152,25 @@ fn mime_to_ext(mime: &str) -> Result<&'static str, DomainError> {
} }
} }
#[allow(clippy::too_many_arguments)] pub struct UploadContext<'a> {
pub users: &'a dyn UserRepository,
pub media: &'a dyn MediaStore,
pub events: &'a dyn EventPublisher,
pub upload_config: &'a UploadConfig,
pub base_url: &'a str,
}
async fn store_image( async fn store_image(
media: &dyn MediaStore, ctx: &UploadContext<'_>,
base_url: &str,
cfg: &UploadConfig,
content_type: &str, content_type: &str,
data: Bytes, data: Bytes,
user_id: &UserId, user_id: &UserId,
key_segment: &str, key_segment: &str,
old_url: Option<&str>, old_url: Option<&str>,
) -> Result<String, DomainError> { ) -> Result<String, DomainError> {
let cfg = ctx.upload_config;
let media = ctx.media;
let base_url = ctx.base_url;
if !cfg.allowed_content_types.iter().any(|t| t == content_type) { if !cfg.allowed_content_types.iter().any(|t| t == content_type) {
return Err(DomainError::InvalidInput("unsupported content type".into())); return Err(DomainError::InvalidInput("unsupported content type".into()));
} }
@@ -146,25 +190,19 @@ async fn store_image(
Ok(key) Ok(key)
} }
#[allow(clippy::too_many_arguments)]
pub async fn upload_avatar( pub async fn upload_avatar(
users: &dyn UserRepository, ctx: &UploadContext<'_>,
media: &dyn MediaStore,
events: &dyn EventPublisher,
user_id: &UserId, user_id: &UserId,
base_url: &str,
cfg: &UploadConfig,
content_type: &str, content_type: &str,
data: Bytes, data: Bytes,
) -> Result<(), DomainError> { ) -> Result<(), DomainError> {
let current = users let current = ctx
.users
.find_by_id(user_id) .find_by_id(user_id)
.await? .await?
.ok_or(DomainError::NotFound)?; .ok_or(DomainError::NotFound)?;
let key = store_image( let key = store_image(
media, ctx,
base_url,
cfg,
content_type, content_type,
data, data,
user_id, user_id,
@@ -172,41 +210,35 @@ pub async fn upload_avatar(
current.avatar_url.as_deref(), current.avatar_url.as_deref(),
) )
.await?; .await?;
users ctx.users
.update_profile( .update_profile(
user_id, user_id,
UpdateProfileInput { UpdateProfileInput {
avatar_url: Some(format!("{base_url}/media/{key}")), avatar_url: Some(format!("{}/media/{key}", ctx.base_url)),
..Default::default() ..Default::default()
}, },
) )
.await?; .await?;
events ctx.events
.publish(&DomainEvent::ProfileUpdated { .publish(&DomainEvent::ProfileUpdated {
user_id: user_id.clone(), user_id: user_id.clone(),
}) })
.await .await
} }
#[allow(clippy::too_many_arguments)]
pub async fn upload_banner( pub async fn upload_banner(
users: &dyn UserRepository, ctx: &UploadContext<'_>,
media: &dyn MediaStore,
events: &dyn EventPublisher,
user_id: &UserId, user_id: &UserId,
base_url: &str,
cfg: &UploadConfig,
content_type: &str, content_type: &str,
data: Bytes, data: Bytes,
) -> Result<(), DomainError> { ) -> Result<(), DomainError> {
let current = users let current = ctx
.users
.find_by_id(user_id) .find_by_id(user_id)
.await? .await?
.ok_or(DomainError::NotFound)?; .ok_or(DomainError::NotFound)?;
let key = store_image( let key = store_image(
media, ctx,
base_url,
cfg,
content_type, content_type,
data, data,
user_id, user_id,
@@ -214,21 +246,62 @@ pub async fn upload_banner(
current.header_url.as_deref(), current.header_url.as_deref(),
) )
.await?; .await?;
users ctx.users
.update_profile( .update_profile(
user_id, user_id,
UpdateProfileInput { UpdateProfileInput {
header_url: Some(format!("{base_url}/media/{key}")), header_url: Some(format!("{}/media/{key}", ctx.base_url)),
..Default::default() ..Default::default()
}, },
) )
.await?; .await?;
events ctx.events
.publish(&DomainEvent::ProfileUpdated { .publish(&DomainEvent::ProfileUpdated {
user_id: user_id.clone(), user_id: user_id.clone(),
}) })
.await .await
} }
pub async fn get_user_profile(
users: &dyn UserReader,
follows: &dyn FollowRepository,
id_or_username: &str,
viewer_id: Option<&UserId>,
) -> Result<(User, bool), DomainError> {
let user = get_user_by_id_or_username(users, id_or_username).await?;
let is_followed = match viewer_id {
Some(vid) if vid != &user.id => follows.find(vid, &user.id).await?.is_some(),
_ => false,
};
Ok((user, is_followed))
}
pub async fn list_users(
users: &dyn UserReader,
page: PageParams,
) -> Result<Paginated<UserSummary>, DomainError> {
users.list_paginated(page).await
}
pub async fn count_local_users(users: &dyn UserReader) -> Result<i64, DomainError> {
users.count().await
}
pub async fn list_local_followers(
follows: &dyn FollowRepository,
user_id: &UserId,
page: PageParams,
) -> Result<Paginated<User>, DomainError> {
follows.list_followers(user_id, &page).await
}
pub async fn list_local_following(
follows: &dyn FollowRepository,
user_id: &UserId,
page: PageParams,
) -> Result<Paginated<User>, DomainError> {
follows.list_following(user_id, &page).await
}
#[cfg(test)] #[cfg(test)]
mod tests; mod tests;

View File

@@ -113,22 +113,29 @@ fn default_cfg() -> UploadConfig {
UploadConfig::default() UploadConfig::default()
} }
fn make_ctx<'a>(
store: &'a TestStore,
media: &'a MockMedia,
cfg: &'a UploadConfig,
) -> UploadContext<'a> {
UploadContext {
users: store,
media,
events: store,
upload_config: cfg,
base_url: "http://localhost",
}
}
#[tokio::test] #[tokio::test]
async fn upload_avatar_rejects_unsupported_mime() { async fn upload_avatar_rejects_unsupported_mime() {
let store = TestStore::default(); let store = TestStore::default();
let media = MockMedia::default(); let media = MockMedia::default();
let user = make_user(); let user = make_user();
store.users.lock().unwrap().push(user.clone()); store.users.lock().unwrap().push(user.clone());
let err = upload_avatar( let cfg = default_cfg();
&store, let ctx = make_ctx(&store, &media, &cfg);
&media, let err = upload_avatar(&ctx, &user.id, "text/plain", Bytes::from("hi"))
&store,
&user.id,
"http://localhost",
&default_cfg(),
"text/plain",
Bytes::from("hi"),
)
.await .await
.unwrap_err(); .unwrap_err();
assert!(matches!(err, DomainError::InvalidInput(_))); assert!(matches!(err, DomainError::InvalidInput(_)));
@@ -141,16 +148,9 @@ async fn upload_avatar_rejects_oversized_data() {
let user = make_user(); let user = make_user();
store.users.lock().unwrap().push(user.clone()); store.users.lock().unwrap().push(user.clone());
let big = Bytes::from(vec![0u8; 6 * 1024 * 1024]); let big = Bytes::from(vec![0u8; 6 * 1024 * 1024]);
let err = upload_avatar( let cfg = default_cfg();
&store, let ctx = make_ctx(&store, &media, &cfg);
&media, let err = upload_avatar(&ctx, &user.id, "image/jpeg", big)
&store,
&user.id,
"http://localhost",
&default_cfg(),
"image/jpeg",
big,
)
.await .await
.unwrap_err(); .unwrap_err();
assert!(matches!(err, DomainError::InvalidInput(_))); assert!(matches!(err, DomainError::InvalidInput(_)));
@@ -162,16 +162,9 @@ async fn upload_avatar_stores_file_and_updates_url() {
let media = MockMedia::default(); let media = MockMedia::default();
let user = make_user(); let user = make_user();
store.users.lock().unwrap().push(user.clone()); store.users.lock().unwrap().push(user.clone());
upload_avatar( let cfg = default_cfg();
&store, let ctx = make_ctx(&store, &media, &cfg);
&media, upload_avatar(&ctx, &user.id, "image/jpeg", Bytes::from("img"))
&store,
&user.id,
"http://localhost",
&default_cfg(),
"image/jpeg",
Bytes::from("img"),
)
.await .await
.unwrap(); .unwrap();
let key = format!("users/{}/avatar.jpg", user.id.as_uuid()); let key = format!("users/{}/avatar.jpg", user.id.as_uuid());
@@ -203,16 +196,9 @@ async fn upload_avatar_deletes_old_file_on_reupload() {
.lock() .lock()
.unwrap() .unwrap()
.insert(old_key.clone(), Bytes::from("old")); .insert(old_key.clone(), Bytes::from("old"));
upload_avatar( let cfg = default_cfg();
&store, let ctx = make_ctx(&store, &media, &cfg);
&media, upload_avatar(&ctx, &user.id, "image/jpeg", Bytes::from("new"))
&store,
&user.id,
"http://localhost",
&default_cfg(),
"image/jpeg",
Bytes::from("new"),
)
.await .await
.unwrap(); .unwrap();
assert!(!media.store.lock().unwrap().contains_key(&old_key)); assert!(!media.store.lock().unwrap().contains_key(&old_key));
@@ -225,16 +211,9 @@ async fn upload_banner_stores_file_and_updates_header_url() {
let media = MockMedia::default(); let media = MockMedia::default();
let user = make_user(); let user = make_user();
store.users.lock().unwrap().push(user.clone()); store.users.lock().unwrap().push(user.clone());
upload_banner( let cfg = default_cfg();
&store, let ctx = make_ctx(&store, &media, &cfg);
&media, upload_banner(&ctx, &user.id, "image/png", Bytes::from("banner"))
&store,
&user.id,
"http://localhost",
&default_cfg(),
"image/png",
Bytes::from("banner"),
)
.await .await
.unwrap(); .unwrap();
let key = format!("users/{}/banner.png", user.id.as_uuid()); let key = format!("users/{}/banner.png", user.id.as_uuid());
@@ -266,16 +245,9 @@ async fn upload_banner_deletes_old_file_on_reupload() {
.lock() .lock()
.unwrap() .unwrap()
.insert(old_key.clone(), Bytes::from("old")); .insert(old_key.clone(), Bytes::from("old"));
upload_banner( let cfg = default_cfg();
&store, let ctx = make_ctx(&store, &media, &cfg);
&media, upload_banner(&ctx, &user.id, "image/png", Bytes::from("new"))
&store,
&user.id,
"http://localhost",
&default_cfg(),
"image/png",
Bytes::from("new"),
)
.await .await
.unwrap(); .unwrap();
assert!(!media.store.lock().unwrap().contains_key(&old_key)); assert!(!media.store.lock().unwrap().contains_key(&old_key));

View File

@@ -2,10 +2,14 @@ use chrono::Utc;
use domain::{ use domain::{
errors::DomainError, errors::DomainError,
events::DomainEvent, events::DomainEvent,
models::social::{Block, Boost, Follow, FollowState, Like}, models::{
feed::{PageParams, Paginated},
social::{Block, Boost, Follow, FollowState, Like},
user::User,
},
ports::{ ports::{
BlockRepository, BoostRepository, EventPublisher, FederationFollowPort, FollowRepository, BlockRepository, BoostRepository, EventPublisher, FederationBlockPort,
LikeRepository, UserReader, FederationFollowPort, FollowRepository, LikeRepository, UserReader,
}, },
value_objects::{BoostId, LikeId, ThoughtId, UserId, Username}, value_objects::{BoostId, LikeId, ThoughtId, UserId, Username},
}; };
@@ -213,10 +217,14 @@ pub async fn reject_follow(
pub async fn block_by_username( pub async fn block_by_username(
blocks: &dyn BlockRepository, blocks: &dyn BlockRepository,
users: &dyn UserReader, users: &dyn UserReader,
federation: &dyn FederationBlockPort,
events: &dyn EventPublisher, events: &dyn EventPublisher,
blocker_id: &UserId, blocker_id: &UserId,
username: &str, username: &str,
) -> Result<(), DomainError> { ) -> Result<(), DomainError> {
if username.contains('@') {
return federation.block_remote(blocker_id, username).await;
}
let uname = Username::new(username).map_err(|_| DomainError::NotFound)?; let uname = Username::new(username).map_err(|_| DomainError::NotFound)?;
let target = users let target = users
.find_by_username(&uname) .find_by_username(&uname)
@@ -228,10 +236,14 @@ pub async fn block_by_username(
pub async fn unblock_by_username( pub async fn unblock_by_username(
blocks: &dyn BlockRepository, blocks: &dyn BlockRepository,
users: &dyn UserReader, users: &dyn UserReader,
federation: &dyn FederationBlockPort,
events: &dyn EventPublisher, events: &dyn EventPublisher,
blocker_id: &UserId, blocker_id: &UserId,
username: &str, username: &str,
) -> Result<(), DomainError> { ) -> Result<(), DomainError> {
if username.contains('@') {
return federation.unblock_remote(blocker_id, username).await;
}
let uname = Username::new(username).map_err(|_| DomainError::NotFound)?; let uname = Username::new(username).map_err(|_| DomainError::NotFound)?;
let target = users let target = users
.find_by_username(&uname) .find_by_username(&uname)
@@ -280,5 +292,13 @@ pub async fn unblock_user(
Ok(()) Ok(())
} }
pub async fn get_local_friends(
follows: &dyn FollowRepository,
user_id: &UserId,
page: &PageParams,
) -> Result<Paginated<User>, DomainError> {
follows.list_mutual(user_id, page).await
}
#[cfg(test)] #[cfg(test)]
mod tests; mod tests;

View File

@@ -34,6 +34,7 @@ async fn like_and_unlike() {
visibility: Visibility::Public, visibility: Visibility::Public,
content_warning: None, content_warning: None,
sensitive: false, sensitive: false,
mood: None,
})); }));
like_thought(&store, &store, &alice.id, &tid).await.unwrap(); like_thought(&store, &store, &alice.id, &tid).await.unwrap();
assert_eq!(store.likes.lock().unwrap().len(), 1); assert_eq!(store.likes.lock().unwrap().len(), 1);
@@ -204,3 +205,51 @@ async fn boost_and_unboost() {
.iter() .iter()
.any(|e| matches!(e, DomainEvent::BoostRemoved { .. }))); .any(|e| matches!(e, DomainEvent::BoostRemoved { .. })));
} }
#[tokio::test]
async fn get_local_friends_returns_mutual_follows() {
use domain::models::feed::PageParams;
let store = TestStore::default();
let alice = user("alice");
let bob = user("bob");
let carol = user("carol");
store
.users
.lock()
.unwrap()
.extend([alice.clone(), bob.clone(), carol.clone()]);
// alice ↔ bob = friends; alice → carol but not back
store.follows.lock().unwrap().extend([
domain::models::social::Follow {
follower_id: alice.id.clone(),
following_id: bob.id.clone(),
state: domain::models::social::FollowState::Accepted,
ap_id: None,
created_at: chrono::Utc::now(),
},
domain::models::social::Follow {
follower_id: bob.id.clone(),
following_id: alice.id.clone(),
state: domain::models::social::FollowState::Accepted,
ap_id: None,
created_at: chrono::Utc::now(),
},
domain::models::social::Follow {
follower_id: alice.id.clone(),
following_id: carol.id.clone(),
state: domain::models::social::FollowState::Accepted,
ap_id: None,
created_at: chrono::Utc::now(),
},
]);
let page = PageParams {
page: 1,
per_page: 20,
};
let result = get_local_friends(&store, &alice.id, &page).await.unwrap();
assert_eq!(result.total, 1);
assert_eq!(result.items[0].id, bob.id);
}

View File

@@ -26,6 +26,7 @@ pub struct CreateThoughtInput {
pub visibility: Option<String>, pub visibility: Option<String>,
pub content_warning: Option<String>, pub content_warning: Option<String>,
pub sensitive: bool, pub sensitive: bool,
pub mood: Option<String>,
} }
pub struct CreateThoughtOutput { pub struct CreateThoughtOutput {
pub thought: Thought, pub thought: Thought,
@@ -39,6 +40,11 @@ pub async fn create_thought(
outbox: &dyn OutboxWriter, outbox: &dyn OutboxWriter,
input: CreateThoughtInput, input: CreateThoughtInput,
) -> Result<CreateThoughtOutput, DomainError> { ) -> Result<CreateThoughtOutput, DomainError> {
if let Some(ref m) = input.mood {
if m.len() > 64 {
return Err(DomainError::InvalidInput("mood: max 64 chars".into()));
}
}
let content = Content::new_local(input.content)?; let content = Content::new_local(input.content)?;
let visibility = match input.visibility.as_deref() { let visibility = match input.visibility.as_deref() {
Some("followers") => Visibility::Followers, Some("followers") => Visibility::Followers,
@@ -54,6 +60,7 @@ pub async fn create_thought(
visibility, visibility,
content_warning: input.content_warning, content_warning: input.content_warning,
sensitive: input.sensitive, sensitive: input.sensitive,
mood: input.mood,
}); });
thoughts.save(&thought).await?; thoughts.save(&thought).await?;

View File

@@ -22,6 +22,7 @@ fn input(uid: UserId) -> CreateThoughtInput {
visibility: None, visibility: None,
content_warning: None, content_warning: None,
sensitive: false, sensitive: false,
mood: None,
} }
} }
@@ -207,6 +208,7 @@ async fn create_reply_sets_in_reply_to_id() {
visibility: None, visibility: None,
content_warning: None, content_warning: None,
sensitive: false, sensitive: false,
mood: None,
}, },
) )
.await .await
@@ -243,6 +245,7 @@ fn make_thought(user_id: UserId) -> Thought {
visibility: Visibility::Public, visibility: Visibility::Public,
content_warning: None, content_warning: None,
sensitive: false, sensitive: false,
mood: None,
}) })
} }
@@ -295,6 +298,7 @@ async fn get_thread_views_batches_correctly() {
visibility: Visibility::Public, visibility: Visibility::Public,
content_warning: None, content_warning: None,
sensitive: false, sensitive: false,
mood: None,
}); });
<TestStore as ThoughtRepository>::save(&store, &reply) <TestStore as ThoughtRepository>::save(&store, &reply)
.await .await

View File

@@ -14,9 +14,12 @@ postgres = { workspace = true }
postgres-search = { workspace = true } postgres-search = { workspace = true }
postgres-federation = { workspace = true } postgres-federation = { workspace = true }
activitypub = { workspace = true } activitypub = { workspace = true }
k-ap = { git = "https://git.gabrielkaszewski.dev/GKaszewski/k-ap.git", tag = "v0.1.9" } k-ap = { version = "0.4.0", registry = "gitea" }
serde_json = { workspace = true }
anyhow = { workspace = true }
nats = { workspace = true } nats = { workspace = true }
event-transport = { workspace = true } event-transport = { workspace = true }
event-payload = { workspace = true }
auth = { workspace = true } auth = { workspace = true }
storage = { workspace = true } storage = { workspace = true }
application = { workspace = true } application = { workspace = true }

View File

@@ -31,12 +31,12 @@ impl Config {
Self { Self {
database_url: std::env::var("DATABASE_URL").expect("DATABASE_URL is required"), database_url: std::env::var("DATABASE_URL").expect("DATABASE_URL is required"),
jwt_secret: std::env::var("JWT_SECRET").expect("JWT_SECRET is required"), jwt_secret: std::env::var("JWT_SECRET").expect("JWT_SECRET is required"),
base_url: std::env::var("BASE_URL").unwrap_or_else(|_| "http://localhost:3000".into()), base_url: std::env::var("BASE_URL").unwrap_or_else(|_| "http://localhost:8000".into()),
nats_url: std::env::var("NATS_URL").ok(), nats_url: std::env::var("NATS_URL").ok(),
port: std::env::var("PORT") port: std::env::var("PORT")
.ok() .ok()
.and_then(|p| p.parse().ok()) .and_then(|p| p.parse().ok())
.unwrap_or(3000), .unwrap_or(8000),
allow_registration: std::env::var("ALLOW_REGISTRATION") allow_registration: std::env::var("ALLOW_REGISTRATION")
.map(|v| v == "true") .map(|v| v == "true")
.unwrap_or(true), .unwrap_or(true),

View File

@@ -8,21 +8,21 @@ use std::sync::Arc;
use application::use_cases::profile::UploadConfig; use application::use_cases::profile::UploadConfig;
use storage::{build_store, ObjectStorageAdapter, StorageConfig}; use storage::{build_store, ObjectStorageAdapter, StorageConfig};
use activitypub::{ApFederationAdapter, ThoughtsObjectHandler}; use activitypub::{build_ap_service, ApFederationAdapter, ApServiceConfig, ThoughtsObjectHandler};
use auth::ApiKeyServiceImpl; use auth::ApiKeyServiceImpl;
use domain::{ use domain::{
errors::DomainError, errors::DomainError,
events::DomainEvent, events::DomainEvent,
ports::{EventPublisher, OutboxWriter}, ports::{EventPublisher, OutboxWriter},
}; };
use event_transport::EventPublisherAdapter; use event_transport::{EventPublisherAdapter, Transport};
use k_ap::ActivityPubService; use k_ap::FederationEvent;
use nats::NatsTransport; use nats::NatsTransport;
use postgres::activitypub::PgActivityPubRepository; use postgres::activitypub::PgActivityPubRepository;
use postgres::engagement::PgEngagementRepository; use postgres::engagement::PgEngagementRepository;
use postgres::outbox::PgOutboxWriter; use postgres::outbox::PgOutboxWriter;
use postgres::remote_actor_connections::PgRemoteActorConnectionRepository; use postgres::remote_actor_connections::PgRemoteActorConnectionRepository;
use postgres_federation::{PostgresApUserRepository, PostgresFederationRepository}; use postgres_federation::{PgApUserRepository, PgFederationRepository};
use presentation::state::AppState; use presentation::state::AppState;
use crate::config::Config; use crate::config::Config;
@@ -42,6 +42,46 @@ impl EventPublisher for NoOpEventPublisher {
} }
} }
struct KapPublisher(NatsTransport);
#[async_trait]
impl k_ap::data::EventPublisher for KapPublisher {
async fn publish(&self, event: FederationEvent) -> anyhow::Result<()> {
let (subject, payload) = match event {
FederationEvent::DeliveryRequested {
inbox,
activity,
signing_actor_id,
} => (
"federation.delivery.requested",
serde_json::to_vec(&event_payload::EventPayload::FederationDeliveryRequested {
inbox: inbox.to_string(),
activity,
signing_actor_id: signing_actor_id.to_string(),
})?,
),
FederationEvent::BackfillRequested {
owner_user_id,
follower_inbox_url,
} => (
"federation.backfill.requested",
serde_json::to_vec(&event_payload::EventPayload::FederationBackfillRequested {
owner_user_id: owner_user_id.to_string(),
follower_inbox_url,
})?,
),
FederationEvent::DeliveryFailed { inbox, error, .. } => {
tracing::warn!(%inbox, %error, "AP delivery failed permanently");
return Ok(());
}
};
self.0
.publish_bytes(subject, &payload)
.await
.map_err(|e| anyhow::anyhow!(e))
}
}
pub async fn build(cfg: &Config) -> Infrastructure { pub async fn build(cfg: &Config) -> Infrastructure {
// 1. Database connection + migrations // 1. Database connection + migrations
let pool = PgPool::connect(&cfg.database_url) let pool = PgPool::connect(&cfg.database_url)
@@ -54,51 +94,64 @@ pub async fn build(cfg: &Config) -> Infrastructure {
tracing::info!("Database connected and migrations applied"); tracing::info!("Database connected and migrations applied");
// 2. Event publisher — real NATS or no-op fallback // 2. Event publisher — real NATS or no-op fallback
let event_publisher: Arc<dyn EventPublisher> = match &cfg.nats_url { let nats_client: Option<async_nats::Client> = match &cfg.nats_url {
Some(url) => match async_nats::connect(url).await { Some(url) => match async_nats::connect(url).await {
Ok(client) => { Ok(client) => {
tracing::info!("Connected to NATS at {url}"); tracing::info!("Connected to NATS at {url}");
if let Err(e) = nats::ensure_stream(&client).await { if let Err(e) = nats::ensure_stream(&client).await {
tracing::warn!("JetStream stream setup failed: {e} — events may be lost"); tracing::warn!("JetStream stream setup failed: {e} — events may be lost");
} }
Arc::new(EventPublisherAdapter::new(NatsTransport::new(client))) Some(client)
} }
Err(e) => { Err(e) => {
tracing::warn!("NATS connect failed ({e}) — falling back to no-op publisher"); tracing::warn!("NATS connect failed ({e}) — falling back to no-op publisher");
Arc::new(NoOpEventPublisher) None
} }
}, },
None => { None => {
tracing::info!("NATS_URL not set — using no-op event publisher"); tracing::info!("NATS_URL not set — using no-op event publisher");
Arc::new(NoOpEventPublisher) None
} }
}; };
let event_publisher: Arc<dyn EventPublisher> = match &nats_client {
Some(client) => Arc::new(EventPublisherAdapter::new(NatsTransport::new(
client.clone(),
))),
None => Arc::new(NoOpEventPublisher),
};
let kap_publisher: Option<Arc<dyn k_ap::data::EventPublisher>> = nats_client
.as_ref()
.map(|c| Arc::new(KapPublisher(NatsTransport::new(c.clone()))) as _);
// 3. ActivityPub federation // 3. ActivityPub federation
let connections_repo = Arc::new(PgRemoteActorConnectionRepository::new(pool.clone())); let connections_repo = Arc::new(PgRemoteActorConnectionRepository::new(pool.clone()));
let raw_ap_service = Arc::new( let fed_repo = Arc::new(PgFederationRepository::new(pool.clone()));
ActivityPubService::builder( let likes: Arc<dyn domain::ports::LikeRepository> =
Arc::new(PostgresFederationRepository::new(pool.clone())), Arc::new(postgres::like::PgLikeRepository::new(pool.clone()));
Arc::new(PostgresApUserRepository::new( let boosts: Arc<dyn domain::ports::BoostRepository> =
pool.clone(), Arc::new(postgres::boost::PgBoostRepository::new(pool.clone()));
cfg.base_url.clone(), let ap_handler = Arc::new(ThoughtsObjectHandler::new(
)),
Arc::new(ThoughtsObjectHandler::new(
Arc::new(PgActivityPubRepository::new(pool.clone())), Arc::new(PgActivityPubRepository::new(pool.clone())),
&cfg.base_url, &cfg.base_url,
Some(event_publisher.clone()), Some(event_publisher.clone()),
Arc::new(postgres::tag::PgTagRepository::new(pool.clone())), Arc::new(postgres::tag::PgTagRepository::new(pool.clone())),
)), likes.clone(),
cfg.base_url.clone(), boosts.clone(),
) ));
.allow_registration(cfg.allow_registration) let (_raw, ap_service) = build_ap_service(ApServiceConfig {
.software_name("thoughts") base_url: cfg.base_url.clone(),
.debug(cfg.debug) activity_repo: fed_repo.clone(),
.build() follow_repo: fed_repo.clone(),
.await actor_repo: fed_repo.clone(),
.expect("Failed to build ActivityPubService"), blocklist_repo: fed_repo.clone(),
); user_repo: Arc::new(PgApUserRepository::new(pool.clone(), cfg.base_url.clone())),
let ap_service = Arc::new(ApFederationAdapter::new(raw_ap_service, connections_repo)); ap_handler,
connections_repo,
event_publisher: kap_publisher,
allow_registration: cfg.allow_registration,
debug: cfg.debug,
})
.await;
// 4. Storage adapter // 4. Storage adapter
let storage_cfg = StorageConfig { let storage_cfg = StorageConfig {
@@ -124,8 +177,8 @@ pub async fn build(cfg: &Config) -> Infrastructure {
let state = AppState { let state = AppState {
users: Arc::new(postgres::user::PgUserRepository::new(pool.clone())), users: Arc::new(postgres::user::PgUserRepository::new(pool.clone())),
thoughts: Arc::new(postgres::thought::PgThoughtRepository::new(pool.clone())), thoughts: Arc::new(postgres::thought::PgThoughtRepository::new(pool.clone())),
likes: Arc::new(postgres::like::PgLikeRepository::new(pool.clone())), likes: likes.clone(),
boosts: Arc::new(postgres::boost::PgBoostRepository::new(pool.clone())), boosts: boosts.clone(),
follows: Arc::new(postgres::follow::PgFollowRepository::new(pool.clone())), follows: Arc::new(postgres::follow::PgFollowRepository::new(pool.clone())),
blocks: Arc::new(postgres::block::PgBlockRepository::new(pool.clone())), blocks: Arc::new(postgres::block::PgBlockRepository::new(pool.clone())),
tags: Arc::new(postgres::tag::PgTagRepository::new(pool.clone())), tags: Arc::new(postgres::tag::PgTagRepository::new(pool.clone())),

View File

@@ -35,8 +35,13 @@ async fn main() {
.allow_headers(tower_http::cors::Any) .allow_headers(tower_http::cors::Any)
}; };
let ap_router = infra
.ap_service
.router::<presentation::state::AppState>()
.layer(axum::extract::DefaultBodyLimit::max(256 * 1024));
let base = presentation::routes::router() let base = presentation::routes::router()
.merge(infra.ap_service.router::<presentation::state::AppState>()) .merge(ap_router)
.with_state(infra.state) .with_state(infra.state)
.layer(cors); .layer(cors);

View File

@@ -63,6 +63,18 @@ pub enum DomainEvent {
ProfileUpdated { ProfileUpdated {
user_id: UserId, user_id: UserId,
}, },
RemoteFollowAccepted {
local_user_id: UserId,
remote_actor_url: String,
},
RemoteFollowRejected {
local_user_id: UserId,
remote_actor_url: String,
},
ActorMoved {
user_id: UserId,
new_actor_url: String,
},
MentionReceived { MentionReceived {
thought_id: ThoughtId, thought_id: ThoughtId,
mentioned_user_id: UserId, mentioned_user_id: UserId,

View File

@@ -8,10 +8,12 @@ pub struct RemoteActor {
pub avatar_url: Option<String>, pub avatar_url: Option<String>,
pub bio: Option<String>, pub bio: Option<String>,
pub banner_url: Option<String>, pub banner_url: Option<String>,
pub also_known_as: Option<String>, pub also_known_as: Vec<String>,
pub outbox_url: Option<String>, pub outbox_url: Option<String>,
pub followers_url: Option<String>, pub followers_url: Option<String>,
pub following_url: Option<String>, pub following_url: Option<String>,
pub inbox_url: Option<String>,
pub shared_inbox_url: Option<String>,
pub attachment: Vec<(String, String)>, pub attachment: Vec<(String, String)>,
pub last_fetched_at: DateTime<Utc>, pub last_fetched_at: DateTime<Utc>,
} }

View File

@@ -22,6 +22,7 @@ pub struct Thought {
pub created_at: DateTime<Utc>, pub created_at: DateTime<Utc>,
pub updated_at: Option<DateTime<Utc>>, pub updated_at: Option<DateTime<Utc>>,
pub note_extensions: Option<serde_json::Value>, pub note_extensions: Option<serde_json::Value>,
pub mood: Option<String>,
} }
impl Visibility { impl Visibility {
@@ -55,6 +56,7 @@ pub struct NewThought {
pub visibility: Visibility, pub visibility: Visibility,
pub content_warning: Option<String>, pub content_warning: Option<String>,
pub sensitive: bool, pub sensitive: bool,
pub mood: Option<String>,
} }
impl Thought { impl Thought {
@@ -71,6 +73,7 @@ impl Thought {
created_at: Utc::now(), created_at: Utc::now(),
updated_at: None, updated_at: None,
note_extensions: None, note_extensions: None,
mood: p.mood,
} }
} }
} }

View File

@@ -8,6 +8,8 @@ pub struct UpdateProfileInput {
pub avatar_url: Option<String>, pub avatar_url: Option<String>,
pub header_url: Option<String>, pub header_url: Option<String>,
pub custom_css: Option<String>, pub custom_css: Option<String>,
pub profile_fields: Option<Vec<(String, String)>>,
pub custom_moods: Option<Vec<(String, String)>>,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -21,6 +23,8 @@ pub struct User {
pub avatar_url: Option<String>, pub avatar_url: Option<String>,
pub header_url: Option<String>, pub header_url: Option<String>,
pub custom_css: Option<String>, pub custom_css: Option<String>,
pub profile_fields: Vec<(String, String)>,
pub custom_moods: Vec<(String, String)>,
pub local: bool, pub local: bool,
pub created_at: DateTime<Utc>, pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>, pub updated_at: DateTime<Utc>,
@@ -44,9 +48,31 @@ impl User {
avatar_url: None, avatar_url: None,
header_url: None, header_url: None,
custom_css: None, custom_css: None,
profile_fields: vec![],
custom_moods: vec![],
local: true, local: true,
created_at: now, created_at: now,
updated_at: now, updated_at: now,
} }
} }
pub fn new_remote(id: UserId, username: Username, email: Email) -> Self {
let now = Utc::now();
Self {
id,
username,
email,
password_hash: PasswordHash(String::new()),
display_name: None,
bio: None,
avatar_url: None,
header_url: None,
custom_css: None,
profile_fields: vec![],
custom_moods: vec![],
local: false,
created_at: now,
updated_at: now,
}
}
} }

View File

@@ -83,6 +83,11 @@ pub trait UserWriter: Send + Sync {
user_id: &UserId, user_id: &UserId,
input: UpdateProfileInput, input: UpdateProfileInput,
) -> Result<(), DomainError>; ) -> Result<(), DomainError>;
async fn set_also_known_as(
&self,
user_id: &UserId,
value: Option<String>,
) -> Result<(), DomainError>;
} }
/// Combined supertrait — `AppState.users` stays `Arc<dyn UserRepository>`. /// Combined supertrait — `AppState.users` stays `Arc<dyn UserRepository>`.
@@ -166,6 +171,11 @@ pub trait FollowRepository: Send + Sync {
&self, &self,
user_id: &UserId, user_id: &UserId,
) -> Result<Vec<UserId>, DomainError>; ) -> Result<Vec<UserId>, DomainError>;
async fn list_mutual(
&self,
user_id: &UserId,
page: &PageParams,
) -> Result<Paginated<User>, DomainError>;
} }
#[async_trait] #[async_trait]
@@ -317,6 +327,20 @@ pub trait FederationFollowRequestPort: Send + Sync {
user_id: &UserId, user_id: &UserId,
actor_url: &str, actor_url: &str,
) -> Result<(), DomainError>; ) -> Result<(), DomainError>;
/// Update follower status to Accepted in DB only — no federation activity sent.
async fn mark_follower_accepted(
&self,
user_id: &UserId,
actor_url: &str,
) -> Result<(), DomainError>;
/// Remove follower from DB only — no federation activity sent.
async fn mark_follower_rejected(
&self,
user_id: &UserId,
actor_url: &str,
) -> Result<(), DomainError>;
} }
#[async_trait] #[async_trait]
@@ -336,15 +360,27 @@ pub trait FederationFetchPort: Send + Sync {
) -> Vec<crate::models::actor_connection_summary::ActorConnectionSummary>; ) -> Vec<crate::models::actor_connection_summary::ActorConnectionSummary>;
} }
#[async_trait]
pub trait FederationBlockPort: Send + Sync {
async fn block_remote(&self, local_user_id: &UserId, handle: &str) -> Result<(), DomainError>;
async fn unblock_remote(&self, local_user_id: &UserId, handle: &str)
-> Result<(), DomainError>;
}
pub trait FederationActionPort: pub trait FederationActionPort:
FederationLookupPort + FederationFollowPort + FederationFollowRequestPort + FederationFetchPort FederationLookupPort
+ FederationFollowPort
+ FederationFollowRequestPort
+ FederationFetchPort
+ FederationBlockPort
{ {
} }
impl< impl<
T: FederationLookupPort T: FederationLookupPort
+ FederationFollowPort + FederationFollowPort
+ FederationFollowRequestPort + FederationFollowRequestPort
+ FederationFetchPort, + FederationFetchPort
+ FederationBlockPort,
> FederationActionPort for T > FederationActionPort for T
{ {
} }
@@ -407,9 +443,38 @@ impl FeedQuery {
} }
} }
#[derive(Debug, Clone, Default)]
pub enum FeedSort {
#[default]
Newest,
Oldest,
MostLiked,
MostBoosted,
MostDiscussed,
}
#[derive(Debug, Clone, Default)]
pub struct FeedFilter {
pub originals_only: bool,
pub replies_only: bool,
pub local_only: bool,
pub hide_sensitive: bool,
}
#[derive(Debug, Clone, Default)]
pub struct FeedOptions {
pub sort: FeedSort,
pub filter: FeedFilter,
}
pub struct FeedRequest {
pub query: FeedQuery,
pub options: FeedOptions,
}
#[async_trait] #[async_trait]
pub trait FeedRepository: Send + Sync { pub trait FeedRepository: Send + Sync {
async fn query(&self, q: &FeedQuery) -> Result<Paginated<FeedEntry>, DomainError>; async fn query(&self, req: &FeedRequest) -> Result<Paginated<FeedEntry>, DomainError>;
} }
#[async_trait] #[async_trait]
@@ -446,3 +511,136 @@ pub trait FederationSchedulerPort: Send + Sync {
page: u32, page: u32,
) -> Result<(), DomainError>; ) -> Result<(), DomainError>;
} }
// ── Federation content & broadcast ports ────────────────────────────────
pub struct AcceptNoteInput<'a> {
pub ap_id: &'a str,
pub author_id: &'a UserId,
pub content: &'a str,
pub published: chrono::DateTime<chrono::Utc>,
pub sensitive: bool,
pub content_warning: Option<String>,
pub visibility: &'a str,
pub in_reply_to: Option<&'a str>,
pub note_extensions: Option<serde_json::Value>,
}
#[derive(Debug, Clone)]
pub struct ActorFederationUrls {
pub ap_id: String,
pub inbox_url: String,
}
#[derive(Debug, Clone)]
pub struct OutboxEntry {
pub thought: Thought,
pub author_username: Username,
}
#[async_trait]
pub trait FederationContentRepository: Send + Sync {
async fn outbox_entries_for_actor(
&self,
user_id: &UserId,
) -> Result<Vec<OutboxEntry>, DomainError>;
async fn outbox_page_for_actor(
&self,
user_id: &UserId,
before: Option<chrono::DateTime<chrono::Utc>>,
limit: usize,
) -> Result<Vec<OutboxEntry>, DomainError>;
async fn find_remote_actor_id(&self, actor_ap_url: &str)
-> Result<Option<UserId>, DomainError>;
async fn intern_remote_actor(&self, actor_ap_url: &str) -> Result<UserId, DomainError>;
async fn update_remote_actor_display(
&self,
user_id: &UserId,
display_name: Option<&str>,
avatar_url: Option<&str>,
) -> Result<(), DomainError>;
async fn accept_note(&self, input: AcceptNoteInput<'_>) -> Result<ThoughtId, DomainError>;
async fn apply_note_update(
&self,
ap_id: &str,
new_content: &str,
note_extensions: Option<serde_json::Value>,
) -> Result<(), DomainError>;
async fn retract_note(&self, ap_id: &str) -> Result<(), DomainError>;
async fn retract_actor_notes(&self, actor_ap_url: &str) -> Result<(), DomainError>;
async fn count_local_notes(&self) -> Result<u64, DomainError>;
async fn get_thought_ap_id(
&self,
thought_id: &ThoughtId,
) -> Result<Option<String>, DomainError>;
async fn get_actor_ap_urls(
&self,
user_id: &UserId,
) -> Result<Option<ActorFederationUrls>, DomainError>;
async fn sync_remote_actor_to_user(&self, actor_ap_url: &str) -> Result<(), DomainError>;
}
#[async_trait]
pub trait FederationBroadcastPort: Send + Sync {
async fn broadcast_create(
&self,
author_user_id: &UserId,
thought: &Thought,
author_username: &str,
in_reply_to_url: Option<&str>,
) -> Result<(), DomainError>;
async fn broadcast_delete(
&self,
author_user_id: &UserId,
thought_ap_id: &str,
) -> Result<(), DomainError>;
async fn broadcast_update(
&self,
author_user_id: &UserId,
thought: &Thought,
author_username: &str,
in_reply_to_url: Option<&str>,
) -> Result<(), DomainError>;
async fn broadcast_announce(
&self,
booster_user_id: &UserId,
object_ap_id: &str,
) -> Result<(), DomainError>;
async fn broadcast_undo_announce(
&self,
booster_user_id: &UserId,
object_ap_id: &str,
) -> Result<(), DomainError>;
async fn broadcast_like(
&self,
liker_user_id: &UserId,
object_ap_id: &str,
author_inbox_url: &str,
) -> Result<(), DomainError>;
async fn broadcast_undo_like(
&self,
liker_user_id: &UserId,
object_ap_id: &str,
author_inbox_url: &str,
) -> Result<(), DomainError>;
async fn broadcast_actor_update(&self, user_id: &UserId) -> Result<(), DomainError>;
}

View File

@@ -13,9 +13,7 @@ use crate::{
user::{UpdateProfileInput, User}, user::{UpdateProfileInput, User},
}, },
ports::*, ports::*,
value_objects::{ value_objects::{ApiKeyId, Content, Email, NotificationId, ThoughtId, UserId, Username},
ApiKeyId, Content, Email, NotificationId, PasswordHash, ThoughtId, UserId, Username,
},
}; };
use async_trait::async_trait; use async_trait::async_trait;
use chrono::Utc; use chrono::Utc;
@@ -39,6 +37,8 @@ pub struct TestStore {
pub actor_ap_ids: Arc<Mutex<HashMap<String, UserId>>>, pub actor_ap_ids: Arc<Mutex<HashMap<String, UserId>>>,
/// ThoughtId → AP object URL (used by get_thought_ap_id) /// ThoughtId → AP object URL (used by get_thought_ap_id)
pub thought_ap_ids: Arc<Mutex<HashMap<ThoughtId, String>>>, pub thought_ap_ids: Arc<Mutex<HashMap<ThoughtId, String>>>,
pub remote_following: Arc<Mutex<Vec<RemoteActor>>>,
pub remote_followers: Arc<Mutex<Vec<RemoteActor>>>,
} }
#[async_trait] #[async_trait]
@@ -152,6 +152,14 @@ impl UserWriter for TestStore {
} }
Ok(()) Ok(())
} }
async fn set_also_known_as(
&self,
_user_id: &UserId,
_value: Option<String>,
) -> Result<(), DomainError> {
Ok(())
}
} }
#[async_trait] #[async_trait]
@@ -446,6 +454,46 @@ impl FollowRepository for TestStore {
.map(|f| f.following_id.clone()) .map(|f| f.following_id.clone())
.collect()) .collect())
} }
async fn list_mutual(
&self,
user_id: &UserId,
page: &PageParams,
) -> Result<Paginated<User>, DomainError> {
use std::collections::HashSet;
let follows = self.follows.lock().unwrap();
let following_ids: HashSet<UserId> = follows
.iter()
.filter(|f| &f.follower_id == user_id && f.state == FollowState::Accepted)
.map(|f| f.following_id.clone())
.collect();
let follower_ids: HashSet<UserId> = follows
.iter()
.filter(|f| &f.following_id == user_id && f.state == FollowState::Accepted)
.map(|f| f.follower_id.clone())
.collect();
let mutual_ids: HashSet<UserId> =
following_ids.intersection(&follower_ids).cloned().collect();
drop(follows);
let users = self.users.lock().unwrap();
let all_items: Vec<User> = users
.iter()
.filter(|u| mutual_ids.contains(&u.id))
.cloned()
.collect();
let total = all_items.len() as i64;
let offset = page.offset() as usize;
let items: Vec<User> = all_items
.into_iter()
.skip(offset)
.take(page.limit() as usize)
.collect();
Ok(Paginated {
items,
total,
page: page.page,
per_page: page.per_page,
})
}
} }
#[async_trait] #[async_trait]
@@ -704,7 +752,7 @@ impl FederationFollowPort for TestStore {
&self, &self,
_user_id: &UserId, _user_id: &UserId,
) -> Result<Vec<RemoteActor>, DomainError> { ) -> Result<Vec<RemoteActor>, DomainError> {
Ok(vec![]) Ok(self.remote_following.lock().unwrap().clone())
} }
async fn broadcast_move( async fn broadcast_move(
@@ -745,7 +793,7 @@ impl FederationFollowRequestPort for TestStore {
&self, &self,
_user_id: &UserId, _user_id: &UserId,
) -> Result<Vec<RemoteActor>, DomainError> { ) -> Result<Vec<RemoteActor>, DomainError> {
Ok(vec![]) Ok(self.remote_followers.lock().unwrap().clone())
} }
async fn remove_remote_follower( async fn remove_remote_follower(
@@ -755,6 +803,22 @@ impl FederationFollowRequestPort for TestStore {
) -> Result<(), DomainError> { ) -> Result<(), DomainError> {
Ok(()) Ok(())
} }
async fn mark_follower_accepted(
&self,
_user_id: &UserId,
_actor_url: &str,
) -> Result<(), DomainError> {
Ok(())
}
async fn mark_follower_rejected(
&self,
_user_id: &UserId,
_actor_url: &str,
) -> Result<(), DomainError> {
Ok(())
}
} }
#[async_trait] #[async_trait]
@@ -782,6 +846,24 @@ impl FederationFetchPort for TestStore {
} }
} }
#[async_trait]
impl FederationBlockPort for TestStore {
async fn block_remote(
&self,
_local_user_id: &UserId,
_handle: &str,
) -> Result<(), DomainError> {
Ok(())
}
async fn unblock_remote(
&self,
_local_user_id: &UserId,
_handle: &str,
) -> Result<(), DomainError> {
Ok(())
}
}
#[async_trait] #[async_trait]
impl RemoteActorConnectionRepository for TestStore { impl RemoteActorConnectionRepository for TestStore {
async fn upsert_connections( async fn upsert_connections(
@@ -818,7 +900,7 @@ impl RemoteActorConnectionRepository for TestStore {
impl FeedRepository for TestStore { impl FeedRepository for TestStore {
async fn query( async fn query(
&self, &self,
_q: &crate::ports::FeedQuery, _req: &crate::ports::FeedRequest,
) -> Result<Paginated<FeedEntry>, DomainError> { ) -> Result<Paginated<FeedEntry>, DomainError> {
Ok(Paginated { Ok(Paginated {
items: vec![], items: vec![],

View File

@@ -89,7 +89,7 @@ impl Email {
} }
} }
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct PasswordHash(pub String); pub struct PasswordHash(pub String);
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]

View File

@@ -5,7 +5,6 @@ edition = "2021"
[dependencies] [dependencies]
domain = { workspace = true } domain = { workspace = true }
activitypub = { workspace = true }
application = { workspace = true } application = { workspace = true }
api-types = { workspace = true } api-types = { workspace = true }
axum = { workspace = true } axum = { workspace = true }

View File

@@ -37,11 +37,13 @@ pub async fn post_api_key(
Deps(d): Deps<ApiKeysDeps>, Deps(d): Deps<ApiKeysDeps>,
AuthUser(uid): AuthUser, AuthUser(uid): AuthUser,
Json(body): Json<CreateApiKeyRequest>, Json(body): Json<CreateApiKeyRequest>,
) -> Result<Json<serde_json::Value>, ApiError> { ) -> Result<Json<CreatedApiKeyResponse>, ApiError> {
let (key, raw) = create_api_key(&*d.api_keys, &uid, body.name).await?; let (key, raw) = create_api_key(&*d.api_keys, &uid, body.name).await?;
Ok(Json( Ok(Json(CreatedApiKeyResponse {
serde_json::json!({ "id": key.id.as_uuid(), "name": key.name, "key": raw }), id: key.id.as_uuid(),
)) name: key.name,
key: raw,
}))
} }
#[utoipa::path(delete, path = "/api-keys/{id}", params(("id" = uuid::Uuid, Path, description = "Key ID")), responses((status = 204, description = "Deleted")), security(("bearer_auth" = [])))] #[utoipa::path(delete, path = "/api-keys/{id}", params(("id" = uuid::Uuid, Path, description = "Key ID")), responses((status = 204, description = "Deleted")), security(("bearer_auth" = [])))]
pub async fn delete_api_key_handler( pub async fn delete_api_key_handler(

View File

@@ -1,10 +1,11 @@
use crate::{deps_struct, errors::ApiError, extractors::Deps}; use crate::{deps_struct, errors::ApiError, extractors::Deps};
use api_types::{ use api_types::{
requests::{LoginRequest, RegisterRequest}, requests::{LoginRequest, RegisterRequest},
responses::{AuthResponse, ErrorResponse, UserResponse}, responses::{AuthResponse, ErrorResponse, ProfileField, UserResponse},
}; };
use application::use_cases::auth::{login, register, LoginInput, RegisterInput}; use application::use_cases::auth::{login, register, LoginInput, RegisterInput};
use axum::{http::StatusCode, response::IntoResponse, Json}; use axum::{http::StatusCode, response::IntoResponse, Json};
use domain::models::feed::UserSummary;
use domain::ports::{AuthService, EventPublisher, PasswordHasher, UserRepository}; use domain::ports::{AuthService, EventPublisher, PasswordHasher, UserRepository};
deps_struct!(AuthDeps { deps_struct!(AuthDeps {
@@ -23,12 +24,45 @@ pub fn to_user_response(u: &domain::models::user::User) -> UserResponse {
avatar_url: u.avatar_url.clone(), avatar_url: u.avatar_url.clone(),
header_url: u.header_url.clone(), header_url: u.header_url.clone(),
custom_css: u.custom_css.clone(), custom_css: u.custom_css.clone(),
profile_fields: u
.profile_fields
.iter()
.map(|(n, v)| ProfileField {
name: n.clone(),
value: v.clone(),
})
.collect(),
custom_moods: u
.custom_moods
.iter()
.map(|(n, v)| ProfileField {
name: n.clone(),
value: v.clone(),
})
.collect(),
local: u.local, local: u.local,
is_followed_by_viewer: false, is_followed_by_viewer: false,
created_at: u.created_at, created_at: u.created_at,
} }
} }
pub fn to_summary_response(u: &UserSummary) -> UserResponse {
UserResponse {
id: u.id.as_uuid(),
username: u.username.clone(),
display_name: u.display_name.clone(),
bio: u.bio.clone(),
avatar_url: u.avatar_url.clone(),
header_url: None,
custom_css: None,
profile_fields: vec![],
custom_moods: vec![],
local: true,
is_followed_by_viewer: false,
created_at: chrono::Utc::now(),
}
}
#[utoipa::path( #[utoipa::path(
post, path = "/auth/register", post, path = "/auth/register",
request_body = RegisterRequest, request_body = RegisterRequest,

View File

@@ -4,10 +4,11 @@ use crate::{
handlers::feed::to_thought_response, handlers::feed::to_thought_response,
state::AppState, state::AppState,
}; };
use activitypub::ActivityPubRepository;
use api_types::{ use api_types::{
requests::PaginationQuery, requests::PaginationQuery,
responses::{ActorConnectionPageResponse, ActorConnectionResponse}, responses::{
ActorConnectionPageResponse, ActorConnectionResponse, PagedResponse, ThoughtResponse,
},
}; };
use application::use_cases::federation_management::{ use application::use_cases::federation_management::{
get_actor_connections_page, get_remote_actor_posts, get_actor_connections_page, get_remote_actor_posts,
@@ -16,6 +17,7 @@ use axum::{
extract::{Path, Query}, extract::{Path, Query},
Json, Json,
}; };
use domain::ports::FederationContentRepository;
use domain::{ use domain::{
models::feed::PageParams, models::feed::PageParams,
ports::{ ports::{
@@ -27,7 +29,7 @@ use std::sync::Arc;
pub struct FederationActorsDeps { pub struct FederationActorsDeps {
pub federation: Arc<dyn FederationActionPort>, pub federation: Arc<dyn FederationActionPort>,
pub ap_repo: Arc<dyn ActivityPubRepository>, pub ap_repo: Arc<dyn FederationContentRepository>,
pub feed: Arc<dyn FeedRepository>, pub feed: Arc<dyn FeedRepository>,
pub federation_scheduler: Arc<dyn FederationSchedulerPort>, pub federation_scheduler: Arc<dyn FederationSchedulerPort>,
pub remote_actor_connections: Arc<dyn RemoteActorConnectionRepository>, pub remote_actor_connections: Arc<dyn RemoteActorConnectionRepository>,
@@ -45,12 +47,20 @@ impl FromAppState for FederationActorsDeps {
} }
} }
#[utoipa::path(
get, path = "/federation/actors/{handle}/posts",
params(
("handle" = String, Path, description = "Fediverse handle (@user@instance.tld)"),
PaginationQuery,
),
responses((status = 200, description = "Posts by this remote actor"))
)]
pub async fn remote_actor_posts_handler( pub async fn remote_actor_posts_handler(
Deps(d): Deps<FederationActorsDeps>, Deps(d): Deps<FederationActorsDeps>,
Path(handle): Path<String>, Path(handle): Path<String>,
Query(q): Query<PaginationQuery>, Query(q): Query<PaginationQuery>,
OptionalAuthUser(viewer): OptionalAuthUser, OptionalAuthUser(viewer): OptionalAuthUser,
) -> Result<Json<serde_json::Value>, ApiError> { ) -> Result<Json<PagedResponse<ThoughtResponse>>, ApiError> {
let page = PageParams { let page = PageParams {
page: q.page(), page: q.page(),
per_page: q.per_page(), per_page: q.per_page(),
@@ -65,14 +75,22 @@ pub async fn remote_actor_posts_handler(
viewer.as_ref(), viewer.as_ref(),
) )
.await?; .await?;
Ok(Json(serde_json::json!({ Ok(Json(PagedResponse {
"total": result.total, items: result.items.iter().map(to_thought_response).collect(),
"page": result.page, total: result.total,
"per_page": result.per_page, page: result.page,
"items": result.items.iter().map(to_thought_response).collect::<Vec<_>>(), per_page: result.per_page,
}))) }))
} }
#[utoipa::path(
get, path = "/federation/actors/{handle}/followers-list",
params(
("handle" = String, Path, description = "Fediverse handle (@user@instance.tld)"),
PaginationQuery,
),
responses((status = 200, description = "Followers of this remote actor", body = ActorConnectionPageResponse)),
)]
pub async fn actor_followers_handler( pub async fn actor_followers_handler(
Deps(d): Deps<FederationActorsDeps>, Deps(d): Deps<FederationActorsDeps>,
Path(handle): Path<String>, Path(handle): Path<String>,
@@ -81,6 +99,14 @@ pub async fn actor_followers_handler(
actor_connections_handler(d, handle, "followers", q.page() as u32).await actor_connections_handler(d, handle, "followers", q.page() as u32).await
} }
#[utoipa::path(
get, path = "/federation/actors/{handle}/following-list",
params(
("handle" = String, Path, description = "Fediverse handle (@user@instance.tld)"),
PaginationQuery,
),
responses((status = 200, description = "Accounts this remote actor follows", body = ActorConnectionPageResponse)),
)]
pub async fn actor_following_handler( pub async fn actor_following_handler(
Deps(d): Deps<FederationActorsDeps>, Deps(d): Deps<FederationActorsDeps>,
Path(handle): Path<String>, Path(handle): Path<String>,

View File

@@ -3,27 +3,31 @@ use crate::{
errors::ApiError, errors::ApiError,
extractors::{AuthUser, Deps}, extractors::{AuthUser, Deps},
}; };
use api_types::responses::{ProfileField, RemoteActorResponse}; use api_types::responses::{ErrorResponse, ProfileField, RemoteActorResponse};
use application::use_cases::federation_management::{ use application::use_cases::federation_management::{
accept_follow_request, list_pending_requests, list_remote_followers, list_remote_following, accept_follow_request, get_remote_friends, initiate_actor_move, list_pending_requests,
reject_follow_request, remove_remote_following, list_remote_followers, list_remote_following, reject_follow_request, remove_remote_following,
set_also_known_as,
}; };
use axum::{http::StatusCode, Json}; use axum::{http::StatusCode, Json};
use domain::ports::{EventPublisher, FederationActionPort, FollowRepository, UserRepository}; use domain::ports::{EventPublisher, FederationActionPort, FollowRepository, UserRepository};
use serde::Deserialize; use serde::Deserialize;
#[derive(Deserialize)] #[derive(Deserialize, utoipa::ToSchema)]
pub struct ActorUrlBody { pub struct ActorUrlBody {
/// Full ActivityPub actor URL
pub actor_url: String, pub actor_url: String,
} }
#[derive(Deserialize)] #[derive(Deserialize, utoipa::ToSchema)]
pub struct HandleBody { pub struct HandleBody {
/// Fediverse handle (`@user@instance.tld`)
pub handle: String, pub handle: String,
} }
#[derive(Deserialize)] #[derive(Deserialize, utoipa::ToSchema)]
pub struct MoveBody { pub struct MoveBody {
/// New actor URL to migrate to
pub new_actor_url: String, pub new_actor_url: String,
} }
@@ -54,6 +58,11 @@ fn to_response(a: domain::models::remote_actor::RemoteActor) -> RemoteActorRespo
} }
} }
#[utoipa::path(
get, path = "/federation/me/followers/pending",
responses((status = 200, description = "Pending inbound follow requests", body = Vec<RemoteActorResponse>)),
security(("bearer_auth" = []))
)]
pub async fn get_pending_requests( pub async fn get_pending_requests(
Deps(d): Deps<FederationManagementDeps>, Deps(d): Deps<FederationManagementDeps>,
AuthUser(uid): AuthUser, AuthUser(uid): AuthUser,
@@ -62,24 +71,47 @@ pub async fn get_pending_requests(
Ok(Json(actors.into_iter().map(to_response).collect())) Ok(Json(actors.into_iter().map(to_response).collect()))
} }
#[utoipa::path(
post, path = "/federation/me/followers/accept",
request_body = ActorUrlBody,
responses(
(status = 204, description = "Follow request accepted"),
(status = 400, description = "Invalid request", body = ErrorResponse),
),
security(("bearer_auth" = []))
)]
pub async fn post_accept_request( pub async fn post_accept_request(
Deps(d): Deps<FederationManagementDeps>, Deps(d): Deps<FederationManagementDeps>,
AuthUser(uid): AuthUser, AuthUser(uid): AuthUser,
Json(body): Json<ActorUrlBody>, Json(body): Json<ActorUrlBody>,
) -> Result<StatusCode, ApiError> { ) -> Result<StatusCode, ApiError> {
accept_follow_request(&*d.federation, &uid, &body.actor_url).await?; accept_follow_request(&*d.federation, &*d.events, &uid, &body.actor_url).await?;
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
} }
#[utoipa::path(
delete, path = "/federation/me/followers",
request_body = ActorUrlBody,
responses(
(status = 204, description = "Follower removed / request rejected"),
(status = 400, description = "Invalid request", body = ErrorResponse),
),
security(("bearer_auth" = []))
)]
pub async fn delete_follower( pub async fn delete_follower(
Deps(d): Deps<FederationManagementDeps>, Deps(d): Deps<FederationManagementDeps>,
AuthUser(uid): AuthUser, AuthUser(uid): AuthUser,
Json(body): Json<ActorUrlBody>, Json(body): Json<ActorUrlBody>,
) -> Result<StatusCode, ApiError> { ) -> Result<StatusCode, ApiError> {
reject_follow_request(&*d.federation, &uid, &body.actor_url).await?; reject_follow_request(&*d.federation, &*d.events, &uid, &body.actor_url).await?;
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
} }
#[utoipa::path(
get, path = "/federation/me/followers",
responses((status = 200, description = "Accepted remote followers", body = Vec<RemoteActorResponse>)),
security(("bearer_auth" = []))
)]
pub async fn get_remote_followers( pub async fn get_remote_followers(
Deps(d): Deps<FederationManagementDeps>, Deps(d): Deps<FederationManagementDeps>,
AuthUser(uid): AuthUser, AuthUser(uid): AuthUser,
@@ -88,6 +120,11 @@ pub async fn get_remote_followers(
Ok(Json(actors.into_iter().map(to_response).collect())) Ok(Json(actors.into_iter().map(to_response).collect()))
} }
#[utoipa::path(
get, path = "/federation/me/following",
responses((status = 200, description = "Remote accounts I follow", body = Vec<RemoteActorResponse>)),
security(("bearer_auth" = []))
)]
pub async fn get_remote_following( pub async fn get_remote_following(
Deps(d): Deps<FederationManagementDeps>, Deps(d): Deps<FederationManagementDeps>,
AuthUser(uid): AuthUser, AuthUser(uid): AuthUser,
@@ -96,6 +133,28 @@ pub async fn get_remote_following(
Ok(Json(actors.into_iter().map(to_response).collect())) Ok(Json(actors.into_iter().map(to_response).collect()))
} }
#[utoipa::path(
get, path = "/federation/me/friends",
responses((status = 200, description = "Remote mutual follows (I follow them and they follow me)", body = Vec<RemoteActorResponse>)),
security(("bearer_auth" = []))
)]
pub async fn get_remote_friends_handler(
Deps(d): Deps<FederationManagementDeps>,
AuthUser(uid): AuthUser,
) -> Result<Json<Vec<RemoteActorResponse>>, ApiError> {
let actors = get_remote_friends(&*d.federation, &uid).await?;
Ok(Json(actors.into_iter().map(to_response).collect()))
}
#[utoipa::path(
delete, path = "/federation/me/following",
request_body = HandleBody,
responses(
(status = 204, description = "Unfollowed remote account"),
(status = 400, description = "Invalid handle", body = ErrorResponse),
),
security(("bearer_auth" = []))
)]
pub async fn delete_following( pub async fn delete_following(
Deps(d): Deps<FederationManagementDeps>, Deps(d): Deps<FederationManagementDeps>,
AuthUser(uid): AuthUser, AuthUser(uid): AuthUser,
@@ -113,6 +172,15 @@ pub async fn delete_following(
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
} }
#[utoipa::path(
post, path = "/federation/me/move",
request_body = MoveBody,
responses(
(status = 204, description = "Account move initiated"),
(status = 400, description = "Invalid URL", body = ErrorResponse),
),
security(("bearer_auth" = []))
)]
pub async fn post_move_account( pub async fn post_move_account(
Deps(d): Deps<FederationManagementDeps>, Deps(d): Deps<FederationManagementDeps>,
AuthUser(uid): AuthUser, AuthUser(uid): AuthUser,
@@ -120,9 +188,27 @@ pub async fn post_move_account(
) -> Result<StatusCode, ApiError> { ) -> Result<StatusCode, ApiError> {
let new_url = url::Url::parse(&body.new_actor_url) let new_url = url::Url::parse(&body.new_actor_url)
.map_err(|_| ApiError::BadRequest("invalid new_actor_url".into()))?; .map_err(|_| ApiError::BadRequest("invalid new_actor_url".into()))?;
d.federation initiate_actor_move(&*d.events, &uid, new_url).await?;
.broadcast_move(&uid, new_url) Ok(StatusCode::NO_CONTENT)
.await }
.map_err(ApiError::from)?;
#[derive(Deserialize, utoipa::ToSchema)]
pub struct AlsoKnownAsBody {
/// Actor URL of the account this identity is also known as (for migration verification)
pub also_known_as: Option<String>,
}
#[utoipa::path(
patch, path = "/federation/me/also-known-as",
request_body = AlsoKnownAsBody,
responses((status = 204, description = "Also-known-as updated")),
security(("bearer_auth" = []))
)]
pub async fn patch_also_known_as(
Deps(d): Deps<FederationManagementDeps>,
AuthUser(uid): AuthUser,
Json(body): Json<AlsoKnownAsBody>,
) -> Result<StatusCode, ApiError> {
set_also_known_as(&*d.users, &uid, body.also_known_as).await?;
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
} }

View File

@@ -5,9 +5,14 @@ use crate::{
handlers::auth::to_user_response, handlers::auth::to_user_response,
}; };
use api_types::requests::{PaginationQuery, SearchQuery}; use api_types::requests::{PaginationQuery, SearchQuery};
use api_types::responses::ThoughtResponse; use api_types::responses::{PagedResponse, ThoughtResponse};
use application::use_cases::feed::get_home_feed; use application::use_cases::feed::{
use application::use_cases::profile::{get_user_by_id_or_username, get_user_by_username}; get_home_feed, get_popular_tags as uc_get_popular_tags, get_public_feed, get_tag_feed,
get_user_feed,
};
use application::use_cases::profile::{
get_user_by_id_or_username, get_user_by_username, list_local_followers, list_local_following,
};
use axum::{ use axum::{
extract::{Path, Query}, extract::{Path, Query},
http::{header, HeaderMap}, http::{header, HeaderMap},
@@ -17,11 +22,66 @@ use axum::{
use domain::{ use domain::{
models::feed::PageParams, models::feed::PageParams,
ports::{ ports::{
FederationActionPort, FeedQuery, FeedRepository, FollowRepository, SearchPort, FederationActionPort, FeedFilter, FeedOptions, FeedRepository, FeedSort, FollowRepository,
TagRepository, UserRepository, SearchPort, TagRepository, UserRepository,
}, },
}; };
#[derive(serde::Deserialize, Default, utoipa::IntoParams)]
#[into_params(parameter_in = Query)]
pub struct FeedOptionsQuery {
/// Sort order: `newest` (default), `oldest`, `most_liked`, `most_boosted`, `most_discussed`
pub sort: Option<String>,
/// Show only original posts (mutually exclusive with `replies_only`)
pub originals_only: Option<bool>,
/// Show only replies (mutually exclusive with `originals_only`)
pub replies_only: Option<bool>,
/// Show only posts from this instance
pub local_only: Option<bool>,
/// Hide posts marked as sensitive
pub hide_sensitive: Option<bool>,
}
impl TryFrom<FeedOptionsQuery> for FeedOptions {
type Error = crate::errors::ApiError;
fn try_from(q: FeedOptionsQuery) -> Result<Self, Self::Error> {
if q.originals_only.unwrap_or(false) && q.replies_only.unwrap_or(false) {
return Err(crate::errors::ApiError::BadRequest(
"originals_only and replies_only are mutually exclusive".to_string(),
));
}
let sort = match q.sort.as_deref() {
None | Some("newest") => FeedSort::Newest,
Some("oldest") => FeedSort::Oldest,
Some("most_liked") => FeedSort::MostLiked,
Some("most_boosted") => FeedSort::MostBoosted,
Some("most_discussed") => FeedSort::MostDiscussed,
Some(other) => {
return Err(crate::errors::ApiError::BadRequest(format!(
"unknown sort value: {other}"
)))
}
};
Ok(FeedOptions {
sort,
filter: FeedFilter {
originals_only: q.originals_only.unwrap_or(false),
replies_only: q.replies_only.unwrap_or(false),
local_only: q.local_only.unwrap_or(false),
hide_sensitive: q.hide_sensitive.unwrap_or(false),
},
})
}
}
fn wants_activity_json(headers: &HeaderMap) -> bool {
headers
.get(header::ACCEPT)
.and_then(|v| v.to_str().ok())
.is_some_and(|a| a.contains("application/activity+json"))
}
deps_struct!(FeedDeps { deps_struct!(FeedDeps {
feed: FeedRepository, feed: FeedRepository,
follows: FollowRepository, follows: FollowRepository,
@@ -49,12 +109,13 @@ pub fn to_thought_response(e: &domain::models::feed::FeedEntry) -> ThoughtRespon
created_at: e.thought.created_at, created_at: e.thought.created_at,
updated_at: e.thought.updated_at, updated_at: e.thought.updated_at,
note_extensions: e.thought.note_extensions.clone(), note_extensions: e.thought.note_extensions.clone(),
mood: e.thought.mood.clone(),
} }
} }
#[utoipa::path( #[utoipa::path(
get, path = "/feed", get, path = "/feed",
params(PaginationQuery), params(PaginationQuery, FeedOptionsQuery),
responses((status = 200, description = "Home feed")), responses((status = 200, description = "Home feed")),
security(("bearer_auth" = [])) security(("bearer_auth" = []))
)] )]
@@ -62,41 +123,45 @@ pub async fn home_feed(
Deps(d): Deps<FeedDeps>, Deps(d): Deps<FeedDeps>,
AuthUser(uid): AuthUser, AuthUser(uid): AuthUser,
Query(q): Query<PaginationQuery>, Query(q): Query<PaginationQuery>,
) -> Result<Json<serde_json::Value>, ApiError> { Query(opts_q): Query<FeedOptionsQuery>,
) -> Result<Json<PagedResponse<ThoughtResponse>>, ApiError> {
let page = PageParams { let page = PageParams {
page: q.page(), page: q.page(),
per_page: q.per_page(), per_page: q.per_page(),
}; };
let result = get_home_feed(&*d.feed, &*d.follows, &uid, page).await?; let opts = FeedOptions::try_from(opts_q)?;
Ok(Json(serde_json::json!({ let result = get_home_feed(&*d.feed, &*d.follows, &uid, page, opts).await?;
"items": result.items.iter().map(to_thought_response).collect::<Vec<_>>(), Ok(Json(PagedResponse {
"total": result.total, items: result.items.iter().map(to_thought_response).collect(),
"page": result.page, total: result.total,
"per_page": result.per_page, page: result.page,
}))) per_page: result.per_page,
}))
} }
#[utoipa::path( #[utoipa::path(
get, path = "/feed/public", get, path = "/feed/public",
params(PaginationQuery), params(PaginationQuery, FeedOptionsQuery),
responses((status = 200, description = "Public feed")) responses((status = 200, description = "Public feed"))
)] )]
pub async fn public_feed( pub async fn public_feed(
Deps(d): Deps<FeedDeps>, Deps(d): Deps<FeedDeps>,
OptionalAuthUser(viewer): OptionalAuthUser, OptionalAuthUser(viewer): OptionalAuthUser,
Query(q): Query<PaginationQuery>, Query(q): Query<PaginationQuery>,
) -> Result<Json<serde_json::Value>, ApiError> { Query(opts_q): Query<FeedOptionsQuery>,
) -> Result<Json<PagedResponse<ThoughtResponse>>, ApiError> {
let page = PageParams { let page = PageParams {
page: q.page(), page: q.page(),
per_page: q.per_page(), per_page: q.per_page(),
}; };
let result = d.feed.query(&FeedQuery::public(page, viewer)).await?; let opts = FeedOptions::try_from(opts_q)?;
Ok(Json(serde_json::json!({ let result = get_public_feed(&*d.feed, viewer, page, opts).await?;
"items": result.items.iter().map(to_thought_response).collect::<Vec<_>>(), Ok(Json(PagedResponse {
"total": result.total, items: result.items.iter().map(to_thought_response).collect(),
"page": result.page, total: result.total,
"per_page": result.per_page, page: result.page,
}))) per_page: result.per_page,
}))
} }
#[utoipa::path( #[utoipa::path(
@@ -139,18 +204,21 @@ pub async fn search_handler(
}))) })))
} }
#[utoipa::path(
get, path = "/users/{username}/following",
params(
("username" = String, Path, description = "Username"),
PaginationQuery,
),
responses((status = 200, description = "Users this account follows"))
)]
pub async fn get_following_handler( pub async fn get_following_handler(
Deps(d): Deps<FeedDeps>, Deps(d): Deps<FeedDeps>,
Path(param): Path<String>, Path(param): Path<String>,
Query(q): Query<PaginationQuery>, Query(q): Query<PaginationQuery>,
headers: HeaderMap, headers: HeaderMap,
) -> Result<Response, ApiError> { ) -> Result<Response, ApiError> {
let accept = headers if wants_activity_json(&headers) {
.get(header::ACCEPT)
.and_then(|v| v.to_str().ok())
.unwrap_or("");
if accept.contains("application/activity+json") {
let user = get_user_by_id_or_username(&*d.users, &param).await?; let user = get_user_by_id_or_username(&*d.users, &param).await?;
let user_id = user.id; let user_id = user.id;
let page = q.page().try_into().ok(); let page = q.page().try_into().ok();
@@ -166,26 +234,31 @@ pub async fn get_following_handler(
page: q.page(), page: q.page(),
per_page: q.per_page(), per_page: q.per_page(),
}; };
let result = d.follows.list_following(&user.id, &page).await?; let result = list_local_following(&*d.follows, &user.id, page).await?;
Ok(Json(serde_json::json!({ Ok(Json(PagedResponse {
"total": result.total, items: result.items.iter().map(to_user_response).collect(),
"items": result.items.iter().map(to_user_response).collect::<Vec<_>>() total: result.total,
})) page: result.page,
per_page: result.per_page,
})
.into_response()) .into_response())
} }
#[utoipa::path(
get, path = "/users/{username}/followers",
params(
("username" = String, Path, description = "Username"),
PaginationQuery,
),
responses((status = 200, description = "Accounts that follow this user"))
)]
pub async fn get_followers_handler( pub async fn get_followers_handler(
Deps(d): Deps<FeedDeps>, Deps(d): Deps<FeedDeps>,
Path(param): Path<String>, Path(param): Path<String>,
Query(q): Query<PaginationQuery>, Query(q): Query<PaginationQuery>,
headers: HeaderMap, headers: HeaderMap,
) -> Result<Response, ApiError> { ) -> Result<Response, ApiError> {
let accept = headers if wants_activity_json(&headers) {
.get(header::ACCEPT)
.and_then(|v| v.to_str().ok())
.unwrap_or("");
if accept.contains("application/activity+json") {
let user = get_user_by_id_or_username(&*d.users, &param).await?; let user = get_user_by_id_or_username(&*d.users, &param).await?;
let user_id = user.id; let user_id = user.id;
let page = q.page().try_into().ok(); let page = q.page().try_into().ok();
@@ -201,11 +274,13 @@ pub async fn get_followers_handler(
page: q.page(), page: q.page(),
per_page: q.per_page(), per_page: q.per_page(),
}; };
let result = d.follows.list_followers(&user.id, &page).await?; let result = list_local_followers(&*d.follows, &user.id, page).await?;
Ok(Json(serde_json::json!({ Ok(Json(PagedResponse {
"total": result.total, items: result.items.iter().map(to_user_response).collect(),
"items": result.items.iter().map(to_user_response).collect::<Vec<_>>() total: result.total,
})) page: result.page,
per_page: result.per_page,
})
.into_response()) .into_response())
} }
@@ -214,6 +289,7 @@ pub async fn get_followers_handler(
params( params(
("username" = String, Path, description = "Username"), ("username" = String, Path, description = "Username"),
PaginationQuery, PaginationQuery,
FeedOptionsQuery,
), ),
responses((status = 200, description = "User's public thoughts")) responses((status = 200, description = "User's public thoughts"))
)] )]
@@ -222,24 +298,30 @@ pub async fn user_thoughts_handler(
Path(username): Path<String>, Path(username): Path<String>,
OptionalAuthUser(viewer): OptionalAuthUser, OptionalAuthUser(viewer): OptionalAuthUser,
Query(q): Query<PaginationQuery>, Query(q): Query<PaginationQuery>,
) -> Result<Json<serde_json::Value>, ApiError> { Query(opts_q): Query<FeedOptionsQuery>,
) -> Result<Json<PagedResponse<ThoughtResponse>>, ApiError> {
let user = get_user_by_username(&*d.users, &username).await?; let user = get_user_by_username(&*d.users, &username).await?;
let page = PageParams { let page = PageParams {
page: q.page(), page: q.page(),
per_page: q.per_page(), per_page: q.per_page(),
}; };
let result = d let opts = FeedOptions::try_from(opts_q)?;
.feed let result = get_user_feed(&*d.feed, user.id.clone(), page, opts, viewer).await?;
.query(&FeedQuery::user(user.id.clone(), page, viewer)) Ok(Json(PagedResponse {
.await?; items: result.items.iter().map(to_thought_response).collect(),
Ok(Json(serde_json::json!({ total: result.total,
"total": result.total, page: result.page,
"page": result.page, per_page: result.per_page,
"per_page": result.per_page, }))
"items": result.items.iter().map(to_thought_response).collect::<Vec<_>>()
})))
} }
#[utoipa::path(
get, path = "/tags/popular",
params(
("limit" = Option<u64>, Query, description = "Max tags to return (default 20, max 100)"),
),
responses((status = 200, description = "Most-used tags"))
)]
pub async fn get_popular_tags( pub async fn get_popular_tags(
Deps(d): Deps<FeedDeps>, Deps(d): Deps<FeedDeps>,
Query(params): Query<std::collections::HashMap<String, String>>, Query(params): Query<std::collections::HashMap<String, String>>,
@@ -247,11 +329,9 @@ pub async fn get_popular_tags(
let limit: usize = params let limit: usize = params
.get("limit") .get("limit")
.and_then(|v| v.parse().ok()) .and_then(|v| v.parse().ok())
.unwrap_or(api_types::requests::DEFAULT_PER_PAGE as usize); .unwrap_or(api_types::requests::DEFAULT_PER_PAGE as usize)
let tags = d .min(api_types::requests::MAX_PER_PAGE as usize);
.tags let tags = uc_get_popular_tags(&*d.tags, limit).await?;
.popular_tags(limit.min(api_types::requests::MAX_PER_PAGE as usize))
.await?;
Ok(Json(serde_json::json!({ Ok(Json(serde_json::json!({
"tags": tags.iter().map(|(name, count)| serde_json::json!({ "tags": tags.iter().map(|(name, count)| serde_json::json!({
"name": name, "name": name,
@@ -265,6 +345,7 @@ pub async fn get_popular_tags(
params( params(
("name" = String, Path, description = "Tag name"), ("name" = String, Path, description = "Tag name"),
PaginationQuery, PaginationQuery,
FeedOptionsQuery,
), ),
responses((status = 200, description = "Thoughts with this tag")) responses((status = 200, description = "Thoughts with this tag"))
)] )]
@@ -273,20 +354,18 @@ pub async fn tag_thoughts_handler(
Path(tag_name): Path<String>, Path(tag_name): Path<String>,
OptionalAuthUser(viewer): OptionalAuthUser, OptionalAuthUser(viewer): OptionalAuthUser,
Query(q): Query<PaginationQuery>, Query(q): Query<PaginationQuery>,
) -> Result<Json<serde_json::Value>, ApiError> { Query(opts_q): Query<FeedOptionsQuery>,
) -> Result<Json<PagedResponse<ThoughtResponse>>, ApiError> {
let page = PageParams { let page = PageParams {
page: q.page(), page: q.page(),
per_page: q.per_page(), per_page: q.per_page(),
}; };
let result = d let opts = FeedOptions::try_from(opts_q)?;
.feed let result = get_tag_feed(&*d.feed, &tag_name, page, opts, viewer).await?;
.query(&FeedQuery::tag(&tag_name, page, viewer)) Ok(Json(PagedResponse {
.await?; items: result.items.iter().map(to_thought_response).collect(),
Ok(Json(serde_json::json!({ total: result.total,
"tag": tag_name, page: result.page,
"total": result.total, per_page: result.per_page,
"page": result.page, }))
"per_page": result.per_page,
"items": result.items.iter().map(to_thought_response).collect::<Vec<_>>(),
})))
} }

View File

@@ -9,3 +9,4 @@ pub mod notifications;
pub mod social; pub mod social;
pub mod thoughts; pub mod thoughts;
pub mod users; pub mod users;
pub mod well_known;

View File

@@ -3,7 +3,7 @@ use crate::{
errors::ApiError, errors::ApiError,
extractors::{AuthUser, Deps}, extractors::{AuthUser, Deps},
}; };
use api_types::requests::NotificationUpdateRequest; use api_types::{requests::NotificationUpdateRequest, responses::NotificationSummaryResponse};
use application::use_cases::notifications::{ use application::use_cases::notifications::{
count_unread_notifications, list_notifications as uc_list_notifications, count_unread_notifications, list_notifications as uc_list_notifications,
mark_all_notifications_read, mark_notification_read as uc_mark_notification_read, mark_all_notifications_read, mark_notification_read as uc_mark_notification_read,
@@ -22,17 +22,17 @@ deps_struct!(NotificationsDeps {
pub async fn list_notifications( pub async fn list_notifications(
Deps(d): Deps<NotificationsDeps>, Deps(d): Deps<NotificationsDeps>,
AuthUser(uid): AuthUser, AuthUser(uid): AuthUser,
) -> Result<Json<serde_json::Value>, ApiError> { ) -> Result<Json<NotificationSummaryResponse>, ApiError> {
let page = PageParams { let page = PageParams {
page: 1, page: 1,
per_page: 20, per_page: 20,
}; };
let result = uc_list_notifications(&*d.notifications, &uid, page).await?; let result = uc_list_notifications(&*d.notifications, &uid, page).await?;
let unread = count_unread_notifications(&*d.notifications, &uid).await?; let unread = count_unread_notifications(&*d.notifications, &uid).await?;
Ok(Json(serde_json::json!({ Ok(Json(NotificationSummaryResponse {
"total": result.total, total: result.total,
"unread": unread unread,
}))) }))
} }
#[utoipa::path(patch, path = "/notifications/{id}", params(("id" = uuid::Uuid, Path, description = "Notification ID")), request_body = NotificationUpdateRequest, responses((status = 204, description = "Marked read")), security(("bearer_auth" = [])))] #[utoipa::path(patch, path = "/notifications/{id}", params(("id" = uuid::Uuid, Path, description = "Notification ID")), request_body = NotificationUpdateRequest, responses((status = 204, description = "Marked read")), security(("bearer_auth" = [])))]

View File

@@ -3,7 +3,7 @@ use crate::testing::make_state;
use axum::{ use axum::{
body::Body, body::Body,
http::{header, Request}, http::{header, Request},
routing::{get, patch}, routing::patch,
Router, Router,
}; };
use tower::ServiceExt; use tower::ServiceExt;

View File

@@ -4,11 +4,15 @@ use crate::{
errors::ApiError, errors::ApiError,
extractors::{AuthUser, Deps}, extractors::{AuthUser, Deps},
}; };
use api_types::requests::SetTopFriendsRequest; use api_types::requests::{PaginationQuery, SetTopFriendsRequest};
use api_types::responses::TopFriendsResponse; use api_types::responses::{PagedResponse, TopFriendsResponse, UserResponse};
use application::use_cases::profile::{get_top_friends, get_user_by_username, set_top_friends}; use application::use_cases::profile::{get_top_friends, get_user_by_username, set_top_friends};
use application::use_cases::social::*; use application::use_cases::social::*;
use axum::{extract::Path, http::StatusCode, Json}; use axum::{
extract::{Path, Query},
http::StatusCode,
Json,
};
use domain::{ use domain::{
ports::{ ports::{
BlockRepository, BoostRepository, EventPublisher, FederationActionPort, FollowRepository, BlockRepository, BoostRepository, EventPublisher, FederationActionPort, FollowRepository,
@@ -115,7 +119,15 @@ pub async fn post_block(
AuthUser(uid): AuthUser, AuthUser(uid): AuthUser,
Path(username): Path<String>, Path(username): Path<String>,
) -> Result<StatusCode, ApiError> { ) -> Result<StatusCode, ApiError> {
block_by_username(&*d.blocks, &*d.users, &*d.events, &uid, &username).await?; block_by_username(
&*d.blocks,
&*d.users,
&*d.federation,
&*d.events,
&uid,
&username,
)
.await?;
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
} }
#[utoipa::path(delete, path = "/users/{username}/block", params(("username" = String, Path, description = "Username")), responses((status = 204, description = "Unblocked")), security(("bearer_auth" = [])))] #[utoipa::path(delete, path = "/users/{username}/block", params(("username" = String, Path, description = "Username")), responses((status = 204, description = "Unblocked")), security(("bearer_auth" = [])))]
@@ -124,7 +136,15 @@ pub async fn delete_block(
AuthUser(uid): AuthUser, AuthUser(uid): AuthUser,
Path(username): Path<String>, Path(username): Path<String>,
) -> Result<StatusCode, ApiError> { ) -> Result<StatusCode, ApiError> {
unblock_by_username(&*d.blocks, &*d.users, &*d.events, &uid, &username).await?; unblock_by_username(
&*d.blocks,
&*d.users,
&*d.federation,
&*d.events,
&uid,
&username,
)
.await?;
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
} }
#[utoipa::path(put, path = "/users/me/top-friends", request_body = SetTopFriendsRequest, responses((status = 204, description = "Top friends updated")), security(("bearer_auth" = [])))] #[utoipa::path(put, path = "/users/me/top-friends", request_body = SetTopFriendsRequest, responses((status = 204, description = "Top friends updated")), security(("bearer_auth" = [])))]
@@ -150,5 +170,33 @@ pub async fn get_top_friends_handler(
Ok(Json(TopFriendsResponse { top_friends })) Ok(Json(TopFriendsResponse { top_friends }))
} }
#[utoipa::path(
get, path = "/users/me/friends",
params(PaginationQuery),
responses(
(status = 200, description = "Local mutual follows (paginated)", body = inline(PagedResponse<UserResponse>)),
(status = 401, description = "Unauthorized"),
),
security(("bearer_auth" = []))
)]
pub async fn get_friends_handler(
Deps(d): Deps<SocialDeps>,
AuthUser(uid): AuthUser,
Query(q): Query<PaginationQuery>,
) -> Result<Json<PagedResponse<UserResponse>>, ApiError> {
use domain::models::feed::PageParams;
let page = PageParams {
page: q.page(),
per_page: q.per_page(),
};
let result = get_local_friends(&*d.follows, &uid, &page).await?;
Ok(Json(PagedResponse {
items: result.items.iter().map(to_user_response).collect(),
total: result.total,
page: result.page,
per_page: result.per_page,
}))
}
#[cfg(test)] #[cfg(test)]
mod tests; mod tests;

View File

@@ -1,9 +1,10 @@
use super::get_friends_handler;
use super::*; use super::*;
use crate::testing::make_state; use crate::testing::make_state;
use axum::{ use axum::{
body::Body, body::Body,
http::Request, http::Request,
routing::{delete, post}, routing::{get, post},
Router, Router,
}; };
use tower::ServiceExt; use tower::ServiceExt;
@@ -32,6 +33,24 @@ async fn follow_without_auth_returns_401() {
assert_eq!(resp.status(), 401); assert_eq!(resp.status(), 401);
} }
#[tokio::test]
async fn get_friends_without_auth_returns_401() {
let app = Router::new()
.route("/users/me/friends", get(get_friends_handler))
.with_state(make_state());
let resp = app
.oneshot(
Request::builder()
.method("GET")
.uri("/users/me/friends")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), 401);
}
#[tokio::test] #[tokio::test]
async fn unfollow_remote_without_auth_returns_401() { async fn unfollow_remote_without_auth_returns_401() {
let resp = app() let resp = app()

View File

@@ -61,6 +61,7 @@ pub async fn post_thought(
visibility: body.visibility, visibility: body.visibility,
content_warning: body.content_warning, content_warning: body.content_warning,
sensitive: body.sensitive.unwrap_or(false), sensitive: body.sensitive.unwrap_or(false),
mood: body.mood,
}, },
) )
.await?; .await?;

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