diff --git a/server/src/routes/user_web.rs b/server/src/routes/user_web.rs index c3b3d44..5a8945a 100644 --- a/server/src/routes/user_web.rs +++ b/server/src/routes/user_web.rs @@ -409,6 +409,10 @@ struct CaseRecordingsTemplate { recordings: Vec, transcribe_busy: bool, is_admin: bool, + /// True when the case carries the `.closed` marker. Drives the read-only + /// view: the per-recording delete form is withheld and the back-to-list + /// link returns to the closed-inclusive list (`?show_closed=1`). + is_closed: bool, /// Session CSRF token — rendered as a hidden field into the /// per-recording delete forms and the logout form. csrf_token: String, @@ -736,7 +740,6 @@ pub async fn handle_case_page( State(boot_time): State, State(in_flight): State, CaseIdPath(case_id): CaseIdPath, - Query(query): Query, ) -> Result, AppError> { let user_root = config.data_path.join(&user.slug); pipeline @@ -750,24 +753,13 @@ pub async fn handle_case_page( ) .await; - let show_closed = query.include_closed(); - let case_dir = if show_closed { - locate_closed_case_or_404( - &user_root, - &case_id, - &user.slug, - "case page (show_closed=1)", - ) - .await? - } else { - locate_case_or_404( - &user_root, - &case_id, - &user.slug, - "case page (possible IDOR probe)", - ) - .await? - }; + // Direct case access is closed-agnostic: the `.closed` marker only hides + // a case from the default LIST (the discovery gate lives in + // `scan_user_cases`). A deep link or back link to a known, owned case — + // open or closed — must resolve, so the recordings page's back link works + // without threading `?show_closed=1`. IDOR is still guarded by the + // per-user root plus the existence check inside the resolver. + let case_dir = locate_closed_case_or_404(&user_root, &case_id, &user.slug, "case page").await?; // Auto-analysis trigger for deep-link navigation: same evaluation as // the list view. Document render below still wins the race if the // worker happens to finish synchronously. @@ -892,7 +884,6 @@ pub async fn handle_case_recordings( State(http_client): State, State(vocab): State>, CaseIdPath(case_id): CaseIdPath, - Query(query): Query, ) -> Result, AppError> { let user_root = config.data_path.join(&user.slug); pipeline @@ -906,27 +897,12 @@ pub async fn handle_case_recordings( ) .await; - // Closing a case only writes a `.closed` marker; the `.m4a` recordings - // stay on disk. A user who opted into viewing closed cases must still - // reach this list, so branch on `show_closed=1` exactly like - // `handle_case_page` does. - let case_dir = if query.include_closed() { - locate_closed_case_or_404( - &user_root, - &case_id, - &user.slug, - "case recordings (show_closed=1)", - ) - .await? - } else { - locate_case_or_404( - &user_root, - &case_id, - &user.slug, - "case recordings (possible IDOR probe)", - ) - .await? - }; + // Closed-agnostic, like `handle_case_page`: closing only writes a + // `.closed` marker and the `.m4a` recordings stay on disk, so the + // recordings sub-page must resolve for a closed case too. The discovery + // gate is the case LIST, not this view. + let case_dir = + locate_closed_case_or_404(&user_root, &case_id, &user.slug, "case recordings").await?; let case_id_str = case_id.to_string(); info!(slug = %user.slug, case_id = %case_id, "case recordings viewed"); @@ -936,6 +912,7 @@ pub async fn handle_case_recordings( let t_busy = pipeline.transcribe_busy.0.load(Ordering::Acquire); let case_id_short = case_id_str.chars().take(8).collect(); let is_admin = user.is_admin(); + let is_closed = crate::paths::is_closed(&case_dir).await; CaseRecordingsTemplate { csrf_token: user.csrf_token, @@ -946,6 +923,7 @@ pub async fn handle_case_recordings( recordings, transcribe_busy: t_busy, is_admin, + is_closed, } .render() .map(Html) @@ -992,9 +970,12 @@ pub(crate) async fn locate_case_or_404( } } -/// Like `locate_case_or_404` but accepts closed cases. Only the detail -/// view uses this, and only when the user navigated there with -/// `?show_closed=1`. A non-existent directory still 404s as expected. +/// Like `locate_case_or_404` but accepts closed cases. The read-only VIEW +/// handlers (case page, recordings sub-page) use this: the `.closed` marker +/// is a discovery filter for the case LIST, not an access gate on a known +/// case, so direct/deep-link access resolves regardless of closed-ness. The +/// mutating handlers keep using `locate_case_or_404` (which rejects closed) +/// since a closed case is read-only. A non-existent directory still 404s. pub(crate) async fn locate_closed_case_or_404( user_root: &Path, case_id: &CaseId, diff --git a/server/src/routes/web.rs b/server/src/routes/web.rs index cbbf433..60eba68 100644 --- a/server/src/routes/web.rs +++ b/server/src/routes/web.rs @@ -56,10 +56,12 @@ pub async fn handle_audio( .map_err(|_| AppError::BadRequest("Invalid case_id (not a UUID)".into()))?; validate_filename(&filename)?; + // The `.closed` marker is a discovery filter for the case LIST, not an + // access gate on the audio file server: a closed case keeps its `.m4a` + // files on disk and its recordings page must still play them. IDOR is + // guarded by the per-user path plus the slug/UUID/filename validation + // above, independent of whether the case is closed. let case_path = crate::paths::case_dir(&config.data_path, &user, &case_id); - if crate::paths::is_closed(&case_path).await { - return Err(AppError::NotFound("Audio file not found".into())); - } let file_path = case_path.join(&filename); let metadata = match tokio::fs::metadata(&file_path).await { Ok(m) => m, diff --git a/server/templates/case_page.html b/server/templates/case_page.html index 3ad2c7e..d3025dd 100644 --- a/server/templates/case_page.html +++ b/server/templates/case_page.html @@ -377,7 +377,7 @@
- +
{% if is_admin %}