diff --git a/package.json b/package.json index 9d729a7..13b2119 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "web": "node webserver.js", "import-talkgroups": "node import_csv.js", "check:config": "node scripts/check-config.js", - "check:syntax": "node --check bot.js && node --check webserver.js && node --check geocoding.js && node --check import_csv.js && node --check public/app.js", + "check:syntax": "node --check bot.js && node --check webserver.js && node --check geocoding.js && node --check import_csv.js && node --check public/app.js && node --check public/setup.js && node --check public/settings.js", "demo:data": "node scripts/generate-demo-data.js", "test": "node --test test/*.test.js" }, diff --git a/public/index.html b/public/index.html index 809b2fe..094eb9c 100644 --- a/public/index.html +++ b/public/index.html @@ -482,6 +482,7 @@ Add User View Users Manage Sessions + Settings Console Call Purge diff --git a/public/settings.html b/public/settings.html new file mode 100644 index 0000000..89224b5 --- /dev/null +++ b/public/settings.html @@ -0,0 +1,82 @@ + + + + + + Scanner Map Settings + + + +
+
+
+

Scanner Map Settings

+

Manage runtime settings, write-only secrets, and setup diagnostics.

+
+ Back to Map +
+ +
+ + +
+
+

General

+
+
+
+
+
+
+
+ +
+

Ingestion

+
+
+
+
+
+
+ +
+

Providers

+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+

Diagnostics

+
+ + +
+
+
+ +
+ + +
+
+
+
+ + + diff --git a/public/settings.js b/public/settings.js new file mode 100644 index 0000000..47d24ae --- /dev/null +++ b/public/settings.js @@ -0,0 +1,77 @@ +const normalKeys = [ + 'publicDomain', 'timezone', 'summaryLookbackHours', 'askAiLookbackHours', + 'mappedTalkGroups', 'enableMappedTalkGroups', 'storageMode', 'transcriptionMode', + 'transcriptionDevice', 'aiProvider', 'ollamaUrl', 'ollamaModel', 'openaiModel', + 'fasterWhisperServerUrl' +]; +const secretKeys = ['uploadApiKey', 'googleMapsApiKey', 'locationIqApiKey', 'openaiApiKey']; + +function showStep(id) { + document.querySelectorAll('.section').forEach((section) => section.classList.toggle('active', section.id === id)); + document.querySelectorAll('.step-button').forEach((button) => button.classList.toggle('active', button.dataset.step === id)); +} + +document.querySelectorAll('.step-button').forEach((button) => button.addEventListener('click', () => showStep(button.dataset.step))); + +async function jsonFetch(url, options = {}) { + const response = await fetch(url, { headers: { 'Content-Type': 'application/json' }, ...options }); + const data = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(data.error || response.statusText); + return data; +} + +async function loadSettings() { + const data = await jsonFetch('/api/settings'); + for (const key of normalKeys) { + const input = document.getElementById(key); + if (input && data.settings[key]) input.value = data.settings[key].value; + } + for (const key of secretKeys) { + const input = document.getElementById(key); + if (input && data.secrets[key]?.configured) input.placeholder = 'Configured - enter a new value to replace'; + } +} + +document.getElementById('save-settings').addEventListener('click', async () => { + const result = document.getElementById('save-result'); + try { + const payload = {}; + for (const key of normalKeys) { + const input = document.getElementById(key); + if (input) payload[key] = input.value; + } + const saved = await jsonFetch('/api/settings', { method: 'PUT', body: JSON.stringify(payload) }); + + for (const key of secretKeys) { + const input = document.getElementById(key); + if (input && input.value) { + await jsonFetch(`/api/settings/secrets/${key}`, { method: 'PUT', body: JSON.stringify({ value: input.value }) }); + input.value = ''; + input.placeholder = 'Configured - enter a new value to replace'; + } + } + + result.textContent = saved.requiresRestart ? 'Saved. Restart required for some changes.' : 'Saved.'; + } catch (error) { + result.textContent = error.message; + } +}); + +document.getElementById('run-diagnostics').addEventListener('click', async () => { + const output = document.getElementById('diagnostic-output'); + const checks = await jsonFetch('/api/settings/checks'); + output.innerHTML = `
${JSON.stringify(checks, null, 2)}
`; +}); + +document.getElementById('load-jobs').addEventListener('click', async () => { + const output = document.getElementById('diagnostic-output'); + const [summary, recent] = await Promise.all([ + jsonFetch('/api/jobs/summary'), + jsonFetch('/api/jobs/recent?limit=10') + ]); + output.innerHTML = `
${JSON.stringify({ summary, recent }, null, 2)}
`; +}); + +loadSettings().catch((error) => { + document.getElementById('save-result').textContent = error.message; +}); diff --git a/public/setup.css b/public/setup.css new file mode 100644 index 0000000..51eaa2d --- /dev/null +++ b/public/setup.css @@ -0,0 +1,227 @@ +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-height: 100vh; + font-family: "Segoe UI", Tahoma, sans-serif; + color: #17202a; + background: + linear-gradient(135deg, rgba(19, 83, 91, 0.12), rgba(238, 183, 76, 0.14)), + #f5f7f8; +} + +.setup-shell { + max-width: 1180px; + margin: 0 auto; + padding: 32px 20px 48px; +} + +.setup-header { + display: flex; + justify-content: space-between; + gap: 24px; + align-items: flex-start; + margin-bottom: 24px; +} + +.setup-header h1 { + margin: 0 0 8px; + font-size: 34px; + letter-spacing: 0; +} + +.setup-header p { + margin: 0; + color: #52616b; + max-width: 680px; +} + +.status-pill { + border: 1px solid #cbd7dd; + background: #ffffff; + border-radius: 999px; + padding: 8px 14px; + white-space: nowrap; + font-weight: 600; +} + +.layout { + display: grid; + grid-template-columns: 240px 1fr; + gap: 18px; +} + +.steps, +.panel { + background: rgba(255, 255, 255, 0.92); + border: 1px solid #d8e1e6; + border-radius: 8px; + box-shadow: 0 18px 42px rgba(21, 39, 52, 0.08); +} + +.steps { + padding: 10px; + height: fit-content; +} + +.step-button { + width: 100%; + border: 0; + border-radius: 6px; + background: transparent; + color: #344955; + padding: 12px; + text-align: left; + font-weight: 700; + cursor: pointer; +} + +.step-button.active { + background: #0f4c5c; + color: #fff; +} + +.panel { + padding: 24px; + min-height: 520px; +} + +.section { + display: none; +} + +.section.active { + display: block; +} + +.section h2 { + margin: 0 0 8px; + font-size: 24px; +} + +.section p { + color: #52616b; +} + +.grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; +} + +.field { + display: grid; + gap: 6px; +} + +.field label { + font-weight: 700; + color: #2c3f4b; +} + +.field input, +.field select { + min-height: 42px; + border: 1px solid #bdcbd2; + border-radius: 6px; + padding: 9px 11px; + font-size: 15px; + background: #fff; +} + +.actions { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-top: 20px; +} + +button.primary, +button.secondary { + border: 0; + border-radius: 6px; + padding: 11px 15px; + font-weight: 800; + cursor: pointer; +} + +button.primary { + background: #0f4c5c; + color: white; +} + +button.secondary { + background: #e8eef1; + color: #1f3440; +} + +.check-list, +.result-list { + display: grid; + gap: 10px; + margin-top: 16px; +} + +.check-row, +.result-row { + display: grid; + grid-template-columns: 120px 1fr; + gap: 12px; + align-items: start; + border: 1px solid #d8e1e6; + border-radius: 6px; + padding: 12px; + background: #fbfcfd; +} + +.badge { + display: inline-block; + width: fit-content; + border-radius: 999px; + padding: 4px 9px; + font-size: 12px; + font-weight: 800; +} + +.ok { + color: #0d5f3c; + background: #dff5ea; +} + +.warn { + color: #8a5600; + background: #fff0cf; +} + +.error { + color: #8a1f1f; + background: #ffe0df; +} + +code { + display: inline-block; + max-width: 100%; + padding: 3px 6px; + border-radius: 4px; + background: #edf2f4; + overflow-wrap: anywhere; +} + +@media (max-width: 780px) { + .setup-header, + .layout, + .grid { + display: block; + } + + .steps { + margin-bottom: 14px; + } + + .status-pill { + margin-top: 12px; + display: inline-block; + } +} diff --git a/public/setup.html b/public/setup.html new file mode 100644 index 0000000..abc6824 --- /dev/null +++ b/public/setup.html @@ -0,0 +1,118 @@ + + + + + + Scanner Map Setup + + + +
+
+
+

Scanner Map Setup

+

Configure the instance, verify dependencies, and finish first-run setup from the browser.

+
+
Checking setup...
+
+ +
+ + +
+
+

Installer Checks

+

Scanner Map verifies dependencies and shows exact commands for anything missing. The web app does not run privileged installer commands.

+
+ +
+
+
+ +
+

Admin Account

+

Create or update the local admin account used for protected setup and settings screens.

+
+
+ + +
+
+ + +
+
+
+ +
+
+
+ +
+

Providers

+

Set the core runtime choices and write-only secrets. Existing secret values are never shown back.

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+
+ +
+

Finish Setup

+

Complete setup after the required account, upload key, geocoding, transcription, and storage settings are configured.

+
+ + Open Map + Open Settings +
+
+
+
+
+
+ + + diff --git a/public/setup.js b/public/setup.js new file mode 100644 index 0000000..30a1540 --- /dev/null +++ b/public/setup.js @@ -0,0 +1,128 @@ +const sections = document.querySelectorAll('.section'); +const buttons = document.querySelectorAll('.step-button'); + +function showStep(id) { + sections.forEach((section) => section.classList.toggle('active', section.id === id)); + buttons.forEach((button) => button.classList.toggle('active', button.dataset.step === id)); +} + +function renderMessage(targetId, message, type = 'ok') { + const target = document.getElementById(targetId); + target.innerHTML = `
${type}
${message}
`; +} + +function labelFor(value) { + return value.replace(/([A-Z])/g, ' $1').replace(/^./, (c) => c.toUpperCase()); +} + +async function jsonFetch(url, options = {}) { + const response = await fetch(url, { + headers: { 'Content-Type': 'application/json' }, + ...options + }); + const data = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(data.error || response.statusText); + return data; +} + +async function loadStatus() { + const status = await jsonFetch('/api/setup/status'); + const el = document.getElementById('setup-status'); + el.textContent = status.setupComplete ? 'Setup complete' : `Missing: ${status.missing.join(', ') || 'review'}`; + el.className = `status-pill ${status.setupComplete ? 'ok' : 'warn'}`; +} + +async function runChecks() { + const checks = await jsonFetch('/api/setup/checks'); + const rows = Object.entries(checks).map(([key, check]) => { + const command = check.installCommand ? `
Install: ${check.installCommand}
` : ''; + const detail = check.version || check.error || check.url || ''; + return `
+ ${check.ok ? 'ok' : (check.optional ? 'optional' : 'missing')} +
${labelFor(key)}
${detail}
${command}
+
`; + }).join(''); + document.getElementById('checks-list').innerHTML = rows; +} + +buttons.forEach((button) => button.addEventListener('click', () => showStep(button.dataset.step))); + +document.getElementById('run-checks').addEventListener('click', () => { + runChecks().catch((error) => renderMessage('checks-list', error.message, 'error')); +}); + +document.getElementById('save-admin').addEventListener('click', async () => { + const password = document.getElementById('admin-password').value; + const confirm = document.getElementById('confirm-password').value; + if (password !== confirm) return renderMessage('admin-result', 'Passwords do not match.', 'error'); + try { + await jsonFetch('/api/setup/admin', { + method: 'POST', + body: JSON.stringify({ username: 'admin', password }) + }); + renderMessage('admin-result', 'Admin account saved.'); + await loadStatus(); + } catch (error) { + renderMessage('admin-result', error.message, 'error'); + } +}); + +document.getElementById('save-providers').addEventListener('click', async () => { + try { + await jsonFetch('/api/setup/settings', { + method: 'POST', + body: JSON.stringify({ + storageMode: document.getElementById('storage-mode').value, + transcriptionMode: document.getElementById('transcription-mode').value, + aiProvider: document.getElementById('ai-provider').value, + timezone: document.getElementById('timezone').value + }) + }); + + const uploadKey = document.getElementById('upload-key').value; + if (uploadKey) { + await jsonFetch('/api/setup/secrets', { + method: 'POST', + body: JSON.stringify({ key: 'uploadApiKey', value: uploadKey }) + }); + } + + const geocodeKey = document.getElementById('geocode-key').value; + if (geocodeKey) { + await jsonFetch('/api/setup/secrets', { + method: 'POST', + body: JSON.stringify({ key: 'googleMapsApiKey', value: geocodeKey }) + }); + } + + renderMessage('provider-result', 'Provider settings saved. Restart may be required for some settings.'); + await loadStatus(); + } catch (error) { + renderMessage('provider-result', error.message, 'error'); + } +}); + +document.getElementById('test-providers').addEventListener('click', async () => { + try { + const checks = await Promise.all(['geocoding', 'transcription', 'ai', 'storage', 'upload'].map((provider) => + jsonFetch('/api/setup/test-provider', { method: 'POST', body: JSON.stringify({ provider }) }).then((result) => [provider, result]) + )); + document.getElementById('provider-result').innerHTML = checks.map(([provider, result]) => + `
${result.ok ? 'ok' : 'check'}
${labelFor(provider)}
${JSON.stringify(result, null, 2)}
` + ).join(''); + } catch (error) { + renderMessage('provider-result', error.message, 'error'); + } +}); + +document.getElementById('complete-setup').addEventListener('click', async () => { + try { + await jsonFetch('/api/setup/complete', { method: 'POST', body: '{}' }); + renderMessage('finish-result', 'Setup complete. You can open the map or settings.'); + await loadStatus(); + } catch (error) { + renderMessage('finish-result', error.message, 'error'); + } +}); + +loadStatus().catch(() => {}); diff --git a/src/db/migrations.js b/src/db/migrations.js index 36e519a..5583c35 100644 --- a/src/db/migrations.js +++ b/src/db/migrations.js @@ -84,11 +84,36 @@ const BASE_MIGRATIONS = [ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, started_at DATETIME, completed_at DATETIME, - FOREIGN KEY(transcription_id) REFERENCES transcriptions(id) + FOREIGN KEY(transcription_id) REFERENCES transcriptions(id) ON DELETE SET NULL )`, `CREATE INDEX IF NOT EXISTS idx_call_jobs_status_priority ON call_jobs (status, priority DESC, created_at ASC)`, `CREATE INDEX IF NOT EXISTS idx_call_jobs_transcription_type ON call_jobs (transcription_id, job_type)` ] + }, + { + id: '004_create_app_settings', + statements: [ + `CREATE TABLE IF NOT EXISTS app_settings ( + key TEXT PRIMARY KEY, + value TEXT, + is_secret INTEGER NOT NULL DEFAULT 0, + requires_restart INTEGER NOT NULL DEFAULT 0, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + )`, + `CREATE TABLE IF NOT EXISTS setup_state ( + key TEXT PRIMARY KEY, + value TEXT, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + )`, + `CREATE TABLE IF NOT EXISTS settings_audit_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + event_type TEXT NOT NULL, + setting_key TEXT, + actor TEXT, + details_json TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + )` + ] } ]; diff --git a/src/jobs/processingJobs.js b/src/jobs/processingJobs.js index 345fb1e..4cd7d0a 100644 --- a/src/jobs/processingJobs.js +++ b/src/jobs/processingJobs.js @@ -45,6 +45,15 @@ function get(db, sql, params = []) { }); } +function all(db, sql, params = []) { + return new Promise((resolve, reject) => { + db.all(sql, params, (err, rows) => { + if (err) reject(err); + else resolve(rows); + }); + }); +} + async function createProcessingJob(db, { transcriptionId, jobType, @@ -116,11 +125,69 @@ async function getJobById(db, jobId) { }; } +async function getJobSummary(db) { + const rows = await all( + db, + `SELECT job_type, status, COUNT(*) AS count + FROM call_jobs + GROUP BY job_type, status + ORDER BY job_type ASC, status ASC` + ); + + const totals = {}; + for (const row of rows) { + if (!totals[row.job_type]) totals[row.job_type] = {}; + totals[row.job_type][row.status] = row.count; + } + + return { + totals, + rows + }; +} + +async function getRecentJobs(db, { limit = 50, status, jobType } = {}) { + const safeLimit = Math.max(1, Math.min(parseInt(limit, 10) || 50, 200)); + const where = []; + const params = []; + + if (status) { + where.push('status = ?'); + params.push(status); + } + + if (jobType) { + where.push('job_type = ?'); + params.push(jobType); + } + + const whereClause = where.length ? `WHERE ${where.join(' AND ')}` : ''; + const rows = await all( + db, + `SELECT id, transcription_id, job_type, status, attempts, max_attempts, priority, + run_after, payload_json, result_json, last_error, created_at, updated_at, + started_at, completed_at + FROM call_jobs + ${whereClause} + ORDER BY created_at DESC + LIMIT ?`, + [...params, safeLimit] + ); + + return rows.map((row) => ({ + ...row, + payload: parseJson(row.payload_json, {}), + result: parseJson(row.result_json, null) + })); +} + module.exports = { JOB_STATUS, JOB_TYPES, createProcessingJob, getJobById, + getJobSummary, + getRecentJobs, markJobCompleted, markJobFailed, markJobProcessing, diff --git a/src/settings/settingsService.js b/src/settings/settingsService.js new file mode 100644 index 0000000..fa37e34 --- /dev/null +++ b/src/settings/settingsService.js @@ -0,0 +1,277 @@ +const crypto = require('crypto'); +const fs = require('fs'); +const path = require('path'); + +const SETTING_DEFINITIONS = { + publicDomain: { envKey: 'PUBLIC_DOMAIN', defaultValue: 'localhost', requiresRestart: true }, + timezone: { envKey: 'TIMEZONE', defaultValue: 'US/Eastern', requiresRestart: false }, + storageMode: { envKey: 'STORAGE_MODE', defaultValue: 'local', requiresRestart: true }, + transcriptionMode: { envKey: 'TRANSCRIPTION_MODE', defaultValue: 'local', requiresRestart: true }, + transcriptionDevice: { envKey: 'TRANSCRIPTION_DEVICE', defaultValue: 'cpu', requiresRestart: true }, + aiProvider: { envKey: 'AI_PROVIDER', defaultValue: 'ollama', requiresRestart: false }, + ollamaUrl: { envKey: 'OLLAMA_URL', defaultValue: 'http://localhost:11434', requiresRestart: false }, + ollamaModel: { envKey: 'OLLAMA_MODEL', defaultValue: 'llama3.1:8b', requiresRestart: false }, + openaiModel: { envKey: 'OPENAI_MODEL', defaultValue: 'gpt-4o-mini', requiresRestart: false }, + fasterWhisperServerUrl: { envKey: 'FASTER_WHISPER_SERVER_URL', defaultValue: '', requiresRestart: false }, + whisperModel: { envKey: 'WHISPER_MODEL', defaultValue: 'large-v3', requiresRestart: false }, + mappedTalkGroups: { envKey: 'MAPPED_TALK_GROUPS', defaultValue: '', requiresRestart: false }, + enableMappedTalkGroups: { envKey: 'ENABLE_MAPPED_TALK_GROUPS', defaultValue: 'true', requiresRestart: false }, + summaryLookbackHours: { envKey: 'SUMMARY_LOOKBACK_HOURS', defaultValue: '1', requiresRestart: false }, + askAiLookbackHours: { envKey: 'ASK_AI_LOOKBACK_HOURS', defaultValue: '8', requiresRestart: false }, + maxConcurrentTranscriptions: { envKey: 'MAX_CONCURRENT_TRANSCRIPTIONS', defaultValue: '3', requiresRestart: true } +}; + +const SECRET_DEFINITIONS = { + discordToken: { envKey: 'DISCORD_TOKEN', requiresRestart: true }, + googleMapsApiKey: { envKey: 'GOOGLE_MAPS_API_KEY', requiresRestart: false }, + locationIqApiKey: { envKey: 'LOCATIONIQ_API_KEY', requiresRestart: false }, + openaiApiKey: { envKey: 'OPENAI_API_KEY', requiresRestart: false }, + icadApiKey: { envKey: 'ICAD_API_KEY', requiresRestart: false }, + s3AccessKeyId: { envKey: 'S3_ACCESS_KEY_ID', requiresRestart: true }, + s3SecretAccessKey: { envKey: 'S3_SECRET_ACCESS_KEY', requiresRestart: true }, + webserverPassword: { envKey: 'WEBSERVER_PASSWORD', requiresRestart: true }, + uploadApiKey: { envKey: 'SCANNER_MAP_UPLOAD_API_KEY', requiresRestart: false } +}; + +function run(db, sql, params = []) { + return new Promise((resolve, reject) => { + db.run(sql, params, function onRun(err) { + if (err) reject(err); + else resolve(this); + }); + }); +} + +function get(db, sql, params = []) { + return new Promise((resolve, reject) => { + db.get(sql, params, (err, row) => { + if (err) reject(err); + else resolve(row); + }); + }); +} + +function all(db, sql, params = []) { + return new Promise((resolve, reject) => { + db.all(sql, params, (err, rows) => { + if (err) reject(err); + else resolve(rows); + }); + }); +} + +function deriveKey(secret) { + return crypto.createHash('sha256').update(secret).digest(); +} + +function encryptSecret(plainText, secret) { + const iv = crypto.randomBytes(12); + const cipher = crypto.createCipheriv('aes-256-gcm', deriveKey(secret), iv); + const encrypted = Buffer.concat([cipher.update(String(plainText), 'utf8'), cipher.final()]); + const tag = cipher.getAuthTag(); + return JSON.stringify({ + v: 1, + iv: iv.toString('base64'), + tag: tag.toString('base64'), + data: encrypted.toString('base64') + }); +} + +function decryptSecret(payload, secret) { + const parsed = JSON.parse(payload); + const decipher = crypto.createDecipheriv('aes-256-gcm', deriveKey(secret), Buffer.from(parsed.iv, 'base64')); + decipher.setAuthTag(Buffer.from(parsed.tag, 'base64')); + return Buffer.concat([ + decipher.update(Buffer.from(parsed.data, 'base64')), + decipher.final() + ]).toString('utf8'); +} + +function getInstanceSecret(options = {}) { + if (options.env && options.env.SETTINGS_ENCRYPTION_KEY) { + return options.env.SETTINGS_ENCRYPTION_KEY; + } + + const dataDir = options.dataDir || path.join(__dirname, '..', '..', 'data'); + const secretPath = options.secretPath || path.join(dataDir, 'instance-secret.key'); + fs.mkdirSync(dataDir, { recursive: true }); + + if (fs.existsSync(secretPath)) { + return fs.readFileSync(secretPath, 'utf8').trim(); + } + + const generated = crypto.randomBytes(32).toString('hex'); + fs.writeFileSync(secretPath, `${generated}\n`, { mode: 0o600 }); + return generated; +} + +async function audit(db, eventType, settingKey, details = {}, actor = 'system') { + await run( + db, + 'INSERT INTO settings_audit_events (event_type, setting_key, actor, details_json) VALUES (?, ?, ?, ?)', + [eventType, settingKey || null, actor, JSON.stringify(details)] + ); +} + +async function getStoredSettings(db) { + const rows = await all(db, 'SELECT key, value, is_secret, requires_restart, updated_at FROM app_settings ORDER BY key'); + const settings = {}; + const secrets = {}; + + for (const row of rows) { + if (row.is_secret) { + secrets[row.key] = { + configured: Boolean(row.value), + source: 'sqlite', + requiresRestart: Boolean(row.requires_restart), + updatedAt: row.updated_at + }; + } else { + settings[row.key] = { + value: row.value, + source: 'sqlite', + requiresRestart: Boolean(row.requires_restart), + updatedAt: row.updated_at + }; + } + } + + return { settings, secrets }; +} + +async function resolveSettings(db, env = process.env) { + const stored = await getStoredSettings(db); + const settings = {}; + + for (const [key, definition] of Object.entries(SETTING_DEFINITIONS)) { + const storedValue = stored.settings[key]; + if (storedValue) { + settings[key] = storedValue; + } else if (env[definition.envKey] !== undefined && env[definition.envKey] !== '') { + settings[key] = { + value: env[definition.envKey], + source: 'env', + requiresRestart: definition.requiresRestart + }; + } else { + settings[key] = { + value: definition.defaultValue, + source: 'default', + requiresRestart: definition.requiresRestart + }; + } + } + + const secrets = {}; + for (const [key, definition] of Object.entries(SECRET_DEFINITIONS)) { + const storedSecret = stored.secrets[key]; + secrets[key] = storedSecret || { + configured: Boolean(env[definition.envKey]), + source: env[definition.envKey] ? 'env' : 'missing', + requiresRestart: definition.requiresRestart + }; + } + + return { settings, secrets }; +} + +async function saveSettings(db, values, actor = 'admin') { + const results = {}; + + for (const [key, value] of Object.entries(values || {})) { + const definition = SETTING_DEFINITIONS[key]; + if (!definition) { + results[key] = { ok: false, error: 'Unknown setting' }; + continue; + } + + await run( + db, + `INSERT INTO app_settings (key, value, is_secret, requires_restart, updated_at) + VALUES (?, ?, 0, ?, CURRENT_TIMESTAMP) + ON CONFLICT(key) DO UPDATE SET value = excluded.value, is_secret = 0, + requires_restart = excluded.requires_restart, updated_at = CURRENT_TIMESTAMP`, + [key, String(value), definition.requiresRestart ? 1 : 0] + ); + await audit(db, 'setting_updated', key, { requiresRestart: definition.requiresRestart }, actor); + results[key] = { ok: true, requiresRestart: definition.requiresRestart }; + } + + return results; +} + +async function saveSecret(db, key, value, options = {}) { + const definition = SECRET_DEFINITIONS[key]; + if (!definition) { + return { ok: false, error: 'Unknown secret' }; + } + + if (!value) { + return { ok: false, error: 'Secret value is required' }; + } + + const instanceSecret = getInstanceSecret({ env: options.env || process.env }); + await run( + db, + `INSERT INTO app_settings (key, value, is_secret, requires_restart, updated_at) + VALUES (?, ?, 1, ?, CURRENT_TIMESTAMP) + ON CONFLICT(key) DO UPDATE SET value = excluded.value, is_secret = 1, + requires_restart = excluded.requires_restart, updated_at = CURRENT_TIMESTAMP`, + [key, encryptSecret(value, instanceSecret), definition.requiresRestart ? 1 : 0] + ); + await audit(db, 'secret_updated', key, { requiresRestart: definition.requiresRestart }, options.actor || 'admin'); + + return { ok: true, configured: true, requiresRestart: definition.requiresRestart }; +} + +async function getSetupStatus(db, env = process.env) { + const resolved = await resolveSettings(db, env); + const setupRow = await get(db, 'SELECT value FROM setup_state WHERE key = ?', ['setup_complete']); + const adminRow = await get(db, 'SELECT COUNT(*) AS count FROM users WHERE username = ?', ['admin']).catch(() => ({ count: 0 })); + + const hasGeocoding = resolved.secrets.googleMapsApiKey.configured || resolved.secrets.locationIqApiKey.configured; + const missing = []; + if (!adminRow || adminRow.count === 0) missing.push('adminAccount'); + if (!resolved.secrets.uploadApiKey.configured) missing.push('uploadApiKey'); + if (!hasGeocoding) missing.push('geocodingProvider'); + if (!resolved.settings.transcriptionMode.value) missing.push('transcriptionMode'); + if (!resolved.settings.storageMode.value) missing.push('storageMode'); + + return { + setupRequired: setupRow?.value !== 'true' || missing.length > 0, + setupComplete: setupRow?.value === 'true' && missing.length === 0, + missing, + checks: { + adminAccount: Boolean(adminRow && adminRow.count > 0), + uploadApiKey: resolved.secrets.uploadApiKey.configured, + geocodingProvider: hasGeocoding, + transcriptionMode: Boolean(resolved.settings.transcriptionMode.value), + storageMode: Boolean(resolved.settings.storageMode.value) + }, + settings: resolved.settings, + secrets: resolved.secrets + }; +} + +async function markSetupComplete(db, actor = 'admin') { + await run( + db, + `INSERT INTO setup_state (key, value, updated_at) + VALUES ('setup_complete', 'true', CURRENT_TIMESTAMP) + ON CONFLICT(key) DO UPDATE SET value = 'true', updated_at = CURRENT_TIMESTAMP` + ); + await audit(db, 'setup_completed', 'setup_complete', {}, actor); +} + +module.exports = { + SECRET_DEFINITIONS, + SETTING_DEFINITIONS, + decryptSecret, + encryptSecret, + getInstanceSecret, + getSetupStatus, + resolveSettings, + saveSecret, + saveSettings, + markSetupComplete +}; diff --git a/src/setup/checks.js b/src/setup/checks.js new file mode 100644 index 0000000..8beaa4b --- /dev/null +++ b/src/setup/checks.js @@ -0,0 +1,94 @@ +const fs = require('fs'); +const path = require('path'); +const { execFile } = require('child_process'); + +function checkCommand(command, args = ['--version']) { + return new Promise((resolve) => { + execFile(command, args, { timeout: 5000 }, (error, stdout, stderr) => { + resolve({ + ok: !error, + command, + version: (stdout || stderr || '').split(/\r?\n/)[0].trim(), + error: error ? error.message : null + }); + }); + }); +} + +function commandHint(name) { + const isWindows = process.platform === 'win32'; + const hints = { + node: isWindows ? 'winget install OpenJS.NodeJS.LTS' : 'sudo apt-get install -y nodejs npm', + python: isWindows ? 'winget install Python.Python.3.11' : 'sudo apt-get install -y python3 python3-venv python3-pip', + ffmpeg: isWindows ? 'winget install Gyan.FFmpeg' : 'sudo apt-get install -y ffmpeg', + ollama: isWindows ? 'winget install Ollama.Ollama' : 'curl -fsSL https://ollama.com/install.sh | sh' + }; + return hints[name] || ''; +} + +function checkWritableDir(dirPath) { + try { + fs.mkdirSync(dirPath, { recursive: true }); + const testFile = path.join(dirPath, `.write-test-${Date.now()}`); + fs.writeFileSync(testFile, 'ok'); + fs.unlinkSync(testFile); + return { ok: true, path: dirPath }; + } catch (error) { + return { ok: false, path: dirPath, error: error.message }; + } +} + +async function runSetupChecks(options = {}) { + const rootDir = options.rootDir || path.join(__dirname, '..', '..'); + const env = options.env || process.env; + const [node, python, ffmpeg, ollama] = await Promise.all([ + checkCommand(process.execPath, ['--version']), + checkCommand(env.PYTHON_COMMAND || (process.platform === 'win32' ? 'py' : 'python3'), ['--version']), + checkCommand('ffmpeg', ['-version']), + checkCommand('ollama', ['--version']) + ]); + + const checks = { + node: { ...node, installCommand: commandHint('node') }, + python: { ...python, installCommand: commandHint('python') }, + ffmpeg: { ...ffmpeg, installCommand: commandHint('ffmpeg') }, + ollama: { ...ollama, optional: true, installCommand: commandHint('ollama') }, + cuda: { ok: false, optional: true, command: 'nvidia-smi', installCommand: 'Install NVIDIA drivers, CUDA Toolkit, cuDNN, and compatible PyTorch wheels.' }, + dataDir: checkWritableDir(path.join(rootDir, 'data')), + audioDir: checkWritableDir(path.join(rootDir, 'audio')), + geocodingProvider: { + ok: Boolean(env.GOOGLE_MAPS_API_KEY || env.LOCATIONIQ_API_KEY), + configuredProviders: { + google: Boolean(env.GOOGLE_MAPS_API_KEY), + locationiq: Boolean(env.LOCATIONIQ_API_KEY) + } + }, + transcriptionProvider: { + ok: Boolean(env.TRANSCRIPTION_MODE || 'local'), + mode: env.TRANSCRIPTION_MODE || 'local' + }, + aiProvider: { + ok: Boolean(env.AI_PROVIDER || 'ollama'), + provider: env.AI_PROVIDER || 'ollama' + }, + uploadEndpoint: { + ok: true, + url: `/api/call-upload` + } + }; + + checks.cuda = await checkCommand('nvidia-smi', ['--query-gpu=name', '--format=csv,noheader']).then((result) => ({ + ...checks.cuda, + ok: result.ok, + version: result.version, + error: result.error + })); + + return checks; +} + +module.exports = { + checkCommand, + checkWritableDir, + runSetupChecks +}; diff --git a/test/migrations.test.js b/test/migrations.test.js index 3c1fd3e..8974ef9 100644 --- a/test/migrations.test.js +++ b/test/migrations.test.js @@ -6,13 +6,13 @@ const { getMigrationPlan } = require('../src/db/migrations'); test('migration plan includes core tables by default', () => { assert.deepEqual( getMigrationPlan({ enableAuth: false }).map((migration) => migration.id), - ['001_create_core_tables', '003_create_call_jobs'] + ['001_create_core_tables', '003_create_call_jobs', '004_create_app_settings'] ); }); test('migration plan includes auth tables when auth is enabled', () => { assert.deepEqual( getMigrationPlan({ enableAuth: true }).map((migration) => migration.id), - ['001_create_core_tables', '002_create_auth_tables', '003_create_call_jobs'] + ['001_create_core_tables', '002_create_auth_tables', '003_create_call_jobs', '004_create_app_settings'] ); }); diff --git a/test/processingJobs.test.js b/test/processingJobs.test.js index 14d1984..bcc966b 100644 --- a/test/processingJobs.test.js +++ b/test/processingJobs.test.js @@ -4,6 +4,8 @@ const assert = require('node:assert/strict'); const { JOB_STATUS, JOB_TYPES, + getRecentJobs, + getJobSummary, parseJson, serializeJson } = require('../src/jobs/processingJobs'); @@ -23,3 +25,43 @@ test('serializeJson and parseJson preserve payload objects', () => { test('parseJson returns fallback for invalid JSON', () => { assert.deepEqual(parseJson('{bad json', { ok: false }), { ok: false }); }); + +test('getJobSummary groups rows by job type and status', async () => { + const rows = [ + { job_type: JOB_TYPES.TRANSCRIPTION, status: JOB_STATUS.PENDING, count: 2 }, + { job_type: JOB_TYPES.TRANSCRIPTION, status: JOB_STATUS.COMPLETED, count: 1 } + ]; + const db = { + all(sql, params, callback) { + callback(null, rows); + } + }; + + const summary = await getJobSummary(db); + + assert.equal(summary.totals.transcription.pending, 2); + assert.equal(summary.totals.transcription.completed, 1); + assert.deepEqual(summary.rows, rows); +}); + +test('getRecentJobs clamps limit and parses payload/result JSON', async () => { + const db = { + all(sql, params, callback) { + assert.equal(params.at(-1), 200); + callback(null, [{ + id: 7, + transcription_id: 42, + job_type: JOB_TYPES.TRANSCRIPTION, + status: JOB_STATUS.COMPLETED, + payload_json: '{"mode":"local"}', + result_json: '{"empty":false}' + }]); + } + }; + + const jobs = await getRecentJobs(db, { limit: 999 }); + + assert.equal(jobs[0].id, 7); + assert.deepEqual(jobs[0].payload, { mode: 'local' }); + assert.deepEqual(jobs[0].result, { empty: false }); +}); diff --git a/test/settingsService.test.js b/test/settingsService.test.js new file mode 100644 index 0000000..ce572b4 --- /dev/null +++ b/test/settingsService.test.js @@ -0,0 +1,100 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { + decryptSecret, + encryptSecret, + getSetupStatus, + resolveSettings +} = require('../src/settings/settingsService'); + +function createFakeDb({ settingsRows = [], setupComplete = false, adminCount = 0 } = {}) { + return { + all(sql, params, callback) { + callback(null, settingsRows); + }, + get(sql, params, callback) { + if (sql.includes('setup_state')) { + callback(null, setupComplete ? { value: 'true' } : undefined); + return; + } + if (sql.includes('COUNT(*) AS count FROM users')) { + callback(null, { count: adminCount }); + return; + } + callback(null, undefined); + } + }; +} + +test('resolveSettings prefers SQLite settings over env and defaults', async () => { + const db = createFakeDb({ + settingsRows: [ + { key: 'timezone', value: 'America/Chicago', is_secret: 0, requires_restart: 0, updated_at: 'now' } + ] + }); + + const resolved = await resolveSettings(db, { + TIMEZONE: 'US/Eastern', + PUBLIC_DOMAIN: 'scanner.example' + }); + + assert.equal(resolved.settings.timezone.value, 'America/Chicago'); + assert.equal(resolved.settings.timezone.source, 'sqlite'); + assert.equal(resolved.settings.publicDomain.value, 'scanner.example'); + assert.equal(resolved.settings.publicDomain.source, 'env'); + assert.equal(resolved.settings.storageMode.value, 'local'); + assert.equal(resolved.settings.storageMode.source, 'default'); +}); + +test('resolveSettings redacts write-only secret values', async () => { + const db = createFakeDb({ + settingsRows: [ + { key: 'openaiApiKey', value: 'encrypted-payload', is_secret: 1, requires_restart: 0, updated_at: 'now' } + ] + }); + + const resolved = await resolveSettings(db, {}); + + assert.equal(resolved.secrets.openaiApiKey.configured, true); + assert.equal(resolved.secrets.openaiApiKey.source, 'sqlite'); + assert.equal(Object.hasOwn(resolved.secrets.openaiApiKey, 'value'), false); +}); + +test('encryptSecret and decryptSecret round trip secret values', () => { + const secret = 'local-instance-secret'; + const encrypted = encryptSecret('api-key-value', secret); + + assert.notEqual(encrypted, 'api-key-value'); + assert.equal(decryptSecret(encrypted, secret), 'api-key-value'); +}); + +test('getSetupStatus reports incomplete setup requirements', async () => { + const db = createFakeDb({ setupComplete: false, adminCount: 0 }); + const status = await getSetupStatus(db, {}); + + assert.equal(status.setupRequired, true); + assert.equal(status.setupComplete, false); + assert.ok(status.missing.includes('adminAccount')); + assert.ok(status.missing.includes('uploadApiKey')); + assert.ok(status.missing.includes('geocodingProvider')); +}); + +test('getSetupStatus accepts configured essentials', async () => { + const db = createFakeDb({ + setupComplete: true, + adminCount: 1, + settingsRows: [ + { key: 'uploadApiKey', value: 'encrypted', is_secret: 1, requires_restart: 0, updated_at: 'now' }, + { key: 'googleMapsApiKey', value: 'encrypted', is_secret: 1, requires_restart: 0, updated_at: 'now' }, + { key: 'transcriptionMode', value: 'local', is_secret: 0, requires_restart: 1, updated_at: 'now' }, + { key: 'storageMode', value: 'local', is_secret: 0, requires_restart: 1, updated_at: 'now' } + ] + }); + + const status = await getSetupStatus(db, {}); + + assert.equal(status.setupRequired, false); + assert.equal(status.setupComplete, true); + assert.deepEqual(status.missing, []); +}); diff --git a/test/setupChecks.test.js b/test/setupChecks.test.js new file mode 100644 index 0000000..d24ad02 --- /dev/null +++ b/test/setupChecks.test.js @@ -0,0 +1,18 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +const { checkWritableDir } = require('../src/setup/checks'); + +test('checkWritableDir creates and verifies writable directories', () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'scanner-map-check-')); + const nested = path.join(tempDir, 'data'); + + const result = checkWritableDir(nested); + + assert.equal(result.ok, true); + assert.equal(fs.existsSync(nested), true); + fs.rmSync(tempDir, { recursive: true, force: true }); +}); diff --git a/webserver.js b/webserver.js index e24e14f..587a4ed 100644 --- a/webserver.js +++ b/webserver.js @@ -2,6 +2,16 @@ require('dotenv').config(); const { loadConfig } = require('./src/config'); +const { applyMigrations } = require('./src/db/migrations'); +const { getJobSummary, getRecentJobs } = require('./src/jobs/processingJobs'); +const { + getSetupStatus, + markSetupComplete, + resolveSettings, + saveSecret, + saveSettings +} = require('./src/settings/settingsService'); +const { runSetupChecks } = require('./src/setup/checks'); const AWS = require('aws-sdk'); // Add AWS SDK const express = require('express'); @@ -45,27 +55,24 @@ const { const startupConfig = loadConfig(process.env); if (!startupConfig.isValid) { - console.error('ERROR: Invalid configuration:'); + console.warn('WARNING: Configuration has issues. Setup mode will remain available:'); for (const error of startupConfig.errors) { - console.error(`- ${error.key}: ${error.message}`); + console.warn(`- ${error.key}: ${error.message}`); } - process.exit(1); } - -// Validate required environment variables -const requiredVars = ['WEBSERVER_PORT', 'PUBLIC_DOMAIN']; -const missingVars = requiredVars.filter(varName => !process.env[varName]); - -if (missingVars.length > 0) { - console.error(`ERROR: Missing required environment variables: ${missingVars.join(', ')}`); - process.exit(1); -} - -// Check for at least one geocoding API key -if (!GOOGLE_MAPS_API_KEY && !LOCATIONIQ_API_KEY) { - console.error('ERROR: At least one geocoding API key is required (GOOGLE_MAPS_API_KEY or LOCATIONIQ_API_KEY)'); - process.exit(1); -} + +// Validate required environment variables +const requiredVars = ['WEBSERVER_PORT', 'PUBLIC_DOMAIN']; +const missingVars = requiredVars.filter(varName => !process.env[varName]); + +if (missingVars.length > 0) { + console.warn(`WARNING: Missing environment variables: ${missingVars.join(', ')}. Setup mode will remain available.`); +} + +// Check for at least one geocoding API key +if (!GOOGLE_MAPS_API_KEY && !LOCATIONIQ_API_KEY) { + console.warn('WARNING: No geocoding API key configured yet. Use /setup to configure Google Maps or LocationIQ.'); +} // Log geocoding API availability if (GOOGLE_MAPS_API_KEY) { @@ -125,23 +132,23 @@ app.get('/api/test', (req, res) => { // --- NEW: S3 Client Setup --- let s3 = null; -if (STORAGE_MODE === 's3') { - if (!S3_ENDPOINT || !S3_BUCKET_NAME || !S3_ACCESS_KEY_ID || !S3_SECRET_ACCESS_KEY) { - console.error('FATAL: STORAGE_MODE is s3, but required S3 environment variables are missing! Check webserver .env'); - process.exit(1); // Exit if S3 config is incomplete - } - AWS.config.update({ - accessKeyId: S3_ACCESS_KEY_ID, - secretAccessKey: S3_SECRET_ACCESS_KEY, - endpoint: S3_ENDPOINT, - s3ForcePathStyle: true, // Necessary for MinIO/non-AWS S3 - signatureVersion: 'v4' - }); - s3 = new AWS.S3(); - console.log(`[Webserver] Storage mode set to S3. Endpoint: ${S3_ENDPOINT}, Bucket: ${S3_BUCKET_NAME}`); -} else { - console.log('[Webserver] Storage mode set to local.'); -} +if (STORAGE_MODE === 's3') { + if (!S3_ENDPOINT || !S3_BUCKET_NAME || !S3_ACCESS_KEY_ID || !S3_SECRET_ACCESS_KEY) { + console.warn('WARNING: STORAGE_MODE=s3, but S3 configuration is incomplete. Audio serving from S3 will be unavailable until setup is completed.'); + } else { + AWS.config.update({ + accessKeyId: S3_ACCESS_KEY_ID, + secretAccessKey: S3_SECRET_ACCESS_KEY, + endpoint: S3_ENDPOINT, + s3ForcePathStyle: true, // Necessary for MinIO/non-AWS S3 + signatureVersion: 'v4' + }); + s3 = new AWS.S3(); + console.log(`[Webserver] Storage mode set to S3. Endpoint: ${S3_ENDPOINT}, Bucket: ${S3_BUCKET_NAME}`); + } +} else { + console.log('[Webserver] Storage mode set to local.'); +} // Authentication is enabled if ENABLE_AUTH=true const authEnabled = ENABLE_AUTH?.toLowerCase() === 'true'; @@ -156,51 +163,31 @@ const server = http.createServer(app); const io = socketIo(server); // Database setup -const db = new sqlite3.Database('./botdata.db', sqlite3.OPEN_READWRITE, (err) => { - if (err) { - console.error('Error opening database', err.message); - } else { +const db = new sqlite3.Database('./botdata.db', (err) => { + if (err) { + console.error('Error opening database', err.message); + } else { console.log('Connected to the SQLite database.'); - } -}); - -db.run(`ALTER TABLE transcriptions ADD COLUMN category TEXT`, err => { - // Ignore error if column already exists - if (!err || err.message.includes('duplicate column name')) { - console.log('Category column exists or was created successfully'); - } -}); - -// Create authentication tables if authentication is enabled -if (authEnabled) { - db.serialize(() => { - // Users table - db.run(` - CREATE TABLE IF NOT EXISTS users ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - username TEXT UNIQUE NOT NULL, - password_hash TEXT NOT NULL, - salt TEXT NOT NULL, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP - ) - `); - - // Sessions table - db.run(` - CREATE TABLE IF NOT EXISTS sessions ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL, - token TEXT UNIQUE NOT NULL, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - expires_at DATETIME NOT NULL, - last_activity DATETIME DEFAULT CURRENT_TIMESTAMP, - ip_address TEXT, - user_agent TEXT, - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE - ) - `); - }); -} + } +}); + +const dbReady = applyMigrations(db, { enableAuth: true }) + .then((applied) => { + if (applied.length > 0) { + console.log(`[Webserver] Applied migrations: ${applied.join(', ')}`); + } + return new Promise((resolve) => { + db.run(`ALTER TABLE transcriptions ADD COLUMN category TEXT`, err => { + if (!err || err.message.includes('duplicate column name')) { + console.log('Category column exists or was created successfully'); + } + resolve(); + }); + }); + }) + .catch((err) => { + console.error('[Webserver] Error initializing database schema:', err); + }); // Helper Functions for Authentication function hashPassword(password, salt) { @@ -685,7 +672,7 @@ async function serveAudioFromDb(res, transcriptionId) { } // Public Routes (No Auth Required) -app.get('/audio/:id', async (req, res) => { +app.get('/audio/:id', async (req, res) => { const transcriptionId = req.params.id; try { @@ -729,11 +716,133 @@ app.get('/audio/:id', async (req, res) => { } catch (dbErr) { console.error('[Audio Request] Database error:', dbErr); return res.status(500).send('Internal Server Error'); - } -}); - -// Apply authentication middleware to protected routes if auth is enabled -app.use(basicAuth); + } +}); + +app.get('/setup', (req, res) => { + res.sendFile(path.join(__dirname, 'public', 'setup.html')); +}); + +app.get('/settings', basicAuth, (req, res) => { + res.sendFile(path.join(__dirname, 'public', 'settings.html')); +}); + +app.get('/api/setup/status', async (req, res) => { + await dbReady; + try { + const status = await getSetupStatus(db, process.env); + res.json(status); + } catch (err) { + console.error('Error fetching setup status:', err); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +app.get('/api/setup/checks', async (req, res) => { + await dbReady; + try { + const checks = await runSetupChecks({ rootDir: __dirname, env: process.env }); + res.json(checks); + } catch (err) { + console.error('Error running setup checks:', err); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +app.post('/api/setup/admin', async (req, res) => { + await dbReady; + const { username = 'admin', password } = req.body || {}; + if (username !== 'admin') { + return res.status(400).json({ error: 'The first setup user must be admin.' }); + } + if (!password || password.length < 8) { + return res.status(400).json({ error: 'Admin password must be at least 8 characters.' }); + } + + try { + const existing = await new Promise((resolve, reject) => { + db.get('SELECT id FROM users WHERE username = ?', ['admin'], (err, row) => err ? reject(err) : resolve(row)); + }); + const salt = crypto.randomBytes(16).toString('hex'); + const passwordHash = hashPassword(password, salt); + + if (existing) { + db.run('UPDATE users SET password_hash = ?, salt = ? WHERE username = ?', [passwordHash, salt, 'admin'], (err) => { + if (err) return res.status(500).json({ error: 'Failed to update admin user' }); + res.json({ ok: true, updated: true }); + }); + } else { + db.run('INSERT INTO users (username, password_hash, salt) VALUES (?, ?, ?)', ['admin', passwordHash, salt], (err) => { + if (err) return res.status(500).json({ error: 'Failed to create admin user' }); + res.json({ ok: true, created: true }); + }); + } + } catch (err) { + console.error('Error creating setup admin:', err); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +app.post('/api/setup/settings', async (req, res) => { + await dbReady; + try { + const result = await saveSettings(db, req.body || {}, 'setup'); + res.json({ ok: true, result }); + } catch (err) { + console.error('Error saving setup settings:', err); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +app.post('/api/setup/secrets', async (req, res) => { + await dbReady; + const { key, value } = req.body || {}; + try { + const result = await saveSecret(db, key, value, { actor: 'setup', env: process.env }); + if (!result.ok) return res.status(400).json({ error: result.error }); + res.json(result); + } catch (err) { + console.error('Error saving setup secret:', err); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +app.post('/api/setup/test-provider', async (req, res) => { + await dbReady; + const { provider } = req.body || {}; + try { + const checks = await runSetupChecks({ rootDir: __dirname, env: process.env }); + const providerMap = { + geocoding: checks.geocodingProvider, + transcription: checks.transcriptionProvider, + ai: checks.aiProvider, + storage: checks.dataDir, + upload: checks.uploadEndpoint + }; + res.json(providerMap[provider] || { ok: false, error: 'Unknown provider test' }); + } catch (err) { + console.error('Error testing provider:', err); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +app.post('/api/setup/complete', async (req, res) => { + await dbReady; + try { + const status = await getSetupStatus(db, process.env); + if (status.missing.length > 0) { + return res.status(400).json({ error: 'Setup is incomplete', missing: status.missing }); + } + await markSetupComplete(db, 'setup'); + res.json({ ok: true, setupComplete: true }); + } catch (err) { + console.error('Error completing setup:', err); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +// Apply authentication middleware to protected routes if auth is enabled +app.use(basicAuth); // Serve static files from the 'public' directory app.use(express.static(path.join(__dirname, 'public'))); @@ -801,7 +910,7 @@ app.delete('/api/sessions/:token', adminAuth, (req, res) => { ); }); -app.get('/api/sessions/me', (req, res) => { +app.get('/api/sessions/me', (req, res) => { if (!authEnabled) { return res.json([]); } @@ -819,11 +928,85 @@ app.get('/api/sessions/me', (req, res) => { } res.json(sessions); } - ); -}); - -// User Management Routes (Admin Only when auth is enabled) -app.post('/api/users', adminAuth, async (req, res) => { + ); +}); + +// Processing Job Diagnostics Routes (Admin Only when auth is enabled) +app.get('/api/jobs/summary', adminAuth, async (req, res) => { + try { + const summary = await getJobSummary(db); + res.json(summary); + } catch (err) { + console.error('Error fetching job summary:', err); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +app.get('/api/jobs/recent', adminAuth, async (req, res) => { + try { + const jobs = await getRecentJobs(db, { + limit: req.query.limit, + status: req.query.status, + jobType: req.query.jobType + }); + res.json(jobs); + } catch (err) { + console.error('Error fetching recent jobs:', err); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +app.get('/api/settings', adminAuth, async (req, res) => { + await dbReady; + try { + const settings = await resolveSettings(db, process.env); + res.json(settings); + } catch (err) { + console.error('Error fetching settings:', err); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +app.put('/api/settings', adminAuth, async (req, res) => { + await dbReady; + try { + const result = await saveSettings(db, req.body || {}, req.user?.username || 'admin'); + const requiresRestart = Object.values(result).some((item) => item.requiresRestart); + res.json({ ok: true, result, requiresRestart }); + } catch (err) { + console.error('Error updating settings:', err); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +app.put('/api/settings/secrets/:key', adminAuth, async (req, res) => { + await dbReady; + try { + const result = await saveSecret(db, req.params.key, req.body?.value, { + actor: req.user?.username || 'admin', + env: process.env + }); + if (!result.ok) return res.status(400).json({ error: result.error }); + res.json(result); + } catch (err) { + console.error('Error updating secret:', err); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +app.get('/api/settings/checks', adminAuth, async (req, res) => { + await dbReady; + try { + const checks = await runSetupChecks({ rootDir: __dirname, env: process.env }); + res.json(checks); + } catch (err) { + console.error('Error running settings checks:', err); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +// User Management Routes (Admin Only when auth is enabled) +app.post('/api/users', adminAuth, async (req, res) => { if (!authEnabled) { return res.status(400).json({ error: 'Authentication is disabled' }); }