@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,249 @@
1
+ 'use strict';
2
+ const fsP = require('fs').promises;
3
+ const path = require('path');
4
+ const config = require('../config');
5
+
6
+
7
+ class AuditService {
8
+ constructor (org) {
9
+ this._org = org || null;
10
+ }
11
+
12
+ _file (cfgRef, orgAttr) {
13
+ return this._org && this._org[orgAttr] ? this._org[orgAttr] : cfgRef;
14
+ }
15
+
16
+ _getFile () {
17
+ return this._file(config.AUDIT_FILE, 'auditFile');
18
+ }
19
+
20
+ // ── Write ─────────────────────────────────────────────────────────────────
21
+ async log (actor, action, target, meta = {}, ip = '') {
22
+ console.log('AUDIT', { actor, action, target, ...meta });
23
+ const entry = JSON.stringify({
24
+ ts: new Date().toISOString(),
25
+ actor: actor || 'system',
26
+ action,
27
+ target: target || '',
28
+ ip: ip || '',
29
+ ...meta,
30
+ });
31
+ try {
32
+ await fsP.appendFile(this._getFile(), `${entry}\n`, 'utf8');
33
+ } catch {}
34
+ }
35
+
36
+ /** Track a page view with optional duration (seconds) */
37
+ async logPageView (username, page, durationSec, ip) {
38
+ await this.log(
39
+ username,
40
+ 'page_view',
41
+ page,
42
+ { durationSec: durationSec || 0 },
43
+ ip,
44
+ );
45
+ }
46
+
47
+ /** Track a log search / view event */
48
+ async logLogAccess (username, service, date, q, ip) {
49
+ await this.log(
50
+ username,
51
+ 'log_view',
52
+ service,
53
+ { date, q: q || '', filter: `${service}/${date}` },
54
+ ip,
55
+ );
56
+ }
57
+
58
+ // ── Read ──────────────────────────────────────────────────────────────────
59
+ async list ({ limit = 200, offset = 0, q = '', action = '' } = {}) {
60
+ try {
61
+ const raw = await fsP.readFile(this._getFile(), 'utf8');
62
+ let lines = raw.split('\n').filter(Boolean).reverse();
63
+ if (q) {
64
+ const lq = q.toLowerCase();
65
+ lines = lines.filter((l) => l.toLowerCase().includes(lq));
66
+ }
67
+ if (action) {
68
+ lines = lines.filter((l) => l.includes(`"action":"${action}"`));
69
+ }
70
+ const total = lines.length;
71
+ const page = lines.slice(offset, offset + limit).map((l) => {
72
+ try {
73
+ return JSON.parse(l);
74
+ } catch {
75
+ return { raw: l };
76
+ }
77
+ });
78
+ return { entries: page, total };
79
+ } catch (e) {
80
+ if (e.code === 'ENOENT') {
81
+ return { entries: [], total: 0 };
82
+ }
83
+ throw e;
84
+ }
85
+ }
86
+
87
+ async clear () {
88
+ await fsP.writeFile(this._getFile(), '', 'utf8');
89
+ }
90
+
91
+ // ── Analytics ─────────────────────────────────────────────────────────────
92
+ async getAnalytics (days = 7) {
93
+ try {
94
+ const raw = await fsP.readFile(this._getFile(), 'utf8');
95
+ const lines = raw.split('\n').filter(Boolean);
96
+ const cutoff = new Date(
97
+ Date.now() - days * 24 * 60 * 60 * 1000,
98
+ ).toISOString();
99
+
100
+ // Parse only entries within the window
101
+ const entries = [];
102
+ for (const l of lines) {
103
+ try {
104
+ const e = JSON.parse(l);
105
+ if (e.ts >= cutoff) {
106
+ entries.push(e);
107
+ }
108
+ } catch {}
109
+ }
110
+
111
+ // ── 1. Activity by day ─────────────────────────────────────────────
112
+ const byDay = {};
113
+ for (let i = 0; i < days; i++) {
114
+ const d = new Date();
115
+ d.setDate(d.getDate() - i);
116
+ byDay[d.toISOString().slice(0, 10)] = 0;
117
+ }
118
+ entries.forEach((e) => {
119
+ const day = e.ts.slice(0, 10);
120
+ if (day in byDay) {
121
+ byDay[day]++;
122
+ }
123
+ });
124
+ const dailyActivity = Object.entries(byDay)
125
+ .sort(([a], [b]) => a.localeCompare(b))
126
+ .map(([date, count]) => ({ date, count }));
127
+
128
+ // ── 2. Action breakdown ────────────────────────────────────────────
129
+ const actionCounts = {};
130
+ entries.forEach((e) => {
131
+ actionCounts[e.action] = (actionCounts[e.action] || 0) + 1;
132
+ });
133
+ const topActions = Object.entries(actionCounts)
134
+ .sort(([, a], [, b]) => b - a)
135
+ .slice(0, 12)
136
+ .map(([action, count]) => ({ action, count }));
137
+
138
+ // ── 3. Most active users ───────────────────────────────────────────
139
+ const userCounts = {};
140
+ entries.forEach((e) => {
141
+ if (e.actor && e.actor !== 'system') {
142
+ userCounts[e.actor] = (userCounts[e.actor] || 0) + 1;
143
+ }
144
+ });
145
+ const topUsers = Object.entries(userCounts)
146
+ .sort(([, a], [, b]) => b - a)
147
+ .slice(0, 10)
148
+ .map(([actor, count]) => ({ actor, count }));
149
+
150
+ // ── 4. Most viewed pages ───────────────────────────────────────────
151
+ const pageCounts = {};
152
+ const pageTime = {};
153
+ entries
154
+ .filter((e) => e.action === 'page_view')
155
+ .forEach((e) => {
156
+ const p = e.target || 'unknown';
157
+ pageCounts[p] = (pageCounts[p] || 0) + 1;
158
+ pageTime[p] = (pageTime[p] || 0) + (e.durationSec || 0);
159
+ });
160
+ const topPages = Object.entries(pageCounts)
161
+ .sort(([, a], [, b]) => b - a)
162
+ .slice(0, 10)
163
+ .map(([page, views]) => ({
164
+ page,
165
+ views,
166
+ avgDurationSec: Math.round((pageTime[page] || 0) / views),
167
+ }));
168
+
169
+ // ── 5. Most accessed services (log views) ─────────────────────────
170
+ const svcCounts = {};
171
+ entries
172
+ .filter((e) => e.action === 'log_view')
173
+ .forEach((e) => {
174
+ svcCounts[e.target] = (svcCounts[e.target] || 0) + 1;
175
+ });
176
+ const topServices = Object.entries(svcCounts)
177
+ .sort(([, a], [, b]) => b - a)
178
+ .slice(0, 10)
179
+ .map(([service, views]) => ({ service, views }));
180
+
181
+ // ── 6. Login timeline ──────────────────────────────────────────────
182
+ const loginByDay = {};
183
+ Object.keys(byDay).forEach((d) => (loginByDay[d] = 0));
184
+ entries
185
+ .filter((e) => e.action === 'login')
186
+ .forEach((e) => {
187
+ const d = e.ts.slice(0, 10);
188
+ if (d in loginByDay) {
189
+ loginByDay[d]++;
190
+ }
191
+ });
192
+ const loginTimeline = Object.entries(loginByDay)
193
+ .sort(([a], [b]) => a.localeCompare(b))
194
+ .map(([date, logins]) => ({ date, logins }));
195
+
196
+ // ── 7. Settings changes ────────────────────────────────────────────
197
+ const settingsChanges = entries.filter(
198
+ (e) => e.action === 'settings_update',
199
+ ).length;
200
+
201
+ // ── 8. Security events ─────────────────────────────────────────────
202
+ const securityEvents = entries.filter((e) =>
203
+ [
204
+ 'login_fail',
205
+ '2fa_enable',
206
+ '2fa_disable',
207
+ 'password_change',
208
+ 'role_change',
209
+ 'api_key_create',
210
+ 'api_key_delete',
211
+ ].includes(e.action),
212
+ );
213
+
214
+ return {
215
+ period: days,
216
+ totalEvents: entries.length,
217
+ uniqueUsers: Object.keys(userCounts).length,
218
+ dailyActivity,
219
+ topActions,
220
+ topUsers,
221
+ topPages,
222
+ topServices,
223
+ loginTimeline,
224
+ settingsChanges,
225
+ securityEvents: securityEvents.slice(-20).reverse(),
226
+ generatedAt: new Date().toISOString(),
227
+ };
228
+ } catch (e) {
229
+ if (e.code === 'ENOENT') {
230
+ return {
231
+ period: days,
232
+ totalEvents: 0,
233
+ uniqueUsers: 0,
234
+ dailyActivity: [],
235
+ topActions: [],
236
+ topUsers: [],
237
+ topPages: [],
238
+ topServices: [],
239
+ loginTimeline: [],
240
+ settingsChanges: 0,
241
+ securityEvents: [],
242
+ };
243
+ }
244
+ throw e;
245
+ }
246
+ }
247
+ }
248
+
249
+ module.exports = AuditService;
@@ -0,0 +1,234 @@
1
+ 'use strict';
2
+ const jwt = require('jsonwebtoken');
3
+ const bcrypt = require('bcryptjs');
4
+ const speakeasy = require('speakeasy');
5
+ const QRCode = require('qrcode');
6
+ const fsP = require('fs').promises;
7
+ const config = require('../config');
8
+ const logger = require('../lib/logger');
9
+
10
+ // ── Org-aware user store helpers ──────────────────────────────────────────
11
+ async function getUsers (org) {
12
+ const file = org ? org.usersFile : config.USERS_FILE;
13
+ try {
14
+ return JSON.parse(await fsP.readFile(file, 'utf8'));
15
+ } catch (e) {
16
+ if (e.code === 'ENOENT') {
17
+ return {};
18
+ }
19
+ throw e;
20
+ }
21
+ }
22
+
23
+ async function saveUsers (users, org) {
24
+ const file = org ? org.usersFile : config.USERS_FILE;
25
+ await fsP.writeFile(file, JSON.stringify(users, null, 2), 'utf8');
26
+ }
27
+
28
+ // ── Find which org a username belongs to (for login without org context) ──
29
+ async function findUserOrg (username) {
30
+ const OrgService = require('./OrgService');
31
+ const orgs = await OrgService.getOrgs();
32
+ for (const slug of Object.keys(orgs)) {
33
+ const p = OrgService.orgPaths(slug);
34
+ try {
35
+ const users = JSON.parse(await fsP.readFile(p.usersFile, 'utf8'));
36
+ if (users[username]) {
37
+ return { slug, user: users[username] };
38
+ }
39
+ } catch {}
40
+ }
41
+ return null;
42
+ }
43
+
44
+ class AuthService {
45
+ // ── Login ─────────────────────────────────────────────────────────────────
46
+ // orgSlug is optional — if not provided, searches all orgs (legacy/single-tenant mode)
47
+ async login (username, password, totpToken, orgSlug) {
48
+ if (!username || !password) {
49
+ throw Object.assign(new Error('Username and password required'), {
50
+ status: 400,
51
+ });
52
+ }
53
+
54
+ let user = null;
55
+ let resolvedOrgSlug = orgSlug;
56
+
57
+ if (orgSlug) {
58
+ // Org-specific login
59
+ const OrgService = require('./OrgService');
60
+ const org = await OrgService.getOrg(orgSlug);
61
+ if (!org) {
62
+ throw Object.assign(new Error('Org not found'), { status: 404 });
63
+ }
64
+ const users = await getUsers(org);
65
+ user = users[username];
66
+ } else {
67
+ // Legacy mode — search all orgs then fall back to flat users file
68
+ const found = await findUserOrg(username);
69
+ if (found) {
70
+ user = found.user;
71
+ resolvedOrgSlug = found.slug;
72
+ } else {
73
+ // Check super-admins.json
74
+ try {
75
+ const SFILE = require('path').join(config.DATA_DIR, 'super-admins.json');
76
+ const sas = JSON.parse(await fsP.readFile(SFILE, 'utf8'));
77
+ if (sas[username] && await bcrypt.compare(password, sas[username].password)) {
78
+ const t = jwt.sign({ username, role: 'super-admin', orgSlug: null }, config.JWT_SECRET, { expiresIn: '8h' });
79
+ logger.info('[Auth] Super-admin login: "' + username + '"');
80
+ return { token: t, role: 'super-admin', orgSlug: null, totpEnabled: false };
81
+ }
82
+ } catch {}
83
+ // Flat file fallback (pre-migration)
84
+ try {
85
+ const users = JSON.parse(
86
+ await fsP.readFile(config.USERS_FILE, 'utf8'),
87
+ );
88
+ user = users[username];
89
+ resolvedOrgSlug = 'default';
90
+ } catch {}
91
+ }
92
+ }
93
+
94
+ // Timing-safe comparison even when user doesn't exist
95
+ const dummyHash
96
+ = '$2a$10$invalidhashfortimingprotection000000000000000000000000';
97
+ const valid = await bcrypt.compare(
98
+ password,
99
+ user ? user.password : dummyHash,
100
+ );
101
+ if (!user || !valid) {
102
+ logger.warn(
103
+ `[Auth] Failed login: "${username}" (org: ${resolvedOrgSlug || 'none'})`,
104
+ );
105
+ throw Object.assign(new Error('Invalid credentials'), { status: 401 });
106
+ }
107
+
108
+ if (user.totpSecret) {
109
+ if (!totpToken) {
110
+ throw Object.assign(new Error('2FA token required'), { status: 401 });
111
+ }
112
+ const ok = speakeasy.totp.verify({
113
+ secret: user.totpSecret,
114
+ encoding: 'base32',
115
+ token: totpToken,
116
+ window: 1,
117
+ });
118
+ if (!ok) {
119
+ throw Object.assign(new Error('Invalid 2FA token'), { status: 401 });
120
+ }
121
+ }
122
+
123
+ // JWT includes orgSlug so every subsequent request knows the org
124
+ const payload = {
125
+ username,
126
+ role: user.role || 'viewer',
127
+ orgSlug: resolvedOrgSlug || 'default',
128
+ };
129
+ const token = jwt.sign(payload, config.JWT_SECRET, {
130
+ expiresIn: config.JWT_EXPIRES_IN || '24h',
131
+ });
132
+ logger.info(
133
+ `[Auth] Login: "${username}" (org: ${resolvedOrgSlug}, role: ${user.role || 'viewer'})`,
134
+ );
135
+ return {
136
+ token,
137
+ role: user.role || 'viewer',
138
+ orgSlug: resolvedOrgSlug || 'default',
139
+ totpEnabled: !!user.totpSecret,
140
+ };
141
+ }
142
+
143
+ // ── Super-admin login (no org — crosses all orgs) ─────────────────────────
144
+ async superAdminLogin (username, password) {
145
+ const SUPER_FILE = require('path').join(
146
+ config.DATA_DIR,
147
+ 'super-admins.json',
148
+ );
149
+ let superAdmins = {};
150
+ try {
151
+ superAdmins = JSON.parse(await fsP.readFile(SUPER_FILE, 'utf8'));
152
+ } catch {}
153
+ const sa = superAdmins[username];
154
+ const dummyHash
155
+ = '$2a$10$invalidhashfortimingprotection000000000000000000000000';
156
+ const valid = await bcrypt.compare(password, sa ? sa.password : dummyHash);
157
+ if (!sa || !valid) {
158
+ throw Object.assign(new Error('Invalid super-admin credentials'), {
159
+ status: 401,
160
+ });
161
+ }
162
+ const token = jwt.sign(
163
+ { username, role: 'super-admin', orgSlug: null },
164
+ config.JWT_SECRET,
165
+ { expiresIn: '8h' },
166
+ );
167
+ return { token, role: 'super-admin' };
168
+ }
169
+
170
+ // ── TOTP ──────────────────────────────────────────────────────────────────
171
+ async setupTotp (username, org) {
172
+ const secret = speakeasy.generateSecret({
173
+ length: 20,
174
+ name: `LogBoard (${username})`,
175
+ });
176
+ const qr = await QRCode.toDataURL(secret.otpauth_url);
177
+ return { secret: secret.base32, otpauth: secret.otpauth_url, qr };
178
+ }
179
+
180
+ async enableTotp (username, base32Secret, token, org) {
181
+ const ok = speakeasy.totp.verify({
182
+ secret: base32Secret,
183
+ encoding: 'base32',
184
+ token,
185
+ window: 1,
186
+ });
187
+ if (!ok) {
188
+ throw Object.assign(new Error('Invalid OTP'), { status: 400 });
189
+ }
190
+ const users = await getUsers(org);
191
+ if (!users[username]) {
192
+ throw Object.assign(new Error('User not found'), { status: 404 });
193
+ }
194
+ users[username].totpSecret = base32Secret;
195
+ await saveUsers(users, org);
196
+ }
197
+
198
+ async disableTotp (username, org) {
199
+ const users = await getUsers(org);
200
+ if (!users[username]) {
201
+ throw Object.assign(new Error('User not found'), { status: 404 });
202
+ }
203
+ delete users[username].totpSecret;
204
+ await saveUsers(users, org);
205
+ }
206
+
207
+ async getTotpStatus (username, org) {
208
+ const users = await getUsers(org);
209
+ return { enabled: !!users[username]?.totpSecret };
210
+ }
211
+
212
+ // ── Password change ────────────────────────────────────────────────────────
213
+ async changePassword (username, oldPassword, newPassword, org) {
214
+ if (!newPassword || newPassword.length < 6) {
215
+ throw Object.assign(new Error('Password must be at least 6 chars'), {
216
+ status: 400,
217
+ });
218
+ }
219
+ const users = await getUsers(org);
220
+ if (!users[username]) {
221
+ throw Object.assign(new Error('User not found'), { status: 404 });
222
+ }
223
+ const valid = await bcrypt.compare(oldPassword, users[username].password);
224
+ if (!valid) {
225
+ throw Object.assign(new Error('Current password incorrect'), {
226
+ status: 401,
227
+ });
228
+ }
229
+ users[username].password = await bcrypt.hash(newPassword, 10);
230
+ await saveUsers(users, org);
231
+ }
232
+ }
233
+
234
+ module.exports = AuthService;
@@ -0,0 +1,49 @@
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, 'bookmarks.json');
8
+
9
+ class BookmarkService {
10
+ async _load () { try { return JSON.parse(await fsP.readFile(FILE, 'utf8')); } catch { return {}; } }
11
+ async _save (obj) { await fsP.writeFile(FILE, JSON.stringify(obj, null, 2), 'utf8'); }
12
+
13
+ async getAll (username) {
14
+ const db = await this._load();
15
+ return (db[username] || []).sort((a, b) => new Date(b.createdAt)-new Date(a.createdAt));
16
+ }
17
+
18
+ async create (username, { service, date, lineIndex, line, note }) {
19
+ const db = await this._load();
20
+ if (!db[username]) { db[username] = []; }
21
+ // Prevent duplicate bookmarks of same line
22
+ const exists = db[username].find((b) => b.service===service && b.date===date && b.lineIndex===lineIndex);
23
+ if (exists) { if (note) { exists.note = note; await this._save(db); } return exists; }
24
+ const entry = {
25
+ id: crypto.randomUUID(), service, date, lineIndex: Number(lineIndex),
26
+ line: (line||'').slice(0, 500), note: note||'',
27
+ createdAt: new Date().toISOString(),
28
+ };
29
+ db[username].unshift(entry);
30
+ if (db[username].length > 200) { db[username] = db[username].slice(0, 200); }
31
+ await this._save(db);
32
+ return entry;
33
+ }
34
+
35
+ async updateNote (username, id, note) {
36
+ const db = await this._load();
37
+ const bm = (db[username]||[]).find((b) => b.id===id);
38
+ if (bm) { bm.note = note; await this._save(db); }
39
+ return bm;
40
+ }
41
+
42
+ async delete (username, id) {
43
+ const db = await this._load();
44
+ db[username] = (db[username]||[]).filter((b) => b.id !== id);
45
+ await this._save(db);
46
+ }
47
+ }
48
+
49
+ module.exports = new BookmarkService();
@@ -0,0 +1,44 @@
1
+ 'use strict';
2
+ const fsP = require('fs').promises;
3
+ const path = require('path');
4
+ const config= require('../config');
5
+
6
+ const FILE = () => path.join(config.DATA_DIR, 'global-settings.json');
7
+
8
+ const DEFAULTS = {
9
+ registrationMode: 'open', // 'open' | 'invite'
10
+ defaultPlan: 'free',
11
+ githubClientId: '',
12
+ githubClientSecret: '',
13
+ googleClientId: '',
14
+ googleClientSecret: '',
15
+ smtpHost: '',
16
+ smtpPort: 587,
17
+ smtpUser: '',
18
+ smtpPass: '',
19
+ smtpFrom: '',
20
+ appBaseUrl: 'http://localhost:9900',
21
+ };
22
+
23
+ class GlobalSettingsService {
24
+ async get() {
25
+ try { return { ...DEFAULTS, ...JSON.parse(await fsP.readFile(FILE(),'utf8')) }; }
26
+ catch(e) { if(e.code==='ENOENT') return { ...DEFAULTS }; throw e; }
27
+ }
28
+
29
+ async save(upd = {}) {
30
+ const cur = await this.get();
31
+ const next = { ...cur };
32
+ const allowed = ['registrationMode','defaultPlan','githubClientId','githubClientSecret',
33
+ 'googleClientId','googleClientSecret','smtpHost','smtpPort','smtpUser',
34
+ 'smtpPass','smtpFrom','appBaseUrl'];
35
+ for (const k of allowed) {
36
+ if (upd[k] !== undefined && upd[k] !== '') next[k] = upd[k];
37
+ }
38
+ await fsP.mkdir(config.DATA_DIR, { recursive: true });
39
+ await fsP.writeFile(FILE(), JSON.stringify(next, null, 2), 'utf8');
40
+ return next;
41
+ }
42
+ }
43
+
44
+ module.exports = new GlobalSettingsService();