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')
}
}