The queue lived in memory and was shared by all clients, so a restart or a finished sync cleared it. Every client now has its own queue in the new queue and queue_song tables (added to the undeployed migration 000006). The music endpoints return the queue ID in the X-Queue-Id header and expect it back. A request without an ID, or with an ended one, starts a new queue. /music/reset ends the queue and returns a new ID, and a sync ends all running queues. Ended queues are kept as history. The deprecated legacy routes without the /api/v1 prefix are removed.
132 lines
4.5 KiB
Go
132 lines
4.5 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"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/danielgtaylor/huma/v2"
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
"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,omitempty" doc:"API password, required when the server has one configured"`
|
|
ClientType string `json:"client_type,omitempty" doc:"Kind of client, for example web, godot or api" default:"web"`
|
|
}
|
|
|
|
// TokenResponse represents the response with a new token
|
|
type TokenResponse struct {
|
|
Token string `json:"token" doc:"Send as Authorization: Bearer <token>"`
|
|
ExpiresAt time.Time `json:"expires_at"`
|
|
ClientType string `json:"client_type"`
|
|
}
|
|
|
|
type tokenInput struct {
|
|
Body TokenRequest `required:"false"`
|
|
}
|
|
|
|
type statusMessage struct {
|
|
Status string `json:"status"`
|
|
}
|
|
|
|
func generateToken() (string, error) {
|
|
b := make([]byte, 32)
|
|
if _, err := rand.Read(b); err != nil {
|
|
return "", err
|
|
}
|
|
return base64.URLEncoding.EncodeToString(b), nil
|
|
}
|
|
|
|
func (s *Server) passwordValid(password string) bool {
|
|
if s.cfg.DisableSessionAuth || s.cfg.APIPassword == "" {
|
|
return true
|
|
}
|
|
return subtle.ConstantTimeCompare([]byte(password), []byte(s.cfg.APIPassword)) == 1
|
|
}
|
|
|
|
func (s *Server) registerToken(a huma.API) {
|
|
create := huma.Operation{
|
|
OperationID: "create-token",
|
|
Method: http.MethodPost,
|
|
Path: "/token",
|
|
Summary: "Create session token",
|
|
Description: "Returns a token for the other /api/v1 endpoints, valid for 24 hours. " +
|
|
"Requires the API password when the server has one. Limited to 10 requests per minute per client.",
|
|
Tags: []string{"auth"},
|
|
Errors: []int{http.StatusUnauthorized, http.StatusTooManyRequests},
|
|
Middlewares: huma.Middlewares{middleware.RateLimit(a, 10, time.Minute)},
|
|
}
|
|
register(s, a, route{op: create, public: true},
|
|
func(ctx context.Context, in *tokenInput) (*jsonBody[TokenResponse], error) {
|
|
client := middleware.ClientFrom(ctx)
|
|
if !s.passwordValid(in.Body.Password) {
|
|
logging.GetLogger().Warn("Invalid password attempt for token creation",
|
|
zap.String("ip", client.IP), zap.String("user_agent", client.UserAgent))
|
|
return nil, huma.Error401Unauthorized("Invalid password")
|
|
}
|
|
|
|
clientType := in.Body.ClientType
|
|
if clientType == "" {
|
|
clientType = "web"
|
|
}
|
|
token, err := generateToken()
|
|
if err != nil {
|
|
logging.GetLogger().Error("Failed to generate token", zap.Error(err))
|
|
return nil, huma.Error500InternalServerError("Failed to generate token")
|
|
}
|
|
session, err := s.q.CreateSession(ctx, repository.CreateSessionParams{
|
|
Token: token,
|
|
IpAddress: client.IP,
|
|
UserAgent: client.UserAgent,
|
|
ClientType: &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 nil, huma.Error500InternalServerError("Failed to create session")
|
|
}
|
|
return ok(TokenResponse{Token: session.Token, ExpiresAt: session.ExpiresAt.Time, ClientType: clientType}, nil)
|
|
})
|
|
|
|
register(s, a, route{op: huma.Operation{
|
|
OperationID: "delete-token",
|
|
Method: http.MethodDelete,
|
|
Path: "/token",
|
|
Summary: "Invalidate session token",
|
|
Description: "Deletes the token used for this request.",
|
|
Tags: []string{"auth"},
|
|
}}, func(ctx context.Context, _ *struct{}) (*jsonBody[statusMessage], error) {
|
|
if err := s.q.DeleteSession(ctx, middleware.Token(ctx)); err != nil {
|
|
logging.GetLogger().Error("Failed to delete session", zap.Error(err))
|
|
return nil, huma.Error500InternalServerError("Failed to invalidate token")
|
|
}
|
|
return ok(statusMessage{Status: "token invalidated"}, nil)
|
|
})
|
|
|
|
register(s, a, route{op: huma.Operation{
|
|
OperationID: "cleanup-tokens",
|
|
Method: http.MethodPost,
|
|
Path: "/token/cleanup",
|
|
Summary: "Remove expired tokens",
|
|
Description: "Deletes all expired session tokens.",
|
|
Tags: []string{"auth"},
|
|
}}, func(ctx context.Context, _ *struct{}) (*jsonBody[statusMessage], error) {
|
|
if err := s.q.DeleteExpiredSessions(ctx); err != nil {
|
|
logging.GetLogger().Error("Failed to cleanup sessions", zap.Error(err))
|
|
return nil, huma.Error500InternalServerError("Failed to cleanup sessions")
|
|
}
|
|
return ok(statusMessage{Status: "cleanup complete"}, nil)
|
|
})
|
|
}
|