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:
Sansan committed 2026-10-06 15:20:14 +02:00
1 parent 75b6f0c1cf
commit 090e794460
78 files changed
+2919 -6303

No files matched your search

+204 -220
View File
@@ -1,23 +1,23 @@
// Package docs GENERATED BY THE COMMAND ABOVE; DO NOT EDIT
// This file was generated by swaggo/swag
// Package docs Code generated by swaggo/swag. DO NOT EDIT
package docs
import (
"bytes"
"encoding/json"
"strings"
"text/template"
import "github.com/swaggo/swag"
"github.com/swaggo/swag"
)
var doc = `{
const docTemplate = `{
"schemes": {{ marshal .Schemes }},
"swagger": "2.0",
"info": {
"description": "{{escape .Description}}",
"title": "{{.Title}}",
"contact": {},
"termsOfService": "http://sanplex.xyz/terms/",
"contact": {
"name": "Sebastian Olsson",
"email": "zarnor91@gmail.com"
},
"license": {
"name": "MIT",
"url": "http://opensource.org/licenses/MIT"
},
"version": "{{.Version}}"
},
"host": "{{.Host}}",
@@ -26,9 +26,6 @@ var doc = `{
"/api/v1/statistics/songs/least-played": {
"get": {
"description": "Returns the top N least played songs with their soundtrack info",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
@@ -39,7 +36,7 @@ var doc = `{
"parameters": [
{
"type": "integer",
"description": "Number of results (default: 10)",
"description": "Number of results (default: 10, max: 100)",
"name": "limit",
"in": "query"
}
@@ -78,9 +75,6 @@ var doc = `{
"/api/v1/statistics/songs/most-played": {
"get": {
"description": "Returns the top N most played songs with their soundtrack info",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
@@ -91,7 +85,7 @@ var doc = `{
"parameters": [
{
"type": "integer",
"description": "Number of results (default: 10)",
"description": "Number of results (default: 10, max: 100)",
"name": "limit",
"in": "query"
}
@@ -129,10 +123,7 @@ var doc = `{
},
"/api/v1/statistics/soundtracks/last-played": {
"get": {
"description": "Returns the most recently played soundtracks",
"consumes": [
"application/json"
],
"description": "Returns the most recently played soundtracks with their songs",
"produces": [
"application/json"
],
@@ -143,7 +134,7 @@ var doc = `{
"parameters": [
{
"type": "integer",
"description": "Number of results (default: 10)",
"description": "Number of results (default: 10, max: 100)",
"name": "limit",
"in": "query"
}
@@ -182,9 +173,6 @@ var doc = `{
"/api/v1/statistics/soundtracks/least-played": {
"get": {
"description": "Returns the top N least played soundtracks with their songs",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
@@ -195,7 +183,7 @@ var doc = `{
"parameters": [
{
"type": "integer",
"description": "Number of results (default: 10)",
"description": "Number of results (default: 10, max: 100)",
"name": "limit",
"in": "query"
}
@@ -234,9 +222,6 @@ var doc = `{
"/api/v1/statistics/soundtracks/most-played": {
"get": {
"description": "Returns the top N most played soundtracks with their songs",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
@@ -247,7 +232,7 @@ var doc = `{
"parameters": [
{
"type": "integer",
"description": "Number of results (default: 10)",
"description": "Number of results (default: 10, max: 100)",
"name": "limit",
"in": "query"
}
@@ -285,10 +270,7 @@ var doc = `{
},
"/api/v1/statistics/soundtracks/never-played": {
"get": {
"description": "Returns all soundtracks that have never been played (times_played = 0)",
"consumes": [
"application/json"
],
"description": "Returns all soundtracks that have never been played",
"produces": [
"application/json"
],
@@ -320,10 +302,7 @@ var doc = `{
},
"/api/v1/statistics/soundtracks/oldest-played": {
"get": {
"description": "Returns the least recently played soundtracks (that have been played at least once)",
"consumes": [
"application/json"
],
"description": "Returns the least recently played soundtracks with their songs",
"produces": [
"application/json"
],
@@ -334,7 +313,7 @@ var doc = `{
"parameters": [
{
"type": "integer",
"description": "Number of results (default: 10)",
"description": "Number of results (default: 10, max: 100)",
"name": "limit",
"in": "query"
}
@@ -372,10 +351,7 @@ var doc = `{
},
"/api/v1/statistics/summary": {
"get": {
"description": "Returns overall statistics about the music library",
"consumes": [
"application/json"
],
"description": "Returns overall play statistics",
"produces": [
"application/json"
],
@@ -404,7 +380,7 @@ var doc = `{
},
"/api/v1/token": {
"post": {
"description": "Returns a new session token for API access. Requires valid API password.",
"description": "Returns a new session token for API access. Requires the API password when one is configured.",
"consumes": [
"application/json"
],
@@ -451,6 +427,15 @@ var doc = `{
}
}
},
"429": {
"description": "Too Many Requests",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal Server Error",
"schema": {
@@ -464,9 +449,6 @@ var doc = `{
},
"delete": {
"description": "Deletes the current session token",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
@@ -517,9 +499,6 @@ var doc = `{
"/api/v1/token/cleanup": {
"post": {
"description": "Removes all expired session tokens from the database",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
@@ -541,7 +520,9 @@ var doc = `{
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
"additionalProperties": {
"type": "string"
}
}
},
"401": {
@@ -568,11 +549,9 @@ var doc = `{
"/character": {
"get": {
"description": "Returns the image for a specific character",
"consumes": [
"application/json"
],
"produces": [
"image/png"
"image/png",
"image/jpeg"
],
"tags": [
"characters"
@@ -581,7 +560,7 @@ var doc = `{
"parameters": [
{
"type": "string",
"description": "Character name",
"description": "Character file name",
"name": "name",
"in": "query",
"required": true
@@ -593,6 +572,18 @@ var doc = `{
"schema": {
"type": "file"
}
},
"400": {
"description": "invalid name",
"schema": {
"type": "string"
}
},
"404": {
"description": "not found",
"schema": {
"type": "string"
}
}
}
}
@@ -600,9 +591,6 @@ var doc = `{
"/characters": {
"get": {
"description": "Returns a list of all available characters",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
@@ -625,10 +613,7 @@ var doc = `{
},
"/download": {
"get": {
"description": "Checks for the latest version of the application",
"consumes": [
"application/json"
],
"description": "Returns the latest version of the client application",
"produces": [
"application/json"
],
@@ -642,6 +627,12 @@ var doc = `{
"schema": {
"type": "string"
}
},
"502": {
"description": "upstream service unavailable",
"schema": {
"type": "string"
}
}
}
}
@@ -649,9 +640,6 @@ var doc = `{
"/download/linux": {
"get": {
"description": "Redirects to download the latest Linux version",
"produces": [
"application/octet-stream"
],
"tags": [
"download"
],
@@ -662,6 +650,18 @@ var doc = `{
"schema": {
"type": "string"
}
},
"404": {
"description": "not found",
"schema": {
"type": "string"
}
},
"502": {
"description": "upstream service unavailable",
"schema": {
"type": "string"
}
}
}
}
@@ -669,9 +669,6 @@ var doc = `{
"/download/list": {
"get": {
"description": "Lists all assets available for the latest version",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
@@ -688,6 +685,12 @@ var doc = `{
"type": "string"
}
}
},
"502": {
"description": "upstream service unavailable",
"schema": {
"type": "string"
}
}
}
}
@@ -695,9 +698,6 @@ var doc = `{
"/download/windows": {
"get": {
"description": "Redirects to download the latest Windows version",
"produces": [
"application/octet-stream"
],
"tags": [
"download"
],
@@ -708,6 +708,18 @@ var doc = `{
"schema": {
"type": "string"
}
},
"404": {
"description": "not found",
"schema": {
"type": "string"
}
},
"502": {
"description": "upstream service unavailable",
"schema": {
"type": "string"
}
}
}
}
@@ -715,9 +727,6 @@ var doc = `{
"/health": {
"get": {
"description": "Returns the health status of the server",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
@@ -729,7 +738,10 @@ var doc = `{
"200": {
"description": "OK",
"schema": {
"type": "string"
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
@@ -737,10 +749,7 @@ var doc = `{
},
"/music": {
"get": {
"description": "Returns a specific song by name",
"consumes": [
"application/json"
],
"description": "Returns the song at the given position in the queue",
"produces": [
"audio/mpeg"
],
@@ -750,8 +759,8 @@ var doc = `{
"summary": "Get a specific song",
"parameters": [
{
"type": "string",
"description": "Song name",
"type": "integer",
"description": "Song number in the queue",
"name": "song",
"in": "query",
"required": true
@@ -764,8 +773,14 @@ var doc = `{
"type": "file"
}
},
"206": {
"description": "Partial Content",
"schema": {
"type": "file"
}
},
"400": {
"description": "song can't be empty",
"description": "song must be a number",
"schema": {
"type": "string"
}
@@ -787,17 +802,14 @@ var doc = `{
},
"/music/addPlayed": {
"get": {
"description": "Adds the latest song to the played list",
"consumes": [
"application/json"
],
"description": "Marks the current song as played",
"tags": [
"music"
],
"summary": "Add latest to played",
"responses": {
"204": {
"description": ""
"200": {
"description": "OK"
},
"423": {
"description": "Syncing is in progress",
@@ -811,16 +823,13 @@ var doc = `{
"/music/addQue": {
"get": {
"description": "Adds the latest song to the queue",
"consumes": [
"application/json"
],
"tags": [
"music"
],
"summary": "Add latest to queue",
"responses": {
"204": {
"description": ""
"200": {
"description": "OK"
},
"423": {
"description": "Syncing is in progress",
@@ -834,9 +843,6 @@ var doc = `{
"/music/all/order": {
"get": {
"description": "Returns a list of all soundtracks in order",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
@@ -850,8 +856,7 @@ var doc = `{
"schema": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": true
"type": "string"
}
}
},
@@ -867,9 +872,6 @@ var doc = `{
"/music/all/random": {
"get": {
"description": "Returns a list of all soundtracks in random order",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
@@ -883,8 +885,7 @@ var doc = `{
"schema": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": true
"type": "string"
}
}
},
@@ -900,9 +901,6 @@ var doc = `{
"/music/info": {
"get": {
"description": "Returns information about the current song",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
@@ -914,8 +912,7 @@ var doc = `{
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
"$ref": "#/definitions/backend.SongInfo"
}
}
}
@@ -924,9 +921,6 @@ var doc = `{
"/music/list": {
"get": {
"description": "Returns a list of played songs",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
@@ -940,8 +934,7 @@ var doc = `{
"schema": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": true
"$ref": "#/definitions/backend.SongInfo"
}
}
}
@@ -982,13 +975,7 @@ var doc = `{
},
"/music/played": {
"put": {
"description": "Marks a song as played by its ID",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"description": "Marks the song at the given queue position as played",
"tags": [
"music"
],
@@ -996,15 +983,15 @@ var doc = `{
"parameters": [
{
"type": "integer",
"description": "Song ID",
"description": "Song number in the queue",
"name": "song",
"in": "query",
"required": true
}
],
"responses": {
"204": {
"description": ""
"200": {
"description": "OK"
},
"400": {
"description": "Bad Request",
@@ -1087,7 +1074,7 @@ var doc = `{
},
"/music/rand/classic": {
"get": {
"description": "Returns a random song from the classic selection",
"description": "Returns a random song where every song has the same chance",
"produces": [
"audio/mpeg"
],
@@ -1119,7 +1106,7 @@ var doc = `{
},
"/music/rand/low": {
"get": {
"description": "Returns a random song with low chance selection",
"description": "Returns a random song, favouring soundtracks that have been played less",
"produces": [
"audio/mpeg"
],
@@ -1152,16 +1139,13 @@ var doc = `{
"/music/reset": {
"get": {
"description": "Resets the music state",
"consumes": [
"application/json"
],
"tags": [
"music"
],
"summary": "Reset music state",
"responses": {
"204": {
"description": ""
"200": {
"description": "OK"
},
"423": {
"description": "Syncing is in progress",
@@ -1205,11 +1189,8 @@ var doc = `{
}
},
"/sync": {
"get": {
"description": "Starts syncing soundtracks with only new changes",
"consumes": [
"application/json"
],
"post": {
"description": "Starts syncing soundtracks that have changed",
"produces": [
"application/json"
],
@@ -1234,11 +1215,8 @@ var doc = `{
}
},
"/sync/full": {
"get": {
"post": {
"description": "Starts a full sync of all soundtracks",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
@@ -1264,10 +1242,7 @@ var doc = `{
},
"/sync/progress": {
"get": {
"description": "Returns the current sync progress or result",
"consumes": [
"application/json"
],
"description": "Returns the progress of the running sync, or the result of the last sync",
"produces": [
"application/json"
],
@@ -1279,19 +1254,15 @@ var doc = `{
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
"$ref": "#/definitions/backend.SyncResponse"
}
}
}
}
},
"/sync/reset": {
"get": {
"post": {
"description": "Resets the soundtracks database by deleting all soundtracks and songs",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
@@ -1318,9 +1289,6 @@ var doc = `{
"/version": {
"get": {
"description": "get latest version info",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
@@ -1334,12 +1302,6 @@ var doc = `{
"schema": {
"$ref": "#/definitions/backend.VersionData"
}
},
"404": {
"description": "Not Found",
"schema": {
"type": "string"
}
}
}
}
@@ -1347,9 +1309,6 @@ var doc = `{
"/version/history": {
"get": {
"description": "get version history",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
@@ -1366,18 +1325,35 @@ var doc = `{
"$ref": "#/definitions/backend.VersionData"
}
}
},
"404": {
"description": "Not Found",
"schema": {
"type": "string"
}
}
}
}
}
},
"definitions": {
"backend.SongInfo": {
"type": "object",
"properties": {
"CurrentlyPlaying": {
"type": "boolean"
},
"Song": {
"type": "string"
},
"SongNo": {
"type": "integer"
},
"SongPlayed": {
"type": "integer"
},
"Soundtrack": {
"type": "string"
},
"SoundtrackPlayed": {
"type": "integer"
}
}
},
"backend.SongInfoForStats": {
"type": "object",
"properties": {
@@ -1450,6 +1426,50 @@ var doc = `{
}
}
},
"backend.SyncResponse": {
"type": "object",
"properties": {
"catched_errors": {
"type": "array",
"items": {
"type": "string"
}
},
"soundtracks_added": {
"type": "array",
"items": {
"type": "string"
}
},
"soundtracks_changed_content": {
"type": "array",
"items": {
"type": "string"
}
},
"soundtracks_changed_title": {
"type": "object",
"additionalProperties": {
"type": "string"
}
},
"soundtracks_re_added": {
"type": "array",
"items": {
"type": "string"
}
},
"soundtracks_removed": {
"type": "array",
"items": {
"type": "string"
}
},
"total_time": {
"type": "string"
}
}
},
"backend.VersionData": {
"type": "object",
"properties": {
@@ -1477,7 +1497,7 @@ var doc = `{
"type": "string"
},
"password": {
"description": "Required: API password",
"description": "API password, required when API_PASSWORD is set",
"type": "string"
}
}
@@ -1499,56 +1519,20 @@ var doc = `{
}
}`
type swaggerInfo struct {
Version string
Host string
BasePath string
Schemes []string
Title string
Description string
}
// SwaggerInfo holds exported Swagger Info so clients can modify it
var SwaggerInfo = swaggerInfo{
Version: "",
Host: "",
BasePath: "",
Schemes: []string{},
Title: "",
Description: "",
}
type s struct{}
func (s *s) ReadDoc() string {
sInfo := SwaggerInfo
sInfo.Description = strings.Replace(sInfo.Description, "\n", "\\n", -1)
t, err := template.New("swagger_info").Funcs(template.FuncMap{
"marshal": func(v interface{}) string {
a, _ := json.Marshal(v)
return string(a)
},
"escape": func(v interface{}) string {
// escape tabs
str := strings.Replace(v.(string), "\t", "\\t", -1)
// replace " with \", and if that results in \\", replace that with \\\"
str = strings.Replace(str, "\"", "\\\"", -1)
return strings.Replace(str, "\\\\\"", "\\\\\\\"", -1)
},
}).Parse(doc)
if err != nil {
return doc
}
var tpl bytes.Buffer
if err := t.Execute(&tpl, sInfo); err != nil {
return doc
}
return tpl.String()
var SwaggerInfo = &swag.Spec{
Version: "1.0",
Host: "",
BasePath: "/",
Schemes: []string{},
Title: "MusicServer API",
Description: "API for the MusicServer application",
InfoInstanceName: "swagger",
SwaggerTemplate: docTemplate,
LeftDelim: "{{",
RightDelim: "}}",
}
func init() {
swag.Register("swagger", &s{})
swag.Register(SwaggerInfo.InstanceName(), SwaggerInfo)
}