lobsterboard 0.3.1 → 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.
package/CHANGELOG.md CHANGED
@@ -12,6 +12,12 @@
12
12
  - **Phosphor icon system** — themed widgets use Phosphor icons; Default theme keeps emoji
13
13
  - **Theme selector dropdown** in edit mode header
14
14
  - Theme persists to localStorage and dashboard config
15
+ - **Themes showcase** on website and README with lightbox gallery
16
+
17
+ ## [0.2.6] - 2026-02-23
18
+
19
+ ### Fixed
20
+ - **Version suffix comparison** — versions like `2026.2.22-2` (npm post-release patches) now correctly match GitHub tags like `v2026.2.22`, fixing false "Update available" indicators — thanks @JamesTsetsekas!
15
21
 
16
22
  ## [0.2.5] - 2026-02-19
17
23
 
package/README.md CHANGED
@@ -37,9 +37,27 @@ Open **http://localhost:8080** → press **Ctrl+E** to enter edit mode → drag
37
37
  - **Custom pages** — extend your dashboard with full custom pages (notes, kanban boards, anything)
38
38
  - **Canvas sizes** — preset resolutions (1920×1080, 2560×1440, etc.) or custom sizes
39
39
  - **Live data** — system stats stream via Server-Sent Events, widgets auto-refresh
40
- - **Dark theme** — the only correct choice
40
+ - **5 themes** — Default (dark), Terminal (CRT green), Feminine (pastel pink), Feminine Dark, Paper (sepia)
41
41
  - **No cloud** — everything runs locally, your data stays yours
42
42
 
43
+ ## Themes
44
+
45
+ LobsterBoard ships with 5 built-in themes. Switch themes from the dropdown in edit mode — your choice persists across sessions.
46
+
47
+ | Default | Terminal | Paper |
48
+ |---------|----------|-------|
49
+ | ![Default](site-assets/themes/theme-default.png) | ![Terminal](site-assets/themes/theme-terminal.png) | ![Paper](site-assets/themes/theme-paper.png) |
50
+
51
+ | Feminine | Feminine Dark |
52
+ |----------|---------------|
53
+ | ![Feminine](site-assets/themes/theme-feminine.png) | ![Feminine Dark](site-assets/themes/theme-feminine-dark.png) |
54
+
55
+ - **Default** — dark theme with emoji icons (the classic look)
56
+ - **Terminal** — green CRT aesthetic with scanlines and Phosphor icons
57
+ - **Paper** — warm cream/sepia tones, serif fonts, vintage feel
58
+ - **Feminine** — soft pink and lavender pastels with subtle glows
59
+ - **Feminine Dark** — pink/purple accents on a dark background
60
+
43
61
  ## Configuration
44
62
 
45
63
  ```bash
@@ -1,4 +1,4 @@
1
- /* LobsterBoard v0.3.1 - 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.1
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.1
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.1
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.1
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', () => {
@@ -155,7 +162,7 @@
155
162
  tplGrid.style.display = '';
156
163
  loadTemplates();
157
164
  } else {
158
- alert('❌ ' + (data.error || 'Delete failed'));
165
+ alert('❌ ' + (data.error || data.message || 'Delete failed'));
159
166
  }
160
167
  } catch (e) {
161
168
  alert('❌ Delete failed: ' + e.message);
@@ -187,7 +194,7 @@
187
194
  alert(`✅ ${data.message}\n\nReloading dashboard...`);
188
195
  location.reload();
189
196
  } else {
190
- alert('❌ ' + (data.error || 'Import failed'));
197
+ alert('❌ ' + (data.error || data.message || 'Import failed'));
191
198
  }
192
199
  } catch (e) {
193
200
  alert('❌ Import failed: ' + e.message);
@@ -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.1",
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, {
@@ -1458,12 +1597,25 @@ const server = http.createServer(async (req, res) => {
1458
1597
  req.on('end', () => {
1459
1598
  try {
1460
1599
  const { id, mode } = JSON.parse(body);
1600
+ if (!id) { sendJson(res, 400, { error: 'Missing template id' }); return; }
1601
+ if (!mode) { sendJson(res, 400, { error: 'Missing import mode' }); return; }
1602
+
1461
1603
  const tplConfigPath = path.join(TEMPLATES_DIR, id, 'config.json');
1462
- if (!fs.existsSync(tplConfigPath)) { sendJson(res, 404, { error: 'Template not found' }); return; }
1463
- const tplConfig = JSON.parse(fs.readFileSync(tplConfigPath, 'utf8'));
1604
+ if (!fs.existsSync(tplConfigPath)) { sendJson(res, 404, { error: `Template "${id}" not found` }); return; }
1605
+
1606
+ let tplConfig;
1607
+ try {
1608
+ tplConfig = JSON.parse(fs.readFileSync(tplConfigPath, 'utf8'));
1609
+ } catch (parseErr) {
1610
+ sendJson(res, 500, { error: `Template config is invalid JSON: ${parseErr.message}` }); return;
1611
+ }
1464
1612
 
1465
1613
  if (mode === 'replace') {
1466
- fs.writeFileSync(CONFIG_FILE, JSON.stringify(tplConfig, null, 2));
1614
+ try {
1615
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify(tplConfig, null, 2));
1616
+ } catch (writeErr) {
1617
+ sendJson(res, 500, { error: `Failed to write config: ${writeErr.message}` }); return;
1618
+ }
1467
1619
  sendJson(res, 200, { status: 'success', message: 'Template imported (replace)' });
1468
1620
  } else if (mode === 'merge') {
1469
1621
  let currentConfig = { canvas: { width: 1920, height: 1080 }, widgets: [] };
@@ -1481,12 +1633,16 @@ const server = http.createServer(async (req, res) => {
1481
1633
  y: (w.y || 0) + offset
1482
1634
  }));
1483
1635
  currentConfig.widgets = [...(currentConfig.widgets || []), ...newWidgets];
1484
- fs.writeFileSync(CONFIG_FILE, JSON.stringify(currentConfig, null, 2));
1636
+ try {
1637
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify(currentConfig, null, 2));
1638
+ } catch (writeErr) {
1639
+ sendJson(res, 500, { error: `Failed to write config: ${writeErr.message}` }); return;
1640
+ }
1485
1641
  sendJson(res, 200, { status: 'success', message: `Merged ${newWidgets.length} widgets` });
1486
1642
  } else {
1487
1643
  sendJson(res, 400, { error: 'Invalid mode. Use "replace" or "merge"' });
1488
1644
  }
1489
- } catch (e) { sendError(res, e.message); }
1645
+ } catch (e) { sendJson(res, 500, { error: `Import error: ${e.message}` }); }
1490
1646
  });
1491
1647
  return;
1492
1648
  }
@@ -1711,8 +1867,13 @@ process.on('SIGTERM', () => server.close(() => process.exit(0)));
1711
1867
  process.on('SIGINT', () => server.close(() => process.exit(0)));
1712
1868
 
1713
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';
1714
1873
  console.log(`
1715
- 🦞 LobsterBoard Builder Server running at http://${HOST}:${PORT}
1874
+ LobsterBoard Builder Server running at http://${HOST}:${PORT}
1875
+
1876
+ ${authStatus}
1716
1877
 
1717
1878
  Press Ctrl+C to stop
1718
1879
  `);