summaryrefslogtreecommitdiff
path: root/internal/serve
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-09-21 06:50:42 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-09-21 06:50:42 +0000
commit293a627358553c9f61802caf9d4bc76af9c2e239 (patch)
treee4769ac949659953bd5e09cebea9d0697e82548f /internal/serve
podkit: playlist URL to private podcast feed
One Go binary with fetch / tag / feed / serve / all. The stages run separately on purpose: the download is the slow part and should not be repeated while adjusting titles or the base URL. Built from a shell + Python + Go sketch. The pieces that needed fixing on the way, all of which produce a feed that loads wrong or not at all: - Enclosure URLs were unescaped. Track titles routinely contain spaces and ampersands, which make both the URL and the XML invalid — the likeliest way for a feed to fail to load entirely. Only the final path segment is escaped; escaping the base would mangle scheme and host. - Ordering came from filename sort, which is correct only while zero-padding is applied consistently. One unpadded file puts lecture 10 after lecture 1. Order now comes from the parsed track number. - No pubDate and no itunes:type. Podcast clients default to newest-first, which starts a lecture course at the final lecture. Items now carry distinct ascending dates and the feed is marked serial. - Missing channel description and link, which several clients reject outright. - A struct field with no xml tag emitted a stray <EnclosureURL> element. - guid had no isPermaLink="false", so a reader may treat it as a fetchable URL. - Errors from ReadDir, Info and Marshal were discarded, so a missing directory produced an empty feed that looked like a working setup with nothing in it. - os.Args indexed without a length check. Beyond the sketch: a download archive so an interrupted series resumes, an up-front check for yt-dlp and ffmpeg before the long download rather than a post-processing failure after the bytes are on disk, non-audio files excluded by extension so cover art and .part files cannot become episodes, and a serve command with token gating that 404s rather than 401s. Found by the first live run against a LibriVox collection: sources whose own titles begin with a track number came out as "01 - 01 - April.mp3", because the output template prepends the playlist index regardless. Collapsed, but only when the two numbers agree — "01 - 1964 - Lecture" is a year and "05 - 12 Angry Men" is a title, and neither is a repeat. 47 tests. Verified end to end against public-domain audio: downloaded, converted, tagged, served, and the delivered file is a playable 128kbps MP3 with correct ID3 title/artist/album/track frames.
Diffstat (limited to 'internal/serve')
-rw-r--r--internal/serve/serve.go91
-rw-r--r--internal/serve/serve_test.go145
2 files changed, 236 insertions, 0 deletions
diff --git a/internal/serve/serve.go b/internal/serve/serve.go
new file mode 100644
index 0000000..0b3b340
--- /dev/null
+++ b/internal/serve/serve.go
@@ -0,0 +1,91 @@
+// Package serve hosts the audio directory and its feed over HTTP.
+package serve
+
+import (
+ "crypto/rand"
+ "crypto/subtle"
+ "encoding/hex"
+ "fmt"
+ "net/http"
+ "path"
+ "strings"
+)
+
+// Options configure the server.
+type Options struct {
+ Dir string
+ Addr string
+ // Token, if set, must appear in every request as ?k=<token> or an
+ // Authorization: Bearer header.
+ //
+ // "Private" here means unlisted-and-unguessable, not authenticated in any
+ // serious sense: podcast clients do not do interactive login, so a shared
+ // secret in the URL is the mechanism that actually works with them. Anyone
+ // holding the URL holds the access.
+ Token string
+}
+
+// NewToken returns a random token suitable for Options.Token.
+func NewToken() (string, error) {
+ b := make([]byte, 16)
+ if _, err := rand.Read(b); err != nil {
+ return "", fmt.Errorf("generating token: %w", err)
+ }
+ return hex.EncodeToString(b), nil
+}
+
+// Handler builds the HTTP handler: the feed at /feed.xml, audio under /audio/.
+func Handler(opts Options) http.Handler {
+ mux := http.NewServeMux()
+
+ files := http.FileServer(http.Dir(opts.Dir))
+ mux.Handle("/audio/", http.StripPrefix("/audio/", files))
+
+ mux.HandleFunc("/feed.xml", func(w http.ResponseWriter, r *http.Request) {
+ http.ServeFile(w, r, path.Join(opts.Dir, "feed.xml"))
+ })
+
+ mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/" {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "text/plain; charset=utf-8")
+ fmt.Fprintln(w, "podkit — subscribe your podcast app to /feed.xml")
+ })
+
+ return withToken(opts.Token, mux)
+}
+
+// withToken gates every request on a shared secret.
+func withToken(token string, next http.Handler) http.Handler {
+ if token == "" {
+ return next
+ }
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if !tokenOK(r, token) {
+ // 404 rather than 401: a 401 confirms something is here worth
+ // guessing at, and no podcast client is going to act on the
+ // challenge anyway.
+ http.NotFound(w, r)
+ return
+ }
+ next.ServeHTTP(w, r)
+ })
+}
+
+func tokenOK(r *http.Request, want string) bool {
+ if got := r.URL.Query().Get("k"); got != "" && constantTimeEqual(got, want) {
+ return true
+ }
+ auth := r.Header.Get("Authorization")
+ if strings.HasPrefix(auth, "Bearer ") {
+ return constantTimeEqual(strings.TrimPrefix(auth, "Bearer "), want)
+ }
+ return false
+}
+
+// constantTimeEqual avoids leaking the token through response timing.
+func constantTimeEqual(a, b string) bool {
+ return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
+}
diff --git a/internal/serve/serve_test.go b/internal/serve/serve_test.go
new file mode 100644
index 0000000..04b25ee
--- /dev/null
+++ b/internal/serve/serve_test.go
@@ -0,0 +1,145 @@
+package serve
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func setup(t *testing.T, token string) (http.Handler, string) {
+ t.Helper()
+ dir := t.TempDir()
+ if err := os.WriteFile(filepath.Join(dir, "feed.xml"), []byte("<rss/>"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(dir, "01 - One.mp3"), []byte("audio"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ return Handler(Options{Dir: dir, Token: token}), dir
+}
+
+func get(h http.Handler, target string, bearer string) *httptest.ResponseRecorder {
+ req := httptest.NewRequest(http.MethodGet, target, nil)
+ if bearer != "" {
+ req.Header.Set("Authorization", "Bearer "+bearer)
+ }
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+ return rec
+}
+
+func TestServesFeedAndAudioWithoutToken(t *testing.T) {
+ h, _ := setup(t, "")
+ if rec := get(h, "/feed.xml", ""); rec.Code != http.StatusOK {
+ t.Errorf("/feed.xml = %d, want 200", rec.Code)
+ }
+ if rec := get(h, "/audio/01%20-%20One.mp3", ""); rec.Code != http.StatusOK {
+ t.Errorf("audio = %d, want 200", rec.Code)
+ }
+}
+
+// The escaped URL the feed generator emits must actually resolve against the
+// server that serves those files — the two halves have to agree.
+func TestEscapedFilenameResolves(t *testing.T) {
+ h, dir := setup(t, "")
+ if err := os.WriteFile(filepath.Join(dir, "10 - Probability & Uncertainty.mp3"), []byte("x"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ rec := get(h, "/audio/10%20-%20Probability%20&%20Uncertainty.mp3", "")
+ if rec.Code != http.StatusOK {
+ t.Errorf("escaped filename = %d, want 200", rec.Code)
+ }
+}
+
+func TestTokenRequiredWhenSet(t *testing.T) {
+ h, _ := setup(t, "s3cret")
+ for _, target := range []string{"/feed.xml", "/audio/01%20-%20One.mp3", "/"} {
+ if rec := get(h, target, ""); rec.Code != http.StatusNotFound {
+ t.Errorf("%s without a token = %d, want 404", target, rec.Code)
+ }
+ }
+}
+
+func TestTokenAcceptedInQuery(t *testing.T) {
+ h, _ := setup(t, "s3cret")
+ if rec := get(h, "/feed.xml?k=s3cret", ""); rec.Code != http.StatusOK {
+ t.Errorf("query token = %d, want 200", rec.Code)
+ }
+}
+
+// Podcast clients cannot do interactive auth, so the query form is the one
+// that matters; the bearer header is for curl and for testing.
+func TestTokenAcceptedAsBearer(t *testing.T) {
+ h, _ := setup(t, "s3cret")
+ if rec := get(h, "/feed.xml", "s3cret"); rec.Code != http.StatusOK {
+ t.Errorf("bearer token = %d, want 200", rec.Code)
+ }
+}
+
+func TestWrongTokenRejected(t *testing.T) {
+ h, _ := setup(t, "s3cret")
+ if rec := get(h, "/feed.xml?k=wrong", ""); rec.Code != http.StatusNotFound {
+ t.Errorf("wrong token = %d, want 404", rec.Code)
+ }
+ if rec := get(h, "/feed.xml", "wrong"); rec.Code != http.StatusNotFound {
+ t.Errorf("wrong bearer = %d, want 404", rec.Code)
+ }
+}
+
+// A gated server must not confirm that anything is there.
+func TestRejectionLooksLikeAbsence(t *testing.T) {
+ h, _ := setup(t, "s3cret")
+ rec := get(h, "/feed.xml", "")
+ if rec.Code == http.StatusUnauthorized || rec.Code == http.StatusForbidden {
+ t.Errorf("status %d advertises that something exists here", rec.Code)
+ }
+ if strings.Contains(strings.ToLower(rec.Body.String()), "token") {
+ t.Error("the rejection body mentions a token, which tells a prober what to look for")
+ }
+}
+
+// http.FileServer resolves .. before dispatch, but this is the kind of thing
+// worth a standing assertion rather than a memory of having checked once.
+func TestNoPathTraversal(t *testing.T) {
+ h, dir := setup(t, "")
+ secret := filepath.Join(filepath.Dir(dir), "secret.txt")
+ if err := os.WriteFile(secret, []byte("do not serve"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ defer func() { _ = os.Remove(secret) }()
+
+ for _, target := range []string{
+ "/audio/../secret.txt",
+ "/audio/..%2fsecret.txt",
+ "/audio/%2e%2e/secret.txt",
+ } {
+ rec := get(h, target, "")
+ if strings.Contains(rec.Body.String(), "do not serve") {
+ t.Errorf("%s escaped the served directory", target)
+ }
+ }
+}
+
+func TestNewTokenIsRandomAndLongEnough(t *testing.T) {
+ a, err := NewToken()
+ if err != nil {
+ t.Fatalf("NewToken: %v", err)
+ }
+ b, _ := NewToken()
+ if a == b {
+ t.Error("two generated tokens were identical")
+ }
+ if len(a) < 32 {
+ t.Errorf("token is only %d chars; it is the only thing protecting the feed", len(a))
+ }
+}
+
+func TestUnknownPathIs404(t *testing.T) {
+ h, _ := setup(t, "")
+ if rec := get(h, "/nope", ""); rec.Code != http.StatusNotFound {
+ t.Errorf("unknown path = %d, want 404", rec.Code)
+ }
+}