@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.
- package/.env.example +37 -0
- package/README.md +200 -0
- package/bin/logboard +536 -0
- package/client/logger.js +309 -0
- package/config/index.js +142 -0
- package/config.js +2 -0
- package/controllers/AnalyticsController.js +46 -0
- package/controllers/ApiAnalyticsController.js +129 -0
- package/controllers/ApiKeyController.js +58 -0
- package/controllers/AuthController.js +131 -0
- package/controllers/HealthController.js +56 -0
- package/controllers/LogController.js +197 -0
- package/controllers/OrgController.js +152 -0
- package/controllers/RoleConfigController.js +20 -0
- package/controllers/SettingsController.js +39 -0
- package/controllers/StreamController.js +55 -0
- package/controllers/UiController.js +789 -0
- package/controllers/UserController.js +79 -0
- package/lib/batchWriter.js +57 -0
- package/lib/cleanup.js +67 -0
- package/lib/ejs.js +103 -0
- package/lib/emitter.js +5 -0
- package/lib/healthMonitor.js +245 -0
- package/lib/logger.js +21 -0
- package/lib/streams.js +32 -0
- package/lib/theme.js +77 -0
- package/lib/userStore.js +13 -0
- package/lib/utils.js +44 -0
- package/middleware/apiKey.js +82 -0
- package/middleware/auth.js +55 -0
- package/middleware/ipWhitelist.js +59 -0
- package/middleware/org.js +85 -0
- package/middleware/pageAccess.js +20 -0
- package/middleware/rateLimit.js +29 -0
- package/middleware/roles.js +11 -0
- package/package.json +77 -0
- package/routes/alerts.js +18 -0
- package/routes/analytics.js +26 -0
- package/routes/api-analytics.js +30 -0
- package/routes/api-keys.js +12 -0
- package/routes/archive.js +91 -0
- package/routes/audit.js +50 -0
- package/routes/auth.js +22 -0
- package/routes/bookmarks.js +13 -0
- package/routes/health.js +11 -0
- package/routes/logs.js +88 -0
- package/routes/metrics.js +66 -0
- package/routes/notifications.js +14 -0
- package/routes/orgs.js +98 -0
- package/routes/registration.js +202 -0
- package/routes/role-config.js +97 -0
- package/routes/saved-searches.js +12 -0
- package/routes/server.js +151 -0
- package/routes/settings.js +28 -0
- package/routes/status.js +21 -0
- package/routes/stream.js +11 -0
- package/routes/super.js +129 -0
- package/routes/ui.js +120 -0
- package/routes/users.js +13 -0
- package/server.js +172 -0
- package/services/AlertRulesService.js +323 -0
- package/services/AnalyticsService.js +665 -0
- package/services/ApiAnalyticsService.js +471 -0
- package/services/ApiKeyService.js +166 -0
- package/services/AuditService.js +249 -0
- package/services/AuthService.js +234 -0
- package/services/BookmarkService.js +49 -0
- package/services/GlobalSettingsService.js +44 -0
- package/services/LogService.js +1066 -0
- package/services/MetricsService.js +116 -0
- package/services/NotificationService.js +70 -0
- package/services/OrgService.js +217 -0
- package/services/ReportService.js +247 -0
- package/services/RoleConfigService.js +201 -0
- package/services/SavedSearchService.js +63 -0
- package/services/SettingsService.js +220 -0
- package/services/UserService.js +121 -0
- package/setup.js +132 -0
- package/views/404.ejs +8 -0
- package/views/alerts.ejs +190 -0
- package/views/analytics.ejs +209 -0
- package/views/api-analytics.ejs +660 -0
- package/views/api-keys.ejs +150 -0
- package/views/archive.ejs +123 -0
- package/views/audit.ejs +314 -0
- package/views/bookmarks.ejs +54 -0
- package/views/custom-dashboard.ejs +162 -0
- package/views/dashboard.ejs +186 -0
- package/views/diff.ejs +98 -0
- package/views/health.ejs +269 -0
- package/views/heatmap.ejs +126 -0
- package/views/insights.ejs +334 -0
- package/views/invite.ejs +74 -0
- package/views/live.ejs +299 -0
- package/views/login.ejs +64 -0
- package/views/logo.png +0 -0
- package/views/logs.ejs +754 -0
- package/views/notifications.ejs +58 -0
- package/views/partials/head.ejs +282 -0
- package/views/partials/sidebar.ejs +168 -0
- package/views/register.ejs +100 -0
- package/views/roles.ejs +279 -0
- package/views/saved-searches.ejs +51 -0
- package/views/service-map.ejs +142 -0
- package/views/settings.ejs +1159 -0
- package/views/sidebar.ejs +129 -0
- package/views/status.ejs +100 -0
- package/views/super-admin-admins.ejs +58 -0
- package/views/super-admin-analytics.ejs +49 -0
- package/views/super-admin-orgs.ejs +310 -0
- package/views/super-admin-profile.ejs +77 -0
- package/views/super-admin-settings.ejs +108 -0
- package/views/super-admin-system.ejs +46 -0
- package/views/users.ejs +153 -0
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const config = require('../config');
|
|
3
|
+
|
|
4
|
+
const AuditService = require('../services/AuditService');
|
|
5
|
+
class AuthController {
|
|
6
|
+
constructor (authService) {
|
|
7
|
+
this.svc = authService;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
async login (req, res) {
|
|
11
|
+
try {
|
|
12
|
+
const { username, password, token } = req.body;
|
|
13
|
+
// Get org from query param or request body (for org-specific login)
|
|
14
|
+
const orgSlug = req.body.orgSlug || req.query.org || null;
|
|
15
|
+
const result = await this.svc.login(username, password, token, orgSlug);
|
|
16
|
+
new AuditService(req.org)
|
|
17
|
+
.log(username, 'login', username, {}, req.ip || '')
|
|
18
|
+
.catch(() => {});
|
|
19
|
+
res.cookie(config.SESSION_NAME, result.token, {
|
|
20
|
+
httpOnly: true,
|
|
21
|
+
secure: config.NODE_ENV === 'production',
|
|
22
|
+
sameSite: 'strict',
|
|
23
|
+
maxAge: 24 * 60 * 60 * 1000,
|
|
24
|
+
});
|
|
25
|
+
// Determine first accessible page for this role
|
|
26
|
+
const { RoleConfigService } = require('../services/RoleConfigService');
|
|
27
|
+
const { ALL_PAGES } = require('../services/RoleConfigService');
|
|
28
|
+
let firstPage = '/dashboard';
|
|
29
|
+
try {
|
|
30
|
+
const rc = new RoleConfigService(req.org || null);
|
|
31
|
+
const allowedPages = await rc.getAllowedPages(result.role);
|
|
32
|
+
if (allowedPages && allowedPages.length) {
|
|
33
|
+
// Find first page from ALL_PAGES order that is allowed
|
|
34
|
+
const ordered = (ALL_PAGES || []).map((p) => p.id || p);
|
|
35
|
+
const first = ordered.find((p) => allowedPages.includes(p));
|
|
36
|
+
if (first) {
|
|
37
|
+
firstPage = `/${first}`;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
} catch {}
|
|
41
|
+
res.json({
|
|
42
|
+
success: true,
|
|
43
|
+
role: result.role,
|
|
44
|
+
orgSlug: result.orgSlug,
|
|
45
|
+
totpEnabled: result.totpEnabled,
|
|
46
|
+
redirect: firstPage,
|
|
47
|
+
});
|
|
48
|
+
} catch (err) {
|
|
49
|
+
res.status(err.status || 500).json({ error: err.message });
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
logout (req, res) {
|
|
54
|
+
res.clearCookie(config.SESSION_NAME, {
|
|
55
|
+
httpOnly: true,
|
|
56
|
+
sameSite: 'strict',
|
|
57
|
+
});
|
|
58
|
+
res.json({ success: true });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
verify (req, res) {
|
|
62
|
+
const jwt = require('jsonwebtoken');
|
|
63
|
+
const tok = req.cookies[config.SESSION_NAME];
|
|
64
|
+
if (!tok) {
|
|
65
|
+
return res.status(401).json({ error: 'No session' });
|
|
66
|
+
}
|
|
67
|
+
try {
|
|
68
|
+
const payload = jwt.verify(tok, config.JWT_SECRET);
|
|
69
|
+
res.json({ valid: true, username: payload.username, role: payload.role });
|
|
70
|
+
} catch {
|
|
71
|
+
res.status(401).json({ error: 'Invalid or expired session' });
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async setup2fa (req, res) {
|
|
76
|
+
try {
|
|
77
|
+
const result = await this.svc.setupTotp(req.user.username, req.org);
|
|
78
|
+
res.json(result);
|
|
79
|
+
} catch (err) {
|
|
80
|
+
res.status(err.status || 500).json({ error: err.message });
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async enable2fa (req, res) {
|
|
85
|
+
try {
|
|
86
|
+
const { secret, token } = req.body;
|
|
87
|
+
if (!secret || !token) {
|
|
88
|
+
return res.status(400).json({ error: 'secret and token required' });
|
|
89
|
+
}
|
|
90
|
+
await this.svc.enableTotp(req.user.username, req.org, secret, token);
|
|
91
|
+
res.json({ success: true });
|
|
92
|
+
} catch (err) {
|
|
93
|
+
res.status(err.status || 500).json({ error: err.message });
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async disable2fa (req, res) {
|
|
98
|
+
try {
|
|
99
|
+
await this.svc.disableTotp(req.user.username);
|
|
100
|
+
res.json({ success: true });
|
|
101
|
+
} catch (err) {
|
|
102
|
+
res.status(err.status || 500).json({ error: err.message });
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async changePassword (req, res) {
|
|
107
|
+
try {
|
|
108
|
+
const { currentPassword, newPassword } = req.body;
|
|
109
|
+
await this.svc.changePassword(
|
|
110
|
+
req.user.username,
|
|
111
|
+
currentPassword,
|
|
112
|
+
newPassword,
|
|
113
|
+
req.org,
|
|
114
|
+
);
|
|
115
|
+
res.json({ success: true });
|
|
116
|
+
} catch (err) {
|
|
117
|
+
res.status(err.status || 500).json({ error: err.message });
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async totpStatus (req, res) {
|
|
122
|
+
try {
|
|
123
|
+
const result = await this.svc.getTotpStatus(req.user.username);
|
|
124
|
+
res.json(result);
|
|
125
|
+
} catch (err) {
|
|
126
|
+
res.status(err.status || 500).json({ error: err.message });
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
module.exports = AuthController;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const fsP = require('fs').promises;
|
|
3
|
+
const config = require('../config');
|
|
4
|
+
|
|
5
|
+
const startTime = Date.now();
|
|
6
|
+
|
|
7
|
+
class HealthController {
|
|
8
|
+
async health (req, res) {
|
|
9
|
+
const mem = process.memoryUsage();
|
|
10
|
+
const uptime = Math.floor((Date.now() - startTime) / 1000);
|
|
11
|
+
|
|
12
|
+
let logDirOk = true;
|
|
13
|
+
try { await fsP.access(config.LOG_BASE_DIR); } catch { logDirOk = false; }
|
|
14
|
+
|
|
15
|
+
res.json({
|
|
16
|
+
status: 'ok',
|
|
17
|
+
timestamp: new Date().toISOString(),
|
|
18
|
+
uptime,
|
|
19
|
+
uptimeHuman: `${Math.floor(uptime / 3600)}h ${Math.floor((uptime % 3600) / 60)}m ${uptime % 60}s`,
|
|
20
|
+
memory: {
|
|
21
|
+
rss: `${Math.round(mem.rss / 1024 / 1024)} MB`,
|
|
22
|
+
heapUsed: `${Math.round(mem.heapUsed / 1024 / 1024)} MB`,
|
|
23
|
+
heapTotal: `${Math.round(mem.heapTotal / 1024 / 1024)} MB`,
|
|
24
|
+
},
|
|
25
|
+
checks: {
|
|
26
|
+
logDir: logDirOk ? 'ok' : 'missing',
|
|
27
|
+
stream: config.ENABLE_STREAM ? 'enabled' : 'disabled',
|
|
28
|
+
nodeVer: process.version,
|
|
29
|
+
env: config.NODE_ENV,
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
metrics (req, res) {
|
|
35
|
+
const mem = process.memoryUsage();
|
|
36
|
+
const cpu = process.cpuUsage();
|
|
37
|
+
res.setHeader('Content-Type', 'text/plain; version=0.0.4');
|
|
38
|
+
res.send([
|
|
39
|
+
'# HELP nodejs_heap_bytes Heap memory bytes',
|
|
40
|
+
'# TYPE nodejs_heap_bytes gauge',
|
|
41
|
+
`nodejs_heap_bytes{type="heapTotal"} ${mem.heapTotal}`,
|
|
42
|
+
`nodejs_heap_bytes{type="heapUsed"} ${mem.heapUsed}`,
|
|
43
|
+
'# HELP nodejs_rss_bytes Resident set size bytes',
|
|
44
|
+
'# TYPE nodejs_rss_bytes gauge',
|
|
45
|
+
`nodejs_rss_bytes ${mem.rss}`,
|
|
46
|
+
'# HELP process_cpu_seconds_total Total CPU time seconds',
|
|
47
|
+
'# TYPE process_cpu_seconds_total counter',
|
|
48
|
+
`process_cpu_seconds_total ${((cpu.user + cpu.system) / 1e6).toFixed(3)}`,
|
|
49
|
+
'# HELP process_uptime_seconds Process uptime seconds',
|
|
50
|
+
'# TYPE process_uptime_seconds gauge',
|
|
51
|
+
`process_uptime_seconds ${process.uptime().toFixed(1)}`,
|
|
52
|
+
].join('\n'));
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
module.exports = HealthController;
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
class LogController {
|
|
4
|
+
constructor (logService) {
|
|
5
|
+
this.svc = logService;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
_logAudit (req, service, date, q) {
|
|
9
|
+
try {
|
|
10
|
+
const audit = require('../services/AuditService');
|
|
11
|
+
new audit()
|
|
12
|
+
.logLogAccess(
|
|
13
|
+
req.user?.username || 'anon',
|
|
14
|
+
service || '',
|
|
15
|
+
date || '',
|
|
16
|
+
q || '',
|
|
17
|
+
req.ip || '',
|
|
18
|
+
)
|
|
19
|
+
.catch(() => {});
|
|
20
|
+
} catch {}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async ingest (req, res) {
|
|
24
|
+
try {
|
|
25
|
+
const { logs, appName } = req.body;
|
|
26
|
+
if (!Array.isArray(logs)) {
|
|
27
|
+
return res.status(400).json({ error: 'logs must be an array' });
|
|
28
|
+
}
|
|
29
|
+
const resolvedName = appName || req.apiKey?.name || '';
|
|
30
|
+
if (!resolvedName) {
|
|
31
|
+
return res
|
|
32
|
+
.status(400)
|
|
33
|
+
.json({
|
|
34
|
+
error:
|
|
35
|
+
'appName is required in the request body or API key name must be set',
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
// Use org-scoped LogService so logs go to logs/{orgSlug}/{appName}/
|
|
39
|
+
const LogService = require('../services/LogService');
|
|
40
|
+
const svc = new LogService(this.svc.batchWriter, req.org);
|
|
41
|
+
const written = await svc.ingest(logs, resolvedName);
|
|
42
|
+
res.json({ written });
|
|
43
|
+
} catch (e) {
|
|
44
|
+
res.status(e.status || 500).json({ error: e.message });
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async services (req, res) {
|
|
49
|
+
try {
|
|
50
|
+
res.json(await this.svc.getServices());
|
|
51
|
+
} catch (e) {
|
|
52
|
+
res.status(500).json({ error: e.message });
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async tail (req, res) {
|
|
57
|
+
try {
|
|
58
|
+
const { appName, date } = req.params;
|
|
59
|
+
const n = Math.min(parseInt(req.query.n || '200', 10), 2000);
|
|
60
|
+
this._logAudit(req, appName, date, '');
|
|
61
|
+
res.json(await this.svc.tail(appName, date, n));
|
|
62
|
+
} catch (e) {
|
|
63
|
+
res.status(e.status || 500).json({ error: e.message });
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async search (req, res) {
|
|
68
|
+
try {
|
|
69
|
+
const {
|
|
70
|
+
service,
|
|
71
|
+
date,
|
|
72
|
+
level,
|
|
73
|
+
q,
|
|
74
|
+
limit,
|
|
75
|
+
offset,
|
|
76
|
+
fromTime,
|
|
77
|
+
toTime,
|
|
78
|
+
tzOffset,
|
|
79
|
+
} = req.query;
|
|
80
|
+
if (fromTime || toTime) {
|
|
81
|
+
const result = await this.svc.searchTimeRange(service, date, {
|
|
82
|
+
fromTime,
|
|
83
|
+
toTime,
|
|
84
|
+
level,
|
|
85
|
+
q,
|
|
86
|
+
tzOffset: Number(tzOffset || 0),
|
|
87
|
+
limit: Number(limit || 500),
|
|
88
|
+
});
|
|
89
|
+
return res.json(result);
|
|
90
|
+
}
|
|
91
|
+
this._logAudit(req, service, date, q);
|
|
92
|
+
res.json(
|
|
93
|
+
await this.svc.search({
|
|
94
|
+
service,
|
|
95
|
+
date,
|
|
96
|
+
level,
|
|
97
|
+
q,
|
|
98
|
+
limit: Number(limit || 200),
|
|
99
|
+
offset: Number(offset || 0),
|
|
100
|
+
}),
|
|
101
|
+
);
|
|
102
|
+
} catch (e) {
|
|
103
|
+
res.status(e.status || 500).json({ error: e.message });
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async context (req, res) {
|
|
108
|
+
try {
|
|
109
|
+
const { appName, date } = req.params;
|
|
110
|
+
const { lineIndex, n = 10 } = req.query;
|
|
111
|
+
res.json(
|
|
112
|
+
await this.svc.getContext(appName, date, Number(lineIndex), Number(n)),
|
|
113
|
+
);
|
|
114
|
+
} catch (e) {
|
|
115
|
+
res.status(e.status || 500).json({ error: e.message });
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async replay (req, res) {
|
|
120
|
+
try {
|
|
121
|
+
const { minutes } = req.query;
|
|
122
|
+
res.json(await this.svc.replay(minutes));
|
|
123
|
+
} catch (e) {
|
|
124
|
+
res.status(500).json({ error: e.message });
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async download (req, res) {
|
|
129
|
+
try {
|
|
130
|
+
const { appName, date } = req.params;
|
|
131
|
+
const { level, q, format, fromTime, toTime, tzOffset } = req.query;
|
|
132
|
+
if (format === 'txt') {
|
|
133
|
+
await this.svc.downloadTxt(
|
|
134
|
+
appName,
|
|
135
|
+
date,
|
|
136
|
+
{ level, q, fromTime, toTime, tzOffset: Number(tzOffset || 0) },
|
|
137
|
+
res,
|
|
138
|
+
);
|
|
139
|
+
} else {
|
|
140
|
+
await this.svc.download(appName, date, { level, q }, res);
|
|
141
|
+
}
|
|
142
|
+
} catch (e) {
|
|
143
|
+
res.status(e.status || 500).json({ error: e.message });
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async traceSearch (req, res) {
|
|
148
|
+
try {
|
|
149
|
+
const { traceId, date } = req.query;
|
|
150
|
+
if (!traceId) {
|
|
151
|
+
return res.status(400).json({ error: 'traceId required' });
|
|
152
|
+
}
|
|
153
|
+
res.json(await this.svc.findByTraceId(traceId, date));
|
|
154
|
+
} catch (e) {
|
|
155
|
+
res.status(e.status || 500).json({ error: e.message });
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async searchRange (req, res) {
|
|
160
|
+
try {
|
|
161
|
+
const { service, fromDate, toDate, level, q, limit } = req.query;
|
|
162
|
+
if (!service || !fromDate || !toDate) {
|
|
163
|
+
return res
|
|
164
|
+
.status(400)
|
|
165
|
+
.json({ error: 'service, fromDate, toDate required' });
|
|
166
|
+
}
|
|
167
|
+
res.json(
|
|
168
|
+
await this.svc.searchMultiDate(service, fromDate, toDate, {
|
|
169
|
+
level,
|
|
170
|
+
q,
|
|
171
|
+
limit: Number(limit || 500),
|
|
172
|
+
}),
|
|
173
|
+
);
|
|
174
|
+
} catch (e) {
|
|
175
|
+
res.status(e.status || 500).json({ error: e.message });
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async clusters (req, res) {
|
|
180
|
+
try {
|
|
181
|
+
const { appName, date } = req.params;
|
|
182
|
+
res.json(await this.svc.clusterErrors(appName, date));
|
|
183
|
+
} catch (e) {
|
|
184
|
+
res.status(e.status || 500).json({ error: e.message });
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async anomalies (req, res) {
|
|
189
|
+
try {
|
|
190
|
+
res.json(await this.svc.detectAnomalies(req.params.appName));
|
|
191
|
+
} catch (e) {
|
|
192
|
+
res.status(500).json({ error: e.message });
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
module.exports = LogController;
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const OrgService = require('../services/OrgService');
|
|
3
|
+
const config = require('../config');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const fsP = require('fs').promises;
|
|
6
|
+
const bcrypt = require('bcryptjs');
|
|
7
|
+
|
|
8
|
+
class OrgController {
|
|
9
|
+
// ── Super-admin: list all orgs ───────────────────────────────────────────
|
|
10
|
+
async listOrgs (req, res) {
|
|
11
|
+
try {
|
|
12
|
+
const orgs = await OrgService.getOrgs();
|
|
13
|
+
res.json({ orgs: Object.values(orgs) });
|
|
14
|
+
} catch (e) { res.status(500).json({ error: e.message }); }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// ── Super-admin: create org ───────────────────────────────────────────────
|
|
18
|
+
async createOrg (req, res) {
|
|
19
|
+
try {
|
|
20
|
+
const { name, ownerUsername, ownerPassword, plan } = req.body;
|
|
21
|
+
const org = await OrgService.create({ name, ownerUsername, ownerPassword, plan });
|
|
22
|
+
res.status(201).json({ success: true, org: { slug: org.slug, name: org.name, plan: org.plan } });
|
|
23
|
+
} catch (e) { res.status(e.status || 500).json({ error: e.message }); }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// ── Super-admin: get org detail ───────────────────────────────────────────
|
|
27
|
+
async getOrg (req, res) {
|
|
28
|
+
try {
|
|
29
|
+
const org = await OrgService.getOrg(req.params.slug);
|
|
30
|
+
if (!org) { return res.status(404).json({ error: 'Org not found' }); }
|
|
31
|
+
// Include user count
|
|
32
|
+
let userCount = 0;
|
|
33
|
+
try {
|
|
34
|
+
const users = JSON.parse(await fsP.readFile(org.usersFile, 'utf8'));
|
|
35
|
+
userCount = Object.keys(users).length;
|
|
36
|
+
} catch {}
|
|
37
|
+
res.json({ ...org, dataDir: undefined, logsDir: undefined, userCount });
|
|
38
|
+
} catch (e) { res.status(500).json({ error: e.message }); }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ── Super-admin: delete org ───────────────────────────────────────────────
|
|
42
|
+
async deleteOrg (req, res) {
|
|
43
|
+
try {
|
|
44
|
+
if (req.params.slug === 'default') { return res.status(400).json({ error: 'Cannot delete default org' }); }
|
|
45
|
+
await OrgService.delete(req.params.slug);
|
|
46
|
+
res.json({ success: true });
|
|
47
|
+
} catch (e) { res.status(e.status || 500).json({ error: e.message }); }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ── Admin: invite user to org ─────────────────────────────────────────────
|
|
51
|
+
async createInvite (req, res) {
|
|
52
|
+
try {
|
|
53
|
+
const orgSlug = req.org?.slug;
|
|
54
|
+
if (!orgSlug) { return res.status(400).json({ error: 'No org context' }); }
|
|
55
|
+
const { email, role } = req.body;
|
|
56
|
+
if (!email) { return res.status(400).json({ error: 'Email required' }); }
|
|
57
|
+
const inv = await OrgService.createInvite(orgSlug, email, role || 'viewer', req.user.username);
|
|
58
|
+
// Send invite email if SMTP configured
|
|
59
|
+
try {
|
|
60
|
+
const SettingsService = require('../services/SettingsService');
|
|
61
|
+
const settings = await new SettingsService(req.org).get();
|
|
62
|
+
if (settings.smtpHost && settings.emailEnabled) {
|
|
63
|
+
const baseUrl = process.env.OAUTH_CALLBACK_BASE || `http://localhost:${config.PORT}`;
|
|
64
|
+
const link = `${baseUrl}/invite/${inv.token}`;
|
|
65
|
+
const nodemailer = require('nodemailer');
|
|
66
|
+
const transport = nodemailer.createTransport({ host: settings.smtpHost, port: settings.smtpPort || 587, auth: { user: settings.smtpUser, pass: settings.smtpPass } });
|
|
67
|
+
await transport.sendMail({
|
|
68
|
+
from: settings.smtpFrom || 'logboard@example.com',
|
|
69
|
+
to: email,
|
|
70
|
+
subject: `You've been invited to ${settings.appName || 'LogBoard'}`,
|
|
71
|
+
html: `<p>You've been invited to join <strong>${settings.appName || 'LogBoard'}</strong> as <strong>${role || 'viewer'}</strong>.</p><p><a href="${link}">Accept Invitation</a></p><p>This link expires in 7 days.</p>`,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
} catch (mailErr) { /* email sending optional */ }
|
|
75
|
+
res.json({ success: true, token: inv.token, email: inv.email, role: inv.role });
|
|
76
|
+
} catch (e) { res.status(e.status || 500).json({ error: e.message }); }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ── Public: accept invite ────────────────────────────────────────────────
|
|
80
|
+
async acceptInvite (req, res) {
|
|
81
|
+
try {
|
|
82
|
+
const { token, username, password } = req.body;
|
|
83
|
+
if (!token || !username || !password) { return res.status(400).json({ error: 'token, username, password required' }); }
|
|
84
|
+
const result = await OrgService.acceptInvite(token, username, password);
|
|
85
|
+
res.json({ success: true, ...result });
|
|
86
|
+
} catch (e) { res.status(e.status || 500).json({ error: e.message }); }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ── Public: get invite info (for the accept page) ────────────────────────
|
|
90
|
+
async getInvite (req, res) {
|
|
91
|
+
try {
|
|
92
|
+
const inv = await OrgService.getInvite(req.params.token);
|
|
93
|
+
if (!inv) { return res.status(404).json({ error: 'Invite not found or expired' }); }
|
|
94
|
+
const org = await OrgService.getOrg(inv.orgSlug);
|
|
95
|
+
const settings = {};
|
|
96
|
+
try {
|
|
97
|
+
const SettingsService = require('../services/SettingsService');
|
|
98
|
+
Object.assign(settings, await new SettingsService(org).get());
|
|
99
|
+
} catch {}
|
|
100
|
+
res.json({ email: inv.email, role: inv.role, orgName: org?.name || inv.orgSlug, orgSlug: inv.orgSlug, appName: settings.appName || 'LogBoard' });
|
|
101
|
+
} catch (e) { res.status(500).json({ error: e.message }); }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ── Super-admin: run migration ────────────────────────────────────────────
|
|
105
|
+
async runMigration (req, res) {
|
|
106
|
+
try {
|
|
107
|
+
const result = await OrgService.migrateToDefaultOrg();
|
|
108
|
+
res.json(result);
|
|
109
|
+
} catch (e) { res.status(500).json({ error: e.message }); }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ── Super-admin: create super-admin account ───────────────────────────────
|
|
113
|
+
async createSuperAdmin (req, res) {
|
|
114
|
+
try {
|
|
115
|
+
const { username, password } = req.body;
|
|
116
|
+
if (!username || !password || password.length < 8) { return res.status(400).json({ error: 'Username and password (min 8 chars) required' }); }
|
|
117
|
+
const SUPER_FILE = path.join(config.DATA_DIR, 'super-admins.json');
|
|
118
|
+
let superAdmins = {};
|
|
119
|
+
try { superAdmins = JSON.parse(await fsP.readFile(SUPER_FILE, 'utf8')); } catch {}
|
|
120
|
+
if (superAdmins[username]) { return res.status(409).json({ error: 'Super-admin already exists' }); }
|
|
121
|
+
superAdmins[username] = { username, password: await bcrypt.hash(password, 10), createdAt: new Date().toISOString() };
|
|
122
|
+
await fsP.writeFile(SUPER_FILE, JSON.stringify(superAdmins, null, 2), 'utf8');
|
|
123
|
+
res.json({ success: true });
|
|
124
|
+
} catch (e) { res.status(500).json({ error: e.message }); }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ── UI: orgs management page (super-admin) ────────────────────────────────
|
|
128
|
+
async orgsPage (req, res) {
|
|
129
|
+
try {
|
|
130
|
+
const orgs = await OrgService.getOrgs();
|
|
131
|
+
res.render('super-admin-orgs', { title: 'Orgs', user: req.user, orgs: Object.values(orgs), settings: {}, themeSnippet: '' });
|
|
132
|
+
} catch (e) { res.status(500).send(e.message); }
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ── UI: invite accept page ────────────────────────────────────────────────
|
|
136
|
+
async invitePage (req, res) {
|
|
137
|
+
try {
|
|
138
|
+
const inv = await OrgService.getInvite(req.params.token);
|
|
139
|
+
if (!inv) { return res.render('invite', { title: 'Invite', expired: true, invite: null, settings: {}, themeSnippet: '' }); }
|
|
140
|
+
const org = await OrgService.getOrg(inv.orgSlug);
|
|
141
|
+
const settings = {};
|
|
142
|
+
try {
|
|
143
|
+
const SettingsService = require('../services/SettingsService');
|
|
144
|
+
Object.assign(settings, await new SettingsService(org).get());
|
|
145
|
+
} catch {}
|
|
146
|
+
const { buildThemeSnippet } = require('../lib/theme');
|
|
147
|
+
res.render('invite', { title: `Join ${ settings.appName || 'LogBoard'}`, expired: false, invite: { ...inv, token: req.params.token }, settings, themeSnippet: buildThemeSnippet(settings) });
|
|
148
|
+
} catch (e) { res.status(500).send(e.message); }
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
module.exports = OrgController;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const { RoleConfigService } = require('../services/RoleConfigService');
|
|
3
|
+
|
|
4
|
+
class RoleConfigController {
|
|
5
|
+
constructor () { this.svc = null; }
|
|
6
|
+
_svc(req) { return new RoleConfigService(req.org); }
|
|
7
|
+
|
|
8
|
+
async getConfig (req, res) {
|
|
9
|
+
try { res.json(await this._svc(req).getConfig()); } catch (err) { res.status(500).json({ error: err.message }); }
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
async saveConfig (req, res) {
|
|
13
|
+
try {
|
|
14
|
+
const saved = await this._svc(req).saveConfig(req.body);
|
|
15
|
+
res.json({ success: true, config: saved });
|
|
16
|
+
} catch (err) { res.status(err.status || 500).json({ error: err.message }); }
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
module.exports = RoleConfigController;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const { cleanupOldLogs } = require('../lib/cleanup');
|
|
3
|
+
const logger = require('../lib/logger');
|
|
4
|
+
|
|
5
|
+
class SettingsController {
|
|
6
|
+
constructor (settingsService) { this.svc = settingsService; }
|
|
7
|
+
|
|
8
|
+
async getSettings (req, res) {
|
|
9
|
+
try { res.json(await this.svc.get()); } catch (e) { res.status(500).json({ error: e.message }); }
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// POST /api/settings — pass EVERY field from req.body; SettingsService validates each one
|
|
13
|
+
async saveSettings (req, res) {
|
|
14
|
+
try {
|
|
15
|
+
const updated = await this.svc.save(req.body);
|
|
16
|
+
// Audit what changed
|
|
17
|
+
try {
|
|
18
|
+
const audit = require('../services/AuditService');
|
|
19
|
+
const fields = Object.keys(req.body).filter((k) => !['smtpPass'].includes(k)).join(', ');
|
|
20
|
+
await new audit().log(req.user?.username || 'admin', 'settings_update', fields, {}, req.ip || '');
|
|
21
|
+
} catch {}
|
|
22
|
+
res.json({ success: true, settings: updated });
|
|
23
|
+
} catch (e) { res.status(e.status || 500).json({ error: e.message }); }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async runCleanup (req, res) {
|
|
27
|
+
try {
|
|
28
|
+
logger.info(`[Settings] Manual cleanup by "${req.user.username}"`);
|
|
29
|
+
await cleanupOldLogs();
|
|
30
|
+
res.json({ success: true, message: 'Cleanup completed' });
|
|
31
|
+
} catch (e) { res.status(500).json({ error: e.message }); }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async resetSettings (req, res) {
|
|
35
|
+
try { res.json({ success: true, settings: await this.svc.reset() }); } catch (e) { res.status(500).json({ error: e.message }); }
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
module.exports = SettingsController;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const emitter = require('../lib/emitter');
|
|
3
|
+
const config = require('../config');
|
|
4
|
+
|
|
5
|
+
class StreamController {
|
|
6
|
+
async stream (req, res) {
|
|
7
|
+
if (!config.ENABLE_STREAM) { return res.status(404).json({ error: 'Stream disabled' }); }
|
|
8
|
+
|
|
9
|
+
res.setHeader('Content-Type', 'text/event-stream');
|
|
10
|
+
res.setHeader('Cache-Control', 'no-cache');
|
|
11
|
+
res.setHeader('Connection', 'keep-alive');
|
|
12
|
+
res.setHeader('X-Accel-Buffering', 'no');
|
|
13
|
+
res.flushHeaders();
|
|
14
|
+
|
|
15
|
+
// Initial connect event
|
|
16
|
+
res.write('event: connected\n');
|
|
17
|
+
res.write('data: {"status":"connected"}\n\n');
|
|
18
|
+
|
|
19
|
+
const levelFilter = req.query.level || null;
|
|
20
|
+
const serviceFilter = req.query.service || null; // ?service=my-app
|
|
21
|
+
|
|
22
|
+
// RBAC: get allowed apps for this user's role
|
|
23
|
+
let allowedApps = [];
|
|
24
|
+
try {
|
|
25
|
+
const { RoleConfigService } = require('../services/RoleConfigService');
|
|
26
|
+
const role = req.user?.role || 'viewer';
|
|
27
|
+
allowedApps = await new RoleConfigService(req.org).getAllowedApps(role);
|
|
28
|
+
} catch {}
|
|
29
|
+
|
|
30
|
+
const sendLog = (raw) => {
|
|
31
|
+
try {
|
|
32
|
+
const parsed = typeof raw === 'string' ? JSON.parse(raw) : raw;
|
|
33
|
+
// Service filter from query
|
|
34
|
+
if (serviceFilter && parsed.appName !== serviceFilter) { return; }
|
|
35
|
+
// RBAC: only show allowed services
|
|
36
|
+
if (allowedApps.length && parsed.appName && !allowedApps.includes(parsed.appName)) { return; }
|
|
37
|
+
if (levelFilter && (parsed.level||'').toLowerCase() !== levelFilter.toLowerCase()) { return; }
|
|
38
|
+
res.write(`data: ${JSON.stringify(parsed)}\n\n`);
|
|
39
|
+
} catch {
|
|
40
|
+
res.write(`data: ${String(raw).replace(/\n/g, ' ')}\n\n`);
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
emitter.on('log', sendLog);
|
|
45
|
+
const heartbeat = setInterval(() => res.write(': ping\n\n'), 15_000);
|
|
46
|
+
|
|
47
|
+
req.on('close', () => {
|
|
48
|
+
emitter.off('log', sendLog);
|
|
49
|
+
clearInterval(heartbeat);
|
|
50
|
+
res.end();
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
module.exports = StreamController;
|