QiitaとKobitoで画像アップロードができるようになりました。
その後ろ側をちょっぴり公開します。
件名からも分かるように、背後ではAWSのS3を画像ストレージに採用しています。
用語統一
サーバ はQiitaのサーバのことを指すことにします。(つまり、S3ではない、ということ)
また クライアント は各ユーザのブラウザのことを指します。
要件
画像アップロード機能を実装するにあたっていくつかの要求がありました。
- 成りすましを防げる
- アップロードされたファイルを管理できる
- 自分達のサーバに負荷をかけたくない
- 変な画像のアップロードを阻止したい
QiitaユーザがQiitaないしQiita:Teamへ記事を投稿するために提供する機能なので、例えば他のサービスからのアップロードなど、意図しない用途での使用を防げなければなりません。
またコスト的な問題から容量無制限で機能を提供することはできません。画像以外のファイルや大き過ぎるファイル、過剰なアップロードを排除する仕組みが必要です。
画像をアップロードする際に自前のサーバに中継させることもできますがサーバに負荷がかかるので、クライアントが直接S3へアップロードしたいです。
画像アップロード機能を実装するなら、だいたいどこでも同じような要求になるのではないでしょうか?
S3で解決できた要求
成りすましを防げる
S3にファイルをアップロードするにはサーバが発行するポリシーとシグニチャが必要です。そしてこれを適切に発行するにはAWSのシークレットキーが必須なので、(シークレットキーが外部に流出しない限りは)サーバを経由せずに画像をアップロードすることは実質的に不可能です。
サーバではユーザの認証やファイルの簡単なチェックを行うことができます。ポリシーを厳格に定めることでユーザによるファイル偽装も(ある程度は)弾くこともできます。
アップロードされたファイルを管理できる
画像アップロードするにはサーバを経由しなければならないので、サーバ側でアップロードを許可したユーザとそのパスを紐付けて覚えておけば、S3上にアップロードされたファイルが何時誰によってアップロードされたのかを管理することができます。
自分達のサーバに負荷をかけたくない
ブラウザからS3へ直接ファイルをアップロードすることが可能です。
S3で解決できない要求
変な画像のアップロードを阻止したい
残念ながら今回の構成では画像の中身までチェックすることはできません。
取れる対策としては定期的にクローラを走らせて中身を見るなどですがコスト的に見合わないので、
- 規約で縛る(もともとQiitaは技術情報とは無関係の投稿は禁止されてます)
- 万が一変な画像をアップロードされても直ぐに削除できて誰が投稿したかが分かるようにしておく
つまり運用でカバーする、という感じです。(他のメジャーなサービスもだいたい同じですね)
概略図
Qiitaの画像アップロード機能の概略は次の画像のような感じになっています。
-
選択されたファイルの情報をサーバに送る
このときサーバには画像の種類と大きさだけを送るので、負荷はかからない。
-
サーバがクライアントの認証及びS3へアクセスするためのトークンを発行する
アップロードに必要な、複製不可能な情報(ポリシーとシグニチャ)をその場で作ってクライアントに返す。
-
サーバからもらったポリシーとシグニチャと一緒に選択された画像ファイルをS3に送る
サーバから返された情報を元に画像をS3に直接アップロード。その際S3はユーザのポリシーが有効なものであるか確かめて、ポリシーに違反していなければ画像を受け取る。
-
アップロードされたファイルのURLを挿入する。
コード
サーバ
上の画像でいうところの (2) のRubyでのサンプル実装です。
policy_document
がポリシーの実態で、ここで色々な条件を指定することができます。ここでは画像形式と画像サイズの改ざんをチェックする設定だけを抜き出しています。その他に設定可能な項目は色々あります。詳しくは公式ドキュメントを参照してください。
S3_BUCKET = 'xxx'
AWS_SECRET_KEY = 'xxx'
AWS_ACCESS_KEY_ID = 'xxx'
# アクセスしてきたクライアントにS3へアップロードするためのpolicyと
# それをハッシュ化したsignatureを返す。
def policies
# 0. セッションの確認など
# 1. 画像情報をバリデーションする
# ファイルサイズは大き過ぎないか、種類は適切か、など。
# 2. policyを作る
# policyで許可した内容しかクライアントはアップロードできなくなる。
key = '/path/to/file' # アップロード先のパス
policy_document = {
expiration: (Time.now + 1.minute).utc, # このポリシーは1分間のみ有効
conditions: [
# アップロード先のS3バケットを指定する
{ bucket: S3_BUCKET },
# ファイルのS3上でのパスを指定する。
# ワイルドカードも指定できるが、完全一致させておく。
{ key: key },
# オプション。クライアントから送られてきたものそのままにする。
{ 'Content-Type' => params[:content_type] },
# オプション。アップロード可能なファイルのサイズを範囲で指定できる。
# クライアントから送られてきたファイルサイズをそのまま指定することで
# 1バイトでも大きさの異なるファイルは拒否できる。
['content-length-range', params[:size], params[:size]]
]
}.to_json
policy = Base64.encode64(policy_document).gsub("\n", '')
# 3. signatureを作る
# AWSのシークレットキーとpolicyからsignatureを作る。
# signatureはシークレットキーを知っている人しか計算できないので
# クライアントがpolicyを改ざんしてもAmazon側がそれを検知できる。
signature = Base64.encode64(
OpenSSL::HMAC.digest(
OpenSSL::Digest::Digest.new('sha1'),
AWS_SECRET_KEY, policy)).gsub("\n", '')
# 4. アップロードに必要な情報をクライアントに返す
return {
url: "https://#{S3_BUCKET}.s3.amazonaws.com/",
form: {
'AWSAccessKeyId' => AWS_ACCESS_KEY_ID,
signature: signature,
policy: policy,
key: key
}
}
end
content-length-range
を範囲指定しないことで、例えばサーバには10KBとして申請しておいて、実際は1MBの画像をS3にアップロードするなどの偽装を行えないようにしています。Qiitaではアップロード制限をかけているので、このような実装にしました。
クライアント
var fileInput = $('input[type="file"]');
fileInput.on('change', function (e) {
var file = e.target.files[0];
// 0. 事前にファイルの大きさや種類をチェックする
// 1. サーバからpolicyとsignatureをもらう
// 上図でいう(1)に対応
$.ajax({
url: '/policies',
type: 'POST'
dataType: 'json'
data: {
// サーバにこれからアップロードするファイルの情報を渡す。
size: file.size, // ファイルの大きさ
content_type: file.type // ファイルの形式
}
}).done(function (data) {
// 2. サーバが返した情報をそのまま使ってFormDataを作る
var name, fd = new FormData();
for (name in data.form) if (data.form.hasOwnProperty(name)) {
fd.append(name, data.form[name]);
}
fd.append('file', file); // ファイルも忘れずに添付する
// 送信
// 上図でいう(3)に対応
var xhr = new XMLHttpRequest();
xhr.open('POST', data.url, true)
xhr.send(fd);
})
});
ここで味噌なのはサーバから返したデータをそのままコピペしている点です。画像アップロードはKobitoからも行えるようになっていますが、Webサービスとして提供しているQiitaとは異なり、デスクトップアプリケーションであるKobitoは何か問題があっても直ぐにはコードを変更できません。ここでやっているようにすることで、将来的にS3の仕様が万が一変更になっても、サーバ側で対応するだけで仕様変更に対応することができて嬉しいですね。