canvas-lms/app/controllers/external_tools_controller.rb

1235 lines
48 KiB
Ruby

#
# Copyright (C) 2011 - present Instructure, Inc.
#
# This file is part of Canvas.
#
# Canvas is free software: you can redistribute it and/or modify it under
# the terms of the GNU Affero General Public License as published by the Free
# Software Foundation, version 3 of the License.
#
# Canvas 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 Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along
# with this program. If not, see <http://www.gnu.org/licenses/>.
#
# @API External Tools
# API for accessing and configuring external tools on accounts and courses.
# "External tools" are IMS LTI links: http://www.imsglobal.org/developers/LTI/index.cfm
#
# NOTE: Placements not documented here should be considered beta features and are not officially supported.
class ExternalToolsController < ApplicationController
before_action :require_context
before_action :require_tool_create_rights, only: [:create, :create_tool_from_tool_config]
before_action :require_tool_configuration, only: [:create_tool_from_tool_config]
before_action :require_access_to_context, except: [:index, :sessionless_launch]
before_action :require_user, only: [:generate_sessionless_launch]
before_action :get_context, :only => [:retrieve, :show, :resource_selection]
skip_before_action :verify_authenticity_token, only: :resource_selection
include Api::V1::ExternalTools
REDIS_PREFIX = 'external_tool:sessionless_launch:'.freeze
WHITELISTED_QUERY_PARAMS = [
:platform
].freeze
# @API List external tools
# Returns the paginated list of external tools for the current context.
# See the get request docs for a single tool for a list of properties on an external tool.
#
# @argument search_term [String]
# The partial name of the tools to match and return.
#
# @argument selectable [Boolean]
# If true, then only tools that are meant to be selectable are returned
#
# @argument include_parents [Boolean]
# If true, then include tools installed in all accounts above the current context
#
# @example_response
# [
# {
# "id": 1,
# "domain": "domain.example.com",
# "url": "http://www.example.com/ims/lti",
# "consumer_key": "key",
# "name": "LTI Tool",
# "description": "This is for cool things",
# "created_at": "2037-07-21T13:29:31Z",
# "updated_at": "2037-07-28T19:38:31Z",
# "privacy_level": "anonymous",
# "custom_fields": {"key": "value"},
# "account_navigation": {
# "canvas_icon_class": "icon-lti",
# "icon_url": "...",
# "text": "...",
# "url": "...",
# "label": "...",
# "selection_width": 50,
# "selection_height":50
# },
# "assignment_selection": null,
# "course_home_sub_navigation": null,
# "course_navigation": {
# "canvas_icon_class": "icon-lti",
# "icon_url": "...",
# "text": "...",
# "url": "...",
# "default": "disabled",
# "enabled": "true",
# "visibility": "public",
# "windowTarget": "_blank"
# },
# "editor_button": {
# "canvas_icon_class": "icon-lti",
# "icon_url": "...",
# "message_type": "ContentItemSelectionRequest",
# "text": "...",
# "url": "...",
# "label": "...",
# "selection_width": 50,
# "selection_height": 50
# },
# "homework_submission": null,
# "link_selection": null,
# "migration_selection": null,
# "resource_selection": null,
# "tool_configuration": null,
# "user_navigation": null,
# "selection_width": 500,
# "selection_height": 500,
# "icon_url": "...",
# "not_selectable": false
# },
# { ... }
# ]
def index
if authorized_action(@context, @current_user, :read)
if params[:include_parents]
@tools = ContextExternalTool.all_tools_for(@context, :user => (params[:include_personal] ? @current_user : nil))
else
@tools = @context.context_external_tools.active
end
@tools = ContextExternalTool.search_by_attribute(@tools, :name, params[:search_term])
@context.shard.activate do
@tools = @tools.placements(params[:placement]) if params[:placement]
end
if Canvas::Plugin.value_to_boolean(params[:selectable])
@tools = @tools.select{|t| t.selectable }
end
respond_to do |format|
@tools = Api.paginate(@tools, self, tool_pagination_url)
format.json { render :json => external_tools_json(@tools, @context, @current_user, session) }
end
end
end
def homework_submissions
@tools = ContextExternalTool.all_tools_for(@context, :user => @current_user, :type => :has_homework_submission)
respond_to do |format|
format.json { render :json => external_tools_json(@tools, @context, @current_user, session) }
end
end
def finished
@headers = false
end
def retrieve
@tool = ContextExternalTool.find_external_tool(params[:url], @context)
if !@tool
flash[:error] = t "#application.errors.invalid_external_tool", "Couldn't find valid settings for this link"
redirect_to named_context_url(@context, :context_url)
return
end
placement = placement_from_params
add_crumb(@context.name, named_context_url(@context, :context_url))
@lti_launch = lti_launch(
tool: @tool,
selection_type: placement,
launch_url: params[:url],
content_item_id: params[:content_item_id],
secure_params: params[:secure_params]
)
display_override = params['borderless'] ? 'borderless' : params[:display]
render Lti::AppUtil.display_template(@tool.display_type(placement), display_override: display_override)
end
# @API Get a sessionless launch url for an external tool.
# Returns a sessionless launch url for an external tool.
#
# NOTE: Either the id or url must be provided unless launch_type is assessment or module_item.
#
# @argument id [String]
# The external id of the tool to launch.
#
# @argument url [String]
# The LTI launch url for the external tool.
#
# @argument assignment_id [String]
# The assignment id for an assignment launch. Required if launch_type is set to "assessment".
#
# @argument module_item_id [String]
# The assignment id for a module item launch. Required if launch_type is set to "module_item".
#
# @argument launch_type [String, "assessment"|"module_item"]
# The type of launch to perform on the external tool. Placement names (eg. "course_navigation")
# can also be specified to use the custom launch url for that placement; if done, the tool id
# must be provided.
#
# @response_field id The id for the external tool to be launched.
# @response_field name The name of the external tool to be launched.
# @response_field url The url to load to launch the external tool for the user.
#
# @example_request
#
# Finds the tool by id and returns a sessionless launch url
# curl 'https://<canvas>/api/v1/courses/<course_id>/external_tools/sessionless_launch' \
# -H "Authorization: Bearer <token>" \
# -F 'id=<external_tool_id>'
#
# @example_request
#
# Finds the tool by launch url and returns a sessionless launch url
# curl 'https://<canvas>/api/v1/courses/<course_id>/external_tools/sessionless_launch' \
# -H "Authorization: Bearer <token>" \
# -F 'url=<lti launch url>'
#
# @example_request
#
# Finds the tool associated with a specific assignment and returns a sessionless launch url
# curl 'https://<canvas>/api/v1/courses/<course_id>/external_tools/sessionless_launch' \
# -H "Authorization: Bearer <token>" \
# -F 'launch_type=assessment' \
# -F 'assignment_id=<assignment_id>'
#
# @example_request
#
# Finds the tool associated with a specific module item and returns a sessionless launch url
# curl 'https://<canvas>/api/v1/courses/<course_id>/external_tools/sessionless_launch' \
# -H "Authorization: Bearer <token>" \
# -F 'launch_type=module_item' \
# -F 'module_item_id=<module_item_id>'
#
# @example_request
#
# Finds the tool by id and returns a sessionless launch url for a specific placement
# curl 'https://<canvas>/api/v1/courses/<course_id>/external_tools/sessionless_launch' \
# -H "Authorization: Bearer <token>" \
# -F 'id=<external_tool_id>' \
# -F 'launch_type=<placement_name>'
def generate_sessionless_launch
# prerequisite checks
unless Canvas.redis_enabled?
@context.errors.add(:redis, 'Redis is not enabled, but is required for sessionless LTI launch')
return render json: @context.errors, status: :service_unavailable
end
launch_type = params[:launch_type]
if launch_type == 'module_item'
generate_module_item_sessionless_launch
elsif launch_type == 'assessment'
generate_assignment_sessionless_launch
else
generate_common_sessionless_launch
end
end
def sessionless_launch
if Canvas.redis_enabled?
redis_key = "#{@context.class.name}:#{REDIS_PREFIX}#{params[:verifier]}"
launch_settings = Canvas.redis.get(redis_key)
Canvas.redis.del(redis_key)
end
unless launch_settings
render :plain => t(:cannot_locate_launch_request, 'Cannot locate launch request, please try again.'), :status => :not_found
return
end
launch_settings = JSON.parse(launch_settings)
@lti_launch = Lti::Launch.new
@lti_launch.params = launch_settings['tool_settings']
@lti_launch.resource_url = launch_settings['launch_url']
@lti_launch.link_text = launch_settings['tool_name']
@lti_launch.analytics_id = launch_settings['analytics_id']
render Lti::AppUtil.display_template('borderless')
end
# @API Get a single external tool
# Returns the specified external tool.
#
# @response_field id The unique identifier for the tool
# @response_field domain The domain to match links against
# @response_field url The url to match links against
# @response_field consumer_key The consumer key used by the tool (The associated shared secret is not returned)
# @response_field name The name of the tool
# @response_field description A description of the tool
# @response_field created_at Timestamp of creation
# @response_field updated_at Timestamp of last update
# @response_field privacy_level What information to send to the external tool, "anonymous", "name_only", "public"
# @response_field custom_fields Custom fields that will be sent to the tool consumer
# @response_field account_navigation The configuration for account navigation links (see create API for values)
# @response_field assignment_selection The configuration for assignment selection links (see create API for values)
# @response_field course_home_sub_navigation The configuration for course home navigation links (see create API for values)
# @response_field course_navigation The configuration for course navigation links (see create API for values)
# @response_field editor_button The configuration for a WYSIWYG editor button (see create API for values)
# @response_field homework_submission The configuration for homework submission selection (see create API for values)
# @response_field link_selection The configuration for link selection (see create API for values)
# @response_field migration_selection The configuration for migration selection (see create API for values)
# @response_field resource_selection The configuration for a resource selector in modules (see create API for values)
# @response_field tool_configuration The configuration for a tool configuration link (see create API for values)
# @response_field user_navigation The configuration for user navigation links (see create API for values)
# @response_field selection_width The pixel width of the iFrame that the tool will be rendered in
# @response_field selection_height The pixel height of the iFrame that the tool will be rendered in
# @response_field icon_url The url for the tool icon
# @response_field not_selectable whether the tool is not selectable from assignment and modules
#
# @example_response
# {
# "id": 1,
# "domain": "domain.example.com",
# "url": "http://www.example.com/ims/lti",
# "consumer_key": "key",
# "name": "LTI Tool",
# "description": "This is for cool things",
# "created_at": "2037-07-21T13:29:31Z",
# "updated_at": "2037-07-28T19:38:31Z",
# "privacy_level": "anonymous",
# "custom_fields": {"key": "value"},
# "account_navigation": {
# "canvas_icon_class": "icon-lti",
# "icon_url": "...",
# "text": "...",
# "url": "...",
# "label": "...",
# "selection_width": 50,
# "selection_height":50
# },
# "assignment_selection": null,
# "course_home_sub_navigation": null,
# "course_navigation": {
# "canvas_icon_class": "icon-lti",
# "icon_url": "...",
# "text": "...",
# "url": "...",
# "default": "disabled",
# "enabled": "true",
# "visibility": "public",
# "windowTarget": "_blank"
# },
# "editor_button": {
# "canvas_icon_class": "icon-lti",
# "icon_url": "...",
# "message_type": "ContentItemSelectionRequest",
# "text": "...",
# "url": "...",
# "label": "...",
# "selection_width": 50,
# "selection_height": 50
# },
# "homework_submission": null,
# "link_selection": null,
# "migration_selection": null,
# "resource_selection": null,
# "tool_configuration": null,
# "user_navigation": {
# "canvas_icon_class": "icon-lti",
# "icon_url": "...",
# "text": "...",
# "url": "...",
# "default": "disabled",
# "enabled": "true",
# "visibility": "public",
# "windowTarget": "_blank"
# },
# "selection_width": 500,
# "selection_height": 500,
# "icon_url": "...",
# "not_selectable": false
# }
def show
if api_request?
tool = @context.context_external_tools.active.find(params[:external_tool_id])
render :json => external_tool_json(tool, @context, @current_user, session)
else
placement = placement_from_params
return unless find_tool(params[:id], placement)
add_crumb(@context.name, named_context_url(@context, :context_url))
log_asset_access(@tool, "external_tools", "external_tools")
@return_url = named_context_url(@context, :context_external_content_success_url, 'external_tool_redirect', {include_host: true})
@redirect_return = true
success_url = tool_return_success_url(placement)
cancel_url = tool_return_cancel_url(placement) || success_url
js_env(:redirect_return_success_url => success_url,
:redirect_return_cancel_url => cancel_url)
js_env(:course_id => @context.id) if @context.is_a?(Course)
@active_tab = @tool.asset_string
@show_embedded_chat = false if @tool.tool_id == 'chat'
@lti_launch = lti_launch(tool: @tool, selection_type: placement)
return unless @lti_launch
render Lti::AppUtil.display_template(@tool.display_type(placement), display_override: params[:display])
end
end
def tool_return_success_url(selection_type=nil)
case @context
when Course
case selection_type
when "course_settings_sub_navigation"
course_settings_url(@context)
else
course_url(@context)
end
when Account
case selection_type
when "global_navigation"
dashboard_url
else
account_url(@context)
end
else
dashboard_url
end
end
def tool_return_cancel_url(selection_type)
case @context
when Course
if selection_type == "course_home_sub_navigation"
course_url(@context)
end
else
nil
end
end
def resource_selection
add_crumb(@context.name, named_context_url(@context, :context_url))
placement = params[:placement] || params[:launch_type]
selection_type = placement || 'resource_selection'
selection_type = 'editor_button' if params[:editor]
selection_type = 'homework_submission' if params[:homework]
@return_url = named_context_url(@context, :context_external_content_success_url, 'external_tool_dialog', {include_host: true})
@headers = false
return unless find_tool(params[:external_tool_id], selection_type)
@lti_launch = lti_launch(tool: @tool, selection_type: selection_type, launch_token: params[:launch_token])
return unless @lti_launch
render Lti::AppUtil.display_template('borderless')
end
def find_tool(id, selection_type)
if selection_type.nil? || Lti::ResourcePlacement::PLACEMENTS.include?(selection_type.to_sym)
@tool = ContextExternalTool.find_for(id, @context, selection_type, false)
end
if !@tool
flash[:error] = t "#application.errors.invalid_external_tool_id", "Couldn't find valid settings for this tool"
redirect_to named_context_url(@context, :context_url)
end
@tool
end
protected :find_tool
def lti_launch(tool:, selection_type: nil, launch_url: nil, content_item_id: nil, secure_params: nil, launch_token: nil)
link_params = {custom:{}, ext:{}}
if secure_params.present?
jwt_body = Canvas::Security.decode_jwt(secure_params)
link_params[:ext][:lti_assignment_id] = jwt_body[:lti_assignment_id] if jwt_body[:lti_assignment_id]
end
opts = {launch_url: launch_url, link_params: link_params, launch_token: launch_token}
@return_url ||= url_for(@context)
message_type = tool.extension_setting(selection_type, 'message_type') if selection_type
case message_type
when 'ContentItemSelectionResponse', 'ContentItemSelection'
#ContentItemSelectionResponse is deprecated, use ContentItemSelection instead
content_item_selection(tool, selection_type, message_type, opts)
when 'ContentItemSelectionRequest'
opts[:content_item_id] = content_item_id if content_item_id
content_item_selection_request(tool, selection_type, opts)
else
basic_lti_launch_request(tool, selection_type, opts)
end
rescue Lti::Errors::UnauthorizedError
render_unauthorized_action
nil
rescue Lti::Errors::UnsupportedExportTypeError, Lti::Errors::InvalidMediaTypeError
respond_to do |format|
err = t('There was an error generating the tool launch')
format.html do
flash[:error] = err
redirect_to named_context_url(@context, :context_url)
end
format.json { render :json => { error: err } }
end
nil
end
protected :lti_launch
def basic_lti_launch_request(tool, selection_type = nil, opts = {})
lti_launch = tool.settings['post_only'] ? Lti::Launch.new(post_only: true) : Lti::Launch.new
default_opts = {
resource_type: selection_type,
selected_html: params[:selection]
}
opts = default_opts.merge(opts)
assignment = @context.assignments.active.find(params[:assignment_id]) if params[:assignment_id]
expander = variable_expander(assignment: assignment, tool: tool, launch: lti_launch, post_message_token: opts[:launch_token])
adapter = if tool.use_1_3?
Lti::LtiAdvantageAdapter.new(
tool: tool,
user: @current_user,
context: @context,
return_url: @return_url,
expander: expander,
opts: opts
)
else
Lti::LtiOutboundAdapter.new(tool, @current_user, @context).prepare_tool_launch(
@return_url,
expander,
opts
)
end
lti_launch.params = if selection_type == 'homework_submission' && assignment
adapter.generate_post_payload_for_homework_submission(assignment)
else
adapter.generate_post_payload
end
lti_launch.resource_url = opts[:launch_url] || adapter.launch_url
lti_launch.link_text = selection_type ? tool.label_for(selection_type.to_sym, I18n.locale) : tool.default_label
lti_launch.analytics_id = tool.tool_id
lti_launch
end
protected :basic_lti_launch_request
def content_item_selection(tool, placement, message_type, opts = {})
media_types = params.select do |param|
Lti::ContentItemResponse::MEDIA_TYPES.include?(param.to_sym)
end
content_item_response = Lti::ContentItemResponse.new(
@context,
self,
@current_user,
media_types.to_unsafe_h,
params["export_type"]
)
params = Lti::ContentItemSelectionRequest.default_lti_params(@context, @domain_root_account, @current_user).
merge({
#required params
lti_message_type: message_type,
lti_version: 'LTI-1p0',
resource_link_id: Lti::Asset.opaque_identifier_for(@context),
content_items: content_item_response.to_json(lti_message_type: message_type),
launch_presentation_return_url: @return_url,
context_title: @context.name,
tool_consumer_instance_name: @domain_root_account.name,
tool_consumer_instance_contact_email: HostUrl.outgoing_email_address,
}).
merge(variable_expander(tool: tool, attachment: content_item_response.file).
expand_variables!(tool.set_custom_fields(placement)))
lti_launch = @tool.settings['post_only'] ? Lti::Launch.new(post_only: true) : Lti::Launch.new
lti_launch.resource_url = opts[:launch_url] || tool.extension_setting(placement, :url)
lti_launch.params = Lti::Security.signed_post_params(
params,
lti_launch.resource_url,
tool.consumer_key,
tool.shared_secret,
@context.root_account.feature_enabled?(:disable_lti_post_only) || tool.extension_setting(:oauth_compliant)
)
lti_launch.link_text = tool.label_for(placement.to_sym)
lti_launch.analytics_id = tool.tool_id
lti_launch
end
protected :content_item_selection
# Do an official content-item request as specified: http://www.imsglobal.org/LTI/services/ltiCIv1p0pd/ltiCIv1p0pd.html
def content_item_selection_request(tool, placement, opts = {})
selection_request = Lti::ContentItemSelectionRequest.new(context: @context,
domain_root_account: @domain_root_account,
user: @current_user,
base_url: request.base_url,
tool: tool,
secure_params: params[:secure_params])
assignment = @context.assignments.active.find(params[:assignment_id]) if params[:assignment_id].present?
opts = {
post_only: @tool.settings['post_only'].present?,
launch_url: opts[:launch_url] || tool.extension_setting(placement, :url),
content_item_id: opts[:content_item_id],
assignment: assignment
}
collaboration = opts[:content_item_id].present? ? ExternalToolCollaboration.find(opts[:content_item_id]) : nil
base_expander = variable_expander(
tool: tool,
collaboration: collaboration,
editor_contents: params[:editor_contents],
editor_selection: params[:selection]
)
expander = Lti::PrivacyLevelExpander.new(placement, base_expander)
selection_request.generate_lti_launch(
placement: placement,
expanded_variables: expander.expanded_variables!(tool.set_custom_fields(placement)),
opts: opts
)
end
protected :content_item_selection_request
# @API Create an external tool
# Create an external tool in the specified course/account.
# The created tool will be returned, see the "show" endpoint for an example.
#
# @argument name [Required, String]
# The name of the tool
#
# @argument privacy_level [Required, String, "anonymous"|"name_only"|"public"]
# What information to send to the external tool.
#
# @argument consumer_key [Required, String]
# The consumer key for the external tool
#
# @argument shared_secret [Required, String]
# The shared secret with the external tool
#
# @argument description [String]
# A description of the tool
#
# @argument url [String]
# The url to match links against. Either "url" or "domain" should be set,
# not both.
#
# @argument domain [String]
# The domain to match links against. Either "url" or "domain" should be
# set, not both.
#
# @argument icon_url [String]
# The url of the icon to show for this tool
#
# @argument text [String]
# The default text to show for this tool
#
# @argument custom_fields[field_name] [String]
# Custom fields that will be sent to the tool consumer; can be used
# multiple times
#
# @argument account_navigation[url] [String]
# The url of the external tool for account navigation
#
# @argument account_navigation[enabled] [Boolean]
# Set this to enable this feature
#
# @argument account_navigation[text] [String]
# The text that will show on the left-tab in the account navigation
#
# @argument account_navigation[selection_width] [String]
# The width of the dialog the tool is launched in
#
# @argument account_navigation[selection_height] [String]
# The height of the dialog the tool is launched in
#
# @argument account_navigation[display_type] [String]
# The layout type to use when launching the tool. Must be
# "full_width", "full_width_in_context", "borderless", or "default"
#
# @argument user_navigation[url] [String]
# The url of the external tool for user navigation
#
# @argument user_navigation[enabled] [Boolean]
# Set this to enable this feature
#
# @argument user_navigation[text] [String]
# The text that will show on the left-tab in the user navigation
#
# @argument user_navigation[visibility] [String, "admins"|"members"|"public"]
# Who will see the navigation tab. "admins" for admins, "public" or
# "members" for everyone
#
# @argument course_home_sub_navigation[url] [String]
# The url of the external tool for right-side course home navigation menu
#
# @argument course_home_sub_navigation[enabled] [Boolean]
# Set this to enable this feature
#
# @argument course_home_sub_navigation[text] [String]
# The text that will show on the right-side course home navigation menu
#
# @argument course_home_sub_navigation[icon_url] [String]
# The url of the icon to show in the right-side course home navigation menu
#
# @argument course_navigation[enabled] [Boolean]
# Set this to enable this feature
#
# @argument course_navigation[text] [String]
# The text that will show on the left-tab in the course navigation
#
# @argument course_navigation[visibility] [String, "admins"|"members"]
# Who will see the navigation tab. "admins" for course admins, "members" for
# students, null for everyone
#
# @argument course_navigation[windowTarget] [String, "_blank"|"_self"]
# Determines how the navigation tab will be opened.
# "_blank" Launches the external tool in a new window or tab.
# "_self" (Default) Launches the external tool in an iframe inside of Canvas.
#
# @argument course_navigation[default] [Boolean]
# Whether the navigation option will show in the course by default or
# whether the teacher will have to explicitly enable it
#
# @argument course_navigation[display_type] [String]
# The layout type to use when launching the tool. Must be
# "full_width", "full_width_in_context", "borderless", or "default"
#
# @argument editor_button[url] [String]
# The url of the external tool
#
# @argument editor_button[enabled] [Boolean]
# Set this to enable this feature
#
# @argument editor_button[icon_url] [String]
# The url of the icon to show in the WYSIWYG editor
#
# @argument editor_button[selection_width] [String]
# The width of the dialog the tool is launched in
#
# @argument editor_button[selection_height] [String]
# The height of the dialog the tool is launched in
#
# @argument editor_button[message_type] [String]
# Set this to ContentItemSelectionRequest to tell the tool to use
# content-item; otherwise, omit
#
# @argument homework_submission[url] [String]
# The url of the external tool
#
# @argument homework_submission[enabled] [Boolean]
# Set this to enable this feature
#
# @argument homework_submission[text] [String]
# The text that will show on the homework submission tab
#
# @argument homework_submission[message_type] [String]
# Set this to ContentItemSelectionRequest to tell the tool to use
# content-item; otherwise, omit
#
# @argument link_selection[url] [String]
# The url of the external tool
#
# @argument link_selection[enabled] [Boolean]
# Set this to enable this feature
#
# @argument link_selection[text] [String]
# The text that will show for the link selection text
#
# @argument link_selection[message_type] [String]
# Set this to ContentItemSelectionRequest to tell the tool to use
# content-item; otherwise, omit
#
# @argument migration_selection[url] [String]
# The url of the external tool
#
# @argument migration_selection[enabled] [Boolean]
# Set this to enable this feature
#
# @argument migration_selection[message_type] [String]
# Set this to ContentItemSelectionRequest to tell the tool to use
# content-item; otherwise, omit
#
# @argument tool_configuration[url] [String]
# The url of the external tool
#
# @argument tool_configuration[enabled] [Boolean]
# Set this to enable this feature
#
# @argument tool_configuration[message_type] [String]
# Set this to ContentItemSelectionRequest to tell the tool to use
# content-item; otherwise, omit
#
# @argument tool_configuration[prefer_sis_email] [Boolean]
# Set this to default the lis_person_contact_email_primary to prefer
# provisioned sis_email; otherwise, omit
#
# @argument resource_selection[url] [String]
# The url of the external tool
#
# @argument resource_selection[enabled] [Boolean]
# Set this to enable this feature
#
# @argument resource_selection[icon_url] [String]
# The url of the icon to show in the module external tool list
#
# @argument resource_selection[selection_width] [String]
# The width of the dialog the tool is launched in
#
# @argument resource_selection[selection_height] [String]
# The height of the dialog the tool is launched in
#
# @argument config_type [String]
# Configuration can be passed in as CC xml instead of using query
# parameters. If this value is "by_url" or "by_xml" then an xml
# configuration will be expected in either the "config_xml" or "config_url"
# parameter. Note that the name parameter overrides the tool name provided
# in the xml
#
# @argument config_xml [String]
# XML tool configuration, as specified in the CC xml specification. This is
# required if "config_type" is set to "by_xml"
#
# @argument config_url [String]
# URL where the server can retrieve an XML tool configuration, as specified
# in the CC xml specification. This is required if "config_type" is set to
# "by_url"
#
# @argument not_selectable [Boolean]
# Default: false, if set to true the tool won't show up in the external tool
# selection UI in modules and assignments
#
# @argument oauth_compliant [Boolean]
# Default: false, if set to true LTI query params will not be copied to the
# post body.
#
# @example_request
#
# This would create a tool on this course with two custom fields and a course navigation tab
# curl -X POST 'https://<canvas>/api/v1/courses/<course_id>/external_tools' \
# -H "Authorization: Bearer <token>" \
# -F 'name=LTI Example' \
# -F 'consumer_key=asdfg' \
# -F 'shared_secret=lkjh' \
# -F 'url=https://example.com/ims/lti' \
# -F 'privacy_level=name_only' \
# -F 'custom_fields[key1]=value1' \
# -F 'custom_fields[key2]=value2' \
# -F 'course_navigation[text]=Course Materials' \
# -F 'course_navigation[default]=false'
# -F 'course_navigation[enabled]=true'
#
# @example_request
#
# This would create a tool on the account with navigation for the user profile page
# curl -X POST 'https://<canvas>/api/v1/accounts/<account_id>/external_tools' \
# -H "Authorization: Bearer <token>" \
# -F 'name=LTI Example' \
# -F 'consumer_key=asdfg' \
# -F 'shared_secret=lkjh' \
# -F 'url=https://example.com/ims/lti' \
# -F 'privacy_level=name_only' \
# -F 'user_navigation[url]=https://example.com/ims/lti/user_endpoint' \
# -F 'user_navigation[text]=Something Cool'
# -F 'user_navigation[enabled]=true'
#
# @example_request
#
# This would create a tool on the account with configuration pulled from an external URL
# curl -X POST 'https://<canvas>/api/v1/accounts/<account_id>/external_tools' \
# -H "Authorization: Bearer <token>" \
# -F 'name=LTI Example' \
# -F 'consumer_key=asdfg' \
# -F 'shared_secret=lkjh' \
# -F 'config_type=by_url' \
# -F 'config_url=https://example.com/ims/lti/tool_config.xml'
def create
external_tool_params = (params[:external_tool] || params).to_unsafe_h
@tool = @context.context_external_tools.new
if request.content_type == 'application/x-www-form-urlencoded'
custom_fields = Lti::AppUtil.custom_params(request.raw_post)
external_tool_params[:custom_fields] = custom_fields if custom_fields.present?
end
set_tool_attributes(@tool, external_tool_params)
check_for_duplication(@tool)
if @tool.errors.blank? && @tool.save
invalidate_nav_tabs_cache(@tool)
if api_request?
render :json => external_tool_json(@tool, @context, @current_user, session)
else
render :json => @tool.as_json(:methods => [:readable_state, :custom_fields_string, :vendor_help_link], :include_root => false)
end
else
render :json => @tool.errors, :status => :bad_request
@tool.destroy if @tool.persisted?
end
end
# Add an external tool and verify the provided
# configuration url matches the associated
# configuration url listed on the app
# center. Besides the argument listed, all arguments
# are identical to the "Create an external tool"
# endpoint.
#
# @argument app_center_id [Required, String]
# ID of the external tool in the app center
#
# @argument config_settings [String]
# Stringified object of key/value pairs to be used
# as query string parameters on the XML configuration
# URL.
def create_tool_with_verification
if authorized_action(@context, @current_user, :update)
app_api = AppCenter::AppApi.new(@context)
required_params = [
:consumer_key,
:shared_secret,
:name,
:app_center_id,
:context_id,
:context_type,
:config_settings
]
# we're ok with an "unsafe" hash because we're filtering via required_params
external_tool_params = params.to_unsafe_h.select{|k, _| required_params.include?(k.to_sym)}
external_tool_params[:config_url] = app_api.get_app_config_url(external_tool_params[:app_center_id], external_tool_params[:config_settings])
external_tool_params[:config_type] = 'by_url'
@tool = @context.context_external_tools.new
set_tool_attributes(@tool, external_tool_params)
respond_to do |format|
if @tool.save
invalidate_nav_tabs_cache(@tool)
format.json { render :json => external_tool_json(@tool, @context, @current_user, session) }
else
format.json { render :json => @tool.errors, :status => :bad_request }
end
end
end
end
# @API Create Tool from ToolConfiguration
# Creates context_external_tool from attached tool_configuration of
# the provided developer_key if not already present in context.
# DeveloperKey must have a ToolConfiguration to create tool or 404 will be raised.
# Will return an existing ContextExternalTool if one already exists.
#
# @argument account_id [String]
# if account
#
# @argument course_id [String]
# if course
#
# @argument developer_key_id [String]
#
# @returns ContextExternalTool
def create_tool_from_tool_config
cet = fetch_existing_tool_in_context_chain
if cet.nil?
cet = developer_key.tool_configuration.new_external_tool(@context)
cet.save!
invalidate_nav_tabs_cache(cet)
end
render json: external_tool_json(cet, @context, @current_user, session)
end
# @API Edit an external tool
# Update the specified external tool. Uses same parameters as create
#
# @example_request
#
# This would update the specified keys on this external tool
# curl -X PUT 'https://<canvas>/api/v1/courses/<course_id>/external_tools/<external_tool_id>' \
# -H "Authorization: Bearer <token>" \
# -F 'name=Public Example' \
# -F 'privacy_level=public'
def update
@tool = @context.context_external_tools.active.find(params[:id] || params[:external_tool_id])
if authorized_action(@tool, @current_user, :update_manually)
external_tool_params = (params[:external_tool] || params).to_unsafe_h
if request.content_type == 'application/x-www-form-urlencoded'
custom_fields = Lti::AppUtil.custom_params(request.raw_post)
external_tool_params[:custom_fields] = custom_fields if custom_fields.present?
end
respond_to do |format|
set_tool_attributes(@tool, external_tool_params)
if @tool.save
invalidate_nav_tabs_cache(@tool)
if api_request?
format.json { render :json => external_tool_json(@tool, @context, @current_user, session) }
else
format.json { render :json => @tool.as_json(:methods => [:readable_state, :custom_fields_string], :include_root => false) }
end
else
format.json { render :json => @tool.errors, :status => :bad_request }
end
end
end
end
# @API Delete an external tool
# Remove the specified external tool
#
# @example_request
#
# This would delete the specified external tool
# curl -X DELETE 'https://<canvas>/api/v1/courses/<course_id>/external_tools/<external_tool_id>' \
# -H "Authorization: Bearer <token>"
def destroy
@tool = @context.context_external_tools.active.find(params[:id] || params[:external_tool_id])
if authorized_action(@tool, @current_user, :delete)
respond_to do |format|
if @tool.destroy
if api_request?
invalidate_nav_tabs_cache(@tool)
format.json { render :json => external_tool_json(@tool, @context, @current_user, session) }
else
format.json { render :json => @tool.as_json(:methods => [:readable_state, :custom_fields_string], :include_root => false) }
end
else
format.json { render :json => @tool.errors, :status => :bad_request }
end
end
end
end
def jwt_token
tool = ContextExternalTool.find_external_tool(params[:tool_launch_url], @context, params[:tool_id])
raise ActiveRecord::RecordNotFound if tool.nil?
launch = lti_launch(tool: tool)
return unless launch
params = launch.params.reject {|p| p.starts_with?('oauth_')}
params[:consumer_key] = tool.consumer_key
params[:iat] = Time.zone.now.to_i
render json: {jwt_token: Canvas::Security.create_jwt(params, nil, tool.shared_secret)}
end
private
def check_for_duplication(tool)
if tool.duplicated_in_context? && params.dig(:external_tool, :verify_uniqueness).present?
tool.errors.add(:tool_currently_installed, 'The tool is already installed in this context.')
end
end
def generate_module_item_sessionless_launch
module_item_id = params[:module_item_id]
unless module_item_id
@context.errors.add(:module_item_id, 'A module item id must be provided for module item LTI launch')
return render json: @context.errors, status: :bad_request
end
module_item = ContentTag.find(module_item_id)
if module_item.context_module_id.blank?
@context.errors.add(:module_item_id, 'The content tag with the specified id is not a content item')
return render json: @context.errors, status: :bad_request
end
generate_common_sessionless_launch(
launch_url: module_item.url,
options: {module_item: module_item}
)
end
def generate_assignment_sessionless_launch
unless params[:assignment_id]
@context.errors.add(:assignment_id, 'An assignment id must be provided for assessment LTI launch')
return render json: @context.errors, status: :bad_request
end
assignment = @context.assignments.find(params[:assignment_id])
return unless authorized_action(assignment, @current_user, :read)
unless assignment.external_tool_tag
@context.errors.add(:assignment_id, 'The assignment must have an external tool tag')
return render json: @context.errors, status: :bad_request
end
generate_common_sessionless_launch(
launch_url: assignment.external_tool_tag.url,
options: {assignment: assignment}
)
end
def generate_common_sessionless_launch(launch_url: nil, options: {})
tool_id = params[:id]
launch_url = params[:url] || launch_url
launch_type = params[:launch_type]
module_item = options[:module_item]
unless tool_id || launch_url || module_item
@context.errors.add(:id, 'A tool id, tool url, or module item id must be provided')
@context.errors.add(:url, 'A tool id, tool url, or module item id must be provided')
@context.errors.add(:module_item_id, 'A tool id, tool url, or module item id must be provided')
return render :json => @context.errors, :status => :bad_request
end
if launch_url && module_item.blank?
@tool = ContextExternalTool.find_external_tool(launch_url, @context, tool_id)
elsif module_item
@tool = ContextExternalTool.find_external_tool(
module_item.url,
@context,
module_item.content_id
)
else
return unless find_tool(tool_id, launch_type)
end
if @tool.blank? || (@tool.url.blank? && @tool&.extension_setting(launch_type, :url).blank? && launch_url.blank?)
respond_to do |format|
format.html do
flash[:error] = t "#application.errors.invalid_external_tool", "Couldn't find valid settings for this link"
return redirect_to named_context_url(@context, :context_url)
end
format.json { render json: {errors: {external_tool: "Unable to find a matching external tool"}} and return }
end
end
# generate the launch
opts = {
launch_url: launch_url,
resource_type: launch_type
}
case launch_type
when 'module_item'
opts[:link_code] = @tool.opaque_identifier_for(module_item)
when 'assessment'
opts[:link_code] = @tool.opaque_identifier_for(options[:assignment].external_tool_tag)
end
opts[:overrides] = whitelisted_query_params if whitelisted_query_params.any?
adapter = Lti::LtiOutboundAdapter.new(
@tool,
@current_user,
@context
).prepare_tool_launch(
url_for(@context),
variable_expander(assignment: options[:assignment], content_tag: module_item),
opts
)
launch_settings = {
'launch_url' => adapter.launch_url(post_only: @tool.settings['post_only']),
'tool_name' => @tool.name,
'analytics_id' => @tool.tool_id
}
launch_settings['tool_settings'] = if options[:assignment]
adapter.generate_post_payload_for_assignment(
options[:assignment],
lti_grade_passback_api_url(@tool),
blti_legacy_grade_passback_api_url(@tool),
lti_turnitin_outcomes_placement_url(@tool.id)
)
else
adapter.generate_post_payload
end
# store the launch settings and return to the user
verifier = SecureRandom.hex(64)
Canvas.redis.setex("#{@context.class.name}:#{REDIS_PREFIX}#{verifier}", 5.minutes, launch_settings.to_json)
uri = if @context.is_a?(Account)
URI(account_external_tools_sessionless_launch_url(@context))
else
URI(course_external_tools_sessionless_launch_url(@context))
end
uri.query = {:verifier => verifier}.to_query
render :json => {:id => @tool.id, :name => @tool.name, :url => uri.to_s}
end
def set_tool_attributes(tool, params)
attrs = Lti::ResourcePlacement::PLACEMENTS
attrs += [:name, :description, :url, :icon_url, :canvas_icon_class, :domain, :privacy_level, :consumer_key, :shared_secret,
:custom_fields, :custom_fields_string, :text, :config_type, :config_url, :config_xml, :not_selectable, :app_center_id,
:oauth_compliant]
attrs += [:allow_membership_service_access] if @context.root_account.feature_enabled?(:membership_service_for_lti_tools)
attrs += [:developer_key_id] if set_developer_key?(params)
attrs.each do |prop|
tool.send("#{prop}=", params[prop]) if params.has_key?(prop)
end
end
def set_developer_key?(external_tool_params)
developer_key_id = external_tool_params[:developer_key_id]
return false if developer_key_id.blank?
return false unless DeveloperKey.find(developer_key_id).owner_account == @context
return false unless @context.grants_right?(@current_user, session, :manage_developer_keys)
true
end
def invalidate_nav_tabs_cache(tool)
if tool.has_placement?(:user_navigation) || tool.has_placement?(:course_navigation) || tool.has_placement?(:account_navigation)
Lti::NavigationCache.new(@domain_root_account).invalidate_cache_key
end
end
def require_access_to_context
if @context.is_a?(Account)
require_user
elsif !@context.grants_right?(@current_user, session, :read)
render_unauthorized_action
end
end
def variable_expander(opts = {})
default_opts = {
current_user: @current_user,
current_pseudonym: @current_pseudonym,
tool: @tool }
Lti::VariableExpander.new(@domain_root_account, @context, self, default_opts.merge(opts))
end
def require_tool_create_rights
authorized_action(@context, @current_user, :create_tool_manually)
end
def require_tool_configuration
return if developer_key.tool_configuration.present?
head :not_found
end
def fetch_existing_tool_in_context_chain
ContextExternalTool.all_tools_for(@context).where(developer_key: developer_key).take
end
def developer_key
@_developer_key = DeveloperKey.nondeleted.find(params[:developer_key_id])
end
def placement_from_params
params[:placement] || params[:launch_type] || "#{@context.class.base_class.to_s.downcase}_navigation"
end
def whitelisted_query_params
@_whitelisted_query_params ||= WHITELISTED_QUERY_PARAMS.each_with_object({}) do |query_param, h|
h[query_param] = params[query_param] if params.key?(query_param)
end
end
end