パスワード保護された投稿を表示する

中級 約10分 最終更新 2026-10-01 SDK 0.6+

管理画面で投稿ごとに「閲覧パスワード」を設定すると、公開サイトの訪問者は正しいパスワードを入力したときだけ本文を読めます。この保護された投稿を公開サイト側でどう扱うか——保護の見分け方、本文がゲートされた get() の挙動、unlock() による本文取得、そして静的サイト(SSG)での実装までをまとめます。

前提:この機能は、サイト設定で「投稿のパスワード保護」をONにし、かつ投稿ごとにパスワードを設定したときだけ有効になります。公開サイト側は is_password_protected を見るだけでよく、サイト設定の状態はAPIが織り込み済みです(保護OFFのサイトでは常に false で、本文はそのまま返ります)。unlock() は SDK 0.6 以上が必要です。

1. 保護の有無を知る

投稿の要約(一覧の各項目)・詳細の両方に is_password_protected(真偽値)が入ります。 true の投稿は本文がゲートされており、鍵アイコンを出すなどの表示に使えます。タイトル・カテゴリ・アイキャッチなどのメタ情報は保護対象ではなく、そのまま返ります(一覧でタイトルを隠す必要はありません)。

src/pages/news/index.astro
---
import { cms } from '../../lib/cms';
const { items } = await cms.posts.list('news', { perPage: 20 });
---
<ul>
  {items.map((p) => (
    <li>
      <a href={`/news/${p.slug}`}>{p.title}</a>
      {p.is_password_protected && <span aria-label="パスワード保護">🔒</span>}
    </li>
  ))}
</ul>

2. 本文は get() で返らない

保護中の投稿を cms.posts.get() で取得すると、本文(body)は null で返ります(related_urls や meta は返ります)。SDK 0.6 で PostDetail.body の型は ProseMirrorNode | null になりました。renderProseMirror() に渡す前に null チェックしてください。

src/lib/post.ts
const post = await cms.posts.get('news', slug);

if (post.body === null) {
  // 保護中でパスワード未照合。本文は返っていない。
  // → is_password_protected は true。パスワード入力フォームを表示する。
} else {
  // 保護なし(またはサイト設定で保護OFF)。本文はそのまま使える。
  const html = renderProseMirror(post.body);
}

3. unlock() で本文を取得する

本文は cms.posts.unlock(postType, slug, password) に正しい閲覧パスワードを送ったときだけ返ります(本文入りの PostDetail)。パスワード不一致は 401(isUnauthorized・E001006)、試行回数超過は 429(isRateLimited・E001007・retryAfter)、未公開・未存在は 404(isNotFound)です。応答はキャッシュされません(Cache-Control: no-store)。多言語オプション有効時は第4引数に任意の locale を渡すと、本文も個別取得と同じ言語の公開版で返ります(多言語サイトを作る)。

src/lib/reveal-body.ts
import { ShareDanCmsApiError } from '@sharedan/cms-sdk';
import { renderProseMirror } from '@sharedan/cms-sdk';

async function revealBody(slug: string, password: string): Promise<string> {
  try {
    const post = await cms.posts.unlock('news', slug, password);
    // 照合成功。body が入って返る。
    return post.body ? renderProseMirror(post.body) : '';
  } catch (err) {
    if (err instanceof ShareDanCmsApiError) {
      if (err.isUnauthorized) throw new Error('パスワードが違います');          // 401 E001006
      if (err.isRateLimited) throw new Error(`しばらく待ってから再試行してください(${err.retryAfter ?? '数分'}秒)`); // 429 E001007
      if (err.isNotFound) throw new Error('記事が見つかりません');               // 404
    }
    throw err;
  }
}
総当たり対策:照合は「サイト+種別+slug+閲覧者IP」単位で試行回数が制限されます。超過すると 429 が返り、Retry-After に待機秒数が入ります。入力フォームでは、連投を抑え、429 のときは retryAfter を見てメッセージを出しましょう。

4. 静的サイト(SSG)での実装

SSG では、保護投稿の本文をビルド時に静的HTMLへ焼けません(焼いたらソースに本文が露出してしまいます)。そこでビルド時は「ゲート(パスワード入力フォーム)」だけを出力し、本文は閲覧者の入力を受けてブラウザから実行時に unlock() を呼ぶ構成にします。

src/pages/news/[slug].astro
---
// src/pages/news/[slug].astro(SSG・ビルド時)
import { cms } from '../../lib/cms';
import { renderProseMirror } from '@sharedan/cms-sdk';

export async function getStaticPaths() {
  const { items } = await cms.posts.list('news', { perPage: 100 });
  return items.map((p) => ({ params: { slug: p.slug }, props: { post: p } }));
}

const { post } = Astro.props;               // 一覧由来=本文は無い(軽量)
const detail = await cms.posts.get('news', post.slug);
const locked = detail.body === null;        // 保護中=ビルド時に本文を焼けない
const html = locked ? '' : renderProseMirror(detail.body!);
---
<article>
  <h1>{detail.title}</h1>
  {locked ? (
    <form data-unlock data-slug={post.slug}>
      <input type="password" name="password" autocomplete="off" required />
      <button type="submit">本文を表示</button>
      <p data-unlock-error hidden></p>
    </form>
  ) : (
    <div set:html={html} />
  )}
  <div data-unlock-body></div>
</article>

ブラウザから公開APIを直接叩くと読み取り用APIキーが露出します。問い合わせフォーム連携と同じく、同一オリジンの薄いプロキシ(cms-proxy.php)を経由し、鍵の付与はサーバー側に任せます。プロキシは POST .../unlock と Retry-After の透過に対応しています。

src/pages/news/[slug].astro(続き)
<script>
  import { ShareDanCmsClient, ShareDanCmsApiError, renderProseMirror } from '@sharedan/cms-sdk';

  // 鍵はブラウザに出さない。同一オリジンのプロキシ(cms-proxy.php)がサーバー側で付与する。
  const cms = new ShareDanCmsClient({
    baseUrl: `${location.origin}/cms-proxy.php`,
    apiKey: 'via-proxy',
    siteCode: 'via-proxy',
  });

  document.querySelector('[data-unlock]')?.addEventListener('submit', async (e) => {
    e.preventDefault();
    const form = e.currentTarget as HTMLFormElement;
    const slug = form.dataset.slug!;
    const password = new FormData(form).get('password') as string;
    const err = form.querySelector('[data-unlock-error]') as HTMLElement;
    try {
      const post = await cms.posts.unlock('news', slug, password);
      const html = post.body ? renderProseMirror(post.body) : '';
      document.querySelector('[data-unlock-body]')!.innerHTML = html;
      form.remove();
    } catch (ex) {
      err.hidden = false;
      err.textContent =
        ex instanceof ShareDanCmsApiError && ex.isRateLimited
          ? '試行回数が多すぎます。しばらく待ってください。'
          : 'パスワードが違います。';
    }
  });
</script>
ヒント:保護投稿は「存在はするが本文はゲート」です。getStaticPaths で URL は生成しておき、本文だけを実行時に出す——この分離で、SSGの速さを保ったまま限定公開を実現できます。

5. 注意点

  • 本文をキャッシュに載せない:unlock() の応答は no-store です。取得した本文(HTML)を自前のCDNやエッジにキャッシュしないでください。
  • 検索エンジン・SSR:保護本文はビルド時に出力されないため、インデックスもされません。限定公開の記事はこれが期待挙動です。
  • サイト設定でOFFにすると保護は無効化されます:その間 is_password_protected は false になり、本文はパスワードなしで返ります(設定したパスワード自体はCMSに保持され、再度ONにすると復活します)。
  • パスワードは機密情報ではありません:閲覧パスワードは「限定公開のための軽い鍵」です。機微情報の保護には、APIキー保護や会員認証など、別の仕組みを使ってください。
  • 多言語オプションでは本文も訳される:unlock() も get() と同じく任意の locale(第4引数)を受け取り、個別取得と同じ言語を渡すと、ゲートされた本文も指定言語の公開版で返り、タイトルと本文の言語が揃います。未訳の本文は原文(日本語)を返し translation_available: false を含めます(多言語サイトを作る)。
FEEDBACK

このページはお役に立ちましたか?

改善要望・不具合・ドキュメントの分かりにくい点など、お気軽にお寄せください。いただいた内容は今後の改善に役立てます。

返信をご希望の場合のみご記入ください。

スパム対策(レートリミット・ハニーポット・送信速度チェック)はサーバ側で自動適用されます。