shinke1987.net
雑多な備忘録等のはず。
他のカテゴリ・タブ
目次
PR

PHPでWebブラウザのプッシュ通知の動作確認

2024-08-14 2024-08-14
カテゴリ: PHP

概要

minishlink/web-push を利用したWebブラウザのプッシュ通知の動作確認を行う。(PCのみ)

DockerコンテナではなくPHPのビルトイン開発用サーバで実行する。

PHP:8.3.7

OS:MacOS Sonoma 14.5

Safari:17.5

Chrome:127.0.6533.120(Official Build) (arm64)

Firefox:129.0.1 (64 ビット)

参考資料

Service Worker の概要 (Chrome for Developers)

ServiceWorker – Web API | MDN

サービスワーカー API – Web API | MDN

プッシュ API – Web API | MDN

mdn/serviceworker-cookbook(プッシュ通知の例)

web-push-libs/web-push-php: Web Push library for PHP

JSとPHPでWebPushを送信するWebアプリケーションを作ってみる

Web Pushを試してみた(フロントエンド編) | KEYPOINT

大事なこと

MDNによると、

「すべてのプッシュ通知は有益で時刻に敏感なものであるべきであり、ユーザーには最初のプッシュ通知を送信する前に必ずその許可を求め、今後プッシュ通知を取得しないようにする簡単な方法を提供する必要があります。」

とのこと。

覚え書き

ServiceWorkerが利用できるのは、localhost以外ではhttps通信のみ。

プッシュサーバと通信せずJSだけで通知を表示する

概要

実際に使うことはないだろうけれども、理解を深めるために実行した。

前もって指定した通知が表示できることを確認できる。

index.html の内容

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <title>BasicTest1</title>
  <script src="app.js"></script>
</head>
<body>
BasicTest1<br>
<button
  type="button"
  onclick="handleClick()"
  >
  通知を表示
</button>
</body>
</html>

app.js の内容

async function handleClick (event) {
  const result = await Notification.requestPermission();

  switch (result) {
    case 'default':
      // 通知の許可を得るダイアログで×ボタンが押下され、許可も拒否もされなかった場合。
      // または通知の許可を得るダイアログが表示される前。
      break;
    case 'denied':
      // 通知の許可を得るダイアログで拒否を選択された場合。
      // または過去に通知の許可を得るダイアログで拒否が選択された場合。
      break;
    case 'granted':
      // 通知の許可を得るダイアログで許可が選択された場合。
      // または過去に通知の許可を得るダイアログで許可が選択された場合。
      await navigator.serviceWorker.register('serviceWorker.js');

      const swr = await navigator.serviceWorker.ready;
      await swr.showNotification('title1', {
        body: '内容',
      });
      break;
  }
}

serviceWorker.js の内容

※ プッシュサーバと通信しないで良いなら、空白で良い。

// コード無し。

動作確認

Chrome, Firefox, Safari で動作確認をする。

余談ですがコードを試している間、下記の現象があった。

プッシュサーバと通信して通知を表示する

minishlink/web-push をインストール

% composer require minishlink/web-push

公開鍵と秘密鍵のペアを生成

シェルから実行する場合と、PHPのコードから実行する場合の、2つの方法がある。

Linuxのシェルからopensslコマンドを利用して生成するには下記コマンド。

$ mkdir keys && cd keys
$ openssl ecparam -genkey -name prime256v1 -out private_key.pem
$ openssl ec -in private_key.pem -pubout -outform DER|tail -c 65|base64|tr -d '=' |tr '/+' '_-' >> public_key.txt
$ openssl ec -in private_key.pem -outform DER|tail -c +8|head -c 32|base64|tr -d '=' |tr '/+' '_-' >> private_key.txt

※ public_key.txt に公開鍵が保存される。
※ private_key.txt に公開鍵が保存される。

PHPのコードから実行する場合には下記コードを実行する。

<?php

require_once __DIR__ . '/vendor/autoload.php';

use Minishlink\WebPush\VAPID;

$keys = VAPID::createVapidKeys();
printf("publicKey = %s%s", $keys['publicKey'], PHP_EOL);
printf("privateKey = %s%s", $keys['privateKey'], PHP_EOL);

コードの準備

ディレクトリ構成

BasicTest2
├ vendor
│ └ ...
├ app.js
├ composer.json
├ composer.lock
├ index.html
├ manual_send_push_notification.php
├ receive_subscription.php
├ router.php
├ send_push_notification.php
├ serviceWorker.js
└ subscription.txt

各ファイルの概要

router.php の内容

<?php

if ($_SERVER['REQUEST_URI'] === '/') {
    require_once 'index.html';
} else {
    $fileName = mb_substr($_SERVER['REQUEST_URI'], 1);
    if (preg_match('|.js$|u', $fileName) === 1) {
        header('Content-Type: text/javascript');
    }
    require_once $fileName;
}

index.html の内容

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <title>BasicTest2</title>
  <script type="text/javascript" src="app.js"></script>
</head>
<body>
BasicTest2<br>
<br>
<button
    type="button"
    onclick="getPermission()"
>
  通知の許可を取得
</button>
<br>
<br>
<button
    type="button"
    onclick="showNotification()"
>
  通知を表示
</button>
<br>
<br>
<button
    type="button"
    onclick="updateSw()"
>
  サービスワーカーを更新
</button>
</body>
</html>

app.js の内容

// 公開鍵。
const applicationServerKey = '生成した公開鍵の文字列';

// 公開鍵を編集する関数。minishlink/web-pushからコピペした。
function urlBase64ToUint8Array (base64String) {
  const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
  const base64 = (base64String + padding).replace(/\-/g, '+').
    replace(/_/g, '/');

  const rawData = window.atob(base64);
  const outputArray = new Uint8Array(rawData.length);

  for (let i = 0; i < rawData.length; ++i) {
    outputArray[i] = rawData.charCodeAt(i);
  }
  return outputArray;
}


async function getPermission() {
  // 通知の許可を取得。
  const result = await Notification.requestPermission();

  if (result !== 'granted') {
    // 許可以外だった場合は何もしない。
    return;
  }

  // サービスワーカーを登録。
  await navigator.serviceWorker.register('serviceWorker.js');

  // アクティブになったサービスワーカーを取得。
  const swr = await navigator.serviceWorker.ready;

  // プッシュサーバへ登録。
  const subscription = await swr.pushManager.subscribe({
    userVisibleOnly: true,
    applicationServerKey: urlBase64ToUint8Array(applicationServerKey)
  });

  // 暗号化方式を確認。
  let contentEncoding = 'aesgcm';
  if (PushManager.supportedContentEncodings?.includes('aes128gcm')) {
    contentEncoding = 'aes128gcm';
  }

  // アプリケーションサーバへsubscriptionの情報を保存。
  await fetch('receive_subscription.php', {
    method: 'POST',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify(Object.assign(subscription.toJSON(), {contentEncoding}))
  });

  // 完了した旨表示。
  alert('プッシュサーバの登録まで完了');
}


async function showNotification() {
  // 暗号化方式を設定。
  let contentEncoding = 'aesgcm';
  if (PushManager.supportedContentEncodings?.includes('aes128gcm')) {
    contentEncoding = 'aes128gcm';
  }

  // 既存のプッシュサブスクリプションを取得。
  const swr = await navigator.serviceWorker.ready;
  const jsonSubscription = (await swr.pushManager.getSubscription()).toJSON();

  // アプリケーションサーバへプッシュ通知をするようPOST。
  await fetch('send_push_notification.php', {
    method: 'POST',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify(Object.assign(jsonSubscription, { contentEncoding }))
  });

  // 完了した旨表示。
  alert('プッシュ通知依頼のPOSTまで完了')
}


async function updateSw() {
  const swr = await navigator.serviceWorker.ready;
  swr.update();
}

serviceWorker.js の内容

// 通知をクリックした時に表示するURL。
let url = null;

self.addEventListener('push', async function (event) {
  const jsonData = event.data.json();

  url = jsonData.url;

  event.waitUntil(
    self.registration.showNotification(jsonData.title, {
      body: jsonData.message,
    }),
  );
});

// 通知をクリックされた時のイベント。
self.addEventListener('notificationclick', function (event) {
  event.notification.close();
  clients.openWindow(url);
});

receive_subscription.php の内容

<?php

require_once __DIR__.'/vendor/autoload.php';

$subscription = json_decode(file_get_contents('php://input'), true);

file_put_contents('subscription.txt', '$endpoint = "' . $subscription['endpoint'] . '";' . PHP_EOL, FILE_APPEND);
file_put_contents('subscription.txt', '$auth = "' . $subscription['keys']['auth'] . '";' . PHP_EOL, FILE_APPEND);
file_put_contents('subscription.txt', '$p256dh = "' . $subscription['keys']['p256dh'] . '";' . PHP_EOL, FILE_APPEND);
file_put_contents('subscription.txt', '$contentEncoding = "' . $subscription['contentEncoding'] . '";' . PHP_EOL . PHP_EOL, FILE_APPEND);

return;

send_push_notification.php の内容

<?php

require_once __DIR__.'/vendor/autoload.php';

use Minishlink\WebPush\WebPush;
use Minishlink\WebPush\Subscription;

$subscription = Subscription::create(json_decode(file_get_contents('php://input'), true));

$auth = array(
    'VAPID' => array(
        'subject' => 'mailto:メールアドレス',    // サイトのURLでも良い。
        'publicKey' => '生成した公開鍵の文字列',
        'privateKey' => '生成した秘密鍵の文字列',
    ),
);

$webPush = new WebPush($auth);

$payload = json_encode([
    'title' => 'タイトル',
    'message' => 'メッセージ',
    'url' => 'https://google.co.jp'
]);

$report = $webPush->sendOneNotification(
    $subscription,
    $payload,
);

echo $report->isSuccess();
echo $report->getReason();

manual_send_push_notification.php の内容

<?php

// このスクリプトは「php manual_send_push_notification.php」という形で
// 直接実行する必要がある。

require_once __DIR__.'/vendor/autoload.php';

use Minishlink\WebPush\WebPush;
use Minishlink\WebPush\Subscription;

// ここから
$endpoint = 'エンドポイントを表す文字列';
$auth = '文字列';
$p256dh = '文字列';
$contentEncoding = '文字列';
// ここまでの情報は、
// receive_subscription.php が subscription.txt に保存している内容を
// コピペすれば良い。

$subscription = new Subscription($endpoint, $p256dh, $auth, $contentEncoding);

$auth = array(
    'VAPID' => array(
        'subject' => 'mailto:メールアドレス',
        'publicKey' => '生成した公開鍵の文字列',
        'privateKey' => '生成した秘密鍵の文字列',
    ),
);

$webPush = new WebPush($auth);

$payload = json_encode([
    'title' => 'タイトル手動2',
    'message' => 'メッセージ手動2',
    'url' => 'https://google.co.jp'
]);

$report = $webPush->sendOneNotification(
    $subscription,
    $payload
);

printf("isSuccess = %s%s", (string)$report->isSuccess(), PHP_EOL);
// $report->getReason() でエラー詳細を確認できる。

動作確認

PHPのビルトイン開発用サーバを起動するために、下記コマンドを実行する。

% cd BasicTest2
% php -S localhost:8888 router.php

その後、ChromeとFirefoxとSafariで http://localhost:8888 へアクセスし動作確認をすれば良い。

manual_send_push_notification.php についてはphpコマンドを利用して直接実行する必要がある。

各ブラウザで挙動が違う箇所があったので、慣れるまでは各Webブラウザで動作確認すると良い。

違う箇所については、例えば以下があった。

同一カテゴリの記事