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
+20 -20
View File
@@ -330,13 +330,13 @@
</head>
<body>
<header>
<div><a class="back" href="/web/cases">&larr; Fälle</a></div>
<div><a class="back" href="/cases">&larr; Fälle</a></div>
<div class="header-right">
{% if is_admin %}<label class="admin-toggle"
><input type="checkbox" id="admin-view-toggle" /> Admin
view</label
>{% endif %}
<form method="post" action="/web/logout">
<form method="post" action="/logout">
{% call csrf::field(csrf_token) %}
<button type="submit">Logout</button>
</form>
@@ -362,7 +362,7 @@
<form
class="delete-form"
method="post"
action="/web/cases/{{ case_id }}/reopen"
action="/cases/{{ case_id }}/reopen"
>
{% call csrf::field(csrf_token) %}
<button
@@ -393,7 +393,7 @@
<form
class="delete-form"
method="post"
action="/web/cases/{{ case_id }}/close"
action="/cases/{{ case_id }}/close"
>
{% call csrf::field(csrf_token) %}
<button
@@ -435,15 +435,15 @@
<div class="actions">
{% if can_analyze && !has_document %}
<form method="post" action="/web/cases/{{ case_id }}/analyze">
<form method="post" action="/cases/{{ case_id }}/analyze">
{% call csrf::field(csrf_token) %}
<input type="hidden" name="return_to" value="/web/cases/{{ case_id }}">
<input type="hidden" name="return_to" value="/cases/{{ case_id }}">
<button type="submit">Analysieren</button>
</form>
{% if is_admin %}
<form class="admin-only" method="post" action="/web/cases/{{ case_id }}/analyze">
<form class="admin-only" method="post" action="/cases/{{ case_id }}/analyze">
{% call csrf::field(csrf_token) %}
<input type="hidden" name="return_to" value="/web/cases/{{ case_id }}">
<input type="hidden" name="return_to" value="/cases/{{ case_id }}">
{% for b in backends %}
<button type="submit" name="backend" value="{{ b.id }}"
title="Analysieren mit {{ b.label }}">{{ b.label }}</button>
@@ -456,10 +456,10 @@
<form
class="admin-only"
method="post"
action="/web/cases/{{ case_id }}/reset"
action="/cases/{{ case_id }}/reset"
>
{% call csrf::field(csrf_token) %}
<input type="hidden" name="return_to" value="/web/cases/{{ case_id }}">
<input type="hidden" name="return_to" value="/cases/{{ case_id }}">
<button type="submit">Reset</button>
</form>
<button
@@ -478,9 +478,9 @@
<div id="doc-content" class="doc-content">
{% if can_analyze %}
<div class="doc-actions-top">
<form method="post" action="/web/cases/{{ case_id }}/analyze">
<form method="post" action="/cases/{{ case_id }}/analyze">
{% call csrf::field(csrf_token) %}
<input type="hidden" name="return_to" value="/web/cases/{{ case_id }}">
<input type="hidden" name="return_to" value="/cases/{{ case_id }}">
<button
type="submit"
class="copy-btn"
@@ -506,9 +506,9 @@
</button>
</form>
{% if is_admin %}
<form class="admin-only" method="post" action="/web/cases/{{ case_id }}/analyze">
<form class="admin-only" method="post" action="/cases/{{ case_id }}/analyze">
{% call csrf::field(csrf_token) %}
<input type="hidden" name="return_to" value="/web/cases/{{ case_id }}">
<input type="hidden" name="return_to" value="/cases/{{ case_id }}">
{% for b in backends %}
<button
type="submit"
@@ -625,13 +625,13 @@
</details>
<form
method="post"
action="/web/cases/{{ case_id }}/analyze"
action="/cases/{{ case_id }}/analyze"
>
{% call csrf::field(csrf_token) %}
<input
type="hidden"
name="return_to"
value="/web/cases/{{ case_id }}"
value="/cases/{{ case_id }}"
/>
<button type="submit">Erneut versuchen</button>
</form>
@@ -639,13 +639,13 @@
<form
class="admin-only"
method="post"
action="/web/cases/{{ case_id }}/analyze"
action="/cases/{{ case_id }}/analyze"
>
{% call csrf::field(csrf_token) %}
<input
type="hidden"
name="return_to"
value="/web/cases/{{ case_id }}"
value="/cases/{{ case_id }}"
/>
{% for b in backends %}
<button type="submit" name="backend" value="{{ b.id }}">
@@ -669,7 +669,7 @@
<div>
<a
class="recordings-link"
href="/web/cases/{{ case_id }}/recordings"
href="/cases/{{ case_id }}/recordings"
>&rarr; Aufnahmen anzeigen ({{ recordings_count }})</a
>
</div>
@@ -730,7 +730,7 @@
clearTimeout(timer);
timer = setTimeout(() => location.reload(), 300);
};
const es = new EventSource("/web/events");
const es = new EventSource("/events");
// Release the socket-pool slot on navigation. Without this, Chrome
// keeps the SSE connection "draining" after unload; six rapid nav
// cycles exhaust the 6-per-origin pool and stall further requests.
+7 -7
View File
@@ -123,17 +123,17 @@ try {
<body>
<header>
<div class="breadcrumb">
<a href="/web/cases">&larr; Fälle</a>
<a href="/cases">&larr; Fälle</a>
{% match oneliner %}
{% when Some with (t) %}
<a href="/web/cases/{{ case_id }}">&larr; {{ t }}</a>
<a href="/cases/{{ case_id }}">&larr; {{ t }}</a>
{% when None %}
<a href="/web/cases/{{ case_id }}">&larr; Fall</a>
<a href="/cases/{{ case_id }}">&larr; Fall</a>
{% endmatch %}
</div>
<div class="header-right">
{% if is_admin %}<label class="admin-toggle"><input type="checkbox" id="admin-view-toggle"> Admin view</label>{% endif %}
<form method="post" action="/web/logout">{% call csrf::field(csrf_token) %}<button type="submit">Logout</button></form>
<form method="post" action="/logout">{% call csrf::field(csrf_token) %}<button type="submit">Logout</button></form>
</div>
</header>
<h1>Aufnahmen</h1>
@@ -143,12 +143,12 @@ try {
<div class="recording{% if rec.failed %} failed-row{% endif %}">
<div class="recording-head">
<time class="rec-time" datetime="{{ rec.recorded_at_iso }}">{{ rec.recorded_at_iso }}</time>
<div class="player" data-src="/web/audio/{{ slug }}/{{ case_id }}/{{ rec.filename }}"{% match rec.duration_seconds %}{% when Some with (d) %} data-duration="{{ d }}"{% when None %}{% endmatch %}{% match rec.gain_db %}{% when Some with (g) %} data-gain-db="{{ g }}"{% when None %}{% endmatch %}>
<div class="player" data-src="/audio/{{ slug }}/{{ case_id }}/{{ rec.filename }}"{% match rec.duration_seconds %}{% when Some with (d) %} data-duration="{{ d }}"{% when None %}{% endmatch %}{% match rec.gain_db %}{% when Some with (g) %} data-gain-db="{{ g }}"{% when None %}{% endmatch %}>
<button type="button" class="play" aria-label="Abspielen"></button>
<input type="range" class="seek" min="0" max="1000" value="0" step="1" aria-label="Position">
<span class="time">0:00 / 0:00</span>
</div>
<form method="post" action="/web/cases/{{ case_id }}/recordings/delete" class="rec-delete-form" data-ts="{{ rec.recorded_at_iso }}">
<form method="post" action="/cases/{{ case_id }}/recordings/delete" class="rec-delete-form" data-ts="{{ rec.recorded_at_iso }}">
{% call csrf::field(csrf_token) %}
<input type="hidden" name="filename" value="{{ rec.filename }}">
<button type="submit" class="delete-btn" title="Aufnahme entfernen" aria-label="Aufnahme entfernen"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"></path><path d="M10 11v6"></path><path d="M14 11v6"></path><path d="M9 6V4a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2v2"></path></svg></button>
@@ -406,7 +406,7 @@ try {
else location.reload();
}, 300);
};
const es = new EventSource('/web/events');
const es = new EventSource('/events');
// Release the socket-pool slot on navigation. Without this, Chrome
// keeps the SSE connection "draining" after unload; six rapid nav
// cycles exhaust the 6-per-origin pool and stall further requests.
+1 -1
View File
@@ -20,7 +20,7 @@ button { cursor: pointer; margin-top: 0.4em; }
<div class="error">{{ msg }}</div>
{% when None %}
{% endmatch %}
<form method="post" action="/web/login">
<form method="post" action="/login">
<label>Benutzer<input type="text" name="slug" autofocus required></label>
<label>Passwort<input type="password" name="password" required></label>
<button type="submit">Anmelden</button>
+13 -13
View File
@@ -88,22 +88,22 @@ try {
<h1>{{ display_name }}</h1>
<div class="header-right">
{% if is_admin %}<label class="admin-toggle"><input type="checkbox" id="admin-view-toggle"> Admin view</label>{% endif %}
<form method="post" action="/web/logout">{% call csrf::field(csrf_token) %}<button type="submit">Logout</button></form>
<form method="post" action="/logout">{% call csrf::field(csrf_token) %}<button type="submit">Logout</button></form>
</div>
</header>
{% if show_closed %}
<a class="closed-toggle" href="/web/cases">← Nur offene Fälle anzeigen</a>
<a class="closed-toggle" href="/cases">← Nur offene Fälle anzeigen</a>
{% else %}
<a class="closed-toggle" href="/web/cases?show_closed=1">Geschlossene Fälle einblenden</a>
<a class="closed-toggle" href="/cases?show_closed=1">Geschlossene Fälle einblenden</a>
{% endif %}
{% if total == 0 %}
<section><p class="empty">{% if show_closed %}Keine offenen oder geschlossenen Fälle.{% else %}Keine Fälle.{% endif %}</p></section>
{% else %}
<form method="post" action="/web/cases/bulk">
<form method="post" action="/cases/bulk">
{% call csrf::field(csrf_token) %}
<input type="hidden" name="return_to" value="/web/cases{% if show_closed %}?show_closed=1{% endif %}">
<input type="hidden" name="return_to" value="/cases{% if show_closed %}?show_closed=1{% endif %}">
{% for g in groups %}
<section>
@@ -114,19 +114,19 @@ try {
{% if is_admin %}<div class="check admin-only"><input type="checkbox" name="case_id" value="{{ case.case_id }}"{% if case.is_closed %} disabled{% endif %}></div>{% endif %}
<div class="body">
<div class="line1">
<a class="case-link" href="/web/cases/{{ case.case_id }}{% if case.is_closed %}?show_closed=1{% endif %}"><span class="time"><time datetime="{{ case.recorded_at_iso }}">{{ case.time_hms_utc }}</time></span> — {% call ol::render(case.oneliner) %}{% if case.oneliner_is_manual %} <span class="manual-marker" title="manuell bearbeitet" aria-label="manuell bearbeitet">👤</span>{% endif %}{% if case.has_failed_recording %} <span class="status-badge fehler">Fehler</span>{% else if case.has_document %} <span class="status-badge done">ausgewertet</span>{% else if !case.is_closed %} <span class="status-badge open">offen</span>{% endif %}{% if case.analyzing %} <span class="label analyzing">wird analysiert</span>{% endif %}{% if case.is_closed %} <span class="closed-badge">geschlossen{% match case.days_until_purge %}{% when Some with (d) %} — wird in {{ d }} Tagen entfernt{% when None %}{% endmatch %}</span>{% endif %}</a>
<a class="recordings-link" href="/web/cases/{{ case.case_id }}/recordings{% if case.is_closed %}?show_closed=1{% endif %}">{{ case.recordings_count }} Aufnahmen</a>
<a class="case-link" href="/cases/{{ case.case_id }}{% if case.is_closed %}?show_closed=1{% endif %}"><span class="time"><time datetime="{{ case.recorded_at_iso }}">{{ case.time_hms_utc }}</time></span> — {% call ol::render(case.oneliner) %}{% if case.oneliner_is_manual %} <span class="manual-marker" title="manuell bearbeitet" aria-label="manuell bearbeitet">👤</span>{% endif %}{% if case.has_failed_recording %} <span class="status-badge fehler">Fehler</span>{% else if case.has_document %} <span class="status-badge done">ausgewertet</span>{% else if !case.is_closed %} <span class="status-badge open">offen</span>{% endif %}{% if case.analyzing %} <span class="label analyzing">wird analysiert</span>{% endif %}{% if case.is_closed %} <span class="closed-badge">geschlossen{% match case.days_until_purge %}{% when Some with (d) %} — wird in {{ d }} Tagen entfernt{% when None %}{% endmatch %}</span>{% endif %}</a>
<a class="recordings-link" href="/cases/{{ case.case_id }}/recordings{% if case.is_closed %}?show_closed=1{% endif %}">{{ case.recordings_count }} Aufnahmen</a>
</div>
{% match case.analysis_html %}{% when Some with (html) %}<div class="analysis" data-expand><span class="arrow"></span><div class="analysis-content">{{ html|safe }}</div></div>{% when None %}{% endmatch %}
{% if is_admin %}<div class="uuid admin-only">{{ case.case_id }}</div>{% endif %}
</div>
<div class="actions">
{% if is_admin %}<button type="submit" formaction="/web/cases/{{ case.case_id }}/analyze" class="admin-only"{% if !case.can_analyze || case.is_closed %} disabled{% endif %}>{% if case.has_document %}Neu analysieren{% else %}Analysieren{% endif %}</button>
<button type="submit" formaction="/web/cases/{{ case.case_id }}/reset" class="admin-only"{% if case.is_closed %} disabled{% endif %}>Reset</button>{% endif %}
{% if is_admin %}<button type="submit" formaction="/cases/{{ case.case_id }}/analyze" class="admin-only"{% if !case.can_analyze || case.is_closed %} disabled{% endif %}>{% if case.has_document %}Neu analysieren{% else %}Analysieren{% endif %}</button>
<button type="submit" formaction="/cases/{{ case.case_id }}/reset" class="admin-only"{% if case.is_closed %} disabled{% endif %}>Reset</button>{% endif %}
{% if case.is_closed %}
<button type="submit" formaction="/web/cases/{{ case.case_id }}/reopen" class="reopen-btn" title="Fall wiedereröffnen" aria-label="Fall wiedereröffnen"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"></path><path d="M3 3v5h5"></path></svg></button>
<button type="submit" formaction="/cases/{{ case.case_id }}/reopen" class="reopen-btn" title="Fall wiedereröffnen" aria-label="Fall wiedereröffnen"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"></path><path d="M3 3v5h5"></path></svg></button>
{% else %}
<button type="submit" formaction="/web/cases/{{ case.case_id }}/close" class="delete-btn" title="Fall schließen" aria-label="Fall schließen"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"></path><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"></path><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path><line x1="10" x2="10" y1="11" y2="17"></line><line x1="14" x2="14" y1="11" y2="17"></line></svg></button>
<button type="submit" formaction="/cases/{{ case.case_id }}/close" class="delete-btn" title="Fall schließen" aria-label="Fall schließen"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"></path><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"></path><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path><line x1="10" x2="10" y1="11" y2="17"></line><line x1="14" x2="14" y1="11" y2="17"></line></svg></button>
{% endif %}
</div>
</li>
@@ -143,7 +143,7 @@ try {
</div>{% endif %}
</form>
{% if is_admin && show_closed && any_closed %}
<form class="bulk-bar purge admin-only" method="post" action="/web/cases/purge-closed" onsubmit="return confirm('Alle geschlossenen Fälle UNWIDERRUFLICH löschen? Diese Aktion kann nicht rückgängig gemacht werden.')">
<form class="bulk-bar purge admin-only" method="post" action="/cases/purge-closed" onsubmit="return confirm('Alle geschlossenen Fälle UNWIDERRUFLICH löschen? Diese Aktion kann nicht rückgängig gemacht werden.')">
{% call csrf::field(csrf_token) %}
<input type="hidden" name="confirm" value="yes">
<span>Geschlossene Fälle:</span>
@@ -222,7 +222,7 @@ try {
clearTimeout(timer);
timer = setTimeout(() => location.reload(), 300);
};
const es = new EventSource('/web/events');
const es = new EventSource('/events');
// Release the socket-pool slot on navigation. Without this, Chrome
// keeps the SSE connection "draining" after unload; six rapid nav
// cycles exhaust the 6-per-origin pool and stall further requests.
+2 -2
View File
@@ -1,5 +1,5 @@
// Inline tap-to-edit for .oneliner-edit blocks. Click swaps the inner
// .oneliner-text for an <input>; Enter POSTs to /web/cases/{id}/oneliner;
// .oneliner-text for an <input>; Enter POSTs to /cases/{id}/oneliner;
// Escape (or blur) cancels. The SSE-driven reload picks up the persisted
// state, so no DOM patching is needed on success.
(() => {
@@ -37,7 +37,7 @@
});
try {
const r = await fetch(
`/web/cases/${host.dataset.caseId}/oneliner`,
`/cases/${host.dataset.caseId}/oneliner`,
{ method: 'POST', body, credentials: 'same-origin' }
);
if (!r.ok) {