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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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
+
+
+
+
+
+
+
+
+
+
+
+
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.
+
+
+
+
+
+
+
+
+
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 = ``;
+}
+
+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' });
}