mirror of
https://github.com/navidrome/navidrome.git
synced 2026-03-04 06:35:52 +00:00
190 lines
5.8 KiB
Go
190 lines
5.8 KiB
Go
package plugins
|
|
|
|
import (
|
|
"io"
|
|
"net/http"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/go-chi/httprate"
|
|
"github.com/navidrome/navidrome/conf"
|
|
"github.com/navidrome/navidrome/log"
|
|
"github.com/navidrome/navidrome/model"
|
|
"github.com/navidrome/navidrome/model/request"
|
|
"github.com/navidrome/navidrome/plugins/capabilities"
|
|
)
|
|
|
|
const maxEndpointBodySize = 1 << 20 // 1MB
|
|
|
|
// SubsonicAuthValidator validates Subsonic authentication and returns the user.
|
|
// This is set by the cmd/ package to avoid import cycles (plugins -> server/subsonic).
|
|
type SubsonicAuthValidator func(ds model.DataStore, r *http.Request) (*model.User, error)
|
|
|
|
// NativeAuthMiddleware is an HTTP middleware that authenticates using JWT tokens.
|
|
// This is set by the cmd/ package to avoid import cycles (plugins -> server).
|
|
type NativeAuthMiddleware func(ds model.DataStore) func(next http.Handler) http.Handler
|
|
|
|
// NewEndpointRouter creates an HTTP handler that dispatches requests to plugin endpoints.
|
|
// It should be mounted at both /ext and /rest/ext. The handler uses a catch-all pattern
|
|
// because Chi does not support adding routes after startup, and plugins can be loaded/unloaded
|
|
// at runtime. Plugin lookup happens per-request under RLock.
|
|
func NewEndpointRouter(manager *Manager, ds model.DataStore, subsonicAuth SubsonicAuthValidator, nativeAuth NativeAuthMiddleware) http.Handler {
|
|
r := chi.NewRouter()
|
|
|
|
// Apply rate limiting if configured
|
|
if conf.Server.Plugins.EndpointRequestLimit > 0 {
|
|
r.Use(httprate.LimitByIP(conf.Server.Plugins.EndpointRequestLimit, conf.Server.Plugins.EndpointRequestWindow))
|
|
}
|
|
|
|
h := &endpointHandler{
|
|
manager: manager,
|
|
ds: ds,
|
|
subsonicAuth: subsonicAuth,
|
|
nativeAuth: nativeAuth,
|
|
}
|
|
r.HandleFunc("/{pluginID}/*", h.ServeHTTP)
|
|
r.HandleFunc("/{pluginID}", h.ServeHTTP)
|
|
return r
|
|
}
|
|
|
|
type endpointHandler struct {
|
|
manager *Manager
|
|
ds model.DataStore
|
|
subsonicAuth SubsonicAuthValidator
|
|
nativeAuth NativeAuthMiddleware
|
|
}
|
|
|
|
func (h *endpointHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
pluginID := chi.URLParam(r, "pluginID")
|
|
|
|
h.manager.mu.RLock()
|
|
p, ok := h.manager.plugins[pluginID]
|
|
h.manager.mu.RUnlock()
|
|
|
|
if !ok || !hasCapability(p.capabilities, CapabilityHTTPEndpoint) {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
|
|
if p.manifest.Permissions == nil || p.manifest.Permissions.Endpoints == nil {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
|
|
authType := p.manifest.Permissions.Endpoints.Auth
|
|
|
|
switch authType {
|
|
case EndpointsPermissionAuthSubsonic:
|
|
h.serveWithSubsonicAuth(w, r, p)
|
|
case EndpointsPermissionAuthNative:
|
|
h.serveWithNativeAuth(w, r, p)
|
|
case EndpointsPermissionAuthNone:
|
|
h.dispatch(w, r, p)
|
|
default:
|
|
http.Error(w, "Unknown auth type", http.StatusInternalServerError)
|
|
}
|
|
}
|
|
|
|
func (h *endpointHandler) serveWithSubsonicAuth(w http.ResponseWriter, r *http.Request, p *plugin) {
|
|
usr, err := h.subsonicAuth(h.ds, r)
|
|
if err != nil {
|
|
log.Warn(r.Context(), "Plugin endpoint auth failed", "plugin", p.name, "auth", "subsonic", err)
|
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
ctx := request.WithUser(r.Context(), *usr)
|
|
h.dispatch(w, r.WithContext(ctx), p)
|
|
}
|
|
|
|
func (h *endpointHandler) serveWithNativeAuth(w http.ResponseWriter, r *http.Request, p *plugin) {
|
|
h.nativeAuth(h.ds)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
h.dispatch(w, r, p)
|
|
})).ServeHTTP(w, r)
|
|
}
|
|
|
|
func (h *endpointHandler) dispatch(w http.ResponseWriter, r *http.Request, p *plugin) {
|
|
ctx := r.Context()
|
|
|
|
// Check user authorization and extract user info (skip for auth:"none")
|
|
var httpUser *capabilities.HTTPUser
|
|
if p.manifest.Permissions.Endpoints.Auth != EndpointsPermissionAuthNone {
|
|
user, ok := request.UserFrom(ctx)
|
|
if !ok {
|
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
if !p.userAccess.IsAllowed(user.ID) {
|
|
log.Warn(ctx, "Plugin endpoint access denied", "plugin", p.name, "user", user.UserName)
|
|
http.Error(w, "Forbidden", http.StatusForbidden)
|
|
return
|
|
}
|
|
httpUser = &capabilities.HTTPUser{
|
|
ID: user.ID,
|
|
Username: user.UserName,
|
|
Name: user.Name,
|
|
IsAdmin: user.IsAdmin,
|
|
}
|
|
}
|
|
|
|
// Read request body with size limit
|
|
body, err := io.ReadAll(io.LimitReader(r.Body, maxEndpointBodySize))
|
|
if err != nil {
|
|
log.Error(ctx, "Failed to read request body", "plugin", p.name, err)
|
|
http.Error(w, "Failed to read request body", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Build the plugin request
|
|
// Normalize path: both /ext/plugin and /ext/plugin/ map to ""
|
|
rawPath := chi.URLParam(r, "*")
|
|
relPath := ""
|
|
if rawPath != "" {
|
|
relPath = "/" + rawPath
|
|
}
|
|
|
|
pluginReq := capabilities.HTTPHandleRequest{
|
|
Method: r.Method,
|
|
Path: relPath,
|
|
Query: r.URL.RawQuery,
|
|
Headers: r.Header,
|
|
Body: body,
|
|
User: httpUser,
|
|
}
|
|
|
|
// Call the plugin using binary framing for []byte Body fields
|
|
resp, err := callPluginFunctionRaw(
|
|
ctx, p, FuncHTTPHandleRequest,
|
|
pluginReq, pluginReq.Body,
|
|
func(r *capabilities.HTTPHandleResponse, raw []byte) { r.Body = raw },
|
|
)
|
|
if err != nil {
|
|
log.Error(ctx, "Plugin endpoint call failed", "plugin", p.name, "path", relPath, err)
|
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Write response headers from plugin
|
|
for key, values := range resp.Headers {
|
|
for _, v := range values {
|
|
w.Header().Add(key, v)
|
|
}
|
|
}
|
|
|
|
// Security hardening: override any plugin-set security headers
|
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
|
w.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; img-src data:; sandbox")
|
|
|
|
// Write status code (default to 200)
|
|
status := int(resp.Status)
|
|
if status == 0 {
|
|
status = http.StatusOK
|
|
}
|
|
w.WriteHeader(status)
|
|
|
|
// Write response body
|
|
if len(resp.Body) > 0 {
|
|
if _, err := w.Write(resp.Body); err != nil {
|
|
log.Error(ctx, "Failed to write plugin endpoint response", "plugin", p.name, err)
|
|
}
|
|
}
|
|
}
|