diff options
| author | Peter Stone <thepeterstone@gmail.com> | 2026-09-21 06:50:42 +0000 |
|---|---|---|
| committer | Peter Stone <thepeterstone@gmail.com> | 2026-09-21 06:50:42 +0000 |
| commit | 293a627358553c9f61802caf9d4bc76af9c2e239 (patch) | |
| tree | e4769ac949659953bd5e09cebea9d0697e82548f /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.go | 91 | ||||
| -rw-r--r-- | internal/serve/serve_test.go | 145 |
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) + } +} |
