Merge pull request 'feat(subsonic): auth + envelope + ping.view + getLicense.view (Fable #294)' (#7) from dev into main

This commit was merged in pull request #7.
This commit is contained in:
2026-04-19 18:10:12 +00:00
18 changed files with 557 additions and 16 deletions
+4 -1
View File
@@ -17,6 +17,7 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
"git.fabledsword.com/bvandeusen/minstrel/internal/logging"
"git.fabledsword.com/bvandeusen/minstrel/internal/server"
"git.fabledsword.com/bvandeusen/minstrel/internal/subsonic"
)
func main() {
@@ -72,7 +73,9 @@ func run() error {
}()
}
srv := server.New(logger, pool, scanner)
srv := server.New(logger, pool, scanner, subsonic.Config{
AllowPlaintextPassword: cfg.Subsonic.AllowPlaintextPassword,
})
httpServer := &http.Server{
Addr: cfg.Server.Address,
Handler: srv.Router(),
+23
View File
@@ -13,3 +13,26 @@ database:
log:
level: "info" # debug | info | warn | error
format: "text" # text | json
auth:
# One-shot admin bootstrap. Applied only when the users table is empty.
# Set username to enable; leave password blank to have the server generate
# one and log it to stderr on first start.
# Env: SMARTMUSIC_AUTH_ADMIN_USERNAME, SMARTMUSIC_AUTH_ADMIN_PASSWORD
admin_bootstrap:
username: ""
password: ""
library:
# Filesystem roots to scan for music. Each path is walked recursively.
# Env: SMARTMUSIC_LIBRARY_SCAN_PATHS (colon-separated, PATH-style)
scan_paths: []
# Kick off a scan on server startup. Env: SMARTMUSIC_LIBRARY_SCAN_ON_STARTUP
scan_on_startup: false
subsonic:
# Gate for the legacy `p=` plaintext password parameter on /rest/*.
# Leave disabled unless a specific client requires it — Minstrel prefers
# apiKey (OpenSubsonic) and falls back to u+t+s token auth.
# Env: SMARTMUSIC_SUBSONIC_ALLOW_PLAINTEXT_PASSWORD
allow_plaintext_password: false
+13
View File
@@ -15,6 +15,7 @@ type Config struct {
Log LogConfig `yaml:"log"`
Auth AuthConfig `yaml:"auth"`
Library LibraryConfig `yaml:"library"`
Subsonic SubsonicConfig `yaml:"subsonic"`
}
type ServerConfig struct {
@@ -47,6 +48,13 @@ type LibraryConfig struct {
ScanOnStartup bool `yaml:"scan_on_startup"`
}
// SubsonicConfig controls wire-format concessions on the /rest/* surface.
// AllowPlaintextPassword gates the `p=` parameter; the recommended posture is
// to leave it disabled and require apiKey (OpenSubsonic) or t+s.
type SubsonicConfig struct {
AllowPlaintextPassword bool `yaml:"allow_plaintext_password"`
}
func Default() Config {
return Config{
Server: ServerConfig{Address: ":4533"},
@@ -98,6 +106,11 @@ func applyEnv(cfg *Config) {
cfg.Library.ScanOnStartup = b
}
}
if v, ok := os.LookupEnv("SMARTMUSIC_SUBSONIC_ALLOW_PLAINTEXT_PASSWORD"); ok {
if b, err := strconv.ParseBool(v); err == nil {
cfg.Subsonic.AllowPlaintextPassword = b
}
}
}
// splitPaths splits a colon-separated path list, dropping empty entries.
+11
View File
@@ -117,6 +117,17 @@ func TestLibraryEnvOverrides(t *testing.T) {
}
}
func TestSubsonicEnvOverride(t *testing.T) {
t.Setenv("SMARTMUSIC_SUBSONIC_ALLOW_PLAINTEXT_PASSWORD", "true")
cfg, err := Load("")
if err != nil {
t.Fatalf("Load: %v", err)
}
if !cfg.Subsonic.AllowPlaintextPassword {
t.Error("allow_plaintext_password = false, want true")
}
}
func TestLibraryYAMLLoads(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.yaml")
+7 -6
View File
@@ -48,10 +48,11 @@ type Track struct {
}
type User struct {
ID pgtype.UUID
Username string
PasswordHash string
ApiToken string
IsAdmin bool
CreatedAt pgtype.Timestamptz
ID pgtype.UUID
Username string
PasswordHash string
ApiToken string
IsAdmin bool
CreatedAt pgtype.Timestamptz
SubsonicPassword *string
}
+24 -3
View File
@@ -7,6 +7,8 @@ package dbq
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const countUsers = `-- name: CountUsers :one
@@ -23,7 +25,7 @@ func (q *Queries) CountUsers(ctx context.Context) (int64, error) {
const createUser = `-- name: CreateUser :one
INSERT INTO users (username, password_hash, api_token, is_admin)
VALUES ($1, $2, $3, $4)
RETURNING id, username, password_hash, api_token, is_admin, created_at
RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password
`
type CreateUserParams struct {
@@ -48,12 +50,13 @@ func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, e
&i.ApiToken,
&i.IsAdmin,
&i.CreatedAt,
&i.SubsonicPassword,
)
return i, err
}
const getUserByAPIToken = `-- name: GetUserByAPIToken :one
SELECT id, username, password_hash, api_token, is_admin, created_at FROM users WHERE api_token = $1
SELECT id, username, password_hash, api_token, is_admin, created_at, subsonic_password FROM users WHERE api_token = $1
`
func (q *Queries) GetUserByAPIToken(ctx context.Context, apiToken string) (User, error) {
@@ -66,12 +69,13 @@ func (q *Queries) GetUserByAPIToken(ctx context.Context, apiToken string) (User,
&i.ApiToken,
&i.IsAdmin,
&i.CreatedAt,
&i.SubsonicPassword,
)
return i, err
}
const getUserByUsername = `-- name: GetUserByUsername :one
SELECT id, username, password_hash, api_token, is_admin, created_at FROM users WHERE username = $1
SELECT id, username, password_hash, api_token, is_admin, created_at, subsonic_password FROM users WHERE username = $1
`
func (q *Queries) GetUserByUsername(ctx context.Context, username string) (User, error) {
@@ -84,6 +88,23 @@ func (q *Queries) GetUserByUsername(ctx context.Context, username string) (User,
&i.ApiToken,
&i.IsAdmin,
&i.CreatedAt,
&i.SubsonicPassword,
)
return i, err
}
const setSubsonicPassword = `-- name: SetSubsonicPassword :exec
UPDATE users SET subsonic_password = $2 WHERE id = $1
`
type SetSubsonicPasswordParams struct {
ID pgtype.UUID
SubsonicPassword *string
}
// Stores (or clears with NULL) the per-user Subsonic legacy credential used
// for t/s and p auth on /rest/*. Must be plaintext; see migration 0003.
func (q *Queries) SetSubsonicPassword(ctx context.Context, arg SetSubsonicPasswordParams) error {
_, err := q.db.Exec(ctx, setSubsonicPassword, arg.ID, arg.SubsonicPassword)
return err
}
@@ -0,0 +1 @@
ALTER TABLE users DROP COLUMN IF EXISTS subsonic_password;
@@ -0,0 +1,7 @@
-- Subsonic's token auth (t = md5(password + salt)) requires the server to
-- compute md5 over the user's plaintext password. bcrypt (password_hash) is
-- not reversible, so we store a *separate*, opt-in, plaintext credential used
-- exclusively for the /rest/* legacy auth path. NULL means the user has not
-- opted into Subsonic t/s auth and must use apiKey (OpenSubsonic) instead.
ALTER TABLE users ADD COLUMN subsonic_password text;
+5
View File
@@ -11,3 +11,8 @@ SELECT * FROM users WHERE api_token = $1;
-- name: CountUsers :one
SELECT count(*) FROM users;
-- name: SetSubsonicPassword :exec
-- Stores (or clears with NULL) the per-user Subsonic legacy credential used
-- for t/s and p auth on /rest/*. Must be plaintext; see migration 0003.
UPDATE users SET subsonic_password = $2 WHERE id = $1;
+8 -5
View File
@@ -12,6 +12,7 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
"git.fabledsword.com/bvandeusen/minstrel/internal/subsonic"
)
// ScanTrigger is the subset of the scanner the HTTP handler needs. Kept as an
@@ -21,13 +22,14 @@ type ScanTrigger interface {
}
type Server struct {
Logger *slog.Logger
Pool *pgxpool.Pool
Scanner ScanTrigger
Logger *slog.Logger
Pool *pgxpool.Pool
Scanner ScanTrigger
SubsonicCfg subsonic.Config
}
func New(logger *slog.Logger, pool *pgxpool.Pool, scanner ScanTrigger) *Server {
return &Server{Logger: logger, Pool: pool, Scanner: scanner}
func New(logger *slog.Logger, pool *pgxpool.Pool, scanner ScanTrigger, subCfg subsonic.Config) *Server {
return &Server{Logger: logger, Pool: pool, Scanner: scanner, SubsonicCfg: subCfg}
}
func (s *Server) Router() http.Handler {
@@ -46,6 +48,7 @@ func (s *Server) Router() http.Handler {
}
})
})
subsonic.Mount(r, s.Pool, s.Logger, s.SubsonicCfg)
}
return r
}
+3 -1
View File
@@ -7,10 +7,12 @@ import (
"net/http"
"net/http/httptest"
"testing"
"git.fabledsword.com/bvandeusen/minstrel/internal/subsonic"
)
func TestHealthz(t *testing.T) {
s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil)
s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{})
ts := httptest.NewServer(s.Router())
defer ts.Close()
+123
View File
@@ -0,0 +1,123 @@
package subsonic
import (
"context"
"crypto/md5"
"crypto/subtle"
"encoding/hex"
"errors"
"net/http"
"strings"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
// Config controls wire-format concessions the server makes for Subsonic
// clients. At-rest policy (which users have a subsonic_password set) is
// driven per-user; this struct only covers the server-wide opt-ins.
type Config struct {
AllowPlaintextPassword bool
}
type ctxKey int
const userCtxKey ctxKey = 1
// UserFromContext returns the user attached by Middleware.
func UserFromContext(ctx context.Context) (dbq.User, bool) {
u, ok := ctx.Value(userCtxKey).(dbq.User)
return u, ok
}
// Middleware authenticates the request against users.api_token (apiKey) or
// users.subsonic_password (t/s or p). On failure it writes a Subsonic failed
// envelope using the request's f= format; downstream handlers never see an
// unauthenticated request.
func Middleware(pool *pgxpool.Pool, cfg Config) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user, code, msg := authenticate(r, pool, cfg)
if code != 0 {
WriteFail(w, r, code, msg)
return
}
ctx := context.WithValue(r.Context(), userCtxKey, user)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
func authenticate(r *http.Request, pool *pgxpool.Pool, cfg Config) (dbq.User, int, string) {
q := dbq.New(pool)
params := r.URL.Query()
if apiKey := params.Get("apiKey"); apiKey != "" {
user, err := q.GetUserByAPIToken(r.Context(), apiKey)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return dbq.User{}, ErrWrongCredentials, "Invalid apiKey"
}
return dbq.User{}, ErrGeneric, "Auth lookup failed"
}
return user, 0, ""
}
username := params.Get("u")
if username == "" {
return dbq.User{}, ErrMissingParameter, "Missing required parameter: u or apiKey"
}
user, err := q.GetUserByUsername(r.Context(), username)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return dbq.User{}, ErrWrongCredentials, "Wrong username or password"
}
return dbq.User{}, ErrGeneric, "Auth lookup failed"
}
if token, salt := params.Get("t"), params.Get("s"); token != "" && salt != "" {
if user.SubsonicPassword == nil {
return dbq.User{}, ErrTokenNotSupported, "Subsonic token auth not configured; use apiKey or set a Subsonic password"
}
sum := md5.Sum([]byte(*user.SubsonicPassword + salt))
expected := hex.EncodeToString(sum[:])
if subtle.ConstantTimeCompare([]byte(expected), []byte(token)) != 1 {
return dbq.User{}, ErrWrongCredentials, "Wrong username or password"
}
return user, 0, ""
}
if password := params.Get("p"); password != "" {
if !cfg.AllowPlaintextPassword {
return dbq.User{}, ErrTokenNotSupported, "Plaintext password auth is disabled; use apiKey or t+s"
}
if user.SubsonicPassword == nil {
return dbq.User{}, ErrTokenNotSupported, "Subsonic password not configured for this user"
}
decoded, err := decodePassword(password)
if err != nil {
return dbq.User{}, ErrWrongCredentials, "Malformed password"
}
if subtle.ConstantTimeCompare([]byte(*user.SubsonicPassword), []byte(decoded)) != 1 {
return dbq.User{}, ErrWrongCredentials, "Wrong username or password"
}
return user, 0, ""
}
return dbq.User{}, ErrMissingParameter, "Missing required parameter: p, t+s, or apiKey"
}
// decodePassword unwraps Subsonic's enc:HEX obfuscation. Non-enc values pass
// through unchanged so the caller can treat the result as the literal secret.
func decodePassword(p string) (string, error) {
if !strings.HasPrefix(p, "enc:") {
return p, nil
}
b, err := hex.DecodeString(p[4:])
if err != nil {
return "", err
}
return string(b), nil
}
+44
View File
@@ -0,0 +1,44 @@
package subsonic
import (
"crypto/md5"
"encoding/hex"
"testing"
)
func TestDecodePasswordEnc(t *testing.T) {
out, err := decodePassword("enc:68656c6c6f")
if err != nil {
t.Fatalf("decode: %v", err)
}
if out != "hello" {
t.Errorf("decode = %q, want hello", out)
}
}
func TestDecodePasswordPlain(t *testing.T) {
out, err := decodePassword("hunter2")
if err != nil || out != "hunter2" {
t.Errorf("plain passthrough = %q, %v", out, err)
}
}
func TestDecodePasswordBadHex(t *testing.T) {
if _, err := decodePassword("enc:zzzz"); err == nil {
t.Error("expected error for malformed enc: prefix")
}
}
// TestTokenMD5Derivation documents the t = md5(password + salt) construction
// so regressions in the auth path surface as a failing checksum rather than a
// 40/wrong-credentials response that could be mistaken for bad inputs.
func TestTokenMD5Derivation(t *testing.T) {
password := "hunter2"
salt := "NaClz"
sum := md5.Sum([]byte(password + salt))
want := hex.EncodeToString(sum[:])
const expected = "91b894c3c9af9c16bec1b3f771e31462"
if want != expected {
t.Errorf("md5(password+salt) = %s, want %s", want, expected)
}
}
+98
View File
@@ -0,0 +1,98 @@
package subsonic
import (
"encoding/json"
"encoding/xml"
"fmt"
"io"
"net/http"
"strings"
)
// APIVersion is the Subsonic REST version we advertise. 1.16.1 is the last
// "classic" level; OpenSubsonic extensions are surfaced via the openSubsonic
// flag and the type/serverVersion fields.
const (
APIVersion = "1.16.1"
ServerType = "minstrel"
XMLNS = "http://subsonic.org/restapi"
)
// ServerVersion identifies this build. Overwritten by cmd/minstrel at startup.
var ServerVersion = "dev"
// Envelope holds the attributes present on every Subsonic response. Concrete
// response types embed it and add endpoint-specific fields with json+xml tags.
type Envelope struct {
XMLName xml.Name `json:"-" xml:"subsonic-response"`
XMLNS string `json:"-" xml:"xmlns,attr"`
Status string `json:"status" xml:"status,attr"`
Version string `json:"version" xml:"version,attr"`
Type string `json:"type" xml:"type,attr"`
ServerVersion string `json:"serverVersion" xml:"serverVersion,attr"`
OpenSubsonic bool `json:"openSubsonic" xml:"openSubsonic,attr"`
Error *Error `json:"error,omitempty" xml:"error,omitempty"`
}
// Error is the body of a failed response.
type Error struct {
XMLName xml.Name `json:"-" xml:"error"`
Code int `json:"code" xml:"code,attr"`
Message string `json:"message" xml:"message,attr"`
}
// NewEnvelope returns a populated header for the given status ("ok"|"failed").
func NewEnvelope(status string) Envelope {
return Envelope{
XMLNS: XMLNS,
Status: status,
Version: APIVersion,
Type: ServerType,
ServerVersion: ServerVersion,
OpenSubsonic: true,
}
}
// Write renders body in the format requested by the ?f= query parameter.
// body must embed Envelope. Unknown formats fall back to JSON.
func Write(w http.ResponseWriter, r *http.Request, body any) {
switch strings.ToLower(r.URL.Query().Get("f")) {
case "xml":
writeXML(w, body)
case "jsonp":
writeJSONP(w, body, r.URL.Query().Get("callback"))
default:
writeJSON(w, body)
}
}
// WriteFail emits an envelope-only failed response with the given error code.
func WriteFail(w http.ResponseWriter, r *http.Request, code int, message string) {
env := NewEnvelope("failed")
env.Error = &Error{Code: code, Message: message}
Write(w, r, env)
}
func writeJSON(w http.ResponseWriter, body any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_ = json.NewEncoder(w).Encode(map[string]any{"subsonic-response": body})
}
func writeJSONP(w http.ResponseWriter, body any, callback string) {
if callback == "" {
// Per spec the client must supply callback; fall back to plain JSON
// rather than producing invalid JavaScript.
writeJSON(w, body)
return
}
w.Header().Set("Content-Type", "application/javascript; charset=utf-8")
_, _ = fmt.Fprintf(w, "%s(", callback)
_ = json.NewEncoder(w).Encode(map[string]any{"subsonic-response": body})
_, _ = io.WriteString(w, ")")
}
func writeXML(w http.ResponseWriter, body any) {
w.Header().Set("Content-Type", "application/xml; charset=utf-8")
_, _ = io.WriteString(w, xml.Header)
_ = xml.NewEncoder(w).Encode(body)
}
+101
View File
@@ -0,0 +1,101 @@
package subsonic
import (
"encoding/json"
"encoding/xml"
"io"
"net/http/httptest"
"strings"
"testing"
)
func TestWriteJSONEnvelope(t *testing.T) {
rec := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/rest/ping.view", nil)
Write(rec, req, PingResponse{Envelope: NewEnvelope("ok")})
if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "application/json") {
t.Errorf("content-type = %q", ct)
}
var payload map[string]map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
t.Fatalf("unmarshal: %v — body=%s", err, rec.Body)
}
inner, ok := payload["subsonic-response"]
if !ok {
t.Fatalf("missing subsonic-response key: %s", rec.Body)
}
if inner["status"] != "ok" || inner["version"] != APIVersion || inner["type"] != ServerType {
t.Errorf("envelope fields = %+v", inner)
}
if inner["openSubsonic"] != true {
t.Errorf("openSubsonic flag missing: %+v", inner)
}
}
func TestWriteXMLEnvelope(t *testing.T) {
rec := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/rest/getLicense.view?f=xml", nil)
Write(rec, req, LicenseResponse{
Envelope: NewEnvelope("ok"),
License: License{Valid: true},
})
if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "application/xml") {
t.Errorf("content-type = %q", ct)
}
body, _ := io.ReadAll(rec.Body)
var decoded struct {
XMLName xml.Name `xml:"subsonic-response"`
Status string `xml:"status,attr"`
Version string `xml:"version,attr"`
License struct {
Valid bool `xml:"valid,attr"`
} `xml:"license"`
}
if err := xml.Unmarshal(body, &decoded); err != nil {
t.Fatalf("xml decode: %v — body=%s", err, body)
}
if decoded.Status != "ok" || decoded.Version != APIVersion {
t.Errorf("envelope attrs = %+v", decoded)
}
if !decoded.License.Valid {
t.Errorf("license.valid attr not parsed: %+v", decoded)
}
}
func TestWriteFailEnvelopeJSON(t *testing.T) {
rec := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/rest/ping.view", nil)
WriteFail(rec, req, ErrWrongCredentials, "nope")
var payload map[string]map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
t.Fatalf("unmarshal: %v", err)
}
inner := payload["subsonic-response"]
if inner["status"] != "failed" {
t.Errorf("status = %v, want failed", inner["status"])
}
errMap, ok := inner["error"].(map[string]any)
if !ok {
t.Fatalf("error field missing/not object: %+v", inner)
}
if int(errMap["code"].(float64)) != ErrWrongCredentials {
t.Errorf("error.code = %v, want %d", errMap["code"], ErrWrongCredentials)
}
if errMap["message"] != "nope" {
t.Errorf("error.message = %v", errMap["message"])
}
}
func TestWriteJSONPWrapsCallback(t *testing.T) {
rec := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/rest/ping.view?f=jsonp&callback=cb", nil)
Write(rec, req, PingResponse{Envelope: NewEnvelope("ok")})
body := rec.Body.String()
if !strings.HasPrefix(body, "cb(") || !strings.HasSuffix(strings.TrimSpace(body), ")") {
t.Errorf("jsonp wrapper missing: %q", body)
}
}
+17
View File
@@ -0,0 +1,17 @@
package subsonic
// Subsonic REST error codes per http://www.subsonic.org/pages/api.jsp §3.
// ErrTokenNotSupported (41) is reused for "this user has not enabled the
// requested auth mode" — the historical meaning ("LDAP users can't use
// token auth") is the closest analogue.
const (
ErrGeneric = 0
ErrMissingParameter = 10
ErrClientTooOld = 20
ErrServerTooOld = 30
ErrWrongCredentials = 40
ErrTokenNotSupported = 41
ErrNotAuthorized = 50
ErrTrialExpired = 60
ErrDataNotFound = 70
)
+34
View File
@@ -0,0 +1,34 @@
package subsonic
import (
"encoding/xml"
"net/http"
)
// PingResponse is envelope-only; clients treat any ok response as success.
type PingResponse struct {
Envelope
}
func handlePing(w http.ResponseWriter, r *http.Request) {
Write(w, r, PingResponse{Envelope: NewEnvelope("ok")})
}
// LicenseResponse reports the (always-valid) self-hosted license. Many
// Subsonic clients refuse to stream until this endpoint returns valid=true.
type LicenseResponse struct {
Envelope
License License `json:"license" xml:"license"`
}
type License struct {
XMLName xml.Name `json:"-" xml:"license"`
Valid bool `json:"valid" xml:"valid,attr"`
}
func handleGetLicense(w http.ResponseWriter, r *http.Request) {
Write(w, r, LicenseResponse{
Envelope: NewEnvelope("ok"),
License: License{Valid: true},
})
}
+34
View File
@@ -0,0 +1,34 @@
// Package subsonic implements a Subsonic-compatible REST API surface under
// /rest. It exists for third-party Subsonic clients (Symfonium, Substreamer,
// DSub, Tempo, play:Sub, …). Minstrel's own clients use the native /api/*
// surface, which has modern auth; /rest/* retains Subsonic's legacy auth for
// compatibility only. See docs/auth.md for the security posture.
package subsonic
import (
"log/slog"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// Mount attaches Subsonic handlers at /rest on r. Endpoints are exposed at
// both /rest/foo and /rest/foo.view because client conventions vary. Both
// GET and POST are accepted; Subsonic's auth params live in the query string
// either way.
func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, cfg Config) {
r.Route("/rest", func(sub chi.Router) {
sub.Use(Middleware(pool, cfg))
register(sub, "/ping", handlePing)
register(sub, "/getLicense", handleGetLicense)
_ = logger
})
}
func register(r chi.Router, path string, h func(http.ResponseWriter, *http.Request)) {
r.Get(path, h)
r.Post(path, h)
r.Get(path+".view", h)
r.Post(path+".view", h)
}