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}}"
|
||||
title="Favourite" aria-label="Toggle favourite"
|
||||
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}}
|
||||
</button>
|
||||
<button class="icon" title="Set chapter" aria-label="Set chapter"
|
||||
onclick="toggleChapterForm('{{.Key}}')">✎</button>
|
||||
<button class="icon danger" title="Remove" aria-label="Remove"
|
||||
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>
|
||||
</div>
|
||||
<form class="chapter-form" id="chapter-form-{{.Key}}" hidden
|
||||
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"
|
||||
value="{{.LastChapterNum}}" aria-label="Chapter number" required>
|
||||
<button type="submit">Save</button>
|
||||
|
||||
+2
-1
@@ -6,6 +6,7 @@ import (
|
||||
"html/template"
|
||||
"io/fs"
|
||||
"log"
|
||||
"math"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -266,7 +267,7 @@ func (h *webHandler) uiChapter(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
raw := strings.TrimSpace(r.PostFormValue("chapter"))
|
||||
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)
|
||||
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) {
|
||||
cfg := webConfig()
|
||||
srv, store := newWebTestServer(t, cfg)
|
||||
@@ -324,7 +360,7 @@ func TestChapterOverrideRejectsBadInput(t *testing.T) {
|
||||
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) {
|
||||
rr := httptest.NewRecorder()
|
||||
srv.ServeHTTP(rr, uiRequest(t, cfg, http.MethodPost,
|
||||
|
||||
Reference in New Issue
Block a user