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 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
|
||||
|
||||
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')
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user