feat: per-user settings routes with subsonic validation
This commit is contained in:
@@ -3,6 +3,7 @@ import Database from 'better-sqlite3'
|
|||||||
import cookie from '@fastify/cookie'
|
import cookie from '@fastify/cookie'
|
||||||
import type { Config } from './config.js'
|
import type { Config } from './config.js'
|
||||||
import { registerAuthRoutes } from './routes/authRoutes.js'
|
import { registerAuthRoutes } from './routes/authRoutes.js'
|
||||||
|
import { registerSettingsRoutes } from './routes/settingsRoutes.js'
|
||||||
|
|
||||||
declare module 'fastify' {
|
declare module 'fastify' {
|
||||||
interface FastifyInstance {
|
interface FastifyInstance {
|
||||||
@@ -29,6 +30,7 @@ export async function buildApp(opts: AppOptions): Promise<FastifyInstance> {
|
|||||||
|
|
||||||
await app.register(cookie)
|
await app.register(cookie)
|
||||||
await registerAuthRoutes(app)
|
await registerAuthRoutes(app)
|
||||||
|
await registerSettingsRoutes(app)
|
||||||
|
|
||||||
app.get('/api/health', async () => ({ ok: true }))
|
app.get('/api/health', async () => ({ ok: true }))
|
||||||
return app
|
return app
|
||||||
|
|||||||
89
server/src/routes/settingsRoutes.ts
Normal file
89
server/src/routes/settingsRoutes.ts
Normal 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
74
server/src/subsonic.ts
Normal 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')
|
||||||
|
}
|
||||||
|
}
|
||||||
124
server/test/settings.test.ts
Normal file
124
server/test/settings.test.ts
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user