defile 0.2.0

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 +4 -0
  6. data/LICENSE.txt +22 -0
  7. data/README.md +466 -0
  8. data/Rakefile +11 -0
  9. data/app/assets/javascripts/defile.js +50 -0
  10. data/app/helpers/attachment_helper.rb +50 -0
  11. data/config.ru +8 -0
  12. data/config/locales/en.yml +5 -0
  13. data/config/routes.rb +3 -0
  14. data/defile.gemspec +34 -0
  15. data/lib/defile.rb +72 -0
  16. data/lib/defile/app.rb +97 -0
  17. data/lib/defile/attachment.rb +89 -0
  18. data/lib/defile/attachment/active_record.rb +24 -0
  19. data/lib/defile/backend/file_system.rb +70 -0
  20. data/lib/defile/backend/s3.rb +129 -0
  21. data/lib/defile/file.rb +65 -0
  22. data/lib/defile/image_processing.rb +73 -0
  23. data/lib/defile/rails.rb +36 -0
  24. data/lib/defile/random_hasher.rb +5 -0
  25. data/lib/defile/version.rb +3 -0
  26. data/spec/defile/app_spec.rb +151 -0
  27. data/spec/defile/attachment_spec.rb +141 -0
  28. data/spec/defile/backend/file_system_spec.rb +30 -0
  29. data/spec/defile/backend/s3_spec.rb +11 -0
  30. data/spec/defile/backend_examples.rb +215 -0
  31. data/spec/defile/features/direct_upload_spec.rb +29 -0
  32. data/spec/defile/features/normal_upload_spec.rb +36 -0
  33. data/spec/defile/features/presigned_upload_spec.rb +29 -0
  34. data/spec/defile/fixtures/hello.txt +1 -0
  35. data/spec/defile/fixtures/large.txt +44 -0
  36. data/spec/defile/spec_helper.rb +58 -0
  37. data/spec/defile/test_app.rb +46 -0
  38. data/spec/defile/test_app/app/assets/javascripts/application.js +40 -0
  39. data/spec/defile/test_app/app/controllers/application_controller.rb +2 -0
  40. data/spec/defile/test_app/app/controllers/direct_posts_controller.rb +15 -0
  41. data/spec/defile/test_app/app/controllers/home_controller.rb +4 -0
  42. data/spec/defile/test_app/app/controllers/normal_posts_controller.rb +19 -0
  43. data/spec/defile/test_app/app/controllers/presigned_posts_controller.rb +30 -0
  44. data/spec/defile/test_app/app/models/post.rb +5 -0
  45. data/spec/defile/test_app/app/views/direct_posts/new.html.erb +16 -0
  46. data/spec/defile/test_app/app/views/home/index.html.erb +1 -0
  47. data/spec/defile/test_app/app/views/layouts/application.html.erb +14 -0
  48. data/spec/defile/test_app/app/views/normal_posts/new.html.erb +20 -0
  49. data/spec/defile/test_app/app/views/normal_posts/show.html.erb +9 -0
  50. data/spec/defile/test_app/app/views/presigned_posts/new.html.erb +16 -0
  51. data/spec/defile/test_app/config/database.yml +7 -0
  52. data/spec/defile/test_app/config/routes.rb +17 -0
  53. data/spec/defile/test_app/public/favicon.ico +0 -0
  54. data/spec/defile_spec.rb +35 -0
  55. metadata +294 -0
@@ -0,0 +1,70 @@
1
+ module Defile
2
+ module Backend
3
+ class FileSystem
4
+ attr_reader :directory
5
+
6
+ def initialize(directory, max_size: nil, hasher: Defile::RandomHasher.new)
7
+ @hasher = hasher
8
+ @directory = directory
9
+ @max_size = max_size
10
+
11
+ FileUtils.mkdir_p(@directory)
12
+ end
13
+
14
+ def upload(uploadable)
15
+ Defile.verify_uploadable(uploadable, @max_size)
16
+
17
+ id = @hasher.hash(uploadable)
18
+
19
+ if uploadable.respond_to?(:path) and ::File.exist?(uploadable.path)
20
+ FileUtils.cp(uploadable.path, path(id))
21
+ else
22
+ ::File.open(path(id), "wb") do |file|
23
+ buffer = "" # reuse the same buffer
24
+ until uploadable.eof?
25
+ uploadable.read(Defile.read_chunk_size, buffer)
26
+ file.write(buffer)
27
+ end
28
+ uploadable.close
29
+ end
30
+ end
31
+
32
+ Defile::File.new(self, id)
33
+ end
34
+
35
+ def get(id)
36
+ Defile::File.new(self, id)
37
+ end
38
+
39
+ def delete(id)
40
+ FileUtils.rm(path(id)) if exists?(id)
41
+ end
42
+
43
+ def open(id)
44
+ ::File.open(path(id), "rb")
45
+ end
46
+
47
+ def read(id)
48
+ ::File.read(path(id)) if exists?(id)
49
+ end
50
+
51
+ def size(id)
52
+ ::File.size(path(id)) if exists?(id)
53
+ end
54
+
55
+ def exists?(id)
56
+ ::File.exists?(path(id))
57
+ end
58
+
59
+ def clear!(confirm = nil)
60
+ 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
61
+ FileUtils.rm_rf(@directory)
62
+ FileUtils.mkdir_p(@directory)
63
+ end
64
+
65
+ def path(id)
66
+ ::File.join(@directory, id)
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,129 @@
1
+ require "aws-sdk"
2
+
3
+ module Defile
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: Defile::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
+ Defile.verify_uploadable(uploadable, @max_size)
72
+
73
+ id = @hasher.hash(uploadable)
74
+
75
+ if uploadable.is_a?(Defile::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
+ Defile::File.new(self, id)
82
+ end
83
+
84
+ def get(id)
85
+ Defile::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 Defile
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(Defile.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 "defile"
2
+ require "mini_magick"
3
+
4
+ module Defile
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
+ Defile.processor(name, Defile::ImageProcessor.new(name))
73
+ end
@@ -0,0 +1,36 @@
1
+ require "defile"
2
+
3
+ module Defile
4
+ module Controller
5
+ def show
6
+ file = Defile.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 "defile", before: :load_environment_config do
24
+ Defile.store ||= Defile::Backend::FileSystem.new(Rails.root.join("tmp/uploads/store").to_s)
25
+ Defile.cache ||= Defile::Backend::FileSystem.new(Rails.root.join("tmp/uploads/cache").to_s)
26
+
27
+ Defile.app = Defile::App.new(logger: Rails.logger)
28
+
29
+ ActiveSupport.on_load :active_record do
30
+ require "defile/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 Defile::RandomHasher
2
+ def hash(uploadable=nil)
3
+ SecureRandom.hex(30)
4
+ end
5
+ end
@@ -0,0 +1,3 @@
1
+ module Defile
2
+ VERSION = "0.2.0"
3
+ end
@@ -0,0 +1,151 @@
1
+ require "rack/test"
2
+
3
+ Defile.processor(:reverse) do |file|
4
+ StringIO.new(file.read.reverse)
5
+ end
6
+
7
+ Defile.processor(:upcase, proc { |file| StringIO.new(file.read.upcase) })
8
+
9
+ Defile.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 Defile::App do
21
+ include Rack::Test::Methods
22
+
23
+ def app
24
+ Defile::App.new
25
+ end
26
+
27
+ describe "GET /:backend/:id/:filename" do
28
+ it "returns a stored file" do
29
+ file = Defile.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 = Defile.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 = Defile.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
+ Defile::App.new(allow_origin: "example.com")
58
+ end
59
+
60
+ it "sets CORS header" do
61
+ file = Defile.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 = Defile.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 = Defile.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 = Defile.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 = Defile.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 = Defile.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
+