refactor(server): introduce CaseId newtype with typed Path extractor
Replace five duplicated uuid::Uuid::parse_str calls in web handlers (analyze, document view, delete, reset, case detail) with a CaseIdPath extractor that validates the URL segment once and returns the legacy "Invalid case_id" 400 body byte-for-byte. The extractor uses axum's FromRequestParts and maps any failure to AppError::BadRequest, so IntoResponse handling is unchanged.
This commit is contained in:
@@ -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<Self, Self::Err> {
|
||||
Uuid::parse_str(s).map(CaseId)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for CaseId {
|
||||
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||
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<S> FromRequestParts<S> for CaseIdPath
|
||||
where
|
||||
S: Send + Sync,
|
||||
{
|
||||
type Rejection = AppError;
|
||||
|
||||
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
|
||||
let Path(s) = Path::<String>::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);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
pub mod analyze;
|
||||
pub mod auth;
|
||||
pub mod case_id;
|
||||
pub mod config;
|
||||
pub mod error;
|
||||
pub mod gazetteer;
|
||||
|
||||
@@ -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<Arc<Config>>,
|
||||
State(analyze_tx): State<AnalyzeSender>,
|
||||
AxumPath(case_id): AxumPath<String>,
|
||||
CaseIdPath(case_id): CaseIdPath,
|
||||
) -> Result<Redirect, AppError> {
|
||||
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<Arc<Config>>,
|
||||
AxumPath(case_id): AxumPath<String>,
|
||||
CaseIdPath(case_id): CaseIdPath,
|
||||
) -> Result<Html<String>, 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<Arc<Config>>,
|
||||
AxumPath(case_id): AxumPath<String>,
|
||||
CaseIdPath(case_id): CaseIdPath,
|
||||
) -> Result<Redirect, 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, "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<Arc<Config>>,
|
||||
AxumPath(case_id): AxumPath<String>,
|
||||
CaseIdPath(case_id): CaseIdPath,
|
||||
) -> Result<Redirect, AppError> {
|
||||
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");
|
||||
|
||||
@@ -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<AnalyzeSender>,
|
||||
State(transcribe_busy): State<TranscribeBusy>,
|
||||
State(transcribe_tx): State<TranscribeSender>,
|
||||
AxumPath(case_id): AxumPath<String>,
|
||||
CaseIdPath(case_id): CaseIdPath,
|
||||
) -> Result<Html<String>, 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,
|
||||
|
||||
Reference in New Issue
Block a user