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,7 +1,7 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"context"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
@@ -13,88 +13,71 @@ type SoundtrackData struct {
|
||||
Cleaned string
|
||||
}
|
||||
|
||||
var (
|
||||
searchCache []SoundtrackData
|
||||
searchCacheMutex sync.Mutex
|
||||
searchRegexCache = make(map[string]*regexp.Regexp)
|
||||
searchRegexCacheMutex sync.Mutex
|
||||
)
|
||||
// SearchCache holds precomputed search data for all soundtrack names.
|
||||
type SearchCache struct {
|
||||
load func(context.Context) ([]string, error)
|
||||
|
||||
// GetSearchCache returns the precomputed search cache, initializing it if needed
|
||||
func GetSearchCache() []SoundtrackData {
|
||||
searchCacheMutex.Lock()
|
||||
defer searchCacheMutex.Unlock()
|
||||
|
||||
if len(searchCache) == 0 {
|
||||
loadSearchCache()
|
||||
}
|
||||
return searchCache
|
||||
mu sync.Mutex
|
||||
cache []SoundtrackData
|
||||
}
|
||||
|
||||
// ReloadSearchCache clears and reloads the search cache.
|
||||
// This should be called after a sync operation to ensure search results are up to date.
|
||||
func ReloadSearchCache() {
|
||||
searchCacheMutex.Lock()
|
||||
defer searchCacheMutex.Unlock()
|
||||
searchCache = nil
|
||||
loadSearchCache()
|
||||
// NewSearchCache creates a SearchCache that gets the soundtrack names from load.
|
||||
func NewSearchCache(load func(context.Context) ([]string, error)) *SearchCache {
|
||||
return &SearchCache{load: load}
|
||||
}
|
||||
|
||||
// loadSearchCache loads the soundtrack data into the cache
|
||||
func loadSearchCache() {
|
||||
soundtracks := GetAllSoundtracks()
|
||||
searchCache = make([]SoundtrackData, len(soundtracks))
|
||||
|
||||
for i, soundtrack := range soundtracks {
|
||||
cleaned := cleanSearchTerm(soundtrack)
|
||||
lowercase := strings.ToLower(soundtrack)
|
||||
searchCache[i] = SoundtrackData{
|
||||
Original: soundtrack,
|
||||
Lowercase: lowercase,
|
||||
Cleaned: cleaned,
|
||||
// Get returns the precomputed search data, loading it if needed.
|
||||
func (s *SearchCache) Get(ctx context.Context) ([]SoundtrackData, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if len(s.cache) == 0 {
|
||||
if err := s.reload(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return s.cache, nil
|
||||
}
|
||||
|
||||
// CleanSearchTermForMatch cleans a search term for better matching
|
||||
func CleanSearchTermForMatch(term string) string {
|
||||
term = strings.ReplaceAll(term, " ", "")
|
||||
term = strings.ReplaceAll(term, "é", "e")
|
||||
term = strings.ReplaceAll(term, "+", "plus")
|
||||
term = strings.ReplaceAll(term, "&", "and")
|
||||
term = strings.ReplaceAll(term, "'n", "and")
|
||||
return strings.ToLower(term)
|
||||
// Reload reloads the search data. Call it after a sync.
|
||||
func (s *SearchCache) Reload(ctx context.Context) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.reload(ctx)
|
||||
}
|
||||
|
||||
// cleanSearchTerm cleans a search term for better matching (internal)
|
||||
func cleanSearchTerm(term string) string {
|
||||
return CleanSearchTermForMatch(term)
|
||||
}
|
||||
|
||||
// MatchRegexCached checks if a search term matches using cached compiled regex
|
||||
func MatchRegexCached(searchTerm string, soundtrackName string) bool {
|
||||
if searchTerm == "" {
|
||||
return true
|
||||
func (s *SearchCache) reload(ctx context.Context) error {
|
||||
soundtracks, err := s.load(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Check cache first
|
||||
searchRegexCacheMutex.Lock()
|
||||
re, exists := searchRegexCache[searchTerm]
|
||||
if !exists {
|
||||
re = compileSearchRegex(searchTerm)
|
||||
searchRegexCache[searchTerm] = re
|
||||
cache := make([]SoundtrackData, len(soundtracks))
|
||||
for i, soundtrack := range soundtracks {
|
||||
cache[i] = SoundtrackData{
|
||||
Original: soundtrack,
|
||||
Lowercase: strings.ToLower(soundtrack),
|
||||
Cleaned: CleanSearchTerm(soundtrack),
|
||||
}
|
||||
}
|
||||
searchRegexCacheMutex.Unlock()
|
||||
|
||||
return re.MatchString(soundtrackName)
|
||||
s.cache = cache
|
||||
return nil
|
||||
}
|
||||
|
||||
// compileSearchRegex compiles a search term into a regex pattern
|
||||
func compileSearchRegex(searchTerm string) *regexp.Regexp {
|
||||
regText := ".*"
|
||||
for _, letter := range searchTerm {
|
||||
regText += string(letter) + ".*"
|
||||
var searchReplacer = strings.NewReplacer(" ", "", "é", "e", "+", "plus", "&", "and", "'n", "and")
|
||||
|
||||
// CleanSearchTerm normalises a search term or soundtrack name for matching.
|
||||
func CleanSearchTerm(term string) string {
|
||||
return searchReplacer.Replace(strings.ToLower(term))
|
||||
}
|
||||
|
||||
// MatchesSubsequence reports whether all characters of term appear in text in the same order.
|
||||
func MatchesSubsequence(term, text string) bool {
|
||||
rest := text
|
||||
for _, r := range term {
|
||||
i := strings.IndexRune(rest, r)
|
||||
if i < 0 {
|
||||
return false
|
||||
}
|
||||
rest = rest[i+len(string(r)):]
|
||||
}
|
||||
re, _ := regexp.Compile(regText)
|
||||
return re
|
||||
return true
|
||||
}
|
||||
Reference in new issue
Block a user