Files
MusicServer/internal/server/token_handler.go
T
Sansan 090e794460 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
2026-10-06 15:20:14 +02:00

166 lines
5.6 KiB
Go

package server
import (
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"net/http"
"time"
"music-server/internal/db/repository"
"music-server/internal/logging"
"music-server/internal/server/middleware"
"github.com/jackc/pgx/v5/pgtype"
"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"` // API password, required when API_PASSWORD is set
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 creates and removes session tokens.
type TokenHandler struct {
q repository.Querier
apiPassword string
disableSessionAuth bool
}
// 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}
}
func generateToken() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
}
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
//
// @Summary Create session token
// @Description Returns a new session token for API access. Requires the API password when one is configured.
// @Tags auth
// @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]
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 !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"
}
token, err := generateToken()
if err != nil {
logging.GetLogger().Error("Failed to generate token", zap.Error(err))
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to generate token"})
}
session, err := h.q.CreateSession(c.Request().Context(), repository.CreateSessionParams{
Token: token,
IpAddress: c.RealIP(),
UserAgent: c.Request().UserAgent(),
ClientType: &req.ClientType,
ExpiresAt: pgtype.Timestamptz{Time: time.Now().Add(tokenLifetime), Valid: true},
})
if err != nil {
logging.GetLogger().Error("Failed to create session", zap.Error(err))
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to create session"})
}
clientType := req.ClientType
if session.ClientType != nil {
clientType = *session.ClientType
}
return c.JSON(http.StatusOK, TokenResponse{
Token: session.Token,
ExpiresAt: session.ExpiresAt.Time,
ClientType: clientType,
})
}
// DeleteTokenHandler invalidates a session token
//
// @Summary Invalidate session token
// @Description Deletes the current session token
// @Tags auth
// @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 {
if c.Request().Header.Get("Authorization") == "" {
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Authorization header required"})
}
token, ok := middleware.BearerToken(c)
if !ok {
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Invalid authorization format"})
}
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
//
// @Summary Cleanup expired sessions
// @Description Removes all expired session tokens from the database
// @Tags auth
// @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/cleanup [post]
func (h *TokenHandler) CleanupExpiredSessionsHandler(c *echo.Context) 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"})
}
return c.JSON(http.StatusOK, map[string]string{"status": "cleanup complete"})
}