diff options
| author | Adam Malczewski <[email protected]> | 2026-06-01 09:50:30 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-01 09:50:30 +0900 |
| commit | 1870d0b86e797077b2a86c39d93fd34004b39e40 (patch) | |
| tree | e1da8ed73fbec1483a5f5a2e263fbdae6a9f5f9f /packages/frontend/src/lib/components | |
| parent | 29bdd00f946d75671bea7c4b534d32197e5b4b55 (diff) | |
| download | dispatch-1870d0b86e797077b2a86c39d93fd34004b39e40.tar.gz dispatch-1870d0b86e797077b2a86c39d93fd34004b39e40.zip | |
fix(notifications): address Gemini review — tighten validation, sanitize Click, support Basic auth, non-optimistic UI clear
Acted on 4 of 6 findings from the gemini-3-flash-preview second-opinion
review (the other 2 were verified-wrong or judged not worth the
complexity — see HANDOFF.md).
core/src/notifications/ntfy.ts:
- validateTopicUrl now enforces ntfy's actual topic-name constraints:
exactly one path segment, 1–64 chars, charset [A-Za-z0-9_-]. Prevents
users from saving topic URLs that look fine but silently 404 at
publish time (cf. binwiederhier/ntfy#1451 for the 64-char limit and
binwiederhier/ntfy's topic-name regex for the charset).
- Click header now passes through sanitizeHeader, closing the same
CRLF-injection vector that Title/Tags already had.
- Authorization header construction now factors through a small
buildAuthHeaderValue helper: a value that already starts with a scheme
token ("Bearer xyz", "Basic dXNlcjpwYXNz") is used verbatim, so users
of private ntfy servers that want Basic auth can paste the full header
value. Bare tokens still get the "Bearer " prefix automatically.
frontend/SettingsPanel.svelte:
- clearNtfyAuthToken() was optimistic: it flipped hasAuthToken=false
locally before awaiting the network call. If the request failed the
UI lied about server state, and worse — a subsequent Save() with
authToken:undefined would silently re-arm the original token. Now
awaits the response, surfaces failures via the existing ntfySaveError
banner, and only mutates local state on success. Adds a
ntfyClearingToken loading flag so the button disables + spins during
the request.
Tests: +6 in ntfy.test.ts (multi-segment rejection, charset rejection,
length boundary, 64-char acceptance, Basic auth pass-through, Click
sanitization). All 442 tests pass; biome clean; svelte-check clean;
manual ntfy.sh end-to-end re-verified.
Diffstat (limited to 'packages/frontend/src/lib/components')
| -rw-r--r-- | packages/frontend/src/lib/components/SettingsPanel.svelte | 57 |
1 files changed, 42 insertions, 15 deletions
diff --git a/packages/frontend/src/lib/components/SettingsPanel.svelte b/packages/frontend/src/lib/components/SettingsPanel.svelte index 1d9ebf7..8031500 100644 --- a/packages/frontend/src/lib/components/SettingsPanel.svelte +++ b/packages/frontend/src/lib/components/SettingsPanel.svelte @@ -72,6 +72,7 @@ let ntfySaveOk = $state(false); let ntfyTesting = $state(false); let ntfyTestResult = $state<string | null>(null); let ntfyTestOk = $state(false); +let ntfyClearingToken = $state(false); function onChunkLimitChange(e: Event): void { const input = e.target as HTMLInputElement; @@ -212,22 +213,43 @@ async function sendNtfyTest(): Promise<void> { } } -function clearNtfyAuthToken(): void { +async function clearNtfyAuthToken(): Promise<void> { // `""` ⇒ explicit clear on save (vs. `undefined` which keeps existing). - ntfyAuthTokenInput = ""; - ntfy = { ...ntfy, hasAuthToken: false }; - // Send a save with explicit empty string to clear server-side. - void (async () => { - try { - await fetch(`${apiBase}/notifications`, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ authToken: "" }), - }); - } catch { - // ignore + // Optimistic local state on failure caused a real bug pre-review: UI showed + // the token cleared while the server still held it, then "Save" treated the + // blank input as "keep existing" and silently re-armed the old token. Await + // the response and only flip local state on success. + ntfyClearingToken = true; + ntfySaveError = null; + try { + const res = await fetch(`${apiBase}/notifications`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ authToken: "" }), + }); + const data = (await res.json().catch(() => ({}))) as { + config?: NtfyConfigView; + error?: string; + }; + if (!res.ok) { + ntfySaveError = data.error ?? `Clear failed (HTTP ${res.status})`; + return; } - })(); + ntfyAuthTokenInput = ""; + if (data.config) { + ntfy = { + ...DEFAULT_NTFY, + ...data.config, + events: { ...DEFAULT_NTFY.events, ...(data.config.events ?? {}) }, + }; + } else { + ntfy = { ...ntfy, hasAuthToken: false }; + } + } catch (e) { + ntfySaveError = e instanceof Error ? e.message : "Network error"; + } finally { + ntfyClearingToken = false; + } } async function toggleAutoExpand(): Promise<void> { @@ -421,9 +443,14 @@ $effect(() => { <button type="button" class="btn btn-xs btn-ghost btn-outline self-start" + disabled={ntfyClearingToken} onclick={clearNtfyAuthToken} > - Clear stored token + {#if ntfyClearingToken} + <span class="loading loading-spinner loading-xs"></span> + {:else} + Clear stored token + {/if} </button> {/if} </label> |
