作業ログ アップロードされるファイルサイズを制限する 2019/09/01

※この記事に登場するソースコードは僕個人が開発しているプロジェクトのやつです

ざっくり状況ややりたいことを説明

ユーザーが画像ファイルをアップロードできる機能があります。 現在アプリケーションレベルでファイルサイズに制限を掛けていないので大きなファイルをアップロードされると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超の画像を取得することになります

tmpl.blog.jp

ブラウザでの確認もOKでした

f:id:Kenta-s:20190901162023p:plain