印刷したQRコードのリンク先を、あとから変える —— WordPressで「動的QRコード」を実装する
QRコードは刷り直せない
チラシを1万枚刷った。QRコードも入れた。キャンペーンページに飛ぶようにした。
キャンペーンが終わった。
このとき手元にあるのは、「もう存在しないページに飛ぶQRコードが印刷された紙が1万枚」です。刷り直すには金がかかる。すでに配ってしまった分は回収できない。名刺やパッケージや看板なら、もっと寿命が長い。看板は数年そこに立ち続けます。
解決の方向自体は単純です。QRコードに焼き込むURLと、実際に見せたいURLを分ける。 QRには短縮URL(例:example.com/ab)だけを入れておいて、「そのURLがどこへ飛ぶか」はサーバ側のデータベースに持つ。行き先を変えたいときはDBを書き換える。印刷物には一切触らない。これが「動的QRコード」と呼ばれるものです。
ここまでは、既存の短縮URLサービスを使っても実現できます。実際そういうサービスはたくさんあります。ではなぜ自分で作るのか。
ドメインが他人の資産だからです。
短縮URLサービスが終了すれば、そのドメインで刷った印刷物は全部死にます。サービスは終わります。過去に終了した短縮URLサービスは一つや二つではありません。ドメインが失効して第三者に取得され、印刷済みのQRが全く別のサイトに飛ぶようになる、という事故も起こり得ます。印刷物の寿命が数年あるのに、そのリンク先の生死を他人に握らせるのは、けっこう怖い判断です。
だから自分のドメインでやりたい。そして日本の中小事業者にとって、「自分のドメインで動いているもの」は、かなりの確率で WordPress です。
というわけで、WordPress プラグインとして短縮URLリダイレクタを実装しました。この記事はその実装の話です。動くコードとして WordPress 公式ディレクトリで公開している ので、以下のコードはすべて実物からの抜粋です(GPLv2)。
書きたいのは機能紹介ではなく、同じものを作るときにどこで判断が必要になるかです。結論を先に言うと、この種のプラグインで難しいのは「リダイレクトすること」ではありません。「リダイレクトしないこと」を正しく実装することが仕事の8割でした。
想定環境は WordPress 6.0 以上、PHP 7.4 以上。GD も Imagick も Composer も使いません。 共用レンタルサーバーで動くことを設計上の制約にしています。この制約が後半でいくつかの設計判断の要因となります。
1. データモデル:QRの識別子と、遷移先を分離する
まずテーブル設計です。中心は2つです。
-- リンク(QRが指す「宛先」の識別子)
CREATE TABLE wp_pvqr_links (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
slug VARCHAR(32) NOT NULL,
title VARCHAR(255) NULL,
enabled TINYINT(1) NOT NULL DEFAULT 1,
qr_ec_level CHAR(1) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY slug (slug)
);
-- スケジュール(実際の遷移先URL。1リンクに複数持てる)
CREATE TABLE wp_pvqr_schedules (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
link_id BIGINT UNSIGNED NOT NULL,
target_url TEXT NOT NULL,
target_url_iphone TEXT NULL,
target_url_android TEXT NULL,
target_url_pc TEXT NULL,
start_at DATETIME NULL,
end_at DATETIME NULL,
PRIMARY KEY (id),
KEY link_id (link_id)
);
ポイントは、wp_pvqr_links が遷移先URLを持っていないことです。
QRコードが encode するのは links.slug だけ。遷移先は wp_pvqr_schedules 側にあります。だから schedules を書き換えれば、その slug で印刷済みのQRコード全部の行き先が、同時に変わります。1リンクに複数のスケジュールを持てるので、「8月はキャンペーンページ、9月からは通常の商品ページ」という予約もできます。
もし links.target_url という設計にしていたら、「切り替え予約」を後から足すときにテーブルを作り直すことになっていました。最初から1対多にしておいてよかった部分です。
これに加えて logs(アクセス1件1行)、tags(設置場所別のサブslug、後述)、categories を持ちます。テーブル生成は dbDelta() で、プラグイン有効化時と、バージョン不一致を検出したときに走らせます。
register_activation_hook(__FILE__, ['Pivot_QR_DB', 'install']);
add_action('plugins_loaded', function () {
if (version_compare((string) get_option('pivot_qr_db_version', '0'), PIVOT_QR_VERSION, '<')) {
Pivot_QR_DB::install();
}
}, 0);
register_activation_hook だけに頼ると、「プラグインを更新したがカラムが増えていない」という状態が起きます。有効化は更新時に走らないので。plugins_loaded でバージョンを見て追いかけるのが確実です。
2. どのフックで割り込むか
さて、example.com/ab というリクエストを捕まえないといけません。WordPress でURLを扱うといえば rewrite rule ですが、今回は使いませんでした。
// リダイレクトの割り込みは可能な限り早く。
add_action('plugins_loaded', ['Pivot_QR_Redirect', 'maybe_redirect'], 1);
plugins_loaded の priority 1 です。理由を3つ書きます。
rewrite rule を使わなかった理由。 rewrite rule はフラッシュが必要で、フラッシュのタイミングを間違えると「有効化したのに効かない」「他のプラグインを触ったら消えた」という再現しにくい問題になります。加えて、今回捕まえたいのは /ab のようなルート直下の超短いパスです。これを rewrite に載せると、他のプラグインやテーマのルールとの優先順位争いになります。パーマリンク構造の変更でも壊れます。URL空間の一番おいしいところを奪う以上、他人のルールと同じ土俵で殴り合わないほうが安全という判断です。
template_redirect を使わなかった理由。 ここまで来ると WP_Query がもう走っています。リダイレクトするだけなのにメインクエリのDBアクセスを1回無駄にしますし、テーマや他プラグインが template_redirect より前のフックで何かしている場合、その副作用を踏みます。リダイレクタは「何も起こさないうちに決着をつける」のが筋です。
早すぎることの制約。 plugins_loaded の時点では、使えるWordPress APIが限られます。is_user_logged_in() などのユーザー系はまだ使えません。今回は $wpdb、get_option()、wp_redirect()、wp_die()、current_time() だけで完結する処理なので問題ありませんが、「ログインユーザーには別の挙動」のような要件が入ると、この位置では実装できません。設計を先に絞ったから、この位置に置けています。
3. 落とし穴①:既存ページとの衝突 —— 「何もしない」を実装する
ここが記事の核心です。
example.com/ab を自分のものにする、というのは、本来 WordPress のものであるURL空間に手を突っ込む行為です。ここを雑にやると、固定ページが404になる、/feed が死ぬ、robots.txt が返らない、といった事故になります。しかも「プラグインを入れたら関係ないページが壊れた」という形で出るので、原因が分かりにくい。
対策は3段構えにしました。順に見ます。
(a) 形で弾く
$request_uri = sanitize_text_field(wp_unslash($_SERVER['REQUEST_URI']));
$uri = wp_parse_url($request_uri, PHP_URL_PATH);
$path = trim((string) $uri, '/');
if ($path === '') return;
// "<slug>" または "<slug>/<tag_slug>" のみ受ける。それより深いものは我々の管轄外。
$parts = explode('/', $path);
if (count($parts) > 2) return;
$slug = $parts[0];
$tag_slug = isset($parts[1]) ? $parts[1] : '';
if (!preg_match('/^[a-zA-Z0-9_-]{2,16}$/', $slug)) return;
if ($tag_slug !== '' && !preg_match('/^[a-zA-Z0-9_-]{1,8}$/', $tag_slug)) return;
2〜16文字の [a-zA-Z0-9_-]、階層は2階層まで。それ以外の形のパスは、そもそも見に行きません。
ここで書いておきたいのが、「slug生成のルール」と「ルーティングが受け付けるルール」を意図的に分けたことです。
設定画面には「slug長」という項目があり、既定は3文字、2〜8文字で変えられます。しかし上の正規表現は 2〜16文字を受けます。一致させていません。
なぜかというと、設定を3文字に変えた瞬間に、すでに印刷済みの2文字QRが死んではいけないからです。もしルーティングが設定値を見ていたら、管理者が何気なく設定を変えただけで、去年配ったチラシのQRが全部404になります。しかもその管理者は、自分が何を壊したのか分かりません。
だから設定値は「これから新しく作る slug をどう生成するか」だけを決め、ルーティングは「形として妥当な slug なら受ける」に留めています。実際に存在するかどうかを判定するのは、この後のDB照会です。ルーティングのパターンを広く取ることは、既存リンクの挙動を一切変えません。
生成のルールと、受け入れのルールは別物である —— 印刷物のように「あとから直せないもの」を相手にする実装では、これがかなり効きます。受け入れは寛容に、生成は厳しく。
slug生成側はこうなっています。
public static function unique_slug($len = null) {
// a-z, A-Z, 0-9, ハイフン, アンダースコア(URLセーフ+大文字小文字を区別)
$alphabet = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_';
// ...
for ($i = 0; $i < 200; $i++) {
$s = '';
for ($j = 0; $j < $len; $j++) {
$s .= $alphabet[random_int(0, $abLen - 1)];
}
// 先頭・末尾が - や _ になる slug は避ける(URLとして見た目が悪い)
if ($s[0] === '-' || $s[0] === '_' || $s[$len - 1] === '-' || $s[$len - 1] === '_') {
continue;
}
$exists = $wpdb->get_var($wpdb->prepare("SELECT 1 FROM $links WHERE BINARY slug = %s", $s));
if (!$exists) return $s;
}
throw new RuntimeException('slug generation failed');
}
アルファベットは64種です。BINARY 比較で大文字小文字を区別しているので、aB と ab は別の slug として共存できます。これで2文字なら約4,000通り、3文字なら約26万通りになります。
大文字小文字を区別するのは判断が必要な部分です。電話で伝えるには向きません(「小文字のえーびー、大文字のビー」)。ただ動的QRの主用途は「印刷物を読み取ってもらう」であって「口頭で伝える」ではないので、アドレス空間を広げて slug を短く保つ方を取りました。 slug が1文字短いことがQRコードの読み取りやすさに直結するからです(これは第6節で説明します)。
(b) 予約パスで弾く
$reserved = Pivot_QR_Settings::reserved_paths();
foreach ($reserved as $r) {
if (strcasecmp($slug, $r) === 0) return;
}
既定の予約リストはこれです。
wp
feed
wp-admin
wp-content
wp-includes
wp-login
wp-cron
robots.txt
sitemap.xml
favicon.ico
xmlrpc.php
strcasecmp() で大文字小文字を無視して比較しています。slug 自体は大文字小文字を区別する設計なのに、ここだけ無視しているのは矛盾のようですが、意図的です。予約は「守り」なので、広く効いたほうがいい。 WP-ADMIN を slug として登録できてしまう余地を残す意味がありません。
ユーザーが追加できるようにしてあります。というのも、既定のリストで守れるのは WordPress 本体の分だけで、そのサイトが持っている短い固定ページ(/ec、/faq など)はサイトごとに違うからです。ここは自動検出しようとして深追いせず、「設定で足せる」に留めました。
(c) DBに無ければ、何もせずに return する
$link = Pivot_QR_DB::find_link_by_slug($slug);
if (!$link) return;
たった2行ですが、この記事で一番大事な2行です。
自分の管轄でないパスに対して、404 を返してはいけません。黙って処理を降りて、WordPress に本来の仕事をさせる。ここで wp_die() したり status_header(404) したりすると、サイトのすべての未知のURLがこのプラグインの404で塗り潰されます。
URL空間に割り込むプラグインの作法として、素通しがデフォルトであるべきです。逆に言うと、この種のプラグインのテストで最初に書くべきは「リダイレクトされること」ではなく「関係ないURLが今までどおり動くこと」です。
まとめると、判定はこの順です。
リクエスト
├─ 管理画面 / AJAX / cron / REST / CLI → 何もしない
├─ パスが空、3階層以上 → 何もしない
├─ slug の形が不正 → 何もしない
├─ 予約パスに一致 → 何もしない
├─ DBに該当 slug なし → 何もしない
└─ ここまで来たものだけリダイレクトする
冒頭のガードも書いておきます。
if (is_admin() || (defined('DOING_AJAX') && DOING_AJAX) || (defined('DOING_CRON') && DOING_CRON)) return;
if (defined('REST_REQUEST') && REST_REQUEST) return;
if (php_sapi_name() === 'cli') return;
if (!isset($_SERVER['REQUEST_URI'])) return;
REST を除外し忘れると、自分の管理画面用APIが自分のリダイレクタに食われます。WP-CLI を除外し忘れると wp コマンドが動かなくなります。どちらも実際に踏みました。
4. 落とし穴②:リダイレクトは、勝手にキャッシュされる
これも実装前に気づきにくいところです。
301 を返してはいけません。
301 Moved Permanently は「恒久的な移転」なので、ブラウザ、CDN、中間プロキシがそれを覚えます。そして次からはサーバに問い合わせずに、キャッシュした行き先へ直接飛びます。
動的QRコードは「あとから行き先を変える」ための仕組みです。それを301で返すと、行き先を変えても、変更が届かない端末が出ます。 しかも「一部の人だけ古いページに飛ぶ」という形で出るので、報告を受けても再現できません。開発者の手元では新しいページに飛ぶからです。
nocache_headers();
wp_redirect(esc_url_raw($target), 302);
exit;
302 を返し、nocache_headers() で明示的にキャッシュを拒否します。nocache_headers() は WordPress の関数で、Cache-Control: no-cache, must-revalidate, max-age=0 と Expires を適切に吐いてくれます。自分でヘッダを組むより、これを呼ぶのが確実です。
「SEO的には301のほうがいい」という一般論とは衝突します。ただこのリダイレクタは検索エンジンにインデックスさせるものではないので、ここは用途に応じてステータスコードを選ぶ場面だと考えています。恒久的でないものを恒久的と宣言してはいけない、という素直な話でもあります。
キャッシュ層は他にもある
もうひとつ、共用サーバーやCDNを使っていると踏みやすい落とし穴があります。
ページキャッシュを、リダイレクト対象パスに効かせてはいけません。 ページキャッシュプラグインやCDNが /ab のレスポンスをキャッシュすると、「最初にアクセスした人の行き先が、その後全員に配られる」という状態になります。端末別振り分け(第7節)を入れていると、iPhone で最初に踏まれた結果が PC ユーザーにも配られる、という形で壊れます。302 を返してキャッシュ拒否ヘッダを付けているので、素直な実装のキャッシュ層なら効きませんが、設定によっては無視されます。導入時に除外設定を確認する必要があります。
OPcache は別の層の話です。 OPcache がキャッシュするのは PHP のバイトコードであって、DBの中身ではありません。だからリダイレクト先の変更が OPcache のせいで届かないことはありません。ただしプラグインを更新したとき、古いコードが動き続けることがあります。「バージョンを上げたのに直っていない」という症状はこれです。共用サーバーでは opcache_reset() を叩けないことも多いので、こうなったら管理画面からのキャッシュクリア機能を探すか、時間で失効するのを待つことになります。
整理すると、キャッシュの層はこう分かれています。
| 層 | 何をキャッシュするか | リダイレクト先の変更に影響するか |
|---|---|---|
| ブラウザ / CDN / プロキシ | HTTPレスポンス | する(301だと致命的) |
| ページキャッシュ | 生成済みHTML・レスポンス | する(除外設定が必要) |
| OPcache | PHPバイトコード | しない(ただし更新が反映されない) |
| オブジェクトキャッシュ | get_option() などの結果 | 設定値の反映に影響しうる |
「データはDB、コードはファイル」でキャッシュの層が違う、と押さえておくと切り分けが早くなります。
5. 落とし穴③:計測を、あとから足せる形にしておく
リダイレクトの直前に、ログを1行 INSERT します。
global $wpdb;
$logs = Pivot_QR_DB::table('logs');
$wpdb->insert($logs, [
'link_id' => $link['id'],
'tag_id' => $tag_id,
'source' => $source,
'user_agent' => mb_substr($ua, 0, 1000),
'referer' => $referer !== '' ? mb_substr($referer, 0, 1000) : null,
'ip' => self::client_ip(),
'accessed_at' => current_time('mysql'),
]);
集計はここではしません。生ログを1行入れるだけです。カウンタをインクリメントする設計にすると軽くなりますが、あとから見たい軸が必ず増えます。 「日別で見たい」「QRからの流入とURL直踏みを分けたい」「設置場所別に見たい」——これは全部あとから出てきた要求で、生ログを持っていたおかげで集計クエリを足すだけで済みました。
インデックスはこれです。
KEY link_id_date (link_id, accessed_at)
KEY tag_id (tag_id)
集計は基本的に「あるリンクの、ある期間」なので、(link_id, accessed_at) の複合インデックスがそのまま効きます。
SELECT DATE(accessed_at) AS d, source, COUNT(*) AS c
FROM wp_pvqr_logs
WHERE link_id = %d AND accessed_at >= %s AND accessed_at <= %s
GROUP BY d, source
なお、日別バケットを埋める配列は366日で打ち切っています。長寿命のリンク(看板は数年立っています)で「全期間」を選ばれると、日数ぶんの配列を作ることになるので。
QRスキャンと直踏みを区別する
「このQRコード、実際に何回読まれたのか」を知りたい。でも同じ短縮URLはメールやSNSにも貼れます。混ざります。
素朴に解決しました。QRに焼くURLだけ、クエリを1つ足しておく。
private static function short_url($slug) {
return Pivot_QR_Settings::public_base() . '/' . $slug;
}
private static function qr_url($slug) {
return Pivot_QR_Settings::public_base() . '/' . $slug . '?from=qr';
}
受け側はこれだけです。
$from = isset($_GET['from']) ? sanitize_key(wp_unslash($_GET['from'])) : '';
$source = ($from === 'qr') ? 'qr' : 'short';
限界も正直に書いておきます。QRを読んだあとにアドレスバーからURLをコピーして誰かに送れば、その人のアクセスも qr として記録されます。厳密な区別ではありません。ただ印刷物の効果測定としては、この精度で十分実用になります。 厳密にやろうとすると計測用の別ドメインを立てるとか、そういう話になっていくので、費用対効果で止めました。
?from=qr が付くとURLが7文字長くなり、QRコードのセル数がわずかに増えます。ここもトレードオフですが、計測できる価値のほうが大きいと判断しました。
設置場所別に測る —— タイポを想定内として扱う
ポスターとチラシに同じQRを載せた。どちらから来たのか知りたい。
2階層目を設置場所にしました。/ab/p がポスター、/ab/f がチラシ。
CREATE TABLE wp_pvqr_tags (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
link_id BIGINT UNSIGNED NOT NULL,
slug VARCHAR(8) NOT NULL,
name VARCHAR(120) NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY link_slug (link_id, slug),
KEY link_id (link_id)
);
ユニーク制約は (link_id, slug) です。リンクをまたげば同じ p を使えます。全リンクで p がユニーク、という設計にすると、2つ目のリンクで「ポスター」に別の記号を割り当てることになって、運用が破綻します。
実装上の要点はここです。
// タグ slug があれば解決する。未知なら親リンクにフォールバックし
// (tag_id = NULL で記録)、エラーではなく打ち間違いとして扱う。
$tag_id = null;
if ($tag_slug !== '') {
$tag = Pivot_QR_DB::find_tag((int) $link['id'], $tag_slug);
if ($tag) {
$tag_id = (int) $tag['id'];
}
}
/ab/x のように未知のタグを踏まれたとき、404 にしていません。親リンクの遷移先へ普通に飛ばして、tag_id = NULL で記録します。
印刷物のQRは修正できないので、タイポは事故ではなく想定内です。 印刷所で1文字間違った、タグを消したあとに古いポスターがまだ貼ってある、といった状況は起きます。そのとき「404を見せて機会を捨てる」か「計測だけ諦めて目的地には届ける」か——後者を選ぶべきです。ユーザーはQRを読んで商品を見たいのであって、こちらの計測都合の犠牲になる理由がありません。
「エラーにしない」という判断が、印刷物を相手にする実装ではあちこちで出てきます。第3節の slug 長の話も、根は同じです。
IPアドレスの扱いとプライバシー
IPアドレス、User-Agent、referer は自サイトのDBにのみ保存し、外部には一切送りません。 外部の解析サービスを呼ばないので、これは設計として保証できます。プラグイン削除時に全部消えます。
IPアドレスの取得はこうしています。
private static function client_ip() {
foreach (array('HTTP_CF_CONNECTING_IP', 'HTTP_X_FORWARDED_FOR', 'REMOTE_ADDR') as $k) {
if (empty($_SERVER[$k])) continue;
$raw = sanitize_text_field(wp_unslash($_SERVER[$k]));
$ip = trim(explode(',', $raw)[0]);
if (filter_var($ip, FILTER_VALIDATE_IP)) return $ip;
}
return null;
}
CDN配下では REMOTE_ADDR がCDNのIPアドレスになるので、そのままだと全アクセスが同じIPアドレスに見えます。Cloudflare なら CF-Connecting-IP、汎用なら X-Forwarded-For を先に見ます。X-Forwarded-For はカンマ区切りのリストなので先頭を取り、filter_var() で形を検証します。
ただしこれらのヘッダはクライアントが偽装できます。 だから、このIPアドレスを「アクセス制御の根拠」に使ってはいけません。今回は概算のアクセス元把握にしか使わないので許容していますが、用途を決めてから取るヘッダを選ぶ、という順序が大事です。
6. QR側の実務:誤り訂正レベルとURL長
サーバ側から少し離れて、QRコード自体の話をします。ここは動的QRを作るなら知っておいたほうがいい部分です。
短いURLは、印刷で読みやすい
QRコードは、埋め込むデータ量が増えるとセル(黒白の最小マス)の数が増えます。「バージョン」と呼ばれる規格上の段階が上がっていくわけです。
同じ印刷面積なら、セル数が少ないほうがセル1つが大きくなります。 つまり、URLが短いQRのほうが、小さく印刷しても読み取れる。
これが「自分のドメインで短縮URLを持つ」ことの、副次的だけれど実利のある効果です。example.com/ab は21文字。名刺の隅に1cm角で入れても読めます。元のキャンペーンURLが example.com/campaign/2026/summer/?utm_source=flyer&utm_medium=print のような長さだったら、同じ面積では相当厳しくなります。
第3節で slug の大文字小文字を区別してアドレス空間を広げた話をしましたが、あれはここに効いています。slug が1文字短いことが、印刷物の読み取り率に直結します。
誤り訂正レベル L / M / Q / H
QRコードには誤り訂正が入っていて、4段階から選べます。
| レベル | 復元できる欠損の目安 | 向く用途 |
|---|---|---|
| L | 約7% | 紙・名刺・雑誌など、汚れにくいもの |
| M | 約15% | 一般的なチラシ、DM |
| Q | 約25% | 店頭POP、繰り返し触られるもの |
| H | 約30% | 屋外看板、ステッカー、包装 |
トレードオフは単純です。レベルを上げると汚れや擦れに強くなるが、冗長データが増えるのでセル数が増え、印刷が細かくなる。
既定は L にしました。
'qr_ec_level' => 'L', // L (~7%), M (~15%), Q (~25%), H (~30%)
短縮URLは元々データが少ないので、L でもかなり余裕があります。それより「小さく印刷しても読める」を優先したほうが、実際の用途に合います。屋外に貼るとき、ロゴを中央に重ねたいときは H に上げます。リンク単位でも上書きできるようにしてあります(links.qr_ec_level)。ロゴを重ねるなら誤り訂正で対応するしかないので、この上書きは必要でした。
QR生成はブラウザでやる
実装上の判断としてこれが一番大きいです。QRコードの画像生成を、サーバでやりません。
PHP でQR画像を作ろうとすると、GD か Imagick が必要になります。どちらも共用レンタルサーバーでは「入っている環境と入っていない環境がある」ものです。Composer が使えない前提だと、ライブラリの導入自体も面倒です。
なので、管理画面の JavaScript で生成しています。
const lvl = (linkLvl || CFG.ecLevel || 'L').toUpperCase();
const qr = window.qrcode(0, lvl);
第1引数の 0 は「バージョン自動判定」です。データ量から必要なバージョンをライブラリが決めてくれるので、URL長を気にせず投げられます。ここを固定値にすると、長いURLで生成に失敗します。
これで PNG / JPG / SVG を書き出せて、サーバ要件がゼロになります。 GD も Imagick も Composer も不要で、共用サーバーで確実に動きます。
外部CDNも使わず、ライブラリはプラグインフォルダに同梱しています(qrcode-generator と Chart.js、どちらも MIT)。これは WordPress 公式ディレクトリの規約上も正しい選択です。外部CDNからのスクリプト読み込みは、レビューで指摘されます。 配布物が第三者のサーバに依存すること自体が問題視されるので、公式ディレクトリに出すつもりなら最初から同梱で作るのが早いです。
7. 期間と端末で行き先を変える
schedules に start_at / end_at があるので、期間で自動的に切り替わります。実装で悩んだのは「期間が重なったとき、どれを選ぶか」でした。
管理者は普通に矛盾した設定を作ります。開始日だけ入れたスケジュールを2つ作る、期間を重ねる、終わったスケジュールを消さずに残す。そのたびに「設定が矛盾しています」とエラーを出すのは、印刷物を相手にする道具として最悪です。QRは今スキャンされているので。
必ず何かに飛ばすことを優先して、こういうルールにしました。
if ($active) {
// 有効なものが複数あれば、開始が最も新しいものを採用
usort($active, function ($a, $b) {
return strcmp((string) $b['start_at'], (string) $a['start_at']);
});
return $active[0];
}
if ($upcoming) {
// なければ、最も早く始まる予定を返す(呼び出し側が upcoming として扱う)
usort($upcoming, function ($a, $b) {
return strcmp((string) $a['start_at'], (string) $b['start_at']);
});
return $upcoming[0];
}
// それもなければ最後の1件
return $rows[count($rows) - 1];
「有効なものが複数あれば、開始が最も新しいもの」は、あとから追加した設定が勝つということです。管理者の意図としてこれが自然でした。「新しいキャンペーンを足したのに古いほうに飛ぶ」より直感に合います。
ただし、期間前・期間後だと分かっている場合はリダイレクトせず、理由を出して止めます。
$sched = Pivot_QR_DB::pick_active_schedule((int) $link['id']);
if (!$sched) {
self::render_message(404, __('No redirect destination has been set.', 'pivot-qr'));
}
$status = Pivot_QR_DB::schedule_status($sched);
if ($status !== 'active') {
self::render_message(403, __('Outside of the publication period.', 'pivot-qr'), /* ... */);
}
未知の slug は素通しする(第3節)が、存在する slug で行き先がない場合はちゃんと止める。 自分の管轄内で起きた問題は自分で説明する、というのが境界の引き方です。
端末別振り分け
$target = '';
if ($device === 'iphone' && !empty($sched['target_url_iphone'])) $target = $sched['target_url_iphone'];
elseif ($device === 'android' && !empty($sched['target_url_android'])) $target = $sched['target_url_android'];
elseif ($device === 'pc' && !empty($sched['target_url_pc'])) $target = $sched['target_url_pc'];
if ($target === '') $target = $sched['target_url'];
用途は圧倒的に App Store / Google Play の振り分けです。「アプリをダウンロード」のQRを1つだけ刷って、iPhone なら App Store、Android なら Google Play に飛ばす。中間ページを挟まずに済みます。
判定は User-Agent です。
public static function detect_device($ua) {
if ($ua === '') return 'pc';
if (preg_match('/iPhone|iPod/i', $ua)) return 'iphone';
if (preg_match('/iPad/i', $ua)) return 'iphone'; // iPadOS は iOS 側に寄せる
if (preg_match('/Android/i', $ua)) return 'android';
return 'pc';
}
iPad を iPhone 側に寄せているのは、行き先が App Store で共通だからです。厳密なデバイス分類が目的ではなく、「どのストアに送るか」が目的なので、それに合わせた分類にしています。
UA判定は原理的に脆く、これで全端末を正しく分類できるとは思っていません。だから重要なのは最後の2行、device別URLが空なら既定URLにフォールバックすることです。判定が外れても、既定URLには届く。判定を頑張るより、外れたときに壊れない構造にするほうが安く済みます。
8. モバイルアプリと繋ぐ —— 認証なし・読み取り専用の公開エンドポイント
このプラグインには連携する iPhone アプリがあります。「貼ったポスターのQR、何回読まれた?」を外出先で見るためのものです。
ここで認証の問題が出ました。WordPress REST API の標準的な認証はアプリケーションパスワードですが、これを一般ユーザーに設定させるのは現実的ではありません。 管理画面のユーザープロフィールから発行して、24文字の文字列をアプリに転記してもらう。これは店舗のオーナーに頼める作業ではありません。
採った方針はこうです。
slug を知っていること自体を、アクセス権とみなす。
// 単一 slug の公開読み取り専用スタッツ(モバイルアプリ用)。認証なし:
// slug を知っていること(= QRコードを物理的に持っていること)がアクセス権。
// `public_stats` 設定でオフにでき(既定オン)、厳密に読み取り専用 ——
// 変更系エンドポイントはここに一切公開しない。
register_rest_route(self::NS, '/public-stats/(?P<slug>[A-Za-z0-9_-]{1,32})', [
'methods' => 'GET',
'callback' => [__CLASS__, 'public_stats'],
'permission_callback' => '__return_true',
]);
permission_callback => '__return_true' です。REST API のセキュリティの話では真っ先に怒られる書き方ですが、これは意図してそう書いています。 「QRコードを物理的に持っている人」=「そのQRのスキャン数を見てよい人」という割り切りです。ポスターの前に立っている人がそのポスターの反応を見られる、というのは、そこまで守るべき秘密ではない。
ただし、割り切ったぶん制約は厳しく守りました。
GETのみ。 変更系はこのルートに一切載せません。管理系は別ルートで、manage_options で守っています。
$auth = function () { return current_user_can('manage_options'); };
register_rest_route(self::NS, '/links', [
['methods' => 'GET', 'callback' => [__CLASS__, 'list_links'], 'permission_callback' => $auth],
['methods' => 'POST', 'callback' => [__CLASS__, 'create_link'], 'permission_callback' => $auth],
]);
同じプラグインの中に __return_true と manage_options が並びますが、「どちらを書いているか」がルート定義を読めば一目で分かる形になっています。これが大事だと思っています。認証を1箇所にまとめて共通化すると、公開されているものと守られているものの区別が見えにくくなる。
返すのは集計値だけ。 total / 7日 / 30日のアクセス数、最終アクセス日時、設置場所別の件数、スケジュールの状態。IPアドレスやUAといった生ログは絶対に返しません。 ここが漏れると、単なる「スキャン数の公開」から「訪問者情報の公開」に化けます。公開エンドポイントを作るときは、権限の議論より前に「返すフィールドを列挙して、それ以外を返さない」ことを実装で保証したほうが安全です。
$not_found = new WP_Error('pvqr_not_found', __('Not found.', 'pivot-qr'), ['status' => 404]);
if (!(int) Pivot_QR_Settings::get('public_stats')) return $not_found;
設定でオフにできる。 既定はオンですが、切れることが重要です。「この機能は安全だから常時オンでいい」と作者が決めてしまうと、要件の違うサイトで使えません。オフのときは 403 ではなく 404 を返しています。機能の有無自体を明かさないためです。
総当たりへの態度。 slug が2〜3文字だと、総当たりで全リンクのスタッツを列挙できます。これは防げません。防げないので、「列挙されても困らない情報だけを返す」という方向で解いています。守れないものを守ろうとするより、守る必要がない状態に持っていくことにしました。
なお、複数サイトのリンクを1つのダッシュボードでまとめて見る用途には、別ルートを用意しました。こちらはサイトごとのAPIキーで認証します。
public static function dashboard_key_ok(WP_REST_Request $req) {
$given = (string) ($req->get_param('key') ?: $req->get_header('X-Pivot-QR-Key'));
if ($given === '') return false;
$stored = self::get_or_create_dashboard_key();
if (!hash_equals($stored, $given)) return false;
self::send_dashboard_cors();
return true;
}
比較は hash_equals() です。=== だとタイミング攻撃の余地が理論上残るので、秘密の比較には必ずこれを使います。キーはローテーションできるようにしてあります(/dashboard-key/rotate、manage_options で保護)。
公開エンドポイントと、キー認証エンドポイントを混ぜなかったのがポイントです。「キーがあれば詳しい情報、なければ概要」のような1本のエンドポイントにすると、キーなしのパスで詳細が漏れるバグが入りやすい。用途ごとに別のルートにして、それぞれの permission_callback を独立させたほうが、読んで検証できます。
9. 公式ディレクトリに出して分かったこと
最後に、WordPress 公式ディレクトリで公開する過程で学んだことを、これから出す人向けに書いておきます。
直接SQLは全部 $wpdb->prepare() を通す。 テーブル名は変数展開で構いませんが、値は必ずプレースホルダにします。今回のように独自テーブルを持つプラグインだと直接SQLが増えるので、ここは徹底的に見られます。
入力の sanitize と出力の escape を、両方書く。 $_SERVER['REQUEST_URI'] のようなものまで sanitize_text_field(wp_unslash(...)) を通します。「自分のサーバの値だから安全」は通りません。出力側も esc_html() / esc_url_raw() を省略できません。
文字列は翻訳可能にする。 __() / _e() でテキストドメインを付けます。今回は内部の状態キー(active、upcoming など)を英語で固定し、表示するときだけ翻訳する形にしました。状態キーそのものを翻訳可能にすると、比較が言語で壊れます。
/**
* 'active' / 'upcoming' / 'past' / 'none' を返す。
* 呼び出し側(REST 経由のJS、リダイレクトハンドラ)はこのキーで比較し、
* 表示ラベルは標準のi18nで翻訳する。
*/
public static function schedule_status($s) { /* ... */ }
外部CDNからの読み込みは避ける。 第6節で書いたとおりです。ライブラリは同梱し、ライセンスを readme に明記します。
設定項目を減らす方向に動く。 開発中、「短縮URLのベースドメインを設定で変えられる」機能を持っていました。0.7.6 で削除しました。
public static function public_base() {
// 常にサイト自身のURL。(設定可能なベースは 0.7.6 で削除:
// ドメインを打ち間違えたり所有していないドメインを入れると、
// 気づかないまま死んだQRコードが刷られる。)
return untrailingslashit(home_url());
}
自由度としては便利な機能でした。しかし打ち間違えたドメインで生成したQRコードは、印刷されるまで誰も気づきません。 そして印刷されたら手遅れです。柔軟性が事故に直結する場所では、柔軟性を取り上げるのが正しい。この判断は、印刷物を相手にする道具を作っていて一番学んだところです。
まとめ
動的QRコードの仕組み自体は「短縮URLを1段挟む」だけで、難しいものではありません。難しかったのは周辺です。
URL空間に割り込むプラグインは、「何もしない」を正しく実装することが仕事の8割でした。 3段の防御で弾いて、最後にDBに無ければ黙って降りる。関係ないURLが今までどおり動くことが、リダイレクトが動くことより先に保証されるべきです。
リダイレクトは302で返し、キャッシュを明示的に拒否する。 301は「あとから変える」という前提と矛盾します。ページキャッシュの除外設定も忘れずに。
印刷物を相手にするコードには、あとから直せないものが前提にある。 だからタイポも、期間の矛盾も、未知の値も、UA判定の失敗も、エラーではなく通常系として扱う。設定を変えたら過去に刷ったQRが死ぬ、という設計にはしない。柔軟性が事故を招く場所では、機能を削る。
このあたりの判断は、QRコードに限らず「物理的な世界に出ていってしまうもの」を扱うシステムに共通する話だと思います。Webだけを相手にしていると、直せる前提でコードを書いてしまう。直せないものが1つ混ざるだけで、設計の重心がずいぶん変わります。
プラグインは GPLv2 で公開しています。実装を確認したい方は WordPress公式ディレクトリ からどうぞ。