diff --git a/server/src/case_id.rs b/server/src/case_id.rs new file mode 100644 index 0000000..5db69c5 --- /dev/null +++ b/server/src/case_id.rs @@ -0,0 +1,80 @@ +use std::fmt; +use std::str::FromStr; + +use axum::extract::{FromRequestParts, Path}; +use axum::http::request::Parts; +use serde::{Deserialize, Deserializer}; +use uuid::Uuid; + +use crate::error::AppError; + +/// Typed wrapper around `uuid::Uuid` for case identifiers. Enforces the +/// "parse, don't validate" idiom: any `CaseId` in code has already passed +/// UUID validation at the request boundary. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct CaseId(pub Uuid); + +impl fmt::Display for CaseId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt::Display::fmt(&self.0, f) + } +} + +impl FromStr for CaseId { + type Err = uuid::Error; + + fn from_str(s: &str) -> Result { + Uuid::parse_str(s).map(CaseId) + } +} + +impl<'de> Deserialize<'de> for CaseId { + fn deserialize>(deserializer: D) -> Result { + let s = String::deserialize(deserializer)?; + CaseId::from_str(&s).map_err(serde::de::Error::custom) + } +} + +/// Axum Path-extractor that validates the `{case_id}` URL segment and +/// returns `AppError::BadRequest("Invalid case_id")` on failure — matching +/// the legacy error body byte-for-byte. +pub struct CaseIdPath(pub CaseId); + +impl FromRequestParts for CaseIdPath +where + S: Send + Sync, +{ + type Rejection = AppError; + + async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { + let Path(s) = Path::::from_request_parts(parts, state) + .await + .map_err(|_| AppError::BadRequest("Invalid case_id".into()))?; + let id = CaseId::from_str(&s) + .map_err(|_| AppError::BadRequest("Invalid case_id".into()))?; + Ok(CaseIdPath(id)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn from_str_accepts_valid_uuid() { + let id = CaseId::from_str("11111111-1111-1111-1111-111111111111").unwrap(); + assert_eq!(id.0.to_string(), "11111111-1111-1111-1111-111111111111"); + } + + #[test] + fn from_str_rejects_non_uuid() { + assert!(CaseId::from_str("not-a-uuid").is_err()); + } + + #[test] + fn display_matches_uuid_display() { + let raw = "22222222-2222-2222-2222-222222222222"; + let id = CaseId::from_str(raw).unwrap(); + assert_eq!(id.to_string(), raw); + } +} diff --git a/server/src/lib.rs b/server/src/lib.rs index 4ca8123..d597f13 100644 --- a/server/src/lib.rs +++ b/server/src/lib.rs @@ -1,5 +1,6 @@ pub mod analyze; pub mod auth; +pub mod case_id; pub mod config; pub mod error; pub mod gazetteer; diff --git a/server/src/routes/case_actions.rs b/server/src/routes/case_actions.rs index 80dd2dd..c8ba937 100644 --- a/server/src/routes/case_actions.rs +++ b/server/src/routes/case_actions.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use std::time::SystemTime; use askama::Template; -use axum::extract::{Path as AxumPath, State}; +use axum::extract::State; use axum::response::{Html, Redirect}; use time::format_description::well_known::Rfc3339; use time::OffsetDateTime; @@ -16,6 +16,7 @@ use crate::analyze::{ AnalysisInput, AnalyzeJob, AnalyzeSender, RecordingInput, ANALYSIS_INPUT_FILE, DOCUMENT_FILE, }; use crate::auth::AuthenticatedWebUser; +use crate::case_id::CaseIdPath; use crate::config::Config; use crate::error::AppError; use crate::paths::{read_delete_marker, write_delete_marker, DeleteMarker, DELETE_MARKER}; @@ -39,11 +40,8 @@ pub async fn handle_analyze_case( user: AuthenticatedWebUser, State(config): State>, State(analyze_tx): State, - AxumPath(case_id): AxumPath, + CaseIdPath(case_id): CaseIdPath, ) -> Result { - uuid::Uuid::parse_str(&case_id) - .map_err(|_| AppError::BadRequest("Invalid case_id".into()))?; - if !config.llm_configured() { return Err(AppError::ServiceUnavailable( "LLM-Analyse nicht konfiguriert".into(), @@ -51,7 +49,7 @@ pub async fn handle_analyze_case( } let user_root = config.data_path.join(&user.slug); - let case_dir = match locate_case(&user_root, &case_id).await { + let case_dir = match locate_case(&user_root, &case_id.to_string()).await { Some(p) => p, None => { warn!(slug = %user.slug, case_id = %case_id, "analyze: case not found"); @@ -102,13 +100,10 @@ pub async fn handle_analyze_case( pub async fn handle_document_view( user: AuthenticatedWebUser, State(config): State>, - AxumPath(case_id): AxumPath, + CaseIdPath(case_id): CaseIdPath, ) -> Result, AppError> { - uuid::Uuid::parse_str(&case_id) - .map_err(|_| AppError::BadRequest("Invalid case_id".into()))?; - let user_root = config.data_path.join(&user.slug); - let case_dir = match locate_case(&user_root, &case_id).await { + let case_dir = match locate_case(&user_root, &case_id.to_string()).await { Some(p) => p, None => { warn!(slug = %user.slug, case_id = %case_id, "document view: case not found"); @@ -121,7 +116,10 @@ pub async fn handle_document_view( .ok_or_else(|| AppError::NotFound("Noch kein Dokument erzeugt".into()))?; let content = crate::analyze::render::md_to_html(&md); - DocumentTemplate { case_id, content } + DocumentTemplate { + case_id: case_id.to_string(), + content, + } .render() .map(Html) .map_err(|e| AppError::Internal(format!("Template render failed: {e}"))) @@ -271,13 +269,10 @@ pub(crate) async fn reset_case_artefacts(case_dir: &Path) -> std::io::Result<()> pub async fn handle_delete_case( user: AuthenticatedWebUser, State(config): State>, - AxumPath(case_id): AxumPath, + CaseIdPath(case_id): CaseIdPath, ) -> Result { - uuid::Uuid::parse_str(&case_id) - .map_err(|_| AppError::BadRequest("Invalid case_id".into()))?; - let user_root = config.data_path.join(&user.slug); - let case_dir = match locate_case(&user_root, &case_id).await { + let case_dir = match locate_case(&user_root, &case_id.to_string()).await { Some(p) => p, None => { warn!(slug = %user.slug, case_id = %case_id, "delete: case not found"); @@ -311,16 +306,14 @@ pub async fn handle_delete_case( pub async fn handle_reset_case( user: AuthenticatedWebUser, State(config): State>, - AxumPath(case_id): AxumPath, + CaseIdPath(case_id): CaseIdPath, ) -> Result { if !user.is_admin() { return Err(AppError::Forbidden("Nur für Admins".into())); } - uuid::Uuid::parse_str(&case_id) - .map_err(|_| AppError::BadRequest("Invalid case_id".into()))?; let user_root = config.data_path.join(&user.slug); - let case_dir = match locate_case(&user_root, &case_id).await { + let case_dir = match locate_case(&user_root, &case_id.to_string()).await { Some(p) => p, None => { warn!(slug = %user.slug, case_id = %case_id, "reset: case not found"); diff --git a/server/src/routes/user_web.rs b/server/src/routes/user_web.rs index c9a9b3b..c047cf2 100644 --- a/server/src/routes/user_web.rs +++ b/server/src/routes/user_web.rs @@ -3,7 +3,7 @@ use std::sync::atomic::Ordering; use std::sync::Arc; use askama::Template; -use axum::extract::{Path as AxumPath, State}; +use axum::extract::State; use axum::response::Html; use tracing::{info, warn}; @@ -11,6 +11,7 @@ use time::{format_description::well_known::Rfc3339, Date, OffsetDateTime}; use crate::analyze::{recovery as analyze_recovery, AnalyzeSender, ANALYSIS_INPUT_FILE, DOCUMENT_FILE}; use crate::auth::AuthenticatedWebUser; +use crate::case_id::CaseIdPath; use crate::config::Config; use crate::error::AppError; use crate::routes::web::{scan_recordings, RecordingView}; @@ -263,11 +264,8 @@ pub async fn handle_case_detail( State(analyze_tx): State, State(transcribe_busy): State, State(transcribe_tx): State, - AxumPath(case_id): AxumPath, + CaseIdPath(case_id): CaseIdPath, ) -> Result, AppError> { - uuid::Uuid::parse_str(&case_id) - .map_err(|_| AppError::BadRequest("Invalid case_id".into()))?; - // IDOR guard: case_dir must live under the session's user_slug. let user_root = config.data_path.join(&user.slug); heal_orphans_if_idle( @@ -280,7 +278,8 @@ pub async fn handle_case_detail( ) .await; - let case_dir = match locate_case(&user_root, &case_id).await { + let case_id_str = case_id.to_string(); + let case_dir = match locate_case(&user_root, &case_id_str).await { Some(p) => p, None => { warn!(slug = %user.slug, case_id = %case_id, "case detail: not found (possible IDOR probe)"); @@ -299,12 +298,12 @@ pub async fn handle_case_detail( let a_busy = analyze_busy.0.load(Ordering::Acquire); let t_busy = transcribe_busy.0.load(Ordering::Acquire); let flags = compute_flags(&case_dir, &recordings, config.llm_configured(), a_busy).await; - let case_id_short = case_id.chars().take(8).collect(); + let case_id_short = case_id_str.chars().take(8).collect(); let is_admin = user.is_admin(); CaseDetailTemplate { slug: user.slug, - case_id, + case_id: case_id_str, case_id_short, oneliner, recordings,