Restructure backend into services and fix security, crash and sync issues
Security - Serve character images through os.Root so names like ../ can't escape the characters folder - Sync start and /sync/reset are POST only (legacy and /api/v1) - /api/v1/token/cleanup requires a token, password compared in constant time, tokens no longer logged - Echo's rate limiter replaces the custom one; TRUSTED_PROXIES enables X-Forwarded-For for reverse proxies Crashes and races - Sync state lives in SyncService with atomics and a mutex instead of unsynchronised globals (fixes concurrent map writes during full sync) - Only one sync can run; /api/v1 sync and music routes are locked during sync like the legacy ones - No log.Fatal outside startup: errors become 4xx/5xx responses - Search uses an in-order character match instead of unescaped regexes - Empty queue / no songs return 404 instead of panicking or looping Behaviour - Sync result is computed once and stays the same between polls - Swagger and /openapi serve the embedded spec - Shutdown stops HTTP before closing the database - Expired tokens are deleted with a context that outlives the request - Files are served with Range support and correct content types; audio has no write deadline Performance - Migration 000006 adds soundtrack.fingerprint (names, sizes, mtimes) so unchanged folders are skipped without reading any files - Songs are reconciled per soundtrack with one query plus needed writes; renames keep play counts - Moved MUSIC_PATH updates soundtrack and song paths - .id cleanup only runs for changed soundtracks or a full sync - Classic random uses one SQL query; Gitea release lookups are cached with a timeout Structure - internal/config reads all environment variables once - sqlc emits a Querier interface; backend globals become services injected into handlers - One route registration shared by legacy and /api/v1 - Removed dead code (dbHelper.go, unused middleware, duplicate Gitea functions, embedded Swagger UI copy) and lib/pq - templ and swag pinned as go tools
This commit is contained in:
1 parent
75b6f0c1cf
commit
090e794460
78 files changed
+2919
-6303
No files matched your search
@@ -2,24 +2,25 @@ package server
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"music-server/internal/db/repository"
|
||||
"music-server/internal/logging"
|
||||
"music-server/internal/server/middleware"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/labstack/echo/v5"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
const tokenLifetime = 24 * time.Hour
|
||||
|
||||
// TokenRequest represents a request to generate a new token
|
||||
type TokenRequest struct {
|
||||
Password string `json:"password"` // Required: API password
|
||||
Password string `json:"password"` // API password, required when API_PASSWORD is set
|
||||
ClientType string `json:"client_type"` // Optional: "web", "mobile", "api"
|
||||
}
|
||||
|
||||
@@ -30,20 +31,19 @@ type TokenResponse struct {
|
||||
ClientType string `json:"client_type"`
|
||||
}
|
||||
|
||||
// TokenHandler contains the database pool for token operations
|
||||
// TokenHandler creates and removes session tokens.
|
||||
type TokenHandler struct {
|
||||
pool *pgxpool.Pool
|
||||
q repository.Querier
|
||||
apiPassword string
|
||||
disableSessionAuth bool
|
||||
}
|
||||
|
||||
// NewTokenHandler creates a new token handler with database pool
|
||||
func NewTokenHandler(pool *pgxpool.Pool) *TokenHandler {
|
||||
return &TokenHandler{
|
||||
pool: pool,
|
||||
}
|
||||
// NewTokenHandler creates a TokenHandler. An empty apiPassword accepts any password.
|
||||
func NewTokenHandler(q repository.Querier, apiPassword string, disableSessionAuth bool) *TokenHandler {
|
||||
return &TokenHandler{q: q, apiPassword: apiPassword, disableSessionAuth: disableSessionAuth}
|
||||
}
|
||||
|
||||
// generateToken creates a new cryptographically secure token
|
||||
func (h *TokenHandler) generateToken() (string, error) {
|
||||
func generateToken() (string, error) {
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
@@ -51,149 +51,115 @@ func (h *TokenHandler) generateToken() (string, error) {
|
||||
return base64.URLEncoding.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
func (h *TokenHandler) passwordValid(password string) bool {
|
||||
if h.disableSessionAuth || h.apiPassword == "" {
|
||||
return true
|
||||
}
|
||||
return subtle.ConstantTimeCompare([]byte(password), []byte(h.apiPassword)) == 1
|
||||
}
|
||||
|
||||
// CreateTokenHandler creates a new session token
|
||||
// POST /api/v1/token
|
||||
//
|
||||
// @Summary Create session token
|
||||
// @Description Returns a new session token for API access. Requires valid API password.
|
||||
// @Description Returns a new session token for API access. Requires the API password when one is configured.
|
||||
// @Tags auth
|
||||
// @Accept json
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body TokenRequest true "Password and client type"
|
||||
// @Success 200 {object} TokenResponse
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 401 {object} map[string]string
|
||||
// @Failure 429 {object} map[string]string
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /api/v1/token [post]
|
||||
// @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"})
|
||||
}
|
||||
|
||||
// Check if session auth is disabled
|
||||
disableSessionAuth := os.Getenv("DISABLE_SESSION_AUTH") == "true"
|
||||
apiPassword := os.Getenv("API_PASSWORD")
|
||||
|
||||
// Password validation logic:
|
||||
// - If session auth is disabled, skip validation
|
||||
// - If API_PASSWORD is empty, allow any password (open access mode)
|
||||
// - If API_PASSWORD is set, require exact password match
|
||||
if !disableSessionAuth {
|
||||
if apiPassword != "" {
|
||||
// API password is set - require exact match
|
||||
if req.Password != apiPassword {
|
||||
logging.GetLogger().Warn("Invalid password attempt for token creation",
|
||||
zap.String("ip", c.RealIP()),
|
||||
zap.String("user_agent", c.Request().UserAgent()))
|
||||
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Invalid password"})
|
||||
}
|
||||
}
|
||||
// If API_PASSWORD is empty, allow any password through (open access mode)
|
||||
if !h.passwordValid(req.Password) {
|
||||
logging.GetLogger().Warn("Invalid password attempt for token creation",
|
||||
zap.String("ip", c.RealIP()),
|
||||
zap.String("user_agent", c.Request().UserAgent()))
|
||||
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Invalid password"})
|
||||
}
|
||||
|
||||
if req.ClientType == "" {
|
||||
req.ClientType = "web"
|
||||
}
|
||||
|
||||
// Generate token
|
||||
token, err := h.generateToken()
|
||||
token, err := generateToken()
|
||||
if err != nil {
|
||||
logging.GetLogger().Error("Failed to generate token", zap.String("error", err.Error()))
|
||||
logging.GetLogger().Error("Failed to generate token", zap.Error(err))
|
||||
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{
|
||||
session, err := h.q.CreateSession(c.Request().Context(), repository.CreateSessionParams{
|
||||
Token: token,
|
||||
IpAddress: c.RealIP(),
|
||||
UserAgent: c.Request().UserAgent(),
|
||||
ClientType: &clientType,
|
||||
ExpiresAt: pgtype.Timestamptz{Time: expiresAt, Valid: true},
|
||||
ClientType: &req.ClientType,
|
||||
ExpiresAt: pgtype.Timestamptz{Time: time.Now().Add(tokenLifetime), Valid: true},
|
||||
})
|
||||
if err != nil {
|
||||
logging.GetLogger().Error("Failed to create session", zap.String("error", err.Error()))
|
||||
logging.GetLogger().Error("Failed to create session", zap.Error(err))
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to create session"})
|
||||
}
|
||||
|
||||
response := TokenResponse{
|
||||
clientType := req.ClientType
|
||||
if session.ClientType != nil {
|
||||
clientType = *session.ClientType
|
||||
}
|
||||
return c.JSON(http.StatusOK, TokenResponse{
|
||||
Token: session.Token,
|
||||
ExpiresAt: session.ExpiresAt.Time,
|
||||
ClientType: *session.ClientType,
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, response)
|
||||
ClientType: clientType,
|
||||
})
|
||||
}
|
||||
|
||||
// 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]
|
||||
// @Router /api/v1/token [delete]
|
||||
func (h *TokenHandler) DeleteTokenHandler(c *echo.Context) error {
|
||||
authHeader := c.Request().Header.Get("Authorization")
|
||||
if authHeader == "" {
|
||||
if c.Request().Header.Get("Authorization") == "" {
|
||||
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Authorization header required"})
|
||||
}
|
||||
|
||||
parts := strings.Split(authHeader, " ")
|
||||
if len(parts) != 2 || parts[0] != "Bearer" {
|
||||
token, ok := middleware.BearerToken(c)
|
||||
if !ok {
|
||||
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()))
|
||||
if err := h.q.DeleteSession(c.Request().Context(), token); err != nil {
|
||||
logging.GetLogger().Error("Failed to delete session", zap.Error(err))
|
||||
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{}
|
||||
// @Success 200 {object} map[string]string
|
||||
// @Failure 401 {object} map[string]string
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /api/v1/token/cleanup [post]
|
||||
// @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()))
|
||||
if err := h.q.DeleteExpiredSessions(c.Request().Context()); err != nil {
|
||||
logging.GetLogger().Error("Failed to cleanup sessions", zap.Error(err))
|
||||
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",
|
||||
})
|
||||
return c.JSON(http.StatusOK, map[string]string{"status": "cleanup complete"})
|
||||
}
|
||||
Reference in new issue
Block a user