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
115 lines
2.9 KiB
Go
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
|
|
}
|