refile 0.2.2

Sign up to get free protection for your applications and to get access to all the features.
Files changed (55) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +27 -0
  3. data/.rspec +1 -0
  4. data/.travis.yml +8 -0
  5. data/Gemfile +3 -0
  6. data/LICENSE.txt +22 -0
  7. data/README.md +476 -0
  8. data/Rakefile +11 -0
  9. data/app/assets/javascripts/refile.js +50 -0
  10. data/app/helpers/attachment_helper.rb +52 -0
  11. data/config.ru +8 -0
  12. data/config/locales/en.yml +5 -0
  13. data/config/routes.rb +3 -0
  14. data/lib/refile.rb +72 -0
  15. data/lib/refile/app.rb +97 -0
  16. data/lib/refile/attachment.rb +89 -0
  17. data/lib/refile/attachment/active_record.rb +24 -0
  18. data/lib/refile/backend/file_system.rb +70 -0
  19. data/lib/refile/backend/s3.rb +129 -0
  20. data/lib/refile/file.rb +65 -0
  21. data/lib/refile/image_processing.rb +73 -0
  22. data/lib/refile/rails.rb +36 -0
  23. data/lib/refile/random_hasher.rb +5 -0
  24. data/lib/refile/version.rb +3 -0
  25. data/refile.gemspec +34 -0
  26. data/spec/refile/app_spec.rb +151 -0
  27. data/spec/refile/attachment_spec.rb +141 -0
  28. data/spec/refile/backend/file_system_spec.rb +30 -0
  29. data/spec/refile/backend/s3_spec.rb +11 -0
  30. data/spec/refile/backend_examples.rb +215 -0
  31. data/spec/refile/features/direct_upload_spec.rb +29 -0
  32. data/spec/refile/features/normal_upload_spec.rb +36 -0
  33. data/spec/refile/features/presigned_upload_spec.rb +29 -0
  34. data/spec/refile/fixtures/hello.txt +1 -0
  35. data/spec/refile/fixtures/large.txt +44 -0
  36. data/spec/refile/spec_helper.rb +58 -0
  37. data/spec/refile/test_app.rb +46 -0
  38. data/spec/refile/test_app/app/assets/javascripts/application.js +40 -0
  39. data/spec/refile/test_app/app/controllers/application_controller.rb +2 -0
  40. data/spec/refile/test_app/app/controllers/direct_posts_controller.rb +15 -0
  41. data/spec/refile/test_app/app/controllers/home_controller.rb +4 -0
  42. data/spec/refile/test_app/app/controllers/normal_posts_controller.rb +19 -0
  43. data/spec/refile/test_app/app/controllers/presigned_posts_controller.rb +30 -0
  44. data/spec/refile/test_app/app/models/post.rb +5 -0
  45. data/spec/refile/test_app/app/views/direct_posts/new.html.erb +16 -0
  46. data/spec/refile/test_app/app/views/home/index.html.erb +1 -0
  47. data/spec/refile/test_app/app/views/layouts/application.html.erb +14 -0
  48. data/spec/refile/test_app/app/views/normal_posts/new.html.erb +20 -0
  49. data/spec/refile/test_app/app/views/normal_posts/show.html.erb +9 -0
  50. data/spec/refile/test_app/app/views/presigned_posts/new.html.erb +16 -0
  51. data/spec/refile/test_app/config/database.yml +7 -0
  52. data/spec/refile/test_app/config/routes.rb +17 -0
  53. data/spec/refile/test_app/public/favicon.ico +0 -0
  54. data/spec/refile_spec.rb +35 -0
  55. metadata +294 -0
@@ -0,0 +1,129 @@
1
+ require "aws-sdk"
2
+
3
+ module Refile
4
+ module Backend
5
+ class S3
6
+ # Emulates an IO-object like interface on top of S3Object#read. To avoid
7
+ # memory allocations and unnecessary complexity, this treats the `length`
8
+ # parameter to read as a boolean flag instead. If given, it will read the
9
+ # file in chunks of undetermined size, if not given it will read the
10
+ # entire file.
11
+ class Reader
12
+ def initialize(object)
13
+ @object = object
14
+ @closed = false
15
+ end
16
+
17
+ def read(length = nil, buffer = nil)
18
+ result = if length
19
+ raise "closed" if @closed
20
+
21
+ unless eof? # sets @peek
22
+ @peek
23
+ end
24
+ else
25
+ @object.read
26
+ end
27
+ buffer.replace(result) if buffer and result
28
+ result
29
+ ensure
30
+ @peek = nil
31
+ end
32
+
33
+ def eof?
34
+ @peek ||= enumerator.next
35
+ false
36
+ rescue StopIteration
37
+ true
38
+ end
39
+
40
+ def size
41
+ @object.content_length
42
+ end
43
+
44
+ def close
45
+ @closed = true
46
+ end
47
+
48
+ private
49
+
50
+ def enumerator
51
+ @enumerator ||= @object.to_enum(:read)
52
+ end
53
+ end
54
+
55
+ Signature = Struct.new(:as, :id, :url, :fields)
56
+
57
+ attr_reader :access_key_id
58
+
59
+ def initialize(access_key_id:, secret_access_key:, bucket:, max_size: nil, prefix: nil, hasher: Refile::RandomHasher.new)
60
+ @access_key_id = access_key_id
61
+ @secret_access_key = secret_access_key
62
+ @s3 = AWS::S3.new(access_key_id: access_key_id, secret_access_key: secret_access_key)
63
+ @bucket_name = bucket
64
+ @bucket = @s3.buckets[@bucket_name]
65
+ @hasher = hasher
66
+ @prefix = prefix
67
+ @max_size = max_size
68
+ end
69
+
70
+ def upload(uploadable)
71
+ Refile.verify_uploadable(uploadable, @max_size)
72
+
73
+ id = @hasher.hash(uploadable)
74
+
75
+ if uploadable.is_a?(Refile::File) and uploadable.backend.is_a?(S3) and uploadable.backend.access_key_id == access_key_id
76
+ uploadable.backend.object(uploadable.id).copy_to(object(id))
77
+ else
78
+ object(id).write(uploadable, content_length: uploadable.size)
79
+ end
80
+
81
+ Refile::File.new(self, id)
82
+ end
83
+
84
+ def get(id)
85
+ Refile::File.new(self, id)
86
+ end
87
+
88
+ def delete(id)
89
+ object(id).delete
90
+ end
91
+
92
+ def open(id)
93
+ Reader.new(object(id))
94
+ end
95
+
96
+ def read(id)
97
+ object(id).read
98
+ rescue AWS::S3::Errors::NoSuchKey
99
+ nil
100
+ end
101
+
102
+ def size(id)
103
+ object(id).content_length
104
+ rescue AWS::S3::Errors::NoSuchKey
105
+ nil
106
+ end
107
+
108
+ def exists?(id)
109
+ object(id).exists?
110
+ end
111
+
112
+ def clear!(confirm = nil)
113
+ raise ArgumentError, "are you sure? this will remove all files in the backend, call as `clear!(:confirm)` if you're sure you want to do this" unless confirm == :confirm
114
+ @bucket.objects.with_prefix(@prefix).delete_all
115
+ end
116
+
117
+ def presign
118
+ id = RandomHasher.new.hash
119
+ signature = @bucket.presigned_post(key: [*@prefix, id].join("/"))
120
+ signature.where(content_length: @max_size) if @max_size
121
+ Signature.new("file", id, signature.url.to_s, signature.fields)
122
+ end
123
+
124
+ def object(id)
125
+ @bucket.objects[[*@prefix, id].join("/")]
126
+ end
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,65 @@
1
+ module Refile
2
+ class File
3
+ attr_reader :backend, :id
4
+
5
+ def initialize(backend, id)
6
+ @backend = backend
7
+ @id = id
8
+ end
9
+
10
+ def read(*args)
11
+ io.read(*args)
12
+ end
13
+
14
+ def eof?
15
+ io.eof?
16
+ end
17
+
18
+ def close
19
+ io.close
20
+ end
21
+
22
+ def size
23
+ backend.size(id)
24
+ end
25
+
26
+ def delete
27
+ backend.delete(id)
28
+ end
29
+
30
+ def exists?
31
+ backend.exists?(id)
32
+ end
33
+
34
+ def to_io
35
+ io
36
+ end
37
+
38
+ def download
39
+ tempfile = Tempfile.new(id)
40
+ tempfile.binmode
41
+ each do |chunk|
42
+ tempfile.write(chunk)
43
+ end
44
+ close
45
+ tempfile.close
46
+ tempfile
47
+ end
48
+
49
+ def each
50
+ if block_given?
51
+ until eof?
52
+ yield(read(Refile.read_chunk_size))
53
+ end
54
+ else
55
+ to_enum
56
+ end
57
+ end
58
+
59
+ private
60
+
61
+ def io
62
+ @io ||= backend.open(id)
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,73 @@
1
+ require "refile"
2
+ require "mini_magick"
3
+
4
+ module Refile
5
+ class ImageProcessor
6
+ def initialize(method)
7
+ @method = method
8
+ end
9
+
10
+ def convert(img, format)
11
+ img.format(format.to_s.downcase)
12
+ end
13
+
14
+ def limit(img, width, height)
15
+ img.resize "#{width}x#{height}>"
16
+ end
17
+
18
+ def fit(img, width, height)
19
+ img.resize "#{width}x#{height}"
20
+ end
21
+
22
+ def fill(img, width, height, gravity = 'Center')
23
+ width = width.to_i
24
+ height = height.to_i
25
+ cols, rows = img[:dimensions]
26
+ img.combine_options do |cmd|
27
+ if width != cols || height != rows
28
+ scale_x = width/cols.to_f
29
+ scale_y = height/rows.to_f
30
+ if scale_x >= scale_y
31
+ cols = (scale_x * (cols + 0.5)).round
32
+ rows = (scale_x * (rows + 0.5)).round
33
+ cmd.resize "#{cols}"
34
+ else
35
+ cols = (scale_y * (cols + 0.5)).round
36
+ rows = (scale_y * (rows + 0.5)).round
37
+ cmd.resize "x#{rows}"
38
+ end
39
+ end
40
+ cmd.gravity gravity
41
+ cmd.background "rgba(255,255,255,0.0)"
42
+ cmd.extent "#{width}x#{height}" if cols != width || rows != height
43
+ end
44
+ end
45
+
46
+ def pad(img, width, height, background = "transparent", gravity = "Center")
47
+ img.combine_options do |cmd|
48
+ cmd.thumbnail "#{width}x#{height}>"
49
+ if background == "transparent"
50
+ cmd.background "rgba(255, 255, 255, 0.0)"
51
+ else
52
+ cmd.background background
53
+ end
54
+ cmd.gravity gravity
55
+ cmd.extent "#{width}x#{height}"
56
+ end
57
+ end
58
+
59
+ def call(file, *args)
60
+ path = file.download.path
61
+ img = ::MiniMagick::Image.open(path)
62
+ send(@method, img, *args)
63
+
64
+ img.write(path)
65
+
66
+ ::File.open(path, "rb")
67
+ end
68
+ end
69
+ end
70
+
71
+ [:fill, :fit, :limit, :pad, :convert].each do |name|
72
+ Refile.processor(name, Refile::ImageProcessor.new(name))
73
+ end
@@ -0,0 +1,36 @@
1
+ require "refile"
2
+
3
+ module Refile
4
+ module Controller
5
+ def show
6
+ file = Refile.backends.fetch(params[:backend_name]).get(params[:id])
7
+
8
+ options = { disposition: "inline" }
9
+ options[:type] = Mime::Type.lookup_by_extension(params[:format]).to_s if params[:format]
10
+
11
+ send_data file.read, options
12
+ end
13
+ end
14
+
15
+ module AttachmentFieldHelper
16
+ def attachment_field(method, options = {})
17
+ self.multipart = true
18
+ @template.attachment_field(@object_name, method, objectify_options(options))
19
+ end
20
+ end
21
+
22
+ class Engine < Rails::Engine
23
+ initializer "refile", before: :load_environment_config do
24
+ Refile.store ||= Refile::Backend::FileSystem.new(Rails.root.join("tmp/uploads/store").to_s)
25
+ Refile.cache ||= Refile::Backend::FileSystem.new(Rails.root.join("tmp/uploads/cache").to_s)
26
+
27
+ Refile.app = Refile::App.new(logger: Rails.logger)
28
+
29
+ ActiveSupport.on_load :active_record do
30
+ require "refile/attachment/active_record"
31
+ end
32
+
33
+ ActionView::Helpers::FormBuilder.send(:include, AttachmentFieldHelper)
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,5 @@
1
+ class Refile::RandomHasher
2
+ def hash(uploadable=nil)
3
+ SecureRandom.hex(30)
4
+ end
5
+ end
@@ -0,0 +1,3 @@
1
+ module Refile
2
+ VERSION = "0.2.2"
3
+ end
@@ -0,0 +1,34 @@
1
+ # coding: utf-8
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'refile/version'
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = "refile"
8
+ spec.version = Refile::VERSION
9
+ spec.authors = ["Jonas Nicklas"]
10
+ spec.email = ["jonas.nicklas@gmail.com"]
11
+ spec.summary = %q{Simple and powerful file upload library}
12
+ spec.homepage = ""
13
+ spec.license = "MIT"
14
+
15
+ spec.files = `git ls-files -z`.split("\x0")
16
+ spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
17
+ spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
18
+ spec.require_paths = ["lib"]
19
+
20
+ spec.required_ruby_version = ">= 2.1.0"
21
+
22
+ spec.add_development_dependency "bundler", "~> 1.6"
23
+ spec.add_development_dependency "rake"
24
+ spec.add_development_dependency "rspec", "~> 3.0"
25
+ spec.add_development_dependency "rspec-rails", "~> 3.0"
26
+ spec.add_development_dependency "jquery-rails"
27
+ spec.add_development_dependency "capybara"
28
+ spec.add_development_dependency "pry"
29
+ spec.add_development_dependency "aws-sdk"
30
+ spec.add_development_dependency "rack-test", "~> 0.6.2"
31
+ spec.add_development_dependency "rails", "~> 4.1.8"
32
+ spec.add_development_dependency "sqlite3"
33
+ spec.add_development_dependency "selenium-webdriver"
34
+ end
@@ -0,0 +1,151 @@
1
+ require "rack/test"
2
+
3
+ Refile.processor(:reverse) do |file|
4
+ StringIO.new(file.read.reverse)
5
+ end
6
+
7
+ Refile.processor(:upcase, proc { |file| StringIO.new(file.read.upcase) })
8
+
9
+ Refile.processor(:concat) do |file, *words|
10
+ content = File.read(file.download.path)
11
+ tempfile = Tempfile.new("concat")
12
+ tempfile.write(content)
13
+ words.each do |word|
14
+ tempfile.write(word)
15
+ end
16
+ tempfile.close
17
+ File.open(tempfile.path, "r")
18
+ end
19
+
20
+ describe Refile::App do
21
+ include Rack::Test::Methods
22
+
23
+ def app
24
+ Refile::App.new
25
+ end
26
+
27
+ describe "GET /:backend/:id/:filename" do
28
+ it "returns a stored file" do
29
+ file = Refile.store.upload(StringIO.new("hello"))
30
+
31
+ get "/store/#{file.id}/hello"
32
+
33
+ expect(last_response.status).to eq(200)
34
+ expect(last_response.body).to eq("hello")
35
+ end
36
+
37
+ it "returns a 404 if the file doesn't exist" do
38
+ file = Refile.store.upload(StringIO.new("hello"))
39
+
40
+ get "/store/doesnotexist/hello"
41
+
42
+ expect(last_response.status).to eq(404)
43
+ expect(last_response.body).to eq("not found")
44
+ end
45
+
46
+ it "returns a 404 if the backend doesn't exist" do
47
+ file = Refile.store.upload(StringIO.new("hello"))
48
+
49
+ get "/doesnotexist/#{file.id}/hello"
50
+
51
+ expect(last_response.status).to eq(404)
52
+ expect(last_response.body).to eq("not found")
53
+ end
54
+
55
+ context "with allow origin" do
56
+ def app
57
+ Refile::App.new(allow_origin: "example.com")
58
+ end
59
+
60
+ it "sets CORS header" do
61
+ file = Refile.store.upload(StringIO.new("hello"))
62
+
63
+ get "/store/#{file.id}/hello"
64
+
65
+ expect(last_response.status).to eq(200)
66
+ expect(last_response.body).to eq("hello")
67
+ expect(last_response.headers["Access-Control-Allow-Origin"]).to eq("example.com")
68
+ end
69
+ end
70
+
71
+ it "returns a 404 for non get requests" do
72
+ file = Refile.store.upload(StringIO.new("hello"))
73
+
74
+ post "/store/#{file.id}/hello"
75
+
76
+ expect(last_response.status).to eq(404)
77
+ expect(last_response.body).to eq("not found")
78
+ end
79
+ end
80
+
81
+ describe "GET /:backend/:processor/:id/:filename" do
82
+ it "returns 404 if processor does not exist" do
83
+ file = Refile.store.upload(StringIO.new("hello"))
84
+
85
+ get "/store/doesnotexist/#{file.id}/hello"
86
+
87
+ expect(last_response.status).to eq(404)
88
+ expect(last_response.body).to eq("not found")
89
+ end
90
+
91
+ it "applies block processor to file" do
92
+ file = Refile.store.upload(StringIO.new("hello"))
93
+
94
+ get "/store/reverse/#{file.id}/hello"
95
+
96
+ expect(last_response.status).to eq(200)
97
+ expect(last_response.body).to eq("olleh")
98
+ end
99
+
100
+ it "applies object processor to file" do
101
+ file = Refile.store.upload(StringIO.new("hello"))
102
+
103
+ get "/store/upcase/#{file.id}/hello"
104
+
105
+ expect(last_response.status).to eq(200)
106
+ expect(last_response.body).to eq("HELLO")
107
+ end
108
+
109
+ it "applies processor with arguments" do
110
+ file = Refile.store.upload(StringIO.new("hello"))
111
+
112
+ get "/store/concat/foo/bar/baz/#{file.id}/hello"
113
+
114
+ expect(last_response.status).to eq(200)
115
+ expect(last_response.body).to eq("hellofoobarbaz")
116
+ end
117
+ end
118
+
119
+ describe "POST /:backend" do
120
+ it "returns 404 if backend is not marked as direct upload" do
121
+ file = Rack::Test::UploadedFile.new(path("hello.txt"))
122
+ post "/store", file: file
123
+
124
+ expect(last_response.status).to eq(404)
125
+ expect(last_response.body).to eq("not found")
126
+ end
127
+
128
+ it "uploads a file for direct upload backends" do
129
+ file = Rack::Test::UploadedFile.new(path("hello.txt"))
130
+ post "/cache", file: file
131
+
132
+ expect(last_response.status).to eq(200)
133
+ expect(JSON.parse(last_response.body)["id"]).not_to be_empty
134
+ end
135
+ end
136
+
137
+ it "returns a 404 if id not given" do
138
+ get "/store"
139
+
140
+ expect(last_response.status).to eq(404)
141
+ expect(last_response.body).to eq("not found")
142
+ end
143
+
144
+ it "returns a 404 for root" do
145
+ get "/"
146
+
147
+ expect(last_response.status).to eq(404)
148
+ expect(last_response.body).to eq("not found")
149
+ end
150
+ end
151
+