diff options
Diffstat (limited to 'internal/serve/serve.go')
| -rw-r--r-- | internal/serve/serve.go | 91 |
1 files changed, 91 insertions, 0 deletions
diff --git a/internal/serve/serve.go b/internal/serve/serve.go new file mode 100644 index 0000000..0b3b340 --- /dev/null +++ b/internal/serve/serve.go @@ -0,0 +1,91 @@ +// 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=<token> 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 +} + +// 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")) + }) + + 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 +} |
