Reviewed-on: #26
This commit was merged in pull request #26.
This commit is contained in:
@@ -1,5 +1,2 @@
|
||||
[env]
|
||||
SQLX_OFFLINE = "true"
|
||||
|
||||
[registries.gitea]
|
||||
index = "sparse+https://git.gabrielkaszewski.dev/api/packages/GKaszewski/cargo/"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
25
.github/workflows/ci.yml
vendored
25
.github/workflows/ci.yml
vendored
@@ -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
1
.gitignore
vendored
@@ -18,3 +18,4 @@ docs/*
|
||||
|
||||
imgs/
|
||||
.sqlx/
|
||||
!crates/adapters/sqlite/tests/fixtures/*.db
|
||||
|
||||
295
CODE_STYLE.md
Normal file
295
CODE_STYLE.md
Normal 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.
|
||||
12
CONTEXT.md
12
CONTEXT.md
@@ -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
|
||||
|
||||
@@ -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
901
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
133
Cargo.toml
133
Cargo.toml
@@ -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
|
||||
|
||||
91
Dockerfile
91
Dockerfile
@@ -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
121
Makefile
@@ -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
113
README.md
@@ -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)
|
||||
[](https://www.rust-lang.org/)
|
||||
[](https://hub.docker.com/)
|
||||
[](https://activitypub.rocks/)
|
||||
[](https://www.sqlite.org/)
|
||||
[](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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
33
crates/adapters/activitypub/src/deletion_notifier.rs
Normal file
33
crates/adapters/activitypub/src/deletion_notifier.rs
Normal 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()))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -306,7 +306,3 @@ pub fn goal_to_ap_object(
|
||||
cc: vec![format!("{}/followers", actor_url)],
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/objects.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -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<()>;
|
||||
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"]);
|
||||
}
|
||||
32
crates/adapters/activitypub/src/tombstone_gate.rs
Normal file
32
crates/adapters/activitypub/src/tombstone_gate.rs
Normal 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()
|
||||
}
|
||||
@@ -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)))
|
||||
}
|
||||
|
||||
253
crates/adapters/activitypub/tests/activity_ids.rs
Normal file
253
crates/adapters/activitypub/tests/activity_ids.rs
Normal 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");
|
||||
}
|
||||
4
crates/adapters/activitypub/tests/objects.rs
Normal file
4
crates/adapters/activitypub/tests/objects.rs
Normal 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;
|
||||
112
crates/adapters/activitypub/tests/objects/review_object_test.rs
Normal file
112
crates/adapters/activitypub/tests/objects/review_object_test.rs
Normal 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());
|
||||
}
|
||||
@@ -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"])
|
||||
);
|
||||
}
|
||||
67
crates/adapters/activitypub/tests/user_adapter.rs
Normal file
67
crates/adapters/activitypub/tests/user_adapter.rs
Normal 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);
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
[package]
|
||||
name = "adapter-common"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
domain = { workspace = true }
|
||||
sqlx = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
@@ -1,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"] }
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 }
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
))
|
||||
39
crates/adapters/http-axum/Cargo.toml
Normal file
39
crates/adapters/http-axum/Cargo.toml
Normal 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"] }
|
||||
102
crates/adapters/http-axum/src/api/auth.rs
Normal file
102
crates/adapters/http-axum/src/api/auth.rs
Normal 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
|
||||
}
|
||||
197
crates/adapters/http-axum/src/api/diary.rs
Normal file
197
crates/adapters/http-axum/src/api/diary.rs
Normal 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(¶ms.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,
|
||||
}))
|
||||
}
|
||||
@@ -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,
|
||||
5
crates/adapters/http-axum/src/api/import/mod.rs
Normal file
5
crates/adapters/http-axum/src/api/import/mod.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
mod profiles;
|
||||
mod sessions;
|
||||
|
||||
pub use profiles::*;
|
||||
pub use sessions::*;
|
||||
102
crates/adapters/http-axum/src/api/import/profiles.rs
Normal file
102
crates/adapters/http-axum/src/api/import/profiles.rs
Normal 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)
|
||||
}
|
||||
307
crates/adapters/http-axum/src/api/import/sessions.rs
Normal file
307
crates/adapters/http-axum/src/api/import/sessions.rs
Normal 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()})))
|
||||
}
|
||||
@@ -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,
|
||||
201
crates/adapters/http-axum/src/api/mod.rs
Normal file
201
crates/adapters/http-axum/src/api/mod.rs
Normal 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),
|
||||
)
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
344
crates/adapters/http-axum/src/api/social.rs
Normal file
344
crates/adapters/http-axum/src/api/social.rs
Normal 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;
|
||||
69
crates/adapters/http-axum/src/api/social/tests.rs
Normal file
69
crates/adapters/http-axum/src/api/social/tests.rs
Normal 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);
|
||||
}
|
||||
287
crates/adapters/http-axum/src/api/user_profile.rs
Normal file
287
crates/adapters/http-axum/src/api/user_profile.rs
Normal 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,
|
||||
¶ms,
|
||||
)
|
||||
.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(),
|
||||
}
|
||||
}
|
||||
217
crates/adapters/http-axum/src/api/users.rs
Normal file
217
crates/adapters/http-axum/src/api/users.rs
Normal 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
|
||||
}
|
||||
144
crates/adapters/http-axum/src/api/watchlist.rs
Normal file
144
crates/adapters/http-axum/src/api/watchlist.rs
Normal 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 }))
|
||||
}
|
||||
@@ -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(),
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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>,
|
||||
81
crates/adapters/http-axum/src/errors.rs
Normal file
81
crates/adapters/http-axum/src/errors.rs
Normal 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
122
crates/adapters/http-axum/src/extractors/json.rs
Normal file
122
crates/adapters/http-axum/src/extractors/json.rs
Normal 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))
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
170
crates/adapters/http-axum/src/federation/blocklist_api.rs
Normal file
170
crates/adapters/http-axum/src/federation/blocklist_api.rs
Normal 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;
|
||||
@@ -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));
|
||||
}
|
||||
191
crates/adapters/http-axum/src/federation/blocklist_html.rs
Normal file
191
crates/adapters/http-axum/src/federation/blocklist_html.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
95
crates/adapters/http-axum/src/federation/collections.rs
Normal file
95
crates/adapters/http-axum/src/federation/collections.rs
Normal 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()
|
||||
}
|
||||
110
crates/adapters/http-axum/src/federation/mod.rs
Normal file
110
crates/adapters/http-axum/src/federation/mod.rs
Normal 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
|
||||
}
|
||||
43
crates/adapters/http-axum/src/federation/negotiation.rs
Normal file
43
crates/adapters/http-axum/src/federation/negotiation.rs
Normal 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(),
|
||||
},
|
||||
)
|
||||
}
|
||||
155
crates/adapters/http-axum/src/html/auth.rs
Normal file
155
crates/adapters/http-axum/src/html/auth.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
166
crates/adapters/http-axum/src/html/diary.rs
Normal file
166
crates/adapters/http-axum/src/html/diary.rs
Normal 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(¶ms.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),
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
62
crates/adapters/http-axum/src/html/helpers.rs
Normal file
62
crates/adapters/http-axum/src/html/helpers.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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>,
|
||||
149
crates/adapters/http-axum/src/html/import/mapping.rs
Normal file
149
crates/adapters/http-axum/src/html/import/mapping.rs
Normal 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(),
|
||||
}
|
||||
}
|
||||
7
crates/adapters/http-axum/src/html/import/mod.rs
Normal file
7
crates/adapters/http-axum/src/html/import/mod.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
mod mapping;
|
||||
mod preview;
|
||||
mod upload;
|
||||
|
||||
pub use mapping::*;
|
||||
pub use preview::*;
|
||||
pub use upload::*;
|
||||
131
crates/adapters/http-axum/src/html/import/preview.rs
Normal file
131
crates/adapters/http-axum/src/html/import/preview.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
147
crates/adapters/http-axum/src/html/import/upload.rs
Normal file
147
crates/adapters/http-axum/src/html/import/upload.rs
Normal 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()
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
@@ -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 {
|
||||
@@ -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;
|
||||
@@ -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
|
||||
@@ -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>,
|
||||
165
crates/adapters/http-axum/src/html/mod.rs
Normal file
165
crates/adapters/http-axum/src/html/mod.rs
Normal 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),
|
||||
)
|
||||
}
|
||||
77
crates/adapters/http-axum/src/html/movies.rs
Normal file
77
crates/adapters/http-axum/src/html/movies.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
262
crates/adapters/http-axum/src/html/profile.rs
Normal file
262
crates/adapters/http-axum/src/html/profile.rs
Normal 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),
|
||||
}
|
||||
}
|
||||
@@ -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) => {
|
||||
@@ -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")],
|
||||
319
crates/adapters/http-axum/src/html/social.rs
Normal file
319
crates/adapters/http-axum/src/html/social.rs
Normal 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;
|
||||
14
crates/adapters/http-axum/src/html/social/tests.rs
Normal file
14
crates/adapters/http-axum/src/html/social/tests.rs
Normal 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));
|
||||
}
|
||||
12
crates/adapters/http-axum/src/html/tags.rs
Normal file
12
crates/adapters/http-axum/src/html/tags.rs
Normal 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))
|
||||
}
|
||||
}
|
||||
16
crates/adapters/http-axum/src/html/templates/auth.rs
Normal file
16
crates/adapters/http-axum/src/html/templates/auth.rs
Normal 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,
|
||||
}
|
||||
36
crates/adapters/http-axum/src/html/templates/common.rs
Normal file
36
crates/adapters/http-axum/src/html/templates/common.rs
Normal 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
|
||||
}
|
||||
55
crates/adapters/http-axum/src/html/templates/diary.rs
Normal file
55
crates/adapters/http-axum/src/html/templates/diary.rs
Normal 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("&"))
|
||||
}
|
||||
}
|
||||
47
crates/adapters/http-axum/src/html/templates/import.rs
Normal file
47
crates/adapters/http-axum/src/html/templates/import.rs
Normal 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),
|
||||
}
|
||||
36
crates/adapters/http-axum/src/html/templates/integrations.rs
Normal file
36
crates/adapters/http-axum/src/html/templates/integrations.rs
Normal 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
Reference in New Issue
Block a user