ページ

2026年7月28日火曜日

PHPブログに「途中まで無料」の有料記事を自作する — PayPal APIとオンチェーン検証の二刀流

軽量PHPブログエンジン Bludit で運用しているブログに、有料記事機能を自作しました。noteのように「途中まで無料で読めて、続きから有料」になるパターンです。決済は、法定通貨(PayPal 200円)と独自トークン(ERC20を20,000枚)の2レールを用意しました。

外部のペイウォールSaaSを使わず、PHPファイル3つとJSONファイル1つで完結しています。この記事では、その設計と、実装で踏んだ罠を解説します。

全体設計

構成要素は次の4つだけです。

  • テーマ改修: 本文をマーカーで分割し、未購入者には無料部分だけ出す
  • 決済API (paywall.php): 購入の記録・検証・解錠
  • 共通ライブラリ (lib.php): 購入台帳・Cookie・PayPal照合・オンチェーン照合
  • 購入台帳: JSONファイル(.htaccessで外部読み取り拒否)

データベースは使いません。低頻度の購入記録なら、flockで保護したJSONファイルで十分です。

1. 「途中まで無料」はサーバーサイドで分割する

記事本文に HTMLコメントのマーカーを1行入れます。

(ここまで無料の本文)

<!--paywall-->

(ここから有料の本文)

テーマ側(記事テンプレート)で、このマーカーを境に分割します。

$parts = preg_split('/<!--\s*paywall\s*-->/i', $page->content(), 2);
if (count($parts) === 2 && !pw_is_unlocked($page->key())) {
    echo $parts[0];               // 無料部分だけ出力
    include 'paywall-box.php';    // 決済UI
} else {
    echo $page->content();        // 購入済み or マーカー無しは全文
}

重要なのは、未購入者へのレスポンスHTMLに有料部分を1バイトも含めないことです。CSSで隠すだけの実装(display:noneやぼかし)は、ソース表示で全文読めてしまいます。分割は必ずサーバーサイドで行います。

2. PayPal: ブラウザの自己申告を信用しない

決済UIは PayPal の Smart Buttons(JS SDK)です。ブラウザ側で決済が完了すると注文IDが得られるので、それをAPIに送って解錠します。

ここに罠があります。ブラウザから送られてくる「決済完了しました」は偽装できます。 注文IDの形式さえ合っていれば、決済せずに解錠APIを叩けてしまう。

対策は、サーバー側でPayPal APIに注文を照合することです。

1. client_id + secret で OAuth トークン取得 (POST /v1/oauth2/token)
2. GET /v2/checkout/orders/{注文ID}
3. status == COMPLETED を確認
4. 金額 (200 JPY) を確認
5. 購入者メールは PayPal の応答から取る(ブラウザの申告値は使わない)

この照合を入れると、偽の注文IDは「注文が見つかりません」で弾かれます。実際にテストで偽IDを投げて拒否されることを確認しました。

Sandbox / Live の罠

PayPal Developer ダッシュボードは、開くと必ず Sandbox(テスト環境)側が表示されます。ここで作ったアプリの Client ID / Secret は、本番APIでは invalid_client で認証に失敗します。

私はこれで2回、Sandboxの資格情報を掴まされました。見分け方は簡単で、取得した資格情報で両方のエンドポイントにOAuthを投げることです。

  • api-m.paypal.com で成功 → 本番(Live)の資格情報
  • api-m.sandbox.paypal.com で成功 → テスト用。作り直し

ダッシュボード上部の Sandbox / Live 切り替えを「Live」にしてからアプリを作成する必要があります。切り替えた直後はアプリ一覧が空になるのが「Live側にいる」目印です。

3. トークン決済: オンチェーンをサーバーが直接見る

もう1つの決済レールは、Base チェーン上の ERC20 トークンです。読者は指定ウォレットへ 20,000 枚を送金し、送金元アドレスを入力して「支払い確認」を押します。

サーバーは、公開RPCに eth_getLogs を投げて Transfer イベントを直接確認します。

$topics = [
    keccak256("Transfer(address,address,uint256)"),  // イベントシグネチャ
    pad32($送金元ウォレット),                          // from
    pad32($受け取りウォレット),                        // to
];
// トークンコントラクト宛に、直近ブロック範囲で照会し、amountを合算

ポイントは3つあります。

  • 決済プロバイダ不要: RPCはパブリックノードで足りる。手数料も審査もない
  • from と to を両方 topic で絞る: 対象の送金だけがピンポイントで返る
  • ブロック範囲は分割して照会: 公開RPCは広範囲の getLogs を拒むことがある。5万ブロックずつ数回に分けると安定する

送金額はイベントの data フィールド(32バイトのhex)から復元します。18桁のdecimalsを割るときは、閾値判定が目的なら float 精度で十分です。

4. 匿名のまま「買った人」を識別する

会員登録は要求しません。識別子は決済手段が勝手に連れてきます。

  • PayPal購入者 → PayPal応答のメールアドレス
  • トークン購入者 → 送金元ウォレットアドレス

台帳には 識別子 × 記事キー で1レコード記録し、閲覧の維持は識別子をHMAC署名したCookie(1年)で行います。

Cookie: KURAGEPAY = identifier|HMAC-SHA256(identifier, server_secret)

Cookieは「目印」であって、解錠判定は毎回サーバーの台帳と照合します。署名鍵はサーバー側だけが持つので、Cookieを自作しても識別子を偽装できません。

別端末での再解錠は、メール購入者には「メール + PayPal注文ID」の両方一致を要求します(メールだけで解錠できると、他人のメールを入れるだけで読めてしまうため)。ウォレット購入者は、オンチェーンに送金の事実が刻まれているので、再検証するだけです。

5. 障害時の設計: 決済は fail-closed、それ以外は素直に

エントリー可否のようなゲートは fail-open(障害時は通す)にすることが多いですが、決済の解錠は逆で、確認できなければ必ず閉じる(fail-closed)にします。PayPal APIに到達できない・RPCが応答しない場合は、解錠せずエラーを返して再試行してもらいます。

一方で、購入済みの読者を締め出さないよう、台帳・Cookie検証はシンプルに保ち、依存を減らしています。ペイウォールの実装そのものが落ちた場合は、テーマ側の分岐で「マーカーが無い記事は全文表示」に倒れるため、無料記事には一切影響しません。

まとめ

  • 「途中まで無料」はサーバーサイド分割が絶対条件。CSSで隠すのは実装ではない
  • PayPalはブラウザの完了報告を信用せず、サーバーからAPIで注文を照合する
  • Sandbox/Liveの取り違えは両エンドポイントへのOAuth試行で機械的に判別できる
  • ERC20の受領確認は eth_getLogs で自前実装できる。決済プロバイダは必須ではない
  • 匿名決済でも、メール/ウォレットという「決済が連れてくる識別子」で購入者を追跡できる

外部SaaSに月額を払わなくても、有料記事は数百行で自作できます。決済の検証をサーバーに置くこと、これだけは省略しないでください。