morz-infoboard/server/backend/internal/httpapi/playerstatus.go
Jesko Anschütz 79fcc20b79 fix(display): screen UUID lookup, authScreen middleware, JSON encoding
- playerstatus: look up screen by slug before UpsertDisplayState to pass UUID (not slug) and avoid FK violation
- router: switch display command route from authOnly to authScreen for proper permission enforcement
- display.go: remove redundant GetBySlug + requireScreenAccess (now handled by authScreen middleware), drop store dependency
- notifier: replace fmt.Sprintf %q with json.Marshal for correct JSON encoding of display command payload

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 23:35:05 +01:00

444 lines
13 KiB
Go

package httpapi
import (
"errors"
"log/slog"
"net/http"
"net/url"
"sort"
"strconv"
"strings"
"time"
storePackage "git.az-it.net/az/morz-infoboard/server/backend/internal/store"
)
type screenStatusSummary struct {
Total int `json:"total"`
Online int `json:"online"`
Degraded int `json:"degraded"`
Offline int `json:"offline"`
Stale int `json:"stale"`
}
type screenStatusOverview struct {
Summary screenStatusSummary `json:"summary"`
Screens []playerStatusRecord `json:"screens"`
}
var allowedStatuses = map[string]struct{}{
"starting": {},
"running": {},
"stopped": {},
}
var allowedServerConnectivity = map[string]struct{}{
"": {},
"unknown": {},
"online": {},
"degraded": {},
"offline": {},
}
type playerStatusRequest struct {
ScreenID string `json:"screen_id"`
Timestamp string `json:"ts"`
Status string `json:"status"`
ServerConnectivity string `json:"server_connectivity"`
ServerURL string `json:"server_url"`
MQTTBroker string `json:"mqtt_broker"`
HeartbeatEverySeconds int `json:"heartbeat_every_seconds"`
StartedAt string `json:"started_at"`
LastHeartbeatAt string `json:"last_heartbeat_at"`
DisplayState string `json:"display_state,omitempty"`
}
// playerStatusMQTTConfig is the MQTT configuration returned to agents in the
// status-report response. It is omitted entirely when Broker is empty.
type playerStatusMQTTConfig struct {
Broker string `json:"broker"`
Username string `json:"username,omitempty"`
Password string `json:"password,omitempty"`
}
// playerStatusResponse is the JSON body returned for POST /api/v1/player/status.
type playerStatusResponse struct {
Status string `json:"status"`
MQTT *playerStatusMQTTConfig `json:"mqtt,omitempty"`
}
func handlePlayerStatus(store playerStatusStore, screenStore *storePackage.ScreenStore, mqttBroker, mqttUsername, mqttPassword string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var request playerStatusRequest
if err := decodeJSON(r, &request); err != nil {
writeError(w, http.StatusBadRequest, "invalid_json", "ungueltiger JSON-Body", nil)
return
}
if strings.TrimSpace(request.ScreenID) == "" {
writeError(w, http.StatusBadRequest, "screen_id_required", "screen_id ist erforderlich", nil)
return
}
request.ScreenID = strings.TrimSpace(request.ScreenID)
if strings.TrimSpace(request.Timestamp) == "" {
writeError(w, http.StatusBadRequest, "timestamp_required", "ts ist erforderlich", nil)
return
}
if strings.TrimSpace(request.Status) == "" {
writeError(w, http.StatusBadRequest, "status_required", "status ist erforderlich", nil)
return
}
request.Status = strings.TrimSpace(request.Status)
if _, ok := allowedStatuses[request.Status]; !ok {
writeError(w, http.StatusBadRequest, "invalid_status", "status ist ungueltig", nil)
return
}
request.ServerConnectivity = strings.TrimSpace(request.ServerConnectivity)
if _, ok := allowedServerConnectivity[request.ServerConnectivity]; !ok {
writeError(w, http.StatusBadRequest, "invalid_server_connectivity", "server_connectivity ist ungueltig", nil)
return
}
if request.HeartbeatEverySeconds <= 0 {
writeError(w, http.StatusBadRequest, "invalid_heartbeat_interval", "heartbeat_every_seconds muss positiv sein", nil)
return
}
if err := validateOptionalRFC3339(request.Timestamp); err != nil {
writeError(w, http.StatusBadRequest, "invalid_timestamp", "ts ist kein gueltiger RFC3339-Zeitstempel", nil)
return
}
if err := validateOptionalRFC3339(request.StartedAt); err != nil {
writeError(w, http.StatusBadRequest, "invalid_started_at", "started_at ist kein gueltiger RFC3339-Zeitstempel", nil)
return
}
if err := validateOptionalRFC3339(request.LastHeartbeatAt); err != nil {
writeError(w, http.StatusBadRequest, "invalid_last_heartbeat_at", "last_heartbeat_at ist kein gueltiger RFC3339-Zeitstempel", nil)
return
}
store.Save(playerStatusRecord{
ScreenID: request.ScreenID,
Timestamp: request.Timestamp,
Status: request.Status,
ServerConnectivity: request.ServerConnectivity,
ServerURL: request.ServerURL,
MQTTBroker: request.MQTTBroker,
HeartbeatEverySeconds: request.HeartbeatEverySeconds,
StartedAt: request.StartedAt,
LastHeartbeatAt: request.LastHeartbeatAt,
})
if request.DisplayState != "" && screenStore != nil {
if screen, err := screenStore.GetBySlug(r.Context(), request.ScreenID); err == nil {
if err := screenStore.UpsertDisplayState(r.Context(), screen.ID, request.DisplayState); err != nil {
slog.Error("upsert display state", "screen_id", screen.ID, "err", err)
}
}
}
resp := playerStatusResponse{Status: "accepted"}
if mqttBroker != "" {
resp.MQTT = &playerStatusMQTTConfig{
Broker: mqttBroker,
Username: mqttUsername,
Password: mqttPassword,
}
}
writeJSON(w, http.StatusOK, resp)
}
}
func handleGetLatestPlayerStatus(store playerStatusStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
screenID := strings.TrimSpace(r.PathValue("screenId"))
if screenID == "" {
writeError(w, http.StatusBadRequest, "screen_id_required", "screenId ist erforderlich", nil)
return
}
record, ok := store.Get(screenID)
if !ok {
writeError(w, http.StatusNotFound, "screen_status_not_found", "Fuer diesen Screen liegt noch kein Status vor", nil)
return
}
record.Stale = isStale(record, store.Now())
record.DerivedState = deriveState(record)
writeJSON(w, http.StatusOK, record)
}
}
func handleDeletePlayerStatus(store playerStatusStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
screenID := strings.TrimSpace(r.PathValue("screenId"))
if screenID == "" {
writeError(w, http.StatusBadRequest, "screen_id_required", "screenId ist erforderlich", nil)
return
}
if !store.Delete(screenID) {
writeError(w, http.StatusNotFound, "screen_status_not_found", "Fuer diesen Screen liegt noch kein Status vor", nil)
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
}
}
func handleListLatestPlayerStatuses(store playerStatusStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
overview, err := buildScreenStatusOverview(store, r.URL.Query())
if err != nil {
writeOverviewQueryError(w, err)
return
}
writeJSON(w, http.StatusOK, overview)
}
}
func buildScreenStatusOverview(store playerStatusStore, query url.Values) (screenStatusOverview, error) {
records := store.List()
screenIDFilter := strings.ToLower(strings.TrimSpace(query.Get("q")))
wantConnectivity := strings.TrimSpace(query.Get("server_connectivity"))
switch wantConnectivity {
case "", "online", "degraded", "offline", "unknown":
// valid
default:
return screenStatusOverview{}, errInvalidServerConnectivity
}
wantStale := strings.TrimSpace(query.Get("stale"))
switch wantStale {
case "", "true", "false":
// valid
default:
return screenStatusOverview{}, errInvalidStale
}
wantDerivedState := strings.TrimSpace(query.Get("derived_state"))
switch wantDerivedState {
case "", "online", "degraded", "offline":
// valid
default:
return screenStatusOverview{}, errInvalidDerivedState
}
updatedSince, err := parseOptionalRFC3339(query.Get("updated_since"))
if err != nil {
return screenStatusOverview{}, errInvalidUpdatedSince
}
limit, err := parseOptionalPositiveInt(query.Get("limit"))
if err != nil {
return screenStatusOverview{}, errInvalidLimit
}
overview := screenStatusOverview{
Screens: make([]playerStatusRecord, 0, len(records)),
}
for i := range records {
records[i].Stale = isStale(records[i], store.Now())
records[i].DerivedState = deriveState(records[i])
overview.Summary.Total++
switch records[i].DerivedState {
case "offline":
overview.Summary.Offline++
case "degraded":
overview.Summary.Degraded++
default:
overview.Summary.Online++
}
if records[i].Stale {
overview.Summary.Stale++
}
if screenIDFilter != "" && !strings.Contains(strings.ToLower(records[i].ScreenID), screenIDFilter) {
continue
}
if updatedSince != nil && !isUpdatedSince(records[i], *updatedSince) {
continue
}
if wantConnectivity != "" && records[i].ServerConnectivity != wantConnectivity {
continue
}
if wantStale != "" {
if wantStale == "true" && !records[i].Stale {
continue
}
if wantStale == "false" && records[i].Stale {
continue
}
}
if wantDerivedState != "" && records[i].DerivedState != wantDerivedState {
continue
}
overview.Screens = append(overview.Screens, records[i])
}
sort.Slice(overview.Screens, func(i, j int) bool {
leftPriority := derivedStatePriority(overview.Screens[i].DerivedState)
rightPriority := derivedStatePriority(overview.Screens[j].DerivedState)
if leftPriority != rightPriority {
return leftPriority < rightPriority
}
return overview.Screens[i].ScreenID < overview.Screens[j].ScreenID
})
if limit > 0 && len(overview.Screens) > limit {
overview.Screens = overview.Screens[:limit]
}
return overview, nil
}
var (
errInvalidUpdatedSince = errors.New("invalid updated_since")
errInvalidLimit = errors.New("invalid limit")
errInvalidServerConnectivity = errors.New("invalid server_connectivity")
errInvalidStale = errors.New("invalid stale")
errInvalidDerivedState = errors.New("invalid derived_state")
)
// overviewQueryErrorCode returns the machine-readable error code for query
// validation errors. It is used by both the JSON and HTML error paths.
func overviewQueryErrorCode(err error) string {
switch err {
case errInvalidUpdatedSince:
return "invalid_updated_since"
case errInvalidLimit:
return "invalid_limit"
case errInvalidServerConnectivity:
return "invalid_server_connectivity"
case errInvalidStale:
return "invalid_stale"
case errInvalidDerivedState:
return "invalid_derived_state"
default:
return "invalid_query"
}
}
// overviewQueryErrorMessage returns the human-readable message for query
// validation errors. It is used by both the JSON and HTML error paths so
// that the wording stays consistent regardless of response format.
func overviewQueryErrorMessage(err error) string {
switch err {
case errInvalidUpdatedSince:
return "updated_since ist kein gueltiger RFC3339-Zeitstempel"
case errInvalidLimit:
return "limit muss eine positive Ganzzahl sein"
case errInvalidServerConnectivity:
return "server_connectivity muss online, offline, degraded oder unknown sein"
case errInvalidStale:
return "stale muss true oder false sein"
case errInvalidDerivedState:
return "derived_state muss online, degraded oder offline sein"
default:
return "ungueltige Query-Parameter"
}
}
func writeOverviewQueryError(w http.ResponseWriter, err error) {
writeError(w, http.StatusBadRequest, overviewQueryErrorCode(err), overviewQueryErrorMessage(err), nil)
}
func validateOptionalRFC3339(value string) error {
if strings.TrimSpace(value) == "" {
return nil
}
_, err := time.Parse(time.RFC3339, value)
return err
}
func parseOptionalRFC3339(value string) (*time.Time, error) {
if strings.TrimSpace(value) == "" {
return nil, nil
}
parsed, err := time.Parse(time.RFC3339, strings.TrimSpace(value))
if err != nil {
return nil, err
}
return &parsed, nil
}
func parseOptionalPositiveInt(value string) (int, error) {
if strings.TrimSpace(value) == "" {
return 0, nil
}
parsed, err := strconv.Atoi(strings.TrimSpace(value))
if err != nil || parsed <= 0 {
return 0, strconv.ErrSyntax
}
return parsed, nil
}
func isStale(record playerStatusRecord, now time.Time) bool {
if strings.TrimSpace(record.ReceivedAt) == "" {
return false
}
receivedAt, err := time.Parse(time.RFC3339, record.ReceivedAt)
if err != nil {
return false
}
threshold := staleThresholdFor(record)
return now.Sub(receivedAt) > threshold
}
func staleThresholdFor(record playerStatusRecord) time.Duration {
if record.HeartbeatEverySeconds > 0 {
return time.Duration(record.HeartbeatEverySeconds*2) * time.Second
}
return 2 * time.Minute
}
func deriveState(record playerStatusRecord) string {
if record.Stale || record.ServerConnectivity == "offline" {
return "offline"
}
if record.ServerConnectivity == "degraded" || record.ServerConnectivity == "unknown" || record.Status != "running" {
return "degraded"
}
return "online"
}
func isUpdatedSince(record playerStatusRecord, threshold time.Time) bool {
if strings.TrimSpace(record.ReceivedAt) == "" {
return false
}
receivedAt, err := time.Parse(time.RFC3339, record.ReceivedAt)
if err != nil {
return false
}
return !receivedAt.Before(threshold)
}
func derivedStatePriority(state string) int {
switch state {
case "offline":
return 0
case "degraded":
return 1
default:
return 2
}
}