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:
2026-07-25 23:19:16 +07:00
parent 2c3d687b6d
commit daec18546c
3 changed files with 42 additions and 5 deletions
+3 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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,