「中木な掲示板」開発記録 ― 専用WordPressプラグインの設計・実装・検証

2026年10月31日、「中木な掲示板」をプレ公開します。見た目は懐かしいCGI掲示板ですが、中身は既製品のプラグインを一切使わず、このサイトのためだけに一から設計・実装した専用のWordPressプラグインです。この記事では、その裏側を技術的な視点から紹介します。

かっちゃん
この記事について
この記事は、2026年10月に開発した「中木な掲示板」の技術的な設計・実装・検証内容を記録したものです。
将来の改修やトラブル対応の際に、当時の設計意図や開発経緯を振り返れるよう、具体的なコードや検証結果も含めて保存しています。
記載内容は開発当時の仕様であり、その後の更新によって現在の動作とは異なる場合があります。

なぜ既製品を使わなかったのか

WordPressにはbbPressやwpForoなど、実績のあるフォーラム系プラグインがあります。それでも自作を選んだのは、要件が既製品の想定と大きく異なっていたためです。

  • 返信もスレッドもない、1本の時系列の掲示板(昔のCGI掲示板と同じ体験)
  • パスワードを使わない会員制(メール認証のみ)
  • 写真の位置情報を必ず消し、承認前の写真は公開ディレクトリの外に置く
  • 既存のテーマ(STORK19)・予約システム・他のプラグインに一切影響を与えない
  • 不要な機能による読み込みの増加や、攻撃対象の拡大を避ける

既製品にこれらを後から付け足すより、必要な機能だけを最小限の構成で作る方が、安全で保守しやすいと判断しました。

全体の構成

プラグインは、役割ごとに分けた20個のPHPクラスで構成しています。テーマのfunctions.phpは一切編集せず、ZIPファイルで導入・更新できる独立したプラグインです。

領域主な役割
データ専用の投稿タイプ、記事番号の採番、DB構造のバージョン管理
認証会員登録、メール認証リンク、Cloudflare Turnstile
投稿入力検証、スパム判定、連続投稿の制限、二重送信の防止
画像形式判定、向きの補正、メタデータの除去、公開・非公開の保管管理
管理承認、停止、退会、自動削除、WordPress本体からの誤操作の防止
表示2種類のデザイン、レスポンシブ対応、頭文字アバター

開発は版ごとに段階的に進め、a版からj2版まで13回のリリースを重ねました。どの版も、PHP 7.4から8.4までの互換性を静的解析(PHPCompatibility)で確認しています。

投稿データを「どこにも漏らさない」

書き込みは、WordPress標準のコメント機能ではなく、専用のカスタム投稿タイプに保存しています。コメント機能を使うと、コメントのRSS、REST API、「最近のコメント」ウィジェットなど、意図しない公開経路が多数生まれるためです。

register_post_type( 'hirizo_board_post', array(
    'public'              => false,
    'publicly_queryable'  => false,
    'exclude_from_search' => true,
    'show_in_rest'        => false,
    'rewrite'             => false,
    'query_var'           => false,
    'map_meta_cap'        => false,
    'capabilities'        => array( 'edit_post' => 'manage_options', /* … */ ),
) );

個別URL・検索・RSS・REST API・サイトマップのどこにも出ない状態を、実際にHTTPでアクセスして確認しています。

パスワードのない認証

ログインには、メールで届くリンク(いわゆるマジックリンク)を使います。パスワードを覚える必要はなく、パスワードの流出という問題そのものが起きません。

トークンの扱い

  • 256ビットの乱数から生成
  • データベースにはHMAC-SHA256のハッシュ値だけを保存(データベースが漏れても、元のリンクは復元できない)
  • 有効期限は15分、使えるのは1回だけ。再発行すると、前のリンクは無効

「開いただけ」では消費しない

メールソフトやセキュリティ製品の中には、届いたメールのリンクを自動で先読みするものがあります。リンクを開いた瞬間にログインが完了する作りだと、本人が開く前にトークンが使われてしまいます。

そこで、リンクを開くと確認画面を表示し、ボタンを押した時点(POST)で初めてトークンを消費する方式にしました。消費の処理は、条件付きの更新を1回だけ行う形にしているため、同時に何回押されても成功するのは1回だけです。

$n = $wpdb->query( $wpdb->prepare(
    "UPDATE {$t} SET used_at = %s
      WHERE id = %d AND used_at IS NULL AND expires_at > %s",
    $now, $id, $now
) );
return 1 === (int) $n;  // 成功するのは最初の1回だけ

新規登録にはCloudflare Turnstileを使い、その判定結果は必ずサーバー側で検証しています(結果の真偽だけでなく、発行元のホスト名・用途・発行時刻も確認)。

記事番号を重複させない

昔の掲示板と同じく、書き込みには通し番号が付きます。WordPressの投稿IDとは別の、掲示板専用の番号です。

単純に「最大値+1」で採番すると、同時に投稿されたときに同じ番号が付いてしまいます。そこで、MySQLの名前付きロックで採番を直列化しています。

$got = $wpdb->get_var( $wpdb->prepare( 'SELECT GET_LOCK(%s, %d)', $lock, 10 ) );
if ( '1' !== (string) $got ) {
    return 0;  // 取れなければ採番しない(次の機会に再試行)
}
try {
    // ロック取得後に再確認してから採番(同じ投稿の二重採番も防ぐ)
} finally {
    $wpdb->get_var( $wpdb->prepare( 'SELECT RELEASE_LOCK(%s)', $lock ) );
}

開発環境で20件をほぼ同時に公開する試験を行い、重複・欠番がないことを確認しました。削除された番号も再利用しません。

写真の処理

スマートフォンの写真には、撮影場所のGPS座標が含まれていることがあります。掲示板に載せる写真からは、これを確実に取り除く必要があります。

2段階の処理

段階処理
ブラウザCanvasで長辺2560pxに縮小し、JPEGに再エンコードしてから送信(通信量を減らし、この時点でメタデータも落ちる)
サーバーファイルの中身で形式を判定 → 向きを補正 → メタデータを除去(色の情報=ICCプロファイルだけは残す)→ 拡大用(幅1600px・JPEG)と一覧用(幅1080px・WebP)を生成。元の画像は保存しない
$icc = $im->getImageProfiles( 'icc', true );
$im->stripImage();                    // EXIF・GPS・XMPなどをすべて除去
if ( ! empty( $icc['icc'] ) ) {
    $im->profileImage( 'icc', $icc['icc'] );   // 色の再現性は維持
}

処理後のファイルもバイト単位で検査し、EXIFが残っていないことを確かめてから保存します。

開発中に見つけたImageMagickの落とし穴

大きなJPEGを省メモリで読み込むために、jpeg:sizeという読み込み時の縮小指定を使っていました。ところが検証の過程で、指定値が元の画像より大きいと、逆に拡大して読み込まれることが分かりました。

元の画像修正前の読み込み結果修正後
1200×6002400×1200(拡大)1200×600
4000×30008000×6000(画素数4倍)4000×3000
8064×6048(4800万画素)—4032×3024(77ms)
// 指定値が元の寸法を超えないようにする
if ( $w > self::HINT || $h > self::HINT ) {
    $im->setOption( 'jpeg:size', min( $w, self::HINT ) . 'x' . min( $h, self::HINT ) );
}

表示上はほとんど差が出ないため気付きにくい問題でしたが、サーバーのメモリを無駄に使っていたため、根本から修正しました。

承認前の写真は「URLを知っていても見られない」

  • 承認前の写真は、Webから一切アクセスできない公開ディレクトリの外に保管
  • 公開されたときだけ公開ディレクトリへ移動し、そのたびに新しいランダムなファイル名を付ける
  • 非公開に戻すと元の場所へ戻すため、以前のURLは404になる。再公開しても古いURLは復活しない
  • 公開ディレクトリでは、決まった形式の画像ファイル以外はすべて拒否(PHPの実行、一覧表示も不可)

この挙動は、実際のサーバー(Xserver)上でも、一覧用・拡大用の両方の画像について確認しています。

iPhoneのHEICへの対応

iPhoneの写真の標準形式であるHEICは、サーバー側のImageMagickが対応していません。そこで、ブラウザがHEICを読み込めるかどうかを、458バイトの小さなHEIC画像で実際に試してから、選択できる形式を切り替えています。

var HEIC_SAMPLE = 'data:image/heic;base64,AAAAHGZ0eXBoZWlj…';
function detectHeic(cb) {
    var t = new Image();
    t.onload  = function () { cb(t.naturalWidth > 0); };
    t.onerror = function () { cb(false); };
    t.src = HEIC_SAMPLE;
}

常にHEICを許可すると、HEICを読み込めない古いiPhoneでは、OSによる自動のJPEG変換が働かなくなり、かえって投稿できなくなります。機能を判定する方式にしたことで、新旧どちらのiPhoneでも投稿できます。

レスポンシブな画像の配信

<img src="…_s.webp"
     srcset="…_s.webp 1080w, …_l.jpg 1600w"
     sizes="(max-width: 600px) 100vw, 1100px"
     width="1600" height="1200" loading="lazy" decoding="async">

スマートフォンには軽いWebP、PCには高解像度のJPEGを、ブラウザが自動で選びます。以前の版で投稿された600pxの画像も、幅の情報から「足りない」と判断され、再アップロードなしで高解像度の画像が使われます。

表示:2つのデザインを1つのHTMLで

「クラシック」と「モダン」は、HTMLは共通のまま、外枠の属性ひとつでCSSを切り替えています。

<div class="hirizo-board hb-root" data-hb-theme="classic"> … </div>

.hirizo-board.hb-root[data-hb-theme="classic"] { --hb-list-bg: #23402E; /* … */ }
.hirizo-board.hb-root[data-hb-theme="modern"]  { --hb-list-bg: #ffffff; /* … */ }
  • 色・文字・余白・角丸は、すべてCSSカスタムプロパティで管理
  • モダン表示の「投稿者名を上に置く」配置は、HTMLの順序を変えずにFlexboxのorderだけで実現
  • 選んだ表示はブラウザ(localStorage)に保存し、サーバーには何も送らない
  • ページを開いた瞬間に一瞬だけ別のデザインが見える「ちらつき」を防ぐため、外枠の直後の短いスクリプトで、描画の前にデザインを確定
  • すべてのCSSを掲示板の外枠の内側だけに限定し、テーマや他のページには一切影響させない

文字サイズはPCとスマートフォンで別々に

部品PC(601px以上)スマートフォン(600px以下)
投稿タイトル20px17px
投稿本文18px16px
投稿者名16px15px
日時14px13px

タイトルと本文は管理画面から変更でき、スマートフォンの値は差分と下限値から自動で計算します(比率で計算すると端数が出て、文字がにじむため)。

頭文字アバター

モダン表示では、ニックネームの先頭2文字をアイコンにしています。ここで単純に「2文字」を切り出すと、濁点が別の文字として付いた「か+゛」や、複数の絵文字を結合した家族の絵文字などが途中で割れてしまいます。そこで、書記素クラスタ(人が見て1文字と認識する単位)で数えています。

if ( preg_match_all( '/\X/u', $name, $m ) ) {
    return implode( '', array_slice( $m[0], 0, 2 ) );
}

背景色は16色。すべて白い文字とのコントラスト比が5.1以上(アクセシビリティの基準値は4.5)になるように選んでいます。

運用:止まらず、壊さず、重ならない

定期処理

期限切れの認証リンクの削除、接続情報の90日後の削除、退会者の90日後の削除などを、1時間ごとに実行しています。WordPress標準のWP-Cronはアクセスがないと動かないため、サーバー側のcronでも呼び出し、二重に動かしています。

二重に動いても問題が起きないよう、各処理は待たずに取得を試みるロック(GET_LOCK(name, 0))で保護し、取れなければ何もしません。3つのプロセスを同時に起動する試験でも、実際に処理したのは1つだけでした。

途中で失敗しても、続きからやり直せる削除処理

退会者の削除は、書き込み → 写真ファイル → 認証情報 → ユーザーの順に、段階ごとに記録しながら進めます。途中で失敗した場合はそこで止まり、原因が解消されれば、次回の実行で続きから完了します。また、WordPressの管理者や掲示板以外のユーザーが削除の対象になることがないよう、複数の条件をすべて満たした場合にだけ削除を実行します。

WordPress本体からの誤操作も防ぐ

掲示板の会員はWordPressのユーザーとして管理しているため、WordPressのユーザー画面から直接削除や権限の変更ができてしまいます。これを、画面上のボタンを隠すだけでなく、WordPressの権限判定そのもので拒否しています。

add_filter( 'map_meta_cap', function ( $caps, $cap, $user_id, $args ) {
    if ( in_array( $cap, array( 'delete_user', 'promote_user' ), true )
        && ! empty( $args[0] ) && is_board_member( $args[0] ) ) {
        return array( 'do_not_allow' );
    }
    return $caps;
}, 10, 4 );

管理画面のボタン、URLの直接入力、一括操作、REST APIのどの経路から操作しても、同じ判定で拒否されます。

テストの方法

本番環境を壊さないよう、開発用の環境を別に用意して検証しました。

  • WordPress 6.5 + MariaDB + Apache(本番と同じ.htaccessの挙動を確認するため)
  • ヘッドレスブラウザ(Playwright)による、PC・スマートフォンの画面幅での表示と操作の自動確認
  • 攻撃を想定した入力(XSS、CSRF、権限の偽装、不正なトークン、同時操作)を含む回帰テスト
  • 版を上げるたびに、既存のテスト一式を再実行

実機でしか確認できない項目(サーバー固有の配信の挙動、iPhoneでの写真の投稿など)だけを、本番のサーバーと実際の端末で確認しています。

AIとの共同開発

今回の開発では、プログラムの実装をClaude、仕様や使いやすさの検討をChatGPTが担当し、私が実際に動かして判断する、という分担で進めました。

AIは驚くほどの速さでコードを書きます。それでも、「何を作るか」を決め、実際に動かして違和感に気付き、「これでいい」と判断するのは人間の仕事でした。ImageMagickの拡大の問題も、iPhoneの古い機種への配慮も、そうした確認の積み重ねの中で見つかったものです。

おわりに

ここまで書いてきた仕組みは、掲示板を使う方の目にはほとんど触れません。画面に見えるのは、懐かしい黒板と、書き込みと、写真だけです。

それで良いと思っています。見えないところで守り、見えるところはシンプルに。昔の掲示板がそうだったように、海と中木が好きな人たちが、気軽に話せる場所であることが一番大切だからです。

10月31日、「中木な掲示板」をプレ公開します。

かっちゃん

中木な掲示板(hirizo-board)更新履歴 版 日付 主な内容
0.0.1 10/25 環境チェック用の一時プラグイン(サーバー・画像処理・メール認証の事前調査。確認後に削除)
0.1.0-a 10/25 掲示板の骨格。専用の投稿タイプ、記事番号、一覧表示、ページ送り、黒板風デザイン
0.1.0-b 10/25 会員登録とログイン。メール認証リンク(パスワードなし)、Cloudflare Turnstile、利用規約への同意
0.1.0-c 10/25 書き込みと写真。EXIF・GPSの除去、承認前の写真の非公開保管、スパム対策、連続投稿の制限、IP情報の90日削除
0.1.0-d 10/25 管理機能。承認待ち画面、通知メール、会員の停止・退会、退会から90日後の自動削除、記事番号の開始値
0.1.0-e 10/25 写真を20MBまで選択可能に、画質の向上、古い通知が残る不具合の修正、写真の注意書き
0.1.0-f 10/26 管理者が掲示板から直接書き込み可能に([管理人]の印)、写真を横幅いっぱいの大きな表示に、タイトル画像の設定
0.1.0-f1 10/26 WordPressのユーザー画面からの、掲示板会員の誤操作(削除・権限変更・パスワード設定)を防止
0.1.0-g 10/26 クラシック/モダン表示の切り替え、モダン表示のデザイン、両表示用のタイトル画像
0.1.0-h 10/26 頭文字アバター(名前の先頭2文字+色を選択)
0.1.0-i 10/26 表示件数の設定、ページ送りを上下に、NEWの表示、投稿フォームの開閉、クラシック表示の周囲の背景、アバターを16色に
0.1.0-j 10/26 PC・スマホ別の文字サイズ(管理画面で調整可能)、iPhoneの「ファイル」アプリのHEICに対応
0.1.0-j1 10/26 新規登録画面に、ニックネームとアイコンについての注意書き
0.1.0-j2 10/26 管理者の表示名(かっちゃん)を、会員が登録できない名前に追加
0.1.0-j3 10/26 PCで縦長の写真と本文を横並びに表示(現在の公開候補版)

ABOUT US
「かっちゃん|ヒリゾ浜渡し船長」
中木・ヒリゾ浜の現地から、運航情報や海況、観光情報などを発信しています。 ヒリゾ浜渡しの船長として、実際の海を見ながら、現地ならではの情報をお伝えしています。