1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
|
// 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
// 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
}
|