panda_pal 5.6.7.beta1 → 5.6.7.beta2

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6e63f91a338bf3876346da15600d8d51de860c1b7eca7cb988f54d1edcd9f425
4
- data.tar.gz: 34aa9d21697f44409ae69fb16e6bf412d2d6c4c00a492c6c745e2202625d90d5
3
+ metadata.gz: 98591ab595d9224925ed0d0bd4259b3a0c6a9b80369ab8ed86539829583906fc
4
+ data.tar.gz: a240dacbb53ff02cd6e3f0688f41ba3cb0310bfa01af2f369c7229ecc3179c80
5
5
  SHA512:
6
- metadata.gz: fca067f026f174fb1ce47561c0adca4ec454a61ae5e03437dbf8b7ebfb784aa2624b4e232ece1fbb642442ac3fe7c5c0f2169a9de1b71c37e8430409133e045a
7
- data.tar.gz: 3de864a856dcb3928ef53066275946a24136c4cb877207090110aaf5a9b3cceca095748860d6bcbc132956570bd893dd0ede48b9672fd8025bcc3526bee383cc
6
+ metadata.gz: cf95e243d952873de333d242374c902d45bf4415efad2de495acebd31b760ae745ffcedbec205687c24b4d5a607af60b8c147db679f0a9defe1de362a1b85ec2
7
+ data.tar.gz: ae41d6a39f11e2aa2c4dccc837342d110d25491db1acc56edc1047d8cc9790f550d45d218e4d4bf5d46c6b781785f1dd8e8a7813416bccd94de92cf4215d8f3c
@@ -12,14 +12,11 @@ module PandaPal
12
12
  around_action :switch_tenant, only: [:resource_link_request]
13
13
 
14
14
  def login
15
- # TODO Figure out how to make this support deployment_ids
16
- # May need to move Platform#jwks_url and Platform#authentication_redirect_url to
17
- # a new GenericPlatform class/model and then associate the here-generated Session with
18
- # the public tenant, re-homing it once we have a deployment_id available
19
- # ... or create a new AuthState model instead of using Session here
20
- @organization = PandaPal::Organization.find_by!(key: params[:client_id])
15
+ @current_lti_platform = PandaPal::Platform.resolve_platform(params)
21
16
 
17
+ current_session_data[:lti_platform] = @current_lti_platform&.serialize
22
18
  current_session_data[:lti_oauth_nonce] = SecureRandom.uuid
19
+ current_session.panda_pal_organization_id = -1
23
20
 
24
21
  @form_action = current_lti_platform.authentication_redirect_url
25
22
  @method = :post
@@ -39,6 +36,7 @@ module PandaPal
39
36
 
40
37
  def resource_link_request
41
38
  # Redirect to correct region/env?
39
+
42
40
  if params[:launch_type]
43
41
  current_session_data.merge!({
44
42
  lti_version: 'v1p3',
@@ -88,20 +88,8 @@ module PandaPal
88
88
  end
89
89
  end
90
90
 
91
- def lti_platform
92
- lti_platform_type.new(self)
93
- end
94
-
95
- def lti_platform_type
96
- platform = PandaPal.lti_options[:platform] || 'canvas.instructure.com'
97
- case platform
98
- when 'canvas.instructure.com'
99
- PandaPal::Platform::Canvas
100
- # when 'bridgeapp.com'
101
- # TODO Add support for Bridge?
102
- else
103
- raise "Unknown platform '#{platform}'"
104
- end
91
+ def self.resolve_platform(hint)
92
+ iss = hint["iss"]
105
93
  end
106
94
 
107
95
  def rename!(new_name)
@@ -114,25 +102,15 @@ module PandaPal
114
102
  switch_tenant if do_switch
115
103
  end
116
104
 
117
- def respond_to_missing?(name, include_private = false)
118
- (platform_org_extension_module&.instance_method(name) rescue nil) || super
119
- end
120
-
121
- def method_missing(method, *args, **kwargs, &block)
122
- ext = platform_org_extension_module
123
- if (ext.instance_method(method) rescue nil)
124
- ext.instance_method(method).bind_call(self, *args, **kwargs, &block)
125
- else
126
- super
105
+ if !PandaPal.lti_options[:platform].present? || PandaPal.lti_options[:platform].is_a?(String)
106
+ platform_cls = Platform.resolve_platform_class(nil) rescue nil
107
+ if platform_cls && defined?(platform_cls::OrgExtension)
108
+ include platform_cls::OrgExtension
127
109
  end
128
110
  end
129
111
 
130
112
  private
131
113
 
132
- def platform_org_extension_module
133
- defined?(lti_platform_type::OrgExtension) ? lti_platform_type::OrgExtension : nil
134
- end
135
-
136
114
  def create_schema
137
115
  Apartment::Tenant.create name
138
116
  end
@@ -0,0 +1,163 @@
1
+ begin
2
+ require 'bearcat'
3
+ rescue LoadError
4
+ end
5
+
6
+ module PandaPal
7
+ class Platform::Canvas < Platform
8
+ attr_accessor :organization
9
+
10
+ KNOWN_ISSUERS = [
11
+ "https://canvas.instructure.com",
12
+ "https://canvas.beta.instructure.com",
13
+ "https://canvas.test.instructure.com",
14
+ ]
15
+
16
+ def jwks_url
17
+ "#{@issuer}/api/lti/security/jwks"
18
+ end
19
+
20
+ def authentication_redirect_url
21
+ "#{@issuer}/api/lti/authorize_redirect"
22
+ end
23
+
24
+ def grant_url
25
+ "#{@issuer}/login/oauth2/token"
26
+ end
27
+
28
+ module OrgExtension
29
+ extend ActiveSupport::Concern
30
+
31
+ def install_lti(host: nil, context: :root_account, version: 'v1p1', exists: :error)
32
+ raise "Automatically installing this LTI requires Bearcat." unless defined?(Bearcat)
33
+
34
+ context = context.to_s
35
+ context = 'account/self' if context == 'root_account'
36
+ cid, ctype = context.split(/[\.\/]/).reverse
37
+ ctype ||= 'account'
38
+
39
+ existing_installs = bearcat_client.send(:"#{ctype}_external_tools", cid).all_pages_each.filter do |cet|
40
+ cet[:consumer_key] == self.key
41
+ end
42
+
43
+ if existing_installs.present?
44
+ case exists
45
+ when :error
46
+ raise "Tool with key #{self.key} already installed"
47
+ when :duplicate
48
+ when :replace
49
+ existing_installs.each do |install|
50
+ bearcat_client.send(:"delete_#{ctype}_external_tool", cid, install[:id])
51
+ end
52
+ else
53
+ raise "exists: #{exists} is not supported"
54
+ end
55
+ end
56
+
57
+ # TODO LTI 1.3 Support
58
+
59
+ conf = {
60
+ name: PandaPal.lti_options[:title],
61
+ description: PandaPal.lti_options[:description],
62
+ consumer_key: self.key,
63
+ shared_secret: self.secret,
64
+ privacy_level: "public",
65
+ config_type: 'by_url',
66
+ config_url: PandaPal::LaunchUrlHelpers.resolve_route(:v1p0_config_url, host: host),
67
+ }
68
+
69
+ bearcat_client.send(:"create_#{ctype}_external_tool", cid, conf)
70
+ end
71
+
72
+ def reinstall_lti!(*args, **kwargs)
73
+ install_lti(*args, exists: :replace, **kwargs)
74
+ end
75
+
76
+ def lti_api_configuration(host: nil)
77
+ PandaPal::LaunchUrlHelpers.with_uri_host(host) do
78
+ domain = PandaPal.lti_properties[:domain] || host.host
79
+ launch_url = PandaPal.lti_options[:secure_launch_url] ||
80
+ "#{domain}#{PandaPal.lti_options[:secure_launch_path]}" ||
81
+ PandaPal.lti_options[:launch_url] ||
82
+ "#{domain}#{PandaPal.lti_options[:launch_path]}" ||
83
+ domain
84
+
85
+ lti_json = {
86
+ name: PandaPal.lti_options[:title],
87
+ description: PandaPal.lti_options[:description],
88
+ domain: host.host,
89
+ url: launch_url,
90
+ consumer_key: self.key,
91
+ shared_secret: self.secret,
92
+ privacy_level: "public",
93
+
94
+ custom_fields: {},
95
+
96
+ environments: PandaPal.lti_environments,
97
+ }
98
+
99
+ lti_json = lti_json.with_indifferent_access
100
+ lti_json.merge!(PandaPal.lti_properties)
101
+
102
+ (PandaPal.lti_options[:custom_fields] || []).each do |k, v|
103
+ lti_json[:custom_fields][k] = v
104
+ end
105
+
106
+ PandaPal.lti_paths.each do |k, options|
107
+ options = PandaPal::LaunchUrlHelpers.normalize_lti_launch_desc(options)
108
+ options[:url] = PandaPal::LaunchUrlHelpers.absolute_launch_url(
109
+ k.to_sym,
110
+ host: host,
111
+ launch_handler: :v1p0_launch_path,
112
+ default_auto_launch: false
113
+ ).to_s
114
+ lti_json[k] = options
115
+ end
116
+
117
+ lti_json
118
+ end
119
+ end
120
+
121
+ def canvas_url
122
+ PandaPal::Platform.find_org_setting([
123
+ "canvas.base_url",
124
+ "canvas_url",
125
+ "canvas_base_url",
126
+ "canvas.url",
127
+ "base_url",
128
+ ], self) || (Rails.env.development? && 'http://localhost:3000') || 'https://canvas.instructure.com'
129
+ end
130
+
131
+ def canvas_api_token
132
+ PandaPal::Platform.find_org_setting([
133
+ "canvas.api_token",
134
+ "canvas.api_key",
135
+ "canvas.token",
136
+ "canvas_api_token",
137
+ "canvas_token",
138
+ "api_token",
139
+ ], self)
140
+ end
141
+
142
+ def root_account_info
143
+ Rails.cache.fetch("panda_pal/org:#{name}/root_account_info", expires_in: 24.hours) do
144
+ response = bearcat_client.account("self")
145
+ response = bearcat_client.account(response[:root_account_id]) if response[:root_account_id].present?
146
+ response
147
+ end
148
+ end
149
+
150
+ if defined?(Bearcat)
151
+ def bearcat_client
152
+ return canvas_sync_client if defined?(canvas_sync_client)
153
+
154
+ Bearcat::Client.new(
155
+ prefix: canvas_url,
156
+ token: canvas_token,
157
+ master_rate_limit: (Rails.env.production? && !!defined?(Redis) && ENV['REDIS_URL'].present?),
158
+ )
159
+ end
160
+ end
161
+ end
162
+ end
163
+ end
@@ -1,187 +1,106 @@
1
- begin
2
- require 'bearcat'
3
- rescue LoadError
4
- end
5
1
 
6
2
  require 'httparty'
7
3
 
8
4
  module PandaPal
9
5
  class Platform
6
+ require_relative "platform/canvas"
7
+
8
+ def initialize(params)
9
+ @issuer = params[:iss]
10
+ end
11
+
10
12
  def public_jwks
11
13
  require "json/jwt"
12
14
 
13
- response = HTTParty.get(jwks_url)
14
- return nil unless response.success?
15
+ jwk_json = Rails.cache.fetch("panda_pal/jwks/#{jwks_url}")
16
+ jwk_json ||= begin
17
+ response = HTTParty.get(jwks_url)
18
+ response.success? ? response.body : nil
19
+ end
15
20
 
16
- JSON::JWK::Set.new(JSON.parse(response.body))
17
- rescue
18
- nil
19
- end
21
+ return nil unless jwk_json.present?
20
22
 
21
- protected
23
+ Rails.cache.write("panda_pal/jwks/#{jwks_url}", jwk_json, expires_in: 24.hours)
22
24
 
23
- def self.find_org_setting(paths, org = current_organization)
24
- paths.each do |p|
25
- p = p.split('.').map(&:to_sym)
26
- val = org.settings.dig(*p)
27
- return val if val.present?
28
- end
25
+ JSON::JWK::Set.new(JSON.parse(jwk_json))
26
+ rescue
29
27
  nil
30
28
  end
31
- end
32
-
33
- class Platform::Canvas < Platform
34
- attr_accessor :organization
35
29
 
36
- def initialize(org)
37
- @organization = org
30
+ def self.deserialize(params)
31
+ params[:platform_class].safe_constantize.new(params)
38
32
  end
39
33
 
40
- def host
41
- base_url
34
+ def serialize
35
+ {
36
+ platform_class: self.class.to_s,
37
+ iss: @issuer,
38
+ }
42
39
  end
43
40
 
44
- def jwks_url
45
- "#{base_url}/api/lti/security/jwks"
41
+ def self.resolve_platform(params)
42
+ resolved = resolve_raw_platform(params)
43
+ resolved.is_a?(Class) ? resolved.new({ iss: params[:iss] }) : resolved
46
44
  end
47
45
 
48
- def authentication_redirect_url
49
- "#{base_url}/api/lti/authorize_redirect"
46
+ def self.resolve_platform_class(params)
47
+ resolved = resolve_raw_platform(params)
48
+ resolved.is_a?(Class) ? resolved : resolved.class
50
49
  end
51
50
 
52
- def grant_url
53
- "#{base_url}/login/oauth2/token"
54
- end
51
+ def self.resolve_raw_platform(params)
52
+ platform = PandaPal.lti_options[:platform] || "canvas.instructure.com"
55
53
 
56
- def base_url; @organization.canvas_url; end # TODO Dissolve?
57
-
58
- module OrgExtension
59
- def install_lti(host: nil, context: :root_account, version: 'v1p1', exists: :error)
60
- raise "Automatically installing this LTI requires Bearcat." unless defined?(Bearcat)
61
-
62
- context = context.to_s
63
- context = 'account/self' if context == 'root_account'
64
- cid, ctype = context.split(/[\.\/]/).reverse
65
- ctype ||= 'account'
66
-
67
- existing_installs = bearcat_client.send(:"#{ctype}_external_tools", cid).all_pages_each.filter do |cet|
68
- cet[:consumer_key] == self.key
69
- end
70
-
71
- if existing_installs.present?
72
- case exists
73
- when :error
74
- raise "Tool with key #{self.key} already installed"
75
- when :duplicate
76
- when :replace
77
- existing_installs.each do |install|
78
- bearcat_client.send(:"delete_#{ctype}_external_tool", cid, install[:id])
79
- end
80
- else
81
- raise "exists: #{exists} is not supported"
82
- end
83
- end
84
-
85
- # TODO LTI 1.3 Support
86
-
87
- conf = {
88
- name: PandaPal.lti_options[:title],
89
- description: PandaPal.lti_options[:description],
90
- consumer_key: self.key,
91
- shared_secret: self.secret,
92
- privacy_level: "public",
93
- config_type: 'by_url',
94
- config_url: PandaPal::LaunchUrlHelpers.resolve_route(:v1p0_config_url, host: host),
95
- }
96
-
97
- bearcat_client.send(:"create_#{ctype}_external_tool", cid, conf)
98
- end
54
+ if platform.is_a? Symbol
55
+ platform_const = Object.send(platform, params)
56
+ return platform_const if platform_const
99
57
 
100
- def lti_api_configuration(host: nil)
101
- PandaPal::LaunchUrlHelpers.with_uri_host(host) do
102
- domain = PandaPal.lti_properties[:domain] || host.host
103
- launch_url = PandaPal.lti_options[:secure_launch_url] ||
104
- "#{domain}#{PandaPal.lti_options[:secure_launch_path]}" ||
105
- PandaPal.lti_options[:launch_url] ||
106
- "#{domain}#{PandaPal.lti_options[:launch_path]}" ||
107
- domain
108
-
109
- lti_json = {
110
- name: PandaPal.lti_options[:title],
111
- description: PandaPal.lti_options[:description],
112
- domain: host.host,
113
- url: launch_url,
114
- consumer_key: self.key,
115
- shared_secret: self.secret,
116
- privacy_level: "public",
117
-
118
- custom_fields: {},
119
-
120
- environments: PandaPal.lti_environments,
121
- }
122
-
123
- lti_json = lti_json.with_indifferent_access
124
- lti_json.merge!(PandaPal.lti_properties)
125
-
126
- (PandaPal.lti_options[:custom_fields] || []).each do |k, v|
127
- lti_json[:custom_fields][k] = v
128
- end
129
-
130
- PandaPal.lti_paths.each do |k, options|
131
- options = PandaPal::LaunchUrlHelpers.normalize_lti_launch_desc(options)
132
- options[:url] = PandaPal::LaunchUrlHelpers.absolute_launch_url(
133
- k.to_sym,
134
- host: host,
135
- launch_handler: :v1p0_launch_path,
136
- default_auto_launch: false
137
- ).to_s
138
- lti_json[k] = options
139
- end
140
-
141
- lti_json
142
- end
143
- end
58
+ elsif platform.is_a? Proc
59
+ platform_const = platform.call(params)
60
+ return platform_const if platform_const
144
61
 
145
- def canvas_url
146
- PandaPal::Platform.find_org_setting([
147
- "canvas.base_url",
148
- "canvas_url",
149
- "canvas_base_url",
150
- "canvas.url",
151
- "base_url",
152
- ], self) || (Rails.env.development? && 'http://localhost:3000') || 'https://canvas.instructure.com'
153
- end
62
+ else
63
+ return Platform::Canvas if platform == "canvas.instructure.com"
154
64
 
155
- def canvas_api_token
156
- PandaPal::Platform.find_org_setting([
157
- "canvas.api_token",
158
- "canvas.api_key",
159
- "canvas.token",
160
- "canvas_api_token",
161
- "canvas_token",
162
- "api_token",
163
- ], self)
65
+ platform_const = platform.safe_constantize
66
+ return platform_const if platform_const.is_a? Class
164
67
  end
165
68
 
166
- def root_account_info
167
- Rails.cache.fetch("panda_pal/org:#{name}/root_account_info", expires_in: 24.hours) do
168
- response = bearcat_client.account("self")
169
- response = bearcat_client.account(response[:root_account_id]) if response[:root_account_id].present?
170
- response
171
- end
172
- end
69
+ # TODO Default logic?
70
+ return Platform::Canvas if Platform::Canvas::KNOWN_ISSUERS.include?(params[:iss])
71
+
72
+ raise "Unknown platform '#{platform}'"
73
+ end
173
74
 
174
- if defined?(Bearcat)
175
- def bearcat_client
176
- return canvas_sync_client if defined?(canvas_sync_client)
75
+ def self.materialize_extension(org)
76
+ return nil unless defined?(self::OrgExtension)
77
+
78
+ ext_mod = self::OrgExtension
79
+ org = org.dup
80
+ org.extend(ext_mod)
81
+
82
+ org
83
+ end
84
+
85
+ protected
177
86
 
178
- Bearcat::Client.new(
179
- prefix: canvas_url,
180
- token: canvas_token,
181
- master_rate_limit: (Rails.env.production? && !!defined?(Redis) && ENV['REDIS_URL'].present?),
182
- )
183
- end
87
+ def self.find_org_setting(paths, org = current_organization)
88
+ paths.each do |p|
89
+ p = p.split('.').map(&:to_sym)
90
+ val = org.settings.dig(*p)
91
+ return val if val.present?
184
92
  end
93
+ nil
94
+ end
95
+ end
96
+
97
+ class Platform::Generic < Platform
98
+ attr_reader :jwks_url, :authentication_redirect_url, :grant_url
99
+
100
+ def init(base_url, jwks: "/api/lti/security/jwks", auth_redirect: "/api/lti/authorize_redirect", grant: "/login/oauth2/token")
101
+ @jwks_url = URI.join(base_url, jwks).to_s
102
+ @authentication_redirect_url = URI.join(base_url, auth_redirect).to_s
103
+ @grant_url = URI.join(base_url, grant).to_s
185
104
  end
186
105
  end
187
106
  end
@@ -1,13 +1,17 @@
1
1
  module PandaPal
2
2
  class Session < ActiveRecord::Base
3
- belongs_to :panda_pal_organization, class_name: 'PandaPal::Organization'
4
-
5
- validates :panda_pal_organization_id, presence: true
3
+ belongs_to :panda_pal_organization, class_name: 'PandaPal::Organization', optional: true
6
4
 
7
5
  after_initialize do
8
6
  self.session_key ||= SecureRandom.urlsafe_base64(60)
9
7
  end
10
8
 
9
+ def lti_platform
10
+ return nil unless data[:lti_platform].present?
11
+
12
+ @lti_platform ||= Platform.deserialize(data[:lti_platform])
13
+ end
14
+
11
15
  class DataSerializer
12
16
  def self.load(str)
13
17
  return {} unless str.present?
@@ -13,7 +13,7 @@ module PandaPal::Helpers
13
13
  end
14
14
 
15
15
  def current_lti_platform
16
- @current_lti_platform ||= current_organization.lti_platform
16
+ @current_lti_platform ||= current_session(create_missing: false)&.lti_platform
17
17
  end
18
18
 
19
19
  def lti_launch_params
@@ -55,6 +55,8 @@ module PandaPal::Helpers
55
55
  end
56
56
 
57
57
  def validate_v1p3_launch
58
+ require "json/jwt"
59
+
58
60
  decoded_jwt = JSON::JWT.decode(params.require(:id_token), :skip_verification)
59
61
  raise JSON::JWT::VerificationFailed, 'error decoding id_token' if decoded_jwt.blank?
60
62
 
@@ -70,14 +72,17 @@ module PandaPal::Helpers
70
72
 
71
73
  raise JSON::JWT::VerificationFailed, 'Unrecognized Organization' unless @organization.present?
72
74
 
75
+ params[:session_key] = params[:state]
76
+
73
77
  decoded_jwt.verify!(current_lti_platform.public_jwks)
74
78
 
75
- params[:session_key] = params[:state]
76
79
  raise JSON::JWT::VerificationFailed, 'State is invalid' unless current_session_data[:lti_oauth_nonce] == decoded_jwt['nonce']
77
80
 
78
81
  jwt_verifier = PandaPal::LtiJwtValidator.new(decoded_jwt, client_id)
79
82
  raise JSON::JWT::VerificationFailed, jwt_verifier.errors unless jwt_verifier.valid?
80
83
 
84
+ current_session.update(panda_pal_organization: @organization)
85
+
81
86
  @decoded_lti_jwt = decoded_jwt
82
87
  rescue JSON::JWT::VerificationFailed => e
83
88
  payload = Array(e.message)
@@ -124,7 +129,7 @@ module PandaPal::Helpers
124
129
 
125
130
  def find_or_create_session(key:)
126
131
  if key == :create
127
- PandaPal::Session.new(panda_pal_organization_id: current_organization.id)
132
+ PandaPal::Session.new(panda_pal_organization_id: current_organization&.id)
128
133
  else
129
134
  PandaPal::Session.find_by(session_key: key)
130
135
  end
@@ -29,9 +29,11 @@ module PandaPal
29
29
 
30
30
  # Allow stuff like rack-mini-profiler to work in development:
31
31
  # https://github.com/MiniProfiler/rack-mini-profiler/issues/327
32
- # DON'T ENABLE THIS FOR PRODUCTION!
33
32
  csp_entry(:script_src, "'unsafe-eval'")
34
33
 
34
+ # React Devtools
35
+ csp_entry(:script_src, "'unsafe-inline'")
36
+
35
37
  # Detect and permit Scout APM in Dev
36
38
  if MiscHelper.to_boolean(ENV['SCOUT_DEV_TRACE'])
37
39
  csp_entry(:default_src, 'https://scoutapm.com')
@@ -1,3 +1,3 @@
1
1
  module PandaPal
2
- VERSION = "5.6.7.beta1"
2
+ VERSION = "5.6.7.beta2"
3
3
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: panda_pal
3
3
  version: !ruby/object:Gem::Version
4
- version: 5.6.7.beta1
4
+ version: 5.6.7.beta2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Instructure ProServe
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2023-01-31 00:00:00.000000000 Z
11
+ date: 2023-02-01 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -315,6 +315,7 @@ files:
315
315
  - app/models/panda_pal/organization_concerns/settings_validation.rb
316
316
  - app/models/panda_pal/organization_concerns/task_scheduling.rb
317
317
  - app/models/panda_pal/platform.rb
318
+ - app/models/panda_pal/platform/canvas.rb
318
319
  - app/models/panda_pal/session.rb
319
320
  - app/views/layouts/panda_pal/application.html.erb
320
321
  - app/views/panda_pal/lti/launch.html.erb