Restructure backend into services and fix security, crash and sync issues

Security
- Serve character images through os.Root so names like ../ can't escape
  the characters folder
- Sync start and /sync/reset are POST only (legacy and /api/v1)
- /api/v1/token/cleanup requires a token, password compared in constant
  time, tokens no longer logged
- Echo's rate limiter replaces the custom one; TRUSTED_PROXIES enables
  X-Forwarded-For for reverse proxies

Crashes and races
- Sync state lives in SyncService with atomics and a mutex instead of
  unsynchronised globals (fixes concurrent map writes during full sync)
- Only one sync can run; /api/v1 sync and music routes are locked during
  sync like the legacy ones
- No log.Fatal outside startup: errors become 4xx/5xx responses
- Search uses an in-order character match instead of unescaped regexes
- Empty queue / no songs return 404 instead of panicking or looping

Behaviour
- Sync result is computed once and stays the same between polls
- Swagger and /openapi serve the embedded spec
- Shutdown stops HTTP before closing the database
- Expired tokens are deleted with a context that outlives the request
- Files are served with Range support and correct content types; audio
  has no write deadline

Performance
- Migration 000006 adds soundtrack.fingerprint (names, sizes, mtimes) so
  unchanged folders are skipped without reading any files
- Songs are reconciled per soundtrack with one query plus needed writes;
  renames keep play counts
- Moved MUSIC_PATH updates soundtrack and song paths
- .id cleanup only runs for changed soundtracks or a full sync
- Classic random uses one SQL query; Gitea release lookups are cached
  with a timeout

Structure
- internal/config reads all environment variables once
- sqlc emits a Querier interface; backend globals become services
  injected into handlers
- One route registration shared by legacy and /api/v1
- Removed dead code (dbHelper.go, unused middleware, duplicate Gitea
  functions, embedded Swagger UI copy) and lib/pq
- templ and swag pinned as go tools
This commit is contained in:
Sansan committed 2026-10-06 15:20:14 +02:00
1 parent 75b6f0c1cf
commit 090e794460
78 files changed
+2919 -6303

No files matched your search

+120 -236
View File
@@ -1,22 +1,23 @@
package server
import (
"net/http"
"time"
_ "music-server/cmd/docs" // registers the OpenAPI spec
"music-server/cmd/web"
"music-server/internal/logging"
"music-server/internal/server/middleware"
"net/http"
"sort"
"strings"
"time"
"github.com/a-h/templ"
"github.com/labstack/echo/v5"
echoMiddleware "github.com/labstack/echo/v5/middleware"
echoSwagger "github.com/swaggo/echo-swagger/v2"
"github.com/swaggo/swag"
"go.uber.org/zap"
)
// @Title MusicServer API
// @title MusicServer API
// @version 1.0
// @description API for the MusicServer application
// @termsOfService http://sanplex.xyz/terms/
@@ -27,19 +28,45 @@ import (
// @license.name MIT
// @license.url http://opensource.org/licenses/MIT
// @host localhost:8080
// @BasePath /
// handlers holds all HTTP handlers.
type handlers struct {
health *HealthHandler
version *VersionHandler
character *CharacterHandler
download *DownloadHandler
sync *SyncHandler
music *MusicHandler
statistics *StatisticsHandler
token *TokenHandler
}
func (s *Server) handlers() handlers {
return handlers{
health: NewHealthHandler(s.db),
version: NewVersionHandler(),
character: NewCharacterHandler(s.characters),
download: NewDownloadHandler(s.releases),
sync: NewSyncHandler(s.sync),
music: NewMusicHandler(s.music),
statistics: NewStatisticsHandler(s.stats),
token: NewTokenHandler(s.q, s.cfg.APIPassword, s.cfg.DisableSessionAuth),
}
}
func (s *Server) RegisterRoutes() http.Handler {
e := echo.New()
if len(s.cfg.TrustedProxies) > 0 {
var trust []echo.TrustOption
for _, ipNet := range s.cfg.TrustedProxies {
trust = append(trust, echo.TrustIPRange(ipNet))
}
e.IPExtractor = echo.ExtractIPFromXFFHeader(trust...)
}
// Serve OpenAPI spec at /openapi
e.GET("/openapi", echo.WrapHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
http.ServeFile(w, r, "cmd/docs/swagger.json")
})))
e.Use(logging.RequestLogger())
e.Use(echoMiddleware.Recover())
e.Use(echoMiddleware.CORSWithConfig(echoMiddleware.CORSConfig{
AllowOrigins: []string{"https://*", "http://*"},
AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"},
@@ -48,240 +75,97 @@ func (s *Server) RegisterRoutes() http.Handler {
MaxAge: 300,
}))
fileServer := http.FileServer(http.FS(web.Assets))
e.GET("/assets/*", echo.WrapHandler(fileServer))
e.GET("/search", echo.WrapHandler(templ.Handler(web.SearchForm())))
e.POST("/find", echo.WrapHandler(http.HandlerFunc(web.FindSoundtrackWebHandler)))
e.Static("/", "/frontend")
// Swagger UI
e.GET("/openapi", serveOpenAPI)
e.GET("/swagger/*", echoSwagger.WrapHandler)
// ============================================
// Legacy Endpoints (Deprecated - use /api/v1/ instead)
// ============================================
deprecatedMiddleware := middleware.DeprecationMiddleware
e.GET("/assets/*", echo.WrapHandler(http.FileServer(http.FS(web.Assets))))
e.GET("/search", echo.WrapHandler(templ.Handler(web.SearchForm())))
e.POST("/find", echo.WrapHandler(web.NewSearchHandler(s.search)))
e.Static("/", s.cfg.FrontendPath)
health := NewHealthHandler(s.db)
e.GET("/health", deprecatedMiddleware(health.HealthCheck))
h := s.handlers()
syncLock := middleware.SyncCheck(s.sync.IsSyncing)
version := NewVersionHandler()
e.GET("/version", deprecatedMiddleware(version.GetLatestVersion))
e.GET("/version/history", deprecatedMiddleware(version.GetVersionHistory))
// Legacy endpoints (deprecated - use /api/v1/ instead)
legacy := e.Group("", middleware.DeprecationMiddleware)
registerCommon(legacy, h, syncLock)
legacy.GET("/health", h.health.HealthCheck)
legacy.GET("/characters", h.character.GetCharacterList)
character := NewCharacterHandler()
e.GET("/character", deprecatedMiddleware(character.GetCharacter))
e.GET("/characters", deprecatedMiddleware(character.GetCharacterList))
download := NewDownloadHandler()
e.GET("/download", deprecatedMiddleware(download.checkLatest))
e.GET("/download/list", deprecatedMiddleware(download.listAssetsOfLatest))
e.GET("/download/windows", deprecatedMiddleware(download.downloadLatestWindows))
e.GET("/download/linux", deprecatedMiddleware(download.downloadLatestLinux))
sync := NewSyncHandler()
syncGroup := e.Group("/sync")
syncGroup.GET("", deprecatedMiddleware(middleware.SyncCheckMiddleware(sync.SyncSoundtracksNewOnlyChanges)))
syncGroup.GET("/progress", deprecatedMiddleware(sync.SyncProgress))
syncGroup.GET("/new", deprecatedMiddleware(middleware.SyncCheckMiddleware(sync.SyncSoundtracksNewOnlyChanges)))
syncGroup.GET("/full", deprecatedMiddleware(middleware.SyncCheckMiddleware(sync.SyncSoundtracksNewFull)))
syncGroup.GET("/new/full", deprecatedMiddleware(middleware.SyncCheckMiddleware(sync.SyncSoundtracksNewFull)))
syncGroup.GET("/quick", deprecatedMiddleware(middleware.SyncCheckMiddleware(sync.SyncSoundtracksNewOnlyChanges)))
syncGroup.GET("/reset", deprecatedMiddleware(middleware.SyncCheckMiddleware(sync.ResetDB)))
music := NewMusicHandler()
musicGroup := e.Group("/music")
musicGroup.GET("", deprecatedMiddleware(middleware.SyncCheckMiddleware(music.GetSong)))
musicGroup.GET("/soundTest", deprecatedMiddleware(middleware.SyncCheckMiddleware(music.GetSoundCheckSong)))
musicGroup.GET("/reset", deprecatedMiddleware(middleware.SyncCheckMiddleware(music.ResetMusic)))
musicGroup.GET("/rand", deprecatedMiddleware(middleware.SyncCheckMiddleware(music.GetRandomSong)))
musicGroup.GET("/rand/low", deprecatedMiddleware(middleware.SyncCheckMiddleware(music.GetRandomSongLowChance)))
musicGroup.GET("/rand/classic", deprecatedMiddleware(middleware.SyncCheckMiddleware(music.GetRandomSongClassic)))
musicGroup.GET("/info", deprecatedMiddleware(music.GetSongInfo))
musicGroup.GET("/list", deprecatedMiddleware(music.GetPlayedSongs))
musicGroup.GET("/next", deprecatedMiddleware(middleware.SyncCheckMiddleware(music.GetNextSong)))
musicGroup.GET("/previous", deprecatedMiddleware(middleware.SyncCheckMiddleware(music.GetPreviousSong)))
musicGroup.GET("/all", deprecatedMiddleware(middleware.SyncCheckMiddleware(music.GetAllSoundtracksRandom)))
musicGroup.GET("/all/order", deprecatedMiddleware(middleware.SyncCheckMiddleware(music.GetAllSoundtracks)))
musicGroup.GET("/all/random", deprecatedMiddleware(middleware.SyncCheckMiddleware(music.GetAllSoundtracksRandom)))
musicGroup.PUT("/played", deprecatedMiddleware(middleware.SyncCheckMiddleware(music.PutPlayed)))
musicGroup.GET("/addQue", deprecatedMiddleware(middleware.SyncCheckMiddleware(music.AddLatestToQue)))
musicGroup.GET("/addPlayed", deprecatedMiddleware(middleware.SyncCheckMiddleware(music.AddLatestPlayed)))
// ============================================
// API v1 Routes with Token Authentication
// ============================================
// Create /api/v1 group
// API v1
apiV1 := e.Group("/api/v1")
// Create rate limiter for token creation: 10 requests per minute per IP
tokenRateLimiter := middleware.NewRateLimiter(10, time.Minute)
tokenRateLimitMiddleware := middleware.RateLimitMiddleware(tokenRateLimiter)
// Token endpoints (public - no token required)
tokenGroup := apiV1.Group("/token")
tokenGroup.POST("", func(c *echo.Context) error {
return s.tokenHandler.CreateTokenHandler(c)
}, tokenRateLimitMiddleware)
tokenGroup.DELETE("", func(c *echo.Context) error {
return s.tokenHandler.DeleteTokenHandler(c)
})
tokenGroup.POST("/cleanup", func(c *echo.Context) error {
return s.tokenHandler.CleanupExpiredSessionsHandler(c)
})
tokenGroup.POST("", h.token.CreateTokenHandler, middleware.RateLimit(10, time.Minute))
tokenGroup.DELETE("", h.token.DeleteTokenHandler)
// Protected endpoints - require valid token
// Create token auth middleware with pool access
tokenAuthMiddleware := middleware.TokenAuthMiddleware(s.db.Pool)
protectedV1 := apiV1.Group("", tokenAuthMiddleware)
protected := apiV1.Group("", middleware.TokenAuth(s.q, s.cfg.DisableSessionAuth))
protected.POST("/token/cleanup", h.token.CleanupExpiredSessionsHandler)
registerCommon(protected, h, syncLock)
protected.GET("/character/list", h.character.GetCharacterList)
// Statistics endpoints (protected)
statisticsHandler := s.statisticsHandler
statsGroup := protectedV1.Group("/statistics")
statsGroup.GET("/soundtracks/most-played", func(c *echo.Context) error {
return statisticsHandler.GetMostPlayedSoundtracks(c)
})
statsGroup.GET("/soundtracks/least-played", func(c *echo.Context) error {
return statisticsHandler.GetLeastPlayedSoundtracks(c)
})
statsGroup.GET("/soundtracks/never-played", func(c *echo.Context) error {
return statisticsHandler.GetNeverPlayedSoundtracks(c)
})
statsGroup.GET("/soundtracks/last-played", func(c *echo.Context) error {
return statisticsHandler.GetLastPlayedSoundtracks(c)
})
statsGroup.GET("/soundtracks/oldest-played", func(c *echo.Context) error {
return statisticsHandler.GetOldestPlayedSoundtracks(c)
})
statsGroup.GET("/songs/most-played", func(c *echo.Context) error {
return statisticsHandler.GetMostPlayedSongs(c)
})
statsGroup.GET("/songs/least-played", func(c *echo.Context) error {
return statisticsHandler.GetLeastPlayedSongs(c)
})
statsGroup.GET("/summary", func(c *echo.Context) error {
return statisticsHandler.GetStatisticsSummary(c)
})
stats := protected.Group("/statistics")
stats.GET("/soundtracks/most-played", h.statistics.GetMostPlayedSoundtracks)
stats.GET("/soundtracks/least-played", h.statistics.GetLeastPlayedSoundtracks)
stats.GET("/soundtracks/never-played", h.statistics.GetNeverPlayedSoundtracks)
stats.GET("/soundtracks/last-played", h.statistics.GetLastPlayedSoundtracks)
stats.GET("/soundtracks/oldest-played", h.statistics.GetOldestPlayedSoundtracks)
stats.GET("/songs/most-played", h.statistics.GetMostPlayedSongs)
stats.GET("/songs/least-played", h.statistics.GetLeastPlayedSongs)
stats.GET("/summary", h.statistics.GetStatisticsSummary)
// Version endpoints (protected)
versionGroup := protectedV1.Group("/version")
versionGroup.GET("", func(c *echo.Context) error {
return version.GetLatestVersion(c)
})
versionGroup.GET("/history", func(c *echo.Context) error {
return version.GetVersionHistory(c)
})
// Character endpoints (protected)
characterGroup := protectedV1.Group("/character")
characterGroup.GET("", func(c *echo.Context) error {
return character.GetCharacter(c)
})
characterGroup.GET("/list", func(c *echo.Context) error {
return character.GetCharacterList(c)
})
// Download endpoints (protected)
downloadGroup := protectedV1.Group("/download")
downloadGroup.GET("", func(c *echo.Context) error {
return download.checkLatest(c)
})
downloadGroup.GET("/list", func(c *echo.Context) error {
return download.listAssetsOfLatest(c)
})
downloadGroup.GET("/windows", func(c *echo.Context) error {
return download.downloadLatestWindows(c)
})
downloadGroup.GET("/linux", func(c *echo.Context) error {
return download.downloadLatestLinux(c)
})
// Sync endpoints (protected)
protectedSyncGroup := protectedV1.Group("/sync")
protectedSyncGroup.GET("", func(c *echo.Context) error {
return sync.SyncSoundtracksNewOnlyChanges(c)
})
protectedSyncGroup.GET("/progress", func(c *echo.Context) error {
return sync.SyncProgress(c)
})
protectedSyncGroup.GET("/new", func(c *echo.Context) error {
return sync.SyncSoundtracksNewOnlyChanges(c)
})
protectedSyncGroup.GET("/full", func(c *echo.Context) error {
return sync.SyncSoundtracksNewFull(c)
})
protectedSyncGroup.GET("/new/full", func(c *echo.Context) error {
return sync.SyncSoundtracksNewFull(c)
})
protectedSyncGroup.GET("/quick", func(c *echo.Context) error {
return sync.SyncSoundtracksNewOnlyChanges(c)
})
protectedSyncGroup.GET("/reset", func(c *echo.Context) error {
return sync.ResetDB(c)
})
// Music endpoints (protected)
protectedMusicGroup := protectedV1.Group("/music")
protectedMusicGroup.GET("", func(c *echo.Context) error {
return music.GetSong(c)
})
protectedMusicGroup.GET("/soundTest", func(c *echo.Context) error {
return music.GetSoundCheckSong(c)
})
protectedMusicGroup.GET("/reset", func(c *echo.Context) error {
return music.ResetMusic(c)
})
protectedMusicGroup.GET("/rand", func(c *echo.Context) error {
return music.GetRandomSong(c)
})
protectedMusicGroup.GET("/rand/low", func(c *echo.Context) error {
return music.GetRandomSongLowChance(c)
})
protectedMusicGroup.GET("/rand/classic", func(c *echo.Context) error {
return music.GetRandomSongClassic(c)
})
protectedMusicGroup.GET("/info", func(c *echo.Context) error {
return music.GetSongInfo(c)
})
protectedMusicGroup.GET("/list", func(c *echo.Context) error {
return music.GetPlayedSongs(c)
})
protectedMusicGroup.GET("/next", func(c *echo.Context) error {
return music.GetNextSong(c)
})
protectedMusicGroup.GET("/previous", func(c *echo.Context) error {
return music.GetPreviousSong(c)
})
protectedMusicGroup.GET("/all", func(c *echo.Context) error {
return music.GetAllSoundtracksRandom(c)
})
protectedMusicGroup.GET("/all/order", func(c *echo.Context) error {
return music.GetAllSoundtracks(c)
})
protectedMusicGroup.GET("/all/random", func(c *echo.Context) error {
return music.GetAllSoundtracksRandom(c)
})
protectedMusicGroup.PUT("/played", func(c *echo.Context) error {
return music.PutPlayed(c)
})
protectedMusicGroup.GET("/addQue", func(c *echo.Context) error {
return music.AddLatestToQue(c)
})
protectedMusicGroup.GET("/addPlayed", func(c *echo.Context) error {
return music.AddLatestPlayed(c)
})
routes := e.Router().Routes()
sort.Slice(routes, func(i, j int) bool {
return routes[i].Path < routes[j].Path
})
for _, r := range routes {
if (r.Method == "GET" || r.Method == "POST" || r.Method == "PUT" || r.Method == "DELETE") && !strings.Contains(r.Name, "github") {
logging.GetLogger().Debug("Registered route", zap.String("method", r.Method), zap.String("path", r.Path))
}
for _, r := range e.Router().Routes() {
logging.GetLogger().Debug("Registered route", zap.String("method", r.Method), zap.String("path", r.Path))
}
return e
}
// registerCommon registers the routes shared by the legacy and the v1 API.
// syncLock is applied to routes that must not run during a sync.
func registerCommon(g *echo.Group, h handlers, syncLock echo.MiddlewareFunc) {
g.GET("/version", h.version.GetLatestVersion)
g.GET("/version/history", h.version.GetVersionHistory)
g.GET("/character", h.character.GetCharacter)
g.GET("/download", h.download.CheckLatest)
g.GET("/download/list", h.download.ListAssetsOfLatest)
g.GET("/download/windows", h.download.DownloadLatestWindows)
g.GET("/download/linux", h.download.DownloadLatestLinux)
g.GET("/sync/progress", h.sync.SyncProgress)
for _, path := range []string{"/sync", "/sync/new", "/sync/quick"} {
g.POST(path, h.sync.SyncSoundtracksNewOnlyChanges, syncLock)
}
for _, path := range []string{"/sync/full", "/sync/new/full"} {
g.POST(path, h.sync.SyncSoundtracksNewFull, syncLock)
}
g.POST("/sync/reset", h.sync.ResetDB, syncLock)
music := g.Group("/music", syncLock)
music.GET("", h.music.GetSong)
music.GET("/soundTest", h.music.GetSoundCheckSong)
music.GET("/reset", h.music.ResetMusic)
music.GET("/rand", h.music.GetRandomSong)
music.GET("/rand/low", h.music.GetRandomSongLowChance)
music.GET("/rand/classic", h.music.GetRandomSongClassic)
music.GET("/next", h.music.GetNextSong)
music.GET("/previous", h.music.GetPreviousSong)
music.GET("/all", h.music.GetAllSoundtracksRandom)
music.GET("/all/order", h.music.GetAllSoundtracks)
music.GET("/all/random", h.music.GetAllSoundtracksRandom)
music.PUT("/played", h.music.PutPlayed)
music.GET("/addQue", h.music.AddLatestToQue)
music.GET("/addPlayed", h.music.AddLatestPlayed)
// Info and list are read-only and stay available during a sync.
g.GET("/music/info", h.music.GetSongInfo)
g.GET("/music/list", h.music.GetPlayedSongs)
}
// serveOpenAPI serves the embedded OpenAPI spec.
func serveOpenAPI(c *echo.Context) error {
doc, err := swag.ReadDoc()
if err != nil {
return err
}
return c.Blob(http.StatusOK, echo.MIMEApplicationJSON, []byte(doc))
}