summaryrefslogtreecommitdiff
path: root/internal/serve/ingest.go
diff options
context:
space:
mode:
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, ".")
+}