@logboard/cli 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/.env.example +37 -0
  2. package/README.md +200 -0
  3. package/bin/logboard +536 -0
  4. package/client/logger.js +309 -0
  5. package/config/index.js +142 -0
  6. package/config.js +2 -0
  7. package/controllers/AnalyticsController.js +46 -0
  8. package/controllers/ApiAnalyticsController.js +129 -0
  9. package/controllers/ApiKeyController.js +58 -0
  10. package/controllers/AuthController.js +131 -0
  11. package/controllers/HealthController.js +56 -0
  12. package/controllers/LogController.js +197 -0
  13. package/controllers/OrgController.js +152 -0
  14. package/controllers/RoleConfigController.js +20 -0
  15. package/controllers/SettingsController.js +39 -0
  16. package/controllers/StreamController.js +55 -0
  17. package/controllers/UiController.js +789 -0
  18. package/controllers/UserController.js +79 -0
  19. package/lib/batchWriter.js +57 -0
  20. package/lib/cleanup.js +67 -0
  21. package/lib/ejs.js +103 -0
  22. package/lib/emitter.js +5 -0
  23. package/lib/healthMonitor.js +245 -0
  24. package/lib/logger.js +21 -0
  25. package/lib/streams.js +32 -0
  26. package/lib/theme.js +77 -0
  27. package/lib/userStore.js +13 -0
  28. package/lib/utils.js +44 -0
  29. package/middleware/apiKey.js +82 -0
  30. package/middleware/auth.js +55 -0
  31. package/middleware/ipWhitelist.js +59 -0
  32. package/middleware/org.js +85 -0
  33. package/middleware/pageAccess.js +20 -0
  34. package/middleware/rateLimit.js +29 -0
  35. package/middleware/roles.js +11 -0
  36. package/package.json +77 -0
  37. package/routes/alerts.js +18 -0
  38. package/routes/analytics.js +26 -0
  39. package/routes/api-analytics.js +30 -0
  40. package/routes/api-keys.js +12 -0
  41. package/routes/archive.js +91 -0
  42. package/routes/audit.js +50 -0
  43. package/routes/auth.js +22 -0
  44. package/routes/bookmarks.js +13 -0
  45. package/routes/health.js +11 -0
  46. package/routes/logs.js +88 -0
  47. package/routes/metrics.js +66 -0
  48. package/routes/notifications.js +14 -0
  49. package/routes/orgs.js +98 -0
  50. package/routes/registration.js +202 -0
  51. package/routes/role-config.js +97 -0
  52. package/routes/saved-searches.js +12 -0
  53. package/routes/server.js +151 -0
  54. package/routes/settings.js +28 -0
  55. package/routes/status.js +21 -0
  56. package/routes/stream.js +11 -0
  57. package/routes/super.js +129 -0
  58. package/routes/ui.js +120 -0
  59. package/routes/users.js +13 -0
  60. package/server.js +172 -0
  61. package/services/AlertRulesService.js +323 -0
  62. package/services/AnalyticsService.js +665 -0
  63. package/services/ApiAnalyticsService.js +471 -0
  64. package/services/ApiKeyService.js +166 -0
  65. package/services/AuditService.js +249 -0
  66. package/services/AuthService.js +234 -0
  67. package/services/BookmarkService.js +49 -0
  68. package/services/GlobalSettingsService.js +44 -0
  69. package/services/LogService.js +1066 -0
  70. package/services/MetricsService.js +116 -0
  71. package/services/NotificationService.js +70 -0
  72. package/services/OrgService.js +217 -0
  73. package/services/ReportService.js +247 -0
  74. package/services/RoleConfigService.js +201 -0
  75. package/services/SavedSearchService.js +63 -0
  76. package/services/SettingsService.js +220 -0
  77. package/services/UserService.js +121 -0
  78. package/setup.js +132 -0
  79. package/views/404.ejs +8 -0
  80. package/views/alerts.ejs +190 -0
  81. package/views/analytics.ejs +209 -0
  82. package/views/api-analytics.ejs +660 -0
  83. package/views/api-keys.ejs +150 -0
  84. package/views/archive.ejs +123 -0
  85. package/views/audit.ejs +314 -0
  86. package/views/bookmarks.ejs +54 -0
  87. package/views/custom-dashboard.ejs +162 -0
  88. package/views/dashboard.ejs +186 -0
  89. package/views/diff.ejs +98 -0
  90. package/views/health.ejs +269 -0
  91. package/views/heatmap.ejs +126 -0
  92. package/views/insights.ejs +334 -0
  93. package/views/invite.ejs +74 -0
  94. package/views/live.ejs +299 -0
  95. package/views/login.ejs +64 -0
  96. package/views/logo.png +0 -0
  97. package/views/logs.ejs +754 -0
  98. package/views/notifications.ejs +58 -0
  99. package/views/partials/head.ejs +282 -0
  100. package/views/partials/sidebar.ejs +168 -0
  101. package/views/register.ejs +100 -0
  102. package/views/roles.ejs +279 -0
  103. package/views/saved-searches.ejs +51 -0
  104. package/views/service-map.ejs +142 -0
  105. package/views/settings.ejs +1159 -0
  106. package/views/sidebar.ejs +129 -0
  107. package/views/status.ejs +100 -0
  108. package/views/super-admin-admins.ejs +58 -0
  109. package/views/super-admin-analytics.ejs +49 -0
  110. package/views/super-admin-orgs.ejs +310 -0
  111. package/views/super-admin-profile.ejs +77 -0
  112. package/views/super-admin-settings.ejs +108 -0
  113. package/views/super-admin-system.ejs +46 -0
  114. package/views/users.ejs +153 -0
@@ -0,0 +1,201 @@
1
+ 'use strict';
2
+ const fsP = require('fs').promises;
3
+
4
+ const ALL_PAGES = [
5
+ { id: 'dashboard', label: 'Dashboard' },
6
+ { id: 'logs', label: 'Logs' },
7
+ { id: 'live', label: 'Live Stream' },
8
+ { id: 'insights', label: 'Insights' },
9
+ { id: 'health', label: 'Health' },
10
+ { id: 'users', label: 'User Management', adminOnly: true },
11
+ { id: 'api-keys', label: 'API Keys', adminOnly: true },
12
+ { id: 'settings', label: 'Settings', adminOnly: true },
13
+ { id: 'roles', label: 'Role Config', adminOnly: true },
14
+ { id: 'alerts', label: 'Alert Rules', adminOnly: true },
15
+ { id: 'audit', label: 'Audit Log', adminOnly: true },
16
+ { id: 'archive', label: 'Log Archive', adminOnly: true },
17
+ { id: 'status', label: 'Public Status', alwaysOn: true },
18
+ ];
19
+
20
+ const ALL_CARDS = [
21
+ { id: 'stat-logs', label: 'Logs Today', section: 'Dashboard Stats' },
22
+ { id: 'stat-errors', label: 'Errors Today', section: 'Dashboard Stats' },
23
+ { id: 'stat-services', label: 'Services Count', section: 'Dashboard Stats' },
24
+ { id: 'stat-errorrate', label: 'Error Rate', section: 'Dashboard Stats' },
25
+ { id: 'chart-hourly', label: 'Hourly Volume', section: 'Dashboard Charts' },
26
+ { id: 'chart-services', label: 'Top Services', section: 'Dashboard Charts' },
27
+ { id: 'recent-errors', label: 'Recent Errors', section: 'Dashboard Tables' },
28
+ { id: 'ins-stats', label: 'Log Level Stats', section: 'Insights - Logs' },
29
+ { id: 'ins-hourly', label: 'Log Hourly Chart', section: 'Insights - Logs' },
30
+ { id: 'ins-donut', label: 'Level Distribution', section: 'Insights - Logs' },
31
+ { id: 'ins-trend', label: '7-Day Trend', section: 'Insights - Logs' },
32
+ {
33
+ id: 'ins-services',
34
+ label: 'Top Services Table',
35
+ section: 'Insights - Logs',
36
+ },
37
+ { id: 'api-stats', label: 'API Stat Cards', section: 'Insights - API' },
38
+ { id: 'api-hourly', label: 'API Hourly Chart', section: 'Insights - API' },
39
+ { id: 'api-status', label: 'Status Distribution', section: 'Insights - API' },
40
+ { id: 'api-slowest', label: 'Slowest Endpoints', section: 'Insights - API' },
41
+ { id: 'api-endpoints', label: 'Endpoints Table', section: 'Insights - API' },
42
+ { id: 'api-errors', label: 'Error Hot-spots', section: 'Insights - API' },
43
+ { id: 'api-trend', label: 'Trend Analysis', section: 'Insights - API' },
44
+ ];
45
+
46
+ const BUILT_IN = {
47
+ admin: {
48
+ label: 'Administrator',
49
+ color: '#6366f1',
50
+ isBuiltIn: true,
51
+ pages: ALL_PAGES.map((p) => p.id),
52
+ cards: ALL_CARDS.map((c) => c.id),
53
+ },
54
+ viewer: {
55
+ label: 'Viewer',
56
+ color: '#22c55e',
57
+ isBuiltIn: true,
58
+ pages: ['dashboard', 'logs', 'live', 'insights'],
59
+ cards: [
60
+ 'stat-logs',
61
+ 'stat-errors',
62
+ 'chart-hourly',
63
+ 'recent-errors',
64
+ 'ins-stats',
65
+ 'ins-hourly',
66
+ 'ins-donut',
67
+ 'ins-trend',
68
+ ],
69
+ },
70
+ };
71
+
72
+ class RoleConfigService {
73
+ constructor (org) {
74
+ this._org = org || null;
75
+ this._rolesFile
76
+ = org && org.rolesFile ? org.rolesFile : require('../config').ROLES_FILE;
77
+ }
78
+
79
+ static ALL_PAGES = ALL_PAGES;
80
+ static ALL_CARDS = ALL_CARDS;
81
+
82
+ async getRoles () {
83
+ try {
84
+ const file = JSON.parse(await fsP.readFile(this._rolesFile, 'utf8'));
85
+ // Merge built-ins with file (file can override built-in pages/cards)
86
+ const merged = { ...BUILT_IN };
87
+ for (const [name, def] of Object.entries(file)) {
88
+ merged[name] = { ...BUILT_IN[name], ...def };
89
+ }
90
+ return merged;
91
+ } catch (e) {
92
+ if (e.code === 'ENOENT') {
93
+ return { ...BUILT_IN };
94
+ }
95
+ throw e;
96
+ }
97
+ }
98
+
99
+ async getRoleConfig (role) {
100
+ const roles = await this.getRoles();
101
+ return roles[role] || roles.viewer;
102
+ }
103
+
104
+ async canAccessPage (role, pageId) {
105
+ const cfg = await this.getRoleConfig(role);
106
+ return Array.isArray(cfg.pages) && cfg.pages.includes(pageId);
107
+ }
108
+
109
+ async getAllowedPages (role) {
110
+ const cfg = await this.getRoleConfig(role);
111
+ return Array.isArray(cfg.pages) ? cfg.pages : [];
112
+ }
113
+
114
+ async getAllowedCards (role) {
115
+ const cfg = await this.getRoleConfig(role);
116
+ return Array.isArray(cfg.cards) ? cfg.cards : [];
117
+ }
118
+
119
+ async saveRoles (roles) {
120
+ await fsP.writeFile(
121
+ this._rolesFile,
122
+ JSON.stringify(roles, null, 2),
123
+ 'utf8',
124
+ );
125
+ return roles;
126
+ }
127
+
128
+ async upsertRole (name, def) {
129
+ if (!name || !/^[a-zA-Z0-9_-]{2,32}$/.test(name)) {
130
+ throw Object.assign(new Error('Role name 2-32 chars alphanumeric/_-'), {
131
+ status: 400,
132
+ });
133
+ }
134
+ const roles = await this.getRoles();
135
+ const existing = roles[name] || {};
136
+ roles[name] = {
137
+ ...existing,
138
+ label: def.label || existing.label || name,
139
+ color: def.color || existing.color || '#6b7280',
140
+ isBuiltIn: existing.isBuiltIn || false,
141
+ pages: Array.isArray(def.pages) ? def.pages : existing.pages || [],
142
+ cards: Array.isArray(def.cards) ? def.cards : existing.cards || [],
143
+ allowedApps: Array.isArray(def.allowedApps)
144
+ ? def.allowedApps
145
+ : existing.allowedApps || [],
146
+ };
147
+ await this.saveRoles(roles);
148
+ return roles[name];
149
+ }
150
+
151
+ async deleteRole (name) {
152
+ if (name === 'admin' || name === 'viewer') {
153
+ throw Object.assign(new Error('Cannot delete built-in roles'), {
154
+ status: 400,
155
+ });
156
+ }
157
+ const roles = await this.getRoles();
158
+ if (!roles[name]) {
159
+ throw Object.assign(new Error('Role not found'), { status: 404 });
160
+ }
161
+ delete roles[name];
162
+ await this.saveRoles(roles);
163
+ }
164
+
165
+ /**
166
+ * Returns the list of appNames a role can access in Logs.
167
+ * Empty array = can see ALL apps (default for admin/viewer).
168
+ */
169
+ async getAllowedApps (role) {
170
+ const roles = await this.getRoles();
171
+ const r = roles[role];
172
+ if (!r) {
173
+ return [];
174
+ } // unknown role → no access
175
+ if (role === 'admin' || role === 'super-admin') {
176
+ return [];
177
+ } // admins see all
178
+ return Array.isArray(r.allowedApps) ? r.allowedApps : [];
179
+ }
180
+
181
+ /**
182
+ * Set the allowed apps for a role (empty = all).
183
+ */
184
+ async setAllowedApps (roleName, appNames) {
185
+ const roles = await this.getRoles();
186
+ if (!roles[roleName]) {
187
+ throw Object.assign(new Error('Role not found'), { status: 404 });
188
+ }
189
+ roles[roleName].allowedApps = Array.isArray(appNames)
190
+ ? appNames.filter((a) => typeof a === 'string')
191
+ : [];
192
+ await require('fs').promises.writeFile(
193
+ require('../config').ROLES_FILE,
194
+ JSON.stringify(roles, null, 2),
195
+ 'utf8',
196
+ );
197
+ return roles[roleName];
198
+ }
199
+ }
200
+
201
+ module.exports = { RoleConfigService, ALL_PAGES, ALL_CARDS };
@@ -0,0 +1,63 @@
1
+ 'use strict';
2
+ const fsP = require('fs').promises;
3
+ const path = require('path');
4
+ const crypto = require('crypto');
5
+ const config = require('../config');
6
+
7
+ const FILE = path.join(config.DATA_DIR, 'saved-searches.json');
8
+
9
+ class SavedSearchService {
10
+ async _load () {
11
+ try {
12
+ return JSON.parse(await fsP.readFile(FILE, 'utf8'));
13
+ } catch {
14
+ return {};
15
+ }
16
+ }
17
+
18
+ async _save (obj) {
19
+ await fsP.writeFile(FILE, JSON.stringify(obj, null, 2), 'utf8');
20
+ }
21
+
22
+ async getAll (username) {
23
+ const db = await this._load();
24
+ return (db[username] || []).sort(
25
+ (a, b) => new Date(b.createdAt) - new Date(a.createdAt),
26
+ );
27
+ }
28
+
29
+ async create (username, { name, service, date, level, q, fromTime, toTime }) {
30
+ if (!name) {
31
+ throw Object.assign(new Error('name required'), { status: 400 });
32
+ }
33
+ const db = await this._load();
34
+ if (!db[username]) {
35
+ db[username] = [];
36
+ }
37
+ const entry = {
38
+ id: crypto.randomUUID(),
39
+ name,
40
+ service: service || '',
41
+ date: date || '',
42
+ level: level || '',
43
+ q: q || '',
44
+ fromTime: fromTime || '',
45
+ toTime: toTime || '',
46
+ createdAt: new Date().toISOString(),
47
+ };
48
+ db[username].unshift(entry);
49
+ if (db[username].length > 50) {
50
+ db[username] = db[username].slice(0, 50);
51
+ }
52
+ await this._save(db);
53
+ return entry;
54
+ }
55
+
56
+ async delete (username, id) {
57
+ const db = await this._load();
58
+ db[username] = (db[username] || []).filter((s) => s.id !== id);
59
+ await this._save(db);
60
+ }
61
+ }
62
+
63
+ module.exports = new SavedSearchService();
@@ -0,0 +1,220 @@
1
+ 'use strict';
2
+ const fs = require('fs').promises;
3
+ const config = require('../config');
4
+ const logger = require('../lib/logger');
5
+
6
+ const DEFAULTS = {
7
+ retentionDays: 7,
8
+ webhookUrl: '',
9
+ enableStream: true,
10
+ ipWhitelist: [],
11
+ // Branding
12
+ appName: 'LogBoard',
13
+ appLogoUrl: '/public/logo.png',
14
+ faviconUrl: '',
15
+ // Theme
16
+ themeId: 'dark-indigo',
17
+ themeMode: 'dark',
18
+ accentR: 99,
19
+ accentG: 102,
20
+ accentB: 241,
21
+ // Health alert thresholds
22
+ alertRamPct: 85,
23
+ alertCpuPct: 90,
24
+ alertDiskPct: 90,
25
+ alertsEnabled: true,
26
+ // Custom Slack message template
27
+ alertSlackTemplate:
28
+ '🚨 *{appName}* — {metric} at *{value}%* (threshold {threshold}%) on `{host}`\n> {time}',
29
+ // Email / SMTP
30
+ smtpHost: '',
31
+ smtpPort: 587,
32
+ smtpUser: '',
33
+ smtpPass: '',
34
+ smtpFrom: 'logboard@example.com',
35
+ emailEnabled: false,
36
+ reportEmail: '',
37
+ reportSchedule: 'daily',
38
+ reportEnabled: false,
39
+ lastReportAt: null,
40
+ // Integrations
41
+ discordUrl: '',
42
+ pagerdutyKey: '',
43
+ pagerdutyEnabled: false,
44
+ // OAuth (future)
45
+ githubClientId: '',
46
+ githubClientSecret: '',
47
+ googleClientId: '',
48
+ googleClientSecret: '',
49
+ oauthEnabled: false,
50
+ };
51
+
52
+ class SettingsService {
53
+ constructor (org) {
54
+ this._org = org || null;
55
+ }
56
+
57
+ _file (cfgRef, orgAttr) {
58
+ return this._org && this._org[orgAttr] ? this._org[orgAttr] : cfgRef;
59
+ }
60
+
61
+ async get () {
62
+ let s = {};
63
+ try {
64
+ s = JSON.parse(
65
+ await fs.readFile(
66
+ this._file(config.SETTINGS_FILE, 'settingsFile'),
67
+ 'utf8',
68
+ ),
69
+ );
70
+ } catch (e) {
71
+ if (e.code !== 'ENOENT') {
72
+ throw e;
73
+ }
74
+ }
75
+ return { ...DEFAULTS, ...s };
76
+ }
77
+
78
+ async save (upd = {}) {
79
+ const current = await this.get();
80
+ const next = { ...current };
81
+
82
+ // ── Helpers ────────────────────────────────────────────────────────────
83
+ const str = (k, max = 512) =>
84
+ upd[k] !== undefined ? String(upd[k]).slice(0, max) : undefined;
85
+ const bool = (k) =>
86
+ upd[k] !== undefined
87
+ ? upd[k] === true || upd[k] === 'true' || upd[k] === 1
88
+ : undefined;
89
+ const num = (k, lo, hi) => {
90
+ if (upd[k] === undefined) {
91
+ return undefined;
92
+ }
93
+ const v = parseInt(upd[k], 10);
94
+ return !isNaN(v) && v >= lo && v <= hi ? v : undefined;
95
+ };
96
+ const set = (k, v) => {
97
+ if (v !== undefined) {
98
+ next[k] = v;
99
+ }
100
+ };
101
+
102
+ // ── General ────────────────────────────────────────────────────────────
103
+ set('retentionDays', num('retentionDays', 1, 365));
104
+ set('enableStream', bool('enableStream'));
105
+
106
+ if (upd.webhookUrl !== undefined) {
107
+ const u = String(upd.webhookUrl).trim();
108
+ if (u && !/^https?:\/\/.+/.test(u)) {
109
+ throw Object.assign(new Error('Invalid webhook URL'), { status: 400 });
110
+ }
111
+ next.webhookUrl = u;
112
+ }
113
+ if (upd.ipWhitelist !== undefined) {
114
+ if (!Array.isArray(upd.ipWhitelist)) {
115
+ throw Object.assign(new Error('ipWhitelist must be array'), {
116
+ status: 400,
117
+ });
118
+ }
119
+ next.ipWhitelist = upd.ipWhitelist.filter(
120
+ (s) => typeof s === 'string' && s.trim(),
121
+ );
122
+ }
123
+
124
+ // ── Branding ───────────────────────────────────────────────────────────
125
+ set('appName', str('appName', 64) || undefined);
126
+ set('appLogoUrl', str('appLogoUrl') || undefined);
127
+ set('faviconUrl', str('faviconUrl', 1024));
128
+ if (upd.appName === '') {
129
+ next.appName = 'LogBoard';
130
+ }
131
+ if (upd.appLogoUrl === '') {
132
+ next.appLogoUrl = '/public/logo.png';
133
+ }
134
+
135
+ // ── Theme ──────────────────────────────────────────────────────────────
136
+ set('themeId', str('themeId', 32));
137
+ set(
138
+ 'themeMode',
139
+ upd.themeMode === 'light'
140
+ ? 'light'
141
+ : upd.themeMode === 'dark'
142
+ ? 'dark'
143
+ : undefined,
144
+ );
145
+ set('accentR', num('accentR', 0, 255));
146
+ set('accentG', num('accentG', 0, 255));
147
+ set('accentB', num('accentB', 0, 255));
148
+
149
+ // ── Health alerts ──────────────────────────────────────────────────────
150
+ set('alertRamPct', num('alertRamPct', 1, 99));
151
+ set('alertCpuPct', num('alertCpuPct', 1, 99));
152
+ set('alertDiskPct', num('alertDiskPct', 1, 99));
153
+ set('alertsEnabled', bool('alertsEnabled'));
154
+ set('alertSlackTemplate', str('alertSlackTemplate', 500));
155
+
156
+ // ── Email / SMTP ───────────────────────────────────────────────────────
157
+ set('smtpHost', str('smtpHost'));
158
+ set('smtpPort', num('smtpPort', 1, 65535));
159
+ set('smtpUser', str('smtpUser'));
160
+ set('smtpFrom', str('smtpFrom'));
161
+ set('emailEnabled', bool('emailEnabled'));
162
+ set('reportEmail', str('reportEmail'));
163
+ set('reportSchedule', str('reportSchedule', 16));
164
+ set('reportEnabled', bool('reportEnabled'));
165
+ set('lastReportAt', str('lastReportAt'));
166
+ if (upd.smtpPass !== undefined && upd.smtpPass !== '') {
167
+ next.smtpPass = String(upd.smtpPass);
168
+ }
169
+
170
+ // ── Integrations ───────────────────────────────────────────────────────
171
+ set('discordUrl', str('discordUrl'));
172
+ set('pagerdutyKey', str('pagerdutyKey'));
173
+ set('pagerdutyEnabled', bool('pagerdutyEnabled'));
174
+
175
+ // ── OAuth ──────────────────────────────────────────────────────────────
176
+ set('githubClientId', str('githubClientId'));
177
+ set('githubClientSecret', str('githubClientSecret'));
178
+ set('googleClientId', str('googleClientId'));
179
+ set('googleClientSecret', str('googleClientSecret'));
180
+ set('oauthEnabled', bool('oauthEnabled'));
181
+
182
+ await fs.writeFile(
183
+ this._file(config.SETTINGS_FILE, 'settingsFile'),
184
+ JSON.stringify(next, null, 2),
185
+ 'utf8',
186
+ );
187
+ logger.info(
188
+ `[Settings] Saved: ${Object.keys(upd)
189
+ .filter((k) => k !== 'smtpPass')
190
+ .join(', ')}`,
191
+ );
192
+
193
+ // Push updates into live config
194
+ if (upd.retentionDays !== undefined) {
195
+ config.RETENTION_DAYS = next.retentionDays;
196
+ }
197
+ if (upd.webhookUrl !== undefined) {
198
+ config.WEBHOOK_URL = next.webhookUrl;
199
+ }
200
+ if (upd.enableStream !== undefined) {
201
+ config.ENABLE_STREAM = next.enableStream;
202
+ }
203
+ try {
204
+ require('../middleware/ipWhitelist').bustCache();
205
+ } catch {}
206
+
207
+ return next;
208
+ }
209
+
210
+ async reset () {
211
+ await fs.writeFile(
212
+ this._file(config.SETTINGS_FILE, 'settingsFile'),
213
+ JSON.stringify(DEFAULTS, null, 2),
214
+ 'utf8',
215
+ );
216
+ return DEFAULTS;
217
+ }
218
+ }
219
+
220
+ module.exports = SettingsService;
@@ -0,0 +1,121 @@
1
+ 'use strict';
2
+ const bcrypt = require('bcryptjs');
3
+ const { getUsers, saveUsers } = require('../lib/userStore');
4
+ const logger = require('../lib/logger');
5
+
6
+ const SAFE_USER = /^[a-zA-Z0-9_.-]{2,32}$/;
7
+
8
+ class UserService {
9
+ async getAll () {
10
+ const users = await getUsers();
11
+ return Object.entries(users)
12
+ .map(([username, u]) => ({
13
+ username,
14
+ role: u.role || 'viewer',
15
+ totpEnabled: !!u.totpSecret,
16
+ createdAt: u.createdAt || null,
17
+ }))
18
+ .sort((a, b) => a.username.localeCompare(b.username));
19
+ }
20
+
21
+ async add (username, password, role = 'viewer', actorUsername) {
22
+ if (!SAFE_USER.test(username || '')) {
23
+ throw Object.assign(
24
+ new Error('Username must be 2-32 chars: letters, digits, _ . -'),
25
+ { status: 400 },
26
+ );
27
+ }
28
+ if (!password || password.length < 8) {
29
+ throw Object.assign(new Error('Password must be 8+ characters'), {
30
+ status: 400,
31
+ });
32
+ }
33
+
34
+ const users = await getUsers();
35
+ if (users[username]) {
36
+ throw Object.assign(new Error(`User "${username}" already exists`), {
37
+ status: 409,
38
+ });
39
+ }
40
+
41
+ users[username] = {
42
+ password: await bcrypt.hash(password, 12),
43
+ role,
44
+ createdAt: new Date().toISOString(),
45
+ };
46
+ await saveUsers(users);
47
+ logger.info(
48
+ `[Users] "${actorUsername}" created user "${username}" (role: ${role})`,
49
+ );
50
+ return { username, role, createdAt: users[username].createdAt };
51
+ }
52
+
53
+ async updateRole (username, newRole, actorUsername) {
54
+ const users = await getUsers();
55
+ if (!users[username]) {
56
+ throw Object.assign(new Error('User not found'), { status: 404 });
57
+ }
58
+ if (username === actorUsername) {
59
+ throw Object.assign(new Error('Cannot change your own role'), {
60
+ status: 400,
61
+ });
62
+ }
63
+ users[username].role = newRole;
64
+ await saveUsers(users);
65
+ logger.info(
66
+ `[Users] "${actorUsername}" changed "${username}" role to ${newRole}`,
67
+ );
68
+ }
69
+
70
+ async resetPassword (username, newPassword, actorUsername) {
71
+ if (!newPassword || newPassword.length < 8) {
72
+ throw Object.assign(new Error('Password must be 8+ characters'), {
73
+ status: 400,
74
+ });
75
+ }
76
+ const users = await getUsers();
77
+ if (!users[username]) {
78
+ throw Object.assign(new Error('User not found'), { status: 404 });
79
+ }
80
+ users[username].password = await bcrypt.hash(newPassword, 12);
81
+ await saveUsers(users);
82
+ logger.info(`[Users] "${actorUsername}" reset password for "${username}"`);
83
+ }
84
+
85
+ async remove (username, actorUsername) {
86
+ if (username === actorUsername) {
87
+ throw Object.assign(new Error('Cannot delete your own account'), {
88
+ status: 400,
89
+ });
90
+ }
91
+ const users = await getUsers();
92
+ if (!users[username]) {
93
+ throw Object.assign(new Error('User not found'), { status: 404 });
94
+ }
95
+ // Guard last admin
96
+ if (users[username].role === 'admin') {
97
+ const admins = Object.values(users).filter(
98
+ (u) => u.role === 'admin',
99
+ ).length;
100
+ if (admins <= 1) {
101
+ throw Object.assign(new Error('Cannot delete the last admin account'), {
102
+ status: 400,
103
+ });
104
+ }
105
+ }
106
+ delete users[username];
107
+ await saveUsers(users);
108
+ logger.info(`[Users] "${actorUsername}" deleted user "${username}"`);
109
+ }
110
+
111
+ async revoke2fa (username, actorUsername) {
112
+ const users = await getUsers();
113
+ if (!users[username]) {
114
+ throw Object.assign(new Error('User not found'), { status: 404 });
115
+ }
116
+ delete users[username].totpSecret;
117
+ await saveUsers(users);
118
+ logger.info(`[Users] "${actorUsername}" revoked 2FA for "${username}"`);
119
+ }
120
+ }
121
+ module.exports = UserService;