Phase 2: Subsonic REST API framework (auth, response formatting, error handling, routing) plus user permission model

This commit is contained in:
2026-08-22 23:58:54 -05:00
parent f81c8704a5
commit b5e2bf01e2
26 changed files with 1772 additions and 75 deletions
+128
View File
@@ -0,0 +1,128 @@
package subsonic
import (
"crypto/md5"
"database/sql"
"encoding/hex"
"strconv"
"strings"
vsauth "git.noroutes.net/NoRoutes/vibetunes/internal/auth"
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
)
// authenticate implements original Subsonic authentication: either
// token/salt (t = md5(password + salt), the modern recommended mode) or
// password (p=<plaintext> or p=enc:<hex>, kept for older clients).
//
// This never touches VibeSonic's Argon2id-hashed password directly for
// token auth — that hash is one-way by design. Token auth is instead
// verified against a separately maintained, AES-GCM-encrypted copy of
// the password (see internal/auth/subsonic_secret.go); the /rest/*
// surface never reintroduces MD5 or plaintext as VibeSonic's own storage
// format, it only speaks it on the wire where the protocol requires it.
func authenticate(db *sql.DB, encryptionKey []byte, p params) (*database.User, *apiError) {
username, aerr := p.Required("u")
if aerr != nil {
return nil, aerr
}
user, err := database.GetUserByUsername(db, username)
if err != nil || user.Disabled {
return nil, errWrongCredentials
}
token := p.Get("t")
salt := p.Get("s")
password := p.Get("p")
switch {
case token != "" && salt != "":
if !user.SubsonicAuthSecret.Valid {
return nil, errTokenAuthNotSupported
}
plaintext, derr := vsauth.DecryptPassword(encryptionKey, user.SubsonicAuthSecret.String)
if derr != nil {
return nil, errWrongCredentials
}
sum := md5.Sum([]byte(plaintext + salt))
expected := hex.EncodeToString(sum[:])
if !strings.EqualFold(expected, token) {
return nil, errWrongCredentials
}
case password != "":
plaintext := password
if strings.HasPrefix(password, "enc:") {
decoded, herr := hex.DecodeString(strings.TrimPrefix(password, "enc:"))
if herr != nil {
return nil, errWrongCredentials
}
plaintext = string(decoded)
}
if !user.PasswordHash.Valid {
return nil, errWrongCredentials
}
ok, verr := vsauth.VerifyPassword(plaintext, user.PasswordHash.String)
if verr != nil || !ok {
return nil, errWrongCredentials
}
default:
return nil, errMissingParameter("p or t/s")
}
return user, nil
}
// checkVersion rejects clients whose declared protocol version is newer
// than what this server implements. A missing or unparsable v is
// tolerated (treated as compatible) rather than rejected outright, to
// stay lenient with tooling that omits it — the protocol is additive, so
// there is no reason to reject an old client either.
func checkVersion(v string) *apiError {
if v == "" {
return nil
}
client, ok := parseVersion(v)
if !ok {
return nil
}
server, _ := parseVersion(ServerVersion)
if versionGreater(client, server) {
return errClientMustUpgrade
}
return nil
}
type semver struct{ major, minor, patch int }
func parseVersion(v string) (semver, bool) {
parts := strings.SplitN(v, ".", 3)
if len(parts) == 0 {
return semver{}, false
}
var s semver
var err error
s.major, err = strconv.Atoi(parts[0])
if err != nil {
return semver{}, false
}
if len(parts) > 1 {
s.minor, _ = strconv.Atoi(parts[1])
}
if len(parts) > 2 {
s.patch, _ = strconv.Atoi(parts[2])
}
return s, true
}
func versionGreater(a, b semver) bool {
if a.major != b.major {
return a.major > b.major
}
if a.minor != b.minor {
return a.minor > b.minor
}
return a.patch > b.patch
}
+48
View File
@@ -0,0 +1,48 @@
package subsonic
// Error codes from the Subsonic REST API specification.
const (
ErrGeneric = 0
ErrMissingParameter = 10
ErrClientMustUpgrade = 20 // client's protocol version is newer than this server supports
ErrServerMustUpgrade = 30 // server requires a newer client
ErrWrongCredentials = 40
ErrTokenAuthNotSupported = 41 // e.g. LDAP-backed accounts with no local password
ErrUnauthorized = 50 // authenticated, but not permitted to perform this action
ErrTrialExpired = 60 // VibeSonic never returns this: no license is required
ErrNotFound = 70
)
// apiError is a Subsonic protocol error carrying an HTTP-agnostic code
// and message. Handlers return one instead of writing to the response
// directly, so the router can format it consistently in whatever
// wire format the caller asked for and it never leaks a raw Go error
// (which could expose internal paths/state) to the client.
type apiError struct {
Code int
Message string
}
func (e *apiError) Error() string { return e.Message }
func newError(code int, message string) *apiError {
return &apiError{Code: code, Message: message}
}
func errMissingParameter(name string) *apiError {
return newError(ErrMissingParameter, "Required parameter '"+name+"' is missing")
}
func errNotFound(what string) *apiError {
return newError(ErrNotFound, what+" not found")
}
func errUnauthorized(action string) *apiError {
return newError(ErrUnauthorized, "User is not authorized for the requested "+action)
}
var (
errWrongCredentials = newError(ErrWrongCredentials, "Wrong username or password")
errClientMustUpgrade = newError(ErrClientMustUpgrade, "Incompatible Subsonic REST protocol version. Client must upgrade.")
errTokenAuthNotSupported = newError(ErrTokenAuthNotSupported, "Token authentication is not supported for this account; use password authentication")
)
+114
View File
@@ -0,0 +1,114 @@
// Package subsonic implements the original Subsonic REST API (through
// version 1.16.1) on top of VibeSonic's existing database, scanner,
// streaming, and cover art packages. It does not duplicate business
// logic: every handler here is a thin adapter from Subsonic's wire
// format onto internal/database, internal/library, internal/streaming,
// and internal/coverart.
//
// One set of Go structs (this file and its model_*.go siblings) is
// shared between XML and JSON output via struct tags, so response
// shape can never drift between formats — see response.go for how they
// are serialized.
package subsonic
import "encoding/xml"
// ServerVersion is the highest Subsonic REST API version VibeSonic
// implements. Requests from a client claiming a newer protocol version
// are rejected (error code 20); older clients are accepted, since the
// protocol is additive.
const ServerVersion = "1.16.1"
// Response is the envelope every /rest/* call returns: exactly one of
// the payload fields below is set, matching the single child element
// real Subsonic servers emit inside <subsonic-response>.
type Response struct {
XMLName xml.Name `xml:"subsonic-response" json:"-"`
Xmlns string `xml:"xmlns,attr" json:"-"`
Status string `xml:"status,attr" json:"status"`
Version string `xml:"version,attr" json:"version"`
Error *Error `xml:"error,omitempty" json:"error,omitempty"`
License *License `xml:"license,omitempty" json:"license,omitempty"`
MusicFolders *MusicFolders `xml:"musicFolders,omitempty" json:"musicFolders,omitempty"`
Indexes *Indexes `xml:"indexes,omitempty" json:"indexes,omitempty"`
Directory *Directory `xml:"directory,omitempty" json:"directory,omitempty"`
Genres *Genres `xml:"genres,omitempty" json:"genres,omitempty"`
Artists *ArtistsID3 `xml:"artists,omitempty" json:"artists,omitempty"`
Artist *ArtistWithAlbumsID3 `xml:"artist,omitempty" json:"artist,omitempty"`
Album *AlbumWithSongsID3 `xml:"album,omitempty" json:"album,omitempty"`
Song *Child `xml:"song,omitempty" json:"song,omitempty"`
Videos *Videos `xml:"videos,omitempty" json:"videos,omitempty"`
VideoInfo *VideoInfo `xml:"videoInfo,omitempty" json:"videoInfo,omitempty"`
ArtistInfo *ArtistInfo `xml:"artistInfo,omitempty" json:"artistInfo,omitempty"`
ArtistInfo2 *ArtistInfo2 `xml:"artistInfo2,omitempty" json:"artistInfo2,omitempty"`
AlbumInfo *AlbumInfo `xml:"albumInfo,omitempty" json:"albumInfo,omitempty"`
SimilarSongs *SimilarSongs `xml:"similarSongs,omitempty" json:"similarSongs,omitempty"`
SimilarSongs2 *SimilarSongs2 `xml:"similarSongs2,omitempty" json:"similarSongs2,omitempty"`
TopSongs *TopSongs `xml:"topSongs,omitempty" json:"topSongs,omitempty"`
AlbumList *AlbumList `xml:"albumList,omitempty" json:"albumList,omitempty"`
AlbumList2 *AlbumList2 `xml:"albumList2,omitempty" json:"albumList2,omitempty"`
RandomSongs *Songs `xml:"randomSongs,omitempty" json:"randomSongs,omitempty"`
SongsByGenre *Songs `xml:"songsByGenre,omitempty" json:"songsByGenre,omitempty"`
NowPlaying *NowPlaying `xml:"nowPlaying,omitempty" json:"nowPlaying,omitempty"`
Starred *Starred `xml:"starred,omitempty" json:"starred,omitempty"`
Starred2 *Starred2 `xml:"starred2,omitempty" json:"starred2,omitempty"`
SearchResult *SearchResult `xml:"searchResult,omitempty" json:"searchResult,omitempty"`
SearchResult2 *SearchResult2 `xml:"searchResult2,omitempty" json:"searchResult2,omitempty"`
SearchResult3 *SearchResult3 `xml:"searchResult3,omitempty" json:"searchResult3,omitempty"`
Playlists *Playlists `xml:"playlists,omitempty" json:"playlists,omitempty"`
Playlist *PlaylistWithSongs `xml:"playlist,omitempty" json:"playlist,omitempty"`
JukeboxStatus *JukeboxStatus `xml:"jukeboxStatus,omitempty" json:"jukeboxStatus,omitempty"`
JukeboxPlaylist *JukeboxPlaylist `xml:"jukeboxPlaylist,omitempty" json:"jukeboxPlaylist,omitempty"`
InternetRadioStations *InternetRadioStations `xml:"internetRadioStations,omitempty" json:"internetRadioStations,omitempty"`
ChatMessages *ChatMessages `xml:"chatMessages,omitempty" json:"chatMessages,omitempty"`
User *User `xml:"user,omitempty" json:"user,omitempty"`
Users *Users `xml:"users,omitempty" json:"users,omitempty"`
Bookmarks *Bookmarks `xml:"bookmarks,omitempty" json:"bookmarks,omitempty"`
PlayQueue *PlayQueue `xml:"playQueue,omitempty" json:"playQueue,omitempty"`
Shares *Shares `xml:"shares,omitempty" json:"shares,omitempty"`
Podcasts *Podcasts `xml:"podcasts,omitempty" json:"podcasts,omitempty"`
NewestPodcasts *NewestPodcasts `xml:"newestPodcasts,omitempty" json:"newestPodcasts,omitempty"`
ScanStatus *ScanStatus `xml:"scanStatus,omitempty" json:"scanStatus,omitempty"`
Lyrics *Lyrics `xml:"lyrics,omitempty" json:"lyrics,omitempty"`
}
func newResponse() *Response {
return &Response{Xmlns: "http://subsonic.org/restapi", Status: "ok", Version: ServerVersion}
}
// Error is the payload of a failed response.
type Error struct {
Code int `xml:"code,attr" json:"code"`
Message string `xml:"message,attr" json:"message"`
}
// --- System ---
type License struct {
Valid bool `xml:"valid,attr" json:"valid"`
Email string `xml:"email,attr,omitempty" json:"email,omitempty"`
LicenseExpires string `xml:"licenseExpires,attr,omitempty" json:"licenseExpires,omitempty"`
}
// --- Music folders / genres ---
type MusicFolders struct {
MusicFolder []MusicFolder `xml:"musicFolder" json:"musicFolder"`
}
type MusicFolder struct {
ID string `xml:"id,attr" json:"id"`
Name string `xml:"name,attr" json:"name"`
}
type Genres struct {
Genre []Genre `xml:"genre" json:"genre"`
}
type Genre struct {
Name string `xml:",chardata" json:"value"`
SongCount int `xml:"songCount,attr" json:"songCount"`
AlbumCount int `xml:"albumCount,attr" json:"albumCount"`
}
+188
View File
@@ -0,0 +1,188 @@
package subsonic
// --- Filesystem-style browsing: getIndexes / getMusicDirectory ---
type Indexes struct {
LastModified int64 `xml:"lastModified,attr" json:"lastModified"`
IgnoredArticles string `xml:"ignoredArticles,attr" json:"ignoredArticles"`
Shortcut []Artist `xml:"shortcut,omitempty" json:"shortcut,omitempty"`
Index []Index `xml:"index" json:"index"`
Child []Child `xml:"child,omitempty" json:"child,omitempty"` // top-level files not under any letter index
}
type Index struct {
Name string `xml:"name,attr" json:"name"`
Artist []Artist `xml:"artist" json:"artist"`
}
// Artist is the filesystem-style artist entry (a "directory" one level
// below a music folder root), distinct from the ID3 ArtistID3 below.
type Artist struct {
ID string `xml:"id,attr" json:"id"`
Name string `xml:"name,attr" json:"name"`
}
type Directory struct {
ID string `xml:"id,attr" json:"id"`
Parent string `xml:"parent,attr,omitempty" json:"parent,omitempty"`
Name string `xml:"name,attr" json:"name"`
starred // embedded for the optional "starred" attr, see model.go helper types
Child []Child `xml:"child" json:"child"`
}
// Child represents both a directory entry and a song/video — the single
// polymorphic element type Subsonic uses throughout getMusicDirectory,
// search results, playlists, and album contents.
type Child struct {
ID string `xml:"id,attr" json:"id"`
Parent string `xml:"parent,attr,omitempty" json:"parent,omitempty"`
IsDir bool `xml:"isDir,attr" json:"isDir"`
Title string `xml:"title,attr" json:"title"`
Album string `xml:"album,attr,omitempty" json:"album,omitempty"`
Artist string `xml:"artist,attr,omitempty" json:"artist,omitempty"`
Track int `xml:"track,attr,omitempty" json:"track,omitempty"`
Year int `xml:"year,attr,omitempty" json:"year,omitempty"`
Genre string `xml:"genre,attr,omitempty" json:"genre,omitempty"`
CoverArt string `xml:"coverArt,attr,omitempty" json:"coverArt,omitempty"`
Size int64 `xml:"size,attr,omitempty" json:"size,omitempty"`
ContentType string `xml:"contentType,attr,omitempty" json:"contentType,omitempty"`
Suffix string `xml:"suffix,attr,omitempty" json:"suffix,omitempty"`
TranscodedContentType string `xml:"transcodedContentType,attr,omitempty" json:"transcodedContentType,omitempty"`
TranscodedSuffix string `xml:"transcodedSuffix,attr,omitempty" json:"transcodedSuffix,omitempty"`
Duration int `xml:"duration,attr,omitempty" json:"duration,omitempty"`
BitRate int `xml:"bitRate,attr,omitempty" json:"bitRate,omitempty"`
Path string `xml:"path,attr,omitempty" json:"path,omitempty"`
IsVideo bool `xml:"isVideo,attr,omitempty" json:"isVideo,omitempty"`
UserRating int `xml:"userRating,attr,omitempty" json:"userRating,omitempty"`
AverageRating float64 `xml:"averageRating,attr,omitempty" json:"averageRating,omitempty"`
PlayCount int64 `xml:"playCount,attr,omitempty" json:"playCount,omitempty"`
DiscNumber int `xml:"discNumber,attr,omitempty" json:"discNumber,omitempty"`
Created string `xml:"created,attr,omitempty" json:"created,omitempty"`
Starred string `xml:"starred,attr,omitempty" json:"starred,omitempty"`
AlbumID string `xml:"albumId,attr,omitempty" json:"albumId,omitempty"`
ArtistID string `xml:"artistId,attr,omitempty" json:"artistId,omitempty"`
Type string `xml:"type,attr,omitempty" json:"type,omitempty"`
BookmarkPosition int64 `xml:"bookmarkPosition,attr,omitempty" json:"bookmarkPosition,omitempty"`
}
type Songs struct {
Song []Child `xml:"song" json:"song"`
}
// starred is embedded into structs that carry an optional "starred"
// timestamp attribute (Directory, AlbumID3, ArtistID3, ...).
type starred struct {
Starred string `xml:"starred,attr,omitempty" json:"starred,omitempty"`
}
// --- ID3/tag-style browsing: getArtists / getArtist / getAlbum / getSong ---
type ArtistsID3 struct {
IgnoredArticles string `xml:"ignoredArticles,attr" json:"ignoredArticles"`
Index []IndexID3 `xml:"index" json:"index"`
}
type IndexID3 struct {
Name string `xml:"name,attr" json:"name"`
Artist []ArtistID3 `xml:"artist" json:"artist"`
}
type ArtistID3 struct {
ID string `xml:"id,attr" json:"id"`
Name string `xml:"name,attr" json:"name"`
CoverArt string `xml:"coverArt,attr,omitempty" json:"coverArt,omitempty"`
AlbumCount int `xml:"albumCount,attr" json:"albumCount"`
starred
}
type ArtistWithAlbumsID3 struct {
ArtistID3
Album []AlbumID3 `xml:"album,omitempty" json:"album,omitempty"`
}
type AlbumID3 struct {
ID string `xml:"id,attr" json:"id"`
Name string `xml:"name,attr" json:"name"`
Artist string `xml:"artist,attr,omitempty" json:"artist,omitempty"`
ArtistID string `xml:"artistId,attr,omitempty" json:"artistId,omitempty"`
CoverArt string `xml:"coverArt,attr,omitempty" json:"coverArt,omitempty"`
SongCount int `xml:"songCount,attr" json:"songCount"`
Duration int `xml:"duration,attr" json:"duration"`
PlayCount int64 `xml:"playCount,attr,omitempty" json:"playCount,omitempty"`
Created string `xml:"created,attr" json:"created"`
starred
Year int `xml:"year,attr,omitempty" json:"year,omitempty"`
Genre string `xml:"genre,attr,omitempty" json:"genre,omitempty"`
}
type AlbumWithSongsID3 struct {
AlbumID3
Song []Child `xml:"song,omitempty" json:"song,omitempty"`
}
// --- Artist/album info (last.fm-style metadata; VibeSonic has no
// external metadata provider in Phase 2, so these return locally-known
// data only: biography/images are empty, similar artists are omitted) ---
type ArtistInfoBase struct {
Biography string `xml:"biography,omitempty" json:"biography,omitempty"`
MusicBrainzID string `xml:"musicBrainzId,omitempty" json:"musicBrainzId,omitempty"`
LastFmURL string `xml:"lastFmUrl,omitempty" json:"lastFmUrl,omitempty"`
SmallImageURL string `xml:"smallImageUrl,omitempty" json:"smallImageUrl,omitempty"`
MediumImageURL string `xml:"mediumImageUrl,omitempty" json:"mediumImageUrl,omitempty"`
LargeImageURL string `xml:"largeImageUrl,omitempty" json:"largeImageUrl,omitempty"`
}
type ArtistInfo struct {
ArtistInfoBase
SimilarArtist []Artist `xml:"similarArtist,omitempty" json:"similarArtist,omitempty"`
}
type ArtistInfo2 struct {
ArtistInfoBase
SimilarArtist []ArtistID3 `xml:"similarArtist,omitempty" json:"similarArtist,omitempty"`
}
type AlbumInfo struct {
Notes string `xml:"notes,omitempty" json:"notes,omitempty"`
MusicBrainzID string `xml:"musicBrainzId,omitempty" json:"musicBrainzId,omitempty"`
LastFmURL string `xml:"lastFmUrl,omitempty" json:"lastFmUrl,omitempty"`
SmallImageURL string `xml:"smallImageUrl,omitempty" json:"smallImageUrl,omitempty"`
MediumImageURL string `xml:"mediumImageUrl,omitempty" json:"mediumImageUrl,omitempty"`
LargeImageURL string `xml:"largeImageUrl,omitempty" json:"largeImageUrl,omitempty"`
}
type SimilarSongs struct {
Song []Child `xml:"song,omitempty" json:"song,omitempty"`
}
type SimilarSongs2 struct {
Song []Child `xml:"song,omitempty" json:"song,omitempty"`
}
type TopSongs struct {
Song []Child `xml:"song,omitempty" json:"song,omitempty"`
}
// --- Video ---
type Videos struct {
Video []Child `xml:"video,omitempty" json:"video,omitempty"`
}
type VideoInfo struct {
ID string `xml:"id,attr" json:"id"`
AudioTrack []AudioTrack `xml:"audioTrack,omitempty" json:"audioTrack,omitempty"`
Captions []Captions `xml:"captions,omitempty" json:"captions,omitempty"`
}
type AudioTrack struct {
ID string `xml:"id,attr" json:"id"`
Name string `xml:"name,attr,omitempty" json:"name,omitempty"`
LanguageCode string `xml:"languageCode,attr,omitempty" json:"languageCode,omitempty"`
}
type Captions struct {
ID string `xml:"id,attr" json:"id"`
Name string `xml:"name,attr,omitempty" json:"name,omitempty"`
}
+59
View File
@@ -0,0 +1,59 @@
package subsonic
// --- Album lists ---
type AlbumList struct {
Album []Child `xml:"album,omitempty" json:"album,omitempty"`
}
type AlbumList2 struct {
Album []AlbumID3 `xml:"album,omitempty" json:"album,omitempty"`
}
// --- Now playing ---
type NowPlaying struct {
Entry []NowPlayingEntry `xml:"entry,omitempty" json:"entry,omitempty"`
}
type NowPlayingEntry struct {
Child
Username string `xml:"username,attr" json:"username"`
MinutesAgo int `xml:"minutesAgo,attr" json:"minutesAgo"`
PlayerID int `xml:"playerId,attr" json:"playerId"`
PlayerName string `xml:"playerName,attr,omitempty" json:"playerName,omitempty"`
}
// --- Starred ---
type Starred struct {
Artist []Artist `xml:"artist,omitempty" json:"artist,omitempty"`
Album []Child `xml:"album,omitempty" json:"album,omitempty"`
Song []Child `xml:"song,omitempty" json:"song,omitempty"`
}
type Starred2 struct {
Artist []ArtistID3 `xml:"artist,omitempty" json:"artist,omitempty"`
Album []AlbumID3 `xml:"album,omitempty" json:"album,omitempty"`
Song []Child `xml:"song,omitempty" json:"song,omitempty"`
}
// --- Search ---
type SearchResult struct {
Offset int `xml:"offset,attr" json:"offset"`
TotalHits int `xml:"totalHits,attr" json:"totalHits"`
Match []Child `xml:"match,omitempty" json:"match,omitempty"`
}
type SearchResult2 struct {
Artist []Artist `xml:"artist,omitempty" json:"artist,omitempty"`
Album []Child `xml:"album,omitempty" json:"album,omitempty"`
Song []Child `xml:"song,omitempty" json:"song,omitempty"`
}
type SearchResult3 struct {
Artist []ArtistID3 `xml:"artist,omitempty" json:"artist,omitempty"`
Album []AlbumID3 `xml:"album,omitempty" json:"album,omitempty"`
Song []Child `xml:"song,omitempty" json:"song,omitempty"`
}
+29
View File
@@ -0,0 +1,29 @@
package subsonic
type Lyrics struct {
Artist string `xml:"artist,attr,omitempty" json:"artist,omitempty"`
Title string `xml:"title,attr,omitempty" json:"title,omitempty"`
Value string `xml:",chardata" json:"value,omitempty"`
}
type Bookmarks struct {
Bookmark []Bookmark `xml:"bookmark,omitempty" json:"bookmark,omitempty"`
}
type Bookmark struct {
Position int64 `xml:"position,attr" json:"position"` // milliseconds
Username string `xml:"username,attr" json:"username"`
Comment string `xml:"comment,attr,omitempty" json:"comment,omitempty"`
Created string `xml:"created,attr" json:"created"`
Changed string `xml:"changed,attr" json:"changed"`
Entry Child `xml:"entry" json:"entry"`
}
type PlayQueue struct {
Current string `xml:"current,attr,omitempty" json:"current,omitempty"`
Position int64 `xml:"position,attr,omitempty" json:"position,omitempty"`
Username string `xml:"username,attr" json:"username"`
Changed string `xml:"changed,attr" json:"changed"`
ChangedBy string `xml:"changedBy,attr" json:"changedBy"`
Entry []Child `xml:"entry,omitempty" json:"entry,omitempty"`
}
+96
View File
@@ -0,0 +1,96 @@
package subsonic
// --- Jukebox ---
type JukeboxStatus struct {
CurrentIndex int `xml:"currentIndex,attr" json:"currentIndex"`
Playing bool `xml:"playing,attr" json:"playing"`
Gain float64 `xml:"gain,attr" json:"gain"`
Position int `xml:"position,attr,omitempty" json:"position,omitempty"`
}
type JukeboxPlaylist struct {
JukeboxStatus
Entry []Child `xml:"entry,omitempty" json:"entry,omitempty"`
}
// --- Internet radio ---
type InternetRadioStations struct {
InternetRadioStation []InternetRadioStation `xml:"internetRadioStation,omitempty" json:"internetRadioStation,omitempty"`
}
type InternetRadioStation struct {
ID string `xml:"id,attr" json:"id"`
Name string `xml:"name,attr" json:"name"`
StreamURL string `xml:"streamUrl,attr" json:"streamUrl"`
HomepageURL string `xml:"homepageUrl,attr,omitempty" json:"homepageUrl,omitempty"`
}
// --- Chat ---
type ChatMessages struct {
ChatMessage []ChatMessage `xml:"chatMessage,omitempty" json:"chatMessage,omitempty"`
}
type ChatMessage struct {
Username string `xml:"username,attr" json:"username"`
Time int64 `xml:"time,attr" json:"time"` // millis since epoch
Message string `xml:"message,attr" json:"message"`
}
// --- Scan status ---
type ScanStatus struct {
Scanning bool `xml:"scanning,attr" json:"scanning"`
Count int64 `xml:"count,attr,omitempty" json:"count,omitempty"`
}
// --- Shares ---
type Shares struct {
Share []Share `xml:"share,omitempty" json:"share,omitempty"`
}
type Share struct {
ID string `xml:"id,attr" json:"id"`
URL string `xml:"url,attr" json:"url"`
Description string `xml:"description,attr,omitempty" json:"description,omitempty"`
Username string `xml:"username,attr" json:"username"`
Created string `xml:"created,attr" json:"created"`
Expires string `xml:"expires,attr,omitempty" json:"expires,omitempty"`
LastVisited string `xml:"lastVisited,attr,omitempty" json:"lastVisited,omitempty"`
VisitCount int64 `xml:"visitCount,attr" json:"visitCount"`
Entry []Child `xml:"entry,omitempty" json:"entry,omitempty"`
}
// --- Podcasts ---
type Podcasts struct {
Channel []PodcastChannel `xml:"channel,omitempty" json:"channel,omitempty"`
}
type NewestPodcasts struct {
Episode []PodcastEpisode `xml:"episode,omitempty" json:"episode,omitempty"`
}
type PodcastChannel struct {
ID string `xml:"id,attr" json:"id"`
URL string `xml:"url,attr" json:"url"`
Title string `xml:"title,attr,omitempty" json:"title,omitempty"`
Description string `xml:"description,attr,omitempty" json:"description,omitempty"`
CoverArt string `xml:"coverArt,attr,omitempty" json:"coverArt,omitempty"`
OriginalImageURL string `xml:"originalImageUrl,attr,omitempty" json:"originalImageUrl,omitempty"`
Status string `xml:"status,attr" json:"status"`
ErrorMessage string `xml:"errorMessage,attr,omitempty" json:"errorMessage,omitempty"`
Episode []PodcastEpisode `xml:"episode,omitempty" json:"episode,omitempty"`
}
type PodcastEpisode struct {
Child
StreamID string `xml:"streamId,attr,omitempty" json:"streamId,omitempty"`
ChannelID string `xml:"channelId,attr" json:"channelId"`
Description string `xml:"description,attr,omitempty" json:"description,omitempty"`
Status string `xml:"status,attr" json:"status"`
PublishDate string `xml:"publishDate,attr,omitempty" json:"publishDate,omitempty"`
}
+23
View File
@@ -0,0 +1,23 @@
package subsonic
type Playlists struct {
Playlist []Playlist `xml:"playlist,omitempty" json:"playlist,omitempty"`
}
type Playlist struct {
ID string `xml:"id,attr" json:"id"`
Name string `xml:"name,attr" json:"name"`
Comment string `xml:"comment,attr,omitempty" json:"comment,omitempty"`
Owner string `xml:"owner,attr,omitempty" json:"owner,omitempty"`
Public bool `xml:"public,attr" json:"public"`
SongCount int `xml:"songCount,attr" json:"songCount"`
Duration int `xml:"duration,attr" json:"duration"`
Created string `xml:"created,attr" json:"created"`
Changed string `xml:"changed,attr" json:"changed"`
CoverArt string `xml:"coverArt,attr,omitempty" json:"coverArt,omitempty"`
}
type PlaylistWithSongs struct {
Playlist
Entry []Child `xml:"entry,omitempty" json:"entry,omitempty"`
}
+25
View File
@@ -0,0 +1,25 @@
package subsonic
type Users struct {
User []User `xml:"user" json:"user"`
}
type User struct {
Username string `xml:"username,attr" json:"username"`
Email string `xml:"email,attr,omitempty" json:"email,omitempty"`
ScrobblingEnabled bool `xml:"scrobblingEnabled,attr" json:"scrobblingEnabled"`
MaxBitRate int `xml:"maxBitRate,attr,omitempty" json:"maxBitRate,omitempty"`
AdminRole bool `xml:"adminRole,attr" json:"adminRole"`
SettingsRole bool `xml:"settingsRole,attr" json:"settingsRole"`
DownloadRole bool `xml:"downloadRole,attr" json:"downloadRole"`
UploadRole bool `xml:"uploadRole,attr" json:"uploadRole"`
PlaylistRole bool `xml:"playlistRole,attr" json:"playlistRole"`
CoverArtRole bool `xml:"coverArtRole,attr" json:"coverArtRole"`
CommentRole bool `xml:"commentRole,attr" json:"commentRole"`
PodcastRole bool `xml:"podcastRole,attr" json:"podcastRole"`
StreamRole bool `xml:"streamRole,attr" json:"streamRole"`
JukeboxRole bool `xml:"jukeboxRole,attr" json:"jukeboxRole"`
ShareRole bool `xml:"shareRole,attr" json:"shareRole"`
VideoConversionRole bool `xml:"videoConversionRole,attr" json:"videoConversionRole"`
Folder []int `xml:"folder,omitempty" json:"folder,omitempty"`
}
+82
View File
@@ -0,0 +1,82 @@
package subsonic
import (
"net/url"
"strconv"
)
// params wraps the request's merged query+form values with typed,
// error-producing accessors, so handlers never hand-roll parameter
// parsing/validation.
type params struct {
values url.Values
}
func (p params) Get(name string) string {
return p.values.Get(name)
}
func (p params) GetAll(name string) []string {
return p.values[name]
}
func (p params) Has(name string) bool {
_, ok := p.values[name]
return ok
}
func (p params) Required(name string) (string, *apiError) {
v := p.values.Get(name)
if v == "" {
return "", errMissingParameter(name)
}
return v, nil
}
func (p params) Int(name string, def int) int {
v := p.values.Get(name)
if v == "" {
return def
}
n, err := strconv.Atoi(v)
if err != nil {
return def
}
return n
}
func (p params) RequiredInt(name string) (int, *apiError) {
v := p.values.Get(name)
if v == "" {
return 0, errMissingParameter(name)
}
n, err := strconv.Atoi(v)
if err != nil {
return 0, errMissingParameter(name)
}
return n, nil
}
func (p params) Int64(name string, def int64) int64 {
v := p.values.Get(name)
if v == "" {
return def
}
n, err := strconv.ParseInt(v, 10, 64)
if err != nil {
return def
}
return n
}
func (p params) Bool(name string, def bool) bool {
v := p.values.Get(name)
if v == "" {
return def
}
b, err := strconv.ParseBool(v)
if err != nil {
return def
}
return b
}
+59
View File
@@ -0,0 +1,59 @@
package subsonic
import (
"encoding/json"
"encoding/xml"
"net/http"
"regexp"
)
// jsonEnvelope is the {"subsonic-response": {...}} wrapper the JSON
// format requires around the same Response struct XML uses directly.
type jsonEnvelope struct {
Response *Response `json:"subsonic-response"`
}
// jsonpCallbackRE matches a safe JavaScript identifier (including dotted
// paths like "foo.bar", which some clients use). Anything else is
// rejected outright rather than interpolated into the response body,
// which would otherwise be a script-injection vector.
var jsonpCallbackRE = regexp.MustCompile(`^[A-Za-z_$][A-Za-z0-9_$]*(\.[A-Za-z_$][A-Za-z0-9_$]*)*$`)
// writeResponse serializes resp in the format requested by the client
// (f=xml, the default; f=json; f=jsonp with a validated callback) and
// writes it with the correct Content-Type. It never returns an error to
// the caller — formatting failures are logged-and-swallowed rather than
// leaking a broken partial body or a Go panic through the wire.
func writeResponse(w http.ResponseWriter, format, callback string, resp *Response) {
switch format {
case "json":
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(jsonEnvelope{Response: resp})
case "jsonp":
w.Header().Set("Content-Type", "application/javascript")
body, err := json.Marshal(jsonEnvelope{Response: resp})
if err != nil {
return
}
if !jsonpCallbackRE.MatchString(callback) {
// Should already be rejected before we get here; fail safe
// by falling back to a fixed, harmless callback name rather
// than ever writing an unvalidated callback into the body.
callback = "callback"
}
_, _ = w.Write([]byte(callback + "("))
_, _ = w.Write(body)
_, _ = w.Write([]byte(")"))
default:
w.Header().Set("Content-Type", "application/xml; charset=utf-8")
_, _ = w.Write([]byte(xml.Header))
_ = xml.NewEncoder(w).Encode(resp)
}
}
func errorResponse(err *apiError) *Response {
r := newResponse()
r.Status = "failed"
r.Error = &Error{Code: err.Code, Message: err.Message}
return r
}
+92
View File
@@ -0,0 +1,92 @@
package subsonic
import (
"net/http"
"strings"
)
type handlerFunc func(rc *Context) *apiError
// registry maps a bare endpoint name (no "/rest/" prefix, no ".view"
// suffix) to its handler. Populated by init()-time registrations in each
// endpoint group's file (system.go, browsing.go, ...) via register().
var registry = map[string]handlerFunc{}
func register(name string, fn handlerFunc) {
if _, exists := registry[name]; exists {
panic("subsonic: duplicate handler registration for " + name)
}
registry[name] = fn
}
// Router returns the /rest/* handler. All authentication, protocol
// version validation, parameter parsing, and response formatting is
// centralized here — individual endpoint handlers never touch any of it
// themselves, and never write directly to the ResponseWriter except the
// small set of binary/streaming endpoints (stream, download, hls,
// getCoverArt, getAvatar, getCaptions) which set rc.Handled.
func (s *Server) Router() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("/rest/", s.dispatch)
return mux
}
func (s *Server) dispatch(w http.ResponseWriter, r *http.Request) {
name := strings.TrimPrefix(r.URL.Path, "/rest/")
name = strings.TrimSuffix(name, ".view")
if name == "" {
http.NotFound(w, r)
return
}
// Subsonic clients may send parameters via query string or (for
// endpoints with large/binary bodies, or just by client preference)
// POST form encoding; ParseForm merges both into r.Form.
if err := r.ParseForm(); err != nil {
writeResponse(w, "xml", "", errorResponse(newError(ErrGeneric, "could not parse request")))
return
}
p := params{values: r.Form}
format := p.Get("f")
if format == "" {
format = "xml"
}
callback := p.Get("callback")
if format == "jsonp" && !jsonpCallbackRE.MatchString(callback) {
// A malformed/malicious callback name must never reach the
// response body; fail the request instead of sanitizing it.
writeResponse(w, "json", "", errorResponse(newError(ErrGeneric, "invalid callback parameter")))
return
}
handler, ok := registry[name]
if !ok {
writeResponse(w, format, callback, errorResponse(newError(ErrNotFound, "Unknown Subsonic API endpoint: "+name)))
return
}
if verr := checkVersion(p.Get("v")); verr != nil {
writeResponse(w, format, callback, errorResponse(verr))
return
}
user, aerr := authenticate(s.DB, s.EncryptionKey, p)
if aerr != nil {
writeResponse(w, format, callback, errorResponse(aerr))
return
}
rc := &Context{W: w, R: r, Server: s, User: user, Params: p, Format: format, Callback: callback}
if herr := handler(rc); herr != nil {
writeResponse(w, format, callback, errorResponse(herr))
return
}
if rc.Handled {
return
}
if rc.Response == nil {
rc.Response = newResponse()
}
writeResponse(w, format, callback, rc.Response)
}
+325
View File
@@ -0,0 +1,325 @@
package subsonic
import (
"crypto/md5"
"encoding/hex"
"encoding/json"
"encoding/xml"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
)
func get(t *testing.T, srv string, q url.Values) *http.Response {
t.Helper()
resp, err := http.Get(srv + "/rest/ping.view?" + q.Encode())
if err != nil {
t.Fatal(err)
}
return resp
}
func getPath(t *testing.T, srv, path string, q url.Values) *http.Response {
t.Helper()
resp, err := http.Get(srv + path + "?" + q.Encode())
if err != nil {
t.Fatal(err)
}
return resp
}
func baseParams(username, password string) url.Values {
return url.Values{
"u": {username},
"p": {password},
"v": {"1.16.1"},
"c": {"vibesonic-test"},
}
}
func tokenParams(username, salt, token string) url.Values {
return url.Values{
"u": {username},
"t": {token},
"s": {salt},
"v": {"1.16.1"},
"c": {"vibesonic-test"},
}
}
type xmlResp struct {
XMLName xml.Name `xml:"subsonic-response"`
Status string `xml:"status,attr"`
Version string `xml:"version,attr"`
Error *struct {
Code int `xml:"code,attr"`
Message string `xml:"message,attr"`
} `xml:"error"`
}
func decodeXML(t *testing.T, resp *http.Response) xmlResp {
t.Helper()
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
var x xmlResp
if err := xml.Unmarshal(body, &x); err != nil {
t.Fatalf("invalid XML response: %v\nbody: %s", err, body)
}
return x
}
func decodeJSON(t *testing.T, resp *http.Response) map[string]any {
t.Helper()
defer resp.Body.Close()
var m map[string]any
if err := json.NewDecoder(resp.Body).Decode(&m); err != nil {
t.Fatalf("invalid JSON response: %v", err)
}
inner, ok := m["subsonic-response"].(map[string]any)
if !ok {
t.Fatalf("response missing subsonic-response envelope: %v", m)
}
return inner
}
func TestPingSuccessXML(t *testing.T) {
s, srv := newTestRecorderServer(t)
createTestUser(t, s, "alice", "password123", false)
resp := get(t, srv.URL, baseParams("alice", "password123"))
x := decodeXML(t, resp)
if x.Status != "ok" {
t.Fatalf("status = %q, want ok", x.Status)
}
if x.Version != ServerVersion {
t.Fatalf("version = %q, want %q", x.Version, ServerVersion)
}
if ct := resp.Header.Get("Content-Type"); !strings.Contains(ct, "xml") {
t.Errorf("Content-Type = %q, want xml", ct)
}
}
func TestPingSuccessJSON(t *testing.T) {
s, srv := newTestRecorderServer(t)
createTestUser(t, s, "alice", "password123", false)
q := baseParams("alice", "password123")
q.Set("f", "json")
resp := get(t, srv.URL, q)
j := decodeJSON(t, resp)
if j["status"] != "ok" {
t.Fatalf("status = %v, want ok", j["status"])
}
if ct := resp.Header.Get("Content-Type"); !strings.Contains(ct, "json") {
t.Errorf("Content-Type = %q, want json", ct)
}
}
func TestPingJSONP(t *testing.T) {
s, srv := newTestRecorderServer(t)
createTestUser(t, s, "alice", "password123", false)
q := baseParams("alice", "password123")
q.Set("f", "jsonp")
q.Set("callback", "myCallback")
resp := get(t, srv.URL, q)
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if !strings.HasPrefix(string(body), "myCallback(") || !strings.HasSuffix(strings.TrimSpace(string(body)), ")") {
t.Fatalf("jsonp body = %s, want wrapped in myCallback(...)", body)
}
}
func TestJSONPRejectsMaliciousCallback(t *testing.T) {
s, srv := newTestRecorderServer(t)
createTestUser(t, s, "alice", "password123", false)
q := baseParams("alice", "password123")
q.Set("f", "jsonp")
q.Set("callback", "alert(1);foo")
resp := get(t, srv.URL, q)
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if strings.Contains(string(body), "alert(1)") {
t.Fatalf("malicious callback was reflected into the response body: %s", body)
}
}
func TestWrongPasswordFails(t *testing.T) {
s, srv := newTestRecorderServer(t)
createTestUser(t, s, "alice", "password123", false)
resp := get(t, srv.URL, baseParams("alice", "wrongpassword"))
x := decodeXML(t, resp)
if x.Status != "failed" || x.Error == nil || x.Error.Code != ErrWrongCredentials {
t.Fatalf("expected error 40, got %+v", x)
}
}
func TestUnknownUserFails(t *testing.T) {
_, srv := newTestRecorderServer(t)
resp := get(t, srv.URL, baseParams("nosuchuser", "password123"))
x := decodeXML(t, resp)
if x.Status != "failed" || x.Error == nil || x.Error.Code != ErrWrongCredentials {
t.Fatalf("expected error 40, got %+v", x)
}
}
func TestDisabledUserFails(t *testing.T) {
s, srv := newTestRecorderServer(t)
u := createTestUser(t, s, "alice", "password123", false)
if err := database.SetUserDisabled(s.DB, u.ID, true); err != nil {
t.Fatal(err)
}
resp := get(t, srv.URL, baseParams("alice", "password123"))
x := decodeXML(t, resp)
if x.Status != "failed" || x.Error == nil || x.Error.Code != ErrWrongCredentials {
t.Fatalf("expected error 40 for disabled user, got %+v", x)
}
}
func TestMissingUsernameFails(t *testing.T) {
_, srv := newTestRecorderServer(t)
q := url.Values{"p": {"x"}, "v": {"1.16.1"}, "c": {"test"}}
resp := get(t, srv.URL, q)
x := decodeXML(t, resp)
if x.Status != "failed" || x.Error == nil || x.Error.Code != ErrMissingParameter {
t.Fatalf("expected error 10, got %+v", x)
}
}
func TestMissingCredentialsFails(t *testing.T) {
s, srv := newTestRecorderServer(t)
createTestUser(t, s, "alice", "password123", false)
q := url.Values{"u": {"alice"}, "v": {"1.16.1"}, "c": {"test"}}
resp := get(t, srv.URL, q)
x := decodeXML(t, resp)
if x.Status != "failed" || x.Error == nil || x.Error.Code != ErrMissingParameter {
t.Fatalf("expected error 10 with neither p nor t/s, got %+v", x)
}
}
func TestTokenAuthSuccess(t *testing.T) {
s, srv := newTestRecorderServer(t)
createTestUser(t, s, "alice", "password123", false)
salt := "saltysalt"
sum := md5.Sum([]byte("password123" + salt))
token := hex.EncodeToString(sum[:])
resp := get(t, srv.URL, tokenParams("alice", salt, token))
x := decodeXML(t, resp)
if x.Status != "ok" {
t.Fatalf("token auth failed: %+v", x)
}
}
func TestTokenAuthWrongToken(t *testing.T) {
s, srv := newTestRecorderServer(t)
createTestUser(t, s, "alice", "password123", false)
resp := get(t, srv.URL, tokenParams("alice", "saltysalt", "0000000000000000000000000000000"))
x := decodeXML(t, resp)
if x.Status != "failed" || x.Error == nil || x.Error.Code != ErrWrongCredentials {
t.Fatalf("expected error 40, got %+v", x)
}
}
func TestEncPasswordAuth(t *testing.T) {
s, srv := newTestRecorderServer(t)
createTestUser(t, s, "alice", "password123", false)
encoded := "enc:" + hex.EncodeToString([]byte("password123"))
q := url.Values{"u": {"alice"}, "p": {encoded}, "v": {"1.16.1"}, "c": {"test"}}
resp := get(t, srv.URL, q)
x := decodeXML(t, resp)
if x.Status != "ok" {
t.Fatalf("enc: password auth failed: %+v", x)
}
}
func TestExternalOnlyUserCannotTokenAuth(t *testing.T) {
s, db := newTestServer(t)
if _, err := database.CreateExternalUser(db, "extuser"); err != nil {
t.Fatal(err)
}
srv := httptest.NewServer(s.Router())
defer srv.Close()
sum := md5.Sum([]byte("irrelevant" + "salt"))
resp := get(t, srv.URL, tokenParams("extuser", "salt", hex.EncodeToString(sum[:])))
x := decodeXML(t, resp)
if x.Status != "failed" || x.Error == nil || x.Error.Code != ErrTokenAuthNotSupported {
t.Fatalf("expected error 41 for external-auth-only user, got %+v", x)
}
}
func TestClientVersionTooNewFails(t *testing.T) {
s, srv := newTestRecorderServer(t)
createTestUser(t, s, "alice", "password123", false)
q := baseParams("alice", "password123")
q.Set("v", "99.0.0")
resp := get(t, srv.URL, q)
x := decodeXML(t, resp)
if x.Status != "failed" || x.Error == nil || x.Error.Code != ErrClientMustUpgrade {
t.Fatalf("expected error 20 for too-new client version, got %+v", x)
}
}
func TestOlderClientVersionAccepted(t *testing.T) {
s, srv := newTestRecorderServer(t)
createTestUser(t, s, "alice", "password123", false)
q := baseParams("alice", "password123")
q.Set("v", "1.1.0")
resp := get(t, srv.URL, q)
x := decodeXML(t, resp)
if x.Status != "ok" {
t.Fatalf("older client version should be accepted, got %+v", x)
}
}
func TestUnknownEndpoint(t *testing.T) {
s, srv := newTestRecorderServer(t)
createTestUser(t, s, "alice", "password123", false)
resp := getPath(t, srv.URL, "/rest/thisEndpointDoesNotExist.view", baseParams("alice", "password123"))
x := decodeXML(t, resp)
if x.Status != "failed" || x.Error == nil || x.Error.Code != ErrNotFound {
t.Fatalf("expected error 70 for unknown endpoint, got %+v", x)
}
}
func TestViewSuffixOptional(t *testing.T) {
s, srv := newTestRecorderServer(t)
createTestUser(t, s, "alice", "password123", false)
resp := getPath(t, srv.URL, "/rest/ping", baseParams("alice", "password123"))
x := decodeXML(t, resp)
if x.Status != "ok" {
t.Fatalf("expected /rest/ping (no .view) to work, got %+v", x)
}
}
func TestGetLicenseAlwaysValid(t *testing.T) {
s, srv := newTestRecorderServer(t)
createTestUser(t, s, "alice", "password123", false)
q := baseParams("alice", "password123")
q.Set("f", "json")
resp := getPath(t, srv.URL, "/rest/getLicense.view", q)
j := decodeJSON(t, resp)
license, ok := j["license"].(map[string]any)
if !ok {
t.Fatalf("missing license in response: %v", j)
}
if valid, _ := license["valid"].(bool); !valid {
t.Fatalf("license.valid = %v, want true", license["valid"])
}
}
+39
View File
@@ -0,0 +1,39 @@
package subsonic
import (
"database/sql"
"log/slog"
"net/http"
"git.noroutes.net/NoRoutes/vibetunes/internal/config"
"git.noroutes.net/NoRoutes/vibetunes/internal/coverart"
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
"git.noroutes.net/NoRoutes/vibetunes/internal/scanner"
)
// Server holds every dependency the Subsonic REST API needs. It mirrors
// internal/web.Web's role for the browser UI: a thin adapter, not a
// second copy of the domain logic in internal/database/internal/library/
// internal/scanner/internal/streaming/internal/coverart.
type Server struct {
DB *sql.DB
Config config.Config
EncryptionKey []byte
Scanner *scanner.Scanner
CoverArt *coverart.Store
Log *slog.Logger
}
// Context carries one request's parsed parameters, resolved user, and
// output format through a handler.
type Context struct {
W http.ResponseWriter
R *http.Request
Server *Server
User *database.User
Params params
Format string
Callback string
Response *Response // set by success handlers; ignored if Handled is true
Handled bool // true if the handler already wrote the response itself (streaming endpoints)
}
+19
View File
@@ -0,0 +1,19 @@
package subsonic
func init() {
register("ping", handlePing)
register("getLicense", handleGetLicense)
}
func handlePing(rc *Context) *apiError {
rc.Response = newResponse()
return nil
}
// handleGetLicense always reports a valid, unlimited license: VibeSonic
// is free software and never requires a commercial Subsonic license.
func handleGetLicense(rc *Context) *apiError {
rc.Response = newResponse()
rc.Response.License = &License{Valid: true}
return nil
}
+69
View File
@@ -0,0 +1,69 @@
package subsonic
import (
"database/sql"
"io"
"log/slog"
"net/http/httptest"
"path/filepath"
"testing"
vsauth "git.noroutes.net/NoRoutes/vibetunes/internal/auth"
"git.noroutes.net/NoRoutes/vibetunes/internal/config"
"git.noroutes.net/NoRoutes/vibetunes/internal/coverart"
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
"git.noroutes.net/NoRoutes/vibetunes/internal/metadata"
"git.noroutes.net/NoRoutes/vibetunes/internal/scanner"
)
func newTestServer(t *testing.T) (*Server, *sql.DB) {
t.Helper()
db, err := database.Open(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatalf("database.Open: %v", err)
}
t.Cleanup(func() { db.Close() })
key, err := vsauth.EncryptionKey(db, "test-secret")
if err != nil {
t.Fatalf("EncryptionKey: %v", err)
}
log := slog.New(slog.NewTextHandler(io.Discard, nil))
s := &Server{
DB: db,
Config: config.Config{},
EncryptionKey: key,
Scanner: scanner.New(db, &metadata.Prober{}, log),
CoverArt: coverart.New(t.TempDir(), ""),
Log: log,
}
return s, db
}
// createTestUser creates a user with the given password (via
// auth.SetUserPassword, so both the Argon2id hash and the encrypted
// Subsonic-token-auth secret are populated, matching production).
func createTestUser(t *testing.T, s *Server, username, password string, isAdmin bool) *database.User {
t.Helper()
u, err := database.CreateUser(s.DB, username, "", "", isAdmin)
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
if err := vsauth.SetUserPassword(s.DB, s.EncryptionKey, u.ID, password); err != nil {
t.Fatalf("SetUserPassword: %v", err)
}
u, err = database.GetUserByID(s.DB, u.ID)
if err != nil {
t.Fatalf("GetUserByID: %v", err)
}
return u
}
func newTestRecorderServer(t *testing.T) (*Server, *httptest.Server) {
t.Helper()
s, _ := newTestServer(t)
srv := httptest.NewServer(s.Router())
t.Cleanup(srv.Close)
return s, srv
}