diff --git a/server/src/app.ts b/server/src/app.ts index 7627156..b0df77a 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -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 { await app.register(cookie) await registerAuthRoutes(app) + await registerSettingsRoutes(app) app.get('/api/health', async () => ({ ok: true })) return app diff --git a/server/src/routes/settingsRoutes.ts b/server/src/routes/settingsRoutes.ts new file mode 100644 index 0000000..0a7e414 --- /dev/null +++ b/server/src/routes/settingsRoutes.ts @@ -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 { + 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 + + 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)) + }) +} diff --git a/server/src/subsonic.ts b/server/src/subsonic.ts new file mode 100644 index 0000000..cf63f61 --- /dev/null +++ b/server/src/subsonic.ts @@ -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 { + 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 = {}): Promise { + 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 { + await this.request('ping') + } +} diff --git a/server/test/settings.test.ts b/server/test/settings.test.ts new file mode 100644 index 0000000..f434d3a --- /dev/null +++ b/server/test/settings.test.ts @@ -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() + }) +})