diff --git a/frontend/js/api.js b/frontend/js/api.js index f5c38a1..0e52631 100644 --- a/frontend/js/api.js +++ b/frontend/js/api.js @@ -181,6 +181,9 @@ export const api = { adminListOrgs: () => request('/admin/orgs'), adminDeleteOrg: (id) => request(`/admin/orgs/${id}`, { method: 'DELETE' }), adminDeleteWorkspace: (id) => request(`/admin/workspaces/${id}`, { method: 'DELETE' }), + aiGetSettings: () => request('/ai/settings'), + aiSaveSettings: (data) => request('/ai/settings', { method: 'PUT', body: JSON.stringify(data) }), + aiGenerateDesign: (prompt) => request('/ai/generate-design', { method: 'POST', body: JSON.stringify({ prompt }) }), // Instance-level default branding (#15, platform admin). adminGetBranding: () => request('/admin/branding'), diff --git a/frontend/js/i18n/en.js b/frontend/js/i18n/en.js index 724aaff..d12cd73 100644 --- a/frontend/js/i18n/en.js +++ b/frontend/js/i18n/en.js @@ -600,6 +600,25 @@ export default { // Designer 'designer.title': 'Content Designer', 'designer.subtitle': 'Create dynamic signage content', + 'designer.ai.title': '✨ AI generate', + 'designer.ai.settings': 'AI settings', + 'designer.ai.placeholder': "Describe your sign — e.g. 'Summer sale, 20% off mains, bright modern'", + 'designer.ai.generate': 'Generate design', + 'designer.ai.generating': 'Generating…', + 'designer.ai.contacting': 'Contacting your AI endpoint… (local models can be slow)', + 'designer.ai.done': 'Generated {n} element(s) — tweak and Publish.', + 'designer.ai.failed': 'Generation failed', + 'designer.ai.need_prompt': 'Enter a prompt first', + 'designer.ai.settings_title': 'AI design settings', + 'designer.ai.settings_desc': 'Bring your own OpenAI-compatible endpoint — OpenAI cloud, or self-hosted Ollama / LM Studio. Your provider bills you; the key is stored encrypted and never shown again.', + 'designer.ai.base_url': 'Endpoint base URL', + 'designer.ai.model': 'Model', + 'designer.ai.api_key': 'API key', + 'designer.ai.key_set': '•••••• saved — leave blank to keep', + 'designer.ai.key_placeholder': 'leave blank if your endpoint needs none', + 'designer.ai.key_hint': 'Stored encrypted, server-side. Local endpoints (Ollama) usually need no key.', + 'designer.ai.saved': 'AI settings saved', + 'designer.ai.save_failed': 'Could not save AI settings', 'designer.help_tip': 'Create custom signage with live elements: clocks, weather, RSS tickers, countdowns, QR codes. Publish as a widget or export as PNG.', 'designer.load_design': 'Load Design', 'designer.export_png': 'Export PNG', diff --git a/frontend/js/views/designer.js b/frontend/js/views/designer.js index 50988d5..840ceea 100644 --- a/frontend/js/views/designer.js +++ b/frontend/js/views/designer.js @@ -1,5 +1,6 @@ import { api } from '../api.js'; import { showToast } from '../components/toast.js'; +import { esc } from '../utils.js'; import { t } from '../i18n.js'; // Background swatches: ids resolve to translated names; values are the actual @@ -50,6 +51,19 @@ export function render(container) {
+ +
+
+

${t('designer.ai.title')}

+ +
+ + +
+
+

${t('designer.add_element')}

@@ -110,6 +124,32 @@ export function render(container) { reader.readAsDataURL(file); }; + // AI generate (#41): prompt -> validated design spec -> load onto the canvas. + document.getElementById('aiSettingsBtn').onclick = openAiSettings; + const aiGenBtn = document.getElementById('aiGenerateBtn'); + aiGenBtn.onclick = async () => { + const prompt = document.getElementById('aiPrompt').value.trim(); + const status = document.getElementById('aiStatus'); + if (!prompt) { status.textContent = t('designer.ai.need_prompt'); return; } + aiGenBtn.disabled = true; aiGenBtn.textContent = t('designer.ai.generating'); + status.textContent = t('designer.ai.contacting'); + try { + const design = await api.aiGenerateDesign(prompt); + elements = []; selectedIdx = -1; + if (design.background) { + bgValue = design.background; bgImageDataUrl = null; + const bc = document.getElementById('bgColor'); if (bc) bc.value = design.background; + } + (design.elements || []).forEach(el => elements.push(el)); + redraw(); + status.textContent = t('designer.ai.done', { n: (design.elements || []).length }); + } catch (err) { + status.textContent = (err && err.message) || t('designer.ai.failed'); + } finally { + aiGenBtn.disabled = false; aiGenBtn.textContent = t('designer.ai.generate'); + } + }; + // Add element handlers document.getElementById('addText').onclick = () => addElement({ type: 'text', x: 10, y: 60, text: t('designer.default.text'), fontSize: 24, fontFamily: 'Arial', color: '#FFFFFF', bold: false, shadow: false }); document.getElementById('addHeading').onclick = () => addElement({ type: 'text', x: 5, y: 5, text: t('designer.default.heading'), fontSize: 64, fontFamily: 'Impact', color: '#FFFFFF', bold: true, shadow: true }); @@ -273,6 +313,59 @@ function addElement(el) { redraw(); } +// #41: per-workspace AI endpoint config (BYO OpenAI-compatible endpoint + key). +async function openAiSettings() { + let cur = {}; + try { cur = await api.aiGetSettings(); } catch { /* show empty form */ } + const overlay = document.createElement('div'); + overlay.className = 'modal-overlay'; + overlay.style.display = 'flex'; + overlay.innerHTML = ` + `; + document.body.appendChild(overlay); + const close = () => overlay.remove(); + overlay.querySelectorAll('[data-ai-close]').forEach(b => b.onclick = close); + overlay.onclick = (e) => { if (e.target === overlay) close(); }; + overlay.querySelector('#aiSaveSettings').onclick = async () => { + const errEl = overlay.querySelector('#aiSettingsErr'); + errEl.style.display = 'none'; + const data = { + base_url: overlay.querySelector('#aiBaseUrl').value.trim(), + model: overlay.querySelector('#aiModel').value.trim(), + }; + const key = overlay.querySelector('#aiKey').value; + if (key) data.api_key = key; + try { + await api.aiSaveSettings(data); + showToast(t('designer.ai.saved'), 'success'); + close(); + } catch (e2) { + errEl.textContent = (e2 && e2.message) || t('designer.ai.save_failed'); + errEl.style.display = 'block'; + } + }; +} + function getBounds(el) { const w = el.width || el.size || (el.fontSize ? el.fontSize * 0.6 * (el.text?.length || 8) / 100 * 100 : 20); const h = el.height || el.size || (el.fontSize ? el.fontSize * 1.2 / 100 * 100 : 10); diff --git a/server/db/schema.sql b/server/db/schema.sql index 8395854..39ea7f9 100644 --- a/server/db/schema.sql +++ b/server/db/schema.sql @@ -389,6 +389,21 @@ CREATE TABLE IF NOT EXISTS white_labels ( updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now')) ); +-- ===================== AI (BYOK) SETTINGS ===================== +-- #41: per-workspace AI design generation. Bring-your-own OpenAI-COMPATIBLE +-- endpoint (OpenAI cloud, or self-hosted: Ollama / LM Studio / llama.cpp, and +-- AUTOMATIC1111 etc. for images), so the operator bears no AI cost. api_key_enc +-- is AES-256-GCM encrypted (lib/secretbox.js); it is never returned to clients. +CREATE TABLE IF NOT EXISTS ai_settings ( + workspace_id TEXT PRIMARY KEY REFERENCES workspaces(id) ON DELETE CASCADE, + base_url TEXT, + api_key_enc TEXT, + model TEXT, + image_base_url TEXT, + image_model TEXT, + updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now')) +); + -- ===================== KIOSK PAGES ===================== CREATE TABLE IF NOT EXISTS kiosk_pages ( diff --git a/server/lib/secretbox.js b/server/lib/secretbox.js new file mode 100644 index 0000000..d046a19 --- /dev/null +++ b/server/lib/secretbox.js @@ -0,0 +1,31 @@ +'use strict'; + +// AES-256-GCM encrypt/decrypt for secrets at rest (e.g. BYOK AI provider keys, +// #41). The key is derived from the instance's JWT secret so there's no extra +// env to manage; rotating JWT_SECRET invalidates stored secrets (they're +// re-enterable). Format: base64(iv[12] | tag[16] | ciphertext). +const crypto = require('crypto'); +const config = require('../config'); + +const KEY = crypto.createHash('sha256').update(String(config.jwtSecret) + ':secretbox-v1').digest(); + +function encrypt(plain) { + if (plain == null || plain === '') return null; + const iv = crypto.randomBytes(12); + const cipher = crypto.createCipheriv('aes-256-gcm', KEY, iv); + const enc = Buffer.concat([cipher.update(String(plain), 'utf8'), cipher.final()]); + return Buffer.concat([iv, cipher.getAuthTag(), enc]).toString('base64'); +} + +function decrypt(b64) { + if (!b64) return null; + try { + const buf = Buffer.from(b64, 'base64'); + const iv = buf.subarray(0, 12), tag = buf.subarray(12, 28), enc = buf.subarray(28); + const decipher = crypto.createDecipheriv('aes-256-gcm', KEY, iv); + decipher.setAuthTag(tag); + return Buffer.concat([decipher.update(enc), decipher.final()]).toString('utf8'); + } catch { return null; } +} + +module.exports = { encrypt, decrypt }; diff --git a/server/routes/ai.js b/server/routes/ai.js new file mode 100644 index 0000000..3879831 --- /dev/null +++ b/server/routes/ai.js @@ -0,0 +1,165 @@ +'use strict'; + +// #41: AI content design. Bring-your-own OpenAI-COMPATIBLE endpoint (OpenAI cloud +// or self-hosted Ollama / LM Studio / llama.cpp) generates a *structured* design +// spec that the existing Designer renders with real fonts — so text is crisp and +// editable (raw image-gen garbles text). The operator bears no AI cost; each +// workspace configures its own endpoint/key (encrypted at rest, never returned). +const express = require('express'); +const router = express.Router(); +const { db } = require('../db/database'); +const config = require('../config'); +const { encrypt, decrypt } = require('../lib/secretbox'); +const { logActivity, getClientIp } = require('../services/activity'); + +const isWorkspaceAdmin = (req) => req.isPlatformAdmin || req.actingAs || req.workspaceRole === 'workspace_admin'; +const canEdit = (req) => req.isPlatformAdmin || req.actingAs || ['workspace_admin', 'workspace_editor'].includes(req.workspaceRole); + +// SSRF guard. Self-hosted instances may point at localhost/LAN (the whole point); +// the hosted instance must not let a tenant admin reach the host's private network. +function endpointAllowed(rawUrl) { + let u; + try { u = new URL(rawUrl); } catch { return false; } + if (!/^https?:$/.test(u.protocol)) return false; + if (config.selfHosted) return true; + const h = u.hostname.toLowerCase(); + if (h === 'localhost' || h === '0.0.0.0' || h === '::1' || h.endsWith('.local')) return false; + if (/^127\./.test(h) || /^10\./.test(h) || /^192\.168\./.test(h) || /^169\.254\./.test(h)) return false; + if (/^172\.(1[6-9]|2\d|3[01])\./.test(h)) return false; + if (/^(fc|fd)/.test(h)) return false; // IPv6 ULA + return true; +} + +const DESIGN_SYSTEM_PROMPT = +`You are a digital-signage designer. The canvas is 1920x1080 (16:9). Respond with ONLY a JSON object (no prose, no markdown fences) shaped exactly: +{"background":"#RRGGBB","elements":[ELEMENT, ...]} +ELEMENT is one of: +{"type":"text","x":N,"y":N,"text":"STRING","fontSize":N,"color":"#RRGGBB","bold":true|false} +{"type":"shape","x":N,"y":N,"width":N,"height":N,"color":"#RRGGBB","opacity":N} +x, y, width, height are PERCENTAGES of the canvas (0-100). fontSize is a number where a big headline is about 90 and body text about 36. Use 3 to 6 elements: one bold headline, 1-2 supporting lines, and 0-2 shapes as colored accent bands behind/beside the text. Pick a tasteful, high-contrast palette that fits the request. Keep every element within 0-95 on both axes. Output JSON only.`; + +const clampN = (n, lo, hi, d) => { n = Number(n); return Number.isFinite(n) ? Math.min(hi, Math.max(lo, n)) : d; }; +const hex = (c, d) => (typeof c === 'string' && /^#[0-9a-fA-F]{3,8}$/.test(c.trim())) ? c.trim() : d; +const cleanText = (s) => String(s == null ? '' : s).replace(/<[^>]*>/g, '').trim().slice(0, 200); + +// Never trust raw model output: cap count, clamp ranges, fix px-vs-% (models +// often emit pixels), strip any HTML from text, validate colors. +function normalizeDesign(raw) { + const out = { background: hex(raw && raw.background, '#111827'), elements: [] }; + const els = Array.isArray(raw && raw.elements) ? raw.elements.slice(0, 20) : []; + for (const e of els) { + if (!e || typeof e !== 'object') continue; + if (e.type === 'text') { + const text = cleanText(e.text); + if (!text) continue; + out.elements.push({ + type: 'text', x: clampN(e.x, 0, 95, 5), y: clampN(e.y, 0, 95, 5), text, + fontSize: clampN(e.fontSize, 12, 200, 48), fontFamily: 'Arial', + color: hex(e.color, '#FFFFFF'), bold: !!e.bold, shadow: !!e.shadow, + }); + } else if (e.type === 'shape') { + let w = Number(e.width), h = Number(e.height); + if (w > 100) w = w / 19.2; // px of 1920 -> % + if (h > 100) h = h / 10.8; // px of 1080 -> % + out.elements.push({ + type: 'shape', shape: 'rect', + x: clampN(e.x, 0, 100, 0), y: clampN(e.y, 0, 100, 0), + width: clampN(w, 1, 100, 30), height: clampN(h, 1, 100, 20), + color: hex(e.color, '#3b82f6'), opacity: clampN(e.opacity, 0, 1, 0.85), radius: 0, + }); + } + } + return out; +} + +// GET /api/ai/settings — workspace members (never returns the key) +router.get('/settings', (req, res) => { + const row = db.prepare('SELECT base_url, model, image_base_url, image_model, api_key_enc FROM ai_settings WHERE workspace_id = ?').get(req.workspaceId); + res.json({ + base_url: row ? row.base_url || '' : '', + model: row ? row.model || '' : '', + image_base_url: row ? row.image_base_url || '' : '', + image_model: row ? row.image_model || '' : '', + has_key: !!(row && row.api_key_enc), + configured: !!(row && row.base_url && row.model), + }); +}); + +// PUT /api/ai/settings — workspace admin +router.put('/settings', (req, res) => { + if (!isWorkspaceAdmin(req)) return res.status(403).json({ error: 'Workspace admin required' }); + const base_url = String(req.body && req.body.base_url || '').trim().replace(/\/+$/, ''); + const model = String(req.body && req.body.model || '').trim(); + const image_base_url = String(req.body && req.body.image_base_url || '').trim().replace(/\/+$/, ''); + const image_model = String(req.body && req.body.image_model || '').trim(); + if (base_url && !endpointAllowed(base_url)) return res.status(400).json({ error: 'Endpoint URL not allowed (private/internal addresses are blocked on this instance).' }); + if (image_base_url && !endpointAllowed(image_base_url)) return res.status(400).json({ error: 'Image endpoint URL not allowed.' }); + + const existing = db.prepare('SELECT api_key_enc FROM ai_settings WHERE workspace_id = ?').get(req.workspaceId); + let api_key_enc = existing ? existing.api_key_enc : null; + if (typeof (req.body && req.body.api_key) === 'string' && req.body.api_key.length) api_key_enc = encrypt(req.body.api_key); + if (req.body && req.body.clear_key) api_key_enc = null; + + db.prepare(` + INSERT INTO ai_settings (workspace_id, base_url, api_key_enc, model, image_base_url, image_model, updated_at) + VALUES (?, ?, ?, ?, ?, ?, strftime('%s','now')) + ON CONFLICT(workspace_id) DO UPDATE SET base_url=excluded.base_url, api_key_enc=excluded.api_key_enc, + model=excluded.model, image_base_url=excluded.image_base_url, image_model=excluded.image_model, updated_at=excluded.updated_at + `).run(req.workspaceId, base_url || null, api_key_enc, model || null, image_base_url || null, image_model || null); + logActivity(req.user.id, 'ai_settings_update', `endpoint: ${base_url || '(none)'} model: ${model || '(none)'}`, null, getClientIp(req), req.workspaceId); + res.json({ ok: true }); +}); + +// POST /api/ai/generate-design — editor+; proxies the workspace's endpoint +router.post('/generate-design', async (req, res) => { + if (!canEdit(req)) return res.status(403).json({ error: 'Editor access required' }); + const prompt = String(req.body && req.body.prompt || '').trim().slice(0, 500); + if (!prompt) return res.status(400).json({ error: 'Prompt required' }); + + const row = db.prepare('SELECT base_url, api_key_enc, model FROM ai_settings WHERE workspace_id = ?').get(req.workspaceId); + if (!row || !row.base_url || !row.model) return res.status(400).json({ error: 'AI is not configured. Set an endpoint and model in AI settings first.' }); + if (!endpointAllowed(row.base_url)) return res.status(400).json({ error: 'Configured endpoint is not allowed.' }); + + const key = decrypt(row.api_key_enc) || 'none'; + const url = row.base_url.replace(/\/+$/, '') + '/chat/completions'; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 120000); + let aiRes; + try { + aiRes = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${key}` }, + body: JSON.stringify({ + model: row.model, temperature: 0.6, stream: false, + messages: [{ role: 'system', content: DESIGN_SYSTEM_PROMPT }, { role: 'user', content: prompt }], + }), + signal: controller.signal, + }); + } catch (e) { + clearTimeout(timer); + return res.status(502).json({ error: 'Could not reach the AI endpoint: ' + (e.name === 'AbortError' ? 'timed out' : e.message) }); + } + clearTimeout(timer); + if (!aiRes.ok) { + const t = await aiRes.text().catch(() => ''); + return res.status(502).json({ error: `AI endpoint error ${aiRes.status}: ${t.slice(0, 150)}` }); + } + let json; + try { json = await aiRes.json(); } catch { return res.status(502).json({ error: 'AI returned non-JSON.' }); } + const content = (json && json.choices && json.choices[0] && json.choices[0].message && json.choices[0].message.content) || ''; + let parsed; + try { + const m = content.match(/\{[\s\S]*\}/); + parsed = JSON.parse(m ? m[0] : content); + } catch { return res.status(502).json({ error: 'AI did not return a usable design. Try rephrasing.' }); } + const design = normalizeDesign(parsed); + if (!design.elements.length) return res.status(502).json({ error: 'AI returned an empty design. Try a more specific prompt.' }); + logActivity(req.user.id, 'ai_generate_design', `prompt: ${prompt.slice(0, 80)}`, null, getClientIp(req), req.workspaceId); + res.json(design); +}); + +module.exports = router; +// Exposed for unit tests (security-critical: untrusted-LLM-output normalization +// and the SSRF guard). +module.exports.normalizeDesign = normalizeDesign; +module.exports.endpointAllowed = endpointAllowed; diff --git a/server/server.js b/server/server.js index ef02016..7014b91 100644 --- a/server/server.js +++ b/server/server.js @@ -413,6 +413,7 @@ app.use('/api/admin', requireAuth, require('./routes/admin')); app.use('/api/devices', requireAuth, resolveTenancy, require('./routes/devices')); app.use('/api/content', requireAuth, resolveTenancy, require('./routes/content')); +app.use('/api/ai', requireAuth, resolveTenancy, require('./routes/ai')); // #41 AI design (BYOK) app.use('/api/folders', requireAuth, resolveTenancy, require('./routes/folders')); app.use('/api/assignments', requireAuth, resolveTenancy, require('./routes/assignments')); app.use('/api/provision', requireAuth, resolveTenancy, require('./routes/provisioning')); diff --git a/server/test/ai-design.test.js b/server/test/ai-design.test.js new file mode 100644 index 0000000..9685147 --- /dev/null +++ b/server/test/ai-design.test.js @@ -0,0 +1,76 @@ +'use strict'; + +// #41: unit tests for the security-critical bits of the AI design route - +// normalizing untrusted LLM output, and the SSRF guard on the configurable +// endpoint. Node v20 built-ins only; db is mocked so requiring the route doesn't +// touch a real database. SELF_HOSTED=false so the SSRF guard is active. + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const Database = require('better-sqlite3'); + +process.env.JWT_SECRET = 'test-secret-ai'; +process.env.SELF_HOSTED = 'false'; + +const db = new Database(':memory:'); +const dbModulePath = require.resolve('../db/database'); +require.cache[dbModulePath] = { id: dbModulePath, filename: dbModulePath, loaded: true, exports: { db, pruneTelemetry() {}, pruneScreenshots() {} } }; + +const ai = require('../routes/ai'); +const { normalizeDesign, endpointAllowed } = ai; + +test('normalizeDesign: keeps valid text+shape, sets background', () => { + const d = normalizeDesign({ background: '#102030', elements: [ + { type: 'text', x: 5, y: 5, text: 'HELLO', fontSize: 90, color: '#ffffff', bold: true }, + { type: 'shape', x: 0, y: 90, width: 100, height: 8, color: '#ff0000', opacity: 0.5 }, + ]}); + assert.equal(d.background, '#102030'); + assert.equal(d.elements.length, 2); + assert.equal(d.elements[0].text, 'HELLO'); + assert.equal(d.elements[0].fontFamily, 'Arial'); +}); + +test('normalizeDesign: converts pixel shape dims to %, clamps ranges', () => { + const d = normalizeDesign({ elements: [ + { type: 'shape', x: -10, y: 200, width: 1920, height: 1080, color: 'red', opacity: 5 }, + ]}); + const s = d.elements[0]; + assert.equal(s.x, 0, 'x clamped to 0'); + assert.equal(s.y, 100, 'y clamped to 100'); + assert.ok(Math.abs(s.width - 100) < 0.01, '1920px -> 100%'); + assert.ok(Math.abs(s.height - 100) < 0.01, '1080px -> 100%'); + assert.equal(s.color, '#3b82f6', 'non-hex color -> default'); + assert.equal(s.opacity, 1, 'opacity clamped to 1'); +}); + +test('normalizeDesign: strips HTML from text, drops empty/invalid', () => { + const d = normalizeDesign({ elements: [ + { type: 'text', text: 'Sale', fontSize: 9999 }, + { type: 'text', text: ' ' }, + { type: 'bogus', text: 'x' }, + null, + ]}); + assert.equal(d.elements.length, 1, 'only the one real text survives'); + assert.equal(d.elements[0].text, 'Sale'); + assert.ok(!/[<>]/.test(d.elements[0].text), 'no angle brackets'); + assert.equal(d.elements[0].fontSize, 200, 'fontSize clamped to max'); +}); + +test('normalizeDesign: caps element count + bad input', () => { + const many = { elements: Array.from({ length: 50 }, () => ({ type: 'text', text: 'x' })) }; + assert.ok(normalizeDesign(many).elements.length <= 20); + assert.deepEqual(normalizeDesign(null).elements, []); + assert.equal(normalizeDesign({ background: 'notacolor' }).background, '#111827'); +}); + +test('endpointAllowed: blocks private/internal when hosted, allows public https', () => { + assert.equal(endpointAllowed('https://api.openai.com/v1'), true); + assert.equal(endpointAllowed('http://localhost:11434/v1'), false); + assert.equal(endpointAllowed('http://127.0.0.1:1234'), false); + assert.equal(endpointAllowed('http://10.0.0.5/v1'), false); + assert.equal(endpointAllowed('http://192.168.1.9/v1'), false); + assert.equal(endpointAllowed('http://169.254.169.254/latest/meta-data'), false, 'cloud metadata blocked'); + assert.equal(endpointAllowed('http://172.16.5.5/v1'), false); + assert.equal(endpointAllowed('ftp://example.com'), false, 'non-http blocked'); + assert.equal(endpointAllowed('not a url'), false); +});