fix(backend): valid hx-target selector and reject NaN/Infinity chapter input
hx-target="#card-<key>" is an invalid CSS selector for any key containing a colon (every real bookmark key is "<site>:<series_id>"), so htmx threw before swapping and the favourite/delete/chapter-override controls were dead in the browser. Switch to the attribute-selector form [id='card-<key>'], which querySelectorAll accepts regardless of the id's characters. Also close a validation gap in uiChapter: strconv.ParseFloat accepts "NaN"/"Infinity"/"-Inf" with err == nil, and every comparison against NaN is false, so num < 0 let both through to last_chapter_num and permanently broke HasNewChapter. Reject non-finite values explicitly. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -15,19 +15,19 @@
|
|||||||
<button class="icon {{if .Favorite}}on{{end}}"
|
<button class="icon {{if .Favorite}}on{{end}}"
|
||||||
title="Favourite" aria-label="Toggle favourite"
|
title="Favourite" aria-label="Toggle favourite"
|
||||||
hx-post="/ui/bookmarks/{{.Key}}/favorite"
|
hx-post="/ui/bookmarks/{{.Key}}/favorite"
|
||||||
hx-target="#card-{{.Key}}" hx-swap="outerHTML">
|
hx-target="[id='card-{{.Key}}']" hx-swap="outerHTML">
|
||||||
{{if .Favorite}}★{{else}}☆{{end}}
|
{{if .Favorite}}★{{else}}☆{{end}}
|
||||||
</button>
|
</button>
|
||||||
<button class="icon" title="Set chapter" aria-label="Set chapter"
|
<button class="icon" title="Set chapter" aria-label="Set chapter"
|
||||||
onclick="toggleChapterForm('{{.Key}}')">✎</button>
|
onclick="toggleChapterForm('{{.Key}}')">✎</button>
|
||||||
<button class="icon danger" title="Remove" aria-label="Remove"
|
<button class="icon danger" title="Remove" aria-label="Remove"
|
||||||
hx-delete="/ui/bookmarks/{{.Key}}"
|
hx-delete="/ui/bookmarks/{{.Key}}"
|
||||||
hx-target="#card-{{.Key}}" hx-swap="outerHTML"
|
hx-target="[id='card-{{.Key}}']" hx-swap="outerHTML"
|
||||||
hx-confirm="Remove {{.Title}} from the list?">🗑</button>
|
hx-confirm="Remove {{.Title}} from the list?">🗑</button>
|
||||||
</div>
|
</div>
|
||||||
<form class="chapter-form" id="chapter-form-{{.Key}}" hidden
|
<form class="chapter-form" id="chapter-form-{{.Key}}" hidden
|
||||||
hx-post="/ui/bookmarks/{{.Key}}/chapter"
|
hx-post="/ui/bookmarks/{{.Key}}/chapter"
|
||||||
hx-target="#card-{{.Key}}" hx-swap="outerHTML">
|
hx-target="[id='card-{{.Key}}']" hx-swap="outerHTML">
|
||||||
<input name="chapter" type="number" step="0.1" min="0"
|
<input name="chapter" type="number" step="0.1" min="0"
|
||||||
value="{{.LastChapterNum}}" aria-label="Chapter number" required>
|
value="{{.LastChapterNum}}" aria-label="Chapter number" required>
|
||||||
<button type="submit">Save</button>
|
<button type="submit">Save</button>
|
||||||
|
|||||||
+2
-1
@@ -6,6 +6,7 @@ import (
|
|||||||
"html/template"
|
"html/template"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"log"
|
"log"
|
||||||
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -266,7 +267,7 @@ func (h *webHandler) uiChapter(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
raw := strings.TrimSpace(r.PostFormValue("chapter"))
|
raw := strings.TrimSpace(r.PostFormValue("chapter"))
|
||||||
num, err := strconv.ParseFloat(raw, 64)
|
num, err := strconv.ParseFloat(raw, 64)
|
||||||
if err != nil || num < 0 {
|
if err != nil || num < 0 || math.IsNaN(num) || math.IsInf(num, 0) {
|
||||||
http.Error(w, "chapter must be a non-negative number", http.StatusBadRequest)
|
http.Error(w, "chapter must be a non-negative number", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
+37
-1
@@ -281,6 +281,42 @@ func TestFavoriteTogglesWithoutReordering(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestCardHxTargetIsValidSelectorForColonKey asserts the rendered card's
|
||||||
|
// hx-target attributes use the fixed-string attribute-selector form
|
||||||
|
// ([id='card-<key>']) rather than a bare CSS id-selector (#card-<key>).
|
||||||
|
//
|
||||||
|
// A key like "asura:solo" makes "#card-asura:solo" an invalid CSS selector:
|
||||||
|
// the browser parses ":solo" as an unrecognised pseudo-class and htmx's
|
||||||
|
// querySelectorAll throws SyntaxError, so the button never resolves its
|
||||||
|
// swap target. httptest never executes htmx, so this only checks the
|
||||||
|
// rendered attribute's shape — it is not proof the browser accepts the
|
||||||
|
// selector, just a regression guard against reintroducing the bare-id form.
|
||||||
|
func TestCardHxTargetIsValidSelectorForColonKey(t *testing.T) {
|
||||||
|
cfg := webConfig()
|
||||||
|
srv, store := newWebTestServer(t, cfg)
|
||||||
|
seed(t, store, Bookmark{
|
||||||
|
Key: "asura:solo", Site: "asura", SeriesID: "solo",
|
||||||
|
Title: "Solo Leveling", LastChapter: "45", LastChapterNum: 45,
|
||||||
|
UpdatedAt: 1_000_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
srv.ServeHTTP(rr, uiRequest(t, cfg, http.MethodGet, "/ui/list", nil))
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200", rr.Code)
|
||||||
|
}
|
||||||
|
body := rr.Body.String()
|
||||||
|
|
||||||
|
want := `hx-target="[id='card-asura:solo']"`
|
||||||
|
if strings.Count(body, want) != 3 {
|
||||||
|
t.Fatalf("body has %d occurrences of %s, want 3 (favorite button, delete button, chapter form)",
|
||||||
|
strings.Count(body, want), want)
|
||||||
|
}
|
||||||
|
if strings.Contains(body, `hx-target="#card-asura:solo"`) {
|
||||||
|
t.Fatal("body still uses the bare id CSS selector, which is invalid for a key containing ':'")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestChapterOverrideMovesUpdatedAt(t *testing.T) {
|
func TestChapterOverrideMovesUpdatedAt(t *testing.T) {
|
||||||
cfg := webConfig()
|
cfg := webConfig()
|
||||||
srv, store := newWebTestServer(t, cfg)
|
srv, store := newWebTestServer(t, cfg)
|
||||||
@@ -324,7 +360,7 @@ func TestChapterOverrideRejectsBadInput(t *testing.T) {
|
|||||||
Title: "Solo Leveling", LastChapterNum: 45, UpdatedAt: 1_000_000,
|
Title: "Solo Leveling", LastChapterNum: 45, UpdatedAt: 1_000_000,
|
||||||
})
|
})
|
||||||
|
|
||||||
for _, bad := range []string{"", "abc", "-3"} {
|
for _, bad := range []string{"", "abc", "-3", "NaN", "Infinity", "-Inf"} {
|
||||||
t.Run("input "+bad, func(t *testing.T) {
|
t.Run("input "+bad, func(t *testing.T) {
|
||||||
rr := httptest.NewRecorder()
|
rr := httptest.NewRecorder()
|
||||||
srv.ServeHTTP(rr, uiRequest(t, cfg, http.MethodPost,
|
srv.ServeHTTP(rr, uiRequest(t, cfg, http.MethodPost,
|
||||||
|
|||||||
Reference in New Issue
Block a user