saviour 0.2.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +20 -0
- data/.travis.yml +10 -0
- data/Gemfile +6 -0
- data/LICENSE.txt +22 -0
- data/README.md +491 -0
- data/Rakefile +6 -0
- data/lib/saviour.rb +170 -0
- data/lib/saviour/base_uploader.rb +81 -0
- data/lib/saviour/config.rb +13 -0
- data/lib/saviour/file.rb +124 -0
- data/lib/saviour/local_storage.rb +72 -0
- data/lib/saviour/processors/digest.rb +16 -0
- data/lib/saviour/s3_storage.rb +77 -0
- data/lib/saviour/string_source.rb +15 -0
- data/lib/saviour/uploader/element.rb +19 -0
- data/lib/saviour/uploader/processors_runner.rb +88 -0
- data/lib/saviour/uploader/store_dir_extractor.rb +41 -0
- data/lib/saviour/url_source.rb +55 -0
- data/lib/saviour/version.rb +3 -0
- data/saviour.gemspec +26 -0
- data/spec/feature/access_to_model_and_mounted_as.rb +30 -0
- data/spec/feature/crud_workflows_spec.rb +138 -0
- data/spec/feature/persisted_path_spec.rb +35 -0
- data/spec/feature/reload_model_spec.rb +25 -0
- data/spec/feature/validations_spec.rb +172 -0
- data/spec/feature/versions_spec.rb +186 -0
- data/spec/models/base_uploader_spec.rb +396 -0
- data/spec/models/config_spec.rb +16 -0
- data/spec/models/file_spec.rb +210 -0
- data/spec/models/local_storage_spec.rb +154 -0
- data/spec/models/processors/digest_spec.rb +22 -0
- data/spec/models/s3_storage_spec.rb +170 -0
- data/spec/models/saviour_spec.rb +80 -0
- data/spec/models/url_source_spec.rb +76 -0
- data/spec/spec_helper.rb +93 -0
- data/spec/support/data/camaloon.jpg +0 -0
- data/spec/support/data/example.xml +21 -0
- data/spec/support/data/text.txt +1 -0
- data/spec/support/models.rb +3 -0
- data/spec/support/schema.rb +9 -0
- metadata +196 -0
@@ -0,0 +1,16 @@
|
|
1
|
+
module Saviour
|
2
|
+
module Processors
|
3
|
+
module Digest
|
4
|
+
def digest_filename(contents, filename, opts = {})
|
5
|
+
separator = opts.fetch(:separator, "-")
|
6
|
+
|
7
|
+
digest = ::Digest::MD5.hexdigest(contents)
|
8
|
+
extension = ::File.extname(filename)
|
9
|
+
|
10
|
+
new_filename = "#{[::File.basename(filename, ".*"), digest].join(separator)}#{extension}"
|
11
|
+
|
12
|
+
[contents, new_filename]
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
@@ -0,0 +1,77 @@
|
|
1
|
+
module Saviour
|
2
|
+
class S3Storage
|
3
|
+
def initialize(conf = {})
|
4
|
+
@bucket = conf.delete(:bucket)
|
5
|
+
@public_url_prefix = conf.delete(:public_url_prefix)
|
6
|
+
@conf = conf
|
7
|
+
@overwrite_protection = conf.delete(:overwrite_protection) { true }
|
8
|
+
assert_directory_exists!
|
9
|
+
end
|
10
|
+
|
11
|
+
def write(contents, path)
|
12
|
+
raise(RuntimeError, "The path you're trying to write already exists!") if @overwrite_protection && exists?(path)
|
13
|
+
|
14
|
+
path = sanitize_leading_slash(path)
|
15
|
+
directory.files.create(
|
16
|
+
key: path,
|
17
|
+
body: contents,
|
18
|
+
public: true
|
19
|
+
)
|
20
|
+
end
|
21
|
+
|
22
|
+
def read(path)
|
23
|
+
path = sanitize_leading_slash(path)
|
24
|
+
assert_exists(path)
|
25
|
+
directory.files.get(path).body
|
26
|
+
end
|
27
|
+
|
28
|
+
def delete(path)
|
29
|
+
path = sanitize_leading_slash(path)
|
30
|
+
assert_exists(path)
|
31
|
+
directory.files.get(path).destroy
|
32
|
+
end
|
33
|
+
|
34
|
+
def exists?(path)
|
35
|
+
path = sanitize_leading_slash(path)
|
36
|
+
!!directory.files.head(path)
|
37
|
+
end
|
38
|
+
|
39
|
+
def public_url(path)
|
40
|
+
raise(RuntimeError, "You must provide a `public_url_prefix`") unless public_url_prefix
|
41
|
+
|
42
|
+
path = sanitize_leading_slash(path)
|
43
|
+
::File.join(public_url_prefix, path)
|
44
|
+
end
|
45
|
+
|
46
|
+
|
47
|
+
private
|
48
|
+
|
49
|
+
def public_url_prefix
|
50
|
+
if @public_url_prefix.respond_to?(:call)
|
51
|
+
@public_url_prefix.call
|
52
|
+
else
|
53
|
+
@public_url_prefix
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
def sanitize_leading_slash(path)
|
58
|
+
path.gsub(/\A\/*/, '')
|
59
|
+
end
|
60
|
+
|
61
|
+
def assert_directory_exists!
|
62
|
+
directory || raise(ArgumentError, "The bucket #{@bucket} doesn't exists or misconfigured connection.")
|
63
|
+
end
|
64
|
+
|
65
|
+
def assert_exists(path)
|
66
|
+
raise RuntimeError, "File does not exists: #{path}" unless exists?(path)
|
67
|
+
end
|
68
|
+
|
69
|
+
def directory
|
70
|
+
@directory ||= connection.directories.get(@bucket)
|
71
|
+
end
|
72
|
+
|
73
|
+
def connection
|
74
|
+
@connection ||= Fog::Storage.new({provider: 'AWS'}.merge(@conf))
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|
@@ -0,0 +1,19 @@
|
|
1
|
+
module Saviour
|
2
|
+
module Uploader
|
3
|
+
class Element
|
4
|
+
attr_reader :version, :method_or_block
|
5
|
+
|
6
|
+
def initialize(version, method_or_block)
|
7
|
+
@version, @method_or_block = version, method_or_block
|
8
|
+
end
|
9
|
+
|
10
|
+
def versioned?
|
11
|
+
!!@version
|
12
|
+
end
|
13
|
+
|
14
|
+
def block?
|
15
|
+
@method_or_block.respond_to?(:call)
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
@@ -0,0 +1,88 @@
|
|
1
|
+
module Saviour
|
2
|
+
module Uploader
|
3
|
+
class ProcessorsRunner
|
4
|
+
attr_writer :file
|
5
|
+
attr_accessor :contents
|
6
|
+
attr_accessor :filename
|
7
|
+
|
8
|
+
def initialize(uploader, version_name)
|
9
|
+
@uploader, @version_name = uploader, version_name
|
10
|
+
end
|
11
|
+
|
12
|
+
def matching_processors
|
13
|
+
@uploader.class.processors.select { |processor| !processor[:element].versioned? || processor[:element].version == @version_name }
|
14
|
+
end
|
15
|
+
|
16
|
+
def file
|
17
|
+
@file ||= Tempfile.new([SecureRandom.hex, ::File.extname(filename)]).tap { |x| x.binmode }
|
18
|
+
end
|
19
|
+
|
20
|
+
def run_element(element, opts, data)
|
21
|
+
if element.block?
|
22
|
+
@uploader.instance_exec(data, filename, &element.method_or_block)
|
23
|
+
else
|
24
|
+
if opts.empty?
|
25
|
+
@uploader.send(element.method_or_block, data, filename)
|
26
|
+
else
|
27
|
+
@uploader.send(element.method_or_block, data, filename, opts)
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
def advance!(processor, previous_type)
|
33
|
+
if processor[:type] != previous_type
|
34
|
+
if processor[:type] == :memory
|
35
|
+
self.contents = ::File.read(file)
|
36
|
+
else
|
37
|
+
file.rewind
|
38
|
+
file.write(contents)
|
39
|
+
file.flush
|
40
|
+
file.rewind
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
def run_processor(processor)
|
46
|
+
element = processor[:element]
|
47
|
+
opts = processor[:opts]
|
48
|
+
|
49
|
+
if processor[:type] == :memory
|
50
|
+
result = run_element(element, opts, contents)
|
51
|
+
|
52
|
+
self.contents = result[0]
|
53
|
+
self.filename = result[1]
|
54
|
+
|
55
|
+
else
|
56
|
+
file.rewind
|
57
|
+
|
58
|
+
result = run_element(element, opts, file)
|
59
|
+
|
60
|
+
self.file = result[0]
|
61
|
+
self.filename = result[1]
|
62
|
+
end
|
63
|
+
end
|
64
|
+
|
65
|
+
def run!(content_data, initial_filename)
|
66
|
+
self.contents = content_data
|
67
|
+
self.filename = initial_filename
|
68
|
+
previous_type = :memory
|
69
|
+
|
70
|
+
matching_processors.each do |processor|
|
71
|
+
advance!(processor, previous_type)
|
72
|
+
|
73
|
+
run_processor(processor)
|
74
|
+
previous_type = processor[:type]
|
75
|
+
end
|
76
|
+
|
77
|
+
if previous_type == :file
|
78
|
+
file.rewind
|
79
|
+
self.contents = ::File.read(file)
|
80
|
+
end
|
81
|
+
|
82
|
+
file.delete
|
83
|
+
|
84
|
+
[contents, filename]
|
85
|
+
end
|
86
|
+
end
|
87
|
+
end
|
88
|
+
end
|
@@ -0,0 +1,41 @@
|
|
1
|
+
module Saviour
|
2
|
+
module Uploader
|
3
|
+
class StoreDirExtractor
|
4
|
+
def initialize(uploader)
|
5
|
+
@uploader = uploader
|
6
|
+
end
|
7
|
+
|
8
|
+
def candidate_store_dirs
|
9
|
+
@candidate_store_dirs ||= @uploader.class.store_dirs
|
10
|
+
end
|
11
|
+
|
12
|
+
def versioned_store_dirs?
|
13
|
+
candidate_store_dirs.any? { |x| x.versioned? && x.version == @uploader.version_name }
|
14
|
+
end
|
15
|
+
|
16
|
+
def versioned_store_dir
|
17
|
+
candidate_store_dirs.select { |x| x.versioned? && x.version == @uploader.version_name }.last if versioned_store_dirs?
|
18
|
+
end
|
19
|
+
|
20
|
+
def non_versioned_store_dir
|
21
|
+
candidate_store_dirs.select { |x| !x.versioned? }.last
|
22
|
+
end
|
23
|
+
|
24
|
+
def store_dir_handler
|
25
|
+
@store_dir_handler ||= versioned_store_dir || non_versioned_store_dir
|
26
|
+
end
|
27
|
+
|
28
|
+
def store_dir
|
29
|
+
@store_dir ||= begin
|
30
|
+
if store_dir_handler
|
31
|
+
if store_dir_handler.block?
|
32
|
+
@uploader.instance_eval(&store_dir_handler.method_or_block)
|
33
|
+
else
|
34
|
+
@uploader.send(store_dir_handler.method_or_block)
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
@@ -0,0 +1,55 @@
|
|
1
|
+
require 'uri'
|
2
|
+
|
3
|
+
module Saviour
|
4
|
+
class UrlSource
|
5
|
+
MAX_REDIRECTS = 10
|
6
|
+
|
7
|
+
def initialize(url)
|
8
|
+
@uri = wrap_uri_string(url)
|
9
|
+
end
|
10
|
+
|
11
|
+
def read
|
12
|
+
with_retry(3) { resolve(@uri) }
|
13
|
+
end
|
14
|
+
|
15
|
+
def original_filename
|
16
|
+
::File.basename(@uri.path)
|
17
|
+
end
|
18
|
+
|
19
|
+
def path
|
20
|
+
@uri.path
|
21
|
+
end
|
22
|
+
|
23
|
+
|
24
|
+
private
|
25
|
+
|
26
|
+
def resolve(uri, max_redirects = MAX_REDIRECTS)
|
27
|
+
raise RuntimeError, "Max number of allowed redirects reached (#{MAX_REDIRECTS}) when resolving #{@uri}" if max_redirects == 0
|
28
|
+
|
29
|
+
response = Net::HTTP.get_response(uri)
|
30
|
+
|
31
|
+
case response
|
32
|
+
when Net::HTTPSuccess
|
33
|
+
response.body
|
34
|
+
when Net::HTTPRedirection
|
35
|
+
resolve(wrap_uri_string(response['location']), max_redirects - 1)
|
36
|
+
else
|
37
|
+
false
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
def wrap_uri_string(url)
|
42
|
+
begin
|
43
|
+
URI(url)
|
44
|
+
rescue URI::InvalidURIError
|
45
|
+
raise ArgumentError, "'#{url}' is not a valid URI"
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
def with_retry(n = 3, &block)
|
50
|
+
raise(RuntimeError, "Connection to #{@uri} failed after 3 attempts.") if n == 0
|
51
|
+
|
52
|
+
block.call || with_retry(n - 1, &block)
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
data/saviour.gemspec
ADDED
@@ -0,0 +1,26 @@
|
|
1
|
+
require './lib/saviour/version'
|
2
|
+
|
3
|
+
Gem::Specification.new do |spec|
|
4
|
+
spec.name = "saviour"
|
5
|
+
spec.version = Saviour::VERSION
|
6
|
+
spec.authors = ["Roger Campos"]
|
7
|
+
spec.email = ["roger@rogercampos.com"]
|
8
|
+
spec.description = %q{File storage handler following active record model lifecycle}
|
9
|
+
spec.summary = %q{File storage handler following active record model lifecycle}
|
10
|
+
spec.homepage = "https://github.com/rogercampos/saviour"
|
11
|
+
spec.license = "MIT"
|
12
|
+
|
13
|
+
spec.files = `git ls-files`.split($/)
|
14
|
+
spec.require_paths = ["lib"]
|
15
|
+
|
16
|
+
spec.required_ruby_version = ">= 2.0.0"
|
17
|
+
|
18
|
+
spec.add_dependency "activerecord", ">= 3.0"
|
19
|
+
spec.add_dependency "activesupport", ">= 3.0"
|
20
|
+
spec.add_dependency "fog-aws"
|
21
|
+
spec.add_dependency "mime-types"
|
22
|
+
spec.add_development_dependency "bundler"
|
23
|
+
spec.add_development_dependency "rake"
|
24
|
+
spec.add_development_dependency "rspec"
|
25
|
+
spec.add_development_dependency "sqlite3"
|
26
|
+
end
|
@@ -0,0 +1,30 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
describe "access to model data from uploaders" do
|
4
|
+
before { Saviour::Config.storage = Saviour::LocalStorage.new(local_prefix: @tmpdir, public_url_prefix: "http://domain.com") }
|
5
|
+
after { Saviour::Config.storage = nil }
|
6
|
+
|
7
|
+
let(:uploader) {
|
8
|
+
Class.new(Saviour::BaseUploader) do
|
9
|
+
store_dir { "/store/dir/#{model.id}" }
|
10
|
+
process { |contents, name| [contents, "#{model.id}-#{attached_as}-#{name}"] }
|
11
|
+
end
|
12
|
+
}
|
13
|
+
|
14
|
+
let(:klass) {
|
15
|
+
a = Class.new(Text)
|
16
|
+
a.attach_file :file, uploader
|
17
|
+
a
|
18
|
+
}
|
19
|
+
|
20
|
+
describe "file store" do
|
21
|
+
it do
|
22
|
+
with_test_file("example.xml") do |example, name|
|
23
|
+
a = klass.create! id: 87
|
24
|
+
expect(a.update_attributes(file: example)).to be_truthy
|
25
|
+
expect(Saviour::Config.storage.exists?(a[:file])).to be_truthy
|
26
|
+
expect(a[:file]).to eq "/store/dir/87/87-file-#{name}"
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
@@ -0,0 +1,138 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
describe "saving a new file" do
|
4
|
+
before { Saviour::Config.storage = Saviour::LocalStorage.new(local_prefix: @tmpdir, public_url_prefix: "http://domain.com") }
|
5
|
+
after { Saviour::Config.storage = nil }
|
6
|
+
|
7
|
+
let(:uploader) {
|
8
|
+
Class.new(Saviour::BaseUploader) {
|
9
|
+
store_dir { "/store/dir" }
|
10
|
+
}
|
11
|
+
}
|
12
|
+
|
13
|
+
let(:klass) {
|
14
|
+
a = Class.new(Test) { include Saviour }
|
15
|
+
a.attach_file :file, uploader
|
16
|
+
a
|
17
|
+
}
|
18
|
+
|
19
|
+
describe "creation" do
|
20
|
+
it do
|
21
|
+
with_test_file("example.xml") do |example|
|
22
|
+
a = klass.create!
|
23
|
+
expect(a.update_attributes(file: example)).to be_truthy
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
it do
|
28
|
+
with_test_file("example.xml") do |example|
|
29
|
+
a = klass.create!
|
30
|
+
a.update_attributes(file: example)
|
31
|
+
|
32
|
+
expect(Saviour::Config.storage.exists?(a[:file])).to be_truthy
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
it do
|
37
|
+
with_test_file("example.xml") do |example, real_filename|
|
38
|
+
a = klass.create!
|
39
|
+
a.update_attributes(file: example)
|
40
|
+
expect(a[:file]).to eq "/store/dir/#{real_filename}"
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
it do
|
45
|
+
with_test_file("example.xml") do |example|
|
46
|
+
a = klass.create!
|
47
|
+
a.update_attributes(file: example)
|
48
|
+
|
49
|
+
example.rewind
|
50
|
+
expect(a.file.read).to eq example.read
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
it do
|
55
|
+
with_test_file("example.xml") do |example|
|
56
|
+
a = klass.create!
|
57
|
+
a.update_attributes(file: example)
|
58
|
+
|
59
|
+
expect(a.file.exists?).to be_truthy
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
it do
|
64
|
+
with_test_file("example.xml") do |example, real_filename|
|
65
|
+
a = klass.create!
|
66
|
+
a.update_attributes(file: example)
|
67
|
+
|
68
|
+
expect(a.file.filename).to eq real_filename
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
it do
|
73
|
+
with_test_file("example.xml") do |example, real_filename|
|
74
|
+
a = klass.create!
|
75
|
+
a.update_attributes(file: example)
|
76
|
+
|
77
|
+
expect(a.file.url).to eq "http://domain.com/store/dir/#{real_filename}"
|
78
|
+
expect(a.file.public_url).to eq a.file.url
|
79
|
+
end
|
80
|
+
end
|
81
|
+
|
82
|
+
it "don't create anything if save do not completes (halt during before_save)" do
|
83
|
+
klass = Class.new(Test) do
|
84
|
+
attr_accessor :fail_at_save
|
85
|
+
before_save { !fail_at_save }
|
86
|
+
include Saviour
|
87
|
+
end
|
88
|
+
klass.attach_file :file, uploader
|
89
|
+
|
90
|
+
expect {
|
91
|
+
a = klass.new
|
92
|
+
a.fail_at_save = true
|
93
|
+
a.save!
|
94
|
+
}.to raise_error(ActiveRecord::RecordNotSaved)
|
95
|
+
|
96
|
+
with_test_file("example.xml") do |example, _|
|
97
|
+
a = klass.new
|
98
|
+
a.fail_at_save = true
|
99
|
+
a.file = example
|
100
|
+
|
101
|
+
expect(Saviour::Config.storage).not_to receive(:write)
|
102
|
+
a.save
|
103
|
+
end
|
104
|
+
end
|
105
|
+
end
|
106
|
+
|
107
|
+
describe "deletion" do
|
108
|
+
it do
|
109
|
+
with_test_file("example.xml") do |example|
|
110
|
+
a = klass.create!
|
111
|
+
a.update_attributes(file: example)
|
112
|
+
expect(a.file.exists?).to be_truthy
|
113
|
+
expect(a.destroy).to be_truthy
|
114
|
+
|
115
|
+
expect(Saviour::Config.storage.exists?(a[:file])).to be_falsey
|
116
|
+
end
|
117
|
+
end
|
118
|
+
end
|
119
|
+
|
120
|
+
describe "updating" do
|
121
|
+
it do
|
122
|
+
with_test_file("example.xml") do |example|
|
123
|
+
a = klass.create!
|
124
|
+
a.update_attributes(file: example)
|
125
|
+
|
126
|
+
expect(Saviour::Config.storage.exists?(a[:file])).to be_truthy
|
127
|
+
previous_location = a[:file]
|
128
|
+
|
129
|
+
with_test_file("camaloon.jpg") do |example_2|
|
130
|
+
a.update_attributes(file: example_2)
|
131
|
+
expect(Saviour::Config.storage.exists?(a[:file])).to be_truthy
|
132
|
+
|
133
|
+
expect(Saviour::Config.storage.exists?(previous_location)).to be_falsey
|
134
|
+
end
|
135
|
+
end
|
136
|
+
end
|
137
|
+
end
|
138
|
+
end
|