1使っている画面
#
実際に使われ続けているサイトと、毎日のブラウザ操作を片付ける拡張機能。公開したあとに止まらないこと、使う人を待たせないことを考えて作りました。
boyscout-tajimi
PS> Get-Item .\boyscout-tajimi | Format-List *
ボーイスカウト多治見第一団の公式サイトと、Webに詳しくない指導者でも更新を続けられる仕組み。

見どころ
boyscout-tajimiの技術的な詳細を読む(設計・つまずき・コード)

2作品の詳細
外から届く要求を、疑ってかかる
Webhookは誰でも叩けるURLなので、送り主がApps Scriptであることを署名で確かめます。送る側は「時刻.本文」にHMAC-SHA256で署名し、受ける側は同じ計算をして比べます。
- 時刻が5分以上ずれていたら受け付けません。盗み見た要求を後から送り直されても通りません(リプレイ攻撃への対策)
- 署名の照合には、かかる時間が中身によって変わらない関数を使います(タイミング攻撃への対策)
- 署名は生の本文に対して検証するので、JSONとして読むのはそのあと
| 入口 | 回数の上限 |
|---|---|
| Webhook | 1分に10回 |
| ログイン | 15分に10回 |
| お問い合わせ | 1分に3回、1時間に10回 |
管理画面での操作は、誰が・いつ・どこから・何をしたかを監査ログに残します。
問い合わせフォームは、ボットに気づかせない
入力欄の中に、人には見えない欄を1つ混ぜています(ハニーポット)。そこが埋まっていたり、フォームを開いてから3秒たたずに送られてきたりしたら、ボットと見なします。ボットには「ありがとうございます」と成功したように返し、何も送りません。通った問い合わせだけが、管理者にメールで届きます。


公開したあとに、止めない
mainにpushすると、GitHub ActionsがVPSへ反映します。途中で失敗しても、サイトが落ちたままにならないようにしました。
- ビルドと構文チェック通らなければ、ここで止めてDiscordに知らせます
- サーバーをmainの最新に合わせるVPSにSSHで入り、サーバー上のファイルをmainの最新にそろえます
- 依存パッケージを入れ直す失敗したとき、依存の定義が変わっていれば直前のコミットに戻します。変わっていなければ、今動いているパッケージのまま続けて、サイトを止めません
- 再起動して、実際にアクセスして確かめるpm2で再起動し、HTTPで応答を確認します。だめならログを出します
- 結果をDiscordに知らせる
3こだわったこと
Webに詳しくない人が、更新を続けられる
- やりたかったこと
-
活動を続けている指導者が、自分の手で記事を出し続けられること。更新が止まったサイトは、かえって団体の印象を悪くしてしまいます。
- ぶつかった壁
-
更新するのは、Web制作に慣れていない人たち。管理画面にログインして、操作を覚えてもらうだけでも負担になります。
- どう作ったか
-
管理画面に加えて、ふだん使っているGoogleフォームからも投稿できるようにしました。フォームの内容は、Google Apps Scriptが署名をつけてサイトに送ります。写真もドライブから取り込み、表示用の大きさに縮めて保存します。
フォームは、ニュースと活動報告のそれぞれに「簡単」と「Googleドキュメント連携」の2種類があります。 - どうなったか
-
フォームに書いて送るだけで、写真つきの記事が公開されます。サイトの仕組みを知らなくても、いつもの手順で更新できます。
つまずいたところ日本語が入った記録だけ、署名が合わない
- 起きたこと
Googleフォームから送った活動記録の一部が、署名の検証で弾かれました。
- 原因
切り分けのため、英数字だけの本文を、時刻を固定して送るテスト用の関数を作りました。これは通ります。日本語を入れると、通りません。署名の計算ではなく、日本語をバイト列にするところで食い違っていると分かりました。
署名は、文字列ではなくバイト列から計算します。サーバー側(Node.js)は UTF-8 を指定して計算していましたが、Google Apps Script 側は文字コードを指定していませんでした。
- 直し方
送る前に、本文の英数字以外の文字を、すべて
\uXXXX形式のエスケープに置き換えました。本文が英数字と記号だけになるので、どちらの環境でも同じバイト列になります。JSON としての意味は変わらないので、受け取る側は何も直さずに済みました。
docs/gas-webhook-code-v2.js(581〜582行目)
let body = JSON.stringify(payload);
body = body.replace(/[^\x00-\x7F]/g, c => '\\u' + ('0000' + c.charCodeAt(0).toString(16)).slice(-4));
要になったコード5分より古い要求は、署名を見る前に断る
src/server/middleware/auth.middleware.js(35〜55行目)
const tol = parseInt(process.env.HMAC_TOLERANCE_SEC || '300', 10);
const now = Math.floor(Date.now() / 1000);
const ts = parseInt(String(timestamp || ''), 10);
if (!ts || Math.abs(now - ts) > tol) return false;
…
const expBuf = crypto.createHmac('sha256', secret).update(`${ts}.${bodyRaw}`, 'utf8').digest();
…
return gotBuf.length === expBuf.length && crypto.timingSafeEqual(gotBuf, expBuf);
Google Apps Script は、送る時刻と本文をまとめて署名します。受け取る側は、まず送信時刻が5分以内かを確かめ、それから同じ秘密の鍵で署名を作り直して比べます。
盗んだ要求をあとから送り直しても、5分を過ぎれば通りません。比べるときは timingSafeEqual を使い、比べ終わるまでの時間から正解を探られないようにしています。
DownloadEx
PS> Get-Item .\DownloadEx | Format-List *
ダウンロードしたファイルを、ルールに従って自動でフォルダに振り分けるChrome拡張。

DownloadExの画面と技術的な詳細を読む(設計・つまずき・コード)
1使っている画面

2作品の詳細
ルールで決めること
| 項目 | 選べるもの |
|---|---|
| どのURLで判定するか | ファイルそのもののURLか、ダウンロードしたページのURLか |
| 一致のしかた | 部分一致・完全一致・正規表現 |
| ファイルの種類 | 拡張子で絞る(任意) |
| 保存先 | {YYYY} {MM} {DD} {YYYY-MM-DD} {domain} を差し込める。フォルダ名に使えない文字は置き換える |
| 優先度 | 複数のルールに当てはまったら、数字の大きい方を使う |
求める権限は、ダウンロード・保存・右クリックメニューの3つだけ。閲覧しているページの中身には触りません。
右クリックから、そのサイトのルールを作る
ページの上でもリンクの上でも、右クリックメニューの「DownloadEx: このサイトのルールを作成」からルールを作れます。リンクの上ならリンク先の、ページの上なら今見ているページのサイト名が入った状態で、設定画面の新規ルールが開きます。
3こだわったこと
ダウンロードのたびに、待たせない
- やりたかったこと
-
ダウンロードした瞬間に、もう正しいフォルダに入っています。拡張機能が動いていることを、使う人に意識させたくありませんでした。
- ぶつかった壁
-
Manifest V3の拡張は、しばらく使わないと裏で動く処理(Service Worker)ごと止められ、次のダウンロードで起こされます。起きるたびにルールを読み直すと、そのぶん保存が遅れます。
端末間で同期される保存先(
storage.sync)は便利ですが、読み込みに時間がかかります。 - どう作ったか
-
同期される保存先の写しを、手元の速い保存先(
storage.local)に作っておきます。起動したらまず写しから読み、判定に使う形に先に直してメモリに持ちます。書き方の間違った正規表現は、この段階で見つけて無効にします。ダウンロードのたびに例外で止まることがありません。
- どうなったか
-
起こされた直後でも、同期される保存先の遅い読み込みを待たずに、手元の写しからすぐに振り分けられます。
つまずいたところURLが合わないルールでも、振り分けてしまう
- 起きたこと
URLが合っていないダウンロードまで、優先度がいちばん高いルールのフォルダへ入っていました。反対に、ファイルの種類を指定していないルールは、URLが合っても使われませんでした。
- 原因
ルールを優先度の順に調べるループで、URLが合ったときだけ、ファイルの種類を確かめていました。合わなかったときに次のルールへ進む処理がなく、そのまま振り分けの処理まで素通りしていました。
ファイルの種類の判定も、「指定なし」を「どれにも当たらない」と読んでいました。
- 直し方
URLが合わなければ、その場で次のルールへ進むようにしました。条件を入れ子にせず、合わない条件があれば先に抜けるガード節の形にしたので、外れたルールが下へ素通りすることはありません。ファイルの種類は、指定がなければ全部を対象にします。
background.js(335〜344行目)
// URLマッチしなければ次のルールへ
if (!isMatch) continue;
// ファイルタイプ指定がある場合のみ拡張子チェック
// 空配列 = 全ファイルタイプ対象なのでチェックをスキップ
if (rule.lowerCaseFileTypes.length > 0) {
if (!ext || !rule.lowerCaseFileTypes.includes(ext)) {
continue;
}
}
要になったコード壊れた正規表現は、最初の1回で見つけて外す
background.js(143〜148行目)
const compiledRegex = (rule.matchType === 'regex' && rule.sitePattern)
? (() => {
try { return new RegExp(rule.sitePattern, 'i'); }
catch (e) { console.error(`[DownloaderExt] Invalid Regex: ${rule.sitePattern}`); return null; }
})()
: null;
ルールを読み込んだときに、正規表現を1回だけ組み立てておきます。書き方が間違っていれば、ここで null にして、そのルールだけを外します。
ダウンロードのたびに組み立て直さないので速く、間違ったルールがあっても、ほかのルールの振り分けは止まりません。