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:
@@ -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)]
|
||||||
|
|||||||
@@ -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>,
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)]
|
||||||
|
|||||||
@@ -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(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
})?;
|
||||||
.iter()
|
|
||||||
.enumerate()
|
let annotated: Vec<AnnotatedRow> = session.row_results.unwrap_or_default();
|
||||||
.map(|(i, a)| {
|
let rows = annotated
|
||||||
use domain::models::import::RowResult;
|
.iter()
|
||||||
match &a.result {
|
.enumerate()
|
||||||
RowResult::Valid(row) => serde_json::json!({
|
.map(|(i, a)| {
|
||||||
"index": i,
|
use domain::models::import::RowResult;
|
||||||
"status": if a.is_duplicate { "duplicate" } else { "valid" },
|
match &a.result {
|
||||||
"title": row.title,
|
RowResult::Valid(row) if a.is_duplicate => PreviewRowDto::Duplicate {
|
||||||
"release_year": row.release_year,
|
index: i,
|
||||||
"director": row.director,
|
title: row.title.clone(),
|
||||||
"rating": row.rating,
|
release_year: row.release_year.clone(),
|
||||||
"watched_at": row.watched_at,
|
director: row.director.clone(),
|
||||||
"comment": row.comment,
|
rating: row.rating.clone(),
|
||||||
}),
|
watched_at: row.watched_at.clone(),
|
||||||
RowResult::Invalid { errors, .. } => serde_json::json!({
|
comment: row.comment.clone(),
|
||||||
"index": i,
|
},
|
||||||
"status": "invalid",
|
RowResult::Valid(row) => PreviewRowDto::Valid {
|
||||||
"errors": errors,
|
index: i,
|
||||||
}),
|
title: row.title.clone(),
|
||||||
}
|
release_year: row.release_year.clone(),
|
||||||
})
|
director: row.director.clone(),
|
||||||
.collect();
|
rating: row.rating.clone(),
|
||||||
axum::Json(serde_json::json!({ "rows": rows })).into_response()
|
watched_at: row.watched_at.clone(),
|
||||||
}
|
comment: row.comment.clone(),
|
||||||
Ok(None) => (
|
},
|
||||||
StatusCode::NOT_FOUND,
|
RowResult::Invalid { errors, .. } => PreviewRowDto::Invalid {
|
||||||
axum::Json(serde_json::json!({"error": "session not found"})),
|
index: i,
|
||||||
)
|
errors: errors.clone(),
|
||||||
.into_response(),
|
},
|
||||||
Err(e) => (
|
}
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
})
|
||||||
axum::Json(serde_json::json!({"error": e.to_string()})),
|
.collect();
|
||||||
)
|
|
||||||
.into_response(),
|
Ok(axum::Json(ImportPreviewResponse { rows }))
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
Reference in New Issue
Block a user