diff --git a/app/assets/javascripts/discourse/components/click_track.js b/app/assets/javascripts/discourse/components/click_track.js index 0d98cd68c..399d5a8ba 100644 --- a/app/assets/javascripts/discourse/components/click_track.js +++ b/app/assets/javascripts/discourse/components/click_track.js @@ -85,7 +85,7 @@ Discourse.ClickTrack = { } // If we're on the same site, use the router and track via AJAX - if ((href.indexOf(Discourse.URL.origin()) === 0) && (!href.match(/\.(png|gif|jpg|jpeg)$/i))) { + if ((href.indexOf(Discourse.URL.origin()) === 0) && !href.match(/\/uploads\//i)) { Discourse.ajax("/clicks/track", { data: { url: href, @@ -109,5 +109,3 @@ Discourse.ClickTrack = { return false; } }; - - diff --git a/app/assets/javascripts/discourse/components/utilities.js b/app/assets/javascripts/discourse/components/utilities.js index e16eb48f3..9b010113c 100644 --- a/app/assets/javascripts/discourse/components/utilities.js +++ b/app/assets/javascripts/discourse/components/utilities.js @@ -212,6 +212,30 @@ Discourse.Utilities = { if (!extensions) return false; var regexp = new RegExp("\\.(" + extensions.replace(/\./g, "") + ")$", "i"); return file && file.name ? file.name.match(regexp) : false; + }, + + /** + Get the markdown template for an upload (either an image or an attachment) + + @method getUploadMarkdown + @param {Upload} upload The upload we want the markdown from + **/ + getUploadMarkdown: function(upload) { + if (this.isAnImage(upload.original_filename)) { + return '<img src="' + upload.url + '" width="' + upload.width + '" height="' + upload.height + '">'; + } else { + return '<a class="attachment" href="' + upload.url + '">' + upload.original_filename + '</a>'; + } + }, + + /** + Check whether the path is refering to an image + + @method isAnImage + @param {String} path The path + **/ + isAnImage: function(path) { + return path && path.match(/\.(png|jpg|jpeg|gif|bmp|tif)$/i); } }; diff --git a/app/assets/javascripts/discourse/views/composer_view.js b/app/assets/javascripts/discourse/views/composer_view.js index 395ff20a9..465c8d9c9 100644 --- a/app/assets/javascripts/discourse/views/composer_view.js +++ b/app/assets/javascripts/discourse/views/composer_view.js @@ -285,9 +285,9 @@ Discourse.ComposerView = Discourse.View.extend({ // done $uploadTarget.on('fileuploaddone', function (e, data) { - var upload = data.result; - var html = "<img src=\"" + upload.url + "\" width=\"" + upload.width + "\" height=\"" + upload.height + "\">"; - composerView.addMarkdown(html); + var markdown = Discourse.Utilities.getUploadMarkdown(data.result); + // appends a space at the end of the inserted markdown + composerView.addMarkdown(markdown + " "); composerView.set('isUploading', false); }); diff --git a/app/assets/javascripts/external/Markdown.Converter.js b/app/assets/javascripts/external/Markdown.Converter.js index 39fbc5d83..756687126 100644 --- a/app/assets/javascripts/external/Markdown.Converter.js +++ b/app/assets/javascripts/external/Markdown.Converter.js @@ -1275,7 +1275,7 @@ else // autolink anything like <http://example.com> var replacer = function (wholematch, m1) { - m1encoded = m1.replace(/\_\_/, '%5F%5F'); + var m1encoded = m1.replace(/\_\_/, '%5F%5F'); return "<a href=\"" + m1encoded + "\">" + pluginHooks.plainLinkText(m1) + "</a>"; } text = text.replace(/<((https?|ftp):[^'">\s]+)>/gi, replacer); diff --git a/app/assets/stylesheets/application/topic.css.scss b/app/assets/stylesheets/application/topic.css.scss index c55d097a3..d5ce90ad0 100644 --- a/app/assets/stylesheets/application/topic.css.scss +++ b/app/assets/stylesheets/application/topic.css.scss @@ -175,6 +175,16 @@ } } + a.attachment:before { + display: inline-block; + margin-right: 4px; + font-family: "FontAwesome"; + content: "\f019"; + } + .attachment + .size { + margin: 0 5px; + } + // When we are quoting something aside.quote { border-left: 5px solid #d7d7d7; diff --git a/app/controllers/uploads_controller.rb b/app/controllers/uploads_controller.rb index ceff160e3..bd812f535 100644 --- a/app/controllers/uploads_controller.rb +++ b/app/controllers/uploads_controller.rb @@ -4,8 +4,7 @@ class UploadsController < ApplicationController def create file = params[:file] || params[:files].first - # only supports images for now - return render status: 415, json: failed_json unless file.content_type =~ /^image\/.+/ + return render status: 415, json: failed_json unless SiteSetting.authorized_file?(file) upload = Upload.create_for(current_user.id, file) diff --git a/app/models/optimized_image.rb b/app/models/optimized_image.rb index 331c0716c..09d3c176c 100644 --- a/app/models/optimized_image.rb +++ b/app/models/optimized_image.rb @@ -49,19 +49,15 @@ class OptimizedImage < ActiveRecord::Base end def url - "#{Upload.base_url}/#{optimized_path}/#{filename}" + "#{LocalStore.base_url}/#{optimized_path}/#{filename}" end def path - "#{path_root}/#{optimized_path}/#{filename}" - end - - def path_root - @path_root ||= "#{Rails.root}/public" + "#{LocalStore.base_path}/#{optimized_path}/#{filename}" end def optimized_path - "uploads/#{RailsMultisite::ConnectionManagement.current_db}/_optimized/#{sha1[0..2]}/#{sha1[3..5]}" + "_optimized/#{sha1[0..2]}/#{sha1[3..5]}" end def filename diff --git a/app/models/site_setting.rb b/app/models/site_setting.rb index 3a961ebb8..021eb2d47 100644 --- a/app/models/site_setting.rb +++ b/app/models/site_setting.rb @@ -274,6 +274,23 @@ class SiteSetting < ActiveRecord::Base top_menu_items.map { |item| item.name }.select{ |item| list.include?(item) }.first end + def self.authorized_file?(file) + file.original_filename =~ /\.(#{authorized_extensions.tr(". ", "")})$/i + end + + def self.images + @images ||= ["jpg", "jpeg", "png", "gif", "tif", "tiff", "bmp"] + end + + def self.authorized_image?(file) + authorized_images = authorized_extensions + .tr(". ", "") + .split("|") + .select { |extension| images.include?(extension) } + .join("|") + file.original_filename =~ /\.(#{authorized_images})$/i + end + end # == Schema Information diff --git a/app/models/upload.rb b/app/models/upload.rb index 38d5a8592..02c3d0c2f 100644 --- a/app/models/upload.rb +++ b/app/models/upload.rb @@ -1,9 +1,9 @@ require 'digest/sha1' require 'image_sizer' -require 's3' -require 'local_store' require 'tempfile' require 'pathname' +require_dependency 's3' +require_dependency 'local_store' class Upload < ActiveRecord::Base belongs_to :user @@ -48,24 +48,25 @@ class Upload < ActiveRecord::Base sha1 = Digest::SHA1.file(file.tempfile).hexdigest # check if the file has already been uploaded unless upload = Upload.where(sha1: sha1).first - # retrieve image info - image_info = FastImage.new(file.tempfile, raise_on_failure: true) - # compute image aspect ratio - width, height = ImageSizer.resize(*image_info.size) # create a db record (so we can use the id) upload = Upload.create!({ user_id: user_id, original_filename: file.original_filename, filesize: File.size(file.tempfile), sha1: sha1, - width: width, - height: height, url: "" }) - # make sure we're at the beginning of the file (FastImage is moving the pointer) - file.rewind + # deal with width & heights for images + if SiteSetting.authorized_image?(file) + # retrieve image info + image_info = FastImage.new(file.tempfile, raise_on_failure: true) + # compute image aspect ratio + upload.width, upload.height = ImageSizer.resize(*image_info.size) + # make sure we're at the beginning of the file (FastImage is moving the pointer) + file.rewind + end # store the file and update its url - upload.url = Upload.store_file(file, sha1, image_info, upload.id) + upload.url = Upload.store_file(file, sha1, upload.id) # save the url upload.save end @@ -73,9 +74,9 @@ class Upload < ActiveRecord::Base upload end - def self.store_file(file, sha1, image_info, upload_id) - return S3.store_file(file, sha1, image_info, upload_id) if SiteSetting.enable_s3_uploads? - return LocalStore.store_file(file, sha1, image_info, upload_id) + def self.store_file(file, sha1, upload_id) + return S3.store_file(file, sha1, upload_id) if SiteSetting.enable_s3_uploads? + return LocalStore.store_file(file, sha1, upload_id) end def self.remove_file(url) @@ -92,29 +93,15 @@ class Upload < ActiveRecord::Base end def self.is_local?(url) - url.start_with?(base_url) + !SiteSetting.enable_s3_uploads? && url.start_with?(LocalStore.base_url) end def self.is_on_s3?(url) SiteSetting.enable_s3_uploads? && url.start_with?(S3.base_url) end - def self.base_url - asset_host.present? ? asset_host : Discourse.base_url_no_prefix - end - - def self.asset_host - ActionController::Base.asset_host - end - def self.get_from_url(url) - if has_been_uploaded?(url) - if m = LocalStore.uploaded_regex.match(url) - Upload.where(id: m[:upload_id]).first - elsif is_on_s3?(url) - Upload.where(url: url).first - end - end + Upload.where(url: url).first if has_been_uploaded?(url) end end diff --git a/app/serializers/upload_serializer.rb b/app/serializers/upload_serializer.rb index 8971e9a3d..9a0a7753b 100644 --- a/app/serializers/upload_serializer.rb +++ b/app/serializers/upload_serializer.rb @@ -1,5 +1,5 @@ class UploadSerializer < ApplicationSerializer - attributes :url, :filesize, :original_filename, :width, :height + attributes :url, :original_filename, :width, :height end diff --git a/config/nginx.sample.conf b/config/nginx.sample.conf index 83da2e882..2fa141bc0 100644 --- a/config/nginx.sample.conf +++ b/config/nginx.sample.conf @@ -21,8 +21,8 @@ server { location / { root /home/discourse/discourse/public; - ## optional image anti-hotlinking rules - #location ~ \.(jpe?g|png|gif)$ { + ## optional upload anti-hotlinking rules + #location ~ ^/uploads/ { # valid_referers none blocked mysite.com *.mysite.com; # if ($invalid_referer) { # return 403; @@ -35,7 +35,7 @@ server { add_header ETag ""; } - location ~ ^/assets/ { + location ~ ^/(assets|uploads)/ { expires 1y; add_header Cache-Control public; add_header ETag ""; diff --git a/lib/cooked_post_processor.rb b/lib/cooked_post_processor.rb index 97507b86b..84db92074 100644 --- a/lib/cooked_post_processor.rb +++ b/lib/cooked_post_processor.rb @@ -16,10 +16,26 @@ class CookedPostProcessor end def post_process + post_process_attachments post_process_images post_process_oneboxes end + def post_process_attachments + attachments.each do |attachment| + href = attachment['href'] + attachment['href'] = relative_to_absolute(href) + if upload = Upload.get_from_url(href) + # update reverse index + associate_to_post(upload) + # append the size + append_human_size!(attachment, upload) + end + # mark as dirty + @dirty = true + end + end + def post_process_images images = extract_images return if images.blank? @@ -33,7 +49,7 @@ class CookedPostProcessor # make sure the img has proper width and height attributes update_dimensions!(img) # retrieve the associated upload, if any - if upload = Upload.get_from_url(img['src']) + if upload = Upload.get_from_url(src) # update reverse index associate_to_post(upload) end @@ -209,6 +225,22 @@ class CookedPostProcessor rescue URI::InvalidURIError end + def attachments + if SiteSetting.enable_s3_uploads? + @doc.css("a[href^=\"#{S3.base_url}\"]") + else + # local uploads are identified using a relative uri + @doc.css("a[href^=\"#{LocalStore.directory}\"]") + end + end + + def append_human_size!(attachment, upload) + size = Nokogiri::XML::Node.new("span", @doc) + size["class"] = "size" + size.content = "(#{number_to_human_size(upload.filesize)})" + attachment.add_next_sibling(size) + end + def dirty? @dirty end diff --git a/lib/local_store.rb b/lib/local_store.rb index f893461da..a87ff8a0e 100644 --- a/lib/local_store.rb +++ b/lib/local_store.rb @@ -1,18 +1,21 @@ module LocalStore - def self.store_file(file, sha1, image_info, upload_id) - clean_name = Digest::SHA1.hexdigest("#{Time.now.to_s}#{file.original_filename}")[0,16] + ".#{image_info.type}" + def self.store_file(file, sha1, upload_id) + unique_sha1 = Digest::SHA1.hexdigest("#{Time.now.to_s}#{file.original_filename}")[0,16] + extension = File.extname(file.original_filename) + clean_name = "#{unique_sha1}#{extension}" url_root = "/uploads/#{RailsMultisite::ConnectionManagement.current_db}/#{upload_id}" path = "#{Rails.root}/public#{url_root}" FileUtils.mkdir_p path + # not using cause mv, cause permissions are no good on move File.open("#{path}/#{clean_name}", "wb") do |f| f.write File.read(file.tempfile) end # url - return Discourse::base_uri + "#{url_root}/#{clean_name}" + Discourse::base_uri + "#{url_root}/#{clean_name}" end def self.remove_file(url) @@ -24,4 +27,21 @@ module LocalStore /\/uploads\/#{RailsMultisite::ConnectionManagement.current_db}\/(?<upload_id>\d+)\/[0-9a-f]{16}\.(png|jpg|jpeg|gif|tif|tiff|bmp)/ end + def self.base_url + url = asset_host.present? ? asset_host : Discourse.base_url_no_prefix + "#{url}#{directory}" + end + + def self.base_path + "#{Rails.root}/public#{directory}" + end + + def self.directory + "/uploads/#{RailsMultisite::ConnectionManagement.current_db}" + end + + def self.asset_host + ActionController::Base.asset_host + end + end diff --git a/lib/s3.rb b/lib/s3.rb index a880cfd0e..09d485a72 100644 --- a/lib/s3.rb +++ b/lib/s3.rb @@ -1,11 +1,11 @@ module S3 - def self.store_file(file, sha1, image_info, upload_id) + def self.store_file(file, sha1, upload_id) S3.check_missing_site_settings directory = S3.get_or_create_directory(SiteSetting.s3_upload_bucket) - - remote_filename = "#{upload_id}#{sha1}.#{image_info.type}" + extension = File.extname(file.original_filename) + remote_filename = "#{upload_id}#{sha1}#{extension}" # if this fails, it will throw an exception file = S3.upload(file, remote_filename, directory) diff --git a/spec/components/cooked_post_processor_spec.rb b/spec/components/cooked_post_processor_spec.rb index 3fc9c0cfc..e216c8b6c 100644 --- a/spec/components/cooked_post_processor_spec.rb +++ b/spec/components/cooked_post_processor_spec.rb @@ -9,7 +9,8 @@ describe CookedPostProcessor do let(:cpp) { CookedPostProcessor.new(post) } let(:post_process) { sequence("post_process") } - it "works on images before oneboxes" do + it "post process in sequence" do + cpp.expects(:post_process_attachments).in_sequence(post_process) cpp.expects(:post_process_images).in_sequence(post_process) cpp.expects(:post_process_oneboxes).in_sequence(post_process) cpp.post_process @@ -17,6 +18,35 @@ describe CookedPostProcessor do end + context "post_process_attachments" do + + context "with attachment" do + + let(:upload) { Fabricate(:upload) } + let(:post) { Fabricate(:post_with_an_attachment) } + let(:cpp) { CookedPostProcessor.new(post) } + + # all in one test to speed things up + it "works" do + Upload.expects(:get_from_url).returns(upload) + cpp.post_process_attachments + # ensures absolute urls on attachment + cpp.html.should =~ /#{LocalStore.base_url}/ + # ensure name is present + cpp.html.should =~ /archive.zip/ + # ensure size is present + cpp.html.should =~ /<span class=\"size\">\(1.21 KB\)<\/span>/ + # dirty + cpp.should be_dirty + # keeps the reverse index up to date + post.uploads.reload + post.uploads.count.should == 1 + end + + end + + end + context "post_process_images" do context "with images in quotes and oneboxes" do @@ -48,7 +78,7 @@ describe CookedPostProcessor do Upload.expects(:get_from_url).returns(upload).twice cpp.post_process_images # ensures absolute urls on uploaded images - cpp.html.should =~ /#{Discourse.base_url_no_prefix}/ + cpp.html.should =~ /#{LocalStore.base_url}/ # dirty cpp.should be_dirty # keeps the reverse index up to date @@ -58,7 +88,7 @@ describe CookedPostProcessor do end - context "width sized images" do + context "with sized images" do let(:post) { build(:post_with_image_url) } let(:cpp) { CookedPostProcessor.new(post, image_sizes: {'http://foo.bar/image.png' => {'width' => 111, 'height' => 222}}) } diff --git a/spec/components/local_store_spec.rb b/spec/components/local_store_spec.rb index 2b79ef676..3e87081e1 100644 --- a/spec/components/local_store_spec.rb +++ b/spec/components/local_store_spec.rb @@ -22,7 +22,7 @@ describe LocalStore do # The Time needs to be frozen as it is used to generate a clean & unique name Time.stubs(:now).returns(Time.utc(2013, 2, 17, 12, 0, 0, 0)) # - LocalStore.store_file(file, "", image_info, 1).should == '/uploads/default/1/253dc8edf9d4ada1.png' + LocalStore.store_file(file, "", 1).should == '/uploads/default/1/253dc8edf9d4ada1.png' end end diff --git a/spec/components/s3_spec.rb b/spec/components/s3_spec.rb index f2b4d2682..d0de2d44a 100644 --- a/spec/components/s3_spec.rb +++ b/spec/components/s3_spec.rb @@ -24,7 +24,7 @@ describe S3 do end it 'returns the url of the S3 upload if successful' do - S3.store_file(file, "SHA", image_info, 1).should == '//s3_upload_bucket.s3.amazonaws.com/1SHA.png' + S3.store_file(file, "SHA", 1).should == '//s3_upload_bucket.s3.amazonaws.com/1SHA.png' end after(:each) do diff --git a/spec/controllers/uploads_controller_spec.rb b/spec/controllers/uploads_controller_spec.rb index f71342b18..686713cfc 100644 --- a/spec/controllers/uploads_controller_spec.rb +++ b/spec/controllers/uploads_controller_spec.rb @@ -41,20 +41,39 @@ describe UploadsController do let(:files) { [ logo_dev, logo ] } context 'with a file' do - it 'is succesful' do + + it 'is successful' do xhr :post, :create, file: logo response.should be_success end - it 'supports only images' do - xhr :post, :create, file: text_file - response.status.should eq 415 + context 'when authorized' do + + before { SiteSetting.stubs(:authorized_extensions).returns(".txt") } + + it 'is successful' do + xhr :post, :create, file: text_file + response.status.should eq 200 + end + end + + context 'when not authorized' do + + before { SiteSetting.stubs(:authorized_extensions).returns(".png") } + + it 'rejects the upload' do + xhr :post, :create, file: text_file + response.status.should eq 415 + end + + end + end context 'with some files' do - it 'is succesful' do + it 'is successful' do xhr :post, :create, files: files response.should be_success end diff --git a/spec/fabricators/post_fabricator.rb b/spec/fabricators/post_fabricator.rb index 9ef164e2e..5bb76f998 100644 --- a/spec/fabricators/post_fabricator.rb +++ b/spec/fabricators/post_fabricator.rb @@ -50,6 +50,10 @@ Fabricator(:post_with_uploaded_images, from: :post) do ' end +Fabricator(:post_with_an_attachment, from: :post) do + cooked '<a class="attachment" href="/uploads/default/186/66b3ed1503efc936.zip">archive.zip</a>' +end + Fabricator(:post_with_unsized_images, from: :post) do cooked ' <img src="http://foo.bar/image.png"> diff --git a/spec/fabricators/upload_fabricator.rb b/spec/fabricators/upload_fabricator.rb index 16fed8837..53a7911ea 100644 --- a/spec/fabricators/upload_fabricator.rb +++ b/spec/fabricators/upload_fabricator.rb @@ -6,3 +6,10 @@ Fabricator(:upload) do height 200 url "/uploads/default/1/1234567890123456.jpg" end + +Fabricator(:attachment, from: :upload) do + user + original_filename "archive.zip" + filesize 1234 + url "/uploads/default/186/66b3ed1503efc936.zip" +end diff --git a/spec/models/upload_spec.rb b/spec/models/upload_spec.rb index dc300dbac..850fb8410 100644 --- a/spec/models/upload_spec.rb +++ b/spec/models/upload_spec.rb @@ -62,13 +62,13 @@ describe Upload do it "identifies internal or relatives urls" do Discourse.expects(:base_url_no_prefix).returns("http://discuss.site.com") - Upload.has_been_uploaded?("http://discuss.site.com/upload/1234/42/0123456789ABCDEF.jpg").should == true + Upload.has_been_uploaded?("http://discuss.site.com/uploads/default/42/0123456789ABCDEF.jpg").should == true Upload.has_been_uploaded?("/upload/42/0123456789ABCDEF.jpg").should == true end it "identifies internal urls when using a CDN" do ActionController::Base.expects(:asset_host).returns("http://my.cdn.com").twice - Upload.has_been_uploaded?("http://my.cdn.com/upload/1234/42/0123456789ABCDEF.jpg").should == true + Upload.has_been_uploaded?("http://my.cdn.com/uploads/default/42/0123456789ABCDEF.jpg").should == true end it "identifies S3 uploads" do @@ -78,7 +78,7 @@ describe Upload do end it "identifies external urls" do - Upload.has_been_uploaded?("http://domain.com/upload/1234/42/0123456789ABCDEF.jpg").should == false + Upload.has_been_uploaded?("http://domain.com/uploads/default/42/0123456789ABCDEF.jpg").should == false Upload.has_been_uploaded?("//bucket.s3.amazonaws.com/1337.png").should == false end diff --git a/test/javascripts/components/click_track_test.js b/test/javascripts/components/click_track_test.js index 7ddf7a481..72eb1c593 100644 --- a/test/javascripts/components/click_track_test.js +++ b/test/javascripts/components/click_track_test.js @@ -20,6 +20,7 @@ module("Discourse.ClickTrack", { ' <a id="inside-onebox-forced" class="track-link" href="http://www.google.com">google.com<span class="badge">1</span></a>', ' </div>', ' <a id="same-site" href="http://discuss.domain.com">forum</a>', + ' <a class="attachment" href="http://discuss.domain.com/uploads/default/1234/1532357280.txt">log.txt</a>', ' </article>', '</div>'].join("\n")); }, @@ -156,6 +157,13 @@ test("tracks via AJAX if we're on the same site", function() { ok(Discourse.URL.routeTo.calledOnce); }); +test("does not track via AJAX for attachments", function() { + this.stub(Discourse.URL, "routeTo"); + this.stub(Discourse.URL, "origin").returns("http://discuss.domain.com"); + + ok(!track(generateClickEventOn('.attachment'))); + ok(Discourse.URL.redirectTo.calledOnce); +}); test("tracks custom urls when opening in another window", function() { var clickEvent = generateClickEventOn('a'); diff --git a/test/javascripts/components/utilities_test.js b/test/javascripts/components/utilities_test.js index 7077c2968..416b2fde2 100644 --- a/test/javascripts/components/utilities_test.js +++ b/test/javascripts/components/utilities_test.js @@ -7,7 +7,6 @@ test("emailValid", function() { ok(utils.emailValid('bob@EXAMPLE.com'), "allows upper case in the email domain"); }); - var validUpload = utils.validateFilesForUpload; test("validateFilesForUpload", function() { @@ -51,9 +50,9 @@ test("prevents files that are too big from being uploaded", function() { }); var dummyBlob = function() { - window.BlobBuilder = window.BlobBuilder || window.WebKitBlobBuilder || window.MozBlobBuilder || window.MSBlobBuilder; - if (window.BlobBuilder) { - var bb = new window.BlobBuilder(); + var BlobBuilder = window.BlobBuilder || window.WebKitBlobBuilder || window.MozBlobBuilder || window.MSBlobBuilder; + if (BlobBuilder) { + var bb = new BlobBuilder(); bb.append([1]); return bb.getBlob("image/png"); } else { @@ -85,3 +84,28 @@ test("isAuthorizedUpload", function() { ok(!isAuthorized("image.txt")); ok(!isAuthorized("")); }); + +var getUploadMarkdown = function(filename) { + return utils.getUploadMarkdown({ + original_filename: filename, + width: 100, + height: 200, + url: "/upload/123/abcdef.ext" + }); +}; + +test("getUploadMarkdown", function() { + ok(getUploadMarkdown("lolcat.gif") === '<img src="/upload/123/abcdef.ext" width="100" height="200">'); + ok(getUploadMarkdown("important.txt") === '<a class="attachment" href="/upload/123/abcdef.ext">important.txt</a>'); +}); + +test("isAnImage", function() { + _.each(["png", "jpg", "jpeg", "bmp", "gif", "tif"], function(extension) { + var image = "image." + extension; + ok(utils.isAnImage(image)); + ok(utils.isAnImage("http://foo.bar/path/to/" + image)); + }); + ok(!utils.isAnImage("file.txt")); + ok(!utils.isAnImage("http://foo.bar/path/to/file.txt")); + ok(!utils.isAnImage("")); +});