neofiles 1.0.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/LICENSE +1 -0
- data/README.md +417 -0
- data/Rakefile +40 -0
- data/app/assets/images/neofiles/loading.gif +0 -0
- data/app/assets/images/neofiles/swf-thumb-100x100.png +0 -0
- data/app/assets/images/neofiles/watermark.png +0 -0
- data/app/assets/javascripts/neofiles/index.js.coffee +3 -0
- data/app/assets/javascripts/neofiles/jquery.fileupload.js +1128 -0
- data/app/assets/javascripts/neofiles/jquery.iframe-transport.js +172 -0
- data/app/assets/javascripts/neofiles/jquery.neofiles.js.coffee +191 -0
- data/app/assets/stylesheets/neofiles/index.css.scss +3 -0
- data/app/assets/stylesheets/neofiles/neofiles.css.scss +149 -0
- data/app/controllers/concerns/neofiles/not_found.rb +21 -0
- data/app/controllers/neofiles/admin_controller.rb +228 -0
- data/app/controllers/neofiles/admin_test_controller.rb +5 -0
- data/app/controllers/neofiles/files_controller.rb +28 -0
- data/app/controllers/neofiles/images_controller.rb +130 -0
- data/app/helpers/neofiles/neofiles_helper.rb +188 -0
- data/app/models/neofiles/file.rb +319 -0
- data/app/models/neofiles/file_chunk.rb +18 -0
- data/app/models/neofiles/image.rb +119 -0
- data/app/models/neofiles/swf.rb +45 -0
- data/app/views/neofiles/admin/_file_compact.html.haml +85 -0
- data/app/views/neofiles/admin/file_compact.html.haml +1 -0
- data/app/views/neofiles/admin_test/file_compact.erb +7 -0
- data/config/locales/ru.yml +21 -0
- data/config/routes.rb +1 -0
- data/lib/neofiles.rb +94 -0
- data/lib/neofiles/engine.rb +33 -0
- data/lib/neofiles/version.rb +3 -0
- metadata +131 -0
@@ -0,0 +1,319 @@
|
|
1
|
+
# This model stores file metadata like name, size, md5 hash etc. A model ID is essentially what is a "file" in the
|
2
|
+
# rest of an application. In some way Neofiles::File may be seen as remote filesystem, where you drop in files and keep
|
3
|
+
# their generated IDs to fetch them later, or setup web frontend via Neofiles::Files/ImagesController and request file
|
4
|
+
# bytes by ID from there.
|
5
|
+
#
|
6
|
+
# When persisting new file to the MongoDB database one must initialize new instance with metadata and set field #file=
|
7
|
+
# to IO-like object that holds real bytes. When #save method is called, file metadata are saved into Neofiles::File
|
8
|
+
# and file content is read and saved into collection of Neofiles::FileChunk, each of maximum length of #chunk_size bytes:
|
9
|
+
#
|
10
|
+
# logo = Neofiles::File.new
|
11
|
+
# logo.description = 'ACME inc logo'
|
12
|
+
# logo.file = '~/my-first-try.png' # or some opened file handle, or IO stream
|
13
|
+
# logo.filename = 'acme.png'
|
14
|
+
# logo.save
|
15
|
+
# logo.chunks.to_a # return an array of Neofiles::FileChunk in order
|
16
|
+
# logo.data # byte string of file contents
|
17
|
+
#
|
18
|
+
# # in view.html.slim
|
19
|
+
# - logo = Neofiles::File.find 'xxx'
|
20
|
+
# = neofiles_file_url logo # 'http://doma.in/neofiles/serve-file/#{logo.id}'
|
21
|
+
# = neofiles_link logo, 'Our logo' # '<a href="...#{logo.id}">Our logo</a>'
|
22
|
+
#
|
23
|
+
# This file/chunks concept is called Mongo GridFS (Grid File System) and is described as a standard way of storing files
|
24
|
+
# in MongoDB.
|
25
|
+
#
|
26
|
+
# MongoDB collection & client (session) can be changed via Rails.application.config.neofiles.mongo_files_collection
|
27
|
+
# and Rails.application.config.neofiles.mongo_client
|
28
|
+
#
|
29
|
+
# Model fields:
|
30
|
+
#
|
31
|
+
# filename - real name of file, is guessed when setting #file= but can be changed manually later
|
32
|
+
# content_type - MIME content type, is guessed when setting #file= but can be changed manually later
|
33
|
+
# length - file size in bytes
|
34
|
+
# chunk_size - max Neofiles::FileChunk size in bytes
|
35
|
+
# md5 - md5 hash of file (to find duplicates for example)
|
36
|
+
# description - arbitrary description
|
37
|
+
# owner_type/id - as in Mongoid polymorphic belongs_to relation, a class name & ID of object this file belongs to
|
38
|
+
# is_deleted - flag that file was once marked as deleted (just a flag for future use, affects nothing)
|
39
|
+
#
|
40
|
+
# There is no sense in deleting a file since space it used to hold is not reallocated by MongoDB, so files are considered
|
41
|
+
# forever lasting. But technically it is possible to delete model instance and it's chunks will be deleted as well.
|
42
|
+
#
|
43
|
+
class Neofiles::File
|
44
|
+
|
45
|
+
include Mongoid::Document
|
46
|
+
include Mongoid::Timestamps
|
47
|
+
|
48
|
+
store_in collection: Rails.application.config.neofiles.mongo_files_collection, client: Rails.application.config.neofiles.mongo_client
|
49
|
+
|
50
|
+
has_many :chunks, dependent: :destroy, order: [:n, :asc], class_name: 'Neofiles::FileChunk'
|
51
|
+
|
52
|
+
DEFAULT_CHUNK_SIZE = Rails.application.config.neofiles.mongo_default_chunk_size
|
53
|
+
|
54
|
+
field :filename, type: String
|
55
|
+
field :content_type, type: String
|
56
|
+
field :length, type: Integer, default: 0
|
57
|
+
field :chunk_size, type: Integer, default: DEFAULT_CHUNK_SIZE
|
58
|
+
field :md5, type: String, default: Digest::MD5.hexdigest('')
|
59
|
+
field :description, type: String
|
60
|
+
field :owner_type, type: String
|
61
|
+
field :owner_id, type: String
|
62
|
+
field :is_deleted, type: Mongoid::Boolean
|
63
|
+
|
64
|
+
validates :filename, :length, :chunk_size, :md5, presence: true
|
65
|
+
|
66
|
+
before_save :save_file
|
67
|
+
after_save :nullify_unpersisted_file
|
68
|
+
|
69
|
+
|
70
|
+
|
71
|
+
# Yield block for each chunk.
|
72
|
+
def each(&block)
|
73
|
+
chunks.all.order_by([:n, :asc]).each do |chunk|
|
74
|
+
block.call(chunk.to_s)
|
75
|
+
end
|
76
|
+
end
|
77
|
+
|
78
|
+
# Get a portion of chunks, either via Range of Fixnum (length).
|
79
|
+
def slice(*args)
|
80
|
+
case args.first
|
81
|
+
when Range
|
82
|
+
range = args.first
|
83
|
+
first_chunk = (range.min / chunk_size).floor
|
84
|
+
last_chunk = (range.max / chunk_size).ceil
|
85
|
+
offset = range.min % chunk_size
|
86
|
+
length = range.max - range.min + 1
|
87
|
+
when Fixnum
|
88
|
+
start = args.first
|
89
|
+
start = self.length + start if start < 0
|
90
|
+
length = args.size == 2 ? args.last : 1
|
91
|
+
first_chunk = (start / chunk_size).floor
|
92
|
+
last_chunk = ((start + length) / chunk_size).ceil
|
93
|
+
offset = start % chunk_size
|
94
|
+
end
|
95
|
+
|
96
|
+
data = ''
|
97
|
+
|
98
|
+
chunks.where(n: first_chunk..last_chunk).order_by(n: :asc).each do |chunk|
|
99
|
+
data << chunk
|
100
|
+
end
|
101
|
+
|
102
|
+
data[offset, length]
|
103
|
+
end
|
104
|
+
|
105
|
+
# Chunks bytes concatenated, that is the whole file content.
|
106
|
+
def data
|
107
|
+
data = ''
|
108
|
+
each { |chunk| data << chunk }
|
109
|
+
data
|
110
|
+
end
|
111
|
+
|
112
|
+
# Encode bytes in base64.
|
113
|
+
def base64
|
114
|
+
Array(to_s).pack('m')
|
115
|
+
end
|
116
|
+
|
117
|
+
# Encode bytes id data uri.
|
118
|
+
def data_uri(options = {})
|
119
|
+
data = base64.chomp
|
120
|
+
"data:#{content_type};base64,#{data}"
|
121
|
+
end
|
122
|
+
|
123
|
+
# Bytes as chunks array, if block is given — yield it.
|
124
|
+
def bytes(&block)
|
125
|
+
if block
|
126
|
+
each { |data| block.call(data) }
|
127
|
+
length
|
128
|
+
else
|
129
|
+
bytes = []
|
130
|
+
each { |data| bytes.push(*data) }
|
131
|
+
bytes
|
132
|
+
end
|
133
|
+
end
|
134
|
+
|
135
|
+
|
136
|
+
|
137
|
+
attr_reader :file
|
138
|
+
|
139
|
+
# If not nil the next call to #save will fetch bytes from this file and save them in chunks.
|
140
|
+
# Filename and content type are guessed from argument.
|
141
|
+
def file=(file)
|
142
|
+
@file = file
|
143
|
+
|
144
|
+
if @file
|
145
|
+
self.filename = self.class.extract_basename(@file)
|
146
|
+
self.content_type = self.class.extract_content_type(filename) || 'application/octet-stream'
|
147
|
+
else
|
148
|
+
self.filename = nil
|
149
|
+
self.content_type = nil
|
150
|
+
end
|
151
|
+
end
|
152
|
+
|
153
|
+
# Are we going to save file bytes on next #save?
|
154
|
+
def unpersisted_file?
|
155
|
+
not @file.nil?
|
156
|
+
end
|
157
|
+
|
158
|
+
# Real file saving goes here.
|
159
|
+
# File length and md5 hash are computed automatically.
|
160
|
+
def save_file
|
161
|
+
if @file
|
162
|
+
self.chunks.delete_all
|
163
|
+
|
164
|
+
md5 = Digest::MD5.new
|
165
|
+
length, n = 0, 0
|
166
|
+
|
167
|
+
self.class.reading(@file) do |io|
|
168
|
+
self.class.chunking(io, chunk_size) do |buf|
|
169
|
+
md5 << buf
|
170
|
+
length += buf.size
|
171
|
+
chunk = self.chunks.build
|
172
|
+
chunk.data = self.class.binary_for(buf)
|
173
|
+
chunk.n = n
|
174
|
+
n += 1
|
175
|
+
chunk.save!
|
176
|
+
self.chunks.push(chunk)
|
177
|
+
end
|
178
|
+
end
|
179
|
+
|
180
|
+
self.length = length
|
181
|
+
self.md5 = md5.hexdigest
|
182
|
+
end
|
183
|
+
end
|
184
|
+
|
185
|
+
# Reset @file after save.
|
186
|
+
def nullify_unpersisted_file
|
187
|
+
@file = nil
|
188
|
+
end
|
189
|
+
|
190
|
+
# Representation of file in admin "compact" mode, @see Neofiles::AdminController#file_compact.
|
191
|
+
# To be redefined by descendants.
|
192
|
+
def admin_compact_view(template)
|
193
|
+
template.neofiles_link self, nil, target: '_blank'
|
194
|
+
end
|
195
|
+
|
196
|
+
# Yield block with IO stream made from input arg, which can be file name or other IO readable object.
|
197
|
+
def self.reading(arg, &block)
|
198
|
+
if arg.respond_to?(:read)
|
199
|
+
self.rewind(arg) do |io|
|
200
|
+
block.call(io)
|
201
|
+
end
|
202
|
+
else
|
203
|
+
open(arg.to_s) do |io|
|
204
|
+
block.call(io)
|
205
|
+
end
|
206
|
+
end
|
207
|
+
end
|
208
|
+
|
209
|
+
# Split IO stream by chunks chunk_size bytes each and yield each chunk in block.
|
210
|
+
def self.chunking(io, chunk_size, &block)
|
211
|
+
if io.method(:read).arity == 0
|
212
|
+
data = io.read
|
213
|
+
i = 0
|
214
|
+
loop do
|
215
|
+
offset = i * chunk_size
|
216
|
+
length = i + chunk_size < data.size ? chunk_size : data.size - offset
|
217
|
+
|
218
|
+
break if offset >= data.size
|
219
|
+
|
220
|
+
buf = data[offset, length]
|
221
|
+
block.call(buf)
|
222
|
+
i += 1
|
223
|
+
end
|
224
|
+
else
|
225
|
+
while buf = io.read(chunk_size)
|
226
|
+
block.call(buf)
|
227
|
+
end
|
228
|
+
end
|
229
|
+
end
|
230
|
+
|
231
|
+
# Construct Mongoid binary object from string of bytes.
|
232
|
+
def self.binary_for(*buf)
|
233
|
+
BSON::Binary.new(:generic, buf.join)
|
234
|
+
end
|
235
|
+
|
236
|
+
# Try different methods to extract file name or path from argument object.
|
237
|
+
def self.extract_basename(object)
|
238
|
+
filename = nil
|
239
|
+
%i{ original_path original_filename path filename pathname }.each do |msg|
|
240
|
+
if object.respond_to?(msg)
|
241
|
+
filename = object.send(msg)
|
242
|
+
break
|
243
|
+
end
|
244
|
+
end
|
245
|
+
filename ? cleanname(filename) : nil
|
246
|
+
end
|
247
|
+
|
248
|
+
# Try different methods to extract MIME content type from file name, e.g. jpeg -> image/jpeg
|
249
|
+
def self.extract_content_type(basename)
|
250
|
+
if defined?(MIME)
|
251
|
+
content_type = MIME::Types.type_for(basename.to_s).first
|
252
|
+
else
|
253
|
+
ext = ::File.extname(basename.to_s).downcase.sub(/[.]/, '')
|
254
|
+
if ext.in? %w{ jpeg jpg gif png }
|
255
|
+
content_type = 'image/' + ext.sub(/jpg/, 'jpeg')
|
256
|
+
elsif ext == 'swf'
|
257
|
+
content_type = 'application/x-shockwave-flash'
|
258
|
+
else
|
259
|
+
content_type = nil
|
260
|
+
end
|
261
|
+
end
|
262
|
+
|
263
|
+
content_type.to_s if content_type
|
264
|
+
end
|
265
|
+
|
266
|
+
# Extract only file name partion from path.
|
267
|
+
def self.cleanname(pathname)
|
268
|
+
::File.basename(pathname.to_s)
|
269
|
+
end
|
270
|
+
|
271
|
+
# Guess descendant class of Neofiles::File by MIME content type to use special purpose class for different file types:
|
272
|
+
#
|
273
|
+
# Neofiles::File.file_class_by_content_type('image/jpeg') # -> Neofiles::Image
|
274
|
+
# Neofiles::File.file_class_by_content_type('some/unknown') # -> Neofiles::File
|
275
|
+
#
|
276
|
+
# Can be used when persisting new files or loading from database.
|
277
|
+
#
|
278
|
+
def self.class_by_content_type(content_type)
|
279
|
+
case content_type
|
280
|
+
when /\Aimage\//
|
281
|
+
::Neofiles::Image
|
282
|
+
when 'application/x-shockwave-flash'
|
283
|
+
::Neofiles::Swf
|
284
|
+
else
|
285
|
+
self
|
286
|
+
end
|
287
|
+
end
|
288
|
+
|
289
|
+
# Same as file_class_by_content_type but for file name string.
|
290
|
+
def self.class_by_file_name(file_name)
|
291
|
+
class_by_content_type(extract_content_type(file_name))
|
292
|
+
end
|
293
|
+
|
294
|
+
# Same as file_class_by_content_type but for file-like object.
|
295
|
+
def self.class_by_file_object(file_object)
|
296
|
+
class_by_file_name(extract_basename(file_object))
|
297
|
+
end
|
298
|
+
|
299
|
+
# Yield IO-like argument to block rewinding it first, if possible.
|
300
|
+
def self.rewind(io, &block)
|
301
|
+
begin
|
302
|
+
pos = io.pos
|
303
|
+
io.flush
|
304
|
+
io.rewind
|
305
|
+
rescue
|
306
|
+
nil
|
307
|
+
end
|
308
|
+
|
309
|
+
begin
|
310
|
+
block.call(io)
|
311
|
+
ensure
|
312
|
+
begin
|
313
|
+
io.pos = pos
|
314
|
+
rescue
|
315
|
+
nil
|
316
|
+
end
|
317
|
+
end
|
318
|
+
end
|
319
|
+
end
|
@@ -0,0 +1,18 @@
|
|
1
|
+
# Model for storing portion of bytes from particular Neofiles::File. Has only two fields: the bytes string #data
|
2
|
+
# and sequence number #n
|
3
|
+
#
|
4
|
+
class Neofiles::FileChunk
|
5
|
+
|
6
|
+
include Mongoid::Document
|
7
|
+
|
8
|
+
store_in collection: Rails.application.config.neofiles.mongo_chunks_collection, client: Rails.application.config.neofiles.mongo_client
|
9
|
+
|
10
|
+
belongs_to :file, class_name: 'Neofiles::File'
|
11
|
+
|
12
|
+
field :n, type: Integer, default: 0 # что это за поле?
|
13
|
+
field :data, type: BSON::Binary
|
14
|
+
|
15
|
+
def to_s
|
16
|
+
data.data
|
17
|
+
end
|
18
|
+
end
|
@@ -0,0 +1,119 @@
|
|
1
|
+
# Special case of Neofiles::File for dealing with images.
|
2
|
+
#
|
3
|
+
# Alongside usual file things:
|
4
|
+
# 1) stores width & height of image;
|
5
|
+
# 2) does some useful manipulations, like EXIF rotation & cleaning;
|
6
|
+
# 3) stores no_wm [no_watermark] flag to tell Neofiles::ImagesController not to put watermark automatically.
|
7
|
+
#
|
8
|
+
class Neofiles::Image < Neofiles::File
|
9
|
+
|
10
|
+
class ImageFormatException < Exception; end
|
11
|
+
|
12
|
+
field :width, type: Integer
|
13
|
+
field :height, type: Integer
|
14
|
+
|
15
|
+
field :no_wm, type: Mongoid::Boolean
|
16
|
+
|
17
|
+
# Do useful stuf before calling parent #save from Neofiles::File.
|
18
|
+
#
|
19
|
+
# 1. Rotates image if orientation is present in EXIF and Rails.application.config.neofiles.image_rotate_exif == true.
|
20
|
+
# 2. Cleans all EXIF data if Rails.application.config.neofiles.image_clean_exif == true.
|
21
|
+
# 3. Crops input to some max size in case enormous 10000x10000 px input is provided
|
22
|
+
# (fill Rails.application.config.neofiles.image_max_dimensions with [w, h] or {width: w, height: h} or wh)
|
23
|
+
#
|
24
|
+
# Uses MiniMagick and works only with JPEG, PNG & GIF formats.
|
25
|
+
#
|
26
|
+
# TODO: переделать работу с файлом. Сейчас МиниМеджик копирует входной файл в темповую директорию, после его обработки
|
27
|
+
# я делаю еще одну темповую копию и ее тут же читаю - неэкономно! Это сделано потому, что МиниМеджик не дает мне инфу
|
28
|
+
# о своем темповом файле, если бы давал дескриптор или его имя, я бы его читал. Но я могу только считать содержимое
|
29
|
+
# или попросить МиниМеджик скопировать его, что и происходит. Вариант: построить класс StringIO, который может читать
|
30
|
+
# строку блоками, и натравить на image.to_blob (этот метод прочитает содержимое темпового файла), и уже этот поток
|
31
|
+
# нарезать на чанки. Еще вариант: тупо пройтись по строке image.to_blob в цикле.
|
32
|
+
def save_file
|
33
|
+
|
34
|
+
return if @file.nil?
|
35
|
+
|
36
|
+
begin
|
37
|
+
image = ::MiniMagick::Image.read @file
|
38
|
+
rescue ::MiniMagick::Invalid
|
39
|
+
raise ImageFormatException.new I18n.t('neofiles.mini_magick_error')
|
40
|
+
end
|
41
|
+
|
42
|
+
# check input forma
|
43
|
+
type = image[:format].downcase
|
44
|
+
raise ImageFormatException.new I18n.t('neofiles.unsupported_image_type', type: type.upcase) unless type.in? %w{ jpeg gif png }
|
45
|
+
|
46
|
+
# rotate from exit
|
47
|
+
dimensions = image[:dimensions]
|
48
|
+
if Rails.application.config.neofiles.image_rotate_exif
|
49
|
+
case image['exif:orientation']
|
50
|
+
when '3'
|
51
|
+
image.rotate '180'
|
52
|
+
when '6'
|
53
|
+
image.rotate '90'
|
54
|
+
dimensions.reverse!
|
55
|
+
when '8'
|
56
|
+
image.rotate '-90'
|
57
|
+
dimensions.reverse!
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
# clean exif
|
62
|
+
image.strip if Rails.application.config.neofiles.image_clean_exif
|
63
|
+
|
64
|
+
# crop to max size
|
65
|
+
if crop_dimensions = Rails.application.config.neofiles.image_max_dimensions
|
66
|
+
if crop_dimensions.is_a? Hash
|
67
|
+
crop_dimensions = crop_dimensions.values_at :width, :height
|
68
|
+
elsif !(crop_dimensions.is_a? Array)
|
69
|
+
crop_dimensions = [crop_dimensions, crop_dimensions]
|
70
|
+
end
|
71
|
+
|
72
|
+
image.resize crop_dimensions.join('x').concat('>')
|
73
|
+
dimensions = image[:dimensions]
|
74
|
+
end
|
75
|
+
|
76
|
+
# fill in some fields
|
77
|
+
self.width = dimensions[0]
|
78
|
+
self.height = dimensions[1]
|
79
|
+
self.content_type = "image/#{type}"
|
80
|
+
|
81
|
+
begin
|
82
|
+
# make temp image
|
83
|
+
tempfile = Tempfile.new 'neofiles-image'
|
84
|
+
tempfile.binmode
|
85
|
+
image.write tempfile
|
86
|
+
|
87
|
+
# substitute file to be saved with the temp
|
88
|
+
@file = tempfile
|
89
|
+
|
90
|
+
# call super #save
|
91
|
+
super
|
92
|
+
|
93
|
+
ensure
|
94
|
+
tempfile.close
|
95
|
+
tempfile.unlink
|
96
|
+
end
|
97
|
+
end
|
98
|
+
|
99
|
+
# Return array with width & height decorated with singleton function to_s returning 'WxH' string.
|
100
|
+
def dimensions
|
101
|
+
dim = [width, height]
|
102
|
+
def dim.to_s
|
103
|
+
join 'x'
|
104
|
+
end
|
105
|
+
dim
|
106
|
+
end
|
107
|
+
|
108
|
+
# Overrides parent "admin views" with square 100x100 thumbnail.
|
109
|
+
def admin_compact_view(template)
|
110
|
+
# _path instead of _url to keep admin session cookie which is lost when changing domains
|
111
|
+
url_method = Neofiles.is_admin?(template) ? :neofiles_image_nowm_path : :neofiles_image_path
|
112
|
+
template.neofiles_img_link self, 100, 100, {}, target: '_blank', href: template.send(url_method, self)
|
113
|
+
end
|
114
|
+
|
115
|
+
# Set no_wm from HTML form (value is a string '1'/'0').
|
116
|
+
def no_wm=(value)
|
117
|
+
write_attribute :no_wm, value.is_a?(String) ? value == '1' : value
|
118
|
+
end
|
119
|
+
end
|