const express = require('express'); const router = express.Router(); const { v4: uuidv4 } = require('uuid'); const { db } = require('../db/database'); const { PLATFORM_ROLES, ELEVATED_ROLES } = require('../middleware/auth'); // Phase 2.2e: workspace-aware access. Same pattern as content/widgets/folders. const { accessContext } = require('../lib/tenancy'); // 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 CSS color values to prevent style injection function safeColor(val, fallback) { if (!val) return fallback; if (/^#[0-9a-fA-F]{3,8}$/.test(val) || /^[a-zA-Z]+$/.test(val)) return val; return fallback; } // Validate CSS numeric values function safeNumber(val, fallback) { const n = Number(val); return isFinite(n) ? n : fallback; } // List kiosk pages in the caller's current workspace plus any platform-template // rows (workspace_id IS NULL) shared with all workspaces. // Phase 2.2e: 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 pages = db.prepare( 'SELECT * FROM kiosk_pages WHERE (workspace_id = ? OR workspace_id IS NULL) ORDER BY created_at DESC' ).all(req.workspaceId); res.json(pages); }); // Phase 2.2e: workspace-aware access. Mirrors widgets/content helpers. // Platform-template kiosks (workspace_id IS NULL) are readable by anyone // authenticated and writable only by platform_admin. function checkKioskRead(req, res) { const page = db.prepare('SELECT * FROM kiosk_pages WHERE id = ?').get(req.params.id); if (!page) { res.status(404).json({ error: 'Page not found' }); return null; } if (!page.workspace_id) return page; const ws = db.prepare('SELECT * FROM workspaces WHERE id = ?').get(page.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 page; } function checkKioskWrite(req, res) { const page = db.prepare('SELECT * FROM kiosk_pages WHERE id = ?').get(req.params.id); if (!page) { res.status(404).json({ error: 'Page not found' }); return null; } if (!page.workspace_id) { if (!PLATFORM_ROLES.includes(req.user.role)) { res.status(403).json({ error: 'Platform admin required to modify shared kiosk pages' }); return null; } return page; } const ws = db.prepare('SELECT * FROM workspaces WHERE id = ?').get(page.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 page; } // Get kiosk page router.get('/:id', (req, res) => { const page = checkKioskRead(req, res); if (!page) return; res.json(page); }); // Render kiosk page (public - accessed by devices) router.get('/:id/render', (req, res) => { const page = db.prepare('SELECT * FROM kiosk_pages WHERE id = ?').get(req.params.id); if (!page) return res.status(404).send('Page not found'); const config = JSON.parse(page.config || '{}'); const buttons = config.buttons || []; const style = config.style || {}; const html = `
${config.logoUrl ? `Logo` : ''}

${escapeHtml(config.title) || 'Welcome'}

${config.subtitle ? `

${escapeHtml(config.subtitle)}

` : ''}
${buttons.map(btn => `
${btn.icon ? `
${escapeHtml(btn.icon)}
` : ''}
${escapeHtml(btn.label) || 'Button'}
${btn.sublabel ? `
${escapeHtml(btn.sublabel)}
` : ''}
`).join('')}

${escapeHtml(config.idleTitle) || 'Touch to Begin'}

${escapeHtml(config.idleSubtitle) || ''}

`; res.setHeader('Content-Type', 'text/html'); res.send(html); }); // Create kiosk page 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 kiosk pages.' }); const { name, config: pageConfig } = req.body; if (!name) return res.status(400).json({ error: 'name required' }); const id = uuidv4(); db.prepare('INSERT INTO kiosk_pages (id, user_id, workspace_id, name, config) VALUES (?, ?, ?, ?, ?)') .run(id, req.user.id, req.workspaceId, name, JSON.stringify(pageConfig || getDefaultKioskConfig())); res.status(201).json(db.prepare('SELECT * FROM kiosk_pages WHERE id = ?').get(id)); }); // Update kiosk page router.put('/:id', (req, res) => { const page = checkKioskWrite(req, res); if (!page) return; const { name, config: pageConfig } = req.body; if (name) db.prepare('UPDATE kiosk_pages SET name = ? WHERE id = ?').run(name, req.params.id); if (pageConfig) db.prepare('UPDATE kiosk_pages SET config = ?, updated_at = strftime(\'%s\',\'now\') WHERE id = ?') .run(JSON.stringify(pageConfig), req.params.id); res.json(db.prepare('SELECT * FROM kiosk_pages WHERE id = ?').get(req.params.id)); }); // Delete kiosk page router.delete('/:id', (req, res) => { const page = checkKioskWrite(req, res); if (!page) return; db.prepare('DELETE FROM kiosk_pages WHERE id = ?').run(req.params.id); res.json({ success: true }); }); function getDefaultKioskConfig() { return { title: 'Welcome', subtitle: 'How can we help you today?', footer: '', logoUrl: '', idleTitle: 'Touch to Begin', idleSubtitle: '', idleTimeout: 60, buttons: [ { label: 'Directory', sublabel: 'Find a location', icon: '📍', action: 'page', page: '' }, { label: 'Events', sublabel: 'See what\'s happening', icon: '📅', action: 'page', page: '' }, { label: 'Map', sublabel: 'Building map', icon: '🗺', action: 'page', page: '' }, { label: 'Contact', sublabel: 'Get in touch', icon: '📞', action: 'page', page: '' }, { label: 'WiFi', sublabel: 'Connect to WiFi', icon: '📶', action: 'page', page: '' }, { label: 'Help', sublabel: 'Need assistance?', icon: '❔', action: 'page', page: '' }, ], style: { background: 'linear-gradient(135deg, #0c0c0c 0%, #1a1a2e 50%, #16213e 100%)', textColor: '#f1f5f9', columns: 3, buttonBg: '#1e293b', buttonBorder: '#334155', buttonHover: '#3b82f6', buttonRadius: 16, buttonPadding: 32, gap: 24, titleSize: 48, iconSize: 48, labelSize: 20, } }; } module.exports = router;