Compare commits

..

92 Commits

Author SHA1 Message Date
d083f8ae3d refactor: use constant for minimum password length and API rate limit
Co-authored-by: Copilot <copilot@github.com>
2026-05-04 21:41:07 +02:00
874c406d4a fix: security hardening — SameSite=Strict, Secure cookie flag, password min length, generic registration error, auth rate limiting 2026-05-04 21:38:23 +02:00
78e1f4ef72 clean up 2026-05-04 21:24:44 +02:00
cf74b06b4a fix: use pixel bar heights and show avg rating values in trends chart 2026-05-04 21:22:47 +02:00
317898d51b fix: count distinct movies per user in users list, not total reviews 2026-05-04 21:10:32 +02:00
790bb6fbb5 fix: read BASE_URL from env for RSS channel link 2026-05-04 21:06:51 +02:00
658df38788 fix: move rss_url after user lookup, extract RSS_FEED_LIMIT constant 2026-05-04 21:05:08 +02:00
cff0f854fa feat: point RSS nav link to user feed when on profile page 2026-05-04 21:00:31 +02:00
66ade70273 feat: add GET /users/{id}/feed.rss per-user RSS feed handler 2026-05-04 20:58:20 +02:00
cbd2ac5b3e feat: add rss_url to HtmlPageContext, use it in nav 2026-05-04 20:55:31 +02:00
0433cd4d9b fix: remove unused feed_title from RssAdapter 2026-05-04 20:54:32 +02:00
b5a8ea2395 feat: add title param to render_feed, use dynamic title in RSS adapter 2026-05-04 20:52:07 +02:00
49b79799c1 feat: add user_id filter to GetDiaryQuery and get_diary use case 2026-05-04 20:49:31 +02:00
f4aba551a2 fix: derive heatmap color from primary instead of hardcoded blue 2026-05-04 20:38:13 +02:00
91df35dbd3 fix: count distinct movies in user stats, not total reviews 2026-05-04 20:35:48 +02:00
623f90e43f fix: remove timezone-broken future-date check from Review::new 2026-05-04 20:29:11 +02:00
e28f628c80 fix: remove redundant 'common' section from documentation 2026-05-04 20:28:06 +02:00
60c25d4c24 fix: update test assertion for new default page limit 2026-05-04 20:18:04 +02:00
22aafe99be fix: set domain DEFAULT_LIMIT to 5 for pagination 2026-05-04 20:16:18 +02:00
0ff22cca5f fix: remove email from top bar nav 2026-05-04 20:13:14 +02:00
ccc39e27e4 fix: lower default page limit to 5 2026-05-04 20:11:55 +02:00
76319756f4 feat: add chrono dependency to Cargo.lock 2026-05-04 19:48:40 +02:00
7703227970 fix: add missing trait stubs to test mock impls 2026-05-04 19:23:56 +02:00
b9933bb48d feat: add profile/feed/chart CSS styles 2026-05-04 19:17:39 +02:00
0c48708ce6 fix: has_more overflow, magic constant, remove dead get_index handler 2026-05-04 19:15:42 +02:00
a2a889bced feat: wire activity feed, users list, and profile page handlers 2026-05-04 19:12:06 +02:00
a4846f3bea fix: pagination underflow, remove |safe, move bar_height_pct to adapter 2026-05-04 19:09:28 +02:00
27be840faa fix: adjust domain accessors and template adapter for Askama compatibility 2026-05-04 19:03:48 +02:00
965fc0eda8 feat: add activity feed, users, and profile HTML templates 2026-05-04 19:03:44 +02:00
d700b85337 fix: correct relative_time future guard, heatmap exact match, max clarity 2026-05-04 18:57:17 +02:00
ffbab75910 feat: add Askama template structs for feed/users/profile 2026-05-04 18:55:18 +02:00
dda7c40f7f fix: validate view param, document V1 history load 2026-05-04 18:53:26 +02:00
1b827b1bdd feat: add activity feed/users/profile use cases and port methods 2026-05-04 18:48:16 +02:00
1ee6873a60 fix: address code review issues in SQLite adapter 2026-05-04 18:46:31 +02:00
7352b533ff feat: implement feed/stats/history/trends SQLite queries 2026-05-04 18:42:45 +02:00
85e254fee2 feat: impl UserRepository::list_with_stats 2026-05-04 18:40:58 +02:00
fa8221322d feat: add feed/stats SQLite row types 2026-05-04 18:32:59 +02:00
38da37de55 feat: add feed/profile/stats port methods to repositories 2026-05-04 18:30:01 +02:00
f3dedbad8a fix: use UserId newtype in UserSummary instead of raw Uuid 2026-05-04 18:29:10 +02:00
d468ce131f feat: add feed/profile domain models, extend DiaryFilter 2026-05-04 18:26:55 +02:00
d034af9e9c feat: update color scheme to use primary variables for consistency in styling 2026-05-04 17:47:00 +02:00
59d308f41b feat: enhance movie rating display with star icons and improved styling 2026-05-04 15:43:02 +02:00
bbb2ee00d6 feat: enhance styling and layout; add background image and improve UI elements 2026-05-04 15:39:15 +02:00
5dd9aac68d feat: add Dockerfile, .dockerignore, and README; remove common crate
Co-authored-by: Copilot <copilot@github.com>
2026-05-04 15:19:29 +02:00
6dcc4c8317 Refactor movie review logging and resolution strategies
- Introduced `MovieResolver` and associated strategies for resolving movie data based on external metadata ID, manual title, or manual entry.
- Updated `log_review` use case to utilize the new `MovieResolver` for fetching movie details.
- Simplified the `LogReviewData` structure and its conversion to `LogReviewCommand`.
- Enhanced error handling for date parsing in review forms and requests.
- Updated dependencies in `Cargo.toml` and `Cargo.lock` to include necessary crates for async operations.
- Added tests for new functionality in `movie_resolver.rs` to ensure correct behavior of resolution strategies.
2026-05-04 15:08:04 +02:00
e31d99a240 fix(tests): add missing trait methods to PanicRepo mocks 2026-05-04 14:37:48 +02:00
41fec1efa5 fix(presentation): restore user_id in get_new_review_page for nav bar 2026-05-04 14:34:46 +02:00
160c08d1c4 fix(presentation): pass None user_id for non-diary page contexts 2026-05-04 14:32:30 +02:00
7aa6d7bf4d feat(template): add user_id to HtmlPageContext and delete button to diary 2026-05-04 14:31:12 +02:00
144f2f8e0c fix(presentation): use {id} syntax in delete review route 2026-05-04 14:29:24 +02:00
cff64f7a6b feat(presentation): add POST /reviews/:id/delete handler and route 2026-05-04 14:27:43 +02:00
5baff54cb9 feat(presentation): add DELETE /api/reviews/:id handler and route 2026-05-04 14:24:48 +02:00
f94d2db8b1 feat(sqlite): implement get_review_by_id, delete_review, delete_movie 2026-05-04 14:21:25 +02:00
48875a6e86 feat(application): add DeleteReviewCommand and delete_review use case 2026-05-04 14:17:13 +02:00
9387ae705b feat(domain): add get_review_by_id to MovieRepository 2026-05-04 14:15:04 +02:00
9871e21bc0 feat(gitignore): add .superpowers and docs/ to .gitignore 2026-05-04 14:00:56 +02:00
fa8efbaa23 feat(database): remove unused SQL queries and update Cargo dependencies 2026-05-04 14:00:33 +02:00
d769a5b55c feat(css): add monospace minimal stylesheet 2026-05-04 13:38:57 +02:00
8e1fb1a974 feat(routes): replace /diary with /, add login/logout/register/reviews/new routes 2026-05-04 13:38:19 +02:00
6145b873f5 feat(handlers): add HTML handlers for login, logout, register, new review, diary index 2026-05-04 13:38:16 +02:00
cc668ae44d feat(dtos): add LoginForm, RegisterForm, ErrorQuery 2026-05-04 13:38:14 +02:00
e5097c22dd feat(extractors): add OptionalCookieUser and RequiredCookieUser 2026-05-04 13:34:31 +02:00
450468ef3d feat(templates): add base layout, login, register, new_review templates; update diary 2026-05-04 13:30:33 +02:00
6e7c6467a7 feat(domain): add find_by_id to UserRepository + SQLite impl 2026-05-04 13:28:20 +02:00
7f815f8207 feat(ports): extend HtmlRenderer with page context and new render methods 2026-05-04 13:20:30 +02:00
5df89200d4 docs: add frontend HTML design spec 2026-05-04 13:19:21 +02:00
eb273dc277 fix(database): update database connection to use DATABASE_URL with SqliteConnectOptions 2026-05-04 12:54:28 +02:00
5689db0ad7 feat(wiring): wire PosterSyncHandler into event channel in main.rs 2026-05-04 12:44:53 +02:00
5c70b8b8be fix(event-handlers): expect over unwrap, panic-stub comment, fix deprecated chrono call 2026-05-04 12:42:03 +02:00
4c547df04e feat(presentation): implement PosterSyncHandler with retry 2026-05-04 12:37:57 +02:00
602df8df22 feat(application): derive Clone on SyncPosterCommand 2026-05-04 12:35:10 +02:00
5b69a3a7c0 test(event-publisher): fix flaky sleep synchronization in EventWorker tests 2026-05-04 12:34:29 +02:00
a38f78d261 feat(event-publisher): add EventHandler trait and fan-out in EventWorker 2026-05-04 12:32:44 +02:00
17f90726e8 feat(event-publisher): add event publisher adapter with configuration and integration 2026-05-04 12:30:42 +02:00
563f33212e docs: event-driven poster sync implementation plan 2026-05-04 12:30:20 +02:00
8e5ac9f433 docs: event-driven poster sync design spec 2026-05-04 12:24:52 +02:00
f790fa2a0f feat(rss): implement RSS feed adapter and integrate with application state 2026-05-04 12:03:17 +02:00
edcf3c1170 feat(poster-fetcher): add poster fetcher adapter with configuration and integration 2026-05-04 11:51:20 +02:00
1985d2c57f feat(poster-storage): implement S3/Minio storage adapter and configuration
Co-authored-by: Copilot <copilot@github.com>
2026-05-04 11:44:44 +02:00
f0b3d8ad90 feat(log_review): add manual title resolution for movie lookup 2026-05-04 11:24:18 +02:00
da72ab1446 feat(metadata): Implement OMDB metadata provider and refactor metadata client
- Added `OmdbProvider` to fetch movie metadata from the OMDB API.
- Refactored `MetadataClient` to use `MetadataSearchCriteria` for fetching movie metadata.
- Updated `MetadataClientImpl` to support fetching metadata using OMDB.
- Modified `log_review` use case to utilize the new metadata fetching mechanism.
- Updated tests and presentation layer to accommodate changes in metadata handling.
- Added dependencies for `reqwest` and `async-trait` in relevant `Cargo.toml` files.
2026-05-04 11:19:51 +02:00
93c65cd155 feat(auth): implement JWT authentication and user registration
- Added JWT authentication with token generation and validation.
- Introduced user registration functionality with email and password.
- Integrated Argon2 for password hashing.
- Created SQLite user repository for user data persistence.
- Updated application context to include user repository and configuration settings.
- Added environment variable support for JWT secret and registration allowance.
- Enhanced error handling for unauthorized access and validation errors.
- Updated presentation layer to handle login and registration requests.
2026-05-04 10:43:07 +02:00
ba42d3d445 refactor(tests): remove unused models from api_test 2026-05-04 09:35:54 +02:00
819332522a cargo lock 2026-05-04 09:30:58 +02:00
79a06e6844 presentation wiring
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 09:30:20 +02:00
97a496553a chore: ignore .worktrees/ directory 2026-05-04 02:43:32 +02:00
5a58625265 feat(presentation): add initial structure with dtos, errors, extractors, handlers, and routes modules 2026-05-04 02:11:33 +02:00
6d9ac07dfc refactor(template-askama): clean up comments and improve code readability 2026-05-04 02:05:13 +02:00
b6a7cf9417 feat(template-askama): add Askama template adapter for diary entries 2026-05-04 02:04:52 +02:00
c4b39c9410 feat(sqlite): implement movie and review management with migrations
- Added SQL migrations for movies and reviews tables.
- Implemented SqliteMovieRepository with methods for upserting movies, saving reviews, and querying diary entries.
- Introduced models for database rows and conversion to domain models.
- Integrated async migration handling in the repository.
- Updated Cargo.toml files to include necessary dependencies for async operations and HTTP handling.
2026-05-04 01:59:52 +02:00
f60cc368b6 Add SQLite repository implementation and update domain models for persistence 2026-05-04 01:34:52 +02:00
65bab7fd44 application layer
Co-authored-by: Copilot <copilot@github.com>
2026-05-04 01:19:59 +02:00
1097 changed files with 8339 additions and 110894 deletions

View File

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

View File

@@ -4,20 +4,3 @@ target/
*.db
*.db-shm
*.db-wal
# .cargo is needed at build time (registry config)
docs/
dev.db
spa/node_modules/
spa/dist/
spa/.env
spa/.env.local
.sqlx/
.superpowers/
.worktrees/
.claude/
.github/
.gitea/
imgs/
screenshots/
test_data/
*.md

View File

@@ -1,60 +1,13 @@
# ── Required ──────────────────────────────────────────────────
# Database (SQLite — file auto-created on first run)
DATABASE_URL=sqlite:///data/movies.db
# Authentication
JWT_SECRET=change-me-to-a-random-string
# Movie metadata — one of these is required (TMDB preferred)
# TMDB_API_KEY=your-tmdb-key
OMDB_API_KEY=your-omdb-key
# ── Recommended ──────────────────────────────────────────────
# Public URL (used for ActivityPub federation and canonical links)
BASE_URL=https://yourdomain.example.com
# Enable sign-ups (default: false — set true so you can register)
DATABASE_URL=sqlite:./dev.db
BASE_URL=http://localhost:3000
PORT=3000
SECURE_COOKIES=false
JWT_SECRET=
JWT_TTL_SECONDS=
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_BUCKET=posters
# MINIO_REGION=minio
# MINIO_ACCESS_KEY_ID=minioadmin
# MINIO_SECRET_ACCESS_KEY=minioadmin
# ── Optional ─────────────────────────────────────────────────
# TMDb enrichment (cast, crew, genres — separate from search metadata above)
# TMDB_API_KEY=your-tmdb-key
# Image conversion (converts uploaded posters to AVIF or WebP)
# IMAGE_CONVERSION_ENABLED=false
# IMAGE_CONVERSION_FORMAT=avif
# Server
# HOST=0.0.0.0
# PORT=3000
# RATE_LIMIT=60
# SECURE_COOKIES=true
# Handler-level logs come from the `http_axum` crate; startup/wiring logs come
# from `bootstrap`; config-load logs come from `config`. Include all three —
# `server=info` alone silently drops them.
# Crates not named here (adapters, etc.) still log warnings and errors unless
# you set a bare level yourself (e.g. `info,http_axum=debug`).
# RUST_LOG=server=info,worker=info,bootstrap=info,config=info,http_axum=info,tower_http=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
OMDB_API_KEY=
POSTER_FETCH_TIMEOUT_SECONDS=30
MINIO_ENDPOINT=
MINIO_ACCESS_KEY_ID=
MINIO_SECRET_ACCESS_KEY=
MINIO_BUCKET=

View File

@@ -1,43 +0,0 @@
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: Swatinem/rust-cache@v2
- name: fmt
run: cargo fmt --all -- --check
- name: clippy
run: cargo clippy --workspace --all-targets -- -D warnings
- name: test
run: cargo test --workspace
- name: test (federation off)
run: cargo test --workspace --no-default-features
# Layering guards. These live in the Makefile and were previously local-only,
# so a PR could go green in CI while violating them.
- name: guards
run: make check-appcontext check-handler-deps check-handler-repos check-handler-services check-http-adapter-deps check-federation-off-social check-sqlite-is-ap-free check-binaries-are-thin check-federation-off-deps check-nats-isolated check-no-second-web-stack

View File

@@ -1,87 +0,0 @@
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: Swatinem/rust-cache@v2
- name: fmt
run: cargo fmt --all -- --check
- name: clippy
run: cargo clippy --workspace --all-targets -- -D warnings
- name: test
run: cargo test --workspace
- name: test (federation off)
run: cargo test --workspace --no-default-features
# Layering guards. These live in the Makefile and were previously local-only,
# so a PR could go green in CI while violating them.
- name: guards
run: make check-appcontext check-handler-deps check-handler-repos check-handler-services check-http-adapter-deps check-federation-off-social check-sqlite-is-ap-free check-binaries-are-thin check-federation-off-deps check-nats-isolated check-no-second-web-stack
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: Set up Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GHCR_TOKEN || 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=federation
cache-from: type=gha
cache-to: type=gha,mode=max

9
.gitignore vendored
View File

@@ -8,14 +8,7 @@
.env.prod
*.db
*db-shm
*db-wal
.worktrees/
.superpowers/
docs/*
!docs/adr/
imgs/
.sqlx/
!crates/adapters/sqlite/tests/fixtures/*.db
docs/

View File

@@ -0,0 +1,92 @@
{
"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\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"
}
],
"parameters": {
"Right": 3
},
"nullable": [
false,
true,
false,
false,
true,
true,
false,
false,
false,
false,
true,
false,
false
]
},
"hash": "01a08873b7fa815ad98a56a0902b60414cfcdc2c7a8570351320c4bc425347c6"
}

View File

@@ -0,0 +1,92 @@
{
"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\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"
}
],
"parameters": {
"Right": 2
},
"nullable": [
false,
true,
false,
false,
true,
true,
false,
false,
false,
false,
true,
false,
false
]
},
"hash": "026e2afeb573707cb360fcdab8f6137aabfaf603b5ed57b98ac2888b4a0389ff"
}

View File

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

View File

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

View File

@@ -0,0 +1,32 @@
{
"db_name": "SQLite",
"query": "SELECT id, email, password_hash FROM users WHERE email = ?",
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "email",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "password_hash",
"ordinal": 2,
"type_info": "Text"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
false
]
},
"hash": "167481bb1692cc81531d9a5cd85425e43d09a6df97c335ac347f7cfd61acd171"
}

View File

@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "INSERT OR IGNORE INTO users (id, email, password_hash, created_at) VALUES (?, ?, ?, ?)",
"describe": {
"columns": [],
"parameters": {
"Right": 4
},
"nullable": []
},
"hash": "18de90feb13b9f467f06d0ce25332d9ea7eabc99d9f1a44694e5d10762606f82"
}

View File

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

View File

@@ -0,0 +1,98 @@
{
"db_name": "SQLite",
"query": "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,\n r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at,\n u.email AS user_email\n FROM reviews r\n INNER JOIN movies m ON m.id = r.movie_id\n INNER JOIN users u ON u.id = r.user_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": "user_email",
"ordinal": 13,
"type_info": "Text"
}
],
"parameters": {
"Right": 2
},
"nullable": [
false,
true,
false,
false,
true,
true,
false,
false,
false,
false,
true,
false,
false,
false
]
},
"hash": "217854179b4f77897178e6cfae51fb743e5be49ffc59826509be37a7cc81b6ee"
}

View File

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

View File

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

View File

@@ -0,0 +1,92 @@
{
"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\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"
}
],
"parameters": {
"Right": 3
},
"nullable": [
false,
true,
false,
false,
true,
true,
false,
false,
false,
false,
true,
false,
false
]
},
"hash": "47f7cf95ce3450635b643ab710cadba96f40319140834d510bc5207b2552e055"
}

View File

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

View File

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

View File

@@ -0,0 +1,92 @@
{
"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\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\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"
}
],
"parameters": {
"Right": 3
},
"nullable": [
false,
true,
false,
false,
true,
true,
false,
false,
false,
false,
true,
false,
false
]
},
"hash": "5a861b5a934c9831ff17d896fa48feb95e6dab051c5ac55a66f9793482522199"
}

View File

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

View File

@@ -0,0 +1,56 @@
{
"db_name": "SQLite",
"query": "SELECT id, movie_id, user_id, rating, comment, watched_at, created_at\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"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
false,
false,
true,
false,
false
]
},
"hash": "70ee6050284475b5641af712e5923ba2091b8b70b1885ca6518dfa4bb01fdac2"
}

View File

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

View File

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

View File

@@ -0,0 +1,92 @@
{
"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\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"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
true,
false,
false,
true,
true,
false,
false,
false,
false,
true,
false,
false
]
},
"hash": "8d144859b397a842118c2dc4ab30e74015a814ed8185b6f86fbe39e641ab804e"
}

View File

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

View File

@@ -0,0 +1,92 @@
{
"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\n FROM reviews r\n INNER JOIN movies m ON m.id = r.movie_id\n WHERE r.user_id = ?\n ORDER BY r.rating DESC, 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"
}
],
"parameters": {
"Right": 3
},
"nullable": [
false,
true,
false,
false,
true,
true,
false,
false,
false,
false,
true,
false,
false
]
},
"hash": "a3f4385bac7f78a9959648fb325d37096c87859ded1762137ce745955f46830c"
}

View File

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

View File

@@ -0,0 +1,56 @@
{
"db_name": "SQLite",
"query": "SELECT id, movie_id, user_id, rating, comment, watched_at, created_at\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"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
false,
false,
true,
false,
false
]
},
"hash": "af883f8b78f185077e2d3dcfaa0a6e62fbdfbf00c97c9b33b699dc631476181d"
}

View File

@@ -0,0 +1,92 @@
{
"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\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"
}
],
"parameters": {
"Right": 2
},
"nullable": [
false,
true,
false,
false,
true,
true,
false,
false,
false,
false,
true,
false,
false
]
},
"hash": "affe1eb261283c09d4b1ce6e684681755f079a044ffec8ff2bd79cfd8efe16b8"
}

View File

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

View File

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

View File

@@ -0,0 +1,38 @@
{
"db_name": "SQLite",
"query": "SELECT u.id,\n u.email,\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\n GROUP BY u.id, u.email\n ORDER BY u.email ASC",
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "email",
"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": [
false,
false,
false,
true
]
},
"hash": "f259059d76f29cade94e249735d37ef4993fe5bff095dc43e681b848a398f318"
}

View File

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

View File

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

View File

@@ -1,295 +0,0 @@
# Code Style Guide
This document defines the coding standards for this project. All contributors (human and agent) must follow these rules. When in doubt, prefer clarity and correctness over cleverness and speed.
## Philosophy
- Code reads like prose
- Do things properly — no shortcuts
- SOLID principles — always, no exceptions
- KISS — simplest solution that solves the problem **correctly and properly**. KISS means no unnecessary complexity, NOT skipping proper architecture. If the design calls for a trait, a newtype, or a pattern — that IS the simple solution. When in doubt, follow the architecture (CONTEXT.md, ADRs) over perceived simplicity.
- DRY — one source of truth for every piece of knowledge
- Use design patterns where they fit (Strategy, Observer, Builder, etc.) — name them in code so intent is clear
- Single responsibility everywhere: files, functions, types, modules
- Single point of entry, single control flow — no parallel data/control paths
- Open/Closed — extend via new types, don't modify existing ones
- Depend on abstractions (traits), not concretions
- Keep things as private as possible
- Zero magic numbers, strings, or unnamed constants
- Newtypes for self-documenting code
- Descriptive variable names — `review_rating`, not `rr`. `watched_on`, not `wd`. `poster_url`, not `pu`. No abbreviations unless universally understood (e.g., `id`, `url`, `i` for a trivial loop index).
## Project Structure
### Module Organization
Deep module hierarchy, grouped by concern. No flat file dumps. Each file is its own small, enclosed thing.
```
crates/domain/src/
├── models/
│ ├── review.rs
│ ├── diary_entry.rs
│ └── movie.rs
├── value_objects/
│ ├── movie_id.rs
│ ├── rating.rs
│ └── watched_on.rs
├── ports/
│ ├── review_repository.rs
│ └── event_publisher.rs
└── ...
```
### File Size
Small files with a single concern. If a file grows past ~100-150 lines, it's probably doing too much — split it.
### Tests
Tests live outside production files, apart from the few exceptions listed below. Each crate keeps its tests in `crates/<crate>/tests/`, one file per unit under test, grouped by the same module as the production code. Cargo builds each top-level `tests/<group>.rs` as one integration-test binary; the group file lists its members with `#[path]`:
```
crates/application/src/diary/log_review.rs
crates/application/tests/diary.rs # mod support; + #[path] list of the group
crates/application/tests/diary/log_review_test.rs
crates/application/tests/support/ # shared contexts and command builders
```
Adapters follow the same layout (`crates/adapters/sqlite/tests/repositories/diary_repository_test.rs`). Route smoke, API and handler tests run against the real composition and live in `crates/bootstrap/tests/`; http-axum also has integration tests in `crates/adapters/http-axum/tests/` (`log_review_form_test.rs`, `openapi_contract_test.rs`).
Inline `#[cfg(test)]` tests remain only where they must reach private items:
- `crates/adapters/http-axum`: the `api/social`, `federation/blocklist_api` and `html/social` mapper tests (`tests.rs` beside each; shared fixtures in `src/test_support.rs`) and the rate-limit tests in `router.rs`
- `crates/application/src/users/diary_filter.rs`
- `crates/adapters/nats/src/subject.rs`
- `crates/tui/src/client.rs`
## Naming
### Conventions
- Files and modules: `snake_case`
- Types: `PascalCase` with semantic suffixes for clarity and navigation
- Functions and methods: `snake_case`, verb-first, descriptive
### Type Suffixes
| Suffix | Usage | Example |
|---|---|---|
| (none) | Value objects, entities | `Review`, `Movie`, `DiaryEntry` |
| `Id` | Identity newtypes | `MovieId`, `ReviewId`, `UserId` |
| `Config` | Configuration data | `ServerConfig`, `StorageConfig` |
| `Service` | Domain services | `LocalSocialService` |
| `Error` | Error types | `DomainError`, `AuthError` |
| `UseCase` | Application use cases | `LogReviewUseCase`, `GetDiaryUseCase` |
| `Strategy` | Strategy trait/impls | `RatingAggregationStrategy` |
Ports are named for the capability they provide (`ReviewRepository`, `EventPublisher`); their implementations are named for the technology (`SqliteReviewRepository`).
### Newtypes
Use newtypes everywhere a primitive carries domain meaning:
```rust
// YES
struct MovieId(Uuid);
struct ReviewId(Uuid);
struct Rating(u8);
struct PosterUrl(Url);
// NO
type MovieId = String;
fn log_review(movie: &str, rating: u8, watched_on: &str) -> ...
```
Newtypes are self-documenting and prevent accidental misuse (can't pass a `MovieId` where a `ReviewId` is expected).
## Functions and Methods
### Single Responsibility
Each function does ONE thing. Compose small functions into larger behaviors.
```rust
// YES
fn resolve_movie(...) -> Movie { ... }
fn build_review(movie: &Movie, draft: ReviewDraft) -> Review { ... }
fn record_diary_entry(review: &Review) -> DiaryEntry { ... }
// NO
fn log_review(...) -> DiaryEntry {
// 80 lines doing movie resolution, review construction and diary recording
}
```
### Visibility
Default to private. Only `pub` what the module's consumers actually need. Use `pub(crate)` for intra-crate sharing that shouldn't leak outside.
### impl Blocks
Split by concern:
```rust
// Construction
impl Review {
pub fn new(movie: MovieId, rating: Rating, watched_on: WatchedOn) -> Self { ... }
}
// Queries
impl Review {
pub fn is_rewatch(&self) -> bool { ... }
pub fn rating(&self) -> Rating { ... }
}
// Mutations
impl Review {
pub fn revise_rating(&mut self, rating: Rating) { ... }
}
```
## Error Handling
### Domain and Application: `thiserror`
Typed, matchable error enums. Every error variant is meaningful.
```rust
#[derive(Debug, thiserror::Error)]
pub enum DiaryError {
#[error("movie {0} not found")]
MovieNotFound(MovieId),
#[error("review rejected: {0}")]
ReviewRejected(String),
}
```
### Adapters and Bootstrap: `anyhow`
Pragmatic error handling. Context strings for debugging.
```rust
fn fetch_poster_blob(poster_url: &PosterUrl) -> anyhow::Result<Bytes> {
let data = http_client.get(poster_url)
.context("failed to fetch poster")?;
// ...
}
```
### Never `unwrap` or `expect` in production code
Errors propagate with `?` and bubble up to a single handler. No scattered panic points.
`unwrap` is allowed **only** in test code.
## Configuration
Secrets (API keys, JWT secrets) and deployment settings are read once at startup by the `config` crate into a typed `AppConfig`: built-in defaults, then `config.toml` (or the file named by `CONFIG_FILE`), then environment variables. Malformed values fail startup; empty values count as unset. Only the binaries load configuration; everything else receives typed config values.
Zero magic numbers or strings. Everything named, everything explained by its name.
```rust
// YES
const MAX_REVIEW_BODY_LENGTH: usize = 10_000;
// NO
if body.len() > 10_000 { ... }
```
Constants are only for true invariants that never change. Tunable values belong in config.
## Comments
Comments only where a name, test or ADR cannot carry the reason. A comment is noise that drifts out of date while the code moves on, so prefer to put the reason somewhere that cannot rot silently:
- **A name.** Rename the function, the variable, or the type until the reason is visible in the code.
- **A test.** A constraint worth a comment is worth a test that fails when someone breaks it. `media_is_resolved_before_the_cascade_runs` outlives any note explaining why the order matters.
- **An ADR.** Architectural reasoning belongs in `docs/adr/`, where it is versioned and discoverable.
When none of those can carry it, a short comment stating the reason (never restating the code) is acceptable.
## Dependencies
- Use good crates when they exist — don't reinvent
- Prefer crates with minimal transitive dependencies
- Aim for small memory footprint and executable size
- Every dependency that has a `[workspace.dependencies]` entry is declared `workspace = true`; crates inherit `edition` from `[workspace.package]`
- Domain crate: zero external dependencies beyond `thiserror`, `serde` and small value-type crates
- Application crate: depends only on domain
- Adapters: free to pull in platform crates (`axum`, `sqlx`, `nats`, etc.)
- Bootstrap: wiring only; the only crate that names adapters
- Binaries (`server`, `worker`): load config, call `bootstrap`, run — nothing else (`make check-binaries-are-thin`)
## Trait Objects for Ports
Ports are `dyn` behind `Arc`. Use cases hold their dependencies as trait objects:
```rust
pub struct Deps {
pub reviews: Arc<dyn ReviewRepository>,
pub events: Arc<dyn EventPublisher>,
}
```
Prefer a closed enum over `dyn` only where exhaustiveness is the point — when adding a variant must force every consumer to handle it. That is a decision about compiler-enforced coverage, not about dispatch cost.
## Testing
### Test Helpers Are Gated
`domain::testing` (factories, the cascading `InMemoryStore`, noops and `Refusing*` fakes) compiles only with the `test-helpers` feature, which crates enable in their `[dev-dependencies]`. Production builds must never contain fakes. Build entities with the `domain::testing` factories instead of repeating struct literals in each test.
### Coverage Targets
| Crate | Approach | Target |
|---|---|---|
| Domain | TDD (red-green-refactor) | 100% |
| Application | TDD | High coverage |
| Adapters | Integration tests where practical | Best effort |
| Bootstrap | Route smoke, API and handler tests over the real wiring | Every route reachable |
### Fakes Must Not Be Kinder Than Production
A fake that is more forgiving than the real adapter turns a test into a lie: it passes whichever way the code is written, which is worse than having no test. Two rules follow.
**A fake must reproduce the constraints the database enforces.** SQLite removes dependent rows through `ON DELETE CASCADE`, so an in-memory store must cascade the same way, and enforce the same uniqueness constraints. Without that, an ordering requirement such as "resolve the poster before deleting the `DiaryEntry`" could not be tested at all.
**A fake must be able to fail.** Every port whose failure is meant to degrade rather than propagate needs a fake that refuses (a poster fetcher that errors, an event publisher that rejects). A best-effort path with no failing test is a path that has never run.
When a claim cannot be observed through the fake, move the test to where the real thing runs rather than asserting it against the fake's own behaviour.
### Test Naming
Tests read as specifications:
```rust
#[tokio::test]
async fn logging_review_creates_diary_entry() { ... }
#[tokio::test]
async fn rewatch_review_keeps_original_entry() { ... }
```
## Control Flow
### Single Point of Entry
One path through the code. No parallel flows that do similar things in different places.
```rust
// YES — one function handles movie resolution
let movie = movie_resolver.resolve(movie_id, requester);
// NO — movie resolution duplicated in two places
// diary_handler.rs: resolves movie
// import_handler.rs: also resolves movie with slightly different logic
```
### Single Call, Cascading Changes
When something changes, it should cascade from one point. Don't require updating multiple files for a single logical change.
### No God Objects
If a struct has more than 5-6 fields, question whether it's doing too much. If a function takes more than 4-5 parameters, consider grouping them into a dedicated type.

View File

@@ -1,29 +0,0 @@
# Movies Diary
A personal movie diary where users record each watching of a movie with a rating and an optional written review; reviews appear in diaries and social feeds shared with local and federated followers.
## Language
**Review**:
The record of a single watching of a movie: a rating, an optional written comment, when it was watched, and optionally how.
_Avoid_: Diary entry, post
**Diary Entry**:
A Review together with the Movie it is about, as it appears in a diary or a feed.
_Avoid_: Review (that is the record itself)
**Spoiler Flag**:
An author-set marker on a Review stating that its entire comment reveals plot details.
_Avoid_: Spoiler warning, spoiler tag, spoiler markup
**Account deletion**:
Two-phase removal of a user. The request tombstones the account immediately; a background purge then erases its data and notifies the fediverse.
_Avoid_: Account removal, deactivation
**Tombstone**:
The data-free users row (plus signing keypair) an Account deletion leaves behind so queued fediverse `Delete` deliveries can still be signed; removed after a retention period.
_Avoid_: Soft delete
**Retired Username**:
A username of a deleted account, refused for every future registration.
_Avoid_: Reserved username, banned username

View File

@@ -1,97 +0,0 @@
# 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 server # 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 check # the full gate; plain `make` runs it too
```
`make check` runs, in order: `fmt-check`, `clippy` (workspace, all targets, `-D warnings`), `test`, `test-federation-off` (workspace tests with default features off), then the layering guards (`check-appcontext`, `check-handler-deps`, `check-handler-repos`, `check-handler-services`, `check-http-adapter-deps`, `check-federation-off-social`, `check-sqlite-is-ap-free`, `check-binaries-are-thin`, `check-federation-off-deps`, `check-nats-isolated`, `check-no-second-web-stack`). CI runs the same steps.
For the SPA also run:
```bash
cd spa && bunx tsc --noEmit
```
PRs with a failing gate won't be merged.
## Architecture
The project follows hexagonal (ports & adapters) architecture. See `architecture.mmd` for the full diagram.
**Key rules:**
- HTTP 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 the http-axum mappers (`crates/adapters/http-axum/src/html/mappers/` for the classic UI, the DTO conversions beside each handler under `api/`)
- Use cases called from HTTP handlers take a `&FooDeps` struct (registered in `application::Deps`, built by `bootstrap`) — never `&AppContext` itself, which `application` cannot even depend on. A few keep individual `Arc<dyn Trait>` params instead: `enrich_movie` and `request_enrichment` because they are called from adapters rather than handlers, and `diary::log_review` because its one extra dependency comes from `Services` rather than a repository. `wrapup::compute`, `import::cleanup` and `integrations::cleanup` also take individual params, but they are only ever called from jobs. See ADR-0007 for the exact list and why each is legitimate
```
domain → pure types, traits (ports), zero deps
application → use cases, orchestration
config → typed AppConfig: defaults → config.toml / CONFIG_FILE → env
bootstrap → composition root, the only crate that names adapters
server, worker → thin binaries: load config, call bootstrap, run
adapters/* → implement domain ports (sqlite, activitypub, ...); http-axum is the HTTP driving adapter
spa/ → React SPA (TanStack Router + shadcn/ui)
```
Cargo features: `federation` (default; ActivityPub) and `nats`. `sqlite` and `sqlite-federation` remain as deprecated aliases so old build commands keep working.
**Dependencies.** Every external crate is declared once in the root `[workspace.dependencies]`, usually with `default-features = false`; member crates write `foo = { workspace = true, features = [...] }` and enable only the features they use. A new crate goes into both `members` and `default-members` in the root `Cargo.toml` (only `tui` is left out of `default-members`). `cargo machete` should stay clean, and each crate must compile on its own (`cargo check -p <crate> --all-targets`), not just through feature unification in the binaries.
### Adding a new feature
1. **Domain first** — models in `domain/src/models/`, ports in `domain/src/ports/`, events in `domain/src/events.rs`
2. **Adapters** — implement ports under `crates/adapters/sqlite/src/repositories/<aggregate>/`, add a migration
3. **Application** — use cases in `application/src/<domain>/`, add the deps struct to `application/src/deps.rs`, build it in `bootstrap`
4. **API types** — DTOs in `api-types/src/`
5. **HTTP** — JSON handlers in `crates/adapters/http-axum/src/api/<domain>.rs`, classic HTML handlers in `.../html/<domain>.rs` (templates in `html/templates/`), routes in `router.rs` / `api/mod.rs` / `html/mod.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`
### Tests
Tests live in `crates/<crate>/tests/`, not in the production file. A few inline tests remain where they need private items; `CODE_STYLE.md` lists them. Build fixtures with the `domain::testing` factories and use the cascading `InMemoryStore` and `Refusing*` fakes. Route smoke, API and handler tests over the real wiring are in `crates/bootstrap/tests/`.
### Database adapters
SQLite is the only supported backend (see [ADR-0012](docs/adr/0012-sqlite-is-the-only-backend.md)). Add migrations under `crates/adapters/sqlite`; SQLite uses `?` params and text datetimes.
### Federation (ActivityPub)
Events: never publish from a use case after the fact. Pass the events to the repository/command-port method that writes the data so they land in the outbox (`event_queue`) in the same transaction (ADR-0015). The worker delivers them at-least-once and a ledger skips handlers already done, so every handler must be idempotent.
Federation is feature-gated (`#[cfg(feature = "federation")]`). If your feature should federate, add domain events, handle them in `crates/adapters/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+ tests; more route and handler tests in `crates/bootstrap/tests/` are welcome
- **Docs** — API usage examples, deployment guides

3709
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,152 +1,48 @@
[workspace]
default-members = [
"crates/adapters/auth",
"crates/adapters/event-publisher",
"crates/adapters/metadata",
"crates/adapters/poster-fetcher",
"crates/adapters/object-storage",
"crates/adapters/rss-adapter",
"crates/adapters/sqlite",
"crates/adapters/sqlite-federation",
"crates/adapters/activitypub",
"crates/adapters/export",
"crates/adapters/nats",
"crates/adapters/tmdb-enrichment",
"crates/adapters/image-converter",
"crates/adapters/http-axum",
"crates/adapters/importer",
"crates/adapters/media-server",
"crates/event-payload",
"crates/api-types",
"crates/application",
"crates/domain",
"crates/bootstrap",
"crates/server",
"crates/worker",
"crates/config",
]
members = [
"crates/adapters/auth",
"crates/adapters/event-publisher",
"crates/adapters/metadata",
"crates/adapters/poster-fetcher",
"crates/adapters/object-storage",
"crates/adapters/rss-adapter",
"crates/adapters/poster-storage",
"crates/adapters/rss",
"crates/adapters/sqlite",
"crates/adapters/sqlite-federation",
"crates/adapters/activitypub",
"crates/adapters/export",
"crates/event-payload",
"crates/adapters/nats",
"crates/api-types",
"crates/adapters/template-askama",
"crates/application",
"crates/adapters/tmdb-enrichment",
"crates/adapters/image-converter",
"crates/domain",
"crates/bootstrap",
"crates/adapters/http-axum",
"crates/server",
"crates/tui",
"crates/worker",
"crates/adapters/importer",
"crates/adapters/media-server",
"crates/config",
"crates/presentation",
]
resolver = "2"
[workspace.package]
edition = "2024"
[workspace.dependencies]
tokio = { version = "1.0", default-features = false }
bytes = "1"
futures = "0.3"
async-stream = "0.3"
tokio = { version = "1.0", features = ["full"] }
dotenvy = "0.15"
serde = { version = "1.0", default-features = false, features = ["std"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
anyhow = "1.0"
thiserror = "2.0"
tracing = "0.1"
tracing-subscriber = { version = "0.3.23", default-features = false }
async-trait = "0.1.92"
uuid = { version = "1.23.0", default-features = false, features = ["std"] }
chrono = { version = "0.4", default-features = false, features = ["std"] }
chrono-tz = "0.10"
sqlx = { version = "0.8.6", default-features = false, features = [
"runtime-tokio",
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
async-trait = "0.1"
uuid = { version = "1.23.0", features = ["v4", "serde"] }
chrono = { version = "0.4", features = ["serde"] }
sqlx = { version = "0.8.6", features = [
"runtime-tokio-rustls",
"sqlite",
"uuid",
"derive",
"migrate",
"macros",
] }
rand = "0.9"
tempfile = "3"
reqwest = { version = "0.13", default-features = false, features = ["json", "query", "charset", "http2", "system-proxy", "rustls-no-provider"] }
sha2 = "0.10"
hex = "0.4"
object_store = { version = "0.11", default-features = false, features = ["aws"] }
axum = { version = "0.8.8", default-features = false }
csv = "1"
figment = { version = "0.10", features = ["toml"] }
rustls = { version = "0.23", default-features = false }
tower = { version = "0.5", default-features = false }
tower-http = { version = "0.6.8", default-features = false }
utoipa = "5.5.0"
utoipa-swagger-ui = "9.0.2"
askama = "0.16.0"
infer = "0.19.0"
percent-encoding = "2"
axum-governor = "2"
email_address = "0.2.9"
jsonwebtoken = "9"
argon2 = { version = "0.5", default-features = false }
rand_core = { version = "0.6", default-features = false }
async-nats = "0.48.0"
k-ap = { version = "0.5.1", registry = "gitea" }
url = "2"
ammonia = "4"
calamine = "0.35"
image = { version = "0.25", default-features = false }
ravif = { version = "0.11", default-features = false }
webp = "0.3"
rss = { version = "2" }
ratatui = "0.30.0"
keyring-core = "1.0.0"
apple-native-keyring-store = { version = "1.0.0", features = ["keychain"] }
zbus-secret-service-keyring-store = { version = "1.0.0" }
windows-native-keyring-store = { version = "1.0.0" }
directories = "6"
reqwest = { version = "0.13", features = ["json", "query"] }
object_store = { version = "0.11", features = ["aws"] }
api-types = { path = "crates/api-types" }
domain = { path = "crates/domain" }
tmdb-enrichment = { path = "crates/adapters/tmdb-enrichment" }
application = { path = "crates/application" }
bootstrap = { path = "crates/bootstrap" }
http-axum = { path = "crates/adapters/http-axum" }
presentation = { path = "crates/presentation" }
auth = { path = "crates/adapters/auth" }
metadata = { path = "crates/adapters/metadata" }
poster-fetcher = { path = "crates/adapters/poster-fetcher" }
object-storage = { path = "crates/adapters/object-storage" }
poster-storage = { path = "crates/adapters/poster-storage" }
event-publisher = { path = "crates/adapters/event-publisher" }
rss-adapter = { path = "crates/adapters/rss-adapter" }
export = { path = "crates/adapters/export" }
rss = { path = "crates/adapters/rss" }
sqlite = { path = "crates/adapters/sqlite" }
sqlite-federation = { path = "crates/adapters/sqlite-federation" }
activitypub = { path = "crates/adapters/activitypub" }
event-payload = { path = "crates/event-payload" }
media-server = { path = "crates/adapters/media-server" }
nats = { path = "crates/adapters/nats" }
importer = { path = "crates/adapters/importer" }
image-converter = { path = "crates/adapters/image-converter" }
config = { path = "crates/config" }
[profile.dev]
debug = 1 # line tables only — still debuggable, much faster linking
[profile.dev.package."*"]
opt-level = 2 # compile deps faster at runtime; paid once, cached after
[profile.release]
codegen-units = 1
strip = true
template-askama = { path = "crates/adapters/template-askama" }

View File

@@ -1,66 +1,57 @@
# ----- 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 -----
FROM rust:slim-bookworm AS builder
RUN apt-get update && apt-get install -y --no-install-recommends sqlite3 && rm -rf /var/lib/apt/lists/*
# ----- chef base -----
FROM rust:slim-bookworm AS chef
# libwebp-dev: required at build time by the `webp` crate (C bindings)
RUN apt-get update && apt-get install -y --no-install-recommends \
libwebp-dev \
pkg-config \
&& rm -rf /var/lib/apt/lists/*
RUN cargo install cargo-chef --locked --version 0.1.78
WORKDIR /build
FROM chef AS planner
COPY . .
RUN cargo chef prepare --recipe-path recipe.json
# ----- build -----
FROM chef AS builder
# Features: federation (ActivityPub), nats (EVENT_BUS_BACKEND=nats).
# Build without federation: --build-arg FEATURES=
# To add NATS support: --build-arg FEATURES=federation,nats
ARG FEATURES=federation
COPY .cargo ./.cargo
COPY --from=planner /build/recipe.json recipe.json
RUN --mount=type=cache,target=/usr/local/cargo/registry,sharing=locked \
--mount=type=cache,target=/usr/local/cargo/git,sharing=locked \
cargo chef cook --release -p server -p worker --no-default-features --features "${FEATURES}" --recipe-path recipe.json
# All sqlx queries use the runtime API (no query! macros), so no database
# or .sqlx cache is needed at compile time.
# Cache dependency compilation separately from source
COPY Cargo.toml Cargo.lock ./
COPY crates/adapters/auth/Cargo.toml crates/adapters/auth/Cargo.toml
COPY crates/adapters/event-publisher/Cargo.toml crates/adapters/event-publisher/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-storage/Cargo.toml crates/adapters/poster-storage/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/template-askama/Cargo.toml crates/adapters/template-askama/Cargo.toml
COPY crates/application/Cargo.toml crates/application/Cargo.toml
COPY crates/domain/Cargo.toml crates/domain/Cargo.toml
COPY crates/presentation/Cargo.toml crates/presentation/Cargo.toml
# Stub every crate so cargo can resolve and fetch deps
RUN find crates -name "Cargo.toml" | sed 's|/Cargo.toml||' | \
xargs -I{} sh -c 'mkdir -p {}/src && echo "fn main(){}" > {}/src/main.rs && echo "" > {}/src/lib.rs'
RUN cargo fetch
# Now copy real sources (invalidates cache only on source changes)
COPY crates ./crates
RUN --mount=type=cache,target=/usr/local/cargo/registry,sharing=locked \
--mount=type=cache,target=/usr/local/cargo/git,sharing=locked \
cargo build --release -p server -p worker --no-default-features --features "${FEATURES}"
# sqlx macros verify queries at compile time; create a real DB from migrations
RUN sqlite3 /build/dev.db \
< crates/adapters/sqlite/migrations/0001_initial.sql && \
sqlite3 /build/dev.db \
< crates/adapters/sqlite/migrations/0002_users.sql
ENV DATABASE_URL=sqlite:///build/dev.db
RUN cargo build --release -p presentation
# ----- runtime -----
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
wget \
libwebp7 \
fonts-dejavu-core \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=builder /build/target/release/server ./server
COPY --from=builder /build/target/release/worker ./worker
COPY --from=builder /build/target/release/presentation ./presentation
COPY static ./static
COPY --from=spa-builder /spa/dist ./spa/dist
EXPOSE 3000
ENV RUST_LOG=server=info,worker=info,bootstrap=info,config=info,http_axum=info,tower_http=info
ENV RUST_LOG=presentation=info,tower_http=info
CMD ["./server"]
CMD ["./presentation"]

180
Makefile
View File

@@ -1,180 +0,0 @@
.DEFAULT_GOAL := check
check: fmt-check clippy test test-federation-off check-appcontext check-handler-deps check-handler-repos check-handler-services check-http-adapter-deps check-federation-off-social check-sqlite-is-ap-free check-binaries-are-thin check-federation-off-deps check-nats-isolated check-no-second-web-stack
@echo "✅ All checks passed"
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"
check-handler-deps:
@if grep -rnE "[A-Za-z0-9_]*Deps(\s*\{|::from\()" crates/adapters/http-axum/src --include="*.rs" --exclude-dir=tests --exclude=tests.rs | grep -q .; then \
echo "❌ hand-built deps struct found in http-axum:"; \
grep -rnE "[A-Za-z0-9_]*Deps(\s*\{|::from\()" crates/adapters/http-axum/src --include="*.rs" --exclude-dir=tests --exclude=tests.rs; \
exit 1; \
fi
@echo "✅ No hand-built deps structs in http-axum"
check-handler-repos:
@if grep -rn "app_ctx\.repos" crates/adapters/http-axum/src --include="*.rs" --exclude-dir=tests --exclude=tests.rs | grep -q .; then \
echo "❌ app_ctx.repos referenced in http-axum:"; \
grep -rn "app_ctx\.repos" crates/adapters/http-axum/src --include="*.rs" --exclude-dir=tests --exclude=tests.rs; \
exit 1; \
fi
@echo "✅ No app_ctx.repos references in http-axum"
HTTP_ADAPTER_ALLOWED_ADAPTERS :=
check-handler-services:
@hits=$$(find crates/adapters/http-axum/src -name '*.rs' -not -path '*/tests/*' -not -name tests.rs \
| xargs perl -0ne 'while (/app_ctx\s*\.\s*services\s*\.\s*([a-z_]+)/g) { print "$$ARGV: services.$$1\n" unless $$1 eq "review_logger" }'); \
if [ -n "$$hits" ]; then \
echo "❌ http-axum reaches a service port directly (call a use case instead):"; \
echo "$$hits"; \
exit 1; \
fi; \
echo "✅ No direct service-port calls in http-axum"
check-http-adapter-deps:
@deps=$$(awk '/^\[dependencies\]/{f=1;next} /^\[/{f=0} f' crates/adapters/http-axum/Cargo.toml \
| sed -n 's/^\([A-Za-z0-9_-]\{1,\}\)[[:space:]]*=.*/\1/p'); \
bad=""; \
for a in $$(ls crates/adapters); do \
case " $(HTTP_ADAPTER_ALLOWED_ADAPTERS) " in *" $$a "*) continue;; esac; \
if echo "$$deps" | grep -qx "$$a"; then bad="$$bad $$a"; fi; \
done; \
if [ -n "$$bad" ]; then \
echo "❌ http-axum depends on adapter crate(s):$$bad"; \
echo " http-axum may name renderers, not reachers — see ADR-0008."; \
exit 1; \
fi; \
echo "✅ http-axum depends on no other adapter crate"
check-federation-off-social:
@if grep -n "NoopSocialCommand\|NoopSocialQuery" crates/bootstrap/src/federation.rs | grep -q .; then \
echo "❌ bootstrap wires social noops — federation-off would report an empty follow graph:"; \
grep -n "NoopSocialCommand\|NoopSocialQuery" crates/bootstrap/src/federation.rs; \
exit 1; \
fi
@if ! awk '/cfg\(not\(feature = "federation"\)\)/ && !started {started=1} started && /cfg\(feature = "federation"\)/ {exit} started {print}' crates/bootstrap/src/federation.rs | grep -q "LocalSocialService"; then \
echo "❌ LocalSocialService not wired in the federation-off region of crates/bootstrap/src/federation.rs — federation-off has no social behavior wired at all, not even noops"; \
exit 1; \
fi
@echo "✅ bootstrap wires real social behavior in every configuration"
BINARIES_ALLOWED_DEPS := bootstrap config tokio axum anyhow dotenvy tracing
check-binaries-are-thin:
@bad=""; \
for c in server worker; do \
deps=$$(awk '/^\[dependencies\]/{f=1;next} /^\[/{f=0} f' crates/$$c/Cargo.toml \
| sed -n 's/^\([A-Za-z0-9_-]\{1,\}\)[[:space:]]*=.*/\1/p'); \
for d in $$deps; do \
case " $(BINARIES_ALLOWED_DEPS) " in *" $$d "*) continue;; esac; \
bad="$$bad $$c:$$d"; \
done; \
done; \
if [ -n "$$bad" ]; then \
echo "❌ binary [dependencies] outside {$(BINARIES_ALLOWED_DEPS)}:$$bad"; \
echo " wiring belongs in crates/bootstrap — see ADR-0013."; \
exit 1; \
fi; \
echo "✅ server and worker depend only on {$(BINARIES_ALLOWED_DEPS)}"
check-sqlite-is-ap-free:
@if grep -nE '^[[:space:]]*(k-ap|activitypub[A-Za-z0-9_-]*|[A-Za-z0-9_-]+-federation)[[:space:]]*=' crates/adapters/sqlite/Cargo.toml | grep -q .; then \
echo "❌ sqlite crate depends on ActivityPub:"; \
grep -nE '^[[:space:]]*(k-ap|activitypub[A-Za-z0-9_-]*|[A-Za-z0-9_-]+-federation)[[:space:]]*=' crates/adapters/sqlite/Cargo.toml; \
exit 1; \
fi
@echo "✅ sqlite crate depends on no k-ap, activitypub, or *-federation crate"
CARGO_TREE_NAMES := cargo tree -p server -p worker -e normal --prefix none
check-federation-off-deps:
@tree=$$($(CARGO_TREE_NAMES) --no-default-features) || exit 1; \
hits=$$(echo "$$tree" | grep -E '^(k-ap|activitypub_federation|activitypub|sqlite-federation) ' | sort -u); \
if [ -n "$$hits" ]; then \
echo "❌ federation-only crates in the --no-default-features server/worker tree:"; \
echo "$$hits"; \
exit 1; \
fi; \
echo "✅ federation-off tree has no k-ap, activitypub_federation, activitypub, or sqlite-federation"
check-nats-isolated:
@tree=$$($(CARGO_TREE_NAMES)) || exit 1; \
nats_tree=$$($(CARGO_TREE_NAMES) --features nats) || exit 1; \
if echo "$$tree" | grep -qE '^(async-nats|nats) '; then \
echo "❌ nats crates in the default server/worker tree:"; \
echo "$$tree" | grep -E '^(async-nats|nats) ' | sort -u; \
exit 1; \
fi; \
if ! echo "$$nats_tree" | grep -qE '^async-nats '; then \
echo "❌ async-nats missing from the --features nats server/worker tree — feature is not wired"; \
exit 1; \
fi; \
echo "✅ async-nats only appears with --features nats"
check-no-second-web-stack:
@for f in "" "--features nats"; do \
tree=$$($(CARGO_TREE_NAMES) $$f) || exit 1; \
hits=$$(echo "$$tree" | grep -E '^(actix|aws-lc)' | sort -u); \
if [ -n "$$hits" ]; then \
echo "❌ actix/aws-lc crates in the server/worker tree ($${f:-default}):"; \
echo "$$hits"; \
exit 1; \
fi; \
done; \
echo "✅ no actix-* or aws-lc-* in the default or nats server/worker tree"
fmt:
cargo fmt
fmt-check:
cargo fmt --check
clippy:
cargo clippy --workspace --all-targets -- -D warnings
test:
cargo test --workspace
test-federation-off:
cargo test --workspace --no-default-features
fix:
cargo fmt
cargo clippy --fix --allow-dirty --allow-staged
# Run the dev stack:
# server (:3000, in the foreground, Ctrl-C stops it)
# worker (background, log at /tmp/movies-diary-worker.log)
# spa (:5173, background, log at /tmp/movies-diary-spa.log)
# Tear down the background processes with `make dev-stop`.
# .env enables EVENT_BUS_BACKEND=nats, so both binaries are built with the nats feature.
dev: dev-worker dev-spa
cargo run -p server --features nats
dev-stop:
-pkill -f '[t]arget.debug.worker' || true
-pkill -f '[s]pa/node_modules/[.]bin/vite' || true
@echo "dev stack stopped"
dev-worker:
@bash -c 'nohup cargo run -p worker --features nats >> /tmp/movies-diary-worker.log 2>&1 & echo "worker → pid $$! (log: /tmp/movies-diary-worker.log)"'
dev-spa:
@bash -c 'nohup npm --prefix spa run dev >> /tmp/movies-diary-spa.log 2>&1 & echo "spa → pid $$! (log: /tmp/movies-diary-spa.log, http://localhost:5173)"'
spa-dev:
@npm --prefix spa run dev
.PHONY: check fmt fmt-check clippy test test-federation-off fix dev dev-stop dev-worker dev-spa spa-dev check-appcontext check-handler-deps check-handler-repos check-handler-services check-http-adapter-deps check-federation-off-social check-sqlite-is-ap-free check-binaries-are-thin check-federation-off-deps check-nats-isolated check-no-second-web-stack

329
README.md
View File

@@ -1,330 +1,85 @@
# Movies Diary
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.
[![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-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.
---
A self-hosted, server-side rendered movie logging system. Built in Rust — no JavaScript, no SPA, just HTML forms and an RSS feed. Designed to run as a lightweight widget embedded on a personal site.
## Features
- Log movies with a TMDB/OMDb ID or manual title/year/director, with a 1–5 rating and optional watch medium (cinema, streaming, TV, physical media, download, media server)
- Edit reviews after the fact — update rating, comment, date, or watch medium via partial PATCH; each watch is still a separate record (re-watches tracked)
- Spoiler Flag — authors mark a review as containing spoilers; the comment is veiled (blur + reveal) for every viewer except the author, withheld from RSS, and federates as `containsSpoilers` + an AP `summary` content warning so Mastodon-style clients also collapse it
- Background poster fetching and storage (local filesystem or S3-compatible)
- Movie enrichment via TMDb — full cast, crew, genres, keywords, runtime, budget/revenue, ratings; fetched automatically on movie discovery and refreshed every 30 days; exposed via `GET /api/v1/movies/{id}/profile`
- Full-text search across movies and people via `GET /api/v1/search` — free-text query plus structured filters (genre, year, person, department, language); backed by SQLite FTS5
- People as first-class entities — browse by person via `GET /api/v1/people/{id}` and full credit history via `GET /api/v1/people/{id}/credits`; index populated automatically during TMDb enrichment
- RSS/Atom feed for public subscription (global and per-user)
- Log movies with a TMDB/OMDb ID and a 0–5 rating
- Immutable append-only viewing ledger (tracks re-watches)
- Background poster fetching and storage (S3-compatible)
- RSS/Atom feed for public subscription
- 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 movie poster image attachment, `#MoviesDiary` + `#MovieTitle` hashtags, paginated outbox (reviews, watchlist entries, goals), boost/Announce tracking, NodeInfo discovery endpoint, shared inbox delivery, actor profile sync (bio, avatar, discoverable); account migration via `Move` activity; account deletion broadcasts `Delete` actor to followers
- Federation moderation — instance-level domain blocking (admin-managed), per-user actor blocking with `Block` / `Undo Block` activities, 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; account deletion broadcasts AP `Delete` actor activity; `alsoKnownAs` change triggers AP `Move` for account migration
- 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, watch medium breakdown, rewatches, budget analysis); directors/actors filtered by minimum watch count for statistical relevance; 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)
- Profile trends — top directors, genre breakdown, rating distribution histogram, watch medium breakdown, monthly activity chart; all computed from the user's review history
- Account deletion — self-service (own password) or admin (admin's password) from the settings page, profile page, or `DELETE /api/v1/users/me` / `DELETE /api/v1/users/{id}`; the account is tombstoned at once, then a background purge erases its data and tells the fediverse; usernames are never reused (ADR-0016)
- CSV and JSON diary export
- File importer: upload CSV, TSV, JSON, or XLSX from any source (Letterboxd, IMDb, etc.), map columns to domain fields via a step-by-step wizard or REST API, save mapping profiles for repeat imports
- REST API v1 (`/api/v1/`) with full feature parity with the HTML interface
- OpenAPI documentation at `/docs` (Swagger UI)
- 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`)
- 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) |
- Zero JavaScript
## Architecture
Hexagonal (Ports & Adapters) with Domain-Driven Design:
```
api-types — shared REST API request/response DTOs (Serialize/Deserialize + utoipa schemas) + HtmlPageContext; used by http-axum and tui
config — typed configuration (defaults, config.toml, legacy env names); loaded once by the binaries
bootstrap — composition root: the only crate that names adapters; builds the server router and the worker service
event-payload — serde payloads for domain events, shared by the NATS and SQLite-queue adapters
domain — pure types and CQRS port traits (MovieCommand/MovieQuery, WatchEventCommand/WatchEventQuery, GoalCommand/GoalQuery, DiaryQuery, PersonCommand/PersonQuery, SearchCommand/SearchPort, SocialCommand/SocialQuery, ImageFetcher, RssFeedRenderer), no external deps except serde
application — use cases (commands + queries), business logic orchestration; handlers delegate here for all domain logic; modules: auth, diary, goals, import, insights, integrations, jobs, movies, person, search, social, users, watchlist, wrapup
server — thin HTTP binary (dotenv, config, bootstrap::build_server, axum::serve); features (federation, nats) forward to bootstrap
worker — thin worker binary (bootstrap::build_worker, run until ctrl-c)
domain — pure types and trait definitions, no external deps
application — use cases / business logic orchestration
presentation — Axum HTTP router, wires all adapters together
adapters/
http-axum — Axum HTTP adapter: JSON API, classic HTML UI (askama templates), OpenAPI spec, Swagger UI, federation-gated routes; library crate, no binary of its own
auth — JWT issuance and validation (Argon2 passwords)
sqlite — SQLite repositories (one module per aggregate under repositories/), search (FTS5), social graph, durable event queue, connection factory + migrations; the only storage adapter
metadata — TMDB / OMDb HTTP client
poster-fetcher — downloads poster images
object-storage — stores images (posters + user avatars) on local filesystem or S3-compatible storage
image-converter — optional background worker: converts stored images to AVIF or WebP; backfills existing images via a 24h periodic job
tmdb-enrichment — TMDb HTTP client implementing MovieEnrichmentClient and PersonEnrichmentClient; event handlers (MovieEnrichmentHandler, PersonEnrichmentHandler) live in the application layer
rss — RSS/Atom feed generation
export — CSV and JSON diary serialization
importer — CSV/TSV/JSON/XLSX parser and column mapper for bulk import
media-server — Jellyfin and Plex webhook payload parsers (MediaServerParser adapter; Plex requires Plex Pass)
event-publisher — in-memory event channel
nats — NATS Core / JetStream event publisher and consumer
activitypub — ActivityPub federation adapter (follow, inbox/outbox, actor); delegates to k-ap for protocol internals
sqlite-federation — SQLite-backed federation repository (k-ap trait implementations; only built with the `federation` feature)
tui — terminal UI client (ratatui); shares api-types with http-axum for typed API access
spa/ — React SPA (TanStack Router + shadcn/ui + Vite); served at /app/ by the backend
auth — JWT issuance and validation (Argon2 passwords)
sqlite — SQLite repository via sqlx
metadata — OMDb HTTP client
poster-fetcher — downloads poster images
poster-storage — uploads posters to S3-compatible storage
template-askama — Askama HTML rendering
rss — RSS/Atom feed generation
event-publisher — async event channel for background poster sync
```
## Prerequisites
- Rust (stable, 2024 edition)
- SQLite
- Poster storage: local filesystem (zero deps) or an S3-compatible object store (e.g. MinIO)
- An S3-compatible object store (e.g. MinIO) for poster storage
- An [OMDb API key](https://www.omdbapi.com/apikey.aspx)
## Configuration
## Environment Variables
Settings are layered, later sources winning:
Copy and fill in the following (e.g. in a `.env` file):
1. built-in defaults
2. `config.toml` in the working directory, or the file named by `CONFIG_FILE` (a named file that does not exist is an error; the default `config.toml` is optional)
3. environment variables (including those from `.env`), under the names in the table below
```env
# Database
DATABASE_URL=sqlite://movies.db
Copy `.env.example` to `.env` and set the values you need. Malformed values (a non-numeric `PORT`, an unknown `EVENT_BUS_BACKEND`) fail startup with a message naming the setting. An empty value (`PORT=`) counts as unset, so the default applies. The variable names are unchanged from earlier releases; every one except `RUST_LOG` and `CONFIG_FILE` maps to a `section.field` key in `config.toml` (for example `PORT` is `server.port`, `JWT_SECRET` is `auth.jwt_secret`).
# Authentication
JWT_SECRET=change-me
JWT_TTL_SECONDS=86400
| Variable | Default | Required | Description |
|---|---|---|---|
| `DATABASE_URL` | — | Yes | SQLite connection string (e.g. `sqlite://movies.db`); the only value the config loader itself requires |
| `JWT_SECRET` | — | Server | Secret for JWT signing — use a long random string. The server refuses to start without it (the worker does not need it) |
| `OMDB_API_KEY` | — | One of the two | [OMDb](https://www.omdbapi.com/apikey.aspx) key for movie metadata. Server and worker refuse to start unless `TMDB_API_KEY` or `OMDB_API_KEY` is set |
| `TMDB_API_KEY` | — | One of the two | [TMDb](https://www.themoviedb.org/settings/api) key — used for metadata when set (takes precedence over OMDb); enables cast, crew, genres, enrichment |
| `BASE_URL` | `http://localhost:3000` | No | Public URL of your instance (used for ActivityPub actor URLs); set it for any real deployment |
| `IMAGE_STORAGE_BACKEND` | `local` | No | `local` or `s3` |
| `IMAGE_STORAGE_PATH` | `./images` | No | Path for local image storage |
| `MINIO_ENDPOINT` | — | S3 only | S3-compatible endpoint (e.g. `http://localhost:9000`) |
| `MINIO_BUCKET` | — | S3 only | Bucket name |
| `MINIO_REGION` | `minio` | No | Region for S3 storage |
| `MINIO_ACCESS_KEY_ID` | — | S3 only | Access key ID |
| `MINIO_SECRET_ACCESS_KEY` | — | S3 only | Secret access key |
| `IMAGE_CONVERSION_ENABLED` | `false` | No | Set `true` to convert stored images to AVIF or WebP (only the value `true` enables it) |
| `IMAGE_CONVERSION_FORMAT` | — | When conversion is enabled | `avif` or `webp`; the worker fails at startup if conversion is enabled without it |
| `HOST` | `0.0.0.0` | No | Bind address |
| `PORT` | `3000` | No | HTTP port |
| `RATE_LIMIT` | `60` | No | Requests per minute per IP |
| `ALLOW_REGISTRATION` | `false` | No | Set `true` (or `1`) to allow new sign-ups |
| `SECURE_COOKIES` | `false` | No | Set `true` when serving over HTTPS (only the value `true` enables it) |
| `RUST_LOG` | — | No | Log verbosity (e.g. `server=info,worker=info,bootstrap=info,config=info,http_axum=info`); read by the logger, not part of `config.toml` |
| `CORS_ORIGINS` | empty | No | Comma-separated allowed origins for the API; empty or `*` allows any origin |
| `EVENT_BUS_BACKEND` | `db` | No | `db` (default) or `nats` |
| `NATS_URL` | — | NATS only | NATS connection URL (e.g. `nats://localhost:4222`) |
# OMDb metadata
OMDB_API_KEY=your-key
`DATABASE_URL` is the only setting the loader requires; the other "required" entries are enforced when the corresponding adapter is built, and failing there also stops startup. Further optional variables (`CONFIG_FILE`, `JWT_TTL_SECONDS`, `REFRESH_TTL_SECONDS`, `POSTER_FETCH_TIMEOUT_SECONDS`, `EVENT_CHANNEL_BUFFER`, `EVENT_QUEUE_*`, `NATS_MODE`, `NATS_STREAM_NAME`, `NATS_SUBJECT_PREFIX`, `NATS_CONSUMER_NAME`, `FEDERATION_DEBUG`, `WRAPUP_*`) are read by `crates/config`; see `crates/config/src/` for defaults.
# Poster storage (S3-compatible)
MINIO_ENDPOINT=http://localhost:9000
MINIO_BUCKET=posters
MINIO_REGION=us-east-1
MINIO_ACCESS_KEY_ID=minioadmin
MINIO_SECRET_ACCESS_KEY=minioadmin
### Cargo features
| Feature | Default | Effect |
|---|---|---|
| `federation` | yes | ActivityPub federation (`activitypub`, `sqlite-federation`, federation routes) |
| `nats` | no | NATS event bus (`EVENT_BUS_BACKEND=nats`) |
Build without federation with `--no-default-features`. `sqlite` and `sqlite-federation` are deprecated aliases kept so old build commands still work.
### Build notes
- `cargo build` / `cargo test` build every crate except the deprecated `tui`, which sits outside `default-members`. Use `-p tui` for it; `make check` and CI use `--workspace`, so it is still linted and tested.
- Release builds use `codegen-units = 1` and `strip = true` (smaller binaries; thin LTO was measured and made builds slower and binaries larger, so it is off).
- SQLite is compiled from source (bundled `libsqlite3-sys`), so the first clean build spends about a minute on it; incremental builds and the Docker/CI caches don't repeat that.
- TLS uses rustls with the `ring` provider only; `aws-lc` and `actix` are kept out of the graph by `make check-no-second-web-stack`.
The `worker` binary must run alongside `server` to process events:
```bash
cargo run -p worker
# Optional
ALLOW_REGISTRATION=false
POSTER_FETCH_TIMEOUT_SECONDS=10
EVENT_CHANNEL_BUFFER=32
RUST_LOG=presentation=debug,tower_http=debug
```
## Run
```bash
cargo run -p server # HTTP server (0.0.0.0:3000)
cargo run -p worker # event worker (poster sync, in a separate terminal)
cargo run -p presentation
```
The worker polls the event queue and must run alongside the server to process background tasks like poster fetching. Both processes share the same database.
**Event flow.** The server never publishes events directly: every write that raises an event stores it in the `event_queue` table in the same transaction (a transactional outbox, see [ADR-0015](docs/adr/0015-transactional-outbox.md)). With `EVENT_BUS_BACKEND=db` the worker consumes those rows; with `nats` the worker relays them to JetStream (with `Nats-Msg-Id` set to the event id) and consumes them from there. Duplicates are filtered in three layers: the broker's duplicate window, a claim lease on queue rows, and a per-(event, handler) processed-event ledger. Handlers must stay idempotent.
## API
All REST endpoints are under `/api/v1/`. Authentication uses `Authorization: Bearer <token>` obtained from `POST /api/v1/auth/login`.
Interactive API documentation is available at runtime:
- **Swagger UI** — `http://localhost:3000/docs`
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
cargo run -p tui
```
Supports review logging, bulk CSV import, and diary browsing.
## Development
A `Makefile` wraps the most common dev commands:
```bash
make # make check: the full gate, same steps as CI
make fix # auto-apply fmt + clippy fixes
make fmt # apply rustfmt
make clippy # clippy with -D warnings
make test # cargo test --workspace
```
`make check` runs fmt-check, clippy, tests (default features and `--no-default-features`), and the layering guards (see `CONTRIBUTING.md`).
Server listens on `0.0.0.0:3000`.
## Test
```bash
cargo test --workspace # everything; needs no external services
cargo test -p application # business logic tests only — in-memory fakes, no database
cargo test -p domain # domain model + value object tests
cargo test -p bootstrap # route smoke, API and handler tests over the real wiring
cargo llvm-cov -p application -p domain # line coverage report (requires cargo-llvm-cov)
cargo test
```
Tests live in `crates/<crate>/tests/`; the exceptions are listed in `CODE_STYLE.md`. The `application` and `domain` crates have 400+ tests covering all use case modules (auth, diary, goals, import, insights, integrations, movies, person, search, users, watchlist, wrapup) backed by the in-memory fakes and factories in `domain::testing` (the `test-helpers` feature). They are the fastest feedback loop for business logic changes.
## Docker
### Quick start
```bash
cp .env.example .env
# Edit .env — set JWT_SECRET and OMDB_API_KEY (or TMDB_API_KEY)
docker compose up -d
```
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 `server` 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
```
Default build enables `federation`; add NATS with `--build-arg FEATURES=federation,nats`. The Dockerfile uses [cargo-chef](https://github.com/LukeMathWalker/cargo-chef): dependencies are compiled in their own layer keyed on the manifests, so a source-only change rebuilds just the workspace crates. BuildKit cache mounts keep the cargo registry between builds. No Dockerfile edit is needed when a crate is added. SQLite is the only database backend (see [ADR-0012](docs/adr/0012-sqlite-is-the-only-backend.md)).
## 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
MIT License. See [LICENSE](LICENSE).

View File

@@ -1,161 +0,0 @@
---
title: Movies Diary — Hexagonal Architecture
---
graph TB
subgraph Binaries["Binaries (thin: config + bootstrap + run)"]
SERVER["server<br/><i>load config, bootstrap::build_server, axum::serve</i>"]
WORKER["worker<br/><i>bootstrap::build_worker</i><br/>Concurrent dispatch, graceful shutdown"]
TUI["tui<br/><i>Terminal UI (deprecated)</i>"]
end
subgraph Config["config"]
CFG["AppConfig<br/><i>defaults, config.toml / CONFIG_FILE,<br/>legacy env names</i>"]
end
subgraph Application["Application Layer"]
direction TB
DEPS["Deps<br/><i>one narrow deps struct per use case</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, edit_review,<br/>get_diary, get_activity_feed,<br/>export"]
UC_MOVIES["movies<br/>get_movies, get_movie_profile,<br/>enrich_movie, request_enrichment,<br/>sync_poster, reindex_search,<br/>merge_duplicates"]
UC_IMPORT["import<br/>create_session, apply_mapping,<br/>execute, profiles"]
UC_USERS["users<br/>get_users, get_profile,<br/>update_profile, delete_account"]
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"]
UC_SOCIAL["social<br/>follow, unfollow,<br/>accept, reject, block"]
end
subgraph EventHandlers["Event Handlers"]
EH_MOVIE["MovieEnrichmentHandler<br/><i>on MovieEnrichmentRequested</i>"]
EH_PERSON["PersonEnrichmentHandler<br/><i>on PersonEnrichmentRequested</i>"]
EH_DISCOVER["MovieDiscoveryIndexer"]
EH_CLEANUP["SearchCleanupHandler"]
EH_REINDEX["SearchReindexHandler"]
EH_WRAPUP["WrapUpEventHandler"]
EH_POSTER["PosterSyncHandler<br/><i>poster sync</i>"]
end
subgraph Jobs["Periodic Jobs"]
JOB_IMPORT["ImportSessionCleanup"]
JOB_WATCH["WatchEventCleanup"]
JOB_STALE["EnrichmentStaleness"]
JOB_WRAPGEN["WrapUpAutoGenerate"]
JOB_WRAPCLEAN["WrapUpCleanup"]
JOB_SESSION["RefreshSessionCleanup"]
JOB_DEDUP["MovieDeduplication<br/><i>merge duplicate movie<br/>records (daily)</i>"]
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, ReviewEdit,<br/>DiaryEntry, 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["MovieCommand / MovieQuery<br/>ReviewRepository<br/>DiaryQuery / StatsRepository<br/>UserRepository / UserProfileFieldsRepository<br/>WatchlistRepository<br/>WatchEventCommand / WatchEventQuery<br/>WebhookTokenRepository<br/>ImportSessionRepository / ImportProfileRepository<br/>MovieProfileRepository<br/>WrapUpRepository / WrapUpStatsQuery<br/>GoalCommand / GoalQuery<br/>UserSettingsRepository / RefreshSessionRepository<br/>MovieDeduplicator"]
P_SERVICES["AuthService<br/>MetadataClient / MovieEnrichmentClient<br/>PersonEnrichmentClient<br/>PosterFetcherClient<br/>ImageFetcher / ObjectStorage<br/>EventPublisher / EventConsumer<br/>PasswordHasher<br/>DiaryExporter / DocumentParser<br/>RssFeedRenderer / MediaServerParser"]
P_SEARCH["SearchPort / SearchCommand<br/>PersonQuery / PersonCommand<br/>FederatedProfileQuery"]
P_FEDERATION["SocialCommand / SocialQuery<br/>FederationAdminQuery<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/>UserDeleted, UserAccountMoved,<br/>SearchReindexRequested, ...</i>"]
VO["Value Objects<br/><i>MovieId, UserId, Rating,<br/>WatchMedium, Email, Username,<br/>Password, ...</i>"]
end
subgraph ApiTypes["api-types"]
DTO["DTOs<br/><i>MovieDto, ReviewDto,<br/>FeedEntryDto, UserSummaryDto,<br/>HtmlPageContext, ...</i>"]
end
subgraph Bootstrap["bootstrap"]
BS["build_server, build_worker<br/><i>Composition root: the only crate that names adapters</i>"]
end
subgraph EventPayload["event-payload"]
EP["Serde payloads for domain events"]
end
subgraph Adapters["Adapters (implement Port Traits)"]
direction TB
subgraph Http["HTTP driving adapter"]
A_HTTP["http-axum<br/><i>JSON API, classic HTML (askama),<br/>OpenAPI, federation-gated routes,<br/>one refusal shape</i>"]
end
subgraph Storage["Storage"]
A_SQLITE["sqlite<br/><i>repositories per aggregate, FTS5 search,<br/>social graph, durable event queue, migrations</i>"]
A_OBJ["object-storage<br/><i>S3 / filesystem</i>"]
end
subgraph Messaging["Messaging"]
A_NATS["nats<br/><i>JetStream / Core</i>"]
A_EVT_PUB["event-publisher<br/><i>In-memory channel</i>"]
end
subgraph External["External Services"]
A_METADATA["metadata<br/><i>TMDB / OMDb 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; ActivityPubEventHandler<br/>(reviews, watchlist, goals, UserDeleted, UserAccountMoved)</i>"]
A_SQLITE_FED["sqlite-federation<br/><i>k_ap repositories</i>"]
end
subgraph Media["Media Processing"]
A_IMG["image-converter<br/><i>AVIF/WebP</i>"]
end
subgraph Formats["Formats"]
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>"]
A_MEDIA_SERVER["media-server<br/><i>Jellyfin + Plex webhook parsers</i>"]
end
end
%% Dependency arrows
SERVER -->|"loads"| Config
SERVER -->|"calls"| Bootstrap
WORKER -->|"loads"| Config
WORKER -->|"calls"| Bootstrap
Bootstrap -->|"wires"| Adapters
Bootstrap -->|"builds deps for"| Application
A_HTTP -->|"uses"| Application
A_HTTP -->|"maps to"| ApiTypes
TUI -->|"typed API access"| ApiTypes
Application -->|"depends on"| Domain
UC_WRAPUP -->|"delegates to"| DS_WRAPUP
Adapters -.->|"implements"| Ports
A_NATS -.->|"serialises with"| EventPayload
A_SQLITE -.->|"event queue serialises with"| EventPayload
%% Key data flows
A_HTTP ===|"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 Config app
class Bootstrap binary
class ApiTypes api

View File

@@ -1,26 +0,0 @@
[package]
name = "activitypub"
version = "0.1.0"
edition.workspace = true
[dependencies]
config = { workspace = true }
k-ap = { workspace = true }
domain = { workspace = true }
axum = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
uuid = { workspace = true, features = ["v4", "serde"] }
chrono = { workspace = true, features = ["clock", "serde"] }
anyhow = { workspace = true }
tracing = { workspace = true }
async-trait = { workspace = true }
url = { workspace = true, features = ["serde"] }
ammonia = { workspace = true }
tokio = { workspace = true, features = ["time", "sync"] }
[dev-dependencies]
tokio = { workspace = true, features = ["rt", "macros"] }
domain = { workspace = true, features = ["test-helpers"] }

View File

@@ -1,135 +0,0 @@
use std::sync::Arc;
use async_trait::async_trait;
use k_ap::{ApContentReader, ApObjectHandler, LocalObject};
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<chrono::DateTime<chrono::Utc>>,
limit: usize,
) -> anyhow::Result<Vec<LocalObject>> {
let fetch_limit = limit * 3;
let reviews = self
.review
.get_local_objects_page(user_id, before, fetch_limit)
.await?;
let watchlist = self
.watchlist
.get_local_objects_page(user_id, None, usize::MAX)
.await?;
let goals = self
.goal
.get_local_objects_page(user_id, None, usize::MAX)
.await?;
let mut all: Vec<LocalObject> = Vec::new();
all.extend(reviews);
all.extend(watchlist);
all.extend(goals);
if let Some(before_ts) = before {
all.retain(|obj| obj.published_at < before_ts);
}
all.sort_by_key(|obj| std::cmp::Reverse(obj.published_at));
all.truncate(limit);
Ok(all)
}
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_review = object.get("review").and_then(|v| v.as_bool()) == Some(true)
|| object.get("rating").is_some();
let is_watchlist = object.get("watchlistEntry").and_then(|v| v.as_bool()) == Some(true)
|| (object.get("movieTitle").is_some()
&& object.get("rating").is_none()
&& object.get("review").is_none());
let is_goal = object.get("goal").and_then(|v| v.as_bool()) == Some(true);
if is_review {
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_review = object.get("review").and_then(|v| v.as_bool()) == Some(true)
|| object.get("rating").is_some();
let is_goal = object.get("goal").and_then(|v| v.as_bool()) == Some(true);
let is_watchlist = object.get("watchlistEntry").and_then(|v| v.as_bool()) == Some(true);
if is_review {
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 if is_watchlist {
self.watchlist.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, _: &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,33 +0,0 @@
use std::sync::Arc;
use async_trait::async_trait;
use domain::{
errors::DomainError,
ports::FederationDeletionNotifier,
value_objects::{InstanceIdentity, UserId},
};
use k_ap::ActivityPubService;
use crate::urls::actor_url;
pub struct ApDeletionNotifier {
service: Arc<ActivityPubService>,
instance: InstanceIdentity,
}
impl ApDeletionNotifier {
pub fn new(service: Arc<ActivityPubService>, instance: InstanceIdentity) -> Self {
Self { service, instance }
}
}
#[async_trait]
impl FederationDeletionNotifier for ApDeletionNotifier {
async fn notify_deleted(&self, user: &UserId) -> Result<(), DomainError> {
let ap_id = actor_url(&self.instance, user.value());
self.service
.broadcast_delete_to_followers(user.value(), ap_id)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))
}
}

View File

@@ -1,565 +0,0 @@
use async_trait::async_trait;
use chrono::Datelike;
use domain::ports::EventHandler;
use domain::{
errors::DomainError,
events::DomainEvent,
ports::{
GoalQuery, LocalApContentQuery, MovieQuery, ReviewRepository, StatsRepository,
UserFederationSettingsQuery, UserRepository,
},
value_objects::{InstanceIdentity, MovieId, ReviewId, UserId},
};
use std::sync::Arc;
use k_ap::{ActivityPubService, ApVisibility};
use crate::objects::{ReviewApInput, goal_to_ap_object, review_to_ap_object};
use crate::urls::{actor_url, goal_url, review_url};
pub struct ActivityPubEventHandler {
ap_service: Arc<ActivityPubService>,
content_query: Arc<dyn LocalApContentQuery>,
review_repo: Arc<dyn ReviewRepository>,
movie_repo: Arc<dyn MovieQuery>,
goal_repo: Arc<dyn GoalQuery>,
stats_repo: Arc<dyn StatsRepository>,
federation_settings: Arc<dyn UserFederationSettingsQuery>,
user_repo: Arc<dyn UserRepository>,
instance: InstanceIdentity,
}
impl ActivityPubEventHandler {
#[allow(clippy::too_many_arguments)]
pub fn new(
ap_service: Arc<ActivityPubService>,
content_query: Arc<dyn LocalApContentQuery>,
review_repo: Arc<dyn ReviewRepository>,
movie_repo: Arc<dyn MovieQuery>,
goal_repo: Arc<dyn GoalQuery>,
stats_repo: Arc<dyn StatsRepository>,
federation_settings: Arc<dyn UserFederationSettingsQuery>,
user_repo: Arc<dyn UserRepository>,
instance: InstanceIdentity,
) -> Self {
Self {
ap_service,
content_query,
review_repo,
movie_repo,
goal_repo,
stats_repo,
federation_settings,
user_repo,
instance,
}
}
}
#[async_trait]
impl EventHandler for ActivityPubEventHandler {
fn name(&self) -> &'static str {
"activitypub"
}
async fn handle(&self, event: &DomainEvent) -> Result<(), DomainError> {
if let Some(author) = self.outbound_author(event)
&& self.is_tombstoned(&author).await
{
tracing::debug!(user = %author.value(), "skipping federation event of a deleted account");
return Ok(());
}
match event {
DomainEvent::ReviewLogged {
review_id, user_id, ..
} => self
.on_review_logged(user_id, review_id)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string())),
DomainEvent::ReviewUpdated {
review_id, user_id, ..
} => self
.on_review_updated(user_id, review_id)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string())),
DomainEvent::ReviewDeleted { review_id, user_id } => self
.on_review_deleted(user_id, review_id)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string())),
DomainEvent::UserUpdated { user_id } => self
.ap_service
.broadcast_actor_update(user_id.value())
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string())),
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}")))?;
self.ap_service
.deliver_to_inbox(inbox, activity_json.clone(), *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())),
DomainEvent::UserAccountMoved {
user_id,
new_actor_url,
} => {
let target = new_actor_url.parse::<url::Url>().map_err(|e| {
DomainError::InfrastructureError(format!("invalid new_actor_url: {e}"))
})?;
self.ap_service
.broadcast_move(user_id.value(), target)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))
}
_ => Ok(()),
}
}
}
impl ActivityPubEventHandler {
fn outbound_author(&self, event: &DomainEvent) -> Option<UserId> {
match event {
DomainEvent::ReviewLogged { user_id, .. }
| DomainEvent::ReviewUpdated { user_id, .. }
| DomainEvent::ReviewDeleted { user_id, .. }
| DomainEvent::UserUpdated { user_id }
| DomainEvent::WatchlistEntryAdded { user_id, .. }
| DomainEvent::WatchlistEntryRemoved { user_id, .. }
| DomainEvent::GoalCreated { user_id, .. }
| DomainEvent::GoalUpdated { user_id, .. }
| DomainEvent::GoalDeleted { user_id, .. }
| DomainEvent::UserAccountMoved { user_id, .. } => Some(user_id.clone()),
DomainEvent::FederationDeliveryRequested {
activity_json,
signing_actor_id,
..
} if activity_json.get("type").and_then(|t| t.as_str()) != Some("Delete") => {
Some(UserId::from_uuid(*signing_actor_id))
}
_ => None,
}
}
async fn is_tombstoned(&self, user: &UserId) -> bool {
matches!(
self.user_repo.find_tombstoned_by_id(user).await,
Ok(Some(_))
)
}
async fn on_review_logged(&self, user_id: &UserId, review_id: &ReviewId) -> anyhow::Result<()> {
let flags = self
.federation_settings
.get_federation_flags(user_id)
.await
.unwrap_or_default();
if !flags.reviews {
return Ok(());
}
let review = match self.review_repo.get_review_by_id(review_id).await? {
Some(r) => r,
None => return Ok(()),
};
let ap_id = review_url(&self.instance, review_id);
let actor = actor_url(&self.instance, user_id.value());
let movie = self
.movie_repo
.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 obj = review_to_ap_object(
&review,
ReviewApInput {
ap_id: ap_id.clone(),
actor_url: actor,
movie_title,
release_year,
external_metadata_id: movie
.as_ref()
.and_then(|m| m.external_metadata_id())
.map(|id| id.value().to_string()),
poster_url: movie
.as_ref()
.and_then(|m| m.poster_path())
.map(|p| self.instance.image_url_for(p.value())),
base_url: self.instance.base_url().to_string(),
},
);
let json = serde_json::to_value(obj)?;
self.ap_service
.broadcast_create(user_id.value(), json, ApVisibility::Public, vec![])
.await?;
let year = review.watched_at().year() as u16;
self.broadcast_goal_progress_update(user_id, year).await?;
Ok(())
}
async fn on_review_updated(
&self,
user_id: &UserId,
review_id: &ReviewId,
) -> anyhow::Result<()> {
let flags = self
.federation_settings
.get_federation_flags(user_id)
.await
.unwrap_or_default();
if !flags.reviews {
return Ok(());
}
let review = match self.review_repo.get_review_by_id(review_id).await? {
Some(r) => r,
None => return Ok(()),
};
let ap_id = review_url(&self.instance, review_id);
let actor = actor_url(&self.instance, user_id.value());
let movie = self
.movie_repo
.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 obj = review_to_ap_object(
&review,
ReviewApInput {
ap_id,
actor_url: actor,
movie_title,
release_year,
external_metadata_id: movie
.as_ref()
.and_then(|m| m.external_metadata_id())
.map(|id| id.value().to_string()),
poster_url: movie
.as_ref()
.and_then(|m| m.poster_path())
.map(|p| self.instance.image_url_for(p.value())),
base_url: self.instance.base_url().to_string(),
},
);
let json = serde_json::to_value(obj)?;
self.ap_service
.broadcast_update(user_id.value(), json, ApVisibility::Public, vec![])
.await?;
Ok(())
}
async fn on_review_deleted(
&self,
user_id: &UserId,
review_id: &ReviewId,
) -> anyhow::Result<()> {
let ap_id = review_url(&self.instance, review_id);
self.ap_service
.broadcast_delete_to_followers(user_id.value(), ap_id)
.await?;
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_default();
if !flags.watchlist {
return Ok(());
}
use crate::urls::watchlist_entry_url;
let ap_id = watchlist_entry_url(&self.instance, user_id.value(), movie_id.value());
let actor = actor_url(&self.instance, user_id.value());
let poster_url = self
.movie_repo
.get_movie_by_id(movie_id)
.await
.ok()
.flatten()
.and_then(|m| {
m.poster_path()
.map(|p| self.instance.image_url_for(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.instance.base_url().to_string(),
});
let json = serde_json::to_value(obj)?;
self.ap_service
.broadcast_create(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.instance, 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.movie_repo.get_movie_by_id(movie_id).await?;
let movie = match movie {
Some(m) => m,
None => return Ok(()),
};
let external_metadata_id = movie
.external_metadata_id()
.map(|id| id.value().to_string());
let poster_url = movie
.poster_path()
.map(|p| self.instance.image_url_for(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_default();
if !flags.reviews {
continue;
}
let ap_id = review_url(&self.instance, review.id());
let actor = actor_url(&self.instance, user_id.value());
let obj = review_to_ap_object(
review,
ReviewApInput {
ap_id,
actor_url: actor,
movie_title: movie.title().value().to_string(),
release_year: movie.release_year().value(),
external_metadata_id: external_metadata_id.clone(),
poster_url: poster_url.clone(),
base_url: self.instance.base_url().to_string(),
},
);
let json = serde_json::to_value(obj)?;
self.ap_service
.broadcast_update(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_default();
if !flags.goals {
return Ok(());
}
let Some(goal) = self
.goal_repo
.find_by_user_and_year(user_id, year)
.await
.ok()
.flatten()
else {
return Ok(());
};
let current = self
.stats_repo
.count_reviews_in_year(user_id, year)
.await
.unwrap_or(0);
let ap_id = goal_url(&self.instance, user_id.value(), year);
let actor = actor_url(&self.instance, user_id.value());
let obj = goal_to_ap_object(
ap_id,
actor,
year,
goal.target_count(),
current,
self.instance.base_url(),
);
let json = serde_json::to_value(obj)?;
self.ap_service
.broadcast_update(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_default();
if !flags.goals {
return Ok(());
}
let current = self
.stats_repo
.count_reviews_in_year(user_id, year)
.await
.unwrap_or(0);
let ap_id = goal_url(&self.instance, user_id.value(), year);
let actor = actor_url(&self.instance, user_id.value());
let obj = goal_to_ap_object(
ap_id,
actor,
year,
target_count,
current,
self.instance.base_url(),
);
let json = serde_json::to_value(obj)?;
if is_create {
self.ap_service
.broadcast_create(user_id.value(), json, ApVisibility::Public, vec![])
.await?;
} else {
self.ap_service
.broadcast_update(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_default();
if !flags.goals {
return Ok(());
}
let ap_id = goal_url(&self.instance, user_id.value(), year);
self.ap_service
.broadcast_delete_to_followers(user_id.value(), ap_id)
.await?;
Ok(())
}
}

View File

@@ -1,72 +0,0 @@
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,
} => self
.domain_publisher
.publish(&DomainEvent::FederationDeliveryRequested {
inbox_url: inbox.to_string(),
activity_json: activity,
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(())
}
FederationEvent::OutboundFollowAccepted {
local_user_id,
remote_actor_url,
outbox_url,
} => {
let identity = domain::value_objects::SocialIdentity::Remote {
actor_url: remote_actor_url,
};
self.domain_publisher
.publish(&DomainEvent::FollowAccepted {
owner: UserId::from_uuid(local_user_id),
requester: identity,
})
.await
.map_err(|e| anyhow::anyhow!(e.to_string()))?;
if let Some(outbox) = outbox_url {
tracing::info!(outbox = %outbox, "importing remote outbox after follow accepted");
}
Ok(())
}
}
}
}

View File

@@ -1,133 +0,0 @@
use std::sync::Arc;
use async_trait::async_trait;
use domain::{
errors::DomainError,
models::{BlockedDomainInfo, FollowedActorInfo},
ports::{ApBackfillPort, ApDocumentPort, InstanceBlocklistPort, UserRepository},
value_objects::UserId,
};
use k_ap::ActivityPubService;
use uuid::Uuid;
pub struct ApServiceAdapter {
service: Arc<ActivityPubService>,
users: Arc<dyn UserRepository>,
}
impl ApServiceAdapter {
pub fn new(service: Arc<ActivityPubService>, users: Arc<dyn UserRepository>) -> Self {
Self { service, users }
}
}
fn ap_err(e: anyhow::Error) -> DomainError {
tracing::error!("ActivityPub error: {:?}", e);
DomainError::InfrastructureError(e.to_string())
}
#[async_trait]
impl ApDocumentPort for ApServiceAdapter {
async fn actor_is_gone(&self, user_id: Uuid) -> Result<bool, DomainError> {
Ok(self
.users
.find_tombstoned_by_id(&UserId::from_uuid(user_id))
.await?
.is_some())
}
async fn actor_json(&self, user_id: &str) -> Result<String, DomainError> {
self.service.actor_json(user_id).await.map_err(ap_err)
}
async fn followers_collection_json(
&self,
user_id: Uuid,
page: Option<u32>,
) -> Result<String, DomainError> {
self.service
.followers_collection_json(user_id, page)
.await
.map_err(ap_err)
}
async fn following_collection_json(
&self,
user_id: Uuid,
page: Option<u32>,
) -> Result<String, DomainError> {
self.service
.following_collection_json(user_id, page)
.await
.map_err(ap_err)
}
}
#[async_trait]
impl InstanceBlocklistPort for ApServiceAdapter {
async fn get_blocked_domains(&self) -> Result<Vec<BlockedDomainInfo>, DomainError> {
let domains = self.service.get_blocked_domains().await.map_err(ap_err)?;
Ok(domains
.into_iter()
.map(|d| BlockedDomainInfo {
domain: d.domain,
reason: d.reason,
blocked_at: d.blocked_at,
})
.collect())
}
async fn add_blocked_domain(
&self,
domain: &str,
reason: Option<&str>,
) -> Result<(), DomainError> {
self.service
.add_blocked_domain(domain, reason)
.await
.map_err(ap_err)
}
async fn remove_blocked_domain(&self, domain: &str) -> Result<(), DomainError> {
self.service
.remove_blocked_domain(domain)
.await
.map_err(ap_err)
}
}
#[async_trait]
impl ApBackfillPort for ApServiceAdapter {
async fn get_following(
&self,
local_user_id: Uuid,
) -> Result<Vec<FollowedActorInfo>, DomainError> {
let actors = self
.service
.get_following(local_user_id)
.await
.map_err(ap_err)?;
Ok(actors
.into_iter()
.map(|a| FollowedActorInfo {
url: a.url,
outbox_url: a.outbox_url,
})
.collect())
}
async fn import_remote_outbox(
&self,
outbox_url: &str,
actor_url: &str,
) -> Result<(), DomainError> {
self.service
.import_remote_outbox(outbox_url, actor_url)
.await
.map_err(ap_err)
}
async fn run_backfill_for_follower(
&self,
owner_user_id: Uuid,
follower_inbox_url: String,
) -> Result<(), DomainError> {
self.service
.run_backfill_for_follower(owner_user_id, follower_inbox_url)
.await
.map_err(ap_err)
}
}

View File

@@ -1,150 +0,0 @@
use std::sync::Arc;
use async_trait::async_trait;
use chrono::DateTime;
use domain::{
models::RemoteGoalEntry,
ports::{GoalQuery, RemoteGoalRepository},
value_objects::{InstanceIdentity, UserId},
};
use k_ap::{AS_PUBLIC, ApContentReader, ApObjectHandler, LocalObject};
use url::Url;
use crate::objects::{GoalObject, goal_to_ap_object};
use crate::urls::{actor_url, goal_url};
pub struct GoalObjectHandler {
pub remote_goal_repo: Arc<dyn RemoteGoalRepository>,
pub goal_repo: Arc<dyn GoalQuery>,
pub instance: InstanceIdentity,
}
#[async_trait]
impl ApContentReader for GoalObjectHandler {
async fn get_local_objects_page(
&self,
user_id: uuid::Uuid,
_before: Option<DateTime<chrono::Utc>>,
_limit: usize,
) -> anyhow::Result<Vec<LocalObject>> {
let uid = UserId::from_uuid(user_id);
let goals = self
.goal_repo
.list_for_user(&uid)
.await
.map_err(|e| anyhow::anyhow!(e.to_string()))?;
let actor = actor_url(&self.instance, user_id);
let follower_cc = format!("{}/followers", actor);
let mut results = Vec::new();
for goal in goals {
let ap_id = goal_url(&self.instance, user_id, goal.year());
let published = DateTime::from_naive_utc_and_offset(*goal.created_at(), chrono::Utc);
let obj = goal_to_ap_object(
ap_id.clone(),
actor.clone(),
goal.year(),
goal.target_count(),
0,
self.instance.base_url(),
);
results.push(LocalObject {
ap_id,
object: serde_json::to_value(obj)?,
published_at: published,
to: vec![AS_PUBLIC.to_string()],
cc: vec![follower_cc.clone()],
bto: vec![],
bcc: vec![],
});
}
Ok(results)
}
async fn count_local_posts(&self) -> anyhow::Result<u64> {
Ok(0)
}
}
#[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,201 +0,0 @@
pub mod composite_handler;
pub mod deletion_notifier;
pub mod event_handler;
pub mod federation_event_bridge;
pub mod federation_ports;
pub mod goal_handler;
pub mod objects;
pub mod remote_review_repository;
pub mod review_handler;
pub mod social_adapter;
pub mod tombstone_gate;
pub(crate) mod urls;
pub mod user_adapter;
pub mod watchlist_handler;
pub const INSTANCE_ACTOR_ID: uuid::Uuid = domain::value_objects::SERVICE_ACTOR_UUID;
pub use k_ap::{
ActivityPubService, ActivityRepository, ActorRepository, ApContentReader, ApFederationConfig,
ApObjectHandler, ApUser, ApUserRepository, BlocklistRepository, FederationData,
FollowRepository, Follower, FollowerStatus, FollowingStatus, LocalObject, RemoteActor,
};
pub use deletion_notifier::ApDeletionNotifier;
pub use event_handler::ActivityPubEventHandler;
pub use federation_ports::ApServiceAdapter;
pub use remote_review_repository::{NewRemoteReview, RemoteReviewRepository, RemoteReviewUpdate};
pub use review_handler::ReviewObjectHandler;
pub use social_adapter::CompositeSocialAdapter;
pub use user_adapter::DomainUserRepoAdapter;
pub struct FederationRepos {
pub activity: std::sync::Arc<dyn ActivityRepository>,
pub follow: std::sync::Arc<dyn FollowRepository>,
pub actor: std::sync::Arc<dyn ActorRepository>,
pub blocklist: std::sync::Arc<dyn BlocklistRepository>,
pub admin_query: std::sync::Arc<dyn domain::ports::FederationAdminQuery>,
pub review_store: std::sync::Arc<dyn RemoteReviewRepository>,
pub remote_watchlist: std::sync::Arc<dyn domain::ports::RemoteWatchlistRepository>,
pub follow_command: std::sync::Arc<dyn domain::ports::FollowCommand>,
pub follow_query: std::sync::Arc<dyn domain::ports::FollowQuery>,
}
pub struct ActivityPubWire {
pub document: std::sync::Arc<dyn domain::ports::ApDocumentPort>,
pub blocklist: std::sync::Arc<dyn domain::ports::InstanceBlocklistPort>,
pub backfill: std::sync::Arc<dyn domain::ports::ApBackfillPort>,
pub service: std::sync::Arc<ActivityPubService>,
pub router: axum::Router,
pub event_handler: std::sync::Arc<dyn domain::ports::EventHandler>,
pub deletion_notifier: std::sync::Arc<dyn domain::ports::FederationDeletionNotifier>,
}
pub struct ActivityPubDeps {
pub activity_repo: std::sync::Arc<dyn ActivityRepository>,
pub follow_repo: std::sync::Arc<dyn FollowRepository>,
pub actor_repo: std::sync::Arc<dyn ActorRepository>,
pub blocklist_repo: std::sync::Arc<dyn BlocklistRepository>,
pub review_store: std::sync::Arc<dyn RemoteReviewRepository>,
pub remote_watchlist_repo: std::sync::Arc<dyn domain::ports::RemoteWatchlistRepository>,
pub remote_goal_repo: std::sync::Arc<dyn domain::ports::RemoteGoalRepository>,
pub local_ap_content: std::sync::Arc<dyn domain::ports::LocalApContentQuery>,
pub movie_repo: std::sync::Arc<dyn domain::ports::MovieQuery>,
pub review_repo: std::sync::Arc<dyn domain::ports::ReviewRepository>,
pub diary_repo: std::sync::Arc<dyn domain::ports::DiaryQuery>,
pub goal_repo: std::sync::Arc<dyn domain::ports::GoalQuery>,
pub stats_repo: std::sync::Arc<dyn domain::ports::StatsRepository>,
pub user_repo: std::sync::Arc<dyn domain::ports::UserRepository>,
pub federation_settings: std::sync::Arc<dyn domain::ports::UserFederationSettingsQuery>,
pub follow_command: std::sync::Arc<dyn domain::ports::FollowCommand>,
pub follow_query: std::sync::Arc<dyn domain::ports::FollowQuery>,
pub instance: domain::value_objects::InstanceIdentity,
pub allow_registration: bool,
pub federation: config::FederationConfig,
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,
movie_repo,
review_repo,
diary_repo,
goal_repo,
stats_repo,
user_repo,
federation_settings,
follow_command: _,
follow_query: _,
instance,
allow_registration,
federation,
event_publisher,
} = deps;
let review_handler = std::sync::Arc::new(ReviewObjectHandler {
content_query: std::sync::Arc::clone(&local_ap_content),
movie_repo: std::sync::Arc::clone(&movie_repo),
diary_repo,
review_store,
instance: instance.clone(),
});
let watchlist_handler = std::sync::Arc::new(watchlist_handler::WatchlistObjectHandler {
remote_watchlist_repo,
content_query: std::sync::Arc::clone(&local_ap_content),
instance: instance.clone(),
});
let goal_handler = std::sync::Arc::new(goal_handler::GoalObjectHandler {
remote_goal_repo,
goal_repo: std::sync::Arc::clone(&goal_repo),
instance: instance.clone(),
});
let composite = std::sync::Arc::new(composite_handler::CompositeObjectHandler {
review: review_handler,
watchlist: watchlist_handler,
goal: goal_handler,
});
let federation_debug = federation.debug;
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(
ActivityPubService::builder(instance.base_url().to_string())
.activity_repo(activity_repo)
.follow_repo(follow_repo)
.actor_repo(actor_repo)
.blocklist_repo(blocklist_repo)
.user_repo(std::sync::Arc::new(DomainUserRepoAdapter::new(
std::sync::Arc::clone(&user_repo),
instance.clone(),
)))
.signed_fetch_actor_id(INSTANCE_ACTOR_ID)
.content_reader(composite.clone() as std::sync::Arc<dyn ApContentReader>)
.object_handler(composite as std::sync::Arc<dyn ApObjectHandler>)
.event_publisher(fed_event_bridge)
.allow_registration(allow_registration)
.software_name("movies-diary")
.nodeinfo_metadata(serde_json::json!({
"nodeName": "movies-diary",
"nodeDescription": "A federated movie diary"
}))
.debug(federation_debug)
.build()
.await?,
);
let router = concrete
.router()
.layer(axum::middleware::from_fn_with_state(
std::sync::Arc::clone(&user_repo),
tombstone_gate::tombstone_gate,
));
let deletion_notifier = std::sync::Arc::new(ApDeletionNotifier::new(
std::sync::Arc::clone(&concrete),
instance.clone(),
)) as std::sync::Arc<dyn domain::ports::FederationDeletionNotifier>;
let event_handler = std::sync::Arc::new(ActivityPubEventHandler::new(
std::sync::Arc::clone(&concrete),
local_ap_content,
review_repo,
movie_repo,
goal_repo,
stats_repo,
federation_settings,
std::sync::Arc::clone(&user_repo),
instance,
)) as std::sync::Arc<dyn domain::ports::EventHandler>;
let ports = std::sync::Arc::new(federation_ports::ApServiceAdapter::new(
std::sync::Arc::clone(&concrete),
std::sync::Arc::clone(&user_repo),
));
Ok(ActivityPubWire {
document: std::sync::Arc::clone(&ports)
as std::sync::Arc<dyn domain::ports::ApDocumentPort>,
blocklist: std::sync::Arc::clone(&ports)
as std::sync::Arc<dyn domain::ports::InstanceBlocklistPort>,
backfill: ports as std::sync::Arc<dyn domain::ports::ApBackfillPort>,
service: concrete,
router,
event_handler,
deletion_notifier,
})
}

View File

@@ -1,308 +0,0 @@
use chrono::{DateTime, Utc};
use k_ap::AS_PUBLIC;
use serde::{Deserialize, Serialize};
use url::Url;
use domain::models::Review;
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub(crate) enum ActivityStreamsType {
#[default]
Note,
Article,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ApAttachment {
#[serde(rename = "type")]
pub(crate) kind: String,
pub(crate) url: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) media_type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) name: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ApHashtag {
#[serde(rename = "type")]
pub(crate) kind: String,
pub(crate) href: Url,
pub(crate) name: String,
}
pub(crate) fn normalize_hashtag(title: &str) -> String {
title.chars().filter(|c| c.is_alphanumeric()).collect()
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ReviewObject {
#[serde(rename = "type")]
pub(crate) kind: ActivityStreamsType,
pub(crate) id: Url,
pub(crate) attributed_to: Url,
pub(crate) content: String,
pub(crate) published: DateTime<Utc>,
pub(crate) movie_title: String,
#[serde(default)]
pub(crate) release_year: u16,
#[serde(default)]
pub(crate) external_metadata_id: Option<String>,
#[serde(default)]
pub(crate) poster_url: Option<String>,
pub(crate) rating: u8,
pub(crate) comment: Option<String>,
pub(crate) watched_at: DateTime<Utc>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub(crate) watch_medium: Option<String>,
/// Discriminator so Movies Diary instances detect this as a review Note.
#[serde(default)]
pub(crate) review: bool,
/// Spoiler Flag. Movies-Diary peers veil the comment behind this boolean;
/// other fediverse apps render `summary` as a content warning (ADR-0011).
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub(crate) contains_spoilers: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) summary: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub(crate) attachment: Vec<ApAttachment>,
#[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 struct ReviewApInput {
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 base_url: String,
}
pub fn review_to_ap_object(review: &Review, input: ReviewApInput) -> ReviewObject {
let ReviewApInput {
ap_id,
actor_url,
movie_title,
release_year,
external_metadata_id,
poster_url,
base_url,
} = input;
let stars: String = "\u{2B50}".repeat(review.rating().value() as usize);
let comment_text = review.comment().map(|c| c.value().to_string());
let year_str = if release_year > 0 {
format!(" ({})", release_year)
} else {
String::new()
};
let watched_str = format!("Watched: {}", review.watched_at().format("%b %-d, %Y"));
let spoiler_flag = review.spoilers() && comment_text.is_some();
let content = match &comment_text {
Some(c) => format!(
"{} {}{}\n{}\n{}",
stars, movie_title, year_str, c, watched_str
),
None => format!("{} {}{}\n{}", stars, movie_title, year_str, watched_str),
};
let normalized = normalize_hashtag(&movie_title);
let tag = vec![
ApHashtag {
kind: "Hashtag".to_string(),
href: Url::parse(&format!("{}/tags/moviesdiary", base_url)).expect("valid base_url"),
name: "#MoviesDiary".to_string(),
},
ApHashtag {
kind: "Hashtag".to_string(),
href: Url::parse(&format!("{}/tags/{}", base_url, normalized.to_lowercase()))
.expect("valid base_url"),
name: format!("#{}", normalized),
},
];
let attachment = match &poster_url {
Some(url) => vec![ApAttachment {
kind: "Image".to_string(),
url: url.clone(),
media_type: Some("image/jpeg".to_string()),
name: Some(movie_title.clone()),
}],
None => vec![],
};
ReviewObject {
kind: ActivityStreamsType::default(),
id: ap_id,
attributed_to: actor_url.clone(),
content,
published: DateTime::from_naive_utc_and_offset(*review.created_at(), Utc),
movie_title,
release_year,
external_metadata_id,
poster_url,
rating: review.rating().value(),
comment: comment_text,
watched_at: DateTime::from_naive_utc_and_offset(*review.watched_at(), Utc),
watch_medium: review.watch_medium().map(|wm| wm.to_string()),
review: true,
contains_spoilers: spoiler_flag,
summary: spoiler_flag.then(|| "Contains spoilers".to_string()),
attachment,
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: ActivityStreamsType,
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: ActivityStreamsType::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: ActivityStreamsType,
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: ActivityStreamsType::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,36 +0,0 @@
use anyhow::Result;
use async_trait::async_trait;
use chrono::NaiveDateTime;
use domain::{events::DomainEvent, models::Review};
pub struct RemoteReviewUpdate<'a> {
pub ap_id: &'a str,
pub actor_url: &'a str,
pub rating: u8,
pub comment: Option<&'a str>,
pub watched_at: NaiveDateTime,
pub poster_url: Option<&'a str>,
pub watch_medium: Option<&'a str>,
pub spoilers: bool,
}
pub struct NewRemoteReview<'a> {
pub review: &'a Review,
pub ap_id: &'a str,
pub movie_title: &'a str,
pub release_year: u16,
pub external_metadata_id: Option<&'a str>,
pub poster_url: Option<&'a str>,
pub events: &'a [DomainEvent],
}
#[async_trait]
pub trait RemoteReviewRepository: Send + Sync {
async fn save_remote_review(&self, new: NewRemoteReview<'_>) -> Result<()>;
async fn delete_remote_review(&self, ap_id: &str, actor_url: &str) -> Result<()>;
async fn update_remote_review(&self, update: RemoteReviewUpdate<'_>) -> Result<()>;
async fn delete_by_actor(&self, actor_url: &str) -> Result<()>;
}

View File

@@ -1,268 +0,0 @@
use std::sync::Arc;
use async_trait::async_trait;
use domain::{
events::DomainEvent,
models::ReviewSource,
ports::{DiaryQuery, LocalApContentQuery, MovieQuery},
value_objects::{
Comment, ExternalMetadataId, InstanceIdentity, MovieId, Rating, ReviewId, UserId,
},
};
use k_ap::{AS_PUBLIC, ApContentReader, ApObjectHandler, LocalObject};
use url::Url;
use crate::objects::{ReviewApInput, ReviewObject, review_to_ap_object};
use crate::remote_review_repository::{NewRemoteReview, RemoteReviewRepository};
use crate::urls::{actor_url, review_url};
pub struct ReviewObjectHandler {
pub content_query: Arc<dyn LocalApContentQuery>,
pub movie_repo: Arc<dyn MovieQuery>,
pub diary_repo: Arc<dyn DiaryQuery>,
pub review_store: Arc<dyn RemoteReviewRepository>,
pub instance: InstanceIdentity,
}
#[async_trait]
impl ApContentReader for ReviewObjectHandler {
async fn get_local_objects_page(
&self,
user_id: uuid::Uuid,
before: Option<chrono::DateTime<chrono::Utc>>,
limit: usize,
) -> anyhow::Result<Vec<LocalObject>> {
let domain_user_id = UserId::from_uuid(user_id);
let before_naive = before.map(|dt| dt.naive_utc());
let entries = self
.content_query
.get_local_reviews_page(&domain_user_id, before_naive, limit)
.await
.map_err(|e| anyhow::anyhow!(e.to_string()))?;
let actor = actor_url(&self.instance, user_id);
let mut results = Vec::new();
for entry in entries {
let review = entry.review();
let published =
chrono::DateTime::from_naive_utc_and_offset(*review.watched_at(), chrono::Utc);
let movie = entry.movie();
let ap_id = review_url(&self.instance, review.id());
let poster_url = movie
.poster_path()
.map(|p| self.instance.image_url_for(p.value()));
let obj = review_to_ap_object(
review,
ReviewApInput {
ap_id: ap_id.clone(),
actor_url: actor.clone(),
movie_title: movie.title().value().to_string(),
release_year: movie.release_year().value(),
external_metadata_id: movie
.external_metadata_id()
.map(|id| id.value().to_string()),
poster_url,
base_url: self.instance.base_url().to_string(),
},
);
let follower_cc = format!("{}/followers", actor);
results.push(LocalObject {
ap_id,
object: serde_json::to_value(obj)?,
published_at: published,
to: vec![AS_PUBLIC.to_string()],
cc: vec![follower_cc],
bto: vec![],
bcc: vec![],
});
}
Ok(results)
}
async fn count_local_posts(&self) -> anyhow::Result<u64> {
self.diary_repo
.count_local_posts()
.await
.map_err(|e| anyhow::anyhow!(e.to_string()))
}
}
#[async_trait]
impl ApObjectHandler for ReviewObjectHandler {
async fn on_create(
&self,
_ap_id: &Url,
_actor_url: &Url,
object: serde_json::Value,
) -> anyhow::Result<()> {
let mut obj: ReviewObject = match serde_json::from_value(object) {
Ok(o) => o,
Err(e) => {
tracing::warn!("ignoring unrecognized Create object: {}", e);
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 review_id = ReviewId::generate();
let movie_id = if let Some(ref ext_id) = obj.external_metadata_id {
let found = if let Ok(ext_meta_id) = ExternalMetadataId::new(ext_id.clone()) {
self.movie_repo
.get_movie_by_external_id(&ext_meta_id)
.await
.ok()
.flatten()
} else {
None
};
match found {
Some(movie) => movie.id().clone(),
None => MovieId::from_uuid(uuid::Uuid::new_v5(
&uuid::Uuid::NAMESPACE_URL,
ext_id.as_bytes(),
)),
}
} else {
MovieId::from_uuid(uuid::Uuid::new_v5(
&uuid::Uuid::NAMESPACE_URL,
format!("{}:{}", obj.movie_title, obj.release_year).as_bytes(),
))
};
let user_id = UserId::from_uuid(uuid::Uuid::new_v5(
&uuid::Uuid::NAMESPACE_URL,
actor_url_str.as_bytes(),
));
let rating = Rating::new(obj.rating.min(5))?;
let comment = obj.comment.map(Comment::new).transpose()?;
let watch_medium = obj
.watch_medium
.as_deref()
.map(|s| s.parse())
.transpose()
.unwrap_or(None);
let review = domain::models::Review::from_persistence(domain::models::PersistedReview {
id: review_id,
movie_id: movie_id.clone(),
user_id,
rating,
comment,
watched_at: obj.watched_at.naive_utc(),
created_at: obj.published.naive_utc(),
source: ReviewSource::Remote {
actor_url: actor_url_str,
},
watch_medium,
spoilers: obj.contains_spoilers,
});
let events: Vec<DomainEvent> = obj
.external_metadata_id
.as_ref()
.and_then(|ext_id| ExternalMetadataId::new(ext_id.clone()).ok())
.map(
|external_metadata_id| DomainEvent::MovieEnrichmentRequested {
movie_id: movie_id.clone(),
external_metadata_id,
},
)
.into_iter()
.collect();
self.review_store
.save_remote_review(NewRemoteReview {
review: &review,
ap_id: obj.id.as_str(),
movie_title: &obj.movie_title,
release_year: obj.release_year,
external_metadata_id: obj.external_metadata_id.as_deref(),
poster_url: obj.poster_url.as_deref(),
events: &events,
})
.await?;
Ok(())
}
async fn on_update(
&self,
ap_id: &Url,
actor_url: &Url,
object: serde_json::Value,
) -> anyhow::Result<()> {
let mut obj: ReviewObject = match serde_json::from_value(object) {
Ok(o) => o,
Err(_) => {
tracing::warn!(actor = %actor_url, "ignoring non-review Update activity");
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 {
anyhow::bail!("update actor does not match object attributed_to");
}
self.review_store
.update_remote_review(crate::remote_review_repository::RemoteReviewUpdate {
ap_id: ap_id.as_str(),
actor_url: actor_url.as_str(),
rating: obj.rating.min(5),
comment: obj.comment.as_deref(),
watched_at: obj.watched_at.naive_utc(),
poster_url: obj.poster_url.as_deref(),
watch_medium: obj.watch_medium.as_deref(),
spoilers: obj.contains_spoilers,
})
.await?;
Ok(())
}
async fn on_delete(&self, ap_id: &Url, actor_url: &Url) -> anyhow::Result<()> {
self.review_store
.delete_remote_review(ap_id.as_str(), actor_url.as_str())
.await
}
async fn on_actor_removed(&self, actor_url: &Url) -> anyhow::Result<()> {
self.review_store.delete_by_actor(actor_url.as_str()).await
}
async fn 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,261 +0,0 @@
use std::sync::Arc;
use async_trait::async_trait;
use domain::{
errors::DomainError,
events::DomainEvent,
ports::{
BlockQuery, EventPublisher, FollowGraphQuery, LocalSocial, SocialCommand, UserRepository,
},
value_objects::{
FollowRelation, FollowTarget, InstanceIdentity, SocialActor, SocialIdentity, UserId,
},
};
use k_ap::ActivityPubService;
pub struct CompositeSocialAdapter {
local: Arc<dyn LocalSocial>,
ap_service: Arc<ActivityPubService>,
user_repo: Arc<dyn UserRepository>,
instance: InstanceIdentity,
event_publisher: Arc<dyn EventPublisher>,
}
impl CompositeSocialAdapter {
pub fn new(
local: Arc<dyn LocalSocial>,
ap_service: Arc<ActivityPubService>,
user_repo: Arc<dyn UserRepository>,
instance: InstanceIdentity,
event_publisher: Arc<dyn EventPublisher>,
) -> Self {
Self {
local,
ap_service,
user_repo,
instance,
event_publisher,
}
}
async fn announce(&self, events: &[DomainEvent]) {
for event in events {
if let Err(e) = self.event_publisher.publish(event).await {
tracing::warn!("failed to publish {event:?} after k_ap write: {e}");
}
}
}
}
fn ap_err(e: anyhow::Error) -> DomainError {
DomainError::InfrastructureError(e.to_string())
}
#[async_trait]
impl SocialCommand for CompositeSocialAdapter {
async fn follow(
&self,
follower: &UserId,
target: &FollowTarget,
events: &[DomainEvent],
) -> Result<(), DomainError> {
let identity = self.local.resolve_target(target).await?;
if let SocialIdentity::Local(_) = identity {
return self
.local
.follow_resolved(follower, &identity, events)
.await;
}
let handle = match target {
FollowTarget::Handle(h) => h.clone(),
FollowTarget::Identity(id) => match id {
SocialIdentity::Local(uid) => {
let user = self
.user_repo
.find_by_id(uid)
.await?
.ok_or_else(|| DomainError::NotFound("User not found".into()))?;
self.instance.handle_for(user.username().value())
}
SocialIdentity::Remote { actor_url } => actor_url.clone(),
},
};
self.ap_service
.follow(follower.value(), &handle)
.await
.map_err(ap_err)?;
self.announce(events).await;
Ok(())
}
async fn unfollow(
&self,
follower: &UserId,
target: &SocialIdentity,
events: &[DomainEvent],
) -> Result<(), DomainError> {
match target {
SocialIdentity::Local(_) => self.local.unfollow(follower, target, events).await,
SocialIdentity::Remote { .. } => {
self.ap_service
.unfollow(follower.value(), &self.instance.actor_url_of(target))
.await
.map_err(ap_err)?;
self.announce(events).await;
Ok(())
}
}
}
async fn accept_follow(
&self,
owner: &UserId,
requester: &SocialIdentity,
events: &[DomainEvent],
) -> Result<(), DomainError> {
match requester {
SocialIdentity::Local(_) => self.local.accept_follow(owner, requester, events).await,
SocialIdentity::Remote { .. } => {
self.ap_service
.accept_follower(owner.value(), &self.instance.actor_url_of(requester))
.await
.map_err(ap_err)?;
self.announce(events).await;
Ok(())
}
}
}
async fn reject_follow(
&self,
owner: &UserId,
requester: &SocialIdentity,
events: &[DomainEvent],
) -> Result<(), DomainError> {
match requester {
SocialIdentity::Local(_) => self.local.reject_follow(owner, requester, events).await,
SocialIdentity::Remote { .. } => {
self.ap_service
.reject_follower(owner.value(), &self.instance.actor_url_of(requester))
.await
.map_err(ap_err)?;
self.announce(events).await;
Ok(())
}
}
}
async fn remove_follower(
&self,
owner: &UserId,
follower: &SocialIdentity,
events: &[DomainEvent],
) -> Result<(), DomainError> {
match follower {
SocialIdentity::Local(_) => self.local.remove_follower(owner, follower, events).await,
SocialIdentity::Remote { .. } => {
self.ap_service
.remove_follower(owner.value(), &self.instance.actor_url_of(follower))
.await
.map_err(ap_err)?;
self.announce(events).await;
Ok(())
}
}
}
async fn block(
&self,
blocker: &UserId,
target: &SocialIdentity,
events: &[DomainEvent],
) -> Result<(), DomainError> {
let actor_url = self.instance.actor_url_of(target);
self.ap_service
.block_actor(blocker.value(), &actor_url)
.await
.map_err(ap_err)?;
self.announce(events).await;
Ok(())
}
async fn unblock(
&self,
blocker: &UserId,
target: &SocialIdentity,
events: &[DomainEvent],
) -> Result<(), DomainError> {
let actor_url = self.instance.actor_url_of(target);
self.ap_service
.unblock_actor(blocker.value(), &actor_url)
.await
.map_err(ap_err)?;
self.announce(events).await;
Ok(())
}
}
#[async_trait]
impl FollowGraphQuery for CompositeSocialAdapter {
async fn get_following(&self, user: &UserId) -> Result<Vec<SocialActor>, DomainError> {
self.local.get_following(user).await
}
async fn get_followers(&self, user: &UserId) -> Result<Vec<SocialActor>, DomainError> {
self.local.get_followers(user).await
}
async fn get_pending_followers(&self, user: &UserId) -> Result<Vec<SocialActor>, DomainError> {
self.local.get_pending_followers(user).await
}
async fn get_pending_following(&self, user: &UserId) -> Result<Vec<SocialActor>, DomainError> {
self.local.get_pending_following(user).await
}
async fn count_following(&self, user: &UserId) -> Result<usize, DomainError> {
self.local.count_following(user).await
}
async fn count_followers(&self, user: &UserId) -> Result<usize, DomainError> {
self.local.count_followers(user).await
}
async fn count_pending_followers(&self, user: &UserId) -> Result<usize, DomainError> {
self.local.count_pending_followers(user).await
}
async fn get_relation(
&self,
viewer: &UserId,
target: &SocialIdentity,
) -> Result<FollowRelation, DomainError> {
self.local.get_relation(viewer, target).await
}
}
#[async_trait]
impl BlockQuery for CompositeSocialAdapter {
async fn get_blocked(&self, user: &UserId) -> Result<Vec<SocialActor>, DomainError> {
let actors = self
.ap_service
.get_blocked_actors(user.value())
.await
.map_err(ap_err)?;
Ok(actors
.into_iter()
.map(|a| {
let identity = self.instance.identify(&a.url);
SocialActor {
identity,
handle: a.handle,
display_name: a.display_name,
avatar_url: a.avatar_url,
}
})
.collect())
}
}

View File

@@ -1,32 +0,0 @@
use std::sync::Arc;
use axum::{
extract::{Request, State},
http::StatusCode,
middleware::Next,
response::{IntoResponse, Response},
};
use domain::{ports::UserRepository, value_objects::UserId};
pub async fn tombstone_gate(
State(users): State<Arc<dyn UserRepository>>,
request: Request,
next: Next,
) -> Response {
if let Some(id) = gated_user(request.uri().path())
&& matches!(
users.find_tombstoned_by_id(&UserId::from_uuid(id)).await,
Ok(Some(_))
)
{
return StatusCode::GONE.into_response();
}
next.run(request).await
}
pub fn gated_user(path: &str) -> Option<uuid::Uuid> {
let rest = path.strip_prefix("/users/")?;
let (id, endpoint) = rest.split_once('/')?;
(!endpoint.is_empty()).then_some(())?;
uuid::Uuid::parse_str(id).ok()
}

View File

@@ -1,40 +0,0 @@
use domain::value_objects::{InstanceIdentity, ReviewId, UserId};
use url::Url;
pub fn actor_url(instance: &InstanceIdentity, user_id: uuid::Uuid) -> Url {
Url::parse(&instance.actor_url_for(&UserId::from_uuid(user_id)))
.expect("base_url is always a valid URL prefix")
}
pub fn review_url(instance: &InstanceIdentity, review_id: &ReviewId) -> Url {
Url::parse(&format!(
"{}/reviews/{}",
instance.base_url(),
review_id.value()
))
.expect("base_url is always a valid URL prefix")
}
pub fn goal_url(instance: &InstanceIdentity, user_id: uuid::Uuid, year: u16) -> Url {
Url::parse(&format!(
"{}/users/{}/goals/{}",
instance.base_url(),
user_id,
year
))
.expect("base_url is always a valid URL prefix")
}
pub fn watchlist_entry_url(
instance: &InstanceIdentity,
user_id: uuid::Uuid,
movie_id: uuid::Uuid,
) -> Url {
Url::parse(&format!(
"{}/users/{}/watchlist/{}",
instance.base_url(),
user_id,
movie_id
))
.expect("base_url is always a valid URL prefix")
}

View File

@@ -1,96 +0,0 @@
use std::sync::Arc;
use async_trait::async_trait;
use domain::{
ports::UserRepository,
value_objects::{InstanceIdentity, UserId},
};
use k_ap::{ApProfileField, ApUser, ApUserRepository};
use url::Url;
pub struct DomainUserRepoAdapter {
pub repo: Arc<dyn UserRepository>,
pub instance: InstanceIdentity,
}
impl DomainUserRepoAdapter {
pub fn new(repo: Arc<dyn UserRepository>, instance: InstanceIdentity) -> Self {
Self { repo, instance }
}
fn build_user(&self, u: &domain::models::User) -> ApUser {
let avatar_url = u
.avatar_path()
.and_then(|p| Url::parse(&self.instance.image_url_for(p)).ok());
let banner_url = u
.banner_path()
.and_then(|p| Url::parse(&self.instance.image_url_for(p)).ok());
let profile_url = Url::parse(&format!(
"{}/u/{}",
self.instance.base_url(),
u.username().value()
))
.ok();
ApUser {
id: u.id().value(),
username: u.username().value().to_string(),
display_name: u.display_name().map(|s| s.to_string()),
bio: u.bio().map(|s| s.to_string()),
avatar_url,
banner_url,
also_known_as: u
.also_known_as()
.map(|s| vec![s.to_string()])
.unwrap_or_default(),
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!("{}/featured", self.instance.actor_url_for(u.id())))
.ok(),
}
}
}
#[async_trait]
impl ApUserRepository for DomainUserRepoAdapter {
async fn find_by_id(&self, id: uuid::Uuid) -> anyhow::Result<Option<ApUser>> {
let user_id = UserId::from_uuid(id);
let user = match self.repo.find_by_id(&user_id).await? {
Some(u) => u,
None => match self.repo.find_tombstoned_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>> {
use domain::value_objects::Username;
let uname =
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> {
Ok(self
.repo
.list_with_stats()
.await
.map_err(|e| anyhow::anyhow!(e.to_string()))?
.len())
}
}

View File

@@ -1,168 +0,0 @@
use std::sync::Arc;
use async_trait::async_trait;
use chrono::DateTime;
use domain::{
models::{RemoteWatchlistEntry, WatchlistWithMovie},
ports::{LocalApContentQuery, RemoteWatchlistRepository},
value_objects::{InstanceIdentity, UserId},
};
use k_ap::{AS_PUBLIC, ApContentReader, ApObjectHandler, LocalObject};
use url::Url;
use crate::objects::{WatchlistApInput, WatchlistObject, watchlist_to_ap_object};
use crate::urls::{actor_url, watchlist_entry_url};
pub struct WatchlistObjectHandler {
pub remote_watchlist_repo: Arc<dyn RemoteWatchlistRepository>,
pub content_query: Arc<dyn LocalApContentQuery>,
pub instance: InstanceIdentity,
}
#[async_trait]
impl ApContentReader for WatchlistObjectHandler {
async fn get_local_objects_page(
&self,
user_id: uuid::Uuid,
_before: Option<DateTime<chrono::Utc>>,
_limit: usize,
) -> anyhow::Result<Vec<LocalObject>> {
let uid = UserId::from_uuid(user_id);
let entries = self
.content_query
.get_local_watchlist_for_user(&uid)
.await
.map_err(|e| anyhow::anyhow!(e.to_string()))?;
let actor = actor_url(&self.instance, user_id);
let follower_cc = format!("{}/followers", actor);
let mut results = Vec::new();
for WatchlistWithMovie { entry, movie } in entries {
let ap_id = watchlist_entry_url(&self.instance, user_id, entry.movie_id.value());
let published = DateTime::from_naive_utc_and_offset(entry.added_at, chrono::Utc);
let poster_url = movie
.poster_path()
.map(|p| self.instance.image_url_for(p.value()));
let obj = watchlist_to_ap_object(WatchlistApInput {
ap_id: ap_id.clone(),
actor_url: actor.clone(),
movie_title: movie.title().value().to_string(),
release_year: movie.release_year().value(),
external_metadata_id: movie
.external_metadata_id()
.map(|id| id.value().to_string()),
poster_url,
added_at: published,
base_url: self.instance.base_url().to_string(),
});
results.push(LocalObject {
ap_id,
object: serde_json::to_value(obj)?,
published_at: published,
to: vec![AS_PUBLIC.to_string()],
cc: vec![follower_cc.clone()],
bto: vec![],
bcc: vec![],
});
}
Ok(results)
}
async fn count_local_posts(&self) -> anyhow::Result<u64> {
Ok(0)
}
}
#[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<()> {
let mut obj: WatchlistObject = match serde_json::from_value(object) {
Ok(o) => o,
Err(e) => {
tracing::warn!(ap_id = %ap_id, "ignoring malformed watchlist Update: {}", e);
return Ok(());
}
};
if obj.attributed_to != *actor_url {
anyhow::bail!("watchlist Update actor does not match object attributed_to");
}
obj.movie_title = ammonia::clean(&obj.movie_title);
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: obj.published,
};
self.remote_watchlist_repo.save(entry).await?;
tracing::info!(ap_id = %ap_id, "updated remote watchlist entry");
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, _: &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,253 +0,0 @@
use std::sync::Arc;
use std::time::Duration;
use activitypub::ActivityPubEventHandler;
use async_trait::async_trait;
use chrono::NaiveDate;
use domain::{
errors::DomainError,
events::DomainEvent,
models::{DiaryEntry, WatchlistWithMovie},
ports::{
EventHandler, FederationDeletionNotifier, LocalApContentQuery, MovieCommand, UserRepository,
},
testing::{
InMemoryStore, PanicGoalQuery, PanicReviewRepository, PanicStatsRepository, test_movie,
},
value_objects::{InstanceIdentity, MovieId, UserId},
};
use k_ap::{
ActivityPubService, ApUser, FederationEvent,
testing::{MockActorRepoBuilder, MockFollowRepoBuilder, MockUserRepoBuilder},
};
use tokio::sync::mpsc;
struct NoContent;
#[async_trait]
impl LocalApContentQuery for NoContent {
async fn get_local_watchlist_for_user(
&self,
_: &UserId,
) -> Result<Vec<WatchlistWithMovie>, DomainError> {
Ok(vec![])
}
async fn get_local_reviews_for_movie(
&self,
_: &MovieId,
) -> Result<Vec<DiaryEntry>, DomainError> {
Ok(vec![])
}
async fn get_local_reviews_page(
&self,
_: &UserId,
_: Option<chrono::NaiveDateTime>,
_: usize,
) -> Result<Vec<DiaryEntry>, DomainError> {
Ok(vec![])
}
}
struct Capture(mpsc::UnboundedSender<serde_json::Value>);
#[async_trait]
impl k_ap::EventPublisher for Capture {
async fn publish(&self, event: FederationEvent) -> anyhow::Result<()> {
if let FederationEvent::DeliveryRequested { activity, .. } = event {
let _ = self.0.send(activity);
}
Ok(())
}
}
struct Harness {
handler: ActivityPubEventHandler,
delivered: mpsc::UnboundedReceiver<serde_json::Value>,
user: UserId,
movie_id: MovieId,
store: Arc<InMemoryStore>,
service: Arc<ActivityPubService>,
}
async fn harness() -> Harness {
let user = UserId::from_uuid(uuid::Uuid::new_v4());
let uid = user.value();
let (tx, rx) = mpsc::unbounded_channel();
let service = ActivityPubService::builder("https://diary.example")
.activity_repo(k_ap::testing::MockActivityRepoBuilder::new().build())
.follow_repo(
MockFollowRepoBuilder::new()
.on_get_accepted_follower_inboxes(|_| {
Ok(vec!["https://remote.example/inbox".to_string()])
})
.build(),
)
.actor_repo(MockActorRepoBuilder::new().build())
.blocklist_repo(k_ap::testing::MockBlocklistRepoBuilder::new().build())
.user_repo(
MockUserRepoBuilder::new()
.on_find_by_id(move |id| {
Ok((id == uid).then(|| ApUser {
id,
username: "alice".into(),
display_name: None,
bio: None,
avatar_url: None,
banner_url: None,
also_known_as: vec![],
profile_url: None,
attachment: vec![],
manually_approves_followers: false,
discoverable: true,
actor_type: Default::default(),
featured_url: None,
}))
})
.build(),
)
.content_reader(k_ap::testing::MockContentReaderBuilder::new().build())
.object_handler(k_ap::testing::MockObjectHandlerBuilder::new().build())
.event_publisher(Arc::new(Capture(tx)))
.build()
.await
.expect("service builds");
let store = InMemoryStore::new();
let movie = test_movie("Heat");
store.upsert_movie(&movie, &[]).await.unwrap();
let service = Arc::new(service);
store
.save(&domain::testing::test_user_with_role(
user.clone(),
domain::models::UserRole::Standard,
))
.await
.unwrap();
let handler = ActivityPubEventHandler::new(
Arc::clone(&service),
Arc::new(NoContent),
Arc::new(PanicReviewRepository),
store.clone(),
Arc::new(PanicGoalQuery),
Arc::new(PanicStatsRepository),
store.clone(),
store.clone(),
InstanceIdentity::new("https://diary.example"),
);
Harness {
handler,
delivered: rx,
user,
movie_id: movie.id().clone(),
store,
service,
}
}
impl Harness {
async fn next_activity_id(&mut self) -> String {
let activity = tokio::time::timeout(Duration::from_secs(5), self.delivered.recv())
.await
.expect("an activity was delivered")
.expect("channel open");
activity["id"]
.as_str()
.expect("activity has an id")
.to_string()
}
}
#[tokio::test]
async fn activitypub_is_idempotent_when_run_twice() {
let mut h = harness().await;
let event = DomainEvent::WatchlistEntryAdded {
user_id: h.user.clone(),
movie_id: h.movie_id.clone(),
movie_title: "Heat".into(),
release_year: 1995,
external_metadata_id: None,
added_at: NaiveDate::from_ymd_opt(2026, 1, 1)
.unwrap()
.and_hms_opt(0, 0, 0)
.unwrap(),
};
h.handler.handle(&event).await.unwrap();
let first = h.next_activity_id().await;
h.handler.handle(&event).await.unwrap();
let second = h.next_activity_id().await;
assert!(first.starts_with("https://diary.example/activities/create/"));
assert_eq!(first, second, "a redelivered Create keeps its activity id");
}
#[tokio::test]
async fn redelivered_delete_gets_a_fresh_activity_id_known_k_ap_limitation() {
let mut h = harness().await;
let event = DomainEvent::WatchlistEntryRemoved {
user_id: h.user.clone(),
movie_id: h.movie_id.clone(),
};
h.handler.handle(&event).await.unwrap();
let first = h.next_activity_id().await;
h.handler.handle(&event).await.unwrap();
let second = h.next_activity_id().await;
assert_ne!(first, second);
}
#[tokio::test]
async fn a_deleted_account_sends_nothing_but_its_delete() {
let mut h = harness().await;
h.store.mark_deleted(&h.user, &[]).await.unwrap();
let logged = DomainEvent::ReviewLogged {
review_id: domain::value_objects::ReviewId::generate(),
user_id: h.user.clone(),
movie_id: h.movie_id.clone(),
rating: domain::value_objects::Rating::new(4).unwrap(),
watched_at: NaiveDate::from_ymd_opt(2026, 1, 1)
.unwrap()
.and_hms_opt(0, 0, 0)
.unwrap(),
};
let added = DomainEvent::WatchlistEntryAdded {
user_id: h.user.clone(),
movie_id: h.movie_id.clone(),
movie_title: "Heat".into(),
release_year: 1995,
external_metadata_id: None,
added_at: NaiveDate::from_ymd_opt(2026, 1, 1)
.unwrap()
.and_hms_opt(0, 0, 0)
.unwrap(),
};
let queued = DomainEvent::FederationDeliveryRequested {
inbox_url: "https://remote.example/inbox".into(),
activity_json: serde_json::json!({"type": "Create"}),
signing_actor_id: h.user.value(),
};
for event in [&logged, &added, &queued] {
h.handler.handle(event).await.unwrap();
}
assert!(
tokio::time::timeout(Duration::from_millis(300), h.delivered.recv())
.await
.is_err(),
"nothing is delivered for a deleted account"
);
activitypub::ApDeletionNotifier::new(
Arc::clone(&h.service),
InstanceIdentity::new("https://diary.example"),
)
.notify_deleted(&h.user)
.await
.unwrap();
let delete = tokio::time::timeout(Duration::from_secs(5), h.delivered.recv())
.await
.expect("the Delete is delivered")
.unwrap();
assert_eq!(delete["type"], "Delete");
}

View File

@@ -1,4 +0,0 @@
#[path = "objects/review_object_test.rs"]
mod review_object_test;
#[path = "objects/watchlist_object_test.rs"]
mod watchlist_object_test;

View File

@@ -1,112 +0,0 @@
use activitypub::objects::{ReviewApInput, review_to_ap_object};
use chrono::NaiveDateTime;
use domain::models::{PersistedReview, Review, ReviewSource};
use domain::value_objects::{Comment, MovieId, Rating, ReviewId, UserId};
use serde_json::{Value, json};
const PUBLIC: &str = "https://www.w3.org/ns/activitystreams#Public";
fn timestamp() -> NaiveDateTime {
NaiveDateTime::parse_from_str("2024-06-01 00:00:00", "%Y-%m-%d %H:%M:%S").unwrap()
}
fn review(rating: u8, comment: Option<&str>, spoilers: bool) -> 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(rating).unwrap(),
comment: comment.map(|c| Comment::new(c.into()).unwrap()),
watched_at: timestamp(),
created_at: timestamp(),
source: ReviewSource::Local,
watch_medium: None,
spoilers,
})
}
fn input(movie_title: &str, actor_url: &str) -> ReviewApInput {
ReviewApInput {
ap_id: "https://example.com/reviews/1".parse().unwrap(),
actor_url: actor_url.parse().unwrap(),
movie_title: movie_title.to_string(),
release_year: 2021,
external_metadata_id: None,
poster_url: None,
base_url: "https://example.com".to_string(),
}
}
fn render(review: &Review, input: ReviewApInput) -> Value {
serde_json::to_value(review_to_ap_object(review, input)).unwrap()
}
fn tag_names(json: &Value) -> Vec<&str> {
json["tag"]
.as_array()
.unwrap()
.iter()
.map(|t| t["name"].as_str().unwrap())
.collect()
}
#[test]
fn review_object_tags_movie_title_with_non_alphanumerics_stripped() {
for (title, hashtag) in [
("The Dark Knight", "#TheDarkKnight"),
("Schindler's List", "#SchindlersList"),
("2001: A Space Odyssey", "#2001ASpaceOdyssey"),
] {
let json = render(
&review(4, None, false),
input(title, "https://example.com/users/1"),
);
assert!(
tag_names(&json).contains(&hashtag),
"{title}: {:?}",
tag_names(&json)
);
}
}
#[test]
fn review_object_includes_two_hashtags() {
let json = render(
&review(4, None, false),
input("Dune", "https://example.com/users/1"),
);
assert_eq!(tag_names(&json), vec!["#MoviesDiary", "#Dune"]);
}
#[test]
fn review_object_is_publicly_addressed_with_followers_in_cc() {
let json = render(
&review(3, None, false),
input("Dune", "https://example.com/users/abc"),
);
assert_eq!(json["to"], json!([PUBLIC]));
assert_eq!(
json["cc"],
json!(["https://example.com/users/abc/followers"])
);
}
#[test]
fn review_object_carries_spoiler_flag_and_summary_only_with_a_comment() {
let json = render(
&review(4, Some("the mentor is the villain"), true),
input("Dune", "https://example.com/users/1"),
);
assert_eq!(json["containsSpoilers"], Value::Bool(true));
assert_eq!(json["summary"], "Contains spoilers");
// A flag without a comment never travels: there is nothing to veil.
let json = render(
&review(4, None, true),
input("Dune", "https://example.com/users/1"),
);
assert!(json.get("containsSpoilers").is_none());
assert!(json.get("summary").is_none());
}

View File

@@ -1,26 +0,0 @@
use activitypub::objects::{WatchlistApInput, watchlist_to_ap_object};
use serde_json::json;
#[test]
fn watchlist_object_is_publicly_addressed_with_followers_in_cc() {
let obj = watchlist_to_ap_object(WatchlistApInput {
ap_id: "https://example.com/watchlist/1".parse().unwrap(),
actor_url: "https://example.com/users/abc".parse().unwrap(),
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(),
});
let json = serde_json::to_value(&obj).unwrap();
assert_eq!(
json["to"],
json!(["https://www.w3.org/ns/activitystreams#Public"])
);
assert_eq!(
json["cc"],
json!(["https://example.com/users/abc/followers"])
);
}

View File

@@ -1,67 +0,0 @@
use domain::{
ports::UserRepository,
testing::{InMemoryStore, test_user},
value_objects::InstanceIdentity,
};
use k_ap::ApUserRepository;
use activitypub::DomainUserRepoAdapter;
async fn adapter_with_deleted_alice() -> (DomainUserRepoAdapter, uuid::Uuid) {
let store = InMemoryStore::new();
let alice = test_user("alice");
store.save(&alice).await.unwrap();
store.mark_deleted(alice.id(), &[]).await.unwrap();
let adapter = DomainUserRepoAdapter::new(store, InstanceIdentity::new("https://md.example"));
(adapter, alice.id().value())
}
#[tokio::test]
async fn tombstoned_actor_can_still_sign() {
let (adapter, id) = adapter_with_deleted_alice().await;
let user = adapter.find_by_id(id).await.unwrap();
assert_eq!(user.expect("k-ap resolves the tombstone").username, "alice");
}
#[tokio::test]
async fn a_tombstone_is_not_found_by_username() {
let (adapter, _) = adapter_with_deleted_alice().await;
assert!(adapter.find_by_username("alice").await.unwrap().is_none());
}
#[tokio::test]
async fn an_unknown_user_is_none() {
let (adapter, _) = adapter_with_deleted_alice().await;
assert!(
adapter
.find_by_id(uuid::Uuid::new_v4())
.await
.unwrap()
.is_none()
);
}
#[test]
fn every_per_user_path_is_gated() {
use activitypub::tombstone_gate::gated_user;
let id = uuid::Uuid::new_v4();
let rid = uuid::Uuid::new_v4();
for endpoint in [
"outbox",
"featured",
"inbox",
"followers",
"following",
&format!("reviews/{rid}"),
] {
assert_eq!(gated_user(&format!("/users/{id}/{endpoint}")), Some(id));
}
assert_eq!(gated_user(&format!("/users/{id}")), None);
assert_eq!(gated_user(&format!("/users/{id}/")), None);
assert_eq!(gated_user("/inbox"), None);
assert_eq!(gated_user("/users/not-a-uuid/outbox"), None);
}

View File

@@ -1,16 +1,15 @@
[package]
name = "auth"
version = "0.1.0"
edition.workspace = true
edition = "2024"
[dependencies]
config = { workspace = true }
async-trait = { workspace = true }
domain = { workspace = true }
anyhow = { workspace = true }
chrono = { workspace = true, features = ["clock"] }
chrono = { workspace = true }
uuid = { workspace = true }
serde = { workspace = true, features = ["derive"] }
jsonwebtoken = { workspace = true }
argon2 = { workspace = true, features = ["std"] }
rand_core = { workspace = true, features = ["getrandom"] }
serde = { version = "1.0", features = ["derive"] }
jsonwebtoken = "9"
argon2 = { version = "0.5", features = ["std"] }
rand_core = { version = "0.6", features = ["getrandom"] }

View File

@@ -1,8 +1,8 @@
use async_trait::async_trait;
use argon2::{
Argon2,
password_hash::{PasswordHasher as _, PasswordVerifier, SaltString},
};
use async_trait::async_trait;
use chrono::{Duration, Utc};
use jsonwebtoken::{DecodingKey, EncodingKey, Header, Validation, decode, encode};
use rand_core::OsRng;
@@ -11,8 +11,7 @@ use uuid::Uuid;
use domain::{
errors::DomainError,
models::GeneratedToken,
ports::{AuthService, PasswordHasher},
ports::{AuthService, GeneratedToken, PasswordHasher},
value_objects::{PasswordHash, UserId},
};
@@ -22,18 +21,17 @@ pub struct AuthConfig {
}
impl AuthConfig {
pub fn from_config(config: &config::AuthConfig) -> anyhow::Result<Self> {
let secret = config
.jwt_secret
.clone()
.ok_or_else(|| anyhow::anyhow!("JWT_SECRET env var is required"))?;
pub fn from_env() -> anyhow::Result<Self> {
let secret = std::env::var("JWT_SECRET")
.map_err(|_| anyhow::anyhow!("JWT_SECRET env var is required"))?;
if secret.is_empty() {
anyhow::bail!("JWT_SECRET must not be empty");
}
Ok(Self {
secret,
ttl_seconds: config.jwt_ttl_seconds,
})
let ttl_seconds = std::env::var("JWT_TTL_SECONDS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(86400u64);
Ok(Self { secret, ttl_seconds })
}
}
@@ -104,16 +102,3 @@ impl PasswordHasher for Argon2PasswordHasher {
.is_ok())
}
}
pub fn create(
config: &config::AuthConfig,
) -> anyhow::Result<(
std::sync::Arc<dyn domain::ports::AuthService>,
std::sync::Arc<dyn domain::ports::PasswordHasher>,
)> {
let config = AuthConfig::from_config(config)?;
Ok((
std::sync::Arc::new(JwtAuthService::new(config)),
std::sync::Arc::new(Argon2PasswordHasher),
))
}

View File

@@ -1,14 +1,10 @@
[package]
name = "event-publisher"
version = "0.1.0"
edition.workspace = true
edition = "2024"
[dependencies]
config = { workspace = true }
domain = { workspace = true }
async-trait = { workspace = true }
tokio = { workspace = true, features = ["sync"] }
futures = { workspace = true }
[dev-dependencies]
tokio = { workspace = true, features = ["rt", "macros"] }
tokio = { workspace = true }
tracing = { workspace = true }

View File

@@ -1,28 +1,26 @@
use async_trait::async_trait;
use domain::{
errors::DomainError,
events::{AckHandle, DomainEvent, EventEnvelope},
ports::{EventConsumer, EventPublisher},
};
use futures::stream::{self, BoxStream};
use std::sync::Arc;
use tokio::sync::Mutex;
use domain::{errors::DomainError, events::DomainEvent, ports::EventPublisher};
use tokio::sync::mpsc;
pub use domain::ports::EventHandler;
pub struct EventPublisherConfig {
pub channel_buffer: usize,
}
impl EventPublisherConfig {
pub fn from_config(config: &config::EventsConfig) -> Self {
Self {
channel_buffer: config.channel_buffer,
}
pub fn from_env() -> Self {
let channel_buffer = std::env::var("EVENT_CHANNEL_BUFFER")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(128);
Self { channel_buffer }
}
}
#[async_trait]
pub trait EventHandler: Send + Sync {
async fn handle(&self, event: &DomainEvent) -> Result<(), DomainError>;
}
pub struct ChannelEventPublisher {
sender: mpsc::Sender<DomainEvent>,
}
@@ -37,33 +35,49 @@ impl EventPublisher for ChannelEventPublisher {
}
}
struct NoopAck;
#[async_trait]
impl AckHandle for NoopAck {
async fn ack(&self) -> Result<(), DomainError> {
Ok(())
}
async fn nack(&self) -> Result<(), DomainError> {
Ok(())
}
async fn defer(&self, _: std::time::Duration) -> Result<(), DomainError> {
Ok(())
}
pub struct EventWorker {
receiver: mpsc::Receiver<DomainEvent>,
handlers: Vec<Box<dyn EventHandler>>,
}
pub struct ChannelEventConsumer {
receiver: Arc<Mutex<mpsc::Receiver<DomainEvent>>>,
}
impl EventConsumer for ChannelEventConsumer {
fn consume(&self) -> BoxStream<'_, Result<EventEnvelope, DomainError>> {
let receiver = Arc::clone(&self.receiver);
Box::pin(stream::unfold(receiver, |rx| async move {
let event = rx.lock().await.recv().await?;
let envelope = EventEnvelope::new(None, event, Box::new(NoopAck));
Some((Ok(envelope), rx))
}))
impl EventWorker {
pub async fn run(mut self) {
while let Some(event) = self.receiver.recv().await {
match &event {
DomainEvent::ReviewLogged {
review_id,
movie_id,
user_id,
rating,
watched_at,
} => {
tracing::info!(
review_id = %review_id.value(),
movie_id = %movie_id.value(),
user_id = %user_id.value(),
rating = rating.value(),
watched_at = %watched_at,
"event: review_logged"
);
}
DomainEvent::MovieDiscovered {
movie_id,
external_metadata_id,
} => {
tracing::info!(
movie_id = %movie_id.value(),
external_id = external_metadata_id.value(),
"event: movie_discovered"
);
}
}
for handler in &self.handlers {
if let Err(e) = handler.handle(&event).await {
tracing::error!("event handler error: {e}");
}
}
}
tracing::info!("event worker shut down");
}
}
@@ -78,12 +92,118 @@ impl EventPublisher for NoopEventPublisher {
pub fn create_event_channel(
config: EventPublisherConfig,
) -> (ChannelEventPublisher, ChannelEventConsumer) {
handlers: Vec<Box<dyn EventHandler>>,
) -> (ChannelEventPublisher, EventWorker) {
let (tx, rx) = mpsc::channel(config.channel_buffer);
(
ChannelEventPublisher { sender: tx },
ChannelEventConsumer {
receiver: Arc::new(Mutex::new(rx)),
EventWorker {
receiver: rx,
handlers,
},
)
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::{Arc, Mutex};
use domain::{
errors::DomainError,
events::DomainEvent,
value_objects::{ExternalMetadataId, MovieId},
};
struct RecordingHandler {
calls: Arc<Mutex<Vec<String>>>,
}
#[async_trait]
impl EventHandler for RecordingHandler {
async fn handle(&self, event: &DomainEvent) -> Result<(), DomainError> {
let label = match event {
DomainEvent::MovieDiscovered { .. } => "movie_discovered",
DomainEvent::ReviewLogged { .. } => "review_logged",
};
self.calls.lock().unwrap().push(label.to_string());
Ok(())
}
}
#[tokio::test]
async fn single_handler_receives_event() {
let calls = Arc::new(Mutex::new(vec![]));
let handler = RecordingHandler { calls: Arc::clone(&calls) };
let config = EventPublisherConfig { channel_buffer: 8 };
let (publisher, worker) = create_event_channel(config, vec![Box::new(handler)]);
let handle = tokio::spawn(worker.run());
let event = DomainEvent::MovieDiscovered {
movie_id: MovieId::generate(),
external_metadata_id: ExternalMetadataId::new("tt1234567".into()).unwrap(),
};
publisher.publish(&event).await.unwrap();
drop(publisher);
handle.await.unwrap();
assert_eq!(*calls.lock().unwrap(), vec!["movie_discovered"]);
}
#[tokio::test]
async fn multiple_handlers_all_receive_event() {
let calls1 = Arc::new(Mutex::new(vec![]));
let calls2 = Arc::new(Mutex::new(vec![]));
let handler1 = RecordingHandler { calls: Arc::clone(&calls1) };
let handler2 = RecordingHandler { calls: Arc::clone(&calls2) };
let config = EventPublisherConfig { channel_buffer: 8 };
let (publisher, worker) = create_event_channel(
config,
vec![Box::new(handler1), Box::new(handler2)],
);
let handle = tokio::spawn(worker.run());
let event = DomainEvent::MovieDiscovered {
movie_id: MovieId::generate(),
external_metadata_id: ExternalMetadataId::new("tt9999999".into()).unwrap(),
};
publisher.publish(&event).await.unwrap();
drop(publisher);
handle.await.unwrap();
assert_eq!(calls1.lock().unwrap().len(), 1);
assert_eq!(calls2.lock().unwrap().len(), 1);
}
#[tokio::test]
async fn handler_error_does_not_stop_worker() {
struct FailingHandler;
#[async_trait]
impl EventHandler for FailingHandler {
async fn handle(&self, _: &DomainEvent) -> Result<(), DomainError> {
Err(DomainError::InfrastructureError("boom".into()))
}
}
let calls = Arc::new(Mutex::new(vec![]));
let good = RecordingHandler { calls: Arc::clone(&calls) };
let config = EventPublisherConfig { channel_buffer: 8 };
let (publisher, worker) = create_event_channel(
config,
vec![Box::new(FailingHandler), Box::new(good)],
);
let handle = tokio::spawn(worker.run());
let event = DomainEvent::MovieDiscovered {
movie_id: MovieId::generate(),
external_metadata_id: ExternalMetadataId::new("tt0000001".into()).unwrap(),
};
publisher.publish(&event).await.unwrap();
drop(publisher);
handle.await.unwrap();
assert_eq!(calls.lock().unwrap().len(), 1);
}
}

View File

@@ -1,58 +0,0 @@
use domain::ports::{EventConsumer, EventPublisher};
use domain::{
events::DomainEvent,
value_objects::{ExternalMetadataId, MovieId},
};
use event_publisher::{EventPublisherConfig, create_event_channel};
use futures::StreamExt;
fn movie_discovered() -> DomainEvent {
DomainEvent::MovieDiscovered {
movie_id: MovieId::generate(),
external_metadata_id: ExternalMetadataId::new("tt1234567".into()).unwrap(),
}
}
#[tokio::test]
async fn consumer_yields_published_events() {
let config = EventPublisherConfig { channel_buffer: 8 };
let (publisher, consumer) = create_event_channel(config);
publisher.publish(&movie_discovered()).await.unwrap();
drop(publisher);
let mut stream = consumer.consume();
let envelope = stream.next().await.unwrap().unwrap();
assert!(matches!(
envelope.event,
DomainEvent::MovieDiscovered { .. }
));
assert!(stream.next().await.is_none());
}
#[tokio::test]
async fn consumer_yields_multiple_events_in_order() {
let config = EventPublisherConfig { channel_buffer: 8 };
let (publisher, consumer) = create_event_channel(config);
publisher.publish(&movie_discovered()).await.unwrap();
publisher.publish(&movie_discovered()).await.unwrap();
drop(publisher);
let mut stream = consumer.consume();
let first = stream.next().await.unwrap().unwrap();
let second = stream.next().await.unwrap().unwrap();
assert!(matches!(first.event, DomainEvent::MovieDiscovered { .. }));
assert!(matches!(second.event, DomainEvent::MovieDiscovered { .. }));
assert!(stream.next().await.is_none());
}
#[tokio::test]
async fn stream_ends_when_publisher_dropped() {
let config = EventPublisherConfig { channel_buffer: 8 };
let (publisher, consumer) = create_event_channel(config);
drop(publisher);
let mut stream = consumer.consume();
assert!(stream.next().await.is_none());
}

View File

@@ -1,16 +0,0 @@
[package]
name = "export"
version = "0.1.0"
edition.workspace = true
[dependencies]
domain = { workspace = true }
serde_json = { workspace = true }
futures = { workspace = true }
bytes = { workspace = true }
async-stream = { workspace = true }
[dev-dependencies]
domain = { workspace = true, features = ["test-helpers"] }
uuid = { workspace = true }
tokio = { workspace = true, features = ["rt", "macros"] }

View File

@@ -1,109 +0,0 @@
use bytes::Bytes;
use domain::{
errors::DomainError,
models::{DiaryEntry, ExportFormat},
ports::DiaryExporter,
};
use futures::stream::BoxStream;
pub struct ExportAdapter;
impl DiaryExporter for ExportAdapter {
fn stream_entries(
&self,
stream: BoxStream<'static, Result<DiaryEntry, DomainError>>,
format: ExportFormat,
) -> BoxStream<'static, Result<Bytes, DomainError>> {
match format {
ExportFormat::Csv => stream_csv(stream),
ExportFormat::Json => stream_json(stream),
}
}
}
fn stream_csv(
entries: BoxStream<'static, Result<DiaryEntry, DomainError>>,
) -> BoxStream<'static, Result<Bytes, DomainError>> {
use futures::StreamExt;
let header = futures::stream::once(async {
Ok(Bytes::from_static(
b"title,year,director,rating,comment,watched_at,external_metadata_id,spoilers\n",
))
});
let rows = entries.map(|r| r.map(|e| Bytes::from(csv_row(&e))));
Box::pin(header.chain(rows))
}
fn stream_json(
stream: BoxStream<'static, Result<DiaryEntry, DomainError>>,
) -> BoxStream<'static, Result<Bytes, DomainError>> {
Box::pin(async_stream::stream! {
futures::pin_mut!(stream);
let mut is_first = true;
while let Some(r) = futures::StreamExt::next(&mut stream).await {
match r {
Err(e) => { yield Err(e); return; }
Ok(entry) => {
let json = serde_json::to_string(&entry_to_json(&entry))
.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();
let spoilers = e.review().spoilers();
format!(
"{},{},{},{},{},{},{},{}\n",
title, year, director, rating, comment, watched_at, ext_id, spoilers
)
}
fn csv_escape(s: &str) -> String {
if s.contains(',') || s.contains('"') || s.contains('\n') {
format!("\"{}\"", s.replace('"', "\"\""))
} else {
s.to_string()
}
}
fn entry_to_json(e: &DiaryEntry) -> serde_json::Value {
serde_json::json!({
"title": e.movie().title().value(),
"year": e.movie().release_year().value(),
"director": e.movie().director(),
"rating": e.review().rating().value(),
"comment": e.review().comment().map(|c| c.value().to_string()),
"watched_at": e.review().watched_at().format("%Y-%m-%d").to_string(),
"external_metadata_id": e.movie().external_metadata_id().map(|id| id.value().to_string()),
"spoilers": e.review().spoilers(),
})
}

View File

@@ -1,144 +0,0 @@
use domain::{
models::{DiaryEntry, ExportFormat, Movie, ReviewEdit},
ports::DiaryExporter,
testing::{
test_movie_with_director, test_movie_with_external_id, test_movie_with_year, test_review,
},
value_objects::{Comment, ExternalMetadataId, UserId},
};
use export::ExportAdapter;
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 entry_for(movie: Movie, rating: u8, comment: Option<&str>) -> DiaryEntry {
let review = test_review(movie.id(), &UserId::generate(), rating).apply_edit(ReviewEdit {
comment: Some(comment.map(|c| Comment::new(c.to_string()).unwrap())),
..Default::default()
});
DiaryEntry::new(movie, review)
}
#[tokio::test]
async fn csv_has_header_and_one_row() {
let adapter = ExportAdapter;
let entry = entry_for(
test_movie_with_director("Inception", 2010, "Christopher Nolan"),
5,
Some("great"),
);
let bytes =
collect_stream(adapter.stream_entries(entry_stream(vec![entry]), ExportFormat::Csv)).await;
let text = String::from_utf8(bytes).unwrap();
assert!(text.starts_with(
"title,year,director,rating,comment,watched_at,external_metadata_id,spoilers\n"
));
assert!(text.contains("Inception"));
assert!(text.contains("2010"));
assert!(text.contains("Christopher Nolan"));
assert!(text.contains("5"));
assert!(text.contains("great"));
assert!(text.contains("2024-03-15"));
}
#[tokio::test]
async fn csv_escapes_commas_in_title() {
let adapter = ExportAdapter;
let entry = entry_for(test_movie_with_year("Tár, A Film", 2022), 4, None);
let bytes =
collect_stream(adapter.stream_entries(entry_stream(vec![entry]), ExportFormat::Csv)).await;
let text = String::from_utf8(bytes).unwrap();
assert!(text.contains("\"Tár, A Film\""));
}
#[tokio::test]
async fn json_is_valid_array() {
let adapter = ExportAdapter;
let entry = entry_for(
test_movie_with_director("Dune", 2021, "Denis Villeneuve"),
5,
None,
);
let bytes =
collect_stream(adapter.stream_entries(entry_stream(vec![entry]), ExportFormat::Json)).await;
let arr: Vec<serde_json::Value> = serde_json::from_slice(&bytes).unwrap();
assert_eq!(arr.len(), 1);
assert_eq!(arr[0]["title"], "Dune");
assert_eq!(arr[0]["year"], 2021);
assert_eq!(arr[0]["rating"], 5);
assert_eq!(arr[0]["comment"], serde_json::Value::Null);
assert_eq!(arr[0]["external_metadata_id"], serde_json::Value::Null);
}
#[tokio::test]
async fn external_metadata_id_included_when_present() {
let adapter = ExportAdapter;
let entry = entry_for(
test_movie_with_external_id(
"Alien",
1979,
ExternalMetadataId::new("tt0078748".to_string()).unwrap(),
),
5,
None,
);
let bytes =
collect_stream(adapter.stream_entries(entry_stream(vec![entry]), ExportFormat::Json)).await;
let arr: Vec<serde_json::Value> = serde_json::from_slice(&bytes).unwrap();
assert_eq!(arr[0]["external_metadata_id"], "tt0078748");
let bytes = collect_stream(adapter.stream_entries(
entry_stream(vec![entry_for(
test_movie_with_external_id(
"Alien",
1979,
ExternalMetadataId::new("tt0078748".to_string()).unwrap(),
),
5,
None,
)]),
ExportFormat::Csv,
))
.await;
let text = String::from_utf8(bytes).unwrap();
assert!(text.contains("tt0078748"));
}
#[tokio::test]
async fn empty_entries_returns_csv_header_only() {
let adapter = ExportAdapter;
let bytes =
collect_stream(adapter.stream_entries(entry_stream(vec![]), ExportFormat::Csv)).await;
let text = String::from_utf8(bytes).unwrap();
assert_eq!(
text,
"title,year,director,rating,comment,watched_at,external_metadata_id,spoilers\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

@@ -1,39 +0,0 @@
[package]
name = "http-axum"
version = "0.1.0"
edition.workspace = true
description = "Axum HTTP adapter: JSON API, classic HTML UI, optional ActivityPub-facing routes"
license = "MIT"
[features]
default = ["federation"]
federation = ["application/federation"]
[dependencies]
tower-http = { workspace = true, features = ["cors", "fs", "trace", "tracing"] }
infer = { workspace = true }
percent-encoding = { workspace = true }
axum-governor = { workspace = true }
axum = { workspace = true, features = ["macros", "multipart", "http1", "json", "query", "form", "tokio", "matched-path", "original-uri", "tower-log", "tracing"] }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
tracing = { workspace = true }
uuid = { workspace = true, features = ["v4", "serde"] }
chrono = { workspace = true, features = ["clock", "serde"] }
chrono-tz = { workspace = true }
futures = { workspace = true }
api-types = { workspace = true }
domain = { workspace = true }
application = { workspace = true }
askama = { workspace = true }
utoipa = { workspace = true, features = ["axum_extras", "uuid"] }
utoipa-swagger-ui = { workspace = true, features = ["axum", "vendored"] }
[dev-dependencies]
bytes = { workspace = true }
futures = { workspace = true }
tower = { workspace = true, features = ["util"] }
tokio = { workspace = true, features = ["rt", "macros"] }
domain = { workspace = true, features = ["test-helpers"] }

View File

@@ -1,102 +0,0 @@
use crate::extractors::Body;
use crate::{errors::ApiError, state::AppState};
use api_types::{
LoginRequest, LoginResponse, LogoutRequest, RefreshRequest, RefreshResponse, RegisterRequest,
};
use application::auth::{
commands::RegisterCommand, login as login_uc, queries::LoginCommand, register as register_uc,
};
use axum::{Json, extract::State, http::StatusCode};
#[utoipa::path(
post, path = "/api/v1/auth/login",
request_body = LoginRequest,
responses(
(status = 200, body = LoginResponse),
(status = 401, description = "Invalid credentials", body = api_types::ErrorResponse),
)
)]
pub async fn login(
State(state): State<AppState>,
Body(req): Body<LoginRequest>,
) -> Result<Json<LoginResponse>, ApiError> {
let result = login_uc::execute(
&state.app_ctx.deps.auth.login,
LoginCommand {
email: req.email,
password: req.password,
},
)
.await?;
Ok(Json(LoginResponse {
token: result.token,
refresh_token: result.refresh_token,
user_id: result.user_id,
email: result.email,
expires_at: result.expires_at.to_rfc3339(),
role: result.role,
}))
}
#[utoipa::path(
post, path = "/api/v1/auth/register",
request_body = RegisterRequest,
responses(
(status = 201, description = "User registered"),
(status = 400, description = "Invalid input", body = api_types::ErrorResponse),
)
)]
pub async fn register(
State(state): State<AppState>,
Body(req): Body<RegisterRequest>,
) -> Result<StatusCode, ApiError> {
register_uc::execute(
&state.app_ctx.deps.auth.register,
RegisterCommand {
email: req.email,
username: req.username,
password: req.password,
role: domain::models::UserRole::Standard,
},
)
.await?;
Ok(StatusCode::CREATED)
}
#[utoipa::path(
post, path = "/api/v1/auth/refresh",
request_body = RefreshRequest,
responses(
(status = 200, body = RefreshResponse),
(status = 401, description = "Invalid or expired refresh token", body = api_types::ErrorResponse),
)
)]
pub async fn refresh(
State(state): State<AppState>,
Body(req): Body<RefreshRequest>,
) -> Result<Json<RefreshResponse>, ApiError> {
let result =
application::auth::refresh::execute(&state.app_ctx.deps.auth.refresh, &req.refresh_token)
.await?;
Ok(Json(RefreshResponse {
token: result.token,
refresh_token: result.refresh_token,
expires_at: result.expires_at.to_rfc3339(),
}))
}
#[utoipa::path(
post, path = "/api/v1/auth/logout",
request_body = LogoutRequest,
responses(
(status = 204, description = "Logged out"),
)
)]
pub async fn api_logout(
State(state): State<AppState>,
Body(req): Body<LogoutRequest>,
) -> StatusCode {
let _ = application::auth::logout::execute(&state.app_ctx.deps.auth.logout, &req.refresh_token)
.await;
StatusCode::NO_CONTENT
}

View File

@@ -1,197 +0,0 @@
use crate::extractors::{Body, Params, PathId};
use crate::support::build_export_response;
use crate::{
errors::ApiError,
extractors::AuthenticatedUser,
html::forms::{LogReviewData, to_diary_query},
state::AppState,
};
use api_types::{
ActivityFeedQueryParams, ActivityFeedResponse, DiaryQueryParams, DiaryResponse,
EditReviewRequest, ExportQueryParams, LogReviewRequest,
};
use application::diary::{
commands::{DeleteReviewCommand, EditReviewCommand},
delete_review, edit_review, get_activity_feed as get_feed_uc, get_diary, log_review,
queries::GetActivityFeedQuery,
};
use axum::{Json, extract::State, http::StatusCode, response::IntoResponse};
use uuid::Uuid;
#[utoipa::path(
get, path = "/api/v1/diary",
params(DiaryQueryParams),
responses(
(status = 200, body = DiaryResponse),
(status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
),
security(("bearer_auth" = []))
)]
pub async fn get_diary(
State(state): State<AppState>,
Params(params): Params<DiaryQueryParams>,
) -> Result<Json<DiaryResponse>, ApiError> {
let page =
get_diary::execute(&state.app_ctx.deps.diary.get_diary, to_diary_query(params)).await?;
Ok(Json(DiaryResponse {
items: page
.items
.iter()
.map(api_types::mappers::entry_to_dto)
.collect(),
total_count: page.total_count,
limit: page.limit,
offset: page.offset,
}))
}
#[utoipa::path(
post, path = "/api/v1/reviews",
request_body = LogReviewRequest,
responses(
(status = 201, description = "Review created"),
(status = 400, description = "Invalid input", body = api_types::ErrorResponse),
(status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
),
security(("bearer_auth" = []))
)]
pub async fn post_review(
State(state): State<AppState>,
user: AuthenticatedUser,
Body(req): Body<LogReviewRequest>,
) -> Result<impl IntoResponse, ApiError> {
let data = LogReviewData::try_from(req).map_err(ApiError)?;
log_review::execute(
&state.app_ctx.services.review_logger,
data.into_command(user.0.value()),
)
.await?;
Ok(StatusCode::CREATED)
}
#[utoipa::path(
delete, path = "/api/v1/reviews/{id}",
params(("id" = Uuid, Path, description = "Review ID")),
responses(
(status = 204, description = "Review deleted"),
(status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
(status = 403, description = "Forbidden", body = api_types::ErrorResponse),
(status = 404, description = "Review not found", body = api_types::ErrorResponse),
),
security(("bearer_auth" = []))
)]
pub async fn delete_review(
State(state): State<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser,
PathId(review_id): PathId<Uuid>,
) -> Result<StatusCode, ApiError> {
let cmd = DeleteReviewCommand {
review_id,
requesting_user_id: user_id.value(),
};
delete_review::execute(&state.app_ctx.deps.diary.delete_review, cmd).await?;
Ok(StatusCode::NO_CONTENT)
}
#[utoipa::path(
patch, path = "/api/v1/reviews/{id}",
request_body = EditReviewRequest,
params(("id" = Uuid, Path, description = "Review ID")),
responses(
(status = 200, description = "Review updated"),
(status = 400, description = "Invalid input", body = api_types::ErrorResponse),
(status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
(status = 403, description = "Forbidden", body = api_types::ErrorResponse),
(status = 404, description = "Review not found", body = api_types::ErrorResponse),
),
security(("bearer_auth" = []))
)]
pub async fn patch_review(
State(state): State<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser,
PathId(review_id): PathId<Uuid>,
Body(req): Body<EditReviewRequest>,
) -> Result<StatusCode, ApiError> {
let watched_at = req
.watched_at
.map(|s| domain::value_objects::parse_watched_at(&s).map_err(ApiError))
.transpose()?;
let cmd = EditReviewCommand {
review_id,
requesting_user_id: user_id.value(),
rating: req.rating,
comment: req.comment,
watched_at,
watch_medium: req.watch_medium,
spoilers: req.spoilers,
};
edit_review::execute(&state.app_ctx.deps.diary.edit_review, cmd).await?;
Ok(StatusCode::OK)
}
#[utoipa::path(
get, path = "/api/v1/diary/export",
params(ExportQueryParams),
responses(
(status = 200, description = "Diary file download", content_type = "text/csv"),
(status = 400, description = "Invalid format parameter", body = api_types::ErrorResponse),
(status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
),
security(("bearer_auth" = []))
)]
pub async fn export_diary(
State(state): State<AppState>,
user: AuthenticatedUser,
Params(params): Params<ExportQueryParams>,
) -> impl IntoResponse {
if !matches!(params.format.as_str(), "csv" | "json") {
return crate::errors::refuse(
StatusCode::BAD_REQUEST,
crate::errors::INVALID_INPUT,
"format must be csv or json",
);
}
build_export_response(&params.format, user.0.value(), &state)
}
#[utoipa::path(
get, path = "/api/v1/activity-feed",
params(ActivityFeedQueryParams),
responses(
(status = 200, body = ActivityFeedResponse),
(status = 400, description = "Invalid query", body = api_types::ErrorResponse),
),
)]
pub async fn get_activity_feed(
State(state): State<AppState>,
Params(params): Params<ActivityFeedQueryParams>,
) -> Result<Json<ActivityFeedResponse>, ApiError> {
let page = get_feed_uc::execute(
&state.app_ctx.deps.diary.get_activity_feed,
GetActivityFeedQuery {
limit: params.limit.unwrap_or(20),
offset: params.offset.unwrap_or(0),
sort_by: params
.sort_by
.as_deref()
.map(|s| s.parse().unwrap_or_default())
.unwrap_or_default(),
search: None,
viewer_user_id: None,
filter_following: false,
},
)
.await?;
Ok(Json(ActivityFeedResponse {
items: page
.items
.iter()
.map(api_types::mappers::feed_entry_to_dto)
.collect(),
total_count: page.total_count,
limit: page.limit,
offset: page.offset,
}))
}

View File

@@ -1,186 +0,0 @@
use crate::extractors::{Body, PathId};
use crate::{errors::ApiError, extractors::AuthenticatedUser, state::AppState};
use api_types::mappers::goal_with_progress_to_dto;
use api_types::{
CreateGoalRequest, GoalDto, GoalsResponse, UpdateGoalRequest, UpdateUserSettingsRequest,
UserSettingsDto,
};
use axum::{Json, extract::State, http::StatusCode};
use uuid::Uuid;
#[utoipa::path(
get, path = "/api/v1/goals",
responses(
(status = 200, body = GoalsResponse),
(status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
),
security(("bearer_auth" = []))
)]
pub async fn list_goals(
State(state): State<AppState>,
user: AuthenticatedUser,
) -> Result<Json<GoalsResponse>, ApiError> {
let goals = application::goals::list::execute(
&state.app_ctx.deps.goals.query,
application::goals::queries::ListGoalsQuery {
user_id: user.0.value(),
},
)
.await?;
Ok(Json(GoalsResponse {
goals: goals.iter().map(goal_with_progress_to_dto).collect(),
}))
}
#[utoipa::path(
post, path = "/api/v1/goals",
request_body = CreateGoalRequest,
responses(
(status = 200, body = GoalDto),
(status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
),
security(("bearer_auth" = []))
)]
pub async fn create_goal(
State(state): State<AppState>,
user: AuthenticatedUser,
Body(req): Body<CreateGoalRequest>,
) -> Result<Json<GoalDto>, ApiError> {
let g = application::goals::create::execute(
&state.app_ctx.deps.goals.command,
application::goals::commands::CreateGoalCommand {
user_id: user.0.value(),
year: req.year,
target_count: req.target_count,
},
)
.await?;
Ok(Json(goal_with_progress_to_dto(&g)))
}
#[utoipa::path(
put, path = "/api/v1/goals/{year}",
request_body = UpdateGoalRequest,
responses(
(status = 200, body = GoalDto),
(status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
(status = 404, description = "Goal not found", body = api_types::ErrorResponse),
),
security(("bearer_auth" = []))
)]
pub async fn update_goal(
State(state): State<AppState>,
user: AuthenticatedUser,
PathId(year): PathId<u16>,
Body(req): Body<UpdateGoalRequest>,
) -> Result<Json<GoalDto>, ApiError> {
let g = application::goals::update::execute(
&state.app_ctx.deps.goals.command,
application::goals::commands::UpdateGoalCommand {
user_id: user.0.value(),
year,
target_count: req.target_count,
},
)
.await?;
Ok(Json(goal_with_progress_to_dto(&g)))
}
#[utoipa::path(
delete, path = "/api/v1/goals/{year}",
responses(
(status = 204, description = "Goal deleted"),
(status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
(status = 404, description = "Goal not found", body = api_types::ErrorResponse),
),
security(("bearer_auth" = []))
)]
pub async fn delete_goal(
State(state): State<AppState>,
user: AuthenticatedUser,
PathId(year): PathId<u16>,
) -> Result<StatusCode, ApiError> {
application::goals::delete::execute(
&state.app_ctx.deps.goals.command,
application::goals::commands::DeleteGoalCommand {
user_id: user.0.value(),
year,
},
)
.await?;
Ok(StatusCode::NO_CONTENT)
}
#[utoipa::path(
get, path = "/api/v1/users/{id}/goals",
responses(
(status = 200, body = GoalsResponse),
(status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
),
security(("bearer_auth" = []))
)]
pub async fn get_user_goals(
State(state): State<AppState>,
AuthenticatedUser(_viewer): AuthenticatedUser,
PathId(user_id): PathId<Uuid>,
) -> Result<Json<GoalsResponse>, ApiError> {
let goals = application::goals::list::execute(
&state.app_ctx.deps.goals.query,
application::goals::queries::ListGoalsQuery { user_id },
)
.await?;
Ok(Json(GoalsResponse {
goals: goals.iter().map(goal_with_progress_to_dto).collect(),
}))
}
#[utoipa::path(
get, path = "/api/v1/settings",
responses(
(status = 200, body = UserSettingsDto),
(status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
),
security(("bearer_auth" = []))
)]
pub async fn get_settings(
State(state): State<AppState>,
user: AuthenticatedUser,
) -> Result<Json<UserSettingsDto>, ApiError> {
let settings = application::users::get_settings::execute(
&state.app_ctx.deps.users.get_settings,
user.0.value(),
)
.await?;
Ok(Json(UserSettingsDto {
federate_goals: settings.federate_goals(),
federate_reviews: settings.federate_reviews(),
federate_watchlist: settings.federate_watchlist(),
}))
}
#[utoipa::path(
put, path = "/api/v1/settings",
request_body = UpdateUserSettingsRequest,
responses(
(status = 204, description = "Settings updated"),
(status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
),
security(("bearer_auth" = []))
)]
pub async fn update_settings(
State(state): State<AppState>,
user: AuthenticatedUser,
Body(req): Body<UpdateUserSettingsRequest>,
) -> Result<StatusCode, ApiError> {
application::users::update_settings::execute(
&state.app_ctx.deps.users.update_settings,
application::users::update_settings::UpdateUserSettingsCommand {
user_id: user.0.value(),
federate_goals: req.federate_goals,
federate_reviews: req.federate_reviews,
federate_watchlist: req.federate_watchlist,
},
)
.await?;
Ok(StatusCode::NO_CONTENT)
}

View File

@@ -1,5 +0,0 @@
mod profiles;
mod sessions;
pub use profiles::*;
pub use sessions::*;

View File

@@ -1,102 +0,0 @@
use crate::extractors::{Body, PathId};
use crate::{errors::ApiError, extractors::AuthenticatedUser, state::AppState};
use api_types::SaveProfileRequest;
use application::import::{
commands::{DeleteImportProfileCommand, SaveImportProfileCommand},
delete_profile as delete_import_profile, list_profiles as list_import_profiles,
save_profile as save_import_profile,
};
use axum::{extract::State, http::StatusCode, response::IntoResponse};
use domain::errors::DomainError;
use domain::value_objects::ImportSessionId;
#[utoipa::path(
get, path = "/api/v1/import/profiles",
responses(
(status = 200, description = "List of saved import profiles", body = inline(Vec<serde_json::Value>)),
(status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
),
security(("bearer_auth" = []))
)]
pub async fn api_get_profiles(
State(state): State<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser,
) -> Result<impl IntoResponse, ApiError> {
let profiles =
list_import_profiles::execute(&state.app_ctx.deps.import.list_profiles, &user_id).await?;
Ok(axum::Json(
profiles
.into_iter()
.map(|p| {
serde_json::json!({
"id": p.id.value().to_string(),
"name": p.name,
"created_at": p.created_at.to_string(),
})
})
.collect::<Vec<_>>(),
))
}
#[utoipa::path(
post, path = "/api/v1/import/profiles",
request_body = SaveProfileRequest,
responses(
(status = 200, description = "Profile saved", body = inline(serde_json::Value)),
(status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
(status = 422, description = "Session has no mapping yet", body = api_types::ErrorResponse),
),
security(("bearer_auth" = []))
)]
pub async fn api_post_profile(
State(state): State<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser,
Body(body): Body<SaveProfileRequest>,
) -> Result<impl IntoResponse, ApiError> {
let session_id = body
.session_id
.parse::<uuid::Uuid>()
.map(ImportSessionId::from_uuid)
.map_err(|_| DomainError::ValidationError("invalid session id".into()))?;
let id = save_import_profile::execute(
&state.app_ctx.deps.import.save_profile,
SaveImportProfileCommand {
user_id: user_id.value(),
session_id: session_id.value(),
name: body.name,
},
)
.await?;
Ok(axum::Json(
serde_json::json!({"id": id.value().to_string()}),
))
}
#[utoipa::path(
delete, path = "/api/v1/import/profiles/{id}",
params(("id" = String, Path, description = "Import profile UUID")),
responses(
(status = 204, description = "Deleted"),
(status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
(status = 404, description = "Profile not found", body = api_types::ErrorResponse),
),
security(("bearer_auth" = []))
)]
pub async fn api_delete_profile(
State(state): State<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser,
PathId(profile_id_str): PathId<String>,
) -> Result<impl IntoResponse, ApiError> {
let profile_id = profile_id_str
.parse::<uuid::Uuid>()
.map_err(|_| DomainError::ValidationError("invalid profile id".into()))?;
delete_import_profile::execute(
&state.app_ctx.deps.import.delete_profile,
DeleteImportProfileCommand {
user_id: user_id.value(),
profile_id,
},
)
.await?;
Ok(StatusCode::NO_CONTENT)
}

View File

@@ -1,307 +0,0 @@
use crate::extractors::{Body, PathId, Upload};
use crate::support::str_to_domain_field;
use crate::{errors::ApiError, extractors::AuthenticatedUser, state::AppState};
use api_types::{
ApplyMappingRequest, ConfirmRequest, ImportPreviewResponse, PreviewRowDto,
SessionCreatedResponse, SessionStateResponse,
};
use application::import::{
apply_mapping as apply_import_mapping, apply_profile_and_map,
commands::{
ApplyImportMappingCommand, ApplyProfileAndMapCommand, CreateImportSessionCommand,
ExecuteImportCommand,
},
create_session as create_import_session, execute as execute_import, get_preview_stage,
get_session_state,
};
use axum::{extract::State, response::IntoResponse};
use domain::errors::DomainError;
use domain::models::{
AnnotatedRow, FieldMapping, FileFormat,
import::{DomainField, Transform},
};
use domain::value_objects::ImportSessionId;
#[utoipa::path(
post, path = "/api/v1/import/sessions",
request_body(content_type = "multipart/form-data", description = "file (binary) + format (csv|json|xlsx)"),
responses(
(status = 200, body = SessionCreatedResponse),
(status = 400, description = "No file provided", body = api_types::ErrorResponse),
(status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
(status = 422, description = "Parse error", body = api_types::ErrorResponse),
),
security(("bearer_auth" = []))
)]
pub async fn api_post_session(
State(state): State<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser,
Upload(mut multipart): Upload,
) -> Result<impl IntoResponse, ApiError> {
let mut file_bytes: Option<Vec<u8>> = None;
let mut format_str = "csv".to_string();
while let Ok(Some(field)) = multipart.next_field().await {
match field.name() {
Some("file") => {
if let Ok(b) = field.bytes().await {
file_bytes = Some(b.to_vec());
}
}
Some("format") => {
if let Ok(t) = field.text().await {
format_str = t;
}
}
_ => {}
}
}
let bytes = match file_bytes {
Some(b) if !b.is_empty() => b,
_ => return Err(DomainError::ValidationError("no file".into()).into()),
};
let format = match format_str.as_str() {
"json" => FileFormat::Json,
"xlsx" => FileFormat::Xlsx,
_ => FileFormat::Csv,
};
let r = create_import_session::execute(
&state.app_ctx.deps.import.create_session,
CreateImportSessionCommand {
user_id: user_id.value(),
bytes,
format,
},
)
.await?;
Ok(axum::Json(SessionCreatedResponse {
session_id: r.session_id.value().to_string(),
columns: r.columns,
sample_rows: r.sample_rows,
}))
}
#[utoipa::path(
get, path = "/api/v1/import/sessions/{id}",
params(("id" = String, Path, description = "Import session UUID")),
responses(
(status = 200, body = SessionStateResponse),
(status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
(status = 404, description = "Session not found", body = api_types::ErrorResponse),
),
security(("bearer_auth" = []))
)]
pub async fn api_get_session(
State(state): State<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser,
PathId(session_id_str): PathId<String>,
) -> Result<impl IntoResponse, ApiError> {
let session_id = session_id_str
.parse::<uuid::Uuid>()
.map(ImportSessionId::from_uuid)
.map_err(|_| DomainError::ValidationError("invalid session id".into()))?;
let state_data = get_session_state::execute(
&state.app_ctx.deps.import.get_session_state,
session_id,
user_id.value(),
)
.await?;
Ok(axum::Json(SessionStateResponse {
session_id: session_id_str,
columns: state_data.columns,
has_mappings: state_data.has_mappings,
row_count: state_data.row_count,
}))
}
#[utoipa::path(
put, path = "/api/v1/import/sessions/{id}/mapping",
params(("id" = String, Path, description = "Import session UUID")),
request_body = ApplyMappingRequest,
responses(
(status = 200, description = "Mapping applied", body = inline(serde_json::Value)),
(status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
(status = 422, description = "Mapping error", body = api_types::ErrorResponse),
),
security(("bearer_auth" = []))
)]
pub async fn api_put_mapping(
State(state): State<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser,
PathId(session_id_str): PathId<String>,
Body(body): Body<ApplyMappingRequest>,
) -> Result<impl IntoResponse, ApiError> {
let session_id = session_id_str
.parse::<uuid::Uuid>()
.map(ImportSessionId::from_uuid)
.map_err(|_| DomainError::ValidationError("invalid session id".into()))?;
let mappings: Vec<FieldMapping> = body
.mappings
.into_iter()
.filter_map(|m| {
let domain_field = str_to_domain_field(&m.domain_field)?;
let transform = if domain_field == DomainField::Rating {
Transform::RatingScale(m.rating_scale.unwrap_or(1.0))
} else if domain_field == DomainField::WatchedAt {
m.date_format
.map(Transform::DateFormat)
.unwrap_or(Transform::Identity)
} else {
Transform::Identity
};
Some(FieldMapping {
source_column: m.source_column,
domain_field,
transform,
})
})
.collect();
let rows = apply_import_mapping::execute(
&state.app_ctx.deps.import.apply_mapping,
ApplyImportMappingCommand {
user_id: user_id.value(),
session_id: session_id.value(),
mappings,
},
)
.await?;
Ok(axum::Json(serde_json::json!({"row_count": rows.len()})))
}
pub async fn api_get_preview(
State(state): State<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser,
PathId(session_id_str): PathId<String>,
) -> Result<axum::Json<ImportPreviewResponse>, ApiError> {
let session_id = session_id_str
.parse::<uuid::Uuid>()
.map(ImportSessionId::from_uuid)
.map_err(|_| DomainError::ValidationError("invalid session id".into()))?;
let stage = get_preview_stage::execute(
&state.app_ctx.deps.import.get_preview_stage,
session_id,
user_id.value(),
)
.await?;
// Not-yet-mapped sessions render as an empty preview here (200, `rows: []`),
// matching this endpoint's behavior before the stage-gate moved into
// `get_preview_stage` — unlike the HTML handler, this API route never
// redirected on the same condition, so its response shape is unchanged.
let annotated: Vec<AnnotatedRow> = match stage {
get_preview_stage::PreviewStage::Ready(preview) => preview.rows,
get_preview_stage::PreviewStage::NotYetMapped => Vec::new(),
};
let rows = annotated
.iter()
.enumerate()
.map(|(i, a)| {
use domain::models::import::RowResult;
match &a.result {
RowResult::Valid(row) if a.is_duplicate => {
PreviewRowDto::Duplicate(api_types::PreviewRowData {
index: i,
title: row.title.clone(),
release_year: row.release_year.clone(),
director: row.director.clone(),
rating: row.rating.clone(),
watched_at: row.watched_at.clone(),
comment: row.comment.clone(),
})
}
RowResult::Valid(row) => PreviewRowDto::Valid(api_types::PreviewRowData {
index: i,
title: row.title.clone(),
release_year: row.release_year.clone(),
director: row.director.clone(),
rating: row.rating.clone(),
watched_at: row.watched_at.clone(),
comment: row.comment.clone(),
}),
RowResult::Invalid { errors, .. } => PreviewRowDto::Invalid {
index: i,
errors: errors.clone(),
},
}
})
.collect();
Ok(axum::Json(ImportPreviewResponse { rows }))
}
#[utoipa::path(
post, path = "/api/v1/import/sessions/{id}/confirm",
params(("id" = String, Path, description = "Import session UUID")),
request_body = ConfirmRequest,
responses(
(status = 200, description = "Import summary", body = inline(serde_json::Value)),
(status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
(status = 404, description = "Session not found", body = api_types::ErrorResponse),
),
security(("bearer_auth" = []))
)]
pub async fn api_post_confirm(
State(state): State<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser,
PathId(session_id_str): PathId<String>,
Body(body): Body<ConfirmRequest>,
) -> Result<impl IntoResponse, ApiError> {
let session_id = session_id_str
.parse::<uuid::Uuid>()
.map(ImportSessionId::from_uuid)
.map_err(|_| DomainError::ValidationError("invalid session id".into()))?;
let s = execute_import::execute(
&state.app_ctx.deps.import.execute_import,
ExecuteImportCommand {
user_id: user_id.value(),
session_id: session_id.value(),
confirmed_indices: body.confirmed_indices,
},
)
.await?;
Ok(axum::Json(serde_json::json!({
"imported": s.imported,
"skipped_duplicates": s.skipped_duplicates,
"failed": s.failed.iter().map(|(i, e)| serde_json::json!({"index": i, "error": e})).collect::<Vec<_>>(),
})))
}
#[utoipa::path(
put, path = "/api/v1/import/sessions/{id}/profile/{profile_id}",
params(
("id" = String, Path, description = "Import session UUID"),
("profile_id" = String, Path, description = "Import profile UUID"),
),
responses(
(status = 200, description = "Profile applied and mapping regenerated", body = inline(serde_json::Value)),
(status = 400, description = "Invalid ID", body = api_types::ErrorResponse),
(status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
(status = 404, description = "Session or profile not found", body = api_types::ErrorResponse),
(status = 422, description = "Mapping error", body = api_types::ErrorResponse),
),
security(("bearer_auth" = []))
)]
pub async fn api_apply_profile(
State(state): State<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser,
PathId((session_id_str, profile_id_str)): PathId<(String, String)>,
) -> Result<impl IntoResponse, ApiError> {
let session_id = session_id_str
.parse::<uuid::Uuid>()
.map_err(|_| DomainError::ValidationError("invalid session id".into()))?;
let profile_id = profile_id_str
.parse::<uuid::Uuid>()
.map_err(|_| DomainError::ValidationError("invalid profile id".into()))?;
let rows = apply_profile_and_map::execute(
&state.app_ctx.deps.import.apply_profile_and_map,
ApplyProfileAndMapCommand {
user_id: user_id.value(),
session_id,
profile_id,
},
)
.await?;
Ok(axum::Json(serde_json::json!({"row_count": rows.len()})))
}

View File

@@ -1,91 +0,0 @@
use crate::extractors::Params;
use crate::{errors::ApiError, extractors::AuthenticatedUser, state::AppState};
use api_types::insights::{InsightDto, InsightsQueryParams, InsightsResponse};
use application::insights::get as get_insights_uc;
use axum::{Json, extract::State};
use domain::{errors::DomainError, models::Insight};
fn to_dto(insight: Insight) -> InsightDto {
match insight {
Insight::RatingLift {
genre,
genre_avg,
user_avg,
delta,
sample_count,
} => InsightDto::RatingLift {
genre,
genre_avg,
user_avg,
delta,
sample_count,
},
Insight::Seasonality {
genre,
month,
multiplier,
sample_count,
} => InsightDto::Seasonality {
genre,
month,
multiplier,
sample_count,
},
Insight::MediumTiming {
medium,
multiplier,
weekend_share,
baseline_share,
sample_count,
} => InsightDto::MediumTiming {
medium,
multiplier,
weekend_share,
baseline_share,
sample_count,
},
Insight::Rhythm {
peak_hour_start,
peak_hour_end,
top_days,
sample_count,
} => InsightDto::Rhythm {
peak_hour_start,
peak_hour_end,
top_days,
sample_count,
},
}
}
/// Live insights for the authenticated user's own diary. Private surface:
/// habits (late-night watching, mediums) are more sensitive than the taste
/// statistics already public on profiles (ADR-0010).
#[utoipa::path(
get, path = "/api/v1/insights",
params(InsightsQueryParams),
responses(
(status = 200, body = InsightsResponse),
(status = 400, description = "Unknown time zone", body = api_types::ErrorResponse),
(status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
),
security(("bearer_auth" = []))
)]
pub async fn get_insights(
State(state): State<AppState>,
user: AuthenticatedUser,
Params(params): Params<InsightsQueryParams>,
) -> Result<Json<InsightsResponse>, ApiError> {
let tz: chrono_tz::Tz = match params.tz.as_deref() {
None => chrono_tz::UTC,
Some(name) => name.parse().map_err(|_| {
ApiError(DomainError::ValidationError(format!(
"unknown time zone: {name}"
)))
})?,
};
let insights = get_insights_uc::execute(&state.app_ctx.deps.insights.get, user.0, &tz).await?;
Ok(Json(InsightsResponse {
insights: insights.into_iter().map(to_dto).collect(),
}))
}

View File

@@ -1,201 +0,0 @@
pub mod auth;
pub mod diary;
pub mod goals;
pub mod import;
pub mod insights;
pub mod movies;
pub mod search;
pub mod social;
pub mod user_profile;
pub mod users;
pub mod watchlist;
pub mod webhook;
pub mod wrapup;
use axum::{Router, routing};
use crate::state::AppState;
pub fn router() -> Router<AppState> {
let base = Router::new()
.route("/diary", routing::get(diary::get_diary))
.route(
"/movies/{id}/history",
routing::get(movies::get_review_history),
)
.route("/movies", routing::get(movies::list_movies))
.route("/movies/{id}", routing::get(movies::get_movie_detail))
.route(
"/movies/{id}/profile",
routing::get(movies::get_movie_profile),
)
.route("/reviews", routing::post(diary::post_review))
.route(
"/reviews/{id}",
routing::delete(diary::delete_review).patch(diary::patch_review),
)
.route(
"/movies/{id}/sync-poster",
routing::post(movies::sync_poster),
)
.route("/auth/login", routing::post(auth::login))
.route("/auth/register", routing::post(auth::register))
.route("/auth/refresh", routing::post(auth::refresh))
.route("/auth/logout", routing::post(auth::api_logout))
.route("/diary/export", routing::get(diary::export_diary))
.route("/activity-feed", routing::get(diary::get_activity_feed))
.route("/users", routing::get(users::list_users))
.route("/users/me", routing::delete(users::delete_me))
.route(
"/users/{id}",
routing::get(user_profile::get_user_profile).delete(users::delete_user),
)
.route("/import/sessions", routing::post(import::api_post_session))
.route(
"/import/sessions/{id}",
routing::get(import::api_get_session),
)
.route(
"/import/sessions/{id}/mapping",
routing::put(import::api_put_mapping),
)
.route(
"/import/sessions/{id}/preview",
routing::get(import::api_get_preview),
)
.route(
"/import/sessions/{id}/confirm",
routing::post(import::api_post_confirm),
)
.route(
"/import/profiles",
routing::get(import::api_get_profiles).post(import::api_post_profile),
)
.route(
"/import/profiles/{id}",
routing::delete(import::api_delete_profile),
)
.route(
"/import/sessions/{id}/profile/{profile_id}",
routing::put(import::api_apply_profile),
)
.route(
"/profile",
routing::get(users::get_profile).put(users::update_profile_handler),
)
.route(
"/profile/fields",
routing::put(users::update_profile_fields_handler),
)
.route("/search", routing::get(search::get_search))
.route("/people/{id}", routing::get(search::get_person_handler))
.route(
"/people/{id}/credits",
routing::get(search::get_person_credits_handler),
)
.route(
"/watchlist",
routing::get(watchlist::get_watchlist_handler).post(watchlist::post_watchlist_add),
)
.route(
"/watchlist/{movie_id}",
routing::get(watchlist::get_watchlist_status).delete(watchlist::delete_watchlist_entry),
)
.route(
"/settings/webhook-tokens",
routing::get(webhook::get_webhook_tokens).post(webhook::post_generate_webhook_token),
)
.route(
"/settings/webhook-tokens/{id}",
routing::delete(webhook::delete_webhook_token),
)
.route("/watch-queue", routing::get(webhook::get_watch_queue))
.route(
"/watch-queue/confirm",
routing::post(webhook::post_confirm_watch_events),
)
.route(
"/watch-queue/dismiss",
routing::post(webhook::post_dismiss_watch_events),
)
.route("/wrapups/generate", routing::post(wrapup::post_generate))
.route("/wrapups", routing::get(wrapup::get_list))
.route(
"/wrapups/{id}",
routing::get(wrapup::get_status).delete(wrapup::delete_wrapup_handler),
)
.route("/wrapups/{id}/report", routing::get(wrapup::get_report))
.route("/insights", routing::get(insights::get_insights))
.route(
"/admin/reindex-search",
routing::post(search::post_reindex_search),
)
.route(
"/goals",
routing::get(goals::list_goals).post(goals::create_goal),
)
.route(
"/goals/{year}",
routing::put(goals::update_goal).delete(goals::delete_goal),
)
.route("/users/{id}/goals", routing::get(goals::get_user_goals))
.route(
"/settings",
routing::get(goals::get_settings).put(goals::update_settings),
);
base.merge(social_routes())
}
pub fn webhook_router() -> Router<AppState> {
Router::new()
.route(
"/webhooks/jellyfin",
routing::post(webhook::post_jellyfin_webhook),
)
.route("/webhooks/plex", routing::post(webhook::post_plex_webhook))
}
fn social_routes() -> Router<AppState> {
Router::new()
.route("/social/following", routing::get(social::get_following))
.route("/social/followers", routing::get(social::get_followers))
.route(
"/social/followers/pending",
routing::get(social::get_pending_followers),
)
.route(
"/social/followers/pending/count",
routing::get(social::get_pending_follower_count),
)
.route(
"/social/following/pending",
routing::get(social::get_pending_following),
)
.route(
"/social/relationship",
routing::get(social::get_relationship),
)
.route("/social/follow", routing::post(social::follow))
.route("/social/unfollow", routing::post(social::unfollow))
.route(
"/social/followers/accept",
routing::post(social::accept_follower),
)
.route(
"/social/followers/reject",
routing::post(social::reject_follower),
)
.route(
"/social/followers/remove",
routing::post(social::remove_follower),
)
.route(
"/users/{id}/following",
routing::get(social::get_user_following),
)
.route(
"/users/{id}/followers",
routing::get(social::get_user_followers),
)
}

View File

@@ -1,248 +0,0 @@
use crate::extractors::{Params, PathId};
use crate::{errors::ApiError, extractors::AuthenticatedUser, state::AppState};
use api_types::{
CastMemberDto, CrewMemberDto, GenreDto, KeywordDto, MovieDetailResponse, MovieProfileResponse,
MovieStatsDto, MoviesQueryParams, MoviesResponse, PaginationQueryParams, ReviewHistoryResponse,
SocialFeedResponse, SocialReviewDto,
};
use application::{
diary::{
commands::SyncPosterCommand,
get_movie_social_page, get_review_history,
queries::{GetMovieSocialPageQuery, GetReviewHistoryQuery},
},
movies::{get_movies, queries::GetMoviesQuery, sync_poster},
};
use axum::{Json, extract::State, http::StatusCode, response::IntoResponse};
use domain::services::review_history::Trend;
use uuid::Uuid;
#[utoipa::path(
get, path = "/api/v1/movies",
params(MoviesQueryParams),
responses(
(status = 200, body = MoviesResponse),
(status = 400, description = "Invalid query", body = api_types::ErrorResponse),
)
)]
pub async fn list_movies(
State(state): State<AppState>,
Params(params): Params<MoviesQueryParams>,
) -> Result<Json<MoviesResponse>, ApiError> {
let page = get_movies::execute(
&state.app_ctx.deps.movies.get_movies,
GetMoviesQuery {
limit: params.limit,
offset: params.offset,
search: params.search,
genre: params.genre,
language: params.language,
},
)
.await?;
Ok(Json(MoviesResponse {
items: page
.items
.iter()
.map(api_types::mappers::summary_to_dto)
.collect(),
total_count: page.total_count,
limit: page.limit,
offset: page.offset,
}))
}
#[utoipa::path(
get, path = "/api/v1/movies/{id}/history",
params(("id" = Uuid, Path, description = "Movie ID")),
responses(
(status = 200, body = ReviewHistoryResponse),
(status = 404, description = "Movie not found", body = api_types::ErrorResponse),
)
)]
pub async fn get_review_history(
State(state): State<AppState>,
PathId(movie_id): PathId<Uuid>,
) -> Result<Json<ReviewHistoryResponse>, ApiError> {
let (history, trend) = get_review_history::execute(
&state.app_ctx.deps.diary.get_review_history,
GetReviewHistoryQuery { movie_id },
)
.await?;
Ok(Json(ReviewHistoryResponse {
movie: api_types::mappers::movie_to_dto(history.movie()),
viewings: history
.viewings()
.iter()
.map(api_types::mappers::review_to_dto)
.collect(),
trend: match trend {
Trend::Improved => "improved",
Trend::Declined => "declined",
Trend::Neutral => "neutral",
}
.to_string(),
}))
}
#[utoipa::path(
post, path = "/api/v1/movies/{id}/sync-poster",
params(("id" = Uuid, Path, description = "Movie ID")),
responses(
(status = 204, description = "Poster synced"),
(status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
(status = 404, description = "Movie not found", body = api_types::ErrorResponse),
),
security(("bearer_auth" = []))
)]
pub async fn sync_poster(
State(state): State<AppState>,
_user: AuthenticatedUser,
PathId(movie_id): PathId<Uuid>,
) -> Result<impl IntoResponse, ApiError> {
sync_poster::execute(
&state.app_ctx.deps.movies.sync_poster,
SyncPosterCommand { movie_id },
)
.await?;
Ok(StatusCode::NO_CONTENT)
}
#[utoipa::path(
get, path = "/api/v1/movies/{movie_id}",
params(("movie_id" = Uuid, Path, description = "Movie ID")),
responses(
(status = 200, body = MovieDetailResponse),
(status = 404, description = "Movie not found", body = api_types::ErrorResponse),
)
)]
pub async fn get_movie_detail(
State(state): State<AppState>,
PathId(movie_id): PathId<Uuid>,
Params(params): Params<PaginationQueryParams>,
) -> Result<Json<MovieDetailResponse>, ApiError> {
let limit = params.limit.unwrap_or(20);
let offset = params.offset.unwrap_or(0);
let result = get_movie_social_page::execute(
&state.app_ctx.deps.diary.get_movie_social_page,
GetMovieSocialPageQuery {
movie_id,
limit,
offset,
},
)
.await?;
Ok(Json(MovieDetailResponse {
movie: api_types::mappers::movie_to_dto(&result.movie),
stats: MovieStatsDto {
total_count: result.stats.total_count,
avg_rating: result.stats.avg_rating,
federated_count: result.stats.federated_count,
rating_histogram: result.stats.rating_histogram,
},
reviews: SocialFeedResponse {
items: result
.reviews
.items
.iter()
.map(|e| SocialReviewDto {
user_display: e.user_display_name().to_string(),
user_id: e.review().user_id().value(),
rating: e.review().rating().value(),
comment: e.review().comment().map(|c| c.value().to_string()),
watched_at: domain::value_objects::format_watched_at(e.review().watched_at()),
is_federated: e.review().is_remote(),
watch_medium: e.review().watch_medium().copied(),
spoilers: e.review().spoilers(),
})
.collect(),
total_count: result.reviews.total_count,
limit: result.reviews.limit,
offset: result.reviews.offset,
},
}))
}
#[utoipa::path(
get, path = "/api/v1/movies/{id}/profile",
params(("id" = Uuid, Path, description = "Movie ID")),
responses(
(status = 200, body = MovieProfileResponse),
(status = 404, description = "No profile found for this movie", body = api_types::ErrorResponse),
)
)]
pub async fn get_movie_profile(
State(state): State<AppState>,
PathId(movie_id): PathId<Uuid>,
) -> impl IntoResponse {
use application::movies::get_movie_profile;
let query = get_movie_profile::GetMovieProfileQuery { movie_id };
match get_movie_profile::execute(&state.app_ctx.deps.movies.get_movie_profile, query).await {
Ok(Some(result)) => {
let p = result.profile;
Json(MovieProfileResponse {
tmdb_id: p.tmdb_id,
imdb_id: p.imdb_id,
overview: p.overview,
tagline: p.tagline,
runtime_minutes: p.runtime_minutes,
budget_usd: p.budget_usd,
revenue_usd: p.revenue_usd,
vote_average: p.vote_average,
vote_count: p.vote_count,
original_language: p.original_language,
collection_name: p.collection_name,
genres: p
.genres
.into_iter()
.map(|g| GenreDto {
tmdb_id: g.tmdb_id,
name: g.name,
})
.collect(),
keywords: p
.keywords
.into_iter()
.map(|k| KeywordDto {
tmdb_id: k.tmdb_id,
name: k.name,
})
.collect(),
cast: result
.cast
.into_iter()
.map(|c| CastMemberDto {
person_id: c.person_id.value().to_string(),
tmdb_person_id: c.tmdb_person_id,
name: c.name,
character: c.character,
billing_order: c.billing_order,
profile_path: c.profile_path,
})
.collect(),
crew: result
.crew
.into_iter()
.map(|c| CrewMemberDto {
person_id: c.person_id.value().to_string(),
tmdb_person_id: c.tmdb_person_id,
name: c.name,
job: c.job,
department: c.department,
profile_path: c.profile_path,
})
.collect(),
enriched_at: p.enriched_at.to_rfc3339(),
})
.into_response()
}
Ok(None) => {
crate::errors::refuse(StatusCode::NOT_FOUND, crate::errors::NOT_FOUND, "not found")
}
Err(e) => crate::errors::ApiError(e).into_response(),
}
}

View File

@@ -1,179 +0,0 @@
use crate::extractors::{Params, PathId};
use crate::state::AppState;
use api_types::search::{
CastCreditDto, CrewCreditDto, MovieSearchHitDto, PaginatedMovieHits, PaginatedPersonHits,
PersonCreditsDto, PersonSearchHitDto, SearchQueryParams, SearchResponse,
};
use application::{
person::{get as get_person, get_credits as get_person_credits},
search::execute as search_uc,
};
use axum::{extract::State, http::StatusCode, response::IntoResponse};
use domain::models::{PersonId, collections::PageParams};
#[utoipa::path(
get, path = "/api/v1/search",
params(api_types::search::SearchQueryParams),
responses(
(status = 200, body = api_types::search::SearchResponse),
(status = 400, description = "Invalid query", body = api_types::ErrorResponse),
),
tag = "search",
)]
pub async fn get_search(
State(state): State<AppState>,
Params(params): Params<SearchQueryParams>,
) -> impl IntoResponse {
let query = domain::models::SearchQuery {
text: params.q,
filters: domain::models::SearchFilters {
genre: params.genre,
year: params.year,
person_id: params.person_id.map(PersonId::from_uuid),
department: params.department,
language: params.language,
},
page: PageParams {
limit: params.limit.unwrap_or(5),
offset: params.offset.unwrap_or(0),
},
};
match search_uc::execute(&state.app_ctx.deps.search.execute, query).await {
Ok(results) => axum::Json(SearchResponse {
movies: PaginatedMovieHits {
items: results
.movies
.items
.iter()
.map(|h| MovieSearchHitDto {
movie_id: h.movie_id.value(),
title: h.title.clone(),
release_year: h.release_year,
director: h.director.clone(),
poster_path: h.poster_path.clone(),
genres: h.genres.clone(),
})
.collect(),
total_count: results.movies.total_count,
limit: results.movies.limit,
offset: results.movies.offset,
},
people: PaginatedPersonHits {
items: results
.people
.items
.iter()
.map(|h| PersonSearchHitDto {
person_id: h.person_id.value(),
name: h.name.clone(),
known_for_department: h.known_for_department.clone(),
profile_path: h.profile_path.clone(),
known_for_titles: h.known_for_titles.clone(),
})
.collect(),
total_count: results.people.total_count,
limit: results.people.limit,
offset: results.people.offset,
},
})
.into_response(),
Err(e) => crate::errors::ApiError(e).into_response(),
}
}
#[utoipa::path(
get, path = "/api/v1/people/{id}",
params(("id" = Uuid, Path, description = "Person ID")),
responses(
(status = 200, body = api_types::search::PersonDto),
(status = 404, description = "Person not found", body = api_types::ErrorResponse),
),
tag = "search",
)]
pub async fn get_person_handler(
State(state): State<AppState>,
PathId(id): PathId<uuid::Uuid>,
) -> impl IntoResponse {
match get_person::execute(
&state.app_ctx.deps.person.get_person,
PersonId::from_uuid(id),
)
.await
{
Ok(Some(person)) => axum::Json(api_types::mappers::person_to_dto(&person)).into_response(),
Ok(None) => {
crate::errors::refuse(StatusCode::NOT_FOUND, crate::errors::NOT_FOUND, "not found")
}
Err(e) => crate::errors::ApiError(e).into_response(),
}
}
#[utoipa::path(
get, path = "/api/v1/people/{id}/credits",
params(("id" = Uuid, Path, description = "Person ID")),
responses(
(status = 200, body = api_types::search::PersonCreditsDto),
(status = 404, description = "Person not found", body = api_types::ErrorResponse),
),
tag = "search",
)]
pub async fn get_person_credits_handler(
State(state): State<AppState>,
PathId(id): PathId<uuid::Uuid>,
) -> impl IntoResponse {
match get_person_credits::execute(
&state.app_ctx.deps.person.get_person,
PersonId::from_uuid(id),
)
.await
{
Ok(credits) => axum::Json(PersonCreditsDto {
person: api_types::mappers::person_to_dto(&credits.person),
cast: credits
.cast
.iter()
.map(|c| CastCreditDto {
movie_id: c.movie_id.value(),
title: c.title.clone(),
release_year: c.release_year,
character: c.character.clone(),
poster_path: c.poster_path.clone(),
})
.collect(),
crew: credits
.crew
.iter()
.map(|c| CrewCreditDto {
movie_id: c.movie_id.value(),
title: c.title.clone(),
release_year: c.release_year,
job: c.job.clone(),
department: c.department.clone(),
poster_path: c.poster_path.clone(),
})
.collect(),
})
.into_response(),
Err(e) => crate::errors::ApiError(e).into_response(),
}
}
pub async fn post_reindex_search(
State(state): State<AppState>,
_admin: crate::extractors::AdminApiUser,
) -> impl IntoResponse {
match application::search::request_reindex::execute(&state.app_ctx.deps.search.request_reindex)
.await
{
Ok(()) => StatusCode::ACCEPTED.into_response(),
Err(e) => {
tracing::error!("failed to publish reindex event: {:?}", e);
crate::errors::refuse(
StatusCode::INTERNAL_SERVER_ERROR,
crate::errors::INTERNAL,
"internal server error",
)
}
}
}

View File

@@ -1,344 +0,0 @@
use crate::extractors::{Body, Params, PathId};
use crate::{errors::ApiError, extractors::AuthenticatedUser, state::AppState};
use api_types::{
ActorListResponse, ActorUrlRequest, FollowRelationResponse, FollowRequest,
PendingCountResponse, RemoteActorDto,
};
use axum::{Json, extract::State, http::StatusCode, response::IntoResponse};
use domain::value_objects::{FollowTarget, InstanceIdentity, SocialActor, SocialIdentity};
use uuid::Uuid;
fn social_actor_to_dto(actor: SocialActor, instance: &InstanceIdentity) -> RemoteActorDto {
RemoteActorDto {
url: instance.actor_url_of(&actor.identity),
user_id: match &actor.identity {
SocialIdentity::Local(uid) => Some(uid.value()),
SocialIdentity::Remote { .. } => None,
},
handle: actor.handle,
display_name: actor.display_name,
avatar_url: actor.avatar_url,
}
}
#[utoipa::path(
get, path = "/api/v1/social/following",
responses(
(status = 200, body = ActorListResponse),
(status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
),
security(("bearer_auth" = []))
)]
pub async fn get_following(
State(state): State<AppState>,
user: AuthenticatedUser,
) -> Result<Json<ActorListResponse>, ApiError> {
let instance = state.app_ctx.instance.clone();
let identities = application::social::get_following::execute(
&state.app_ctx.deps.social.query,
user.0.value(),
)
.await?;
Ok(Json(ActorListResponse {
actors: identities
.into_iter()
.map(|a| social_actor_to_dto(a, &instance))
.collect(),
}))
}
#[utoipa::path(
get, path = "/api/v1/social/followers",
responses(
(status = 200, body = ActorListResponse),
(status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
),
security(("bearer_auth" = []))
)]
pub async fn get_followers(
State(state): State<AppState>,
user: AuthenticatedUser,
) -> Result<Json<ActorListResponse>, ApiError> {
let instance = state.app_ctx.instance.clone();
let identities = application::social::get_followers::execute(
&state.app_ctx.deps.social.query,
user.0.value(),
)
.await?;
Ok(Json(ActorListResponse {
actors: identities
.into_iter()
.map(|a| social_actor_to_dto(a, &instance))
.collect(),
}))
}
pub async fn get_user_following(
State(state): State<AppState>,
_user: AuthenticatedUser,
PathId(user_id): PathId<Uuid>,
) -> Result<Json<ActorListResponse>, ApiError> {
let instance = state.app_ctx.instance.clone();
let identities =
application::social::get_following::execute(&state.app_ctx.deps.social.query, user_id)
.await?;
Ok(Json(ActorListResponse {
actors: identities
.into_iter()
.map(|a| social_actor_to_dto(a, &instance))
.collect(),
}))
}
pub async fn get_user_followers(
State(state): State<AppState>,
_user: AuthenticatedUser,
PathId(user_id): PathId<Uuid>,
) -> Result<Json<ActorListResponse>, ApiError> {
let instance = state.app_ctx.instance.clone();
let identities =
application::social::get_followers::execute(&state.app_ctx.deps.social.query, user_id)
.await?;
Ok(Json(ActorListResponse {
actors: identities
.into_iter()
.map(|a| social_actor_to_dto(a, &instance))
.collect(),
}))
}
#[utoipa::path(
post, path = "/api/v1/social/follow",
request_body = FollowRequest,
responses(
(status = 200, description = "Follow request sent"),
(status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
),
security(("bearer_auth" = []))
)]
pub async fn follow(
State(state): State<AppState>,
user: AuthenticatedUser,
Body(body): Body<FollowRequest>,
) -> Result<impl IntoResponse, ApiError> {
application::social::execute::execute_command(
&state.app_ctx.deps.social.command,
application::social::commands::SocialCmd::Follow {
follower_id: user.0.value(),
target: FollowTarget::Handle(body.handle),
},
)
.await?;
Ok(StatusCode::OK)
}
#[utoipa::path(
post, path = "/api/v1/social/unfollow",
request_body = ActorUrlRequest,
responses(
(status = 200, description = "Unfollowed"),
(status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
),
security(("bearer_auth" = []))
)]
pub async fn unfollow(
State(state): State<AppState>,
user: AuthenticatedUser,
Body(body): Body<ActorUrlRequest>,
) -> Result<impl IntoResponse, ApiError> {
let instance = state.app_ctx.instance.clone();
application::social::execute::execute_command(
&state.app_ctx.deps.social.command,
application::social::commands::SocialCmd::Unfollow {
follower_id: user.0.value(),
target: instance.identify(&body.actor_url),
},
)
.await?;
Ok(StatusCode::OK)
}
#[utoipa::path(
post, path = "/api/v1/social/followers/accept",
request_body = ActorUrlRequest,
responses(
(status = 200, description = "Follower accepted"),
(status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
),
security(("bearer_auth" = []))
)]
pub async fn accept_follower(
State(state): State<AppState>,
user: AuthenticatedUser,
Body(body): Body<ActorUrlRequest>,
) -> Result<impl IntoResponse, ApiError> {
let instance = state.app_ctx.instance.clone();
application::social::execute::execute_command(
&state.app_ctx.deps.social.command,
application::social::commands::SocialCmd::AcceptFollow {
owner_id: user.0.value(),
requester: instance.identify(&body.actor_url),
},
)
.await?;
Ok(StatusCode::OK)
}
#[utoipa::path(
post, path = "/api/v1/social/followers/reject",
request_body = ActorUrlRequest,
responses(
(status = 200, description = "Follower rejected"),
(status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
),
security(("bearer_auth" = []))
)]
pub async fn reject_follower(
State(state): State<AppState>,
user: AuthenticatedUser,
Body(body): Body<ActorUrlRequest>,
) -> Result<impl IntoResponse, ApiError> {
let instance = state.app_ctx.instance.clone();
application::social::execute::execute_command(
&state.app_ctx.deps.social.command,
application::social::commands::SocialCmd::RejectFollow {
owner_id: user.0.value(),
requester: instance.identify(&body.actor_url),
},
)
.await?;
Ok(StatusCode::OK)
}
#[utoipa::path(
post, path = "/api/v1/social/followers/remove",
request_body = ActorUrlRequest,
responses(
(status = 200, description = "Follower removed"),
(status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
),
security(("bearer_auth" = []))
)]
pub async fn remove_follower(
State(state): State<AppState>,
user: AuthenticatedUser,
Body(body): Body<ActorUrlRequest>,
) -> Result<impl IntoResponse, ApiError> {
let instance = state.app_ctx.instance.clone();
application::social::execute::execute_command(
&state.app_ctx.deps.social.command,
application::social::commands::SocialCmd::RemoveFollower {
owner_id: user.0.value(),
follower: instance.identify(&body.actor_url),
},
)
.await?;
Ok(StatusCode::OK)
}
#[utoipa::path(
get, path = "/api/v1/social/followers/pending",
responses(
(status = 200, body = ActorListResponse),
(status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
),
security(("bearer_auth" = []))
)]
pub async fn get_pending_followers(
State(state): State<AppState>,
user: AuthenticatedUser,
) -> Result<Json<ActorListResponse>, ApiError> {
let instance = state.app_ctx.instance.clone();
let identities = application::social::get_pending_followers::execute(
&state.app_ctx.deps.social.query,
user.0.value(),
)
.await?;
Ok(Json(ActorListResponse {
actors: identities
.into_iter()
.map(|a| social_actor_to_dto(a, &instance))
.collect(),
}))
}
#[utoipa::path(
get, path = "/api/v1/social/followers/pending/count",
responses(
(status = 200, body = PendingCountResponse),
(status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
),
security(("bearer_auth" = []))
)]
pub async fn get_pending_follower_count(
State(state): State<AppState>,
user: AuthenticatedUser,
) -> Result<Json<PendingCountResponse>, ApiError> {
let count = application::social::count_pending_followers::execute(
&state.app_ctx.deps.social.query,
user.0.value(),
)
.await?;
Ok(Json(PendingCountResponse { count }))
}
#[utoipa::path(
get, path = "/api/v1/social/following/pending",
responses(
(status = 200, body = ActorListResponse),
(status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
),
security(("bearer_auth" = []))
)]
pub async fn get_pending_following(
State(state): State<AppState>,
user: AuthenticatedUser,
) -> Result<Json<ActorListResponse>, ApiError> {
let instance = state.app_ctx.instance.clone();
let actors = application::social::get_pending_following::execute(
&state.app_ctx.deps.social.query,
user.0.value(),
)
.await?;
Ok(Json(ActorListResponse {
actors: actors
.into_iter()
.map(|a| social_actor_to_dto(a, &instance))
.collect(),
}))
}
#[derive(serde::Deserialize)]
pub struct RelationshipQuery {
pub actor_url: String,
}
#[utoipa::path(
get, path = "/api/v1/social/relationship",
params(("actor_url" = String, Query, description = "Canonical actor URL of the target")),
responses(
(status = 200, body = FollowRelationResponse),
(status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
),
security(("bearer_auth" = []))
)]
pub async fn get_relationship(
State(state): State<AppState>,
user: AuthenticatedUser,
Params(q): Params<RelationshipQuery>,
) -> Result<Json<FollowRelationResponse>, ApiError> {
let target = state.app_ctx.instance.identify(&q.actor_url);
let rel = application::social::get_relation::execute(
&state.app_ctx.deps.social.query,
user.0.value(),
target,
)
.await?;
Ok(Json(FollowRelationResponse {
following: rel.following.into(),
followed_by: rel.followed_by.into(),
}))
}
#[cfg(test)]
mod tests;

View File

@@ -1,69 +0,0 @@
use super::*;
use crate::test_support::local_actor;
use domain::value_objects::{InstanceIdentity, SocialActor, SocialIdentity, UserId};
#[test]
fn local_actor_dto_url_round_trips_back_to_the_same_identity() {
let instance = InstanceIdentity::new("https://md.example");
let uid = UserId::from_uuid(uuid::Uuid::new_v4());
let dto = social_actor_to_dto(local_actor(&uid), &instance);
assert!(!dto.url.starts_with("local:"), "url was {}", dto.url);
assert_eq!(
instance.identify(&dto.url),
SocialIdentity::Local(uid),
"a local actor's dto url must parse back as Local"
);
}
#[test]
fn remote_actor_dto_url_is_passed_through_unchanged() {
let instance = InstanceIdentity::new("https://md.example");
let actor = SocialActor {
identity: SocialIdentity::Remote {
actor_url: "https://other.example/users/bob".into(),
},
handle: "@bob@other.example".into(),
display_name: None,
avatar_url: None,
};
let dto = social_actor_to_dto(actor, &instance);
assert_eq!(dto.url, "https://other.example/users/bob");
}
#[test]
fn local_actor_dto_carries_user_id_and_avatar() {
let instance = InstanceIdentity::new("https://md.example");
let uid = UserId::from_uuid(uuid::Uuid::new_v4());
let mut actor = local_actor(&uid);
actor.avatar_url = Some("https://md.example/images/a.webp".into());
let dto = social_actor_to_dto(actor, &instance);
assert_eq!(
dto.user_id,
Some(uid.value()),
"local actors must carry user_id for internal links"
);
assert_eq!(
dto.avatar_url.as_deref(),
Some("https://md.example/images/a.webp")
);
}
#[test]
fn remote_actor_dto_has_no_user_id() {
let instance = InstanceIdentity::new("https://md.example");
let actor = SocialActor {
identity: SocialIdentity::Remote {
actor_url: "https://other.example/users/bob".into(),
},
handle: "@bob@other.example".into(),
display_name: None,
avatar_url: None,
};
assert_eq!(social_actor_to_dto(actor, &instance).user_id, None);
}

View File

@@ -1,287 +0,0 @@
use crate::extractors::{Params, PathId};
use crate::{extractors::AuthenticatedUser, state::AppState};
use api_types::mappers::goal_with_progress_to_dto;
use api_types::{
ActorStatDto, DiaryResponse, DirectorStatDto, GenreStatDto, MonthActivityDto, MonthlyRatingDto,
UserProfileQueryParams, UserProfileResponse, UserStatsDto, UserTrendsDto, WatchMediumStatDto,
};
use application::users::{
get_federated_profile, get_federated_profile_stats, get_local_profile,
queries::GetUserProfileQuery,
};
use axum::{Json, extract::State, http::StatusCode, response::IntoResponse};
use domain::errors::DomainError;
use std::str::FromStr;
use uuid::Uuid;
#[utoipa::path(
get, path = "/api/v1/users/{id}",
params(
("id" = Uuid, Path, description = "User ID"),
UserProfileQueryParams,
),
responses(
(status = 200, body = UserProfileResponse),
(status = 404, description = "User not found", body = api_types::ErrorResponse),
)
)]
pub async fn get_user_profile(
State(state): State<AppState>,
AuthenticatedUser(viewer_id): AuthenticatedUser,
PathId(user_id): PathId<Uuid>,
Params(params): Params<UserProfileQueryParams>,
) -> impl IntoResponse {
let view_str = params.view.as_deref().unwrap_or("recent");
let profile_view = match application::users::queries::ProfileView::from_str(view_str) {
Ok(v) => v,
Err(_) => {
return crate::errors::refuse(
StatusCode::BAD_REQUEST,
crate::errors::INVALID_INPUT,
"invalid profile view",
);
}
};
let profile = match get_local_profile::execute(
&state.app_ctx.deps.users.get_local_profile,
GetUserProfileQuery {
user_id,
view: profile_view,
limit: params.limit,
offset: params.offset,
sort_by: domain::models::FeedSortBy::Date,
search: params.search.clone(),
is_own_profile: viewer_id.value() == user_id,
include_remote: false,
},
)
.await
{
Ok(p) => p,
Err(DomainError::NotFound(_)) => {
if let Ok(Some(fed)) = get_federated_profile::execute(
&state.app_ctx.deps.users.get_federated_profile,
user_id,
)
.await
{
return build_federated_profile_response(
&state,
user_id,
fed,
profile_view,
&params,
)
.await;
}
return crate::errors::refuse(
StatusCode::NOT_FOUND,
crate::errors::NOT_FOUND,
"not found",
);
}
Err(e) => return crate::errors::ApiError(e).into_response(),
};
let view_data = if let Some(p) = profile.entries {
Some(api_types::ProfileViewData::Entries {
entries: DiaryResponse {
items: p
.items
.iter()
.map(api_types::mappers::entry_to_dto)
.collect(),
total_count: p.total_count,
limit: p.limit,
offset: p.offset,
},
})
} else if let Some(h) = profile.history {
Some(api_types::ProfileViewData::History {
history: application::users::group_by_month(h)
.into_iter()
.map(|m| MonthActivityDto {
year_month: m.year_month,
month_label: m.month_label,
count: m.count,
entries: m
.entries
.iter()
.map(api_types::mappers::entry_to_dto)
.collect(),
})
.collect(),
})
} else {
profile.trends.map(|t| api_types::ProfileViewData::Trends {
trends: trends_to_dto(t),
})
};
Json(UserProfileResponse {
user_id,
profile: api_types::UserProfileBase {
username: profile.identity.username.clone(),
avatar_url: profile.identity.avatar_url.clone(),
banner_url: profile.identity.banner_url.clone(),
display_name: profile.identity.display_name.clone(),
bio: profile.identity.bio.clone(),
},
stats: UserStatsDto {
total_movies: profile.stats.total_movies,
avg_rating: profile.stats.avg_rating,
favorite_director: profile.stats.favorite_director,
most_active_month: profile.stats.most_active_month,
},
following_count: profile.following_count,
followers_count: profile.followers_count,
view_data,
goals: {
let goals_list = application::goals::list::execute(
&state.app_ctx.deps.goals.query,
application::goals::queries::ListGoalsQuery { user_id },
)
.await
.unwrap_or_default();
if goals_list.is_empty() {
None
} else {
Some(goals_list.iter().map(goal_with_progress_to_dto).collect())
}
},
is_federated: false,
is_service_account: domain::value_objects::UserId::from_uuid(user_id).is_service_actor(),
handle: Some(profile.identity.handle.clone()),
actor_url: Some(profile.identity.actor_url.clone()),
})
.into_response()
}
async fn build_federated_profile_response(
state: &AppState,
user_id: Uuid,
fed: domain::models::FederatedProfile,
profile_view: application::users::queries::ProfileView,
params: &UserProfileQueryParams,
) -> axum::response::Response {
let profile = match get_federated_profile_stats::execute(
&state.app_ctx.deps.users.get_federated_profile_stats,
GetUserProfileQuery {
user_id,
view: profile_view,
limit: params.limit,
offset: params.offset,
sort_by: domain::models::FeedSortBy::Date,
search: params.search.clone(),
is_own_profile: false,
include_remote: true,
},
)
.await
{
Ok(p) => p,
Err(e) => return crate::errors::ApiError(e).into_response(),
};
let view_data = if let Some(p) = profile.entries {
Some(api_types::ProfileViewData::Entries {
entries: DiaryResponse {
items: p
.items
.iter()
.map(api_types::mappers::entry_to_dto)
.collect(),
total_count: p.total_count,
limit: p.limit,
offset: p.offset,
},
})
} else {
profile.trends.map(|t| api_types::ProfileViewData::Trends {
trends: trends_to_dto(t),
})
};
let username = fed
.display_name
.clone()
.unwrap_or_else(|| fed.handle.clone());
Json(UserProfileResponse {
user_id,
profile: api_types::UserProfileBase {
username,
avatar_url: fed.avatar_url,
banner_url: fed.banner_url,
display_name: fed.display_name,
bio: fed.bio,
},
stats: UserStatsDto {
total_movies: profile.stats.total_movies,
avg_rating: profile.stats.avg_rating,
favorite_director: profile.stats.favorite_director,
most_active_month: profile.stats.most_active_month,
},
following_count: 0,
followers_count: 0,
view_data,
goals: None,
is_federated: true,
is_service_account: false,
handle: Some(fed.handle),
actor_url: Some(fed.actor_url),
})
.into_response()
}
fn trends_to_dto(t: domain::models::UserTrends) -> UserTrendsDto {
UserTrendsDto {
monthly_ratings: t
.monthly_ratings
.into_iter()
.map(|r| MonthlyRatingDto {
year_month: r.year_month,
month_label: r.month_label,
avg_rating: r.avg_rating,
count: r.count,
})
.collect(),
top_directors: t
.top_directors
.into_iter()
.map(|d| DirectorStatDto {
director: d.director,
count: d.count,
})
.collect(),
max_director_count: t.max_director_count,
top_genres: t
.top_genres
.into_iter()
.map(|g| GenreStatDto {
genre: g.genre,
count: g.count,
})
.collect(),
rating_distribution: t.rating_distribution,
watch_medium_distribution: t
.watch_medium_distribution
.into_iter()
.map(|m| WatchMediumStatDto {
medium: m.medium,
count: m.count,
})
.collect(),
top_actors: t
.top_actors
.into_iter()
.map(|a| ActorStatDto {
name: a.name,
profile_path: a.profile_path,
movie_count: a.movie_count,
score: a.score,
})
.collect(),
}
}

View File

@@ -1,217 +0,0 @@
use crate::extractors::{Body, PathId, Upload};
use crate::{errors::ApiError, extractors::AuthenticatedUser, state::AppState};
use api_types::{DeleteAccountRequest, ProfileResponse, UserSummaryDto, UsersResponse};
use application::users::{
get_users, queries::GetUsersQuery, request_account_deletion, update_profile,
update_profile_fields,
};
use axum::{Json, extract::State, http::StatusCode, response::IntoResponse};
use domain::value_objects::UserId;
#[utoipa::path(
get, path = "/api/v1/profile",
responses(
(status = 200, body = ProfileResponse),
(status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
(status = 404, description = "User not found", body = api_types::ErrorResponse),
),
security(("bearer_auth" = []))
)]
pub async fn get_profile(
State(state): State<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser,
) -> Result<Json<ProfileResponse>, ApiError> {
let profile = application::users::get_current_profile::execute(
&state.app_ctx.deps.users.get_current_profile,
application::users::queries::GetCurrentProfileQuery {
user_id: user_id.value(),
},
)
.await?;
let instance = &state.app_ctx.instance;
Ok(Json(ProfileResponse {
profile: api_types::UserProfileBase {
username: profile.username,
display_name: profile.display_name,
bio: profile.bio,
avatar_url: profile.avatar_path.map(|p| instance.image_url_for(&p)),
banner_url: profile.banner_path.map(|p| instance.image_url_for(&p)),
},
also_known_as: profile.also_known_as,
fields: profile
.fields
.into_iter()
.map(|f| api_types::ProfileFieldDto {
name: f.name,
value: f.value,
})
.collect(),
role: profile.role,
}))
}
#[utoipa::path(
put, path = "/api/v1/profile",
responses(
(status = 204, description = "Profile updated"),
(status = 400, description = "Invalid input", body = api_types::ErrorResponse),
(status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
(status = 500, description = "Internal server error", body = api_types::ErrorResponse),
),
security(("bearer_auth" = []))
)]
pub async fn update_profile_handler(
State(state): State<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser,
Upload(multipart): Upload,
) -> impl IntoResponse {
let data = crate::support::parse_profile_multipart(multipart).await;
let cmd = application::users::commands::UpdateProfileCommand {
user_id: user_id.value(),
display_name: data.display_name,
bio: data.bio,
avatar_bytes: data.avatar_bytes,
avatar_content_type: data.avatar_content_type,
banner_bytes: data.banner_bytes,
banner_content_type: data.banner_content_type,
also_known_as: data.also_known_as,
};
match update_profile::execute(&state.app_ctx.deps.users.update_profile, cmd).await {
Ok(()) => StatusCode::NO_CONTENT.into_response(),
Err(e) => crate::errors::ApiError(e).into_response(),
}
}
#[utoipa::path(
put, path = "/api/v1/profile/fields",
request_body = api_types::UpdateProfileFieldsRequest,
responses(
(status = 204, description = "Profile fields updated"),
(status = 400, description = "Invalid input", body = api_types::ErrorResponse),
(status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
(status = 500, description = "Internal server error", body = api_types::ErrorResponse),
),
security(("bearer_auth" = []))
)]
pub async fn update_profile_fields_handler(
State(state): State<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser,
Body(body): Body<serde_json::Value>,
) -> impl IntoResponse {
let raw_fields = match body.get("fields").and_then(|f| f.as_array()) {
Some(arr) => arr.clone(),
None => {
return crate::errors::refuse(
StatusCode::BAD_REQUEST,
crate::errors::INVALID_INPUT,
"body must contain a fields array",
);
}
};
let fields: Vec<domain::models::ProfileField> = raw_fields
.iter()
.filter_map(|f| {
let name = f.get("name").and_then(|n| n.as_str())?.to_string();
let value = f.get("value").and_then(|v| v.as_str())?.to_string();
Some(domain::models::ProfileField { name, value })
})
.collect();
let cmd = application::users::commands::UpdateProfileFieldsCommand {
user_id: user_id.value(),
fields,
};
match update_profile_fields::execute(&state.app_ctx.deps.users.update_profile_fields, cmd).await
{
Ok(()) => StatusCode::NO_CONTENT.into_response(),
Err(e) => crate::errors::ApiError(e).into_response(),
}
}
#[utoipa::path(
get, path = "/api/v1/users",
responses(
(status = 200, body = UsersResponse),
(status = 429, description = "Rate limited", body = api_types::ErrorResponse),
),
)]
pub async fn list_users(State(state): State<AppState>) -> Result<Json<UsersResponse>, ApiError> {
let result =
get_users::execute(&state.app_ctx.deps.users.get_users_list, GetUsersQuery).await?;
Ok(Json(UsersResponse {
users: result
.users
.iter()
.map(|u| UserSummaryDto {
id: u.user_id.value(),
email: u.email().to_string(),
username: u.username().to_string(),
display_name: u.display_name().map(String::from),
total_movies: u.total_movies,
avg_rating: u.avg_rating,
})
.collect(),
}))
}
async fn delete_account(
state: &AppState,
actor: UserId,
target: UserId,
password: &str,
) -> Result<StatusCode, ApiError> {
request_account_deletion::execute(
&state.app_ctx.deps.users.request_account_deletion,
actor,
target,
password,
)
.await?;
Ok(StatusCode::NO_CONTENT)
}
#[utoipa::path(
delete, path = "/api/v1/users/me",
request_body = DeleteAccountRequest,
responses(
(status = 204, description = "Account deleted"),
(status = 400, description = "Invalid input", body = api_types::ErrorResponse),
(status = 401, description = "Unauthorized or incorrect password", body = api_types::ErrorResponse),
(status = 409, description = "The last admin cannot be deleted", body = api_types::ErrorResponse),
),
security(("bearer_auth" = []))
)]
pub async fn delete_me(
State(state): State<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser,
Body(req): Body<DeleteAccountRequest>,
) -> Result<StatusCode, ApiError> {
delete_account(&state, user_id.clone(), user_id, &req.password).await
}
#[utoipa::path(
delete, path = "/api/v1/users/{id}",
params(("id" = uuid::Uuid, Path, description = "User ID")),
request_body = DeleteAccountRequest,
responses(
(status = 204, description = "Account deleted"),
(status = 400, description = "Invalid input", body = api_types::ErrorResponse),
(status = 401, description = "Unauthorized or incorrect password", body = api_types::ErrorResponse),
(status = 403, description = "Admin only", body = api_types::ErrorResponse),
(status = 404, description = "User not found", body = api_types::ErrorResponse),
(status = 409, description = "The last admin cannot be deleted", body = api_types::ErrorResponse),
),
security(("bearer_auth" = []))
)]
pub async fn delete_user(
State(state): State<AppState>,
AuthenticatedUser(actor): AuthenticatedUser,
PathId(target): PathId<uuid::Uuid>,
Body(req): Body<DeleteAccountRequest>,
) -> Result<StatusCode, ApiError> {
delete_account(&state, actor, UserId::from_uuid(target), &req.password).await
}

View File

@@ -1,144 +0,0 @@
use crate::extractors::{Body, Params, PathId};
use crate::{errors::ApiError, extractors::AuthenticatedUser, state::AppState};
use api_types::{
AddToWatchlistRequest, PaginationQueryParams, WatchlistEntryDto, WatchlistResponse,
WatchlistStatusResponse,
};
use application::{
diary::commands::MovieInput,
watchlist::{
add as add_to_watchlist,
commands::{AddToWatchlistCommand, RemoveFromWatchlistCommand},
get as get_watchlist, is_on as is_on_watchlist,
queries::{GetWatchlistQuery, IsOnWatchlistQuery},
remove as remove_from_watchlist,
},
};
use axum::{Json, extract::State, http::StatusCode, response::IntoResponse};
use uuid::Uuid;
#[utoipa::path(
get, path = "/api/v1/watchlist",
params(
("limit" = Option<u32>, Query, description = "Max results"),
("offset" = Option<u32>, Query, description = "Offset"),
),
responses(
(status = 200, body = WatchlistResponse),
(status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
),
security(("bearer_auth" = []))
)]
pub async fn get_watchlist_handler(
State(state): State<AppState>,
user: AuthenticatedUser,
Params(params): Params<PaginationQueryParams>,
) -> Result<Json<WatchlistResponse>, ApiError> {
let page = get_watchlist::execute(
&state.app_ctx.deps.watchlist.get_watchlist,
GetWatchlistQuery {
user_id: user.0.value(),
limit: params.limit,
offset: params.offset,
},
)
.await?;
Ok(Json(WatchlistResponse {
items: page
.items
.into_iter()
.map(|w| WatchlistEntryDto {
id: w.entry.id.value(),
movie: api_types::mappers::movie_to_dto(&w.movie),
added_at: w.entry.added_at.to_string(),
})
.collect(),
total_count: page.total_count,
limit: page.limit,
offset: page.offset,
}))
}
#[utoipa::path(
post, path = "/api/v1/watchlist",
request_body = AddToWatchlistRequest,
responses(
(status = 201, description = "Added to watchlist"),
(status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
(status = 404, description = "Movie not found", body = api_types::ErrorResponse),
),
security(("bearer_auth" = []))
)]
pub async fn post_watchlist_add(
State(state): State<AppState>,
user: AuthenticatedUser,
Body(req): Body<AddToWatchlistRequest>,
) -> Result<impl IntoResponse, ApiError> {
add_to_watchlist::execute(
&state.app_ctx.deps.watchlist.add,
AddToWatchlistCommand {
user_id: user.0.value(),
input: MovieInput {
movie_id: req.movie_id,
external_metadata_id: req.external_metadata_id,
manual_title: req.manual_title,
manual_release_year: req.manual_release_year,
manual_director: None,
},
},
)
.await?;
Ok(StatusCode::CREATED)
}
#[utoipa::path(
delete, path = "/api/v1/watchlist/{movie_id}",
params(("movie_id" = Uuid, Path, description = "Movie ID")),
responses(
(status = 204, description = "Removed from watchlist"),
(status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
(status = 404, description = "Not on watchlist", body = api_types::ErrorResponse),
),
security(("bearer_auth" = []))
)]
pub async fn delete_watchlist_entry(
State(state): State<AppState>,
user: AuthenticatedUser,
PathId(movie_id): PathId<Uuid>,
) -> Result<impl IntoResponse, ApiError> {
remove_from_watchlist::execute(
&state.app_ctx.deps.watchlist.remove_from_watchlist,
RemoveFromWatchlistCommand {
user_id: user.0.value(),
movie_id,
},
)
.await?;
Ok(StatusCode::NO_CONTENT)
}
#[utoipa::path(
get, path = "/api/v1/watchlist/{movie_id}",
params(("movie_id" = Uuid, Path, description = "Movie ID")),
responses(
(status = 200, body = WatchlistStatusResponse),
(status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
),
security(("bearer_auth" = []))
)]
pub async fn get_watchlist_status(
State(state): State<AppState>,
user: AuthenticatedUser,
PathId(movie_id): PathId<Uuid>,
) -> Result<Json<WatchlistStatusResponse>, ApiError> {
let on_watchlist = is_on_watchlist::execute(
&state.app_ctx.deps.watchlist.is_on_watchlist,
IsOnWatchlistQuery {
user_id: user.0.value(),
movie_id,
},
)
.await?;
Ok(Json(WatchlistStatusResponse { on_watchlist }))
}

View File

@@ -1,344 +0,0 @@
use crate::extractors::{Body, Params, PathId, Upload};
use crate::{errors::ApiError, extractors::AuthenticatedUser, state::AppState};
use api_types::{
ConfirmWatchRequest, ConfirmWatchResponse, DismissWatchRequest, DismissWatchResponse,
GenerateTokenRequest, GenerateTokenResponse, WatchQueueEntryDto, WebhookTokenDto,
};
use application::integrations::{
commands::{
ConfirmWatchEventsCommand, DismissWatchEventsCommand, GenerateWebhookTokenCommand,
IngestWatchEventCommand, RevokeWebhookTokenCommand, WatchEventConfirmation,
},
confirm as confirm_watch_events, dismiss as dismiss_watch_events,
generate_token as generate_webhook_token, get_queue as get_watch_queue,
get_tokens as get_webhook_tokens, ingest as ingest_watch_event,
queries::{GetWatchQueueQuery, GetWebhookTokensQuery},
revoke_token as revoke_webhook_token,
};
use axum::{
Json,
extract::State,
http::{HeaderMap, StatusCode},
response::IntoResponse,
};
use domain::models::WatchEventSource;
use uuid::Uuid;
fn extract_bearer_token(headers: &HeaderMap) -> Option<String> {
headers
.get("authorization")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.map(|t| t.trim().to_string())
}
#[derive(serde::Deserialize, Default)]
pub struct WebhookTokenQuery {
pub token: Option<String>,
}
fn missing_token() -> axum::response::Response {
crate::errors::refuse(
StatusCode::UNAUTHORIZED,
crate::errors::UNAUTHORIZED,
"missing webhook token",
)
}
fn extract_webhook_token(headers: &HeaderMap, query: &WebhookTokenQuery) -> Option<String> {
extract_bearer_token(headers).or_else(|| query.token.clone())
}
#[utoipa::path(
post, path = "/api/v1/webhooks/jellyfin",
request_body(content = String, description = "Jellyfin webhook JSON payload (SendAllProperties=true)", content_type = "application/json"),
responses(
(status = 200, description = "Event accepted or ignored"),
(status = 400, description = "Invalid payload", body = api_types::ErrorResponse),
(status = 401, description = "Invalid or missing webhook token", body = api_types::ErrorResponse),
),
security(("bearer_auth" = []))
)]
pub async fn post_jellyfin_webhook(
State(state): State<AppState>,
headers: HeaderMap,
Params(query): Params<WebhookTokenQuery>,
body: axum::body::Bytes,
) -> impl IntoResponse {
let token = match extract_webhook_token(&headers, &query) {
Some(t) => t,
None => return missing_token(),
};
let cmd = IngestWatchEventCommand {
token,
raw_payload: body.to_vec(),
source: WatchEventSource::Jellyfin,
};
run_ingest(
&state,
cmd,
&*state.app_ctx.deps.integrations.jellyfin_parser,
)
.await
}
#[utoipa::path(
post, path = "/api/v1/webhooks/plex",
request_body(content = String, description = "Plex webhook multipart form (payload field contains JSON)", content_type = "multipart/form-data"),
responses(
(status = 200, description = "Event accepted or ignored"),
(status = 400, description = "Invalid payload", body = api_types::ErrorResponse),
(status = 401, description = "Invalid or missing webhook token", body = api_types::ErrorResponse),
),
security(("bearer_auth" = []))
)]
pub async fn post_plex_webhook(
State(state): State<AppState>,
headers: HeaderMap,
Params(query): Params<WebhookTokenQuery>,
Upload(mut multipart): Upload,
) -> impl IntoResponse {
let token = match extract_webhook_token(&headers, &query) {
Some(t) => t,
None => return missing_token(),
};
let mut payload_bytes: Option<Vec<u8>> = None;
while let Ok(Some(field)) = multipart.next_field().await {
if field.name() == Some("payload")
&& let Ok(bytes) = field.bytes().await
{
payload_bytes = Some(bytes.to_vec());
break;
}
}
let raw_payload = match payload_bytes {
Some(b) => b,
None => {
return crate::errors::refuse(
StatusCode::BAD_REQUEST,
crate::errors::INVALID_INPUT,
"multipart form has no payload field",
);
}
};
let cmd = IngestWatchEventCommand {
token,
raw_payload,
source: WatchEventSource::Plex,
};
run_ingest(&state, cmd, &*state.app_ctx.deps.integrations.plex_parser).await
}
async fn run_ingest(
state: &AppState,
cmd: IngestWatchEventCommand,
parser: &dyn domain::ports::MediaServerParser,
) -> axum::response::Response {
match ingest_watch_event::execute(
&state.app_ctx.deps.integrations.ingest_watch_event,
cmd,
parser,
)
.await
{
Ok(()) => StatusCode::OK.into_response(),
Err(e) => crate::errors::ApiError(e).into_response(),
}
}
#[utoipa::path(
post, path = "/api/v1/settings/webhook-tokens",
request_body = GenerateTokenRequest,
responses(
(status = 200, body = GenerateTokenResponse),
(status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
),
security(("bearer_auth" = []))
)]
pub async fn post_generate_webhook_token(
State(state): State<AppState>,
user: AuthenticatedUser,
Body(req): Body<GenerateTokenRequest>,
) -> Result<Json<GenerateTokenResponse>, ApiError> {
let provider: WatchEventSource = req
.provider
.parse()
.map_err(|e: String| domain::errors::DomainError::ValidationError(e))?;
let cmd = GenerateWebhookTokenCommand {
user_id: user.0.value(),
provider: provider.clone(),
label: req.label,
};
let result = generate_webhook_token::execute(
&state.app_ctx.deps.integrations.generate_webhook_token,
cmd,
)
.await?;
let base_url = &state.app_ctx.config.base_url;
let webhook_url = format!("{base_url}/api/v1/webhooks/{provider}");
Ok(Json(GenerateTokenResponse {
id: result.token.id().value().to_string(),
token: result.token_plaintext,
webhook_url,
}))
}
#[utoipa::path(
get, path = "/api/v1/settings/webhook-tokens",
responses(
(status = 200, body = Vec<WebhookTokenDto>),
(status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
),
security(("bearer_auth" = []))
)]
pub async fn get_webhook_tokens(
State(state): State<AppState>,
user: AuthenticatedUser,
) -> Result<Json<Vec<WebhookTokenDto>>, ApiError> {
let query = GetWebhookTokensQuery {
user_id: user.0.value(),
};
let tokens =
get_webhook_tokens::execute(&state.app_ctx.deps.integrations.get_webhook_tokens, query)
.await?;
let dtos = tokens
.into_iter()
.map(|t| WebhookTokenDto {
id: t.id().value().to_string(),
provider: t.provider().to_string(),
label: t.label().map(String::from),
created_at: t.created_at().to_string(),
last_used_at: t.last_used_at().map(|d| d.to_string()),
})
.collect();
Ok(Json(dtos))
}
#[utoipa::path(
delete, path = "/api/v1/settings/webhook-tokens/{id}",
params(("id" = Uuid, Path, description = "Webhook token ID")),
responses(
(status = 204, description = "Token revoked"),
(status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
(status = 404, description = "Token not found", body = api_types::ErrorResponse),
),
security(("bearer_auth" = []))
)]
pub async fn delete_webhook_token(
State(state): State<AppState>,
user: AuthenticatedUser,
PathId(id): PathId<Uuid>,
) -> Result<StatusCode, ApiError> {
let cmd = RevokeWebhookTokenCommand {
user_id: user.0.value(),
token_id: id,
};
revoke_webhook_token::execute(&state.app_ctx.deps.integrations.revoke_webhook_token, cmd)
.await?;
Ok(StatusCode::NO_CONTENT)
}
#[utoipa::path(
get, path = "/api/v1/watch-queue",
responses(
(status = 200, body = Vec<WatchQueueEntryDto>),
(status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
),
security(("bearer_auth" = []))
)]
pub async fn get_watch_queue(
State(state): State<AppState>,
user: AuthenticatedUser,
) -> Result<Json<Vec<WatchQueueEntryDto>>, ApiError> {
let query = GetWatchQueueQuery {
user_id: user.0.value(),
};
let events =
get_watch_queue::execute(&state.app_ctx.deps.integrations.get_watch_queue, query).await?;
let dtos = events
.into_iter()
.map(|e| WatchQueueEntryDto {
id: e.id().value().to_string(),
title: e.title().to_string(),
year: e.year(),
movie_id: e.movie_id().map(|m| m.value().to_string()),
source: e.source().to_string(),
watched_at: e.watched_at().to_string(),
})
.collect();
Ok(Json(dtos))
}
#[utoipa::path(
post, path = "/api/v1/watch-queue/confirm",
request_body = ConfirmWatchRequest,
responses(
(status = 200, body = ConfirmWatchResponse),
(status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
(status = 403, description = "Forbidden — not your watch event", body = api_types::ErrorResponse),
),
security(("bearer_auth" = []))
)]
pub async fn post_confirm_watch_events(
State(state): State<AppState>,
user: AuthenticatedUser,
Body(req): Body<ConfirmWatchRequest>,
) -> Result<Json<ConfirmWatchResponse>, ApiError> {
let cmd = ConfirmWatchEventsCommand {
user_id: user.0.value(),
confirmations: req
.confirmations
.into_iter()
.map(|c| WatchEventConfirmation {
watch_event_id: c.watch_event_id,
rating: c.rating,
comment: c.comment,
})
.collect(),
};
let confirmed =
confirm_watch_events::execute(&state.app_ctx.deps.integrations.confirm_watch_events, cmd)
.await?;
Ok(Json(ConfirmWatchResponse { confirmed }))
}
#[utoipa::path(
post, path = "/api/v1/watch-queue/dismiss",
request_body = DismissWatchRequest,
responses(
(status = 200, body = DismissWatchResponse),
(status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
(status = 403, description = "Forbidden — not your watch event", body = api_types::ErrorResponse),
),
security(("bearer_auth" = []))
)]
pub async fn post_dismiss_watch_events(
State(state): State<AppState>,
user: AuthenticatedUser,
Body(req): Body<DismissWatchRequest>,
) -> Result<Json<DismissWatchResponse>, ApiError> {
let cmd = DismissWatchEventsCommand {
user_id: user.0.value(),
event_ids: req.event_ids,
};
let dismissed =
dismiss_watch_events::execute(&state.app_ctx.deps.integrations.dismiss_watch_events, cmd)
.await?;
Ok(Json(DismissWatchResponse { dismissed }))
}

View File

@@ -1,176 +0,0 @@
use crate::extractors::{Body, PathId};
use crate::{
errors::ApiError,
extractors::{AdminApiUser, AuthenticatedUser},
state::AppState,
};
use api_types::wrapup::{
GenerateWrapUpRequest, WrapUpGeneratedResponse, WrapUpListResponse, WrapUpStatusResponse,
};
use application::wrapup::{
commands::RequestWrapUpCommand,
delete as delete_wrapup, generate, get_wrapup,
list_wrapups::{self, ListWrapUpsQuery},
};
use axum::{Json, extract::State, http::StatusCode, response::IntoResponse};
use chrono::NaiveDate;
use domain::errors::DomainError;
use domain::models::wrapup::{WrapUpRecord, WrapUpStatus};
use domain::value_objects::WrapUpId;
use uuid::Uuid;
fn record_to_dto(r: &WrapUpRecord) -> WrapUpStatusResponse {
WrapUpStatusResponse {
id: r.id.value().to_string(),
user_id: r.user_id.map(|u| u.to_string()),
status: format!("{:?}", r.status),
start_date: r.start_date.to_string(),
end_date: r.end_date.to_string(),
created_at: r.created_at.to_string(),
completed_at: r.completed_at.map(|t| t.to_string()),
error_message: r.error_message.clone(),
}
}
#[utoipa::path(
post, path = "/api/v1/wrapups/generate",
request_body = GenerateWrapUpRequest,
responses(
(status = 200, body = WrapUpGeneratedResponse),
(status = 400, description = "Invalid date format", body = api_types::ErrorResponse),
(status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
(status = 403, description = "Forbidden — admin only", body = api_types::ErrorResponse),
),
security(("bearer_auth" = []))
)]
pub async fn post_generate(
State(state): State<AppState>,
_admin: AdminApiUser,
Body(req): Body<GenerateWrapUpRequest>,
) -> Result<Json<WrapUpGeneratedResponse>, ApiError> {
let start = NaiveDate::parse_from_str(&req.start_date, "%Y-%m-%d")
.map_err(|_| DomainError::ValidationError("invalid start_date".into()))?;
let end = NaiveDate::parse_from_str(&req.end_date, "%Y-%m-%d")
.map_err(|_| DomainError::ValidationError("invalid end_date".into()))?;
let user_id = req.user_id;
let cmd = RequestWrapUpCommand {
user_id,
start_date: start,
end_date: end,
};
let id = generate::execute(&state.app_ctx.deps.wrapup.generate, cmd).await?;
Ok(Json(WrapUpGeneratedResponse {
id: id.value().to_string(),
}))
}
#[utoipa::path(
get, path = "/api/v1/wrapups",
responses(
(status = 200, body = WrapUpListResponse),
(status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
),
security(("bearer_auth" = []))
)]
pub async fn get_list(
State(state): State<AppState>,
user: AuthenticatedUser,
) -> Result<Json<WrapUpListResponse>, ApiError> {
let records = list_wrapups::execute(
&state.app_ctx.deps.wrapup.list_wrapups,
ListWrapUpsQuery {
user_id: Some(user.0.value()),
},
)
.await?;
Ok(Json(WrapUpListResponse {
items: records.iter().map(record_to_dto).collect(),
}))
}
#[utoipa::path(
get, path = "/api/v1/wrapups/{id}",
params(("id" = Uuid, Path, description = "Wrap-up ID")),
responses(
(status = 200, body = WrapUpStatusResponse),
(status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
(status = 404, description = "Not found", body = api_types::ErrorResponse),
),
security(("bearer_auth" = []))
)]
pub async fn get_status(
State(state): State<AppState>,
_user: AuthenticatedUser,
PathId(id): PathId<Uuid>,
) -> Result<Json<WrapUpStatusResponse>, ApiError> {
let record = get_wrapup::execute(
&state.app_ctx.deps.wrapup.get_wrapup,
WrapUpId::from_uuid(id),
)
.await?
.ok_or_else(|| DomainError::NotFound("wrap-up not found".into()))?;
Ok(Json(record_to_dto(&record)))
}
#[utoipa::path(
get, path = "/api/v1/wrapups/{id}/report",
params(("id" = Uuid, Path, description = "Wrap-up ID")),
responses(
(status = 200, description = "Report JSON", body = inline(serde_json::Value)),
(status = 202, description = "Still generating"),
(status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
(status = 404, description = "Not found", body = api_types::ErrorResponse),
),
security(("bearer_auth" = []))
)]
pub async fn get_report(
State(state): State<AppState>,
_user: AuthenticatedUser,
PathId(id): PathId<Uuid>,
) -> impl IntoResponse {
match get_wrapup::execute(
&state.app_ctx.deps.wrapup.get_wrapup,
WrapUpId::from_uuid(id),
)
.await
{
Ok(Some(record)) if record.status == WrapUpStatus::Ready => match record.report {
Some(ref report) => {
let json = serde_json::to_string(report).unwrap_or_default();
(StatusCode::OK, [("content-type", "application/json")], json).into_response()
}
None => {
crate::errors::refuse(StatusCode::NOT_FOUND, crate::errors::NOT_FOUND, "not found")
}
},
Ok(Some(_)) => StatusCode::ACCEPTED.into_response(),
Ok(None) => {
crate::errors::refuse(StatusCode::NOT_FOUND, crate::errors::NOT_FOUND, "not found")
}
Err(e) => crate::errors::ApiError(e).into_response(),
}
}
#[utoipa::path(
delete, path = "/api/v1/wrapups/{id}",
params(("id" = Uuid, Path, description = "Wrap-up ID")),
responses(
(status = 204, description = "Deleted"),
(status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
(status = 403, description = "Forbidden — admin only", body = api_types::ErrorResponse),
(status = 404, description = "Not found", body = api_types::ErrorResponse),
),
security(("bearer_auth" = []))
)]
pub async fn delete_wrapup_handler(
State(state): State<AppState>,
_admin: AdminApiUser,
PathId(id): PathId<Uuid>,
) -> Result<StatusCode, ApiError> {
delete_wrapup::execute(
&state.app_ctx.deps.wrapup.delete_wrapup,
WrapUpId::from_uuid(id),
)
.await?;
Ok(StatusCode::NO_CONTENT)
}

View File

@@ -1,15 +0,0 @@
use std::sync::Arc;
use application::config::AppSettings;
#[derive(Clone)]
pub struct AppContext {
pub deps: Arc<application::Deps>,
pub services: application::Services,
pub config: AppSettings,
pub instance: domain::value_objects::InstanceIdentity,
#[cfg(feature = "federation")]
pub ap_document: Arc<dyn domain::ports::ApDocumentPort>,
#[cfg(feature = "federation")]
pub ap_blocklist: Arc<dyn domain::ports::InstanceBlocklistPort>,
}

View File

@@ -1,81 +0,0 @@
use api_types::ErrorResponse;
use axum::{
http::StatusCode,
response::{IntoResponse, Response},
};
use domain::errors::DomainError;
pub const NOT_FOUND: &str = "NOT_FOUND";
pub const INVALID_INPUT: &str = "INVALID_INPUT";
pub const UNAUTHORIZED: &str = "UNAUTHORIZED";
pub const INVALID_CREDENTIALS: &str = "INVALID_CREDENTIALS";
pub const FORBIDDEN: &str = "FORBIDDEN";
pub const CONFLICT: &str = "CONFLICT";
pub const RATE_LIMITED: &str = "RATE_LIMITED";
pub const INTERNAL: &str = "INTERNAL";
pub fn refuse(status: StatusCode, code: &'static str, message: impl Into<String>) -> Response {
(
status,
axum::Json(ErrorResponse {
error: message.into(),
code: code.to_string(),
}),
)
.into_response()
}
pub fn domain_error_status(e: &DomainError) -> StatusCode {
match e {
DomainError::InvalidRating { .. } | DomainError::ValidationError(_) => {
StatusCode::BAD_REQUEST
}
DomainError::NotFound(_) => StatusCode::NOT_FOUND,
DomainError::Unauthorized(_) | DomainError::InvalidCredentials(_) => {
StatusCode::UNAUTHORIZED
}
DomainError::Forbidden(_) => StatusCode::FORBIDDEN,
DomainError::Conflict(_) => StatusCode::CONFLICT,
DomainError::InfrastructureError(_) => StatusCode::INTERNAL_SERVER_ERROR,
}
}
pub fn domain_error_response(e: DomainError) -> Response {
match &e {
DomainError::InfrastructureError(_) => {
tracing::error!("Internal error: {:?}", e);
StatusCode::INTERNAL_SERVER_ERROR.into_response()
}
_ => (domain_error_status(&e), e.to_string()).into_response(),
}
}
pub struct ApiError(pub DomainError);
impl From<DomainError> for ApiError {
fn from(err: DomainError) -> Self {
Self(err)
}
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
let status = domain_error_status(&self.0);
match &self.0 {
DomainError::InvalidRating { .. } | DomainError::ValidationError(_) => {
refuse(status, INVALID_INPUT, self.0.to_string())
}
DomainError::NotFound(_) => refuse(status, NOT_FOUND, self.0.to_string()),
DomainError::Unauthorized(_) => refuse(status, UNAUTHORIZED, self.0.to_string()),
DomainError::InvalidCredentials(_) => {
refuse(status, INVALID_CREDENTIALS, self.0.to_string())
}
DomainError::Forbidden(_) => refuse(status, FORBIDDEN, self.0.to_string()),
DomainError::Conflict(_) => refuse(status, CONFLICT, self.0.to_string()),
DomainError::InfrastructureError(_) => {
tracing::error!("Internal error: {:?}", self.0);
refuse(status, INTERNAL, "internal server error")
}
}
}
}

View File

@@ -1,122 +0,0 @@
use axum::{
extract::{
FromRequest, FromRequestParts, Multipart, Path, Query, Request,
multipart::MultipartRejection,
rejection::{JsonRejection, PathRejection, QueryRejection},
},
http::{StatusCode, request::Parts},
response::{IntoResponse, Response},
};
use serde::de::DeserializeOwned;
use crate::errors::{INTERNAL, INVALID_INPUT, refuse};
pub struct MalformedRequest {
status: StatusCode,
message: String,
}
impl IntoResponse for MalformedRequest {
fn into_response(self) -> Response {
let code = if self.status.is_server_error() {
INTERNAL
} else {
INVALID_INPUT
};
refuse(self.status, code, self.message)
}
}
impl From<JsonRejection> for MalformedRequest {
fn from(r: JsonRejection) -> Self {
Self {
status: r.status(),
message: r.body_text(),
}
}
}
impl From<QueryRejection> for MalformedRequest {
fn from(r: QueryRejection) -> Self {
Self {
status: r.status(),
message: r.body_text(),
}
}
}
impl From<PathRejection> for MalformedRequest {
fn from(r: PathRejection) -> Self {
Self {
status: r.status(),
message: r.body_text(),
}
}
}
impl From<MultipartRejection> for MalformedRequest {
fn from(r: MultipartRejection) -> Self {
Self {
status: r.status(),
message: r.body_text(),
}
}
}
pub struct Upload(pub Multipart);
impl<S> FromRequest<S> for Upload
where
S: Send + Sync,
{
type Rejection = MalformedRequest;
async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
Ok(Self(Multipart::from_request(req, state).await?))
}
}
pub struct Body<T>(pub T);
impl<S, T> FromRequest<S> for Body<T>
where
S: Send + Sync,
T: DeserializeOwned,
{
type Rejection = MalformedRequest;
async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
let axum::Json(v) = axum::Json::<T>::from_request(req, state).await?;
Ok(Self(v))
}
}
pub struct Params<T>(pub T);
impl<S, T> FromRequestParts<S> for Params<T>
where
S: Send + Sync,
T: DeserializeOwned,
{
type Rejection = MalformedRequest;
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
let Query(v) = Query::<T>::from_request_parts(parts, state).await?;
Ok(Self(v))
}
}
pub struct PathId<T>(pub T);
impl<S, T> FromRequestParts<S> for PathId<T>
where
S: Send + Sync,
T: DeserializeOwned + Send,
{
type Rejection = MalformedRequest;
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
let Path(v) = Path::<T>::from_request_parts(parts, state).await?;
Ok(Self(v))
}
}

View File

@@ -1,160 +0,0 @@
mod json;
pub use json::{Body, MalformedRequest, Params, PathId, Upload};
use axum::{
extract::{FromRef, FromRequestParts},
http::{StatusCode, header, header::AUTHORIZATION, request::Parts},
response::{IntoResponse, Redirect},
};
use domain::{errors::DomainError, value_objects::UserId};
use crate::{errors::ApiError, state::AppState};
async fn authenticate(state: &AppState, token: &str) -> Result<UserId, DomainError> {
application::auth::authenticate::execute(&state.app_ctx.deps.auth.authenticate, token).await
}
pub struct AuthenticatedUser(pub UserId);
impl<S> FromRequestParts<S> for AuthenticatedUser
where
AppState: FromRef<S>,
S: Send + Sync,
{
type Rejection = ApiError;
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
let app_state = AppState::from_ref(state);
let token = parts
.headers
.get(AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.ok_or_else(|| {
ApiError(DomainError::Unauthorized(
"Missing or invalid auth token".into(),
))
})?;
let user_id = authenticate(&app_state, token).await?;
Ok(AuthenticatedUser(user_id))
}
}
pub struct OptionalCookieUser(pub Option<UserId>);
pub struct RequiredCookieUser(pub UserId);
fn extract_token_from_cookie(parts: &Parts) -> Option<String> {
parts
.headers
.get(header::COOKIE)
.and_then(|v| v.to_str().ok())
.and_then(|cookies| {
cookies
.split(';')
.find_map(|c| c.trim().strip_prefix("token=").map(str::to_string))
})
}
impl<S> FromRequestParts<S> for OptionalCookieUser
where
AppState: FromRef<S>,
S: Send + Sync,
{
type Rejection = std::convert::Infallible;
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
let app_state = AppState::from_ref(state);
let Some(token) = extract_token_from_cookie(parts) else {
return Ok(OptionalCookieUser(None));
};
let user_id = match authenticate(&app_state, &token).await {
Ok(id) => Some(id),
Err(e) => {
if e.is_transient() {
tracing::warn!("authenticate failed, treating as anonymous: {e:?}");
}
None
}
};
Ok(OptionalCookieUser(user_id))
}
}
impl<S> FromRequestParts<S> for RequiredCookieUser
where
AppState: FromRef<S>,
S: Send + Sync,
{
type Rejection = axum::response::Response;
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
let app_state = AppState::from_ref(state);
let token = extract_token_from_cookie(parts)
.ok_or_else(|| Redirect::to("/login").into_response())?;
let user_id = authenticate(&app_state, &token)
.await
.map_err(|e| match e {
DomainError::InfrastructureError(_) => {
StatusCode::INTERNAL_SERVER_ERROR.into_response()
}
_ => Redirect::to("/login").into_response(),
})?;
Ok(RequiredCookieUser(user_id))
}
}
pub struct AdminApiUser(pub UserId);
impl<S> FromRequestParts<S> for AdminApiUser
where
AppState: FromRef<S>,
S: Send + Sync,
{
type Rejection = ApiError;
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
let AuthenticatedUser(user_id) =
AuthenticatedUser::from_request_parts(parts, state).await?;
let app_state = AppState::from_ref(state);
let is_admin = application::users::authorize_admin::execute(
&app_state.app_ctx.deps.users.authorize_admin,
user_id.value(),
)
.await
.map_err(ApiError)?
.ok_or_else(|| ApiError(DomainError::NotFound("user not found".into())))?;
if is_admin {
Ok(AdminApiUser(user_id))
} else {
Err(ApiError(DomainError::Forbidden("admin only".into())))
}
}
}
pub struct AdminUser(pub UserId);
impl<S> FromRequestParts<S> for AdminUser
where
AppState: FromRef<S>,
S: Send + Sync,
{
type Rejection = axum::response::Response;
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
let app_state = AppState::from_ref(state);
let RequiredCookieUser(user_id) =
RequiredCookieUser::from_request_parts(parts, state).await?;
let is_admin = application::users::authorize_admin::execute(
&app_state.app_ctx.deps.users.authorize_admin,
user_id.value(),
)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())?
.ok_or_else(|| StatusCode::UNAUTHORIZED.into_response())?;
if is_admin {
Ok(AdminUser(user_id))
} else {
Err(StatusCode::FORBIDDEN.into_response())
}
}
}

View File

@@ -1,170 +0,0 @@
use crate::extractors::AdminApiUser;
use crate::extractors::{Body, PathId};
use crate::{errors::ApiError, extractors::AuthenticatedUser, state::AppState};
use api_types::ActorUrlRequest;
use api_types::{AddBlockedDomainRequest, BlockedActorResponse, BlockedDomainResponse};
use axum::{Json, extract::State, http::StatusCode, response::IntoResponse};
use domain::value_objects::{InstanceIdentity, SocialActor};
fn social_actor_to_blocked_dto(
actor: SocialActor,
instance: &InstanceIdentity,
) -> BlockedActorResponse {
BlockedActorResponse {
url: instance.actor_url_of(&actor.identity),
handle: actor.handle,
display_name: actor.display_name,
avatar_url: actor.avatar_url,
}
}
#[utoipa::path(
get, path = "/api/v1/admin/blocked-domains",
responses(
(status = 200, body = Vec<BlockedDomainResponse>),
(status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
(status = 403, description = "Forbidden - admin only", body = api_types::ErrorResponse),
),
security(("bearer_auth" = []))
)]
pub async fn get_blocked_domains_admin(
State(state): State<AppState>,
_admin: AdminApiUser,
) -> Result<Json<Vec<BlockedDomainResponse>>, ApiError> {
let domains = state.app_ctx.ap_blocklist.get_blocked_domains().await?;
Ok(Json(
domains
.into_iter()
.map(|d| BlockedDomainResponse {
domain: d.domain,
reason: d.reason,
blocked_at: d.blocked_at,
})
.collect(),
))
}
#[utoipa::path(
post, path = "/api/v1/admin/blocked-domains",
request_body = AddBlockedDomainRequest,
responses(
(status = 201, description = "Domain blocked"),
(status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
(status = 403, description = "Forbidden - admin only", body = api_types::ErrorResponse),
),
security(("bearer_auth" = []))
)]
pub async fn add_blocked_domain_admin(
State(state): State<AppState>,
_admin: AdminApiUser,
Body(body): Body<AddBlockedDomainRequest>,
) -> Result<impl IntoResponse, ApiError> {
state
.app_ctx
.ap_blocklist
.add_blocked_domain(&body.domain, body.reason.as_deref())
.await?;
Ok(StatusCode::CREATED)
}
#[utoipa::path(
delete, path = "/api/v1/admin/blocked-domains/{domain}",
params(("domain" = String, Path, description = "Domain to unblock")),
responses(
(status = 204, description = "Domain unblocked"),
(status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
(status = 403, description = "Forbidden - admin only", body = api_types::ErrorResponse),
),
security(("bearer_auth" = []))
)]
pub async fn remove_blocked_domain_admin(
State(state): State<AppState>,
_admin: AdminApiUser,
PathId(domain): PathId<String>,
) -> Result<impl IntoResponse, ApiError> {
state
.app_ctx
.ap_blocklist
.remove_blocked_domain(&domain)
.await?;
Ok(StatusCode::NO_CONTENT)
}
#[utoipa::path(
post, path = "/api/v1/social/block",
request_body = ActorUrlRequest,
responses(
(status = 204, description = "Actor blocked"),
(status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
),
security(("bearer_auth" = []))
)]
pub async fn block_actor_api(
State(state): State<AppState>,
user: AuthenticatedUser,
Body(body): Body<ActorUrlRequest>,
) -> Result<impl IntoResponse, ApiError> {
let instance = state.app_ctx.instance.clone();
application::social::execute::execute_command(
&state.app_ctx.deps.social.command,
application::social::commands::SocialCmd::Block {
blocker_id: user.0.value(),
target: instance.identify(&body.actor_url),
},
)
.await?;
Ok(StatusCode::NO_CONTENT)
}
#[utoipa::path(
post, path = "/api/v1/social/unblock",
request_body = ActorUrlRequest,
responses(
(status = 204, description = "Actor unblocked"),
(status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
),
security(("bearer_auth" = []))
)]
pub async fn unblock_actor_api(
State(state): State<AppState>,
user: AuthenticatedUser,
Body(body): Body<ActorUrlRequest>,
) -> Result<impl IntoResponse, ApiError> {
let instance = state.app_ctx.instance.clone();
application::social::execute::execute_command(
&state.app_ctx.deps.social.command,
application::social::commands::SocialCmd::Unblock {
blocker_id: user.0.value(),
target: instance.identify(&body.actor_url),
},
)
.await?;
Ok(StatusCode::NO_CONTENT)
}
#[utoipa::path(
get, path = "/api/v1/social/blocked",
responses(
(status = 200, body = Vec<BlockedActorResponse>),
(status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
),
security(("bearer_auth" = []))
)]
pub async fn get_blocked_actors_api(
State(state): State<AppState>,
user: AuthenticatedUser,
) -> Result<Json<Vec<BlockedActorResponse>>, ApiError> {
let instance = state.app_ctx.instance.clone();
let identities =
application::social::get_blocked::execute(&state.app_ctx.deps.social.query, user.0.value())
.await?;
Ok(Json(
identities
.into_iter()
.map(|a| social_actor_to_blocked_dto(a, &instance))
.collect(),
))
}
#[cfg(test)]
mod tests;

View File

@@ -1,14 +0,0 @@
use super::*;
use crate::test_support::local_actor;
use domain::value_objects::{InstanceIdentity, SocialIdentity, UserId};
#[test]
fn local_blocked_dto_url_round_trips_back_to_the_same_identity() {
let instance = InstanceIdentity::new("https://md.example");
let uid = UserId::from_uuid(uuid::Uuid::new_v4());
let dto = social_actor_to_blocked_dto(local_actor(&uid), &instance);
assert!(!dto.url.starts_with("local:"), "url was {}", dto.url);
assert_eq!(instance.identify(&dto.url), SocialIdentity::Local(uid));
}

View File

@@ -1,191 +0,0 @@
use crate::html::forms::{ActorUrlForm, BlockDomainForm, RemoveDomainForm};
use crate::html::helpers::build_page_context;
use crate::html::templates::{BlockedActorsTemplate, BlockedDomainsTemplate};
use crate::{
extractors::RequiredCookieUser, html::csrf::CsrfToken, html::render::render_page,
state::AppState,
};
use axum::{
Form,
extract::{Extension, State},
http::StatusCode,
response::{IntoResponse, Redirect},
};
pub async fn get_blocked_domains_page(
crate::extractors::AdminUser(user_id): crate::extractors::AdminUser,
State(state): State<AppState>,
Extension(csrf): Extension<CsrfToken>,
) -> impl IntoResponse {
let mut ctx = build_page_context(&state, Some(user_id), csrf.0).await;
ctx.page_title = "Blocked Domains — Movies Diary".to_string();
ctx.canonical_url = format!("{}/admin/blocked-domains", state.app_ctx.config.base_url);
match state.app_ctx.ap_blocklist.get_blocked_domains().await {
Ok(domains) => {
let entries: Vec<crate::html::templates::BlockedDomainEntry> = domains
.into_iter()
.map(|d| crate::html::templates::BlockedDomainEntry {
domain: d.domain,
reason: d.reason,
blocked_at: d.blocked_at,
})
.collect();
render_page(BlockedDomainsTemplate {
ctx: &ctx,
domains: &entries,
})
.into_response()
}
Err(e) => {
tracing::error!("get_blocked_domains error: {:?}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
"Failed to load blocked domains",
)
.into_response()
}
}
}
pub async fn post_blocked_domain(
crate::extractors::AdminUser(_): crate::extractors::AdminUser,
State(state): State<AppState>,
Extension(csrf): Extension<CsrfToken>,
Form(form): Form<BlockDomainForm>,
) -> impl IntoResponse {
if crate::html::csrf::mismatch(&csrf, &form.csrf_token) {
return StatusCode::FORBIDDEN.into_response();
}
let reason = form.reason.as_deref().filter(|s| !s.trim().is_empty());
match state
.app_ctx
.ap_blocklist
.add_blocked_domain(&form.domain, reason)
.await
{
Ok(()) => Redirect::to("/admin/blocked-domains").into_response(),
Err(e) => {
tracing::error!("add_blocked_domain error: {:?}", e);
Redirect::to("/admin/blocked-domains").into_response()
}
}
}
pub async fn post_remove_blocked_domain(
crate::extractors::AdminUser(_): crate::extractors::AdminUser,
State(state): State<AppState>,
Extension(csrf): Extension<CsrfToken>,
Form(form): Form<RemoveDomainForm>,
) -> impl IntoResponse {
if crate::html::csrf::mismatch(&csrf, &form.csrf_token) {
return StatusCode::FORBIDDEN.into_response();
}
match state
.app_ctx
.ap_blocklist
.remove_blocked_domain(&form.domain)
.await
{
Ok(()) => Redirect::to("/admin/blocked-domains").into_response(),
Err(e) => {
tracing::error!("remove_blocked_domain error: {:?}", e);
Redirect::to("/admin/blocked-domains").into_response()
}
}
}
pub async fn get_blocked_actors_page(
RequiredCookieUser(user_id): RequiredCookieUser,
State(state): State<AppState>,
Extension(csrf): Extension<CsrfToken>,
) -> impl IntoResponse {
let mut ctx = build_page_context(&state, Some(user_id.clone()), csrf.0).await;
ctx.page_title = "Blocked Users — Movies Diary".to_string();
ctx.canonical_url = format!("{}/social/blocked", state.app_ctx.config.base_url);
let instance = state.app_ctx.instance.clone();
match application::social::get_blocked::execute(
&state.app_ctx.deps.social.query,
user_id.value(),
)
.await
{
Ok(blocked) => {
let entries: Vec<crate::html::templates::BlockedActorEntry> = blocked
.into_iter()
.map(|a| crate::html::templates::BlockedActorEntry {
url: instance.actor_url_of(&a.identity),
handle: a.handle,
display_name: a.display_name,
avatar_url: a.avatar_url,
})
.collect();
render_page(BlockedActorsTemplate {
ctx: &ctx,
actors: &entries,
})
.into_response()
}
Err(e) => {
tracing::error!("get_blocked_actors error: {:?}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
"Failed to load blocked users",
)
.into_response()
}
}
}
pub async fn post_block_actor_html(
RequiredCookieUser(user_id): RequiredCookieUser,
State(state): State<AppState>,
Extension(csrf): Extension<CsrfToken>,
Form(form): Form<ActorUrlForm>,
) -> impl IntoResponse {
if crate::html::csrf::mismatch(&csrf, &form.csrf_token) {
return StatusCode::FORBIDDEN.into_response();
}
let instance = state.app_ctx.instance.clone();
match application::social::execute::execute_command(
&state.app_ctx.deps.social.command,
application::social::commands::SocialCmd::Block {
blocker_id: user_id.value(),
target: instance.identify(&form.actor_url),
},
)
.await
{
Ok(()) => Redirect::to("/social/blocked").into_response(),
Err(e) => {
tracing::error!("block_actor html error: {:?}", e);
Redirect::to("/social/blocked").into_response()
}
}
}
pub async fn post_unblock_actor(
RequiredCookieUser(user_id): RequiredCookieUser,
State(state): State<AppState>,
Extension(csrf): Extension<CsrfToken>,
Form(form): Form<ActorUrlForm>,
) -> impl IntoResponse {
if crate::html::csrf::mismatch(&csrf, &form.csrf_token) {
return StatusCode::FORBIDDEN.into_response();
}
let instance = state.app_ctx.instance.clone();
match application::social::execute::execute_command(
&state.app_ctx.deps.social.command,
application::social::commands::SocialCmd::Unblock {
blocker_id: user_id.value(),
target: instance.identify(&form.actor_url),
},
)
.await
{
Ok(()) => Redirect::to("/social/blocked").into_response(),
Err(e) => {
tracing::error!("unblock_actor error: {:?}", e);
Redirect::to("/social/blocked").into_response()
}
}
}

View File

@@ -1,95 +0,0 @@
use crate::state::AppState;
use axum::{
extract::{Path, Query, State},
http::StatusCode,
response::IntoResponse,
};
use uuid::Uuid;
pub async fn get_followers_collection(
State(state): State<AppState>,
Path(user_id): Path<Uuid>,
headers: axum::http::HeaderMap,
Query(params): Query<std::collections::HashMap<String, String>>,
) -> impl IntoResponse {
let accept = headers
.get(axum::http::header::ACCEPT)
.and_then(|v| v.to_str().ok())
.unwrap_or("");
if accept.contains("application/activity+json") || accept.contains("application/ld+json") {
if state
.app_ctx
.ap_document
.actor_is_gone(user_id)
.await
.unwrap_or(false)
{
return StatusCode::GONE.into_response();
}
let page = params.get("page").and_then(|p| p.parse::<u32>().ok());
return match state
.app_ctx
.ap_document
.followers_collection_json(user_id, page)
.await
{
Ok(json) => (
[(
axum::http::header::CONTENT_TYPE,
"application/activity+json",
)],
json,
)
.into_response(),
Err(e) => {
tracing::error!("followers_collection_json error: {:?}", e);
StatusCode::INTERNAL_SERVER_ERROR.into_response()
}
};
}
axum::response::Redirect::to(&format!("/users/{}/followers-list", user_id)).into_response()
}
pub async fn get_following_collection(
State(state): State<AppState>,
Path(user_id): Path<Uuid>,
headers: axum::http::HeaderMap,
Query(params): Query<std::collections::HashMap<String, String>>,
) -> impl IntoResponse {
let accept = headers
.get(axum::http::header::ACCEPT)
.and_then(|v| v.to_str().ok())
.unwrap_or("");
if accept.contains("application/activity+json") || accept.contains("application/ld+json") {
if state
.app_ctx
.ap_document
.actor_is_gone(user_id)
.await
.unwrap_or(false)
{
return StatusCode::GONE.into_response();
}
let page = params.get("page").and_then(|p| p.parse::<u32>().ok());
return match state
.app_ctx
.ap_document
.following_collection_json(user_id, page)
.await
{
Ok(json) => (
[(
axum::http::header::CONTENT_TYPE,
"application/activity+json",
)],
json,
)
.into_response(),
Err(e) => {
tracing::error!("following_collection_json error: {:?}", e);
StatusCode::INTERNAL_SERVER_ERROR.into_response()
}
};
}
axum::response::Redirect::to(&format!("/users/{}/following-list", user_id)).into_response()
}

View File

@@ -1,110 +0,0 @@
#[cfg(feature = "federation")]
pub(crate) mod blocklist_api;
#[cfg(feature = "federation")]
mod blocklist_html;
#[cfg(feature = "federation")]
pub(crate) mod collections;
#[cfg(feature = "federation")]
mod negotiation;
#[cfg(feature = "federation")]
pub use negotiation::actor_document_response;
use axum::{Router, routing};
use crate::state::AppState;
#[cfg(feature = "federation")]
pub fn html_routes() -> Router<AppState> {
Router::new()
.route(
"/users/{id}/followers",
routing::get(collections::get_followers_collection),
)
.route(
"/users/{id}/following",
routing::get(collections::get_following_collection),
)
.route(
"/admin/blocked-domains",
routing::get(blocklist_html::get_blocked_domains_page)
.post(blocklist_html::post_blocked_domain),
)
.route(
"/admin/blocked-domains/remove",
routing::post(blocklist_html::post_remove_blocked_domain),
)
.route(
"/social/blocked",
routing::get(blocklist_html::get_blocked_actors_page),
)
.route(
"/social/block",
routing::post(blocklist_html::post_block_actor_html),
)
.route(
"/social/unblock",
routing::post(blocklist_html::post_unblock_actor),
)
}
#[cfg(feature = "federation")]
pub fn api_routes() -> Router<AppState> {
Router::new()
.route(
"/admin/blocked-domains",
routing::get(blocklist_api::get_blocked_domains_admin)
.post(blocklist_api::add_blocked_domain_admin),
)
.route(
"/admin/blocked-domains/{domain}",
routing::delete(blocklist_api::remove_blocked_domain_admin),
)
.route(
"/social/block",
routing::post(blocklist_api::block_actor_api),
)
.route(
"/social/unblock",
routing::post(blocklist_api::unblock_actor_api),
)
.route(
"/social/blocked",
routing::get(blocklist_api::get_blocked_actors_api),
)
}
#[cfg(not(feature = "federation"))]
pub fn html_routes() -> Router<AppState> {
Router::new()
.route(
"/users/{id}/followers",
routing::get(
|axum::extract::Path(id): axum::extract::Path<uuid::Uuid>| async move {
axum::response::Redirect::permanent(&format!("/users/{}/followers-list", id))
},
),
)
.route(
"/users/{id}/following",
routing::get(
|axum::extract::Path(id): axum::extract::Path<uuid::Uuid>| async move {
axum::response::Redirect::permanent(&format!("/users/{}/following-list", id))
},
),
)
}
#[cfg(not(feature = "federation"))]
pub fn api_routes() -> Router<AppState> {
Router::new()
}
#[cfg(not(feature = "federation"))]
pub async fn actor_document_response(
_state: &AppState,
_headers: &axum::http::HeaderMap,
_user_id: uuid::Uuid,
) -> Option<axum::response::Response> {
None
}

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