From 6cc014ffa3dd97832380d5fdd0069552979bc7e6 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Mon, 1 Jun 2026 18:07:28 +0200 Subject: [PATCH 1/4] feat: Implement Session Token System with /api/v1 base path - Add migration 000004 for sessions table and performance indexes - Create session.sql queries for CRUD operations - Generate session repository code with sqlc - Create token auth middleware for Echo framework - Create token handler with create/delete/cleanup endpoints - Add /api/v1 router with token authentication infrastructure - Update dbHelper.go to use Up() instead of Migrate(2) - Update server.go to initialize token handler - Existing endpoints remain functional (to be deprecated) New endpoints: - POST /api/v1/token - Create new session token - DELETE /api/v1/token - Invalidate token - POST /api/v1/token/cleanup - Remove expired sessions Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe --- internal/db/dbHelper.go | 24 ++- ...create_sessions_table_and_indexes.down.sql | 24 +++ ...4_create_sessions_table_and_indexes.up.sql | 39 ++++ internal/db/queries/session.sql | 23 +++ internal/db/repository/models.go | 11 ++ internal/db/repository/session.sql.go | 120 ++++++++++++ internal/server/middleware/init.go | 2 + internal/server/middleware/token_auth.go | 77 ++++++++ internal/server/routes.go | 36 +++- internal/server/server.go | 10 +- internal/server/token_handler.go | 172 ++++++++++++++++++ 11 files changed, 523 insertions(+), 15 deletions(-) create mode 100644 internal/db/migrations/000004_create_sessions_table_and_indexes.down.sql create mode 100644 internal/db/migrations/000004_create_sessions_table_and_indexes.up.sql create mode 100644 internal/db/queries/session.sql create mode 100644 internal/db/repository/session.sql.go create mode 100644 internal/server/middleware/init.go create mode 100644 internal/server/middleware/token_auth.go create mode 100644 internal/server/token_handler.go diff --git a/internal/db/dbHelper.go b/internal/db/dbHelper.go index 8a0f003..9cc754a 100644 --- a/internal/db/dbHelper.go +++ b/internal/db/dbHelper.go @@ -121,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())) //} - err = m.Migrate(2) - //err = m.Up() // or m.Steps(2) if you want to explicitly set the number of migrations to run + // Use Up() to apply all pending migrations instead of Migrate(2) + err = m.Up() 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") db.Close() diff --git a/internal/db/migrations/000004_create_sessions_table_and_indexes.down.sql b/internal/db/migrations/000004_create_sessions_table_and_indexes.down.sql new file mode 100644 index 0000000..b9f4a70 --- /dev/null +++ b/internal/db/migrations/000004_create_sessions_table_and_indexes.down.sql @@ -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; diff --git a/internal/db/migrations/000004_create_sessions_table_and_indexes.up.sql b/internal/db/migrations/000004_create_sessions_table_and_indexes.up.sql new file mode 100644 index 0000000..d96f734 --- /dev/null +++ b/internal/db/migrations/000004_create_sessions_table_and_indexes.up.sql @@ -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); diff --git a/internal/db/queries/session.sql b/internal/db/queries/session.sql new file mode 100644 index 0000000..7ed4be0 --- /dev/null +++ b/internal/db/queries/session.sql @@ -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; diff --git a/internal/db/repository/models.go b/internal/db/repository/models.go index e562a2f..aed7c47 100644 --- a/internal/db/repository/models.go +++ b/internal/db/repository/models.go @@ -6,6 +6,8 @@ package repository import ( "time" + + "github.com/jackc/pgx/v5/pgtype" ) type Game struct { @@ -21,6 +23,15 @@ type Game struct { 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 { GameID int32 `json:"game_id"` SongName string `json:"song_name"` diff --git a/internal/db/repository/session.sql.go b/internal/db/repository/session.sql.go new file mode 100644 index 0000000..b011790 --- /dev/null +++ b/internal/db/repository/session.sql.go @@ -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 +} diff --git a/internal/server/middleware/init.go b/internal/server/middleware/init.go new file mode 100644 index 0000000..bb94d76 --- /dev/null +++ b/internal/server/middleware/init.go @@ -0,0 +1,2 @@ +// Package middleware provides Echo middleware for the MusicServer application. +package middleware diff --git a/internal/server/middleware/token_auth.go b/internal/server/middleware/token_auth.go new file mode 100644 index 0000000..70f24cc --- /dev/null +++ b/internal/server/middleware/token_auth.go @@ -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 := 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) + } +} diff --git a/internal/server/routes.go b/internal/server/routes.go index 3cba20b..3afe3e9 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -2,13 +2,16 @@ package server import ( "music-server/cmd/web" + "music-server/internal/db" + "music-server/internal/logging" + "music-server/internal/server/middleware" "net/http" "sort" "strings" "github.com/a-h/templ" "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" "go.uber.org/zap" "music-server/internal/logging" @@ -36,9 +39,9 @@ func (s *Server) RegisterRoutes() http.Handler { http.ServeFile(w, r, "cmd/docs/swagger.json") }))) 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://*"}, AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"}, AllowHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"}, @@ -103,6 +106,33 @@ func (s *Server) RegisterRoutes() http.Handler { musicGroup.GET("/addQue", music.AddLatestToQue) 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(db.Dbpool) + + // 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() sort.Slice(routes, func(i, j int) bool { return routes[i].Path < routes[j].Path diff --git a/internal/server/server.go b/internal/server/server.go index 83973d4..b48b17d 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -14,7 +14,8 @@ import ( ) type Server struct { - port int + port int + tokenHandler *TokenHandler } var ( @@ -39,8 +40,13 @@ func NewServer() *http.Server { logger := logging.GetLogger() port, _ := strconv.Atoi(os.Getenv("PORT")) + + // Initialize token handler + tokenHandler := NewTokenHandler() + NewServer := &Server{ - port: port, + port: port, + tokenHandler: tokenHandler, } logger.Info("Starting server", diff --git a/internal/server/token_handler.go b/internal/server/token_handler.go new file mode 100644 index 0000000..910d147 --- /dev/null +++ b/internal/server/token_handler.go @@ -0,0 +1,172 @@ +package server + +import ( + "crypto/rand" + "encoding/base64" + "net/http" + "strings" + "time" + + "music-server/internal/db" + "music-server/internal/db/repository" + "music-server/internal/logging" + + "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 *repository.Queries +} + +// NewTokenHandler creates a new token handler with database pool +func NewTokenHandler() *TokenHandler { + return &TokenHandler{ + pool: repository.New(db.Dbpool), + } +} + +// 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 + session, err := h.pool.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 + err := h.pool.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 + + err := h.pool.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", + }) +} -- 2.52.0 From 24a911133336a313e710d9253038202393ecfb84 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Mon, 1 Jun 2026 18:50:05 +0200 Subject: [PATCH 2/4] feat: Remove global db.Dbpool with dependency injection (Phase 0) - Add Database struct in internal/db/database.go with Pool, Ctx, and RunMigrations() - Update server.go to use Database struct with NewServerInstance() - Add backend.go with InitBackend(), BackendRepo(), BackendCtx(), BackendPool() - Update music.go and sync.go to use BackendRepo() and BackendCtx() instead of db.Dbpool/db.Ctx - Update token_handler.go to accept pool parameter - Update routes.go to use s.db.Pool for middleware - Update cmd/main.go to use NewServerInstance() and HTTPServer() - Update test_helpers.go to initialize backend with test database - Update test files to use backend.BackendPool() and backend.BackendCtx() Benefits: - Easier to mock database for unit tests - Follows Go best practices (dependency injection) - Better architecture with explicit dependencies - RunMigrations() replaces old Migrate_db() function Note: Global db.Dbpool and db.Ctx still exist in dbHelper.go for backward compatibility with test_helpers.go, but production code no longer uses them. Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe --- cmd/main.go | 22 +++-- internal/backend/backend.go | 40 ++++++++ internal/backend/music.go | 33 ++++--- internal/backend/sync.go | 47 +++++---- internal/db/database.go | 121 +++++++++++++++++++++++ internal/server/routes.go | 3 +- internal/server/server.go | 83 +++++++++++----- internal/server/sync_handler_test.go | 30 +++--- internal/server/test_helpers.go | 20 +++- internal/server/token_handler.go | 17 ++-- internal/server/zz_music_handler_test.go | 4 +- 11 files changed, 320 insertions(+), 100 deletions(-) create mode 100644 internal/backend/backend.go create mode 100644 internal/db/database.go diff --git a/cmd/main.go b/cmd/main.go index 8c47fdc..e3f2571 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -2,7 +2,6 @@ package main import ( "context" - "music-server/internal/db" "music-server/internal/logging" "music-server/internal/server" "net/http" @@ -19,9 +18,11 @@ import ( // @description This is a sample server Petstore server. // @termsOfService http://swagger.io/terms/ +// // @contact.name Sebastian Olsson // @contact.email zarnor91@gmail.com +// // @license.name Apache 2.0 // @license.url http://www.apache.org/licenses/LICENSE-2.0.html @@ -34,16 +35,17 @@ func main() { pprof.StartCPUProfile(f) defer pprof.StopCPUProfile()*/ - server := server.NewServer() + appServer := server.NewServerInstance() + httpServer := appServer.HTTPServer() // Create a done channel to signal when the shutdown is complete done := make(chan bool, 1) // 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)) - err := server.ListenAndServe() + logging.GetLogger().Info("Server starting", zap.String("address", httpServer.Addr)) + err := httpServer.ListenAndServe() if err != nil && err != http.ErrServerClosed { logging.GetLogger().Fatal("HTTP server error", zap.String("error", err.Error())) } @@ -53,7 +55,7 @@ func main() { 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. ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer stop() @@ -62,13 +64,17 @@ func gracefulShutdown(apiServer *http.Server, done chan bool) { <-ctx.Done() 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 request it is currently handling ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 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())) } diff --git a/internal/backend/backend.go b/internal/backend/backend.go new file mode 100644 index 0000000..88833f4 --- /dev/null +++ b/internal/backend/backend.go @@ -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 +} diff --git a/internal/backend/music.go b/internal/backend/music.go index b298975..c9717b4 100644 --- a/internal/backend/music.go +++ b/internal/backend/music.go @@ -2,7 +2,6 @@ package backend import ( "math/rand" - "music-server/internal/db" "music-server/internal/db/repository" "music-server/internal/logging" "os" @@ -28,18 +27,20 @@ var gamesNew []repository.Game var songQueNew []repository.Song var lastFetchedNew repository.Song -var repo *repository.Queries func initRepo() { - if repo == nil { - repo = repository.New(db.Dbpool) + // This function is kept for backward compatibility + // 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 { if len(gamesNew) == 0 { initRepo() - gamesNew, _ = repo.FindAllGames(db.Ctx) + gamesNew, _ = BackendRepo().FindAllGames(BackendCtx()) } return gamesNew @@ -58,7 +59,7 @@ func Reset() { songQueNew = nil currentSong = -1 initRepo() - gamesNew, _ = repo.FindAllGames(db.Ctx) + gamesNew, _ = BackendRepo().FindAllGames(BackendCtx()) } func AddLatestToQue() { @@ -76,8 +77,8 @@ func AddLatestPlayed() { currentSongData := songQueNew[currentSong] initRepo() - repo.AddGamePlayed(db.Ctx, currentSongData.GameID) - repo.AddSongPlayed(db.Ctx, repository.AddSongPlayedParams{GameID: currentSongData.GameID, SongName: currentSongData.SongName}) + BackendRepo().AddGamePlayed(BackendCtx(), currentSongData.GameID) + BackendRepo().AddSongPlayed(BackendCtx(), repository.AddSongPlayedParams{GameID: currentSongData.GameID, SongName: currentSongData.SongName}) } func SetPlayed(songNumber int) { @@ -86,8 +87,8 @@ func SetPlayed(songNumber int) { } songData := songQueNew[songNumber] initRepo() - repo.AddGamePlayed(db.Ctx, songData.GameID) - repo.AddSongPlayed(db.Ctx, repository.AddSongPlayedParams{GameID: songData.GameID, SongName: songData.SongName}) + BackendRepo().AddGamePlayed(BackendCtx(), songData.GameID) + BackendRepo().AddSongPlayed(BackendCtx(), repository.AddSongPlayedParams{GameID: songData.GameID, SongName: songData.SongName}) } func GetRandomSong() string { @@ -130,7 +131,7 @@ func GetRandomSongClassic() string { var listOfAllSongs []repository.Song for _, game := range gamesNew { - songList, _ := repo.FindSongsFromGame(db.Ctx, game.ID) + songList, _ := BackendRepo().FindSongsFromGame(BackendCtx(), game.ID) listOfAllSongs = append(listOfAllSongs, songList...) } @@ -138,10 +139,10 @@ func GetRandomSongClassic() string { var song repository.Song for !songFound { song = listOfAllSongs[rand.Intn(len(listOfAllSongs))] - gameData, err := repo.GetGameById(db.Ctx, song.GameID) + gameData, err := BackendRepo().GetGameById(BackendCtx(), song.GameID) if err != nil { - repo.RemoveBrokenSong(db.Ctx, song.Path) + BackendRepo().RemoveBrokenSong(BackendCtx(), song.Path) logging.GetLogger().Warn("Song not found, removed from database", zap.String("song", song.SongName), zap.String("game", gameData.GameName), @@ -153,7 +154,7 @@ func GetRandomSongClassic() string { openFile, err := os.Open(song.Path) if err != nil || (song.FileName != nil && gameData.Path+*song.FileName != song.Path) { //File not found - repo.RemoveBrokenSong(db.Ctx, song.Path) + BackendRepo().RemoveBrokenSong(BackendCtx(), song.Path) logging.GetLogger().Warn("Song not found, removed from database", zap.String("song", song.SongName), zap.String("game", gameData.GameName), @@ -270,7 +271,7 @@ func getSongFromList(games []repository.Game) repository.Song { var song repository.Song for !songFound { game := getRandomGame(games) - songs, _ := repo.FindSongsFromGame(db.Ctx, game.ID) + songs, _ := BackendRepo().FindSongsFromGame(BackendCtx(), game.ID) if len(songs) == 0 { continue } @@ -281,7 +282,7 @@ func getSongFromList(games []repository.Game) repository.Song { 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")) { //File not found - repo.RemoveBrokenSong(db.Ctx, song.Path) + BackendRepo().RemoveBrokenSong(BackendCtx(), song.Path) logging.GetLogger().Warn("Song not found, removed from database", zap.String("song", song.SongName), zap.String("game", game.GameName), diff --git a/internal/backend/sync.go b/internal/backend/sync.go index 8a18822..42d7621 100644 --- a/internal/backend/sync.go +++ b/internal/backend/sync.go @@ -7,7 +7,6 @@ import ( "fmt" "io" "io/fs" - "music-server/internal/db" "music-server/internal/db/repository" "music-server/internal/logging" "os" @@ -80,8 +79,8 @@ func (gs GameStatus) String() string { } func ResetDB() { - repo.ClearSongs(db.Ctx) - repo.ClearGames(db.Ctx) + repo.ClearSongs(BackendCtx()) + repo.ClearGames(BackendCtx()) } func SyncProgress() ProgressResponse { @@ -206,13 +205,13 @@ func syncGamesNew(full bool) { catchedErrors = nil brokenSongs = nil - gamesBeforeSync, err = repo.FindAllGames(db.Ctx) + gamesBeforeSync, err = repo.FindAllGames(BackendCtx()) handleError("FindAllGames Before", err, "") 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, "") - err = repo.SetGameDeletionDate(db.Ctx) + err = repo.SetGameDeletionDate(BackendCtx()) handleError("SetGameDeletionDate", err, "") directories, err := os.ReadDir(musicPath) @@ -237,7 +236,7 @@ func syncGamesNew(full bool) { syncWg.Wait() checkBrokenSongsNew() - gamesAfterSync, err = repo.FindAllGames(db.Ctx) + gamesAfterSync, err = repo.FindAllGames(BackendCtx()) handleError("FindAllGames After", err, "") finished := time.Now() @@ -249,7 +248,7 @@ func syncGamesNew(full bool) { } func checkBrokenSongsNew() { - allSongs, err := repo.FetchAllSongs(db.Ctx) + allSongs, err := repo.FetchAllSongs(BackendCtx()) handleError("FetchAllSongs", err, "") var brokenWg sync.WaitGroup poolBroken, _ := ants.NewPool(200, ants.WithPreAlloc(true)) @@ -263,7 +262,7 @@ func checkBrokenSongsNew() { }) } brokenWg.Wait() - err = repo.RemoveBrokenSongs(db.Ctx, brokenSongs) + err = repo.RemoveBrokenSongs(BackendCtx(), brokenSongs) handleError("RemoveBrokenSongs", err, "") } @@ -336,7 +335,7 @@ func syncGameNew(file os.DirEntry, foldersToSkip []string, baseDir string, full 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, "") if err != nil { 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("hash", dirHash), 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, "") gamesChangedContent = append(gamesChangedContent, file.Name()) 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("hash", dirHash), 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, "") newCheckSongs(entries, gameDir, id) if gamesChangedTitle == nil { @@ -416,7 +415,7 @@ func syncGameNew(file os.DirEntry, foldersToSkip []string, baseDir string, full zap.String("game", file.Name()), zap.String("hash", dirHash), zap.String("status", status.String())) - err = repo.RemoveDeletionDate(db.Ctx, id) + err = repo.RemoveDeletionDate(BackendCtx(), id) handleError("RemoveDeletionDate", err, "") } foldersSynced++ @@ -428,13 +427,13 @@ func syncGameNew(file os.DirEntry, foldersToSkip []string, baseDir string, full func insertGameNew(name string, path string, hash string) int32 { 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, "") if err != nil { logging.GetLogger().Warn("ID collision detected, resetting sequence") if strings.HasPrefix(err.Error(), duplicateError.Error()) { logging.GetLogger().Debug("Resetting game ID sequence") - _, err = repo.ResetGameIdSeq(db.Ctx) + _, err = repo.ResetGameIdSeq(BackendCtx()) handleError("ResetGameIdSeq", err, "") id = insertGameNew(name, path, hash) } @@ -478,7 +477,7 @@ func newCheckSong(entry os.DirEntry, gameDir string, id int32) bool { fileName := entry.Name() 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)) if err == nil { 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_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)) 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)) 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)) - 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)) } } //count, _ := repo.CheckSong(ctx, path) 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)) } 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)) 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)) } 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)) } diff --git a/internal/db/database.go b/internal/db/database.go new file mode 100644 index 0000000..c37301b --- /dev/null +++ b/internal/db/database.go @@ -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 +} diff --git a/internal/server/routes.go b/internal/server/routes.go index 3afe3e9..6a1558f 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -2,7 +2,6 @@ package server import ( "music-server/cmd/web" - "music-server/internal/db" "music-server/internal/logging" "music-server/internal/server/middleware" "net/http" @@ -126,7 +125,7 @@ func (s *Server) RegisterRoutes() http.Handler { // Protected endpoints - require valid token // Create token auth middleware with pool access - tokenAuthMiddleware := middleware.TokenAuthMiddleware(db.Dbpool) + tokenAuthMiddleware := middleware.TokenAuthMiddleware(s.db.Pool) // Protected group with token authentication - will be used by VGMQ and Statistics API _ = apiV1.Group("", tokenAuthMiddleware) diff --git a/internal/server/server.go b/internal/server/server.go index b48b17d..b0e1b85 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -6,6 +6,7 @@ import ( "strconv" "time" + "music-server/internal/backend" "music-server/internal/db" "music-server/internal/logging" "net/http" @@ -15,7 +16,9 @@ import ( type Server struct { port int + db *db.Database tokenHandler *TokenHandler + httpServer *http.Server } var ( @@ -30,7 +33,9 @@ var ( 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 if logLevel == "" { logLevel = "info" @@ -40,15 +45,45 @@ func NewServer() *http.Server { logger := logging.GetLogger() port, _ := strconv.Atoi(os.Getenv("PORT")) - - // Initialize token handler - tokenHandler := NewTokenHandler() - - NewServer := &Server{ + + // 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", zap.String("host", host), zap.String("dbPort", dbPort), @@ -61,23 +96,21 @@ func NewServer() *http.Server { zap.String("charactersPath", charactersPath), ) - //conf.SetupDb() - if host == "" || dbPort == "" || username == "" || password == "" || dbName == "" || musicPath == "" || charactersPath == "" { - logging.GetLogger().Fatal("Invalid settings - missing required environment variables") - } - - db.Migrate_db(host, dbPort, username, password, dbName) - - db.InitDB(host, dbPort, username, password, dbName) - - // Declare Server config - server := &http.Server{ - Addr: fmt.Sprintf(":%d", NewServer.port), - Handler: NewServer.RegisterRoutes(), - IdleTimeout: time.Minute, - ReadTimeout: 10 * time.Second, - WriteTimeout: 30 * time.Second, - } - - return server + return appServer +} + +// HTTPServer returns the underlying http.Server for serving HTTP requests. +func (s *Server) HTTPServer() *http.Server { + return s.httpServer +} + +// DB returns the database instance for dependency injection. +func (s *Server) DB() *db.Database { + return s.db +} + +// NewServer creates a new HTTP server (deprecated, use NewServerInstance instead). +// This function is kept for backward compatibility. +func NewServer() *http.Server { + return NewServerInstance().HTTPServer() } diff --git a/internal/server/sync_handler_test.go b/internal/server/sync_handler_test.go index 7606515..15ecb6d 100644 --- a/internal/server/sync_handler_test.go +++ b/internal/server/sync_handler_test.go @@ -75,8 +75,8 @@ func TestSyncPopulatesDatabase(t *testing.T) { db.TestClearDatabase(t) // Before sync - should have no games - repo := repository.New(db.Dbpool) - gamesBefore, err := repo.FindAllGames(db.Ctx) + repo := repository.New(backend.BackendPool()) + gamesBefore, err := repo.FindAllGames(backend.BackendCtx()) assert.NoError(t, err) beforeCount := len(gamesBefore) t.Logf("Games before sync: %d", beforeCount) @@ -92,7 +92,7 @@ func TestSyncPopulatesDatabase(t *testing.T) { } // After sync - should have games - gamesAfter, err := repo.FindAllGames(db.Ctx) + gamesAfter, err := repo.FindAllGames(backend.BackendCtx()) assert.NoError(t, err) afterCount := len(gamesAfter) t.Logf("Games after sync: %d", afterCount) @@ -112,8 +112,8 @@ func TestSyncMakesDifference(t *testing.T) { db.TestClearDatabase(t) // Before sync - should have no games - repo := repository.New(db.Dbpool) - gamesBefore, err := repo.FindAllGames(db.Ctx) + repo := repository.New(backend.BackendPool()) + gamesBefore, err := repo.FindAllGames(backend.BackendCtx()) assert.NoError(t, err) 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 - gamesAfter, err := repo.FindAllGames(db.Ctx) + gamesAfter, err := repo.FindAllGames(backend.BackendCtx()) assert.NoError(t, err) assert.True(t, len(gamesAfter) > 0, "Should have games after sync") } @@ -199,8 +199,8 @@ func TestSyncGamesNewOnlyChanges(t *testing.T) { } // Get initial count - repo := repository.New(db.Dbpool) - gamesBefore, _ := repo.FindAllGames(db.Ctx) + repo := repository.New(backend.BackendPool()) + gamesBefore, _ := repo.FindAllGames(backend.BackendCtx()) beforeCount := len(gamesBefore) // Run incremental sync (should not change count if nothing changed) @@ -211,7 +211,7 @@ func TestSyncGamesNewOnlyChanges(t *testing.T) { time.Sleep(2 * time.Second) // Count should be the same - gamesAfter, _ := repo.FindAllGames(db.Ctx) + gamesAfter, _ := repo.FindAllGames(backend.BackendCtx()) afterCount := len(gamesAfter) // 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) // First ensure we have data - repo := repository.New(db.Dbpool) - gamesBefore, _ := repo.FindAllGames(db.Ctx) + repo := repository.New(backend.BackendPool()) + gamesBefore, _ := repo.FindAllGames(backend.BackendCtx()) beforeCount := len(gamesBefore) if beforeCount == 0 { @@ -238,7 +238,7 @@ func TestResetGames(t *testing.T) { t.Error("Sync did not complete within timeout") return } - gamesBefore, _ = repo.FindAllGames(db.Ctx) + gamesBefore, _ = repo.FindAllGames(backend.BackendCtx()) beforeCount = len(gamesBefore) } @@ -253,7 +253,7 @@ func TestResetGames(t *testing.T) { // Note: reset might take a moment to propagate time.Sleep(1 * time.Second) - gamesAfter, _ := repo.FindAllGames(db.Ctx) + gamesAfter, _ := repo.FindAllGames(backend.BackendCtx()) afterCount := len(gamesAfter) t.Logf("Games after reset: %d", afterCount) @@ -281,8 +281,8 @@ func TestSyncGamesNewFull(t *testing.T) { } // Verify database is populated - repo := repository.New(db.Dbpool) - games, err := repo.FindAllGames(db.Ctx) + repo := repository.New(backend.BackendPool()) + games, err := repo.FindAllGames(backend.BackendCtx()) assert.NoError(t, err) assert.True(t, len(games) > 0, "Database should be populated after full sync") t.Logf("Full sync populated %d games", len(games)) diff --git a/internal/server/test_helpers.go b/internal/server/test_helpers.go index 77f0b28..e0e4f3e 100644 --- a/internal/server/test_helpers.go +++ b/internal/server/test_helpers.go @@ -8,6 +8,9 @@ import ( "testing" "time" + "music-server/internal/backend" + "music-server/internal/db" + "github.com/labstack/echo/v5" ) @@ -45,8 +48,23 @@ func StartTestServer(t *testing.T) *echo.Echo { 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 - s := &Server{} + s := &Server{ + db: &db.Database{ + Pool: db.Dbpool, + Ctx: db.Ctx, + }, + tokenHandler: NewTokenHandler(db.Dbpool), + } handler := s.RegisterRoutes() // Wrap the http.Handler in an echo.Echo diff --git a/internal/server/token_handler.go b/internal/server/token_handler.go index 910d147..e234e67 100644 --- a/internal/server/token_handler.go +++ b/internal/server/token_handler.go @@ -7,10 +7,10 @@ import ( "strings" "time" - "music-server/internal/db" "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" @@ -30,13 +30,13 @@ type TokenResponse struct { // TokenHandler contains the database pool for token operations type TokenHandler struct { - pool *repository.Queries + pool *pgxpool.Pool } // NewTokenHandler creates a new token handler with database pool -func NewTokenHandler() *TokenHandler { +func NewTokenHandler(pool *pgxpool.Pool) *TokenHandler { return &TokenHandler{ - pool: repository.New(db.Dbpool), + pool: pool, } } @@ -84,7 +84,8 @@ func (h *TokenHandler) CreateTokenHandler(c *echo.Context) error { clientType := req.ClientType // Store in database using sqlc-generated repository - session, err := h.pool.CreateSession(c.Request().Context(), repository.CreateSessionParams{ + queries := repository.New(h.pool) + session, err := queries.CreateSession(c.Request().Context(), repository.CreateSessionParams{ Token: token, IpAddress: c.RealIP(), UserAgent: c.Request().UserAgent(), @@ -132,7 +133,8 @@ func (h *TokenHandler) DeleteTokenHandler(c *echo.Context) error { token := parts[1] // Delete session using sqlc-generated repository - err := h.pool.DeleteSession(c.Request().Context(), token) + 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"}) @@ -158,7 +160,8 @@ func (h *TokenHandler) CleanupExpiredSessionsHandler(c *echo.Context) error { // Verify token is valid first (using existing middleware) // The middleware will have already validated the token - err := h.pool.DeleteExpiredSessions(c.Request().Context()) + 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"}) diff --git a/internal/server/zz_music_handler_test.go b/internal/server/zz_music_handler_test.go index a3d2363..bd7e92d 100644 --- a/internal/server/zz_music_handler_test.go +++ b/internal/server/zz_music_handler_test.go @@ -15,8 +15,8 @@ import ( // ensureSyncRan ensures that sync has been run before testing music endpoints func ensureSyncRan(t *testing.T, e *echo.Echo) { - repo := repository.New(db.Dbpool) - games, err := repo.FindAllGames(db.Ctx) + repo := repository.New(backend.BackendPool()) + games, err := repo.FindAllGames(backend.BackendCtx()) assert.NoError(t, err) if len(games) == 0 { -- 2.52.0 From 6d4a034753b6e902b15073a6f724c1395d7fd248 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Mon, 8 Jun 2026 20:44:17 +0200 Subject: [PATCH 3/4] Fix duplicate import in routes.go Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe --- internal/server/routes.go | 1 - 1 file changed, 1 deletion(-) diff --git a/internal/server/routes.go b/internal/server/routes.go index 6a1558f..a83b8ce 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -13,7 +13,6 @@ import ( echoMiddleware "github.com/labstack/echo/v5/middleware" echoSwagger "github.com/swaggo/echo-swagger/v2" "go.uber.org/zap" - "music-server/internal/logging" ) // @Title MusicServer API -- 2.52.0 From c6a07e69e76a32c0bed37ef828af829ce9fe987a Mon Sep 17 00:00:00 2001 From: Sebastian Date: Sat, 13 Jun 2026 11:26:52 +0200 Subject: [PATCH 4/4] Fixed some small bugs. Frontend is now included in the docker image --- Dockerfile | 28 ++-- cmd/docs/docs.go | 250 ++++++++++++++++++++++++---- cmd/docs/swagger.json | 250 ++++++++++++++++++++++++---- cmd/docs/swagger.yaml | 167 ++++++++++++++++--- internal/backend/sync.go | 7 +- internal/server/characterHandler.go | 9 +- internal/server/syncHandler.go | 2 + 7 files changed, 610 insertions(+), 103 deletions(-) diff --git a/Dockerfile b/Dockerfile index 8b6ca7c..c041846 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,26 +1,35 @@ +# Stage 1: Build frontend +FROM node:18-alpine AS frontend-builder +RUN apk add --no-cache git +WORKDIR /app +RUN git clone https://gitea.sanplex.xyz/Sansan/MusicFrontend.git +WORKDIR /app/MusicFrontend +RUN npm install +RUN npm run build +# Generate config.js with empty API_HOSTNAME (relative paths) +RUN echo "window.__RUNTIME_CONFIG__ = { API_HOSTNAME: '' };" > dist/config.js + +# Stage 2: Build backend FROM golang:1.25-alpine as build_go RUN apk add --no-cache curl - WORKDIR /app - COPY go.mod go.sum ./ RUN go mod download - COPY . . - RUN go install github.com/a-h/templ/cmd/templ@latest - RUN templ generate - RUN go build -o main cmd/main.go -# Stage 2, distribution container +# Stage 3: Final image FROM golang:1.25-alpine EXPOSE 8080 VOLUME /sorted -VOLUME /frontend VOLUME /characters +COPY --from=build_go /app/main . +COPY --from=frontend-builder /app/MusicFrontend/dist /frontend +COPY ./songs/ ./songs/ + ENV PORT 8080 ENV DB_HOST "" ENV DB_PORT "" @@ -30,7 +39,4 @@ ENV DB_NAME "" ENV MUSIC_PATH "" ENV CHARACTERS_PATH "" -COPY --from=build_go /app/main . -COPY ./songs/ ./songs/ - CMD ./main diff --git a/cmd/docs/docs.go b/cmd/docs/docs.go index db1d0a5..848b952 100644 --- a/cmd/docs/docs.go +++ b/cmd/docs/docs.go @@ -23,6 +23,160 @@ var doc = `{ "host": "{{.Host}}", "basePath": "{{.BasePath}}", "paths": { + "/api/v1/token": { + "post": { + "description": "Returns a new session token for API access", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Create session token", + "parameters": [ + { + "description": "Client type", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/server.TokenRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/server.TokenResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "delete": { + "description": "Deletes the current session token", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Invalidate session token", + "parameters": [ + { + "type": "string", + "description": "Bearer token", + "name": "Authorization", + "in": "header", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/token/cleanup": { + "post": { + "description": "Removes all expired session tokens from the database", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Cleanup expired sessions", + "parameters": [ + { + "type": "string", + "description": "Bearer token", + "name": "Authorization", + "in": "header", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, "/character": { "get": { "description": "Returns the image for a specific character", @@ -81,29 +235,6 @@ var doc = `{ } } }, - "/dbtest": { - "get": { - "description": "Tests the database connection", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "database" - ], - "summary": "Test database connection", - "responses": { - "200": { - "description": "TestedDB", - "schema": { - "type": "string" - } - } - } - } - }, "/download": { "get": { "description": "Checks for the latest version of the application", @@ -798,7 +929,7 @@ var doc = `{ }, "/version": { "get": { - "description": "get string by ID", + "description": "get latest version info", "consumes": [ "application/json" ], @@ -806,9 +937,9 @@ var doc = `{ "application/json" ], "tags": [ - "accounts" + "version" ], - "summary": "Getting the version of the backend", + "summary": "Getting the latest version of the backend", "responses": { "200": { "description": "OK", @@ -824,6 +955,38 @@ var doc = `{ } } } + }, + "/version/history": { + "get": { + "description": "get version history", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "version" + ], + "summary": "Getting the version history of the backend", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/backend.VersionData" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "string" + } + } + } + } } }, "definitions": { @@ -831,20 +994,43 @@ var doc = `{ "type": "object", "properties": { "changelog": { - "type": "string", - "example": "account name" - }, - "history": { "type": "array", "items": { - "$ref": "#/definitions/backend.VersionData" - } + "type": "string" + }, + "example": [ + "[\"Initial release\"", + "\"Bug fixes\"]" + ] }, "version": { "type": "string", "example": "1.0.0" } } + }, + "server.TokenRequest": { + "type": "object", + "properties": { + "client_type": { + "description": "Optional: \"web\", \"mobile\", \"api\"", + "type": "string" + } + } + }, + "server.TokenResponse": { + "type": "object", + "properties": { + "client_type": { + "type": "string" + }, + "expires_at": { + "type": "string" + }, + "token": { + "type": "string" + } + } } } }` diff --git a/cmd/docs/swagger.json b/cmd/docs/swagger.json index ce18ae6..cde977f 100644 --- a/cmd/docs/swagger.json +++ b/cmd/docs/swagger.json @@ -4,6 +4,160 @@ "contact": {} }, "paths": { + "/api/v1/token": { + "post": { + "description": "Returns a new session token for API access", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Create session token", + "parameters": [ + { + "description": "Client type", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/server.TokenRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/server.TokenResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "delete": { + "description": "Deletes the current session token", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Invalidate session token", + "parameters": [ + { + "type": "string", + "description": "Bearer token", + "name": "Authorization", + "in": "header", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/token/cleanup": { + "post": { + "description": "Removes all expired session tokens from the database", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Cleanup expired sessions", + "parameters": [ + { + "type": "string", + "description": "Bearer token", + "name": "Authorization", + "in": "header", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, "/character": { "get": { "description": "Returns the image for a specific character", @@ -62,29 +216,6 @@ } } }, - "/dbtest": { - "get": { - "description": "Tests the database connection", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "database" - ], - "summary": "Test database connection", - "responses": { - "200": { - "description": "TestedDB", - "schema": { - "type": "string" - } - } - } - } - }, "/download": { "get": { "description": "Checks for the latest version of the application", @@ -779,7 +910,7 @@ }, "/version": { "get": { - "description": "get string by ID", + "description": "get latest version info", "consumes": [ "application/json" ], @@ -787,9 +918,9 @@ "application/json" ], "tags": [ - "accounts" + "version" ], - "summary": "Getting the version of the backend", + "summary": "Getting the latest version of the backend", "responses": { "200": { "description": "OK", @@ -805,6 +936,38 @@ } } } + }, + "/version/history": { + "get": { + "description": "get version history", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "version" + ], + "summary": "Getting the version history of the backend", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/backend.VersionData" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "string" + } + } + } + } } }, "definitions": { @@ -812,20 +975,43 @@ "type": "object", "properties": { "changelog": { - "type": "string", - "example": "account name" - }, - "history": { "type": "array", "items": { - "$ref": "#/definitions/backend.VersionData" - } + "type": "string" + }, + "example": [ + "[\"Initial release\"", + "\"Bug fixes\"]" + ] }, "version": { "type": "string", "example": "1.0.0" } } + }, + "server.TokenRequest": { + "type": "object", + "properties": { + "client_type": { + "description": "Optional: \"web\", \"mobile\", \"api\"", + "type": "string" + } + } + }, + "server.TokenResponse": { + "type": "object", + "properties": { + "client_type": { + "type": "string" + }, + "expires_at": { + "type": "string" + }, + "token": { + "type": "string" + } + } } } } \ No newline at end of file diff --git a/cmd/docs/swagger.yaml b/cmd/docs/swagger.yaml index e537b4f..242592d 100644 --- a/cmd/docs/swagger.yaml +++ b/cmd/docs/swagger.yaml @@ -2,19 +2,136 @@ definitions: backend.VersionData: properties: changelog: - example: account name - type: string - history: + example: + - '["Initial release"' + - '"Bug fixes"]' items: - $ref: '#/definitions/backend.VersionData' + type: string type: array version: example: 1.0.0 type: string type: object + server.TokenRequest: + properties: + client_type: + description: 'Optional: "web", "mobile", "api"' + type: string + type: object + server.TokenResponse: + properties: + client_type: + type: string + expires_at: + type: string + token: + type: string + type: object info: contact: {} paths: + /api/v1/token: + delete: + consumes: + - application/json + description: Deletes the current session token + parameters: + - description: Bearer token + in: header + name: Authorization + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: + type: string + type: object + "401": + description: Unauthorized + schema: + additionalProperties: + type: string + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: + type: string + type: object + summary: Invalidate session token + tags: + - auth + post: + consumes: + - application/json + description: Returns a new session token for API access + parameters: + - description: Client type + in: body + name: request + required: true + schema: + $ref: '#/definitions/server.TokenRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/server.TokenResponse' + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: + type: string + type: object + summary: Create session token + tags: + - auth + /api/v1/token/cleanup: + post: + consumes: + - application/json + description: Removes all expired session tokens from the database + parameters: + - description: Bearer token + in: header + name: Authorization + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "401": + description: Unauthorized + schema: + additionalProperties: + type: string + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: + type: string + type: object + summary: Cleanup expired sessions + tags: + - auth /character: get: consumes: @@ -53,21 +170,6 @@ paths: summary: Get list of characters tags: - characters - /dbtest: - get: - consumes: - - application/json - description: Tests the database connection - produces: - - application/json - responses: - "200": - description: TestedDB - schema: - type: string - summary: Test database connection - tags: - - database /download: get: consumes: @@ -527,7 +629,7 @@ paths: get: consumes: - application/json - description: get string by ID + description: get latest version info produces: - application/json responses: @@ -539,7 +641,28 @@ paths: description: Not Found schema: type: string - summary: Getting the version of the backend + summary: Getting the latest version of the backend tags: - - accounts + - version + /version/history: + get: + consumes: + - application/json + description: get version history + produces: + - application/json + responses: + "200": + description: OK + schema: + items: + $ref: '#/definitions/backend.VersionData' + type: array + "404": + description: Not Found + schema: + type: string + summary: Getting the version history of the backend + tags: + - version swagger: "2.0" diff --git a/internal/backend/sync.go b/internal/backend/sync.go index 42d7621..42a77ea 100644 --- a/internal/backend/sync.go +++ b/internal/backend/sync.go @@ -180,8 +180,6 @@ func SyncGamesNewOnlyChanges() { } func syncGamesNew(full bool) { - Syncing = true - musicPath := os.Getenv("MUSIC_PATH") fmt.Printf("dir: %s\n", musicPath) logging.GetLogger().Debug("Folder to sync", zap.String("MUSIC_PATH", musicPath)) @@ -193,7 +191,7 @@ func syncGamesNew(full bool) { initRepo() start = time.Now() - foldersToSkip := []string{".sync", "dist", "old", "characters"} + foldersToSkip := []string{".sync", "characters", "dist", "old"} logging.GetLogger().Debug("Folders to skip during sync", zap.Strings("folders", foldersToSkip)) var err error @@ -218,7 +216,6 @@ func syncGamesNew(full bool) { if err != nil { logging.GetLogger().Fatal("Failed to read music directory", zap.String("path", musicPath), zap.String("error", err.Error())) } - pool, _ = ants.NewPool(10, ants.WithPreAlloc(true)) poolSong, _ = ants.NewPool(10, ants.WithPreAlloc(true)) defer pool.Release() @@ -314,7 +311,7 @@ func syncGameNew(file os.DirEntry, foldersToSkip []string, baseDir string, full } } - if full { + if full && status != NewGame { status = TitleChanged } entries, err := os.ReadDir(gameDir) diff --git a/internal/server/characterHandler.go b/internal/server/characterHandler.go index 941ffc3..3f3822d 100644 --- a/internal/server/characterHandler.go +++ b/internal/server/characterHandler.go @@ -2,6 +2,7 @@ package server import ( "net/http" + "os" "github.com/labstack/echo/v5" "music-server/internal/backend" @@ -38,5 +39,11 @@ func (c *CharacterHandler) GetCharacterList(ctx *echo.Context) error { // @Router /character [get] func (c *CharacterHandler) GetCharacter(ctx *echo.Context) error { character := ctx.QueryParam("name") - return ctx.File(backend.GetCharacter(character)) + characterPath := backend.GetCharacter(character) + file, err := os.Open(characterPath) + if err != nil { + return echo.NewHTTPError(http.StatusNotFound, err.Error()) + } + defer file.Close() + return ctx.Stream(http.StatusOK, "image/png", file) } diff --git a/internal/server/syncHandler.go b/internal/server/syncHandler.go index 55e378c..382a675 100644 --- a/internal/server/syncHandler.go +++ b/internal/server/syncHandler.go @@ -49,6 +49,7 @@ func (s *SyncHandler) SyncGamesNewOnlyChanges(ctx *echo.Context) error { return ctx.JSON(http.StatusLocked, "Syncing is in progress") } logging.GetLogger().Info("Starting sync with only changes") + backend.Syncing = true go backend.SyncGamesNewOnlyChanges() return ctx.JSON(http.StatusOK, "Start syncing games") } @@ -68,6 +69,7 @@ func (s *SyncHandler) SyncGamesNewFull(ctx *echo.Context) error { return ctx.JSON(http.StatusLocked, "Syncing is in progress") } logging.GetLogger().Info("Starting full sync") + backend.Syncing = true go backend.SyncGamesNewFull() return ctx.JSON(http.StatusOK, "Start syncing games full") } -- 2.52.0