HEIC を JPEG に自動変換！Rails で作るセキュアな画像投稿アプリ

## はじめに:point_up:
**CarrierWave**と**MiniMagivk**で`HEIC`変換を実装したけどうまくいかなかったので別の方法を探していたとき***cloudinary***に出会って、***cloudinary***を使って変換して投稿することを実装しました。
実装をする上で***cloudinary***を無料枠で使いたい&画像の管理を**AWSS3**だけにしたいという思いがあります。

## 今回の実装でできること:v:
* HEIC 画像をアップロードできる
* アップロードされた HEIC 画像は Cloudinary で JPEG に自動変換される
* 変換された JPEG 画像は AWS S3 に保存される
* Cloudinary 上の HEIC 画像は自動的に削除される (無料枠の節約)
* S3 に保存された画像の署名付き URL を生成し、有効期限付きでセキュアに画像を表示
* Cloudinary Webhook を利用し、非同期で処理を行う

## 前提条件:open_hands:
*   Rails で基本的な投稿アプリケーションが作成済みであること
*   AWS S3 バケットが作成済みであり、アクセスキーとシークレットキーが設定済みであること
*   CarrierWave を使った画像アップロード機能が実装済みであること
*   Cloudinary アカウントが作成済みであること

 ```ruby:環境変数(.env)に設定していること
 # AWS S3の設定
AWS_ACCESS_KEY_ID='アクセスキー'
AWS_SECRET_ACCESS_KEY='シークレットアクセスキー'
AWS_REGION='ap-northeast-1'
AWS_BUCKET_NAME='バケット名'

# Cloudinaryの設定
# Cloudinary のダッシュボードから取得し、環境変数に設定してください。
CLOUDINARY_CLOUD_NAME='クラウド名'
CLOUDINARY_API_KEY='APIキー'
CLOUDINARY_API_SECRET='APIシークレットキー'
```
   
 https://zenn.dev/mockey/articles/daf80384bf18d2

 https://github.com/carrierwaveuploader/carrierwave

 https://railsguides.jp/active_storage_overview.html

 https://cloudinary.com

## 実装手順:writing_hand:
### 1. Gem のインストール
`Gemfile` に以下の gem を追加し、`bundle install` を実行します。
```ruby:Gemfile
gem 'cloudinary'
gem 'aws-sdk-s3'
```

### 2.Cloudinary の設定
`config/initializers/cloudinary.rb` を作成し、`Cloudinary` の API キーなどを設定します。
```ruby:cloudinary.rb
Cloudinary.config do |config|
  config.cloud_name = ENV['CLOUDINARY_CLOUD_NAME']
  config.api_key = ENV['CLOUDINARY_API_KEY']
  config.api_secret = ENV['CLOUDINARY_API_SECRET']
  config.secure = true
end
```

### 3. CarrierWave の設定変更
`app/uploaders/ooo_uploader.rb`を以下のように変更します。
```ruby:ooo_uploader.rb
class oooUploader < CarrierWave::Uploader::Base
# Cloudinary の機能を CarrierWave で使えるようにする
  include Cloudinary::CarrierWave

  # Cloudinary の public_id を保存 (Webhook で Post オブジェクトを特定するために使用)
  after :store, :set_cloudinary_asset_id

  # 保存先ディレクトリ
  def store_dir
    "uploads/#{model.class.to_s.underscore}/#{mounted_as}/#{model.id}"
  end

  # 許可する拡張子
  def extension_allowlist
    %w(jpg jpeg gif png heic)
  end

  # Cloudinary で JPEG に変換
  process convert: "jpg"

  private

  # Cloudinary の public_id を Post モデルの cloudinary_asset_id カラムに保存
  def set_cloudinary_asset_id(file)
    Rails.logger.debug "set_cloudinary_asset_id called"
  # Cloudinary へのアップロードが成功し、public_id が存在する場合のみ更新
    if model && file.respond_to?(:public_id)
      model.update_column(:cloudinary_asset_id, file.public_id)
    end
  end
end
```

### 4. Model の変更
Post モデル`app/models/post.rb`に、以下の変更を加えます。
```ruby:post.rb
class Post < ApplicationRecord
  # CarrierWave を使って image カラムに PostImageUploader をマウント
  mount_uploader :image, PostImageUploader

  belongs_to :user
  
  # Post 保存後に Cloudinary の asset_id を更新
  after_save :update_cloudinary_asset_id
  # Post 削除後に Cloudinary と S3 から画像を削除
  after_destroy :delete_image_from_cloudinary_and_s3

  private

  # Cloudinary の asset_id を更新
  def update_cloudinary_asset_id
    if self.image.present? && self.image.file.respond_to?(:public_id) && self.cloudinary_asset_id.blank?
      self.update_column(:cloudinary_asset_id, self.image.file.public_id)
    end
  end

  # Cloudinary と S3 から画像を削除
  def delete_image_from_cloudinary_and_s3
    if self.cloudinary_asset_id.present?
      begin
        Cloudinary::Uploader.destroy(self.cloudinary_asset_id) # Cloudinary から画像を削除
      rescue => e
        Rails.logger.error "Failed to delete image from Cloudinary: #{e.message}"
      end
    end

    if self.s3_image_url.present?
      begin
        s3 = Aws::S3::Resource.new( # AWS SDK for Ruby (v3) を使って S3 に接続
          region: "ap-northeast-1",
          access_key_id: ENV["AWS_ACCESS_KEY_ID"],
          secret_access_key: ENV["AWS_SECRET_ACCESS_KEY"]
        )
        uri = URI.parse(self.s3_image_url)
        bucket_name = uri.host.split(".").first # S3 のバケット名を取得
        object_key = uri.path[1..-1] # S3 のオブジェクトキーを取得
        bucket = s3.bucket(bucket_name)
        obj = bucket.object(object_key)
        obj.delete # S3 から画像を削除
      rescue => e
        Rails.logger.error "Failed to delete image from S3: #{e.message}"
      end
    end
  end
```

### 5. Migration の追加
Post モデルに`cloudinary_asset_id`と`s3_image_url`カラムを追加するためのマイグレーションファイルを作成します。
```bash:
% rails generate migration AddCloudinaryAssetIdAndS3ImageUrlToPosts cloudinary_asset_id:string s3_image_url:string
% rails db:migrate
```

### 6. Controller の変更`PostsController`
```ruby:posts_controller.rb
class PostsController < ApplicationController

  def create
    @post = current_user.posts.build(post_params)
    if @post.save
      redirect_to posts_path
    else
      render :new
    end
  end

  private

  def post_params
    params.require(:post).permit(:body, :image)
  end
end
```

### 7. Webhook Controller の作成
`Cloudinary`からの`Webhook`を受け取るためのコントローラーを作成します。
```bash
rails generate controller CloudinaryWebhooks create --skip-routes
```
```ruby:cloudinary_webhooks_controller.rb
# AWS S3とのやり取り(オブジェクトのアップロード、署名付き URL の生成など)
require "aws-sdk-s3"
# Cloudinary API を使って、画像のアップロード、変換、削除などを行う
require "cloudinary"
# HTTP/HTTPS 経由でファイル(この場合は Cloudinary 上の画像)をダウンロードできます
require "open-uri"

class CloudinaryWebhooksController < ApplicationController
  skip_before_action :verify_authenticity_token # CSRF トークンの検証をスキップ (Cloudinary からの Webhook のため)

  def create
    Rails.logger.debug "Cloudinary webhook received!"
    Rails.logger.debug "Request body: #{request.body.read}"

    payload = request.body.read # リクエストボディを取得
    event = JSON.parse(payload) # JSON として解析

    # Cloudinary からのレスポンスから必要な情報を取得
    secure_url = event["secure_url"] # HTTPS の画像 URL
    public_id = event["public_id"]   # Cloudinary での画像の識別子
    format = event["format"]         # 画像のフォーマット (例: "jpg")
    asset_id = event["asset_id"]     # Cloudinary でのアセット ID (今回は未使用)

    # public_id が nil の場合は処理を中断
    if public_id.nil?
      Rails.logger.error "public_id is nil. Aborting webhook processing."
      render json: { error: "public_id is nil" }, status: :bad_request
      return
    end

    begin
      # Cloudinary の secure_url から画像データをダウンロード
      image_data = URI.open(secure_url).read

      # AWS SDK for Ruby (v3) を使って S3 に接続
      s3 = Aws::S3::Resource.new(
        region: ENV["AWS_REGION"],
        access_key_id: ENV["AWS_ACCESS_KEY_ID"],
        secret_access_key: ENV["AWS_SECRET_ACCESS_KEY"]
      )

      # S3 に保存するオブジェクトのキーを生成 (Cloudinary の public_id とフォーマットを使用)
      object_key = "#{public_id}.#{format}"

      # S3 バケットにオブジェクトをアップロード
      obj = s3.bucket(ENV["AWS_S3_BUCKET_NAME"]).object(object_key)
      obj.put(body: image_data, content_type: "image/#{format}", acl: "public-read") # 公開読み取り可能に設定

      # Cloudinary から画像を削除
      Cloudinary::Uploader.destroy(public_id)

      # 対応する Post オブジェクトを検索
      post = Post.find_by(cloudinary_asset_id: public_id)
      if post
        Rails.logger.debug "Post found: #{post.inspect}"

        # S3 オブジェクトの署名付き URL を生成 (有効期限: 3600秒 = 1時間)
        signer = Aws::S3::Presigner.new(client: s3.client)
        presigned_url = signer.presigned_url(:get_object, bucket: ENV["AWS_S3_BUCKET_NAME"], key: object_key, expires_in: 3600)
        Rails.logger.debug "Presigned URL: #{presigned_url}"

        # Post オブジェクトの s3_image_url カラムに署名付き URL を保存
        post.update_column(:s3_image_url, presigned_url.to_s)
        post.reload

        # Turbo Stream を使って、画像表示を動的に更新 (必要に応じて)
        Turbo::StreamsChannel.broadcast_replace_to(
          "posts", # ストリーム名
          target: "post_image_#{post.id}", # 更新する要素の ID
          partial: "posts/image", # 使用するパーシャル
          locals: { post: post } # パーシャルに渡す変数
        )
      else
        Rails.logger.warn "Post not found for public_id: #{public_id}"
      end

      head :ok # 成功レスポンスを返す
    rescue => e
      Rails.logger.error "Error processing Cloudinary webhook: #{e.message}"
      Rails.logger.error e.backtrace.join("\n")
      render json: { error: e.message }, status: :internal_server_error
    end
  end
end
```

### 8. ルーティングの設定
```ruby:routes.rb
Rails.application.routes.draw do
  post '/cloudinary_webhook', to: 'cloudinary_webhooks#create'
end
```

### 9. Cloudinary の Webhook 設定
`Cloudinary`の管理画面で`Webhook`を設定します。
1. `Cloudinary` のダッシュボードにログインします
2. `Settings` > `Webhook Notifications` を選択します 
3. `Notification URL`を設定`https://your-url/cloudinary_webhook`
4. `Notification Type(s)`を`Upload`に設定
:::note info
Webhook の URL は、HTTPS である必要があると思います。
ローカル環境でテストする場合は、ngrok などのツールを使って、ローカルサーバーを外部に公開する必要があります。
:::

### 10. PostsControllerのアクションで、適切な画像 URL にリダイレクトする
`display_image_post_path(post)`によって生成された URL にアクセスすると、`PostsController#show_image` アクションが実行されます。
 * `S3 の署名付き URL にリダイレクト`: post.s3_image_url が存在する場合 (Webhook 処理が完了し、S3 に画像が保存されている場合)
 * `Cloudinary の URL にリダイレクト`: post.s3_image_url が存在しない場合 (Webhook 処理が完了していない、または投稿に画像がない場合)
 ```ruby:PostsController#show_image
  # 画像表示用のアクション
  def show_image
    if @post.s3_image_url.present?
      # S3 の署名付き URL が存在する場合 (Webhook 処理が完了している場合)
      s3 = Aws::S3::Resource.new(
        region: "ap-northeast-1",
        access_key_id: ENV["AWS_ACCESS_KEY_ID"],
        secret_access_key: ENV["AWS_SECRET_ACCESS_KEY"]
      )
      signer = Aws::S3::Presigner.new(client: s3.client)
      # S3 オブジェクトの署名付き URL を生成 (有効期限: 3600秒 = 1時間)
      url = signer.presigned_url(:get_object, bucket: "walk-and", key: URI.parse(@post.s3_image_url).path[1..-1], expires_in: 3600)
      redirect_to url, allow_other_host: true # 署名付き URL にリダイレクト
    else
      # S3 の署名付き URL が存在しない場合 (Webhook 処理が完了していない場合)
      # Cloudinary の URL にリダイレクト
      redirect_to @post.image.url, allow_other_host: true
    end
  end
```

### 11. ルーティング設定
```ruby:routes.rb
resources :posts do
  get "image", on: :member, to: "posts#show_image", as: :display_image
end
```

### 12. viewでの画像表示
```ruby:index.htnl.erb
<% if post.image.present? %>
  <%= image_tag display_image_post_path(post), id: "post_image_#{post.id}" %>
<% else %>
  <%= image_tag 'default_image.jpg', alt: t('posts.index.default_image_alt'), class: "rounded-lg shadow-lg w-full" %>
<% end %>
```

## まとめ:grinning:
この記事では、Rails アプリケーションで HEIC 画像を扱い、Cloudinary と AWS S3 を連携させて、以下の機能を実装する方法を解説しました。

*   **HEIC 画像のアップロード**: ユーザーが HEIC 形式の画像をアップロードできる
*   **Cloudinary による JPEG 自動変換**: CarrierWave と Cloudinary の連携により、アップロードされた HEIC 画像を自動的に JPEG 形式に変換する
*   **AWS S3 への保存**: Cloudinary で変換された JPEG 画像を AWS S3 に保存する
*   **Cloudinary からの自動削除**: Cloudinary の Webhook を利用して、S3 への保存が完了した画像を Cloudinary から自動的に削除する (無料枠の節約)
*   **署名付き URL によるセキュアな画像配信**: AWS SDK for Ruby を使用して、S3 に保存された画像への署名付き URL を生成し、有効期限付きでセキュアに画像を配信する
*   **Turbo Stream による動的な画像更新**: Webhook 処理後に Turbo Stream を送信し、画像表示を動的に更新する (オプション)

これらの機能を実装することで、以下のメリットが得られます。

*   **HEIC 画像対応**: 最新の iPhone などで撮影された HEIC 画像を、Web ブラウザで表示可能な JPEG 形式に自動変換できる
*   **ストレージコストの最適化**: Cloudinary の無料枠を節約しつつ、AWS S3 の低コストなストレージを活用できる
*   **セキュリティの向上**: S3 の署名付き URL を使用することで、画像の不正なアクセスを防ぎ、セキュアに配信できる
*   **パフォーマンスの向上**: Cloudinary の Webhook を利用することで、画像変換・保存処理を非同期で行い、ユーザーの待ち時間を短縮できる
*   **柔軟な画像表示**: `PostsController#show_image` アクションにより、Webhook 処理の完了前 (Cloudinary の URL) でも、完了後 (S3 の署名付き URL) でも、常に適切な画像を表示できる