diff --git a/frontend/js/views/widgets.js b/frontend/js/views/widgets.js
index 60dfa49..06cf613 100644
--- a/frontend/js/views/widgets.js
+++ b/frontend/js/views/widgets.js
@@ -105,7 +105,7 @@ function openContentPicker({ multiple = false, title } = {}) {
});
}
-function showPreviewModal(html, widgetType) {
+function showPreviewModal(sessionId, widgetType) {
const overlay = document.createElement('div');
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.85);display:flex;align-items:center;justify-content:center;z-index:10000;padding:16px';
// #104: webpage widgets pointing at frame-denying sites (X-Frame-Options) can't be
@@ -124,12 +124,7 @@ function showPreviewModal(html, widgetType) {
${webpageNote}
`;
document.body.appendChild(overlay);
- // srcdoc resolves relative URLs against about:srcdoc, so inject pointing to our origin
- const baseTag = ``;
- const withBase = /
]*>/i.test(html)
- ? html.replace(/]*)>/i, `${baseTag}`)
- : html.replace(/]*)>/i, `${baseTag}`);
- overlay.querySelector('#pvIframe').srcdoc = withBase;
+ overlay.querySelector('#pvIframe').src = '/api/widgets/preview-session/' + sessionId;
const close = () => overlay.remove();
overlay.querySelector('#pvClose').onclick = close;
overlay.onclick = (e) => { if (e.target === overlay) close(); };
@@ -551,14 +546,14 @@ export async function render(container) {
if (!type) return;
const config = getConfigFromForm(type);
try {
- const res = await fetch('/api/widgets/preview', {
+ const res = await fetch('/api/widgets/preview-session', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('token')}` },
body: JSON.stringify({ widget_type: type, config }),
});
if (!res.ok) throw new Error(t('widget.toast.preview_failed'));
- const html = await res.text();
- showPreviewModal(html, type);
+ const { id } = await res.json();
+ showPreviewModal(id, type);
} catch (err) { showToast(err.message, 'error'); }
};
diff --git a/server/routes/widgets.js b/server/routes/widgets.js
index a854b03..7212b15 100644
--- a/server/routes/widgets.js
+++ b/server/routes/widgets.js
@@ -212,6 +212,42 @@ router.post('/preview', (req, res) => {
res.send(html);
});
+// Preview sessions — ephemeral store so the preview iframe loads via src (not srcdoc)
+// and bypasses the dashboard CSP that would block the widget's inline scripts.
+const previewStore = new Map();
+const PREVIEW_TTL = 5 * 60 * 1000;
+setInterval(() => {
+ const now = Date.now();
+ for (const [key, entry] of previewStore) {
+ if (now - entry.created > PREVIEW_TTL) previewStore.delete(key);
+ }
+}, 60 * 1000).unref();
+
+router.post('/preview-session', (req, res) => {
+ const { widget_type, config } = req.body || {};
+ if (!widget_type || typeof widget_type !== 'string') return res.status(400).json({ error: 'widget_type required' });
+ if (!KNOWN_WIDGET_TYPES.has(widget_type)) return res.status(400).json({ error: 'Unknown widget_type' });
+ const id = uuidv4();
+ const html = renderWidgetHtml(widget_type, config || {});
+ previewStore.set(id, { html, widget_type, created: Date.now() });
+ res.json({ id, url: `/api/widgets/preview-session/${id}` });
+});
+
+router.get('/preview-session/:id', (req, res) => {
+ const entry = previewStore.get(req.params.id);
+ if (!entry) return res.status(410).send('Preview expired');
+ if (Date.now() - entry.created > PREVIEW_TTL) {
+ previewStore.delete(req.params.id);
+ return res.status(410).send('Preview expired');
+ }
+ let html = entry.html;
+ if (req.workspaceId) html = inlineUserContent(html, req.workspaceId);
+ res.removeHeader('X-Frame-Options');
+ res.setHeader('Cache-Control', 'no-store');
+ res.setHeader('Content-Type', 'text/html');
+ res.send(html);
+});
+
function renderClock(c) {
return `