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:
1 parent
75b6f0c1cf
commit
090e794460
78 files changed
+2919
-6303
No files matched your search
+120
-236
@@ -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))
|
||||
}
|
||||
Reference in new issue
Block a user