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:
@@ -330,13 +330,13 @@
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div><a class="back" href="/web/cases">← Fälle</a></div>
|
||||
<div><a class="back" href="/cases">← 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"
|
||||
>→ 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.
|
||||
|
||||
@@ -123,17 +123,17 @@ try {
|
||||
<body>
|
||||
<header>
|
||||
<div class="breadcrumb">
|
||||
<a href="/web/cases">← Fälle</a>
|
||||
<a href="/cases">← Fälle</a>
|
||||
{% match oneliner %}
|
||||
{% when Some with (t) %}
|
||||
<a href="/web/cases/{{ case_id }}">← {{ t }}</a>
|
||||
<a href="/cases/{{ case_id }}">← {{ t }}</a>
|
||||
{% when None %}
|
||||
<a href="/web/cases/{{ case_id }}">← Fall</a>
|
||||
<a href="/cases/{{ case_id }}">← 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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user