From 253d8d0e8ed81823b57424f5a8f03658e1d78b81 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Thu, 29 Oct 2026 20:12:39 -0400 Subject: [PATCH] feat(podcasts+ops): Apple launcher-scheme feeds + visible build stamp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Apple scheme normalization (follow-up to the Scribl discussion): Add Feed accepts the launcher spellings podcast pages ship next to plain RSS — itpc://, podcasts://, feed:// — and rewrites them to the https feed URL they wrap (scheme match case-insensitive). http stays and the guard still rejects it. Unit-pinned. Build stamp (stale-container drift guard): the 2026-10-29 podcast session lost an hour to a container running a build that predated the feature. version.Commit rides ldflags from a GIT_COMMIT build arg; the dev compose passes git rev-parse --short HEAD via the Makefile; the app logs 'Bookhoard (commit )' at boot and /api/health reports it — the UI and the repo can no longer silently diverge. --- Dockerfile | 4 +++- Makefile | 4 ++++ cmd/server/main.go | 2 ++ docker-compose.dev.yml | 2 ++ internal/router/frontend.go | 1 + internal/services/podcast_rss_test.go | 11 +++++++++++ internal/services/podcast_service.go | 19 ++++++++++++++++++- internal/version/version.go | 8 ++++++++ 8 files changed, 49 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 916f9657..1250495d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -41,12 +41,14 @@ RUN npm run build:ts # Declared after `COPY . .` so a version bump only invalidates this layer. # The release workflow passes the git tag, e.g. APP_VERSION=v1.5. ARG APP_VERSION=dev +# Short commit hash of the built source (stale-container drift guard). +ARG GIT_COMMIT=none # Build Go binary (cached unless Go files or generated code changes) RUN --mount=type=cache,target=/root/go/pkg/mod \ --mount=type=cache,target=/root/.cache/go-build \ CGO_ENABLED=0 GOOS=linux go build -installsuffix cgo \ - -ldflags "-X bookhoard/internal/version.Version=${APP_VERSION}" \ + -ldflags "-X bookhoard/internal/version.Version=${APP_VERSION} -X bookhoard/internal/version.Commit=${GIT_COMMIT}" \ -o main ./cmd/server # Materialize the Go module cache into an image layer so the test-runner diff --git a/Makefile b/Makefile index 8f35ba00..2dc3e325 100644 --- a/Makefile +++ b/Makefile @@ -11,6 +11,10 @@ endif # Override with: CONTAINER_RUNTIME=podman make rebuild-app CONTAINER_RUNTIME ?= $(shell command -v docker 2>/dev/null || command -v podman 2>/dev/null) +# Build stamp (stale-container drift guard): the rebuilt image carries +# the source commit; the app logs it at boot and /api/health reports it. +GIT_COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo none) + # Dev compose stack: base prod file merged with the dev override (local build + tests). # Prod deploy does NOT use this — it runs plain `docker compose` against the base file only. COMPOSE := $(CONTAINER_RUNTIME) compose -f docker-compose.yml -f docker-compose.dev.yml diff --git a/cmd/server/main.go b/cmd/server/main.go index 269ec2bb..43413559 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -10,6 +10,7 @@ import ( "bookhoard/internal/router" "bookhoard/internal/services" "bookhoard/internal/sync" + "bookhoard/internal/version" "bookhoard/templates" "context" "log" @@ -33,6 +34,7 @@ func (cv *CustomValidator) Validate(i interface{}) error { } func main() { + log.Printf("Bookhoard %s (commit %s) starting", version.Version, version.Commit) cfg := config.LoadConfig() dbPool, err := pgxpool.New(context.Background(), cfg.DatabaseURL()) diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index bebd5356..fdd831e6 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -12,6 +12,8 @@ services: build: context: . dockerfile: ./Dockerfile + args: + GIT_COMMIT: ${GIT_COMMIT:-none} # Integration Tests - runs against containerized app and db (dev only) tests: diff --git a/internal/router/frontend.go b/internal/router/frontend.go index 09492ee4..3f1158d4 100644 --- a/internal/router/frontend.go +++ b/internal/router/frontend.go @@ -1984,6 +1984,7 @@ func (cfg *Config) GetHealth(c *echo.Context) error { return c.JSON(http.StatusOK, map[string]interface{}{ "status": "healthy", "version": version.Version, + "commit": version.Commit, "database": "connected", "scan": map[string]interface{}{ "scan_in_progress": cfg.Worker.HasActiveScans(), diff --git a/internal/services/podcast_rss_test.go b/internal/services/podcast_rss_test.go index c33e0a0e..44a0e9e6 100644 --- a/internal/services/podcast_rss_test.go +++ b/internal/services/podcast_rss_test.go @@ -290,3 +290,14 @@ func newInsecureFeedServer(t *testing.T, body string) *httptest.Server { t.Cleanup(srv.Close) return srv } + +// Apple Podcast launcher schemes normalize to the https feed they wrap. +func TestNormalizeFeedURL(t *testing.T) { + assert.Equal(t, "https://x.test/feed.xml", normalizeFeedURL("itpc://x.test/feed.xml")) + assert.Equal(t, "https://x.test/feed.xml", normalizeFeedURL("podcasts://x.test/feed.xml")) + assert.Equal(t, "https://x.test/feed.xml", normalizeFeedURL("feed://x.test/feed.xml")) + assert.Equal(t, "https://x.test/feed.xml", normalizeFeedURL("ITPC://x.test/feed.xml"), "scheme match is case-insensitive") + assert.Equal(t, "https://x.test/feed.xml", normalizeFeedURL("https://x.test/feed.xml")) + assert.Equal(t, "http://x.test/feed.xml", normalizeFeedURL("http://x.test/feed.xml"), "http stays — the guard rejects it downstream") + assert.Equal(t, "", normalizeFeedURL(" ")) +} diff --git a/internal/services/podcast_service.go b/internal/services/podcast_service.go index 506f1bd3..5c366d4e 100644 --- a/internal/services/podcast_service.go +++ b/internal/services/podcast_service.go @@ -156,7 +156,7 @@ func (s *PodcastService) AddFeed(ctx context.Context, userID pgtype.UUID, isAdmi } } - feedURL := strings.TrimSpace(rawFeedURL) + feedURL := normalizeFeedURL(rawFeedURL) if feedURL == "" { return nil, errBadRequest("feed URL is required") } @@ -711,6 +711,23 @@ func sortedStringSet(set map[string]bool) []string { // classifyFetchError maps guard/HTTP errors to user-facing text (the // add-feed dialog and the download_error column both render it). +// normalizeFeedURL accepts the launcher-scheme spellings podcast +// pages hand out alongside plain RSS (issue follow-up: "one says RSS, +// the other says Apple Podcast") and rewrites them to the https URL +// they wrap: itpc:// and podcasts:// are Apple's subscribe-launcher +// aliases for the SAME feed document; feed:// is the generic form. +// Anything else keeps its scheme — guardFetch enforces https. +func normalizeFeedURL(raw string) string { + u := strings.TrimSpace(raw) + lowered := strings.ToLower(u) + for _, prefix := range []string{"itpc://", "podcasts://", "feed://"} { + if strings.HasPrefix(lowered, prefix) { + return "https://" + u[len(prefix):] + } + } + return u +} + func classifyFetchError(err error) error { if errors.Is(err, ErrBlockedPrivateAddress) { return fmt.Errorf("blocked: private/loopback addresses are not fetchable") diff --git a/internal/version/version.go b/internal/version/version.go index dfce3be2..a32c4ac5 100644 --- a/internal/version/version.go +++ b/internal/version/version.go @@ -8,3 +8,11 @@ package version // The release workflow passes the git tag (e.g. v1.5) as the APP_VERSION // Docker build arg, which the Dockerfile forwards to this flag. var Version = "dev" + +// Commit is the git commit the binary was built from (short hash), +// injected alongside Version via ldflags (GIT_COMMIT build arg). It +// exists so "the UI I'm looking at" vs "the code in the repo" drift is +// VISIBLE — the 2026-10-29 podcast debugging session burned an hour on +// a stale container that predated the feature. "none" for unstampeded +// local builds. +var Commit = "none"