Files
MusicServer/internal/backend/releases.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

115 lines
2.9 KiB
Go

package backend
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"sync"
"time"
"music-server/internal/logging"
"go.uber.org/zap"
)
const releaseCacheTTL = 5 * time.Minute
type giteaRelease struct {
Id int `json:"id"`
Name string `json:"name"`
Assets []giteaAsset `json:"assets"`
}
type giteaAsset struct {
Id int `json:"id"`
Name string `json:"name"`
DownloadUrl string `json:"browser_download_url"`
}
// ReleaseService looks up the latest client release on Gitea.
type ReleaseService struct {
url string
client *http.Client
now func() time.Time
mu sync.Mutex
cached giteaRelease
fetchedAt time.Time
}
// NewReleaseService creates a ReleaseService that reads the latest release from url.
func NewReleaseService(url string) *ReleaseService {
return &ReleaseService{url: url, client: &http.Client{Timeout: 10 * time.Second}, now: time.Now}
}
func (r *ReleaseService) latest(ctx context.Context) (giteaRelease, error) {
r.mu.Lock()
defer r.mu.Unlock()
if !r.fetchedAt.IsZero() && r.now().Sub(r.fetchedAt) < releaseCacheTTL {
return r.cached, nil
}
release, err := r.fetch(ctx)
if err != nil {
logging.GetLogger().Error("Failed to fetch latest release", zap.String("url", r.url), zap.Error(err))
return giteaRelease{}, fmt.Errorf("%w: %v", ErrUpstream, err)
}
r.cached, r.fetchedAt = release, r.now()
return release, nil
}
func (r *ReleaseService) fetch(ctx context.Context) (giteaRelease, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, r.url, nil)
if err != nil {
return giteaRelease{}, err
}
resp, err := r.client.Do(req)
if err != nil {
return giteaRelease{}, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return giteaRelease{}, fmt.Errorf("unexpected status %s", resp.Status)
}
var release giteaRelease
if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
return giteaRelease{}, err
}
return release, nil
}
// LatestVersion returns the name of the latest release.
func (r *ReleaseService) LatestVersion(ctx context.Context) (string, error) {
release, err := r.latest(ctx)
return release.Name, err
}
// AssetNames returns the names of all assets of the latest release.
func (r *ReleaseService) AssetNames(ctx context.Context) ([]string, error) {
release, err := r.latest(ctx)
if err != nil {
return nil, err
}
names := make([]string, len(release.Assets))
for i, asset := range release.Assets {
names[i] = asset.Name
}
return names, nil
}
// AssetURL returns the download URL of the first asset of the latest release with the given suffix.
func (r *ReleaseService) AssetURL(ctx context.Context, suffix string) (string, error) {
release, err := r.latest(ctx)
if err != nil {
return "", err
}
for _, asset := range release.Assets {
if strings.HasSuffix(asset.Name, suffix) {
return asset.DownloadUrl, nil
}
}
return "", ErrNotFound
}