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:
Sansan committed 2026-10-06 15:20:14 +02:00
1 parent 75b6f0c1cf
commit 090e794460
78 files changed
+2919 -6303

No files matched your search

+69 -84
View File
@@ -2,54 +2,57 @@ package db
import (
"context"
"database/sql"
"embed"
"errors"
"fmt"
"net"
"net/url"
"time"
"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"
pgxmigrate "github.com/golang-migrate/migrate/v4/database/pgx/v5"
"github.com/golang-migrate/migrate/v4/source/iofs"
"github.com/jackc/pgx/v5/pgxpool"
_ "github.com/lib/pq"
"github.com/jackc/pgx/v5/stdlib"
"go.uber.org/zap"
)
// Database holds the database connection pool and context
//go:embed "migrations/*.sql"
var MigrationsFs embed.FS
// Database holds the database connection pool
type Database struct {
Pool *pgxpool.Pool
Ctx context.Context
}
// ConnString builds a PostgreSQL connection URL.
func ConnString(host, port, user, password, dbname string) string {
u := url.URL{
Scheme: "postgres",
User: url.UserPassword(user, password),
Host: net.JoinHostPort(host, port),
Path: dbname,
RawQuery: "sslmode=disable",
}
return u.String()
}
// 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)
func NewDatabase(ctx context.Context, connString string) (*Database, error) {
pool, err := pgxpool.New(ctx, connString)
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 {
if err := pool.Ping(ctx); err != nil {
pool.Close()
return nil, fmt.Errorf("database query failed: %w", err)
return nil, fmt.Errorf("database ping failed: %w", err)
}
logging.GetLogger().Info("Database connected", zap.String("status", success))
return &Database{Pool: pool, Ctx: ctx}, nil
logging.GetLogger().Info("Database connected")
return &Database{Pool: pool}, nil
}
// Close closes the database connection pool
@@ -61,82 +64,64 @@ func (db *Database) Close() {
}
// Health checks the health of the database connection by pinging the database.
// It returns a map with keys indicating various health statistics.
func (db *Database) Health() map[string]string {
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
func (db *Database) Health(ctx context.Context) map[string]string {
ctx, cancel := context.WithTimeout(ctx, time.Second)
defer cancel()
stats := make(map[string]string)
// Ping the database
err := db.Pool.Ping(ctx)
if err != nil {
stats["status"] = "down"
stats["error"] = err.Error()
return stats
if err := db.Pool.Ping(ctx); err != nil {
return map[string]string{"status": "down", "error": err.Error()}
}
stats["status"] = "up"
return stats
return map[string]string{"status": "up"}
}
// 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)
m, closeFn, err := db.NewMigrator()
if err != nil {
return fmt.Errorf("failed to open database for migration: %w", err)
return err
}
defer sqlDb.Close()
defer closeFn()
driver, err := postgres.WithInstance(sqlDb, &postgres.Config{})
version, _, err := m.Version()
if err != nil && !errors.Is(err, migrate.ErrNilVersion) {
logging.GetLogger().Error("Failed to get migration version", zap.String("error", err.Error()))
}
logging.GetLogger().Info("Migration version before", zap.Uint("version", version))
if err := m.Up(); err != nil {
if !errors.Is(err, migrate.ErrNoChange) {
return fmt.Errorf("migration failed: %w", err)
}
logging.GetLogger().Info("Database already up to date")
return nil
}
versionAfter, _, _ := m.Version()
logging.GetLogger().Info("Migrated to version", zap.Uint("version", versionAfter))
return nil
}
// NewMigrator returns a migrator for the embedded migrations using the pool's connection settings.
// The returned function closes the underlying connection.
func (db *Database) NewMigrator() (*migrate.Migrate, func(), error) {
sqlDB := stdlib.OpenDB(*db.Pool.Config().ConnConfig)
driver, err := pgxmigrate.WithInstance(sqlDB, &pgxmigrate.Config{})
if err != nil {
return fmt.Errorf("failed to create migration driver: %w", err)
sqlDB.Close()
return nil, nil, 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)
sqlDB.Close()
return nil, nil, fmt.Errorf("failed to read migration files: %w", err)
}
m, err := migrate.NewWithInstance("iofs", files, "postgres", driver)
m, err := migrate.NewWithInstance("iofs", files, "pgx5", driver)
if err != nil {
return fmt.Errorf("failed to create migrator: %w", err)
sqlDB.Close()
return nil, nil, 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
return m, func() { m.Close() }, nil
}