JavaScript
Push通知
Polymer
Firebase
PubSubHubbub
PolymerDay 8

Polymer & Firebase でサイト更新したらプッシュ通知

概要

Polymer Japan のサイトが更新されたら自動でプッシュ通知を送るようにしました。

ユーザはトップページ右上のimage.pngアイコンをクリックしてプッシュ通知をトグルします。
※ サイト訪問時にいきなりダイアログで聞かないように (Web Fundamentals: パーミッションの UX)

qitta-top.png

サイト管理者側は、
1. コンテンツを追加、更新したら「PUBLISH」ボタンをクリック → /feed.xmlが更新される
2. TriggerでPubSubHubbub/publish に更新通知を送られ、
3. Callbackがきたら、最新の記事を取得し、/topics/feedにプッシュ通知が送られます

だいぶやっつけで作ってしまってますが、稼動しているサイトのソースは こちらです。

全体的な流れ

flow

PlantUML

プッシュ通知のリクエスト

フロントエンド側はPolymerfireを使うと簡単にTokenを取得できます。取得できたTokenはiron-ajax経由でサーバサイドのCloud Functions for Firebaseで処理します。

src/polymer-jp.html
<!-- Firebase のライブラリ読み込み -->
<link rel="import" href="../bower_components/polymerfire/firebase-app.html">
<link rel="import" href="../bower_components/polymerfire/firebase-messaging.html">

<!-- プッシュ通知の許諾を保存していくlocalStorage用のライブラリ読み込み -->
<link rel="import" href="../bower_components/app-storage/app-localstorage/app-localstorage-document.html">

<!-- Firebase Cloud Messagingへ送信するAjax用のライブラリ読み込み -->
<link rel="import" href="../bower_components/iron-ajax/iron-ajax.html">

...

<!-- Firebaseのサーバ設定 -->
<firebase-app auth-domain=...></firebase-app>
<!-- メッセージTokenの格納先設定 -->
<firebase-messaging token="{{_token}}"></firebase-messaging>

<!-- プッシュ通知の許諾をlocalStorageに保存 -->
<app-localstorage-document key="polymer-jp:v0:firebase-push-msg" data="{{_shouldPush}}"></app-localstorage-document>

<!-- autoがTrueになったらその値とともに/pushへtokenを投げる -->
<iron-ajax auto="[[_shouldPushRequest(_pushRequest,_token,_shouldPush)]]"
    url="/push?add=[[_shouldPush]]&token=[[_token]]"></iron-ajax>

<!--
  1. プッシュ通知のアイコンを表示して、タップされたら_pushToggleを呼ぶ
  2. _shouldPushの値によってアイコンの表示を変える
  -->
<paper-icon-button icon="i:[[_iconPush(_shouldPush)]]" toggles on-tap="_pushToggle"></paper-icon-button>

...

<script>
 class PolymerJp extends Polymer.FirestoreElement(Polymer.Element) {

  /**
   * ブラウザ側のプッシュ通知の許諾(Notification.permission[granted,denied,default])
   * に合わせてアイコンを変更する
   */
  _iconPush(shouldPush){
    return shouldPush && Notification.permission == 'granted' ? 'notifications' : 'notifications-none';
  }

  /**
   * アイコンがクリックされたらトグルで_pushRequestと_shouldPushを変更する
   */
  _pushToggle(e){
    if(! this._shouldPush){
      this.shadowRoot.querySelector('firebase-messaging').requestPermission().then(_=>{
        this.setProperties({_pushRequest: true, _shouldPush: true});
      });
    }else{
      this.setProperties({_pushRequest: true, _shouldPush: false});
    }
  }

  /**
   * アイコンがクリックされ、Tokenが取得できて、shouldPushの値が確定した時にAjaxリクエストを投げる
   */
  _shouldPushRequest(pushRequest, token, shouldPush) {
    return pushRequest && token && shouldPush !== undefined;
  }

</script>

サーバサイド側もFirebaseのライブラリ(FCM)があって楽です。

functions/index.js
// 各モジュールの読み込み
const functions = require('firebase-functions');
const admin = require('firebase-admin');
const fcm = admin.messaging();

/**
 * iron-ajaxで送信されてきたフラッグで処理を振り分け、tokenを登録
 */
exports.push = functions.https.onRequest((req, res) => {

  if(req.query.add === 'true'){
    // /topics/feedへ登録
    fcm.subscribeToTopic(req.query.token, 'feed');
  }else{
    // /topics/feedから削除
    fcm.unsubscribeFromTopic(req.query.token, 'feed');
  }

  res.send('OK');

});

サイト更新からプッシュ通知の送信

編集画面からは明示的に「PUBLISH」ボタンをクリックし、feed情報をCloud Firestoreに登録します。

qiita-pub.png

src/polymer-jp-marked-edit.html
<!-- ボタンクリックで_publishを呼び出す -->
<paper-button raised on-tap="_publish">PUBLISH</paper-button>

<script>
  class PolymerJpMarkedEdit extends Polymer.Element {
    ...
    _publish(e){
      // feedコレクションからURLで検索
      firebase.firestore().collection('feed').where('path','==',location.pathname).get().then(s=>{
        // 一致するものがあったら消す
        s.forEach(d=>firebase.firestore().collection('feed').doc(d.id).delete());
      }).then(_=>{
        // feedにコンテンツを登録
        firebase.firestore().collection('feed').add({
          date: firebase.firestore.FieldValue.serverTimestamp(),
          id: 'https://polymer-jp.org'+location.pathname,
          link: 'https://polymer-jp.org'+location.pathname,
          description: this.doc.desc || '',
          path: location.pathname,
          title: this.doc.title
        });
      });
    }
    ...

サーバサイド側は、事前にSubscriberの登録を手動でしておき、Firestoreのfeedコレクションが更新されたらupdateFeedが起動、PubSubHubbubへPublish通知を送ります。
PubSubHubbub側からfeed.xmlの取得リクエストがくるのでfeedで記事を返します。PubSubHubbubは記事のSubscriberへ更新通知を飛ばしてくるのでsubsで最新記事を取得して、FCM側へプッシュ通知配信をリクエストします。

image.png

functions/index.js
// 各モジュールの読み込み
const functions = require('firebase-functions');
const admin = require('firebase-admin');
// Firestore
const db = admin.firestore();
// Firebase Cloud Messaging(FCM)
const fcm = admin.messaging();
// Feed生成用ライブラリ
const Feed = require('feed');
// HTTP Request送信用ライブラリ
const request = require('request');
// Functionsのサイト毎設定を保存できるConfigライブラリ
const config = functions.config();
// PubSubHubbubの講読開始時に使用する暗号化ライブラリ
const crypto = require('crypto');

/**
 * /feedコレクションの更新時に起動し、PubSubHubbubへPublishリクエストを送る
 */
exports.updateFeed = functions.firestore.document('/feed/{feedId}')
  .onWrite(event => {
    request({
      url: 'https://pubsubhubbub.appspot.com/publish',
      method: 'POST',
      body: 'hub.mode=publish&hub.url='+config.server.url+'feed.xml'
    }, (err, resp, body) => {
      console.log(err,body);
    });
    return true;
  });

/**
 * /feedコレクションの更新時に起動し、PubSubHubbubへPublishリクエストを送る
 */
exports.feed = functions.https.onRequest((req, res) => {

  // 仕様上PubSubHubbubに対応させるには`hub`プロパティを設定しておく
  const feed = new Feed({
    title: 'Polymer Japan',
    id: config.server.url,
    link: config.server.url,
    hub: 'https://pubsubhubbub.appspot.com',
    feedLinks: {
      atom: config.server.url+'feed.xml',
    }
  });

  db.collection('feed').orderBy('date','desc').limit(10).get()
    .then(s=>s.docs.map(d=>{
      const doc = d.data();
      if(! feed.options.updated)
        feed.options.updated=doc.date;
      feed.addItem(doc);
    }))
    .then(_=>{
      res.header('Content-Type', 'application/xml');
      res.send( feed.rss2() );
    });

});

/**
 * Subscriber: PubSubHubbubからのリクエストは2種類
 * - GET
 *   講読開始時に適当なパスワードを`verify_token`にいれ、その一致を確認する
 * - POST
 *   feed.xmlが更新されている場合にfeed.xmlがREQUEST BODYに入ってPOSTされてくる
 *   こちらも適当なパスワードを`HMAC secret`として登録しておき、HMAC署名での一致を確認する
 *   Firestoreのfeedコレクションから最新の記事をとりだし、FCM経由でプッシュ通知を送信する
 */
exports.subs = functions.https.onRequest((req, res) => {

  // Subscriber登録時の処理
  if(req.method=='GET'){
    // 上記Subscriber登録フォームに入力する`verify_token`とサーバ側の設定の`verify_token`を一致させる
    if(config.server.verify_token == req.query['hub.verify_token']){
      // PubSubHubbub側から飛んでくる`hub.challenge`のパラメータをそのまま表示すれば完了
      res.send(req.query['hub.challenge']);
    }else{
      res.status(404).end();
    }
  }else{
    // 上記Subscriber登録フォームに入力する`HMAC secret`とサーバ側の設定の`hmac_secret`を一致させる
    const hmac = crypto.createHmac('sha1', config.server.hmac_secret);
    // 署名検証はPOSTされてきたREQUEST BODY(payload)に対して生成させる
    hmac.update(req.body.toString());
    // secretはX-HUB-SIGNATUREヘッダにsha1=XXXとう形式で入ってるので一致するか検証する
    const hmacSignature = 'sha1='+hmac.digest('hex');
    if(req.headers['x-hub-signature'] == hmacSignature){
      db.collection('feed').orderBy('date','desc').limit(1).get()
        .then(s=>s.docs.map(d=>{
          const doc = d.data();
          // 取得したコンテンツを/topics/feedに送信する
          fcm.sendToTopic('feed',{
            notification: {
              title: doc.title,
              body: doc.description,
              icon: config.server.url+'assets/logos/polymer-jp-logo-192.png',
              click_action: doc.link
            }
          });
        }));
    }
    res.send('OK');
  }

})

課題・問題点

  • プッシュ通知(feedトピックス)の講読者数がわからず、無効になったtokenも追えない
    → tokenをDBに保存しておけばよいのだろうけど、これはちゃんとやるべきなのか自信ない
  • Publish後PubSubHubbub側からのPOSTにはfeedの内容が飛んでくるので、毎回DBから読み出す必要はない
    → それほど呼ばれる頻度はなく、アクセスが多くなることもないと思われるので、いったんこのままで

よい方法、一般的によく使われているアプローチなど教えてもらえると嬉しいです! :bow::bow::bow:


:christmas_tree: Polymer Advent Calendar 2017
:arrow_left: 7日目 lit-htmlをTODO Exampleを通して紹介する @hirodeath
:arrow_right: 9日目 Polymer コミュニティの紹介 @sizuhiko