refactor: drop /web/ URL prefix from browser routes

The /web/ prefix predated the /api/ split; today it just clutters every URL
without disambiguating anything. All 16 browser routes move to the apex
(/cases, /login, /magic, /events, /audio/...). The 6 /api/* routes are
unchanged. A new /->>/cases redirect closes the apex 404.

The open-redirect guard in magic.rs and case_actions.rs flips from a
positive whitelist (starts_with("/web/")) to a deny-list: same-origin path,
not protocol-relative, not under /api/, no \. The /api/ exclusion is now
load-bearing and covered by tests.

Pre-production: no transition redirects.
This commit is contained in:
2026-05-04 18:36:10 +02:00
parent 3d67cbc1c8
commit 2c6062a53e
43 changed files with 313 additions and 266 deletions
+31 -31
View File
@@ -9,65 +9,65 @@
pub use doctate_common::{ONELINERS_PATH, UPLOAD_PATH};
// -- /web/* --
// -- browser UI routes --
/// `POST /web/login` — form login (slug, password).
pub const LOGIN: &str = "/web/login";
/// `POST /web/logout` — CSRF-protected logout.
pub const LOGOUT: &str = "/web/logout";
/// `GET /web/cases` — case list; also triggers the lazy retention sweep.
pub const CASES: &str = "/web/cases";
/// `POST /web/cases/bulk` — admin bulk action (close/analyze/reset).
pub const BULK: &str = "/web/cases/bulk";
/// `POST /web/cases/purge-closed` — admin hard-delete of closed cases.
pub const PURGE_CLOSED: &str = "/web/cases/purge-closed";
/// `GET /web/events` — SSE stream (per-user scoped).
pub const EVENTS: &str = "/web/events";
/// `POST /login` — form login (slug, password).
pub const LOGIN: &str = "/login";
/// `POST /logout` — CSRF-protected logout.
pub const LOGOUT: &str = "/logout";
/// `GET /cases` — case list; also triggers the lazy retention sweep.
pub const CASES: &str = "/cases";
/// `POST /cases/bulk` — admin bulk action (close/analyze/reset).
pub const BULK: &str = "/cases/bulk";
/// `POST /cases/purge-closed` — admin hard-delete of closed cases.
pub const PURGE_CLOSED: &str = "/cases/purge-closed";
/// `GET /events` — SSE stream (per-user scoped).
pub const EVENTS: &str = "/events";
// -- per-case builders --
/// `/web/cases/{case_id}`.
/// `/cases/{case_id}`.
pub fn case_detail(case_id: &str) -> String {
format!("/web/cases/{case_id}")
format!("/cases/{case_id}")
}
/// `/web/cases/{case_id}/recordings`.
/// `/cases/{case_id}/recordings`.
pub fn case_recordings(case_id: &str) -> String {
format!("/web/cases/{case_id}/recordings")
format!("/cases/{case_id}/recordings")
}
/// `POST /web/cases/{case_id}/close`.
/// `POST /cases/{case_id}/close`.
pub fn case_close(case_id: &str) -> String {
format!("/web/cases/{case_id}/close")
format!("/cases/{case_id}/close")
}
/// `POST /web/cases/{case_id}/reopen`.
/// `POST /cases/{case_id}/reopen`.
pub fn case_reopen(case_id: &str) -> String {
format!("/web/cases/{case_id}/reopen")
format!("/cases/{case_id}/reopen")
}
/// `POST /web/cases/{case_id}/analyze`.
/// `POST /cases/{case_id}/analyze`.
pub fn case_analyze(case_id: &str) -> String {
format!("/web/cases/{case_id}/analyze")
format!("/cases/{case_id}/analyze")
}
/// `POST /web/cases/{case_id}/reset` — admin-only hard-reset.
/// `POST /cases/{case_id}/reset` — admin-only hard-reset.
pub fn case_reset(case_id: &str) -> String {
format!("/web/cases/{case_id}/reset")
format!("/cases/{case_id}/reset")
}
/// `POST /web/cases/{case_id}/recordings/delete` — per-recording delete.
/// `POST /cases/{case_id}/recordings/delete` — per-recording delete.
pub fn recording_delete(case_id: &str) -> String {
format!("/web/cases/{case_id}/recordings/delete")
format!("/cases/{case_id}/recordings/delete")
}
/// `POST /web/cases/{case_id}/oneliner` — session-authenticated manual
/// `POST /cases/{case_id}/oneliner` — session-authenticated manual
/// oneliner override (browser web UI).
pub fn case_oneliner_web(case_id: &str) -> String {
format!("/web/cases/{case_id}/oneliner")
format!("/cases/{case_id}/oneliner")
}
/// `GET /web/magic?token={token}` — magic-link consumption.
/// `GET /magic?token={token}` — magic-link consumption.
pub fn magic(token: &str) -> String {
format!("/web/magic?token={token}")
format!("/magic?token={token}")
}