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:
2026-04-19 15:05:46 +02:00
parent a1ff410d1b
commit 16c6fb2e07
4 changed files with 102 additions and 29 deletions
+14 -21
View File
@@ -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");
+7 -8
View File
@@ -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,