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'); const { PLATFORM_ROLES, ELEVATED_ROLES } = require('../middleware/auth'); // Phase 2.2d: workspace-aware access. Same pattern as devices.js / content.js. const { accessContext } = require('../lib/tenancy'); // For preview only: inline /api/content/:id/file and /thumbnail URLs as data URIs, // scoped to the caller's current workspace. 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, workspaceId) { if (!workspaceId) return html; return html.replace(/\/api\/content\/([a-f0-9-]+)\/(file|thumbnail)/gi, (match, id, kind) => { const c = db.prepare('SELECT filepath, thumbnail_path, mime_type, workspace_id FROM content WHERE id = ?').get(id); // Inline content only when it lives in the caller's workspace, or is a // platform-template row (workspace_id IS NULL) shared with everyone. if (!c) return match; if (c.workspace_id && c.workspace_id !== workspaceId) return match; const filename = kind === 'thumbnail' ? c.thumbnail_path : c.filepath; if (!filename) return match; // YouTube (and other remote-sourced) content stores thumbnail_path as a remote // http(s) URL, not a local file. Don't try to read it from disk (would ENOENT the // same way the serving route did) — leave the /api/content/:id/thumbnail reference // in place; the thumbnail route proxies it same-origin and CSP img-src allows https:. if (/^https?:\/\//i.test(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) { if (typeof str !== 'string') return str; return str.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, '''); } // Validate timezone format (e.g. America/New_York, UTC, Etc/GMT+5) function safeTimezone(tz) { if (!tz) return 'UTC'; return /^[A-Za-z_\-\/+0-9]+$/.test(tz) ? tz : 'UTC'; } // Validate ISO date string format function safeDateString(d) { if (!d) return ''; return /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}(:\d{2})?)?/.test(d) ? d : ''; } // Validate URL is http/https function safeUrl(url) { if (!url) return 'about:blank'; try { const parsed = new URL(url); return ['http:', 'https:'].includes(parsed.protocol) ? url : 'about:blank'; } catch { return 'about:blank'; } } // Security: widget render output is public and CSP-exempt, so config values that // get inlined into `; } function renderWeather(c) { return `
--
${escapeHtml(c.location) || 'Unknown'}
`; } function renderRSS(c) { return `
Loading feed...
`; } function renderText(c) { let html = c.html || '

Empty text widget

'; // LEGACY DESIGNER RESCUE — deliberately narrow. // // The Content Designer used to publish absolute font sizes as fontSize*10.8 px; today it emits // cqw (see designer.js). Converting px/108 back to vw restores the author's intended size and // makes those old widgets scale to any screen. // // It must NOT touch hand-authored HTML. This regex used to run over EVERY text widget, so // someone writing `font-size:16px` in the Text/HTML editor got 0.15vw — 2.8px on a 1080p // screen, and smaller still on anything narrower. Their text was not clipped or hidden; it was // rendered too small to read, in the one widget whose whole purpose is hand-written HTML. // // Designer output is identified by its absolutely-positioned elements, the same signal the // dashboard uses to decide whether a text widget can be reopened in the designer. Hand-written // markup keeps its px exactly as typed. const isDesignerAuthored = /position:\s*absolute;\s*left:/.test(html); if (isDesignerAuthored) { html = html.replace(/font-size:\s*([\d.]+)px/g, (match, px) => { return `font-size:${(parseFloat(px) / 108).toFixed(2)}vw`; }); } // What to do when the text is taller than the screen. It used to be clipped in silence: the // document was overflow:hidden with no scrollbar and nothing to scroll it, so on a display // shorter than the content the bottom simply vanished — reported as "text goes to bottom and // disappears. It dont fit." // // fit (default) shrink until it fits. A no-op when the content already fits, so this // rescues widgets that are currently losing text without altering ones that are fine. // scroll pan through it on a loop, with a pause at each end. For content that is genuinely // longer than a screen, where shrinking it would make it unreadable. // clip the old behaviour, kept because a designer-positioned layout may deliberately run // past the edge and must not be rescaled underneath the author. const overflowMode = ['fit', 'scroll', 'clip'].includes(c.overflow) ? c.overflow : 'fit'; // Runs inside the sandboxed iframe (allow-scripts, null origin). Measures after layout, after // web fonts settle, and on resize — a rotation or a resized zone changes the answer, and fonts // loading late is the classic cause of a fit that was computed against the wrong height. const fitScript = overflowMode === 'clip' ? '' : ``; // Security: c.html / c.css are intentionally raw user-authored content, but the // render is public and same-origin with the dashboard - injected ` : ''} `; } function renderSocial(c) { return `

Social Feed

${escapeHtml(c.platform) || 'twitter'}: ${escapeHtml(c.query) || ''}

Configure API key in widget settings

`; } // 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
`; } // Friendly full-page fallback when a directory-search points at a missing or // non-directory-board source. Matches the "Unknown widget" fallback tone. function renderDirectorySearchMissing() { return `Directory Search

Directory source not found

Pick a directory board in the widget settings.

`; } // Interactive walk-up search over an existing directory-board's entries. It // REFERENCES the source board by id (no data copy): the board scrolls on a main // screen while this lets someone find an entry instantly on a tablet. function renderDirectorySearch(c) { c = c || {}; const src = db.prepare('SELECT * FROM widgets WHERE id = ?').get(c.source_widget_id); if (!src || src.widget_type !== 'directory-board') return renderDirectorySearchMissing(); let categories = []; try { const sc = JSON.parse(src.config || '{}'); categories = Array.isArray(sc.categories) ? sc.categories : []; } catch (e) { categories = []; } // Inline everything the page needs as one JSON blob, guarded the same way the // board does. All user text is rendered via textContent below — never concat. const payload = { categories: categories, source_widget_id: src.id, title: c.title || '', logo_url: c.logo_url || '', theme: c.theme === 'light' ? 'light' : 'dark', placeholder_text: c.placeholder_text || 'Search…', show_onscreen_keyboard: c.show_onscreen_keyboard !== false, }; const configJson = JSON.stringify(payload).replace(/ Directory Search
`; } // diag-smoothness: a self-contained frame-cadence tester for the ACTUAL panel. Two GPU-composited // animations (a vertical scroll like the board + a fast sweep) plus a big on-screen HUD (FPS, refresh // estimate, long-frame count, worst stall, SMOOTH/STALLING verdict) — so a stutter can be read off the // panel screen with no console. If this stalls on real signage hardware, the hardware is the cause. function renderDiagSmoothness(config) { return `Smoothness Diagnostic

Panel Smoothness Diagnostic

Two GPU-composited animations, zero app logic. If the scroll or the yellow bar skips — or the HUD reads STALLING — this panel/hardware is dropping frames.

TEST 1 · vertical scroll
TEST 2 · fast sweep
measuring…collecting frames
FPS now
Refresh est.
Hz
Long frames
0 >50ms
Worst stall
0 ms
no stalls yet · a healthy panel shows 0 long frames
`; } module.exports = router;