summaryrefslogtreecommitdiff
path: root/internal/serve/ingest.go
blob: a949e1ec29e9bc89a8cedbaf01235b1fc2ba6d7b (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
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
package serve

import (
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"os"
	"path/filepath"
	"strings"

	"podkit/internal/feed"
)

// Manifest is what the server already holds, used by `podkit push` to decide
// what still needs uploading. Keyed by filename.
type Manifest struct {
	Files map[string]int64 `json:"files"`
}

// maxUpload caps a single uploaded file. Generous enough for a long lecture at
// a high bitrate; present so a runaway or hostile client cannot fill the disk
// in one request.
const maxUpload = 500 << 20 // 500 MiB

// readManifest lists the audio already present in dir.
func readManifest(dir string) (Manifest, error) {
	m := Manifest{Files: map[string]int64{}}
	entries, err := os.ReadDir(dir)
	if err != nil {
		return m, err
	}
	for _, e := range entries {
		if e.IsDir() || !feed.IsAudio(e.Name()) {
			continue
		}
		info, err := e.Info()
		if err != nil {
			// A file that vanished mid-scan just isn't in the manifest; push
			// will then offer it, which is the safe direction to be wrong in.
			continue
		}
		m.Files[e.Name()] = info.Size()
	}
	return m, nil
}

func handleManifest(dir string) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		m, err := readManifest(dir)
		if err != nil {
			http.Error(w, "cannot read library", http.StatusInternalServerError)
			return
		}
		w.Header().Set("Content-Type", "application/json")
		_ = json.NewEncoder(w).Encode(m)
	}
}

// handleIngest accepts an uploaded audio file and rebuilds the feed.
//
// This is the half of podkit that runs on the server. The download half runs
// wherever the person doing it actually is — which is the point: the machine
// hosting the feed never needs to talk to the source at all.
func handleIngest(dir string, rebuild func() error) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		if r.Method != http.MethodPost {
			w.Header().Set("Allow", http.MethodPost)
			http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
			return
		}

		// Validated raw, not via filepath.Base: Base would quietly turn
		// "../evil.mp3" into "evil.mp3" and store it under a name the client
		// never asked for. That is safe from traversal but leaves push and the
		// server disagreeing about what the library contains, which breaks the
		// manifest comparison on the next run. A name with a separator in it is
		// a bug or an attack either way, so reject it.
		name := r.URL.Query().Get("name")
		if !validUploadName(name) {
			http.Error(w, "invalid or missing name parameter", http.StatusBadRequest)
			return
		}
		if !feed.IsAudio(name) {
			http.Error(w, "not an audio file", http.StatusUnsupportedMediaType)
			return
		}

		if err := os.MkdirAll(dir, 0o755); err != nil {
			http.Error(w, "cannot write to library", http.StatusInternalServerError)
			return
		}

		// Written to a temporary name and renamed into place, so an upload cut
		// off halfway can never be picked up as a half-length episode.
		tmp, err := os.CreateTemp(dir, ".upload-*")
		if err != nil {
			http.Error(w, "cannot write to library", http.StatusInternalServerError)
			return
		}
		tmpName := tmp.Name()
		defer os.Remove(tmpName) // no-op once the rename below succeeds

		written, err := io.Copy(tmp, http.MaxBytesReader(w, r.Body, maxUpload))
		closeErr := tmp.Close()
		if err != nil {
			http.Error(w, "upload failed or too large", http.StatusBadRequest)
			return
		}
		if closeErr != nil {
			http.Error(w, "cannot write to library", http.StatusInternalServerError)
			return
		}
		if written == 0 {
			http.Error(w, "empty upload", http.StatusBadRequest)
			return
		}

		// CreateTemp makes the file 0600; match the 0644 that downloaded files
		// get, so a library's permissions don't depend on how each episode
		// happened to arrive.
		if err := os.Chmod(tmpName, 0o644); err != nil {
			http.Error(w, "cannot write to library", http.StatusInternalServerError)
			return
		}

		if err := os.Rename(tmpName, filepath.Join(dir, name)); err != nil {
			http.Error(w, "cannot write to library", http.StatusInternalServerError)
			return
		}

		// A new file that isn't in the feed is invisible to the podcast app, so
		// a rebuild failure has to be reported rather than logged and forgotten.
		if rebuild != nil {
			if err := rebuild(); err != nil {
				http.Error(w, fmt.Sprintf("stored %s but the feed rebuild failed: %v", name, err),
					http.StatusInternalServerError)
				return
			}
		}

		w.Header().Set("Content-Type", "application/json")
		_ = json.NewEncoder(w).Encode(map[string]any{
			"stored": name,
			"bytes":  written,
		})
	}
}

// validUploadName rejects names that would escape the directory or collide with
// podkit's own files.
func validUploadName(name string) bool {
	switch name {
	case "", ".", "..", "/", "feed.xml":
		return false
	}
	if strings.ContainsAny(name, `/\`) {
		return false
	}
	// Dotfiles are podkit's own bookkeeping (.podkit-archive, .upload-*).
	return !strings.HasPrefix(name, ".")
}