summaryrefslogtreecommitdiff
path: root/internal/serve/serve.go
blob: 396babe109f79333b27ebb7c58810d70d53c6e4e (plain)
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
}