refactor: http-axum split into api/html/federation modules, router.rs, templates per page group
This commit is contained in:
@@ -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]
|
||||
|
||||
101
crates/adapters/http-axum/src/api/auth.rs
Normal file
101
crates/adapters/http-axum/src/api/auth.rs
Normal file
@@ -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<AppState>,
|
||||
Json(req): Json<LoginRequest>,
|
||||
) -> Result<Json<LoginResponse>, ApiError> {
|
||||
let result = login_uc::execute(
|
||||
&state.app_ctx.deps.auth.login,
|
||||
LoginCommand {
|
||||
email: req.email,
|
||||
password: req.password,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(LoginResponse {
|
||||
token: result.token,
|
||||
refresh_token: result.refresh_token,
|
||||
user_id: result.user_id,
|
||||
email: result.email,
|
||||
expires_at: result.expires_at.to_rfc3339(),
|
||||
role: result.role,
|
||||
}))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post, path = "/api/v1/auth/register",
|
||||
request_body = RegisterRequest,
|
||||
responses(
|
||||
(status = 201, description = "User registered"),
|
||||
(status = 400, description = "Invalid input"),
|
||||
)
|
||||
)]
|
||||
pub async fn register(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<RegisterRequest>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
register_uc::execute(
|
||||
&state.app_ctx.deps.auth.register,
|
||||
RegisterCommand {
|
||||
email: req.email,
|
||||
username: req.username,
|
||||
password: req.password,
|
||||
role: domain::models::UserRole::Standard,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
Ok(StatusCode::CREATED)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post, path = "/api/v1/auth/refresh",
|
||||
request_body = RefreshRequest,
|
||||
responses(
|
||||
(status = 200, body = RefreshResponse),
|
||||
(status = 401, description = "Invalid or expired refresh token"),
|
||||
)
|
||||
)]
|
||||
pub async fn refresh(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<RefreshRequest>,
|
||||
) -> Result<Json<RefreshResponse>, ApiError> {
|
||||
let result =
|
||||
application::auth::refresh::execute(&state.app_ctx.deps.auth.refresh, &req.refresh_token)
|
||||
.await?;
|
||||
Ok(Json(RefreshResponse {
|
||||
token: result.token,
|
||||
refresh_token: result.refresh_token,
|
||||
expires_at: result.expires_at.to_rfc3339(),
|
||||
}))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post, path = "/api/v1/auth/logout",
|
||||
request_body = LogoutRequest,
|
||||
responses(
|
||||
(status = 204, description = "Logged out"),
|
||||
)
|
||||
)]
|
||||
pub async fn api_logout(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<LogoutRequest>,
|
||||
) -> StatusCode {
|
||||
let _ = application::auth::logout::execute(&state.app_ctx.deps.auth.logout, &req.refresh_token)
|
||||
.await;
|
||||
StatusCode::NO_CONTENT
|
||||
}
|
||||
191
crates/adapters/http-axum/src/api/diary.rs
Normal file
191
crates/adapters/http-axum/src/api/diary.rs
Normal file
@@ -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<AppState>,
|
||||
Query(params): Query<DiaryQueryParams>,
|
||||
) -> Result<Json<DiaryResponse>, ApiError> {
|
||||
let page =
|
||||
get_diary::execute(&state.app_ctx.deps.diary.get_diary, to_diary_query(params)).await?;
|
||||
|
||||
Ok(Json(DiaryResponse {
|
||||
items: page
|
||||
.items
|
||||
.iter()
|
||||
.map(api_types::mappers::entry_to_dto)
|
||||
.collect(),
|
||||
total_count: page.total_count,
|
||||
limit: page.limit,
|
||||
offset: page.offset,
|
||||
}))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post, path = "/api/v1/reviews",
|
||||
request_body = LogReviewRequest,
|
||||
responses(
|
||||
(status = 201, description = "Review created"),
|
||||
(status = 400, description = "Invalid input"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn post_review(
|
||||
State(state): State<AppState>,
|
||||
user: AuthenticatedUser,
|
||||
Json(req): Json<LogReviewRequest>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let data = LogReviewData::try_from(req).map_err(ApiError)?;
|
||||
log_review::execute(
|
||||
&state.app_ctx.services.review_logger,
|
||||
data.into_command(user.0.value()),
|
||||
)
|
||||
.await?;
|
||||
Ok(StatusCode::CREATED)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
delete, path = "/api/v1/reviews/{id}",
|
||||
params(("id" = Uuid, Path, description = "Review ID")),
|
||||
responses(
|
||||
(status = 204, description = "Review deleted"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 403, description = "Forbidden"),
|
||||
(status = 404, description = "Review not found"),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn delete_review(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
Path(review_id): Path<Uuid>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
let cmd = DeleteReviewCommand {
|
||||
review_id,
|
||||
requesting_user_id: user_id.value(),
|
||||
};
|
||||
delete_review::execute(&state.app_ctx.deps.diary.delete_review, cmd).await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
patch, path = "/api/v1/reviews/{id}",
|
||||
request_body = EditReviewRequest,
|
||||
params(("id" = Uuid, Path, description = "Review ID")),
|
||||
responses(
|
||||
(status = 200, description = "Review updated"),
|
||||
(status = 400, description = "Invalid input"),
|
||||
(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<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
Path(review_id): Path<Uuid>,
|
||||
Json(req): Json<EditReviewRequest>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
let watched_at = req
|
||||
.watched_at
|
||||
.map(|s| domain::value_objects::parse_watched_at(&s).map_err(ApiError))
|
||||
.transpose()?;
|
||||
|
||||
let cmd = EditReviewCommand {
|
||||
review_id,
|
||||
requesting_user_id: user_id.value(),
|
||||
rating: req.rating,
|
||||
comment: req.comment,
|
||||
watched_at,
|
||||
watch_medium: req.watch_medium,
|
||||
spoilers: req.spoilers,
|
||||
};
|
||||
edit_review::execute(&state.app_ctx.deps.diary.edit_review, cmd).await?;
|
||||
Ok(StatusCode::OK)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get, path = "/api/v1/diary/export",
|
||||
params(ExportQueryParams),
|
||||
responses(
|
||||
(status = 200, description = "Diary file download", content_type = "text/csv"),
|
||||
(status = 400, description = "Invalid format parameter"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn export_diary(
|
||||
State(state): State<AppState>,
|
||||
user: AuthenticatedUser,
|
||||
Query(params): Query<ExportQueryParams>,
|
||||
) -> 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<AppState>,
|
||||
Query(params): Query<ActivityFeedQueryParams>,
|
||||
) -> Result<Json<ActivityFeedResponse>, ApiError> {
|
||||
let page = get_feed_uc::execute(
|
||||
&state.app_ctx.deps.diary.get_activity_feed,
|
||||
GetActivityFeedQuery {
|
||||
limit: params.limit.unwrap_or(20),
|
||||
offset: params.offset.unwrap_or(0),
|
||||
sort_by: params
|
||||
.sort_by
|
||||
.as_deref()
|
||||
.map(|s| s.parse().unwrap_or_default())
|
||||
.unwrap_or_default(),
|
||||
search: None,
|
||||
viewer_user_id: None,
|
||||
filter_following: false,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(ActivityFeedResponse {
|
||||
items: page
|
||||
.items
|
||||
.iter()
|
||||
.map(api_types::mappers::feed_entry_to_dto)
|
||||
.collect(),
|
||||
total_count: page.total_count,
|
||||
limit: page.limit,
|
||||
offset: page.offset,
|
||||
}))
|
||||
}
|
||||
@@ -1,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(
|
||||
5
crates/adapters/http-axum/src/api/import/mod.rs
Normal file
5
crates/adapters/http-axum/src/api/import/mod.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
mod profiles;
|
||||
mod sessions;
|
||||
|
||||
pub use profiles::*;
|
||||
pub use sessions::*;
|
||||
105
crates/adapters/http-axum/src/api/import/profiles.rs
Normal file
105
crates/adapters/http-axum/src/api/import/profiles.rs
Normal file
@@ -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<serde_json::Value>)),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn api_get_profiles(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let profiles =
|
||||
list_import_profiles::execute(&state.app_ctx.deps.import.list_profiles, &user_id).await?;
|
||||
Ok(axum::Json(
|
||||
profiles
|
||||
.into_iter()
|
||||
.map(|p| {
|
||||
serde_json::json!({
|
||||
"id": p.id.value().to_string(),
|
||||
"name": p.name,
|
||||
"created_at": p.created_at.to_string(),
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post, path = "/api/v1/import/profiles",
|
||||
request_body = SaveProfileRequest,
|
||||
responses(
|
||||
(status = 200, description = "Profile saved", body = inline(serde_json::Value)),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 422, description = "Session has no mapping yet"),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn api_post_profile(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
axum::Json(body): axum::Json<SaveProfileRequest>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let session_id = body
|
||||
.session_id
|
||||
.parse::<uuid::Uuid>()
|
||||
.map(ImportSessionId::from_uuid)
|
||||
.map_err(|_| DomainError::ValidationError("invalid session id".into()))?;
|
||||
let id = save_import_profile::execute(
|
||||
&state.app_ctx.deps.import.save_profile,
|
||||
SaveImportProfileCommand {
|
||||
user_id: user_id.value(),
|
||||
session_id: session_id.value(),
|
||||
name: body.name,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
Ok(axum::Json(
|
||||
serde_json::json!({"id": id.value().to_string()}),
|
||||
))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
delete, path = "/api/v1/import/profiles/{id}",
|
||||
params(("id" = String, Path, description = "Import profile UUID")),
|
||||
responses(
|
||||
(status = 204, description = "Deleted"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 404, description = "Profile not found"),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn api_delete_profile(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
Path(profile_id_str): Path<String>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let profile_id = profile_id_str
|
||||
.parse::<uuid::Uuid>()
|
||||
.map_err(|_| DomainError::ValidationError("invalid profile id".into()))?;
|
||||
delete_import_profile::execute(
|
||||
&state.app_ctx.deps.import.delete_profile,
|
||||
DeleteImportProfileCommand {
|
||||
user_id: user_id.value(),
|
||||
profile_id,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
309
crates/adapters/http-axum/src/api/import/sessions.rs
Normal file
309
crates/adapters/http-axum/src/api/import/sessions.rs
Normal file
@@ -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<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
mut multipart: Multipart,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let mut file_bytes: Option<Vec<u8>> = None;
|
||||
let mut format_str = "csv".to_string();
|
||||
while let Ok(Some(field)) = multipart.next_field().await {
|
||||
match field.name() {
|
||||
Some("file") => {
|
||||
if let Ok(b) = field.bytes().await {
|
||||
file_bytes = Some(b.to_vec());
|
||||
}
|
||||
}
|
||||
Some("format") => {
|
||||
if let Ok(t) = field.text().await {
|
||||
format_str = t;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let bytes = match file_bytes {
|
||||
Some(b) if !b.is_empty() => b,
|
||||
_ => return Err(DomainError::ValidationError("no file".into()).into()),
|
||||
};
|
||||
let format = match format_str.as_str() {
|
||||
"json" => FileFormat::Json,
|
||||
"xlsx" => FileFormat::Xlsx,
|
||||
_ => FileFormat::Csv,
|
||||
};
|
||||
let r = create_import_session::execute(
|
||||
&state.app_ctx.deps.import.create_session,
|
||||
CreateImportSessionCommand {
|
||||
user_id: user_id.value(),
|
||||
bytes,
|
||||
format,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
Ok(axum::Json(SessionCreatedResponse {
|
||||
session_id: r.session_id.value().to_string(),
|
||||
columns: r.columns,
|
||||
sample_rows: r.sample_rows,
|
||||
}))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get, path = "/api/v1/import/sessions/{id}",
|
||||
params(("id" = String, Path, description = "Import session UUID")),
|
||||
responses(
|
||||
(status = 200, body = SessionStateResponse),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 404, description = "Session not found"),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn api_get_session(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
Path(session_id_str): Path<String>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let session_id = session_id_str
|
||||
.parse::<uuid::Uuid>()
|
||||
.map(ImportSessionId::from_uuid)
|
||||
.map_err(|_| DomainError::ValidationError("invalid session id".into()))?;
|
||||
let state_data = get_session_state::execute(
|
||||
&state.app_ctx.deps.import.get_session_state,
|
||||
session_id,
|
||||
user_id.value(),
|
||||
)
|
||||
.await?;
|
||||
Ok(axum::Json(SessionStateResponse {
|
||||
session_id: session_id_str,
|
||||
columns: state_data.columns,
|
||||
has_mappings: state_data.has_mappings,
|
||||
row_count: state_data.row_count,
|
||||
}))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
put, path = "/api/v1/import/sessions/{id}/mapping",
|
||||
params(("id" = String, Path, description = "Import session UUID")),
|
||||
request_body = ApplyMappingRequest,
|
||||
responses(
|
||||
(status = 200, description = "Mapping applied", body = inline(serde_json::Value)),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 422, description = "Mapping error"),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn api_put_mapping(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
Path(session_id_str): Path<String>,
|
||||
axum::Json(body): axum::Json<ApplyMappingRequest>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let session_id = session_id_str
|
||||
.parse::<uuid::Uuid>()
|
||||
.map(ImportSessionId::from_uuid)
|
||||
.map_err(|_| DomainError::ValidationError("invalid session id".into()))?;
|
||||
let mappings: Vec<FieldMapping> = body
|
||||
.mappings
|
||||
.into_iter()
|
||||
.filter_map(|m| {
|
||||
let domain_field = str_to_domain_field(&m.domain_field)?;
|
||||
let transform = if domain_field == DomainField::Rating {
|
||||
Transform::RatingScale(m.rating_scale.unwrap_or(1.0))
|
||||
} else if domain_field == DomainField::WatchedAt {
|
||||
m.date_format
|
||||
.map(Transform::DateFormat)
|
||||
.unwrap_or(Transform::Identity)
|
||||
} else {
|
||||
Transform::Identity
|
||||
};
|
||||
Some(FieldMapping {
|
||||
source_column: m.source_column,
|
||||
domain_field,
|
||||
transform,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
let rows = apply_import_mapping::execute(
|
||||
&state.app_ctx.deps.import.apply_mapping,
|
||||
ApplyImportMappingCommand {
|
||||
user_id: user_id.value(),
|
||||
session_id: session_id.value(),
|
||||
mappings,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
Ok(axum::Json(serde_json::json!({"row_count": rows.len()})))
|
||||
}
|
||||
|
||||
pub async fn api_get_preview(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
Path(session_id_str): Path<String>,
|
||||
) -> Result<axum::Json<ImportPreviewResponse>, ApiError> {
|
||||
let session_id = session_id_str
|
||||
.parse::<uuid::Uuid>()
|
||||
.map(ImportSessionId::from_uuid)
|
||||
.map_err(|_| DomainError::ValidationError("invalid session id".into()))?;
|
||||
|
||||
let stage = get_preview_stage::execute(
|
||||
&state.app_ctx.deps.import.get_preview_stage,
|
||||
session_id,
|
||||
user_id.value(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Not-yet-mapped sessions render as an empty preview here (200, `rows: []`),
|
||||
// matching this endpoint's behavior before the stage-gate moved into
|
||||
// `get_preview_stage` — unlike the HTML handler, this API route never
|
||||
// redirected on the same condition, so its response shape is unchanged.
|
||||
let annotated: Vec<AnnotatedRow> = match stage {
|
||||
get_preview_stage::PreviewStage::Ready(preview) => preview.rows,
|
||||
get_preview_stage::PreviewStage::NotYetMapped => Vec::new(),
|
||||
};
|
||||
let rows = annotated
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, a)| {
|
||||
use domain::models::import::RowResult;
|
||||
match &a.result {
|
||||
RowResult::Valid(row) if a.is_duplicate => {
|
||||
PreviewRowDto::Duplicate(api_types::PreviewRowData {
|
||||
index: i,
|
||||
title: row.title.clone(),
|
||||
release_year: row.release_year.clone(),
|
||||
director: row.director.clone(),
|
||||
rating: row.rating.clone(),
|
||||
watched_at: row.watched_at.clone(),
|
||||
comment: row.comment.clone(),
|
||||
})
|
||||
}
|
||||
RowResult::Valid(row) => PreviewRowDto::Valid(api_types::PreviewRowData {
|
||||
index: i,
|
||||
title: row.title.clone(),
|
||||
release_year: row.release_year.clone(),
|
||||
director: row.director.clone(),
|
||||
rating: row.rating.clone(),
|
||||
watched_at: row.watched_at.clone(),
|
||||
comment: row.comment.clone(),
|
||||
}),
|
||||
RowResult::Invalid { errors, .. } => PreviewRowDto::Invalid {
|
||||
index: i,
|
||||
errors: errors.clone(),
|
||||
},
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(axum::Json(ImportPreviewResponse { rows }))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post, path = "/api/v1/import/sessions/{id}/confirm",
|
||||
params(("id" = String, Path, description = "Import session UUID")),
|
||||
request_body = ConfirmRequest,
|
||||
responses(
|
||||
(status = 200, description = "Import summary", body = inline(serde_json::Value)),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 404, description = "Session not found"),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn api_post_confirm(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
Path(session_id_str): Path<String>,
|
||||
axum::Json(body): axum::Json<ConfirmRequest>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let session_id = session_id_str
|
||||
.parse::<uuid::Uuid>()
|
||||
.map(ImportSessionId::from_uuid)
|
||||
.map_err(|_| DomainError::ValidationError("invalid session id".into()))?;
|
||||
let s = execute_import::execute(
|
||||
&state.app_ctx.deps.import.execute_import,
|
||||
ExecuteImportCommand {
|
||||
user_id: user_id.value(),
|
||||
session_id: session_id.value(),
|
||||
confirmed_indices: body.confirmed_indices,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
Ok(axum::Json(serde_json::json!({
|
||||
"imported": s.imported,
|
||||
"skipped_duplicates": s.skipped_duplicates,
|
||||
"failed": s.failed.iter().map(|(i, e)| serde_json::json!({"index": i, "error": e})).collect::<Vec<_>>(),
|
||||
})))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
put, path = "/api/v1/import/sessions/{id}/profile/{profile_id}",
|
||||
params(
|
||||
("id" = String, Path, description = "Import session UUID"),
|
||||
("profile_id" = String, Path, description = "Import profile UUID"),
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Profile applied and mapping regenerated", body = inline(serde_json::Value)),
|
||||
(status = 400, description = "Invalid ID"),
|
||||
(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<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
Path((session_id_str, profile_id_str)): Path<(String, String)>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let session_id = session_id_str
|
||||
.parse::<uuid::Uuid>()
|
||||
.map_err(|_| DomainError::ValidationError("invalid session id".into()))?;
|
||||
let profile_id = profile_id_str
|
||||
.parse::<uuid::Uuid>()
|
||||
.map_err(|_| DomainError::ValidationError("invalid profile id".into()))?;
|
||||
|
||||
let rows = apply_profile_and_map::execute(
|
||||
&state.app_ctx.deps.import.apply_profile_and_map,
|
||||
ApplyProfileAndMapCommand {
|
||||
user_id: user_id.value(),
|
||||
session_id,
|
||||
profile_id,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
Ok(axum::Json(serde_json::json!({"row_count": rows.len()})))
|
||||
}
|
||||
@@ -1,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 {
|
||||
201
crates/adapters/http-axum/src/api/mod.rs
Normal file
201
crates/adapters/http-axum/src/api/mod.rs
Normal file
@@ -0,0 +1,201 @@
|
||||
pub mod auth;
|
||||
pub mod diary;
|
||||
pub mod goals;
|
||||
pub mod import;
|
||||
pub mod insights;
|
||||
pub mod movies;
|
||||
pub mod search;
|
||||
pub mod social;
|
||||
pub mod user_profile;
|
||||
pub mod users;
|
||||
pub mod watchlist;
|
||||
pub mod webhook;
|
||||
pub mod wrapup;
|
||||
|
||||
use axum::{Router, routing};
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
/// 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<AppState> {
|
||||
let base = Router::new()
|
||||
.route("/diary", routing::get(diary::get_diary))
|
||||
.route(
|
||||
"/movies/{id}/history",
|
||||
routing::get(movies::get_review_history),
|
||||
)
|
||||
.route("/movies", routing::get(movies::list_movies))
|
||||
.route("/movies/{id}", routing::get(movies::get_movie_detail))
|
||||
.route(
|
||||
"/movies/{id}/profile",
|
||||
routing::get(movies::get_movie_profile),
|
||||
)
|
||||
.route("/reviews", routing::post(diary::post_review))
|
||||
.route(
|
||||
"/reviews/{id}",
|
||||
routing::delete(diary::delete_review).patch(diary::patch_review),
|
||||
)
|
||||
.route(
|
||||
"/movies/{id}/sync-poster",
|
||||
routing::post(movies::sync_poster),
|
||||
)
|
||||
.route("/auth/login", routing::post(auth::login))
|
||||
.route("/auth/register", routing::post(auth::register))
|
||||
.route("/auth/refresh", routing::post(auth::refresh))
|
||||
.route("/auth/logout", routing::post(auth::api_logout))
|
||||
.route("/diary/export", routing::get(diary::export_diary))
|
||||
.route("/activity-feed", routing::get(diary::get_activity_feed))
|
||||
.route("/users", routing::get(users::list_users))
|
||||
.route("/users/{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<AppState> {
|
||||
Router::new()
|
||||
.route(
|
||||
"/webhooks/jellyfin",
|
||||
routing::post(webhook::post_jellyfin_webhook),
|
||||
)
|
||||
.route("/webhooks/plex", routing::post(webhook::post_plex_webhook))
|
||||
}
|
||||
|
||||
fn social_routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/social/following", routing::get(social::get_following))
|
||||
.route("/social/followers", routing::get(social::get_followers))
|
||||
.route(
|
||||
"/social/followers/pending",
|
||||
routing::get(social::get_pending_followers),
|
||||
)
|
||||
.route(
|
||||
"/social/followers/pending/count",
|
||||
routing::get(social::get_pending_follower_count),
|
||||
)
|
||||
.route(
|
||||
"/social/following/pending",
|
||||
routing::get(social::get_pending_following),
|
||||
)
|
||||
.route(
|
||||
"/social/relationship",
|
||||
routing::get(social::get_relationship),
|
||||
)
|
||||
.route("/social/follow", routing::post(social::follow))
|
||||
.route("/social/unfollow", routing::post(social::unfollow))
|
||||
.route(
|
||||
"/social/followers/accept",
|
||||
routing::post(social::accept_follower),
|
||||
)
|
||||
.route(
|
||||
"/social/followers/reject",
|
||||
routing::post(social::reject_follower),
|
||||
)
|
||||
.route(
|
||||
"/social/followers/remove",
|
||||
routing::post(social::remove_follower),
|
||||
)
|
||||
.route(
|
||||
"/users/{id}/following",
|
||||
routing::get(social::get_user_following),
|
||||
)
|
||||
.route(
|
||||
"/users/{id}/followers",
|
||||
routing::get(social::get_user_followers),
|
||||
)
|
||||
}
|
||||
@@ -1,11 +1,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<AppState>,
|
||||
Path(movie_id): Path<uuid::Uuid>,
|
||||
Query(params): Query<api_types::PaginationQueryParams>,
|
||||
Extension(csrf): Extension<CsrfToken>,
|
||||
) -> impl IntoResponse {
|
||||
let ctx = build_page_context(&state, user_id.clone(), csrf.0).await;
|
||||
let limit = params.limit.unwrap_or(20);
|
||||
let offset = params.offset.unwrap_or(0);
|
||||
|
||||
match get_movie_social_page::execute(
|
||||
&state.app_ctx.deps.diary.get_movie_social_page,
|
||||
GetMovieSocialPageQuery {
|
||||
movie_id,
|
||||
limit,
|
||||
offset,
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
Err(e) => crate::errors::domain_error_response(e),
|
||||
Ok(result) => {
|
||||
let histogram_max = result
|
||||
.stats
|
||||
.rating_histogram
|
||||
.iter()
|
||||
.copied()
|
||||
.max()
|
||||
.unwrap_or(1);
|
||||
let has_more =
|
||||
result.reviews.offset + result.reviews.limit < result.reviews.total_count as u32;
|
||||
let on_watchlist = match &user_id {
|
||||
Some(uid) => is_on_watchlist::execute(
|
||||
&state.app_ctx.deps.watchlist.is_on_watchlist,
|
||||
IsOnWatchlistQuery {
|
||||
user_id: uid.value(),
|
||||
movie_id,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap_or(false),
|
||||
None => false,
|
||||
};
|
||||
let current_offset = result.reviews.offset;
|
||||
let reviews_limit = result.reviews.limit;
|
||||
render_page(MovieDetailTemplate {
|
||||
ctx: &ctx,
|
||||
movie: &result.movie,
|
||||
stats: &result.stats,
|
||||
profile: result.profile.as_ref(),
|
||||
reviews: result.reviews.items.as_slice(),
|
||||
on_watchlist,
|
||||
current_offset,
|
||||
has_more,
|
||||
limit: reviews_limit,
|
||||
histogram_max,
|
||||
})
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<String>) -> impl IntoResponse {
|
||||
if tag.eq_ignore_ascii_case("moviesdiary") {
|
||||
Redirect::temporary("/")
|
||||
} else {
|
||||
Redirect::temporary(&format!("/?search={}", tag))
|
||||
}
|
||||
}
|
||||
348
crates/adapters/http-axum/src/api/social.rs
Normal file
348
crates/adapters/http-axum/src/api/social.rs
Normal file
@@ -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<AppState>,
|
||||
user: AuthenticatedUser,
|
||||
) -> Result<Json<ActorListResponse>, ApiError> {
|
||||
let instance = state.app_ctx.instance.clone();
|
||||
let identities = application::social::get_following::execute(
|
||||
&state.app_ctx.deps.social.query,
|
||||
user.0.value(),
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(ActorListResponse {
|
||||
actors: identities
|
||||
.into_iter()
|
||||
.map(|a| social_actor_to_dto(a, &instance))
|
||||
.collect(),
|
||||
}))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get, path = "/api/v1/social/followers",
|
||||
responses(
|
||||
(status = 200, body = ActorListResponse),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn get_followers(
|
||||
State(state): State<AppState>,
|
||||
user: AuthenticatedUser,
|
||||
) -> Result<Json<ActorListResponse>, ApiError> {
|
||||
let instance = state.app_ctx.instance.clone();
|
||||
let identities = application::social::get_followers::execute(
|
||||
&state.app_ctx.deps.social.query,
|
||||
user.0.value(),
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(ActorListResponse {
|
||||
actors: identities
|
||||
.into_iter()
|
||||
.map(|a| social_actor_to_dto(a, &instance))
|
||||
.collect(),
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn get_user_following(
|
||||
State(state): State<AppState>,
|
||||
_user: AuthenticatedUser,
|
||||
Path(user_id): Path<Uuid>,
|
||||
) -> Result<Json<ActorListResponse>, ApiError> {
|
||||
let instance = state.app_ctx.instance.clone();
|
||||
let identities =
|
||||
application::social::get_following::execute(&state.app_ctx.deps.social.query, user_id)
|
||||
.await?;
|
||||
Ok(Json(ActorListResponse {
|
||||
actors: identities
|
||||
.into_iter()
|
||||
.map(|a| social_actor_to_dto(a, &instance))
|
||||
.collect(),
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn get_user_followers(
|
||||
State(state): State<AppState>,
|
||||
_user: AuthenticatedUser,
|
||||
Path(user_id): Path<Uuid>,
|
||||
) -> Result<Json<ActorListResponse>, ApiError> {
|
||||
let instance = state.app_ctx.instance.clone();
|
||||
let identities =
|
||||
application::social::get_followers::execute(&state.app_ctx.deps.social.query, user_id)
|
||||
.await?;
|
||||
Ok(Json(ActorListResponse {
|
||||
actors: identities
|
||||
.into_iter()
|
||||
.map(|a| social_actor_to_dto(a, &instance))
|
||||
.collect(),
|
||||
}))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post, path = "/api/v1/social/follow",
|
||||
request_body = FollowRequest,
|
||||
responses(
|
||||
(status = 200, description = "Follow request sent"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn follow(
|
||||
State(state): State<AppState>,
|
||||
user: AuthenticatedUser,
|
||||
Json(body): Json<FollowRequest>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
application::social::execute::execute_command(
|
||||
&state.app_ctx.deps.social.command,
|
||||
application::social::commands::SocialCmd::Follow {
|
||||
follower_id: user.0.value(),
|
||||
target: FollowTarget::Handle(body.handle),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
Ok(StatusCode::OK)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post, path = "/api/v1/social/unfollow",
|
||||
request_body = ActorUrlRequest,
|
||||
responses(
|
||||
(status = 200, description = "Unfollowed"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn unfollow(
|
||||
State(state): State<AppState>,
|
||||
user: AuthenticatedUser,
|
||||
Json(body): Json<ActorUrlRequest>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let instance = state.app_ctx.instance.clone();
|
||||
application::social::execute::execute_command(
|
||||
&state.app_ctx.deps.social.command,
|
||||
application::social::commands::SocialCmd::Unfollow {
|
||||
follower_id: user.0.value(),
|
||||
target: instance.identify(&body.actor_url),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
Ok(StatusCode::OK)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post, path = "/api/v1/social/followers/accept",
|
||||
request_body = ActorUrlRequest,
|
||||
responses(
|
||||
(status = 200, description = "Follower accepted"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn accept_follower(
|
||||
State(state): State<AppState>,
|
||||
user: AuthenticatedUser,
|
||||
Json(body): Json<ActorUrlRequest>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let instance = state.app_ctx.instance.clone();
|
||||
application::social::execute::execute_command(
|
||||
&state.app_ctx.deps.social.command,
|
||||
application::social::commands::SocialCmd::AcceptFollow {
|
||||
owner_id: user.0.value(),
|
||||
requester: instance.identify(&body.actor_url),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
Ok(StatusCode::OK)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post, path = "/api/v1/social/followers/reject",
|
||||
request_body = ActorUrlRequest,
|
||||
responses(
|
||||
(status = 200, description = "Follower rejected"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn reject_follower(
|
||||
State(state): State<AppState>,
|
||||
user: AuthenticatedUser,
|
||||
Json(body): Json<ActorUrlRequest>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let instance = state.app_ctx.instance.clone();
|
||||
application::social::execute::execute_command(
|
||||
&state.app_ctx.deps.social.command,
|
||||
application::social::commands::SocialCmd::RejectFollow {
|
||||
owner_id: user.0.value(),
|
||||
requester: instance.identify(&body.actor_url),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
Ok(StatusCode::OK)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post, path = "/api/v1/social/followers/remove",
|
||||
request_body = ActorUrlRequest,
|
||||
responses(
|
||||
(status = 200, description = "Follower removed"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn remove_follower(
|
||||
State(state): State<AppState>,
|
||||
user: AuthenticatedUser,
|
||||
Json(body): Json<ActorUrlRequest>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let instance = state.app_ctx.instance.clone();
|
||||
application::social::execute::execute_command(
|
||||
&state.app_ctx.deps.social.command,
|
||||
application::social::commands::SocialCmd::RemoveFollower {
|
||||
owner_id: user.0.value(),
|
||||
follower: instance.identify(&body.actor_url),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
Ok(StatusCode::OK)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get, path = "/api/v1/social/followers/pending",
|
||||
responses(
|
||||
(status = 200, body = ActorListResponse),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn get_pending_followers(
|
||||
State(state): State<AppState>,
|
||||
user: AuthenticatedUser,
|
||||
) -> Result<Json<ActorListResponse>, ApiError> {
|
||||
let instance = state.app_ctx.instance.clone();
|
||||
let identities = application::social::get_pending_followers::execute(
|
||||
&state.app_ctx.deps.social.query,
|
||||
user.0.value(),
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(ActorListResponse {
|
||||
actors: identities
|
||||
.into_iter()
|
||||
.map(|a| social_actor_to_dto(a, &instance))
|
||||
.collect(),
|
||||
}))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get, path = "/api/v1/social/followers/pending/count",
|
||||
responses(
|
||||
(status = 200, body = PendingCountResponse),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn get_pending_follower_count(
|
||||
State(state): State<AppState>,
|
||||
user: AuthenticatedUser,
|
||||
) -> Result<Json<PendingCountResponse>, ApiError> {
|
||||
let count = application::social::count_pending_followers::execute(
|
||||
&state.app_ctx.deps.social.query,
|
||||
user.0.value(),
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(PendingCountResponse { count }))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get, path = "/api/v1/social/following/pending",
|
||||
responses(
|
||||
(status = 200, body = ActorListResponse),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn get_pending_following(
|
||||
State(state): State<AppState>,
|
||||
user: AuthenticatedUser,
|
||||
) -> Result<Json<ActorListResponse>, ApiError> {
|
||||
let instance = state.app_ctx.instance.clone();
|
||||
let actors = application::social::get_pending_following::execute(
|
||||
&state.app_ctx.deps.social.query,
|
||||
user.0.value(),
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(ActorListResponse {
|
||||
actors: actors
|
||||
.into_iter()
|
||||
.map(|a| social_actor_to_dto(a, &instance))
|
||||
.collect(),
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct RelationshipQuery {
|
||||
pub actor_url: String,
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get, path = "/api/v1/social/relationship",
|
||||
params(("actor_url" = String, Query, description = "Canonical actor URL of the target")),
|
||||
responses(
|
||||
(status = 200, body = FollowRelationResponse),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn get_relationship(
|
||||
State(state): State<AppState>,
|
||||
user: AuthenticatedUser,
|
||||
Query(q): Query<RelationshipQuery>,
|
||||
) -> Result<Json<FollowRelationResponse>, ApiError> {
|
||||
let target = state.app_ctx.instance.identify(&q.actor_url);
|
||||
let rel = application::social::get_relation::execute(
|
||||
&state.app_ctx.deps.social.query,
|
||||
user.0.value(),
|
||||
target,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(FollowRelationResponse {
|
||||
following: rel.following.into(),
|
||||
followed_by: rel.followed_by.into(),
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -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");
|
||||
279
crates/adapters/http-axum/src/api/user_profile.rs
Normal file
279
crates/adapters/http-axum/src/api/user_profile.rs
Normal file
@@ -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<AppState>,
|
||||
AuthenticatedUser(viewer_id): AuthenticatedUser,
|
||||
Path(user_id): Path<Uuid>,
|
||||
Query(params): Query<UserProfileQueryParams>,
|
||||
) -> impl IntoResponse {
|
||||
let view_str = params.view.as_deref().unwrap_or("recent");
|
||||
let profile_view = match application::users::queries::ProfileView::from_str(view_str) {
|
||||
Ok(v) => v,
|
||||
Err(_) => return 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(),
|
||||
}
|
||||
}
|
||||
152
crates/adapters/http-axum/src/api/users.rs
Normal file
152
crates/adapters/http-axum/src/api/users.rs
Normal file
@@ -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<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
) -> Result<Json<ProfileResponse>, ApiError> {
|
||||
let profile = application::users::get_current_profile::execute(
|
||||
&state.app_ctx.deps.users.get_current_profile,
|
||||
application::users::queries::GetCurrentProfileQuery {
|
||||
user_id: user_id.value(),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
let instance = &state.app_ctx.instance;
|
||||
Ok(Json(ProfileResponse {
|
||||
profile: api_types::UserProfileBase {
|
||||
username: profile.username,
|
||||
display_name: profile.display_name,
|
||||
bio: profile.bio,
|
||||
avatar_url: profile.avatar_path.map(|p| instance.image_url_for(&p)),
|
||||
banner_url: profile.banner_path.map(|p| instance.image_url_for(&p)),
|
||||
},
|
||||
also_known_as: profile.also_known_as,
|
||||
fields: profile
|
||||
.fields
|
||||
.into_iter()
|
||||
.map(|f| api_types::ProfileFieldDto {
|
||||
name: f.name,
|
||||
value: f.value,
|
||||
})
|
||||
.collect(),
|
||||
role: profile.role,
|
||||
}))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
put, path = "/api/v1/profile",
|
||||
responses(
|
||||
(status = 204, description = "Profile updated"),
|
||||
(status = 400, description = "Invalid input"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 500, description = "Internal server error"),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn update_profile_handler(
|
||||
State(state): State<AppState>,
|
||||
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<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
axum::Json(body): axum::Json<serde_json::Value>,
|
||||
) -> 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<domain::models::ProfileField> = raw_fields
|
||||
.iter()
|
||||
.filter_map(|f| {
|
||||
let name = f.get("name").and_then(|n| n.as_str())?.to_string();
|
||||
let value = f.get("value").and_then(|v| v.as_str())?.to_string();
|
||||
Some(domain::models::ProfileField { name, value })
|
||||
})
|
||||
.collect();
|
||||
|
||||
let cmd = application::users::commands::UpdateProfileFieldsCommand {
|
||||
user_id: user_id.value(),
|
||||
fields,
|
||||
};
|
||||
|
||||
match update_profile_fields::execute(&state.app_ctx.deps.users.update_profile_fields, cmd).await
|
||||
{
|
||||
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
||||
Err(e) => crate::errors::domain_error_response(e),
|
||||
}
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get, path = "/api/v1/users",
|
||||
responses((status = 200, body = UsersResponse)),
|
||||
)]
|
||||
pub async fn list_users(State(state): State<AppState>) -> Result<Json<UsersResponse>, ApiError> {
|
||||
let result =
|
||||
get_users::execute(&state.app_ctx.deps.users.get_users_list, GetUsersQuery).await?;
|
||||
Ok(Json(UsersResponse {
|
||||
users: result
|
||||
.users
|
||||
.iter()
|
||||
.map(|u| UserSummaryDto {
|
||||
id: u.user_id.value(),
|
||||
email: u.email().to_string(),
|
||||
username: u.username().to_string(),
|
||||
display_name: u.display_name().map(String::from),
|
||||
total_movies: u.total_movies,
|
||||
avg_rating: u.avg_rating,
|
||||
})
|
||||
.collect(),
|
||||
}))
|
||||
}
|
||||
148
crates/adapters/http-axum/src/api/watchlist.rs
Normal file
148
crates/adapters/http-axum/src/api/watchlist.rs
Normal file
@@ -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<u32>, Query, description = "Max results"),
|
||||
("offset" = Option<u32>, Query, description = "Offset"),
|
||||
),
|
||||
responses(
|
||||
(status = 200, body = WatchlistResponse),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn get_watchlist_handler(
|
||||
State(state): State<AppState>,
|
||||
user: AuthenticatedUser,
|
||||
Query(params): Query<PaginationQueryParams>,
|
||||
) -> Result<Json<WatchlistResponse>, ApiError> {
|
||||
let page = get_watchlist::execute(
|
||||
&state.app_ctx.deps.watchlist.get_watchlist,
|
||||
GetWatchlistQuery {
|
||||
user_id: user.0.value(),
|
||||
limit: params.limit,
|
||||
offset: params.offset,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Json(WatchlistResponse {
|
||||
items: page
|
||||
.items
|
||||
.into_iter()
|
||||
.map(|w| WatchlistEntryDto {
|
||||
id: w.entry.id.value(),
|
||||
movie: api_types::mappers::movie_to_dto(&w.movie),
|
||||
added_at: w.entry.added_at.to_string(),
|
||||
})
|
||||
.collect(),
|
||||
total_count: page.total_count,
|
||||
limit: page.limit,
|
||||
offset: page.offset,
|
||||
}))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post, path = "/api/v1/watchlist",
|
||||
request_body = AddToWatchlistRequest,
|
||||
responses(
|
||||
(status = 201, description = "Added to watchlist"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 404, description = "Movie not found"),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn post_watchlist_add(
|
||||
State(state): State<AppState>,
|
||||
user: AuthenticatedUser,
|
||||
Json(req): Json<AddToWatchlistRequest>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
add_to_watchlist::execute(
|
||||
&state.app_ctx.deps.watchlist.add,
|
||||
AddToWatchlistCommand {
|
||||
user_id: user.0.value(),
|
||||
input: MovieInput {
|
||||
movie_id: req.movie_id,
|
||||
external_metadata_id: req.external_metadata_id,
|
||||
manual_title: req.manual_title,
|
||||
manual_release_year: req.manual_release_year,
|
||||
manual_director: None,
|
||||
},
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
Ok(StatusCode::CREATED)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
delete, path = "/api/v1/watchlist/{movie_id}",
|
||||
params(("movie_id" = Uuid, Path, description = "Movie ID")),
|
||||
responses(
|
||||
(status = 204, description = "Removed from watchlist"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 404, description = "Not on watchlist"),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn delete_watchlist_entry(
|
||||
State(state): State<AppState>,
|
||||
user: AuthenticatedUser,
|
||||
Path(movie_id): Path<Uuid>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
remove_from_watchlist::execute(
|
||||
&state.app_ctx.deps.watchlist.remove_from_watchlist,
|
||||
RemoveFromWatchlistCommand {
|
||||
user_id: user.0.value(),
|
||||
movie_id,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get, path = "/api/v1/watchlist/{movie_id}",
|
||||
params(("movie_id" = Uuid, Path, description = "Movie ID")),
|
||||
responses(
|
||||
(status = 200, body = WatchlistStatusResponse),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn get_watchlist_status(
|
||||
State(state): State<AppState>,
|
||||
user: AuthenticatedUser,
|
||||
Path(movie_id): Path<Uuid>,
|
||||
) -> Result<Json<WatchlistStatusResponse>, ApiError> {
|
||||
let on_watchlist = is_on_watchlist::execute(
|
||||
&state.app_ctx.deps.watchlist.is_on_watchlist,
|
||||
IsOnWatchlistQuery {
|
||||
user_id: user.0.value(),
|
||||
movie_id,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(WatchlistStatusResponse { on_watchlist }))
|
||||
}
|
||||
@@ -1,11 +1,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<String> {
|
||||
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(
|
||||
@@ -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<f64> = 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<AppState>,
|
||||
Path((user_id, year)): Path<(Uuid, i32)>,
|
||||
Extension(csrf): Extension<CsrfToken>,
|
||||
) -> impl IntoResponse {
|
||||
let scope = WrapUpScope::User(user_id);
|
||||
let report =
|
||||
match get_ready_report::execute(&state.app_ctx.deps.wrapup.get_ready_report, scope, year)
|
||||
.await
|
||||
{
|
||||
Ok(report) => report,
|
||||
Err(DomainError::ValidationError(_)) => return StatusCode::BAD_REQUEST.into_response(),
|
||||
Err(_) => return StatusCode::NOT_FOUND.into_response(),
|
||||
};
|
||||
|
||||
let ctx = super::helpers::build_page_context(&state, viewer, csrf.0).await;
|
||||
render_wrapup(&report, year, &ctx)
|
||||
}
|
||||
|
||||
pub async fn get_global_wrapup_html(
|
||||
OptionalCookieUser(viewer): OptionalCookieUser,
|
||||
State(state): State<AppState>,
|
||||
Path(year): Path<i32>,
|
||||
Extension(csrf): Extension<CsrfToken>,
|
||||
) -> impl IntoResponse {
|
||||
let report = match get_ready_report::execute(
|
||||
&state.app_ctx.deps.wrapup.get_ready_report,
|
||||
WrapUpScope::Global,
|
||||
year,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(report) => report,
|
||||
Err(DomainError::ValidationError(_)) => return StatusCode::BAD_REQUEST.into_response(),
|
||||
Err(_) => return StatusCode::NOT_FOUND.into_response(),
|
||||
};
|
||||
|
||||
let ctx = super::helpers::build_page_context(&state, viewer, csrf.0).await;
|
||||
render_wrapup(&report, year, &ctx)
|
||||
}
|
||||
@@ -155,5 +155,4 @@ where
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/extractors.rs"]
|
||||
pub(crate) mod tests;
|
||||
169
crates/adapters/http-axum/src/federation/blocklist_api.rs
Normal file
169
crates/adapters/http-axum/src/federation/blocklist_api.rs
Normal file
@@ -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<BlockedDomainResponse>),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 403, description = "Forbidden - admin only"),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn get_blocked_domains_admin(
|
||||
State(state): State<AppState>,
|
||||
_admin: AdminApiUser,
|
||||
) -> Result<Json<Vec<BlockedDomainResponse>>, ApiError> {
|
||||
let domains = state.app_ctx.ap_blocklist.get_blocked_domains().await?;
|
||||
Ok(Json(
|
||||
domains
|
||||
.into_iter()
|
||||
.map(|d| BlockedDomainResponse {
|
||||
domain: d.domain,
|
||||
reason: d.reason,
|
||||
blocked_at: d.blocked_at,
|
||||
})
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post, path = "/api/v1/admin/blocked-domains",
|
||||
request_body = AddBlockedDomainRequest,
|
||||
responses(
|
||||
(status = 201, description = "Domain blocked"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 403, description = "Forbidden - admin only"),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn add_blocked_domain_admin(
|
||||
State(state): State<AppState>,
|
||||
_admin: AdminApiUser,
|
||||
axum::Json(body): axum::Json<AddBlockedDomainRequest>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
state
|
||||
.app_ctx
|
||||
.ap_blocklist
|
||||
.add_blocked_domain(&body.domain, body.reason.as_deref())
|
||||
.await?;
|
||||
Ok(StatusCode::CREATED)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
delete, path = "/api/v1/admin/blocked-domains/{domain}",
|
||||
params(("domain" = String, Path, description = "Domain to unblock")),
|
||||
responses(
|
||||
(status = 204, description = "Domain unblocked"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 403, description = "Forbidden - admin only"),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn remove_blocked_domain_admin(
|
||||
State(state): State<AppState>,
|
||||
_admin: AdminApiUser,
|
||||
axum::extract::Path(domain): axum::extract::Path<String>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
state
|
||||
.app_ctx
|
||||
.ap_blocklist
|
||||
.remove_blocked_domain(&domain)
|
||||
.await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post, path = "/api/v1/social/block",
|
||||
request_body = ActorUrlRequest,
|
||||
responses(
|
||||
(status = 204, description = "Actor blocked"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn block_actor_api(
|
||||
State(state): State<AppState>,
|
||||
user: AuthenticatedUser,
|
||||
axum::Json(body): axum::Json<ActorUrlRequest>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let instance = state.app_ctx.instance.clone();
|
||||
application::social::execute::execute_command(
|
||||
&state.app_ctx.deps.social.command,
|
||||
application::social::commands::SocialCmd::Block {
|
||||
blocker_id: user.0.value(),
|
||||
target: instance.identify(&body.actor_url),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post, path = "/api/v1/social/unblock",
|
||||
request_body = ActorUrlRequest,
|
||||
responses(
|
||||
(status = 204, description = "Actor unblocked"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn unblock_actor_api(
|
||||
State(state): State<AppState>,
|
||||
user: AuthenticatedUser,
|
||||
axum::Json(body): axum::Json<ActorUrlRequest>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let instance = state.app_ctx.instance.clone();
|
||||
application::social::execute::execute_command(
|
||||
&state.app_ctx.deps.social.command,
|
||||
application::social::commands::SocialCmd::Unblock {
|
||||
blocker_id: user.0.value(),
|
||||
target: instance.identify(&body.actor_url),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get, path = "/api/v1/social/blocked",
|
||||
responses(
|
||||
(status = 200, body = Vec<BlockedActorResponse>),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn get_blocked_actors_api(
|
||||
State(state): State<AppState>,
|
||||
user: AuthenticatedUser,
|
||||
) -> Result<Json<Vec<BlockedActorResponse>>, ApiError> {
|
||||
let instance = state.app_ctx.instance.clone();
|
||||
let identities =
|
||||
application::social::get_blocked::execute(&state.app_ctx.deps.social.query, user.0.value())
|
||||
.await?;
|
||||
Ok(Json(
|
||||
identities
|
||||
.into_iter()
|
||||
.map(|a| social_actor_to_blocked_dto(a, &instance))
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,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));
|
||||
}
|
||||
191
crates/adapters/http-axum/src/federation/blocklist_html.rs
Normal file
191
crates/adapters/http-axum/src/federation/blocklist_html.rs
Normal file
@@ -0,0 +1,191 @@
|
||||
use crate::html::forms::{ActorUrlForm, BlockDomainForm, RemoveDomainForm};
|
||||
use crate::html::helpers::build_page_context;
|
||||
use crate::html::templates::{BlockedActorsTemplate, BlockedDomainsTemplate};
|
||||
use crate::{
|
||||
extractors::RequiredCookieUser, html::csrf::CsrfToken, html::render::render_page,
|
||||
state::AppState,
|
||||
};
|
||||
use axum::{
|
||||
Form,
|
||||
extract::{Extension, State},
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Redirect},
|
||||
};
|
||||
|
||||
pub async fn get_blocked_domains_page(
|
||||
crate::extractors::AdminUser(user_id): crate::extractors::AdminUser,
|
||||
State(state): State<AppState>,
|
||||
Extension(csrf): Extension<CsrfToken>,
|
||||
) -> impl IntoResponse {
|
||||
let mut ctx = build_page_context(&state, Some(user_id), csrf.0).await;
|
||||
ctx.page_title = "Blocked Domains — Movies Diary".to_string();
|
||||
ctx.canonical_url = format!("{}/admin/blocked-domains", state.app_ctx.config.base_url);
|
||||
match state.app_ctx.ap_blocklist.get_blocked_domains().await {
|
||||
Ok(domains) => {
|
||||
let entries: Vec<crate::html::templates::BlockedDomainEntry> = domains
|
||||
.into_iter()
|
||||
.map(|d| crate::html::templates::BlockedDomainEntry {
|
||||
domain: d.domain,
|
||||
reason: d.reason,
|
||||
blocked_at: d.blocked_at,
|
||||
})
|
||||
.collect();
|
||||
render_page(BlockedDomainsTemplate {
|
||||
ctx: &ctx,
|
||||
domains: &entries,
|
||||
})
|
||||
.into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("get_blocked_domains error: {:?}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Failed to load blocked domains",
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn post_blocked_domain(
|
||||
crate::extractors::AdminUser(_): crate::extractors::AdminUser,
|
||||
State(state): State<AppState>,
|
||||
Extension(csrf): Extension<CsrfToken>,
|
||||
Form(form): Form<BlockDomainForm>,
|
||||
) -> impl IntoResponse {
|
||||
if crate::html::csrf::mismatch(&csrf, &form.csrf_token) {
|
||||
return StatusCode::FORBIDDEN.into_response();
|
||||
}
|
||||
let reason = form.reason.as_deref().filter(|s| !s.trim().is_empty());
|
||||
match state
|
||||
.app_ctx
|
||||
.ap_blocklist
|
||||
.add_blocked_domain(&form.domain, reason)
|
||||
.await
|
||||
{
|
||||
Ok(()) => Redirect::to("/admin/blocked-domains").into_response(),
|
||||
Err(e) => {
|
||||
tracing::error!("add_blocked_domain error: {:?}", e);
|
||||
Redirect::to("/admin/blocked-domains").into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn post_remove_blocked_domain(
|
||||
crate::extractors::AdminUser(_): crate::extractors::AdminUser,
|
||||
State(state): State<AppState>,
|
||||
Extension(csrf): Extension<CsrfToken>,
|
||||
Form(form): Form<RemoveDomainForm>,
|
||||
) -> impl IntoResponse {
|
||||
if crate::html::csrf::mismatch(&csrf, &form.csrf_token) {
|
||||
return StatusCode::FORBIDDEN.into_response();
|
||||
}
|
||||
match state
|
||||
.app_ctx
|
||||
.ap_blocklist
|
||||
.remove_blocked_domain(&form.domain)
|
||||
.await
|
||||
{
|
||||
Ok(()) => Redirect::to("/admin/blocked-domains").into_response(),
|
||||
Err(e) => {
|
||||
tracing::error!("remove_blocked_domain error: {:?}", e);
|
||||
Redirect::to("/admin/blocked-domains").into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_blocked_actors_page(
|
||||
RequiredCookieUser(user_id): RequiredCookieUser,
|
||||
State(state): State<AppState>,
|
||||
Extension(csrf): Extension<CsrfToken>,
|
||||
) -> impl IntoResponse {
|
||||
let mut ctx = build_page_context(&state, Some(user_id.clone()), csrf.0).await;
|
||||
ctx.page_title = "Blocked Users — Movies Diary".to_string();
|
||||
ctx.canonical_url = format!("{}/social/blocked", state.app_ctx.config.base_url);
|
||||
let instance = state.app_ctx.instance.clone();
|
||||
match application::social::get_blocked::execute(
|
||||
&state.app_ctx.deps.social.query,
|
||||
user_id.value(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(blocked) => {
|
||||
let entries: Vec<crate::html::templates::BlockedActorEntry> = blocked
|
||||
.into_iter()
|
||||
.map(|a| crate::html::templates::BlockedActorEntry {
|
||||
url: instance.actor_url_of(&a.identity),
|
||||
handle: a.handle,
|
||||
display_name: a.display_name,
|
||||
avatar_url: a.avatar_url,
|
||||
})
|
||||
.collect();
|
||||
render_page(BlockedActorsTemplate {
|
||||
ctx: &ctx,
|
||||
actors: &entries,
|
||||
})
|
||||
.into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("get_blocked_actors error: {:?}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Failed to load blocked users",
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn post_block_actor_html(
|
||||
RequiredCookieUser(user_id): RequiredCookieUser,
|
||||
State(state): State<AppState>,
|
||||
Extension(csrf): Extension<CsrfToken>,
|
||||
Form(form): Form<ActorUrlForm>,
|
||||
) -> impl IntoResponse {
|
||||
if crate::html::csrf::mismatch(&csrf, &form.csrf_token) {
|
||||
return StatusCode::FORBIDDEN.into_response();
|
||||
}
|
||||
let instance = state.app_ctx.instance.clone();
|
||||
match application::social::execute::execute_command(
|
||||
&state.app_ctx.deps.social.command,
|
||||
application::social::commands::SocialCmd::Block {
|
||||
blocker_id: user_id.value(),
|
||||
target: instance.identify(&form.actor_url),
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(()) => Redirect::to("/social/blocked").into_response(),
|
||||
Err(e) => {
|
||||
tracing::error!("block_actor html error: {:?}", e);
|
||||
Redirect::to("/social/blocked").into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn post_unblock_actor(
|
||||
RequiredCookieUser(user_id): RequiredCookieUser,
|
||||
State(state): State<AppState>,
|
||||
Extension(csrf): Extension<CsrfToken>,
|
||||
Form(form): Form<ActorUrlForm>,
|
||||
) -> impl IntoResponse {
|
||||
if crate::html::csrf::mismatch(&csrf, &form.csrf_token) {
|
||||
return StatusCode::FORBIDDEN.into_response();
|
||||
}
|
||||
let instance = state.app_ctx.instance.clone();
|
||||
match application::social::execute::execute_command(
|
||||
&state.app_ctx.deps.social.command,
|
||||
application::social::commands::SocialCmd::Unblock {
|
||||
blocker_id: user_id.value(),
|
||||
target: instance.identify(&form.actor_url),
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(()) => Redirect::to("/social/blocked").into_response(),
|
||||
Err(e) => {
|
||||
tracing::error!("unblock_actor error: {:?}", e);
|
||||
Redirect::to("/social/blocked").into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
77
crates/adapters/http-axum/src/federation/collections.rs
Normal file
77
crates/adapters/http-axum/src/federation/collections.rs
Normal file
@@ -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<AppState>,
|
||||
Path(user_id): Path<Uuid>,
|
||||
headers: axum::http::HeaderMap,
|
||||
Query(params): Query<std::collections::HashMap<String, String>>,
|
||||
) -> impl IntoResponse {
|
||||
let accept = headers
|
||||
.get(axum::http::header::ACCEPT)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("");
|
||||
if accept.contains("application/activity+json") || accept.contains("application/ld+json") {
|
||||
let page = params.get("page").and_then(|p| p.parse::<u32>().ok());
|
||||
return match state
|
||||
.app_ctx
|
||||
.ap_document
|
||||
.followers_collection_json(user_id, page)
|
||||
.await
|
||||
{
|
||||
Ok(json) => (
|
||||
[(
|
||||
axum::http::header::CONTENT_TYPE,
|
||||
"application/activity+json",
|
||||
)],
|
||||
json,
|
||||
)
|
||||
.into_response(),
|
||||
Err(e) => {
|
||||
tracing::error!("followers_collection_json error: {:?}", e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR.into_response()
|
||||
}
|
||||
};
|
||||
}
|
||||
axum::response::Redirect::to(&format!("/users/{}/followers-list", user_id)).into_response()
|
||||
}
|
||||
|
||||
pub async fn get_following_collection(
|
||||
State(state): State<AppState>,
|
||||
Path(user_id): Path<Uuid>,
|
||||
headers: axum::http::HeaderMap,
|
||||
Query(params): Query<std::collections::HashMap<String, String>>,
|
||||
) -> impl IntoResponse {
|
||||
let accept = headers
|
||||
.get(axum::http::header::ACCEPT)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("");
|
||||
if accept.contains("application/activity+json") || accept.contains("application/ld+json") {
|
||||
let page = params.get("page").and_then(|p| p.parse::<u32>().ok());
|
||||
return match state
|
||||
.app_ctx
|
||||
.ap_document
|
||||
.following_collection_json(user_id, page)
|
||||
.await
|
||||
{
|
||||
Ok(json) => (
|
||||
[(
|
||||
axum::http::header::CONTENT_TYPE,
|
||||
"application/activity+json",
|
||||
)],
|
||||
json,
|
||||
)
|
||||
.into_response(),
|
||||
Err(e) => {
|
||||
tracing::error!("following_collection_json error: {:?}", e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR.into_response()
|
||||
}
|
||||
};
|
||||
}
|
||||
axum::response::Redirect::to(&format!("/users/{}/following-list", user_id)).into_response()
|
||||
}
|
||||
120
crates/adapters/http-axum/src/federation/mod.rs
Normal file
120
crates/adapters/http-axum/src/federation/mod.rs
Normal file
@@ -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<AppState> {
|
||||
Router::new()
|
||||
.route(
|
||||
"/users/{id}/followers",
|
||||
routing::get(collections::get_followers_collection),
|
||||
)
|
||||
.route(
|
||||
"/users/{id}/following",
|
||||
routing::get(collections::get_following_collection),
|
||||
)
|
||||
.route(
|
||||
"/admin/blocked-domains",
|
||||
routing::get(blocklist_html::get_blocked_domains_page)
|
||||
.post(blocklist_html::post_blocked_domain),
|
||||
)
|
||||
.route(
|
||||
"/admin/blocked-domains/remove",
|
||||
routing::post(blocklist_html::post_remove_blocked_domain),
|
||||
)
|
||||
.route(
|
||||
"/social/blocked",
|
||||
routing::get(blocklist_html::get_blocked_actors_page),
|
||||
)
|
||||
.route(
|
||||
"/social/block",
|
||||
routing::post(blocklist_html::post_block_actor_html),
|
||||
)
|
||||
.route(
|
||||
"/social/unblock",
|
||||
routing::post(blocklist_html::post_unblock_actor),
|
||||
)
|
||||
}
|
||||
|
||||
/// JSON API routes that only exist with federation (blocklist administration).
|
||||
#[cfg(feature = "federation")]
|
||||
pub fn api_routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route(
|
||||
"/admin/blocked-domains",
|
||||
routing::get(blocklist_api::get_blocked_domains_admin)
|
||||
.post(blocklist_api::add_blocked_domain_admin),
|
||||
)
|
||||
.route(
|
||||
"/admin/blocked-domains/{domain}",
|
||||
routing::delete(blocklist_api::remove_blocked_domain_admin),
|
||||
)
|
||||
.route(
|
||||
"/social/block",
|
||||
routing::post(blocklist_api::block_actor_api),
|
||||
)
|
||||
.route(
|
||||
"/social/unblock",
|
||||
routing::post(blocklist_api::unblock_actor_api),
|
||||
)
|
||||
.route(
|
||||
"/social/blocked",
|
||||
routing::get(blocklist_api::get_blocked_actors_api),
|
||||
)
|
||||
}
|
||||
|
||||
/// Without federation the follower/following collection URLs fall back to the
|
||||
/// classic list pages.
|
||||
#[cfg(not(feature = "federation"))]
|
||||
pub fn html_routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route(
|
||||
"/users/{id}/followers",
|
||||
routing::get(
|
||||
|axum::extract::Path(id): axum::extract::Path<uuid::Uuid>| async move {
|
||||
axum::response::Redirect::permanent(&format!("/users/{}/followers-list", id))
|
||||
},
|
||||
),
|
||||
)
|
||||
.route(
|
||||
"/users/{id}/following",
|
||||
routing::get(
|
||||
|axum::extract::Path(id): axum::extract::Path<uuid::Uuid>| async move {
|
||||
axum::response::Redirect::permanent(&format!("/users/{}/following-list", id))
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "federation"))]
|
||||
pub fn api_routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
}
|
||||
|
||||
/// 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<axum::response::Response> {
|
||||
None
|
||||
}
|
||||
36
crates/adapters/http-axum/src/federation/negotiation.rs
Normal file
36
crates/adapters/http-axum/src/federation/negotiation.rs
Normal file
@@ -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<Response> {
|
||||
let accept = headers
|
||||
.get(header::ACCEPT)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("");
|
||||
if !(accept.contains("application/activity+json") || accept.contains("application/ld+json")) {
|
||||
return None;
|
||||
}
|
||||
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(),
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -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<AppState>,
|
||||
Query(params): Query<DiaryQueryParams>,
|
||||
) -> Result<Json<DiaryResponse>, ApiError> {
|
||||
let page =
|
||||
get_diary::execute(&state.app_ctx.deps.diary.get_diary, to_diary_query(params)).await?;
|
||||
|
||||
Ok(Json(DiaryResponse {
|
||||
items: page
|
||||
.items
|
||||
.iter()
|
||||
.map(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<AppState>,
|
||||
user: AuthenticatedUser,
|
||||
Json(req): Json<LogReviewRequest>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let data = LogReviewData::try_from(req).map_err(ApiError)?;
|
||||
log_review::execute(
|
||||
&state.app_ctx.services.review_logger,
|
||||
data.into_command(user.0.value()),
|
||||
)
|
||||
.await?;
|
||||
Ok(StatusCode::CREATED)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
delete, path = "/api/v1/reviews/{id}",
|
||||
params(("id" = Uuid, Path, description = "Review ID")),
|
||||
responses(
|
||||
(status = 204, description = "Review deleted"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 403, description = "Forbidden"),
|
||||
(status = 404, description = "Review not found"),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn delete_review(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
Path(review_id): Path<Uuid>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
let cmd = DeleteReviewCommand {
|
||||
review_id,
|
||||
requesting_user_id: user_id.value(),
|
||||
};
|
||||
delete_review::execute(&state.app_ctx.deps.diary.delete_review, cmd).await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
patch, path = "/api/v1/reviews/{id}",
|
||||
request_body = EditReviewRequest,
|
||||
params(("id" = Uuid, Path, description = "Review ID")),
|
||||
responses(
|
||||
(status = 200, description = "Review updated"),
|
||||
(status = 400, description = "Invalid input"),
|
||||
(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<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
Path(review_id): Path<Uuid>,
|
||||
Json(req): Json<EditReviewRequest>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
let watched_at = req
|
||||
.watched_at
|
||||
.map(|s| domain::value_objects::parse_watched_at(&s).map_err(ApiError))
|
||||
.transpose()?;
|
||||
|
||||
let cmd = EditReviewCommand {
|
||||
review_id,
|
||||
requesting_user_id: user_id.value(),
|
||||
rating: req.rating,
|
||||
comment: req.comment,
|
||||
watched_at,
|
||||
watch_medium: req.watch_medium,
|
||||
spoilers: req.spoilers,
|
||||
};
|
||||
edit_review::execute(&state.app_ctx.deps.diary.edit_review, cmd).await?;
|
||||
Ok(StatusCode::OK)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get, path = "/api/v1/diary/export",
|
||||
params(ExportQueryParams),
|
||||
responses(
|
||||
(status = 200, description = "Diary file download", content_type = "text/csv"),
|
||||
(status = 400, description = "Invalid format parameter"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn export_diary(
|
||||
State(state): State<AppState>,
|
||||
user: AuthenticatedUser,
|
||||
Query(params): Query<ExportQueryParams>,
|
||||
) -> 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<AppState>,
|
||||
Query(params): Query<ActivityFeedQueryParams>,
|
||||
) -> Result<Json<ActivityFeedResponse>, ApiError> {
|
||||
let page = get_feed_uc::execute(
|
||||
&state.app_ctx.deps.diary.get_activity_feed,
|
||||
GetActivityFeedQuery {
|
||||
limit: params.limit.unwrap_or(20),
|
||||
offset: params.offset.unwrap_or(0),
|
||||
sort_by: params
|
||||
.sort_by
|
||||
.as_deref()
|
||||
.map(|s| s.parse().unwrap_or_default())
|
||||
.unwrap_or_default(),
|
||||
search: None,
|
||||
viewer_user_id: None,
|
||||
filter_following: false,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(ActivityFeedResponse {
|
||||
items: page
|
||||
.items
|
||||
.iter()
|
||||
.map(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<AppState>,
|
||||
Query(params): Query<ErrorQuery>,
|
||||
Extension(csrf): Extension<CsrfToken>,
|
||||
) -> impl IntoResponse {
|
||||
let mut ctx = build_page_context(&state, Some(user_id), csrf.0).await;
|
||||
ctx.page_title = "Log a Review — Movies Diary".to_string();
|
||||
ctx.canonical_url = format!("{}/reviews/new", state.app_ctx.config.base_url);
|
||||
render_page(NewReviewTemplate {
|
||||
ctx: &ctx,
|
||||
error: params.error.as_deref(),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn post_review_html(
|
||||
State(state): State<AppState>,
|
||||
RequiredCookieUser(user_id): RequiredCookieUser,
|
||||
Extension(csrf): Extension<CsrfToken>,
|
||||
Form(form): Form<LogReviewForm>,
|
||||
) -> impl IntoResponse {
|
||||
if crate::csrf::mismatch(&csrf, &form.csrf_token) {
|
||||
return StatusCode::FORBIDDEN.into_response();
|
||||
}
|
||||
let data = match LogReviewData::try_from(form) {
|
||||
Ok(d) => d,
|
||||
Err(_) => {
|
||||
return Redirect::to("/reviews/new?error=Invalid+date+format").into_response();
|
||||
}
|
||||
};
|
||||
|
||||
match log_review::execute(
|
||||
&state.app_ctx.services.review_logger,
|
||||
data.into_command(user_id.value()),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => Redirect::to("/").into_response(),
|
||||
Err(e) => {
|
||||
let msg = encode_domain_error(&e);
|
||||
Redirect::to(&format!("/reviews/new?error={}", msg)).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn post_delete_review_html(
|
||||
State(state): State<AppState>,
|
||||
RequiredCookieUser(user_id): RequiredCookieUser,
|
||||
Extension(csrf): Extension<CsrfToken>,
|
||||
Path(review_id): Path<Uuid>,
|
||||
Form(form): Form<crate::forms::DeleteRedirectForm>,
|
||||
) -> 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<AppState>,
|
||||
RequiredCookieUser(user_id): RequiredCookieUser,
|
||||
Query(params): Query<api_types::ExportQueryParams>,
|
||||
) -> impl IntoResponse {
|
||||
build_export_response(¶ms.format, user_id.value(), &state)
|
||||
}
|
||||
|
||||
pub async fn get_activity_feed_html(
|
||||
OptionalCookieUser(user_id): OptionalCookieUser,
|
||||
State(state): State<AppState>,
|
||||
Query(params): Query<FeedQueryParams>,
|
||||
Extension(csrf): Extension<CsrfToken>,
|
||||
) -> impl IntoResponse {
|
||||
let ctx = build_page_context(&state, user_id.clone(), csrf.0).await;
|
||||
let limit = params.limit.unwrap_or(20);
|
||||
let offset = params.offset.unwrap_or(0);
|
||||
|
||||
let filter_following = params.filter == "following" && user_id.is_some();
|
||||
let filter_str = if filter_following { "following" } else { "all" };
|
||||
|
||||
let sort_by_str = match params.sort_by.as_str() {
|
||||
"date_asc" => "date_asc",
|
||||
"rating" => "rating",
|
||||
"rating_asc" => "rating_asc",
|
||||
_ => "date",
|
||||
};
|
||||
|
||||
let query = application::diary::queries::GetActivityFeedQuery {
|
||||
limit,
|
||||
offset,
|
||||
sort_by: sort_by_str.parse().unwrap_or_default(),
|
||||
search: if params.search.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(params.search.clone())
|
||||
},
|
||||
viewer_user_id: user_id.map(|u| u.value()),
|
||||
filter_following,
|
||||
};
|
||||
|
||||
match application::diary::get_activity_feed::execute(
|
||||
&state.app_ctx.deps.diary.get_activity_feed,
|
||||
query,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(entries) => {
|
||||
let entry_limit = entries.limit;
|
||||
let entry_offset = entries.offset;
|
||||
let has_more =
|
||||
(entry_offset as u64).saturating_add(entry_limit as u64) < entries.total_count;
|
||||
let total_pages = (entries.total_count as u32)
|
||||
.saturating_add(entry_limit.saturating_sub(1))
|
||||
.checked_div(entry_limit)
|
||||
.unwrap_or(1);
|
||||
let current_page = entry_offset.checked_div(entry_limit).unwrap_or(0);
|
||||
let page_items = build_page_items(total_pages, current_page);
|
||||
render_page(ActivityFeedTemplate {
|
||||
entries: entries.items.as_slice(),
|
||||
current_offset: entry_offset,
|
||||
limit: entry_limit,
|
||||
has_more,
|
||||
ctx: &ctx,
|
||||
page_items,
|
||||
filter: filter_str.to_string(),
|
||||
sort_by: sort_by_str.to_string(),
|
||||
search: params.search,
|
||||
})
|
||||
.into_response()
|
||||
}
|
||||
Err(e) => crate::errors::domain_error_response(e),
|
||||
}
|
||||
}
|
||||
@@ -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<DomainField> {
|
||||
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<String, String>) -> Vec<FieldMapping> {
|
||||
let mut mappings = Vec::new();
|
||||
let mut i = 0usize;
|
||||
loop {
|
||||
if i > 64 {
|
||||
break;
|
||||
}
|
||||
let col_key = format!("mapping_{i}_col");
|
||||
let Some(col) = form.get(&col_key).cloned() else {
|
||||
break;
|
||||
};
|
||||
let field_str = form
|
||||
.get(&format!("mapping_{i}_field"))
|
||||
.map(|s| s.as_str())
|
||||
.unwrap_or("");
|
||||
if let Some(domain_field) = str_to_domain_field(field_str) {
|
||||
let transform = if domain_field == DomainField::Rating {
|
||||
let scale: f64 = form
|
||||
.get(&format!("mapping_{i}_scale"))
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(1.0);
|
||||
Transform::RatingScale(scale)
|
||||
} else if domain_field == DomainField::WatchedAt {
|
||||
form.get(&format!("mapping_{i}_datefmt"))
|
||||
.filter(|s| !s.is_empty())
|
||||
.cloned()
|
||||
.map(Transform::DateFormat)
|
||||
.unwrap_or(Transform::Identity)
|
||||
} else {
|
||||
Transform::Identity
|
||||
};
|
||||
mappings.push(FieldMapping {
|
||||
source_column: col,
|
||||
domain_field,
|
||||
transform,
|
||||
});
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
mappings
|
||||
}
|
||||
|
||||
use crate::mappers::import::annotated_to_preview_row;
|
||||
|
||||
// ── HTML wizard handlers ───────────────────────────────────────────────────
|
||||
|
||||
pub async fn get_import_page(
|
||||
State(state): State<AppState>,
|
||||
RequiredCookieUser(user_id): RequiredCookieUser,
|
||||
Extension(csrf): Extension<CsrfToken>,
|
||||
) -> 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::<Vec<_>>();
|
||||
render_page(ImportUploadTemplate {
|
||||
ctx: &ctx,
|
||||
profiles: &profiles,
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn post_upload(
|
||||
State(state): State<AppState>,
|
||||
RequiredCookieUser(user_id): RequiredCookieUser,
|
||||
mut multipart: Multipart,
|
||||
) -> impl IntoResponse {
|
||||
let mut file_bytes: Option<Vec<u8>> = None;
|
||||
let mut format_str = "csv".to_string();
|
||||
|
||||
while let Ok(Some(field)) = multipart.next_field().await {
|
||||
match field.name() {
|
||||
Some("file") => {
|
||||
if let Ok(bytes) = field.bytes().await {
|
||||
file_bytes = Some(bytes.to_vec());
|
||||
}
|
||||
}
|
||||
Some("format") => {
|
||||
if let Ok(text) = field.text().await {
|
||||
format_str = text;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let bytes = match file_bytes {
|
||||
Some(b) if !b.is_empty() => b,
|
||||
_ => return Redirect::to("/import?error=no+file+provided").into_response(),
|
||||
};
|
||||
|
||||
let format = match format_str.as_str() {
|
||||
"json" => FileFormat::Json,
|
||||
"xlsx" => FileFormat::Xlsx,
|
||||
_ => FileFormat::Csv,
|
||||
};
|
||||
|
||||
match create_import_session::execute(
|
||||
&state.app_ctx.deps.import.create_session,
|
||||
CreateImportSessionCommand {
|
||||
user_id: user_id.value(),
|
||||
bytes,
|
||||
format,
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(r) => Redirect::to(&format!("/import/{}/mapping", r.session_id.value())).into_response(),
|
||||
Err(e) => {
|
||||
Redirect::to(&format!("/import?error={}", encode_domain_error(&e))).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_mapping_page(
|
||||
State(state): State<AppState>,
|
||||
RequiredCookieUser(user_id): RequiredCookieUser,
|
||||
Path(session_id_str): Path<String>,
|
||||
Extension(csrf): Extension<CsrfToken>,
|
||||
) -> impl IntoResponse {
|
||||
let Ok(session_id) = session_id_str
|
||||
.parse::<uuid::Uuid>()
|
||||
.map(ImportSessionId::from_uuid)
|
||||
else {
|
||||
return Redirect::to("/import").into_response();
|
||||
};
|
||||
let Ok(stage) = get_mapping_stage::execute(
|
||||
&state.app_ctx.deps.import.get_mapping_stage,
|
||||
session_id,
|
||||
user_id.value(),
|
||||
)
|
||||
.await
|
||||
else {
|
||||
return Redirect::to("/import").into_response();
|
||||
};
|
||||
|
||||
let ctx = 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<AppState>,
|
||||
RequiredCookieUser(user_id): RequiredCookieUser,
|
||||
Path(session_id_str): Path<String>,
|
||||
Extension(csrf): Extension<CsrfToken>,
|
||||
Form(form): Form<HashMap<String, String>>,
|
||||
) -> impl IntoResponse {
|
||||
let csrf_token = form.get("_csrf").map(|s| s.as_str()).unwrap_or("");
|
||||
if crate::csrf::mismatch(&csrf, csrf_token) {
|
||||
return Redirect::to("/import").into_response();
|
||||
}
|
||||
let Ok(session_id) = session_id_str
|
||||
.parse::<uuid::Uuid>()
|
||||
.map(ImportSessionId::from_uuid)
|
||||
else {
|
||||
return Redirect::to("/import").into_response();
|
||||
};
|
||||
let mappings = parse_mapping_form(&form);
|
||||
if mappings.is_empty() {
|
||||
return Redirect::to(&format!(
|
||||
"/import/{}/mapping?error=select+at+least+one+mapping",
|
||||
session_id_str
|
||||
))
|
||||
.into_response();
|
||||
}
|
||||
match apply_import_mapping::execute(
|
||||
&state.app_ctx.deps.import.apply_mapping,
|
||||
ApplyImportMappingCommand {
|
||||
user_id: user_id.value(),
|
||||
session_id: session_id.value(),
|
||||
mappings,
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => Redirect::to(&format!("/import/{}/preview", session_id_str)).into_response(),
|
||||
Err(e) => Redirect::to(&format!(
|
||||
"/import/{}/mapping?error={}",
|
||||
session_id_str,
|
||||
encode_domain_error(&e)
|
||||
))
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_preview_page(
|
||||
State(state): State<AppState>,
|
||||
RequiredCookieUser(user_id): RequiredCookieUser,
|
||||
Path(session_id_str): Path<String>,
|
||||
Extension(csrf): Extension<CsrfToken>,
|
||||
) -> impl IntoResponse {
|
||||
let Ok(session_id) = session_id_str
|
||||
.parse::<uuid::Uuid>()
|
||||
.map(ImportSessionId::from_uuid)
|
||||
else {
|
||||
return Redirect::to("/import").into_response();
|
||||
};
|
||||
let Ok(stage) = get_preview_stage::execute(
|
||||
&state.app_ctx.deps.import.get_preview_stage,
|
||||
session_id,
|
||||
user_id.value(),
|
||||
)
|
||||
.await
|
||||
else {
|
||||
return Redirect::to("/import").into_response();
|
||||
};
|
||||
|
||||
let preview = match stage {
|
||||
get_preview_stage::PreviewStage::Ready(preview) => preview,
|
||||
get_preview_stage::PreviewStage::NotYetMapped => {
|
||||
return Redirect::to(&format!("/import/{}/mapping", session_id_str)).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let rows: Vec<ImportPreviewRow> = preview
|
||||
.rows
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, a)| annotated_to_preview_row(i, a))
|
||||
.collect();
|
||||
|
||||
let ctx = 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<AppState>,
|
||||
RequiredCookieUser(user_id): RequiredCookieUser,
|
||||
Path(session_id_str): Path<String>,
|
||||
Extension(csrf): Extension<CsrfToken>,
|
||||
Form(form_entries): Form<Vec<(String, String)>>,
|
||||
) -> impl IntoResponse {
|
||||
let csrf_token = form_entries
|
||||
.iter()
|
||||
.find(|(k, _)| k == "_csrf")
|
||||
.map(|(_, v)| v.as_str())
|
||||
.unwrap_or("");
|
||||
if crate::csrf::mismatch(&csrf, csrf_token) {
|
||||
return Redirect::to("/import").into_response();
|
||||
}
|
||||
let Ok(session_id) = session_id_str
|
||||
.parse::<uuid::Uuid>()
|
||||
.map(ImportSessionId::from_uuid)
|
||||
else {
|
||||
return Redirect::to("/import").into_response();
|
||||
};
|
||||
|
||||
// Save profile if name provided
|
||||
let profile_name = form_entries
|
||||
.iter()
|
||||
.find(|(k, _)| k == "profile_name")
|
||||
.map(|(_, v)| v.clone())
|
||||
.filter(|n| !n.trim().is_empty());
|
||||
if let Some(name) = profile_name {
|
||||
let _ = save_import_profile::execute(
|
||||
&state.app_ctx.deps.import.save_profile,
|
||||
SaveImportProfileCommand {
|
||||
user_id: user_id.value(),
|
||||
session_id: session_id.value(),
|
||||
name,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
// Collect all "confirmed" checkbox values
|
||||
let confirmed: Vec<usize> = form_entries
|
||||
.iter()
|
||||
.filter(|(k, _)| k == "confirmed")
|
||||
.filter_map(|(_, v)| v.parse::<usize>().ok())
|
||||
.collect();
|
||||
|
||||
match execute_import::execute(
|
||||
&state.app_ctx.deps.import.execute_import,
|
||||
ExecuteImportCommand {
|
||||
user_id: user_id.value(),
|
||||
session_id: session_id.value(),
|
||||
confirmed_indices: confirmed,
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(summary) => Redirect::to(&format!(
|
||||
"/import/done?imported={}&skipped={}&failed={}",
|
||||
summary.imported,
|
||||
summary.skipped_duplicates,
|
||||
summary.failed.len()
|
||||
))
|
||||
.into_response(),
|
||||
Err(e) => {
|
||||
Redirect::to(&format!("/import?error={}", encode_domain_error(&e))).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn post_delete_profile(
|
||||
State(state): State<AppState>,
|
||||
RequiredCookieUser(user_id): RequiredCookieUser,
|
||||
Path(profile_id_str): Path<String>,
|
||||
Extension(csrf): Extension<CsrfToken>,
|
||||
Form(form): Form<HashMap<String, String>>,
|
||||
) -> impl IntoResponse {
|
||||
let csrf_token = form.get("_csrf").map(|s| s.as_str()).unwrap_or("");
|
||||
if crate::csrf::mismatch(&csrf, csrf_token) {
|
||||
return Redirect::to("/import").into_response();
|
||||
}
|
||||
if let Ok(profile_id) = profile_id_str.parse::<uuid::Uuid>() {
|
||||
let _ = delete_import_profile::execute(
|
||||
&state.app_ctx.deps.import.delete_profile,
|
||||
DeleteImportProfileCommand {
|
||||
user_id: user_id.value(),
|
||||
profile_id,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
Redirect::to("/import").into_response()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ImportDoneParams {
|
||||
pub imported: Option<usize>,
|
||||
pub skipped: Option<usize>,
|
||||
pub failed: Option<usize>,
|
||||
}
|
||||
|
||||
pub async fn get_import_done(
|
||||
State(state): State<AppState>,
|
||||
RequiredCookieUser(user_id): RequiredCookieUser,
|
||||
Extension(csrf): Extension<CsrfToken>,
|
||||
axum::extract::Query(params): axum::extract::Query<ImportDoneParams>,
|
||||
) -> impl IntoResponse {
|
||||
let _ctx = super::helpers::build_page_context(&state, Some(user_id.clone()), csrf.0).await;
|
||||
let html = format!(
|
||||
r#"<!doctype html><html><body>
|
||||
<h1>Import Complete</h1>
|
||||
<p>Imported: {}</p>
|
||||
<p>Skipped duplicates: {}</p>
|
||||
<p>Failed: {}</p>
|
||||
<a href="/users/{}">Go to My Profile</a>
|
||||
</body></html>"#,
|
||||
params.imported.unwrap_or(0),
|
||||
params.skipped.unwrap_or(0),
|
||||
params.failed.unwrap_or(0),
|
||||
user_id.value(),
|
||||
);
|
||||
Html(html)
|
||||
}
|
||||
|
||||
// ── 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<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
mut multipart: Multipart,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let mut file_bytes: Option<Vec<u8>> = None;
|
||||
let mut format_str = "csv".to_string();
|
||||
while let Ok(Some(field)) = multipart.next_field().await {
|
||||
match field.name() {
|
||||
Some("file") => {
|
||||
if let Ok(b) = field.bytes().await {
|
||||
file_bytes = Some(b.to_vec());
|
||||
}
|
||||
}
|
||||
Some("format") => {
|
||||
if let Ok(t) = field.text().await {
|
||||
format_str = t;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let bytes = match file_bytes {
|
||||
Some(b) if !b.is_empty() => b,
|
||||
_ => return Err(DomainError::ValidationError("no file".into()).into()),
|
||||
};
|
||||
let format = match format_str.as_str() {
|
||||
"json" => FileFormat::Json,
|
||||
"xlsx" => FileFormat::Xlsx,
|
||||
_ => FileFormat::Csv,
|
||||
};
|
||||
let r = create_import_session::execute(
|
||||
&state.app_ctx.deps.import.create_session,
|
||||
CreateImportSessionCommand {
|
||||
user_id: user_id.value(),
|
||||
bytes,
|
||||
format,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
Ok(axum::Json(SessionCreatedResponse {
|
||||
session_id: r.session_id.value().to_string(),
|
||||
columns: r.columns,
|
||||
sample_rows: r.sample_rows,
|
||||
}))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get, path = "/api/v1/import/sessions/{id}",
|
||||
params(("id" = String, Path, description = "Import session UUID")),
|
||||
responses(
|
||||
(status = 200, body = SessionStateResponse),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 404, description = "Session not found"),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn api_get_session(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
Path(session_id_str): Path<String>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let session_id = session_id_str
|
||||
.parse::<uuid::Uuid>()
|
||||
.map(ImportSessionId::from_uuid)
|
||||
.map_err(|_| DomainError::ValidationError("invalid session id".into()))?;
|
||||
let state_data = get_session_state::execute(
|
||||
&state.app_ctx.deps.import.get_session_state,
|
||||
session_id,
|
||||
user_id.value(),
|
||||
)
|
||||
.await?;
|
||||
Ok(axum::Json(SessionStateResponse {
|
||||
session_id: session_id_str,
|
||||
columns: state_data.columns,
|
||||
has_mappings: state_data.has_mappings,
|
||||
row_count: state_data.row_count,
|
||||
}))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
put, path = "/api/v1/import/sessions/{id}/mapping",
|
||||
params(("id" = String, Path, description = "Import session UUID")),
|
||||
request_body = ApplyMappingRequest,
|
||||
responses(
|
||||
(status = 200, description = "Mapping applied", body = inline(serde_json::Value)),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 422, description = "Mapping error"),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn api_put_mapping(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
Path(session_id_str): Path<String>,
|
||||
axum::Json(body): axum::Json<ApplyMappingRequest>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let session_id = session_id_str
|
||||
.parse::<uuid::Uuid>()
|
||||
.map(ImportSessionId::from_uuid)
|
||||
.map_err(|_| DomainError::ValidationError("invalid session id".into()))?;
|
||||
let mappings: Vec<FieldMapping> = body
|
||||
.mappings
|
||||
.into_iter()
|
||||
.filter_map(|m| {
|
||||
let domain_field = str_to_domain_field(&m.domain_field)?;
|
||||
let transform = if domain_field == DomainField::Rating {
|
||||
Transform::RatingScale(m.rating_scale.unwrap_or(1.0))
|
||||
} else if domain_field == DomainField::WatchedAt {
|
||||
m.date_format
|
||||
.map(Transform::DateFormat)
|
||||
.unwrap_or(Transform::Identity)
|
||||
} else {
|
||||
Transform::Identity
|
||||
};
|
||||
Some(FieldMapping {
|
||||
source_column: m.source_column,
|
||||
domain_field,
|
||||
transform,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
let rows = apply_import_mapping::execute(
|
||||
&state.app_ctx.deps.import.apply_mapping,
|
||||
ApplyImportMappingCommand {
|
||||
user_id: user_id.value(),
|
||||
session_id: session_id.value(),
|
||||
mappings,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
Ok(axum::Json(serde_json::json!({"row_count": rows.len()})))
|
||||
}
|
||||
|
||||
pub async fn api_get_preview(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
Path(session_id_str): Path<String>,
|
||||
) -> Result<axum::Json<ImportPreviewResponse>, ApiError> {
|
||||
let session_id = session_id_str
|
||||
.parse::<uuid::Uuid>()
|
||||
.map(ImportSessionId::from_uuid)
|
||||
.map_err(|_| DomainError::ValidationError("invalid session id".into()))?;
|
||||
|
||||
let stage = get_preview_stage::execute(
|
||||
&state.app_ctx.deps.import.get_preview_stage,
|
||||
session_id,
|
||||
user_id.value(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Not-yet-mapped sessions render as an empty preview here (200, `rows: []`),
|
||||
// matching this endpoint's behavior before the stage-gate moved into
|
||||
// `get_preview_stage` — unlike the HTML handler, this API route never
|
||||
// redirected on the same condition, so its response shape is unchanged.
|
||||
let annotated: Vec<AnnotatedRow> = match stage {
|
||||
get_preview_stage::PreviewStage::Ready(preview) => preview.rows,
|
||||
get_preview_stage::PreviewStage::NotYetMapped => Vec::new(),
|
||||
};
|
||||
let rows = annotated
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, a)| {
|
||||
use domain::models::import::RowResult;
|
||||
match &a.result {
|
||||
RowResult::Valid(row) if a.is_duplicate => {
|
||||
PreviewRowDto::Duplicate(api_types::PreviewRowData {
|
||||
index: i,
|
||||
title: row.title.clone(),
|
||||
release_year: row.release_year.clone(),
|
||||
director: row.director.clone(),
|
||||
rating: row.rating.clone(),
|
||||
watched_at: row.watched_at.clone(),
|
||||
comment: row.comment.clone(),
|
||||
})
|
||||
}
|
||||
RowResult::Valid(row) => PreviewRowDto::Valid(api_types::PreviewRowData {
|
||||
index: i,
|
||||
title: row.title.clone(),
|
||||
release_year: row.release_year.clone(),
|
||||
director: row.director.clone(),
|
||||
rating: row.rating.clone(),
|
||||
watched_at: row.watched_at.clone(),
|
||||
comment: row.comment.clone(),
|
||||
}),
|
||||
RowResult::Invalid { errors, .. } => PreviewRowDto::Invalid {
|
||||
index: i,
|
||||
errors: errors.clone(),
|
||||
},
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(axum::Json(ImportPreviewResponse { rows }))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post, path = "/api/v1/import/sessions/{id}/confirm",
|
||||
params(("id" = String, Path, description = "Import session UUID")),
|
||||
request_body = ConfirmRequest,
|
||||
responses(
|
||||
(status = 200, description = "Import summary", body = inline(serde_json::Value)),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 404, description = "Session not found"),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn api_post_confirm(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
Path(session_id_str): Path<String>,
|
||||
axum::Json(body): axum::Json<ConfirmRequest>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let session_id = session_id_str
|
||||
.parse::<uuid::Uuid>()
|
||||
.map(ImportSessionId::from_uuid)
|
||||
.map_err(|_| DomainError::ValidationError("invalid session id".into()))?;
|
||||
let s = execute_import::execute(
|
||||
&state.app_ctx.deps.import.execute_import,
|
||||
ExecuteImportCommand {
|
||||
user_id: user_id.value(),
|
||||
session_id: session_id.value(),
|
||||
confirmed_indices: body.confirmed_indices,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
Ok(axum::Json(serde_json::json!({
|
||||
"imported": s.imported,
|
||||
"skipped_duplicates": s.skipped_duplicates,
|
||||
"failed": s.failed.iter().map(|(i, e)| serde_json::json!({"index": i, "error": e})).collect::<Vec<_>>(),
|
||||
})))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get, path = "/api/v1/import/profiles",
|
||||
responses(
|
||||
(status = 200, description = "List of saved import profiles", body = inline(Vec<serde_json::Value>)),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn api_get_profiles(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let profiles =
|
||||
list_import_profiles::execute(&state.app_ctx.deps.import.list_profiles, &user_id).await?;
|
||||
Ok(axum::Json(
|
||||
profiles
|
||||
.into_iter()
|
||||
.map(|p| {
|
||||
serde_json::json!({
|
||||
"id": p.id.value().to_string(),
|
||||
"name": p.name,
|
||||
"created_at": p.created_at.to_string(),
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post, path = "/api/v1/import/profiles",
|
||||
request_body = SaveProfileRequest,
|
||||
responses(
|
||||
(status = 200, description = "Profile saved", body = inline(serde_json::Value)),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 422, description = "Session has no mapping yet"),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn api_post_profile(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
axum::Json(body): axum::Json<SaveProfileRequest>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let session_id = body
|
||||
.session_id
|
||||
.parse::<uuid::Uuid>()
|
||||
.map(ImportSessionId::from_uuid)
|
||||
.map_err(|_| DomainError::ValidationError("invalid session id".into()))?;
|
||||
let id = save_import_profile::execute(
|
||||
&state.app_ctx.deps.import.save_profile,
|
||||
SaveImportProfileCommand {
|
||||
user_id: user_id.value(),
|
||||
session_id: session_id.value(),
|
||||
name: body.name,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
Ok(axum::Json(
|
||||
serde_json::json!({"id": id.value().to_string()}),
|
||||
))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
delete, path = "/api/v1/import/profiles/{id}",
|
||||
params(("id" = String, Path, description = "Import profile UUID")),
|
||||
responses(
|
||||
(status = 204, description = "Deleted"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 404, description = "Profile not found"),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn api_delete_profile(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
Path(profile_id_str): Path<String>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let profile_id = profile_id_str
|
||||
.parse::<uuid::Uuid>()
|
||||
.map_err(|_| DomainError::ValidationError("invalid profile id".into()))?;
|
||||
delete_import_profile::execute(
|
||||
&state.app_ctx.deps.import.delete_profile,
|
||||
DeleteImportProfileCommand {
|
||||
user_id: user_id.value(),
|
||||
profile_id,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
#[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<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
Path((session_id_str, profile_id_str)): Path<(String, String)>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let session_id = session_id_str
|
||||
.parse::<uuid::Uuid>()
|
||||
.map_err(|_| DomainError::ValidationError("invalid session id".into()))?;
|
||||
let profile_id = profile_id_str
|
||||
.parse::<uuid::Uuid>()
|
||||
.map_err(|_| DomainError::ValidationError("invalid profile id".into()))?;
|
||||
|
||||
let rows = apply_profile_and_map::execute(
|
||||
&state.app_ctx.deps.import.apply_profile_and_map,
|
||||
ApplyProfileAndMapCommand {
|
||||
user_id: user_id.value(),
|
||||
session_id,
|
||||
profile_id,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
Ok(axum::Json(serde_json::json!({"row_count": rows.len()})))
|
||||
}
|
||||
@@ -1,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;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
) -> Result<Json<ProfileResponse>, ApiError> {
|
||||
let profile = application::users::get_current_profile::execute(
|
||||
&state.app_ctx.deps.users.get_current_profile,
|
||||
application::users::queries::GetCurrentProfileQuery {
|
||||
user_id: user_id.value(),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
let instance = &state.app_ctx.instance;
|
||||
Ok(Json(ProfileResponse {
|
||||
profile: api_types::UserProfileBase {
|
||||
username: profile.username,
|
||||
display_name: profile.display_name,
|
||||
bio: profile.bio,
|
||||
avatar_url: profile.avatar_path.map(|p| instance.image_url_for(&p)),
|
||||
banner_url: profile.banner_path.map(|p| instance.image_url_for(&p)),
|
||||
},
|
||||
also_known_as: profile.also_known_as,
|
||||
fields: profile
|
||||
.fields
|
||||
.into_iter()
|
||||
.map(|f| api_types::ProfileFieldDto {
|
||||
name: f.name,
|
||||
value: f.value,
|
||||
})
|
||||
.collect(),
|
||||
role: profile.role,
|
||||
}))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
put, path = "/api/v1/profile",
|
||||
responses(
|
||||
(status = 204, description = "Profile updated"),
|
||||
(status = 400, description = "Invalid input"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 500, description = "Internal server error"),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn update_profile_handler(
|
||||
State(state): State<AppState>,
|
||||
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<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
axum::Json(body): axum::Json<serde_json::Value>,
|
||||
) -> 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<domain::models::ProfileField> = raw_fields
|
||||
.iter()
|
||||
.filter_map(|f| {
|
||||
let name = f.get("name").and_then(|n| n.as_str())?.to_string();
|
||||
let value = f.get("value").and_then(|v| v.as_str())?.to_string();
|
||||
Some(domain::models::ProfileField { name, value })
|
||||
})
|
||||
.collect();
|
||||
|
||||
let cmd = application::users::commands::UpdateProfileFieldsCommand {
|
||||
user_id: user_id.value(),
|
||||
fields,
|
||||
};
|
||||
|
||||
match update_profile_fields::execute(&state.app_ctx.deps.users.update_profile_fields, cmd).await
|
||||
{
|
||||
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
||||
Err(e) => crate::errors::domain_error_response(e),
|
||||
}
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get, path = "/api/v1/users",
|
||||
responses((status = 200, body = UsersResponse)),
|
||||
)]
|
||||
pub async fn list_users(State(state): State<AppState>) -> Result<Json<UsersResponse>, ApiError> {
|
||||
let result =
|
||||
get_users::execute(&state.app_ctx.deps.users.get_users_list, GetUsersQuery).await?;
|
||||
Ok(Json(UsersResponse {
|
||||
users: result
|
||||
.users
|
||||
.iter()
|
||||
.map(|u| UserSummaryDto {
|
||||
id: u.user_id.value(),
|
||||
email: u.email().to_string(),
|
||||
username: u.username().to_string(),
|
||||
display_name: u.display_name().map(String::from),
|
||||
total_movies: u.total_movies,
|
||||
avg_rating: u.avg_rating,
|
||||
})
|
||||
.collect(),
|
||||
}))
|
||||
}
|
||||
|
||||
#[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<AppState>,
|
||||
AuthenticatedUser(viewer_id): AuthenticatedUser,
|
||||
Path(user_id): Path<Uuid>,
|
||||
Query(params): Query<UserProfileQueryParams>,
|
||||
) -> impl IntoResponse {
|
||||
let view_str = params.view.as_deref().unwrap_or("recent");
|
||||
let profile_view = match application::users::queries::ProfileView::from_str(view_str) {
|
||||
Ok(v) => v,
|
||||
Err(_) => return 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<AppState>,
|
||||
Extension(csrf): Extension<CsrfToken>,
|
||||
) -> 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<UserSummaryView> = result
|
||||
.users
|
||||
.iter()
|
||||
.map(crate::mappers::users::user_summary_view)
|
||||
.collect();
|
||||
let remote_actors: Vec<RemoteActorDisplay> = 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<AppState>,
|
||||
Path(username): Path<String>,
|
||||
) -> 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<crate::html::templates::PageItem>,
|
||||
}
|
||||
|
||||
fn compute_pagination(
|
||||
entries: Option<&domain::models::collections::Paginated<domain::models::DiaryEntry>>,
|
||||
) -> PaginationInfo {
|
||||
let (offset, has_more, limit) = entries
|
||||
.map(|e| {
|
||||
let has_more = (e.offset as u64).saturating_add(e.limit as u64) < e.total_count;
|
||||
(e.offset, has_more, e.limit)
|
||||
})
|
||||
.unwrap_or((0, false, 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<MonthlyRatingRow<'a>> {
|
||||
trends
|
||||
.map(|t| {
|
||||
t.monthly_ratings
|
||||
.iter()
|
||||
.map(|r| MonthlyRatingRow {
|
||||
rating: r,
|
||||
bar_height_px: bar_height_px(r.avg_rating),
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
async fn fetch_profile_goals(
|
||||
state: &AppState,
|
||||
user_id: Uuid,
|
||||
) -> Vec<crate::html::templates::GoalViewData> {
|
||||
let goals_list = application::goals::list::execute(
|
||||
&state.app_ctx.deps.goals.query,
|
||||
application::goals::queries::ListGoalsQuery { user_id },
|
||||
)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
goals_list
|
||||
.iter()
|
||||
.map(|g| crate::html::templates::GoalViewData {
|
||||
year: g.goal.year(),
|
||||
target_count: g.goal.target_count(),
|
||||
current_count: g.current_count,
|
||||
percentage: g.percentage().round(),
|
||||
is_complete: g.is_complete(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
// ── Handler ─────────────────────────────────────────────────────────────────
|
||||
|
||||
pub async fn get_user_profile_html(
|
||||
OptionalCookieUser(user_id): OptionalCookieUser,
|
||||
State(state): State<AppState>,
|
||||
Path(profile_user_uuid): Path<Uuid>,
|
||||
headers: axum::http::HeaderMap,
|
||||
Query(params): Query<crate::forms::ProfileQueryParams>,
|
||||
Extension(csrf): Extension<CsrfToken>,
|
||||
) -> 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<RemoteActorData> = 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<String>,
|
||||
}
|
||||
|
||||
pub async fn get_profile_settings(
|
||||
RequiredCookieUser(user_id): RequiredCookieUser,
|
||||
State(state): State<AppState>,
|
||||
Query(params): Query<SavedQuery>,
|
||||
Extension(csrf): Extension<CsrfToken>,
|
||||
) -> 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<AppState>,
|
||||
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<domain::models::ProfileField> = (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()
|
||||
}
|
||||
@@ -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<u32>, Query, description = "Max results"),
|
||||
("offset" = Option<u32>, Query, description = "Offset"),
|
||||
),
|
||||
responses(
|
||||
(status = 200, body = WatchlistResponse),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn get_watchlist_handler(
|
||||
State(state): State<AppState>,
|
||||
user: AuthenticatedUser,
|
||||
Query(params): Query<PaginationQueryParams>,
|
||||
) -> Result<Json<WatchlistResponse>, ApiError> {
|
||||
let page = get_watchlist::execute(
|
||||
&state.app_ctx.deps.watchlist.get_watchlist,
|
||||
GetWatchlistQuery {
|
||||
user_id: user.0.value(),
|
||||
limit: params.limit,
|
||||
offset: params.offset,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Json(WatchlistResponse {
|
||||
items: page
|
||||
.items
|
||||
.into_iter()
|
||||
.map(|w| WatchlistEntryDto {
|
||||
id: w.entry.id.value(),
|
||||
movie: 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<AppState>,
|
||||
user: AuthenticatedUser,
|
||||
Json(req): Json<AddToWatchlistRequest>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
add_to_watchlist::execute(
|
||||
&state.app_ctx.deps.watchlist.add,
|
||||
AddToWatchlistCommand {
|
||||
user_id: user.0.value(),
|
||||
input: MovieInput {
|
||||
movie_id: req.movie_id,
|
||||
external_metadata_id: req.external_metadata_id,
|
||||
manual_title: req.manual_title,
|
||||
manual_release_year: req.manual_release_year,
|
||||
manual_director: None,
|
||||
},
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
Ok(StatusCode::CREATED)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
delete, path = "/api/v1/watchlist/{movie_id}",
|
||||
params(("movie_id" = Uuid, Path, description = "Movie ID")),
|
||||
responses(
|
||||
(status = 204, description = "Removed from watchlist"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 404, description = "Not on watchlist"),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn delete_watchlist_entry(
|
||||
State(state): State<AppState>,
|
||||
user: AuthenticatedUser,
|
||||
Path(movie_id): Path<Uuid>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
remove_from_watchlist::execute(
|
||||
&state.app_ctx.deps.watchlist.remove_from_watchlist,
|
||||
RemoveFromWatchlistCommand {
|
||||
user_id: user.0.value(),
|
||||
movie_id,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get, path = "/api/v1/watchlist/{movie_id}",
|
||||
params(("movie_id" = Uuid, Path, description = "Movie ID")),
|
||||
responses(
|
||||
(status = 200, body = WatchlistStatusResponse),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn get_watchlist_status(
|
||||
State(state): State<AppState>,
|
||||
user: AuthenticatedUser,
|
||||
Path(movie_id): Path<Uuid>,
|
||||
) -> Result<Json<WatchlistStatusResponse>, ApiError> {
|
||||
let on_watchlist = is_on_watchlist::execute(
|
||||
&state.app_ctx.deps.watchlist.is_on_watchlist,
|
||||
IsOnWatchlistQuery {
|
||||
user_id: user.0.value(),
|
||||
movie_id,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(WatchlistStatusResponse { on_watchlist }))
|
||||
}
|
||||
|
||||
// ── HTML ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
pub async fn get_watchlist_page(
|
||||
OptionalCookieUser(viewer_id): OptionalCookieUser,
|
||||
State(state): State<AppState>,
|
||||
Path(owner_id): Path<uuid::Uuid>,
|
||||
Query(params): Query<crate::forms::WatchlistQuery>,
|
||||
Extension(csrf): Extension<CsrfToken>,
|
||||
) -> 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<AppState>,
|
||||
RequiredCookieUser(user_id): RequiredCookieUser,
|
||||
Extension(csrf): Extension<CsrfToken>,
|
||||
Form(form): Form<crate::forms::WatchlistAddForm>,
|
||||
) -> 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<AppState>,
|
||||
RequiredCookieUser(user_id): RequiredCookieUser,
|
||||
Extension(csrf): Extension<CsrfToken>,
|
||||
Path(movie_id): Path<uuid::Uuid>,
|
||||
Form(form): Form<crate::forms::DeleteRedirectForm>,
|
||||
) -> 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),
|
||||
}
|
||||
}
|
||||
@@ -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<AppState>,
|
||||
Json(req): Json<LoginRequest>,
|
||||
) -> Result<Json<LoginResponse>, ApiError> {
|
||||
let result = login_uc::execute(
|
||||
&state.app_ctx.deps.auth.login,
|
||||
LoginCommand {
|
||||
email: req.email,
|
||||
password: req.password,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(LoginResponse {
|
||||
token: result.token,
|
||||
refresh_token: result.refresh_token,
|
||||
user_id: result.user_id,
|
||||
email: result.email,
|
||||
expires_at: result.expires_at.to_rfc3339(),
|
||||
role: result.role,
|
||||
}))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post, path = "/api/v1/auth/register",
|
||||
request_body = RegisterRequest,
|
||||
responses(
|
||||
(status = 201, description = "User registered"),
|
||||
(status = 400, description = "Invalid input"),
|
||||
)
|
||||
)]
|
||||
pub async fn register(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<RegisterRequest>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
register_uc::execute(
|
||||
&state.app_ctx.deps.auth.register,
|
||||
RegisterCommand {
|
||||
email: req.email,
|
||||
username: req.username,
|
||||
password: req.password,
|
||||
role: domain::models::UserRole::Standard,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
Ok(StatusCode::CREATED)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post, path = "/api/v1/auth/refresh",
|
||||
request_body = RefreshRequest,
|
||||
responses(
|
||||
(status = 200, body = RefreshResponse),
|
||||
(status = 401, description = "Invalid or expired refresh token"),
|
||||
)
|
||||
)]
|
||||
pub async fn refresh(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<RefreshRequest>,
|
||||
) -> Result<Json<RefreshResponse>, ApiError> {
|
||||
let result =
|
||||
application::auth::refresh::execute(&state.app_ctx.deps.auth.refresh, &req.refresh_token)
|
||||
.await?;
|
||||
Ok(Json(RefreshResponse {
|
||||
token: result.token,
|
||||
refresh_token: result.refresh_token,
|
||||
expires_at: result.expires_at.to_rfc3339(),
|
||||
}))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post, path = "/api/v1/auth/logout",
|
||||
request_body = LogoutRequest,
|
||||
responses(
|
||||
(status = 204, description = "Logged out"),
|
||||
)
|
||||
)]
|
||||
pub async fn api_logout(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<LogoutRequest>,
|
||||
) -> 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<AppState>,
|
||||
Query(params): Query<ErrorQuery>,
|
||||
@@ -167,7 +60,7 @@ pub async fn post_login(
|
||||
Extension(csrf): Extension<CsrfToken>,
|
||||
Form(form): Form<LoginForm>,
|
||||
) -> 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(
|
||||
166
crates/adapters/http-axum/src/html/diary.rs
Normal file
166
crates/adapters/http-axum/src/html/diary.rs
Normal file
@@ -0,0 +1,166 @@
|
||||
use crate::html::helpers::{build_page_context, encode_domain_error};
|
||||
use crate::html::templates::{ActivityFeedTemplate, NewReviewTemplate, build_page_items};
|
||||
use crate::support::build_export_response;
|
||||
use crate::{
|
||||
extractors::{OptionalCookieUser, RequiredCookieUser},
|
||||
html::csrf::CsrfToken,
|
||||
html::forms::{ErrorQuery, FeedQueryParams, LogReviewData, LogReviewForm},
|
||||
html::render::render_page,
|
||||
state::AppState,
|
||||
};
|
||||
use application::diary::{commands::DeleteReviewCommand, delete_review, log_review};
|
||||
use axum::{
|
||||
Form,
|
||||
extract::{Extension, Path, Query, State},
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Redirect},
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
pub async fn get_new_review_page(
|
||||
RequiredCookieUser(user_id): RequiredCookieUser,
|
||||
State(state): State<AppState>,
|
||||
Query(params): Query<ErrorQuery>,
|
||||
Extension(csrf): Extension<CsrfToken>,
|
||||
) -> impl IntoResponse {
|
||||
let mut ctx = build_page_context(&state, Some(user_id), csrf.0).await;
|
||||
ctx.page_title = "Log a Review — Movies Diary".to_string();
|
||||
ctx.canonical_url = format!("{}/reviews/new", state.app_ctx.config.base_url);
|
||||
render_page(NewReviewTemplate {
|
||||
ctx: &ctx,
|
||||
error: params.error.as_deref(),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn post_review_html(
|
||||
State(state): State<AppState>,
|
||||
RequiredCookieUser(user_id): RequiredCookieUser,
|
||||
Extension(csrf): Extension<CsrfToken>,
|
||||
Form(form): Form<LogReviewForm>,
|
||||
) -> impl IntoResponse {
|
||||
if crate::html::csrf::mismatch(&csrf, &form.csrf_token) {
|
||||
return StatusCode::FORBIDDEN.into_response();
|
||||
}
|
||||
let data = match LogReviewData::try_from(form) {
|
||||
Ok(d) => d,
|
||||
Err(_) => {
|
||||
return Redirect::to("/reviews/new?error=Invalid+date+format").into_response();
|
||||
}
|
||||
};
|
||||
|
||||
match log_review::execute(
|
||||
&state.app_ctx.services.review_logger,
|
||||
data.into_command(user_id.value()),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => Redirect::to("/").into_response(),
|
||||
Err(e) => {
|
||||
let msg = encode_domain_error(&e);
|
||||
Redirect::to(&format!("/reviews/new?error={}", msg)).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn post_delete_review_html(
|
||||
State(state): State<AppState>,
|
||||
RequiredCookieUser(user_id): RequiredCookieUser,
|
||||
Extension(csrf): Extension<CsrfToken>,
|
||||
Path(review_id): Path<Uuid>,
|
||||
Form(form): Form<crate::html::forms::DeleteRedirectForm>,
|
||||
) -> impl IntoResponse {
|
||||
if crate::html::csrf::mismatch(&csrf, &form.csrf_token) {
|
||||
return StatusCode::FORBIDDEN.into_response();
|
||||
}
|
||||
let cmd = DeleteReviewCommand {
|
||||
review_id,
|
||||
requesting_user_id: user_id.value(),
|
||||
};
|
||||
match delete_review::execute(&state.app_ctx.deps.diary.delete_review, cmd).await {
|
||||
Ok(()) => {
|
||||
let redirect_url = form
|
||||
.redirect_after
|
||||
.filter(|url| {
|
||||
(url.starts_with('/') && !url.starts_with("//")) || url.starts_with('?')
|
||||
})
|
||||
.unwrap_or_else(|| "/".to_string());
|
||||
Redirect::to(&redirect_url).into_response()
|
||||
}
|
||||
Err(e) => crate::errors::domain_error_response(e),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_export_html(
|
||||
State(state): State<AppState>,
|
||||
RequiredCookieUser(user_id): RequiredCookieUser,
|
||||
Query(params): Query<api_types::ExportQueryParams>,
|
||||
) -> impl IntoResponse {
|
||||
build_export_response(¶ms.format, user_id.value(), &state)
|
||||
}
|
||||
|
||||
pub async fn get_activity_feed_html(
|
||||
OptionalCookieUser(user_id): OptionalCookieUser,
|
||||
State(state): State<AppState>,
|
||||
Query(params): Query<FeedQueryParams>,
|
||||
Extension(csrf): Extension<CsrfToken>,
|
||||
) -> impl IntoResponse {
|
||||
let ctx = build_page_context(&state, user_id.clone(), csrf.0).await;
|
||||
let limit = params.limit.unwrap_or(20);
|
||||
let offset = params.offset.unwrap_or(0);
|
||||
|
||||
let filter_following = params.filter == "following" && user_id.is_some();
|
||||
let filter_str = if filter_following { "following" } else { "all" };
|
||||
|
||||
let sort_by_str = match params.sort_by.as_str() {
|
||||
"date_asc" => "date_asc",
|
||||
"rating" => "rating",
|
||||
"rating_asc" => "rating_asc",
|
||||
_ => "date",
|
||||
};
|
||||
|
||||
let query = application::diary::queries::GetActivityFeedQuery {
|
||||
limit,
|
||||
offset,
|
||||
sort_by: sort_by_str.parse().unwrap_or_default(),
|
||||
search: if params.search.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(params.search.clone())
|
||||
},
|
||||
viewer_user_id: user_id.map(|u| u.value()),
|
||||
filter_following,
|
||||
};
|
||||
|
||||
match application::diary::get_activity_feed::execute(
|
||||
&state.app_ctx.deps.diary.get_activity_feed,
|
||||
query,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(entries) => {
|
||||
let entry_limit = entries.limit;
|
||||
let entry_offset = entries.offset;
|
||||
let has_more =
|
||||
(entry_offset as u64).saturating_add(entry_limit as u64) < entries.total_count;
|
||||
let total_pages = (entries.total_count as u32)
|
||||
.saturating_add(entry_limit.saturating_sub(1))
|
||||
.checked_div(entry_limit)
|
||||
.unwrap_or(1);
|
||||
let current_page = entry_offset.checked_div(entry_limit).unwrap_or(0);
|
||||
let page_items = build_page_items(total_pages, current_page);
|
||||
render_page(ActivityFeedTemplate {
|
||||
entries: entries.items.as_slice(),
|
||||
current_offset: entry_offset,
|
||||
limit: entry_limit,
|
||||
has_more,
|
||||
ctx: &ctx,
|
||||
page_items,
|
||||
filter: filter_str.to_string(),
|
||||
sort_by: sort_by_str.to_string(),
|
||||
search: params.search,
|
||||
})
|
||||
.into_response()
|
||||
}
|
||||
Err(e) => crate::errors::domain_error_response(e),
|
||||
}
|
||||
}
|
||||
@@ -312,5 +312,5 @@ pub struct DismissWatchForm {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/forms.rs"]
|
||||
#[path = "forms_tests.rs"]
|
||||
mod tests;
|
||||
62
crates/adapters/http-axum/src/html/helpers.rs
Normal file
62
crates/adapters/http-axum/src/html/helpers.rs
Normal file
@@ -0,0 +1,62 @@
|
||||
use api_types::HtmlPageContext;
|
||||
use domain::value_objects::UserId;
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
pub(crate) fn encode_error(msg: &str) -> String {
|
||||
use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode};
|
||||
utf8_percent_encode(msg, NON_ALPHANUMERIC).to_string()
|
||||
}
|
||||
|
||||
/// Error text for a classic-page `?error=` redirect. Infrastructure failures
|
||||
/// are logged here — the redirect is their only exit, so nothing else would —
|
||||
/// and shown to the user generically, never with internal detail.
|
||||
pub(crate) fn encode_domain_error(e: &domain::errors::DomainError) -> String {
|
||||
match e {
|
||||
domain::errors::DomainError::InfrastructureError(_) => {
|
||||
tracing::error!("Internal error: {e}");
|
||||
encode_error("Something went wrong, please try again")
|
||||
}
|
||||
_ => encode_error(&e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn build_page_context(
|
||||
state: &AppState,
|
||||
user_id: Option<UserId>,
|
||||
csrf_token: String,
|
||||
) -> HtmlPageContext {
|
||||
let uuid = user_id.as_ref().map(|u| u.value());
|
||||
// Page chrome (email/admin badge, pending-follow badge) degrades rather than
|
||||
// blanking the page: a failed lookup or count logs a warning and falls back
|
||||
// to a zeroed `PageViewer`, same as the previous silent `.unwrap_or(0)` — now
|
||||
// logged and explicit, per the error policy in `get_page_viewer`'s doc comment.
|
||||
let (user_email, is_admin, pending_follow_count) = if let Some(ref id) = user_id {
|
||||
match application::users::get_page_viewer::execute(
|
||||
&state.app_ctx.deps.users.get_page_viewer,
|
||||
id.value(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(viewer) => (viewer.email, viewer.is_admin, viewer.pending_follow_count),
|
||||
Err(e) => {
|
||||
tracing::warn!("get_page_viewer failed for {}: {e}", id.value());
|
||||
(None, false, 0)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
(None, false, 0)
|
||||
};
|
||||
HtmlPageContext {
|
||||
user_email,
|
||||
user_id: uuid,
|
||||
is_admin,
|
||||
register_enabled: state.app_ctx.config.allow_registration,
|
||||
rss_url: "/feed.rss".to_string(),
|
||||
page_title: "Movies Diary".to_string(),
|
||||
canonical_url: state.app_ctx.config.base_url.clone(),
|
||||
csrf_token,
|
||||
page_rss_url: None,
|
||||
pending_follow_count,
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,10 @@
|
||||
use crate::state::AppState;
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::{StatusCode, header},
|
||||
response::IntoResponse,
|
||||
};
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
pub async fn get_image(
|
||||
State(state): State<AppState>,
|
||||
Path(key): Path<String>,
|
||||
149
crates/adapters/http-axum/src/html/import/mapping.rs
Normal file
149
crates/adapters/http-axum/src/html/import/mapping.rs
Normal file
@@ -0,0 +1,149 @@
|
||||
use crate::html::helpers::encode_domain_error;
|
||||
use crate::html::render::render_page;
|
||||
use crate::html::templates::ImportMappingTemplate;
|
||||
use crate::support::str_to_domain_field;
|
||||
use crate::{extractors::RequiredCookieUser, html::csrf::CsrfToken, state::AppState};
|
||||
use application::import::{
|
||||
apply_mapping as apply_import_mapping, commands::ApplyImportMappingCommand, get_mapping_stage,
|
||||
};
|
||||
use axum::{
|
||||
Extension, Form,
|
||||
extract::{Path, State},
|
||||
response::{IntoResponse, Redirect},
|
||||
};
|
||||
use domain::models::{
|
||||
FieldMapping,
|
||||
import::{DomainField, Transform},
|
||||
};
|
||||
use domain::value_objects::ImportSessionId;
|
||||
use std::collections::HashMap;
|
||||
|
||||
fn parse_mapping_form(form: &HashMap<String, String>) -> Vec<FieldMapping> {
|
||||
let mut mappings = Vec::new();
|
||||
let mut i = 0usize;
|
||||
loop {
|
||||
if i > 64 {
|
||||
break;
|
||||
}
|
||||
let col_key = format!("mapping_{i}_col");
|
||||
let Some(col) = form.get(&col_key).cloned() else {
|
||||
break;
|
||||
};
|
||||
let field_str = form
|
||||
.get(&format!("mapping_{i}_field"))
|
||||
.map(|s| s.as_str())
|
||||
.unwrap_or("");
|
||||
if let Some(domain_field) = str_to_domain_field(field_str) {
|
||||
let transform = if domain_field == DomainField::Rating {
|
||||
let scale: f64 = form
|
||||
.get(&format!("mapping_{i}_scale"))
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(1.0);
|
||||
Transform::RatingScale(scale)
|
||||
} else if domain_field == DomainField::WatchedAt {
|
||||
form.get(&format!("mapping_{i}_datefmt"))
|
||||
.filter(|s| !s.is_empty())
|
||||
.cloned()
|
||||
.map(Transform::DateFormat)
|
||||
.unwrap_or(Transform::Identity)
|
||||
} else {
|
||||
Transform::Identity
|
||||
};
|
||||
mappings.push(FieldMapping {
|
||||
source_column: col,
|
||||
domain_field,
|
||||
transform,
|
||||
});
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
mappings
|
||||
}
|
||||
|
||||
pub async fn get_mapping_page(
|
||||
State(state): State<AppState>,
|
||||
RequiredCookieUser(user_id): RequiredCookieUser,
|
||||
Path(session_id_str): Path<String>,
|
||||
Extension(csrf): Extension<CsrfToken>,
|
||||
) -> impl IntoResponse {
|
||||
let Ok(session_id) = session_id_str
|
||||
.parse::<uuid::Uuid>()
|
||||
.map(ImportSessionId::from_uuid)
|
||||
else {
|
||||
return Redirect::to("/import").into_response();
|
||||
};
|
||||
let Ok(stage) = get_mapping_stage::execute(
|
||||
&state.app_ctx.deps.import.get_mapping_stage,
|
||||
session_id,
|
||||
user_id.value(),
|
||||
)
|
||||
.await
|
||||
else {
|
||||
return Redirect::to("/import").into_response();
|
||||
};
|
||||
|
||||
let ctx = crate::html::helpers::build_page_context(&state, Some(user_id), csrf.0).await;
|
||||
let domain_fields: Vec<(&str, &str)> = vec![
|
||||
("title", "Title"),
|
||||
("release_year", "Release Year"),
|
||||
("director", "Director"),
|
||||
("rating", "Rating"),
|
||||
("watched_at", "Watched At"),
|
||||
("comment", "Comment"),
|
||||
("external_metadata_id", "External ID"),
|
||||
];
|
||||
render_page(ImportMappingTemplate {
|
||||
ctx: &ctx,
|
||||
session_id: &session_id_str,
|
||||
columns: &stage.columns,
|
||||
sample_rows: &stage.sample_rows,
|
||||
domain_fields: &domain_fields,
|
||||
error: None,
|
||||
})
|
||||
.into_response()
|
||||
}
|
||||
|
||||
pub async fn post_mapping(
|
||||
State(state): State<AppState>,
|
||||
RequiredCookieUser(user_id): RequiredCookieUser,
|
||||
Path(session_id_str): Path<String>,
|
||||
Extension(csrf): Extension<CsrfToken>,
|
||||
Form(form): Form<HashMap<String, String>>,
|
||||
) -> impl IntoResponse {
|
||||
let csrf_token = form.get("_csrf").map(|s| s.as_str()).unwrap_or("");
|
||||
if crate::html::csrf::mismatch(&csrf, csrf_token) {
|
||||
return Redirect::to("/import").into_response();
|
||||
}
|
||||
let Ok(session_id) = session_id_str
|
||||
.parse::<uuid::Uuid>()
|
||||
.map(ImportSessionId::from_uuid)
|
||||
else {
|
||||
return Redirect::to("/import").into_response();
|
||||
};
|
||||
let mappings = parse_mapping_form(&form);
|
||||
if mappings.is_empty() {
|
||||
return Redirect::to(&format!(
|
||||
"/import/{}/mapping?error=select+at+least+one+mapping",
|
||||
session_id_str
|
||||
))
|
||||
.into_response();
|
||||
}
|
||||
match apply_import_mapping::execute(
|
||||
&state.app_ctx.deps.import.apply_mapping,
|
||||
ApplyImportMappingCommand {
|
||||
user_id: user_id.value(),
|
||||
session_id: session_id.value(),
|
||||
mappings,
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => Redirect::to(&format!("/import/{}/preview", session_id_str)).into_response(),
|
||||
Err(e) => Redirect::to(&format!(
|
||||
"/import/{}/mapping?error={}",
|
||||
session_id_str,
|
||||
encode_domain_error(&e)
|
||||
))
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
7
crates/adapters/http-axum/src/html/import/mod.rs
Normal file
7
crates/adapters/http-axum/src/html/import/mod.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
mod mapping;
|
||||
mod preview;
|
||||
mod upload;
|
||||
|
||||
pub use mapping::*;
|
||||
pub use preview::*;
|
||||
pub use upload::*;
|
||||
131
crates/adapters/http-axum/src/html/import/preview.rs
Normal file
131
crates/adapters/http-axum/src/html/import/preview.rs
Normal file
@@ -0,0 +1,131 @@
|
||||
use crate::html::helpers::encode_domain_error;
|
||||
use crate::html::mappers::import::annotated_to_preview_row;
|
||||
use crate::html::render::render_page;
|
||||
use crate::html::templates::{ImportPreviewRow, ImportPreviewTemplate};
|
||||
use crate::{extractors::RequiredCookieUser, html::csrf::CsrfToken, state::AppState};
|
||||
use application::import::{
|
||||
commands::{ExecuteImportCommand, SaveImportProfileCommand},
|
||||
execute as execute_import, get_preview_stage, save_profile as save_import_profile,
|
||||
};
|
||||
use axum::{
|
||||
Extension, Form,
|
||||
extract::{Path, State},
|
||||
response::{IntoResponse, Redirect},
|
||||
};
|
||||
use domain::value_objects::ImportSessionId;
|
||||
|
||||
pub async fn get_preview_page(
|
||||
State(state): State<AppState>,
|
||||
RequiredCookieUser(user_id): RequiredCookieUser,
|
||||
Path(session_id_str): Path<String>,
|
||||
Extension(csrf): Extension<CsrfToken>,
|
||||
) -> impl IntoResponse {
|
||||
let Ok(session_id) = session_id_str
|
||||
.parse::<uuid::Uuid>()
|
||||
.map(ImportSessionId::from_uuid)
|
||||
else {
|
||||
return Redirect::to("/import").into_response();
|
||||
};
|
||||
let Ok(stage) = get_preview_stage::execute(
|
||||
&state.app_ctx.deps.import.get_preview_stage,
|
||||
session_id,
|
||||
user_id.value(),
|
||||
)
|
||||
.await
|
||||
else {
|
||||
return Redirect::to("/import").into_response();
|
||||
};
|
||||
|
||||
let preview = match stage {
|
||||
get_preview_stage::PreviewStage::Ready(preview) => preview,
|
||||
get_preview_stage::PreviewStage::NotYetMapped => {
|
||||
return Redirect::to(&format!("/import/{}/mapping", session_id_str)).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let rows: Vec<ImportPreviewRow> = preview
|
||||
.rows
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, a)| annotated_to_preview_row(i, a))
|
||||
.collect();
|
||||
|
||||
let ctx = crate::html::helpers::build_page_context(&state, Some(user_id), csrf.0).await;
|
||||
render_page(ImportPreviewTemplate {
|
||||
ctx: &ctx,
|
||||
session_id: &session_id_str,
|
||||
columns: &preview.columns,
|
||||
rows: &rows,
|
||||
})
|
||||
.into_response()
|
||||
}
|
||||
|
||||
pub async fn post_confirm(
|
||||
State(state): State<AppState>,
|
||||
RequiredCookieUser(user_id): RequiredCookieUser,
|
||||
Path(session_id_str): Path<String>,
|
||||
Extension(csrf): Extension<CsrfToken>,
|
||||
Form(form_entries): Form<Vec<(String, String)>>,
|
||||
) -> impl IntoResponse {
|
||||
let csrf_token = form_entries
|
||||
.iter()
|
||||
.find(|(k, _)| k == "_csrf")
|
||||
.map(|(_, v)| v.as_str())
|
||||
.unwrap_or("");
|
||||
if crate::html::csrf::mismatch(&csrf, csrf_token) {
|
||||
return Redirect::to("/import").into_response();
|
||||
}
|
||||
let Ok(session_id) = session_id_str
|
||||
.parse::<uuid::Uuid>()
|
||||
.map(ImportSessionId::from_uuid)
|
||||
else {
|
||||
return Redirect::to("/import").into_response();
|
||||
};
|
||||
|
||||
// Save profile if name provided
|
||||
let profile_name = form_entries
|
||||
.iter()
|
||||
.find(|(k, _)| k == "profile_name")
|
||||
.map(|(_, v)| v.clone())
|
||||
.filter(|n| !n.trim().is_empty());
|
||||
if let Some(name) = profile_name {
|
||||
let _ = save_import_profile::execute(
|
||||
&state.app_ctx.deps.import.save_profile,
|
||||
SaveImportProfileCommand {
|
||||
user_id: user_id.value(),
|
||||
session_id: session_id.value(),
|
||||
name,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
// Collect all "confirmed" checkbox values
|
||||
let confirmed: Vec<usize> = form_entries
|
||||
.iter()
|
||||
.filter(|(k, _)| k == "confirmed")
|
||||
.filter_map(|(_, v)| v.parse::<usize>().ok())
|
||||
.collect();
|
||||
|
||||
match execute_import::execute(
|
||||
&state.app_ctx.deps.import.execute_import,
|
||||
ExecuteImportCommand {
|
||||
user_id: user_id.value(),
|
||||
session_id: session_id.value(),
|
||||
confirmed_indices: confirmed,
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(summary) => Redirect::to(&format!(
|
||||
"/import/done?imported={}&skipped={}&failed={}",
|
||||
summary.imported,
|
||||
summary.skipped_duplicates,
|
||||
summary.failed.len()
|
||||
))
|
||||
.into_response(),
|
||||
Err(e) => {
|
||||
Redirect::to(&format!("/import?error={}", encode_domain_error(&e))).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
147
crates/adapters/http-axum/src/html/import/upload.rs
Normal file
147
crates/adapters/http-axum/src/html/import/upload.rs
Normal file
@@ -0,0 +1,147 @@
|
||||
use crate::html::helpers::encode_domain_error;
|
||||
use crate::html::render::render_page;
|
||||
use crate::html::templates::{ImportProfileView, ImportUploadTemplate};
|
||||
use crate::{extractors::RequiredCookieUser, html::csrf::CsrfToken, state::AppState};
|
||||
use application::import::{
|
||||
commands::{CreateImportSessionCommand, DeleteImportProfileCommand},
|
||||
create_session as create_import_session, delete_profile as delete_import_profile,
|
||||
list_profiles as list_import_profiles,
|
||||
};
|
||||
use axum::{
|
||||
Extension, Form,
|
||||
extract::{Multipart, Path, State},
|
||||
response::{Html, IntoResponse, Redirect},
|
||||
};
|
||||
use domain::models::FileFormat;
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashMap;
|
||||
|
||||
pub async fn get_import_page(
|
||||
State(state): State<AppState>,
|
||||
RequiredCookieUser(user_id): RequiredCookieUser,
|
||||
Extension(csrf): Extension<CsrfToken>,
|
||||
) -> impl IntoResponse {
|
||||
let ctx = crate::html::helpers::build_page_context(&state, Some(user_id.clone()), csrf.0).await;
|
||||
let profiles =
|
||||
list_import_profiles::execute(&state.app_ctx.deps.import.list_profiles, &user_id)
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|p| ImportProfileView {
|
||||
id: p.id.value().to_string(),
|
||||
name: p.name,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
render_page(ImportUploadTemplate {
|
||||
ctx: &ctx,
|
||||
profiles: &profiles,
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn post_upload(
|
||||
State(state): State<AppState>,
|
||||
RequiredCookieUser(user_id): RequiredCookieUser,
|
||||
mut multipart: Multipart,
|
||||
) -> impl IntoResponse {
|
||||
let mut file_bytes: Option<Vec<u8>> = None;
|
||||
let mut format_str = "csv".to_string();
|
||||
|
||||
while let Ok(Some(field)) = multipart.next_field().await {
|
||||
match field.name() {
|
||||
Some("file") => {
|
||||
if let Ok(bytes) = field.bytes().await {
|
||||
file_bytes = Some(bytes.to_vec());
|
||||
}
|
||||
}
|
||||
Some("format") => {
|
||||
if let Ok(text) = field.text().await {
|
||||
format_str = text;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let bytes = match file_bytes {
|
||||
Some(b) if !b.is_empty() => b,
|
||||
_ => return Redirect::to("/import?error=no+file+provided").into_response(),
|
||||
};
|
||||
|
||||
let format = match format_str.as_str() {
|
||||
"json" => FileFormat::Json,
|
||||
"xlsx" => FileFormat::Xlsx,
|
||||
_ => FileFormat::Csv,
|
||||
};
|
||||
|
||||
match create_import_session::execute(
|
||||
&state.app_ctx.deps.import.create_session,
|
||||
CreateImportSessionCommand {
|
||||
user_id: user_id.value(),
|
||||
bytes,
|
||||
format,
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(r) => Redirect::to(&format!("/import/{}/mapping", r.session_id.value())).into_response(),
|
||||
Err(e) => {
|
||||
Redirect::to(&format!("/import?error={}", encode_domain_error(&e))).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_import_done(
|
||||
State(state): State<AppState>,
|
||||
RequiredCookieUser(user_id): RequiredCookieUser,
|
||||
Extension(csrf): Extension<CsrfToken>,
|
||||
axum::extract::Query(params): axum::extract::Query<ImportDoneParams>,
|
||||
) -> impl IntoResponse {
|
||||
let _ctx =
|
||||
crate::html::helpers::build_page_context(&state, Some(user_id.clone()), csrf.0).await;
|
||||
let html = format!(
|
||||
r#"<!doctype html><html><body>
|
||||
<h1>Import Complete</h1>
|
||||
<p>Imported: {}</p>
|
||||
<p>Skipped duplicates: {}</p>
|
||||
<p>Failed: {}</p>
|
||||
<a href="/users/{}">Go to My Profile</a>
|
||||
</body></html>"#,
|
||||
params.imported.unwrap_or(0),
|
||||
params.skipped.unwrap_or(0),
|
||||
params.failed.unwrap_or(0),
|
||||
user_id.value(),
|
||||
);
|
||||
Html(html)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ImportDoneParams {
|
||||
pub imported: Option<usize>,
|
||||
pub skipped: Option<usize>,
|
||||
pub failed: Option<usize>,
|
||||
}
|
||||
|
||||
pub async fn post_delete_profile(
|
||||
State(state): State<AppState>,
|
||||
RequiredCookieUser(user_id): RequiredCookieUser,
|
||||
Path(profile_id_str): Path<String>,
|
||||
Extension(csrf): Extension<CsrfToken>,
|
||||
Form(form): Form<HashMap<String, String>>,
|
||||
) -> impl IntoResponse {
|
||||
let csrf_token = form.get("_csrf").map(|s| s.as_str()).unwrap_or("");
|
||||
if crate::html::csrf::mismatch(&csrf, csrf_token) {
|
||||
return Redirect::to("/import").into_response();
|
||||
}
|
||||
if let Ok(profile_id) = profile_id_str.parse::<uuid::Uuid>() {
|
||||
let _ = delete_import_profile::execute(
|
||||
&state.app_ctx.deps.import.delete_profile,
|
||||
DeleteImportProfileCommand {
|
||||
user_id: user_id.value(),
|
||||
profile_id,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
Redirect::to("/import").into_response()
|
||||
}
|
||||
@@ -1,11 +1,9 @@
|
||||
use axum::{
|
||||
Form,
|
||||
extract::{Extension, Path, Query, State},
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Redirect},
|
||||
use crate::html::helpers::{build_page_context, encode_domain_error};
|
||||
use crate::html::templates::{IntegrationsTemplate, WatchQueueTemplate};
|
||||
use crate::{
|
||||
extractors::RequiredCookieUser, html::csrf::CsrfToken, html::forms::ErrorQuery,
|
||||
html::render::render_page, state::AppState,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
use application::integrations::{
|
||||
commands::{
|
||||
ConfirmWatchEventsCommand, DismissWatchEventsCommand, GenerateWebhookTokenCommand,
|
||||
@@ -17,21 +15,18 @@ use application::integrations::{
|
||||
queries::{GetWatchQueueQuery, GetWebhookTokensQuery},
|
||||
revoke_token as revoke_webhook_token,
|
||||
};
|
||||
|
||||
use crate::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<AppState>,
|
||||
Query(params): Query<crate::forms::IntegrationsQuery>,
|
||||
Query(params): Query<crate::html::forms::IntegrationsQuery>,
|
||||
Extension(csrf): Extension<CsrfToken>,
|
||||
) -> impl IntoResponse {
|
||||
let mut ctx = build_page_context(&state, Some(user_id.clone()), csrf.0).await;
|
||||
@@ -48,7 +43,7 @@ pub async fn get_integrations_page(
|
||||
|
||||
let token_views: Vec<crate::html::templates::WebhookTokenView> = tokens
|
||||
.iter()
|
||||
.map(crate::mappers::integrations::webhook_token_view)
|
||||
.map(crate::html::mappers::integrations::webhook_token_view)
|
||||
.collect();
|
||||
|
||||
let webhook_base_url = state.app_ctx.config.base_url.clone();
|
||||
@@ -65,9 +60,9 @@ pub async fn post_generate_token(
|
||||
RequiredCookieUser(user_id): RequiredCookieUser,
|
||||
State(state): State<AppState>,
|
||||
Extension(csrf): Extension<CsrfToken>,
|
||||
Form(form): Form<crate::forms::GenerateTokenForm>,
|
||||
Form(form): Form<crate::html::forms::GenerateTokenForm>,
|
||||
) -> impl IntoResponse {
|
||||
if crate::csrf::mismatch(&csrf, &form.csrf_token) {
|
||||
if crate::html::csrf::mismatch(&csrf, &form.csrf_token) {
|
||||
return StatusCode::FORBIDDEN.into_response();
|
||||
}
|
||||
|
||||
@@ -107,9 +102,9 @@ pub async fn post_revoke_token(
|
||||
State(state): State<AppState>,
|
||||
Path(token_id): Path<Uuid>,
|
||||
Extension(csrf): Extension<CsrfToken>,
|
||||
Form(form): Form<crate::forms::RevokeTokenForm>,
|
||||
Form(form): Form<crate::html::forms::RevokeTokenForm>,
|
||||
) -> impl IntoResponse {
|
||||
if crate::csrf::mismatch(&csrf, &form.csrf_token) {
|
||||
if crate::html::csrf::mismatch(&csrf, &form.csrf_token) {
|
||||
return StatusCode::FORBIDDEN.into_response();
|
||||
}
|
||||
|
||||
@@ -127,8 +122,6 @@ pub async fn post_revoke_token(
|
||||
Redirect::to("/settings/integrations").into_response()
|
||||
}
|
||||
|
||||
// ── Watch Queue ──────────────────────────────────────────────────────────────
|
||||
|
||||
pub async fn get_watch_queue_page(
|
||||
RequiredCookieUser(user_id): RequiredCookieUser,
|
||||
State(state): State<AppState>,
|
||||
@@ -148,7 +141,7 @@ pub async fn get_watch_queue_page(
|
||||
|
||||
let entries: Vec<crate::html::templates::WatchQueueDisplayEntry> = events
|
||||
.iter()
|
||||
.map(crate::mappers::integrations::watch_queue_entry)
|
||||
.map(crate::html::mappers::integrations::watch_queue_entry)
|
||||
.collect();
|
||||
|
||||
render_page(WatchQueueTemplate {
|
||||
@@ -164,9 +157,9 @@ pub async fn post_confirm_single(
|
||||
State(state): State<AppState>,
|
||||
Path(event_id): Path<Uuid>,
|
||||
Extension(csrf): Extension<CsrfToken>,
|
||||
Form(form): Form<crate::forms::ConfirmWatchForm>,
|
||||
Form(form): Form<crate::html::forms::ConfirmWatchForm>,
|
||||
) -> impl IntoResponse {
|
||||
if crate::csrf::mismatch(&csrf, &form.csrf_token) {
|
||||
if crate::html::csrf::mismatch(&csrf, &form.csrf_token) {
|
||||
return StatusCode::FORBIDDEN.into_response();
|
||||
}
|
||||
|
||||
@@ -195,9 +188,9 @@ pub async fn post_dismiss_single(
|
||||
State(state): State<AppState>,
|
||||
Path(event_id): Path<Uuid>,
|
||||
Extension(csrf): Extension<CsrfToken>,
|
||||
Form(form): Form<crate::forms::DismissWatchForm>,
|
||||
Form(form): Form<crate::html::forms::DismissWatchForm>,
|
||||
) -> impl IntoResponse {
|
||||
if crate::csrf::mismatch(&csrf, &form.csrf_token) {
|
||||
if crate::html::csrf::mismatch(&csrf, &form.csrf_token) {
|
||||
return StatusCode::FORBIDDEN.into_response();
|
||||
}
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
pub mod diary;
|
||||
pub mod import;
|
||||
pub mod integrations;
|
||||
pub mod movies;
|
||||
pub mod search;
|
||||
pub mod users;
|
||||
pub mod watchlist;
|
||||
@@ -1 +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<AppState> {
|
||||
let base = Router::new()
|
||||
.route("/", routing::get(diary::get_activity_feed_html))
|
||||
.route("/users", routing::get(users::get_users_list))
|
||||
.route("/u/{username}", routing::get(users::get_user_by_username))
|
||||
.route("/users/{id}", routing::get(profile::get_user_profile_html))
|
||||
.route(
|
||||
"/movies/{movie_id}",
|
||||
routing::get(movies::get_movie_detail_html),
|
||||
)
|
||||
.route("/reviews/new", routing::get(diary::get_new_review_page))
|
||||
.route("/reviews", routing::post(diary::post_review_html))
|
||||
.route(
|
||||
"/reviews/{id}/delete",
|
||||
routing::post(diary::post_delete_review_html),
|
||||
)
|
||||
.route("/images/{*key}", routing::get(images::get_image))
|
||||
.route(
|
||||
"/posters/{path}",
|
||||
routing::get(
|
||||
|axum::extract::Path(p): axum::extract::Path<String>| async move {
|
||||
axum::response::Redirect::permanent(&format!("/images/{}", p))
|
||||
},
|
||||
),
|
||||
)
|
||||
.route("/diary/export", routing::get(diary::get_export_html))
|
||||
.route("/import", routing::get(import::get_import_page))
|
||||
.route("/import/upload", routing::post(import::post_upload))
|
||||
.route(
|
||||
"/import/{id}/mapping",
|
||||
routing::get(import::get_mapping_page).post(import::post_mapping),
|
||||
)
|
||||
.route(
|
||||
"/import/{id}/preview",
|
||||
routing::get(import::get_preview_page),
|
||||
)
|
||||
.route("/import/{id}/confirm", routing::post(import::post_confirm))
|
||||
.route("/import/done", routing::get(import::get_import_done))
|
||||
.route(
|
||||
"/import/profiles/{profile_id}/delete",
|
||||
routing::post(import::post_delete_profile),
|
||||
)
|
||||
.route("/feed.rss", routing::get(rss::get_feed))
|
||||
.route("/users/{id}/feed.rss", routing::get(rss::get_user_feed))
|
||||
.route(
|
||||
"/settings/profile",
|
||||
routing::get(users::get_profile_settings).post(users::post_profile_settings),
|
||||
)
|
||||
.route("/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<AppState> {
|
||||
Router::new()
|
||||
.route(
|
||||
"/login",
|
||||
routing::get(auth::get_login_page).post(auth::post_login),
|
||||
)
|
||||
.route("/logout", routing::get(auth::get_logout))
|
||||
.route(
|
||||
"/register",
|
||||
routing::get(auth::get_register_page).post(auth::post_register),
|
||||
)
|
||||
}
|
||||
|
||||
fn social_routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/users/{id}/follow", routing::post(social::follow_user))
|
||||
.route("/users/{id}/unfollow", routing::post(social::unfollow_user))
|
||||
.route(
|
||||
"/users/{id}/followers/accept",
|
||||
routing::post(social::accept_follower_html),
|
||||
)
|
||||
.route(
|
||||
"/users/{id}/followers/reject",
|
||||
routing::post(social::reject_follower_html),
|
||||
)
|
||||
.route(
|
||||
"/users/{id}/followers/remove",
|
||||
routing::post(social::remove_follower_html),
|
||||
)
|
||||
.route(
|
||||
"/users/{id}/following-list",
|
||||
routing::get(social::get_following_page),
|
||||
)
|
||||
.route(
|
||||
"/users/{id}/followers-list",
|
||||
routing::get(social::get_followers_page),
|
||||
)
|
||||
}
|
||||
|
||||
77
crates/adapters/http-axum/src/html/movies.rs
Normal file
77
crates/adapters/http-axum/src/html/movies.rs
Normal file
@@ -0,0 +1,77 @@
|
||||
use crate::html::helpers::build_page_context;
|
||||
use crate::html::templates::MovieDetailTemplate;
|
||||
use crate::{
|
||||
extractors::OptionalCookieUser, html::csrf::CsrfToken, html::render::render_page,
|
||||
state::AppState,
|
||||
};
|
||||
use application::{
|
||||
diary::{get_movie_social_page, queries::GetMovieSocialPageQuery},
|
||||
watchlist::{is_on as is_on_watchlist, queries::IsOnWatchlistQuery},
|
||||
};
|
||||
use axum::{
|
||||
extract::{Extension, Path, Query, State},
|
||||
response::IntoResponse,
|
||||
};
|
||||
|
||||
pub async fn get_movie_detail_html(
|
||||
OptionalCookieUser(user_id): OptionalCookieUser,
|
||||
State(state): State<AppState>,
|
||||
Path(movie_id): Path<uuid::Uuid>,
|
||||
Query(params): Query<api_types::PaginationQueryParams>,
|
||||
Extension(csrf): Extension<CsrfToken>,
|
||||
) -> impl IntoResponse {
|
||||
let ctx = build_page_context(&state, user_id.clone(), csrf.0).await;
|
||||
let limit = params.limit.unwrap_or(20);
|
||||
let offset = params.offset.unwrap_or(0);
|
||||
|
||||
match get_movie_social_page::execute(
|
||||
&state.app_ctx.deps.diary.get_movie_social_page,
|
||||
GetMovieSocialPageQuery {
|
||||
movie_id,
|
||||
limit,
|
||||
offset,
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
Err(e) => crate::errors::domain_error_response(e),
|
||||
Ok(result) => {
|
||||
let histogram_max = result
|
||||
.stats
|
||||
.rating_histogram
|
||||
.iter()
|
||||
.copied()
|
||||
.max()
|
||||
.unwrap_or(1);
|
||||
let has_more =
|
||||
result.reviews.offset + result.reviews.limit < result.reviews.total_count as u32;
|
||||
let on_watchlist = match &user_id {
|
||||
Some(uid) => is_on_watchlist::execute(
|
||||
&state.app_ctx.deps.watchlist.is_on_watchlist,
|
||||
IsOnWatchlistQuery {
|
||||
user_id: uid.value(),
|
||||
movie_id,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap_or(false),
|
||||
None => false,
|
||||
};
|
||||
let current_offset = result.reviews.offset;
|
||||
let reviews_limit = result.reviews.limit;
|
||||
render_page(MovieDetailTemplate {
|
||||
ctx: &ctx,
|
||||
movie: &result.movie,
|
||||
stats: &result.stats,
|
||||
profile: result.profile.as_ref(),
|
||||
reviews: result.reviews.items.as_slice(),
|
||||
on_watchlist,
|
||||
current_offset,
|
||||
has_more,
|
||||
limit: reviews_limit,
|
||||
histogram_max,
|
||||
})
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
260
crates/adapters/http-axum/src/html/profile.rs
Normal file
260
crates/adapters/http-axum/src/html/profile.rs
Normal file
@@ -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<crate::html::templates::PageItem>,
|
||||
}
|
||||
|
||||
fn compute_pagination(
|
||||
entries: Option<&domain::models::collections::Paginated<domain::models::DiaryEntry>>,
|
||||
) -> PaginationInfo {
|
||||
let (offset, has_more, limit) = entries
|
||||
.map(|e| {
|
||||
let has_more = (e.offset as u64).saturating_add(e.limit as u64) < e.total_count;
|
||||
(e.offset, has_more, e.limit)
|
||||
})
|
||||
.unwrap_or((0, false, DEFAULT_PAGE_LIMIT));
|
||||
let total = entries.map(|e| e.total_count as u32).unwrap_or(0);
|
||||
let total_pages = total
|
||||
.saturating_add(limit.saturating_sub(1))
|
||||
.checked_div(limit)
|
||||
.unwrap_or(1);
|
||||
let current_page = offset.checked_div(limit).unwrap_or(0);
|
||||
let page_items = build_page_items(total_pages, current_page);
|
||||
PaginationInfo {
|
||||
offset,
|
||||
has_more,
|
||||
limit,
|
||||
page_items,
|
||||
}
|
||||
}
|
||||
|
||||
struct StatsDisplay {
|
||||
avg_rating: String,
|
||||
favorite_director: String,
|
||||
most_active_month: String,
|
||||
}
|
||||
|
||||
fn build_stats_display(stats: &domain::models::UserStats) -> StatsDisplay {
|
||||
StatsDisplay {
|
||||
avg_rating: stats
|
||||
.avg_rating
|
||||
.map(|r| format!("{:.1}", r))
|
||||
.unwrap_or_else(|| "\u{2014}".to_string()),
|
||||
favorite_director: stats
|
||||
.favorite_director
|
||||
.clone()
|
||||
.unwrap_or_else(|| "\u{2014}".to_string()),
|
||||
most_active_month: stats
|
||||
.most_active_month
|
||||
.clone()
|
||||
.unwrap_or_else(|| "\u{2014}".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_monthly_rating_rows<'a>(
|
||||
trends: Option<&'a domain::models::UserTrends>,
|
||||
) -> Vec<MonthlyRatingRow<'a>> {
|
||||
trends
|
||||
.map(|t| {
|
||||
t.monthly_ratings
|
||||
.iter()
|
||||
.map(|r| MonthlyRatingRow {
|
||||
rating: r,
|
||||
bar_height_px: bar_height_px(r.avg_rating),
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
async fn fetch_profile_goals(
|
||||
state: &AppState,
|
||||
user_id: Uuid,
|
||||
) -> Vec<crate::html::templates::GoalViewData> {
|
||||
let goals_list = application::goals::list::execute(
|
||||
&state.app_ctx.deps.goals.query,
|
||||
application::goals::queries::ListGoalsQuery { user_id },
|
||||
)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
goals_list
|
||||
.iter()
|
||||
.map(|g| crate::html::templates::GoalViewData {
|
||||
year: g.goal.year(),
|
||||
target_count: g.goal.target_count(),
|
||||
current_count: g.current_count,
|
||||
percentage: g.percentage().round(),
|
||||
is_complete: g.is_complete(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub async fn get_user_profile_html(
|
||||
OptionalCookieUser(user_id): OptionalCookieUser,
|
||||
State(state): State<AppState>,
|
||||
Path(profile_user_uuid): Path<Uuid>,
|
||||
headers: axum::http::HeaderMap,
|
||||
Query(params): Query<crate::html::forms::ProfileQueryParams>,
|
||||
Extension(csrf): Extension<CsrfToken>,
|
||||
) -> impl IntoResponse {
|
||||
// Content negotiation: AP clients request application/activity+json
|
||||
if let Some(response) =
|
||||
crate::federation::actor_document_response(&state, &headers, profile_user_uuid).await
|
||||
{
|
||||
return response;
|
||||
}
|
||||
|
||||
let mut ctx = build_page_context(&state, user_id.clone(), csrf.0).await;
|
||||
let view_str = params.view.as_deref().unwrap_or("recent");
|
||||
let profile_view = match application::users::queries::ProfileView::from_str(view_str) {
|
||||
Ok(v) => v,
|
||||
Err(_) => {
|
||||
return (
|
||||
axum::http::StatusCode::BAD_REQUEST,
|
||||
"invalid view parameter",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let sort_by_str = match params.sort_by.as_str() {
|
||||
"date_asc" => "date_asc",
|
||||
"rating" => "rating",
|
||||
"rating_asc" => "rating_asc",
|
||||
_ => "date",
|
||||
};
|
||||
|
||||
let is_own_profile = user_id
|
||||
.as_ref()
|
||||
.map(|u| u.value() == profile_user_uuid)
|
||||
.unwrap_or(false);
|
||||
|
||||
let query = application::users::queries::GetUserProfileQuery {
|
||||
user_id: profile_user_uuid,
|
||||
view: profile_view,
|
||||
limit: params.limit,
|
||||
offset: params.offset,
|
||||
sort_by: sort_by_str.parse().unwrap_or_default(),
|
||||
search: if params.search.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(params.search.clone())
|
||||
},
|
||||
is_own_profile,
|
||||
include_remote: false,
|
||||
};
|
||||
|
||||
match get_local_profile::execute(&state.app_ctx.deps.users.get_local_profile, query).await {
|
||||
Ok(profile) => {
|
||||
ctx.page_title = format!("{}'s Diary — Movies Diary", profile.identity.username);
|
||||
ctx.canonical_url = format!(
|
||||
"{}/users/{}",
|
||||
state.app_ctx.config.base_url, profile_user_uuid
|
||||
);
|
||||
|
||||
let pag = compute_pagination(profile.entries.as_ref());
|
||||
if !is_own_profile {
|
||||
ctx.page_rss_url = Some(format!("/users/{}/feed.rss", profile_user_uuid));
|
||||
}
|
||||
let display_name = profile
|
||||
.identity
|
||||
.email
|
||||
.split('@')
|
||||
.next()
|
||||
.unwrap_or("?")
|
||||
.to_string();
|
||||
let stats_disp = build_stats_display(&profile.stats);
|
||||
let history = profile.history.map(application::users::group_by_month);
|
||||
let heatmap = history.as_deref().map(build_heatmap).unwrap_or_default();
|
||||
let monthly_rating_rows = build_monthly_rating_rows(profile.trends.as_ref());
|
||||
let pending_followers: Vec<RemoteActorData> = profile
|
||||
.pending_followers
|
||||
.iter()
|
||||
.map(crate::html::mappers::users::pending_follower_data)
|
||||
.collect();
|
||||
if params.embed {
|
||||
let profile_url = format!(
|
||||
"{}/users/{}",
|
||||
state.app_ctx.config.base_url, profile_user_uuid
|
||||
);
|
||||
let response = render_page(EmbedProfileTemplate {
|
||||
profile_display_name: display_name,
|
||||
profile_user_id: profile_user_uuid,
|
||||
profile_url,
|
||||
stats: &profile.stats,
|
||||
avg_rating_display: stats_disp.avg_rating,
|
||||
favorite_director_display: stats_disp.favorite_director,
|
||||
most_active_month_display: stats_disp.most_active_month,
|
||||
view: profile_view.as_str(),
|
||||
entries: profile.entries.as_ref(),
|
||||
current_offset: pag.offset,
|
||||
has_more: pag.has_more,
|
||||
limit: pag.limit,
|
||||
history: history.as_ref(),
|
||||
trends: profile.trends.as_ref(),
|
||||
monthly_rating_rows,
|
||||
heatmap,
|
||||
page_items: pag.page_items,
|
||||
sort_by: sort_by_str.to_string(),
|
||||
});
|
||||
let mut resp = response.into_response();
|
||||
resp.headers_mut().remove("x-frame-options");
|
||||
resp
|
||||
} else {
|
||||
let goals = fetch_profile_goals(&state, profile_user_uuid).await;
|
||||
render_page(ProfileTemplate {
|
||||
ctx: &ctx,
|
||||
profile_display_name: display_name,
|
||||
profile_user_id: profile_user_uuid,
|
||||
stats: &profile.stats,
|
||||
avg_rating_display: stats_disp.avg_rating,
|
||||
favorite_director_display: stats_disp.favorite_director,
|
||||
most_active_month_display: stats_disp.most_active_month,
|
||||
view: profile_view.as_str(),
|
||||
entries: profile.entries.as_ref(),
|
||||
current_offset: pag.offset,
|
||||
has_more: pag.has_more,
|
||||
limit: pag.limit,
|
||||
history: history.as_ref(),
|
||||
trends: profile.trends.as_ref(),
|
||||
monthly_rating_rows,
|
||||
heatmap,
|
||||
page_items: pag.page_items,
|
||||
is_own_profile,
|
||||
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),
|
||||
}
|
||||
}
|
||||
@@ -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<AppState>) -> Result<impl IntoResponse, ApiError> {
|
||||
let query = GetDiaryQuery {
|
||||
limit: Some(super::RSS_FEED_LIMIT),
|
||||
limit: Some(RSS_FEED_LIMIT),
|
||||
offset: Some(0),
|
||||
sort_by: Some(ReviewSortBy::Descending),
|
||||
movie_id: None,
|
||||
@@ -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?;
|
||||
|
||||
319
crates/adapters/http-axum/src/html/social.rs
Normal file
319
crates/adapters/http-axum/src/html/social.rs
Normal file
@@ -0,0 +1,319 @@
|
||||
use crate::html::helpers::{build_page_context, encode_domain_error};
|
||||
use crate::html::templates::{FollowersTemplate, FollowingTemplate, RemoteActorData};
|
||||
use crate::{
|
||||
extractors::RequiredCookieUser,
|
||||
html::csrf::CsrfToken,
|
||||
html::forms::{FollowForm, FollowerActionForm, UnfollowForm},
|
||||
html::render::render_page,
|
||||
state::AppState,
|
||||
};
|
||||
use axum::{
|
||||
Form,
|
||||
extract::{Extension, Path, Query, State},
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Redirect},
|
||||
};
|
||||
use domain::value_objects::{FollowTarget, InstanceIdentity, SocialActor};
|
||||
use uuid::Uuid;
|
||||
|
||||
fn social_actor_to_template(actor: SocialActor, instance: &InstanceIdentity) -> RemoteActorData {
|
||||
RemoteActorData {
|
||||
url: instance.actor_url_of(&actor.identity),
|
||||
handle: actor.handle,
|
||||
display_name: actor.display_name,
|
||||
avatar_url: actor.avatar_url,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn follow_user(
|
||||
RequiredCookieUser(user_id): RequiredCookieUser,
|
||||
State(state): State<AppState>,
|
||||
Path(profile_user_uuid): Path<Uuid>,
|
||||
Extension(csrf): Extension<CsrfToken>,
|
||||
Form(form): Form<FollowForm>,
|
||||
) -> impl IntoResponse {
|
||||
if user_id.value() != profile_user_uuid {
|
||||
return StatusCode::FORBIDDEN.into_response();
|
||||
}
|
||||
if crate::html::csrf::mismatch(&csrf, &form.csrf_token) {
|
||||
return StatusCode::FORBIDDEN.into_response();
|
||||
}
|
||||
let redirect_base = form
|
||||
.redirect_after
|
||||
.as_deref()
|
||||
.filter(|u| u.starts_with('/') && !u.starts_with("//"))
|
||||
.unwrap_or(&format!("/users/{}", profile_user_uuid))
|
||||
.to_string();
|
||||
|
||||
match application::social::execute::execute_command(
|
||||
&state.app_ctx.deps.social.command,
|
||||
application::social::commands::SocialCmd::Follow {
|
||||
follower_id: user_id.value(),
|
||||
target: FollowTarget::Handle(form.handle),
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(()) => Redirect::to(&redirect_base).into_response(),
|
||||
Err(e) => {
|
||||
tracing::error!("follow error: {:?}", e);
|
||||
let msg = encode_domain_error(&e);
|
||||
let sep = if redirect_base.contains('?') {
|
||||
'&'
|
||||
} else {
|
||||
'?'
|
||||
};
|
||||
Redirect::to(&format!("{}{}error={}", redirect_base, sep, msg)).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn unfollow_user(
|
||||
RequiredCookieUser(user_id): RequiredCookieUser,
|
||||
State(state): State<AppState>,
|
||||
Path(profile_user_uuid): Path<Uuid>,
|
||||
Extension(csrf): Extension<CsrfToken>,
|
||||
Form(form): Form<UnfollowForm>,
|
||||
) -> impl IntoResponse {
|
||||
if user_id.value() != profile_user_uuid {
|
||||
return StatusCode::FORBIDDEN.into_response();
|
||||
}
|
||||
if crate::html::csrf::mismatch(&csrf, &form.csrf_token) {
|
||||
return StatusCode::FORBIDDEN.into_response();
|
||||
}
|
||||
let instance = state.app_ctx.instance.clone();
|
||||
match application::social::execute::execute_command(
|
||||
&state.app_ctx.deps.social.command,
|
||||
application::social::commands::SocialCmd::Unfollow {
|
||||
follower_id: user_id.value(),
|
||||
target: instance.identify(&form.actor_url),
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(()) => {
|
||||
Redirect::to(&format!("/users/{}/following-list", profile_user_uuid)).into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
let msg = encode_domain_error(&e);
|
||||
Redirect::to(&format!(
|
||||
"/users/{}/following-list?error={}",
|
||||
profile_user_uuid, msg
|
||||
))
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn accept_follower_html(
|
||||
RequiredCookieUser(user_id): RequiredCookieUser,
|
||||
State(state): State<AppState>,
|
||||
Path(profile_user_uuid): Path<Uuid>,
|
||||
Extension(csrf): Extension<CsrfToken>,
|
||||
Form(form): Form<FollowerActionForm>,
|
||||
) -> impl IntoResponse {
|
||||
if user_id.value() != profile_user_uuid {
|
||||
return StatusCode::FORBIDDEN.into_response();
|
||||
}
|
||||
if crate::html::csrf::mismatch(&csrf, &form.csrf_token) {
|
||||
return StatusCode::FORBIDDEN.into_response();
|
||||
}
|
||||
let instance = state.app_ctx.instance.clone();
|
||||
match application::social::execute::execute_command(
|
||||
&state.app_ctx.deps.social.command,
|
||||
application::social::commands::SocialCmd::AcceptFollow {
|
||||
owner_id: user_id.value(),
|
||||
requester: instance.identify(&form.actor_url),
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => Redirect::to(&format!("/users/{}", profile_user_uuid)).into_response(),
|
||||
Err(e) => {
|
||||
let msg = encode_domain_error(&e);
|
||||
Redirect::to(&format!("/users/{}?error={}", profile_user_uuid, msg)).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn reject_follower_html(
|
||||
RequiredCookieUser(user_id): RequiredCookieUser,
|
||||
State(state): State<AppState>,
|
||||
Path(profile_user_uuid): Path<Uuid>,
|
||||
Extension(csrf): Extension<CsrfToken>,
|
||||
Form(form): Form<FollowerActionForm>,
|
||||
) -> impl IntoResponse {
|
||||
if user_id.value() != profile_user_uuid {
|
||||
return StatusCode::FORBIDDEN.into_response();
|
||||
}
|
||||
if crate::html::csrf::mismatch(&csrf, &form.csrf_token) {
|
||||
return StatusCode::FORBIDDEN.into_response();
|
||||
}
|
||||
let instance = state.app_ctx.instance.clone();
|
||||
match application::social::execute::execute_command(
|
||||
&state.app_ctx.deps.social.command,
|
||||
application::social::commands::SocialCmd::RejectFollow {
|
||||
owner_id: user_id.value(),
|
||||
requester: instance.identify(&form.actor_url),
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => Redirect::to(&format!("/users/{}", profile_user_uuid)).into_response(),
|
||||
Err(e) => {
|
||||
let msg = encode_domain_error(&e);
|
||||
Redirect::to(&format!("/users/{}?error={}", profile_user_uuid, msg)).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_following_page(
|
||||
RequiredCookieUser(user_id): RequiredCookieUser,
|
||||
State(state): State<AppState>,
|
||||
Path(profile_user_uuid): Path<Uuid>,
|
||||
Query(params): Query<crate::html::forms::ErrorQuery>,
|
||||
Extension(csrf): Extension<CsrfToken>,
|
||||
) -> impl IntoResponse {
|
||||
if user_id.value() != profile_user_uuid {
|
||||
return StatusCode::FORBIDDEN.into_response();
|
||||
}
|
||||
let mut ctx = build_page_context(&state, Some(user_id.clone()), csrf.0).await;
|
||||
ctx.page_title = "Following — Movies Diary".to_string();
|
||||
ctx.canonical_url = format!(
|
||||
"{}/users/{}/following-list",
|
||||
state.app_ctx.config.base_url, profile_user_uuid
|
||||
);
|
||||
let instance = state.app_ctx.instance.clone();
|
||||
match application::social::get_following::execute(
|
||||
&state.app_ctx.deps.social.query,
|
||||
user_id.value(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(following) => {
|
||||
let actors: Vec<RemoteActorData> = following
|
||||
.into_iter()
|
||||
.map(|a| social_actor_to_template(a, &instance))
|
||||
.collect();
|
||||
let pending_actors: Vec<RemoteActorData> =
|
||||
match application::social::get_pending_following::execute(
|
||||
&state.app_ctx.deps.social.query,
|
||||
user_id.value(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(pending) => pending
|
||||
.into_iter()
|
||||
.map(|a| social_actor_to_template(a, &instance))
|
||||
.collect(),
|
||||
Err(e) => {
|
||||
tracing::error!("get_pending_following error: {:?}", e);
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
render_page(FollowingTemplate {
|
||||
ctx,
|
||||
user_id: profile_user_uuid,
|
||||
actors,
|
||||
pending_actors,
|
||||
error: params.error,
|
||||
})
|
||||
.into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("get_following error: {:?}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Failed to load following list",
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_followers_page(
|
||||
RequiredCookieUser(user_id): RequiredCookieUser,
|
||||
State(state): State<AppState>,
|
||||
Path(profile_user_uuid): Path<Uuid>,
|
||||
Query(params): Query<crate::html::forms::ErrorQuery>,
|
||||
Extension(csrf): Extension<CsrfToken>,
|
||||
) -> impl IntoResponse {
|
||||
if user_id.value() != profile_user_uuid {
|
||||
return StatusCode::FORBIDDEN.into_response();
|
||||
}
|
||||
let mut ctx = build_page_context(&state, Some(user_id.clone()), csrf.0).await;
|
||||
ctx.page_title = "Followers — Movies Diary".to_string();
|
||||
ctx.canonical_url = format!(
|
||||
"{}/users/{}/followers-list",
|
||||
state.app_ctx.config.base_url, profile_user_uuid
|
||||
);
|
||||
let instance = state.app_ctx.instance.clone();
|
||||
match application::social::get_followers::execute(
|
||||
&state.app_ctx.deps.social.query,
|
||||
user_id.value(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(followers) => {
|
||||
let actors: Vec<RemoteActorData> = followers
|
||||
.into_iter()
|
||||
.map(|a| social_actor_to_template(a, &instance))
|
||||
.collect();
|
||||
render_page(FollowersTemplate {
|
||||
ctx,
|
||||
user_id: profile_user_uuid,
|
||||
actors,
|
||||
error: params.error,
|
||||
})
|
||||
.into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("get_followers error: {:?}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Failed to load followers list",
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn remove_follower_html(
|
||||
RequiredCookieUser(user_id): RequiredCookieUser,
|
||||
State(state): State<AppState>,
|
||||
Path(profile_user_uuid): Path<Uuid>,
|
||||
Extension(csrf): Extension<CsrfToken>,
|
||||
Form(form): Form<FollowerActionForm>,
|
||||
) -> impl IntoResponse {
|
||||
if user_id.value() != profile_user_uuid {
|
||||
return StatusCode::FORBIDDEN.into_response();
|
||||
}
|
||||
if crate::html::csrf::mismatch(&csrf, &form.csrf_token) {
|
||||
return StatusCode::FORBIDDEN.into_response();
|
||||
}
|
||||
let instance = state.app_ctx.instance.clone();
|
||||
match application::social::execute::execute_command(
|
||||
&state.app_ctx.deps.social.command,
|
||||
application::social::commands::SocialCmd::RemoveFollower {
|
||||
owner_id: user_id.value(),
|
||||
follower: instance.identify(&form.actor_url),
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
Redirect::to(&format!("/users/{}/followers-list", profile_user_uuid)).into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
let msg = encode_domain_error(&e);
|
||||
Redirect::to(&format!(
|
||||
"/users/{}/followers-list?error={}",
|
||||
profile_user_uuid, msg
|
||||
))
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
22
crates/adapters/http-axum/src/html/social/tests.rs
Normal file
22
crates/adapters/http-axum/src/html/social/tests.rs
Normal file
@@ -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));
|
||||
}
|
||||
12
crates/adapters/http-axum/src/html/tags.rs
Normal file
12
crates/adapters/http-axum/src/html/tags.rs
Normal file
@@ -0,0 +1,12 @@
|
||||
use axum::{
|
||||
extract::Path,
|
||||
response::{IntoResponse, Redirect},
|
||||
};
|
||||
|
||||
pub async fn get_tag(Path(tag): Path<String>) -> impl IntoResponse {
|
||||
if tag.eq_ignore_ascii_case("moviesdiary") {
|
||||
Redirect::temporary("/")
|
||||
} else {
|
||||
Redirect::temporary(&format!("/?search={}", tag))
|
||||
}
|
||||
}
|
||||
@@ -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<String>,
|
||||
pub movie_title: String,
|
||||
pub release_year: u16,
|
||||
pub movie_url: Option<String>,
|
||||
pub added_at: String,
|
||||
pub remove_url: Option<String>,
|
||||
}
|
||||
|
||||
mod filters {
|
||||
#[askama::filter_fn]
|
||||
pub fn poster_src<T: std::fmt::Display>(
|
||||
path: T,
|
||||
_env: &dyn askama::Values,
|
||||
) -> askama::Result<String> {
|
||||
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<PageItem> {
|
||||
if total_pages <= 1 {
|
||||
return vec![];
|
||||
}
|
||||
let mut set = std::collections::BTreeSet::new();
|
||||
set.insert(0u32);
|
||||
set.insert(total_pages - 1);
|
||||
let start = current_page.saturating_sub(2);
|
||||
let end = (current_page + 2).min(total_pages - 1);
|
||||
for p in start..=end {
|
||||
set.insert(p);
|
||||
}
|
||||
let pages: Vec<u32> = set.into_iter().collect();
|
||||
let mut items = Vec::new();
|
||||
for (i, &p) in pages.iter().enumerate() {
|
||||
if i > 0 && p > pages[i - 1] + 1 {
|
||||
items.push(PageItem {
|
||||
number: 0,
|
||||
is_current: false,
|
||||
is_ellipsis: true,
|
||||
});
|
||||
}
|
||||
items.push(PageItem {
|
||||
number: p,
|
||||
is_current: p == current_page,
|
||||
is_ellipsis: false,
|
||||
});
|
||||
}
|
||||
items
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "diary.html")]
|
||||
pub struct DiaryTemplate<'a> {
|
||||
pub entries: &'a [DiaryEntry],
|
||||
pub current_offset: u32,
|
||||
pub limit: u32,
|
||||
pub has_more: bool,
|
||||
pub ctx: &'a HtmlPageContext,
|
||||
pub page_items: Vec<PageItem>,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "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<PageItem>,
|
||||
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<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("&"))
|
||||
}
|
||||
}
|
||||
|
||||
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<String>,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "users.html")]
|
||||
pub struct UsersTemplate<'a> {
|
||||
pub users: Vec<UserSummaryView>,
|
||||
pub ctx: &'a HtmlPageContext,
|
||||
pub remote_actors: Vec<RemoteActorDisplay>,
|
||||
}
|
||||
|
||||
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<DiaryEntry>>,
|
||||
pub current_offset: u32,
|
||||
pub has_more: bool,
|
||||
pub limit: u32,
|
||||
pub history: Option<&'a Vec<MonthActivity>>,
|
||||
pub trends: Option<&'a UserTrends>,
|
||||
pub monthly_rating_rows: Vec<MonthlyRatingRow<'a>>,
|
||||
pub heatmap: Vec<HeatmapCell>,
|
||||
pub page_items: Vec<PageItem>,
|
||||
pub is_own_profile: bool,
|
||||
pub error: Option<String>,
|
||||
pub following_count: usize,
|
||||
pub followers_count: usize,
|
||||
pub pending_followers: Vec<RemoteActorData>,
|
||||
pub sort_by: String,
|
||||
pub search: String,
|
||||
pub goals: Vec<GoalViewData>,
|
||||
}
|
||||
|
||||
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<DiaryEntry>>,
|
||||
pub current_offset: u32,
|
||||
pub has_more: bool,
|
||||
pub limit: u32,
|
||||
pub history: Option<&'a Vec<MonthActivity>>,
|
||||
pub trends: Option<&'a UserTrends>,
|
||||
pub monthly_rating_rows: Vec<MonthlyRatingRow<'a>>,
|
||||
pub heatmap: Vec<HeatmapCell>,
|
||||
pub page_items: Vec<PageItem>,
|
||||
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<String>,
|
||||
pub url: String,
|
||||
pub avatar_url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "following.html")]
|
||||
pub struct FollowingTemplate {
|
||||
pub ctx: HtmlPageContext,
|
||||
pub user_id: uuid::Uuid,
|
||||
pub actors: Vec<RemoteActorData>,
|
||||
pub pending_actors: Vec<RemoteActorData>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "followers.html")]
|
||||
pub struct FollowersTemplate {
|
||||
pub ctx: HtmlPageContext,
|
||||
pub user_id: uuid::Uuid,
|
||||
pub actors: Vec<RemoteActorData>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
pub blocked_at: String,
|
||||
}
|
||||
|
||||
pub struct BlockedActorEntry {
|
||||
pub url: String,
|
||||
pub handle: String,
|
||||
pub display_name: Option<String>,
|
||||
pub avatar_url: Option<String>,
|
||||
}
|
||||
|
||||
pub struct HeatmapCell {
|
||||
pub month_label: String,
|
||||
pub count: i64,
|
||||
pub alpha: f64,
|
||||
}
|
||||
|
||||
pub fn build_heatmap(history: &[MonthActivity]) -> Vec<HeatmapCell> {
|
||||
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<i64> = 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<String>,
|
||||
pub created_at: String,
|
||||
pub last_used_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "watch_queue.html")]
|
||||
pub struct WatchQueueTemplate<'a> {
|
||||
pub ctx: &'a HtmlPageContext,
|
||||
pub entries: &'a [WatchQueueDisplayEntry],
|
||||
pub error: Option<&'a str>,
|
||||
}
|
||||
|
||||
pub struct WatchQueueDisplayEntry {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub year: Option<u16>,
|
||||
pub source: String,
|
||||
pub watched_at: String,
|
||||
pub movie_url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "import_upload.html")]
|
||||
pub struct ImportUploadTemplate<'a> {
|
||||
pub ctx: &'a HtmlPageContext,
|
||||
pub profiles: &'a [ImportProfileView],
|
||||
pub error: Option<&'a str>,
|
||||
}
|
||||
|
||||
pub struct ImportProfileView {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "import_mapping.html")]
|
||||
pub struct ImportMappingTemplate<'a> {
|
||||
pub ctx: &'a HtmlPageContext,
|
||||
pub session_id: &'a str,
|
||||
pub columns: &'a [String],
|
||||
pub sample_rows: &'a [Vec<String>],
|
||||
pub domain_fields: &'a [(&'static str, &'static str)],
|
||||
pub error: Option<&'a str>,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "import_preview.html")]
|
||||
pub struct ImportPreviewTemplate<'a> {
|
||||
pub ctx: &'a HtmlPageContext,
|
||||
pub session_id: &'a str,
|
||||
pub columns: &'a [String],
|
||||
pub rows: &'a [ImportPreviewRow],
|
||||
}
|
||||
|
||||
pub struct ImportPreviewRow {
|
||||
pub index: usize,
|
||||
pub status: ImportRowStatus,
|
||||
pub cells: Vec<String>,
|
||||
}
|
||||
|
||||
pub enum ImportRowStatus {
|
||||
Valid,
|
||||
Duplicate,
|
||||
Invalid(String),
|
||||
}
|
||||
|
||||
#[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<f64>,
|
||||
}
|
||||
16
crates/adapters/http-axum/src/html/templates/auth.rs
Normal file
16
crates/adapters/http-axum/src/html/templates/auth.rs
Normal file
@@ -0,0 +1,16 @@
|
||||
use api_types::HtmlPageContext;
|
||||
use askama::Template;
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "login.html")]
|
||||
pub struct LoginTemplate<'a> {
|
||||
pub error: Option<&'a str>,
|
||||
pub ctx: &'a HtmlPageContext,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "register.html")]
|
||||
pub struct RegisterTemplate<'a> {
|
||||
pub error: Option<&'a str>,
|
||||
pub ctx: &'a HtmlPageContext,
|
||||
}
|
||||
36
crates/adapters/http-axum/src/html/templates/common.rs
Normal file
36
crates/adapters/http-axum/src/html/templates/common.rs
Normal file
@@ -0,0 +1,36 @@
|
||||
pub struct PageItem {
|
||||
pub number: u32,
|
||||
pub is_current: bool,
|
||||
pub is_ellipsis: bool,
|
||||
}
|
||||
|
||||
pub fn build_page_items(total_pages: u32, current_page: u32) -> Vec<PageItem> {
|
||||
if total_pages <= 1 {
|
||||
return vec![];
|
||||
}
|
||||
let mut set = std::collections::BTreeSet::new();
|
||||
set.insert(0u32);
|
||||
set.insert(total_pages - 1);
|
||||
let start = current_page.saturating_sub(2);
|
||||
let end = (current_page + 2).min(total_pages - 1);
|
||||
for p in start..=end {
|
||||
set.insert(p);
|
||||
}
|
||||
let pages: Vec<u32> = set.into_iter().collect();
|
||||
let mut items = Vec::new();
|
||||
for (i, &p) in pages.iter().enumerate() {
|
||||
if i > 0 && p > pages[i - 1] + 1 {
|
||||
items.push(PageItem {
|
||||
number: 0,
|
||||
is_current: false,
|
||||
is_ellipsis: true,
|
||||
});
|
||||
}
|
||||
items.push(PageItem {
|
||||
number: p,
|
||||
is_current: p == current_page,
|
||||
is_ellipsis: false,
|
||||
});
|
||||
}
|
||||
items
|
||||
}
|
||||
55
crates/adapters/http-axum/src/html/templates/diary.rs
Normal file
55
crates/adapters/http-axum/src/html/templates/diary.rs
Normal file
@@ -0,0 +1,55 @@
|
||||
use super::{PageItem, filters};
|
||||
use api_types::HtmlPageContext;
|
||||
use askama::Template;
|
||||
use domain::models::{DiaryEntry, FeedEntry, ReviewSource};
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "diary.html")]
|
||||
pub struct DiaryTemplate<'a> {
|
||||
pub entries: &'a [DiaryEntry],
|
||||
pub current_offset: u32,
|
||||
pub limit: u32,
|
||||
pub has_more: bool,
|
||||
pub ctx: &'a HtmlPageContext,
|
||||
pub page_items: Vec<PageItem>,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "new_review.html")]
|
||||
pub struct NewReviewTemplate<'a> {
|
||||
pub error: Option<&'a str>,
|
||||
pub ctx: &'a HtmlPageContext,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "activity_feed.html")]
|
||||
pub struct ActivityFeedTemplate<'a> {
|
||||
pub entries: &'a [FeedEntry],
|
||||
pub current_offset: u32,
|
||||
pub limit: u32,
|
||||
pub has_more: bool,
|
||||
pub ctx: &'a HtmlPageContext,
|
||||
pub page_items: Vec<PageItem>,
|
||||
pub filter: String,
|
||||
pub sort_by: String,
|
||||
pub search: String,
|
||||
}
|
||||
|
||||
impl<'a> ActivityFeedTemplate<'a> {
|
||||
pub fn filter_qs(&self) -> String {
|
||||
let mut parts = vec![
|
||||
format!("filter={}", self.filter),
|
||||
format!("sort_by={}", self.sort_by),
|
||||
];
|
||||
if !self.search.is_empty() {
|
||||
let encoded = self
|
||||
.search
|
||||
.replace(' ', "+")
|
||||
.replace('#', "%23")
|
||||
.replace('&', "%26")
|
||||
.replace('=', "%3D");
|
||||
parts.push(format!("search={}", encoded));
|
||||
}
|
||||
format!("&{}", parts.join("&"))
|
||||
}
|
||||
}
|
||||
47
crates/adapters/http-axum/src/html/templates/import.rs
Normal file
47
crates/adapters/http-axum/src/html/templates/import.rs
Normal file
@@ -0,0 +1,47 @@
|
||||
use api_types::HtmlPageContext;
|
||||
use askama::Template;
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "import_upload.html")]
|
||||
pub struct ImportUploadTemplate<'a> {
|
||||
pub ctx: &'a HtmlPageContext,
|
||||
pub profiles: &'a [ImportProfileView],
|
||||
pub error: Option<&'a str>,
|
||||
}
|
||||
|
||||
pub struct ImportProfileView {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "import_mapping.html")]
|
||||
pub struct ImportMappingTemplate<'a> {
|
||||
pub ctx: &'a HtmlPageContext,
|
||||
pub session_id: &'a str,
|
||||
pub columns: &'a [String],
|
||||
pub sample_rows: &'a [Vec<String>],
|
||||
pub domain_fields: &'a [(&'static str, &'static str)],
|
||||
pub error: Option<&'a str>,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "import_preview.html")]
|
||||
pub struct ImportPreviewTemplate<'a> {
|
||||
pub ctx: &'a HtmlPageContext,
|
||||
pub session_id: &'a str,
|
||||
pub columns: &'a [String],
|
||||
pub rows: &'a [ImportPreviewRow],
|
||||
}
|
||||
|
||||
pub struct ImportPreviewRow {
|
||||
pub index: usize,
|
||||
pub status: ImportRowStatus,
|
||||
pub cells: Vec<String>,
|
||||
}
|
||||
|
||||
pub enum ImportRowStatus {
|
||||
Valid,
|
||||
Duplicate,
|
||||
Invalid(String),
|
||||
}
|
||||
36
crates/adapters/http-axum/src/html/templates/integrations.rs
Normal file
36
crates/adapters/http-axum/src/html/templates/integrations.rs
Normal file
@@ -0,0 +1,36 @@
|
||||
use api_types::HtmlPageContext;
|
||||
use askama::Template;
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "integrations.html")]
|
||||
pub struct IntegrationsTemplate<'a> {
|
||||
pub ctx: &'a HtmlPageContext,
|
||||
pub tokens: &'a [WebhookTokenView],
|
||||
pub webhook_base_url: &'a str,
|
||||
pub new_token: Option<&'a str>,
|
||||
}
|
||||
|
||||
pub struct WebhookTokenView {
|
||||
pub id: String,
|
||||
pub provider: String,
|
||||
pub label: Option<String>,
|
||||
pub created_at: String,
|
||||
pub last_used_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "watch_queue.html")]
|
||||
pub struct WatchQueueTemplate<'a> {
|
||||
pub ctx: &'a HtmlPageContext,
|
||||
pub entries: &'a [WatchQueueDisplayEntry],
|
||||
pub error: Option<&'a str>,
|
||||
}
|
||||
|
||||
pub struct WatchQueueDisplayEntry {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub year: Option<u16>,
|
||||
pub source: String,
|
||||
pub watched_at: String,
|
||||
pub movie_url: Option<String>,
|
||||
}
|
||||
36
crates/adapters/http-axum/src/html/templates/mod.rs
Normal file
36
crates/adapters/http-axum/src/html/templates/mod.rs
Normal file
@@ -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<T: std::fmt::Display>(
|
||||
path: T,
|
||||
_env: &dyn askama::Values,
|
||||
) -> askama::Result<String> {
|
||||
let p = path.to_string();
|
||||
if p.starts_with("http://") || p.starts_with("https://") {
|
||||
Ok(p)
|
||||
} else {
|
||||
Ok(format!("/images/{}", p))
|
||||
}
|
||||
}
|
||||
}
|
||||
19
crates/adapters/http-axum/src/html/templates/movies.rs
Normal file
19
crates/adapters/http-axum/src/html/templates/movies.rs
Normal file
@@ -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,
|
||||
}
|
||||
55
crates/adapters/http-axum/src/html/templates/social.rs
Normal file
55
crates/adapters/http-axum/src/html/templates/social.rs
Normal file
@@ -0,0 +1,55 @@
|
||||
use api_types::HtmlPageContext;
|
||||
use askama::Template;
|
||||
|
||||
pub struct RemoteActorData {
|
||||
pub handle: String,
|
||||
pub display_name: Option<String>,
|
||||
pub url: String,
|
||||
pub avatar_url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "following.html")]
|
||||
pub struct FollowingTemplate {
|
||||
pub ctx: HtmlPageContext,
|
||||
pub user_id: uuid::Uuid,
|
||||
pub actors: Vec<RemoteActorData>,
|
||||
pub pending_actors: Vec<RemoteActorData>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "followers.html")]
|
||||
pub struct FollowersTemplate {
|
||||
pub ctx: HtmlPageContext,
|
||||
pub user_id: uuid::Uuid,
|
||||
pub actors: Vec<RemoteActorData>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
pub blocked_at: String,
|
||||
}
|
||||
|
||||
pub struct BlockedActorEntry {
|
||||
pub url: String,
|
||||
pub handle: String,
|
||||
pub display_name: Option<String>,
|
||||
pub avatar_url: Option<String>,
|
||||
}
|
||||
193
crates/adapters/http-axum/src/html/templates/users.rs
Normal file
193
crates/adapters/http-axum/src/html/templates/users.rs
Normal file
@@ -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<String>,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "users.html")]
|
||||
pub struct UsersTemplate<'a> {
|
||||
pub users: Vec<UserSummaryView>,
|
||||
pub ctx: &'a HtmlPageContext,
|
||||
pub remote_actors: Vec<RemoteActorDisplay>,
|
||||
}
|
||||
|
||||
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<DiaryEntry>>,
|
||||
pub current_offset: u32,
|
||||
pub has_more: bool,
|
||||
pub limit: u32,
|
||||
pub history: Option<&'a Vec<MonthActivity>>,
|
||||
pub trends: Option<&'a UserTrends>,
|
||||
pub monthly_rating_rows: Vec<MonthlyRatingRow<'a>>,
|
||||
pub heatmap: Vec<HeatmapCell>,
|
||||
pub page_items: Vec<PageItem>,
|
||||
pub is_own_profile: bool,
|
||||
pub error: Option<String>,
|
||||
pub following_count: usize,
|
||||
pub followers_count: usize,
|
||||
pub pending_followers: Vec<RemoteActorData>,
|
||||
pub sort_by: String,
|
||||
pub search: String,
|
||||
pub goals: Vec<GoalViewData>,
|
||||
}
|
||||
|
||||
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<DiaryEntry>>,
|
||||
pub current_offset: u32,
|
||||
pub has_more: bool,
|
||||
pub limit: u32,
|
||||
pub history: Option<&'a Vec<MonthActivity>>,
|
||||
pub trends: Option<&'a UserTrends>,
|
||||
pub monthly_rating_rows: Vec<MonthlyRatingRow<'a>>,
|
||||
pub heatmap: Vec<HeatmapCell>,
|
||||
pub page_items: Vec<PageItem>,
|
||||
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<HeatmapCell> {
|
||||
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<i64> = 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,
|
||||
}
|
||||
25
crates/adapters/http-axum/src/html/templates/watchlist.rs
Normal file
25
crates/adapters/http-axum/src/html/templates/watchlist.rs
Normal file
@@ -0,0 +1,25 @@
|
||||
use api_types::HtmlPageContext;
|
||||
use askama::Template;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct WatchlistDisplayEntry {
|
||||
pub poster_url: Option<String>,
|
||||
pub movie_title: String,
|
||||
pub release_year: u16,
|
||||
pub movie_url: Option<String>,
|
||||
pub added_at: String,
|
||||
pub remove_url: Option<String>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
}
|
||||
16
crates/adapters/http-axum/src/html/templates/wrapup.rs
Normal file
16
crates/adapters/http-axum/src/html/templates/wrapup.rs
Normal file
@@ -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<f64>,
|
||||
}
|
||||
179
crates/adapters/http-axum/src/html/users.rs
Normal file
179
crates/adapters/http-axum/src/html/users.rs
Normal file
@@ -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<AppState>,
|
||||
Extension(csrf): Extension<CsrfToken>,
|
||||
) -> 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<UserSummaryView> = result
|
||||
.users
|
||||
.iter()
|
||||
.map(crate::html::mappers::users::user_summary_view)
|
||||
.collect();
|
||||
let remote_actors: Vec<RemoteActorDisplay> = 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<AppState>,
|
||||
Path(username): Path<String>,
|
||||
) -> 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<String>,
|
||||
}
|
||||
|
||||
pub async fn get_profile_settings(
|
||||
RequiredCookieUser(user_id): RequiredCookieUser,
|
||||
State(state): State<AppState>,
|
||||
Query(params): Query<SavedQuery>,
|
||||
Extension(csrf): Extension<CsrfToken>,
|
||||
) -> 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<AppState>,
|
||||
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<domain::models::ProfileField> = (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()
|
||||
}
|
||||
171
crates/adapters/http-axum/src/html/watchlist.rs
Normal file
171
crates/adapters/http-axum/src/html/watchlist.rs
Normal file
@@ -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<AppState>,
|
||||
Path(owner_id): Path<uuid::Uuid>,
|
||||
Query(params): Query<crate::html::forms::WatchlistQuery>,
|
||||
Extension(csrf): Extension<CsrfToken>,
|
||||
) -> 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<AppState>,
|
||||
RequiredCookieUser(user_id): RequiredCookieUser,
|
||||
Extension(csrf): Extension<CsrfToken>,
|
||||
Form(form): Form<crate::html::forms::WatchlistAddForm>,
|
||||
) -> 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<AppState>,
|
||||
RequiredCookieUser(user_id): RequiredCookieUser,
|
||||
Extension(csrf): Extension<CsrfToken>,
|
||||
Path(movie_id): Path<uuid::Uuid>,
|
||||
Form(form): Form<crate::html::forms::DeleteRedirectForm>,
|
||||
) -> 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),
|
||||
}
|
||||
}
|
||||
106
crates/adapters/http-axum/src/html/wrapup.rs
Normal file
106
crates/adapters/http-axum/src/html/wrapup.rs
Normal file
@@ -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<f64> = 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<AppState>,
|
||||
Path((user_id, year)): Path<(Uuid, i32)>,
|
||||
Extension(csrf): Extension<CsrfToken>,
|
||||
) -> impl IntoResponse {
|
||||
let scope = WrapUpScope::User(user_id);
|
||||
let report =
|
||||
match get_ready_report::execute(&state.app_ctx.deps.wrapup.get_ready_report, scope, year)
|
||||
.await
|
||||
{
|
||||
Ok(report) => report,
|
||||
Err(DomainError::ValidationError(_)) => return StatusCode::BAD_REQUEST.into_response(),
|
||||
Err(_) => return StatusCode::NOT_FOUND.into_response(),
|
||||
};
|
||||
|
||||
let ctx = 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<AppState>,
|
||||
Path(year): Path<i32>,
|
||||
Extension(csrf): Extension<CsrfToken>,
|
||||
) -> impl IntoResponse {
|
||||
let report = match get_ready_report::execute(
|
||||
&state.app_ctx.deps.wrapup.get_ready_report,
|
||||
WrapUpScope::Global,
|
||||
year,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(report) => report,
|
||||
Err(DomainError::ValidationError(_)) => return StatusCode::BAD_REQUEST.into_response(),
|
||||
Err(_) => return StatusCode::NOT_FOUND.into_response(),
|
||||
};
|
||||
|
||||
let ctx = crate::html::helpers::build_page_context(&state, viewer, csrf.0).await;
|
||||
render_wrapup(&report, year, &ctx)
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
131
crates/adapters/http-axum/src/router.rs
Normal file
131
crates/adapters/http-axum/src/router.rs
Normal file
@@ -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<serde_json::Value> {
|
||||
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<AppState> {
|
||||
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<AppState> {
|
||||
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))
|
||||
}
|
||||
@@ -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<serde_json::Value> {
|
||||
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<AppState> {
|
||||
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<String>| 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<AppState> {
|
||||
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<uuid::Uuid>| async move {
|
||||
axum::response::Redirect::permanent(&format!("/users/{}/followers-list", id))
|
||||
},
|
||||
),
|
||||
)
|
||||
.route(
|
||||
"/users/{id}/following",
|
||||
routing::get(
|
||||
|axum::extract::Path(id): axum::extract::Path<uuid::Uuid>| async move {
|
||||
axum::response::Redirect::permanent(&format!("/users/{}/following-list", id))
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
base
|
||||
}
|
||||
|
||||
#[cfg(feature = "federation")]
|
||||
fn federation_html_routes() -> Router<AppState> {
|
||||
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<AppState> {
|
||||
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<AppState> {
|
||||
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<AppState> {
|
||||
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),
|
||||
)
|
||||
}
|
||||
@@ -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<UserId>,
|
||||
csrf_token: String,
|
||||
) -> HtmlPageContext {
|
||||
let uuid = user_id.as_ref().map(|u| u.value());
|
||||
// Page chrome (email/admin badge, pending-follow badge) degrades rather than
|
||||
// blanking the page: a failed lookup or count logs a warning and falls back
|
||||
// to a zeroed `PageViewer`, same as the previous silent `.unwrap_or(0)` — now
|
||||
// logged and explicit, per the error policy in `get_page_viewer`'s doc comment.
|
||||
let (user_email, is_admin, pending_follow_count) = if let Some(ref id) = user_id {
|
||||
match application::users::get_page_viewer::execute(
|
||||
&state.app_ctx.deps.users.get_page_viewer,
|
||||
id.value(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(viewer) => (viewer.email, viewer.is_admin, viewer.pending_follow_count),
|
||||
Err(e) => {
|
||||
tracing::warn!("get_page_viewer failed for {}: {e}", id.value());
|
||||
(None, false, 0)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
(None, false, 0)
|
||||
};
|
||||
HtmlPageContext {
|
||||
user_email,
|
||||
user_id: uuid,
|
||||
is_admin,
|
||||
register_enabled: state.app_ctx.config.allow_registration,
|
||||
rss_url: "/feed.rss".to_string(),
|
||||
page_title: "Movies Diary".to_string(),
|
||||
canonical_url: state.app_ctx.config.base_url.clone(),
|
||||
csrf_token,
|
||||
page_rss_url: None,
|
||||
pending_follow_count,
|
||||
pub(crate) fn str_to_domain_field(field: &str) -> Option<DomainField> {
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use api_types::FeedEntryDto;
|
||||
use crate::FeedEntryDto;
|
||||
use domain::models::FeedEntry;
|
||||
|
||||
use super::movies::{movie_to_dto, review_to_dto};
|
||||
12
crates/api-types/src/mappers/goals.rs
Normal file
12
crates/api-types/src/mappers/goals.rs
Normal file
@@ -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(),
|
||||
}
|
||||
}
|
||||
10
crates/api-types/src/mappers/mod.rs
Normal file
10
crates/api-types/src/mappers/mod.rs
Normal file
@@ -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::*;
|
||||
@@ -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 {
|
||||
@@ -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 {
|
||||
@@ -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,
|
||||
)))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user