Fixes the defect the operator spotted in #370 immediately after it shipped: auth.ClientIP ignored X-Forwarded-For whenever RemoteAddr was public, so a proxy on a public address — a separate host, or a CDN, i.e. anyone running this publicly, since public means TLS means a proxy — recorded the PROXY for every session. created_ip and last_ip were then always equal and the "Address changed" signal could never fire. The feature looked like it worked and reported nothing. Replaced with the standard trusted-hop model (Rails, Caddy, Traefik, nginx). XFF grows left-to-right as each proxy appends the peer it received from, so for client -> CDN -> own-proxy -> app the app sees [client, CDN] with RemoteAddr = own-proxy, and the client sits at XFF[len - hops]: 0 RemoteAddr, XFF ignored — no proxy 1 the address your own proxy observed 2 through a CDN in front of your proxy Default 1, per the operator: publicly reachable means a TLS terminator in front. The cost is real and stated rather than hidden. hops >= 1 DECLARES that a proxy exists; set it with no proxy, or deeper than the actual chain, and the index reaches attacker-supplied entries, letting a visitor choose which address their own session shows — defeating exactly the detection #370 is for. That's inherent to the model, which is why 0 is a first-class value and the admin card says "count your proxies, don't guess high" instead of just exposing a number. Both mis-set shapes are pinned by tests so they stay known consequences rather than surprises. Migration 0053 + internal/netsettings, cached under an RWMutex. That's not an optimisation: ClientIP runs in RequireUser for every authenticated request, so a per-request query would put the database on the critical path of the whole API. New() always returns a usable service so a boot-time DB hiccup degrades to the default instead of breaking that path (rule #131), and Hops() is nil-safe because test routers construct middleware without it. RequireUser now takes a func() int rather than an int — the value is operator-editable at runtime while the middleware is built once at boot, and reading it per request is what makes a save take effect with no restart (rule #25). The admin card is verifiable, not just configurable: it reports the address the CURRENT setting resolves THIS request to, the raw forwarded chain, and the socket peer — so you set the number, save, and confirm the address matches the machine you're on. It also counts the arriving chain and says how many proxies that implies. GET/PUT both return that payload, PUT recomputed under the new value, so the effect is visible without a reload. Also fixes styling in the #370 card that CI could not catch: text-destructive and bg-destructive don't exist in this Tailwind config — the palette is colors.action.destructive — so the "Address changed" warning and the sign-out-others button were rendering unstyled. Both now use text-action-destructive / bg-action-destructive / text-action-fg. Not done here: requestlog.go still logs raw RemoteAddr and will disagree with the sessions UI about who connected. Left for its own change.
285 lines
14 KiB
Go
285 lines
14 KiB
Go
// Package api implements Minstrel's native JSON surface under /api. It is
|
|
// consumed by the built-in web SPA and (eventually) the Flutter client.
|
|
// Subsonic-compatible endpoints under /rest are intentionally separate —
|
|
// see internal/subsonic — and the two packages must not depend on each other.
|
|
package api
|
|
|
|
import (
|
|
"log/slog"
|
|
"math/rand"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/config"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/coverart"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/eventbus"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrquarantine"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/mailer"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/netsettings"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/recsettings"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/tags"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/tracks"
|
|
)
|
|
|
|
// Mount attaches /api/* handlers to r. Public endpoints (login) are outside
|
|
// RequireUser; everything else is gated by the middleware. The events writer
|
|
// is shared with the Subsonic mount so /rest/scrobble feeds the same store.
|
|
func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playevents.Writer, recCfg config.RecommendationConfig, recSettings *recsettings.Service, lidarrCfg *lidarrconfig.Service, lidarrReqs *lidarrrequests.Service, lidarrQuar *lidarrquarantine.Service, tracksSvc *tracks.Service, playlistsSvc *playlists.Service, coverEnricher *coverart.Enricher, coverSettings *coverart.SettingsService, tagSettings *tags.SettingsService, scanner *library.Scanner, scanCfg library.RunScanConfig, dataDir string, sender mailer.Sender, bus *eventbus.Bus, playlistScheduler *playlists.Scheduler, streamSecret []byte, netSettings *netsettings.Service) {
|
|
rng := rand.New(rand.NewSource(rand.Int63()))
|
|
h := &handlers{
|
|
pool: pool, logger: logger, events: events, recCfg: recCfg,
|
|
recSettings: recSettings,
|
|
rng: rng.Float64,
|
|
lidarrCfg: lidarrCfg,
|
|
lidarrRequests: lidarrReqs,
|
|
lidarrQuarantine: lidarrQuar,
|
|
tracks: tracksSvc,
|
|
playlists: playlistsSvc,
|
|
coverart: coverEnricher,
|
|
coverSettings: coverSettings,
|
|
tagSettings: tagSettings,
|
|
scanner: scanner,
|
|
scanCfg: scanCfg,
|
|
dataDir: dataDir,
|
|
mailer: sender,
|
|
eventbus: bus,
|
|
playlistScheduler: playlistScheduler,
|
|
streamSecret: streamSecret,
|
|
netSettings: netSettings,
|
|
}
|
|
|
|
r.Route("/api", func(api chi.Router) {
|
|
api.Post("/auth/login", h.handleLogin)
|
|
api.Post("/auth/register", h.handleRegister)
|
|
api.Post("/auth/forgot-password", h.handleForgotPassword)
|
|
api.Post("/auth/reset-password", h.handleResetPassword)
|
|
|
|
// Stream lives outside authed.Group so it can accept EITHER a
|
|
// session (resolved by the OptionalUser middleware) OR a signed
|
|
// query token (UPnP / Sonos path; see streamAuthOk). The
|
|
// middleware attaches user to context when a valid cookie /
|
|
// bearer is present but does NOT 401 on absence; the handler's
|
|
// own streamAuthOk performs the actual auth check. See the
|
|
// design at
|
|
// docs/superpowers/specs/2026-06-03-android-output-picker-upnp-design.md.
|
|
api.With(auth.OptionalUser(pool, logger)).Get("/tracks/{id}/stream", h.handleGetStream)
|
|
// Extension-bearing alias so Sonos's URL probe can identify the
|
|
// audio format from the path. The {ext} param is consumed by chi
|
|
// and ignored by the handler (which keys off {id}). See task #610.
|
|
api.With(auth.OptionalUser(pool, logger)).Get("/tracks/{id}/stream.{ext}", h.handleGetStream)
|
|
|
|
api.Group(func(authed chi.Router) {
|
|
authed.Use(auth.RequireUser(pool, netSettings.Hops))
|
|
authed.Post("/auth/logout", h.handleLogout)
|
|
authed.Get("/me", h.handleGetMe)
|
|
authed.Get("/me/system-playlists-status", h.handleGetSystemPlaylistsStatus)
|
|
authed.Get("/me/recommendation-metrics", h.handleGetRecommendationMetrics)
|
|
authed.Get("/me/listenbrainz", h.handleGetListenBrainz)
|
|
authed.Put("/me/listenbrainz", h.handlePutListenBrainz)
|
|
authed.Get("/me/history", h.handleGetMyHistory)
|
|
authed.Put("/me/password", h.handleChangePassword)
|
|
authed.Put("/me/profile", h.handleUpdateMyProfile)
|
|
authed.Put("/me/timezone", h.handlePutTimezone)
|
|
authed.Get("/me/api-token", h.handleGetMyAPIToken)
|
|
authed.Post("/me/api-token", h.handleRegenerateMyAPIToken)
|
|
authed.Get("/me/sessions", h.handleListMySessions)
|
|
authed.Delete("/me/sessions/{id}", h.handleRevokeMySession)
|
|
authed.Post("/me/sessions/logout-others", h.handleRevokeMyOtherSessions)
|
|
|
|
authed.Get("/artists", h.handleListArtists)
|
|
authed.Get("/artists/{id}", h.handleGetArtist)
|
|
authed.Get("/artists/{id}/tracks", h.handleGetArtistTracks)
|
|
authed.Get("/artists/{id}/similar", h.handleGetSimilarArtists)
|
|
authed.Get("/artists/{id}/top-tracks", h.handleGetArtistTopTracks)
|
|
authed.Get("/albums/{id}", h.handleGetAlbum)
|
|
authed.Get("/albums/{id}/cover", h.handleGetCover)
|
|
authed.Get("/library/shuffle", h.handleLibraryShuffle)
|
|
authed.Get("/library/albums", h.handleListLibraryAlbums)
|
|
authed.Get("/library/sync", h.handleLibrarySync)
|
|
authed.Get("/tracks/{id}", h.handleGetTrack)
|
|
// /tracks/{id}/stream is mounted above with OptionalUser so
|
|
// it can accept either a session or a signed token.
|
|
authed.Get("/search", h.handleSearch)
|
|
authed.Get("/radio", h.handleRadio)
|
|
authed.Get("/discover/suggestions", h.handleListSuggestions)
|
|
// Snooze = "not right now", time-boxed and self-expiring
|
|
// (#2374). Not a dislike — see the migration for why.
|
|
authed.Post("/discover/suggestions/{mbid}/snooze", h.handleSnoozeSuggestion)
|
|
authed.Delete("/discover/suggestions/{mbid}/snooze", h.handleUnsnoozeSuggestion)
|
|
authed.Get("/discover/snoozes", h.handleListSuggestionSnoozes)
|
|
authed.Get("/home", h.handleGetHome)
|
|
authed.Get("/home/index", h.handleGetHomeIndex)
|
|
authed.Post("/events", h.handleEvents)
|
|
authed.Get("/events/stream", h.handleEventsStream)
|
|
// UPnP / Sonos cast slice: issue a short-lived HMAC stream URL
|
|
// the speaker can fetch without the user's session. See the
|
|
// design at
|
|
// docs/superpowers/specs/2026-06-03-android-output-picker-upnp-design.md.
|
|
authed.Post("/cast/stream-token", h.handleCastStreamToken)
|
|
authed.Post("/likes/tracks/{id}", h.handleLikeTrack)
|
|
authed.Delete("/likes/tracks/{id}", h.handleUnlikeTrack)
|
|
authed.Post("/likes/albums/{id}", h.handleLikeAlbum)
|
|
authed.Delete("/likes/albums/{id}", h.handleUnlikeAlbum)
|
|
authed.Post("/likes/artists/{id}", h.handleLikeArtist)
|
|
authed.Delete("/likes/artists/{id}", h.handleUnlikeArtist)
|
|
authed.Get("/likes/tracks", h.handleListLikedTracks)
|
|
authed.Get("/likes/albums", h.handleListLikedAlbums)
|
|
authed.Get("/likes/artists", h.handleListLikedArtists)
|
|
authed.Get("/likes/ids", h.handleGetLikedIDs)
|
|
|
|
authed.Get("/lidarr/search", h.handleLidarrSearch)
|
|
|
|
authed.Post("/requests", h.handleCreateRequest)
|
|
authed.Get("/requests", h.handleListRequests)
|
|
authed.Get("/requests/{id}", h.handleGetRequest)
|
|
authed.Delete("/requests/{id}", h.handleCancelRequest)
|
|
|
|
authed.Post("/quarantine", h.handleFlag)
|
|
authed.Delete("/quarantine/{track_id}", h.handleUnflag)
|
|
authed.Get("/quarantine/mine", h.handleListMyQuarantine)
|
|
|
|
// Client-reported playback errors (zero-duration tracks,
|
|
// load failures). Admin-only inbox; any user can report.
|
|
authed.Post("/playback-errors", h.handleReportPlaybackError)
|
|
|
|
// Device diagnostics ingest (M9). Any signed-in user can
|
|
// POST a batch, but events are only stored when the
|
|
// account's debug_mode_enabled flag is on (handler no-ops
|
|
// otherwise). Admin views live under /admin/diagnostics.
|
|
authed.Post("/diagnostics", h.handleReportDiagnostics)
|
|
|
|
// Self-hosted in-app update channel (#397). Auth-gated to
|
|
// prevent anonymous bandwidth abuse on the APK stream;
|
|
// /apk additionally per-user rate-limited.
|
|
authed.Get("/client/version", h.handleClientVersion)
|
|
authed.Get("/client/apk", h.handleClientAPK)
|
|
|
|
authed.Route("/admin", func(admin chi.Router) {
|
|
admin.Use(auth.RequireAdmin())
|
|
admin.Get("/lidarr/config", h.handleGetLidarrConfig)
|
|
admin.Put("/lidarr/config", h.handlePutLidarrConfig)
|
|
admin.Post("/lidarr/test", h.handleTestLidarrConnection)
|
|
admin.Get("/lidarr/quality-profiles", h.handleListQualityProfiles)
|
|
admin.Get("/lidarr/metadata-profiles", h.handleListMetadataProfiles)
|
|
admin.Get("/lidarr/root-folders", h.handleListRootFolders)
|
|
admin.Get("/requests", h.handleListAdminRequests)
|
|
admin.Post("/requests/{id}/approve", h.handleApproveRequest)
|
|
admin.Post("/requests/{id}/reject", h.handleRejectRequest)
|
|
|
|
admin.Get("/quarantine", h.handleListAdminQuarantine)
|
|
admin.Post("/quarantine/{track_id}/resolve", h.handleResolveQuarantine)
|
|
|
|
admin.Get("/playback-errors", h.handleListAdminPlaybackErrors)
|
|
admin.Post("/playback-errors/{id}/resolve", h.handleResolvePlaybackError)
|
|
admin.Post("/quarantine/{track_id}/delete-file", h.handleDeleteQuarantineFile)
|
|
admin.Post("/quarantine/{track_id}/delete-via-lidarr", h.handleDeleteQuarantineViaLidarr)
|
|
admin.Get("/quarantine/actions", h.handleListQuarantineActions)
|
|
|
|
admin.Delete("/tracks/{id}", h.handleRemoveTrack)
|
|
|
|
admin.Post("/albums/{id}/cover/refetch", h.handleAdminAlbumRefetchCover)
|
|
admin.Post("/covers/refetch-missing", h.handleAdminBulkRefetchCovers)
|
|
|
|
admin.Get("/network-settings", h.handleGetNetworkSettings)
|
|
admin.Put("/network-settings", h.handleUpdateNetworkSettings)
|
|
|
|
admin.Get("/scan/status", h.handleGetScanStatus)
|
|
admin.Post("/scan/run", h.handleTriggerScan)
|
|
|
|
admin.Get("/library/coverage", h.handleGetLibraryCoverage)
|
|
|
|
admin.Get("/invites", h.handleListInvites)
|
|
admin.Post("/invites", h.handleCreateInvite)
|
|
admin.Delete("/invites/{token}", h.handleDeleteInvite)
|
|
admin.Get("/users", h.handleAdminListUsers)
|
|
admin.Put("/users/{id}/admin", h.handleUpdateUserAdmin)
|
|
admin.Post("/users", h.handleAdminCreateUser)
|
|
admin.Delete("/users/{id}", h.handleAdminDeleteUser)
|
|
admin.Post("/users/{id}/reset-password", h.handleAdminResetPassword)
|
|
admin.Put("/users/{id}/auto-approve", h.handleAdminAutoApproveToggle)
|
|
admin.Put("/users/{id}/debug-mode", h.handleAdminDebugModeToggle)
|
|
|
|
// Device diagnostics timeline + device overview (M9).
|
|
admin.Get("/diagnostics", h.handleListAdminDiagnostics)
|
|
admin.Get("/diagnostics/devices", h.handleListAdminDiagnosticDevices)
|
|
|
|
admin.Get("/cover-sources", h.handleListCoverSources)
|
|
admin.Patch("/cover-sources/{provider_id}", h.handleUpdateCoverSource)
|
|
admin.Post("/cover-sources/{provider_id}/test", h.handleTestCoverSource)
|
|
admin.Post("/cover-sources/research", h.handleResearchMissingArt)
|
|
|
|
admin.Get("/tag-sources", h.handleListTagSources)
|
|
admin.Patch("/tag-sources/{provider_id}", h.handleUpdateTagSource)
|
|
admin.Post("/tag-sources/{provider_id}/test", h.handleTestTagSource)
|
|
admin.Post("/tag-sources/research", h.handleResearchTags)
|
|
|
|
admin.Get("/smtp-config", h.handleGetSMTPConfig)
|
|
admin.Put("/smtp-config", h.handleUpdateSMTPConfig)
|
|
admin.Post("/smtp-config/test", h.handleTestSMTPConfig)
|
|
|
|
// Recommendation tuning lab (#1250): scoring-weight
|
|
// profiles + taste-build knobs, DB-backed, live effect.
|
|
admin.Get("/recommendation-tuning", h.handleGetRecommendationTuning)
|
|
admin.Patch("/recommendation-tuning/{scope}", h.handlePatchRecommendationTuning)
|
|
admin.Post("/recommendation-tuning/{scope}/reset", h.handleResetRecommendationTuning)
|
|
// Weekly outcome trends + knob-turn markers (#1251).
|
|
admin.Get("/recommendation-trends", h.handleGetRecommendationTrends)
|
|
})
|
|
|
|
authed.Get("/playlists", h.handleListPlaylists)
|
|
authed.Post("/playlists", h.handleCreatePlaylist)
|
|
authed.Get("/playlists/{id}", h.handleGetPlaylist)
|
|
authed.Patch("/playlists/{id}", h.handleUpdatePlaylist)
|
|
authed.Delete("/playlists/{id}", h.handleDeletePlaylist)
|
|
authed.Post("/playlists/{id}/tracks", h.handleAppendTracks)
|
|
authed.Delete("/playlists/{id}/tracks/{position}", h.handleRemovePlaylistTrack)
|
|
authed.Put("/playlists/{id}/tracks", h.handleReorderPlaylist)
|
|
authed.Get("/playlists/{id}/cover", h.handleGetPlaylistCover)
|
|
authed.Post("/playlists/system/{kind}/refresh", h.handleSystemPlaylistRefresh)
|
|
authed.Get("/playlists/system/{kind}/shuffle", h.handleSystemPlaylistShuffle)
|
|
})
|
|
})
|
|
}
|
|
|
|
type handlers struct {
|
|
pool *pgxpool.Pool
|
|
logger *slog.Logger
|
|
events *playevents.Writer
|
|
recCfg config.RecommendationConfig
|
|
recSettings *recsettings.Service
|
|
rng func() float64
|
|
lidarrCfg *lidarrconfig.Service
|
|
lidarrRequests *lidarrrequests.Service
|
|
lidarrQuarantine *lidarrquarantine.Service
|
|
tracks *tracks.Service
|
|
playlists *playlists.Service
|
|
coverart *coverart.Enricher
|
|
coverSettings *coverart.SettingsService
|
|
tagSettings *tags.SettingsService
|
|
scanner *library.Scanner
|
|
scanCfg library.RunScanConfig
|
|
dataDir string
|
|
mailer mailer.Sender
|
|
eventbus *eventbus.Bus
|
|
playlistScheduler *playlists.Scheduler
|
|
// netSettings caches the trusted reverse-proxy depth read by the auth
|
|
// middleware on every request and edited from the admin network card.
|
|
netSettings *netsettings.Service
|
|
// streamSecret is the HMAC key used by SignStreamToken /
|
|
// VerifyStreamToken to authenticate the UPnP-speaker stream path
|
|
// (see internal/api/stream_token.go and the design at
|
|
// docs/superpowers/specs/2026-06-03-android-output-picker-upnp-design.md).
|
|
// nil in slice 1; slice 2 wires the env-var-with-app_preferences-
|
|
// fallback loader. A nil secret leaves the cookie path intact and
|
|
// makes the token path unreachable (HMAC of empty key won't match
|
|
// anything a client mints), which is the desired slice-1 default.
|
|
streamSecret []byte
|
|
}
|