From 08a83c9ba9ee1c36d421d112c264ce884f93d43d Mon Sep 17 00:00:00 2001 From: ScreenTinker Date: Tue, 21 Apr 2026 22:28:37 -0500 Subject: [PATCH] Add directory board widget renderer with scrolling, anti-burn-in, dark/light themes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lobby-style tenant/room directory with vertical marquee, seamless loop via content cloning, pixel shift + bg pulse for anti-burn-in, rotating background images with crossfade. Supports logo, title, footer, subtitles per entry, and Available (green) state. All user strings rendered via textContent in browser — no server-side HTML escaping of entries needed. Also refactors render dispatch into renderWidgetHtml() and adds a POST /preview endpoint that inlines user-owned image content as base64 data URIs so the editor can preview unsaved widgets. Preview is gated by: - image/* MIME only - 10 MB size cap - user_id ownership check - path traversal guard via basename + resolve Unknown widget_type on /preview returns 400. Co-Authored-By: Claude Opus 4.7 --- server/routes/widgets.js | 379 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 354 insertions(+), 25 deletions(-) diff --git a/server/routes/widgets.js b/server/routes/widgets.js index c180165..7967c3f 100644 --- a/server/routes/widgets.js +++ b/server/routes/widgets.js @@ -1,7 +1,34 @@ const express = require('express'); const router = express.Router(); +const fs = require('fs'); +const path = require('path'); const { v4: uuidv4 } = require('uuid'); const { db } = require('../db/database'); +const appConfig = require('../config'); + +// For preview only: inline /api/content/:id/file and /thumbnail URLs as data URIs, +// scoped to the current user. Lets the srcdoc preview iframe show logos/bg images +// before the widget is saved (post-save they're reachable via the widget-reference gate). +const MAX_INLINE_BYTES = 10 * 1024 * 1024; // 10MB cap — base64 expands ~1.33x +const MIME_RE = /^image\/[a-zA-Z0-9.+-]+$/; +function inlineUserContent(html, userId) { + return html.replace(/\/api\/content\/([a-f0-9-]+)\/(file|thumbnail)/gi, (match, id, kind) => { + const c = db.prepare('SELECT filepath, thumbnail_path, mime_type, user_id FROM content WHERE id = ?').get(id); + if (!c || c.user_id !== userId) return match; + const filename = kind === 'thumbnail' ? c.thumbnail_path : c.filepath; + if (!filename) return match; + const mime = kind === 'thumbnail' ? 'image/jpeg' : c.mime_type; + if (!mime || !MIME_RE.test(mime)) return match; + const safe = path.resolve(appConfig.contentDir, path.basename(filename)); + if (!safe.startsWith(path.resolve(appConfig.contentDir))) return match; + try { + const st = fs.statSync(safe); + if (!st.isFile() || st.size > MAX_INLINE_BYTES) return match; + const buf = fs.readFileSync(safe); + return `data:${mime};base64,${buf.toString('base64')}`; + } catch { return match; } + }); +} // Escape HTML to prevent XSS function escapeHtml(str) { @@ -89,37 +116,37 @@ router.delete('/:id', (req, res) => { res.json({ success: true }); }); +const KNOWN_WIDGET_TYPES = new Set(['clock','weather','rss','text','webpage','social','directory-board']); +function renderWidgetHtml(type, config) { + config = config || {}; + switch (type) { + case 'clock': return renderClock(config); + case 'weather': return renderWeather(config); + case 'rss': return renderRSS(config); + case 'text': return renderText(config); + case 'webpage': return renderWebpage(config); + case 'social': return renderSocial(config); + case 'directory-board': return renderDirectoryBoard(config); + default: return '

Unknown widget

'; + } +} + // Render widget as HTML page router.get('/:id/render', (req, res) => { const widget = db.prepare('SELECT * FROM widgets WHERE id = ?').get(req.params.id); if (!widget) return res.status(404).send('Widget not found'); - const config = JSON.parse(widget.config || '{}'); - let html = ''; - - switch (widget.widget_type) { - case 'clock': - html = renderClock(config); - break; - case 'weather': - html = renderWeather(config); - break; - case 'rss': - html = renderRSS(config); - break; - case 'text': - html = renderText(config); - break; - case 'webpage': - html = renderWebpage(config); - break; - case 'social': - html = renderSocial(config); - break; - default: - html = '

Unknown widget

'; - } + res.setHeader('Content-Type', 'text/html'); + res.send(renderWidgetHtml(widget.widget_type, config)); +}); +// Preview unsaved widget from config (used by editor Preview button) +router.post('/preview', (req, res) => { + const { widget_type, config } = req.body || {}; + if (!widget_type || typeof widget_type !== 'string') return res.status(400).json({ error: 'widget_type required' }); + if (!KNOWN_WIDGET_TYPES.has(widget_type)) return res.status(400).json({ error: 'Unknown widget_type' }); + let html = renderWidgetHtml(widget_type, config || {}); + if (req.user && req.user.id) html = inlineUserContent(html, req.user.id); res.setHeader('Content-Type', 'text/html'); res.send(html); }); @@ -244,4 +271,306 @@ function renderSocial(c) { `; } +// Directory Board — lobby tenant directory with scrolling content, header/footer, +// rotating background images, and anti-burn-in motion (pixel shift, bg pulse). +// All user-supplied strings are rendered via textContent in-browser, not inlined +// into HTML, so no server-side HTML escaping is needed for entries/categories. +function renderDirectoryBoard(c) { + const configJson = JSON.stringify(c || {}).replace(/ + + +Directory + + + +
+
+ +
+
+
+
+
+ + +`; +} + module.exports = router;