neofiles 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (32) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +1 -0
  3. data/README.md +417 -0
  4. data/Rakefile +40 -0
  5. data/app/assets/images/neofiles/loading.gif +0 -0
  6. data/app/assets/images/neofiles/swf-thumb-100x100.png +0 -0
  7. data/app/assets/images/neofiles/watermark.png +0 -0
  8. data/app/assets/javascripts/neofiles/index.js.coffee +3 -0
  9. data/app/assets/javascripts/neofiles/jquery.fileupload.js +1128 -0
  10. data/app/assets/javascripts/neofiles/jquery.iframe-transport.js +172 -0
  11. data/app/assets/javascripts/neofiles/jquery.neofiles.js.coffee +191 -0
  12. data/app/assets/stylesheets/neofiles/index.css.scss +3 -0
  13. data/app/assets/stylesheets/neofiles/neofiles.css.scss +149 -0
  14. data/app/controllers/concerns/neofiles/not_found.rb +21 -0
  15. data/app/controllers/neofiles/admin_controller.rb +228 -0
  16. data/app/controllers/neofiles/admin_test_controller.rb +5 -0
  17. data/app/controllers/neofiles/files_controller.rb +28 -0
  18. data/app/controllers/neofiles/images_controller.rb +130 -0
  19. data/app/helpers/neofiles/neofiles_helper.rb +188 -0
  20. data/app/models/neofiles/file.rb +319 -0
  21. data/app/models/neofiles/file_chunk.rb +18 -0
  22. data/app/models/neofiles/image.rb +119 -0
  23. data/app/models/neofiles/swf.rb +45 -0
  24. data/app/views/neofiles/admin/_file_compact.html.haml +85 -0
  25. data/app/views/neofiles/admin/file_compact.html.haml +1 -0
  26. data/app/views/neofiles/admin_test/file_compact.erb +7 -0
  27. data/config/locales/ru.yml +21 -0
  28. data/config/routes.rb +1 -0
  29. data/lib/neofiles.rb +94 -0
  30. data/lib/neofiles/engine.rb +33 -0
  31. data/lib/neofiles/version.rb +3 -0
  32. 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