const DEFAULT_PAGE_LIMIT: u32 = 5; const RSS_FEED_LIMIT: u32 = 50; pub mod html { use axum::{ extract::{Path, Query, State}, http::{HeaderValue, StatusCode, header::SET_COOKIE}, response::{Html, IntoResponse, Redirect}, Form, }; use chrono::Utc; use uuid::Uuid; use application::{ commands::{DeleteReviewCommand, LoginCommand, RegisterCommand}, ports::{HtmlPageContext, LoginPageData, NewReviewPageData, RegisterPageData}, use_cases::{delete_review, log_review, login as login_uc, register as register_uc}, }; use domain::{errors::DomainError, value_objects::UserId}; use crate::{ dtos::{DiaryQueryParams, ErrorQuery, LoginForm, LogReviewData, LogReviewForm, RegisterForm}, extractors::{OptionalCookieUser, RequiredCookieUser}, state::AppState, }; async fn build_page_context(state: &AppState, user_id: Option) -> HtmlPageContext { let uuid = user_id.as_ref().map(|u| u.value()); let user_email = if let Some(ref id) = user_id { state .app_ctx .user_repository .find_by_id(id) .await .ok() .flatten() .map(|u| u.email().value().to_string()) } else { None }; HtmlPageContext { user_email, user_id: uuid, register_enabled: state.app_ctx.config.allow_registration, rss_url: "/feed.rss".to_string(), } } fn encode_error(msg: &str) -> String { msg.replace(' ', "+") .replace('&', "%26") .replace('=', "%3D") .replace('"', "%22") } fn set_cookie_header(token: &str, max_age: i64) -> (axum::http::HeaderName, HeaderValue) { let val = format!( "token={}; HttpOnly; Path=/; SameSite=Lax; Max-Age={}", token, max_age ); (SET_COOKIE, HeaderValue::from_str(&val).expect("valid cookie")) } pub async fn get_login_page( State(state): State, Query(params): Query, ) -> impl IntoResponse { let ctx = HtmlPageContext { user_email: None, user_id: None, register_enabled: state.app_ctx.config.allow_registration, rss_url: "/feed.rss".to_string(), }; let html = state .html_renderer .render_login_page(LoginPageData { ctx, error: params.error.as_deref(), }) .expect("login template failed"); Html(html) } pub async fn post_login( State(state): State, Form(form): Form, ) -> impl IntoResponse { match login_uc::execute( &state.app_ctx, LoginCommand { email: form.email, password: form.password, }, ) .await { Ok(result) => { let max_age = (result.expires_at - Utc::now()).num_seconds().max(0); let cookie = set_cookie_header(&result.token, max_age); ([cookie], Redirect::to("/")).into_response() } Err(_) => Redirect::to("/login?error=Invalid+credentials").into_response(), } } pub async fn get_logout() -> impl IntoResponse { let cookie = ( SET_COOKIE, HeaderValue::from_static("token=; HttpOnly; Path=/; SameSite=Lax; Max-Age=0"), ); ([cookie], Redirect::to("/")).into_response() } pub async fn get_register_page( State(state): State, Query(params): Query, ) -> impl IntoResponse { if !state.app_ctx.config.allow_registration { return Redirect::to("/").into_response(); } let ctx = HtmlPageContext { user_email: None, user_id: None, register_enabled: true, rss_url: "/feed.rss".to_string(), }; let html = state .html_renderer .render_register_page(RegisterPageData { ctx, error: params.error.as_deref(), }) .expect("register template failed"); Html(html).into_response() } pub async fn post_register( State(state): State, Form(form): Form, ) -> impl IntoResponse { if !state.app_ctx.config.allow_registration { return Redirect::to("/").into_response(); } let email = form.email.clone(); let password = form.password.clone(); match register_uc::execute( &state.app_ctx, RegisterCommand { email: form.email, password: form.password, }, ) .await { Ok(_) => { match login_uc::execute(&state.app_ctx, LoginCommand { email, password }).await { Ok(result) => { let max_age = (result.expires_at - Utc::now()).num_seconds().max(0); let cookie = set_cookie_header(&result.token, max_age); ([cookie], Redirect::to("/")).into_response() } Err(_) => Redirect::to("/login").into_response(), } } Err(e) => { let msg = encode_error(&e.to_string()); Redirect::to(&format!("/register?error={}", msg)).into_response() } } } pub async fn get_new_review_page( RequiredCookieUser(user_id): RequiredCookieUser, State(state): State, Query(params): Query, ) -> impl IntoResponse { let ctx = build_page_context(&state, Some(user_id)).await; let html = state .html_renderer .render_new_review_page(NewReviewPageData { ctx, error: params.error.as_deref(), }) .expect("new_review template failed"); Html(html) } pub async fn post_review( State(state): State, RequiredCookieUser(user_id): RequiredCookieUser, Form(form): Form, ) -> impl IntoResponse { 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, data.into_command(user_id.value())).await { Ok(_) => Redirect::to("/").into_response(), Err(e) => { let msg = encode_error(&e.to_string()); Redirect::to(&format!("/reviews/new?error={}", msg)).into_response() } } } pub async fn post_delete_review( State(state): State, RequiredCookieUser(user_id): RequiredCookieUser, Path(review_id): Path, ) -> impl IntoResponse { let cmd = DeleteReviewCommand { review_id, requesting_user_id: user_id.value(), }; match delete_review::execute(&state.app_ctx, cmd).await { Ok(()) => Redirect::to("/").into_response(), Err(DomainError::NotFound(_)) => StatusCode::NOT_FOUND.into_response(), Err(DomainError::Unauthorized(_)) => StatusCode::FORBIDDEN.into_response(), Err(e) => { tracing::error!("delete_review html error: {:?}", e); StatusCode::INTERNAL_SERVER_ERROR.into_response() } } } pub async fn get_activity_feed( OptionalCookieUser(user_id): OptionalCookieUser, State(state): State, Query(params): Query, ) -> impl IntoResponse { let ctx = build_page_context(&state, user_id).await; let query = application::queries::GetActivityFeedQuery { limit: params.limit, offset: params.offset, }; match application::use_cases::get_activity_feed::execute(&state.app_ctx, query).await { Ok(entries) => { let limit = entries.limit; let offset = entries.offset; let has_more = (offset as u64).saturating_add(limit as u64) < entries.total_count; let data = application::ports::ActivityFeedPageData { ctx, current_offset: offset, has_more, limit, entries, }; match state.html_renderer.render_activity_feed_page(data) { Ok(html) => Html(html).into_response(), Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(), } } Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), } } pub async fn get_users_list( OptionalCookieUser(user_id): OptionalCookieUser, State(state): State, ) -> impl IntoResponse { let ctx = build_page_context(&state, user_id).await; match application::use_cases::get_users::execute(&state.app_ctx, application::queries::GetUsersQuery).await { Ok(users) => { let data = application::ports::UsersPageData { ctx, users }; match state.html_renderer.render_users_page(data) { Ok(html) => Html(html).into_response(), Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(), } } Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), } } pub async fn get_user_profile( OptionalCookieUser(user_id): OptionalCookieUser, State(state): State, Path(profile_user_uuid): Path, Query(params): Query, ) -> impl IntoResponse { let mut ctx = build_page_context(&state, user_id).await; let view = params.view.unwrap_or_else(|| "recent".to_string()); let profile_user = match state.app_ctx.user_repository .find_by_id(&domain::value_objects::UserId::from_uuid(profile_user_uuid)) .await { Ok(Some(u)) => u, Ok(None) => return (StatusCode::NOT_FOUND, "User not found").into_response(), Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), }; let query = application::queries::GetUserProfileQuery { user_id: profile_user_uuid, view: view.clone(), limit: params.limit, offset: params.offset, }; match application::use_cases::get_user_profile::execute(&state.app_ctx, query).await { Ok(profile) => { let (offset, has_more, limit) = profile.entries.as_ref() .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)); ctx.rss_url = format!("/users/{}/feed.rss", profile_user_uuid); let data = application::ports::ProfilePageData { ctx, profile_user_id: profile_user_uuid, profile_user_email: profile_user.email().value().to_string(), stats: profile.stats, view, entries: profile.entries, current_offset: offset, has_more, limit, history: profile.history, trends: profile.trends, }; match state.html_renderer.render_profile_page(data) { Ok(html) => Html(html).into_response(), Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(), } } Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), } } } pub mod posters { use axum::{ extract::{Path, State}, http::{StatusCode, header}, response::IntoResponse, }; use domain::value_objects::PosterPath; use crate::state::AppState; pub async fn get_poster( State(state): State, Path(path): Path, ) -> impl IntoResponse { let poster_path = match PosterPath::new(path) { Ok(p) => p, Err(_) => return StatusCode::BAD_REQUEST.into_response(), }; match state.app_ctx.poster_storage.get_poster(&poster_path).await { Ok(bytes) => { let mime = infer::get(&bytes) .map(|t| t.mime_type()) .unwrap_or("application/octet-stream"); ([(header::CONTENT_TYPE, mime)], bytes).into_response() } Err(_) => StatusCode::NOT_FOUND.into_response(), } } } pub mod rss { use axum::{ extract::{Path, State}, http::header, response::IntoResponse, }; use uuid::Uuid; use application::{queries::GetDiaryQuery, use_cases::get_diary}; use domain::{errors::DomainError, models::SortDirection, value_objects::UserId}; use crate::{errors::ApiError, state::AppState}; pub async fn get_feed(State(state): State) -> Result { let query = GetDiaryQuery { limit: Some(super::RSS_FEED_LIMIT), offset: Some(0), sort_by: Some(SortDirection::Descending), movie_id: None, user_id: None, }; let page = get_diary::execute(&state.app_ctx, query).await?; let xml = state .rss_renderer .render_feed(&page.items, "Movie Diary") .map_err(|e| ApiError(DomainError::InfrastructureError(e)))?; Ok(([(header::CONTENT_TYPE, "application/rss+xml; charset=utf-8")], xml)) } pub async fn get_user_feed( State(state): State, Path(user_id): Path, ) -> Result { let user = state .app_ctx .user_repository .find_by_id(&UserId::from_uuid(user_id)) .await .map_err(ApiError)? .ok_or_else(|| ApiError(DomainError::NotFound(format!("User {user_id}"))))?; let query = GetDiaryQuery { limit: Some(super::RSS_FEED_LIMIT), offset: Some(0), sort_by: Some(SortDirection::Descending), movie_id: None, user_id: Some(user_id), }; let page = get_diary::execute(&state.app_ctx, query).await?; let display_name = user.email().value().split('@').next().unwrap_or("User"); let title = format!("{}'s Movie Diary", display_name); let xml = state .rss_renderer .render_feed(&page.items, &title) .map_err(|e| ApiError(DomainError::InfrastructureError(e)))?; Ok(([(header::CONTENT_TYPE, "application/rss+xml; charset=utf-8")], xml)) } } pub mod api { use axum::{ Json, extract::{Path, Query, State}, http::StatusCode, response::IntoResponse, }; use uuid::Uuid; use application::{ commands::{DeleteReviewCommand, LoginCommand, RegisterCommand, SyncPosterCommand}, queries::GetReviewHistoryQuery, use_cases::{delete_review, get_diary, get_review_history, log_review, login as login_uc, register as register_uc, sync_poster}, }; use domain::{ errors::DomainError, models::{DiaryEntry, Movie, Review}, services::review_history::Trend, value_objects::MovieId, }; use crate::{ dtos::{ DiaryEntryDto, DiaryQueryParams, DiaryResponse, LoginRequest, LoginResponse, LogReviewData, LogReviewRequest, MovieDto, RegisterRequest, ReviewDto, ReviewHistoryResponse, }, errors::ApiError, extractors::AuthenticatedUser, state::AppState, }; pub async fn get_diary( State(state): State, Query(params): Query, ) -> Result, ApiError> { let page = get_diary::execute(&state.app_ctx, params.into()).await?; Ok(Json(DiaryResponse { items: page.items.iter().map(entry_to_dto).collect(), total_count: page.total_count, limit: page.limit, offset: page.offset, })) } pub async fn get_review_history( State(state): State, Path(movie_id): Path, ) -> Result, ApiError> { let (history, trend) = get_review_history::execute( &state.app_ctx, GetReviewHistoryQuery { movie_id }, ) .await?; Ok(Json(ReviewHistoryResponse { movie: movie_to_dto(history.movie()), viewings: history.viewings().iter().map(review_to_dto).collect(), trend: match trend { Trend::Improved => "improved", Trend::Declined => "declined", Trend::Neutral => "neutral", } .to_string(), })) } 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, data.into_command(user.0.value())).await?; Ok(StatusCode::CREATED) } pub async fn sync_poster( State(state): State, _user: AuthenticatedUser, Path(movie_id): Path, ) -> Result { let movie = state .app_ctx .repository .get_movie_by_id(&MovieId::from_uuid(movie_id)) .await? .ok_or_else(|| ApiError(DomainError::NotFound(format!("Movie {movie_id}"))))?; let external_id = movie .external_metadata_id() .ok_or_else(|| { ApiError(DomainError::ValidationError( "Movie has no external metadata ID, cannot sync poster".into(), )) })? .value() .to_string(); sync_poster::execute( &state.app_ctx, SyncPosterCommand { movie_id, external_metadata_id: external_id, }, ) .await?; Ok(StatusCode::NO_CONTENT) } pub async fn login( State(state): State, Json(req): Json, ) -> Result, ApiError> { let result = login_uc::execute(&state.app_ctx, LoginCommand { email: req.email, password: req.password, }) .await?; Ok(Json(LoginResponse { token: result.token, user_id: result.user_id, email: result.email, expires_at: result.expires_at.to_rfc3339(), })) } pub async fn register( State(state): State, Json(req): Json, ) -> Result { register_uc::execute(&state.app_ctx, RegisterCommand { email: req.email, password: req.password, }) .await?; Ok(StatusCode::CREATED) } pub async fn delete_review( State(state): State, AuthenticatedUser(user_id): AuthenticatedUser, Path(review_id): Path, ) -> impl IntoResponse { let cmd = DeleteReviewCommand { review_id, requesting_user_id: user_id.value(), }; match delete_review::execute(&state.app_ctx, cmd).await { Ok(()) => StatusCode::NO_CONTENT.into_response(), Err(DomainError::NotFound(_)) => StatusCode::NOT_FOUND.into_response(), Err(DomainError::Unauthorized(_)) => StatusCode::FORBIDDEN.into_response(), Err(e) => { tracing::error!("delete_review error: {:?}", e); StatusCode::INTERNAL_SERVER_ERROR.into_response() } } } fn movie_to_dto(movie: &Movie) -> MovieDto { MovieDto { id: movie.id().value(), title: movie.title().value().to_string(), release_year: movie.release_year().value(), director: movie.director().map(|d| d.to_string()), poster_path: movie.poster_path().map(|p| p.value().to_string()), } } fn review_to_dto(review: &Review) -> ReviewDto { ReviewDto { id: review.id().value(), rating: review.rating().value(), comment: review.comment().map(|c| c.value().to_string()), watched_at: review.watched_at().to_string(), } } fn entry_to_dto(entry: &DiaryEntry) -> DiaryEntryDto { DiaryEntryDto { movie: movie_to_dto(entry.movie()), review: review_to_dto(entry.review()), } } }