lobsterboard 0.3.2 → 0.3.3

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.
@@ -1,4 +1,4 @@
1
- /* LobsterBoard v0.3.2 - Dashboard Styles */
1
+ /* LobsterBoard v0.3.3 - Dashboard Styles */
2
2
  /* LobsterBoard Dashboard - Generated Styles */
3
3
 
4
4
  :root {
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * LobsterBoard v0.3.2
2
+ * LobsterBoard v0.3.3
3
3
  * Dashboard builder with customizable widgets
4
4
  * https://github.com/curbob/LobsterBoard
5
5
  * @license MIT
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * LobsterBoard v0.3.2
2
+ * LobsterBoard v0.3.3
3
3
  * Dashboard builder with customizable widgets
4
4
  * https://github.com/curbob/LobsterBoard
5
5
  * @license MIT
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * LobsterBoard v0.3.2
2
+ * LobsterBoard v0.3.3
3
3
  * Dashboard builder with customizable widgets
4
4
  * https://github.com/curbob/LobsterBoard
5
5
  * @license MIT
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * LobsterBoard v0.3.2
2
+ * LobsterBoard v0.3.3
3
3
  * Dashboard builder with customizable widgets
4
4
  * https://github.com/curbob/LobsterBoard
5
5
  * @license MIT
package/js/builder.js CHANGED
@@ -3290,32 +3290,35 @@ async function openDirBrowser(startDir) {
3290
3290
  try {
3291
3291
  const res = await fetch('/api/browse-dirs?dir=' + encodeURIComponent(dir));
3292
3292
  const data = await res.json();
3293
- if (data.status !== 'ok') { browser.innerHTML = `<span style="color:#f85149;">${data.message}</span>`; return; }
3294
- let html = `<div style="margin-bottom:6px;color:var(--text-secondary);font-size:11px;word-break:break-all;">${data.path}</div>`;
3293
+ if (data.status !== 'ok') { browser.innerHTML = `<span style="color:#f85149;">${escapeHtml(data.message)}</span>`; return; }
3294
+ let html = `<div style="margin-bottom:6px;color:var(--text-secondary);font-size:11px;word-break:break-all;">${escapeHtml(data.path)}</div>`;
3295
3295
  if (data.imageCount > 0) {
3296
- html += `<div style="margin-bottom:6px;padding:4px 8px;background:var(--bg-secondary);border-radius:4px;color:#3fb950;font-size:11px;">📷 ${data.imageCount} image${data.imageCount !== 1 ? 's' : ''} in this folder</div>`;
3296
+ html += `<div style="margin-bottom:6px;padding:4px 8px;background:var(--bg-secondary);border-radius:4px;color:#3fb950;font-size:11px;">📷 ${escapeHtml(String(data.imageCount))} image${data.imageCount !== 1 ? 's' : ''} in this folder</div>`;
3297
3297
  }
3298
3298
  // Up one level
3299
3299
  const parent = data.path.replace(/\/[^/]+\/?$/, '') || '/';
3300
3300
  if (data.path !== parent) {
3301
- html += `<div class="dir-entry" data-path="${parent}" style="cursor:pointer;padding:3px 6px;border-radius:4px;color:var(--text-primary);" onmouseover="this.style.background='var(--bg-secondary)'" onmouseout="this.style.background='none'">📁 ..</div>`;
3301
+ html += `<div class="dir-entry" data-path="${escapeHtml(parent)}" style="cursor:pointer;padding:3px 6px;border-radius:4px;color:var(--text-primary);" onmouseover="this.style.background='var(--bg-secondary)'" onmouseout="this.style.background='none'">📁 ..</div>`;
3302
3302
  }
3303
3303
  for (const d of data.dirs) {
3304
3304
  const full = data.path + '/' + d;
3305
- html += `<div class="dir-entry" data-path="${full}" style="cursor:pointer;padding:3px 6px;border-radius:4px;color:var(--text-primary);" onmouseover="this.style.background='var(--bg-secondary)'" onmouseout="this.style.background='none'">📁 ${d}</div>`;
3305
+ html += `<div class="dir-entry" data-path="${escapeHtml(full)}" style="cursor:pointer;padding:3px 6px;border-radius:4px;color:var(--text-primary);" onmouseover="this.style.background='var(--bg-secondary)'" onmouseout="this.style.background='none'">📁 ${escapeHtml(d)}</div>`;
3306
3306
  }
3307
3307
  if (data.dirs.length === 0 && data.imageCount === 0) {
3308
3308
  html += `<div style="color:var(--text-muted);font-size:11px;padding:4px;">Empty directory</div>`;
3309
3309
  }
3310
3310
  html += `<div style="margin-top:8px;display:flex;gap:4px;">`;
3311
- html += `<button type="button" onclick="selectDir('${data.path.replace(/'/g, "\\'")}')" style="flex:1;padding:4px 8px;background:var(--accent-blue);color:#fff;border:none;border-radius:4px;cursor:pointer;font-size:11px;">✓ Select this folder</button>`;
3311
+ html += `<button type="button" style="flex:1;padding:4px 8px;background:var(--accent-blue);color:#fff;border:none;border-radius:4px;cursor:pointer;font-size:11px;">✓ Select this folder</button>`;
3312
3312
  html += `<button type="button" onclick="document.getElementById('dir-browser').style.display='none'" style="padding:4px 8px;background:var(--bg-secondary);color:var(--text-primary);border:1px solid var(--border-color);border-radius:4px;cursor:pointer;font-size:11px;">Cancel</button>`;
3313
3313
  html += `</div>`;
3314
3314
  browser.innerHTML = html;
3315
+ // Attach select button handler safely (avoid inline onclick with path data)
3316
+ const selectBtn = browser.querySelector('button');
3317
+ if (selectBtn) selectBtn.addEventListener('click', () => selectDir(data.path));
3315
3318
  browser.querySelectorAll('.dir-entry').forEach(el => {
3316
3319
  el.addEventListener('click', () => openDirBrowser(el.dataset.path));
3317
3320
  });
3318
- } catch (e) { browser.innerHTML = `<span style="color:#f85149;">Error: ${e.message}</span>`; }
3321
+ } catch (e) { browser.innerHTML = `<span style="color:#f85149;">Error: ${escapeHtml(e.message)}</span>`; }
3319
3322
  }
3320
3323
 
3321
3324
  function selectDir(dirPath) {
package/js/templates.js CHANGED
@@ -2,6 +2,13 @@
2
2
  * LobsterBoard Template Gallery System
3
3
  */
4
4
  (function() {
5
+ function _esc(str) {
6
+ if (str == null) return '';
7
+ const div = document.createElement('div');
8
+ div.textContent = String(str);
9
+ return div.innerHTML;
10
+ }
11
+
5
12
  const galleryModal = document.getElementById('template-gallery-modal');
6
13
  const exportModal = document.getElementById('template-export-modal');
7
14
  const tplGrid = document.getElementById('tpl-grid');
@@ -88,19 +95,19 @@
88
95
  return;
89
96
  }
90
97
  tplGrid.innerHTML = templates.map(t => `
91
- <div class="tpl-card" data-id="${t.id}">
98
+ <div class="tpl-card" data-id="${_esc(t.id)}">
92
99
  <div class="tpl-card-img">
93
- <img src="/api/templates/${t.id}/preview" alt="${t.name}" onerror="this.parentElement.innerHTML='<div class=\\'tpl-no-preview\\'>🦞</div>'">
100
+ <img src="/api/templates/${_esc(t.id)}/preview" alt="${_esc(t.name)}" onerror="this.parentElement.innerHTML='<div class=\\'tpl-no-preview\\'>🦞</div>'">
94
101
  </div>
95
102
  <div class="tpl-card-body">
96
- <h3>${t.name}</h3>
97
- <p>${t.description || ''}</p>
103
+ <h3>${_esc(t.name)}</h3>
104
+ <p>${_esc(t.description || '')}</p>
98
105
  <div class="tpl-card-meta">
99
- <span>${t.widgetCount || 0} widgets</span>
100
- <span>${t.canvasSize || ''}</span>
106
+ <span>${_esc(String(t.widgetCount || 0))} widgets</span>
107
+ <span>${_esc(t.canvasSize || '')}</span>
101
108
  </div>
102
- ${(t.widgetTypes || []).length ? `<div style="margin-top:4px;font-size:10px;color:var(--text-muted);">${t.widgetTypes.slice(0,6).map(w => (w.icon || '') + ' ' + w.name).join(' · ')}${t.widgetTypes.length > 6 ? ' · +' + (t.widgetTypes.length - 6) + ' more' : ''}</div>` : ''}
103
- <div class="tpl-card-tags">${(t.tags || []).map(tag => `<span class="tpl-tag">${tag}</span>`).join('')}</div>
109
+ ${(t.widgetTypes || []).length ? `<div style="margin-top:4px;font-size:10px;color:var(--text-muted);">${t.widgetTypes.slice(0,6).map(w => _esc((w.icon || '') + ' ' + w.name)).join(' · ')}${t.widgetTypes.length > 6 ? ' · +' + (t.widgetTypes.length - 6) + ' more' : ''}</div>` : ''}
110
+ <div class="tpl-card-tags">${(t.tags || []).map(tag => `<span class="tpl-tag">${_esc(tag)}</span>`).join('')}</div>
104
111
  </div>
105
112
  </div>
106
113
  `).join('');
@@ -122,13 +129,13 @@
122
129
  document.getElementById('tpl-detail-name').textContent = selectedTemplate.name;
123
130
  document.getElementById('tpl-detail-desc').textContent = selectedTemplate.description || '';
124
131
  document.getElementById('tpl-detail-meta').innerHTML = `
125
- <div><strong>Author:</strong> ${selectedTemplate.author || 'anonymous'}</div>
126
- <div><strong>Canvas:</strong> ${selectedTemplate.canvasSize || 'unknown'}</div>
127
- <div><strong>Widgets:</strong> ${selectedTemplate.widgetCount || 0}</div>
128
- ${(selectedTemplate.requiresSetup || []).length ? `<div><strong>Requires:</strong> ${selectedTemplate.requiresSetup.join(', ')}</div>` : ''}
129
- ${(selectedTemplate.widgetTypes || []).length ? `<div style="margin-top:8px;"><strong>Widget Types:</strong><div style="margin-top:4px;">${selectedTemplate.widgetTypes.map(w => `<span style="display:inline-block;padding:2px 8px;margin:2px;background:var(--bg-tertiary);border-radius:4px;font-size:11px;">${w.icon || ''} ${w.name}${w.count > 1 ? ' ×' + w.count : ''}</span>`).join('')}</div></div>` : ''}
132
+ <div><strong>Author:</strong> ${_esc(selectedTemplate.author || 'anonymous')}</div>
133
+ <div><strong>Canvas:</strong> ${_esc(selectedTemplate.canvasSize || 'unknown')}</div>
134
+ <div><strong>Widgets:</strong> ${_esc(String(selectedTemplate.widgetCount || 0))}</div>
135
+ ${(selectedTemplate.requiresSetup || []).length ? `<div><strong>Requires:</strong> ${(selectedTemplate.requiresSetup || []).map(s => _esc(s)).join(', ')}</div>` : ''}
136
+ ${(selectedTemplate.widgetTypes || []).length ? `<div style="margin-top:8px;"><strong>Widget Types:</strong><div style="margin-top:4px;">${selectedTemplate.widgetTypes.map(w => `<span style="display:inline-block;padding:2px 8px;margin:2px;background:var(--bg-tertiary);border-radius:4px;font-size:11px;">${_esc((w.icon || '') + ' ' + w.name)}${w.count > 1 ? ' ×' + _esc(String(w.count)) : ''}</span>`).join('')}</div></div>` : ''}
130
137
  `;
131
- document.getElementById('tpl-detail-tags').innerHTML = (selectedTemplate.tags || []).map(t => `<span class="tpl-tag">${t}</span>`).join('');
138
+ document.getElementById('tpl-detail-tags').innerHTML = (selectedTemplate.tags || []).map(t => `<span class="tpl-tag">${_esc(t)}</span>`).join('');
132
139
  }
133
140
 
134
141
  document.getElementById('tpl-back').addEventListener('click', () => {
@@ -267,16 +274,16 @@
267
274
  body: JSON.stringify({ data: screenshotData })
268
275
  });
269
276
  }
270
- resultEl.innerHTML = `✅ Template exported as <strong>${data.id}</strong>${screenshotData ? '<br>Screenshot captured!' : '<br>⚠️ No screenshot (auto-capture failed).'}`;
277
+ resultEl.innerHTML = `✅ Template exported as <strong>${_esc(data.id)}</strong>${screenshotData ? '<br>Screenshot captured!' : '<br>⚠️ No screenshot (auto-capture failed).'}`;
271
278
  resultEl.className = 'tpl-export-result tpl-export-success';
272
279
  } else {
273
- resultEl.innerHTML = `❌ ${data.error || 'Export failed'}`;
280
+ resultEl.innerHTML = `❌ ${_esc(data.error || 'Export failed')}`;
274
281
  resultEl.className = 'tpl-export-result tpl-export-error';
275
282
  }
276
283
  resultEl.style.display = 'block';
277
284
  } catch (e) {
278
285
  const resultEl = document.getElementById('tpl-export-result');
279
- resultEl.innerHTML = `❌ Export failed: ${e.message}`;
286
+ resultEl.innerHTML = `❌ Export failed: ${_esc(e.message)}`;
280
287
  resultEl.className = 'tpl-export-result tpl-export-error';
281
288
  resultEl.style.display = 'block';
282
289
  }
package/js/widgets.js CHANGED
@@ -3,6 +3,29 @@
3
3
  * Each widget defines its default size, properties, and generated code
4
4
  */
5
5
 
6
+ // ─────────────────────────────────────────────
7
+ // Security helpers (available to generated widget scripts via window)
8
+ // ─────────────────────────────────────────────
9
+
10
+ function _escHtmlGlobal(str) {
11
+ if (str == null) return '';
12
+ const div = document.createElement('div');
13
+ div.textContent = String(str);
14
+ return div.innerHTML;
15
+ }
16
+ window._esc = _escHtmlGlobal;
17
+
18
+ function _isSafeUrl(url) {
19
+ if (!url) return false;
20
+ try {
21
+ const u = new URL(url);
22
+ return u.protocol === 'https:' || u.protocol === 'http:';
23
+ } catch (e) {
24
+ return false;
25
+ }
26
+ }
27
+ window._isSafeUrl = _isSafeUrl;
28
+
6
29
  // ─────────────────────────────────────────────
7
30
  // Icon System - Themeable widget icons
8
31
  // ─────────────────────────────────────────────
@@ -287,8 +310,8 @@ const WIDGETS = {
287
310
  }
288
311
  }));
289
312
 
290
- container.innerHTML = results.map(r =>
291
- '<div class="weather-row"><span class="weather-icon lb-icon" data-icon="' + r.iconId + '">' + r.emoji + '</span><span class="weather-loc">' + r.loc + '</span><span class="weather-temp">' + r.temp + unitSymbol + '</span></div>'
313
+ container.innerHTML = results.map(r =>
314
+ '<div class="weather-row"><span class="weather-icon lb-icon" data-icon="' + _esc(r.iconId) + '">' + _esc(r.emoji) + '</span><span class="weather-loc">' + _esc(r.loc) + '</span><span class="weather-temp">' + _esc(r.temp) + _esc(unitSymbol) + '</span></div>'
292
315
  ).join('');
293
316
  }
294
317
  update_${props.id.replace(/-/g, '_')}();
@@ -746,11 +769,11 @@ const WIDGETS = {
746
769
  const fs = 'calc(12px * var(--font-scale, 1))';
747
770
  list.innerHTML = activities.slice(0, ${props.maxItems || 10}).map(a => {
748
771
  const icon = a.status === 'ok' ? '✓' : a.status === 'error' ? '❌' : '';
749
- const text = (a.text || '').replace(/</g, '&lt;');
750
- const source = (a.source || '').replace(/</g, '&lt;');
772
+ const text = _esc(a.text || '');
773
+ const source = _esc(a.source || '');
751
774
  return '<div style="display:flex;align-items:flex-start;justify-content:space-between;padding:4px 0;border-bottom:1px solid #30363d;font-size:' + fs + ';">' +
752
- '<div style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">' + (a.icon || '') + ' ' + text + '</div>' +
753
- '<div style="flex-shrink:0;font-size:0.85em;color:#8b949e;margin-left:8px;">' + icon + ' ' + source + '</div>' +
775
+ '<div style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">' + _esc(a.icon || '') + ' ' + text + '</div>' +
776
+ '<div style="flex-shrink:0;font-size:0.85em;color:#8b949e;margin-left:8px;">' + _esc(icon) + ' ' + source + '</div>' +
754
777
  '</div>';
755
778
  }).join('');
756
779
  } catch (e) { console.error('Today widget error:', e); }
@@ -815,12 +838,12 @@ const WIDGETS = {
815
838
  const lastRun = job.lastRun ? new Date(job.lastRun).toLocaleTimeString('en-US', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }) : 'Never';
816
839
  const statusBadge = job.lastStatus ? (job.lastStatus === 'ok' ? '✓' : '✗') : '';
817
840
  return '<div class="cron-item" style="display:flex;align-items:center;gap:8px;padding:4px 0;border-bottom:1px solid var(--border,#30363d);font-size:calc(13px * var(--font-scale, 1));">' +
818
- '<span style="flex-shrink:0;">' + statusDot + '</span>' +
841
+ '<span style="flex-shrink:0;">' + _esc(statusDot) + '</span>' +
819
842
  '<div style="flex:1;min-width:0;">' +
820
- '<div style="font-weight:500;">' + job.name + '</div>' +
843
+ '<div style="font-weight:500;">' + _esc(job.name) + '</div>' +
821
844
  '</div>' +
822
845
  '<div style="text-align:right;font-size:0.8em;opacity:0.6;flex-shrink:0;">' +
823
- '<div>' + statusBadge + ' ' + lastRun + '</div>' +
846
+ '<div>' + _esc(statusBadge) + ' ' + _esc(lastRun) + '</div>' +
824
847
  '</div>' +
825
848
  '</div>';
826
849
  }).join('');
@@ -2788,12 +2811,12 @@ const WIDGETS = {
2788
2811
  container.style.display = 'grid';
2789
2812
  container.style.gridTemplateColumns = 'repeat(' + cols + ', 1fr)';
2790
2813
  container.style.gap = '4px';
2791
- container.innerHTML = links.map(link => {
2814
+ container.innerHTML = links.filter(link => _isSafeUrl(link.url)).map(link => {
2792
2815
  const domain = new URL(link.url).hostname;
2793
- const favicon = 'https://www.google.com/s2/favicons?sz=32&domain=' + domain;
2794
- return '<a href="' + link.url + '" class="quick-link" target="_blank" style="display:flex;align-items:center;gap:8px;padding:6px 4px;text-decoration:none;color:var(--text-primary);border-bottom:1px solid var(--border);overflow:hidden;">' +
2816
+ const favicon = 'https://www.google.com/s2/favicons?sz=32&domain=' + _esc(domain);
2817
+ return '<a href="' + _esc(link.url) + '" class="quick-link" target="_blank" rel="noopener noreferrer" style="display:flex;align-items:center;gap:8px;padding:6px 4px;text-decoration:none;color:var(--text-primary);border-bottom:1px solid var(--border);overflow:hidden;">' +
2795
2818
  '<img src="' + favicon + '" style="width:16px;height:16px;flex-shrink:0;" onerror="this.style.display=\\'none\\'">' +
2796
- '<span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">' + link.name + '</span>' +
2819
+ '<span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">' + _esc(link.name) + '</span>' +
2797
2820
  '</a>';
2798
2821
  }).join('');
2799
2822
  })();
@@ -2823,7 +2846,7 @@ const WIDGETS = {
2823
2846
  <span class="dash-card-title">${renderIcon('embed')} ${props.title || 'Embed'}</span>
2824
2847
  </div>
2825
2848
  <div class="dash-card-body" style="padding:0;overflow:hidden;">
2826
- <iframe src="${props.embedUrl || 'about:blank'}" style="width:100%;height:100%;border:none;" ${props.allowFullscreen ? 'allowfullscreen' : ''}></iframe>
2849
+ <iframe src="${_isSafeUrl(props.embedUrl) ? props.embedUrl : 'about:blank'}" style="width:100%;height:100%;border:none;" ${props.allowFullscreen ? 'allowfullscreen' : ''}></iframe>
2827
2850
  </div>
2828
2851
  </div>`,
2829
2852
  generateJs: (props) => `
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lobsterboard",
3
- "version": "0.3.2",
3
+ "version": "0.3.3",
4
4
  "description": "Self-hosted drag-and-drop dashboard builder with 50 widgets, template gallery, and custom pages. Works standalone or with OpenClaw.",
5
5
  "keywords": [
6
6
  "dashboard",
@@ -63,6 +63,8 @@
63
63
  "build": "rollup -c",
64
64
  "build:watch": "rollup -c -w",
65
65
  "prebuild": "rm -rf dist",
66
+ "start": "node server.cjs",
67
+ "dev": "node server.cjs",
66
68
  "prepublishOnly": "npm run build"
67
69
  },
68
70
  "devDependencies": {
@@ -71,7 +73,7 @@
71
73
  "rollup-plugin-copy": "^3.5.0"
72
74
  },
73
75
  "engines": {
74
- "node": ">=16.0.0"
76
+ "node": ">=16.0.0 <23.0.0"
75
77
  },
76
78
  "dependencies": {
77
79
  "systeminformation": "^5.30.7"
package/server.cjs CHANGED
@@ -298,10 +298,78 @@ const AUTH_FILE = path.join(__dirname, 'auth.json');
298
298
  const SECRETS_FILE = path.join(__dirname, 'secrets.json');
299
299
 
300
300
  // ─────────────────────────────────────────────
301
- // Security helpers
301
+ // Server-side Session Authentication
302
302
  // ─────────────────────────────────────────────
303
303
  const crypto = require('crypto');
304
304
 
305
+ const DASHBOARD_PASSWORD = process.env.DASHBOARD_PASSWORD || null;
306
+ const SESSION_TTL_MS = (parseInt(process.env.SESSION_TTL_HOURS) || 24) * 60 * 60 * 1000;
307
+
308
+ // In-memory session store: token -> expiresAt timestamp
309
+ const sessions = new Map();
310
+
311
+ // Rate limit store: ip -> { count, resetAt }
312
+ const loginAttempts = new Map();
313
+ const MAX_LOGIN_ATTEMPTS = 5;
314
+ const LOCKOUT_MS = 15 * 60 * 1000;
315
+
316
+ function generateSessionToken() {
317
+ return crypto.randomBytes(32).toString('hex'); // 64-char hex
318
+ }
319
+
320
+ function createSession() {
321
+ const token = generateSessionToken();
322
+ sessions.set(token, Date.now() + SESSION_TTL_MS);
323
+ return token;
324
+ }
325
+
326
+ function isValidSession(token) {
327
+ if (!token || !/^[a-f0-9]{64}$/.test(token)) return false;
328
+ const exp = sessions.get(token);
329
+ if (!exp) return false;
330
+ if (Date.now() > exp) { sessions.delete(token); return false; }
331
+ return true;
332
+ }
333
+
334
+ function getSessionCookie(req) {
335
+ const cookie = req.headers.cookie || '';
336
+ const match = cookie.match(/(?:^|;\s*)lb_session=([a-f0-9]{64})/);
337
+ return match ? match[1] : null;
338
+ }
339
+
340
+ function checkPassword(input) {
341
+ if (!DASHBOARD_PASSWORD || !input) return false;
342
+ // HMAC both inputs so timingSafeEqual always compares equal-length buffers
343
+ const inputHash = crypto.createHmac('sha256', 'lb-session-auth').update(String(input)).digest();
344
+ const correctHash = crypto.createHmac('sha256', 'lb-session-auth').update(DASHBOARD_PASSWORD).digest();
345
+ return crypto.timingSafeEqual(inputHash, correctHash);
346
+ }
347
+
348
+ function isRateLimited(ip) {
349
+ const entry = loginAttempts.get(ip);
350
+ if (!entry) return false;
351
+ if (Date.now() > entry.resetAt) { loginAttempts.delete(ip); return false; }
352
+ return entry.count >= MAX_LOGIN_ATTEMPTS;
353
+ }
354
+
355
+ function recordFailedAttempt(ip) {
356
+ const entry = loginAttempts.get(ip) || { count: 0, resetAt: Date.now() + LOCKOUT_MS };
357
+ entry.count++;
358
+ loginAttempts.set(ip, entry);
359
+ }
360
+
361
+ // Clean expired sessions every hour
362
+ setInterval(() => {
363
+ const now = Date.now();
364
+ for (const [token, exp] of sessions) {
365
+ if (now > exp) sessions.delete(token);
366
+ }
367
+ }, 60 * 60 * 1000);
368
+
369
+ // ─────────────────────────────────────────────
370
+ // Security helpers
371
+ // ─────────────────────────────────────────────
372
+
305
373
  function hashPin(pin) {
306
374
  return crypto.createHash('sha256').update(pin).digest('hex');
307
375
  }
@@ -487,6 +555,77 @@ const server = http.createServer(async (req, res) => {
487
555
  const parsedUrl = new URL(req.url, `http://${req.headers.host}`);
488
556
  const pathname = parsedUrl.pathname;
489
557
 
558
+ // ── Auth: serve login page (always accessible) ──
559
+ if (req.method === 'GET' && pathname === '/login') {
560
+ const loginPath = path.join(__dirname, 'login.html');
561
+ fs.readFile(loginPath, (err, data) => {
562
+ if (err) { sendResponse(res, 404, 'text/plain', 'Login page not found'); return; }
563
+ sendResponse(res, 200, 'text/html', data);
564
+ });
565
+ return;
566
+ }
567
+
568
+ // ── Auth: login action ──
569
+ if (req.method === 'POST' && pathname === '/api/auth/login') {
570
+ if (!DASHBOARD_PASSWORD) {
571
+ sendJson(res, 200, { status: 'ok', redirect: '/' });
572
+ return;
573
+ }
574
+ const ip = req.socket.remoteAddress || 'unknown';
575
+ if (isRateLimited(ip)) {
576
+ sendJson(res, 429, { error: 'Too many failed attempts. Try again in 15 minutes.' });
577
+ return;
578
+ }
579
+ let body = '';
580
+ req.on('data', c => { body += c; if (body.length > 4096) req.destroy(); });
581
+ req.on('end', () => {
582
+ try {
583
+ const { password } = JSON.parse(body);
584
+ if (checkPassword(password)) {
585
+ const token = createSession();
586
+ const cookieOpts = `Path=/; HttpOnly; SameSite=Strict; Max-Age=${SESSION_TTL_MS / 1000}`;
587
+ res.writeHead(200, {
588
+ 'Content-Type': 'application/json',
589
+ 'Set-Cookie': `lb_session=${token}; ${cookieOpts}`
590
+ });
591
+ res.end(JSON.stringify({ status: 'ok', redirect: '/' }));
592
+ } else {
593
+ recordFailedAttempt(ip);
594
+ sendJson(res, 401, { error: 'Invalid password' });
595
+ }
596
+ } catch (e) {
597
+ sendJson(res, 400, { error: 'Invalid request' });
598
+ }
599
+ });
600
+ return;
601
+ }
602
+
603
+ // ── Auth: logout ──
604
+ if (req.method === 'POST' && pathname === '/api/auth/logout') {
605
+ const token = getSessionCookie(req);
606
+ if (token) sessions.delete(token);
607
+ res.writeHead(302, {
608
+ 'Set-Cookie': 'lb_session=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0',
609
+ 'Location': '/login'
610
+ });
611
+ res.end();
612
+ return;
613
+ }
614
+
615
+ // ── Auth: enforce session for all other routes ──
616
+ if (DASHBOARD_PASSWORD) {
617
+ const token = getSessionCookie(req);
618
+ if (!isValidSession(token)) {
619
+ if (pathname.startsWith('/api/')) {
620
+ sendJson(res, 401, { status: 'error', message: 'Not authenticated' });
621
+ } else {
622
+ res.writeHead(302, { 'Location': '/login' });
623
+ res.end();
624
+ }
625
+ return;
626
+ }
627
+ }
628
+
490
629
  // CORS preflight for /config
491
630
  if (req.method === 'OPTIONS' && pathname === '/config') {
492
631
  res.writeHead(204, {
@@ -1728,8 +1867,13 @@ process.on('SIGTERM', () => server.close(() => process.exit(0)));
1728
1867
  process.on('SIGINT', () => server.close(() => process.exit(0)));
1729
1868
 
1730
1869
  server.listen(PORT, HOST, () => {
1870
+ const authStatus = DASHBOARD_PASSWORD
1871
+ ? ' Password auth: ENABLED (DASHBOARD_PASSWORD is set)'
1872
+ : ' Password auth: DISABLED — set DASHBOARD_PASSWORD=yourpassword to enable';
1731
1873
  console.log(`
1732
- 🦞 LobsterBoard Builder Server running at http://${HOST}:${PORT}
1874
+ LobsterBoard Builder Server running at http://${HOST}:${PORT}
1875
+
1876
+ ${authStatus}
1733
1877
 
1734
1878
  Press Ctrl+C to stop
1735
1879
  `);