refile 0.3.0 → 0.4.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (41) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +56 -0
  3. data/.travis.yml +11 -0
  4. data/Gemfile +1 -1
  5. data/History.md +15 -0
  6. data/README.md +41 -5
  7. data/Rakefile +4 -1
  8. data/config/locales/en.yml +2 -0
  9. data/config/routes.rb +4 -2
  10. data/lib/refile/app.rb +96 -71
  11. data/lib/refile/attacher.rb +115 -0
  12. data/lib/refile/attachment/active_record.rb +5 -5
  13. data/lib/refile/attachment.rb +50 -117
  14. data/lib/refile/backend/file_system.rb +3 -3
  15. data/lib/refile/backend/s3.rb +7 -12
  16. data/lib/refile/file.rb +1 -3
  17. data/lib/refile/image_processing.rb +6 -3
  18. data/lib/refile/rails/attachment_helper.rb +18 -21
  19. data/lib/refile/rails.rb +2 -1
  20. data/lib/refile/random_hasher.rb +9 -8
  21. data/lib/refile/signature.rb +16 -0
  22. data/lib/refile/version.rb +1 -1
  23. data/lib/refile.rb +59 -1
  24. data/refile.gemspec +10 -6
  25. data/spec/refile/app_spec.rb +28 -4
  26. data/spec/refile/attachment_spec.rb +134 -33
  27. data/spec/refile/backend_examples.rb +7 -11
  28. data/spec/refile/features/direct_upload_spec.rb +0 -1
  29. data/spec/refile/features/normal_upload_spec.rb +21 -0
  30. data/spec/refile/features/presigned_upload_spec.rb +0 -1
  31. data/spec/refile/rails/attachment_helper_spec.rb +61 -0
  32. data/spec/refile/spec_helper.rb +26 -20
  33. data/spec/refile/test_app/app/controllers/normal_posts_controller.rb +10 -1
  34. data/spec/refile/test_app/app/controllers/presigned_posts_controller.rb +1 -0
  35. data/spec/refile/test_app/app/models/post.rb +1 -1
  36. data/spec/refile/test_app/app/views/normal_posts/index.html +5 -0
  37. data/spec/refile/test_app/app/views/normal_posts/show.html.erb +2 -0
  38. data/spec/refile/test_app/config/routes.rb +1 -1
  39. data/spec/refile/test_app.rb +13 -6
  40. data/spec/refile_spec.rb +39 -0
  41. metadata +51 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: bd423a66e8cd66e1dbb128aad4724806341335ea
4
- data.tar.gz: 8eef6a2838a39c3d74b87fe03467905ea548145e
3
+ metadata.gz: 07f335bddd4f808d06e31efe59579f85ddaa635b
4
+ data.tar.gz: bb5c872f88e17f6756988f2c3cb0bd65ecac9843
5
5
  SHA512:
6
- metadata.gz: 7b2b30128fd4f7980a5b3e1b490fa47f17acf38c7bc458b0eec8a117856c90d087606341055ff1af77b9b37138849020a219c9cd59d6640ff30334595d4d6162
7
- data.tar.gz: 4a01443db981b687b7933463cfa5a1a1837f8e16470cfdbf4d4fb3bf9f609d2334afebbb803c8735cf1836626ef1e3e0ace656ceae19bbce49132fbea873b2af
6
+ metadata.gz: 277ff85d62eb22974311570c0f8245aec981ef1f9bc6e7f9086f8d0ee8890dd4af89154efba369acc52071d20f11641a0b54b2687cfd831b3139f6ef75d95a1a
7
+ data.tar.gz: 8f22cf639037cb0ed933f5a399abc1f9534dd8c4b6e60e76f9f55452653cb25679bd8e2c5e5655cabbae6d8fd00739526dfd55c9f60afd19b56c88228414bd8c
data/.rubocop.yml ADDED
@@ -0,0 +1,56 @@
1
+ Metrics/LineLength:
2
+ Max: 150 # TODO: we should decrease this to 120
3
+
4
+ Metrics/ClassLength:
5
+ Max: 300
6
+
7
+ Metrics/MethodLength:
8
+ Max: 25
9
+
10
+ Metrics/ParameterLists:
11
+ Max: 8
12
+
13
+ Metrics/AbcSize:
14
+ Max: 20
15
+
16
+ Metrics/CyclomaticComplexity:
17
+ Max: 7 # TODO: reduce
18
+
19
+ Style/AlignParameters:
20
+ EnforcedStyle: with_fixed_indentation
21
+
22
+ Style/StringLiterals:
23
+ EnforcedStyle: double_quotes
24
+
25
+ Style/StringLiteralsInInterpolation:
26
+ EnforcedStyle: double_quotes
27
+
28
+ Style/AndOr:
29
+ Enabled: false
30
+
31
+ Style/Not:
32
+ Enabled: false
33
+
34
+ Documentation:
35
+ Enabled: false # TODO: Enable again once we have more docs
36
+
37
+ Style/CaseIndentation:
38
+ IndentWhenRelativeTo: case
39
+ SupportedStyles:
40
+ - case
41
+ - end
42
+ IndentOneStep: true
43
+
44
+ Style/PercentLiteralDelimiters:
45
+ PreferredDelimiters:
46
+ '%w': "[]"
47
+ '%W': "[]"
48
+
49
+ Style/AccessModifierIndentation:
50
+ EnforcedStyle: outdent
51
+
52
+ Style/SignalException:
53
+ Enabled: false
54
+
55
+ Lint/EndAlignment:
56
+ AlignWith: variable
data/.travis.yml CHANGED
@@ -1,8 +1,19 @@
1
1
  language: ruby
2
+
2
3
  rvm:
4
+ - 2.1
3
5
  - 2.1.5
6
+ - ruby-head
7
+
4
8
  gemfile:
5
9
  - Gemfile
10
+
11
+ sudo: false
12
+
6
13
  before_script:
7
14
  - export DISPLAY=:99.0
8
15
  - sh -e /etc/init.d/xvfb start
16
+
17
+ matrix:
18
+ allow_failures:
19
+ - rvm: ruby-head
data/Gemfile CHANGED
@@ -1,3 +1,3 @@
1
- source 'https://rubygems.org'
1
+ source "https://rubygems.org"
2
2
 
3
3
  gemspec
data/History.md CHANGED
@@ -1,3 +1,18 @@
1
+ # 0.4.0
2
+
3
+ Release date: 2014-12-26
4
+
5
+ - [ADDED] Pass through additional args to S3
6
+ - [ADDED] Rack app sets far future expiry headers
7
+ - [ADDED] Sinatra app supports CORS preflight requests
8
+ - [ADDED] Helpers can take `host` option
9
+ - [ADDED] File type validations
10
+ - [ADDED] attachment_field accept attribute is set from file type restrictions
11
+ - [CHANGED] Dynamically generated methods in attachments are included via Module
12
+ - [CHANGED] Rack app replaced with Sinatra app
13
+ - [FIXED] Various content type fixes in Sinatra app
14
+ - [FIXED] Don't set id of record if it is frozen
15
+
1
16
  # 0.3.0
2
17
 
3
18
  Release date: 2014-12-14
data/README.md CHANGED
@@ -1,6 +1,7 @@
1
1
  # Refile
2
2
  [![Gem Version](https://badge.fury.io/rb/refile.svg)](http://badge.fury.io/rb/refile)
3
3
  [![Build Status](https://travis-ci.org/elabs/refile.svg?branch=master)](https://travis-ci.org/elabs/refile)
4
+ [![Code Climate](https://codeclimate.com/github/elabs/refile/badges/gpa.svg)](https://codeclimate.com/github/elabs/refile)
4
5
 
5
6
  Refile is a modern file upload library for Ruby applications. It is simple, yet
6
7
  powerful. Refile is an attempt by CarrierWave's original author to fix the
@@ -218,9 +219,9 @@ end
218
219
 
219
220
  ## 3. Rack Application
220
221
 
221
- Refile includes a Rack application (an endpoint, not a middleware). This application
222
- streams files from backends and can even accept file uploads and upload them to
223
- backends.
222
+ Refile includes a Rack application (an endpoint, not a middleware), written in
223
+ Sinatra. This application streams files from backends and can even accept file
224
+ uploads and upload them to backends.
224
225
 
225
226
  **Important:** Unlike other file upload solutions, Refile always streams your files through your
226
227
  application. It cannot generate URLs to your files. This means that you should
@@ -341,7 +342,7 @@ With this helper you can specify an image which is used as a fallback in case
341
342
  no file has been uploaded:
342
343
 
343
344
  ``` erb
344
- <%= attachment_image_tag(@user, :profile_image, :fill, 300, 300, fallback: "defaul.png") %>
345
+ <%= attachment_image_tag(@user, :profile_image, :fill, 300, 300, fallback: "default.png") %>
345
346
  ```
346
347
 
347
348
  ## 5. JavaScript library
@@ -430,7 +431,7 @@ cross site AJAX requests from posting to buckets. Fixing this is easy though.
430
431
  - Click "Add CORS Configuration"
431
432
 
432
433
  The default configuration only allows "GET", you'll want to allow "POST" as
433
- well. You'll also want to permit the "Content-Type" header.
434
+ well. You'll also want to permit the "Content-Type" and "Origin" headers.
434
435
 
435
436
  It could look something like this:
436
437
 
@@ -443,6 +444,7 @@ It could look something like this:
443
444
  <MaxAgeSeconds>3000</MaxAgeSeconds>
444
445
  <AllowedHeader>Authorization</AllowedHeader>
445
446
  <AllowedHeader>Content-Type</AllowedHeader>
447
+ <AllowedHeader>Origin</AllowedHeader>
446
448
  </CORSRule>
447
449
  </CORSConfiguration>
448
450
  ```
@@ -477,6 +479,40 @@ Refile's JavaScript library requires HTML5 features which are unavailable on
477
479
  IE9 and earlier versions. All other major browsers are supported. Note though
478
480
  that it has not yet been extensively tested.
479
481
 
482
+ ## File type validations
483
+
484
+ Refile can check that attached files have a given content type or extension.
485
+ This allows you to warn users if they try to upload an invalid file.
486
+
487
+ **Important:** You should regard this as a convenience feature for your users,
488
+ not a security feature. Both file extension and content type can easily be
489
+ spoofed.
490
+
491
+ In order to limit attachments to an extension or content type, you can provide
492
+ them like this:
493
+
494
+ ``` ruby
495
+ attachment :cv, extension: "pdf"
496
+ attachment :profile_image, content_type: "image/jpeg"
497
+ ```
498
+
499
+ You can also provide a list of content type or extensions:
500
+
501
+ ``` ruby
502
+ attachment :cv, extension: ["pdf", "doc"]
503
+ attachment :profile_image, content_type: ["image/jpeg", "image/png", "image/gif"]
504
+ ```
505
+
506
+ Since the combination of JPEG, PNG and GIF is so common, you can also specify
507
+ this more succinctly like this:
508
+
509
+ ``` ruby
510
+ attachment :profile_image, type: :image
511
+ ```
512
+
513
+ When a user uploads a file with an invalid extension or content type and
514
+ submits the form, they'll be presented with a validation error.
515
+
480
516
  ## Removing attached files
481
517
 
482
518
  File input fields unfortunately do not have the option of removing an already
data/Rakefile CHANGED
@@ -4,6 +4,7 @@ require "bundler/gem_tasks"
4
4
  require "refile/test_app"
5
5
  require "rspec/core/rake_task"
6
6
  require "yard"
7
+ require "rubocop/rake_task"
7
8
 
8
9
  YARD::Rake::YardocTask.new do |t|
9
10
  t.files = ["README.md", "lib/**/*.rb"]
@@ -11,6 +12,8 @@ end
11
12
 
12
13
  RSpec::Core::RakeTask.new(:spec)
13
14
 
14
- task default: :spec
15
+ RuboCop::RakeTask.new
16
+
17
+ task default: [:spec, :rubocop]
15
18
 
16
19
  Rails.application.load_tasks
@@ -4,3 +4,5 @@ en:
4
4
  messages:
5
5
  too_large: "is too large"
6
6
  download_failed: "could not be downloaded"
7
+ invalid_content_type: "has an invalid file format"
8
+ invalid_extension: "has an invalid file format"
data/config/routes.rb CHANGED
@@ -1,3 +1,5 @@
1
- Rails.application.routes.draw do
2
- mount Refile.app, at: "attachments", as: :refile_app
1
+ if Refile.automount
2
+ Rails.application.routes.draw do
3
+ mount Refile.app, at: Refile.mount_point, as: :refile_app
4
+ end
3
5
  end
data/lib/refile/app.rb CHANGED
@@ -1,99 +1,124 @@
1
- require "logger"
2
1
  require "json"
2
+ require "sinatra/base"
3
3
 
4
4
  module Refile
5
- class App
6
- def initialize(logger: nil, allow_origin: nil)
7
- @logger = logger
8
- @logger ||= ::Logger.new(nil)
9
- @allow_origin = allow_origin
10
- end
11
-
12
- # @api private
13
- class Proxy
14
- def initialize(peek, file)
15
- @peek = peek
16
- @file = file
17
- end
5
+ class App < Sinatra::Base
6
+ configure do
7
+ set :show_exceptions, false
8
+ set :raise_errors, false
9
+ set :sessions, false
10
+ set :logging, false
11
+ set :dump_errors, false
12
+ end
18
13
 
19
- def close
20
- @file.close
14
+ before do
15
+ content_type ::File.extname(request.path), default: "application/octet-stream"
16
+ if Refile.allow_origin
17
+ response["Access-Control-Allow-Origin"] = Refile.allow_origin
18
+ response["Access-Control-Allow-Headers"] = request.env["HTTP_ACCESS_CONTROL_REQUEST_HEADERS"].to_s
19
+ response["Access-Control-Allow-Method"] = request.env["HTTP_ACCESS_CONTROL_REQUEST_METHOD"].to_s
21
20
  end
21
+ end
22
22
 
23
- def each(&block)
24
- block.call(@peek)
25
- @file.each(&block)
26
- end
23
+ get "/:backend/:id/:filename" do
24
+ set_expires_header
25
+ stream_file(file)
27
26
  end
28
27
 
29
- def call(env)
30
- @logger.info { "Refile: #{env["REQUEST_METHOD"]} #{env["PATH_INFO"]}" }
28
+ get "/:backend/:processor/:id/:file_basename.:extension" do
29
+ set_expires_header
30
+ stream_file processor.call(file, format: params[:extension])
31
+ end
31
32
 
32
- backend_name, *args = env["PATH_INFO"].sub(/^\//, "").split("/")
33
- backend = Refile.backends[backend_name]
33
+ get "/:backend/:processor/:id/:filename" do
34
+ set_expires_header
35
+ stream_file processor.call(file)
36
+ end
34
37
 
35
- if env["REQUEST_METHOD"] == "GET" and backend and args.length >= 2
36
- *process_args, id, filename = args
37
- format = ::File.extname(filename)[1..-1]
38
+ get "/:backend/:processor/*/:id/:file_basename.:extension" do
39
+ set_expires_header
40
+ stream_file processor.call(file, *params[:splat].first.split("/"), format: params[:extension])
41
+ end
38
42
 
39
- @logger.debug { "Refile: serving #{id.inspect} from #{backend_name} backend which is of type #{backend.class}" }
43
+ get "/:backend/:processor/*/:id/:filename" do
44
+ set_expires_header
45
+ stream_file processor.call(file, *params[:splat].first.split("/"))
46
+ end
40
47
 
41
- file = backend.get(id)
48
+ options "/:backend" do
49
+ ""
50
+ end
42
51
 
43
- unless process_args.empty?
44
- name = process_args.shift
45
- processor = Refile.processors[name]
46
- unless processor
47
- @logger.debug { "Refile: no such processor #{name.inspect}" }
48
- return not_found
49
- end
50
- file = if format
51
- processor.call(file, *process_args, format: format)
52
- else
53
- processor.call(file, *process_args)
54
- end
55
- end
52
+ post "/:backend" do
53
+ backend = Refile.backends[params[:backend]]
54
+ halt 404 unless backend && Refile.direct_upload.include?(params[:backend])
55
+ tempfile = request.params.fetch("file").fetch(:tempfile)
56
+ file = backend.upload(tempfile)
57
+ content_type :json
58
+ { id: file.id }.to_json
59
+ end
56
60
 
57
- peek = begin
58
- file.read(Refile.read_chunk_size)
59
- rescue => e
60
- log_error(e)
61
- return not_found
62
- end
61
+ not_found do
62
+ content_type :text
63
+ "not found"
64
+ end
63
65
 
64
- headers = {}
65
- headers["Access-Control-Allow-Origin"] = @allow_origin if @allow_origin
66
+ error do |error_thrown|
67
+ log_error("Error -> #{error_thrown}")
68
+ error_thrown.backtrace.each do |line|
69
+ log_error(line)
70
+ end
71
+ content_type :text
72
+ "error"
73
+ end
66
74
 
67
- [200, headers, Proxy.new(peek, file)]
68
- elsif env["REQUEST_METHOD"] == "POST" and backend and args.empty? and Refile.direct_upload.include?(backend_name)
69
- @logger.debug { "Refile: uploading to #{backend_name} backend which is of type #{backend.class}" }
75
+ private
70
76
 
71
- tempfile = Rack::Request.new(env).params.fetch("file").fetch(:tempfile)
72
- file = backend.upload(tempfile)
77
+ def set_expires_header
78
+ expires Refile.content_max_age, :public, :must_revalidate
79
+ end
73
80
 
74
- [200, { "Content-Type" => "application/json" }, [{ id: file.id }.to_json]]
75
- else
76
- not_found
81
+ def logger
82
+ Refile.logger
83
+ end
84
+
85
+ def stream_file(file)
86
+ stream do |out|
87
+ file.each do |chunk|
88
+ out << chunk
89
+ end
77
90
  end
78
- rescue => e
79
- log_error(e)
80
- [500, {}, ["error"]]
81
91
  end
82
92
 
83
- private
93
+ def backend
94
+ backend = Refile.backends[params[:backend]]
95
+ unless backend
96
+ log_error("Could not find backend: #{params[:backend]}")
97
+ halt 404
98
+ end
99
+ backend
100
+ end
84
101
 
85
- def not_found
86
- [404, {}, ["not found"]]
102
+ def file
103
+ file = backend.get(params[:id])
104
+ unless file.exists?
105
+ log_error("Could not find attachment by id: #{params[:id]}")
106
+ halt 404
107
+ end
108
+ file
87
109
  end
88
110
 
89
- def log_error(e)
90
- if @logger.debug?
91
- @logger.debug "Refile: unable to read file"
92
- @logger.debug "#{e.class}: #{e.message}"
93
- e.backtrace.each do |line|
94
- @logger.debug " #{line}"
95
- end
111
+ def processor
112
+ processor = Refile.processors[params[:processor]]
113
+ unless processor
114
+ log_error("Could not find processor: #{params[:processor]}")
115
+ halt 404
96
116
  end
117
+ processor
118
+ end
119
+
120
+ def log_error(message)
121
+ logger.error "#{self.class.name}: #{message}"
97
122
  end
98
123
  end
99
124
  end
@@ -0,0 +1,115 @@
1
+ module Refile
2
+ # @api private
3
+ class Attacher
4
+ attr_reader :record, :name, :cache, :store, :cache_id, :options, :errors, :type, :extensions, :content_types
5
+ attr_accessor :remove
6
+
7
+ def initialize(record, name, cache:, store:, raise_errors: true, type: nil, extension: nil, content_type: nil)
8
+ @record = record
9
+ @name = name
10
+ @raise_errors = raise_errors
11
+ @cache = Refile.backends.fetch(cache.to_s)
12
+ @store = Refile.backends.fetch(store.to_s)
13
+ @type = type
14
+ @extensions = [extension].flatten if extension
15
+ @content_types = [content_type].flatten if content_type
16
+ @content_types ||= %w[image/jpeg image/gif image/png] if type == :image
17
+ @errors = []
18
+ end
19
+
20
+ def id
21
+ record.send(:"#{name}_id")
22
+ end
23
+
24
+ def id=(id)
25
+ record.send(:"#{name}_id=", id) unless record.frozen?
26
+ end
27
+
28
+ def get
29
+ if cached?
30
+ cache.get(cache_id)
31
+ elsif id and not id == ""
32
+ store.get(id)
33
+ end
34
+ end
35
+
36
+ def valid?(uploadable)
37
+ @errors = []
38
+ @errors << :invalid_extension if @extensions and not valid_extension?(uploadable)
39
+ @errors << :invalid_content_type if @content_types and not valid_content_type?(uploadable)
40
+ @errors << :too_large if cache.max_size and uploadable.size >= cache.max_size
41
+ @errors.empty?
42
+ end
43
+
44
+ def cache!(uploadable)
45
+ if valid?(uploadable)
46
+ @cache_file = cache.upload(uploadable)
47
+ @cache_id = @cache_file.id
48
+ elsif @raise_errors
49
+ raise Refile::Invalid, @errors.join(", ")
50
+ end
51
+ end
52
+
53
+ def download(url)
54
+ if url and not url == ""
55
+ cache!(RestClient::Request.new(method: :get, url: url, raw_response: true).execute.file)
56
+ end
57
+ rescue RestClient::Exception
58
+ @errors = [:download_failed]
59
+ raise if @raise_errors
60
+ end
61
+
62
+ def cache_id=(id)
63
+ @cache_id = id unless @cache_file
64
+ end
65
+
66
+ def store!
67
+ if remove?
68
+ delete!
69
+ elsif cached?
70
+ file = store.upload(cache.get(cache_id))
71
+ delete!
72
+ self.id = file.id
73
+ end
74
+ end
75
+
76
+ def delete!
77
+ if cached?
78
+ cache.delete(cache_id)
79
+ @cache_id = nil
80
+ @cache_file = nil
81
+ end
82
+ store.delete(id) if id
83
+ self.id = nil
84
+ end
85
+
86
+ def remove?
87
+ remove and remove != "" and remove !~ /\A0|false$\z/
88
+ end
89
+
90
+ def accept
91
+ if content_types
92
+ content_types.join(",")
93
+ elsif extensions
94
+ extensions.map { |e| ".#{e}" }.join(",")
95
+ end
96
+ end
97
+
98
+ private
99
+
100
+ def valid_content_type?(uploadable)
101
+ content_type = Refile.extract_content_type(uploadable) or return false
102
+ @content_types.include?(content_type)
103
+ end
104
+
105
+ def valid_extension?(uploadable)
106
+ filename = Refile.extract_filename(uploadable) or return false
107
+ extension = ::File.extname(filename).sub(/^\./, "")
108
+ @extensions.include?(extension)
109
+ end
110
+
111
+ def cached?
112
+ cache_id and not cache_id == ""
113
+ end
114
+ end
115
+ end
@@ -6,22 +6,22 @@ module Refile
6
6
  # Attachment method which hooks into ActiveRecord models
7
7
  #
8
8
  # @see Refile::Attachment#attachment
9
- def attachment(name, cache: :cache, store: :store, raise_errors: false)
9
+ def attachment(name, raise_errors: false, **)
10
10
  super
11
11
 
12
- attachment = "#{name}_attachment"
12
+ attacher = "#{name}_attacher"
13
13
 
14
14
  validate do
15
- errors = send(attachment).errors
15
+ errors = send(attacher).errors
16
16
  self.errors.add(name, *errors) unless errors.empty?
17
17
  end
18
18
 
19
19
  before_save do
20
- send(attachment).store!
20
+ send(attacher).store!
21
21
  end
22
22
 
23
23
  after_destroy do
24
- send(attachment).delete!
24
+ send(attacher).delete!
25
25
  end
26
26
  end
27
27
  end