Compare commits
4 Commits
98c1948eff
...
6d4a034753
| Author | SHA1 | Date | |
|---|---|---|---|
| 6d4a034753 | |||
| 24a9111333 | |||
| 6cc014ffa3 | |||
| 8f8b555ea5 |
+14
-8
@@ -2,7 +2,6 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"music-server/internal/db"
|
|
||||||
"music-server/internal/logging"
|
"music-server/internal/logging"
|
||||||
"music-server/internal/server"
|
"music-server/internal/server"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -19,9 +18,11 @@ import (
|
|||||||
// @description This is a sample server Petstore server.
|
// @description This is a sample server Petstore server.
|
||||||
// @termsOfService http://swagger.io/terms/
|
// @termsOfService http://swagger.io/terms/
|
||||||
|
|
||||||
|
//
|
||||||
// @contact.name Sebastian Olsson
|
// @contact.name Sebastian Olsson
|
||||||
// @contact.email zarnor91@gmail.com
|
// @contact.email zarnor91@gmail.com
|
||||||
|
|
||||||
|
//
|
||||||
// @license.name Apache 2.0
|
// @license.name Apache 2.0
|
||||||
// @license.url http://www.apache.org/licenses/LICENSE-2.0.html
|
// @license.url http://www.apache.org/licenses/LICENSE-2.0.html
|
||||||
|
|
||||||
@@ -34,16 +35,17 @@ func main() {
|
|||||||
pprof.StartCPUProfile(f)
|
pprof.StartCPUProfile(f)
|
||||||
defer pprof.StopCPUProfile()*/
|
defer pprof.StopCPUProfile()*/
|
||||||
|
|
||||||
server := server.NewServer()
|
appServer := server.NewServerInstance()
|
||||||
|
httpServer := appServer.HTTPServer()
|
||||||
|
|
||||||
// Create a done channel to signal when the shutdown is complete
|
// Create a done channel to signal when the shutdown is complete
|
||||||
done := make(chan bool, 1)
|
done := make(chan bool, 1)
|
||||||
|
|
||||||
// Run graceful shutdown in a separate goroutine
|
// Run graceful shutdown in a separate goroutine
|
||||||
go gracefulShutdown(server, done)
|
go gracefulShutdown(appServer, httpServer, done)
|
||||||
|
|
||||||
logging.GetLogger().Info("Server starting", zap.String("address", server.Addr))
|
logging.GetLogger().Info("Server starting", zap.String("address", httpServer.Addr))
|
||||||
err := server.ListenAndServe()
|
err := httpServer.ListenAndServe()
|
||||||
if err != nil && err != http.ErrServerClosed {
|
if err != nil && err != http.ErrServerClosed {
|
||||||
logging.GetLogger().Fatal("HTTP server error", zap.String("error", err.Error()))
|
logging.GetLogger().Fatal("HTTP server error", zap.String("error", err.Error()))
|
||||||
}
|
}
|
||||||
@@ -53,7 +55,7 @@ func main() {
|
|||||||
logging.GetLogger().Info("Graceful shutdown complete")
|
logging.GetLogger().Info("Graceful shutdown complete")
|
||||||
}
|
}
|
||||||
|
|
||||||
func gracefulShutdown(apiServer *http.Server, done chan bool) {
|
func gracefulShutdown(appServer *server.Server, httpServer *http.Server, done chan bool) {
|
||||||
// Create context that listens for the interrupt signal from the OS.
|
// Create context that listens for the interrupt signal from the OS.
|
||||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||||
defer stop()
|
defer stop()
|
||||||
@@ -62,13 +64,17 @@ func gracefulShutdown(apiServer *http.Server, done chan bool) {
|
|||||||
<-ctx.Done()
|
<-ctx.Done()
|
||||||
|
|
||||||
logging.GetLogger().Info("Shutting down gracefully, press Ctrl+C again to force")
|
logging.GetLogger().Info("Shutting down gracefully, press Ctrl+C again to force")
|
||||||
db.CloseDb()
|
|
||||||
|
// Close database connection
|
||||||
|
if appServer != nil && appServer.DB() != nil {
|
||||||
|
appServer.DB().Close()
|
||||||
|
}
|
||||||
|
|
||||||
// The context is used to inform the server it has 5 seconds to finish
|
// The context is used to inform the server it has 5 seconds to finish
|
||||||
// the request it is currently handling
|
// the request it is currently handling
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
if err := apiServer.Shutdown(ctx); err != nil {
|
if err := httpServer.Shutdown(ctx); err != nil {
|
||||||
logging.GetLogger().Error("Server forced to shutdown with error", zap.String("error", err.Error()))
|
logging.GetLogger().Error("Server forced to shutdown with error", zap.String("error", err.Error()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package backend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"music-server/internal/db/repository"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Global variables - these are initialized by InitBackend
|
||||||
|
var (
|
||||||
|
backendPool *pgxpool.Pool
|
||||||
|
repo *repository.Queries
|
||||||
|
backendCtx context.Context = context.Background()
|
||||||
|
)
|
||||||
|
|
||||||
|
// InitBackend initializes the backend package with the database pool.
|
||||||
|
// This should be called once at application startup.
|
||||||
|
func InitBackend(pool *pgxpool.Pool) {
|
||||||
|
backendPool = pool
|
||||||
|
repo = repository.New(pool)
|
||||||
|
backendCtx = context.Background()
|
||||||
|
}
|
||||||
|
|
||||||
|
// BackendCtx returns the context used by backend operations.
|
||||||
|
// This is exposed for use by the backend functions.
|
||||||
|
func BackendCtx() context.Context {
|
||||||
|
return backendCtx
|
||||||
|
}
|
||||||
|
|
||||||
|
// BackendRepo returns the repository queries instance.
|
||||||
|
// This is exposed for use by the backend functions.
|
||||||
|
func BackendRepo() *repository.Queries {
|
||||||
|
return repo
|
||||||
|
}
|
||||||
|
|
||||||
|
// BackendPool returns the underlying database pool.
|
||||||
|
// This is exposed for test utilities that need direct pool access.
|
||||||
|
func BackendPool() *pgxpool.Pool {
|
||||||
|
return backendPool
|
||||||
|
}
|
||||||
@@ -4,8 +4,8 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"music-server/internal/logging"
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
"music-server/internal/logging"
|
||||||
)
|
)
|
||||||
|
|
||||||
func GetCharacterList() []string {
|
func GetCharacterList() []string {
|
||||||
@@ -30,10 +30,10 @@ func GetCharacterList() []string {
|
|||||||
|
|
||||||
func GetCharacter(character string) string {
|
func GetCharacter(character string) string {
|
||||||
charactersPath := os.Getenv("CHARACTERS_PATH")
|
charactersPath := os.Getenv("CHARACTERS_PATH")
|
||||||
logging.GetLogger().Debug("Getting character", zap.String("character", character), zap.String("path", charactersPath))
|
|
||||||
// Clean the path - remove trailing slashes and then add one for consistency
|
// Clean the path - remove trailing slashes and then add one for consistency
|
||||||
charactersPath = strings.TrimSuffix(charactersPath, "/")
|
charactersPath = strings.TrimSuffix(charactersPath, "/")
|
||||||
charactersPath += "/"
|
charactersPath += "/"
|
||||||
|
logging.GetLogger().Debug("Getting character", zap.String("character", character), zap.String("path", charactersPath+character))
|
||||||
return charactersPath + character
|
return charactersPath + character
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,95 +0,0 @@
|
|||||||
package backend
|
|
||||||
|
|
||||||
import (
|
|
||||||
"music-server/internal/db"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestDB() {
|
|
||||||
db.Testf()
|
|
||||||
}
|
|
||||||
|
|
||||||
type VersionData struct {
|
|
||||||
Version string `json:"version" example:"1.0.0"`
|
|
||||||
Changelog string `json:"changelog" example:"account name"`
|
|
||||||
History []VersionData `json:"history"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func GetVersionHistory() VersionData {
|
|
||||||
data := VersionData{Version: "4.5.0",
|
|
||||||
Changelog: "#1 - Created request to check newest version of the app\n" +
|
|
||||||
"#2 - Added request to download the newest version of the app\n" +
|
|
||||||
"#3 - Added request to check progress during sync\n" +
|
|
||||||
"#4 - Now blocking all request while sync is in progress\n" +
|
|
||||||
"#5 - Implemented ants for thread pooling\n" +
|
|
||||||
"#6 - Changed the sync request to now only start the sync",
|
|
||||||
History: []VersionData{
|
|
||||||
{
|
|
||||||
Version: "4.0.0",
|
|
||||||
Changelog: "Changed framework from gin to Echo\n" +
|
|
||||||
"Reorganized the code\n" +
|
|
||||||
"Implemented sqlc\n" +
|
|
||||||
"Added support to send character images from the server\n" +
|
|
||||||
"Added function to create a new database of no one exists",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Version: "3.2",
|
|
||||||
Changelog: "Upgraded Go version and the version of all dependencies. Fixed som more bugs.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Version: "3.1",
|
|
||||||
Changelog: "Fixed some bugs with songs not found made the application crash. Now checking if song exists and if not, remove song from DB and find another one. Frontend is now decoupled from the backend.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Version: "3.0",
|
|
||||||
Changelog: "Changed routing framework from mux to Gin. Swagger doc is now included in the application. A fronted can now be hosted from the application.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Version: "2.3.0",
|
|
||||||
Changelog: "Images should not be included in the database, removes songs where the path doesn't work.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Version: "2.2.0",
|
|
||||||
Changelog: "Changed the structure of the whole application, should be no changes to functionality.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Version: "2.1.4",
|
|
||||||
Changelog: "Game list should now be sorted, a new endpoint with the game list in random order have been added.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Version: "2.1.3",
|
|
||||||
Changelog: "Added a check to see if song exists before returning it, if not a new song will be picked up.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Version: "2.1.2",
|
|
||||||
Changelog: "Added test server to swagger file.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Version: "2.1.1",
|
|
||||||
Changelog: "Fixed bug where wrong song was showed as currently played.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Version: "2.1.0",
|
|
||||||
Changelog: "Added /addQue to add the last received song to the songQue. " +
|
|
||||||
"Changed /rand and /rand/low to not add song to the que. " +
|
|
||||||
"Changed /next to not call /rand when the end of the que is reached, instead the last song in the que will be resent.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Version: "2.0.3",
|
|
||||||
Changelog: "Another small change that should fix the caching problem.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Version: "2.0.2",
|
|
||||||
Changelog: "Hopefully fixed the caching problem with random.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Version: "2.0.1",
|
|
||||||
Changelog: "Fixed CORS",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Version: "2.0.0",
|
|
||||||
Changelog: "Rebuilt the application in Go.",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
+17
-16
@@ -2,7 +2,6 @@ package backend
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"math/rand"
|
"math/rand"
|
||||||
"music-server/internal/db"
|
|
||||||
"music-server/internal/db/repository"
|
"music-server/internal/db/repository"
|
||||||
"music-server/internal/logging"
|
"music-server/internal/logging"
|
||||||
"os"
|
"os"
|
||||||
@@ -28,18 +27,20 @@ var gamesNew []repository.Game
|
|||||||
var songQueNew []repository.Song
|
var songQueNew []repository.Song
|
||||||
|
|
||||||
var lastFetchedNew repository.Song
|
var lastFetchedNew repository.Song
|
||||||
var repo *repository.Queries
|
|
||||||
|
|
||||||
func initRepo() {
|
func initRepo() {
|
||||||
if repo == nil {
|
// This function is kept for backward compatibility
|
||||||
repo = repository.New(db.Dbpool)
|
// but now uses the backend package's initialized repo
|
||||||
|
// If not initialized, this will panic intentionally
|
||||||
|
if BackendRepo() == nil {
|
||||||
|
panic("backend not initialized - call backend.InitBackend() first")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func getAllGames() []repository.Game {
|
func getAllGames() []repository.Game {
|
||||||
if len(gamesNew) == 0 {
|
if len(gamesNew) == 0 {
|
||||||
initRepo()
|
initRepo()
|
||||||
gamesNew, _ = repo.FindAllGames(db.Ctx)
|
gamesNew, _ = BackendRepo().FindAllGames(BackendCtx())
|
||||||
}
|
}
|
||||||
return gamesNew
|
return gamesNew
|
||||||
|
|
||||||
@@ -58,7 +59,7 @@ func Reset() {
|
|||||||
songQueNew = nil
|
songQueNew = nil
|
||||||
currentSong = -1
|
currentSong = -1
|
||||||
initRepo()
|
initRepo()
|
||||||
gamesNew, _ = repo.FindAllGames(db.Ctx)
|
gamesNew, _ = BackendRepo().FindAllGames(BackendCtx())
|
||||||
}
|
}
|
||||||
|
|
||||||
func AddLatestToQue() {
|
func AddLatestToQue() {
|
||||||
@@ -76,8 +77,8 @@ func AddLatestPlayed() {
|
|||||||
currentSongData := songQueNew[currentSong]
|
currentSongData := songQueNew[currentSong]
|
||||||
|
|
||||||
initRepo()
|
initRepo()
|
||||||
repo.AddGamePlayed(db.Ctx, currentSongData.GameID)
|
BackendRepo().AddGamePlayed(BackendCtx(), currentSongData.GameID)
|
||||||
repo.AddSongPlayed(db.Ctx, repository.AddSongPlayedParams{GameID: currentSongData.GameID, SongName: currentSongData.SongName})
|
BackendRepo().AddSongPlayed(BackendCtx(), repository.AddSongPlayedParams{GameID: currentSongData.GameID, SongName: currentSongData.SongName})
|
||||||
}
|
}
|
||||||
|
|
||||||
func SetPlayed(songNumber int) {
|
func SetPlayed(songNumber int) {
|
||||||
@@ -86,8 +87,8 @@ func SetPlayed(songNumber int) {
|
|||||||
}
|
}
|
||||||
songData := songQueNew[songNumber]
|
songData := songQueNew[songNumber]
|
||||||
initRepo()
|
initRepo()
|
||||||
repo.AddGamePlayed(db.Ctx, songData.GameID)
|
BackendRepo().AddGamePlayed(BackendCtx(), songData.GameID)
|
||||||
repo.AddSongPlayed(db.Ctx, repository.AddSongPlayedParams{GameID: songData.GameID, SongName: songData.SongName})
|
BackendRepo().AddSongPlayed(BackendCtx(), repository.AddSongPlayedParams{GameID: songData.GameID, SongName: songData.SongName})
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetRandomSong() string {
|
func GetRandomSong() string {
|
||||||
@@ -130,7 +131,7 @@ func GetRandomSongClassic() string {
|
|||||||
|
|
||||||
var listOfAllSongs []repository.Song
|
var listOfAllSongs []repository.Song
|
||||||
for _, game := range gamesNew {
|
for _, game := range gamesNew {
|
||||||
songList, _ := repo.FindSongsFromGame(db.Ctx, game.ID)
|
songList, _ := BackendRepo().FindSongsFromGame(BackendCtx(), game.ID)
|
||||||
listOfAllSongs = append(listOfAllSongs, songList...)
|
listOfAllSongs = append(listOfAllSongs, songList...)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,10 +139,10 @@ func GetRandomSongClassic() string {
|
|||||||
var song repository.Song
|
var song repository.Song
|
||||||
for !songFound {
|
for !songFound {
|
||||||
song = listOfAllSongs[rand.Intn(len(listOfAllSongs))]
|
song = listOfAllSongs[rand.Intn(len(listOfAllSongs))]
|
||||||
gameData, err := repo.GetGameById(db.Ctx, song.GameID)
|
gameData, err := BackendRepo().GetGameById(BackendCtx(), song.GameID)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
repo.RemoveBrokenSong(db.Ctx, song.Path)
|
BackendRepo().RemoveBrokenSong(BackendCtx(), song.Path)
|
||||||
logging.GetLogger().Warn("Song not found, removed from database",
|
logging.GetLogger().Warn("Song not found, removed from database",
|
||||||
zap.String("song", song.SongName),
|
zap.String("song", song.SongName),
|
||||||
zap.String("game", gameData.GameName),
|
zap.String("game", gameData.GameName),
|
||||||
@@ -153,7 +154,7 @@ func GetRandomSongClassic() string {
|
|||||||
openFile, err := os.Open(song.Path)
|
openFile, err := os.Open(song.Path)
|
||||||
if err != nil || (song.FileName != nil && gameData.Path+*song.FileName != song.Path) {
|
if err != nil || (song.FileName != nil && gameData.Path+*song.FileName != song.Path) {
|
||||||
//File not found
|
//File not found
|
||||||
repo.RemoveBrokenSong(db.Ctx, song.Path)
|
BackendRepo().RemoveBrokenSong(BackendCtx(), song.Path)
|
||||||
logging.GetLogger().Warn("Song not found, removed from database",
|
logging.GetLogger().Warn("Song not found, removed from database",
|
||||||
zap.String("song", song.SongName),
|
zap.String("song", song.SongName),
|
||||||
zap.String("game", gameData.GameName),
|
zap.String("game", gameData.GameName),
|
||||||
@@ -270,7 +271,7 @@ func getSongFromList(games []repository.Game) repository.Song {
|
|||||||
var song repository.Song
|
var song repository.Song
|
||||||
for !songFound {
|
for !songFound {
|
||||||
game := getRandomGame(games)
|
game := getRandomGame(games)
|
||||||
songs, _ := repo.FindSongsFromGame(db.Ctx, game.ID)
|
songs, _ := BackendRepo().FindSongsFromGame(BackendCtx(), game.ID)
|
||||||
if len(songs) == 0 {
|
if len(songs) == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -281,7 +282,7 @@ func getSongFromList(games []repository.Game) repository.Song {
|
|||||||
openFile, err := os.Open(song.Path)
|
openFile, err := os.Open(song.Path)
|
||||||
if err != nil || (song.FileName != nil && game.Path+*song.FileName != song.Path) || (song.FileName != nil && strings.HasSuffix(*song.FileName, ".wav")) {
|
if err != nil || (song.FileName != nil && game.Path+*song.FileName != song.Path) || (song.FileName != nil && strings.HasSuffix(*song.FileName, ".wav")) {
|
||||||
//File not found
|
//File not found
|
||||||
repo.RemoveBrokenSong(db.Ctx, song.Path)
|
BackendRepo().RemoveBrokenSong(BackendCtx(), song.Path)
|
||||||
logging.GetLogger().Warn("Song not found, removed from database",
|
logging.GetLogger().Warn("Song not found, removed from database",
|
||||||
zap.String("song", song.SongName),
|
zap.String("song", song.SongName),
|
||||||
zap.String("game", game.GameName),
|
zap.String("game", game.GameName),
|
||||||
|
|||||||
+23
-24
@@ -7,7 +7,6 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"music-server/internal/db"
|
|
||||||
"music-server/internal/db/repository"
|
"music-server/internal/db/repository"
|
||||||
"music-server/internal/logging"
|
"music-server/internal/logging"
|
||||||
"os"
|
"os"
|
||||||
@@ -80,8 +79,8 @@ func (gs GameStatus) String() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func ResetDB() {
|
func ResetDB() {
|
||||||
repo.ClearSongs(db.Ctx)
|
repo.ClearSongs(BackendCtx())
|
||||||
repo.ClearGames(db.Ctx)
|
repo.ClearGames(BackendCtx())
|
||||||
}
|
}
|
||||||
|
|
||||||
func SyncProgress() ProgressResponse {
|
func SyncProgress() ProgressResponse {
|
||||||
@@ -206,13 +205,13 @@ func syncGamesNew(full bool) {
|
|||||||
catchedErrors = nil
|
catchedErrors = nil
|
||||||
brokenSongs = nil
|
brokenSongs = nil
|
||||||
|
|
||||||
gamesBeforeSync, err = repo.FindAllGames(db.Ctx)
|
gamesBeforeSync, err = repo.FindAllGames(BackendCtx())
|
||||||
handleError("FindAllGames Before", err, "")
|
handleError("FindAllGames Before", err, "")
|
||||||
logging.GetLogger().Info("Starting sync", zap.Int("games_before", len(gamesBeforeSync)))
|
logging.GetLogger().Info("Starting sync", zap.Int("games_before", len(gamesBeforeSync)))
|
||||||
|
|
||||||
allGames, err = repo.GetAllGamesIncludingDeleted(db.Ctx)
|
allGames, err = repo.GetAllGamesIncludingDeleted(BackendCtx())
|
||||||
handleError("GetAllGamesIncludingDeleted", err, "")
|
handleError("GetAllGamesIncludingDeleted", err, "")
|
||||||
err = repo.SetGameDeletionDate(db.Ctx)
|
err = repo.SetGameDeletionDate(BackendCtx())
|
||||||
handleError("SetGameDeletionDate", err, "")
|
handleError("SetGameDeletionDate", err, "")
|
||||||
|
|
||||||
directories, err := os.ReadDir(musicPath)
|
directories, err := os.ReadDir(musicPath)
|
||||||
@@ -237,7 +236,7 @@ func syncGamesNew(full bool) {
|
|||||||
syncWg.Wait()
|
syncWg.Wait()
|
||||||
checkBrokenSongsNew()
|
checkBrokenSongsNew()
|
||||||
|
|
||||||
gamesAfterSync, err = repo.FindAllGames(db.Ctx)
|
gamesAfterSync, err = repo.FindAllGames(BackendCtx())
|
||||||
handleError("FindAllGames After", err, "")
|
handleError("FindAllGames After", err, "")
|
||||||
|
|
||||||
finished := time.Now()
|
finished := time.Now()
|
||||||
@@ -249,7 +248,7 @@ func syncGamesNew(full bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func checkBrokenSongsNew() {
|
func checkBrokenSongsNew() {
|
||||||
allSongs, err := repo.FetchAllSongs(db.Ctx)
|
allSongs, err := repo.FetchAllSongs(BackendCtx())
|
||||||
handleError("FetchAllSongs", err, "")
|
handleError("FetchAllSongs", err, "")
|
||||||
var brokenWg sync.WaitGroup
|
var brokenWg sync.WaitGroup
|
||||||
poolBroken, _ := ants.NewPool(200, ants.WithPreAlloc(true))
|
poolBroken, _ := ants.NewPool(200, ants.WithPreAlloc(true))
|
||||||
@@ -263,7 +262,7 @@ func checkBrokenSongsNew() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
brokenWg.Wait()
|
brokenWg.Wait()
|
||||||
err = repo.RemoveBrokenSongs(db.Ctx, brokenSongs)
|
err = repo.RemoveBrokenSongs(BackendCtx(), brokenSongs)
|
||||||
handleError("RemoveBrokenSongs", err, "")
|
handleError("RemoveBrokenSongs", err, "")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -336,7 +335,7 @@ func syncGameNew(file os.DirEntry, foldersToSkip []string, baseDir string, full
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
err = repo.InsertGameWithExistingId(db.Ctx, repository.InsertGameWithExistingIdParams{ID: id, GameName: file.Name(), Path: gameDir, Hash: dirHash})
|
err = repo.InsertGameWithExistingId(BackendCtx(), repository.InsertGameWithExistingIdParams{ID: id, GameName: file.Name(), Path: gameDir, Hash: dirHash})
|
||||||
handleError("InsertGameWithExistingId", err, "")
|
handleError("InsertGameWithExistingId", err, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logging.GetLogger().Debug("Game already exists, removing old ID file",
|
logging.GetLogger().Debug("Game already exists, removing old ID file",
|
||||||
@@ -370,7 +369,7 @@ func syncGameNew(file os.DirEntry, foldersToSkip []string, baseDir string, full
|
|||||||
zap.String("game", file.Name()),
|
zap.String("game", file.Name()),
|
||||||
zap.String("hash", dirHash),
|
zap.String("hash", dirHash),
|
||||||
zap.String("status", status.String()))
|
zap.String("status", status.String()))
|
||||||
err = repo.UpdateGameHash(db.Ctx, repository.UpdateGameHashParams{Hash: dirHash, ID: id})
|
err = repo.UpdateGameHash(BackendCtx(), repository.UpdateGameHashParams{Hash: dirHash, ID: id})
|
||||||
handleError("UpdateGameHash", err, "")
|
handleError("UpdateGameHash", err, "")
|
||||||
gamesChangedContent = append(gamesChangedContent, file.Name())
|
gamesChangedContent = append(gamesChangedContent, file.Name())
|
||||||
newCheckSongs(entries, gameDir, id)
|
newCheckSongs(entries, gameDir, id)
|
||||||
@@ -381,7 +380,7 @@ func syncGameNew(file os.DirEntry, foldersToSkip []string, baseDir string, full
|
|||||||
zap.String("newName", file.Name()),
|
zap.String("newName", file.Name()),
|
||||||
zap.String("hash", dirHash),
|
zap.String("hash", dirHash),
|
||||||
zap.String("status", status.String()))
|
zap.String("status", status.String()))
|
||||||
err = repo.UpdateGameName(db.Ctx, repository.UpdateGameNameParams{Name: file.Name(), Path: gameDir, ID: id})
|
err = repo.UpdateGameName(BackendCtx(), repository.UpdateGameNameParams{Name: file.Name(), Path: gameDir, ID: id})
|
||||||
handleError("UpdateGameName", err, "")
|
handleError("UpdateGameName", err, "")
|
||||||
newCheckSongs(entries, gameDir, id)
|
newCheckSongs(entries, gameDir, id)
|
||||||
if gamesChangedTitle == nil {
|
if gamesChangedTitle == nil {
|
||||||
@@ -416,7 +415,7 @@ func syncGameNew(file os.DirEntry, foldersToSkip []string, baseDir string, full
|
|||||||
zap.String("game", file.Name()),
|
zap.String("game", file.Name()),
|
||||||
zap.String("hash", dirHash),
|
zap.String("hash", dirHash),
|
||||||
zap.String("status", status.String()))
|
zap.String("status", status.String()))
|
||||||
err = repo.RemoveDeletionDate(db.Ctx, id)
|
err = repo.RemoveDeletionDate(BackendCtx(), id)
|
||||||
handleError("RemoveDeletionDate", err, "")
|
handleError("RemoveDeletionDate", err, "")
|
||||||
}
|
}
|
||||||
foldersSynced++
|
foldersSynced++
|
||||||
@@ -428,13 +427,13 @@ func syncGameNew(file os.DirEntry, foldersToSkip []string, baseDir string, full
|
|||||||
|
|
||||||
func insertGameNew(name string, path string, hash string) int32 {
|
func insertGameNew(name string, path string, hash string) int32 {
|
||||||
var duplicateError = errors.New("ERROR: duplicate key value violates unique")
|
var duplicateError = errors.New("ERROR: duplicate key value violates unique")
|
||||||
id, err := repo.InsertGame(db.Ctx, repository.InsertGameParams{GameName: name, Path: path, Hash: hash})
|
id, err := repo.InsertGame(BackendCtx(), repository.InsertGameParams{GameName: name, Path: path, Hash: hash})
|
||||||
handleError("InsertGame", err, "")
|
handleError("InsertGame", err, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logging.GetLogger().Warn("ID collision detected, resetting sequence")
|
logging.GetLogger().Warn("ID collision detected, resetting sequence")
|
||||||
if strings.HasPrefix(err.Error(), duplicateError.Error()) {
|
if strings.HasPrefix(err.Error(), duplicateError.Error()) {
|
||||||
logging.GetLogger().Debug("Resetting game ID sequence")
|
logging.GetLogger().Debug("Resetting game ID sequence")
|
||||||
_, err = repo.ResetGameIdSeq(db.Ctx)
|
_, err = repo.ResetGameIdSeq(BackendCtx())
|
||||||
handleError("ResetGameIdSeq", err, "")
|
handleError("ResetGameIdSeq", err, "")
|
||||||
id = insertGameNew(name, path, hash)
|
id = insertGameNew(name, path, hash)
|
||||||
}
|
}
|
||||||
@@ -478,7 +477,7 @@ func newCheckSong(entry os.DirEntry, gameDir string, id int32) bool {
|
|||||||
fileName := entry.Name()
|
fileName := entry.Name()
|
||||||
songName, _ := strings.CutSuffix(fileName, ".mp3")
|
songName, _ := strings.CutSuffix(fileName, ".mp3")
|
||||||
|
|
||||||
song, err := repo.GetSongWithHash(db.Ctx, songHash)
|
song, err := repo.GetSongWithHash(BackendCtx(), songHash)
|
||||||
handleError("GetSongWithHash", err, fmt.Sprintf("GameID: %d | Path: %s | SongName: %s | SongHash: %s", id, path, entry.Name(), songHash))
|
handleError("GetSongWithHash", err, fmt.Sprintf("GameID: %d | Path: %s | SongName: %s | SongHash: %s", id, path, entry.Name(), songHash))
|
||||||
if err == nil {
|
if err == nil {
|
||||||
if song.SongName == songName && song.Path == path {
|
if song.SongName == songName && song.Path == path {
|
||||||
@@ -491,31 +490,31 @@ func newCheckSong(entry os.DirEntry, gameDir string, id int32) bool {
|
|||||||
zap.String("song_name", songName),
|
zap.String("song_name", songName),
|
||||||
zap.String("song_hash", songHash))
|
zap.String("song_hash", songHash))
|
||||||
|
|
||||||
count, err := repo.CheckSongWithHash(db.Ctx, songHash)
|
count, err := repo.CheckSongWithHash(BackendCtx(), songHash)
|
||||||
handleError("CheckSongWithHash", err, fmt.Sprintf("GameID: %d | Path: %s | SongName: %s | SongHash: %s\n", id, path, entry.Name(), songHash))
|
handleError("CheckSongWithHash", err, fmt.Sprintf("GameID: %d | Path: %s | SongName: %s | SongHash: %s\n", id, path, entry.Name(), songHash))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
count2, err := repo.CheckSong(db.Ctx, path)
|
count2, err := repo.CheckSong(BackendCtx(), path)
|
||||||
handleError("CheckSong", err, fmt.Sprintf("GameID: %d | Path: %s | SongName: %s | SongHash: %s\n", id, path, entry.Name(), songHash))
|
handleError("CheckSong", err, fmt.Sprintf("GameID: %d | Path: %s | SongName: %s | SongHash: %s\n", id, path, entry.Name(), songHash))
|
||||||
if count2 > 0 {
|
if count2 > 0 {
|
||||||
err = repo.AddHashToSong(db.Ctx, repository.AddHashToSongParams{Hash: songHash, Path: path})
|
err = repo.AddHashToSong(BackendCtx(), repository.AddHashToSongParams{Hash: songHash, Path: path})
|
||||||
handleError("AddHashToSong", err, fmt.Sprintf("GameID: %d | Path: %s | SongName: %s | SongHash: %s", id, path, entry.Name(), songHash))
|
handleError("AddHashToSong", err, fmt.Sprintf("GameID: %d | Path: %s | SongName: %s | SongHash: %s", id, path, entry.Name(), songHash))
|
||||||
count, err = repo.CheckSongWithHash(db.Ctx, songHash)
|
count, err = repo.CheckSongWithHash(BackendCtx(), songHash)
|
||||||
handleError("CheckSongWithHash 2", err, fmt.Sprintf("GameID: %d | Path: %s | SongName: %s | SongHash: %s", id, path, entry.Name(), songHash))
|
handleError("CheckSongWithHash 2", err, fmt.Sprintf("GameID: %d | Path: %s | SongName: %s | SongHash: %s", id, path, entry.Name(), songHash))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
//count, _ := repo.CheckSong(ctx, path)
|
//count, _ := repo.CheckSong(ctx, path)
|
||||||
if count > 0 {
|
if count > 0 {
|
||||||
err = repo.UpdateSong(db.Ctx, repository.UpdateSongParams{SongName: songName, FileName: &fileName, Path: path, Hash: songHash})
|
err = repo.UpdateSong(BackendCtx(), repository.UpdateSongParams{SongName: songName, FileName: &fileName, Path: path, Hash: songHash})
|
||||||
handleError("UpdateSong", err, fmt.Sprintf("GameID: %d | Path: %s | SongName: %s | SongHash: %s", id, path, entry.Name(), songHash))
|
handleError("UpdateSong", err, fmt.Sprintf("GameID: %d | Path: %s | SongName: %s | SongHash: %s", id, path, entry.Name(), songHash))
|
||||||
} else {
|
} else {
|
||||||
count2, err := repo.CheckSong(db.Ctx, path)
|
count2, err := repo.CheckSong(BackendCtx(), path)
|
||||||
handleError("CheckSong", err, fmt.Sprintf("GameID: %d | Path: %s | SongName: %s | SongHash: %s", id, path, entry.Name(), songHash))
|
handleError("CheckSong", err, fmt.Sprintf("GameID: %d | Path: %s | SongName: %s | SongHash: %s", id, path, entry.Name(), songHash))
|
||||||
if count2 > 0 {
|
if count2 > 0 {
|
||||||
err = repo.AddHashToSong(db.Ctx, repository.AddHashToSongParams{Hash: songHash, Path: path})
|
err = repo.AddHashToSong(BackendCtx(), repository.AddHashToSongParams{Hash: songHash, Path: path})
|
||||||
handleError("AddHashToSong", err, fmt.Sprintf("GameID: %d | Path: %s | SongName: %s | SongHash: %s", id, path, entry.Name(), songHash))
|
handleError("AddHashToSong", err, fmt.Sprintf("GameID: %d | Path: %s | SongName: %s | SongHash: %s", id, path, entry.Name(), songHash))
|
||||||
} else {
|
} else {
|
||||||
err = repo.AddSong(db.Ctx, repository.AddSongParams{GameID: id, SongName: songName, Path: path, FileName: &fileName, Hash: songHash})
|
err = repo.AddSong(BackendCtx(), repository.AddSongParams{GameID: id, SongName: songName, Path: path, FileName: &fileName, Hash: songHash})
|
||||||
handleError("AddSong", err, fmt.Sprintf("GameID: %d | Path: %s | SongName: %s | SongHash: %s", id, path, entry.Name(), songHash))
|
handleError("AddSong", err, fmt.Sprintf("GameID: %d | Path: %s | SongName: %s | SongHash: %s", id, path, entry.Name(), songHash))
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
package backend
|
||||||
|
|
||||||
|
type VersionData struct {
|
||||||
|
Version string `json:"version" example:"1.0.0"`
|
||||||
|
Changelog []string `json:"changelog" example:"[\"Initial release\",\"Bug fixes\"]"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var data = []VersionData{
|
||||||
|
{
|
||||||
|
Version: "5.0.0-Beta",
|
||||||
|
Changelog: []string{
|
||||||
|
"#16 - Upgrade Echo framework from v4 to v5",
|
||||||
|
"#17 - Add Zap structured logging framework",
|
||||||
|
"#18 - Add OpenAPI/Swagger documentation",
|
||||||
|
"#19 - Replace Tailwind CSS with pure CSS",
|
||||||
|
"#20 - Change domain from sanplex.tech to sanplex.xyz",
|
||||||
|
"#21 - Refactor handlers into domain-specific files",
|
||||||
|
"#22 - Change VersionData Changelog from string to string array",
|
||||||
|
"#23 - Update all dependencies to latest versions",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Version: "4.5.0",
|
||||||
|
Changelog: []string{
|
||||||
|
"#1 - Created request to check newest version of the app",
|
||||||
|
"#2 - Added request to download the newest version of the app",
|
||||||
|
"#3 - Added request to check progress during sync",
|
||||||
|
"#4 - Now blocking all request while sync is in progress",
|
||||||
|
"#5 - Implemented ants for thread pooling",
|
||||||
|
"#6 - Changed the sync request to now only start the sync",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Version: "4.0.0",
|
||||||
|
Changelog: []string{
|
||||||
|
"Changed framework from gin to Echo",
|
||||||
|
"Reorganized the code",
|
||||||
|
"Implemented sqlc",
|
||||||
|
"Added support to send character images from the server",
|
||||||
|
"Added function to create a new database of no one exists",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Version: "3.2",
|
||||||
|
Changelog: []string{"Upgraded Go version and the version of all dependencies. Fixed som more bugs."},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Version: "3.1",
|
||||||
|
Changelog: []string{"Fixed some bugs with songs not found made the application crash. Now checking if song exists and if not, remove song from DB and find another one. Frontend is now decoupled from the backend."},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Version: "3.0",
|
||||||
|
Changelog: []string{"Changed routing framework from mux to Gin. Swagger doc is now included in the application. A fronted can now be hosted from the application."},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Version: "2.3.0",
|
||||||
|
Changelog: []string{"Images should not be included in the database, removes songs where the path doesn't work."},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Version: "2.2.0",
|
||||||
|
Changelog: []string{"Changed the structure of the whole application, should be no changes to functionality."},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Version: "2.1.4",
|
||||||
|
Changelog: []string{"Game list should now be sorted, a new endpoint with the game list in random order have been added."},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Version: "2.1.3",
|
||||||
|
Changelog: []string{"Added a check to see if song exists before returning it, if not a new song will be picked up."},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Version: "2.1.2",
|
||||||
|
Changelog: []string{"Added test server to swagger file."},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Version: "2.1.1",
|
||||||
|
Changelog: []string{"Fixed bug where wrong song was showed as currently played."},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Version: "2.1.0",
|
||||||
|
Changelog: []string{
|
||||||
|
"Added /addQue to add the last received song to the songQue.",
|
||||||
|
"Changed /rand and /rand/low to not add song to the que.",
|
||||||
|
"Changed /next to not call /rand when the end of the que is reached, instead the last song in the que will be resent.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Version: "2.0.3",
|
||||||
|
Changelog: []string{"Another small change that should fix the caching problem."},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Version: "2.0.2",
|
||||||
|
Changelog: []string{"Hopefully fixed the caching problem with random."},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Version: "2.0.1",
|
||||||
|
Changelog: []string{"Fixed CORS"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Version: "2.0.0",
|
||||||
|
Changelog: []string{"Rebuilt the application in Go."},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetLatestVersion() VersionData {
|
||||||
|
return data[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetVersionHistory() []VersionData {
|
||||||
|
return data
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"music-server/internal/logging"
|
||||||
|
|
||||||
|
"github.com/golang-migrate/migrate/v4"
|
||||||
|
"github.com/golang-migrate/migrate/v4/database/postgres"
|
||||||
|
_ "github.com/golang-migrate/migrate/v4/database/postgres"
|
||||||
|
"github.com/golang-migrate/migrate/v4/source/iofs"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
_ "github.com/lib/pq"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Database holds the database connection pool and context
|
||||||
|
type Database struct {
|
||||||
|
Pool *pgxpool.Pool
|
||||||
|
Ctx context.Context
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewDatabase creates a new Database instance with connection pool
|
||||||
|
func NewDatabase(host, port, user, password, dbname string) (*Database, error) {
|
||||||
|
ctx := context.Background()
|
||||||
|
psqlInfo := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable",
|
||||||
|
host, port, user, password, dbname)
|
||||||
|
|
||||||
|
logging.GetLogger().Debug("Database connection info",
|
||||||
|
zap.String("host", host),
|
||||||
|
zap.String("port", port),
|
||||||
|
zap.String("dbname", dbname))
|
||||||
|
|
||||||
|
pool, err := pgxpool.New(ctx, psqlInfo)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("unable to connect to database: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test connection
|
||||||
|
var success string
|
||||||
|
err = pool.QueryRow(ctx, "select 'Successfully connected!'").Scan(&success)
|
||||||
|
if err != nil {
|
||||||
|
pool.Close()
|
||||||
|
return nil, fmt.Errorf("database query failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
logging.GetLogger().Info("Database connected", zap.String("status", success))
|
||||||
|
|
||||||
|
return &Database{Pool: pool, Ctx: ctx}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close closes the database connection pool
|
||||||
|
func (db *Database) Close() {
|
||||||
|
if db.Pool != nil {
|
||||||
|
logging.GetLogger().Info("Closing database connection")
|
||||||
|
db.Pool.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunMigrations runs all pending database migrations to the latest version.
|
||||||
|
// Uses the existing pool to extract connection details.
|
||||||
|
func (db *Database) RunMigrations() error {
|
||||||
|
// Extract connection info from pool config
|
||||||
|
connConfig := db.Pool.Config().ConnConfig
|
||||||
|
migrationURL := fmt.Sprintf("postgres://%s:%s@%s:%d/%s?sslmode=disable",
|
||||||
|
connConfig.User,
|
||||||
|
connConfig.Password,
|
||||||
|
connConfig.Host,
|
||||||
|
connConfig.Port,
|
||||||
|
connConfig.Database)
|
||||||
|
|
||||||
|
logging.GetLogger().Debug("Migration info", zap.String("url", migrationURL))
|
||||||
|
|
||||||
|
sqlDb, err := sql.Open("postgres", migrationURL)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to open database for migration: %w", err)
|
||||||
|
}
|
||||||
|
defer sqlDb.Close()
|
||||||
|
|
||||||
|
driver, err := postgres.WithInstance(sqlDb, &postgres.Config{})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create migration driver: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
files, err := iofs.New(MigrationsFs, "migrations")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create migration files: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
m, err := migrate.NewWithInstance("iofs", files, "postgres", driver)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create migrator: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current version for logging
|
||||||
|
version, _, err := m.Version()
|
||||||
|
if err != nil {
|
||||||
|
logging.GetLogger().Error("Failed to get migration version", zap.String("error", err.Error()))
|
||||||
|
}
|
||||||
|
|
||||||
|
logging.GetLogger().Info("Migration version before", zap.Uint("version", version))
|
||||||
|
|
||||||
|
// Run all pending migrations to latest version
|
||||||
|
err = m.Up()
|
||||||
|
if err != nil {
|
||||||
|
if err == migrate.ErrNoChange {
|
||||||
|
logging.GetLogger().Info("Database already up to date")
|
||||||
|
} else {
|
||||||
|
return fmt.Errorf("migration failed: %w", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Get new version after migration
|
||||||
|
versionAfter, _, _ := m.Version()
|
||||||
|
logging.GetLogger().Info("Migrated to version", zap.Uint("version", versionAfter))
|
||||||
|
}
|
||||||
|
|
||||||
|
logging.GetLogger().Info("Migration completed")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
+14
-25
@@ -53,21 +53,6 @@ func CloseDb() {
|
|||||||
Dbpool.Close()
|
Dbpool.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
func Testf() {
|
|
||||||
rows, dbErr := Dbpool.Query(Ctx, "select game_name from game")
|
|
||||||
if dbErr != nil {
|
|
||||||
logging.GetLogger().Fatal("Query failed", zap.String("error", dbErr.Error()))
|
|
||||||
}
|
|
||||||
for rows.Next() {
|
|
||||||
var gameName string
|
|
||||||
dbErr = rows.Scan(&gameName)
|
|
||||||
if dbErr != nil {
|
|
||||||
logging.GetLogger().Error("Row scan failed", zap.String("error", dbErr.Error()))
|
|
||||||
}
|
|
||||||
logging.GetLogger().Debug("Game found", zap.String("name", gameName))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func ResetGameIdSeq() {
|
func ResetGameIdSeq() {
|
||||||
_, err := Dbpool.Query(Ctx, "SELECT setval('game_id_seq', (SELECT MAX(id) FROM game)+1);")
|
_, err := Dbpool.Query(Ctx, "SELECT setval('game_id_seq', (SELECT MAX(id) FROM game)+1);")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -136,19 +121,23 @@ func Migrate_db(host string, port string, user string, password string, dbname s
|
|||||||
// logging.GetLogger().Error("Force migration error", zap.String("error", err.Error()))
|
// logging.GetLogger().Error("Force migration error", zap.String("error", err.Error()))
|
||||||
//}
|
//}
|
||||||
|
|
||||||
err = m.Migrate(2)
|
// Use Up() to apply all pending migrations instead of Migrate(2)
|
||||||
//err = m.Up() // or m.Steps(2) if you want to explicitly set the number of migrations to run
|
err = m.Up()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logging.GetLogger().Error("Migration error", zap.String("error", err.Error()))
|
if err == migrate.ErrNoChange {
|
||||||
|
logging.GetLogger().Info("Database already up to date")
|
||||||
|
} else {
|
||||||
|
logging.GetLogger().Error("Migration error", zap.String("error", err.Error()))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
versionAfter, _, err := m.Version()
|
||||||
|
if err != nil {
|
||||||
|
logging.GetLogger().Error("Failed to get migration version after", zap.String("error", err.Error()))
|
||||||
|
} else {
|
||||||
|
logging.GetLogger().Info("Migrated to version", zap.Uint("version", versionAfter))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
versionAfter, _, err := m.Version()
|
|
||||||
if err != nil {
|
|
||||||
logging.GetLogger().Error("Failed to get migration version after", zap.String("error", err.Error()))
|
|
||||||
}
|
|
||||||
|
|
||||||
logging.GetLogger().Info("Migration version after", zap.Uint("version", versionAfter))
|
|
||||||
|
|
||||||
logging.GetLogger().Info("Migration completed")
|
logging.GetLogger().Info("Migration completed")
|
||||||
|
|
||||||
db.Close()
|
db.Close()
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
-- Drop indexes for sessions table
|
||||||
|
DROP INDEX IF EXISTS idx_sessions_expires;
|
||||||
|
DROP INDEX IF EXISTS idx_sessions_token;
|
||||||
|
DROP INDEX IF EXISTS idx_sessions_ip;
|
||||||
|
DROP INDEX IF EXISTS idx_sessions_created;
|
||||||
|
|
||||||
|
-- Drop sessions table
|
||||||
|
DROP TABLE IF EXISTS sessions;
|
||||||
|
|
||||||
|
-- Drop performance indexes for song_list
|
||||||
|
DROP INDEX IF EXISTS idx_song_list_match_date;
|
||||||
|
DROP INDEX IF EXISTS idx_song_list_match_id;
|
||||||
|
|
||||||
|
-- Drop performance indexes for song
|
||||||
|
DROP INDEX IF EXISTS idx_song_hash;
|
||||||
|
DROP INDEX IF EXISTS idx_song_path;
|
||||||
|
DROP INDEX IF EXISTS idx_song_game_id;
|
||||||
|
DROP INDEX IF EXISTS idx_song_game_id_song_name;
|
||||||
|
|
||||||
|
-- Drop performance indexes for game
|
||||||
|
DROP INDEX IF EXISTS idx_game_deleted;
|
||||||
|
DROP INDEX IF EXISTS idx_game_hash;
|
||||||
|
DROP INDEX IF EXISTS idx_game_path;
|
||||||
|
DROP INDEX IF EXISTS idx_game_name;
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
-- ============================================
|
||||||
|
-- PERFORMANCE INDEXES FOR EXISTING TABLES
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
-- Game table indexes
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_game_deleted ON game(deleted) WHERE deleted IS NULL;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_game_hash ON game(hash);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_game_path ON game(path);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_game_name ON game(game_name);
|
||||||
|
|
||||||
|
-- Song table indexes
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_song_hash ON song(hash);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_song_path ON song(path);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_song_game_id ON song(game_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_song_game_id_song_name ON song(game_id, song_name);
|
||||||
|
|
||||||
|
-- Song_list table indexes
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_song_list_match_date ON song_list(match_date);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_song_list_match_id ON song_list(match_id);
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- SESSIONS TABLE FOR TOKEN MANAGEMENT
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
-- Create sessions table for tracking client tokens
|
||||||
|
CREATE TABLE sessions (
|
||||||
|
token VARCHAR(64) PRIMARY KEY,
|
||||||
|
ip_address VARCHAR(45) NOT NULL,
|
||||||
|
user_agent TEXT NOT NULL,
|
||||||
|
client_type VARCHAR(20) DEFAULT 'web',
|
||||||
|
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indexes for fast lookup and cleanup
|
||||||
|
CREATE INDEX idx_sessions_expires ON sessions(expires_at);
|
||||||
|
CREATE INDEX idx_sessions_token ON sessions(token);
|
||||||
|
CREATE INDEX idx_sessions_ip ON sessions(ip_address);
|
||||||
|
CREATE INDEX idx_sessions_created ON sessions(created_at);
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
-- name: CreateSession :one
|
||||||
|
INSERT INTO sessions (token, ip_address, user_agent, client_type, expires_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
RETURNING token, ip_address, user_agent, client_type, expires_at, created_at;
|
||||||
|
|
||||||
|
-- name: GetSession :one
|
||||||
|
SELECT token, ip_address, user_agent, client_type, expires_at, created_at
|
||||||
|
FROM sessions
|
||||||
|
WHERE token = $1
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
|
-- name: DeleteSession :exec
|
||||||
|
DELETE FROM sessions
|
||||||
|
WHERE token = $1;
|
||||||
|
|
||||||
|
-- name: DeleteExpiredSessions :exec
|
||||||
|
DELETE FROM sessions
|
||||||
|
WHERE expires_at < NOW();
|
||||||
|
|
||||||
|
-- name: ListSessions :many
|
||||||
|
SELECT token, ip_address, user_agent, client_type, expires_at, created_at
|
||||||
|
FROM sessions
|
||||||
|
ORDER BY created_at DESC;
|
||||||
@@ -6,6 +6,8 @@ package repository
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Game struct {
|
type Game struct {
|
||||||
@@ -21,6 +23,15 @@ type Game struct {
|
|||||||
Hash string `json:"hash"`
|
Hash string `json:"hash"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Session struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
IpAddress string `json:"ip_address"`
|
||||||
|
UserAgent string `json:"user_agent"`
|
||||||
|
ClientType *string `json:"client_type"`
|
||||||
|
ExpiresAt pgtype.Timestamptz `json:"expires_at"`
|
||||||
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
type Song struct {
|
type Song struct {
|
||||||
GameID int32 `json:"game_id"`
|
GameID int32 `json:"game_id"`
|
||||||
SongName string `json:"song_name"`
|
SongName string `json:"song_name"`
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// sqlc v1.31.1
|
||||||
|
// source: session.sql
|
||||||
|
|
||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
)
|
||||||
|
|
||||||
|
const createSession = `-- name: CreateSession :one
|
||||||
|
INSERT INTO sessions (token, ip_address, user_agent, client_type, expires_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
RETURNING token, ip_address, user_agent, client_type, expires_at, created_at
|
||||||
|
`
|
||||||
|
|
||||||
|
type CreateSessionParams struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
IpAddress string `json:"ip_address"`
|
||||||
|
UserAgent string `json:"user_agent"`
|
||||||
|
ClientType *string `json:"client_type"`
|
||||||
|
ExpiresAt pgtype.Timestamptz `json:"expires_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) CreateSession(ctx context.Context, arg CreateSessionParams) (Session, error) {
|
||||||
|
row := q.db.QueryRow(ctx, createSession,
|
||||||
|
arg.Token,
|
||||||
|
arg.IpAddress,
|
||||||
|
arg.UserAgent,
|
||||||
|
arg.ClientType,
|
||||||
|
arg.ExpiresAt,
|
||||||
|
)
|
||||||
|
var i Session
|
||||||
|
err := row.Scan(
|
||||||
|
&i.Token,
|
||||||
|
&i.IpAddress,
|
||||||
|
&i.UserAgent,
|
||||||
|
&i.ClientType,
|
||||||
|
&i.ExpiresAt,
|
||||||
|
&i.CreatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteExpiredSessions = `-- name: DeleteExpiredSessions :exec
|
||||||
|
DELETE FROM sessions
|
||||||
|
WHERE expires_at < NOW()
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) DeleteExpiredSessions(ctx context.Context) error {
|
||||||
|
_, err := q.db.Exec(ctx, deleteExpiredSessions)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteSession = `-- name: DeleteSession :exec
|
||||||
|
DELETE FROM sessions
|
||||||
|
WHERE token = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) DeleteSession(ctx context.Context, token string) error {
|
||||||
|
_, err := q.db.Exec(ctx, deleteSession, token)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
const getSession = `-- name: GetSession :one
|
||||||
|
SELECT token, ip_address, user_agent, client_type, expires_at, created_at
|
||||||
|
FROM sessions
|
||||||
|
WHERE token = $1
|
||||||
|
LIMIT 1
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) GetSession(ctx context.Context, token string) (Session, error) {
|
||||||
|
row := q.db.QueryRow(ctx, getSession, token)
|
||||||
|
var i Session
|
||||||
|
err := row.Scan(
|
||||||
|
&i.Token,
|
||||||
|
&i.IpAddress,
|
||||||
|
&i.UserAgent,
|
||||||
|
&i.ClientType,
|
||||||
|
&i.ExpiresAt,
|
||||||
|
&i.CreatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const listSessions = `-- name: ListSessions :many
|
||||||
|
SELECT token, ip_address, user_agent, client_type, expires_at, created_at
|
||||||
|
FROM sessions
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) ListSessions(ctx context.Context) ([]Session, error) {
|
||||||
|
rows, err := q.db.Query(ctx, listSessions)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []Session
|
||||||
|
for rows.Next() {
|
||||||
|
var i Session
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.Token,
|
||||||
|
&i.IpAddress,
|
||||||
|
&i.UserAgent,
|
||||||
|
&i.ClientType,
|
||||||
|
&i.ExpiresAt,
|
||||||
|
&i.CreatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/labstack/echo/v5"
|
||||||
|
"music-server/internal/backend"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CharacterHandler struct {
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCharacterHandler() *CharacterHandler {
|
||||||
|
return &CharacterHandler{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCharacterList godoc
|
||||||
|
// @Summary Get list of characters
|
||||||
|
// @Description Returns a list of all available characters
|
||||||
|
// @Tags characters
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Success 200 {array} string
|
||||||
|
// @Router /characters [get]
|
||||||
|
func (c *CharacterHandler) GetCharacterList(ctx *echo.Context) error {
|
||||||
|
characters := backend.GetCharacterList()
|
||||||
|
return ctx.JSON(http.StatusOK, characters)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCharacter godoc
|
||||||
|
// @Summary Get character image
|
||||||
|
// @Description Returns the image for a specific character
|
||||||
|
// @Tags characters
|
||||||
|
// @Accept json
|
||||||
|
// @Produce image/png
|
||||||
|
// @Param name query string true "Character name"
|
||||||
|
// @Success 200 {file} file
|
||||||
|
// @Router /character [get]
|
||||||
|
func (c *CharacterHandler) GetCharacter(ctx *echo.Context) error {
|
||||||
|
character := ctx.QueryParam("name")
|
||||||
|
return ctx.File(backend.GetCharacter(character))
|
||||||
|
}
|
||||||
@@ -5,45 +5,9 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"music-server/internal/backend"
|
|
||||||
"music-server/internal/db"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestHealthCheck verifies the health endpoint returns database status
|
|
||||||
func TestHealthCheck(t *testing.T) {
|
|
||||||
// Setup database
|
|
||||||
db.TestSetupDB(t)
|
|
||||||
defer db.TestTearDownDB(t)
|
|
||||||
|
|
||||||
e := StartTestServer(t)
|
|
||||||
|
|
||||||
resp := MakeTestRequest(t, e, "GET", "/health")
|
|
||||||
assert.Equal(t, http.StatusOK, resp.Code)
|
|
||||||
|
|
||||||
var healthData map[string]string
|
|
||||||
err := json.Unmarshal(resp.Body.Bytes(), &healthData)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
assert.NotEmpty(t, healthData)
|
|
||||||
assert.Equal(t, "up", healthData["status"])
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestGetVersion verifies the version endpoint returns version history
|
|
||||||
func TestGetVersion(t *testing.T) {
|
|
||||||
e := StartTestServer(t)
|
|
||||||
|
|
||||||
resp := MakeTestRequest(t, e, "GET", "/version")
|
|
||||||
assert.Equal(t, http.StatusOK, resp.Code)
|
|
||||||
|
|
||||||
var versionData backend.VersionData
|
|
||||||
err := json.Unmarshal(resp.Body.Bytes(), &versionData)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
assert.NotEmpty(t, versionData.Version)
|
|
||||||
assert.NotEmpty(t, versionData.Changelog)
|
|
||||||
assert.NotEmpty(t, versionData.History)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestGetCharacterList verifies the characters endpoint returns list of characters
|
// TestGetCharacterList verifies the characters endpoint returns list of characters
|
||||||
func TestGetCharacterList(t *testing.T) {
|
func TestGetCharacterList(t *testing.T) {
|
||||||
e := StartTestServer(t)
|
e := StartTestServer(t)
|
||||||
@@ -81,16 +45,3 @@ func TestGetCharacterNotFound(t *testing.T) {
|
|||||||
// Should return 404 or similar error
|
// Should return 404 or similar error
|
||||||
assert.NotEqual(t, http.StatusOK, resp.Code)
|
assert.NotEqual(t, http.StatusOK, resp.Code)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestDBTest verifies the database test endpoint
|
|
||||||
func TestDBTest(t *testing.T) {
|
|
||||||
// Setup database
|
|
||||||
db.TestSetupDB(t)
|
|
||||||
defer db.TestTearDownDB(t)
|
|
||||||
|
|
||||||
e := StartTestServer(t)
|
|
||||||
|
|
||||||
resp := MakeTestRequest(t, e, "GET", "/dbtest")
|
|
||||||
assert.Equal(t, http.StatusOK, resp.Code)
|
|
||||||
assert.Contains(t, resp.Body.String(), "TestedDB")
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/labstack/echo/v5"
|
||||||
|
"music-server/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
type HealthHandler struct {
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHealthHandler() *HealthHandler {
|
||||||
|
return &HealthHandler{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HealthCheck godoc
|
||||||
|
//
|
||||||
|
// @Summary Check server health
|
||||||
|
// @Description Returns the health status of the server
|
||||||
|
// @Tags health
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Success 200 {string} string "OK"
|
||||||
|
// @Router /health [get]
|
||||||
|
func (h *HealthHandler) HealthCheck(ctx *echo.Context) error {
|
||||||
|
return ctx.JSON(http.StatusOK, db.Health())
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"music-server/internal/db"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestHealthCheck verifies the health endpoint returns database status
|
||||||
|
func TestHealthCheck(t *testing.T) {
|
||||||
|
// Setup database
|
||||||
|
db.TestSetupDB(t)
|
||||||
|
defer db.TestTearDownDB(t)
|
||||||
|
|
||||||
|
e := StartTestServer(t)
|
||||||
|
|
||||||
|
resp := MakeTestRequest(t, e, "GET", "/health")
|
||||||
|
assert.Equal(t, http.StatusOK, resp.Code)
|
||||||
|
|
||||||
|
var healthData map[string]string
|
||||||
|
err := json.Unmarshal(resp.Body.Bytes(), &healthData)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotEmpty(t, healthData)
|
||||||
|
assert.Equal(t, "up", healthData["status"])
|
||||||
|
}
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
package server
|
|
||||||
|
|
||||||
import (
|
|
||||||
"music-server/internal/backend"
|
|
||||||
"music-server/internal/db"
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"github.com/labstack/echo/v5"
|
|
||||||
)
|
|
||||||
|
|
||||||
type IndexHandler struct {
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewIndexHandler() *IndexHandler {
|
|
||||||
return &IndexHandler{}
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetVersion godoc
|
|
||||||
//
|
|
||||||
// @Summary Getting the version of the backend
|
|
||||||
// @Description get string by ID
|
|
||||||
// @Tags accounts
|
|
||||||
// @Accept json
|
|
||||||
// @Produce json
|
|
||||||
// @Success 200 {object} backend.VersionData
|
|
||||||
// @Failure 404 {object} string
|
|
||||||
// @Router /version [get]
|
|
||||||
func (i *IndexHandler) GetVersion(ctx *echo.Context) error {
|
|
||||||
versionHistory := backend.GetVersionHistory()
|
|
||||||
if versionHistory.Version == "" {
|
|
||||||
return ctx.JSON(http.StatusNotFound, "version not found")
|
|
||||||
}
|
|
||||||
return ctx.JSON(http.StatusOK, versionHistory)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetDBTest godoc
|
|
||||||
// @Summary Test database connection
|
|
||||||
// @Description Tests the database connection
|
|
||||||
// @Tags database
|
|
||||||
// @Accept json
|
|
||||||
// @Produce json
|
|
||||||
// @Success 200 {string} string "TestedDB"
|
|
||||||
// @Router /dbtest [get]
|
|
||||||
func (i *IndexHandler) GetDBTest(ctx *echo.Context) error {
|
|
||||||
backend.TestDB()
|
|
||||||
return ctx.JSON(http.StatusOK, "TestedDB")
|
|
||||||
}
|
|
||||||
|
|
||||||
// HealthCheck godoc
|
|
||||||
// @Summary Check server health
|
|
||||||
// @Description Returns the health status of the server
|
|
||||||
// @Tags health
|
|
||||||
// @Accept json
|
|
||||||
// @Produce json
|
|
||||||
// @Success 200 {string} string "OK"
|
|
||||||
// @Router /health [get]
|
|
||||||
func (i *IndexHandler) HealthCheck(ctx *echo.Context) error {
|
|
||||||
return ctx.JSON(http.StatusOK, db.Health())
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetCharacterList godoc
|
|
||||||
// @Summary Get list of characters
|
|
||||||
// @Description Returns a list of all available characters
|
|
||||||
// @Tags characters
|
|
||||||
// @Accept json
|
|
||||||
// @Produce json
|
|
||||||
// @Success 200 {array} string
|
|
||||||
// @Router /characters [get]
|
|
||||||
func (i *IndexHandler) GetCharacterList(ctx *echo.Context) error {
|
|
||||||
characters := backend.GetCharacterList()
|
|
||||||
return ctx.JSON(http.StatusOK, characters)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetCharacter godoc
|
|
||||||
// @Summary Get character image
|
|
||||||
// @Description Returns the image for a specific character
|
|
||||||
// @Tags characters
|
|
||||||
// @Accept json
|
|
||||||
// @Produce image/png
|
|
||||||
// @Param name query string true "Character name"
|
|
||||||
// @Success 200 {file} file
|
|
||||||
// @Router /character [get]
|
|
||||||
func (i *IndexHandler) GetCharacter(ctx *echo.Context) error {
|
|
||||||
character := ctx.QueryParam("name")
|
|
||||||
return ctx.File(backend.GetCharacter(character))
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
// Package middleware provides Echo middleware for the MusicServer application.
|
||||||
|
package middleware
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"music-server/internal/db/repository"
|
||||||
|
"music-server/internal/logging"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
"github.com/labstack/echo/v5"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TokenAuthMiddleware returns an Echo middleware that validates session tokens
|
||||||
|
func TokenAuthMiddleware(pool *pgxpool.Pool) echo.MiddlewareFunc {
|
||||||
|
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||||
|
return func(c *echo.Context) error {
|
||||||
|
// Extract token from Authorization header
|
||||||
|
authHeader := c.Request().Header.Get("Authorization")
|
||||||
|
if authHeader == "" {
|
||||||
|
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Authorization header required"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bearer token format
|
||||||
|
parts := strings.Split(authHeader, " ")
|
||||||
|
if len(parts) != 2 || parts[0] != "Bearer" {
|
||||||
|
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Invalid authorization format. Use: Bearer <token>"})
|
||||||
|
}
|
||||||
|
|
||||||
|
token := parts[1]
|
||||||
|
queries := repository.New(pool)
|
||||||
|
session, err := queries.GetSession(c.Request().Context(), token)
|
||||||
|
if err != nil {
|
||||||
|
logging.GetLogger().Warn("Invalid token attempt",
|
||||||
|
zap.String("token", token),
|
||||||
|
zap.String("ip", c.RealIP()),
|
||||||
|
zap.String("error", err.Error()),
|
||||||
|
)
|
||||||
|
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Invalid or expired token"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if token is expired
|
||||||
|
if time.Now().After(session.ExpiresAt.Time) {
|
||||||
|
// Clean up expired session in background
|
||||||
|
go func() {
|
||||||
|
queries.DeleteSession(c.Request().Context(), token)
|
||||||
|
}()
|
||||||
|
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Token expired"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add session to request context for potential use by handlers
|
||||||
|
c.Set("session", session)
|
||||||
|
|
||||||
|
return next(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TokenIPCheckMiddleware checks if the request IP matches the session IP
|
||||||
|
func TokenIPCheckMiddleware(next echo.HandlerFunc) echo.HandlerFunc {
|
||||||
|
return func(c *echo.Context) error {
|
||||||
|
sessionVal := c.Get("session")
|
||||||
|
if sessionVal == nil {
|
||||||
|
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "No session in context"})
|
||||||
|
}
|
||||||
|
session := sessionVal.(repository.Session)
|
||||||
|
if session.IpAddress != c.RealIP() {
|
||||||
|
logging.GetLogger().Warn("Token IP mismatch",
|
||||||
|
zap.String("token_ip", session.IpAddress),
|
||||||
|
zap.String("request_ip", c.RealIP()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return next(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
+42
-10
@@ -2,15 +2,16 @@ package server
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"music-server/cmd/web"
|
"music-server/cmd/web"
|
||||||
|
"music-server/internal/logging"
|
||||||
|
"music-server/internal/server/middleware"
|
||||||
"net/http"
|
"net/http"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/a-h/templ"
|
"github.com/a-h/templ"
|
||||||
"github.com/labstack/echo/v5"
|
"github.com/labstack/echo/v5"
|
||||||
"github.com/labstack/echo/v5/middleware"
|
echoMiddleware "github.com/labstack/echo/v5/middleware"
|
||||||
echoSwagger "github.com/swaggo/echo-swagger/v2"
|
echoSwagger "github.com/swaggo/echo-swagger/v2"
|
||||||
"music-server/internal/logging"
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -36,9 +37,9 @@ func (s *Server) RegisterRoutes() http.Handler {
|
|||||||
http.ServeFile(w, r, "cmd/docs/swagger.json")
|
http.ServeFile(w, r, "cmd/docs/swagger.json")
|
||||||
})))
|
})))
|
||||||
e.Use(logging.RequestLogger())
|
e.Use(logging.RequestLogger())
|
||||||
e.Use(middleware.Recover())
|
e.Use(echoMiddleware.Recover())
|
||||||
|
|
||||||
e.Use(middleware.CORSWithConfig(middleware.CORSConfig{
|
e.Use(echoMiddleware.CORSWithConfig(echoMiddleware.CORSConfig{
|
||||||
AllowOrigins: []string{"https://*", "http://*"},
|
AllowOrigins: []string{"https://*", "http://*"},
|
||||||
AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"},
|
AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"},
|
||||||
AllowHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"},
|
AllowHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"},
|
||||||
@@ -57,12 +58,16 @@ func (s *Server) RegisterRoutes() http.Handler {
|
|||||||
// Swagger UI
|
// Swagger UI
|
||||||
e.GET("/swagger/*", echoSwagger.WrapHandler)
|
e.GET("/swagger/*", echoSwagger.WrapHandler)
|
||||||
|
|
||||||
index := NewIndexHandler()
|
health := NewHealthHandler()
|
||||||
e.GET("/version", index.GetVersion)
|
e.GET("/health", health.HealthCheck)
|
||||||
e.GET("/dbtest", index.GetDBTest)
|
|
||||||
e.GET("/health", index.HealthCheck)
|
version := NewVersionHandler()
|
||||||
e.GET("/character", index.GetCharacter)
|
e.GET("/version", version.GetLatestVersion)
|
||||||
e.GET("/characters", index.GetCharacterList)
|
e.GET("/version/history", version.GetVersionHistory)
|
||||||
|
|
||||||
|
character := NewCharacterHandler()
|
||||||
|
e.GET("/character", character.GetCharacter)
|
||||||
|
e.GET("/characters", character.GetCharacterList)
|
||||||
|
|
||||||
download := NewDownloadHandler()
|
download := NewDownloadHandler()
|
||||||
e.GET("/download", download.checkLatest)
|
e.GET("/download", download.checkLatest)
|
||||||
@@ -99,6 +104,33 @@ func (s *Server) RegisterRoutes() http.Handler {
|
|||||||
musicGroup.GET("/addQue", music.AddLatestToQue)
|
musicGroup.GET("/addQue", music.AddLatestToQue)
|
||||||
musicGroup.GET("/addPlayed", music.AddLatestPlayed)
|
musicGroup.GET("/addPlayed", music.AddLatestPlayed)
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// API v1 Routes with Token Authentication
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
// Create /api/v1 group
|
||||||
|
apiV1 := e.Group("/api/v1")
|
||||||
|
|
||||||
|
// Public endpoints - no token required
|
||||||
|
apiV1.POST("/token", func(c *echo.Context) error {
|
||||||
|
return s.tokenHandler.CreateTokenHandler(c)
|
||||||
|
})
|
||||||
|
apiV1.DELETE("/token", func(c *echo.Context) error {
|
||||||
|
return s.tokenHandler.DeleteTokenHandler(c)
|
||||||
|
})
|
||||||
|
apiV1.POST("/token/cleanup", func(c *echo.Context) error {
|
||||||
|
return s.tokenHandler.CleanupExpiredSessionsHandler(c)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Protected endpoints - require valid token
|
||||||
|
// Create token auth middleware with pool access
|
||||||
|
tokenAuthMiddleware := middleware.TokenAuthMiddleware(s.db.Pool)
|
||||||
|
|
||||||
|
// Protected group with token authentication - will be used by VGMQ and Statistics API
|
||||||
|
_ = apiV1.Group("", tokenAuthMiddleware)
|
||||||
|
|
||||||
|
// Note: Future protected endpoints (VGMQ, Statistics) will be added here
|
||||||
|
|
||||||
routes := e.Router().Routes()
|
routes := e.Router().Routes()
|
||||||
sort.Slice(routes, func(i, j int) bool {
|
sort.Slice(routes, func(i, j int) bool {
|
||||||
return routes[i].Path < routes[j].Path
|
return routes[i].Path < routes[j].Path
|
||||||
|
|||||||
+62
-23
@@ -6,6 +6,7 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"music-server/internal/backend"
|
||||||
"music-server/internal/db"
|
"music-server/internal/db"
|
||||||
"music-server/internal/logging"
|
"music-server/internal/logging"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -14,7 +15,10 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Server struct {
|
type Server struct {
|
||||||
port int
|
port int
|
||||||
|
db *db.Database
|
||||||
|
tokenHandler *TokenHandler
|
||||||
|
httpServer *http.Server
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -29,7 +33,9 @@ var (
|
|||||||
logJSON = os.Getenv("LOG_JSON") == "true"
|
logJSON = os.Getenv("LOG_JSON") == "true"
|
||||||
)
|
)
|
||||||
|
|
||||||
func NewServer() *http.Server {
|
// NewServerInstance creates a new Server instance with all dependencies initialized.
|
||||||
|
// Use this for dependency injection and proper lifecycle management.
|
||||||
|
func NewServerInstance() *Server {
|
||||||
// Initialize logger
|
// Initialize logger
|
||||||
if logLevel == "" {
|
if logLevel == "" {
|
||||||
logLevel = "info"
|
logLevel = "info"
|
||||||
@@ -39,8 +45,43 @@ func NewServer() *http.Server {
|
|||||||
logger := logging.GetLogger()
|
logger := logging.GetLogger()
|
||||||
|
|
||||||
port, _ := strconv.Atoi(os.Getenv("PORT"))
|
port, _ := strconv.Atoi(os.Getenv("PORT"))
|
||||||
NewServer := &Server{
|
|
||||||
port: port,
|
// Validate required environment variables
|
||||||
|
if host == "" || dbPort == "" || username == "" || password == "" || dbName == "" || musicPath == "" || charactersPath == "" {
|
||||||
|
logging.GetLogger().Fatal("Invalid settings - missing required environment variables")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create database instance
|
||||||
|
database, err := db.NewDatabase(host, dbPort, username, password, dbName)
|
||||||
|
if err != nil {
|
||||||
|
logging.GetLogger().Fatal("Failed to initialize database", zap.String("error", err.Error()))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run migrations using the new method
|
||||||
|
if err := database.RunMigrations(); err != nil {
|
||||||
|
logging.GetLogger().Fatal("Migration failed", zap.String("error", err.Error()))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize backend package with database pool
|
||||||
|
backend.InitBackend(database.Pool)
|
||||||
|
|
||||||
|
// Initialize token handler with database pool
|
||||||
|
tokenHandler := NewTokenHandler(database.Pool)
|
||||||
|
|
||||||
|
// Create the server instance
|
||||||
|
appServer := &Server{
|
||||||
|
port: port,
|
||||||
|
db: database,
|
||||||
|
tokenHandler: tokenHandler,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the HTTP server
|
||||||
|
appServer.httpServer = &http.Server{
|
||||||
|
Addr: fmt.Sprintf(":%d", port),
|
||||||
|
Handler: appServer.RegisterRoutes(),
|
||||||
|
IdleTimeout: time.Minute,
|
||||||
|
ReadTimeout: 10 * time.Second,
|
||||||
|
WriteTimeout: 30 * time.Second,
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.Info("Starting server",
|
logger.Info("Starting server",
|
||||||
@@ -55,23 +96,21 @@ func NewServer() *http.Server {
|
|||||||
zap.String("charactersPath", charactersPath),
|
zap.String("charactersPath", charactersPath),
|
||||||
)
|
)
|
||||||
|
|
||||||
//conf.SetupDb()
|
return appServer
|
||||||
if host == "" || dbPort == "" || username == "" || password == "" || dbName == "" || musicPath == "" || charactersPath == "" {
|
}
|
||||||
logging.GetLogger().Fatal("Invalid settings - missing required environment variables")
|
|
||||||
}
|
// HTTPServer returns the underlying http.Server for serving HTTP requests.
|
||||||
|
func (s *Server) HTTPServer() *http.Server {
|
||||||
db.Migrate_db(host, dbPort, username, password, dbName)
|
return s.httpServer
|
||||||
|
}
|
||||||
db.InitDB(host, dbPort, username, password, dbName)
|
|
||||||
|
// DB returns the database instance for dependency injection.
|
||||||
// Declare Server config
|
func (s *Server) DB() *db.Database {
|
||||||
server := &http.Server{
|
return s.db
|
||||||
Addr: fmt.Sprintf(":%d", NewServer.port),
|
}
|
||||||
Handler: NewServer.RegisterRoutes(),
|
|
||||||
IdleTimeout: time.Minute,
|
// NewServer creates a new HTTP server (deprecated, use NewServerInstance instead).
|
||||||
ReadTimeout: 10 * time.Second,
|
// This function is kept for backward compatibility.
|
||||||
WriteTimeout: 30 * time.Second,
|
func NewServer() *http.Server {
|
||||||
}
|
return NewServerInstance().HTTPServer()
|
||||||
|
|
||||||
return server
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,8 +75,8 @@ func TestSyncPopulatesDatabase(t *testing.T) {
|
|||||||
db.TestClearDatabase(t)
|
db.TestClearDatabase(t)
|
||||||
|
|
||||||
// Before sync - should have no games
|
// Before sync - should have no games
|
||||||
repo := repository.New(db.Dbpool)
|
repo := repository.New(backend.BackendPool())
|
||||||
gamesBefore, err := repo.FindAllGames(db.Ctx)
|
gamesBefore, err := repo.FindAllGames(backend.BackendCtx())
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
beforeCount := len(gamesBefore)
|
beforeCount := len(gamesBefore)
|
||||||
t.Logf("Games before sync: %d", beforeCount)
|
t.Logf("Games before sync: %d", beforeCount)
|
||||||
@@ -92,7 +92,7 @@ func TestSyncPopulatesDatabase(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// After sync - should have games
|
// After sync - should have games
|
||||||
gamesAfter, err := repo.FindAllGames(db.Ctx)
|
gamesAfter, err := repo.FindAllGames(backend.BackendCtx())
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
afterCount := len(gamesAfter)
|
afterCount := len(gamesAfter)
|
||||||
t.Logf("Games after sync: %d", afterCount)
|
t.Logf("Games after sync: %d", afterCount)
|
||||||
@@ -112,8 +112,8 @@ func TestSyncMakesDifference(t *testing.T) {
|
|||||||
db.TestClearDatabase(t)
|
db.TestClearDatabase(t)
|
||||||
|
|
||||||
// Before sync - should have no games
|
// Before sync - should have no games
|
||||||
repo := repository.New(db.Dbpool)
|
repo := repository.New(backend.BackendPool())
|
||||||
gamesBefore, err := repo.FindAllGames(db.Ctx)
|
gamesBefore, err := repo.FindAllGames(backend.BackendCtx())
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Equal(t, 0, len(gamesBefore), "Should have no games before sync")
|
assert.Equal(t, 0, len(gamesBefore), "Should have no games before sync")
|
||||||
|
|
||||||
@@ -127,7 +127,7 @@ func TestSyncMakesDifference(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// After sync - should have games
|
// After sync - should have games
|
||||||
gamesAfter, err := repo.FindAllGames(db.Ctx)
|
gamesAfter, err := repo.FindAllGames(backend.BackendCtx())
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.True(t, len(gamesAfter) > 0, "Should have games after sync")
|
assert.True(t, len(gamesAfter) > 0, "Should have games after sync")
|
||||||
}
|
}
|
||||||
@@ -199,8 +199,8 @@ func TestSyncGamesNewOnlyChanges(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get initial count
|
// Get initial count
|
||||||
repo := repository.New(db.Dbpool)
|
repo := repository.New(backend.BackendPool())
|
||||||
gamesBefore, _ := repo.FindAllGames(db.Ctx)
|
gamesBefore, _ := repo.FindAllGames(backend.BackendCtx())
|
||||||
beforeCount := len(gamesBefore)
|
beforeCount := len(gamesBefore)
|
||||||
|
|
||||||
// Run incremental sync (should not change count if nothing changed)
|
// Run incremental sync (should not change count if nothing changed)
|
||||||
@@ -211,7 +211,7 @@ func TestSyncGamesNewOnlyChanges(t *testing.T) {
|
|||||||
time.Sleep(2 * time.Second)
|
time.Sleep(2 * time.Second)
|
||||||
|
|
||||||
// Count should be the same
|
// Count should be the same
|
||||||
gamesAfter, _ := repo.FindAllGames(db.Ctx)
|
gamesAfter, _ := repo.FindAllGames(backend.BackendCtx())
|
||||||
afterCount := len(gamesAfter)
|
afterCount := len(gamesAfter)
|
||||||
|
|
||||||
// Note: This might not be exactly equal due to timing, but should be close
|
// Note: This might not be exactly equal due to timing, but should be close
|
||||||
@@ -227,8 +227,8 @@ func TestResetGames(t *testing.T) {
|
|||||||
e := StartTestServer(t)
|
e := StartTestServer(t)
|
||||||
|
|
||||||
// First ensure we have data
|
// First ensure we have data
|
||||||
repo := repository.New(db.Dbpool)
|
repo := repository.New(backend.BackendPool())
|
||||||
gamesBefore, _ := repo.FindAllGames(db.Ctx)
|
gamesBefore, _ := repo.FindAllGames(backend.BackendCtx())
|
||||||
beforeCount := len(gamesBefore)
|
beforeCount := len(gamesBefore)
|
||||||
|
|
||||||
if beforeCount == 0 {
|
if beforeCount == 0 {
|
||||||
@@ -238,7 +238,7 @@ func TestResetGames(t *testing.T) {
|
|||||||
t.Error("Sync did not complete within timeout")
|
t.Error("Sync did not complete within timeout")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
gamesBefore, _ = repo.FindAllGames(db.Ctx)
|
gamesBefore, _ = repo.FindAllGames(backend.BackendCtx())
|
||||||
beforeCount = len(gamesBefore)
|
beforeCount = len(gamesBefore)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -253,7 +253,7 @@ func TestResetGames(t *testing.T) {
|
|||||||
// Note: reset might take a moment to propagate
|
// Note: reset might take a moment to propagate
|
||||||
time.Sleep(1 * time.Second)
|
time.Sleep(1 * time.Second)
|
||||||
|
|
||||||
gamesAfter, _ := repo.FindAllGames(db.Ctx)
|
gamesAfter, _ := repo.FindAllGames(backend.BackendCtx())
|
||||||
afterCount := len(gamesAfter)
|
afterCount := len(gamesAfter)
|
||||||
|
|
||||||
t.Logf("Games after reset: %d", afterCount)
|
t.Logf("Games after reset: %d", afterCount)
|
||||||
@@ -281,8 +281,8 @@ func TestSyncGamesNewFull(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Verify database is populated
|
// Verify database is populated
|
||||||
repo := repository.New(db.Dbpool)
|
repo := repository.New(backend.BackendPool())
|
||||||
games, err := repo.FindAllGames(db.Ctx)
|
games, err := repo.FindAllGames(backend.BackendCtx())
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.True(t, len(games) > 0, "Database should be populated after full sync")
|
assert.True(t, len(games) > 0, "Database should be populated after full sync")
|
||||||
t.Logf("Full sync populated %d games", len(games))
|
t.Logf("Full sync populated %d games", len(games))
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"music-server/internal/backend"
|
||||||
|
"music-server/internal/db"
|
||||||
|
|
||||||
"github.com/labstack/echo/v5"
|
"github.com/labstack/echo/v5"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -45,8 +48,23 @@ func StartTestServer(t *testing.T) *echo.Echo {
|
|||||||
os.Setenv("LOG_JSON", "false")
|
os.Setenv("LOG_JSON", "false")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Initialize database for tests
|
||||||
|
db.TestSetupDB(t)
|
||||||
|
|
||||||
|
// Initialize backend with the global Dbpool
|
||||||
|
// This ensures BackendRepo() and BackendCtx() are available
|
||||||
|
if db.Dbpool != nil {
|
||||||
|
backend.InitBackend(db.Dbpool)
|
||||||
|
}
|
||||||
|
|
||||||
// Create a Server instance and get its routes
|
// Create a Server instance and get its routes
|
||||||
s := &Server{}
|
s := &Server{
|
||||||
|
db: &db.Database{
|
||||||
|
Pool: db.Dbpool,
|
||||||
|
Ctx: db.Ctx,
|
||||||
|
},
|
||||||
|
tokenHandler: NewTokenHandler(db.Dbpool),
|
||||||
|
}
|
||||||
handler := s.RegisterRoutes()
|
handler := s.RegisterRoutes()
|
||||||
|
|
||||||
// Wrap the http.Handler in an echo.Echo
|
// Wrap the http.Handler in an echo.Echo
|
||||||
|
|||||||
@@ -0,0 +1,175 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"music-server/internal/db/repository"
|
||||||
|
"music-server/internal/logging"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
"github.com/labstack/echo/v5"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TokenRequest represents a request to generate a new token
|
||||||
|
type TokenRequest struct {
|
||||||
|
ClientType string `json:"client_type"` // Optional: "web", "mobile", "api"
|
||||||
|
}
|
||||||
|
|
||||||
|
// TokenResponse represents the response with a new token
|
||||||
|
type TokenResponse struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
ExpiresAt time.Time `json:"expires_at"`
|
||||||
|
ClientType string `json:"client_type"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TokenHandler contains the database pool for token operations
|
||||||
|
type TokenHandler struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewTokenHandler creates a new token handler with database pool
|
||||||
|
func NewTokenHandler(pool *pgxpool.Pool) *TokenHandler {
|
||||||
|
return &TokenHandler{
|
||||||
|
pool: pool,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateToken creates a new cryptographically secure token
|
||||||
|
func (h *TokenHandler) generateToken() (string, error) {
|
||||||
|
b := make([]byte, 32)
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return base64.URLEncoding.EncodeToString(b), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateTokenHandler creates a new session token
|
||||||
|
// POST /api/v1/token
|
||||||
|
//
|
||||||
|
// @Summary Create session token
|
||||||
|
// @Description Returns a new session token for API access
|
||||||
|
// @Tags auth
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param request body TokenRequest true "Client type"
|
||||||
|
// @Success 200 {object} TokenResponse
|
||||||
|
// @Failure 400 {object} map[string]string
|
||||||
|
// @Failure 500 {object} map[string]string
|
||||||
|
// @Router /api/v1/token [post]
|
||||||
|
func (h *TokenHandler) CreateTokenHandler(c *echo.Context) error {
|
||||||
|
var req TokenRequest
|
||||||
|
if err := c.Bind(&req); err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request body"})
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.ClientType == "" {
|
||||||
|
req.ClientType = "web"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate token
|
||||||
|
token, err := h.generateToken()
|
||||||
|
if err != nil {
|
||||||
|
logging.GetLogger().Error("Failed to generate token", zap.String("error", err.Error()))
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to generate token"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set expiration (24 hours from now)
|
||||||
|
expiresAt := time.Now().Add(24 * time.Hour)
|
||||||
|
clientType := req.ClientType
|
||||||
|
|
||||||
|
// Store in database using sqlc-generated repository
|
||||||
|
queries := repository.New(h.pool)
|
||||||
|
session, err := queries.CreateSession(c.Request().Context(), repository.CreateSessionParams{
|
||||||
|
Token: token,
|
||||||
|
IpAddress: c.RealIP(),
|
||||||
|
UserAgent: c.Request().UserAgent(),
|
||||||
|
ClientType: &clientType,
|
||||||
|
ExpiresAt: pgtype.Timestamptz{Time: expiresAt, Valid: true},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
logging.GetLogger().Error("Failed to create session", zap.String("error", err.Error()))
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to create session"})
|
||||||
|
}
|
||||||
|
|
||||||
|
response := TokenResponse{
|
||||||
|
Token: session.Token,
|
||||||
|
ExpiresAt: session.ExpiresAt.Time,
|
||||||
|
ClientType: *session.ClientType,
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(http.StatusOK, response)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteTokenHandler invalidates a session token
|
||||||
|
// DELETE /api/v1/token
|
||||||
|
//
|
||||||
|
// @Summary Invalidate session token
|
||||||
|
// @Description Deletes the current session token
|
||||||
|
// @Tags auth
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param Authorization header string true "Bearer token"
|
||||||
|
// @Success 200 {object} map[string]string
|
||||||
|
// @Failure 401 {object} map[string]string
|
||||||
|
// @Failure 500 {object} map[string]string
|
||||||
|
// @Router /api/v1/token [delete]
|
||||||
|
func (h *TokenHandler) DeleteTokenHandler(c *echo.Context) error {
|
||||||
|
authHeader := c.Request().Header.Get("Authorization")
|
||||||
|
if authHeader == "" {
|
||||||
|
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Authorization header required"})
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := strings.Split(authHeader, " ")
|
||||||
|
if len(parts) != 2 || parts[0] != "Bearer" {
|
||||||
|
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Invalid authorization format"})
|
||||||
|
}
|
||||||
|
|
||||||
|
token := parts[1]
|
||||||
|
|
||||||
|
// Delete session using sqlc-generated repository
|
||||||
|
queries := repository.New(h.pool)
|
||||||
|
err := queries.DeleteSession(c.Request().Context(), token)
|
||||||
|
if err != nil {
|
||||||
|
logging.GetLogger().Error("Failed to delete session", zap.String("error", err.Error()))
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to invalidate token"})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(http.StatusOK, map[string]string{"status": "token invalidated"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// CleanupExpiredSessionsHandler removes all expired sessions
|
||||||
|
// POST /api/v1/token/cleanup
|
||||||
|
//
|
||||||
|
// @Summary Cleanup expired sessions
|
||||||
|
// @Description Removes all expired session tokens from the database
|
||||||
|
// @Tags auth
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param Authorization header string true "Bearer token"
|
||||||
|
// @Success 200 {object} map[string]interface{}
|
||||||
|
// @Failure 401 {object} map[string]string
|
||||||
|
// @Failure 500 {object} map[string]string
|
||||||
|
// @Router /api/v1/token/cleanup [post]
|
||||||
|
func (h *TokenHandler) CleanupExpiredSessionsHandler(c *echo.Context) error {
|
||||||
|
// Verify token is valid first (using existing middleware)
|
||||||
|
// The middleware will have already validated the token
|
||||||
|
|
||||||
|
queries := repository.New(h.pool)
|
||||||
|
err := queries.DeleteExpiredSessions(c.Request().Context())
|
||||||
|
if err != nil {
|
||||||
|
logging.GetLogger().Error("Failed to cleanup sessions", zap.String("error", err.Error()))
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to cleanup sessions"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get count of deleted rows (DeleteExpiredSessions doesn't return count in the generated code)
|
||||||
|
// So we just return success
|
||||||
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||||
|
"status": "cleanup complete",
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/labstack/echo/v5"
|
||||||
|
"music-server/internal/backend"
|
||||||
|
)
|
||||||
|
|
||||||
|
type VersionHandler struct {
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewVersionHandler() *VersionHandler {
|
||||||
|
return &VersionHandler{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetVersionHistory godoc
|
||||||
|
//
|
||||||
|
// @Summary Getting the version history of the backend
|
||||||
|
// @Description get version history
|
||||||
|
// @Tags version
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Success 200 {array} backend.VersionData
|
||||||
|
// @Failure 404 {object} string
|
||||||
|
// @Router /version/history [get]
|
||||||
|
func (v *VersionHandler) GetVersionHistory(ctx *echo.Context) error {
|
||||||
|
versionHistory := backend.GetVersionHistory()
|
||||||
|
if len(versionHistory) == 0 {
|
||||||
|
return ctx.JSON(http.StatusNotFound, "version not found")
|
||||||
|
}
|
||||||
|
return ctx.JSON(http.StatusOK, versionHistory)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLatestVersion godoc
|
||||||
|
//
|
||||||
|
// @Summary Getting the latest version of the backend
|
||||||
|
// @Description get latest version info
|
||||||
|
// @Tags version
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Success 200 {object} backend.VersionData
|
||||||
|
// @Failure 404 {object} string
|
||||||
|
// @Router /version [get]
|
||||||
|
func (v *VersionHandler) GetLatestVersion(ctx *echo.Context) error {
|
||||||
|
latestVersion := backend.GetLatestVersion()
|
||||||
|
if latestVersion.Version == "" {
|
||||||
|
return ctx.JSON(http.StatusNotFound, "version not found")
|
||||||
|
}
|
||||||
|
return ctx.JSON(http.StatusOK, latestVersion)
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"music-server/internal/backend"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestGetLatestVersion verifies the version endpoint returns latest version
|
||||||
|
func TestGetLatestVersion(t *testing.T) {
|
||||||
|
e := StartTestServer(t)
|
||||||
|
|
||||||
|
resp := MakeTestRequest(t, e, "GET", "/version")
|
||||||
|
assert.Equal(t, http.StatusOK, resp.Code)
|
||||||
|
|
||||||
|
var versionData backend.VersionData
|
||||||
|
err := json.Unmarshal(resp.Body.Bytes(), &versionData)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotEmpty(t, versionData.Version)
|
||||||
|
assert.NotEmpty(t, versionData.Changelog)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGetVersionHistory verifies the version history endpoint returns version history
|
||||||
|
func TestGetVersionHistory(t *testing.T) {
|
||||||
|
e := StartTestServer(t)
|
||||||
|
|
||||||
|
resp := MakeTestRequest(t, e, "GET", "/version/history")
|
||||||
|
assert.Equal(t, http.StatusOK, resp.Code)
|
||||||
|
|
||||||
|
var versionHistory []backend.VersionData
|
||||||
|
err := json.Unmarshal(resp.Body.Bytes(), &versionHistory)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotEmpty(t, versionHistory)
|
||||||
|
assert.NotEmpty(t, versionHistory[0].Version)
|
||||||
|
assert.NotEmpty(t, versionHistory[0].Changelog)
|
||||||
|
}
|
||||||
@@ -15,8 +15,8 @@ import (
|
|||||||
|
|
||||||
// ensureSyncRan ensures that sync has been run before testing music endpoints
|
// ensureSyncRan ensures that sync has been run before testing music endpoints
|
||||||
func ensureSyncRan(t *testing.T, e *echo.Echo) {
|
func ensureSyncRan(t *testing.T, e *echo.Echo) {
|
||||||
repo := repository.New(db.Dbpool)
|
repo := repository.New(backend.BackendPool())
|
||||||
games, err := repo.FindAllGames(db.Ctx)
|
games, err := repo.FindAllGames(backend.BackendCtx())
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
if len(games) == 0 {
|
if len(games) == 0 {
|
||||||
|
|||||||
Reference in New Issue
Block a user