mini_paperclip 0.1.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 (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