feat: CORS, role in auth, banner_url, diary sort, cleanup

- CORS layer on API routes via CORS_ORIGINS env var
- role field in login + profile responses
- banner_url in profile response
- diary sort_by: rating_desc/rating_asc/date_asc/date_desc
- UserRole::as_str() to deduplicate role mapping
- typed DTOs for import preview (replace ad-hoc JSON)
- warn on invalid CORS origins
This commit is contained in:
2026-06-04 02:06:51 +02:00
parent 7b9b0f9ffe
commit bf73d4a695
10 changed files with 122 additions and 62 deletions

View File

@@ -13,6 +13,7 @@ pub struct LoginResponse {
pub user_id: Uuid, pub user_id: Uuid,
pub email: String, pub email: String,
pub expires_at: String, pub expires_at: String,
pub role: String,
} }
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]

View File

@@ -45,3 +45,35 @@ pub struct SaveProfileRequest {
/// Human-readable profile name (e.g. "Letterboxd") /// Human-readable profile name (e.g. "Letterboxd")
pub name: String, pub name: String,
} }
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
#[serde(tag = "status")]
pub enum PreviewRowDto {
#[serde(rename = "valid")]
Valid {
index: usize,
title: Option<String>,
release_year: Option<String>,
director: Option<String>,
rating: Option<String>,
watched_at: Option<String>,
comment: Option<String>,
},
#[serde(rename = "duplicate")]
Duplicate {
index: usize,
title: Option<String>,
release_year: Option<String>,
director: Option<String>,
rating: Option<String>,
watched_at: Option<String>,
comment: Option<String>,
},
#[serde(rename = "invalid")]
Invalid { index: usize, errors: Vec<String> },
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct ImportPreviewResponse {
pub rows: Vec<PreviewRowDto>,
}

View File

@@ -82,6 +82,8 @@ pub struct ProfileResponse {
pub username: String, pub username: String,
pub bio: Option<String>, pub bio: Option<String>,
pub avatar_url: Option<String>, pub avatar_url: Option<String>,
pub banner_url: Option<String>,
pub role: String,
} }
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]

View File

@@ -10,6 +10,7 @@ pub struct LoginResult {
pub user_id: Uuid, pub user_id: Uuid,
pub email: String, pub email: String,
pub expires_at: DateTime<Utc>, pub expires_at: DateTime<Utc>,
pub role: String,
} }
pub async fn execute(ctx: &AppContext, query: LoginQuery) -> Result<LoginResult, DomainError> { pub async fn execute(ctx: &AppContext, query: LoginQuery) -> Result<LoginResult, DomainError> {
@@ -37,6 +38,7 @@ pub async fn execute(ctx: &AppContext, query: LoginQuery) -> Result<LoginResult,
user_id: user.id().value(), user_id: user.id().value(),
email: user.email().value().to_string(), email: user.email().value().to_string(),
expires_at: generated.expires_at, expires_at: generated.expires_at,
role: user.role().as_str().into(),
}) })
} }

View File

@@ -6,6 +6,8 @@ pub struct CurrentProfileData {
pub username: String, pub username: String,
pub bio: Option<String>, pub bio: Option<String>,
pub avatar_url: Option<String>, pub avatar_url: Option<String>,
pub banner_url: Option<String>,
pub role: String,
} }
pub async fn execute( pub async fn execute(
@@ -23,10 +25,15 @@ pub async fn execute(
let avatar_url = user let avatar_url = user
.avatar_path() .avatar_path()
.map(|path| format!("{}/images/{}", ctx.config.base_url, path)); .map(|path| format!("{}/images/{}", ctx.config.base_url, path));
let banner_url = user
.banner_path()
.map(|path| format!("{}/images/{}", ctx.config.base_url, path));
Ok(CurrentProfileData { Ok(CurrentProfileData {
username: user.username().value().to_string(), username: user.username().value().to_string(),
bio: user.bio().map(|s| s.to_string()), bio: user.bio().map(|s| s.to_string()),
avatar_url, avatar_url,
banner_url,
role: user.role().as_str().into(),
}) })
} }

View File

@@ -317,6 +317,15 @@ pub enum UserRole {
Admin, Admin,
} }
impl UserRole {
pub fn as_str(&self) -> &'static str {
match self {
Self::Standard => "standard",
Self::Admin => "admin",
}
}
}
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct ProfileField { pub struct ProfileField {
pub name: String, pub name: String,

View File

@@ -24,7 +24,7 @@ postgres-federation = [
] ]
[dependencies] [dependencies]
tower-http = { version = "0.6.8", features = ["fs", "trace", "tracing"] } tower-http = { version = "0.6.8", features = ["cors", "fs", "trace", "tracing"] }
infer = "0.19.0" infer = "0.19.0"
percent-encoding = "2" percent-encoding = "2"
axum-governor = "2" axum-governor = "2"

View File

@@ -251,12 +251,11 @@ pub fn to_diary_query(p: DiaryQueryParams) -> GetDiaryQuery {
GetDiaryQuery { GetDiaryQuery {
limit: p.limit, limit: p.limit,
offset: p.offset, offset: p.offset,
sort_by: p.sort_by.as_deref().map(|s| { sort_by: p.sort_by.as_deref().map(|s| match s {
if s == "asc" { "date_asc" | "asc" => SortDirection::Ascending,
SortDirection::Ascending "rating_desc" => SortDirection::ByRatingDesc,
} else { "rating_asc" => SortDirection::ByRatingAsc,
SortDirection::Descending _ => SortDirection::Descending,
}
}), }),
movie_id: p.movie_id, movie_id: p.movie_id,
user_id: None, user_id: None,

View File

@@ -1,6 +1,6 @@
use api_types::{ use api_types::{
ApplyMappingRequest, ConfirmRequest, SaveProfileRequest, SessionCreatedResponse, ApplyMappingRequest, ConfirmRequest, ImportPreviewResponse, PreviewRowDto, SaveProfileRequest,
SessionStateResponse, SessionCreatedResponse, SessionStateResponse,
}; };
use axum::{ use axum::{
Extension, Form, Extension, Form,
@@ -34,6 +34,7 @@ use template_askama::{
use crate::{ use crate::{
csrf::CsrfToken, csrf::CsrfToken,
errors::ApiError,
extractors::{AuthenticatedUser, RequiredCookieUser}, extractors::{AuthenticatedUser, RequiredCookieUser},
state::AppState, state::AppState,
}; };
@@ -639,64 +640,62 @@ pub async fn api_get_preview(
State(state): State<AppState>, State(state): State<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser, AuthenticatedUser(user_id): AuthenticatedUser,
Path(session_id_str): Path<String>, Path(session_id_str): Path<String>,
) -> impl IntoResponse { ) -> Result<axum::Json<ImportPreviewResponse>, ApiError> {
let Ok(session_id) = session_id_str let session_id = session_id_str
.parse::<uuid::Uuid>() .parse::<uuid::Uuid>()
.map(ImportSessionId::from_uuid) .map(ImportSessionId::from_uuid)
else { .map_err(|_| {
return ( ApiError(domain::errors::DomainError::ValidationError(
StatusCode::BAD_REQUEST, "invalid session id".into(),
axum::Json(serde_json::json!({"error": "invalid session id"})), ))
) })?;
.into_response();
}; let session = state
match state
.app_ctx .app_ctx
.repos .repos
.import_session .import_session
.get(&session_id, &user_id) .get(&session_id, &user_id)
.await .await?
{ .ok_or_else(|| {
Ok(Some(session)) => { ApiError(domain::errors::DomainError::NotFound(
let annotated: Vec<domain::models::AnnotatedRow> = "session not found".into(),
session.row_results.unwrap_or_default(); ))
let rows: Vec<serde_json::Value> = annotated })?;
let annotated: Vec<AnnotatedRow> = session.row_results.unwrap_or_default();
let rows = annotated
.iter() .iter()
.enumerate() .enumerate()
.map(|(i, a)| { .map(|(i, a)| {
use domain::models::import::RowResult; use domain::models::import::RowResult;
match &a.result { match &a.result {
RowResult::Valid(row) => serde_json::json!({ RowResult::Valid(row) if a.is_duplicate => PreviewRowDto::Duplicate {
"index": i, index: i,
"status": if a.is_duplicate { "duplicate" } else { "valid" }, title: row.title.clone(),
"title": row.title, release_year: row.release_year.clone(),
"release_year": row.release_year, director: row.director.clone(),
"director": row.director, rating: row.rating.clone(),
"rating": row.rating, watched_at: row.watched_at.clone(),
"watched_at": row.watched_at, comment: row.comment.clone(),
"comment": row.comment, },
}), RowResult::Valid(row) => PreviewRowDto::Valid {
RowResult::Invalid { errors, .. } => serde_json::json!({ index: i,
"index": i, title: row.title.clone(),
"status": "invalid", release_year: row.release_year.clone(),
"errors": errors, 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(); .collect();
axum::Json(serde_json::json!({ "rows": rows })).into_response()
} Ok(axum::Json(ImportPreviewResponse { rows }))
Ok(None) => (
StatusCode::NOT_FOUND,
axum::Json(serde_json::json!({"error": "session not found"})),
)
.into_response(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
axum::Json(serde_json::json!({"error": e.to_string()})),
)
.into_response(),
}
} }
#[utoipa::path( #[utoipa::path(

View File

@@ -264,7 +264,16 @@ fn cors_layer() -> CorsLayer {
} else { } else {
let parsed: Vec<_> = origins let parsed: Vec<_> = origins
.split(',') .split(',')
.filter_map(|s| s.trim().parse().ok()) .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(); .collect();
layer layer
.allow_origin(AllowOrigin::list(parsed)) .allow_origin(AllowOrigin::list(parsed))