package api import ( "crypto/hmac" "crypto/rand" "crypto/sha256" "encoding/base64" "encoding/binary" "net/http" "net/url" "strings" "time" "github.com/ponzischeme89/memby/server/internal/emby" ) const ( installerCookieName = "memby_installer" installerSessionTTL = 30 * time.Minute adminSessionTTL = 12 * time.Hour installerDeviceID = "memby-web-installer" // adminRenewWithin is how close to expiry a session must be before an operator's own // request re-issues it. Half the TTL avoids rewriting the cookie on every request. adminRenewWithin = adminSessionTTL / 2 ) // gatewayDeviceName is what Emby records for a device row the gateway creates for itself. // It follows the gateway's client name so one operator-set word covers both halves of how // the server identifies itself, and it is deliberately never the product name — Emby's // device list is read by whoever runs the server, and an entry called "Memby …" there // reads as one of the household's televisions. func (s *Server) gatewayDeviceName() string { if name := strings.TrimSpace(s.cfg.GatewayClientName); name != "" { return name } return emby.DefaultGatewayClientName } // installerDeviceName separates the temporary record an admin or installer sign-in // creates from the gateway's own, so a password check is recognisable while it exists. func (s *Server) installerDeviceName() string { return s.gatewayDeviceName() + " Installer" } func (s *Server) installerSecret() []byte { if s.cfg.ReleasePublishToken == "" { return nil } // Domain separation means a cookie/signature is not the release publisher token and // cannot be presented to the upload endpoint. sum := sha256.Sum256([]byte("memby installer access v1\x00" + s.cfg.ReleasePublishToken)) return sum[:] } func (s *Server) signInstallerValue(purpose string, payload []byte) []byte { mac := hmac.New(sha256.New, s.installerSecret()) _, _ = mac.Write([]byte(purpose)) _, _ = mac.Write([]byte{0}) _, _ = mac.Write(payload) return mac.Sum(nil) } func (s *Server) newInstallerSession() (string, error) { return s.newBrowserSession(installerSessionTTL) } func (s *Server) newBrowserSession(ttl time.Duration) (string, error) { payload := make([]byte, 8+16) binary.BigEndian.PutUint64(payload[:8], uint64(time.Now().Add(ttl).Unix())) if _, err := rand.Read(payload[8:]); err != nil { return "", err } signature := s.signInstallerValue("session", payload) return base64.RawURLEncoding.EncodeToString(payload) + "." + base64.RawURLEncoding.EncodeToString(signature), nil } // installerSessionExpiry reports when the request's session runs out. A cookie that is // missing, malformed, forged or already expired is reported the same way: no session. func (s *Server) installerSessionExpiry(r *http.Request) (time.Time, bool) { if len(s.installerSecret()) == 0 { return time.Time{}, false } cookie, err := r.Cookie(installerCookieName) if err != nil { return time.Time{}, false } parts := strings.Split(cookie.Value, ".") if len(parts) != 2 { return time.Time{}, false } payload, err := base64.RawURLEncoding.DecodeString(parts[0]) if err != nil || len(payload) != 24 { return time.Time{}, false } signature, err := base64.RawURLEncoding.DecodeString(parts[1]) if err != nil || !hmac.Equal(signature, s.signInstallerValue("session", payload)) { return time.Time{}, false } expires := int64(binary.BigEndian.Uint64(payload[:8])) now := time.Now().Unix() if expires <= now || expires > now+int64(adminSessionTTL/time.Second)+60 { return time.Time{}, false } return time.Unix(expires, 0), true } func (s *Server) validInstallerSession(r *http.Request) bool { _, ok := s.installerSessionExpiry(r) return ok } // renewAdminSession slides a valid session's expiry forward. The TTL was absolute and // nothing extended it, so an operator working the admin console was signed out from under // themselves and the page's poll became a permanent "invalid admin // token" banner with no sign-in to return to. Callers must only reach here for a request // an operator actually made — see operatorPresent — or an abandoned tab's own polling // would keep the session alive indefinitely, which is what the TTL exists to stop. func (s *Server) renewAdminSession(w http.ResponseWriter, r *http.Request) { expires, ok := s.installerSessionExpiry(r) if !ok || time.Until(expires) > adminRenewWithin { return } session, err := s.newBrowserSession(adminSessionTTL) if err != nil { s.loggerFor(r.Context()).Error("installer session renewal failed", "error", err) return } s.setBrowserSessionCookie(w, session, adminSessionTTL) } func (s *Server) setInstallerCookie(w http.ResponseWriter, value string) { s.setBrowserSessionCookie(w, value, installerSessionTTL) } func (s *Server) setBrowserSessionCookie(w http.ResponseWriter, value string, ttl time.Duration) { http.SetCookie(w, &http.Cookie{ Name: installerCookieName, Value: value, Path: "/", MaxAge: int(ttl / time.Second), HttpOnly: true, Secure: true, SameSite: http.SameSiteStrictMode, }) } func (s *Server) clearInstallerCookie(w http.ResponseWriter) { http.SetCookie(w, &http.Cookie{ Name: installerCookieName, Path: "/", MaxAge: -1, HttpOnly: true, Secure: true, SameSite: http.SameSiteStrictMode, }) } func (s *Server) releaseAccessToken(filename string) string { if len(s.installerSecret()) == 0 || !releaseFilenamePattern.MatchString(filename) { return "" } return base64.RawURLEncoding.EncodeToString( s.signInstallerValue("release", []byte(filename)), ) } func (s *Server) signedReleasePath(filename string) string { token := s.releaseAccessToken(filename) if token == "" { return "" } return "/updates/" + filename + "?access=" + url.QueryEscape(token) } func (s *Server) allowedReleaseDownload(r *http.Request, filename string) bool { if s.validInstallerSession(r) { return true } expected := s.releaseAccessToken(filename) presented := strings.TrimSpace(r.URL.Query().Get("access")) return expected != "" && hmac.Equal([]byte(presented), []byte(expected)) } func (s *Server) handleInstallLogin(w http.ResponseWriter, r *http.Request) { if len(s.installerSecret()) == 0 { http.NotFound(w, r) return } // Password authentication necessarily registers a device with Emby. Do not start // it unless the service credential needed to remove that temporary record exists. if s.cfg.SyncAPIKey == "" { s.loggerFor(r.Context()).Error("installer login unavailable: MEMBY_SYNC_API_KEY is not configured") s.renderAccessLogin(w, r, "Sign-in is temporarily unavailable.", http.StatusServiceUnavailable, "/install") return } r.Body = http.MaxBytesReader(w, r.Body, 8<<10) if err := r.ParseForm(); err != nil { s.renderAccessLogin(w, r, "Sign-in failed.", http.StatusBadRequest, "/install") return } next := cleanInstallerDestination(r.FormValue("next")) username := strings.TrimSpace(r.FormValue("username")) password := r.FormValue("password") if username == "" || password == "" { s.renderAccessLogin(w, r, "Enter your username and password.", http.StatusBadRequest, next) return } // Gateway, not a television: this sign-in is the admin console or the web installer // checking a password, so Emby records it under the gateway's own client name. auth, err := s.emby.Authenticate( r.Context(), username, password, emby.Credentials{ DeviceID: installerDeviceID, DeviceName: s.installerDeviceName(), Gateway: true, }, ) if err != nil { s.loggerFor(r.Context()).Warn("installer Emby authentication failed", "username", username) s.renderAccessLogin(w, r, "Sign-in failed.", http.StatusUnauthorized, next) return } // Authentication creates an Emby access token. The installer needs only proof that // it succeeded, so retire the upstream session immediately and never persist it. if err := s.emby.Logout(r.Context(), emby.Credentials{ UserID: auth.User.ID, Token: auth.AccessToken, DeviceID: installerDeviceID, DeviceName: s.installerDeviceName(), Gateway: true, }); err != nil { s.loggerFor(r.Context()).Warn("installer Emby session cleanup failed", "error", err) } if err := s.emby.DeleteDevice(r.Context(), emby.Credentials{ UserID: s.cfg.SyncUserID, Token: s.cfg.SyncAPIKey, DeviceID: "memby-gateway", DeviceName: s.gatewayDeviceName(), Gateway: true, }, installerDeviceID); err != nil { s.loggerFor(r.Context()).Error("installer Emby device cleanup failed", "error", err) s.renderAccessLogin(w, r, "Sign-in is temporarily unavailable.", http.StatusBadGateway, next) return } ttl := installerSessionTTL if strings.HasPrefix(next, "/admin/") { ttl = adminSessionTTL } session, err := s.newBrowserSession(ttl) if err != nil { s.loggerFor(r.Context()).Error("installer session generation failed", "error", err) writeError(w, http.StatusInternalServerError, "could not start installer session") return } s.setBrowserSessionCookie(w, session, ttl) http.Redirect(w, r, next, http.StatusSeeOther) } func (s *Server) handleInstallLogout(w http.ResponseWriter, r *http.Request) { s.clearInstallerCookie(w) http.Redirect(w, r, "/install", http.StatusSeeOther) } func cleanInstallerDestination(value string) string { value = strings.TrimSpace(value) if value == "/admin/" { return "/admin/" } if strings.HasPrefix(value, "/admin/") { page := strings.TrimPrefix(value, "/admin/") if adminPages[page] { return value } } return "/install" }