diff --git a/.gitignore b/.gitignore index d4ce4ca..b1a68a9 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,5 @@ web/dist/ data/ *.log .DS_Store +coverage/ +.env* diff --git a/server/src/config.ts b/server/src/config.ts index 7e102b4..73e7241 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -1,3 +1,7 @@ +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import path from 'node:path' +import crypto from 'node:crypto' + export interface Config { dataDir: string artworkDir: string @@ -5,3 +9,30 @@ export interface Config { port: number sessionSecret: string } + +export function loadConfig(env: Record = process.env): Config { + const dataDir = env.DATA_DIR ?? path.resolve('data') + mkdirSync(dataDir, { recursive: true }) + const artworkDir = path.join(dataDir, 'artwork-cache') + mkdirSync(artworkDir, { recursive: true }) + return { + dataDir, + artworkDir, + dbPath: path.join(dataDir, 'record-shop.db'), + port: Number(env.PORT ?? 3000), + sessionSecret: getOrCreateSecret(dataDir), + } +} + +function getOrCreateSecret(dataDir: string): string { + const secretPath = path.join(dataDir, 'session-secret') + try { + const existing = readFileSync(secretPath, 'utf8').trim() + if (existing) return existing + } catch { + // first boot — create below + } + const secret = crypto.randomBytes(32).toString('hex') + writeFileSync(secretPath, secret, { mode: 0o600 }) + return secret +} diff --git a/server/src/db.ts b/server/src/db.ts index 201e500..7ffe290 100644 --- a/server/src/db.ts +++ b/server/src/db.ts @@ -6,5 +6,75 @@ export function openDatabase(path: string): DB { const db = new Database(path) db.pragma('journal_mode = WAL') db.pragma('foreign_keys = ON') + migrate(db) return db } + +export function migrate(db: DB): void { + db.exec(` + CREATE TABLE IF NOT EXISTS app_meta ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + is_admin INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS sessions ( + token TEXT PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + expires_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS settings ( + user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + discogs_token TEXT, + subsonic_url TEXT, + subsonic_username TEXT, + subsonic_password TEXT + ); + + CREATE TABLE IF NOT EXISTS collection_items ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + discogs_release_id INTEGER NOT NULL, + title TEXT NOT NULL, + artist TEXT NOT NULL, + year INTEGER, + formats TEXT NOT NULL DEFAULT '[]', + genres TEXT NOT NULL DEFAULT '[]', + labels TEXT NOT NULL DEFAULT '[]', + tracklist TEXT NOT NULL DEFAULT '[]', + catno TEXT, + country TEXT, + cover_url TEXT, + local_artwork_path TEXT, + barcodes TEXT NOT NULL DEFAULT '[]', + rip_override INTEGER, + date_added TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE (user_id, discogs_release_id) + ); + + CREATE TABLE IF NOT EXISTS digital_albums ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + subsonic_id TEXT NOT NULL, + title TEXT NOT NULL, + artist TEXT NOT NULL, + UNIQUE (user_id, subsonic_id) + ); + + CREATE TABLE IF NOT EXISTS match_links ( + user_id INTEGER NOT NULL, + item_id INTEGER NOT NULL REFERENCES collection_items(id) ON DELETE CASCADE, + album_id INTEGER NOT NULL REFERENCES digital_albums(id) ON DELETE CASCADE, + PRIMARY KEY (user_id, item_id) + ); + `) +} diff --git a/server/test/config.test.ts b/server/test/config.test.ts new file mode 100644 index 0000000..f6b6be0 --- /dev/null +++ b/server/test/config.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect } from 'vitest' +import { mkdtempSync, rmSync, readFileSync, existsSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { loadConfig } from '../src/config.js' + +function tempDir(): string { + return mkdtempSync(path.join(tmpdir(), 'rs-config-')) +} + +describe('loadConfig', () => { + it('creates data + artwork dirs and returns paths', () => { + const dir = tempDir() + try { + const cfg = loadConfig({ DATA_DIR: dir }) + expect(cfg.dataDir).toBe(dir) + expect(cfg.dbPath).toBe(path.join(dir, 'record-shop.db')) + expect(cfg.port).toBe(3000) + expect(existsSync(path.join(dir, 'artwork-cache'))).toBe(true) + expect(cfg.sessionSecret).toMatch(/^[0-9a-f]{64}$/) + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('persists and reuses the session secret', () => { + const dir = tempDir() + try { + const a = loadConfig({ DATA_DIR: dir }) + const b = loadConfig({ DATA_DIR: dir }) + expect(a.sessionSecret).toBe(b.sessionSecret) + expect(readFileSync(path.join(dir, 'session-secret'), 'utf8')).toBe(a.sessionSecret) + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('honours PORT', () => { + const dir = tempDir() + try { + expect(loadConfig({ DATA_DIR: dir, PORT: '8080' }).port).toBe(8080) + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) +}) diff --git a/server/test/db.test.ts b/server/test/db.test.ts new file mode 100644 index 0000000..dfadd5d --- /dev/null +++ b/server/test/db.test.ts @@ -0,0 +1,37 @@ +import { describe, it, expect } from 'vitest' +import { openDatabase } from '../src/db.js' + +describe('openDatabase', () => { + it('creates the full schema', () => { + const db = openDatabase(':memory:') + const tables = ( + db.prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name").all() as { + name: string + }[] + ).map((r) => r.name) + for (const t of [ + 'users', + 'sessions', + 'settings', + 'collection_items', + 'digital_albums', + 'match_links', + 'app_meta', + ]) { + expect(tables).toContain(t) + } + }) + + it('enforces unique (user_id, discogs_release_id)', () => { + const db = openDatabase(':memory:') + db.prepare( + "INSERT INTO users (username, password_hash, is_admin) VALUES ('sam', 'x', 1)" + ).run() + const insert = db.prepare( + `INSERT INTO collection_items (user_id, discogs_release_id, title, artist) + VALUES (1, 100, 'Album', 'Artist')` + ) + insert.run() + expect(() => insert.run()).toThrow() + }) +})