タケユー・ウェブ日報

Ruby on Rails や Flutter といったWeb・モバイルアプリ技術を武器にお客様のビジネス立ち上げを支援する、タケユー・ウェブ株式会社の技術ブログです。

Rails 6.1 の rails_storage_proxy_url でActiveStorage のリダイレクトURL問題を解決する

f:id:uzuki05:20151102155000j:plain

Rails 6.1 の新機能 rails_storage_proxy_url を使うと、ActiveStorage で添付したファイルへのリンクが署名付きURLへのリダイレクトにならず、RailsアプリのURLのままファイルをダウンロードできるようになります。

どういうこと?

ActiveStorageはこれまで、S3をバックエンドとして使った場合、S3への署名付きURL=タイムスタンプなどが付与されたURLへのリダイレクトを行ってきました。 しかしこれは扱いづらいことも少なくなく、悩みの種の1つでした。

Rails 6.1 でこの問題に対する回答が(ようやく)公式に用意されたことになります。

続きを読む

ActiveStorageのダイレクトアップロードを付属のJavaScriptライブラリ以外で使う

たとえば、graphqlなどアップロード機能を提供したいとき、

  • ダイレクトアップロード用の ActiveStorage::Blob とURL等を生成
  • 結果を受け取ってレコードにファイルを添付する

をMutationで実装したいことがあります。

  1. クライアントはアップロードしようとするファイルのMD5チェックサムとファイルサイズ、MIMEタイプを1のAPIサーバに送信します。
  2. APIサーバは、受け取ったパラメータからアップロード用の情報を返します。
  3. クライアントは受け取った情報を使ってクラウドストレージへダイレクトアップロードします。アップロードが終わったらAPIサーバにアップロードしたファイルを特定できる情報を送ります。
  4. APIサーバは対象のファイルを特定しレコードに紐付けます。

試したバージョン: Rails 6.1

1. ファイルの情報を取得する

Rubyによるサンプルコードです。 実際にはクライアントで実装することになるので、JavaだったりSwiftだったりします。 ファイルの情報を何らかの方法でサーバに送信します。

file_path = "/src/spec/fixtures/files/male.jpg"
file_data = File.read(file_path)
byte_size = file_data.bytes.size # => 57937
checksum = Base64.strict_encode64(Digest::MD5.digest(file_data)) # => "0Nq1WCcyKbbw4wipYw1xag=="
content_type = Mime[File.extname(file_path).split('.').last].to_s # => "image/jpeg"

2. サーバでダイレクトアップロード用の情報を生成する

create_before_direct_upload!ActiveStorage::Blob レコードを作成します。 ActiveStorage::Blob#service_url_for_direct_upload および ActiveStorage::Blob#service_headers_for_direct_upload でダイレクトアップロードに使用するURLとHTTPヘッダーを生成することができます。

blob = ActiveStorage::Blob.create_before_direct_upload!(
  filename: "file.jpg",
  byte_size: byte_size,
  checksum: checksum,
  content_type: content_type,
  service_name: "amazon"  # 今回はS3を使う
)

# アップロード用のURLとHTTPヘッダーを生成
blob_signed_id = blob.signed_id # アップロード後の紐付けに使う署名付きURL
direct_upload_url =  blob.service_url_for_direct_upload
direct_upload_headers = blob.service_headers_for_direct_upload

アップロード用の情報には、MD5チェックサムMIMEタイプなどのデジタル署名が含まれ、一致しないファイルのアップロードを防ぐことができます。 なので、サーバ側で事前にファイルサイズやMIMEタイプなどをみてアップロード許可を出すこともできます。

pp direct_upload_url 
# => "https://your_bucket_name.s3.ap-northeast-1.amazonaws.com/b6msshsvihnanisrlfwgpab9miqk?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AK000000000000000000%2F20201219%2Fap-northeast-1%2Fs3%2Faws4_request&X-Amz-Date=20201219T021918Z&X-Amz-Expires=300&X-Amz-SignedHeaders=content-length%3Bcontent-md5%3Bcontent-type%3Bhost&X-Amz-Signature=c43651b09080ed543b1deb2f0baa8971251f06ff52360f8d31a1da7d6bc992ee"
pp direct_upload_headers 
# => {"Content-Type"=>"image/jpeg",
# "Content-MD5"=>"0Nq1WCcyKbbw4wipYw1xag==",
# "Content-Disposition"=>
 # "inline; filename=\"file.jpg\"; filename*=UTF-8''file.jpg"}
# config/storage.yml
amazon:
  service: S3
  access_key_id: <%= Rails.application.credentials.dig(:aws, :access_key_id) %>
  secret_access_key: <%= Rails.application.credentials.dig(:aws, :secret_access_key) %>
  region: <%= Rails.application.credentials.dig(:aws, :region) %>
  bucket: your_bucket_name 

3. クラウドストレージへダイレクトアップロード

Rubyによるサンプルコード。 ファイルのバイナリデータを、受け取ったURLあてに、受け取ったHTTPヘッダーと共に送ります。 一点注意が必要なのが Content-Length ヘッダーでHTTPクライアントによっては自動でつけてくれないんので、その場合は送信するバイナリのサイズ(最初にサーバに伝えたバイト数)を自分で付けないと SignatureDoesNotMatch になります。

url = URI.parse(direct_upload_url)
req = Net::HTTP::Put.new(url.request_uri)
req.initialize_http_header(direct_upload_headers)
req.body = file_data  # RubyのHTTP::Requestクラスはこれで content-length もつけてくれる
req.each_header { |k,v| p "#{k}=#{v}" }
# => "content-type=image/jpeg"
# "content-md5=0Nq1WCcyKbbw4wipYw1xag=="
# "content-disposition=inline; filename=\"file.jpg\"; filename*=UTF-8''file.jpg"
# "connection=close"
# "host=your_bucket_name .s3.ap-northeast-1.amazonaws.com"
# "content-length=57937"

http = Net::HTTP.new(url.host, url.port)
http.use_ssl = true
res = http.request(req)
# => #<Net::HTTPOK 200 OK readbody=true>

4. サーバでファイルを特定しレコードに紐付ける

class User < ApplicationRecord
  has_one_attached :photo
end

blob = ActiveStorage::Blob.find_signed!(blob_signed_id)
User.find(user_id).photo.attach(blob)

AWS SDK for Ruby でアップロード用の署名付きURLを生成する方法

s3_resource = Aws::S3::Resource.new
object = s3_resource.bucket(BUCKET_NAME).object(OBJECT_KEY)
url = URI.parse(object.presigned_url(:put))

ちなみにcurl コマンドでアップロードするには

$ curl -i -X PUT --upload-file README.md "<PRESIGNED_URL>"

HTTP/1.1 200 OK
x-amz-id-2: FdLlF7a48jiq8Q7mM/VdAe8ZmcNfCcSKTIry5/LEZNUhizUb1+ALaNNoWjB/ZRLpD+ZtnUDqjlA=
x-amz-request-id: 61D6A2090B5A471E
Date: Mon, 07 Dec 2020 04:37:30 GMT
x-amz-version-id: DQ_QAWEZz600VtEtZJdGvZqZdiT4NzBS
ETag: "0cfbd649b94d4dc64447b516ec65f1a9"
Content-Length: 0
Server: AmazonS3

Adobe XD + VSCode + Flutter でデザイントークンを開発者に共有する

先日VSCodeAdobe XDプラグインが公開され、Adobe XDで作成したプロトタイプをコーディングに利用することが可能になりました。 利用までの手順と、実際に何ができて何ができないのか?試してみたので記録しておきます。

coliss.com

letsxd.com

Adobe XD + VSCode でできること

Adobe XD + VSCode でできないこと

続きを読む

RubyでFirebase Authenticationトークンを検証するサンプル

認証をFirebase Authenticationに丸投げし、そちらで作ったトークンをサーバーに送信、認可を行う時に有効

FIREBASE_PROJECT_ID = "FIREBASEPROJECTID"
CIRTIFICATE_URL = 'https://www.googleapis.com/robot/v1/metadata/x509/securetoken@system.gserviceaccount.com'
EXP_LEEWAY = 30.seconds
VALID_ISS = "https://securetoken.google.com/#{FIREBASE_PROJECT_ID}"

CERTIFICATE_MAP = JSON.parse(Net::HTTP.get_response(URI.parse(CIRTIFICATE_URL)).body)

TokenVerifyFailed = Class.new(StandardError)
InvalidAuthTime = Class.new(TokenVerifyFailed)

def from_firebase(id_token)
  # https://firebase.google.com/docs/auth/admin/verify-id-tokens?hl=ja
  a, decoded_token_header = JWT.decode(id_token, nil, false)

  uri = URI.parse(CIRTIFICATE_URL)
  certificate = CERTIFICATE_MAP.fetch(decoded_token_header["kid"])

  public_key = OpenSSL::X509::Certificate.new(certificate).public_key
  decoded_token_payload, _ = JWT.decode(
    id_token,
    public_key,
    true,
    exp_leeway: EXP_LEEWAY,   # 有効期限の検証をするが、ゆるめに。 EXP_LEEWAY 秒は大目に見る。
    verify_iat: true,         # 発行時の検証をする
    aud: FIREBASE_PROJECT_ID,
    verify_aud: true,         # 対象の検証をする
    iss: VALID_ISS,
    verify_iss: true,         # 発行元の検証をする
    verify_sub: true,         # 件名の存在を検証する
    algorithm: decoded_token_header["alg"]
  )

  raise InvalidAuthTime.new('Invalid auth_time') unless Time.zone.at(decoded_token_payload['auth_time']).past?

  # decoded_token_payload = {
  #   "name"=>"Takeuchi Yuichi",
  #   "picture"=>"https://lh3.googleusercontent.com/a-/AAuE7mAZU7Rh7lIFStzfWGe3tC24qDIX4UIoEWR8426flA",
  #   "iss"=>"https://securetoken.google.com/rails-firebase-sample",
  #   "aud"=>"rails-firebase-sample",
  #   "auth_time"=>1580712233,
  #   "user_id"=>"Qgk3sd1HgoPLVbSy8uXAWnRmWmx1",
  #   "sub"=>"Qgk3sd1HgoPLVbSy8uXAWnRmWmx1",
  #   "iat"=>1580712233,
  #   "exp"=>1580715833,
  #   "email"=>"yuichi.takeuchi@takeyuweb.co.jp",
  #   "email_verified"=>true,
  #   "firebase"=>{
  #     "identities"=>{
  #       "google.com"=>["100008179958237311525"],
  #       "email"=>["yuichi.takeuchi@takeyuweb.co.jp"]
  #     },
  #     "sign_in_provider"=>"google.com"
  #   }
  # }

  decoded_token_payload
end

Railsにおける利用例

例えばこんな感じで、

  • Bearer トークンとして受け取って、対応する User モデルを作る/返す
  • セッションに記録されたものがあればそれを使う

みたいなことができる

  def current_user
    return @current_user if defined?(@current_user)

    # fallback
    if session[:authorization].present? && request.headers['Authorization'].blank?
      request.headers['Authorization'] = "Bearer #{session[:authorization]}";
    end

    user = authenticate_with_http_token do |id_token, options|
      User.from_firebase(id_token)
    rescue JWT::DecodeError, User::TokenVerifyFailed => e
      Rails.logger.error(e)
      nil
    end
    if user&.persisted?
      @current_user = user
    else
      @current_user = nil
    end
  end

graphql-ruby + Multiple Databases with ActiveRecord

Query では読み込み専用 、Mutation では読み書きを使うようにしたかった。

github.com

上記のコメントで動くのだが、テストでトランザクションを使ったロールバック戦略を採用していると、レプリカに変更が伝搬されずに失敗する点に注意。(関連:Rails 6.0の複数DBでリードレプリカのテストするのたぶん大変

module Tracers
  class DatabaseRoleTracer
    EVENT_NAME = 'execute_multiplex'.freeze

    def trace(event, data)
      # テストではテストケースごとにトランザクションをロールバックするためコミットされずレプリカに伝搬しない
      # ので、やむなくテストモードでは無効=プライマリに繋ぐ
      if event == EVENT_NAME && !Rails.env.test?
        multiplex = data[:multiplex]

        role = multiplex.queries.all?(&:query?) ?
          ActiveRecord::Base.reading_role :
          ActiveRecord::Base.writing_role

        Rails.logger.debug("[#{self.class.name}] ActiveRecord::Base.connected_to(role: #{role.inspect})")
        ActiveRecord::Base.connected_to(role: role) do
          yield
        end
      else
        yield
      end
    end
  end
end

class MyAppSchema < GraphQL::Schema
  mutation(Types::MutationType)
  query(Types::QueryType)

  # 省略

  # Query か Mutation かで接続先データベースを変更する
  tracer Tracers::DatabaseRoleTracer.new
end

railsguides.jp

graphql-ruby.org