// Package serve hosts the audio directory and its feed over HTTP. package serve import ( "crypto/rand" "crypto/subtle" "encoding/hex" "fmt" "net/http" "path" "strings" ) // Options configure the server. type Options struct { Dir string Addr string // Token, if set, must appear in every request as ?k= or an // Authorization: Bearer header. // // "Private" here means unlisted-and-unguessable, not authenticated in any // serious sense: podcast clients do not do interactive login, so a shared // secret in the URL is the mechanism that actually works with them. Anyone // holding the URL holds the access. Token string // AllowIngest enables /manifest and /ingest, letting `podkit push` upload // episodes from elsewhere. Off by default: a feed host that also accepts // writes is a different security proposition from one that only reads. AllowIngest bool // Rebuild regenerates feed.xml after an upload. Injected rather than built // here so the server does not need to know the feed's metadata, and so the // ingest path is testable without a real feed. Rebuild func() error } // NewToken returns a random token suitable for Options.Token. func NewToken() (string, error) { b := make([]byte, 16) if _, err := rand.Read(b); err != nil { return "", fmt.Errorf("generating token: %w", err) } return hex.EncodeToString(b), nil } // Handler builds the HTTP handler: the feed at /feed.xml, audio under /audio/. func Handler(opts Options) http.Handler { mux := http.NewServeMux() files := http.FileServer(http.Dir(opts.Dir)) mux.Handle("/audio/", http.StripPrefix("/audio/", files)) mux.HandleFunc("/feed.xml", func(w http.ResponseWriter, r *http.Request) { http.ServeFile(w, r, path.Join(opts.Dir, "feed.xml")) }) if opts.AllowIngest { mux.HandleFunc("/manifest", handleManifest(opts.Dir)) mux.HandleFunc("/ingest", handleIngest(opts.Dir, opts.Rebuild)) } mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/" { http.NotFound(w, r) return } w.Header().Set("Content-Type", "text/plain; charset=utf-8") fmt.Fprintln(w, "podkit — subscribe your podcast app to /feed.xml") }) return withToken(opts.Token, mux) } // withToken gates every request on a shared secret. func withToken(token string, next http.Handler) http.Handler { if token == "" { return next } return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if !tokenOK(r, token) { // 404 rather than 401: a 401 confirms something is here worth // guessing at, and no podcast client is going to act on the // challenge anyway. http.NotFound(w, r) return } next.ServeHTTP(w, r) }) } func tokenOK(r *http.Request, want string) bool { if got := r.URL.Query().Get("k"); got != "" && constantTimeEqual(got, want) { return true } auth := r.Header.Get("Authorization") if strings.HasPrefix(auth, "Bearer ") { return constantTimeEqual(strings.TrimPrefix(auth, "Bearer "), want) } return false } // constantTimeEqual avoids leaking the token through response timing. func constantTimeEqual(a, b string) bool { return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1 }