dragonfly 1.1.4 → 1.4.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.
Files changed (43) hide show
  1. checksums.yaml +5 -5
  2. data/.travis.yml +14 -6
  3. data/History.md +336 -309
  4. data/README.md +1 -1
  5. data/dev/rails_template.rb +35 -33
  6. data/dragonfly.gemspec +10 -16
  7. data/lib/dragonfly.rb +3 -1
  8. data/lib/dragonfly/content.rb +21 -22
  9. data/lib/dragonfly/image_magick/commands.rb +35 -0
  10. data/lib/dragonfly/image_magick/generators/plain.rb +13 -7
  11. data/lib/dragonfly/image_magick/generators/plasma.rb +10 -6
  12. data/lib/dragonfly/image_magick/generators/text.rb +67 -58
  13. data/lib/dragonfly/image_magick/plugin.rb +26 -25
  14. data/lib/dragonfly/image_magick/processors/encode.rb +16 -5
  15. data/lib/dragonfly/image_magick/processors/thumb.rb +37 -31
  16. data/lib/dragonfly/job/fetch_url.rb +1 -1
  17. data/lib/dragonfly/model/class_methods.rb +6 -1
  18. data/lib/dragonfly/param_validators.rb +37 -0
  19. data/lib/dragonfly/response.rb +2 -2
  20. data/lib/dragonfly/shell.rb +19 -13
  21. data/lib/dragonfly/utils.rb +1 -1
  22. data/lib/dragonfly/version.rb +1 -1
  23. data/samples/white pixel.png b/data/samples/mevs' white → pixel.png +0 -0
  24. data/spec/dragonfly/content_spec.rb +3 -3
  25. data/spec/dragonfly/cookie_monster_spec.rb +2 -2
  26. data/spec/dragonfly/image_magick/commands_spec.rb +98 -0
  27. data/spec/dragonfly/image_magick/generators/plain_spec.rb +39 -13
  28. data/spec/dragonfly/image_magick/generators/plasma_spec.rb +28 -9
  29. data/spec/dragonfly/image_magick/generators/text_spec.rb +51 -20
  30. data/spec/dragonfly/image_magick/plugin_spec.rb +45 -28
  31. data/spec/dragonfly/image_magick/processors/encode_spec.rb +30 -0
  32. data/spec/dragonfly/image_magick/processors/thumb_spec.rb +46 -45
  33. data/spec/dragonfly/model/active_record_spec.rb +62 -0
  34. data/spec/dragonfly/param_validators_spec.rb +89 -0
  35. data/spec/dragonfly/shell_spec.rb +12 -10
  36. data/spec/dragonfly_spec.rb +37 -13
  37. data/spec/functional/shell_commands_spec.rb +6 -9
  38. data/spec/spec_helper.rb +12 -14
  39. metadata +45 -14
  40. data/lib/dragonfly/image_magick/generators/convert.rb +0 -19
  41. data/lib/dragonfly/image_magick/processors/convert.rb +0 -33
  42. data/spec/dragonfly/image_magick/generators/convert_spec.rb +0 -19
  43. data/spec/dragonfly/image_magick/processors/convert_spec.rb +0 -88
@@ -1,11 +1,11 @@
1
- require 'dragonfly/image_magick/analysers/image_properties'
2
- require 'dragonfly/image_magick/generators/convert'
3
- require 'dragonfly/image_magick/generators/plain'
4
- require 'dragonfly/image_magick/generators/plasma'
5
- require 'dragonfly/image_magick/generators/text'
6
- require 'dragonfly/image_magick/processors/convert'
7
- require 'dragonfly/image_magick/processors/encode'
8
- require 'dragonfly/image_magick/processors/thumb'
1
+ require "dragonfly/image_magick/analysers/image_properties"
2
+ require "dragonfly/image_magick/generators/plain"
3
+ require "dragonfly/image_magick/generators/plasma"
4
+ require "dragonfly/image_magick/generators/text"
5
+ require "dragonfly/image_magick/processors/encode"
6
+ require "dragonfly/image_magick/processors/thumb"
7
+ require "dragonfly/image_magick/commands"
8
+ require "dragonfly/param_validators"
9
9
 
10
10
  module Dragonfly
11
11
  module ImageMagick
@@ -13,37 +13,36 @@ module Dragonfly
13
13
  # The ImageMagick Plugin registers an app with generators, analysers and processors.
14
14
  # Look at the source code for #call to see exactly how it configures the app.
15
15
  class Plugin
16
-
17
- def call(app, opts={})
16
+ def call(app, opts = {})
18
17
  # ENV
19
- app.env[:convert_command] = opts[:convert_command] || 'convert'
20
- app.env[:identify_command] = opts[:identify_command] || 'identify'
18
+ app.env[:convert_command] = opts[:convert_command] || "convert"
19
+ app.env[:identify_command] = opts[:identify_command] || "identify"
21
20
 
22
21
  # Analysers
23
22
  app.add_analyser :image_properties, ImageMagick::Analysers::ImageProperties.new
24
23
  app.add_analyser :width do |content|
25
- content.analyse(:image_properties)['width']
24
+ content.analyse(:image_properties)["width"]
26
25
  end
27
26
  app.add_analyser :height do |content|
28
- content.analyse(:image_properties)['height']
27
+ content.analyse(:image_properties)["height"]
29
28
  end
30
29
  app.add_analyser :format do |content|
31
- content.analyse(:image_properties)['format']
30
+ content.analyse(:image_properties)["format"]
32
31
  end
33
32
  app.add_analyser :aspect_ratio do |content|
34
33
  attrs = content.analyse(:image_properties)
35
- attrs['width'].to_f / attrs['height']
34
+ attrs["width"].to_f / attrs["height"]
36
35
  end
37
36
  app.add_analyser :portrait do |content|
38
37
  attrs = content.analyse(:image_properties)
39
- attrs['width'] <= attrs['height']
38
+ attrs["width"] <= attrs["height"]
40
39
  end
41
40
  app.add_analyser :landscape do |content|
42
41
  !content.analyse(:portrait)
43
42
  end
44
43
  app.add_analyser :image do |content|
45
44
  begin
46
- content.analyse(:image_properties)['format'] != 'pdf'
45
+ content.analyse(:image_properties)["format"] != "pdf"
47
46
  rescue Shell::CommandFailed
48
47
  false
49
48
  end
@@ -55,29 +54,31 @@ module Dragonfly
55
54
  app.define(:image?) { image }
56
55
 
57
56
  # Generators
58
- app.add_generator :convert, ImageMagick::Generators::Convert.new
59
57
  app.add_generator :plain, ImageMagick::Generators::Plain.new
60
58
  app.add_generator :plasma, ImageMagick::Generators::Plasma.new
61
59
  app.add_generator :text, ImageMagick::Generators::Text.new
60
+ app.add_generator :convert do
61
+ raise "The convert generator is deprecated for better security - use Dragonfly::ImageMagick::Commands.generate(content, args, format) instead."
62
+ end
62
63
 
63
64
  # Processors
64
- app.add_processor :convert, Processors::Convert.new
65
65
  app.add_processor :encode, Processors::Encode.new
66
66
  app.add_processor :thumb, Processors::Thumb.new
67
67
  app.add_processor :rotate do |content, amount|
68
- content.process!(:convert, "-rotate #{amount}")
68
+ ParamValidators.validate!(amount, &ParamValidators.is_number)
69
+ Commands.convert(content, "-rotate #{amount}")
70
+ end
71
+ app.add_processor :convert do
72
+ raise "The convert processor is deprecated for better security - use Dragonfly::ImageMagick::Commands.convert(content, args, opts) instead."
69
73
  end
70
74
 
71
75
  # Extra methods
72
- app.define :identify do |cli_args=nil|
76
+ app.define :identify do |cli_args = nil|
73
77
  shell_eval do |path|
74
78
  "#{app.env[:identify_command]} #{cli_args} #{path}"
75
79
  end
76
80
  end
77
-
78
81
  end
79
-
80
82
  end
81
83
  end
82
84
  end
83
-
@@ -1,18 +1,29 @@
1
+ require "dragonfly/image_magick/commands"
2
+
1
3
  module Dragonfly
2
4
  module ImageMagick
3
5
  module Processors
4
6
  class Encode
7
+ include ParamValidators
8
+
9
+ WHITELISTED_ARGS = %w(quality)
10
+
11
+ IS_IN_WHITELISTED_ARGS = ->(args_string) {
12
+ args_string.scan(/-\w+/).all? { |arg|
13
+ WHITELISTED_ARGS.include?(arg.sub("-", ""))
14
+ }
15
+ }
5
16
 
6
- def update_url(attrs, format, args="")
17
+ def update_url(attrs, format, args = "")
7
18
  attrs.ext = format.to_s
8
19
  end
9
20
 
10
- def call(content, format, args="")
11
- content.process!(:convert, args, 'format' => format)
21
+ def call(content, format, args = "")
22
+ validate!(format, &is_word)
23
+ validate!(args, &IS_IN_WHITELISTED_ARGS)
24
+ Commands.convert(content, args, "format" => format)
12
25
  end
13
-
14
26
  end
15
27
  end
16
28
  end
17
29
  end
18
-
@@ -1,32 +1,40 @@
1
+ require "dragonfly/image_magick/commands"
2
+
1
3
  module Dragonfly
2
4
  module ImageMagick
3
5
  module Processors
4
6
  class Thumb
7
+ include ParamValidators
5
8
 
6
9
  GRAVITIES = {
7
- 'nw' => 'NorthWest',
8
- 'n' => 'North',
9
- 'ne' => 'NorthEast',
10
- 'w' => 'West',
11
- 'c' => 'Center',
12
- 'e' => 'East',
13
- 'sw' => 'SouthWest',
14
- 's' => 'South',
15
- 'se' => 'SouthEast'
10
+ "nw" => "NorthWest",
11
+ "n" => "North",
12
+ "ne" => "NorthEast",
13
+ "w" => "West",
14
+ "c" => "Center",
15
+ "e" => "East",
16
+ "sw" => "SouthWest",
17
+ "s" => "South",
18
+ "se" => "SouthEast",
16
19
  }
17
20
 
18
21
  # Geometry string patterns
19
- RESIZE_GEOMETRY = /\A\d*x\d*[><%^!]?\z|\A\d+@\z/ # e.g. '300x200!'
22
+ RESIZE_GEOMETRY = /\A\d*x\d*[><%^!]?\z|\A\d+@\z/ # e.g. '300x200!'
20
23
  CROPPED_RESIZE_GEOMETRY = /\A(\d+)x(\d+)#(\w{1,2})?\z/ # e.g. '20x50#ne'
21
- CROP_GEOMETRY = /\A(\d+)x(\d+)([+-]\d+)?([+-]\d+)?(\w{1,2})?\z/ # e.g. '30x30+10+10'
24
+ CROP_GEOMETRY = /\A(\d+)x(\d+)([+-]\d+)?([+-]\d+)?(\w{1,2})?\z/ # e.g. '30x30+10+10'
22
25
 
23
- def update_url(url_attributes, geometry, opts={})
24
- format = opts['format']
26
+ def update_url(url_attributes, geometry, opts = {})
27
+ format = opts["format"]
25
28
  url_attributes.ext = format if format
26
29
  end
27
30
 
28
- def call(content, geometry, opts={})
29
- content.process!(:convert, args_for_geometry(geometry), opts)
31
+ def call(content, geometry, opts = {})
32
+ validate!(opts["format"], &is_word)
33
+ validate!(opts["frame"], &is_number)
34
+ Commands.convert(content, args_for_geometry(geometry), {
35
+ "format" => opts["format"],
36
+ "frame" => opts["frame"],
37
+ })
30
38
  end
31
39
 
32
40
  def args_for_geometry(geometry)
@@ -37,11 +45,11 @@ module Dragonfly
37
45
  resize_and_crop_args($1, $2, $3)
38
46
  when CROP_GEOMETRY
39
47
  crop_args(
40
- 'width' => $1,
41
- 'height' => $2,
42
- 'x' => $3,
43
- 'y' => $4,
44
- 'gravity' => $5
48
+ "width" => $1,
49
+ "height" => $2,
50
+ "x" => $3,
51
+ "y" => $4,
52
+ "gravity" => $5,
45
53
  )
46
54
  else raise ArgumentError, "Didn't recognise the geometry string #{geometry}"
47
55
  end
@@ -54,26 +62,24 @@ module Dragonfly
54
62
  end
55
63
 
56
64
  def crop_args(opts)
57
- raise ArgumentError, "you can't give a crop offset and gravity at the same time" if opts['x'] && opts['gravity']
65
+ raise ArgumentError, "you can't give a crop offset and gravity at the same time" if opts["x"] && opts["gravity"]
58
66
 
59
- width = opts['width']
60
- height = opts['height']
61
- gravity = GRAVITIES[opts['gravity']]
62
- x = "#{opts['x'] || 0}"
63
- x = '+' + x unless x[/\A[+-]/]
64
- y = "#{opts['y'] || 0}"
65
- y = '+' + y unless y[/\A[+-]/]
67
+ width = opts["width"]
68
+ height = opts["height"]
69
+ gravity = GRAVITIES[opts["gravity"]]
70
+ x = "#{opts["x"] || 0}"
71
+ x = "+" + x unless x[/\A[+-]/]
72
+ y = "#{opts["y"] || 0}"
73
+ y = "+" + y unless y[/\A[+-]/]
66
74
 
67
75
  "#{"-gravity #{gravity} " if gravity}-crop #{width}x#{height}#{x}#{y} +repage"
68
76
  end
69
77
 
70
78
  def resize_and_crop_args(width, height, gravity)
71
- gravity = GRAVITIES[gravity || 'c']
79
+ gravity = GRAVITIES[gravity || "c"]
72
80
  "-resize #{width}x#{height}^^ -gravity #{gravity} -crop #{width}x#{height}+0+0 +repage"
73
81
  end
74
-
75
82
  end
76
83
  end
77
84
  end
78
85
  end
79
-
@@ -91,7 +91,7 @@ module Dragonfly
91
91
  end
92
92
 
93
93
  def parse_url(url)
94
- URI.parse(url)
94
+ URI.parse(url.to_s)
95
95
  rescue URI::InvalidURIError
96
96
  begin
97
97
  encoded_uri = Addressable::URI.parse(url).normalize.to_s
@@ -30,7 +30,12 @@ module Dragonfly
30
30
 
31
31
  # Add callbacks
32
32
  before_save :save_dragonfly_attachments if respond_to?(:before_save)
33
- before_destroy :destroy_dragonfly_attachments if respond_to?(:before_destroy)
33
+ case
34
+ when respond_to?(:after_commit)
35
+ after_commit :destroy_dragonfly_attachments, on: :destroy
36
+ when respond_to?(:after_destroy)
37
+ after_destroy :destroy_dragonfly_attachments
38
+ end
34
39
 
35
40
  # Register the new attribute
36
41
  dragonfly_attachment_classes << new_dragonfly_attachment_class(attribute, app, config_block)
@@ -0,0 +1,37 @@
1
+ module Dragonfly
2
+ module ParamValidators
3
+ class InvalidParameter < RuntimeError; end
4
+
5
+ module_function
6
+
7
+ IS_NUMBER = ->(param) {
8
+ param.is_a?(Numeric) || /\A[\d\.]+\z/ === param
9
+ }
10
+
11
+ IS_WORD = ->(param) {
12
+ /\A\w+\z/ === param
13
+ }
14
+
15
+ IS_WORDS = ->(param) {
16
+ /\A[\w ]+\z/ === param
17
+ }
18
+
19
+ def is_number; IS_NUMBER; end
20
+ def is_word; IS_WORD; end
21
+ def is_words; IS_WORDS; end
22
+
23
+ def validate!(parameter, &validator)
24
+ return if parameter.nil?
25
+ raise InvalidParameter unless validator.(parameter)
26
+ end
27
+
28
+ def validate_all!(parameters, &validator)
29
+ parameters.each { |p| validate!(p, &validator) }
30
+ end
31
+
32
+ def validate_all_keys!(obj, keys, &validator)
33
+ parameters = keys.map { |key| obj[key] }
34
+ validate_all!(parameters, &validator)
35
+ end
36
+ end
37
+ end
@@ -1,4 +1,4 @@
1
- require 'uri'
1
+ require 'cgi'
2
2
  require 'rack'
3
3
 
4
4
  module Dragonfly
@@ -99,7 +99,7 @@ module Dragonfly
99
99
 
100
100
  def filename_string
101
101
  return unless job.name
102
- filename = request_from_msie? ? URI.encode(job.name) : job.name
102
+ filename = request_from_msie? ? CGI.escape(job.name) : job.name
103
103
  %(filename="#{filename}")
104
104
  end
105
105
 
@@ -15,14 +15,11 @@ module Dragonfly
15
15
  end
16
16
 
17
17
  def escape_args(args)
18
- args.shellsplit.map do |arg|
19
- quote arg.gsub(/\\?'/, %q('\\\\''))
20
- end.join(' ')
18
+ args.shellsplit.map{|arg| escape(arg) }.join(' ')
21
19
  end
22
20
 
23
- def quote(string)
24
- q = Dragonfly.running_on_windows? ? '"' : "'"
25
- q + string + q
21
+ def escape(string)
22
+ Shellwords.escape(string)
26
23
  end
27
24
 
28
25
  private
@@ -36,22 +33,31 @@ module Dragonfly
36
33
  def run_command(command)
37
34
  result = `#{command}`
38
35
  status = $?
39
- raise CommandFailed, "Command failed (#{command}) with exit status #{status.exitstatus}" unless status.success?
36
+ raise_command_failed!(command, status.exitstatus) unless status.success?
40
37
  result
38
+ rescue Errno::ENOENT => e
39
+ raise_command_failed!(command, nil, e.message)
41
40
  end
42
41
 
43
42
  else
44
43
 
45
44
  def run_command(command)
46
- Open3.popen3 command do |stdin, stdout, stderr, wait_thread|
47
- stdin.close_write # make sure it doesn't hang
48
- status = wait_thread.value
49
- raise CommandFailed, "Command failed (#{command}) with exit status #{status.exitstatus} and stderr #{stderr.read}" unless status.success?
50
- stdout.read
51
- end
45
+ stdout_str, stderr_str, status = Open3.capture3(command)
46
+ raise_command_failed!(command, status.exitstatus, stderr_str) unless status.success?
47
+ stdout_str
48
+ rescue Errno::ENOENT => e
49
+ raise_command_failed!(command, nil, e.message)
52
50
  end
53
51
 
54
52
  end
55
53
 
54
+ def raise_command_failed!(command, status=nil, error=nil)
55
+ raise CommandFailed, [
56
+ "Command failed: #{command}",
57
+ ("exit status: #{status}" if status),
58
+ ("error: #{error}" if error),
59
+ ].join(', ')
60
+ end
61
+
56
62
  end
57
63
  end
@@ -38,7 +38,7 @@ module Dragonfly
38
38
  end
39
39
 
40
40
  def uri_unescape(string)
41
- URI.unescape(string)
41
+ URI::DEFAULT_PARSER.unescape(string)
42
42
  end
43
43
 
44
44
  end
@@ -1,3 +1,3 @@
1
1
  module Dragonfly
2
- VERSION = '1.1.4'
2
+ VERSION = "1.4.0"
3
3
  end
@@ -232,7 +232,7 @@ describe Dragonfly::Content do
232
232
  path = p
233
233
  "cat #{path}"
234
234
  end.should == "big\nstuff"
235
- path.should == app.shell.quote(content.path)
235
+ path.should == app.shell.escape(content.path)
236
236
  end
237
237
 
238
238
  it "allows evaluating without escaping" do
@@ -253,8 +253,8 @@ describe Dragonfly::Content do
253
253
  new_path = n
254
254
  "cp #{o} #{n}"
255
255
  end.should == content
256
- old_path.should == app.shell.quote(original_path)
257
- new_path.should == app.shell.quote(content.path)
256
+ old_path.should == app.shell.escape(original_path)
257
+ new_path.should == app.shell.escape(content.path)
258
258
  content.data.should == "big\nstuff"
259
259
  end
260
260
 
@@ -7,7 +7,7 @@ describe Dragonfly::CookieMonster do
7
7
  def app(extra_env={})
8
8
  Rack::Builder.new do
9
9
  use Dragonfly::CookieMonster
10
- run proc{|env| env.merge!(extra_env); [200, {"Set-Cookie" => "blah", "Something" => "else"}, ["body here"]] }
10
+ run proc{|env| env.merge!(extra_env); [200, {"Set-Cookie" => "blah=thing", "Something" => "else"}, ["body here"]] }
11
11
  end
12
12
  end
13
13
 
@@ -15,7 +15,7 @@ describe Dragonfly::CookieMonster do
15
15
  response = Rack::MockRequest.new(app).get('')
16
16
  response.status.should == 200
17
17
  response.body.should == "body here"
18
- response.headers["Set-Cookie"].should == "blah"
18
+ response.headers["Set-Cookie"].should == "blah=thing"
19
19
  response.headers["Something"].should == "else"
20
20
  end
21
21
 
@@ -0,0 +1,98 @@
1
+ require "spec_helper"
2
+ require "dragonfly/image_magick/commands"
3
+
4
+ describe Dragonfly::ImageMagick::Commands do
5
+ include Dragonfly::ImageMagick::Commands
6
+
7
+ let(:app) { test_app }
8
+
9
+ def sample_content(name)
10
+ Dragonfly::Content.new(app, SAMPLES_DIR.join(name))
11
+ end
12
+
13
+ describe "convert" do
14
+ let(:image) { sample_content("beach.png") } # 280x355
15
+
16
+ it "should allow for general convert commands" do
17
+ convert(image, "-scale 56x71")
18
+ image.should have_width(56)
19
+ image.should have_height(71)
20
+ end
21
+
22
+ it "should allow for general convert commands with added format" do
23
+ convert(image, "-scale 56x71", "format" => "gif")
24
+ image.should have_width(56)
25
+ image.should have_height(71)
26
+ image.should have_format("gif")
27
+ image.meta["format"].should == "gif"
28
+ end
29
+
30
+ it "should work for commands with parenthesis" do
31
+ convert(image, "\\( +clone -sparse-color Barycentric '0,0 black 0,%[fx:h-1] white' -function polynomial 2,-2,0.5 \\) -compose Blur -set option:compose:args 15 -composite")
32
+ image.should have_width(280)
33
+ end
34
+
35
+ it "should work for files with spaces/apostrophes in the name" do
36
+ image = Dragonfly::Content.new(app, SAMPLES_DIR.join("mevs' white pixel.png"))
37
+ convert(image, "-resize 2x2!")
38
+ image.should have_width(2)
39
+ end
40
+
41
+ it "allows converting specific frames" do
42
+ gif = sample_content("gif.gif")
43
+ convert(gif, "-resize 50x50")
44
+ all_frames_size = gif.size
45
+
46
+ gif = sample_content("gif.gif")
47
+ convert(gif, "-resize 50x50", "frame" => 0)
48
+ one_frame_size = gif.size
49
+
50
+ one_frame_size.should < all_frames_size
51
+ end
52
+
53
+ it "accepts input arguments for convert commands" do
54
+ image2 = image.clone
55
+ convert(image, "")
56
+ convert(image2, "", "input_args" => "-extract 50x50+10+10")
57
+
58
+ image.should_not equal_image(image2)
59
+ image2.should have_width(50)
60
+ end
61
+
62
+ it "allows converting using specific delegates" do
63
+ expect {
64
+ convert(image, "", "format" => "jpg", "delegate" => "png")
65
+ }.to call_command(app.shell, %r{convert png:/[^']+?/beach\.png /[^']+?\.jpg})
66
+ end
67
+
68
+ it "maintains the mime_type meta if it exists already" do
69
+ convert(image, "-resize 10x")
70
+ image.meta["mime_type"].should be_nil
71
+
72
+ image.add_meta("mime_type" => "image/png")
73
+ convert(image, "-resize 5x")
74
+ image.meta["mime_type"].should == "image/png"
75
+ image.mime_type.should == "image/png" # sanity check
76
+ end
77
+
78
+ it "doesn't maintain the mime_type meta on format change" do
79
+ image.add_meta("mime_type" => "image/png")
80
+ convert(image, "", "format" => "gif")
81
+ image.meta["mime_type"].should be_nil
82
+ image.mime_type.should == "image/gif" # sanity check
83
+ end
84
+ end
85
+
86
+ describe "generate" do
87
+ let (:image) { Dragonfly::Content.new(app) }
88
+
89
+ before(:each) do
90
+ generate(image, "-size 1x1 xc:white", "png")
91
+ end
92
+
93
+ it { image.should have_width(1) }
94
+ it { image.should have_height(1) }
95
+ it { image.should have_format("png") }
96
+ it { image.meta.should == { "format" => "png" } }
97
+ end
98
+ end