ActiveStorageのファイルにアクセスするときに自前の認証機構を経由させる

Rails 5.2 から導入されたActiveStorageは `rails_blob_path` を使うことで署名つきURLへのリダイレクトを返してくれます。

```ruby
# in view

rails_blob_path(object.image)
# => "/rails/active_storage/blobs/<signed_id>/<filename>"
# 上記のURLにアクセスするとサービスに応じた認証URLへリダイレクトしてくれる。
# たとえばGCSなら `https://storage.googleapis.com/<bucket-name>/xxxyyyzzz`
# というようなURLにリダイレクトされる。
```

認証URLには有効期限がついている（デフォルトで5分）ので、`/rails/active_storage/blobs/**` にアクセスが来たときに適当に認証処理を挟むとファイルに対して簡易的なアクセス制御が実現できます。これを実現するには上記のエンドポイントを処理する `ActiveStorage::BlobsController` を上書きすれば良いです。参考のために ActiveStorage::BlobsControllerのデフォルトの実装を見てみましょう。

[rails/blobs_controller.rb at bc9fb9cf8b5dbe8ecf399ffd5d48d84bdb96a9db · rails/rails](https://github.com/rails/rails/blob/bc9fb9cf8b5dbe8ecf399ffd5d48d84bdb96a9db/activestorage/app/controllers/active_storage/blobs_controller.rb)

```ruby
# frozen_string_literal: true

# Take a signed permanent reference for a blob and turn it into an expiring service URL for download.
# Note: These URLs are publicly accessible. If you need to enforce access protection beyond the
# security-through-obscurity factor of the signed blob references, you'll need to implement your own
# authenticated redirection controller.
class ActiveStorage::BlobsController < ActiveStorage::BaseController
  include ActiveStorage::SetBlob

  def show
    expires_in ActiveStorage::Blob.service.url_expires_in
    redirect_to @blob.service_url(disposition: params[:disposition])
  end
end
```

実はここのコメントにも**「アクセス制御をしたい場合は自前でリダイレクトのためのコントローラーを実装しろ」**と書いてあります。

というわけで`app/controllers/active_storage/blobs_controller.rb`ファイルを作成し、アクセス制御のコードを挿入します。アクセス制御の内容はサービスによって様々でしょうから適宜書き換えてみてください。

```ruby:app/controllers/active_storage/blobs_controller.rb
class ActiveStorage::BlobsController < ActiveStorage::BaseController
  include ActiveStorage::SetBlob

  def show
    expires_in ActiveStorage::Blob.service.url_expires_in

    if access_allowed?(@blob)
      redirect_to @blob.service_url(disposition: params[:disposition])
    else
      head :forbidden
    end
  end

  private
  def access_allowed?(blob)
    # サービスに応じたアクセス処理を記述する。

    ...
  end
end
```

## どのレコードにアクセスが来たのか

上記のコントローラーで参照できるインスタンスは基本的に`@blob`だけで、`Blob`オブジェクトはサービス固有の情報を一切持っていないのでこのままではアクセス制御は行えません。しかしながら、`@blob`に一対多の関係で紐づいている`@blob.attachments`が持つ`record`という参照を利用することで、元のレコードを（だいたい）特定できます。`Attachment`が複数なので結局どのレコードに対するアクセスなのかは判明しませんが、基本的にはそのうちのどれか一つに対するアクセスが許可されるならば、ここでは認証URLを返して良いと思います（つまり `blob.attachments.any? { ... }` が `true` ならOK）。

単一のファイル（`Blob`）を複数のレコードに対して紐づけることも想定されているためこのような実装になっているそうです（ @scivola さんありがとうございます！）。そのような場合は単一の`Blob`に対し複数のレコードと、「関係」を抽象化した`Attachment`が存在するわけですが、複数のレコードのうちどれかに閲覧権を持つユーザーならファイル自体の閲覧権も持つでしょう。

`ActiveStorage::BlobsController` ではどのレコードに対するアクセスなのか特定できないためこのような回りくどいことをしているわけですが、もしかしたらレコードを特定する方法があるかもしれません（し、ないなら導入しても良いんじゃないでしょうか？）。

## `expires_in` だけ変えたい

認証URLの有効期限(`expires_in`)はコントローラーで直接変えてもいいですが、`config/application.rb` 等で `ActiveStorage::Service.url_expires_in` を設定することでも変更できます。

```ruby:config/application.rb
require_relative 'boot'

require 'rails/all'

# Require the gems listed in Gemfile, including any gems
# you've limited to :test, :development, or :production.
Bundler.require(*Rails.groups)

module HogeApp
  class Application < Rails::Application
    # Initialize configuration defaults for originally generated Rails version.
    config.load_defaults 5.2

    ActiveStorage::Service.url_expires_in = 1.minute
  end
end
```