Compare commits
165 Commits
master
...
086d40205b
| Author | SHA1 | Date | |
|---|---|---|---|
| 086d40205b | |||
| a4b311dde3 | |||
| 2da2075d03 | |||
| f23392751d | |||
| 51f4770c23 | |||
| 5da979649b | |||
| 000c74d3b3 | |||
| 3ba5d61d5b | |||
| 63746ade50 | |||
| f2761b8e97 | |||
| 08d0734d03 | |||
| 595460373b | |||
| 3714b6d7a7 | |||
| 660a8d618d | |||
| 126ab43287 | |||
| 44273457ae | |||
| 4b9fd8168d | |||
| d9613308a3 | |||
| 1f5c05a5a2 | |||
| 3342905e22 | |||
| c8fe4a77d1 | |||
| ed8ca1d220 | |||
| fdd5dbc935 | |||
| 6cab2166a7 | |||
| 5bf750e0fc | |||
| 3d905fc39b | |||
| 35fffeef67 | |||
| d668d54cc3 | |||
| 0d32de993b | |||
| 8d78babda0 | |||
| cf99332dc4 | |||
| 0b1824d874 | |||
| e445a5eaf4 | |||
| a714c53a07 | |||
| ae7ff757d0 | |||
| 6323322d97 | |||
| fe55d694f9 | |||
| dda8b32e6a | |||
| d75db17ece | |||
| 642d7ef3d0 | |||
| 5df26b69a0 | |||
| c3af91ea7b | |||
| 4425c72f36 | |||
| e7e5d9935d | |||
| 6d2b415075 | |||
| 87cb36f9fe | |||
| bacf7b7252 | |||
| c4b6d52196 | |||
| ffb323dbbf | |||
| 0a18992a73 | |||
| b0ce316c30 | |||
| 0d3c2c937d | |||
| 86909ecede | |||
| 482575aed0 | |||
| 377ee1d176 | |||
| 9cda5b4681 | |||
| 74fb893751 | |||
| 32b114cecd | |||
| bd571b3b51 | |||
| f226071cbd | |||
| aaa9cb0a1e | |||
| 31dba4ca95 | |||
| 1b3f906312 | |||
| 465d233ae9 | |||
| 1f03889b30 | |||
| 4159e60148 | |||
| d66a89059d | |||
| b3c243257d | |||
| e1f2442e77 | |||
| 7a66661932 | |||
| b30a6a102b | |||
| 38a3aa6bbf | |||
| 3135a15cb3 | |||
| d083f8ae3d | |||
| 874c406d4a | |||
| 78e1f4ef72 | |||
| cf74b06b4a | |||
| 317898d51b | |||
| 790bb6fbb5 | |||
| 658df38788 | |||
| cff0f854fa | |||
| 66ade70273 | |||
| cbd2ac5b3e | |||
| 0433cd4d9b | |||
| b5a8ea2395 | |||
| 49b79799c1 | |||
| f4aba551a2 | |||
| 91df35dbd3 | |||
| 623f90e43f | |||
| e28f628c80 | |||
| 60c25d4c24 | |||
| 22aafe99be | |||
| 0ff22cca5f | |||
| ccc39e27e4 | |||
| 76319756f4 | |||
| 7703227970 | |||
| b9933bb48d | |||
| 0c48708ce6 | |||
| a2a889bced | |||
| a4846f3bea | |||
| 27be840faa | |||
| 965fc0eda8 | |||
| d700b85337 | |||
| ffbab75910 | |||
| dda7c40f7f | |||
| 1b827b1bdd | |||
| 1ee6873a60 | |||
| 7352b533ff | |||
| 85e254fee2 | |||
| fa8221322d | |||
| 38da37de55 | |||
| f3dedbad8a | |||
| d468ce131f | |||
| d034af9e9c | |||
| 59d308f41b | |||
| bbb2ee00d6 | |||
| 5dd9aac68d | |||
| 6dcc4c8317 | |||
| e31d99a240 | |||
| 41fec1efa5 | |||
| 160c08d1c4 | |||
| 7aa6d7bf4d | |||
| 144f2f8e0c | |||
| cff64f7a6b | |||
| 5baff54cb9 | |||
| f94d2db8b1 | |||
| 48875a6e86 | |||
| 9387ae705b | |||
| 9871e21bc0 | |||
| fa8efbaa23 | |||
| d769a5b55c | |||
| 8e1fb1a974 | |||
| 6145b873f5 | |||
| cc668ae44d | |||
| e5097c22dd | |||
| 450468ef3d | |||
| 6e7c6467a7 | |||
| 7f815f8207 | |||
| 5df89200d4 | |||
| eb273dc277 | |||
| 5689db0ad7 | |||
| 5c70b8b8be | |||
| 4c547df04e | |||
| 602df8df22 | |||
| 5b69a3a7c0 | |||
| a38f78d261 | |||
| 17f90726e8 | |||
| 563f33212e | |||
| 8e5ac9f433 | |||
| f790fa2a0f | |||
| edcf3c1170 | |||
| 1985d2c57f | |||
| f0b3d8ad90 | |||
| da72ab1446 | |||
| 93c65cd155 | |||
| ba42d3d445 | |||
| 819332522a | |||
| 79a06e6844 | |||
| 97a496553a | |||
| 5a58625265 | |||
| 6d9ac07dfc | |||
| b6a7cf9417 | |||
| c4b39c9410 | |||
| f60cc368b6 | |||
| 65bab7fd44 |
@@ -1,5 +1,2 @@
|
||||
[env]
|
||||
SQLX_OFFLINE = "true"
|
||||
|
||||
[registries.gitea]
|
||||
index = "sparse+https://git.gabrielkaszewski.dev/api/packages/GKaszewski/cargo/"
|
||||
|
||||
@@ -7,7 +7,3 @@ target/
|
||||
# .cargo and .sqlx are needed at build time (SQLX_OFFLINE mode)
|
||||
docs/
|
||||
dev.db
|
||||
spa/node_modules/
|
||||
spa/dist/
|
||||
spa/.env
|
||||
spa/.env.local
|
||||
|
||||
81
.env.example
81
.env.example
@@ -1,55 +1,60 @@
|
||||
# ── Required ──────────────────────────────────────────────────
|
||||
# Database backend — "sqlite" (default) or "postgres"
|
||||
DATABASE_BACKEND=sqlite
|
||||
|
||||
# Database (SQLite — file auto-created on first run)
|
||||
DATABASE_URL=sqlite:///data/movies.db
|
||||
# Option A: SQLite (default, zero external dependencies)
|
||||
DATABASE_URL=sqlite://movies.db
|
||||
|
||||
# Option B: PostgreSQL
|
||||
# DATABASE_BACKEND=postgres
|
||||
# DATABASE_URL=postgres://user:password@localhost:5432/movies_diary
|
||||
|
||||
# Authentication
|
||||
JWT_SECRET=change-me-to-a-random-string
|
||||
JWT_SECRET=change-me
|
||||
JWT_TTL_SECONDS=86400
|
||||
|
||||
# Movie metadata — one of these is required (TMDB preferred)
|
||||
# TMDB_API_KEY=your-tmdb-key
|
||||
OMDB_API_KEY=your-omdb-key
|
||||
# OMDb/TMDB metadata
|
||||
OMDB_API_KEY=your-key
|
||||
TMDB_API_KEY=your-key
|
||||
|
||||
# ── Recommended ──────────────────────────────────────────────
|
||||
# Poster storage — Option A (local) is active. To use S3, comment it out and uncomment Option B:
|
||||
|
||||
# Public URL (used for ActivityPub federation and canonical links)
|
||||
BASE_URL=https://yourdomain.example.com
|
||||
# Option A: local filesystem (zero external dependencies)
|
||||
POSTER_STORAGE_BACKEND=local
|
||||
POSTER_STORAGE_PATH=./posters
|
||||
|
||||
# Enable sign-ups (default: false — set true so you can register)
|
||||
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
|
||||
# Option B: S3-compatible (MinIO, AWS S3, etc.)
|
||||
# POSTER_STORAGE_BACKEND=s3
|
||||
# MINIO_ENDPOINT=http://localhost:9000
|
||||
# MINIO_BUCKET=posters
|
||||
# MINIO_REGION=minio
|
||||
# MINIO_ACCESS_KEY_ID=minioadmin
|
||||
# MINIO_SECRET_ACCESS_KEY=minioadmin
|
||||
|
||||
# ── Optional ─────────────────────────────────────────────────
|
||||
# Optional
|
||||
HOST=0.0.0.0
|
||||
PORT=3000
|
||||
BASE_URL=http://localhost:3000
|
||||
SECURE_COOKIES=false
|
||||
ALLOW_REGISTRATION=false
|
||||
RATE_LIMIT=20
|
||||
POSTER_FETCH_TIMEOUT_SECONDS=30
|
||||
|
||||
# TMDb enrichment (cast, crew, genres — separate from search metadata above)
|
||||
# TMDB_API_KEY=your-tmdb-key
|
||||
# Event bus — "db" (default) or "nats"
|
||||
# The worker binary must run alongside the presentation to process events.
|
||||
EVENT_BUS_BACKEND=db
|
||||
|
||||
# Image conversion (converts uploaded posters to AVIF or WebP)
|
||||
# IMAGE_CONVERSION_ENABLED=false
|
||||
# IMAGE_CONVERSION_FORMAT=avif
|
||||
# Option A: DB queue (default — no extra infrastructure needed)
|
||||
# Events are persisted in the same database as the app and polled by the worker.
|
||||
# EVENT_QUEUE_POLL_INTERVAL_MS=500 # polling interval (default: 500ms)
|
||||
# EVENT_QUEUE_BATCH_SIZE=10 # rows claimed per poll cycle (default: 10)
|
||||
# EVENT_QUEUE_MAX_ATTEMPTS=5 # retries before dead-lettering (default: 5)
|
||||
|
||||
# Server
|
||||
# HOST=0.0.0.0
|
||||
# PORT=3000
|
||||
# RATE_LIMIT=60
|
||||
# SECURE_COOKIES=true
|
||||
# RUST_LOG=presentation=info,tower_http=info,worker=info
|
||||
|
||||
# CORS (for SPA development only)
|
||||
# CORS_ORIGINS=http://localhost:5173
|
||||
|
||||
# Event bus — "db" (default, uses same database) or "nats"
|
||||
# EVENT_BUS_BACKEND=db
|
||||
# Option B: NATS (at-least-once delivery, recommended for higher throughput)
|
||||
# EVENT_BUS_BACKEND=nats
|
||||
# NATS_URL=nats://localhost:4222
|
||||
# NATS_MODE=jetstream # "jetstream" (default, at-least-once) or "core" (fire-and-forget)
|
||||
# NATS_SUBJECT_PREFIX=movies-diary.events
|
||||
# NATS_STREAM_NAME=MOVIES_DIARY_EVENTS
|
||||
# NATS_CONSUMER_NAME=worker
|
||||
|
||||
RUST_LOG=presentation=debug,tower_http=debug,worker=info,application=info
|
||||
|
||||
@@ -1,42 +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: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
target
|
||||
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
|
||||
restore-keys: ${{ runner.os }}-cargo-
|
||||
|
||||
- name: fmt
|
||||
run: cargo fmt --all -- --check
|
||||
|
||||
- name: clippy
|
||||
run: cargo clippy --all-targets -- -D warnings
|
||||
|
||||
- name: test
|
||||
run: cargo test
|
||||
81
.github/workflows/ci.yml
vendored
81
.github/workflows/ci.yml
vendored
@@ -1,81 +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: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
target
|
||||
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
|
||||
restore-keys: ${{ runner.os }}-cargo-
|
||||
|
||||
- name: fmt
|
||||
run: cargo fmt --all -- --check
|
||||
|
||||
- name: clippy
|
||||
run: cargo clippy --all-targets -- -D warnings
|
||||
|
||||
- name: test
|
||||
run: cargo test
|
||||
|
||||
docker:
|
||||
name: Build & Push Docker Image
|
||||
runs-on: ubuntu-latest
|
||||
needs: ci
|
||||
if: github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/tags/v')
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.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=sqlite,sqlite-federation
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -14,7 +14,3 @@
|
||||
.worktrees/
|
||||
.superpowers/
|
||||
docs/
|
||||
!docs/adr/
|
||||
|
||||
imgs/
|
||||
.sqlx/
|
||||
98
.sqlx/query-05d958c1fa38095ae2b5b81ede48fc85702d8c39c6301839de7b4d27f4a4d41b.json
generated
Normal file
98
.sqlx/query-05d958c1fa38095ae2b5b81ede48fc85702d8c39c6301839de7b4d27f4a4d41b.json
generated
Normal file
@@ -0,0 +1,98 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,\n r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url\n FROM reviews r\n INNER JOIN movies m ON m.id = r.movie_id\n WHERE r.movie_id = ?\n ORDER BY r.watched_at DESC\n LIMIT ? OFFSET ?",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "id",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "external_metadata_id",
|
||||
"ordinal": 1,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "title",
|
||||
"ordinal": 2,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "release_year",
|
||||
"ordinal": 3,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "director",
|
||||
"ordinal": 4,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "poster_path",
|
||||
"ordinal": 5,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "review_id",
|
||||
"ordinal": 6,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "movie_id",
|
||||
"ordinal": 7,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "user_id",
|
||||
"ordinal": 8,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "rating",
|
||||
"ordinal": 9,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "comment",
|
||||
"ordinal": 10,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "watched_at",
|
||||
"ordinal": 11,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "created_at",
|
||||
"ordinal": 12,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "remote_actor_url",
|
||||
"ordinal": 13,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 3
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "05d958c1fa38095ae2b5b81ede48fc85702d8c39c6301839de7b4d27f4a4d41b"
|
||||
}
|
||||
20
.sqlx/query-0963b9661182e139cd760bbabb0d6ea3a301a2a3adbdfdda4a88f333a1144c77.json
generated
Normal file
20
.sqlx/query-0963b9661182e139cd760bbabb0d6ea3a301a2a3adbdfdda4a88f333a1144c77.json
generated
Normal 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"
|
||||
}
|
||||
12
.sqlx/query-0acf3515a4d1bef7a2458d878481ba3b11c9f252311579f7daba335f70d78f07.json
generated
Normal file
12
.sqlx/query-0acf3515a4d1bef7a2458d878481ba3b11c9f252311579f7daba335f70d78f07.json
generated
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "INSERT INTO users (id, email, username, password_hash, created_at, role) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 6
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "0acf3515a4d1bef7a2458d878481ba3b11c9f252311579f7daba335f70d78f07"
|
||||
}
|
||||
98
.sqlx/query-106b5b65162314c47217c26b7e89194094e10122ea596e8d9323968e600635a9.json
generated
Normal file
98
.sqlx/query-106b5b65162314c47217c26b7e89194094e10122ea596e8d9323968e600635a9.json
generated
Normal file
@@ -0,0 +1,98 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,\n r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url\n FROM reviews r\n INNER JOIN movies m ON m.id = r.movie_id\n WHERE r.user_id = ?\n ORDER BY r.watched_at DESC",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "id",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "external_metadata_id",
|
||||
"ordinal": 1,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "title",
|
||||
"ordinal": 2,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "release_year",
|
||||
"ordinal": 3,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "director",
|
||||
"ordinal": 4,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "poster_path",
|
||||
"ordinal": 5,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "review_id",
|
||||
"ordinal": 6,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "movie_id",
|
||||
"ordinal": 7,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "user_id",
|
||||
"ordinal": 8,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "rating",
|
||||
"ordinal": 9,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "comment",
|
||||
"ordinal": 10,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "watched_at",
|
||||
"ordinal": 11,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "created_at",
|
||||
"ordinal": 12,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "remote_actor_url",
|
||||
"ordinal": 13,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "106b5b65162314c47217c26b7e89194094e10122ea596e8d9323968e600635a9"
|
||||
}
|
||||
12
.sqlx/query-1c70d5d455e5ab3dfb68d35ce5255c7e0e86b9f6549ae8ae09247806f1321086.json
generated
Normal file
12
.sqlx/query-1c70d5d455e5ab3dfb68d35ce5255c7e0e86b9f6549ae8ae09247806f1321086.json
generated
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "DELETE FROM import_sessions WHERE expires_at < datetime('now')",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 0
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "1c70d5d455e5ab3dfb68d35ce5255c7e0e86b9f6549ae8ae09247806f1321086"
|
||||
}
|
||||
98
.sqlx/query-25fd01355c929a83daf2c802b8ae3adaa4ce73fc037e2bf2a87d60187aeb7361.json
generated
Normal file
98
.sqlx/query-25fd01355c929a83daf2c802b8ae3adaa4ce73fc037e2bf2a87d60187aeb7361.json
generated
Normal file
@@ -0,0 +1,98 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,\n r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url\n FROM reviews r\n INNER JOIN movies m ON m.id = r.movie_id\n ORDER BY r.watched_at ASC\n LIMIT ? OFFSET ?",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "id",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "external_metadata_id",
|
||||
"ordinal": 1,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "title",
|
||||
"ordinal": 2,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "release_year",
|
||||
"ordinal": 3,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "director",
|
||||
"ordinal": 4,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "poster_path",
|
||||
"ordinal": 5,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "review_id",
|
||||
"ordinal": 6,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "movie_id",
|
||||
"ordinal": 7,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "user_id",
|
||||
"ordinal": 8,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "rating",
|
||||
"ordinal": 9,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "comment",
|
||||
"ordinal": 10,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "watched_at",
|
||||
"ordinal": 11,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "created_at",
|
||||
"ordinal": 12,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "remote_actor_url",
|
||||
"ordinal": 13,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 2
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "25fd01355c929a83daf2c802b8ae3adaa4ce73fc037e2bf2a87d60187aeb7361"
|
||||
}
|
||||
38
.sqlx/query-2c7353f34c4748d4d4be6abbf343fa7ea30eeb985c4bfd12b0fc3997d1ba03bb.json
generated
Normal file
38
.sqlx/query-2c7353f34c4748d4d4be6abbf343fa7ea30eeb985c4bfd12b0fc3997d1ba03bb.json
generated
Normal file
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT u.id AS \"id!: String\",\n u.email AS \"email!: String\",\n COUNT(DISTINCT r.movie_id) AS \"total_movies!: i64\",\n AVG(CAST(r.rating AS REAL)) AS avg_rating\n FROM users u\n LEFT JOIN reviews r ON r.user_id = u.id AND r.remote_actor_url IS NULL\n GROUP BY u.id, u.email\n ORDER BY u.email ASC",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "id!: String",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "email!: String",
|
||||
"ordinal": 1,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "total_movies!: i64",
|
||||
"ordinal": 2,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "avg_rating",
|
||||
"ordinal": 3,
|
||||
"type_info": "Float"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 0
|
||||
},
|
||||
"nullable": [
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "2c7353f34c4748d4d4be6abbf343fa7ea30eeb985c4bfd12b0fc3997d1ba03bb"
|
||||
}
|
||||
50
.sqlx/query-3047579c6ed13ce87aad9b9ce6300c02f0df3516979518976e13f9d9abc6a403.json
generated
Normal file
50
.sqlx/query-3047579c6ed13ce87aad9b9ce6300c02f0df3516979518976e13f9d9abc6a403.json
generated
Normal 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"
|
||||
}
|
||||
44
.sqlx/query-319b5d09824809a971f6c9546dde6389a0aca5a732531bb9ca9b99675d0a89f4.json
generated
Normal file
44
.sqlx/query-319b5d09824809a971f6c9546dde6389a0aca5a732531bb9ca9b99675d0a89f4.json
generated
Normal file
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT id, email, username, password_hash, role FROM users WHERE username = ?",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "id",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "email",
|
||||
"ordinal": 1,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "username",
|
||||
"ordinal": 2,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "password_hash",
|
||||
"ordinal": 3,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "role",
|
||||
"ordinal": 4,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "319b5d09824809a971f6c9546dde6389a0aca5a732531bb9ca9b99675d0a89f4"
|
||||
}
|
||||
50
.sqlx/query-33d0dae7d16b0635c1c7eb5afd10824bb55af7cc7a854f590d326622863759d1.json
generated
Normal file
50
.sqlx/query-33d0dae7d16b0635c1c7eb5afd10824bb55af7cc7a854f590d326622863759d1.json
generated
Normal 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"
|
||||
}
|
||||
12
.sqlx/query-34a0a7b5e6a7f2b97b59e9de6a34b41a7128a465c0957915c7ed7031c6b83fb0.json
generated
Normal file
12
.sqlx/query-34a0a7b5e6a7f2b97b59e9de6a34b41a7128a465c0957915c7ed7031c6b83fb0.json
generated
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "DELETE FROM import_sessions WHERE user_id = ? AND expires_at < datetime('now')",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "34a0a7b5e6a7f2b97b59e9de6a34b41a7128a465c0957915c7ed7031c6b83fb0"
|
||||
}
|
||||
20
.sqlx/query-4b3074b532342c6356ee0e8e4d8c4a830f016234bb690e1f6240f02824d6d84f.json
generated
Normal file
20
.sqlx/query-4b3074b532342c6356ee0e8e4d8c4a830f016234bb690e1f6240f02824d6d84f.json
generated
Normal 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"
|
||||
}
|
||||
20
.sqlx/query-4d85f0ff9732576bba77dc84d3885a0002c2b600c34ba4d99f1e1c5e99f35e75.json
generated
Normal file
20
.sqlx/query-4d85f0ff9732576bba77dc84d3885a0002c2b600c34ba4d99f1e1c5e99f35e75.json
generated
Normal 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"
|
||||
}
|
||||
12
.sqlx/query-6638569b8759b965f8919f4998a8665b15c14fc0b4f6018e548b76474254073e.json
generated
Normal file
12
.sqlx/query-6638569b8759b965f8919f4998a8665b15c14fc0b4f6018e548b76474254073e.json
generated
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "INSERT OR REPLACE INTO import_profiles (id, user_id, name, field_mappings, created_at)\n VALUES (?, ?, ?, ?, ?)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 5
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "6638569b8759b965f8919f4998a8665b15c14fc0b4f6018e548b76474254073e"
|
||||
}
|
||||
12
.sqlx/query-759b45ac8426d93d4668e208003d52957f2f52d8a021cc6f117cb6f241689a07.json
generated
Normal file
12
.sqlx/query-759b45ac8426d93d4668e208003d52957f2f52d8a021cc6f117cb6f241689a07.json
generated
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "UPDATE import_sessions SET field_mappings = ?, row_results = ? WHERE id = ?",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 3
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "759b45ac8426d93d4668e208003d52957f2f52d8a021cc6f117cb6f241689a07"
|
||||
}
|
||||
44
.sqlx/query-79af0324db4d0b8e4bd66e12816583598865630241deffbb8b4175fa7a755099.json
generated
Normal file
44
.sqlx/query-79af0324db4d0b8e4bd66e12816583598865630241deffbb8b4175fa7a755099.json
generated
Normal file
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT id, email, username, password_hash, role FROM users WHERE id = ?",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "id",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "email",
|
||||
"ordinal": 1,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "username",
|
||||
"ordinal": 2,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "password_hash",
|
||||
"ordinal": 3,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "role",
|
||||
"ordinal": 4,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "79af0324db4d0b8e4bd66e12816583598865630241deffbb8b4175fa7a755099"
|
||||
}
|
||||
50
.sqlx/query-7bc4aebcb94547976d3d7e063e4e908fc22b977b3cbf063ee93ffe4648c42011.json
generated
Normal file
50
.sqlx/query-7bc4aebcb94547976d3d7e063e4e908fc22b977b3cbf063ee93ffe4648c42011.json
generated
Normal 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"
|
||||
}
|
||||
12
.sqlx/query-7d7e23355ee0e442f2aa27e898dcfa40bdc4b09391afe04325f076157d9d84aa.json
generated
Normal file
12
.sqlx/query-7d7e23355ee0e442f2aa27e898dcfa40bdc4b09391afe04325f076157d9d84aa.json
generated
Normal 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"
|
||||
}
|
||||
44
.sqlx/query-7fa9ce795286905f4b6c3d5a8975fabd813a2b39652dd7e18f03f5b10a4beaca.json
generated
Normal file
44
.sqlx/query-7fa9ce795286905f4b6c3d5a8975fabd813a2b39652dd7e18f03f5b10a4beaca.json
generated
Normal file
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT id, user_id, name, field_mappings, created_at FROM import_profiles WHERE id = ? AND user_id = ?",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "id",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "user_id",
|
||||
"ordinal": 1,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "name",
|
||||
"ordinal": 2,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "field_mappings",
|
||||
"ordinal": 3,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "created_at",
|
||||
"ordinal": 4,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 2
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "7fa9ce795286905f4b6c3d5a8975fabd813a2b39652dd7e18f03f5b10a4beaca"
|
||||
}
|
||||
62
.sqlx/query-7ff439f22f880f999a72aad1359eb8fec11fe868b940faee5a351795caaa2357.json
generated
Normal file
62
.sqlx/query-7ff439f22f880f999a72aad1359eb8fec11fe868b940faee5a351795caaa2357.json
generated
Normal file
@@ -0,0 +1,62 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url\n FROM reviews WHERE movie_id = ? ORDER BY watched_at ASC",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "id",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "movie_id",
|
||||
"ordinal": 1,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "user_id",
|
||||
"ordinal": 2,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "rating",
|
||||
"ordinal": 3,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "comment",
|
||||
"ordinal": 4,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "watched_at",
|
||||
"ordinal": 5,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "created_at",
|
||||
"ordinal": 6,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "remote_actor_url",
|
||||
"ordinal": 7,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "7ff439f22f880f999a72aad1359eb8fec11fe868b940faee5a351795caaa2357"
|
||||
}
|
||||
98
.sqlx/query-8a70f21c39d203867c06dc0bf74a54745b3331b84ce9a2178f7812f1ed7262cc.json
generated
Normal file
98
.sqlx/query-8a70f21c39d203867c06dc0bf74a54745b3331b84ce9a2178f7812f1ed7262cc.json
generated
Normal file
@@ -0,0 +1,98 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,\n r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url\n FROM reviews r\n INNER JOIN movies m ON m.id = r.movie_id\n WHERE r.movie_id = ?\n ORDER BY r.watched_at ASC\n LIMIT ? OFFSET ?",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "id",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "external_metadata_id",
|
||||
"ordinal": 1,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "title",
|
||||
"ordinal": 2,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "release_year",
|
||||
"ordinal": 3,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "director",
|
||||
"ordinal": 4,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "poster_path",
|
||||
"ordinal": 5,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "review_id",
|
||||
"ordinal": 6,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "movie_id",
|
||||
"ordinal": 7,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "user_id",
|
||||
"ordinal": 8,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "rating",
|
||||
"ordinal": 9,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "comment",
|
||||
"ordinal": 10,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "watched_at",
|
||||
"ordinal": 11,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "created_at",
|
||||
"ordinal": 12,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "remote_actor_url",
|
||||
"ordinal": 13,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 3
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "8a70f21c39d203867c06dc0bf74a54745b3331b84ce9a2178f7812f1ed7262cc"
|
||||
}
|
||||
12
.sqlx/query-9024c2b533d0100f18d5a5550220a148d8dc29bbf0df715f895fa4248d83785f.json
generated
Normal file
12
.sqlx/query-9024c2b533d0100f18d5a5550220a148d8dc29bbf0df715f895fa4248d83785f.json
generated
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "DELETE FROM import_profiles WHERE id = ?",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "9024c2b533d0100f18d5a5550220a148d8dc29bbf0df715f895fa4248d83785f"
|
||||
}
|
||||
26
.sqlx/query-a01336632a54099e31686a9cbe6fc53fef1299fc7c7b52be44f99c2302490a22.json
generated
Normal file
26
.sqlx/query-a01336632a54099e31686a9cbe6fc53fef1299fc7c7b52be44f99c2302490a22.json
generated
Normal 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"
|
||||
}
|
||||
98
.sqlx/query-a7c424c26663e4e51b1c563fa977f28e1d55234a242a7ddba50db13cf73b488d.json
generated
Normal file
98
.sqlx/query-a7c424c26663e4e51b1c563fa977f28e1d55234a242a7ddba50db13cf73b488d.json
generated
Normal file
@@ -0,0 +1,98 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,\n r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url\n FROM reviews r\n INNER JOIN movies m ON m.id = r.movie_id\n ORDER BY r.watched_at DESC\n LIMIT ? OFFSET ?",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "id",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "external_metadata_id",
|
||||
"ordinal": 1,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "title",
|
||||
"ordinal": 2,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "release_year",
|
||||
"ordinal": 3,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "director",
|
||||
"ordinal": 4,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "poster_path",
|
||||
"ordinal": 5,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "review_id",
|
||||
"ordinal": 6,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "movie_id",
|
||||
"ordinal": 7,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "user_id",
|
||||
"ordinal": 8,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "rating",
|
||||
"ordinal": 9,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "comment",
|
||||
"ordinal": 10,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "watched_at",
|
||||
"ordinal": 11,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "created_at",
|
||||
"ordinal": 12,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "remote_actor_url",
|
||||
"ordinal": 13,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 2
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "a7c424c26663e4e51b1c563fa977f28e1d55234a242a7ddba50db13cf73b488d"
|
||||
}
|
||||
26
.sqlx/query-aca9e7aaa32c23b4de3f5048d60340e978d31a36be9121da3c59378f2fc1ed8e.json
generated
Normal file
26
.sqlx/query-aca9e7aaa32c23b4de3f5048d60340e978d31a36be9121da3c59378f2fc1ed8e.json
generated
Normal 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"
|
||||
}
|
||||
62
.sqlx/query-ae983138ad90fda3794b784fbf62c31fddcf182850782e9bfbc4ff3ee8b7d4bb.json
generated
Normal file
62
.sqlx/query-ae983138ad90fda3794b784fbf62c31fddcf182850782e9bfbc4ff3ee8b7d4bb.json
generated
Normal file
@@ -0,0 +1,62 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url\n FROM reviews WHERE id = ?",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "id",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "movie_id",
|
||||
"ordinal": 1,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "user_id",
|
||||
"ordinal": 2,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "rating",
|
||||
"ordinal": 3,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "comment",
|
||||
"ordinal": 4,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "watched_at",
|
||||
"ordinal": 5,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "created_at",
|
||||
"ordinal": 6,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "remote_actor_url",
|
||||
"ordinal": 7,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "ae983138ad90fda3794b784fbf62c31fddcf182850782e9bfbc4ff3ee8b7d4bb"
|
||||
}
|
||||
12
.sqlx/query-bf4aaf630ebefde88d87a95c257bbeafaf39e2f53611dab435d485648eb7e598.json
generated
Normal file
12
.sqlx/query-bf4aaf630ebefde88d87a95c257bbeafaf39e2f53611dab435d485648eb7e598.json
generated
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "DELETE FROM import_sessions WHERE id = ?",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "bf4aaf630ebefde88d87a95c257bbeafaf39e2f53611dab435d485648eb7e598"
|
||||
}
|
||||
44
.sqlx/query-c43249558d535343e387586fa00499fe813b18b9e7d8d241462f0689969dc64f.json
generated
Normal file
44
.sqlx/query-c43249558d535343e387586fa00499fe813b18b9e7d8d241462f0689969dc64f.json
generated
Normal file
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT id, email, username, password_hash, role FROM users WHERE email = ?",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "id",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "email",
|
||||
"ordinal": 1,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "username",
|
||||
"ordinal": 2,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "password_hash",
|
||||
"ordinal": 3,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "role",
|
||||
"ordinal": 4,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "c43249558d535343e387586fa00499fe813b18b9e7d8d241462f0689969dc64f"
|
||||
}
|
||||
56
.sqlx/query-ca7c29f4c80cee4877625a259edef662b169377c5bafdd03583645a39368c910.json
generated
Normal file
56
.sqlx/query-ca7c29f4c80cee4877625a259edef662b169377c5bafdd03583645a39368c910.json
generated
Normal file
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT id, user_id, parsed_data, field_mappings, row_results, created_at, expires_at\n FROM import_sessions WHERE id = ? AND user_id = ?",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "id",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "user_id",
|
||||
"ordinal": 1,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "parsed_data",
|
||||
"ordinal": 2,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "field_mappings",
|
||||
"ordinal": 3,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "row_results",
|
||||
"ordinal": 4,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "created_at",
|
||||
"ordinal": 5,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "expires_at",
|
||||
"ordinal": 6,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 2
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "ca7c29f4c80cee4877625a259edef662b169377c5bafdd03583645a39368c910"
|
||||
}
|
||||
12
.sqlx/query-cca022ac6275f2b1aaf63a14420897074c8ff4cdd1d3e9a13ef4b9dd5346d12a.json
generated
Normal file
12
.sqlx/query-cca022ac6275f2b1aaf63a14420897074c8ff4cdd1d3e9a13ef4b9dd5346d12a.json
generated
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "INSERT INTO reviews (id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url)\n VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 8
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "cca022ac6275f2b1aaf63a14420897074c8ff4cdd1d3e9a13ef4b9dd5346d12a"
|
||||
}
|
||||
20
.sqlx/query-d5d2a81306488a8cee5654cea7e14d76d76ecc7d2190ffb73d12bec2874111d2.json
generated
Normal file
20
.sqlx/query-d5d2a81306488a8cee5654cea7e14d76d76ecc7d2190ffb73d12bec2874111d2.json
generated
Normal 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"
|
||||
}
|
||||
12
.sqlx/query-e3a3b56d63df433339bd8183f20ef33ded742ef03df885686cd5198f9f8e1c01.json
generated
Normal file
12
.sqlx/query-e3a3b56d63df433339bd8183f20ef33ded742ef03df885686cd5198f9f8e1c01.json
generated
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "INSERT INTO import_sessions (id, user_id, parsed_data, field_mappings, row_results, created_at, expires_at)\n VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 7
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "e3a3b56d63df433339bd8183f20ef33ded742ef03df885686cd5198f9f8e1c01"
|
||||
}
|
||||
12
.sqlx/query-e431381ad41c1c2f7b9c89509d5e3f4c19cb52dcfff66772145cd80c53c16883.json
generated
Normal file
12
.sqlx/query-e431381ad41c1c2f7b9c89509d5e3f4c19cb52dcfff66772145cd80c53c16883.json
generated
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "DELETE FROM movies WHERE id = ?",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "e431381ad41c1c2f7b9c89509d5e3f4c19cb52dcfff66772145cd80c53c16883"
|
||||
}
|
||||
12
.sqlx/query-f84e5483ca4210aec67b38cc1a9de4a42c12891025236abc48ea4f175292a6cc.json
generated
Normal file
12
.sqlx/query-f84e5483ca4210aec67b38cc1a9de4a42c12891025236abc48ea4f175292a6cc.json
generated
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "DELETE FROM reviews WHERE id = ?",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "f84e5483ca4210aec67b38cc1a9de4a42c12891025236abc48ea4f175292a6cc"
|
||||
}
|
||||
44
.sqlx/query-f8559923b7a885fb0d4938479f11bd97708b401a6a2290abe6df49fcb7f28a8d.json
generated
Normal file
44
.sqlx/query-f8559923b7a885fb0d4938479f11bd97708b401a6a2290abe6df49fcb7f28a8d.json
generated
Normal file
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT id, user_id, name, field_mappings, created_at FROM import_profiles WHERE user_id = ? ORDER BY created_at DESC",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "id",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "user_id",
|
||||
"ordinal": 1,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "name",
|
||||
"ordinal": 2,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "field_mappings",
|
||||
"ordinal": 3,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "created_at",
|
||||
"ordinal": 4,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "f8559923b7a885fb0d4938479f11bd97708b401a6a2290abe6df49fcb7f28a8d"
|
||||
}
|
||||
32
.sqlx/query-fdd5b522f26b5e0ce62f76c774fbb606fd9ee9884f4457831f693a0df3609317.json
generated
Normal file
32
.sqlx/query-fdd5b522f26b5e0ce62f76c774fbb606fd9ee9884f4457831f693a0df3609317.json
generated
Normal 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"
|
||||
}
|
||||
65
CONTEXT.md
65
CONTEXT.md
@@ -1,65 +0,0 @@
|
||||
# Movies Diary
|
||||
|
||||
A personal movie diary that tracks what you watch, when, and what you thought about it. Supports federation via ActivityPub.
|
||||
|
||||
## Language
|
||||
|
||||
**Movie**:
|
||||
A film in the catalog, identified by title and release year. Optionally linked to an external metadata provider (e.g. TMDb) for enrichment. One Movie record is shared across all users — "Blade Runner (1982)" exists once regardless of how many people review it.
|
||||
_Avoid_: Film entry, title record
|
||||
|
||||
**Person**:
|
||||
Someone involved in making a movie — actor, director, crew member. Sourced from an external metadata provider and enriched with biographical data. Linked to Movies through cast/crew credits. Not a User — Person is movie-industry people only.
|
||||
_Avoid_: Celebrity, artist, talent
|
||||
|
||||
**Review**:
|
||||
A single record of watching a movie — captures the rating, optional comment, when it was watched, and how it was watched.
|
||||
_Avoid_: Diary entry, watch, log entry
|
||||
|
||||
**Rating**:
|
||||
A 1–5 whole-star score given to a movie in a Review. No half-stars, no zero.
|
||||
_Avoid_: Score, grade, stars (as a noun for the value itself)
|
||||
|
||||
**WatchMedium**:
|
||||
The channel through which a movie was watched: Cinema, Streaming, TV, PhysicalMedia, Download, MediaServer, or Other.
|
||||
_Avoid_: Source, format, venue, platform
|
||||
|
||||
**Watchlist**:
|
||||
A user's collection of movies they intend to watch. Each item is a simple bookmark — no priority or ordering. A movie leaves the watchlist implicitly when reviewed, or explicitly when removed.
|
||||
_Avoid_: Queue, backlog, to-watch list
|
||||
|
||||
**Goal**:
|
||||
A yearly target a user sets — e.g. "watch 50 movies in 2025." Progress is tracked automatically as reviews are logged. Currently only supports movie-count goals, but the model is designed for other goal types in the future.
|
||||
_Avoid_: Challenge, resolution, target
|
||||
|
||||
**WrapUp**:
|
||||
A generated summary report of viewing activity over a date range — statistics, trends, highlights, top directors/actors/genres. Can be personal (one user) or global (all users). Generated asynchronously. Shown to users as "Year in Review."
|
||||
_Avoid_: Stats page, recap, summary
|
||||
|
||||
**User**:
|
||||
A registered account with a username, email, and profile (display name, bio, avatar, banner). Can be Standard or Admin.
|
||||
_Avoid_: Account, member, profile (as a synonym for the whole User)
|
||||
|
||||
**SocialIdentity**:
|
||||
The uniform identifier for anyone involved in a social interaction — either a local User or a remote federated actor. Social commands and queries operate on SocialIdentity so the domain never branches on local vs remote.
|
||||
_Avoid_: Actor, participant, social user
|
||||
|
||||
**Follow**:
|
||||
A social relationship where one user subscribes to another's activity. Always requires acceptance by the target user. Works identically for local and federated (ActivityPub) users. Once accepted, the followed user's reviews appear in the follower's Feed.
|
||||
_Avoid_: Subscribe, connect, friend
|
||||
|
||||
**Feed**:
|
||||
A chronological timeline of reviews from users you follow — both local and federated. The main social surface of the app.
|
||||
_Avoid_: Timeline, activity stream, home
|
||||
|
||||
**WatchEvent**:
|
||||
An automatically detected viewing reported by an external source — currently Jellyfin and Plex via webhook, but conceptually any system that can report "this person watched this movie" (e.g. a cinema ticket service). Arrives in a pending state; the user confirms it (creating a Review) or dismisses it.
|
||||
_Avoid_: Playback event, webhook event, auto-import
|
||||
|
||||
**Import**:
|
||||
Bulk ingestion of reviews from an external file — Letterboxd CSV, IMDb CSV, or a generic JSON format. The user uploads a file, column mappings are applied, and reviews are created in batch.
|
||||
_Avoid_: Upload, migration, sync
|
||||
|
||||
**ImportProfile**:
|
||||
A saved set of column-to-field mappings for an Import. Reusable across imports and shareable between users.
|
||||
_Avoid_: Template, mapping preset, import config
|
||||
@@ -1,87 +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 presentation # HTTP server on :3000
|
||||
cargo run -p worker # event worker (separate terminal)
|
||||
```
|
||||
|
||||
5. Run the SPA dev server:
|
||||
|
||||
```bash
|
||||
cd spa && bun install && bun run dev
|
||||
```
|
||||
|
||||
## Before submitting a PR
|
||||
|
||||
```bash
|
||||
make # runs fmt-check + clippy + test
|
||||
```
|
||||
|
||||
Or individually:
|
||||
|
||||
```bash
|
||||
cargo fmt --check
|
||||
cargo clippy -- -D warnings
|
||||
cargo test
|
||||
cd spa && bunx tsc --noEmit
|
||||
```
|
||||
|
||||
All four must pass. PRs with clippy warnings or failing tests won't be merged.
|
||||
|
||||
## Architecture
|
||||
|
||||
The project follows hexagonal (ports & adapters) architecture. See `architecture.mmd` for the full diagram.
|
||||
|
||||
**Key rules:**
|
||||
- Presentation handlers never touch repositories directly — all domain logic goes through use cases in the `application` crate
|
||||
- Application use cases return raw domain data — URL formatting, date display, and view model assembly belong in presentation mappers (`presentation/src/mappers/`)
|
||||
- Use cases called from presentation handlers take `&AppContext`. Functions called from adapter event handlers take individual `Arc<dyn Trait>` params to keep adapter dependencies explicit
|
||||
|
||||
```
|
||||
domain → pure types, traits (ports), zero deps
|
||||
application → use cases, orchestration
|
||||
presentation → Axum handlers, routes, OpenAPI
|
||||
worker → event consumer, background jobs
|
||||
adapters/* → implements domain ports (sqlite, postgres, AP, etc.)
|
||||
spa/ → React SPA (TanStack Router + shadcn/ui)
|
||||
```
|
||||
|
||||
### Adding a new feature
|
||||
|
||||
1. **Domain first** — models in `domain/src/models/`, ports in `ports.rs`, events in `events.rs`
|
||||
2. **Adapters** — implement ports in both `sqlite` and `postgres` adapters, add migration
|
||||
3. **Application** — use cases in `application/src/<domain>/`, wire into `context.rs`
|
||||
4. **API types** — DTOs in `api-types/src/`
|
||||
5. **Presentation** — handler file in `handlers/<domain>.rs`, routes in `routes.rs`
|
||||
6. **SPA** — API client in `spa/src/lib/api/`, hook in `spa/src/hooks/`, components
|
||||
7. **Classic HTML** — Askama template + CSS in `static/style.css`
|
||||
|
||||
### Database adapters
|
||||
|
||||
Both SQLite and PostgreSQL are supported. If you add a migration or repository, implement it for both. The postgres adapter uses `$1, $2` params and `TIMESTAMPTZ`; SQLite uses `?` and text datetimes.
|
||||
|
||||
### Federation (ActivityPub)
|
||||
|
||||
Federation is feature-gated (`#[cfg(feature = "federation")]`). If your feature should federate, add domain events, handle them in `activitypub/src/event_handler.rs`, and create an AP object + inbound handler.
|
||||
|
||||
## Code style
|
||||
|
||||
- No comments unless the *why* is non-obvious
|
||||
- Concise commit messages
|
||||
- One feature per PR — don't bundle unrelated changes
|
||||
- Follow existing patterns (check a similar feature for reference)
|
||||
|
||||
## Areas seeking help
|
||||
|
||||
- **TUI** (`crates/tui`) — deprecated, needs a maintainer to bring it up to feature parity
|
||||
- **Tests** — the domain and application crates have 400+ unit tests; integration tests for the presentation layer are welcome
|
||||
- **Docs** — API usage examples, deployment guides
|
||||
1223
Cargo.lock
generated
1223
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
40
Cargo.toml
40
Cargo.toml
@@ -4,7 +4,7 @@ members = [
|
||||
"crates/adapters/event-publisher",
|
||||
"crates/adapters/metadata",
|
||||
"crates/adapters/poster-fetcher",
|
||||
"crates/adapters/object-storage",
|
||||
"crates/adapters/image-storage",
|
||||
"crates/adapters/poster-sync",
|
||||
"crates/adapters/rss",
|
||||
"crates/adapters/sqlite",
|
||||
@@ -15,32 +15,23 @@ members = [
|
||||
"crates/adapters/postgres-event-queue",
|
||||
"crates/adapters/template-askama",
|
||||
"crates/adapters/activitypub",
|
||||
"crates/adapters/activitypub-base",
|
||||
"crates/adapters/export",
|
||||
"crates/adapters/event-payload",
|
||||
"crates/adapters/nats",
|
||||
"crates/api-types",
|
||||
"crates/application",
|
||||
"crates/adapters/tmdb-enrichment",
|
||||
"crates/adapters/image-converter",
|
||||
"crates/domain",
|
||||
"crates/presentation",
|
||||
"crates/tui",
|
||||
"crates/doc",
|
||||
"crates/worker",
|
||||
"crates/adapters/importer",
|
||||
"crates/adapters/jellyfin",
|
||||
"crates/adapters/plex",
|
||||
"crates/adapters/sqlite-search",
|
||||
"crates/adapters/postgres-search",
|
||||
"crates/adapters/adapter-common",
|
||||
"crates/infra-wiring",
|
||||
]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.dependencies]
|
||||
tokio = { version = "1.0", features = ["macros", "net", "rt", "rt-multi-thread", "sync", "time"] }
|
||||
bytes = "1"
|
||||
tokio = { version = "1.0", features = ["full"] }
|
||||
futures = "0.3"
|
||||
async-stream = "0.3"
|
||||
dotenvy = "0.15"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
@@ -55,24 +46,20 @@ sqlx = { version = "0.8.6", features = [
|
||||
"runtime-tokio-rustls",
|
||||
"sqlite",
|
||||
"uuid",
|
||||
"macros",
|
||||
] }
|
||||
rand = "0.9"
|
||||
reqwest = { version = "0.13", features = ["json", "query"] }
|
||||
sha2 = "0.10"
|
||||
hex = "0.4"
|
||||
object_store = { version = "0.11", features = ["aws"] }
|
||||
axum = { version = "0.8.8", features = ["macros", "multipart"] }
|
||||
csv = "1"
|
||||
|
||||
api-types = { path = "crates/api-types" }
|
||||
domain = { path = "crates/domain" }
|
||||
tmdb-enrichment = { path = "crates/adapters/tmdb-enrichment" }
|
||||
application = { path = "crates/application" }
|
||||
presentation = { path = "crates/presentation" }
|
||||
auth = { path = "crates/adapters/auth" }
|
||||
metadata = { path = "crates/adapters/metadata" }
|
||||
poster-fetcher = { path = "crates/adapters/poster-fetcher" }
|
||||
object-storage = { path = "crates/adapters/object-storage" }
|
||||
image-storage = { path = "crates/adapters/image-storage" }
|
||||
poster-sync = { path = "crates/adapters/poster-sync" }
|
||||
event-publisher = { path = "crates/adapters/event-publisher" }
|
||||
rss = { path = "crates/adapters/rss" }
|
||||
@@ -83,21 +70,10 @@ postgres = { path = "crates/adapters/postgres" }
|
||||
postgres-federation = { path = "crates/adapters/postgres-federation" }
|
||||
template-askama = { path = "crates/adapters/template-askama" }
|
||||
activitypub = { path = "crates/adapters/activitypub" }
|
||||
activitypub-base = { path = "crates/adapters/activitypub-base" }
|
||||
doc = { path = "crates/doc" }
|
||||
event-payload = { path = "crates/adapters/event-payload" }
|
||||
nats = { path = "crates/adapters/nats" }
|
||||
sqlite-event-queue = { path = "crates/adapters/sqlite-event-queue" }
|
||||
postgres-event-queue = { path = "crates/adapters/postgres-event-queue" }
|
||||
importer = { path = "crates/adapters/importer" }
|
||||
jellyfin = { path = "crates/adapters/jellyfin" }
|
||||
plex = { path = "crates/adapters/plex" }
|
||||
image-converter = { path = "crates/adapters/image-converter" }
|
||||
sqlite-search = { path = "crates/adapters/sqlite-search" }
|
||||
postgres-search = { path = "crates/adapters/postgres-search" }
|
||||
adapter-common = { path = "crates/adapters/adapter-common" }
|
||||
infra-wiring = { path = "crates/infra-wiring" }
|
||||
|
||||
[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
|
||||
|
||||
36
Dockerfile
36
Dockerfile
@@ -1,11 +1,3 @@
|
||||
# ----- 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
|
||||
|
||||
@@ -14,19 +6,19 @@ WORKDIR /build
|
||||
# Cache dependency compilation separately from source
|
||||
COPY Cargo.toml Cargo.lock ./
|
||||
COPY .cargo ./.cargo
|
||||
COPY .sqlx ./.sqlx
|
||||
COPY crates/adapters/activitypub/Cargo.toml crates/adapters/activitypub/Cargo.toml
|
||||
COPY crates/adapters/activitypub-base/Cargo.toml crates/adapters/activitypub-base/Cargo.toml
|
||||
COPY crates/adapters/auth/Cargo.toml crates/adapters/auth/Cargo.toml
|
||||
COPY crates/adapters/event-payload/Cargo.toml crates/adapters/event-payload/Cargo.toml
|
||||
COPY crates/adapters/event-publisher/Cargo.toml crates/adapters/event-publisher/Cargo.toml
|
||||
COPY crates/adapters/nats/Cargo.toml crates/adapters/nats/Cargo.toml
|
||||
COPY crates/adapters/metadata/Cargo.toml crates/adapters/metadata/Cargo.toml
|
||||
COPY crates/adapters/poster-fetcher/Cargo.toml crates/adapters/poster-fetcher/Cargo.toml
|
||||
COPY crates/adapters/object-storage/Cargo.toml crates/adapters/object-storage/Cargo.toml
|
||||
COPY crates/adapters/image-storage/Cargo.toml crates/adapters/image-storage/Cargo.toml
|
||||
COPY crates/adapters/poster-sync/Cargo.toml crates/adapters/poster-sync/Cargo.toml
|
||||
COPY crates/adapters/export/Cargo.toml crates/adapters/export/Cargo.toml
|
||||
COPY crates/adapters/importer/Cargo.toml crates/adapters/importer/Cargo.toml
|
||||
COPY crates/adapters/jellyfin/Cargo.toml crates/adapters/jellyfin/Cargo.toml
|
||||
COPY crates/adapters/plex/Cargo.toml crates/adapters/plex/Cargo.toml
|
||||
COPY crates/adapters/rss/Cargo.toml crates/adapters/rss/Cargo.toml
|
||||
COPY crates/adapters/sqlite/Cargo.toml crates/adapters/sqlite/Cargo.toml
|
||||
COPY crates/adapters/sqlite-federation/Cargo.toml crates/adapters/sqlite-federation/Cargo.toml
|
||||
@@ -35,36 +27,24 @@ COPY crates/adapters/postgres/Cargo.toml crates/adapters/postgres/Car
|
||||
COPY crates/adapters/postgres-federation/Cargo.toml crates/adapters/postgres-federation/Cargo.toml
|
||||
COPY crates/adapters/postgres-event-queue/Cargo.toml crates/adapters/postgres-event-queue/Cargo.toml
|
||||
COPY crates/adapters/template-askama/Cargo.toml crates/adapters/template-askama/Cargo.toml
|
||||
COPY crates/api-types/Cargo.toml crates/api-types/Cargo.toml
|
||||
COPY crates/application/Cargo.toml crates/application/Cargo.toml
|
||||
COPY crates/adapters/tmdb-enrichment/Cargo.toml crates/adapters/tmdb-enrichment/Cargo.toml
|
||||
COPY crates/domain/Cargo.toml crates/domain/Cargo.toml
|
||||
COPY crates/presentation/Cargo.toml crates/presentation/Cargo.toml
|
||||
COPY crates/doc/Cargo.toml crates/doc/Cargo.toml
|
||||
COPY crates/tui/Cargo.toml crates/tui/Cargo.toml
|
||||
COPY crates/adapters/image-converter/Cargo.toml crates/adapters/image-converter/Cargo.toml
|
||||
COPY crates/adapters/sqlite-search/Cargo.toml crates/adapters/sqlite-search/Cargo.toml
|
||||
COPY crates/adapters/postgres-search/Cargo.toml crates/adapters/postgres-search/Cargo.toml
|
||||
COPY crates/adapters/adapter-common/Cargo.toml crates/adapters/adapter-common/Cargo.toml
|
||||
COPY crates/worker/Cargo.toml crates/worker/Cargo.toml
|
||||
COPY crates/infra-wiring/Cargo.toml crates/infra-wiring/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'
|
||||
|
||||
# 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 fetch
|
||||
|
||||
# Now copy real sources (invalidates cache only on source changes)
|
||||
COPY crates ./crates
|
||||
|
||||
# All sqlx queries use the runtime API (no query! macros), so no database
|
||||
# or .sqlx cache is needed at compile time.
|
||||
# .cargo/config.toml sets SQLX_OFFLINE=true; .sqlx contains the pre-verified query cache.
|
||||
# No live database needed at compile time.
|
||||
#
|
||||
# To build with PostgreSQL backend instead:
|
||||
# --build-arg FEATURES=postgres,postgres-federation
|
||||
@@ -78,9 +58,6 @@ 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
|
||||
@@ -88,7 +65,6 @@ WORKDIR /app
|
||||
COPY --from=builder /build/target/release/presentation ./presentation
|
||||
COPY --from=builder /build/target/release/worker ./worker
|
||||
COPY static ./static
|
||||
COPY --from=spa-builder /spa/dist ./spa/dist
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
|
||||
37
Makefile
37
Makefile
@@ -1,37 +0,0 @@
|
||||
.DEFAULT_GOAL := check
|
||||
|
||||
# Run the full local check suite — same order as CI would.
|
||||
check: fmt-check clippy test check-appcontext
|
||||
@echo "✅ All checks passed"
|
||||
|
||||
# Enforce that no application use case imports AppContext (god-object guard).
|
||||
check-appcontext:
|
||||
@if grep -rn "AppContext" crates/application/src --include="*.rs" | grep -q .; then \
|
||||
echo "❌ AppContext found in application crate:"; \
|
||||
grep -rn "AppContext" crates/application/src --include="*.rs"; \
|
||||
exit 1; \
|
||||
fi
|
||||
@echo "✅ No AppContext in application crate"
|
||||
|
||||
# Apply rustfmt to all files.
|
||||
fmt:
|
||||
cargo fmt
|
||||
|
||||
# Check formatting without modifying files (CI-safe).
|
||||
fmt-check:
|
||||
cargo fmt --check
|
||||
|
||||
# Run Clippy and treat warnings as errors.
|
||||
clippy:
|
||||
cargo clippy -- -D warnings
|
||||
|
||||
# Run the test suite.
|
||||
test:
|
||||
cargo test
|
||||
|
||||
# Apply fmt + clippy auto-fixes in one shot.
|
||||
fix:
|
||||
cargo fmt
|
||||
cargo clippy --fix --allow-dirty --allow-staged
|
||||
|
||||
.PHONY: check fmt fmt-check clippy test fix
|
||||
301
README.md
301
README.md
@@ -1,130 +1,56 @@
|
||||
# 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 or PostgreSQL.
|
||||
|
||||
[](LICENSE)
|
||||
[](https://www.rust-lang.org/)
|
||||
[](https://hub.docker.com/)
|
||||
[](https://activitypub.rocks/)
|
||||
[](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 with a full REST API. Built in Rust — no JavaScript in the HTML interface, just HTML forms and an RSS feed. Designed to run as a lightweight widget embedded on a personal site or as a backend for third-party clients.
|
||||
|
||||
## Features
|
||||
|
||||
- Log movies with a TMDB/OMDb ID or manual title/year/director, with a 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)
|
||||
- Log movies with a TMDB/OMDb ID or manual title/year/director, with a 0–5 rating
|
||||
- Immutable append-only viewing ledger (tracks re-watches)
|
||||
- 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 or PostgreSQL tsvector + GIN indexes
|
||||
- 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)
|
||||
- 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
|
||||
- ActivityPub federation — follow/unfollow remote users, accept/reject/remove followers, federated reviews broadcast as `Note` objects with `#MoviesDiary` + `#MovieTitle` hashtags, paginated outbox, boost/Announce tracking, NodeInfo discovery endpoint, shared inbox delivery, actor profile sync (bio, avatar, discoverable)
|
||||
- Federation moderation — instance-level domain blocking (admin-managed), per-user actor blocking with `Block` activity, delivery filter excludes blocked actors and blocked-domain inboxes
|
||||
- CSV and JSON diary export
|
||||
- File importer: upload CSV, TSV, JSON, or XLSX from any source (Letterboxd, IMDb, etc.), map columns to domain fields via a step-by-step wizard or REST API, save mapping profiles for repeat imports
|
||||
- REST API v1 (`/api/v1/`) with full feature parity with the HTML interface
|
||||
- OpenAPI documentation at `/docs` (Swagger UI) and `/scalar` (Scalar)
|
||||
- CSRF protection on all HTML form routes (double-submit cookie, defense-in-depth on top of `SameSite=Strict`)
|
||||
- Per-IP rate limiting via token bucket (production-grade, backed by `axum-governor`)
|
||||
- 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 |
|
||||
|------|-------|--------|
|
||||
|  |  |  |
|
||||
|
||||
| Profile | Wrap-Up | Wrap-Up card |
|
||||
|---------|---------|--------------|
|
||||
|  |  |  |
|
||||
- Terminal UI client (`crates/tui`) for logging reviews, bulk CSV import, and diary browsing
|
||||
|
||||
## Architecture
|
||||
|
||||
Hexagonal (Ports & Adapters) with Domain-Driven Design:
|
||||
|
||||
```
|
||||
api-types — shared REST API request/response DTOs (Serialize/Deserialize + utoipa schemas) + HtmlPageContext; used by presentation, tui, and template adapters
|
||||
infra-wiring — shared infrastructure types (DbPool, EventBusBackend, AppConfig) used by both presentation and worker binaries
|
||||
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, integrations, movies, person, search, social, users, watchlist, wrapup
|
||||
presentation — Axum HTTP router, OpenAPI spec assembly, Swagger UI + Scalar serving, composition root for the HTTP process
|
||||
domain — pure types and trait definitions, no external deps
|
||||
application — use cases / business logic orchestration
|
||||
presentation — Axum HTTP router, composition root for the HTTP process
|
||||
worker — standalone worker binary (event consumer, poster sync, federation)
|
||||
adapters/
|
||||
adapter-common — shared row-to-domain conversions, sqlx error mapping, date/uuid parsing utils
|
||||
auth — JWT issuance and validation (Argon2 passwords)
|
||||
sqlite — SQLite repository + connection factory
|
||||
postgres — PostgreSQL repository + connection factory
|
||||
metadata — TMDB / OMDb HTTP client
|
||||
poster-fetcher — downloads poster images
|
||||
object-storage — stores images (posters + user avatars) on local filesystem or S3-compatible storage
|
||||
image-storage — stores images (posters + user avatars) on local filesystem or S3-compatible storage
|
||||
poster-sync — event handler: triggers poster fetch+store on MovieDiscovered
|
||||
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
|
||||
template-askama — Askama HTML rendering
|
||||
rss — RSS/Atom feed generation
|
||||
export — CSV and JSON diary serialization
|
||||
importer — CSV/TSV/JSON/XLSX parser and column mapper for bulk import
|
||||
jellyfin — Jellyfin webhook payload parser (MediaServerParser adapter)
|
||||
plex — Plex webhook payload parser (MediaServerParser adapter; requires Plex Pass)
|
||||
event-payload — shared event serialization DTOs (used by all event bus adapters)
|
||||
sqlite-event-queue — durable polling event queue backed by SQLite
|
||||
postgres-event-queue — durable polling event queue backed by PostgreSQL
|
||||
event-publisher — in-memory event channel (used in tests)
|
||||
nats — NATS Core / JetStream event publisher and consumer
|
||||
event-publisher — in-memory event channel (used in tests)
|
||||
activitypub — ActivityPub federation adapter (follow, inbox/outbox, actor); delegates to k-ap for protocol internals
|
||||
sqlite-search — SQLite FTS5 implementation of SearchPort + SearchCommand
|
||||
postgres-search — PostgreSQL tsvector + GIN implementation of SearchPort + SearchCommand
|
||||
event-publisher — in-memory event channel (tests only)
|
||||
activitypub — ActivityPub federation wiring (follow, inbox/outbox, actor)
|
||||
activitypub-base — core ActivityPub protocol types and service
|
||||
sqlite-federation — SQLite-backed federation repository
|
||||
postgres-federation — PostgreSQL-backed federation repository
|
||||
tui — terminal UI client (ratatui); shares api-types with presentation for typed API access
|
||||
spa/ — React SPA (TanStack Router + shadcn/ui + Vite); served at /app/ by the backend
|
||||
doc — OpenAPI spec assembly and Swagger UI / Scalar serving
|
||||
tui — terminal UI client (ratatui)
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
@@ -134,35 +60,49 @@ spa/ — React SPA (TanStack Router + shadcn/ui + Vite); served a
|
||||
- Poster storage: local filesystem (zero deps) or an S3-compatible object store (e.g. MinIO)
|
||||
- An [OMDb API key](https://www.omdbapi.com/apikey.aspx)
|
||||
|
||||
## Configuration
|
||||
## Environment Variables
|
||||
|
||||
Copy `.env.example` to `.env` and set the values below. Required fields must be set before the server will start.
|
||||
A `.env.example` file is provided at the repo root — copy it to `.env` and fill in your values.
|
||||
|
||||
| Variable | Default | Required | Description |
|
||||
|---|---|---|---|
|
||||
| `DATABASE_URL` | `sqlite://movies.db` | Yes | SQLite or PostgreSQL connection string |
|
||||
| `JWT_SECRET` | — | Yes | Secret for JWT signing — use a long random string |
|
||||
| `OMDB_API_KEY` | — | Yes | [OMDb](https://www.omdbapi.com/apikey.aspx) key for movie metadata |
|
||||
| `TMDB_API_KEY` | — | No | [TMDb](https://www.themoviedb.org/settings/api) key — enables cast, crew, genres, enrichment |
|
||||
| `BASE_URL` | — | Yes | Public URL of your instance (used for ActivityPub actor URLs) |
|
||||
| `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` | — | S3 only | Region (e.g. `minio`) |
|
||||
| `MINIO_ACCESS_KEY_ID` | — | S3 only | Access key ID |
|
||||
| `MINIO_SECRET_ACCESS_KEY` | — | S3 only | Secret access key |
|
||||
| `IMAGE_CONVERSION_ENABLED` | `false` | No | Convert stored images to AVIF or WebP |
|
||||
| `IMAGE_CONVERSION_FORMAT` | `avif` | No | `avif` or `webp` |
|
||||
| `HOST` | `0.0.0.0` | No | Bind address |
|
||||
| `PORT` | `3000` | No | HTTP port |
|
||||
| `RATE_LIMIT` | `60` | No | Requests per minute per IP |
|
||||
| `ALLOW_REGISTRATION` | `true` | No | Set `false` to disable new sign-ups |
|
||||
| `SECURE_COOKIES` | `true` | No | Must be `true` when serving over HTTPS |
|
||||
| `RUST_LOG` | — | No | Log verbosity (e.g. `presentation=info,worker=info`) |
|
||||
| `CORS_ORIGINS` | `*` | No | Comma-separated allowed origins for SPA dev |
|
||||
| `EVENT_BUS_BACKEND` | `db` | No | `db` (default) or `nats` |
|
||||
| `NATS_URL` | — | NATS only | NATS connection URL (e.g. `nats://localhost:4222`) |
|
||||
```env
|
||||
# Database
|
||||
DATABASE_URL=sqlite://movies.db
|
||||
|
||||
# Authentication
|
||||
JWT_SECRET=change-me
|
||||
|
||||
# OMDb metadata
|
||||
OMDB_API_KEY=your-key
|
||||
|
||||
# Public base URL (used for ActivityPub actor URLs and canonical links)
|
||||
BASE_URL=https://yourdomain.example.com
|
||||
|
||||
# Image storage — pick one backend:
|
||||
|
||||
# Option A: local filesystem (zero deps)
|
||||
IMAGE_STORAGE_BACKEND=local
|
||||
IMAGE_STORAGE_PATH=./images
|
||||
|
||||
# Option B: S3-compatible (MinIO, AWS S3, etc.)
|
||||
# IMAGE_STORAGE_BACKEND=s3
|
||||
# MINIO_ENDPOINT=http://localhost:9000
|
||||
# MINIO_BUCKET=posters
|
||||
# MINIO_REGION=minio
|
||||
# MINIO_ACCESS_KEY_ID=minioadmin
|
||||
# MINIO_SECRET_ACCESS_KEY=minioadmin
|
||||
|
||||
# Optional
|
||||
HOST=0.0.0.0
|
||||
PORT=3000
|
||||
RATE_LIMIT=60 # requests per minute per IP (default: 60)
|
||||
ALLOW_REGISTRATION=true # set to false to disable new sign-ups
|
||||
SECURE_COOKIES=true # set when serving over HTTPS
|
||||
RUST_LOG=presentation=info,tower_http=info,worker=info,application=info
|
||||
|
||||
# Event bus — "db" (default, uses same database) or "nats"
|
||||
EVENT_BUS_BACKEND=db
|
||||
# NATS_URL=nats://localhost:4222 # required when EVENT_BUS_BACKEND=nats
|
||||
```
|
||||
|
||||
The `worker` binary must run alongside `presentation` to process events:
|
||||
|
||||
@@ -188,121 +128,54 @@ Interactive API documentation is available at runtime:
|
||||
- **Swagger UI** — `http://localhost:3000/docs`
|
||||
- **Scalar** — `http://localhost:3000/scalar`
|
||||
|
||||
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.
|
||||
## Terminal UI
|
||||
|
||||
```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 # default: fmt-check + clippy + test (same order as CI)
|
||||
make fix # auto-apply fmt + clippy fixes
|
||||
make fmt # apply rustfmt
|
||||
make clippy # clippy with -D warnings
|
||||
make test # cargo test
|
||||
```
|
||||
Supports review logging, bulk CSV import (column order matches the export format), and diary browsing with review history.
|
||||
|
||||
## Test
|
||||
|
||||
```bash
|
||||
cargo test # full workspace (requires DATABASE_URL for sqlx offline checks)
|
||||
cargo test -p application # business logic tests only — no database required
|
||||
cargo test -p domain # domain model + value object tests
|
||||
cargo llvm-cov -p application -p domain # line coverage report (requires cargo-llvm-cov)
|
||||
cargo test
|
||||
```
|
||||
|
||||
The `application` and `domain` crates have 400+ unit tests covering all use case modules (auth, diary, goals, import, integrations, movies, person, search, users, watchlist, wrapup) backed by in-memory fakes from `domain`'s `test-helpers` feature. These run without a database and are the fastest feedback loop for business logic changes.
|
||||
|
||||
## Docker
|
||||
|
||||
### Quick start
|
||||
The image contains both `presentation` (HTTP server) and `worker` (event processor). Run them as separate containers sharing the same data volume:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# Edit .env — set JWT_SECRET and OMDB_API_KEY (or TMDB_API_KEY)
|
||||
docker compose up -d
|
||||
# Build (SQLite + federation + NATS support)
|
||||
docker build -t movies-diary \
|
||||
--build-arg FEATURES=sqlite,sqlite-federation,nats .
|
||||
|
||||
# HTTP server
|
||||
docker run -p 3000:3000 \
|
||||
-e DATABASE_URL=sqlite:///data/movies.db \
|
||||
-e JWT_SECRET=change-me \
|
||||
-e OMDB_API_KEY=your-key \
|
||||
-e BASE_URL=https://yourdomain.example.com \
|
||||
-e EVENT_BUS_BACKEND=nats \
|
||||
-e NATS_URL=nats://nats:4222 \
|
||||
-v $(pwd)/data:/data \
|
||||
movies-diary
|
||||
|
||||
# Event worker (separate container, same image)
|
||||
docker run \
|
||||
-e DATABASE_URL=sqlite:///data/movies.db \
|
||||
-e JWT_SECRET=change-me \
|
||||
-e OMDB_API_KEY=your-key \
|
||||
-e BASE_URL=https://yourdomain.example.com \
|
||||
-e EVENT_BUS_BACKEND=nats \
|
||||
-e NATS_URL=nats://nats:4222 \
|
||||
-v $(pwd)/data:/data \
|
||||
--entrypoint ./worker \
|
||||
movies-diary
|
||||
```
|
||||
|
||||
This builds and starts the HTTP server (port 3000) and event worker. Data is persisted in a Docker volume.
|
||||
|
||||
### Manual docker run
|
||||
|
||||
The image contains both `presentation` and `worker` binaries. Run them as separate containers sharing the same data volume:
|
||||
|
||||
```bash
|
||||
docker build -t movies-diary .
|
||||
|
||||
docker run -p 3000:3000 --env-file .env -v movies-diary-data:/data movies-diary
|
||||
docker run --env-file .env -v movies-diary-data:/data --entrypoint ./worker movies-diary
|
||||
```
|
||||
|
||||
Build for PostgreSQL: `--build-arg FEATURES=postgres,postgres-federation`
|
||||
|
||||
## Media Server Integration
|
||||
|
||||
Auto-log movies you finish watching. Go to `/settings/integrations` to generate a webhook token, then configure your media server.
|
||||
|
||||
### Jellyfin
|
||||
|
||||
1. Install the **Webhook** plugin (Dashboard > Plugins > Catalog)
|
||||
2. Add a **Generic** destination:
|
||||
- **URL**: `https://yourdomain.example.com/api/v1/webhooks/jellyfin`
|
||||
- **Header**: `Authorization` = `Bearer <your-token>`
|
||||
- **Send All Properties**: enabled
|
||||
- **Notification Type**: Playback Stop only
|
||||
- **Item Type**: Movies only
|
||||
|
||||
### Plex (requires Plex Pass)
|
||||
|
||||
1. Go to Settings > Webhooks in your Plex server
|
||||
2. Add webhook URL: `https://yourdomain.example.com/api/v1/webhooks/plex`
|
||||
3. Plex does not support custom headers natively — pass the token as a query param: `https://yourdomain.example.com/api/v1/webhooks/plex?token=<your-token>`
|
||||
|
||||
Movies you finish watching appear in your watch queue at `/watch-queue` — rate and confirm to add to your diary.
|
||||
|
||||
## Annual Wrap-Up
|
||||
|
||||
Generate a year-in-review summary for any user — top directors, actors, genres, rating distribution, total watch time, rewatch stats, and more. Available as a shareable HTML page.
|
||||
|
||||
**Generate via API** (admin only):
|
||||
```bash
|
||||
curl -X POST http://localhost:3000/api/v1/wrapups/generate \
|
||||
-H "Authorization: Bearer <admin-token>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"user_id": "<uuid>", "start_date": "2025-01-01", "end_date": "2026-01-01"}'
|
||||
```
|
||||
|
||||
Omit `user_id` for a global instance wrap-up. The worker computes stats in the background — poll `GET /api/v1/wrapups/{id}` for status.
|
||||
|
||||
**View:** `http://localhost:3000/wrapups/{user_id}/2025` (public, no login required)
|
||||
|
||||
**Auto-generate:** The worker runs a daily job in January that generates wrap-ups for all users with reviews in the previous year.
|
||||
|
||||
## Contributing
|
||||
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for setup instructions, architecture overview, and PR guidelines.
|
||||
To build for PostgreSQL: `--build-arg FEATURES=postgres,postgres-federation,nats`
|
||||
|
||||
## License
|
||||
|
||||
|
||||
153
architecture.mmd
153
architecture.mmd
@@ -1,153 +0,0 @@
|
||||
---
|
||||
title: Movies Diary — Hexagonal Architecture
|
||||
---
|
||||
graph TB
|
||||
subgraph Binaries["Binaries (Composition Root)"]
|
||||
WEB["presentation<br/><i>Axum web server</i><br/>Routes, Handlers, Mappers"]
|
||||
WORKER["worker<br/><i>Event consumer</i><br/>Concurrent dispatch, graceful shutdown"]
|
||||
TUI["tui<br/><i>Terminal UI</i>"]
|
||||
end
|
||||
|
||||
subgraph Application["Application Layer"]
|
||||
direction TB
|
||||
CTX["AppContext<br/><i>Repositories + Services</i>"]
|
||||
APP_PORTS["ReviewLogger<br/><i>application-layer port</i>"]
|
||||
subgraph UseCases["Use Cases"]
|
||||
UC_AUTH["auth<br/>login, register"]
|
||||
UC_DIARY["diary<br/>log_review, 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_AP["ActivityPubEventHandler<br/><i>reviews, watchlist, goals,<br/>UserDeleted, UserAccountMoved</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 InfraWiring["infra-wiring"]
|
||||
IW["DbPool, EventBusBackend,<br/>AppConfig<br/><i>Shared infra types</i>"]
|
||||
end
|
||||
|
||||
subgraph Adapters["Adapters (implement Port Traits)"]
|
||||
direction TB
|
||||
A_COMMON["adapter-common<br/><i>Shared row conversions,<br/>error mapping, date utils</i>"]
|
||||
subgraph Storage["Storage"]
|
||||
A_SQLITE["sqlite<br/><i>SQLite repos</i>"]
|
||||
A_PG["postgres<br/><i>PostgreSQL repos</i>"]
|
||||
A_SQLITE_SEARCH["sqlite-search<br/><i>FTS5</i>"]
|
||||
A_PG_SEARCH["postgres-search<br/><i>tsvector/GIN</i>"]
|
||||
A_OBJ["object-storage<br/><i>S3 / filesystem</i>"]
|
||||
end
|
||||
subgraph Messaging["Messaging"]
|
||||
A_NATS["nats<br/><i>JetStream / Core</i>"]
|
||||
A_SQLITE_QUEUE["sqlite-event-queue<br/><i>Polling, dead-letter</i>"]
|
||||
A_PG_QUEUE["postgres-event-queue<br/><i>Polling, dead-letter</i>"]
|
||||
A_EVT_PUB["event-publisher<br/><i>In-memory (tests)</i>"]
|
||||
A_PAYLOAD["event-payload<br/><i>Serde (de)serialization</i>"]
|
||||
end
|
||||
subgraph External["External Services"]
|
||||
A_METADATA["metadata<br/><i>TMDB search/details</i>"]
|
||||
A_TMDB["tmdb-enrichment<br/><i>Credits, keywords, cast</i>"]
|
||||
A_POSTER["poster-fetcher<br/><i>TMDB image download</i>"]
|
||||
A_AUTH["auth<br/><i>JWT tokens</i>"]
|
||||
end
|
||||
subgraph Federation["Federation (feature-gated)"]
|
||||
A_AP["activitypub<br/><i>k_ap library</i>"]
|
||||
A_SQLITE_FED["sqlite-federation"]
|
||||
A_PG_FED["postgres-federation"]
|
||||
end
|
||||
subgraph Media["Media Processing"]
|
||||
A_IMG["image-converter<br/><i>AVIF/WebP</i>"]
|
||||
A_POSTER_SYNC["poster-sync"]
|
||||
end
|
||||
subgraph Presentation["Presentation Helpers"]
|
||||
A_TEMPLATE["template-askama<br/><i>HTML templates</i>"]
|
||||
A_RSS["rss<br/><i>Feed generation</i>"]
|
||||
A_EXPORT["export<br/><i>CSV/JSON diary</i>"]
|
||||
A_IMPORT["importer<br/><i>CSV/JSON/XLSX parser</i>"]
|
||||
end
|
||||
subgraph Webhooks["Webhook Parsers"]
|
||||
A_JELLYFIN["jellyfin"]
|
||||
A_PLEX["plex"]
|
||||
end
|
||||
end
|
||||
|
||||
%% Dependency arrows
|
||||
WEB -->|"uses"| Application
|
||||
WEB -->|"maps to"| ApiTypes
|
||||
WORKER -->|"uses"| Application
|
||||
|
||||
Application -->|"depends on"| Domain
|
||||
UC_WRAPUP -->|"delegates to"| DS_WRAPUP
|
||||
|
||||
Adapters -.->|"implements"| Ports
|
||||
|
||||
%% Key data flows
|
||||
WEB ===|"HTTP"| DTO
|
||||
WORKER ===|"Events"| EVENTS
|
||||
|
||||
classDef domain fill:#1a1a2e,stroke:#e94560,color:#fff
|
||||
classDef app fill:#16213e,stroke:#0f3460,color:#fff
|
||||
classDef adapter fill:#0f3460,stroke:#533483,color:#fff
|
||||
classDef binary fill:#533483,stroke:#e94560,color:#fff
|
||||
classDef api fill:#2a2a4a,stroke:#e94560,color:#fff
|
||||
|
||||
class Domain domain
|
||||
class DomainServices domain
|
||||
class Application app
|
||||
class Adapters adapter
|
||||
class Binaries binary
|
||||
class ApiTypes api
|
||||
21
crates/adapters/activitypub-base/Cargo.toml
Normal file
21
crates/adapters/activitypub-base/Cargo.toml
Normal file
@@ -0,0 +1,21 @@
|
||||
[package]
|
||||
name = "activitypub-base"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
tokio = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
axum = { workspace = true }
|
||||
|
||||
activitypub_federation = "0.7.0-beta.11"
|
||||
url = { version = "2", features = ["serde"] }
|
||||
enum_delegate = "0.2"
|
||||
508
crates/adapters/activitypub-base/src/activities.rs
Normal file
508
crates/adapters/activitypub-base/src/activities.rs
Normal file
@@ -0,0 +1,508 @@
|
||||
use activitypub_federation::{
|
||||
config::Data,
|
||||
fetch::object_id::ObjectId,
|
||||
kinds::activity::{
|
||||
AcceptType, CreateType, DeleteType, FollowType, RejectType, UndoType, UpdateType,
|
||||
},
|
||||
traits::Activity,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use url::Url;
|
||||
|
||||
#[derive(Clone, Default, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename = "Announce")]
|
||||
pub struct AnnounceType;
|
||||
|
||||
use crate::actors::DbActor;
|
||||
use crate::data::FederationData;
|
||||
use crate::error::Error;
|
||||
use crate::repository::{FollowerStatus, FollowingStatus};
|
||||
|
||||
// --- Follow ---
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FollowActivity {
|
||||
pub(crate) id: Url,
|
||||
#[serde(rename = "type", default)]
|
||||
pub(crate) kind: FollowType,
|
||||
pub(crate) actor: ObjectId<DbActor>,
|
||||
pub(crate) object: ObjectId<DbActor>,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl Activity for FollowActivity {
|
||||
type DataType = FederationData;
|
||||
type Error = Error;
|
||||
|
||||
fn id(&self) -> &Url {
|
||||
&self.id
|
||||
}
|
||||
|
||||
fn actor(&self) -> &Url {
|
||||
self.actor.inner()
|
||||
}
|
||||
|
||||
async fn verify(&self, data: &Data<Self::DataType>) -> Result<(), Self::Error> {
|
||||
let target_url = self.object.inner();
|
||||
let target_domain = match (target_url.host_str(), target_url.port()) {
|
||||
(Some(host), Some(port)) => format!("{}:{}", host, port),
|
||||
(Some(host), None) => host.to_string(),
|
||||
_ => {
|
||||
return Err(Error::bad_request(anyhow::anyhow!(
|
||||
"invalid follow target URL"
|
||||
)));
|
||||
}
|
||||
};
|
||||
if target_domain != data.domain {
|
||||
return Err(Error::bad_request(anyhow::anyhow!(
|
||||
"follow target is not a local actor"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn receive(self, data: &Data<Self::DataType>) -> Result<(), Self::Error> {
|
||||
let domain = self.actor().host_str().unwrap_or("");
|
||||
if data.federation_repo.is_domain_blocked(domain).await? {
|
||||
tracing::info!(actor = %self.actor(), "ignoring activity from blocked domain");
|
||||
return Ok(());
|
||||
}
|
||||
let _follower = self.actor.dereference(data).await?;
|
||||
let local_actor = self.object.dereference(data).await?;
|
||||
|
||||
if data.federation_repo
|
||||
.is_actor_blocked(local_actor.user_id, self.actor.inner().as_str())
|
||||
.await?
|
||||
{
|
||||
tracing::info!(actor = %self.actor.inner(), "ignoring follow from blocked actor");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
data.federation_repo
|
||||
.add_follower(
|
||||
local_actor.user_id,
|
||||
self.actor.inner().as_str(),
|
||||
FollowerStatus::Pending,
|
||||
self.id.as_str(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
tracing::info!(
|
||||
follower = %self.actor.inner(),
|
||||
local_user = %local_actor.user_id,
|
||||
"follow request pending approval"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// --- Accept ---
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AcceptActivity {
|
||||
pub(crate) id: Url,
|
||||
#[serde(rename = "type", default)]
|
||||
pub(crate) kind: AcceptType,
|
||||
pub(crate) actor: ObjectId<DbActor>,
|
||||
pub(crate) object: FollowActivity,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl Activity for AcceptActivity {
|
||||
type DataType = FederationData;
|
||||
type Error = Error;
|
||||
|
||||
fn id(&self) -> &Url {
|
||||
&self.id
|
||||
}
|
||||
|
||||
fn actor(&self) -> &Url {
|
||||
self.actor.inner()
|
||||
}
|
||||
|
||||
async fn verify(&self, _data: &Data<Self::DataType>) -> Result<(), Self::Error> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn receive(self, data: &Data<Self::DataType>) -> Result<(), Self::Error> {
|
||||
let domain = self.actor().host_str().unwrap_or("");
|
||||
if data.federation_repo.is_domain_blocked(domain).await? {
|
||||
tracing::info!(actor = %self.actor(), "ignoring activity from blocked domain");
|
||||
return Ok(());
|
||||
}
|
||||
let local_user_id = crate::urls::extract_user_id_from_url(self.object.actor.inner())
|
||||
.ok_or_else(|| Error::bad_request(anyhow::anyhow!("invalid actor URL in Follow")))?;
|
||||
data.federation_repo
|
||||
.update_following_status(
|
||||
local_user_id,
|
||||
self.actor.inner().as_str(),
|
||||
FollowingStatus::Accepted,
|
||||
)
|
||||
.await?;
|
||||
tracing::info!(remote_actor = %self.actor.inner(), "follow accepted by remote");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// --- Reject ---
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RejectActivity {
|
||||
pub(crate) id: Url,
|
||||
#[serde(rename = "type", default)]
|
||||
pub(crate) kind: RejectType,
|
||||
pub(crate) actor: ObjectId<DbActor>,
|
||||
pub(crate) object: FollowActivity,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl Activity for RejectActivity {
|
||||
type DataType = FederationData;
|
||||
type Error = Error;
|
||||
|
||||
fn id(&self) -> &Url {
|
||||
&self.id
|
||||
}
|
||||
|
||||
fn actor(&self) -> &Url {
|
||||
self.actor.inner()
|
||||
}
|
||||
|
||||
async fn verify(&self, _data: &Data<Self::DataType>) -> Result<(), Self::Error> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn receive(self, data: &Data<Self::DataType>) -> Result<(), Self::Error> {
|
||||
let domain = self.actor().host_str().unwrap_or("");
|
||||
if data.federation_repo.is_domain_blocked(domain).await? {
|
||||
tracing::info!(actor = %self.actor(), "ignoring activity from blocked domain");
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(user_id) = crate::urls::extract_user_id_from_url(self.object.actor.inner()) {
|
||||
data.federation_repo
|
||||
.remove_following(user_id, self.actor.inner().as_str())
|
||||
.await?;
|
||||
}
|
||||
tracing::info!(actor = %self.actor.inner(), "follow rejected");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// --- Undo ---
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UndoActivity {
|
||||
pub(crate) id: Url,
|
||||
#[serde(rename = "type", default)]
|
||||
pub(crate) kind: UndoType,
|
||||
pub(crate) actor: ObjectId<DbActor>,
|
||||
pub(crate) object: FollowActivity,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl Activity for UndoActivity {
|
||||
type DataType = FederationData;
|
||||
type Error = Error;
|
||||
|
||||
fn id(&self) -> &Url {
|
||||
&self.id
|
||||
}
|
||||
|
||||
fn actor(&self) -> &Url {
|
||||
self.actor.inner()
|
||||
}
|
||||
|
||||
async fn verify(&self, _data: &Data<Self::DataType>) -> Result<(), Self::Error> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn receive(self, data: &Data<Self::DataType>) -> Result<(), Self::Error> {
|
||||
let domain = self.actor().host_str().unwrap_or("");
|
||||
if data.federation_repo.is_domain_blocked(domain).await? {
|
||||
tracing::info!(actor = %self.actor(), "ignoring activity from blocked domain");
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(user_id) = crate::urls::extract_user_id_from_url(self.object.object.inner()) {
|
||||
data.federation_repo
|
||||
.remove_follower(user_id, self.actor.inner().as_str())
|
||||
.await?;
|
||||
}
|
||||
data.object_handler
|
||||
.on_actor_removed(self.actor.inner())
|
||||
.await
|
||||
.map_err(|e| Error::from(anyhow::anyhow!(e)))?;
|
||||
tracing::info!(actor = %self.actor.inner(), "unfollowed");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// --- Create ---
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreateActivity {
|
||||
pub(crate) id: Url,
|
||||
#[serde(rename = "type", default)]
|
||||
pub(crate) kind: CreateType,
|
||||
pub(crate) actor: ObjectId<DbActor>,
|
||||
pub(crate) object: serde_json::Value,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl Activity for CreateActivity {
|
||||
type DataType = FederationData;
|
||||
type Error = Error;
|
||||
|
||||
fn id(&self) -> &Url {
|
||||
&self.id
|
||||
}
|
||||
|
||||
fn actor(&self) -> &Url {
|
||||
self.actor.inner()
|
||||
}
|
||||
|
||||
async fn verify(&self, _data: &Data<Self::DataType>) -> Result<(), Self::Error> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn receive(self, data: &Data<Self::DataType>) -> Result<(), Self::Error> {
|
||||
let domain = self.actor().host_str().unwrap_or("");
|
||||
if data.federation_repo.is_domain_blocked(domain).await? {
|
||||
tracing::info!(actor = %self.actor(), "ignoring activity from blocked domain");
|
||||
return Ok(());
|
||||
}
|
||||
let ap_id = self.id.clone();
|
||||
let actor_url = self.actor.inner().clone();
|
||||
data.object_handler
|
||||
.on_create(&ap_id, &actor_url, self.object)
|
||||
.await
|
||||
.map_err(|e| Error::from(anyhow::anyhow!(e)))?;
|
||||
tracing::info!(actor = %actor_url, "received create activity");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// --- Delete ---
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DeleteActivity {
|
||||
pub(crate) id: Url,
|
||||
#[serde(rename = "type", default)]
|
||||
pub(crate) kind: DeleteType,
|
||||
pub(crate) actor: ObjectId<DbActor>,
|
||||
pub(crate) object: Url,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl Activity for DeleteActivity {
|
||||
type DataType = FederationData;
|
||||
type Error = Error;
|
||||
|
||||
fn id(&self) -> &Url {
|
||||
&self.id
|
||||
}
|
||||
|
||||
fn actor(&self) -> &Url {
|
||||
self.actor.inner()
|
||||
}
|
||||
|
||||
async fn verify(&self, _data: &Data<Self::DataType>) -> Result<(), Self::Error> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn receive(self, data: &Data<Self::DataType>) -> Result<(), Self::Error> {
|
||||
let domain = self.actor().host_str().unwrap_or("");
|
||||
if data.federation_repo.is_domain_blocked(domain).await? {
|
||||
tracing::info!(actor = %self.actor(), "ignoring activity from blocked domain");
|
||||
return Ok(());
|
||||
}
|
||||
let actor_url = self.actor.inner().clone();
|
||||
data.object_handler
|
||||
.on_delete(&self.object, &actor_url)
|
||||
.await
|
||||
.map_err(|e| Error::from(anyhow::anyhow!(e)))?;
|
||||
tracing::info!(object = %self.object, "received delete activity");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// --- Update ---
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UpdateActivity {
|
||||
pub(crate) id: Url,
|
||||
#[serde(rename = "type", default)]
|
||||
pub(crate) kind: UpdateType,
|
||||
pub(crate) actor: ObjectId<DbActor>,
|
||||
pub(crate) object: serde_json::Value,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl Activity for UpdateActivity {
|
||||
type DataType = FederationData;
|
||||
type Error = Error;
|
||||
|
||||
fn id(&self) -> &Url {
|
||||
&self.id
|
||||
}
|
||||
|
||||
fn actor(&self) -> &Url {
|
||||
self.actor.inner()
|
||||
}
|
||||
|
||||
async fn verify(&self, _data: &Data<Self::DataType>) -> Result<(), Self::Error> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn receive(self, data: &Data<Self::DataType>) -> Result<(), Self::Error> {
|
||||
let domain = self.actor().host_str().unwrap_or("");
|
||||
if data.federation_repo.is_domain_blocked(domain).await? {
|
||||
tracing::info!(actor = %self.actor(), "ignoring activity from blocked domain");
|
||||
return Ok(());
|
||||
}
|
||||
let ap_id = self.id.clone();
|
||||
let actor_url = self.actor.inner().clone();
|
||||
data.object_handler
|
||||
.on_update(&ap_id, &actor_url, self.object)
|
||||
.await
|
||||
.map_err(|e| Error::from(anyhow::anyhow!(e)))?;
|
||||
tracing::info!(actor = %actor_url, "received update activity");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// --- Announce ---
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AnnounceActivity {
|
||||
pub(crate) id: Url,
|
||||
#[serde(rename = "type", default)]
|
||||
pub(crate) kind: AnnounceType,
|
||||
pub(crate) actor: ObjectId<DbActor>,
|
||||
pub(crate) object: Url,
|
||||
pub(crate) published: Option<chrono::DateTime<chrono::Utc>>,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl Activity for AnnounceActivity {
|
||||
type DataType = FederationData;
|
||||
type Error = Error;
|
||||
|
||||
fn id(&self) -> &Url {
|
||||
&self.id
|
||||
}
|
||||
|
||||
fn actor(&self) -> &Url {
|
||||
self.actor.inner()
|
||||
}
|
||||
|
||||
async fn verify(&self, _data: &Data<Self::DataType>) -> Result<(), Self::Error> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn receive(self, data: &Data<Self::DataType>) -> Result<(), Self::Error> {
|
||||
let domain = self.actor().host_str().unwrap_or("");
|
||||
if data.federation_repo.is_domain_blocked(domain).await? {
|
||||
tracing::info!(actor = %self.actor(), "ignoring activity from blocked domain");
|
||||
return Ok(());
|
||||
}
|
||||
let object_domain = self.object.host_str().unwrap_or("");
|
||||
if object_domain != data.domain {
|
||||
return Ok(());
|
||||
}
|
||||
data.federation_repo
|
||||
.add_announce(
|
||||
self.id.as_str(),
|
||||
self.object.as_str(),
|
||||
self.actor.inner().as_str(),
|
||||
self.published.unwrap_or_else(chrono::Utc::now),
|
||||
)
|
||||
.await?;
|
||||
tracing::info!(actor = %self.actor.inner(), object = %self.object, "received announce");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// --- Block ---
|
||||
|
||||
#[derive(Clone, Default, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename = "Block")]
|
||||
pub struct BlockType;
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BlockActivity {
|
||||
pub(crate) id: Url,
|
||||
#[serde(rename = "type", default)]
|
||||
pub(crate) kind: BlockType,
|
||||
pub(crate) actor: ObjectId<DbActor>,
|
||||
pub(crate) object: Url,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl Activity for BlockActivity {
|
||||
type DataType = FederationData;
|
||||
type Error = Error;
|
||||
|
||||
fn id(&self) -> &Url {
|
||||
&self.id
|
||||
}
|
||||
|
||||
fn actor(&self) -> &Url {
|
||||
self.actor.inner()
|
||||
}
|
||||
|
||||
async fn verify(&self, _data: &Data<Self::DataType>) -> Result<(), Self::Error> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn receive(self, data: &Data<Self::DataType>) -> Result<(), Self::Error> {
|
||||
let domain = self.actor().host_str().unwrap_or("");
|
||||
if data.federation_repo.is_domain_blocked(domain).await? {
|
||||
tracing::info!(actor = %self.actor(), "ignoring activity from blocked domain");
|
||||
return Ok(());
|
||||
}
|
||||
// They blocked us — remove them from our following list
|
||||
if let Some(local_user_id) = crate::urls::extract_user_id_from_url(&self.object) {
|
||||
let _ = data
|
||||
.federation_repo
|
||||
.remove_following(local_user_id, self.actor.inner().as_str())
|
||||
.await;
|
||||
}
|
||||
tracing::info!(actor = %self.actor.inner(), "received block");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// --- Inbox dispatch enum ---
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
#[serde(tag = "type")]
|
||||
#[enum_delegate::implement(Activity)]
|
||||
pub enum InboxActivities {
|
||||
#[serde(rename = "Follow")]
|
||||
Follow(FollowActivity),
|
||||
#[serde(rename = "Accept")]
|
||||
Accept(AcceptActivity),
|
||||
#[serde(rename = "Reject")]
|
||||
Reject(RejectActivity),
|
||||
#[serde(rename = "Undo")]
|
||||
Undo(UndoActivity),
|
||||
#[serde(rename = "Create")]
|
||||
Create(CreateActivity),
|
||||
#[serde(rename = "Delete")]
|
||||
Delete(DeleteActivity),
|
||||
#[serde(rename = "Update")]
|
||||
Update(UpdateActivity),
|
||||
#[serde(rename = "Announce")]
|
||||
Announce(AnnounceActivity),
|
||||
#[serde(rename = "Block")]
|
||||
Block(BlockActivity),
|
||||
}
|
||||
21
crates/adapters/activitypub-base/src/actor_handler.rs
Normal file
21
crates/adapters/activitypub-base/src/actor_handler.rs
Normal file
@@ -0,0 +1,21 @@
|
||||
use activitypub_federation::{
|
||||
axum::json::FederationJson, config::Data, protocol::context::WithContext, traits::Object,
|
||||
};
|
||||
use axum::extract::Path;
|
||||
|
||||
use crate::actors::{Person, get_local_actor};
|
||||
use crate::data::FederationData;
|
||||
use crate::error::Error;
|
||||
|
||||
pub async fn actor_handler(
|
||||
Path(user_id_str): Path<String>,
|
||||
data: Data<FederationData>,
|
||||
) -> Result<FederationJson<WithContext<Person>>, Error> {
|
||||
let uuid = uuid::Uuid::parse_str(&user_id_str)
|
||||
.map_err(|_| Error::bad_request(anyhow::anyhow!("invalid user id")))?;
|
||||
|
||||
let db_actor = get_local_actor(uuid, &data).await?;
|
||||
let person = db_actor.into_json(&data).await?;
|
||||
|
||||
Ok(FederationJson(WithContext::new_default(person)))
|
||||
}
|
||||
300
crates/adapters/activitypub-base/src/actors.rs
Normal file
300
crates/adapters/activitypub-base/src/actors.rs
Normal file
@@ -0,0 +1,300 @@
|
||||
use activitypub_federation::{
|
||||
config::Data,
|
||||
fetch::object_id::ObjectId,
|
||||
http_signatures::generate_actor_keypair,
|
||||
kinds::actor::PersonType,
|
||||
protocol::{public_key::PublicKey, verification::verify_domains_match},
|
||||
traits::{Actor, Object},
|
||||
};
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use url::Url;
|
||||
|
||||
use crate::data::FederationData;
|
||||
use crate::error::Error;
|
||||
use crate::repository::RemoteActor;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DbActor {
|
||||
pub user_id: uuid::Uuid,
|
||||
pub username: String,
|
||||
pub public_key_pem: String,
|
||||
pub private_key_pem: Option<String>,
|
||||
pub inbox_url: Url,
|
||||
pub outbox_url: Url,
|
||||
pub followers_url: Url,
|
||||
pub following_url: Url,
|
||||
pub ap_id: Url,
|
||||
pub last_refreshed_at: DateTime<Utc>,
|
||||
pub bio: Option<String>,
|
||||
pub avatar_url: Option<Url>,
|
||||
pub profile_url: Option<Url>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct ApImageObject {
|
||||
#[serde(rename = "type")]
|
||||
pub kind: String,
|
||||
pub url: Url,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Person {
|
||||
#[serde(rename = "type")]
|
||||
kind: PersonType,
|
||||
id: ObjectId<DbActor>,
|
||||
preferred_username: String,
|
||||
inbox: Url,
|
||||
outbox: Url,
|
||||
followers: Url,
|
||||
following: Url,
|
||||
public_key: PublicKey,
|
||||
name: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
summary: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
icon: Option<ApImageObject>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
url: Option<Url>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
discoverable: Option<bool>,
|
||||
manually_approves_followers: bool,
|
||||
}
|
||||
|
||||
pub async fn get_local_actor(
|
||||
user_id: uuid::Uuid,
|
||||
data: &Data<FederationData>,
|
||||
) -> Result<DbActor, Error> {
|
||||
let user = data
|
||||
.user_repo
|
||||
.find_by_id(user_id)
|
||||
.await
|
||||
.map_err(Error::from)?
|
||||
.ok_or_else(|| Error::not_found(anyhow::anyhow!("user not found: {}", user_id)))?;
|
||||
|
||||
let (public_key, private_key) = match data
|
||||
.federation_repo
|
||||
.get_local_actor_keypair(user_id)
|
||||
.await?
|
||||
{
|
||||
Some(kp) => kp,
|
||||
None => {
|
||||
let kp = generate_actor_keypair()?;
|
||||
data.federation_repo
|
||||
.save_local_actor_keypair(user_id, kp.public_key.clone(), kp.private_key.clone())
|
||||
.await?;
|
||||
(kp.public_key, kp.private_key)
|
||||
}
|
||||
};
|
||||
|
||||
let ap_id = crate::urls::actor_url(&data.base_url, user_id);
|
||||
let inbox_url = Url::parse(&format!("{}/inbox", &ap_id)).expect("valid inbox url");
|
||||
let outbox_url = Url::parse(&format!("{}/outbox", &ap_id)).expect("valid outbox url");
|
||||
let followers_url = Url::parse(&format!("{}/followers", &ap_id)).expect("valid followers url");
|
||||
let following_url = Url::parse(&format!("{}/following", &ap_id)).expect("valid following url");
|
||||
|
||||
Ok(DbActor {
|
||||
user_id,
|
||||
username: user.username,
|
||||
public_key_pem: public_key,
|
||||
private_key_pem: Some(private_key),
|
||||
inbox_url,
|
||||
outbox_url,
|
||||
followers_url,
|
||||
following_url,
|
||||
ap_id,
|
||||
last_refreshed_at: Utc::now(),
|
||||
bio: user.bio,
|
||||
avatar_url: user.avatar_url,
|
||||
profile_url: user.profile_url,
|
||||
})
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl Object for DbActor {
|
||||
type DataType = FederationData;
|
||||
type Kind = Person;
|
||||
type Error = Error;
|
||||
|
||||
fn id(&self) -> &Url {
|
||||
&self.ap_id
|
||||
}
|
||||
|
||||
fn last_refreshed_at(&self) -> Option<DateTime<Utc>> {
|
||||
Some(self.last_refreshed_at)
|
||||
}
|
||||
|
||||
async fn read_from_id(
|
||||
object_id: Url,
|
||||
data: &Data<Self::DataType>,
|
||||
) -> Result<Option<Self>, Self::Error> {
|
||||
let user_id = match crate::urls::extract_user_id_from_url(&object_id) {
|
||||
Some(id) => id,
|
||||
None => return Ok(None),
|
||||
};
|
||||
let user = match data.user_repo.find_by_id(user_id).await {
|
||||
Ok(Some(u)) => u,
|
||||
_ => return Ok(None),
|
||||
};
|
||||
|
||||
let keypair = data
|
||||
.federation_repo
|
||||
.get_local_actor_keypair(user_id)
|
||||
.await?;
|
||||
|
||||
let (public_key, private_key) = match keypair {
|
||||
Some(kp) => (kp.0, Some(kp.1)),
|
||||
None => return Ok(None),
|
||||
};
|
||||
|
||||
let ap_id = crate::urls::actor_url(&data.base_url, user_id);
|
||||
let inbox_url = Url::parse(&format!("{}/inbox", &ap_id)).expect("valid url");
|
||||
let outbox_url = Url::parse(&format!("{}/outbox", &ap_id)).expect("valid url");
|
||||
let followers_url = Url::parse(&format!("{}/followers", &ap_id)).expect("valid url");
|
||||
let following_url = Url::parse(&format!("{}/following", &ap_id)).expect("valid url");
|
||||
|
||||
Ok(Some(DbActor {
|
||||
user_id,
|
||||
username: user.username,
|
||||
public_key_pem: public_key,
|
||||
private_key_pem: private_key,
|
||||
inbox_url,
|
||||
outbox_url,
|
||||
followers_url,
|
||||
following_url,
|
||||
ap_id,
|
||||
last_refreshed_at: Utc::now(),
|
||||
bio: None,
|
||||
avatar_url: None,
|
||||
profile_url: None,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn into_json(self, _data: &Data<Self::DataType>) -> Result<Self::Kind, Self::Error> {
|
||||
let public_key = PublicKey {
|
||||
id: format!("{}#main-key", &self.ap_id),
|
||||
owner: self.ap_id.clone(),
|
||||
public_key_pem: self.public_key_pem.clone(),
|
||||
};
|
||||
|
||||
let icon = self.avatar_url.map(|url| ApImageObject {
|
||||
kind: "Image".to_string(),
|
||||
url,
|
||||
});
|
||||
let profile_url = self.profile_url;
|
||||
|
||||
Ok(Person {
|
||||
kind: Default::default(),
|
||||
id: self.ap_id.clone().into(),
|
||||
preferred_username: self.username.clone(),
|
||||
inbox: self.inbox_url.clone(),
|
||||
outbox: self.outbox_url.clone(),
|
||||
followers: self.followers_url.clone(),
|
||||
following: self.following_url.clone(),
|
||||
public_key,
|
||||
name: Some(self.username.clone()),
|
||||
summary: self.bio.clone(),
|
||||
icon,
|
||||
url: profile_url,
|
||||
discoverable: Some(true),
|
||||
manually_approves_followers: false,
|
||||
})
|
||||
}
|
||||
|
||||
async fn verify(
|
||||
json: &Self::Kind,
|
||||
expected_domain: &Url,
|
||||
_data: &Data<Self::DataType>,
|
||||
) -> Result<(), Self::Error> {
|
||||
verify_domains_match(json.id.inner(), expected_domain)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn from_json(json: Self::Kind, data: &Data<Self::DataType>) -> Result<Self, Self::Error> {
|
||||
let actor = RemoteActor {
|
||||
url: json.id.inner().to_string(),
|
||||
handle: json.preferred_username.clone(),
|
||||
inbox_url: json.inbox.to_string(),
|
||||
shared_inbox_url: None,
|
||||
display_name: json.name.clone(),
|
||||
avatar_url: json.icon.as_ref().map(|i| i.url.to_string()),
|
||||
};
|
||||
data.federation_repo.upsert_remote_actor(actor).await?;
|
||||
|
||||
let url_str = json.id.inner().to_string();
|
||||
let user_id = uuid::Uuid::new_v5(&uuid::Uuid::NAMESPACE_URL, url_str.as_bytes());
|
||||
let ap_id = json.id.inner().clone();
|
||||
let inbox_url = json.inbox.clone();
|
||||
let outbox_url = json.outbox.clone();
|
||||
let followers_url = json.followers.clone();
|
||||
let following_url = json.following.clone();
|
||||
|
||||
Ok(DbActor {
|
||||
user_id,
|
||||
username: json.preferred_username.clone(),
|
||||
public_key_pem: json.public_key.public_key_pem,
|
||||
private_key_pem: None,
|
||||
inbox_url,
|
||||
outbox_url,
|
||||
followers_url,
|
||||
following_url,
|
||||
ap_id,
|
||||
last_refreshed_at: Utc::now(),
|
||||
bio: None,
|
||||
avatar_url: None,
|
||||
profile_url: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Actor for DbActor {
|
||||
fn public_key_pem(&self) -> &str {
|
||||
&self.public_key_pem
|
||||
}
|
||||
|
||||
fn private_key_pem(&self) -> Option<String> {
|
||||
self.private_key_pem.clone()
|
||||
}
|
||||
|
||||
fn inbox(&self) -> Url {
|
||||
self.inbox_url.clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn person_serializes_with_enriched_fields() {
|
||||
let person = Person {
|
||||
kind: Default::default(),
|
||||
id: "https://example.com/users/1".parse::<url::Url>().unwrap().into(),
|
||||
preferred_username: "alice".to_string(),
|
||||
inbox: "https://example.com/users/1/inbox".parse().unwrap(),
|
||||
outbox: "https://example.com/users/1/outbox".parse().unwrap(),
|
||||
followers: "https://example.com/users/1/followers".parse().unwrap(),
|
||||
following: "https://example.com/users/1/following".parse().unwrap(),
|
||||
public_key: PublicKey {
|
||||
id: "https://example.com/users/1#main-key".to_string(),
|
||||
owner: "https://example.com/users/1".parse().unwrap(),
|
||||
public_key_pem: "pem".to_string(),
|
||||
},
|
||||
name: Some("Alice".to_string()),
|
||||
summary: Some("Bio text".to_string()),
|
||||
icon: Some(ApImageObject {
|
||||
kind: "Image".to_string(),
|
||||
url: "https://example.com/images/avatars/1".parse().unwrap(),
|
||||
}),
|
||||
url: Some("https://example.com/u/alice".parse().unwrap()),
|
||||
discoverable: Some(true),
|
||||
manually_approves_followers: false,
|
||||
};
|
||||
let json = serde_json::to_value(&person).unwrap();
|
||||
assert_eq!(json["discoverable"], true);
|
||||
assert_eq!(json["summary"], "Bio text");
|
||||
assert_eq!(json["icon"]["type"], "Image");
|
||||
assert!(json.get("manuallyApprovesFollowers").is_some());
|
||||
}
|
||||
}
|
||||
47
crates/adapters/activitypub-base/src/content.rs
Normal file
47
crates/adapters/activitypub-base/src/content.rs
Normal file
@@ -0,0 +1,47 @@
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use url::Url;
|
||||
|
||||
#[async_trait]
|
||||
pub trait ApObjectHandler: Send + Sync {
|
||||
/// Returns (ap_id, serialized object) for all local content owned by this user.
|
||||
/// Used by outbox (count) and backfill (delivery). Must only return locally-authored content.
|
||||
async fn get_local_objects_for_user(
|
||||
&self,
|
||||
user_id: uuid::Uuid,
|
||||
) -> anyhow::Result<Vec<(Url, serde_json::Value)>>;
|
||||
|
||||
/// Returns up to `limit` objects ordered newest-first, published before `before`.
|
||||
/// Returns (ap_id, object_json, published_at).
|
||||
async fn get_local_objects_page(
|
||||
&self,
|
||||
user_id: uuid::Uuid,
|
||||
before: Option<DateTime<Utc>>,
|
||||
limit: usize,
|
||||
) -> anyhow::Result<Vec<(Url, serde_json::Value, DateTime<Utc>)>>;
|
||||
|
||||
/// Incoming Create activity — persist remote content.
|
||||
async fn on_create(
|
||||
&self,
|
||||
ap_id: &Url,
|
||||
actor_url: &Url,
|
||||
object: serde_json::Value,
|
||||
) -> anyhow::Result<()>;
|
||||
|
||||
/// Incoming Update activity — update existing remote content.
|
||||
async fn on_update(
|
||||
&self,
|
||||
ap_id: &Url,
|
||||
actor_url: &Url,
|
||||
object: serde_json::Value,
|
||||
) -> anyhow::Result<()>;
|
||||
|
||||
/// Incoming Delete activity — remove specific remote content.
|
||||
async fn on_delete(&self, ap_id: &Url, actor_url: &Url) -> anyhow::Result<()>;
|
||||
|
||||
/// Actor unfollowed/was removed — clean up all their remote content.
|
||||
async fn on_actor_removed(&self, actor_url: &Url) -> anyhow::Result<()>;
|
||||
|
||||
/// Total number of locally-authored posts across all users.
|
||||
async fn count_local_posts(&self) -> anyhow::Result<u64>;
|
||||
}
|
||||
44
crates/adapters/activitypub-base/src/data.rs
Normal file
44
crates/adapters/activitypub-base/src/data.rs
Normal file
@@ -0,0 +1,44 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::content::ApObjectHandler;
|
||||
use crate::repository::FederationRepository;
|
||||
use crate::user::ApUserRepository;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct FederationData {
|
||||
pub(crate) federation_repo: Arc<dyn FederationRepository>,
|
||||
pub(crate) user_repo: Arc<dyn ApUserRepository>,
|
||||
pub(crate) object_handler: Arc<dyn ApObjectHandler>,
|
||||
pub(crate) base_url: String,
|
||||
pub(crate) domain: String,
|
||||
pub(crate) allow_registration: bool,
|
||||
pub(crate) software_name: String,
|
||||
}
|
||||
|
||||
impl FederationData {
|
||||
pub fn new(
|
||||
federation_repo: Arc<dyn FederationRepository>,
|
||||
user_repo: Arc<dyn ApUserRepository>,
|
||||
object_handler: Arc<dyn ApObjectHandler>,
|
||||
base_url: String,
|
||||
allow_registration: bool,
|
||||
software_name: String,
|
||||
) -> Self {
|
||||
let domain = base_url
|
||||
.trim_start_matches("https://")
|
||||
.trim_start_matches("http://")
|
||||
.split('/')
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
Self {
|
||||
federation_repo,
|
||||
user_repo,
|
||||
object_handler,
|
||||
base_url,
|
||||
domain,
|
||||
allow_registration,
|
||||
software_name,
|
||||
}
|
||||
}
|
||||
}
|
||||
48
crates/adapters/activitypub-base/src/error.rs
Normal file
48
crates/adapters/activitypub-base/src/error.rs
Normal file
@@ -0,0 +1,48 @@
|
||||
use std::fmt::{Display, Formatter};
|
||||
|
||||
use axum::http::StatusCode;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Error(pub(crate) anyhow::Error, pub(crate) StatusCode);
|
||||
|
||||
impl Error {
|
||||
pub fn not_found(e: impl Into<anyhow::Error>) -> Self {
|
||||
Self(e.into(), StatusCode::NOT_FOUND)
|
||||
}
|
||||
|
||||
pub fn bad_request(e: impl Into<anyhow::Error>) -> Self {
|
||||
Self(e.into(), StatusCode::BAD_REQUEST)
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for Error {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
std::fmt::Display::fmt(&self.0, f)
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> From<T> for Error
|
||||
where
|
||||
T: Into<anyhow::Error>,
|
||||
{
|
||||
fn from(t: T) -> Self {
|
||||
Error(t.into(), StatusCode::INTERNAL_SERVER_ERROR)
|
||||
}
|
||||
}
|
||||
|
||||
impl axum::response::IntoResponse for Error {
|
||||
fn into_response(self) -> axum::response::Response {
|
||||
let status = self.1;
|
||||
if status.is_server_error() {
|
||||
tracing::error!(error = %self.0, status = status.as_u16(), "federation error");
|
||||
} else {
|
||||
tracing::debug!(error = %self.0, status = status.as_u16(), "federation response");
|
||||
}
|
||||
let body = if status.is_server_error() {
|
||||
"internal server error".to_string()
|
||||
} else {
|
||||
self.0.to_string()
|
||||
};
|
||||
(status, body).into_response()
|
||||
}
|
||||
}
|
||||
50
crates/adapters/activitypub-base/src/federation.rs
Normal file
50
crates/adapters/activitypub-base/src/federation.rs
Normal file
@@ -0,0 +1,50 @@
|
||||
use activitypub_federation::config::{Data, FederationConfig, FederationMiddleware, UrlVerifier};
|
||||
use activitypub_federation::error::Error as FedError;
|
||||
use url::Url;
|
||||
|
||||
use crate::data::FederationData;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct PermissiveVerifier;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl UrlVerifier for PermissiveVerifier {
|
||||
async fn verify(&self, _url: &Url) -> Result<(), FedError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ApFederationConfig(pub FederationConfig<FederationData>);
|
||||
|
||||
impl ApFederationConfig {
|
||||
pub async fn new(data: FederationData, debug: bool) -> anyhow::Result<Self> {
|
||||
let config = if debug {
|
||||
FederationConfig::builder()
|
||||
.domain(&data.domain)
|
||||
.app_data(data)
|
||||
.debug(true)
|
||||
.http_signature_compat(true)
|
||||
.url_verifier(Box::new(PermissiveVerifier))
|
||||
.build()
|
||||
.await?
|
||||
} else {
|
||||
FederationConfig::builder()
|
||||
.domain(&data.domain)
|
||||
.app_data(data)
|
||||
.debug(false)
|
||||
.http_signature_compat(true)
|
||||
.build()
|
||||
.await?
|
||||
};
|
||||
Ok(Self(config))
|
||||
}
|
||||
|
||||
pub fn to_request_data(&self) -> Data<FederationData> {
|
||||
self.0.to_request_data()
|
||||
}
|
||||
|
||||
pub fn middleware(&self) -> FederationMiddleware<FederationData> {
|
||||
FederationMiddleware::new(self.0.clone())
|
||||
}
|
||||
}
|
||||
71
crates/adapters/activitypub-base/src/followers_handler.rs
Normal file
71
crates/adapters/activitypub-base/src/followers_handler.rs
Normal file
@@ -0,0 +1,71 @@
|
||||
use activitypub_federation::{axum::json::FederationJson, config::Data};
|
||||
use axum::extract::Path;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::data::FederationData;
|
||||
use crate::error::Error;
|
||||
use crate::repository::FollowerStatus;
|
||||
|
||||
fn ordered_collection(id: String, total: usize, items: Vec<String>) -> serde_json::Value {
|
||||
json!({
|
||||
"@context": "https://www.w3.org/ns/activitystreams",
|
||||
"type": "OrderedCollection",
|
||||
"id": id,
|
||||
"totalItems": total,
|
||||
"orderedItems": items,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn followers_handler(
|
||||
Path(user_id_str): Path<String>,
|
||||
data: Data<FederationData>,
|
||||
) -> Result<FederationJson<serde_json::Value>, Error> {
|
||||
let user_id = uuid::Uuid::parse_str(&user_id_str)
|
||||
.map_err(|_| Error::bad_request(anyhow::anyhow!("invalid user id")))?;
|
||||
|
||||
data.user_repo
|
||||
.find_by_id(user_id)
|
||||
.await
|
||||
.map_err(Error::from)?
|
||||
.ok_or_else(|| Error::not_found(anyhow::anyhow!("user not found")))?;
|
||||
|
||||
let followers = data
|
||||
.federation_repo
|
||||
.get_followers(user_id)
|
||||
.await
|
||||
.map_err(Error::from)?;
|
||||
|
||||
let items: Vec<String> = followers
|
||||
.into_iter()
|
||||
.filter(|f| f.status == FollowerStatus::Accepted)
|
||||
.map(|f| f.actor.url)
|
||||
.collect();
|
||||
|
||||
let id = format!("{}/users/{}/followers", data.base_url, user_id_str);
|
||||
Ok(FederationJson(ordered_collection(id, items.len(), items)))
|
||||
}
|
||||
|
||||
pub async fn following_handler(
|
||||
Path(user_id_str): Path<String>,
|
||||
data: Data<FederationData>,
|
||||
) -> Result<FederationJson<serde_json::Value>, Error> {
|
||||
let user_id = uuid::Uuid::parse_str(&user_id_str)
|
||||
.map_err(|_| Error::bad_request(anyhow::anyhow!("invalid user id")))?;
|
||||
|
||||
data.user_repo
|
||||
.find_by_id(user_id)
|
||||
.await
|
||||
.map_err(Error::from)?
|
||||
.ok_or_else(|| Error::not_found(anyhow::anyhow!("user not found")))?;
|
||||
|
||||
let following = data
|
||||
.federation_repo
|
||||
.get_following(user_id)
|
||||
.await
|
||||
.map_err(Error::from)?;
|
||||
|
||||
let items: Vec<String> = following.into_iter().map(|a| a.url).collect();
|
||||
|
||||
let id = format!("{}/users/{}/following", data.base_url, user_id_str);
|
||||
Ok(FederationJson(ordered_collection(id, items.len(), items)))
|
||||
}
|
||||
18
crates/adapters/activitypub-base/src/inbox.rs
Normal file
18
crates/adapters/activitypub-base/src/inbox.rs
Normal file
@@ -0,0 +1,18 @@
|
||||
use activitypub_federation::{
|
||||
axum::inbox::{ActivityData, receive_activity},
|
||||
config::Data,
|
||||
protocol::context::WithContext,
|
||||
};
|
||||
|
||||
use crate::activities::InboxActivities;
|
||||
use crate::actors::DbActor;
|
||||
use crate::data::FederationData;
|
||||
use crate::error::Error;
|
||||
|
||||
pub async fn inbox_handler(
|
||||
data: Data<FederationData>,
|
||||
activity_data: ActivityData,
|
||||
) -> Result<(), Error> {
|
||||
receive_activity::<WithContext<InboxActivities>, DbActor, FederationData>(activity_data, &data)
|
||||
.await
|
||||
}
|
||||
26
crates/adapters/activitypub-base/src/lib.rs
Normal file
26
crates/adapters/activitypub-base/src/lib.rs
Normal file
@@ -0,0 +1,26 @@
|
||||
pub mod activities;
|
||||
pub mod actor_handler;
|
||||
pub mod actors;
|
||||
pub mod content;
|
||||
pub mod data;
|
||||
pub mod error;
|
||||
pub mod federation;
|
||||
pub mod followers_handler;
|
||||
pub mod inbox;
|
||||
pub mod nodeinfo;
|
||||
pub mod outbox;
|
||||
pub mod repository;
|
||||
pub mod service;
|
||||
pub(crate) mod urls;
|
||||
pub mod user;
|
||||
pub mod webfinger;
|
||||
|
||||
pub use content::ApObjectHandler;
|
||||
pub use data::FederationData;
|
||||
pub use error::Error;
|
||||
pub use federation::ApFederationConfig;
|
||||
pub use repository::{
|
||||
BlockedDomain, FederationRepository, Follower, FollowerStatus, FollowingStatus, RemoteActor,
|
||||
};
|
||||
pub use service::ActivityPubService;
|
||||
pub use user::{ApUser, ApUserRepository};
|
||||
119
crates/adapters/activitypub-base/src/nodeinfo.rs
Normal file
119
crates/adapters/activitypub-base/src/nodeinfo.rs
Normal file
@@ -0,0 +1,119 @@
|
||||
use activitypub_federation::config::Data;
|
||||
use axum::Json;
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::data::FederationData;
|
||||
use crate::error::Error;
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct NodeInfoWellKnown {
|
||||
pub links: Vec<NodeInfoLink>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct NodeInfoLink {
|
||||
pub rel: String,
|
||||
pub href: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct NodeInfoSoftware {
|
||||
pub name: String,
|
||||
pub version: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct NodeInfoUsage {
|
||||
pub users: NodeInfoUsers,
|
||||
pub local_posts: u64,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct NodeInfoUsers {
|
||||
pub total: usize,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct NodeInfo {
|
||||
pub version: String,
|
||||
pub software: NodeInfoSoftware,
|
||||
pub protocols: Vec<String>,
|
||||
pub usage: NodeInfoUsage,
|
||||
pub open_registrations: bool,
|
||||
}
|
||||
|
||||
pub async fn nodeinfo_well_known_handler(
|
||||
data: Data<FederationData>,
|
||||
) -> Result<Json<NodeInfoWellKnown>, Error> {
|
||||
let href = format!("{}/nodeinfo/2.0", data.base_url);
|
||||
Ok(Json(NodeInfoWellKnown {
|
||||
links: vec![NodeInfoLink {
|
||||
rel: "http://nodeinfo.diaspora.software/ns/schema/2.0".to_string(),
|
||||
href,
|
||||
}],
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn nodeinfo_handler(
|
||||
data: Data<FederationData>,
|
||||
) -> Result<Json<NodeInfo>, Error> {
|
||||
let user_count = data.user_repo.count_users().await.unwrap_or(0);
|
||||
let local_posts = data.object_handler.count_local_posts().await.unwrap_or(0);
|
||||
|
||||
Ok(Json(NodeInfo {
|
||||
version: "2.0".to_string(),
|
||||
software: NodeInfoSoftware {
|
||||
name: data.software_name.clone(),
|
||||
version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
},
|
||||
protocols: vec!["activitypub".to_string()],
|
||||
usage: NodeInfoUsage {
|
||||
users: NodeInfoUsers { total: user_count },
|
||||
local_posts,
|
||||
},
|
||||
open_registrations: data.allow_registration,
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn nodeinfo_well_known_serializes_correctly() {
|
||||
let doc = NodeInfoWellKnown {
|
||||
links: vec![NodeInfoLink {
|
||||
rel: "http://nodeinfo.diaspora.software/ns/schema/2.0".to_string(),
|
||||
href: "https://example.com/nodeinfo/2.0".to_string(),
|
||||
}],
|
||||
};
|
||||
let json = serde_json::to_value(&doc).unwrap();
|
||||
assert_eq!(json["links"][0]["rel"], "http://nodeinfo.diaspora.software/ns/schema/2.0");
|
||||
assert_eq!(json["links"][0]["href"], "https://example.com/nodeinfo/2.0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nodeinfo_serializes_camel_case() {
|
||||
let doc = NodeInfo {
|
||||
version: "2.0".to_string(),
|
||||
software: NodeInfoSoftware {
|
||||
name: "my-app".to_string(),
|
||||
version: "0.1.0".to_string(),
|
||||
},
|
||||
protocols: vec!["activitypub".to_string()],
|
||||
usage: NodeInfoUsage {
|
||||
users: NodeInfoUsers { total: 3 },
|
||||
local_posts: 42,
|
||||
},
|
||||
open_registrations: false,
|
||||
};
|
||||
let json = serde_json::to_value(&doc).unwrap();
|
||||
assert_eq!(json["version"], "2.0");
|
||||
assert_eq!(json["software"]["name"], "my-app");
|
||||
assert_eq!(json["usage"]["users"]["total"], 3);
|
||||
assert_eq!(json["usage"]["localPosts"], 42);
|
||||
assert_eq!(json["openRegistrations"], false);
|
||||
}
|
||||
}
|
||||
135
crates/adapters/activitypub-base/src/outbox.rs
Normal file
135
crates/adapters/activitypub-base/src/outbox.rs
Normal file
@@ -0,0 +1,135 @@
|
||||
use axum::extract::{Path, Query};
|
||||
use axum::response::IntoResponse;
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use url::Url;
|
||||
|
||||
use activitypub_federation::{config::Data, fetch::object_id::ObjectId, kinds::activity::CreateType};
|
||||
|
||||
use crate::{activities::CreateActivity, data::FederationData, error::Error};
|
||||
|
||||
const PAGE_SIZE: usize = 20;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct OutboxQuery {
|
||||
page: Option<bool>,
|
||||
before: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct OrderedCollection {
|
||||
#[serde(rename = "@context")]
|
||||
context: String,
|
||||
#[serde(rename = "type")]
|
||||
kind: String,
|
||||
id: String,
|
||||
total_items: u64,
|
||||
first: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct OrderedCollectionPage {
|
||||
#[serde(rename = "@context")]
|
||||
context: String,
|
||||
#[serde(rename = "type")]
|
||||
kind: String,
|
||||
id: String,
|
||||
part_of: String,
|
||||
ordered_items: Vec<serde_json::Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
next: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn outbox_handler(
|
||||
Path(user_id_str): Path<String>,
|
||||
Query(query): Query<OutboxQuery>,
|
||||
data: Data<FederationData>,
|
||||
) -> Result<axum::response::Response, Error> {
|
||||
let uuid = uuid::Uuid::parse_str(&user_id_str)
|
||||
.map_err(|_| Error::bad_request(anyhow::anyhow!("invalid user id")))?;
|
||||
|
||||
data.user_repo
|
||||
.find_by_id(uuid)
|
||||
.await
|
||||
.map_err(Error::from)?
|
||||
.ok_or_else(|| Error::not_found(anyhow::anyhow!("user not found")))?;
|
||||
|
||||
let outbox_url = format!("{}/users/{}/outbox", data.base_url, user_id_str);
|
||||
|
||||
if query.page.unwrap_or(false) {
|
||||
let before: Option<DateTime<Utc>> = query.before.as_deref().and_then(|s| s.parse().ok());
|
||||
|
||||
let items = data
|
||||
.object_handler
|
||||
.get_local_objects_page(uuid, before, PAGE_SIZE)
|
||||
.await
|
||||
.map_err(|e| Error::from(anyhow::anyhow!("{}", e)))?;
|
||||
|
||||
let actor_url: Url = format!("{}/users/{}", data.base_url, user_id_str)
|
||||
.parse()
|
||||
.expect("valid url");
|
||||
|
||||
let has_more = items.len() == PAGE_SIZE;
|
||||
let oldest_ts = items.last().map(|(_, _, ts)| *ts);
|
||||
|
||||
let ordered_items: Vec<serde_json::Value> = items
|
||||
.into_iter()
|
||||
.map(|(ap_id, object, _)| {
|
||||
let create_id =
|
||||
Url::parse(&format!("{}/activity", ap_id)).expect("valid url");
|
||||
serde_json::to_value(CreateActivity {
|
||||
id: create_id,
|
||||
kind: CreateType::default(),
|
||||
actor: ObjectId::from(actor_url.clone()),
|
||||
object,
|
||||
})
|
||||
.expect("serializable")
|
||||
})
|
||||
.collect();
|
||||
|
||||
let page_id = match &query.before {
|
||||
Some(b) => format!("{}?page=true&before={}", outbox_url, b),
|
||||
None => format!("{}?page=true", outbox_url),
|
||||
};
|
||||
|
||||
let next = if has_more {
|
||||
oldest_ts.map(|ts| {
|
||||
// Use RFC 3339 with Z suffix (no + sign) to avoid percent-encoding
|
||||
let ts_str = ts
|
||||
.format("%Y-%m-%dT%H:%M:%S%.3fZ")
|
||||
.to_string();
|
||||
format!("{}?page=true&before={}", outbox_url, ts_str)
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Ok(axum::Json(OrderedCollectionPage {
|
||||
context: "https://www.w3.org/ns/activitystreams".to_string(),
|
||||
kind: "OrderedCollectionPage".to_string(),
|
||||
id: page_id,
|
||||
part_of: outbox_url,
|
||||
ordered_items,
|
||||
next,
|
||||
})
|
||||
.into_response())
|
||||
} else {
|
||||
let total = data
|
||||
.object_handler
|
||||
.get_local_objects_for_user(uuid)
|
||||
.await
|
||||
.map_err(|e| Error::from(anyhow::anyhow!("{}", e)))?
|
||||
.len() as u64;
|
||||
|
||||
Ok(axum::Json(OrderedCollection {
|
||||
context: "https://www.w3.org/ns/activitystreams".to_string(),
|
||||
kind: "OrderedCollection".to_string(),
|
||||
id: outbox_url.clone(),
|
||||
total_items: total,
|
||||
first: format!("{}?page=true", outbox_url),
|
||||
})
|
||||
.into_response())
|
||||
}
|
||||
}
|
||||
115
crates/adapters/activitypub-base/src/repository.rs
Normal file
115
crates/adapters/activitypub-base/src/repository.rs
Normal file
@@ -0,0 +1,115 @@
|
||||
use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum FollowerStatus {
|
||||
Pending,
|
||||
Accepted,
|
||||
Rejected,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum FollowingStatus {
|
||||
Pending,
|
||||
Accepted,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RemoteActor {
|
||||
pub url: String,
|
||||
pub handle: String,
|
||||
pub inbox_url: String,
|
||||
pub shared_inbox_url: Option<String>,
|
||||
pub display_name: Option<String>,
|
||||
pub avatar_url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Follower {
|
||||
pub actor: RemoteActor,
|
||||
pub status: FollowerStatus,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BlockedDomain {
|
||||
pub domain: String,
|
||||
pub reason: Option<String>,
|
||||
pub blocked_at: String,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait FederationRepository: Send + Sync {
|
||||
async fn add_follower(
|
||||
&self,
|
||||
local_user_id: uuid::Uuid,
|
||||
remote_actor_url: &str,
|
||||
status: FollowerStatus,
|
||||
follow_activity_id: &str,
|
||||
) -> Result<()>;
|
||||
async fn get_follower_follow_activity_id(
|
||||
&self,
|
||||
local_user_id: uuid::Uuid,
|
||||
remote_actor_url: &str,
|
||||
) -> Result<Option<String>>;
|
||||
async fn remove_follower(
|
||||
&self,
|
||||
local_user_id: uuid::Uuid,
|
||||
remote_actor_url: &str,
|
||||
) -> Result<()>;
|
||||
async fn get_followers(&self, local_user_id: uuid::Uuid) -> Result<Vec<Follower>>;
|
||||
async fn update_follower_status(
|
||||
&self,
|
||||
local_user_id: uuid::Uuid,
|
||||
remote_actor_url: &str,
|
||||
status: FollowerStatus,
|
||||
) -> Result<()>;
|
||||
async fn add_following(
|
||||
&self,
|
||||
local_user_id: uuid::Uuid,
|
||||
actor: RemoteActor,
|
||||
follow_activity_id: &str,
|
||||
) -> Result<()>;
|
||||
async fn get_follow_activity_id(
|
||||
&self,
|
||||
local_user_id: uuid::Uuid,
|
||||
remote_actor_url: &str,
|
||||
) -> Result<Option<String>>;
|
||||
async fn remove_following(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<()>;
|
||||
async fn get_following(&self, local_user_id: uuid::Uuid) -> Result<Vec<RemoteActor>>;
|
||||
async fn count_following(&self, local_user_id: uuid::Uuid) -> Result<usize>;
|
||||
async fn upsert_remote_actor(&self, actor: RemoteActor) -> Result<()>;
|
||||
async fn get_remote_actor(&self, actor_url: &str) -> Result<Option<RemoteActor>>;
|
||||
async fn get_local_actor_keypair(
|
||||
&self,
|
||||
user_id: uuid::Uuid,
|
||||
) -> Result<Option<(String, String)>>;
|
||||
async fn save_local_actor_keypair(
|
||||
&self,
|
||||
user_id: uuid::Uuid,
|
||||
public_key: String,
|
||||
private_key: String,
|
||||
) -> Result<()>;
|
||||
async fn get_pending_followers(&self, local_user_id: uuid::Uuid) -> Result<Vec<RemoteActor>>;
|
||||
async fn update_following_status(
|
||||
&self,
|
||||
local_user_id: uuid::Uuid,
|
||||
remote_actor_url: &str,
|
||||
status: FollowingStatus,
|
||||
) -> Result<()>;
|
||||
async fn add_announce(
|
||||
&self,
|
||||
activity_id: &str,
|
||||
object_url: &str,
|
||||
actor_url: &str,
|
||||
announced_at: chrono::DateTime<chrono::Utc>,
|
||||
) -> Result<()>;
|
||||
async fn count_announces(&self, object_url: &str) -> Result<usize>;
|
||||
async fn add_blocked_domain(&self, domain: &str, reason: Option<&str>) -> Result<()>;
|
||||
async fn remove_blocked_domain(&self, domain: &str) -> Result<()>;
|
||||
async fn get_blocked_domains(&self) -> Result<Vec<BlockedDomain>>;
|
||||
async fn is_domain_blocked(&self, domain: &str) -> Result<bool>;
|
||||
async fn add_blocked_actor(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<()>;
|
||||
async fn remove_blocked_actor(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<()>;
|
||||
async fn get_blocked_actors(&self, local_user_id: uuid::Uuid) -> Result<Vec<String>>;
|
||||
async fn is_actor_blocked(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<bool>;
|
||||
}
|
||||
975
crates/adapters/activitypub-base/src/service.rs
Normal file
975
crates/adapters/activitypub-base/src/service.rs
Normal file
@@ -0,0 +1,975 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use activitypub_federation::{
|
||||
activity_sending::SendActivityTask,
|
||||
fetch::{object_id::ObjectId, webfinger::webfinger_resolve_actor},
|
||||
protocol::context::WithContext,
|
||||
traits::Actor,
|
||||
};
|
||||
use axum::{Router, routing::get, routing::post};
|
||||
use url::Url;
|
||||
|
||||
use crate::{
|
||||
activities::{AcceptActivity, CreateActivity, FollowActivity, RejectActivity, UndoActivity, UpdateActivity},
|
||||
actors::{DbActor, get_local_actor},
|
||||
content::ApObjectHandler,
|
||||
data::FederationData,
|
||||
federation::ApFederationConfig,
|
||||
followers_handler::{followers_handler, following_handler},
|
||||
inbox::inbox_handler,
|
||||
outbox::outbox_handler,
|
||||
repository::{BlockedDomain, FederationRepository, FollowerStatus, FollowingStatus, RemoteActor},
|
||||
urls::activity_url,
|
||||
user::ApUserRepository,
|
||||
nodeinfo::{nodeinfo_handler, nodeinfo_well_known_handler},
|
||||
webfinger::webfinger_handler,
|
||||
};
|
||||
|
||||
fn collect_inboxes(followers: &[crate::repository::Follower]) -> Vec<Url> {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut inboxes = Vec::new();
|
||||
for f in followers {
|
||||
let inbox_str = f
|
||||
.actor
|
||||
.shared_inbox_url
|
||||
.as_deref()
|
||||
.unwrap_or(&f.actor.inbox_url);
|
||||
if seen.insert(inbox_str.to_string()) {
|
||||
if let Ok(url) = Url::parse(inbox_str) {
|
||||
inboxes.push(url);
|
||||
}
|
||||
}
|
||||
}
|
||||
inboxes
|
||||
}
|
||||
|
||||
pub(crate) async fn send_with_retry(
|
||||
sends: Vec<SendActivityTask>,
|
||||
data: &activitypub_federation::config::Data<FederationData>,
|
||||
) -> Vec<anyhow::Error> {
|
||||
let mut failures = vec![];
|
||||
for send in sends {
|
||||
let mut delay = std::time::Duration::from_secs(1);
|
||||
for attempt in 1..=3u32 {
|
||||
match send.clone().sign_and_send(data).await {
|
||||
Ok(()) => break,
|
||||
Err(e) if attempt < 3 => {
|
||||
tracing::warn!(attempt, error = %e, "delivery failed, retrying");
|
||||
tokio::time::sleep(delay).await;
|
||||
delay *= 2;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(attempt, error = %e, "delivery failed permanently");
|
||||
failures.push(anyhow::anyhow!(e));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
failures
|
||||
}
|
||||
|
||||
pub struct ActivityPubService {
|
||||
federation_config: ApFederationConfig,
|
||||
base_url: String,
|
||||
}
|
||||
|
||||
impl ActivityPubService {
|
||||
pub async fn new(
|
||||
repo: Arc<dyn FederationRepository>,
|
||||
user_repo: Arc<dyn ApUserRepository>,
|
||||
object_handler: Arc<dyn ApObjectHandler>,
|
||||
base_url: String,
|
||||
allow_registration: bool,
|
||||
software_name: String,
|
||||
debug: bool,
|
||||
) -> anyhow::Result<Self> {
|
||||
let data = FederationData::new(repo, user_repo, object_handler, base_url.clone(), allow_registration, software_name);
|
||||
let federation_config = ApFederationConfig::new(data, debug).await?;
|
||||
Ok(Self {
|
||||
federation_config,
|
||||
base_url,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn federation_config(&self) -> &ApFederationConfig {
|
||||
&self.federation_config
|
||||
}
|
||||
|
||||
pub fn request_data(&self) -> activitypub_federation::config::Data<FederationData> {
|
||||
self.federation_config.to_request_data()
|
||||
}
|
||||
|
||||
pub async fn actor_json(&self, user_id_str: &str) -> anyhow::Result<String> {
|
||||
use activitypub_federation::traits::Object;
|
||||
let uuid = uuid::Uuid::parse_str(user_id_str)?;
|
||||
let data = self.federation_config.to_request_data();
|
||||
let actor = get_local_actor(uuid, &data)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
let person = actor
|
||||
.into_json(&data)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
Ok(serde_json::to_string(&WithContext::new_default(person))?)
|
||||
}
|
||||
|
||||
pub fn router(&self) -> Router {
|
||||
Router::new()
|
||||
.route("/.well-known/nodeinfo", get(nodeinfo_well_known_handler))
|
||||
.route("/nodeinfo/2.0", get(nodeinfo_handler))
|
||||
.route("/.well-known/webfinger", get(webfinger_handler))
|
||||
.route("/users/{id}/inbox", post(inbox_handler))
|
||||
.route("/users/{id}/outbox", get(outbox_handler))
|
||||
.route("/users/{id}/followers", get(followers_handler))
|
||||
.route("/users/{id}/following", get(following_handler))
|
||||
.layer(self.federation_config.middleware())
|
||||
}
|
||||
|
||||
pub async fn follow(&self, local_user_id: uuid::Uuid, handle: &str) -> anyhow::Result<()> {
|
||||
let data = self.federation_config.to_request_data();
|
||||
|
||||
let normalized = handle.trim_start_matches('@');
|
||||
let parts: Vec<&str> = normalized.splitn(2, '@').collect();
|
||||
if parts.len() == 2 && parts[1] == data.domain {
|
||||
return self.follow_local(local_user_id, parts[0], &data).await;
|
||||
}
|
||||
|
||||
let remote_actor: DbActor = webfinger_resolve_actor(handle, &data)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
|
||||
let local_actor = get_local_actor(local_user_id, &data)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
|
||||
let follow_id = activity_url(&self.base_url).map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
let follow_id_str = follow_id.to_string();
|
||||
let follow = FollowActivity {
|
||||
id: follow_id,
|
||||
kind: Default::default(),
|
||||
actor: ObjectId::from(local_actor.ap_id.clone()),
|
||||
object: ObjectId::from(remote_actor.ap_id.clone()),
|
||||
};
|
||||
let follow_with_ctx = WithContext::new_default(follow);
|
||||
|
||||
let sends = SendActivityTask::prepare(
|
||||
&follow_with_ctx,
|
||||
&local_actor,
|
||||
vec![remote_actor.inbox()],
|
||||
&data,
|
||||
)
|
||||
.await?;
|
||||
let failures = send_with_retry(sends, &data).await;
|
||||
if !failures.is_empty() {
|
||||
tracing::warn!(
|
||||
count = failures.len(),
|
||||
"some activity deliveries failed permanently"
|
||||
);
|
||||
}
|
||||
|
||||
let remote = RemoteActor {
|
||||
url: remote_actor.ap_id.to_string(),
|
||||
handle: remote_actor.username.clone(),
|
||||
inbox_url: remote_actor.inbox_url.to_string(),
|
||||
shared_inbox_url: None,
|
||||
display_name: Some(remote_actor.username.clone()),
|
||||
avatar_url: None,
|
||||
};
|
||||
data.federation_repo
|
||||
.add_following(local_user_id, remote, &follow_id_str)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn unfollow(
|
||||
&self,
|
||||
local_user_id: uuid::Uuid,
|
||||
actor_url_str: &str,
|
||||
) -> anyhow::Result<()> {
|
||||
let data = self.federation_config.to_request_data();
|
||||
|
||||
if actor_url_str.starts_with(&self.base_url) {
|
||||
return self
|
||||
.unfollow_local(local_user_id, actor_url_str, &data)
|
||||
.await;
|
||||
}
|
||||
|
||||
let remote = data
|
||||
.federation_repo
|
||||
.get_remote_actor(actor_url_str)
|
||||
.await?
|
||||
.ok_or_else(|| anyhow::anyhow!("remote actor not found: {}", actor_url_str))?;
|
||||
|
||||
let local_actor = get_local_actor(local_user_id, &data)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
|
||||
let remote_ap_id = Url::parse(actor_url_str)?;
|
||||
let inbox = Url::parse(&remote.inbox_url)?;
|
||||
|
||||
let follow_activity_id_str = data
|
||||
.federation_repo
|
||||
.get_follow_activity_id(local_user_id, actor_url_str)
|
||||
.await?;
|
||||
let follow_id = match follow_activity_id_str {
|
||||
Some(id) => Url::parse(&id)?,
|
||||
None => activity_url(&self.base_url).map_err(|e| anyhow::anyhow!("{e}"))?,
|
||||
};
|
||||
let follow = FollowActivity {
|
||||
id: follow_id,
|
||||
kind: Default::default(),
|
||||
actor: ObjectId::from(local_actor.ap_id.clone()),
|
||||
object: ObjectId::from(remote_ap_id),
|
||||
};
|
||||
|
||||
let undo_id = activity_url(&self.base_url).map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
let undo = UndoActivity {
|
||||
id: undo_id,
|
||||
kind: Default::default(),
|
||||
actor: ObjectId::from(local_actor.ap_id.clone()),
|
||||
object: follow,
|
||||
};
|
||||
|
||||
let sends = SendActivityTask::prepare(
|
||||
&WithContext::new_default(undo),
|
||||
&local_actor,
|
||||
vec![inbox],
|
||||
&data,
|
||||
)
|
||||
.await?;
|
||||
let failures = send_with_retry(sends, &data).await;
|
||||
if !failures.is_empty() {
|
||||
tracing::warn!(
|
||||
count = failures.len(),
|
||||
"some activity deliveries failed permanently"
|
||||
);
|
||||
}
|
||||
|
||||
data.federation_repo
|
||||
.remove_following(local_user_id, actor_url_str)
|
||||
.await?;
|
||||
|
||||
data.object_handler
|
||||
.on_actor_removed(&Url::parse(actor_url_str)?)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn accept_follower(
|
||||
&self,
|
||||
local_user_id: uuid::Uuid,
|
||||
remote_actor_url: &str,
|
||||
) -> anyhow::Result<()> {
|
||||
let data = self.federation_config.to_request_data();
|
||||
let local_actor = get_local_actor(local_user_id, &data)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
|
||||
let remote_actor = data
|
||||
.federation_repo
|
||||
.get_remote_actor(remote_actor_url)
|
||||
.await?
|
||||
.ok_or_else(|| anyhow::anyhow!("remote actor not found"))?;
|
||||
|
||||
let follow_id_str = data
|
||||
.federation_repo
|
||||
.get_follower_follow_activity_id(local_user_id, remote_actor_url)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
anyhow::anyhow!("follow activity id not found for {}", remote_actor_url)
|
||||
})?;
|
||||
let follow_id = Url::parse(&follow_id_str)?;
|
||||
let follow = FollowActivity {
|
||||
id: follow_id,
|
||||
kind: Default::default(),
|
||||
actor: ObjectId::from(Url::parse(remote_actor_url)?),
|
||||
object: ObjectId::from(local_actor.ap_id.clone()),
|
||||
};
|
||||
let accept = AcceptActivity {
|
||||
id: activity_url(&self.base_url).map_err(|e| anyhow::anyhow!("{e}"))?,
|
||||
kind: Default::default(),
|
||||
actor: ObjectId::from(local_actor.ap_id.clone()),
|
||||
object: follow,
|
||||
};
|
||||
|
||||
data.federation_repo
|
||||
.update_follower_status(local_user_id, remote_actor_url, FollowerStatus::Accepted)
|
||||
.await?;
|
||||
|
||||
let inbox = Url::parse(&remote_actor.inbox_url)?;
|
||||
let sends = SendActivityTask::prepare(
|
||||
&WithContext::new_default(accept),
|
||||
&local_actor,
|
||||
vec![inbox.clone()],
|
||||
&data,
|
||||
)
|
||||
.await?;
|
||||
let failures = send_with_retry(sends, &data).await;
|
||||
if !failures.is_empty() {
|
||||
tracing::warn!(
|
||||
"failed to deliver Accept activity, but follower is marked accepted locally"
|
||||
);
|
||||
}
|
||||
|
||||
let target_inbox = remote_actor
|
||||
.shared_inbox_url
|
||||
.clone()
|
||||
.unwrap_or_else(|| remote_actor.inbox_url.clone());
|
||||
self.spawn_backfill(local_user_id, target_inbox);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn reject_follower(
|
||||
&self,
|
||||
local_user_id: uuid::Uuid,
|
||||
remote_actor_url: &str,
|
||||
) -> anyhow::Result<()> {
|
||||
let data = self.federation_config.to_request_data();
|
||||
let local_actor = get_local_actor(local_user_id, &data)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
|
||||
let remote_actor = data
|
||||
.federation_repo
|
||||
.get_remote_actor(remote_actor_url)
|
||||
.await?
|
||||
.ok_or_else(|| anyhow::anyhow!("remote actor not found"))?;
|
||||
|
||||
let follow_id = activity_url(&self.base_url).map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
let follow = FollowActivity {
|
||||
id: follow_id,
|
||||
kind: Default::default(),
|
||||
actor: ObjectId::from(Url::parse(remote_actor_url)?),
|
||||
object: ObjectId::from(local_actor.ap_id.clone()),
|
||||
};
|
||||
let reject = RejectActivity {
|
||||
id: activity_url(&self.base_url).map_err(|e| anyhow::anyhow!("{e}"))?,
|
||||
kind: Default::default(),
|
||||
actor: ObjectId::from(local_actor.ap_id.clone()),
|
||||
object: follow,
|
||||
};
|
||||
|
||||
let inbox = Url::parse(&remote_actor.inbox_url)?;
|
||||
let sends = SendActivityTask::prepare(
|
||||
&WithContext::new_default(reject),
|
||||
&local_actor,
|
||||
vec![inbox],
|
||||
&data,
|
||||
)
|
||||
.await?;
|
||||
let failures = send_with_retry(sends, &data).await;
|
||||
if !failures.is_empty() {
|
||||
tracing::warn!(
|
||||
count = failures.len(),
|
||||
"some activity deliveries failed permanently"
|
||||
);
|
||||
}
|
||||
|
||||
data.federation_repo
|
||||
.remove_follower(local_user_id, remote_actor_url)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_pending_followers(
|
||||
&self,
|
||||
local_user_id: uuid::Uuid,
|
||||
) -> anyhow::Result<Vec<RemoteActor>> {
|
||||
let data = self.federation_config.to_request_data();
|
||||
data.federation_repo
|
||||
.get_pending_followers(local_user_id)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn get_accepted_followers(
|
||||
&self,
|
||||
local_user_id: uuid::Uuid,
|
||||
) -> anyhow::Result<Vec<RemoteActor>> {
|
||||
let data = self.federation_config.to_request_data();
|
||||
let followers = data.federation_repo.get_followers(local_user_id).await?;
|
||||
Ok(followers
|
||||
.into_iter()
|
||||
.filter(|f| f.status == FollowerStatus::Accepted)
|
||||
.map(|f| f.actor)
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub async fn count_accepted_followers(
|
||||
&self,
|
||||
local_user_id: uuid::Uuid,
|
||||
) -> anyhow::Result<usize> {
|
||||
let data = self.federation_config.to_request_data();
|
||||
let followers = data.federation_repo.get_followers(local_user_id).await?;
|
||||
Ok(followers
|
||||
.into_iter()
|
||||
.filter(|f| f.status == FollowerStatus::Accepted)
|
||||
.count())
|
||||
}
|
||||
|
||||
pub async fn get_following(
|
||||
&self,
|
||||
local_user_id: uuid::Uuid,
|
||||
) -> anyhow::Result<Vec<RemoteActor>> {
|
||||
let data = self.federation_config.to_request_data();
|
||||
data.federation_repo.get_following(local_user_id).await
|
||||
}
|
||||
|
||||
pub async fn count_following(&self, local_user_id: uuid::Uuid) -> anyhow::Result<usize> {
|
||||
let data = self.federation_config.to_request_data();
|
||||
data.federation_repo.count_following(local_user_id).await
|
||||
}
|
||||
|
||||
pub async fn remove_follower(
|
||||
&self,
|
||||
local_user_id: uuid::Uuid,
|
||||
actor_url: &str,
|
||||
) -> anyhow::Result<()> {
|
||||
let data = self.federation_config.to_request_data();
|
||||
data.federation_repo
|
||||
.remove_follower(local_user_id, actor_url)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Broadcast a single object to all accepted followers as a Create activity.
|
||||
/// Called by project-specific event handlers when new content is created.
|
||||
pub async fn broadcast_to_followers(
|
||||
&self,
|
||||
local_user_id: uuid::Uuid,
|
||||
ap_id: Url,
|
||||
object: serde_json::Value,
|
||||
) -> anyhow::Result<()> {
|
||||
let data = self.federation_config.to_request_data();
|
||||
let local_actor = get_local_actor(local_user_id, &data)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
|
||||
let followers = data.federation_repo.get_followers(local_user_id).await?;
|
||||
let blocked = data
|
||||
.federation_repo
|
||||
.get_blocked_actors(local_user_id)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let blocked_set: std::collections::HashSet<String> = blocked.into_iter().collect();
|
||||
let blocked_domains = data
|
||||
.federation_repo
|
||||
.get_blocked_domains()
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let blocked_domain_set: std::collections::HashSet<String> =
|
||||
blocked_domains.into_iter().map(|d| d.domain).collect();
|
||||
let accepted: Vec<_> = followers
|
||||
.into_iter()
|
||||
.filter(|f| f.status == FollowerStatus::Accepted)
|
||||
.filter(|f| !blocked_set.contains(&f.actor.url))
|
||||
.filter(|f| {
|
||||
let domain = url::Url::parse(&f.actor.inbox_url)
|
||||
.ok()
|
||||
.and_then(|u| u.host_str().map(|s| s.to_string()))
|
||||
.unwrap_or_default();
|
||||
!blocked_domain_set.contains(&domain)
|
||||
})
|
||||
.collect();
|
||||
|
||||
if accepted.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let create = CreateActivity {
|
||||
id: ap_id.clone(),
|
||||
kind: Default::default(),
|
||||
actor: ObjectId::from(local_actor.ap_id.clone()),
|
||||
object,
|
||||
};
|
||||
let create_with_ctx = WithContext::new_default(create);
|
||||
|
||||
let inboxes = collect_inboxes(&accepted);
|
||||
|
||||
let sends =
|
||||
SendActivityTask::prepare(&create_with_ctx, &local_actor, inboxes, &data).await?;
|
||||
let failures = send_with_retry(sends, &data).await;
|
||||
if !failures.is_empty() {
|
||||
tracing::warn!(
|
||||
count = failures.len(),
|
||||
"some activity deliveries failed permanently"
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Broadcast a Delete activity to all accepted followers for a removed review.
|
||||
pub async fn broadcast_delete_to_followers(
|
||||
&self,
|
||||
local_user_id: uuid::Uuid,
|
||||
ap_id: Url,
|
||||
) -> anyhow::Result<()> {
|
||||
let data = self.federation_config.to_request_data();
|
||||
let local_actor = get_local_actor(local_user_id, &data)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
|
||||
let followers = data.federation_repo.get_followers(local_user_id).await?;
|
||||
let blocked = data
|
||||
.federation_repo
|
||||
.get_blocked_actors(local_user_id)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let blocked_set: std::collections::HashSet<String> = blocked.into_iter().collect();
|
||||
let blocked_domains = data
|
||||
.federation_repo
|
||||
.get_blocked_domains()
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let blocked_domain_set: std::collections::HashSet<String> =
|
||||
blocked_domains.into_iter().map(|d| d.domain).collect();
|
||||
let accepted: Vec<_> = followers
|
||||
.into_iter()
|
||||
.filter(|f| f.status == FollowerStatus::Accepted)
|
||||
.filter(|f| !blocked_set.contains(&f.actor.url))
|
||||
.filter(|f| {
|
||||
let domain = url::Url::parse(&f.actor.inbox_url)
|
||||
.ok()
|
||||
.and_then(|u| u.host_str().map(|s| s.to_string()))
|
||||
.unwrap_or_default();
|
||||
!blocked_domain_set.contains(&domain)
|
||||
})
|
||||
.collect();
|
||||
|
||||
if accepted.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let delete_id = crate::urls::activity_url(&self.base_url)
|
||||
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
let delete = crate::activities::DeleteActivity {
|
||||
id: delete_id,
|
||||
kind: Default::default(),
|
||||
actor: ObjectId::from(local_actor.ap_id.clone()),
|
||||
object: ap_id,
|
||||
};
|
||||
let delete_with_ctx = WithContext::new_default(delete);
|
||||
let inboxes = collect_inboxes(&accepted);
|
||||
let sends =
|
||||
SendActivityTask::prepare(&delete_with_ctx, &local_actor, inboxes, &data).await?;
|
||||
let failures = send_with_retry(sends, &data).await;
|
||||
if !failures.is_empty() {
|
||||
tracing::warn!(
|
||||
count = failures.len(),
|
||||
"some delete activity deliveries failed"
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Broadcast an Update(Note) activity to all accepted followers for an edited review.
|
||||
pub async fn broadcast_update_to_followers(
|
||||
&self,
|
||||
local_user_id: uuid::Uuid,
|
||||
object: serde_json::Value,
|
||||
) -> anyhow::Result<()> {
|
||||
let data = self.federation_config.to_request_data();
|
||||
let local_actor = get_local_actor(local_user_id, &data)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
|
||||
let followers = data.federation_repo.get_followers(local_user_id).await?;
|
||||
let blocked = data
|
||||
.federation_repo
|
||||
.get_blocked_actors(local_user_id)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let blocked_set: std::collections::HashSet<String> = blocked.into_iter().collect();
|
||||
let blocked_domains = data
|
||||
.federation_repo
|
||||
.get_blocked_domains()
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let blocked_domain_set: std::collections::HashSet<String> =
|
||||
blocked_domains.into_iter().map(|d| d.domain).collect();
|
||||
let accepted: Vec<_> = followers
|
||||
.into_iter()
|
||||
.filter(|f| f.status == FollowerStatus::Accepted)
|
||||
.filter(|f| !blocked_set.contains(&f.actor.url))
|
||||
.filter(|f| {
|
||||
let domain = url::Url::parse(&f.actor.inbox_url)
|
||||
.ok()
|
||||
.and_then(|u| u.host_str().map(|s| s.to_string()))
|
||||
.unwrap_or_default();
|
||||
!blocked_domain_set.contains(&domain)
|
||||
})
|
||||
.collect();
|
||||
|
||||
if accepted.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let update_id = Url::parse(&format!(
|
||||
"{}/activities/update/{}",
|
||||
self.base_url,
|
||||
uuid::Uuid::new_v4()
|
||||
))?;
|
||||
let update = crate::activities::UpdateActivity {
|
||||
id: update_id,
|
||||
kind: Default::default(),
|
||||
actor: ObjectId::from(local_actor.ap_id.clone()),
|
||||
object,
|
||||
};
|
||||
let update_with_ctx = WithContext::new_default(update);
|
||||
let inboxes = collect_inboxes(&accepted);
|
||||
let sends =
|
||||
SendActivityTask::prepare(&update_with_ctx, &local_actor, inboxes, &data).await?;
|
||||
let failures = send_with_retry(sends, &data).await;
|
||||
if !failures.is_empty() {
|
||||
tracing::warn!(
|
||||
count = failures.len(),
|
||||
"some update activity deliveries failed"
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn broadcast_actor_update(&self, user_id: uuid::Uuid) -> anyhow::Result<()> {
|
||||
use activitypub_federation::traits::Object;
|
||||
|
||||
let data = self.federation_config.to_request_data();
|
||||
let local_actor = get_local_actor(user_id, &data)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
|
||||
let person = local_actor.clone().into_json(&data).await
|
||||
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
let person_json = serde_json::to_value(&person)?;
|
||||
|
||||
let update_id = Url::parse(&format!(
|
||||
"{}/activities/update/{}",
|
||||
self.base_url,
|
||||
uuid::Uuid::new_v4()
|
||||
))?;
|
||||
|
||||
let update = UpdateActivity {
|
||||
id: update_id,
|
||||
kind: Default::default(),
|
||||
actor: ObjectId::from(local_actor.ap_id.clone()),
|
||||
object: person_json,
|
||||
};
|
||||
|
||||
let followers = data.federation_repo.get_followers(user_id).await?;
|
||||
let accepted: Vec<_> = followers
|
||||
.into_iter()
|
||||
.filter(|f| f.status == FollowerStatus::Accepted)
|
||||
.collect();
|
||||
|
||||
if accepted.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let inboxes = collect_inboxes(&accepted);
|
||||
let sends = SendActivityTask::prepare(
|
||||
&WithContext::new_default(update),
|
||||
&local_actor,
|
||||
inboxes,
|
||||
&data,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let failures = send_with_retry(sends, &data).await;
|
||||
if !failures.is_empty() {
|
||||
tracing::warn!(count = failures.len(), "actor update delivery failures");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn block_actor(&self, local_user_id: uuid::Uuid, actor_url: &str) -> anyhow::Result<()> {
|
||||
let data = self.federation_config.to_request_data();
|
||||
|
||||
data.federation_repo
|
||||
.add_blocked_actor(local_user_id, actor_url)
|
||||
.await?;
|
||||
let _ = data.federation_repo.remove_follower(local_user_id, actor_url).await;
|
||||
let _ = data.federation_repo.remove_following(local_user_id, actor_url).await;
|
||||
|
||||
let local_actor = get_local_actor(local_user_id, &data)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
|
||||
if let Ok(Some(remote_actor)) = data.federation_repo.get_remote_actor(actor_url).await {
|
||||
let block_id = crate::urls::activity_url(&self.base_url)
|
||||
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
let block = crate::activities::BlockActivity {
|
||||
id: block_id,
|
||||
kind: Default::default(),
|
||||
actor: ObjectId::from(local_actor.ap_id.clone()),
|
||||
object: Url::parse(actor_url)?,
|
||||
};
|
||||
let inbox = Url::parse(&remote_actor.inbox_url)?;
|
||||
let sends = SendActivityTask::prepare(
|
||||
&WithContext::new_default(block),
|
||||
&local_actor,
|
||||
vec![inbox],
|
||||
&data,
|
||||
)
|
||||
.await?;
|
||||
let failures = send_with_retry(sends, &data).await;
|
||||
if !failures.is_empty() {
|
||||
tracing::warn!(actor = %actor_url, "failed to deliver Block activity");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn unblock_actor(&self, local_user_id: uuid::Uuid, actor_url: &str) -> anyhow::Result<()> {
|
||||
let data = self.federation_config.to_request_data();
|
||||
data.federation_repo
|
||||
.remove_blocked_actor(local_user_id, actor_url)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn get_blocked_actors(&self, local_user_id: uuid::Uuid) -> anyhow::Result<Vec<RemoteActor>> {
|
||||
let data = self.federation_config.to_request_data();
|
||||
let actor_urls = data.federation_repo.get_blocked_actors(local_user_id).await?;
|
||||
let mut actors = Vec::new();
|
||||
for url in actor_urls {
|
||||
let actor = match data.federation_repo.get_remote_actor(&url).await {
|
||||
Ok(Some(a)) => a,
|
||||
_ => RemoteActor {
|
||||
url: url.clone(),
|
||||
handle: url.clone(),
|
||||
inbox_url: url.clone(),
|
||||
shared_inbox_url: None,
|
||||
display_name: None,
|
||||
avatar_url: None,
|
||||
},
|
||||
};
|
||||
actors.push(actor);
|
||||
}
|
||||
Ok(actors)
|
||||
}
|
||||
|
||||
pub async fn add_blocked_domain(&self, domain: &str, reason: Option<&str>) -> anyhow::Result<()> {
|
||||
let data = self.federation_config.to_request_data();
|
||||
data.federation_repo.add_blocked_domain(domain, reason).await
|
||||
}
|
||||
|
||||
pub async fn remove_blocked_domain(&self, domain: &str) -> anyhow::Result<()> {
|
||||
let data = self.federation_config.to_request_data();
|
||||
data.federation_repo.remove_blocked_domain(domain).await
|
||||
}
|
||||
|
||||
pub async fn get_blocked_domains(&self) -> anyhow::Result<Vec<BlockedDomain>> {
|
||||
let data = self.federation_config.to_request_data();
|
||||
data.federation_repo.get_blocked_domains().await
|
||||
}
|
||||
|
||||
async fn follow_local(
|
||||
&self,
|
||||
local_user_id: uuid::Uuid,
|
||||
target_username: &str,
|
||||
data: &activitypub_federation::config::Data<FederationData>,
|
||||
) -> anyhow::Result<()> {
|
||||
let target = data
|
||||
.user_repo
|
||||
.find_by_username(target_username)
|
||||
.await?
|
||||
.ok_or_else(|| anyhow::anyhow!("user not found: {}", target_username))?;
|
||||
|
||||
if target.id == local_user_id {
|
||||
return Err(anyhow::anyhow!("cannot follow yourself"));
|
||||
}
|
||||
|
||||
let follower_actor_url = crate::urls::actor_url(&self.base_url, local_user_id).to_string();
|
||||
let target_actor_url = crate::urls::actor_url(&self.base_url, target.id);
|
||||
let target_inbox_url = format!("{}/inbox", target_actor_url);
|
||||
let follow_id = activity_url(&self.base_url)
|
||||
.map_err(|e| anyhow::anyhow!("{e}"))?
|
||||
.to_string();
|
||||
|
||||
data.federation_repo
|
||||
.add_follower(
|
||||
target.id,
|
||||
&follower_actor_url,
|
||||
FollowerStatus::Accepted,
|
||||
&follow_id,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let target_as_remote = RemoteActor {
|
||||
url: target_actor_url.to_string(),
|
||||
handle: format!("{}@{}", target.username, data.domain),
|
||||
inbox_url: target_inbox_url,
|
||||
shared_inbox_url: None,
|
||||
display_name: Some(target.username),
|
||||
avatar_url: None,
|
||||
};
|
||||
data.federation_repo
|
||||
.add_following(local_user_id, target_as_remote, &follow_id)
|
||||
.await?;
|
||||
|
||||
data.federation_repo
|
||||
.update_following_status(
|
||||
local_user_id,
|
||||
&target_actor_url.to_string(),
|
||||
FollowingStatus::Accepted,
|
||||
)
|
||||
.await?;
|
||||
|
||||
tracing::info!(follower = %local_user_id, followee = %target.id, "local follow");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn unfollow_local(
|
||||
&self,
|
||||
local_user_id: uuid::Uuid,
|
||||
target_actor_url: &str,
|
||||
data: &activitypub_federation::config::Data<FederationData>,
|
||||
) -> anyhow::Result<()> {
|
||||
let target_url = Url::parse(target_actor_url)?;
|
||||
let target_user_id = crate::urls::extract_user_id_from_url(&target_url)
|
||||
.ok_or_else(|| anyhow::anyhow!("invalid local actor URL: {}", target_actor_url))?;
|
||||
|
||||
let local_actor_url = crate::urls::actor_url(&self.base_url, local_user_id).to_string();
|
||||
|
||||
data.federation_repo
|
||||
.remove_follower(target_user_id, &local_actor_url)
|
||||
.await?;
|
||||
data.federation_repo
|
||||
.remove_following(local_user_id, target_actor_url)
|
||||
.await?;
|
||||
|
||||
tracing::info!(follower = %local_user_id, followee = %target_user_id, "local unfollow");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn spawn_backfill(&self, owner_user_id: uuid::Uuid, follower_inbox_url: String) {
|
||||
let config = self.federation_config.clone();
|
||||
let base_url = self.base_url.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = ActivityPubService::run_backfill(
|
||||
config,
|
||||
base_url,
|
||||
owner_user_id,
|
||||
follower_inbox_url,
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(error = %e, "backfill: task failed");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async fn run_backfill(
|
||||
config: ApFederationConfig,
|
||||
base_url: String,
|
||||
owner_user_id: uuid::Uuid,
|
||||
follower_inbox_url: String,
|
||||
) -> anyhow::Result<()> {
|
||||
const BATCH_SIZE: usize = 20;
|
||||
|
||||
let data = config.to_request_data();
|
||||
let local_actor = get_local_actor(owner_user_id, &data)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
let inbox = Url::parse(&follower_inbox_url)?;
|
||||
|
||||
let mut objects = data
|
||||
.object_handler
|
||||
.get_local_objects_for_user(owner_user_id)
|
||||
.await?;
|
||||
objects.reverse(); // oldest first → chronological feed
|
||||
|
||||
let total = objects.len();
|
||||
let mut success_count = 0usize;
|
||||
let mut failure_count = 0usize;
|
||||
|
||||
for chunk in objects.chunks(BATCH_SIZE) {
|
||||
for (ap_id, object_json) in chunk {
|
||||
// Use a stable Create activity ID derived from the object's ap_id
|
||||
let create_id = Url::parse(&format!(
|
||||
"{}/activities/create/{}",
|
||||
base_url,
|
||||
uuid::Uuid::new_v5(&uuid::Uuid::NAMESPACE_URL, ap_id.as_str().as_bytes())
|
||||
))?;
|
||||
|
||||
let create = CreateActivity {
|
||||
id: create_id,
|
||||
kind: Default::default(),
|
||||
actor: ObjectId::from(local_actor.ap_id.clone()),
|
||||
object: object_json.clone(),
|
||||
};
|
||||
|
||||
let sends = SendActivityTask::prepare(
|
||||
&WithContext::new_default(create),
|
||||
&local_actor,
|
||||
vec![inbox.clone()],
|
||||
&data,
|
||||
)
|
||||
.await?;
|
||||
let failures = send_with_retry(sends, &data).await;
|
||||
if failures.is_empty() {
|
||||
success_count += 1;
|
||||
} else {
|
||||
failure_count += 1;
|
||||
}
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
user_id = %owner_user_id,
|
||||
follower = %follower_inbox_url,
|
||||
sent = success_count,
|
||||
failed = failure_count,
|
||||
total = total,
|
||||
"backfill complete"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::repository::{Follower, FollowerStatus, RemoteActor};
|
||||
|
||||
fn make_follower(inbox: &str, shared: Option<&str>) -> Follower {
|
||||
Follower {
|
||||
actor: RemoteActor {
|
||||
url: format!("https://remote/{}", inbox),
|
||||
handle: "user".to_string(),
|
||||
inbox_url: inbox.to_string(),
|
||||
shared_inbox_url: shared.map(|s| s.to_string()),
|
||||
display_name: None,
|
||||
avatar_url: None,
|
||||
},
|
||||
status: FollowerStatus::Accepted,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_inboxes_deduplicates_shared() {
|
||||
let followers = vec![
|
||||
make_follower("https://mastodon.social/users/a/inbox", Some("https://mastodon.social/inbox")),
|
||||
make_follower("https://mastodon.social/users/b/inbox", Some("https://mastodon.social/inbox")),
|
||||
make_follower("https://other.instance/users/c/inbox", None),
|
||||
];
|
||||
let inboxes = collect_inboxes(&followers);
|
||||
assert_eq!(inboxes.len(), 2);
|
||||
let strs: Vec<_> = inboxes.iter().map(|u| u.as_str()).collect();
|
||||
assert!(strs.contains(&"https://mastodon.social/inbox"));
|
||||
assert!(strs.contains(&"https://other.instance/users/c/inbox"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_inboxes_falls_back_to_individual_inbox() {
|
||||
let followers = vec![
|
||||
make_follower("https://example.com/users/x/inbox", None),
|
||||
];
|
||||
let inboxes = collect_inboxes(&followers);
|
||||
assert_eq!(inboxes.len(), 1);
|
||||
assert_eq!(inboxes[0].as_str(), "https://example.com/users/x/inbox");
|
||||
}
|
||||
}
|
||||
20
crates/adapters/activitypub-base/src/urls.rs
Normal file
20
crates/adapters/activitypub-base/src/urls.rs
Normal file
@@ -0,0 +1,20 @@
|
||||
use url::Url;
|
||||
|
||||
use crate::error::Error;
|
||||
|
||||
pub fn extract_user_id_from_url(url: &Url) -> Option<uuid::Uuid> {
|
||||
let path = url.path();
|
||||
path.strip_prefix("/users/")
|
||||
.and_then(|s| s.split('/').next())
|
||||
.and_then(|s| uuid::Uuid::parse_str(s).ok())
|
||||
}
|
||||
|
||||
pub fn activity_url(base_url: &str) -> Result<Url, Error> {
|
||||
Url::parse(&format!("{}/activities/{}", base_url, uuid::Uuid::new_v4()))
|
||||
.map_err(|e| Error::bad_request(anyhow::anyhow!(e)))
|
||||
}
|
||||
|
||||
pub fn actor_url(base_url: &str, user_id: uuid::Uuid) -> Url {
|
||||
Url::parse(&format!("{}/users/{}", base_url, user_id))
|
||||
.expect("base_url is always a valid URL prefix")
|
||||
}
|
||||
18
crates/adapters/activitypub-base/src/user.rs
Normal file
18
crates/adapters/activitypub-base/src/user.rs
Normal file
@@ -0,0 +1,18 @@
|
||||
use async_trait::async_trait;
|
||||
use url::Url;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ApUser {
|
||||
pub id: uuid::Uuid,
|
||||
pub username: String,
|
||||
pub bio: Option<String>,
|
||||
pub avatar_url: Option<Url>,
|
||||
pub profile_url: Option<Url>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait ApUserRepository: Send + Sync {
|
||||
async fn find_by_id(&self, id: uuid::Uuid) -> anyhow::Result<Option<ApUser>>;
|
||||
async fn find_by_username(&self, username: &str) -> anyhow::Result<Option<ApUser>>;
|
||||
async fn count_users(&self) -> anyhow::Result<usize>;
|
||||
}
|
||||
38
crates/adapters/activitypub-base/src/webfinger.rs
Normal file
38
crates/adapters/activitypub-base/src/webfinger.rs
Normal file
@@ -0,0 +1,38 @@
|
||||
use activitypub_federation::{
|
||||
config::Data,
|
||||
fetch::webfinger::{Webfinger, build_webfinger_response, extract_webfinger_name},
|
||||
};
|
||||
use axum::{
|
||||
extract::Query,
|
||||
http::header,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::data::FederationData;
|
||||
use crate::error::Error;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct WebfingerQuery {
|
||||
resource: String,
|
||||
}
|
||||
|
||||
pub async fn webfinger_handler(
|
||||
Query(query): Query<WebfingerQuery>,
|
||||
data: Data<FederationData>,
|
||||
) -> Result<Response, Error> {
|
||||
let name = extract_webfinger_name(&query.resource, &data)?;
|
||||
|
||||
let user = data
|
||||
.user_repo
|
||||
.find_by_username(name)
|
||||
.await
|
||||
.map_err(Error::from)?
|
||||
.ok_or_else(|| Error::not_found(anyhow::anyhow!("user not found")))?;
|
||||
|
||||
let ap_id = crate::urls::actor_url(&data.base_url, user.id);
|
||||
|
||||
let wf: Webfinger = build_webfinger_response(query.resource, ap_id);
|
||||
let body = serde_json::to_string(&wf).map_err(|e| Error::from(anyhow::anyhow!(e)))?;
|
||||
Ok(([(header::CONTENT_TYPE, "application/jrd+json")], body).into_response())
|
||||
}
|
||||
@@ -4,9 +4,10 @@ version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
k-ap = { version = "0.5.0", registry = "gitea" }
|
||||
activitypub-base = { workspace = true }
|
||||
domain = { workspace = true }
|
||||
axum = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
@@ -15,5 +16,5 @@ anyhow = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
|
||||
activitypub_federation = "0.7.0-beta.11"
|
||||
url = { version = "2", features = ["serde"] }
|
||||
ammonia = "4"
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -1,53 +1,36 @@
|
||||
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,
|
||||
},
|
||||
value_objects::{MovieId, ReviewId, UserId},
|
||||
ports::{MovieRepository, ReviewRepository},
|
||||
value_objects::{ReviewId, UserId},
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
use k_ap::{ActivityPubService, ApVisibility};
|
||||
use activitypub_base::ActivityPubService;
|
||||
|
||||
use crate::objects::{ReviewApInput, goal_to_ap_object, review_to_ap_object};
|
||||
use crate::urls::{actor_url, goal_url, review_url};
|
||||
use crate::objects::review_to_ap_object;
|
||||
use crate::urls::{actor_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>,
|
||||
movie_repository: Arc<dyn MovieRepository>,
|
||||
review_repository: Arc<dyn ReviewRepository>,
|
||||
base_url: String,
|
||||
}
|
||||
|
||||
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>,
|
||||
movie_repository: Arc<dyn MovieRepository>,
|
||||
review_repository: Arc<dyn ReviewRepository>,
|
||||
base_url: String,
|
||||
) -> Self {
|
||||
Self {
|
||||
ap_service,
|
||||
content_query,
|
||||
review_repo,
|
||||
movie_repo,
|
||||
goal_repo,
|
||||
stats_repo,
|
||||
federation_settings,
|
||||
movie_repository,
|
||||
review_repository,
|
||||
base_url,
|
||||
}
|
||||
}
|
||||
@@ -57,15 +40,11 @@ impl ActivityPubEventHandler {
|
||||
impl EventHandler for ActivityPubEventHandler {
|
||||
async fn handle(&self, event: &DomainEvent) -> Result<(), DomainError> {
|
||||
match event {
|
||||
DomainEvent::ReviewLogged {
|
||||
review_id, user_id, ..
|
||||
} => self
|
||||
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
|
||||
DomainEvent::ReviewUpdated { review_id, user_id, .. } => self
|
||||
.on_review_updated(user_id, review_id)
|
||||
.await
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string())),
|
||||
@@ -78,86 +57,6 @@ impl EventHandler for ActivityPubEventHandler {
|
||||
.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::UserDeleted { user_id } => {
|
||||
let ap_id = actor_url(&self.base_url, user_id.value());
|
||||
self.ap_service
|
||||
.broadcast_delete_to_followers(user_id.value(), ap_id)
|
||||
.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(()),
|
||||
}
|
||||
}
|
||||
@@ -165,16 +64,7 @@ impl EventHandler for ActivityPubEventHandler {
|
||||
|
||||
impl ActivityPubEventHandler {
|
||||
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? {
|
||||
let review = match self.review_repository.get_review_by_id(review_id).await? {
|
||||
Some(r) => r,
|
||||
None => return Ok(()),
|
||||
};
|
||||
@@ -183,7 +73,7 @@ impl ActivityPubEventHandler {
|
||||
let actor = actor_url(&self.base_url, user_id.value());
|
||||
|
||||
let movie = self
|
||||
.movie_repo
|
||||
.movie_repository
|
||||
.get_movie_by_id(review.movie_id())
|
||||
.await
|
||||
.ok()
|
||||
@@ -196,324 +86,77 @@ impl ActivityPubEventHandler {
|
||||
.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| format!("{}/images/{}", self.base_url, p.value())),
|
||||
base_url: self.base_url.clone(),
|
||||
},
|
||||
);
|
||||
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.base_url, review_id);
|
||||
let actor = actor_url(&self.base_url, 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| format!("{}/images/{}", self.base_url, p.value())),
|
||||
base_url: self.base_url.clone(),
|
||||
},
|
||||
);
|
||||
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.base_url, 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.base_url, user_id.value(), movie_id.value());
|
||||
let actor = actor_url(&self.base_url, 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| format!("{}/images/{}", self.base_url, p.value()))
|
||||
});
|
||||
|
||||
let added_at_utc =
|
||||
chrono::DateTime::<chrono::Utc>::from_naive_utc_and_offset(*added_at, chrono::Utc);
|
||||
let obj = crate::objects::watchlist_to_ap_object(crate::objects::WatchlistApInput {
|
||||
ap_id: ap_id.clone(),
|
||||
actor_url: actor,
|
||||
movie_title: movie_title.to_string(),
|
||||
release_year,
|
||||
external_metadata_id: external_metadata_id.clone(),
|
||||
poster_url,
|
||||
added_at: added_at_utc,
|
||||
base_url: self.base_url.clone(),
|
||||
});
|
||||
let json = serde_json::to_value(obj)?;
|
||||
|
||||
self.ap_service
|
||||
.broadcast_create(user_id.value(), json, ApVisibility::Public, vec![])
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn on_watchlist_removed(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
movie_id: &domain::value_objects::MovieId,
|
||||
) -> anyhow::Result<()> {
|
||||
use crate::urls::watchlist_entry_url;
|
||||
let ap_id = watchlist_entry_url(&self.base_url, user_id.value(), movie_id.value());
|
||||
self.ap_service
|
||||
.broadcast_delete_to_followers(user_id.value(), ap_id)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn on_poster_synced(&self, movie_id: &MovieId) -> anyhow::Result<()> {
|
||||
let entries = self
|
||||
.content_query
|
||||
.get_local_reviews_for_movie(movie_id)
|
||||
.await?;
|
||||
|
||||
let movie = self.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()
|
||||
.as_ref()
|
||||
.and_then(|m| m.poster_path())
|
||||
.map(|p| format!("{}/images/{}", self.base_url, p.value()));
|
||||
|
||||
for entry in entries {
|
||||
let review = entry.review();
|
||||
let user_id = review.user_id();
|
||||
|
||||
let flags = self
|
||||
.federation_settings
|
||||
.get_federation_flags(user_id)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
if !flags.reviews {
|
||||
continue;
|
||||
}
|
||||
|
||||
let ap_id = review_url(&self.base_url, review.id());
|
||||
let actor = actor_url(&self.base_url, user_id.value());
|
||||
|
||||
let obj = review_to_ap_object(
|
||||
review,
|
||||
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.base_url.clone(),
|
||||
},
|
||||
);
|
||||
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.base_url, user_id.value(), year);
|
||||
let actor = actor_url(&self.base_url, user_id.value());
|
||||
let obj = goal_to_ap_object(
|
||||
ap_id,
|
||||
let obj = review_to_ap_object(
|
||||
&review,
|
||||
ap_id.clone(),
|
||||
actor,
|
||||
year,
|
||||
goal.target_count(),
|
||||
current,
|
||||
movie_title,
|
||||
release_year,
|
||||
poster_url,
|
||||
&self.base_url,
|
||||
);
|
||||
let json = serde_json::to_value(obj)?;
|
||||
|
||||
self.ap_service
|
||||
.broadcast_update(user_id.value(), json, ApVisibility::Public, vec![])
|
||||
.broadcast_to_followers(user_id.value(), ap_id, json)
|
||||
.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);
|
||||
async fn on_review_updated(&self, user_id: &UserId, review_id: &ReviewId) -> anyhow::Result<()> {
|
||||
let review = match self.review_repository.get_review_by_id(review_id).await? {
|
||||
Some(r) => r,
|
||||
None => return Ok(()),
|
||||
};
|
||||
|
||||
let ap_id = goal_url(&self.base_url, user_id.value(), year);
|
||||
let ap_id = review_url(&self.base_url, review_id);
|
||||
let actor = actor_url(&self.base_url, user_id.value());
|
||||
let obj = goal_to_ap_object(ap_id, actor, year, target_count, current, &self.base_url);
|
||||
|
||||
let movie = self
|
||||
.movie_repository
|
||||
.get_movie_by_id(review.movie_id())
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
let movie_title = movie
|
||||
.as_ref()
|
||||
.map(|m| m.title().value().to_string())
|
||||
.unwrap_or_else(|| "Unknown".to_string());
|
||||
let release_year = movie
|
||||
.as_ref()
|
||||
.map(|m| m.release_year().value())
|
||||
.unwrap_or(0);
|
||||
let poster_url = movie
|
||||
.as_ref()
|
||||
.and_then(|m| m.poster_path())
|
||||
.map(|p| format!("{}/images/{}", self.base_url, p.value()));
|
||||
|
||||
let obj = review_to_ap_object(
|
||||
&review,
|
||||
ap_id,
|
||||
actor,
|
||||
movie_title,
|
||||
release_year,
|
||||
poster_url,
|
||||
&self.base_url,
|
||||
);
|
||||
let json = serde_json::to_value(obj)?;
|
||||
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?;
|
||||
}
|
||||
|
||||
self.ap_service
|
||||
.broadcast_update_to_followers(user_id.value(), json)
|
||||
.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.base_url, user_id.value(), year);
|
||||
async fn on_review_deleted(&self, user_id: &UserId, review_id: &ReviewId) -> anyhow::Result<()> {
|
||||
let ap_id = review_url(&self.base_url, review_id);
|
||||
self.ap_service
|
||||
.broadcast_delete_to_followers(user_id.value(), ap_id)
|
||||
.await?;
|
||||
|
||||
@@ -1,73 +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");
|
||||
// Handled by FollowBackfillHandler reacting to FollowAccepted
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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::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 base_url: String,
|
||||
}
|
||||
|
||||
#[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.base_url, user_id);
|
||||
let follower_cc = format!("{}/followers", actor);
|
||||
let mut results = Vec::new();
|
||||
for goal in goals {
|
||||
let ap_id = goal_url(&self.base_url, 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.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(())
|
||||
}
|
||||
}
|
||||
@@ -1,170 +1,62 @@
|
||||
pub mod composite_handler;
|
||||
pub mod event_handler;
|
||||
pub mod federation_event_bridge;
|
||||
pub mod goal_handler;
|
||||
pub mod objects;
|
||||
pub mod port;
|
||||
pub mod remote_review_repository;
|
||||
pub mod review_handler;
|
||||
pub mod social_adapter;
|
||||
pub(crate) mod urls;
|
||||
pub mod user_adapter;
|
||||
pub mod watchlist_handler;
|
||||
|
||||
pub const INSTANCE_ACTOR_ID: uuid::Uuid =
|
||||
uuid::Uuid::from_bytes([0, 0, 0, 0, 0, 0, 0x40, 0, 0x80, 0, 0, 0, 0, 0, 0, 0]);
|
||||
|
||||
// Re-export the generic base types that callers need
|
||||
pub use k_ap::{
|
||||
ActivityPubService, ActivityRepository, ActorRepository, ApContentReader, ApFederationConfig,
|
||||
ApObjectHandler, ApUser, ApUserRepository, BlocklistRepository, FederationData,
|
||||
FollowRepository, Follower, FollowerStatus, FollowingStatus, LocalObject, RemoteActor,
|
||||
pub use activitypub_base::{
|
||||
ActivityPubService, ApFederationConfig, ApObjectHandler, ApUser, ApUserRepository,
|
||||
FederationData, FederationRepository, Follower, FollowerStatus, FollowingStatus, RemoteActor,
|
||||
};
|
||||
|
||||
pub use event_handler::ActivityPubEventHandler;
|
||||
pub use port::{ActivityPubPort, NoopActivityPubService};
|
||||
pub use remote_review_repository::{RemoteReviewRepository, RemoteReviewUpdate};
|
||||
pub use remote_review_repository::RemoteReviewRepository;
|
||||
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 service: std::sync::Arc<dyn ActivityPubPort>,
|
||||
pub router: axum::Router,
|
||||
pub service: std::sync::Arc<dyn ActivityPubPort>,
|
||||
pub router: axum::Router,
|
||||
pub event_handler: std::sync::Arc<dyn domain::ports::EventHandler>,
|
||||
}
|
||||
|
||||
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 base_url: String,
|
||||
pub allow_registration: bool,
|
||||
pub event_publisher: std::sync::Arc<dyn domain::ports::EventPublisher>,
|
||||
}
|
||||
|
||||
pub async fn wire(deps: ActivityPubDeps) -> anyhow::Result<ActivityPubWire> {
|
||||
let ActivityPubDeps {
|
||||
activity_repo,
|
||||
follow_repo,
|
||||
actor_repo,
|
||||
blocklist_repo,
|
||||
review_store,
|
||||
remote_watchlist_repo,
|
||||
remote_goal_repo,
|
||||
local_ap_content,
|
||||
movie_repo,
|
||||
review_repo,
|
||||
diary_repo,
|
||||
goal_repo,
|
||||
stats_repo,
|
||||
user_repo,
|
||||
federation_settings,
|
||||
follow_command: _,
|
||||
follow_query: _,
|
||||
base_url,
|
||||
allow_registration,
|
||||
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,
|
||||
event_publisher: std::sync::Arc::clone(&event_publisher),
|
||||
base_url: base_url.clone(),
|
||||
});
|
||||
let watchlist_handler = std::sync::Arc::new(watchlist_handler::WatchlistObjectHandler {
|
||||
remote_watchlist_repo,
|
||||
content_query: std::sync::Arc::clone(&local_ap_content),
|
||||
base_url: base_url.clone(),
|
||||
});
|
||||
let goal_handler = std::sync::Arc::new(goal_handler::GoalObjectHandler {
|
||||
remote_goal_repo,
|
||||
goal_repo: std::sync::Arc::clone(&goal_repo),
|
||||
base_url: base_url.clone(),
|
||||
});
|
||||
let composite = std::sync::Arc::new(composite_handler::CompositeObjectHandler {
|
||||
review: review_handler,
|
||||
watchlist: watchlist_handler,
|
||||
goal: goal_handler,
|
||||
});
|
||||
|
||||
let federation_debug = std::env::var("FEDERATION_DEBUG")
|
||||
.map(|v| v == "true" || v == "1")
|
||||
.unwrap_or(false);
|
||||
|
||||
if federation_debug {
|
||||
tracing::warn!(
|
||||
"federation running in DEBUG mode — PermissiveVerifier active, \
|
||||
no URL/signature validation. Do NOT use in production."
|
||||
);
|
||||
}
|
||||
|
||||
let fed_event_bridge = std::sync::Arc::new(
|
||||
federation_event_bridge::FederationEventBridge::new(event_publisher),
|
||||
);
|
||||
|
||||
pub async fn wire(
|
||||
federation_repo: std::sync::Arc<dyn FederationRepository>,
|
||||
review_store: std::sync::Arc<dyn RemoteReviewRepository>,
|
||||
user_repo: std::sync::Arc<dyn domain::ports::UserRepository>,
|
||||
movie_repo: std::sync::Arc<dyn domain::ports::MovieRepository>,
|
||||
review_repo: std::sync::Arc<dyn domain::ports::ReviewRepository>,
|
||||
diary_repo: std::sync::Arc<dyn domain::ports::DiaryRepository>,
|
||||
base_url: String,
|
||||
allow_registration: bool,
|
||||
) -> anyhow::Result<ActivityPubWire> {
|
||||
let concrete = std::sync::Arc::new(
|
||||
ActivityPubService::builder(base_url.clone())
|
||||
.activity_repo(activity_repo)
|
||||
.follow_repo(follow_repo)
|
||||
.actor_repo(actor_repo)
|
||||
.blocklist_repo(blocklist_repo)
|
||||
.user_repo(std::sync::Arc::new(DomainUserRepoAdapter::new(
|
||||
user_repo,
|
||||
base_url.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?,
|
||||
ActivityPubService::new(
|
||||
federation_repo,
|
||||
std::sync::Arc::new(DomainUserRepoAdapter::new(user_repo, base_url.clone())),
|
||||
std::sync::Arc::new(ReviewObjectHandler {
|
||||
movie_repository: std::sync::Arc::clone(&movie_repo),
|
||||
diary_repository: diary_repo,
|
||||
review_store,
|
||||
base_url: base_url.clone(),
|
||||
}),
|
||||
base_url.clone(),
|
||||
allow_registration,
|
||||
"movies-diary".to_string(),
|
||||
cfg!(debug_assertions),
|
||||
)
|
||||
.await?,
|
||||
);
|
||||
|
||||
let router = concrete.router();
|
||||
let event_handler = std::sync::Arc::new(ActivityPubEventHandler::new(
|
||||
std::sync::Arc::clone(&concrete),
|
||||
local_ap_content,
|
||||
review_repo,
|
||||
movie_repo,
|
||||
goal_repo,
|
||||
stats_repo,
|
||||
federation_settings,
|
||||
review_repo,
|
||||
base_url,
|
||||
)) as std::sync::Arc<dyn domain::ports::EventHandler>;
|
||||
|
||||
|
||||
@@ -1,29 +1,10 @@
|
||||
use activitypub_federation::kinds::object::NoteType;
|
||||
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")]
|
||||
@@ -40,7 +21,7 @@ pub(crate) fn normalize_hashtag(title: &str) -> String {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ReviewObject {
|
||||
#[serde(rename = "type")]
|
||||
pub(crate) kind: ActivityStreamsType,
|
||||
pub(crate) kind: NoteType,
|
||||
pub(crate) id: Url,
|
||||
pub(crate) attributed_to: Url,
|
||||
pub(crate) content: String,
|
||||
@@ -49,47 +30,25 @@ pub struct ReviewObject {
|
||||
#[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,
|
||||
#[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;
|
||||
/// Serialize a local Review into a ReviewObject for AP delivery.
|
||||
/// Takes movie metadata explicitly since the handler fetches it separately.
|
||||
pub fn review_to_ap_object(
|
||||
review: &Review,
|
||||
ap_id: Url,
|
||||
actor_url: Url,
|
||||
movie_title: String,
|
||||
release_year: u16,
|
||||
poster_url: Option<String>,
|
||||
base_url: &str,
|
||||
) -> ReviewObject {
|
||||
let stars: String = "\u{2B50}".repeat(review.rating().value() as usize);
|
||||
let comment_text = review.comment().map(|c| c.value().to_string());
|
||||
let year_str = if release_year > 0 {
|
||||
@@ -110,7 +69,8 @@ pub fn review_to_ap_object(review: &Review, input: ReviewApInput) -> ReviewObjec
|
||||
let tag = vec![
|
||||
ApHashtag {
|
||||
kind: "Hashtag".to_string(),
|
||||
href: Url::parse(&format!("{}/tags/moviesdiary", base_url)).expect("valid base_url"),
|
||||
href: Url::parse(&format!("{}/tags/moviesdiary", base_url))
|
||||
.expect("valid base_url"),
|
||||
name: "#MoviesDiary".to_string(),
|
||||
},
|
||||
ApHashtag {
|
||||
@@ -120,184 +80,64 @@ pub fn review_to_ap_object(review: &Review, input: ReviewApInput) -> ReviewObjec
|
||||
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(),
|
||||
kind: NoteType::default(),
|
||||
id: ap_id,
|
||||
attributed_to: actor_url.clone(),
|
||||
attributed_to: actor_url,
|
||||
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,
|
||||
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)],
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/objects.rs"]
|
||||
mod tests;
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn normalize_hashtag_strips_non_alphanumeric() {
|
||||
assert_eq!(normalize_hashtag("The Dark Knight"), "TheDarkKnight");
|
||||
assert_eq!(normalize_hashtag("Schindler's List"), "SchindlersList");
|
||||
assert_eq!(normalize_hashtag("2001: A Space Odyssey"), "2001ASpaceOdyssey");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn review_to_ap_object_includes_two_hashtags() {
|
||||
use chrono::NaiveDateTime;
|
||||
use domain::{
|
||||
models::{Review, ReviewSource},
|
||||
value_objects::{MovieId, Rating, ReviewId, UserId},
|
||||
};
|
||||
|
||||
let review = Review::from_persistence(
|
||||
ReviewId::generate(),
|
||||
MovieId::from_uuid(uuid::Uuid::new_v4()),
|
||||
UserId::from_uuid(uuid::Uuid::new_v4()),
|
||||
Rating::new(4).unwrap(),
|
||||
None,
|
||||
NaiveDateTime::parse_from_str("2024-01-01 00:00:00", "%Y-%m-%d %H:%M:%S").unwrap(),
|
||||
NaiveDateTime::parse_from_str("2024-01-01 00:00:00", "%Y-%m-%d %H:%M:%S").unwrap(),
|
||||
ReviewSource::Local,
|
||||
);
|
||||
let obj = review_to_ap_object(
|
||||
&review,
|
||||
"https://example.com/reviews/1".parse().unwrap(),
|
||||
"https://example.com/users/1".parse().unwrap(),
|
||||
"Dune".to_string(),
|
||||
2021,
|
||||
None,
|
||||
"https://example.com",
|
||||
);
|
||||
assert_eq!(obj.tag.len(), 2);
|
||||
let names: Vec<&str> = obj.tag.iter().map(|t| t.name.as_str()).collect();
|
||||
assert!(names.contains(&"#MoviesDiary"));
|
||||
assert!(names.contains(&"#Dune"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
use async_trait::async_trait;
|
||||
use uuid::Uuid;
|
||||
|
||||
use k_ap::{ActivityPubService, BlockedDomain, RemoteActor};
|
||||
use activitypub_base::{ActivityPubService, BlockedDomain, RemoteActor};
|
||||
|
||||
#[async_trait]
|
||||
pub trait ActivityPubPort: Send + Sync {
|
||||
async fn actor_json(&self, user_id: &str) -> anyhow::Result<String>;
|
||||
async fn count_following(&self, local_user_id: Uuid) -> anyhow::Result<usize>;
|
||||
async fn count_accepted_followers(&self, local_user_id: Uuid) -> anyhow::Result<usize>;
|
||||
async fn get_pending_followers(&self, local_user_id: Uuid) -> anyhow::Result<Vec<RemoteActor>>;
|
||||
async fn follow(&self, local_user_id: Uuid, handle: &str) -> anyhow::Result<()>;
|
||||
async fn unfollow(&self, local_user_id: Uuid, actor_url: &str) -> anyhow::Result<()>;
|
||||
async fn accept_follower(
|
||||
@@ -19,6 +22,8 @@ pub trait ActivityPubPort: Send + Sync {
|
||||
remote_actor_url: &str,
|
||||
) -> anyhow::Result<()>;
|
||||
async fn get_following(&self, local_user_id: Uuid) -> anyhow::Result<Vec<RemoteActor>>;
|
||||
async fn get_accepted_followers(&self, local_user_id: Uuid)
|
||||
-> anyhow::Result<Vec<RemoteActor>>;
|
||||
async fn remove_follower(&self, local_user_id: Uuid, actor_url: &str) -> anyhow::Result<()>;
|
||||
async fn block_actor(&self, local_user_id: Uuid, actor_url: &str) -> anyhow::Result<()>;
|
||||
async fn unblock_actor(&self, local_user_id: Uuid, actor_url: &str) -> anyhow::Result<()>;
|
||||
@@ -26,22 +31,6 @@ pub trait ActivityPubPort: Send + Sync {
|
||||
async fn add_blocked_domain(&self, domain: &str, reason: Option<&str>) -> anyhow::Result<()>;
|
||||
async fn remove_blocked_domain(&self, domain: &str) -> anyhow::Result<()>;
|
||||
async fn get_blocked_domains(&self) -> anyhow::Result<Vec<BlockedDomain>>;
|
||||
async fn import_remote_outbox(&self, outbox_url: &str, actor_url: &str) -> anyhow::Result<()>;
|
||||
async fn followers_collection_json(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
page: Option<u32>,
|
||||
) -> anyhow::Result<String>;
|
||||
async fn following_collection_json(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
page: Option<u32>,
|
||||
) -> anyhow::Result<String>;
|
||||
async fn run_backfill_for_follower(
|
||||
&self,
|
||||
owner_user_id: Uuid,
|
||||
follower_inbox_url: String,
|
||||
) -> anyhow::Result<()>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -49,6 +38,15 @@ impl ActivityPubPort for ActivityPubService {
|
||||
async fn actor_json(&self, user_id: &str) -> anyhow::Result<String> {
|
||||
self.actor_json(user_id).await
|
||||
}
|
||||
async fn count_following(&self, local_user_id: Uuid) -> anyhow::Result<usize> {
|
||||
self.count_following(local_user_id).await
|
||||
}
|
||||
async fn count_accepted_followers(&self, local_user_id: Uuid) -> anyhow::Result<usize> {
|
||||
self.count_accepted_followers(local_user_id).await
|
||||
}
|
||||
async fn get_pending_followers(&self, local_user_id: Uuid) -> anyhow::Result<Vec<RemoteActor>> {
|
||||
self.get_pending_followers(local_user_id).await
|
||||
}
|
||||
async fn follow(&self, local_user_id: Uuid, handle: &str) -> anyhow::Result<()> {
|
||||
self.follow(local_user_id, handle).await
|
||||
}
|
||||
@@ -72,6 +70,12 @@ impl ActivityPubPort for ActivityPubService {
|
||||
async fn get_following(&self, local_user_id: Uuid) -> anyhow::Result<Vec<RemoteActor>> {
|
||||
self.get_following(local_user_id).await
|
||||
}
|
||||
async fn get_accepted_followers(
|
||||
&self,
|
||||
local_user_id: Uuid,
|
||||
) -> anyhow::Result<Vec<RemoteActor>> {
|
||||
self.get_accepted_followers(local_user_id).await
|
||||
}
|
||||
async fn remove_follower(&self, local_user_id: Uuid, actor_url: &str) -> anyhow::Result<()> {
|
||||
self.remove_follower(local_user_id, actor_url).await
|
||||
}
|
||||
@@ -93,31 +97,6 @@ impl ActivityPubPort for ActivityPubService {
|
||||
async fn get_blocked_domains(&self) -> anyhow::Result<Vec<BlockedDomain>> {
|
||||
self.get_blocked_domains().await
|
||||
}
|
||||
async fn import_remote_outbox(&self, outbox_url: &str, actor_url: &str) -> anyhow::Result<()> {
|
||||
self.import_remote_outbox(outbox_url, actor_url).await
|
||||
}
|
||||
async fn followers_collection_json(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
page: Option<u32>,
|
||||
) -> anyhow::Result<String> {
|
||||
self.followers_collection_json(user_id, page).await
|
||||
}
|
||||
async fn following_collection_json(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
page: Option<u32>,
|
||||
) -> anyhow::Result<String> {
|
||||
self.following_collection_json(user_id, page).await
|
||||
}
|
||||
async fn run_backfill_for_follower(
|
||||
&self,
|
||||
owner_user_id: Uuid,
|
||||
follower_inbox_url: String,
|
||||
) -> anyhow::Result<()> {
|
||||
self.run_backfill_for_follower(owner_user_id, follower_inbox_url)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
pub struct NoopActivityPubService;
|
||||
@@ -127,6 +106,15 @@ impl ActivityPubPort for NoopActivityPubService {
|
||||
async fn actor_json(&self, _: &str) -> anyhow::Result<String> {
|
||||
Ok(String::new())
|
||||
}
|
||||
async fn count_following(&self, _: Uuid) -> anyhow::Result<usize> {
|
||||
Ok(0)
|
||||
}
|
||||
async fn count_accepted_followers(&self, _: Uuid) -> anyhow::Result<usize> {
|
||||
Ok(0)
|
||||
}
|
||||
async fn get_pending_followers(&self, _: Uuid) -> anyhow::Result<Vec<RemoteActor>> {
|
||||
Ok(vec![])
|
||||
}
|
||||
async fn follow(&self, _: Uuid, _: &str) -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
@@ -142,6 +130,9 @@ impl ActivityPubPort for NoopActivityPubService {
|
||||
async fn get_following(&self, _: Uuid) -> anyhow::Result<Vec<RemoteActor>> {
|
||||
Ok(vec![])
|
||||
}
|
||||
async fn get_accepted_followers(&self, _: Uuid) -> anyhow::Result<Vec<RemoteActor>> {
|
||||
Ok(vec![])
|
||||
}
|
||||
async fn remove_follower(&self, _: Uuid, _: &str) -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
@@ -163,16 +154,4 @@ impl ActivityPubPort for NoopActivityPubService {
|
||||
async fn get_blocked_domains(&self) -> anyhow::Result<Vec<BlockedDomain>> {
|
||||
Ok(vec![])
|
||||
}
|
||||
async fn import_remote_outbox(&self, _: &str, _: &str) -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
async fn followers_collection_json(&self, _: Uuid, _: Option<u32>) -> anyhow::Result<String> {
|
||||
Ok(String::new())
|
||||
}
|
||||
async fn following_collection_json(&self, _: Uuid, _: Option<u32>) -> anyhow::Result<String> {
|
||||
Ok(String::new())
|
||||
}
|
||||
async fn run_backfill_for_follower(&self, _: Uuid, _: String) -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,16 +3,6 @@ use async_trait::async_trait;
|
||||
use chrono::NaiveDateTime;
|
||||
use domain::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>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait RemoteReviewRepository: Send + Sync {
|
||||
async fn save_remote_review(
|
||||
@@ -21,13 +11,19 @@ pub trait RemoteReviewRepository: Send + Sync {
|
||||
ap_id: &str,
|
||||
movie_title: &str,
|
||||
release_year: u16,
|
||||
external_metadata_id: Option<&str>,
|
||||
poster_url: Option<&str>,
|
||||
) -> 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 update_remote_review(
|
||||
&self,
|
||||
ap_id: &str,
|
||||
actor_url: &str,
|
||||
rating: u8,
|
||||
comment: Option<&str>,
|
||||
watched_at: NaiveDateTime,
|
||||
) -> Result<()>;
|
||||
|
||||
async fn delete_by_actor(&self, actor_url: &str) -> Result<()>;
|
||||
}
|
||||
|
||||
@@ -1,161 +1,188 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use activitypub_base::ApObjectHandler;
|
||||
use async_trait::async_trait;
|
||||
use domain::{
|
||||
events::DomainEvent,
|
||||
models::ReviewSource,
|
||||
ports::{DiaryQuery, EventPublisher, LocalApContentQuery, MovieQuery},
|
||||
value_objects::{Comment, ExternalMetadataId, MovieId, Rating, ReviewId, UserId},
|
||||
models::{Review, ReviewSource},
|
||||
ports::{DiaryRepository, MovieRepository},
|
||||
value_objects::{Comment, 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::objects::{ReviewObject, review_to_ap_object};
|
||||
use crate::remote_review_repository::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 movie_repository: Arc<dyn MovieRepository>,
|
||||
pub diary_repository: Arc<dyn DiaryRepository>,
|
||||
pub review_store: Arc<dyn RemoteReviewRepository>,
|
||||
pub event_publisher: Arc<dyn EventPublisher>,
|
||||
pub base_url: String,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ApContentReader for ReviewObjectHandler {
|
||||
impl ApObjectHandler for ReviewObjectHandler {
|
||||
async fn get_local_objects_for_user(
|
||||
&self,
|
||||
user_id: uuid::Uuid,
|
||||
) -> anyhow::Result<Vec<(Url, serde_json::Value)>> {
|
||||
let domain_user_id = UserId::from_uuid(user_id);
|
||||
let history = self
|
||||
.diary_repository
|
||||
.get_user_history(&domain_user_id)
|
||||
.await?;
|
||||
|
||||
let mut results = Vec::new();
|
||||
for entry in history {
|
||||
let review = entry.review();
|
||||
if !matches!(review.source(), ReviewSource::Local) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let ap_id = review_url(&self.base_url, review.id());
|
||||
let actor_url = actor_url(&self.base_url, user_id);
|
||||
|
||||
let movie = self
|
||||
.movie_repository
|
||||
.get_movie_by_id(review.movie_id())
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
let movie_title = movie
|
||||
.as_ref()
|
||||
.map(|m| m.title().value().to_string())
|
||||
.unwrap_or_else(|| "Unknown".to_string());
|
||||
let release_year = movie
|
||||
.as_ref()
|
||||
.map(|m| m.release_year().value())
|
||||
.unwrap_or(0);
|
||||
let poster_url = movie
|
||||
.as_ref()
|
||||
.and_then(|m| m.poster_path())
|
||||
.map(|p| format!("{}/images/{}", self.base_url, p.value()));
|
||||
|
||||
let obj = review_to_ap_object(
|
||||
review,
|
||||
ap_id.clone(),
|
||||
actor_url,
|
||||
movie_title,
|
||||
release_year,
|
||||
poster_url,
|
||||
&self.base_url,
|
||||
);
|
||||
let json = serde_json::to_value(obj)?;
|
||||
results.push((ap_id, json));
|
||||
}
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
async fn get_local_objects_page(
|
||||
&self,
|
||||
user_id: uuid::Uuid,
|
||||
before: Option<chrono::DateTime<chrono::Utc>>,
|
||||
limit: usize,
|
||||
) -> anyhow::Result<Vec<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()))?;
|
||||
) -> anyhow::Result<Vec<(url::Url, serde_json::Value, chrono::DateTime<chrono::Utc>)>> {
|
||||
use domain::value_objects::UserId;
|
||||
|
||||
let domain_user_id = UserId::from_uuid(user_id);
|
||||
let history = self
|
||||
.diary_repository
|
||||
.get_user_history(&domain_user_id)
|
||||
.await?;
|
||||
|
||||
let actor = actor_url(&self.base_url, user_id);
|
||||
let mut results = Vec::new();
|
||||
for entry in entries {
|
||||
for entry in history {
|
||||
let review = entry.review();
|
||||
if !matches!(review.source(), ReviewSource::Local) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let published =
|
||||
chrono::DateTime::from_naive_utc_and_offset(*review.watched_at(), chrono::Utc);
|
||||
let movie = entry.movie();
|
||||
|
||||
if let Some(cutoff) = before {
|
||||
if published >= cutoff {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
let ap_id = review_url(&self.base_url, review.id());
|
||||
let actor_url = actor_url(&self.base_url, user_id);
|
||||
|
||||
let movie = self
|
||||
.movie_repository
|
||||
.get_movie_by_id(review.movie_id())
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
let movie_title = movie
|
||||
.as_ref()
|
||||
.map(|m| m.title().value().to_string())
|
||||
.unwrap_or_else(|| "Unknown".to_string());
|
||||
let release_year = movie.as_ref().map(|m| m.release_year().value()).unwrap_or(0);
|
||||
let poster_url = movie
|
||||
.poster_path()
|
||||
.as_ref()
|
||||
.and_then(|m| m.poster_path())
|
||||
.map(|p| format!("{}/images/{}", self.base_url, 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.base_url.clone(),
|
||||
},
|
||||
ap_id.clone(),
|
||||
actor_url,
|
||||
movie_title,
|
||||
release_year,
|
||||
poster_url,
|
||||
&self.base_url,
|
||||
);
|
||||
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![],
|
||||
});
|
||||
let json = serde_json::to_value(obj)?;
|
||||
results.push((ap_id, json, published));
|
||||
|
||||
if results.len() >= limit {
|
||||
break;
|
||||
}
|
||||
}
|
||||
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) {
|
||||
let obj: ReviewObject = match serde_json::from_value(object) {
|
||||
Ok(o) => o,
|
||||
Err(e) => {
|
||||
tracing::warn!("ignoring unrecognized Create object: {}", e);
|
||||
tracing::debug!("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 movie_id = MovieId::from_uuid(uuid::Uuid::new_v5(
|
||||
&uuid::Uuid::NAMESPACE_URL,
|
||||
obj.movie_title.as_bytes(),
|
||||
));
|
||||
let user_id = UserId::from_uuid(uuid::Uuid::new_v5(
|
||||
&uuid::Uuid::NAMESPACE_URL,
|
||||
actor_url_str.as_bytes(),
|
||||
));
|
||||
let rating = Rating::new(obj.rating.min(5))?;
|
||||
let comment = obj.comment.map(Comment::new).transpose()?;
|
||||
let 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(),
|
||||
let review = Review::from_persistence(
|
||||
review_id,
|
||||
movie_id,
|
||||
user_id,
|
||||
rating,
|
||||
comment,
|
||||
watched_at: obj.watched_at.naive_utc(),
|
||||
created_at: obj.published.naive_utc(),
|
||||
source: ReviewSource::Remote {
|
||||
obj.watched_at.naive_utc(),
|
||||
obj.published.naive_utc(),
|
||||
ReviewSource::Remote {
|
||||
actor_url: actor_url_str,
|
||||
},
|
||||
watch_medium,
|
||||
});
|
||||
);
|
||||
|
||||
self.review_store
|
||||
.save_remote_review(
|
||||
@@ -163,23 +190,10 @@ impl ApObjectHandler for ReviewObjectHandler {
|
||||
obj.id.as_str(),
|
||||
&obj.movie_title,
|
||||
obj.release_year,
|
||||
obj.external_metadata_id.as_deref(),
|
||||
obj.poster_url.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
if let Some(ref ext_id_str) = obj.external_metadata_id
|
||||
&& let Ok(external_metadata_id) = ExternalMetadataId::new(ext_id_str.clone())
|
||||
{
|
||||
let _ = self
|
||||
.event_publisher
|
||||
.publish(&DomainEvent::MovieEnrichmentRequested {
|
||||
movie_id: movie_id.clone(),
|
||||
external_metadata_id,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -189,30 +203,26 @@ impl ApObjectHandler for ReviewObjectHandler {
|
||||
actor_url: &Url,
|
||||
object: serde_json::Value,
|
||||
) -> anyhow::Result<()> {
|
||||
let mut obj: ReviewObject = match serde_json::from_value(object) {
|
||||
let obj: ReviewObject = match serde_json::from_value(object) {
|
||||
Ok(o) => o,
|
||||
Err(_) => {
|
||||
tracing::warn!(actor = %actor_url, "ignoring non-review Update activity");
|
||||
tracing::debug!(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(),
|
||||
})
|
||||
.update_remote_review(
|
||||
ap_id.as_str(),
|
||||
actor_url.as_str(),
|
||||
obj.rating.min(5),
|
||||
obj.comment.as_deref(),
|
||||
obj.watched_at.naive_utc(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
@@ -228,36 +238,10 @@ impl ApObjectHandler for ReviewObjectHandler {
|
||||
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(())
|
||||
async fn count_local_posts(&self) -> anyhow::Result<u64> {
|
||||
self.diary_repository
|
||||
.count_local_posts()
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!(e.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,298 +0,0 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
ports::{FollowCommand, FollowQuery, SocialCommand, SocialQuery, UserRepository},
|
||||
value_objects::{FollowStatus, FollowTarget, SocialActor, SocialIdentity, UserId, Username},
|
||||
};
|
||||
|
||||
use super::ActivityPubPort;
|
||||
|
||||
pub struct CompositeSocialAdapter {
|
||||
ap_service: Arc<dyn ActivityPubPort>,
|
||||
user_repo: Arc<dyn UserRepository>,
|
||||
follow_command: Arc<dyn FollowCommand>,
|
||||
follow_query: Arc<dyn FollowQuery>,
|
||||
base_url: String,
|
||||
}
|
||||
|
||||
impl CompositeSocialAdapter {
|
||||
pub fn new(
|
||||
ap_service: Arc<dyn ActivityPubPort>,
|
||||
user_repo: Arc<dyn UserRepository>,
|
||||
follow_command: Arc<dyn FollowCommand>,
|
||||
follow_query: Arc<dyn FollowQuery>,
|
||||
base_url: String,
|
||||
) -> Self {
|
||||
Self {
|
||||
ap_service,
|
||||
user_repo,
|
||||
follow_command,
|
||||
follow_query,
|
||||
base_url,
|
||||
}
|
||||
}
|
||||
|
||||
fn local_actor_url(&self, user_id: &UserId) -> String {
|
||||
format!("{}/users/{}", self.base_url, user_id.value())
|
||||
}
|
||||
|
||||
fn actor_url_from_identity(&self, identity: &SocialIdentity) -> String {
|
||||
match identity {
|
||||
SocialIdentity::Local(uid) => self.local_actor_url(uid),
|
||||
SocialIdentity::Remote { actor_url } => actor_url.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn resolve_target_identity(
|
||||
&self,
|
||||
target: &FollowTarget,
|
||||
) -> Result<SocialIdentity, DomainError> {
|
||||
match target {
|
||||
FollowTarget::Identity(id) => Ok(id.clone()),
|
||||
FollowTarget::Handle(handle) => {
|
||||
let host = handle.rsplit_once('@').map(|(_, h)| h).unwrap_or("");
|
||||
let local_host = SocialIdentity::host_from_base_url(&self.base_url);
|
||||
if host == local_host {
|
||||
let username_str = handle
|
||||
.trim_start_matches('@')
|
||||
.split('@')
|
||||
.next()
|
||||
.unwrap_or("");
|
||||
if let Ok(username) = Username::new(username_str.to_string())
|
||||
&& let Some(user) = self.user_repo.find_by_username(&username).await?
|
||||
{
|
||||
return Ok(SocialIdentity::Local(user.id().clone()));
|
||||
}
|
||||
}
|
||||
Ok(SocialIdentity::Remote {
|
||||
actor_url: handle.clone(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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) -> Result<(), DomainError> {
|
||||
let identity = self.resolve_target_identity(target).await?;
|
||||
|
||||
if let SocialIdentity::Local(ref target_id) = identity {
|
||||
if follower == target_id {
|
||||
return Err(DomainError::ValidationError(
|
||||
"Cannot follow yourself".into(),
|
||||
));
|
||||
}
|
||||
let follower_url = self.local_actor_url(follower);
|
||||
let target_url = self.local_actor_url(target_id);
|
||||
self.follow_command
|
||||
.add_follower(target_id.value(), &follower_url, FollowStatus::Pending)
|
||||
.await?;
|
||||
self.follow_command
|
||||
.add_follow(follower.value(), &target_url, FollowStatus::Pending)
|
||||
.await?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
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()))?;
|
||||
SocialIdentity::format_local_handle(user.username().value(), &self.base_url)
|
||||
}
|
||||
SocialIdentity::Remote { actor_url } => actor_url.clone(),
|
||||
},
|
||||
};
|
||||
self.ap_service
|
||||
.follow(follower.value(), &handle)
|
||||
.await
|
||||
.map_err(ap_err)
|
||||
}
|
||||
|
||||
async fn unfollow(
|
||||
&self,
|
||||
follower: &UserId,
|
||||
target: &SocialIdentity,
|
||||
) -> Result<(), DomainError> {
|
||||
let actor_url = self.actor_url_from_identity(target);
|
||||
match target {
|
||||
SocialIdentity::Local(target_id) => {
|
||||
let follower_url = self.local_actor_url(follower);
|
||||
self.follow_command
|
||||
.remove_follow(follower.value(), &actor_url)
|
||||
.await?;
|
||||
self.follow_command
|
||||
.remove_follower_record(target_id.value(), &follower_url)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
SocialIdentity::Remote { .. } => self
|
||||
.ap_service
|
||||
.unfollow(follower.value(), &actor_url)
|
||||
.await
|
||||
.map_err(ap_err),
|
||||
}
|
||||
}
|
||||
|
||||
async fn accept_follow(
|
||||
&self,
|
||||
owner: &UserId,
|
||||
requester: &SocialIdentity,
|
||||
) -> Result<(), DomainError> {
|
||||
let actor_url = self.actor_url_from_identity(requester);
|
||||
match requester {
|
||||
SocialIdentity::Local(requester_id) => {
|
||||
let owner_url = self.local_actor_url(owner);
|
||||
self.follow_command
|
||||
.update_follower_status(owner.value(), &actor_url, FollowStatus::Accepted)
|
||||
.await?;
|
||||
self.follow_command
|
||||
.update_follow_status(requester_id.value(), &owner_url, FollowStatus::Accepted)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
SocialIdentity::Remote { .. } => self
|
||||
.ap_service
|
||||
.accept_follower(owner.value(), &actor_url)
|
||||
.await
|
||||
.map_err(ap_err),
|
||||
}
|
||||
}
|
||||
|
||||
async fn reject_follow(
|
||||
&self,
|
||||
owner: &UserId,
|
||||
requester: &SocialIdentity,
|
||||
) -> Result<(), DomainError> {
|
||||
let actor_url = self.actor_url_from_identity(requester);
|
||||
match requester {
|
||||
SocialIdentity::Local(requester_id) => {
|
||||
let owner_url = self.local_actor_url(owner);
|
||||
self.follow_command
|
||||
.update_follower_status(owner.value(), &actor_url, FollowStatus::Rejected)
|
||||
.await?;
|
||||
self.follow_command
|
||||
.remove_follow(requester_id.value(), &owner_url)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
SocialIdentity::Remote { .. } => self
|
||||
.ap_service
|
||||
.reject_follower(owner.value(), &actor_url)
|
||||
.await
|
||||
.map_err(ap_err),
|
||||
}
|
||||
}
|
||||
|
||||
async fn remove_follower(
|
||||
&self,
|
||||
owner: &UserId,
|
||||
follower: &SocialIdentity,
|
||||
) -> Result<(), DomainError> {
|
||||
let actor_url = self.actor_url_from_identity(follower);
|
||||
match follower {
|
||||
SocialIdentity::Local(follower_id) => {
|
||||
let owner_url = self.local_actor_url(owner);
|
||||
self.follow_command
|
||||
.remove_follower_record(owner.value(), &actor_url)
|
||||
.await?;
|
||||
self.follow_command
|
||||
.remove_follow(follower_id.value(), &owner_url)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
SocialIdentity::Remote { .. } => self
|
||||
.ap_service
|
||||
.remove_follower(owner.value(), &actor_url)
|
||||
.await
|
||||
.map_err(ap_err),
|
||||
}
|
||||
}
|
||||
|
||||
async fn block(&self, blocker: &UserId, target: &SocialIdentity) -> Result<(), DomainError> {
|
||||
let actor_url = self.actor_url_from_identity(target);
|
||||
self.ap_service
|
||||
.block_actor(blocker.value(), &actor_url)
|
||||
.await
|
||||
.map_err(ap_err)
|
||||
}
|
||||
|
||||
async fn unblock(&self, blocker: &UserId, target: &SocialIdentity) -> Result<(), DomainError> {
|
||||
let actor_url = self.actor_url_from_identity(target);
|
||||
self.ap_service
|
||||
.unblock_actor(blocker.value(), &actor_url)
|
||||
.await
|
||||
.map_err(ap_err)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl SocialQuery for CompositeSocialAdapter {
|
||||
async fn get_following(&self, user: &UserId) -> Result<Vec<SocialActor>, DomainError> {
|
||||
self.follow_query
|
||||
.get_following(user.value(), &self.base_url)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn get_followers(&self, user: &UserId) -> Result<Vec<SocialActor>, DomainError> {
|
||||
self.follow_query
|
||||
.get_followers(user.value(), &self.base_url)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn get_pending_followers(&self, user: &UserId) -> Result<Vec<SocialActor>, DomainError> {
|
||||
self.follow_query
|
||||
.get_pending_followers(user.value(), &self.base_url)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn count_following(&self, user: &UserId) -> Result<usize, DomainError> {
|
||||
self.follow_query.count_following(user.value()).await
|
||||
}
|
||||
|
||||
async fn count_followers(&self, user: &UserId) -> Result<usize, DomainError> {
|
||||
self.follow_query.count_followers(user.value()).await
|
||||
}
|
||||
|
||||
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 = SocialIdentity::from_actor_url(&a.url, &self.base_url);
|
||||
SocialActor {
|
||||
identity,
|
||||
handle: a.handle,
|
||||
display_name: a.display_name,
|
||||
avatar_url: a.avatar_url,
|
||||
}
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn is_following(
|
||||
&self,
|
||||
follower: &UserId,
|
||||
target: &SocialIdentity,
|
||||
) -> Result<bool, DomainError> {
|
||||
let actor_url = self.actor_url_from_identity(target);
|
||||
self.follow_query
|
||||
.is_following(follower.value(), &actor_url)
|
||||
.await
|
||||
}
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn normalize_hashtag_strips_non_alphanumeric() {
|
||||
assert_eq!(normalize_hashtag("The Dark Knight"), "TheDarkKnight");
|
||||
assert_eq!(normalize_hashtag("Schindler's List"), "SchindlersList");
|
||||
assert_eq!(
|
||||
normalize_hashtag("2001: A Space Odyssey"),
|
||||
"2001ASpaceOdyssey"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn review_to_ap_object_includes_two_hashtags() {
|
||||
use chrono::NaiveDateTime;
|
||||
use domain::{
|
||||
models::{PersistedReview, Review, ReviewSource},
|
||||
value_objects::{MovieId, Rating, ReviewId, UserId},
|
||||
};
|
||||
|
||||
let review = Review::from_persistence(PersistedReview {
|
||||
id: ReviewId::generate(),
|
||||
movie_id: MovieId::from_uuid(uuid::Uuid::new_v4()),
|
||||
user_id: UserId::from_uuid(uuid::Uuid::new_v4()),
|
||||
rating: Rating::new(4).unwrap(),
|
||||
comment: None,
|
||||
watched_at: NaiveDateTime::parse_from_str("2024-01-01 00:00:00", "%Y-%m-%d %H:%M:%S")
|
||||
.unwrap(),
|
||||
created_at: NaiveDateTime::parse_from_str("2024-01-01 00:00:00", "%Y-%m-%d %H:%M:%S")
|
||||
.unwrap(),
|
||||
source: ReviewSource::Local,
|
||||
watch_medium: None,
|
||||
});
|
||||
let obj = review_to_ap_object(
|
||||
&review,
|
||||
ReviewApInput {
|
||||
ap_id: "https://example.com/reviews/1".parse().unwrap(),
|
||||
actor_url: "https://example.com/users/1".parse().unwrap(),
|
||||
movie_title: "Dune".to_string(),
|
||||
release_year: 2021,
|
||||
external_metadata_id: None,
|
||||
poster_url: None,
|
||||
base_url: "https://example.com".to_string(),
|
||||
},
|
||||
);
|
||||
assert_eq!(obj.tag.len(), 2);
|
||||
let names: Vec<&str> = obj.tag.iter().map(|t| t.name.as_str()).collect();
|
||||
assert!(names.contains(&"#MoviesDiary"));
|
||||
assert!(names.contains(&"#Dune"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn review_to_ap_object_has_public_addressing() {
|
||||
use chrono::NaiveDateTime;
|
||||
use domain::{
|
||||
models::{PersistedReview, Review, ReviewSource},
|
||||
value_objects::{MovieId, Rating, ReviewId, UserId},
|
||||
};
|
||||
|
||||
let review = Review::from_persistence(PersistedReview {
|
||||
id: ReviewId::generate(),
|
||||
movie_id: MovieId::from_uuid(uuid::Uuid::new_v4()),
|
||||
user_id: UserId::from_uuid(uuid::Uuid::new_v4()),
|
||||
rating: Rating::new(3).unwrap(),
|
||||
comment: None,
|
||||
watched_at: NaiveDateTime::parse_from_str("2024-06-01 00:00:00", "%Y-%m-%d %H:%M:%S")
|
||||
.unwrap(),
|
||||
created_at: NaiveDateTime::parse_from_str("2024-06-01 00:00:00", "%Y-%m-%d %H:%M:%S")
|
||||
.unwrap(),
|
||||
source: ReviewSource::Local,
|
||||
watch_medium: None,
|
||||
});
|
||||
let actor_url: url::Url = "https://example.com/users/abc".parse().unwrap();
|
||||
let obj = review_to_ap_object(
|
||||
&review,
|
||||
ReviewApInput {
|
||||
ap_id: "https://example.com/reviews/1".parse().unwrap(),
|
||||
actor_url: actor_url.clone(),
|
||||
movie_title: "Dune".to_string(),
|
||||
release_year: 2021,
|
||||
external_metadata_id: None,
|
||||
poster_url: None,
|
||||
base_url: "https://example.com".to_string(),
|
||||
},
|
||||
);
|
||||
assert_eq!(obj.to, vec!["https://www.w3.org/ns/activitystreams#Public"]);
|
||||
assert_eq!(obj.cc, vec!["https://example.com/users/abc/followers"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn watchlist_to_ap_object_has_public_addressing() {
|
||||
let actor_url: url::Url = "https://example.com/users/abc".parse().unwrap();
|
||||
let obj = watchlist_to_ap_object(WatchlistApInput {
|
||||
ap_id: "https://example.com/watchlist/1".parse().unwrap(),
|
||||
actor_url: actor_url.clone(),
|
||||
movie_title: "Alien".to_string(),
|
||||
release_year: 1979,
|
||||
external_metadata_id: None,
|
||||
poster_url: None,
|
||||
added_at: chrono::Utc::now(),
|
||||
base_url: "https://example.com".to_string(),
|
||||
});
|
||||
assert_eq!(obj.to, vec!["https://www.w3.org/ns/activitystreams#Public"]);
|
||||
assert_eq!(obj.cc, vec!["https://example.com/users/abc/followers"]);
|
||||
}
|
||||
@@ -12,17 +12,3 @@ pub fn review_url(base_url: &str, review_id: &ReviewId) -> Url {
|
||||
Url::parse(&format!("{}/reviews/{}", base_url, review_id.value()))
|
||||
.expect("base_url is always a valid URL prefix")
|
||||
}
|
||||
|
||||
pub fn goal_url(base_url: &str, user_id: uuid::Uuid, year: u16) -> Url {
|
||||
Url::parse(&format!("{}/users/{}/goals/{}", base_url, user_id, year))
|
||||
.expect("base_url is always a valid URL prefix")
|
||||
}
|
||||
|
||||
/// Builds the canonical watchlist entry URL: `{base_url}/users/{user_id}/watchlist/{movie_id}`
|
||||
pub fn watchlist_entry_url(base_url: &str, user_id: uuid::Uuid, movie_id: uuid::Uuid) -> Url {
|
||||
Url::parse(&format!(
|
||||
"{}/users/{}/watchlist/{}",
|
||||
base_url, user_id, movie_id
|
||||
))
|
||||
.expect("base_url is always a valid URL prefix")
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use activitypub_base::{ApUser, ApUserRepository};
|
||||
use async_trait::async_trait;
|
||||
use domain::{ports::UserRepository, value_objects::UserId};
|
||||
use k_ap::{ApProfileField, ApUser, ApUserRepository};
|
||||
use url::Url;
|
||||
|
||||
pub struct DomainUserRepoAdapter {
|
||||
@@ -16,42 +16,16 @@ impl DomainUserRepoAdapter {
|
||||
}
|
||||
|
||||
fn build_user(&self, u: &domain::models::User) -> ApUser {
|
||||
let avatar_url = u
|
||||
.avatar_path()
|
||||
.and_then(|p| Url::parse(&format!("{}/images/{}", self.base_url, p)).ok());
|
||||
let banner_url = u
|
||||
.banner_path()
|
||||
.and_then(|p| Url::parse(&format!("{}/images/{}", self.base_url, p)).ok());
|
||||
let avatar_url = u.avatar_path().and_then(|p| {
|
||||
Url::parse(&format!("{}/images/{}", self.base_url, p)).ok()
|
||||
});
|
||||
let profile_url = Url::parse(&format!("{}/u/{}", self.base_url, u.username().value())).ok();
|
||||
ApUser {
|
||||
id: u.id().value(),
|
||||
username: u.username().value().to_string(),
|
||||
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!(
|
||||
"{}/users/{}/featured",
|
||||
self.base_url,
|
||||
u.id().value()
|
||||
))
|
||||
.ok(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -60,29 +34,17 @@ impl DomainUserRepoAdapter {
|
||||
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 => return Ok(None),
|
||||
};
|
||||
Ok(Some(self.build_user(&user)))
|
||||
Ok(self.repo.find_by_id(&user_id).await?.as_ref().map(|u| self.build_user(u)))
|
||||
}
|
||||
|
||||
async fn find_by_username(&self, username: &str) -> anyhow::Result<Option<ApUser>> {
|
||||
use domain::value_objects::Username;
|
||||
let uname =
|
||||
Username::new(username.to_string()).map_err(|e| anyhow::anyhow!(e.to_string()))?;
|
||||
let user = match self.repo.find_by_username(&uname).await? {
|
||||
Some(u) => u,
|
||||
None => return Ok(None),
|
||||
};
|
||||
Ok(Some(self.build_user(&user)))
|
||||
let uname = Username::new(username.to_string()).map_err(|e| anyhow::anyhow!(e.to_string()))?;
|
||||
Ok(self.repo.find_by_username(&uname).await?.as_ref().map(|u| self.build_user(u)))
|
||||
}
|
||||
|
||||
async fn count_users(&self) -> anyhow::Result<usize> {
|
||||
Ok(self
|
||||
.repo
|
||||
.list_with_stats()
|
||||
.await
|
||||
Ok(self.repo.list_with_stats().await
|
||||
.map_err(|e| anyhow::anyhow!(e.to_string()))?
|
||||
.len())
|
||||
}
|
||||
|
||||
@@ -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::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 base_url: String,
|
||||
}
|
||||
|
||||
#[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.base_url, 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.base_url, 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| format!("{}/images/{}", self.base_url, 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.base_url.clone(),
|
||||
});
|
||||
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(())
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
[package]
|
||||
name = "adapter-common"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
domain = { workspace = true }
|
||||
sqlx = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
@@ -1,220 +0,0 @@
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::{
|
||||
Movie, MovieStats, MovieSummary, PersistedReview, Review, ReviewSource, UserSummary,
|
||||
WatchlistEntry, WatchlistWithMovie,
|
||||
},
|
||||
value_objects::{
|
||||
Comment, Email, ExternalMetadataId, MovieId, MovieTitle, PosterPath, Rating, ReleaseYear,
|
||||
ReviewId, UserId, Username, WatchlistEntryId,
|
||||
},
|
||||
};
|
||||
|
||||
/// Map a [`sqlx::Error`] to a [`DomainError::InfrastructureError`], logging the
|
||||
/// underlying database error at `error` level.
|
||||
pub fn map_sqlx_error(e: sqlx::Error) -> DomainError {
|
||||
tracing::error!("Database error: {:?}", e);
|
||||
DomainError::InfrastructureError("Database operation failed".into())
|
||||
}
|
||||
|
||||
/// Parse a string as a UUID, returning a [`DomainError`] on failure.
|
||||
pub fn parse_uuid(s: &str) -> Result<uuid::Uuid, DomainError> {
|
||||
uuid::Uuid::parse_str(s)
|
||||
.map_err(|e| DomainError::InfrastructureError(format!("Invalid UUID '{}': {}", s, e)))
|
||||
}
|
||||
|
||||
/// Parse a `%Y-%m-%d %H:%M:%S` string into a [`chrono::NaiveDateTime`].
|
||||
pub fn parse_datetime(s: &str) -> Result<chrono::NaiveDateTime, DomainError> {
|
||||
chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S")
|
||||
.map_err(|e| DomainError::InfrastructureError(format!("Invalid datetime '{}': {}", s, e)))
|
||||
}
|
||||
|
||||
/// Format a [`chrono::NaiveDateTime`] as `%Y-%m-%d %H:%M:%S`.
|
||||
pub fn datetime_to_str(dt: &chrono::NaiveDateTime) -> String {
|
||||
dt.format("%Y-%m-%d %H:%M:%S").to_string()
|
||||
}
|
||||
|
||||
/// Convert a `YYYY-MM` string into a human-readable label like `Jan '24`.
|
||||
pub fn format_year_month(ym: &str) -> String {
|
||||
let parts: Vec<&str> = ym.splitn(2, '-').collect();
|
||||
if parts.len() != 2 {
|
||||
return ym.to_string();
|
||||
}
|
||||
let year = parts[0].get(2..).unwrap_or(parts[0]);
|
||||
let month = match parts[1] {
|
||||
"01" => "Jan",
|
||||
"02" => "Feb",
|
||||
"03" => "Mar",
|
||||
"04" => "Apr",
|
||||
"05" => "May",
|
||||
"06" => "Jun",
|
||||
"07" => "Jul",
|
||||
"08" => "Aug",
|
||||
"09" => "Sep",
|
||||
"10" => "Oct",
|
||||
"11" => "Nov",
|
||||
"12" => "Dec",
|
||||
_ => parts[1],
|
||||
};
|
||||
format!("{} '{}", month, year)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared row-to-domain conversion functions
|
||||
//
|
||||
// Each database adapter keeps its own `FromRow` structs (sqlite vs postgres
|
||||
// derive different impls) but the conversion from parsed row fields into
|
||||
// domain types is identical. These functions capture that shared logic so
|
||||
// each adapter's `into_domain()` becomes a one-liner delegation.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Convert raw movie row fields into a [`Movie`] domain object.
|
||||
pub fn movie_row_to_domain(
|
||||
id: String,
|
||||
external_metadata_id: Option<String>,
|
||||
title: String,
|
||||
release_year: i64,
|
||||
director: Option<String>,
|
||||
poster_path: Option<String>,
|
||||
) -> Result<Movie, DomainError> {
|
||||
let id = MovieId::from_uuid(parse_uuid(&id)?);
|
||||
let external_metadata_id = external_metadata_id
|
||||
.map(ExternalMetadataId::new)
|
||||
.transpose()?;
|
||||
let title = MovieTitle::new(title)?;
|
||||
let release_year = ReleaseYear::new(release_year as u16)?;
|
||||
let poster_path = poster_path.map(PosterPath::new).transpose()?;
|
||||
Ok(Movie::from_persistence(
|
||||
id,
|
||||
external_metadata_id,
|
||||
title,
|
||||
release_year,
|
||||
director,
|
||||
poster_path,
|
||||
))
|
||||
}
|
||||
|
||||
/// Convert raw review row fields into a [`Review`] domain object.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn review_row_to_domain(
|
||||
id: String,
|
||||
movie_id: String,
|
||||
user_id: String,
|
||||
rating: i64,
|
||||
comment: Option<String>,
|
||||
watched_at: String,
|
||||
created_at: String,
|
||||
remote_actor_url: Option<String>,
|
||||
watch_medium: Option<String>,
|
||||
) -> Result<Review, DomainError> {
|
||||
let id = ReviewId::from_uuid(parse_uuid(&id)?);
|
||||
let movie_id = MovieId::from_uuid(parse_uuid(&movie_id)?);
|
||||
let user_id = UserId::from_uuid(parse_uuid(&user_id)?);
|
||||
let rating = Rating::new(rating as u8)?;
|
||||
let comment = comment.map(Comment::new).transpose()?;
|
||||
let watched_at = parse_datetime(&watched_at)?;
|
||||
let created_at = parse_datetime(&created_at)?;
|
||||
let source = match remote_actor_url {
|
||||
None => ReviewSource::Local,
|
||||
Some(url) => ReviewSource::Remote { actor_url: url },
|
||||
};
|
||||
let watch_medium = watch_medium.map(|s| s.parse()).transpose()?;
|
||||
Ok(Review::from_persistence(PersistedReview {
|
||||
id,
|
||||
movie_id,
|
||||
user_id,
|
||||
rating,
|
||||
comment,
|
||||
watched_at,
|
||||
created_at,
|
||||
source,
|
||||
watch_medium,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Assemble a [`MovieSummary`] from an already-converted [`Movie`] and extra
|
||||
/// metadata fields. The caller is responsible for converting genres into a
|
||||
/// `Vec<String>` (sqlite splits a comma-separated string, postgres receives a
|
||||
/// `Vec` directly).
|
||||
pub fn movie_summary_to_domain(
|
||||
movie: Movie,
|
||||
genres: Vec<String>,
|
||||
runtime_minutes: Option<i64>,
|
||||
original_language: Option<String>,
|
||||
overview: Option<String>,
|
||||
collection_name: Option<String>,
|
||||
) -> MovieSummary {
|
||||
MovieSummary {
|
||||
movie,
|
||||
genres,
|
||||
runtime_minutes: runtime_minutes.map(|v| v as u32),
|
||||
original_language,
|
||||
overview,
|
||||
collection_name,
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert raw aggregate stats into a [`MovieStats`] domain object.
|
||||
pub fn movie_stats_to_domain(
|
||||
total_count: i64,
|
||||
avg_rating: Option<f64>,
|
||||
federated_count: i64,
|
||||
rating_histogram: [i64; 5],
|
||||
) -> MovieStats {
|
||||
MovieStats {
|
||||
total_count: total_count as u64,
|
||||
avg_rating,
|
||||
federated_count: federated_count as u64,
|
||||
rating_histogram: [
|
||||
rating_histogram[0] as u64,
|
||||
rating_histogram[1] as u64,
|
||||
rating_histogram[2] as u64,
|
||||
rating_histogram[3] as u64,
|
||||
rating_histogram[4] as u64,
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert raw user summary row fields into a [`UserSummary`] domain object.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn user_summary_to_domain(
|
||||
id: String,
|
||||
email: String,
|
||||
username: String,
|
||||
display_name: Option<String>,
|
||||
total_movies: i64,
|
||||
avg_rating: Option<f64>,
|
||||
avatar_path: Option<String>,
|
||||
) -> Result<UserSummary, DomainError> {
|
||||
Ok(UserSummary::new(
|
||||
UserId::from_uuid(parse_uuid(&id)?),
|
||||
Email::new(email)?,
|
||||
Username::new(username)?,
|
||||
display_name,
|
||||
total_movies,
|
||||
avg_rating,
|
||||
avatar_path,
|
||||
))
|
||||
}
|
||||
|
||||
/// Convert raw watchlist entry fields into a [`WatchlistEntry`] domain object.
|
||||
pub fn watchlist_entry_to_domain(
|
||||
id: String,
|
||||
user_id: String,
|
||||
movie_id: String,
|
||||
added_at: String,
|
||||
) -> Result<WatchlistEntry, DomainError> {
|
||||
Ok(WatchlistEntry {
|
||||
id: WatchlistEntryId::from_uuid(parse_uuid(&id)?),
|
||||
user_id: UserId::from_uuid(parse_uuid(&user_id)?),
|
||||
movie_id: MovieId::from_uuid(parse_uuid(&movie_id)?),
|
||||
added_at: parse_datetime(&added_at)?,
|
||||
})
|
||||
}
|
||||
|
||||
/// Convert raw watchlist+movie row fields into a [`WatchlistWithMovie`].
|
||||
///
|
||||
/// Takes the watchlist entry fields and a pre-converted [`Movie`].
|
||||
pub fn watchlist_with_movie_to_domain(entry: WatchlistEntry, movie: Movie) -> WatchlistWithMovie {
|
||||
WatchlistWithMovie { entry, movie }
|
||||
}
|
||||
@@ -11,8 +11,7 @@ use uuid::Uuid;
|
||||
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::GeneratedToken,
|
||||
ports::{AuthService, PasswordHasher},
|
||||
ports::{AuthService, GeneratedToken, PasswordHasher},
|
||||
value_objects::{PasswordHash, UserId},
|
||||
};
|
||||
|
||||
@@ -31,7 +30,7 @@ impl AuthConfig {
|
||||
let ttl_seconds = std::env::var("JWT_TTL_SECONDS")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(900u64);
|
||||
.unwrap_or(86400u64);
|
||||
Ok(Self {
|
||||
secret,
|
||||
ttl_seconds,
|
||||
|
||||
@@ -8,4 +8,5 @@ domain = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
@@ -2,11 +2,7 @@ use chrono::NaiveDateTime;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
events::DomainEvent,
|
||||
models::{ExternalPersonId, PersonId},
|
||||
value_objects::{
|
||||
ExternalMetadataId, GoalId, MovieId, PosterPath, Rating, ReviewId, SocialIdentity, UserId,
|
||||
WrapUpId,
|
||||
},
|
||||
value_objects::{ExternalMetadataId, MovieId, PosterPath, Rating, ReviewId, UserId},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
@@ -43,115 +39,6 @@ pub enum EventPayload {
|
||||
review_id: String,
|
||||
user_id: String,
|
||||
},
|
||||
MovieEnrichmentRequested {
|
||||
movie_id: String,
|
||||
external_metadata_id: String,
|
||||
},
|
||||
ImageStored {
|
||||
key: String,
|
||||
},
|
||||
WatchlistEntryAdded {
|
||||
user_id: String,
|
||||
movie_id: String,
|
||||
movie_title: String,
|
||||
release_year: u16,
|
||||
external_metadata_id: Option<String>,
|
||||
added_at: i64,
|
||||
},
|
||||
WatchlistEntryRemoved {
|
||||
user_id: String,
|
||||
movie_id: String,
|
||||
},
|
||||
FollowRequested {
|
||||
follower_id: String,
|
||||
target_kind: String,
|
||||
target_id: String,
|
||||
},
|
||||
FollowAccepted {
|
||||
owner_id: String,
|
||||
requester_kind: String,
|
||||
requester_id: String,
|
||||
},
|
||||
FollowRejected {
|
||||
owner_id: String,
|
||||
requester_kind: String,
|
||||
requester_id: String,
|
||||
},
|
||||
Unfollowed {
|
||||
follower_id: String,
|
||||
target_kind: String,
|
||||
target_id: String,
|
||||
},
|
||||
FollowerRemoved {
|
||||
owner_id: String,
|
||||
follower_kind: String,
|
||||
follower_id: String,
|
||||
},
|
||||
ActorBlocked {
|
||||
blocker_id: String,
|
||||
target_kind: String,
|
||||
target_id: String,
|
||||
},
|
||||
ActorUnblocked {
|
||||
blocker_id: String,
|
||||
target_kind: String,
|
||||
target_id: String,
|
||||
},
|
||||
BackfillFollower {
|
||||
owner_user_id: String,
|
||||
follower_inbox_url: String,
|
||||
},
|
||||
FederationDeliveryRequested {
|
||||
inbox_url: String,
|
||||
activity_json: serde_json::Value,
|
||||
signing_actor_id: String,
|
||||
},
|
||||
WatchEventIngested {
|
||||
user_id: String,
|
||||
title: String,
|
||||
source: String,
|
||||
},
|
||||
WrapUpRequested {
|
||||
wrapup_id: String,
|
||||
user_id: Option<String>,
|
||||
start_date: String,
|
||||
end_date: String,
|
||||
},
|
||||
WrapUpCompleted {
|
||||
wrapup_id: String,
|
||||
},
|
||||
SearchReindexRequested,
|
||||
PosterSynced {
|
||||
movie_id: String,
|
||||
},
|
||||
GoalCreated {
|
||||
goal_id: String,
|
||||
user_id: String,
|
||||
year: u16,
|
||||
target_count: u32,
|
||||
},
|
||||
GoalUpdated {
|
||||
goal_id: String,
|
||||
user_id: String,
|
||||
year: u16,
|
||||
target_count: u32,
|
||||
},
|
||||
GoalDeleted {
|
||||
goal_id: String,
|
||||
user_id: String,
|
||||
year: u16,
|
||||
},
|
||||
PersonEnrichmentRequested {
|
||||
person_id: String,
|
||||
external_person_id: String,
|
||||
},
|
||||
UserDeleted {
|
||||
user_id: String,
|
||||
},
|
||||
UserAccountMoved {
|
||||
user_id: String,
|
||||
new_actor_url: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl EventPayload {
|
||||
@@ -163,74 +50,13 @@ impl EventPayload {
|
||||
EventPayload::MovieDeleted { .. } => "MovieDeleted",
|
||||
EventPayload::UserUpdated { .. } => "UserUpdated",
|
||||
EventPayload::ReviewDeleted { .. } => "ReviewDeleted",
|
||||
EventPayload::MovieEnrichmentRequested { .. } => "MovieEnrichmentRequested",
|
||||
EventPayload::ImageStored { .. } => "ImageStored",
|
||||
EventPayload::WatchlistEntryAdded { .. } => "WatchlistEntryAdded",
|
||||
EventPayload::WatchlistEntryRemoved { .. } => "WatchlistEntryRemoved",
|
||||
EventPayload::FollowRequested { .. } => "FollowRequested",
|
||||
EventPayload::FollowAccepted { .. } => "FollowAccepted",
|
||||
EventPayload::FollowRejected { .. } => "FollowRejected",
|
||||
EventPayload::Unfollowed { .. } => "Unfollowed",
|
||||
EventPayload::FollowerRemoved { .. } => "FollowerRemoved",
|
||||
EventPayload::ActorBlocked { .. } => "ActorBlocked",
|
||||
EventPayload::ActorUnblocked { .. } => "ActorUnblocked",
|
||||
EventPayload::BackfillFollower { .. } => "BackfillFollower",
|
||||
EventPayload::FederationDeliveryRequested { .. } => "FederationDeliveryRequested",
|
||||
EventPayload::WatchEventIngested { .. } => "WatchEventIngested",
|
||||
EventPayload::WrapUpRequested { .. } => "WrapUpRequested",
|
||||
EventPayload::WrapUpCompleted { .. } => "WrapUpCompleted",
|
||||
EventPayload::SearchReindexRequested => "SearchReindexRequested",
|
||||
EventPayload::PosterSynced { .. } => "PosterSynced",
|
||||
EventPayload::GoalCreated { .. } => "GoalCreated",
|
||||
EventPayload::GoalUpdated { .. } => "GoalUpdated",
|
||||
EventPayload::GoalDeleted { .. } => "GoalDeleted",
|
||||
EventPayload::PersonEnrichmentRequested { .. } => "PersonEnrichmentRequested",
|
||||
EventPayload::UserDeleted { .. } => "UserDeleted",
|
||||
EventPayload::UserAccountMoved { .. } => "UserAccountMoved",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_uuid(s: &str, field: &str) -> Result<Uuid, DomainError> {
|
||||
Uuid::parse_str(s).map_err(|e| DomainError::InfrastructureError(format!("{field}: {e}")))
|
||||
}
|
||||
|
||||
fn identity_to_payload(id: &SocialIdentity) -> (String, String) {
|
||||
match id {
|
||||
SocialIdentity::Local(uid) => ("local".into(), uid.value().to_string()),
|
||||
SocialIdentity::Remote { actor_url } => ("remote".into(), actor_url.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
fn follow_target_to_payload(target: &domain::value_objects::FollowTarget) -> (String, String) {
|
||||
match target {
|
||||
domain::value_objects::FollowTarget::Identity(id) => identity_to_payload(id),
|
||||
domain::value_objects::FollowTarget::Handle(h) => ("handle".into(), h.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
fn payload_to_identity(kind: &str, id: String) -> Result<SocialIdentity, DomainError> {
|
||||
match kind {
|
||||
"local" => Ok(SocialIdentity::Local(UserId::from_uuid(parse_uuid(
|
||||
&id, "user_id",
|
||||
)?))),
|
||||
"remote" => Ok(SocialIdentity::Remote { actor_url: id }),
|
||||
other => Err(DomainError::InfrastructureError(format!(
|
||||
"unknown identity kind: {other}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
fn payload_to_follow_target(
|
||||
kind: &str,
|
||||
id: String,
|
||||
) -> Result<domain::value_objects::FollowTarget, DomainError> {
|
||||
match kind {
|
||||
"handle" => Ok(domain::value_objects::FollowTarget::Handle(id)),
|
||||
other => Ok(domain::value_objects::FollowTarget::Identity(
|
||||
payload_to_identity(other, id)?,
|
||||
)),
|
||||
}
|
||||
Uuid::parse_str(s)
|
||||
.map_err(|e| DomainError::InfrastructureError(format!("{field}: {e}")))
|
||||
}
|
||||
|
||||
fn parse_ts(ts: i64) -> Result<NaiveDateTime, DomainError> {
|
||||
@@ -242,43 +68,31 @@ fn parse_ts(ts: i64) -> Result<NaiveDateTime, DomainError> {
|
||||
impl From<&DomainEvent> for EventPayload {
|
||||
fn from(event: &DomainEvent) -> Self {
|
||||
match event {
|
||||
DomainEvent::ReviewLogged {
|
||||
review_id,
|
||||
movie_id,
|
||||
user_id,
|
||||
rating,
|
||||
watched_at,
|
||||
} => EventPayload::ReviewLogged {
|
||||
review_id: review_id.value().to_string(),
|
||||
movie_id: movie_id.value().to_string(),
|
||||
user_id: user_id.value().to_string(),
|
||||
rating: rating.value(),
|
||||
watched_at: watched_at.and_utc().timestamp(),
|
||||
},
|
||||
DomainEvent::ReviewUpdated {
|
||||
review_id,
|
||||
movie_id,
|
||||
user_id,
|
||||
rating,
|
||||
watched_at,
|
||||
} => EventPayload::ReviewUpdated {
|
||||
review_id: review_id.value().to_string(),
|
||||
movie_id: movie_id.value().to_string(),
|
||||
user_id: user_id.value().to_string(),
|
||||
rating: rating.value(),
|
||||
watched_at: watched_at.and_utc().timestamp(),
|
||||
},
|
||||
DomainEvent::MovieDiscovered {
|
||||
movie_id,
|
||||
external_metadata_id,
|
||||
} => EventPayload::MovieDiscovered {
|
||||
movie_id: movie_id.value().to_string(),
|
||||
external_metadata_id: external_metadata_id.value().to_owned(),
|
||||
},
|
||||
DomainEvent::MovieDeleted {
|
||||
movie_id,
|
||||
poster_path,
|
||||
} => EventPayload::MovieDeleted {
|
||||
DomainEvent::ReviewLogged { review_id, movie_id, user_id, rating, watched_at } => {
|
||||
EventPayload::ReviewLogged {
|
||||
review_id: review_id.value().to_string(),
|
||||
movie_id: movie_id.value().to_string(),
|
||||
user_id: user_id.value().to_string(),
|
||||
rating: rating.value(),
|
||||
watched_at: watched_at.and_utc().timestamp(),
|
||||
}
|
||||
}
|
||||
DomainEvent::ReviewUpdated { review_id, movie_id, user_id, rating, watched_at } => {
|
||||
EventPayload::ReviewUpdated {
|
||||
review_id: review_id.value().to_string(),
|
||||
movie_id: movie_id.value().to_string(),
|
||||
user_id: user_id.value().to_string(),
|
||||
rating: rating.value(),
|
||||
watched_at: watched_at.and_utc().timestamp(),
|
||||
}
|
||||
}
|
||||
DomainEvent::MovieDiscovered { movie_id, external_metadata_id } => {
|
||||
EventPayload::MovieDiscovered {
|
||||
movie_id: movie_id.value().to_string(),
|
||||
external_metadata_id: external_metadata_id.value().to_owned(),
|
||||
}
|
||||
}
|
||||
DomainEvent::MovieDeleted { movie_id, poster_path } => EventPayload::MovieDeleted {
|
||||
movie_id: movie_id.value().to_string(),
|
||||
poster_path: poster_path.as_ref().map(|p| p.value().to_string()),
|
||||
},
|
||||
@@ -289,182 +103,6 @@ impl From<&DomainEvent> for EventPayload {
|
||||
review_id: review_id.value().to_string(),
|
||||
user_id: user_id.value().to_string(),
|
||||
},
|
||||
DomainEvent::MovieEnrichmentRequested {
|
||||
movie_id,
|
||||
external_metadata_id,
|
||||
} => EventPayload::MovieEnrichmentRequested {
|
||||
movie_id: movie_id.value().to_string(),
|
||||
external_metadata_id: external_metadata_id.value().to_string(),
|
||||
},
|
||||
DomainEvent::ImageStored { key } => EventPayload::ImageStored { key: key.clone() },
|
||||
DomainEvent::WatchlistEntryAdded {
|
||||
user_id,
|
||||
movie_id,
|
||||
movie_title,
|
||||
release_year,
|
||||
external_metadata_id,
|
||||
added_at,
|
||||
} => EventPayload::WatchlistEntryAdded {
|
||||
user_id: user_id.value().to_string(),
|
||||
movie_id: movie_id.value().to_string(),
|
||||
movie_title: movie_title.clone(),
|
||||
release_year: *release_year,
|
||||
external_metadata_id: external_metadata_id.clone(),
|
||||
added_at: added_at.and_utc().timestamp(),
|
||||
},
|
||||
DomainEvent::WatchlistEntryRemoved { user_id, movie_id } => {
|
||||
EventPayload::WatchlistEntryRemoved {
|
||||
user_id: user_id.value().to_string(),
|
||||
movie_id: movie_id.value().to_string(),
|
||||
}
|
||||
}
|
||||
DomainEvent::FollowRequested { follower, target } => {
|
||||
let (kind, id) = follow_target_to_payload(target);
|
||||
EventPayload::FollowRequested {
|
||||
follower_id: follower.value().to_string(),
|
||||
target_kind: kind,
|
||||
target_id: id,
|
||||
}
|
||||
}
|
||||
DomainEvent::FollowAccepted { owner, requester } => {
|
||||
let (kind, id) = identity_to_payload(requester);
|
||||
EventPayload::FollowAccepted {
|
||||
owner_id: owner.value().to_string(),
|
||||
requester_kind: kind,
|
||||
requester_id: id,
|
||||
}
|
||||
}
|
||||
DomainEvent::FollowRejected { owner, requester } => {
|
||||
let (kind, id) = identity_to_payload(requester);
|
||||
EventPayload::FollowRejected {
|
||||
owner_id: owner.value().to_string(),
|
||||
requester_kind: kind,
|
||||
requester_id: id,
|
||||
}
|
||||
}
|
||||
DomainEvent::Unfollowed { follower, target } => {
|
||||
let (kind, id) = identity_to_payload(target);
|
||||
EventPayload::Unfollowed {
|
||||
follower_id: follower.value().to_string(),
|
||||
target_kind: kind,
|
||||
target_id: id,
|
||||
}
|
||||
}
|
||||
DomainEvent::FollowerRemoved { owner, follower } => {
|
||||
let (kind, id) = identity_to_payload(follower);
|
||||
EventPayload::FollowerRemoved {
|
||||
owner_id: owner.value().to_string(),
|
||||
follower_kind: kind,
|
||||
follower_id: id,
|
||||
}
|
||||
}
|
||||
DomainEvent::ActorBlocked { blocker, target } => {
|
||||
let (kind, id) = identity_to_payload(target);
|
||||
EventPayload::ActorBlocked {
|
||||
blocker_id: blocker.value().to_string(),
|
||||
target_kind: kind,
|
||||
target_id: id,
|
||||
}
|
||||
}
|
||||
DomainEvent::ActorUnblocked { blocker, target } => {
|
||||
let (kind, id) = identity_to_payload(target);
|
||||
EventPayload::ActorUnblocked {
|
||||
blocker_id: blocker.value().to_string(),
|
||||
target_kind: kind,
|
||||
target_id: id,
|
||||
}
|
||||
}
|
||||
DomainEvent::BackfillFollower {
|
||||
owner_user_id,
|
||||
follower_inbox_url,
|
||||
} => EventPayload::BackfillFollower {
|
||||
owner_user_id: owner_user_id.value().to_string(),
|
||||
follower_inbox_url: follower_inbox_url.clone(),
|
||||
},
|
||||
DomainEvent::FederationDeliveryRequested {
|
||||
inbox_url,
|
||||
activity_json,
|
||||
signing_actor_id,
|
||||
} => EventPayload::FederationDeliveryRequested {
|
||||
inbox_url: inbox_url.clone(),
|
||||
activity_json: activity_json.clone(),
|
||||
signing_actor_id: signing_actor_id.to_string(),
|
||||
},
|
||||
DomainEvent::WatchEventIngested {
|
||||
user_id,
|
||||
title,
|
||||
source,
|
||||
} => EventPayload::WatchEventIngested {
|
||||
user_id: user_id.value().to_string(),
|
||||
title: title.clone(),
|
||||
source: source.clone(),
|
||||
},
|
||||
DomainEvent::WrapUpRequested {
|
||||
wrapup_id,
|
||||
user_id,
|
||||
start_date,
|
||||
end_date,
|
||||
} => EventPayload::WrapUpRequested {
|
||||
wrapup_id: wrapup_id.value().to_string(),
|
||||
user_id: user_id.as_ref().map(|u| u.value().to_string()),
|
||||
start_date: start_date.to_string(),
|
||||
end_date: end_date.to_string(),
|
||||
},
|
||||
DomainEvent::WrapUpCompleted { wrapup_id } => EventPayload::WrapUpCompleted {
|
||||
wrapup_id: wrapup_id.value().to_string(),
|
||||
},
|
||||
DomainEvent::SearchReindexRequested => EventPayload::SearchReindexRequested,
|
||||
DomainEvent::PosterSynced { movie_id } => EventPayload::PosterSynced {
|
||||
movie_id: movie_id.value().to_string(),
|
||||
},
|
||||
DomainEvent::GoalCreated {
|
||||
goal_id,
|
||||
user_id,
|
||||
year,
|
||||
target_count,
|
||||
} => EventPayload::GoalCreated {
|
||||
goal_id: goal_id.value().to_string(),
|
||||
user_id: user_id.value().to_string(),
|
||||
year: *year,
|
||||
target_count: *target_count,
|
||||
},
|
||||
DomainEvent::GoalUpdated {
|
||||
goal_id,
|
||||
user_id,
|
||||
year,
|
||||
target_count,
|
||||
} => EventPayload::GoalUpdated {
|
||||
goal_id: goal_id.value().to_string(),
|
||||
user_id: user_id.value().to_string(),
|
||||
year: *year,
|
||||
target_count: *target_count,
|
||||
},
|
||||
DomainEvent::GoalDeleted {
|
||||
goal_id,
|
||||
user_id,
|
||||
year,
|
||||
} => EventPayload::GoalDeleted {
|
||||
goal_id: goal_id.value().to_string(),
|
||||
user_id: user_id.value().to_string(),
|
||||
year: *year,
|
||||
},
|
||||
DomainEvent::PersonEnrichmentRequested {
|
||||
person_id,
|
||||
external_person_id,
|
||||
} => EventPayload::PersonEnrichmentRequested {
|
||||
person_id: person_id.value().to_string(),
|
||||
external_person_id: external_person_id.value().to_string(),
|
||||
},
|
||||
DomainEvent::UserDeleted { user_id } => EventPayload::UserDeleted {
|
||||
user_id: user_id.value().to_string(),
|
||||
},
|
||||
DomainEvent::UserAccountMoved {
|
||||
user_id,
|
||||
new_actor_url,
|
||||
} => EventPayload::UserAccountMoved {
|
||||
user_id: user_id.value().to_string(),
|
||||
new_actor_url: new_actor_url.clone(),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -473,252 +111,123 @@ impl TryFrom<EventPayload> for DomainEvent {
|
||||
type Error = DomainError;
|
||||
fn try_from(payload: EventPayload) -> Result<Self, DomainError> {
|
||||
match payload {
|
||||
EventPayload::ReviewLogged {
|
||||
review_id,
|
||||
movie_id,
|
||||
user_id,
|
||||
rating,
|
||||
watched_at,
|
||||
} => Ok(DomainEvent::ReviewLogged {
|
||||
review_id: ReviewId::from_uuid(parse_uuid(&review_id, "review_id")?),
|
||||
movie_id: MovieId::from_uuid(parse_uuid(&movie_id, "movie_id")?),
|
||||
user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?),
|
||||
rating: Rating::new(rating)?,
|
||||
watched_at: parse_ts(watched_at)?,
|
||||
}),
|
||||
EventPayload::ReviewUpdated {
|
||||
review_id,
|
||||
movie_id,
|
||||
user_id,
|
||||
rating,
|
||||
watched_at,
|
||||
} => Ok(DomainEvent::ReviewUpdated {
|
||||
review_id: ReviewId::from_uuid(parse_uuid(&review_id, "review_id")?),
|
||||
movie_id: MovieId::from_uuid(parse_uuid(&movie_id, "movie_id")?),
|
||||
user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?),
|
||||
rating: Rating::new(rating)?,
|
||||
watched_at: parse_ts(watched_at)?,
|
||||
}),
|
||||
EventPayload::MovieDiscovered {
|
||||
movie_id,
|
||||
external_metadata_id,
|
||||
} => Ok(DomainEvent::MovieDiscovered {
|
||||
movie_id: MovieId::from_uuid(parse_uuid(&movie_id, "movie_id")?),
|
||||
external_metadata_id: ExternalMetadataId::new(external_metadata_id)?,
|
||||
}),
|
||||
EventPayload::MovieDeleted {
|
||||
movie_id,
|
||||
poster_path,
|
||||
} => {
|
||||
EventPayload::ReviewLogged { review_id, movie_id, user_id, rating, watched_at } => {
|
||||
Ok(DomainEvent::ReviewLogged {
|
||||
review_id: ReviewId::from_uuid(parse_uuid(&review_id, "review_id")?),
|
||||
movie_id: MovieId::from_uuid(parse_uuid(&movie_id, "movie_id")?),
|
||||
user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?),
|
||||
rating: Rating::new(rating)?,
|
||||
watched_at: parse_ts(watched_at)?,
|
||||
})
|
||||
}
|
||||
EventPayload::ReviewUpdated { review_id, movie_id, user_id, rating, watched_at } => {
|
||||
Ok(DomainEvent::ReviewUpdated {
|
||||
review_id: ReviewId::from_uuid(parse_uuid(&review_id, "review_id")?),
|
||||
movie_id: MovieId::from_uuid(parse_uuid(&movie_id, "movie_id")?),
|
||||
user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?),
|
||||
rating: Rating::new(rating)?,
|
||||
watched_at: parse_ts(watched_at)?,
|
||||
})
|
||||
}
|
||||
EventPayload::MovieDiscovered { movie_id, external_metadata_id } => {
|
||||
Ok(DomainEvent::MovieDiscovered {
|
||||
movie_id: MovieId::from_uuid(parse_uuid(&movie_id, "movie_id")?),
|
||||
external_metadata_id: ExternalMetadataId::new(external_metadata_id)?,
|
||||
})
|
||||
}
|
||||
EventPayload::MovieDeleted { movie_id, poster_path } => {
|
||||
let movie_id = MovieId::from_uuid(parse_uuid(&movie_id, "movie_id")?);
|
||||
let poster_path = poster_path
|
||||
.map(PosterPath::new)
|
||||
.map(|p| PosterPath::new(p))
|
||||
.transpose()
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
Ok(DomainEvent::MovieDeleted {
|
||||
movie_id,
|
||||
poster_path,
|
||||
})
|
||||
Ok(DomainEvent::MovieDeleted { movie_id, poster_path })
|
||||
}
|
||||
EventPayload::UserUpdated { user_id } => Ok(DomainEvent::UserUpdated {
|
||||
user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?),
|
||||
}),
|
||||
EventPayload::ReviewDeleted { review_id, user_id } => Ok(DomainEvent::ReviewDeleted {
|
||||
review_id: ReviewId::from_uuid(parse_uuid(&review_id, "review_id")?),
|
||||
user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?),
|
||||
}),
|
||||
EventPayload::MovieEnrichmentRequested {
|
||||
movie_id,
|
||||
external_metadata_id,
|
||||
} => Ok(DomainEvent::MovieEnrichmentRequested {
|
||||
movie_id: MovieId::from_uuid(parse_uuid(&movie_id, "movie_id")?),
|
||||
external_metadata_id: ExternalMetadataId::new(external_metadata_id)
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?,
|
||||
}),
|
||||
EventPayload::ImageStored { key } => Ok(DomainEvent::ImageStored { key }),
|
||||
EventPayload::WatchlistEntryAdded {
|
||||
user_id,
|
||||
movie_id,
|
||||
movie_title,
|
||||
release_year,
|
||||
external_metadata_id,
|
||||
added_at,
|
||||
} => Ok(DomainEvent::WatchlistEntryAdded {
|
||||
user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?),
|
||||
movie_id: MovieId::from_uuid(parse_uuid(&movie_id, "movie_id")?),
|
||||
movie_title,
|
||||
release_year,
|
||||
external_metadata_id,
|
||||
added_at: parse_ts(added_at)?,
|
||||
}),
|
||||
EventPayload::WatchlistEntryRemoved { user_id, movie_id } => {
|
||||
Ok(DomainEvent::WatchlistEntryRemoved {
|
||||
EventPayload::UserUpdated { user_id } => {
|
||||
Ok(DomainEvent::UserUpdated {
|
||||
user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?),
|
||||
movie_id: MovieId::from_uuid(parse_uuid(&movie_id, "movie_id")?),
|
||||
})
|
||||
}
|
||||
EventPayload::FollowRequested {
|
||||
follower_id,
|
||||
target_kind,
|
||||
target_id,
|
||||
} => Ok(DomainEvent::FollowRequested {
|
||||
follower: UserId::from_uuid(parse_uuid(&follower_id, "follower_id")?),
|
||||
target: payload_to_follow_target(&target_kind, target_id)?,
|
||||
}),
|
||||
EventPayload::FollowAccepted {
|
||||
owner_id,
|
||||
requester_kind,
|
||||
requester_id,
|
||||
} => Ok(DomainEvent::FollowAccepted {
|
||||
owner: UserId::from_uuid(parse_uuid(&owner_id, "owner_id")?),
|
||||
requester: payload_to_identity(&requester_kind, requester_id)?,
|
||||
}),
|
||||
EventPayload::FollowRejected {
|
||||
owner_id,
|
||||
requester_kind,
|
||||
requester_id,
|
||||
} => Ok(DomainEvent::FollowRejected {
|
||||
owner: UserId::from_uuid(parse_uuid(&owner_id, "owner_id")?),
|
||||
requester: payload_to_identity(&requester_kind, requester_id)?,
|
||||
}),
|
||||
EventPayload::Unfollowed {
|
||||
follower_id,
|
||||
target_kind,
|
||||
target_id,
|
||||
} => Ok(DomainEvent::Unfollowed {
|
||||
follower: UserId::from_uuid(parse_uuid(&follower_id, "follower_id")?),
|
||||
target: payload_to_identity(&target_kind, target_id)?,
|
||||
}),
|
||||
EventPayload::FollowerRemoved {
|
||||
owner_id,
|
||||
follower_kind,
|
||||
follower_id,
|
||||
} => Ok(DomainEvent::FollowerRemoved {
|
||||
owner: UserId::from_uuid(parse_uuid(&owner_id, "owner_id")?),
|
||||
follower: payload_to_identity(&follower_kind, follower_id)?,
|
||||
}),
|
||||
EventPayload::ActorBlocked {
|
||||
blocker_id,
|
||||
target_kind,
|
||||
target_id,
|
||||
} => Ok(DomainEvent::ActorBlocked {
|
||||
blocker: UserId::from_uuid(parse_uuid(&blocker_id, "blocker_id")?),
|
||||
target: payload_to_identity(&target_kind, target_id)?,
|
||||
}),
|
||||
EventPayload::ActorUnblocked {
|
||||
blocker_id,
|
||||
target_kind,
|
||||
target_id,
|
||||
} => Ok(DomainEvent::ActorUnblocked {
|
||||
blocker: UserId::from_uuid(parse_uuid(&blocker_id, "blocker_id")?),
|
||||
target: payload_to_identity(&target_kind, target_id)?,
|
||||
}),
|
||||
EventPayload::BackfillFollower {
|
||||
owner_user_id,
|
||||
follower_inbox_url,
|
||||
} => Ok(DomainEvent::BackfillFollower {
|
||||
owner_user_id: UserId::from_uuid(parse_uuid(&owner_user_id, "owner_user_id")?),
|
||||
follower_inbox_url,
|
||||
}),
|
||||
EventPayload::FederationDeliveryRequested {
|
||||
inbox_url,
|
||||
activity_json,
|
||||
signing_actor_id,
|
||||
} => Ok(DomainEvent::FederationDeliveryRequested {
|
||||
inbox_url,
|
||||
activity_json,
|
||||
signing_actor_id: parse_uuid(&signing_actor_id, "signing_actor_id")?,
|
||||
}),
|
||||
EventPayload::WatchEventIngested {
|
||||
user_id,
|
||||
title,
|
||||
source,
|
||||
} => Ok(DomainEvent::WatchEventIngested {
|
||||
user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?),
|
||||
title,
|
||||
source,
|
||||
}),
|
||||
EventPayload::WrapUpRequested {
|
||||
wrapup_id,
|
||||
user_id,
|
||||
start_date,
|
||||
end_date,
|
||||
} => {
|
||||
let wid = parse_uuid(&wrapup_id, "wrapup_id")?;
|
||||
let uid = user_id.map(|s| parse_uuid(&s, "user_id")).transpose()?;
|
||||
let sd = chrono::NaiveDate::parse_from_str(&start_date, "%Y-%m-%d")
|
||||
.map_err(|e| DomainError::ValidationError(e.to_string()))?;
|
||||
let ed = chrono::NaiveDate::parse_from_str(&end_date, "%Y-%m-%d")
|
||||
.map_err(|e| DomainError::ValidationError(e.to_string()))?;
|
||||
Ok(DomainEvent::WrapUpRequested {
|
||||
wrapup_id: WrapUpId::from_uuid(wid),
|
||||
user_id: uid.map(UserId::from_uuid),
|
||||
start_date: sd,
|
||||
end_date: ed,
|
||||
EventPayload::ReviewDeleted { review_id, user_id } => {
|
||||
Ok(DomainEvent::ReviewDeleted {
|
||||
review_id: ReviewId::from_uuid(parse_uuid(&review_id, "review_id")?),
|
||||
user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?),
|
||||
})
|
||||
}
|
||||
EventPayload::WrapUpCompleted { wrapup_id } => {
|
||||
let wid = parse_uuid(&wrapup_id, "wrapup_id")?;
|
||||
Ok(DomainEvent::WrapUpCompleted {
|
||||
wrapup_id: WrapUpId::from_uuid(wid),
|
||||
})
|
||||
}
|
||||
EventPayload::SearchReindexRequested => Ok(DomainEvent::SearchReindexRequested),
|
||||
EventPayload::PosterSynced { movie_id } => Ok(DomainEvent::PosterSynced {
|
||||
movie_id: MovieId::from_uuid(parse_uuid(&movie_id, "movie_id")?),
|
||||
}),
|
||||
EventPayload::GoalCreated {
|
||||
goal_id,
|
||||
user_id,
|
||||
year,
|
||||
target_count,
|
||||
} => Ok(DomainEvent::GoalCreated {
|
||||
goal_id: GoalId::from_uuid(parse_uuid(&goal_id, "goal_id")?),
|
||||
user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?),
|
||||
year,
|
||||
target_count,
|
||||
}),
|
||||
EventPayload::GoalUpdated {
|
||||
goal_id,
|
||||
user_id,
|
||||
year,
|
||||
target_count,
|
||||
} => Ok(DomainEvent::GoalUpdated {
|
||||
goal_id: GoalId::from_uuid(parse_uuid(&goal_id, "goal_id")?),
|
||||
user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?),
|
||||
year,
|
||||
target_count,
|
||||
}),
|
||||
EventPayload::GoalDeleted {
|
||||
goal_id,
|
||||
user_id,
|
||||
year,
|
||||
} => Ok(DomainEvent::GoalDeleted {
|
||||
goal_id: GoalId::from_uuid(parse_uuid(&goal_id, "goal_id")?),
|
||||
user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?),
|
||||
year,
|
||||
}),
|
||||
EventPayload::PersonEnrichmentRequested {
|
||||
person_id,
|
||||
external_person_id,
|
||||
} => Ok(DomainEvent::PersonEnrichmentRequested {
|
||||
person_id: PersonId::from_uuid(parse_uuid(&person_id, "person_id")?),
|
||||
external_person_id: ExternalPersonId::new(external_person_id),
|
||||
}),
|
||||
EventPayload::UserDeleted { user_id } => Ok(DomainEvent::UserDeleted {
|
||||
user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?),
|
||||
}),
|
||||
EventPayload::UserAccountMoved {
|
||||
user_id,
|
||||
new_actor_url,
|
||||
} => Ok(DomainEvent::UserAccountMoved {
|
||||
user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?),
|
||||
new_actor_url,
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/lib.rs"]
|
||||
mod tests;
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn fixed_dt() -> NaiveDateTime {
|
||||
chrono::DateTime::from_timestamp(1_700_000_000, 0).unwrap().naive_utc()
|
||||
}
|
||||
|
||||
fn review_logged() -> DomainEvent {
|
||||
DomainEvent::ReviewLogged {
|
||||
review_id: ReviewId::from_uuid(Uuid::new_v4()),
|
||||
movie_id: MovieId::from_uuid(Uuid::new_v4()),
|
||||
user_id: UserId::from_uuid(Uuid::new_v4()),
|
||||
rating: Rating::new(4).unwrap(),
|
||||
watched_at: fixed_dt(),
|
||||
}
|
||||
}
|
||||
|
||||
fn review_updated() -> DomainEvent {
|
||||
DomainEvent::ReviewUpdated {
|
||||
review_id: ReviewId::from_uuid(Uuid::new_v4()),
|
||||
movie_id: MovieId::from_uuid(Uuid::new_v4()),
|
||||
user_id: UserId::from_uuid(Uuid::new_v4()),
|
||||
rating: Rating::new(3).unwrap(),
|
||||
watched_at: fixed_dt(),
|
||||
}
|
||||
}
|
||||
|
||||
fn movie_discovered() -> DomainEvent {
|
||||
DomainEvent::MovieDiscovered {
|
||||
movie_id: MovieId::from_uuid(Uuid::new_v4()),
|
||||
external_metadata_id: ExternalMetadataId::new("tt1234567".into()).unwrap(),
|
||||
}
|
||||
}
|
||||
|
||||
fn round_trip(event: DomainEvent) {
|
||||
let payload = EventPayload::from(&event);
|
||||
let json = serde_json::to_string(&payload).expect("serialize");
|
||||
let back: EventPayload = serde_json::from_str(&json).expect("deserialize");
|
||||
let recovered = DomainEvent::try_from(back).expect("try_from");
|
||||
assert_eq!(EventPayload::from(&event), EventPayload::from(&recovered));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip_review_logged() {
|
||||
round_trip(review_logged());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip_review_updated() {
|
||||
round_trip(review_updated());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip_movie_discovered() {
|
||||
round_trip(movie_discovered());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serialized_format_is_tagged() {
|
||||
let payload = EventPayload::from(&movie_discovered());
|
||||
let json = serde_json::to_string(&payload).unwrap();
|
||||
assert!(json.contains(r#""type":"MovieDiscovered""#));
|
||||
assert!(json.contains(r#""data":"#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn event_type_strings() {
|
||||
assert_eq!(EventPayload::from(&review_logged()).event_type(), "ReviewLogged");
|
||||
assert_eq!(EventPayload::from(&review_updated()).event_type(), "ReviewUpdated");
|
||||
assert_eq!(EventPayload::from(&movie_discovered()).event_type(), "MovieDiscovered");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
use super::*;
|
||||
|
||||
fn fixed_dt() -> NaiveDateTime {
|
||||
chrono::DateTime::from_timestamp(1_700_000_000, 0)
|
||||
.unwrap()
|
||||
.naive_utc()
|
||||
}
|
||||
|
||||
fn review_logged() -> DomainEvent {
|
||||
DomainEvent::ReviewLogged {
|
||||
review_id: ReviewId::from_uuid(Uuid::new_v4()),
|
||||
movie_id: MovieId::from_uuid(Uuid::new_v4()),
|
||||
user_id: UserId::from_uuid(Uuid::new_v4()),
|
||||
rating: Rating::new(4).unwrap(),
|
||||
watched_at: fixed_dt(),
|
||||
}
|
||||
}
|
||||
|
||||
fn review_updated() -> DomainEvent {
|
||||
DomainEvent::ReviewUpdated {
|
||||
review_id: ReviewId::from_uuid(Uuid::new_v4()),
|
||||
movie_id: MovieId::from_uuid(Uuid::new_v4()),
|
||||
user_id: UserId::from_uuid(Uuid::new_v4()),
|
||||
rating: Rating::new(3).unwrap(),
|
||||
watched_at: fixed_dt(),
|
||||
}
|
||||
}
|
||||
|
||||
fn movie_discovered() -> DomainEvent {
|
||||
DomainEvent::MovieDiscovered {
|
||||
movie_id: MovieId::from_uuid(Uuid::new_v4()),
|
||||
external_metadata_id: ExternalMetadataId::new("tt1234567".into()).unwrap(),
|
||||
}
|
||||
}
|
||||
|
||||
fn round_trip(event: DomainEvent) {
|
||||
let payload = EventPayload::from(&event);
|
||||
let json = serde_json::to_string(&payload).expect("serialize");
|
||||
let back: EventPayload = serde_json::from_str(&json).expect("deserialize");
|
||||
let recovered = DomainEvent::try_from(back).expect("try_from");
|
||||
assert_eq!(EventPayload::from(&event), EventPayload::from(&recovered));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip_review_logged() {
|
||||
round_trip(review_logged());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip_review_updated() {
|
||||
round_trip(review_updated());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip_movie_discovered() {
|
||||
round_trip(movie_discovered());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serialized_format_is_tagged() {
|
||||
let payload = EventPayload::from(&movie_discovered());
|
||||
let json = serde_json::to_string(&payload).unwrap();
|
||||
assert!(json.contains(r#""type":"MovieDiscovered""#));
|
||||
assert!(json.contains(r#""data":"#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn event_type_strings() {
|
||||
assert_eq!(
|
||||
EventPayload::from(&review_logged()).event_type(),
|
||||
"ReviewLogged"
|
||||
);
|
||||
assert_eq!(
|
||||
EventPayload::from(&review_updated()).event_type(),
|
||||
"ReviewUpdated"
|
||||
);
|
||||
assert_eq!(
|
||||
EventPayload::from(&movie_discovered()).event_type(),
|
||||
"MovieDiscovered"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip_image_stored() {
|
||||
let event = DomainEvent::ImageStored {
|
||||
key: "avatars/abc123".into(),
|
||||
};
|
||||
let payload = EventPayload::from(&event);
|
||||
let json = serde_json::to_string(&payload).unwrap();
|
||||
let back: EventPayload = serde_json::from_str(&json).unwrap();
|
||||
let recovered = DomainEvent::try_from(back).unwrap();
|
||||
assert_eq!(EventPayload::from(&event), EventPayload::from(&recovered));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_stored_event_type() {
|
||||
let payload = EventPayload::from(&DomainEvent::ImageStored {
|
||||
key: "posters/x".into(),
|
||||
});
|
||||
assert_eq!(payload.event_type(), "ImageStored");
|
||||
}
|
||||
@@ -7,4 +7,5 @@ edition = "2024"
|
||||
domain = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
|
||||
@@ -43,12 +43,8 @@ struct NoopAck;
|
||||
|
||||
#[async_trait]
|
||||
impl AckHandle for NoopAck {
|
||||
async fn ack(&self) -> Result<(), DomainError> {
|
||||
Ok(())
|
||||
}
|
||||
async fn nack(&self) -> Result<(), DomainError> {
|
||||
Ok(())
|
||||
}
|
||||
async fn ack(&self) -> Result<(), DomainError> { Ok(()) }
|
||||
async fn nack(&self) -> Result<(), DomainError> { Ok(()) }
|
||||
}
|
||||
|
||||
pub struct ChannelEventConsumer {
|
||||
@@ -88,5 +84,59 @@ pub fn create_event_channel(
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/lib.rs"]
|
||||
mod tests;
|
||||
mod tests {
|
||||
use super::*;
|
||||
use domain::{
|
||||
events::DomainEvent,
|
||||
value_objects::{ExternalMetadataId, MovieId},
|
||||
};
|
||||
use futures::StreamExt;
|
||||
|
||||
fn movie_discovered() -> DomainEvent {
|
||||
DomainEvent::MovieDiscovered {
|
||||
movie_id: MovieId::generate(),
|
||||
external_metadata_id: ExternalMetadataId::new("tt1234567".into()).unwrap(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn consumer_yields_published_events() {
|
||||
let config = EventPublisherConfig { channel_buffer: 8 };
|
||||
let (publisher, consumer) = create_event_channel(config);
|
||||
|
||||
publisher.publish(&movie_discovered()).await.unwrap();
|
||||
drop(publisher);
|
||||
|
||||
let mut stream = consumer.consume();
|
||||
let envelope = stream.next().await.unwrap().unwrap();
|
||||
assert!(matches!(envelope.event, DomainEvent::MovieDiscovered { .. }));
|
||||
assert!(stream.next().await.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn consumer_yields_multiple_events_in_order() {
|
||||
let config = EventPublisherConfig { channel_buffer: 8 };
|
||||
let (publisher, consumer) = create_event_channel(config);
|
||||
|
||||
publisher.publish(&movie_discovered()).await.unwrap();
|
||||
publisher.publish(&movie_discovered()).await.unwrap();
|
||||
drop(publisher);
|
||||
|
||||
let mut stream = consumer.consume();
|
||||
let first = stream.next().await.unwrap().unwrap();
|
||||
let second = stream.next().await.unwrap().unwrap();
|
||||
assert!(matches!(first.event, DomainEvent::MovieDiscovered { .. }));
|
||||
assert!(matches!(second.event, DomainEvent::MovieDiscovered { .. }));
|
||||
assert!(stream.next().await.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stream_ends_when_publisher_dropped() {
|
||||
let config = EventPublisherConfig { channel_buffer: 8 };
|
||||
let (publisher, consumer) = create_event_channel(config);
|
||||
drop(publisher);
|
||||
|
||||
let mut stream = consumer.consume();
|
||||
assert!(stream.next().await.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
use super::*;
|
||||
use domain::{
|
||||
events::DomainEvent,
|
||||
value_objects::{ExternalMetadataId, MovieId},
|
||||
};
|
||||
use futures::StreamExt;
|
||||
|
||||
fn movie_discovered() -> DomainEvent {
|
||||
DomainEvent::MovieDiscovered {
|
||||
movie_id: MovieId::generate(),
|
||||
external_metadata_id: ExternalMetadataId::new("tt1234567".into()).unwrap(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn consumer_yields_published_events() {
|
||||
let config = EventPublisherConfig { channel_buffer: 8 };
|
||||
let (publisher, consumer) = create_event_channel(config);
|
||||
|
||||
publisher.publish(&movie_discovered()).await.unwrap();
|
||||
drop(publisher);
|
||||
|
||||
let mut stream = consumer.consume();
|
||||
let envelope = stream.next().await.unwrap().unwrap();
|
||||
assert!(matches!(
|
||||
envelope.event,
|
||||
DomainEvent::MovieDiscovered { .. }
|
||||
));
|
||||
assert!(stream.next().await.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn consumer_yields_multiple_events_in_order() {
|
||||
let config = EventPublisherConfig { channel_buffer: 8 };
|
||||
let (publisher, consumer) = create_event_channel(config);
|
||||
|
||||
publisher.publish(&movie_discovered()).await.unwrap();
|
||||
publisher.publish(&movie_discovered()).await.unwrap();
|
||||
drop(publisher);
|
||||
|
||||
let mut stream = consumer.consume();
|
||||
let first = stream.next().await.unwrap().unwrap();
|
||||
let second = stream.next().await.unwrap().unwrap();
|
||||
assert!(matches!(first.event, DomainEvent::MovieDiscovered { .. }));
|
||||
assert!(matches!(second.event, DomainEvent::MovieDiscovered { .. }));
|
||||
assert!(stream.next().await.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stream_ends_when_publisher_dropped() {
|
||||
let config = EventPublisherConfig { channel_buffer: 8 };
|
||||
let (publisher, consumer) = create_event_channel(config);
|
||||
drop(publisher);
|
||||
|
||||
let mut stream = consumer.consume();
|
||||
assert!(stream.next().await.is_none());
|
||||
}
|
||||
@@ -8,9 +8,6 @@ domain = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
bytes = { workspace = true }
|
||||
async-stream = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
uuid = { workspace = true }
|
||||
|
||||
@@ -1,89 +1,51 @@
|
||||
use bytes::Bytes;
|
||||
use async_trait::async_trait;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::{DiaryEntry, ExportFormat},
|
||||
ports::DiaryExporter,
|
||||
};
|
||||
use futures::stream::BoxStream;
|
||||
|
||||
pub struct ExportAdapter;
|
||||
|
||||
#[async_trait]
|
||||
impl DiaryExporter for ExportAdapter {
|
||||
fn stream_entries(
|
||||
async fn serialize_entries(
|
||||
&self,
|
||||
stream: BoxStream<'static, Result<DiaryEntry, DomainError>>,
|
||||
entries: &[DiaryEntry],
|
||||
format: ExportFormat,
|
||||
) -> BoxStream<'static, Result<Bytes, DomainError>> {
|
||||
) -> Result<Vec<u8>, DomainError> {
|
||||
match format {
|
||||
ExportFormat::Csv => stream_csv(stream),
|
||||
ExportFormat::Json => stream_json(stream),
|
||||
ExportFormat::Csv => serialize_csv(entries),
|
||||
ExportFormat::Json => serialize_json(entries),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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\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();
|
||||
format!(
|
||||
"{},{},{},{},{},{},{}\n",
|
||||
title, year, director, rating, comment, watched_at, ext_id
|
||||
)
|
||||
fn serialize_csv(entries: &[DiaryEntry]) -> Result<Vec<u8>, DomainError> {
|
||||
let mut out =
|
||||
String::from("title,year,director,rating,comment,watched_at,external_metadata_id\n");
|
||||
for e in entries {
|
||||
let title = csv_escape(e.movie().title().value());
|
||||
let year = e.movie().release_year().value();
|
||||
let director = e.movie().director().map(csv_escape).unwrap_or_default();
|
||||
let rating = e.review().rating().value();
|
||||
let comment = e
|
||||
.review()
|
||||
.comment()
|
||||
.map(|c| csv_escape(c.value()))
|
||||
.unwrap_or_default();
|
||||
let watched_at = e.review().watched_at().format("%Y-%m-%d");
|
||||
let ext_id = e
|
||||
.movie()
|
||||
.external_metadata_id()
|
||||
.map(|id| id.value().to_string())
|
||||
.unwrap_or_default();
|
||||
out.push_str(&format!(
|
||||
"{},{},{},{},{},{},{}\n",
|
||||
title, year, director, rating, comment, watched_at, ext_id
|
||||
));
|
||||
}
|
||||
Ok(out.into_bytes())
|
||||
}
|
||||
|
||||
fn csv_escape(s: &str) -> String {
|
||||
@@ -94,18 +56,170 @@ fn csv_escape(s: &str) -> 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()),
|
||||
})
|
||||
fn serialize_json(entries: &[DiaryEntry]) -> Result<Vec<u8>, DomainError> {
|
||||
let arr: Vec<serde_json::Value> = entries
|
||||
.iter()
|
||||
.map(|e| {
|
||||
serde_json::json!({
|
||||
"title": e.movie().title().value(),
|
||||
"year": e.movie().release_year().value(),
|
||||
"director": e.movie().director(),
|
||||
"rating": e.review().rating().value(),
|
||||
"comment": e.review().comment().map(|c| c.value()),
|
||||
"watched_at": e.review().watched_at().format("%Y-%m-%d").to_string(),
|
||||
"external_metadata_id": e.movie().external_metadata_id().map(|id| id.value()),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
serde_json::to_vec_pretty(&arr).map_err(|e| DomainError::InfrastructureError(e.to_string()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/lib.rs"]
|
||||
mod tests;
|
||||
mod tests {
|
||||
use super::ExportAdapter;
|
||||
use domain::{
|
||||
models::{DiaryEntry, ExportFormat, Movie, Review},
|
||||
ports::DiaryExporter,
|
||||
value_objects::{ExternalMetadataId, MovieTitle, Rating, ReleaseYear},
|
||||
};
|
||||
|
||||
fn make_entry(
|
||||
title: &str,
|
||||
year: u16,
|
||||
director: Option<&str>,
|
||||
rating: u8,
|
||||
comment: Option<&str>,
|
||||
) -> DiaryEntry {
|
||||
make_entry_full(title, year, director, rating, comment, None)
|
||||
}
|
||||
|
||||
fn make_entry_full(
|
||||
title: &str,
|
||||
year: u16,
|
||||
director: Option<&str>,
|
||||
rating: u8,
|
||||
comment: Option<&str>,
|
||||
external_id: Option<&str>,
|
||||
) -> DiaryEntry {
|
||||
let movie = Movie::new(
|
||||
external_id.map(|id| ExternalMetadataId::new(id.to_string()).unwrap()),
|
||||
MovieTitle::new(title.to_string()).unwrap(),
|
||||
ReleaseYear::new(year).unwrap(),
|
||||
director.map(str::to_string),
|
||||
None,
|
||||
);
|
||||
let user_id = domain::value_objects::UserId::from_uuid(uuid::Uuid::new_v4());
|
||||
let review = Review::new(
|
||||
movie.id().clone(),
|
||||
user_id,
|
||||
Rating::new(rating).unwrap(),
|
||||
comment.map(|c| domain::value_objects::Comment::new(c.to_string()).unwrap()),
|
||||
chrono::NaiveDate::from_ymd_opt(2024, 3, 15)
|
||||
.unwrap()
|
||||
.and_hms_opt(0, 0, 0)
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
DiaryEntry::new(movie, review)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn csv_has_header_and_one_row() {
|
||||
let adapter = ExportAdapter;
|
||||
let entry = make_entry(
|
||||
"Inception",
|
||||
2010,
|
||||
Some("Christopher Nolan"),
|
||||
5,
|
||||
Some("great"),
|
||||
);
|
||||
let bytes = adapter
|
||||
.serialize_entries(&[entry], ExportFormat::Csv)
|
||||
.await
|
||||
.unwrap();
|
||||
let text = String::from_utf8(bytes).unwrap();
|
||||
assert!(
|
||||
text.starts_with(
|
||||
"title,year,director,rating,comment,watched_at,external_metadata_id\n"
|
||||
)
|
||||
);
|
||||
assert!(text.contains("Inception"));
|
||||
assert!(text.contains("2010"));
|
||||
assert!(text.contains("Christopher Nolan"));
|
||||
assert!(text.contains("5"));
|
||||
assert!(text.contains("great"));
|
||||
assert!(text.contains("2024-03-15"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn csv_escapes_commas_in_title() {
|
||||
let adapter = ExportAdapter;
|
||||
let entry = make_entry("Tár, A Film", 2022, None, 4, None);
|
||||
let bytes = adapter
|
||||
.serialize_entries(&[entry], ExportFormat::Csv)
|
||||
.await
|
||||
.unwrap();
|
||||
let text = String::from_utf8(bytes).unwrap();
|
||||
assert!(text.contains("\"Tár, A Film\""));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn json_is_valid_array() {
|
||||
let adapter = ExportAdapter;
|
||||
let entry = make_entry("Dune", 2021, Some("Denis Villeneuve"), 5, None);
|
||||
let bytes = adapter
|
||||
.serialize_entries(&[entry], ExportFormat::Json)
|
||||
.await
|
||||
.unwrap();
|
||||
let arr: Vec<serde_json::Value> = serde_json::from_slice(&bytes).unwrap();
|
||||
assert_eq!(arr.len(), 1);
|
||||
assert_eq!(arr[0]["title"], "Dune");
|
||||
assert_eq!(arr[0]["year"], 2021);
|
||||
assert_eq!(arr[0]["rating"], 5);
|
||||
assert_eq!(arr[0]["comment"], serde_json::Value::Null);
|
||||
assert_eq!(arr[0]["external_metadata_id"], serde_json::Value::Null);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn external_metadata_id_included_when_present() {
|
||||
let adapter = ExportAdapter;
|
||||
let entry = make_entry_full("Alien", 1979, None, 5, None, Some("tt0078748"));
|
||||
let bytes = adapter
|
||||
.serialize_entries(&[entry], ExportFormat::Json)
|
||||
.await
|
||||
.unwrap();
|
||||
let arr: Vec<serde_json::Value> = serde_json::from_slice(&bytes).unwrap();
|
||||
assert_eq!(arr[0]["external_metadata_id"], "tt0078748");
|
||||
|
||||
let bytes = adapter
|
||||
.serialize_entries(
|
||||
&[make_entry_full(
|
||||
"Alien",
|
||||
1979,
|
||||
None,
|
||||
5,
|
||||
None,
|
||||
Some("tt0078748"),
|
||||
)],
|
||||
ExportFormat::Csv,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let text = String::from_utf8(bytes).unwrap();
|
||||
assert!(text.contains("tt0078748"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_entries_returns_csv_header_only() {
|
||||
let adapter = ExportAdapter;
|
||||
let bytes = adapter
|
||||
.serialize_entries(&[], ExportFormat::Csv)
|
||||
.await
|
||||
.unwrap();
|
||||
let text = String::from_utf8(bytes).unwrap();
|
||||
assert_eq!(
|
||||
text,
|
||||
"title,year,director,rating,comment,watched_at,external_metadata_id\n"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,163 +0,0 @@
|
||||
use super::ExportAdapter;
|
||||
use domain::{
|
||||
models::{DiaryEntry, ExportFormat, Movie, Review},
|
||||
ports::DiaryExporter,
|
||||
value_objects::{ExternalMetadataId, MovieTitle, Rating, ReleaseYear},
|
||||
};
|
||||
|
||||
async fn collect_stream(
|
||||
stream: futures::stream::BoxStream<'static, Result<bytes::Bytes, domain::errors::DomainError>>,
|
||||
) -> Vec<u8> {
|
||||
use futures::StreamExt;
|
||||
let mut out = Vec::new();
|
||||
futures::pin_mut!(stream);
|
||||
while let Some(chunk) = stream.next().await {
|
||||
out.extend_from_slice(&chunk.unwrap());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn entry_stream(
|
||||
entries: Vec<domain::models::DiaryEntry>,
|
||||
) -> futures::stream::BoxStream<
|
||||
'static,
|
||||
Result<domain::models::DiaryEntry, domain::errors::DomainError>,
|
||||
> {
|
||||
Box::pin(futures::stream::iter(entries.into_iter().map(Ok)))
|
||||
}
|
||||
|
||||
fn make_entry(
|
||||
title: &str,
|
||||
year: u16,
|
||||
director: Option<&str>,
|
||||
rating: u8,
|
||||
comment: Option<&str>,
|
||||
) -> DiaryEntry {
|
||||
make_entry_full(title, year, director, rating, comment, None)
|
||||
}
|
||||
|
||||
fn make_entry_full(
|
||||
title: &str,
|
||||
year: u16,
|
||||
director: Option<&str>,
|
||||
rating: u8,
|
||||
comment: Option<&str>,
|
||||
external_id: Option<&str>,
|
||||
) -> DiaryEntry {
|
||||
let movie = Movie::new(
|
||||
external_id.map(|id| ExternalMetadataId::new(id.to_string()).unwrap()),
|
||||
MovieTitle::new(title.to_string()).unwrap(),
|
||||
ReleaseYear::new(year).unwrap(),
|
||||
director.map(str::to_string),
|
||||
None,
|
||||
);
|
||||
let user_id = domain::value_objects::UserId::from_uuid(uuid::Uuid::new_v4());
|
||||
let review = Review::new(
|
||||
movie.id().clone(),
|
||||
user_id,
|
||||
Rating::new(rating).unwrap(),
|
||||
comment.map(|c| domain::value_objects::Comment::new(c.to_string()).unwrap()),
|
||||
chrono::NaiveDate::from_ymd_opt(2024, 3, 15)
|
||||
.unwrap()
|
||||
.and_hms_opt(0, 0, 0)
|
||||
.unwrap(),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
DiaryEntry::new(movie, review)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn csv_has_header_and_one_row() {
|
||||
let adapter = ExportAdapter;
|
||||
let entry = make_entry(
|
||||
"Inception",
|
||||
2010,
|
||||
Some("Christopher Nolan"),
|
||||
5,
|
||||
Some("great"),
|
||||
);
|
||||
let bytes =
|
||||
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\n")
|
||||
);
|
||||
assert!(text.contains("Inception"));
|
||||
assert!(text.contains("2010"));
|
||||
assert!(text.contains("Christopher Nolan"));
|
||||
assert!(text.contains("5"));
|
||||
assert!(text.contains("great"));
|
||||
assert!(text.contains("2024-03-15"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn csv_escapes_commas_in_title() {
|
||||
let adapter = ExportAdapter;
|
||||
let entry = make_entry("Tár, A Film", 2022, None, 4, None);
|
||||
let bytes =
|
||||
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 = make_entry("Dune", 2021, Some("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 = make_entry_full("Alien", 1979, None, 5, None, Some("tt0078748"));
|
||||
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![make_entry_full(
|
||||
"Alien",
|
||||
1979,
|
||||
None,
|
||||
5,
|
||||
None,
|
||||
Some("tt0078748"),
|
||||
)]),
|
||||
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\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());
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
[package]
|
||||
name = "image-converter"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
domain = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp"] }
|
||||
ravif = { version = "0.11", default-features = false }
|
||||
webp = "0.3"
|
||||
|
||||
[dev-dependencies]
|
||||
object-storage = { workspace = true }
|
||||
object_store = "0.11"
|
||||
uuid = { workspace = true }
|
||||
@@ -1,55 +0,0 @@
|
||||
use std::{sync::Arc, time::Duration};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
events::DomainEvent,
|
||||
ports::{EventPublisher, ImageRefQuery, PeriodicJob},
|
||||
};
|
||||
|
||||
pub struct ConversionBackfillJob {
|
||||
image_ref: Arc<dyn ImageRefQuery>,
|
||||
event_publisher: Arc<dyn EventPublisher>,
|
||||
}
|
||||
|
||||
impl ConversionBackfillJob {
|
||||
pub fn new(
|
||||
image_ref: Arc<dyn ImageRefQuery>,
|
||||
event_publisher: Arc<dyn EventPublisher>,
|
||||
) -> Self {
|
||||
Self {
|
||||
image_ref,
|
||||
event_publisher,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl PeriodicJob for ConversionBackfillJob {
|
||||
fn interval(&self) -> Duration {
|
||||
Duration::from_secs(60 * 60 * 24) // 24h
|
||||
}
|
||||
|
||||
async fn run(&self) -> Result<(), DomainError> {
|
||||
let keys = self.image_ref.list_keys().await?;
|
||||
|
||||
for key in keys {
|
||||
if key.ends_with(".avif") || key.ends_with(".webp") {
|
||||
continue;
|
||||
}
|
||||
if let Err(e) = self
|
||||
.event_publisher
|
||||
.publish(&DomainEvent::ImageStored { key: key.clone() })
|
||||
.await
|
||||
{
|
||||
tracing::warn!("backfill: failed to emit ImageStored for {key}: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/backfill.rs"]
|
||||
mod tests;
|
||||
@@ -1,51 +0,0 @@
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum Format {
|
||||
Avif,
|
||||
Webp,
|
||||
}
|
||||
|
||||
impl Format {
|
||||
pub fn extension(self) -> &'static str {
|
||||
match self {
|
||||
Format::Avif => ".avif",
|
||||
Format::Webp => ".webp",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ConversionConfig {
|
||||
pub format: Format,
|
||||
}
|
||||
|
||||
impl ConversionConfig {
|
||||
pub fn from_env() -> anyhow::Result<Option<Self>> {
|
||||
Self::from_vars(
|
||||
std::env::var("IMAGE_CONVERSION_ENABLED").ok().as_deref(),
|
||||
std::env::var("IMAGE_CONVERSION_FORMAT").ok().as_deref(),
|
||||
)
|
||||
}
|
||||
|
||||
fn from_vars(enabled: Option<&str>, format: Option<&str>) -> anyhow::Result<Option<Self>> {
|
||||
if enabled != Some("true") {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let format_str = format.ok_or_else(|| {
|
||||
anyhow::anyhow!("IMAGE_CONVERSION_FORMAT required when IMAGE_CONVERSION_ENABLED=true")
|
||||
})?;
|
||||
|
||||
let format = match format_str {
|
||||
"avif" => Format::Avif,
|
||||
"webp" => Format::Webp,
|
||||
other => anyhow::bail!(
|
||||
"Unknown IMAGE_CONVERSION_FORMAT: {other:?}. Valid values: avif, webp"
|
||||
),
|
||||
};
|
||||
|
||||
Ok(Some(Self { format }))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/config.rs"]
|
||||
mod tests;
|
||||
@@ -1,105 +0,0 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
events::DomainEvent,
|
||||
ports::{EventHandler, ImageRefCommand, ObjectStorage},
|
||||
};
|
||||
|
||||
use crate::Format;
|
||||
|
||||
pub struct ImageConversionHandler {
|
||||
storage: Arc<dyn ObjectStorage>,
|
||||
image_ref: Arc<dyn ImageRefCommand>,
|
||||
format: Format,
|
||||
}
|
||||
|
||||
impl ImageConversionHandler {
|
||||
pub fn new(
|
||||
storage: Arc<dyn ObjectStorage>,
|
||||
image_ref: Arc<dyn ImageRefCommand>,
|
||||
format: Format,
|
||||
) -> Self {
|
||||
Self {
|
||||
storage,
|
||||
image_ref,
|
||||
format,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl EventHandler for ImageConversionHandler {
|
||||
async fn handle(&self, event: &DomainEvent) -> Result<(), DomainError> {
|
||||
let key = match event {
|
||||
DomainEvent::ImageStored { key } => key.clone(),
|
||||
_ => return Ok(()),
|
||||
};
|
||||
|
||||
if key.ends_with(".avif") || key.ends_with(".webp") {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let bytes = self.storage.get(&key).await?;
|
||||
let format = self.format;
|
||||
|
||||
let converted = tokio::task::spawn_blocking(move || convert(bytes, format))
|
||||
.await
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?
|
||||
.map_err(DomainError::InfrastructureError)?;
|
||||
|
||||
let ext = format.extension();
|
||||
let new_key = format!("{key}{ext}");
|
||||
self.storage.store(&new_key, &converted).await?;
|
||||
|
||||
if let Err(e) = self.image_ref.swap(&key, &new_key).await {
|
||||
tracing::error!("swap failed for {key} → {new_key}: {e}");
|
||||
return Err(e);
|
||||
}
|
||||
|
||||
if let Err(e) = self.storage.delete(&key).await {
|
||||
tracing::warn!("failed to delete old image key {key}: {e}");
|
||||
}
|
||||
|
||||
tracing::info!("converted {key} → {new_key}");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn convert(bytes: Vec<u8>, format: Format) -> Result<Vec<u8>, String> {
|
||||
let img = image::load_from_memory(&bytes).map_err(|e| e.to_string())?;
|
||||
|
||||
match format {
|
||||
Format::Avif => {
|
||||
let rgba = img.to_rgba8();
|
||||
let width = rgba.width() as usize;
|
||||
let height = rgba.height() as usize;
|
||||
let pixels: Vec<ravif::RGBA8> = rgba
|
||||
.pixels()
|
||||
.map(|p| ravif::RGBA8 {
|
||||
r: p.0[0],
|
||||
g: p.0[1],
|
||||
b: p.0[2],
|
||||
a: p.0[3],
|
||||
})
|
||||
.collect();
|
||||
let result = ravif::Encoder::new()
|
||||
.with_quality(80.0)
|
||||
.with_speed(6)
|
||||
.encode_rgba(ravif::Img::new(&pixels, width, height))
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(result.avif_file.to_vec())
|
||||
}
|
||||
Format::Webp => {
|
||||
let rgba = img.to_rgba8();
|
||||
let (width, height) = (rgba.width(), rgba.height());
|
||||
let encoder = webp::Encoder::from_rgba(rgba.as_raw(), width, height);
|
||||
Ok(encoder.encode(80.0).to_vec())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/handler.rs"]
|
||||
mod tests;
|
||||
@@ -1,41 +0,0 @@
|
||||
mod backfill;
|
||||
mod config;
|
||||
mod handler;
|
||||
|
||||
pub use backfill::ConversionBackfillJob;
|
||||
pub use config::{ConversionConfig, Format};
|
||||
pub use handler::ImageConversionHandler;
|
||||
|
||||
use domain::ports::{
|
||||
EventHandler, EventPublisher, ImageRefCommand, ImageRefQuery, ObjectStorage, PeriodicJob,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
type ConversionPair = (Arc<dyn EventHandler>, Arc<dyn PeriodicJob>);
|
||||
|
||||
pub fn build(
|
||||
object_storage: Arc<dyn ObjectStorage>,
|
||||
image_ref_command: Arc<dyn ImageRefCommand>,
|
||||
image_ref_query: Arc<dyn ImageRefQuery>,
|
||||
event_publisher: Arc<dyn EventPublisher>,
|
||||
) -> anyhow::Result<Option<ConversionPair>> {
|
||||
let config = match ConversionConfig::from_env()? {
|
||||
Some(c) => c,
|
||||
None => return Ok(None),
|
||||
};
|
||||
|
||||
let format = config.format;
|
||||
|
||||
let handler = Arc::new(ImageConversionHandler::new(
|
||||
Arc::clone(&object_storage),
|
||||
image_ref_command,
|
||||
format,
|
||||
)) as Arc<dyn EventHandler>;
|
||||
|
||||
let job = Arc::new(ConversionBackfillJob::new(
|
||||
image_ref_query,
|
||||
Arc::clone(&event_publisher),
|
||||
)) as Arc<dyn PeriodicJob>;
|
||||
|
||||
Ok(Some((handler, job)))
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user