寄せ書き。SvelteKit + Bun + SQLite のコメントサーバ。記事のどのコメント欄からでも、サイト全体の新着・自分宛ての返信・承認待ちが見える。管理画面は無い。
<div id="yosegaki"></div>
<script src="https://yk.doany.io/embed.js"></script>これだけで動く。アカウントは要らず、名前だけで書ける。返信・いいね・Markdown・プレビュー・本人による編集と削除・メール通知。既定では承認なしで即公開し、bot は Cloudflare Turnstile とハニーポットで止める。
- スタイルを押し付けない: 描画は埋め込み先の DOM に直接行い、色は埋め込み先の文字色を薄めて使う。ライト/ダークの切り替えは何もしなくて追従する。
--ysg-accentなどの変数で寄せられるし、data-css="false"で同梱 CSS を丸ごと捨てて自分で書いてもいい。クラスは全部ysg-始まり - 管理画面を作らない: 管理者ログインはコメント欄の「通知」の中にあるが、読者には見せない。記事の URL に
#yosegaki-adminを付けて開いたときだけボタンが出る。パスワードは持たず OIDC (Entra ID など) だけで、ログインすると、その場で承認・削除ができ、承認待ちと検索のタブが増える。「最近」に並ぶ全記事のコメントもパネルから直接消せる。荒らしの掃除に別のページは要らない - API は UI と独立:
/api/v1は JSON だけ返す。埋め込みスクリプトはその一利用者に過ぎず、自分で画面を作るならそのまま叩ける - 1 プロセス 1 ファイル: SQLite (WAL) を 1 つ持つだけ。バックアップはファイルのコピー
<script> の data-* 属性で調整する。全部省略できる。
| 属性 | 既定 | 意味 |
|---|---|---|
data-target |
#yosegaki |
描画先のセレクタ |
data-page |
location.origin + pathname |
スレッドの鍵。クエリ文字列は含まれない |
data-title |
document.title |
通知センターに出す記事名 |
data-url |
data-page と同じ |
通知やメールからのリンク先 |
data-lang |
<html lang> から判定 |
ja / en |
data-sort |
newest |
newest / oldest / popular |
data-css |
true |
false なら同梱 CSS を差さない |
data-auto |
true |
false なら自動で描画せず Yosegaki.init() を待つ |
data-admin-hash |
#yosegaki-admin |
この hash を付けて開いたときだけ管理者ログインを出す |
data-lazy |
true |
描画先が画面に近づく (手前 600px) まで本体を読まない。false ならすぐ読む。#ysg-<id> や管理者用の hash 付きで開いたときは待たない |
SPA (swup など) でページを差し替えるなら data-auto="false" にして、遷移のたびに呼ぶ。
const instance = Yosegaki.init({ page: location.origin + location.pathname });
instance.destroy(); // 次の遷移の前にinit() はすぐ戻るが、描画は本体を読み終えてから (lazy なら画面に近づいてから)。待ちたければ await instance.ready で、instance.store もそれまでは null。
一覧ページで件数だけ欲しいときは Yosegaki.counts([url1, url2]) が {url: 件数} を返す。
embed.js は 2KB ほどの入口で、Svelte も描画も持たない。本体 (/embed/app-<hash>.js など) は init() のあと、既定ではコメント欄が画面に近づいたときに import() で読む。描画先が display: none の間は近づいたことにならないので、隠れたタブの中などに置くなら data-lazy="false" にする。通知パネルはさらに別のチャンクで開くときに、Cloudflare の Turnstile は投稿欄に触れたときに初めて取りに行く。
本体は embed.js と同じ場所の embed/ から読む。embed.js を自分でコピーして配るなら embed/ も一緒に置く。
embed.jsとwidget.css:max-age=300, stale-while-revalidate=86400と ETag。切れても裏で取り直し、変わっていなければ 304/embed/*-<hash>.js: 中身が変わればファイル名が変わるのでmax-age=31536000, immutable/embed/app.js: 下の逃げ道。no-cacheで毎回 ETag で確かめさせる- どれも br / gzip で返す (前段のプロキシに圧縮を任せない)。他のオリジンから
import()されるのでAccess-Control-Allow-Origin: *
デプロイ直後にキャッシュに残った古い embed.js が、もう無い本体を指すことがある。そのときはハッシュの無い /embed/app.js (今の本体を指す) から取り直す。古い本体がブラウザのキャッシュから使われた回に限り、通知パネルのチャンクが見つからず開けないことがある (次に読み直せば直る)。投稿に要るものは本体に入れてあるので、投稿はできる。
#yosegaki {
--ysg-accent: var(--primary); /* ボタン・リンク */
--ysg-accent-fg: #fff; /* その上の文字 */
--ysg-radius: 0.75rem;
--ysg-line: rgb(0 0 0 / 0.1); /* 罫線 */
--ysg-surface: rgb(0 0 0 / 0.04); /* 入力欄などの背景 */
}docker run -d -p 3000:3000 -v yosegaki:/usr/src/app/data \
-e ALLOWED_ORIGINS=https://example.com \
-e SECRET=... \
-e OIDC_ISSUER=... -e OIDC_CLIENT_ID=... -e OIDC_CLIENT_SECRET=... -e OIDC_ADMIN_GROUPS=... \
ghcr.io/danything/yosegaki| 環境変数 | 既定 | 意味 |
|---|---|---|
ALLOWED_ORIGINS |
(無し) | 埋め込みを許すオリジン。カンマ区切り。空のままだと起動しない |
ALLOW_ANY_ORIGIN |
false |
true なら ALLOWED_ORIGINS が空でも起動する。誰でも埋め込める状態になる |
OIDC_ISSUER / OIDC_CLIENT_ID / OIDC_CLIENT_SECRET |
(無効) | 管理者のログイン。3 つ揃うと有効で、無ければ管理機能ごと消える。redirect URI は https://<host>/admin/callback |
OIDC_ADMIN_GROUPS |
(無し) | 管理者にするグループ。groups / roles クレームに含まれる値のカンマ区切り (Entra ならグループの Object ID) |
OIDC_ADMINS |
(無し) | 管理者にする個人。email / preferred_username / sub のカンマ区切り |
OIDC_LABEL |
SSO |
ログインボタンの表示 (「〜 でログイン」) |
ADMIN_NAME / ADMIN_EMAIL |
admin / (無し) |
管理者の表示名と、新着を受け取るメール |
SECRET |
起動ごとに乱数 | トークン署名と IP ハッシュの鍵。無いと再起動でログアウトする |
ADMIN_TOKEN_DAYS |
7 |
管理者トークンの有効期間 (日) |
TURNSTILE_SITE_KEY / TURNSTILE_SECRET |
(無し) | Cloudflare Turnstile の鍵。両方あれば投稿に人間の確認を挟む。見た目は必要なときだけ出る |
MODERATION |
none |
none: 承認なしで公開 / links: リンクが MAX_LINKS (2) を超えたら承認待ち / all: 全部承認待ち |
BLOCK_WORDS |
(無し) | 含んでいたら承認待ちにする語。カンマ区切り |
MAX_LENGTH |
4000 |
本文の上限 |
RATE_LIMIT_MAX / RATE_LIMIT_WINDOW |
5 / 600 |
同じ IP から 600 秒に 5 件まで |
OWNER_EDIT_MINUTES |
60 |
本人が編集・削除できる時間。0 で無期限 |
AVATAR |
gravatar |
none でアバターを出さない |
ALLOW_IMAGES |
false |
Markdown の画像を許す |
CLIENT_IP_HEADER |
(無し) | 接続元 IP を取るヘッダ。Cloudflare なら CF-Connecting-IP |
SMTP_HOST / SMTP_PORT / SMTP_USER / SMTP_PASS / SMTP_FROM / SMTP_SECURE |
(無し) | 通知メール。SMTP_HOST が無ければ送らない |
WEBHOOK_URL |
(無し) | 新着と承認を JSON で POST する先 |
SITE_NAME |
yosegaki |
メールの件名 |
DB_PATH |
data/yosegaki.db |
SQLite の置き場 |
- Turnstile:
TURNSTILE_SITE_KEYとTURNSTILE_SECRETを入れると、送信のたびにトークンを取ってサーバで照会する。Cloudflare のダッシュボードで Turnstile → ウィジェットを作り、ホスト名に埋め込み先 (doany.io) を登録する。ウィジェットのモードは Managed のままでいい。表示は「必要なときだけ」なので普段は何も出ない - ハニーポット: 人間に見えない入力欄が埋まっていたら捨てる
- レート制限: 同じ IP から
RATE_LIMIT_WINDOW秒にRATE_LIMIT_MAX件まで - 承認待ち:
BLOCK_WORDSを含むものは常に承認待ち。MODERATION=linksならリンクの多いものも
自前のパスワード認証は持たない。追従が辛い割に得るものが無いので、Entra ID や Keycloak など OIDC が話せる IdP に任せる。記事を #yosegaki-admin 付きで開くと通知パネルに「〜 でログイン」が出る。押すとポップアップで /admin/login を開き、IdP を経て /admin/callback に戻る。そこで id_token の aud / iss / exp / nonce を確かめ、OIDC_ADMIN_GROUPS / OIDC_ADMINS に照らし、通れば管理者トークンを postMessage で開いた元のウィンドウに返す。Cookie は使わないので、埋め込み先のドメインが違っても Safari で困らない。
id_token の署名は見ない。認可コードフローでは token endpoint から TLS で直接受け取るので、相手が誰かは TLS が保証している (OIDC Core 3.1.3.7 が明示的に認めている)。その代わり discovery が広告してくる token_endpoint と authorization_endpoint が https かどうかを確かめる (localhost は開発用に例外)。
IdP 側では redirect URI に https://<host>/admin/callback を登録し、scope openid profile email を許す。グループで絞るなら id_token に groups クレームを出す設定にして (Entra: トークン構成 → グループ要求を追加)、OIDC_ADMIN_GROUPS にグループの Object ID を書く。
本人の識別は端末ごとの乱数 (X-Visitor) で、サーバには SHA-256 だけ残る。メールは通知にしか使わず、API には出ない (アバターは gravatar のハッシュ)。IP もハッシュしてレート制限にだけ使う。
ALLOWED_ORIGINS に書いたオリジンからしか使えない。空のままでは起動しないので、誰でも埋め込める状態に事故で出ることはない (承知の上なら ALLOW_ANY_ORIGIN=true)。効き方は 3 つ。
- CORS: 許可外オリジンからのプリフライトは 403。ウィジェットは常に
X-Visitorを送るのでプリフライトが必ず起き、許可していないサイトに貼っても動かない。プリフライトの要らない形 (text/plainの POST など) で抜けられないよう、許可外オリジンからの書き込みはハンドラの手前で 403 にする - スレッドの鍵:
pageは http(s) の URL に限り、オリジンをサーバ側で照合する。クエリと hash は落として正規化するので、同じ記事のスレッドが分裂しない - 管理者ログイン: トークンを
postMessageで返す先も許可オリジンだけ
ただし Origin を送るのはブラウザだけなので、これは「他人のサイトに貼らせない」仕組みであって、スクリプトからの直接投稿は止まらない。そこを止めているのは Turnstile とレート制限と承認待ちで、匿名で書ける以上どのコメントサーバも事情は同じ。Origin の無いリクエストは API の利用者として通す。
管理者トークンは埋め込み先の DOM に描く都合上そのドメインの JS からは見えるので、sessionStorage に置いてタブを閉じたら消し、有効期間も既定 7 日と短めにしてある。埋め込み先に XSS があれば読まれる前提で、ADMIN_TOKEN_DAYS はできるだけ短く。
- 管理者 (
ADMIN_EMAIL) には投稿のたびにメール。承認待ちならそう書いてある - 投稿者がメールを書いて「返信を受け取る」に印を付けていれば、返信が公開された時点でメール
WEBHOOK_URLにはcomment.created/comment.approvedを POST
チャートは oci://ghcr.io/danything/charts/yosegaki。k3s の HelmChart CRD から参照する。
apiVersion: helm.cattle.io/v1
kind: HelmChart
metadata:
name: yosegaki
namespace: kube-system
spec:
chart: oci://ghcr.io/danything/charts/yosegaki
version: 0.1.x
targetNamespace: blog
valuesContent: |-
host: yk.doany.io
allowedOrigins: [https://doany.io]
turnstile:
siteKey: 0x...
oidc:
issuer: https://login.microsoftonline.com/<tenant>/v2.0
clientId: ...
adminGroups: [<グループの Object ID>]
label: Microsoft
infisicalSecret:
enabled: true
identityId: ...
projectSlug: k3s-cluster
path: /yosegaki/yosegaki-secrets入口は Gateway API の HTTPRoute と標準の Ingress のどちらでも作れる。既定は ingress.enabled: true の
Ingress (annotations は Traefik 向け) だが、Ingress API は凍結済みなので新しく組むなら httpRoute。
その場合は ingress.enabled: false にして、付け先の Gateway を httpRoute.parentRefs に書く
(HTTPRoute は自分では待ち受けを持たないので省略できない)。証明書は Gateway 側のリスナーが持つ。
host: yk.doany.io
ingress:
enabled: false
httpRoute:
enabled: true
parentRefs:
- name: my-gateway
namespace: gateway-system
sectionName: https
# 空なら上の host をそのまま使う
hostnames: []values.yaml に全部書いてある。秘密は existingSecret (既定 yosegaki-secrets) のキー secret oidc-client-secret smtp-password turnstile-secret で渡し、Infisical 純正 operator で引くなら infisicalSecret を有効にする。PVC には helm.sh/resource-policy: keep が付いていて、リリースを消しても DB は残る。
すべて /api/v1 配下。エラーは常に {"error": "rate_limited", "message": "..."} の形。認証は 2 つのヘッダだけ。
X-Visitor: <端末ごとの乱数>… 本人判定 (いいね、編集、自分宛ての返信)Authorization: Bearer <token>… 管理者
| メソッド | パス | 中身 |
|---|---|---|
| GET | /comments?page=&sort=&limit=&offset= |
ページのコメント。返信も同じ配列に平らに入る (parent_id / root_id)。承認待ちは本人と管理者にだけ |
| POST | /comments |
{page, title, url, parent_id, name, email, website, body, notify}。hp が埋まっていたら bot とみなして捨てる |
| GET / PATCH / DELETE | /comments/:id |
本人 (期限内) か管理者。返信が付いていれば墓標として残り、無ければ消える |
| PUT | /comments/:id/like |
{value: true|false}。何度送っても同じ |
| POST | /preview |
{body} → {body_html} |
| GET | /count?page=a&page=b |
承認済みの件数 |
| GET | /recent?before=&limit= |
サイト全体の新着 |
| GET | /me/comments /me/replies |
自分の投稿、自分宛ての返信 |
| GET | /admin/me /admin/pending /admin/comments?q=&status= |
管理者の確認、承認待ち、横断検索 |
| GET | /admin/login?origin= /admin/callback |
(API 外) OIDC の入口と戻り先。ポップアップで使う |
| POST | /admin/comments/:id/approve |
承認 |
| GET | /config /health |
公開設定、死活 |
一覧系は before=<id> で続きを取る (新しい順)。本文は body_html (サニタイズ済み) と body_md の両方が返る。
bun i
bun run dev # 埋め込みを一度ビルドしてから SvelteKit の dev サーバ
bun run dev:widget # 別ターミナルで埋め込みを監視ビルド
bun run check # biome + svelte-check
bun testhttp://localhost:5173 がデモページで、そのサーバ自身に埋め込みを貼ってある。genkan があれば docker compose up で https://yosegaki.localhost 。
src/lib/server/ env / db (bun:sqlite) / comments (取得・投稿・削除) / markdown (marked + sanitize-html) / spam / auth / notify
src/routes/api/ エンドポイント。1 ファイル 1 パス
src/widget/ 埋め込み (Svelte 5)。main.ts が embed.js (入口)、app.ts が本体、store.svelte.ts が状態、widget.css が同梱スタイル
widget-dist/ vite.widget.config.ts で作る生成物 (git には入れない)。routes/embed.js と routes/embed/[file] が配る
charts/yosegaki/ Helm チャート。CI が OCI で ghcr に push する