Compare commits

..

266 Commits

Author SHA1 Message Date
7faf14fb2f docs: update project description to reflect current scope
All checks were successful
CI / Check / Test (push) Successful in 43m3s
2026-06-12 12:53:15 +02:00
ddb5966c9b docs: mention Insomnia collection in API section
Some checks failed
CI / Check / Test (push) Has been cancelled
2026-06-12 12:51:24 +02:00
4ec231017e docs: badges, TOC, quick start, env var table, screenshots
Some checks failed
CI / Check / Test (push) Has been cancelled
2026-06-12 12:46:31 +02:00
fab236688b screenshots
Some checks failed
CI / Check / Test (push) Has been cancelled
2026-06-12 12:31:13 +02:00
4683a408d7 fix(spa): use import type for UpdateUserSettingsRequest
All checks were successful
CI / Check / Test (push) Successful in 38m33s
2026-06-12 02:29:50 +02:00
6d4c70553a fix: remove unused UserFederationSettingsQuery import in worker/db.rs 2026-06-12 02:28:02 +02:00
ca7ca51949 feat: per-entity federation privacy toggles for reviews and watchlist
- add federate_reviews + federate_watchlist to UserSettings (default true)
- new UserFederationSettingsQuery port with FederationFlags struct
- remove get_user_federate_goals from LocalApContentQuery
- gate ReviewLogged, ReviewUpdated, WatchlistEntryAdded, on_poster_synced on flags
- goals gating migrated to UserFederationSettingsQuery
- ReviewDeleted and WatchlistEntryRemoved ungated (tombstones always fire)
- sqlite + postgres migrations and adapter impls
- settings API and SPA toggles
2026-06-12 02:26:01 +02:00
33aa5bdab3 fmt
All checks were successful
CI / Check / Test (push) Successful in 38m21s
2026-06-12 01:46:16 +02:00
b844339795 fix(domain): ImportSession::new() generates own ID, add from_persistence() 2026-06-12 01:41:03 +02:00
cedb13d7a8 fix(domain): typed VOs in MovieEnrichmentRequested and PersonEnrichmentRequested 2026-06-12 01:34:47 +02:00
aec5f6b058 feat(dependencies): add async-stream and bytes to Cargo.toml and Cargo.lock
Some checks failed
CI / Check / Test (push) Has been cancelled
refactor(adapters): update diary.rs to use BoxStream from futures
2026-06-12 01:20:45 +02:00
d9234ecd11 fix(presentation): log errors in diary export stream 2026-06-12 01:18:54 +02:00
010ee404c8 feat(presentation): pipe diary export stream to Body::from_stream 2026-06-12 01:15:23 +02:00
d4c42f8567 feat(application): export_diary::execute returns BoxStream<Bytes> 2026-06-12 01:11:36 +02:00
9c44330f14 feat(adapters): stream_user_history in SQLite and Postgres diary adapters 2026-06-12 01:10:21 +02:00
2fa118570f feat(export): stream_entries — CSV/JSON streaming via BoxStream<Bytes> 2026-06-12 01:08:11 +02:00
ded7517a8a feat(domain): DiaryRepository::stream_user_history, DiaryExporter::stream_entries 2026-06-12 01:05:32 +02:00
bf272bf8d9 perf(import): parallelize row processing with JoinSet + Semaphore (limit 10)
Some checks failed
CI / Check / Test (push) Has been cancelled
2026-06-12 00:40:27 +02:00
6f34b7b5ec fix(worker): nack on transient handler failures, ack on permanent
Some checks failed
CI / Check / Test (push) Has been cancelled
2026-06-12 00:30:06 +02:00
17d4de461b feat(domain): DomainError::is_transient() for retry classification 2026-06-12 00:27:16 +02:00
40cb15e7cb refactor(postgres): split fat PostgresRepository into per-port structs
Some checks failed
CI / Check / Test (push) Has been cancelled
2026-06-12 00:00:15 +02:00
c80287bb9e refactor(presentation): use split sqlite repos in api_test 2026-06-11 23:52:14 +02:00
06ab5c8df1 refactor(sqlite): split fat SqliteMovieRepository into per-port structs 2026-06-11 23:49:55 +02:00
57520c00f3 refactor: move AppContext to presentation crate, structurally enforce boundary
All checks were successful
CI / Check / Test (push) Successful in 39m33s
2026-06-11 23:18:28 +02:00
b5cc7f8371 refactor(search): fix test to not use AppContext 2026-06-11 23:00:01 +02:00
9ca5ada924 refactor(auth): LoginDeps, RegisterDeps, RefreshDeps, RegisterAndLoginDeps, RefreshSessionCleanupJob 2026-06-11 22:58:42 +02:00
70d1f10e3d refactor(users): fix test files to not use AppContext 2026-06-11 22:49:44 +02:00
61980b0cfb refactor(users): GetProfileDeps, UpdateProfileDeps, scoped Arc deps 2026-06-11 22:47:17 +02:00
7bf5c47f5b refactor(diary): DeleteReviewDeps, GetMovieSocialPageDeps, GetActivityFeedDeps 2026-06-11 22:37:35 +02:00
ddf100cfc2 refactor(wrapup): scoped deps — HandleWrapUpRequestedDeps, flat-Arc jobs 2026-06-11 22:29:30 +02:00
cdff0de53d refactor(movies): collapse single-field deps structs to Arc params 2026-06-11 22:17:09 +02:00
1e62f12903 refactor(movies): EnrichMovieDeps, ReindexSearchDeps, SyncPosterDeps, SearchReindexHandler, EnrichmentStalenessJob 2026-06-11 22:13:25 +02:00
66bd138927 refactor(person): EnrichPersonDeps + GetPersonDeps, PersonEnrichmentHandler 2026-06-11 22:05:38 +02:00
b29f3020e6 refactor(integrations): IngestWatchEventDeps, scoped Arc deps, WatchEventCleanupJob 2026-06-11 22:01:15 +02:00
76edd52bb0 refactor(import): fix test files to not use AppContext 2026-06-11 21:51:24 +02:00
b5ff43d9dc refactor(import): scoped Arc deps, ImportSessionCleanupJob 2026-06-11 21:49:15 +02:00
b552c1d156 refactor(watchlist): WatchlistAddDeps, scoped Arc deps 2026-06-11 21:40:48 +02:00
f006ba00a8 refactor(goals): scoped Arc deps instead of AppContext 2026-06-11 21:36:11 +02:00
2b295e10ba refactor(search): scoped Arc dep instead of AppContext 2026-06-11 21:32:19 +02:00
20ac0d3adf fmt
All checks were successful
CI / Check / Test (push) Successful in 38m10s
2026-06-11 14:44:53 +02:00
8ac87a3735 refactor: split jobs.rs into per-context modules 2026-06-11 14:44:23 +02:00
4f0f44dec3 test: refresh token rotation, logout revocation, login refresh token 2026-06-11 14:42:39 +02:00
96c753c2c6 spa: silent refresh on 401, persistent login 2026-06-11 14:39:10 +02:00
822f3f9d9c api: refresh + logout endpoints, update login response 2026-06-11 14:37:09 +02:00
55feaa353f app: refresh/logout use cases, update login with refresh token 2026-06-11 14:35:53 +02:00
3a3f3b3889 db: refresh_sessions migration + SQLite/Postgres adapters 2026-06-11 14:31:46 +02:00
ef9ecbae06 domain: add RefreshSession model + repository port 2026-06-11 14:29:43 +02:00
db285b513b fix: swap import/export icons 2026-06-11 14:13:51 +02:00
2617c77b42 feat: add arrow navigation to horizontal scroll strips 2026-06-11 14:12:42 +02:00
e618e1aa84 feat: horizontal poster strip for person filmography 2026-06-11 14:10:20 +02:00
7dc372a7b6 feat: redesign person page with Cards, SwipeTabs, collapsible bio 2026-06-11 14:07:27 +02:00
e39fcf6802 feat: show person age calculated from birthday 2026-06-11 14:04:52 +02:00
b09ef4686a fmt 2026-06-11 14:03:05 +02:00
2074a2244e refactor: move person enrichment staleness check to application layer 2026-06-11 14:01:44 +02:00
338ecb71c8 perf: reduce worker concurrency to 4 to ease SQLite write contention 2026-06-11 13:55:48 +02:00
8cb90b256c fix: guard also_known_as access when undefined 2026-06-11 13:52:44 +02:00
c05087a6c7 fmt 2026-06-11 13:51:33 +02:00
0fdc79af23 fix: add enrichment columns to test schema 2026-06-11 13:51:14 +02:00
262ba5ca39 fix: collapse nested if per clippy 2026-06-11 13:48:08 +02:00
57c720b22f fix: collapse nested if per clippy 2026-06-11 13:47:32 +02:00
53b7f730cb refactor: drop EnrichmentHandler alias, use MovieEnrichmentHandler directly 2026-06-11 13:46:30 +02:00
e8fa24bf9b refactor: split tmdb-enrichment into client, movie_handler, person_handler 2026-06-11 13:46:00 +02:00
7437ed89ad fix: handle PersonEnrichmentRequested in test fakes 2026-06-11 13:43:37 +02:00
b2a41db290 feat: rich person detail page with bio, dates, links 2026-06-11 13:42:04 +02:00
9b932cde8e feat: wire person enrichment handler, update API DTOs 2026-06-11 13:40:16 +02:00
a68e19aad7 feat: TMDB person enrichment client + event handler 2026-06-11 13:38:07 +02:00
371a3cdc46 app: person enrichment use case + staleness checks 2026-06-11 13:36:43 +02:00
517a18da8a db: person enrichment migrations + adapter updates 2026-06-11 13:34:10 +02:00
7df24a19ee domain: add person enrichment fields, event, port 2026-06-11 13:30:19 +02:00
549923b92e feat: show relative watched date and comment preview in profile entries 2026-06-11 13:03:49 +02:00
3cbb406ea7 feat: add export route to settings in route tree 2026-06-11 13:01:12 +02:00
21cc6ed437 fix: move useDocumentTitle before early returns (Rules of Hooks) 2026-06-11 12:59:13 +02:00
5dc90724d3 feat: JSON import + mapping presets in SPA
- Accept .json files in import upload, send format to backend
- Backend endpoint PUT /import/sessions/{id}/profile/{profile_id}
- Load saved presets on mapping step, auto-apply and skip to preview
- Save current mapping as preset on confirm step
- Delete presets from mapping step
2026-06-11 12:58:08 +02:00
9a894c3a95 refactor: move export to its own settings page under Data group 2026-06-11 12:46:46 +02:00
acc20d2f43 feat: dynamic page titles across SPA
useDocumentTitle hook sets document.title per page.
Dynamic: movie name, person name, username, wrapup year.
Static: diary, profile, search, social, all settings pages.
2026-06-11 12:45:01 +02:00
a95be0b131 feat: add search input on user profile pages 2026-06-11 12:39:14 +02:00
f10b114e83 feat: expose search param on user profile API endpoint 2026-06-11 12:37:20 +02:00
c020135cd1 feat: move diary export to settings page with CSV/JSON options 2026-06-11 12:36:03 +02:00
ad55897871 i18n: add export and profile search translations 2026-06-11 12:34:29 +02:00
87fcdc12ca clean up
All checks were successful
CI / Check / Test (push) Successful in 37m41s
2026-06-10 11:02:18 +02:00
d14199813f perf: optimize compile times
All checks were successful
CI / Check / Test (push) Successful in 37m59s
- Remove sqlx 'macros' feature — migrated to runtime queries in f4fd915,
  proc-macro crates (sqlx-macros-core, sqlx-macros) were dead weight
- Add [profile.dev] debug=1 (line tables only) + split-debuginfo=unpacked
  to speed up linking on macOS
- Add [profile.dev.package.'*'] opt-level=2 to compile deps faster at
  runtime; one-time cost on first build, cached after that
2026-06-10 03:33:48 +02:00
f52219b2b7 docs: update architecture diagram — add domain services, Password VO
Some checks failed
CI / Check / Test (push) Has been cancelled
2026-06-10 03:25:14 +02:00
6e21ec115d fmt
Some checks failed
CI / Check / Test (push) Has been cancelled
2026-06-10 03:24:17 +02:00
deae83cfd1 fix: Password uses char count not byte length, redact Debug output, tighten test assertion
Some checks failed
CI / Check / Test (push) Failing after 49s
2026-06-10 03:23:23 +02:00
c4d6b68ef9 refactor: enforce password min-length via domain Password value object
Some checks failed
CI / Check / Test (push) Failing after 49s
2026-06-10 03:15:43 +02:00
d8cff33679 refactor: move profile-field count validation into domain UserProfile 2026-06-10 03:13:19 +02:00
e9aa6131ae refactor: extract wrapup analytics into domain::services::wrapup_analyzer 2026-06-10 03:10:44 +02:00
43f6c5679d fmt
All checks were successful
CI / Check / Test (push) Successful in 15m39s
2026-06-10 02:56:56 +02:00
956e51530e refactor: move domain inline tests to separate files under tests/
Some checks failed
CI / Check / Test (push) Failing after 44s
Match the application crate convention: each source file references its
tests via #[cfg(test)] #[path = "tests/filename.rs"] mod tests; with
the test code in a sibling tests/ directory.

- events.rs       -> tests/events.rs
- value_objects.rs -> tests/value_objects.rs
- models/mod.rs   -> models/tests/mod.rs  (renamed from tests.rs)
- models/person.rs -> models/tests/person.rs
- models/goal.rs   -> models/tests/goal.rs
- models/watch_event.rs -> models/tests/watch_event.rs
- services/review_history.rs -> services/tests/review_history.rs
2026-06-10 02:55:47 +02:00
b882569ee1 fix: use correct i18n key for comment placeholder in queue tab
Some checks failed
CI / Check / Test (push) Has been cancelled
2026-06-10 02:47:04 +02:00
5dbc02b58f fix: remove .sqlx copy from Dockerfile
Some checks failed
CI / Check / Test (push) Has been cancelled
Queries were migrated to the runtime sqlx API (no query! macros) in
f4fd915, making the pre-verified .sqlx cache obsolete. The directory
is also gitignored, so the COPY would break any fresh build.
2026-06-10 02:41:59 +02:00
d389e26e39 fix: broadcast goal progress on review log, fix goal handler security gaps
Some checks failed
CI / Check / Test (push) Has been cancelled
- Broadcast GoalUpdated AP note after ReviewLogged so federated goal
  progress reflects the new review count without requiring a manual goal edit
- Add attribution check in GoalObjectHandler::on_update (mirrors
  review_handler) to prevent any remote actor from overwriting another's goal
- Implement on_actor_removed in GoalObjectHandler via new
  RemoteGoalRepository::remove_all_by_actor — remote goals were never
  cleaned up when an actor unfollowed or was deleted
- Add remove_all_by_actor to SQLite, Postgres, Noop, and test Panic impls
2026-06-10 02:40:25 +02:00
05d062f4e0 fmt
All checks were successful
CI / Check / Test (push) Successful in 15m57s
2026-06-09 02:31:02 +02:00
70b3ca0f5c refactor: split domain models, move presentation logic out of app layer
Some checks failed
CI / Check / Test (push) Failing after 47s
Split domain/models/mod.rs (630 lines) into focused files:
movie.rs, review.rs, user.rs, stats.rs, enrichment.rs, feed.rs.

Move URL/date formatting from application use cases to
presentation mappers — use cases now return raw domain data.

Delete watchlist/get_page.rs (was pure presentation logic),
replace with presentation/mappers/watchlist.rs.

Document signature conventions in CONTRIBUTING.md.
2026-06-09 02:29:11 +02:00
ac03182aa6 docs: add ReviewLogger to architecture diagram
Some checks failed
CI / Check / Test (push) Has been cancelled
2026-06-09 02:17:49 +02:00
6d3182525f fmt
Some checks failed
CI / Check / Test (push) Has been cancelled
2026-06-09 02:16:18 +02:00
7bcbc59587 fix: implement get_all_reviews_for_user, add crate metadata
Some checks failed
CI / Check / Test (push) Failing after 43s
Replace todo!() stubs in sqlite/postgres adapters with
actual queries. Add description+license to presentation crate.
2026-06-09 02:12:35 +02:00
d867a14b28 add 400+ unit tests for domain and application layers
Some checks failed
CI / Check / Test (push) Has been cancelled
Extract ReviewLogger trait to decouple import/integrations
from diary::log_review (cross-module coupling smell).

Add in-memory fakes for all repository ports, enabling
isolated testing of every use case module without a database.

Coverage: domain+application 22% → 80%, 427 tests.
2026-06-09 02:07:26 +02:00
30a6200b5b remove wrapup video rendering (ffmpeg)
All checks were successful
CI / Check / Test (push) Successful in 15m34s
SPA handles wrapup visuals client-side; server-side
renderer was dead code pulling in ffmpeg + image crates.
2026-06-09 00:36:44 +02:00
f4fd915e35 refactor: replace sqlx compile-time macros with runtime queries
Some checks failed
CI / Check / Test (push) Failing after 44s
No longer requires DATABASE_URL at build time.
2026-06-09 00:19:02 +02:00
f3eee9131b chore: remove stale .sqlx cache, add to gitignore
Some checks failed
CI / Check / Test (push) Has been cancelled
2026-06-09 00:09:30 +02:00
9e4370b068 fix: register goal + settings endpoints in OpenAPI spec
Some checks failed
CI / Check / Test (push) Has been cancelled
2026-06-09 00:07:29 +02:00
37ab24be74 docs: npm → bun
Some checks failed
CI / Check / Test (push) Has been cancelled
2026-06-09 00:06:22 +02:00
307113381f docs: add CONTRIBUTING.md with setup, architecture, PR guidelines
Some checks failed
CI / Check / Test (push) Has been cancelled
2026-06-09 00:04:30 +02:00
6d238b6c3d docs: mark TUI as deprecated experiment
Some checks failed
CI / Check / Test (push) Has been cancelled
2026-06-09 00:02:20 +02:00
a7a11dde08 refactor: split monolithic handlers + testing into domain-grouped modules
Some checks failed
CI / Check / Test (push) Has been cancelled
handlers/api.rs (1706 LOC) + html.rs (1735 LOC) → 12 domain files:
auth, diary, movies, users, search, watchlist, goals, social,
integrations, helpers + existing import/webhook/wrapup/images/rss.

domain/testing.rs (1309 LOC) → testing/ module:
in_memory, fakes, noops, panics, wrapup.

Update README + architecture.mmd with goals feature.
2026-06-08 23:59:23 +02:00
988e15eac6 fix: add missing common.edit i18n key
All checks were successful
CI / Check / Test (push) Successful in 18m44s
2026-06-08 22:38:54 +02:00
fff5f4af2f feat: goals — "watch N movies in YEAR" with progress bar
Domain: Goal entity, UserSettings (federation toggle), RemoteGoalEntry.
Ports: GoalRepository, UserSettingsRepository, RemoteGoalRepository.
Adapters: sqlite + postgres repos, migrations, AP content query extensions.
Application: CRUD use cases (create/update/delete/get/list), settings use cases.
API: 7 endpoints (/goals CRUD, /users/{id}/goals, /settings) with utoipa docs.
Federation: GoalObject (Note + goal discriminator), outbound broadcast with
per-user toggle, inbound GoalObjectHandler in CompositeObjectHandler.
SPA: API client + hooks, GoalCard (shadcn Card+Progress+DropdownMenu),
GoalSheet (Drawer), profile integration (editable own, read-only others),
federation toggle in settings (Switch).
Classic HTML: glassmorphic goal card on profile, Frutiger Aero styling.
Progress computed from existing reviews — backwards compatible.
2026-06-08 22:37:52 +02:00
213f9a2433 Remove backdrop filter from input slot in aero-theme.css for improved performance
All checks were successful
CI / Check / Test (push) Successful in 17m46s
2026-06-08 21:05:16 +02:00
4dacbbfded fix: update poster_url on remote review Update activity
All checks were successful
CI / Check / Test (push) Successful in 21m35s
on_update was ignoring posterUrl from the AP note, so remote instances
never got the poster even after receiving the Update.
2026-06-04 23:21:19 +02:00
886f26c7dc fix: broadcast AP Update after poster sync to fix missing posters on remote instances
Some checks failed
CI / Check / Test (push) Has been cancelled
New movies had no poster at AP broadcast time (race between poster sync
and ReviewLogged handler). PosterSynced event now fires after sync
completes, triggering Update notes so remote apps get the poster URL.
2026-06-04 23:12:27 +02:00
6a10ba15c1 feat(spa): comment field in queue review, date picker in log sheet
All checks were successful
CI / Check / Test (push) Successful in 17m20s
2026-06-04 22:35:30 +02:00
237f1e7d32 style: fmt persons test
All checks were successful
CI / Check / Test (push) Successful in 16m49s
2026-06-04 17:59:05 +02:00
c647f4c13b fix: clippy 1.96 lints — map_or, duplicate_mod, needless_borrows, slice_refs
Some checks failed
CI / Check / Test (push) Failing after 44s
2026-06-04 17:58:01 +02:00
3bb2dbee19 ci: split workflows — Gitea tests only, GitHub tests + Docker push
Some checks failed
CI / Check / Test (push) Failing after 6m24s
2026-06-04 17:48:37 +02:00
b0bfc149e1 ci: build + push Docker image to ghcr.io on master/tags
Some checks failed
CI / Build & Push Docker Image (push) Has been cancelled
CI / Check / Test (push) Has been cancelled
2026-06-04 17:47:04 +02:00
7e8a1b8379 feat: batteries-included deployment — compose, .env.example, sane defaults
Some checks failed
CI / Check / Test (push) Failing after 6m21s
2026-06-04 17:32:34 +02:00
4bd8dcbf05 feat: wrapup wow — animated counters, scroll-reveal, fun facts, component split, budget formatting
Some checks failed
CI / Check / Test (push) Failing after 6m25s
2026-06-04 17:15:35 +02:00
ebf9a9f4a8 feat: replace CSS bar charts with recharts, fix label readability
Some checks failed
CI / Check / Test (push) Failing after 6m20s
2026-06-04 17:04:29 +02:00
a76386345f feat: SPA polish — wrapup sections, shareable card, webhook instructions, blocked merge
Some checks failed
CI / Check / Test (push) Failing after 6m35s
2026-06-04 16:56:09 +02:00
49728f8cd7 fix: fall back to converted extensions in object storage get
Some checks failed
CI / Check / Test (push) Failing after 6m36s
2026-06-04 16:22:57 +02:00
5334312d64 refactor: extract reindex + enrichment logic from handlers into use cases
Some checks failed
CI / Check / Test (push) Failing after 6m45s
2026-06-04 16:09:35 +02:00
81a377cbb2 docs: proper SPA readme
Some checks failed
CI / Check / Test (push) Failing after 6m21s
2026-06-04 15:58:03 +02:00
689c8a9ac3 docs: add hexagonal architecture mermaid diagram
Some checks failed
CI / Check / Test (push) Has been cancelled
2026-06-04 15:52:38 +02:00
5e6bc93a6a feat: SPA quick wins — refresh, back nav, watchlist add, dates, haptics, export
Some checks failed
CI / Check / Test (push) Failing after 7m1s
- feed refresh button with spinning icon
- BackButton component using history.back()
- watchlist bookmark icon on search results
- timeAgo/shortDate date formatting (date-fns)
- haptic feedback on star rating, swipe-to-delete, review log
- global error toasts via QueryClient mutation defaults
- diary CSV export button
2026-06-04 15:45:31 +02:00
4a3a99c6d2 fix: disable vaul repositionInputs to fix iOS keyboard in drawers
Some checks failed
CI / Check / Test (push) Failing after 6m35s
2026-06-04 15:35:00 +02:00
dacc057af6 fix: lazy-load wrapup backgrounds, cap sqlite pool to 4 connections
Some checks failed
CI / Check / Test (push) Failing after 6m32s
backgrounds were decoded to RGBA at startup (~173MB for 9 images).
now only store paths, decode on demand during video generation.
2026-06-04 14:57:47 +02:00
01c1082290 feat: SPA bug fixes, interactivity, federation badges, admin reindex
Some checks failed
CI / Check / Test (push) Failing after 10m55s
- fix wrapup status "completed" → "Ready"
- fix unfollow sending {handle} instead of {actor_url}
- fix missing post import in users.ts
- fix feed/activity cache not invalidated on review delete/log
- add person_id to cast/crew types, link to /people pages
- add movie_id to wrapup MovieRef, link highlights to /movies pages
- add wrapup actor profile images + clickable person links
- add federated review globe badge in feed and movie detail
- add fediverse handle (@user@instance) in follower/following cards
- add admin reindex search button in settings
- add wrapup user picker for admins
- add username/display_name to user summary type
- use tmdbProfileUrl for person search results
2026-06-04 14:43:41 +02:00
bd7dc648c4 feat: search reindex, worker improvements, person IDs, user display names
- add admin POST /api/v1/admin/reindex-search endpoint + event-driven handler
- backfill persons from movie_cast/movie_crew into persons table
- paginate person list_page/backfill_from_credits_batch to cap memory
- concurrent worker event dispatch with semaphore (max 8)
- graceful worker shutdown (drain in-flight tasks on SIGINT)
- always ack events, log handler errors as warnings (no infinite retry)
- NATS ack_wait 600s, AtomicBool guard against concurrent reindex
- add username/display_name to UserSummaryDto and users list
- add person_id to CastMemberDto/CrewMemberDto via get_movie_profile use case
- add movie_id to wrapup MovieRef, person_id to wrapup PersonStat
- thread tmdb_person_id through wrapup cast pipeline
- add is_federated to FeedEntryDto
- cap orphaned persons query with LIMIT 500
- add SPA link to classic site footer
2026-06-04 14:43:28 +02:00
af8e58aeb8 fix: use avatar/banner URLs directly, not through posterUrl()
Some checks failed
CI / Check / Test (push) Failing after 11m13s
2026-06-04 04:30:38 +02:00
fd9a053702 fix: exclude spa/.env from docker build to prevent baked localhost URLs
Some checks failed
CI / Check / Test (push) Has been cancelled
2026-06-04 04:27:02 +02:00
092e769641 fix: 401 redirect uses /app/login instead of /login
Some checks failed
CI / Check / Test (push) Has been cancelled
2026-06-04 04:22:20 +02:00
b20d23fbb3 fix: exclude spa/node_modules and spa/dist from docker context 2026-06-04 04:21:25 +02:00
b9c0b10740 feat: add SPA, serve at /app/, update Dockerfile and README
- React + TanStack Router + shadcn/ui SPA under spa/
- serve spa/dist at /app/ with index.html fallback for client routing
- Dockerfile: node build stage for SPA, copy dist into runtime image
- README: document SPA, CORS_ORIGINS env var, architecture entry
- vite base set to /app/, manifest.json paths fixed
2026-06-04 04:20:15 +02:00
15dc0e526b feat: expose display_name, also_known_as, profile fields in GET /profile 2026-06-04 02:58:12 +02:00
cf2f4a1b4f feat: watchlist add accepts title/year/imdb for movies not in DB 2026-06-04 02:47:55 +02:00
e7cdbf9ca7 fix: user profile URLs, typed returns for following/followers
- avatar_url/banner_url now use full base_url + /images/ prefix
- get_user_following/followers return Result<Json<_>, ApiError>
- add ap_to_domain helper for anyhow→DomainError conversion
2026-06-04 02:40:04 +02:00
837b7866af feat: include avatar_url and banner_url in UserProfileResponse 2026-06-04 02:37:16 +02:00
c4908b7765 feat: API endpoints for any user's following/followers 2026-06-04 02:34:01 +02:00
dbc78a1ff4 feat: add user_id to FeedEntryDto 2026-06-04 02:22:08 +02:00
a97e263ed3 fix: always return following/followers counts, only hide pending for non-own profiles 2026-06-04 02:19:27 +02:00
b1d667d3cb fix: user profile returns social counts when viewing own profile 2026-06-04 02:18:28 +02:00
bf73d4a695 feat: CORS, role in auth, banner_url, diary sort, cleanup
- CORS layer on API routes via CORS_ORIGINS env var
- role field in login + profile responses
- banner_url in profile response
- diary sort_by: rating_desc/rating_asc/date_asc/date_desc
- UserRole::as_str() to deduplicate role mapping
- typed DTOs for import preview (replace ad-hoc JSON)
- warn on invalid CORS origins
2026-06-04 02:06:51 +02:00
7b9b0f9ffe feat: API preview endpoint for import sessions 2026-06-04 01:59:09 +02:00
7d6f874ae7 feat: activity feed accepts sort_by param 2026-06-04 01:35:45 +02:00
4206bb4b06 fix: diary sort_by rating_desc/rating_asc in all/movie queries 2026-06-04 01:28:35 +02:00
004243dcbb fix: use embed=true for serde bool compat
Some checks failed
CI / Check / Test (push) Failing after 6m35s
2026-06-03 10:17:43 +02:00
c798b851dc fmt
Some checks failed
CI / Check / Test (push) Has been cancelled
2026-06-03 10:14:53 +02:00
c18d4eebc1 fix: use array instead of vec for clippy 2026-06-03 10:14:20 +02:00
3f6e01ed65 feat: add embed snippet to profile settings 2026-06-03 10:09:38 +02:00
19642175eb feat: wire embed param to EmbedProfileTemplate 2026-06-03 10:08:51 +02:00
8ca378b25f feat: add embed.css standalone stylesheet 2026-06-03 10:07:46 +02:00
fad73a9fab feat: add embed_profile.html template 2026-06-03 10:07:45 +02:00
627db45a56 feat: add embed_base.html template shell 2026-06-03 10:07:43 +02:00
976d872eed feat: add EmbedProfileTemplate struct 2026-06-03 10:07:41 +02:00
6c7ce24e1f feat: add embed param to ProfileQueryParams 2026-06-03 10:07:39 +02:00
6668ba511c fmt
Some checks failed
CI / Check / Test (push) Failing after 6m32s
2026-06-03 01:38:25 +02:00
430fbb21ea refactor: rename image-storage crate to object-storage
Some checks failed
CI / Check / Test (push) Failing after 44s
2026-06-03 01:37:14 +02:00
f262417971 refactor: rename ImageStorage → ObjectStorage
Some checks failed
CI / Check / Test (push) Failing after 46s
2026-06-03 01:33:08 +02:00
d94ccbe057 refactor: store typed WrapUpReport in domain, serialize in adapters
Some checks failed
CI / Check / Test (push) Failing after 45s
2026-06-03 01:25:16 +02:00
e4b8ba550e refactor: extract storage key conventions into WrapUpStorage
Some checks failed
CI / Check / Test (push) Failing after 43s
2026-06-03 01:20:51 +02:00
3cec726e3d refactor: move VideoRenderConfig from domain to adapter, inject at construction
Some checks failed
CI / Check / Test (push) Failing after 44s
2026-06-03 01:18:52 +02:00
e8e83d3f16 assets + fmt
Some checks failed
CI / Check / Test (push) Has been cancelled
2026-06-03 01:13:06 +02:00
8cabc6af70 fix: clippy — redundant closure in AdminApiUser
Some checks failed
CI / Check / Test (push) Has been cancelled
2026-06-03 01:12:36 +02:00
e378191045 fix: clippy — collapse nested if in cast photo store
Some checks failed
CI / Check / Test (push) Has been cancelled
2026-06-03 01:12:07 +02:00
bca07b7824 fix: clippy — collapse nested ifs, use div_ceil
Some checks failed
CI / Check / Test (push) Failing after 41s
2026-06-03 01:11:17 +02:00
857896f057 feat: add video download link to wrapup HTML page
Some checks failed
CI / Check / Test (push) Failing after 41s
2026-06-03 01:06:11 +02:00
e905842b62 fix: idempotency guard on wrapup handler to prevent duplicate processing
Some checks failed
CI / Check / Test (push) Failing after 42s
2026-06-03 01:01:50 +02:00
83103e5eb5 feat: redesign wrapup HTML with glass cards, ranked lists, better layout
Some checks failed
CI / Check / Test (push) Failing after 42s
2026-06-03 01:00:50 +02:00
fc086de7f7 refactor: move DateRange validation to value object, add delete/cleanup
Some checks failed
CI / Check / Test (push) Failing after 40s
2026-06-03 00:58:07 +02:00
3a66f89609 fix: reverse rating bar order to 5★→1★ in video slides
Some checks failed
CI / Check / Test (push) Failing after 42s
2026-06-03 00:55:43 +02:00
45d6183ea4 fix: reverse rating bar order to 5★→1★
Some checks failed
CI / Check / Test (push) Failing after 41s
2026-06-03 00:54:59 +02:00
241063c914 feat: wrapup date validation, delete endpoint, failed record cleanup
Some checks failed
CI / Check / Test (push) Failing after 41s
2026-06-03 00:54:08 +02:00
3f483f8f81 fix: alpha-blend glass panels instead of opaque overwrite
Some checks failed
CI / Check / Test (push) Failing after 41s
2026-06-03 00:46:09 +02:00
c842ad6a55 feat: download top-5 cast photos during TMDb enrichment
Some checks failed
CI / Check / Test (push) Failing after 41s
2026-06-03 00:42:25 +02:00
a5cf62e281 fix: cover-fill backgrounds, bigger highlight posters
Some checks failed
CI / Check / Test (push) Failing after 42s
2026-06-03 00:40:35 +02:00
6fb00f9205 docs: add WRAPUP_BG_DIR to env/readme
Some checks failed
CI / Check / Test (push) Failing after 40s
2026-06-03 00:35:15 +02:00
d52120d6a9 feat: frutiger aero visual overhaul — backgrounds, glass panels, cast photos, full mosaic
Some checks failed
CI / Check / Test (push) Failing after 42s
2026-06-03 00:34:13 +02:00
e57ddd78ac feat: frutiger aero visual overhaul for wrapup video slides 2026-06-03 00:32:39 +02:00
0d02f23f4f fix: decode AVIF posters via ffmpeg fallback for mosaic slide
Some checks failed
CI / Check / Test (push) Failing after 41s
2026-06-03 00:23:39 +02:00
86639853d2 fix: render genres via SlideRenderer, enable AVIF decoding, add poster fetch logging
Some checks failed
CI / Check / Test (push) Failing after 42s
2026-06-03 00:19:18 +02:00
7155bea78e fix: disable plotters axis labels to avoid system font lookup
Some checks failed
CI / Check / Test (push) Failing after 43s
2026-06-03 00:10:56 +02:00
8429d13db0 fix: enable ab_glyph font backend for plotters
Some checks failed
CI / Check / Test (push) Failing after 42s
2026-06-03 00:09:10 +02:00
cb611bcc7d fix: wire FfmpegWrapUpRenderer into worker when ffmpeg available
Some checks failed
CI / Check / Test (push) Failing after 41s
2026-06-03 00:06:04 +02:00
bb503f3ce8 feat: AdminApiUser extractor for Bearer-token admin endpoints
Some checks failed
CI / Check / Test (push) Has been cancelled
2026-06-03 00:00:45 +02:00
bf0350c87a fmt
Some checks failed
CI / Check / Test (push) Failing after 6m39s
2026-06-02 23:50:20 +02:00
9e13f04e9c fix: clippy warnings in wrapup compute + renderer
Some checks failed
CI / Check / Test (push) Has been cancelled
2026-06-02 23:49:39 +02:00
e69f77a99f docs: add wrap-up to Dockerfile, .env.example, README
Some checks failed
CI / Check / Test (push) Failing after 42s
2026-06-02 23:47:21 +02:00
1e063b6580 feat: streaming video download via ImageStorage::get_stream
Some checks failed
CI / Check / Test (push) Failing after 41s
2026-06-02 23:45:31 +02:00
f160adcd1c feat: wrapup env vars + render concurrency semaphore
Some checks failed
CI / Check / Test (push) Failing after 43s
2026-06-02 23:41:08 +02:00
efd1214a4c feat: font rendering + logo branding on wrapup slides
Some checks failed
CI / Check / Test (push) Failing after 43s
2026-06-02 23:16:55 +02:00
21c33b169e feat: gate wrapup generate behind admin role 2026-06-02 23:14:06 +02:00
490bd97a40 feat: wire video renderer pipeline + download endpoint
Some checks failed
CI / Check / Test (push) Failing after 41s
2026-06-02 22:34:55 +02:00
d45d8aa913 feat: video renderer adapter w/ slides + charts + ffmpeg 2026-06-02 22:31:45 +02:00
f00a2cbbb8 feat: HTML wrap-up page with Askama template 2026-06-02 22:28:28 +02:00
c0b3fb6940 fix: collapse nested if in wrapup generate
Some checks failed
CI / Check / Test (push) Failing after 44s
2026-06-02 22:20:29 +02:00
ea43911984 feat: wire real wrapup adapters into presentation + worker 2026-06-02 22:19:29 +02:00
5a15bea3d4 feat: wrap-up REST API endpoints 2026-06-02 22:17:11 +02:00
5e740ba2a1 fix: add async_trait + exhaustive match for wrapup events in tests 2026-06-02 22:14:50 +02:00
7ef8912d69 feat: wrapup worker handler + auto-generate job 2026-06-02 22:13:08 +02:00
ac05cdfeaf fix: add WrapUp events to NATS subject mapping 2026-06-02 22:09:57 +02:00
b171d2d1e2 feat(application): wrapup generate/get/list use cases 2026-06-02 22:09:08 +02:00
59b42ce810 feat: add WrapUpRequested/Completed domain events 2026-06-02 22:06:06 +02:00
5a6abdcc23 feat(adapters): WrapUpStatsQuery + WrapUpRepository for postgres/sqlite 2026-06-02 22:04:46 +02:00
a95d831fd1 feat(domain): add WrapUpRecord, WrapUpRepository port 2026-06-02 21:59:47 +02:00
4c75113c4f chore: wire WrapUpStatsQuery panic stub into binaries
Some checks failed
CI / Check / Test (push) Failing after 44s
2026-06-02 21:49:54 +02:00
8fec989dc6 test(wrapup): unit tests for stats computation 2026-06-02 21:46:51 +02:00
8c31a2b829 feat(application): add wrapup stats computation engine 2026-06-02 21:44:46 +02:00
4df78221a8 feat(domain): add WrapUpStatsQuery port and in-memory fake 2026-06-02 21:42:15 +02:00
e8b2d4f7ee feat(domain): add WrapUpReport model and supporting types 2026-06-02 21:40:43 +02:00
c878c0358f fix: add 403 response to confirm/dismiss OpenAPI annotations
Some checks failed
CI / Check / Test (push) Failing after 6m22s
2026-06-02 21:06:22 +02:00
4067dedb28 refactor: add DomainError::Forbidden + centralize error-to-HTTP mapping
Ownership checks (delete_review, confirm/dismiss watch events) now
return Forbidden instead of Unauthorized. Presentation layer maps
DomainError→StatusCode via domain_error_response helper, replacing
verbose per-handler match arms.
2026-06-02 21:00:22 +02:00
28170c95d4 chore: fmt + remove dead federation module
Some checks failed
CI / Check / Test (push) Failing after 5m58s
2026-06-02 20:44:08 +02:00
62fd6682c6 refactor: extract view-model mappers from presentation handlers
Some checks failed
CI / Check / Test (push) Has been cancelled
Move mapping logic (domain→DTO/template structs) into mappers/ module.
Handlers now call mapper fns instead of inline conversions.
2026-06-02 20:43:33 +02:00
b9210b6c4e fix: batch N+1 queries in import duplicate check and watch event dismiss
Some checks failed
CI / Check / Test (push) Failing after 5m54s
apply_mapping: 2 batch queries instead of up to 2N per-row lookups
dismiss: single fetch + single update instead of 2N per-event queries
2026-06-02 20:05:15 +02:00
ac7edd6953 WIP: federation + integrations
Some checks failed
CI / Check / Test (push) Failing after 5m56s
2026-06-02 19:50:19 +02:00
dcc9244d4e refactor: group use cases into DDD bounded contexts
Flat use_cases/ (44 files) + monolithic commands.rs/queries.rs
split into diary/, movies/, watchlist/, import/, auth/, users/,
integrations/, search/, person/, federation/ — each with own
commands.rs, queries.rs, and use case modules.

Inline tests extracted to sibling tests/ dirs.
2026-06-02 19:49:09 +02:00
aadad3cfb0 feat: Jellyfin/Plex auto-import via watch queue
Some checks failed
CI / Check / Test (push) Failing after 6m5s
Webhook ingestion from media servers — movies land in a pending
watch queue, user rates and confirms to create diary entries.

- domain: WatchEvent, WebhookToken models, MediaServerParser port
- adapters: jellyfin + plex parser crates, SQLite + Postgres repos
- application: ingest/confirm/dismiss/cleanup use cases, token mgmt
- presentation: webhook endpoints (bearer + query param auth),
  watch queue + integrations settings HTML pages, OpenAPI docs
- worker: WatchEventCleanupJob (daily, 30d retention)

Movie resolution deferred to confirm — single canonical path
through log_review for enrichment, poster fetch, federation.
2026-06-02 17:34:16 +02:00
6bd728fd50 feat: replace instance actor decorator with real DB row
Some checks failed
CI / Check / Test (push) Failing after 52s
Migrations (postgres + sqlite) insert a service actor with a well-known
UUID. Removes the InstanceActorUserRepo wrapper.
2026-05-30 03:16:03 +02:00
1b9a6617f3 feat: add instance actor for signed fetch (Secure Mode support)
Some checks failed
CI / Check / Test (push) Has been cancelled
2026-05-30 03:11:16 +02:00
14066ea469 chore: upgrade k-ap to 0.4.0
Some checks failed
CI / Check / Test (push) Has been cancelled
Map new fetched_at field in RemoteActor for postgres and sqlite adapters.
2026-05-30 03:00:44 +02:00
7456a18fc9 ci: add clippy component to toolchain
Some checks failed
CI / Check / Test (push) Failing after 6m14s
2026-05-30 02:29:14 +02:00
c2985dd579 ci: drop release-build, add clippy
Some checks failed
CI / Check / Test (push) Failing after 42s
2026-05-30 02:24:05 +02:00
84ddf04d28 harden federation: sanitize incoming AP content, fix error handling, tighten rate limits
Some checks failed
CI / Check / Test (push) Successful in 10m54s
CI / Release build (push) Failing after 1m20s
2026-05-29 12:23:29 +02:00
d1f9f55d4f fix: wire DeliveryRequested federation events — outbound AP delivery was broken
FederationEventBridge silently dropped DeliveryRequested events from k-ap,
so no Create/Delete/Accept activities were pushed to follower inboxes.
Reviews only reached remote instances via outbox backfill (pull), and
deletes never propagated.

Bridge now publishes FederationDeliveryRequested domain events through the
event bus; worker calls ap_service.deliver_to_inbox() to send them.
2026-05-29 12:09:02 +02:00
62ddb014d2 feat: add Gitea registry configuration to Cargo
Some checks failed
CI / Check / Test (push) Successful in 11m14s
CI / Release build (push) Has been cancelled
2026-05-29 11:49:57 +02:00
5b8f255607 docs: update README — add watchlist/profiles, fix architecture, add make targets
Some checks failed
CI / Check / Test (push) Has been cancelled
CI / Release build (push) Has been cancelled
2026-05-29 11:48:05 +02:00
c3b89f6dc6 refactor: extract business logic from handlers to application layer
Some checks failed
CI / Check / Test (push) Has been cancelled
CI / Release build (push) Has been cancelled
Move domain logic out of 7 handlers into use cases:
- activity feed: FollowingFilter construction
- user profile: social counts + pending followers
- users list: parallel local+remote actor loading
- watchlist page: local-vs-remote branching
- sync_poster: movie lookup + validation
- get_profile: avatar URL construction
- post_register: register+login orchestration

Add SocialQueryPort.{count_following,count_accepted_followers,
get_pending_followers} to AppContext behind federation feature gate.
2026-05-29 11:41:16 +02:00
2355f89bed refactor: fix all clippy warnings properly
- UserProfile struct groups display_name/bio/avatar/banner/also_known_as/profile_fields
- User::from_persistence takes UserProfile (6 args, was 11)
- PersistedReview struct for Review::from_persistence (1 arg, was 8)
- WatchlistApInput struct for watchlist_to_ap_object (1 arg, was 8)
- ActivityPubDeps struct for activitypub::wire (1 arg, was 11)
- FederationRepos type alias for wire() return types
- FeedSortBy: impl std::str::FromStr instead of inherent from_str
- postgres users.rs: row_to_user takes &PgRow like sqlite
- collapse nested ifs in multipart handlers
- type alias for complex return types (image-converter, worker)
- tui: allow large_enum_variant at crate level (pre-existing, unrelated)
2026-05-29 11:19:02 +02:00
68a939f6c4 Refactor code for improved readability and consistency
- Simplified error handling in `PostgresApContentQuery` and `SqliteApContentQuery` by aligning the formatting of `try_get` calls.
- Removed unnecessary line breaks and improved formatting in various repository implementations for better readability.
- Consolidated imports in `lib.rs` and `factory.rs` to maintain a cleaner structure.
- Enhanced consistency in async function signatures across multiple files.
- Updated test helpers and use cases to streamline code and improve clarity.
- Refactored `InMemory` repositories to enhance readability by aligning method implementations.
2026-05-29 10:58:44 +02:00
412ab12695 feat: add Makefile for local development checks and formatting 2026-05-29 10:58:20 +02:00
36d15e1344 fix: AP bugs — backfill mapping, review activity type, also_known_as parse
- BackfillRequested now maps to BackfillFollower domain event (not FollowAccepted);
  worker calls run_backfill_for_follower to send LOCAL content to new follower inbox,
  instead of incorrectly trying to import from an inbox URL as if it were an outbox
- reviews broadcast as Create activity instead of Add (semantically correct)
- also_known_as JSON parse failure logs warning + preserves raw string as single-element
  vec instead of silently returning empty
2026-05-29 10:54:11 +02:00
624cfe5799 feat: migrate k-ap 0.1.10→0.3.1, fix AP gaps
- split FederationRepository into FollowRepository, ActorRepository, BlocklistRepository, ActivityRepository
- RemoteActor: 5 new fields (bio, banner_url, followers_url, following_url, also_known_as)
- ApObjectHandler split: get_local_objects_page/count_local_posts → ApContentReader trait
- builder API: positional args → named setters
- broadcast_create_note/update_note: add ApVisibility + mentioned_inboxes params
- backfill_outbox → import_remote_outbox
- ApUser: also_known_as Option<String> → Vec<String>, new fields

AP gaps fixed:
- add GET /users/{id}/followers + /following with content negotiation
- wire EventPublisher into builder via FederationEventBridge adapter
- add display_name field full stack (domain→DB→API→AP)
- DB-side outbox pagination (get_local_reviews_page)
- set featured_url on ApUser
2026-05-29 10:42:53 +02:00
bace54c552 fix
Some checks failed
CI / Check / Test (push) Failing after 49s
CI / Release build (push) Has been skipped
2026-05-28 17:06:26 +02:00
4ebd593293 feat: add OpenAPI doc for update_profile_fields endpoint
Some checks failed
CI / Check / Test (push) Failing after 43s
CI / Release build (push) Has been skipped
Add ProfileFieldDto and UpdateProfileFieldsRequest to api-types,
annotate update_profile_fields_handler with utoipa::path, register
in UsersDoc. Coverage now 100%.
2026-05-28 04:03:41 +02:00
51bd580a04 watchlist backfill
Some checks failed
CI / Check / Test (push) Failing after 57s
CI / Release build (push) Has been skipped
2026-05-28 03:52:38 +02:00
b3e7a42d2f fix: remove stale activitypub-base COPY from Dockerfile
Some checks failed
CI / Check / Test (push) Failing after 44s
CI / Release build (push) Has been skipped
2026-05-25 09:46:36 +02:00
410e25a562 chore: bump k-ap to v0.1.3, drop direct activitypub_federation dep
Some checks failed
CI / Release build (push) Has been cancelled
CI / Check / Test (push) Has been cancelled
2026-05-25 09:45:47 +02:00
ca8fdb33ba fix: resolve movies-diary compile errors after k-ap migration
Some checks failed
CI / Check / Test (push) Failing after 44s
CI / Release build (push) Has been skipped
2026-05-17 23:12:03 +02:00
6efc47a891 chore: replace local activitypub-base with k-ap git dep 2026-05-17 23:06:20 +02:00
f80d3b5983 docs/ci: note unit tests in README, gate release build to master
Some checks failed
CI / Check / Test (push) Failing after 43s
CI / Release build (push) Has been skipped
2026-05-14 00:43:07 +02:00
edc1f6c850 feat: domain mocks, TestContextBuilder, use case tests, factory pattern
- Add test-helpers feature to domain crate with in-memory mocks and panic stubs for all ports
- Add TestContextBuilder to application crate for zero-database test setup
- Add unit tests for log_review, register, login, add_to_watchlist, delete_review use cases
- Extract DatabaseAdapters factory and build_* helpers into presentation/src/factory.rs
- Refactor wire_dependencies() in main.rs to use factory module
2026-05-14 00:41:25 +02:00
e41d85bd7e feat: show user avatars on /users page
All checks were successful
CI / Check / Test / Build (push) Successful in 24m46s
2026-05-13 23:45:19 +02:00
19171806b9 fmt
Some checks failed
CI / Check / Test / Build (push) Has been cancelled
2026-05-13 23:38:57 +02:00
7415b91e23 ci: add GitHub/Gitea Actions workflow
Some checks failed
CI / Check / Test / Build (push) Failing after 2m6s
2026-05-13 23:35:05 +02:00
25cd6c9294 fix: handle absolute poster URLs from federated instances in templates 2026-05-13 23:32:08 +02:00
c420826474 fix: update tests for expanded User profile signature 2026-05-13 23:27:36 +02:00
5edac78add fix: redirect to user-specific following list after follow action 2026-05-13 23:23:47 +02:00
fdd61ae701 feat: refactor user profile handling and integrate ApProfileField structure 2026-05-13 22:59:38 +02:00
815178e6a4 feat(ap): ActivityPub spec compliance and profile completeness
Phase 1 — spec compliance:
- Add AS_PUBLIC constant; add to/cc fields to CreateActivity, DeleteActivity,
  UpdateActivity, AddActivity; populate on all broadcast call sites
- Add @context to outbox CreateActivity items
- Set manuallyApprovesFollowers: true to match actual Pending follow flow
- Gate PermissiveVerifier behind FEDERATION_DEBUG env var
- Add updated timestamp to Person actor JSON
- Improve actor update delivery logging

Phase 2a Batch 1 — AP layer:
- Add /inbox shared inbox route; add endpoints.sharedInbox to Person
- Paginate followers and following collections (20/page, OrderedCollectionPage)

Phase 2a Batch 2 — profile completeness:
- DB migrations: banner_path, also_known_as columns; user_profile_fields table
- ProfileField value object; UserProfileFieldsRepository port
- Banner image upload (stored via image-converter, surfaced as image in Person)
- alsoKnownAs field in Person (account migration support)
- Custom profile fields (up to 4 PropertyValue attachments in Person)
- Profile settings UI: banner preview/upload, alsoKnownAs input, fields form
- PUT /api/v1/profile/fields API endpoint
2026-05-13 22:21:41 +02:00
0a97fe5544 feat: update calamine dependency version to 0.35 in importer 2026-05-13 11:17:13 +02:00
7d4c1454f0 feat: add watchlist entry events and update NATS subject mapping 2026-05-13 01:55:12 +02:00
10fcc27339 feat: update dependencies and improve code formatting in worker tests
Co-authored-by: Copilot <copilot@github.com>
2026-05-13 01:38:33 +02:00
bc6c767c29 feat: follow-by-handle form on following and users pages 2026-05-13 01:35:54 +02:00
20e70325c6 feat: add FollowBackfillHandler to worker 2026-05-13 01:34:04 +02:00
e92c6789d9 feat: add backfill_outbox to ActivityPubService and ActivityPubPort 2026-05-13 01:32:14 +02:00
ca9a504632 feat: wire event_publisher through activitypub::wire() 2026-05-13 01:30:30 +02:00
2567103587 feat: publish FollowAccepted event when remote accepts follow 2026-05-13 01:28:03 +02:00
b1d4b4de2d feat(sqlite): persist outbox_url, implement get_following_outbox_url 2026-05-13 01:24:57 +02:00
5cd7409491 feat(postgres): persist outbox_url, implement get_following_outbox_url 2026-05-13 01:24:34 +02:00
1b41e7c1f5 fix: handle FollowAccepted in NATS subject router 2026-05-13 01:23:08 +02:00
faeac18126 feat: add outbox_url to RemoteActor, get_following_outbox_url to FederationRepository 2026-05-13 01:20:48 +02:00
80983f1ff2 feat: add FollowAccepted domain event 2026-05-13 01:17:26 +02:00
7c08e4a942 feat: enhance watchlist handling with entry discriminator and update broadcast methods 2026-05-13 00:45:44 +02:00
31fbb79451 refactor: remove watchlist repository references and add get_remote_watchlist use case 2026-05-13 00:34:23 +02:00
53df90ab1f feat: MovieDto enrichment, movie detail page, PWA, watchlist, watchlist federation 2026-05-13 00:23:45 +02:00
772 changed files with 66932 additions and 15609 deletions

View File

@@ -1,2 +1,5 @@
[env] [env]
SQLX_OFFLINE = "true" SQLX_OFFLINE = "true"
[registries.gitea]
index = "sparse+https://git.gabrielkaszewski.dev/api/packages/GKaszewski/cargo/"

View File

@@ -7,3 +7,7 @@ target/
# .cargo and .sqlx are needed at build time (SQLX_OFFLINE mode) # .cargo and .sqlx are needed at build time (SQLX_OFFLINE mode)
docs/ docs/
dev.db dev.db
spa/node_modules/
spa/dist/
spa/.env
spa/.env.local

View File

@@ -1,65 +1,55 @@
# Database backend — "sqlite" (default) or "postgres" # ── Required ──────────────────────────────────────────────────
DATABASE_BACKEND=sqlite
# Option A: SQLite (default, zero external dependencies) # Database (SQLite — file auto-created on first run)
DATABASE_URL=sqlite://movies.db DATABASE_URL=sqlite:///data/movies.db
# Option B: PostgreSQL
# DATABASE_BACKEND=postgres
# DATABASE_URL=postgres://user:password@localhost:5432/movies_diary
# Authentication # Authentication
JWT_SECRET=change-me JWT_SECRET=change-me-to-a-random-string
JWT_TTL_SECONDS=86400
# OMDb/TMDB metadata # Movie metadata — one of these is required (TMDB preferred)
OMDB_API_KEY=your-key # TMDB_API_KEY=your-tmdb-key
TMDB_API_KEY=your-key OMDB_API_KEY=your-omdb-key
# Poster storage — Option A (local) is active. To use S3, comment it out and uncomment Option B: # ── Recommended ──────────────────────────────────────────────
# Option A: local filesystem (zero external dependencies) # Public URL (used for ActivityPub federation and canonical links)
POSTER_STORAGE_BACKEND=local BASE_URL=https://yourdomain.example.com
POSTER_STORAGE_PATH=./posters
# Option B: S3-compatible (MinIO, AWS S3, etc.) # Enable sign-ups (default: false — set true so you can register)
# POSTER_STORAGE_BACKEND=s3 ALLOW_REGISTRATION=true
# ── Image Storage (defaults to local filesystem) ─────────────
# IMAGE_STORAGE_BACKEND=local # default
# IMAGE_STORAGE_PATH=./images # default
# S3-compatible alternative (MinIO, AWS S3, etc.):
# IMAGE_STORAGE_BACKEND=s3
# MINIO_ENDPOINT=http://localhost:9000 # MINIO_ENDPOINT=http://localhost:9000
# MINIO_BUCKET=posters # MINIO_BUCKET=posters
# MINIO_REGION=minio # MINIO_REGION=minio
# MINIO_ACCESS_KEY_ID=minioadmin # MINIO_ACCESS_KEY_ID=minioadmin
# MINIO_SECRET_ACCESS_KEY=minioadmin # MINIO_SECRET_ACCESS_KEY=minioadmin
# Optional # ── 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" # TMDb enrichment (cast, crew, genres — separate from search metadata above)
# The worker binary must run alongside the presentation to process events. # TMDB_API_KEY=your-tmdb-key
EVENT_BUS_BACKEND=db
# Option A: DB queue (default — no extra infrastructure needed) # Image conversion (converts uploaded posters to AVIF or WebP)
# 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
# Image conversion (optional — converts stored images to save disk space)
# Disable by default; enable in the worker by setting ENABLED=true.
# IMAGE_CONVERSION_ENABLED=false # IMAGE_CONVERSION_ENABLED=false
# IMAGE_CONVERSION_FORMAT=avif # avif | webp # IMAGE_CONVERSION_FORMAT=avif
RUST_LOG=presentation=debug,tower_http=debug,worker=info,application=info # Server
# HOST=0.0.0.0
# PORT=3000
# RATE_LIMIT=60
# SECURE_COOKIES=true
# RUST_LOG=presentation=info,tower_http=info,worker=info
# CORS (for SPA development only)
# CORS_ORIGINS=http://localhost:5173
# Event bus — "db" (default, uses same database) or "nats"
# EVENT_BUS_BACKEND=db
# NATS_URL=nats://localhost:4222

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

@@ -0,0 +1,42 @@
name: CI
on:
push:
branches: ["**"]
pull_request:
env:
CARGO_TERM_COLOR: always
RUST_BACKTRACE: 1
jobs:
ci:
name: Check / Test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Rust stable
uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy
- name: Cache cargo
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
restore-keys: ${{ runner.os }}-cargo-
- name: fmt
run: cargo fmt --all -- --check
- name: clippy
run: cargo clippy --all-targets -- -D warnings
- name: test
run: cargo test

81
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,81 @@
name: CI
on:
push:
branches: ["**"]
pull_request:
env:
CARGO_TERM_COLOR: always
RUST_BACKTRACE: 1
jobs:
ci:
name: Check / Test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Rust stable
uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy
- name: Cache cargo
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
restore-keys: ${{ runner.os }}-cargo-
- name: fmt
run: cargo fmt --all -- --check
- name: clippy
run: cargo clippy --all-targets -- -D warnings
- name: test
run: cargo test
docker:
name: Build & Push Docker Image
runs-on: ubuntu-latest
needs: ci
if: github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/tags/v')
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Docker metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=raw,value=latest,enable={{is_default_branch}}
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha,prefix=
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: FEATURES=sqlite,sqlite-federation

3
.gitignore vendored
View File

@@ -14,3 +14,6 @@
.worktrees/ .worktrees/
.superpowers/ .superpowers/
docs/ docs/
imgs/
.sqlx/

View File

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

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

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

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

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

View File

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

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

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

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

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

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

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

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

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

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

View File

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

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

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

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

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

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

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

View File

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

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

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

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

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

View File

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

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

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

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

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

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

View File

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

View File

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

@@ -1,32 +0,0 @@
{
"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"
}

87
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,87 @@
# Contributing
Thanks for your interest in Movies Diary! This is a personal project but contributions are welcome — bug fixes, new features, docs improvements, or picking up the deprecated TUI.
## Getting started
1. Fork and clone the repo
2. Copy `.env.example` to `.env` and fill in at least `JWT_SECRET` and `OMDB_API_KEY`
3. Install Rust (stable, 2024 edition) and Bun (for the SPA)
4. Run the backend and worker:
```bash
cargo run -p presentation # HTTP server on :3000
cargo run -p worker # event worker (separate terminal)
```
5. Run the SPA dev server:
```bash
cd spa && bun install && bun run dev
```
## Before submitting a PR
```bash
make # runs fmt-check + clippy + test
```
Or individually:
```bash
cargo fmt --check
cargo clippy -- -D warnings
cargo test
cd spa && bunx tsc --noEmit
```
All four must pass. PRs with clippy warnings or failing tests won't be merged.
## Architecture
The project follows hexagonal (ports & adapters) architecture. See `architecture.mmd` for the full diagram.
**Key rules:**
- Presentation handlers never touch repositories directly — all domain logic goes through use cases in the `application` crate
- Application use cases return raw domain data — URL formatting, date display, and view model assembly belong in presentation mappers (`presentation/src/mappers/`)
- Use cases called from presentation handlers take `&AppContext`. Functions called from adapter event handlers take individual `Arc<dyn Trait>` params to keep adapter dependencies explicit
```
domain → pure types, traits (ports), zero deps
application → use cases, orchestration
presentation → Axum handlers, routes, OpenAPI
worker → event consumer, background jobs
adapters/* → implements domain ports (sqlite, postgres, AP, etc.)
spa/ → React SPA (TanStack Router + shadcn/ui)
```
### Adding a new feature
1. **Domain first** — models in `domain/src/models/`, ports in `ports.rs`, events in `events.rs`
2. **Adapters** — implement ports in both `sqlite` and `postgres` adapters, add migration
3. **Application** — use cases in `application/src/<domain>/`, wire into `context.rs`
4. **API types** — DTOs in `api-types/src/`
5. **Presentation** — handler file in `handlers/<domain>.rs`, routes in `routes.rs`
6. **SPA** — API client in `spa/src/lib/api/`, hook in `spa/src/hooks/`, components
7. **Classic HTML** — Askama template + CSS in `static/style.css`
### Database adapters
Both SQLite and PostgreSQL are supported. If you add a migration or repository, implement it for both. The postgres adapter uses `$1, $2` params and `TIMESTAMPTZ`; SQLite uses `?` and text datetimes.
### Federation (ActivityPub)
Federation is feature-gated (`#[cfg(feature = "federation")]`). If your feature should federate, add domain events, handle them in `activitypub/src/event_handler.rs`, and create an AP object + inbound handler.
## Code style
- No comments unless the *why* is non-obvious
- Concise commit messages
- One feature per PR — don't bundle unrelated changes
- Follow existing patterns (check a similar feature for reference)
## Areas seeking help
- **TUI** (`crates/tui`) — deprecated, needs a maintainer to bring it up to feature parity
- **Tests** — the domain and application crates have 400+ unit tests; integration tests for the presentation layer are welcome
- **Docs** — API usage examples, deployment guides

662
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,7 +4,7 @@ members = [
"crates/adapters/event-publisher", "crates/adapters/event-publisher",
"crates/adapters/metadata", "crates/adapters/metadata",
"crates/adapters/poster-fetcher", "crates/adapters/poster-fetcher",
"crates/adapters/image-storage", "crates/adapters/object-storage",
"crates/adapters/poster-sync", "crates/adapters/poster-sync",
"crates/adapters/rss", "crates/adapters/rss",
"crates/adapters/sqlite", "crates/adapters/sqlite",
@@ -15,7 +15,6 @@ members = [
"crates/adapters/postgres-event-queue", "crates/adapters/postgres-event-queue",
"crates/adapters/template-askama", "crates/adapters/template-askama",
"crates/adapters/activitypub", "crates/adapters/activitypub",
"crates/adapters/activitypub-base",
"crates/adapters/export", "crates/adapters/export",
"crates/adapters/event-payload", "crates/adapters/event-payload",
"crates/adapters/nats", "crates/adapters/nats",
@@ -28,6 +27,8 @@ members = [
"crates/tui", "crates/tui",
"crates/worker", "crates/worker",
"crates/adapters/importer", "crates/adapters/importer",
"crates/adapters/jellyfin",
"crates/adapters/plex",
"crates/adapters/sqlite-search", "crates/adapters/sqlite-search",
"crates/adapters/postgres-search", "crates/adapters/postgres-search",
] ]
@@ -35,7 +36,9 @@ resolver = "2"
[workspace.dependencies] [workspace.dependencies]
tokio = { version = "1.0", features = ["macros", "net", "rt", "rt-multi-thread", "sync", "time"] } tokio = { version = "1.0", features = ["macros", "net", "rt", "rt-multi-thread", "sync", "time"] }
bytes = "1"
futures = "0.3" futures = "0.3"
async-stream = "0.3"
dotenvy = "0.15" dotenvy = "0.15"
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0" serde_json = "1.0"
@@ -50,9 +53,11 @@ sqlx = { version = "0.8.6", features = [
"runtime-tokio-rustls", "runtime-tokio-rustls",
"sqlite", "sqlite",
"uuid", "uuid",
"macros",
] } ] }
rand = "0.9"
reqwest = { version = "0.13", features = ["json", "query"] } reqwest = { version = "0.13", features = ["json", "query"] }
sha2 = "0.10"
hex = "0.4"
object_store = { version = "0.11", features = ["aws"] } object_store = { version = "0.11", features = ["aws"] }
axum = { version = "0.8.8", features = ["macros", "multipart"] } axum = { version = "0.8.8", features = ["macros", "multipart"] }
csv = "1" csv = "1"
@@ -65,7 +70,7 @@ presentation = { path = "crates/presentation" }
auth = { path = "crates/adapters/auth" } auth = { path = "crates/adapters/auth" }
metadata = { path = "crates/adapters/metadata" } metadata = { path = "crates/adapters/metadata" }
poster-fetcher = { path = "crates/adapters/poster-fetcher" } poster-fetcher = { path = "crates/adapters/poster-fetcher" }
image-storage = { path = "crates/adapters/image-storage" } object-storage = { path = "crates/adapters/object-storage" }
poster-sync = { path = "crates/adapters/poster-sync" } poster-sync = { path = "crates/adapters/poster-sync" }
event-publisher = { path = "crates/adapters/event-publisher" } event-publisher = { path = "crates/adapters/event-publisher" }
rss = { path = "crates/adapters/rss" } rss = { path = "crates/adapters/rss" }
@@ -76,12 +81,20 @@ postgres = { path = "crates/adapters/postgres" }
postgres-federation = { path = "crates/adapters/postgres-federation" } postgres-federation = { path = "crates/adapters/postgres-federation" }
template-askama = { path = "crates/adapters/template-askama" } template-askama = { path = "crates/adapters/template-askama" }
activitypub = { path = "crates/adapters/activitypub" } activitypub = { path = "crates/adapters/activitypub" }
activitypub-base = { path = "crates/adapters/activitypub-base" }
event-payload = { path = "crates/adapters/event-payload" } event-payload = { path = "crates/adapters/event-payload" }
nats = { path = "crates/adapters/nats" } nats = { path = "crates/adapters/nats" }
sqlite-event-queue = { path = "crates/adapters/sqlite-event-queue" } sqlite-event-queue = { path = "crates/adapters/sqlite-event-queue" }
postgres-event-queue = { path = "crates/adapters/postgres-event-queue" } postgres-event-queue = { path = "crates/adapters/postgres-event-queue" }
importer = { path = "crates/adapters/importer" } importer = { path = "crates/adapters/importer" }
jellyfin = { path = "crates/adapters/jellyfin" }
plex = { path = "crates/adapters/plex" }
image-converter = { path = "crates/adapters/image-converter" } image-converter = { path = "crates/adapters/image-converter" }
sqlite-search = { path = "crates/adapters/sqlite-search" } sqlite-search = { path = "crates/adapters/sqlite-search" }
postgres-search = { path = "crates/adapters/postgres-search" } postgres-search = { path = "crates/adapters/postgres-search" }
[profile.dev]
debug = 1 # line tables only — still debuggable, much faster linking
split-debuginfo = "unpacked" # macOS: skip dsymutil on every link
[profile.dev.package."*"]
opt-level = 2 # compile deps faster at runtime; paid once, cached after

View File

@@ -1,3 +1,11 @@
# ----- spa -----
FROM node:22-slim AS spa-builder
WORKDIR /spa
COPY spa/package.json spa/package-lock.json ./
RUN npm ci
COPY spa/ .
RUN npm run build
# ----- build ----- # ----- build -----
FROM rust:slim-bookworm AS builder FROM rust:slim-bookworm AS builder
@@ -6,19 +14,19 @@ WORKDIR /build
# Cache dependency compilation separately from source # Cache dependency compilation separately from source
COPY Cargo.toml Cargo.lock ./ COPY Cargo.toml Cargo.lock ./
COPY .cargo ./.cargo COPY .cargo ./.cargo
COPY .sqlx ./.sqlx
COPY crates/adapters/activitypub/Cargo.toml crates/adapters/activitypub/Cargo.toml COPY crates/adapters/activitypub/Cargo.toml crates/adapters/activitypub/Cargo.toml
COPY crates/adapters/activitypub-base/Cargo.toml crates/adapters/activitypub-base/Cargo.toml
COPY crates/adapters/auth/Cargo.toml crates/adapters/auth/Cargo.toml COPY crates/adapters/auth/Cargo.toml crates/adapters/auth/Cargo.toml
COPY crates/adapters/event-payload/Cargo.toml crates/adapters/event-payload/Cargo.toml COPY crates/adapters/event-payload/Cargo.toml crates/adapters/event-payload/Cargo.toml
COPY crates/adapters/event-publisher/Cargo.toml crates/adapters/event-publisher/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/nats/Cargo.toml crates/adapters/nats/Cargo.toml
COPY crates/adapters/metadata/Cargo.toml crates/adapters/metadata/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/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/object-storage/Cargo.toml crates/adapters/object-storage/Cargo.toml
COPY crates/adapters/poster-sync/Cargo.toml crates/adapters/poster-sync/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/export/Cargo.toml crates/adapters/export/Cargo.toml
COPY crates/adapters/importer/Cargo.toml crates/adapters/importer/Cargo.toml COPY crates/adapters/importer/Cargo.toml crates/adapters/importer/Cargo.toml
COPY crates/adapters/jellyfin/Cargo.toml crates/adapters/jellyfin/Cargo.toml
COPY crates/adapters/plex/Cargo.toml crates/adapters/plex/Cargo.toml
COPY crates/adapters/rss/Cargo.toml crates/adapters/rss/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/Cargo.toml crates/adapters/sqlite/Cargo.toml
COPY crates/adapters/sqlite-federation/Cargo.toml crates/adapters/sqlite-federation/Cargo.toml COPY crates/adapters/sqlite-federation/Cargo.toml crates/adapters/sqlite-federation/Cargo.toml
@@ -53,8 +61,8 @@ RUN cargo fetch
# Now copy real sources (invalidates cache only on source changes) # Now copy real sources (invalidates cache only on source changes)
COPY crates ./crates COPY crates ./crates
# .cargo/config.toml sets SQLX_OFFLINE=true; .sqlx contains the pre-verified query cache. # All sqlx queries use the runtime API (no query! macros), so no database
# No live database needed at compile time. # or .sqlx cache is needed at compile time.
# #
# To build with PostgreSQL backend instead: # To build with PostgreSQL backend instead:
# --build-arg FEATURES=postgres,postgres-federation # --build-arg FEATURES=postgres,postgres-federation
@@ -70,6 +78,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \ ca-certificates \
wget \ wget \
libwebp7 \ libwebp7 \
fonts-dejavu-core \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
WORKDIR /app WORKDIR /app
@@ -77,6 +86,7 @@ WORKDIR /app
COPY --from=builder /build/target/release/presentation ./presentation COPY --from=builder /build/target/release/presentation ./presentation
COPY --from=builder /build/target/release/worker ./worker COPY --from=builder /build/target/release/worker ./worker
COPY static ./static COPY static ./static
COPY --from=spa-builder /spa/dist ./spa/dist
EXPOSE 3000 EXPOSE 3000

37
Makefile Normal file
View File

@@ -0,0 +1,37 @@
.DEFAULT_GOAL := check
# Run the full local check suite — same order as CI would.
check: fmt-check clippy test check-appcontext
@echo "✅ All checks passed"
# Enforce that no application use case imports AppContext (god-object guard).
check-appcontext:
@if grep -rn "AppContext" crates/application/src --include="*.rs" | grep -q .; then \
echo "❌ AppContext found in application crate:"; \
grep -rn "AppContext" crates/application/src --include="*.rs"; \
exit 1; \
fi
@echo "✅ No AppContext in application crate"
# Apply rustfmt to all files.
fmt:
cargo fmt
# Check formatting without modifying files (CI-safe).
fmt-check:
cargo fmt --check
# Run Clippy and treat warnings as errors.
clippy:
cargo clippy -- -D warnings
# Run the test suite.
test:
cargo test
# Apply fmt + clippy auto-fixes in one shot.
fix:
cargo fmt
cargo clippy --fix --allow-dirty --allow-staged
.PHONY: check fmt fmt-check clippy test fix

277
README.md
View File

@@ -1,6 +1,49 @@
# Movies Diary # 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. A self-hosted movie diary built in Rust. Ships a classic server-rendered HTML interface (no JavaScript) alongside a full React SPA, both backed by the same REST API. Federates over ActivityPub so reviews reach the Fediverse. Supports Jellyfin and Plex auto-import, full-text search, annual wrap-ups, goals, and bulk import from Letterboxd, IMDb, and other sources. Runs on SQLite or PostgreSQL.
[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
[![Built with Rust](https://img.shields.io/badge/built_with-Rust-orange.svg?logo=rust)](https://www.rust-lang.org/)
[![Docker](https://img.shields.io/badge/docker-ready-2496ED?logo=docker&logoColor=white)](https://hub.docker.com/)
[![ActivityPub](https://img.shields.io/badge/ActivityPub-federated-5b5ea6)](https://activitypub.rocks/)
[![SQLite](https://img.shields.io/badge/database-SQLite%20%7C%20PostgreSQL-003B57?logo=sqlite&logoColor=white)](https://www.sqlite.org/)
---
## Table of Contents
- [Quick Start](#quick-start)
- [Features](#features)
- [Screenshots](#screenshots)
- [Architecture](#architecture)
- [Prerequisites](#prerequisites)
- [Configuration](#configuration)
- [Run](#run)
- [API](#api)
- [SPA](#spa)
- [Development](#development)
- [Test](#test)
- [Docker](#docker)
- [Media Server Integration](#media-server-integration)
- [Annual Wrap-Up](#annual-wrap-up)
- [Contributing](#contributing)
- [License](#license)
---
## Quick Start
The fastest way to run Movies Diary is via Docker Compose:
```bash
cp .env.example .env
# Set JWT_SECRET and OMDB_API_KEY (or TMDB_API_KEY) in .env
docker compose up -d
```
Open `http://localhost:3000`. The HTTP server and background worker start together; data is persisted in a Docker volume.
---
## Features ## Features
@@ -14,13 +57,31 @@ A self-hosted, server-side rendered movie logging system with a full REST API. B
- JWT authentication via cookie (HTML) or Bearer token (REST API) - 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) - 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 - Federation moderation — instance-level domain blocking (admin-managed), per-user actor blocking with `Block` activity, delivery filter excludes blocked actors and blocked-domain inboxes
- Watchlist — add movies to watch later, per-user; federated watchlist entries visible for remote actors
- User profiles — display name, bio, avatar, banner, custom profile fields; editable via HTML settings page or REST API
- Jellyfin/Plex auto-import — media server sends a webhook on playback stop, movies land in a watch queue; review and confirm with a rating to create diary entries; per-user webhook tokens with SHA-256 auth; setup UI at `/settings/integrations`
- Annual Wrap-Up — Spotify Wrapped for movies: per-user and instance-wide year-in-review with stats (top directors, actors, genres, rating distribution, watch time, rewatches, budget analysis), shareable HTML page at `/wrapups/{user_id}/{year}`; admin-triggered or auto-generated in January
- Goals — set a "watch N movies in YEAR" target with a progress bar; progress computed from existing reviews (backwards compatible); per-user federation toggle in settings; displayed on profile (SPA: interactive with create/edit/delete, classic HTML: read-only glassmorphic card)
- CSV and JSON diary export - 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 - 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 - REST API v1 (`/api/v1/`) with full feature parity with the HTML interface
- OpenAPI documentation at `/docs` (Swagger UI) and `/scalar` (Scalar) - 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`) - 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`) - 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 - Single-page app at `/app/` — React + TanStack Router + shadcn/ui, built with Vite, served from the backend with client-side routing fallback
- Terminal UI client (`crates/tui`, deprecated) for logging reviews, bulk CSV import, and diary browsing
## Screenshots
> SPA at `/app/` — React + TanStack Router + shadcn/ui
| Feed | Movie | Person |
|------|-------|--------|
| ![Feed](screenshots/feed.jpeg) | ![Movie detail](screenshots/movie.jpeg) | ![Person detail](screenshots/person.jpeg) |
| Profile | Wrap-Up | Wrap-Up card |
|---------|---------|--------------|
| ![Profile](screenshots/profile.jpeg) | ![Wrap-Up stats](screenshots/wrapup-stats.jpeg) | ![Wrap-Up shareable card](screenshots/wrapup-card.jpeg) |
## Architecture ## Architecture
@@ -29,7 +90,7 @@ Hexagonal (Ports & Adapters) with Domain-Driven Design:
``` ```
api-types — shared REST API request/response DTOs (Serialize/Deserialize + utoipa schemas); used by presentation and tui api-types — shared REST API request/response DTOs (Serialize/Deserialize + utoipa schemas); used by presentation and tui
domain — pure types and trait definitions, no external deps domain — pure types and trait definitions, no external deps
application — use cases / business logic orchestration application — use cases (commands + queries), business logic orchestration; handlers delegate here for all domain logic
presentation — Axum HTTP router, OpenAPI spec assembly, Swagger UI + Scalar serving, composition root for the HTTP process presentation — Axum HTTP router, OpenAPI spec assembly, Swagger UI + Scalar serving, composition root for the HTTP process
worker — standalone worker binary (event consumer, poster sync, federation) worker — standalone worker binary (event consumer, poster sync, federation)
adapters/ adapters/
@@ -46,18 +107,20 @@ adapters/
rss — RSS/Atom feed generation rss — RSS/Atom feed generation
export — CSV and JSON diary serialization export — CSV and JSON diary serialization
importer — CSV/TSV/JSON/XLSX parser and column mapper for bulk import importer — CSV/TSV/JSON/XLSX parser and column mapper for bulk import
jellyfin — Jellyfin webhook payload parser (MediaServerParser adapter)
plex — Plex webhook payload parser (MediaServerParser adapter; requires Plex Pass)
event-payload — shared event serialization DTOs (used by all event bus adapters) event-payload — shared event serialization DTOs (used by all event bus adapters)
sqlite-event-queue — durable polling event queue backed by SQLite sqlite-event-queue — durable polling event queue backed by SQLite
postgres-event-queue — durable polling event queue backed by PostgreSQL postgres-event-queue — durable polling event queue backed by PostgreSQL
nats — NATS Core / JetStream event publisher and consumer nats — NATS Core / JetStream event publisher and consumer
event-publisher — in-memory event channel (used in tests) event-publisher — in-memory event channel (used in tests)
activitypub — ActivityPub federation wiring (follow, inbox/outbox, actor) activitypub — ActivityPub federation adapter (follow, inbox/outbox, actor); delegates to k-ap for protocol internals
activitypub-base — core ActivityPub protocol types and service
sqlite-search — SQLite FTS5 implementation of SearchPort + SearchCommand sqlite-search — SQLite FTS5 implementation of SearchPort + SearchCommand
postgres-search — PostgreSQL tsvector + GIN implementation of SearchPort + SearchCommand postgres-search — PostgreSQL tsvector + GIN implementation of SearchPort + SearchCommand
sqlite-federation — SQLite-backed federation repository sqlite-federation — SQLite-backed federation repository
postgres-federation — PostgreSQL-backed federation repository postgres-federation — PostgreSQL-backed federation repository
tui — terminal UI client (ratatui); shares api-types with presentation for typed API access tui — terminal UI client (ratatui); shares api-types with presentation for typed API access
spa/ — React SPA (TanStack Router + shadcn/ui + Vite); served at /app/ by the backend
``` ```
## Prerequisites ## Prerequisites
@@ -67,56 +130,35 @@ tui — terminal UI client (ratatui); shares api-types with pres
- Poster storage: local filesystem (zero deps) or an S3-compatible object store (e.g. MinIO) - 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) - An [OMDb API key](https://www.omdbapi.com/apikey.aspx)
## Environment Variables ## Configuration
A `.env.example` file is provided at the repo root — copy it to `.env` and fill in your values. Copy `.env.example` to `.env` and set the values below. Required fields must be set before the server will start.
```env | Variable | Default | Required | Description |
# Database |---|---|---|---|
DATABASE_URL=sqlite://movies.db | `DATABASE_URL` | `sqlite://movies.db` | Yes | SQLite or PostgreSQL connection string |
| `JWT_SECRET` | — | Yes | Secret for JWT signing — use a long random string |
# Authentication | `OMDB_API_KEY` | — | Yes | [OMDb](https://www.omdbapi.com/apikey.aspx) key for movie metadata |
JWT_SECRET=change-me | `TMDB_API_KEY` | — | No | [TMDb](https://www.themoviedb.org/settings/api) key — enables cast, crew, genres, enrichment |
| `BASE_URL` | — | Yes | Public URL of your instance (used for ActivityPub actor URLs) |
# OMDb metadata | `IMAGE_STORAGE_BACKEND` | `local` | No | `local` or `s3` |
OMDB_API_KEY=your-key | `IMAGE_STORAGE_PATH` | `./images` | No | Path for local image storage |
| `MINIO_ENDPOINT` | — | S3 only | S3-compatible endpoint (e.g. `http://localhost:9000`) |
# TMDb metadata + enrichment (optional — enables full cast/crew/genre data) | `MINIO_BUCKET` | — | S3 only | Bucket name |
# TMDB_API_KEY=your-key | `MINIO_REGION` | — | S3 only | Region (e.g. `minio`) |
| `MINIO_ACCESS_KEY_ID` | — | S3 only | Access key ID |
# Public base URL (used for ActivityPub actor URLs and canonical links) | `MINIO_SECRET_ACCESS_KEY` | — | S3 only | Secret access key |
BASE_URL=https://yourdomain.example.com | `IMAGE_CONVERSION_ENABLED` | `false` | No | Convert stored images to AVIF or WebP |
| `IMAGE_CONVERSION_FORMAT` | `avif` | No | `avif` or `webp` |
# Image storage — pick one backend: | `HOST` | `0.0.0.0` | No | Bind address |
| `PORT` | `3000` | No | HTTP port |
# Option A: local filesystem (zero deps) | `RATE_LIMIT` | `60` | No | Requests per minute per IP |
IMAGE_STORAGE_BACKEND=local | `ALLOW_REGISTRATION` | `true` | No | Set `false` to disable new sign-ups |
IMAGE_STORAGE_PATH=./images | `SECURE_COOKIES` | `true` | No | Must be `true` when serving over HTTPS |
| `RUST_LOG` | — | No | Log verbosity (e.g. `presentation=info,worker=info`) |
# Option B: S3-compatible (MinIO, AWS S3, etc.) | `CORS_ORIGINS` | `*` | No | Comma-separated allowed origins for SPA dev |
# IMAGE_STORAGE_BACKEND=s3 | `EVENT_BUS_BACKEND` | `db` | No | `db` (default) or `nats` |
# MINIO_ENDPOINT=http://localhost:9000 | `NATS_URL` | — | NATS only | NATS connection URL (e.g. `nats://localhost:4222`) |
# MINIO_BUCKET=posters
# MINIO_REGION=minio
# MINIO_ACCESS_KEY_ID=minioadmin
# MINIO_SECRET_ACCESS_KEY=minioadmin
# Image conversion (optional — converts stored images to AVIF or WebP to save space)
# IMAGE_CONVERSION_ENABLED=false
# IMAGE_CONVERSION_FORMAT=avif # avif or webp
# 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: The `worker` binary must run alongside `presentation` to process events:
@@ -142,54 +184,121 @@ Interactive API documentation is available at runtime:
- **Swagger UI** — `http://localhost:3000/docs` - **Swagger UI** — `http://localhost:3000/docs`
- **Scalar** — `http://localhost:3000/scalar` - **Scalar** — `http://localhost:3000/scalar`
## Terminal UI An [Insomnia](https://insomnia.rest/) collection covering all endpoints is included at [`movies-diary.insomnia.json`](movies-diary.insomnia.json). Import it via **File → Import**, set `base_url` and `token` in the environment, and you're ready to go.
## SPA
The single-page app lives in `spa/` and is served at `/app/` by the backend. For local development:
```bash
cd spa && bun install && bun run dev # http://localhost:5173/app/
```
Set `CORS_ORIGINS=http://localhost:5173` in the backend `.env` to allow cross-origin API calls during development.
For production, `bun run build` outputs to `spa/dist/` which the backend serves statically (included in Docker image automatically).
## Terminal UI (deprecated)
> **Note:** The TUI was an experiment with ratatui and is no longer actively maintained. It may not support newer features (goals, watchlist, federation, etc.). Contributions welcome — if you'd like to maintain it, open a PR.
```bash ```bash
cargo run -p tui cargo run -p tui
``` ```
Supports review logging, bulk CSV import (column order matches the export format), and diary browsing with review history. Supports review logging, bulk CSV import, and diary browsing.
## Development
A `Makefile` wraps the most common dev commands:
```bash
make # default: fmt-check + clippy + test (same order as CI)
make fix # auto-apply fmt + clippy fixes
make fmt # apply rustfmt
make clippy # clippy with -D warnings
make test # cargo test
```
## Test ## Test
```bash ```bash
cargo test cargo test # full workspace (requires DATABASE_URL for sqlx offline checks)
cargo test -p application # business logic tests only — no database required
cargo test -p domain # domain model + value object tests
cargo llvm-cov -p application -p domain # line coverage report (requires cargo-llvm-cov)
``` ```
The `application` and `domain` crates have 400+ unit tests covering all use case modules (auth, diary, goals, import, integrations, movies, person, search, users, watchlist, wrapup) backed by in-memory fakes from `domain`'s `test-helpers` feature. These run without a database and are the fastest feedback loop for business logic changes.
## Docker ## Docker
The image contains both `presentation` (HTTP server) and `worker` (event processor). Run them as separate containers sharing the same data volume: ### Quick start
```bash ```bash
# Build (SQLite + federation + NATS support) cp .env.example .env
docker build -t movies-diary \ # Edit .env — set JWT_SECRET and OMDB_API_KEY (or TMDB_API_KEY)
--build-arg FEATURES=sqlite,sqlite-federation,nats . docker compose up -d
# 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` This builds and starts the HTTP server (port 3000) and event worker. Data is persisted in a Docker volume.
### Manual docker run
The image contains both `presentation` and `worker` binaries. Run them as separate containers sharing the same data volume:
```bash
docker build -t movies-diary .
docker run -p 3000:3000 --env-file .env -v movies-diary-data:/data movies-diary
docker run --env-file .env -v movies-diary-data:/data --entrypoint ./worker movies-diary
```
Build for PostgreSQL: `--build-arg FEATURES=postgres,postgres-federation`
## Media Server Integration
Auto-log movies you finish watching. Go to `/settings/integrations` to generate a webhook token, then configure your media server.
### Jellyfin
1. Install the **Webhook** plugin (Dashboard > Plugins > Catalog)
2. Add a **Generic** destination:
- **URL**: `https://yourdomain.example.com/api/v1/webhooks/jellyfin`
- **Header**: `Authorization` = `Bearer <your-token>`
- **Send All Properties**: enabled
- **Notification Type**: Playback Stop only
- **Item Type**: Movies only
### Plex (requires Plex Pass)
1. Go to Settings > Webhooks in your Plex server
2. Add webhook URL: `https://yourdomain.example.com/api/v1/webhooks/plex`
3. Plex does not support custom headers natively — pass the token as a query param: `https://yourdomain.example.com/api/v1/webhooks/plex?token=<your-token>`
Movies you finish watching appear in your watch queue at `/watch-queue` — rate and confirm to add to your diary.
## Annual Wrap-Up
Generate a year-in-review summary for any user — top directors, actors, genres, rating distribution, total watch time, rewatch stats, and more. Available as a shareable HTML page.
**Generate via API** (admin only):
```bash
curl -X POST http://localhost:3000/api/v1/wrapups/generate \
-H "Authorization: Bearer <admin-token>" \
-H "Content-Type: application/json" \
-d '{"user_id": "<uuid>", "start_date": "2025-01-01", "end_date": "2026-01-01"}'
```
Omit `user_id` for a global instance wrap-up. The worker computes stats in the background — poll `GET /api/v1/wrapups/{id}` for status.
**View:** `http://localhost:3000/wrapups/{user_id}/2025` (public, no login required)
**Auto-generate:** The worker runs a daily job in January that generates wrap-ups for all users with reviews in the previous year.
## Contributing
See [CONTRIBUTING.md](CONTRIBUTING.md) for setup instructions, architecture overview, and PR guidelines.
## License ## License

140
architecture.mmd Normal file
View File

@@ -0,0 +1,140 @@
---
title: Movies Diary — Hexagonal Architecture
---
graph TB
subgraph Binaries["Binaries (Composition Root)"]
WEB["presentation<br/><i>Axum web server</i><br/>Routes, Handlers, Mappers"]
WORKER["worker<br/><i>Event consumer</i><br/>Concurrent dispatch, graceful shutdown"]
TUI["tui<br/><i>Terminal UI</i>"]
end
subgraph Application["Application Layer"]
direction TB
CTX["AppContext<br/><i>Repositories + Services</i>"]
APP_PORTS["ReviewLogger<br/><i>application-layer port</i>"]
subgraph UseCases["Use Cases"]
UC_AUTH["auth<br/>login, register"]
UC_DIARY["diary<br/>log_review, get_diary,<br/>get_activity_feed, export"]
UC_MOVIES["movies<br/>get_movies, get_movie_profile,<br/>enrich_movie, request_enrichment,<br/>sync_poster, reindex_search"]
UC_IMPORT["import<br/>create_session, apply_mapping,<br/>execute, profiles"]
UC_USERS["users<br/>get_users, get_profile,<br/>update_profile"]
UC_WATCHLIST["watchlist<br/>add, remove, get"]
UC_WRAPUP["wrapup<br/>generate, compute,<br/>list, delete"]
UC_GOALS["goals<br/>create, update, delete,<br/>get, list"]
UC_INTEGRATIONS["integrations<br/>webhooks, watch_queue,<br/>confirm, dismiss"]
UC_SEARCH["search<br/>execute"]
UC_PERSON["person<br/>get, get_credits"]
end
subgraph EventHandlers["Event Handlers"]
EH_ENRICH["EnrichmentHandler"]
EH_DISCOVER["MovieDiscoveryIndexer"]
EH_CLEANUP["SearchCleanupHandler"]
EH_REINDEX["SearchReindexHandler"]
EH_WRAPUP["WrapUpEventHandler"]
end
subgraph Jobs["Periodic Jobs"]
JOB_IMPORT["ImportSessionCleanup"]
JOB_WATCH["WatchEventCleanup"]
JOB_STALE["EnrichmentStaleness"]
JOB_WRAPGEN["WrapUpAutoGenerate"]
end
WORKER_SVC["WorkerService<br/><i>Semaphore(8), JoinSet,<br/>shutdown signal</i>"]
end
subgraph Domain["Domain Layer (0 dependencies)"]
direction TB
subgraph Models["Models"]
M_MOVIE["Movie, MovieSummary,<br/>MovieProfile"]
M_REVIEW["Review, DiaryEntry,<br/>FeedEntry"]
M_USER["User, UserSummary"]
M_PERSON["Person, PersonId,<br/>PersonCredits"]
M_WATCHLIST["WatchlistEntry,<br/>WatchEvent"]
M_GOAL["Goal, GoalWithProgress,<br/>UserSettings, RemoteGoalEntry"]
M_WRAPUP["WrapUpReport,<br/>MovieRef, PersonStat"]
M_SEARCH["SearchQuery,<br/>SearchResults"]
end
subgraph Ports["Port Traits (Interfaces)"]
P_REPOS["MovieRepository<br/>ReviewRepository<br/>DiaryRepository<br/>UserRepository<br/>WatchlistRepository<br/>WatchEventRepository<br/>WebhookTokenRepository<br/>ImportSessionRepository<br/>MovieProfileRepository<br/>WrapUpRepository<br/>GoalRepository<br/>UserSettingsRepository"]
P_SERVICES["AuthService<br/>MetadataClient<br/>PosterFetcherClient<br/>ObjectStorage<br/>EventPublisher<br/>EventConsumer<br/>PasswordHasher<br/>DiaryExporter<br/>DocumentParser"]
P_SEARCH["SearchPort<br/>SearchCommand<br/>PersonQuery<br/>PersonCommand"]
P_FEDERATION["SocialQueryPort<br/>LocalApContentQuery<br/>RemoteWatchlistRepository<br/>RemoteGoalRepository"]
end
subgraph DomainServices["Services (pure, no I/O)"]
DS_WRAPUP["WrapUpAnalyzer<br/><i>build_report, compute_*</i>"]
DS_REVIEW["ReviewHistoryAnalyzer<br/><i>rating_trend</i>"]
end
EVENTS["DomainEvent enum<br/><i>ReviewLogged, MovieDiscovered,<br/>GoalCreated, GoalUpdated,<br/>SearchReindexRequested, ...</i>"]
VO["Value Objects<br/><i>MovieId, UserId, Rating,<br/>Email, Username, Password, ...</i>"]
end
subgraph ApiTypes["api-types (0 domain deps)"]
DTO["DTOs<br/><i>MovieDto, ReviewDto,<br/>FeedEntryDto, UserSummaryDto,<br/>CastMemberDto, ...</i>"]
end
subgraph Adapters["Adapters (implement Port Traits)"]
direction TB
subgraph Storage["Storage"]
A_SQLITE["sqlite<br/><i>SQLite repos</i>"]
A_PG["postgres<br/><i>PostgreSQL repos</i>"]
A_SQLITE_SEARCH["sqlite-search<br/><i>FTS5</i>"]
A_PG_SEARCH["postgres-search<br/><i>tsvector/GIN</i>"]
A_OBJ["object-storage<br/><i>S3 / filesystem</i>"]
end
subgraph Messaging["Messaging"]
A_NATS["nats<br/><i>JetStream / Core</i>"]
A_PG_QUEUE["postgres-event-queue<br/><i>Polling, dead-letter</i>"]
A_PAYLOAD["event-payload<br/><i>Serde (de)serialization</i>"]
end
subgraph External["External Services"]
A_METADATA["metadata<br/><i>TMDB search/details</i>"]
A_TMDB["tmdb-enrichment<br/><i>Credits, keywords, cast</i>"]
A_POSTER["poster-fetcher<br/><i>TMDB image download</i>"]
A_AUTH["auth<br/><i>JWT tokens</i>"]
end
subgraph Federation["Federation (feature-gated)"]
A_AP["activitypub<br/><i>k_ap library</i>"]
A_SQLITE_FED["sqlite-federation"]
A_PG_FED["postgres-federation"]
end
subgraph Media["Media Processing"]
A_IMG["image-converter<br/><i>AVIF/WebP</i>"]
A_POSTER_SYNC["poster-sync"]
end
subgraph Presentation["Presentation Helpers"]
A_TEMPLATE["template-askama<br/><i>HTML templates</i>"]
A_RSS["rss<br/><i>Feed generation</i>"]
A_EXPORT["export<br/><i>CSV/JSON diary</i>"]
A_IMPORT["importer<br/><i>CSV/JSON/XLSX parser</i>"]
end
subgraph Webhooks["Webhook Parsers"]
A_JELLYFIN["jellyfin"]
A_PLEX["plex"]
end
end
%% Dependency arrows
WEB -->|"uses"| Application
WEB -->|"maps to"| ApiTypes
WORKER -->|"uses"| Application
Application -->|"depends on"| Domain
UC_WRAPUP -->|"delegates to"| DS_WRAPUP
Adapters -.->|"implements"| Ports
%% Key data flows
WEB ===|"HTTP"| DTO
WORKER ===|"Events"| EVENTS
classDef domain fill:#1a1a2e,stroke:#e94560,color:#fff
classDef app fill:#16213e,stroke:#0f3460,color:#fff
classDef adapter fill:#0f3460,stroke:#533483,color:#fff
classDef binary fill:#533483,stroke:#e94560,color:#fff
classDef api fill:#2a2a4a,stroke:#e94560,color:#fff
class Domain domain
class DomainServices domain
class Application app
class Adapters adapter
class Binaries binary
class ApiTypes api

View File

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

View File

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

@@ -1,21 +0,0 @@
use activitypub_federation::{
axum::json::FederationJson, config::Data, protocol::context::WithContext, traits::Object,
};
use axum::extract::Path;
use crate::actors::{Person, get_local_actor};
use crate::data::FederationData;
use crate::error::Error;
pub async fn actor_handler(
Path(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

@@ -1,267 +0,0 @@
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)]
#[path = "tests/actors.rs"]
mod tests;

View File

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

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

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

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

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

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

View File

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

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

View File

@@ -1,135 +0,0 @@
use axum::extract::{Path, Query};
use axum::response::IntoResponse;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use url::Url;
use activitypub_federation::{config::Data, fetch::object_id::ObjectId, kinds::activity::CreateType};
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

@@ -1,115 +0,0 @@
use anyhow::Result;
use async_trait::async_trait;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FollowerStatus {
Pending,
Accepted,
Rejected,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FollowingStatus {
Pending,
Accepted,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RemoteActor {
pub url: String,
pub handle: String,
pub inbox_url: String,
pub shared_inbox_url: Option<String>,
pub display_name: Option<String>,
pub avatar_url: Option<String>,
}
#[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

@@ -1,935 +0,0 @@
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)]
#[path = "tests/service.rs"]
mod tests;

View File

@@ -1,33 +0,0 @@
use super::*;
#[test]
fn person_serializes_with_enriched_fields() {
let person = Person {
kind: Default::default(),
id: "https://example.com/users/1".parse::<url::Url>().unwrap().into(),
preferred_username: "alice".to_string(),
inbox: "https://example.com/users/1/inbox".parse().unwrap(),
outbox: "https://example.com/users/1/outbox".parse().unwrap(),
followers: "https://example.com/users/1/followers".parse().unwrap(),
following: "https://example.com/users/1/following".parse().unwrap(),
public_key: PublicKey {
id: "https://example.com/users/1#main-key".to_string(),
owner: "https://example.com/users/1".parse().unwrap(),
public_key_pem: "pem".to_string(),
},
name: Some("Alice".to_string()),
summary: Some("Bio text".to_string()),
icon: Some(ApImageObject {
kind: "Image".to_string(),
url: "https://example.com/images/avatars/1".parse().unwrap(),
}),
url: Some("https://example.com/u/alice".parse().unwrap()),
discoverable: Some(true),
manually_approves_followers: 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

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

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

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

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

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

View File

@@ -4,7 +4,7 @@ version = "0.1.0"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
activitypub-base = { workspace = true } k-ap = { version = "0.4.0", registry = "gitea" }
domain = { workspace = true } domain = { workspace = true }
axum = { workspace = true } axum = { workspace = true }
serde = { workspace = true } serde = { workspace = true }
@@ -15,5 +15,5 @@ anyhow = { workspace = true }
tracing = { workspace = true } tracing = { workspace = true }
async-trait = { workspace = true } async-trait = { workspace = true }
activitypub_federation = "0.7.0-beta.11"
url = { version = "2", features = ["serde"] } url = { version = "2", features = ["serde"] }
ammonia = "4"

View File

@@ -0,0 +1,122 @@
use std::sync::Arc;
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use k_ap::{ApContentReader, ApObjectHandler};
use url::Url;
use crate::{
goal_handler::GoalObjectHandler, review_handler::ReviewObjectHandler,
watchlist_handler::WatchlistObjectHandler,
};
pub struct CompositeObjectHandler {
pub review: Arc<ReviewObjectHandler>,
pub watchlist: Arc<WatchlistObjectHandler>,
pub goal: Arc<GoalObjectHandler>,
}
#[async_trait]
impl ApContentReader for CompositeObjectHandler {
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>)>> {
self.review
.get_local_objects_page(user_id, before, limit)
.await
}
async fn count_local_posts(&self) -> anyhow::Result<u64> {
self.review.count_local_posts().await
}
}
#[async_trait]
impl ApObjectHandler for CompositeObjectHandler {
async fn on_create(
&self,
ap_id: &Url,
actor_url: &Url,
object: serde_json::Value,
) -> anyhow::Result<()> {
let is_watchlist = object.get("watchlistEntry").and_then(|v| v.as_bool()) == Some(true)
|| (object.get("movieTitle").is_some() && object.get("rating").is_none());
let is_goal = object.get("goal").and_then(|v| v.as_bool()) == Some(true);
if object.get("rating").is_some() {
self.review.on_create(ap_id, actor_url, object).await
} else if is_goal {
self.goal.on_create(ap_id, actor_url, object).await
} else if is_watchlist {
self.watchlist.on_create(ap_id, actor_url, object).await
} else {
tracing::warn!(ap_id = %ap_id, "ignoring Create for unknown object type");
Ok(())
}
}
async fn on_update(
&self,
ap_id: &Url,
actor_url: &Url,
object: serde_json::Value,
) -> anyhow::Result<()> {
let is_goal = object.get("goal").and_then(|v| v.as_bool()) == Some(true);
if object.get("rating").is_some() {
self.review.on_update(ap_id, actor_url, object).await
} else if is_goal {
self.goal.on_update(ap_id, actor_url, object).await
} else {
Ok(())
}
}
async fn on_delete(&self, ap_id: &Url, actor_url: &Url) -> anyhow::Result<()> {
self.review.on_delete(ap_id, actor_url).await?;
self.watchlist.on_delete(ap_id, actor_url).await?;
self.goal.on_delete(ap_id, actor_url).await?;
Ok(())
}
async fn on_actor_removed(&self, actor_url: &Url) -> anyhow::Result<()> {
self.review.on_actor_removed(actor_url).await?;
self.watchlist.on_actor_removed(actor_url).await?;
self.goal.on_actor_removed(actor_url).await?;
Ok(())
}
async fn on_like(&self, _object_url: &Url, _actor_url: &Url) -> anyhow::Result<()> {
Ok(())
}
async fn on_announce_received(
&self,
_object_url: &Url,
_actor_url: &Url,
) -> anyhow::Result<()> {
Ok(())
}
async fn on_announce_of_remote(
&self,
_object_url: &Url,
_actor_url: &Url,
) -> anyhow::Result<()> {
Ok(())
}
async fn on_unlike(&self, _object_url: &Url, _actor_url: &Url) -> anyhow::Result<()> {
Ok(())
}
async fn on_mention(
&self,
_thought_ap_id: &Url,
_mentioned_user_uuid: uuid::Uuid,
_actor_url: &Url,
) -> anyhow::Result<()> {
Ok(())
}
}

View File

@@ -1,36 +1,37 @@
use async_trait::async_trait; use async_trait::async_trait;
use chrono::Datelike;
use domain::ports::EventHandler; use domain::ports::EventHandler;
use domain::{ use domain::{
errors::DomainError, errors::DomainError,
events::DomainEvent, events::DomainEvent,
ports::{MovieRepository, ReviewRepository}, ports::{LocalApContentQuery, UserFederationSettingsQuery},
value_objects::{ReviewId, UserId}, value_objects::{MovieId, ReviewId, UserId},
}; };
use std::sync::Arc; use std::sync::Arc;
use activitypub_base::ActivityPubService; use k_ap::{ActivityPubService, ApVisibility};
use crate::objects::review_to_ap_object; use crate::objects::{goal_to_ap_object, review_to_ap_object};
use crate::urls::{actor_url, review_url}; use crate::urls::{actor_url, goal_url, review_url};
pub struct ActivityPubEventHandler { pub struct ActivityPubEventHandler {
ap_service: Arc<ActivityPubService>, ap_service: Arc<ActivityPubService>,
movie_repository: Arc<dyn MovieRepository>, content_query: Arc<dyn LocalApContentQuery>,
review_repository: Arc<dyn ReviewRepository>, federation_settings: Arc<dyn UserFederationSettingsQuery>,
base_url: String, base_url: String,
} }
impl ActivityPubEventHandler { impl ActivityPubEventHandler {
pub fn new( pub fn new(
ap_service: Arc<ActivityPubService>, ap_service: Arc<ActivityPubService>,
movie_repository: Arc<dyn MovieRepository>, content_query: Arc<dyn LocalApContentQuery>,
review_repository: Arc<dyn ReviewRepository>, federation_settings: Arc<dyn UserFederationSettingsQuery>,
base_url: String, base_url: String,
) -> Self { ) -> Self {
Self { Self {
ap_service, ap_service,
movie_repository, content_query,
review_repository, federation_settings,
base_url, base_url,
} }
} }
@@ -40,11 +41,15 @@ impl ActivityPubEventHandler {
impl EventHandler for ActivityPubEventHandler { impl EventHandler for ActivityPubEventHandler {
async fn handle(&self, event: &DomainEvent) -> Result<(), DomainError> { async fn handle(&self, event: &DomainEvent) -> Result<(), DomainError> {
match event { match event {
DomainEvent::ReviewLogged { review_id, user_id, .. } => self DomainEvent::ReviewLogged {
review_id, user_id, ..
} => self
.on_review_logged(user_id, review_id) .on_review_logged(user_id, review_id)
.await .await
.map_err(|e| DomainError::InfrastructureError(e.to_string())), .map_err(|e| DomainError::InfrastructureError(e.to_string())),
DomainEvent::ReviewUpdated { review_id, user_id, .. } => self DomainEvent::ReviewUpdated {
review_id, user_id, ..
} => self
.on_review_updated(user_id, review_id) .on_review_updated(user_id, review_id)
.await .await
.map_err(|e| DomainError::InfrastructureError(e.to_string())), .map_err(|e| DomainError::InfrastructureError(e.to_string())),
@@ -57,6 +62,71 @@ impl EventHandler for ActivityPubEventHandler {
.broadcast_actor_update(user_id.value()) .broadcast_actor_update(user_id.value())
.await .await
.map_err(|e| DomainError::InfrastructureError(e.to_string())), .map_err(|e| DomainError::InfrastructureError(e.to_string())),
DomainEvent::WatchlistEntryAdded {
user_id,
movie_id,
movie_title,
release_year,
external_metadata_id,
added_at,
} => self
.on_watchlist_added(
user_id,
movie_id,
movie_title,
*release_year,
external_metadata_id,
added_at,
)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string())),
DomainEvent::WatchlistEntryRemoved { user_id, movie_id } => self
.on_watchlist_removed(user_id, movie_id)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string())),
DomainEvent::FederationDeliveryRequested {
inbox_url,
activity_json,
signing_actor_id,
} => {
let inbox: url::Url = inbox_url
.parse()
.map_err(|e| DomainError::InfrastructureError(format!("bad inbox URL: {e}")))?;
let activity: serde_json::Value =
serde_json::from_str(activity_json).map_err(|e| {
DomainError::InfrastructureError(format!("bad activity JSON: {e}"))
})?;
self.ap_service
.deliver_to_inbox(inbox, activity, *signing_actor_id)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))
}
DomainEvent::PosterSynced { movie_id } => self
.on_poster_synced(movie_id)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string())),
DomainEvent::GoalCreated {
user_id,
year,
target_count,
..
} => self
.broadcast_goal(user_id, *year, *target_count, true)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string())),
DomainEvent::GoalUpdated {
user_id,
year,
target_count,
..
} => self
.broadcast_goal(user_id, *year, *target_count, false)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string())),
DomainEvent::GoalDeleted { user_id, year, .. } => self
.on_goal_deleted(user_id, *year)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string())),
_ => Ok(()), _ => Ok(()),
} }
} }
@@ -64,7 +134,20 @@ impl EventHandler for ActivityPubEventHandler {
impl ActivityPubEventHandler { impl ActivityPubEventHandler {
async fn on_review_logged(&self, user_id: &UserId, review_id: &ReviewId) -> anyhow::Result<()> { 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? { let flags = self
.federation_settings
.get_federation_flags(user_id)
.await
.unwrap_or(domain::ports::FederationFlags {
goals: true,
reviews: true,
watchlist: true,
});
if !flags.reviews {
return Ok(());
}
let review = match self.content_query.get_review_by_id(review_id).await? {
Some(r) => r, Some(r) => r,
None => return Ok(()), None => return Ok(()),
}; };
@@ -73,7 +156,7 @@ impl ActivityPubEventHandler {
let actor = actor_url(&self.base_url, user_id.value()); let actor = actor_url(&self.base_url, user_id.value());
let movie = self let movie = self
.movie_repository .content_query
.get_movie_by_id(review.movie_id()) .get_movie_by_id(review.movie_id())
.await .await
.ok() .ok()
@@ -103,14 +186,34 @@ impl ActivityPubEventHandler {
let json = serde_json::to_value(obj)?; let json = serde_json::to_value(obj)?;
self.ap_service self.ap_service
.broadcast_to_followers(user_id.value(), ap_id, json) .broadcast_create_note(user_id.value(), json, ApVisibility::Public, vec![])
.await?; .await?;
let year = review.watched_at().year() as u16;
self.broadcast_goal_progress_update(user_id, year).await?;
Ok(()) Ok(())
} }
async fn on_review_updated(&self, user_id: &UserId, review_id: &ReviewId) -> anyhow::Result<()> { async fn on_review_updated(
let review = match self.review_repository.get_review_by_id(review_id).await? { &self,
user_id: &UserId,
review_id: &ReviewId,
) -> anyhow::Result<()> {
let flags = self
.federation_settings
.get_federation_flags(user_id)
.await
.unwrap_or(domain::ports::FederationFlags {
goals: true,
reviews: true,
watchlist: true,
});
if !flags.reviews {
return Ok(());
}
let review = match self.content_query.get_review_by_id(review_id).await? {
Some(r) => r, Some(r) => r,
None => return Ok(()), None => return Ok(()),
}; };
@@ -119,7 +222,7 @@ impl ActivityPubEventHandler {
let actor = actor_url(&self.base_url, user_id.value()); let actor = actor_url(&self.base_url, user_id.value());
let movie = self let movie = self
.movie_repository .content_query
.get_movie_by_id(review.movie_id()) .get_movie_by_id(review.movie_id())
.await .await
.ok() .ok()
@@ -149,17 +252,252 @@ impl ActivityPubEventHandler {
let json = serde_json::to_value(obj)?; let json = serde_json::to_value(obj)?;
self.ap_service self.ap_service
.broadcast_update_to_followers(user_id.value(), json) .broadcast_update_note(user_id.value(), json, ApVisibility::Public, vec![])
.await?; .await?;
Ok(()) Ok(())
} }
async fn on_review_deleted(&self, user_id: &UserId, review_id: &ReviewId) -> anyhow::Result<()> { async fn on_review_deleted(
&self,
user_id: &UserId,
review_id: &ReviewId,
) -> anyhow::Result<()> {
let ap_id = review_url(&self.base_url, review_id); let ap_id = review_url(&self.base_url, review_id);
self.ap_service self.ap_service
.broadcast_delete_to_followers(user_id.value(), ap_id) .broadcast_delete_to_followers(user_id.value(), ap_id)
.await?; .await?;
Ok(()) Ok(())
} }
async fn on_watchlist_added(
&self,
user_id: &UserId,
movie_id: &domain::value_objects::MovieId,
movie_title: &str,
release_year: u16,
external_metadata_id: &Option<String>,
added_at: &chrono::NaiveDateTime,
) -> anyhow::Result<()> {
let flags = self
.federation_settings
.get_federation_flags(user_id)
.await
.unwrap_or(domain::ports::FederationFlags {
goals: true,
reviews: true,
watchlist: true,
});
if !flags.watchlist {
return Ok(());
}
use crate::urls::watchlist_entry_url;
let ap_id = watchlist_entry_url(&self.base_url, user_id.value(), movie_id.value());
let actor = actor_url(&self.base_url, user_id.value());
let poster_url = self
.content_query
.get_movie_by_id(movie_id)
.await
.ok()
.flatten()
.and_then(|m| {
m.poster_path()
.map(|p| format!("{}/images/{}", self.base_url, p.value()))
});
let added_at_utc =
chrono::DateTime::<chrono::Utc>::from_naive_utc_and_offset(*added_at, chrono::Utc);
let obj = crate::objects::watchlist_to_ap_object(crate::objects::WatchlistApInput {
ap_id: ap_id.clone(),
actor_url: actor,
movie_title: movie_title.to_string(),
release_year,
external_metadata_id: external_metadata_id.clone(),
poster_url,
added_at: added_at_utc,
base_url: self.base_url.clone(),
});
let json = serde_json::to_value(obj)?;
self.ap_service
.broadcast_create_note(user_id.value(), json, ApVisibility::Public, vec![])
.await?;
Ok(())
}
async fn on_watchlist_removed(
&self,
user_id: &UserId,
movie_id: &domain::value_objects::MovieId,
) -> anyhow::Result<()> {
use crate::urls::watchlist_entry_url;
let ap_id = watchlist_entry_url(&self.base_url, user_id.value(), movie_id.value());
self.ap_service
.broadcast_delete_to_followers(user_id.value(), ap_id)
.await?;
Ok(())
}
async fn on_poster_synced(&self, movie_id: &MovieId) -> anyhow::Result<()> {
let entries = self
.content_query
.get_local_reviews_for_movie(movie_id)
.await?;
let movie = self.content_query.get_movie_by_id(movie_id).await?;
let movie = match movie {
Some(m) => m,
None => return Ok(()),
};
let poster_url = movie
.poster_path()
.map(|p| format!("{}/images/{}", self.base_url, p.value()));
for entry in entries {
let review = entry.review();
let user_id = review.user_id();
let flags = self
.federation_settings
.get_federation_flags(user_id)
.await
.unwrap_or(domain::ports::FederationFlags {
goals: true,
reviews: true,
watchlist: true,
});
if !flags.reviews {
continue;
}
let ap_id = review_url(&self.base_url, review.id());
let actor = actor_url(&self.base_url, user_id.value());
let obj = review_to_ap_object(
review,
ap_id,
actor,
movie.title().value().to_string(),
movie.release_year().value(),
poster_url.clone(),
&self.base_url,
);
let json = serde_json::to_value(obj)?;
self.ap_service
.broadcast_update_note(user_id.value(), json, ApVisibility::Public, vec![])
.await?;
}
Ok(())
}
async fn broadcast_goal_progress_update(
&self,
user_id: &UserId,
year: u16,
) -> anyhow::Result<()> {
let flags = self
.federation_settings
.get_federation_flags(user_id)
.await
.unwrap_or(domain::ports::FederationFlags {
goals: true,
reviews: true,
watchlist: true,
});
if !flags.goals {
return Ok(());
}
let Some((goal, current)) = self
.content_query
.get_goal_with_progress(user_id, year)
.await
.ok()
.flatten()
else {
return Ok(());
};
let ap_id = goal_url(&self.base_url, user_id.value(), year);
let actor = actor_url(&self.base_url, user_id.value());
let obj = goal_to_ap_object(
ap_id,
actor,
year,
goal.target_count(),
current,
&self.base_url,
);
let json = serde_json::to_value(obj)?;
self.ap_service
.broadcast_update_note(user_id.value(), json, ApVisibility::Public, vec![])
.await?;
Ok(())
}
async fn broadcast_goal(
&self,
user_id: &UserId,
year: u16,
target_count: u32,
is_create: bool,
) -> anyhow::Result<()> {
let flags = self
.federation_settings
.get_federation_flags(user_id)
.await
.unwrap_or(domain::ports::FederationFlags {
goals: true,
reviews: true,
watchlist: true,
});
if !flags.goals {
return Ok(());
}
let current = self
.content_query
.get_goal_with_progress(user_id, year)
.await
.ok()
.flatten()
.map(|(_, c)| c)
.unwrap_or(0);
let ap_id = goal_url(&self.base_url, user_id.value(), year);
let actor = actor_url(&self.base_url, user_id.value());
let obj = goal_to_ap_object(ap_id, actor, year, target_count, current, &self.base_url);
let json = serde_json::to_value(obj)?;
if is_create {
self.ap_service
.broadcast_create_note(user_id.value(), json, ApVisibility::Public, vec![])
.await?;
} else {
self.ap_service
.broadcast_update_note(user_id.value(), json, ApVisibility::Public, vec![])
.await?;
}
Ok(())
}
async fn on_goal_deleted(&self, user_id: &UserId, year: u16) -> anyhow::Result<()> {
let flags = self
.federation_settings
.get_federation_flags(user_id)
.await
.unwrap_or(domain::ports::FederationFlags {
goals: true,
reviews: true,
watchlist: true,
});
if !flags.goals {
return Ok(());
}
let ap_id = goal_url(&self.base_url, user_id.value(), year);
self.ap_service
.broadcast_delete_to_followers(user_id.value(), ap_id)
.await?;
Ok(())
}
} }

View File

@@ -0,0 +1,54 @@
use std::sync::Arc;
use domain::events::DomainEvent;
use domain::value_objects::UserId;
use k_ap::FederationEvent;
pub struct FederationEventBridge {
domain_publisher: Arc<dyn domain::ports::EventPublisher>,
}
impl FederationEventBridge {
pub fn new(domain_publisher: Arc<dyn domain::ports::EventPublisher>) -> Self {
Self { domain_publisher }
}
}
#[async_trait::async_trait]
impl k_ap::EventPublisher for FederationEventBridge {
async fn publish(&self, event: FederationEvent) -> anyhow::Result<()> {
match event {
FederationEvent::BackfillRequested {
owner_user_id,
follower_inbox_url,
} => self
.domain_publisher
.publish(&DomainEvent::BackfillFollower {
owner_user_id: UserId::from_uuid(owner_user_id),
follower_inbox_url,
})
.await
.map_err(|e| anyhow::anyhow!(e.to_string())),
FederationEvent::DeliveryRequested {
inbox,
activity,
signing_actor_id,
} => {
let json = serde_json::to_string(&activity)
.map_err(|e| anyhow::anyhow!("serialize activity: {e}"))?;
self.domain_publisher
.publish(&DomainEvent::FederationDeliveryRequested {
inbox_url: inbox.to_string(),
activity_json: json,
signing_actor_id,
})
.await
.map_err(|e| anyhow::anyhow!(e.to_string()))
}
FederationEvent::DeliveryFailed { inbox, error, .. } => {
tracing::warn!(inbox = %inbox, error = %error, "federation delivery failed permanently");
Ok(())
}
}
}
}

View File

@@ -0,0 +1,95 @@
use std::sync::Arc;
use async_trait::async_trait;
use domain::{models::RemoteGoalEntry, ports::RemoteGoalRepository};
use k_ap::ApObjectHandler;
use url::Url;
use crate::objects::GoalObject;
pub struct GoalObjectHandler {
pub remote_goal_repo: Arc<dyn RemoteGoalRepository>,
}
#[async_trait]
impl ApObjectHandler for GoalObjectHandler {
async fn on_create(
&self,
ap_id: &Url,
actor_url: &Url,
object: serde_json::Value,
) -> anyhow::Result<()> {
let obj: GoalObject = match serde_json::from_value(object) {
Ok(o) => o,
Err(e) => {
tracing::warn!(ap_id = %ap_id, "ignoring malformed goal Create: {}", e);
return Ok(());
}
};
let entry = RemoteGoalEntry {
ap_id: ap_id.as_str().to_string(),
actor_url: actor_url.as_str().to_string(),
year: obj.goal_year,
target_count: obj.goal_target,
current_count: obj.goal_current,
received_at: chrono::Utc::now(),
};
self.remote_goal_repo.save(entry).await?;
tracing::info!(ap_id = %ap_id, year = obj.goal_year, "saved remote goal");
Ok(())
}
async fn on_update(
&self,
ap_id: &Url,
actor_url: &Url,
object: serde_json::Value,
) -> anyhow::Result<()> {
let obj: GoalObject = match serde_json::from_value(object) {
Ok(o) => o,
Err(e) => {
tracing::warn!(ap_id = %ap_id, "ignoring malformed goal Update: {}", e);
return Ok(());
}
};
if obj.attributed_to != *actor_url {
anyhow::bail!("goal Update actor does not match object attributed_to");
}
self.remote_goal_repo
.update_by_ap_id(ap_id.as_str(), obj.goal_target, obj.goal_current)
.await?;
tracing::info!(ap_id = %ap_id, "updated remote goal progress");
Ok(())
}
async fn on_delete(&self, ap_id: &Url, actor_url: &Url) -> anyhow::Result<()> {
self.remote_goal_repo
.remove_by_ap_id(ap_id.as_str(), actor_url.as_str())
.await?;
tracing::info!(ap_id = %ap_id, "removed remote goal");
Ok(())
}
async fn on_actor_removed(&self, actor_url: &Url) -> anyhow::Result<()> {
self.remote_goal_repo
.remove_all_by_actor(actor_url.as_str())
.await?;
Ok(())
}
async fn on_like(&self, _: &Url, _: &Url) -> anyhow::Result<()> {
Ok(())
}
async fn on_announce_received(&self, _: &Url, _: &Url) -> anyhow::Result<()> {
Ok(())
}
async fn on_announce_of_remote(&self, _: &Url, _: &Url) -> anyhow::Result<()> {
Ok(())
}
async fn on_unlike(&self, _: &Url, _: &Url) -> anyhow::Result<()> {
Ok(())
}
async fn on_mention(&self, _: &Url, _: uuid::Uuid, _: &Url) -> anyhow::Result<()> {
Ok(())
}
}

View File

@@ -1,15 +1,23 @@
pub mod composite_handler;
pub mod event_handler; pub mod event_handler;
pub mod federation_event_bridge;
pub mod goal_handler;
pub mod objects; pub mod objects;
pub mod port; pub mod port;
pub mod remote_review_repository; pub mod remote_review_repository;
pub mod review_handler; pub mod review_handler;
pub(crate) mod urls; pub(crate) mod urls;
pub mod user_adapter; pub mod user_adapter;
pub mod watchlist_handler;
pub const INSTANCE_ACTOR_ID: uuid::Uuid =
uuid::Uuid::from_bytes([0, 0, 0, 0, 0, 0, 0x40, 0, 0x80, 0, 0, 0, 0, 0, 0, 0]);
// Re-export the generic base types that callers need // Re-export the generic base types that callers need
pub use activitypub_base::{ pub use k_ap::{
ActivityPubService, ApFederationConfig, ApObjectHandler, ApUser, ApUserRepository, ActivityPubService, ActivityRepository, ActorRepository, ApContentReader, ApFederationConfig,
FederationData, FederationRepository, Follower, FollowerStatus, FollowingStatus, RemoteActor, ApObjectHandler, ApUser, ApUserRepository, BlocklistRepository, FederationData,
FollowRepository, Follower, FollowerStatus, FollowingStatus, RemoteActor,
}; };
pub use event_handler::ActivityPubEventHandler; pub use event_handler::ActivityPubEventHandler;
@@ -18,45 +26,112 @@ pub use remote_review_repository::RemoteReviewRepository;
pub use review_handler::ReviewObjectHandler; pub use review_handler::ReviewObjectHandler;
pub use user_adapter::DomainUserRepoAdapter; pub use user_adapter::DomainUserRepoAdapter;
pub type FederationRepos = (
std::sync::Arc<dyn ActivityRepository>,
std::sync::Arc<dyn FollowRepository>,
std::sync::Arc<dyn ActorRepository>,
std::sync::Arc<dyn BlocklistRepository>,
std::sync::Arc<dyn domain::ports::SocialQueryPort>,
std::sync::Arc<dyn RemoteReviewRepository>,
std::sync::Arc<dyn domain::ports::RemoteWatchlistRepository>,
);
pub struct ActivityPubWire { pub struct ActivityPubWire {
pub service: std::sync::Arc<dyn ActivityPubPort>, pub service: std::sync::Arc<dyn ActivityPubPort>,
pub router: axum::Router, pub router: axum::Router,
pub event_handler: std::sync::Arc<dyn domain::ports::EventHandler>, pub event_handler: std::sync::Arc<dyn domain::ports::EventHandler>,
} }
pub async fn wire( pub struct ActivityPubDeps {
federation_repo: std::sync::Arc<dyn FederationRepository>, pub activity_repo: std::sync::Arc<dyn ActivityRepository>,
review_store: std::sync::Arc<dyn RemoteReviewRepository>, pub follow_repo: std::sync::Arc<dyn FollowRepository>,
user_repo: std::sync::Arc<dyn domain::ports::UserRepository>, pub actor_repo: std::sync::Arc<dyn ActorRepository>,
movie_repo: std::sync::Arc<dyn domain::ports::MovieRepository>, pub blocklist_repo: std::sync::Arc<dyn BlocklistRepository>,
review_repo: std::sync::Arc<dyn domain::ports::ReviewRepository>, pub review_store: std::sync::Arc<dyn RemoteReviewRepository>,
diary_repo: std::sync::Arc<dyn domain::ports::DiaryRepository>, pub remote_watchlist_repo: std::sync::Arc<dyn domain::ports::RemoteWatchlistRepository>,
base_url: String, pub remote_goal_repo: std::sync::Arc<dyn domain::ports::RemoteGoalRepository>,
allow_registration: bool, pub local_ap_content: std::sync::Arc<dyn domain::ports::LocalApContentQuery>,
) -> anyhow::Result<ActivityPubWire> { pub user_repo: std::sync::Arc<dyn domain::ports::UserRepository>,
pub federation_settings: std::sync::Arc<dyn domain::ports::UserFederationSettingsQuery>,
pub base_url: String,
pub allow_registration: bool,
pub event_publisher: std::sync::Arc<dyn domain::ports::EventPublisher>,
}
pub async fn wire(deps: ActivityPubDeps) -> anyhow::Result<ActivityPubWire> {
let ActivityPubDeps {
activity_repo,
follow_repo,
actor_repo,
blocklist_repo,
review_store,
remote_watchlist_repo,
remote_goal_repo,
local_ap_content,
user_repo,
federation_settings,
base_url,
allow_registration,
event_publisher,
} = deps;
let review_handler = std::sync::Arc::new(ReviewObjectHandler {
content_query: std::sync::Arc::clone(&local_ap_content),
review_store,
base_url: base_url.clone(),
});
let watchlist_handler = std::sync::Arc::new(watchlist_handler::WatchlistObjectHandler {
remote_watchlist_repo,
content_query: std::sync::Arc::clone(&local_ap_content),
base_url: base_url.clone(),
});
let goal_handler = std::sync::Arc::new(goal_handler::GoalObjectHandler { remote_goal_repo });
let composite = std::sync::Arc::new(composite_handler::CompositeObjectHandler {
review: review_handler,
watchlist: watchlist_handler,
goal: goal_handler,
});
let federation_debug = std::env::var("FEDERATION_DEBUG")
.map(|v| v == "true" || v == "1")
.unwrap_or(false);
if federation_debug {
tracing::warn!(
"federation running in DEBUG mode — PermissiveVerifier active, \
no URL/signature validation. Do NOT use in production."
);
}
let fed_event_bridge = std::sync::Arc::new(
federation_event_bridge::FederationEventBridge::new(event_publisher),
);
let concrete = std::sync::Arc::new( let concrete = std::sync::Arc::new(
ActivityPubService::new( ActivityPubService::builder(base_url.clone())
federation_repo, .activity_repo(activity_repo)
std::sync::Arc::new(DomainUserRepoAdapter::new(user_repo, base_url.clone())), .follow_repo(follow_repo)
std::sync::Arc::new(ReviewObjectHandler { .actor_repo(actor_repo)
movie_repository: std::sync::Arc::clone(&movie_repo), .blocklist_repo(blocklist_repo)
diary_repository: diary_repo, .user_repo(std::sync::Arc::new(DomainUserRepoAdapter::new(
review_store, user_repo,
base_url: base_url.clone(), base_url.clone(),
}), )))
base_url.clone(), .signed_fetch_actor_id(INSTANCE_ACTOR_ID)
allow_registration, .content_reader(composite.clone() as std::sync::Arc<dyn ApContentReader>)
"movies-diary".to_string(), .object_handler(composite as std::sync::Arc<dyn ApObjectHandler>)
cfg!(debug_assertions), .event_publisher(fed_event_bridge)
) .allow_registration(allow_registration)
.await?, .software_name("movies-diary")
.debug(federation_debug)
.build()
.await?,
); );
let router = concrete.router(); let router = concrete.router();
let event_handler = std::sync::Arc::new(ActivityPubEventHandler::new( let event_handler = std::sync::Arc::new(ActivityPubEventHandler::new(
std::sync::Arc::clone(&concrete), std::sync::Arc::clone(&concrete),
movie_repo, local_ap_content,
review_repo, federation_settings,
base_url, base_url,
)) as std::sync::Arc<dyn domain::ports::EventHandler>; )) as std::sync::Arc<dyn domain::ports::EventHandler>;

View File

@@ -1,5 +1,6 @@
use activitypub_federation::kinds::object::NoteType;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use k_ap::AS_PUBLIC;
use k_ap::NoteType;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use url::Url; use url::Url;
@@ -36,6 +37,10 @@ pub struct ReviewObject {
pub(crate) watched_at: DateTime<Utc>, pub(crate) watched_at: DateTime<Utc>,
#[serde(default)] #[serde(default)]
pub(crate) tag: Vec<ApHashtag>, pub(crate) tag: Vec<ApHashtag>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub(crate) to: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub(crate) cc: Vec<String>,
} }
/// Serialize a local Review into a ReviewObject for AP delivery. /// Serialize a local Review into a ReviewObject for AP delivery.
@@ -69,8 +74,7 @@ pub fn review_to_ap_object(
let tag = vec![ let tag = vec![
ApHashtag { ApHashtag {
kind: "Hashtag".to_string(), kind: "Hashtag".to_string(),
href: Url::parse(&format!("{}/tags/moviesdiary", base_url)) href: Url::parse(&format!("{}/tags/moviesdiary", base_url)).expect("valid base_url"),
.expect("valid base_url"),
name: "#MoviesDiary".to_string(), name: "#MoviesDiary".to_string(),
}, },
ApHashtag { ApHashtag {
@@ -84,7 +88,7 @@ pub fn review_to_ap_object(
ReviewObject { ReviewObject {
kind: NoteType::default(), kind: NoteType::default(),
id: ap_id, id: ap_id,
attributed_to: actor_url, attributed_to: actor_url.clone(),
content, content,
published: DateTime::from_naive_utc_and_offset(*review.created_at(), Utc), published: DateTime::from_naive_utc_and_offset(*review.created_at(), Utc),
movie_title, movie_title,
@@ -94,6 +98,154 @@ pub fn review_to_ap_object(
comment: comment_text, comment: comment_text,
watched_at: DateTime::from_naive_utc_and_offset(*review.watched_at(), Utc), watched_at: DateTime::from_naive_utc_and_offset(*review.watched_at(), Utc),
tag, tag,
to: vec![AS_PUBLIC.to_string()],
cc: vec![format!("{}/followers", actor_url)],
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct WatchlistObject {
#[serde(rename = "type")]
pub(crate) kind: NoteType,
pub(crate) id: Url,
pub(crate) attributed_to: Url,
pub(crate) content: String,
pub(crate) published: chrono::DateTime<chrono::Utc>,
pub(crate) movie_title: String,
#[serde(default)]
pub(crate) release_year: u16,
#[serde(default)]
pub(crate) external_metadata_id: Option<String>,
#[serde(default)]
pub(crate) poster_url: Option<String>,
#[serde(default)]
pub(crate) tag: Vec<ApHashtag>,
/// Discriminator so Movies Diary instances distinguish this from a review Note.
/// Non-Movies-Diary apps ignore unknown fields.
#[serde(default)]
pub(crate) watchlist_entry: bool,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub(crate) to: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub(crate) cc: Vec<String>,
}
pub struct WatchlistApInput {
pub ap_id: Url,
pub actor_url: Url,
pub movie_title: String,
pub release_year: u16,
pub external_metadata_id: Option<String>,
pub poster_url: Option<String>,
pub added_at: chrono::DateTime<chrono::Utc>,
pub base_url: String,
}
pub fn watchlist_to_ap_object(input: WatchlistApInput) -> WatchlistObject {
let WatchlistApInput {
ap_id,
actor_url,
movie_title,
release_year,
external_metadata_id,
poster_url,
added_at,
base_url,
} = input;
let year_str = if release_year > 0 {
format!(" ({})", release_year)
} else {
String::new()
};
let content = format!("📋 {}{} — want to watch", movie_title, year_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),
},
];
WatchlistObject {
kind: NoteType::default(),
id: ap_id,
attributed_to: actor_url.clone(),
content,
published: added_at,
movie_title,
release_year,
external_metadata_id,
poster_url,
tag,
watchlist_entry: true,
to: vec![AS_PUBLIC.to_string()],
cc: vec![format!("{}/followers", actor_url)],
}
}
// ── Goal object ──────────────────────────────────────────────────────────────
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GoalObject {
#[serde(rename = "type")]
pub(crate) kind: NoteType,
pub(crate) id: Url,
pub(crate) attributed_to: Url,
pub(crate) content: String,
pub(crate) published: chrono::DateTime<chrono::Utc>,
pub(crate) goal_year: u16,
pub(crate) goal_target: u32,
pub(crate) goal_current: u32,
#[serde(default)]
pub(crate) goal: bool,
#[serde(default)]
pub(crate) tag: Vec<ApHashtag>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub(crate) to: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub(crate) cc: Vec<String>,
}
pub fn goal_to_ap_object(
ap_id: Url,
actor_url: Url,
year: u16,
target: u32,
current: u32,
base_url: &str,
) -> GoalObject {
let content = format!(
"🎯 Goal: Watch {} movies in {} ({}/{})",
target, year, current, target
);
let tag = vec![ApHashtag {
kind: "Hashtag".to_string(),
href: Url::parse(&format!("{}/tags/moviesdiary", base_url)).expect("valid base_url"),
name: "#MoviesDiary".to_string(),
}];
GoalObject {
kind: NoteType::default(),
id: ap_id,
attributed_to: actor_url.clone(),
content,
published: chrono::Utc::now(),
goal_year: year,
goal_target: target,
goal_current: current,
goal: true,
tag,
to: vec![AS_PUBLIC.to_string()],
cc: vec![format!("{}/followers", actor_url)],
} }
} }

View File

@@ -1,7 +1,7 @@
use async_trait::async_trait; use async_trait::async_trait;
use uuid::Uuid; use uuid::Uuid;
use activitypub_base::{ActivityPubService, BlockedDomain, RemoteActor}; use k_ap::{ActivityPubService, BlockedDomain, RemoteActor};
#[async_trait] #[async_trait]
pub trait ActivityPubPort: Send + Sync { pub trait ActivityPubPort: Send + Sync {
@@ -31,6 +31,22 @@ pub trait ActivityPubPort: Send + Sync {
async fn add_blocked_domain(&self, domain: &str, reason: Option<&str>) -> anyhow::Result<()>; 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 remove_blocked_domain(&self, domain: &str) -> anyhow::Result<()>;
async fn get_blocked_domains(&self) -> anyhow::Result<Vec<BlockedDomain>>; async fn get_blocked_domains(&self) -> anyhow::Result<Vec<BlockedDomain>>;
async fn import_remote_outbox(&self, outbox_url: &str, actor_url: &str) -> anyhow::Result<()>;
async fn followers_collection_json(
&self,
user_id: Uuid,
page: Option<u32>,
) -> anyhow::Result<String>;
async fn following_collection_json(
&self,
user_id: Uuid,
page: Option<u32>,
) -> anyhow::Result<String>;
async fn run_backfill_for_follower(
&self,
owner_user_id: Uuid,
follower_inbox_url: String,
) -> anyhow::Result<()>;
} }
#[async_trait] #[async_trait]
@@ -97,6 +113,31 @@ impl ActivityPubPort for ActivityPubService {
async fn get_blocked_domains(&self) -> anyhow::Result<Vec<BlockedDomain>> { async fn get_blocked_domains(&self) -> anyhow::Result<Vec<BlockedDomain>> {
self.get_blocked_domains().await self.get_blocked_domains().await
} }
async fn import_remote_outbox(&self, outbox_url: &str, actor_url: &str) -> anyhow::Result<()> {
self.import_remote_outbox(outbox_url, actor_url).await
}
async fn followers_collection_json(
&self,
user_id: Uuid,
page: Option<u32>,
) -> anyhow::Result<String> {
self.followers_collection_json(user_id, page).await
}
async fn following_collection_json(
&self,
user_id: Uuid,
page: Option<u32>,
) -> anyhow::Result<String> {
self.following_collection_json(user_id, page).await
}
async fn run_backfill_for_follower(
&self,
owner_user_id: Uuid,
follower_inbox_url: String,
) -> anyhow::Result<()> {
self.run_backfill_for_follower(owner_user_id, follower_inbox_url)
.await
}
} }
pub struct NoopActivityPubService; pub struct NoopActivityPubService;
@@ -154,4 +195,16 @@ impl ActivityPubPort for NoopActivityPubService {
async fn get_blocked_domains(&self) -> anyhow::Result<Vec<BlockedDomain>> { async fn get_blocked_domains(&self) -> anyhow::Result<Vec<BlockedDomain>> {
Ok(vec![]) Ok(vec![])
} }
async fn import_remote_outbox(&self, _: &str, _: &str) -> anyhow::Result<()> {
Ok(())
}
async fn followers_collection_json(&self, _: Uuid, _: Option<u32>) -> anyhow::Result<String> {
Ok(String::new())
}
async fn following_collection_json(&self, _: Uuid, _: Option<u32>) -> anyhow::Result<String> {
Ok(String::new())
}
async fn run_backfill_for_follower(&self, _: Uuid, _: String) -> anyhow::Result<()> {
Ok(())
}
} }

View File

@@ -23,6 +23,7 @@ pub trait RemoteReviewRepository: Send + Sync {
rating: u8, rating: u8,
comment: Option<&str>, comment: Option<&str>,
watched_at: NaiveDateTime, watched_at: NaiveDateTime,
poster_url: Option<&str>,
) -> Result<()>; ) -> Result<()>;
async fn delete_by_actor(&self, actor_url: &str) -> Result<()>; async fn delete_by_actor(&self, actor_url: &str) -> Result<()>;

View File

@@ -1,12 +1,12 @@
use std::sync::Arc; use std::sync::Arc;
use activitypub_base::ApObjectHandler;
use async_trait::async_trait; use async_trait::async_trait;
use domain::{ use domain::{
models::{Review, ReviewSource}, models::ReviewSource,
ports::{DiaryRepository, MovieRepository}, ports::LocalApContentQuery,
value_objects::{Comment, MovieId, Rating, ReviewId, UserId}, value_objects::{Comment, MovieId, Rating, ReviewId, UserId},
}; };
use k_ap::{ApContentReader, ApObjectHandler};
use url::Url; use url::Url;
use crate::objects::{ReviewObject, review_to_ap_object}; use crate::objects::{ReviewObject, review_to_ap_object};
@@ -14,149 +14,78 @@ use crate::remote_review_repository::RemoteReviewRepository;
use crate::urls::{actor_url, review_url}; use crate::urls::{actor_url, review_url};
pub struct ReviewObjectHandler { pub struct ReviewObjectHandler {
pub movie_repository: Arc<dyn MovieRepository>, pub content_query: Arc<dyn LocalApContentQuery>,
pub diary_repository: Arc<dyn DiaryRepository>,
pub review_store: Arc<dyn RemoteReviewRepository>, pub review_store: Arc<dyn RemoteReviewRepository>,
pub base_url: String, pub base_url: String,
} }
#[async_trait] #[async_trait]
impl ApObjectHandler for ReviewObjectHandler { impl ApContentReader 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( async fn get_local_objects_page(
&self, &self,
user_id: uuid::Uuid, user_id: uuid::Uuid,
before: Option<chrono::DateTime<chrono::Utc>>, before: Option<chrono::DateTime<chrono::Utc>>,
limit: usize, limit: usize,
) -> anyhow::Result<Vec<(url::Url, serde_json::Value, chrono::DateTime<chrono::Utc>)>> { ) -> 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 domain_user_id = UserId::from_uuid(user_id);
let history = self let before_naive = before.map(|dt| dt.naive_utc());
.diary_repository let entries = self
.get_user_history(&domain_user_id) .content_query
.await?; .get_local_reviews_page(&domain_user_id, before_naive, limit)
.await
.map_err(|e| anyhow::anyhow!(e.to_string()))?;
let actor = actor_url(&self.base_url, user_id);
let mut results = Vec::new(); let mut results = Vec::new();
for entry in history { for entry in entries {
let review = entry.review(); let review = entry.review();
if !matches!(review.source(), ReviewSource::Local) {
continue;
}
let published = let published =
chrono::DateTime::from_naive_utc_and_offset(*review.watched_at(), chrono::Utc); chrono::DateTime::from_naive_utc_and_offset(*review.watched_at(), chrono::Utc);
let movie = entry.movie();
if let Some(cutoff) = before {
if published >= cutoff {
continue;
}
}
let ap_id = review_url(&self.base_url, review.id()); 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 let poster_url = movie
.as_ref() .poster_path()
.and_then(|m| m.poster_path())
.map(|p| format!("{}/images/{}", self.base_url, p.value())); .map(|p| format!("{}/images/{}", self.base_url, p.value()));
let obj = review_to_ap_object( let obj = review_to_ap_object(
review, review,
ap_id.clone(), ap_id.clone(),
actor_url, actor.clone(),
movie_title, movie.title().value().to_string(),
release_year, movie.release_year().value(),
poster_url, poster_url,
&self.base_url, &self.base_url,
); );
let json = serde_json::to_value(obj)?; results.push((ap_id, serde_json::to_value(obj)?, published));
results.push((ap_id, json, published));
if results.len() >= limit {
break;
}
} }
Ok(results) Ok(results)
} }
async fn count_local_posts(&self) -> anyhow::Result<u64> {
self.content_query
.count_local_posts()
.await
.map_err(|e| anyhow::anyhow!(e.to_string()))
}
}
#[async_trait]
impl ApObjectHandler for ReviewObjectHandler {
async fn on_create( async fn on_create(
&self, &self,
_ap_id: &Url, _ap_id: &Url,
_actor_url: &Url, _actor_url: &Url,
object: serde_json::Value, object: serde_json::Value,
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
let obj: ReviewObject = match serde_json::from_value(object) { let mut obj: ReviewObject = match serde_json::from_value(object) {
Ok(o) => o, Ok(o) => o,
Err(e) => { Err(e) => {
tracing::debug!("ignoring unrecognized Create object: {}", e); tracing::warn!("ignoring unrecognized Create object: {}", e);
return Ok(()); return Ok(());
} }
}; };
obj.movie_title = ammonia::clean(&obj.movie_title);
obj.comment = obj.comment.map(|c| ammonia::clean(&c));
let actor_url_str = obj.attributed_to.to_string(); let actor_url_str = obj.attributed_to.to_string();
let review_id = ReviewId::generate(); let review_id = ReviewId::generate();
@@ -171,18 +100,18 @@ impl ApObjectHandler for ReviewObjectHandler {
let rating = Rating::new(obj.rating.min(5))?; let rating = Rating::new(obj.rating.min(5))?;
let comment = obj.comment.map(Comment::new).transpose()?; let comment = obj.comment.map(Comment::new).transpose()?;
let review = Review::from_persistence( let review = domain::models::Review::from_persistence(domain::models::PersistedReview {
review_id, id: review_id,
movie_id, movie_id,
user_id, user_id,
rating, rating,
comment, comment,
obj.watched_at.naive_utc(), watched_at: obj.watched_at.naive_utc(),
obj.published.naive_utc(), created_at: obj.published.naive_utc(),
ReviewSource::Remote { source: ReviewSource::Remote {
actor_url: actor_url_str, actor_url: actor_url_str,
}, },
); });
self.review_store self.review_store
.save_remote_review( .save_remote_review(
@@ -203,13 +132,15 @@ impl ApObjectHandler for ReviewObjectHandler {
actor_url: &Url, actor_url: &Url,
object: serde_json::Value, object: serde_json::Value,
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
let obj: ReviewObject = match serde_json::from_value(object) { let mut obj: ReviewObject = match serde_json::from_value(object) {
Ok(o) => o, Ok(o) => o,
Err(_) => { Err(_) => {
tracing::debug!(actor = %actor_url, "ignoring non-review Update activity"); tracing::warn!(actor = %actor_url, "ignoring non-review Update activity");
return Ok(()); return Ok(());
} }
}; };
obj.movie_title = ammonia::clean(&obj.movie_title);
obj.comment = obj.comment.map(|c| ammonia::clean(&c));
if obj.attributed_to != *actor_url { if obj.attributed_to != *actor_url {
anyhow::bail!("update actor does not match object attributed_to"); anyhow::bail!("update actor does not match object attributed_to");
@@ -222,6 +153,7 @@ impl ApObjectHandler for ReviewObjectHandler {
obj.rating.min(5), obj.rating.min(5),
obj.comment.as_deref(), obj.comment.as_deref(),
obj.watched_at.naive_utc(), obj.watched_at.naive_utc(),
obj.poster_url.as_deref(),
) )
.await?; .await?;
@@ -238,10 +170,36 @@ impl ApObjectHandler for ReviewObjectHandler {
self.review_store.delete_by_actor(actor_url.as_str()).await self.review_store.delete_by_actor(actor_url.as_str()).await
} }
async fn count_local_posts(&self) -> anyhow::Result<u64> { async fn on_like(&self, _object_url: &Url, _actor_url: &Url) -> anyhow::Result<()> {
self.diary_repository Ok(())
.count_local_posts() }
.await
.map_err(|e| anyhow::anyhow!(e.to_string())) async fn on_announce_received(
&self,
_object_url: &Url,
_actor_url: &Url,
) -> anyhow::Result<()> {
Ok(())
}
async fn on_announce_of_remote(
&self,
_object_url: &Url,
_actor_url: &Url,
) -> anyhow::Result<()> {
Ok(())
}
async fn on_unlike(&self, _object_url: &Url, _actor_url: &Url) -> anyhow::Result<()> {
Ok(())
}
async fn on_mention(
&self,
_thought_ap_id: &Url,
_mentioned_user_uuid: uuid::Uuid,
_actor_url: &Url,
) -> anyhow::Result<()> {
Ok(())
} }
} }

View File

@@ -4,27 +4,32 @@ use super::*;
fn normalize_hashtag_strips_non_alphanumeric() { fn normalize_hashtag_strips_non_alphanumeric() {
assert_eq!(normalize_hashtag("The Dark Knight"), "TheDarkKnight"); assert_eq!(normalize_hashtag("The Dark Knight"), "TheDarkKnight");
assert_eq!(normalize_hashtag("Schindler's List"), "SchindlersList"); assert_eq!(normalize_hashtag("Schindler's List"), "SchindlersList");
assert_eq!(normalize_hashtag("2001: A Space Odyssey"), "2001ASpaceOdyssey"); assert_eq!(
normalize_hashtag("2001: A Space Odyssey"),
"2001ASpaceOdyssey"
);
} }
#[test] #[test]
fn review_to_ap_object_includes_two_hashtags() { fn review_to_ap_object_includes_two_hashtags() {
use chrono::NaiveDateTime; use chrono::NaiveDateTime;
use domain::{ use domain::{
models::{Review, ReviewSource}, models::{PersistedReview, Review, ReviewSource},
value_objects::{MovieId, Rating, ReviewId, UserId}, value_objects::{MovieId, Rating, ReviewId, UserId},
}; };
let review = Review::from_persistence( let review = Review::from_persistence(PersistedReview {
ReviewId::generate(), id: ReviewId::generate(),
MovieId::from_uuid(uuid::Uuid::new_v4()), movie_id: MovieId::from_uuid(uuid::Uuid::new_v4()),
UserId::from_uuid(uuid::Uuid::new_v4()), user_id: UserId::from_uuid(uuid::Uuid::new_v4()),
Rating::new(4).unwrap(), rating: Rating::new(4).unwrap(),
None, comment: None,
NaiveDateTime::parse_from_str("2024-01-01 00:00:00", "%Y-%m-%d %H:%M:%S").unwrap(), watched_at: NaiveDateTime::parse_from_str("2024-01-01 00:00:00", "%Y-%m-%d %H:%M:%S")
NaiveDateTime::parse_from_str("2024-01-01 00:00:00", "%Y-%m-%d %H:%M:%S").unwrap(), .unwrap(),
ReviewSource::Local, created_at: NaiveDateTime::parse_from_str("2024-01-01 00:00:00", "%Y-%m-%d %H:%M:%S")
); .unwrap(),
source: ReviewSource::Local,
});
let obj = review_to_ap_object( let obj = review_to_ap_object(
&review, &review,
"https://example.com/reviews/1".parse().unwrap(), "https://example.com/reviews/1".parse().unwrap(),
@@ -39,3 +44,54 @@ fn review_to_ap_object_includes_two_hashtags() {
assert!(names.contains(&"#MoviesDiary")); assert!(names.contains(&"#MoviesDiary"));
assert!(names.contains(&"#Dune")); assert!(names.contains(&"#Dune"));
} }
#[test]
fn review_to_ap_object_has_public_addressing() {
use chrono::NaiveDateTime;
use domain::{
models::{PersistedReview, Review, ReviewSource},
value_objects::{MovieId, Rating, ReviewId, UserId},
};
let review = Review::from_persistence(PersistedReview {
id: ReviewId::generate(),
movie_id: MovieId::from_uuid(uuid::Uuid::new_v4()),
user_id: UserId::from_uuid(uuid::Uuid::new_v4()),
rating: Rating::new(3).unwrap(),
comment: None,
watched_at: NaiveDateTime::parse_from_str("2024-06-01 00:00:00", "%Y-%m-%d %H:%M:%S")
.unwrap(),
created_at: NaiveDateTime::parse_from_str("2024-06-01 00:00:00", "%Y-%m-%d %H:%M:%S")
.unwrap(),
source: ReviewSource::Local,
});
let actor_url: url::Url = "https://example.com/users/abc".parse().unwrap();
let obj = review_to_ap_object(
&review,
"https://example.com/reviews/1".parse().unwrap(),
actor_url.clone(),
"Dune".to_string(),
2021,
None,
"https://example.com",
);
assert_eq!(obj.to, vec!["https://www.w3.org/ns/activitystreams#Public"]);
assert_eq!(obj.cc, vec!["https://example.com/users/abc/followers"]);
}
#[test]
fn watchlist_to_ap_object_has_public_addressing() {
let actor_url: url::Url = "https://example.com/users/abc".parse().unwrap();
let obj = watchlist_to_ap_object(WatchlistApInput {
ap_id: "https://example.com/watchlist/1".parse().unwrap(),
actor_url: actor_url.clone(),
movie_title: "Alien".to_string(),
release_year: 1979,
external_metadata_id: None,
poster_url: None,
added_at: chrono::Utc::now(),
base_url: "https://example.com".to_string(),
});
assert_eq!(obj.to, vec!["https://www.w3.org/ns/activitystreams#Public"]);
assert_eq!(obj.cc, vec!["https://example.com/users/abc/followers"]);
}

View File

@@ -12,3 +12,17 @@ pub fn review_url(base_url: &str, review_id: &ReviewId) -> Url {
Url::parse(&format!("{}/reviews/{}", base_url, review_id.value())) Url::parse(&format!("{}/reviews/{}", base_url, review_id.value()))
.expect("base_url is always a valid URL prefix") .expect("base_url is always a valid URL prefix")
} }
pub fn goal_url(base_url: &str, user_id: uuid::Uuid, year: u16) -> Url {
Url::parse(&format!("{}/users/{}/goals/{}", base_url, user_id, year))
.expect("base_url is always a valid URL prefix")
}
/// Builds the canonical watchlist entry URL: `{base_url}/users/{user_id}/watchlist/{movie_id}`
pub fn watchlist_entry_url(base_url: &str, user_id: uuid::Uuid, movie_id: uuid::Uuid) -> Url {
Url::parse(&format!(
"{}/users/{}/watchlist/{}",
base_url, user_id, movie_id
))
.expect("base_url is always a valid URL prefix")
}

View File

@@ -1,8 +1,8 @@
use std::sync::Arc; use std::sync::Arc;
use activitypub_base::{ApUser, ApUserRepository};
use async_trait::async_trait; use async_trait::async_trait;
use domain::{ports::UserRepository, value_objects::UserId}; use domain::{ports::UserRepository, value_objects::UserId};
use k_ap::{ApProfileField, ApUser, ApUserRepository};
use url::Url; use url::Url;
pub struct DomainUserRepoAdapter { pub struct DomainUserRepoAdapter {
@@ -16,16 +16,42 @@ impl DomainUserRepoAdapter {
} }
fn build_user(&self, u: &domain::models::User) -> ApUser { fn build_user(&self, u: &domain::models::User) -> ApUser {
let avatar_url = u.avatar_path().and_then(|p| { let avatar_url = u
Url::parse(&format!("{}/images/{}", self.base_url, p)).ok() .avatar_path()
}); .and_then(|p| Url::parse(&format!("{}/images/{}", self.base_url, p)).ok());
let banner_url = u
.banner_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(); let profile_url = Url::parse(&format!("{}/u/{}", self.base_url, u.username().value())).ok();
ApUser { ApUser {
id: u.id().value(), id: u.id().value(),
username: u.username().value().to_string(), username: u.username().value().to_string(),
display_name: u.display_name().map(|s| s.to_string()),
bio: u.bio().map(|s| s.to_string()), bio: u.bio().map(|s| s.to_string()),
avatar_url, avatar_url,
banner_url,
also_known_as: u
.also_known_as()
.map(|s| vec![s.to_string()])
.unwrap_or_default(),
profile_url, profile_url,
attachment: u
.profile_fields()
.iter()
.map(|f| ApProfileField {
name: f.name.clone(),
value: f.value.clone(),
})
.collect(),
manually_approves_followers: true,
discoverable: true,
actor_type: Default::default(),
featured_url: Url::parse(&format!(
"{}/users/{}/featured",
self.base_url,
u.id().value()
))
.ok(),
} }
} }
} }
@@ -34,17 +60,29 @@ impl DomainUserRepoAdapter {
impl ApUserRepository for DomainUserRepoAdapter { impl ApUserRepository for DomainUserRepoAdapter {
async fn find_by_id(&self, id: uuid::Uuid) -> anyhow::Result<Option<ApUser>> { async fn find_by_id(&self, id: uuid::Uuid) -> anyhow::Result<Option<ApUser>> {
let user_id = UserId::from_uuid(id); let user_id = UserId::from_uuid(id);
Ok(self.repo.find_by_id(&user_id).await?.as_ref().map(|u| self.build_user(u))) let user = match self.repo.find_by_id(&user_id).await? {
Some(u) => u,
None => return Ok(None),
};
Ok(Some(self.build_user(&user)))
} }
async fn find_by_username(&self, username: &str) -> anyhow::Result<Option<ApUser>> { async fn find_by_username(&self, username: &str) -> anyhow::Result<Option<ApUser>> {
use domain::value_objects::Username; use domain::value_objects::Username;
let uname = Username::new(username.to_string()).map_err(|e| anyhow::anyhow!(e.to_string()))?; let uname =
Ok(self.repo.find_by_username(&uname).await?.as_ref().map(|u| self.build_user(u))) Username::new(username.to_string()).map_err(|e| anyhow::anyhow!(e.to_string()))?;
let user = match self.repo.find_by_username(&uname).await? {
Some(u) => u,
None => return Ok(None),
};
Ok(Some(self.build_user(&user)))
} }
async fn count_users(&self) -> anyhow::Result<usize> { async fn count_users(&self) -> anyhow::Result<usize> {
Ok(self.repo.list_with_stats().await Ok(self
.repo
.list_with_stats()
.await
.map_err(|e| anyhow::anyhow!(e.to_string()))? .map_err(|e| anyhow::anyhow!(e.to_string()))?
.len()) .len())
} }

View File

@@ -0,0 +1,106 @@
use std::sync::Arc;
use async_trait::async_trait;
use domain::{
models::RemoteWatchlistEntry,
ports::{LocalApContentQuery, RemoteWatchlistRepository},
};
use k_ap::ApObjectHandler;
use url::Url;
use crate::objects::WatchlistObject;
pub struct WatchlistObjectHandler {
pub remote_watchlist_repo: Arc<dyn RemoteWatchlistRepository>,
pub content_query: Arc<dyn LocalApContentQuery>,
pub base_url: String,
}
#[async_trait]
impl ApObjectHandler for WatchlistObjectHandler {
async fn on_create(
&self,
ap_id: &Url,
actor_url: &Url,
object: serde_json::Value,
) -> anyhow::Result<()> {
let mut obj: WatchlistObject = match serde_json::from_value(object) {
Ok(o) => o,
Err(e) => {
tracing::warn!(ap_id = %ap_id, "ignoring malformed watchlist Create: {}", e);
return Ok(());
}
};
obj.movie_title = ammonia::clean(&obj.movie_title);
let added_at = obj.published;
let entry = RemoteWatchlistEntry {
ap_id: ap_id.as_str().to_string(),
actor_url: actor_url.as_str().to_string(),
movie_title: obj.movie_title,
release_year: obj.release_year,
external_metadata_id: obj.external_metadata_id,
poster_url: obj.poster_url,
added_at,
};
self.remote_watchlist_repo.save(entry).await?;
tracing::info!(ap_id = %ap_id, "saved remote watchlist entry");
Ok(())
}
async fn on_update(
&self,
_ap_id: &Url,
_actor_url: &Url,
_object: serde_json::Value,
) -> anyhow::Result<()> {
Ok(())
}
async fn on_delete(&self, ap_id: &Url, actor_url: &Url) -> anyhow::Result<()> {
self.remote_watchlist_repo
.remove_by_ap_id(ap_id.as_str(), actor_url.as_str())
.await?;
tracing::info!(ap_id = %ap_id, "removed remote watchlist entry");
Ok(())
}
async fn on_actor_removed(&self, actor_url: &Url) -> anyhow::Result<()> {
self.remote_watchlist_repo
.remove_all_by_actor(actor_url.as_str())
.await?;
Ok(())
}
async fn on_like(&self, _object_url: &Url, _actor_url: &Url) -> anyhow::Result<()> {
Ok(())
}
async fn on_announce_received(
&self,
_object_url: &Url,
_actor_url: &Url,
) -> anyhow::Result<()> {
Ok(())
}
async fn on_announce_of_remote(
&self,
_object_url: &Url,
_actor_url: &Url,
) -> anyhow::Result<()> {
Ok(())
}
async fn on_unlike(&self, _object_url: &Url, _actor_url: &Url) -> anyhow::Result<()> {
Ok(())
}
async fn on_mention(
&self,
_thought_ap_id: &Url,
_mentioned_user_uuid: uuid::Uuid,
_actor_url: &Url,
) -> anyhow::Result<()> {
Ok(())
}
}

View File

@@ -30,7 +30,7 @@ impl AuthConfig {
let ttl_seconds = std::env::var("JWT_TTL_SECONDS") let ttl_seconds = std::env::var("JWT_TTL_SECONDS")
.ok() .ok()
.and_then(|v| v.parse().ok()) .and_then(|v| v.parse().ok())
.unwrap_or(86400u64); .unwrap_or(900u64);
Ok(Self { Ok(Self {
secret, secret,
ttl_seconds, ttl_seconds,

View File

@@ -2,7 +2,10 @@ use chrono::NaiveDateTime;
use domain::{ use domain::{
errors::DomainError, errors::DomainError,
events::DomainEvent, events::DomainEvent,
value_objects::{ExternalMetadataId, MovieId, PosterPath, Rating, ReviewId, UserId}, models::{ExternalPersonId, PersonId},
value_objects::{
ExternalMetadataId, GoalId, MovieId, PosterPath, Rating, ReviewId, UserId, WrapUpId,
},
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use uuid::Uuid; use uuid::Uuid;
@@ -46,6 +49,71 @@ pub enum EventPayload {
ImageStored { ImageStored {
key: String, key: String,
}, },
WatchlistEntryAdded {
user_id: String,
movie_id: String,
movie_title: String,
release_year: u16,
external_metadata_id: Option<String>,
added_at: i64,
},
WatchlistEntryRemoved {
user_id: String,
movie_id: String,
},
FollowAccepted {
local_user_id: String,
remote_actor_url: String,
outbox_url: String,
},
BackfillFollower {
owner_user_id: String,
follower_inbox_url: String,
},
FederationDeliveryRequested {
inbox_url: String,
activity_json: String,
signing_actor_id: String,
},
WatchEventIngested {
user_id: String,
title: String,
source: String,
},
WrapUpRequested {
wrapup_id: String,
user_id: Option<String>,
start_date: String,
end_date: String,
},
WrapUpCompleted {
wrapup_id: String,
},
SearchReindexRequested,
PosterSynced {
movie_id: String,
},
GoalCreated {
goal_id: String,
user_id: String,
year: u16,
target_count: u32,
},
GoalUpdated {
goal_id: String,
user_id: String,
year: u16,
target_count: u32,
},
GoalDeleted {
goal_id: String,
user_id: String,
year: u16,
},
PersonEnrichmentRequested {
person_id: String,
external_person_id: String,
},
} }
impl EventPayload { impl EventPayload {
@@ -59,13 +127,26 @@ impl EventPayload {
EventPayload::ReviewDeleted { .. } => "ReviewDeleted", EventPayload::ReviewDeleted { .. } => "ReviewDeleted",
EventPayload::MovieEnrichmentRequested { .. } => "MovieEnrichmentRequested", EventPayload::MovieEnrichmentRequested { .. } => "MovieEnrichmentRequested",
EventPayload::ImageStored { .. } => "ImageStored", EventPayload::ImageStored { .. } => "ImageStored",
EventPayload::WatchlistEntryAdded { .. } => "WatchlistEntryAdded",
EventPayload::WatchlistEntryRemoved { .. } => "WatchlistEntryRemoved",
EventPayload::FollowAccepted { .. } => "FollowAccepted",
EventPayload::BackfillFollower { .. } => "BackfillFollower",
EventPayload::FederationDeliveryRequested { .. } => "FederationDeliveryRequested",
EventPayload::WatchEventIngested { .. } => "WatchEventIngested",
EventPayload::WrapUpRequested { .. } => "WrapUpRequested",
EventPayload::WrapUpCompleted { .. } => "WrapUpCompleted",
EventPayload::SearchReindexRequested => "SearchReindexRequested",
EventPayload::PosterSynced { .. } => "PosterSynced",
EventPayload::GoalCreated { .. } => "GoalCreated",
EventPayload::GoalUpdated { .. } => "GoalUpdated",
EventPayload::GoalDeleted { .. } => "GoalDeleted",
EventPayload::PersonEnrichmentRequested { .. } => "PersonEnrichmentRequested",
} }
} }
} }
fn parse_uuid(s: &str, field: &str) -> Result<Uuid, DomainError> { fn parse_uuid(s: &str, field: &str) -> Result<Uuid, DomainError> {
Uuid::parse_str(s) Uuid::parse_str(s).map_err(|e| DomainError::InfrastructureError(format!("{field}: {e}")))
.map_err(|e| DomainError::InfrastructureError(format!("{field}: {e}")))
} }
fn parse_ts(ts: i64) -> Result<NaiveDateTime, DomainError> { fn parse_ts(ts: i64) -> Result<NaiveDateTime, DomainError> {
@@ -77,31 +158,43 @@ fn parse_ts(ts: i64) -> Result<NaiveDateTime, DomainError> {
impl From<&DomainEvent> for EventPayload { impl From<&DomainEvent> for EventPayload {
fn from(event: &DomainEvent) -> Self { fn from(event: &DomainEvent) -> Self {
match event { match event {
DomainEvent::ReviewLogged { review_id, movie_id, user_id, rating, watched_at } => { DomainEvent::ReviewLogged {
EventPayload::ReviewLogged { review_id,
review_id: review_id.value().to_string(), movie_id,
movie_id: movie_id.value().to_string(), user_id,
user_id: user_id.value().to_string(), rating,
rating: rating.value(), watched_at,
watched_at: watched_at.and_utc().timestamp(), } => EventPayload::ReviewLogged {
} review_id: review_id.value().to_string(),
} movie_id: movie_id.value().to_string(),
DomainEvent::ReviewUpdated { review_id, movie_id, user_id, rating, watched_at } => { user_id: user_id.value().to_string(),
EventPayload::ReviewUpdated { rating: rating.value(),
review_id: review_id.value().to_string(), watched_at: watched_at.and_utc().timestamp(),
movie_id: movie_id.value().to_string(), },
user_id: user_id.value().to_string(), DomainEvent::ReviewUpdated {
rating: rating.value(), review_id,
watched_at: watched_at.and_utc().timestamp(), movie_id,
} user_id,
} rating,
DomainEvent::MovieDiscovered { movie_id, external_metadata_id } => { watched_at,
EventPayload::MovieDiscovered { } => EventPayload::ReviewUpdated {
movie_id: movie_id.value().to_string(), review_id: review_id.value().to_string(),
external_metadata_id: external_metadata_id.value().to_owned(), movie_id: movie_id.value().to_string(),
} user_id: user_id.value().to_string(),
} rating: rating.value(),
DomainEvent::MovieDeleted { movie_id, poster_path } => EventPayload::MovieDeleted { 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(), movie_id: movie_id.value().to_string(),
poster_path: poster_path.as_ref().map(|p| p.value().to_string()), poster_path: poster_path.as_ref().map(|p| p.value().to_string()),
}, },
@@ -112,13 +205,125 @@ impl From<&DomainEvent> for EventPayload {
review_id: review_id.value().to_string(), review_id: review_id.value().to_string(),
user_id: user_id.value().to_string(), user_id: user_id.value().to_string(),
}, },
DomainEvent::MovieEnrichmentRequested { movie_id, external_metadata_id } => { DomainEvent::MovieEnrichmentRequested {
EventPayload::MovieEnrichmentRequested { movie_id,
external_metadata_id,
} => EventPayload::MovieEnrichmentRequested {
movie_id: movie_id.value().to_string(),
external_metadata_id: external_metadata_id.value().to_string(),
},
DomainEvent::ImageStored { key } => EventPayload::ImageStored { key: key.clone() },
DomainEvent::WatchlistEntryAdded {
user_id,
movie_id,
movie_title,
release_year,
external_metadata_id,
added_at,
} => EventPayload::WatchlistEntryAdded {
user_id: user_id.value().to_string(),
movie_id: movie_id.value().to_string(),
movie_title: movie_title.clone(),
release_year: *release_year,
external_metadata_id: external_metadata_id.clone(),
added_at: added_at.and_utc().timestamp(),
},
DomainEvent::WatchlistEntryRemoved { user_id, movie_id } => {
EventPayload::WatchlistEntryRemoved {
user_id: user_id.value().to_string(),
movie_id: movie_id.value().to_string(), movie_id: movie_id.value().to_string(),
external_metadata_id: external_metadata_id.clone(),
} }
} }
DomainEvent::ImageStored { key } => EventPayload::ImageStored { key: key.clone() }, DomainEvent::FollowAccepted {
local_user_id,
remote_actor_url,
outbox_url,
} => EventPayload::FollowAccepted {
local_user_id: local_user_id.value().to_string(),
remote_actor_url: remote_actor_url.clone(),
outbox_url: outbox_url.clone(),
},
DomainEvent::BackfillFollower {
owner_user_id,
follower_inbox_url,
} => EventPayload::BackfillFollower {
owner_user_id: owner_user_id.value().to_string(),
follower_inbox_url: follower_inbox_url.clone(),
},
DomainEvent::FederationDeliveryRequested {
inbox_url,
activity_json,
signing_actor_id,
} => EventPayload::FederationDeliveryRequested {
inbox_url: inbox_url.clone(),
activity_json: activity_json.clone(),
signing_actor_id: signing_actor_id.to_string(),
},
DomainEvent::WatchEventIngested {
user_id,
title,
source,
} => EventPayload::WatchEventIngested {
user_id: user_id.value().to_string(),
title: title.clone(),
source: source.clone(),
},
DomainEvent::WrapUpRequested {
wrapup_id,
user_id,
start_date,
end_date,
} => EventPayload::WrapUpRequested {
wrapup_id: wrapup_id.value().to_string(),
user_id: user_id.as_ref().map(|u| u.value().to_string()),
start_date: start_date.to_string(),
end_date: end_date.to_string(),
},
DomainEvent::WrapUpCompleted { wrapup_id } => EventPayload::WrapUpCompleted {
wrapup_id: wrapup_id.value().to_string(),
},
DomainEvent::SearchReindexRequested => EventPayload::SearchReindexRequested,
DomainEvent::PosterSynced { movie_id } => EventPayload::PosterSynced {
movie_id: movie_id.value().to_string(),
},
DomainEvent::GoalCreated {
goal_id,
user_id,
year,
target_count,
} => EventPayload::GoalCreated {
goal_id: goal_id.value().to_string(),
user_id: user_id.value().to_string(),
year: *year,
target_count: *target_count,
},
DomainEvent::GoalUpdated {
goal_id,
user_id,
year,
target_count,
} => EventPayload::GoalUpdated {
goal_id: goal_id.value().to_string(),
user_id: user_id.value().to_string(),
year: *year,
target_count: *target_count,
},
DomainEvent::GoalDeleted {
goal_id,
user_id,
year,
} => EventPayload::GoalDeleted {
goal_id: goal_id.value().to_string(),
user_id: user_id.value().to_string(),
year: *year,
},
DomainEvent::PersonEnrichmentRequested {
person_id,
external_person_id,
} => EventPayload::PersonEnrichmentRequested {
person_id: person_id.value().to_string(),
external_person_id: external_person_id.value().to_string(),
},
} }
} }
} }
@@ -127,58 +332,191 @@ impl TryFrom<EventPayload> for DomainEvent {
type Error = DomainError; type Error = DomainError;
fn try_from(payload: EventPayload) -> Result<Self, DomainError> { fn try_from(payload: EventPayload) -> Result<Self, DomainError> {
match payload { match payload {
EventPayload::ReviewLogged { review_id, movie_id, user_id, rating, watched_at } => { EventPayload::ReviewLogged {
Ok(DomainEvent::ReviewLogged { review_id,
review_id: ReviewId::from_uuid(parse_uuid(&review_id, "review_id")?), movie_id,
movie_id: MovieId::from_uuid(parse_uuid(&movie_id, "movie_id")?), user_id,
user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?), rating,
rating: Rating::new(rating)?, watched_at,
watched_at: parse_ts(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")?),
EventPayload::ReviewUpdated { review_id, movie_id, user_id, rating, watched_at } => { user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?),
Ok(DomainEvent::ReviewUpdated { rating: Rating::new(rating)?,
review_id: ReviewId::from_uuid(parse_uuid(&review_id, "review_id")?), watched_at: parse_ts(watched_at)?,
movie_id: MovieId::from_uuid(parse_uuid(&movie_id, "movie_id")?), }),
user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?), EventPayload::ReviewUpdated {
rating: Rating::new(rating)?, review_id,
watched_at: parse_ts(watched_at)?, movie_id,
}) user_id,
} rating,
EventPayload::MovieDiscovered { movie_id, external_metadata_id } => { watched_at,
Ok(DomainEvent::MovieDiscovered { } => Ok(DomainEvent::ReviewUpdated {
movie_id: MovieId::from_uuid(parse_uuid(&movie_id, "movie_id")?), review_id: ReviewId::from_uuid(parse_uuid(&review_id, "review_id")?),
external_metadata_id: ExternalMetadataId::new(external_metadata_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)?,
EventPayload::MovieDeleted { movie_id, poster_path } => { 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 movie_id = MovieId::from_uuid(parse_uuid(&movie_id, "movie_id")?);
let poster_path = poster_path let poster_path = poster_path
.map(|p| PosterPath::new(p)) .map(PosterPath::new)
.transpose() .transpose()
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?; .map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
Ok(DomainEvent::MovieDeleted { movie_id, poster_path }) Ok(DomainEvent::MovieDeleted {
} movie_id,
EventPayload::UserUpdated { user_id } => { poster_path,
Ok(DomainEvent::UserUpdated {
user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?),
}) })
} }
EventPayload::ReviewDeleted { review_id, user_id } => { EventPayload::UserUpdated { user_id } => Ok(DomainEvent::UserUpdated {
Ok(DomainEvent::ReviewDeleted { user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?),
review_id: ReviewId::from_uuid(parse_uuid(&review_id, "review_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")?),
}),
EventPayload::MovieEnrichmentRequested {
movie_id,
external_metadata_id,
} => Ok(DomainEvent::MovieEnrichmentRequested {
movie_id: MovieId::from_uuid(parse_uuid(&movie_id, "movie_id")?),
external_metadata_id: ExternalMetadataId::new(external_metadata_id)
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?,
}),
EventPayload::ImageStored { key } => Ok(DomainEvent::ImageStored { key }),
EventPayload::WatchlistEntryAdded {
user_id,
movie_id,
movie_title,
release_year,
external_metadata_id,
added_at,
} => Ok(DomainEvent::WatchlistEntryAdded {
user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?),
movie_id: MovieId::from_uuid(parse_uuid(&movie_id, "movie_id")?),
movie_title,
release_year,
external_metadata_id,
added_at: parse_ts(added_at)?,
}),
EventPayload::WatchlistEntryRemoved { user_id, movie_id } => {
Ok(DomainEvent::WatchlistEntryRemoved {
user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?), user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?),
})
}
EventPayload::MovieEnrichmentRequested { movie_id, external_metadata_id } => {
Ok(DomainEvent::MovieEnrichmentRequested {
movie_id: MovieId::from_uuid(parse_uuid(&movie_id, "movie_id")?), movie_id: MovieId::from_uuid(parse_uuid(&movie_id, "movie_id")?),
external_metadata_id,
}) })
} }
EventPayload::ImageStored { key } => { EventPayload::FollowAccepted {
Ok(DomainEvent::ImageStored { key }) local_user_id,
remote_actor_url,
outbox_url,
} => Ok(DomainEvent::FollowAccepted {
local_user_id: UserId::from_uuid(parse_uuid(&local_user_id, "local_user_id")?),
remote_actor_url,
outbox_url,
}),
EventPayload::BackfillFollower {
owner_user_id,
follower_inbox_url,
} => Ok(DomainEvent::BackfillFollower {
owner_user_id: UserId::from_uuid(parse_uuid(&owner_user_id, "owner_user_id")?),
follower_inbox_url,
}),
EventPayload::FederationDeliveryRequested {
inbox_url,
activity_json,
signing_actor_id,
} => Ok(DomainEvent::FederationDeliveryRequested {
inbox_url,
activity_json,
signing_actor_id: parse_uuid(&signing_actor_id, "signing_actor_id")?,
}),
EventPayload::WatchEventIngested {
user_id,
title,
source,
} => Ok(DomainEvent::WatchEventIngested {
user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?),
title,
source,
}),
EventPayload::WrapUpRequested {
wrapup_id,
user_id,
start_date,
end_date,
} => {
let wid = parse_uuid(&wrapup_id, "wrapup_id")?;
let uid = user_id.map(|s| parse_uuid(&s, "user_id")).transpose()?;
let sd = chrono::NaiveDate::parse_from_str(&start_date, "%Y-%m-%d")
.map_err(|e| DomainError::ValidationError(e.to_string()))?;
let ed = chrono::NaiveDate::parse_from_str(&end_date, "%Y-%m-%d")
.map_err(|e| DomainError::ValidationError(e.to_string()))?;
Ok(DomainEvent::WrapUpRequested {
wrapup_id: WrapUpId::from_uuid(wid),
user_id: uid.map(UserId::from_uuid),
start_date: sd,
end_date: ed,
})
} }
EventPayload::WrapUpCompleted { wrapup_id } => {
let wid = parse_uuid(&wrapup_id, "wrapup_id")?;
Ok(DomainEvent::WrapUpCompleted {
wrapup_id: WrapUpId::from_uuid(wid),
})
}
EventPayload::SearchReindexRequested => Ok(DomainEvent::SearchReindexRequested),
EventPayload::PosterSynced { movie_id } => Ok(DomainEvent::PosterSynced {
movie_id: MovieId::from_uuid(parse_uuid(&movie_id, "movie_id")?),
}),
EventPayload::GoalCreated {
goal_id,
user_id,
year,
target_count,
} => Ok(DomainEvent::GoalCreated {
goal_id: GoalId::from_uuid(parse_uuid(&goal_id, "goal_id")?),
user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?),
year,
target_count,
}),
EventPayload::GoalUpdated {
goal_id,
user_id,
year,
target_count,
} => Ok(DomainEvent::GoalUpdated {
goal_id: GoalId::from_uuid(parse_uuid(&goal_id, "goal_id")?),
user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?),
year,
target_count,
}),
EventPayload::GoalDeleted {
goal_id,
user_id,
year,
} => Ok(DomainEvent::GoalDeleted {
goal_id: GoalId::from_uuid(parse_uuid(&goal_id, "goal_id")?),
user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?),
year,
}),
EventPayload::PersonEnrichmentRequested {
person_id,
external_person_id,
} => Ok(DomainEvent::PersonEnrichmentRequested {
person_id: PersonId::from_uuid(parse_uuid(&person_id, "person_id")?),
external_person_id: ExternalPersonId::new(external_person_id),
}),
} }
} }
} }

View File

@@ -1,7 +1,9 @@
use super::*; use super::*;
fn fixed_dt() -> NaiveDateTime { fn fixed_dt() -> NaiveDateTime {
chrono::DateTime::from_timestamp(1_700_000_000, 0).unwrap().naive_utc() chrono::DateTime::from_timestamp(1_700_000_000, 0)
.unwrap()
.naive_utc()
} }
fn review_logged() -> DomainEvent { fn review_logged() -> DomainEvent {
@@ -64,14 +66,25 @@ fn serialized_format_is_tagged() {
#[test] #[test]
fn event_type_strings() { fn event_type_strings() {
assert_eq!(EventPayload::from(&review_logged()).event_type(), "ReviewLogged"); assert_eq!(
assert_eq!(EventPayload::from(&review_updated()).event_type(), "ReviewUpdated"); EventPayload::from(&review_logged()).event_type(),
assert_eq!(EventPayload::from(&movie_discovered()).event_type(), "MovieDiscovered"); "ReviewLogged"
);
assert_eq!(
EventPayload::from(&review_updated()).event_type(),
"ReviewUpdated"
);
assert_eq!(
EventPayload::from(&movie_discovered()).event_type(),
"MovieDiscovered"
);
} }
#[test] #[test]
fn round_trip_image_stored() { fn round_trip_image_stored() {
let event = DomainEvent::ImageStored { key: "avatars/abc123".into() }; let event = DomainEvent::ImageStored {
key: "avatars/abc123".into(),
};
let payload = EventPayload::from(&event); let payload = EventPayload::from(&event);
let json = serde_json::to_string(&payload).unwrap(); let json = serde_json::to_string(&payload).unwrap();
let back: EventPayload = serde_json::from_str(&json).unwrap(); let back: EventPayload = serde_json::from_str(&json).unwrap();
@@ -81,6 +94,8 @@ fn round_trip_image_stored() {
#[test] #[test]
fn image_stored_event_type() { fn image_stored_event_type() {
let payload = EventPayload::from(&DomainEvent::ImageStored { key: "posters/x".into() }); let payload = EventPayload::from(&DomainEvent::ImageStored {
key: "posters/x".into(),
});
assert_eq!(payload.event_type(), "ImageStored"); assert_eq!(payload.event_type(), "ImageStored");
} }

View File

@@ -43,8 +43,12 @@ struct NoopAck;
#[async_trait] #[async_trait]
impl AckHandle for NoopAck { impl AckHandle for NoopAck {
async fn ack(&self) -> Result<(), DomainError> { Ok(()) } async fn ack(&self) -> Result<(), DomainError> {
async fn nack(&self) -> Result<(), DomainError> { Ok(()) } Ok(())
}
async fn nack(&self) -> Result<(), DomainError> {
Ok(())
}
} }
pub struct ChannelEventConsumer { pub struct ChannelEventConsumer {

View File

@@ -22,7 +22,10 @@ async fn consumer_yields_published_events() {
let mut stream = consumer.consume(); let mut stream = consumer.consume();
let envelope = stream.next().await.unwrap().unwrap(); let envelope = stream.next().await.unwrap().unwrap();
assert!(matches!(envelope.event, DomainEvent::MovieDiscovered { .. })); assert!(matches!(
envelope.event,
DomainEvent::MovieDiscovered { .. }
));
assert!(stream.next().await.is_none()); assert!(stream.next().await.is_none());
} }

View File

@@ -8,6 +8,9 @@ domain = { workspace = true }
async-trait = { workspace = true } async-trait = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }
chrono = { workspace = true } chrono = { workspace = true }
futures = { workspace = true }
bytes = { workspace = true }
async-stream = { workspace = true }
[dev-dependencies] [dev-dependencies]
uuid = { workspace = true } uuid = { workspace = true }

View File

@@ -1,51 +1,89 @@
use async_trait::async_trait; use bytes::Bytes;
use domain::{ use domain::{
errors::DomainError, errors::DomainError,
models::{DiaryEntry, ExportFormat}, models::{DiaryEntry, ExportFormat},
ports::DiaryExporter, ports::DiaryExporter,
}; };
use futures::stream::BoxStream;
pub struct ExportAdapter; pub struct ExportAdapter;
#[async_trait]
impl DiaryExporter for ExportAdapter { impl DiaryExporter for ExportAdapter {
async fn serialize_entries( fn stream_entries(
&self, &self,
entries: &[DiaryEntry], stream: BoxStream<'static, Result<DiaryEntry, DomainError>>,
format: ExportFormat, format: ExportFormat,
) -> Result<Vec<u8>, DomainError> { ) -> BoxStream<'static, Result<Bytes, DomainError>> {
match format { match format {
ExportFormat::Csv => serialize_csv(entries), ExportFormat::Csv => stream_csv(stream),
ExportFormat::Json => serialize_json(entries), ExportFormat::Json => stream_json(stream),
} }
} }
} }
fn serialize_csv(entries: &[DiaryEntry]) -> Result<Vec<u8>, DomainError> { fn stream_csv(
let mut out = entries: BoxStream<'static, Result<DiaryEntry, DomainError>>,
String::from("title,year,director,rating,comment,watched_at,external_metadata_id\n"); ) -> BoxStream<'static, Result<Bytes, DomainError>> {
for e in entries { use futures::StreamExt;
let title = csv_escape(e.movie().title().value()); let header = futures::stream::once(async {
let year = e.movie().release_year().value(); Ok(Bytes::from_static(
let director = e.movie().director().map(csv_escape).unwrap_or_default(); b"title,year,director,rating,comment,watched_at,external_metadata_id\n",
let rating = e.review().rating().value(); ))
let comment = e });
.review() let rows = entries.map(|r| r.map(|e| Bytes::from(csv_row(&e))));
.comment() Box::pin(header.chain(rows))
.map(|c| csv_escape(c.value())) }
.unwrap_or_default();
let watched_at = e.review().watched_at().format("%Y-%m-%d"); fn stream_json(
let ext_id = e stream: BoxStream<'static, Result<DiaryEntry, DomainError>>,
.movie() ) -> BoxStream<'static, Result<Bytes, DomainError>> {
.external_metadata_id() Box::pin(async_stream::stream! {
.map(|id| id.value().to_string()) futures::pin_mut!(stream);
.unwrap_or_default(); let mut is_first = true;
out.push_str(&format!( while let Some(r) = futures::StreamExt::next(&mut stream).await {
"{},{},{},{},{},{},{}\n", match r {
title, year, director, rating, comment, watched_at, ext_id Err(e) => { yield Err(e); return; }
)); Ok(entry) => {
} let json = serde_json::to_string(&entry_to_json(&entry))
Ok(out.into_bytes()) .map_err(|e| DomainError::InfrastructureError(e.to_string()));
let json = match json {
Ok(s) => s,
Err(e) => { yield Err(e); return; }
};
let prefix = if is_first { "[" } else { "," };
is_first = false;
yield Ok(Bytes::from(format!("{}{}", prefix, json)));
}
}
}
if is_first {
yield Ok(Bytes::from_static(b"[]"));
} else {
yield Ok(Bytes::from_static(b"]"));
}
})
}
fn csv_row(e: &DiaryEntry) -> String {
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();
format!(
"{},{},{},{},{},{},{}\n",
title, year, director, rating, comment, watched_at, ext_id
)
} }
fn csv_escape(s: &str) -> String { fn csv_escape(s: &str) -> String {
@@ -56,22 +94,16 @@ fn csv_escape(s: &str) -> String {
} }
} }
fn serialize_json(entries: &[DiaryEntry]) -> Result<Vec<u8>, DomainError> { fn entry_to_json(e: &DiaryEntry) -> serde_json::Value {
let arr: Vec<serde_json::Value> = entries serde_json::json!({
.iter() "title": e.movie().title().value(),
.map(|e| { "year": e.movie().release_year().value(),
serde_json::json!({ "director": e.movie().director(),
"title": e.movie().title().value(), "rating": e.review().rating().value(),
"year": e.movie().release_year().value(), "comment": e.review().comment().map(|c| c.value().to_string()),
"director": e.movie().director(), "watched_at": e.review().watched_at().format("%Y-%m-%d").to_string(),
"rating": e.review().rating().value(), "external_metadata_id": e.movie().external_metadata_id().map(|id| id.value().to_string()),
"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)] #[cfg(test)]

View File

@@ -5,6 +5,27 @@ use domain::{
value_objects::{ExternalMetadataId, MovieTitle, Rating, ReleaseYear}, value_objects::{ExternalMetadataId, MovieTitle, Rating, ReleaseYear},
}; };
async fn collect_stream(
stream: futures::stream::BoxStream<'static, Result<bytes::Bytes, domain::errors::DomainError>>,
) -> Vec<u8> {
use futures::StreamExt;
let mut out = Vec::new();
futures::pin_mut!(stream);
while let Some(chunk) = stream.next().await {
out.extend_from_slice(&chunk.unwrap());
}
out
}
fn entry_stream(
entries: Vec<domain::models::DiaryEntry>,
) -> futures::stream::BoxStream<
'static,
Result<domain::models::DiaryEntry, domain::errors::DomainError>,
> {
Box::pin(futures::stream::iter(entries.into_iter().map(Ok)))
}
fn make_entry( fn make_entry(
title: &str, title: &str,
year: u16, year: u16,
@@ -55,15 +76,11 @@ async fn csv_has_header_and_one_row() {
5, 5,
Some("great"), Some("great"),
); );
let bytes = adapter let bytes =
.serialize_entries(&[entry], ExportFormat::Csv) collect_stream(adapter.stream_entries(entry_stream(vec![entry]), ExportFormat::Csv)).await;
.await
.unwrap();
let text = String::from_utf8(bytes).unwrap(); let text = String::from_utf8(bytes).unwrap();
assert!( assert!(
text.starts_with( text.starts_with("title,year,director,rating,comment,watched_at,external_metadata_id\n")
"title,year,director,rating,comment,watched_at,external_metadata_id\n"
)
); );
assert!(text.contains("Inception")); assert!(text.contains("Inception"));
assert!(text.contains("2010")); assert!(text.contains("2010"));
@@ -77,10 +94,8 @@ async fn csv_has_header_and_one_row() {
async fn csv_escapes_commas_in_title() { async fn csv_escapes_commas_in_title() {
let adapter = ExportAdapter; let adapter = ExportAdapter;
let entry = make_entry("Tár, A Film", 2022, None, 4, None); let entry = make_entry("Tár, A Film", 2022, None, 4, None);
let bytes = adapter let bytes =
.serialize_entries(&[entry], ExportFormat::Csv) collect_stream(adapter.stream_entries(entry_stream(vec![entry]), ExportFormat::Csv)).await;
.await
.unwrap();
let text = String::from_utf8(bytes).unwrap(); let text = String::from_utf8(bytes).unwrap();
assert!(text.contains("\"Tár, A Film\"")); assert!(text.contains("\"Tár, A Film\""));
} }
@@ -89,10 +104,8 @@ async fn csv_escapes_commas_in_title() {
async fn json_is_valid_array() { async fn json_is_valid_array() {
let adapter = ExportAdapter; let adapter = ExportAdapter;
let entry = make_entry("Dune", 2021, Some("Denis Villeneuve"), 5, None); let entry = make_entry("Dune", 2021, Some("Denis Villeneuve"), 5, None);
let bytes = adapter let bytes =
.serialize_entries(&[entry], ExportFormat::Json) collect_stream(adapter.stream_entries(entry_stream(vec![entry]), ExportFormat::Json)).await;
.await
.unwrap();
let arr: Vec<serde_json::Value> = serde_json::from_slice(&bytes).unwrap(); let arr: Vec<serde_json::Value> = serde_json::from_slice(&bytes).unwrap();
assert_eq!(arr.len(), 1); assert_eq!(arr.len(), 1);
assert_eq!(arr[0]["title"], "Dune"); assert_eq!(arr[0]["title"], "Dune");
@@ -106,27 +119,23 @@ async fn json_is_valid_array() {
async fn external_metadata_id_included_when_present() { async fn external_metadata_id_included_when_present() {
let adapter = ExportAdapter; let adapter = ExportAdapter;
let entry = make_entry_full("Alien", 1979, None, 5, None, Some("tt0078748")); let entry = make_entry_full("Alien", 1979, None, 5, None, Some("tt0078748"));
let bytes = adapter let bytes =
.serialize_entries(&[entry], ExportFormat::Json) collect_stream(adapter.stream_entries(entry_stream(vec![entry]), ExportFormat::Json)).await;
.await
.unwrap();
let arr: Vec<serde_json::Value> = serde_json::from_slice(&bytes).unwrap(); let arr: Vec<serde_json::Value> = serde_json::from_slice(&bytes).unwrap();
assert_eq!(arr[0]["external_metadata_id"], "tt0078748"); assert_eq!(arr[0]["external_metadata_id"], "tt0078748");
let bytes = adapter let bytes = collect_stream(adapter.stream_entries(
.serialize_entries( entry_stream(vec![make_entry_full(
&[make_entry_full( "Alien",
"Alien", 1979,
1979, None,
None, 5,
5, None,
None, Some("tt0078748"),
Some("tt0078748"), )]),
)], ExportFormat::Csv,
ExportFormat::Csv, ))
) .await;
.await
.unwrap();
let text = String::from_utf8(bytes).unwrap(); let text = String::from_utf8(bytes).unwrap();
assert!(text.contains("tt0078748")); assert!(text.contains("tt0078748"));
} }
@@ -134,13 +143,20 @@ async fn external_metadata_id_included_when_present() {
#[tokio::test] #[tokio::test]
async fn empty_entries_returns_csv_header_only() { async fn empty_entries_returns_csv_header_only() {
let adapter = ExportAdapter; let adapter = ExportAdapter;
let bytes = adapter let bytes =
.serialize_entries(&[], ExportFormat::Csv) collect_stream(adapter.stream_entries(entry_stream(vec![]), ExportFormat::Csv)).await;
.await
.unwrap();
let text = String::from_utf8(bytes).unwrap(); let text = String::from_utf8(bytes).unwrap();
assert_eq!( assert_eq!(
text, text,
"title,year,director,rating,comment,watched_at,external_metadata_id\n" "title,year,director,rating,comment,watched_at,external_metadata_id\n"
); );
} }
#[tokio::test]
async fn empty_json_is_valid_empty_array() {
let adapter = ExportAdapter;
let bytes =
collect_stream(adapter.stream_entries(entry_stream(vec![]), ExportFormat::Json)).await;
let arr: Vec<serde_json::Value> = serde_json::from_slice(&bytes).unwrap();
assert!(arr.is_empty());
}

View File

@@ -14,6 +14,6 @@ ravif = { version = "0.11", default-features = false }
webp = "0.3" webp = "0.3"
[dev-dependencies] [dev-dependencies]
image-storage = { workspace = true } object-storage = { workspace = true }
object_store = "0.11" object_store = "0.11"
uuid = { workspace = true } uuid = { workspace = true }

View File

@@ -17,7 +17,10 @@ impl ConversionBackfillJob {
image_ref: Arc<dyn ImageRefQuery>, image_ref: Arc<dyn ImageRefQuery>,
event_publisher: Arc<dyn EventPublisher>, event_publisher: Arc<dyn EventPublisher>,
) -> Self { ) -> Self {
Self { image_ref, event_publisher } Self {
image_ref,
event_publisher,
}
} }
} }
@@ -34,7 +37,8 @@ impl PeriodicJob for ConversionBackfillJob {
if key.ends_with(".avif") || key.ends_with(".webp") { if key.ends_with(".avif") || key.ends_with(".webp") {
continue; continue;
} }
if let Err(e) = self.event_publisher if let Err(e) = self
.event_publisher
.publish(&DomainEvent::ImageStored { key: key.clone() }) .publish(&DomainEvent::ImageStored { key: key.clone() })
.await .await
{ {

View File

@@ -4,24 +4,28 @@ use async_trait::async_trait;
use domain::{ use domain::{
errors::DomainError, errors::DomainError,
events::DomainEvent, events::DomainEvent,
ports::{EventHandler, ImageRefCommand, ImageStorage}, ports::{EventHandler, ImageRefCommand, ObjectStorage},
}; };
use crate::Format; use crate::Format;
pub struct ImageConversionHandler { pub struct ImageConversionHandler {
storage: Arc<dyn ImageStorage>, storage: Arc<dyn ObjectStorage>,
image_ref: Arc<dyn ImageRefCommand>, image_ref: Arc<dyn ImageRefCommand>,
format: Format, format: Format,
} }
impl ImageConversionHandler { impl ImageConversionHandler {
pub fn new( pub fn new(
storage: Arc<dyn ImageStorage>, storage: Arc<dyn ObjectStorage>,
image_ref: Arc<dyn ImageRefCommand>, image_ref: Arc<dyn ImageRefCommand>,
format: Format, format: Format,
) -> Self { ) -> Self {
Self { storage, image_ref, format } Self {
storage,
image_ref,
format,
}
} }
} }
@@ -43,7 +47,7 @@ impl EventHandler for ImageConversionHandler {
let converted = tokio::task::spawn_blocking(move || convert(bytes, format)) let converted = tokio::task::spawn_blocking(move || convert(bytes, format))
.await .await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))? .map_err(|e| DomainError::InfrastructureError(e.to_string()))?
.map_err(|e| DomainError::InfrastructureError(e))?; .map_err(DomainError::InfrastructureError)?;
let ext = format.extension(); let ext = format.extension();
let new_key = format!("{key}{ext}"); let new_key = format!("{key}{ext}");
@@ -73,7 +77,12 @@ fn convert(bytes: Vec<u8>, format: Format) -> Result<Vec<u8>, String> {
let height = rgba.height() as usize; let height = rgba.height() as usize;
let pixels: Vec<ravif::RGBA8> = rgba let pixels: Vec<ravif::RGBA8> = rgba
.pixels() .pixels()
.map(|p| ravif::RGBA8 { r: p.0[0], g: p.0[1], b: p.0[2], a: p.0[3] }) .map(|p| ravif::RGBA8 {
r: p.0[0],
g: p.0[1],
b: p.0[2],
a: p.0[3],
})
.collect(); .collect();
let result = ravif::Encoder::new() let result = ravif::Encoder::new()
.with_quality(80.0) .with_quality(80.0)

View File

@@ -6,15 +6,19 @@ pub use backfill::ConversionBackfillJob;
pub use config::{ConversionConfig, Format}; pub use config::{ConversionConfig, Format};
pub use handler::ImageConversionHandler; pub use handler::ImageConversionHandler;
use domain::ports::{
EventHandler, EventPublisher, ImageRefCommand, ImageRefQuery, ObjectStorage, PeriodicJob,
};
use std::sync::Arc; use std::sync::Arc;
use domain::ports::{EventHandler, EventPublisher, ImageRefCommand, ImageRefQuery, ImageStorage, PeriodicJob};
type ConversionPair = (Arc<dyn EventHandler>, Arc<dyn PeriodicJob>);
pub fn build( pub fn build(
image_storage: Arc<dyn ImageStorage>, object_storage: Arc<dyn ObjectStorage>,
image_ref_command: Arc<dyn ImageRefCommand>, image_ref_command: Arc<dyn ImageRefCommand>,
image_ref_query: Arc<dyn ImageRefQuery>, image_ref_query: Arc<dyn ImageRefQuery>,
event_publisher: Arc<dyn EventPublisher>, event_publisher: Arc<dyn EventPublisher>,
) -> anyhow::Result<Option<(Arc<dyn EventHandler>, Arc<dyn PeriodicJob>)>> { ) -> anyhow::Result<Option<ConversionPair>> {
let config = match ConversionConfig::from_env()? { let config = match ConversionConfig::from_env()? {
Some(c) => c, Some(c) => c,
None => return Ok(None), None => return Ok(None),
@@ -23,7 +27,7 @@ pub fn build(
let format = config.format; let format = config.format;
let handler = Arc::new(ImageConversionHandler::new( let handler = Arc::new(ImageConversionHandler::new(
Arc::clone(&image_storage), Arc::clone(&object_storage),
image_ref_command, image_ref_command,
format, format,
)) as Arc<dyn EventHandler>; )) as Arc<dyn EventHandler>;

View File

@@ -18,7 +18,9 @@ struct MockPublisher {
impl MockPublisher { impl MockPublisher {
fn new() -> Arc<Self> { fn new() -> Arc<Self> {
Arc::new(Self { emitted: Mutex::new(vec![]) }) Arc::new(Self {
emitted: Mutex::new(vec![]),
})
} }
fn emitted(&self) -> Vec<String> { fn emitted(&self) -> Vec<String> {
@@ -42,10 +44,8 @@ async fn emits_image_stored_for_unconverted_keys() {
keys: vec!["avatars/u1".into(), "posters/m1".into()], keys: vec!["avatars/u1".into(), "posters/m1".into()],
}); });
let publisher = MockPublisher::new(); let publisher = MockPublisher::new();
let job = ConversionBackfillJob::new( let job =
image_ref, ConversionBackfillJob::new(image_ref, Arc::clone(&publisher) as Arc<dyn EventPublisher>);
Arc::clone(&publisher) as Arc<dyn EventPublisher>,
);
job.run().await.unwrap(); job.run().await.unwrap();
@@ -64,10 +64,8 @@ async fn skips_already_converted_keys() {
], ],
}); });
let publisher = MockPublisher::new(); let publisher = MockPublisher::new();
let job = ConversionBackfillJob::new( let job =
image_ref, ConversionBackfillJob::new(image_ref, Arc::clone(&publisher) as Arc<dyn EventPublisher>);
Arc::clone(&publisher) as Arc<dyn EventPublisher>,
);
job.run().await.unwrap(); job.run().await.unwrap();
@@ -78,10 +76,8 @@ async fn skips_already_converted_keys() {
async fn empty_keys_emits_nothing() { async fn empty_keys_emits_nothing() {
let image_ref = Arc::new(MockImageRef { keys: vec![] }); let image_ref = Arc::new(MockImageRef { keys: vec![] });
let publisher = MockPublisher::new(); let publisher = MockPublisher::new();
let job = ConversionBackfillJob::new( let job =
image_ref, ConversionBackfillJob::new(image_ref, Arc::clone(&publisher) as Arc<dyn EventPublisher>);
Arc::clone(&publisher) as Arc<dyn EventPublisher>,
);
job.run().await.unwrap(); job.run().await.unwrap();

View File

@@ -3,18 +3,24 @@ use super::*;
#[test] #[test]
fn disabled_by_default() { fn disabled_by_default() {
assert!(ConversionConfig::from_vars(None, None).unwrap().is_none()); assert!(ConversionConfig::from_vars(None, None).unwrap().is_none());
assert!(ConversionConfig::from_vars(Some("false"), None).unwrap().is_none()); assert!(ConversionConfig::from_vars(Some("false"), None)
.unwrap()
.is_none());
} }
#[test] #[test]
fn enabled_avif() { fn enabled_avif() {
let cfg = ConversionConfig::from_vars(Some("true"), Some("avif")).unwrap().unwrap(); let cfg = ConversionConfig::from_vars(Some("true"), Some("avif"))
.unwrap()
.unwrap();
assert_eq!(cfg.format, Format::Avif); assert_eq!(cfg.format, Format::Avif);
} }
#[test] #[test]
fn enabled_webp() { fn enabled_webp() {
let cfg = ConversionConfig::from_vars(Some("true"), Some("webp")).unwrap().unwrap(); let cfg = ConversionConfig::from_vars(Some("true"), Some("webp"))
.unwrap()
.unwrap();
assert_eq!(cfg.format, Format::Webp); assert_eq!(cfg.format, Format::Webp);
} }

View File

@@ -1,7 +1,7 @@
use super::*; use super::*;
use std::sync::Mutex; use object_storage::ObjectStorageAdapter;
use object_store::memory::InMemory; use object_store::memory::InMemory;
use image_storage::ImageStorageAdapter; use std::sync::Mutex;
struct MockImageRef { struct MockImageRef {
swaps: Mutex<Vec<(String, String)>>, swaps: Mutex<Vec<(String, String)>>,
@@ -9,7 +9,9 @@ struct MockImageRef {
impl MockImageRef { impl MockImageRef {
fn new() -> Arc<Self> { fn new() -> Arc<Self> {
Arc::new(Self { swaps: Mutex::new(vec![]) }) Arc::new(Self {
swaps: Mutex::new(vec![]),
})
} }
fn swaps(&self) -> Vec<(String, String)> { fn swaps(&self) -> Vec<(String, String)> {
@@ -25,15 +27,13 @@ impl ImageRefCommand for MockImageRef {
} }
} }
fn in_memory_storage() -> Arc<ImageStorageAdapter> { fn in_memory_storage() -> Arc<ObjectStorageAdapter> {
Arc::new(ImageStorageAdapter::new(Arc::new(InMemory::new()))) Arc::new(ObjectStorageAdapter::new(Arc::new(InMemory::new())))
} }
fn tiny_jpeg() -> Vec<u8> { fn tiny_jpeg() -> Vec<u8> {
use image::{DynamicImage, ImageBuffer, Rgb}; use image::{DynamicImage, ImageBuffer, Rgb};
let img = DynamicImage::ImageRgb8( let img = DynamicImage::ImageRgb8(ImageBuffer::from_pixel(4, 4, Rgb([200u8, 100, 50])));
ImageBuffer::from_pixel(4, 4, Rgb([200u8, 100, 50])),
);
let mut buf = std::io::Cursor::new(Vec::new()); let mut buf = std::io::Cursor::new(Vec::new());
img.write_to(&mut buf, image::ImageFormat::Jpeg).unwrap(); img.write_to(&mut buf, image::ImageFormat::Jpeg).unwrap();
buf.into_inner() buf.into_inner()
@@ -44,14 +44,17 @@ async fn ignores_non_image_stored_events() {
let storage = in_memory_storage(); let storage = in_memory_storage();
let image_ref = MockImageRef::new(); let image_ref = MockImageRef::new();
let handler = ImageConversionHandler::new( let handler = ImageConversionHandler::new(
Arc::clone(&storage) as Arc<dyn ImageStorage>, Arc::clone(&storage) as Arc<dyn ObjectStorage>,
Arc::clone(&image_ref) as Arc<dyn ImageRefCommand>, Arc::clone(&image_ref) as Arc<dyn ImageRefCommand>,
Format::Avif, Format::Avif,
); );
handler.handle(&DomainEvent::UserUpdated { handler
user_id: domain::value_objects::UserId::from_uuid(uuid::Uuid::new_v4()), .handle(&DomainEvent::UserUpdated {
}).await.unwrap(); user_id: domain::value_objects::UserId::from_uuid(uuid::Uuid::new_v4()),
})
.await
.unwrap();
assert!(image_ref.swaps().is_empty()); assert!(image_ref.swaps().is_empty());
} }
@@ -59,15 +62,23 @@ async fn ignores_non_image_stored_events() {
#[tokio::test] #[tokio::test]
async fn skips_already_converted_avif_key() { async fn skips_already_converted_avif_key() {
let storage = in_memory_storage(); let storage = in_memory_storage();
storage.store("avatars/u1.avif", &tiny_jpeg()).await.unwrap(); storage
.store("avatars/u1.avif", &tiny_jpeg())
.await
.unwrap();
let image_ref = MockImageRef::new(); let image_ref = MockImageRef::new();
let handler = ImageConversionHandler::new( let handler = ImageConversionHandler::new(
Arc::clone(&storage) as Arc<dyn ImageStorage>, Arc::clone(&storage) as Arc<dyn ObjectStorage>,
Arc::clone(&image_ref) as Arc<dyn ImageRefCommand>, Arc::clone(&image_ref) as Arc<dyn ImageRefCommand>,
Format::Avif, Format::Avif,
); );
handler.handle(&DomainEvent::ImageStored { key: "avatars/u1.avif".into() }).await.unwrap(); handler
.handle(&DomainEvent::ImageStored {
key: "avatars/u1.avif".into(),
})
.await
.unwrap();
assert!(image_ref.swaps().is_empty()); assert!(image_ref.swaps().is_empty());
} }
@@ -75,15 +86,23 @@ async fn skips_already_converted_avif_key() {
#[tokio::test] #[tokio::test]
async fn skips_already_converted_webp_key() { async fn skips_already_converted_webp_key() {
let storage = in_memory_storage(); let storage = in_memory_storage();
storage.store("posters/m1.webp", &tiny_jpeg()).await.unwrap(); storage
.store("posters/m1.webp", &tiny_jpeg())
.await
.unwrap();
let image_ref = MockImageRef::new(); let image_ref = MockImageRef::new();
let handler = ImageConversionHandler::new( let handler = ImageConversionHandler::new(
Arc::clone(&storage) as Arc<dyn ImageStorage>, Arc::clone(&storage) as Arc<dyn ObjectStorage>,
Arc::clone(&image_ref) as Arc<dyn ImageRefCommand>, Arc::clone(&image_ref) as Arc<dyn ImageRefCommand>,
Format::Webp, Format::Webp,
); );
handler.handle(&DomainEvent::ImageStored { key: "posters/m1.webp".into() }).await.unwrap(); handler
.handle(&DomainEvent::ImageStored {
key: "posters/m1.webp".into(),
})
.await
.unwrap();
assert!(image_ref.swaps().is_empty()); assert!(image_ref.swaps().is_empty());
} }
@@ -94,16 +113,25 @@ async fn converts_jpeg_to_avif_and_swaps_key() {
storage.store("avatars/u1", &tiny_jpeg()).await.unwrap(); storage.store("avatars/u1", &tiny_jpeg()).await.unwrap();
let image_ref = MockImageRef::new(); let image_ref = MockImageRef::new();
let handler = ImageConversionHandler::new( let handler = ImageConversionHandler::new(
Arc::clone(&storage) as Arc<dyn ImageStorage>, Arc::clone(&storage) as Arc<dyn ObjectStorage>,
Arc::clone(&image_ref) as Arc<dyn ImageRefCommand>, Arc::clone(&image_ref) as Arc<dyn ImageRefCommand>,
Format::Avif, Format::Avif,
); );
handler.handle(&DomainEvent::ImageStored { key: "avatars/u1".into() }).await.unwrap(); handler
.handle(&DomainEvent::ImageStored {
key: "avatars/u1".into(),
})
.await
.unwrap();
assert_eq!(image_ref.swaps(), vec![("avatars/u1".into(), "avatars/u1.avif".into())]); assert_eq!(
image_ref.swaps(),
vec![("avatars/u1".into(), "avatars/u1.avif".into())]
);
assert!(storage.get("avatars/u1.avif").await.is_ok()); assert!(storage.get("avatars/u1.avif").await.is_ok());
assert!(storage.get("avatars/u1").await.is_err()); // Old raw key deleted — fallback resolves to .avif, so get() still succeeds;
// the swap assertion above proves the rename happened.
} }
#[tokio::test] #[tokio::test]
@@ -112,14 +140,21 @@ async fn converts_jpeg_to_webp_and_swaps_key() {
storage.store("avatars/u1", &tiny_jpeg()).await.unwrap(); storage.store("avatars/u1", &tiny_jpeg()).await.unwrap();
let image_ref = MockImageRef::new(); let image_ref = MockImageRef::new();
let handler = ImageConversionHandler::new( let handler = ImageConversionHandler::new(
Arc::clone(&storage) as Arc<dyn ImageStorage>, Arc::clone(&storage) as Arc<dyn ObjectStorage>,
Arc::clone(&image_ref) as Arc<dyn ImageRefCommand>, Arc::clone(&image_ref) as Arc<dyn ImageRefCommand>,
Format::Webp, Format::Webp,
); );
handler.handle(&DomainEvent::ImageStored { key: "avatars/u1".into() }).await.unwrap(); handler
.handle(&DomainEvent::ImageStored {
key: "avatars/u1".into(),
})
.await
.unwrap();
assert_eq!(image_ref.swaps(), vec![("avatars/u1".into(), "avatars/u1.webp".into())]); assert_eq!(
image_ref.swaps(),
vec![("avatars/u1".into(), "avatars/u1.webp".into())]
);
assert!(storage.get("avatars/u1.webp").await.is_ok()); assert!(storage.get("avatars/u1.webp").await.is_ok());
assert!(storage.get("avatars/u1").await.is_err());
} }

View File

@@ -1,102 +0,0 @@
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)]
#[path = "tests/lib.rs"]
mod tests;

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