feather for Mastodonのプッシュ通知のバックエンドの紹介

feather for Mastodonは、Mastodonサーバーからのプッシュ通知をユーザーの端末に届ける機能があります。

Twitter版の頃は搭載が難しかった機能ですがMastodon版では提供することが可能になりました。

この記事では、プッシュ通知の一覧の流れとバックエンドの一部であるRelay Serverの仕組みを紹介します。

インフラストラクチャのフロー:

通知の送信フローは以下のようになります。

  1. MastodonのサーバーにユーザーがWeb Pushの購読を登録する
  2. Mastodonのサーバーでイベント(例: お気に入り)が発生します。
  3. MastodonのサーバーからWeb PushがRelay Serverに向けて送信されます。
  4. Relay ServerがWeb Pushを受け取り、FCMの形式に変換して再送信します。
  5. FCMによって通知がAPNsに変換され、ユーザーの端末にプッシュ通知が届きます。
  6. 暗号化されたWeb Pushの内容が端末で解読されます。
  7. 暗号解読が完了すると、ユーザーに通知が表示されます。

Relay Serverの選択と実装:

Relay Serverは、Google Cloud PlatformのCloud Run上で実行されています。この選択は、デプロイとスケーリングの容易さから来ています。また、コスト面でも現時点で問題ないレベルで推移しています。

また実行環境にfirebase functionsも検討していますが こちら の理由で断念しました。

リレーサーバーの実装には、node.jsのフレームワークであるfastifyを採用しています。express.jsではなくfastifyを選んだ理由は、速度の面での利点が見込まれたためです。しかし、実際には、FCMへのリクエストを行う部分が処理時間の大部分を占めているため、フレームワークの選択はあまり影響を与えていないと考えています。

Cloud Runでのfastifyの利用:

fastifyをCloud Runで利用する際には、いくつかの設定が必要です。以下は、その設定の一例です。

// index.ts
// cloud run の実行に必要な設定
const IS_GOOGLE_CLOUD_RUN = process.env.K_SERVICE !== undefined
const host = IS_GOOGLE_CLOUD_RUN ? "0.0.0.0" : undefined
// Run the server
try {
  const port = parseInt(process.env.PORT || "3000", 10)
  const address = await fastify.listen({ port, host })
  console.log(`Listening on ${address}`)
} catch (err) {
  fastify.log.error(err)
  process.exit(1)
}

また、WebPushはRawなデータが飛んでくるので、fastify.addContentTypeParserを利用して"application/octet-stream"のデータを処理できるように設定します。

// server.ts
fastify.addContentTypeParser(
    "application/octet-stream",
    { parseAs: "buffer" },
    (_, body, done) => {
      done(null, body)
    },
  )

リレーしている処理は以下のようになっています。来たリクエストをFCMに送信できる形式にして渡しているだけです。

※この段階では暗号解読に必要な情報がリクエストには含まれていないのでユーザーのプライバシーは保護されています。

type MessageData = {
  p: string
  k: string
  s: string
  e?: string
}

const relay: RouteHandlerMethod<
  RawServerDefault,
  RawRequestDefaultExpression,
  RawReplyDefaultExpression,
  {
    Querystring: { env?: string }
    Params: { fcmToken: string }
    Body: Buffer
  }
> = async function handler(request, reply) {
  if (request.headers["content-encoding"] !== "aesgcm") {
    request.log.error(
      `Invalid content-encoding: ${request.headers["content-encoding"]}`,
    )
    reply.code(400)
    return { message: "Invalid content-encoding" }
  }

  if (request.headers["content-type"] !== "application/octet-stream") {
    request.log.error(
      `Invalid content-type: ${request.headers["content-type"]}`,
    )
    reply.code(400)
    return { message: "Invalid content-type" }
  }

  const token = request.params.fcmToken
  const payload = request.body.toString("base64url")
  const publicKey = getHeaderDecodedValue("crypto-key", request.headers)
  if (!publicKey) {
    request.log.error("crypto-key is not found")
    reply.code(400)
    return { message: "crypto-key is not found" }
  }
  const salt = getHeaderDecodedValue("encryption", request.headers)
  if (!salt) {
    request.log.error("encryption is not found")
    reply.code(400)
    return { message: "encryption is not found" }
  }
  const data: MessageData = {
    // payload base64
    p: payload,
    // publick key
    k: publicKey,
    // salt
    s: salt,
  }
  if (request.query.env) {
    data.e = request.query.env
  }
  const message: Message = {
    data,
    notification: {
      title: "Notification",
    },
    apns: {
      payload: {
        aps: {
          // NotificationServiceExtensionを使うのでtrueにする
          mutableContent: true,
        },
      },
    },
    token,
  }
  // FCMに送信
  const messageID = await sendMessage(message)
  request.log.info({ ...message, messageID }, "success send message to FCM")
  // Web Pushのレスポンスは201
  reply.code(201)
  return { message: "success" }
}

まとめ:

ざっくりですが以上が feather のプッシュ通知のバックエンドの仕組みです。