shrine 2.5.0 → 2.6.0
Sign up to get free protection for your applications and to get access to all the features.
Potentially problematic release.
This version of shrine might be problematic. Click here for more details.
- checksums.yaml +4 -4
- data/README.md +14 -13
- data/doc/attacher.md +7 -6
- data/doc/carrierwave.md +19 -17
- data/doc/design.md +1 -1
- data/doc/direct_s3.md +8 -5
- data/doc/multiple_files.md +4 -4
- data/doc/paperclip.md +7 -6
- data/doc/refile.md +67 -4
- data/doc/securing_uploads.md +41 -25
- data/doc/testing.md +6 -15
- data/lib/shrine.rb +19 -10
- data/lib/shrine/plugins/activerecord.rb +4 -4
- data/lib/shrine/plugins/add_metadata.rb +7 -3
- data/lib/shrine/plugins/background_helpers.rb +1 -1
- data/lib/shrine/plugins/backgrounding.rb +19 -6
- data/lib/shrine/plugins/cached_attachment_data.rb +4 -4
- data/lib/shrine/plugins/data_uri.rb +105 -31
- data/lib/shrine/plugins/default_url.rb +1 -1
- data/lib/shrine/plugins/delete_raw.rb +7 -3
- data/lib/shrine/plugins/determine_mime_type.rb +96 -44
- data/lib/shrine/plugins/direct_upload.rb +3 -1
- data/lib/shrine/plugins/download_endpoint.rb +14 -5
- data/lib/shrine/plugins/logging.rb +4 -4
- data/lib/shrine/plugins/metadata_attributes.rb +61 -0
- data/lib/shrine/plugins/migration_helpers.rb +1 -1
- data/lib/shrine/plugins/rack_file.rb +54 -30
- data/lib/shrine/plugins/recache.rb +1 -1
- data/lib/shrine/plugins/refresh_metadata.rb +29 -0
- data/lib/shrine/plugins/remote_url.rb +26 -4
- data/lib/shrine/plugins/remove_invalid.rb +5 -4
- data/lib/shrine/plugins/restore_cached_data.rb +10 -13
- data/lib/shrine/plugins/sequel.rb +4 -4
- data/lib/shrine/plugins/signature.rb +146 -0
- data/lib/shrine/plugins/store_dimensions.rb +68 -24
- data/lib/shrine/plugins/validation_helpers.rb +48 -29
- data/lib/shrine/plugins/versions.rb +16 -8
- data/lib/shrine/storage/file_system.rb +27 -16
- data/lib/shrine/storage/s3.rb +99 -58
- data/lib/shrine/version.rb +1 -1
- data/shrine.gemspec +1 -1
- metadata +9 -6
@@ -20,7 +20,7 @@ class Shrine
|
|
20
20
|
# Recaching will be automatically triggered in a "before save" callback,
|
21
21
|
# but if you're using the attacher directly, you can call it manually:
|
22
22
|
#
|
23
|
-
# attacher.recache if attacher.
|
23
|
+
# attacher.recache if attacher.changed?
|
24
24
|
module Recache
|
25
25
|
module AttacherMethods
|
26
26
|
def save
|
@@ -0,0 +1,29 @@
|
|
1
|
+
class Shrine
|
2
|
+
module Plugins
|
3
|
+
# The `refresh_metadata` plugin allows you to re-extract metadata from an
|
4
|
+
# uploaded file.
|
5
|
+
#
|
6
|
+
# plugin :refresh_metadata
|
7
|
+
#
|
8
|
+
# It provides `UploadedFile#refresh_metadata!` method, which calls
|
9
|
+
# `Shrine#extract_metadata` with the uploaded file opened for reading,
|
10
|
+
# and updates the existing metadata hash with the results.
|
11
|
+
#
|
12
|
+
# uploaded_file.refresh_metadata!
|
13
|
+
# uploaded_file.metadata # re-extracted metadata
|
14
|
+
#
|
15
|
+
# For remote storages this will make an HTTP request to open the file for
|
16
|
+
# reading, but only the portion of the file needed for extracting each
|
17
|
+
# metadata value will be downloaded.
|
18
|
+
module RefreshMetadata
|
19
|
+
module FileMethods
|
20
|
+
def refresh_metadata!(context = {})
|
21
|
+
refreshed_metadata = open { uploader.extract_metadata(self, context) }
|
22
|
+
metadata.merge!(refreshed_metadata)
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
register_plugin(:refresh_metadata, RefreshMetadata)
|
28
|
+
end
|
29
|
+
end
|
@@ -21,8 +21,12 @@ class Shrine
|
|
21
21
|
# attacher.remote_url = "http://example.com/cool-image.png"
|
22
22
|
#
|
23
23
|
# The file will by default be downloaded using [Down], which is a wrapper
|
24
|
-
# around the open-uri standard library.
|
25
|
-
#
|
24
|
+
# around the `open-uri` standard library. Note that Down expects the given
|
25
|
+
# URL to be URI-encoded.
|
26
|
+
#
|
27
|
+
# ## Maximum size
|
28
|
+
#
|
29
|
+
# It's a good practice to limit the maximum filesize of the remote file:
|
26
30
|
#
|
27
31
|
# plugin :remote_url, max_size: 20*1024*1024 # 20 MB
|
28
32
|
#
|
@@ -34,20 +38,38 @@ class Shrine
|
|
34
38
|
#
|
35
39
|
# plugin :remote_url, max_size: nil
|
36
40
|
#
|
37
|
-
#
|
38
|
-
#
|
41
|
+
# ## Custom downloader
|
42
|
+
#
|
43
|
+
# If you want to customize how the file is downloaded, you can override the
|
44
|
+
# `:downloader` parameter and provide your own implementation. For example,
|
45
|
+
# you can ensure the URL is URI-encoded (using the [Addressable] gem) before
|
46
|
+
# passing it to Down:
|
47
|
+
#
|
48
|
+
# require "down"
|
49
|
+
# require "addressable/uri"
|
39
50
|
#
|
40
51
|
# plugin :remote_url, max_size: 20*1024*1024, downloader: ->(url, max_size:) do
|
52
|
+
# url = Addressable::URI.encode(Addressable::URI.decode(url))
|
41
53
|
# Down.download(url, max_size: max_size, max_redirects: 4, read_timeout: 3)
|
42
54
|
# end
|
43
55
|
#
|
56
|
+
# ## Errors
|
57
|
+
#
|
44
58
|
# If download errors, the error is rescued and a validation error is added
|
45
59
|
# equal to the error message. You can change the default error message:
|
46
60
|
#
|
47
61
|
# plugin :remote_url, error_message: "download failed"
|
48
62
|
# plugin :remote_url, error_message: ->(url, error) { I18n.t("errors.download_failed") }
|
49
63
|
#
|
64
|
+
# ## Background
|
65
|
+
#
|
66
|
+
# If you want the file to be downloaded from the URL in the background, you
|
67
|
+
# can use the [shrine-url] storage which allows you to assign a custom URL
|
68
|
+
# as cached file ID, and pair that with the `backgrounding` plugin.
|
69
|
+
#
|
50
70
|
# [Down]: https://github.com/janko-m/down
|
71
|
+
# [Addressable]: https://github.com/sporkmonger/addressable
|
72
|
+
# [shrine-url]: https://github.com/janko-m/shrine-url
|
51
73
|
module RemoteUrl
|
52
74
|
def self.configure(uploader, opts = {})
|
53
75
|
raise Error, "The :max_size option is required for remote_url plugin" if !opts.key?(:max_size) && !uploader.opts.key?(:remote_url_max_size)
|
@@ -1,8 +1,9 @@
|
|
1
1
|
class Shrine
|
2
2
|
module Plugins
|
3
|
-
# The `remove_invalid` plugin automatically deletes a
|
4
|
-
# invalid and deassigns it from the record. If there was a previous
|
5
|
-
# attached, it will be assigned back, otherwise
|
3
|
+
# The `remove_invalid` plugin automatically deletes a new assigned file if
|
4
|
+
# it was invalid and deassigns it from the record. If there was a previous
|
5
|
+
# file attached, it will be assigned back, otherwise no attachment will be
|
6
|
+
# assigned.
|
6
7
|
#
|
7
8
|
# plugin :remove_invalid
|
8
9
|
module RemoveInvalid
|
@@ -10,7 +11,7 @@ class Shrine
|
|
10
11
|
def validate
|
11
12
|
super
|
12
13
|
ensure
|
13
|
-
if errors.any? &&
|
14
|
+
if errors.any? && changed?
|
14
15
|
_delete(get, action: :validate)
|
15
16
|
_set(@old)
|
16
17
|
remove_instance_variable(:@old)
|
@@ -1,28 +1,25 @@
|
|
1
1
|
class Shrine
|
2
2
|
module Plugins
|
3
|
-
# The `restore_cached_data` plugin re-extracts
|
4
|
-
#
|
5
|
-
# errors
|
6
|
-
#
|
3
|
+
# The `restore_cached_data` plugin re-extracts metadata when assigning
|
4
|
+
# already cached files, i.e. when the attachment has been retained on
|
5
|
+
# validation errors or assigned from a direct upload. In both cases you may
|
6
|
+
# want to re-extract metadata on the server side, mainly to prevent
|
7
7
|
# tempering, but also in case of direct uploads to obtain metadata that
|
8
8
|
# couldn't be extracted on the client side.
|
9
9
|
#
|
10
10
|
# plugin :restore_cached_data
|
11
11
|
#
|
12
|
-
#
|
13
|
-
# remote storages this will make an HTTP request, and since metadata is
|
14
|
-
# typically found in the beginning of the file, Shrine will download only
|
15
|
-
# the amount of bytes necessary for extracting the metadata.
|
12
|
+
# It uses the `refresh_metadata` plugin to re-extract metadata.
|
16
13
|
module RestoreCachedData
|
14
|
+
def self.load_dependencies(uploader, *)
|
15
|
+
uploader.plugin :refresh_metadata
|
16
|
+
end
|
17
|
+
|
17
18
|
module AttacherMethods
|
18
19
|
private
|
19
20
|
|
20
21
|
def assign_cached(cached_file)
|
21
|
-
uploaded_file(cached_file)
|
22
|
-
real_metadata = file.open { cache.extract_metadata(file, context) }
|
23
|
-
file.metadata.update(real_metadata)
|
24
|
-
end
|
25
|
-
|
22
|
+
uploaded_file(cached_file) { |file| file.refresh_metadata!(context) }
|
26
23
|
super(cached_file)
|
27
24
|
end
|
28
25
|
end
|
@@ -22,7 +22,7 @@ class Shrine
|
|
22
22
|
# saves again with a stored attachment, you can detect this in callbacks:
|
23
23
|
#
|
24
24
|
# class User < Sequel::Model
|
25
|
-
# include ImageUploader
|
25
|
+
# include ImageUploader::Attachment.new(:avatar)
|
26
26
|
#
|
27
27
|
# def before_save
|
28
28
|
# super
|
@@ -48,7 +48,7 @@ class Shrine
|
|
48
48
|
# attachment, you can do it directly on the model.
|
49
49
|
#
|
50
50
|
# class User < Sequel::Model
|
51
|
-
# include ImageUploader
|
51
|
+
# include ImageUploader::Attachment.new(:avatar)
|
52
52
|
# validates_presence_of :avatar
|
53
53
|
# end
|
54
54
|
#
|
@@ -82,12 +82,12 @@ class Shrine
|
|
82
82
|
module_eval <<-RUBY, __FILE__, __LINE__ + 1 if opts[:sequel_callbacks]
|
83
83
|
def before_save
|
84
84
|
super
|
85
|
-
#{@name}_attacher.save if #{@name}_attacher.
|
85
|
+
#{@name}_attacher.save if #{@name}_attacher.changed?
|
86
86
|
end
|
87
87
|
|
88
88
|
def after_save
|
89
89
|
super
|
90
|
-
db.after_commit{#{@name}_attacher.finalize} if #{@name}_attacher.
|
90
|
+
db.after_commit{#{@name}_attacher.finalize} if #{@name}_attacher.changed?
|
91
91
|
end
|
92
92
|
|
93
93
|
def after_destroy
|
@@ -0,0 +1,146 @@
|
|
1
|
+
class Shrine
|
2
|
+
module Plugins
|
3
|
+
# The `signature` plugin provides the ability to calculate a hash from file
|
4
|
+
# content. The hash can then be used as a checksum or just as a unique
|
5
|
+
# signature of the file.
|
6
|
+
#
|
7
|
+
# plugin :signature
|
8
|
+
#
|
9
|
+
# The plugin adds a `calculate_signature` instance and class method to
|
10
|
+
# `Shrine`, which accepts the IO object and hashing algorithm and returns
|
11
|
+
# the calculated hash.
|
12
|
+
#
|
13
|
+
# Shrine.calculate_signature(io, :md5)
|
14
|
+
# #=> "9a0364b9e99bb480dd25e1f0284c8555"
|
15
|
+
#
|
16
|
+
# You can then use the `add_metadata` plugin to add a new metadata field
|
17
|
+
# with the calculated hash.
|
18
|
+
#
|
19
|
+
# plugin :add_metadata
|
20
|
+
#
|
21
|
+
# add_metadata :md5 do |io, context|
|
22
|
+
# calculate_signature(io, :md5)
|
23
|
+
# end
|
24
|
+
#
|
25
|
+
# This will generate a hash for each uploaded file, but if you want to
|
26
|
+
# generate one only for the original file, you can add a conditional:
|
27
|
+
#
|
28
|
+
# add_metadata :md5 do |io, context|
|
29
|
+
# calculate_signature(io, :md5) if context[:action] == :cache
|
30
|
+
# end
|
31
|
+
#
|
32
|
+
# The following hashing algorithms are supported:
|
33
|
+
#
|
34
|
+
# * `sha1`
|
35
|
+
# * `sha256`
|
36
|
+
# * `sha384`
|
37
|
+
# * `sha512`
|
38
|
+
# * `md5`
|
39
|
+
# * `crc32`
|
40
|
+
#
|
41
|
+
# You can also choose which format will the calculated hash be encoded in:
|
42
|
+
#
|
43
|
+
# Shrine.calculate_signature(io, :md5, format: :hex)
|
44
|
+
#
|
45
|
+
# The following encoding formats are supported:
|
46
|
+
#
|
47
|
+
# * `none`
|
48
|
+
# * `hex` (default)
|
49
|
+
# * `base64`
|
50
|
+
module Signature
|
51
|
+
module ClassMethods
|
52
|
+
# Calculates `algorithm` hash of the contents of the IO object, and
|
53
|
+
# encodes it into `format`.
|
54
|
+
def calculate_signature(io, algorithm, format: :hex)
|
55
|
+
algorithm = algorithm.downcase # support uppercase algorithm names like :MD5
|
56
|
+
SignatureCalculator.new(algorithm, format: format).call(io)
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
module InstanceMethods
|
61
|
+
# Calculates `algorithm` hash of the contents of the IO object, and
|
62
|
+
# encodes it into `format`.
|
63
|
+
def calculate_signature(io, algorithm, format: :hex)
|
64
|
+
self.class.calculate_signature(io, algorithm, format: format)
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
class SignatureCalculator
|
69
|
+
SUPPORTED_ALGORITHMS = [:sha1, :sha256, :sha384, :sha512, :md5, :crc32]
|
70
|
+
SUPPORTED_FORMATS = [:none, :hex, :base64]
|
71
|
+
|
72
|
+
attr_reader :algorithm, :format
|
73
|
+
|
74
|
+
def initialize(algorithm, format:)
|
75
|
+
raise ArgumentError, "hash algorithm not supported: #{algorithm}" unless SUPPORTED_ALGORITHMS.include?(algorithm)
|
76
|
+
raise ArgumentError, "hash format not supported: #{format}" unless SUPPORTED_FORMATS.include?(format)
|
77
|
+
|
78
|
+
@algorithm = algorithm
|
79
|
+
@format = format
|
80
|
+
end
|
81
|
+
|
82
|
+
def call(io)
|
83
|
+
hash = send(:"calculate_#{algorithm}", io)
|
84
|
+
io.rewind
|
85
|
+
|
86
|
+
encoded_hash = send(:"encode_#{format}", hash)
|
87
|
+
encoded_hash
|
88
|
+
end
|
89
|
+
|
90
|
+
private
|
91
|
+
|
92
|
+
def calculate_sha1(io)
|
93
|
+
calculate_digest(:SHA1, io)
|
94
|
+
end
|
95
|
+
|
96
|
+
def calculate_sha256(io)
|
97
|
+
calculate_digest(:SHA256, io)
|
98
|
+
end
|
99
|
+
|
100
|
+
def calculate_sha384(io)
|
101
|
+
calculate_digest(:SHA384, io)
|
102
|
+
end
|
103
|
+
|
104
|
+
def calculate_sha512(io)
|
105
|
+
calculate_digest(:SHA512, io)
|
106
|
+
end
|
107
|
+
|
108
|
+
def calculate_md5(io)
|
109
|
+
calculate_digest(:MD5, io)
|
110
|
+
end
|
111
|
+
|
112
|
+
def calculate_crc32(io)
|
113
|
+
require "zlib"
|
114
|
+
crc = 0
|
115
|
+
crc = Zlib.crc32(io.read(16*1024, buffer ||= ""), crc) until io.eof?
|
116
|
+
crc.to_s
|
117
|
+
end
|
118
|
+
|
119
|
+
def calculate_digest(name, io)
|
120
|
+
require "digest"
|
121
|
+
digest = Digest.const_get(name).new
|
122
|
+
digest.update(io.read(16*1024, buffer ||= "")) until io.eof?
|
123
|
+
digest.digest
|
124
|
+
end
|
125
|
+
|
126
|
+
def encode_none(hash)
|
127
|
+
hash
|
128
|
+
end
|
129
|
+
|
130
|
+
def encode_hex(hash)
|
131
|
+
hash.unpack("H*").first
|
132
|
+
end
|
133
|
+
|
134
|
+
def encode_base64(hash)
|
135
|
+
require "base64"
|
136
|
+
Base64.encode64(hash)
|
137
|
+
end
|
138
|
+
end
|
139
|
+
|
140
|
+
SUPPORTED_ALGORITHMS = SignatureCalculator::SUPPORTED_ALGORITHMS
|
141
|
+
SUPPORTED_FORMATS = SignatureCalculator::SUPPORTED_FORMATS
|
142
|
+
end
|
143
|
+
|
144
|
+
register_plugin(:signature, Signature)
|
145
|
+
end
|
146
|
+
end
|
@@ -1,7 +1,8 @@
|
|
1
1
|
class Shrine
|
2
2
|
module Plugins
|
3
3
|
# The `store_dimensions` plugin extracts and stores dimensions of the
|
4
|
-
# uploaded image using the [fastimage] gem
|
4
|
+
# uploaded image using the [fastimage] gem, which has built-in protection
|
5
|
+
# agains [image bombs].
|
5
6
|
#
|
6
7
|
# plugin :store_dimensions
|
7
8
|
#
|
@@ -18,15 +19,27 @@ class Shrine
|
|
18
19
|
# # or
|
19
20
|
# image.dimensions #=> [300, 500]
|
20
21
|
#
|
21
|
-
#
|
22
|
-
#
|
23
|
-
#
|
22
|
+
# You can provide your own custom dimensions analyzer, and reuse any of the
|
23
|
+
# built-in analyzers; you just need to return a two-element array of width
|
24
|
+
# and height, or nil to signal that dimensions weren't extracted.
|
25
|
+
#
|
26
|
+
# require "mini_magick"
|
24
27
|
#
|
25
28
|
# plugin :store_dimensions, analyzer: ->(io, analyzers) do
|
26
29
|
# dimensions = analyzers[:fastimage].call(io)
|
27
30
|
# dimensions || MiniMagick::Image.new(io).dimensions
|
28
31
|
# end
|
29
32
|
#
|
33
|
+
# You can also use methods for extracting the dimensions directly:
|
34
|
+
#
|
35
|
+
# # or YourUploader.extract_dimensions(io)
|
36
|
+
# Shrine.extract_dimensions(io) # calls the defined analyzer
|
37
|
+
# #=> [300, 400]
|
38
|
+
#
|
39
|
+
# # or YourUploader.dimensions_analyzers
|
40
|
+
# Shrine.dimensions_analyzers[:fastimage].call(io) # calls a built-in analyzer
|
41
|
+
# #=> [300, 400]
|
42
|
+
#
|
30
43
|
# [fastimage]: https://github.com/sdsykes/fastimage
|
31
44
|
# [image bombs]: https://www.bamsoftware.com/hacks/deflate.html
|
32
45
|
module StoreDimensions
|
@@ -34,6 +47,30 @@ class Shrine
|
|
34
47
|
uploader.opts[:dimensions_analyzer] = opts.fetch(:analyzer, uploader.opts.fetch(:dimensions_analyzer, :fastimage))
|
35
48
|
end
|
36
49
|
|
50
|
+
module ClassMethods
|
51
|
+
# Determines the dimensions of the IO object by calling the specified
|
52
|
+
# analyzer.
|
53
|
+
def extract_dimensions(io)
|
54
|
+
analyzer = opts[:dimensions_analyzer]
|
55
|
+
analyzer = dimensions_analyzers[analyzer] if analyzer.is_a?(Symbol)
|
56
|
+
args = [io, dimensions_analyzers].take(analyzer.arity.abs)
|
57
|
+
|
58
|
+
dimensions = analyzer.call(*args)
|
59
|
+
io.rewind
|
60
|
+
|
61
|
+
dimensions
|
62
|
+
end
|
63
|
+
|
64
|
+
# Returns a hash of built-in dimensions analyzers, where keys are
|
65
|
+
# analyzer names and values are `#call`-able objects which accepts the
|
66
|
+
# IO object.
|
67
|
+
def dimensions_analyzers
|
68
|
+
@dimensions_analyzers ||= DimensionsAnalyzer::SUPPORTED_TOOLS.inject({}) do |hash, tool|
|
69
|
+
hash.merge!(tool => DimensionsAnalyzer.new(tool).method(:call))
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|
73
|
+
|
37
74
|
module InstanceMethods
|
38
75
|
# We update the metadata with "width" and "height".
|
39
76
|
def extract_metadata(io, context)
|
@@ -47,30 +84,14 @@ class Shrine
|
|
47
84
|
|
48
85
|
private
|
49
86
|
|
50
|
-
#
|
51
|
-
# calls the predefined or custom analyzer.
|
87
|
+
# Extracts dimensions using the specified analyzer.
|
52
88
|
def extract_dimensions(io)
|
53
|
-
|
54
|
-
analyzer = dimensions_analyzers[analyzer] if analyzer.is_a?(Symbol)
|
55
|
-
args = [io, dimensions_analyzers].take(analyzer.arity.abs)
|
56
|
-
|
57
|
-
dimensions = analyzer.call(*args)
|
58
|
-
io.rewind
|
59
|
-
|
60
|
-
dimensions
|
89
|
+
self.class.extract_dimensions(io)
|
61
90
|
end
|
62
91
|
|
92
|
+
# Returns a hash of built-in dimensions analyzers.
|
63
93
|
def dimensions_analyzers
|
64
|
-
|
65
|
-
end
|
66
|
-
|
67
|
-
def _extract_dimensions_with_fastimage(io)
|
68
|
-
require "fastimage"
|
69
|
-
|
70
|
-
dimensions = FastImage.size(io)
|
71
|
-
io.rewind
|
72
|
-
|
73
|
-
dimensions
|
94
|
+
self.class.dimensions_analyzers
|
74
95
|
end
|
75
96
|
end
|
76
97
|
|
@@ -87,6 +108,29 @@ class Shrine
|
|
87
108
|
[width, height] if width || height
|
88
109
|
end
|
89
110
|
end
|
111
|
+
|
112
|
+
class DimensionsAnalyzer
|
113
|
+
SUPPORTED_TOOLS = [:fastimage]
|
114
|
+
|
115
|
+
def initialize(tool)
|
116
|
+
raise ArgumentError, "unsupported mime type analyzer tool: #{tool}" unless SUPPORTED_TOOLS.include?(tool)
|
117
|
+
|
118
|
+
@tool = tool
|
119
|
+
end
|
120
|
+
|
121
|
+
def call(io)
|
122
|
+
dimensions = send(:"extract_with_#{@tool}", io)
|
123
|
+
io.rewind
|
124
|
+
dimensions
|
125
|
+
end
|
126
|
+
|
127
|
+
private
|
128
|
+
|
129
|
+
def extract_with_fastimage(io)
|
130
|
+
require "fastimage"
|
131
|
+
FastImage.size(io)
|
132
|
+
end
|
133
|
+
end
|
90
134
|
end
|
91
135
|
|
92
136
|
register_plugin(:store_dimensions, StoreDimensions)
|