summaryrefslogtreecommitdiff
path: root/internal/serve/serve.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/serve/serve.go')
-rw-r--r--internal/serve/serve.go91
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
+}