LabCodeHub öffentliche Ansicht
Anmelden
Küpper / ehc-tvwall öffentlich
Branch: main
ehc-tvwall / internal / web / web.go
Verlauf Rohdaten
R Rüdiger Küpper feat: add silent boot script to enhance boot experience
461e563c vor 11 Tagen
internal/web/web.go 500 Zeilen · 13.2 KB · Go
1
// Package web stellt das Upload- und Sortier-Interface bereit.
2
package web
3
4
import (
5
	"crypto/sha1"
6
	"crypto/subtle"
7
	"encoding/hex"
8
	"encoding/json"
9
	"errors"
10
	"fmt"
11
	"io"
12
	"log"
13
	"mime/multipart"
14
	"net/http"
15
	"os"
16
	"path/filepath"
17
	"strings"
18
	"sync"
19
	"time"
20
21
	"ehc/tvwall/internal/config"
22
	"ehc/tvwall/internal/player"
23
	"ehc/tvwall/internal/playlist"
24
)
25
26
// Server buendelt alles, was die Handler brauchen.
27
type Server struct {
28
	cfg      *config.Config
29
	pl       *playlist.Manager
30
	player   *player.Player
31
	settings *config.Store
32
33
	thumbMu sync.Mutex
34
	hasFF   bool
35
}
36
37
// New erzeugt den Webserver.
38
func New(cfg *config.Config, pl *playlist.Manager, pp *player.Player, settings *config.Store) *Server {
39
	s := &Server{cfg: cfg, pl: pl, player: pp, settings: settings}
40
	if _, err := lookPath("ffmpeg"); err == nil {
41
		s.hasFF = true
42
	} else {
43
		log.Printf("web: ffmpeg nicht gefunden - Vorschaubilder fuer Videos sind deaktiviert")
44
	}
45
	return s
46
}
47
48
// Handler liefert den fertig verdrahteten HTTP-Handler.
49
func (s *Server) Handler() http.Handler {
50
	mux := http.NewServeMux()
51
52
	mux.HandleFunc("/", s.handleIndex)
53
	mux.Handle("/static/", http.StripPrefix("/static/", staticHandler()))
54
	mux.HandleFunc("/api/state", s.handleState)
55
	mux.HandleFunc("/api/upload", s.handleUpload)
56
	mux.HandleFunc("/api/order", s.handleOrder)
57
	mux.HandleFunc("/api/delete", s.handleDelete)
58
	mux.HandleFunc("/api/enabled", s.handleEnabled)
59
	mux.HandleFunc("/api/settings", s.handleSettings)
60
	mux.HandleFunc("/api/control", s.handleControl)
61
	mux.HandleFunc("/thumb/", s.handleThumb)
62
	mux.HandleFunc("/media/", s.handleMedia)
63
	mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
64
		w.Header().Set("Content-Type", "text/plain; charset=utf-8")
65
		io.WriteString(w, "ok\n")
66
	})
67
68
	return s.withAuth(mux)
69
}
70
71
// withAuth schuetzt alles per HTTP-Basic-Auth, sofern ein Passwort gesetzt ist.
72
func (s *Server) withAuth(next http.Handler) http.Handler {
73
	if s.cfg.WebPassword == "" {
74
		return next
75
	}
76
	want := []byte(s.cfg.WebPassword)
77
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
78
		if r.URL.Path == "/healthz" {
79
			next.ServeHTTP(w, r)
80
			return
81
		}
82
		_, pass, ok := r.BasicAuth()
83
		if !ok || subtle.ConstantTimeCompare([]byte(pass), want) != 1 {
84
			w.Header().Set("WWW-Authenticate", `Basic realm="TV-Wall"`)
85
			http.Error(w, "Anmeldung erforderlich", http.StatusUnauthorized)
86
			return
87
		}
88
		next.ServeHTTP(w, r)
89
	})
90
}
91
92
// ---------------------------------------------------------------------------
93
// Handler
94
// ---------------------------------------------------------------------------
95
96
func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
97
	if r.URL.Path != "/" {
98
		http.NotFound(w, r)
99
		return
100
	}
101
	w.Header().Set("Content-Type", "text/html; charset=utf-8")
102
	w.Header().Set("Cache-Control", "no-store")
103
	writeAsset(w, "index.html")
104
}
105
106
type stateResponse struct {
107
	Entries  []playlist.Entry `json:"entries"`
108
	Version  uint64           `json:"version"`
109
	Status   player.Status    `json:"status"`
110
	Settings config.Settings  `json:"settings"`
111
	Device   deviceInfo       `json:"device"`
112
	Disk     diskInfo         `json:"disk"`
113
}
114
115
type deviceInfo struct {
116
	SSID      string `json:"ssid"`
117
	Hostname  string `json:"hostname"`
118
	Images    int    `json:"images"`
119
	Videos    int    `json:"videos"`
120
	Active    int    `json:"active"`
121
	Thumbs    bool   `json:"thumbs"`
122
	MaxUpload int64  `json:"max_upload_mb"`
123
}
124
125
func (s *Server) handleState(w http.ResponseWriter, r *http.Request) {
126
	entries := s.pl.Entries()
127
	var images, videos, active int
128
	for _, e := range entries {
129
		if e.Kind == "video" {
130
			videos++
131
		} else {
132
			images++
133
		}
134
		if e.Enabled {
135
			active++
136
		}
137
	}
138
	if entries == nil {
139
		entries = []playlist.Entry{}
140
	}
141
	free, total := diskUsage(s.cfg.MediaDir())
142
143
	writeJSON(w, http.StatusOK, stateResponse{
144
		Entries:  entries,
145
		Version:  s.pl.Version(),
146
		Status:   s.player.Status(),
147
		Settings: s.settings.Get(),
148
		Device: deviceInfo{
149
			SSID:      s.cfg.APSSID,
150
			Hostname:  s.cfg.Hostname,
151
			Images:    images,
152
			Videos:    videos,
153
			Active:    active,
154
			Thumbs:    s.hasFF,
155
			MaxUpload: s.cfg.MaxUploadMB,
156
		},
157
		Disk: diskInfo{Free: free, Total: total},
158
	})
159
}
160
161
type uploadResult struct {
162
	Uploaded []string `json:"uploaded"`
163
	Skipped  []string `json:"skipped"`
164
}
165
166
func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) {
167
	if r.Method != http.MethodPost {
168
		httpError(w, http.StatusMethodNotAllowed, "nur POST")
169
		return
170
	}
171
	maxBytes := s.cfg.MaxUploadMB << 20
172
	r.Body = http.MaxBytesReader(w, r.Body, maxBytes)
173
174
	mr, err := r.MultipartReader()
175
	if err != nil {
176
		httpError(w, http.StatusBadRequest, "kein gueltiger Upload: "+err.Error())
177
		return
178
	}
179
180
	res := uploadResult{Uploaded: []string{}, Skipped: []string{}}
181
	for {
182
		part, err := mr.NextPart()
183
		if errors.Is(err, io.EOF) {
184
			break
185
		}
186
		if err != nil {
187
			httpError(w, http.StatusBadRequest, "Upload abgebrochen: "+err.Error())
188
			return
189
		}
190
		name := playlist.CleanUploadName(part.FileName())
191
		if name == "" {
192
			part.Close()
193
			continue
194
		}
195
		if !playlist.UploadAllowed(name) {
196
			res.Skipped = append(res.Skipped, name+" (Dateityp wird nicht unterstuetzt)")
197
			part.Close()
198
			continue
199
		}
200
		if playlist.Convertible(name) && !s.hasFF {
201
			res.Skipped = append(res.Skipped, name+" (HEIC braucht ffmpeg auf dem Pi)")
202
			part.Close()
203
			continue
204
		}
205
		saved, err := s.saveUpload(part, name)
206
		part.Close()
207
		if err != nil {
208
			log.Printf("web: upload %q: %v", name, err)
209
			res.Skipped = append(res.Skipped, name+" ("+err.Error()+")")
210
			continue
211
		}
212
		res.Uploaded = append(res.Uploaded, saved)
213
	}
214
215
	if len(res.Uploaded) > 0 {
216
		if _, err := s.pl.Scan(); err != nil {
217
			log.Printf("web: scan nach upload: %v", err)
218
		}
219
	}
220
	writeJSON(w, http.StatusOK, res)
221
}
222
223
// saveUpload schreibt den Datenstrom in eine temporaere Datei und benennt sie
224
// erst nach vollstaendigem Empfang um - so landen keine halben Dateien in der
225
// Playliste.
226
func (s *Server) saveUpload(part *multipart.Part, name string) (string, error) {
227
	dir := s.pl.MediaDir()
228
	if err := os.MkdirAll(dir, 0o775); err != nil {
229
		return "", err
230
	}
231
	tmp, err := os.CreateTemp(dir, ".upload-*")
232
	if err != nil {
233
		return "", err
234
	}
235
	tmpName := tmp.Name()
236
	defer os.Remove(tmpName)
237
238
	if _, err := io.Copy(tmp, part); err != nil {
239
		tmp.Close()
240
		return "", err
241
	}
242
	if err := tmp.Close(); err != nil {
243
		return "", err
244
	}
245
	// iPhone-Fotos kommen als HEIC - daraus wird ein JPEG, das mpv anzeigen kann.
246
	if playlist.Convertible(name) {
247
		converted := tmpName + ".jpg"
248
		if err := convertToJPEG(tmpName, converted); err != nil {
249
			return "", fmt.Errorf("HEIC umwandeln: %w", err)
250
		}
251
		defer os.Remove(converted)
252
		tmpName = converted
253
		name = strings.TrimSuffix(name, filepath.Ext(name)) + ".jpg"
254
	}
255
256
	if err := os.Chmod(tmpName, 0o664); err != nil {
257
		return "", err
258
	}
259
260
	final := playlist.UniqueName(dir, name)
261
	if err := os.Rename(tmpName, filepath.Join(dir, final)); err != nil {
262
		return "", err
263
	}
264
	return final, nil
265
}
266
267
func (s *Server) handleOrder(w http.ResponseWriter, r *http.Request) {
268
	if r.Method != http.MethodPost {
269
		httpError(w, http.StatusMethodNotAllowed, "nur POST")
270
		return
271
	}
272
	var req struct {
273
		Order []string `json:"order"`
274
	}
275
	if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&req); err != nil {
276
		httpError(w, http.StatusBadRequest, "ungueltige Anfrage")
277
		return
278
	}
279
	if err := s.pl.SetOrder(req.Order); err != nil {
280
		httpError(w, http.StatusInternalServerError, err.Error())
281
		return
282
	}
283
	writeJSON(w, http.StatusOK, map[string]any{"ok": true})
284
}
285
286
func (s *Server) handleDelete(w http.ResponseWriter, r *http.Request) {
287
	if r.Method != http.MethodPost {
288
		httpError(w, http.StatusMethodNotAllowed, "nur POST")
289
		return
290
	}
291
	var req struct {
292
		Name string `json:"name"`
293
	}
294
	if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req); err != nil {
295
		httpError(w, http.StatusBadRequest, "ungueltige Anfrage")
296
		return
297
	}
298
	if err := s.pl.Delete(req.Name); err != nil {
299
		httpError(w, http.StatusBadRequest, err.Error())
300
		return
301
	}
302
	writeJSON(w, http.StatusOK, map[string]any{"ok": true})
303
}
304
305
func (s *Server) handleEnabled(w http.ResponseWriter, r *http.Request) {
306
	if r.Method != http.MethodPost {
307
		httpError(w, http.StatusMethodNotAllowed, "nur POST")
308
		return
309
	}
310
	var req struct {
311
		Name    string `json:"name"`
312
		Enabled bool   `json:"enabled"`
313
	}
314
	if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req); err != nil {
315
		httpError(w, http.StatusBadRequest, "ungueltige Anfrage")
316
		return
317
	}
318
	if err := s.pl.SetEnabled(req.Name, req.Enabled); err != nil {
319
		httpError(w, http.StatusBadRequest, err.Error())
320
		return
321
	}
322
	writeJSON(w, http.StatusOK, map[string]any{"ok": true})
323
}
324
325
func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) {
326
	if r.Method != http.MethodPost {
327
		httpError(w, http.StatusMethodNotAllowed, "nur POST")
328
		return
329
	}
330
	cur := s.settings.Get()
331
	var req struct {
332
		ImageDuration *float64 `json:"image_duration"`
333
		Mute          *bool    `json:"mute"`
334
	}
335
	if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req); err != nil {
336
		httpError(w, http.StatusBadRequest, "ungueltige Anfrage")
337
		return
338
	}
339
	if req.ImageDuration != nil {
340
		cur.ImageDuration = *req.ImageDuration
341
	}
342
	if req.Mute != nil {
343
		cur.Mute = *req.Mute
344
	}
345
	saved, err := s.settings.Set(cur)
346
	if err != nil {
347
		httpError(w, http.StatusInternalServerError, err.Error())
348
		return
349
	}
350
	writeJSON(w, http.StatusOK, saved)
351
}
352
353
func (s *Server) handleControl(w http.ResponseWriter, r *http.Request) {
354
	if r.Method != http.MethodPost {
355
		httpError(w, http.StatusMethodNotAllowed, "nur POST")
356
		return
357
	}
358
	var req struct {
359
		Action string `json:"action"`
360
	}
361
	if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req); err != nil {
362
		httpError(w, http.StatusBadRequest, "ungueltige Anfrage")
363
		return
364
	}
365
366
	var err error
367
	switch req.Action {
368
	case "next":
369
		err = s.player.Next()
370
	case "prev":
371
		err = s.player.Prev()
372
	case "pause":
373
		err = s.player.TogglePause()
374
	case "reload":
375
		err = s.player.ReloadNow()
376
	case "restart":
377
		err = s.player.Restart()
378
	case "rescan":
379
		_, err = s.pl.Scan()
380
	default:
381
		httpError(w, http.StatusBadRequest, "unbekannte Aktion")
382
		return
383
	}
384
	if err != nil {
385
		httpError(w, http.StatusConflict, err.Error())
386
		return
387
	}
388
	writeJSON(w, http.StatusOK, map[string]any{"ok": true})
389
}
390
391
func (s *Server) handleMedia(w http.ResponseWriter, r *http.Request) {
392
	name, err := playlist.SafeName(strings.TrimPrefix(r.URL.Path, "/media/"))
393
	if err != nil {
394
		http.NotFound(w, r)
395
		return
396
	}
397
	path := filepath.Join(s.pl.MediaDir(), name)
398
	if !playlist.Supported(name) {
399
		http.NotFound(w, r)
400
		return
401
	}
402
	http.ServeFile(w, r, path)
403
}
404
405
// handleThumb liefert ein Vorschaubild und legt es dabei im Cache ab.
406
func (s *Server) handleThumb(w http.ResponseWriter, r *http.Request) {
407
	name, err := playlist.SafeName(strings.TrimPrefix(r.URL.Path, "/thumb/"))
408
	if err != nil {
409
		http.NotFound(w, r)
410
		return
411
	}
412
	kind := playlist.Kind(name)
413
	if kind == "" {
414
		http.NotFound(w, r)
415
		return
416
	}
417
	src := filepath.Join(s.pl.MediaDir(), name)
418
	info, err := os.Stat(src)
419
	if err != nil {
420
		http.NotFound(w, r)
421
		return
422
	}
423
424
	if !s.hasFF {
425
		if kind == "image" {
426
			http.ServeFile(w, r, src)
427
			return
428
		}
429
		s.servePlaceholder(w)
430
		return
431
	}
432
433
	sum := sha1.Sum([]byte(fmt.Sprintf("%s|%d|%d", name, info.ModTime().UnixNano(), info.Size())))
434
	thumb := filepath.Join(s.cfg.ThumbDir(), hex.EncodeToString(sum[:])+".jpg")
435
436
	if _, err := os.Stat(thumb); err != nil {
437
		s.thumbMu.Lock()
438
		_, statErr := os.Stat(thumb)
439
		if statErr != nil {
440
			err = makeThumb(src, thumb, kind)
441
		} else {
442
			err = nil
443
		}
444
		s.thumbMu.Unlock()
445
		if err != nil {
446
			log.Printf("web: vorschaubild %q: %v", name, err)
447
			if kind == "image" {
448
				http.ServeFile(w, r, src)
449
			} else {
450
				s.servePlaceholder(w)
451
			}
452
			return
453
		}
454
	}
455
456
	w.Header().Set("Cache-Control", "public, max-age=86400")
457
	http.ServeFile(w, r, thumb)
458
}
459
460
func (s *Server) servePlaceholder(w http.ResponseWriter) {
461
	w.Header().Set("Content-Type", "image/svg+xml")
462
	w.Header().Set("Cache-Control", "public, max-age=86400")
463
	io.WriteString(w, `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 4 3">`+
464
		`<rect width="4" height="3" fill="#243040"/>`+
465
		`<path d="M1.6 1 2.8 1.5 1.6 2z" fill="#6f8cad"/></svg>`)
466
}
467
468
// ---------------------------------------------------------------------------
469
// Server starten
470
// ---------------------------------------------------------------------------
471
472
// ListenAndServe startet den HTTP-Server auf dem konfigurierten Port.
473
func (s *Server) ListenAndServe(addr string) error {
474
	srv := &http.Server{
475
		Addr:    addr,
476
		Handler: s.Handler(),
477
		// Kein ReadTimeout: grosse Videouploads duerfen lange dauern.
478
		ReadHeaderTimeout: 30 * time.Second,
479
		IdleTimeout:       120 * time.Second,
480
	}
481
	log.Printf("web: hoere auf %s", addr)
482
	return srv.ListenAndServe()
483
}
484
485
// ---------------------------------------------------------------------------
486
// Hilfsfunktionen
487
// ---------------------------------------------------------------------------
488
489
func writeJSON(w http.ResponseWriter, code int, v any) {
490
	w.Header().Set("Content-Type", "application/json; charset=utf-8")
491
	w.Header().Set("Cache-Control", "no-store")
492
	w.WriteHeader(code)
493
	if err := json.NewEncoder(w).Encode(v); err != nil {
494
		log.Printf("web: antwort schreiben: %v", err)
495
	}
496
}
497
498
func httpError(w http.ResponseWriter, code int, msg string) {
499
	writeJSON(w, code, map[string]string{"error": msg})
500
}