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:
1 parent
75b6f0c1cf
commit
090e794460
78 files changed
+2919
-6303
No files matched your search
@@ -1,193 +0,0 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"music-server/internal/backend"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestCleanTerm tests that search terms are cleaned correctly
|
||||
func TestCleanTerm(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
// CleanSearchTermForMatch removes spaces, replaces specific chars, and lowercases
|
||||
{"MGS4", "mgs4"},
|
||||
{"RE2", "re2"},
|
||||
{"FFVII", "ffvii"},
|
||||
{"Ys", "ys"},
|
||||
{" test ", "test"},
|
||||
{"Test 123", "test123"},
|
||||
{"A+B", "aplusb"},
|
||||
{"é", "e"},
|
||||
// Note: CleanSearchTermForMatch does NOT remove apostrophes, it only replaces "'n" with "and"
|
||||
{"It's", "it's"},
|
||||
// But it does replace "'n" with "and"
|
||||
{"I'n", "iand"},
|
||||
{"", ""},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
result := backend.CleanSearchTermForMatch(tt.input)
|
||||
if result != tt.expected {
|
||||
t.Errorf("CleanSearchTermForMatch(%q) = %q, want %q", tt.input, result, tt.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// searchWithDataHelper is a helper to create backend.SoundtrackData from original names
|
||||
func searchWithDataHelper(originals []string) []backend.SoundtrackData {
|
||||
data := make([]backend.SoundtrackData, len(originals))
|
||||
for i, orig := range originals {
|
||||
data[i] = backend.SoundtrackData{
|
||||
Original: orig,
|
||||
Lowercase: strings.ToLower(orig),
|
||||
Cleaned: backend.CleanSearchTermForMatch(orig),
|
||||
}
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
// TestNormalSearch tests the normal search function
|
||||
func TestNormalSearch(t *testing.T) {
|
||||
// Set up comprehensive test data with all user-provided soundtracks
|
||||
testData := searchWithDataHelper([]string{
|
||||
"A Bird Story",
|
||||
"Actraiser",
|
||||
"Actraiser 2",
|
||||
"Bangai-O Spirits",
|
||||
"Ciconia When they Cry",
|
||||
"Civilization IV",
|
||||
"Civilization VI",
|
||||
"Clair Obscur - Expedition 33",
|
||||
"Club House Games (42 All-Time Classics)",
|
||||
"Final Fantasy Dissidia 012 Duodecim (Sista Fantasin Meningsskiljaktigheter 012 Tolv)",
|
||||
"Final Fantasy VII Crisis Core",
|
||||
"Final Fantasy VII Rebirth",
|
||||
"Final Fantasy VII Remake",
|
||||
"Final Fantasy VII",
|
||||
"Joe & Mac",
|
||||
"Judgment",
|
||||
"Kingdom Hearts",
|
||||
"Kingdom Hearts Birth By Sleep",
|
||||
"Kingdom Hearts II",
|
||||
"Kingdom Hearts III",
|
||||
"Lemmings (SNES + Mega Drive)",
|
||||
"Lemmings 3D",
|
||||
"Mega Man X4",
|
||||
"Metal Gear Rising - Revengeance",
|
||||
"Metal Gear Solid (+Twin Snakes)",
|
||||
"Metal Gear Solid 2 - Sons of Liberty",
|
||||
"Metal Gear Solid 3 - Snake Eater",
|
||||
"Metal Gear Solid 4 - Guns of the Patriots",
|
||||
"Metroid Prime",
|
||||
"Octopath Traveler",
|
||||
"Octopath Traveler 2",
|
||||
"Ori and the Blind Forest",
|
||||
"Ori and the Will of the Wisps",
|
||||
"OutRun 2006 - Coast 2 Coast",
|
||||
"Paper Mario - The Thousand Year Door",
|
||||
"Pokémon HeartGold & SoulSilver",
|
||||
"Resident Evil 2 (Remake)",
|
||||
"Shenmue",
|
||||
"Super Mario Galaxy",
|
||||
"Trails in the Sky FC (Sora no Kiseki FC)",
|
||||
"Trails in the Sky SC (Sora no Kiseki SC)",
|
||||
"Turrican II - The Final Fight",
|
||||
"Umineko no Naku Koro ni",
|
||||
"Umineko no Naku Koro ni Chiru",
|
||||
"VVVVVV",
|
||||
"YS VIII - Lacrimosa of DANA",
|
||||
"Yakuza 0",
|
||||
"Ys (Chronicles)",
|
||||
"Ys - Memories of Celceta",
|
||||
"Ys - The Ark of Napishtim",
|
||||
"Ys II (Chronicles)",
|
||||
"Ys III - Wanderers from Ys (+The Oath in Felghana)",
|
||||
"Ys IV - The Dawn of Ys",
|
||||
"Ys Origin",
|
||||
"Ys Seven",
|
||||
"Ys X Nordics",
|
||||
"Call of Duty: Modern Warfare",
|
||||
"Half-Life 3",
|
||||
})
|
||||
|
||||
// Test cases covering all match types
|
||||
tests := []struct {
|
||||
query string
|
||||
wantFound string
|
||||
}{
|
||||
// Exact substring matches
|
||||
{"Metal", "Metal Gear Solid 4 - Guns of the Patriots"},
|
||||
{"Resident", "Resident Evil 2 (Remake)"},
|
||||
{"Final", "Final Fantasy VII"},
|
||||
{"Ys", "Ys III - Wanderers from Ys (+The Oath in Felghana)"},
|
||||
|
||||
// Contains matches (on cleaned terms)
|
||||
{"gotp", "Metal Gear Solid 4 - Guns of the Patriots"},
|
||||
{"mgs", "Metal Gear Solid 4 - Guns of the Patriots"},
|
||||
{"kh", "Kingdom Hearts"},
|
||||
{"mg", "Metal Gear Solid 4 - Guns of the Patriots"},
|
||||
|
||||
// Regex matches
|
||||
{"mgs4go", "Metal Gear Solid 4 - Guns of the Patriots"},
|
||||
{"re2r", "Resident Evil 2 (Remake)"},
|
||||
{"ffv", "Final Fantasy VII"},
|
||||
{"yiwfyt", "Ys III - Wanderers from Ys (+The Oath in Felghana)"},
|
||||
{"codmw", "Call of Duty: Modern Warfare"},
|
||||
{"mgs4gop", "Metal Gear Solid 4 - Guns of the Patriots"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.query, func(t *testing.T) {
|
||||
results := searchWithData(tt.query, testData)
|
||||
|
||||
// Check that the expected item is in the results
|
||||
found := false
|
||||
for _, result := range results {
|
||||
if result == tt.wantFound {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("searchWithData(%q, ...) did not find expected item %q, got %v", tt.query, tt.wantFound, results)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSearchOrdering tests that Pass 1 matches come before Pass 2 matches
|
||||
func TestSearchOrdering(t *testing.T) {
|
||||
// "MG" matches Pass 1 (exact substring on original)
|
||||
// "Metal Gear" matches Pass 2 (contains on cleaned) but NOT Pass 1 (no "mg" substring in "metal gear")
|
||||
testData := searchWithDataHelper([]string{
|
||||
"Metal Gear",
|
||||
"MG",
|
||||
})
|
||||
|
||||
results := searchWithData("mg", testData)
|
||||
|
||||
// Find indices
|
||||
indexPass1 := -1
|
||||
indexPass2 := -1
|
||||
for i, result := range results {
|
||||
if result == "MG" {
|
||||
indexPass1 = i
|
||||
}
|
||||
if result == "Metal Gear" {
|
||||
indexPass2 = i
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 1 match (MG) should come before Pass 2 match (Metal Gear)
|
||||
if indexPass1 >= 0 && indexPass2 >= 0 && indexPass1 >= indexPass2 {
|
||||
t.Errorf("Expected Pass 1 match 'MG' to come before Pass 2 match 'Metal Gear', but got order: %v", results)
|
||||
}
|
||||
}
|
||||
|
||||
// Note: The tests above verify:
|
||||
// 1. clean_term - the term cleaning logic
|
||||
// 2. search - the complete normal search with exact, contains, and regex matching
|
||||
// 3. ordering - exact matches come before contains matches
|
||||
Reference in new issue
Block a user