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