panda_pal 4.1.0.beta2 → 5.0.0.beta.4
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +5 -5
- data/README.md +60 -71
- data/app/models/panda_pal/organization.rb +46 -17
- data/db/618eef7c0380ba654ad16f867a919e72.sqlite3 +0 -0
- data/db/9ff93d4f7e0e9dc80a43f68997caf4a1.sqlite3 +0 -0
- data/db/a3fda4044a7215bc2c9eb01a4b9e517a.sqlite3 +0 -0
- data/db/daa0e6378a5ec76fcce83b7070dad219.sqlite3 +0 -0
- data/lib/panda_pal/helpers/controller_helper.rb +38 -33
- data/lib/panda_pal/version.rb +1 -1
- data/panda_pal.gemspec +0 -3
- data/spec/dummy/config/application.rb +1 -7
- data/spec/dummy/config/environments/development.rb +14 -0
- data/spec/dummy/config/environments/production.rb +11 -0
- data/spec/dummy/config/initializers/assets.rb +11 -0
- data/spec/dummy/db/development.sqlite3 +0 -0
- data/spec/dummy/db/test.sqlite3 +0 -0
- data/spec/dummy/log/development.log +15058 -0
- data/spec/dummy/log/test.log +0 -0
- data/spec/models/panda_pal/organization_spec.rb +89 -0
- data/spec/spec_helper.rb +0 -8
- metadata +17 -39
- data/app/models/panda_pal/organization/settings_validation.rb +0 -115
- data/app/models/panda_pal/organization/task_scheduling.rb +0 -164
- data/app/views/panda_pal/lti/iframe_cookie_fix.html.erb +0 -12
- data/spec/models/panda_pal/organization/settings_validation_spec.rb +0 -175
- data/spec/models/panda_pal/organization/task_scheduling_spec.rb +0 -134
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
|
-
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 689e3885a1cc8e9ae58ed8f07e0089af4d2b82668f8a6cf9f41e053530762d5d
|
4
|
+
data.tar.gz: eb7fea8c5df5b973db4dc0be811f3e5961a0e9b3efd4a012ffde72bdb36f6888
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 8b994a6c7a7bf474d7e38bfa36efc00d4bfb57913e684f626bf2f457615dcf37346ffed346f0937ebdf50bed19ca2875080f1b69a794358a94f4e7fcc7e518f3
|
7
|
+
data.tar.gz: 399ea543b5c5765348e0ea9f11517d48b384de5939556c25e780a9d312950b5e1bdb58da931b070151c68bdb4a6e574e9844d3977a4594481346aa6c76beaba1
|
data/README.md
CHANGED
@@ -28,38 +28,6 @@ Use one of these 6 options in `PandaPal.lti_options` hash.
|
|
28
28
|
5. Leave this property off, and you will get the dynamic host with the root path ('http://appdomain.com/') by default.
|
29
29
|
6. If you really do not want this property use the option `launch_url: false` for it to be left off.
|
30
30
|
|
31
|
-
### Task Scheduling
|
32
|
-
`PandaPal` includes an integration with `sidekiq-scheduler`. You can define tasks on an Organization class Stub like so:
|
33
|
-
```ruby
|
34
|
-
# <your_app>/app/models/organization.rb
|
35
|
-
module PandaPal
|
36
|
-
class Organization
|
37
|
-
# Will invoke CanvasSyncStarterWorker.perform_async() according to the cron schedule
|
38
|
-
scheduled_task '0 15 05 * * *', :identifier, worker: CanvasSyncStarterWorker
|
39
|
-
|
40
|
-
# Will invoke the method 'organization_method' on the Organization
|
41
|
-
scheduled_task '0 15 05 * * *', :organization_method_and_identifier
|
42
|
-
|
43
|
-
# If you need to invoke the same method on multiple schedules
|
44
|
-
scheduled_task '0 15 05 * * *', :identifier, worker: :organization_method
|
45
|
-
|
46
|
-
# You can also use a block
|
47
|
-
scheduled_task '0 15 05 * * *', :identifier do
|
48
|
-
# Do Stuff
|
49
|
-
end
|
50
|
-
|
51
|
-
# You can use a Proc (called in the context of the Organization) to determine the schedule
|
52
|
-
scheduled_task -> { settings[:cron] }, :identifier
|
53
|
-
|
54
|
-
# You can specify a timezone. If a TZ is not coded and settings[:timezone] is present, it will be appended automatically
|
55
|
-
scheduled_task '0 15 05 * * * America/Denver', :identifier, worker: :organization_method
|
56
|
-
|
57
|
-
# Setting settings[:task_schedules][:identifier] will override the code cron schedule. Setting it to false will disable the Task
|
58
|
-
# :identifer values _must_ be unique, but can be nil, in which case they will be determined by where (lineno etc) scheduled_task is called
|
59
|
-
end
|
60
|
-
end
|
61
|
-
```
|
62
|
-
|
63
31
|
# Organization Attributes
|
64
32
|
id: Primary Key
|
65
33
|
name: Name of the organization. Used to on requests to select the tenant
|
@@ -258,43 +226,9 @@ In your panda_pal initializer (i.e. config/initializers/panda_pal.rb or config/i
|
|
258
226
|
You can specify options that can include a structure for your settings. If specified, PandaPal will
|
259
227
|
enforce this structure on any new / updated organizations.
|
260
228
|
|
261
|
-
```ruby
|
262
|
-
PandaPal.lti_options = {
|
263
|
-
title: 'LBS Gradebook',
|
264
|
-
settings_structure: {
|
265
|
-
allow_additional: true, # Allow additional properties that aren't included in the :properties Hash.
|
266
|
-
allow_additional: { type: 'String' }, # You can also set :allow_additional to a settings specification that will be used to validate each additional setting
|
267
|
-
validate: ->(value, spec, **kwargs) {
|
268
|
-
# kwargs currently includes:
|
269
|
-
# :errors => [An array to push errors to]
|
270
|
-
# :path => [An array representation of the current path in the settings object]
|
271
|
-
|
272
|
-
# To add errors, you may:
|
273
|
-
# Push strings to the kwargs[:errors] Array:
|
274
|
-
kwargs[:errors] << "Your error message at <path>" unless value < 10
|
275
|
-
# Or return a string or string array:
|
276
|
-
value.valid? ? nil : "Your error message at <path>" # <path> will be replaced with the actual path that the error occurred at
|
277
|
-
},
|
278
|
-
properties: {
|
279
|
-
canvas_api_token: { type: 'String', required: true, },
|
280
|
-
catalog: { # :validate, :allow_additional, :properties keys are all supported at this level as well
|
281
|
-
type: 'Hash',
|
282
|
-
required: false,
|
283
|
-
validate: -> (*args) {},
|
284
|
-
allow_additional: false,
|
285
|
-
properties: {
|
286
|
-
|
287
|
-
},
|
288
|
-
}
|
289
|
-
}
|
290
|
-
},
|
291
|
-
}
|
292
|
-
```
|
293
|
-
|
294
|
-
#### Legacy Settings Structure:
|
295
229
|
Here is an example options specification:
|
296
230
|
|
297
|
-
```
|
231
|
+
```
|
298
232
|
PandaPal.lti_options = {
|
299
233
|
title: 'LBS Gradebook',
|
300
234
|
settings_structure: YAML.load("
|
@@ -347,7 +281,62 @@ Safari is weird, and you'll potentially run into issues getting `POST` requests
|
|
347
281
|
|
348
282
|
This will allow `PandaPal` to apply an iframe cookie fix that will allow CSRF validation to work.
|
349
283
|
|
350
|
-
|
351
|
-
|
352
|
-
|
353
|
-
|
284
|
+
|
285
|
+
### PandaPal 5
|
286
|
+
|
287
|
+
It has been a constant struggle to force safari to store and allow
|
288
|
+
access to a rails session while the application is embedded in Canvas.
|
289
|
+
|
290
|
+
As of PandaPal 5, a persistent session is no longer required by panda_pal.
|
291
|
+
|
292
|
+
This means that safari will likely refuse to send info about your rails session
|
293
|
+
back to the LTI, and the application will start up a new session each time the
|
294
|
+
browser navigates. This likely means a new session each time the LTI launches.
|
295
|
+
|
296
|
+
You will want to watch out for a few scenarios:
|
297
|
+
|
298
|
+
1) Make sure you are using "redirect_with_session_to" if you need to redirect
|
299
|
+
and have your PandaPal session_key persisted server side.
|
300
|
+
2) Use the "Authorization" header with "token={session_key}" to send your
|
301
|
+
PandaPal session info into api calls.
|
302
|
+
3) If you use link_to and navigate in your LTI (apps that are not single page)
|
303
|
+
make sure you include an encrypted_session_key parameter in your links.
|
304
|
+
|
305
|
+
# Upgrading from PandaPal 4 to 5:
|
306
|
+
|
307
|
+
If your tool is setup according to a pretty standard pattern (see pace_plans,
|
308
|
+
canvas_group_enrollment, etc), you shouldn't have to do anything to upgrade.
|
309
|
+
|
310
|
+
You will want to make sure that IF your launch controller is redirecting, it is
|
311
|
+
using "redirect_with_session_to".
|
312
|
+
|
313
|
+
Here is an example launch / account controller setup, assuming an account launch.
|
314
|
+
|
315
|
+
```
|
316
|
+
class LaunchController < ApplicationController
|
317
|
+
# We don't verify CSRF on launch because the LTI launch is done via a POST
|
318
|
+
# request, and Canvas wouldn't know anything about the CSRF
|
319
|
+
skip_before_action :verify_authenticity_token
|
320
|
+
skip_before_action :forbid_access_if_lacking_session # We don't have a session yet
|
321
|
+
around_action :switch_tenant
|
322
|
+
before_action :validate_launch!
|
323
|
+
before_action :handle_launch
|
324
|
+
|
325
|
+
def handle_launch
|
326
|
+
current_session_data[:canvas_user_id] = params[:custom_canvas_user_id]
|
327
|
+
current_session_data[:canvas_course_id] = params[:custom_canvas_course_id]
|
328
|
+
end
|
329
|
+
|
330
|
+
def account
|
331
|
+
redirect_with_session_to :accounts_url
|
332
|
+
end
|
333
|
+
end
|
334
|
+
|
335
|
+
class AccountController < ApplicationController
|
336
|
+
prepend_before_action :forbid_access_if_lacking_session
|
337
|
+
|
338
|
+
def index
|
339
|
+
end
|
340
|
+
end
|
341
|
+
```
|
342
|
+
|
@@ -1,23 +1,15 @@
|
|
1
|
-
Dir[File.dirname(__FILE__) + "/organization/*.rb"].each { |file| require file }
|
2
|
-
|
3
1
|
module PandaPal
|
4
|
-
module OrganizationConcerns; end
|
5
2
|
|
6
3
|
class Organization < ActiveRecord::Base
|
7
|
-
include OrganizationConcerns::SettingsValidation
|
8
|
-
include OrganizationConcerns::TaskScheduling if defined?(Sidekiq.schedule)
|
9
|
-
|
10
4
|
attribute :settings
|
11
|
-
serialize :settings, Hash
|
12
5
|
attr_encrypted :settings, marshal: true, key: :encryption_key
|
13
6
|
before_save {|a| a.settings = a.settings} # this is a hacky work-around to a bug where attr_encrypted is not saving settings in place
|
14
|
-
|
15
7
|
validates :key, uniqueness: { case_sensitive: false }, presence: true
|
16
8
|
validates :secret, presence: true
|
17
9
|
validates :name, uniqueness: { case_sensitive: false }, presence: true, format: { with: /\A[a-z0-9_]+\z/i }
|
18
10
|
validates :canvas_account_id, presence: true
|
19
11
|
validates :salesforce_id, presence: true, uniqueness: true
|
20
|
-
|
12
|
+
validate :validate_settings
|
21
13
|
after_create :create_schema
|
22
14
|
after_commit :destroy_schema, on: :destroy
|
23
15
|
|
@@ -25,6 +17,8 @@ module PandaPal
|
|
25
17
|
errors.add(:name, 'should not be changed after creation') if name_changed?
|
26
18
|
end
|
27
19
|
|
20
|
+
serialize :settings, Hash
|
21
|
+
|
28
22
|
def encryption_key
|
29
23
|
# production environment might not have loaded secret_key_base yet.
|
30
24
|
# In that case, just read it from env.
|
@@ -35,14 +29,6 @@ module PandaPal
|
|
35
29
|
end
|
36
30
|
end
|
37
31
|
|
38
|
-
def switch_tenant(&block)
|
39
|
-
if block_given?
|
40
|
-
Apartment::Tenant.switch(name, &block)
|
41
|
-
else
|
42
|
-
Apartment::Tenant.switch!(name)
|
43
|
-
end
|
44
|
-
end
|
45
|
-
|
46
32
|
private
|
47
33
|
|
48
34
|
def create_schema
|
@@ -52,5 +38,48 @@ module PandaPal
|
|
52
38
|
def destroy_schema
|
53
39
|
Apartment::Tenant.drop name
|
54
40
|
end
|
41
|
+
|
42
|
+
def validate_settings
|
43
|
+
record = self
|
44
|
+
if PandaPal.lti_options && PandaPal.lti_options[:settings_structure]
|
45
|
+
validate_level(record, PandaPal.lti_options[:settings_structure], record.settings, [])
|
46
|
+
end
|
47
|
+
end
|
48
|
+
def validate_level(record, expectation, reality, previous_keys)
|
49
|
+
# Verify that the data elements at this level conform to requirements.
|
50
|
+
if expectation
|
51
|
+
expectation.each do |key, value|
|
52
|
+
is_required = expectation[key].try(:delete, :is_required)
|
53
|
+
data_type = expectation[key].try(:delete, :data_type)
|
54
|
+
value = reality.is_a?(Hash) ? reality.dig(key) : reality
|
55
|
+
|
56
|
+
if is_required && !value
|
57
|
+
record.errors[:settings] << "PandaPal::Organization.settings requires key [:#{previous_keys.push(key).join("][:")}]. It was not found."
|
58
|
+
end
|
59
|
+
if data_type && value
|
60
|
+
if value.class.to_s != data_type
|
61
|
+
record.errors[:settings] << "PandaPal::Organization.settings expected key [:#{previous_keys.push(key).join("][:")}] to be #{data_type} but it was instead #{value.class}."
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
66
|
+
# Verify that anything that is in the real settings has an expectation.
|
67
|
+
if reality
|
68
|
+
if reality.is_a? Hash
|
69
|
+
reality.each do |key, value|
|
70
|
+
was_expected = expectation.has_key?(key)
|
71
|
+
if !was_expected
|
72
|
+
record.errors[:settings] << "PandaPal::Organization.settings had unexpected key: #{key}. If settings have expanded please update your lti_options accordingly."
|
73
|
+
end
|
74
|
+
end
|
75
|
+
end
|
76
|
+
end
|
77
|
+
# Recursively check out any children settings as well.
|
78
|
+
if expectation
|
79
|
+
expectation.each do |key, value|
|
80
|
+
validate_level(record, expectation[key], (reality.is_a?(Hash) ? reality.dig(key) : nil), previous_keys.deep_dup.push(key))
|
81
|
+
end
|
82
|
+
end
|
83
|
+
end
|
55
84
|
end
|
56
85
|
end
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
@@ -42,17 +42,6 @@ module PandaPal::Helpers::ControllerHelper
|
|
42
42
|
render plain: 'Invalid Credentials, please contact your Administrator.', :status => :unauthorized unless authorized
|
43
43
|
return authorized
|
44
44
|
end
|
45
|
-
if cookies_need_iframe_fix?
|
46
|
-
fix_iframe_cookies
|
47
|
-
return false
|
48
|
-
end
|
49
|
-
# For safari we may have been launched temporarily full-screen by canvas. This allows us to set the session cookie.
|
50
|
-
# In this case, we should make sure the session cookie is fixed and redirect back to canvas to properly launch the embedded LTI.
|
51
|
-
if params[:platform_redirect_url]
|
52
|
-
session[:safari_cookie_fixed] = true
|
53
|
-
redirect_to params[:platform_redirect_url]
|
54
|
-
return false
|
55
|
-
end
|
56
45
|
return authorized
|
57
46
|
end
|
58
47
|
|
@@ -65,29 +54,8 @@ module PandaPal::Helpers::ControllerHelper
|
|
65
54
|
end
|
66
55
|
end
|
67
56
|
|
68
|
-
# Browsers that prevent 3rd party cookies by default (Safari and IE) run into problems
|
69
|
-
# with CSRF handling because the Rails session cookie isn't set. To fix this, we
|
70
|
-
# redirect the current page to the LTI using JavaScript, which will set the cookie,
|
71
|
-
# and then immediately redirect back to Canvas.
|
72
|
-
def fix_iframe_cookies
|
73
|
-
if params[:safari_cookie_fix].present?
|
74
|
-
session[:safari_cookie_fixed] = true
|
75
|
-
redirect_to params[:return_to]
|
76
|
-
else
|
77
|
-
render 'panda_pal/lti/iframe_cookie_fix', layout: false
|
78
|
-
end
|
79
|
-
end
|
80
|
-
|
81
|
-
def cookies_need_iframe_fix?
|
82
|
-
browser.safari? && !request.referrer&.include?('sessionless_launch') && !session[:safari_cookie_fixed] && !params[:platform_redirect_url]
|
83
|
-
end
|
84
|
-
|
85
57
|
def forbid_access_if_lacking_session
|
86
|
-
|
87
|
-
fix_iframe_cookies
|
88
|
-
else
|
89
|
-
render plain: 'You should do an LTI Tool Launch.', status: :unauthorized unless valid_session?
|
90
|
-
end
|
58
|
+
render plain: 'You should do an LTI Tool Launch.', status: :unauthorized unless valid_session?
|
91
59
|
safari_override
|
92
60
|
end
|
93
61
|
|
@@ -114,6 +82,10 @@ module PandaPal::Helpers::ControllerHelper
|
|
114
82
|
end
|
115
83
|
|
116
84
|
def session_key
|
85
|
+
if params[:encrypted_session_key]
|
86
|
+
crypt = ActiveSupport::MessageEncryptor.new(Rails.application.secret_key_base[0..31])
|
87
|
+
return crypt.decrypt_and_verify(params[:encrypted_session_key])
|
88
|
+
end
|
117
89
|
params[:session_key] || session_key_header || flash[:session_key] || session[:session_key]
|
118
90
|
end
|
119
91
|
|
@@ -122,4 +94,37 @@ module PandaPal::Helpers::ControllerHelper
|
|
122
94
|
match[1]
|
123
95
|
end
|
124
96
|
end
|
97
|
+
|
98
|
+
# Redirect with the session key intact. In production,
|
99
|
+
# handle this by encrypting the session key. That way if the
|
100
|
+
# url is logged anywhere, it will all be encrypted data. In dev,
|
101
|
+
# just put it in the URL. Putting it in the URL
|
102
|
+
# is insecure, but is fine in development.
|
103
|
+
# Keeping it in the URL in development means that it plays
|
104
|
+
# nicely with webpack-dev-server live reloading (otherwise
|
105
|
+
# you get an access error everytime it tries to live reload).
|
106
|
+
|
107
|
+
def redirect_with_session_to(location, params = {})
|
108
|
+
if Rails.env.development?
|
109
|
+
redirect_development_mode(location, params)
|
110
|
+
else
|
111
|
+
redirect_production_mode(location, params)
|
112
|
+
end
|
113
|
+
end
|
114
|
+
|
115
|
+
def redirect_development_mode(location, params)
|
116
|
+
redirect_to send(location, {
|
117
|
+
session_key: current_session.session_key,
|
118
|
+
organization_id: current_organization.id
|
119
|
+
}.merge(params))
|
120
|
+
end
|
121
|
+
|
122
|
+
def redirect_production_mode(location, params)
|
123
|
+
crypt = ActiveSupport::MessageEncryptor.new(Rails.application.secret_key_base[0..31])
|
124
|
+
encrypted_data = crypt.encrypt_and_sign(current_session.session_key)
|
125
|
+
redirect_to send(location, {
|
126
|
+
encrypted_session_key: encrypted_data,
|
127
|
+
organization_id: current_organization.id
|
128
|
+
}.merge(params))
|
129
|
+
end
|
125
130
|
end
|
data/lib/panda_pal/version.rb
CHANGED
data/panda_pal.gemspec
CHANGED
@@ -22,9 +22,6 @@ Gem::Specification.new do |s|
|
|
22
22
|
s.add_dependency 'browser', '2.5.0'
|
23
23
|
s.add_dependency 'attr_encrypted', '~> 3.0.0'
|
24
24
|
s.add_dependency 'secure_headers', '~> 6.1.2'
|
25
|
-
|
26
|
-
s.add_development_dependency 'sidekiq'
|
27
|
-
s.add_development_dependency 'sidekiq-scheduler'
|
28
25
|
s.add_development_dependency 'rspec-rails'
|
29
26
|
s.add_development_dependency 'factory_girl_rails'
|
30
27
|
end
|
@@ -1,12 +1,6 @@
|
|
1
1
|
require File.expand_path('../boot', __FILE__)
|
2
2
|
|
3
|
-
require
|
4
|
-
require "active_job/railtie"
|
5
|
-
require "active_record/railtie"
|
6
|
-
# require "active_storage/engine"
|
7
|
-
require "action_controller/railtie"
|
8
|
-
require "action_mailer/railtie"
|
9
|
-
require "action_view/railtie"
|
3
|
+
require 'rails/all'
|
10
4
|
|
11
5
|
Bundler.require(*Rails.groups)
|
12
6
|
require "panda_pal"
|
@@ -22,6 +22,20 @@ Rails.application.configure do
|
|
22
22
|
# Raise an error on page load if there are pending migrations.
|
23
23
|
config.active_record.migration_error = :page_load
|
24
24
|
|
25
|
+
# Debug mode disables concatenation and preprocessing of assets.
|
26
|
+
# This option may cause significant delays in view rendering with a large
|
27
|
+
# number of complex assets.
|
28
|
+
config.assets.debug = true
|
29
|
+
|
30
|
+
# Asset digests allow you to set far-future HTTP expiration dates on all assets,
|
31
|
+
# yet still be able to expire them through the digest params.
|
32
|
+
config.assets.digest = true
|
33
|
+
|
34
|
+
# Adds additional error checking when serving assets at runtime.
|
35
|
+
# Checks for improperly declared sprockets dependencies.
|
36
|
+
# Raises helpful error messages.
|
37
|
+
config.assets.raise_runtime_errors = true
|
38
|
+
|
25
39
|
# Raises error for missing translations
|
26
40
|
# config.action_view.raise_on_missing_translations = true
|
27
41
|
end
|
@@ -24,6 +24,17 @@ Rails.application.configure do
|
|
24
24
|
# Apache or NGINX already handles this.
|
25
25
|
config.serve_static_files = ENV['RAILS_SERVE_STATIC_FILES'].present?
|
26
26
|
|
27
|
+
# Compress JavaScripts and CSS.
|
28
|
+
config.assets.js_compressor = :uglifier
|
29
|
+
# config.assets.css_compressor = :sass
|
30
|
+
|
31
|
+
# Do not fallback to assets pipeline if a precompiled asset is missed.
|
32
|
+
config.assets.compile = false
|
33
|
+
|
34
|
+
# Asset digests allow you to set far-future HTTP expiration dates on all assets,
|
35
|
+
# yet still be able to expire them through the digest params.
|
36
|
+
config.assets.digest = true
|
37
|
+
|
27
38
|
# `config.assets.precompile` and `config.assets.version` have moved to config/initializers/assets.rb
|
28
39
|
|
29
40
|
# Specifies the header that your server uses for sending files.
|
@@ -0,0 +1,11 @@
|
|
1
|
+
# Be sure to restart your server when you modify this file.
|
2
|
+
|
3
|
+
# Version of your assets, change this if you want to expire all your assets.
|
4
|
+
Rails.application.config.assets.version = '1.0'
|
5
|
+
|
6
|
+
# Add additional assets to the asset load path
|
7
|
+
# Rails.application.config.assets.paths << Emoji.images_path
|
8
|
+
|
9
|
+
# Precompile additional assets.
|
10
|
+
# application.js, application.css, and all non-JS/CSS in app/assets folder are already added.
|
11
|
+
# Rails.application.config.assets.precompile += %w( search.js )
|