| name | gkill-go-backend |
| description | gkill の Go バックエンド(src/server/gkill/)の禁止事項と契約。検索フィルタ(rep名の絞り込み・タグ絞り込みの2経路・FindQuery.IDs のチャンク分割)、キャッシュrepの判定とライトスルー、キャッシュのフルリビルド抑止、リポジトリ4層と doc コメント網羅、safefetch・共有ファイル認可・型別 GetXxx の最新版取得などの HTTP セキュリティを扱う。src/server/gkill/api/・dao/・usecase/・req_res/ を編集・追加するとき必読。「検索が黙って0件になる」「追加したタグが最大1分見えない」「全種類の検索が数分止まる」「タグの個数で検索結果が変わる」を調べるときも必読。 |
Go バックエンドの不変条件
対象: src/server/gkill/api/** / src/server/gkill/dao/** / src/server/gkill/usecase/**
このファイルは全文が、実際に起きた事故の再発防止である。該当作業では飛ばさずに読むこと。
多くは「例外もエラーも出さずに静かに壊れる」種類で、破っても目の前ではエラーにならない。
Backend (Go) — src/server/
Module: github.com/mt3hr/gkill/src/server (Go 1.26.6)
Key packages:
gkill/api/ — Shared infrastructure: embed.go (//go:embed serves Vue SPA at /), version.go, gkill_version_data.go, find_filter.go, find_filter_helpers.go, find_kyou_context.go
gkill/api/gkill_server_api/ — HTTP API handlers (105 files incl. tests, 1 handler per file). GkillServerAPI struct with serve.go, close.go, route definitions in gkill_server_api_address.go. Auth middleware (auth.go, auth_context.go, auth_middleware.go) extracts session→account→device→repositories via AuthContext, authMiddleware, authWithReposMiddleware. Handler registration uses wrapper functions: wrapNoAuth (no session), wrapAuth (session + account), wrapAuthRepos (session + account + device + repositories). Utility files: filter_local_only.go, utils.go, web_push.go. ZIP browsing: handle_browse_zip_contents.go (path traversal prevention, Shift_JIS→UTF-8, singleflight dedup).
gkill/api/req_res/ — Request/response structs for every endpoint (186 files)
gkill/usecase/ — HTTP-independent business logic (17 files). Extracted from handlers to enable reuse without HTTP context. Functions operate on DAO/repository types directly.
gkill/dao/ — Data access layer with GkillDAOManager managing SQLite3 databases
gkill/dao/reps/ — Repository interfaces and implementations for each data type.
Key dependencies: gorilla/mux, modernc.org/sqlite (pure Go), spf13/cobra+viper, asticode/go-astilectron, SherClockHolmes/webpush-go, go-git/go-git, twpayne/go-gpx, nicksnyder/go-i18n/v2, google/uuid
Doc comments: dao/reps/*_repository.go のインターフェースメソッドは 459/459 documented、gkill_server_api の HandleXxx は 92/92 handlers documented。どちらも verify_docs が網羅率を機械検査するので、メソッドやハンドラを追加したら doc コメントも書くこと。共通契約の完全文は基底 dao/reps/repository.go に1回だけ書き、型別ファイルは「契約は Repository.Xxx を参照。」の1行参照+差分のみにする(457箇所へ複製するとドリフト源になるため)。ハンドラは「1行説明 / 空行 / パス・HTTPメソッド・認証区分 / req_res 型」の形式。
Repository pattern: SQLite-backed data types have 4 implementation layers: *_repository.go (interface) → *_repository_sqlite3_impl.go → *_repository_cached_sqlite3_impl.go → *_repository_temp_sqlite3_impl.go. Exceptions: git_commit_log (_local_dir_impl + _cached_sqlite3_impl), gps_log (_gpx_dir_impl), and plugin (plugin_repository_impl) read external sources directly and do not have all 4 layers.
FindQuery.IDs は分割して渡す(SQLのバインド変数には上限がある): 新しくIDリストを渡す検索を書くときは findChunkedByIDs(repositories.go)を通すこと。Mi / MiReKyou は5射影のUNIONでバインド変数が 5N+5 になり、SQLite の上限を N=6553 で超える。今かかっているのは Repositories.findKyous / MiRepositories.FindMi / MiReKyouRepositories.FindMiReKyou と、GkillRepositories.FindTags / FindTexts の中で最新版アドレスからIDリストを作っている箇所。上限は maxIDsPerFindQuery(4000)。この壊れ方は静かなので気付きにくい ―― 失敗しても GkillError が立たず HTTP 200 + errors: null + 0件で返る。受け皿として message.EnsureNotEmpty で ERR000410 を必ず立てるようにしてある。「失敗したのに GkillError が空」で return する分岐を新しく作らないこと。 回帰は repositories_id_chunk_test.go / handle_get_kyous_mcp_test.go の TestHandleGetKyousMCP_ManyIDs / message/gkill_error_test.go が守る。経緯と却下案は ADR-0005。
rep名の絞り込みは「検索するrep」ではなく「検索結果」でやる: find_filter.go の selectMatchRepsFromQuery で UnWrap() の戻り値を MatchReps に入れてはいけない。UnWrap() はインメモリキャッシュrep(と --cache_reps_local のローカルコピー層)を剥がして生のディスクrepに戻すので、キャッシュを丸ごとバイパスして端末別重複repぶんディスクを舐めることになる。UnWrap() を使ってよいのは「そのラッパに選ばれた実repが1つでもあるか」の枝刈り判定だけ(これは省けない)。実際の絞り込みは findKyous の filterKyousByRepName が Kyou.RepName で行う。落とし穴が5つある: (1) 本文ヒット由来の2本目の検索(matchTextFindByIDQuery)にも同じ絞り込みが要る、(2) 全部落ちたIDはキーごと消す(空スライスを残すと kyous[0] を見る filterLocationKyous / filterMiForMi / overrideKyous が panic する)、(3) Reps == nil は「未指定」で len() で判定すると全件消える、(4) RepName が空の行は残す ―― 追加直後の行は REP_NAME が空なので、落とすといま追加した記録が最大1分間一覧から消える。(5) 書き込み側に実在しないrep名を入れさせない ―― 非空のrep名は「実在するが選ばれていないrep」として落とされるので、合成した名前を渡すと記録が黙って消える。フィルタ側に *Temp の例外を足してはいけない(直すのは常に書き込み側)。dao/reps 側にも置いてはいけない(ReKyou/MiReKyou のワード委譲が利用者のクエリをそのまま FindKyousSequential へ渡すので、チェックしていないrepに参照先があるリポストが黙って語句検索に当たらなくなる)。SQL の WHERE へ降ろすのも不可(相関サブクエリに足すと最新版判定が壊れる → ADR-0002)。守るテストは select_match_reps_cache_test.go / find_kyou_rep_name_filter_test.go / get_kyous_rep_filter_test.go / get_kyous_tx_rep_filter_test.go(すべてキャッシュON/OFFの両方)。実測(11rep→約940rep・20.7秒)と事件の経緯は ADR-0001。
タグ絞り込みの取得は2経路。切り替えは「タグ名の個数」で決める: find_filter.go の collectTagsForFilter が MatchTags(クエリのタグ名に一致するタグ)・RelatedTagIDs(タグが1つでも付いているIDの集合)・AllHideTagsWhenUnchecked(強制非表示タグ)の3つを作る。切り替えの閾値は maxTagNamesForSQLFilter(32。クエリのタグ名と非表示タグ名の合計で見る)。「タグ無し」仮想タグ(NoTags)を使う検索では、名前の個数によらずGo側で照合する(RelatedTagIDs のために結局は全タグを取るので、そこから名前を拾うぶんはタダになる)。照合は完全一致・大小無視で、filterTagsKyous のAND分岐と同じ意味論(SQLは TAG 列だけでなく ID 列とも突き合わせていたので、そこも写してある)。2経路が同じ結果を出すことをテストで固定してある(find_filter_test.go)—— ずれるとタグの個数によって検索結果が変わるという静かな壊れ方になる。RelatedTagIDs の読み手は NoTags 分岐しか無いので、NoTags を使わない検索では作らない(走らせ忘れると全件が「タグなし」扱いになりタグの付いた記録まで返る。回帰は get_kyous_tag_filter_test.go と get_kyous_regressions_test.go の TestHandleGetKyous_TimeIsTagsFilterWorksWithoutKyouTagFilter)。強制非表示タグも2経路の一致を TestHandleGetKyous_HideTagsBothPaths が固定する。実測表(2万タグでの交差点)と却下案は ADR-0003、実測表そのものは dao/reps/tag_find_bench_test.go。
キャッシュのフルリビルドは「実DBが変わったときだけ」: cached rep の UpdateCache は DELETE FROM + 全行再INSERT なので、変わっていない rep まで作り直すと共有の書き込みロックを握ったまま全種類の検索が止まる。抑止は dbFileChangeDetector(mtime + サイズ)で、基準を進めるのは再構築成功後の CommitCacheRebuild だけ(失敗した回を取りこぼさないため)。--cache_reps_local=true のときに挟まる *_local_cached.go も同じ約束を守ること ―― 「コピーが要るか」の判定は必ず os.Remove より前に置く。消してから os.Stat すると常に「要コピー」になり、LastUpdateCacheChanged() が常に true を返して抑止が丸ごと無効化される。共通ヘルパは local_rep_cache_copy.go、回帰検出は local_rep_cache_granular_test.go。ReKyou / MiReKyou は変更検知に載せてはいけない(コピー省略のみ可): この2つはターゲット解決のためアドレス確定後にもう一度更新される仕様で、mtime判定を入れると2回目が飛んでターゲット未解決の中身が残る。実測(phase1 が 0.2秒→1〜2分)と却下案は ADR-0011。
最新版アドレス表へ焼く rep 名は「行ごとの実rep名」。集約名を焼いてはいけない: キャッシュrepの GetLatestDataRepositoryAddress は LATEST_DATA_REPOSITORY_NAME に REP_NAME 列を射影する(? AS ... に GetRepName() をバインドしない)。キャッシュrepが包んでいるのは集約なので GetRepName() は "KmemoReps" / "IDFKyouReps" のようなどのrepにも一致しない固定文字列を返す。GkillRepositories.GetKyou はこの名前を Reps.UnWrap() が返す leaf rep の実名と突き合わせて問い合わせ先を1repへ絞るので、集約名を入れると比較が永遠に外れ、エラーも立たず (nil, nil) が返る。2026-08-24 に usecase/tag.go / usecase/text.go の実在検査がこれを踏み、実在する記録へのタグ/テキスト追加が軒並み ERR000092 で失敗した。? を1つ減らしたら QueryContext の引数も対で落とす(残すとバインドがずれて静かに0件。2026-08-02 の notification_repository_cached_sqlite3_impl.go と同型)。MiReKyou だけは例外 —— UnWrap() が自分自身を返すので両辺が同じ GetRepName() で自己一貫しており、REP_NAME へ変えるとむしろ壊れる。壊れ方は条件つきで、APIの書き込み経路はアドレス表へ leaf 名を直接書くため同じプロセスで追加した直後の記録には付けられる —— 失敗するのは UpdateCache が索引した後(スキャン由来の全記録と再起動後の全記録)。キャッシュOFFでは再現しない。守るテストは dao/reps/gkill_repositories_get_kyou_test.go と dao/reps/latest_data_address_rep_name_scan_test.go(ソース走査)。経緯と実測は ADR-0019。
len(XxxReps) == 1 でキャッシュrepを判定してはいけない。 アダプタの append は「キャッシュrepで XxxReps を1個に差し替える」処理より後なので、provides を持つプラグインが1つ入るだけで長さが2になる。書き込み後のキャッシュ反映は構築時に控えた GkillRepositories.CachedReps を見る repositories.WriteThroughXxxCache(ctx, ...) を使うこと(54箇所)。読み取りはキャッシュrepしか見ず下層repへフォールバックしないので、反映を飛ばすと追加したタグが最大1分見えず、その間にPWAが古い応答をキャッシュし直すと恒久的に古いまま焼き付く。再発は usecase/write_through_cache_test.go の TestNoRepsCountCacheGuard がソース走査で落とす。経緯と却下案は ADR-0012。
Mi の1件取得は、どの射影を名乗るかを SQLite の UNION 出力順に決めさせない。 MI テーブルに DATA_TYPE 列は無く、5射影(mi_create / mi_check / mi_limit / mi_start / mi_end)は同じ1行から SQL が合成するラベル。5つとも UPDATE_TIME が同着なので、素の slices.MaxFunc は「UNION が返した先頭」を返す。SELECT の列の並びが違うだけで勝つ射影が変わり、実際 GetMi は mi_check・GetKyou は mi_create を返して1つの応答の中で種別名が食い違っていた(2026-08-24 の再監査)。同着は compareMiProjectionPreference で正準(mi_create=検索の既定 mi_sort_type=create_time と同じ)に割る。単体取得は &kyous[0] を返さないという既存の規則(外部監査 H-07)から外れていた2箇所(mi_repository_cached_sqlite3_impl.go / mi_re_kyou_repository_sqlite3_impl.go)も揃えてある。
索引を持つ rep は鮮度を出す。 rep ディレクトリへ置いただけのファイルは UpdateCache が IDF() を走らせるまで検索に出ないが、定期実行も監視も無く、警告も出ないので「0件」が「まだ取り込んでいない」なのか「本当に無い」なのか区別できなかった。get_rep_infos の indexed_at(任意インタフェース IndexUpdatedAt を実装した rep だけ)で判断させる。検索のたびにディレクトリを全走査して未採番を数えてはいけない(実データは56万行規模)。
タグ語彙の列挙は2つある。検証には「対象の生死を問わない」ほうを使う。 GkillRepositories.GetAllTagNames は対象が削除済みのタグを落とす —— 記録を消してもタグは消えない(消すと gkill_restore_kyou で復活したときにタグが失われる)ので、落とさないと「選んでも0件」の候補が溜まり続ける。生存判定は最新版アドレス表を引くだけで追加のI/Oは無い(GetAllTags が既に TargetID を持って返る)。アドレス表に載っていない対象は落とさないこと —— プラグインや git の記録は表に載らないので、落とすと語彙が黙って痩せる。一方「そのタグ名は実在するか」の検証(collectMCPUnknownValueWarnings)は GetAllTagNamesIncludingDeletedTargets を使う。フィルタ済みの一覧で検証すると、include_deleted_data:true で削除済みを開いたタグ検索に未知のタグという誤警告が出る。却下案(カスケード削除・SQLへの降ろし)は ADR-0073。
HTTP ステータス(2026-08 導入)
エラーコード → HTTP ステータスの表が正本。 api/message/http_status.go の
errorCodeHTTPStatus(414件、400/401/403/404/409/413/429/500)と HTTPStatusForErrors。
2026-08 まで /api/* の JSON ハンドラは全部が暗黙の200で、セッション切れも権限不足も
内部エラーも「成功」に見えていた。ステータスを見る層(監視・プロキシ・アクセスログ・
素朴なHTTPクライアント)から障害が完全に隠れていた。
守ること3つ。どれも破っても目の前ではエラーにならない。
- エラーコードを足したら
http_status.go の表にも1行足す。 迷ったら 500。
落とすのは message/http_status_test.go(error_codes.go をソース走査して未分類を検出。
ステータスごとの件数も固定してあるので「全部500にしておく」も落ちる)。
- ハンドラを足したら
writeErrorStatus(w, response.Errors) を
json.NewEncoder(w).Encode(response) の直前に置く。 順序が命で、
本文を1バイト書くと net/http が 200 を確定させ、あとから WriteHeader を呼んでも
superfluous response.WriteHeader がログに出るだけでステータスは200のまま返る。
errors 配列は正しいので画面は普段どおり動き、気付けない。
落とすのは gkill_server_api/response_status_guard_test.go(全 handle_*.go を走査。
免除は handle_file_serve.go / handle_urlog_bookmarklet_*.go の3本だけ)。
- ハンドラより手前で打ち切る経路(
auth_middleware.go / filter_local_only.go)は
writeGkillErrorResponse を通す。 直に json.NewEncoder(w).Encode(...) を書くと
ステータスを書き忘れる。本文を必ず JSON で返すのも要件 ——
クライアント(gkill-api.ts)はステータスを見ずに res.json() するので、
本文が空だとそこで例外になり、ログイン画面に「証明書が必要です」という
無関係な文言が出る。
名前から機械的に導けないので推論に置き換えないこと。 error_codes.go の語彙には
Forbidden / Unauthorized / Denied / Permission が1件も無く、Invalid* が 400 と 500 に、
NotFound* が 401 と 404 に跨る。取り違えると実害が出る割り当ては
http_status_test.go の TestHTTPStatusOf_KnownAssignments が名指しで固定してある
(AccountSessionNotFoundError は404ではなく401、NotFoundTLSCertFileError は
サーバの設定不備なので404ではなく500、など)。
AccountNotFoundError(ERR000002) は認証経路(auth.go)専用。 操作対象のアカウントが
無いときは TargetAccountNotFoundError(ERR000413)。混ぜると、クライアントの check_auth が
ERR000002 でログアウトさせるので、存在しないユーザIDにパスワードリセットを実行した
管理者がその場で締め出される(2026-08 まで実際にそうなっていた)。
recoverMiddleware は serve.go で最外層と最内層の両方に登録してある。 内側が要るのは、
gzipMiddleware の defer gzipWriter.Close() が panic の巻き戻しで先に走り、
空の gzip ストリームを書いて暗黙200を確定させるため。外側の recover が書く500は捨てられ、
200 + 復号すると空の本文が返っていた(再現テストで確認)。順序を戻さないこと。
守るのは response_status_test.go の TestResponseStatus_PanicReturns500WithGzip。
外向き(gkill がHTTPクライアントのとき)も 2xx を見る。 api/safefetch の GetCapped は
2xx 以外をエラーにする。見ていなかったので404ページのHTMLが favicon として base64 で
保存されていた。favicon は加えて LooksLikeSupportedImage(gif/jpeg/png/webp。判定形式は
use-ur-log-view.ts の base64_to_data_uri と揃える)と CheckImageDimensions を通す。
スキーム無しURLは u.Hostname() が空になり Google が汎用アイコンを200で返すので、
リクエスト前に弾く(dao/reps/ur_log.go の getFavicon)。
HTTP セキュリティ(2026-08 外部監査由来)
共有ページのファイル配信は共有クエリの結果に含まれるファイルだけ(2026-08-21、監査 C-03)。handle_file_serve.go の sharedID != "" 経路は、rep名一致だけで IDF rep へ委譲せず、shared_file_authz.go の collectSharedIDFFilePaths で共有 FindQuery を再評価して許可パス集合を作り、要求パスが集合に無ければ403にする(handle_get_shared_kyous.go と同一手順を共有=一覧を2箇所で維持しない)。パス正規化は idf_file_url.go の cleanRelativeURLPath と同一。セッション経路はフルアクセスのまま(sharedKyouInfo != nil のときだけゲート)。守るテストは shared_file_authz_test.go。 却下案(許可パスを共有作成時に固定する等)は ADR-0042。
利用者入力URL・そのページが指す og:image / #landingImage の取得は必ず api/safefetch を通す(2026-08-21、監査 H-04)。safefetch.GetCapped(scheme検査・Dialer.Control での接続先IP検証・サイズ上限)と safefetch.CheckImageDimensions(image.DecodeConfig で復号前に総ピクセル検査)で SSRF・無制限read・画像爆弾を防ぐ。dao/reps/ur_log.go の getBody/getFavicon/getImageOG/getAmazonImage と gkill_server_api の httpGetBase64Data(ブックマークレット)が利用。既定は private 拒否(loopback/RFC1918/link-local(メタデータ)/multicast/unspecified)。http.Get を新しく直に書かないこと。守るテストは api/safefetch/safefetch_test.go。 文字列検査では守れない理由(DNSリバインディング)は ADR-0043。
型別 GetXxx(id, nil) は最新版を返す(2026-08-21、監査 H-07)。dao/reps の各 *_repository_sqlite3_impl.go / *_repository_cached_sqlite3_impl.go の単体取得は onlyLatestData := query.OnlyLatestData(false 固定にしない。GenerateFindSQLCommon はこの引数しか見ない)と slices.MaxFunc(UpdateTime)(&xxx[0] を返さない)で最新版を選ぶ。GkillRepositories.GetKyou はアドレス表 nil でも panic しない(プラグインKyou・追加直後の記録)。守るテストは get_typed_latest_version_test.go / gkill_repositories_get_kyou_test.go / rows_err_check_test.go。
外部URLを取る新規コードや子goroutine内の index 参照に注意: strings.SplitN(x, ",", 2) は末尾要素 parts[len(parts)-1] を取る([1] を長さ未確認で参照しない。子goroutine内 panic は recoverMiddleware で回収できずプロセスが落ちる=監査 H-01)。ファイル書き込みは tmp+rename で原子的に(Override で原本を壊さない=M-03)。ZIP展開は件数・総展開量・圧縮比の上限つき(handle_browse_zip_contents.go の maxZip*=M-04)。http.Server は ReadHeaderTimeout/IdleTimeout/MaxHeaderBytes を張り、認証前ボディは maxAuthBodyBytes でキャップ(H-03)。全レスポンスに securityHeadersMiddleware(nosniff / X-Frame-Options / Referrer-Policy)。CLIサブコマンドは InitGkillServerAPI 失敗で return(nil panic 回避=M-8)、CLIの自己発行セッションは IsLocalAppUser=false(最小権限)。ログインは非存在ユーザとパスワード誤りを同じ error_code + 文言に統一し、非存在時もダミー Argon2id を実行(ユーザ列挙対策=S3-login)。
レスポンスへ載る自由文は端末固有の情報を伏せてから出す(2026-08-24)。GkillError.MarshalJSON が ErrorMessage を message.RedactEnvironmentSpecific へ通し、ホームディレクトリのユーザー名(C:\Users\〈ユーザー名〉 / /home/〈ユーザー名〉/)とメールアドレスをプレースホルダにする。伏せるのは生成側ではなく marshal 側(ErrorMessage: の代入は684箇所あり、そのうち err.Error() を埋めているのは4箇所。生成側で1つずつ包む方式は必ず足し忘れるし、新しいハンドラが同じ書き方をしても自動で載るのはこちらだけ)。GkillError でない自由文——プラグイン診断の last_error と typed_index.last_build_error——は handle_get_plugin_list.go で明示的に通す。パスの形は残すので C:\Windows\System32\config\systemprofile は素通しになり、LocalSystem 起動でホームが化ける事故の診断は従来どおり成立する。サーバのコンソールログには適用しない(端末に閉じた人間の診断チャネルで、リポジトリへ入る経路が無い)。守るテストは api/message/redact_test.go / handle_get_plugin_list_test.go。 なぜ書き手側の約束ではなく出口で伏せるのかは ADR-0046。
IDF走査の一時停止は参照カウント(2026-08-21、監査 M-02)。SetSkipIDF(true/false) は共有 *bool ではなく *atomic.Int64 を増減し「カウント>0 で skip」。重なるアップロード(と UpdateCache)が互いのフラグを倒し合って watcher が走る/変更を取りこぼすのを防ぐ。カウントが0へ戻ったときだけ catch-up 走査を1回キックする(UpdateCache の Add(-1) では catch-up しない=1分周期のリビルド無限ループになるため。SetSkipIDF はアップロードハンドラからしか呼ばれず watcher から再入しない)。
関連スキル
詳しい設計と却下案(ADR)