🦜

GoでWebPushを送る

2024/03/07に公開

目的

ほとんどのブラウザからPWAインストールしたサービスワーカーを通じてWebPushが送れるようになっているのでそれを試す。

依存ライブラリ

https://github.com/SherClockHolmes/webpush-go

WebPushの仕組み

  • ブラウザベンダーが各社WebPushサービスを提供してくれている
  • サービスワーカー上にてサブスクライブ処理することでWebPushサービスに購読申請を行う
  • 購読宛ての通知があればブラウザは受け取り、サービスワーカーへのイベントとして配信する
  • 購読申請にて得られる情報には「enpoint」としてWebPushサービスのAPIエンドポイントURLも含まれる
  • ブラウザごとにWebPushサービスが異なるのでこのendpointのURLは異なる(EdgeとChromeも違う)
  • Push通知イベントのハンドリングにて「Web Notifications」を使って通知をポップアップさせる

必要な手順

  • 予めVAPIDのPrivateKeyとPublicKeyの生成(アプリケーション単位)
  • PublicKeyをブラウザに渡してサービスワーカー上でサブスクライブ処理
  • サブスクリプション情報をプッシュ通知発信元に持ってきて保存
  • PrivateKeyとサブスクリプションを使って通知内容を暗号化してブラウザのプッシュエンドポイントに送る

サブスクリプション保存

subscribesフォルダにサブスクリプション情報のJSONデータを保存する

通知API

  • Basic認証付きで通知APIを実装する
  • subscribesフォルダ配下のJSONファイルをスキャンしてそれぞれについて通知

コード

https://github.com/nobonobo/webpush-demo

  • SvelteKit+Skeletonフロントエンド
  • Goバックエンド

Go実装

main.go
package main

import (
	"crypto/sha1"
	"embed"
	"encoding/base64"
	"encoding/json"
	"flag"
	"io"
	"io/fs"
	"log"
	"net/http"
	"os"
	"path/filepath"

	"github.com/SherClockHolmes/webpush-go"
	"github.com/caarlos0/env/v10"
	"github.com/joho/godotenv"
)

//go:generate sh -c "npm run build"

//go:embed all:build/*
var assets embed.FS

var config = struct {
	Subscriber      string `env:"Subscriber,required"`
	VAPIDPublicKey  string `env:"VAPIDPublicKey,required"`
	VAPIDPrivateKey string `env:"VAPIDPrivateKey,required"`
	ClientID        string `env:"ClientID,required"`
	ClientSecret    string `env:"ClientSecret,required"`
}{}

func init() {
	log.SetFlags(log.Lshortfile)
	var dotenv string
	flag.StringVar(&dotenv, "env", ".env", "load .env file")
	flag.Parse()
	if err := godotenv.Load(dotenv); err != nil {
		log.Print(err)
	}
	if err := env.Parse(&config); err != nil {
		log.Fatal(err)
	}
}

func subscribe(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodPost {
		http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
		return
	}
	b, err := io.ReadAll(r.Body)
	if err != nil {
		http.Error(w, "Error reading request body", http.StatusInternalServerError)
		return
	}

	hasher := sha1.New()
	hasher.Write(b)
	sha := base64.URLEncoding.EncodeToString(hasher.Sum(nil))

	fp, err := os.Create(filepath.Join("subscribes", sha+".json"))
	if err != nil {
		http.Error(w, "Error creating file", http.StatusInternalServerError)
		return
	}
	defer fp.Close()
	if _, err := fp.Write(b); err != nil {
		http.Error(w, "Error writing file", http.StatusInternalServerError)
		return
	}
	w.Header().Add("Content-Type", "application/json")
	if err := json.NewEncoder(w).Encode(map[string]any{
		"error": nil,
	}); err != nil {
		http.Error(w, "Error encoding response", http.StatusInternalServerError)
	}
}

func notify(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodPost {
		http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
		return
	}
	clientID, clientSecret, ok := r.BasicAuth()
	if !ok || clientID != config.ClientID || clientSecret != config.ClientSecret {
		http.Error(w, "Unauthorized", http.StatusUnauthorized)
		return
	}

	b, err := io.ReadAll(r.Body)
	if err != nil {
		http.Error(w, "Error reading request body", http.StatusInternalServerError)
		return
	}
	log.Println("push:", string(b))
	if err := fs.WalkDir(os.DirFS("subscribes"), ".", func(path string, d fs.DirEntry, err error) error {
		if err != nil {
			return err
		}
		if d.IsDir() {
			return nil
		}
		fp, err := os.Open(filepath.Join("subscribes", path))
		if err != nil {
			return err
		}
		defer fp.Close()
		var s *webpush.Subscription
		if err := json.NewDecoder(fp).Decode(&s); err != nil {
			return err
		}
		resp, err := webpush.SendNotificationWithContext(r.Context(), b, s, &webpush.Options{
			Subscriber:      config.Subscriber,
			VAPIDPublicKey:  config.VAPIDPublicKey,
			VAPIDPrivateKey: config.VAPIDPrivateKey,
			TTL:             30,
		})
		if err != nil {
			return err
		}
		log.Println(resp)
		return nil
	}); err != nil {
		log.Println(err)
	}
}
func logger(h http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		log.Printf("%s %s %s", r.RemoteAddr, r.Method, r.URL)
		h.ServeHTTP(w, r)
	})
}

func main() {
	os.MkdirAll("subscribes", 0755)
	sub, err := fs.Sub(assets, "build")
	if err != nil {
		log.Fatal(err)
	}
	http.Handle("/", http.FileServer(http.FS(sub)))
	http.HandleFunc("/subscribe", subscribe)
	http.HandleFunc("/notify", notify)
	log.Fatal(http.ListenAndServe(":8080", logger(http.DefaultServeMux)))
}

サービスワーカーの通知ハンドラと通知クリックハンドラ

  • 基本通知の内容をshowNotificationというデスクトップ通知APIに丸投げ。
  • クリックされた場合、通知のdataにURLがあればそれを別タブで開く(既に開かれたタブがあるならそこのフォーカスをアクティブにする)
service-worker.js
self.addEventListener("push", (event) => {
  if (!event.data) {
    return;
  }
  let data = event.data.json();
  console.log(`[Service Worker] Push Receive data: "${JSON.stringify(data)}"`);
  let options = {
    icon: data.icon || "./favicon.png",
    body: data.body,
    tag: data.tag,
    data: data.data,
    badge: data.badge,
    requireInteraction: data.requireInteraction || false,
  };
  if (data.actions) {
    options.actions = data.actions;
  }
  if (data.lang) {
    options.lang = data.lang;
  }
  if (data.image) {
    options.image = data.image;
  }
  if (data.silent) {
    options.silent = data.silent;
  } else {
    if (data.vibrate) {
      options.vibrate = data.vibrate;
    }
  }
  event.waitUntil(
    self.registration.showNotification(
      data.title || "Push Notification",
      options
    )
  );
});
self.addEventListener("notificationclick", (event) => {
  console.log(event);
  let url = event.notification.data || location.origin + "/info.html";
  event.notification.close(); // Android needs explicit close.
  event.waitUntil(
    clients.matchAll({ type: "window" }).then((windowClients) => {
      // Check if there is already a window/tab open with the target URL
      for (var i = 0; i < windowClients.length; i++) {
        var client = windowClients[i];
        // If so, just focus it.
        if (client.url === url && "focus" in client) {
          return client.focus();
        }
      }
      // If not, then open the target URL in a new window/tab.
      if (clients.openWindow) {
        return clients.openWindow(url);
      }
    })
  );
});

手元にてアプリを起動する

VAPIDキー生成は以下のコードを「go run ./genkey.go」にて実行。

genkey.go
package main

import (
	"log"

	"github.com/SherClockHolmes/webpush-go"
)

func main() {
	// Generate vapid keys
	vapidPrivateKey, vapidPublicKey, err := webpush.GenerateVAPIDKeys()
	if err != nil {
		log.Fatal(err)
	}
	log.Println("VAPID private key:", vapidPrivateKey)
	log.Println("VAPID public key:", vapidPublicKey)
}

得られた情報を埋めつつ、
以下のようなファイルを用意しておき・・・。
ClientIDとClientSecretは通知API用のBASIC認証ID/Passwordです。

.env
Subscriber=example@example.com
VAPIDPrivateKey=###########################################
VAPIDPublicKey=#############################-###########################-#############################
ClientID=####
ClientSecret=########

hub.docker.comに公開してあるイメージを以下のコマンドで起動できます。

docker run --name webpush-demo -it --rm -p 8080:8080 -v subscribes:/app/subscribes --env-file=.env nobonobo/webpush-demo

トップページを開く

http://localhost:8080
を開きます。

「Subscribe!」をクリック!

URLバーにベルマークに斜線の入ったアイコンがある場合、それをクリックして許可しておきます。

通知を投げてみる

echo  '{"body":"ほげ","data":"https://google.com/"}' | curl http://<client-id>:<client-secret>@localhost:8080/notify -
d @-

これで通知が表示されるはず。

通知をクリックするとdataに指定したURLが開くようにしてあります。
これブラウザを閉じてても通知が表示されるの結構感動する。

iOSの場合

トップページ表示をSafariで開いたのち、共有ボタンから「ホーム画面に追加」します。
追加したアイコンから開きなおして、「Subscribe!」をタップすると、
ダイアログでこのApp通知の許可を求めてきますので許可してください。

上の通知を投げてみるとiOSでも通知が届きます。

疑問点

  • これ、レートリミットに関する情報がない。大量に送っても大丈夫なんだろうか?
  • 無効になったサブスクリプションは通知のタイミングのエラー情報でわかるのかな?
  • 端末の電源オフのためにリーチしなかった場合と区別はつく?
  • 無効だと分かったサブスクリプションを削除したほうがよさそう?
  • 表示のされ方はOSによって微妙に異なるっぽい?
    • iOSはPWAマニフェストにあるアイコンが常に表示される(通知に指定するiconやimageは使われない)
  • 通知のdataフィールドにURL文字列入れる想定にしちゃったけど一般的にはここはObjectを入れる?

まとめ

  • 疑問点はいろいろ試してわかったら追記します
  • iOS16.4以降で通知を送れるようになったのは感慨深い
  • 通知をAPIで叩けるようにしてしまったけど、これは別の仕組みでもOK(CLIとかでも)
  • メール通知のかわりになるかというとメール通知もプッシュ通知もどちらもスルーして流れやすい
  • しかし、メールに比べプッシュ通知欄は過去へのさかのぼり機能が弱く、消してしまうと再参照がほぼ無理
  • やはりタイムリーに必要な情報をお知らせするために使うべきかな
  • もしくはWebアプリ側にユーザー認証と通知履歴を表示する機能があればカバーできるかも

Discussion