1
0

feat: per-user settings routes with subsonic validation

This commit is contained in:
2026-08-29 15:51:58 +02:00
parent 6dfaaf465d
commit e94ffb535b
4 changed files with 289 additions and 0 deletions

View File

@@ -3,6 +3,7 @@ import Database from 'better-sqlite3'
import cookie from '@fastify/cookie'
import type { Config } from './config.js'
import { registerAuthRoutes } from './routes/authRoutes.js'
import { registerSettingsRoutes } from './routes/settingsRoutes.js'
declare module 'fastify' {
interface FastifyInstance {
@@ -29,6 +30,7 @@ export async function buildApp(opts: AppOptions): Promise<FastifyInstance> {
await app.register(cookie)
await registerAuthRoutes(app)
await registerSettingsRoutes(app)
app.get('/api/health', async () => ({ ok: true }))
return app

View File

@@ -0,0 +1,89 @@
import { FastifyInstance } from 'fastify'
import { SubsonicClient } from '../subsonic.js'
import { requireAuth } from './authRoutes.js'
import type { DB } from '../db.js'
export interface SettingsRow {
user_id: number
discogs_token: string | null
subsonic_url: string | null
subsonic_username: string | null
subsonic_password: string | null
}
export function getSettings(db: DB, userId: number): SettingsRow {
const row = db.prepare('SELECT * FROM settings WHERE user_id = ?').get(userId) as
| SettingsRow
| undefined
if (row) return row
db.prepare('INSERT INTO settings (user_id) VALUES (?)').run(userId)
return db.prepare('SELECT * FROM settings WHERE user_id = ?').get(userId) as SettingsRow
}
export function settingsView(s: SettingsRow) {
return {
hasDiscogsToken: !!s.discogs_token,
discogsTokenMasked: s.discogs_token ? `****${s.discogs_token.slice(-5)}` : null,
subsonicUrl: s.subsonic_url,
subsonicUsername: s.subsonic_username,
hasSubsonicPassword: !!s.subsonic_password,
}
}
export function subsonicConfigComplete(s: SettingsRow): boolean {
return !!(s.subsonic_url && s.subsonic_username && s.subsonic_password)
}
export async function registerSettingsRoutes(app: FastifyInstance): Promise<void> {
app.get('/api/settings', { preHandler: [requireAuth] }, async (request) => {
const s = getSettings(request.server.db, (request.user as any).id)
return settingsView(s)
})
app.put('/api/settings', { preHandler: [requireAuth] }, async (request, reply) => {
const db = request.server.db
const userId = (request.user as any).id
const s = getSettings(db, userId)
const body = (request.body ?? {}) as Record<string, string | undefined>
const next = {
discogs_token: body.discogsToken !== undefined ? body.discogsToken || null : s.discogs_token,
subsonic_url: body.subsonicUrl !== undefined ? body.subsonicUrl || null : s.subsonic_url,
subsonic_username:
body.subsonicUsername !== undefined ? body.subsonicUsername || null : s.subsonic_username,
subsonic_password:
body.subsonicPassword !== undefined ? body.subsonicPassword || null : s.subsonic_password,
}
const candidate: SettingsRow = { ...s, ...next }
if (subsonicConfigComplete(candidate)) {
const client = new SubsonicClient({
url: candidate.subsonic_url as string,
username: candidate.subsonic_username as string,
password: candidate.subsonic_password as string,
fetchImpl: request.server.fetchImpl,
})
try {
await client.ping()
} catch (err: any) {
if (err.code === 'unreachable') {
return reply.code(400).send({ error: 'subsonic_unreachable', detail: err.message })
}
return reply.code(400).send({ error: 'subsonic_auth', detail: err.message })
}
}
db.prepare(
`UPDATE settings SET discogs_token = ?, subsonic_url = ?, subsonic_username = ?, subsonic_password = ?
WHERE user_id = ?`
).run(
next.discogs_token,
next.subsonic_url,
next.subsonic_username,
next.subsonic_password,
userId
)
return settingsView(getSettings(db, userId))
})
}

74
server/src/subsonic.ts Normal file
View File

@@ -0,0 +1,74 @@
import crypto from 'node:crypto'
export interface SubsonicAlbum {
id: string
title: string
artist: string
}
export class SubsonicError extends Error {
constructor(
public code: 'auth' | 'unreachable' | 'api',
message: string
) {
super(message)
}
}
export interface SubsonicClientOptions {
url: string
username: string
password: string
fetchImpl?: typeof fetch
clientName?: string
}
export class SubsonicClient {
private base: string
private username: string
private password: string
private fetchImpl: typeof fetch
private clientName: string
constructor(opts: SubsonicClientOptions) {
this.base = opts.url.replace(/\/+$/, '')
this.username = opts.username
this.password = opts.password
this.fetchImpl = opts.fetchImpl ?? fetch
this.clientName = opts.clientName ?? 'record-shop'
}
private authParams(): Record<string, string> {
const salt = crypto.randomBytes(8).toString('hex')
const token = crypto.createHash('md5').update(this.password + salt).digest('hex')
return { u: this.username, t: token, s: salt, v: '1.16.1', c: this.clientName, f: 'json' }
}
private async request(endpoint: string, params: Record<string, string> = {}): Promise<any> {
const url = new URL(`${this.base}/rest/${endpoint}`)
const search = { ...this.authParams(), ...params }
for (const [k, v] of Object.entries(search)) url.searchParams.set(k, v)
let res: Response
try {
res = await this.fetchImpl(url.toString())
} catch {
throw new SubsonicError('unreachable', `could not reach ${this.base}`)
}
if (!res.ok) throw new SubsonicError('unreachable', `HTTP ${res.status} from ${this.base}`)
const body = (await res.json()) as {
'subsonic-response'?: { status?: string; error?: { code?: number; message?: string } }
}
const envelope = body['subsonic-response']
if (!envelope) throw new SubsonicError('api', 'malformed subsonic response')
if (envelope.status !== 'ok') {
const message: string = envelope.error?.message ?? 'subsonic request failed'
const code = envelope.error?.code === 40 || /credential|auth/i.test(message) ? 'auth' : 'api'
throw new SubsonicError(code, message)
}
return envelope
}
async ping(): Promise<void> {
await this.request('ping')
}
}

View File

@@ -0,0 +1,124 @@
import { describe, it, expect } from 'vitest'
import { buildTestApp, setupAdmin, auth } from './helpers.js'
function subsonicStubFetch(ok: boolean): typeof fetch {
return (async (url: any) => {
if (String(url).includes('/rest/ping')) {
const body = ok
? { 'subsonic-response': { status: 'ok' } }
: {
'subsonic-response': {
status: 'failed',
error: { code: 40, message: 'Wrong username or password.' },
},
}
return new Response(JSON.stringify(body), {
status: 200,
headers: { 'content-type': 'application/json' },
})
}
return new Response('not found', { status: 404 })
}) as typeof fetch
}
describe('settings routes', () => {
it('starts empty and stores/clears tokens', async () => {
const app = await buildTestApp()
const cookie = await setupAdmin(app)
const empty = await app.inject({ method: 'GET', url: '/api/settings', ...auth(cookie) })
expect(empty.json()).toEqual({
hasDiscogsToken: false,
discogsTokenMasked: null,
subsonicUrl: null,
subsonicUsername: null,
hasSubsonicPassword: false,
})
const put = await app.inject({
method: 'PUT',
url: '/api/settings',
...auth(cookie),
payload: { discogsToken: 'abcdef0123456789' },
})
expect(put.statusCode).toBe(200)
expect(put.json()).toMatchObject({ hasDiscogsToken: true, discogsTokenMasked: '****56789' })
await app.inject({
method: 'PUT',
url: '/api/settings',
...auth(cookie),
payload: { discogsToken: '' },
})
const cleared = await app.inject({ method: 'GET', url: '/api/settings', ...auth(cookie) })
expect(cleared.json().hasDiscogsToken).toBe(false)
await app.close()
})
it('stores valid subsonic config after successful ping', async () => {
const app = await buildTestApp(subsonicStubFetch(true))
const cookie = await setupAdmin(app)
const put = await app.inject({
method: 'PUT',
url: '/api/settings',
...auth(cookie),
payload: {
subsonicUrl: 'http://navidrome.local',
subsonicUsername: 'sam',
subsonicPassword: 'pass',
},
})
expect(put.statusCode).toBe(200)
expect(put.json()).toMatchObject({
subsonicUrl: 'http://navidrome.local',
hasSubsonicPassword: true,
})
await app.close()
})
it('rejects bad subsonic credentials with 400 subsonic_auth', async () => {
const app = await buildTestApp(subsonicStubFetch(false))
const cookie = await setupAdmin(app)
const put = await app.inject({
method: 'PUT',
url: '/api/settings',
...auth(cookie),
payload: {
subsonicUrl: 'http://navidrome.local',
subsonicUsername: 'sam',
subsonicPassword: 'wrong',
},
})
expect(put.statusCode).toBe(400)
expect(put.json()).toMatchObject({ error: 'subsonic_auth' })
await app.close()
})
it('rejects unreachable subsonic with 400 subsonic_unreachable', async () => {
const failing = (async () => {
throw new TypeError('fetch failed')
}) as unknown as typeof fetch
const app = await buildTestApp(failing)
const cookie = await setupAdmin(app)
const put = await app.inject({
method: 'PUT',
url: '/api/settings',
...auth(cookie),
payload: {
subsonicUrl: 'http://nope.invalid',
subsonicUsername: 'sam',
subsonicPassword: 'pass',
},
})
expect(put.statusCode).toBe(400)
expect(put.json()).toMatchObject({ error: 'subsonic_unreachable' })
await app.close()
})
it('requires auth', async () => {
const app = await buildTestApp()
const res = await app.inject({ method: 'GET', url: '/api/settings' })
expect(res.statusCode).toBe(401)
await app.close()
})
})