PHPとlevelDBでPWAのpush通知を実装

# はじめに
pwaでiOSのpush通知ができるようになるのはいつ頃でしょうか？
日本は特にiOSのシェアが大きいので、pwaがイニシアチブを取れるかどうかはこれにかかっていますね。

さて、web-push-phpもメジャーバージョンがあがって、php7.1以降にも対応するようになり、sendOneNotification()が廃止されたりしているようなので、あらためてこれを使ってpush通知ができるようになるまでをまとめてみます。

ついでに購読者管理を最近私的トレンドのlevelDBでやってみます。

# 動作確認要件
* CentOS 7
* PHP 7.4.0
* web-push-php 4.0
* levelDB 1.12

# 事前準備
```bash
sudo yum --enablerepo=epel install leveldb-devel

cd /usr/local/src/
git clone https://github.com/reeze/php-leveldb.git

cd php-leveldb/
phpize
./configure --prefix=/home/{you}/.phpenv/versions/7.4.0/lib/php/leveldb --with-libdir=lib64 --with-leveldb=/usr/include/leveldb --with-php-config=/home/{you}/.phpenv/versions/7.4.0/bin/php-config
make
make install

vi ~/.phpenv/versions/7.4.0/etc/php.ini
	extension=/home/{you}/.phpenv/versions/7.4.0/lib/php/extensions/no-debug-non-zts-20190902/leveldb.so

~/.phpenv/versions/7.4.0/etc/init.d/php-fpm restart

mkdir -p ~/projects/pwa/{public_html,logs}
cd ~/projects/pwa/

composer require minishlink/web-push
vi composer.json
    "minishlink/web-push": "^4.0"

composer update

npm install web-push
./node_modules/.bin/web-push generate-vapid-keys --json > pairs.txt

vi ~/projects/pwa/public_html/vapidPublicKey
    {上記pair.txt内の"publicKey"の値}
```

```~/projects/pwa/public_html/index.html
<html>
<head>
  <meta name="apple-mobile-web-app-capable" content="yes">
  <meta name="apple-mobile-web-app-status-bar-style" content="black">
  <meta name="apple-mobile-web-app-title" content="PWA sandbox">
  <link rel="apple-touch-icon" href="/img/icon-192.png" sizes="192x192">
  <link rel="apple-touch-icon" href="/img/icon-256.png" sizes="256x256">
  <script async src="https://cdn.jsdelivr.net/npm/pwacompat@2.0.6/pwacompat.min.js"
    integrity="sha384-GOaSLecPIMCJksN83HLuYf9FToOiQ2Df0+0ntv7ey8zjUHESXhthwvq9hXAZTifA"
    crossorigin="anonymous"></script>
  <link rel="manifest" href="./manifest.json">
  <script src="common.js" defer></script>
</head>
<body>
  <h1>PWA sandbox</h1>
  <div>test</div>
</body>
</html>
```
```~/projects/pwa/public_html/manifest.json
{
  "name": "PWA sandbox",
  "short_name": "PWA",
  "background_color": "#fff",
  "icons": [{
      "src": "/img/icon-256.png",
      "sizes": "256x256",
      "type": "image/png"
    },{
      "src": "/img/icon-192.png",
      "sizes": "192x192",
      "type": "image/png"
    }],
  "start_url": "/?utm_source=homescreen",
  "display": "standalone"
}
```

```~/projects/pwa/public_html/service-worker.js
var CACHE_NAME = 'v1-0';
self.addEventListener('install', function(event) {
  console.log('[ServiceWorker] Install');
  event.waitUntil(
    caches.open(CACHE_NAME).then(function(cache) {
      return cache.addAll([
        '{プリキャッシュしたい画面パス}'
      ]);
    })
  );
});

self.addEventListener('activate', function(event) {  
  console.log('[ServiceWorker] Activate');
  event.waitUntil(
    caches.keys().then(function(cache) {
      cache.map(function(name) {
        if(CACHE_NAME !== name) caches.delete(name);
      })
    })
  );
});

self.addEventListener('fetch', function(event) {
  console.log('[ServiceWorker] Fetch');
  event.respondWith(
    caches.match(event.request).then(function(res) {
      if(res) return res;
      return fetch(event.request);
    })
  );
});

self.addEventListener('push', function (event) {
    console.log('[ServiceWorker] Push');
    const title = 'test information';
    const options = {
        body: event.data.text(),
        tag: title,
        icon: '/img/icon-256.png',
        badge: '/img/icon-256.png'
    };
    event.waitUntil(self.registration.showNotification(title, options));
});
```

```~/projects/pwa/public_html/common.js
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;
}

navigator.serviceWorker.register('service-worker.js');
navigator.serviceWorker.ready
.then(function(registration) {
  return registration.pushManager.getSubscription()
  .then(async function(subscription) {
    if (subscription) {
      return subscription;
    }

    const response = await fetch('./vapidPublicKey');
    const vapidPublicKey = await response.text();
    const convertedVapidKey = urlBase64ToUint8Array(vapidPublicKey);

    return registration.pushManager.subscribe({
      userVisibleOnly: true,
      applicationServerKey: convertedVapidKey
    });
  });
}).then(function(subscription) {
  const key = subscription.getKey('p256dh');
  const token = subscription.getKey('auth');
  subscription['userPublicKey'] = btoa(String.fromCharCode.apply(null, new Uint8Array(key)));
  subscription['userAuthToken'] = btoa(String.fromCharCode.apply(null, new Uint8Array(token)));
  fetch('/register.php', {
    headers: {'Content-type': 'application/json'},
    method: "post",
    body: JSON.stringify(subscription)
  });
});
```

```~/projects/pwa/common.php
<?php
define('APP_DIR', '/home/{you}/projects/pwa/');
define('LEVEL_DB', 'subscribers');
define('VAPID_SUBJECT', 'mailto:{you}@{your domain}');
define('PUBLIC_KEY', '「事前準備」の最後に取得したpair.txt内の"publicKey"の値');
define('PRIVATE_KEY', '「事前準備」の最後に取得したpair.txt内の"privateKey"の値');

define('LEVEL_DB_BASE_OPTIONS', serialize([
    'create_if_missing' => true,
    'error_if_exists' => false,
    'paranoid_checks' => false,
    'block_cache_size' => 8 * (2 << 20),
    'write_buffer_size' => 4<<20,
    'block_size' => 4096,
    'max_open_files' => 1000,
    'block_restart_interval' => 16,
    'compression' => 1,
    'comparator' => NULL,
]));
define('LEVEL_DB_READ_OPTIONS', serialize([
    'verify_check_sum' => false,
    'fill_cache' => true,
    'snapshot' => null
]));
define('LEVEL_DB_WRITE_OPTIONS', serialize([
    'sync' => false,
]));
```

```~/projects/pwa/sendpush.php
<?php
require_once 'common.php';
require_once 'vendor/autoload.php';

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

if (trim($argv[1] ?? '') === '') {
    echo "need to input message";
    exit(1);
}
$message = $argv[1];

try {
    $db = new LevelDB(APP_DIR . LEVEL_DB, unserialize(LEVEL_DB_BASE_OPTIONS), unserialize(LEVEL_DB_READ_OPTIONS), unserialize(LEVEL_DB_WRITE_OPTIONS));
    $it = new LevelDBIterator($db);
    foreach($it as $endpoint => $pairs) {
        $pairs = unserialize($pairs);
        send_push($message, $endpoint, $pairs['public_key'] ?? null, $pairs['auth_token'] ?? null);
    }

} catch (Exception $e) {
    error_log($e->getMessage());
}

function send_push(String $message, String $endpoint, ?String $public_key, ?String $auth_token) {
    $subscription = Subscription::create([
        'endpoint' => $endpoint,
        'publicKey' => $public_key,
        'authToken' => $auth_token,
    //    'contentEncoding' => 'aes128gcm',
    ]);

    $auth = [
        'VAPID' => [
            'subject' => VAPID_SUBJECT,
            'publicKey' => PUBLIC_KEY,
            'privateKey' => PRIVATE_KEY,
        ]
    ];

    $webPush = new WebPush($auth);
    $report = $webPush->sendNotification(
        $subscription,
        $message,
        false,
        [],
        $auth
    );
    $webPush->flush();
}
```

```~/projects/pwa/public_html/register.php
<?php
require_once '../common.php';
header("Content-Type: application/json; charset=utf-8");

$reqs = json_decode(file_get_contents('php://input'));
$endpoint = $reqs->endpoint ?? null;
$public_key = $reqs->keys->p256dh ?? null;
$auth_token = $reqs->keys->auth ?? null;

$is = false;
if ($endpoint && $public_key && $auth_token) {
    try {
        $db = new LevelDB(APP_DIR . LEVEL_DB, unserialize(LEVEL_DB_BASE_OPTIONS), unserialize(LEVEL_DB_READ_OPTIONS), unserialize(LEVEL_DB_WRITE_OPTIONS));
        $db->put($endpoint, serialize(compact('public_key', 'auth_token')));
        system('find ' . APP_DIR . LEVEL_DB . ' -type d -exec chmod 0770 {} \; && find ' . APP_DIR . LEVEL_DB . ' -type f -exec chmod 0660 {} \;');
        $is = true;
    } catch (Exception $e) {
        error_log($e->getMessage());
    }
}

echo json_encode($is);
```

# まとめ
ざーっと書きましたが、フローとそれぞれの役割的には

1. ~/projects/pwa/public_html/index.html に相当するURLにアクセス
1. ~/projects/pwa/public_html/manifest.json でpwaに関するマニフェストの読み込み
1. ~/projects/pwa/public_html/service-worker.js でpush通知等の振る舞いに関する定義の読み込み
1. ~/projects/pwa/public_html/common.js でpush通知を要求
1. ~/projects/pwa/public_html/register.php で要求されたpush通知の購読者を登録
1. ~/projects/pwa/sendpush.php で任意タイミングでpush通知の実施

となります。
sendpush.php はターミナルから以下のように実施してください。

```bash
php ~/projects/pwa/sendpush.php {通知したいメッセージ}
```

これでpush通知を許可した全端末にlevelDBに登録されている購読者をフェッチしながら配信をおこないます。

![ss.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/282332/a433809d-71d2-29f2-5b16-b6ef2797d13c.png)
上記画像のような通知が届くのを確認できるかと思います。

以下からソースの利用もできます。
https://github.com/cybist/pwa_push_simple_all_in_one