概要
つぶやき(タイムライン上のノート)を投稿する際に、「今プレイしているゲーム」の情報を添付できる機能を追加した。管理画面からゲームタイトルを検索すると、カバー画像・公式サイト・各ストアへのリンクを含む引用カードをつぶやきの下部に表示できる。
背景と目的
これまでのつぶやき投稿では、テキストと画像しか添付できなかった。プレイしているゲームについて投稿する際、タイトルをテキストに書くだけでは情報が伝わりにくかった。ゲーム情報を構造化して表示することで、閲覧者がそのまま公式サイトやストアにアクセスできるようになる。
実装内容
1. IGDB APIを使ったゲーム検索エンドポイント
外部ゲームデータベースの IGDB(Internet Game Database)は、Twitch が提供する API を通じて利用できる。Client Credentials フローで取得したアクセストークンを使い、ゲームタイトルを検索してカバー画像・公式サイト・外部ストアリンクを取得している。
API エンドポイントには以下のセキュリティ対策を施した:
- セッション検証(未認証のリクエストは 401 を返す)
- 検索キーワードの長さ制限(200文字以内)
- 取得した URL の
https?://スキーム検証 - 各フィールドの長さ制限とホワイトリスト絞り込み
2. 管理画面のゲーム検索UI
投稿フォームのツールバーにゲーム情報追加ボタンを設置した。ボタンを押すとアコーディオン形式でスライドアップするUIが表示される。
検索窓にタイトルを入力すると 300ms のデバウンスを経て非同期で検索 API が呼ばれ、結果がリスト表示される。一度選択したゲームは localStorage に最大5件の履歴として保存され、次回以降は検索なしで素早く選択できる。
ゲームを選択すると、テキストエリアの直下にプレビューカードが表示される。
3. データベースへの保存
投稿時に選択中のゲーム情報を JSON 文字列としてフォームに付加し、バックエンド側で安全にパース・バリデーションして投稿テーブルの game_info カラムに保存する。
保存前のバリデーション:
- JSON のパース失敗は無視(game_info を null として保存)
nameフィールドが文字列であることを確認- URL は
https?://スキームのみ許可 - 各文字列フィールドの最大長を制限
- ストアリンクは最大 20 件に制限
スキーマ変更は ALTER TABLE で柔軟に対応し、未移行環境でもエラーが起きないようにしている。
4. タイムラインでの引用カード表示
投稿の表示コンポーネント側でも JSON を安全にパースし、ゲーム情報が存在する場合はつぶやき本文の下にカードを表示する。
カード内には:
- カバー画像(縦長のゲームパッケージ画像)
- 「プレイしているゲーム」ラベルとゲームタイトル
- 公式サイトリンク
- Steam、Epic Games、Nintendo など各ストアへのリンクボタン
レイアウトは彩度を抑えたダークトーンで統一し、他のタイムラインエントリと視覚的に調和するデザインにした。
セキュリティ上の考慮点
XSS 対策
ゲーム情報に含まれる URL は、バックエンド保存時とフロントエンド表示時の両方で https?:// スキームのみ許可するホワイトリスト検証を行っている。Astro のデフォルトのエスケープ機能と組み合わせることで、外部由来のデータが HTML として解釈されることを防いでいる。
認証
ゲーム検索エンドポイントはセッション Cookie を検証するため、未認証のユーザーはゲーム検索を利用できない。これにより、IGDB API の認証情報(Twitch Client ID・Client Secret)が間接的に悪用されるリスクを低減している。
環境変数管理
IGDB_CLIENT_ID と IGDB_CLIENT_SECRET は Cloudflare Workers の環境変数として管理する。これらがコードや設定ファイルにハードコードされることはない。
技術的な学び
IGDB の API は Apicalypse というクエリ言語を使う。SQL に似た構文でフィールドの選択・フィルタ・ソートを POST body で送る形式で、慣れると直感的に扱える。
ゲームのカバー画像は Cloudinary ベースの CDN から配信されており、URL の t_cover_big 部分を変えることでサイズを選択できる。今回は t_cover_big(264×374px)を使用した。
アコーディオン表示は CSS の max-height トランジションで実装した。height: auto にアニメーションを適用する標準的な方法はなく、max-height を十分大きな固定値と 0 の間で遷移させる手法を採用している。
localStorage を使った検索履歴は、プライベートモードなどで使用できない場合を try-catch で吸収し、機能が壊れないようにした。