citrusbyte-milton 0.3.0 → 0.3.1

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