【WordPress+Symbol】特定のトークン（モザイク）を持っている人のみ記事を閲覧できるプラグイン

WordPressと暗号通貨プラットフォームのSymbolを組み合わせて、特定のトークン（モザイク）を持っている人のみ記事を閲覧できるプラグインを作ってみました。

最初に言い訳を……
PHPのコードを書いたのも、WordPressのプラグインを作ったのも、今回が初めてです。
ついでに言うとJavaScriptもそれほど詳しくありません。
そんな人間が勉強目的で作ってみたものなので、これを本番環境で稼働させることはお控えください。
コードやアイディアの叩き台として何かの役に立てば幸いです。

<h2>準備</h2>
Symbolのモザイクを使って記事の閲覧の可否を判断するプラグインということで、WordPressの管理者側でもSymbolのアドレスとモザイクが必要になります。

ウォレットのインストールはこちらをご参照ください。
<a href="https://note.com/nembear/n/n56d2c9a28e8a">Symbol(XYM)デスクトップウォレットのインストール＆設定方法！！</a>

モザイクの発行はこちらをご参照ください。
<a href="https://note.com/yuto2873/n/n3dc4042da8d7">Symbol Walletでモザイクとネームスペースを発行する方法</a>
> モザイク作成とネームスペース作成はメインネットでいきなり使うのではなく、一度テストネットで試すことをオススメします。

テストネットで試す場合、こちらの蛇口からテストネット用のxymを取得できます。
試し終わったら返却しましょう！
<a href="https://testnet.symbol.tools">https://testnet.symbol.tools</a>

また、テストネットに接続する場合は、管理画面で入力するノードにテストネット用のノードの入力と、モザイク一覧を取得するコードで一部変更する箇所があります。

<h2>仕様</h2>

※プラグインをインストールし、有効化した後の流れです。

<h3>管理画面</h3>
![MAR管理画面.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/992966/0ee9cc5d-c233-e482-91d7-7e4fefa884b5.png)
接続するノードと、記事に設定するモザイクが入ったアドレスを入力します。
モザイク一覧を取得し保存すると、記事の投稿画面で入力できるようになります。

テストネットで試す場合、ノード入力欄にテストネット用のノードを入力する必要があります。
<a href="https://symbolnodes.org/nodes_testnet/">テストネットのノード一覧</a>

<h3>投稿画面</h3>
![MAR投稿画面.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/992966/498df049-d205-0344-cf8b-b7f270062ad3.png)
画面下部のカスタムフィールドで、モザイクIDを指定できます。
モザイクIDを指定した記事は、最初にアドレス入力画面が表示されるようになります。

<h3>コンテンツ画面</h3>
![MAR記事画面.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/992966/be03b3e9-36ce-593c-5b36-0ad376bd622e.png)
アドレスを入力し送信ボタンをクリックします。
入力したアドレスにあるモザイクIDと記事に指定したモザイクIDが一致すれば、記事を閲覧できます。

<h2>コード</h2>

<h3>PHP</h3>
<pre>
<code>
&lt;?php
/*
  Plugin Name: mosaic-allow-reading
  Plugin URI:
  Description: 特定のモザイク（トークン）を保持しているアドレス入力すると記事が読めるようになるプラグイン
  Version: 1.0.0
  Author: imogai
  Author URI: 
  License: GPLv2
*/

add_action('init', 'MosaicAllowReading::init');

class MosaicAllowReading
{
    //定数
    const VERSION           = '1.0.0';
    const PLUGIN_ID         = 'mosaic-allow-reading';
    const CREDENTIAL_ACTION = self::PLUGIN_ID . '-nonce-action';
    const CREDENTIAL_NAME   = self::PLUGIN_ID . '-nonce-key';
    const PLUGIN_DB_PREFIX  = self::PLUGIN_ID . '_';
    const COMPLETE_CONFIG   =  self::PLUGIN_ID . '-complete-config';
    const CONFIG_MENU_SLUG  = self::PLUGIN_ID . '-config';
    const PLUGIN_PREFIX  = self::PLUGIN_ID . '-';

    static function init()
    {
        return new self();
    }

    function __construct()
    {
        if (is_admin() && is_user_logged_in()) {

            /*** 管理画面 ***/
            // メニュー追加
            add_action('admin_menu', [$this, 'set_plugin_menu']);

            // コールバック関数定義
            add_action('admin_init', [$this, 'save_config']);

            // SymbolSDKを追加（管理画面）
            add_action('admin_enqueue_scripts', [$this, 'enqueue_scripts_symbol']);

            /*** 投稿画面 ***/
            //固定カスタムフィールドボックスを作成
            add_action('admin_menu', [$this, 'add_allow_mosaic_fields']);
        }

        // SymbolSDKを追加（管理画面以外）
        add_action('wp_enqueue_scripts', [$this, 'enqueue_scripts_symbol']);

        /*** 投稿画面 ***/
        //投稿記事の保存時に指定したモザイクIDも保存
        add_action('save_post', [$this, 'save_allow_mosaic_fields']);

        /*** 記事 ***/
        //記事本文の表示
        add_filter( 'the_content', [$this, 'show_content'] );

        //記事の抜粋の文字数を制限
        add_filter("excerpt_mblength", [$this, "change_excerpt_length"]);
    }

    function set_plugin_menu()
    {
        add_menu_page(
            'mosaic-allow-reading',           /* ページタイトル*/
            'mosaic-allow-reading',           /* メニュータイトル */
            'manage_options',         /* 権限 */
            'mosaic-allow-reading',    /* ページを開いたときのURL */
            [$this, 'show_about_plugin'],       /* メニューに紐づく画面を描画するcallback関数 */
            'dashicons-format-gallery', /* アイコン see: https://developer.wordpress.org/resource/dashicons/#awards */
            99                          /* 表示位置のオフセット */
        );
    }

    //設定画面を表示する
    function show_about_plugin() {

        //WordPressに保存されているデータの取得
        $node = get_option(self::PLUGIN_DB_PREFIX."_node");
        $address = get_option(self::PLUGIN_DB_PREFIX."_address");
        $mosaicList = get_option(self::PLUGIN_DB_PREFIX."_mosaiclist");
?&gt;
        &lt;div class="wrap"&gt;
        &lt;h1&gt;設定&lt;/h1&gt;

        &lt;form action="" method='post' id="my-submenu-form"&gt;
            &lt;!-- nonceの設定 --&gt;
            &lt;?php wp_nonce_field(self::CREDENTIAL_ACTION, self::CREDENTIAL_NAME) ?&gt;

            &lt;p&gt;
              &lt;label for="node"&gt;　ノード：&lt;/label&gt;
              &lt;input type="text" size="100" ID="node" name="node" value="&lt;?= $node ?&gt;"/&gt;
            &lt;/p&gt;

            &lt;p&gt;
              &lt;label for="address"&gt;アドレス：&lt;/label&gt;
              &lt;input type="text" size="100" ID="address" name="address" value="&lt;?= $address ?&gt;"/&gt;
            &lt;/p&gt;

            &lt;p&gt;
                &lt;input type='submit' value='モザイク一覧取得' class='button button-primary button-large' onclick="showMosaicList(); return false;"&gt;
            &lt;/p&gt;

            &lt;!-- WordPressに保存されているモザイク一覧を表示 --&gt;
            &lt;div id="mosaicListSection"&gt;
                &lt;?php
                    echo "&lt;ul&gt;";
                    foreach($mosaicList as $mosaic){
                        echo "&lt;li&gt;".$mosaic."&lt;/li&gt;";
                    }
                    echo "&lt;/ul&gt;";
                ?&gt;
            &lt;/div&gt;
            &lt;p&gt;&lt;input type='submit' value='保存' class='button button-primary button-large'&gt;&lt;/p&gt;
        &lt;/form&gt;
      &lt;/div&gt;
&lt;?php
    }

    //設定画面の項目データベースに保存する
    function save_config()
    {
        // nonceで設定したcredentialのチェック 
        if (isset($_POST[self::CREDENTIAL_NAME]) && $_POST[self::CREDENTIAL_NAME]) {
            if (check_admin_referer(self::CREDENTIAL_ACTION, self::CREDENTIAL_NAME)) {

                // 保存処理
                $key_node = "_node";
                if (isset($_POST['node']) && $_POST['node']){
                    update_option(self::PLUGIN_DB_PREFIX.$key_node, $_POST['node']);
                }

                $key_address = "_address";
                if (isset($_POST['address']) && $_POST['address']){
                    update_option(self::PLUGIN_DB_PREFIX.$key_address, $_POST['address']);
                }

                $key_mosaiclist = "_mosaiclist";
                if (isset($_POST['mosaiclist']) && $_POST['mosaiclist']){
                    update_option(self::PLUGIN_DB_PREFIX.$key_mosaiclist, $_POST['mosaiclist']);
                }

                $completed_text = "設定の保存が完了しました。管理画面にログインした状態で、トップページにアクセスし変更が正しく反映されたか確認してください。";

                // 保存が完了したら、wordpressの機構を使って、一度だけメッセージを表示する
                set_transient(self::COMPLETE_CONFIG, $completed_text, 5);

                // 設定画面にリダイレクト
                wp_safe_redirect(menu_page_url(self::CONFIG_MENU_SLUG), false);

            }
        }
    }

    //SymbolSDKの読み込み
    function enqueue_scripts_symbol(){
        wp_enqueue_script(
            self::PLUGIN_PREFIX.'SymbolSDK',
            'https://xembook.github.io/nem2-browserify/symbol-sdk-1.0.3.js',
            array(),
            null,
            false
        );
    
        wp_enqueue_script(
            self::PLUGIN_PREFIX.'mosaic-list',
            plugins_url('mosaic-list.js', __FILE__),
            array(self::PLUGIN_PREFIX.'SymbolSDK'),
            null,
            false
        );
    }

    //固定カスタムフィールドボックス
    function add_allow_mosaic_fields(){
        add_meta_box(
            'allow_mosaic_fields',
            '読むことを許可するモザイクを指定',
            [$this, 'insert_allow_mosaic_fields'],
            'post',
            'normal'
        );
    }

    //モザイクを選択できるカスタムフィールドの作成
    function insert_allow_mosaic_fields(){

        $mosaicList = get_option(self::PLUGIN_DB_PREFIX."_mosaiclist");
        $allowMosaicId = get_post_meta(get_the_ID(), 'allowMosaicId', true);
        ?&gt;

        &lt;!-- モザイク一覧のドロップダウンリストを作成 --&gt;
        &lt;form action="" method='post' id="my-submenu-form"&gt;
            &lt;label for="mosaicID"&gt;モザイク：&lt;/label&gt;
            &lt;select name="allowMosaic"&gt;
                &lt;option value=""&gt;&lt;/option&gt;
                &lt;?php
                foreach($mosaicList as $mosaic){
                    $mosaicId = explode("：", $mosaic);

                    if(strcmp($allowMosaicId, $mosaicId[0]) == 0){
                        echo "&lt;option value=\"".$mosaicId[0]."\" selected&gt;".$mosaicId[0]."&lt;/option&gt;";
                    }else{
                        echo "&lt;option value=\"".$mosaicId[0]."\"&gt;".$mosaicId[0]."&lt;/option&gt;";
                    }
                }
                ?&gt;
            &lt;/select&gt;
        &lt;/form&gt;
        &lt;?php
    }

    //指定したモザイクIDを保存する
    function save_allow_mosaic_fields($post_id)
    {
        if (isset($_POST['allowMosaic'])) {
            update_post_meta($post_id, 'allowMosaicId', $_POST['allowMosaic']);
        }
    }

    //記事ページを表示
    function show_content($content){

        //投稿画面で指定したモザイクIDを取得
        $allowMosaicId = get_post_meta(get_the_ID(), 'allowMosaicId', true);

        if($_SERVER['REQUEST_METHOD'] === 'GET'){
            if(!empty($allowMosaicId)){

                //記事を閲覧するためのモザイクIDが指定されている場合、アドレス入力フォームを表示する
                return $this-&gt;create_message_address_input_form();
            }else{
                
                return $content;
            }
        }else{
            //POST（閲覧者がアドレスを入力して送信ボタンをクリックした場合）
            
            if(!empty($allowMosaicId)){
                
                if (isset($_POST['mosaiclist']) && $_POST['mosaiclist']){

                    $mosaicList = $_POST['mosaiclist'];
                    $allowRead = false;
                    foreach($mosaicList as $mosaic){
                        $mosaicId = explode("：", $mosaic);

                        if(strcmp($allowMosaicId, $mosaicId[0]) == 0){

                            //一致するモザイクID有り
                            $allowRead = true;
                        }
                    }

                    if($allowRead == true){
                        //記事に指定されたモザイクが、閲覧者のアドレスにもある場合、記事を表示する
                        return $content;
                    }else{
                        //記事に指定されたモザイクが、閲覧者のアドレスにない場合、アドレス入力フォームを表示する
                        $message = $this-&gt;create_message_address_input_form();
                        return $message.'&lt;p&gt;&lt;label for="err"&gt;※※※記事を閲覧するためのトークン（モザイク）を持っていないようです。※※※&lt;/label&gt;&lt;/p&gt;';
                    }
                }else{
                    return $this-&gt;create_message_address_input_form();
                }
            }
        }
    }

    //記事ページのアドレス入力フォーム用のメッセージを作成
    function create_message_address_input_form(){
        
        //Wordpress管理者が設定したノードを取得
        $node = get_option(self::PLUGIN_DB_PREFIX."_node");

        $message = '&lt;form action="" method="post" id="addressInputForm"&gt;'.
                '&lt;p&gt;&lt;label for="address"&gt;Symbolアドレスを入力してください&lt;/label&gt;&lt;/p&gt;'.
                '&lt;p&gt;&lt;input type="text" size="100" ID="address" name="address" value="" /&gt;&lt;/p&gt;'.
                '&lt;p&gt;&lt;input type="submit" value="送信" class="button button-primary button-large" onclick="setMosaicListHidden(); return false;"&gt;&lt;/p&gt;'.
                '&lt;input type="hidden" ID="node" name="node" value="'.$node.'" /&gt;'.
                '&lt;div id="mosaicListSection"&gt;&lt;/div&gt;'.
                '&lt;/form&gt;';

        return $message;
    }

    //記事の抜粋の文字数を制限
    function change_excerpt_length($length){
        return 10;
    }

} // end of class

?&gt;
</code>
</pre>

<h3>JavaScript</h3>
<pre>
<code>
//モザイクの一覧を表示
async function showMosaicList(){

    node = document.getElementById('node').value;
    address = document.getElementById('address').value;
    mosaicListSection = document.getElementById('mosaicListSection');

    //アカウントの情報（保有モザイクや残高）の取得
    accountInfo = await getMosaicList(node, address);
    if(accountInfo == null){
        console.log('アカウント情報の取得に失敗');
        return false;
    }

    //画面上のモザイク一覧をクリア
    while(mosaicListSection.lastChild){
        mosaicListSection.removeChild(mosaicListSection.lastChild);
    }

    //画面への表示と、隠しフィールドに値をセット
    ul = document.createElement('ul');
    for(i = 0 ; i &lt; accountInfo.mosaics.length ; i++){

        text = accountInfo.mosaics[i].id.toHex() + '：' + accountInfo.mosaics[i].amount.compact();

        //リスト作成
        li = document.createElement('li');
        liText = document.createTextNode(text);
        li.appendChild(liText);

        ul.appendChild(li);

        //隠しフィールド作成（PHP側での保存処理に使用する）
        input = document.createElement('input');
        input.type = 'hidden';
        input.name = 'mosaiclist[]';
        input.value = text;
        mosaicListSection.appendChild(input);
    }
    mosaicListSection.appendChild(ul);
}

//モザイクの一覧を隠しフィールドにセット
async function setMosaicListHidden(){

    node = document.getElementById('node').value;
    address = document.getElementById('address').value;
    mosaicListSection = document.getElementById('mosaicListSection');

    //アカウントの情報（保有モザイクや残高）の取得
    accountInfo = await getMosaicList(node, address);
    if(accountInfo == null){
        console.log('アカウント情報の取得に失敗');
        return false;
    }

    //画面上のモザイク一覧をクリア
    while(mosaicListSection.lastChild){
        mosaicListSection.removeChild(mosaicListSection.lastChild);
    }

    for(i = 0 ; i &lt; accountInfo.mosaics.length ; i++){

        text = accountInfo.mosaics[i].id.toHex() + '：' + accountInfo.mosaics[i].amount.compact();

        //隠しフィールド作成（PHP側での保存処理に使用する）
        input = document.createElement('input');
        input.type = 'hidden';
        input.name = 'mosaiclist[]';
        input.value = text;
        mosaicListSection.appendChild(input);
    }

    //※記事ページの送信ボタンonclickではsubmitさせず、こちらで行う
    addressInputForm = document.getElementById('addressInputForm');
    addressInputForm.submit();
}

//モザイクを取得
function getMosaicList(node, rawAddress){
    
    return new Promise((resolve, reject) =&gt; {

        //ライブラリのインスタンス生成
        symbol = require("/node_modules/symbol-sdk");

        //入力されたアドレスを変換
        address = symbol.Address.createFromRawAddress(rawAddress);
        
        //アカウント情報を取得するための準備
        repositoryFactory = new symbol.RepositoryFactoryHttp(node);
        accountHttp = repositoryFactory.createAccountRepository();
        
        //モザイクと残高を取得
        accountHttp.getAccountInfo(address)
        .subscribe(function(accountInfo){

            resolve(accountInfo);

        }, err =&gt; console.error(err));
    });
}
</pre>
</code>

テストネットで試す場合、getMosaicList関数の以下の箇所を書き換える必要があります。
repositoryFactory = new symbol.RepositoryFactoryHttp(node);
　　　　　　　　　　　　　↓
repositoryFactory = new symbol.RepositoryFactoryHttp(node, symbol.NetworkType.TEST_NET);

<h3>ソースのダウンロード</h3>
zipにまとめました。
<a href="https://www.dropbox.com/s/0u6piror733eocl/imogai-mosaic-allow-reading.zip?dl=0">https://www.dropbox.com/s/0u6piror733eocl/imogai-mosaic-allow-reading.zip?dl=0</a>

<h2>現時点で分かっている問題点</h2>

・作りたてホヤホヤのxym等の送受信が全く無いアドレスを入力して発生したエラーに、対処していない。（というかエラー処理全般やってない）
・Simplicity2テーマを適用すると、モザイクで閲覧を制限したページの内容がインデックスページに表示される。
　同じことが起こるテーマは他にもありそう。
・入力値のチェックを全くしていない。
・管理画面で入力したノードやアドレス、モザイク一覧が、プラグインをアンインストールしてもDB上に残る。

把握していない不具合とか、余裕であると思います＼(^o^)／


<h2>作ってみた感想</h2>

躓いた部分としては、WordPressプラグインのアクションフックやフィルターフックの理解だったり、JavaScriptの同期／非同期の部分だったりします。
特にJavaScriptの同期／非同期は、頭の体操をしているかのような疲労を感じました（笑）

PHPやWordPressプラグインの開発経験がある方にとっての未知の領域は、Symbolブロックチェーンの部分かと思います。
このプラグインでは、JavaScriptのgetMosaicList関数のみです。（機能が限られているからというのも大きいですが）
やはり何かしらのプログラム言語をある程度使える方にとって、Symbolブロックチェーンはハードルが低いという印象です。


ちなみに、このプラグインにアグリゲートトランザクションを組み合わせると、決済と閲覧許可モザイクの配布を同時に行えるはずです（出来るよね？）。
noteのような記事を購入すると読めるようになる、みたいなこともWordPressプラグインで可能なはずです（出来るよね？）。


<h2>参考にさせていただいたサイトと書籍</h2>
<a href="https://qiita.com/TanakanoAnchan/items/f1191c4c008f0a2b7c2e">0からわかるWordPressプラグイン開発（スッテプ・バイ・ステップ）</a>
<a href="https://fit-jp.com/customfield/">独自のカスタムフィールドを追加する方法。プラグインなしでオリジナルの入力項目を追加しよう</a>
<a href="https://wordpress-template-media.com/customfield/">カスタムフィールドを自作する方法【プラグインなし】</a>
<a href="https://blog-and-destroy.com/14802">【WordPress】the_contentなどで取得する本文に、カスタムフィールドの値を含める方法</a>
<a href="https://qiita.com/YutopianF/items/ba82eaad855e424dd5d4">Symbol SDKを利用したXYMとモザイクの残高表示プログラム</a>
<a href="https://docs.symbolplatform.com/ja/guides/account/getting-account-information.html">アカウントの情報を取得する</a>
<a href="https://www.amazon.co.jp/dp/B00M939Y0I/ref=dp-kindle-redirect?_encoding=UTF8&btkr=1">サイトの拡張性を飛躍的に高めるWordPressプラグイン開発のバイブル</a>