citrusbyte-milton 0.3.0 → 0.3.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,47 @@
1
+ module Milton
2
+ module Storage
3
+ class StoredFile
4
+ class << self
5
+ # Sanitizes the given filename, removes pathnames and the special chars
6
+ # needed for options seperation for derivatives
7
+ def sanitize_filename(filename, options)
8
+ File.basename(filename, File.extname(filename)).gsub(/^.*(\\|\/)/, '').
9
+ gsub(/[^\w]|#{Regexp.escape(options[:separator])}/, options[:replacement]).
10
+ strip + File.extname(filename)
11
+ end
12
+
13
+ def create(filename, id, source, options)
14
+ returning new(filename, id, options) do |file|
15
+ file.store(source)
16
+ end
17
+ end
18
+
19
+ # Returns the adapter class specified by the given type (by naming
20
+ # convention)
21
+ #
22
+ # Storage::StoredFile.adapter(:s3) => Storage::S3File
23
+ # Storage::StoredFile.adapter(:disk) => Storage::DiskFile
24
+ #
25
+ def adapter(type)
26
+ "Milton::Storage::#{type.to_s.classify}File".constantize
27
+ end
28
+ end
29
+
30
+ attr_accessor :filename, :id, :options
31
+
32
+ def initialize(filename, id, options)
33
+ self.filename = filename
34
+ self.id = id
35
+ self.options = options
36
+ end
37
+
38
+ # Creates a "copy" of this StoredFile of the same type with the same id
39
+ # and options but using the given filename. Doesn't actually do any
40
+ # copying of the underlying file data, just creates a "copy" of the
41
+ # StoredFile object.
42
+ def copy(filename)
43
+ self.class.new(filename, self.id, self.options)
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,82 @@
1
+ module Milton
2
+ module Uploading
3
+ module ClassMethods
4
+ def self.extended(base)
5
+ base.setup_callbacks
6
+ end
7
+
8
+ def setup_callbacks
9
+ # Rails 2.1 fix for callbacks
10
+ define_callbacks(:before_file_saved, :after_file_saved) if defined?(::ActiveSupport::Callbacks)
11
+ after_save :save_uploaded_file
12
+ after_file_saved :create_derivatives if @after_create_callbacks.delete(:create_derivatives)
13
+ end
14
+
15
+ unless defined?(::ActiveSupport::Callbacks)
16
+ def before_file_saved(&block)
17
+ write_inheritable_array(:before_file_saved, [block])
18
+ end
19
+
20
+ def after_file_saved(&block)
21
+ write_inheritable_array(:after_file_saved, [block])
22
+ end
23
+ end
24
+ end
25
+
26
+ module InstanceMethods
27
+ def self.included(base)
28
+ # Rails 2.1 fix for callbacks
29
+ base.define_callbacks *[:before_file_saved, :after_file_saved] if base.respond_to?(:define_callbacks)
30
+ end
31
+
32
+ # Set file=<uploaded file> on your model to handle an uploaded file.
33
+ def file=(file)
34
+ return nil if file.nil? || file.size == 0
35
+ @upload = Upload.new(file, self.class.milton_options)
36
+ self.filename = @upload.filename
37
+ self.size = @upload.size if respond_to?(:size=)
38
+ self.content_type = Milton::File.mime_type?(@upload) if respond_to?(:content_type=)
39
+ end
40
+
41
+ protected
42
+
43
+ def save_uploaded_file
44
+ if @upload && !@upload.stored?
45
+ callback :before_file_saved
46
+ @attached_file = @upload.store(id)
47
+ callback :after_file_saved
48
+ end
49
+ end
50
+ end
51
+ end
52
+
53
+ class Upload
54
+ attr_reader :content_type, :filename, :size, :options
55
+
56
+ def initialize(data_or_path, options)
57
+ @stored = false
58
+ @tempfile = Milton::Tempfile.create(data_or_path, options[:tempfile_path])
59
+ @content_type = data_or_path.content_type
60
+ @filename = Storage::StoredFile.sanitize_filename(data_or_path.original_filename, options) if respond_to?(:filename)
61
+ @size = File.size(self.temp_path)
62
+ @options = options
63
+ end
64
+
65
+ def stored?
66
+ @stored
67
+ end
68
+
69
+ def store(id)
70
+ return true if stored?
71
+ returning Storage::StoredFile.adapter(options[:storage]).create(filename, id, temp_path, options) do
72
+ @stored = true
73
+ end
74
+ end
75
+
76
+ protected
77
+
78
+ def temp_path
79
+ @tempfile.respond_to?(:path) ? @tempfile.path : @tempfile.to_s
80
+ end
81
+ end
82
+ end
data/lib/milton.rb ADDED
@@ -0,0 +1,95 @@
1
+ require 'milton/attachment'
2
+ require 'milton/core/tempfile'
3
+ require 'milton/core/file'
4
+
5
+ module Milton
6
+ # Raised when a file which was expected to exist appears not to exist
7
+ class MissingFileError < StandardError;end;
8
+
9
+ # Some definitions for file semantics used throughout Milton, understanding
10
+ # this will make understanding the code a bit easier and avoid ambiguity:
11
+ #
12
+ # path:
13
+ # the full path to a file or directory in the filesystem
14
+ # /var/log/apache2 or /var/log/apache2/access.log
15
+ # can also be defined as:
16
+ # path == dirname + filename
17
+ # path == dirname + basename + extension
18
+ #
19
+ # dirname:
20
+ # the directory portion of the path to a file or directory, all the chars
21
+ # up to the final /
22
+ # /var/log/apache2 => /var/log
23
+ # /var/log/apache2/ => /var/log/apache2
24
+ # /var/log/apache2/access.log => /var/log/apache2
25
+ #
26
+ # basename:
27
+ # the portion of a filename *with no extension* (ruby's "basename" may or
28
+ # may not have an extension), all the chars after the last / and before
29
+ # the last .
30
+ # /var/log/apache2 => apache2
31
+ # /var/log/apache2/ => nil
32
+ # /var/log/apache2/access.log => access
33
+ # /var/log/apache2/access.2008.log => access.2008
34
+ #
35
+ # extension:
36
+ # the extension portion of a filename w/ no preceding ., all the chars
37
+ # after the final .
38
+ # /var/log/apache2 => nil
39
+ # /var/log/apache2/ => nil
40
+ # /var/log/apache2/access.log => log
41
+ # /var/log/apache2/access.2008.log => log
42
+ #
43
+ # filename:
44
+ # the filename portion of a path w/ extension, all the chars after the
45
+ # final /
46
+ # /var/log/apache2 => apache2
47
+ # /var/log/apache2/ => nil
48
+ # /var/log/apache2/access.log => access.log
49
+ # /var/log/apache2/access.2008.log => access.2008.log
50
+ # can also be defined as:
51
+ # filename == basename + (extension ? '.' + extension : '')
52
+ #
53
+
54
+ # Gives the filename and line number of the method which called the method
55
+ # that invoked #called_by.
56
+ def called_by
57
+ caller[1].gsub(/.*\/(.*):in (.*)/, "\\1:\\2")
58
+ end
59
+ module_function :called_by
60
+
61
+ # Writes the given message to the Rails log at the info level. If given an
62
+ # invoker (just a string) it prepends the message with that. If not given
63
+ # an invoker it outputs the filename and line number which called #log.
64
+ def log(message, invoker=nil)
65
+ invoker ||= Milton.called_by
66
+ Rails.logger.info("[milton] #{invoker}: #{message}")
67
+ end
68
+ module_function :log
69
+
70
+ # Executes the given command, returning the commands output if successful
71
+ # or false if the command failed.
72
+ # Redirects stderr to log/milton.stderr.log in order to examine causes of
73
+ # failure.
74
+ def syscall(command)
75
+ log("executing #{command}", invoker = Milton.called_by)
76
+ stdout = %x{#{command} 2>>#{File.join(Rails.root, 'log', 'milton.stderr.log')}}
77
+ $?.success? ? stdout : (log("failed to execute #{command}", invoker) and return false)
78
+ end
79
+ module_function :syscall
80
+
81
+ # Wraps +require+ on the given path in a rescue which uses the given
82
+ # message for the resulting LoadError on failure instead of the default
83
+ # one to give the user a better idea of what happened (useful for
84
+ # dynamic +require+)
85
+ def try_require(path, message)
86
+ begin
87
+ require path
88
+ rescue LoadError => e
89
+ raise LoadError.new(message + " (failed to require #{path})")
90
+ end
91
+ end
92
+ module_function :try_require
93
+ end
94
+
95
+ ActiveRecord::Base.extend Milton::Attachment
Binary file
Binary file
Binary file
@@ -0,0 +1,329 @@
1
+ require File.dirname(__FILE__) + '/../test_helper'
2
+
3
+ class AttachmentTest < ActiveSupport::TestCase
4
+ context "being included into a model" do
5
+ class NotAnAttachment < ActiveRecord::Base
6
+ end
7
+
8
+ context "NotAnAttachment" do
9
+ should "not have milton_options" do
10
+ assert !NotAnAttachment.respond_to?(:milton_options)
11
+ end
12
+
13
+ should "not have attachment methods" do
14
+ assert !NotAnAttachment.respond_to?(:has_attachment_methods)
15
+ end
16
+ end
17
+
18
+ context "Attachment" do
19
+ should "have milton_options on Attachment" do
20
+ assert Attachment.respond_to?(:milton_options)
21
+ end
22
+
23
+ should "have attachment methods" do
24
+ assert Attachment.respond_to?(:has_attachment_methods)
25
+ end
26
+
27
+ should "have a hash of options" do
28
+ assert Attachment.milton_options.is_a?(Hash)
29
+ end
30
+ end
31
+ end
32
+
33
+ context "setting options" do
34
+ context "defaults" do
35
+ should "use :disk as default storage" do
36
+ assert_equal :disk, Attachment.milton_options[:storage]
37
+ end
38
+ end
39
+
40
+ context "inheritence" do
41
+ class FooImage < Image
42
+ is_attachment :resizeable => { :sizes => { :foo => { :size => '10x10' } } }
43
+ end
44
+
45
+ class BarImage < FooImage # note that BarImage < FooImage < Image
46
+ is_attachment :resizeable => { :sizes => { } }
47
+ end
48
+
49
+ should "inherit settings from Image" do
50
+ assert_equal Image.milton_options[:storage_options][:root], FooImage.milton_options[:storage_options][:root]
51
+ end
52
+
53
+ should "overwrite settings from Image when redefined in FooImage" do
54
+ assert_equal({ :foo => { :size => '10x10' } }, FooImage.milton_options[:resizeable][:sizes])
55
+ end
56
+
57
+ should "overwrite settings from FooImage when redefined in BarImage" do
58
+ assert_equal({}, BarImage.milton_options[:resizeable][:sizes])
59
+ end
60
+ end
61
+
62
+ context "encapsulation" do
63
+ class FooRootImage < Image
64
+ is_attachment :storage_options => { :root => '/foo' }
65
+ end
66
+
67
+ class BarRootImage < Image
68
+ is_attachment :storage_options => { :root => '/bar' }
69
+ end
70
+
71
+ should "not overwrite FooRootImage's root setting with BarRootImage's" do
72
+ assert_equal '/foo', FooRootImage.milton_options[:storage_options][:root]
73
+ end
74
+
75
+ should "not overwrite BarRootImage's root setting with FooRootImage's" do
76
+ assert_equal '/bar', BarRootImage.milton_options[:storage_options][:root]
77
+ end
78
+ end
79
+ end
80
+
81
+ context "getting mime-type" do
82
+ setup do
83
+ @attachment = Attachment.new :file => upload('milton.jpg')
84
+ end
85
+
86
+ context "from freshly uploaded file" do
87
+ should "recognize it as an image/jpg" do
88
+ assert_equal 'image/jpg', @attachment.content_type
89
+ end
90
+ end
91
+
92
+ context "from existing file" do
93
+ setup do
94
+ @attachment.save
95
+ @attachment.reload
96
+ end
97
+
98
+ should "recognize it as an image/jpg" do
99
+ assert_equal 'image/jpg', @attachment.content_type
100
+ end
101
+ end
102
+
103
+ context "from file with no content_type set" do
104
+ setup do
105
+ @attachment.update_attribute(:content_type, nil)
106
+ @attachment.save
107
+ @attachment.reload
108
+ end
109
+
110
+ should "attempt to determine mime_type from file" do
111
+ # this is implemented w/ unix file cmd so is system dependent currently...
112
+ assert_equal 'image/jpeg', @attachment.content_type
113
+ end
114
+ end
115
+ end
116
+
117
+ context "creating attachment folder" do
118
+ raise "Failed to create #{File.join(output_path, 'exists')}" unless FileUtils.mkdir_p(File.join(output_path, 'exists'))
119
+ FileUtils.ln_s 'exists', File.join(output_path, 'linked')
120
+ raise "Failed to symlink #{File.join(output_path, 'linked')}" unless File.symlink?(File.join(output_path, 'linked'))
121
+
122
+ class NoRootAttachment < Attachment
123
+ is_attachment :storage_options => { :root => File.join(ActiveSupport::TestCase.output_path, 'nonexistant') }
124
+ end
125
+
126
+ class RootExistsAttachment < Attachment
127
+ is_attachment :storage_options => { :root => File.join(ActiveSupport::TestCase.output_path, 'exists') }
128
+ end
129
+
130
+ class SymlinkAttachment < Attachment
131
+ is_attachment :storage_options => { :root => File.join(ActiveSupport::TestCase.output_path, 'linked') }
132
+ end
133
+
134
+ should "create root path when root path does not exist" do
135
+ @attachment = NoRootAttachment.create :file => upload('milton.jpg')
136
+ assert File.exists?(@attachment.path)
137
+ assert File.exists?(File.join(output_path, 'nonexistant'))
138
+ assert_match /nonexistant/, @attachment.path
139
+ end
140
+
141
+ should "work when root path already exists" do
142
+ @attachment = RootExistsAttachment.create :file => upload('milton.jpg')
143
+ assert File.exists?(@attachment.path)
144
+ assert_match /exists/, @attachment.path
145
+ end
146
+
147
+ should "work when root path is a symlink" do
148
+ @attachment = SymlinkAttachment.create :file => upload('milton.jpg')
149
+ assert File.exists?(@attachment.path)
150
+ assert_match /linked/, @attachment.path
151
+ end
152
+ end
153
+
154
+ context "being destroyed" do
155
+ setup do
156
+ @attachment = Attachment.create :file => upload('milton.jpg')
157
+ end
158
+
159
+ should "delete the underlying file from the filesystem" do
160
+ @attachment.destroy
161
+ assert !File.exists?(@attachment.path)
162
+ end
163
+
164
+ # the partitioning algorithm ensures that each attachment model has its own
165
+ # folder, so we can safely delete the folder, if you write a new
166
+ # partitioner this might change!
167
+ should "delete the directory containing the file and all derivatives from the filesystem" do
168
+ @attachment.destroy
169
+ assert !File.exists?(File.dirname(@attachment.path))
170
+ end
171
+ end
172
+
173
+ context "instantiating" do
174
+ setup do
175
+ @image = Image.new :file => upload('milton.jpg')
176
+ end
177
+
178
+ should "have a file= method" do
179
+ assert @image.respond_to?(:file=)
180
+ end
181
+
182
+ should "set the filename from the uploaded file" do
183
+ assert_equal 'milton.jpg', @image.filename
184
+ end
185
+
186
+ should "strip seperator (.) from the filename and replace them with replacement (-)" do
187
+ @image.filename = 'foo.bar.baz.jpg'
188
+ assert_equal 'foo-bar-baz.jpg', @image.filename
189
+ end
190
+ end
191
+
192
+ context "path partitioning" do
193
+ setup do
194
+ @image = Image.new :file => upload('milton.jpg')
195
+ end
196
+
197
+ should "be stored in a partitioned folder based on its id" do
198
+ assert_match /^.*\/0*#{@image.id}\/#{@image.filename}$/, @image.path
199
+ end
200
+ end
201
+
202
+ context "public path helper" do
203
+ setup do
204
+ @image = Image.new(:file => upload('milton.jpg'))
205
+ end
206
+
207
+ should "give the path from public/ on to the filename" do
208
+ flexmock(@image, :path => '/root/public/assets/1/milton.jpg')
209
+ assert_equal "/assets/1/milton.jpg", @image.public_path
210
+ end
211
+
212
+ should "give the path from foo/ on to the filename" do
213
+ flexmock(@image, :path => '/root/foo/assets/1/milton.jpg')
214
+ assert_equal "/assets/1/milton.jpg", @image.public_path({}, 'foo')
215
+ end
216
+ end
217
+
218
+ context "handling uploads" do
219
+ context "filename column" do
220
+ should "raise an exception if there is no filename column" do
221
+ assert_raise RuntimeError do
222
+ class NotUploadable < ActiveRecord::Base # see schema.rb, there is a not_uploadables table
223
+ is_attachment
224
+ end
225
+ end
226
+ end
227
+
228
+ should "not raise an exception if the underlying table doesn't exist" do
229
+ assert_nothing_raised do
230
+ class NoTable < ActiveRecord::Base
231
+ is_attachment
232
+ end
233
+ end
234
+ end
235
+ end
236
+
237
+ context "class extensions" do
238
+ context "class methods" do
239
+ should "add before_file_saved callback" do
240
+ assert Attachment.respond_to?(:before_file_saved)
241
+ end
242
+
243
+ should "add after_file_saved callback" do
244
+ assert Attachment.respond_to?(:after_file_saved)
245
+ end
246
+ end
247
+ end
248
+
249
+ context "handling file upload" do
250
+ context "saving upload" do
251
+ setup do
252
+ @attachment = Attachment.new :file => upload('milton.jpg')
253
+ end
254
+
255
+ should "save the upload to the filesystem on save" do
256
+ @attachment.save
257
+ assert File.exists?(@attachment.path)
258
+ end
259
+
260
+ should "have the same filesize as original file when large enough not to be a StringIO" do
261
+ # FIXME: this doesn't actually upload as a StringIO, figure out how to
262
+ # force that
263
+ @attachment.save
264
+ assert_equal File.size(File.join(File.dirname(__FILE__), '..', 'fixtures', 'milton.jpg')), File.size(@attachment.path)
265
+ end
266
+
267
+ should "have the same filesize as original file when small enough to be a StringIO" do
268
+ assert_equal File.size(File.join(File.dirname(__FILE__), '..', 'fixtures', 'mini-milton.jpg')), File.size(Attachment.create(:file => upload('mini-milton.jpg')).path)
269
+ end
270
+ end
271
+
272
+ context "stored full filename" do
273
+ setup do
274
+ @attachment = Attachment.create! :file => upload('milton.jpg')
275
+ end
276
+
277
+ should "use set root" do
278
+ assert_match /^#{@attachment.milton_options[:storage_options][:root]}.*$/, @attachment.path
279
+ end
280
+
281
+ should "use uploaded filename" do
282
+ assert_match /^.*#{@attachment.filename}$/, @attachment.path
283
+ end
284
+ end
285
+
286
+ context "sanitizing filename" do
287
+ setup do
288
+ @attachment = Attachment.create! :file => upload('unsanitary .milton.jpg')
289
+ end
290
+
291
+ should "strip the space and . and replace them with -" do
292
+ assert_match /^.*\/unsanitary--milton.jpg$/, @attachment.path
293
+ end
294
+
295
+ should "exist with sanitized filename" do
296
+ assert File.exists?(@attachment.path)
297
+ end
298
+ end
299
+
300
+ context "saving attachment after upload" do
301
+ setup do
302
+ @attachment = Attachment.create! :file => upload('unsanitary .milton.jpg')
303
+ end
304
+
305
+ should "save the file again" do
306
+ assert_nothing_raised do
307
+ Attachment.find(@attachment.id).save!
308
+ end
309
+ end
310
+ end
311
+ end
312
+ end
313
+
314
+ context "updating an existing attachment" do
315
+ setup do
316
+ @attachment = Attachment.create! :file => upload('milton.jpg')
317
+ @original_path = @attachment.path
318
+ @attachment.update_attributes! :file => upload('big-milton.jpg')
319
+ end
320
+
321
+ should "store the path to the updated upload" do
322
+ assert_equal 'big-milton.jpg', File.basename(@attachment.path)
323
+ end
324
+
325
+ should "save the updated upload" do
326
+ assert File.exists?(@attachment.path)
327
+ end
328
+ end
329
+ end