📄

ヘッドレスブラウザを活用して少しリッチなスタイルのPDFを生成する

2022/09/07に公開2

自社Webサービス上にボタンを配置して、PDFをダウンロードできるようにしたいという要望がありました。しかも

  • 表などのリッチな表現を活用したい
  • 動的に表示する内容が変化し、内容に応じてページ送りをしたい
  • これらは自社Webサービス上のページとしても表示している(しかも認証が必要なページ)

という条件でした。react-pdfhtml-pdf を使えばできなくはなさそうですが、できるだけスタイルを楽に・デバッグをかんたんに行ないたかったので、別の方法を考えました。

そう、みんな大好きヘッドレスブラウザを活用した方法です。この手の課題のときにはよく手段として上がりますよね。@media print などを使って印刷用に表示する内容の調整もできるし、サービス上のコンポーネントを使ってスタイルできるので非常にかんたんにPDFのコンテンツのスタイルが行なえます。

ただ、問題は認証です。Webサービスの認証方法としてID/Passwordでのログインを提供しており、かつどのデータでも見れるAdminユーザーみたいなアカウントが存在していれば、ヘッドレスブラウザを操作してログインすることは可能です。しかし、Adminユーザーがない場合やGoogleログインなどの外部IdPでのログインの場合はかなり難しくなります。これをどう突破しようか少し頭を悩ませてなんとか解決したので、その方法とともにPDF生成方法を紹介します。

TL;DR

  1. サーバー側でヘッドレスブラウザを起動し対象のサービスへアクセス
  2. Firebase AuthのAdmin SDKでCustom Tokenを生成する
  3. 自社Webサービス側にCustom Tokenを受け付けるGlobal関数を準備する
  4. 生成したCustom Tokenをヘッドレスブラウザから3の関数に渡してログイン状態にする
  5. PDfを生成したいページにアクセスし、PDFを生成する

PDFの生成

今回はPlaywrightをヘッドレスブラウザとして利用し、TypeScriptで書いていきます。

まず、サーバー側で playwright-chromium インストールします。chromiumだけでよいのでこうしていますが、playwrightでも問題ありません。また、puppeteerでも同様のことは可能です。

npm i playwright-chromium

PDFの生成自体はとても簡単です。サーバー側で以下を実行します。

import playwright from 'playwright-chromium'

const browser = await playwright.chromium.launch({ headless: true })
const context = await browser.newContext()
const page = await context.newPage()
await page.goto('対象のURL', { waitUntil: 'networkidle または commit'})
await page.pdf({ format: 'A4', path: 'local/path/to/file.pdf' })

たったこれだけで、マシンのChromeで印刷ボタンを押して得られるPDFが生成できます。本当に素晴らしい技術ですね。ただし、日本語フォントがイマイチの場合は工夫が必要になります。対象のページがWebフォントを使っている場合は非常にかんたんで、ロードしてくれるのでフォントもいい感じになりますが、そうでない場合はブラウザにフォントを読み込ませる対応が別途必要です(今回は趣旨から外れるので説明しません)。

認証を突破する

ID/Passwordでのログインが可能な場合は、ヘッドレスブラウザを操作してフォーム入力を行いログイン状態を作りましょう。今回のケースでは不可能な場合だったので以下の手順で突破します。

FirebaseのAdmin SDKを使って、このページが見れるユーザーのCustom Tokenを生成します。どのデータでも見れる管理ユーザーみたいなのを用意しておくか、なんとなく気持ち悪い感じがしますがこのページが見れるユーザーのCustom Tokenを生成します。

参考)Custom Tokenについて
https://firebase.google.com/docs/auth/admin/create-custom-tokens

サーバー側で以下を実行すればCustom Tokenを生成できます。

import * as admin from 'firebase-admin'

const uid = '対象のページが見れるユーザーのID'
const customToken = await admin.auth().createCustomToken(uid)

次にWebサービス側に、このカスタムトークンを使ってログイン状態を作れる関数を用意します。

import { signInWithCustomToken, getAuth } from 'firebase/auth'
// Firebaseを初期化してappを得る
const auth = getAuth(app)

// 関数名は適宜変えてください
window.signIn = (token: string) => {
  return signInWithCustomToken(auth, token)
}

この signIn 関数をヘッドレスブラウザ上で実行するために、サーバー側のコードを以下のようにします。

// ...さっきの続き
const page = await context.newPage()
const customToken = await admin.auth().createCustomToken(uid)

await page.evaluate((passedToken) => {
  const _window = window as any
  return _window.signIn(passedToken)
}, customToken)

これでログイン状態が作れました。あとは対象のページに goto して pdf を呼び、PDFを生成すれば完了です。

Custom Tokenを渡せばサインインできてしまう関数を公開するのは危険なのでは?という疑問はあるかもしれません。私の考えでは、そもそもFirebaseはクライアント側にappId等の情報が埋まっており、抜き取ることは容易です。それらを使えばCustomTokenを渡せばログイン状態を作りID Tokenを得ることができる状態を作り出せます。これらの手間なくログイン試行ができるという点ではよくありませんが、そもそもCustom Tokenを特定されなければ問題はなく、Custom Tokenを生成するにはAdminのCredentialが必要になるため、リスクは少ないと考えました。何か私が思い当たっていないセキュリティリスクがあればご教授いただけると助かります。

Discussion

clientver2clientver2

mogaさん、記事の方ありがとうございます。大変参考になります。
丁度私の方もplaywright かpuppeteer を使用してpdf生成させようとしているのですが、私の方はオンプレのシステムになります。アプリケーションのインストーラーにchromium 同梱させて、それ使ってアプリからpdf生成させようかと思うのですが、chromium ってライセンス的に同梱オッケーかどうかご存知ないでしょうか。ライセンス見た感じ大丈夫だとは思うのですが。