summaryrefslogtreecommitdiff
path: root/internal/serve/ingest.go
diff options
context:
space:
mode:
authorClaude <noreply@anthropic.com>2026-09-21 07:05:24 +0000
committerClaude <noreply@anthropic.com>2026-09-21 07:05:24 +0000
commite3ecae12d7a309eb5a6c8e62910257096cb99375 (patch)
tree4f13a1e67dff0af95e5ff49126c75e56e49d83c6 /internal/serve/ingest.go
parent293a627358553c9f61802caf9d4bc76af9c2e239 (diff)
Split download from hosting; carry the token into enclosure URLsHEADmain
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
Diffstat (limited to 'internal/serve/ingest.go')
-rw-r--r--internal/serve/ingest.go162
1 files changed, 162 insertions, 0 deletions
diff --git a/internal/serve/ingest.go b/internal/serve/ingest.go
new file mode 100644
index 0000000..a949e1e
--- /dev/null
+++ b/internal/serve/ingest.go
@@ -0,0 +1,162 @@
+package serve
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "podkit/internal/feed"
+)
+
+// Manifest is what the server already holds, used by `podkit push` to decide
+// what still needs uploading. Keyed by filename.
+type Manifest struct {
+ Files map[string]int64 `json:"files"`
+}
+
+// maxUpload caps a single uploaded file. Generous enough for a long lecture at
+// a high bitrate; present so a runaway or hostile client cannot fill the disk
+// in one request.
+const maxUpload = 500 << 20 // 500 MiB
+
+// readManifest lists the audio already present in dir.
+func readManifest(dir string) (Manifest, error) {
+ m := Manifest{Files: map[string]int64{}}
+ entries, err := os.ReadDir(dir)
+ if err != nil {
+ return m, err
+ }
+ for _, e := range entries {
+ if e.IsDir() || !feed.IsAudio(e.Name()) {
+ continue
+ }
+ info, err := e.Info()
+ if err != nil {
+ // A file that vanished mid-scan just isn't in the manifest; push
+ // will then offer it, which is the safe direction to be wrong in.
+ continue
+ }
+ m.Files[e.Name()] = info.Size()
+ }
+ return m, nil
+}
+
+func handleManifest(dir string) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ m, err := readManifest(dir)
+ if err != nil {
+ http.Error(w, "cannot read library", http.StatusInternalServerError)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(m)
+ }
+}
+
+// handleIngest accepts an uploaded audio file and rebuilds the feed.
+//
+// This is the half of podkit that runs on the server. The download half runs
+// wherever the person doing it actually is — which is the point: the machine
+// hosting the feed never needs to talk to the source at all.
+func handleIngest(dir string, rebuild func() error) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ w.Header().Set("Allow", http.MethodPost)
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+
+ // Validated raw, not via filepath.Base: Base would quietly turn
+ // "../evil.mp3" into "evil.mp3" and store it under a name the client
+ // never asked for. That is safe from traversal but leaves push and the
+ // server disagreeing about what the library contains, which breaks the
+ // manifest comparison on the next run. A name with a separator in it is
+ // a bug or an attack either way, so reject it.
+ name := r.URL.Query().Get("name")
+ if !validUploadName(name) {
+ http.Error(w, "invalid or missing name parameter", http.StatusBadRequest)
+ return
+ }
+ if !feed.IsAudio(name) {
+ http.Error(w, "not an audio file", http.StatusUnsupportedMediaType)
+ return
+ }
+
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ http.Error(w, "cannot write to library", http.StatusInternalServerError)
+ return
+ }
+
+ // Written to a temporary name and renamed into place, so an upload cut
+ // off halfway can never be picked up as a half-length episode.
+ tmp, err := os.CreateTemp(dir, ".upload-*")
+ if err != nil {
+ http.Error(w, "cannot write to library", http.StatusInternalServerError)
+ return
+ }
+ tmpName := tmp.Name()
+ defer os.Remove(tmpName) // no-op once the rename below succeeds
+
+ written, err := io.Copy(tmp, http.MaxBytesReader(w, r.Body, maxUpload))
+ closeErr := tmp.Close()
+ if err != nil {
+ http.Error(w, "upload failed or too large", http.StatusBadRequest)
+ return
+ }
+ if closeErr != nil {
+ http.Error(w, "cannot write to library", http.StatusInternalServerError)
+ return
+ }
+ if written == 0 {
+ http.Error(w, "empty upload", http.StatusBadRequest)
+ return
+ }
+
+ // CreateTemp makes the file 0600; match the 0644 that downloaded files
+ // get, so a library's permissions don't depend on how each episode
+ // happened to arrive.
+ if err := os.Chmod(tmpName, 0o644); err != nil {
+ http.Error(w, "cannot write to library", http.StatusInternalServerError)
+ return
+ }
+
+ if err := os.Rename(tmpName, filepath.Join(dir, name)); err != nil {
+ http.Error(w, "cannot write to library", http.StatusInternalServerError)
+ return
+ }
+
+ // A new file that isn't in the feed is invisible to the podcast app, so
+ // a rebuild failure has to be reported rather than logged and forgotten.
+ if rebuild != nil {
+ if err := rebuild(); err != nil {
+ http.Error(w, fmt.Sprintf("stored %s but the feed rebuild failed: %v", name, err),
+ http.StatusInternalServerError)
+ return
+ }
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "stored": name,
+ "bytes": written,
+ })
+ }
+}
+
+// validUploadName rejects names that would escape the directory or collide with
+// podkit's own files.
+func validUploadName(name string) bool {
+ switch name {
+ case "", ".", "..", "/", "feed.xml":
+ return false
+ }
+ if strings.ContainsAny(name, `/\`) {
+ return false
+ }
+ // Dotfiles are podkit's own bookkeeping (.podkit-archive, .upload-*).
+ return !strings.HasPrefix(name, ".")
+}