refile 0.2.2

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 (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
+