2026-08-02 22:10:19 +12:00
|
|
|
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
|
2026-08-10 08:37:08 +12:00
|
|
|
adminSessionTTL = 12 * time.Hour
|
2026-08-02 22:10:19 +12:00
|
|
|
installerDeviceID = "memby-web-installer"
|
2026-08-06 22:33:56 +12:00
|
|
|
|
2026-08-10 08:37:08 +12:00
|
|
|
// 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
|
2026-08-02 22:10:19 +12:00
|
|
|
)
|
|
|
|
|
|
2026-08-12 13:08:53 +12:00
|
|
|
// 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"
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-02 22:10:19 +12:00
|
|
|
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) {
|
2026-08-10 08:37:08 +12:00
|
|
|
return s.newBrowserSession(installerSessionTTL)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *Server) newBrowserSession(ttl time.Duration) (string, error) {
|
2026-08-02 22:10:19 +12:00
|
|
|
payload := make([]byte, 8+16)
|
2026-08-10 08:37:08 +12:00
|
|
|
binary.BigEndian.PutUint64(payload[:8], uint64(time.Now().Add(ttl).Unix()))
|
2026-08-02 22:10:19 +12:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-06 22:33:56 +12:00
|
|
|
// 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) {
|
2026-08-02 22:10:19 +12:00
|
|
|
if len(s.installerSecret()) == 0 {
|
2026-08-06 22:33:56 +12:00
|
|
|
return time.Time{}, false
|
2026-08-02 22:10:19 +12:00
|
|
|
}
|
|
|
|
|
cookie, err := r.Cookie(installerCookieName)
|
|
|
|
|
if err != nil {
|
2026-08-06 22:33:56 +12:00
|
|
|
return time.Time{}, false
|
2026-08-02 22:10:19 +12:00
|
|
|
}
|
|
|
|
|
parts := strings.Split(cookie.Value, ".")
|
|
|
|
|
if len(parts) != 2 {
|
2026-08-06 22:33:56 +12:00
|
|
|
return time.Time{}, false
|
2026-08-02 22:10:19 +12:00
|
|
|
}
|
|
|
|
|
payload, err := base64.RawURLEncoding.DecodeString(parts[0])
|
|
|
|
|
if err != nil || len(payload) != 24 {
|
2026-08-06 22:33:56 +12:00
|
|
|
return time.Time{}, false
|
2026-08-02 22:10:19 +12:00
|
|
|
}
|
|
|
|
|
signature, err := base64.RawURLEncoding.DecodeString(parts[1])
|
|
|
|
|
if err != nil || !hmac.Equal(signature, s.signInstallerValue("session", payload)) {
|
2026-08-06 22:33:56 +12:00
|
|
|
return time.Time{}, false
|
2026-08-02 22:10:19 +12:00
|
|
|
}
|
|
|
|
|
expires := int64(binary.BigEndian.Uint64(payload[:8]))
|
|
|
|
|
now := time.Now().Unix()
|
2026-08-10 08:37:08 +12:00
|
|
|
if expires <= now || expires > now+int64(adminSessionTTL/time.Second)+60 {
|
2026-08-06 22:33:56 +12:00
|
|
|
return time.Time{}, false
|
|
|
|
|
}
|
|
|
|
|
return time.Unix(expires, 0), true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *Server) validInstallerSession(r *http.Request) bool {
|
|
|
|
|
_, ok := s.installerSessionExpiry(r)
|
|
|
|
|
return ok
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-10 08:37:08 +12:00
|
|
|
// renewAdminSession slides a valid session's expiry forward. The TTL was absolute and
|
2026-08-06 22:33:56 +12:00
|
|
|
// nothing extended it, so an operator working the admin console was signed out from under
|
2026-08-10 08:37:08 +12:00
|
|
|
// themselves and the page's poll became a permanent "invalid admin
|
2026-08-06 22:33:56 +12:00
|
|
|
// 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.
|
2026-08-10 08:37:08 +12:00
|
|
|
func (s *Server) renewAdminSession(w http.ResponseWriter, r *http.Request) {
|
2026-08-06 22:33:56 +12:00
|
|
|
expires, ok := s.installerSessionExpiry(r)
|
2026-08-10 08:37:08 +12:00
|
|
|
if !ok || time.Until(expires) > adminRenewWithin {
|
2026-08-06 22:33:56 +12:00
|
|
|
return
|
|
|
|
|
}
|
2026-08-10 08:37:08 +12:00
|
|
|
session, err := s.newBrowserSession(adminSessionTTL)
|
2026-08-06 22:33:56 +12:00
|
|
|
if err != nil {
|
|
|
|
|
s.loggerFor(r.Context()).Error("installer session renewal failed", "error", err)
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-08-10 08:37:08 +12:00
|
|
|
s.setBrowserSessionCookie(w, session, adminSessionTTL)
|
2026-08-02 22:10:19 +12:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *Server) setInstallerCookie(w http.ResponseWriter, value string) {
|
2026-08-10 08:37:08 +12:00
|
|
|
s.setBrowserSessionCookie(w, value, installerSessionTTL)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *Server) setBrowserSessionCookie(w http.ResponseWriter, value string, ttl time.Duration) {
|
2026-08-02 22:10:19 +12:00
|
|
|
http.SetCookie(w, &http.Cookie{
|
|
|
|
|
Name: installerCookieName,
|
|
|
|
|
Value: value,
|
|
|
|
|
Path: "/",
|
2026-08-10 08:37:08 +12:00
|
|
|
MaxAge: int(ttl / time.Second),
|
2026-08-02 22:10:19 +12:00
|
|
|
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 == "" {
|
2026-08-06 22:33:56 +12:00
|
|
|
s.loggerFor(r.Context()).Error("installer login unavailable: MEMBY_SYNC_API_KEY is not configured")
|
2026-08-02 22:10:19 +12:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-12 13:08:53 +12:00
|
|
|
// 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.
|
2026-08-02 22:10:19 +12:00
|
|
|
auth, err := s.emby.Authenticate(
|
2026-08-12 13:08:53 +12:00
|
|
|
r.Context(), username, password,
|
|
|
|
|
emby.Credentials{
|
|
|
|
|
DeviceID: installerDeviceID, DeviceName: s.installerDeviceName(), Gateway: true,
|
|
|
|
|
},
|
2026-08-02 22:10:19 +12:00
|
|
|
)
|
|
|
|
|
if err != nil {
|
2026-08-06 22:33:56 +12:00
|
|
|
s.loggerFor(r.Context()).Warn("installer Emby authentication failed", "username", username)
|
2026-08-02 22:10:19 +12:00
|
|
|
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,
|
2026-08-12 13:08:53 +12:00
|
|
|
DeviceID: installerDeviceID, DeviceName: s.installerDeviceName(),
|
|
|
|
|
Gateway: true,
|
2026-08-02 22:10:19 +12:00
|
|
|
}); err != nil {
|
2026-08-06 22:33:56 +12:00
|
|
|
s.loggerFor(r.Context()).Warn("installer Emby session cleanup failed", "error", err)
|
2026-08-02 22:10:19 +12:00
|
|
|
}
|
|
|
|
|
if err := s.emby.DeleteDevice(r.Context(), emby.Credentials{
|
|
|
|
|
UserID: s.cfg.SyncUserID, Token: s.cfg.SyncAPIKey,
|
2026-08-12 13:08:53 +12:00
|
|
|
DeviceID: "memby-gateway", DeviceName: s.gatewayDeviceName(),
|
|
|
|
|
Gateway: true,
|
2026-08-02 22:10:19 +12:00
|
|
|
}, installerDeviceID); err != nil {
|
2026-08-06 22:33:56 +12:00
|
|
|
s.loggerFor(r.Context()).Error("installer Emby device cleanup failed", "error", err)
|
2026-08-02 22:10:19 +12:00
|
|
|
s.renderAccessLogin(w, r, "Sign-in is temporarily unavailable.",
|
|
|
|
|
http.StatusBadGateway, next)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-10 08:37:08 +12:00
|
|
|
ttl := installerSessionTTL
|
|
|
|
|
if strings.HasPrefix(next, "/admin/") {
|
|
|
|
|
ttl = adminSessionTTL
|
|
|
|
|
}
|
|
|
|
|
session, err := s.newBrowserSession(ttl)
|
2026-08-02 22:10:19 +12:00
|
|
|
if err != nil {
|
2026-08-06 22:33:56 +12:00
|
|
|
s.loggerFor(r.Context()).Error("installer session generation failed", "error", err)
|
2026-08-02 22:10:19 +12:00
|
|
|
writeError(w, http.StatusInternalServerError, "could not start installer session")
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-08-10 08:37:08 +12:00
|
|
|
s.setBrowserSessionCookie(w, session, ttl)
|
2026-08-02 22:10:19 +12:00
|
|
|
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"
|
|
|
|
|
}
|