discourse-ai/app/controllers/discourse_ai/ai_bot/artifacts_controller.rb
Sam 02bc9f645e
FEATURE: hybrid artifact security mode (#1431)
In hybrid mode ai artifacts can optionally automatically run.

This is useful for cases where you may want to embed a survey and so on.

Additionally, artifacts now allow for better fidelity around display:

<div class="ai-artifact" data-ai-artifact-id="501" data-ai-artifact-height="300px" data-ai-artifact-autorun data-ai-artifact-seamless></div>

User can supply height and seamless mode to be seamlessly rendered with no box shadow and show full screen button.
2025-06-12 20:04:48 +10:00

345 lines
12 KiB
Ruby

# frozen_string_literal: true
module DiscourseAi
module AiBot
class ArtifactsController < ApplicationController
requires_plugin DiscourseAi::PLUGIN_NAME
before_action :require_site_settings!
skip_before_action :preload_json, :check_xhr, only: %i[show]
def show
artifact = AiArtifact.find(params[:id])
post = Post.find_by(id: artifact.post_id)
if artifact.public?
# no guardian needed
else
raise Discourse::NotFound if !post&.topic&.private_message?
raise Discourse::NotFound if !guardian.can_see?(post)
end
name = artifact.name
artifact_version = nil
if params[:version].present?
artifact_version = artifact.versions.find_by(version_number: params[:version])
raise Discourse::NotFound if !artifact_version
end
untrusted_html = build_untrusted_html(artifact_version || artifact, name)
trusted_html = build_trusted_html(artifact, artifact_version, name, untrusted_html)
set_security_headers
render html: trusted_html.html_safe, layout: false, content_type: "text/html"
end
private
def build_untrusted_html(artifact, name)
js = prepare_javascript(artifact.js)
<<~HTML
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>#{ERB::Util.html_escape(name)}</title>
<style>
#{artifact.css}
</style>
#{build_iframe_javascript}
</head>
<body>
#{artifact.html}
#{js}
</body>
</html>
HTML
end
def build_trusted_html(artifact, artifact_version, name, untrusted_html)
<<~HTML
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>#{ERB::Util.html_escape(name)}</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0, user-scalable=yes, viewport-fit=cover, interactive-widget=resizes-content">
<meta name="csrf-token" content="#{form_authenticity_token}">
<style>
html, body, iframe {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
border: 0;
overflow: hidden;
}
iframe {
overflow: auto;
}
</style>
</head>
<body>
<iframe sandbox="allow-scripts allow-forms" height="100%" width="100%" srcdoc="#{ERB::Util.html_escape(untrusted_html)}" frameborder="0"></iframe>
#{build_parent_javascript(artifact)}
</body>
</html>
HTML
end
def prepare_javascript(js)
return "" if js.blank?
if !js.match?(%r{\A\s*<script.*</script>}mi)
mod = ""
mod = " type=\"module\"" if js.match?(/\A\s*import.*/)
js = "<script#{mod}>\n#{js}\n</script>"
end
js
end
def user_data
{
username: current_user ? current_user.username : nil,
user_id: current_user ? current_user.id : nil,
name: current_user ? current_user.name : nil,
}
end
def build_iframe_javascript
<<~JAVASCRIPT
<script>
window._discourse_user_data = #{user_data.to_json};
window.discourseArtifactReady = new Promise(resolve => {
window._resolveArtifactData = resolve;
});
// Key-value store API
window.discourseArtifact = {
get: function(key) {
return window._postMessageRequest('get', { key: key });
},
set: function(key, value, options = {}) {
return window._postMessageRequest('set', {
key: key,
value: value,
public: options.public || false
});
},
delete: function(key) {
return window._postMessageRequest('delete', { key: key });
},
index: function(filter = {}) {
return window._postMessageRequest('index', filter);
}
};
window._postMessageRequest = function(action, data) {
return new Promise((resolve, reject) => {
const requestId = Math.random().toString(36).substr(2, 9);
const messageHandler = function(event) {
if (event.data && event.data.requestId === requestId) {
window.removeEventListener('message', messageHandler);
if (event.data.error) {
reject(new Error(event.data.error));
} else {
resolve(event.data.result);
}
}
};
window.addEventListener('message', messageHandler);
window.parent.postMessage({
type: 'discourse-artifact-kv',
action: action,
data: data,
requestId: requestId
}, '*');
});
};
window.addEventListener('message', function(event) {
if (event.data && event.data.type === 'discourse-artifact-data') {
window.discourseArtifactData = event.data.dataset || {};
Object.assign(window.discourseArtifactData, window._discourse_user_data);
window._resolveArtifactData(window.discourseArtifactData);
}
});
</script>
JAVASCRIPT
end
def build_parent_javascript(artifact)
<<~JAVASCRIPT
<script>
document.querySelector('iframe').addEventListener('load', function() {
try {
const iframeWindow = this.contentWindow;
const message = { type: 'discourse-artifact-data', dataset: {} };
if (window.frameElement && window.frameElement.dataset) {
Object.assign(message.dataset, window.frameElement.dataset);
}
iframeWindow.postMessage(message, '*');
} catch (e) {
console.error('Error passing data to artifact:', e);
}
});
// Handle key-value store requests from iframe
window.addEventListener('message', async function(event) {
if (event.data && event.data.type === 'discourse-artifact-kv') {
const { action, data, requestId } = event.data;
const artifactId = #{artifact.id};
try {
const result = await handleKeyValueRequest(action, data, artifactId);
event.source.postMessage({
requestId: requestId,
result: result
}, '*');
} catch (error) {
event.source.postMessage({
requestId: requestId,
error: error.message
}, '*');
}
}
});
async function handleKeyValueRequest(action, data, artifactId) {
const baseUrl = '/discourse-ai/ai-bot/artifact-key-values/' + artifactId + ".json";
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || '';
switch (action) {
case 'get':
return await handleGetRequest(baseUrl, data, csrfToken);
case 'set':
return await handleSetRequest(baseUrl, data, csrfToken);
case 'index':
return await handleIndexRequest(baseUrl, data, csrfToken);
case 'delete':
return await handleDeleteRequest(baseUrl, data, csrfToken);
default:
throw new Error('Unknown action: ' + action);
}
}
async function handleGetRequest(baseUrl, data, csrfToken) {
const response = await fetch(baseUrl + '?key=' + encodeURIComponent(data.key), {
method: 'GET',
headers: {
'X-CSRF-Token': csrfToken,
'Content-Type': 'application/json'
},
credentials: 'same-origin'
});
if (!response.ok) throw new Error('Failed to get key-value');
const result = await response.json();
const keyValue = result.key_values.find(kv => kv.key === data.key);
return keyValue ? keyValue.value : null;
}
async function handleSetRequest(baseUrl, data, csrfToken) {
const response = await fetch(baseUrl, {
method: 'POST',
headers: {
'X-CSRF-Token': csrfToken,
'Content-Type': 'application/json'
},
credentials: 'same-origin',
body: JSON.stringify({
key: data.key,
value: data.value,
public: data.public
})
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.errors ? errorData.errors.join(', ') : 'Failed to set key-value');
}
return await response.json();
}
async function handleDeleteRequest(baseUrl, data, csrfToken) {
const response = await fetch(baseUrl, {
method: 'DELETE',
body: JSON.stringify({ key: data.key }),
headers: {
'X-CSRF-Token': csrfToken,
'Content-Type': 'application/json'
},
credentials: 'same-origin'
});
if (!response.ok) {
if (response.status === 404) {
throw new Error('Key not found');
}
const errorData = await response.json();
throw new Error(errorData.errors ? errorData.errors.join(', ') : 'Failed to delete key-value');
}
return true;
}
async function handleIndexRequest(baseUrl, data, csrfToken) {
const params = new URLSearchParams();
if (data.key) params.append('key', data.key);
if (data.all_users) params.append('all_users', data.all_users);
if (data.keys_only) params.append('keys_only', data.keys_only);
if (data.page) params.append('page', data.page);
if (data.per_page) params.append('per_page', data.per_page);
const response = await fetch(baseUrl + '?' + params.toString(), {
method: 'GET',
headers: {
'X-CSRF-Token': csrfToken,
'Content-Type': 'application/json'
},
credentials: 'same-origin'
});
if (!response.ok) throw new Error('Failed to get key-values');
const result = await response.json();
const userMap = {};
result.users.forEach(user => {
userMap[user.id] = user;
});
result.key_values.forEach(kv => {
if (kv.user_id && userMap[kv.user_id]) {
kv.user = userMap[kv.user_id];
}
});
return result;
}
</script>
JAVASCRIPT
end
def set_security_headers
response.headers.delete("X-Frame-Options")
response.headers[
"Content-Security-Policy"
] = "script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval' #{AiArtifact::ALLOWED_CDN_SOURCES.join(" ")};"
response.headers["X-Robots-Tag"] = "noindex"
end
def require_site_settings!
if !SiteSetting.discourse_ai_enabled ||
!SiteSetting.ai_artifact_security.in?(%w[lax hybrid strict])
raise Discourse::NotFound
end
end
end
end
end