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 +6 -0
- package/README.md +19 -1
- package/dist/lobsterboard.css +1 -1
- package/dist/lobsterboard.esm.js +1 -1
- package/dist/lobsterboard.esm.min.js +1 -1
- package/dist/lobsterboard.umd.js +1 -1
- package/dist/lobsterboard.umd.min.js +1 -1
- package/js/builder.js +10 -7
- package/js/templates.js +26 -19
- package/js/widgets.js +37 -14
- package/package.json +4 -2
- package/server.cjs +168 -7
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
|
-
- **
|
|
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
|
+
|  |  |  |
|
|
50
|
+
|
|
51
|
+
| Feminine | Feminine Dark |
|
|
52
|
+
|----------|---------------|
|
|
53
|
+
|  |  |
|
|
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
|
package/dist/lobsterboard.css
CHANGED
package/dist/lobsterboard.esm.js
CHANGED
package/dist/lobsterboard.umd.js
CHANGED
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"
|
|
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 || ''
|
|
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 || '')
|
|
750
|
-
const source = (a.source || '')
|
|
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
|
|
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.
|
|
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
|
-
//
|
|
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:
|
|
1463
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|
|
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
|
-
|
|
1874
|
+
LobsterBoard Builder Server running at http://${HOST}:${PORT}
|
|
1875
|
+
|
|
1876
|
+
${authStatus}
|
|
1716
1877
|
|
|
1717
1878
|
Press Ctrl+C to stop
|
|
1718
1879
|
`);
|