code refactor
Some checks failed
CI / Check / Test (push) Has been cancelled

Reviewed-on: #26
This commit was merged in pull request #26.
This commit is contained in:
2026-10-26 09:36:23 +00:00
parent 2ce7db12c2
commit 017b58d6fa
804 changed files with 33423 additions and 28955 deletions

View File

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

View File

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

View File

@@ -45,11 +45,12 @@ ALLOW_REGISTRATION=true
# PORT=3000
# RATE_LIMIT=60
# SECURE_COOKIES=true
# Handler-level logs come from the `presentation` crate; startup/wiring logs come
# from `server`. Include both — `server=info` alone silently drops handler logs.
# Handler-level logs come from the `http_axum` crate; startup/wiring logs come
# from `bootstrap`; config-load logs come from `config`. Include all three —
# `server=info` alone silently drops them.
# Crates not named here (adapters, etc.) still log warnings and errors unless
# you set a bare level yourself (e.g. `info,presentation=debug`).
# RUST_LOG=server=info,presentation=info,tower_http=info,worker=info
# you set a bare level yourself (e.g. `info,http_axum=debug`).
# RUST_LOG=server=info,worker=info,bootstrap=info,config=info,http_axum=info,tower_http=info
# CORS (for SPA development only)
# CORS_ORIGINS=http://localhost:5173

View File

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

View File

@@ -23,28 +23,24 @@ jobs:
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-
uses: Swatinem/rust-cache@v2
- name: fmt
run: cargo fmt --all -- --check
- name: clippy
run: cargo clippy --all-targets -- -D warnings
run: cargo clippy --workspace --all-targets -- -D warnings
- name: test
run: cargo test
run: cargo test --workspace
- name: test (federation off)
run: cargo test --workspace --no-default-features
# Layering guards. These live in the Makefile and were previously local-only,
# so a PR could go green in CI while violating them.
- name: guards
run: make check-appcontext check-handler-deps check-handler-repos check-presentation-adapters check-federation-off-social check-social-crates-are-ap-free
run: make check-appcontext check-handler-deps check-handler-repos check-http-adapter-deps check-federation-off-social check-sqlite-is-ap-free check-binaries-are-thin check-federation-off-deps check-nats-isolated check-no-second-web-stack
docker:
name: Build & Push Docker Image
@@ -58,6 +54,9 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Set up Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
uses: docker/login-action@v3
with:
@@ -83,4 +82,6 @@ jobs:
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: FEATURES=sqlite,sqlite-federation
build-args: FEATURES=federation
cache-from: type=gha
cache-to: type=gha,mode=max

1
.gitignore vendored
View File

@@ -18,3 +18,4 @@ docs/*
imgs/
.sqlx/
!crates/adapters/sqlite/tests/fixtures/*.db

295
CODE_STYLE.md Normal file
View File

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

View File

@@ -15,3 +15,15 @@ _Avoid_: Review (that is the record itself)
**Spoiler Flag**:
An author-set marker on a Review stating that its entire comment reveals plot details.
_Avoid_: Spoiler warning, spoiler tag, spoiler markup
**Account deletion**:
Two-phase removal of a user. The request tombstones the account immediately; a background purge then erases its data and notifies the fediverse.
_Avoid_: Account removal, deactivation
**Tombstone**:
The data-free users row (plus signing keypair) an Account deletion leaves behind so queued fediverse `Delete` deliveries can still be signed; removed after a retention period.
_Avoid_: Soft delete
**Retired Username**:
A username of a deleted account, refused for every future registration.
_Avoid_: Reserved username, banned username

View File

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

901
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -6,80 +6,41 @@ RUN npm ci
COPY spa/ .
RUN npm run build
# ----- build -----
FROM rust:slim-bookworm AS builder
WORKDIR /build
# Cache dependency compilation separately from source
#
# Every workspace member's Cargo.toml must be listed here by hand — `cargo fetch`
# below reads the whole workspace graph, so a missing manifest fails the build with
# "failed to read .../Cargo.toml". Adding a crate to crates/ means adding a line here.
COPY Cargo.toml Cargo.lock ./
COPY .cargo ./.cargo
COPY crates/adapters/activitypub/Cargo.toml crates/adapters/activitypub/Cargo.toml
COPY crates/adapters/auth/Cargo.toml crates/adapters/auth/Cargo.toml
COPY crates/adapters/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/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
COPY crates/adapters/sqlite-social/Cargo.toml crates/adapters/sqlite-social/Cargo.toml
COPY crates/adapters/sqlite-event-queue/Cargo.toml crates/adapters/sqlite-event-queue/Cargo.toml
COPY crates/adapters/postgres/Cargo.toml crates/adapters/postgres/Cargo.toml
COPY crates/adapters/postgres-federation/Cargo.toml crates/adapters/postgres-federation/Cargo.toml
COPY crates/adapters/postgres-social/Cargo.toml crates/adapters/postgres-social/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/composition/Cargo.toml crates/composition/Cargo.toml
COPY crates/presentation/Cargo.toml crates/presentation/Cargo.toml
COPY crates/server/Cargo.toml crates/server/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'
# ----- chef base -----
FROM rust:slim-bookworm AS chef
# libwebp-dev: required at build time by the `webp` crate (C bindings)
RUN apt-get update && apt-get install -y --no-install-recommends \
libwebp-dev \
pkg-config \
&& rm -rf /var/lib/apt/lists/*
RUN cargo install cargo-chef --locked --version 0.1.78
WORKDIR /build
RUN cargo fetch
FROM chef AS planner
COPY . .
RUN cargo chef prepare --recipe-path recipe.json
# Now copy real sources (invalidates cache only on source changes)
COPY crates ./crates
# ----- build -----
FROM chef AS builder
# Features: federation (ActivityPub), nats (EVENT_BUS_BACKEND=nats).
# Build without federation: --build-arg FEATURES=
# To add NATS support: --build-arg FEATURES=federation,nats
ARG FEATURES=federation
COPY .cargo ./.cargo
COPY --from=planner /build/recipe.json recipe.json
RUN --mount=type=cache,target=/usr/local/cargo/registry,sharing=locked \
--mount=type=cache,target=/usr/local/cargo/git,sharing=locked \
cargo chef cook --release -p server -p worker --no-default-features --features "${FEATURES}" --recipe-path recipe.json
# All sqlx queries use the runtime API (no query! macros), so no database
# or .sqlx cache is needed at compile time.
#
# To build with PostgreSQL backend instead:
# --build-arg FEATURES=postgres,postgres-federation
# To add NATS support (EVENT_BUS_BACKEND=nats):
# --build-arg FEATURES=sqlite,sqlite-federation,nats
ARG FEATURES=sqlite,sqlite-federation
RUN cargo build --release -p server -p worker --no-default-features --features "${FEATURES}"
COPY Cargo.toml Cargo.lock ./
COPY crates ./crates
RUN --mount=type=cache,target=/usr/local/cargo/registry,sharing=locked \
--mount=type=cache,target=/usr/local/cargo/git,sharing=locked \
cargo build --release -p server -p worker --no-default-features --features "${FEATURES}"
# ----- runtime -----
FROM debian:bookworm-slim
@@ -100,6 +61,6 @@ COPY --from=spa-builder /spa/dist ./spa/dist
EXPOSE 3000
ENV RUST_LOG=server=info,tower_http=info
ENV RUST_LOG=server=info,worker=info,bootstrap=info,config=info,http_axum=info,tower_http=info
CMD ["./server"]

121
Makefile
View File

@@ -1,6 +1,6 @@
.DEFAULT_GOAL := check
check: fmt-check clippy test check-appcontext check-handler-deps check-handler-repos check-presentation-adapters check-federation-off-social check-social-crates-are-ap-free
check: fmt-check clippy test test-federation-off check-appcontext check-handler-deps check-handler-repos check-http-adapter-deps check-federation-off-social check-sqlite-is-ap-free check-binaries-are-thin check-federation-off-deps check-nats-isolated check-no-second-web-stack
@echo "✅ All checks passed"
check-appcontext:
@@ -12,60 +12,118 @@ check-appcontext:
@echo "✅ No AppContext in application crate"
check-handler-deps:
@if grep -rnE "[A-Za-z0-9_]*Deps(\s*\{|::from\()" crates/presentation/src --include="*.rs" --exclude-dir=tests | grep -q .; then \
echo "❌ hand-built deps struct found in presentation:"; \
grep -rnE "[A-Za-z0-9_]*Deps(\s*\{|::from\()" crates/presentation/src --include="*.rs" --exclude-dir=tests; \
@if grep -rnE "[A-Za-z0-9_]*Deps(\s*\{|::from\()" crates/adapters/http-axum/src --include="*.rs" --exclude-dir=tests --exclude=tests.rs | grep -q .; then \
echo "❌ hand-built deps struct found in http-axum:"; \
grep -rnE "[A-Za-z0-9_]*Deps(\s*\{|::from\()" crates/adapters/http-axum/src --include="*.rs" --exclude-dir=tests --exclude=tests.rs; \
exit 1; \
fi
@echo "✅ No hand-built deps structs in presentation"
@echo "✅ No hand-built deps structs in http-axum"
check-handler-repos:
@if grep -rn "app_ctx\.repos" crates/presentation/src --include="*.rs" --exclude-dir=tests | grep -q .; then \
echo "❌ app_ctx.repos referenced in presentation:"; \
grep -rn "app_ctx\.repos" crates/presentation/src --include="*.rs" --exclude-dir=tests; \
@if grep -rn "app_ctx\.repos" crates/adapters/http-axum/src --include="*.rs" --exclude-dir=tests --exclude=tests.rs | grep -q .; then \
echo "❌ app_ctx.repos referenced in http-axum:"; \
grep -rn "app_ctx\.repos" crates/adapters/http-axum/src --include="*.rs" --exclude-dir=tests --exclude=tests.rs; \
exit 1; \
fi
@echo "✅ No app_ctx.repos references in presentation"
@echo "✅ No app_ctx.repos references in http-axum"
PRESENTATION_ALLOWED_ADAPTERS := template-askama
HTTP_ADAPTER_ALLOWED_ADAPTERS :=
check-presentation-adapters:
@deps=$$(awk '/^\[dependencies\]/{f=1;next} /^\[/{f=0} f' crates/presentation/Cargo.toml \
check-http-adapter-deps:
@deps=$$(awk '/^\[dependencies\]/{f=1;next} /^\[/{f=0} f' crates/adapters/http-axum/Cargo.toml \
| sed -n 's/^\([A-Za-z0-9_-]\{1,\}\)[[:space:]]*=.*/\1/p'); \
bad=""; \
for a in $$(ls crates/adapters); do \
case " $(PRESENTATION_ALLOWED_ADAPTERS) " in *" $$a "*) continue;; esac; \
case " $(HTTP_ADAPTER_ALLOWED_ADAPTERS) " in *" $$a "*) continue;; esac; \
if echo "$$deps" | grep -qx "$$a"; then bad="$$bad $$a"; fi; \
done; \
if [ -n "$$bad" ]; then \
echo "❌ presentation depends on adapter crate(s):$$bad"; \
echo " presentation may name renderers, not reachers — see ADR-0008."; \
echo "❌ http-axum depends on adapter crate(s):$$bad"; \
echo " http-axum may name renderers, not reachers — see ADR-0008."; \
exit 1; \
fi; \
echo "✅ presentation depends on no adapter crate but $(PRESENTATION_ALLOWED_ADAPTERS)"
echo "✅ http-axum depends on no other adapter crate"
check-federation-off-social:
@if grep -n "NoopSocialCommand\|NoopSocialQuery" crates/server/src/main.rs | grep -q .; then \
echo "❌ server wires social noops — federation-off would report an empty follow graph:"; \
grep -n "NoopSocialCommand\|NoopSocialQuery" crates/server/src/main.rs; \
@if grep -n "NoopSocialCommand\|NoopSocialQuery" crates/bootstrap/src/federation.rs | grep -q .; then \
echo "❌ bootstrap wires social noops — federation-off would report an empty follow graph:"; \
grep -n "NoopSocialCommand\|NoopSocialQuery" crates/bootstrap/src/federation.rs; \
exit 1; \
fi
@if ! awk '/cfg\(not\(feature = "federation"\)\)/ && !started {started=1} started && /cfg\(feature = "federation"\)/ {exit} started {print}' crates/server/src/main.rs | grep -q "LocalSocialService"; then \
echo "❌ LocalSocialService not wired in the federation-off region of crates/server/src/main.rs — federation-off has no social behavior wired at all, not even noops"; \
@if ! awk '/cfg\(not\(feature = "federation"\)\)/ && !started {started=1} started && /cfg\(feature = "federation"\)/ {exit} started {print}' crates/bootstrap/src/federation.rs | grep -q "LocalSocialService"; then \
echo "❌ LocalSocialService not wired in the federation-off region of crates/bootstrap/src/federation.rs — federation-off has no social behavior wired at all, not even noops"; \
exit 1; \
fi
@echo "✅ server wires real social behavior in every configuration"
@echo "✅ bootstrap wires real social behavior in every configuration"
check-social-crates-are-ap-free:
@if grep -nE '^[[:space:]]*(k-ap|activitypub[A-Za-z0-9_-]*|[A-Za-z0-9_-]+-federation)[[:space:]]*=' crates/adapters/*-social/Cargo.toml | grep -q .; then \
echo "❌ social crate depends on ActivityPub:"; \
grep -nE '^[[:space:]]*(k-ap|activitypub[A-Za-z0-9_-]*|[A-Za-z0-9_-]+-federation)[[:space:]]*=' crates/adapters/*-social/Cargo.toml; \
BINARIES_ALLOWED_DEPS := bootstrap config tokio axum anyhow dotenvy tracing
check-binaries-are-thin:
@bad=""; \
for c in server worker; do \
deps=$$(awk '/^\[dependencies\]/{f=1;next} /^\[/{f=0} f' crates/$$c/Cargo.toml \
| sed -n 's/^\([A-Za-z0-9_-]\{1,\}\)[[:space:]]*=.*/\1/p'); \
for d in $$deps; do \
case " $(BINARIES_ALLOWED_DEPS) " in *" $$d "*) continue;; esac; \
bad="$$bad $$c:$$d"; \
done; \
done; \
if [ -n "$$bad" ]; then \
echo "❌ binary [dependencies] outside {$(BINARIES_ALLOWED_DEPS)}:$$bad"; \
echo " wiring belongs in crates/bootstrap — see ADR-0013."; \
exit 1; \
fi; \
echo "✅ server and worker depend only on {$(BINARIES_ALLOWED_DEPS)}"
check-sqlite-is-ap-free:
@if grep -nE '^[[:space:]]*(k-ap|activitypub[A-Za-z0-9_-]*|[A-Za-z0-9_-]+-federation)[[:space:]]*=' crates/adapters/sqlite/Cargo.toml | grep -q .; then \
echo "❌ sqlite crate depends on ActivityPub:"; \
grep -nE '^[[:space:]]*(k-ap|activitypub[A-Za-z0-9_-]*|[A-Za-z0-9_-]+-federation)[[:space:]]*=' crates/adapters/sqlite/Cargo.toml; \
exit 1; \
fi
@echo "✅ social crates depend on no k-ap, activitypub, or *-federation crate"
@echo "✅ sqlite crate depends on no k-ap, activitypub, or *-federation crate"
CARGO_TREE_NAMES := cargo tree -p server -p worker -e normal --prefix none
check-federation-off-deps:
@tree=$$($(CARGO_TREE_NAMES) --no-default-features) || exit 1; \
hits=$$(echo "$$tree" | grep -E '^(k-ap|activitypub_federation|activitypub|sqlite-federation) ' | sort -u); \
if [ -n "$$hits" ]; then \
echo "❌ federation-only crates in the --no-default-features server/worker tree:"; \
echo "$$hits"; \
exit 1; \
fi; \
echo "✅ federation-off tree has no k-ap, activitypub_federation, activitypub, or sqlite-federation"
check-nats-isolated:
@tree=$$($(CARGO_TREE_NAMES)) || exit 1; \
nats_tree=$$($(CARGO_TREE_NAMES) --features nats) || exit 1; \
if echo "$$tree" | grep -qE '^(async-nats|nats) '; then \
echo "❌ nats crates in the default server/worker tree:"; \
echo "$$tree" | grep -E '^(async-nats|nats) ' | sort -u; \
exit 1; \
fi; \
if ! echo "$$nats_tree" | grep -qE '^async-nats '; then \
echo "❌ async-nats missing from the --features nats server/worker tree — feature is not wired"; \
exit 1; \
fi; \
echo "✅ async-nats only appears with --features nats"
check-no-second-web-stack:
@for f in "" "--features nats"; do \
tree=$$($(CARGO_TREE_NAMES) $$f) || exit 1; \
hits=$$(echo "$$tree" | grep -E '^(actix|aws-lc)' | sort -u); \
if [ -n "$$hits" ]; then \
echo "❌ actix/aws-lc crates in the server/worker tree ($${f:-default}):"; \
echo "$$hits"; \
exit 1; \
fi; \
done; \
echo "✅ no actix-* or aws-lc-* in the default or nats server/worker tree"
fmt:
cargo fmt
@@ -74,10 +132,13 @@ fmt-check:
cargo fmt --check
clippy:
cargo clippy -- -D warnings
cargo clippy --workspace --all-targets -- -D warnings
test:
cargo test
cargo test --workspace
test-federation-off:
cargo test --workspace --no-default-features
fix:
cargo fmt
@@ -106,4 +167,4 @@ dev-spa:
spa-dev:
@npm --prefix spa run dev
.PHONY: check fmt fmt-check clippy test fix dev dev-stop dev-worker dev-spa spa-dev check-appcontext check-handler-deps check-handler-repos check-presentation-adapters check-federation-off-social check-social-crates-are-ap-free
.PHONY: check fmt fmt-check clippy test test-federation-off fix dev dev-stop dev-worker dev-spa spa-dev check-appcontext check-handler-deps check-handler-repos check-http-adapter-deps check-federation-off-social check-sqlite-is-ap-free check-binaries-are-thin check-federation-off-deps check-nats-isolated check-no-second-web-stack

113
README.md
View File

@@ -1,12 +1,12 @@
# 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.
A self-hosted movie diary built in Rust. Ships a classic server-rendered HTML interface (no JavaScript) alongside a full React SPA, both backed by the same REST API. Federates over ActivityPub so reviews reach the Fediverse. Supports Jellyfin and Plex auto-import, full-text search, annual wrap-ups, goals, and bulk import from Letterboxd, IMDb, and other sources. Runs on SQLite.
[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
[![Built with Rust](https://img.shields.io/badge/built_with-Rust-orange.svg?logo=rust)](https://www.rust-lang.org/)
[![Docker](https://img.shields.io/badge/docker-ready-2496ED?logo=docker&logoColor=white)](https://hub.docker.com/)
[![ActivityPub](https://img.shields.io/badge/ActivityPub-federated-5b5ea6)](https://activitypub.rocks/)
[![SQLite](https://img.shields.io/badge/database-SQLite%20%7C%20PostgreSQL-003B57?logo=sqlite&logoColor=white)](https://www.sqlite.org/)
[![SQLite](https://img.shields.io/badge/database-SQLite-003B57?logo=sqlite&logoColor=white)](https://www.sqlite.org/)
---
@@ -52,7 +52,7 @@ Open `http://localhost:3000`. The HTTP server and background worker start togeth
- Spoiler Flag — authors mark a review as containing spoilers; the comment is veiled (blur + reveal) for every viewer except the author, withheld from RSS, and federates as `containsSpoilers` + an AP `summary` content warning so Mastodon-style clients also collapse it
- Background poster fetching and storage (local filesystem or S3-compatible)
- Movie enrichment via TMDb — full cast, crew, genres, keywords, runtime, budget/revenue, ratings; fetched automatically on movie discovery and refreshed every 30 days; exposed via `GET /api/v1/movies/{id}/profile`
- Full-text search across movies and people via `GET /api/v1/search` — free-text query plus structured filters (genre, year, person, department, language); backed by SQLite FTS5 or PostgreSQL tsvector + GIN indexes
- Full-text search across movies and people via `GET /api/v1/search` — free-text query plus structured filters (genre, year, person, department, language); backed by SQLite FTS5
- People as first-class entities — browse by person via `GET /api/v1/people/{id}` and full credit history via `GET /api/v1/people/{id}/credits`; index populated automatically during TMDb enrichment
- RSS/Atom feed for public subscription (global and per-user)
- JWT authentication via cookie (HTML) or Bearer token (REST API)
@@ -64,10 +64,11 @@ Open `http://localhost:3000`. The HTTP server and background worker start togeth
- Annual Wrap-Up — Spotify Wrapped for movies: per-user and instance-wide year-in-review with stats (top directors, actors, genres, rating distribution, watch time, watch medium breakdown, rewatches, budget analysis); directors/actors filtered by minimum watch count for statistical relevance; shareable HTML page at `/wrapups/{user_id}/{year}`; admin-triggered or auto-generated in January
- Goals — set a "watch N movies in YEAR" target with a progress bar; progress computed from existing reviews (backwards compatible); per-user federation toggle in settings; displayed on profile (SPA: interactive with create/edit/delete, classic HTML: read-only glassmorphic card)
- Profile trends — top directors, genre breakdown, rating distribution histogram, watch medium breakdown, monthly activity chart; all computed from the user's review history
- Account deletion — self-service (own password) or admin (admin's password) from the settings page, profile page, or `DELETE /api/v1/users/me` / `DELETE /api/v1/users/{id}`; the account is tombstoned at once, then a background purge erases its data and tells the fediverse; usernames are never reused (ADR-0016)
- CSV and JSON diary export
- File importer: upload CSV, TSV, JSON, or XLSX from any source (Letterboxd, IMDb, etc.), map columns to domain fields via a step-by-step wizard or REST API, save mapping profiles for repeat imports
- REST API v1 (`/api/v1/`) with full feature parity with the HTML interface
- OpenAPI documentation at `/docs` (Swagger UI) and `/scalar` (Scalar)
- OpenAPI documentation at `/docs` (Swagger UI)
- CSRF protection on all HTML form routes (double-submit cookie, defense-in-depth on top of `SameSite=Strict`)
- Per-IP rate limiting via token bucket (production-grade, backed by `axum-governor`)
- Single-page app at `/app/` — React + TanStack Router + shadcn/ui, built with Vite, served from the backend with client-side routing fallback
@@ -90,42 +91,32 @@ Open `http://localhost:3000`. The HTTP server and background worker start togeth
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 server and worker binaries
api-types — shared REST API request/response DTOs (Serialize/Deserialize + utoipa schemas) + HtmlPageContext; used by http-axum and tui
config — typed configuration (defaults, config.toml, legacy env names); loaded once by the binaries
bootstrap — composition root: the only crate that names adapters; builds the server router and the worker service
event-payload — serde payloads for domain events, shared by the NATS and SQLite-queue adapters
domain — pure types and CQRS port traits (MovieCommand/MovieQuery, WatchEventCommand/WatchEventQuery, GoalCommand/GoalQuery, DiaryQuery, PersonCommand/PersonQuery, SearchCommand/SearchPort, SocialCommand/SocialQuery, ImageFetcher, RssFeedRenderer), no external deps except serde
application — use cases (commands + queries), business logic orchestration; handlers delegate here for all domain logic; modules: auth, diary, goals, import, integrations, movies, person, search, social, users, watchlist, wrapup
presentation — Axum HTTP router, OpenAPI spec assembly, Swagger UI + Scalar serving; library crate, no binary of its own
server — owns the HTTP binary, backend-selection features (sqlite/postgres, federation), boots presentation's router
worker — standalone worker binary (event consumer, poster sync, federation)
application — use cases (commands + queries), business logic orchestration; handlers delegate here for all domain logic; modules: auth, diary, goals, import, insights, integrations, jobs, movies, person, search, social, users, watchlist, wrapup
server — thin HTTP binary (dotenv, config, bootstrap::build_server, axum::serve); features (federation, nats) forward to bootstrap
worker — thin worker binary (bootstrap::build_worker, run until ctrl-c)
adapters/
adapter-common — shared row-to-domain conversions, sqlx error mapping, date/uuid parsing utils
http-axum — Axum HTTP adapter: JSON API, classic HTML UI (askama templates), OpenAPI spec, Swagger UI, federation-gated routes; library crate, no binary of its own
auth — JWT issuance and validation (Argon2 passwords)
sqlite — SQLite repository + connection factory
postgres — PostgreSQL repository + connection factory
sqlite — SQLite repositories (one module per aggregate under repositories/), search (FTS5), social graph, durable event queue, connection factory + migrations; the only storage adapter
metadata — TMDB / OMDb HTTP client
poster-fetcher — downloads poster images
object-storage — stores images (posters + user avatars) on local filesystem or S3-compatible storage
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)
media-server — Jellyfin and Plex webhook payload parsers (MediaServerParser adapter; Plex requires Plex Pass)
event-publisher — in-memory event channel
nats — NATS Core / JetStream event publisher and consumer
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
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
sqlite-federation — SQLite-backed federation repository (k-ap trait implementations; only built with the `federation` feature)
tui — terminal UI client (ratatui); shares api-types with http-axum for typed API access
spa/ — React SPA (TanStack Router + shadcn/ui + Vite); served at /app/ by the backend
```
@@ -138,34 +129,58 @@ spa/ — React SPA (TanStack Router + shadcn/ui + Vite); served a
## Configuration
Copy `.env.example` to `.env` and set the values below. Required fields must be set before the server will start.
Settings are layered, later sources winning:
1. built-in defaults
2. `config.toml` in the working directory, or the file named by `CONFIG_FILE` (a named file that does not exist is an error; the default `config.toml` is optional)
3. environment variables (including those from `.env`), under the names in the table below
Copy `.env.example` to `.env` and set the values you need. Malformed values (a non-numeric `PORT`, an unknown `EVENT_BUS_BACKEND`) fail startup with a message naming the setting. An empty value (`PORT=`) counts as unset, so the default applies. The variable names are unchanged from earlier releases; every one except `RUST_LOG` and `CONFIG_FILE` maps to a `section.field` key in `config.toml` (for example `PORT` is `server.port`, `JWT_SECRET` is `auth.jwt_secret`).
| 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) |
| `DATABASE_URL` | — | Yes | SQLite connection string (e.g. `sqlite://movies.db`); the only value the config loader itself requires |
| `JWT_SECRET` | — | Server | Secret for JWT signing — use a long random string. The server refuses to start without it (the worker does not need it) |
| `OMDB_API_KEY` | — | One of the two | [OMDb](https://www.omdbapi.com/apikey.aspx) key for movie metadata. Server and worker refuse to start unless `TMDB_API_KEY` or `OMDB_API_KEY` is set |
| `TMDB_API_KEY` | — | One of the two | [TMDb](https://www.themoviedb.org/settings/api) key — used for metadata when set (takes precedence over OMDb); enables cast, crew, genres, enrichment |
| `BASE_URL` | `http://localhost:3000` | No | Public URL of your instance (used for ActivityPub actor URLs); set it for any real deployment |
| `IMAGE_STORAGE_BACKEND` | `local` | No | `local` or `s3` |
| `IMAGE_STORAGE_PATH` | `./images` | No | Path for local image storage |
| `MINIO_ENDPOINT` | — | S3 only | S3-compatible endpoint (e.g. `http://localhost:9000`) |
| `MINIO_BUCKET` | — | S3 only | Bucket name |
| `MINIO_REGION` | — | S3 only | Region (e.g. `minio`) |
| `MINIO_REGION` | `minio` | No | Region for S3 storage |
| `MINIO_ACCESS_KEY_ID` | — | S3 only | Access key ID |
| `MINIO_SECRET_ACCESS_KEY` | — | S3 only | Secret access key |
| `IMAGE_CONVERSION_ENABLED` | `false` | No | Convert stored images to AVIF or WebP |
| `IMAGE_CONVERSION_FORMAT` | `avif` | No | `avif` or `webp` |
| `IMAGE_CONVERSION_ENABLED` | `false` | No | Set `true` to convert stored images to AVIF or WebP (only the value `true` enables it) |
| `IMAGE_CONVERSION_FORMAT` | — | When conversion is enabled | `avif` or `webp`; the worker fails at startup if conversion is enabled without it |
| `HOST` | `0.0.0.0` | No | Bind address |
| `PORT` | `3000` | No | HTTP port |
| `RATE_LIMIT` | `60` | No | Requests per minute per IP |
| `ALLOW_REGISTRATION` | `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. `server=info,worker=info`) |
| `CORS_ORIGINS` | `*` | No | Comma-separated allowed origins for SPA dev |
| `ALLOW_REGISTRATION` | `false` | No | Set `true` (or `1`) to allow new sign-ups |
| `SECURE_COOKIES` | `false` | No | Set `true` when serving over HTTPS (only the value `true` enables it) |
| `RUST_LOG` | — | No | Log verbosity (e.g. `server=info,worker=info,bootstrap=info,config=info,http_axum=info`); read by the logger, not part of `config.toml` |
| `CORS_ORIGINS` | empty | No | Comma-separated allowed origins for the API; empty or `*` allows any origin |
| `EVENT_BUS_BACKEND` | `db` | No | `db` (default) or `nats` |
| `NATS_URL` | — | NATS only | NATS connection URL (e.g. `nats://localhost:4222`) |
`DATABASE_URL` is the only setting the loader requires; the other "required" entries are enforced when the corresponding adapter is built, and failing there also stops startup. Further optional variables (`CONFIG_FILE`, `JWT_TTL_SECONDS`, `REFRESH_TTL_SECONDS`, `POSTER_FETCH_TIMEOUT_SECONDS`, `EVENT_CHANNEL_BUFFER`, `EVENT_QUEUE_*`, `NATS_MODE`, `NATS_STREAM_NAME`, `NATS_SUBJECT_PREFIX`, `NATS_CONSUMER_NAME`, `FEDERATION_DEBUG`, `WRAPUP_*`) are read by `crates/config`; see `crates/config/src/` for defaults.
### Cargo features
| Feature | Default | Effect |
|---|---|---|
| `federation` | yes | ActivityPub federation (`activitypub`, `sqlite-federation`, federation routes) |
| `nats` | no | NATS event bus (`EVENT_BUS_BACKEND=nats`) |
Build without federation with `--no-default-features`. `sqlite` and `sqlite-federation` are deprecated aliases kept so old build commands still work.
### Build notes
- `cargo build` / `cargo test` build every crate except the deprecated `tui`, which sits outside `default-members`. Use `-p tui` for it; `make check` and CI use `--workspace`, so it is still linted and tested.
- Release builds use `codegen-units = 1` and `strip = true` (smaller binaries; thin LTO was measured and made builds slower and binaries larger, so it is off).
- SQLite is compiled from source (bundled `libsqlite3-sys`), so the first clean build spends about a minute on it; incremental builds and the Docker/CI caches don't repeat that.
- TLS uses rustls with the `ring` provider only; `aws-lc` and `actix` are kept out of the graph by `make check-no-second-web-stack`.
The `worker` binary must run alongside `server` to process events:
```bash
@@ -181,6 +196,8 @@ cargo run -p worker # event worker (poster sync, in a separate terminal)
The worker polls the event queue and must run alongside the server to process background tasks like poster fetching. Both processes share the same database.
**Event flow.** The server never publishes events directly: every write that raises an event stores it in the `event_queue` table in the same transaction (a transactional outbox, see [ADR-0015](docs/adr/0015-transactional-outbox.md)). With `EVENT_BUS_BACKEND=db` the worker consumes those rows; with `nats` the worker relays them to JetStream (with `Nats-Msg-Id` set to the event id) and consumes them from there. Duplicates are filtered in three layers: the broker's duplicate window, a claim lease on queue rows, and a per-(event, handler) processed-event ledger. Handlers must stay idempotent.
## API
All REST endpoints are under `/api/v1/`. Authentication uses `Authorization: Bearer <token>` obtained from `POST /api/v1/auth/login`.
@@ -188,7 +205,6 @@ All REST endpoints are under `/api/v1/`. Authentication uses `Authorization: Bea
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.
@@ -219,23 +235,26 @@ Supports review logging, bulk CSV import, and diary browsing.
A `Makefile` wraps the most common dev commands:
```bash
make # default: fmt-check + clippy + test (same order as CI)
make # make check: the full gate, same steps as CI
make fix # auto-apply fmt + clippy fixes
make fmt # apply rustfmt
make clippy # clippy with -D warnings
make test # cargo test
make test # cargo test --workspace
```
`make check` runs fmt-check, clippy, tests (default features and `--no-default-features`), and the layering guards (see `CONTRIBUTING.md`).
## 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 --workspace # everything; needs no external services
cargo test -p application # business logic tests only — in-memory fakes, no database
cargo test -p domain # domain model + value object tests
cargo test -p bootstrap # route smoke, API and handler tests over the real wiring
cargo llvm-cov -p application -p domain # line coverage report (requires cargo-llvm-cov)
```
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.
Tests live in `crates/<crate>/tests/`; the exceptions are listed in `CODE_STYLE.md`. The `application` and `domain` crates have 400+ tests covering all use case modules (auth, diary, goals, import, insights, integrations, movies, person, search, users, watchlist, wrapup) backed by the in-memory fakes and factories in `domain::testing` (the `test-helpers` feature). They are the fastest feedback loop for business logic changes.
## Docker
@@ -260,7 +279,7 @@ 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`
Default build enables `federation`; add NATS with `--build-arg FEATURES=federation,nats`. The Dockerfile uses [cargo-chef](https://github.com/LukeMathWalker/cargo-chef): dependencies are compiled in their own layer keyed on the manifests, so a source-only change rebuilds just the workspace crates. BuildKit cache mounts keep the cargo registry between builds. No Dockerfile edit is needed when a crate is added. SQLite is the only database backend (see [ADR-0012](docs/adr/0012-sqlite-is-the-only-backend.md)).
## Media Server Integration

View File

@@ -2,15 +2,19 @@
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>"]
subgraph Binaries["Binaries (thin: config + bootstrap + run)"]
SERVER["server<br/><i>load config, bootstrap::build_server, axum::serve</i>"]
WORKER["worker<br/><i>bootstrap::build_worker</i><br/>Concurrent dispatch, graceful shutdown"]
TUI["tui<br/><i>Terminal UI (deprecated)</i>"]
end
subgraph Config["config"]
CFG["AppConfig<br/><i>defaults, config.toml / CONFIG_FILE,<br/>legacy env names</i>"]
end
subgraph Application["Application Layer"]
direction TB
CTX["AppContext<br/><i>Repositories + Services</i>"]
DEPS["Deps<br/><i>one narrow deps struct per use case</i>"]
APP_PORTS["ReviewLogger<br/><i>application-layer port</i>"]
subgraph UseCases["Use Cases"]
UC_AUTH["auth<br/>login, register"]
@@ -33,7 +37,7 @@ graph TB
EH_CLEANUP["SearchCleanupHandler"]
EH_REINDEX["SearchReindexHandler"]
EH_WRAPUP["WrapUpEventHandler"]
EH_AP["ActivityPubEventHandler<br/><i>reviews, watchlist, goals,<br/>UserDeleted, UserAccountMoved</i>"]
EH_POSTER["PosterSyncHandler<br/><i>poster sync</i>"]
end
subgraph Jobs["Periodic Jobs"]
JOB_IMPORT["ImportSessionCleanup"]
@@ -77,66 +81,68 @@ graph TB
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>"]
subgraph Bootstrap["bootstrap"]
BS["build_server, build_worker<br/><i>Composition root: the only crate that names adapters</i>"]
end
subgraph EventPayload["event-payload"]
EP["Serde payloads for domain events"]
end
subgraph Adapters["Adapters (implement Port Traits)"]
direction TB
A_COMMON["adapter-common<br/><i>Shared row conversions,<br/>error mapping, date utils</i>"]
subgraph Http["HTTP driving adapter"]
A_HTTP["http-axum<br/><i>JSON API, classic HTML (askama),<br/>OpenAPI, federation-gated routes,<br/>one refusal shape</i>"]
end
subgraph Storage["Storage"]
A_SQLITE["sqlite<br/><i>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_SQLITE["sqlite<br/><i>repositories per aggregate, FTS5 search,<br/>social graph, durable event queue, migrations</i>"]
A_OBJ["object-storage<br/><i>S3 / filesystem</i>"]
end
subgraph Messaging["Messaging"]
A_NATS["nats<br/><i>JetStream / Core</i>"]
A_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>"]
A_EVT_PUB["event-publisher<br/><i>In-memory channel</i>"]
end
subgraph External["External Services"]
A_METADATA["metadata<br/><i>TMDB search/details</i>"]
A_METADATA["metadata<br/><i>TMDB / OMDb search/details</i>"]
A_TMDB["tmdb-enrichment<br/><i>Credits, keywords, cast</i>"]
A_POSTER["poster-fetcher<br/><i>TMDB image download</i>"]
A_AUTH["auth<br/><i>JWT tokens</i>"]
end
subgraph Federation["Federation (feature-gated)"]
A_AP["activitypub<br/><i>k_ap library</i>"]
A_SQLITE_FED["sqlite-federation"]
A_PG_FED["postgres-federation"]
A_AP["activitypub<br/><i>k_ap library; ActivityPubEventHandler<br/>(reviews, watchlist, goals, UserDeleted, UserAccountMoved)</i>"]
A_SQLITE_FED["sqlite-federation<br/><i>k_ap repositories</i>"]
end
subgraph Media["Media Processing"]
A_IMG["image-converter<br/><i>AVIF/WebP</i>"]
A_POSTER_SYNC["poster-sync"]
end
subgraph Presentation["Presentation Helpers"]
A_TEMPLATE["template-askama<br/><i>HTML templates</i>"]
subgraph Formats["Formats"]
A_RSS["rss<br/><i>Feed generation</i>"]
A_EXPORT["export<br/><i>CSV/JSON diary</i>"]
A_IMPORT["importer<br/><i>CSV/JSON/XLSX parser</i>"]
end
subgraph Webhooks["Webhook Parsers"]
A_JELLYFIN["jellyfin"]
A_PLEX["plex"]
A_MEDIA_SERVER["media-server<br/><i>Jellyfin + Plex webhook parsers</i>"]
end
end
%% Dependency arrows
WEB -->|"uses"| Application
WEB -->|"maps to"| ApiTypes
WORKER -->|"uses"| Application
SERVER -->|"loads"| Config
SERVER -->|"calls"| Bootstrap
WORKER -->|"loads"| Config
WORKER -->|"calls"| Bootstrap
Bootstrap -->|"wires"| Adapters
Bootstrap -->|"builds deps for"| Application
A_HTTP -->|"uses"| Application
A_HTTP -->|"maps to"| ApiTypes
TUI -->|"typed API access"| ApiTypes
Application -->|"depends on"| Domain
UC_WRAPUP -->|"delegates to"| DS_WRAPUP
Adapters -.->|"implements"| Ports
A_NATS -.->|"serialises with"| EventPayload
A_SQLITE -.->|"event queue serialises with"| EventPayload
%% Key data flows
WEB ===|"HTTP"| DTO
A_HTTP ===|"HTTP"| DTO
WORKER ===|"Events"| EVENTS
classDef domain fill:#1a1a2e,stroke:#e94560,color:#fff
@@ -150,4 +156,6 @@ graph TB
class Application app
class Adapters adapter
class Binaries binary
class Config app
class Bootstrap binary
class ApiTypes api

View File

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

View File

@@ -0,0 +1,33 @@
use std::sync::Arc;
use async_trait::async_trait;
use domain::{
errors::DomainError,
ports::FederationDeletionNotifier,
value_objects::{InstanceIdentity, UserId},
};
use k_ap::ActivityPubService;
use crate::urls::actor_url;
pub struct ApDeletionNotifier {
service: Arc<ActivityPubService>,
instance: InstanceIdentity,
}
impl ApDeletionNotifier {
pub fn new(service: Arc<ActivityPubService>, instance: InstanceIdentity) -> Self {
Self { service, instance }
}
}
#[async_trait]
impl FederationDeletionNotifier for ApDeletionNotifier {
async fn notify_deleted(&self, user: &UserId) -> Result<(), DomainError> {
let ap_id = actor_url(&self.instance, user.value());
self.service
.broadcast_delete_to_followers(user.value(), ap_id)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))
}
}

View File

@@ -6,7 +6,7 @@ use domain::{
events::DomainEvent,
ports::{
GoalQuery, LocalApContentQuery, MovieQuery, ReviewRepository, StatsRepository,
UserFederationSettingsQuery,
UserFederationSettingsQuery, UserRepository,
},
value_objects::{InstanceIdentity, MovieId, ReviewId, UserId},
};
@@ -25,6 +25,7 @@ pub struct ActivityPubEventHandler {
goal_repo: Arc<dyn GoalQuery>,
stats_repo: Arc<dyn StatsRepository>,
federation_settings: Arc<dyn UserFederationSettingsQuery>,
user_repo: Arc<dyn UserRepository>,
instance: InstanceIdentity,
}
@@ -38,6 +39,7 @@ impl ActivityPubEventHandler {
goal_repo: Arc<dyn GoalQuery>,
stats_repo: Arc<dyn StatsRepository>,
federation_settings: Arc<dyn UserFederationSettingsQuery>,
user_repo: Arc<dyn UserRepository>,
instance: InstanceIdentity,
) -> Self {
Self {
@@ -48,6 +50,7 @@ impl ActivityPubEventHandler {
goal_repo,
stats_repo,
federation_settings,
user_repo,
instance,
}
}
@@ -55,7 +58,17 @@ impl ActivityPubEventHandler {
#[async_trait]
impl EventHandler for ActivityPubEventHandler {
fn name(&self) -> &'static str {
"activitypub"
}
async fn handle(&self, event: &DomainEvent) -> Result<(), DomainError> {
if let Some(author) = self.outbound_author(event)
&& self.is_tombstoned(&author).await
{
tracing::debug!(user = %author.value(), "skipping federation event of a deleted account");
return Ok(());
}
match event {
DomainEvent::ReviewLogged {
review_id, user_id, ..
@@ -139,13 +152,6 @@ impl EventHandler for ActivityPubEventHandler {
.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.instance, 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,
@@ -164,6 +170,36 @@ impl EventHandler for ActivityPubEventHandler {
}
impl ActivityPubEventHandler {
fn outbound_author(&self, event: &DomainEvent) -> Option<UserId> {
match event {
DomainEvent::ReviewLogged { user_id, .. }
| DomainEvent::ReviewUpdated { user_id, .. }
| DomainEvent::ReviewDeleted { user_id, .. }
| DomainEvent::UserUpdated { user_id }
| DomainEvent::WatchlistEntryAdded { user_id, .. }
| DomainEvent::WatchlistEntryRemoved { user_id, .. }
| DomainEvent::GoalCreated { user_id, .. }
| DomainEvent::GoalUpdated { user_id, .. }
| DomainEvent::GoalDeleted { user_id, .. }
| DomainEvent::UserAccountMoved { user_id, .. } => Some(user_id.clone()),
DomainEvent::FederationDeliveryRequested {
activity_json,
signing_actor_id,
..
} if activity_json.get("type").and_then(|t| t.as_str()) != Some("Delete") => {
Some(UserId::from_uuid(*signing_actor_id))
}
_ => None,
}
}
async fn is_tombstoned(&self, user: &UserId) -> bool {
matches!(
self.user_repo.find_tombstoned_by_id(user).await,
Ok(Some(_))
)
}
async fn on_review_logged(&self, user_id: &UserId, review_id: &ReviewId) -> anyhow::Result<()> {
let flags = self
.federation_settings

View File

@@ -4,18 +4,20 @@ use async_trait::async_trait;
use domain::{
errors::DomainError,
models::{BlockedDomainInfo, FollowedActorInfo},
ports::{ApBackfillPort, ApDocumentPort, InstanceBlocklistPort},
ports::{ApBackfillPort, ApDocumentPort, InstanceBlocklistPort, UserRepository},
value_objects::UserId,
};
use k_ap::ActivityPubService;
use uuid::Uuid;
pub struct ApServiceAdapter {
service: Arc<ActivityPubService>,
users: Arc<dyn UserRepository>,
}
impl ApServiceAdapter {
pub fn new(service: Arc<ActivityPubService>) -> Self {
Self { service }
pub fn new(service: Arc<ActivityPubService>, users: Arc<dyn UserRepository>) -> Self {
Self { service, users }
}
}
@@ -26,6 +28,13 @@ fn ap_err(e: anyhow::Error) -> DomainError {
#[async_trait]
impl ApDocumentPort for ApServiceAdapter {
async fn actor_is_gone(&self, user_id: Uuid) -> Result<bool, DomainError> {
Ok(self
.users
.find_tombstoned_by_id(&UserId::from_uuid(user_id))
.await?
.is_some())
}
async fn actor_json(&self, user_id: &str) -> Result<String, DomainError> {
self.service.actor_json(user_id).await.map_err(ap_err)
}

View File

@@ -1,4 +1,5 @@
pub mod composite_handler;
pub mod deletion_notifier;
pub mod event_handler;
pub mod federation_event_bridge;
pub mod federation_ports;
@@ -7,12 +8,12 @@ pub mod objects;
pub mod remote_review_repository;
pub mod review_handler;
pub mod social_adapter;
pub mod tombstone_gate;
pub(crate) mod urls;
pub mod user_adapter;
pub mod watchlist_handler;
pub const INSTANCE_ACTOR_ID: uuid::Uuid =
uuid::Uuid::from_bytes([0, 0, 0, 0, 0, 0, 0x40, 0, 0x80, 0, 0, 0, 0, 0, 0, 0]);
pub const INSTANCE_ACTOR_ID: uuid::Uuid = domain::value_objects::SERVICE_ACTOR_UUID;
pub use k_ap::{
ActivityPubService, ActivityRepository, ActorRepository, ApContentReader, ApFederationConfig,
@@ -20,9 +21,10 @@ pub use k_ap::{
FollowRepository, Follower, FollowerStatus, FollowingStatus, LocalObject, RemoteActor,
};
pub use deletion_notifier::ApDeletionNotifier;
pub use event_handler::ActivityPubEventHandler;
pub use federation_ports::ApServiceAdapter;
pub use remote_review_repository::{RemoteReviewRepository, RemoteReviewUpdate};
pub use remote_review_repository::{NewRemoteReview, RemoteReviewRepository, RemoteReviewUpdate};
pub use review_handler::ReviewObjectHandler;
pub use social_adapter::CompositeSocialAdapter;
pub use user_adapter::DomainUserRepoAdapter;
@@ -46,6 +48,7 @@ pub struct ActivityPubWire {
pub service: std::sync::Arc<ActivityPubService>,
pub router: axum::Router,
pub event_handler: std::sync::Arc<dyn domain::ports::EventHandler>,
pub deletion_notifier: std::sync::Arc<dyn domain::ports::FederationDeletionNotifier>,
}
pub struct ActivityPubDeps {
@@ -68,6 +71,7 @@ pub struct ActivityPubDeps {
pub follow_query: std::sync::Arc<dyn domain::ports::FollowQuery>,
pub instance: domain::value_objects::InstanceIdentity,
pub allow_registration: bool,
pub federation: config::FederationConfig,
pub event_publisher: std::sync::Arc<dyn domain::ports::EventPublisher>,
}
@@ -92,6 +96,7 @@ pub async fn wire(deps: ActivityPubDeps) -> anyhow::Result<ActivityPubWire> {
follow_query: _,
instance,
allow_registration,
federation,
event_publisher,
} = deps;
let review_handler = std::sync::Arc::new(ReviewObjectHandler {
@@ -99,7 +104,6 @@ pub async fn wire(deps: ActivityPubDeps) -> anyhow::Result<ActivityPubWire> {
movie_repo: std::sync::Arc::clone(&movie_repo),
diary_repo,
review_store,
event_publisher: std::sync::Arc::clone(&event_publisher),
instance: instance.clone(),
});
let watchlist_handler = std::sync::Arc::new(watchlist_handler::WatchlistObjectHandler {
@@ -118,9 +122,7 @@ pub async fn wire(deps: ActivityPubDeps) -> anyhow::Result<ActivityPubWire> {
goal: goal_handler,
});
let federation_debug = std::env::var("FEDERATION_DEBUG")
.map(|v| v == "true" || v == "1")
.unwrap_or(false);
let federation_debug = federation.debug;
if federation_debug {
tracing::warn!(
@@ -140,7 +142,7 @@ pub async fn wire(deps: ActivityPubDeps) -> anyhow::Result<ActivityPubWire> {
.actor_repo(actor_repo)
.blocklist_repo(blocklist_repo)
.user_repo(std::sync::Arc::new(DomainUserRepoAdapter::new(
user_repo,
std::sync::Arc::clone(&user_repo),
instance.clone(),
)))
.signed_fetch_actor_id(INSTANCE_ACTOR_ID)
@@ -158,7 +160,16 @@ pub async fn wire(deps: ActivityPubDeps) -> anyhow::Result<ActivityPubWire> {
.await?,
);
let router = concrete.router();
let router = concrete
.router()
.layer(axum::middleware::from_fn_with_state(
std::sync::Arc::clone(&user_repo),
tombstone_gate::tombstone_gate,
));
let deletion_notifier = std::sync::Arc::new(ApDeletionNotifier::new(
std::sync::Arc::clone(&concrete),
instance.clone(),
)) as std::sync::Arc<dyn domain::ports::FederationDeletionNotifier>;
let event_handler = std::sync::Arc::new(ActivityPubEventHandler::new(
std::sync::Arc::clone(&concrete),
local_ap_content,
@@ -167,11 +178,13 @@ pub async fn wire(deps: ActivityPubDeps) -> anyhow::Result<ActivityPubWire> {
goal_repo,
stats_repo,
federation_settings,
std::sync::Arc::clone(&user_repo),
instance,
)) as std::sync::Arc<dyn domain::ports::EventHandler>;
let ports = std::sync::Arc::new(federation_ports::ApServiceAdapter::new(
std::sync::Arc::clone(&concrete),
std::sync::Arc::clone(&user_repo),
));
Ok(ActivityPubWire {
@@ -183,5 +196,6 @@ pub async fn wire(deps: ActivityPubDeps) -> anyhow::Result<ActivityPubWire> {
service: concrete,
router,
event_handler,
deletion_notifier,
})
}

View File

@@ -306,7 +306,3 @@ pub fn goal_to_ap_object(
cc: vec![format!("{}/followers", actor_url)],
}
}
#[cfg(test)]
#[path = "tests/objects.rs"]
mod tests;

View File

@@ -1,7 +1,7 @@
use anyhow::Result;
use async_trait::async_trait;
use chrono::NaiveDateTime;
use domain::models::Review;
use domain::{events::DomainEvent, models::Review};
pub struct RemoteReviewUpdate<'a> {
pub ap_id: &'a str,
@@ -14,17 +14,19 @@ pub struct RemoteReviewUpdate<'a> {
pub spoilers: bool,
}
pub struct NewRemoteReview<'a> {
pub review: &'a Review,
pub ap_id: &'a str,
pub movie_title: &'a str,
pub release_year: u16,
pub external_metadata_id: Option<&'a str>,
pub poster_url: Option<&'a str>,
pub events: &'a [DomainEvent],
}
#[async_trait]
pub trait RemoteReviewRepository: Send + Sync {
async fn save_remote_review(
&self,
review: &Review,
ap_id: &str,
movie_title: &str,
release_year: u16,
external_metadata_id: Option<&str>,
poster_url: Option<&str>,
) -> Result<()>;
async fn save_remote_review(&self, new: NewRemoteReview<'_>) -> Result<()>;
async fn delete_remote_review(&self, ap_id: &str, actor_url: &str) -> Result<()>;

View File

@@ -4,7 +4,7 @@ use async_trait::async_trait;
use domain::{
events::DomainEvent,
models::ReviewSource,
ports::{DiaryQuery, EventPublisher, LocalApContentQuery, MovieQuery},
ports::{DiaryQuery, LocalApContentQuery, MovieQuery},
value_objects::{
Comment, ExternalMetadataId, InstanceIdentity, MovieId, Rating, ReviewId, UserId,
},
@@ -13,7 +13,7 @@ use k_ap::{AS_PUBLIC, ApContentReader, ApObjectHandler, LocalObject};
use url::Url;
use crate::objects::{ReviewApInput, ReviewObject, review_to_ap_object};
use crate::remote_review_repository::RemoteReviewRepository;
use crate::remote_review_repository::{NewRemoteReview, RemoteReviewRepository};
use crate::urls::{actor_url, review_url};
pub struct ReviewObjectHandler {
@@ -21,7 +21,6 @@ pub struct ReviewObjectHandler {
pub movie_repo: Arc<dyn MovieQuery>,
pub diary_repo: Arc<dyn DiaryQuery>,
pub review_store: Arc<dyn RemoteReviewRepository>,
pub event_publisher: Arc<dyn EventPublisher>,
pub instance: InstanceIdentity,
}
@@ -160,28 +159,30 @@ impl ApObjectHandler for ReviewObjectHandler {
spoilers: obj.contains_spoilers,
});
self.review_store
.save_remote_review(
&review,
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 {
let events: Vec<DomainEvent> = obj
.external_metadata_id
.as_ref()
.and_then(|ext_id| ExternalMetadataId::new(ext_id.clone()).ok())
.map(
|external_metadata_id| DomainEvent::MovieEnrichmentRequested {
movie_id: movie_id.clone(),
external_metadata_id,
},
)
.into_iter()
.collect();
self.review_store
.save_remote_review(NewRemoteReview {
review: &review,
ap_id: obj.id.as_str(),
movie_title: &obj.movie_title,
release_year: obj.release_year,
external_metadata_id: obj.external_metadata_id.as_deref(),
poster_url: obj.poster_url.as_deref(),
events: &events,
})
.await;
}
.await?;
Ok(())
}

View File

@@ -3,7 +3,10 @@ use std::sync::Arc;
use async_trait::async_trait;
use domain::{
errors::DomainError,
ports::{BlockQuery, FollowGraphQuery, LocalSocial, SocialCommand, UserRepository},
events::DomainEvent,
ports::{
BlockQuery, EventPublisher, FollowGraphQuery, LocalSocial, SocialCommand, UserRepository,
},
value_objects::{
FollowRelation, FollowTarget, InstanceIdentity, SocialActor, SocialIdentity, UserId,
},
@@ -16,6 +19,7 @@ pub struct CompositeSocialAdapter {
ap_service: Arc<ActivityPubService>,
user_repo: Arc<dyn UserRepository>,
instance: InstanceIdentity,
event_publisher: Arc<dyn EventPublisher>,
}
impl CompositeSocialAdapter {
@@ -24,12 +28,22 @@ impl CompositeSocialAdapter {
ap_service: Arc<ActivityPubService>,
user_repo: Arc<dyn UserRepository>,
instance: InstanceIdentity,
event_publisher: Arc<dyn EventPublisher>,
) -> Self {
Self {
local,
ap_service,
user_repo,
instance,
event_publisher,
}
}
async fn announce(&self, events: &[DomainEvent]) {
for event in events {
if let Err(e) = self.event_publisher.publish(event).await {
tracing::warn!("failed to publish {event:?} after k_ap write: {e}");
}
}
}
}
@@ -40,11 +54,19 @@ fn ap_err(e: anyhow::Error) -> DomainError {
#[async_trait]
impl SocialCommand for CompositeSocialAdapter {
async fn follow(&self, follower: &UserId, target: &FollowTarget) -> Result<(), DomainError> {
async fn follow(
&self,
follower: &UserId,
target: &FollowTarget,
events: &[DomainEvent],
) -> Result<(), DomainError> {
let identity = self.local.resolve_target(target).await?;
if let SocialIdentity::Local(_) = identity {
return self.local.follow_resolved(follower, &identity).await;
return self
.local
.follow_resolved(follower, &identity, events)
.await;
}
let handle = match target {
@@ -64,21 +86,27 @@ impl SocialCommand for CompositeSocialAdapter {
self.ap_service
.follow(follower.value(), &handle)
.await
.map_err(ap_err)
.map_err(ap_err)?;
self.announce(events).await;
Ok(())
}
async fn unfollow(
&self,
follower: &UserId,
target: &SocialIdentity,
events: &[DomainEvent],
) -> Result<(), DomainError> {
match target {
SocialIdentity::Local(_) => self.local.unfollow(follower, target).await,
SocialIdentity::Remote { .. } => self
.ap_service
SocialIdentity::Local(_) => self.local.unfollow(follower, target, events).await,
SocialIdentity::Remote { .. } => {
self.ap_service
.unfollow(follower.value(), &self.instance.actor_url_of(target))
.await
.map_err(ap_err),
.map_err(ap_err)?;
self.announce(events).await;
Ok(())
}
}
}
@@ -86,14 +114,18 @@ impl SocialCommand for CompositeSocialAdapter {
&self,
owner: &UserId,
requester: &SocialIdentity,
events: &[DomainEvent],
) -> Result<(), DomainError> {
match requester {
SocialIdentity::Local(_) => self.local.accept_follow(owner, requester).await,
SocialIdentity::Remote { .. } => self
.ap_service
SocialIdentity::Local(_) => self.local.accept_follow(owner, requester, events).await,
SocialIdentity::Remote { .. } => {
self.ap_service
.accept_follower(owner.value(), &self.instance.actor_url_of(requester))
.await
.map_err(ap_err),
.map_err(ap_err)?;
self.announce(events).await;
Ok(())
}
}
}
@@ -101,14 +133,18 @@ impl SocialCommand for CompositeSocialAdapter {
&self,
owner: &UserId,
requester: &SocialIdentity,
events: &[DomainEvent],
) -> Result<(), DomainError> {
match requester {
SocialIdentity::Local(_) => self.local.reject_follow(owner, requester).await,
SocialIdentity::Remote { .. } => self
.ap_service
SocialIdentity::Local(_) => self.local.reject_follow(owner, requester, events).await,
SocialIdentity::Remote { .. } => {
self.ap_service
.reject_follower(owner.value(), &self.instance.actor_url_of(requester))
.await
.map_err(ap_err),
.map_err(ap_err)?;
self.announce(events).await;
Ok(())
}
}
}
@@ -116,31 +152,49 @@ impl SocialCommand for CompositeSocialAdapter {
&self,
owner: &UserId,
follower: &SocialIdentity,
events: &[DomainEvent],
) -> Result<(), DomainError> {
match follower {
SocialIdentity::Local(_) => self.local.remove_follower(owner, follower).await,
SocialIdentity::Remote { .. } => self
.ap_service
SocialIdentity::Local(_) => self.local.remove_follower(owner, follower, events).await,
SocialIdentity::Remote { .. } => {
self.ap_service
.remove_follower(owner.value(), &self.instance.actor_url_of(follower))
.await
.map_err(ap_err),
.map_err(ap_err)?;
self.announce(events).await;
Ok(())
}
}
}
async fn block(&self, blocker: &UserId, target: &SocialIdentity) -> Result<(), DomainError> {
async fn block(
&self,
blocker: &UserId,
target: &SocialIdentity,
events: &[DomainEvent],
) -> Result<(), DomainError> {
let actor_url = self.instance.actor_url_of(target);
self.ap_service
.block_actor(blocker.value(), &actor_url)
.await
.map_err(ap_err)
.map_err(ap_err)?;
self.announce(events).await;
Ok(())
}
async fn unblock(&self, blocker: &UserId, target: &SocialIdentity) -> Result<(), DomainError> {
async fn unblock(
&self,
blocker: &UserId,
target: &SocialIdentity,
events: &[DomainEvent],
) -> Result<(), DomainError> {
let actor_url = self.instance.actor_url_of(target);
self.ap_service
.unblock_actor(blocker.value(), &actor_url)
.await
.map_err(ap_err)
.map_err(ap_err)?;
self.announce(events).await;
Ok(())
}
}

View File

@@ -1,175 +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,
spoilers: false,
});
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,
spoilers: false,
});
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 review_to_ap_object_carries_spoiler_flag_and_summary() {
use chrono::NaiveDateTime;
use domain::{
models::{PersistedReview, Review, ReviewSource},
value_objects::{Comment, MovieId, Rating, ReviewId, UserId},
};
let ts = NaiveDateTime::parse_from_str("2024-06-01 00:00:00", "%Y-%m-%d %H:%M:%S").unwrap();
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: Some(Comment::new("the mentor is the villain".into()).unwrap()),
watched_at: ts,
created_at: ts,
source: ReviewSource::Local,
watch_medium: None,
spoilers: true,
});
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!(obj.contains_spoilers);
assert_eq!(obj.summary.as_deref(), Some("Contains spoilers"));
let json = serde_json::to_value(&obj).unwrap();
assert_eq!(json["containsSpoilers"], serde_json::Value::Bool(true));
assert_eq!(json["summary"], "Contains spoilers");
// A flag without a comment never travels: there is nothing to veil.
let review_no_comment = 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: ts,
created_at: ts,
source: ReviewSource::Local,
watch_medium: None,
spoilers: true,
});
let obj2 = review_to_ap_object(
&review_no_comment,
ReviewApInput {
ap_id: "https://example.com/reviews/2".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!(!obj2.contains_spoilers);
assert!(obj2.summary.is_none());
}
#[test]
fn watchlist_to_ap_object_has_public_addressing() {
let actor_url: url::Url = "https://example.com/users/abc".parse().unwrap();
let obj = watchlist_to_ap_object(WatchlistApInput {
ap_id: "https://example.com/watchlist/1".parse().unwrap(),
actor_url: actor_url.clone(),
movie_title: "Alien".to_string(),
release_year: 1979,
external_metadata_id: None,
poster_url: None,
added_at: chrono::Utc::now(),
base_url: "https://example.com".to_string(),
});
assert_eq!(obj.to, vec!["https://www.w3.org/ns/activitystreams#Public"]);
assert_eq!(obj.cc, vec!["https://example.com/users/abc/followers"]);
}

View File

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

View File

@@ -65,8 +65,11 @@ impl ApUserRepository for DomainUserRepoAdapter {
async fn find_by_id(&self, id: uuid::Uuid) -> anyhow::Result<Option<ApUser>> {
let user_id = UserId::from_uuid(id);
let user = match self.repo.find_by_id(&user_id).await? {
Some(u) => u,
None => match self.repo.find_tombstoned_by_id(&user_id).await? {
Some(u) => u,
None => return Ok(None),
},
};
Ok(Some(self.build_user(&user)))
}

View File

@@ -0,0 +1,253 @@
use std::sync::Arc;
use std::time::Duration;
use activitypub::ActivityPubEventHandler;
use async_trait::async_trait;
use chrono::NaiveDate;
use domain::{
errors::DomainError,
events::DomainEvent,
models::{DiaryEntry, WatchlistWithMovie},
ports::{
EventHandler, FederationDeletionNotifier, LocalApContentQuery, MovieCommand, UserRepository,
},
testing::{
InMemoryStore, PanicGoalQuery, PanicReviewRepository, PanicStatsRepository, test_movie,
},
value_objects::{InstanceIdentity, MovieId, UserId},
};
use k_ap::{
ActivityPubService, ApUser, FederationEvent,
testing::{MockActorRepoBuilder, MockFollowRepoBuilder, MockUserRepoBuilder},
};
use tokio::sync::mpsc;
struct NoContent;
#[async_trait]
impl LocalApContentQuery for NoContent {
async fn get_local_watchlist_for_user(
&self,
_: &UserId,
) -> Result<Vec<WatchlistWithMovie>, DomainError> {
Ok(vec![])
}
async fn get_local_reviews_for_movie(
&self,
_: &MovieId,
) -> Result<Vec<DiaryEntry>, DomainError> {
Ok(vec![])
}
async fn get_local_reviews_page(
&self,
_: &UserId,
_: Option<chrono::NaiveDateTime>,
_: usize,
) -> Result<Vec<DiaryEntry>, DomainError> {
Ok(vec![])
}
}
struct Capture(mpsc::UnboundedSender<serde_json::Value>);
#[async_trait]
impl k_ap::EventPublisher for Capture {
async fn publish(&self, event: FederationEvent) -> anyhow::Result<()> {
if let FederationEvent::DeliveryRequested { activity, .. } = event {
let _ = self.0.send(activity);
}
Ok(())
}
}
struct Harness {
handler: ActivityPubEventHandler,
delivered: mpsc::UnboundedReceiver<serde_json::Value>,
user: UserId,
movie_id: MovieId,
store: Arc<InMemoryStore>,
service: Arc<ActivityPubService>,
}
async fn harness() -> Harness {
let user = UserId::from_uuid(uuid::Uuid::new_v4());
let uid = user.value();
let (tx, rx) = mpsc::unbounded_channel();
let service = ActivityPubService::builder("https://diary.example")
.activity_repo(k_ap::testing::MockActivityRepoBuilder::new().build())
.follow_repo(
MockFollowRepoBuilder::new()
.on_get_accepted_follower_inboxes(|_| {
Ok(vec!["https://remote.example/inbox".to_string()])
})
.build(),
)
.actor_repo(MockActorRepoBuilder::new().build())
.blocklist_repo(k_ap::testing::MockBlocklistRepoBuilder::new().build())
.user_repo(
MockUserRepoBuilder::new()
.on_find_by_id(move |id| {
Ok((id == uid).then(|| ApUser {
id,
username: "alice".into(),
display_name: None,
bio: None,
avatar_url: None,
banner_url: None,
also_known_as: vec![],
profile_url: None,
attachment: vec![],
manually_approves_followers: false,
discoverable: true,
actor_type: Default::default(),
featured_url: None,
}))
})
.build(),
)
.content_reader(k_ap::testing::MockContentReaderBuilder::new().build())
.object_handler(k_ap::testing::MockObjectHandlerBuilder::new().build())
.event_publisher(Arc::new(Capture(tx)))
.build()
.await
.expect("service builds");
let store = InMemoryStore::new();
let movie = test_movie("Heat");
store.upsert_movie(&movie, &[]).await.unwrap();
let service = Arc::new(service);
store
.save(&domain::testing::test_user_with_role(
user.clone(),
domain::models::UserRole::Standard,
))
.await
.unwrap();
let handler = ActivityPubEventHandler::new(
Arc::clone(&service),
Arc::new(NoContent),
Arc::new(PanicReviewRepository),
store.clone(),
Arc::new(PanicGoalQuery),
Arc::new(PanicStatsRepository),
store.clone(),
store.clone(),
InstanceIdentity::new("https://diary.example"),
);
Harness {
handler,
delivered: rx,
user,
movie_id: movie.id().clone(),
store,
service,
}
}
impl Harness {
async fn next_activity_id(&mut self) -> String {
let activity = tokio::time::timeout(Duration::from_secs(5), self.delivered.recv())
.await
.expect("an activity was delivered")
.expect("channel open");
activity["id"]
.as_str()
.expect("activity has an id")
.to_string()
}
}
#[tokio::test]
async fn activitypub_is_idempotent_when_run_twice() {
let mut h = harness().await;
let event = DomainEvent::WatchlistEntryAdded {
user_id: h.user.clone(),
movie_id: h.movie_id.clone(),
movie_title: "Heat".into(),
release_year: 1995,
external_metadata_id: None,
added_at: NaiveDate::from_ymd_opt(2026, 1, 1)
.unwrap()
.and_hms_opt(0, 0, 0)
.unwrap(),
};
h.handler.handle(&event).await.unwrap();
let first = h.next_activity_id().await;
h.handler.handle(&event).await.unwrap();
let second = h.next_activity_id().await;
assert!(first.starts_with("https://diary.example/activities/create/"));
assert_eq!(first, second, "a redelivered Create keeps its activity id");
}
#[tokio::test]
async fn redelivered_delete_gets_a_fresh_activity_id_known_k_ap_limitation() {
let mut h = harness().await;
let event = DomainEvent::WatchlistEntryRemoved {
user_id: h.user.clone(),
movie_id: h.movie_id.clone(),
};
h.handler.handle(&event).await.unwrap();
let first = h.next_activity_id().await;
h.handler.handle(&event).await.unwrap();
let second = h.next_activity_id().await;
assert_ne!(first, second);
}
#[tokio::test]
async fn a_deleted_account_sends_nothing_but_its_delete() {
let mut h = harness().await;
h.store.mark_deleted(&h.user, &[]).await.unwrap();
let logged = DomainEvent::ReviewLogged {
review_id: domain::value_objects::ReviewId::generate(),
user_id: h.user.clone(),
movie_id: h.movie_id.clone(),
rating: domain::value_objects::Rating::new(4).unwrap(),
watched_at: NaiveDate::from_ymd_opt(2026, 1, 1)
.unwrap()
.and_hms_opt(0, 0, 0)
.unwrap(),
};
let added = DomainEvent::WatchlistEntryAdded {
user_id: h.user.clone(),
movie_id: h.movie_id.clone(),
movie_title: "Heat".into(),
release_year: 1995,
external_metadata_id: None,
added_at: NaiveDate::from_ymd_opt(2026, 1, 1)
.unwrap()
.and_hms_opt(0, 0, 0)
.unwrap(),
};
let queued = DomainEvent::FederationDeliveryRequested {
inbox_url: "https://remote.example/inbox".into(),
activity_json: serde_json::json!({"type": "Create"}),
signing_actor_id: h.user.value(),
};
for event in [&logged, &added, &queued] {
h.handler.handle(event).await.unwrap();
}
assert!(
tokio::time::timeout(Duration::from_millis(300), h.delivered.recv())
.await
.is_err(),
"nothing is delivered for a deleted account"
);
activitypub::ApDeletionNotifier::new(
Arc::clone(&h.service),
InstanceIdentity::new("https://diary.example"),
)
.notify_deleted(&h.user)
.await
.unwrap();
let delete = tokio::time::timeout(Duration::from_secs(5), h.delivered.recv())
.await
.expect("the Delete is delivered")
.unwrap();
assert_eq!(delete["type"], "Delete");
}

View File

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

View File

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

View File

@@ -0,0 +1,26 @@
use activitypub::objects::{WatchlistApInput, watchlist_to_ap_object};
use serde_json::json;
#[test]
fn watchlist_object_is_publicly_addressed_with_followers_in_cc() {
let obj = watchlist_to_ap_object(WatchlistApInput {
ap_id: "https://example.com/watchlist/1".parse().unwrap(),
actor_url: "https://example.com/users/abc".parse().unwrap(),
movie_title: "Alien".to_string(),
release_year: 1979,
external_metadata_id: None,
poster_url: None,
added_at: chrono::Utc::now(),
base_url: "https://example.com".to_string(),
});
let json = serde_json::to_value(&obj).unwrap();
assert_eq!(
json["to"],
json!(["https://www.w3.org/ns/activitystreams#Public"])
);
assert_eq!(
json["cc"],
json!(["https://example.com/users/abc/followers"])
);
}

View File

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

View File

@@ -1,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 }

View File

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

View File

@@ -22,19 +22,17 @@ pub struct AuthConfig {
}
impl AuthConfig {
pub fn from_env() -> anyhow::Result<Self> {
let secret = std::env::var("JWT_SECRET")
.map_err(|_| anyhow::anyhow!("JWT_SECRET env var is required"))?;
pub fn from_config(config: &config::AuthConfig) -> anyhow::Result<Self> {
let secret = config
.jwt_secret
.clone()
.ok_or_else(|| anyhow::anyhow!("JWT_SECRET env var is required"))?;
if secret.is_empty() {
anyhow::bail!("JWT_SECRET must not be empty");
}
let ttl_seconds = std::env::var("JWT_TTL_SECONDS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(900u64);
Ok(Self {
secret,
ttl_seconds,
ttl_seconds: config.jwt_ttl_seconds,
})
}
}
@@ -107,11 +105,13 @@ impl PasswordHasher for Argon2PasswordHasher {
}
}
pub fn create() -> anyhow::Result<(
pub fn create(
config: &config::AuthConfig,
) -> anyhow::Result<(
std::sync::Arc<dyn domain::ports::AuthService>,
std::sync::Arc<dyn domain::ports::PasswordHasher>,
)> {
let config = AuthConfig::from_env()?;
let config = AuthConfig::from_config(config)?;
Ok((
std::sync::Arc::new(JwtAuthService::new(config)),
std::sync::Arc::new(Argon2PasswordHasher),

View File

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

View File

@@ -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");
}

View File

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

View File

@@ -16,12 +16,10 @@ pub struct EventPublisherConfig {
}
impl EventPublisherConfig {
pub fn from_env() -> Self {
let channel_buffer = std::env::var("EVENT_CHANNEL_BUFFER")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(128);
Self { channel_buffer }
pub fn from_config(config: &config::EventsConfig) -> Self {
Self {
channel_buffer: config.channel_buffer,
}
}
}
@@ -49,6 +47,9 @@ impl AckHandle for NoopAck {
async fn nack(&self) -> Result<(), DomainError> {
Ok(())
}
async fn defer(&self, _: std::time::Duration) -> Result<(), DomainError> {
Ok(())
}
}
pub struct ChannelEventConsumer {
@@ -60,7 +61,7 @@ impl EventConsumer for ChannelEventConsumer {
let receiver = Arc::clone(&self.receiver);
Box::pin(stream::unfold(receiver, |rx| async move {
let event = rx.lock().await.recv().await?;
let envelope = EventEnvelope::new(event, Box::new(NoopAck));
let envelope = EventEnvelope::new(None, event, Box::new(NoopAck));
Some((Ok(envelope), rx))
}))
}
@@ -86,7 +87,3 @@ pub fn create_event_channel(
},
)
}
#[cfg(test)]
#[path = "tests/lib.rs"]
mod tests;

View File

@@ -1,8 +1,9 @@
use super::*;
use domain::ports::{EventConsumer, EventPublisher};
use domain::{
events::DomainEvent,
value_objects::{ExternalMetadataId, MovieId},
};
use event_publisher::{EventPublisherConfig, create_event_channel};
use futures::StreamExt;
fn movie_discovered() -> DomainEvent {

View File

@@ -1,17 +1,16 @@
[package]
name = "export"
version = "0.1.0"
edition = "2024"
edition.workspace = true
[dependencies]
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]
domain = { workspace = true, features = ["test-helpers"] }
uuid = { workspace = true }
tokio = { workspace = true }
tokio = { workspace = true, features = ["rt", "macros"] }

View File

@@ -107,7 +107,3 @@ fn entry_to_json(e: &DiaryEntry) -> serde_json::Value {
"spoilers": e.review().spoilers(),
})
}
#[cfg(test)]
#[path = "tests/lib.rs"]
mod tests;

View File

@@ -1,9 +1,12 @@
use super::ExportAdapter;
use domain::{
models::{DiaryEntry, ExportFormat, Movie, Review},
models::{DiaryEntry, ExportFormat, Movie, ReviewEdit},
ports::DiaryExporter,
value_objects::{ExternalMetadataId, MovieTitle, Rating, ReleaseYear},
testing::{
test_movie_with_director, test_movie_with_external_id, test_movie_with_year, test_review,
},
value_objects::{Comment, ExternalMetadataId, UserId},
};
use export::ExportAdapter;
async fn collect_stream(
stream: futures::stream::BoxStream<'static, Result<bytes::Bytes, domain::errors::DomainError>>,
@@ -26,55 +29,19 @@ fn entry_stream(
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,
false,
)
.unwrap();
fn entry_for(movie: Movie, rating: u8, comment: Option<&str>) -> DiaryEntry {
let review = test_review(movie.id(), &UserId::generate(), rating).apply_edit(ReviewEdit {
comment: Some(comment.map(|c| Comment::new(c.to_string()).unwrap())),
..Default::default()
});
DiaryEntry::new(movie, review)
}
#[tokio::test]
async fn csv_has_header_and_one_row() {
let adapter = ExportAdapter;
let entry = make_entry(
"Inception",
2010,
Some("Christopher Nolan"),
let entry = entry_for(
test_movie_with_director("Inception", 2010, "Christopher Nolan"),
5,
Some("great"),
);
@@ -95,7 +62,7 @@ async fn csv_has_header_and_one_row() {
#[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 entry = entry_for(test_movie_with_year("Tár, A Film", 2022), 4, None);
let bytes =
collect_stream(adapter.stream_entries(entry_stream(vec![entry]), ExportFormat::Csv)).await;
let text = String::from_utf8(bytes).unwrap();
@@ -105,7 +72,11 @@ async fn csv_escapes_commas_in_title() {
#[tokio::test]
async fn json_is_valid_array() {
let adapter = ExportAdapter;
let entry = make_entry("Dune", 2021, Some("Denis Villeneuve"), 5, None);
let entry = entry_for(
test_movie_with_director("Dune", 2021, "Denis Villeneuve"),
5,
None,
);
let bytes =
collect_stream(adapter.stream_entries(entry_stream(vec![entry]), ExportFormat::Json)).await;
let arr: Vec<serde_json::Value> = serde_json::from_slice(&bytes).unwrap();
@@ -120,20 +91,29 @@ async fn json_is_valid_array() {
#[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 entry = entry_for(
test_movie_with_external_id(
"Alien",
1979,
ExternalMetadataId::new("tt0078748".to_string()).unwrap(),
),
5,
None,
);
let bytes =
collect_stream(adapter.stream_entries(entry_stream(vec![entry]), ExportFormat::Json)).await;
let arr: Vec<serde_json::Value> = serde_json::from_slice(&bytes).unwrap();
assert_eq!(arr[0]["external_metadata_id"], "tt0078748");
let bytes = collect_stream(adapter.stream_entries(
entry_stream(vec![make_entry_full(
entry_stream(vec![entry_for(
test_movie_with_external_id(
"Alien",
1979,
None,
ExternalMetadataId::new("tt0078748".to_string()).unwrap(),
),
5,
None,
Some("tt0078748"),
)]),
ExportFormat::Csv,
))

View File

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

View File

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

View File

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

View File

@@ -1,35 +1,18 @@
use axum::{
Json,
extract::{Path, State},
http::StatusCode,
};
use uuid::Uuid;
use crate::extractors::{Body, PathId};
use crate::{errors::ApiError, extractors::AuthenticatedUser, state::AppState};
use api_types::mappers::goal_with_progress_to_dto;
use api_types::{
CreateGoalRequest, GoalDto, GoalsResponse, UpdateGoalRequest, UpdateUserSettingsRequest,
UserSettingsDto,
};
// ── Shared mapper ────────────────────────────────────────────────────────────
pub fn goal_with_progress_to_dto(g: &domain::models::GoalWithProgress) -> GoalDto {
GoalDto {
year: g.goal.year(),
target_count: g.goal.target_count(),
current_count: g.current_count,
percentage: g.percentage(),
is_complete: g.is_complete(),
goal_type: g.goal.goal_type().clone(),
}
}
// ── Goals API ────────────────────────────────────────────────────────────────
use axum::{Json, extract::State, http::StatusCode};
use uuid::Uuid;
#[utoipa::path(
get, path = "/api/v1/goals",
responses(
(status = 200, body = GoalsResponse),
(status = 401, description = "Unauthorized"),
(status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
),
security(("bearer_auth" = []))
)]
@@ -54,14 +37,14 @@ pub async fn list_goals(
request_body = CreateGoalRequest,
responses(
(status = 200, body = GoalDto),
(status = 401, description = "Unauthorized"),
(status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
),
security(("bearer_auth" = []))
)]
pub async fn create_goal(
State(state): State<AppState>,
user: AuthenticatedUser,
Json(req): Json<CreateGoalRequest>,
Body(req): Body<CreateGoalRequest>,
) -> Result<Json<GoalDto>, ApiError> {
let g = application::goals::create::execute(
&state.app_ctx.deps.goals.command,
@@ -80,16 +63,16 @@ pub async fn create_goal(
request_body = UpdateGoalRequest,
responses(
(status = 200, body = GoalDto),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Goal not found"),
(status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
(status = 404, description = "Goal not found", body = api_types::ErrorResponse),
),
security(("bearer_auth" = []))
)]
pub async fn update_goal(
State(state): State<AppState>,
user: AuthenticatedUser,
Path(year): Path<u16>,
Json(req): Json<UpdateGoalRequest>,
PathId(year): PathId<u16>,
Body(req): Body<UpdateGoalRequest>,
) -> Result<Json<GoalDto>, ApiError> {
let g = application::goals::update::execute(
&state.app_ctx.deps.goals.command,
@@ -107,15 +90,15 @@ pub async fn update_goal(
delete, path = "/api/v1/goals/{year}",
responses(
(status = 204, description = "Goal deleted"),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Goal not found"),
(status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
(status = 404, description = "Goal not found", body = api_types::ErrorResponse),
),
security(("bearer_auth" = []))
)]
pub async fn delete_goal(
State(state): State<AppState>,
user: AuthenticatedUser,
Path(year): Path<u16>,
PathId(year): PathId<u16>,
) -> Result<StatusCode, ApiError> {
application::goals::delete::execute(
&state.app_ctx.deps.goals.command,
@@ -132,14 +115,14 @@ pub async fn delete_goal(
get, path = "/api/v1/users/{id}/goals",
responses(
(status = 200, body = GoalsResponse),
(status = 401, description = "Unauthorized"),
(status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
),
security(("bearer_auth" = []))
)]
pub async fn get_user_goals(
State(state): State<AppState>,
AuthenticatedUser(_viewer): AuthenticatedUser,
Path(user_id): Path<Uuid>,
PathId(user_id): PathId<Uuid>,
) -> Result<Json<GoalsResponse>, ApiError> {
let goals = application::goals::list::execute(
&state.app_ctx.deps.goals.query,
@@ -151,13 +134,11 @@ pub async fn get_user_goals(
}))
}
// ── User Settings ────────────────────────────────────────────────────────────
#[utoipa::path(
get, path = "/api/v1/settings",
responses(
(status = 200, body = UserSettingsDto),
(status = 401, description = "Unauthorized"),
(status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
),
security(("bearer_auth" = []))
)]
@@ -182,14 +163,14 @@ pub async fn get_settings(
request_body = UpdateUserSettingsRequest,
responses(
(status = 204, description = "Settings updated"),
(status = 401, description = "Unauthorized"),
(status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
),
security(("bearer_auth" = []))
)]
pub async fn update_settings(
State(state): State<AppState>,
user: AuthenticatedUser,
Json(req): Json<UpdateUserSettingsRequest>,
Body(req): Body<UpdateUserSettingsRequest>,
) -> Result<StatusCode, ApiError> {
application::users::update_settings::execute(
&state.app_ctx.deps.users.update_settings,

View File

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

View File

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

View File

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

View File

@@ -1,13 +1,9 @@
use axum::{
Json,
extract::{Query, State},
};
use application::insights::get as get_insights_uc;
use domain::{errors::DomainError, models::Insight};
use crate::extractors::Params;
use crate::{errors::ApiError, extractors::AuthenticatedUser, state::AppState};
use api_types::insights::{InsightDto, InsightsQueryParams, InsightsResponse};
use application::insights::get as get_insights_uc;
use axum::{Json, extract::State};
use domain::{errors::DomainError, models::Insight};
fn to_dto(insight: Insight) -> InsightDto {
match insight {
@@ -70,15 +66,15 @@ fn to_dto(insight: Insight) -> InsightDto {
params(InsightsQueryParams),
responses(
(status = 200, body = InsightsResponse),
(status = 400, description = "Unknown time zone"),
(status = 401, description = "Unauthorized"),
(status = 400, description = "Unknown time zone", body = api_types::ErrorResponse),
(status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
),
security(("bearer_auth" = []))
)]
pub async fn get_insights(
State(state): State<AppState>,
user: AuthenticatedUser,
Query(params): Query<InsightsQueryParams>,
Params(params): Params<InsightsQueryParams>,
) -> Result<Json<InsightsResponse>, ApiError> {
let tz: chrono_tz::Tz = match params.tz.as_deref() {
None => chrono_tz::UTC,

View File

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

View File

@@ -1,11 +1,10 @@
use axum::{
Json,
extract::{Extension, Path, Query, State},
http::StatusCode,
response::IntoResponse,
use crate::extractors::{Params, PathId};
use crate::{errors::ApiError, extractors::AuthenticatedUser, state::AppState};
use api_types::{
CastMemberDto, CrewMemberDto, GenreDto, KeywordDto, MovieDetailResponse, MovieProfileResponse,
MovieStatsDto, MoviesQueryParams, MoviesResponse, PaginationQueryParams, ReviewHistoryResponse,
SocialFeedResponse, SocialReviewDto,
};
use uuid::Uuid;
use application::{
diary::{
commands::SyncPosterCommand,
@@ -13,38 +12,22 @@ use application::{
queries::{GetMovieSocialPageQuery, GetReviewHistoryQuery},
},
movies::{get_movies, queries::GetMoviesQuery, sync_poster},
watchlist::{is_on as is_on_watchlist, queries::IsOnWatchlistQuery},
};
use axum::{Json, extract::State, http::StatusCode, response::IntoResponse};
use domain::services::review_history::Trend;
use crate::{
csrf::CsrfToken,
errors::ApiError,
extractors::{AuthenticatedUser, OptionalCookieUser},
render::render_page,
state::AppState,
};
use api_types::{
CastMemberDto, CrewMemberDto, GenreDto, KeywordDto, MovieDetailResponse, MovieProfileResponse,
MovieStatsDto, MoviesQueryParams, MoviesResponse, PaginationQueryParams, ReviewHistoryResponse,
SocialFeedResponse, SocialReviewDto,
};
use template_askama::MovieDetailTemplate;
use super::helpers::build_page_context;
// ── API ──────────────────────────────────────────────────────────────────────
use uuid::Uuid;
#[utoipa::path(
get, path = "/api/v1/movies",
params(MoviesQueryParams),
responses(
(status = 200, body = MoviesResponse),
(status = 400, description = "Invalid query", body = api_types::ErrorResponse),
)
)]
pub async fn list_movies(
State(state): State<AppState>,
Query(params): Query<MoviesQueryParams>,
Params(params): Params<MoviesQueryParams>,
) -> Result<Json<MoviesResponse>, ApiError> {
let page = get_movies::execute(
&state.app_ctx.deps.movies.get_movies,
@@ -62,7 +45,7 @@ pub async fn list_movies(
items: page
.items
.iter()
.map(crate::mappers::movies::summary_to_dto)
.map(api_types::mappers::summary_to_dto)
.collect(),
total_count: page.total_count,
limit: page.limit,
@@ -75,12 +58,12 @@ pub async fn list_movies(
params(("id" = Uuid, Path, description = "Movie ID")),
responses(
(status = 200, body = ReviewHistoryResponse),
(status = 404, description = "Movie not found"),
(status = 404, description = "Movie not found", body = api_types::ErrorResponse),
)
)]
pub async fn get_review_history(
State(state): State<AppState>,
Path(movie_id): Path<Uuid>,
PathId(movie_id): PathId<Uuid>,
) -> Result<Json<ReviewHistoryResponse>, ApiError> {
let (history, trend) = get_review_history::execute(
&state.app_ctx.deps.diary.get_review_history,
@@ -89,11 +72,11 @@ pub async fn get_review_history(
.await?;
Ok(Json(ReviewHistoryResponse {
movie: crate::mappers::movies::movie_to_dto(history.movie()),
movie: api_types::mappers::movie_to_dto(history.movie()),
viewings: history
.viewings()
.iter()
.map(crate::mappers::movies::review_to_dto)
.map(api_types::mappers::review_to_dto)
.collect(),
trend: match trend {
Trend::Improved => "improved",
@@ -109,15 +92,15 @@ pub async fn get_review_history(
params(("id" = Uuid, Path, description = "Movie ID")),
responses(
(status = 204, description = "Poster synced"),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Movie not found"),
(status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
(status = 404, description = "Movie not found", body = api_types::ErrorResponse),
),
security(("bearer_auth" = []))
)]
pub async fn sync_poster(
State(state): State<AppState>,
_user: AuthenticatedUser,
Path(movie_id): Path<Uuid>,
PathId(movie_id): PathId<Uuid>,
) -> Result<impl IntoResponse, ApiError> {
sync_poster::execute(
&state.app_ctx.deps.movies.sync_poster,
@@ -132,13 +115,13 @@ pub async fn sync_poster(
params(("movie_id" = Uuid, Path, description = "Movie ID")),
responses(
(status = 200, body = MovieDetailResponse),
(status = 404, description = "Movie not found"),
(status = 404, description = "Movie not found", body = api_types::ErrorResponse),
)
)]
pub async fn get_movie_detail(
State(state): State<AppState>,
Path(movie_id): Path<Uuid>,
Query(params): Query<PaginationQueryParams>,
PathId(movie_id): PathId<Uuid>,
Params(params): Params<PaginationQueryParams>,
) -> Result<Json<MovieDetailResponse>, ApiError> {
let limit = params.limit.unwrap_or(20);
let offset = params.offset.unwrap_or(0);
@@ -154,7 +137,7 @@ pub async fn get_movie_detail(
.await?;
Ok(Json(MovieDetailResponse {
movie: crate::mappers::movies::movie_to_dto(&result.movie),
movie: api_types::mappers::movie_to_dto(&result.movie),
stats: MovieStatsDto {
total_count: result.stats.total_count,
avg_rating: result.stats.avg_rating,
@@ -189,12 +172,12 @@ pub async fn get_movie_detail(
params(("id" = Uuid, Path, description = "Movie ID")),
responses(
(status = 200, body = MovieProfileResponse),
(status = 404, description = "No profile found for this movie"),
(status = 404, description = "No profile found for this movie", body = api_types::ErrorResponse),
)
)]
pub async fn get_movie_profile(
State(state): State<AppState>,
Path(movie_id): Path<Uuid>,
PathId(movie_id): PathId<Uuid>,
) -> impl IntoResponse {
use application::movies::get_movie_profile;
let query = get_movie_profile::GetMovieProfileQuery { movie_id };
@@ -257,72 +240,9 @@ pub async fn get_movie_profile(
})
.into_response()
}
Ok(None) => StatusCode::NOT_FOUND.into_response(),
Err(e) => crate::errors::domain_error_response(e),
}
}
// ── HTML ─────────────────────────────────────────────────────────────────────
pub async fn get_movie_detail_html(
OptionalCookieUser(user_id): OptionalCookieUser,
State(state): State<AppState>,
Path(movie_id): Path<uuid::Uuid>,
Query(params): Query<api_types::PaginationQueryParams>,
Extension(csrf): Extension<CsrfToken>,
) -> impl IntoResponse {
let ctx = build_page_context(&state, user_id.clone(), csrf.0).await;
let limit = params.limit.unwrap_or(20);
let offset = params.offset.unwrap_or(0);
match get_movie_social_page::execute(
&state.app_ctx.deps.diary.get_movie_social_page,
GetMovieSocialPageQuery {
movie_id,
limit,
offset,
},
)
.await
{
Err(e) => crate::errors::domain_error_response(e),
Ok(result) => {
let histogram_max = result
.stats
.rating_histogram
.iter()
.copied()
.max()
.unwrap_or(1);
let has_more =
result.reviews.offset + result.reviews.limit < result.reviews.total_count as u32;
let on_watchlist = match &user_id {
Some(uid) => is_on_watchlist::execute(
&state.app_ctx.deps.watchlist.is_on_watchlist,
IsOnWatchlistQuery {
user_id: uid.value(),
movie_id,
},
)
.await
.unwrap_or(false),
None => false,
};
let current_offset = result.reviews.offset;
let reviews_limit = result.reviews.limit;
render_page(MovieDetailTemplate {
ctx: &ctx,
movie: &result.movie,
stats: &result.stats,
profile: result.profile.as_ref(),
reviews: result.reviews.items.as_slice(),
on_watchlist,
current_offset,
has_more,
limit: reviews_limit,
histogram_max,
})
.into_response()
}
Ok(None) => {
crate::errors::refuse(StatusCode::NOT_FOUND, crate::errors::NOT_FOUND, "not found")
}
Err(e) => crate::errors::ApiError(e).into_response(),
}
}

View File

@@ -1,34 +1,28 @@
use axum::{
extract::{Path, Query, State},
http::StatusCode,
response::{IntoResponse, Redirect},
};
use application::{
person::{get as get_person, get_credits as get_person_credits},
search::execute as search_uc,
};
use domain::models::{PersonId, collections::PageParams};
use crate::extractors::{Params, PathId};
use crate::state::AppState;
use api_types::search::{
CastCreditDto, CrewCreditDto, MovieSearchHitDto, PaginatedMovieHits, PaginatedPersonHits,
PersonCreditsDto, PersonSearchHitDto, SearchQueryParams, SearchResponse,
};
// ── API ──────────────────────────────────────────────────────────────────────
use application::{
person::{get as get_person, get_credits as get_person_credits},
search::execute as search_uc,
};
use axum::{extract::State, http::StatusCode, response::IntoResponse};
use domain::models::{PersonId, collections::PageParams};
#[utoipa::path(
get, path = "/api/v1/search",
params(api_types::search::SearchQueryParams),
responses(
(status = 200, body = api_types::search::SearchResponse),
(status = 400, description = "Invalid query", body = api_types::ErrorResponse),
),
tag = "search",
)]
pub async fn get_search(
State(state): State<AppState>,
Query(params): Query<SearchQueryParams>,
Params(params): Params<SearchQueryParams>,
) -> impl IntoResponse {
let query = domain::models::SearchQuery {
text: params.q,
@@ -84,7 +78,7 @@ pub async fn get_search(
},
})
.into_response(),
Err(e) => crate::errors::domain_error_response(e),
Err(e) => crate::errors::ApiError(e).into_response(),
}
}
@@ -93,13 +87,13 @@ pub async fn get_search(
params(("id" = Uuid, Path, description = "Person ID")),
responses(
(status = 200, body = api_types::search::PersonDto),
(status = 404, description = "Person not found"),
(status = 404, description = "Person not found", body = api_types::ErrorResponse),
),
tag = "search",
)]
pub async fn get_person_handler(
State(state): State<AppState>,
Path(id): Path<uuid::Uuid>,
PathId(id): PathId<uuid::Uuid>,
) -> impl IntoResponse {
match get_person::execute(
&state.app_ctx.deps.person.get_person,
@@ -107,11 +101,11 @@ pub async fn get_person_handler(
)
.await
{
Ok(Some(person)) => {
axum::Json(crate::mappers::search::person_to_dto(&person)).into_response()
Ok(Some(person)) => axum::Json(api_types::mappers::person_to_dto(&person)).into_response(),
Ok(None) => {
crate::errors::refuse(StatusCode::NOT_FOUND, crate::errors::NOT_FOUND, "not found")
}
Ok(None) => StatusCode::NOT_FOUND.into_response(),
Err(e) => crate::errors::domain_error_response(e),
Err(e) => crate::errors::ApiError(e).into_response(),
}
}
@@ -120,13 +114,13 @@ pub async fn get_person_handler(
params(("id" = Uuid, Path, description = "Person ID")),
responses(
(status = 200, body = api_types::search::PersonCreditsDto),
(status = 404, description = "Person not found"),
(status = 404, description = "Person not found", body = api_types::ErrorResponse),
),
tag = "search",
)]
pub async fn get_person_credits_handler(
State(state): State<AppState>,
Path(id): Path<uuid::Uuid>,
PathId(id): PathId<uuid::Uuid>,
) -> impl IntoResponse {
match get_person_credits::execute(
&state.app_ctx.deps.person.get_person,
@@ -135,7 +129,7 @@ pub async fn get_person_credits_handler(
.await
{
Ok(credits) => axum::Json(PersonCreditsDto {
person: crate::mappers::search::person_to_dto(&credits.person),
person: api_types::mappers::person_to_dto(&credits.person),
cast: credits
.cast
.iter()
@@ -161,7 +155,7 @@ pub async fn get_person_credits_handler(
.collect(),
})
.into_response(),
Err(e) => crate::errors::domain_error_response(e),
Err(e) => crate::errors::ApiError(e).into_response(),
}
}
@@ -171,20 +165,14 @@ pub async fn post_reindex_search(
) -> impl IntoResponse {
let event = domain::events::DomainEvent::SearchReindexRequested;
match state.app_ctx.services.event_publisher.publish(&event).await {
Ok(()) => StatusCode::ACCEPTED,
Ok(()) => StatusCode::ACCEPTED.into_response(),
Err(e) => {
tracing::error!("failed to publish reindex event: {:?}", e);
StatusCode::INTERNAL_SERVER_ERROR
crate::errors::refuse(
StatusCode::INTERNAL_SERVER_ERROR,
crate::errors::INTERNAL,
"internal server error",
)
}
}
}
// ── HTML ─────────────────────────────────────────────────────────────────────
pub async fn get_tag(Path(tag): Path<String>) -> impl IntoResponse {
if tag.eq_ignore_ascii_case("moviesdiary") {
Redirect::temporary("/")
} else {
Redirect::temporary(&format!("/?search={}", tag))
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,11 +1,5 @@
use axum::{
Json,
extract::{Multipart, Path, State},
http::{HeaderMap, StatusCode},
response::IntoResponse,
};
use uuid::Uuid;
use crate::extractors::{Body, Params, PathId, Upload};
use crate::{errors::ApiError, extractors::AuthenticatedUser, state::AppState};
use api_types::{
ConfirmWatchRequest, ConfirmWatchResponse, DismissWatchRequest, DismissWatchResponse,
GenerateTokenRequest, GenerateTokenResponse, WatchQueueEntryDto, WebhookTokenDto,
@@ -21,9 +15,14 @@ use application::integrations::{
queries::{GetWatchQueueQuery, GetWebhookTokensQuery},
revoke_token as revoke_webhook_token,
};
use axum::{
Json,
extract::State,
http::{HeaderMap, StatusCode},
response::IntoResponse,
};
use domain::models::WatchEventSource;
use crate::{errors::ApiError, extractors::AuthenticatedUser, state::AppState};
use uuid::Uuid;
fn extract_bearer_token(headers: &HeaderMap) -> Option<String> {
headers
@@ -38,31 +37,37 @@ pub struct WebhookTokenQuery {
pub token: Option<String>,
}
fn missing_token() -> axum::response::Response {
crate::errors::refuse(
StatusCode::UNAUTHORIZED,
crate::errors::UNAUTHORIZED,
"missing webhook token",
)
}
fn extract_webhook_token(headers: &HeaderMap, query: &WebhookTokenQuery) -> Option<String> {
extract_bearer_token(headers).or_else(|| query.token.clone())
}
// ── Webhook ingestion (no JWT, uses webhook bearer token) ─────────────────────
#[utoipa::path(
post, path = "/api/v1/webhooks/jellyfin",
request_body(content = String, description = "Jellyfin webhook JSON payload (SendAllProperties=true)", content_type = "application/json"),
responses(
(status = 200, description = "Event accepted or ignored"),
(status = 400, description = "Invalid payload"),
(status = 401, description = "Invalid or missing webhook token"),
(status = 400, description = "Invalid payload", body = api_types::ErrorResponse),
(status = 401, description = "Invalid or missing webhook token", body = api_types::ErrorResponse),
),
security(("bearer_auth" = []))
)]
pub async fn post_jellyfin_webhook(
State(state): State<AppState>,
headers: HeaderMap,
axum::extract::Query(query): axum::extract::Query<WebhookTokenQuery>,
Params(query): Params<WebhookTokenQuery>,
body: axum::body::Bytes,
) -> impl IntoResponse {
let token = match extract_webhook_token(&headers, &query) {
Some(t) => t,
None => return StatusCode::UNAUTHORIZED,
None => return missing_token(),
};
let cmd = IngestWatchEventCommand {
@@ -79,27 +84,25 @@ pub async fn post_jellyfin_webhook(
.await
}
// ── Plex webhook (multipart form data with `payload` JSON field) ──────────────
#[utoipa::path(
post, path = "/api/v1/webhooks/plex",
request_body(content = String, description = "Plex webhook multipart form (payload field contains JSON)", content_type = "multipart/form-data"),
responses(
(status = 200, description = "Event accepted or ignored"),
(status = 400, description = "Invalid payload"),
(status = 401, description = "Invalid or missing webhook token"),
(status = 400, description = "Invalid payload", body = api_types::ErrorResponse),
(status = 401, description = "Invalid or missing webhook token", body = api_types::ErrorResponse),
),
security(("bearer_auth" = []))
)]
pub async fn post_plex_webhook(
State(state): State<AppState>,
headers: HeaderMap,
axum::extract::Query(query): axum::extract::Query<WebhookTokenQuery>,
mut multipart: Multipart,
Params(query): Params<WebhookTokenQuery>,
Upload(mut multipart): Upload,
) -> impl IntoResponse {
let token = match extract_webhook_token(&headers, &query) {
Some(t) => t,
None => return StatusCode::UNAUTHORIZED,
None => return missing_token(),
};
let mut payload_bytes: Option<Vec<u8>> = None;
@@ -114,7 +117,13 @@ pub async fn post_plex_webhook(
let raw_payload = match payload_bytes {
Some(b) => b,
None => return StatusCode::BAD_REQUEST,
None => {
return crate::errors::refuse(
StatusCode::BAD_REQUEST,
crate::errors::INVALID_INPUT,
"multipart form has no payload field",
);
}
};
let cmd = IngestWatchEventCommand {
@@ -130,7 +139,7 @@ async fn run_ingest(
state: &AppState,
cmd: IngestWatchEventCommand,
parser: &dyn domain::ports::MediaServerParser,
) -> StatusCode {
) -> axum::response::Response {
match ingest_watch_event::execute(
&state.app_ctx.deps.integrations.ingest_watch_event,
cmd,
@@ -138,26 +147,24 @@ async fn run_ingest(
)
.await
{
Ok(()) => StatusCode::OK,
Err(e) => crate::errors::domain_error_status(&e),
Ok(()) => StatusCode::OK.into_response(),
Err(e) => crate::errors::ApiError(e).into_response(),
}
}
// ── Token management (JWT-authenticated) ──────────────────────────────────────
#[utoipa::path(
post, path = "/api/v1/settings/webhook-tokens",
request_body = GenerateTokenRequest,
responses(
(status = 200, body = GenerateTokenResponse),
(status = 401, description = "Unauthorized"),
(status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
),
security(("bearer_auth" = []))
)]
pub async fn post_generate_webhook_token(
State(state): State<AppState>,
user: AuthenticatedUser,
Json(req): Json<GenerateTokenRequest>,
Body(req): Body<GenerateTokenRequest>,
) -> Result<Json<GenerateTokenResponse>, ApiError> {
let provider: WatchEventSource = req
.provider
@@ -190,7 +197,7 @@ pub async fn post_generate_webhook_token(
get, path = "/api/v1/settings/webhook-tokens",
responses(
(status = 200, body = Vec<WebhookTokenDto>),
(status = 401, description = "Unauthorized"),
(status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
),
security(("bearer_auth" = []))
)]
@@ -224,15 +231,15 @@ pub async fn get_webhook_tokens(
params(("id" = Uuid, Path, description = "Webhook token ID")),
responses(
(status = 204, description = "Token revoked"),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Token not found"),
(status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
(status = 404, description = "Token not found", body = api_types::ErrorResponse),
),
security(("bearer_auth" = []))
)]
pub async fn delete_webhook_token(
State(state): State<AppState>,
user: AuthenticatedUser,
Path(id): Path<Uuid>,
PathId(id): PathId<Uuid>,
) -> Result<StatusCode, ApiError> {
let cmd = RevokeWebhookTokenCommand {
user_id: user.0.value(),
@@ -243,13 +250,11 @@ pub async fn delete_webhook_token(
Ok(StatusCode::NO_CONTENT)
}
// ── Watch queue (JWT-authenticated) ───────────────────────────────────────────
#[utoipa::path(
get, path = "/api/v1/watch-queue",
responses(
(status = 200, body = Vec<WatchQueueEntryDto>),
(status = 401, description = "Unauthorized"),
(status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
),
security(("bearer_auth" = []))
)]
@@ -283,15 +288,15 @@ pub async fn get_watch_queue(
request_body = ConfirmWatchRequest,
responses(
(status = 200, body = ConfirmWatchResponse),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden — not your watch event"),
(status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
(status = 403, description = "Forbidden — not your watch event", body = api_types::ErrorResponse),
),
security(("bearer_auth" = []))
)]
pub async fn post_confirm_watch_events(
State(state): State<AppState>,
user: AuthenticatedUser,
Json(req): Json<ConfirmWatchRequest>,
Body(req): Body<ConfirmWatchRequest>,
) -> Result<Json<ConfirmWatchResponse>, ApiError> {
let cmd = ConfirmWatchEventsCommand {
user_id: user.0.value(),
@@ -317,15 +322,15 @@ pub async fn post_confirm_watch_events(
request_body = DismissWatchRequest,
responses(
(status = 200, body = DismissWatchResponse),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden — not your watch event"),
(status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
(status = 403, description = "Forbidden — not your watch event", body = api_types::ErrorResponse),
),
security(("bearer_auth" = []))
)]
pub async fn post_dismiss_watch_events(
State(state): State<AppState>,
user: AuthenticatedUser,
Json(req): Json<DismissWatchRequest>,
Body(req): Body<DismissWatchRequest>,
) -> Result<Json<DismissWatchResponse>, ApiError> {
let cmd = DismissWatchEventsCommand {
user_id: user.0.value(),

View File

@@ -1,31 +1,23 @@
use axum::{
Json,
extract::{Extension, Path, State},
http::StatusCode,
response::IntoResponse,
};
use chrono::NaiveDate;
use uuid::Uuid;
use application::wrapup::{
commands::RequestWrapUpCommand,
delete as delete_wrapup, generate, get_ready_report, get_wrapup,
list_wrapups::{self, ListWrapUpsQuery},
};
use domain::errors::DomainError;
use domain::models::wrapup::{WrapUpRecord, WrapUpReport, WrapUpScope, WrapUpStatus};
use domain::value_objects::WrapUpId;
use crate::extractors::{Body, PathId};
use crate::{
csrf::CsrfToken,
errors::ApiError,
extractors::{AdminApiUser, AuthenticatedUser, OptionalCookieUser},
render::render_page,
extractors::{AdminApiUser, AuthenticatedUser},
state::AppState,
};
use api_types::wrapup::{
GenerateWrapUpRequest, WrapUpGeneratedResponse, WrapUpListResponse, WrapUpStatusResponse,
};
use application::wrapup::{
commands::RequestWrapUpCommand,
delete as delete_wrapup, generate, get_wrapup,
list_wrapups::{self, ListWrapUpsQuery},
};
use axum::{Json, extract::State, http::StatusCode, response::IntoResponse};
use chrono::NaiveDate;
use domain::errors::DomainError;
use domain::models::wrapup::{WrapUpRecord, WrapUpStatus};
use domain::value_objects::WrapUpId;
use uuid::Uuid;
fn record_to_dto(r: &WrapUpRecord) -> WrapUpStatusResponse {
WrapUpStatusResponse {
@@ -45,16 +37,16 @@ fn record_to_dto(r: &WrapUpRecord) -> WrapUpStatusResponse {
request_body = GenerateWrapUpRequest,
responses(
(status = 200, body = WrapUpGeneratedResponse),
(status = 400, description = "Invalid date format"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden — admin only"),
(status = 400, description = "Invalid date format", body = api_types::ErrorResponse),
(status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
(status = 403, description = "Forbidden — admin only", body = api_types::ErrorResponse),
),
security(("bearer_auth" = []))
)]
pub async fn post_generate(
State(state): State<AppState>,
_admin: AdminApiUser,
Json(req): Json<GenerateWrapUpRequest>,
Body(req): Body<GenerateWrapUpRequest>,
) -> Result<Json<WrapUpGeneratedResponse>, ApiError> {
let start = NaiveDate::parse_from_str(&req.start_date, "%Y-%m-%d")
.map_err(|_| DomainError::ValidationError("invalid start_date".into()))?;
@@ -76,7 +68,7 @@ pub async fn post_generate(
get, path = "/api/v1/wrapups",
responses(
(status = 200, body = WrapUpListResponse),
(status = 401, description = "Unauthorized"),
(status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
),
security(("bearer_auth" = []))
)]
@@ -101,15 +93,15 @@ pub async fn get_list(
params(("id" = Uuid, Path, description = "Wrap-up ID")),
responses(
(status = 200, body = WrapUpStatusResponse),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Not found"),
(status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
(status = 404, description = "Not found", body = api_types::ErrorResponse),
),
security(("bearer_auth" = []))
)]
pub async fn get_status(
State(state): State<AppState>,
_user: AuthenticatedUser,
Path(id): Path<Uuid>,
PathId(id): PathId<Uuid>,
) -> Result<Json<WrapUpStatusResponse>, ApiError> {
let record = get_wrapup::execute(
&state.app_ctx.deps.wrapup.get_wrapup,
@@ -124,17 +116,17 @@ pub async fn get_status(
get, path = "/api/v1/wrapups/{id}/report",
params(("id" = Uuid, Path, description = "Wrap-up ID")),
responses(
(status = 200, description = "Report JSON", content_type = "application/json"),
(status = 200, description = "Report JSON", body = inline(serde_json::Value)),
(status = 202, description = "Still generating"),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Not found"),
(status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
(status = 404, description = "Not found", body = api_types::ErrorResponse),
),
security(("bearer_auth" = []))
)]
pub async fn get_report(
State(state): State<AppState>,
_user: AuthenticatedUser,
Path(id): Path<Uuid>,
PathId(id): PathId<Uuid>,
) -> impl IntoResponse {
match get_wrapup::execute(
&state.app_ctx.deps.wrapup.get_wrapup,
@@ -147,11 +139,15 @@ pub async fn get_report(
let json = serde_json::to_string(report).unwrap_or_default();
(StatusCode::OK, [("content-type", "application/json")], json).into_response()
}
None => StatusCode::NOT_FOUND.into_response(),
None => {
crate::errors::refuse(StatusCode::NOT_FOUND, crate::errors::NOT_FOUND, "not found")
}
},
Ok(Some(_)) => StatusCode::ACCEPTED.into_response(),
Ok(None) => StatusCode::NOT_FOUND.into_response(),
Err(e) => crate::errors::domain_error_response(e),
Ok(None) => {
crate::errors::refuse(StatusCode::NOT_FOUND, crate::errors::NOT_FOUND, "not found")
}
Err(e) => crate::errors::ApiError(e).into_response(),
}
}
@@ -160,16 +156,16 @@ pub async fn get_report(
params(("id" = Uuid, Path, description = "Wrap-up ID")),
responses(
(status = 204, description = "Deleted"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden — admin only"),
(status = 404, description = "Not found"),
(status = 401, description = "Unauthorized", body = api_types::ErrorResponse),
(status = 403, description = "Forbidden — admin only", body = api_types::ErrorResponse),
(status = 404, description = "Not found", body = api_types::ErrorResponse),
),
security(("bearer_auth" = []))
)]
pub async fn delete_wrapup_handler(
State(state): State<AppState>,
_admin: AdminApiUser,
Path(id): Path<Uuid>,
PathId(id): PathId<Uuid>,
) -> Result<StatusCode, ApiError> {
delete_wrapup::execute(
&state.app_ctx.deps.wrapup.delete_wrapup,
@@ -178,98 +174,3 @@ pub async fn delete_wrapup_handler(
.await?;
Ok(StatusCode::NO_CONTENT)
}
// ── HTML handlers ───────────────────────────────────────────────────────────
fn format_watch_time(minutes: u32) -> String {
let h = minutes / 60;
let m = minutes % 60;
if h > 0 && m > 0 {
format!("{}h {}m", h, m)
} else if h > 0 {
format!("{}h", h)
} else {
format!("{}m", m)
}
}
fn render_wrapup(
report: &WrapUpReport,
year: i32,
ctx: &api_types::HtmlPageContext,
) -> axum::response::Response {
let rating_max = report
.rating_distribution
.iter()
.copied()
.max()
.unwrap_or(1)
.max(1);
let rating_pcts: [f64; 5] =
std::array::from_fn(|i| report.rating_distribution[i] as f64 / rating_max as f64 * 100.0);
let genre_max = report
.top_genres
.first()
.map(|g| g.count)
.unwrap_or(1)
.max(1);
let genre_pcts: Vec<f64> = report
.top_genres
.iter()
.take(8)
.map(|g| g.count as f64 / genre_max as f64 * 100.0)
.collect();
let tmpl = template_askama::WrapUpPageTemplate {
ctx,
report,
year_label: year.to_string(),
watch_time_display: format_watch_time(report.total_watch_time_minutes),
rating_max,
genre_max,
rating_pcts,
genre_pcts,
};
render_page(tmpl)
}
pub async fn get_user_wrapup_html(
OptionalCookieUser(viewer): OptionalCookieUser,
State(state): State<AppState>,
Path((user_id, year)): Path<(Uuid, i32)>,
Extension(csrf): Extension<CsrfToken>,
) -> impl IntoResponse {
let scope = WrapUpScope::User(user_id);
let report =
match get_ready_report::execute(&state.app_ctx.deps.wrapup.get_ready_report, scope, year)
.await
{
Ok(report) => report,
Err(DomainError::ValidationError(_)) => return StatusCode::BAD_REQUEST.into_response(),
Err(_) => return StatusCode::NOT_FOUND.into_response(),
};
let ctx = super::helpers::build_page_context(&state, viewer, csrf.0).await;
render_wrapup(&report, year, &ctx)
}
pub async fn get_global_wrapup_html(
OptionalCookieUser(viewer): OptionalCookieUser,
State(state): State<AppState>,
Path(year): Path<i32>,
Extension(csrf): Extension<CsrfToken>,
) -> impl IntoResponse {
let report = match get_ready_report::execute(
&state.app_ctx.deps.wrapup.get_ready_report,
WrapUpScope::Global,
year,
)
.await
{
Ok(report) => report,
Err(DomainError::ValidationError(_)) => return StatusCode::BAD_REQUEST.into_response(),
Err(_) => return StatusCode::NOT_FOUND.into_response(),
};
let ctx = super::helpers::build_page_context(&state, viewer, csrf.0).await;
render_wrapup(&report, year, &ctx)
}

View File

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

View File

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

View File

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

View File

@@ -1,3 +1,6 @@
mod json;
pub use json::{Body, MalformedRequest, Params, PathId, Upload};
use axum::{
extract::{FromRef, FromRequestParts},
http::{StatusCode, header, header::AUTHORIZATION, request::Parts},
@@ -7,6 +10,11 @@ use domain::{errors::DomainError, value_objects::UserId};
use crate::{errors::ApiError, state::AppState};
async fn ensure_active(state: &AppState, user_id: &UserId) -> Result<(), DomainError> {
application::users::ensure_active::execute(&state.app_ctx.deps.users.ensure_active, user_id)
.await
}
pub struct AuthenticatedUser(pub UserId);
impl<S> FromRequestParts<S> for AuthenticatedUser
@@ -34,6 +42,7 @@ where
.auth
.validate_token(token)
.await?;
ensure_active(&app_state, &user_id).await?;
Ok(AuthenticatedUser(user_id))
}
}
@@ -72,6 +81,18 @@ where
.validate_token(&token)
.await
.ok();
let user_id = match user_id {
Some(id) => match ensure_active(&app_state, &id).await {
Ok(()) => Some(id),
Err(e) => {
if e.is_transient() {
tracing::warn!("ensure_active failed, treating as anonymous: {e:?}");
}
None
}
},
None => None,
};
Ok(OptionalCookieUser(user_id))
}
}
@@ -94,6 +115,14 @@ where
.validate_token(&token)
.await
.map_err(|_| Redirect::to("/login").into_response())?;
ensure_active(&app_state, &user_id)
.await
.map_err(|e| match e {
DomainError::InfrastructureError(_) => {
StatusCode::INTERNAL_SERVER_ERROR.into_response()
}
_ => Redirect::to("/login").into_response(),
})?;
Ok(RequiredCookieUser(user_id))
}
}
@@ -153,7 +182,3 @@ where
}
}
}
#[cfg(test)]
#[path = "tests/extractors.rs"]
pub(crate) mod tests;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,43 @@
use axum::{
http::{HeaderMap, StatusCode, header},
response::{IntoResponse, Response},
};
use uuid::Uuid;
use crate::state::AppState;
pub async fn actor_document_response(
state: &AppState,
headers: &HeaderMap,
user_id: Uuid,
) -> Option<Response> {
let accept = headers
.get(header::ACCEPT)
.and_then(|v| v.to_str().ok())
.unwrap_or("");
if !(accept.contains("application/activity+json") || accept.contains("application/ld+json")) {
return None;
}
if state
.app_ctx
.ap_document
.actor_is_gone(user_id)
.await
.unwrap_or(false)
{
return Some(StatusCode::GONE.into_response());
}
Some(
match state
.app_ctx
.ap_document
.actor_json(&user_id.to_string())
.await
{
Ok(json) => {
([(header::CONTENT_TYPE, "application/activity+json")], json).into_response()
}
Err(_) => StatusCode::NOT_FOUND.into_response(),
},
)
}

View File

@@ -0,0 +1,155 @@
use crate::html::templates::{LoginTemplate, RegisterTemplate};
use crate::{
html::csrf::{CsrfToken, secure_flag},
html::forms::{ErrorQuery, LoginForm, RegisterForm},
html::render::render_page,
state::AppState,
};
use api_types::HtmlPageContext;
use application::auth::{login as login_uc, queries::LoginCommand};
use axum::{
Form,
extract::{Extension, Query, State},
http::{HeaderValue, StatusCode, header::SET_COOKIE},
response::{IntoResponse, Redirect},
};
use chrono::Utc;
fn set_cookie_header(
token: &str,
max_age: i64,
secure_cookies: bool,
) -> (axum::http::HeaderName, HeaderValue) {
let val = format!(
"token={}; HttpOnly; Path=/; SameSite=Strict; Max-Age={}{}",
token,
max_age,
secure_flag(secure_cookies)
);
(
SET_COOKIE,
HeaderValue::from_str(&val).expect("valid cookie"),
)
}
pub async fn get_login_page(
State(state): State<AppState>,
Query(params): Query<ErrorQuery>,
Extension(csrf): Extension<CsrfToken>,
) -> impl IntoResponse {
let ctx = HtmlPageContext {
user_email: None,
user_id: None,
is_admin: false,
register_enabled: state.app_ctx.config.allow_registration,
rss_url: "/feed.rss".to_string(),
page_title: "Login — Movies Diary".to_string(),
canonical_url: format!("{}/login", state.app_ctx.config.base_url),
csrf_token: csrf.0,
page_rss_url: None,
pending_follow_count: 0,
};
render_page(LoginTemplate {
ctx: &ctx,
error: params.error.as_deref(),
})
}
pub async fn post_login(
State(state): State<AppState>,
Extension(csrf): Extension<CsrfToken>,
Form(form): Form<LoginForm>,
) -> impl IntoResponse {
if crate::html::csrf::mismatch(&csrf, &form.csrf_token) {
return StatusCode::FORBIDDEN.into_response();
}
match login_uc::execute(
&state.app_ctx.deps.auth.login,
LoginCommand {
email: form.email,
password: form.password,
},
)
.await
{
Ok(result) => {
let max_age = (result.expires_at - Utc::now()).num_seconds().max(0);
let cookie =
set_cookie_header(&result.token, max_age, state.app_ctx.config.secure_cookies);
([cookie], Redirect::to("/")).into_response()
}
Err(_) => Redirect::to("/login?error=Invalid+credentials").into_response(),
}
}
pub(super) async fn get_logout(State(state): State<AppState>) -> impl IntoResponse {
let val = format!(
"token=; HttpOnly; Path=/; SameSite=Strict; Max-Age=0{}",
secure_flag(state.app_ctx.config.secure_cookies)
);
let cookie = (
SET_COOKIE,
HeaderValue::from_str(&val).expect("valid cookie"),
);
([cookie], Redirect::to("/")).into_response()
}
pub async fn get_register_page(
State(state): State<AppState>,
Query(params): Query<ErrorQuery>,
Extension(csrf): Extension<CsrfToken>,
) -> impl IntoResponse {
if !state.app_ctx.config.allow_registration {
return Redirect::to("/").into_response();
}
let ctx = HtmlPageContext {
user_email: None,
user_id: None,
is_admin: false,
register_enabled: true,
rss_url: "/feed.rss".to_string(),
page_title: "Register — Movies Diary".to_string(),
canonical_url: format!("{}/register", state.app_ctx.config.base_url),
csrf_token: csrf.0,
page_rss_url: None,
pending_follow_count: 0,
};
render_page(RegisterTemplate {
ctx: &ctx,
error: params.error.as_deref(),
})
.into_response()
}
pub async fn post_register(
State(state): State<AppState>,
Extension(csrf): Extension<CsrfToken>,
Form(form): Form<RegisterForm>,
) -> impl IntoResponse {
if !state.app_ctx.config.allow_registration {
return Redirect::to("/").into_response();
}
if crate::html::csrf::mismatch(&csrf, &form.csrf_token) {
return StatusCode::FORBIDDEN.into_response();
}
match application::auth::register_and_login::execute(
&state.app_ctx.deps.auth.register_and_login,
application::auth::commands::RegisterAndLoginCommand {
email: form.email,
username: form.username,
password: form.password,
},
)
.await
{
Ok(result) => {
let max_age = (result.expires_at - Utc::now()).num_seconds().max(0);
let cookie =
set_cookie_header(&result.token, max_age, state.app_ctx.config.secure_cookies);
([cookie], Redirect::to("/")).into_response()
}
Err(_) => {
Redirect::to("/register?error=Registration+failed.+Please+try+again.").into_response()
}
}
}

View File

@@ -1,5 +1,5 @@
use axum::{
extract::Request,
extract::{Request, State},
http::{HeaderValue, header},
middleware::Next,
response::Response,
@@ -19,15 +19,18 @@ pub fn extract_from_cookie(headers: &axum::http::HeaderMap) -> Option<String> {
})
}
pub(crate) fn secure_flag() -> &'static str {
if std::env::var("SECURE_COOKIES").as_deref() == Ok("true") {
"; Secure"
} else {
""
}
#[derive(Clone, Copy)]
pub struct SecureCookies(pub bool);
pub(crate) fn secure_flag(secure_cookies: bool) -> &'static str {
if secure_cookies { "; Secure" } else { "" }
}
pub async fn csrf_middleware(mut req: Request, next: Next) -> Response {
pub async fn csrf_middleware(
State(SecureCookies(secure_cookies)): State<SecureCookies>,
mut req: Request,
next: Next,
) -> Response {
let existing = extract_from_cookie(req.headers());
let (token, needs_set) = match existing {
Some(t) => (t, false),
@@ -42,7 +45,7 @@ pub async fn csrf_middleware(mut req: Request, next: Next) -> Response {
let cookie = format!(
"csrf={}; HttpOnly; Path=/; SameSite=Strict{}",
token,
secure_flag()
secure_flag(secure_cookies)
);
if let Ok(val) = HeaderValue::from_str(&cookie) {
response.headers_mut().append(header::SET_COOKIE, val);

View File

@@ -0,0 +1,166 @@
use crate::html::helpers::{build_page_context, encode_domain_error};
use crate::html::templates::{ActivityFeedTemplate, NewReviewTemplate, build_page_items};
use crate::support::build_export_response;
use crate::{
extractors::{OptionalCookieUser, RequiredCookieUser},
html::csrf::CsrfToken,
html::forms::{ErrorQuery, FeedQueryParams, LogReviewData, LogReviewForm},
html::render::render_page,
state::AppState,
};
use application::diary::{commands::DeleteReviewCommand, delete_review, log_review};
use axum::{
Form,
extract::{Extension, Path, Query, State},
http::StatusCode,
response::{IntoResponse, Redirect},
};
use uuid::Uuid;
pub async fn get_new_review_page(
RequiredCookieUser(user_id): RequiredCookieUser,
State(state): State<AppState>,
Query(params): Query<ErrorQuery>,
Extension(csrf): Extension<CsrfToken>,
) -> impl IntoResponse {
let mut ctx = build_page_context(&state, Some(user_id), csrf.0).await;
ctx.page_title = "Log a Review — Movies Diary".to_string();
ctx.canonical_url = format!("{}/reviews/new", state.app_ctx.config.base_url);
render_page(NewReviewTemplate {
ctx: &ctx,
error: params.error.as_deref(),
})
}
pub async fn post_review_html(
State(state): State<AppState>,
RequiredCookieUser(user_id): RequiredCookieUser,
Extension(csrf): Extension<CsrfToken>,
Form(form): Form<LogReviewForm>,
) -> impl IntoResponse {
if crate::html::csrf::mismatch(&csrf, &form.csrf_token) {
return StatusCode::FORBIDDEN.into_response();
}
let data = match LogReviewData::try_from(form) {
Ok(d) => d,
Err(_) => {
return Redirect::to("/reviews/new?error=Invalid+date+format").into_response();
}
};
match log_review::execute(
&state.app_ctx.services.review_logger,
data.into_command(user_id.value()),
)
.await
{
Ok(_) => Redirect::to("/").into_response(),
Err(e) => {
let msg = encode_domain_error(&e);
Redirect::to(&format!("/reviews/new?error={}", msg)).into_response()
}
}
}
pub async fn post_delete_review_html(
State(state): State<AppState>,
RequiredCookieUser(user_id): RequiredCookieUser,
Extension(csrf): Extension<CsrfToken>,
Path(review_id): Path<Uuid>,
Form(form): Form<crate::html::forms::DeleteRedirectForm>,
) -> impl IntoResponse {
if crate::html::csrf::mismatch(&csrf, &form.csrf_token) {
return StatusCode::FORBIDDEN.into_response();
}
let cmd = DeleteReviewCommand {
review_id,
requesting_user_id: user_id.value(),
};
match delete_review::execute(&state.app_ctx.deps.diary.delete_review, cmd).await {
Ok(()) => {
let redirect_url = form
.redirect_after
.filter(|url| {
(url.starts_with('/') && !url.starts_with("//")) || url.starts_with('?')
})
.unwrap_or_else(|| "/".to_string());
Redirect::to(&redirect_url).into_response()
}
Err(e) => crate::errors::domain_error_response(e),
}
}
pub async fn get_export_html(
State(state): State<AppState>,
RequiredCookieUser(user_id): RequiredCookieUser,
Query(params): Query<api_types::ExportQueryParams>,
) -> impl IntoResponse {
build_export_response(&params.format, user_id.value(), &state)
}
pub async fn get_activity_feed_html(
OptionalCookieUser(user_id): OptionalCookieUser,
State(state): State<AppState>,
Query(params): Query<FeedQueryParams>,
Extension(csrf): Extension<CsrfToken>,
) -> impl IntoResponse {
let ctx = build_page_context(&state, user_id.clone(), csrf.0).await;
let limit = params.limit.unwrap_or(20);
let offset = params.offset.unwrap_or(0);
let filter_following = params.filter == "following" && user_id.is_some();
let filter_str = if filter_following { "following" } else { "all" };
let sort_by_str = match params.sort_by.as_str() {
"date_asc" => "date_asc",
"rating" => "rating",
"rating_asc" => "rating_asc",
_ => "date",
};
let query = application::diary::queries::GetActivityFeedQuery {
limit,
offset,
sort_by: sort_by_str.parse().unwrap_or_default(),
search: if params.search.is_empty() {
None
} else {
Some(params.search.clone())
},
viewer_user_id: user_id.map(|u| u.value()),
filter_following,
};
match application::diary::get_activity_feed::execute(
&state.app_ctx.deps.diary.get_activity_feed,
query,
)
.await
{
Ok(entries) => {
let entry_limit = entries.limit;
let entry_offset = entries.offset;
let has_more =
(entry_offset as u64).saturating_add(entry_limit as u64) < entries.total_count;
let total_pages = (entries.total_count as u32)
.saturating_add(entry_limit.saturating_sub(1))
.checked_div(entry_limit)
.unwrap_or(1);
let current_page = entry_offset.checked_div(entry_limit).unwrap_or(0);
let page_items = build_page_items(total_pages, current_page);
render_page(ActivityFeedTemplate {
entries: entries.items.as_slice(),
current_offset: entry_offset,
limit: entry_limit,
has_more,
ctx: &ctx,
page_items,
filter: filter_str.to_string(),
sort_by: sort_by_str.to_string(),
search: params.search,
})
.into_response()
}
Err(e) => crate::errors::domain_error_response(e),
}
}

View File

@@ -62,6 +62,13 @@ pub struct RegisterForm {
pub csrf_token: String,
}
#[derive(Deserialize)]
pub struct DeleteAccountForm {
pub password: String,
#[serde(rename = "_csrf", default)]
pub csrf_token: String,
}
#[derive(Deserialize)]
pub struct ErrorQuery {
pub error: Option<String>,
@@ -310,7 +317,3 @@ pub struct DismissWatchForm {
#[serde(rename = "_csrf", default)]
pub csrf_token: String,
}
#[cfg(test)]
#[path = "tests/forms.rs"]
mod tests;

View File

@@ -0,0 +1,62 @@
use api_types::HtmlPageContext;
use domain::value_objects::UserId;
use crate::state::AppState;
pub(crate) fn encode_error(msg: &str) -> String {
use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode};
utf8_percent_encode(msg, NON_ALPHANUMERIC).to_string()
}
/// Error text for a classic-page `?error=` redirect. Infrastructure failures
/// are logged here — the redirect is their only exit, so nothing else would —
/// and shown to the user generically, never with internal detail.
pub(crate) fn encode_domain_error(e: &domain::errors::DomainError) -> String {
match e {
domain::errors::DomainError::InfrastructureError(_) => {
tracing::error!("Internal error: {e}");
encode_error("Something went wrong, please try again")
}
_ => encode_error(&e.to_string()),
}
}
pub(crate) async fn build_page_context(
state: &AppState,
user_id: Option<UserId>,
csrf_token: String,
) -> HtmlPageContext {
let uuid = user_id.as_ref().map(|u| u.value());
// Page chrome (email/admin badge, pending-follow badge) degrades rather than
// blanking the page: a failed lookup or count logs a warning and falls back
// to a zeroed `PageViewer`, same as the previous silent `.unwrap_or(0)` — now
// logged and explicit, per the error policy in `get_page_viewer`'s doc comment.
let (user_email, is_admin, pending_follow_count) = if let Some(ref id) = user_id {
match application::users::get_page_viewer::execute(
&state.app_ctx.deps.users.get_page_viewer,
id.value(),
)
.await
{
Ok(viewer) => (viewer.email, viewer.is_admin, viewer.pending_follow_count),
Err(e) => {
tracing::warn!("get_page_viewer failed for {}: {e}", id.value());
(None, false, 0)
}
}
} else {
(None, false, 0)
};
HtmlPageContext {
user_email,
user_id: uuid,
is_admin,
register_enabled: state.app_ctx.config.allow_registration,
rss_url: "/feed.rss".to_string(),
page_title: "Movies Diary".to_string(),
canonical_url: state.app_ctx.config.base_url.clone(),
csrf_token,
page_rss_url: None,
pending_follow_count,
}
}

View File

@@ -1,11 +1,10 @@
use crate::state::AppState;
use axum::{
extract::{Path, State},
http::{StatusCode, header},
response::IntoResponse,
};
use crate::state::AppState;
pub async fn get_image(
State(state): State<AppState>,
Path(key): Path<String>,

View File

@@ -0,0 +1,149 @@
use crate::html::helpers::encode_domain_error;
use crate::html::render::render_page;
use crate::html::templates::ImportMappingTemplate;
use crate::support::str_to_domain_field;
use crate::{extractors::RequiredCookieUser, html::csrf::CsrfToken, state::AppState};
use application::import::{
apply_mapping as apply_import_mapping, commands::ApplyImportMappingCommand, get_mapping_stage,
};
use axum::{
Extension, Form,
extract::{Path, State},
response::{IntoResponse, Redirect},
};
use domain::models::{
FieldMapping,
import::{DomainField, Transform},
};
use domain::value_objects::ImportSessionId;
use std::collections::HashMap;
fn parse_mapping_form(form: &HashMap<String, String>) -> Vec<FieldMapping> {
let mut mappings = Vec::new();
let mut i = 0usize;
loop {
if i > 64 {
break;
}
let col_key = format!("mapping_{i}_col");
let Some(col) = form.get(&col_key).cloned() else {
break;
};
let field_str = form
.get(&format!("mapping_{i}_field"))
.map(|s| s.as_str())
.unwrap_or("");
if let Some(domain_field) = str_to_domain_field(field_str) {
let transform = if domain_field == DomainField::Rating {
let scale: f64 = form
.get(&format!("mapping_{i}_scale"))
.and_then(|s| s.parse().ok())
.unwrap_or(1.0);
Transform::RatingScale(scale)
} else if domain_field == DomainField::WatchedAt {
form.get(&format!("mapping_{i}_datefmt"))
.filter(|s| !s.is_empty())
.cloned()
.map(Transform::DateFormat)
.unwrap_or(Transform::Identity)
} else {
Transform::Identity
};
mappings.push(FieldMapping {
source_column: col,
domain_field,
transform,
});
}
i += 1;
}
mappings
}
pub async fn get_mapping_page(
State(state): State<AppState>,
RequiredCookieUser(user_id): RequiredCookieUser,
Path(session_id_str): Path<String>,
Extension(csrf): Extension<CsrfToken>,
) -> impl IntoResponse {
let Ok(session_id) = session_id_str
.parse::<uuid::Uuid>()
.map(ImportSessionId::from_uuid)
else {
return Redirect::to("/import").into_response();
};
let Ok(stage) = get_mapping_stage::execute(
&state.app_ctx.deps.import.get_mapping_stage,
session_id,
user_id.value(),
)
.await
else {
return Redirect::to("/import").into_response();
};
let ctx = crate::html::helpers::build_page_context(&state, Some(user_id), csrf.0).await;
let domain_fields: Vec<(&str, &str)> = vec![
("title", "Title"),
("release_year", "Release Year"),
("director", "Director"),
("rating", "Rating"),
("watched_at", "Watched At"),
("comment", "Comment"),
("external_metadata_id", "External ID"),
];
render_page(ImportMappingTemplate {
ctx: &ctx,
session_id: &session_id_str,
columns: &stage.columns,
sample_rows: &stage.sample_rows,
domain_fields: &domain_fields,
error: None,
})
.into_response()
}
pub async fn post_mapping(
State(state): State<AppState>,
RequiredCookieUser(user_id): RequiredCookieUser,
Path(session_id_str): Path<String>,
Extension(csrf): Extension<CsrfToken>,
Form(form): Form<HashMap<String, String>>,
) -> impl IntoResponse {
let csrf_token = form.get("_csrf").map(|s| s.as_str()).unwrap_or("");
if crate::html::csrf::mismatch(&csrf, csrf_token) {
return Redirect::to("/import").into_response();
}
let Ok(session_id) = session_id_str
.parse::<uuid::Uuid>()
.map(ImportSessionId::from_uuid)
else {
return Redirect::to("/import").into_response();
};
let mappings = parse_mapping_form(&form);
if mappings.is_empty() {
return Redirect::to(&format!(
"/import/{}/mapping?error=select+at+least+one+mapping",
session_id_str
))
.into_response();
}
match apply_import_mapping::execute(
&state.app_ctx.deps.import.apply_mapping,
ApplyImportMappingCommand {
user_id: user_id.value(),
session_id: session_id.value(),
mappings,
},
)
.await
{
Ok(_) => Redirect::to(&format!("/import/{}/preview", session_id_str)).into_response(),
Err(e) => Redirect::to(&format!(
"/import/{}/mapping?error={}",
session_id_str,
encode_domain_error(&e)
))
.into_response(),
}
}

View File

@@ -0,0 +1,7 @@
mod mapping;
mod preview;
mod upload;
pub use mapping::*;
pub use preview::*;
pub use upload::*;

View File

@@ -0,0 +1,131 @@
use crate::html::helpers::encode_domain_error;
use crate::html::mappers::import::annotated_to_preview_row;
use crate::html::render::render_page;
use crate::html::templates::{ImportPreviewRow, ImportPreviewTemplate};
use crate::{extractors::RequiredCookieUser, html::csrf::CsrfToken, state::AppState};
use application::import::{
commands::{ExecuteImportCommand, SaveImportProfileCommand},
execute as execute_import, get_preview_stage, save_profile as save_import_profile,
};
use axum::{
Extension, Form,
extract::{Path, State},
response::{IntoResponse, Redirect},
};
use domain::value_objects::ImportSessionId;
pub async fn get_preview_page(
State(state): State<AppState>,
RequiredCookieUser(user_id): RequiredCookieUser,
Path(session_id_str): Path<String>,
Extension(csrf): Extension<CsrfToken>,
) -> impl IntoResponse {
let Ok(session_id) = session_id_str
.parse::<uuid::Uuid>()
.map(ImportSessionId::from_uuid)
else {
return Redirect::to("/import").into_response();
};
let Ok(stage) = get_preview_stage::execute(
&state.app_ctx.deps.import.get_preview_stage,
session_id,
user_id.value(),
)
.await
else {
return Redirect::to("/import").into_response();
};
let preview = match stage {
get_preview_stage::PreviewStage::Ready(preview) => preview,
get_preview_stage::PreviewStage::NotYetMapped => {
return Redirect::to(&format!("/import/{}/mapping", session_id_str)).into_response();
}
};
let rows: Vec<ImportPreviewRow> = preview
.rows
.iter()
.enumerate()
.map(|(i, a)| annotated_to_preview_row(i, a))
.collect();
let ctx = crate::html::helpers::build_page_context(&state, Some(user_id), csrf.0).await;
render_page(ImportPreviewTemplate {
ctx: &ctx,
session_id: &session_id_str,
columns: &preview.columns,
rows: &rows,
})
.into_response()
}
pub async fn post_confirm(
State(state): State<AppState>,
RequiredCookieUser(user_id): RequiredCookieUser,
Path(session_id_str): Path<String>,
Extension(csrf): Extension<CsrfToken>,
Form(form_entries): Form<Vec<(String, String)>>,
) -> impl IntoResponse {
let csrf_token = form_entries
.iter()
.find(|(k, _)| k == "_csrf")
.map(|(_, v)| v.as_str())
.unwrap_or("");
if crate::html::csrf::mismatch(&csrf, csrf_token) {
return Redirect::to("/import").into_response();
}
let Ok(session_id) = session_id_str
.parse::<uuid::Uuid>()
.map(ImportSessionId::from_uuid)
else {
return Redirect::to("/import").into_response();
};
// Save profile if name provided
let profile_name = form_entries
.iter()
.find(|(k, _)| k == "profile_name")
.map(|(_, v)| v.clone())
.filter(|n| !n.trim().is_empty());
if let Some(name) = profile_name {
let _ = save_import_profile::execute(
&state.app_ctx.deps.import.save_profile,
SaveImportProfileCommand {
user_id: user_id.value(),
session_id: session_id.value(),
name,
},
)
.await;
}
// Collect all "confirmed" checkbox values
let confirmed: Vec<usize> = form_entries
.iter()
.filter(|(k, _)| k == "confirmed")
.filter_map(|(_, v)| v.parse::<usize>().ok())
.collect();
match execute_import::execute(
&state.app_ctx.deps.import.execute_import,
ExecuteImportCommand {
user_id: user_id.value(),
session_id: session_id.value(),
confirmed_indices: confirmed,
},
)
.await
{
Ok(summary) => Redirect::to(&format!(
"/import/done?imported={}&skipped={}&failed={}",
summary.imported,
summary.skipped_duplicates,
summary.failed.len()
))
.into_response(),
Err(e) => {
Redirect::to(&format!("/import?error={}", encode_domain_error(&e))).into_response()
}
}
}

View File

@@ -0,0 +1,147 @@
use crate::html::helpers::encode_domain_error;
use crate::html::render::render_page;
use crate::html::templates::{ImportProfileView, ImportUploadTemplate};
use crate::{extractors::RequiredCookieUser, html::csrf::CsrfToken, state::AppState};
use application::import::{
commands::{CreateImportSessionCommand, DeleteImportProfileCommand},
create_session as create_import_session, delete_profile as delete_import_profile,
list_profiles as list_import_profiles,
};
use axum::{
Extension, Form,
extract::{Multipart, Path, State},
response::{Html, IntoResponse, Redirect},
};
use domain::models::FileFormat;
use serde::Deserialize;
use std::collections::HashMap;
pub async fn get_import_page(
State(state): State<AppState>,
RequiredCookieUser(user_id): RequiredCookieUser,
Extension(csrf): Extension<CsrfToken>,
) -> impl IntoResponse {
let ctx = crate::html::helpers::build_page_context(&state, Some(user_id.clone()), csrf.0).await;
let profiles =
list_import_profiles::execute(&state.app_ctx.deps.import.list_profiles, &user_id)
.await
.unwrap_or_default()
.into_iter()
.map(|p| ImportProfileView {
id: p.id.value().to_string(),
name: p.name,
})
.collect::<Vec<_>>();
render_page(ImportUploadTemplate {
ctx: &ctx,
profiles: &profiles,
error: None,
})
}
pub async fn post_upload(
State(state): State<AppState>,
RequiredCookieUser(user_id): RequiredCookieUser,
mut multipart: Multipart,
) -> impl IntoResponse {
let mut file_bytes: Option<Vec<u8>> = None;
let mut format_str = "csv".to_string();
while let Ok(Some(field)) = multipart.next_field().await {
match field.name() {
Some("file") => {
if let Ok(bytes) = field.bytes().await {
file_bytes = Some(bytes.to_vec());
}
}
Some("format") => {
if let Ok(text) = field.text().await {
format_str = text;
}
}
_ => {}
}
}
let bytes = match file_bytes {
Some(b) if !b.is_empty() => b,
_ => return Redirect::to("/import?error=no+file+provided").into_response(),
};
let format = match format_str.as_str() {
"json" => FileFormat::Json,
"xlsx" => FileFormat::Xlsx,
_ => FileFormat::Csv,
};
match create_import_session::execute(
&state.app_ctx.deps.import.create_session,
CreateImportSessionCommand {
user_id: user_id.value(),
bytes,
format,
},
)
.await
{
Ok(r) => Redirect::to(&format!("/import/{}/mapping", r.session_id.value())).into_response(),
Err(e) => {
Redirect::to(&format!("/import?error={}", encode_domain_error(&e))).into_response()
}
}
}
pub async fn get_import_done(
State(state): State<AppState>,
RequiredCookieUser(user_id): RequiredCookieUser,
Extension(csrf): Extension<CsrfToken>,
axum::extract::Query(params): axum::extract::Query<ImportDoneParams>,
) -> impl IntoResponse {
let _ctx =
crate::html::helpers::build_page_context(&state, Some(user_id.clone()), csrf.0).await;
let html = format!(
r#"<!doctype html><html><body>
<h1>Import Complete</h1>
<p>Imported: {}</p>
<p>Skipped duplicates: {}</p>
<p>Failed: {}</p>
<a href="/users/{}">Go to My Profile</a>
</body></html>"#,
params.imported.unwrap_or(0),
params.skipped.unwrap_or(0),
params.failed.unwrap_or(0),
user_id.value(),
);
Html(html)
}
#[derive(Deserialize)]
pub struct ImportDoneParams {
pub imported: Option<usize>,
pub skipped: Option<usize>,
pub failed: Option<usize>,
}
pub async fn post_delete_profile(
State(state): State<AppState>,
RequiredCookieUser(user_id): RequiredCookieUser,
Path(profile_id_str): Path<String>,
Extension(csrf): Extension<CsrfToken>,
Form(form): Form<HashMap<String, String>>,
) -> impl IntoResponse {
let csrf_token = form.get("_csrf").map(|s| s.as_str()).unwrap_or("");
if crate::html::csrf::mismatch(&csrf, csrf_token) {
return Redirect::to("/import").into_response();
}
if let Ok(profile_id) = profile_id_str.parse::<uuid::Uuid>() {
let _ = delete_import_profile::execute(
&state.app_ctx.deps.import.delete_profile,
DeleteImportProfileCommand {
user_id: user_id.value(),
profile_id,
},
)
.await;
}
Redirect::to("/import").into_response()
}

View File

@@ -1,11 +1,9 @@
use axum::{
Form,
extract::{Extension, Path, Query, State},
http::StatusCode,
response::{IntoResponse, Redirect},
use crate::html::helpers::{build_page_context, encode_domain_error};
use crate::html::templates::{IntegrationsTemplate, WatchQueueTemplate};
use crate::{
extractors::RequiredCookieUser, html::csrf::CsrfToken, html::forms::ErrorQuery,
html::render::render_page, state::AppState,
};
use uuid::Uuid;
use application::integrations::{
commands::{
ConfirmWatchEventsCommand, DismissWatchEventsCommand, GenerateWebhookTokenCommand,
@@ -17,21 +15,18 @@ use application::integrations::{
queries::{GetWatchQueueQuery, GetWebhookTokensQuery},
revoke_token as revoke_webhook_token,
};
use crate::{
csrf::CsrfToken, extractors::RequiredCookieUser, forms::ErrorQuery, render::render_page,
state::AppState,
use axum::{
Form,
extract::{Extension, Path, Query, State},
http::StatusCode,
response::{IntoResponse, Redirect},
};
use template_askama::{IntegrationsTemplate, WatchQueueTemplate};
use super::helpers::{build_page_context, encode_domain_error};
// ── HTML ─────────────────────────────────────────────────────────────────────
use uuid::Uuid;
pub async fn get_integrations_page(
RequiredCookieUser(user_id): RequiredCookieUser,
State(state): State<AppState>,
Query(params): Query<crate::forms::IntegrationsQuery>,
Query(params): Query<crate::html::forms::IntegrationsQuery>,
Extension(csrf): Extension<CsrfToken>,
) -> impl IntoResponse {
let mut ctx = build_page_context(&state, Some(user_id.clone()), csrf.0).await;
@@ -46,9 +41,9 @@ pub async fn get_integrations_page(
.await
.unwrap_or_default();
let token_views: Vec<template_askama::WebhookTokenView> = tokens
let token_views: Vec<crate::html::templates::WebhookTokenView> = tokens
.iter()
.map(crate::mappers::integrations::webhook_token_view)
.map(crate::html::mappers::integrations::webhook_token_view)
.collect();
let webhook_base_url = state.app_ctx.config.base_url.clone();
@@ -65,9 +60,9 @@ pub async fn post_generate_token(
RequiredCookieUser(user_id): RequiredCookieUser,
State(state): State<AppState>,
Extension(csrf): Extension<CsrfToken>,
Form(form): Form<crate::forms::GenerateTokenForm>,
Form(form): Form<crate::html::forms::GenerateTokenForm>,
) -> impl IntoResponse {
if crate::csrf::mismatch(&csrf, &form.csrf_token) {
if crate::html::csrf::mismatch(&csrf, &form.csrf_token) {
return StatusCode::FORBIDDEN.into_response();
}
@@ -107,9 +102,9 @@ pub async fn post_revoke_token(
State(state): State<AppState>,
Path(token_id): Path<Uuid>,
Extension(csrf): Extension<CsrfToken>,
Form(form): Form<crate::forms::RevokeTokenForm>,
Form(form): Form<crate::html::forms::RevokeTokenForm>,
) -> impl IntoResponse {
if crate::csrf::mismatch(&csrf, &form.csrf_token) {
if crate::html::csrf::mismatch(&csrf, &form.csrf_token) {
return StatusCode::FORBIDDEN.into_response();
}
@@ -127,8 +122,6 @@ pub async fn post_revoke_token(
Redirect::to("/settings/integrations").into_response()
}
// ── Watch Queue ──────────────────────────────────────────────────────────────
pub async fn get_watch_queue_page(
RequiredCookieUser(user_id): RequiredCookieUser,
State(state): State<AppState>,
@@ -146,9 +139,9 @@ pub async fn get_watch_queue_page(
.await
.unwrap_or_default();
let entries: Vec<template_askama::WatchQueueDisplayEntry> = events
let entries: Vec<crate::html::templates::WatchQueueDisplayEntry> = events
.iter()
.map(crate::mappers::integrations::watch_queue_entry)
.map(crate::html::mappers::integrations::watch_queue_entry)
.collect();
render_page(WatchQueueTemplate {
@@ -164,9 +157,9 @@ pub async fn post_confirm_single(
State(state): State<AppState>,
Path(event_id): Path<Uuid>,
Extension(csrf): Extension<CsrfToken>,
Form(form): Form<crate::forms::ConfirmWatchForm>,
Form(form): Form<crate::html::forms::ConfirmWatchForm>,
) -> impl IntoResponse {
if crate::csrf::mismatch(&csrf, &form.csrf_token) {
if crate::html::csrf::mismatch(&csrf, &form.csrf_token) {
return StatusCode::FORBIDDEN.into_response();
}
@@ -195,9 +188,9 @@ pub async fn post_dismiss_single(
State(state): State<AppState>,
Path(event_id): Path<Uuid>,
Extension(csrf): Extension<CsrfToken>,
Form(form): Form<crate::forms::DismissWatchForm>,
Form(form): Form<crate::html::forms::DismissWatchForm>,
) -> impl IntoResponse {
if crate::csrf::mismatch(&csrf, &form.csrf_token) {
if crate::html::csrf::mismatch(&csrf, &form.csrf_token) {
return StatusCode::FORBIDDEN.into_response();
}

View File

@@ -1,6 +1,6 @@
use crate::html::templates::{ImportPreviewRow, ImportRowStatus};
use domain::models::AnnotatedRow;
use domain::models::import::RowResult;
use template_askama::{ImportPreviewRow, ImportRowStatus};
pub fn annotated_to_preview_row(idx: usize, annotated: &AnnotatedRow) -> ImportPreviewRow {
match &annotated.result {

View File

@@ -1,5 +1,5 @@
use crate::html::templates::{WatchQueueDisplayEntry, WebhookTokenView};
use domain::models::{WatchEvent, WebhookToken};
use template_askama::{WatchQueueDisplayEntry, WebhookTokenView};
pub fn webhook_token_view(t: &WebhookToken) -> WebhookTokenView {
WebhookTokenView {

View File

@@ -1,7 +1,4 @@
pub mod diary;
pub mod import;
pub mod integrations;
pub mod movies;
pub mod search;
pub mod users;
pub mod watchlist;

View File

@@ -1,7 +1,7 @@
use crate::html::templates::{RemoteActorData, RemoteActorDisplay, UserSummaryView};
use application::users::get_local_profile::PendingFollowerView;
use domain::models::RemoteActorInfo;
use domain::models::UserSummary;
use template_askama::{RemoteActorData, RemoteActorDisplay, UserSummaryView};
pub fn user_summary_view(u: &UserSummary) -> UserSummaryView {
let name = u

View File

@@ -1,5 +1,5 @@
use crate::html::templates::WatchlistDisplayEntry;
use domain::models::{RemoteWatchlistEntry, WatchlistWithMovie, collections::Paginated};
use template_askama::WatchlistDisplayEntry;
pub struct WatchlistPageResult {
pub display_entries: Vec<WatchlistDisplayEntry>,

View File

@@ -0,0 +1,165 @@
pub mod csrf;
pub mod forms;
pub(crate) mod helpers;
mod mappers;
pub mod render;
pub mod templates;
mod auth;
mod diary;
mod images;
mod import;
mod integrations;
mod movies;
mod profile;
mod rss;
mod social;
mod tags;
mod users;
mod watchlist;
mod wrapup;
use axum::{Router, routing};
use crate::state::AppState;
pub fn router() -> Router<AppState> {
let base = Router::new()
.route("/", routing::get(diary::get_activity_feed_html))
.route("/users", routing::get(users::get_users_list))
.route("/u/{username}", routing::get(users::get_user_by_username))
.route("/users/{id}", routing::get(profile::get_user_profile_html))
.route(
"/movies/{movie_id}",
routing::get(movies::get_movie_detail_html),
)
.route("/reviews/new", routing::get(diary::get_new_review_page))
.route("/reviews", routing::post(diary::post_review_html))
.route(
"/reviews/{id}/delete",
routing::post(diary::post_delete_review_html),
)
.route("/images/{*key}", routing::get(images::get_image))
.route(
"/posters/{path}",
routing::get(
|axum::extract::Path(p): axum::extract::Path<String>| async move {
axum::response::Redirect::permanent(&format!("/images/{}", p))
},
),
)
.route("/diary/export", routing::get(diary::get_export_html))
.route("/import", routing::get(import::get_import_page))
.route("/import/upload", routing::post(import::post_upload))
.route(
"/import/{id}/mapping",
routing::get(import::get_mapping_page).post(import::post_mapping),
)
.route(
"/import/{id}/preview",
routing::get(import::get_preview_page),
)
.route("/import/{id}/confirm", routing::post(import::post_confirm))
.route("/import/done", routing::get(import::get_import_done))
.route(
"/import/profiles/{profile_id}/delete",
routing::post(import::post_delete_profile),
)
.route("/feed.rss", routing::get(rss::get_feed))
.route("/users/{id}/feed.rss", routing::get(rss::get_user_feed))
.route(
"/settings/profile",
routing::get(users::get_profile_settings).post(users::post_profile_settings),
)
.route(
"/settings/account/delete",
routing::post(users::post_delete_own_account),
)
.route("/users/{id}/delete", routing::post(users::post_delete_user))
.route("/tags/{tag}", routing::get(tags::get_tag))
.route(
"/users/{id}/watchlist",
routing::get(watchlist::get_watchlist_page),
)
.route(
"/watchlist/add",
routing::post(watchlist::post_watchlist_add_html),
)
.route(
"/watchlist/{movie_id}/remove",
routing::post(watchlist::post_watchlist_remove_html),
)
.route(
"/settings/integrations",
routing::get(integrations::get_integrations_page),
)
.route(
"/settings/integrations/generate",
routing::post(integrations::post_generate_token),
)
.route(
"/settings/integrations/{id}/revoke",
routing::post(integrations::post_revoke_token),
)
.route(
"/watch-queue",
routing::get(integrations::get_watch_queue_page),
)
.route(
"/watch-queue/{id}/confirm",
routing::post(integrations::post_confirm_single),
)
.route(
"/watch-queue/{id}/dismiss",
routing::post(integrations::post_dismiss_single),
)
.route(
"/wrapups/{user_id}/{year}",
routing::get(wrapup::get_user_wrapup_html),
)
.route(
"/wrapups/global/{year}",
routing::get(wrapup::get_global_wrapup_html),
);
base.merge(social_routes())
}
pub fn auth_router() -> Router<AppState> {
Router::new()
.route(
"/login",
routing::get(auth::get_login_page).post(auth::post_login),
)
.route("/logout", routing::get(auth::get_logout))
.route(
"/register",
routing::get(auth::get_register_page).post(auth::post_register),
)
}
fn social_routes() -> Router<AppState> {
Router::new()
.route("/users/{id}/follow", routing::post(social::follow_user))
.route("/users/{id}/unfollow", routing::post(social::unfollow_user))
.route(
"/users/{id}/followers/accept",
routing::post(social::accept_follower_html),
)
.route(
"/users/{id}/followers/reject",
routing::post(social::reject_follower_html),
)
.route(
"/users/{id}/followers/remove",
routing::post(social::remove_follower_html),
)
.route(
"/users/{id}/following-list",
routing::get(social::get_following_page),
)
.route(
"/users/{id}/followers-list",
routing::get(social::get_followers_page),
)
}

View File

@@ -0,0 +1,77 @@
use crate::html::helpers::build_page_context;
use crate::html::templates::MovieDetailTemplate;
use crate::{
extractors::OptionalCookieUser, html::csrf::CsrfToken, html::render::render_page,
state::AppState,
};
use application::{
diary::{get_movie_social_page, queries::GetMovieSocialPageQuery},
watchlist::{is_on as is_on_watchlist, queries::IsOnWatchlistQuery},
};
use axum::{
extract::{Extension, Path, Query, State},
response::IntoResponse,
};
pub async fn get_movie_detail_html(
OptionalCookieUser(user_id): OptionalCookieUser,
State(state): State<AppState>,
Path(movie_id): Path<uuid::Uuid>,
Query(params): Query<api_types::PaginationQueryParams>,
Extension(csrf): Extension<CsrfToken>,
) -> impl IntoResponse {
let ctx = build_page_context(&state, user_id.clone(), csrf.0).await;
let limit = params.limit.unwrap_or(20);
let offset = params.offset.unwrap_or(0);
match get_movie_social_page::execute(
&state.app_ctx.deps.diary.get_movie_social_page,
GetMovieSocialPageQuery {
movie_id,
limit,
offset,
},
)
.await
{
Err(e) => crate::errors::domain_error_response(e),
Ok(result) => {
let histogram_max = result
.stats
.rating_histogram
.iter()
.copied()
.max()
.unwrap_or(1);
let has_more =
result.reviews.offset + result.reviews.limit < result.reviews.total_count as u32;
let on_watchlist = match &user_id {
Some(uid) => is_on_watchlist::execute(
&state.app_ctx.deps.watchlist.is_on_watchlist,
IsOnWatchlistQuery {
user_id: uid.value(),
movie_id,
},
)
.await
.unwrap_or(false),
None => false,
};
let current_offset = result.reviews.offset;
let reviews_limit = result.reviews.limit;
render_page(MovieDetailTemplate {
ctx: &ctx,
movie: &result.movie,
stats: &result.stats,
profile: result.profile.as_ref(),
reviews: result.reviews.items.as_slice(),
on_watchlist,
current_offset,
has_more,
limit: reviews_limit,
histogram_max,
})
.into_response()
}
}
}

View File

@@ -0,0 +1,262 @@
use crate::html::helpers::build_page_context;
use crate::html::templates::{
EmbedProfileTemplate, MonthlyRatingRow, ProfileTemplate, RemoteActorData, bar_height_px,
build_heatmap, build_page_items,
};
use crate::{
extractors::OptionalCookieUser, html::csrf::CsrfToken, html::render::render_page,
state::AppState,
};
use application::users::get_local_profile;
use axum::{
extract::{Extension, Path, Query, State},
http::StatusCode,
response::IntoResponse,
};
use domain::errors::DomainError;
use std::str::FromStr;
use uuid::Uuid;
const DEFAULT_PAGE_LIMIT: u32 = 5;
struct PaginationInfo {
offset: u32,
has_more: bool,
limit: u32,
page_items: Vec<crate::html::templates::PageItem>,
}
fn compute_pagination(
entries: Option<&domain::models::collections::Paginated<domain::models::DiaryEntry>>,
) -> PaginationInfo {
let (offset, has_more, limit) = entries
.map(|e| {
let has_more = (e.offset as u64).saturating_add(e.limit as u64) < e.total_count;
(e.offset, has_more, e.limit)
})
.unwrap_or((0, false, DEFAULT_PAGE_LIMIT));
let total = entries.map(|e| e.total_count as u32).unwrap_or(0);
let total_pages = total
.saturating_add(limit.saturating_sub(1))
.checked_div(limit)
.unwrap_or(1);
let current_page = offset.checked_div(limit).unwrap_or(0);
let page_items = build_page_items(total_pages, current_page);
PaginationInfo {
offset,
has_more,
limit,
page_items,
}
}
struct StatsDisplay {
avg_rating: String,
favorite_director: String,
most_active_month: String,
}
fn build_stats_display(stats: &domain::models::UserStats) -> StatsDisplay {
StatsDisplay {
avg_rating: stats
.avg_rating
.map(|r| format!("{:.1}", r))
.unwrap_or_else(|| "\u{2014}".to_string()),
favorite_director: stats
.favorite_director
.clone()
.unwrap_or_else(|| "\u{2014}".to_string()),
most_active_month: stats
.most_active_month
.clone()
.unwrap_or_else(|| "\u{2014}".to_string()),
}
}
fn build_monthly_rating_rows<'a>(
trends: Option<&'a domain::models::UserTrends>,
) -> Vec<MonthlyRatingRow<'a>> {
trends
.map(|t| {
t.monthly_ratings
.iter()
.map(|r| MonthlyRatingRow {
rating: r,
bar_height_px: bar_height_px(r.avg_rating),
})
.collect()
})
.unwrap_or_default()
}
async fn fetch_profile_goals(
state: &AppState,
user_id: Uuid,
) -> Vec<crate::html::templates::GoalViewData> {
let goals_list = application::goals::list::execute(
&state.app_ctx.deps.goals.query,
application::goals::queries::ListGoalsQuery { user_id },
)
.await
.unwrap_or_default();
goals_list
.iter()
.map(|g| crate::html::templates::GoalViewData {
year: g.goal.year(),
target_count: g.goal.target_count(),
current_count: g.current_count,
percentage: g.percentage().round(),
is_complete: g.is_complete(),
})
.collect()
}
pub async fn get_user_profile_html(
OptionalCookieUser(user_id): OptionalCookieUser,
State(state): State<AppState>,
Path(profile_user_uuid): Path<Uuid>,
headers: axum::http::HeaderMap,
Query(params): Query<crate::html::forms::ProfileQueryParams>,
Extension(csrf): Extension<CsrfToken>,
) -> impl IntoResponse {
// Content negotiation: AP clients request application/activity+json
if let Some(response) =
crate::federation::actor_document_response(&state, &headers, profile_user_uuid).await
{
return response;
}
let mut ctx = build_page_context(&state, user_id.clone(), csrf.0).await;
let view_str = params.view.as_deref().unwrap_or("recent");
let profile_view = match application::users::queries::ProfileView::from_str(view_str) {
Ok(v) => v,
Err(_) => {
return (
axum::http::StatusCode::BAD_REQUEST,
"invalid view parameter",
)
.into_response();
}
};
let sort_by_str = match params.sort_by.as_str() {
"date_asc" => "date_asc",
"rating" => "rating",
"rating_asc" => "rating_asc",
_ => "date",
};
let is_own_profile = user_id
.as_ref()
.map(|u| u.value() == profile_user_uuid)
.unwrap_or(false);
let query = application::users::queries::GetUserProfileQuery {
user_id: profile_user_uuid,
view: profile_view,
limit: params.limit,
offset: params.offset,
sort_by: sort_by_str.parse().unwrap_or_default(),
search: if params.search.is_empty() {
None
} else {
Some(params.search.clone())
},
is_own_profile,
include_remote: false,
};
match get_local_profile::execute(&state.app_ctx.deps.users.get_local_profile, query).await {
Ok(profile) => {
ctx.page_title = format!("{}'s Diary — Movies Diary", profile.identity.username);
ctx.canonical_url = format!(
"{}/users/{}",
state.app_ctx.config.base_url, profile_user_uuid
);
let pag = compute_pagination(profile.entries.as_ref());
if !is_own_profile {
ctx.page_rss_url = Some(format!("/users/{}/feed.rss", profile_user_uuid));
}
let display_name = profile
.identity
.email
.split('@')
.next()
.unwrap_or("?")
.to_string();
let stats_disp = build_stats_display(&profile.stats);
let history = profile.history.map(application::users::group_by_month);
let heatmap = history.as_deref().map(build_heatmap).unwrap_or_default();
let monthly_rating_rows = build_monthly_rating_rows(profile.trends.as_ref());
let pending_followers: Vec<RemoteActorData> = profile
.pending_followers
.iter()
.map(crate::html::mappers::users::pending_follower_data)
.collect();
if params.embed {
let profile_url = format!(
"{}/users/{}",
state.app_ctx.config.base_url, profile_user_uuid
);
let response = render_page(EmbedProfileTemplate {
profile_display_name: display_name,
profile_user_id: profile_user_uuid,
profile_url,
stats: &profile.stats,
avg_rating_display: stats_disp.avg_rating,
favorite_director_display: stats_disp.favorite_director,
most_active_month_display: stats_disp.most_active_month,
view: profile_view.as_str(),
entries: profile.entries.as_ref(),
current_offset: pag.offset,
has_more: pag.has_more,
limit: pag.limit,
history: history.as_ref(),
trends: profile.trends.as_ref(),
monthly_rating_rows,
heatmap,
page_items: pag.page_items,
sort_by: sort_by_str.to_string(),
});
let mut resp = response.into_response();
resp.headers_mut().remove("x-frame-options");
resp
} else {
let goals = fetch_profile_goals(&state, profile_user_uuid).await;
render_page(ProfileTemplate {
ctx: &ctx,
profile_display_name: display_name,
profile_user_id: profile_user_uuid,
stats: &profile.stats,
avg_rating_display: stats_disp.avg_rating,
favorite_director_display: stats_disp.favorite_director,
most_active_month_display: stats_disp.most_active_month,
view: profile_view.as_str(),
entries: profile.entries.as_ref(),
current_offset: pag.offset,
has_more: pag.has_more,
limit: pag.limit,
history: history.as_ref(),
trends: profile.trends.as_ref(),
monthly_rating_rows,
heatmap,
page_items: pag.page_items,
is_own_profile,
is_service_account: domain::value_objects::UserId::from_uuid(profile_user_uuid)
.is_service_actor(),
error: params.error,
following_count: profile.following_count,
followers_count: profile.followers_count,
pending_followers,
sort_by: sort_by_str.to_string(),
search: params.search.clone(),
goals,
})
.into_response()
}
}
Err(DomainError::NotFound(_)) => StatusCode::NOT_FOUND.into_response(),
Err(e) => crate::errors::domain_error_response(e),
}
}

View File

@@ -3,7 +3,7 @@ use axum::{
response::{Html, IntoResponse, Response},
};
pub fn render_page(template: impl template_askama::askama::Template) -> Response {
pub fn render_page(template: impl askama::Template) -> Response {
match template.render() {
Ok(html) => Html(html).into_response(),
Err(e) => {

View File

@@ -1,18 +1,18 @@
use crate::{errors::ApiError, state::AppState};
use application::{diary::get_diary, diary::get_user_feed, diary::queries::GetDiaryQuery};
use axum::{
extract::{Path, State},
http::header,
response::IntoResponse,
};
use domain::{models::ReviewSortBy, ports::FeedChannel};
use uuid::Uuid;
use application::{diary::get_diary, diary::get_user_feed, diary::queries::GetDiaryQuery};
use domain::{errors::DomainError, models::ReviewSortBy, ports::FeedChannel};
use crate::{errors::ApiError, state::AppState};
const RSS_FEED_LIMIT: u32 = 50;
pub async fn get_feed(State(state): State<AppState>) -> Result<impl IntoResponse, ApiError> {
let query = GetDiaryQuery {
limit: Some(super::RSS_FEED_LIMIT),
limit: Some(RSS_FEED_LIMIT),
offset: Some(0),
sort_by: Some(ReviewSortBy::Descending),
movie_id: None,
@@ -29,7 +29,7 @@ pub async fn get_feed(State(state): State<AppState>) -> Result<impl IntoResponse
self_path: "/feed.rss",
},
)
.map_err(|e| ApiError(DomainError::InfrastructureError(e)))?;
.map_err(ApiError)?;
Ok((
[(header::CONTENT_TYPE, "application/xml; charset=utf-8")],
xml,
@@ -43,7 +43,7 @@ pub async fn get_user_feed(
let feed = get_user_feed::execute(
&state.app_ctx.deps.diary.get_user_feed,
user_id,
super::RSS_FEED_LIMIT,
RSS_FEED_LIMIT,
)
.await?;
@@ -61,7 +61,7 @@ pub async fn get_user_feed(
self_path: &self_path,
},
)
.map_err(|e| ApiError(DomainError::InfrastructureError(e)))?;
.map_err(ApiError)?;
Ok((
[(header::CONTENT_TYPE, "application/xml; charset=utf-8")],

View File

@@ -0,0 +1,319 @@
use crate::html::helpers::{build_page_context, encode_domain_error};
use crate::html::templates::{FollowersTemplate, FollowingTemplate, RemoteActorData};
use crate::{
extractors::RequiredCookieUser,
html::csrf::CsrfToken,
html::forms::{FollowForm, FollowerActionForm, UnfollowForm},
html::render::render_page,
state::AppState,
};
use axum::{
Form,
extract::{Extension, Path, Query, State},
http::StatusCode,
response::{IntoResponse, Redirect},
};
use domain::value_objects::{FollowTarget, InstanceIdentity, SocialActor};
use uuid::Uuid;
fn social_actor_to_template(actor: SocialActor, instance: &InstanceIdentity) -> RemoteActorData {
RemoteActorData {
url: instance.actor_url_of(&actor.identity),
handle: actor.handle,
display_name: actor.display_name,
avatar_url: actor.avatar_url,
}
}
pub async fn follow_user(
RequiredCookieUser(user_id): RequiredCookieUser,
State(state): State<AppState>,
Path(profile_user_uuid): Path<Uuid>,
Extension(csrf): Extension<CsrfToken>,
Form(form): Form<FollowForm>,
) -> impl IntoResponse {
if user_id.value() != profile_user_uuid {
return StatusCode::FORBIDDEN.into_response();
}
if crate::html::csrf::mismatch(&csrf, &form.csrf_token) {
return StatusCode::FORBIDDEN.into_response();
}
let redirect_base = form
.redirect_after
.as_deref()
.filter(|u| u.starts_with('/') && !u.starts_with("//"))
.unwrap_or(&format!("/users/{}", profile_user_uuid))
.to_string();
match application::social::execute::execute_command(
&state.app_ctx.deps.social.command,
application::social::commands::SocialCmd::Follow {
follower_id: user_id.value(),
target: FollowTarget::Handle(form.handle),
},
)
.await
{
Ok(()) => Redirect::to(&redirect_base).into_response(),
Err(e) => {
tracing::error!("follow error: {:?}", e);
let msg = encode_domain_error(&e);
let sep = if redirect_base.contains('?') {
'&'
} else {
'?'
};
Redirect::to(&format!("{}{}error={}", redirect_base, sep, msg)).into_response()
}
}
}
pub async fn unfollow_user(
RequiredCookieUser(user_id): RequiredCookieUser,
State(state): State<AppState>,
Path(profile_user_uuid): Path<Uuid>,
Extension(csrf): Extension<CsrfToken>,
Form(form): Form<UnfollowForm>,
) -> impl IntoResponse {
if user_id.value() != profile_user_uuid {
return StatusCode::FORBIDDEN.into_response();
}
if crate::html::csrf::mismatch(&csrf, &form.csrf_token) {
return StatusCode::FORBIDDEN.into_response();
}
let instance = state.app_ctx.instance.clone();
match application::social::execute::execute_command(
&state.app_ctx.deps.social.command,
application::social::commands::SocialCmd::Unfollow {
follower_id: user_id.value(),
target: instance.identify(&form.actor_url),
},
)
.await
{
Ok(()) => {
Redirect::to(&format!("/users/{}/following-list", profile_user_uuid)).into_response()
}
Err(e) => {
let msg = encode_domain_error(&e);
Redirect::to(&format!(
"/users/{}/following-list?error={}",
profile_user_uuid, msg
))
.into_response()
}
}
}
pub async fn accept_follower_html(
RequiredCookieUser(user_id): RequiredCookieUser,
State(state): State<AppState>,
Path(profile_user_uuid): Path<Uuid>,
Extension(csrf): Extension<CsrfToken>,
Form(form): Form<FollowerActionForm>,
) -> impl IntoResponse {
if user_id.value() != profile_user_uuid {
return StatusCode::FORBIDDEN.into_response();
}
if crate::html::csrf::mismatch(&csrf, &form.csrf_token) {
return StatusCode::FORBIDDEN.into_response();
}
let instance = state.app_ctx.instance.clone();
match application::social::execute::execute_command(
&state.app_ctx.deps.social.command,
application::social::commands::SocialCmd::AcceptFollow {
owner_id: user_id.value(),
requester: instance.identify(&form.actor_url),
},
)
.await
{
Ok(_) => Redirect::to(&format!("/users/{}", profile_user_uuid)).into_response(),
Err(e) => {
let msg = encode_domain_error(&e);
Redirect::to(&format!("/users/{}?error={}", profile_user_uuid, msg)).into_response()
}
}
}
pub async fn reject_follower_html(
RequiredCookieUser(user_id): RequiredCookieUser,
State(state): State<AppState>,
Path(profile_user_uuid): Path<Uuid>,
Extension(csrf): Extension<CsrfToken>,
Form(form): Form<FollowerActionForm>,
) -> impl IntoResponse {
if user_id.value() != profile_user_uuid {
return StatusCode::FORBIDDEN.into_response();
}
if crate::html::csrf::mismatch(&csrf, &form.csrf_token) {
return StatusCode::FORBIDDEN.into_response();
}
let instance = state.app_ctx.instance.clone();
match application::social::execute::execute_command(
&state.app_ctx.deps.social.command,
application::social::commands::SocialCmd::RejectFollow {
owner_id: user_id.value(),
requester: instance.identify(&form.actor_url),
},
)
.await
{
Ok(_) => Redirect::to(&format!("/users/{}", profile_user_uuid)).into_response(),
Err(e) => {
let msg = encode_domain_error(&e);
Redirect::to(&format!("/users/{}?error={}", profile_user_uuid, msg)).into_response()
}
}
}
pub async fn get_following_page(
RequiredCookieUser(user_id): RequiredCookieUser,
State(state): State<AppState>,
Path(profile_user_uuid): Path<Uuid>,
Query(params): Query<crate::html::forms::ErrorQuery>,
Extension(csrf): Extension<CsrfToken>,
) -> impl IntoResponse {
if user_id.value() != profile_user_uuid {
return StatusCode::FORBIDDEN.into_response();
}
let mut ctx = build_page_context(&state, Some(user_id.clone()), csrf.0).await;
ctx.page_title = "Following — Movies Diary".to_string();
ctx.canonical_url = format!(
"{}/users/{}/following-list",
state.app_ctx.config.base_url, profile_user_uuid
);
let instance = state.app_ctx.instance.clone();
match application::social::get_following::execute(
&state.app_ctx.deps.social.query,
user_id.value(),
)
.await
{
Ok(following) => {
let actors: Vec<RemoteActorData> = following
.into_iter()
.map(|a| social_actor_to_template(a, &instance))
.collect();
let pending_actors: Vec<RemoteActorData> =
match application::social::get_pending_following::execute(
&state.app_ctx.deps.social.query,
user_id.value(),
)
.await
{
Ok(pending) => pending
.into_iter()
.map(|a| social_actor_to_template(a, &instance))
.collect(),
Err(e) => {
tracing::error!("get_pending_following error: {:?}", e);
Vec::new()
}
};
render_page(FollowingTemplate {
ctx,
user_id: profile_user_uuid,
actors,
pending_actors,
error: params.error,
})
.into_response()
}
Err(e) => {
tracing::error!("get_following error: {:?}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
"Failed to load following list",
)
.into_response()
}
}
}
pub async fn get_followers_page(
RequiredCookieUser(user_id): RequiredCookieUser,
State(state): State<AppState>,
Path(profile_user_uuid): Path<Uuid>,
Query(params): Query<crate::html::forms::ErrorQuery>,
Extension(csrf): Extension<CsrfToken>,
) -> impl IntoResponse {
if user_id.value() != profile_user_uuid {
return StatusCode::FORBIDDEN.into_response();
}
let mut ctx = build_page_context(&state, Some(user_id.clone()), csrf.0).await;
ctx.page_title = "Followers — Movies Diary".to_string();
ctx.canonical_url = format!(
"{}/users/{}/followers-list",
state.app_ctx.config.base_url, profile_user_uuid
);
let instance = state.app_ctx.instance.clone();
match application::social::get_followers::execute(
&state.app_ctx.deps.social.query,
user_id.value(),
)
.await
{
Ok(followers) => {
let actors: Vec<RemoteActorData> = followers
.into_iter()
.map(|a| social_actor_to_template(a, &instance))
.collect();
render_page(FollowersTemplate {
ctx,
user_id: profile_user_uuid,
actors,
error: params.error,
})
.into_response()
}
Err(e) => {
tracing::error!("get_followers error: {:?}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
"Failed to load followers list",
)
.into_response()
}
}
}
pub async fn remove_follower_html(
RequiredCookieUser(user_id): RequiredCookieUser,
State(state): State<AppState>,
Path(profile_user_uuid): Path<Uuid>,
Extension(csrf): Extension<CsrfToken>,
Form(form): Form<FollowerActionForm>,
) -> impl IntoResponse {
if user_id.value() != profile_user_uuid {
return StatusCode::FORBIDDEN.into_response();
}
if crate::html::csrf::mismatch(&csrf, &form.csrf_token) {
return StatusCode::FORBIDDEN.into_response();
}
let instance = state.app_ctx.instance.clone();
match application::social::execute::execute_command(
&state.app_ctx.deps.social.command,
application::social::commands::SocialCmd::RemoveFollower {
owner_id: user_id.value(),
follower: instance.identify(&form.actor_url),
},
)
.await
{
Ok(_) => {
Redirect::to(&format!("/users/{}/followers-list", profile_user_uuid)).into_response()
}
Err(e) => {
let msg = encode_domain_error(&e);
Redirect::to(&format!(
"/users/{}/followers-list?error={}",
profile_user_uuid, msg
))
.into_response()
}
}
}
#[cfg(test)]
mod tests;

View File

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

View File

@@ -0,0 +1,12 @@
use axum::{
extract::Path,
response::{IntoResponse, Redirect},
};
pub async fn get_tag(Path(tag): Path<String>) -> impl IntoResponse {
if tag.eq_ignore_ascii_case("moviesdiary") {
Redirect::temporary("/")
} else {
Redirect::temporary(&format!("/?search={}", tag))
}
}

View File

@@ -0,0 +1,16 @@
use api_types::HtmlPageContext;
use askama::Template;
#[derive(Template)]
#[template(path = "login.html")]
pub struct LoginTemplate<'a> {
pub error: Option<&'a str>,
pub ctx: &'a HtmlPageContext,
}
#[derive(Template)]
#[template(path = "register.html")]
pub struct RegisterTemplate<'a> {
pub error: Option<&'a str>,
pub ctx: &'a HtmlPageContext,
}

View File

@@ -0,0 +1,36 @@
pub struct PageItem {
pub number: u32,
pub is_current: bool,
pub is_ellipsis: bool,
}
pub fn build_page_items(total_pages: u32, current_page: u32) -> Vec<PageItem> {
if total_pages <= 1 {
return vec![];
}
let mut set = std::collections::BTreeSet::new();
set.insert(0u32);
set.insert(total_pages - 1);
let start = current_page.saturating_sub(2);
let end = (current_page + 2).min(total_pages - 1);
for p in start..=end {
set.insert(p);
}
let pages: Vec<u32> = set.into_iter().collect();
let mut items = Vec::new();
for (i, &p) in pages.iter().enumerate() {
if i > 0 && p > pages[i - 1] + 1 {
items.push(PageItem {
number: 0,
is_current: false,
is_ellipsis: true,
});
}
items.push(PageItem {
number: p,
is_current: p == current_page,
is_ellipsis: false,
});
}
items
}

View File

@@ -0,0 +1,55 @@
use super::{PageItem, filters};
use api_types::HtmlPageContext;
use askama::Template;
use domain::models::{DiaryEntry, FeedEntry, ReviewSource};
#[derive(Template)]
#[template(path = "diary.html")]
pub struct DiaryTemplate<'a> {
pub entries: &'a [DiaryEntry],
pub current_offset: u32,
pub limit: u32,
pub has_more: bool,
pub ctx: &'a HtmlPageContext,
pub page_items: Vec<PageItem>,
}
#[derive(Template)]
#[template(path = "new_review.html")]
pub struct NewReviewTemplate<'a> {
pub error: Option<&'a str>,
pub ctx: &'a HtmlPageContext,
}
#[derive(Template)]
#[template(path = "activity_feed.html")]
pub struct ActivityFeedTemplate<'a> {
pub entries: &'a [FeedEntry],
pub current_offset: u32,
pub limit: u32,
pub has_more: bool,
pub ctx: &'a HtmlPageContext,
pub page_items: Vec<PageItem>,
pub filter: String,
pub sort_by: String,
pub search: String,
}
impl<'a> ActivityFeedTemplate<'a> {
pub fn filter_qs(&self) -> String {
let mut parts = vec![
format!("filter={}", self.filter),
format!("sort_by={}", self.sort_by),
];
if !self.search.is_empty() {
let encoded = self
.search
.replace(' ', "+")
.replace('#', "%23")
.replace('&', "%26")
.replace('=', "%3D");
parts.push(format!("search={}", encoded));
}
format!("&{}", parts.join("&"))
}
}

View File

@@ -0,0 +1,47 @@
use api_types::HtmlPageContext;
use askama::Template;
#[derive(Template)]
#[template(path = "import_upload.html")]
pub struct ImportUploadTemplate<'a> {
pub ctx: &'a HtmlPageContext,
pub profiles: &'a [ImportProfileView],
pub error: Option<&'a str>,
}
pub struct ImportProfileView {
pub id: String,
pub name: String,
}
#[derive(Template)]
#[template(path = "import_mapping.html")]
pub struct ImportMappingTemplate<'a> {
pub ctx: &'a HtmlPageContext,
pub session_id: &'a str,
pub columns: &'a [String],
pub sample_rows: &'a [Vec<String>],
pub domain_fields: &'a [(&'static str, &'static str)],
pub error: Option<&'a str>,
}
#[derive(Template)]
#[template(path = "import_preview.html")]
pub struct ImportPreviewTemplate<'a> {
pub ctx: &'a HtmlPageContext,
pub session_id: &'a str,
pub columns: &'a [String],
pub rows: &'a [ImportPreviewRow],
}
pub struct ImportPreviewRow {
pub index: usize,
pub status: ImportRowStatus,
pub cells: Vec<String>,
}
pub enum ImportRowStatus {
Valid,
Duplicate,
Invalid(String),
}

View File

@@ -0,0 +1,36 @@
use api_types::HtmlPageContext;
use askama::Template;
#[derive(Template)]
#[template(path = "integrations.html")]
pub struct IntegrationsTemplate<'a> {
pub ctx: &'a HtmlPageContext,
pub tokens: &'a [WebhookTokenView],
pub webhook_base_url: &'a str,
pub new_token: Option<&'a str>,
}
pub struct WebhookTokenView {
pub id: String,
pub provider: String,
pub label: Option<String>,
pub created_at: String,
pub last_used_at: Option<String>,
}
#[derive(Template)]
#[template(path = "watch_queue.html")]
pub struct WatchQueueTemplate<'a> {
pub ctx: &'a HtmlPageContext,
pub entries: &'a [WatchQueueDisplayEntry],
pub error: Option<&'a str>,
}
pub struct WatchQueueDisplayEntry {
pub id: String,
pub title: String,
pub year: Option<u16>,
pub source: String,
pub watched_at: String,
pub movie_url: Option<String>,
}

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