bd362ab384
Five admin endpoints under existing RequireAdmin middleware:
- GET /api/admin/invites — list active + recently-redeemed
- POST /api/admin/invites — generate 24h invite, returns
{token, expires_at, ...}. Audits ActionInviteCreate.
- DELETE /api/admin/invites/{token} — revoke unredeemed invite.
Audits ActionInviteRevoke.
- GET /api/admin/users — list all users (id, username,
display_name, is_admin, created_at).
- PUT /api/admin/users/{id}/admin — toggle is_admin with
last-admin guard. Audits ActionPromoteAdmin / ActionDemoteAdmin.
Last-admin guard counts admins, refuses demotion of the sole
admin with 409 'last_admin'. Race window between count and update
is acceptable for v1 — worst case is 'no admins left,' which the
env-driven bootstrap or CLI reset can recover from. Common path
('admin demotes themselves') is now blocked.
Adds ListUsers, CountAdmins, UpdateUserAdmin sqlc queries to
users.sql.
Tests cover: invite create/list/delete round-trip, non-admin gets
403, user list, promote happy path, last-admin demotion refused,
two-admins demotion allowed.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
171 lines
7.8 KiB
Go
171 lines
7.8 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/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/playevents"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
|
|
"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, lidarrCfg *lidarrconfig.Service, lidarrReqs *lidarrrequests.Service, lidarrQuar *lidarrquarantine.Service, tracksSvc *tracks.Service, playlistsSvc *playlists.Service, coverEnricher *coverart.Enricher, coverBackfillCap int, coverSettings *coverart.SettingsService, scanner *library.Scanner, scanCfg library.RunScanConfig, scheduler *library.Scheduler, dataDir string) {
|
|
rng := rand.New(rand.NewSource(rand.Int63()))
|
|
h := &handlers{
|
|
pool: pool, logger: logger, events: events, recCfg: recCfg,
|
|
rng: rng.Float64,
|
|
lidarrCfg: lidarrCfg,
|
|
lidarrRequests: lidarrReqs,
|
|
lidarrQuarantine: lidarrQuar,
|
|
tracks: tracksSvc,
|
|
playlists: playlistsSvc,
|
|
coverart: coverEnricher,
|
|
coverArtBackfillCap: coverBackfillCap,
|
|
coverSettings: coverSettings,
|
|
scanner: scanner,
|
|
scanCfg: scanCfg,
|
|
scheduler: scheduler,
|
|
dataDir: dataDir,
|
|
}
|
|
|
|
r.Route("/api", func(api chi.Router) {
|
|
api.Post("/auth/login", h.handleLogin)
|
|
api.Post("/auth/register", h.handleRegister)
|
|
api.Group(func(authed chi.Router) {
|
|
authed.Use(auth.RequireUser(pool))
|
|
authed.Post("/auth/logout", h.handleLogout)
|
|
authed.Get("/me", h.handleGetMe)
|
|
authed.Get("/me/system-playlists-status", h.handleGetSystemPlaylistsStatus)
|
|
authed.Get("/me/listenbrainz", h.handleGetListenBrainz)
|
|
authed.Put("/me/listenbrainz", h.handlePutListenBrainz)
|
|
authed.Get("/me/history", h.handleGetMyHistory)
|
|
|
|
authed.Get("/artists", h.handleListArtists)
|
|
authed.Get("/artists/{id}", h.handleGetArtist)
|
|
authed.Get("/artists/{id}/tracks", h.handleGetArtistTracks)
|
|
authed.Get("/albums/{id}", h.handleGetAlbum)
|
|
authed.Get("/albums/{id}/cover", h.handleGetCover)
|
|
authed.Get("/library/albums", h.handleListLibraryAlbums)
|
|
authed.Get("/tracks/{id}", h.handleGetTrack)
|
|
authed.Get("/tracks/{id}/stream", h.handleGetStream)
|
|
authed.Get("/search", h.handleSearch)
|
|
authed.Get("/radio", h.handleRadio)
|
|
authed.Get("/discover/suggestions", h.handleListSuggestions)
|
|
authed.Get("/home", h.handleGetHome)
|
|
authed.Post("/events", h.handleEvents)
|
|
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)
|
|
|
|
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.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("/scan/status", h.handleGetScanStatus)
|
|
admin.Post("/scan/run", h.handleTriggerScan)
|
|
admin.Get("/scan/schedule", h.handleGetScanSchedule)
|
|
admin.Patch("/scan/schedule", h.handlePatchScanSchedule)
|
|
|
|
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.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)
|
|
})
|
|
|
|
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/discover/refresh", h.handleDiscoverRefresh)
|
|
authed.Post("/playlists/system/for-you/refresh", h.handleForYouRefresh)
|
|
})
|
|
})
|
|
}
|
|
|
|
type handlers struct {
|
|
pool *pgxpool.Pool
|
|
logger *slog.Logger
|
|
events *playevents.Writer
|
|
recCfg config.RecommendationConfig
|
|
rng func() float64
|
|
lidarrCfg *lidarrconfig.Service
|
|
lidarrRequests *lidarrrequests.Service
|
|
lidarrQuarantine *lidarrquarantine.Service
|
|
tracks *tracks.Service
|
|
playlists *playlists.Service
|
|
coverart *coverart.Enricher
|
|
coverArtBackfillCap int
|
|
coverSettings *coverart.SettingsService
|
|
scanner *library.Scanner
|
|
scanCfg library.RunScanConfig
|
|
scheduler *library.Scheduler
|
|
dataDir string
|
|
}
|