a-blog cmsで、エントリーにパスワードを設定する

a-blog cms3.1がリリースされて、登録会員限定のエントリーの作成が以前よりも容易になりました。

https://developer.a-blogcms.jp/blog/changelog/v301-member.html

ただ、それでも会員登録は必要になるので、WordPressのようにカジュアルにエントリーにパスワードを設定するにはカスタマイズが必要です。
今回は、a-blog cms付属の公式テーマ「utsuwa」を元に、エントリーにパスワードをかけるカスタマイズを試してみました。

https://github.com/appleple/acms-utsuwa

この記事で作成、修正を行なったファイルです。
参考にご覧ください。

https://github.com/kadoyan/acms-Entry-Password

実用にあたっては、パスワードをハッシュ化するなどの対策を検討してください。

## utsuwaテーマを継承して子テーマを作る
直接utsuwaテーマの中身を直接修正してもいいのですが、変更箇所がわかりにくくなり、何か問題あった場合に原因を突き止めるのが難しくなります。
そこで、元のファイルを残したまま修正を加えられるよう、utsuwaテーマを継承して子テーマ`password@utsuwa`を作成します。

https://developer.a-blogcms.jp/document/themes/inherit.html

CMSのインストールディレクトリーにある`themes`ディレクトリーに`password@utsuwa`というディレクトリーを作成してください。
次に、修正を加えるファイルを、utsuwaテーマから子テーマのディレクトリーにコピーしておきます。

- /admin/entry/field.html
- /include/entry/body.html

子テーマにあるファイルは親テーマのものより優先して読み込まれるため、差分のみを子テーマに持っておくことで、親テーマを変更することなくカスタマイズが可能です。
以下、基本的にこのディレクトリー内での作業になります。

子テーマのディレクトリーを作成したら、テーマを「password@utsuwa」に変更しておきます。

https://developer.a-blogcms.jp/document/themes/config.html

## パスワードのカスタムフィールドを追加
パスワードがエントリーのカスタムフィールドとして保存されるようにしたいため、エントリーのカスタムフィールドを設定するファイル`/admin/entry/field.html`を修正して、パスワードの入力欄を追加します。

```html:/admin/entry/field.html
<!-- SEOの設定 -->
@include("/admin/entry/field-seo.html")

<!-- URLコンテキスト別 -->
@include("/admin/entry/bcd/%{BCD}.html")
@include("/admin/entry/rccd/%{RCCD}.html")
@include("/admin/entry/ccd/%{CCD}.html")

<!-- パスワード設定 -->
@include("/admin/entry/password.html")
```
読み込むファイル`/admin/entry/password.html`を作成。
カスタムフィールドのコード作成は、カスタムフィールドメーカーが便利です。

https://developer.a-blogcms.jp/tools/

```html:/admin/entry/password.html
<!-- パスワード欄 -->
<table class="acms-admin-table-admin-edit">
	<tr>
		<th>エントリーのパスワード</th>
		<td>
			<input type="text" name="entry_password" value="{entry_password}" class="acms-admin-form-width-full" />
			<input type="hidden" name="field[]" value="entry_password" />
			<input type="hidden" name="entry_password:v#regex" value="^[a-zA-Z0-9]*$" id="entry_password-v-regex" />
			<!-- BEGIN entry_password:validator#regex -->
			<p class="acms-admin-text-error">半角英数を設定してください</p>
			<!-- END entry_password:validator#regex -->
			<input type="hidden" name="entry_password:c" value="a" />
		</td>
	</tr>
</table>
```

![スクリーンショット 2023-12-07 16.33.15.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/29093/f0b73fdc-15c3-27da-354c-deec4c3a55bc.png)

これで、エントリーの編集画面に「エントリーのパスワード」欄が追加されました。

## エントリー用テンプレートを修正

### ENTRY_BODYモジュールを分割
パスワードの状態によってエントリーの出しわけをするため、エントリー表示用のインクルードファイル`/include/entry/body.html`を編集します。

```html:/include/entry/body.html
<!-- BEGIN_MODULE Entry_Body id="{{module_id}}" -->
<!-- BEGIN notFound -->
<section class="acms-entry entry">
  <h2 class="not-found-title">Not Found</h2>
  <p>ただいまページを準備しております。もうしばらくお待ちください。</p>
</section>
<!-- END notFound -->

<!-- 1記事▼▼ -->
<!-- BEGIN entry:loop -->

<!-- BEGIN_SetRendered id="tag" -->
<!-- BEGIN tag:loop -->
<li class="list-inline-item"><a href="{url}" class="entry-tag-label">#{name}</a></li>
<!-- END tag:loop -->
<!-- END_SetRendered -->

（中略）

</article>
<!-- END entry:loop -->
<!-- 1記事▲▲ -->

<!-- ページャー -->
@include("/include/parts/pager.html")

<!-- END_MODULE Entry_Body -->
```

このファイルの`<!-- BEGIN entry:loop -->`から`<!-- END entry:loop -->`、entry:loopブロックの中身をカットし、別ファイル`/include/entry/ex_body-content.html`に保存します。

```html:/include/entry/ex_body-content.html
<!-- BEGIN_SetRendered id="tag" -->
<!-- BEGIN tag:loop -->
<li class="list-inline-item"><a href="{url}" class="entry-tag-label">#{name}</a></li>
<!-- END tag:loop -->
<!-- END_SetRendered -->

（中略）

</article>
```

:::note info
カスタムフィールドは、[モジュール](https://developer.a-blogcms.jp/document/reference/built_in.html)の特定のブロックまたは[フィールドモジュール](https://developer.a-blogcms.jp/document/reference/field.html)内で表示できます。
[ENTRY_BODYモジュール](https://developer.a-blogcms.jp/document/acms-code/vars/Entry_Body.html?cache=off)の場合、entry:loopブロック内で`{カスタムフィールド名}`として利用できるため、今回はその部分を切り出して別ファイルにし、エントリーのカスタムフィールドを条件分岐（[IFブロック](https://developer.a-blogcms.jp/document/template/if.html)）で使用しやすいようにしました。
:::

パスワードの状況によって表示が変わるよう、表示部分をカットしたbody.htmlを修正します。

```html:/include/entry/body.html
<!-- BEGIN_MODULE Entry_Body id="{{module_id}}" -->
<!-- BEGIN notFound -->
<section class="acms-entry entry">
	<h2 class="not-found-title">Not Found</h2>
	<p>ただいまページを準備しております。もうしばらくお待ちください。</p>
</section>
<!-- END notFound -->
  
<!-- 1記事▼▼ -->
<!-- BEGIN entry:loop -->

<!-- パスワードがない場合 -->
<!-- BEGIN_IF [{entry_password}/em] -->
  @include("/include/entry/ex_body-content.html", {"tag":"{{tag}}", "sns":"{{sns}}"})
<!-- ELSE -->
<!-- パスワードがある場合 -->
  <!-- BEGIN_IF [{entry_password}/eq/%{input_password}] -->
  <!-- パスワードが正解の場合 -->
  @include("/include/entry/ex_body-content.html", {"tag":"{{tag}}", "sns":"{{sns}}"})
  <!-- ELSE -->
  <!-- パスワードが違う、または未入力の場合 -->
  @include("/include/entry/password.html")
  <!-- END_IF -->
<!-- END_IF -->

<!-- END entry:loop -->
<!-- 1記事▲▲ -->

<!-- ページャー -->
@include("/include/parts/pager.html")

<!-- END_MODULE Entry_Body -->
```

パスワードが未設定、もしくはパスワードが正しかった場合には`/include/entry/ex_body-content.html`が、それ以外の場合は`/include/entry/password.html`が表示されます。

:::note info
`@include`の`{"tag":"{{tag}}", "sns":"{{sns}}"}`について。
読み込み元の`/include/entry/body.html`ファイルが`_entry.html`からincludeされる際に`@include("/include/entry/body.html", {"module_id": "body_date", "tag":"on", "sns":"on"})`と、body_date、tagパラメーターが渡されています。
これらを`/include/entry/ex_body-content.html`ファイルにそのまま引き渡すため、上記のような書き方にしました。
インクルードについては、[公式ドキュメント](https://developer.a-blogcms.jp/document/template/include.html)を参照してください。
:::

## パスワードをクエリ文字列として送信
### パスワード入力フォームの作成
パスワード入力欄を表示するファイル`/include/entry/password.html`を作成します。

```html:/include/entry/password.html
<h2>この記事を見るには、パスワードの入力が必要です。</h2>
<div class="acms-entry">
	<form action="" method="GET" class="acms-form">
		<div><input type="password" value="" name="input_password" class="acms-form-large" placeholder="パスワードを入力"></div>
		<button type="submit" name="" class="acms-btn">パスワードを送信する</button>
		<!-- BEGIN_IF [%{input_password}/nem/_and_/{entry_password}/neq/%{input_password}] -->
		<p class="acms-text-error">パスワードが違います。</p>
		<!-- END_IF -->
	</form>
</div>

```

入力されたパスワード`input_password`は、クエリ文字列として送信されます。
a-blog cmsは、クエリ文字列をグローバル変数として扱うことができるため、これを利用してパスワードの確認を行います。

https://developer.a-blogcms.jp/blog/custom/query-string.html

先にあげた`/include/entry/body.html`のコードの
`<!-- BEGIN_IF [{entry_password}/eq/%{input_password}] -->`
の部分です。
エントリーのパスワード`{entry_password}`と、入力されたパスワード`%{input_password}`を比較し、インクルードするファイルを決定します。
これで、ひとまずエントリーにパスワード認証をかけることができました。

ただ、この状態だと、URLにパスワードが平文でついてしまっているのと、ページ遷移などでURLにクエリがない場合に毎回パスワードの入力が必要という問題があります。

## パスワードをグローバル変数にする
次に、ユーザーが入力したパスワードをa-blog cmsのグローバル変数にして、ユーザーの目に触れないようにしてみます。

https://developer.a-blogcms.jp/document/user_hook/

https://developer.a-blogcms.jp/hands-on/program.html

これらのページを参考に、グローバル変数を作成します。

### config.server.phpの修正
`config.server.php`にある`HOOK_ENABLE`を1に設定。
```php:CMSインストールルート/config.server.php
define('HOOK_ENABLE', 1);
```

### Hook.phpの修正
続いて`/extension/acms/Hook.php`を修正します。
「グローバル変数の拡張」とコメントされている部分を探して、
```php:CMSインストールルート/extension/acms/Hook.php
    /**
     * グローバル変数の拡張
     *
     * @param array $globalVars
     */
    public function extendsGlobalVars(&$globalVars)
    {
        $postKey = "input_password";
        if (isset($_POST[$postKey])) {
            $password = $_POST[$postKey];
            $globalVars->set("ENTRY_PASSWORD", $password);
        }
    }
```
これで、`name="input_password"`でポストされたデータが、`%{ENTRY_PASSWORD}`というグローバル変数で出力され、テンプレートから参照できるようになります。

### カスタムのグローバル変数を参照するよう、テンプレートの修正
パスワード送信フォームにあるform要素のmethod属性をPOSTに変更し、参照しているグローバル変数を`%{input_password}`から`%{ENTRY_PASSWORD}`へ修正
```html:/include/entry/password.html（抜粋）
	<form action="" method="POST" class="acms-form">
		<div><input type="password" value="" name="input_password" class="acms-form-large" placeholder="パスワードを入力"></div>
		<button type="submit" name="" class="acms-btn">パスワードを送信する</button>
		<!-- BEGIN_IF [%{ENTRY_PASSWORD}/nem/_and_/{entry_password}/neq/%{ENTRY_PASSWORD}] -->
		<p class="acms-text-error">パスワードが違います。</p>
		<!-- END_IF -->
	</form>
```
次に、IF文の条件の比較対象を`%{input_password}`から新しいグローバル変数`%{ENTRY_PASSWORD}`に変更。
```html:/include/entry/body.html（抜粋）
<!-- パスワードがある場合 -->
  <!-- BEGIN_IF [{entry_password}/eq/%{ENTRY_PASSWORD}] -->
```
入力されたパスワードがグローバル変数となり、ユーザーの見える部分にパスワードが出ることは無くなりました。
ただ、これでは毎回パスワードを入力しなければならない問題は解決していません。
そこで、入力されたパスワードをcookieに保存するよう修正を加えてみます。

## cookieを設定するようHook.phpを修正
グローバル変数の出力と合わせて、cookieを設定するように修正します。
以下、その一例です。

```php:CMSインストールルート/extension/acms/Hook.php
   /**
     * グローバル変数の拡張
     *
     * @param array $globalVars
     */
    public function extendsGlobalVars(&$globalVars)
    {
        // URL情報の整理
        // ドメイン（ポート番号がある場合、削除する）
        $hostWithPort = $_SERVER['HTTP_HOST'];
        $hostArray = explode(':', $hostWithPort);
        $domain = $hostArray[0];

        // リクエストされたURIを取得
        // クエリストリングを取り除く（存在する場合）
        $requestUri = $_SERVER['REQUEST_URI'];
        $requestPath = parse_url($requestUri, PHP_URL_PATH);

        // POSTされる名前
        $postKey = "input_password";
        // cookie名
        $entryCookieName = "entry_pass";

        // ポストデータがある場合
        if (isset($_POST[$postKey])) {
            // パスワードのサニタイズ
            $encFlags = ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML5;
            $password = htmlentities($_POST[$postKey], $encFlags, "UTF-8");
            if (!empty($password)) {
                $limit = time() + 60 * 60 * 24 * 7;
                setcookie($entryCookieName, $password, $limit, $requestPath, $domain, true, true);
                // グローバル変数の設定
                $globalVars->set("ENTRY_PASSWORD", $password);
            }
            // ポストデータがない場合
        } else {
            // cookieを取得
            if (isset($_COOKIE[$entryCookieName])) {
                $userCookie = $_COOKIE[$entryCookieName];
                // グローバル変数の設定
                $globalVars->set("ENTRY_PASSWORD", $userCookie);
            }
        }
    }
```
これで、ページごとにパスワードを一定期間保存して、アクセスするたびにパスワードの入力が求められないようになりました。
この例では、パスワードは平文のまま、エントリーごとに保存できるようになっています。
cookieの設定については、パスを指定しないようにしたり、ハッシュ化したりするなど、サイトの運用ポリシーに合わせて対応を検討してください。

## やり残し……一覧ページの対応
個別のエントリーページについては対応できましたが、カテゴリートップや検索結果など、一覧ページでENTRY_SUMMARYやENTRY_BODYモジュールを使っている場合、本文の一部が一覧に表示される可能性があります。
上記のように条件分岐を一覧ページのテンプレートに入れることで、パスワード付きエントリーのみ制限した表示を出す、ということが可能です。
必要に応じてカスタマイズをしてみてください。