refile 0.2.2
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +27 -0
- data/.rspec +1 -0
- data/.travis.yml +8 -0
- data/Gemfile +3 -0
- data/LICENSE.txt +22 -0
- data/README.md +476 -0
- data/Rakefile +11 -0
- data/app/assets/javascripts/refile.js +50 -0
- data/app/helpers/attachment_helper.rb +52 -0
- data/config.ru +8 -0
- data/config/locales/en.yml +5 -0
- data/config/routes.rb +3 -0
- data/lib/refile.rb +72 -0
- data/lib/refile/app.rb +97 -0
- data/lib/refile/attachment.rb +89 -0
- data/lib/refile/attachment/active_record.rb +24 -0
- data/lib/refile/backend/file_system.rb +70 -0
- data/lib/refile/backend/s3.rb +129 -0
- data/lib/refile/file.rb +65 -0
- data/lib/refile/image_processing.rb +73 -0
- data/lib/refile/rails.rb +36 -0
- data/lib/refile/random_hasher.rb +5 -0
- data/lib/refile/version.rb +3 -0
- data/refile.gemspec +34 -0
- data/spec/refile/app_spec.rb +151 -0
- data/spec/refile/attachment_spec.rb +141 -0
- data/spec/refile/backend/file_system_spec.rb +30 -0
- data/spec/refile/backend/s3_spec.rb +11 -0
- data/spec/refile/backend_examples.rb +215 -0
- data/spec/refile/features/direct_upload_spec.rb +29 -0
- data/spec/refile/features/normal_upload_spec.rb +36 -0
- data/spec/refile/features/presigned_upload_spec.rb +29 -0
- data/spec/refile/fixtures/hello.txt +1 -0
- data/spec/refile/fixtures/large.txt +44 -0
- data/spec/refile/spec_helper.rb +58 -0
- data/spec/refile/test_app.rb +46 -0
- data/spec/refile/test_app/app/assets/javascripts/application.js +40 -0
- data/spec/refile/test_app/app/controllers/application_controller.rb +2 -0
- data/spec/refile/test_app/app/controllers/direct_posts_controller.rb +15 -0
- data/spec/refile/test_app/app/controllers/home_controller.rb +4 -0
- data/spec/refile/test_app/app/controllers/normal_posts_controller.rb +19 -0
- data/spec/refile/test_app/app/controllers/presigned_posts_controller.rb +30 -0
- data/spec/refile/test_app/app/models/post.rb +5 -0
- data/spec/refile/test_app/app/views/direct_posts/new.html.erb +16 -0
- data/spec/refile/test_app/app/views/home/index.html.erb +1 -0
- data/spec/refile/test_app/app/views/layouts/application.html.erb +14 -0
- data/spec/refile/test_app/app/views/normal_posts/new.html.erb +20 -0
- data/spec/refile/test_app/app/views/normal_posts/show.html.erb +9 -0
- data/spec/refile/test_app/app/views/presigned_posts/new.html.erb +16 -0
- data/spec/refile/test_app/config/database.yml +7 -0
- data/spec/refile/test_app/config/routes.rb +17 -0
- data/spec/refile/test_app/public/favicon.ico +0 -0
- data/spec/refile_spec.rb +35 -0
- 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
|
data/lib/refile/file.rb
ADDED
@@ -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
|
data/lib/refile/rails.rb
ADDED
@@ -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
|
data/refile.gemspec
ADDED
@@ -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
|
+
|