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.
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