From e3ecae12d7a309eb5a6c8e62910257096cb99375 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 21 Sep 2026 07:05:24 +0000 Subject: Split download from hosting; carry the token into enclosure URLs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The machine that hosts a feed and the machine that downloads the audio do not have to be the same, and usually shouldn't be: a datacentre IP is treated very differently from a home connection by the larger video sites. Downloading where the person actually is sidesteps that entirely, and means the server needs no credentials for or route to the source. - push/ingest: `podkit push` uploads a local library to a remote `serve --ingest`, asking for a manifest first so only the difference is sent. Uploads land via a temp file and rename, so an interrupted transfer cannot be picked up as a short episode. Ingest is off by default and refuses to start without a token. - get: downloads a direct audio URL or every enclosure in an RSS feed, with no extractor involved. Numbers RSS items in reverse so a mirrored series plays in order rather than backwards. Two bugs found by running it rather than by testing it: - A tokened server produced enclosure URLs with no token. The feed loaded, listed every episode, and all of them 404'd in a real client — the whole point of the tool, broken in the default configuration. Feeds now carry the token; regression test asserts enclosures from a tokened feed actually resolve against a tokened server. - Ingest ran filepath.Base before validating, so "../evil.mp3" was stored as "evil.mp3" — contained, but under a name the client never asked for, leaving push and the server disagreeing about the library. Names are now validated raw and rejected. Also: identify with a real user-agent (libsyn 403s Go's default), write feed.xml via rename so a polling client never reads a truncated feed, and match ingested file permissions to downloaded ones. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B --- internal/feed/feed.go | 40 ++++++++++++++++++++++++++++++++++------ internal/feed/feed_test.go | 6 +++--- 2 files changed, 37 insertions(+), 9 deletions(-) (limited to 'internal/feed') diff --git a/internal/feed/feed.go b/internal/feed/feed.go index 02a016e..642d8f3 100644 --- a/internal/feed/feed.go +++ b/internal/feed/feed.go @@ -28,6 +28,13 @@ type Options struct { // successively later dates so that episode order is unambiguous; see // pubDate handling in Build. Epoch time.Time + // Token, if set, is appended to every enclosure URL. + // + // This is not optional bookkeeping: a podcast client fetches an enclosure as + // a plain GET with nothing carried over from the feed request. If the server + // requires a token and the enclosure URLs lack one, the feed loads, shows + // every episode, and no episode will ever download. + Token string } // Item is one episode. @@ -106,6 +113,22 @@ var audioTypes = map[string]string{ ".wav": "audio/wav", } +// IsAudio reports whether a filename is one podkit treats as an episode. +// +// Exported because the server and the uploader need exactly the same answer as +// the feed generator: a file the server accepts but the generator ignores would +// upload successfully and then never appear in the feed. +func IsAudio(filename string) bool { + _, ok := audioTypes[strings.ToLower(filepath.Ext(filename))] + return ok +} + +// MIMEType returns the enclosure content type for a filename, or "" if it is +// not audio podkit recognises. +func MIMEType(filename string) string { + return audioTypes[strings.ToLower(filepath.Ext(filename))] +} + // ParseName splits "03 - Great Conservation Principles.mp3" into its track // number and title. Files without a numeric prefix keep their whole stem as the // title and report track 0. @@ -126,7 +149,9 @@ func ParseName(filename string) (track int, title string) { // contain spaces and ampersands, and pasting them raw produces both an invalid // URL and invalid XML. Only the final path segment is escaped — escaping the // whole base would mangle its scheme and host. -func EnclosureURL(baseURL, filename string) (string, error) { +// A token, when supplied, is carried on the URL, because that is the only thing +// a podcast client will send back. +func EnclosureURL(baseURL, filename, token string) (string, error) { base, err := url.Parse(baseURL) if err != nil { return "", fmt.Errorf("parsing base URL %q: %w", baseURL, err) @@ -135,6 +160,11 @@ func EnclosureURL(baseURL, filename string) (string, error) { return "", fmt.Errorf("base URL %q needs a scheme and host", baseURL) } base.Path = path.Join(base.Path, filename) + if token != "" { + q := base.Query() + q.Set("k", token) + base.RawQuery = q.Encode() + } // url.URL.String() escapes the path for us; RawPath stays unset so the // escaping is derived from Path. return base.String(), nil @@ -167,11 +197,9 @@ func Build(opts Options, readTags TagReader) (*RSS, error) { if e.IsDir() { continue } - mime, isAudio := audioTypes[strings.ToLower(filepath.Ext(e.Name()))] - if !isAudio { + if !IsAudio(e.Name()) { continue } - _ = mime info, err := e.Info() if err != nil { // A file that vanished mid-scan is not a reason to abandon the @@ -209,7 +237,7 @@ func Build(opts Options, readTags TagReader) (*RSS, error) { items := make([]Item, 0, len(found)) for i, c := range found { - encURL, err := EnclosureURL(opts.BaseURL, c.name) + encURL, err := EnclosureURL(opts.BaseURL, c.name, opts.Token) if err != nil { return nil, err } @@ -229,7 +257,7 @@ func Build(opts Options, readTags TagReader) (*RSS, error) { PubDate: pub.Format(time.RFC1123Z), Enclosure: Enclosure{ URL: encURL, - Type: audioTypes[strings.ToLower(filepath.Ext(c.name))], + Type: MIMEType(c.name), Length: c.size, }, Episode: episode, diff --git a/internal/feed/feed_test.go b/internal/feed/feed_test.go index a529e6f..588c652 100644 --- a/internal/feed/feed_test.go +++ b/internal/feed/feed_test.go @@ -66,7 +66,7 @@ func TestParseName(t *testing.T) { // enclosure URL unescaped produces both an invalid URL and invalid XML — the // single most likely way a hand-rolled feed fails to load at all. func TestEnclosureURL_EscapesFilenames(t *testing.T) { - got, err := EnclosureURL("https://example.com/audio/", "10 - Probability & Uncertainty.mp3") + got, err := EnclosureURL("https://example.com/audio/", "10 - Probability & Uncertainty.mp3", "") if err != nil { t.Fatalf("EnclosureURL: %v", err) } @@ -85,7 +85,7 @@ func TestEnclosureURL_EscapesFilenames(t *testing.T) { } func TestEnclosureURL_HandlesMissingTrailingSlash(t *testing.T) { - got, err := EnclosureURL("https://example.com/audio", "01 - One.mp3") + got, err := EnclosureURL("https://example.com/audio", "01 - One.mp3", "") if err != nil { t.Fatalf("EnclosureURL: %v", err) } @@ -96,7 +96,7 @@ func TestEnclosureURL_HandlesMissingTrailingSlash(t *testing.T) { func TestEnclosureURL_RejectsBadBase(t *testing.T) { for _, bad := range []string{"", "not-a-url", "/just/a/path"} { - if _, err := EnclosureURL(bad, "a.mp3"); err == nil { + if _, err := EnclosureURL(bad, "a.mp3", ""); err == nil { t.Errorf("EnclosureURL(%q) should have failed", bad) } } -- cgit v1.2.3