Compare commits

...

166 Commits

Author SHA1 Message Date
2d6121239f fix: add wget to runtime image for healthcheck 2026-05-12 02:15:37 +02:00
5c918accbc feat: add GET /health endpoint for Docker/k8s liveness probes 2026-05-12 02:05:37 +02:00
6eeba2cf57 feat: review delete/update AP propagation, remote actor avatars, OpenAPI updates
- Send Delete activity when a review is deleted
- Send Update(Note) activity when a review is edited
- Remote actor avatars shown in followers/following pages
- OpenAPI spec updated with profile, blocked domains, blocked actor endpoints
- Fix: worker wire() call missing allow_registration argument
2026-05-12 02:02:41 +02:00
8cd90624bb feat: move Settings/Blocked/Admin links to profile page own-account section 2026-05-12 01:14:43 +02:00
40c0f634d4 feat: add Settings, Blocked, and Admin nav links; add is_admin to HtmlPageContext 2026-05-12 01:12:16 +02:00
f0620f5aa1 feat: discoverability (NodeInfo, hashtags) and moderation (domain/actor blocking)
- NodeInfo at /.well-known/nodeinfo + /nodeinfo/2.0
- Hashtags #MoviesDiary + #MovieTitle on review posts; /tags/{tag} redirect
- Domain blocking: blocked_domains table, admin API + HTML, inbox enforcement
- Per-actor blocking: blocked_actors table, user API + HTML, BlockActivity send/receive
- Delivery filter excludes blocked actors and blocked-domain inboxes
2026-05-12 01:05:46 +02:00
80f620c840 feat: image storage generalization, user profile, and federation polish
- Replace PosterStorage with generic ImageStorage port (IMAGE_STORAGE_BACKEND/PATH env vars)
- Rename poster-storage crate to image-storage; serve at /images/{*key}
- Add bio and avatar_path to User model (migration 0009_user_profile)
- update_profile use case with avatar upload, mime validation, old avatar cleanup
- GET/PUT /api/v1/profile and GET/POST /settings/profile HTML page
- Enrich AP Person actor with summary, icon, url, discoverable fields
- Store remote actor avatar_url (migration 0010_ap_remote_actor_avatar)
- Shared inbox delivery via collect_inboxes deduplication
- Broadcast Update(Person) to followers on UserUpdated event
- Paginated outbox: OrderedCollection + OrderedCollectionPage with cursor
- Announce/boost tracking in ap_announces table (migration 0011_ap_announces)
2026-05-11 22:59:52 +02:00
8a254346f4 inbox rate limiting + poster storage cleanup 2026-05-11 01:07:21 +02:00
b2a2aa4262 movie detail page + importer architecture fix 2026-05-10 23:59:26 +02:00
f2f1317660 importer feature 2026-05-10 21:23:56 +02:00
a47e3ae4e6 feat: update README with improved adapter descriptions and Docker usage instructions 2026-05-10 19:00:21 +02:00
e461c689d9 feat: add axum dependency and implement wiring functions for federation repositories 2026-05-10 18:58:41 +02:00
810d051dee Refactor event payload handling across adapters
- Introduced `event-payload` crate to centralize event payload definitions.
- Updated NATS and PostgreSQL adapters to use the new `EventPayload` type.
- Removed redundant event payload definitions and conversion implementations from NATS and PostgreSQL adapters.
- Simplified SQLite event queue to utilize the new `EventPayload`.
- Refactored wiring functions for PostgreSQL and SQLite to improve database connection handling and migration.
- Cleaned up presentation and worker crates by removing unused event publisher dependencies and related wiring functions.
2026-05-10 18:41:42 +02:00
af25a43bbc feat: add federation support with SQLite and Postgres repositories 2026-05-10 18:22:24 +02:00
bfad8604dd feat: update deploy script to include build arguments for SQLite and NATS features 2026-05-10 18:10:04 +02:00
9ca5bebe59 feat: refactor database connection handling to use DbPool enum for better abstraction 2026-05-10 18:03:08 +02:00
c2a5541706 feat: implement event bus backend configuration for DB and NATS 2026-05-10 17:55:51 +02:00
dca50b46d1 feat: add SQLite and PostgreSQL event queue adapters with migrations 2026-05-10 17:46:16 +02:00
37b0e07055 feat: implement SQLite and Postgres event queue adapters
- Added SQLite and Postgres event queue implementations with migrations and payload structures.
- Created migration scripts for both SQLite and Postgres event queues.
- Implemented event publishing and consumption logic for both adapters.
- Added serialization and deserialization for domain events to database payloads.
- Updated presentation and worker crates to support new event queue features.
- Refactored event handling to utilize the new database-backed event queues.
2026-05-10 16:09:36 +02:00
8678bbf391 nats adapter 2026-05-10 13:42:28 +02:00
05b44e17a1 background worker 2026-05-10 11:12:52 +02:00
661b54d645 feat: add futures dependency to Cargo.lock 2026-05-10 02:53:02 +02:00
e8b1685c9e feat: add futures workspace dep, upgrade EventConsumer to BoxStream 2026-05-10 02:52:37 +02:00
a8092a140d feat: add EventConsumer port to domain 2026-05-10 02:50:58 +02:00
9c11ac2bcc feat: add TMDB metadata provider, prefer over OMDB when TMDB_API_KEY is set 2026-05-10 02:30:53 +02:00
ebf74a59fd feat: feature flags 2026-05-10 02:26:18 +02:00
597685520c feat: postgresql adapter 2026-05-10 01:57:10 +02:00
9be7af50d2 feat: admin role 2026-05-10 01:15:48 +02:00
be30a1d77c feat: show profile RSS in footer when viewing another user's profile 2026-05-10 00:46:51 +02:00
66f9ef887e feat: ux improvements 2026-05-10 00:41:43 +02:00
9f894ebdf2 feat: feed ux improvements 2026-05-10 00:16:29 +02:00
f4e7d4e359 feat: update README with enhanced feature descriptions and API documentation 2026-05-09 22:40:54 +02:00
a078d5315e feat: integrate axum-governor for rate limiting and update dependencies 2026-05-09 22:35:08 +02:00
d89d373a91 feat: implement CSRF protection across forms and routes 2026-05-09 22:09:19 +02:00
e8874f9220 feat: add activity feed and user profile endpoints with corresponding DTOs 2026-05-09 21:40:45 +02:00
fa501706cd feat: add documentation crate and integrate OpenAPI specifications
- Added a new crate `doc` for API documentation.
- Integrated `utoipa` for OpenAPI support in the presentation layer.
- Updated routes to include social features (follow, unfollow, etc.) and diary export.
- Enhanced API request and response structures with new DTOs for social interactions.
- Updated `Cargo.toml` files to include new dependencies and features.
- Modified Dockerfile to copy the new documentation crate.
- Refactored existing handlers and routes to accommodate new API endpoints.
- Updated tests to cover new functionality and ensure proper API behavior.
2026-05-09 21:29:20 +02:00
f66f8745c7 feat: add export diary functionality and update API routes 2026-05-09 20:59:30 +02:00
5f9e047ce3 fix: add missing export adapter Cargo.toml to Dockerfile 2026-05-09 20:52:29 +02:00
dcfc17f542 export feature 2026-05-09 20:51:29 +02:00
1eaa3ca8a6 Refactor test stubs and consolidate panic implementations
- Removed redundant panic repository implementations in `event_handlers.rs` and `extractors.rs`.
- Introduced a single `Panic` struct to serve as a stub for various repository and service traits.
- Simplified test state creation by using a factory function `make_test_state` to reduce code duplication.
- Updated tests to utilize the new panic implementation and streamlined router setup for better readability.
2026-05-09 20:07:44 +02:00
89e78a0d1f Refactor application context and repository structure
- Updated `AppContext` to include separate repositories for movies, reviews, diaries, and stats.
- Modified use cases to utilize the new repository structure, ensuring that the correct repositories are called for their respective operations.
- Introduced `DiaryRepository` and `StatsRepository` traits to encapsulate diary and statistics-related operations.
- Updated all relevant use cases, handlers, and tests to reflect the changes in repository usage.
- Ensured that panic repositories are updated to implement the new traits for testing purposes.
2026-05-09 18:58:29 +02:00
29a5972c01 refactor: remove unused actor_handler import from ActivityPubService 2026-05-09 18:42:57 +02:00
eb4d17885e refactor: standardize user ID route parameter in ActivityPubService 2026-05-09 18:37:55 +02:00
e1f84b6796 Implement local follow and unfollow functionality in ActivityPubService 2026-05-09 18:28:44 +02:00
2120044f1a Refactor ActivityPub integration and add SQLite federation support
- Removed event-publisher dependency from Cargo.lock and Cargo.toml.
- Introduced sqlite-federation crate with necessary dependencies and implementation.
- Updated activitypub crate to use new ActivityPubPort trait for better abstraction.
- Refactored event handling to utilize domain ports instead of direct dependencies.
- Adjusted presentation layer to accommodate new ActivityPub service structure.
- Removed unused test setup for ActivityPub service in favor of NoopActivityPubService.
- Cleaned up SQLite adapter to remove unnecessary dependencies and streamline functionality.
2026-05-09 18:21:16 +02:00
7a43eb4de6 activitypub: remove files moved to activitypub-base 2026-05-09 17:27:57 +02:00
e8d9c0a501 dockerfile: add activitypub-base stub 2026-05-09 17:26:26 +02:00
8819266cf9 separation of activitypub 2026-05-09 17:23:06 +02:00
69f6587623 federation improvements 2026-05-09 15:45:08 +02:00
fa6eacb39f clean up 2026-05-09 14:42:03 +02:00
6cd332f758 todo: exporter 2026-05-09 14:30:11 +02:00
ebab30b1ea local file system 2026-05-09 14:17:25 +02:00
470b29c9e1 federation refinement 2026-05-09 13:53:45 +02:00
df71748897 activity-pub implementation 2026-05-08 21:26:50 +02:00
940c33047c feat: add favicon to the base template 2026-05-08 14:23:15 +02:00
06b3761401 fix: windowed pagination — show 1…current±2…last instead of all pages 2026-05-08 13:47:34 +02:00
4ea5f4cecf feat: add uuid dependency to Cargo.lock and create deploy script 2026-05-08 13:39:44 +02:00
a282539151 fix: profile delete redirect and open redirect via // 2026-05-08 13:33:59 +02:00
5f8eba9f84 fix: guard total_pages division in render_diary_page 2026-05-08 13:28:04 +02:00
e335b6ea9b feat: add page-number navigation to paginated views 2026-05-08 13:20:06 +02:00
8919c36ef6 feat: preserve pagination offset after review delete 2026-05-08 13:15:47 +02:00
53b7ac2e41 feat: accept date-only watched_at in review form 2026-05-08 13:12:56 +02:00
afb012ffde feat(config): add rate limit configuration to AppConfig and update related usages 2026-05-08 10:11:19 +02:00
46bd416410 feat(tui): update keyring initialization and enhance token handling in main function 2026-05-07 21:24:01 +02:00
9a84fcf061 feat(tui): call init_keyring at startup before first keyring operation 2026-05-07 21:10:57 +02:00
72542a9189 feat(tui): fix keyring import path, add init_keyring() with platform feature flags 2026-05-07 21:09:21 +02:00
71586a255a chore(tui): add keyring platform feature flags 2026-05-07 21:00:30 +02:00
eb25c61feb refactor: simplify token handling and enhance input rendering in UI 2026-05-07 00:43:08 +02:00
d5ebfe840a feat: enhance diary navigation with LoadPrev action and pagination hints 2026-05-07 00:35:03 +02:00
fcd8d2122a tui - client app. 2026-05-07 00:14:47 +02:00
bf2f95963a css 2026-05-04 23:24:04 +02:00
b5cd932bfb feat: per-page titles, OG/SEO tags, HOST/PORT env vars, BASE_URL in config 2026-05-04 22:38:58 +02:00
ca06e42caf fix: update .gitignore to include db-shm and db-wal files 2026-05-04 22:23:08 +02:00
a49fbad26c fix: WAL mode + busy_timeout for SQLite, fix rate limiter TOCTOU race 2026-05-04 22:10:19 +02:00
5852af0b05 refactor: use constant for minimum password length and API rate limit 2026-05-04 21:41:07 +02:00
6cb3cf072a fix: security hardening — SameSite=Strict, Secure cookie flag, password min length, generic registration error, auth rate limiting 2026-05-04 21:38:23 +02:00
789a04d38f clean up 2026-05-04 21:24:44 +02:00
2934a3c3fd fix: use pixel bar heights and show avg rating values in trends chart 2026-05-04 21:22:47 +02:00
bb14b26dcd fix: count distinct movies per user in users list, not total reviews 2026-05-04 21:10:32 +02:00
6a42e183b9 fix: read BASE_URL from env for RSS channel link 2026-05-04 21:06:51 +02:00
fe7f915302 fix: move rss_url after user lookup, extract RSS_FEED_LIMIT constant 2026-05-04 21:05:08 +02:00
c30b3a1ae2 feat: point RSS nav link to user feed when on profile page 2026-05-04 21:00:31 +02:00
cdc15d733d feat: add GET /users/{id}/feed.rss per-user RSS feed handler 2026-05-04 20:58:20 +02:00
549b0bbe09 feat: add rss_url to HtmlPageContext, use it in nav 2026-05-04 20:55:31 +02:00
a3b6f3d894 fix: remove unused feed_title from RssAdapter 2026-05-04 20:54:32 +02:00
72ce31ded2 feat: add title param to render_feed, use dynamic title in RSS adapter 2026-05-04 20:52:07 +02:00
f53a129331 feat: add user_id filter to GetDiaryQuery and get_diary use case 2026-05-04 20:49:31 +02:00
6565ec6e66 fix: derive heatmap color from primary instead of hardcoded blue 2026-05-04 20:38:13 +02:00
f5129c8ee7 fix: count distinct movies in user stats, not total reviews 2026-05-04 20:35:48 +02:00
7f431eca23 fix: remove timezone-broken future-date check from Review::new 2026-05-04 20:29:11 +02:00
c1c57a2a5d fix: remove redundant 'common' section from documentation 2026-05-04 20:28:06 +02:00
6c5825ea48 fix: update test assertion for new default page limit 2026-05-04 20:18:04 +02:00
4b3ccccf6f fix: set domain DEFAULT_LIMIT to 5 for pagination 2026-05-04 20:16:18 +02:00
1ad0684249 fix: remove email from top bar nav 2026-05-04 20:13:14 +02:00
12818d41a4 fix: lower default page limit to 5 2026-05-04 20:11:55 +02:00
ada1fc78a9 feat: add chrono dependency to Cargo.lock 2026-05-04 19:48:40 +02:00
94e3b12dca fix: add missing trait stubs to test mock impls 2026-05-04 19:23:56 +02:00
18b373f147 feat: add profile/feed/chart CSS styles 2026-05-04 19:17:39 +02:00
4dd1cd7fc5 fix: has_more overflow, magic constant, remove dead get_index handler 2026-05-04 19:15:42 +02:00
0b6ac2eb77 feat: wire activity feed, users list, and profile page handlers 2026-05-04 19:12:06 +02:00
6bd1ce8c4a fix: pagination underflow, remove |safe, move bar_height_pct to adapter 2026-05-04 19:09:28 +02:00
af945bcd54 fix: adjust domain accessors and template adapter for Askama compatibility 2026-05-04 19:03:48 +02:00
89dd2f3a47 feat: add activity feed, users, and profile HTML templates 2026-05-04 19:03:44 +02:00
77e1e04a44 fix: correct relative_time future guard, heatmap exact match, max clarity 2026-05-04 18:57:17 +02:00
b15b18409d feat: add Askama template structs for feed/users/profile 2026-05-04 18:55:18 +02:00
d95d65c943 fix: validate view param, document V1 history load 2026-05-04 18:53:26 +02:00
0d2ca3ab6c feat: add activity feed/users/profile use cases and port methods 2026-05-04 18:48:16 +02:00
e9b01eea2b fix: address code review issues in SQLite adapter 2026-05-04 18:46:31 +02:00
f5fe8aeaff feat: implement feed/stats/history/trends SQLite queries 2026-05-04 18:42:45 +02:00
bb1337c5b1 feat: impl UserRepository::list_with_stats 2026-05-04 18:40:58 +02:00
70c8676ae3 feat: add feed/stats SQLite row types 2026-05-04 18:32:59 +02:00
96635360a6 feat: add feed/profile/stats port methods to repositories 2026-05-04 18:30:01 +02:00
923d445431 fix: use UserId newtype in UserSummary instead of raw Uuid 2026-05-04 18:29:10 +02:00
5ada90a99f feat: add feed/profile domain models, extend DiaryFilter 2026-05-04 18:26:55 +02:00
02d8741ea5 feat: update color scheme to use primary variables for consistency in styling 2026-05-04 17:47:00 +02:00
2a347dbdc2 feat: enhance movie rating display with star icons and improved styling 2026-05-04 15:43:02 +02:00
41da6e1f56 feat: enhance styling and layout; add background image and improve UI elements 2026-05-04 15:39:15 +02:00
7ba4b39ced feat: add Dockerfile, .dockerignore, and README; remove common crate 2026-05-04 15:19:29 +02:00
7e7c1ee1f4 Refactor movie review logging and resolution strategies
- Introduced `MovieResolver` and associated strategies for resolving movie data based on external metadata ID, manual title, or manual entry.
- Updated `log_review` use case to utilize the new `MovieResolver` for fetching movie details.
- Simplified the `LogReviewData` structure and its conversion to `LogReviewCommand`.
- Enhanced error handling for date parsing in review forms and requests.
- Updated dependencies in `Cargo.toml` and `Cargo.lock` to include necessary crates for async operations.
- Added tests for new functionality in `movie_resolver.rs` to ensure correct behavior of resolution strategies.
2026-05-04 15:08:04 +02:00
7913b3e0e5 fix(tests): add missing trait methods to PanicRepo mocks 2026-05-04 14:37:48 +02:00
39ac5a0c50 fix(presentation): restore user_id in get_new_review_page for nav bar 2026-05-04 14:34:46 +02:00
36f3a9c44b fix(presentation): pass None user_id for non-diary page contexts 2026-05-04 14:32:30 +02:00
7c704f39b3 feat(template): add user_id to HtmlPageContext and delete button to diary 2026-05-04 14:31:12 +02:00
6d71da9f1e fix(presentation): use {id} syntax in delete review route 2026-05-04 14:29:24 +02:00
977a8ed8fd feat(presentation): add POST /reviews/:id/delete handler and route 2026-05-04 14:27:43 +02:00
f74a36a16f feat(presentation): add DELETE /api/reviews/:id handler and route 2026-05-04 14:24:48 +02:00
8d7700995d feat(sqlite): implement get_review_by_id, delete_review, delete_movie 2026-05-04 14:21:25 +02:00
de84bb7331 feat(application): add DeleteReviewCommand and delete_review use case 2026-05-04 14:17:13 +02:00
4c38724d93 feat(domain): add get_review_by_id to MovieRepository 2026-05-04 14:15:04 +02:00
c8cf2f2836 feat(gitignore): add .superpowers and docs/ to .gitignore 2026-05-04 14:00:56 +02:00
6e50b64245 feat(database): remove unused SQL queries and update Cargo dependencies 2026-05-04 14:00:33 +02:00
528a16a235 feat(css): add monospace minimal stylesheet 2026-05-04 13:38:57 +02:00
8ee54817d7 feat(routes): replace /diary with /, add login/logout/register/reviews/new routes 2026-05-04 13:38:19 +02:00
5c0be8b2ca feat(handlers): add HTML handlers for login, logout, register, new review, diary index 2026-05-04 13:38:16 +02:00
17be6f529a feat(dtos): add LoginForm, RegisterForm, ErrorQuery 2026-05-04 13:38:14 +02:00
7560ff8ac5 feat(extractors): add OptionalCookieUser and RequiredCookieUser 2026-05-04 13:34:31 +02:00
2a4f1585dc feat(templates): add base layout, login, register, new_review templates; update diary 2026-05-04 13:30:33 +02:00
37c7c77399 feat(domain): add find_by_id to UserRepository + SQLite impl 2026-05-04 13:28:20 +02:00
657e9d443d feat(ports): extend HtmlRenderer with page context and new render methods 2026-05-04 13:20:30 +02:00
5e1489058d docs: add frontend HTML design spec 2026-05-04 13:19:21 +02:00
b7f4855ba3 fix(database): update database connection to use DATABASE_URL with SqliteConnectOptions 2026-05-04 12:54:28 +02:00
494c907729 feat(wiring): wire PosterSyncHandler into event channel in main.rs 2026-05-04 12:44:53 +02:00
fb27e23861 fix(event-handlers): expect over unwrap, panic-stub comment, fix deprecated chrono call 2026-05-04 12:42:03 +02:00
7ab7561133 feat(presentation): implement PosterSyncHandler with retry 2026-05-04 12:37:57 +02:00
5fffdc93c7 feat(application): derive Clone on SyncPosterCommand 2026-05-04 12:35:10 +02:00
e17082b11d test(event-publisher): fix flaky sleep synchronization in EventWorker tests 2026-05-04 12:34:29 +02:00
c37c79ac6c feat(event-publisher): add EventHandler trait and fan-out in EventWorker 2026-05-04 12:32:44 +02:00
bda0531797 feat(event-publisher): add event publisher adapter with configuration and integration 2026-05-04 12:30:42 +02:00
a13481cb69 docs: event-driven poster sync implementation plan 2026-05-04 12:30:20 +02:00
6f21e8f8a2 docs: event-driven poster sync design spec 2026-05-04 12:24:52 +02:00
992a1a2674 feat(rss): implement RSS feed adapter and integrate with application state 2026-05-04 12:03:17 +02:00
c1c61ad19b feat(poster-fetcher): add poster fetcher adapter with configuration and integration 2026-05-04 11:51:20 +02:00
00c0b940bc feat(poster-storage): implement S3/Minio storage adapter and configuration 2026-05-04 11:44:44 +02:00
bd160e3861 feat(log_review): add manual title resolution for movie lookup 2026-05-04 11:24:18 +02:00
e78216b404 feat(metadata): Implement OMDB metadata provider and refactor metadata client
- Added `OmdbProvider` to fetch movie metadata from the OMDB API.
- Refactored `MetadataClient` to use `MetadataSearchCriteria` for fetching movie metadata.
- Updated `MetadataClientImpl` to support fetching metadata using OMDB.
- Modified `log_review` use case to utilize the new metadata fetching mechanism.
- Updated tests and presentation layer to accommodate changes in metadata handling.
- Added dependencies for `reqwest` and `async-trait` in relevant `Cargo.toml` files.
2026-05-04 11:19:51 +02:00
1f8b24fec2 feat(auth): implement JWT authentication and user registration
- Added JWT authentication with token generation and validation.
- Introduced user registration functionality with email and password.
- Integrated Argon2 for password hashing.
- Created SQLite user repository for user data persistence.
- Updated application context to include user repository and configuration settings.
- Added environment variable support for JWT secret and registration allowance.
- Enhanced error handling for unauthorized access and validation errors.
- Updated presentation layer to handle login and registration requests.
2026-05-04 10:43:07 +02:00
98ad1b19fa refactor(tests): remove unused models from api_test 2026-05-04 09:35:54 +02:00
60d0d68266 cargo lock 2026-05-04 09:30:58 +02:00
0d7a0cc712 presentation wiring 2026-05-04 09:30:20 +02:00
4a9bb5f264 chore: ignore .worktrees/ directory 2026-05-04 02:43:32 +02:00
d2c8886bb0 feat(presentation): add initial structure with dtos, errors, extractors, handlers, and routes modules 2026-05-04 02:11:33 +02:00
408f21b7ec refactor(template-askama): clean up comments and improve code readability 2026-05-04 02:05:13 +02:00
9cc2d3dd7a feat(template-askama): add Askama template adapter for diary entries 2026-05-04 02:04:52 +02:00
1823756fc1 feat(sqlite): implement movie and review management with migrations
- Added SQL migrations for movies and reviews tables.
- Implemented SqliteMovieRepository with methods for upserting movies, saving reviews, and querying diary entries.
- Introduced models for database rows and conversion to domain models.
- Integrated async migration handling in the repository.
- Updated Cargo.toml files to include necessary dependencies for async operations and HTTP handling.
2026-05-04 01:59:52 +02:00
c70a17fb38 Add SQLite repository implementation and update domain models for persistence 2026-05-04 01:34:52 +02:00
b026a184c2 application layer 2026-05-04 01:19:59 +02:00
293 changed files with 34857 additions and 237 deletions

2
.cargo/config.toml Normal file
View File

@@ -0,0 +1,2 @@
[env]
SQLX_OFFLINE = "true"

9
.dockerignore Normal file
View File

@@ -0,0 +1,9 @@
target/
.git/
.env
*.db
*.db-shm
*.db-wal
# .cargo and .sqlx are needed at build time (SQLX_OFFLINE mode)
docs/
dev.db

View File

@@ -0,0 +1,60 @@
# Database backend — "sqlite" (default) or "postgres"
DATABASE_BACKEND=sqlite
# Option A: SQLite (default, zero external dependencies)
DATABASE_URL=sqlite://movies.db
# Option B: PostgreSQL
# DATABASE_BACKEND=postgres
# DATABASE_URL=postgres://user:password@localhost:5432/movies_diary
# Authentication
JWT_SECRET=change-me
JWT_TTL_SECONDS=86400
# OMDb/TMDB metadata
OMDB_API_KEY=your-key
TMDB_API_KEY=your-key
# Poster storage — Option A (local) is active. To use S3, comment it out and uncomment Option B:
# Option A: local filesystem (zero external dependencies)
POSTER_STORAGE_BACKEND=local
POSTER_STORAGE_PATH=./posters
# Option B: S3-compatible (MinIO, AWS S3, etc.)
# POSTER_STORAGE_BACKEND=s3
# MINIO_ENDPOINT=http://localhost:9000
# MINIO_BUCKET=posters
# MINIO_REGION=minio
# MINIO_ACCESS_KEY_ID=minioadmin
# MINIO_SECRET_ACCESS_KEY=minioadmin
# Optional
HOST=0.0.0.0
PORT=3000
BASE_URL=http://localhost:3000
SECURE_COOKIES=false
ALLOW_REGISTRATION=false
RATE_LIMIT=20
POSTER_FETCH_TIMEOUT_SECONDS=30
# Event bus — "db" (default) or "nats"
# The worker binary must run alongside the presentation to process events.
EVENT_BUS_BACKEND=db
# Option A: DB queue (default — no extra infrastructure needed)
# Events are persisted in the same database as the app and polled by the worker.
# EVENT_QUEUE_POLL_INTERVAL_MS=500 # polling interval (default: 500ms)
# EVENT_QUEUE_BATCH_SIZE=10 # rows claimed per poll cycle (default: 10)
# EVENT_QUEUE_MAX_ATTEMPTS=5 # retries before dead-lettering (default: 5)
# Option B: NATS (at-least-once delivery, recommended for higher throughput)
# EVENT_BUS_BACKEND=nats
# NATS_URL=nats://localhost:4222
# NATS_MODE=jetstream # "jetstream" (default, at-least-once) or "core" (fire-and-forget)
# NATS_SUBJECT_PREFIX=movies-diary.events
# NATS_STREAM_NAME=MOVIES_DIARY_EVENTS
# NATS_CONSUMER_NAME=worker
RUST_LOG=presentation=debug,tower_http=debug,worker=info,application=info

10
.gitignore vendored
View File

@@ -5,4 +5,12 @@
.vscode
.env
.env.prod
.env.prod
*.db
*db-shm
*db-wal
.worktrees/
.superpowers/
docs/

View File

@@ -0,0 +1,98 @@
{
"db_name": "SQLite",
"query": "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,\n r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url\n FROM reviews r\n INNER JOIN movies m ON m.id = r.movie_id\n WHERE r.movie_id = ?\n ORDER BY r.watched_at DESC\n LIMIT ? OFFSET ?",
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "external_metadata_id",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "title",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "release_year",
"ordinal": 3,
"type_info": "Integer"
},
{
"name": "director",
"ordinal": 4,
"type_info": "Text"
},
{
"name": "poster_path",
"ordinal": 5,
"type_info": "Text"
},
{
"name": "review_id",
"ordinal": 6,
"type_info": "Text"
},
{
"name": "movie_id",
"ordinal": 7,
"type_info": "Text"
},
{
"name": "user_id",
"ordinal": 8,
"type_info": "Text"
},
{
"name": "rating",
"ordinal": 9,
"type_info": "Integer"
},
{
"name": "comment",
"ordinal": 10,
"type_info": "Text"
},
{
"name": "watched_at",
"ordinal": 11,
"type_info": "Text"
},
{
"name": "created_at",
"ordinal": 12,
"type_info": "Text"
},
{
"name": "remote_actor_url",
"ordinal": 13,
"type_info": "Text"
}
],
"parameters": {
"Right": 3
},
"nullable": [
false,
true,
false,
false,
true,
true,
false,
false,
false,
false,
true,
false,
false,
true
]
},
"hash": "05d958c1fa38095ae2b5b81ede48fc85702d8c39c6301839de7b4d27f4a4d41b"
}

View File

@@ -0,0 +1,20 @@
{
"db_name": "SQLite",
"query": "SELECT COUNT(*) FROM reviews",
"describe": {
"columns": [
{
"name": "COUNT(*)",
"ordinal": 0,
"type_info": "Integer"
}
],
"parameters": {
"Right": 0
},
"nullable": [
false
]
},
"hash": "0963b9661182e139cd760bbabb0d6ea3a301a2a3adbdfdda4a88f333a1144c77"
}

View File

@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "INSERT INTO users (id, email, username, password_hash, created_at, role) VALUES (?, ?, ?, ?, ?, ?)",
"describe": {
"columns": [],
"parameters": {
"Right": 6
},
"nullable": []
},
"hash": "0acf3515a4d1bef7a2458d878481ba3b11c9f252311579f7daba335f70d78f07"
}

View File

@@ -0,0 +1,98 @@
{
"db_name": "SQLite",
"query": "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,\n r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url\n FROM reviews r\n INNER JOIN movies m ON m.id = r.movie_id\n WHERE r.user_id = ?\n ORDER BY r.watched_at DESC",
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "external_metadata_id",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "title",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "release_year",
"ordinal": 3,
"type_info": "Integer"
},
{
"name": "director",
"ordinal": 4,
"type_info": "Text"
},
{
"name": "poster_path",
"ordinal": 5,
"type_info": "Text"
},
{
"name": "review_id",
"ordinal": 6,
"type_info": "Text"
},
{
"name": "movie_id",
"ordinal": 7,
"type_info": "Text"
},
{
"name": "user_id",
"ordinal": 8,
"type_info": "Text"
},
{
"name": "rating",
"ordinal": 9,
"type_info": "Integer"
},
{
"name": "comment",
"ordinal": 10,
"type_info": "Text"
},
{
"name": "watched_at",
"ordinal": 11,
"type_info": "Text"
},
{
"name": "created_at",
"ordinal": 12,
"type_info": "Text"
},
{
"name": "remote_actor_url",
"ordinal": 13,
"type_info": "Text"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
true,
false,
false,
true,
true,
false,
false,
false,
false,
true,
false,
false,
true
]
},
"hash": "106b5b65162314c47217c26b7e89194094e10122ea596e8d9323968e600635a9"
}

View File

@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "DELETE FROM import_sessions WHERE expires_at < datetime('now')",
"describe": {
"columns": [],
"parameters": {
"Right": 0
},
"nullable": []
},
"hash": "1c70d5d455e5ab3dfb68d35ce5255c7e0e86b9f6549ae8ae09247806f1321086"
}

View File

@@ -0,0 +1,98 @@
{
"db_name": "SQLite",
"query": "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,\n r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url\n FROM reviews r\n INNER JOIN movies m ON m.id = r.movie_id\n ORDER BY r.watched_at ASC\n LIMIT ? OFFSET ?",
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "external_metadata_id",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "title",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "release_year",
"ordinal": 3,
"type_info": "Integer"
},
{
"name": "director",
"ordinal": 4,
"type_info": "Text"
},
{
"name": "poster_path",
"ordinal": 5,
"type_info": "Text"
},
{
"name": "review_id",
"ordinal": 6,
"type_info": "Text"
},
{
"name": "movie_id",
"ordinal": 7,
"type_info": "Text"
},
{
"name": "user_id",
"ordinal": 8,
"type_info": "Text"
},
{
"name": "rating",
"ordinal": 9,
"type_info": "Integer"
},
{
"name": "comment",
"ordinal": 10,
"type_info": "Text"
},
{
"name": "watched_at",
"ordinal": 11,
"type_info": "Text"
},
{
"name": "created_at",
"ordinal": 12,
"type_info": "Text"
},
{
"name": "remote_actor_url",
"ordinal": 13,
"type_info": "Text"
}
],
"parameters": {
"Right": 2
},
"nullable": [
false,
true,
false,
false,
true,
true,
false,
false,
false,
false,
true,
false,
false,
true
]
},
"hash": "25fd01355c929a83daf2c802b8ae3adaa4ce73fc037e2bf2a87d60187aeb7361"
}

View File

@@ -0,0 +1,38 @@
{
"db_name": "SQLite",
"query": "SELECT u.id AS \"id!: String\",\n u.email AS \"email!: String\",\n COUNT(DISTINCT r.movie_id) AS \"total_movies!: i64\",\n AVG(CAST(r.rating AS REAL)) AS avg_rating\n FROM users u\n LEFT JOIN reviews r ON r.user_id = u.id AND r.remote_actor_url IS NULL\n GROUP BY u.id, u.email\n ORDER BY u.email ASC",
"describe": {
"columns": [
{
"name": "id!: String",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "email!: String",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "total_movies!: i64",
"ordinal": 2,
"type_info": "Integer"
},
{
"name": "avg_rating",
"ordinal": 3,
"type_info": "Float"
}
],
"parameters": {
"Right": 0
},
"nullable": [
true,
false,
false,
true
]
},
"hash": "2c7353f34c4748d4d4be6abbf343fa7ea30eeb985c4bfd12b0fc3997d1ba03bb"
}

View File

@@ -0,0 +1,50 @@
{
"db_name": "SQLite",
"query": "SELECT id, external_metadata_id, title, release_year, director, poster_path\n FROM movies WHERE title = ? AND release_year = ?",
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "external_metadata_id",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "title",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "release_year",
"ordinal": 3,
"type_info": "Integer"
},
{
"name": "director",
"ordinal": 4,
"type_info": "Text"
},
{
"name": "poster_path",
"ordinal": 5,
"type_info": "Text"
}
],
"parameters": {
"Right": 2
},
"nullable": [
false,
true,
false,
false,
true,
true
]
},
"hash": "3047579c6ed13ce87aad9b9ce6300c02f0df3516979518976e13f9d9abc6a403"
}

View File

@@ -0,0 +1,44 @@
{
"db_name": "SQLite",
"query": "SELECT id, email, username, password_hash, role FROM users WHERE username = ?",
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "email",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "username",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "password_hash",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "role",
"ordinal": 4,
"type_info": "Text"
}
],
"parameters": {
"Right": 1
},
"nullable": [
true,
false,
false,
false,
false
]
},
"hash": "319b5d09824809a971f6c9546dde6389a0aca5a732531bb9ca9b99675d0a89f4"
}

View File

@@ -0,0 +1,50 @@
{
"db_name": "SQLite",
"query": "SELECT id, external_metadata_id, title, release_year, director, poster_path\n FROM movies WHERE id = ?",
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "external_metadata_id",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "title",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "release_year",
"ordinal": 3,
"type_info": "Integer"
},
{
"name": "director",
"ordinal": 4,
"type_info": "Text"
},
{
"name": "poster_path",
"ordinal": 5,
"type_info": "Text"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
true,
false,
false,
true,
true
]
},
"hash": "33d0dae7d16b0635c1c7eb5afd10824bb55af7cc7a854f590d326622863759d1"
}

View File

@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "DELETE FROM import_sessions WHERE user_id = ? AND expires_at < datetime('now')",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "34a0a7b5e6a7f2b97b59e9de6a34b41a7128a465c0957915c7ed7031c6b83fb0"
}

View File

@@ -0,0 +1,20 @@
{
"db_name": "SQLite",
"query": "SELECT COUNT(*) FROM reviews WHERE movie_id = ?",
"describe": {
"columns": [
{
"name": "COUNT(*)",
"ordinal": 0,
"type_info": "Integer"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false
]
},
"hash": "4b3074b532342c6356ee0e8e4d8c4a830f016234bb690e1f6240f02824d6d84f"
}

View File

@@ -0,0 +1,20 @@
{
"db_name": "SQLite",
"query": "SELECT strftime('%Y-%m', watched_at) AS month\n FROM reviews\n WHERE user_id = ?\n GROUP BY month\n ORDER BY COUNT(*) DESC\n LIMIT 1",
"describe": {
"columns": [
{
"name": "month",
"ordinal": 0,
"type_info": "Text"
}
],
"parameters": {
"Right": 1
},
"nullable": [
true
]
},
"hash": "4d85f0ff9732576bba77dc84d3885a0002c2b600c34ba4d99f1e1c5e99f35e75"
}

View File

@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "INSERT OR REPLACE INTO import_profiles (id, user_id, name, field_mappings, created_at)\n VALUES (?, ?, ?, ?, ?)",
"describe": {
"columns": [],
"parameters": {
"Right": 5
},
"nullable": []
},
"hash": "6638569b8759b965f8919f4998a8665b15c14fc0b4f6018e548b76474254073e"
}

View File

@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "UPDATE import_sessions SET field_mappings = ?, row_results = ? WHERE id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 3
},
"nullable": []
},
"hash": "759b45ac8426d93d4668e208003d52957f2f52d8a021cc6f117cb6f241689a07"
}

View File

@@ -0,0 +1,44 @@
{
"db_name": "SQLite",
"query": "SELECT id, email, username, password_hash, role FROM users WHERE id = ?",
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "email",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "username",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "password_hash",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "role",
"ordinal": 4,
"type_info": "Text"
}
],
"parameters": {
"Right": 1
},
"nullable": [
true,
false,
false,
false,
false
]
},
"hash": "79af0324db4d0b8e4bd66e12816583598865630241deffbb8b4175fa7a755099"
}

View File

@@ -0,0 +1,50 @@
{
"db_name": "SQLite",
"query": "SELECT id, external_metadata_id, title, release_year, director, poster_path\n FROM movies WHERE external_metadata_id = ?",
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "external_metadata_id",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "title",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "release_year",
"ordinal": 3,
"type_info": "Integer"
},
{
"name": "director",
"ordinal": 4,
"type_info": "Text"
},
{
"name": "poster_path",
"ordinal": 5,
"type_info": "Text"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
true,
false,
false,
true,
true
]
},
"hash": "7bc4aebcb94547976d3d7e063e4e908fc22b977b3cbf063ee93ffe4648c42011"
}

View File

@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "INSERT INTO movies (id, external_metadata_id, title, release_year, director, poster_path)\n VALUES (?, ?, ?, ?, ?, ?)\n ON CONFLICT(id) DO UPDATE SET\n external_metadata_id = excluded.external_metadata_id,\n title = excluded.title,\n release_year = excluded.release_year,\n director = excluded.director,\n poster_path = excluded.poster_path",
"describe": {
"columns": [],
"parameters": {
"Right": 6
},
"nullable": []
},
"hash": "7d7e23355ee0e442f2aa27e898dcfa40bdc4b09391afe04325f076157d9d84aa"
}

View File

@@ -0,0 +1,44 @@
{
"db_name": "SQLite",
"query": "SELECT id, user_id, name, field_mappings, created_at FROM import_profiles WHERE id = ? AND user_id = ?",
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "user_id",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "name",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "field_mappings",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "created_at",
"ordinal": 4,
"type_info": "Text"
}
],
"parameters": {
"Right": 2
},
"nullable": [
false,
false,
false,
false,
false
]
},
"hash": "7fa9ce795286905f4b6c3d5a8975fabd813a2b39652dd7e18f03f5b10a4beaca"
}

View File

@@ -0,0 +1,62 @@
{
"db_name": "SQLite",
"query": "SELECT id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url\n FROM reviews WHERE movie_id = ? ORDER BY watched_at ASC",
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "movie_id",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "user_id",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "rating",
"ordinal": 3,
"type_info": "Integer"
},
{
"name": "comment",
"ordinal": 4,
"type_info": "Text"
},
{
"name": "watched_at",
"ordinal": 5,
"type_info": "Text"
},
{
"name": "created_at",
"ordinal": 6,
"type_info": "Text"
},
{
"name": "remote_actor_url",
"ordinal": 7,
"type_info": "Text"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
false,
false,
true,
false,
false,
true
]
},
"hash": "7ff439f22f880f999a72aad1359eb8fec11fe868b940faee5a351795caaa2357"
}

View File

@@ -0,0 +1,98 @@
{
"db_name": "SQLite",
"query": "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,\n r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url\n FROM reviews r\n INNER JOIN movies m ON m.id = r.movie_id\n WHERE r.movie_id = ?\n ORDER BY r.watched_at ASC\n LIMIT ? OFFSET ?",
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "external_metadata_id",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "title",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "release_year",
"ordinal": 3,
"type_info": "Integer"
},
{
"name": "director",
"ordinal": 4,
"type_info": "Text"
},
{
"name": "poster_path",
"ordinal": 5,
"type_info": "Text"
},
{
"name": "review_id",
"ordinal": 6,
"type_info": "Text"
},
{
"name": "movie_id",
"ordinal": 7,
"type_info": "Text"
},
{
"name": "user_id",
"ordinal": 8,
"type_info": "Text"
},
{
"name": "rating",
"ordinal": 9,
"type_info": "Integer"
},
{
"name": "comment",
"ordinal": 10,
"type_info": "Text"
},
{
"name": "watched_at",
"ordinal": 11,
"type_info": "Text"
},
{
"name": "created_at",
"ordinal": 12,
"type_info": "Text"
},
{
"name": "remote_actor_url",
"ordinal": 13,
"type_info": "Text"
}
],
"parameters": {
"Right": 3
},
"nullable": [
false,
true,
false,
false,
true,
true,
false,
false,
false,
false,
true,
false,
false,
true
]
},
"hash": "8a70f21c39d203867c06dc0bf74a54745b3331b84ce9a2178f7812f1ed7262cc"
}

View File

@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "DELETE FROM import_profiles WHERE id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "9024c2b533d0100f18d5a5550220a148d8dc29bbf0df715f895fa4248d83785f"
}

View File

@@ -0,0 +1,26 @@
{
"db_name": "SQLite",
"query": "SELECT COUNT(DISTINCT movie_id) AS \"total!: i64\",\n AVG(CAST(rating AS REAL)) AS avg_rating\n FROM reviews WHERE user_id = ?",
"describe": {
"columns": [
{
"name": "total!: i64",
"ordinal": 0,
"type_info": "Integer"
},
{
"name": "avg_rating",
"ordinal": 1,
"type_info": "Float"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
true
]
},
"hash": "a01336632a54099e31686a9cbe6fc53fef1299fc7c7b52be44f99c2302490a22"
}

View File

@@ -0,0 +1,98 @@
{
"db_name": "SQLite",
"query": "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,\n r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url\n FROM reviews r\n INNER JOIN movies m ON m.id = r.movie_id\n ORDER BY r.watched_at DESC\n LIMIT ? OFFSET ?",
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "external_metadata_id",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "title",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "release_year",
"ordinal": 3,
"type_info": "Integer"
},
{
"name": "director",
"ordinal": 4,
"type_info": "Text"
},
{
"name": "poster_path",
"ordinal": 5,
"type_info": "Text"
},
{
"name": "review_id",
"ordinal": 6,
"type_info": "Text"
},
{
"name": "movie_id",
"ordinal": 7,
"type_info": "Text"
},
{
"name": "user_id",
"ordinal": 8,
"type_info": "Text"
},
{
"name": "rating",
"ordinal": 9,
"type_info": "Integer"
},
{
"name": "comment",
"ordinal": 10,
"type_info": "Text"
},
{
"name": "watched_at",
"ordinal": 11,
"type_info": "Text"
},
{
"name": "created_at",
"ordinal": 12,
"type_info": "Text"
},
{
"name": "remote_actor_url",
"ordinal": 13,
"type_info": "Text"
}
],
"parameters": {
"Right": 2
},
"nullable": [
false,
true,
false,
false,
true,
true,
false,
false,
false,
false,
true,
false,
false,
true
]
},
"hash": "a7c424c26663e4e51b1c563fa977f28e1d55234a242a7ddba50db13cf73b488d"
}

View File

@@ -0,0 +1,26 @@
{
"db_name": "SQLite",
"query": "SELECT m.director AS \"director!\",\n COUNT(*) AS \"count!: i64\"\n FROM reviews r\n INNER JOIN movies m ON m.id = r.movie_id\n WHERE r.user_id = ? AND m.director IS NOT NULL\n GROUP BY m.director\n ORDER BY COUNT(*) DESC\n LIMIT 5",
"describe": {
"columns": [
{
"name": "director!",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "count!: i64",
"ordinal": 1,
"type_info": "Integer"
}
],
"parameters": {
"Right": 1
},
"nullable": [
true,
false
]
},
"hash": "aca9e7aaa32c23b4de3f5048d60340e978d31a36be9121da3c59378f2fc1ed8e"
}

View File

@@ -0,0 +1,62 @@
{
"db_name": "SQLite",
"query": "SELECT id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url\n FROM reviews WHERE id = ?",
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "movie_id",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "user_id",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "rating",
"ordinal": 3,
"type_info": "Integer"
},
{
"name": "comment",
"ordinal": 4,
"type_info": "Text"
},
{
"name": "watched_at",
"ordinal": 5,
"type_info": "Text"
},
{
"name": "created_at",
"ordinal": 6,
"type_info": "Text"
},
{
"name": "remote_actor_url",
"ordinal": 7,
"type_info": "Text"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
false,
false,
true,
false,
false,
true
]
},
"hash": "ae983138ad90fda3794b784fbf62c31fddcf182850782e9bfbc4ff3ee8b7d4bb"
}

View File

@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "DELETE FROM import_sessions WHERE id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "bf4aaf630ebefde88d87a95c257bbeafaf39e2f53611dab435d485648eb7e598"
}

View File

@@ -0,0 +1,44 @@
{
"db_name": "SQLite",
"query": "SELECT id, email, username, password_hash, role FROM users WHERE email = ?",
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "email",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "username",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "password_hash",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "role",
"ordinal": 4,
"type_info": "Text"
}
],
"parameters": {
"Right": 1
},
"nullable": [
true,
false,
false,
false,
false
]
},
"hash": "c43249558d535343e387586fa00499fe813b18b9e7d8d241462f0689969dc64f"
}

View File

@@ -0,0 +1,56 @@
{
"db_name": "SQLite",
"query": "SELECT id, user_id, parsed_data, field_mappings, row_results, created_at, expires_at\n FROM import_sessions WHERE id = ? AND user_id = ?",
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "user_id",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "parsed_data",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "field_mappings",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "row_results",
"ordinal": 4,
"type_info": "Text"
},
{
"name": "created_at",
"ordinal": 5,
"type_info": "Text"
},
{
"name": "expires_at",
"ordinal": 6,
"type_info": "Text"
}
],
"parameters": {
"Right": 2
},
"nullable": [
false,
false,
false,
true,
true,
false,
false
]
},
"hash": "ca7c29f4c80cee4877625a259edef662b169377c5bafdd03583645a39368c910"
}

View File

@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "INSERT INTO reviews (id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url)\n VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
"describe": {
"columns": [],
"parameters": {
"Right": 8
},
"nullable": []
},
"hash": "cca022ac6275f2b1aaf63a14420897074c8ff4cdd1d3e9a13ef4b9dd5346d12a"
}

View File

@@ -0,0 +1,20 @@
{
"db_name": "SQLite",
"query": "SELECT m.director\n FROM reviews r\n INNER JOIN movies m ON m.id = r.movie_id\n WHERE r.user_id = ? AND m.director IS NOT NULL\n GROUP BY m.director\n ORDER BY COUNT(*) DESC\n LIMIT 1",
"describe": {
"columns": [
{
"name": "director",
"ordinal": 0,
"type_info": "Text"
}
],
"parameters": {
"Right": 1
},
"nullable": [
true
]
},
"hash": "d5d2a81306488a8cee5654cea7e14d76d76ecc7d2190ffb73d12bec2874111d2"
}

View File

@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "INSERT INTO import_sessions (id, user_id, parsed_data, field_mappings, row_results, created_at, expires_at)\n VALUES (?, ?, ?, ?, ?, ?, ?)",
"describe": {
"columns": [],
"parameters": {
"Right": 7
},
"nullable": []
},
"hash": "e3a3b56d63df433339bd8183f20ef33ded742ef03df885686cd5198f9f8e1c01"
}

View File

@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "DELETE FROM movies WHERE id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "e431381ad41c1c2f7b9c89509d5e3f4c19cb52dcfff66772145cd80c53c16883"
}

View File

@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "DELETE FROM reviews WHERE id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "f84e5483ca4210aec67b38cc1a9de4a42c12891025236abc48ea4f175292a6cc"
}

View File

@@ -0,0 +1,44 @@
{
"db_name": "SQLite",
"query": "SELECT id, user_id, name, field_mappings, created_at FROM import_profiles WHERE user_id = ? ORDER BY created_at DESC",
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "user_id",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "name",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "field_mappings",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "created_at",
"ordinal": 4,
"type_info": "Text"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
false,
false,
false
]
},
"hash": "f8559923b7a885fb0d4938479f11bd97708b401a6a2290abe6df49fcb7f28a8d"
}

View File

@@ -0,0 +1,32 @@
{
"db_name": "SQLite",
"query": "SELECT strftime('%Y-%m', watched_at) AS \"month!\",\n AVG(CAST(rating AS REAL)) AS \"avg_rating!: f64\",\n COUNT(*) AS \"count!: i64\"\n FROM reviews\n WHERE user_id = ? AND watched_at >= datetime('now', '-12 months')\n GROUP BY \"month!\"\n ORDER BY \"month!\" ASC",
"describe": {
"columns": [
{
"name": "month!",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "avg_rating!: f64",
"ordinal": 1,
"type_info": "Float"
},
{
"name": "count!: i64",
"ordinal": 2,
"type_info": "Integer"
}
],
"parameters": {
"Right": 1
},
"nullable": [
true,
false,
false
]
},
"hash": "fdd5b522f26b5e0ce62f76c774fbb606fd9ee9884f4457831f693a0df3609317"
}

4497
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,18 +1,38 @@
[workspace]
members = [
"crates/adapters/auth",
"crates/adapters/event-publisher",
"crates/adapters/metadata",
"crates/adapters/poster-fetcher",
"crates/adapters/image-storage",
"crates/adapters/poster-sync",
"crates/adapters/rss",
"crates/adapters/sqlite",
"crates/adapters/postgres",
"crates/adapters/sqlite-federation",
"crates/adapters/postgres-federation",
"crates/adapters/sqlite-event-queue",
"crates/adapters/postgres-event-queue",
"crates/adapters/template-askama",
"crates/adapters/activitypub",
"crates/adapters/activitypub-base",
"crates/adapters/export",
"crates/adapters/event-payload",
"crates/adapters/nats",
"crates/application",
"crates/common",
"crates/domain",
"crates/presentation",
"crates/tui",
"crates/doc",
"crates/worker",
"crates/adapters/importer",
]
resolver = "2"
[workspace.dependencies]
tokio = { version = "1.0", features = ["full"] }
futures = "0.3"
dotenvy = "0.15"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
anyhow = "1.0"
@@ -20,14 +40,40 @@ thiserror = "2.0"
tracing = "0.1"
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
async-trait = "0.1"
uuid = { version = "1.23.0", features = ["v4", "serde"] }
uuid = { version = "1.23.0", features = ["v4", "v5", "serde"] }
chrono = { version = "0.4", features = ["serde"] }
sqlx = { version = "0.8.6", features = [
"runtime-tokio-rustls",
"sqlite",
"uuid",
"macros",
] }
reqwest = { version = "0.13", features = ["json", "query"] }
object_store = { version = "0.11", features = ["aws"] }
axum = { version = "0.8.8", features = ["macros", "multipart"] }
csv = "1"
domain = { path = "crates/domain" }
common = { path = "crates/common" }
application = { path = "crates/application" }
presentation = { path = "crates/presentation" }
auth = { path = "crates/adapters/auth" }
metadata = { path = "crates/adapters/metadata" }
poster-fetcher = { path = "crates/adapters/poster-fetcher" }
image-storage = { path = "crates/adapters/image-storage" }
poster-sync = { path = "crates/adapters/poster-sync" }
event-publisher = { path = "crates/adapters/event-publisher" }
rss = { path = "crates/adapters/rss" }
export = { path = "crates/adapters/export" }
sqlite = { path = "crates/adapters/sqlite" }
sqlite-federation = { path = "crates/adapters/sqlite-federation" }
postgres = { path = "crates/adapters/postgres" }
postgres-federation = { path = "crates/adapters/postgres-federation" }
template-askama = { path = "crates/adapters/template-askama" }
activitypub = { path = "crates/adapters/activitypub" }
activitypub-base = { path = "crates/adapters/activitypub-base" }
doc = { path = "crates/doc" }
event-payload = { path = "crates/adapters/event-payload" }
nats = { path = "crates/adapters/nats" }
sqlite-event-queue = { path = "crates/adapters/sqlite-event-queue" }
postgres-event-queue = { path = "crates/adapters/postgres-event-queue" }
importer = { path = "crates/adapters/importer" }

74
Dockerfile Normal file
View File

@@ -0,0 +1,74 @@
# ----- build -----
FROM rust:slim-bookworm AS builder
WORKDIR /build
# Cache dependency compilation separately from source
COPY Cargo.toml Cargo.lock ./
COPY .cargo ./.cargo
COPY .sqlx ./.sqlx
COPY crates/adapters/activitypub/Cargo.toml crates/adapters/activitypub/Cargo.toml
COPY crates/adapters/activitypub-base/Cargo.toml crates/adapters/activitypub-base/Cargo.toml
COPY crates/adapters/auth/Cargo.toml crates/adapters/auth/Cargo.toml
COPY crates/adapters/event-payload/Cargo.toml crates/adapters/event-payload/Cargo.toml
COPY crates/adapters/event-publisher/Cargo.toml crates/adapters/event-publisher/Cargo.toml
COPY crates/adapters/nats/Cargo.toml crates/adapters/nats/Cargo.toml
COPY crates/adapters/metadata/Cargo.toml crates/adapters/metadata/Cargo.toml
COPY crates/adapters/poster-fetcher/Cargo.toml crates/adapters/poster-fetcher/Cargo.toml
COPY crates/adapters/image-storage/Cargo.toml crates/adapters/image-storage/Cargo.toml
COPY crates/adapters/poster-sync/Cargo.toml crates/adapters/poster-sync/Cargo.toml
COPY crates/adapters/export/Cargo.toml crates/adapters/export/Cargo.toml
COPY crates/adapters/importer/Cargo.toml crates/adapters/importer/Cargo.toml
COPY crates/adapters/rss/Cargo.toml crates/adapters/rss/Cargo.toml
COPY crates/adapters/sqlite/Cargo.toml crates/adapters/sqlite/Cargo.toml
COPY crates/adapters/sqlite-federation/Cargo.toml crates/adapters/sqlite-federation/Cargo.toml
COPY crates/adapters/sqlite-event-queue/Cargo.toml crates/adapters/sqlite-event-queue/Cargo.toml
COPY crates/adapters/postgres/Cargo.toml crates/adapters/postgres/Cargo.toml
COPY crates/adapters/postgres-federation/Cargo.toml crates/adapters/postgres-federation/Cargo.toml
COPY crates/adapters/postgres-event-queue/Cargo.toml crates/adapters/postgres-event-queue/Cargo.toml
COPY crates/adapters/template-askama/Cargo.toml crates/adapters/template-askama/Cargo.toml
COPY crates/application/Cargo.toml crates/application/Cargo.toml
COPY crates/domain/Cargo.toml crates/domain/Cargo.toml
COPY crates/presentation/Cargo.toml crates/presentation/Cargo.toml
COPY crates/doc/Cargo.toml crates/doc/Cargo.toml
COPY crates/tui/Cargo.toml crates/tui/Cargo.toml
COPY crates/worker/Cargo.toml crates/worker/Cargo.toml
# Stub every crate so cargo can resolve and fetch deps
RUN find crates -name "Cargo.toml" | sed 's|/Cargo.toml||' | \
xargs -I{} sh -c 'mkdir -p {}/src && echo "fn main(){}" > {}/src/main.rs && echo "" > {}/src/lib.rs'
RUN cargo fetch
# Now copy real sources (invalidates cache only on source changes)
COPY crates ./crates
# .cargo/config.toml sets SQLX_OFFLINE=true; .sqlx contains the pre-verified query cache.
# No live database needed at compile time.
#
# To build with PostgreSQL backend instead:
# --build-arg FEATURES=postgres,postgres-federation
# To add NATS support (EVENT_BUS_BACKEND=nats):
# --build-arg FEATURES=sqlite,sqlite-federation,nats
ARG FEATURES=sqlite,sqlite-federation
RUN cargo build --release -p presentation -p worker --no-default-features --features "${FEATURES}"
# ----- runtime -----
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
wget \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=builder /build/target/release/presentation ./presentation
COPY --from=builder /build/target/release/worker ./worker
COPY static ./static
EXPOSE 3000
ENV RUST_LOG=presentation=info,tower_http=info
CMD ["./presentation"]

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Gabriel Kaszewski
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

182
README.md Normal file
View File

@@ -0,0 +1,182 @@
# Movies Diary
A self-hosted, server-side rendered movie logging system with a full REST API. Built in Rust — no JavaScript in the HTML interface, just HTML forms and an RSS feed. Designed to run as a lightweight widget embedded on a personal site or as a backend for third-party clients.
## Features
- Log movies with a TMDB/OMDb ID or manual title/year/director, with a 05 rating
- Immutable append-only viewing ledger (tracks re-watches)
- Background poster fetching and storage (local filesystem or S3-compatible)
- RSS/Atom feed for public subscription (global and per-user)
- JWT authentication via cookie (HTML) or Bearer token (REST API)
- ActivityPub federation — follow/unfollow remote users, accept/reject/remove followers, federated reviews broadcast as `Note` objects with `#MoviesDiary` + `#MovieTitle` hashtags, paginated outbox, boost/Announce tracking, NodeInfo discovery endpoint, shared inbox delivery, actor profile sync (bio, avatar, discoverable)
- Federation moderation — instance-level domain blocking (admin-managed), per-user actor blocking with `Block` activity, delivery filter excludes blocked actors and blocked-domain inboxes
- CSV and JSON diary export
- File importer: upload CSV, TSV, JSON, or XLSX from any source (Letterboxd, IMDb, etc.), map columns to domain fields via a step-by-step wizard or REST API, save mapping profiles for repeat imports
- REST API v1 (`/api/v1/`) with full feature parity with the HTML interface
- OpenAPI documentation at `/docs` (Swagger UI) and `/scalar` (Scalar)
- CSRF protection on all HTML form routes (double-submit cookie, defense-in-depth on top of `SameSite=Strict`)
- Per-IP rate limiting via token bucket (production-grade, backed by `axum-governor`)
- Terminal UI client (`crates/tui`) for logging reviews, bulk CSV import, and diary browsing
## Architecture
Hexagonal (Ports & Adapters) with Domain-Driven Design:
```
domain — pure types and trait definitions, no external deps
application — use cases / business logic orchestration
presentation — Axum HTTP router, composition root for the HTTP process
worker — standalone worker binary (event consumer, poster sync, federation)
adapters/
auth — JWT issuance and validation (Argon2 passwords)
sqlite — SQLite repository + connection factory
postgres — PostgreSQL repository + connection factory
metadata — TMDB / OMDb HTTP client
poster-fetcher — downloads poster images
image-storage — stores images (posters + user avatars) on local filesystem or S3-compatible storage
poster-sync — event handler: triggers poster fetch+store on MovieDiscovered
template-askama — Askama HTML rendering
rss — RSS/Atom feed generation
export — CSV and JSON diary serialization
importer — CSV/TSV/JSON/XLSX parser and column mapper for bulk import
event-payload — shared event serialization DTOs (used by all event bus adapters)
sqlite-event-queue — durable polling event queue backed by SQLite
postgres-event-queue — durable polling event queue backed by PostgreSQL
nats — NATS Core / JetStream event publisher and consumer
event-publisher — in-memory event channel (tests only)
activitypub — ActivityPub federation wiring (follow, inbox/outbox, actor)
activitypub-base — core ActivityPub protocol types and service
sqlite-federation — SQLite-backed federation repository
postgres-federation — PostgreSQL-backed federation repository
doc — OpenAPI spec assembly and Swagger UI / Scalar serving
tui — terminal UI client (ratatui)
```
## Prerequisites
- Rust (stable, 2024 edition)
- SQLite
- Poster storage: local filesystem (zero deps) or an S3-compatible object store (e.g. MinIO)
- An [OMDb API key](https://www.omdbapi.com/apikey.aspx)
## Environment Variables
A `.env.example` file is provided at the repo root — copy it to `.env` and fill in your values.
```env
# Database
DATABASE_URL=sqlite://movies.db
# Authentication
JWT_SECRET=change-me
# OMDb metadata
OMDB_API_KEY=your-key
# Public base URL (used for ActivityPub actor URLs and canonical links)
BASE_URL=https://yourdomain.example.com
# Image storage — pick one backend:
# Option A: local filesystem (zero deps)
IMAGE_STORAGE_BACKEND=local
IMAGE_STORAGE_PATH=./images
# Option B: S3-compatible (MinIO, AWS S3, etc.)
# IMAGE_STORAGE_BACKEND=s3
# MINIO_ENDPOINT=http://localhost:9000
# MINIO_BUCKET=posters
# MINIO_REGION=minio
# MINIO_ACCESS_KEY_ID=minioadmin
# MINIO_SECRET_ACCESS_KEY=minioadmin
# Optional
HOST=0.0.0.0
PORT=3000
RATE_LIMIT=60 # requests per minute per IP (default: 60)
ALLOW_REGISTRATION=true # set to false to disable new sign-ups
SECURE_COOKIES=true # set when serving over HTTPS
RUST_LOG=presentation=info,tower_http=info,worker=info,application=info
# Event bus — "db" (default, uses same database) or "nats"
EVENT_BUS_BACKEND=db
# NATS_URL=nats://localhost:4222 # required when EVENT_BUS_BACKEND=nats
```
The `worker` binary must run alongside `presentation` to process events:
```bash
cargo run -p worker
```
## Run
```bash
cargo run -p presentation # HTTP server (0.0.0.0:3000)
cargo run -p worker # event worker (poster sync, in a separate terminal)
```
The worker polls the event queue and must run alongside the presentation to process background tasks like poster fetching. Both processes share the same database.
## API
All REST endpoints are under `/api/v1/`. Authentication uses `Authorization: Bearer <token>` obtained from `POST /api/v1/auth/login`.
Interactive API documentation is available at runtime:
- **Swagger UI** — `http://localhost:3000/docs`
- **Scalar** — `http://localhost:3000/scalar`
## Terminal UI
```bash
cargo run -p tui
```
Supports review logging, bulk CSV import (column order matches the export format), and diary browsing with review history.
## Test
```bash
cargo test
```
## Docker
The image contains both `presentation` (HTTP server) and `worker` (event processor). Run them as separate containers sharing the same data volume:
```bash
# Build (SQLite + federation + NATS support)
docker build -t movies-diary \
--build-arg FEATURES=sqlite,sqlite-federation,nats .
# HTTP server
docker run -p 3000:3000 \
-e DATABASE_URL=sqlite:///data/movies.db \
-e JWT_SECRET=change-me \
-e OMDB_API_KEY=your-key \
-e BASE_URL=https://yourdomain.example.com \
-e EVENT_BUS_BACKEND=nats \
-e NATS_URL=nats://nats:4222 \
-v $(pwd)/data:/data \
movies-diary
# Event worker (separate container, same image)
docker run \
-e DATABASE_URL=sqlite:///data/movies.db \
-e JWT_SECRET=change-me \
-e OMDB_API_KEY=your-key \
-e BASE_URL=https://yourdomain.example.com \
-e EVENT_BUS_BACKEND=nats \
-e NATS_URL=nats://nats:4222 \
-v $(pwd)/data:/data \
--entrypoint ./worker \
movies-diary
```
To build for PostgreSQL: `--build-arg FEATURES=postgres,postgres-federation,nats`
## License
MIT License. See [LICENSE](LICENSE).

View File

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

View File

@@ -0,0 +1,508 @@
use activitypub_federation::{
config::Data,
fetch::object_id::ObjectId,
kinds::activity::{
AcceptType, CreateType, DeleteType, FollowType, RejectType, UndoType, UpdateType,
},
traits::Activity,
};
use serde::{Deserialize, Serialize};
use url::Url;
#[derive(Clone, Default, Debug, Serialize, Deserialize)]
#[serde(rename = "Announce")]
pub struct AnnounceType;
use crate::actors::DbActor;
use crate::data::FederationData;
use crate::error::Error;
use crate::repository::{FollowerStatus, FollowingStatus};
// --- Follow ---
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct FollowActivity {
pub(crate) id: Url,
#[serde(rename = "type", default)]
pub(crate) kind: FollowType,
pub(crate) actor: ObjectId<DbActor>,
pub(crate) object: ObjectId<DbActor>,
}
#[async_trait::async_trait]
impl Activity for FollowActivity {
type DataType = FederationData;
type Error = Error;
fn id(&self) -> &Url {
&self.id
}
fn actor(&self) -> &Url {
self.actor.inner()
}
async fn verify(&self, data: &Data<Self::DataType>) -> Result<(), Self::Error> {
let target_url = self.object.inner();
let target_domain = match (target_url.host_str(), target_url.port()) {
(Some(host), Some(port)) => format!("{}:{}", host, port),
(Some(host), None) => host.to_string(),
_ => {
return Err(Error::bad_request(anyhow::anyhow!(
"invalid follow target URL"
)));
}
};
if target_domain != data.domain {
return Err(Error::bad_request(anyhow::anyhow!(
"follow target is not a local actor"
)));
}
Ok(())
}
async fn receive(self, data: &Data<Self::DataType>) -> Result<(), Self::Error> {
let domain = self.actor().host_str().unwrap_or("");
if data.federation_repo.is_domain_blocked(domain).await? {
tracing::info!(actor = %self.actor(), "ignoring activity from blocked domain");
return Ok(());
}
let _follower = self.actor.dereference(data).await?;
let local_actor = self.object.dereference(data).await?;
if data.federation_repo
.is_actor_blocked(local_actor.user_id, self.actor.inner().as_str())
.await?
{
tracing::info!(actor = %self.actor.inner(), "ignoring follow from blocked actor");
return Ok(());
}
data.federation_repo
.add_follower(
local_actor.user_id,
self.actor.inner().as_str(),
FollowerStatus::Pending,
self.id.as_str(),
)
.await?;
tracing::info!(
follower = %self.actor.inner(),
local_user = %local_actor.user_id,
"follow request pending approval"
);
Ok(())
}
}
// --- Accept ---
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AcceptActivity {
pub(crate) id: Url,
#[serde(rename = "type", default)]
pub(crate) kind: AcceptType,
pub(crate) actor: ObjectId<DbActor>,
pub(crate) object: FollowActivity,
}
#[async_trait::async_trait]
impl Activity for AcceptActivity {
type DataType = FederationData;
type Error = Error;
fn id(&self) -> &Url {
&self.id
}
fn actor(&self) -> &Url {
self.actor.inner()
}
async fn verify(&self, _data: &Data<Self::DataType>) -> Result<(), Self::Error> {
Ok(())
}
async fn receive(self, data: &Data<Self::DataType>) -> Result<(), Self::Error> {
let domain = self.actor().host_str().unwrap_or("");
if data.federation_repo.is_domain_blocked(domain).await? {
tracing::info!(actor = %self.actor(), "ignoring activity from blocked domain");
return Ok(());
}
let local_user_id = crate::urls::extract_user_id_from_url(self.object.actor.inner())
.ok_or_else(|| Error::bad_request(anyhow::anyhow!("invalid actor URL in Follow")))?;
data.federation_repo
.update_following_status(
local_user_id,
self.actor.inner().as_str(),
FollowingStatus::Accepted,
)
.await?;
tracing::info!(remote_actor = %self.actor.inner(), "follow accepted by remote");
Ok(())
}
}
// --- Reject ---
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct RejectActivity {
pub(crate) id: Url,
#[serde(rename = "type", default)]
pub(crate) kind: RejectType,
pub(crate) actor: ObjectId<DbActor>,
pub(crate) object: FollowActivity,
}
#[async_trait::async_trait]
impl Activity for RejectActivity {
type DataType = FederationData;
type Error = Error;
fn id(&self) -> &Url {
&self.id
}
fn actor(&self) -> &Url {
self.actor.inner()
}
async fn verify(&self, _data: &Data<Self::DataType>) -> Result<(), Self::Error> {
Ok(())
}
async fn receive(self, data: &Data<Self::DataType>) -> Result<(), Self::Error> {
let domain = self.actor().host_str().unwrap_or("");
if data.federation_repo.is_domain_blocked(domain).await? {
tracing::info!(actor = %self.actor(), "ignoring activity from blocked domain");
return Ok(());
}
if let Some(user_id) = crate::urls::extract_user_id_from_url(self.object.actor.inner()) {
data.federation_repo
.remove_following(user_id, self.actor.inner().as_str())
.await?;
}
tracing::info!(actor = %self.actor.inner(), "follow rejected");
Ok(())
}
}
// --- Undo ---
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct UndoActivity {
pub(crate) id: Url,
#[serde(rename = "type", default)]
pub(crate) kind: UndoType,
pub(crate) actor: ObjectId<DbActor>,
pub(crate) object: FollowActivity,
}
#[async_trait::async_trait]
impl Activity for UndoActivity {
type DataType = FederationData;
type Error = Error;
fn id(&self) -> &Url {
&self.id
}
fn actor(&self) -> &Url {
self.actor.inner()
}
async fn verify(&self, _data: &Data<Self::DataType>) -> Result<(), Self::Error> {
Ok(())
}
async fn receive(self, data: &Data<Self::DataType>) -> Result<(), Self::Error> {
let domain = self.actor().host_str().unwrap_or("");
if data.federation_repo.is_domain_blocked(domain).await? {
tracing::info!(actor = %self.actor(), "ignoring activity from blocked domain");
return Ok(());
}
if let Some(user_id) = crate::urls::extract_user_id_from_url(self.object.object.inner()) {
data.federation_repo
.remove_follower(user_id, self.actor.inner().as_str())
.await?;
}
data.object_handler
.on_actor_removed(self.actor.inner())
.await
.map_err(|e| Error::from(anyhow::anyhow!(e)))?;
tracing::info!(actor = %self.actor.inner(), "unfollowed");
Ok(())
}
}
// --- Create ---
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CreateActivity {
pub(crate) id: Url,
#[serde(rename = "type", default)]
pub(crate) kind: CreateType,
pub(crate) actor: ObjectId<DbActor>,
pub(crate) object: serde_json::Value,
}
#[async_trait::async_trait]
impl Activity for CreateActivity {
type DataType = FederationData;
type Error = Error;
fn id(&self) -> &Url {
&self.id
}
fn actor(&self) -> &Url {
self.actor.inner()
}
async fn verify(&self, _data: &Data<Self::DataType>) -> Result<(), Self::Error> {
Ok(())
}
async fn receive(self, data: &Data<Self::DataType>) -> Result<(), Self::Error> {
let domain = self.actor().host_str().unwrap_or("");
if data.federation_repo.is_domain_blocked(domain).await? {
tracing::info!(actor = %self.actor(), "ignoring activity from blocked domain");
return Ok(());
}
let ap_id = self.id.clone();
let actor_url = self.actor.inner().clone();
data.object_handler
.on_create(&ap_id, &actor_url, self.object)
.await
.map_err(|e| Error::from(anyhow::anyhow!(e)))?;
tracing::info!(actor = %actor_url, "received create activity");
Ok(())
}
}
// --- Delete ---
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DeleteActivity {
pub(crate) id: Url,
#[serde(rename = "type", default)]
pub(crate) kind: DeleteType,
pub(crate) actor: ObjectId<DbActor>,
pub(crate) object: Url,
}
#[async_trait::async_trait]
impl Activity for DeleteActivity {
type DataType = FederationData;
type Error = Error;
fn id(&self) -> &Url {
&self.id
}
fn actor(&self) -> &Url {
self.actor.inner()
}
async fn verify(&self, _data: &Data<Self::DataType>) -> Result<(), Self::Error> {
Ok(())
}
async fn receive(self, data: &Data<Self::DataType>) -> Result<(), Self::Error> {
let domain = self.actor().host_str().unwrap_or("");
if data.federation_repo.is_domain_blocked(domain).await? {
tracing::info!(actor = %self.actor(), "ignoring activity from blocked domain");
return Ok(());
}
let actor_url = self.actor.inner().clone();
data.object_handler
.on_delete(&self.object, &actor_url)
.await
.map_err(|e| Error::from(anyhow::anyhow!(e)))?;
tracing::info!(object = %self.object, "received delete activity");
Ok(())
}
}
// --- Update ---
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct UpdateActivity {
pub(crate) id: Url,
#[serde(rename = "type", default)]
pub(crate) kind: UpdateType,
pub(crate) actor: ObjectId<DbActor>,
pub(crate) object: serde_json::Value,
}
#[async_trait::async_trait]
impl Activity for UpdateActivity {
type DataType = FederationData;
type Error = Error;
fn id(&self) -> &Url {
&self.id
}
fn actor(&self) -> &Url {
self.actor.inner()
}
async fn verify(&self, _data: &Data<Self::DataType>) -> Result<(), Self::Error> {
Ok(())
}
async fn receive(self, data: &Data<Self::DataType>) -> Result<(), Self::Error> {
let domain = self.actor().host_str().unwrap_or("");
if data.federation_repo.is_domain_blocked(domain).await? {
tracing::info!(actor = %self.actor(), "ignoring activity from blocked domain");
return Ok(());
}
let ap_id = self.id.clone();
let actor_url = self.actor.inner().clone();
data.object_handler
.on_update(&ap_id, &actor_url, self.object)
.await
.map_err(|e| Error::from(anyhow::anyhow!(e)))?;
tracing::info!(actor = %actor_url, "received update activity");
Ok(())
}
}
// --- Announce ---
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AnnounceActivity {
pub(crate) id: Url,
#[serde(rename = "type", default)]
pub(crate) kind: AnnounceType,
pub(crate) actor: ObjectId<DbActor>,
pub(crate) object: Url,
pub(crate) published: Option<chrono::DateTime<chrono::Utc>>,
}
#[async_trait::async_trait]
impl Activity for AnnounceActivity {
type DataType = FederationData;
type Error = Error;
fn id(&self) -> &Url {
&self.id
}
fn actor(&self) -> &Url {
self.actor.inner()
}
async fn verify(&self, _data: &Data<Self::DataType>) -> Result<(), Self::Error> {
Ok(())
}
async fn receive(self, data: &Data<Self::DataType>) -> Result<(), Self::Error> {
let domain = self.actor().host_str().unwrap_or("");
if data.federation_repo.is_domain_blocked(domain).await? {
tracing::info!(actor = %self.actor(), "ignoring activity from blocked domain");
return Ok(());
}
let object_domain = self.object.host_str().unwrap_or("");
if object_domain != data.domain {
return Ok(());
}
data.federation_repo
.add_announce(
self.id.as_str(),
self.object.as_str(),
self.actor.inner().as_str(),
self.published.unwrap_or_else(chrono::Utc::now),
)
.await?;
tracing::info!(actor = %self.actor.inner(), object = %self.object, "received announce");
Ok(())
}
}
// --- Block ---
#[derive(Clone, Default, Debug, Serialize, Deserialize)]
#[serde(rename = "Block")]
pub struct BlockType;
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct BlockActivity {
pub(crate) id: Url,
#[serde(rename = "type", default)]
pub(crate) kind: BlockType,
pub(crate) actor: ObjectId<DbActor>,
pub(crate) object: Url,
}
#[async_trait::async_trait]
impl Activity for BlockActivity {
type DataType = FederationData;
type Error = Error;
fn id(&self) -> &Url {
&self.id
}
fn actor(&self) -> &Url {
self.actor.inner()
}
async fn verify(&self, _data: &Data<Self::DataType>) -> Result<(), Self::Error> {
Ok(())
}
async fn receive(self, data: &Data<Self::DataType>) -> Result<(), Self::Error> {
let domain = self.actor().host_str().unwrap_or("");
if data.federation_repo.is_domain_blocked(domain).await? {
tracing::info!(actor = %self.actor(), "ignoring activity from blocked domain");
return Ok(());
}
// They blocked us — remove them from our following list
if let Some(local_user_id) = crate::urls::extract_user_id_from_url(&self.object) {
let _ = data
.federation_repo
.remove_following(local_user_id, self.actor.inner().as_str())
.await;
}
tracing::info!(actor = %self.actor.inner(), "received block");
Ok(())
}
}
// --- Inbox dispatch enum ---
#[derive(Debug, Deserialize, Serialize)]
#[serde(tag = "type")]
#[enum_delegate::implement(Activity)]
pub enum InboxActivities {
#[serde(rename = "Follow")]
Follow(FollowActivity),
#[serde(rename = "Accept")]
Accept(AcceptActivity),
#[serde(rename = "Reject")]
Reject(RejectActivity),
#[serde(rename = "Undo")]
Undo(UndoActivity),
#[serde(rename = "Create")]
Create(CreateActivity),
#[serde(rename = "Delete")]
Delete(DeleteActivity),
#[serde(rename = "Update")]
Update(UpdateActivity),
#[serde(rename = "Announce")]
Announce(AnnounceActivity),
#[serde(rename = "Block")]
Block(BlockActivity),
}

View File

@@ -0,0 +1,21 @@
use activitypub_federation::{
axum::json::FederationJson, config::Data, protocol::context::WithContext, traits::Object,
};
use axum::extract::Path;
use crate::actors::{Person, get_local_actor};
use crate::data::FederationData;
use crate::error::Error;
pub async fn actor_handler(
Path(user_id_str): Path<String>,
data: Data<FederationData>,
) -> Result<FederationJson<WithContext<Person>>, Error> {
let uuid = uuid::Uuid::parse_str(&user_id_str)
.map_err(|_| Error::bad_request(anyhow::anyhow!("invalid user id")))?;
let db_actor = get_local_actor(uuid, &data).await?;
let person = db_actor.into_json(&data).await?;
Ok(FederationJson(WithContext::new_default(person)))
}

View File

@@ -0,0 +1,300 @@
use activitypub_federation::{
config::Data,
fetch::object_id::ObjectId,
http_signatures::generate_actor_keypair,
kinds::actor::PersonType,
protocol::{public_key::PublicKey, verification::verify_domains_match},
traits::{Actor, Object},
};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use url::Url;
use crate::data::FederationData;
use crate::error::Error;
use crate::repository::RemoteActor;
#[derive(Debug, Clone)]
pub struct DbActor {
pub user_id: uuid::Uuid,
pub username: String,
pub public_key_pem: String,
pub private_key_pem: Option<String>,
pub inbox_url: Url,
pub outbox_url: Url,
pub followers_url: Url,
pub following_url: Url,
pub ap_id: Url,
pub last_refreshed_at: DateTime<Utc>,
pub bio: Option<String>,
pub avatar_url: Option<Url>,
pub profile_url: Option<Url>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ApImageObject {
#[serde(rename = "type")]
pub kind: String,
pub url: Url,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Person {
#[serde(rename = "type")]
kind: PersonType,
id: ObjectId<DbActor>,
preferred_username: String,
inbox: Url,
outbox: Url,
followers: Url,
following: Url,
public_key: PublicKey,
name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
summary: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
icon: Option<ApImageObject>,
#[serde(skip_serializing_if = "Option::is_none")]
url: Option<Url>,
#[serde(skip_serializing_if = "Option::is_none")]
discoverable: Option<bool>,
manually_approves_followers: bool,
}
pub async fn get_local_actor(
user_id: uuid::Uuid,
data: &Data<FederationData>,
) -> Result<DbActor, Error> {
let user = data
.user_repo
.find_by_id(user_id)
.await
.map_err(Error::from)?
.ok_or_else(|| Error::not_found(anyhow::anyhow!("user not found: {}", user_id)))?;
let (public_key, private_key) = match data
.federation_repo
.get_local_actor_keypair(user_id)
.await?
{
Some(kp) => kp,
None => {
let kp = generate_actor_keypair()?;
data.federation_repo
.save_local_actor_keypair(user_id, kp.public_key.clone(), kp.private_key.clone())
.await?;
(kp.public_key, kp.private_key)
}
};
let ap_id = crate::urls::actor_url(&data.base_url, user_id);
let inbox_url = Url::parse(&format!("{}/inbox", &ap_id)).expect("valid inbox url");
let outbox_url = Url::parse(&format!("{}/outbox", &ap_id)).expect("valid outbox url");
let followers_url = Url::parse(&format!("{}/followers", &ap_id)).expect("valid followers url");
let following_url = Url::parse(&format!("{}/following", &ap_id)).expect("valid following url");
Ok(DbActor {
user_id,
username: user.username,
public_key_pem: public_key,
private_key_pem: Some(private_key),
inbox_url,
outbox_url,
followers_url,
following_url,
ap_id,
last_refreshed_at: Utc::now(),
bio: user.bio,
avatar_url: user.avatar_url,
profile_url: user.profile_url,
})
}
#[async_trait::async_trait]
impl Object for DbActor {
type DataType = FederationData;
type Kind = Person;
type Error = Error;
fn id(&self) -> &Url {
&self.ap_id
}
fn last_refreshed_at(&self) -> Option<DateTime<Utc>> {
Some(self.last_refreshed_at)
}
async fn read_from_id(
object_id: Url,
data: &Data<Self::DataType>,
) -> Result<Option<Self>, Self::Error> {
let user_id = match crate::urls::extract_user_id_from_url(&object_id) {
Some(id) => id,
None => return Ok(None),
};
let user = match data.user_repo.find_by_id(user_id).await {
Ok(Some(u)) => u,
_ => return Ok(None),
};
let keypair = data
.federation_repo
.get_local_actor_keypair(user_id)
.await?;
let (public_key, private_key) = match keypair {
Some(kp) => (kp.0, Some(kp.1)),
None => return Ok(None),
};
let ap_id = crate::urls::actor_url(&data.base_url, user_id);
let inbox_url = Url::parse(&format!("{}/inbox", &ap_id)).expect("valid url");
let outbox_url = Url::parse(&format!("{}/outbox", &ap_id)).expect("valid url");
let followers_url = Url::parse(&format!("{}/followers", &ap_id)).expect("valid url");
let following_url = Url::parse(&format!("{}/following", &ap_id)).expect("valid url");
Ok(Some(DbActor {
user_id,
username: user.username,
public_key_pem: public_key,
private_key_pem: private_key,
inbox_url,
outbox_url,
followers_url,
following_url,
ap_id,
last_refreshed_at: Utc::now(),
bio: None,
avatar_url: None,
profile_url: None,
}))
}
async fn into_json(self, _data: &Data<Self::DataType>) -> Result<Self::Kind, Self::Error> {
let public_key = PublicKey {
id: format!("{}#main-key", &self.ap_id),
owner: self.ap_id.clone(),
public_key_pem: self.public_key_pem.clone(),
};
let icon = self.avatar_url.map(|url| ApImageObject {
kind: "Image".to_string(),
url,
});
let profile_url = self.profile_url;
Ok(Person {
kind: Default::default(),
id: self.ap_id.clone().into(),
preferred_username: self.username.clone(),
inbox: self.inbox_url.clone(),
outbox: self.outbox_url.clone(),
followers: self.followers_url.clone(),
following: self.following_url.clone(),
public_key,
name: Some(self.username.clone()),
summary: self.bio.clone(),
icon,
url: profile_url,
discoverable: Some(true),
manually_approves_followers: false,
})
}
async fn verify(
json: &Self::Kind,
expected_domain: &Url,
_data: &Data<Self::DataType>,
) -> Result<(), Self::Error> {
verify_domains_match(json.id.inner(), expected_domain)?;
Ok(())
}
async fn from_json(json: Self::Kind, data: &Data<Self::DataType>) -> Result<Self, Self::Error> {
let actor = RemoteActor {
url: json.id.inner().to_string(),
handle: json.preferred_username.clone(),
inbox_url: json.inbox.to_string(),
shared_inbox_url: None,
display_name: json.name.clone(),
avatar_url: json.icon.as_ref().map(|i| i.url.to_string()),
};
data.federation_repo.upsert_remote_actor(actor).await?;
let url_str = json.id.inner().to_string();
let user_id = uuid::Uuid::new_v5(&uuid::Uuid::NAMESPACE_URL, url_str.as_bytes());
let ap_id = json.id.inner().clone();
let inbox_url = json.inbox.clone();
let outbox_url = json.outbox.clone();
let followers_url = json.followers.clone();
let following_url = json.following.clone();
Ok(DbActor {
user_id,
username: json.preferred_username.clone(),
public_key_pem: json.public_key.public_key_pem,
private_key_pem: None,
inbox_url,
outbox_url,
followers_url,
following_url,
ap_id,
last_refreshed_at: Utc::now(),
bio: None,
avatar_url: None,
profile_url: None,
})
}
}
impl Actor for DbActor {
fn public_key_pem(&self) -> &str {
&self.public_key_pem
}
fn private_key_pem(&self) -> Option<String> {
self.private_key_pem.clone()
}
fn inbox(&self) -> Url {
self.inbox_url.clone()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn person_serializes_with_enriched_fields() {
let person = Person {
kind: Default::default(),
id: "https://example.com/users/1".parse::<url::Url>().unwrap().into(),
preferred_username: "alice".to_string(),
inbox: "https://example.com/users/1/inbox".parse().unwrap(),
outbox: "https://example.com/users/1/outbox".parse().unwrap(),
followers: "https://example.com/users/1/followers".parse().unwrap(),
following: "https://example.com/users/1/following".parse().unwrap(),
public_key: PublicKey {
id: "https://example.com/users/1#main-key".to_string(),
owner: "https://example.com/users/1".parse().unwrap(),
public_key_pem: "pem".to_string(),
},
name: Some("Alice".to_string()),
summary: Some("Bio text".to_string()),
icon: Some(ApImageObject {
kind: "Image".to_string(),
url: "https://example.com/images/avatars/1".parse().unwrap(),
}),
url: Some("https://example.com/u/alice".parse().unwrap()),
discoverable: Some(true),
manually_approves_followers: false,
};
let json = serde_json::to_value(&person).unwrap();
assert_eq!(json["discoverable"], true);
assert_eq!(json["summary"], "Bio text");
assert_eq!(json["icon"]["type"], "Image");
assert!(json.get("manuallyApprovesFollowers").is_some());
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,71 @@
use activitypub_federation::{axum::json::FederationJson, config::Data};
use axum::extract::Path;
use serde_json::json;
use crate::data::FederationData;
use crate::error::Error;
use crate::repository::FollowerStatus;
fn ordered_collection(id: String, total: usize, items: Vec<String>) -> serde_json::Value {
json!({
"@context": "https://www.w3.org/ns/activitystreams",
"type": "OrderedCollection",
"id": id,
"totalItems": total,
"orderedItems": items,
})
}
pub async fn followers_handler(
Path(user_id_str): Path<String>,
data: Data<FederationData>,
) -> Result<FederationJson<serde_json::Value>, Error> {
let user_id = uuid::Uuid::parse_str(&user_id_str)
.map_err(|_| Error::bad_request(anyhow::anyhow!("invalid user id")))?;
data.user_repo
.find_by_id(user_id)
.await
.map_err(Error::from)?
.ok_or_else(|| Error::not_found(anyhow::anyhow!("user not found")))?;
let followers = data
.federation_repo
.get_followers(user_id)
.await
.map_err(Error::from)?;
let items: Vec<String> = followers
.into_iter()
.filter(|f| f.status == FollowerStatus::Accepted)
.map(|f| f.actor.url)
.collect();
let id = format!("{}/users/{}/followers", data.base_url, user_id_str);
Ok(FederationJson(ordered_collection(id, items.len(), items)))
}
pub async fn following_handler(
Path(user_id_str): Path<String>,
data: Data<FederationData>,
) -> Result<FederationJson<serde_json::Value>, Error> {
let user_id = uuid::Uuid::parse_str(&user_id_str)
.map_err(|_| Error::bad_request(anyhow::anyhow!("invalid user id")))?;
data.user_repo
.find_by_id(user_id)
.await
.map_err(Error::from)?
.ok_or_else(|| Error::not_found(anyhow::anyhow!("user not found")))?;
let following = data
.federation_repo
.get_following(user_id)
.await
.map_err(Error::from)?;
let items: Vec<String> = following.into_iter().map(|a| a.url).collect();
let id = format!("{}/users/{}/following", data.base_url, user_id_str);
Ok(FederationJson(ordered_collection(id, items.len(), items)))
}

View File

@@ -0,0 +1,18 @@
use activitypub_federation::{
axum::inbox::{ActivityData, receive_activity},
config::Data,
protocol::context::WithContext,
};
use crate::activities::InboxActivities;
use crate::actors::DbActor;
use crate::data::FederationData;
use crate::error::Error;
pub async fn inbox_handler(
data: Data<FederationData>,
activity_data: ActivityData,
) -> Result<(), Error> {
receive_activity::<WithContext<InboxActivities>, DbActor, FederationData>(activity_data, &data)
.await
}

View File

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

View File

@@ -0,0 +1,119 @@
use activitypub_federation::config::Data;
use axum::Json;
use serde::Serialize;
use crate::data::FederationData;
use crate::error::Error;
#[derive(Serialize)]
pub struct NodeInfoWellKnown {
pub links: Vec<NodeInfoLink>,
}
#[derive(Serialize)]
pub struct NodeInfoLink {
pub rel: String,
pub href: String,
}
#[derive(Serialize)]
pub struct NodeInfoSoftware {
pub name: String,
pub version: String,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct NodeInfoUsage {
pub users: NodeInfoUsers,
pub local_posts: u64,
}
#[derive(Serialize)]
pub struct NodeInfoUsers {
pub total: usize,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct NodeInfo {
pub version: String,
pub software: NodeInfoSoftware,
pub protocols: Vec<String>,
pub usage: NodeInfoUsage,
pub open_registrations: bool,
}
pub async fn nodeinfo_well_known_handler(
data: Data<FederationData>,
) -> Result<Json<NodeInfoWellKnown>, Error> {
let href = format!("{}/nodeinfo/2.0", data.base_url);
Ok(Json(NodeInfoWellKnown {
links: vec![NodeInfoLink {
rel: "http://nodeinfo.diaspora.software/ns/schema/2.0".to_string(),
href,
}],
}))
}
pub async fn nodeinfo_handler(
data: Data<FederationData>,
) -> Result<Json<NodeInfo>, Error> {
let user_count = data.user_repo.count_users().await.unwrap_or(0);
let local_posts = data.object_handler.count_local_posts().await.unwrap_or(0);
Ok(Json(NodeInfo {
version: "2.0".to_string(),
software: NodeInfoSoftware {
name: data.software_name.clone(),
version: env!("CARGO_PKG_VERSION").to_string(),
},
protocols: vec!["activitypub".to_string()],
usage: NodeInfoUsage {
users: NodeInfoUsers { total: user_count },
local_posts,
},
open_registrations: data.allow_registration,
}))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn nodeinfo_well_known_serializes_correctly() {
let doc = NodeInfoWellKnown {
links: vec![NodeInfoLink {
rel: "http://nodeinfo.diaspora.software/ns/schema/2.0".to_string(),
href: "https://example.com/nodeinfo/2.0".to_string(),
}],
};
let json = serde_json::to_value(&doc).unwrap();
assert_eq!(json["links"][0]["rel"], "http://nodeinfo.diaspora.software/ns/schema/2.0");
assert_eq!(json["links"][0]["href"], "https://example.com/nodeinfo/2.0");
}
#[test]
fn nodeinfo_serializes_camel_case() {
let doc = NodeInfo {
version: "2.0".to_string(),
software: NodeInfoSoftware {
name: "my-app".to_string(),
version: "0.1.0".to_string(),
},
protocols: vec!["activitypub".to_string()],
usage: NodeInfoUsage {
users: NodeInfoUsers { total: 3 },
local_posts: 42,
},
open_registrations: false,
};
let json = serde_json::to_value(&doc).unwrap();
assert_eq!(json["version"], "2.0");
assert_eq!(json["software"]["name"], "my-app");
assert_eq!(json["usage"]["users"]["total"], 3);
assert_eq!(json["usage"]["localPosts"], 42);
assert_eq!(json["openRegistrations"], false);
}
}

View File

@@ -0,0 +1,135 @@
use axum::extract::{Path, Query};
use axum::response::IntoResponse;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use url::Url;
use activitypub_federation::{config::Data, fetch::object_id::ObjectId, kinds::activity::CreateType};
use crate::{activities::CreateActivity, data::FederationData, error::Error};
const PAGE_SIZE: usize = 20;
#[derive(Deserialize)]
pub struct OutboxQuery {
page: Option<bool>,
before: Option<String>,
}
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct OrderedCollection {
#[serde(rename = "@context")]
context: String,
#[serde(rename = "type")]
kind: String,
id: String,
total_items: u64,
first: String,
}
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct OrderedCollectionPage {
#[serde(rename = "@context")]
context: String,
#[serde(rename = "type")]
kind: String,
id: String,
part_of: String,
ordered_items: Vec<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
next: Option<String>,
}
pub async fn outbox_handler(
Path(user_id_str): Path<String>,
Query(query): Query<OutboxQuery>,
data: Data<FederationData>,
) -> Result<axum::response::Response, Error> {
let uuid = uuid::Uuid::parse_str(&user_id_str)
.map_err(|_| Error::bad_request(anyhow::anyhow!("invalid user id")))?;
data.user_repo
.find_by_id(uuid)
.await
.map_err(Error::from)?
.ok_or_else(|| Error::not_found(anyhow::anyhow!("user not found")))?;
let outbox_url = format!("{}/users/{}/outbox", data.base_url, user_id_str);
if query.page.unwrap_or(false) {
let before: Option<DateTime<Utc>> = query.before.as_deref().and_then(|s| s.parse().ok());
let items = data
.object_handler
.get_local_objects_page(uuid, before, PAGE_SIZE)
.await
.map_err(|e| Error::from(anyhow::anyhow!("{}", e)))?;
let actor_url: Url = format!("{}/users/{}", data.base_url, user_id_str)
.parse()
.expect("valid url");
let has_more = items.len() == PAGE_SIZE;
let oldest_ts = items.last().map(|(_, _, ts)| *ts);
let ordered_items: Vec<serde_json::Value> = items
.into_iter()
.map(|(ap_id, object, _)| {
let create_id =
Url::parse(&format!("{}/activity", ap_id)).expect("valid url");
serde_json::to_value(CreateActivity {
id: create_id,
kind: CreateType::default(),
actor: ObjectId::from(actor_url.clone()),
object,
})
.expect("serializable")
})
.collect();
let page_id = match &query.before {
Some(b) => format!("{}?page=true&before={}", outbox_url, b),
None => format!("{}?page=true", outbox_url),
};
let next = if has_more {
oldest_ts.map(|ts| {
// Use RFC 3339 with Z suffix (no + sign) to avoid percent-encoding
let ts_str = ts
.format("%Y-%m-%dT%H:%M:%S%.3fZ")
.to_string();
format!("{}?page=true&before={}", outbox_url, ts_str)
})
} else {
None
};
Ok(axum::Json(OrderedCollectionPage {
context: "https://www.w3.org/ns/activitystreams".to_string(),
kind: "OrderedCollectionPage".to_string(),
id: page_id,
part_of: outbox_url,
ordered_items,
next,
})
.into_response())
} else {
let total = data
.object_handler
.get_local_objects_for_user(uuid)
.await
.map_err(|e| Error::from(anyhow::anyhow!("{}", e)))?
.len() as u64;
Ok(axum::Json(OrderedCollection {
context: "https://www.w3.org/ns/activitystreams".to_string(),
kind: "OrderedCollection".to_string(),
id: outbox_url.clone(),
total_items: total,
first: format!("{}?page=true", outbox_url),
})
.into_response())
}
}

View File

@@ -0,0 +1,115 @@
use anyhow::Result;
use async_trait::async_trait;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FollowerStatus {
Pending,
Accepted,
Rejected,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FollowingStatus {
Pending,
Accepted,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RemoteActor {
pub url: String,
pub handle: String,
pub inbox_url: String,
pub shared_inbox_url: Option<String>,
pub display_name: Option<String>,
pub avatar_url: Option<String>,
}
#[derive(Debug, Clone)]
pub struct Follower {
pub actor: RemoteActor,
pub status: FollowerStatus,
}
#[derive(Debug, Clone)]
pub struct BlockedDomain {
pub domain: String,
pub reason: Option<String>,
pub blocked_at: String,
}
#[async_trait]
pub trait FederationRepository: Send + Sync {
async fn add_follower(
&self,
local_user_id: uuid::Uuid,
remote_actor_url: &str,
status: FollowerStatus,
follow_activity_id: &str,
) -> Result<()>;
async fn get_follower_follow_activity_id(
&self,
local_user_id: uuid::Uuid,
remote_actor_url: &str,
) -> Result<Option<String>>;
async fn remove_follower(
&self,
local_user_id: uuid::Uuid,
remote_actor_url: &str,
) -> Result<()>;
async fn get_followers(&self, local_user_id: uuid::Uuid) -> Result<Vec<Follower>>;
async fn update_follower_status(
&self,
local_user_id: uuid::Uuid,
remote_actor_url: &str,
status: FollowerStatus,
) -> Result<()>;
async fn add_following(
&self,
local_user_id: uuid::Uuid,
actor: RemoteActor,
follow_activity_id: &str,
) -> Result<()>;
async fn get_follow_activity_id(
&self,
local_user_id: uuid::Uuid,
remote_actor_url: &str,
) -> Result<Option<String>>;
async fn remove_following(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<()>;
async fn get_following(&self, local_user_id: uuid::Uuid) -> Result<Vec<RemoteActor>>;
async fn count_following(&self, local_user_id: uuid::Uuid) -> Result<usize>;
async fn upsert_remote_actor(&self, actor: RemoteActor) -> Result<()>;
async fn get_remote_actor(&self, actor_url: &str) -> Result<Option<RemoteActor>>;
async fn get_local_actor_keypair(
&self,
user_id: uuid::Uuid,
) -> Result<Option<(String, String)>>;
async fn save_local_actor_keypair(
&self,
user_id: uuid::Uuid,
public_key: String,
private_key: String,
) -> Result<()>;
async fn get_pending_followers(&self, local_user_id: uuid::Uuid) -> Result<Vec<RemoteActor>>;
async fn update_following_status(
&self,
local_user_id: uuid::Uuid,
remote_actor_url: &str,
status: FollowingStatus,
) -> Result<()>;
async fn add_announce(
&self,
activity_id: &str,
object_url: &str,
actor_url: &str,
announced_at: chrono::DateTime<chrono::Utc>,
) -> Result<()>;
async fn count_announces(&self, object_url: &str) -> Result<usize>;
async fn add_blocked_domain(&self, domain: &str, reason: Option<&str>) -> Result<()>;
async fn remove_blocked_domain(&self, domain: &str) -> Result<()>;
async fn get_blocked_domains(&self) -> Result<Vec<BlockedDomain>>;
async fn is_domain_blocked(&self, domain: &str) -> Result<bool>;
async fn add_blocked_actor(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<()>;
async fn remove_blocked_actor(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<()>;
async fn get_blocked_actors(&self, local_user_id: uuid::Uuid) -> Result<Vec<String>>;
async fn is_actor_blocked(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<bool>;
}

View File

@@ -0,0 +1,975 @@
use std::sync::Arc;
use activitypub_federation::{
activity_sending::SendActivityTask,
fetch::{object_id::ObjectId, webfinger::webfinger_resolve_actor},
protocol::context::WithContext,
traits::Actor,
};
use axum::{Router, routing::get, routing::post};
use url::Url;
use crate::{
activities::{AcceptActivity, CreateActivity, FollowActivity, RejectActivity, UndoActivity, UpdateActivity},
actors::{DbActor, get_local_actor},
content::ApObjectHandler,
data::FederationData,
federation::ApFederationConfig,
followers_handler::{followers_handler, following_handler},
inbox::inbox_handler,
outbox::outbox_handler,
repository::{BlockedDomain, FederationRepository, FollowerStatus, FollowingStatus, RemoteActor},
urls::activity_url,
user::ApUserRepository,
nodeinfo::{nodeinfo_handler, nodeinfo_well_known_handler},
webfinger::webfinger_handler,
};
fn collect_inboxes(followers: &[crate::repository::Follower]) -> Vec<Url> {
let mut seen = std::collections::HashSet::new();
let mut inboxes = Vec::new();
for f in followers {
let inbox_str = f
.actor
.shared_inbox_url
.as_deref()
.unwrap_or(&f.actor.inbox_url);
if seen.insert(inbox_str.to_string()) {
if let Ok(url) = Url::parse(inbox_str) {
inboxes.push(url);
}
}
}
inboxes
}
pub(crate) async fn send_with_retry(
sends: Vec<SendActivityTask>,
data: &activitypub_federation::config::Data<FederationData>,
) -> Vec<anyhow::Error> {
let mut failures = vec![];
for send in sends {
let mut delay = std::time::Duration::from_secs(1);
for attempt in 1..=3u32 {
match send.clone().sign_and_send(data).await {
Ok(()) => break,
Err(e) if attempt < 3 => {
tracing::warn!(attempt, error = %e, "delivery failed, retrying");
tokio::time::sleep(delay).await;
delay *= 2;
}
Err(e) => {
tracing::error!(attempt, error = %e, "delivery failed permanently");
failures.push(anyhow::anyhow!(e));
}
}
}
}
failures
}
pub struct ActivityPubService {
federation_config: ApFederationConfig,
base_url: String,
}
impl ActivityPubService {
pub async fn new(
repo: Arc<dyn FederationRepository>,
user_repo: Arc<dyn ApUserRepository>,
object_handler: Arc<dyn ApObjectHandler>,
base_url: String,
allow_registration: bool,
software_name: String,
debug: bool,
) -> anyhow::Result<Self> {
let data = FederationData::new(repo, user_repo, object_handler, base_url.clone(), allow_registration, software_name);
let federation_config = ApFederationConfig::new(data, debug).await?;
Ok(Self {
federation_config,
base_url,
})
}
pub fn federation_config(&self) -> &ApFederationConfig {
&self.federation_config
}
pub fn request_data(&self) -> activitypub_federation::config::Data<FederationData> {
self.federation_config.to_request_data()
}
pub async fn actor_json(&self, user_id_str: &str) -> anyhow::Result<String> {
use activitypub_federation::traits::Object;
let uuid = uuid::Uuid::parse_str(user_id_str)?;
let data = self.federation_config.to_request_data();
let actor = get_local_actor(uuid, &data)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
let person = actor
.into_json(&data)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
Ok(serde_json::to_string(&WithContext::new_default(person))?)
}
pub fn router(&self) -> Router {
Router::new()
.route("/.well-known/nodeinfo", get(nodeinfo_well_known_handler))
.route("/nodeinfo/2.0", get(nodeinfo_handler))
.route("/.well-known/webfinger", get(webfinger_handler))
.route("/users/{id}/inbox", post(inbox_handler))
.route("/users/{id}/outbox", get(outbox_handler))
.route("/users/{id}/followers", get(followers_handler))
.route("/users/{id}/following", get(following_handler))
.layer(self.federation_config.middleware())
}
pub async fn follow(&self, local_user_id: uuid::Uuid, handle: &str) -> anyhow::Result<()> {
let data = self.federation_config.to_request_data();
let normalized = handle.trim_start_matches('@');
let parts: Vec<&str> = normalized.splitn(2, '@').collect();
if parts.len() == 2 && parts[1] == data.domain {
return self.follow_local(local_user_id, parts[0], &data).await;
}
let remote_actor: DbActor = webfinger_resolve_actor(handle, &data)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
let local_actor = get_local_actor(local_user_id, &data)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
let follow_id = activity_url(&self.base_url).map_err(|e| anyhow::anyhow!("{e}"))?;
let follow_id_str = follow_id.to_string();
let follow = FollowActivity {
id: follow_id,
kind: Default::default(),
actor: ObjectId::from(local_actor.ap_id.clone()),
object: ObjectId::from(remote_actor.ap_id.clone()),
};
let follow_with_ctx = WithContext::new_default(follow);
let sends = SendActivityTask::prepare(
&follow_with_ctx,
&local_actor,
vec![remote_actor.inbox()],
&data,
)
.await?;
let failures = send_with_retry(sends, &data).await;
if !failures.is_empty() {
tracing::warn!(
count = failures.len(),
"some activity deliveries failed permanently"
);
}
let remote = RemoteActor {
url: remote_actor.ap_id.to_string(),
handle: remote_actor.username.clone(),
inbox_url: remote_actor.inbox_url.to_string(),
shared_inbox_url: None,
display_name: Some(remote_actor.username.clone()),
avatar_url: None,
};
data.federation_repo
.add_following(local_user_id, remote, &follow_id_str)
.await?;
Ok(())
}
pub async fn unfollow(
&self,
local_user_id: uuid::Uuid,
actor_url_str: &str,
) -> anyhow::Result<()> {
let data = self.federation_config.to_request_data();
if actor_url_str.starts_with(&self.base_url) {
return self
.unfollow_local(local_user_id, actor_url_str, &data)
.await;
}
let remote = data
.federation_repo
.get_remote_actor(actor_url_str)
.await?
.ok_or_else(|| anyhow::anyhow!("remote actor not found: {}", actor_url_str))?;
let local_actor = get_local_actor(local_user_id, &data)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
let remote_ap_id = Url::parse(actor_url_str)?;
let inbox = Url::parse(&remote.inbox_url)?;
let follow_activity_id_str = data
.federation_repo
.get_follow_activity_id(local_user_id, actor_url_str)
.await?;
let follow_id = match follow_activity_id_str {
Some(id) => Url::parse(&id)?,
None => activity_url(&self.base_url).map_err(|e| anyhow::anyhow!("{e}"))?,
};
let follow = FollowActivity {
id: follow_id,
kind: Default::default(),
actor: ObjectId::from(local_actor.ap_id.clone()),
object: ObjectId::from(remote_ap_id),
};
let undo_id = activity_url(&self.base_url).map_err(|e| anyhow::anyhow!("{e}"))?;
let undo = UndoActivity {
id: undo_id,
kind: Default::default(),
actor: ObjectId::from(local_actor.ap_id.clone()),
object: follow,
};
let sends = SendActivityTask::prepare(
&WithContext::new_default(undo),
&local_actor,
vec![inbox],
&data,
)
.await?;
let failures = send_with_retry(sends, &data).await;
if !failures.is_empty() {
tracing::warn!(
count = failures.len(),
"some activity deliveries failed permanently"
);
}
data.federation_repo
.remove_following(local_user_id, actor_url_str)
.await?;
data.object_handler
.on_actor_removed(&Url::parse(actor_url_str)?)
.await?;
Ok(())
}
pub async fn accept_follower(
&self,
local_user_id: uuid::Uuid,
remote_actor_url: &str,
) -> anyhow::Result<()> {
let data = self.federation_config.to_request_data();
let local_actor = get_local_actor(local_user_id, &data)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
let remote_actor = data
.federation_repo
.get_remote_actor(remote_actor_url)
.await?
.ok_or_else(|| anyhow::anyhow!("remote actor not found"))?;
let follow_id_str = data
.federation_repo
.get_follower_follow_activity_id(local_user_id, remote_actor_url)
.await?
.ok_or_else(|| {
anyhow::anyhow!("follow activity id not found for {}", remote_actor_url)
})?;
let follow_id = Url::parse(&follow_id_str)?;
let follow = FollowActivity {
id: follow_id,
kind: Default::default(),
actor: ObjectId::from(Url::parse(remote_actor_url)?),
object: ObjectId::from(local_actor.ap_id.clone()),
};
let accept = AcceptActivity {
id: activity_url(&self.base_url).map_err(|e| anyhow::anyhow!("{e}"))?,
kind: Default::default(),
actor: ObjectId::from(local_actor.ap_id.clone()),
object: follow,
};
data.federation_repo
.update_follower_status(local_user_id, remote_actor_url, FollowerStatus::Accepted)
.await?;
let inbox = Url::parse(&remote_actor.inbox_url)?;
let sends = SendActivityTask::prepare(
&WithContext::new_default(accept),
&local_actor,
vec![inbox.clone()],
&data,
)
.await?;
let failures = send_with_retry(sends, &data).await;
if !failures.is_empty() {
tracing::warn!(
"failed to deliver Accept activity, but follower is marked accepted locally"
);
}
let target_inbox = remote_actor
.shared_inbox_url
.clone()
.unwrap_or_else(|| remote_actor.inbox_url.clone());
self.spawn_backfill(local_user_id, target_inbox);
Ok(())
}
pub async fn reject_follower(
&self,
local_user_id: uuid::Uuid,
remote_actor_url: &str,
) -> anyhow::Result<()> {
let data = self.federation_config.to_request_data();
let local_actor = get_local_actor(local_user_id, &data)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
let remote_actor = data
.federation_repo
.get_remote_actor(remote_actor_url)
.await?
.ok_or_else(|| anyhow::anyhow!("remote actor not found"))?;
let follow_id = activity_url(&self.base_url).map_err(|e| anyhow::anyhow!("{e}"))?;
let follow = FollowActivity {
id: follow_id,
kind: Default::default(),
actor: ObjectId::from(Url::parse(remote_actor_url)?),
object: ObjectId::from(local_actor.ap_id.clone()),
};
let reject = RejectActivity {
id: activity_url(&self.base_url).map_err(|e| anyhow::anyhow!("{e}"))?,
kind: Default::default(),
actor: ObjectId::from(local_actor.ap_id.clone()),
object: follow,
};
let inbox = Url::parse(&remote_actor.inbox_url)?;
let sends = SendActivityTask::prepare(
&WithContext::new_default(reject),
&local_actor,
vec![inbox],
&data,
)
.await?;
let failures = send_with_retry(sends, &data).await;
if !failures.is_empty() {
tracing::warn!(
count = failures.len(),
"some activity deliveries failed permanently"
);
}
data.federation_repo
.remove_follower(local_user_id, remote_actor_url)
.await?;
Ok(())
}
pub async fn get_pending_followers(
&self,
local_user_id: uuid::Uuid,
) -> anyhow::Result<Vec<RemoteActor>> {
let data = self.federation_config.to_request_data();
data.federation_repo
.get_pending_followers(local_user_id)
.await
}
pub async fn get_accepted_followers(
&self,
local_user_id: uuid::Uuid,
) -> anyhow::Result<Vec<RemoteActor>> {
let data = self.federation_config.to_request_data();
let followers = data.federation_repo.get_followers(local_user_id).await?;
Ok(followers
.into_iter()
.filter(|f| f.status == FollowerStatus::Accepted)
.map(|f| f.actor)
.collect())
}
pub async fn count_accepted_followers(
&self,
local_user_id: uuid::Uuid,
) -> anyhow::Result<usize> {
let data = self.federation_config.to_request_data();
let followers = data.federation_repo.get_followers(local_user_id).await?;
Ok(followers
.into_iter()
.filter(|f| f.status == FollowerStatus::Accepted)
.count())
}
pub async fn get_following(
&self,
local_user_id: uuid::Uuid,
) -> anyhow::Result<Vec<RemoteActor>> {
let data = self.federation_config.to_request_data();
data.federation_repo.get_following(local_user_id).await
}
pub async fn count_following(&self, local_user_id: uuid::Uuid) -> anyhow::Result<usize> {
let data = self.federation_config.to_request_data();
data.federation_repo.count_following(local_user_id).await
}
pub async fn remove_follower(
&self,
local_user_id: uuid::Uuid,
actor_url: &str,
) -> anyhow::Result<()> {
let data = self.federation_config.to_request_data();
data.federation_repo
.remove_follower(local_user_id, actor_url)
.await
}
/// Broadcast a single object to all accepted followers as a Create activity.
/// Called by project-specific event handlers when new content is created.
pub async fn broadcast_to_followers(
&self,
local_user_id: uuid::Uuid,
ap_id: Url,
object: serde_json::Value,
) -> anyhow::Result<()> {
let data = self.federation_config.to_request_data();
let local_actor = get_local_actor(local_user_id, &data)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
let followers = data.federation_repo.get_followers(local_user_id).await?;
let blocked = data
.federation_repo
.get_blocked_actors(local_user_id)
.await
.unwrap_or_default();
let blocked_set: std::collections::HashSet<String> = blocked.into_iter().collect();
let blocked_domains = data
.federation_repo
.get_blocked_domains()
.await
.unwrap_or_default();
let blocked_domain_set: std::collections::HashSet<String> =
blocked_domains.into_iter().map(|d| d.domain).collect();
let accepted: Vec<_> = followers
.into_iter()
.filter(|f| f.status == FollowerStatus::Accepted)
.filter(|f| !blocked_set.contains(&f.actor.url))
.filter(|f| {
let domain = url::Url::parse(&f.actor.inbox_url)
.ok()
.and_then(|u| u.host_str().map(|s| s.to_string()))
.unwrap_or_default();
!blocked_domain_set.contains(&domain)
})
.collect();
if accepted.is_empty() {
return Ok(());
}
let create = CreateActivity {
id: ap_id.clone(),
kind: Default::default(),
actor: ObjectId::from(local_actor.ap_id.clone()),
object,
};
let create_with_ctx = WithContext::new_default(create);
let inboxes = collect_inboxes(&accepted);
let sends =
SendActivityTask::prepare(&create_with_ctx, &local_actor, inboxes, &data).await?;
let failures = send_with_retry(sends, &data).await;
if !failures.is_empty() {
tracing::warn!(
count = failures.len(),
"some activity deliveries failed permanently"
);
}
Ok(())
}
/// Broadcast a Delete activity to all accepted followers for a removed review.
pub async fn broadcast_delete_to_followers(
&self,
local_user_id: uuid::Uuid,
ap_id: Url,
) -> anyhow::Result<()> {
let data = self.federation_config.to_request_data();
let local_actor = get_local_actor(local_user_id, &data)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
let followers = data.federation_repo.get_followers(local_user_id).await?;
let blocked = data
.federation_repo
.get_blocked_actors(local_user_id)
.await
.unwrap_or_default();
let blocked_set: std::collections::HashSet<String> = blocked.into_iter().collect();
let blocked_domains = data
.federation_repo
.get_blocked_domains()
.await
.unwrap_or_default();
let blocked_domain_set: std::collections::HashSet<String> =
blocked_domains.into_iter().map(|d| d.domain).collect();
let accepted: Vec<_> = followers
.into_iter()
.filter(|f| f.status == FollowerStatus::Accepted)
.filter(|f| !blocked_set.contains(&f.actor.url))
.filter(|f| {
let domain = url::Url::parse(&f.actor.inbox_url)
.ok()
.and_then(|u| u.host_str().map(|s| s.to_string()))
.unwrap_or_default();
!blocked_domain_set.contains(&domain)
})
.collect();
if accepted.is_empty() {
return Ok(());
}
let delete_id = crate::urls::activity_url(&self.base_url)
.map_err(|e| anyhow::anyhow!("{e}"))?;
let delete = crate::activities::DeleteActivity {
id: delete_id,
kind: Default::default(),
actor: ObjectId::from(local_actor.ap_id.clone()),
object: ap_id,
};
let delete_with_ctx = WithContext::new_default(delete);
let inboxes = collect_inboxes(&accepted);
let sends =
SendActivityTask::prepare(&delete_with_ctx, &local_actor, inboxes, &data).await?;
let failures = send_with_retry(sends, &data).await;
if !failures.is_empty() {
tracing::warn!(
count = failures.len(),
"some delete activity deliveries failed"
);
}
Ok(())
}
/// Broadcast an Update(Note) activity to all accepted followers for an edited review.
pub async fn broadcast_update_to_followers(
&self,
local_user_id: uuid::Uuid,
object: serde_json::Value,
) -> anyhow::Result<()> {
let data = self.federation_config.to_request_data();
let local_actor = get_local_actor(local_user_id, &data)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
let followers = data.federation_repo.get_followers(local_user_id).await?;
let blocked = data
.federation_repo
.get_blocked_actors(local_user_id)
.await
.unwrap_or_default();
let blocked_set: std::collections::HashSet<String> = blocked.into_iter().collect();
let blocked_domains = data
.federation_repo
.get_blocked_domains()
.await
.unwrap_or_default();
let blocked_domain_set: std::collections::HashSet<String> =
blocked_domains.into_iter().map(|d| d.domain).collect();
let accepted: Vec<_> = followers
.into_iter()
.filter(|f| f.status == FollowerStatus::Accepted)
.filter(|f| !blocked_set.contains(&f.actor.url))
.filter(|f| {
let domain = url::Url::parse(&f.actor.inbox_url)
.ok()
.and_then(|u| u.host_str().map(|s| s.to_string()))
.unwrap_or_default();
!blocked_domain_set.contains(&domain)
})
.collect();
if accepted.is_empty() {
return Ok(());
}
let update_id = Url::parse(&format!(
"{}/activities/update/{}",
self.base_url,
uuid::Uuid::new_v4()
))?;
let update = crate::activities::UpdateActivity {
id: update_id,
kind: Default::default(),
actor: ObjectId::from(local_actor.ap_id.clone()),
object,
};
let update_with_ctx = WithContext::new_default(update);
let inboxes = collect_inboxes(&accepted);
let sends =
SendActivityTask::prepare(&update_with_ctx, &local_actor, inboxes, &data).await?;
let failures = send_with_retry(sends, &data).await;
if !failures.is_empty() {
tracing::warn!(
count = failures.len(),
"some update activity deliveries failed"
);
}
Ok(())
}
pub async fn broadcast_actor_update(&self, user_id: uuid::Uuid) -> anyhow::Result<()> {
use activitypub_federation::traits::Object;
let data = self.federation_config.to_request_data();
let local_actor = get_local_actor(user_id, &data)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
let person = local_actor.clone().into_json(&data).await
.map_err(|e| anyhow::anyhow!("{e}"))?;
let person_json = serde_json::to_value(&person)?;
let update_id = Url::parse(&format!(
"{}/activities/update/{}",
self.base_url,
uuid::Uuid::new_v4()
))?;
let update = UpdateActivity {
id: update_id,
kind: Default::default(),
actor: ObjectId::from(local_actor.ap_id.clone()),
object: person_json,
};
let followers = data.federation_repo.get_followers(user_id).await?;
let accepted: Vec<_> = followers
.into_iter()
.filter(|f| f.status == FollowerStatus::Accepted)
.collect();
if accepted.is_empty() {
return Ok(());
}
let inboxes = collect_inboxes(&accepted);
let sends = SendActivityTask::prepare(
&WithContext::new_default(update),
&local_actor,
inboxes,
&data,
)
.await?;
let failures = send_with_retry(sends, &data).await;
if !failures.is_empty() {
tracing::warn!(count = failures.len(), "actor update delivery failures");
}
Ok(())
}
pub async fn block_actor(&self, local_user_id: uuid::Uuid, actor_url: &str) -> anyhow::Result<()> {
let data = self.federation_config.to_request_data();
data.federation_repo
.add_blocked_actor(local_user_id, actor_url)
.await?;
let _ = data.federation_repo.remove_follower(local_user_id, actor_url).await;
let _ = data.federation_repo.remove_following(local_user_id, actor_url).await;
let local_actor = get_local_actor(local_user_id, &data)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
if let Ok(Some(remote_actor)) = data.federation_repo.get_remote_actor(actor_url).await {
let block_id = crate::urls::activity_url(&self.base_url)
.map_err(|e| anyhow::anyhow!("{e}"))?;
let block = crate::activities::BlockActivity {
id: block_id,
kind: Default::default(),
actor: ObjectId::from(local_actor.ap_id.clone()),
object: Url::parse(actor_url)?,
};
let inbox = Url::parse(&remote_actor.inbox_url)?;
let sends = SendActivityTask::prepare(
&WithContext::new_default(block),
&local_actor,
vec![inbox],
&data,
)
.await?;
let failures = send_with_retry(sends, &data).await;
if !failures.is_empty() {
tracing::warn!(actor = %actor_url, "failed to deliver Block activity");
}
}
Ok(())
}
pub async fn unblock_actor(&self, local_user_id: uuid::Uuid, actor_url: &str) -> anyhow::Result<()> {
let data = self.federation_config.to_request_data();
data.federation_repo
.remove_blocked_actor(local_user_id, actor_url)
.await
}
pub async fn get_blocked_actors(&self, local_user_id: uuid::Uuid) -> anyhow::Result<Vec<RemoteActor>> {
let data = self.federation_config.to_request_data();
let actor_urls = data.federation_repo.get_blocked_actors(local_user_id).await?;
let mut actors = Vec::new();
for url in actor_urls {
let actor = match data.federation_repo.get_remote_actor(&url).await {
Ok(Some(a)) => a,
_ => RemoteActor {
url: url.clone(),
handle: url.clone(),
inbox_url: url.clone(),
shared_inbox_url: None,
display_name: None,
avatar_url: None,
},
};
actors.push(actor);
}
Ok(actors)
}
pub async fn add_blocked_domain(&self, domain: &str, reason: Option<&str>) -> anyhow::Result<()> {
let data = self.federation_config.to_request_data();
data.federation_repo.add_blocked_domain(domain, reason).await
}
pub async fn remove_blocked_domain(&self, domain: &str) -> anyhow::Result<()> {
let data = self.federation_config.to_request_data();
data.federation_repo.remove_blocked_domain(domain).await
}
pub async fn get_blocked_domains(&self) -> anyhow::Result<Vec<BlockedDomain>> {
let data = self.federation_config.to_request_data();
data.federation_repo.get_blocked_domains().await
}
async fn follow_local(
&self,
local_user_id: uuid::Uuid,
target_username: &str,
data: &activitypub_federation::config::Data<FederationData>,
) -> anyhow::Result<()> {
let target = data
.user_repo
.find_by_username(target_username)
.await?
.ok_or_else(|| anyhow::anyhow!("user not found: {}", target_username))?;
if target.id == local_user_id {
return Err(anyhow::anyhow!("cannot follow yourself"));
}
let follower_actor_url = crate::urls::actor_url(&self.base_url, local_user_id).to_string();
let target_actor_url = crate::urls::actor_url(&self.base_url, target.id);
let target_inbox_url = format!("{}/inbox", target_actor_url);
let follow_id = activity_url(&self.base_url)
.map_err(|e| anyhow::anyhow!("{e}"))?
.to_string();
data.federation_repo
.add_follower(
target.id,
&follower_actor_url,
FollowerStatus::Accepted,
&follow_id,
)
.await?;
let target_as_remote = RemoteActor {
url: target_actor_url.to_string(),
handle: format!("{}@{}", target.username, data.domain),
inbox_url: target_inbox_url,
shared_inbox_url: None,
display_name: Some(target.username),
avatar_url: None,
};
data.federation_repo
.add_following(local_user_id, target_as_remote, &follow_id)
.await?;
data.federation_repo
.update_following_status(
local_user_id,
&target_actor_url.to_string(),
FollowingStatus::Accepted,
)
.await?;
tracing::info!(follower = %local_user_id, followee = %target.id, "local follow");
Ok(())
}
async fn unfollow_local(
&self,
local_user_id: uuid::Uuid,
target_actor_url: &str,
data: &activitypub_federation::config::Data<FederationData>,
) -> anyhow::Result<()> {
let target_url = Url::parse(target_actor_url)?;
let target_user_id = crate::urls::extract_user_id_from_url(&target_url)
.ok_or_else(|| anyhow::anyhow!("invalid local actor URL: {}", target_actor_url))?;
let local_actor_url = crate::urls::actor_url(&self.base_url, local_user_id).to_string();
data.federation_repo
.remove_follower(target_user_id, &local_actor_url)
.await?;
data.federation_repo
.remove_following(local_user_id, target_actor_url)
.await?;
tracing::info!(follower = %local_user_id, followee = %target_user_id, "local unfollow");
Ok(())
}
fn spawn_backfill(&self, owner_user_id: uuid::Uuid, follower_inbox_url: String) {
let config = self.federation_config.clone();
let base_url = self.base_url.clone();
tokio::spawn(async move {
if let Err(e) = ActivityPubService::run_backfill(
config,
base_url,
owner_user_id,
follower_inbox_url,
)
.await
{
tracing::warn!(error = %e, "backfill: task failed");
}
});
}
async fn run_backfill(
config: ApFederationConfig,
base_url: String,
owner_user_id: uuid::Uuid,
follower_inbox_url: String,
) -> anyhow::Result<()> {
const BATCH_SIZE: usize = 20;
let data = config.to_request_data();
let local_actor = get_local_actor(owner_user_id, &data)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
let inbox = Url::parse(&follower_inbox_url)?;
let mut objects = data
.object_handler
.get_local_objects_for_user(owner_user_id)
.await?;
objects.reverse(); // oldest first → chronological feed
let total = objects.len();
let mut success_count = 0usize;
let mut failure_count = 0usize;
for chunk in objects.chunks(BATCH_SIZE) {
for (ap_id, object_json) in chunk {
// Use a stable Create activity ID derived from the object's ap_id
let create_id = Url::parse(&format!(
"{}/activities/create/{}",
base_url,
uuid::Uuid::new_v5(&uuid::Uuid::NAMESPACE_URL, ap_id.as_str().as_bytes())
))?;
let create = CreateActivity {
id: create_id,
kind: Default::default(),
actor: ObjectId::from(local_actor.ap_id.clone()),
object: object_json.clone(),
};
let sends = SendActivityTask::prepare(
&WithContext::new_default(create),
&local_actor,
vec![inbox.clone()],
&data,
)
.await?;
let failures = send_with_retry(sends, &data).await;
if failures.is_empty() {
success_count += 1;
} else {
failure_count += 1;
}
}
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
}
tracing::info!(
user_id = %owner_user_id,
follower = %follower_inbox_url,
sent = success_count,
failed = failure_count,
total = total,
"backfill complete"
);
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::repository::{Follower, FollowerStatus, RemoteActor};
fn make_follower(inbox: &str, shared: Option<&str>) -> Follower {
Follower {
actor: RemoteActor {
url: format!("https://remote/{}", inbox),
handle: "user".to_string(),
inbox_url: inbox.to_string(),
shared_inbox_url: shared.map(|s| s.to_string()),
display_name: None,
avatar_url: None,
},
status: FollowerStatus::Accepted,
}
}
#[test]
fn collect_inboxes_deduplicates_shared() {
let followers = vec![
make_follower("https://mastodon.social/users/a/inbox", Some("https://mastodon.social/inbox")),
make_follower("https://mastodon.social/users/b/inbox", Some("https://mastodon.social/inbox")),
make_follower("https://other.instance/users/c/inbox", None),
];
let inboxes = collect_inboxes(&followers);
assert_eq!(inboxes.len(), 2);
let strs: Vec<_> = inboxes.iter().map(|u| u.as_str()).collect();
assert!(strs.contains(&"https://mastodon.social/inbox"));
assert!(strs.contains(&"https://other.instance/users/c/inbox"));
}
#[test]
fn collect_inboxes_falls_back_to_individual_inbox() {
let followers = vec![
make_follower("https://example.com/users/x/inbox", None),
];
let inboxes = collect_inboxes(&followers);
assert_eq!(inboxes.len(), 1);
assert_eq!(inboxes[0].as_str(), "https://example.com/users/x/inbox");
}
}

View File

@@ -0,0 +1,20 @@
use url::Url;
use crate::error::Error;
pub fn extract_user_id_from_url(url: &Url) -> Option<uuid::Uuid> {
let path = url.path();
path.strip_prefix("/users/")
.and_then(|s| s.split('/').next())
.and_then(|s| uuid::Uuid::parse_str(s).ok())
}
pub fn activity_url(base_url: &str) -> Result<Url, Error> {
Url::parse(&format!("{}/activities/{}", base_url, uuid::Uuid::new_v4()))
.map_err(|e| Error::bad_request(anyhow::anyhow!(e)))
}
pub fn actor_url(base_url: &str, user_id: uuid::Uuid) -> Url {
Url::parse(&format!("{}/users/{}", base_url, user_id))
.expect("base_url is always a valid URL prefix")
}

View File

@@ -0,0 +1,18 @@
use async_trait::async_trait;
use url::Url;
#[derive(Debug, Clone)]
pub struct ApUser {
pub id: uuid::Uuid,
pub username: String,
pub bio: Option<String>,
pub avatar_url: Option<Url>,
pub profile_url: Option<Url>,
}
#[async_trait]
pub trait ApUserRepository: Send + Sync {
async fn find_by_id(&self, id: uuid::Uuid) -> anyhow::Result<Option<ApUser>>;
async fn find_by_username(&self, username: &str) -> anyhow::Result<Option<ApUser>>;
async fn count_users(&self) -> anyhow::Result<usize>;
}

View File

@@ -0,0 +1,38 @@
use activitypub_federation::{
config::Data,
fetch::webfinger::{Webfinger, build_webfinger_response, extract_webfinger_name},
};
use axum::{
extract::Query,
http::header,
response::{IntoResponse, Response},
};
use serde::Deserialize;
use crate::data::FederationData;
use crate::error::Error;
#[derive(Deserialize)]
pub struct WebfingerQuery {
resource: String,
}
pub async fn webfinger_handler(
Query(query): Query<WebfingerQuery>,
data: Data<FederationData>,
) -> Result<Response, Error> {
let name = extract_webfinger_name(&query.resource, &data)?;
let user = data
.user_repo
.find_by_username(name)
.await
.map_err(Error::from)?
.ok_or_else(|| Error::not_found(anyhow::anyhow!("user not found")))?;
let ap_id = crate::urls::actor_url(&data.base_url, user.id);
let wf: Webfinger = build_webfinger_response(query.resource, ap_id);
let body = serde_json::to_string(&wf).map_err(|e| Error::from(anyhow::anyhow!(e)))?;
Ok(([(header::CONTENT_TYPE, "application/jrd+json")], body).into_response())
}

View File

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

View File

@@ -0,0 +1,165 @@
use async_trait::async_trait;
use domain::ports::EventHandler;
use domain::{
errors::DomainError,
events::DomainEvent,
ports::{MovieRepository, ReviewRepository},
value_objects::{ReviewId, UserId},
};
use std::sync::Arc;
use activitypub_base::ActivityPubService;
use crate::objects::review_to_ap_object;
use crate::urls::{actor_url, review_url};
pub struct ActivityPubEventHandler {
ap_service: Arc<ActivityPubService>,
movie_repository: Arc<dyn MovieRepository>,
review_repository: Arc<dyn ReviewRepository>,
base_url: String,
}
impl ActivityPubEventHandler {
pub fn new(
ap_service: Arc<ActivityPubService>,
movie_repository: Arc<dyn MovieRepository>,
review_repository: Arc<dyn ReviewRepository>,
base_url: String,
) -> Self {
Self {
ap_service,
movie_repository,
review_repository,
base_url,
}
}
}
#[async_trait]
impl EventHandler for ActivityPubEventHandler {
async fn handle(&self, event: &DomainEvent) -> Result<(), DomainError> {
match event {
DomainEvent::ReviewLogged { review_id, user_id, .. } => self
.on_review_logged(user_id, review_id)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string())),
DomainEvent::ReviewUpdated { review_id, user_id, .. } => self
.on_review_updated(user_id, review_id)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string())),
DomainEvent::ReviewDeleted { review_id, user_id } => self
.on_review_deleted(user_id, review_id)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string())),
DomainEvent::UserUpdated { user_id } => self
.ap_service
.broadcast_actor_update(user_id.value())
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string())),
_ => Ok(()),
}
}
}
impl ActivityPubEventHandler {
async fn on_review_logged(&self, user_id: &UserId, review_id: &ReviewId) -> anyhow::Result<()> {
let review = match self.review_repository.get_review_by_id(review_id).await? {
Some(r) => r,
None => return Ok(()),
};
let ap_id = review_url(&self.base_url, review_id);
let actor = actor_url(&self.base_url, user_id.value());
let movie = self
.movie_repository
.get_movie_by_id(review.movie_id())
.await
.ok()
.flatten();
let movie_title = movie
.as_ref()
.map(|m| m.title().value().to_string())
.unwrap_or_else(|| "Unknown".to_string());
let release_year = movie
.as_ref()
.map(|m| m.release_year().value())
.unwrap_or(0);
let poster_url = movie
.as_ref()
.and_then(|m| m.poster_path())
.map(|p| format!("{}/images/{}", self.base_url, p.value()));
let obj = review_to_ap_object(
&review,
ap_id.clone(),
actor,
movie_title,
release_year,
poster_url,
&self.base_url,
);
let json = serde_json::to_value(obj)?;
self.ap_service
.broadcast_to_followers(user_id.value(), ap_id, json)
.await?;
Ok(())
}
async fn on_review_updated(&self, user_id: &UserId, review_id: &ReviewId) -> anyhow::Result<()> {
let review = match self.review_repository.get_review_by_id(review_id).await? {
Some(r) => r,
None => return Ok(()),
};
let ap_id = review_url(&self.base_url, review_id);
let actor = actor_url(&self.base_url, user_id.value());
let movie = self
.movie_repository
.get_movie_by_id(review.movie_id())
.await
.ok()
.flatten();
let movie_title = movie
.as_ref()
.map(|m| m.title().value().to_string())
.unwrap_or_else(|| "Unknown".to_string());
let release_year = movie
.as_ref()
.map(|m| m.release_year().value())
.unwrap_or(0);
let poster_url = movie
.as_ref()
.and_then(|m| m.poster_path())
.map(|p| format!("{}/images/{}", self.base_url, p.value()));
let obj = review_to_ap_object(
&review,
ap_id,
actor,
movie_title,
release_year,
poster_url,
&self.base_url,
);
let json = serde_json::to_value(obj)?;
self.ap_service
.broadcast_update_to_followers(user_id.value(), json)
.await?;
Ok(())
}
async fn on_review_deleted(&self, user_id: &UserId, review_id: &ReviewId) -> anyhow::Result<()> {
let ap_id = review_url(&self.base_url, review_id);
self.ap_service
.broadcast_delete_to_followers(user_id.value(), ap_id)
.await?;
Ok(())
}
}

View File

@@ -0,0 +1,68 @@
pub mod event_handler;
pub mod objects;
pub mod port;
pub mod remote_review_repository;
pub mod review_handler;
pub(crate) mod urls;
pub mod user_adapter;
// Re-export the generic base types that callers need
pub use activitypub_base::{
ActivityPubService, ApFederationConfig, ApObjectHandler, ApUser, ApUserRepository,
FederationData, FederationRepository, Follower, FollowerStatus, FollowingStatus, RemoteActor,
};
pub use event_handler::ActivityPubEventHandler;
pub use port::{ActivityPubPort, NoopActivityPubService};
pub use remote_review_repository::RemoteReviewRepository;
pub use review_handler::ReviewObjectHandler;
pub use user_adapter::DomainUserRepoAdapter;
pub struct ActivityPubWire {
pub service: std::sync::Arc<dyn ActivityPubPort>,
pub router: axum::Router,
pub event_handler: std::sync::Arc<dyn domain::ports::EventHandler>,
}
pub async fn wire(
federation_repo: std::sync::Arc<dyn FederationRepository>,
review_store: std::sync::Arc<dyn RemoteReviewRepository>,
user_repo: std::sync::Arc<dyn domain::ports::UserRepository>,
movie_repo: std::sync::Arc<dyn domain::ports::MovieRepository>,
review_repo: std::sync::Arc<dyn domain::ports::ReviewRepository>,
diary_repo: std::sync::Arc<dyn domain::ports::DiaryRepository>,
base_url: String,
allow_registration: bool,
) -> anyhow::Result<ActivityPubWire> {
let concrete = std::sync::Arc::new(
ActivityPubService::new(
federation_repo,
std::sync::Arc::new(DomainUserRepoAdapter::new(user_repo, base_url.clone())),
std::sync::Arc::new(ReviewObjectHandler {
movie_repository: std::sync::Arc::clone(&movie_repo),
diary_repository: diary_repo,
review_store,
base_url: base_url.clone(),
}),
base_url.clone(),
allow_registration,
"movies-diary".to_string(),
cfg!(debug_assertions),
)
.await?,
);
let router = concrete.router();
let event_handler = std::sync::Arc::new(ActivityPubEventHandler::new(
std::sync::Arc::clone(&concrete),
movie_repo,
review_repo,
base_url,
)) as std::sync::Arc<dyn domain::ports::EventHandler>;
Ok(ActivityPubWire {
service: concrete as std::sync::Arc<dyn ActivityPubPort>,
router,
event_handler,
})
}

View File

@@ -0,0 +1,143 @@
use activitypub_federation::kinds::object::NoteType;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use url::Url;
use domain::models::Review;
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ApHashtag {
#[serde(rename = "type")]
pub(crate) kind: String,
pub(crate) href: Url,
pub(crate) name: String,
}
pub(crate) fn normalize_hashtag(title: &str) -> String {
title.chars().filter(|c| c.is_alphanumeric()).collect()
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ReviewObject {
#[serde(rename = "type")]
pub(crate) kind: NoteType,
pub(crate) id: Url,
pub(crate) attributed_to: Url,
pub(crate) content: String,
pub(crate) published: DateTime<Utc>,
pub(crate) movie_title: String,
#[serde(default)]
pub(crate) release_year: u16,
#[serde(default)]
pub(crate) poster_url: Option<String>,
pub(crate) rating: u8,
pub(crate) comment: Option<String>,
pub(crate) watched_at: DateTime<Utc>,
#[serde(default)]
pub(crate) tag: Vec<ApHashtag>,
}
/// Serialize a local Review into a ReviewObject for AP delivery.
/// Takes movie metadata explicitly since the handler fetches it separately.
pub fn review_to_ap_object(
review: &Review,
ap_id: Url,
actor_url: Url,
movie_title: String,
release_year: u16,
poster_url: Option<String>,
base_url: &str,
) -> ReviewObject {
let stars: String = "\u{2B50}".repeat(review.rating().value() as usize);
let comment_text = review.comment().map(|c| c.value().to_string());
let year_str = if release_year > 0 {
format!(" ({})", release_year)
} else {
String::new()
};
let watched_str = format!("Watched: {}", review.watched_at().format("%b %-d, %Y"));
let content = match &comment_text {
Some(c) => format!(
"{} {}{}\n{}\n{}",
stars, movie_title, year_str, c, watched_str
),
None => format!("{} {}{}\n{}", stars, movie_title, year_str, watched_str),
};
let normalized = normalize_hashtag(&movie_title);
let tag = vec![
ApHashtag {
kind: "Hashtag".to_string(),
href: Url::parse(&format!("{}/tags/moviesdiary", base_url))
.expect("valid base_url"),
name: "#MoviesDiary".to_string(),
},
ApHashtag {
kind: "Hashtag".to_string(),
href: Url::parse(&format!("{}/tags/{}", base_url, normalized.to_lowercase()))
.expect("valid base_url"),
name: format!("#{}", normalized),
},
];
ReviewObject {
kind: NoteType::default(),
id: ap_id,
attributed_to: actor_url,
content,
published: DateTime::from_naive_utc_and_offset(*review.created_at(), Utc),
movie_title,
release_year,
poster_url,
rating: review.rating().value(),
comment: comment_text,
watched_at: DateTime::from_naive_utc_and_offset(*review.watched_at(), Utc),
tag,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn normalize_hashtag_strips_non_alphanumeric() {
assert_eq!(normalize_hashtag("The Dark Knight"), "TheDarkKnight");
assert_eq!(normalize_hashtag("Schindler's List"), "SchindlersList");
assert_eq!(normalize_hashtag("2001: A Space Odyssey"), "2001ASpaceOdyssey");
}
#[test]
fn review_to_ap_object_includes_two_hashtags() {
use chrono::NaiveDateTime;
use domain::{
models::{Review, ReviewSource},
value_objects::{MovieId, Rating, ReviewId, UserId},
};
let review = Review::from_persistence(
ReviewId::generate(),
MovieId::from_uuid(uuid::Uuid::new_v4()),
UserId::from_uuid(uuid::Uuid::new_v4()),
Rating::new(4).unwrap(),
None,
NaiveDateTime::parse_from_str("2024-01-01 00:00:00", "%Y-%m-%d %H:%M:%S").unwrap(),
NaiveDateTime::parse_from_str("2024-01-01 00:00:00", "%Y-%m-%d %H:%M:%S").unwrap(),
ReviewSource::Local,
);
let obj = review_to_ap_object(
&review,
"https://example.com/reviews/1".parse().unwrap(),
"https://example.com/users/1".parse().unwrap(),
"Dune".to_string(),
2021,
None,
"https://example.com",
);
assert_eq!(obj.tag.len(), 2);
let names: Vec<&str> = obj.tag.iter().map(|t| t.name.as_str()).collect();
assert!(names.contains(&"#MoviesDiary"));
assert!(names.contains(&"#Dune"));
}
}

View File

@@ -0,0 +1,157 @@
use async_trait::async_trait;
use uuid::Uuid;
use activitypub_base::{ActivityPubService, BlockedDomain, RemoteActor};
#[async_trait]
pub trait ActivityPubPort: Send + Sync {
async fn actor_json(&self, user_id: &str) -> anyhow::Result<String>;
async fn count_following(&self, local_user_id: Uuid) -> anyhow::Result<usize>;
async fn count_accepted_followers(&self, local_user_id: Uuid) -> anyhow::Result<usize>;
async fn get_pending_followers(&self, local_user_id: Uuid) -> anyhow::Result<Vec<RemoteActor>>;
async fn follow(&self, local_user_id: Uuid, handle: &str) -> anyhow::Result<()>;
async fn unfollow(&self, local_user_id: Uuid, actor_url: &str) -> anyhow::Result<()>;
async fn accept_follower(
&self,
local_user_id: Uuid,
remote_actor_url: &str,
) -> anyhow::Result<()>;
async fn reject_follower(
&self,
local_user_id: Uuid,
remote_actor_url: &str,
) -> anyhow::Result<()>;
async fn get_following(&self, local_user_id: Uuid) -> anyhow::Result<Vec<RemoteActor>>;
async fn get_accepted_followers(&self, local_user_id: Uuid)
-> anyhow::Result<Vec<RemoteActor>>;
async fn remove_follower(&self, local_user_id: Uuid, actor_url: &str) -> anyhow::Result<()>;
async fn block_actor(&self, local_user_id: Uuid, actor_url: &str) -> anyhow::Result<()>;
async fn unblock_actor(&self, local_user_id: Uuid, actor_url: &str) -> anyhow::Result<()>;
async fn get_blocked_actors(&self, local_user_id: Uuid) -> anyhow::Result<Vec<RemoteActor>>;
async fn add_blocked_domain(&self, domain: &str, reason: Option<&str>) -> anyhow::Result<()>;
async fn remove_blocked_domain(&self, domain: &str) -> anyhow::Result<()>;
async fn get_blocked_domains(&self) -> anyhow::Result<Vec<BlockedDomain>>;
}
#[async_trait]
impl ActivityPubPort for ActivityPubService {
async fn actor_json(&self, user_id: &str) -> anyhow::Result<String> {
self.actor_json(user_id).await
}
async fn count_following(&self, local_user_id: Uuid) -> anyhow::Result<usize> {
self.count_following(local_user_id).await
}
async fn count_accepted_followers(&self, local_user_id: Uuid) -> anyhow::Result<usize> {
self.count_accepted_followers(local_user_id).await
}
async fn get_pending_followers(&self, local_user_id: Uuid) -> anyhow::Result<Vec<RemoteActor>> {
self.get_pending_followers(local_user_id).await
}
async fn follow(&self, local_user_id: Uuid, handle: &str) -> anyhow::Result<()> {
self.follow(local_user_id, handle).await
}
async fn unfollow(&self, local_user_id: Uuid, actor_url: &str) -> anyhow::Result<()> {
self.unfollow(local_user_id, actor_url).await
}
async fn accept_follower(
&self,
local_user_id: Uuid,
remote_actor_url: &str,
) -> anyhow::Result<()> {
self.accept_follower(local_user_id, remote_actor_url).await
}
async fn reject_follower(
&self,
local_user_id: Uuid,
remote_actor_url: &str,
) -> anyhow::Result<()> {
self.reject_follower(local_user_id, remote_actor_url).await
}
async fn get_following(&self, local_user_id: Uuid) -> anyhow::Result<Vec<RemoteActor>> {
self.get_following(local_user_id).await
}
async fn get_accepted_followers(
&self,
local_user_id: Uuid,
) -> anyhow::Result<Vec<RemoteActor>> {
self.get_accepted_followers(local_user_id).await
}
async fn remove_follower(&self, local_user_id: Uuid, actor_url: &str) -> anyhow::Result<()> {
self.remove_follower(local_user_id, actor_url).await
}
async fn block_actor(&self, local_user_id: Uuid, actor_url: &str) -> anyhow::Result<()> {
self.block_actor(local_user_id, actor_url).await
}
async fn unblock_actor(&self, local_user_id: Uuid, actor_url: &str) -> anyhow::Result<()> {
self.unblock_actor(local_user_id, actor_url).await
}
async fn get_blocked_actors(&self, local_user_id: Uuid) -> anyhow::Result<Vec<RemoteActor>> {
self.get_blocked_actors(local_user_id).await
}
async fn add_blocked_domain(&self, domain: &str, reason: Option<&str>) -> anyhow::Result<()> {
self.add_blocked_domain(domain, reason).await
}
async fn remove_blocked_domain(&self, domain: &str) -> anyhow::Result<()> {
self.remove_blocked_domain(domain).await
}
async fn get_blocked_domains(&self) -> anyhow::Result<Vec<BlockedDomain>> {
self.get_blocked_domains().await
}
}
pub struct NoopActivityPubService;
#[async_trait]
impl ActivityPubPort for NoopActivityPubService {
async fn actor_json(&self, _: &str) -> anyhow::Result<String> {
Ok(String::new())
}
async fn count_following(&self, _: Uuid) -> anyhow::Result<usize> {
Ok(0)
}
async fn count_accepted_followers(&self, _: Uuid) -> anyhow::Result<usize> {
Ok(0)
}
async fn get_pending_followers(&self, _: Uuid) -> anyhow::Result<Vec<RemoteActor>> {
Ok(vec![])
}
async fn follow(&self, _: Uuid, _: &str) -> anyhow::Result<()> {
Ok(())
}
async fn unfollow(&self, _: Uuid, _: &str) -> anyhow::Result<()> {
Ok(())
}
async fn accept_follower(&self, _: Uuid, _: &str) -> anyhow::Result<()> {
Ok(())
}
async fn reject_follower(&self, _: Uuid, _: &str) -> anyhow::Result<()> {
Ok(())
}
async fn get_following(&self, _: Uuid) -> anyhow::Result<Vec<RemoteActor>> {
Ok(vec![])
}
async fn get_accepted_followers(&self, _: Uuid) -> anyhow::Result<Vec<RemoteActor>> {
Ok(vec![])
}
async fn remove_follower(&self, _: Uuid, _: &str) -> anyhow::Result<()> {
Ok(())
}
async fn block_actor(&self, _: Uuid, _: &str) -> anyhow::Result<()> {
Ok(())
}
async fn unblock_actor(&self, _: Uuid, _: &str) -> anyhow::Result<()> {
Ok(())
}
async fn get_blocked_actors(&self, _: Uuid) -> anyhow::Result<Vec<RemoteActor>> {
Ok(vec![])
}
async fn add_blocked_domain(&self, _: &str, _: Option<&str>) -> anyhow::Result<()> {
Ok(())
}
async fn remove_blocked_domain(&self, _: &str) -> anyhow::Result<()> {
Ok(())
}
async fn get_blocked_domains(&self) -> anyhow::Result<Vec<BlockedDomain>> {
Ok(vec![])
}
}

View File

@@ -0,0 +1,29 @@
use anyhow::Result;
use async_trait::async_trait;
use chrono::NaiveDateTime;
use domain::models::Review;
#[async_trait]
pub trait RemoteReviewRepository: Send + Sync {
async fn save_remote_review(
&self,
review: &Review,
ap_id: &str,
movie_title: &str,
release_year: u16,
poster_url: Option<&str>,
) -> Result<()>;
async fn delete_remote_review(&self, ap_id: &str, actor_url: &str) -> Result<()>;
async fn update_remote_review(
&self,
ap_id: &str,
actor_url: &str,
rating: u8,
comment: Option<&str>,
watched_at: NaiveDateTime,
) -> Result<()>;
async fn delete_by_actor(&self, actor_url: &str) -> Result<()>;
}

View File

@@ -0,0 +1,247 @@
use std::sync::Arc;
use activitypub_base::ApObjectHandler;
use async_trait::async_trait;
use domain::{
models::{Review, ReviewSource},
ports::{DiaryRepository, MovieRepository},
value_objects::{Comment, MovieId, Rating, ReviewId, UserId},
};
use url::Url;
use crate::objects::{ReviewObject, review_to_ap_object};
use crate::remote_review_repository::RemoteReviewRepository;
use crate::urls::{actor_url, review_url};
pub struct ReviewObjectHandler {
pub movie_repository: Arc<dyn MovieRepository>,
pub diary_repository: Arc<dyn DiaryRepository>,
pub review_store: Arc<dyn RemoteReviewRepository>,
pub base_url: String,
}
#[async_trait]
impl ApObjectHandler for ReviewObjectHandler {
async fn get_local_objects_for_user(
&self,
user_id: uuid::Uuid,
) -> anyhow::Result<Vec<(Url, serde_json::Value)>> {
let domain_user_id = UserId::from_uuid(user_id);
let history = self
.diary_repository
.get_user_history(&domain_user_id)
.await?;
let mut results = Vec::new();
for entry in history {
let review = entry.review();
if !matches!(review.source(), ReviewSource::Local) {
continue;
}
let ap_id = review_url(&self.base_url, review.id());
let actor_url = actor_url(&self.base_url, user_id);
let movie = self
.movie_repository
.get_movie_by_id(review.movie_id())
.await
.ok()
.flatten();
let movie_title = movie
.as_ref()
.map(|m| m.title().value().to_string())
.unwrap_or_else(|| "Unknown".to_string());
let release_year = movie
.as_ref()
.map(|m| m.release_year().value())
.unwrap_or(0);
let poster_url = movie
.as_ref()
.and_then(|m| m.poster_path())
.map(|p| format!("{}/images/{}", self.base_url, p.value()));
let obj = review_to_ap_object(
review,
ap_id.clone(),
actor_url,
movie_title,
release_year,
poster_url,
&self.base_url,
);
let json = serde_json::to_value(obj)?;
results.push((ap_id, json));
}
Ok(results)
}
async fn get_local_objects_page(
&self,
user_id: uuid::Uuid,
before: Option<chrono::DateTime<chrono::Utc>>,
limit: usize,
) -> anyhow::Result<Vec<(url::Url, serde_json::Value, chrono::DateTime<chrono::Utc>)>> {
use domain::value_objects::UserId;
let domain_user_id = UserId::from_uuid(user_id);
let history = self
.diary_repository
.get_user_history(&domain_user_id)
.await?;
let mut results = Vec::new();
for entry in history {
let review = entry.review();
if !matches!(review.source(), ReviewSource::Local) {
continue;
}
let published =
chrono::DateTime::from_naive_utc_and_offset(*review.watched_at(), chrono::Utc);
if let Some(cutoff) = before {
if published >= cutoff {
continue;
}
}
let ap_id = review_url(&self.base_url, review.id());
let actor_url = actor_url(&self.base_url, user_id);
let movie = self
.movie_repository
.get_movie_by_id(review.movie_id())
.await
.ok()
.flatten();
let movie_title = movie
.as_ref()
.map(|m| m.title().value().to_string())
.unwrap_or_else(|| "Unknown".to_string());
let release_year = movie.as_ref().map(|m| m.release_year().value()).unwrap_or(0);
let poster_url = movie
.as_ref()
.and_then(|m| m.poster_path())
.map(|p| format!("{}/images/{}", self.base_url, p.value()));
let obj = review_to_ap_object(
review,
ap_id.clone(),
actor_url,
movie_title,
release_year,
poster_url,
&self.base_url,
);
let json = serde_json::to_value(obj)?;
results.push((ap_id, json, published));
if results.len() >= limit {
break;
}
}
Ok(results)
}
async fn on_create(
&self,
_ap_id: &Url,
_actor_url: &Url,
object: serde_json::Value,
) -> anyhow::Result<()> {
let obj: ReviewObject = match serde_json::from_value(object) {
Ok(o) => o,
Err(e) => {
tracing::debug!("ignoring unrecognized Create object: {}", e);
return Ok(());
}
};
let actor_url_str = obj.attributed_to.to_string();
let review_id = ReviewId::generate();
let movie_id = MovieId::from_uuid(uuid::Uuid::new_v5(
&uuid::Uuid::NAMESPACE_URL,
obj.movie_title.as_bytes(),
));
let user_id = UserId::from_uuid(uuid::Uuid::new_v5(
&uuid::Uuid::NAMESPACE_URL,
actor_url_str.as_bytes(),
));
let rating = Rating::new(obj.rating.min(5))?;
let comment = obj.comment.map(Comment::new).transpose()?;
let review = Review::from_persistence(
review_id,
movie_id,
user_id,
rating,
comment,
obj.watched_at.naive_utc(),
obj.published.naive_utc(),
ReviewSource::Remote {
actor_url: actor_url_str,
},
);
self.review_store
.save_remote_review(
&review,
obj.id.as_str(),
&obj.movie_title,
obj.release_year,
obj.poster_url.as_deref(),
)
.await?;
Ok(())
}
async fn on_update(
&self,
ap_id: &Url,
actor_url: &Url,
object: serde_json::Value,
) -> anyhow::Result<()> {
let obj: ReviewObject = match serde_json::from_value(object) {
Ok(o) => o,
Err(_) => {
tracing::debug!(actor = %actor_url, "ignoring non-review Update activity");
return Ok(());
}
};
if obj.attributed_to != *actor_url {
anyhow::bail!("update actor does not match object attributed_to");
}
self.review_store
.update_remote_review(
ap_id.as_str(),
actor_url.as_str(),
obj.rating.min(5),
obj.comment.as_deref(),
obj.watched_at.naive_utc(),
)
.await?;
Ok(())
}
async fn on_delete(&self, ap_id: &Url, actor_url: &Url) -> anyhow::Result<()> {
self.review_store
.delete_remote_review(ap_id.as_str(), actor_url.as_str())
.await
}
async fn on_actor_removed(&self, actor_url: &Url) -> anyhow::Result<()> {
self.review_store.delete_by_actor(actor_url.as_str()).await
}
async fn count_local_posts(&self) -> anyhow::Result<u64> {
self.diary_repository
.count_local_posts()
.await
.map_err(|e| anyhow::anyhow!(e.to_string()))
}
}

View File

@@ -0,0 +1,14 @@
use domain::value_objects::ReviewId;
use url::Url;
/// Builds the canonical actor URL: `{base_url}/users/{user_id}`
pub fn actor_url(base_url: &str, user_id: uuid::Uuid) -> Url {
Url::parse(&format!("{}/users/{}", base_url, user_id))
.expect("base_url is always a valid URL prefix")
}
/// Builds the canonical review URL: `{base_url}/reviews/{review_id}`
pub fn review_url(base_url: &str, review_id: &ReviewId) -> Url {
Url::parse(&format!("{}/reviews/{}", base_url, review_id.value()))
.expect("base_url is always a valid URL prefix")
}

View File

@@ -0,0 +1,51 @@
use std::sync::Arc;
use activitypub_base::{ApUser, ApUserRepository};
use async_trait::async_trait;
use domain::{ports::UserRepository, value_objects::UserId};
use url::Url;
pub struct DomainUserRepoAdapter {
pub repo: Arc<dyn UserRepository>,
pub base_url: String,
}
impl DomainUserRepoAdapter {
pub fn new(repo: Arc<dyn UserRepository>, base_url: String) -> Self {
Self { repo, base_url }
}
fn build_user(&self, u: &domain::models::User) -> ApUser {
let avatar_url = u.avatar_path().and_then(|p| {
Url::parse(&format!("{}/images/{}", self.base_url, p)).ok()
});
let profile_url = Url::parse(&format!("{}/u/{}", self.base_url, u.username().value())).ok();
ApUser {
id: u.id().value(),
username: u.username().value().to_string(),
bio: u.bio().map(|s| s.to_string()),
avatar_url,
profile_url,
}
}
}
#[async_trait]
impl ApUserRepository for DomainUserRepoAdapter {
async fn find_by_id(&self, id: uuid::Uuid) -> anyhow::Result<Option<ApUser>> {
let user_id = UserId::from_uuid(id);
Ok(self.repo.find_by_id(&user_id).await?.as_ref().map(|u| self.build_user(u)))
}
async fn find_by_username(&self, username: &str) -> anyhow::Result<Option<ApUser>> {
use domain::value_objects::Username;
let uname = Username::new(username.to_string()).map_err(|e| anyhow::anyhow!(e.to_string()))?;
Ok(self.repo.find_by_username(&uname).await?.as_ref().map(|u| self.build_user(u)))
}
async fn count_users(&self) -> anyhow::Result<usize> {
Ok(self.repo.list_with_stats().await
.map_err(|e| anyhow::anyhow!(e.to_string()))?
.len())
}
}

View File

@@ -4,3 +4,12 @@ version = "0.1.0"
edition = "2024"
[dependencies]
async-trait = { workspace = true }
domain = { workspace = true }
anyhow = { workspace = true }
chrono = { workspace = true }
uuid = { workspace = true }
serde = { version = "1.0", features = ["derive"] }
jsonwebtoken = "9"
argon2 = { version = "0.5", features = ["std"] }
rand_core = { version = "0.6", features = ["getrandom"] }

View File

@@ -1,14 +1,118 @@
pub fn add(left: u64, right: u64) -> u64 {
left + right
use argon2::{
Argon2,
password_hash::{PasswordHasher as _, PasswordVerifier, SaltString},
};
use async_trait::async_trait;
use chrono::{Duration, Utc};
use jsonwebtoken::{DecodingKey, EncodingKey, Header, Validation, decode, encode};
use rand_core::OsRng;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use domain::{
errors::DomainError,
ports::{AuthService, GeneratedToken, PasswordHasher},
value_objects::{PasswordHash, UserId},
};
pub struct AuthConfig {
secret: String,
ttl_seconds: u64,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn it_works() {
let result = add(2, 2);
assert_eq!(result, 4);
impl AuthConfig {
pub fn from_env() -> anyhow::Result<Self> {
let secret = std::env::var("JWT_SECRET")
.map_err(|_| anyhow::anyhow!("JWT_SECRET env var is required"))?;
if secret.is_empty() {
anyhow::bail!("JWT_SECRET must not be empty");
}
let ttl_seconds = std::env::var("JWT_TTL_SECONDS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(86400u64);
Ok(Self {
secret,
ttl_seconds,
})
}
}
#[derive(Serialize, Deserialize)]
struct Claims {
sub: String,
exp: u64,
}
pub struct JwtAuthService {
config: AuthConfig,
}
impl JwtAuthService {
pub fn new(config: AuthConfig) -> Self {
Self { config }
}
}
#[async_trait]
impl AuthService for JwtAuthService {
async fn generate_token(&self, user_id: &UserId) -> Result<GeneratedToken, DomainError> {
let expires_at = Utc::now() + Duration::seconds(self.config.ttl_seconds as i64);
let claims = Claims {
sub: user_id.value().to_string(),
exp: expires_at.timestamp() as u64,
};
let token = encode(
&Header::default(),
&claims,
&EncodingKey::from_secret(self.config.secret.as_bytes()),
)
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
Ok(GeneratedToken { token, expires_at })
}
async fn validate_token(&self, token: &str) -> Result<UserId, DomainError> {
let data = decode::<Claims>(
token,
&DecodingKey::from_secret(self.config.secret.as_bytes()),
&Validation::default(),
)
.map_err(|_| DomainError::Unauthorized("Invalid or expired token".into()))?;
let uuid = Uuid::parse_str(&data.claims.sub)
.map_err(|_| DomainError::Unauthorized("Invalid token subject".into()))?;
Ok(UserId::from_uuid(uuid))
}
}
pub struct Argon2PasswordHasher;
#[async_trait]
impl PasswordHasher for Argon2PasswordHasher {
async fn hash(&self, plain_password: &str) -> Result<PasswordHash, DomainError> {
let salt = SaltString::generate(&mut OsRng);
let hash = Argon2::default()
.hash_password(plain_password.as_bytes(), &salt)
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?
.to_string();
PasswordHash::new(hash).map_err(|e| DomainError::InfrastructureError(e.to_string()))
}
async fn verify(&self, plain_password: &str, hash: &PasswordHash) -> Result<bool, DomainError> {
let parsed = argon2::password_hash::PasswordHash::new(hash.value())
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
Ok(Argon2::default()
.verify_password(plain_password.as_bytes(), &parsed)
.is_ok())
}
}
pub fn create() -> anyhow::Result<(
std::sync::Arc<dyn domain::ports::AuthService>,
std::sync::Arc<dyn domain::ports::PasswordHasher>,
)> {
let config = AuthConfig::from_env()?;
Ok((
std::sync::Arc::new(JwtAuthService::new(config)),
std::sync::Arc::new(Argon2PasswordHasher),
))
}

View File

@@ -0,0 +1,12 @@
[package]
name = "event-payload"
version = "0.1.0"
edition = "2024"
[dependencies]
domain = { workspace = true }
serde = { workspace = true }
chrono = { workspace = true }
uuid = { workspace = true }
anyhow = { workspace = true }
serde_json = { workspace = true }

View File

@@ -0,0 +1,233 @@
use chrono::NaiveDateTime;
use domain::{
errors::DomainError,
events::DomainEvent,
value_objects::{ExternalMetadataId, MovieId, PosterPath, Rating, ReviewId, UserId},
};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Serialize, Deserialize, PartialEq)]
#[serde(tag = "type", content = "data")]
pub enum EventPayload {
ReviewLogged {
review_id: String,
movie_id: String,
user_id: String,
rating: u8,
watched_at: i64,
},
ReviewUpdated {
review_id: String,
movie_id: String,
user_id: String,
rating: u8,
watched_at: i64,
},
MovieDiscovered {
movie_id: String,
external_metadata_id: String,
},
MovieDeleted {
movie_id: String,
poster_path: Option<String>,
},
UserUpdated {
user_id: String,
},
ReviewDeleted {
review_id: String,
user_id: String,
},
}
impl EventPayload {
pub fn event_type(&self) -> &'static str {
match self {
EventPayload::ReviewLogged { .. } => "ReviewLogged",
EventPayload::ReviewUpdated { .. } => "ReviewUpdated",
EventPayload::MovieDiscovered { .. } => "MovieDiscovered",
EventPayload::MovieDeleted { .. } => "MovieDeleted",
EventPayload::UserUpdated { .. } => "UserUpdated",
EventPayload::ReviewDeleted { .. } => "ReviewDeleted",
}
}
}
fn parse_uuid(s: &str, field: &str) -> Result<Uuid, DomainError> {
Uuid::parse_str(s)
.map_err(|e| DomainError::InfrastructureError(format!("{field}: {e}")))
}
fn parse_ts(ts: i64) -> Result<NaiveDateTime, DomainError> {
chrono::DateTime::from_timestamp(ts, 0)
.map(|dt| dt.naive_utc())
.ok_or_else(|| DomainError::InfrastructureError(format!("invalid timestamp: {ts}")))
}
impl From<&DomainEvent> for EventPayload {
fn from(event: &DomainEvent) -> Self {
match event {
DomainEvent::ReviewLogged { review_id, movie_id, user_id, rating, watched_at } => {
EventPayload::ReviewLogged {
review_id: review_id.value().to_string(),
movie_id: movie_id.value().to_string(),
user_id: user_id.value().to_string(),
rating: rating.value(),
watched_at: watched_at.and_utc().timestamp(),
}
}
DomainEvent::ReviewUpdated { review_id, movie_id, user_id, rating, watched_at } => {
EventPayload::ReviewUpdated {
review_id: review_id.value().to_string(),
movie_id: movie_id.value().to_string(),
user_id: user_id.value().to_string(),
rating: rating.value(),
watched_at: watched_at.and_utc().timestamp(),
}
}
DomainEvent::MovieDiscovered { movie_id, external_metadata_id } => {
EventPayload::MovieDiscovered {
movie_id: movie_id.value().to_string(),
external_metadata_id: external_metadata_id.value().to_owned(),
}
}
DomainEvent::MovieDeleted { movie_id, poster_path } => EventPayload::MovieDeleted {
movie_id: movie_id.value().to_string(),
poster_path: poster_path.as_ref().map(|p| p.value().to_string()),
},
DomainEvent::UserUpdated { user_id } => EventPayload::UserUpdated {
user_id: user_id.value().to_string(),
},
DomainEvent::ReviewDeleted { review_id, user_id } => EventPayload::ReviewDeleted {
review_id: review_id.value().to_string(),
user_id: user_id.value().to_string(),
},
}
}
}
impl TryFrom<EventPayload> for DomainEvent {
type Error = DomainError;
fn try_from(payload: EventPayload) -> Result<Self, DomainError> {
match payload {
EventPayload::ReviewLogged { review_id, movie_id, user_id, rating, watched_at } => {
Ok(DomainEvent::ReviewLogged {
review_id: ReviewId::from_uuid(parse_uuid(&review_id, "review_id")?),
movie_id: MovieId::from_uuid(parse_uuid(&movie_id, "movie_id")?),
user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?),
rating: Rating::new(rating)?,
watched_at: parse_ts(watched_at)?,
})
}
EventPayload::ReviewUpdated { review_id, movie_id, user_id, rating, watched_at } => {
Ok(DomainEvent::ReviewUpdated {
review_id: ReviewId::from_uuid(parse_uuid(&review_id, "review_id")?),
movie_id: MovieId::from_uuid(parse_uuid(&movie_id, "movie_id")?),
user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?),
rating: Rating::new(rating)?,
watched_at: parse_ts(watched_at)?,
})
}
EventPayload::MovieDiscovered { movie_id, external_metadata_id } => {
Ok(DomainEvent::MovieDiscovered {
movie_id: MovieId::from_uuid(parse_uuid(&movie_id, "movie_id")?),
external_metadata_id: ExternalMetadataId::new(external_metadata_id)?,
})
}
EventPayload::MovieDeleted { movie_id, poster_path } => {
let movie_id = MovieId::from_uuid(parse_uuid(&movie_id, "movie_id")?);
let poster_path = poster_path
.map(|p| PosterPath::new(p))
.transpose()
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
Ok(DomainEvent::MovieDeleted { movie_id, poster_path })
}
EventPayload::UserUpdated { user_id } => {
Ok(DomainEvent::UserUpdated {
user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?),
})
}
EventPayload::ReviewDeleted { review_id, user_id } => {
Ok(DomainEvent::ReviewDeleted {
review_id: ReviewId::from_uuid(parse_uuid(&review_id, "review_id")?),
user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?),
})
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn fixed_dt() -> NaiveDateTime {
chrono::DateTime::from_timestamp(1_700_000_000, 0).unwrap().naive_utc()
}
fn review_logged() -> DomainEvent {
DomainEvent::ReviewLogged {
review_id: ReviewId::from_uuid(Uuid::new_v4()),
movie_id: MovieId::from_uuid(Uuid::new_v4()),
user_id: UserId::from_uuid(Uuid::new_v4()),
rating: Rating::new(4).unwrap(),
watched_at: fixed_dt(),
}
}
fn review_updated() -> DomainEvent {
DomainEvent::ReviewUpdated {
review_id: ReviewId::from_uuid(Uuid::new_v4()),
movie_id: MovieId::from_uuid(Uuid::new_v4()),
user_id: UserId::from_uuid(Uuid::new_v4()),
rating: Rating::new(3).unwrap(),
watched_at: fixed_dt(),
}
}
fn movie_discovered() -> DomainEvent {
DomainEvent::MovieDiscovered {
movie_id: MovieId::from_uuid(Uuid::new_v4()),
external_metadata_id: ExternalMetadataId::new("tt1234567".into()).unwrap(),
}
}
fn round_trip(event: DomainEvent) {
let payload = EventPayload::from(&event);
let json = serde_json::to_string(&payload).expect("serialize");
let back: EventPayload = serde_json::from_str(&json).expect("deserialize");
let recovered = DomainEvent::try_from(back).expect("try_from");
assert_eq!(EventPayload::from(&event), EventPayload::from(&recovered));
}
#[test]
fn round_trip_review_logged() {
round_trip(review_logged());
}
#[test]
fn round_trip_review_updated() {
round_trip(review_updated());
}
#[test]
fn round_trip_movie_discovered() {
round_trip(movie_discovered());
}
#[test]
fn serialized_format_is_tagged() {
let payload = EventPayload::from(&movie_discovered());
let json = serde_json::to_string(&payload).unwrap();
assert!(json.contains(r#""type":"MovieDiscovered""#));
assert!(json.contains(r#""data":"#));
}
#[test]
fn event_type_strings() {
assert_eq!(EventPayload::from(&review_logged()).event_type(), "ReviewLogged");
assert_eq!(EventPayload::from(&review_updated()).event_type(), "ReviewUpdated");
assert_eq!(EventPayload::from(&movie_discovered()).event_type(), "MovieDiscovered");
}
}

View File

@@ -0,0 +1,11 @@
[package]
name = "event-publisher"
version = "0.1.0"
edition = "2024"
[dependencies]
domain = { workspace = true }
async-trait = { workspace = true }
tokio = { workspace = true }
tracing = { workspace = true }
futures = { workspace = true }

View File

@@ -0,0 +1,142 @@
use async_trait::async_trait;
use domain::{
errors::DomainError,
events::{AckHandle, DomainEvent, EventEnvelope},
ports::{EventConsumer, EventPublisher},
};
use futures::stream::{self, BoxStream};
use std::sync::Arc;
use tokio::sync::Mutex;
use tokio::sync::mpsc;
pub use domain::ports::EventHandler;
pub struct EventPublisherConfig {
pub channel_buffer: usize,
}
impl EventPublisherConfig {
pub fn from_env() -> Self {
let channel_buffer = std::env::var("EVENT_CHANNEL_BUFFER")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(128);
Self { channel_buffer }
}
}
pub struct ChannelEventPublisher {
sender: mpsc::Sender<DomainEvent>,
}
#[async_trait]
impl EventPublisher for ChannelEventPublisher {
async fn publish(&self, event: &DomainEvent) -> Result<(), DomainError> {
self.sender
.send(event.clone())
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))
}
}
struct NoopAck;
#[async_trait]
impl AckHandle for NoopAck {
async fn ack(&self) -> Result<(), DomainError> { Ok(()) }
async fn nack(&self) -> Result<(), DomainError> { Ok(()) }
}
pub struct ChannelEventConsumer {
receiver: Arc<Mutex<mpsc::Receiver<DomainEvent>>>,
}
impl EventConsumer for ChannelEventConsumer {
fn consume(&self) -> BoxStream<'_, Result<EventEnvelope, DomainError>> {
let receiver = Arc::clone(&self.receiver);
Box::pin(stream::unfold(receiver, |rx| async move {
let event = rx.lock().await.recv().await?;
let envelope = EventEnvelope::new(event, Box::new(NoopAck));
Some((Ok(envelope), rx))
}))
}
}
pub struct NoopEventPublisher;
#[async_trait]
impl EventPublisher for NoopEventPublisher {
async fn publish(&self, _event: &DomainEvent) -> Result<(), DomainError> {
Ok(())
}
}
pub fn create_event_channel(
config: EventPublisherConfig,
) -> (ChannelEventPublisher, ChannelEventConsumer) {
let (tx, rx) = mpsc::channel(config.channel_buffer);
(
ChannelEventPublisher { sender: tx },
ChannelEventConsumer {
receiver: Arc::new(Mutex::new(rx)),
},
)
}
#[cfg(test)]
mod tests {
use super::*;
use domain::{
events::DomainEvent,
value_objects::{ExternalMetadataId, MovieId},
};
use futures::StreamExt;
fn movie_discovered() -> DomainEvent {
DomainEvent::MovieDiscovered {
movie_id: MovieId::generate(),
external_metadata_id: ExternalMetadataId::new("tt1234567".into()).unwrap(),
}
}
#[tokio::test]
async fn consumer_yields_published_events() {
let config = EventPublisherConfig { channel_buffer: 8 };
let (publisher, consumer) = create_event_channel(config);
publisher.publish(&movie_discovered()).await.unwrap();
drop(publisher);
let mut stream = consumer.consume();
let envelope = stream.next().await.unwrap().unwrap();
assert!(matches!(envelope.event, DomainEvent::MovieDiscovered { .. }));
assert!(stream.next().await.is_none());
}
#[tokio::test]
async fn consumer_yields_multiple_events_in_order() {
let config = EventPublisherConfig { channel_buffer: 8 };
let (publisher, consumer) = create_event_channel(config);
publisher.publish(&movie_discovered()).await.unwrap();
publisher.publish(&movie_discovered()).await.unwrap();
drop(publisher);
let mut stream = consumer.consume();
let first = stream.next().await.unwrap().unwrap();
let second = stream.next().await.unwrap().unwrap();
assert!(matches!(first.event, DomainEvent::MovieDiscovered { .. }));
assert!(matches!(second.event, DomainEvent::MovieDiscovered { .. }));
assert!(stream.next().await.is_none());
}
#[tokio::test]
async fn stream_ends_when_publisher_dropped() {
let config = EventPublisherConfig { channel_buffer: 8 };
let (publisher, consumer) = create_event_channel(config);
drop(publisher);
let mut stream = consumer.consume();
assert!(stream.next().await.is_none());
}
}

View File

@@ -0,0 +1,14 @@
[package]
name = "export"
version = "0.1.0"
edition = "2024"
[dependencies]
domain = { workspace = true }
async-trait = { workspace = true }
serde_json = { workspace = true }
chrono = { workspace = true }
[dev-dependencies]
uuid = { workspace = true }
tokio = { workspace = true }

View File

@@ -0,0 +1,225 @@
use async_trait::async_trait;
use domain::{
errors::DomainError,
models::{DiaryEntry, ExportFormat},
ports::DiaryExporter,
};
pub struct ExportAdapter;
#[async_trait]
impl DiaryExporter for ExportAdapter {
async fn serialize_entries(
&self,
entries: &[DiaryEntry],
format: ExportFormat,
) -> Result<Vec<u8>, DomainError> {
match format {
ExportFormat::Csv => serialize_csv(entries),
ExportFormat::Json => serialize_json(entries),
}
}
}
fn serialize_csv(entries: &[DiaryEntry]) -> Result<Vec<u8>, DomainError> {
let mut out =
String::from("title,year,director,rating,comment,watched_at,external_metadata_id\n");
for e in entries {
let title = csv_escape(e.movie().title().value());
let year = e.movie().release_year().value();
let director = e.movie().director().map(csv_escape).unwrap_or_default();
let rating = e.review().rating().value();
let comment = e
.review()
.comment()
.map(|c| csv_escape(c.value()))
.unwrap_or_default();
let watched_at = e.review().watched_at().format("%Y-%m-%d");
let ext_id = e
.movie()
.external_metadata_id()
.map(|id| id.value().to_string())
.unwrap_or_default();
out.push_str(&format!(
"{},{},{},{},{},{},{}\n",
title, year, director, rating, comment, watched_at, ext_id
));
}
Ok(out.into_bytes())
}
fn csv_escape(s: &str) -> String {
if s.contains(',') || s.contains('"') || s.contains('\n') {
format!("\"{}\"", s.replace('"', "\"\""))
} else {
s.to_string()
}
}
fn serialize_json(entries: &[DiaryEntry]) -> Result<Vec<u8>, DomainError> {
let arr: Vec<serde_json::Value> = entries
.iter()
.map(|e| {
serde_json::json!({
"title": e.movie().title().value(),
"year": e.movie().release_year().value(),
"director": e.movie().director(),
"rating": e.review().rating().value(),
"comment": e.review().comment().map(|c| c.value()),
"watched_at": e.review().watched_at().format("%Y-%m-%d").to_string(),
"external_metadata_id": e.movie().external_metadata_id().map(|id| id.value()),
})
})
.collect();
serde_json::to_vec_pretty(&arr).map_err(|e| DomainError::InfrastructureError(e.to_string()))
}
#[cfg(test)]
mod tests {
use super::ExportAdapter;
use domain::{
models::{DiaryEntry, ExportFormat, Movie, Review},
ports::DiaryExporter,
value_objects::{ExternalMetadataId, MovieTitle, Rating, ReleaseYear},
};
fn make_entry(
title: &str,
year: u16,
director: Option<&str>,
rating: u8,
comment: Option<&str>,
) -> DiaryEntry {
make_entry_full(title, year, director, rating, comment, None)
}
fn make_entry_full(
title: &str,
year: u16,
director: Option<&str>,
rating: u8,
comment: Option<&str>,
external_id: Option<&str>,
) -> DiaryEntry {
let movie = Movie::new(
external_id.map(|id| ExternalMetadataId::new(id.to_string()).unwrap()),
MovieTitle::new(title.to_string()).unwrap(),
ReleaseYear::new(year).unwrap(),
director.map(str::to_string),
None,
);
let user_id = domain::value_objects::UserId::from_uuid(uuid::Uuid::new_v4());
let review = Review::new(
movie.id().clone(),
user_id,
Rating::new(rating).unwrap(),
comment.map(|c| domain::value_objects::Comment::new(c.to_string()).unwrap()),
chrono::NaiveDate::from_ymd_opt(2024, 3, 15)
.unwrap()
.and_hms_opt(0, 0, 0)
.unwrap(),
)
.unwrap();
DiaryEntry::new(movie, review)
}
#[tokio::test]
async fn csv_has_header_and_one_row() {
let adapter = ExportAdapter;
let entry = make_entry(
"Inception",
2010,
Some("Christopher Nolan"),
5,
Some("great"),
);
let bytes = adapter
.serialize_entries(&[entry], ExportFormat::Csv)
.await
.unwrap();
let text = String::from_utf8(bytes).unwrap();
assert!(
text.starts_with(
"title,year,director,rating,comment,watched_at,external_metadata_id\n"
)
);
assert!(text.contains("Inception"));
assert!(text.contains("2010"));
assert!(text.contains("Christopher Nolan"));
assert!(text.contains("5"));
assert!(text.contains("great"));
assert!(text.contains("2024-03-15"));
}
#[tokio::test]
async fn csv_escapes_commas_in_title() {
let adapter = ExportAdapter;
let entry = make_entry("Tár, A Film", 2022, None, 4, None);
let bytes = adapter
.serialize_entries(&[entry], ExportFormat::Csv)
.await
.unwrap();
let text = String::from_utf8(bytes).unwrap();
assert!(text.contains("\"Tár, A Film\""));
}
#[tokio::test]
async fn json_is_valid_array() {
let adapter = ExportAdapter;
let entry = make_entry("Dune", 2021, Some("Denis Villeneuve"), 5, None);
let bytes = adapter
.serialize_entries(&[entry], ExportFormat::Json)
.await
.unwrap();
let arr: Vec<serde_json::Value> = serde_json::from_slice(&bytes).unwrap();
assert_eq!(arr.len(), 1);
assert_eq!(arr[0]["title"], "Dune");
assert_eq!(arr[0]["year"], 2021);
assert_eq!(arr[0]["rating"], 5);
assert_eq!(arr[0]["comment"], serde_json::Value::Null);
assert_eq!(arr[0]["external_metadata_id"], serde_json::Value::Null);
}
#[tokio::test]
async fn external_metadata_id_included_when_present() {
let adapter = ExportAdapter;
let entry = make_entry_full("Alien", 1979, None, 5, None, Some("tt0078748"));
let bytes = adapter
.serialize_entries(&[entry], ExportFormat::Json)
.await
.unwrap();
let arr: Vec<serde_json::Value> = serde_json::from_slice(&bytes).unwrap();
assert_eq!(arr[0]["external_metadata_id"], "tt0078748");
let bytes = adapter
.serialize_entries(
&[make_entry_full(
"Alien",
1979,
None,
5,
None,
Some("tt0078748"),
)],
ExportFormat::Csv,
)
.await
.unwrap();
let text = String::from_utf8(bytes).unwrap();
assert!(text.contains("tt0078748"));
}
#[tokio::test]
async fn empty_entries_returns_csv_header_only() {
let adapter = ExportAdapter;
let bytes = adapter
.serialize_entries(&[], ExportFormat::Csv)
.await
.unwrap();
let text = String::from_utf8(bytes).unwrap();
assert_eq!(
text,
"title,year,director,rating,comment,watched_at,external_metadata_id\n"
);
}
}

View File

@@ -0,0 +1,16 @@
[package]
name = "image-storage"
version = "0.1.0"
edition = "2024"
[dependencies]
domain = { workspace = true }
anyhow = { workspace = true }
async-trait = { workspace = true }
tracing = { workspace = true }
object_store = { workspace = true }
infer = "0.19.0"
[dev-dependencies]
tokio = { workspace = true }
uuid = { workspace = true }

View File

@@ -0,0 +1,83 @@
use anyhow::Context;
use object_store::{ObjectStore, aws::AmazonS3Builder, local::LocalFileSystem};
use std::sync::Arc;
pub struct StorageConfig(Arc<dyn ObjectStore>);
impl StorageConfig {
pub fn from_env() -> anyhow::Result<Self> {
let backend = std::env::var("IMAGE_STORAGE_BACKEND")
.context("IMAGE_STORAGE_BACKEND required (valid values: s3, local)")?;
let store: Arc<dyn ObjectStore> = match backend.as_str() {
"s3" => build_s3_store(
&std::env::var("MINIO_ENDPOINT").context("MINIO_ENDPOINT required")?,
&std::env::var("MINIO_ACCESS_KEY_ID").context("MINIO_ACCESS_KEY_ID required")?,
&std::env::var("MINIO_SECRET_ACCESS_KEY")
.context("MINIO_SECRET_ACCESS_KEY required")?,
&std::env::var("MINIO_BUCKET").context("MINIO_BUCKET required")?,
&std::env::var("MINIO_REGION").unwrap_or_else(|_| "minio".to_string()),
)?,
"local" => build_local_store(
&std::env::var("IMAGE_STORAGE_PATH")
.context("IMAGE_STORAGE_PATH required when IMAGE_STORAGE_BACKEND=local")?,
)?,
other => {
anyhow::bail!("Unknown IMAGE_STORAGE_BACKEND: {other:?}. Valid values: s3, local")
}
};
Ok(Self(store))
}
pub fn build_store(self) -> Arc<dyn ObjectStore> {
self.0
}
}
fn build_s3_store(
endpoint: &str,
access_key_id: &str,
secret_access_key: &str,
bucket: &str,
region: &str,
) -> anyhow::Result<Arc<dyn ObjectStore>> {
let store = AmazonS3Builder::new()
.with_endpoint(endpoint)
.with_access_key_id(access_key_id)
.with_secret_access_key(secret_access_key)
.with_bucket_name(bucket)
.with_region(region)
.with_allow_http(true)
.build()
.context("Failed to build S3/Minio store")?;
Ok(Arc::new(store))
}
fn build_local_store(path: &str) -> anyhow::Result<Arc<dyn ObjectStore>> {
std::fs::create_dir_all(path).context("Failed to create image storage directory")?;
let store = LocalFileSystem::new_with_prefix(path)
.context("Failed to initialise local file system store")?;
Ok(Arc::new(store))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn local_store_creates_dir_and_succeeds() {
let dir = std::env::temp_dir().join(format!("image_test_{}", uuid::Uuid::new_v4()));
let result = build_local_store(dir.to_str().unwrap());
assert!(result.is_ok(), "expected Ok, got: {:?}", result.err());
assert!(dir.exists(), "directory should have been created");
}
#[test]
fn local_store_succeeds_if_dir_already_exists() {
let dir = std::env::temp_dir().join(format!("image_test_{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&dir).unwrap();
let result = build_local_store(dir.to_str().unwrap());
assert!(result.is_ok());
}
}

View File

@@ -0,0 +1,157 @@
mod config;
pub use config::StorageConfig;
use async_trait::async_trait;
use domain::{
errors::DomainError,
events::DomainEvent,
ports::{EventHandler, ImageStorage},
};
use object_store::{Attribute, Attributes, ObjectStore, PutOptions, path::Path};
use std::sync::Arc;
fn detect_mime(bytes: &[u8]) -> &'static str {
infer::get(bytes)
.map(|t| t.mime_type())
.unwrap_or("application/octet-stream")
}
pub struct ImageStorageAdapter {
store: Arc<dyn ObjectStore>,
}
impl ImageStorageAdapter {
pub fn new(store: Arc<dyn ObjectStore>) -> Self {
Self { store }
}
pub fn from_config(config: StorageConfig) -> Self {
Self::new(config.build_store())
}
}
#[async_trait]
impl ImageStorage for ImageStorageAdapter {
async fn store(&self, key: &str, image_bytes: &[u8]) -> Result<String, DomainError> {
let path = Path::from(key);
let mime = detect_mime(image_bytes);
let mut attributes = Attributes::new();
attributes.insert(Attribute::ContentType, mime.into());
let opts = PutOptions { attributes, ..Default::default() };
self.store
.put_opts(&path, image_bytes.to_vec().into(), opts)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
Ok(key.to_string())
}
async fn get(&self, key: &str) -> Result<Vec<u8>, DomainError> {
let path = Path::from(key);
let result = self.store.get(&path).await.map_err(|e| match e {
object_store::Error::NotFound { .. } => DomainError::NotFound("Image not found".into()),
_ => DomainError::InfrastructureError(e.to_string()),
})?;
result
.bytes()
.await
.map(|b| b.to_vec())
.map_err(|e| DomainError::InfrastructureError(e.to_string()))
}
async fn delete(&self, key: &str) -> Result<(), DomainError> {
let path = Path::from(key);
match self.store.delete(&path).await {
Ok(()) => Ok(()),
Err(object_store::Error::NotFound { .. }) => Ok(()),
Err(e) => Err(DomainError::InfrastructureError(e.to_string())),
}
}
}
pub struct ImageCleanupHandler {
image_storage: Arc<dyn ImageStorage>,
}
impl ImageCleanupHandler {
pub fn new(image_storage: Arc<dyn ImageStorage>) -> Self {
Self { image_storage }
}
}
#[async_trait]
impl EventHandler for ImageCleanupHandler {
async fn handle(&self, event: &DomainEvent) -> Result<(), DomainError> {
let poster_path = match event {
DomainEvent::MovieDeleted { poster_path, .. } => poster_path,
_ => return Ok(()),
};
let Some(path) = poster_path else { return Ok(()) };
if let Err(e) = self.image_storage.delete(path.value()).await {
tracing::warn!("image cleanup failed for {}: {e}", path.value());
}
Ok(())
}
}
pub fn create() -> anyhow::Result<Arc<dyn ImageStorage>> {
Ok(Arc::new(ImageStorageAdapter::from_config(StorageConfig::from_env()?)))
}
#[cfg(test)]
mod tests {
use super::*;
use object_store::memory::InMemory;
fn adapter() -> ImageStorageAdapter {
ImageStorageAdapter::new(Arc::new(InMemory::new()))
}
#[tokio::test]
async fn store_and_retrieve_round_trip() {
let adapter = adapter();
let bytes = b"fake-image-bytes";
let path = adapter.store("posters/abc123", bytes).await.unwrap();
assert_eq!(path, "posters/abc123");
let retrieved = adapter.get("posters/abc123").await.unwrap();
assert_eq!(retrieved, bytes);
}
#[tokio::test]
async fn get_missing_returns_not_found() {
let adapter = adapter();
let result = adapter.get("nonexistent").await;
assert!(matches!(result, Err(DomainError::NotFound(_))));
}
#[tokio::test]
async fn delete_removes_key() {
let adapter = adapter();
adapter.store("avatars/user1", b"img").await.unwrap();
adapter.delete("avatars/user1").await.unwrap();
let result = adapter.get("avatars/user1").await;
assert!(matches!(result, Err(DomainError::NotFound(_))));
}
#[tokio::test]
async fn delete_missing_returns_ok() {
let adapter = adapter();
assert!(adapter.delete("does-not-exist").await.is_ok());
}
#[tokio::test]
async fn cleanup_handler_deletes_on_movie_deleted() {
use domain::{events::DomainEvent, value_objects::{MovieId, PosterPath}};
let inner = Arc::new(adapter());
inner.store("some-uuid", b"img").await.unwrap();
let path = PosterPath::new("some-uuid".to_string()).unwrap();
let handler = ImageCleanupHandler::new(Arc::clone(&inner) as Arc<dyn ImageStorage>);
handler
.handle(&DomainEvent::MovieDeleted {
movie_id: MovieId::from_uuid(uuid::Uuid::new_v4()),
poster_path: Some(path.clone()),
})
.await
.unwrap();
assert!(matches!(inner.get("some-uuid").await, Err(DomainError::NotFound(_))));
}
}

View File

@@ -0,0 +1,14 @@
[package]
name = "importer"
version = "0.1.0"
edition = "2024"
[features]
xlsx = ["dep:calamine"]
[dependencies]
domain = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
csv = { workspace = true }
calamine = { version = "0.26", optional = true }

View File

@@ -0,0 +1,28 @@
mod mapper;
mod parsers;
use domain::{
models::{AnnotatedRow, FieldMapping, FileFormat, ImportError, ParsedFile},
ports::DocumentParser,
};
pub struct ImporterDocumentParser;
impl DocumentParser for ImporterDocumentParser {
fn parse(&self, bytes: &[u8], format: FileFormat) -> Result<ParsedFile, ImportError> {
match format {
FileFormat::Csv => parsers::parse_csv(bytes),
FileFormat::Json => parsers::parse_json(bytes),
FileFormat::Xlsx => {
#[cfg(feature = "xlsx")]
{ parsers::parse_xlsx(bytes) }
#[cfg(not(feature = "xlsx"))]
{ Err(ImportError::Xlsx("XLSX support not compiled in".into())) }
}
}
}
fn apply_mapping(&self, file: &ParsedFile, mappings: &[FieldMapping]) -> Vec<AnnotatedRow> {
mapper::apply_mapping(file, mappings)
}
}

View File

@@ -0,0 +1,194 @@
use domain::models::{
AnnotatedRow, DomainField, FieldMapping, ImportRow, ParsedFile, RowResult, Transform,
};
pub fn apply_mapping(file: &ParsedFile, mappings: &[FieldMapping]) -> Vec<AnnotatedRow> {
file.rows.iter().map(|row| {
let result = map_row(row, &file.columns, mappings);
AnnotatedRow { result, is_duplicate: false }
}).collect()
}
fn map_row(row: &[String], columns: &[String], mappings: &[FieldMapping]) -> RowResult {
let mut import_row = ImportRow::default();
let mut errors = Vec::new();
for mapping in mappings {
let Some(col_idx) = columns.iter().position(|c| c == &mapping.source_column) else {
continue;
};
let raw_value = row.get(col_idx).map(|s| s.as_str()).unwrap_or("").trim();
if raw_value.is_empty() {
continue;
}
if let Some(value) = apply_transform(raw_value, &mapping.transform, &mut errors) {
set_field(&mut import_row, &mapping.domain_field, value);
}
}
if import_row.title.is_none() && import_row.external_metadata_id.is_none() {
errors.push("missing required field: title or external_metadata_id".into());
}
if import_row.rating.is_none() {
errors.push("missing required field: rating".into());
}
if import_row.watched_at.is_none() {
errors.push("missing required field: watched_at".into());
}
if errors.is_empty() {
RowResult::Valid(import_row)
} else {
let raw = columns.iter()
.zip(row.iter())
.map(|(c, v)| (c.clone(), v.clone()))
.collect();
RowResult::Invalid { errors, raw }
}
}
fn apply_transform(value: &str, transform: &Transform, errors: &mut Vec<String>) -> Option<String> {
match transform {
Transform::Identity => Some(value.to_string()),
Transform::DateFormat(_) => Some(value.to_string()),
Transform::RatingScale(factor) => {
match value.parse::<f64>() {
Ok(n) => Some((n * factor).round().to_string()),
Err(_) => {
errors.push(format!("rating '{}' is not a number", value));
None
}
}
}
}
}
fn set_field(row: &mut ImportRow, field: &DomainField, value: String) {
match field {
DomainField::Title => row.title = Some(value),
DomainField::ReleaseYear => row.release_year = Some(value),
DomainField::Director => row.director = Some(value),
DomainField::Rating => row.rating = Some(value),
DomainField::WatchedAt => row.watched_at = Some(value),
DomainField::Comment => row.comment = Some(value),
DomainField::ExternalMetadataId => row.external_metadata_id = Some(value),
}
}
#[cfg(test)]
mod tests {
use super::*;
use domain::models::{DomainField, FieldMapping, ParsedFile, RowResult, Transform};
fn sample_file() -> ParsedFile {
ParsedFile {
columns: vec!["Name".into(), "Stars".into(), "Date".into()],
rows: vec![
vec!["Inception".into(), "10".into(), "2024-01-15".into()],
vec!["Dune".into(), "8".into(), "2024-02-20".into()],
vec!["".into(), "3".into(), "2024-03-01".into()], // missing title → invalid
],
}
}
fn full_mappings() -> Vec<FieldMapping> {
vec![
FieldMapping { source_column: "Name".into(), domain_field: DomainField::Title, transform: Transform::Identity },
FieldMapping { source_column: "Stars".into(), domain_field: DomainField::Rating, transform: Transform::RatingScale(0.5) },
FieldMapping { source_column: "Date".into(), domain_field: DomainField::WatchedAt, transform: Transform::Identity },
]
}
#[test]
fn maps_valid_rows() {
let results = apply_mapping(&sample_file(), &full_mappings());
assert_eq!(results.len(), 3);
// First two rows are valid
assert!(matches!(results[0].result, RowResult::Valid(_)));
assert!(matches!(results[1].result, RowResult::Valid(_)));
// is_duplicate defaults to false
assert!(!results[0].is_duplicate);
}
#[test]
fn applies_rating_scale_transform() {
let results = apply_mapping(&sample_file(), &full_mappings());
if let RowResult::Valid(row) = &results[0].result {
// 10 * 0.5 = 5
assert_eq!(row.rating.as_deref(), Some("5"));
} else {
panic!("expected Valid");
}
}
#[test]
fn marks_missing_required_fields_invalid() {
let results = apply_mapping(&sample_file(), &full_mappings());
// Row 2 has empty title
assert!(matches!(results[2].result, RowResult::Invalid { .. }));
}
#[test]
fn ignores_unmapped_columns() {
let mappings = vec![
FieldMapping { source_column: "Name".into(), domain_field: DomainField::Title, transform: Transform::Identity },
];
let file = ParsedFile {
columns: vec!["Name".into(), "Extra".into()],
rows: vec![vec!["Inception".into(), "ignored".into()]],
};
let results = apply_mapping(&file, &mappings);
assert_eq!(results.len(), 1);
// Missing rating and watched_at → invalid
assert!(matches!(results[0].result, RowResult::Invalid { .. }));
}
#[test]
fn nonexistent_source_column_skipped() {
let mappings = vec![
FieldMapping { source_column: "DoesNotExist".into(), domain_field: DomainField::Title, transform: Transform::Identity },
];
let file = ParsedFile {
columns: vec!["Name".into()],
rows: vec![vec!["Inception".into()]],
};
let results = apply_mapping(&file, &mappings);
// Column not found → field not set → invalid (missing title, rating, watched_at)
assert!(matches!(results[0].result, RowResult::Invalid { .. }));
}
#[test]
fn collects_all_errors_not_just_first() {
let mappings = vec![
FieldMapping { source_column: "Name".into(), domain_field: DomainField::Title, transform: Transform::Identity },
FieldMapping { source_column: "Stars".into(), domain_field: DomainField::Rating, transform: Transform::RatingScale(0.5) },
// no watched_at mapping
];
let file = ParsedFile {
columns: vec!["Name".into(), "Stars".into()],
rows: vec![vec!["Inception".into(), "notanumber".into()]],
};
let results = apply_mapping(&file, &mappings);
if let RowResult::Invalid { errors, .. } = &results[0].result {
assert!(errors.iter().any(|e| e.contains("not a number")), "expected rating error, got: {:?}", errors);
assert!(errors.iter().any(|e| e.contains("watched_at")), "expected watched_at error, got: {:?}", errors);
} else {
panic!("expected Invalid");
}
}
#[test]
fn non_numeric_rating_produces_error_in_row() {
let mappings = vec![
FieldMapping { source_column: "Name".into(), domain_field: DomainField::Title, transform: Transform::Identity },
FieldMapping { source_column: "Stars".into(), domain_field: DomainField::Rating, transform: Transform::RatingScale(0.5) },
FieldMapping { source_column: "Date".into(), domain_field: DomainField::WatchedAt, transform: Transform::Identity },
];
let file = ParsedFile {
columns: vec!["Name".into(), "Stars".into(), "Date".into()],
rows: vec![vec!["Inception".into(), "five".into(), "2024-01-15".into()]],
};
let results = apply_mapping(&file, &mappings);
assert!(matches!(results[0].result, RowResult::Invalid { .. }));
}
}

View File

@@ -0,0 +1,49 @@
use domain::models::{ImportError, ParsedFile};
pub fn parse_csv(bytes: &[u8]) -> Result<ParsedFile, ImportError> {
if bytes.is_empty() {
return Err(ImportError::Empty);
}
let delimiter = detect_delimiter(bytes);
let mut rdr = csv::ReaderBuilder::new()
.delimiter(delimiter)
.from_reader(bytes);
let columns: Vec<String> = rdr
.headers()
.map_err(|e| ImportError::Csv(e.to_string()))?
.iter()
.map(|s| s.trim().to_string())
.collect();
if columns.is_empty() {
return Err(ImportError::NoHeader);
}
let rows: Vec<Vec<String>> = rdr
.records()
.map(|r| {
r.map_err(|e| ImportError::Csv(e.to_string()))
.map(|rec| {
let mut cells: Vec<String> = rec.iter().map(|f| f.trim().to_string()).collect();
cells.resize(columns.len(), String::new());
cells.truncate(columns.len());
cells
})
})
.collect::<Result<_, _>>()?;
if rows.is_empty() {
return Err(ImportError::Empty);
}
Ok(ParsedFile { columns, rows })
}
fn detect_delimiter(bytes: &[u8]) -> u8 {
let first_line = bytes.split(|&b| b == b'\n').next().unwrap_or(bytes);
let tabs = first_line.iter().filter(|&&b| b == b'\t').count();
let commas = first_line.iter().filter(|&&b| b == b',').count();
if tabs > commas { b'\t' } else { b',' }
}

View File

@@ -0,0 +1,43 @@
use domain::models::{ImportError, ParsedFile};
use serde_json::Value;
pub fn parse_json(bytes: &[u8]) -> Result<ParsedFile, ImportError> {
let value: Value = serde_json::from_slice(bytes)
.map_err(|e| ImportError::Json(e.to_string()))?;
let arr = value.as_array()
.ok_or_else(|| ImportError::Json("expected a JSON array".into()))?;
if arr.is_empty() {
return Err(ImportError::Empty);
}
let first = arr[0].as_object()
.ok_or_else(|| ImportError::Json("array elements must be objects".into()))?;
let columns: Vec<String> = first.keys().cloned().collect();
if columns.is_empty() {
return Err(ImportError::NoHeader);
}
let rows: Vec<Vec<String>> = arr.iter()
.enumerate()
.map(|(idx, item)| {
let obj = item.as_object()
.ok_or_else(|| ImportError::Json(format!("element at index {} is not an object", idx)))?;
Ok(columns.iter()
.map(|col| obj.get(col).map(value_to_string).unwrap_or_default())
.collect())
})
.collect::<Result<_, ImportError>>()?;
Ok(ParsedFile { columns, rows })
}
fn value_to_string(v: &Value) -> String {
match v {
Value::String(s) => s.clone(),
Value::Null => String::new(),
other => other.to_string(),
}
}

View File

@@ -0,0 +1,50 @@
mod csv;
mod json;
#[cfg(feature = "xlsx")]
mod xlsx;
pub use csv::parse_csv;
pub use json::parse_json;
#[cfg(feature = "xlsx")]
pub use xlsx::parse_xlsx;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn csv_parses_headers_and_rows() {
let data = b"title,rating,watched_at\nInception,5,2024-01-01\nDune,4,2024-02-15\n";
let file = parse_csv(data).unwrap();
assert_eq!(file.columns, vec!["title", "rating", "watched_at"]);
assert_eq!(file.rows.len(), 2);
assert_eq!(file.rows[0], vec!["Inception", "5", "2024-01-01"]);
}
#[test]
fn csv_rejects_empty() {
assert!(parse_csv(b"").is_err());
}
#[test]
fn tsv_parses_correctly() {
let data = b"title\trating\nInception\t5\n";
let file = parse_csv(data).unwrap();
assert_eq!(file.columns, vec!["title", "rating"]);
assert_eq!(file.rows[0], vec!["Inception", "5"]);
}
#[test]
fn json_array_of_objects() {
let data = br#"[{"title":"Inception","rating":"5"},{"title":"Dune","rating":"4"}]"#;
let file = parse_json(data).unwrap();
assert_eq!(file.columns.len(), 2);
assert!(file.columns.contains(&"title".to_string()));
assert_eq!(file.rows.len(), 2);
}
#[test]
fn json_empty_array_errors() {
assert!(parse_json(b"[]").is_err());
}
}

View File

@@ -0,0 +1,64 @@
use calamine::{Reader, open_workbook_from_rs, Xlsx, Data};
use std::io::Cursor;
use domain::models::{ImportError, ParsedFile};
pub fn parse_xlsx(bytes: &[u8]) -> Result<ParsedFile, ImportError> {
let cursor = Cursor::new(bytes);
let mut workbook: Xlsx<_> = open_workbook_from_rs(cursor)
.map_err(|e: calamine::XlsxError| ImportError::Xlsx(e.to_string()))?;
let sheet_name = workbook.sheet_names()
.first()
.cloned()
.ok_or(ImportError::Empty)?;
let range = workbook.worksheet_range(&sheet_name)
.map_err(|e| ImportError::Xlsx(e.to_string()))?;
let mut iter = range.rows();
let header = iter.next().ok_or(ImportError::NoHeader)?;
let columns: Vec<String> = header.iter()
.map(|c| cell_to_string(c).trim().to_string())
.collect();
if columns.is_empty() {
return Err(ImportError::NoHeader);
}
let rows: Vec<Vec<String>> = iter
.map(|row| {
let mut cells: Vec<String> = row.iter().map(cell_to_string).collect();
cells.resize(columns.len(), String::new());
cells.truncate(columns.len());
cells
})
.collect();
if rows.is_empty() {
return Err(ImportError::Empty);
}
Ok(ParsedFile { columns, rows })
}
fn cell_to_string(cell: &Data) -> String {
match cell {
Data::String(s) => s.clone(),
Data::Float(f) => {
if f.fract() == 0.0 { format!("{}", *f as i64) } else { format!("{}", f) }
}
Data::Int(i) => i.to_string(),
Data::Bool(b) => b.to_string(),
Data::DateTime(dt) => {
// ExcelDateTime::to_ymd_hms_milli() works without the chrono feature.
let (year, month, day, _, _, _, _) = dt.to_ymd_hms_milli();
format!("{:04}-{:02}-{:02}", year, month, day)
}
Data::DateTimeIso(s) => s.clone(),
Data::DurationIso(s) => s.clone(),
Data::Empty | Data::Error(_) => String::new(),
// Fallback for unexpected calamine Data variants; renders as debug string
other => format!("{other:?}"),
}
}

View File

@@ -4,3 +4,8 @@ version = "0.1.0"
edition = "2024"
[dependencies]
anyhow = { workspace = true }
async-trait = { workspace = true }
reqwest = { workspace = true }
serde = { workspace = true }
domain = { workspace = true }

View File

@@ -1,14 +1,78 @@
pub fn add(left: u64, right: u64) -> u64 {
left + right
use async_trait::async_trait;
use domain::{
errors::DomainError,
models::Movie,
ports::{MetadataClient, MetadataSearchCriteria},
value_objects::{ExternalMetadataId, MovieTitle, PosterUrl, ReleaseYear},
};
mod omdb;
mod tmdb;
pub(crate) struct ProviderMovie {
pub imdb_id: ExternalMetadataId,
pub title: MovieTitle,
pub release_year: ReleaseYear,
pub director: Option<String>,
pub poster_url: Option<PosterUrl>,
}
#[cfg(test)]
mod tests {
use super::*;
#[async_trait]
pub(crate) trait MetadataProvider: Send + Sync {
async fn fetch(&self, criteria: &MetadataSearchCriteria) -> Result<ProviderMovie, DomainError>;
}
#[test]
fn it_works() {
let result = add(2, 2);
assert_eq!(result, 4);
pub struct MetadataClientImpl {
provider: Box<dyn MetadataProvider>,
}
impl MetadataClientImpl {
pub fn new_omdb(api_key: String) -> Self {
Self {
provider: Box::new(omdb::OmdbProvider::new(api_key)),
}
}
pub fn new_tmdb(api_key: String) -> Self {
Self {
provider: Box::new(tmdb::TmdbProvider::new(api_key)),
}
}
}
#[async_trait]
impl MetadataClient for MetadataClientImpl {
async fn fetch_movie_metadata(
&self,
criteria: &MetadataSearchCriteria,
) -> Result<Movie, DomainError> {
let pm = self.provider.fetch(criteria).await?;
Ok(Movie::new(
Some(pm.imdb_id),
pm.title,
pm.release_year,
pm.director,
None,
))
}
async fn get_poster_url(
&self,
external_metadata_id: &ExternalMetadataId,
) -> Result<Option<PosterUrl>, DomainError> {
let criteria = MetadataSearchCriteria::ImdbId(external_metadata_id.clone());
let pm = self.provider.fetch(&criteria).await?;
Ok(pm.poster_url)
}
}
pub fn create() -> anyhow::Result<std::sync::Arc<dyn domain::ports::MetadataClient>> {
use anyhow::Context;
if let Ok(key) = std::env::var("TMDB_API_KEY") {
Ok(std::sync::Arc::new(MetadataClientImpl::new_tmdb(key)))
} else {
let key = std::env::var("OMDB_API_KEY")
.context("either TMDB_API_KEY or OMDB_API_KEY must be set")?;
Ok(std::sync::Arc::new(MetadataClientImpl::new_omdb(key)))
}
}

View File

@@ -0,0 +1,125 @@
use async_trait::async_trait;
use domain::{
errors::DomainError,
ports::MetadataSearchCriteria,
value_objects::{ExternalMetadataId, MovieTitle, PosterUrl, ReleaseYear},
};
use serde::Deserialize;
use crate::{MetadataProvider, ProviderMovie};
pub(crate) struct OmdbProvider {
client: reqwest::Client,
api_key: String,
base_url: String,
}
impl OmdbProvider {
pub(crate) fn new(api_key: String) -> Self {
Self {
client: reqwest::Client::new(),
api_key,
base_url: "http://www.omdbapi.com/".to_string(),
}
}
}
#[derive(Deserialize)]
struct OmdbResponse {
#[serde(rename = "Title")]
title: String,
#[serde(rename = "Year")]
year: String,
#[serde(rename = "Director")]
director: String,
#[serde(rename = "Poster")]
poster: String,
#[serde(rename = "imdbID")]
imdb_id: String,
#[serde(rename = "Response")]
response: String,
#[serde(rename = "Error")]
error: Option<String>,
}
#[async_trait]
impl MetadataProvider for OmdbProvider {
async fn fetch(&self, criteria: &MetadataSearchCriteria) -> Result<ProviderMovie, DomainError> {
let mut url = reqwest::Url::parse(&self.base_url)
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
{
let mut params = url.query_pairs_mut();
params.append_pair("apikey", &self.api_key);
match criteria {
MetadataSearchCriteria::ImdbId(id) => {
params.append_pair("i", id.value());
}
MetadataSearchCriteria::Title { title, year } => {
params.append_pair("t", title.value());
if let Some(y) = year {
params.append_pair("y", &y.value().to_string());
}
}
}
}
let http_resp = self
.client
.get(url)
.send()
.await
.map_err(|e: reqwest::Error| DomainError::InfrastructureError(e.to_string()))?
.error_for_status()
.map_err(|e: reqwest::Error| DomainError::InfrastructureError(e.to_string()))?;
let resp: OmdbResponse = http_resp
.json()
.await
.map_err(|e: reqwest::Error| DomainError::InfrastructureError(e.to_string()))?;
if resp.response != "True" {
let msg = resp.error.unwrap_or_default();
return if msg.to_lowercase().contains("not found") {
Err(DomainError::NotFound(msg))
} else {
Err(DomainError::InfrastructureError(msg))
};
}
let year: u16 = resp
.year
.chars()
.take(4)
.collect::<String>()
.parse()
.map_err(|_| {
DomainError::InfrastructureError(format!("Unparseable year: {}", resp.year))
})?;
let imdb_id = ExternalMetadataId::new(resp.imdb_id)
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
let title = MovieTitle::new(resp.title)
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
let release_year =
ReleaseYear::new(year).map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
let director = match resp.director.as_str() {
"N/A" | "" => None,
d => Some(d.to_string()),
};
let poster_url = match resp.poster.as_str() {
"N/A" | "" => None,
url => PosterUrl::new(url.to_string()).ok(),
};
Ok(ProviderMovie {
imdb_id,
title,
release_year,
director,
poster_url,
})
}
}

View File

@@ -0,0 +1,179 @@
use async_trait::async_trait;
use domain::{
errors::DomainError,
ports::MetadataSearchCriteria,
value_objects::{ExternalMetadataId, MovieTitle, PosterUrl, ReleaseYear},
};
use serde::Deserialize;
use crate::{MetadataProvider, ProviderMovie};
pub(crate) struct TmdbProvider {
client: reqwest::Client,
api_key: String,
}
impl TmdbProvider {
pub(crate) fn new(api_key: String) -> Self {
Self {
client: reqwest::Client::new(),
api_key,
}
}
fn base(&self, path: &str) -> String {
format!("https://api.themoviedb.org/3{}", path)
}
fn poster_url(&self, path: &str) -> Option<PosterUrl> {
if path.is_empty() || path == "null" {
return None;
}
PosterUrl::new(format!("https://image.tmdb.org/t/p/w500{}", path)).ok()
}
async fn get<T: for<'de> Deserialize<'de>>(
&self,
url: &str,
extra: &[(&str, &str)],
) -> Result<T, DomainError> {
let mut req = self
.client
.get(url)
.query(&[("api_key", self.api_key.as_str())]);
for (k, v) in extra {
req = req.query(&[(k, v)]);
}
req.send()
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?
.error_for_status()
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?
.json::<T>()
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))
}
async fn fetch_details(&self, tmdb_id: u64) -> Result<ProviderMovie, DomainError> {
#[derive(Deserialize)]
struct CrewMember {
job: String,
name: String,
}
#[derive(Deserialize)]
struct Credits {
crew: Vec<CrewMember>,
}
#[derive(Deserialize)]
struct Details {
imdb_id: Option<String>,
title: String,
release_date: String, // "YYYY-MM-DD"
poster_path: Option<String>,
credits: Credits,
}
let url = self.base(&format!("/movie/{}", tmdb_id));
let d: Details = self
.get(&url, &[("append_to_response", "credits")])
.await?;
let year: u16 = d
.release_date
.split('-')
.next()
.and_then(|y| y.parse().ok())
.ok_or_else(|| {
DomainError::InfrastructureError(format!(
"Unparseable release_date: {}",
d.release_date
))
})?;
// Prefer IMDB ID; fall back to "tmdb:{id}" so the record is still usable.
let raw_id = d
.imdb_id
.filter(|s| !s.is_empty())
.unwrap_or_else(|| format!("tmdb:{}", tmdb_id));
let imdb_id = ExternalMetadataId::new(raw_id)
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
let title =
MovieTitle::new(d.title).map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
let release_year =
ReleaseYear::new(year).map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
let director = d
.credits
.crew
.into_iter()
.find(|c| c.job == "Director")
.map(|c| c.name);
let poster_url = d
.poster_path
.as_deref()
.and_then(|p| self.poster_url(p));
Ok(ProviderMovie {
imdb_id,
title,
release_year,
director,
poster_url,
})
}
}
#[async_trait]
impl MetadataProvider for TmdbProvider {
async fn fetch(&self, criteria: &MetadataSearchCriteria) -> Result<ProviderMovie, DomainError> {
let tmdb_id: u64 = match criteria {
MetadataSearchCriteria::ImdbId(id) => {
#[derive(Deserialize)]
struct FindResult {
id: u64,
}
#[derive(Deserialize)]
struct FindResponse {
movie_results: Vec<FindResult>,
}
let url = self.base(&format!("/find/{}", id.value()));
let resp: FindResponse =
self.get(&url, &[("external_source", "imdb_id")]).await?;
resp.movie_results
.into_iter()
.next()
.ok_or_else(|| DomainError::NotFound(format!("TMDB: no movie for {}", id.value())))?
.id
}
MetadataSearchCriteria::Title { title, year } => {
#[derive(Deserialize)]
struct SearchResult {
id: u64,
}
#[derive(Deserialize)]
struct SearchResponse {
results: Vec<SearchResult>,
}
let url = self.base("/search/movie");
let mut extra = vec![("query", title.value())];
let year_str;
if let Some(y) = year {
year_str = y.value().to_string();
extra.push(("year", year_str.as_str()));
}
let resp: SearchResponse = self.get(&url, &extra).await?;
resp.results
.into_iter()
.next()
.ok_or_else(|| {
DomainError::NotFound(format!("TMDB: no results for '{}'", title.value()))
})?
.id
}
};
self.fetch_details(tmdb_id).await
}
}

View File

@@ -0,0 +1,20 @@
[package]
name = "nats"
version = "0.1.0"
edition = "2024"
[dependencies]
async-nats = "0.48.0"
domain = { workspace = true }
event-payload = { workspace = true }
async-trait = { workspace = true }
anyhow = { workspace = true }
thiserror = { workspace = true }
tracing = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
uuid = { workspace = true }
chrono = { workspace = true }
tokio = { workspace = true }
futures = { workspace = true }

View File

@@ -0,0 +1,101 @@
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum NatsMode {
Core,
JetStream,
}
#[derive(Debug, Clone)]
pub struct NatsConfig {
pub url: String,
pub mode: NatsMode,
pub subject_prefix: String,
pub stream_name: String,
pub consumer_name: String,
}
impl NatsConfig {
pub fn from_env() -> anyhow::Result<Self> {
let url = std::env::var("NATS_URL")
.map_err(|_| anyhow::anyhow!("NATS_URL is not set"))?;
let mode = match std::env::var("NATS_MODE")
.unwrap_or_else(|_| "jetstream".to_string())
.as_str()
{
"core" => NatsMode::Core,
"jetstream" => NatsMode::JetStream,
other => anyhow::bail!("unknown NATS_MODE: {other}"),
};
let subject_prefix = std::env::var("NATS_SUBJECT_PREFIX")
.unwrap_or_else(|_| "movies-diary.events".to_string());
let stream_name = std::env::var("NATS_STREAM_NAME")
.unwrap_or_else(|_| "MOVIES_DIARY_EVENTS".to_string());
let consumer_name = std::env::var("NATS_CONSUMER_NAME")
.unwrap_or_else(|_| "worker".to_string());
Ok(Self { url, mode, subject_prefix, stream_name, consumer_name })
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn errors_without_nats_url() {
unsafe { std::env::remove_var("NATS_URL"); }
assert!(NatsConfig::from_env().is_err());
}
#[test]
fn defaults_with_only_url() {
unsafe {
std::env::set_var("NATS_URL", "nats://localhost:4222");
std::env::remove_var("NATS_MODE");
std::env::remove_var("NATS_SUBJECT_PREFIX");
std::env::remove_var("NATS_STREAM_NAME");
std::env::remove_var("NATS_CONSUMER_NAME");
}
let cfg = NatsConfig::from_env().unwrap();
assert_eq!(cfg.url, "nats://localhost:4222");
assert_eq!(cfg.mode, NatsMode::JetStream);
assert_eq!(cfg.subject_prefix, "movies-diary.events");
assert_eq!(cfg.stream_name, "MOVIES_DIARY_EVENTS");
assert_eq!(cfg.consumer_name, "worker");
unsafe { std::env::remove_var("NATS_URL"); }
}
#[test]
fn core_mode_parsed() {
unsafe {
std::env::set_var("NATS_URL", "nats://test:4222");
std::env::set_var("NATS_MODE", "core");
}
let cfg = NatsConfig::from_env().unwrap();
assert_eq!(cfg.mode, NatsMode::Core);
unsafe {
std::env::remove_var("NATS_URL");
std::env::remove_var("NATS_MODE");
}
}
#[test]
fn invalid_mode_errors() {
unsafe {
std::env::set_var("NATS_URL", "nats://test:4222");
std::env::set_var("NATS_MODE", "kafka");
}
assert!(NatsConfig::from_env().is_err());
unsafe {
std::env::remove_var("NATS_URL");
std::env::remove_var("NATS_MODE");
}
}
}

View File

@@ -0,0 +1,213 @@
use async_nats::{
Client,
jetstream::{self, consumer::pull, message::AckKind, stream::Config as StreamConfig},
};
use async_trait::async_trait;
use domain::{
errors::DomainError,
events::{AckHandle, DomainEvent, EventEnvelope},
ports::EventConsumer,
};
use futures::{
StreamExt,
stream::{self, BoxStream},
};
use std::sync::Arc;
use tokio::sync::{Mutex, mpsc};
use crate::{config::NatsConfig, payload::NatsEventPayload, subject::consumer_subject_filter};
// ── JetStream ack handle ─────────────────────────────────────────────────────
struct NatsJetStreamAckHandle {
message: async_nats::jetstream::Message,
}
#[async_trait]
impl AckHandle for NatsJetStreamAckHandle {
async fn ack(&self) -> Result<(), DomainError> {
tracing::debug!(
"acknowledging message with sequence {}",
self.message.info().unwrap().stream_sequence
);
self.message
.ack()
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))
}
async fn nack(&self) -> Result<(), DomainError> {
tracing::debug!(
"negatively acknowledging message with sequence {}",
self.message.info().unwrap().stream_sequence
);
self.message
.ack_with(AckKind::Nak(None))
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))
}
}
// ── Core NATS ack handle (no-op) ─────────────────────────────────────────────
struct NoopAck;
#[async_trait]
impl AckHandle for NoopAck {
async fn ack(&self) -> Result<(), DomainError> {
Ok(())
}
async fn nack(&self) -> Result<(), DomainError> {
Ok(())
}
}
// ── Envelope construction helpers ────────────────────────────────────────────
fn decode_js(msg: async_nats::jetstream::Message) -> Result<EventEnvelope, DomainError> {
let payload: NatsEventPayload = serde_json::from_slice(&msg.payload)
.map_err(|e| DomainError::InfrastructureError(format!("deserialize: {e}")))?;
let event = DomainEvent::try_from(payload)?;
Ok(EventEnvelope::new(
event,
Box::new(NatsJetStreamAckHandle { message: msg }),
))
}
fn decode_core(msg: async_nats::Message) -> Result<EventEnvelope, DomainError> {
let payload: NatsEventPayload = serde_json::from_slice(&msg.payload)
.map_err(|e| DomainError::InfrastructureError(format!("deserialize: {e}")))?;
let event = DomainEvent::try_from(payload)?;
Ok(EventEnvelope::new(event, Box::new(NoopAck)))
}
// ── Channel-bridge shared by both consumers ──────────────────────────────────
type EnvelopeRx = Arc<Mutex<mpsc::Receiver<Result<EventEnvelope, DomainError>>>>;
fn consume_from_rx(rx: EnvelopeRx) -> BoxStream<'static, Result<EventEnvelope, DomainError>> {
Box::pin(stream::unfold(rx, |rx| async move {
let item = rx.lock().await.recv().await?;
Some((item, rx))
}))
}
// ── JetStream consumer ────────────────────────────────────────────────────────
pub struct NatsJetStreamConsumer {
rx: EnvelopeRx,
}
impl NatsJetStreamConsumer {
pub async fn create(cfg: &NatsConfig, client: Client) -> anyhow::Result<Self> {
let js = jetstream::new(client);
let stream = js
.get_or_create_stream(StreamConfig {
name: cfg.stream_name.clone(),
subjects: vec![consumer_subject_filter(&cfg.subject_prefix)],
max_messages: 100_000,
..Default::default()
})
.await?;
let subject_filter = consumer_subject_filter(&cfg.subject_prefix);
let consumer = stream
.get_or_create_consumer(
cfg.consumer_name.as_str(),
pull::Config {
durable_name: Some(cfg.consumer_name.clone()),
filter_subject: subject_filter,
..Default::default()
},
)
.await?;
let (tx, rx) = mpsc::channel(128);
tokio::spawn(async move {
loop {
let mut messages = match consumer.messages().await {
Err(e) => {
tracing::error!("failed to fetch messages: {}", e);
let _ = tx
.send(Err(DomainError::InfrastructureError(e.to_string())))
.await;
return;
}
Ok(m) => m,
};
while let Some(result) = messages.next().await {
let envelope = result
.map_err(|e| DomainError::InfrastructureError(e.to_string()))
.and_then(decode_js);
if tx.send(envelope).await.is_err() {
tracing::info!("consumer channel closed, stopping message processing");
return;
}
tracing::debug!("message sent to consumer channel");
}
// messages() stream ended (fetch expired in strict mode) — restart
}
});
Ok(Self {
rx: Arc::new(Mutex::new(rx)),
})
}
}
impl EventConsumer for NatsJetStreamConsumer {
fn consume(&self) -> BoxStream<'_, Result<EventEnvelope, DomainError>> {
consume_from_rx(Arc::clone(&self.rx))
}
}
// ── Core NATS consumer ────────────────────────────────────────────────────────
pub struct NatsCoreConsumer {
rx: EnvelopeRx,
}
impl NatsCoreConsumer {
pub async fn create(cfg: &NatsConfig, client: Client) -> anyhow::Result<Self> {
let subject = consumer_subject_filter(&cfg.subject_prefix);
let mut subscriber = client.subscribe(subject).await?;
let (tx, rx) = mpsc::channel(128);
tokio::spawn(async move {
while let Some(msg) = subscriber.next().await {
let envelope = decode_core(msg);
tracing::debug!("message received and decoded, sending to consumer channel");
if tx.send(envelope).await.is_err() {
tracing::info!("consumer channel closed, stopping message processing");
break;
}
}
});
Ok(Self {
rx: Arc::new(Mutex::new(rx)),
})
}
}
impl EventConsumer for NatsCoreConsumer {
fn consume(&self) -> BoxStream<'_, Result<EventEnvelope, DomainError>> {
consume_from_rx(Arc::clone(&self.rx))
}
}
fn _assert_send_sync() {
fn check<T: Send + Sync>() {}
check::<NatsJetStreamConsumer>();
check::<NatsCoreConsumer>();
}

View File

@@ -0,0 +1,52 @@
mod config;
mod consumer;
mod payload;
mod publisher;
mod subject;
pub use config::{NatsConfig, NatsMode};
pub use consumer::{NatsCoreConsumer, NatsJetStreamConsumer};
pub use publisher::NatsEventPublisher;
use std::sync::Arc;
use domain::ports::{EventConsumer, EventPublisher};
pub async fn create_publisher(cfg: NatsConfig) -> anyhow::Result<Arc<dyn EventPublisher>> {
let client = async_nats::connect(&cfg.url).await?;
let publisher: Arc<dyn EventPublisher> = match cfg.mode {
NatsMode::Core => Arc::new(NatsEventPublisher::new_core(client, cfg.subject_prefix)),
NatsMode::JetStream => Arc::new(NatsEventPublisher::new_jetstream(
client,
cfg.subject_prefix,
)),
};
tracing::info!("NATS publisher created (mode: {:?})", cfg.mode);
Ok(publisher)
}
pub async fn create_channel(
cfg: NatsConfig,
) -> anyhow::Result<(Arc<dyn EventPublisher>, Arc<dyn EventConsumer>)> {
let client = async_nats::connect(&cfg.url).await?;
let publisher: Arc<dyn EventPublisher> = match cfg.mode {
NatsMode::Core => Arc::new(NatsEventPublisher::new_core(
client.clone(),
cfg.subject_prefix.clone(),
)),
NatsMode::JetStream => Arc::new(NatsEventPublisher::new_jetstream(
client.clone(),
cfg.subject_prefix.clone(),
)),
};
let consumer: Arc<dyn EventConsumer> = match cfg.mode {
NatsMode::Core => Arc::new(NatsCoreConsumer::create(&cfg, client).await?),
NatsMode::JetStream => Arc::new(NatsJetStreamConsumer::create(&cfg, client).await?),
};
tracing::info!("NATS channel created (mode: {:?})", cfg.mode);
Ok((publisher, consumer))
}

View File

@@ -0,0 +1 @@
pub use event_payload::EventPayload as NatsEventPayload;

View File

@@ -0,0 +1,54 @@
use async_nats::{jetstream, Client};
use async_trait::async_trait;
use domain::{errors::DomainError, events::DomainEvent, ports::EventPublisher};
use crate::{payload::NatsEventPayload, subject::event_to_subject};
enum PublisherMode {
Core(Client),
JetStream(jetstream::Context),
}
pub struct NatsEventPublisher {
mode: PublisherMode,
subject_prefix: String,
}
impl NatsEventPublisher {
pub fn new_core(client: Client, subject_prefix: String) -> Self {
Self { mode: PublisherMode::Core(client), subject_prefix }
}
pub fn new_jetstream(client: Client, subject_prefix: String) -> Self {
Self { mode: PublisherMode::JetStream(jetstream::new(client)), subject_prefix }
}
}
#[async_trait]
impl EventPublisher for NatsEventPublisher {
async fn publish(&self, event: &DomainEvent) -> Result<(), DomainError> {
let subject = event_to_subject(&self.subject_prefix, event);
let payload = serde_json::to_vec(&NatsEventPayload::from(event))
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
match &self.mode {
PublisherMode::Core(client) => client
.publish(subject, payload.into())
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string())),
PublisherMode::JetStream(js) => js
.publish(subject, payload.into())
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?
.await
.map(|_| ())
.map_err(|e| DomainError::InfrastructureError(e.to_string())),
}
}
}
fn _assert_send_sync() {
fn check<T: Send + Sync>() {}
check::<NatsEventPublisher>();
}

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