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,22 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "mimemagic"
4
+
3
5
  # A set of transformations that can be applied to a blob to create a variant. This class is exposed via
4
6
  # the ActiveStorage::Blob#variant method and should rarely be used directly.
5
7
  #
6
8
  # In case you do need to use this directly, it's instantiated using a hash of transformations where
7
9
  # the key is the command and the value is the arguments. Example:
8
10
  #
9
- # ActiveStorage::Variation.new(resize: "100x100", monochrome: true, trim: true, rotate: "-90")
10
- #
11
- # You can also combine multiple transformations in one step, e.g. for center-weighted cropping:
11
+ # ActiveStorage::Variation.new(resize_to_limit: [100, 100], monochrome: true, trim: true, rotate: "-90")
12
12
  #
13
- # ActiveStorage::Variation.new(combine_options: {
14
- # resize: "100x100^",
15
- # gravity: "center",
16
- # crop: "100x100+0+0",
17
- # })
18
- #
19
- # A list of all possible transformations is available at https://www.imagemagick.org/script/mogrify.php.
13
+ # The options map directly to {ImageProcessing}[https://github.com/janko-m/image_processing] commands.
20
14
  class ActiveStorage::Variation
21
15
  attr_reader :transformations
22
16
 
@@ -48,42 +42,44 @@ class ActiveStorage::Variation
48
42
  end
49
43
 
50
44
  def initialize(transformations)
51
- @transformations = transformations
45
+ @transformations = transformations.deep_symbolize_keys
46
+ end
47
+
48
+ def default_to(defaults)
49
+ self.class.new transformations.reverse_merge(defaults)
52
50
  end
53
51
 
54
- # Accepts an open MiniMagick image instance, like what's returned by <tt>MiniMagick::Image.read(io)</tt>,
55
- # and performs the +transformations+ against it. The transformed image instance is then returned.
56
- def transform(image)
52
+ # Accepts a File object, performs the +transformations+ against it, and
53
+ # saves the transformed image into a temporary file.
54
+ def transform(file, &block)
57
55
  ActiveSupport::Notifications.instrument("transform.active_storage") do
58
- transformations.each do |name, argument_or_subtransformations|
59
- image.mogrify do |command|
60
- if name.to_s == "combine_options"
61
- argument_or_subtransformations.each do |subtransformation_name, subtransformation_argument|
62
- pass_transform_argument(command, subtransformation_name, subtransformation_argument)
63
- end
64
- else
65
- pass_transform_argument(command, name, argument_or_subtransformations)
66
- end
67
- end
56
+ transformer.transform(file, format: format, &block)
57
+ end
58
+ end
59
+
60
+ def format
61
+ transformations.fetch(:format, :png).tap do |format|
62
+ if MimeMagic.by_extension(format).nil?
63
+ raise ArgumentError, "Invalid variant format (#{format.inspect})"
68
64
  end
69
65
  end
70
66
  end
71
67
 
68
+ def content_type
69
+ MimeMagic.by_extension(format).to_s
70
+ end
71
+
72
72
  # Returns a signed key for all the +transformations+ that this variation was instantiated with.
73
73
  def key
74
74
  self.class.encode(transformations)
75
75
  end
76
76
 
77
- private
78
- def pass_transform_argument(command, method, argument)
79
- if eligible_argument?(argument)
80
- command.public_send(method, argument)
81
- else
82
- command.public_send(method)
83
- end
84
- end
77
+ def digest
78
+ Digest::SHA1.base64digest Marshal.dump(transformations)
79
+ end
85
80
 
86
- def eligible_argument?(argument)
87
- argument.present? && argument != true
81
+ private
82
+ def transformer
83
+ ActiveStorage::Transformers::ImageProcessingTransformer.new(transformations.except(:format))
88
84
  end
89
85
  end
data/config/routes.rb CHANGED
@@ -1,17 +1,19 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  Rails.application.routes.draw do
4
- get "/rails/active_storage/blobs/:signed_id/*filename" => "active_storage/blobs#show", as: :rails_service_blob
4
+ scope ActiveStorage.routes_prefix do
5
+ get "/blobs/redirect/:signed_id/*filename" => "active_storage/blobs/redirect#show", as: :rails_service_blob
6
+ get "/blobs/proxy/:signed_id/*filename" => "active_storage/blobs/proxy#show", as: :rails_service_blob_proxy
7
+ get "/blobs/:signed_id/*filename" => "active_storage/blobs/redirect#show"
5
8
 
6
- direct :rails_blob do |blob, options|
7
- route_for(:rails_service_blob, blob.signed_id, blob.filename, options)
8
- end
9
-
10
- resolve("ActiveStorage::Blob") { |blob, options| route_for(:rails_blob, blob, options) }
11
- resolve("ActiveStorage::Attachment") { |attachment, options| route_for(:rails_blob, attachment.blob, options) }
9
+ get "/representations/redirect/:signed_blob_id/:variation_key/*filename" => "active_storage/representations/redirect#show", as: :rails_blob_representation
10
+ get "/representations/proxy/:signed_blob_id/:variation_key/*filename" => "active_storage/representations/proxy#show", as: :rails_blob_representation_proxy
11
+ get "/representations/:signed_blob_id/:variation_key/*filename" => "active_storage/representations/redirect#show"
12
12
 
13
-
14
- get "/rails/active_storage/representations/:signed_blob_id/:variation_key/*filename" => "active_storage/representations#show", as: :rails_blob_representation
13
+ get "/disk/:encoded_key/*filename" => "active_storage/disk#show", as: :rails_disk_service
14
+ put "/disk/:encoded_token" => "active_storage/disk#update", as: :update_rails_disk_service
15
+ post "/direct_uploads" => "active_storage/direct_uploads#create", as: :rails_direct_uploads
16
+ end
15
17
 
16
18
  direct :rails_representation do |representation, options|
17
19
  signed_blob_id = representation.blob.signed_id
@@ -21,11 +23,60 @@ Rails.application.routes.draw do
21
23
  route_for(:rails_blob_representation, signed_blob_id, variation_key, filename, options)
22
24
  end
23
25
 
24
- resolve("ActiveStorage::Variant") { |variant, options| route_for(:rails_representation, variant, options) }
25
- resolve("ActiveStorage::Preview") { |preview, options| route_for(:rails_representation, preview, options) }
26
+ resolve("ActiveStorage::Variant") { |variant, options| route_for(ActiveStorage.resolve_model_to_route, variant, options) }
27
+ resolve("ActiveStorage::VariantWithRecord") { |variant, options| route_for(ActiveStorage.resolve_model_to_route, variant, options) }
28
+ resolve("ActiveStorage::Preview") { |preview, options| route_for(ActiveStorage.resolve_model_to_route, preview, options) }
26
29
 
30
+ direct :rails_blob do |blob, options|
31
+ route_for(:rails_service_blob, blob.signed_id, blob.filename, options)
32
+ end
33
+
34
+ resolve("ActiveStorage::Blob") { |blob, options| route_for(ActiveStorage.resolve_model_to_route, blob, options) }
35
+ resolve("ActiveStorage::Attachment") { |attachment, options| route_for(ActiveStorage.resolve_model_to_route, attachment.blob, options) }
27
36
 
28
- get "/rails/active_storage/disk/:encoded_key/*filename" => "active_storage/disk#show", as: :rails_disk_service
29
- put "/rails/active_storage/disk/:encoded_token" => "active_storage/disk#update", as: :update_rails_disk_service
30
- post "/rails/active_storage/direct_uploads" => "active_storage/direct_uploads#create", as: :rails_direct_uploads
31
- end
37
+ direct :rails_storage_proxy do |model, options|
38
+ if model.respond_to?(:signed_id)
39
+ route_for(
40
+ :rails_service_blob_proxy,
41
+ model.signed_id,
42
+ model.filename,
43
+ options
44
+ )
45
+ else
46
+ signed_blob_id = model.blob.signed_id
47
+ variation_key = model.variation.key
48
+ filename = model.blob.filename
49
+
50
+ route_for(
51
+ :rails_blob_representation_proxy,
52
+ signed_blob_id,
53
+ variation_key,
54
+ filename,
55
+ options
56
+ )
57
+ end
58
+ end
59
+
60
+ direct :rails_storage_redirect do |model, options|
61
+ if model.respond_to?(:signed_id)
62
+ route_for(
63
+ :rails_service_blob,
64
+ model.signed_id,
65
+ model.filename,
66
+ options
67
+ )
68
+ else
69
+ signed_blob_id = model.blob.signed_id
70
+ variation_key = model.variation.key
71
+ filename = model.blob.filename
72
+
73
+ route_for(
74
+ :rails_blob_representation,
75
+ signed_blob_id,
76
+ variation_key,
77
+ filename,
78
+ options
79
+ )
80
+ end
81
+ end
82
+ end if ActiveStorage.draw_routes
@@ -1,13 +1,14 @@
1
1
  class CreateActiveStorageTables < ActiveRecord::Migration[5.2]
2
2
  def change
3
3
  create_table :active_storage_blobs do |t|
4
- t.string :key, null: false
5
- t.string :filename, null: false
4
+ t.string :key, null: false
5
+ t.string :filename, null: false
6
6
  t.string :content_type
7
7
  t.text :metadata
8
- t.bigint :byte_size, null: false
9
- t.string :checksum, null: false
10
- t.datetime :created_at, null: false
8
+ t.string :service_name, null: false
9
+ t.bigint :byte_size, null: false
10
+ t.string :checksum, null: false
11
+ t.datetime :created_at, null: false
11
12
 
12
13
  t.index [ :key ], unique: true
13
14
  end
@@ -22,5 +23,13 @@ class CreateActiveStorageTables < ActiveRecord::Migration[5.2]
22
23
  t.index [ :record_type, :record_id, :name, :blob_id ], name: "index_active_storage_attachments_uniqueness", unique: true
23
24
  t.foreign_key :active_storage_blobs, column: :blob_id
24
25
  end
26
+
27
+ create_table :active_storage_variant_records do |t|
28
+ t.belongs_to :blob, null: false, index: false
29
+ t.string :variation_digest, null: false
30
+
31
+ t.index %i[ blob_id variation_digest ], name: "index_active_storage_variant_records_uniqueness", unique: true
32
+ t.foreign_key :active_storage_blobs, column: :blob_id
33
+ end
25
34
  end
26
35
  end
@@ -0,0 +1,17 @@
1
+ class AddServiceNameToActiveStorageBlobs < ActiveRecord::Migration[6.0]
2
+ def up
3
+ unless column_exists?(:active_storage_blobs, :service_name)
4
+ add_column :active_storage_blobs, :service_name, :string
5
+
6
+ if configured_service = ActiveStorage::Blob.service.name
7
+ ActiveStorage::Blob.unscoped.update_all(service_name: configured_service)
8
+ end
9
+
10
+ change_column :active_storage_blobs, :service_name, :string, null: false
11
+ end
12
+ end
13
+
14
+ def down
15
+ remove_column :active_storage_blobs, :service_name
16
+ end
17
+ end
@@ -0,0 +1,11 @@
1
+ class CreateActiveStorageVariantRecords < ActiveRecord::Migration[6.0]
2
+ def change
3
+ create_table :active_storage_variant_records do |t|
4
+ t.belongs_to :blob, null: false, index: false
5
+ t.string :variation_digest, null: false
6
+
7
+ t.index %i[ blob_id variation_digest ], name: "index_active_storage_variant_records_uniqueness", unique: true
8
+ t.foreign_key :active_storage_blobs, column: :blob_id
9
+ end
10
+ end
11
+ end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  #--
4
- # Copyright (c) 2017-2018 David Heinemeier Hansson, Basecamp
4
+ # Copyright (c) 2017-2020 David Heinemeier Hansson, Basecamp
5
5
  #
6
6
  # Permission is hereby granted, free of charge, to any person obtaining
7
7
  # a copy of this software and associated documentation files (the
@@ -26,6 +26,7 @@
26
26
  require "active_record"
27
27
  require "active_support"
28
28
  require "active_support/rails"
29
+ require "active_support/core_ext/numeric/time"
29
30
 
30
31
  require "active_storage/version"
31
32
  require "active_storage/errors"
@@ -42,12 +43,34 @@ module ActiveStorage
42
43
 
43
44
  mattr_accessor :logger
44
45
  mattr_accessor :verifier
45
- mattr_accessor :queue
46
+ mattr_accessor :variant_processor, default: :mini_magick
47
+
48
+ mattr_accessor :queues, default: {}
49
+
46
50
  mattr_accessor :previewers, default: []
47
- mattr_accessor :analyzers, default: []
51
+ mattr_accessor :analyzers, default: []
52
+
48
53
  mattr_accessor :paths, default: {}
49
- mattr_accessor :variable_content_types, default: []
54
+
55
+ mattr_accessor :variable_content_types, default: []
56
+ mattr_accessor :web_image_content_types, default: []
57
+ mattr_accessor :binary_content_type, default: "application/octet-stream"
50
58
  mattr_accessor :content_types_to_serve_as_binary, default: []
51
- mattr_accessor :content_types_allowed_inline, default: []
52
- mattr_accessor :binary_content_type, default: "application/octet-stream"
59
+ mattr_accessor :content_types_allowed_inline, default: []
60
+
61
+ mattr_accessor :service_urls_expire_in, default: 5.minutes
62
+
63
+ mattr_accessor :routes_prefix, default: "/rails/active_storage"
64
+ mattr_accessor :draw_routes, default: true
65
+ mattr_accessor :resolve_model_to_route, default: :rails_storage_redirect
66
+
67
+ mattr_accessor :replace_on_assign_to_many, default: false
68
+ mattr_accessor :track_variants, default: false
69
+
70
+ module Transformers
71
+ extend ActiveSupport::Autoload
72
+
73
+ autoload :Transformer
74
+ autoload :ImageProcessingTransformer
75
+ end
53
76
  end
@@ -1,13 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "active_storage/downloading"
4
-
5
3
  module ActiveStorage
6
4
  # This is an abstract base class for analyzers, which extract metadata from blobs. See
7
5
  # ActiveStorage::Analyzer::ImageAnalyzer for an example of a concrete subclass.
8
6
  class Analyzer
9
- include Downloading
10
-
11
7
  attr_reader :blob
12
8
 
13
9
  # Implement this method in a concrete subclass. Have it return true when given a blob from which
@@ -16,6 +12,12 @@ module ActiveStorage
16
12
  false
17
13
  end
18
14
 
15
+ # Implement this method in concrete subclasses. It will determine if blob analysis
16
+ # should be done in a job or performed inline. By default, analysis is enqueued in a job.
17
+ def self.analyze_later?
18
+ true
19
+ end
20
+
19
21
  def initialize(blob)
20
22
  @blob = blob
21
23
  end
@@ -26,8 +28,17 @@ module ActiveStorage
26
28
  end
27
29
 
28
30
  private
31
+ # Downloads the blob to a tempfile on disk. Yields the tempfile.
32
+ def download_blob_to_tempfile(&block) #:doc:
33
+ blob.open tmpdir: tmpdir, &block
34
+ end
35
+
29
36
  def logger #:doc:
30
37
  ActiveStorage.logger
31
38
  end
39
+
40
+ def tmpdir #:doc:
41
+ Dir.tmpdir
42
+ end
32
43
  end
33
44
  end
@@ -25,17 +25,27 @@ module ActiveStorage
25
25
  { width: image.width, height: image.height }
26
26
  end
27
27
  end
28
- rescue LoadError
29
- logger.info "Skipping image analysis because the mini_magick gem isn't installed"
30
- {}
31
28
  end
32
29
 
33
30
  private
34
31
  def read_image
35
32
  download_blob_to_tempfile do |file|
36
33
  require "mini_magick"
37
- yield MiniMagick::Image.new(file.path)
34
+ image = MiniMagick::Image.new(file.path)
35
+
36
+ if image.valid?
37
+ yield image
38
+ else
39
+ logger.info "Skipping image analysis because ImageMagick doesn't support the file"
40
+ {}
41
+ end
38
42
  end
43
+ rescue LoadError
44
+ logger.info "Skipping image analysis because the mini_magick gem isn't installed"
45
+ {}
46
+ rescue MiniMagick::Error => error
47
+ logger.error "Skipping image analysis due to an ImageMagick error: #{error.message}"
48
+ {}
39
49
  end
40
50
 
41
51
  def rotated_image?(image)
@@ -6,6 +6,10 @@ module ActiveStorage
6
6
  true
7
7
  end
8
8
 
9
+ def self.analyze_later?
10
+ false
11
+ end
12
+
9
13
  def metadata
10
14
  {}
11
15
  end
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "active_support/core_ext/hash/compact"
4
-
5
3
  module ActiveStorage
6
4
  # Extracts the following from a video blob:
7
5
  #
@@ -13,12 +11,12 @@ module ActiveStorage
13
11
  #
14
12
  # Example:
15
13
  #
16
- # ActiveStorage::VideoAnalyzer.new(blob).metadata
14
+ # ActiveStorage::Analyzer::VideoAnalyzer.new(blob).metadata
17
15
  # # => { width: 640.0, height: 480.0, duration: 5.0, angle: 0, display_aspect_ratio: [4, 3] }
18
16
  #
19
17
  # When a video's angle is 90 or 270 degrees, its width and height are automatically swapped for convenience.
20
18
  #
21
- # This analyzer requires the {ffmpeg}[https://www.ffmpeg.org] system library, which is not provided by Rails.
19
+ # This analyzer requires the {FFmpeg}[https://www.ffmpeg.org] system library, which is not provided by Rails.
22
20
  class Analyzer::VideoAnalyzer < Analyzer
23
21
  def self.accept?(blob)
24
22
  blob.video?
@@ -46,7 +44,8 @@ module ActiveStorage
46
44
  end
47
45
 
48
46
  def duration
49
- Float(video_stream["duration"]) if video_stream["duration"]
47
+ duration = video_stream["duration"] || container["duration"]
48
+ Float(duration) if duration
50
49
  end
51
50
 
52
51
  def angle
@@ -100,16 +99,26 @@ module ActiveStorage
100
99
  probe["streams"] || []
101
100
  end
102
101
 
102
+ def container
103
+ probe["format"] || {}
104
+ end
105
+
103
106
  def probe
104
- download_blob_to_tempfile { |file| probe_from(file) }
107
+ @probe ||= download_blob_to_tempfile { |file| probe_from(file) }
105
108
  end
106
109
 
107
110
  def probe_from(file)
108
- IO.popen([ ffprobe_path, "-print_format", "json", "-show_streams", "-v", "error", file.path ]) do |output|
111
+ IO.popen([ ffprobe_path,
112
+ "-print_format", "json",
113
+ "-show_streams",
114
+ "-show_format",
115
+ "-v", "error",
116
+ file.path
117
+ ]) do |output|
109
118
  JSON.parse(output.read)
110
119
  end
111
120
  rescue Errno::ENOENT
112
- logger.info "Skipping video analysis because ffmpeg isn't installed"
121
+ logger.info "Skipping video analysis because FFmpeg isn't installed"
113
122
  {}
114
123
  end
115
124