旧バージョンのアプリを止めずにレガシーPHP APIを整理する

APIフォルダ8つ、PHP 102ファイル、深さの違う4種類のinclude、強制アップデートできないアプリ。内部リライトとレスポンス比較で /api/v1 に移した手順。

モバイルアプリがあると、サーバーの整理は一気に難しくなります。Webページなら好きなときにURLを変えられますが、2年前にインストールされたアプリは、ビルドされたときのパスを今日も呼び続けます。そしてユーザー全員にアップデートを強制することはできません。

私たちの共有サーバーのドキュメントルートには、/Lotto/json/、/Takarakuji/json/、/MegaMillions/json/ など8つのAPIフォルダが並んでいました。それぞれがデータベースの接続情報のコピーを持ち、8本のFlutterアプリに応答していました。目標は、これを /api/v1/<アプリ>/ という1本のツリーにまとめ、接続情報をWeb公開領域の外へ出すことです。そのうえで、どのバージョンのアプリから来る旧URLも動き続けなければなりません。以下は2026年11月8日の作業記録と、そのとき実測した数値です。

0. 「動いている」を先に書き出す

何かを動かす前に、現行アプリのコードを読み、アプリが呼ぶURLをすべてYAMLに書き出しました。合計95件です。末尾スラッシュなしのディレクトリ型URL、JSONボディを送るPOST、日本語のクエリ値(priDay=今日)といった癖のあるものも含みます。

小さなスクリプトで各URLをリダイレクトを追わずにリクエストし、ステータス、Content-Type、Location、本文のハッシュ、JSONのキー構造を保存しました。

200    28143  GET /starUnse/json/list.php?priDay=今日
301      795  GET /Lotto/json/latest
200     2579  GET /Lotto/json/grid.php?limit=30
301      795  GET /Takarakuji/json/lotto6/detail/section1
301      795  POST /Takarakuji/json/lotto6
...
saved: data/api_baseline/20261108-220202

保存直後に同じものと比較して差分0だったので、比較結果は信頼できると判断しました。AI予想や人気ランキングのように毎回本文が変わるものは、ステータス・型・JSON構造だけを比べます。

このベースラインの時点で、変更前から存在する問題が2つ見つかりました。

  • 32件の呼び出しが最初に301を経由していた。 アプリは /Lotto/json/latest を呼びますが、latest はディレクトリなので、サーバーは 301 → /Lotto/json/latest/ を返します。GETならリダイレクト後に結果が届きますが、毎回1往復多くかかります。
  • POSTが1つ、そもそも動いていなかった。 POST /Takarakuji/json/lotto6 は301を受け、その先はディレクトリ一覧禁止で403でした。DartのHttpClientはGETとHEADしかリダイレクトを追わないので、アプリではただのエラーです。誰も気づいていませんでした。

1. 深さの違うincludeを1つのブートストラップに

各エンドポイントは、ファイルの置き場所によって深さの違う相対パスでDB設定を読み込んでいました。

include行 ファイル数
require_once('../lib/AppConfig.php'); 37
require_once('../../lib/AppConfig.php'); 4
require_once('../../../lib/AppConfig.php'); 19
require_once('../../../../lib/AppConfig.php'); 41
require_once(__DIR__ . '/../lib/AppConfig.php'); 1

フォルダを動かせば、これらはすべて壊れます。サーバー上の AppConfig.php 10個のハッシュを比べるとすべて同一だったので、1つに集約できると判断しました。集約した1つはWeb公開領域の外の ~/tlabo_private/ に置き、各エンドポイントはブートストラップを1つ読むだけにしました。

<?php
// public_html/api/v1/_bootstrap.php
define('TLABO_PRIVATE', dirname(__DIR__, 5) . '/tlabo_private');
require_once TLABO_PRIVATE . '/AppConfig.php';
require_once TLABO_PRIVATE . '/clsMaria.php';
// どのファイルが応答したかの証明。本文は変えない。アプリは無視する
if (!headers_sent()) { header('X-Tlabo-Api: v1'); }

変換スクリプトは、各フォルダの json/ を丸ごとコピーします。現行アプリが使うファイルだけでなく、旧バージョンが呼ぶかもしれないファイルも残すためです。そのうえで require_once の2行だけを次の1行に置き換えます。

require_once($_SERVER['DOCUMENT_ROOT'] . '/api/v1/_bootstrap.php');

lib/ を参照する require/include が1つでも残れば停止し、全ファイルに php -l をかけます。最初の実行は HorseRace/json/api.php で止まりました。原因はinclude文ではなく、lib/AppConfig.php に触れたコメントでした。そこで、検査の対象をinclude文だけに絞りました。

2. アプリが触れる前に新パスを試す

新しいツリーは旧フォルダの隣にアップロードしただけで、アプリは引き続き旧フォルダを使っています。比較ツールはパスの先頭を読み替えられるので、各アプリのエンドポイントを新しい場所に送り、最初のベースラインと比べました。

python scripts/api_snapshot.py compare --app kr_lottery --map /Lotto/json=/api/v1/lotto

8アプリすべて差分0でした。

3. 新ファイルが「ある時だけ」リライトする

ルートの .htaccess は旧プレフィックスを新ツリーへ割り当てますが、移動先のファイルが実在する場合に限ります。なければルールは発動せず、これまでどおり旧フォルダが応答します。壊れていたPOSTもそのまま残るので、今回の変更でその挙動が変わることはありません。

# kr_lottery
RewriteCond %{DOCUMENT_ROOT}/api/v1/lotto/$1/index.php -f
RewriteRule ^Lotto/json/(.*?)/?$ api/v1/lotto/$1/index.php [L,QSA]
RewriteCond %{DOCUMENT_ROOT}/api/v1/lotto/$1 -f
RewriteRule ^Lotto/json/(.+)$ api/v1/lotto/$1 [L,QSA]

ディレクトリ型のリクエストを直接 index.php に向けたことで、/Lotto/json/latest は301を挟まず200を返すようになりました。本番ルートに置く前に、同じサーバーの使い捨てサブフォルダでルールを試しています。そこではディレクトリ型パスへのPOSTがリライト経由で200になりました。Dartがリダイレクトでは処理できなかったケースそのものです。

4. 切り替えて、証明する

最初の1本は単独で切り替えました。本物のSQLインジェクションを抱えていたのがこのアプリです。list.php は $_GET['priDay'] をそのままSQLに連結していました。修正には、プリペアドステートメントではなく mysqli_real_escape_string を使いました。mysqlnd環境ではプリペアドステートメントが整数カラムをPHPの整数で返すため、json_encode の出力が "12" から 12 に変わります。旧バージョンのアプリが受け付けない可能性のある仕様変更です。エスケープなら出力はバイト単位で同じままです。

HostingerのWAFは典型的な攻撃文字列(' OR '1'='1)をPHPに届く前に「Forbidden」で止めたので、シングルクォート1つで確かめました。

/api/v1/starunsejp/list.php?priDay=今日'   →  []  [200]

修正前のファイルならSQLエラーになるところで、修正後はエスケープされて結果が空になるだけです。この反応を返すのは修正版だけなので、切り替え後に旧URLへ同じリクエストを送ることで、リライトが効いていることも証明できました。

残り7アプリは23分後、.htaccess の1回の変更でまとめて切り替えました。

チェック 結果
アプリに直結するURL(広告設定、ads.txt、app-ads.txt、API) 8/8
旧パスでのAPI回帰比較(全93エンドポイント) 差分0
URL全体のマトリクス(ストアURL、リダイレクト、ページ) 53/53

CDNにだまされかけた

その後、証明ヘッダーの確認で4つのURLが失敗しました。X-Tlabo-Api ヘッダーがなく、以前の301が残っています。理由はレスポンスヘッダーの x-hcdn-cache-status: HIT でした。HostingerのCDNがAPIのレスポンスまでキャッシュしていて、切り替え前の301も保持していたのです。一意のクエリを付けると、4つともサーバーから直接200が返り、ヘッダーもあり、リダイレクトもありませんでした。

/Lotto/json/latest   cache: HIT  | bypass: HTTP/2 200 x-tlabo-api: v1 DYNAMIC

これは移行前から続いていた状態です。データを更新しても、キャッシュが切れるまでアプリに届かない時間があるということで、それ自体知っておく価値があります。

次もやること

  1. 叩かれるURLの一覧は、サーバーのフォルダではなくアプリのコードから作る
  2. ベースラインはリダイレクトを追わずに、リダイレクトそのものを記録する
  3. 相対includeは1つのブートストラップに置き換え、秘密情報は公開領域の外へ出す
  4. リライトは「新ファイルがある時だけ」にして、想定外のケースは旧挙動に戻す
  5. どのコードが応答したかを示すヘッダーを付け、検証は必ずキャッシュ回避付きで行う

旧フォルダは、古いバージョンのアプリだけが呼ぶ何かがログに現れないかを見るため、2週間サーバーに残します。その後、公開領域の外へ移します。

よくある質問

旧APIパスを新パスへ301リダイレクトすればよいのでは?

モバイルのHTTPクライアントはPOSTのリダイレクトを追わないことが多いです。DartのHttpClientはGETとHEADしか自動で追わないため、POSTのエンドポイントに301を返すとアプリではエラーになります。内部リライトなら旧URLのまま200を返せます。

新しいファイルが応答していることをどう証明する?

新しいコードに無害なレスポンスヘッダー(今回は X-Tlabo-Api: v1)を出させ、旧URLにキャッシュ回避のクエリを付けて確認します。本文はバイト単位で同じなのでアプリには影響しません。

SQLインジェクション対策でプリペアドステートメントを使わなかった理由は?

mysqlnd環境ではプリペアドステートメントが整数・小数を数値型で返し、query()は文字列で返します。切り替えるとJSONの数値が "12" から 12 に変わり、旧バージョンのアプリが読めなくなる恐れがありました。エスケープなら出力を変えずに塞げます。

  • #PHP
  • #htaccess
  • #API
  • #モバイルアプリ
  • #Flutter
  • #回帰テスト

t.laboratory

実際に作って運用しているアプリ・サーバー・サービスで起きた問題と、その解決に使ったコードを記録する技術ブログです。

運営者情報 · アプリ

↑