Files
MusicServer/internal/db/database.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

128 lines
3.5 KiB
Go

package db
import (
"context"
"embed"
"errors"
"fmt"
"net"
"net/url"
"time"
"music-server/internal/logging"
"github.com/golang-migrate/migrate/v4"
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/jackc/pgx/v5/stdlib"
"go.uber.org/zap"
)
//go:embed "migrations/*.sql"
var MigrationsFs embed.FS
// Database holds the database connection pool
type Database struct {
Pool *pgxpool.Pool
}
// 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(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)
}
if err := pool.Ping(ctx); err != nil {
pool.Close()
return nil, fmt.Errorf("database ping failed: %w", err)
}
logging.GetLogger().Info("Database connected")
return &Database{Pool: pool}, nil
}
// Close closes the database connection pool
func (db *Database) Close() {
if db.Pool != nil {
logging.GetLogger().Info("Closing database connection")
db.Pool.Close()
}
}
// Health checks the health of the database connection by pinging the database.
func (db *Database) Health(ctx context.Context) map[string]string {
ctx, cancel := context.WithTimeout(ctx, time.Second)
defer cancel()
if err := db.Pool.Ping(ctx); err != nil {
return map[string]string{"status": "down", "error": err.Error()}
}
return map[string]string{"status": "up"}
}
// RunMigrations runs all pending database migrations to the latest version.
func (db *Database) RunMigrations() error {
m, closeFn, err := db.NewMigrator()
if err != nil {
return err
}
defer closeFn()
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 {
sqlDB.Close()
return nil, nil, fmt.Errorf("failed to create migration driver: %w", err)
}
files, err := iofs.New(MigrationsFs, "migrations")
if err != nil {
sqlDB.Close()
return nil, nil, fmt.Errorf("failed to read migration files: %w", err)
}
m, err := migrate.NewWithInstance("iofs", files, "pgx5", driver)
if err != nil {
sqlDB.Close()
return nil, nil, fmt.Errorf("failed to create migrator: %w", err)
}
return m, func() { m.Close() }, nil
}