トークン付きURLで特定ユーザーだけに非公開投稿を公開する方法

Cocoonカスタマイズ集
この記事は約16分で読めます。
記事内に広告が含まれています。

はじめに

「private(非公開)」設定では、通常ログインしているユーザーしか投稿を閲覧できません。
しかし、状況によっては特定の相手だけに非公開投稿を共有したいことがあります。

そこで、投稿に一意のトークンを生成しURLに付与することで、ログインしていないユーザーでもそのURLを知っている人だけがアクセスできる仕組みを実現します。

例えば、期間限定で顧客や関係者に限定コンテンツを提供したい場面で役立ちます。
また、管理画面から簡単にトークンを発行・再発行したり、共有URLを取得したりできるため、運用の手間も最小限に抑えられます。

この記事では、この方法について説明します。

完成イメージ

投稿一覧
期限切れ表示

トークンを再発行すると、以前のURLは無効となります。

実装手順

以下のコードをfunctions.phpに追加します。

/**
 * 共有トークンの有効期限を取得
 *
 * @return int 有効期限(秒)
 */
if (!function_exists('get_share_token_expiry')):
function get_share_token_expiry() {
  return 3 * DAY_IN_SECONDS;
}
endif;


/**
 * 投稿の共有URLを取得
 *
 * @param int $post_id 投稿ID
 * @return string 共有URL(未発行なら空文字)
 */
if (!function_exists('get_private_share_url')):
function get_private_share_url($post_id) {
  // 投稿メタからトークンを取得
  $token = get_post_meta($post_id, 'my_share_token', true);

  // トークンをクエリ引数に付与してURLを返す
  if ($token) {
    return esc_url(add_query_arg(['my_share_token' => rawurlencode($token)], get_permalink($post_id)));
  }

  return '';
}
endif;


/**
 * 新しい共有トークンを発行・保存
 *
 * @param int $post_id 投稿ID
 * @return string 生成されたトークン
 */
if (!function_exists('generate_share_token')):
function generate_share_token($post_id) {
  // ランダムバイトからトークンを生成
  $token = bin2hex(random_bytes(16));

  // トークンと発行日時を登録
  update_post_meta($post_id, 'my_share_token', $token);
  update_post_meta($post_id, 'my_share_token_date', current_time('timestamp'));

  return $token;
}
endif;


/**
 * 404レスポンスを返す
 *
 * @return array 空の投稿配列
 */
if (!function_exists('private_share_return_404')):
function private_share_return_404() {
  global $wp_query;

  // 404ステータスを設定
  $wp_query->set_404();
  status_header(404);

  return [];
}
endif;


// 管理画面一覧にアクションリンクを追加
add_filter('post_row_actions', function ($actions, $post) {
  // 対象ステータス以外は処理対象外
  if ($post->post_status !== 'private') {
    return $actions;
  }

  // トークンとnonceを取得
  $token = get_post_meta($post->ID, 'my_share_token', true);
  $nonce = wp_create_nonce('share_token_action');

  // トークン発行リンクを追加
  $actions['regenerate_share_token'] = sprintf(
    '<a href="#" class="regenerate-share-token" data-post-id="%d" data-nonce="%s">トークン発行</a>',
    $post->ID,
    $nonce
  );

  // URL表示リンクを追加
  if ($token) {
    $show_url_link = sprintf(
      '<a href="#" class="show-share-url" data-post-id="%d" data-nonce="%s">URL表示</a>',
      $post->ID,
      $nonce
    );

    $index = array_search('regenerate_share_token', array_keys($actions));

    if ($index !== false) {
      $actions = array_slice($actions, 0, $index + 1, true) +
                 ['show_share_url' => $show_url_link] +
                 array_slice($actions, $index + 1, null, true);
    }
  }

  return $actions;
}, 99, 2);


// トークン生成・再発行用Ajax
add_action('wp_ajax_generate_share_token', function () {
  $post_id = intval($_POST['post_id'] ?? 0);

  // nonceチェック
  if (!isset($_POST['share_token_nonce']) || !wp_verify_nonce($_POST['share_token_nonce'], 'share_token_action')) {
    wp_send_json_error('アクセスが不正です');
  }

  // 権限チェック
  if (!current_user_can('edit_post', $post_id)) {
    wp_send_json_error('権限がありません');
  }

  // トークンを発行して共有URLを返す
  generate_share_token($post_id);
  wp_send_json_success(['url' => get_private_share_url($post_id)]);
});


// 現在の共有URL取得用Ajax
add_action('wp_ajax_get_current_share_url', function () {
  $post_id = intval($_POST['post_id'] ?? 0);

  // nonceチェック
  if (!isset($_POST['share_token_nonce']) || !wp_verify_nonce($_POST['share_token_nonce'], 'share_token_action')) {
    wp_send_json_error('アクセスが不正です');
  }

  // 権限チェック
  if (!current_user_can('edit_post', $post_id)) {
    wp_send_json_error('権限がありません');
  }

  // 共有URLを取得
  $share_url = get_private_share_url($post_id);

  if ($share_url) {
    wp_send_json_success(['url' => $share_url]);
  } else {
    wp_send_json_error('トークンが設定されていません。');
  }
});


// 管理画面用JS・CSSを追加
add_action('admin_enqueue_scripts', function ($hook_suffix) {
  $screen = get_current_screen();

  if ($hook_suffix !== 'edit.php' || !$screen || $screen->post_type !== 'post') {
    return;
  }

  ob_start();
  ?>
  jQuery(function($) {
    function copyToClipboard(text) {
      if (navigator.clipboard && navigator.clipboard.writeText) {
        return navigator.clipboard.writeText(text);
      }

      var temp = $('<textarea>').val(text).css({
        position: 'fixed',
        left: '-9999px'
      }).appendTo('body');

      temp.select();

      var success = document.execCommand('copy');

      temp.remove();

      return success ? Promise.resolve() : Promise.reject(new Error('Copy failed'));
    }

    // トークン発行
    $(document).on('click', '.regenerate-share-token', function (e) {
      e.preventDefault();

      var $link = $(this);
      var postId = $link.data('post-id');
      var nonce = $link.data('nonce');

      $link.text('処理中...');

      // Ajaxでトークンを再発行
      $.post(ajaxurl, {
        action: 'generate_share_token',
        post_id: postId,
        share_token_nonce: nonce
      }, function (res) {
        $link.text('トークン発行');

        if (res.success) {
          copyToClipboard(res.data.url).then(function() {
            alert('トークンを再発行しました。\nURLがコピーされました:\n' + res.data.url);
          }).catch(function() {
            alert('トークンを再発行しました。コピー失敗:\n' + res.data.url);
          });

          // 行アクションのURL表示リンクを更新
          var $rowActions = $link.closest('.row-actions');

          $rowActions.find('.show-share-url').remove();

          $link.after(' | <a href="#" class="show-share-url" data-post-id="' + postId + '" data-nonce="' + nonce + '">URL表示</a>');
        } else {
          alert('失敗: ' + (res.data || 'エラーが発生しました'));
        }
      });
    });

    // URL表示
    $(document).on('click', '.show-share-url', function (e) {
      e.preventDefault();

      var $link = $(this);
      var postId = $link.data('post-id');
      var nonce = $link.data('nonce');

      $link.text('取得中...');

      // Ajaxで現在のURLを取得
      $.post(ajaxurl, {
        action: 'get_current_share_url',
        post_id: postId,
        share_token_nonce: nonce
      }, function (res) {
        $link.text('URL表示');

        if (res.success) {
          copyToClipboard(res.data.url).then(function() {
            alert('最新URL:\n' + res.data.url);
          }).catch(function() {
            alert('最新URLコピー失敗:\n' + res.data.url);
          });
        } else {
          alert('取得失敗: ' + (res.data || 'トークンがありません'));
        }
      });
    });
  });
  <?php
  wp_add_inline_script('jquery', ob_get_clean());
});


// 管理画面一覧にカラムを追加
add_filter('manage_post_posts_columns', function ($columns) {
  // 日付カラムの直前に「トークン期限」カラムを追加
  $date = $columns['date'];

  unset($columns['date']);

  return array_merge(
    $columns,
    [
      'share_token_expiry' => 'トークン期限',
      'date'               => $date,
    ]
  );
});


// 管理画面一覧のカラムにデータを表示
add_action('manage_post_posts_custom_column', function ($column, $post_id) {
  // 対象カラム・非公開ステータス以外は処理対象外
  if ($column !== 'share_token_expiry' || get_post_status($post_id) !== 'private') {
    return;
  }

  // メタ情報から発行日時とトークンを取得
  $issue_ts = get_post_meta($post_id, 'my_share_token_date', true);
  $token    = get_post_meta($post_id, 'my_share_token', true);

  // 発行日時の有無を確認
  if (empty($issue_ts)) {
    echo $token ? '発行日不明' : '未発行';
    return;
  }

  // 有効期限までの残り時間を計算して表示
  $now = current_time('timestamp');
  $expiry_ts = $issue_ts + get_share_token_expiry();

  echo $expiry_ts <= $now
    ? '<span style="color:red;">期限切れ</span>'
    : '残り' . human_time_diff($now, $expiry_ts);
}, 10, 2);


// 投稿クエリの取得条件を変更
add_action('pre_get_posts', function ($query) {
  // 管理画面・メインクエリ以外・ログイン中は処理対象外
  if (is_admin() || !$query->is_main_query() || is_user_logged_in() || (!$query->is_single() && !$query->is_page())) {
    return;
  }

  // 共有トークンがある場合は取得対象に非公開を追加
  if (!empty($_GET['my_share_token'])) {
    $statuses = (array) $query->get('post_status');

    $query->set(
      'post_status',
      array_unique(array_merge($statuses, ['private']))
    );
  }
});


// トークン検証とアクセス制御
add_filter('the_posts', function ($posts) {
  // 管理画面・対象外の投稿・ログイン中は処理対象外
  if (is_admin() || empty($posts) || is_user_logged_in() || $posts[0]->post_status !== 'private') {
    return $posts;
  }

  $post = $posts[0];

  $token = get_post_meta($post->ID, 'my_share_token', true);
  $issue_ts = intval(get_post_meta($post->ID, 'my_share_token_date', true));
  $url_token = $_GET['my_share_token'] ?? '';

  // 有効期限を確認
  $is_expired = get_share_token_expiry() > 0 &&
                ($issue_ts + get_share_token_expiry()) < current_time('timestamp');

  // トークン不一致・期限切れは404
  if (!$token || !$url_token || $is_expired || !hash_equals($token, $url_token)) {
    return private_share_return_404();
  }

  // インデックスを抑制
  header('X-Robots-Tag: noindex,nofollow', true);

  // 正規化リダイレクトを解除
  remove_filter('template_redirect', 'redirect_canonical');

  $post->post_status = 'publish';

  return [$post];
});


// 404ページメッセージの変更
add_filter('theme_mod_404_page_message', function ($current_mod) {
  // 共有トークン付き404の場合は専用メッセージを表示
  if (is_404() && !empty($_GET['my_share_token'])) {
    return '<div style="background:#fff3f3; border:2px solid #d63638; padding:20px; color:#d63638; font-weight:bold; margin-bottom:20px; text-align:center; border-radius:5px;">この共有URLは有効期限が切れたため、閲覧できません。</div>';
  }

  return $current_mod;
});

さいごに

今回紹介した方法を使えば、非公開投稿を安全かつ柔軟に共有できるようになります。
URLに付与されたトークンによって、閲覧権限を細かく制御でき、しかも管理画面から簡単に発行・再発行が可能です。
運用面でも負担が少なく、特定の相手にだけ一時的に情報を公開したい場面に最適です。

タイトルとURLをコピーしました