saviour 0.2.0
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 +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
data/Rakefile
ADDED
data/lib/saviour.rb
ADDED
@@ -0,0 +1,170 @@
|
|
1
|
+
require 'fileutils'
|
2
|
+
require 'digest/md5'
|
3
|
+
require 'active_support/concern'
|
4
|
+
require 'active_support/core_ext'
|
5
|
+
require 'active_support/core_ext/module/attribute_accessors'
|
6
|
+
require 'fog/aws'
|
7
|
+
|
8
|
+
require 'saviour/processors/digest'
|
9
|
+
|
10
|
+
require 'saviour/version'
|
11
|
+
require 'saviour/base_uploader'
|
12
|
+
require 'saviour/file'
|
13
|
+
require 'saviour/local_storage'
|
14
|
+
require 'saviour/s3_storage'
|
15
|
+
require 'saviour/config'
|
16
|
+
require 'saviour/string_source'
|
17
|
+
require 'saviour/url_source'
|
18
|
+
|
19
|
+
module Saviour
|
20
|
+
class ColumnNamer
|
21
|
+
def initialize(attached_as, version = nil)
|
22
|
+
@attached_as, @version = attached_as, version
|
23
|
+
end
|
24
|
+
|
25
|
+
def name
|
26
|
+
if @version
|
27
|
+
"#{@attached_as}_#{@version}"
|
28
|
+
else
|
29
|
+
@attached_as.to_s
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
class ModelHooks
|
35
|
+
def initialize(model)
|
36
|
+
@model = model
|
37
|
+
end
|
38
|
+
|
39
|
+
def delete!
|
40
|
+
attached_files.each do |column, versions|
|
41
|
+
(versions + [nil]).each { |version| @model.send(column, version).delete if @model.send(column, version).exists? }
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
def save!
|
46
|
+
attached_files.each do |column, versions|
|
47
|
+
base_file_changed = @model.send(column).changed?
|
48
|
+
original_content = @model.send(column).source_data if base_file_changed
|
49
|
+
|
50
|
+
versions.each do |version|
|
51
|
+
if @model.send(column, version).changed?
|
52
|
+
upload_file(column, version)
|
53
|
+
elsif base_file_changed
|
54
|
+
@model.send(column, version).assign(StringSource.new(original_content, default_version_filename(column, version)))
|
55
|
+
upload_file(column, version)
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
upload_file(column, nil) if base_file_changed
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
def validate!
|
64
|
+
validations.each do |column, method_or_blocks|
|
65
|
+
(attached_files[column] + [nil]).each do |version|
|
66
|
+
if @model.send(column, version).changed?
|
67
|
+
method_or_blocks.each { |method_or_block| run_validation(column, version, method_or_block) }
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
def default_version_filename(column, version)
|
74
|
+
filename = @model.send(column).filename_to_be_assigned
|
75
|
+
"#{::File.basename(filename, ".*")}_#{version}#{::File.extname(filename)}"
|
76
|
+
end
|
77
|
+
|
78
|
+
def upload_file(column, version)
|
79
|
+
name = ColumnNamer.new(column, version).name
|
80
|
+
Config.storage.delete(@model.read_attribute(name)) if @model.read_attribute(name)
|
81
|
+
new_path = @model.send(column, version).write
|
82
|
+
@model.update_column(name, new_path)
|
83
|
+
end
|
84
|
+
|
85
|
+
def attached_files
|
86
|
+
@model.class.attached_files || {}
|
87
|
+
end
|
88
|
+
|
89
|
+
def run_validation(column, version, method_or_block)
|
90
|
+
data = @model.send(column, version).source_data
|
91
|
+
filename = @model.send(column, version).filename_to_be_assigned
|
92
|
+
opts = {attached_as: column, version: version}
|
93
|
+
|
94
|
+
if method_or_block.respond_to?(:call)
|
95
|
+
if method_or_block.arity == 2
|
96
|
+
@model.instance_exec(data, filename, &method_or_block)
|
97
|
+
else
|
98
|
+
@model.instance_exec(data, filename, opts, &method_or_block)
|
99
|
+
end
|
100
|
+
else
|
101
|
+
if @model.method(method_or_block).arity == 2
|
102
|
+
@model.send(method_or_block, data, filename)
|
103
|
+
else
|
104
|
+
@model.send(method_or_block, data, filename, opts)
|
105
|
+
end
|
106
|
+
end
|
107
|
+
end
|
108
|
+
|
109
|
+
def validations
|
110
|
+
@model.class.__saviour_validations || {}
|
111
|
+
end
|
112
|
+
end
|
113
|
+
|
114
|
+
extend ActiveSupport::Concern
|
115
|
+
|
116
|
+
NoActiveRecordDetected = Class.new(StandardError)
|
117
|
+
|
118
|
+
included do
|
119
|
+
raise(NoActiveRecordDetected, "Error: ActiveRecord not detected in #{self}") unless self.ancestors.include?(ActiveRecord::Base)
|
120
|
+
|
121
|
+
class_attribute(:attached_files, :__saviour_validations)
|
122
|
+
|
123
|
+
after_destroy { ModelHooks.new(self).delete! }
|
124
|
+
after_save { ModelHooks.new(self).save! }
|
125
|
+
validate { ModelHooks.new(self).validate! }
|
126
|
+
end
|
127
|
+
|
128
|
+
def reload
|
129
|
+
self.class.attached_files.each do |attach_as, versions|
|
130
|
+
(versions + [nil]).each { |version| instance_variable_set("@__uploader_#{version}_#{attach_as}", nil) }
|
131
|
+
end
|
132
|
+
super
|
133
|
+
end
|
134
|
+
|
135
|
+
module ClassMethods
|
136
|
+
def attach_file(attach_as, uploader_klass)
|
137
|
+
self.attached_files ||= {}
|
138
|
+
versions = uploader_klass.versions || []
|
139
|
+
|
140
|
+
([nil] + versions).each do |version|
|
141
|
+
column_name = ColumnNamer.new(attach_as, version).name
|
142
|
+
|
143
|
+
if self.table_exists? && !self.column_names.include?(column_name.to_s)
|
144
|
+
raise RuntimeError, "#{self} must have a database string column named '#{column_name}'"
|
145
|
+
end
|
146
|
+
end
|
147
|
+
|
148
|
+
define_method(attach_as) do |version = nil|
|
149
|
+
instance_variable_get("@__uploader_#{version}_#{attach_as}") ||
|
150
|
+
instance_variable_set("@__uploader_#{version}_#{attach_as}", ::Saviour::File.new(uploader_klass, self, attach_as, version))
|
151
|
+
end
|
152
|
+
|
153
|
+
define_method("#{attach_as}=") do |value|
|
154
|
+
send(attach_as).assign(value)
|
155
|
+
end
|
156
|
+
|
157
|
+
define_method("#{attach_as}_changed?") do
|
158
|
+
send(attach_as).changed?
|
159
|
+
end
|
160
|
+
|
161
|
+
self.attached_files[attach_as] ||= []
|
162
|
+
self.attached_files[attach_as] += versions
|
163
|
+
end
|
164
|
+
|
165
|
+
def attach_validation(attach_as, method_name = nil, &block)
|
166
|
+
self.__saviour_validations ||= Hash.new { [] }
|
167
|
+
self.__saviour_validations[attach_as] += [method_name || block]
|
168
|
+
end
|
169
|
+
end
|
170
|
+
end
|
@@ -0,0 +1,81 @@
|
|
1
|
+
require_relative 'uploader/element'
|
2
|
+
require_relative 'uploader/store_dir_extractor'
|
3
|
+
require_relative 'uploader/processors_runner'
|
4
|
+
|
5
|
+
module Saviour
|
6
|
+
class BaseUploader
|
7
|
+
attr_reader :version_name
|
8
|
+
|
9
|
+
def initialize(opts = {})
|
10
|
+
@version_name = opts[:version]
|
11
|
+
@data = opts.fetch(:data, {})
|
12
|
+
end
|
13
|
+
|
14
|
+
def method_missing(name, *args, &block)
|
15
|
+
if @data.key?(name)
|
16
|
+
@data[name]
|
17
|
+
else
|
18
|
+
super
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
def respond_to?(name, *)
|
23
|
+
@data.key?(name) || super
|
24
|
+
end
|
25
|
+
|
26
|
+
def write(contents, filename)
|
27
|
+
store_dir = Uploader::StoreDirExtractor.new(self).store_dir
|
28
|
+
raise RuntimeError, "Please use `store_dir` before trying to write" unless store_dir
|
29
|
+
|
30
|
+
contents, filename = Uploader::ProcessorsRunner.new(self, @version_name).run!(contents, filename) if Config.processing_enabled
|
31
|
+
|
32
|
+
path = ::File.join(store_dir, filename)
|
33
|
+
Config.storage.write(contents, path)
|
34
|
+
path
|
35
|
+
end
|
36
|
+
|
37
|
+
class << self
|
38
|
+
def store_dirs
|
39
|
+
@store_dirs ||= []
|
40
|
+
end
|
41
|
+
|
42
|
+
def processors
|
43
|
+
@processors ||= []
|
44
|
+
end
|
45
|
+
|
46
|
+
def versions
|
47
|
+
@versions ||= []
|
48
|
+
end
|
49
|
+
|
50
|
+
def process(name = nil, opts = {}, type = :memory, &block)
|
51
|
+
element = Uploader::Element.new(@current_version, name || block)
|
52
|
+
|
53
|
+
if block_given?
|
54
|
+
processors.push({element: element, type: type})
|
55
|
+
else
|
56
|
+
processors.push({element: element, type: type, opts: opts})
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
def process_with_file(name = nil, opts = {}, &block)
|
61
|
+
process(name, opts, :file, &block)
|
62
|
+
end
|
63
|
+
|
64
|
+
|
65
|
+
def store_dir(name = nil, &block)
|
66
|
+
element = Uploader::Element.new(@current_version, name || block)
|
67
|
+
store_dirs.push(element)
|
68
|
+
end
|
69
|
+
|
70
|
+
def version(name, &block)
|
71
|
+
versions.push(name)
|
72
|
+
|
73
|
+
if block
|
74
|
+
@current_version = name
|
75
|
+
instance_eval(&block)
|
76
|
+
@current_version = nil
|
77
|
+
end
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|
81
|
+
end
|
@@ -0,0 +1,13 @@
|
|
1
|
+
module Saviour
|
2
|
+
module Config
|
3
|
+
extend self
|
4
|
+
|
5
|
+
attr_writer :storage
|
6
|
+
def storage
|
7
|
+
@storage || raise(RuntimeError, "You need to provide a storage! Set Saviour::Config.storage = xxx")
|
8
|
+
end
|
9
|
+
|
10
|
+
attr_accessor :processing_enabled
|
11
|
+
@processing_enabled = true
|
12
|
+
end
|
13
|
+
end
|
data/lib/saviour/file.rb
ADDED
@@ -0,0 +1,124 @@
|
|
1
|
+
require 'securerandom'
|
2
|
+
|
3
|
+
module Saviour
|
4
|
+
class File
|
5
|
+
class SourceFilenameExtractor
|
6
|
+
def initialize(source)
|
7
|
+
@source = source
|
8
|
+
end
|
9
|
+
|
10
|
+
def detected_filename
|
11
|
+
original_filename || path_filename
|
12
|
+
end
|
13
|
+
|
14
|
+
def original_filename
|
15
|
+
@source.respond_to?(:original_filename) && @source.original_filename.present? && @source.original_filename
|
16
|
+
end
|
17
|
+
|
18
|
+
def path_filename
|
19
|
+
@source.respond_to?(:path) && @source.path.present? && ::File.basename(@source.path)
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
|
24
|
+
def initialize(uploader_klass, model, attached_as, version = nil)
|
25
|
+
@uploader_klass, @model, @attached_as = uploader_klass, model, attached_as
|
26
|
+
@version = version
|
27
|
+
|
28
|
+
@persisted = !!persisted_path
|
29
|
+
@source_was = @source = nil
|
30
|
+
end
|
31
|
+
|
32
|
+
def exists?
|
33
|
+
persisted? && Config.storage.exists?(persisted_path)
|
34
|
+
end
|
35
|
+
|
36
|
+
def read
|
37
|
+
persisted? && exists? && Config.storage.read(persisted_path)
|
38
|
+
end
|
39
|
+
|
40
|
+
def delete
|
41
|
+
persisted? && exists? && Config.storage.delete(persisted_path)
|
42
|
+
end
|
43
|
+
|
44
|
+
def public_url
|
45
|
+
persisted? && Config.storage.public_url(persisted_path)
|
46
|
+
end
|
47
|
+
|
48
|
+
alias_method :url, :public_url
|
49
|
+
|
50
|
+
def assign(object)
|
51
|
+
raise(RuntimeError, "must respond to `read`") if object && !object.respond_to?(:read)
|
52
|
+
|
53
|
+
@source_data = nil
|
54
|
+
@source = object
|
55
|
+
@persisted = !object
|
56
|
+
|
57
|
+
object
|
58
|
+
end
|
59
|
+
|
60
|
+
def persisted?
|
61
|
+
@persisted
|
62
|
+
end
|
63
|
+
|
64
|
+
def changed?
|
65
|
+
@source_was != @source
|
66
|
+
end
|
67
|
+
|
68
|
+
def filename
|
69
|
+
persisted? && ::File.basename(persisted_path)
|
70
|
+
end
|
71
|
+
|
72
|
+
def with_copy
|
73
|
+
raise "must be persisted" unless persisted?
|
74
|
+
|
75
|
+
Tempfile.open([::File.basename(filename, ".*"), ::File.extname(filename)]) do |file|
|
76
|
+
begin
|
77
|
+
file.binmode
|
78
|
+
file.write(read)
|
79
|
+
file.flush
|
80
|
+
file.rewind
|
81
|
+
|
82
|
+
yield(file)
|
83
|
+
ensure
|
84
|
+
file.close
|
85
|
+
file.delete
|
86
|
+
end
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|
90
|
+
def filename_to_be_assigned
|
91
|
+
changed? ? (SourceFilenameExtractor.new(@source).detected_filename || SecureRandom.hex) : nil
|
92
|
+
end
|
93
|
+
|
94
|
+
def write
|
95
|
+
raise(RuntimeError, "You must provide a source to read from first") unless @source
|
96
|
+
|
97
|
+
path = uploader.write(source_data, filename_to_be_assigned)
|
98
|
+
@source_was = @source
|
99
|
+
@persisted = true
|
100
|
+
path
|
101
|
+
end
|
102
|
+
|
103
|
+
def source_data
|
104
|
+
@source_data ||= @source.read
|
105
|
+
end
|
106
|
+
|
107
|
+
def blank?
|
108
|
+
!@source && !persisted?
|
109
|
+
end
|
110
|
+
|
111
|
+
|
112
|
+
private
|
113
|
+
|
114
|
+
def uploader
|
115
|
+
@uploader ||= @uploader_klass.new(version: @version, data: {model: @model, attached_as: @attached_as})
|
116
|
+
end
|
117
|
+
|
118
|
+
def persisted_path
|
119
|
+
if @model.persisted? || @model.destroyed?
|
120
|
+
@model.read_attribute(ColumnNamer.new(@attached_as, @version).name)
|
121
|
+
end
|
122
|
+
end
|
123
|
+
end
|
124
|
+
end
|
@@ -0,0 +1,72 @@
|
|
1
|
+
require 'fileutils'
|
2
|
+
|
3
|
+
module Saviour
|
4
|
+
class LocalStorage
|
5
|
+
def initialize(opts = {})
|
6
|
+
@local_prefix = opts[:local_prefix]
|
7
|
+
@public_url_prefix = opts[:public_url_prefix]
|
8
|
+
@overwrite_protection = opts.fetch(:overwrite_protection, true)
|
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
|
+
dir = ::File.dirname(real_path(path))
|
15
|
+
FileUtils.mkdir_p(dir) unless ::File.directory?(dir)
|
16
|
+
|
17
|
+
::File.open(real_path(path), "w") do |f|
|
18
|
+
f.binmode
|
19
|
+
f.write(contents)
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
def read(path)
|
24
|
+
assert_exists(path)
|
25
|
+
::File.open(real_path(path)).read
|
26
|
+
end
|
27
|
+
|
28
|
+
def delete(path)
|
29
|
+
assert_exists(path)
|
30
|
+
::File.delete(real_path(path))
|
31
|
+
ensure_removed_empty_dir(path)
|
32
|
+
end
|
33
|
+
|
34
|
+
def exists?(path)
|
35
|
+
::File.file?(real_path(path))
|
36
|
+
end
|
37
|
+
|
38
|
+
def public_url(path)
|
39
|
+
raise(RuntimeError, "You must provide a `public_url_prefix`") unless public_url_prefix
|
40
|
+
::File.join(public_url_prefix, path)
|
41
|
+
end
|
42
|
+
|
43
|
+
|
44
|
+
private
|
45
|
+
|
46
|
+
def public_url_prefix
|
47
|
+
if @public_url_prefix.respond_to?(:call)
|
48
|
+
@public_url_prefix.call
|
49
|
+
else
|
50
|
+
@public_url_prefix
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
def real_path(path)
|
55
|
+
@local_prefix ? ::File.join(@local_prefix, path) : path
|
56
|
+
end
|
57
|
+
|
58
|
+
def assert_exists(path)
|
59
|
+
raise(RuntimeError, "File does not exists: #{path}") unless ::File.file?(real_path(path))
|
60
|
+
end
|
61
|
+
|
62
|
+
def ensure_removed_empty_dir(path)
|
63
|
+
basedir = ::File.dirname(path)
|
64
|
+
return if basedir == "."
|
65
|
+
|
66
|
+
while basedir != "/" && Dir.entries(real_path(basedir)) == [".", ".."]
|
67
|
+
Dir.rmdir(real_path(basedir))
|
68
|
+
basedir = ::File.dirname(basedir)
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|