From 614aec8df9e6491715bd81a4946e3aefe597b3ef Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sun, 25 Oct 2026 21:46:03 +0100 Subject: [PATCH] refactor: http-axum split into api/html/federation modules, router.rs, templates per page group --- crates/adapters/http-axum/Cargo.toml | 2 +- crates/adapters/http-axum/src/api/auth.rs | 101 ++ crates/adapters/http-axum/src/api/diary.rs | 191 +++ .../http-axum/src/{handlers => api}/goals.rs | 28 +- .../adapters/http-axum/src/api/import/mod.rs | 5 + .../http-axum/src/api/import/profiles.rs | 105 ++ .../http-axum/src/api/import/sessions.rs | 309 +++++ .../src/{handlers => api}/insights.rs | 8 +- crates/adapters/http-axum/src/api/mod.rs | 201 +++ .../http-axum/src/{handlers => api}/movies.rs | 111 +- .../http-axum/src/{handlers => api}/search.rs | 40 +- crates/adapters/http-axum/src/api/social.rs | 348 ++++++ .../tests/social.rs => api/social/tests.rs} | 23 - .../http-axum/src/api/user_profile.rs | 279 +++++ crates/adapters/http-axum/src/api/users.rs | 152 +++ .../adapters/http-axum/src/api/watchlist.rs | 148 +++ .../src/{handlers => api}/webhook.rs | 26 +- .../http-axum/src/{handlers => api}/wrapup.rs | 133 +- .../src/{extractors.rs => extractors/mod.rs} | 1 - .../extractors.rs => extractors/tests.rs} | 0 .../http-axum/src/federation/blocklist_api.rs | 169 +++ .../src/federation/blocklist_api/tests.rs | 22 + .../src/federation/blocklist_html.rs | 191 +++ .../http-axum/src/federation/collections.rs | 77 ++ .../adapters/http-axum/src/federation/mod.rs | 120 ++ .../http-axum/src/federation/negotiation.rs | 36 + .../adapters/http-axum/src/handlers/diary.rs | 349 ------ .../adapters/http-axum/src/handlers/import.rs | 812 ------------ crates/adapters/http-axum/src/handlers/mod.rs | 19 - .../adapters/http-axum/src/handlers/social.rs | 1095 ----------------- .../adapters/http-axum/src/handlers/users.rs | 869 ------------- .../http-axum/src/handlers/watchlist.rs | 312 ----- .../http-axum/src/{handlers => html}/auth.rs | 131 +- .../adapters/http-axum/src/{ => html}/csrf.rs | 0 crates/adapters/http-axum/src/html/diary.rs | 166 +++ .../http-axum/src/{ => html}/forms.rs | 2 +- .../{tests/forms.rs => html/forms_tests.rs} | 0 crates/adapters/http-axum/src/html/helpers.rs | 62 + .../src/{handlers => html}/images.rs | 3 +- .../http-axum/src/html/import/mapping.rs | 149 +++ .../adapters/http-axum/src/html/import/mod.rs | 7 + .../http-axum/src/html/import/preview.rs | 131 ++ .../http-axum/src/html/import/upload.rs | 147 +++ .../src/{handlers => html}/integrations.rs | 51 +- .../src/{ => html}/mappers/import.rs | 0 .../src/{ => html}/mappers/integrations.rs | 0 .../http-axum/src/{ => html}/mappers/mod.rs | 3 - .../http-axum/src/{ => html}/mappers/users.rs | 0 .../src/{ => html}/mappers/watchlist.rs | 0 crates/adapters/http-axum/src/html/mod.rs | 162 +++ crates/adapters/http-axum/src/html/movies.rs | 77 ++ crates/adapters/http-axum/src/html/profile.rs | 260 ++++ .../http-axum/src/{ => html}/render.rs | 0 .../http-axum/src/{handlers => html}/rss.rs | 12 +- crates/adapters/http-axum/src/html/social.rs | 319 +++++ .../http-axum/src/html/social/tests.rs | 22 + crates/adapters/http-axum/src/html/tags.rs | 12 + .../adapters/http-axum/src/html/templates.rs | 495 -------- .../http-axum/src/html/templates/auth.rs | 16 + .../http-axum/src/html/templates/common.rs | 36 + .../http-axum/src/html/templates/diary.rs | 55 + .../http-axum/src/html/templates/import.rs | 47 + .../src/html/templates/integrations.rs | 36 + .../http-axum/src/html/templates/mod.rs | 36 + .../http-axum/src/html/templates/movies.rs | 19 + .../http-axum/src/html/templates/social.rs | 55 + .../http-axum/src/html/templates/users.rs | 193 +++ .../http-axum/src/html/templates/watchlist.rs | 25 + .../http-axum/src/html/templates/wrapup.rs | 16 + crates/adapters/http-axum/src/html/users.rs | 179 +++ .../adapters/http-axum/src/html/watchlist.rs | 171 +++ crates/adapters/http-axum/src/html/wrapup.rs | 106 ++ crates/adapters/http-axum/src/lib.rs | 10 +- crates/adapters/http-axum/src/openapi/auth.rs | 2 +- .../adapters/http-axum/src/openapi/diary.rs | 10 +- .../adapters/http-axum/src/openapi/goals.rs | 14 +- .../adapters/http-axum/src/openapi/import.rs | 14 +- .../http-axum/src/openapi/insights.rs | 2 +- .../adapters/http-axum/src/openapi/movies.rs | 10 +- .../adapters/http-axum/src/openapi/search.rs | 6 +- .../adapters/http-axum/src/openapi/social.rs | 34 +- .../adapters/http-axum/src/openapi/users.rs | 10 +- .../http-axum/src/openapi/watchlist.rs | 8 +- .../adapters/http-axum/src/openapi/webhook.rs | 16 +- .../adapters/http-axum/src/openapi/wrapup.rs | 10 +- crates/adapters/http-axum/src/router.rs | 131 ++ crates/adapters/http-axum/src/routes.rs | 593 --------- .../src/{handlers/helpers.rs => support.rs} | 69 +- .../http-axum/src/tests/api_handlers.rs | 18 +- crates/api-types/src/lib.rs | 1 + .../src/mappers/diary.rs | 2 +- crates/api-types/src/mappers/goals.rs | 12 + crates/api-types/src/mappers/mod.rs | 10 + .../src/mappers/movies.rs | 2 +- .../src/mappers/search.rs | 2 +- crates/bootstrap/src/server.rs | 4 +- crates/server/tests/api_test.rs | 4 +- 97 files changed, 5317 insertions(+), 5160 deletions(-) create mode 100644 crates/adapters/http-axum/src/api/auth.rs create mode 100644 crates/adapters/http-axum/src/api/diary.rs rename crates/adapters/http-axum/src/{handlers => api}/goals.rs (85%) create mode 100644 crates/adapters/http-axum/src/api/import/mod.rs create mode 100644 crates/adapters/http-axum/src/api/import/profiles.rs create mode 100644 crates/adapters/http-axum/src/api/import/sessions.rs rename crates/adapters/http-axum/src/{handlers => api}/insights.rs (99%) create mode 100644 crates/adapters/http-axum/src/api/mod.rs rename crates/adapters/http-axum/src/{handlers => api}/movies.rs (71%) rename crates/adapters/http-axum/src/{handlers => api}/search.rs (86%) create mode 100644 crates/adapters/http-axum/src/api/social.rs rename crates/adapters/http-axum/src/{handlers/tests/social.rs => api/social/tests.rs} (73%) create mode 100644 crates/adapters/http-axum/src/api/user_profile.rs create mode 100644 crates/adapters/http-axum/src/api/users.rs create mode 100644 crates/adapters/http-axum/src/api/watchlist.rs rename crates/adapters/http-axum/src/{handlers => api}/webhook.rs (94%) rename crates/adapters/http-axum/src/{handlers => api}/wrapup.rs (62%) rename crates/adapters/http-axum/src/{extractors.rs => extractors/mod.rs} (99%) rename crates/adapters/http-axum/src/{tests/extractors.rs => extractors/tests.rs} (100%) create mode 100644 crates/adapters/http-axum/src/federation/blocklist_api.rs create mode 100644 crates/adapters/http-axum/src/federation/blocklist_api/tests.rs create mode 100644 crates/adapters/http-axum/src/federation/blocklist_html.rs create mode 100644 crates/adapters/http-axum/src/federation/collections.rs create mode 100644 crates/adapters/http-axum/src/federation/mod.rs create mode 100644 crates/adapters/http-axum/src/federation/negotiation.rs delete mode 100644 crates/adapters/http-axum/src/handlers/diary.rs delete mode 100644 crates/adapters/http-axum/src/handlers/import.rs delete mode 100644 crates/adapters/http-axum/src/handlers/mod.rs delete mode 100644 crates/adapters/http-axum/src/handlers/social.rs delete mode 100644 crates/adapters/http-axum/src/handlers/users.rs delete mode 100644 crates/adapters/http-axum/src/handlers/watchlist.rs rename crates/adapters/http-axum/src/{handlers => html}/auth.rs (54%) rename crates/adapters/http-axum/src/{ => html}/csrf.rs (100%) create mode 100644 crates/adapters/http-axum/src/html/diary.rs rename crates/adapters/http-axum/src/{ => html}/forms.rs (99%) rename crates/adapters/http-axum/src/{tests/forms.rs => html/forms_tests.rs} (100%) create mode 100644 crates/adapters/http-axum/src/html/helpers.rs rename crates/adapters/http-axum/src/{handlers => html}/images.rs (99%) create mode 100644 crates/adapters/http-axum/src/html/import/mapping.rs create mode 100644 crates/adapters/http-axum/src/html/import/mod.rs create mode 100644 crates/adapters/http-axum/src/html/import/preview.rs create mode 100644 crates/adapters/http-axum/src/html/import/upload.rs rename crates/adapters/http-axum/src/{handlers => html}/integrations.rs (83%) rename crates/adapters/http-axum/src/{ => html}/mappers/import.rs (100%) rename crates/adapters/http-axum/src/{ => html}/mappers/integrations.rs (100%) rename crates/adapters/http-axum/src/{ => html}/mappers/mod.rs (60%) rename crates/adapters/http-axum/src/{ => html}/mappers/users.rs (100%) rename crates/adapters/http-axum/src/{ => html}/mappers/watchlist.rs (100%) create mode 100644 crates/adapters/http-axum/src/html/movies.rs create mode 100644 crates/adapters/http-axum/src/html/profile.rs rename crates/adapters/http-axum/src/{ => html}/render.rs (100%) rename crates/adapters/http-axum/src/{handlers => html}/rss.rs (95%) create mode 100644 crates/adapters/http-axum/src/html/social.rs create mode 100644 crates/adapters/http-axum/src/html/social/tests.rs create mode 100644 crates/adapters/http-axum/src/html/tags.rs delete mode 100644 crates/adapters/http-axum/src/html/templates.rs create mode 100644 crates/adapters/http-axum/src/html/templates/auth.rs create mode 100644 crates/adapters/http-axum/src/html/templates/common.rs create mode 100644 crates/adapters/http-axum/src/html/templates/diary.rs create mode 100644 crates/adapters/http-axum/src/html/templates/import.rs create mode 100644 crates/adapters/http-axum/src/html/templates/integrations.rs create mode 100644 crates/adapters/http-axum/src/html/templates/mod.rs create mode 100644 crates/adapters/http-axum/src/html/templates/movies.rs create mode 100644 crates/adapters/http-axum/src/html/templates/social.rs create mode 100644 crates/adapters/http-axum/src/html/templates/users.rs create mode 100644 crates/adapters/http-axum/src/html/templates/watchlist.rs create mode 100644 crates/adapters/http-axum/src/html/templates/wrapup.rs create mode 100644 crates/adapters/http-axum/src/html/users.rs create mode 100644 crates/adapters/http-axum/src/html/watchlist.rs create mode 100644 crates/adapters/http-axum/src/html/wrapup.rs create mode 100644 crates/adapters/http-axum/src/router.rs delete mode 100644 crates/adapters/http-axum/src/routes.rs rename crates/adapters/http-axum/src/{handlers/helpers.rs => support.rs} (66%) rename crates/{adapters/http-axum => api-types}/src/mappers/diary.rs (95%) create mode 100644 crates/api-types/src/mappers/goals.rs create mode 100644 crates/api-types/src/mappers/mod.rs rename crates/{adapters/http-axum => api-types}/src/mappers/movies.rs (97%) rename crates/{adapters/http-axum => api-types}/src/mappers/search.rs (96%) diff --git a/crates/adapters/http-axum/Cargo.toml b/crates/adapters/http-axum/Cargo.toml index 77e1df8e..c7954a7a 100644 --- a/crates/adapters/http-axum/Cargo.toml +++ b/crates/adapters/http-axum/Cargo.toml @@ -2,7 +2,7 @@ name = "http-axum" version = "0.1.0" edition.workspace = true -description = "Self-hosted movie diary with REST API and ActivityPub federation" +description = "Axum HTTP adapter: JSON API, classic HTML UI, optional ActivityPub-facing routes" license = "MIT" [features] diff --git a/crates/adapters/http-axum/src/api/auth.rs b/crates/adapters/http-axum/src/api/auth.rs new file mode 100644 index 00000000..b3aecd34 --- /dev/null +++ b/crates/adapters/http-axum/src/api/auth.rs @@ -0,0 +1,101 @@ +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"), + ) +)] +pub async fn login( + State(state): State, + Json(req): Json, +) -> Result, 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"), + ) +)] +pub async fn register( + State(state): State, + Json(req): Json, +) -> Result { + 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"), + ) +)] +pub async fn refresh( + State(state): State, + Json(req): Json, +) -> Result, 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, + Json(req): Json, +) -> StatusCode { + let _ = application::auth::logout::execute(&state.app_ctx.deps.auth.logout, &req.refresh_token) + .await; + StatusCode::NO_CONTENT +} diff --git a/crates/adapters/http-axum/src/api/diary.rs b/crates/adapters/http-axum/src/api/diary.rs new file mode 100644 index 00000000..583ea659 --- /dev/null +++ b/crates/adapters/http-axum/src/api/diary.rs @@ -0,0 +1,191 @@ +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::{Path, Query, 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"), + ), + security(("bearer_auth" = [])) +)] +pub async fn get_diary( + State(state): State, + Query(params): Query, +) -> Result, 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"), + (status = 401, description = "Unauthorized"), + ), + security(("bearer_auth" = [])) +)] +pub async fn post_review( + State(state): State, + user: AuthenticatedUser, + Json(req): Json, +) -> Result { + 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"), + (status = 403, description = "Forbidden"), + (status = 404, description = "Review not found"), + ), + security(("bearer_auth" = [])) +)] +pub async fn delete_review( + State(state): State, + AuthenticatedUser(user_id): AuthenticatedUser, + Path(review_id): Path, +) -> Result { + 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"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 404, description = "Review not found"), + ), + security(("bearer_auth" = [])) +)] +pub async fn patch_review( + State(state): State, + AuthenticatedUser(user_id): AuthenticatedUser, + Path(review_id): Path, + Json(req): Json, +) -> Result { + 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"), + (status = 401, description = "Unauthorized"), + ), + security(("bearer_auth" = [])) +)] +pub async fn export_diary( + State(state): State, + user: AuthenticatedUser, + Query(params): Query, +) -> impl IntoResponse { + build_export_response(¶ms.format, user.0.value(), &state) +} + +#[utoipa::path( + get, path = "/api/v1/activity-feed", + params(ActivityFeedQueryParams), + responses((status = 200, body = ActivityFeedResponse)), +)] +pub async fn get_activity_feed( + State(state): State, + Query(params): Query, +) -> Result, 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, + })) +} diff --git a/crates/adapters/http-axum/src/handlers/goals.rs b/crates/adapters/http-axum/src/api/goals.rs similarity index 85% rename from crates/adapters/http-axum/src/handlers/goals.rs rename to crates/adapters/http-axum/src/api/goals.rs index 2e1778c3..963630e0 100644 --- a/crates/adapters/http-axum/src/handlers/goals.rs +++ b/crates/adapters/http-axum/src/api/goals.rs @@ -1,3 +1,9 @@ +use crate::{errors::ApiError, extractors::AuthenticatedUser, state::AppState}; +use api_types::mappers::goal_with_progress_to_dto; +use api_types::{ + CreateGoalRequest, GoalDto, GoalsResponse, UpdateGoalRequest, UpdateUserSettingsRequest, + UserSettingsDto, +}; use axum::{ Json, extract::{Path, State}, @@ -5,26 +11,6 @@ use axum::{ }; use uuid::Uuid; -use crate::{errors::ApiError, extractors::AuthenticatedUser, state::AppState}; -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 ──────────────────────────────────────────────────────────────── - #[utoipa::path( get, path = "/api/v1/goals", responses( @@ -151,8 +137,6 @@ pub async fn get_user_goals( })) } -// ── User Settings ──────────────────────────────────────────────────────────── - #[utoipa::path( get, path = "/api/v1/settings", responses( diff --git a/crates/adapters/http-axum/src/api/import/mod.rs b/crates/adapters/http-axum/src/api/import/mod.rs new file mode 100644 index 00000000..283cac86 --- /dev/null +++ b/crates/adapters/http-axum/src/api/import/mod.rs @@ -0,0 +1,5 @@ +mod profiles; +mod sessions; + +pub use profiles::*; +pub use sessions::*; diff --git a/crates/adapters/http-axum/src/api/import/profiles.rs b/crates/adapters/http-axum/src/api/import/profiles.rs new file mode 100644 index 00000000..9ede9328 --- /dev/null +++ b/crates/adapters/http-axum/src/api/import/profiles.rs @@ -0,0 +1,105 @@ +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::{Path, 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)), + (status = 401, description = "Unauthorized"), + ), + security(("bearer_auth" = [])) +)] +pub async fn api_get_profiles( + State(state): State, + AuthenticatedUser(user_id): AuthenticatedUser, +) -> Result { + 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::>(), + )) +} + +#[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"), + (status = 422, description = "Session has no mapping yet"), + ), + security(("bearer_auth" = [])) +)] +pub async fn api_post_profile( + State(state): State, + AuthenticatedUser(user_id): AuthenticatedUser, + axum::Json(body): axum::Json, +) -> Result { + let session_id = body + .session_id + .parse::() + .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"), + (status = 404, description = "Profile not found"), + ), + security(("bearer_auth" = [])) +)] +pub async fn api_delete_profile( + State(state): State, + AuthenticatedUser(user_id): AuthenticatedUser, + Path(profile_id_str): Path, +) -> Result { + let profile_id = profile_id_str + .parse::() + .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) +} diff --git a/crates/adapters/http-axum/src/api/import/sessions.rs b/crates/adapters/http-axum/src/api/import/sessions.rs new file mode 100644 index 00000000..96566246 --- /dev/null +++ b/crates/adapters/http-axum/src/api/import/sessions.rs @@ -0,0 +1,309 @@ +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::{Multipart, Path, 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"), + (status = 401, description = "Unauthorized"), + (status = 422, description = "Parse error"), + ), + security(("bearer_auth" = [])) +)] +pub async fn api_post_session( + State(state): State, + AuthenticatedUser(user_id): AuthenticatedUser, + mut multipart: Multipart, +) -> Result { + let mut file_bytes: Option> = 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"), + (status = 404, description = "Session not found"), + ), + security(("bearer_auth" = [])) +)] +pub async fn api_get_session( + State(state): State, + AuthenticatedUser(user_id): AuthenticatedUser, + Path(session_id_str): Path, +) -> Result { + let session_id = session_id_str + .parse::() + .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"), + (status = 422, description = "Mapping error"), + ), + security(("bearer_auth" = [])) +)] +pub async fn api_put_mapping( + State(state): State, + AuthenticatedUser(user_id): AuthenticatedUser, + Path(session_id_str): Path, + axum::Json(body): axum::Json, +) -> Result { + let session_id = session_id_str + .parse::() + .map(ImportSessionId::from_uuid) + .map_err(|_| DomainError::ValidationError("invalid session id".into()))?; + let mappings: Vec = 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, + AuthenticatedUser(user_id): AuthenticatedUser, + Path(session_id_str): Path, +) -> Result, ApiError> { + let session_id = session_id_str + .parse::() + .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 = 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"), + (status = 404, description = "Session not found"), + ), + security(("bearer_auth" = [])) +)] +pub async fn api_post_confirm( + State(state): State, + AuthenticatedUser(user_id): AuthenticatedUser, + Path(session_id_str): Path, + axum::Json(body): axum::Json, +) -> Result { + let session_id = session_id_str + .parse::() + .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::>(), + }))) +} + +#[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"), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Session or profile not found"), + (status = 422, description = "Mapping error"), + ), + security(("bearer_auth" = [])) +)] +pub async fn api_apply_profile( + State(state): State, + AuthenticatedUser(user_id): AuthenticatedUser, + Path((session_id_str, profile_id_str)): Path<(String, String)>, +) -> Result { + let session_id = session_id_str + .parse::() + .map_err(|_| DomainError::ValidationError("invalid session id".into()))?; + let profile_id = profile_id_str + .parse::() + .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()}))) +} diff --git a/crates/adapters/http-axum/src/handlers/insights.rs b/crates/adapters/http-axum/src/api/insights.rs similarity index 99% rename from crates/adapters/http-axum/src/handlers/insights.rs rename to crates/adapters/http-axum/src/api/insights.rs index c71ccad8..61211321 100644 --- a/crates/adapters/http-axum/src/handlers/insights.rs +++ b/crates/adapters/http-axum/src/api/insights.rs @@ -1,14 +1,12 @@ +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::{Query, State}, }; - -use application::insights::get as get_insights_uc; use domain::{errors::DomainError, models::Insight}; -use crate::{errors::ApiError, extractors::AuthenticatedUser, state::AppState}; -use api_types::insights::{InsightDto, InsightsQueryParams, InsightsResponse}; - fn to_dto(insight: Insight) -> InsightDto { match insight { Insight::RatingLift { diff --git a/crates/adapters/http-axum/src/api/mod.rs b/crates/adapters/http-axum/src/api/mod.rs new file mode 100644 index 00000000..95440c08 --- /dev/null +++ b/crates/adapters/http-axum/src/api/mod.rs @@ -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; + +/// JSON API routes (mounted under `/api/v1`), minus the webhook ingestion +/// endpoints ([`webhook_router`], separately rate limited) and the +/// federation-gated admin endpoints (`federation::api_routes`). +pub fn router() -> Router { + 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/{id}", routing::get(user_profile::get_user_profile)) + .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()) +} + +/// Media-server webhook ingestion endpoints (mounted under `/api/v1`). +pub fn webhook_router() -> Router { + Router::new() + .route( + "/webhooks/jellyfin", + routing::post(webhook::post_jellyfin_webhook), + ) + .route("/webhooks/plex", routing::post(webhook::post_plex_webhook)) +} + +fn social_routes() -> Router { + 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), + ) +} diff --git a/crates/adapters/http-axum/src/handlers/movies.rs b/crates/adapters/http-axum/src/api/movies.rs similarity index 71% rename from crates/adapters/http-axum/src/handlers/movies.rs rename to crates/adapters/http-axum/src/api/movies.rs index 89f74ba0..fd893267 100644 --- a/crates/adapters/http-axum/src/handlers/movies.rs +++ b/crates/adapters/http-axum/src/api/movies.rs @@ -1,11 +1,9 @@ -use axum::{ - Json, - extract::{Extension, Path, Query, State}, - http::StatusCode, - response::IntoResponse, +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,27 +11,15 @@ 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::{Path, Query, State}, + http::StatusCode, + response::IntoResponse, }; use domain::services::review_history::Trend; - -use crate::html::templates::MovieDetailTemplate; -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 super::helpers::build_page_context; - -// ── API ────────────────────────────────────────────────────────────────────── +use uuid::Uuid; #[utoipa::path( get, path = "/api/v1/movies", @@ -62,7 +48,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, @@ -89,11 +75,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", @@ -154,7 +140,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, @@ -261,68 +247,3 @@ pub async fn get_movie_profile( Err(e) => crate::errors::domain_error_response(e), } } - -// ── HTML ───────────────────────────────────────────────────────────────────── - -pub async fn get_movie_detail_html( - OptionalCookieUser(user_id): OptionalCookieUser, - State(state): State, - Path(movie_id): Path, - Query(params): Query, - Extension(csrf): Extension, -) -> 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() - } - } -} diff --git a/crates/adapters/http-axum/src/handlers/search.rs b/crates/adapters/http-axum/src/api/search.rs similarity index 86% rename from crates/adapters/http-axum/src/handlers/search.rs rename to crates/adapters/http-axum/src/api/search.rs index 4f39f9bc..63da0f06 100644 --- a/crates/adapters/http-axum/src/handlers/search.rs +++ b/crates/adapters/http-axum/src/api/search.rs @@ -1,22 +1,18 @@ -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::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::{Path, Query, State}, + http::StatusCode, + response::IntoResponse, +}; +use domain::models::{PersonId, collections::PageParams}; #[utoipa::path( get, path = "/api/v1/search", @@ -107,9 +103,7 @@ 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) => StatusCode::NOT_FOUND.into_response(), Err(e) => crate::errors::domain_error_response(e), } @@ -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() @@ -178,13 +172,3 @@ pub async fn post_reindex_search( } } } - -// ── HTML ───────────────────────────────────────────────────────────────────── - -pub async fn get_tag(Path(tag): Path) -> impl IntoResponse { - if tag.eq_ignore_ascii_case("moviesdiary") { - Redirect::temporary("/") - } else { - Redirect::temporary(&format!("/?search={}", tag)) - } -} diff --git a/crates/adapters/http-axum/src/api/social.rs b/crates/adapters/http-axum/src/api/social.rs new file mode 100644 index 00000000..be1c54ac --- /dev/null +++ b/crates/adapters/http-axum/src/api/social.rs @@ -0,0 +1,348 @@ +use crate::{errors::ApiError, extractors::AuthenticatedUser, state::AppState}; +use api_types::{ + ActorListResponse, ActorUrlRequest, FollowRelationResponse, FollowRequest, + PendingCountResponse, RemoteActorDto, +}; +use axum::{ + Json, + extract::{Path, Query, 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"), + ), + security(("bearer_auth" = [])) +)] +pub async fn get_following( + State(state): State, + user: AuthenticatedUser, +) -> Result, 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"), + ), + security(("bearer_auth" = [])) +)] +pub async fn get_followers( + State(state): State, + user: AuthenticatedUser, +) -> Result, 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, + _user: AuthenticatedUser, + Path(user_id): Path, +) -> Result, 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, + _user: AuthenticatedUser, + Path(user_id): Path, +) -> Result, 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"), + ), + security(("bearer_auth" = [])) +)] +pub async fn follow( + State(state): State, + user: AuthenticatedUser, + Json(body): Json, +) -> Result { + 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"), + ), + security(("bearer_auth" = [])) +)] +pub async fn unfollow( + State(state): State, + user: AuthenticatedUser, + Json(body): Json, +) -> Result { + 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"), + ), + security(("bearer_auth" = [])) +)] +pub async fn accept_follower( + State(state): State, + user: AuthenticatedUser, + Json(body): Json, +) -> Result { + 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"), + ), + security(("bearer_auth" = [])) +)] +pub async fn reject_follower( + State(state): State, + user: AuthenticatedUser, + Json(body): Json, +) -> Result { + 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"), + ), + security(("bearer_auth" = [])) +)] +pub async fn remove_follower( + State(state): State, + user: AuthenticatedUser, + Json(body): Json, +) -> Result { + 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"), + ), + security(("bearer_auth" = [])) +)] +pub async fn get_pending_followers( + State(state): State, + user: AuthenticatedUser, +) -> Result, 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"), + ), + security(("bearer_auth" = [])) +)] +pub async fn get_pending_follower_count( + State(state): State, + user: AuthenticatedUser, +) -> Result, 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"), + ), + security(("bearer_auth" = [])) +)] +pub async fn get_pending_following( + State(state): State, + user: AuthenticatedUser, +) -> Result, 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"), + ), + security(("bearer_auth" = [])) +)] +pub async fn get_relationship( + State(state): State, + user: AuthenticatedUser, + Query(q): Query, +) -> Result, 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; diff --git a/crates/adapters/http-axum/src/handlers/tests/social.rs b/crates/adapters/http-axum/src/api/social/tests.rs similarity index 73% rename from crates/adapters/http-axum/src/handlers/tests/social.rs rename to crates/adapters/http-axum/src/api/social/tests.rs index 5c4999bf..0a196dcf 100644 --- a/crates/adapters/http-axum/src/handlers/tests/social.rs +++ b/crates/adapters/http-axum/src/api/social/tests.rs @@ -25,29 +25,6 @@ fn local_actor_dto_url_round_trips_back_to_the_same_identity() { ); } -#[cfg(feature = "federation")] -#[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)); -} - -#[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)); -} - #[test] fn remote_actor_dto_url_is_passed_through_unchanged() { let instance = InstanceIdentity::new("https://md.example"); diff --git a/crates/adapters/http-axum/src/api/user_profile.rs b/crates/adapters/http-axum/src/api/user_profile.rs new file mode 100644 index 00000000..6be466c7 --- /dev/null +++ b/crates/adapters/http-axum/src/api/user_profile.rs @@ -0,0 +1,279 @@ +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::{Path, Query, 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"), + ) +)] +pub async fn get_user_profile( + State(state): State, + AuthenticatedUser(viewer_id): AuthenticatedUser, + Path(user_id): Path, + Query(params): Query, +) -> 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 StatusCode::BAD_REQUEST.into_response(), + }; + + 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 StatusCode::NOT_FOUND.into_response(); + } + Err(e) => return crate::errors::domain_error_response(e), + }; + + 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, + 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::domain_error_response(e), + }; + + 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, + 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(), + } +} diff --git a/crates/adapters/http-axum/src/api/users.rs b/crates/adapters/http-axum/src/api/users.rs new file mode 100644 index 00000000..71589032 --- /dev/null +++ b/crates/adapters/http-axum/src/api/users.rs @@ -0,0 +1,152 @@ +use crate::{errors::ApiError, extractors::AuthenticatedUser, state::AppState}; +use api_types::{ProfileResponse, UserSummaryDto, UsersResponse}; +use application::users::{ + get_users, queries::GetUsersQuery, update_profile, update_profile_fields, +}; +use axum::{ + Json, + extract::{Multipart, State}, + http::StatusCode, + response::IntoResponse, +}; + +#[utoipa::path( + get, path = "/api/v1/profile", + responses( + (status = 200, body = ProfileResponse), + (status = 401, description = "Unauthorized"), + (status = 404, description = "User not found"), + ), + security(("bearer_auth" = [])) +)] +pub async fn get_profile( + State(state): State, + AuthenticatedUser(user_id): AuthenticatedUser, +) -> Result, 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"), + (status = 401, description = "Unauthorized"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] +pub async fn update_profile_handler( + State(state): State, + AuthenticatedUser(user_id): AuthenticatedUser, + multipart: Multipart, +) -> 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::domain_error_response(e), + } +} + +#[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"), + (status = 401, description = "Unauthorized"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] +pub async fn update_profile_fields_handler( + State(state): State, + AuthenticatedUser(user_id): AuthenticatedUser, + axum::Json(body): axum::Json, +) -> impl IntoResponse { + let raw_fields = match body.get("fields").and_then(|f| f.as_array()) { + Some(arr) => arr.clone(), + None => return StatusCode::BAD_REQUEST.into_response(), + }; + + let fields: Vec = 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::domain_error_response(e), + } +} + +#[utoipa::path( + get, path = "/api/v1/users", + responses((status = 200, body = UsersResponse)), +)] +pub async fn list_users(State(state): State) -> Result, 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(), + })) +} diff --git a/crates/adapters/http-axum/src/api/watchlist.rs b/crates/adapters/http-axum/src/api/watchlist.rs new file mode 100644 index 00000000..7753d75c --- /dev/null +++ b/crates/adapters/http-axum/src/api/watchlist.rs @@ -0,0 +1,148 @@ +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::{Path, Query, State}, + http::StatusCode, + response::IntoResponse, +}; +use uuid::Uuid; + +#[utoipa::path( + get, path = "/api/v1/watchlist", + params( + ("limit" = Option, Query, description = "Max results"), + ("offset" = Option, Query, description = "Offset"), + ), + responses( + (status = 200, body = WatchlistResponse), + (status = 401, description = "Unauthorized"), + ), + security(("bearer_auth" = [])) +)] +pub async fn get_watchlist_handler( + State(state): State, + user: AuthenticatedUser, + Query(params): Query, +) -> Result, 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"), + (status = 404, description = "Movie not found"), + ), + security(("bearer_auth" = [])) +)] +pub async fn post_watchlist_add( + State(state): State, + user: AuthenticatedUser, + Json(req): Json, +) -> Result { + 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"), + (status = 404, description = "Not on watchlist"), + ), + security(("bearer_auth" = [])) +)] +pub async fn delete_watchlist_entry( + State(state): State, + user: AuthenticatedUser, + Path(movie_id): Path, +) -> Result { + 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"), + ), + security(("bearer_auth" = [])) +)] +pub async fn get_watchlist_status( + State(state): State, + user: AuthenticatedUser, + Path(movie_id): Path, +) -> Result, 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 })) +} diff --git a/crates/adapters/http-axum/src/handlers/webhook.rs b/crates/adapters/http-axum/src/api/webhook.rs similarity index 94% rename from crates/adapters/http-axum/src/handlers/webhook.rs rename to crates/adapters/http-axum/src/api/webhook.rs index de53ea42..d0a52818 100644 --- a/crates/adapters/http-axum/src/handlers/webhook.rs +++ b/crates/adapters/http-axum/src/api/webhook.rs @@ -1,11 +1,4 @@ -use axum::{ - Json, - extract::{Multipart, Path, State}, - http::{HeaderMap, StatusCode}, - response::IntoResponse, -}; -use uuid::Uuid; - +use crate::{errors::ApiError, extractors::AuthenticatedUser, state::AppState}; use api_types::{ ConfirmWatchRequest, ConfirmWatchResponse, DismissWatchRequest, DismissWatchResponse, GenerateTokenRequest, GenerateTokenResponse, WatchQueueEntryDto, WebhookTokenDto, @@ -21,9 +14,14 @@ use application::integrations::{ queries::{GetWatchQueueQuery, GetWebhookTokensQuery}, revoke_token as revoke_webhook_token, }; +use axum::{ + Json, + extract::{Multipart, Path, 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 { headers @@ -42,8 +40,6 @@ fn extract_webhook_token(headers: &HeaderMap, query: &WebhookTokenQuery) -> Opti 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"), @@ -79,8 +75,6 @@ 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"), @@ -143,8 +137,6 @@ async fn run_ingest( } } -// ── Token management (JWT-authenticated) ────────────────────────────────────── - #[utoipa::path( post, path = "/api/v1/settings/webhook-tokens", request_body = GenerateTokenRequest, @@ -243,8 +235,6 @@ pub async fn delete_webhook_token( Ok(StatusCode::NO_CONTENT) } -// ── Watch queue (JWT-authenticated) ─────────────────────────────────────────── - #[utoipa::path( get, path = "/api/v1/watch-queue", responses( diff --git a/crates/adapters/http-axum/src/handlers/wrapup.rs b/crates/adapters/http-axum/src/api/wrapup.rs similarity index 62% rename from crates/adapters/http-axum/src/handlers/wrapup.rs rename to crates/adapters/http-axum/src/api/wrapup.rs index 026dc598..b17b1af2 100644 --- a/crates/adapters/http-axum/src/handlers/wrapup.rs +++ b/crates/adapters/http-axum/src/api/wrapup.rs @@ -1,31 +1,27 @@ -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::{ - 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::{Path, 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 { @@ -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 = report - .top_genres - .iter() - .take(8) - .map(|g| g.count as f64 / genre_max as f64 * 100.0) - .collect(); - let tmpl = crate::html::templates::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, - Path((user_id, year)): Path<(Uuid, i32)>, - Extension(csrf): Extension, -) -> 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, - Path(year): Path, - Extension(csrf): Extension, -) -> 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) -} diff --git a/crates/adapters/http-axum/src/extractors.rs b/crates/adapters/http-axum/src/extractors/mod.rs similarity index 99% rename from crates/adapters/http-axum/src/extractors.rs rename to crates/adapters/http-axum/src/extractors/mod.rs index b10d0a22..392ee65b 100644 --- a/crates/adapters/http-axum/src/extractors.rs +++ b/crates/adapters/http-axum/src/extractors/mod.rs @@ -155,5 +155,4 @@ where } #[cfg(test)] -#[path = "tests/extractors.rs"] pub(crate) mod tests; diff --git a/crates/adapters/http-axum/src/tests/extractors.rs b/crates/adapters/http-axum/src/extractors/tests.rs similarity index 100% rename from crates/adapters/http-axum/src/tests/extractors.rs rename to crates/adapters/http-axum/src/extractors/tests.rs diff --git a/crates/adapters/http-axum/src/federation/blocklist_api.rs b/crates/adapters/http-axum/src/federation/blocklist_api.rs new file mode 100644 index 00000000..7f213985 --- /dev/null +++ b/crates/adapters/http-axum/src/federation/blocklist_api.rs @@ -0,0 +1,169 @@ +use crate::extractors::AdminApiUser; +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), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden - admin only"), + ), + security(("bearer_auth" = [])) +)] +pub async fn get_blocked_domains_admin( + State(state): State, + _admin: AdminApiUser, +) -> Result>, 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"), + (status = 403, description = "Forbidden - admin only"), + ), + security(("bearer_auth" = [])) +)] +pub async fn add_blocked_domain_admin( + State(state): State, + _admin: AdminApiUser, + axum::Json(body): axum::Json, +) -> Result { + 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"), + (status = 403, description = "Forbidden - admin only"), + ), + security(("bearer_auth" = [])) +)] +pub async fn remove_blocked_domain_admin( + State(state): State, + _admin: AdminApiUser, + axum::extract::Path(domain): axum::extract::Path, +) -> Result { + 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"), + ), + security(("bearer_auth" = [])) +)] +pub async fn block_actor_api( + State(state): State, + user: AuthenticatedUser, + axum::Json(body): axum::Json, +) -> Result { + 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"), + ), + security(("bearer_auth" = [])) +)] +pub async fn unblock_actor_api( + State(state): State, + user: AuthenticatedUser, + axum::Json(body): axum::Json, +) -> Result { + 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), + (status = 401, description = "Unauthorized"), + ), + security(("bearer_auth" = [])) +)] +pub async fn get_blocked_actors_api( + State(state): State, + user: AuthenticatedUser, +) -> Result>, 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; diff --git a/crates/adapters/http-axum/src/federation/blocklist_api/tests.rs b/crates/adapters/http-axum/src/federation/blocklist_api/tests.rs new file mode 100644 index 00000000..900819de --- /dev/null +++ b/crates/adapters/http-axum/src/federation/blocklist_api/tests.rs @@ -0,0 +1,22 @@ +use super::*; +use domain::value_objects::{InstanceIdentity, SocialActor, SocialIdentity, UserId}; + +fn local_actor(uid: &UserId) -> SocialActor { + SocialActor { + identity: SocialIdentity::Local(uid.clone()), + handle: "@gabriel@md.example".into(), + display_name: None, + avatar_url: None, + } +} + +#[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)); +} diff --git a/crates/adapters/http-axum/src/federation/blocklist_html.rs b/crates/adapters/http-axum/src/federation/blocklist_html.rs new file mode 100644 index 00000000..af57277b --- /dev/null +++ b/crates/adapters/http-axum/src/federation/blocklist_html.rs @@ -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, + Extension(csrf): Extension, +) -> 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 = 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, + Extension(csrf): Extension, + Form(form): Form, +) -> 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, + Extension(csrf): Extension, + Form(form): Form, +) -> 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, + Extension(csrf): Extension, +) -> 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 = 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, + Extension(csrf): Extension, + Form(form): Form, +) -> 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, + Extension(csrf): Extension, + Form(form): Form, +) -> 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() + } + } +} diff --git a/crates/adapters/http-axum/src/federation/collections.rs b/crates/adapters/http-axum/src/federation/collections.rs new file mode 100644 index 00000000..2f4cca72 --- /dev/null +++ b/crates/adapters/http-axum/src/federation/collections.rs @@ -0,0 +1,77 @@ +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, + Path(user_id): Path, + headers: axum::http::HeaderMap, + Query(params): Query>, +) -> 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") { + let page = params.get("page").and_then(|p| p.parse::().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, + Path(user_id): Path, + headers: axum::http::HeaderMap, + Query(params): Query>, +) -> 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") { + let page = params.get("page").and_then(|p| p.parse::().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() +} diff --git a/crates/adapters/http-axum/src/federation/mod.rs b/crates/adapters/http-axum/src/federation/mod.rs new file mode 100644 index 00000000..a182b0a2 --- /dev/null +++ b/crates/adapters/http-axum/src/federation/mod.rs @@ -0,0 +1,120 @@ +//! Federation-only HTTP surface. This is the one module that decides, per +//! build, whether federation routes exist: both builds expose the same +//! functions, so `router.rs` and the api/html modules stay free of `cfg`. + +#[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; + +/// Classic-UI routes that only exist with federation (AP collections, admin pages). +#[cfg(feature = "federation")] +pub fn html_routes() -> Router { + 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), + ) +} + +/// JSON API routes that only exist with federation (blocklist administration). +#[cfg(feature = "federation")] +pub fn api_routes() -> Router { + 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), + ) +} + +/// Without federation the follower/following collection URLs fall back to the +/// classic list pages. +#[cfg(not(feature = "federation"))] +pub fn html_routes() -> Router { + Router::new() + .route( + "/users/{id}/followers", + routing::get( + |axum::extract::Path(id): axum::extract::Path| async move { + axum::response::Redirect::permanent(&format!("/users/{}/followers-list", id)) + }, + ), + ) + .route( + "/users/{id}/following", + routing::get( + |axum::extract::Path(id): axum::extract::Path| async move { + axum::response::Redirect::permanent(&format!("/users/{}/following-list", id)) + }, + ), + ) +} + +#[cfg(not(feature = "federation"))] +pub fn api_routes() -> Router { + Router::new() +} + +/// ActivityPub content negotiation for `GET /users/{id}`: federation builds +/// answer AP clients with the actor document, others always render HTML. +#[cfg(not(feature = "federation"))] +pub async fn actor_document_response( + _state: &AppState, + _headers: &axum::http::HeaderMap, + _user_id: uuid::Uuid, +) -> Option { + None +} diff --git a/crates/adapters/http-axum/src/federation/negotiation.rs b/crates/adapters/http-axum/src/federation/negotiation.rs new file mode 100644 index 00000000..3c3a9382 --- /dev/null +++ b/crates/adapters/http-axum/src/federation/negotiation.rs @@ -0,0 +1,36 @@ +use axum::{ + http::{HeaderMap, StatusCode, header}, + response::{IntoResponse, Response}, +}; +use uuid::Uuid; + +use crate::state::AppState; + +/// Answers ActivityPub clients (`Accept: application/activity+json` or +/// `application/ld+json`) with the actor document; `None` means "render HTML". +pub async fn actor_document_response( + state: &AppState, + headers: &HeaderMap, + user_id: Uuid, +) -> Option { + 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; + } + 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(), + }, + ) +} diff --git a/crates/adapters/http-axum/src/handlers/diary.rs b/crates/adapters/http-axum/src/handlers/diary.rs deleted file mode 100644 index f374b7ba..00000000 --- a/crates/adapters/http-axum/src/handlers/diary.rs +++ /dev/null @@ -1,349 +0,0 @@ -use axum::{ - Form, Json, - extract::{Extension, Path, Query, State}, - http::StatusCode, - response::{IntoResponse, Redirect}, -}; -use uuid::Uuid; - -use application::diary::{ - commands::{DeleteReviewCommand, EditReviewCommand}, - delete_review, edit_review, get_activity_feed as get_feed_uc, get_diary, log_review, - queries::GetActivityFeedQuery, -}; - -use crate::html::templates::{ActivityFeedTemplate, NewReviewTemplate, build_page_items}; -use crate::{ - csrf::CsrfToken, - errors::ApiError, - extractors::{AuthenticatedUser, OptionalCookieUser, RequiredCookieUser}, - forms::{ErrorQuery, FeedQueryParams, LogReviewData, LogReviewForm, to_diary_query}, - render::render_page, - state::AppState, -}; -use api_types::{ - ActivityFeedQueryParams, ActivityFeedResponse, DiaryQueryParams, DiaryResponse, - EditReviewRequest, ExportQueryParams, LogReviewRequest, -}; - -use super::helpers::{build_export_response, build_page_context, encode_domain_error}; - -// ── API ────────────────────────────────────────────────────────────────────── - -#[utoipa::path( - get, path = "/api/v1/diary", - params(DiaryQueryParams), - responses( - (status = 200, body = DiaryResponse), - (status = 401, description = "Unauthorized"), - ), - security(("bearer_auth" = [])) -)] -pub async fn get_diary( - State(state): State, - Query(params): Query, -) -> Result, 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(crate::mappers::movies::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"), - (status = 401, description = "Unauthorized"), - ), - security(("bearer_auth" = [])) -)] -pub async fn post_review( - State(state): State, - user: AuthenticatedUser, - Json(req): Json, -) -> Result { - 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"), - (status = 403, description = "Forbidden"), - (status = 404, description = "Review not found"), - ), - security(("bearer_auth" = [])) -)] -pub async fn delete_review( - State(state): State, - AuthenticatedUser(user_id): AuthenticatedUser, - Path(review_id): Path, -) -> Result { - 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"), - (status = 401, description = "Unauthorized"), - (status = 403, description = "Forbidden"), - (status = 404, description = "Review not found"), - ), - security(("bearer_auth" = [])) -)] -pub async fn patch_review( - State(state): State, - AuthenticatedUser(user_id): AuthenticatedUser, - Path(review_id): Path, - Json(req): Json, -) -> Result { - 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"), - (status = 401, description = "Unauthorized"), - ), - security(("bearer_auth" = [])) -)] -pub async fn export_diary( - State(state): State, - user: AuthenticatedUser, - Query(params): Query, -) -> impl IntoResponse { - build_export_response(¶ms.format, user.0.value(), &state) -} - -#[utoipa::path( - get, path = "/api/v1/activity-feed", - params(ActivityFeedQueryParams), - responses((status = 200, body = ActivityFeedResponse)), -)] -pub async fn get_activity_feed( - State(state): State, - Query(params): Query, -) -> Result, 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(crate::mappers::diary::feed_entry_to_dto) - .collect(), - total_count: page.total_count, - limit: page.limit, - offset: page.offset, - })) -} - -// ── HTML ───────────────────────────────────────────────────────────────────── - -pub async fn get_new_review_page( - RequiredCookieUser(user_id): RequiredCookieUser, - State(state): State, - Query(params): Query, - Extension(csrf): Extension, -) -> 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, - RequiredCookieUser(user_id): RequiredCookieUser, - Extension(csrf): Extension, - Form(form): Form, -) -> impl IntoResponse { - if crate::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, - RequiredCookieUser(user_id): RequiredCookieUser, - Extension(csrf): Extension, - Path(review_id): Path, - Form(form): Form, -) -> impl IntoResponse { - if crate::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, - RequiredCookieUser(user_id): RequiredCookieUser, - Query(params): Query, -) -> 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, - Query(params): Query, - Extension(csrf): Extension, -) -> 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), - } -} diff --git a/crates/adapters/http-axum/src/handlers/import.rs b/crates/adapters/http-axum/src/handlers/import.rs deleted file mode 100644 index af372f84..00000000 --- a/crates/adapters/http-axum/src/handlers/import.rs +++ /dev/null @@ -1,812 +0,0 @@ -use api_types::{ - ApplyMappingRequest, ConfirmRequest, ImportPreviewResponse, PreviewRowDto, SaveProfileRequest, - SessionCreatedResponse, SessionStateResponse, -}; -use axum::{ - Extension, Form, - extract::{Multipart, Path, State}, - http::StatusCode, - response::{Html, IntoResponse, Redirect}, -}; -use serde::Deserialize; -use std::collections::HashMap; - -use crate::html::templates::{ - ImportMappingTemplate, ImportPreviewRow, ImportPreviewTemplate, ImportProfileView, - ImportUploadTemplate, -}; -use crate::render::render_page; -use application::import::{ - apply_mapping as apply_import_mapping, apply_profile_and_map, - commands::{ - ApplyImportMappingCommand, ApplyProfileAndMapCommand, CreateImportSessionCommand, - DeleteImportProfileCommand, ExecuteImportCommand, SaveImportProfileCommand, - }, - create_session as create_import_session, delete_profile as delete_import_profile, - execute as execute_import, get_mapping_stage, get_preview_stage, get_session_state, - list_profiles as list_import_profiles, save_profile as save_import_profile, -}; -use domain::errors::DomainError; -use domain::models::{ - AnnotatedRow, FieldMapping, FileFormat, - import::{DomainField, Transform}, -}; -use domain::value_objects::ImportSessionId; - -use crate::{ - csrf::CsrfToken, - errors::ApiError, - extractors::{AuthenticatedUser, RequiredCookieUser}, - state::AppState, -}; - -use super::helpers::encode_domain_error; - -fn str_to_domain_field(field: &str) -> Option { - match field { - "title" => Some(DomainField::Title), - "release_year" => Some(DomainField::ReleaseYear), - "director" => Some(DomainField::Director), - "rating" => Some(DomainField::Rating), - "watched_at" => Some(DomainField::WatchedAt), - "comment" => Some(DomainField::Comment), - "external_metadata_id" => Some(DomainField::ExternalMetadataId), - _ => None, - } -} - -fn parse_mapping_form(form: &HashMap) -> Vec { - 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 -} - -use crate::mappers::import::annotated_to_preview_row; - -// ── HTML wizard handlers ─────────────────────────────────────────────────── - -pub async fn get_import_page( - State(state): State, - RequiredCookieUser(user_id): RequiredCookieUser, - Extension(csrf): Extension, -) -> impl IntoResponse { - let ctx = super::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::>(); - render_page(ImportUploadTemplate { - ctx: &ctx, - profiles: &profiles, - error: None, - }) -} - -pub async fn post_upload( - State(state): State, - RequiredCookieUser(user_id): RequiredCookieUser, - mut multipart: Multipart, -) -> impl IntoResponse { - let mut file_bytes: Option> = 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_mapping_page( - State(state): State, - RequiredCookieUser(user_id): RequiredCookieUser, - Path(session_id_str): Path, - Extension(csrf): Extension, -) -> impl IntoResponse { - let Ok(session_id) = session_id_str - .parse::() - .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 = super::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, - RequiredCookieUser(user_id): RequiredCookieUser, - Path(session_id_str): Path, - Extension(csrf): Extension, - Form(form): Form>, -) -> impl IntoResponse { - let csrf_token = form.get("_csrf").map(|s| s.as_str()).unwrap_or(""); - if crate::csrf::mismatch(&csrf, csrf_token) { - return Redirect::to("/import").into_response(); - } - let Ok(session_id) = session_id_str - .parse::() - .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(), - } -} - -pub async fn get_preview_page( - State(state): State, - RequiredCookieUser(user_id): RequiredCookieUser, - Path(session_id_str): Path, - Extension(csrf): Extension, -) -> impl IntoResponse { - let Ok(session_id) = session_id_str - .parse::() - .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 = preview - .rows - .iter() - .enumerate() - .map(|(i, a)| annotated_to_preview_row(i, a)) - .collect(); - - let ctx = super::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, - RequiredCookieUser(user_id): RequiredCookieUser, - Path(session_id_str): Path, - Extension(csrf): Extension, - Form(form_entries): Form>, -) -> impl IntoResponse { - let csrf_token = form_entries - .iter() - .find(|(k, _)| k == "_csrf") - .map(|(_, v)| v.as_str()) - .unwrap_or(""); - if crate::csrf::mismatch(&csrf, csrf_token) { - return Redirect::to("/import").into_response(); - } - let Ok(session_id) = session_id_str - .parse::() - .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 = form_entries - .iter() - .filter(|(k, _)| k == "confirmed") - .filter_map(|(_, v)| v.parse::().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() - } - } -} - -pub async fn post_delete_profile( - State(state): State, - RequiredCookieUser(user_id): RequiredCookieUser, - Path(profile_id_str): Path, - Extension(csrf): Extension, - Form(form): Form>, -) -> impl IntoResponse { - let csrf_token = form.get("_csrf").map(|s| s.as_str()).unwrap_or(""); - if crate::csrf::mismatch(&csrf, csrf_token) { - return Redirect::to("/import").into_response(); - } - if let Ok(profile_id) = profile_id_str.parse::() { - 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() -} - -#[derive(Deserialize)] -pub struct ImportDoneParams { - pub imported: Option, - pub skipped: Option, - pub failed: Option, -} - -pub async fn get_import_done( - State(state): State, - RequiredCookieUser(user_id): RequiredCookieUser, - Extension(csrf): Extension, - axum::extract::Query(params): axum::extract::Query, -) -> impl IntoResponse { - let _ctx = super::helpers::build_page_context(&state, Some(user_id.clone()), csrf.0).await; - let html = format!( - r#" -

Import Complete

-

Imported: {}

-

Skipped duplicates: {}

-

Failed: {}

- Go to My Profile - "#, - params.imported.unwrap_or(0), - params.skipped.unwrap_or(0), - params.failed.unwrap_or(0), - user_id.value(), - ); - Html(html) -} - -// ── REST API handlers ────────────────────────────────────────────────────── - -#[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"), - (status = 401, description = "Unauthorized"), - (status = 422, description = "Parse error"), - ), - security(("bearer_auth" = [])) -)] -pub async fn api_post_session( - State(state): State, - AuthenticatedUser(user_id): AuthenticatedUser, - mut multipart: Multipart, -) -> Result { - let mut file_bytes: Option> = 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"), - (status = 404, description = "Session not found"), - ), - security(("bearer_auth" = [])) -)] -pub async fn api_get_session( - State(state): State, - AuthenticatedUser(user_id): AuthenticatedUser, - Path(session_id_str): Path, -) -> Result { - let session_id = session_id_str - .parse::() - .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"), - (status = 422, description = "Mapping error"), - ), - security(("bearer_auth" = [])) -)] -pub async fn api_put_mapping( - State(state): State, - AuthenticatedUser(user_id): AuthenticatedUser, - Path(session_id_str): Path, - axum::Json(body): axum::Json, -) -> Result { - let session_id = session_id_str - .parse::() - .map(ImportSessionId::from_uuid) - .map_err(|_| DomainError::ValidationError("invalid session id".into()))?; - let mappings: Vec = 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, - AuthenticatedUser(user_id): AuthenticatedUser, - Path(session_id_str): Path, -) -> Result, ApiError> { - let session_id = session_id_str - .parse::() - .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 = 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"), - (status = 404, description = "Session not found"), - ), - security(("bearer_auth" = [])) -)] -pub async fn api_post_confirm( - State(state): State, - AuthenticatedUser(user_id): AuthenticatedUser, - Path(session_id_str): Path, - axum::Json(body): axum::Json, -) -> Result { - let session_id = session_id_str - .parse::() - .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::>(), - }))) -} - -#[utoipa::path( - get, path = "/api/v1/import/profiles", - responses( - (status = 200, description = "List of saved import profiles", body = inline(Vec)), - (status = 401, description = "Unauthorized"), - ), - security(("bearer_auth" = [])) -)] -pub async fn api_get_profiles( - State(state): State, - AuthenticatedUser(user_id): AuthenticatedUser, -) -> Result { - 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::>(), - )) -} - -#[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"), - (status = 422, description = "Session has no mapping yet"), - ), - security(("bearer_auth" = [])) -)] -pub async fn api_post_profile( - State(state): State, - AuthenticatedUser(user_id): AuthenticatedUser, - axum::Json(body): axum::Json, -) -> Result { - let session_id = body - .session_id - .parse::() - .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"), - (status = 404, description = "Profile not found"), - ), - security(("bearer_auth" = [])) -)] -pub async fn api_delete_profile( - State(state): State, - AuthenticatedUser(user_id): AuthenticatedUser, - Path(profile_id_str): Path, -) -> Result { - let profile_id = profile_id_str - .parse::() - .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) -} - -#[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"), - (status = 401, description = "Unauthorized"), - (status = 404, description = "Session or profile not found"), - (status = 422, description = "Mapping error"), - ), - security(("bearer_auth" = [])) -)] -pub async fn api_apply_profile( - State(state): State, - AuthenticatedUser(user_id): AuthenticatedUser, - Path((session_id_str, profile_id_str)): Path<(String, String)>, -) -> Result { - let session_id = session_id_str - .parse::() - .map_err(|_| DomainError::ValidationError("invalid session id".into()))?; - let profile_id = profile_id_str - .parse::() - .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()}))) -} diff --git a/crates/adapters/http-axum/src/handlers/mod.rs b/crates/adapters/http-axum/src/handlers/mod.rs deleted file mode 100644 index 0fc869cf..00000000 --- a/crates/adapters/http-axum/src/handlers/mod.rs +++ /dev/null @@ -1,19 +0,0 @@ -pub mod auth; -pub mod diary; -pub mod goals; -mod helpers; -pub mod images; -pub mod import; -pub mod insights; -pub mod integrations; -pub mod movies; -pub mod rss; -pub mod search; -pub mod social; -pub mod users; -pub mod watchlist; -pub mod webhook; -pub mod wrapup; - -const DEFAULT_PAGE_LIMIT: u32 = 5; -const RSS_FEED_LIMIT: u32 = 50; diff --git a/crates/adapters/http-axum/src/handlers/social.rs b/crates/adapters/http-axum/src/handlers/social.rs deleted file mode 100644 index b311b686..00000000 --- a/crates/adapters/http-axum/src/handlers/social.rs +++ /dev/null @@ -1,1095 +0,0 @@ -use axum::{ - Form, Json, - extract::{Extension, Path, Query, State}, - http::StatusCode, - response::{IntoResponse, Redirect}, -}; -use uuid::Uuid; - -#[cfg(feature = "federation")] -use crate::html::templates::{BlockedActorsTemplate, BlockedDomainsTemplate}; -use crate::html::templates::{FollowersTemplate, FollowingTemplate, RemoteActorData}; -use crate::{ - csrf::CsrfToken, - errors::ApiError, - extractors::{AuthenticatedUser, RequiredCookieUser}, - forms::{FollowForm, FollowerActionForm, UnfollowForm}, - render::render_page, - state::AppState, -}; -#[cfg(feature = "federation")] -use crate::{ - extractors::AdminApiUser, - forms::{ActorUrlForm, BlockDomainForm, RemoveDomainForm}, -}; -use api_types::{ - ActorListResponse, ActorUrlRequest, FollowRelationResponse, FollowRequest, - PendingCountResponse, RemoteActorDto, -}; -#[cfg(feature = "federation")] -use api_types::{AddBlockedDomainRequest, BlockedActorResponse, BlockedDomainResponse}; -use domain::value_objects::{FollowTarget, InstanceIdentity, SocialActor, SocialIdentity}; - -use super::helpers::{build_page_context, encode_domain_error}; - -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, - } -} - -#[cfg(feature = "federation")] -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, - } -} - -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, - } -} - -// ── API ────────────────────────────────────────────────────────────────────── - -#[cfg(feature = "federation")] -#[utoipa::path( - get, path = "/api/v1/admin/blocked-domains", - responses( - (status = 200, body = Vec), - (status = 401, description = "Unauthorized"), - (status = 403, description = "Forbidden - admin only"), - ), - security(("bearer_auth" = [])) -)] -pub async fn get_blocked_domains_admin( - State(state): State, - _admin: AdminApiUser, -) -> Result>, 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(), - )) -} - -#[cfg(feature = "federation")] -#[utoipa::path( - post, path = "/api/v1/admin/blocked-domains", - request_body = AddBlockedDomainRequest, - responses( - (status = 201, description = "Domain blocked"), - (status = 401, description = "Unauthorized"), - (status = 403, description = "Forbidden - admin only"), - ), - security(("bearer_auth" = [])) -)] -pub async fn add_blocked_domain_admin( - State(state): State, - _admin: AdminApiUser, - axum::Json(body): axum::Json, -) -> Result { - state - .app_ctx - .ap_blocklist - .add_blocked_domain(&body.domain, body.reason.as_deref()) - .await?; - Ok(StatusCode::CREATED) -} - -#[cfg(feature = "federation")] -#[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"), - (status = 403, description = "Forbidden - admin only"), - ), - security(("bearer_auth" = [])) -)] -pub async fn remove_blocked_domain_admin( - State(state): State, - _admin: AdminApiUser, - axum::extract::Path(domain): axum::extract::Path, -) -> Result { - state - .app_ctx - .ap_blocklist - .remove_blocked_domain(&domain) - .await?; - Ok(StatusCode::NO_CONTENT) -} - -#[cfg(feature = "federation")] -#[utoipa::path( - post, path = "/api/v1/social/block", - request_body = ActorUrlRequest, - responses( - (status = 204, description = "Actor blocked"), - (status = 401, description = "Unauthorized"), - ), - security(("bearer_auth" = [])) -)] -pub async fn block_actor_api( - State(state): State, - user: AuthenticatedUser, - axum::Json(body): axum::Json, -) -> Result { - 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) -} - -#[cfg(feature = "federation")] -#[utoipa::path( - post, path = "/api/v1/social/unblock", - request_body = ActorUrlRequest, - responses( - (status = 204, description = "Actor unblocked"), - (status = 401, description = "Unauthorized"), - ), - security(("bearer_auth" = [])) -)] -pub async fn unblock_actor_api( - State(state): State, - user: AuthenticatedUser, - axum::Json(body): axum::Json, -) -> Result { - 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) -} - -#[cfg(feature = "federation")] -#[utoipa::path( - get, path = "/api/v1/social/blocked", - responses( - (status = 200, body = Vec), - (status = 401, description = "Unauthorized"), - ), - security(("bearer_auth" = [])) -)] -pub async fn get_blocked_actors_api( - State(state): State, - user: AuthenticatedUser, -) -> Result>, 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(), - )) -} - -#[utoipa::path( - get, path = "/api/v1/social/following", - responses( - (status = 200, body = ActorListResponse), - (status = 401, description = "Unauthorized"), - ), - security(("bearer_auth" = [])) -)] -pub async fn get_following( - State(state): State, - user: AuthenticatedUser, -) -> Result, 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"), - ), - security(("bearer_auth" = [])) -)] -pub async fn get_followers( - State(state): State, - user: AuthenticatedUser, -) -> Result, 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, - _user: AuthenticatedUser, - Path(user_id): Path, -) -> Result, 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, - _user: AuthenticatedUser, - Path(user_id): Path, -) -> Result, 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"), - ), - security(("bearer_auth" = [])) -)] -pub async fn follow( - State(state): State, - user: AuthenticatedUser, - Json(body): Json, -) -> Result { - 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"), - ), - security(("bearer_auth" = [])) -)] -pub async fn unfollow( - State(state): State, - user: AuthenticatedUser, - Json(body): Json, -) -> Result { - 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"), - ), - security(("bearer_auth" = [])) -)] -pub async fn accept_follower( - State(state): State, - user: AuthenticatedUser, - Json(body): Json, -) -> Result { - 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"), - ), - security(("bearer_auth" = [])) -)] -pub async fn reject_follower( - State(state): State, - user: AuthenticatedUser, - Json(body): Json, -) -> Result { - 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"), - ), - security(("bearer_auth" = [])) -)] -pub async fn remove_follower( - State(state): State, - user: AuthenticatedUser, - Json(body): Json, -) -> Result { - 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"), - ), - security(("bearer_auth" = [])) -)] -pub async fn get_pending_followers( - State(state): State, - user: AuthenticatedUser, -) -> Result, 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"), - ), - security(("bearer_auth" = [])) -)] -pub async fn get_pending_follower_count( - State(state): State, - user: AuthenticatedUser, -) -> Result, 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"), - ), - security(("bearer_auth" = [])) -)] -pub async fn get_pending_following( - State(state): State, - user: AuthenticatedUser, -) -> Result, 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"), - ), - security(("bearer_auth" = [])) -)] -pub async fn get_relationship( - State(state): State, - user: AuthenticatedUser, - Query(q): Query, -) -> Result, 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(), - })) -} - -// ── HTML ───────────────────────────────────────────────────────────────────── - -pub async fn follow_user( - RequiredCookieUser(user_id): RequiredCookieUser, - State(state): State, - Path(profile_user_uuid): Path, - Extension(csrf): Extension, - Form(form): Form, -) -> impl IntoResponse { - if user_id.value() != profile_user_uuid { - return StatusCode::FORBIDDEN.into_response(); - } - if crate::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, - Path(profile_user_uuid): Path, - Extension(csrf): Extension, - Form(form): Form, -) -> impl IntoResponse { - if user_id.value() != profile_user_uuid { - return StatusCode::FORBIDDEN.into_response(); - } - if crate::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, - Path(profile_user_uuid): Path, - Extension(csrf): Extension, - Form(form): Form, -) -> impl IntoResponse { - if user_id.value() != profile_user_uuid { - return StatusCode::FORBIDDEN.into_response(); - } - if crate::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, - Path(profile_user_uuid): Path, - Extension(csrf): Extension, - Form(form): Form, -) -> impl IntoResponse { - if user_id.value() != profile_user_uuid { - return StatusCode::FORBIDDEN.into_response(); - } - if crate::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() - } - } -} - -#[cfg(feature = "federation")] -pub async fn get_followers_collection( - State(state): State, - Path(user_id): Path, - headers: axum::http::HeaderMap, - Query(params): Query>, -) -> 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") { - let page = params.get("page").and_then(|p| p.parse::().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() -} - -#[cfg(feature = "federation")] -pub async fn get_following_collection( - State(state): State, - Path(user_id): Path, - headers: axum::http::HeaderMap, - Query(params): Query>, -) -> 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") { - let page = params.get("page").and_then(|p| p.parse::().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() -} - -pub async fn get_following_page( - RequiredCookieUser(user_id): RequiredCookieUser, - State(state): State, - Path(profile_user_uuid): Path, - Query(params): Query, - Extension(csrf): Extension, -) -> 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 = following - .into_iter() - .map(|a| social_actor_to_template(a, &instance)) - .collect(); - let pending_actors: Vec = - 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, - Path(profile_user_uuid): Path, - Query(params): Query, - Extension(csrf): Extension, -) -> 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 = 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, - Path(profile_user_uuid): Path, - Extension(csrf): Extension, - Form(form): Form, -) -> impl IntoResponse { - if user_id.value() != profile_user_uuid { - return StatusCode::FORBIDDEN.into_response(); - } - if crate::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(feature = "federation")] -pub async fn get_blocked_domains_page( - crate::extractors::AdminUser(user_id): crate::extractors::AdminUser, - State(state): State, - Extension(csrf): Extension, -) -> 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 = 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() - } - } -} - -#[cfg(feature = "federation")] -pub async fn post_blocked_domain( - crate::extractors::AdminUser(_): crate::extractors::AdminUser, - State(state): State, - Extension(csrf): Extension, - Form(form): Form, -) -> impl IntoResponse { - if crate::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() - } - } -} - -#[cfg(feature = "federation")] -pub async fn post_remove_blocked_domain( - crate::extractors::AdminUser(_): crate::extractors::AdminUser, - State(state): State, - Extension(csrf): Extension, - Form(form): Form, -) -> impl IntoResponse { - if crate::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() - } - } -} - -#[cfg(feature = "federation")] -pub async fn get_blocked_actors_page( - RequiredCookieUser(user_id): RequiredCookieUser, - State(state): State, - Extension(csrf): Extension, -) -> 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 = 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() - } - } -} - -#[cfg(feature = "federation")] -pub async fn post_block_actor_html( - RequiredCookieUser(user_id): RequiredCookieUser, - State(state): State, - Extension(csrf): Extension, - Form(form): Form, -) -> impl IntoResponse { - if crate::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() - } - } -} - -#[cfg(feature = "federation")] -pub async fn post_unblock_actor( - RequiredCookieUser(user_id): RequiredCookieUser, - State(state): State, - Extension(csrf): Extension, - Form(form): Form, -) -> impl IntoResponse { - if crate::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() - } - } -} - -#[cfg(test)] -#[path = "tests/social.rs"] -mod tests; diff --git a/crates/adapters/http-axum/src/handlers/users.rs b/crates/adapters/http-axum/src/handlers/users.rs deleted file mode 100644 index c6f8034d..00000000 --- a/crates/adapters/http-axum/src/handlers/users.rs +++ /dev/null @@ -1,869 +0,0 @@ -use std::str::FromStr; - -use axum::{ - Json, - extract::{Extension, Multipart, Path, Query, State}, - http::StatusCode, - response::IntoResponse, -}; -use uuid::Uuid; - -use application::users::{ - get_federated_profile, get_federated_profile_stats, get_local_profile, - get_profile_settings as get_profile_settings_uc, get_users, - queries::{GetUserProfileQuery, GetUsersQuery}, - resolve_username_to_id, update_profile, update_profile_fields, -}; -use domain::{errors::DomainError, value_objects::Username}; - -use crate::html::templates::{ - EmbedProfileTemplate, MonthlyRatingRow, ProfileSettingsTemplate, ProfileTemplate, - RemoteActorData, RemoteActorDisplay, UserSummaryView, UsersTemplate, bar_height_px, - build_heatmap, build_page_items, -}; -use crate::{ - csrf::CsrfToken, - errors::ApiError, - extractors::{AuthenticatedUser, OptionalCookieUser, RequiredCookieUser}, - render::render_page, - state::AppState, -}; -use api_types::{ - ActorStatDto, DiaryResponse, DirectorStatDto, GenreStatDto, MonthActivityDto, MonthlyRatingDto, - ProfileResponse, UserProfileQueryParams, UserProfileResponse, UserStatsDto, UserSummaryDto, - UserTrendsDto, UsersResponse, WatchMediumStatDto, -}; - -use super::goals::goal_with_progress_to_dto; -use super::helpers::build_page_context; - -// ── API ────────────────────────────────────────────────────────────────────── - -#[utoipa::path( - get, path = "/api/v1/profile", - responses( - (status = 200, body = ProfileResponse), - (status = 401, description = "Unauthorized"), - (status = 404, description = "User not found"), - ), - security(("bearer_auth" = [])) -)] -pub async fn get_profile( - State(state): State, - AuthenticatedUser(user_id): AuthenticatedUser, -) -> Result, 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"), - (status = 401, description = "Unauthorized"), - (status = 500, description = "Internal server error"), - ), - security(("bearer_auth" = [])) -)] -pub async fn update_profile_handler( - State(state): State, - AuthenticatedUser(user_id): AuthenticatedUser, - multipart: Multipart, -) -> impl IntoResponse { - let data = super::helpers::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::domain_error_response(e), - } -} - -#[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"), - (status = 401, description = "Unauthorized"), - (status = 500, description = "Internal server error"), - ), - security(("bearer_auth" = [])) -)] -pub async fn update_profile_fields_handler( - State(state): State, - AuthenticatedUser(user_id): AuthenticatedUser, - axum::Json(body): axum::Json, -) -> impl IntoResponse { - let raw_fields = match body.get("fields").and_then(|f| f.as_array()) { - Some(arr) => arr.clone(), - None => return StatusCode::BAD_REQUEST.into_response(), - }; - - let fields: Vec = 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::domain_error_response(e), - } -} - -#[utoipa::path( - get, path = "/api/v1/users", - responses((status = 200, body = UsersResponse)), -)] -pub async fn list_users(State(state): State) -> Result, 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(), - })) -} - -#[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"), - ) -)] -pub async fn get_user_profile( - State(state): State, - AuthenticatedUser(viewer_id): AuthenticatedUser, - Path(user_id): Path, - Query(params): Query, -) -> 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 StatusCode::BAD_REQUEST.into_response(), - }; - - 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 StatusCode::NOT_FOUND.into_response(); - } - Err(e) => return crate::errors::domain_error_response(e), - }; - - let view_data = if let Some(p) = profile.entries { - Some(api_types::ProfileViewData::Entries { - entries: DiaryResponse { - items: p - .items - .iter() - .map(crate::mappers::movies::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(crate::mappers::movies::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, - 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::domain_error_response(e), - }; - - let view_data = if let Some(p) = profile.entries { - Some(api_types::ProfileViewData::Entries { - entries: DiaryResponse { - items: p - .items - .iter() - .map(crate::mappers::movies::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, - 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(), - } -} - -// ── HTML ───────────────────────────────────────────────────────────────────── - -pub async fn get_users_list( - OptionalCookieUser(user_id): OptionalCookieUser, - State(state): State, - Extension(csrf): Extension, -) -> impl IntoResponse { - let mut ctx = build_page_context(&state, user_id, csrf.0).await; - ctx.page_title = "Members — Movies Diary".to_string(); - ctx.canonical_url = format!("{}/users", state.app_ctx.config.base_url); - - match application::users::get_users::execute( - &state.app_ctx.deps.users.get_users_list, - application::users::queries::GetUsersQuery, - ) - .await - { - Ok(result) => { - let users: Vec = result - .users - .iter() - .map(crate::mappers::users::user_summary_view) - .collect(); - let remote_actors: Vec = result - .remote_actors - .iter() - .map(crate::mappers::users::remote_actor_display) - .collect(); - render_page(UsersTemplate { - users, - ctx: &ctx, - remote_actors, - }) - .into_response() - } - Err(e) => crate::errors::domain_error_response(e), - } -} - -pub async fn get_user_by_username( - State(state): State, - Path(username): Path, -) -> impl IntoResponse { - let uname = match Username::new(username) { - Ok(u) => u, - Err(_) => return StatusCode::NOT_FOUND.into_response(), - }; - match resolve_username_to_id::execute(&state.app_ctx.deps.users.resolve_username, &uname).await - { - Ok(Some(uid)) => { - axum::response::Redirect::permanent(&format!("/users/{}", uid.value())).into_response() - } - _ => StatusCode::NOT_FOUND.into_response(), - } -} - -// ── Profile helpers (private) ─────────────────────────────────────────────── - -struct PaginationInfo { - offset: u32, - has_more: bool, - limit: u32, - page_items: Vec, -} - -fn compute_pagination( - entries: Option<&domain::models::collections::Paginated>, -) -> 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, super::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> { - 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 { - 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() -} - -// ── Handler ───────────────────────────────────────────────────────────────── - -pub async fn get_user_profile_html( - OptionalCookieUser(user_id): OptionalCookieUser, - State(state): State, - Path(profile_user_uuid): Path, - headers: axum::http::HeaderMap, - Query(params): Query, - Extension(csrf): Extension, -) -> impl IntoResponse { - // Content negotiation: AP clients request application/activity+json - #[cfg(feature = "federation")] - { - 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") { - return match state - .app_ctx - .ap_document - .actor_json(&profile_user_uuid.to_string()) - .await - { - Ok(json) => ( - [( - axum::http::header::CONTENT_TYPE, - "application/activity+json", - )], - json, - ) - .into_response(), - Err(_) => StatusCode::NOT_FOUND.into_response(), - }; - } - } - - #[cfg(not(feature = "federation"))] - let _ = &headers; - - 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 = profile - .pending_followers - .iter() - .map(crate::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, - 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), - } -} - -#[derive(serde::Deserialize, Default)] -pub struct SavedQuery { - pub saved: Option, -} - -pub async fn get_profile_settings( - RequiredCookieUser(user_id): RequiredCookieUser, - State(state): State, - Query(params): Query, - Extension(csrf): Extension, -) -> impl IntoResponse { - let mut ctx = build_page_context(&state, Some(user_id.clone()), csrf.0).await; - ctx.page_title = "Profile Settings — Movies Diary".to_string(); - ctx.canonical_url = format!("{}/settings/profile", state.app_ctx.config.base_url); - - let settings = match get_profile_settings_uc::execute( - &state.app_ctx.deps.users.get_profile_settings, - user_id.value(), - ) - .await - { - Ok(s) => s, - Err(DomainError::NotFound(_)) => return StatusCode::NOT_FOUND.into_response(), - Err(e) => return crate::errors::domain_error_response(e), - }; - - let saved = params.saved.as_deref() == Some("1"); - - render_page(ProfileSettingsTemplate { - ctx: &ctx, - bio: settings.bio.as_deref(), - avatar_url: settings.avatar_url.as_deref(), - banner_url: settings.banner_url.as_deref(), - also_known_as: settings.also_known_as.as_deref(), - profile_fields: &settings.fields, - saved, - embed_url: format!( - "{}/users/{}?embed=true", - state.app_ctx.config.base_url, - user_id.value() - ), - }) - .into_response() -} - -pub async fn post_profile_settings( - RequiredCookieUser(user_id): RequiredCookieUser, - State(state): State, - multipart: Multipart, -) -> impl IntoResponse { - let data = super::helpers::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, - }; - if let Err(e) = update_profile::execute(&state.app_ctx.deps.users.update_profile, cmd).await { - tracing::error!("update_profile error: {:?}", e); - return axum::response::Redirect::to(&format!( - "/settings/profile?error={}", - super::helpers::encode_domain_error(&e) - )) - .into_response(); - } - - let fields: Vec = (0..4) - .filter_map(|i| { - data.profile_field_names - .get(&i) - .map(|name| domain::models::ProfileField { - name: name.clone(), - value: data - .profile_field_values - .get(&i) - .cloned() - .unwrap_or_default(), - }) - }) - .collect(); - - let fields_cmd = application::users::commands::UpdateProfileFieldsCommand { - user_id: user_id.value(), - fields, - }; - if let Err(e) = - update_profile_fields::execute(&state.app_ctx.deps.users.update_profile_fields, fields_cmd) - .await - { - tracing::error!("update_profile_fields error: {:?}", e); - return axum::response::Redirect::to(&format!( - "/settings/profile?error={}", - super::helpers::encode_domain_error(&e) - )) - .into_response(); - } - - axum::response::Redirect::to("/settings/profile?saved=1").into_response() -} diff --git a/crates/adapters/http-axum/src/handlers/watchlist.rs b/crates/adapters/http-axum/src/handlers/watchlist.rs deleted file mode 100644 index 9854e60f..00000000 --- a/crates/adapters/http-axum/src/handlers/watchlist.rs +++ /dev/null @@ -1,312 +0,0 @@ -use axum::{ - Form, Json, - extract::{Extension, Path, Query, State}, - http::StatusCode, - response::{IntoResponse, Redirect}, -}; -use uuid::Uuid; - -use application::{ - diary::commands::MovieInput, - watchlist::{ - add as add_to_watchlist, - commands::{AddToWatchlistCommand, RemoveFromWatchlistCommand}, - get as get_watchlist, get_watchlist_for_owner, is_on as is_on_watchlist, - queries::{GetWatchlistQuery, IsOnWatchlistQuery}, - remove as remove_from_watchlist, - }, -}; -use domain::errors::DomainError; - -use crate::html::templates::WatchlistTemplate; -use crate::{ - csrf::CsrfToken, - errors::ApiError, - extractors::{AuthenticatedUser, OptionalCookieUser, RequiredCookieUser}, - render::render_page, - state::AppState, -}; -use api_types::{ - AddToWatchlistRequest, PaginationQueryParams, WatchlistEntryDto, WatchlistResponse, - WatchlistStatusResponse, -}; - -use super::helpers::{build_page_context, encode_error}; - -// ── API ────────────────────────────────────────────────────────────────────── - -#[utoipa::path( - get, path = "/api/v1/watchlist", - params( - ("limit" = Option, Query, description = "Max results"), - ("offset" = Option, Query, description = "Offset"), - ), - responses( - (status = 200, body = WatchlistResponse), - (status = 401, description = "Unauthorized"), - ), - security(("bearer_auth" = [])) -)] -pub async fn get_watchlist_handler( - State(state): State, - user: AuthenticatedUser, - Query(params): Query, -) -> Result, 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: crate::mappers::movies::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"), - (status = 404, description = "Movie not found"), - ), - security(("bearer_auth" = [])) -)] -pub async fn post_watchlist_add( - State(state): State, - user: AuthenticatedUser, - Json(req): Json, -) -> Result { - 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"), - (status = 404, description = "Not on watchlist"), - ), - security(("bearer_auth" = [])) -)] -pub async fn delete_watchlist_entry( - State(state): State, - user: AuthenticatedUser, - Path(movie_id): Path, -) -> Result { - 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"), - ), - security(("bearer_auth" = [])) -)] -pub async fn get_watchlist_status( - State(state): State, - user: AuthenticatedUser, - Path(movie_id): Path, -) -> Result, 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 })) -} - -// ── HTML ───────────────────────────────────────────────────────────────────── - -pub async fn get_watchlist_page( - OptionalCookieUser(viewer_id): OptionalCookieUser, - State(state): State, - Path(owner_id): Path, - Query(params): Query, - Extension(csrf): Extension, -) -> impl IntoResponse { - let ctx = build_page_context(&state, viewer_id.clone(), csrf.0).await; - let is_owner = viewer_id.map(|u| u.value() == owner_id).unwrap_or(false); - - let view = match get_watchlist_for_owner::execute( - &state.app_ctx.deps.watchlist.get_watchlist_for_owner, - owner_id, - params.limit.or(Some(20)), - params.offset.or(Some(0)), - ) - .await - { - Ok(view) => view, - Err(e) => return crate::errors::domain_error_response(e), - }; - - let result = match view { - get_watchlist_for_owner::WatchlistView::Local(page) => { - crate::mappers::watchlist::build_watchlist_page(page, is_owner) - } - get_watchlist_for_owner::WatchlistView::Remote(entries) => { - crate::mappers::watchlist::build_remote_watchlist_page(entries) - } - }; - - render_page(WatchlistTemplate { - ctx: &ctx, - owner_id, - display_entries: &result.display_entries, - current_offset: result.current_offset, - has_more: result.has_more, - limit: result.limit, - is_owner, - error: params.error, - }) - .into_response() -} - -pub async fn post_watchlist_add_html( - State(state): State, - RequiredCookieUser(user_id): RequiredCookieUser, - Extension(csrf): Extension, - Form(form): Form, -) -> impl IntoResponse { - if crate::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("/") - .to_string(); - - let input = if let Some(id) = form.movie_id { - MovieInput { - movie_id: Some(id), - external_metadata_id: None, - manual_title: None, - manual_release_year: None, - manual_director: None, - } - } else { - let query = form.query.as_deref().unwrap_or("").trim().to_string(); - let is_external_id = query.starts_with("tmdb:") - || (query.starts_with("tt") - && query.len() > 2 - && query[2..].chars().all(|c| c.is_ascii_digit())); - if is_external_id { - MovieInput { - movie_id: None, - external_metadata_id: Some(query), - manual_title: None, - manual_release_year: None, - manual_director: None, - } - } else { - MovieInput { - movie_id: None, - external_metadata_id: None, - manual_title: if query.is_empty() { None } else { Some(query) }, - manual_release_year: form.year, - manual_director: None, - } - } - }; - - match add_to_watchlist::execute( - &state.app_ctx.deps.watchlist.add, - AddToWatchlistCommand { - user_id: user_id.value(), - input, - }, - ) - .await - { - Ok(()) => Redirect::to(&redirect_base).into_response(), - Err(DomainError::NotFound(_)) => Redirect::to(&redirect_base).into_response(), - Err(DomainError::ValidationError(msg)) => { - let sep = if redirect_base.contains('?') { - '&' - } else { - '?' - }; - let url = format!("{}{}error={}", redirect_base, sep, encode_error(&msg)); - Redirect::to(&url).into_response() - } - Err(e) => crate::errors::domain_error_response(e), - } -} - -pub async fn post_watchlist_remove_html( - State(state): State, - RequiredCookieUser(user_id): RequiredCookieUser, - Extension(csrf): Extension, - Path(movie_id): Path, - Form(form): Form, -) -> impl IntoResponse { - if crate::csrf::mismatch(&csrf, &form.csrf_token) { - return StatusCode::FORBIDDEN.into_response(); - } - match remove_from_watchlist::execute( - &state.app_ctx.deps.watchlist.remove_from_watchlist, - RemoveFromWatchlistCommand { - user_id: user_id.value(), - movie_id, - }, - ) - .await - { - Ok(()) | Err(DomainError::NotFound(_)) => { - let redirect_url = form - .redirect_after - .filter(|u| u.starts_with('/') && !u.starts_with("//")) - .unwrap_or_else(|| "/".to_string()); - Redirect::to(&redirect_url).into_response() - } - Err(e) => crate::errors::domain_error_response(e), - } -} diff --git a/crates/adapters/http-axum/src/handlers/auth.rs b/crates/adapters/http-axum/src/html/auth.rs similarity index 54% rename from crates/adapters/http-axum/src/handlers/auth.rs rename to crates/adapters/http-axum/src/html/auth.rs index e6754dfb..b3f02252 100644 --- a/crates/adapters/http-axum/src/handlers/auth.rs +++ b/crates/adapters/http-axum/src/html/auth.rs @@ -1,30 +1,20 @@ +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, Json, + Form, extract::{Extension, Query, State}, http::{HeaderValue, StatusCode, header::SET_COOKIE}, response::{IntoResponse, Redirect}, }; use chrono::Utc; -use application::auth::{ - commands::RegisterCommand, login as login_uc, queries::LoginCommand, register as register_uc, -}; - -use crate::html::templates::{LoginTemplate, RegisterTemplate}; -use crate::{ - csrf::{CsrfToken, secure_flag}, - errors::ApiError, - forms::{ErrorQuery, LoginForm, RegisterForm}, - render::render_page, - state::AppState, -}; -use api_types::HtmlPageContext; -use api_types::{ - LoginRequest, LoginResponse, LogoutRequest, RefreshRequest, RefreshResponse, RegisterRequest, -}; - -// ── HTML helpers ───────────────────────────────────────────────────────────── - fn set_cookie_header( token: &str, max_age: i64, @@ -42,103 +32,6 @@ fn set_cookie_header( ) } -// ── API ────────────────────────────────────────────────────────────────────── - -#[utoipa::path( - post, path = "/api/v1/auth/login", - request_body = LoginRequest, - responses( - (status = 200, body = LoginResponse), - (status = 401, description = "Invalid credentials"), - ) -)] -pub async fn login( - State(state): State, - Json(req): Json, -) -> Result, 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"), - ) -)] -pub async fn register( - State(state): State, - Json(req): Json, -) -> Result { - 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"), - ) -)] -pub async fn refresh( - State(state): State, - Json(req): Json, -) -> Result, 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, - Json(req): Json, -) -> StatusCode { - let _ = application::auth::logout::execute(&state.app_ctx.deps.auth.logout, &req.refresh_token) - .await; - StatusCode::NO_CONTENT -} - -// ── HTML ───────────────────────────────────────────────────────────────────── - pub async fn get_login_page( State(state): State, Query(params): Query, @@ -167,7 +60,7 @@ pub async fn post_login( Extension(csrf): Extension, Form(form): Form, ) -> impl IntoResponse { - if crate::csrf::mismatch(&csrf, &form.csrf_token) { + if crate::html::csrf::mismatch(&csrf, &form.csrf_token) { return StatusCode::FORBIDDEN.into_response(); } match login_uc::execute( @@ -236,7 +129,7 @@ pub async fn post_register( if !state.app_ctx.config.allow_registration { return Redirect::to("/").into_response(); } - if crate::csrf::mismatch(&csrf, &form.csrf_token) { + if crate::html::csrf::mismatch(&csrf, &form.csrf_token) { return StatusCode::FORBIDDEN.into_response(); } match application::auth::register_and_login::execute( diff --git a/crates/adapters/http-axum/src/csrf.rs b/crates/adapters/http-axum/src/html/csrf.rs similarity index 100% rename from crates/adapters/http-axum/src/csrf.rs rename to crates/adapters/http-axum/src/html/csrf.rs diff --git a/crates/adapters/http-axum/src/html/diary.rs b/crates/adapters/http-axum/src/html/diary.rs new file mode 100644 index 00000000..ae3501e3 --- /dev/null +++ b/crates/adapters/http-axum/src/html/diary.rs @@ -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, + Query(params): Query, + Extension(csrf): Extension, +) -> 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, + RequiredCookieUser(user_id): RequiredCookieUser, + Extension(csrf): Extension, + Form(form): Form, +) -> 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, + RequiredCookieUser(user_id): RequiredCookieUser, + Extension(csrf): Extension, + Path(review_id): Path, + Form(form): Form, +) -> 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, + RequiredCookieUser(user_id): RequiredCookieUser, + Query(params): Query, +) -> 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, + Query(params): Query, + Extension(csrf): Extension, +) -> 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), + } +} diff --git a/crates/adapters/http-axum/src/forms.rs b/crates/adapters/http-axum/src/html/forms.rs similarity index 99% rename from crates/adapters/http-axum/src/forms.rs rename to crates/adapters/http-axum/src/html/forms.rs index ef32ac3e..d5573800 100644 --- a/crates/adapters/http-axum/src/forms.rs +++ b/crates/adapters/http-axum/src/html/forms.rs @@ -312,5 +312,5 @@ pub struct DismissWatchForm { } #[cfg(test)] -#[path = "tests/forms.rs"] +#[path = "forms_tests.rs"] mod tests; diff --git a/crates/adapters/http-axum/src/tests/forms.rs b/crates/adapters/http-axum/src/html/forms_tests.rs similarity index 100% rename from crates/adapters/http-axum/src/tests/forms.rs rename to crates/adapters/http-axum/src/html/forms_tests.rs diff --git a/crates/adapters/http-axum/src/html/helpers.rs b/crates/adapters/http-axum/src/html/helpers.rs new file mode 100644 index 00000000..6e0f8e48 --- /dev/null +++ b/crates/adapters/http-axum/src/html/helpers.rs @@ -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, + 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, + } +} diff --git a/crates/adapters/http-axum/src/handlers/images.rs b/crates/adapters/http-axum/src/html/images.rs similarity index 99% rename from crates/adapters/http-axum/src/handlers/images.rs rename to crates/adapters/http-axum/src/html/images.rs index 04820aaf..6595f5f5 100644 --- a/crates/adapters/http-axum/src/handlers/images.rs +++ b/crates/adapters/http-axum/src/html/images.rs @@ -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, Path(key): Path, diff --git a/crates/adapters/http-axum/src/html/import/mapping.rs b/crates/adapters/http-axum/src/html/import/mapping.rs new file mode 100644 index 00000000..26c820e1 --- /dev/null +++ b/crates/adapters/http-axum/src/html/import/mapping.rs @@ -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) -> Vec { + 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, + RequiredCookieUser(user_id): RequiredCookieUser, + Path(session_id_str): Path, + Extension(csrf): Extension, +) -> impl IntoResponse { + let Ok(session_id) = session_id_str + .parse::() + .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, + RequiredCookieUser(user_id): RequiredCookieUser, + Path(session_id_str): Path, + Extension(csrf): Extension, + Form(form): Form>, +) -> 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::() + .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(), + } +} diff --git a/crates/adapters/http-axum/src/html/import/mod.rs b/crates/adapters/http-axum/src/html/import/mod.rs new file mode 100644 index 00000000..6ad8b3b5 --- /dev/null +++ b/crates/adapters/http-axum/src/html/import/mod.rs @@ -0,0 +1,7 @@ +mod mapping; +mod preview; +mod upload; + +pub use mapping::*; +pub use preview::*; +pub use upload::*; diff --git a/crates/adapters/http-axum/src/html/import/preview.rs b/crates/adapters/http-axum/src/html/import/preview.rs new file mode 100644 index 00000000..ccf99f70 --- /dev/null +++ b/crates/adapters/http-axum/src/html/import/preview.rs @@ -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, + RequiredCookieUser(user_id): RequiredCookieUser, + Path(session_id_str): Path, + Extension(csrf): Extension, +) -> impl IntoResponse { + let Ok(session_id) = session_id_str + .parse::() + .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 = 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, + RequiredCookieUser(user_id): RequiredCookieUser, + Path(session_id_str): Path, + Extension(csrf): Extension, + Form(form_entries): Form>, +) -> 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::() + .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 = form_entries + .iter() + .filter(|(k, _)| k == "confirmed") + .filter_map(|(_, v)| v.parse::().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() + } + } +} diff --git a/crates/adapters/http-axum/src/html/import/upload.rs b/crates/adapters/http-axum/src/html/import/upload.rs new file mode 100644 index 00000000..9ed8ae33 --- /dev/null +++ b/crates/adapters/http-axum/src/html/import/upload.rs @@ -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, + RequiredCookieUser(user_id): RequiredCookieUser, + Extension(csrf): Extension, +) -> 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::>(); + render_page(ImportUploadTemplate { + ctx: &ctx, + profiles: &profiles, + error: None, + }) +} + +pub async fn post_upload( + State(state): State, + RequiredCookieUser(user_id): RequiredCookieUser, + mut multipart: Multipart, +) -> impl IntoResponse { + let mut file_bytes: Option> = 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, + RequiredCookieUser(user_id): RequiredCookieUser, + Extension(csrf): Extension, + axum::extract::Query(params): axum::extract::Query, +) -> impl IntoResponse { + let _ctx = + crate::html::helpers::build_page_context(&state, Some(user_id.clone()), csrf.0).await; + let html = format!( + r#" +

Import Complete

+

Imported: {}

+

Skipped duplicates: {}

+

Failed: {}

+ Go to My Profile + "#, + 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, + pub skipped: Option, + pub failed: Option, +} + +pub async fn post_delete_profile( + State(state): State, + RequiredCookieUser(user_id): RequiredCookieUser, + Path(profile_id_str): Path, + Extension(csrf): Extension, + Form(form): Form>, +) -> 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::() { + 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() +} diff --git a/crates/adapters/http-axum/src/handlers/integrations.rs b/crates/adapters/http-axum/src/html/integrations.rs similarity index 83% rename from crates/adapters/http-axum/src/handlers/integrations.rs rename to crates/adapters/http-axum/src/html/integrations.rs index 2d6aab44..8e98de87 100644 --- a/crates/adapters/http-axum/src/handlers/integrations.rs +++ b/crates/adapters/http-axum/src/html/integrations.rs @@ -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::html::templates::{IntegrationsTemplate, WatchQueueTemplate}; -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 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, - Query(params): Query, + Query(params): Query, Extension(csrf): Extension, ) -> impl IntoResponse { let mut ctx = build_page_context(&state, Some(user_id.clone()), csrf.0).await; @@ -48,7 +43,7 @@ pub async fn get_integrations_page( let token_views: Vec = 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, Extension(csrf): Extension, - Form(form): Form, + Form(form): Form, ) -> 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, Path(token_id): Path, Extension(csrf): Extension, - Form(form): Form, + Form(form): Form, ) -> 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, @@ -148,7 +141,7 @@ pub async fn get_watch_queue_page( let entries: Vec = 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, Path(event_id): Path, Extension(csrf): Extension, - Form(form): Form, + Form(form): Form, ) -> 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, Path(event_id): Path, Extension(csrf): Extension, - Form(form): Form, + Form(form): Form, ) -> impl IntoResponse { - if crate::csrf::mismatch(&csrf, &form.csrf_token) { + if crate::html::csrf::mismatch(&csrf, &form.csrf_token) { return StatusCode::FORBIDDEN.into_response(); } diff --git a/crates/adapters/http-axum/src/mappers/import.rs b/crates/adapters/http-axum/src/html/mappers/import.rs similarity index 100% rename from crates/adapters/http-axum/src/mappers/import.rs rename to crates/adapters/http-axum/src/html/mappers/import.rs diff --git a/crates/adapters/http-axum/src/mappers/integrations.rs b/crates/adapters/http-axum/src/html/mappers/integrations.rs similarity index 100% rename from crates/adapters/http-axum/src/mappers/integrations.rs rename to crates/adapters/http-axum/src/html/mappers/integrations.rs diff --git a/crates/adapters/http-axum/src/mappers/mod.rs b/crates/adapters/http-axum/src/html/mappers/mod.rs similarity index 60% rename from crates/adapters/http-axum/src/mappers/mod.rs rename to crates/adapters/http-axum/src/html/mappers/mod.rs index 12a30a15..b1c2872f 100644 --- a/crates/adapters/http-axum/src/mappers/mod.rs +++ b/crates/adapters/http-axum/src/html/mappers/mod.rs @@ -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; diff --git a/crates/adapters/http-axum/src/mappers/users.rs b/crates/adapters/http-axum/src/html/mappers/users.rs similarity index 100% rename from crates/adapters/http-axum/src/mappers/users.rs rename to crates/adapters/http-axum/src/html/mappers/users.rs diff --git a/crates/adapters/http-axum/src/mappers/watchlist.rs b/crates/adapters/http-axum/src/html/mappers/watchlist.rs similarity index 100% rename from crates/adapters/http-axum/src/mappers/watchlist.rs rename to crates/adapters/http-axum/src/html/mappers/watchlist.rs diff --git a/crates/adapters/http-axum/src/html/mod.rs b/crates/adapters/http-axum/src/html/mod.rs index b9a16841..e735855e 100644 --- a/crates/adapters/http-axum/src/html/mod.rs +++ b/crates/adapters/http-axum/src/html/mod.rs @@ -1 +1,163 @@ +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; + +/// Classic server-rendered UI routes, minus the rate-limited auth forms +/// ([`auth_router`]) and the federation-gated pages (`federation::html_routes`). +pub fn router() -> Router { + 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| 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("/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()) +} + +/// Login, logout and registration forms; the caller adds the auth rate limiter. +pub fn auth_router() -> Router { + 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 { + 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), + ) +} diff --git a/crates/adapters/http-axum/src/html/movies.rs b/crates/adapters/http-axum/src/html/movies.rs new file mode 100644 index 00000000..28aad195 --- /dev/null +++ b/crates/adapters/http-axum/src/html/movies.rs @@ -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, + Path(movie_id): Path, + Query(params): Query, + Extension(csrf): Extension, +) -> 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() + } + } +} diff --git a/crates/adapters/http-axum/src/html/profile.rs b/crates/adapters/http-axum/src/html/profile.rs new file mode 100644 index 00000000..a27e99d3 --- /dev/null +++ b/crates/adapters/http-axum/src/html/profile.rs @@ -0,0 +1,260 @@ +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, +} + +fn compute_pagination( + entries: Option<&domain::models::collections::Paginated>, +) -> 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> { + 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 { + 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, + Path(profile_user_uuid): Path, + headers: axum::http::HeaderMap, + Query(params): Query, + Extension(csrf): Extension, +) -> 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 = 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, + 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), + } +} diff --git a/crates/adapters/http-axum/src/render.rs b/crates/adapters/http-axum/src/html/render.rs similarity index 100% rename from crates/adapters/http-axum/src/render.rs rename to crates/adapters/http-axum/src/html/render.rs diff --git a/crates/adapters/http-axum/src/handlers/rss.rs b/crates/adapters/http-axum/src/html/rss.rs similarity index 95% rename from crates/adapters/http-axum/src/handlers/rss.rs rename to crates/adapters/http-axum/src/html/rss.rs index 847b73ff..94b35d5e 100644 --- a/crates/adapters/http-axum/src/handlers/rss.rs +++ b/crates/adapters/http-axum/src/html/rss.rs @@ -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::{models::ReviewSortBy, ports::FeedChannel}; - -use crate::{errors::ApiError, state::AppState}; +const RSS_FEED_LIMIT: u32 = 50; pub async fn get_feed(State(state): State) -> Result { 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, @@ -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?; diff --git a/crates/adapters/http-axum/src/html/social.rs b/crates/adapters/http-axum/src/html/social.rs new file mode 100644 index 00000000..0dece217 --- /dev/null +++ b/crates/adapters/http-axum/src/html/social.rs @@ -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, + Path(profile_user_uuid): Path, + Extension(csrf): Extension, + Form(form): Form, +) -> 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, + Path(profile_user_uuid): Path, + Extension(csrf): Extension, + Form(form): Form, +) -> 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, + Path(profile_user_uuid): Path, + Extension(csrf): Extension, + Form(form): Form, +) -> 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, + Path(profile_user_uuid): Path, + Extension(csrf): Extension, + Form(form): Form, +) -> 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, + Path(profile_user_uuid): Path, + Query(params): Query, + Extension(csrf): Extension, +) -> 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 = following + .into_iter() + .map(|a| social_actor_to_template(a, &instance)) + .collect(); + let pending_actors: Vec = + 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, + Path(profile_user_uuid): Path, + Query(params): Query, + Extension(csrf): Extension, +) -> 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 = 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, + Path(profile_user_uuid): Path, + Extension(csrf): Extension, + Form(form): Form, +) -> 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; diff --git a/crates/adapters/http-axum/src/html/social/tests.rs b/crates/adapters/http-axum/src/html/social/tests.rs new file mode 100644 index 00000000..9ef45c28 --- /dev/null +++ b/crates/adapters/http-axum/src/html/social/tests.rs @@ -0,0 +1,22 @@ +use super::*; +use domain::value_objects::{InstanceIdentity, SocialActor, SocialIdentity, UserId}; + +fn local_actor(uid: &UserId) -> SocialActor { + SocialActor { + identity: SocialIdentity::Local(uid.clone()), + handle: "@gabriel@md.example".into(), + display_name: None, + avatar_url: None, + } +} + +#[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)); +} diff --git a/crates/adapters/http-axum/src/html/tags.rs b/crates/adapters/http-axum/src/html/tags.rs new file mode 100644 index 00000000..24dddec8 --- /dev/null +++ b/crates/adapters/http-axum/src/html/tags.rs @@ -0,0 +1,12 @@ +use axum::{ + extract::Path, + response::{IntoResponse, Redirect}, +}; + +pub async fn get_tag(Path(tag): Path) -> impl IntoResponse { + if tag.eq_ignore_ascii_case("moviesdiary") { + Redirect::temporary("/") + } else { + Redirect::temporary(&format!("/?search={}", tag)) + } +} diff --git a/crates/adapters/http-axum/src/html/templates.rs b/crates/adapters/http-axum/src/html/templates.rs deleted file mode 100644 index 77e140d6..00000000 --- a/crates/adapters/http-axum/src/html/templates.rs +++ /dev/null @@ -1,495 +0,0 @@ -pub use askama; -use askama::Template; - -use api_types::HtmlPageContext; -use chrono::Datelike; -use domain::models::{ - DiaryEntry, FeedEntry, MonthActivity, MonthlyRating, ReviewSource, UserStats, UserTrends, - collections::Paginated, -}; - -#[derive(Clone, Debug)] -pub struct WatchlistDisplayEntry { - pub poster_url: Option, - pub movie_title: String, - pub release_year: u16, - pub movie_url: Option, - pub added_at: String, - pub remove_url: Option, -} - -mod filters { - #[askama::filter_fn] - pub fn poster_src( - path: T, - _env: &dyn askama::Values, - ) -> askama::Result { - let p = path.to_string(); - if p.starts_with("http://") || p.starts_with("https://") { - Ok(p) - } else { - Ok(format!("/images/{}", p)) - } - } -} - -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 { - 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 = 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 -} - -#[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, -} - -#[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, -} - -#[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, - pub filter: String, - pub sort_by: String, - pub search: String, -} - -#[derive(Template)] -#[template(path = "movie_detail.html")] -pub struct MovieDetailTemplate<'a> { - pub ctx: &'a HtmlPageContext, - pub movie: &'a domain::models::Movie, - pub stats: &'a domain::models::MovieStats, - pub profile: Option<&'a domain::models::MovieProfile>, - pub reviews: &'a [domain::models::FeedEntry], - pub on_watchlist: bool, - pub current_offset: u32, - pub has_more: bool, - pub limit: u32, - pub histogram_max: u64, -} - -#[derive(Template)] -#[template(path = "watchlist.html")] -pub struct WatchlistTemplate<'a> { - pub ctx: &'a HtmlPageContext, - pub owner_id: uuid::Uuid, - pub display_entries: &'a [WatchlistDisplayEntry], - pub current_offset: u32, - pub has_more: bool, - pub limit: u32, - pub is_owner: bool, - pub error: Option, -} - -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("&")) - } -} - -pub struct RemoteActorDisplay { - pub handle: String, - pub display_name: String, - pub initial: char, - pub url: String, -} - -pub struct UserSummaryView { - pub user_id: uuid::Uuid, - pub display_name: String, - pub initial: char, - pub avg_rating_display: String, - pub total_movies: i64, - pub avatar_url: Option, -} - -#[derive(Template)] -#[template(path = "users.html")] -pub struct UsersTemplate<'a> { - pub users: Vec, - pub ctx: &'a HtmlPageContext, - pub remote_actors: Vec, -} - -pub struct MonthlyRatingRow<'a> { - pub rating: &'a MonthlyRating, - pub bar_height_px: i64, -} - -#[derive(Template)] -#[template(path = "profile.html")] -pub struct ProfileTemplate<'a> { - pub ctx: &'a HtmlPageContext, - pub profile_display_name: String, - pub profile_user_id: uuid::Uuid, - pub stats: &'a UserStats, - pub avg_rating_display: String, - pub favorite_director_display: String, - pub most_active_month_display: String, - pub view: &'a str, - pub entries: Option<&'a Paginated>, - pub current_offset: u32, - pub has_more: bool, - pub limit: u32, - pub history: Option<&'a Vec>, - pub trends: Option<&'a UserTrends>, - pub monthly_rating_rows: Vec>, - pub heatmap: Vec, - pub page_items: Vec, - pub is_own_profile: bool, - pub error: Option, - pub following_count: usize, - pub followers_count: usize, - pub pending_followers: Vec, - pub sort_by: String, - pub search: String, - pub goals: Vec, -} - -pub struct GoalViewData { - pub year: u16, - pub target_count: u32, - pub current_count: u32, - pub percentage: f64, - pub is_complete: bool, -} - -impl<'a> ProfileTemplate<'a> { - pub fn filter_qs(&self) -> String { - let mut parts = vec![ - format!("view={}", self.view), - 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("&")) - } -} - -#[derive(Template)] -#[template(path = "embed_profile.html")] -pub struct EmbedProfileTemplate<'a> { - pub profile_display_name: String, - pub profile_user_id: uuid::Uuid, - pub profile_url: String, - pub stats: &'a UserStats, - pub avg_rating_display: String, - pub favorite_director_display: String, - pub most_active_month_display: String, - pub view: &'a str, - pub entries: Option<&'a Paginated>, - pub current_offset: u32, - pub has_more: bool, - pub limit: u32, - pub history: Option<&'a Vec>, - pub trends: Option<&'a UserTrends>, - pub monthly_rating_rows: Vec>, - pub heatmap: Vec, - pub page_items: Vec, - pub sort_by: String, -} - -impl<'a> EmbedProfileTemplate<'a> { - pub fn filter_qs(&self) -> String { - let parts = [ - format!("view={}", self.view), - format!("sort_by={}", self.sort_by), - "embed=true".to_string(), - ]; - format!("&{}", parts.join("&")) - } -} - -pub struct RemoteActorData { - pub handle: String, - pub display_name: Option, - pub url: String, - pub avatar_url: Option, -} - -#[derive(Template)] -#[template(path = "following.html")] -pub struct FollowingTemplate { - pub ctx: HtmlPageContext, - pub user_id: uuid::Uuid, - pub actors: Vec, - pub pending_actors: Vec, - pub error: Option, -} - -#[derive(Template)] -#[template(path = "followers.html")] -pub struct FollowersTemplate { - pub ctx: HtmlPageContext, - pub user_id: uuid::Uuid, - pub actors: Vec, - pub error: Option, -} - -#[derive(Template)] -#[template(path = "blocked_domains.html")] -pub struct BlockedDomainsTemplate<'a> { - pub ctx: &'a HtmlPageContext, - pub domains: &'a [BlockedDomainEntry], -} - -#[derive(Template)] -#[template(path = "blocked_actors.html")] -pub struct BlockedActorsTemplate<'a> { - pub ctx: &'a HtmlPageContext, - pub actors: &'a [BlockedActorEntry], -} - -pub struct BlockedDomainEntry { - pub domain: String, - pub reason: Option, - pub blocked_at: String, -} - -pub struct BlockedActorEntry { - pub url: String, - pub handle: String, - pub display_name: Option, - pub avatar_url: Option, -} - -pub struct HeatmapCell { - pub month_label: String, - pub count: i64, - pub alpha: f64, -} - -pub fn build_heatmap(history: &[MonthActivity]) -> Vec { - let current_year = chrono::Utc::now().year(); - let count_for = |m: &str| -> i64 { - history - .iter() - .find(|a| a.year_month == format!("{}-{}", current_year, m)) - .map(|a| a.count) - .unwrap_or(0) - }; - let months = [ - ("01", "Jan"), - ("02", "Feb"), - ("03", "Mar"), - ("04", "Apr"), - ("05", "May"), - ("06", "Jun"), - ("07", "Jul"), - ("08", "Aug"), - ("09", "Sep"), - ("10", "Oct"), - ("11", "Nov"), - ("12", "Dec"), - ]; - let counts: Vec = months.iter().map(|(m, _)| count_for(m)).collect(); - let max = counts.iter().copied().max().unwrap_or(0).max(1); - months - .iter() - .zip(counts.iter()) - .map(|((_, label), &count)| { - let alpha = if count == 0 { - 0.05 - } else { - 0.15 + 0.75 * (count as f64 / max as f64) - }; - HeatmapCell { - month_label: label.to_string(), - count, - alpha, - } - }) - .collect() -} - -pub fn bar_height_px(avg_rating: f64) -> i64 { - (avg_rating / 5.0 * 60.0) as i64 -} - -#[derive(Template)] -#[template(path = "profile_settings.html")] -pub struct ProfileSettingsTemplate<'a> { - pub ctx: &'a HtmlPageContext, - pub bio: Option<&'a str>, - pub avatar_url: Option<&'a str>, - pub banner_url: Option<&'a str>, - pub also_known_as: Option<&'a str>, - pub profile_fields: &'a [(String, String)], - pub saved: bool, - pub embed_url: String, -} - -#[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, - pub created_at: String, - pub last_used_at: Option, -} - -#[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, - pub source: String, - pub watched_at: String, - pub movie_url: Option, -} - -#[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], - 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, -} - -pub enum ImportRowStatus { - Valid, - Duplicate, - Invalid(String), -} - -#[derive(Template)] -#[template(path = "wrapup.html")] -pub struct WrapUpPageTemplate<'a> { - pub ctx: &'a HtmlPageContext, - pub report: &'a domain::models::wrapup::WrapUpReport, - pub year_label: String, - pub watch_time_display: String, - pub rating_max: u32, - pub genre_max: u32, - pub rating_pcts: [f64; 5], - pub genre_pcts: Vec, -} diff --git a/crates/adapters/http-axum/src/html/templates/auth.rs b/crates/adapters/http-axum/src/html/templates/auth.rs new file mode 100644 index 00000000..29d9be2c --- /dev/null +++ b/crates/adapters/http-axum/src/html/templates/auth.rs @@ -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, +} diff --git a/crates/adapters/http-axum/src/html/templates/common.rs b/crates/adapters/http-axum/src/html/templates/common.rs new file mode 100644 index 00000000..c6aea406 --- /dev/null +++ b/crates/adapters/http-axum/src/html/templates/common.rs @@ -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 { + 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 = 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 +} diff --git a/crates/adapters/http-axum/src/html/templates/diary.rs b/crates/adapters/http-axum/src/html/templates/diary.rs new file mode 100644 index 00000000..2a7ef24c --- /dev/null +++ b/crates/adapters/http-axum/src/html/templates/diary.rs @@ -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, +} + +#[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, + 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("&")) + } +} diff --git a/crates/adapters/http-axum/src/html/templates/import.rs b/crates/adapters/http-axum/src/html/templates/import.rs new file mode 100644 index 00000000..f91e5976 --- /dev/null +++ b/crates/adapters/http-axum/src/html/templates/import.rs @@ -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], + 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, +} + +pub enum ImportRowStatus { + Valid, + Duplicate, + Invalid(String), +} diff --git a/crates/adapters/http-axum/src/html/templates/integrations.rs b/crates/adapters/http-axum/src/html/templates/integrations.rs new file mode 100644 index 00000000..bd79128d --- /dev/null +++ b/crates/adapters/http-axum/src/html/templates/integrations.rs @@ -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, + pub created_at: String, + pub last_used_at: Option, +} + +#[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, + pub source: String, + pub watched_at: String, + pub movie_url: Option, +} diff --git a/crates/adapters/http-axum/src/html/templates/mod.rs b/crates/adapters/http-axum/src/html/templates/mod.rs new file mode 100644 index 00000000..1d662904 --- /dev/null +++ b/crates/adapters/http-axum/src/html/templates/mod.rs @@ -0,0 +1,36 @@ +mod auth; +mod common; +mod diary; +mod import; +mod integrations; +mod movies; +mod social; +mod users; +mod watchlist; +mod wrapup; + +pub use auth::*; +pub use common::*; +pub use diary::*; +pub use import::*; +pub use integrations::*; +pub use movies::*; +pub use social::*; +pub use users::*; +pub use watchlist::*; +pub use wrapup::*; + +pub(super) mod filters { + #[askama::filter_fn] + pub fn poster_src( + path: T, + _env: &dyn askama::Values, + ) -> askama::Result { + let p = path.to_string(); + if p.starts_with("http://") || p.starts_with("https://") { + Ok(p) + } else { + Ok(format!("/images/{}", p)) + } + } +} diff --git a/crates/adapters/http-axum/src/html/templates/movies.rs b/crates/adapters/http-axum/src/html/templates/movies.rs new file mode 100644 index 00000000..0a7f4840 --- /dev/null +++ b/crates/adapters/http-axum/src/html/templates/movies.rs @@ -0,0 +1,19 @@ +use super::filters; +use api_types::HtmlPageContext; +use askama::Template; +use domain::models::ReviewSource; + +#[derive(Template)] +#[template(path = "movie_detail.html")] +pub struct MovieDetailTemplate<'a> { + pub ctx: &'a HtmlPageContext, + pub movie: &'a domain::models::Movie, + pub stats: &'a domain::models::MovieStats, + pub profile: Option<&'a domain::models::MovieProfile>, + pub reviews: &'a [domain::models::FeedEntry], + pub on_watchlist: bool, + pub current_offset: u32, + pub has_more: bool, + pub limit: u32, + pub histogram_max: u64, +} diff --git a/crates/adapters/http-axum/src/html/templates/social.rs b/crates/adapters/http-axum/src/html/templates/social.rs new file mode 100644 index 00000000..5123baed --- /dev/null +++ b/crates/adapters/http-axum/src/html/templates/social.rs @@ -0,0 +1,55 @@ +use api_types::HtmlPageContext; +use askama::Template; + +pub struct RemoteActorData { + pub handle: String, + pub display_name: Option, + pub url: String, + pub avatar_url: Option, +} + +#[derive(Template)] +#[template(path = "following.html")] +pub struct FollowingTemplate { + pub ctx: HtmlPageContext, + pub user_id: uuid::Uuid, + pub actors: Vec, + pub pending_actors: Vec, + pub error: Option, +} + +#[derive(Template)] +#[template(path = "followers.html")] +pub struct FollowersTemplate { + pub ctx: HtmlPageContext, + pub user_id: uuid::Uuid, + pub actors: Vec, + pub error: Option, +} + +#[derive(Template)] +#[template(path = "blocked_domains.html")] +pub struct BlockedDomainsTemplate<'a> { + pub ctx: &'a HtmlPageContext, + pub domains: &'a [BlockedDomainEntry], +} + +#[derive(Template)] +#[template(path = "blocked_actors.html")] +pub struct BlockedActorsTemplate<'a> { + pub ctx: &'a HtmlPageContext, + pub actors: &'a [BlockedActorEntry], +} + +pub struct BlockedDomainEntry { + pub domain: String, + pub reason: Option, + pub blocked_at: String, +} + +pub struct BlockedActorEntry { + pub url: String, + pub handle: String, + pub display_name: Option, + pub avatar_url: Option, +} diff --git a/crates/adapters/http-axum/src/html/templates/users.rs b/crates/adapters/http-axum/src/html/templates/users.rs new file mode 100644 index 00000000..0e514ea9 --- /dev/null +++ b/crates/adapters/http-axum/src/html/templates/users.rs @@ -0,0 +1,193 @@ +use super::{PageItem, RemoteActorData, filters}; +use api_types::HtmlPageContext; +use askama::Template; +use chrono::Datelike; +use domain::models::{ + DiaryEntry, MonthActivity, MonthlyRating, UserStats, UserTrends, collections::Paginated, +}; + +pub struct RemoteActorDisplay { + pub handle: String, + pub display_name: String, + pub initial: char, + pub url: String, +} + +pub struct UserSummaryView { + pub user_id: uuid::Uuid, + pub display_name: String, + pub initial: char, + pub avg_rating_display: String, + pub total_movies: i64, + pub avatar_url: Option, +} + +#[derive(Template)] +#[template(path = "users.html")] +pub struct UsersTemplate<'a> { + pub users: Vec, + pub ctx: &'a HtmlPageContext, + pub remote_actors: Vec, +} + +pub struct MonthlyRatingRow<'a> { + pub rating: &'a MonthlyRating, + pub bar_height_px: i64, +} + +#[derive(Template)] +#[template(path = "profile.html")] +pub struct ProfileTemplate<'a> { + pub ctx: &'a HtmlPageContext, + pub profile_display_name: String, + pub profile_user_id: uuid::Uuid, + pub stats: &'a UserStats, + pub avg_rating_display: String, + pub favorite_director_display: String, + pub most_active_month_display: String, + pub view: &'a str, + pub entries: Option<&'a Paginated>, + pub current_offset: u32, + pub has_more: bool, + pub limit: u32, + pub history: Option<&'a Vec>, + pub trends: Option<&'a UserTrends>, + pub monthly_rating_rows: Vec>, + pub heatmap: Vec, + pub page_items: Vec, + pub is_own_profile: bool, + pub error: Option, + pub following_count: usize, + pub followers_count: usize, + pub pending_followers: Vec, + pub sort_by: String, + pub search: String, + pub goals: Vec, +} + +pub struct GoalViewData { + pub year: u16, + pub target_count: u32, + pub current_count: u32, + pub percentage: f64, + pub is_complete: bool, +} + +impl<'a> ProfileTemplate<'a> { + pub fn filter_qs(&self) -> String { + let mut parts = vec![ + format!("view={}", self.view), + 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("&")) + } +} + +#[derive(Template)] +#[template(path = "embed_profile.html")] +pub struct EmbedProfileTemplate<'a> { + pub profile_display_name: String, + pub profile_user_id: uuid::Uuid, + pub profile_url: String, + pub stats: &'a UserStats, + pub avg_rating_display: String, + pub favorite_director_display: String, + pub most_active_month_display: String, + pub view: &'a str, + pub entries: Option<&'a Paginated>, + pub current_offset: u32, + pub has_more: bool, + pub limit: u32, + pub history: Option<&'a Vec>, + pub trends: Option<&'a UserTrends>, + pub monthly_rating_rows: Vec>, + pub heatmap: Vec, + pub page_items: Vec, + pub sort_by: String, +} + +impl<'a> EmbedProfileTemplate<'a> { + pub fn filter_qs(&self) -> String { + let parts = [ + format!("view={}", self.view), + format!("sort_by={}", self.sort_by), + "embed=true".to_string(), + ]; + format!("&{}", parts.join("&")) + } +} + +pub struct HeatmapCell { + pub month_label: String, + pub count: i64, + pub alpha: f64, +} + +pub fn build_heatmap(history: &[MonthActivity]) -> Vec { + let current_year = chrono::Utc::now().year(); + let count_for = |m: &str| -> i64 { + history + .iter() + .find(|a| a.year_month == format!("{}-{}", current_year, m)) + .map(|a| a.count) + .unwrap_or(0) + }; + let months = [ + ("01", "Jan"), + ("02", "Feb"), + ("03", "Mar"), + ("04", "Apr"), + ("05", "May"), + ("06", "Jun"), + ("07", "Jul"), + ("08", "Aug"), + ("09", "Sep"), + ("10", "Oct"), + ("11", "Nov"), + ("12", "Dec"), + ]; + let counts: Vec = months.iter().map(|(m, _)| count_for(m)).collect(); + let max = counts.iter().copied().max().unwrap_or(0).max(1); + months + .iter() + .zip(counts.iter()) + .map(|((_, label), &count)| { + let alpha = if count == 0 { + 0.05 + } else { + 0.15 + 0.75 * (count as f64 / max as f64) + }; + HeatmapCell { + month_label: label.to_string(), + count, + alpha, + } + }) + .collect() +} + +pub fn bar_height_px(avg_rating: f64) -> i64 { + (avg_rating / 5.0 * 60.0) as i64 +} + +#[derive(Template)] +#[template(path = "profile_settings.html")] +pub struct ProfileSettingsTemplate<'a> { + pub ctx: &'a HtmlPageContext, + pub bio: Option<&'a str>, + pub avatar_url: Option<&'a str>, + pub banner_url: Option<&'a str>, + pub also_known_as: Option<&'a str>, + pub profile_fields: &'a [(String, String)], + pub saved: bool, + pub embed_url: String, +} diff --git a/crates/adapters/http-axum/src/html/templates/watchlist.rs b/crates/adapters/http-axum/src/html/templates/watchlist.rs new file mode 100644 index 00000000..6de0f23f --- /dev/null +++ b/crates/adapters/http-axum/src/html/templates/watchlist.rs @@ -0,0 +1,25 @@ +use api_types::HtmlPageContext; +use askama::Template; + +#[derive(Clone, Debug)] +pub struct WatchlistDisplayEntry { + pub poster_url: Option, + pub movie_title: String, + pub release_year: u16, + pub movie_url: Option, + pub added_at: String, + pub remove_url: Option, +} + +#[derive(Template)] +#[template(path = "watchlist.html")] +pub struct WatchlistTemplate<'a> { + pub ctx: &'a HtmlPageContext, + pub owner_id: uuid::Uuid, + pub display_entries: &'a [WatchlistDisplayEntry], + pub current_offset: u32, + pub has_more: bool, + pub limit: u32, + pub is_owner: bool, + pub error: Option, +} diff --git a/crates/adapters/http-axum/src/html/templates/wrapup.rs b/crates/adapters/http-axum/src/html/templates/wrapup.rs new file mode 100644 index 00000000..f26f91c7 --- /dev/null +++ b/crates/adapters/http-axum/src/html/templates/wrapup.rs @@ -0,0 +1,16 @@ +use super::filters; +use api_types::HtmlPageContext; +use askama::Template; + +#[derive(Template)] +#[template(path = "wrapup.html")] +pub struct WrapUpPageTemplate<'a> { + pub ctx: &'a HtmlPageContext, + pub report: &'a domain::models::wrapup::WrapUpReport, + pub year_label: String, + pub watch_time_display: String, + pub rating_max: u32, + pub genre_max: u32, + pub rating_pcts: [f64; 5], + pub genre_pcts: Vec, +} diff --git a/crates/adapters/http-axum/src/html/users.rs b/crates/adapters/http-axum/src/html/users.rs new file mode 100644 index 00000000..b6621b70 --- /dev/null +++ b/crates/adapters/http-axum/src/html/users.rs @@ -0,0 +1,179 @@ +use crate::html::helpers::build_page_context; +use crate::html::templates::{ + ProfileSettingsTemplate, RemoteActorDisplay, UserSummaryView, UsersTemplate, +}; +use crate::{ + extractors::{OptionalCookieUser, RequiredCookieUser}, + html::csrf::CsrfToken, + html::render::render_page, + state::AppState, +}; +use application::users::{ + get_profile_settings as get_profile_settings_uc, resolve_username_to_id, update_profile, + update_profile_fields, +}; +use axum::{ + extract::{Extension, Multipart, Path, Query, State}, + http::StatusCode, + response::IntoResponse, +}; +use domain::{errors::DomainError, value_objects::Username}; + +pub async fn get_users_list( + OptionalCookieUser(user_id): OptionalCookieUser, + State(state): State, + Extension(csrf): Extension, +) -> impl IntoResponse { + let mut ctx = build_page_context(&state, user_id, csrf.0).await; + ctx.page_title = "Members — Movies Diary".to_string(); + ctx.canonical_url = format!("{}/users", state.app_ctx.config.base_url); + + match application::users::get_users::execute( + &state.app_ctx.deps.users.get_users_list, + application::users::queries::GetUsersQuery, + ) + .await + { + Ok(result) => { + let users: Vec = result + .users + .iter() + .map(crate::html::mappers::users::user_summary_view) + .collect(); + let remote_actors: Vec = result + .remote_actors + .iter() + .map(crate::html::mappers::users::remote_actor_display) + .collect(); + render_page(UsersTemplate { + users, + ctx: &ctx, + remote_actors, + }) + .into_response() + } + Err(e) => crate::errors::domain_error_response(e), + } +} + +pub async fn get_user_by_username( + State(state): State, + Path(username): Path, +) -> impl IntoResponse { + let uname = match Username::new(username) { + Ok(u) => u, + Err(_) => return StatusCode::NOT_FOUND.into_response(), + }; + match resolve_username_to_id::execute(&state.app_ctx.deps.users.resolve_username, &uname).await + { + Ok(Some(uid)) => { + axum::response::Redirect::permanent(&format!("/users/{}", uid.value())).into_response() + } + _ => StatusCode::NOT_FOUND.into_response(), + } +} + +#[derive(serde::Deserialize, Default)] +pub struct SavedQuery { + pub saved: Option, +} + +pub async fn get_profile_settings( + RequiredCookieUser(user_id): RequiredCookieUser, + State(state): State, + Query(params): Query, + Extension(csrf): Extension, +) -> impl IntoResponse { + let mut ctx = build_page_context(&state, Some(user_id.clone()), csrf.0).await; + ctx.page_title = "Profile Settings — Movies Diary".to_string(); + ctx.canonical_url = format!("{}/settings/profile", state.app_ctx.config.base_url); + + let settings = match get_profile_settings_uc::execute( + &state.app_ctx.deps.users.get_profile_settings, + user_id.value(), + ) + .await + { + Ok(s) => s, + Err(DomainError::NotFound(_)) => return StatusCode::NOT_FOUND.into_response(), + Err(e) => return crate::errors::domain_error_response(e), + }; + + let saved = params.saved.as_deref() == Some("1"); + + render_page(ProfileSettingsTemplate { + ctx: &ctx, + bio: settings.bio.as_deref(), + avatar_url: settings.avatar_url.as_deref(), + banner_url: settings.banner_url.as_deref(), + also_known_as: settings.also_known_as.as_deref(), + profile_fields: &settings.fields, + saved, + embed_url: format!( + "{}/users/{}?embed=true", + state.app_ctx.config.base_url, + user_id.value() + ), + }) + .into_response() +} + +pub async fn post_profile_settings( + RequiredCookieUser(user_id): RequiredCookieUser, + State(state): State, + multipart: Multipart, +) -> 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, + }; + if let Err(e) = update_profile::execute(&state.app_ctx.deps.users.update_profile, cmd).await { + tracing::error!("update_profile error: {:?}", e); + return axum::response::Redirect::to(&format!( + "/settings/profile?error={}", + crate::html::helpers::encode_domain_error(&e) + )) + .into_response(); + } + + let fields: Vec = (0..4) + .filter_map(|i| { + data.profile_field_names + .get(&i) + .map(|name| domain::models::ProfileField { + name: name.clone(), + value: data + .profile_field_values + .get(&i) + .cloned() + .unwrap_or_default(), + }) + }) + .collect(); + + let fields_cmd = application::users::commands::UpdateProfileFieldsCommand { + user_id: user_id.value(), + fields, + }; + if let Err(e) = + update_profile_fields::execute(&state.app_ctx.deps.users.update_profile_fields, fields_cmd) + .await + { + tracing::error!("update_profile_fields error: {:?}", e); + return axum::response::Redirect::to(&format!( + "/settings/profile?error={}", + crate::html::helpers::encode_domain_error(&e) + )) + .into_response(); + } + + axum::response::Redirect::to("/settings/profile?saved=1").into_response() +} diff --git a/crates/adapters/http-axum/src/html/watchlist.rs b/crates/adapters/http-axum/src/html/watchlist.rs new file mode 100644 index 00000000..1cd9eab3 --- /dev/null +++ b/crates/adapters/http-axum/src/html/watchlist.rs @@ -0,0 +1,171 @@ +use crate::html::helpers::{build_page_context, encode_error}; +use crate::html::templates::WatchlistTemplate; +use crate::{ + extractors::{OptionalCookieUser, RequiredCookieUser}, + html::csrf::CsrfToken, + html::render::render_page, + state::AppState, +}; +use application::{ + diary::commands::MovieInput, + watchlist::{ + add as add_to_watchlist, + commands::{AddToWatchlistCommand, RemoveFromWatchlistCommand}, + get_watchlist_for_owner, remove as remove_from_watchlist, + }, +}; +use axum::{ + Form, + extract::{Extension, Path, Query, State}, + http::StatusCode, + response::{IntoResponse, Redirect}, +}; +use domain::errors::DomainError; + +pub async fn get_watchlist_page( + OptionalCookieUser(viewer_id): OptionalCookieUser, + State(state): State, + Path(owner_id): Path, + Query(params): Query, + Extension(csrf): Extension, +) -> impl IntoResponse { + let ctx = build_page_context(&state, viewer_id.clone(), csrf.0).await; + let is_owner = viewer_id.map(|u| u.value() == owner_id).unwrap_or(false); + + let view = match get_watchlist_for_owner::execute( + &state.app_ctx.deps.watchlist.get_watchlist_for_owner, + owner_id, + params.limit.or(Some(20)), + params.offset.or(Some(0)), + ) + .await + { + Ok(view) => view, + Err(e) => return crate::errors::domain_error_response(e), + }; + + let result = match view { + get_watchlist_for_owner::WatchlistView::Local(page) => { + crate::html::mappers::watchlist::build_watchlist_page(page, is_owner) + } + get_watchlist_for_owner::WatchlistView::Remote(entries) => { + crate::html::mappers::watchlist::build_remote_watchlist_page(entries) + } + }; + + render_page(WatchlistTemplate { + ctx: &ctx, + owner_id, + display_entries: &result.display_entries, + current_offset: result.current_offset, + has_more: result.has_more, + limit: result.limit, + is_owner, + error: params.error, + }) + .into_response() +} + +pub async fn post_watchlist_add_html( + State(state): State, + RequiredCookieUser(user_id): RequiredCookieUser, + Extension(csrf): Extension, + Form(form): Form, +) -> impl IntoResponse { + 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("/") + .to_string(); + + let input = if let Some(id) = form.movie_id { + MovieInput { + movie_id: Some(id), + external_metadata_id: None, + manual_title: None, + manual_release_year: None, + manual_director: None, + } + } else { + let query = form.query.as_deref().unwrap_or("").trim().to_string(); + let is_external_id = query.starts_with("tmdb:") + || (query.starts_with("tt") + && query.len() > 2 + && query[2..].chars().all(|c| c.is_ascii_digit())); + if is_external_id { + MovieInput { + movie_id: None, + external_metadata_id: Some(query), + manual_title: None, + manual_release_year: None, + manual_director: None, + } + } else { + MovieInput { + movie_id: None, + external_metadata_id: None, + manual_title: if query.is_empty() { None } else { Some(query) }, + manual_release_year: form.year, + manual_director: None, + } + } + }; + + match add_to_watchlist::execute( + &state.app_ctx.deps.watchlist.add, + AddToWatchlistCommand { + user_id: user_id.value(), + input, + }, + ) + .await + { + Ok(()) => Redirect::to(&redirect_base).into_response(), + Err(DomainError::NotFound(_)) => Redirect::to(&redirect_base).into_response(), + Err(DomainError::ValidationError(msg)) => { + let sep = if redirect_base.contains('?') { + '&' + } else { + '?' + }; + let url = format!("{}{}error={}", redirect_base, sep, encode_error(&msg)); + Redirect::to(&url).into_response() + } + Err(e) => crate::errors::domain_error_response(e), + } +} + +pub async fn post_watchlist_remove_html( + State(state): State, + RequiredCookieUser(user_id): RequiredCookieUser, + Extension(csrf): Extension, + Path(movie_id): Path, + Form(form): Form, +) -> impl IntoResponse { + if crate::html::csrf::mismatch(&csrf, &form.csrf_token) { + return StatusCode::FORBIDDEN.into_response(); + } + match remove_from_watchlist::execute( + &state.app_ctx.deps.watchlist.remove_from_watchlist, + RemoveFromWatchlistCommand { + user_id: user_id.value(), + movie_id, + }, + ) + .await + { + Ok(()) | Err(DomainError::NotFound(_)) => { + let redirect_url = form + .redirect_after + .filter(|u| u.starts_with('/') && !u.starts_with("//")) + .unwrap_or_else(|| "/".to_string()); + Redirect::to(&redirect_url).into_response() + } + Err(e) => crate::errors::domain_error_response(e), + } +} diff --git a/crates/adapters/http-axum/src/html/wrapup.rs b/crates/adapters/http-axum/src/html/wrapup.rs new file mode 100644 index 00000000..edb7e042 --- /dev/null +++ b/crates/adapters/http-axum/src/html/wrapup.rs @@ -0,0 +1,106 @@ +use crate::{ + extractors::OptionalCookieUser, html::csrf::CsrfToken, html::render::render_page, + state::AppState, +}; +use application::wrapup::get_ready_report; +use axum::{ + extract::{Extension, Path, State}, + http::StatusCode, + response::IntoResponse, +}; +use domain::errors::DomainError; +use domain::models::wrapup::{WrapUpReport, WrapUpScope}; +use uuid::Uuid; + +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 = report + .top_genres + .iter() + .take(8) + .map(|g| g.count as f64 / genre_max as f64 * 100.0) + .collect(); + let tmpl = crate::html::templates::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, + Path((user_id, year)): Path<(Uuid, i32)>, + Extension(csrf): Extension, +) -> 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 = crate::html::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, + Path(year): Path, + Extension(csrf): Extension, +) -> 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 = crate::html::helpers::build_page_context(&state, viewer, csrf.0).await; + render_wrapup(&report, year, &ctx) +} diff --git a/crates/adapters/http-axum/src/lib.rs b/crates/adapters/http-axum/src/lib.rs index f7858366..76d8477f 100644 --- a/crates/adapters/http-axum/src/lib.rs +++ b/crates/adapters/http-axum/src/lib.rs @@ -1,15 +1,13 @@ +pub mod api; pub mod context; -pub mod csrf; pub mod errors; pub mod extractors; -pub mod forms; -pub mod handlers; +pub mod federation; pub mod html; -pub mod mappers; pub mod openapi; -pub mod render; -pub mod routes; +pub mod router; pub mod state; +mod support; #[cfg(test)] mod tests; diff --git a/crates/adapters/http-axum/src/openapi/auth.rs b/crates/adapters/http-axum/src/openapi/auth.rs index 8e9ff839..f727a70a 100644 --- a/crates/adapters/http-axum/src/openapi/auth.rs +++ b/crates/adapters/http-axum/src/openapi/auth.rs @@ -3,7 +3,7 @@ use utoipa::OpenApi; #[derive(OpenApi)] #[openapi( - paths(crate::handlers::auth::login, crate::handlers::auth::register,), + paths(crate::api::auth::login, crate::api::auth::register,), components(schemas(LoginRequest, LoginResponse, RegisterRequest)) )] pub struct AuthDoc; diff --git a/crates/adapters/http-axum/src/openapi/diary.rs b/crates/adapters/http-axum/src/openapi/diary.rs index 971cf3d7..a91c5ec5 100644 --- a/crates/adapters/http-axum/src/openapi/diary.rs +++ b/crates/adapters/http-axum/src/openapi/diary.rs @@ -6,11 +6,11 @@ use utoipa::OpenApi; #[derive(OpenApi)] #[openapi( paths( - crate::handlers::diary::get_diary, - crate::handlers::diary::post_review, - crate::handlers::diary::delete_review, - crate::handlers::diary::export_diary, - crate::handlers::diary::get_activity_feed, + crate::api::diary::get_diary, + crate::api::diary::post_review, + crate::api::diary::delete_review, + crate::api::diary::export_diary, + crate::api::diary::get_activity_feed, ), components(schemas( DiaryResponse, diff --git a/crates/adapters/http-axum/src/openapi/goals.rs b/crates/adapters/http-axum/src/openapi/goals.rs index 889e08a3..e55456d5 100644 --- a/crates/adapters/http-axum/src/openapi/goals.rs +++ b/crates/adapters/http-axum/src/openapi/goals.rs @@ -7,13 +7,13 @@ use utoipa::OpenApi; #[derive(OpenApi)] #[openapi( paths( - crate::handlers::goals::list_goals, - crate::handlers::goals::create_goal, - crate::handlers::goals::update_goal, - crate::handlers::goals::delete_goal, - crate::handlers::goals::get_user_goals, - crate::handlers::goals::get_settings, - crate::handlers::goals::update_settings, + crate::api::goals::list_goals, + crate::api::goals::create_goal, + crate::api::goals::update_goal, + crate::api::goals::delete_goal, + crate::api::goals::get_user_goals, + crate::api::goals::get_settings, + crate::api::goals::update_settings, ), components(schemas( GoalDto, diff --git a/crates/adapters/http-axum/src/openapi/import.rs b/crates/adapters/http-axum/src/openapi/import.rs index c14ffc37..5bb1ccba 100644 --- a/crates/adapters/http-axum/src/openapi/import.rs +++ b/crates/adapters/http-axum/src/openapi/import.rs @@ -7,13 +7,13 @@ use utoipa::OpenApi; #[derive(OpenApi)] #[openapi( paths( - crate::handlers::import::api_post_session, - crate::handlers::import::api_get_session, - crate::handlers::import::api_put_mapping, - crate::handlers::import::api_post_confirm, - crate::handlers::import::api_get_profiles, - crate::handlers::import::api_post_profile, - crate::handlers::import::api_delete_profile, + crate::api::import::api_post_session, + crate::api::import::api_get_session, + crate::api::import::api_put_mapping, + crate::api::import::api_post_confirm, + crate::api::import::api_get_profiles, + crate::api::import::api_post_profile, + crate::api::import::api_delete_profile, ), components(schemas( SessionCreatedResponse, diff --git a/crates/adapters/http-axum/src/openapi/insights.rs b/crates/adapters/http-axum/src/openapi/insights.rs index b84e841f..19680b2a 100644 --- a/crates/adapters/http-axum/src/openapi/insights.rs +++ b/crates/adapters/http-axum/src/openapi/insights.rs @@ -2,7 +2,7 @@ use utoipa::OpenApi; #[derive(OpenApi)] #[openapi( - paths(crate::handlers::insights::get_insights,), + paths(crate::api::insights::get_insights,), components(schemas(api_types::insights::InsightsResponse, api_types::insights::InsightDto,)) )] pub struct InsightsDoc; diff --git a/crates/adapters/http-axum/src/openapi/movies.rs b/crates/adapters/http-axum/src/openapi/movies.rs index edcecb1c..b76529f1 100644 --- a/crates/adapters/http-axum/src/openapi/movies.rs +++ b/crates/adapters/http-axum/src/openapi/movies.rs @@ -8,11 +8,11 @@ use utoipa::OpenApi; #[derive(OpenApi)] #[openapi( paths( - crate::handlers::movies::list_movies, - crate::handlers::movies::get_movie_detail, - crate::handlers::movies::get_review_history, - crate::handlers::movies::get_movie_profile, - crate::handlers::movies::sync_poster, + crate::api::movies::list_movies, + crate::api::movies::get_movie_detail, + crate::api::movies::get_review_history, + crate::api::movies::get_movie_profile, + crate::api::movies::sync_poster, ), components(schemas( MoviesResponse, diff --git a/crates/adapters/http-axum/src/openapi/search.rs b/crates/adapters/http-axum/src/openapi/search.rs index 8c9fa017..a3d6c794 100644 --- a/crates/adapters/http-axum/src/openapi/search.rs +++ b/crates/adapters/http-axum/src/openapi/search.rs @@ -7,9 +7,9 @@ use utoipa::OpenApi; #[derive(OpenApi)] #[openapi( paths( - crate::handlers::search::get_search, - crate::handlers::search::get_person_handler, - crate::handlers::search::get_person_credits_handler, + crate::api::search::get_search, + crate::api::search::get_person_handler, + crate::api::search::get_person_credits_handler, ), components(schemas( SearchResponse, diff --git a/crates/adapters/http-axum/src/openapi/social.rs b/crates/adapters/http-axum/src/openapi/social.rs index c5c8cee5..22065985 100644 --- a/crates/adapters/http-axum/src/openapi/social.rs +++ b/crates/adapters/http-axum/src/openapi/social.rs @@ -11,23 +11,23 @@ use utoipa::OpenApi; #[derive(OpenApi)] #[openapi( paths( - crate::handlers::social::get_following, - crate::handlers::social::get_followers, - crate::handlers::social::get_pending_followers, - crate::handlers::social::get_pending_follower_count, - crate::handlers::social::get_pending_following, - crate::handlers::social::get_relationship, - crate::handlers::social::follow, - crate::handlers::social::unfollow, - crate::handlers::social::accept_follower, - crate::handlers::social::reject_follower, - crate::handlers::social::remove_follower, - crate::handlers::social::get_blocked_domains_admin, - crate::handlers::social::add_blocked_domain_admin, - crate::handlers::social::remove_blocked_domain_admin, - crate::handlers::social::block_actor_api, - crate::handlers::social::unblock_actor_api, - crate::handlers::social::get_blocked_actors_api, + crate::api::social::get_following, + crate::api::social::get_followers, + crate::api::social::get_pending_followers, + crate::api::social::get_pending_follower_count, + crate::api::social::get_pending_following, + crate::api::social::get_relationship, + crate::api::social::follow, + crate::api::social::unfollow, + crate::api::social::accept_follower, + crate::api::social::reject_follower, + crate::api::social::remove_follower, + crate::federation::blocklist_api::get_blocked_domains_admin, + crate::federation::blocklist_api::add_blocked_domain_admin, + crate::federation::blocklist_api::remove_blocked_domain_admin, + crate::federation::blocklist_api::block_actor_api, + crate::federation::blocklist_api::unblock_actor_api, + crate::federation::blocklist_api::get_blocked_actors_api, ), components(schemas( ActorListResponse, diff --git a/crates/adapters/http-axum/src/openapi/users.rs b/crates/adapters/http-axum/src/openapi/users.rs index 67e4bed6..6bbd4961 100644 --- a/crates/adapters/http-axum/src/openapi/users.rs +++ b/crates/adapters/http-axum/src/openapi/users.rs @@ -7,11 +7,11 @@ use utoipa::OpenApi; #[derive(OpenApi)] #[openapi( paths( - crate::handlers::users::list_users, - crate::handlers::users::get_user_profile, - crate::handlers::users::get_profile, - crate::handlers::users::update_profile_handler, - crate::handlers::users::update_profile_fields_handler, + crate::api::users::list_users, + crate::api::user_profile::get_user_profile, + crate::api::users::get_profile, + crate::api::users::update_profile_handler, + crate::api::users::update_profile_fields_handler, ), components(schemas( UsersResponse, diff --git a/crates/adapters/http-axum/src/openapi/watchlist.rs b/crates/adapters/http-axum/src/openapi/watchlist.rs index 5573c28a..b2f0f1ca 100644 --- a/crates/adapters/http-axum/src/openapi/watchlist.rs +++ b/crates/adapters/http-axum/src/openapi/watchlist.rs @@ -6,10 +6,10 @@ use utoipa::OpenApi; #[derive(OpenApi)] #[openapi( paths( - crate::handlers::watchlist::get_watchlist_handler, - crate::handlers::watchlist::post_watchlist_add, - crate::handlers::watchlist::delete_watchlist_entry, - crate::handlers::watchlist::get_watchlist_status, + crate::api::watchlist::get_watchlist_handler, + crate::api::watchlist::post_watchlist_add, + crate::api::watchlist::delete_watchlist_entry, + crate::api::watchlist::get_watchlist_status, ), components(schemas( WatchlistResponse, diff --git a/crates/adapters/http-axum/src/openapi/webhook.rs b/crates/adapters/http-axum/src/openapi/webhook.rs index c1d5f6a7..f48e72b0 100644 --- a/crates/adapters/http-axum/src/openapi/webhook.rs +++ b/crates/adapters/http-axum/src/openapi/webhook.rs @@ -8,14 +8,14 @@ use utoipa::OpenApi; #[derive(OpenApi)] #[openapi( paths( - crate::handlers::webhook::post_jellyfin_webhook, - crate::handlers::webhook::post_plex_webhook, - crate::handlers::webhook::post_generate_webhook_token, - crate::handlers::webhook::get_webhook_tokens, - crate::handlers::webhook::delete_webhook_token, - crate::handlers::webhook::get_watch_queue, - crate::handlers::webhook::post_confirm_watch_events, - crate::handlers::webhook::post_dismiss_watch_events, + crate::api::webhook::post_jellyfin_webhook, + crate::api::webhook::post_plex_webhook, + crate::api::webhook::post_generate_webhook_token, + crate::api::webhook::get_webhook_tokens, + crate::api::webhook::delete_webhook_token, + crate::api::webhook::get_watch_queue, + crate::api::webhook::post_confirm_watch_events, + crate::api::webhook::post_dismiss_watch_events, ), components(schemas( GenerateTokenRequest, diff --git a/crates/adapters/http-axum/src/openapi/wrapup.rs b/crates/adapters/http-axum/src/openapi/wrapup.rs index ba6e0093..f93a8ed9 100644 --- a/crates/adapters/http-axum/src/openapi/wrapup.rs +++ b/crates/adapters/http-axum/src/openapi/wrapup.rs @@ -3,11 +3,11 @@ use utoipa::OpenApi; #[derive(OpenApi)] #[openapi( paths( - crate::handlers::wrapup::post_generate, - crate::handlers::wrapup::get_list, - crate::handlers::wrapup::get_status, - crate::handlers::wrapup::get_report, - crate::handlers::wrapup::delete_wrapup_handler, + crate::api::wrapup::post_generate, + crate::api::wrapup::get_list, + crate::api::wrapup::get_status, + crate::api::wrapup::get_report, + crate::api::wrapup::delete_wrapup_handler, ), components(schemas( api_types::wrapup::GenerateWrapUpRequest, diff --git a/crates/adapters/http-axum/src/router.rs b/crates/adapters/http-axum/src/router.rs new file mode 100644 index 00000000..1185bde2 --- /dev/null +++ b/crates/adapters/http-axum/src/router.rs @@ -0,0 +1,131 @@ +use std::num::NonZeroU32; + +use axum::{Router, routing}; +use axum_governor::{GovernorConfigBuilder, GovernorLayer, Quota, extractor::PeerIp}; +use tower_http::{ + cors::CorsLayer, + services::{ServeDir, ServeFile}, + trace::TraceLayer, +}; + +use crate::{api, federation, html, state::AppState}; + +pub fn build_router(state: AppState, ap_router: Router) -> Router { + let rate_limit = state.app_ctx.config.rate_limit; + let secure_cookies = state.app_ctx.config.secure_cookies; + let cors_origins = state.app_ctx.config.cors_origins.clone(); + + let ap_cfg = GovernorConfigBuilder::default() + .with_extractor(PeerIp::default()) + .expect_connect_info() + .quota_default(per_minute(rate_limit / 2)) + .finish() + .unwrap(); + let ap_router = ap_router.layer(GovernorLayer::new(ap_cfg)); + + Router::new() + .route("/health", routing::get(health_handler)) + .merge(html_routes(rate_limit, secure_cookies)) + .merge(api_routes(rate_limit, &cors_origins)) + .nest_service("/static", ServeDir::new("static")) + .nest_service( + "/app", + ServeDir::new("spa/dist").fallback(ServeFile::new("spa/dist/index.html")), + ) + .layer(TraceLayer::new_for_http()) + .with_state(state) + .merge(ap_router) +} + +async fn health_handler() -> axum::Json { + axum::Json(serde_json::json!({ "status": "ok" })) +} + +fn per_minute(n: u64) -> Quota { + let n = NonZeroU32::new(n.clamp(1, u32::MAX as u64) as u32).unwrap(); + Quota::requests_per_minute(n) +} + +fn html_routes(rate_limit: u64, secure_cookies: bool) -> Router { + let auth = html::auth_router().layer({ + let cfg = GovernorConfigBuilder::default() + .with_extractor(PeerIp::default()) + .expect_connect_info() + .quota_default(per_minute(rate_limit)) + .finish() + .unwrap(); + GovernorLayer::new(cfg) + }); + + html::router() + .merge(federation::html_routes()) + .merge(auth) + .layer(axum::middleware::from_fn_with_state( + crate::html::csrf::SecureCookies(secure_cookies), + crate::html::csrf::csrf_middleware, + )) +} + +fn cors_layer(origins: &str) -> CorsLayer { + use axum::http::{HeaderName, Method}; + use tower_http::cors::AllowOrigin; + + let layer = CorsLayer::new() + .allow_methods([ + Method::GET, + Method::POST, + Method::PUT, + Method::PATCH, + Method::DELETE, + Method::OPTIONS, + ]) + .allow_headers([ + HeaderName::from_static("content-type"), + HeaderName::from_static("authorization"), + ]); + + if origins.is_empty() || origins == "*" { + layer.allow_origin(AllowOrigin::any()) + } else { + let parsed: Vec<_> = origins + .split(',') + .filter_map(|s| { + let trimmed = s.trim(); + match trimmed.parse() { + Ok(v) => Some(v), + Err(e) => { + tracing::warn!("ignoring invalid CORS origin {trimmed:?}: {e}"); + None + } + } + }) + .collect(); + layer + .allow_origin(AllowOrigin::list(parsed)) + .allow_credentials(true) + } +} + +fn api_routes(rate_limit: u64, cors_origins: &str) -> Router { + let cfg = GovernorConfigBuilder::default() + .with_extractor(PeerIp::default()) + .expect_connect_info() + .quota_default(per_minute(rate_limit)) + .finish() + .unwrap(); + + let base = api::router().merge(federation::api_routes()); + + let webhook_cfg = GovernorConfigBuilder::default() + .with_extractor(PeerIp::default()) + .expect_connect_info() + .quota_default(per_minute(rate_limit / 4)) + .finish() + .unwrap(); + let webhook_routes = api::webhook_router().layer(GovernorLayer::new(webhook_cfg)); + + Router::new() + .nest("/api/v1", base.layer(GovernorLayer::new(cfg))) + .nest("/api/v1", webhook_routes) + .layer(cors_layer(cors_origins)) +} diff --git a/crates/adapters/http-axum/src/routes.rs b/crates/adapters/http-axum/src/routes.rs deleted file mode 100644 index 77e08d53..00000000 --- a/crates/adapters/http-axum/src/routes.rs +++ /dev/null @@ -1,593 +0,0 @@ -use std::num::NonZeroU32; - -use axum::{Router, routing}; -use axum_governor::{GovernorConfigBuilder, GovernorLayer, Quota, extractor::PeerIp}; -use tower_http::{ - cors::CorsLayer, - services::{ServeDir, ServeFile}, - trace::TraceLayer, -}; - -use crate::{handlers, state::AppState}; - -pub fn build_router(state: AppState, ap_router: Router) -> Router { - let rate_limit = state.app_ctx.config.rate_limit; - let secure_cookies = state.app_ctx.config.secure_cookies; - let cors_origins = state.app_ctx.config.cors_origins.clone(); - - let ap_cfg = GovernorConfigBuilder::default() - .with_extractor(PeerIp::default()) - .expect_connect_info() - .quota_default(per_minute(rate_limit / 2)) - .finish() - .unwrap(); - let ap_router = ap_router.layer(GovernorLayer::new(ap_cfg)); - - Router::new() - .route("/health", routing::get(health_handler)) - .merge(html_routes(rate_limit, secure_cookies)) - .merge(api_routes(rate_limit, &cors_origins)) - .nest_service("/static", ServeDir::new("static")) - .nest_service( - "/app", - ServeDir::new("spa/dist").fallback(ServeFile::new("spa/dist/index.html")), - ) - .layer(TraceLayer::new_for_http()) - .with_state(state) - .merge(ap_router) -} - -async fn health_handler() -> axum::Json { - axum::Json(serde_json::json!({ "status": "ok" })) -} - -fn per_minute(n: u64) -> Quota { - let n = NonZeroU32::new(n.clamp(1, u32::MAX as u64) as u32).unwrap(); - Quota::requests_per_minute(n) -} - -fn html_routes(rate_limit: u64, secure_cookies: bool) -> Router { - let auth = Router::new() - .route( - "/login", - routing::get(handlers::auth::get_login_page).post(handlers::auth::post_login), - ) - .route("/logout", routing::get(handlers::auth::get_logout)) - .route( - "/register", - routing::get(handlers::auth::get_register_page).post(handlers::auth::post_register), - ) - .layer({ - let cfg = GovernorConfigBuilder::default() - .with_extractor(PeerIp::default()) - .expect_connect_info() - .quota_default(per_minute(rate_limit)) - .finish() - .unwrap(); - GovernorLayer::new(cfg) - }); - - let base = Router::new() - .route("/", routing::get(handlers::diary::get_activity_feed_html)) - .route("/users", routing::get(handlers::users::get_users_list)) - .route( - "/u/{username}", - routing::get(handlers::users::get_user_by_username), - ) - .route( - "/users/{id}", - routing::get(handlers::users::get_user_profile_html), - ) - .route( - "/movies/{movie_id}", - routing::get(handlers::movies::get_movie_detail_html), - ) - .merge(auth) - .route( - "/reviews/new", - routing::get(handlers::diary::get_new_review_page), - ) - .route("/reviews", routing::post(handlers::diary::post_review_html)) - .route( - "/reviews/{id}/delete", - routing::post(handlers::diary::post_delete_review_html), - ) - .route("/images/{*key}", routing::get(handlers::images::get_image)) - .route( - "/posters/{path}", - routing::get( - |axum::extract::Path(p): axum::extract::Path| async move { - axum::response::Redirect::permanent(&format!("/images/{}", p)) - }, - ), - ) - .route( - "/diary/export", - routing::get(handlers::diary::get_export_html), - ) - .route("/import", routing::get(handlers::import::get_import_page)) - .route( - "/import/upload", - routing::post(handlers::import::post_upload), - ) - .route( - "/import/{id}/mapping", - routing::get(handlers::import::get_mapping_page).post(handlers::import::post_mapping), - ) - .route( - "/import/{id}/preview", - routing::get(handlers::import::get_preview_page), - ) - .route( - "/import/{id}/confirm", - routing::post(handlers::import::post_confirm), - ) - .route( - "/import/done", - routing::get(handlers::import::get_import_done), - ) - .route( - "/import/profiles/{profile_id}/delete", - routing::post(handlers::import::post_delete_profile), - ) - .route("/feed.rss", routing::get(handlers::rss::get_feed)) - .route( - "/users/{id}/feed.rss", - routing::get(handlers::rss::get_user_feed), - ) - .route( - "/settings/profile", - routing::get(handlers::users::get_profile_settings) - .post(handlers::users::post_profile_settings), - ) - .route("/tags/{tag}", routing::get(handlers::search::get_tag)) - .route( - "/users/{id}/watchlist", - routing::get(handlers::watchlist::get_watchlist_page), - ) - .route( - "/watchlist/add", - routing::post(handlers::watchlist::post_watchlist_add_html), - ) - .route( - "/watchlist/{movie_id}/remove", - routing::post(handlers::watchlist::post_watchlist_remove_html), - ) - .route( - "/settings/integrations", - routing::get(handlers::integrations::get_integrations_page), - ) - .route( - "/settings/integrations/generate", - routing::post(handlers::integrations::post_generate_token), - ) - .route( - "/settings/integrations/{id}/revoke", - routing::post(handlers::integrations::post_revoke_token), - ) - .route( - "/watch-queue", - routing::get(handlers::integrations::get_watch_queue_page), - ) - .route( - "/watch-queue/{id}/confirm", - routing::post(handlers::integrations::post_confirm_single), - ) - .route( - "/watch-queue/{id}/dismiss", - routing::post(handlers::integrations::post_dismiss_single), - ) - .route( - "/wrapups/{user_id}/{year}", - routing::get(handlers::wrapup::get_user_wrapup_html), - ) - .route( - "/wrapups/global/{year}", - routing::get(handlers::wrapup::get_global_wrapup_html), - ); - - let base = base.merge(social_html_routes()); - #[cfg(feature = "federation")] - let base = base.merge(federation_html_routes()); - - base.layer(axum::middleware::from_fn_with_state( - crate::csrf::SecureCookies(secure_cookies), - crate::csrf::csrf_middleware, - )) -} - -fn social_html_routes() -> Router { - let base = Router::new() - .route( - "/users/{id}/follow", - routing::post(handlers::social::follow_user), - ) - .route( - "/users/{id}/unfollow", - routing::post(handlers::social::unfollow_user), - ) - .route( - "/users/{id}/followers/accept", - routing::post(handlers::social::accept_follower_html), - ) - .route( - "/users/{id}/followers/reject", - routing::post(handlers::social::reject_follower_html), - ) - .route( - "/users/{id}/followers/remove", - routing::post(handlers::social::remove_follower_html), - ) - .route( - "/users/{id}/following-list", - routing::get(handlers::social::get_following_page), - ) - .route( - "/users/{id}/followers-list", - routing::get(handlers::social::get_followers_page), - ); - - #[cfg(not(feature = "federation"))] - let base = base - .route( - "/users/{id}/followers", - routing::get( - |axum::extract::Path(id): axum::extract::Path| async move { - axum::response::Redirect::permanent(&format!("/users/{}/followers-list", id)) - }, - ), - ) - .route( - "/users/{id}/following", - routing::get( - |axum::extract::Path(id): axum::extract::Path| async move { - axum::response::Redirect::permanent(&format!("/users/{}/following-list", id)) - }, - ), - ); - - base -} - -#[cfg(feature = "federation")] -fn federation_html_routes() -> Router { - Router::new() - .route( - "/users/{id}/followers", - routing::get(handlers::social::get_followers_collection), - ) - .route( - "/users/{id}/following", - routing::get(handlers::social::get_following_collection), - ) - .route( - "/admin/blocked-domains", - routing::get(handlers::social::get_blocked_domains_page) - .post(handlers::social::post_blocked_domain), - ) - .route( - "/admin/blocked-domains/remove", - routing::post(handlers::social::post_remove_blocked_domain), - ) - .route( - "/social/blocked", - routing::get(handlers::social::get_blocked_actors_page), - ) - .route( - "/social/block", - routing::post(handlers::social::post_block_actor_html), - ) - .route( - "/social/unblock", - routing::post(handlers::social::post_unblock_actor), - ) -} - -fn cors_layer(origins: &str) -> CorsLayer { - use axum::http::{HeaderName, Method}; - use tower_http::cors::AllowOrigin; - - let layer = CorsLayer::new() - .allow_methods([ - Method::GET, - Method::POST, - Method::PUT, - Method::PATCH, - Method::DELETE, - Method::OPTIONS, - ]) - .allow_headers([ - HeaderName::from_static("content-type"), - HeaderName::from_static("authorization"), - ]); - - if origins.is_empty() || origins == "*" { - layer.allow_origin(AllowOrigin::any()) - } else { - let parsed: Vec<_> = origins - .split(',') - .filter_map(|s| { - let trimmed = s.trim(); - match trimmed.parse() { - Ok(v) => Some(v), - Err(e) => { - tracing::warn!("ignoring invalid CORS origin {trimmed:?}: {e}"); - None - } - } - }) - .collect(); - layer - .allow_origin(AllowOrigin::list(parsed)) - .allow_credentials(true) - } -} - -fn api_routes(rate_limit: u64, cors_origins: &str) -> Router { - let cfg = GovernorConfigBuilder::default() - .with_extractor(PeerIp::default()) - .expect_connect_info() - .quota_default(per_minute(rate_limit)) - .finish() - .unwrap(); - - let base = Router::new() - .route("/diary", routing::get(handlers::diary::get_diary)) - .route( - "/movies/{id}/history", - routing::get(handlers::movies::get_review_history), - ) - .route("/movies", routing::get(handlers::movies::list_movies)) - .route( - "/movies/{id}", - routing::get(handlers::movies::get_movie_detail), - ) - .route( - "/movies/{id}/profile", - routing::get(handlers::movies::get_movie_profile), - ) - .route("/reviews", routing::post(handlers::diary::post_review)) - .route( - "/reviews/{id}", - routing::delete(handlers::diary::delete_review).patch(handlers::diary::patch_review), - ) - .route( - "/movies/{id}/sync-poster", - routing::post(handlers::movies::sync_poster), - ) - .route("/auth/login", routing::post(handlers::auth::login)) - .route("/auth/register", routing::post(handlers::auth::register)) - .route("/auth/refresh", routing::post(handlers::auth::refresh)) - .route("/auth/logout", routing::post(handlers::auth::api_logout)) - .route("/diary/export", routing::get(handlers::diary::export_diary)) - .route( - "/activity-feed", - routing::get(handlers::diary::get_activity_feed), - ) - .route("/users", routing::get(handlers::users::list_users)) - .route( - "/users/{id}", - routing::get(handlers::users::get_user_profile), - ) - .route( - "/import/sessions", - routing::post(handlers::import::api_post_session), - ) - .route( - "/import/sessions/{id}", - routing::get(handlers::import::api_get_session), - ) - .route( - "/import/sessions/{id}/mapping", - routing::put(handlers::import::api_put_mapping), - ) - .route( - "/import/sessions/{id}/preview", - routing::get(handlers::import::api_get_preview), - ) - .route( - "/import/sessions/{id}/confirm", - routing::post(handlers::import::api_post_confirm), - ) - .route( - "/import/profiles", - routing::get(handlers::import::api_get_profiles) - .post(handlers::import::api_post_profile), - ) - .route( - "/import/profiles/{id}", - routing::delete(handlers::import::api_delete_profile), - ) - .route( - "/import/sessions/{id}/profile/{profile_id}", - routing::put(handlers::import::api_apply_profile), - ) - .route( - "/profile", - routing::get(handlers::users::get_profile).put(handlers::users::update_profile_handler), - ) - .route( - "/profile/fields", - routing::put(handlers::users::update_profile_fields_handler), - ) - .route("/search", routing::get(handlers::search::get_search)) - .route( - "/people/{id}", - routing::get(handlers::search::get_person_handler), - ) - .route( - "/people/{id}/credits", - routing::get(handlers::search::get_person_credits_handler), - ) - .route( - "/watchlist", - routing::get(handlers::watchlist::get_watchlist_handler) - .post(handlers::watchlist::post_watchlist_add), - ) - .route( - "/watchlist/{movie_id}", - routing::get(handlers::watchlist::get_watchlist_status) - .delete(handlers::watchlist::delete_watchlist_entry), - ) - .route( - "/settings/webhook-tokens", - routing::get(handlers::webhook::get_webhook_tokens) - .post(handlers::webhook::post_generate_webhook_token), - ) - .route( - "/settings/webhook-tokens/{id}", - routing::delete(handlers::webhook::delete_webhook_token), - ) - .route( - "/watch-queue", - routing::get(handlers::webhook::get_watch_queue), - ) - .route( - "/watch-queue/confirm", - routing::post(handlers::webhook::post_confirm_watch_events), - ) - .route( - "/watch-queue/dismiss", - routing::post(handlers::webhook::post_dismiss_watch_events), - ) - .route( - "/wrapups/generate", - routing::post(handlers::wrapup::post_generate), - ) - .route("/wrapups", routing::get(handlers::wrapup::get_list)) - .route( - "/wrapups/{id}", - routing::get(handlers::wrapup::get_status) - .delete(handlers::wrapup::delete_wrapup_handler), - ) - .route( - "/wrapups/{id}/report", - routing::get(handlers::wrapup::get_report), - ) - .route("/insights", routing::get(handlers::insights::get_insights)) - .route( - "/admin/reindex-search", - routing::post(handlers::search::post_reindex_search), - ) - .route( - "/goals", - routing::get(handlers::goals::list_goals).post(handlers::goals::create_goal), - ) - .route( - "/goals/{year}", - routing::put(handlers::goals::update_goal).delete(handlers::goals::delete_goal), - ) - .route( - "/users/{id}/goals", - routing::get(handlers::goals::get_user_goals), - ) - .route( - "/settings", - routing::get(handlers::goals::get_settings).put(handlers::goals::update_settings), - ); - - let base = base.merge(social_api_routes()); - #[cfg(feature = "federation")] - let base = base.merge(federation_api_routes()); - - let webhook_cfg = GovernorConfigBuilder::default() - .with_extractor(PeerIp::default()) - .expect_connect_info() - .quota_default(per_minute(rate_limit / 4)) - .finish() - .unwrap(); - let webhook_routes = Router::new() - .route( - "/webhooks/jellyfin", - routing::post(handlers::webhook::post_jellyfin_webhook), - ) - .route( - "/webhooks/plex", - routing::post(handlers::webhook::post_plex_webhook), - ) - .layer(GovernorLayer::new(webhook_cfg)); - - Router::new() - .nest("/api/v1", base.layer(GovernorLayer::new(cfg))) - .nest("/api/v1", webhook_routes) - .layer(cors_layer(cors_origins)) -} - -fn social_api_routes() -> Router { - Router::new() - .route( - "/social/following", - routing::get(handlers::social::get_following), - ) - .route( - "/social/followers", - routing::get(handlers::social::get_followers), - ) - .route( - "/social/followers/pending", - routing::get(handlers::social::get_pending_followers), - ) - .route( - "/social/followers/pending/count", - routing::get(handlers::social::get_pending_follower_count), - ) - .route( - "/social/following/pending", - routing::get(handlers::social::get_pending_following), - ) - .route( - "/social/relationship", - routing::get(handlers::social::get_relationship), - ) - .route("/social/follow", routing::post(handlers::social::follow)) - .route( - "/social/unfollow", - routing::post(handlers::social::unfollow), - ) - .route( - "/social/followers/accept", - routing::post(handlers::social::accept_follower), - ) - .route( - "/social/followers/reject", - routing::post(handlers::social::reject_follower), - ) - .route( - "/social/followers/remove", - routing::post(handlers::social::remove_follower), - ) - .route( - "/users/{id}/following", - routing::get(handlers::social::get_user_following), - ) - .route( - "/users/{id}/followers", - routing::get(handlers::social::get_user_followers), - ) -} - -#[cfg(feature = "federation")] -fn federation_api_routes() -> Router { - Router::new() - .route( - "/admin/blocked-domains", - routing::get(handlers::social::get_blocked_domains_admin) - .post(handlers::social::add_blocked_domain_admin), - ) - .route( - "/admin/blocked-domains/{domain}", - routing::delete(handlers::social::remove_blocked_domain_admin), - ) - .route( - "/social/block", - routing::post(handlers::social::block_actor_api), - ) - .route( - "/social/unblock", - routing::post(handlers::social::unblock_actor_api), - ) - .route( - "/social/blocked", - routing::get(handlers::social::get_blocked_actors_api), - ) -} diff --git a/crates/adapters/http-axum/src/handlers/helpers.rs b/crates/adapters/http-axum/src/support.rs similarity index 66% rename from crates/adapters/http-axum/src/handlers/helpers.rs rename to crates/adapters/http-axum/src/support.rs index 7a0daf06..75420146 100644 --- a/crates/adapters/http-axum/src/handlers/helpers.rs +++ b/crates/adapters/http-axum/src/support.rs @@ -1,25 +1,7 @@ -use api_types::HtmlPageContext; -use domain::value_objects::UserId; +//! Request/response helpers shared by the JSON API and the classic HTML UI. 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()), - } -} +use domain::models::import::DomainField; pub(crate) fn build_export_response( format_str: &str, @@ -149,42 +131,15 @@ pub(crate) async fn parse_profile_multipart( data } -pub(crate) async fn build_page_context( - state: &AppState, - user_id: Option, - 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, +pub(crate) fn str_to_domain_field(field: &str) -> Option { + match field { + "title" => Some(DomainField::Title), + "release_year" => Some(DomainField::ReleaseYear), + "director" => Some(DomainField::Director), + "rating" => Some(DomainField::Rating), + "watched_at" => Some(DomainField::WatchedAt), + "comment" => Some(DomainField::Comment), + "external_metadata_id" => Some(DomainField::ExternalMetadataId), + _ => None, } } diff --git a/crates/adapters/http-axum/src/tests/api_handlers.rs b/crates/adapters/http-axum/src/tests/api_handlers.rs index f79e7fe8..94b8ee2b 100644 --- a/crates/adapters/http-axum/src/tests/api_handlers.rs +++ b/crates/adapters/http-axum/src/tests/api_handlers.rs @@ -75,7 +75,7 @@ impl domain::ports::PersonQuery for PersonQueryStub { async fn search_endpoint_returns_200_with_empty_results() { let state = crate::extractors::tests::test_app_state_with_search_port(Arc::new(SearchPortStub)); let app = Router::new() - .route("/api/v1/search", get(crate::handlers::search::get_search)) + .route("/api/v1/search", get(crate::api::search::get_search)) .with_state(state); let resp = app @@ -95,7 +95,7 @@ async fn search_endpoint_returns_200_with_empty_results() { async fn search_endpoint_with_no_query_returns_200() { let state = crate::extractors::tests::test_app_state_with_search_port(Arc::new(SearchPortStub)); let app = Router::new() - .route("/api/v1/search", get(crate::handlers::search::get_search)) + .route("/api/v1/search", get(crate::api::search::get_search)) .with_state(state); let resp = app @@ -120,7 +120,7 @@ async fn person_endpoint_returns_404_for_unknown_id() { let app = Router::new() .route( "/api/v1/people/{id}", - get(crate::handlers::search::get_person_handler), + get(crate::api::search::get_person_handler), ) .with_state(state); @@ -145,7 +145,7 @@ async fn person_credits_endpoint_returns_404_for_unknown_id() { let app = Router::new() .route( "/api/v1/people/{id}/credits", - get(crate::handlers::search::get_person_credits_handler), + get(crate::api::search::get_person_credits_handler), ) .with_state(state); @@ -183,7 +183,7 @@ async fn get_watchlist_requires_auth() { let app = Router::new() .route( "/api/v1/watchlist", - get(crate::handlers::watchlist::get_watchlist_handler), + get(crate::api::watchlist::get_watchlist_handler), ) .with_state(state); @@ -266,7 +266,7 @@ async fn pending_count_endpoint_reports_the_use_case_result() { let app = Router::new() .route( "/api/v1/social/followers/pending/count", - get(crate::handlers::social::get_pending_follower_count), + get(crate::api::social::get_pending_follower_count), ) .with_state(state); @@ -295,7 +295,7 @@ async fn get_watchlist_status_requires_auth() { let app = Router::new() .route( "/api/v1/watchlist/{movie_id}", - get(crate::handlers::watchlist::get_watchlist_status), + get(crate::api::watchlist::get_watchlist_status), ) .with_state(state); @@ -352,7 +352,7 @@ async fn followers_collection_returns_500_on_federation_error() { let app = Router::new() .route( "/users/{id}/followers", - get(crate::handlers::social::get_followers_collection), + get(crate::federation::collections::get_followers_collection), ) .with_state(state_with_failing_ap_document()); @@ -376,7 +376,7 @@ async fn following_collection_returns_500_on_federation_error() { let app = Router::new() .route( "/users/{id}/following", - get(crate::handlers::social::get_following_collection), + get(crate::federation::collections::get_following_collection), ) .with_state(state_with_failing_ap_document()); diff --git a/crates/api-types/src/lib.rs b/crates/api-types/src/lib.rs index 7d9319fc..1c862985 100644 --- a/crates/api-types/src/lib.rs +++ b/crates/api-types/src/lib.rs @@ -4,6 +4,7 @@ pub mod diary; pub mod goals; pub mod import; pub mod insights; +pub mod mappers; pub mod movies; pub mod rendering; pub mod search; diff --git a/crates/adapters/http-axum/src/mappers/diary.rs b/crates/api-types/src/mappers/diary.rs similarity index 95% rename from crates/adapters/http-axum/src/mappers/diary.rs rename to crates/api-types/src/mappers/diary.rs index e38c24f5..9aa851ca 100644 --- a/crates/adapters/http-axum/src/mappers/diary.rs +++ b/crates/api-types/src/mappers/diary.rs @@ -1,4 +1,4 @@ -use api_types::FeedEntryDto; +use crate::FeedEntryDto; use domain::models::FeedEntry; use super::movies::{movie_to_dto, review_to_dto}; diff --git a/crates/api-types/src/mappers/goals.rs b/crates/api-types/src/mappers/goals.rs new file mode 100644 index 00000000..b69d542f --- /dev/null +++ b/crates/api-types/src/mappers/goals.rs @@ -0,0 +1,12 @@ +use crate::GoalDto; + +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(), + } +} diff --git a/crates/api-types/src/mappers/mod.rs b/crates/api-types/src/mappers/mod.rs new file mode 100644 index 00000000..20b918b2 --- /dev/null +++ b/crates/api-types/src/mappers/mod.rs @@ -0,0 +1,10 @@ +//! Pure domain -> DTO mappers (depend only on `domain` and this crate). +mod diary; +mod goals; +mod movies; +mod search; + +pub use diary::*; +pub use goals::*; +pub use movies::*; +pub use search::*; diff --git a/crates/adapters/http-axum/src/mappers/movies.rs b/crates/api-types/src/mappers/movies.rs similarity index 97% rename from crates/adapters/http-axum/src/mappers/movies.rs rename to crates/api-types/src/mappers/movies.rs index 4bca1606..9b6b729d 100644 --- a/crates/adapters/http-axum/src/mappers/movies.rs +++ b/crates/api-types/src/mappers/movies.rs @@ -1,4 +1,4 @@ -use api_types::{DiaryEntryDto, MovieDto, ReviewDto}; +use crate::{DiaryEntryDto, MovieDto, ReviewDto}; use domain::models::{DiaryEntry, Movie, MovieSummary, Review}; pub fn movie_to_dto(movie: &Movie) -> MovieDto { diff --git a/crates/adapters/http-axum/src/mappers/search.rs b/crates/api-types/src/mappers/search.rs similarity index 96% rename from crates/adapters/http-axum/src/mappers/search.rs rename to crates/api-types/src/mappers/search.rs index 874dc755..74ee04ae 100644 --- a/crates/adapters/http-axum/src/mappers/search.rs +++ b/crates/api-types/src/mappers/search.rs @@ -1,4 +1,4 @@ -use api_types::search::PersonDto; +use crate::search::PersonDto; use domain::models::person::Person; pub fn person_to_dto(p: &Person) -> PersonDto { diff --git a/crates/bootstrap/src/server.rs b/crates/bootstrap/src/server.rs index 76302609..dd58922f 100644 --- a/crates/bootstrap/src/server.rs +++ b/crates/bootstrap/src/server.rs @@ -7,7 +7,7 @@ use domain::ports::{DiaryExporter, DocumentParser, EventPublisher}; use domain::value_objects::InstanceIdentity; use export::ExportAdapter; use http_axum::context::AppContext; -use http_axum::{openapi, routes, state::AppState}; +use http_axum::{openapi, router, state::AppState}; use importer::ImporterDocumentParser; use rss::RssAdapter; @@ -140,7 +140,7 @@ async fn assemble( app_ctx, rss_renderer: Arc::new(RssAdapter::new(config.server.base_url.clone())), }; - Ok(openapi::serve(routes::build_router( + Ok(openapi::serve(router::build_router( state, social.ap_router, ))) diff --git a/crates/server/tests/api_test.rs b/crates/server/tests/api_test.rs index ef4c96b1..c7cb1434 100644 --- a/crates/server/tests/api_test.rs +++ b/crates/server/tests/api_test.rs @@ -22,7 +22,7 @@ use domain::{ value_objects::{Email, ExternalMetadataId, PasswordHash, PosterUrl, UserId}, }; use http_axum::context::AppContext; -use http_axum::{routes, state::AppState}; +use http_axum::{router, state::AppState}; use http_body_util::BodyExt; use rss::RssAdapter; use sqlite::{ @@ -477,7 +477,7 @@ async fn test_app_with(auth: impl AuthService + 'static) -> (Router, SqlitePool) rss_renderer: Arc::new(RssAdapter::new("http://localhost:3000".into())), }; - let router = routes::build_router(state, axum::Router::new()); + let router = router::build_router(state, axum::Router::new()); (router, pool) }