package api import ( "encoding/json" "errors" "net/http" "strings" "github.com/ponzischeme89/memby/server/internal/store" ) // The people under one account, from the operator's side. // // It lives on the **account page** rather than on a rail entry of its own, which is the // whole of the design decision: a viewer only exists under an account, and a top-level // page would have to begin by asking which account is being talked about — a question the // page an operator reached this from has already answered. It is the arrangement the // per-account preference editor and the device list already take. // // A television can now do all of this for itself, so this is the operator's copy rather // than the only way in: what it is for is a household that has asked for help over the // phone, and the case a remote genuinely cannot reach — a viewer created on a set that has // since been unplugged. type adminViewersResponse struct { Viewers []adminViewer `json:"viewers"` // Whether the household's own switch is on. The page says so rather than quietly // offering controls whose effect nothing on any television would show: an operator who // has switched viewers off and then adds one has done something that looks like it // worked and did nothing. Enabled bool `json:"enabled"` // What the gateway will accept, so the console can stop offering Add at the same point // the television does rather than discovering the limit by being refused. MaxShadowViewers int `json:"maxShadowViewers"` } type adminViewer struct { store.Viewer PIN string `json:"pin,omitempty"` } func (s *Server) handleAdminViewers(w http.ResponseWriter, r *http.Request) { userID := strings.TrimSpace(r.PathValue("userID")) if userID == "" { writeError(w, http.StatusBadRequest, "user is required") return } if r.Method == http.MethodPost { s.handleAdminCreateViewer(w, r, userID) return } // The username is only used to name a main viewer that does not exist yet, and an // operator is not the right person to be naming somebody — an account that has never // had a request made against it gets the placeholder, and the television replaces it // with the real Emby name on its first sign-in. viewers, err := s.store.Viewers(r.Context(), userID, "") if err != nil { writeError(w, http.StatusInternalServerError, "could not load viewers") return } admin := make([]adminViewer, 0, len(viewers)) for _, viewer := range viewers { pin, pinErr := s.store.ViewerPIN(r.Context(), userID, viewer.ID) if pinErr != nil { writeError(w, http.StatusInternalServerError, "could not load PINs") return } admin = append(admin, adminViewer{Viewer: viewer, PIN: pin}) } writeJSON(w, http.StatusOK, adminViewersResponse{ Viewers: admin, Enabled: s.viewersEnabled(r.Context()), MaxShadowViewers: store.MaxShadowViewers, }) } func (s *Server) handleAdminCreateViewer(w http.ResponseWriter, r *http.Request, userID string) { var req viewerRequest if json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<10)).Decode(&req) != nil { writeError(w, http.StatusBadRequest, "malformed request body") return } if strings.TrimSpace(req.Name) == "" || len([]rune(req.Name)) > 40 { writeError(w, http.StatusBadRequest, "a name of up to 40 characters is required") return } var requestedPINHash []byte if req.PIN != "" { var err error requestedPINHash, err = pinHash(req.PIN) if err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } } viewer, err := s.store.CreateShadowViewer(r.Context(), userID, req.Name, req.ShortName, req.Colour) if err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } if req.PIN != "" { if err := s.store.SetViewerPIN(r.Context(), userID, viewer.ID, requestedPINHash); err != nil { writeError(w, http.StatusInternalServerError, "could not save PIN") return } if err := s.store.SetViewerPINValue(r.Context(), userID, viewer.ID, req.PIN); err != nil { writeError(w, http.StatusInternalServerError, "could not save PIN") return } viewer.HasPIN = true } // The televisions hold a cached list for viewerListTTL, so the write clears it here for // the same reason it does on the client-facing route: a person added from the console // must be pickable on the next request rather than at the end of the window. s.forgetViewers(userID) s.loggerFor(r.Context()).Info("viewer added by operator", "account", userID, "viewer", viewer.ID, "name", viewer.Name) writeJSON(w, http.StatusOK, viewer) } func (s *Server) handleAdminViewer(w http.ResponseWriter, r *http.Request) { userID := strings.TrimSpace(r.PathValue("userID")) viewerID := strings.TrimSpace(r.PathValue("viewerID")) if userID == "" || viewerID == "" { writeError(w, http.StatusBadRequest, "user and viewer are required") return } if r.Method == http.MethodDelete { if err := s.store.DeleteShadowViewer(r.Context(), userID, viewerID); err != nil { if errors.Is(err, store.ErrViewerNotFound) { writeError(w, http.StatusNotFound, "no such viewer") return } writeError(w, http.StatusInternalServerError, "could not remove that viewer") return } s.forgetViewers(userID) // Everything cached under this viewer's own key is now about nobody. if err := s.cache.InvalidateUser(r.Context(), viewerID); err != nil { s.loggerFor(r.Context()).Warn("cache invalidation failed", "error", err) } s.loggerFor(r.Context()).Info("viewer removed by operator", "account", userID, "viewer", viewerID) w.WriteHeader(http.StatusNoContent) return } var req viewerRequest if json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<10)).Decode(&req) != nil { writeError(w, http.StatusBadRequest, "malformed request body") return } var viewer store.Viewer var err error // The main viewer cannot be renamed here, but its PIN is an account credential and // administrators must still be able to set or reset it. if viewerID == userID && (req.PIN != "" || req.ClearPIN) { viewer, err = s.store.ViewerFor(r.Context(), userID, viewerID) } else { viewer, err = s.store.UpdateShadowViewer( r.Context(), userID, viewerID, req.Name, req.ShortName, req.Colour, ) } if err != nil { if errors.Is(err, store.ErrViewerNotFound) { // The main viewer lands here too, and that is the honest answer: its name is // the Emby account's, so as a *shadow* viewer to rename it does not exist. writeError(w, http.StatusNotFound, "no such viewer") return } writeError(w, http.StatusBadRequest, err.Error()) return } if req.ClearPIN || req.PIN != "" { if req.ClearPIN { err = s.store.ClearViewerPIN(r.Context(), userID, viewerID) if err == nil { err = s.store.SetViewerPINValue(r.Context(), userID, viewerID, "") } } else { var hash []byte hash, err = pinHash(req.PIN) if err == nil { err = s.store.SetViewerPIN(r.Context(), userID, viewerID, hash) if err == nil { err = s.store.SetViewerPINValue(r.Context(), userID, viewerID, req.PIN) } } } if err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } viewer.HasPIN = !req.ClearPIN } s.forgetViewers(userID) s.loggerFor(r.Context()).Info("viewer renamed by operator", "account", userID, "viewer", viewer.ID, "name", viewer.Name) writeJSON(w, http.StatusOK, viewer) }