個人で初めて作ったwebサービスでの技術選択と調査の振り返り
今年1月に個人としては初めてwebサービスをリリースしまして、そいつはpinviteというやつです。機能としてはwebから入力フォームで文字入れて、ボタン押したら自分のツイッターアカウントからこんな画像つきツイートを投稿しますってだけです。本当にツイートを投稿するだけ。
(投稿例) gatsby.jsを使って弊社コーポレートサイトをPWA化します。そこでgatsby.js勉強会の講師をしてくれる人を募集! #pinvitehttps://t.co/ESwVir5ksY
— リチャード 伊真岡 (@RichardImaokaJP) January 13, 2019
構想ではもっと広がる夢があったけど、これだけの機能作るために構想開始から6ヶ月、実装開始から3ヶ月かかりました。プログラミング難しすぎるよ!マジ会社作って新規のウェブサービス作るやつとかバケモノだヨ!どこにそんな元気あるの…
せっかく作ったんだから採用した技術や行った調査のまとめをしておきたいなーと思っていたのでこの機会にやってしまいます。
UIはWebサービスで作る、モバイルアプリは作らない
私はモバイルアプリを作りたかったわけではなく、作る能力もなかったので「画像つきツイートをする」という単純な機能を、私としてはモバイル技術より馴染みのあったWebアプリとして作ることになりました。
バックエンドは運用の簡単さと安さからFirebase一択
バックエンドのインフラを運用したくなかったので、開発の初期段階でFirebaseで実装することを決めました。そしてFirebase安い。今もアプリケーションは走り続けていますが毎月2円の請求しか来ていません。
FirebaseにはFirebase AuthenticationというTwitter OAuth連携をほとんどやってくれる素晴らしいサービスがあります。個人開発でやるとき、ログイン周りはすっごくめんどうですからね。これがあるとありがたい。
画像生成部分はCloudinaryという画像ホスティング + 変換サービス
入力フォームからの情報をもとに画像生成する部分は、このサービスのコア機能です。先ほど載せたツイートの画像部分はオレンジ色の背景画像の上に文字をオーバーレイして画像にしています。
いろいろなクラウド画像変換サービスからCloudinaryを選びました。他にも似たような事ができるところはあるでしょうが、昔ちょっとだけ私が使ったことがあり、今回の文字オーバーレイや無料プランで賄えることなど要件を満たしていたのでCloudinaryでいくことに決定。
Cloudinaryではこのように非常に長いURLを作成して、前もって用意しておいた背景画像にオーバーレイする文字列やフォント、位置などを指定します。
pinviteでのコードはコレ(リンク)。CloudinaryのNode.js APIを使っていますが最終的にはこれで上で述べた形式のURLが得られます。詳しい文字オーバーレイ画像の作り方の情報はここに載っています。
https://cloudinary.com/documentation/image_transformations#adding_text_captions
フロントエンドはReact
今にして思えばReactとか使わなくてプレーンなHTML + テンプレートエンジンでもよかったかなという気がしています。
React楽しかったけど。本当に楽しかったよw
共同開発者のAkira YamamotoさんがReactが得意であり、私も3年ほど前にちょっとだけReactを触ったことがあり、私の安易な判断でReactを使うことにしました。
TypeScript マイグレーション
途中から共同開発社のAkira Yamamotoさんとノリと勢いで「TypeScript入れちゃいましょうか」「いいっすね入れちゃいましょう!」って言って2週間ほどかけてTypeScriptへの移行を果たしました。下記のガイドに従って、ファイル一つずつ拡張子を変え、中身を書き換えてtypeを導入し…という手順でした。JavaScriptとTypeScriptが共存できるのでマイグレーションがやりやすいです。
https://www.typescriptlang.org/docs/handbook/migrating-from-javascript.html
union typeとかあっておもしろいですね。そして最近ではいろんなライブラリがTypeScript対応していてすごいですね。勢いを感じます。他のライブラリのtype script APIを見ているだけでいろいろ勉強になりました。
Atomic Design
時代はAtomic Designだろ!ということでAtomic Design的な画面の作り方をしてみましたが、あんまりうまく実装することができませんでした。デザイン難しいなー。
「Templateはページに必要な全ての要素が詰まったワイヤフレーム」「Pageはワイヤフレームを埋める画像や文字が入ったもの」という点は意識しましたが、よくある「どれをAtomでMoleculeでOrganismにしよう」問題は実感がわかず終わりました。奥が深いね。
それから実装上の細かいやりかたとしてはコレ参考にしましたが、好みが分かれそうだなコレ。私は好きです。
https://alistapart.com/article/learning-from-lego-a-step-forward-in-modular-web-design
サーバーサイドレンダリングとの格闘
このサービスの実装における最大の障壁はサーバーサイドレンダリングでした。
ツイッターに画像つき投稿をするんですが、「画像添付ツイート」ではなく「Twitter Card」を使います。Twitter Cardはリンク先URLを含むツイートで、Twitterがリンク先WebページにあるOGPタグから情報を取得して、この記事の最初にあるツイートのような画像つきツイートを投稿することができます。Twitter Cardを使うことで、ただの画像ではなくリンクとしての機能を持つことができるのです。
コレを実現するため、一旦Webページを生成し、そのページが目的の画像をOGPタグの値としてもつ必要があります。そしてツイートするときは生成されたWebページへのリンクつきで投稿すると、Twitterカードとして展開され画像が見えるという仕組みです。(冒頭の図を再掲)
すなわち生成されるWebページが、ページごと(URLごと)に異なるOGPタグの値を保つ必要があります。そしてWebサーバーから返送されるHTMLの中ですでにOGPタグの値がURLごとに書き換えられていないといけないので、Reactの世界でいわれるサーバーサイドレンダリングが必要になります。
当初静的ホスティングであるFirebase HostingでWebサービスを作ろうと思っていましたが、動的処理を担当するFirebase Cloud Functionsを導入することになりました。
https://www.youtube.com/watch?v=82tZAPMHfT4
生Reactを使っていれば上の動画に従うだけでサーバーサイドレンダリングができたのですが…実はその時点で生Reactではなくgatsby.jsを使っていてそこそこの量のコードを書いてしまっていたので上の動画で説明されている方法が使えなかったのです。
そして…gatsbyでサーバーサイドレンダリングを行う仕組みを考えるのに2週間以上手を止めて考えることになりました。非常に紛らわしいことに、gatsbyにはすでにサーバーサイドレンダリングの仕組みがあるのですが、gatsbyの言うサーバーサイドレンダリングは世間一般のWeb開発者のいうサーバーサイドレンダリングではありません。
Gatsby produces optimized static content by invoking server-side APIs at build time.
世間一般でのサーバーサイドレンダリングは、HTTPリクエストがサーバーに到達したときにHTMLページが生成されることを指します(もちろん、同一URLに対するリクエストにはキャッシュされたページを返しますが)。
しかし、gatsbyのサーバーサイドレンダリングは「ビルド時」にHTMLファイルおよびjs, cssファイルなどのアセットを生成することを指しており、ビルド時にHTMLファイルが持つOGPタグの値が決定されます。これではURLごとに異なるOGPタグの値を実現することができません。つまり、pinviteの根幹に関わる機能を実現できないということです。
コレにはめちゃくちゃ困りました…技術的に正しい選択はgatsbyを捨てて、「本来の意味での」サーバーサイドレンダリングができるフレームワークに乗り換えることだったのですが、結構な量のコードをすでにgatsby前提で書いてしまっており「2週間方法を探してみて、ムリだったらgatsby捨てよう」ということにしました。そして…見つけてしまったのです、力技サーバーサイドレンダリングをw
方法としてはとても原始的なもので、単純な文字列置換です。OGPタグの値を「*|twitter:card|*
」のような置換しやすい文字としておき、その状態でgatsbyのビルドを完了させます。
<meta name="twitter:card" content="*|twitter:card|*">
...
...
<meta property="og:url" content="*|og:url|*">
<meta property="og:image" content="*|og:image|*">
ビルド後Webアプリケーションをデプロイしたら、HTTPリクエストはFirebase Cloud Functionsで処理されます。Firebase Cloud Functionsはgatsbyが生成したHTMLファイルを読み込み、URLに応じてそのHTMLファイルのOGPタグの値を文字列置換で書き換えます。
const html = usersHtml
.replace('*|twitter:card|*', ogpValues.twitterCard)
...
...
.replace('*|og:url|*', ogpValues.ogURL)
.replace('*|og:image|*', ogpValues.ogImage)
これによって、HTTPリクエストが来た時点で、URLによってHTMLの中身を変えるという本来の意味でのサーバーサイドレンダリングを実現することができました。そしてgatsbyを使って書いた既存のコードはそのまま活かすことができます。
この仕組を思いつくためにgatsbyのビルドパイプラインの説明をひたすら眺めることになり、「ここに処理を挟めばサーバーサイドレンダリング行けるんじゃ!?」「やっぱダメだったww」を繰り返し、知るつもりもなかったビルドの仕組みに詳しくなっちまいましたw
もう忘れたがね!!!
XSS脆弱性とそのパッチ
「文字列置換」ということで嫌な予感がしてデプロイ前に調べていたら、やっぱりXSS脆弱性を作ってしまっていました。つまり「置換すべきOGPタグの値のフリをして、悪意のあるコードをユーザーが差し込めば…」というやつです。直前に読んだ徳丸本のXSSの章が役立ちました。
うむ、ものの見事にXSS脆弱性をつくりだしてしまった。 pic.twitter.com/bhlFWMpzIH
— リチャード 伊真岡 (@RichardImaokaJP) January 3, 2019
OWASP XSS Cheat Seatを参考にXSS対策をして入力文字列のクリーンアップ、そして安全な文字だけがFirebase上に保存されるようにし、たとえ悪意のある人間が攻撃コードを埋め込もうとしても、無害化された上で不正のないOGPタグの値として出力されるように書き換えました。めでたし。
本当は信頼性のある入力値無害化ライブラリを使いたかったのですが、デファクトスタンダードみたいなjsのXSS対策ライブラリを探し当てることができず…なので古典的な方法で、自分で調べて対策な必要なケースを網羅するという方法を取りました。こういう事すると対策漏れが起きがちなので本当は他人の書いたツールを使いたかったんですけどね…
Twitter OAuthとの格闘
OAuthもかなり苦労しました。とにかく調査に時間がかかりました。いまだにOAuthよくわからんw
Twitter OAuthは「ツイッター投稿を肩代わりするアプリケーション」には必要ですが、TwitterのOAuthって1.0aなんですよ。世の中の他の多くのサービスはOAuth 2.0に移行しています。それでもFirebase Authenticationの力を借りればTwitter OAuth対応はそれほど苦もなくできるのですが、理解していないものを導入するわけにも行かず調査をすることに。
FirebaseでWebアプリケーションを作るときはSingle-Page Application構成を取ることはよくあり、client-side JavaScriptで全ての動的処理を済ませてしまう事が多いのですが、Twitter公式になんか恐ろしい文言が載っています。
https://developer.twitter.com/en/docs/developer-utilities/twitter-libraries.html
Reminder: It is strongly discouraged to use OAuth 1.0A with client-side Javascript.
「なんでじゃ?」と思って私が調査した限りで、なぜTwitter OAuth 1.0aをclient-side JavaScriptで使ってはいけないかを説明します。TwitterへOAuth連携でツイート投稿するばあい、「サーバー側API key/secret」と「クライアント側OAuth token/secret」両方が必要になります。つまりclient-side JavaScriptでOAuth連携ツイート投稿をしようとすると、クライアント側で「サーバー側API key/secret」と「クライアント側OAuth token/secret」両方のシークレットを持つことになり、それは「サーバー側API key/secret」がクライアント側に漏洩しているということです。
client-side JavaScriptでツイート投稿させるわけにはいかず、サーバー側の処理でツイートをする必要があるので、ここでもFirebase Cloud Functionsを利用することになりました。
そしてこのTwitterのツイート投稿用APIが独特でツラ…おもしろい。ツイートのたびにSignatureというやつを生成するんですけど、その生成の仕方が地獄のように複雑。
https://developer.twitter.com/en/docs/basics/authentication/guides/creating-a-signature
これを自前で実装するわけにはイカンということで以下のライブラリを使いました。最近はコミットが少ないけど、Twitter APIが枯れたからあまりすることがないのかな?とにかくちゃんと動いてくれました。ありがたや。
https://github.com/ttezel/twit
Twitter OAuthトークンどこに保存するんだ問題
さて、twitとFirebase Cloud Functionsの組み合わせによってTwitter OAuth連携でのツイート投稿はできるようになりましたが、まだ重要なセキュリティの問題が残っていました。secretの「保存」です
「クライアント側OAuth token/secret」はTwitter OAuth連携のフローを一巡すると得られるのですが、ツイート投稿の度に同じユーザーに毎回この画面を表示するわけにはいかないですよね。ユーザーにとってはめんどくさくてしょうがない。
初回OAuth連携後は「クライアント側OAuth token/secret」を保存しないと2回目のツイート投稿以降は「クライアント側OAuth token/secret」が取得できなくなってしまいます。そして厄介なことにFirebase Authentication側(サーバー側)では「クライアント側OAuth token/secret」を保存してくれません。
https://stackoverflow.com/questions/50176913/can-firebase-admin-sdk-retrieve-user-auth-tokens
つまりツイート投稿時に必要となる「クライアント側OAuth token/secret」は、クライアント側からサーバー側Firebase Cloud Functions側に飛ばしてあげないといけません。これを「クライアント側でOAuth token/secretを保存し、からツイートのたびに毎回サーバー側に飛ばすのか」と「サーバー側で一旦保存してクライアント側からは初回のみサーバー側に飛ばすのか」は悩みました。
クライアント側で保存し毎回サーバー側に飛ばす処理だと:
- secretをインターネットに晒す回数が増えるから危険じゃない?
- 実はFirebase側では内部的にlocalStorageを使ってOAuth token/secretを保存してるんだけど、それをライブラリ経由で取り出す方法はない
- 無理やりFirebaseで使っているlocalStorageからOAuth token/secretを引っ張り出すこともできるけど、それは実装依存なのでFirebaseのバージョンアップで動かなくなる
- そうなると自前でlocalStorageにOAuth token/secretを保存する仕組みを作る必要があって、でもlocalStorageはそもそもsecretを保存する場所じゃないし…(いやでもFirebaseは内部的にやってるじゃん。)
サーバー側で保存すると:
- いざ脆弱性が見つかってsecretが漏れたときに全ユーザのsecretが漏れる
という困った点がありました。結局こんかいはサーバー側でsecretを保存する手法を取りました。
DeployとCircleCI
デプロイの流れはちょっとこだわりました。なにが大事だったかと言うと私にはお金がない!!!お金が沢山あればGitHub Flowに書いてあるような「Gitコミットのたびに個別テスト環境をCircleCIで立ち上げ…」みたいなことをやりたいところですが、なにぶん個人開発なので金がありません。
CircleCIの無料枠内で収まるだけのテスト実行回数にしないといけません。Circle CIの無料プランは1000分/月。そしてgatsbyを利用したこのwebサービスは一回のビルドからテストまでに10分ほどかかります。つまり、100回コミットして100回CircleCIが走ったらその月はもう開発できません。サービス初期など一気に開発の進捗を出したいときにコレでは心もとない(いや課金しろよ…)
そこで以下のような仕組みを導入しました:
- stagingというブランチを作って、stagingに対するコミットは全てCircleCIを走らせる
- 全ての開発ブランチはstagingに対するPull Requestをあげる。直接開発ブランチからmasterにマージしない。
- masterへのコミットはstaging -> masterへのマージのみ
- gitの履歴を保つため、staging -> masterのマージはSquash Commitしない
最後の4点目については、自分で注意して「Squash Commitしない」というふうに気をつけないといけないので、あまり嬉しくありません。一方で、開発ブランチではSquash Commitは使いたいため、一律にGitHub上のルールでSquash Commitをはじくことはできません。なんかいい方法があれば良いんですけどね。
まとめ
ここまで。単なる羅列なのでまとめはありません。