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, ".") }