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.
- 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 +24 -17
- package/js/widgets.js +37 -14
- package/package.json +4 -2
- package/server.cjs +146 -2
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', () => {
|
|
@@ -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, {
|
|
@@ -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
|
-
|
|
1874
|
+
LobsterBoard Builder Server running at http://${HOST}:${PORT}
|
|
1875
|
+
|
|
1876
|
+
${authStatus}
|
|
1733
1877
|
|
|
1734
1878
|
Press Ctrl+C to stop
|
|
1735
1879
|
`);
|