mirror of
https://github.com/screentinker/screentinker.git
synced 2026-06-14 18:22:46 -06:00
Merge pull request #57 from screentinker/feat/ai-content-design
feat(ai): AI content design, BYO endpoint (#41 Phase 1)
This commit is contained in:
commit
d117016f2d
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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) {
|
|||
</div>
|
||||
<!-- Sidebar -->
|
||||
<div style="width:300px;display:flex;flex-direction:column;gap:12px;max-height:calc(100vh - 120px);overflow-y:auto">
|
||||
<!-- AI Generate (#41) -->
|
||||
<div style="background:var(--bg-card);border:1px solid var(--accent);border-radius:var(--radius);padding:12px">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
|
||||
<h4 style="font-size:13px">${t('designer.ai.title')}</h4>
|
||||
<button class="btn-icon" id="aiSettingsBtn" title="${t('designer.ai.settings')}" style="padding:2px">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<textarea id="aiPrompt" rows="2" class="input" placeholder="${t('designer.ai.placeholder')}" style="width:100%;resize:vertical;font-size:12px"></textarea>
|
||||
<button class="btn btn-primary btn-sm" id="aiGenerateBtn" style="width:100%;justify-content:center;margin-top:6px">${t('designer.ai.generate')}</button>
|
||||
<div id="aiStatus" style="font-size:11px;color:var(--text-muted);margin-top:6px"></div>
|
||||
</div>
|
||||
|
||||
<!-- Add Elements -->
|
||||
<div style="background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius);padding:12px">
|
||||
<h4 style="font-size:13px;margin-bottom:10px">${t('designer.add_element')}</h4>
|
||||
|
|
@ -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 = `
|
||||
<div class="modal" style="max-width:520px;width:95vw">
|
||||
<div class="modal-header">
|
||||
<h3>${t('designer.ai.settings_title')}</h3>
|
||||
<button class="btn-icon" data-ai-close aria-label="Close"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p style="font-size:12px;color:var(--text-muted);margin-bottom:12px">${t('designer.ai.settings_desc')}</p>
|
||||
<div class="form-group"><label>${t('designer.ai.base_url')}</label>
|
||||
<input id="aiBaseUrl" class="input" value="${esc(cur.base_url || '')}" placeholder="https://api.openai.com/v1 · http://localhost:11434/v1" style="width:100%"></div>
|
||||
<div class="form-group"><label>${t('designer.ai.model')}</label>
|
||||
<input id="aiModel" class="input" value="${esc(cur.model || '')}" placeholder="gpt-4o-mini · llama3.1:8b" style="width:100%"></div>
|
||||
<div class="form-group"><label>${t('designer.ai.api_key')}</label>
|
||||
<input id="aiKey" class="input" type="password" autocomplete="off" placeholder="${cur.has_key ? t('designer.ai.key_set') : t('designer.ai.key_placeholder')}" style="width:100%">
|
||||
<div style="font-size:11px;color:var(--text-muted);margin-top:4px">${t('designer.ai.key_hint')}</div></div>
|
||||
<div id="aiSettingsErr" style="display:none;color:var(--danger);font-size:13px;margin-top:8px"></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" data-ai-close>${t('common.cancel')}</button>
|
||||
<button class="btn btn-primary" id="aiSaveSettings">${t('common.save')}</button>
|
||||
</div>
|
||||
</div>`;
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
31
server/lib/secretbox.js
Normal file
31
server/lib/secretbox.js
Normal file
|
|
@ -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 };
|
||||
165
server/routes/ai.js
Normal file
165
server/routes/ai.js
Normal file
|
|
@ -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;
|
||||
|
|
@ -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'));
|
||||
|
|
|
|||
76
server/test/ai-design.test.js
Normal file
76
server/test/ai-design.test.js
Normal file
|
|
@ -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: '<img src=x onerror=alert(1)>Sale</b>', 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);
|
||||
});
|
||||
Loading…
Reference in a new issue