activestorage 5.2.4.4 → 6.1.1

Sign up to get free protection for your applications and to get access to all the features.

Potentially problematic release.


This version of activestorage might be problematic. Click here for more details.

Files changed (77) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +180 -69
  3. data/MIT-LICENSE +1 -1
  4. data/README.md +43 -8
  5. data/app/assets/javascripts/activestorage.js +5 -2
  6. data/app/controllers/active_storage/base_controller.rb +13 -4
  7. data/app/controllers/active_storage/blobs/proxy_controller.rb +14 -0
  8. data/app/controllers/active_storage/{blobs_controller.rb → blobs/redirect_controller.rb} +3 -3
  9. data/app/controllers/active_storage/direct_uploads_controller.rb +2 -2
  10. data/app/controllers/active_storage/disk_controller.rb +13 -22
  11. data/app/controllers/active_storage/representations/proxy_controller.rb +19 -0
  12. data/app/controllers/active_storage/{representations_controller.rb → representations/redirect_controller.rb} +3 -3
  13. data/app/controllers/concerns/active_storage/file_server.rb +18 -0
  14. data/app/controllers/concerns/active_storage/set_blob.rb +1 -1
  15. data/app/controllers/concerns/active_storage/set_current.rb +15 -0
  16. data/app/controllers/concerns/active_storage/set_headers.rb +12 -0
  17. data/app/javascript/activestorage/blob_record.js +7 -2
  18. data/app/jobs/active_storage/analyze_job.rb +5 -0
  19. data/app/jobs/active_storage/base_job.rb +0 -1
  20. data/app/jobs/active_storage/mirror_job.rb +15 -0
  21. data/app/jobs/active_storage/purge_job.rb +3 -0
  22. data/app/models/active_storage/attachment.rb +35 -16
  23. data/app/models/active_storage/blob.rb +178 -68
  24. data/app/models/active_storage/blob/analyzable.rb +6 -2
  25. data/app/models/active_storage/blob/identifiable.rb +7 -6
  26. data/app/models/active_storage/blob/representable.rb +36 -6
  27. data/app/models/active_storage/filename.rb +0 -6
  28. data/app/models/active_storage/preview.rb +37 -12
  29. data/app/models/active_storage/record.rb +7 -0
  30. data/app/models/active_storage/variant.rb +53 -67
  31. data/app/models/active_storage/variant_record.rb +8 -0
  32. data/app/models/active_storage/variant_with_record.rb +54 -0
  33. data/app/models/active_storage/variation.rb +30 -34
  34. data/config/routes.rb +66 -15
  35. data/db/migrate/20170806125915_create_active_storage_tables.rb +14 -5
  36. data/db/update_migrate/20190112182829_add_service_name_to_active_storage_blobs.rb +17 -0
  37. data/db/update_migrate/20191206030411_create_active_storage_variant_records.rb +11 -0
  38. data/lib/active_storage.rb +29 -6
  39. data/lib/active_storage/analyzer.rb +15 -4
  40. data/lib/active_storage/analyzer/image_analyzer.rb +14 -4
  41. data/lib/active_storage/analyzer/null_analyzer.rb +4 -0
  42. data/lib/active_storage/analyzer/video_analyzer.rb +17 -8
  43. data/lib/active_storage/attached.rb +7 -22
  44. data/lib/active_storage/attached/changes.rb +16 -0
  45. data/lib/active_storage/attached/changes/create_many.rb +47 -0
  46. data/lib/active_storage/attached/changes/create_one.rb +82 -0
  47. data/lib/active_storage/attached/changes/create_one_of_many.rb +10 -0
  48. data/lib/active_storage/attached/changes/delete_many.rb +27 -0
  49. data/lib/active_storage/attached/changes/delete_one.rb +19 -0
  50. data/lib/active_storage/attached/many.rb +19 -12
  51. data/lib/active_storage/attached/model.rb +212 -0
  52. data/lib/active_storage/attached/one.rb +19 -21
  53. data/lib/active_storage/downloader.rb +43 -0
  54. data/lib/active_storage/engine.rb +58 -23
  55. data/lib/active_storage/errors.rb +22 -3
  56. data/lib/active_storage/gem_version.rb +4 -4
  57. data/lib/active_storage/log_subscriber.rb +6 -0
  58. data/lib/active_storage/previewer.rb +24 -13
  59. data/lib/active_storage/previewer/mupdf_previewer.rb +3 -3
  60. data/lib/active_storage/previewer/poppler_pdf_previewer.rb +5 -5
  61. data/lib/active_storage/previewer/video_previewer.rb +17 -10
  62. data/lib/active_storage/reflection.rb +64 -0
  63. data/lib/active_storage/service.rb +44 -12
  64. data/lib/active_storage/service/azure_storage_service.rb +65 -44
  65. data/lib/active_storage/service/configurator.rb +6 -2
  66. data/lib/active_storage/service/disk_service.rb +57 -44
  67. data/lib/active_storage/service/gcs_service.rb +68 -64
  68. data/lib/active_storage/service/mirror_service.rb +31 -7
  69. data/lib/active_storage/service/registry.rb +32 -0
  70. data/lib/active_storage/service/s3_service.rb +58 -24
  71. data/lib/active_storage/transformers/image_processing_transformer.rb +45 -0
  72. data/lib/active_storage/transformers/transformer.rb +39 -0
  73. data/lib/tasks/activestorage.rake +7 -0
  74. metadata +84 -19
  75. data/app/models/active_storage/filename/parameters.rb +0 -36
  76. data/lib/active_storage/attached/macros.rb +0 -110
  77. data/lib/active_storage/downloading.rb +0 -39
@@ -1,31 +1,40 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- gem "google-cloud-storage", "~> 1.8"
4
-
3
+ gem "google-cloud-storage", "~> 1.11"
5
4
  require "google/cloud/storage"
6
- require "net/http"
7
-
8
- require "active_support/core_ext/object/to_query"
9
5
 
10
6
  module ActiveStorage
11
7
  # Wraps the Google Cloud Storage as an Active Storage service. See ActiveStorage::Service for the generic API
12
8
  # documentation that applies to all services.
13
9
  class Service::GCSService < Service
14
- def initialize(**config)
10
+ def initialize(public: false, **config)
15
11
  @config = config
12
+ @public = public
16
13
  end
17
14
 
18
15
  def upload(key, io, checksum: nil, content_type: nil, disposition: nil, filename: nil)
19
16
  instrument :upload, key: key, checksum: checksum do
20
- begin
21
- # GCS's signed URLs don't include params such as response-content-type response-content_disposition
22
- # in the signature, which means an attacker can modify them and bypass our effort to force these to
23
- # binary and attachment when the file's content type requires it. The only way to force them is to
24
- # store them as object's metadata.
25
- content_disposition = content_disposition_with(type: disposition, filename: filename) if disposition && filename
26
- bucket.create_file(io, key, md5: checksum, content_type: content_type, content_disposition: content_disposition)
27
- rescue Google::Cloud::InvalidArgumentError
28
- raise ActiveStorage::IntegrityError
17
+ # GCS's signed URLs don't include params such as response-content-type response-content_disposition
18
+ # in the signature, which means an attacker can modify them and bypass our effort to force these to
19
+ # binary and attachment when the file's content type requires it. The only way to force them is to
20
+ # store them as object's metadata.
21
+ content_disposition = content_disposition_with(type: disposition, filename: filename) if disposition && filename
22
+ bucket.create_file(io, key, md5: checksum, content_type: content_type, content_disposition: content_disposition)
23
+ rescue Google::Cloud::InvalidArgumentError
24
+ raise ActiveStorage::IntegrityError
25
+ end
26
+ end
27
+
28
+ def download(key, &block)
29
+ if block_given?
30
+ instrument :streaming_download, key: key do
31
+ stream(key, &block)
32
+ end
33
+ else
34
+ instrument :download, key: key do
35
+ file_for(key).download.string
36
+ rescue Google::Cloud::NotFoundError
37
+ raise ActiveStorage::FileNotFoundError
29
38
  end
30
39
  end
31
40
  end
@@ -39,49 +48,28 @@ module ActiveStorage
39
48
  end
40
49
  end
41
50
 
42
- # FIXME: Download in chunks when given a block.
43
- def download(key)
44
- instrument :download, key: key do
45
- io = file_for(key).download
46
- io.rewind
47
-
48
- if block_given?
49
- yield io.string
50
- else
51
- io.string
52
- end
53
- end
54
- end
55
-
56
51
  def download_chunk(key, range)
57
52
  instrument :download_chunk, key: key, range: range do
58
- file = file_for(key)
59
- uri = URI(file.signed_url(expires: 30.seconds))
60
-
61
- Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") do |client|
62
- client.get(uri, "Range" => "bytes=#{range.begin}-#{range.exclude_end? ? range.end - 1 : range.end}").body
63
- end
53
+ file_for(key).download(range: range).string
54
+ rescue Google::Cloud::NotFoundError
55
+ raise ActiveStorage::FileNotFoundError
64
56
  end
65
57
  end
66
58
 
67
59
  def delete(key)
68
60
  instrument :delete, key: key do
69
- begin
70
- file_for(key).delete
71
- rescue Google::Cloud::NotFoundError
72
- # Ignore files already deleted
73
- end
61
+ file_for(key).delete
62
+ rescue Google::Cloud::NotFoundError
63
+ # Ignore files already deleted
74
64
  end
75
65
  end
76
66
 
77
67
  def delete_prefixed(prefix)
78
68
  instrument :delete_prefixed, prefix: prefix do
79
69
  bucket.files(prefix: prefix).all do |file|
80
- begin
81
- file.delete
82
- rescue Google::Cloud::NotFoundError
83
- # Ignore concurrently-deleted files
84
- end
70
+ file.delete
71
+ rescue Google::Cloud::NotFoundError
72
+ # Ignore concurrently-deleted files
85
73
  end
86
74
  end
87
75
  end
@@ -94,19 +82,6 @@ module ActiveStorage
94
82
  end
95
83
  end
96
84
 
97
- def url(key, expires_in:, filename:, content_type:, disposition:)
98
- instrument :url, key: key do |payload|
99
- generated_url = file_for(key).signed_url expires: expires_in, query: {
100
- "response-content-disposition" => content_disposition_with(type: disposition, filename: filename),
101
- "response-content-type" => content_type
102
- }
103
-
104
- payload[:url] = generated_url
105
-
106
- generated_url
107
- end
108
- end
109
-
110
85
  def url_for_direct_upload(key, expires_in:, checksum:, **)
111
86
  instrument :url, key: key do |payload|
112
87
  generated_url = bucket.signed_url key, method: "PUT", expires: expires_in, content_md5: checksum
@@ -117,23 +92,52 @@ module ActiveStorage
117
92
  end
118
93
  end
119
94
 
120
- def headers_for_direct_upload(key, checksum:, **)
121
- { "Content-MD5" => checksum }
95
+ def headers_for_direct_upload(key, checksum:, filename: nil, disposition: nil, **)
96
+ content_disposition = content_disposition_with(type: disposition, filename: filename) if filename
97
+
98
+ { "Content-MD5" => checksum, "Content-Disposition" => content_disposition }
122
99
  end
123
100
 
124
101
  private
102
+ def private_url(key, expires_in:, filename:, content_type:, disposition:, **)
103
+ file_for(key).signed_url expires: expires_in, query: {
104
+ "response-content-disposition" => content_disposition_with(type: disposition, filename: filename),
105
+ "response-content-type" => content_type
106
+ }
107
+ end
108
+
109
+ def public_url(key, **)
110
+ file_for(key).public_url
111
+ end
112
+
113
+
125
114
  attr_reader :config
126
115
 
127
- def file_for(key)
128
- bucket.file(key, skip_lookup: true)
116
+ def file_for(key, skip_lookup: true)
117
+ bucket.file(key, skip_lookup: skip_lookup)
118
+ end
119
+
120
+ # Reads the file for the given key in chunks, yielding each to the block.
121
+ def stream(key)
122
+ file = file_for(key, skip_lookup: false)
123
+
124
+ chunk_size = 5.megabytes
125
+ offset = 0
126
+
127
+ raise ActiveStorage::FileNotFoundError unless file.present?
128
+
129
+ while offset < file.size
130
+ yield file.download(range: offset..(offset + chunk_size - 1)).string
131
+ offset += chunk_size
132
+ end
129
133
  end
130
134
 
131
135
  def bucket
132
- @bucket ||= client.bucket(config.fetch(:bucket))
136
+ @bucket ||= client.bucket(config.fetch(:bucket), skip_lookup: true)
133
137
  end
134
138
 
135
139
  def client
136
- @client ||= Google::Cloud::Storage.new(config.except(:bucket))
140
+ @client ||= Google::Cloud::Storage.new(**config.except(:bucket))
137
141
  end
138
142
  end
139
143
  end
@@ -4,18 +4,26 @@ require "active_support/core_ext/module/delegation"
4
4
 
5
5
  module ActiveStorage
6
6
  # Wraps a set of mirror services and provides a single ActiveStorage::Service object that will all
7
- # have the files uploaded to them. A +primary+ service is designated to answer calls to +download+, +exists?+,
8
- # and +url+.
7
+ # have the files uploaded to them. A +primary+ service is designated to answer calls to:
8
+ # * +download+
9
+ # * +exists?+
10
+ # * +url+
11
+ # * +url_for_direct_upload+
12
+ # * +headers_for_direct_upload+
9
13
  class Service::MirrorService < Service
10
14
  attr_reader :primary, :mirrors
11
15
 
12
- delegate :download, :download_chunk, :exist?, :url, :path_for, to: :primary
16
+ delegate :download, :download_chunk, :exist?, :url,
17
+ :url_for_direct_upload, :headers_for_direct_upload, :path_for, to: :primary
13
18
 
14
19
  # Stitch together from named services.
15
- def self.build(primary:, mirrors:, configurator:, **options) #:nodoc:
16
- new \
20
+ def self.build(primary:, mirrors:, name:, configurator:, **options) #:nodoc:
21
+ new(
17
22
  primary: configurator.build(primary),
18
- mirrors: mirrors.collect { |name| configurator.build name }
23
+ mirrors: mirrors.collect { |mirror_name| configurator.build mirror_name }
24
+ ).tap do |service_instance|
25
+ service_instance.name = name
26
+ end
19
27
  end
20
28
 
21
29
  def initialize(primary:, mirrors:)
@@ -26,7 +34,8 @@ module ActiveStorage
26
34
  # ensure a match when the upload has completed or raise an ActiveStorage::IntegrityError.
27
35
  def upload(key, io, checksum: nil, **options)
28
36
  each_service.collect do |service|
29
- service.upload key, io.tap(&:rewind), checksum: checksum, **options
37
+ io.rewind
38
+ service.upload key, io, checksum: checksum, **options
30
39
  end
31
40
  end
32
41
 
@@ -40,6 +49,21 @@ module ActiveStorage
40
49
  perform_across_services :delete_prefixed, prefix
41
50
  end
42
51
 
52
+
53
+ # Copy the file at the +key+ from the primary service to each of the mirrors where it doesn't already exist.
54
+ def mirror(key, checksum:)
55
+ instrument :mirror, key: key, checksum: checksum do
56
+ if (mirrors_in_need_of_mirroring = mirrors.select { |service| !service.exist?(key) }).any?
57
+ primary.open(key, checksum: checksum) do |io|
58
+ mirrors_in_need_of_mirroring.each do |service|
59
+ io.rewind
60
+ service.upload key, io, checksum: checksum
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
66
+
43
67
  private
44
68
  def each_service(&block)
45
69
  [ primary, *mirrors ].each(&block)
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveStorage
4
+ class Service::Registry #:nodoc:
5
+ def initialize(configurations)
6
+ @configurations = configurations.deep_symbolize_keys
7
+ @services = {}
8
+ end
9
+
10
+ def fetch(name)
11
+ services.fetch(name.to_sym) do |key|
12
+ if configurations.include?(key)
13
+ services[key] = configurator.build(key)
14
+ else
15
+ if block_given?
16
+ yield key
17
+ else
18
+ raise KeyError, "Missing configuration for the #{key} Active Storage service. " \
19
+ "Configurations available for the #{configurations.keys.to_sentence} services."
20
+ end
21
+ end
22
+ end
23
+ end
24
+
25
+ private
26
+ attr_reader :configurations, :services
27
+
28
+ def configurator
29
+ @configurator ||= ActiveStorage::Service::Configurator.new(configurations)
30
+ end
31
+ end
32
+ end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ gem "aws-sdk-s3", "~> 1.48"
4
+
3
5
  require "aws-sdk-s3"
4
6
  require "active_support/core_ext/numeric/bytes"
5
7
 
@@ -7,21 +9,28 @@ module ActiveStorage
7
9
  # Wraps the Amazon Simple Storage Service (S3) as an Active Storage service.
8
10
  # See ActiveStorage::Service for the generic API documentation that applies to all services.
9
11
  class Service::S3Service < Service
10
- attr_reader :client, :bucket, :upload_options
12
+ attr_reader :client, :bucket
13
+ attr_reader :multipart_upload_threshold, :upload_options
11
14
 
12
- def initialize(bucket:, upload: {}, **options)
15
+ def initialize(bucket:, upload: {}, public: false, **options)
13
16
  @client = Aws::S3::Resource.new(**options)
14
17
  @bucket = @client.bucket(bucket)
15
18
 
19
+ @multipart_upload_threshold = upload.delete(:multipart_threshold) || 100.megabytes
20
+ @public = public
21
+
16
22
  @upload_options = upload
23
+ @upload_options[:acl] = "public-read" if public?
17
24
  end
18
25
 
19
- def upload(key, io, checksum: nil, **)
26
+ def upload(key, io, checksum: nil, filename: nil, content_type: nil, disposition: nil, **)
20
27
  instrument :upload, key: key, checksum: checksum do
21
- begin
22
- object_for(key).put(upload_options.merge(body: io, content_md5: checksum))
23
- rescue Aws::S3::Errors::BadDigest
24
- raise ActiveStorage::IntegrityError
28
+ content_disposition = content_disposition_with(filename: filename, type: disposition) if disposition && filename
29
+
30
+ if io.size < multipart_upload_threshold
31
+ upload_with_single_part key, io, checksum: checksum, content_type: content_type, content_disposition: content_disposition
32
+ else
33
+ upload_with_multipart key, io, content_type: content_type, content_disposition: content_disposition
25
34
  end
26
35
  end
27
36
  end
@@ -34,13 +43,17 @@ module ActiveStorage
34
43
  else
35
44
  instrument :download, key: key do
36
45
  object_for(key).get.body.string.force_encoding(Encoding::BINARY)
46
+ rescue Aws::S3::Errors::NoSuchKey
47
+ raise ActiveStorage::FileNotFoundError
37
48
  end
38
49
  end
39
50
  end
40
51
 
41
52
  def download_chunk(key, range)
42
53
  instrument :download_chunk, key: key, range: range do
43
- object_for(key).get(range: "bytes=#{range.begin}-#{range.exclude_end? ? range.end - 1 : range.end}").body.read.force_encoding(Encoding::BINARY)
54
+ object_for(key).get(range: "bytes=#{range.begin}-#{range.exclude_end? ? range.end - 1 : range.end}").body.string.force_encoding(Encoding::BINARY)
55
+ rescue Aws::S3::Errors::NoSuchKey
56
+ raise ActiveStorage::FileNotFoundError
44
57
  end
45
58
  end
46
59
 
@@ -64,23 +77,11 @@ module ActiveStorage
64
77
  end
65
78
  end
66
79
 
67
- def url(key, expires_in:, filename:, disposition:, content_type:)
68
- instrument :url, key: key do |payload|
69
- generated_url = object_for(key).presigned_url :get, expires_in: expires_in.to_i,
70
- response_content_disposition: content_disposition_with(type: disposition, filename: filename),
71
- response_content_type: content_type
72
-
73
- payload[:url] = generated_url
74
-
75
- generated_url
76
- end
77
- end
78
-
79
80
  def url_for_direct_upload(key, expires_in:, content_type:, content_length:, checksum:)
80
81
  instrument :url, key: key do |payload|
81
82
  generated_url = object_for(key).presigned_url :put, expires_in: expires_in.to_i,
82
83
  content_type: content_type, content_length: content_length, content_md5: checksum,
83
- whitelist_headers: ['content-length']
84
+ whitelist_headers: ["content-length"], **upload_options
84
85
 
85
86
  payload[:url] = generated_url
86
87
 
@@ -88,11 +89,42 @@ module ActiveStorage
88
89
  end
89
90
  end
90
91
 
91
- def headers_for_direct_upload(key, content_type:, checksum:, **)
92
- { "Content-Type" => content_type, "Content-MD5" => checksum }
92
+ def headers_for_direct_upload(key, content_type:, checksum:, filename: nil, disposition: nil, **)
93
+ content_disposition = content_disposition_with(type: disposition, filename: filename) if filename
94
+
95
+ { "Content-Type" => content_type, "Content-MD5" => checksum, "Content-Disposition" => content_disposition }
93
96
  end
94
97
 
95
98
  private
99
+ def private_url(key, expires_in:, filename:, disposition:, content_type:, **)
100
+ object_for(key).presigned_url :get, expires_in: expires_in.to_i,
101
+ response_content_disposition: content_disposition_with(type: disposition, filename: filename),
102
+ response_content_type: content_type
103
+ end
104
+
105
+ def public_url(key, **)
106
+ object_for(key).public_url
107
+ end
108
+
109
+
110
+ MAXIMUM_UPLOAD_PARTS_COUNT = 10000
111
+ MINIMUM_UPLOAD_PART_SIZE = 5.megabytes
112
+
113
+ def upload_with_single_part(key, io, checksum: nil, content_type: nil, content_disposition: nil)
114
+ object_for(key).put(body: io, content_md5: checksum, content_type: content_type, content_disposition: content_disposition, **upload_options)
115
+ rescue Aws::S3::Errors::BadDigest
116
+ raise ActiveStorage::IntegrityError
117
+ end
118
+
119
+ def upload_with_multipart(key, io, content_type: nil, content_disposition: nil)
120
+ part_size = [ io.size.fdiv(MAXIMUM_UPLOAD_PARTS_COUNT).ceil, MINIMUM_UPLOAD_PART_SIZE ].max
121
+
122
+ object_for(key).upload_stream(content_type: content_type, content_disposition: content_disposition, part_size: part_size, **upload_options) do |out|
123
+ IO.copy_stream(io, out)
124
+ end
125
+ end
126
+
127
+
96
128
  def object_for(key)
97
129
  bucket.object(key)
98
130
  end
@@ -104,8 +136,10 @@ module ActiveStorage
104
136
  chunk_size = 5.megabytes
105
137
  offset = 0
106
138
 
139
+ raise ActiveStorage::FileNotFoundError unless object.exists?
140
+
107
141
  while offset < object.content_length
108
- yield object.get(range: "bytes=#{offset}-#{offset + chunk_size - 1}").body.read.force_encoding(Encoding::BINARY)
142
+ yield object.get(range: "bytes=#{offset}-#{offset + chunk_size - 1}").body.string.force_encoding(Encoding::BINARY)
109
143
  offset += chunk_size
110
144
  end
111
145
  end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ begin
4
+ require "image_processing"
5
+ rescue LoadError
6
+ raise LoadError, <<~ERROR.squish
7
+ Generating image variants require the image_processing gem.
8
+ Please add `gem 'image_processing', '~> 1.2'` to your Gemfile.
9
+ ERROR
10
+ end
11
+
12
+ module ActiveStorage
13
+ module Transformers
14
+ class ImageProcessingTransformer < Transformer
15
+ private
16
+ def process(file, format:)
17
+ processor.
18
+ source(file).
19
+ loader(page: 0).
20
+ convert(format).
21
+ apply(operations).
22
+ call
23
+ end
24
+
25
+ def processor
26
+ ImageProcessing.const_get(ActiveStorage.variant_processor.to_s.camelize)
27
+ end
28
+
29
+ def operations
30
+ transformations.each_with_object([]) do |(name, argument), list|
31
+ if name.to_s == "combine_options"
32
+ raise ArgumentError, <<~ERROR.squish
33
+ Active Storage's ImageProcessing transformer doesn't support :combine_options,
34
+ as it always generates a single ImageMagick command.
35
+ ERROR
36
+ end
37
+
38
+ if argument.present?
39
+ list << [ name, argument ]
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end