browse-everything 0.15.1 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +5 -5
- data/.rubocop.yml +61 -9
- data/.rubocop_todo.yml +2 -15
- data/.travis.yml +19 -19
- data/CONTRIBUTING.md +6 -6
- data/Gemfile +12 -8
- data/README.md +30 -0
- data/Rakefile +2 -1
- data/app/assets/javascripts/browse_everything/behavior.js.coffee +5 -0
- data/app/controllers/browse_everything_controller.rb +75 -23
- data/app/helpers/browse_everything_helper.rb +2 -8
- data/app/helpers/font_awesome_version_helper.rb +9 -8
- data/app/services/browse_everything_session.rb +10 -0
- data/app/services/browse_everything_session/provider_session.rb +42 -0
- data/app/services/browser_factory.rb +25 -0
- data/app/views/browse_everything/_files.html.erb +56 -6
- data/browse-everything.gemspec +29 -25
- data/config/routes.rb +7 -2
- data/lib/browse-everything.rb +2 -0
- data/lib/browse_everything.rb +45 -12
- data/lib/browse_everything/auth/google/credentials.rb +28 -0
- data/lib/browse_everything/auth/google/request_parameters.rb +61 -0
- data/lib/browse_everything/browser.rb +11 -4
- data/lib/browse_everything/driver/authentication_factory.rb +22 -0
- data/lib/browse_everything/driver/base.rb +72 -19
- data/lib/browse_everything/driver/box.rb +46 -17
- data/lib/browse_everything/driver/dropbox.rb +36 -10
- data/lib/browse_everything/driver/file_system.rb +14 -26
- data/lib/browse_everything/driver/google_drive.rb +187 -54
- data/lib/browse_everything/driver/s3.rb +81 -75
- data/lib/browse_everything/engine.rb +3 -2
- data/lib/browse_everything/file_entry.rb +3 -1
- data/lib/browse_everything/retriever.rb +103 -31
- data/lib/browse_everything/version.rb +3 -1
- data/lib/generators/browse_everything/assets_generator.rb +3 -2
- data/lib/generators/browse_everything/config_generator.rb +11 -9
- data/lib/generators/browse_everything/install_generator.rb +3 -2
- data/lib/generators/browse_everything/templates/browse_everything_providers.yml.example +12 -11
- data/spec/controllers/browse_everything_controller_spec.rb +80 -0
- data/spec/features/select_files_spec.rb +13 -13
- data/spec/features/test_compiling_stylesheets_spec.rb +2 -0
- data/spec/fixtures/vcr_cassettes/google_drive.yml +331 -0
- data/spec/fixtures/vcr_cassettes/retriever.yml +93 -0
- data/spec/helper/browse_everything_controller_helper_spec.rb +21 -7
- data/spec/javascripts/jasmine_spec.rb +2 -0
- data/spec/javascripts/support/jasmine_helper.rb +1 -0
- data/spec/lib/browse_everything/auth/google/credentials_spec.rb +41 -0
- data/spec/{unit → lib/browse_everything}/browse_everything_helper_spec.rb +2 -0
- data/spec/lib/browse_everything/browser_spec.rb +109 -0
- data/spec/{unit → lib/browse_everything/driver}/base_spec.rb +5 -4
- data/spec/{unit → lib/browse_everything/driver}/box_spec.rb +20 -5
- data/spec/{unit → lib/browse_everything/driver}/dropbox_spec.rb +15 -18
- data/spec/{unit → lib/browse_everything/driver}/file_system_spec.rb +32 -26
- data/spec/lib/browse_everything/driver/google_drive_spec.rb +171 -0
- data/spec/{unit → lib/browse_everything/driver}/s3_spec.rb +38 -21
- data/spec/lib/browse_everything/driver_spec.rb +38 -0
- data/spec/{unit → lib/browse_everything}/file_entry_spec.rb +4 -1
- data/spec/lib/browse_everything/retriever_spec.rb +200 -0
- data/spec/lib/browse_everything_spec.rb +67 -0
- data/spec/services/browse_everything_session/provider_session_spec.rb +50 -0
- data/spec/services/browser_factory_spec.rb +40 -0
- data/spec/spec_helper.rb +39 -18
- data/spec/support/app/controllers/file_handler_controller.rb +4 -4
- data/spec/support/app/views/file_handler/main.html.erb +1 -1
- data/spec/support/capybara.rb +17 -0
- data/spec/support/rake.rb +3 -1
- data/spec/support/wait_for_ajax.rb +14 -0
- data/spec/test_app_templates/Gemfile.extra +1 -0
- data/spec/test_app_templates/lib/generators/test_app_generator.rb +10 -4
- data/spec/views/browse_everything/{_file.html.erb_spec.rb → _files.html.erb_spec.rb} +24 -18
- data/tasks/ci.rake +2 -0
- metadata +159 -107
- data/app/views/browse_everything/_file.html.erb +0 -52
- data/app/views/browse_everything/resolve.html.erb +0 -1
- data/spec/unit/browser_spec.rb +0 -76
- data/spec/unit/retriever_spec.rb +0 -109
@@ -1,8 +1,9 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
module BrowseEverything
|
2
4
|
class Engine < ::Rails::Engine
|
3
5
|
config.assets.paths << config.root.join('vendor', 'assets', 'javascripts')
|
4
6
|
config.assets.paths << config.root.join('vendor', 'assets', 'stylesheets')
|
5
|
-
config.assets.precompile += %w
|
6
|
-
config.assets.precompile += %w(browse_everything.css)
|
7
|
+
config.assets.precompile += %w[browse_everything.js browse_everything.css]
|
7
8
|
end
|
8
9
|
end
|
@@ -1,3 +1,5 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
module BrowseEverything
|
2
4
|
class FileEntry
|
3
5
|
attr_reader :id, :location, :name, :size, :mtime, :type
|
@@ -13,7 +15,7 @@ module BrowseEverything
|
|
13
15
|
end
|
14
16
|
|
15
17
|
def relative_parent_path?
|
16
|
-
name
|
18
|
+
/^\.\.?$/.match(name) ? true : false
|
17
19
|
end
|
18
20
|
|
19
21
|
def container?
|
@@ -1,15 +1,49 @@
|
|
1
|
-
|
2
|
-
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
3
|
require 'addressable'
|
4
|
+
require 'tempfile'
|
5
|
+
require 'typhoeus'
|
4
6
|
|
5
7
|
module BrowseEverything
|
8
|
+
# Class for raising errors when a download is invalid
|
9
|
+
class DownloadError < StandardError
|
10
|
+
attr_reader :response
|
11
|
+
|
12
|
+
# Constructor
|
13
|
+
# @param msg [String]
|
14
|
+
# @param response [Typhoeus::Response] response from the server
|
15
|
+
def initialize(msg, response)
|
16
|
+
@response = response
|
17
|
+
super(msg)
|
18
|
+
end
|
19
|
+
|
20
|
+
# Generate the message for the exception
|
21
|
+
# @return [String]
|
22
|
+
def message
|
23
|
+
"#{super}: #{response.body}"
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
# Class for retrieving a file or resource from a storage provider
|
6
28
|
class Retriever
|
29
|
+
CHUNK_SIZE = 16384
|
30
|
+
|
7
31
|
attr_accessor :chunk_size
|
8
32
|
|
33
|
+
class << self
|
34
|
+
def can_retrieve?(uri)
|
35
|
+
Typhoeus.get(uri, headers: { Range: 'bytes=0-0' }).success?
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
# Constructor
|
9
40
|
def initialize
|
10
|
-
@chunk_size =
|
41
|
+
@chunk_size = CHUNK_SIZE
|
11
42
|
end
|
12
43
|
|
44
|
+
# Download a file or resource
|
45
|
+
# @param options [Hash]
|
46
|
+
# @param target [String, nil] system path to the downloaded file (defaults to a temporary file)
|
13
47
|
def download(spec, target = nil)
|
14
48
|
if target.nil?
|
15
49
|
ext = File.extname(spec['file_name'])
|
@@ -26,65 +60,103 @@ module BrowseEverything
|
|
26
60
|
target
|
27
61
|
end
|
28
62
|
|
29
|
-
|
30
|
-
|
31
|
-
|
63
|
+
# Retrieve the resource from the storage service
|
64
|
+
# @param options [Hash]
|
65
|
+
def retrieve(options, &block)
|
66
|
+
expiry_time_value = options.fetch('expires', nil)
|
67
|
+
if expiry_time_value
|
68
|
+
expiry_time = Time.parse(expiry_time_value)
|
69
|
+
raise ArgumentError, "Download expired at #{expiry_time}" if expiry_time < Time.now
|
32
70
|
end
|
33
71
|
|
34
|
-
|
72
|
+
download_options = extract_download_options(options)
|
73
|
+
url = download_options.fetch(:url)
|
35
74
|
|
36
|
-
case
|
75
|
+
case url.scheme
|
37
76
|
when 'file'
|
38
|
-
retrieve_file(
|
77
|
+
retrieve_file(download_options, &block)
|
39
78
|
when /https?/
|
40
|
-
retrieve_http(
|
79
|
+
retrieve_http(download_options, &block)
|
41
80
|
else
|
42
|
-
raise URI::BadURIError, "Unknown URI scheme: #{
|
81
|
+
raise URI::BadURIError, "Unknown URI scheme: #{url.scheme}"
|
43
82
|
end
|
44
83
|
end
|
45
84
|
|
46
85
|
private
|
47
86
|
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
87
|
+
# Extract and parse options used to download a file or resource from an HTTP API
|
88
|
+
# @param options [Hash]
|
89
|
+
# @return [Hash]
|
90
|
+
def extract_download_options(options)
|
91
|
+
url = options.fetch('url')
|
92
|
+
|
93
|
+
# This avoids the potential for a KeyError
|
94
|
+
headers = options.fetch('auth_header', {}) || {}
|
95
|
+
headers.each_pair { |k, v| headers[k] = v.tr('+', ' ') }
|
96
|
+
|
97
|
+
file_size_value = options.fetch('file_size', 0)
|
98
|
+
file_size = file_size_value.to_i
|
99
|
+
|
100
|
+
output = {
|
101
|
+
url: ::Addressable::URI.parse(url),
|
102
|
+
headers: headers,
|
103
|
+
file_size: file_size
|
53
104
|
}
|
54
105
|
|
55
|
-
|
56
|
-
|
57
|
-
result
|
106
|
+
output[:file_size] = get_file_size(output) if output[:file_size] < 1
|
107
|
+
output
|
58
108
|
end
|
59
109
|
|
60
|
-
|
110
|
+
# Retrieve the file from the file system
|
111
|
+
# @param options [Hash]
|
112
|
+
def retrieve_file(options)
|
113
|
+
file_uri = options.fetch(:url)
|
114
|
+
file_size = options.fetch(:file_size)
|
115
|
+
|
61
116
|
retrieved = 0
|
62
|
-
File.open(
|
117
|
+
File.open(file_uri.path, 'rb') do |f|
|
63
118
|
until f.eof?
|
64
119
|
chunk = f.read(chunk_size)
|
65
120
|
retrieved += chunk.length
|
66
|
-
yield(chunk, retrieved,
|
121
|
+
yield(chunk, retrieved, file_size)
|
67
122
|
end
|
68
123
|
end
|
69
124
|
end
|
70
125
|
|
71
|
-
|
126
|
+
# Retrieve a resource over the HTTP
|
127
|
+
# @param options [Hash]
|
128
|
+
def retrieve_http(options)
|
129
|
+
file_size = options.fetch(:file_size)
|
130
|
+
headers = options.fetch(:headers)
|
131
|
+
url = options.fetch(:url)
|
72
132
|
retrieved = 0
|
73
|
-
stream_body = parsed_spec[:file_size] > 500.megabytes
|
74
133
|
|
75
|
-
|
76
|
-
|
77
|
-
|
134
|
+
request = Typhoeus::Request.new(url.to_s)
|
135
|
+
request.on_headers do |response|
|
136
|
+
raise DownloadError.new("#{self.class}: Failed to download #{url}", response) unless response.code == 200
|
78
137
|
end
|
138
|
+
request.on_body do |chunk|
|
139
|
+
retrieved += chunk.bytesize
|
140
|
+
yield(chunk, retrieved, file_size)
|
141
|
+
end
|
142
|
+
request.run
|
79
143
|
end
|
80
144
|
|
81
|
-
|
82
|
-
|
145
|
+
# Retrieve the file size
|
146
|
+
# @param options [Hash]
|
147
|
+
# @return [Integer] the size of the requested file
|
148
|
+
def get_file_size(options)
|
149
|
+
url = options.fetch(:url)
|
150
|
+
headers = options.fetch(:headers)
|
151
|
+
file_size = options.fetch(:file_size)
|
152
|
+
|
153
|
+
case url.scheme
|
83
154
|
when 'file'
|
84
155
|
File.size(url.path)
|
85
156
|
when /https?/
|
86
|
-
response =
|
87
|
-
|
157
|
+
response = Typhoeus.head(url.to_s, headers: headers)
|
158
|
+
length_value = response.headers['Content-Length'] || file_size
|
159
|
+
length_value.to_i
|
88
160
|
else
|
89
161
|
raise URI::BadURIError, "Unknown URI scheme: #{url.scheme}"
|
90
162
|
end
|
@@ -1,10 +1,11 @@
|
|
1
|
-
#
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
2
3
|
require 'rails/generators'
|
3
4
|
|
4
5
|
class BrowseEverything::AssetsGenerator < Rails::Generators::Base
|
5
6
|
desc 'This generator installs the browse_everything CSS assets into your application'
|
6
7
|
|
7
|
-
source_root File.expand_path('
|
8
|
+
source_root File.expand_path('templates', __dir__)
|
8
9
|
|
9
10
|
def inject_css
|
10
11
|
copy_file 'browse_everything.scss', 'app/assets/stylesheets/browse_everything.scss'
|
@@ -1,13 +1,14 @@
|
|
1
|
-
#
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
2
3
|
require 'rails/generators'
|
3
4
|
|
4
5
|
class BrowseEverything::ConfigGenerator < Rails::Generators::Base
|
5
|
-
desc <<-
|
6
|
-
This generator makes the following changes to your application:
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
source_root File.expand_path('
|
6
|
+
desc <<-DESC
|
7
|
+
This generator makes the following changes to your application:
|
8
|
+
1. Creates config/browse_everything_providers.yml with a placeholder value
|
9
|
+
2. Modifies your app's routes.rb to mount BrowseEverything at /browse
|
10
|
+
DESC
|
11
|
+
source_root File.expand_path('templates', __dir__)
|
11
12
|
|
12
13
|
def inject_routes
|
13
14
|
insert_into_file 'config/routes.rb', after: '.draw do' do
|
@@ -17,12 +18,13 @@ This generator makes the following changes to your application:
|
|
17
18
|
end
|
18
19
|
|
19
20
|
def copy_example_config
|
20
|
-
|
21
|
+
FileUtils.rm 'config/browse_everything_providers.yml', force: true if File.exists? 'config/browse_everything_providers.yml'
|
22
|
+
copy_file 'browse_everything_providers.yml.example', 'config/browse_everything_providers.yml', force: true
|
21
23
|
end
|
22
24
|
|
23
25
|
def insert_file_system_path
|
24
26
|
insert_into_file 'config/browse_everything_providers.yml', before: '# dropbox:' do
|
25
|
-
|
27
|
+
"file_system:\n home: #{Rails.root}\n"
|
26
28
|
end
|
27
29
|
end
|
28
30
|
end
|
@@ -1,4 +1,5 @@
|
|
1
|
-
#
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
2
3
|
require 'rails/generators'
|
3
4
|
|
4
5
|
class BrowseEverything::InstallGenerator < Rails::Generators::Base
|
@@ -6,7 +7,7 @@ class BrowseEverything::InstallGenerator < Rails::Generators::Base
|
|
6
7
|
|
7
8
|
desc 'This generator installs the browse everything configuration and assets into your application'
|
8
9
|
|
9
|
-
source_root File.expand_path('
|
10
|
+
source_root File.expand_path('templates', __dir__)
|
10
11
|
|
11
12
|
def inject_config
|
12
13
|
generate 'browse_everything:config'
|
@@ -3,18 +3,19 @@
|
|
3
3
|
# The file_system provider can be a path to any directory on the server where your application is running.
|
4
4
|
#
|
5
5
|
# dropbox:
|
6
|
-
#
|
7
|
-
#
|
6
|
+
# app_key: YOUR_DROPBOX_APP_KEY
|
7
|
+
# app_secret: YOUR_DROPBOX_APP_SECRET
|
8
8
|
# box:
|
9
|
-
#
|
10
|
-
#
|
9
|
+
# client_id: YOUR_BOX_CLIENT_ID
|
10
|
+
# client_secret: YOUR_BOX_CLIENT_SECRET
|
11
11
|
# google_drive:
|
12
|
-
#
|
13
|
-
#
|
12
|
+
# client_id: YOUR_GOOGLE_API_CLIENT_ID
|
13
|
+
# client_secret: YOUR_GOOGLE_API_CLIENT_SECRET
|
14
14
|
# s3:
|
15
|
-
#
|
16
|
-
#
|
17
|
-
# :
|
18
|
-
# :
|
19
|
-
# :
|
15
|
+
# bucket: YOUR_AWS_S3_BUCKET
|
16
|
+
# response_type: signed_url # set to :public_url for public urls or :s3_uri for an s3://BUCKET/KEY uri
|
17
|
+
# expires_in: 14400 # for signed_url response_type, number of seconds url will be valid for.
|
18
|
+
# app_key: YOUR_AWS_S3_KEY # :app_key, :app_secret, and :region can be specified
|
19
|
+
# app_secret: YOUR_AWS_S3_SECRET # explicitly here, or left out to use system-configured
|
20
|
+
# region: YOUR_AWS_S3_REGION # defaults.
|
20
21
|
# See https://aws.amazon.com/blogs/security/a-new-and-standardized-way-to-manage-credentials-in-the-aws-sdks/
|
@@ -0,0 +1,80 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'spec_helper'
|
4
|
+
require 'signet/errors'
|
5
|
+
|
6
|
+
RSpec.describe BrowseEverythingController, type: :controller do
|
7
|
+
routes { Rails.application.class.routes }
|
8
|
+
|
9
|
+
let(:provider) { instance_double(BrowseEverything::Driver::Base) }
|
10
|
+
|
11
|
+
before do
|
12
|
+
allow(provider).to receive(:authorized?).and_return(true)
|
13
|
+
allow(controller).to receive(:provider).and_return(provider)
|
14
|
+
end
|
15
|
+
|
16
|
+
describe '#auth' do
|
17
|
+
let(:provider_session) { instance_double(BrowseEverythingSession::ProviderSession) }
|
18
|
+
|
19
|
+
before do
|
20
|
+
allow(controller).to receive(:params).and_return('code' => 'test-code')
|
21
|
+
allow(provider_session).to receive(:data).and_return(nil)
|
22
|
+
allow(provider).to receive(:connect)
|
23
|
+
controller.auth
|
24
|
+
end
|
25
|
+
it 'retrieves the authorization code from the parameters' do
|
26
|
+
expect(provider).to have_received(:connect).with({ 'code' => 'test-code' }, nil)
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
describe '#show' do
|
31
|
+
let(:file1) { instance_double(BrowseEverything::FileEntry) }
|
32
|
+
let(:file2) { instance_double(BrowseEverything::FileEntry) }
|
33
|
+
|
34
|
+
before do
|
35
|
+
allow(provider).to receive(:contents).and_return([file1, file2])
|
36
|
+
allow(controller).to receive(:render).with(partial: 'files', layout: true)
|
37
|
+
end
|
38
|
+
|
39
|
+
it 'renders the files retrieved by the provider' do
|
40
|
+
allow(provider).to receive(:contents).and_return([file1, file2])
|
41
|
+
controller.show
|
42
|
+
expect(controller).to have_received(:render).with(partial: 'files', layout: true)
|
43
|
+
end
|
44
|
+
|
45
|
+
context 'when an authentication error occurs while retrieving the files' do
|
46
|
+
let(:provider_session) { instance_double(BrowseEverythingSession::ProviderSession) }
|
47
|
+
|
48
|
+
before do
|
49
|
+
controller.instance_variable_set(:@provider_session, provider_session)
|
50
|
+
allow(controller).to receive(:render).with(partial: 'auth', layout: true)
|
51
|
+
allow(controller).to receive(:render).with(partial: 'files', layout: true).and_raise(Signet::AuthorizationError)
|
52
|
+
allow(provider_session).to receive(:token=)
|
53
|
+
allow(provider_session).to receive(:code=)
|
54
|
+
allow(provider_session).to receive(:data=)
|
55
|
+
controller.show
|
56
|
+
end
|
57
|
+
|
58
|
+
it 'clears the Rails session of auth. data' do
|
59
|
+
expect(provider_session).to have_received(:token=).with(nil)
|
60
|
+
expect(provider_session).to have_received(:code=).with(nil)
|
61
|
+
expect(provider_session).to have_received(:data=).with(nil)
|
62
|
+
expect(provider_session.instance_variable_get(:@provider_session)).to be_nil
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
66
|
+
context 'when a remote API error occurs while retrieving the files' do
|
67
|
+
before do
|
68
|
+
allow(controller).to receive(:render).with(partial: 'auth', layout: true)
|
69
|
+
allow(controller).to receive(:render).with(partial: 'files', layout: true).and_raise(StandardError)
|
70
|
+
allow(controller).to receive(:reset_provider_session!)
|
71
|
+
controller.show
|
72
|
+
end
|
73
|
+
|
74
|
+
it 'directs the user to reauthenticate after attempting to render the files' do
|
75
|
+
expect(controller).to have_received(:render).with(partial: 'auth', layout: true)
|
76
|
+
expect(controller).to have_received(:reset_provider_session!)
|
77
|
+
end
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|
@@ -1,30 +1,30 @@
|
|
1
|
-
|
1
|
+
# frozen_string_literal: true
|
2
2
|
|
3
|
-
describe 'Choosing files', type: :feature do
|
3
|
+
describe 'Choosing files', type: :feature, js: true do
|
4
4
|
before do
|
5
|
-
Capybara.register_driver :poltergeist do |app|
|
6
|
-
Capybara::Poltergeist::Driver.new(app, js_errors: true, timeout: 90)
|
7
|
-
end
|
8
|
-
Capybara.current_driver = :poltergeist
|
9
5
|
visit '/'
|
10
6
|
end
|
11
7
|
|
12
8
|
shared_examples 'browseable files' do
|
13
9
|
it 'selects files from the filesystem' do
|
14
10
|
click_button('Browse')
|
15
|
-
|
16
|
-
|
17
|
-
|
11
|
+
wait_for_ajax
|
12
|
+
|
13
|
+
expect(page).to have_selector '#browse-everything'
|
14
|
+
expect(page).to have_link 'config.ru'
|
15
|
+
find(:css, '#config-ru').set(true)
|
16
|
+
|
18
17
|
within('.modal-footer') do
|
19
|
-
expect(page).to have_selector('span', text: '2 files selected')
|
20
18
|
click_button('Submit')
|
21
19
|
end
|
22
|
-
|
23
|
-
|
20
|
+
|
21
|
+
wait_for_ajax
|
22
|
+
|
23
|
+
expect(page).to have_selector('#status', text: '1 items selected')
|
24
24
|
end
|
25
25
|
end
|
26
26
|
|
27
|
-
context 'when Turbolinks are enabled' do
|
27
|
+
context 'when Turbolinks are enabled', fail: true do
|
28
28
|
before { click_link('Enter Test App (Turbolinks)') }
|
29
29
|
it_behaves_like 'browseable files'
|
30
30
|
end
|