🧐

microCMSで下書きをプレビューする際にdraftKeyを検証する

2023/10/19に公開

microCMS のコンテンツ取得 API(GET /api/v1/{endpoint}/{content_id})では、draftKey={draftKey}のクエリを付けてリクエストすることでコンテンツの下書きデータを取得できます。

https://document.microcms.io/content-api/get-content

ちょっと悩ましい仕様なのが、誤った draftKey を渡してもエラーにはならないことです。
draftKey を指定していない場合と同じレスポンス、つまり公開中のコンテンツが返ってきます。コンテンツが未公開の場合は 404 が返ってきます。
(個人的には誤った draftKey でリクエストした際には「400 Bad Request」エラーを返して欲しいところです。)

今回は私たちが Web メディアの開発を行う上でこの API 仕様に少し困ったことと、その対策として下書きコンテンツを取得する前に draftKey を検証する処理を挟んだことを説明します。
処理内容だけ知りたい方は後半まで読み飛ばしてください。

誤った draftKey を渡してもエラーにならない

今回私たちは microCMS + Next.js (App Router) の構成で Web メディアを開発しました。
Next.js のキャッシュ仕様や機能要件[1][2]を考慮して、下記のように通常の記事ページと下書き記事のプレビューページを分けることにしました。

  • /articles/{content_id}
  • /articles/{content_id}/draft?draftKey={draftKey}

プレビューページの実装はこのようになっています。

app/articles/[articleId]/draft/page.tsx
// 動的レンダリングさせる
// コンポーネント内でsearchParamsを参照しているため自動で動的レンダリングになるが、念の為明示的に指定しておく
export const dynamic = "force-dynamic";

// 下書きプレビューページは検索エンジンにインデックスさせない
export const metadata = {
  robots: "noindex",
};

export default async function ArticleDraftPage({ searchParams, params }) {
  const { articleId } = params;
  const { draftKey } = searchParams;

  // microcms-js-sdk
  const client = createClient(/* ... */);

  const article = await client.getListDetail({
    endpoint: "articles",
    contentId: articleId,
    queries: {
      draftKey,
    },
  });

  return (
    <>
      {/* 「下書きを表示しています」 のような警告を画面上に表示する */}
      <PreviewWarning />

      { /* 記事ページでも使っているコンポーネント */ }
      <Article article={article} />
    </>
  );
}

これで下書きのプレビューができるようになりましたが、冒頭で説明したように microCMS のコンテンツ API に誤った draftKey を渡してもエラーにはなりません。渡されていない時と同じ挙動になります。

つまり誤った draftKey でプレビューページ(/articles/{content_id}/draft?draftKey={誤ったdraftKey})にアクセスすると、記事ページ(/articles/{content_id})にアクセスした時と同じデータが表示されるわけです。
加えてPreviewWarningコンポーネントにより「下書きを表示しています」の警告も表示されます。

このままの仕様でリリースしてしまうと、一般ユーザーが興味本位で/articles/{content_id}/draft?draftKey={適当な文字列}にアクセスすると、見えているのは公開済みのコンテンツですが「下書きを表示しています」の警告は出るわけで、「ハックできたった www」みたいになることが予想できます。親切な方なら不具合報告してくれるかも知れません。
編集者も誤った draftKey でプレビューページにアクセスしてしまった場合、「記事を編集してるのに何度リロードしても反映されないぞ?」となり、クライアントに共有するプレビューページが本当に下書きのプレビューを表示できているか分かりません。
誰でも下書きが見れてしまうとの勘違いからセキュリティを不安に思われることも考えられます。

このように各所から問い合わせが来てしまうことが予想されるため、対策が必要です。
思いついたのはプレビューを表示する前に draftKey を検証し、draftKey が不正なものであればエラーを出すことでした。
調べたところ、ベータ版として公開されているマネジメント API を使うことで draftKey を検証できることが分かりました。

https://document.microcms.io/management-api/introduction

draftKey を検証する

ベータ版として公開されているマネジメント API のGET /api/v1/contents/{endpoint}/{content_id}を使えばコンテンツの draftKey を取得できます。

https://document.microcms.io/management-api/get-content

ベータ版ではありますが、今回の Web メディアでは下書き記事のプレビューページには編集者等の一部の関係者しかアクセスしないため利用することにしました。

レスポンスはこのようになります。

{
    "id": "contentId",
    // DRAFT or PUBLISH or CLOSED or PUBLISH_AND_DRAFT
    "status": [
      "DRAFT"
    ],
    //コンテンツの公開時はnullが入る
    "draftKey": "draftKey",

    ... // 関係ないフィールドは省略
}

レスポンスのdraftKeyを使えば「マネジメント API で取得できた正常な draftKey」と「クエリで渡された draftKey」を比較して検証することができます。

作った関数がこちら:

helpers/validate-draft-key.ts
import { timingSafeEqual } from "node:crypto";

export async function validateDraftKey({ endpoint, contentId, draftKey }): Promise<boolean> {
  if (!draftKey) {
    return false;
  }

  const res = await fetch(
    `https://${SERVICE_ID}.microcms-management.io/api/v1/contents/${endpoint}/${contentId}`
  );

  if (!res.ok) {
    throw new Error("...");
  }

  const body = await res.json();

  return timingSafeEqual(body.draftKey, draftKey);
}

この関数を使ってコンポーネントの中で検証を行います。

app/articles/[articleId]/draft/page.tsx
export default async function ArticleDraftPage({ searchParams, params }) {
  const { articleId } = params;
  const { draftKey } = searchParams;

  const valid = await validateDraftKey({
    endpoint: "articles",
    contentId: articleId,
    draftKey,
  });

  if (!valid) {
    return <p>error: draft key is invalid.</p>;
  }

  // ...

これで誤った draftKey と共にプレビューページにアクセスされた際には "error: draft key is invalid." と表示されるようになりました。

注意:API の不具合とワークアラウンド

2023/11/07 修正されました

microCMS のサポートから修正完了の連絡を受け取りました!
3 週間ほどで対応してもらえました。

起こっていた不具合の詳細

実際に組み込んで動作確認を行なっている最中にこのマネジメント API の不具合に遭遇してしまいました。

未公開のコンテンツの情報を取得すると、本来は値が入っているべきのdraftKeyのフィールドがnullで返ってきてしまいます。

{
    "id": "contentId",
    "status": [
      "DRAFT"
    ],
    "draftKey": null, // ← コンテンツが下書き状態なので本来はdraftKeyの値が入っているべき

    ... // 関係ないフィールドは省略
}

そのため公開前のコンテンツをプレビューしようとすると、先ほどの検証処理が失敗して "error: draft key is invalid." のエラーが表示されてしまいます。

この API の問題は microCMS にも報告済みで、不具合として認識されました。ベータ版の API であるため不具合があってもおかしくはないですし、修正を待ちます。

また、今回の API の用途であれば、この不具合は次に紹介するワークアラウンドを取ることで回避することができます。

ワークアラウンド

取った対応の詳細

この不具合は「記事が未公開("status": ["DRAFT"])の時」に起こります。
また冒頭で説明したように、「コンテンツ取得 API に誤った draftKey を渡した際は公開済みコンテンツのデータが返ってくる」し、「コンテンツが未公開の場合は 404」になります。

上記の条件が見事に噛み合って、今回のケースではvalidateDraftKey関数を「コンテンツが未公開の時は検証をスキップして true を返す」ように一時的に仕様変更することで、未公開記事の下書きプレビューができない問題を回避できるようになりました。

未公開のコンテンツの検証をスキップして true を返しても、draftKey が誤っている場合はコンテンツ取得 API が 404 になるので、下書きプレビューページはエラーになり記事が表示されることはありません。
microCMS が API を修正するまでの間、validateDraftKeyに下記のパッチを適用して待つことにしました。

helpers/validate-draft-key.ts
import { timingSafeEqual } from "node:crypto";

export async function validateDraftKey({ endpoint, contentId, draftKey }): Promise<boolean> {
  if (!draftKey) {
    return false;
  }

  const res = await fetch(
    `https://${SERVICE_ID}.microcms-management.io/api/v1/contents/${endpoint}/${contentId}`
  );

  if (!res.ok) {
    throw new Error("...");
  }

  const body = await res.json();

+  // コンテンツが未公開の際にdraftKeyがnullで返ってきてしまう不具合への暫定対応
+  // https://zenn.dev/alphadrive/articles/2023-10-validate-microcms-draft-key
+  if (body.status.length === 1 && body.status[0] === "DRAFT" && body.draftKey === null) {
+    return true;
+  }

  return timingSafeEqual(body.draftKey, draftKey);
}

まとめ

  • microCMS でパブリックなプレビューページを作る際は draftKey を検証した方が良い
  • マネジメント API の draftKey フィールドには不具合があるため、使う際は注意
    • 今後の修正をウォッチする必要がある
  • (microCMS に限らず)ベータ版の API を使う際は不具合に遭遇することを頭に入れておく

最後に

AlphaDrive では toB SaaS や Web メディアの開発・運用を行っており、一緒にフロントエンドを開発してくれる方を募集しています。
カジュアル面談もやっています。みなさまのご応募をお待ちしております!

https://www.wantedly.com/projects/891500

脚注
  1. 下書き状態の記事をクライアントに確認してもらうケースがあるので、プレビューページの URL を共有するだけで確認できるようにしたい。 ↩︎

  2. プレビューページの URL を共有する際に他の記事の下書きが見えてはいけない。(=Next.js の Draft Modeは使い辛い) ↩︎

GitHubで編集を提案

Discussion