※この記事に登場するソースコードは僕個人が開発しているプロジェクトのやつです
ざっくり状況ややりたいことを説明
ユーザーが画像ファイルをアップロードできる機能があります。 現在アプリケーションレベルでファイルサイズに制限を掛けていないので大きなファイルをアップロードされるとNginxがエラーを返してしまいます。
そこでアプリケーションでアップロードできるファイルのサイズに制限を掛けることにします。 今回はまずサーバサイドでバリデーションを掛けますが、近々JS側でもはじくようにしていきます。
上限はとりあえず10MBにします。
状況説明(ソースコードレベル)
ファイルのアップロードはすべてImageUploadingServiceというクラスを通して行っています。
class ImageUploadingService < BaseService attr :image def after_initialize @image = folder.images.find_or_initialize_by( name: image_blob.original_filename, ) end def call unless valid_content_type? errors << I18n.t('services.errors.image_uploading_service.invalid_content_type', filename: image_blob.original_filename) return false end if image.save image.file.attach(image_blob) PreviewImageProcessingJob.perform_later(image_id: image.id) else errors << image.errors.full_messages return false end true end private def valid_content_type? Image::VALID_CONTENT_TYPES.include?(image_blob.content_type) end end
Content-Typeがホワイトリストに入っているものであれば、Imageモデルのインスタンスをsaveして、以下の処理を行います。
image.file.attach(image_blob) PreviewImageProcessingJob.perform_later(image_id: image.id)
ActiveStorage::Attachment#attach を呼ぶとActiveStorage::AnalyzeJobというジョブがエンキューされて、これが実際のファイルをローカルのディレクトリなりS3なりにに保存します。
PreviewImageProcessingJobは、サムネイルとプレビュー用の画像を作ってストレージに保存するジョブクラスです。
テストを書く
実際に10MBのファイルを使うのはテストが遅くなったりリポジトリが大きくなったりでちょっと辛いので、ファイルサイズを返すメソッドを特定してスタブする作戦でいこうと思います。
まずは10MBぴったりのパターン。
setup do @folder1 = folders(:kentas_folder1) @jpg_blob = fixture_file_upload(Rails.root.join('test', 'fixtures', 'files', 'cat.jpg'), 'image/jpeg') end + test "should allow uploading 10MB jpg" do + @jpg_blob.tempfile.stubs(:size).returns(10 * 1024 * 1024) + service = ImageUploadingService.new(folder: @folder1, image_blob: @jpg_blob) + assert_enqueued_with(job: PreviewImageProcessingJob) do + assert_enqueued_with(job: ActiveStorage::AnalyzeJob) do + assert(service.call) + assert_empty(service.errors) + end + end + end
想定通り、パスします。
Run options: -n test_should_allow_uploading_10MB_jpg --seed 22517 # Running: . Finished in 0.360729s, 2.7722 runs/s, 13.8608 assertions/s. 1 runs, 5 assertions, 0 failures, 0 errors, 0 skips
続いて、10MB+1byteのパターン。これはアップロード不可とします。
※デフォルトのlocaleはjaにしています
+ test "should not allow uploading jpg whose size is 10MB plus 1 byte" do + @jpg_blob.tempfile.stubs(:size).returns(10 * 1024 * 1024 + 1) + service = ImageUploadingService.new(folder: @folder1, image_blob: @jpg_blob) + refute(service.call) + assert_equal(['アップロードできるファイルの上限は10MBです: cat.jpg'], service.errors) + assert_enqueued_jobs 0 + end
想定通り失敗します。
Run options: -n test_should_not_allow_uploading_jpg_whose_size_is_10MB_plus_1_byte --seed 30095 # Running: F Failure: ImageUploadingServiceTest#test_should_not_allow_uploading_jpg_whose_size_is_10MB_plus_1_byte [/home/kenta-s/Repositories/oak/test/services/image_uploading_service_test.rb:60]: Expected true to not be truthy.
コード本体の修正
定数はImageモデルに定義します。
# Image model + MAX_FILE_SIZE = 10.megabytes
気づいたんですがActiveSupport入ってるので10.megabytesって書けるんでした。
あとでテストもこのメソッドを使うように書き直します。
valid_content_type? を valid_flie? にリネームしてContent-Typeとサイズ両方チェックするように書き換えます。
少し気持ち悪い気がしましたが、この時点でエラーメッセージを突っ込みます。
ActiveModelもvalid?を呼ぶとエラーを突っ込むのでそこまでおかしくはないだろうと考えました。
とりあえずテストを通したいのでエラーメッセージべた書きです。
あと今回の内容とは関係ないんですが、image.save成功時にperform_laterが返るのが気持ち悪かったので明示的にtrueを返すようにしました(以前実装したときに忘れていたみたいです)。
def call - unless valid_content_type? - errors << I18n.t('services.errors.image_uploading_service.invalid_content_type', filename: image_blob.original_filename) + unless valid_file? return false end if image.save image.file.attach(image_blob) PreviewImageProcessingJob.perform_later(image_id: image.id) + true else errors << image.errors.full_messages return false @@ -26,8 +26,16 @@ class ImageUploadingService < BaseService private - def valid_content_type? - Image::VALID_CONTENT_TYPES.include?(image_blob.content_type) + def valid_file? + unless Image::VALID_CONTENT_TYPES.include?(image_blob.content_type) + errors << I18n.t('services.errors.image_uploading_service.invalid_content_type', filename: image_blob.original_filename) + return false + end + + unless image_blob.tempfile.size <= Image::MAX_FILE_SIZE + errors << 'アップロードできるファイルの上限は10MBです: cat.jpg' + return false + end + + true end
これで通るようになりました。
Run options: -n test_should_not_allow_uploading_jpg_whose_size_is_10MB_plus_1_byte --seed 31030 # Running: . Finished in 0.424288s, 2.3569 runs/s, 7.0707 assertions/s. 1 runs, 3 assertions, 0 failures, 0 errors, 0 skips
他のテストが壊れていないことも確認
Run options: --seed 59667 # Running: ..... Finished in 0.552939s, 9.0426 runs/s, 47.0214 assertions/s. 5 runs, 26 assertions, 0 failures, 0 errors, 0 skips
エラーメッセージをべた書きしてしまっているところがあるのでテストを追加します。
+ test "should not allow uploading 100MB png" do + @png_blob.tempfile.stubs(:size).returns(100 * 1024 * 1024) + service = ImageUploadingService.new(folder: @folder1, image_blob: @png_blob) + refute(service.call) + assert_equal(['アップロードできるファイルの上限は10MBです: kokusai_man.png'], service.errors) + assert_enqueued_jobs 0 + end
Run options: -n test_should_not_allow_uploading_100MB_png --seed 19808 # Running: F Failure: ImageUploadingServiceTest#test_should_not_allow_uploading_100MB_png [/home/kenta-s/Repositories/oak/test/services/image_uploading_service_test.rb:69]: --- expected +++ actual @@ -1 +1 @@ -["アップロードできるファイルの上限は10MBです: kokusai_man.png"] +["アップロードできるファイルの上限は10MBです: cat.jpg"]
想定通り落ちます。
続いてコード本体を修正
@@ -5,3 +5,4 @@ ja: errors: image_uploading_service: invalid_content_type: "画像ファイルではありません: %{filename}" + filesize_exceeds_limit: "アップロードできるファイルの上限は10MBです: %{filename}"
unless image_blob.tempfile.size <= Image::MAX_FILE_SIZE - errors << 'アップロードできるファイルの上限は10MBです: cat.jpg' + errors << I18n.t('services.errors.image_uploading_service.filesize_exceeds_limit', filename: image_blob.original_filename) return false end
これで通るようになりました。
Run options: --seed 4612 # Running: ...... Finished in 0.714320s, 8.3996 runs/s, 40.5980 assertions/s. 6 runs, 29 assertions, 0 failures, 0 errors, 0 skips
ついでに10 * 1024 * 1024みたいなやつを10.megabytesに書き換えます。
test "should allow uploading 10MB jpg" do - @jpg_blob.tempfile.stubs(:size).returns(10 * 1024 * 1024) + @jpg_blob.tempfile.stubs(:size).returns(10.megabytes) service = ImageUploadingService.new(folder: @folder1, image_blob: @jpg_blob) assert_enqueued_with(job: PreviewImageProcessingJob) do assert_enqueued_with(job: ActiveStorage::AnalyzeJob) do @@ -55,7 +55,7 @@ class ImageUploadingServiceTest < ActiveSupport::TestCase end test "should not allow uploading jpg whose size is 10MB plus 1 byte" do - @jpg_blob.tempfile.stubs(:size).returns(10 * 1024 * 1024 + 1) + @jpg_blob.tempfile.stubs(:size).returns(10.megabytes + 1) service = ImageUploadingService.new(folder: @folder1, image_blob: @jpg_blob) refute(service.call) assert_equal(['アップロードできるファイルの上限は10MBです: cat.jpg'], service.errors) @@ -63,7 +63,7 @@ class ImageUploadingServiceTest < ActiveSupport::TestCase end test "should not allow uploading 100MB png" do - @png_blob.tempfile.stubs(:size).returns(100 * 1024 * 1024) + @png_blob.tempfile.stubs(:size).returns(100.megabytes) service = ImageUploadingService.new(folder: @folder1, image_blob: @png_blob) refute(service.call) assert_equal(['アップロードできるファイルの上限は10MBです: kokusai_man.png'], service.errors)
テストが壊れていないことを確認
Run options: --seed 7336 # Running: ...... Finished in 0.848481s, 7.0715 runs/s, 34.1787 assertions/s. 6 runs, 29 assertions, 0 failures, 0 errors, 0 skips
最後にブラウザで確認したいけど10MB超えてる画像なんて持ってない、、と思ってググってみたらちょうど良い記事がありました。
こんなにピンポイントでニーズを満たす記事を書けるなんてすごい人ですね。。。
※下記リンクにアクセスすると16MB超の画像を取得することになります
ブラウザでの確認もOKでした