${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 = `
+
+
+
+
${t('designer.ai.settings_desc')}
+
+
+
+
+
+
+
+
+
`;
+ 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);
+});