fix(podcasts): download_all binds on every wire — FlexBool + flip log
Reproduced the §0 item 2 suspect: echo v5 bool-binds form values via strconv.ParseBool, which accepts "true"/"1" but REJECTS the browser-native <input type=checkbox> value "on" — and a bind failure kills the WHOLE AddFeed request (400 "invalid request"), not just the checkbox. The add modal posts urlencoded (htmx), so any checkbox-serialization that emits "on" made Download All silently unusable. Fix: FlexBool (encoding.TextUnmarshaler-style UnmarshalParam) binds download_all permissively on every wire — "true"/"on"/"yes"/"checked" => true, explicit false/0/off => false, ABSENCE (unchecked box on any wire) => false; JSON bool binding is native and unaffected. Tolerates the exact wires htmx and browsers emit. AddFeed's subscription flip now logs "[PODCAST] flip ... <feed> — user" so the live flip is provable in app logs (user suspected it never fired). Integration test TestPodcastDownloadAllFormEncoding pins the urlencoded wire (what htmx actually sends): true + on both flip the caller's policy to 'all', an unchecked add leaves the default.
This commit is contained in:
1 parent
cdf81f6cf4
commit
7c7c08748e
2 files changed
+130
-12
No files matched your search
@@ -10,6 +10,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"sort"
|
||||
|
||||
"bookhoard/internal/database"
|
||||
@@ -74,6 +75,7 @@ func (h *PodcastHandler) flipSubscriptionToAll(ctx context.Context, userID, feed
|
||||
AutoDownload: "all",
|
||||
AutoDownloadCount: pgtype.Int4{}, // all needs no count
|
||||
})
|
||||
log.Printf("[PODCAST] flip: user %s → policy 'all' for feed %s (Download All / add checkbox)", userID, feedID)
|
||||
}
|
||||
|
||||
func NewPodcastHandler(db *database.Queries, svc *services.PodcastService, worker *services.PodcastDownloadWorker) *PodcastHandler {
|
||||
@@ -83,9 +85,30 @@ func NewPodcastHandler(db *database.Queries, svc *services.PodcastService, worke
|
||||
// ---- request/response shapes ----
|
||||
|
||||
type AddPodcastFeedRequest struct {
|
||||
LibraryID string `json:"library_id" form:"library_id" validate:"required"`
|
||||
URL string `json:"url" form:"url" validate:"required"`
|
||||
DownloadAll bool `json:"download_all" form:"download_all"`
|
||||
LibraryID string `json:"library_id" form:"library_id" validate:"required"`
|
||||
URL string `json:"url" form:"url" validate:"required"`
|
||||
DownloadAll FlexBool `json:"download_all" form:"download_all"`
|
||||
}
|
||||
|
||||
// FlexBool binds checkbox-style booleans on every wire. echo bool-binds
|
||||
// via strconv.ParseBool, which accepts "true"/"1" but REJECTS the
|
||||
// browser-native checkbox value "on" — and a bind failure is fatal for
|
||||
// the WHOLE request (the add modal would 400 "invalid request" on any
|
||||
// form variant that posts "on"). §0 item 2 (2026-10-30).
|
||||
//
|
||||
// A checkbox only contributes its field when CHECKED, so for form
|
||||
// posts presence really is the signal — JSON's native bool binding
|
||||
// ("false"→false) is unaffected. Absence on either wire = false.
|
||||
type FlexBool bool
|
||||
|
||||
func (b *FlexBool) UnmarshalParam(v string) error {
|
||||
switch strings.ToLower(strings.TrimSpace(v)) {
|
||||
case "false", "0", "off", "n", "no":
|
||||
*b = false
|
||||
default:
|
||||
*b = true // "true", "on", "yes", "checked", "1" — or any truthy variant
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type UpdatePodcastSubscriptionRequest struct {
|
||||
@@ -130,11 +153,11 @@ func podcastErrorResponse(c *echo.Context, err error) error {
|
||||
// feed counts are tens, not thousands; no facet SQL needed).
|
||||
type podcastFeedSummary struct {
|
||||
Feed
|
||||
SubscribedAt string `json:"subscribed_at"`
|
||||
EpisodeTotal int `json:"episode_total"`
|
||||
DownloadedN int `json:"episode_downloaded"`
|
||||
UndownloadedN int `json:"episode_undownloaded"`
|
||||
NewEpisodes bool `json:"new_episodes"`
|
||||
SubscribedAt string `json:"subscribed_at"`
|
||||
EpisodeTotal int `json:"episode_total"`
|
||||
DownloadedN int `json:"episode_downloaded"`
|
||||
UndownloadedN int `json:"episode_undownloaded"`
|
||||
NewEpisodes bool `json:"new_episodes"`
|
||||
}
|
||||
|
||||
// AddFeed POST /api/podcasts/feeds
|
||||
@@ -245,10 +268,10 @@ func (h *PodcastHandler) GetFeed(c *echo.Context) error {
|
||||
episodeViews = append(episodeViews, episodeView(&eps[i]))
|
||||
}
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||
"feed": feedWithSubscription(&feed, sub, subFound == nil),
|
||||
"episodes": episodeViews,
|
||||
"gap_count": countGaps(episodeViews),
|
||||
"undownloaded": len(episodeViews) - countDownloaded(episodeViews),
|
||||
"feed": feedWithSubscription(&feed, sub, subFound == nil),
|
||||
"episodes": episodeViews,
|
||||
"gap_count": countGaps(episodeViews),
|
||||
"undownloaded": len(episodeViews) - countDownloaded(episodeViews),
|
||||
"has_library_access": true, // RLS already gated the row; kept for client convenience
|
||||
})
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user