diff options
| author | Claude <noreply@anthropic.com> | 2026-09-21 07:05:24 +0000 |
|---|---|---|
| committer | Claude <noreply@anthropic.com> | 2026-09-21 07:05:24 +0000 |
| commit | e3ecae12d7a309eb5a6c8e62910257096cb99375 (patch) | |
| tree | 4f13a1e67dff0af95e5ff49126c75e56e49d83c6 /internal/serve/ingest.go | |
| parent | 293a627358553c9f61802caf9d4bc76af9c2e239 (diff) | |
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.go | 162 |
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, ".") +} |
