summaryrefslogtreecommitdiff
path: root/player-server/internal/api/handlers.go
blob: 7f05435038563e3a4f12a93077cdc749d0955211 (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
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
package api

import (
	"bytes"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"log/slog"
	"net/http"
	"strconv"
	"time"

	"codeberg.org/snonux/player/internal/model"
	"codeberg.org/snonux/player/internal/service"
)

// fileETag returns a strong ETag value (without surrounding quotes) for a
// file of the given size and modification time. Combining size with mtime
// nanoseconds is enough to detect any in-place rewrite or replacement —
// callers wrap the result in quotes when emitting the header.
func fileETag(size int64, modTime time.Time) string {
	return fmt.Sprintf("%d-%d", size, modTime.UnixNano())
}

// ------------------------------------------------------------------
// Helpers
// ------------------------------------------------------------------

// writeJSON serialises data to JSON and writes it to w with the given status.
//
// Marshalling happens into an in-memory buffer BEFORE any status or body is
// written to the response. This avoids the previous footgun where the headers
// (and a 200/whatever status) were committed first and then encoding failed
// halfway through, producing a response with a misleading success status and a
// truncated/invalid body. If encoding fails we instead emit a 500 with a small
// JSON error payload so callers always see a consistent error shape.
func writeJSON(w http.ResponseWriter, status int, data interface{}) {
	var buf bytes.Buffer
	if err := json.NewEncoder(&buf).Encode(data); err != nil {
		slog.Error("encode json", "err", err)
		w.Header().Set("Content-Type", "application/json")
		w.WriteHeader(http.StatusInternalServerError)
		_, _ = w.Write([]byte(`{"error":"internal encoding error"}`))
		return
	}
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	_, _ = w.Write(buf.Bytes())
}

func writeError(w http.ResponseWriter, status int, message string) {
	writeJSON(w, status, map[string]string{"error": message})
}

func badRequest(w http.ResponseWriter, message string) {
	writeError(w, http.StatusBadRequest, message)
}

func notFound(w http.ResponseWriter) {
	writeError(w, http.StatusNotFound, "not found")
}

func forbidden(w http.ResponseWriter, message string) {
	writeError(w, http.StatusForbidden, message)
}

// HTTPStatuser is implemented by service errors that know their own HTTP
// status. Sentinels in internal/service implement this so handleError can
// dispatch without an ever-growing switch (OCP): adding a new sentinel only
// requires defining its status alongside the sentinel itself, with no edit
// required here.
type HTTPStatuser interface {
	HTTPStatus() int
}

// handleError dispatches service errors to an HTTP response. If any error in
// the chain implements HTTPStatuser, that status is used together with the
// wrapped error's message (so callers that add context via fmt.Errorf("%w: …")
// keep that context in the body). Unrecognised errors fall back to 500.
func handleError(w http.ResponseWriter, err error) {
	var statuser HTTPStatuser
	if errors.As(err, &statuser) {
		writeError(w, statuser.HTTPStatus(), err.Error())
		return
	}
	writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
}

func readJSON(r *http.Request, dst interface{}) error {
	if r.Body == nil {
		return errors.New("missing body")
	}
	defer r.Body.Close()
	return json.NewDecoder(r.Body).Decode(dst)
}

// pathID parses a path variable as an int64. It returns the parsed id together
// with an explicit error so callers can distinguish "missing/malformed" from a
// legitimately zero value and log the underlying ParseInt failure. The error
// is wrapped with the variable name to make server logs actionable.
func pathID(r *http.Request, name string) (int64, error) {
	id, err := strconv.ParseInt(r.PathValue(name), 10, 64)
	if err != nil {
		return 0, fmt.Errorf("invalid %s: %w", name, err)
	}
	return id, nil
}

func userIDFromContext(r *http.Request) int64 {
	sess, _ := r.Context().Value(sessionCtxKey).(*model.Session)
	if sess == nil {
		return 0
	}
	return sess.UserID
}

func sessionIDFromContext(r *http.Request) string {
	sess, _ := r.Context().Value(sessionCtxKey).(*model.Session)
	if sess == nil {
		return ""
	}
	return sess.ID
}

func stringPtr(s string) *string { return &s }

func floatPtr(f float64) *float64 { return &f }

func intPtr(i int64) *int64 { return &i }

func requireService(w http.ResponseWriter, svc any) bool {
	if svc == nil {
		writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "not implemented"})
		return false
	}
	return true
}

// ------------------------------------------------------------------
// Static pages
// ------------------------------------------------------------------

func (s *Server) serveFile(w http.ResponseWriter, r *http.Request, filename string) {
	f, err := s.staticFS.Open(filename)
	if err != nil {
		http.Error(w, "not found", http.StatusNotFound)
		return
	}
	defer f.Close()
	stat, err := f.Stat()
	if err != nil {
		http.Error(w, "not found", http.StatusNotFound)
		return
	}
	rs, ok := f.(io.ReadSeeker)
	if !ok {
		http.Error(w, "internal error", http.StatusInternalServerError)
		return
	}
	http.ServeContent(w, r, filename, stat.ModTime(), rs)
}

func (s *Server) serveIndex(w http.ResponseWriter, r *http.Request) {
	s.serveFile(w, r, "index.html")
}

func (s *Server) serveLogin(w http.ResponseWriter, r *http.Request) {
	s.serveFile(w, r, "login.html")
}

// serveBootstrap serves bootstrap.html only when no users exist yet.
// Once the first admin account has been created the bootstrap page must no
// longer be reachable — redirecting to /login.html prevents an attacker
// from reaching the form on an already-configured instance.
func (s *Server) serveBootstrap(w http.ResponseWriter, r *http.Request) {
	if s.authSvc != nil {
		count, err := s.authSvc.CountUsers(r.Context())
		if err != nil {
			http.Error(w, "internal server error", http.StatusInternalServerError)
			return
		}
		if count > 0 {
			// Bootstrap is complete; send browsers to the login page.
			http.Redirect(w, r, "/login.html", http.StatusTemporaryRedirect)
			return
		}
	}
	s.serveFile(w, r, "bootstrap.html")
}

func (s *Server) serveDetach(w http.ResponseWriter, r *http.Request) {
	s.serveFile(w, r, "detach.html")
}

// ------------------------------------------------------------------
// File serving helpers
// ------------------------------------------------------------------

func (s *Server) serveFileResult(w http.ResponseWriter, r *http.Request, res *service.FileResult, attachment bool) {
	// s.streamer is required at construction time (see NewServerWithLogger),
	// so it is guaranteed non-nil here. We previously fell back to a default
	// streamer when nil, which silently hid wiring mistakes and violated the
	// Dependency Inversion Principle by letting the handler decide its own
	// dependency.
	streamer := s.streamer

	stream, err := streamer.Open(r.Context(), res, attachment)
	if err != nil {
		s.logger.Warn("api stream open failed", "file", res.FileName, "err", err)
		http.Error(w, "not found", http.StatusNotFound)
		return
	}
	defer stream.File.Close()

	if stream.Remuxed {
		s.serveRemuxed(w, r, streamer, stream)
		return
	}

	if attachment {
		disp := fmt.Sprintf("attachment; filename=%q", res.FileName)
		w.Header().Set("Content-Disposition", disp)
	}
	w.Header().Set("Content-Type", stream.ContentType)
	w.Header().Set("Accept-Ranges", "bytes")
	// Strong ETag derived from size and mtime nanoseconds. http.ServeContent
	// reads If-None-Match / If-Match from the request once ETag is set, so
	// clients (iOS audio player, podcast clients) can revalidate cached
	// downloads without re-fetching the full body.
	w.Header().Set("ETag", fmt.Sprintf("%q", fileETag(stream.Size, stream.ModTime)))
	s.logger.Info("api stream file", "file", stream.FileName, "size", stream.Size, "range", r.Header.Get("Range"))
	http.ServeContent(w, r, stream.FileName, stream.ModTime, stream.File)
}

func (s *Server) serveRemuxed(w http.ResponseWriter, r *http.Request, streamer service.MediaStreamer, stream *service.StreamResult) {
	w.Header().Set("Content-Type", stream.ContentType)
	w.Header().Set("Cache-Control", "no-store")
	if stream.Duration > 0 {
		w.Header().Set("X-Duration", fmt.Sprintf("%f", stream.Duration))
	}
	s.logger.Info("api remux stream file", "file", stream.FileName, "size", stream.Size, "range", r.Header.Get("Range"))
	if err := streamer.Remux(r.Context(), stream, w); err != nil {
		s.logger.Error("remux media", "file", stream.FileName, "err", err)
	}
}