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; 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'; } } // List widgets accessible to the caller's current workspace, plus any // platform-template rows (workspace_id IS NULL) shared with all workspaces. // Phase 2.2d: workspace-scoped. Cross-workspace visibility comes from // switch-workspace, not a special list branch. router.get('/', (req, res) => { if (!req.workspaceId) return res.json([]); const widgets = db.prepare( 'SELECT * FROM widgets WHERE (workspace_id = ? OR workspace_id IS NULL) ORDER BY created_at DESC' ).all(req.workspaceId); res.json(widgets); }); // Create widget in the caller's current workspace. router.post('/', (req, res) => { if (!req.workspaceId) return res.status(403).json({ error: 'No workspace context. Switch to a workspace before creating widgets.' }); const { widget_type, name, config } = req.body; if (!widget_type || !name) return res.status(400).json({ error: 'widget_type and name required' }); const id = uuidv4(); db.prepare('INSERT INTO widgets (id, user_id, workspace_id, widget_type, name, config) VALUES (?, ?, ?, ?, ?, ?)') .run(id, req.user.id, req.workspaceId, widget_type, name, JSON.stringify(config || {})); res.status(201).json(db.prepare('SELECT * FROM widgets WHERE id = ?').get(id)); }); // Phase 2.2d: workspace-aware access. Mirrors the device/content pattern. // Platform-template widgets (workspace_id IS NULL) are readable by anyone // authenticated and writable only by platform_admin. function checkWidgetRead(req, res) { const widget = db.prepare('SELECT * FROM widgets WHERE id = ?').get(req.params.id); if (!widget) { res.status(404).json({ error: 'Widget not found' }); return null; } if (!widget.workspace_id) return widget; const ws = db.prepare('SELECT * FROM workspaces WHERE id = ?').get(widget.workspace_id); const ctx = ws && accessContext(req.user.id, req.user.role, ws); if (!ctx) { res.status(403).json({ error: 'Access denied' }); return null; } return widget; } function checkWidgetWrite(req, res) { const widget = db.prepare('SELECT * FROM widgets WHERE id = ?').get(req.params.id); if (!widget) { res.status(404).json({ error: 'Widget not found' }); return null; } if (!widget.workspace_id) { if (!PLATFORM_ROLES.includes(req.user.role)) { res.status(403).json({ error: 'Platform admin required to modify shared widgets' }); return null; } return widget; } const ws = db.prepare('SELECT * FROM workspaces WHERE id = ?').get(widget.workspace_id); const ctx = ws && accessContext(req.user.id, req.user.role, ws); if (!ctx) { res.status(403).json({ error: 'Access denied' }); return null; } if (!ctx.actingAs && ctx.workspaceRole === 'workspace_viewer') { res.status(403).json({ error: 'Read-only access' }); return null; } return widget; } // Get widget router.get('/:id', (req, res) => { const widget = checkWidgetRead(req, res); if (!widget) return; res.json(widget); }); // Update widget router.put('/:id', (req, res) => { const widget = checkWidgetWrite(req, res); if (!widget) return; const { name, config } = req.body; if (name) db.prepare('UPDATE widgets SET name = ?, updated_at = strftime(\'%s\',\'now\') WHERE id = ?').run(name, req.params.id); if (config) db.prepare('UPDATE widgets SET config = ?, updated_at = strftime(\'%s\',\'now\') WHERE id = ?').run(JSON.stringify(config), req.params.id); res.json(db.prepare('SELECT * FROM widgets WHERE id = ?').get(req.params.id)); }); // Delete widget router.delete('/:id', (req, res) => { const widget = checkWidgetWrite(req, res); if (!widget) return; db.prepare('DELETE FROM widgets WHERE id = ?').run(req.params.id); 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 '
Empty text widget
'; html = html.replace(/font-size:\s*([\d.]+)px/g, (match, px) => { return `font-size:${(parseFloat(px) / 108).toFixed(2)}vw`; }); return `${html}`; // NOTE: c.html is intentionally rendered as raw HTML - this is user-authored content for the text widget } function renderWebpage(c) { const zoom = (c.zoom || 100) / 100; const invZoom = 100 / (c.zoom || 100) * 100; return ` ${c.refresh_interval > 0 ? `` : ''} `; } function renderSocial(c) { return `Social Feed
${escapeHtml(c.platform) || 'twitter'}: ${escapeHtml(c.query) || ''}
Configure API key in widget settings