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:
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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},
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user