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 | } |