Item-shaped results now omit empty fields and drop low-value metadata (overview, lyrics, file_path, creation_date, premiere_date, bitrate); absent numeric fields drop the key instead of emitting "". Player sessions report hh:mm:ss times only; playlists drop overview/date_created. The lyrics_or_description search parameter is removed, along with the unused DTO fields, and upstream Fields requests are slimmed to match. Module path renamed to git.i3omb.com/gronod/emby-mcp.
124 lines
3.2 KiB
Go
124 lines
3.2 KiB
Go
package mcphttp
|
|
|
|
import (
|
|
"net"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"git.i3omb.com/gronod/emby-mcp/internal/applog"
|
|
"git.i3omb.com/gronod/emby-mcp/internal/config"
|
|
)
|
|
|
|
// Home Assistant Supervisor networks. Traffic from Core and other add-ons
|
|
// comes from these ranges when the add-on is not on host_network.
|
|
var localNets = mustCIDRs(
|
|
"127.0.0.0/8",
|
|
"::1/128",
|
|
"172.30.32.0/23", // hassio
|
|
"172.30.33.0/24", // add-ons
|
|
)
|
|
|
|
func mustCIDRs(cidrs ...string) []*net.IPNet {
|
|
out := make([]*net.IPNet, 0, len(cidrs))
|
|
for _, c := range cidrs {
|
|
_, n, err := net.ParseCIDR(c)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
out = append(out, n)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func isLocalPeer(r *http.Request) bool {
|
|
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
|
if err != nil {
|
|
host = r.RemoteAddr
|
|
}
|
|
ip := net.ParseIP(host)
|
|
if ip == nil {
|
|
return false
|
|
}
|
|
if ip.IsLoopback() {
|
|
return true
|
|
}
|
|
for _, n := range localNets {
|
|
if n.Contains(ip) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// restrictLocalhost allows loopback and the Home Assistant container
|
|
// networks only. LAN clients (192.168/10/100) are rejected.
|
|
type statusWriter struct {
|
|
http.ResponseWriter
|
|
code int
|
|
}
|
|
|
|
func (w *statusWriter) WriteHeader(code int) {
|
|
w.code = code
|
|
w.ResponseWriter.WriteHeader(code)
|
|
}
|
|
|
|
func accessLog(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
sw := &statusWriter{ResponseWriter: w, code: http.StatusOK}
|
|
next.ServeHTTP(sw, r)
|
|
applog.Infof("rest %s %s from %s -> %d", r.Method, r.URL.Path, r.RemoteAddr, sw.code)
|
|
})
|
|
}
|
|
|
|
func restrictLocalhost(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if !isLocalPeer(r) {
|
|
applog.Warnf("rest rejected %s %s from %s (not HA-local)", r.Method, r.URL.Path, r.RemoteAddr)
|
|
http.Error(w, "forbidden: add-on is restricted to Home Assistant local network", http.StatusForbidden)
|
|
return
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
// injectConfiguredAuth applies add-on credentials when the client sent none.
|
|
func injectConfiguredAuth(cfg *config.Config, next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Header.Get("Authorization") == "" {
|
|
if cfg.Username != "" && cfg.Password != "" {
|
|
r.SetBasicAuth(cfg.Username, cfg.Password)
|
|
} else if cfg.APIKey != "" {
|
|
r.Header.Set("Authorization", "Bearer "+cfg.APIKey)
|
|
if cfg.UserID != "" {
|
|
r.Header.Set(HeaderUserID, cfg.UserID)
|
|
}
|
|
if cfg.Username != "" {
|
|
r.Header.Set(HeaderUsername, cfg.Username)
|
|
}
|
|
}
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
// WrapAccess applies local-network restriction and configured-credential
|
|
// injection around the HTTP mux.
|
|
func WrapAccess(cfg *config.Config, next http.Handler) http.Handler {
|
|
h := next
|
|
if cfg.RestrictToLocalhost && (cfg.Username != "" || cfg.APIKey != "") {
|
|
h = injectConfiguredAuth(cfg, h)
|
|
}
|
|
if cfg.RestrictToLocalhost {
|
|
h = restrictLocalhost(h)
|
|
}
|
|
return accessLog(h)
|
|
}
|
|
|
|
// HasConfiguredCreds reports whether the add-on has usable stored credentials.
|
|
func HasConfiguredCreds(cfg *config.Config) bool {
|
|
if cfg.Username != "" && cfg.Password != "" {
|
|
return true
|
|
}
|
|
return strings.TrimSpace(cfg.APIKey) != ""
|
|
}
|