leifcr-refile 0.6.3
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/app/assets/javascripts/refile.js +125 -0
- data/config/locales/en.yml +10 -0
- data/config/routes.rb +5 -0
- data/lib/refile.rb +510 -0
- data/lib/refile/app.rb +186 -0
- data/lib/refile/attacher.rb +190 -0
- data/lib/refile/attachment.rb +108 -0
- data/lib/refile/attachment/active_record.rb +133 -0
- data/lib/refile/attachment_definition.rb +83 -0
- data/lib/refile/backend/file_system.rb +120 -0
- data/lib/refile/backend/s3.rb +1 -0
- data/lib/refile/backend_macros.rb +45 -0
- data/lib/refile/custom_logger.rb +48 -0
- data/lib/refile/file.rb +102 -0
- data/lib/refile/file_double.rb +13 -0
- data/lib/refile/image_processing.rb +1 -0
- data/lib/refile/rails.rb +54 -0
- data/lib/refile/rails/attachment_helper.rb +121 -0
- data/lib/refile/random_hasher.rb +11 -0
- data/lib/refile/signature.rb +36 -0
- data/lib/refile/simple_form.rb +17 -0
- data/lib/refile/type.rb +28 -0
- data/lib/refile/version.rb +3 -0
- data/spec/refile/active_record_helper.rb +35 -0
- data/spec/refile/app_spec.rb +424 -0
- data/spec/refile/attachment/active_record_spec.rb +568 -0
- data/spec/refile/attachment_helper_spec.rb +78 -0
- data/spec/refile/attachment_spec.rb +589 -0
- data/spec/refile/backend/file_system_spec.rb +5 -0
- data/spec/refile/backend_examples.rb +228 -0
- data/spec/refile/backend_macros_spec.rb +83 -0
- data/spec/refile/custom_logger_spec.rb +21 -0
- data/spec/refile/features/direct_upload_spec.rb +63 -0
- data/spec/refile/features/multiple_upload_spec.rb +122 -0
- data/spec/refile/features/normal_upload_spec.rb +144 -0
- data/spec/refile/features/presigned_upload_spec.rb +31 -0
- data/spec/refile/features/simple_form_spec.rb +8 -0
- data/spec/refile/fixtures/hello.txt +1 -0
- data/spec/refile/fixtures/image.jpg +0 -0
- data/spec/refile/fixtures/large.txt +44 -0
- data/spec/refile/fixtures/monkey.txt +1 -0
- data/spec/refile/fixtures/world.txt +1 -0
- data/spec/refile/spec_helper.rb +72 -0
- data/spec/refile_spec.rb +355 -0
- metadata +143 -0
@@ -0,0 +1,133 @@
|
|
1
|
+
module Refile
|
2
|
+
module ActiveRecord
|
3
|
+
module Attachment
|
4
|
+
include Refile::Attachment
|
5
|
+
|
6
|
+
# Attachment method which hooks into ActiveRecord models
|
7
|
+
#
|
8
|
+
# @param [true, false] destroy Whether to remove the stored file if its model is destroyed
|
9
|
+
# @return [void]
|
10
|
+
# @see Refile::Attachment#attachment
|
11
|
+
def attachment(name, raise_errors: false, destroy: true, **options)
|
12
|
+
super(name, raise_errors: raise_errors, **options)
|
13
|
+
|
14
|
+
attacher = "#{name}_attacher"
|
15
|
+
|
16
|
+
validate do
|
17
|
+
if send(attacher).present?
|
18
|
+
send(attacher).valid?
|
19
|
+
errors = send(attacher).errors
|
20
|
+
errors.each do |error|
|
21
|
+
self.errors.add(name, *error)
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
define_method "#{name}=" do |value|
|
27
|
+
send("#{name}_id_will_change!") if respond_to?("#{name}_id_will_change!")
|
28
|
+
super(value)
|
29
|
+
end
|
30
|
+
|
31
|
+
define_method "remove_#{name}=" do |value|
|
32
|
+
send("#{name}_id_will_change!")
|
33
|
+
super(value)
|
34
|
+
end
|
35
|
+
|
36
|
+
define_method "remote_#{name}_url=" do |value|
|
37
|
+
send("#{name}_id_will_change!")
|
38
|
+
super(value)
|
39
|
+
end
|
40
|
+
|
41
|
+
before_save do
|
42
|
+
send(attacher).store!
|
43
|
+
end
|
44
|
+
|
45
|
+
after_destroy do
|
46
|
+
send(attacher).delete! if destroy
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
# Macro which generates accessors for assigning multiple attachments at
|
51
|
+
# once. This is primarily useful together with multiple file uploads.
|
52
|
+
#
|
53
|
+
# The name of the generated accessors will be the name of the association
|
54
|
+
# and the name of the attachment in the associated model. So if a `Post`
|
55
|
+
# accepts attachments for `images`, and the attachment in the `Image`
|
56
|
+
# model is named `file`, then the accessors will be named `images_files`.
|
57
|
+
#
|
58
|
+
# @example in model
|
59
|
+
# class Post
|
60
|
+
# has_many :images, dependent: :destroy
|
61
|
+
# accepts_attachments_for :images
|
62
|
+
# end
|
63
|
+
#
|
64
|
+
# @example in associated model
|
65
|
+
# class Image
|
66
|
+
# attachment :image
|
67
|
+
# end
|
68
|
+
#
|
69
|
+
# @example in form
|
70
|
+
# <%= form_for @post do |form| %>
|
71
|
+
# <%= form.attachment_field :images_files, multiple: true %>
|
72
|
+
# <% end %>
|
73
|
+
#
|
74
|
+
# @param [Symbol] association_name Name of the association
|
75
|
+
# @param [Symbol] attachment Name of the attachment in the associated model
|
76
|
+
# @param [Symbol] append If true, new files are appended instead of replacing the entire list of associated models.
|
77
|
+
# @return [void]
|
78
|
+
def accepts_attachments_for(association_name, attachment: :file, append: false)
|
79
|
+
association = reflect_on_association(association_name)
|
80
|
+
attachment_pluralized = attachment.to_s.pluralize
|
81
|
+
name = "#{association_name}_#{attachment_pluralized}"
|
82
|
+
|
83
|
+
mod = Module.new do
|
84
|
+
define_method :"#{name}_attachment_definition" do
|
85
|
+
association.klass.send("#{attachment}_attachment_definition")
|
86
|
+
end
|
87
|
+
|
88
|
+
define_method(:method_missing) do |method|
|
89
|
+
if method == attachment_pluralized.to_sym
|
90
|
+
raise NoMethodError, "wrong association name #{method}, use like this #{name}"
|
91
|
+
else
|
92
|
+
super(method)
|
93
|
+
end
|
94
|
+
end
|
95
|
+
|
96
|
+
define_method :"#{name}_data" do
|
97
|
+
if send(association_name).all? { |record| record.send("#{attachment}_attacher").valid? }
|
98
|
+
send(association_name).map(&:"#{attachment}_data").select(&:present?)
|
99
|
+
end
|
100
|
+
end
|
101
|
+
|
102
|
+
define_method :"#{name}" do
|
103
|
+
send(association_name).map(&attachment)
|
104
|
+
end
|
105
|
+
|
106
|
+
define_method :"#{name}=" do |files|
|
107
|
+
cache, files = files.partition { |file| file.is_a?(String) }
|
108
|
+
|
109
|
+
cache = Refile.parse_json(cache.first)
|
110
|
+
|
111
|
+
if not append and (files.present? or cache.present?)
|
112
|
+
send("#{association_name}=", [])
|
113
|
+
end
|
114
|
+
|
115
|
+
if files.empty? and cache.present?
|
116
|
+
cache.select(&:present?).each do |file|
|
117
|
+
send(association_name).build(attachment => file.to_json)
|
118
|
+
end
|
119
|
+
else
|
120
|
+
files.select(&:present?).each do |file|
|
121
|
+
send(association_name).build(attachment => file)
|
122
|
+
end
|
123
|
+
end
|
124
|
+
end
|
125
|
+
end
|
126
|
+
|
127
|
+
include mod
|
128
|
+
end
|
129
|
+
end
|
130
|
+
end
|
131
|
+
end
|
132
|
+
|
133
|
+
::ActiveRecord::Base.extend(Refile::ActiveRecord::Attachment)
|
@@ -0,0 +1,83 @@
|
|
1
|
+
module Refile
|
2
|
+
# @api private
|
3
|
+
class AttachmentDefinition
|
4
|
+
attr_reader :record, :name, :cache, :store, :options, :type, :valid_content_types
|
5
|
+
attr_accessor :remove
|
6
|
+
|
7
|
+
def initialize(name, cache:, store:, raise_errors: true, type: nil, extension: nil, content_type: nil)
|
8
|
+
@name = name
|
9
|
+
@raise_errors = raise_errors
|
10
|
+
@cache_name = cache
|
11
|
+
@store_name = store
|
12
|
+
@type = type
|
13
|
+
@extension = extension
|
14
|
+
@valid_content_types = [content_type].flatten if content_type
|
15
|
+
@valid_content_types ||= Refile.types.fetch(type).content_type if type
|
16
|
+
end
|
17
|
+
|
18
|
+
def cache
|
19
|
+
Refile.backends.fetch(@cache_name.to_s)
|
20
|
+
end
|
21
|
+
|
22
|
+
def store
|
23
|
+
Refile.backends.fetch(@store_name.to_s)
|
24
|
+
end
|
25
|
+
|
26
|
+
def accept
|
27
|
+
if valid_content_types
|
28
|
+
valid_content_types.join(",")
|
29
|
+
elsif valid_extensions
|
30
|
+
valid_extensions.map { |e| ".#{e}" }.join(",")
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
def raise_errors?
|
35
|
+
@raise_errors
|
36
|
+
end
|
37
|
+
|
38
|
+
def valid_extensions
|
39
|
+
return unless @extension
|
40
|
+
if @extension.is_a?(Proc)
|
41
|
+
Array(@extension.call)
|
42
|
+
else
|
43
|
+
Array(@extension)
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
def validate(attacher)
|
48
|
+
extension = attacher.extension.to_s.downcase
|
49
|
+
content_type = attacher.content_type.to_s.downcase
|
50
|
+
content_type = content_type.split(";").first unless content_type.empty?
|
51
|
+
|
52
|
+
errors = []
|
53
|
+
errors << extension_error_params(extension) if invalid_extension?(extension)
|
54
|
+
errors << content_type_error_params(content_type) if invalid_content_type?(content_type)
|
55
|
+
errors << :too_large if cache.max_size and attacher.size and attacher.size >= cache.max_size
|
56
|
+
errors << :zero_byte_detected if attacher.size.to_i.zero?
|
57
|
+
errors
|
58
|
+
end
|
59
|
+
|
60
|
+
private
|
61
|
+
|
62
|
+
def extension_error_params(extension)
|
63
|
+
[:invalid_extension, extension: format_param(extension), permitted: valid_extensions.to_sentence]
|
64
|
+
end
|
65
|
+
|
66
|
+
def content_type_error_params(content_type)
|
67
|
+
[:invalid_content_type, content: format_param(content_type), permitted: valid_content_types.to_sentence]
|
68
|
+
end
|
69
|
+
|
70
|
+
def invalid_extension?(extension)
|
71
|
+
extension_included = valid_extensions && valid_extensions.map(&:downcase).include?(extension)
|
72
|
+
valid_extensions and not extension_included
|
73
|
+
end
|
74
|
+
|
75
|
+
def invalid_content_type?(content_type)
|
76
|
+
valid_content_types and not valid_content_types.include?(content_type)
|
77
|
+
end
|
78
|
+
|
79
|
+
def format_param(param)
|
80
|
+
param.empty? ? I18n.t("refile.empty_param") : param
|
81
|
+
end
|
82
|
+
end
|
83
|
+
end
|
@@ -0,0 +1,120 @@
|
|
1
|
+
module Refile
|
2
|
+
module Backend
|
3
|
+
# A backend which stores uploaded files in the local filesystem
|
4
|
+
#
|
5
|
+
# @example
|
6
|
+
# backend = Refile::Backend::FileSystem.new("some/path")
|
7
|
+
# file = backend.upload(StringIO.new("hello"))
|
8
|
+
# backend.read(file.id) # => "hello"
|
9
|
+
class FileSystem
|
10
|
+
extend Refile::BackendMacros
|
11
|
+
|
12
|
+
# @return [String] the directory where files are stored
|
13
|
+
attr_reader :directory
|
14
|
+
|
15
|
+
# @return [String] the maximum size of files stored in this backend
|
16
|
+
attr_reader :max_size
|
17
|
+
|
18
|
+
# Creates the given directory if it doesn't exist.
|
19
|
+
#
|
20
|
+
# @param [String] directory The path to a directory where files should be stored
|
21
|
+
# @param [Integer, nil] max_size The maximum size of an uploaded file
|
22
|
+
# @param [#hash] hasher A hasher which is used to generate ids from files
|
23
|
+
def initialize(directory, max_size: nil, hasher: Refile::RandomHasher.new)
|
24
|
+
@hasher = hasher
|
25
|
+
@directory = directory
|
26
|
+
@max_size = max_size
|
27
|
+
|
28
|
+
FileUtils.mkdir_p(@directory)
|
29
|
+
end
|
30
|
+
|
31
|
+
# Upload a file into this backend
|
32
|
+
#
|
33
|
+
# @param [IO] uploadable An uploadable IO-like object.
|
34
|
+
# @return [Refile::File] The uploaded file
|
35
|
+
verify_uploadable def upload(uploadable)
|
36
|
+
id = @hasher.hash(uploadable)
|
37
|
+
IO.copy_stream(uploadable, path(id))
|
38
|
+
|
39
|
+
Refile::File.new(self, id)
|
40
|
+
ensure
|
41
|
+
uploadable.close
|
42
|
+
end
|
43
|
+
|
44
|
+
# Get a file from this backend.
|
45
|
+
#
|
46
|
+
# Note that this method will always return a {Refile::File} object, even
|
47
|
+
# if a file with the given id does not exist in this backend. Use
|
48
|
+
# {FileSystem#exists?} to check if the file actually exists.
|
49
|
+
#
|
50
|
+
# @param [String] id The id of the file
|
51
|
+
# @return [Refile::File] The retrieved file
|
52
|
+
verify_id def get(id)
|
53
|
+
Refile::File.new(self, id)
|
54
|
+
end
|
55
|
+
|
56
|
+
# Delete a file from this backend
|
57
|
+
#
|
58
|
+
# @param [String] id The id of the file
|
59
|
+
# @return [void]
|
60
|
+
verify_id def delete(id)
|
61
|
+
FileUtils.rm(path(id)) if exists?(id)
|
62
|
+
end
|
63
|
+
|
64
|
+
# Return an IO object for the uploaded file which can be used to read its
|
65
|
+
# content.
|
66
|
+
#
|
67
|
+
# @param [String] id The id of the file
|
68
|
+
# @return [IO] An IO object containing the file contents
|
69
|
+
verify_id def open(id)
|
70
|
+
::File.open(path(id), "rb")
|
71
|
+
end
|
72
|
+
|
73
|
+
# Return the entire contents of the uploaded file as a String.
|
74
|
+
#
|
75
|
+
# @param [String] id The id of the file
|
76
|
+
# @return [String] The file's contents
|
77
|
+
verify_id def read(id)
|
78
|
+
::File.read(path(id)) if exists?(id)
|
79
|
+
end
|
80
|
+
|
81
|
+
# Return the size in bytes of the uploaded file.
|
82
|
+
#
|
83
|
+
# @param [String] id The id of the file
|
84
|
+
# @return [Integer] The file's size
|
85
|
+
verify_id def size(id)
|
86
|
+
::File.size(path(id)) if exists?(id)
|
87
|
+
end
|
88
|
+
|
89
|
+
# Return whether the file with the given id exists in this backend.
|
90
|
+
#
|
91
|
+
# @param [String] id The id of the file
|
92
|
+
# @return [Boolean]
|
93
|
+
verify_id def exists?(id)
|
94
|
+
::File.exist?(path(id))
|
95
|
+
end
|
96
|
+
|
97
|
+
# Remove all files in this backend. You must confirm the deletion by
|
98
|
+
# passing the symbol `:confirm` as an argument to this method.
|
99
|
+
#
|
100
|
+
# @example
|
101
|
+
# backend.clear!(:confirm)
|
102
|
+
# @raise [Refile::Confirm] Unless the `:confirm` symbol has been passed.
|
103
|
+
# @param [:confirm] confirm Pass the symbol `:confirm` to confirm deletion.
|
104
|
+
# @return [void]
|
105
|
+
def clear!(confirm = nil)
|
106
|
+
raise Refile::Confirm unless confirm == :confirm
|
107
|
+
FileUtils.rm_rf(@directory)
|
108
|
+
FileUtils.mkdir_p(@directory)
|
109
|
+
end
|
110
|
+
|
111
|
+
# Return the full path of the uploaded file with the given id.
|
112
|
+
#
|
113
|
+
# @param [String] id The id of the file
|
114
|
+
# @return [String]
|
115
|
+
verify_id def path(id)
|
116
|
+
::File.join(@directory, id)
|
117
|
+
end
|
118
|
+
end
|
119
|
+
end
|
120
|
+
end
|
@@ -0,0 +1 @@
|
|
1
|
+
raise "[Refile] the S3 backend has been extracted into a separate gem, see https://github.com/refile/refile-s3"
|
@@ -0,0 +1,45 @@
|
|
1
|
+
module Refile
|
2
|
+
# Macros which make it easier to write secure backends.
|
3
|
+
#
|
4
|
+
# @api private
|
5
|
+
module BackendMacros
|
6
|
+
def verify_id(method)
|
7
|
+
mod = Module.new do
|
8
|
+
define_method(method) do |id|
|
9
|
+
id = self.class.decode_id(id)
|
10
|
+
if self.class.valid_id?(id)
|
11
|
+
super(id)
|
12
|
+
else
|
13
|
+
raise Refile::InvalidID
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
17
|
+
prepend mod
|
18
|
+
end
|
19
|
+
|
20
|
+
def verify_uploadable(method)
|
21
|
+
mod = Module.new do
|
22
|
+
define_method(method) do |uploadable|
|
23
|
+
[:size, :read, :eof?, :rewind, :close].each do |m|
|
24
|
+
unless uploadable.respond_to?(m)
|
25
|
+
raise Refile::InvalidFile, "does not respond to `#{m}`."
|
26
|
+
end
|
27
|
+
end
|
28
|
+
if max_size and uploadable.size > max_size
|
29
|
+
raise Refile::InvalidMaxSize, "#{uploadable.inspect} is too large"
|
30
|
+
end
|
31
|
+
super(uploadable)
|
32
|
+
end
|
33
|
+
end
|
34
|
+
prepend mod
|
35
|
+
end
|
36
|
+
|
37
|
+
def valid_id?(id)
|
38
|
+
id =~ /\A[a-z0-9]+\z/i
|
39
|
+
end
|
40
|
+
|
41
|
+
def decode_id(id)
|
42
|
+
id.to_s
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
@@ -0,0 +1,48 @@
|
|
1
|
+
require "rack/body_proxy"
|
2
|
+
|
3
|
+
module Refile
|
4
|
+
# @api private
|
5
|
+
class CustomLogger
|
6
|
+
LOG_FORMAT = %(%s: [%s] %s "%s%s" %d %0.1fms\n)
|
7
|
+
|
8
|
+
def initialize(app, prefix, logger_proc)
|
9
|
+
@app = app
|
10
|
+
@prefix = prefix
|
11
|
+
@logger_proc = logger_proc
|
12
|
+
end
|
13
|
+
|
14
|
+
def call(env)
|
15
|
+
began_at = Time.now
|
16
|
+
status, header, body = @app.call(env)
|
17
|
+
body = Rack::BodyProxy.new(body) { log(env, status, began_at) }
|
18
|
+
[status, header, body]
|
19
|
+
end
|
20
|
+
|
21
|
+
private
|
22
|
+
|
23
|
+
def log(env, status, began_at)
|
24
|
+
now = Time.now
|
25
|
+
logger.info do
|
26
|
+
format(
|
27
|
+
LOG_FORMAT,
|
28
|
+
@prefix,
|
29
|
+
now.strftime("%F %T %z"),
|
30
|
+
env["REQUEST_METHOD"],
|
31
|
+
env["PATH_INFO"],
|
32
|
+
env["QUERY_STRING"].empty? ? "" : "?" + env["QUERY_STRING"],
|
33
|
+
status.to_s[0..3],
|
34
|
+
(now - began_at) * 1000
|
35
|
+
)
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
def logger
|
40
|
+
@logger ||= @logger_proc.call
|
41
|
+
@logger || fallback_logger
|
42
|
+
end
|
43
|
+
|
44
|
+
def fallback_logger
|
45
|
+
@fallback_logger ||= Logger.new(nil)
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
data/lib/refile/file.rb
ADDED
@@ -0,0 +1,102 @@
|
|
1
|
+
module Refile
|
2
|
+
class File
|
3
|
+
# @return [Backend] the backend the file is stored in
|
4
|
+
attr_reader :backend
|
5
|
+
|
6
|
+
# @return [String] the id of the file
|
7
|
+
attr_reader :id
|
8
|
+
|
9
|
+
# @api private
|
10
|
+
def initialize(backend, id)
|
11
|
+
@backend = backend
|
12
|
+
@id = id
|
13
|
+
end
|
14
|
+
|
15
|
+
# Reads from the file.
|
16
|
+
#
|
17
|
+
# @see http://www.ruby-doc.org/core-2.2.0/IO.html#method-i-read
|
18
|
+
#
|
19
|
+
# @return [String] The contents of the read chunk
|
20
|
+
def read(*args)
|
21
|
+
io.read(*args)
|
22
|
+
end
|
23
|
+
|
24
|
+
# Returns whether there is more data to read. Returns true if the end of
|
25
|
+
# the data has been reached.
|
26
|
+
#
|
27
|
+
# @return [Boolean]
|
28
|
+
def eof?
|
29
|
+
io.eof?
|
30
|
+
end
|
31
|
+
|
32
|
+
# Close the file object and release its file descriptor.
|
33
|
+
#
|
34
|
+
# @return [void]
|
35
|
+
def close
|
36
|
+
io.close
|
37
|
+
end
|
38
|
+
|
39
|
+
# @return [Integer] the size of the file in bytes
|
40
|
+
def size
|
41
|
+
backend.size(id)
|
42
|
+
end
|
43
|
+
|
44
|
+
# Remove the file from the backend.
|
45
|
+
#
|
46
|
+
# @return [void]
|
47
|
+
def delete
|
48
|
+
backend.delete(id)
|
49
|
+
end
|
50
|
+
|
51
|
+
# @return [Boolean] whether the file exists in the backend
|
52
|
+
def exists?
|
53
|
+
backend.exists?(id)
|
54
|
+
end
|
55
|
+
|
56
|
+
# @return [IO] an IO object which contains the contents of the file
|
57
|
+
def to_io
|
58
|
+
io
|
59
|
+
end
|
60
|
+
|
61
|
+
# Downloads the file to a Tempfile on disk and returns this tempfile.
|
62
|
+
#
|
63
|
+
# @example
|
64
|
+
# file = backend.upload(StringIO.new("hello"))
|
65
|
+
# tempfile = file.download
|
66
|
+
# File.read(tempfile.path) # => "hello"
|
67
|
+
#
|
68
|
+
# @return [Tempfile] a tempfile with the file's content
|
69
|
+
def download
|
70
|
+
return io if io.is_a?(Tempfile)
|
71
|
+
|
72
|
+
Tempfile.new(id, binmode: true).tap do |tempfile|
|
73
|
+
IO.copy_stream(io, tempfile)
|
74
|
+
tempfile.rewind
|
75
|
+
tempfile.fsync
|
76
|
+
end
|
77
|
+
end
|
78
|
+
|
79
|
+
# Rewind to beginning of file.
|
80
|
+
#
|
81
|
+
# @return [nil]
|
82
|
+
def rewind
|
83
|
+
@io = nil
|
84
|
+
end
|
85
|
+
|
86
|
+
# Prevent from exposing secure information unexpectedly
|
87
|
+
#
|
88
|
+
# @return [Hash]
|
89
|
+
def as_json
|
90
|
+
{
|
91
|
+
id: id,
|
92
|
+
backend: backend.to_s
|
93
|
+
}
|
94
|
+
end
|
95
|
+
|
96
|
+
private
|
97
|
+
|
98
|
+
def io
|
99
|
+
@io ||= backend.open(id)
|
100
|
+
end
|
101
|
+
end
|
102
|
+
end
|