mini_paperclip 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (39) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/ruby.yml +40 -0
  3. data/.gitignore +14 -0
  4. data/.rspec +3 -0
  5. data/CODE_OF_CONDUCT.md +74 -0
  6. data/Gemfile +12 -0
  7. data/LICENSE.txt +21 -0
  8. data/README.md +175 -0
  9. data/Rakefile +6 -0
  10. data/bin/console +14 -0
  11. data/bin/setup +8 -0
  12. data/gemfiles/rails_52.gemfile +10 -0
  13. data/gemfiles/rails_52.gemfile.lock +98 -0
  14. data/gemfiles/rails_60.gemfile +10 -0
  15. data/gemfiles/rails_60.gemfile.lock +98 -0
  16. data/lib/mini_paperclip.rb +41 -0
  17. data/lib/mini_paperclip/attachment.rb +190 -0
  18. data/lib/mini_paperclip/class_methods.rb +62 -0
  19. data/lib/mini_paperclip/config.rb +32 -0
  20. data/lib/mini_paperclip/interpolator.rb +41 -0
  21. data/lib/mini_paperclip/shoulda/matchers.rb +7 -0
  22. data/lib/mini_paperclip/shoulda/matchers/have_attached_file_matcher.rb +46 -0
  23. data/lib/mini_paperclip/shoulda/matchers/validate_attachment_content_type_matcher.rb +84 -0
  24. data/lib/mini_paperclip/shoulda/matchers/validate_attachment_geometry_matcher.rb +111 -0
  25. data/lib/mini_paperclip/shoulda/matchers/validate_attachment_presence_matcher.rb +52 -0
  26. data/lib/mini_paperclip/shoulda/matchers/validate_attachment_size_matcher.rb +62 -0
  27. data/lib/mini_paperclip/storage.rb +5 -0
  28. data/lib/mini_paperclip/storage/base.rb +39 -0
  29. data/lib/mini_paperclip/storage/filesystem.rb +34 -0
  30. data/lib/mini_paperclip/storage/s3.rb +49 -0
  31. data/lib/mini_paperclip/validators.rb +7 -0
  32. data/lib/mini_paperclip/validators/content_type_validator.rb +17 -0
  33. data/lib/mini_paperclip/validators/file_size_validator.rb +21 -0
  34. data/lib/mini_paperclip/validators/geometry_validator.rb +46 -0
  35. data/lib/mini_paperclip/validators/media_type_spoof_validator.rb +25 -0
  36. data/lib/mini_paperclip/validators/presence_validator.rb +13 -0
  37. data/lib/mini_paperclip/version.rb +5 -0
  38. data/mini_paperclip.gemspec +32 -0
  39. metadata +152 -0
@@ -0,0 +1,10 @@
1
+ source "https://rubygems.org"
2
+ gemspec path: '../'
3
+
4
+ gem "rake", "~> 12.0"
5
+ gem "rspec", "~> 3.0"
6
+ gem "rack-test"
7
+ gem "webmock"
8
+ gem "tapp"
9
+ gem 'activerecord', '~> 6.0.0'
10
+ gem "sqlite3"
@@ -0,0 +1,98 @@
1
+ PATH
2
+ remote: ..
3
+ specs:
4
+ mini_paperclip (0.1.0)
5
+ activemodel
6
+ activesupport
7
+ aws-sdk-s3
8
+ mimemagic
9
+ mini_magick
10
+
11
+ GEM
12
+ remote: https://rubygems.org/
13
+ specs:
14
+ activemodel (6.0.3.4)
15
+ activesupport (= 6.0.3.4)
16
+ activerecord (6.0.3.4)
17
+ activemodel (= 6.0.3.4)
18
+ activesupport (= 6.0.3.4)
19
+ activesupport (6.0.3.4)
20
+ concurrent-ruby (~> 1.0, >= 1.0.2)
21
+ i18n (>= 0.7, < 2)
22
+ minitest (~> 5.1)
23
+ tzinfo (~> 1.1)
24
+ zeitwerk (~> 2.2, >= 2.2.2)
25
+ addressable (2.7.0)
26
+ public_suffix (>= 2.0.2, < 5.0)
27
+ aws-eventstream (1.1.0)
28
+ aws-partitions (1.399.0)
29
+ aws-sdk-core (3.109.3)
30
+ aws-eventstream (~> 1, >= 1.0.2)
31
+ aws-partitions (~> 1, >= 1.239.0)
32
+ aws-sigv4 (~> 1.1)
33
+ jmespath (~> 1.0)
34
+ aws-sdk-kms (1.39.0)
35
+ aws-sdk-core (~> 3, >= 3.109.0)
36
+ aws-sigv4 (~> 1.1)
37
+ aws-sdk-s3 (1.85.0)
38
+ aws-sdk-core (~> 3, >= 3.109.0)
39
+ aws-sdk-kms (~> 1)
40
+ aws-sigv4 (~> 1.1)
41
+ aws-sigv4 (1.2.2)
42
+ aws-eventstream (~> 1, >= 1.0.2)
43
+ concurrent-ruby (1.1.7)
44
+ crack (0.4.4)
45
+ diff-lcs (1.4.4)
46
+ hashdiff (1.0.1)
47
+ i18n (1.8.5)
48
+ concurrent-ruby (~> 1.0)
49
+ jmespath (1.4.0)
50
+ mimemagic (0.3.5)
51
+ mini_magick (4.11.0)
52
+ minitest (5.14.2)
53
+ public_suffix (4.0.6)
54
+ rack (2.2.3)
55
+ rack-test (1.1.0)
56
+ rack (>= 1.0, < 3)
57
+ rake (12.3.3)
58
+ rspec (3.10.0)
59
+ rspec-core (~> 3.10.0)
60
+ rspec-expectations (~> 3.10.0)
61
+ rspec-mocks (~> 3.10.0)
62
+ rspec-core (3.10.0)
63
+ rspec-support (~> 3.10.0)
64
+ rspec-expectations (3.10.0)
65
+ diff-lcs (>= 1.2.0, < 2.0)
66
+ rspec-support (~> 3.10.0)
67
+ rspec-mocks (3.10.0)
68
+ diff-lcs (>= 1.2.0, < 2.0)
69
+ rspec-support (~> 3.10.0)
70
+ rspec-support (3.10.0)
71
+ sqlite3 (1.4.2)
72
+ tapp (1.5.1)
73
+ thor
74
+ thor (1.0.1)
75
+ thread_safe (0.3.6)
76
+ tzinfo (1.2.8)
77
+ thread_safe (~> 0.1)
78
+ webmock (3.10.0)
79
+ addressable (>= 2.3.6)
80
+ crack (>= 0.3.2)
81
+ hashdiff (>= 0.4.0, < 2.0.0)
82
+ zeitwerk (2.4.1)
83
+
84
+ PLATFORMS
85
+ ruby
86
+
87
+ DEPENDENCIES
88
+ activerecord (~> 6.0.0)
89
+ mini_paperclip!
90
+ rack-test
91
+ rake (~> 12.0)
92
+ rspec (~> 3.0)
93
+ sqlite3
94
+ tapp
95
+ webmock
96
+
97
+ BUNDLED WITH
98
+ 2.1.4
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+ require "net/http"
5
+ require "active_model/validator"
6
+ require "active_support"
7
+ require "active_support/number_helper"
8
+ require "mini_magick"
9
+ require "mimemagic"
10
+ require "aws-sdk-s3"
11
+
12
+ require "mini_paperclip/attachment"
13
+ require "mini_paperclip/class_methods"
14
+ require "mini_paperclip/config"
15
+ require "mini_paperclip/interpolator"
16
+ require "mini_paperclip/storage"
17
+ require "mini_paperclip/validators"
18
+ require "mini_paperclip/version"
19
+
20
+ module MiniPaperclip
21
+ class << self
22
+ def config
23
+ @config ||= Config.new(
24
+ # defaults
25
+ interpolates: {
26
+ /:class/ => ->(*) { class_result },
27
+ /:attachment/ => ->(*) { attachment_result },
28
+ /:hash/ => ->(_, style) { hash_key(style) },
29
+ /:extension/ => ->(*) { File.extname(@record.read_attribute("#{@attachment_name}_file_name"))[1..-1] },
30
+ /:id/ => ->(*) { @record.id },
31
+ /:updated_at/ => ->(*) { @record.read_attribute("#{@attachment_name}_updated_at").to_i },
32
+ /:style/ => ->(_, style) { style }
33
+ },
34
+ hash_data: ":class/:attribute/:id/:style/:updated_at",
35
+ url_missing_path: ":attachment/:style/missing.png",
36
+ read_timeout: 60,
37
+ logger: Logger.new($stdout),
38
+ )
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,190 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MiniPaperclip
4
+ class Attachment
5
+ UnsupporedError = Class.new(StandardError)
6
+
7
+ attr_reader :record, :attachment_name, :config, :storage,
8
+ :waiting_write_file, :meta_content_type
9
+
10
+ def initialize(record, attachment_name, overwrite_config = {})
11
+ @record = record
12
+ @attachment_name = attachment_name
13
+ @config = MiniPaperclip.config.merge(overwrite_config)
14
+ @waiting_write_file = nil
15
+ @meta_content_type = nil
16
+ @dirty = false
17
+ @storage = Storage.const_get(@config.storage.to_s.camelcase)
18
+ .new(record, attachment_name, @config)
19
+ end
20
+
21
+ def original_filename
22
+ @record.read_attribute("#{@attachment_name}_file_name")
23
+ end
24
+
25
+ def content_type
26
+ @record.read_attribute("#{@attachment_name}_content_type")
27
+ end
28
+
29
+ def size
30
+ @record.read_attribute("#{@attachment_name}_file_size")
31
+ end
32
+
33
+ def updated_at
34
+ @record.read_attribute("#{@attachment_name}_updated_at").to_i
35
+ end
36
+
37
+ def file?
38
+ original_filename.present?
39
+ end
40
+ alias_method :present?, :file?
41
+
42
+ def blank?
43
+ !file?
44
+ end
45
+
46
+ def exists?(style = :original)
47
+ file? && @storage.exists?(style)
48
+ end
49
+
50
+ def url(style = :original)
51
+ @storage.url_for_read(style)
52
+ end
53
+
54
+ def dirty?
55
+ @dirty
56
+ end
57
+
58
+ def assign(file)
59
+ @dirty = true
60
+ @waiting_copy_attachment = nil
61
+ @waiting_write_file = nil
62
+ @meta_content_type = nil
63
+
64
+ if file.nil?
65
+ # clear
66
+ @record.write_attribute("#{@attachment_name}_file_name", nil)
67
+ @record.write_attribute("#{@attachment_name}_content_type", nil)
68
+ @record.write_attribute("#{@attachment_name}_file_size", nil)
69
+ @record.write_attribute("#{@attachment_name}_updated_at", nil)
70
+ elsif file.instance_of?(Attachment)
71
+ # copy
72
+ @record.write_attribute("#{@attachment_name}_file_name", file.record.read_attribute("#{@attachment_name}_file_name"))
73
+ @record.write_attribute("#{@attachment_name}_content_type", file.record.read_attribute("#{@attachment_name}_content_type"))
74
+ @record.write_attribute("#{@attachment_name}_file_size", file.record.read_attribute("#{@attachment_name}_file_size"))
75
+ @record.write_attribute("#{@attachment_name}_updated_at", Time.current)
76
+ @waiting_copy_attachment = file
77
+ elsif file.respond_to?(:original_filename)
78
+ # e.g. ActionDispatch::Http::UploadedFile
79
+ @record.write_attribute("#{@attachment_name}_file_name", file.original_filename)
80
+ @record.write_attribute("#{@attachment_name}_content_type", strict_content_type(file.to_io))
81
+ @record.write_attribute("#{@attachment_name}_file_size", file.size)
82
+ @record.write_attribute("#{@attachment_name}_updated_at", Time.current)
83
+ @waiting_write_file = build_tempfile(file.tap(&:rewind))
84
+ @meta_content_type = file.content_type
85
+ elsif file.respond_to?(:path)
86
+ # e.g. File
87
+ @record.write_attribute("#{@attachment_name}_file_name", File.basename(file.path))
88
+ @record.write_attribute("#{@attachment_name}_content_type", strict_content_type(file))
89
+ @record.write_attribute("#{@attachment_name}_file_size", file.size)
90
+ @record.write_attribute("#{@attachment_name}_updated_at", Time.current)
91
+ @waiting_write_file = build_tempfile(file.tap(&:rewind))
92
+ elsif file.instance_of?(String)
93
+ if file.empty?
94
+ # do nothing
95
+ elsif file.start_with?('http')
96
+ # download from url
97
+ open_uri_option = {
98
+ read_timeout: MiniPaperclip.config.read_timeout || 60
99
+ }
100
+ uri = URI.parse(file)
101
+ uri.open(open_uri_option) do |io|
102
+ @record.write_attribute("#{@attachment_name}_file_name", File.basename(uri.path))
103
+ @record.write_attribute("#{@attachment_name}_content_type", strict_content_type(io))
104
+ @record.write_attribute("#{@attachment_name}_file_size", io.size)
105
+ @record.write_attribute("#{@attachment_name}_updated_at", Time.current)
106
+ @waiting_write_file = build_tempfile(io.tap(&:rewind))
107
+ @meta_content_type = io.meta["content-type"]
108
+ end
109
+ elsif file.start_with?('data:')
110
+ # data-uri
111
+ match_data = file.match(/\Adata:([-\w]+\/[-\w\+\.]+)?;base64,(.*)/m)
112
+ if match_data.nil?
113
+ raise UnsupporedError, "attachment for \"#{file[0..100]}\" is not supported"
114
+ end
115
+ raw = Base64.decode64(match_data[2])
116
+ @record.write_attribute("#{@attachment_name}_file_name", nil)
117
+ @record.write_attribute("#{@attachment_name}_content_type", strict_content_type(StringIO.new(raw)))
118
+ @record.write_attribute("#{@attachment_name}_file_size", raw.bytesize)
119
+ @record.write_attribute("#{@attachment_name}_updated_at", Time.current)
120
+ @waiting_write_file = build_tempfile(StringIO.new(raw))
121
+ @meta_content_type = match_data[1]
122
+ else
123
+ raise UnsupporedError, "attachment for \"#{file[0..100]}\" is not supported"
124
+ end
125
+ else
126
+ raise UnsupporedError, "attachment for #{file.class} is not supported"
127
+ end
128
+ end
129
+
130
+ def process_and_store
131
+ return unless file?
132
+
133
+ if @waiting_copy_attachment
134
+ debug("start attachment copy")
135
+ @storage.copy(:original, @waiting_copy_attachment)
136
+ @config.styles&.each do |style, size_arg|
137
+ @storage.copy(style, @waiting_copy_attachment)
138
+ end
139
+ @waiting_copy_attachment = nil
140
+ return
141
+ end
142
+
143
+ return if @waiting_write_file.nil?
144
+
145
+ begin
146
+ debug("start attachment styles process")
147
+ @storage.write(:original, @waiting_write_file)
148
+ @config.styles&.each do |style, size_arg|
149
+ Tempfile.create([style.to_s, File.extname(@waiting_write_file.path)]) do |temp|
150
+ MiniMagick::Tool::Convert.new do |convert|
151
+ convert << @waiting_write_file.path
152
+ if size_arg.end_with?('#')
153
+ # crop option
154
+ convert.resize("#{size_arg[0..-2]}^")
155
+ convert.gravity("center")
156
+ convert.extent(size_arg[0..-2])
157
+ else
158
+ convert.resize(size_arg)
159
+ end
160
+ convert << temp.path
161
+ end
162
+ @storage.write(style, temp)
163
+ end
164
+ end
165
+ ensure
166
+ @waiting_write_file.close!
167
+ end
168
+ @waiting_write_file = nil
169
+ end
170
+
171
+ private
172
+
173
+ def strict_content_type(io)
174
+ MimeMagic.by_magic(io)&.type
175
+ end
176
+
177
+ def build_tempfile(io)
178
+ temp = Tempfile.new(['MiniPaperclip'])
179
+ temp.binmode
180
+ debug("copying by tempfile from:#{io.class} to:#{temp.path}")
181
+ IO.copy_stream(io, temp)
182
+ temp.rewind
183
+ temp
184
+ end
185
+
186
+ def debug(str)
187
+ MiniPaperclip.config.logger.debug(str)
188
+ end
189
+ end
190
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MiniPaperclip
4
+ module ClassMethods
5
+ def has_attached_file(attachment_name, option_config = {})
6
+ define_method(attachment_name) do
7
+ instance_variable_get("@#{attachment_name}") or
8
+ instance_variable_set("@#{attachment_name}", Attachment.new(self, attachment_name, option_config))
9
+ end
10
+ define_method("#{attachment_name}=") do |file|
11
+ a = Attachment.new(self, attachment_name, option_config)
12
+ a.assign(file)
13
+ instance_variable_set("@#{attachment_name}", a)
14
+ end
15
+ after_save do
16
+ if valid?
17
+ instance_variable_get("@#{attachment_name}")&.tap do |a|
18
+ a.dirty? && a.process_and_store
19
+ end
20
+ end
21
+ end
22
+ validates_with Validators::MediaTypeSpoofValidator, {
23
+ attributes: attachment_name,
24
+ if: -> { instance_variable_get("@#{attachment_name}")&.dirty? }
25
+ }
26
+ end
27
+
28
+ def validates_attachment(attachment_name, content_type: nil, size: nil, presence: nil, geometry: nil, **opts)
29
+ if content_type
30
+ validates_with Validators::ContentTypeValidator, {
31
+ attributes: attachment_name.to_sym,
32
+ **content_type,
33
+ **opts,
34
+ }
35
+ end
36
+
37
+ if size
38
+ validates_with Validators::FileSizeValidator, {
39
+ attributes: attachment_name.to_sym,
40
+ **size,
41
+ **opts,
42
+ }
43
+ end
44
+
45
+ if !presence.nil?
46
+ validates_with Validators::PresenceValidator, {
47
+ attributes: attachment_name.to_sym,
48
+ presence: presence,
49
+ **opts,
50
+ }
51
+ end
52
+
53
+ if geometry
54
+ validates_with Validators::GeometryValidator, {
55
+ attributes: attachment_name.to_sym,
56
+ **geometry,
57
+ **opts,
58
+ }
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MiniPaperclip
4
+ class Config < Struct.new(
5
+ :storage,
6
+ :filesystem_path,
7
+ :hash_secret,
8
+ :hash_data,
9
+ :styles,
10
+ :url_scheme,
11
+ :url_host,
12
+ :url_path,
13
+ :url_missing_path,
14
+ :s3_host_alias,
15
+ :s3_bucket_name,
16
+ :s3_acl,
17
+ :s3_cache_control,
18
+ :interpolates,
19
+ :read_timeout,
20
+ :logger,
21
+ keyword_init: true,
22
+ )
23
+ def merge(hash)
24
+ dup.merge!(hash)
25
+ end
26
+
27
+ def merge!(hash)
28
+ to_h.deep_merge(hash).each { |k, v| self[k] = v }
29
+ self
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MiniPaperclip
4
+ class Interpolator
5
+ def initialize(record, attachment_name, config)
6
+ @record = record
7
+ @attachment_name = attachment_name
8
+ @config = config
9
+ end
10
+
11
+ def interpolate(template, style)
12
+ template.dup.tap do |t|
13
+ @config.interpolates&.each do |matcher, block|
14
+ t.gsub!(matcher) { instance_exec(attachment, style, &block) }
15
+ end
16
+ end
17
+ end
18
+
19
+ private
20
+
21
+ def class_result
22
+ @record.class.name.underscore.pluralize
23
+ end
24
+
25
+ def attachment_result
26
+ @attachment_name.to_s.downcase.pluralize
27
+ end
28
+
29
+ def attachment
30
+ @record.public_send(@attachment_name)
31
+ end
32
+
33
+ def hash_key(style)
34
+ OpenSSL::HMAC.hexdigest(
35
+ OpenSSL::Digest::SHA1.new,
36
+ @config.hash_secret,
37
+ interpolate(@config.hash_data, style),
38
+ )
39
+ end
40
+ end
41
+ end