Fess 15.7.0のリリース

Fess 15.7.0をリリースしました。今回は、ヘッドレス用途やSPA(シングルページアプリケーション)フロントエンドを意識した新しい/api/v2 REST APIと、HTML/CSS/JavaScriptだけで検索画面を作れる静的テーマの仕組みが目玉になっています。マイルストーンを見てもらうと分かりますが、フロントエンドとAPIまわりを大きく作り直した、ボリュームのあるリリースになりました。

まず一番大きいのが、/api/v2に統一したREST APIです。これまで機能ごとにバラバラだったREST managerを、一貫したJSONエンベロープ(statusschemadata / error)にまとめました。検索やスクロール(NDJSON)、関連クエリ・関連コンテンツ、ドキュメントキャッシュ、お気に入り、クリックログ、認証(ログイン / ログアウト / me / パスワード変更)、CSRFトークン、ログインのレート制限、UI設定、そしてチャット(POSTおよびSSEストリーミング)まで、ひととおりカバーしています。OpenAPIの定義も同梱していて、入力値の上限チェック、状態を変更するリクエストへのCSRF必須化、IP単位・ユーザー単位のレート制限といったセキュリティ面の強化も合わせて入れています。

このAPI刷新にともなって、従来のv1 JSON検索API・チャットAPIは削除されました。もし既存のv1検索APIに依存している場合は、新しく用意したfess-webapp-v1-apiプラグインをインストールすると、これまでどおりの検索APIを使えるようになります。アップグレード時はこの点に注意してください。

次に、静的テーマ(Static Theme System)の仕組みを追加しました。検索画面を、JSPやActionを経由せずにWeb層から直接配信できる、自己完結したHTML/CSS/JavaScriptのバンドルとして提供できるようになっています。テーマはtheme.ymlのマニフェストでバリデーションされ、事前圧縮したアセットやキャッシュヘッダーにも対応します。ZIPアップロードでインストールでき、パストラバーサルやzip-bomb、サイズ上限といったセキュリティガードもしっかり入れてあります。管理画面には新しく「Admin → Theme」を追加して、アップロード・有効化・デフォルト設定・削除がそこから行えるようにしました。

その静的テーマを実際に動かすリファレンス実装として、Bootstrap 5ベースのテーマも同梱しています。素のES2022モジュールで作られていて、ホーム、ファセット付きの検索、詳細検索、キャッシュ表示、AIチャット、プロフィール、ヘルプといった既存の検索UIを、16言語で再現しています。そのまま使ってもいいですし、独自の検索フロントエンドを作るときの出発点としても使えるようにしています。

検索エンジン側では、OpenSearch 3.7に対応しました。バンドルしているkopfプラグインも15.7.0に合わせて更新しています。

セキュリティ周りもまとめて強化しています。状態を変更するv2リクエストにはCSRFを無条件で必須化し、ログインのスロットリングは(クライアントIP、ユーザー名)の単位に、匿名チャットのスロットリングはクライアントIP単位にスコープを切るようにしました。認証情報付きのCORSは許可リストに載せたオリジンだけに制限し、関連コンテンツのプレースホルダーでは検索クエリをエンコードして反射型XSSを防いでいます。セッションCookieにSecure属性を付けるためのsession.cookie.secureオプションも追加しました。このほか、匿名のヘルスレスポンスからcluster_nameを出さないようにしたり、v2レスポンスをデフォルトでCache-Control: no-storeにしたり、クリックのquery_idの妥当性を検証してランクの値を範囲内に収めたりと、細かいところを地道に固めています。

AI Search Mode(RAGチャット)も改善しました。大きなドキュメントから回答を生成するときに、ハイライトされたパッセージを使うようにしています。スマートサマリーは「Minimal Trail」として作り直し、フェーズごとに履歴を持つ形に見直しました。LLMのリトライ・待機・フォールバック・警告といった状態や、検索ヒット件数をブラウザに通知できるようにし、RAGのインテント処理では途中で切れたLLMレスポンスをフォールバックとして扱うようにしています。

クロール・インデックス周りでは、失敗URLとして扱うステータスコードのデフォルトにHTTP 403と410を追加しました。あわせてFessCrawlerThreadのホットパスを引き締め、API・クローラー・LLMのコード全体で正規表現パターンを事前コンパイルするようにして、処理を軽くしています。

そのほか、不具合修正として、デフォルトのGoogle Cloud Storageエンドポイントを使ったときにストレージ機能が正しく有効化されるよう直しています。

今回もコード修正からリリースノート作成、多言語ドキュメントの更新まで、Claude Codeにかなり助けてもらいました。/api/v2への統一と静的テーマの仕組みで、フロントエンドの自由度がぐっと上がっているので、ぜひ新しいFessを使ってみてください。

FessにAPI v2とHTMLテーマ機能を追加

Fessの検索画面は、これまでJSPベースで提供してきました。デザインを大きく変えようとするとJSPやアクションに手を入れる必要があり、フロントエンドだけで完結させるのが難しい構成でした。

そこでFess 15.7で、統一されたWeb API /api/v2 と、HTML/CSS/JSだけで検索画面を構築できる静的テーマ機能を追加しました。これにより、Fessをバックエンドとして扱い、検索画面をシングルページアプリケーション(SPA)として自由に作れるようになります。

API v2 について

/api/v2 は、これまで機能ごとに分かれていたREST APIを単一のディスパッチャに統合したものです。検索・サジェスト・ラベル・お気に入り・クリックログ・認証・RAGチャットなど、検索画面を作るのに必要なエンドポイントがひと通り揃っています。

統一されたレスポンス形式

すべてのレスポンスは以下のように response でラップされた形で返ってきます。

{
  "response": {
    "status": 0,
    "...": "..."
  }
}

エラー時は以下のようになります。

{
  "response": {
    "status": 1,
    "error": {
      "code": "invalid_request",
      "message": "..."
    }
  }
}

status0(成功)、1(クライアントエラー)、9(システムエラー)の3種類です。error.codeinvalid_request auth_required forbidden not_found rate_limited などの安定したコードになっており、クライアント側はメッセージ文字列ではなくこのコードで分岐・ローカライズします。JSONのキーはリクエスト・レスポンスともにすべてスネークケースで統一しています。

主なエンドポイント

代表的なものを挙げると以下の通りです。

メソッド + パス用途
GET /api/v2/searchドキュメント検索
GET /api/v2/suggest-wordsサジェスト
GET /api/v2/labelsラベル一覧
GET /api/v2/popular-words人気の検索ワード
GET /api/v2/ui/configUI初期化用の設定とCSRFトークン
GET /api/v2/auth/meログイン中のユーザー情報
POST /api/v2/auth/loginログイン
POST /api/v2/clickクリックログの記録
POST /api/v2/chat/streamRAGチャット(SSEストリーミング)
GET /api/v2/documents/allスクロール検索(NDJSON)
GET /api/v2/healthクラスタのヘルスチェック

検索の例は以下のような感じです。

$ curl "http://localhost:8080/api/v2/search?q=fess&num=20"

GET /api/v2/searchq(クエリ)、start / num(ページング)、sortlangfacet.field などのパラメータを受け取り、data(ドキュメント配列)、record_countpage_countfacet_fieldrelated_query などをまとめて返します。

UI初期化とCSRF

SPAはまず GET /api/v2/ui/config を匿名で呼び出します。ここでサイト名・機能フラグ・ソートやページサイズの選択肢・テーマ情報、そしてCSRFトークンを取得します。

状態を変更するPOST/PUT/DELETEには X-Fess-CSRF-Token ヘッダーが必須です(/auth/login のみ例外)。トークンはログイン・ログアウト・パスワード変更のたびにローテーションされます。CSRF検証は認証より前に走るため、トークンなしの更新系リクエストは401ではなく403になります。

静的テーマ機能

検索画面は「テーマ」としてファイルシステムから直接配信されます。JSPやアクションを経由しないため、ブラウザのアドレスバーのURLもそのまま保持されます。

テーマは theme.yml というマニフェストを持ちます。

apiVersion: fess.codelibs.org/v1
kind: StaticTheme
name: bootstrap
displayName: "Bootstrap"
version: "1.0.0"
author: "CodeLibs Project"
minFessVersion: "15.7"
supportedLocales: [en, ja, de, es, fr, ko, pt-BR, zh-CN]
entry: index.html
spaFallback: true
type: static
thumbnail: thumbnail.png

/themes/{name}/... でアセットが配信され、/search /help /profile /cache /chat などのSPA用パスではエントリの index.html が返されます。一方で /admin//api/ などはこれまで通りFess側のルートに渡されるため、管理画面やAPIがSPAに飲み込まれることはありません。

テーマは管理画面に追加された /admin/theme/ から、ZIPでアップロードして管理します。アップロード時にはZipSlipやzip爆弾などへの対策が入っています。有効化は管理画面で theme.default にテーマ名を設定するか、バーチャルホストに紐付けて行います。

Bootstrapリファレンステーマ

標準で、Bootstrap 5ベースのリファレンステーマを同梱しています。これは既存のJSP検索画面をそのまま再現したもので、バニラのES2022モジュールで書かれています。独自テーマを作る場合は、このテーマをコピーして始めるのがおすすめです。

中心となるのが api.js で、これがそのまま使い回せるAPIラッパーになっています。

  • init()GET /ui/config を呼び、設定とCSRFトークンをキャッシュ
  • get(path, params) / post(path, body) … エンベロープを検証し、status !== 0 なら ApiError を投げる。POST時はCSRFトークンを自動付与
  • sseStream(path, body, onEvent, onError)/chat/stream 向けのfetchベースのSSE(標準の EventSource はGET限定でCSRFヘッダーを送れないため独自実装)

チャットのストリーミングはこんな感じで使えます。

import * as api from "./api.js";

const ctrl = api.sseStream("/chat/stream", { q: "質問内容" }, (event) => {
  if (event.type === "chunk") bubble.textContent += event.data.content ?? "";
  if (event.type === "done")  ctrl.abort();
}, (err) => console.error(err));

なお、テーマ内では動的な文字列の innerHTML を使わず、検索結果カードやファセット、ページネーションなどはすべて document.createElementtextContent で組み立てています。XSS対策として、独自テーマでもこの方針を踏襲することをおすすめします。

検索画面を自作する流れ

独自の検索画面を作る場合は、ざっくり以下の流れになります。

  1. 同梱の bootstrap テーマのディレクトリをコピーし、theme.ymlname を変更する
  2. index.html(Bootstrap 5 + セマンティックなHTML)と styles.css、各JSモジュールを編集する。エンベロープ・CSRF・SSEの処理は api.js をそのまま使い回せる
  3. ZIPにまとめて /admin/theme/ からアップロードし、有効化する
  4. SPAは api.init()GET /ui/config(匿名)で起動し、機能フラグとCSRFトークンを読み込んでから /api/v2 を通して検索・認証・お気に入り・チャットを動かす

v1 APIについての注意

今回の変更にあわせて、従来の /api/v1 のJSON検索APIとチャットAPIはFess本体から削除し、fess-webapp-v1-api という別プラグインに切り出しました。これは破壊的変更になります。新規の連携は /api/v2 を対象にしてください。

まとめ

API v2と静的テーマ機能により、FessをヘッドレスなバックエンドとしてHTML/CSS/JSだけで検索画面を構築できるようになりました。同梱のBootstrapテーマをベースに、自分のサイトに合わせた検索画面を作ってみてください。

Recotem に SQL / GA4 Data API データソースを追加

推薦システム構築ツール Recotem のレシピ駆動パイプラインに、2 つのファーストパーティデータソースを追加しました。SQLAlchemy 2 経由の汎用 SQL ソース (sql) と、Google Analytics 4 Data API の直接読み込みソース (ga4) です。これまで標準対応していた CSV / Parquet / BigQuery に加え、運用中の RDB や GA4 から直接学習データを取り込めるようになっています。

追加した 2 つのデータソース

sql — 汎用 SQL データソース

SQLAlchemy 2 を使った汎用的な SQL データソースで、PostgreSQL / MySQL / MariaDB / SQLite に対応します。

data:
  source: sql
  dsn_env: RECOTEM_PURCHASE_DSN
  query: |
    SELECT user_id, item_id, purchased_at
    FROM purchases
    WHERE purchased_at >= :since
  params:
    since: '2025-01-01'
  user_column: user_id
  item_column: item_id

DSN はレシピに直接書かず、必ず環境変数経由で渡す設計です。pandas.read_sql を 100k 行単位でチャンク読み込みし、RECOTEM_MAX_SQL_ROWS で行数の上限をハードキャップします。サーバーサイドカーソル (stream_results=True) を使って、メモリに乗り切らないサイズのテーブルを安全に扱えます。

ga4 — GA4 Data API データソース

Google Analytics 4 の Data API を直接叩いて、イベントログを学習データとして取り込みます。

data:
  source: ga4
  property_id: '123456789'
  date_range:
    start_date: '2025-01-01'
    end_date: '2025-12-31'
  event_names: ['purchase', 'add_to_cart']
  user_column: clientId
  item_column: itemId

認証は Application Default Credentials (ADC) のみ対応で、レシピに credentials を埋め込めない設計です。runReport のページングは RECOTEM_GA4_MAX_PAGES で上限を設けています。ResourceExhausted / ServiceUnavailable に対しては 3 × api_timeout_seconds の予算ベースのリトライを行い、各 API コール後に壁時計のデッドラインを再チェックします。

SQL ソースのセキュリティ設計

SQL ソースは「ユーザーが任意の接続先 DSN を指定できる」性質上、SSRF を含む攻撃面が広くなります。以下のような多層防御を組み込んでいます。

読み取り専用トランザクションの強制

ダイアレクトごとに読み取り専用ヒントを発行します。

DB適用するヒント
PostgreSQLSET TRANSACTION READ ONLY
MySQLSET SESSION TRANSACTION READ ONLY
MariaDBSET SESSION TRANSACTION READ ONLY + max_statement_time
SQLitePRAGMA query_only=ON

特に SQLite は以前は silent no-op になっていたものを fail-closed に変更し、ステートメントタイムアウトが適用できない場合は構造化警告 (sql_statement_timeout_not_applied) を出すようにしました。

SSRF ガード

デフォルトで RFC1918 / loopback / link-local の宛先を拒否します。プライベートネットワーク内の RDB を読みたい場合は RECOTEM_SQL_ALLOW_PRIVATE=1 で明示的に opt-in します。

ガードは DSN の netloc だけでなく、各ドライバが認識するルーティング指定を全て検査します。

  • ?host= / ?hostaddr= (libpq)
  • ?service= / ?unix_socket= (拒否)
  • 絶対パスの ?host= (拒否)
  • ホスト情報を持たないネットワーク DSN (拒否)

これは SQLAlchemy の make_url が netloc しか url.host に格納しない一方で、libpq や PyMySQL は上記のクエリパラメータも参照するため、netloc だけ見ていると簡単にバイパスできてしまうからです。

DNS リバインディング対策

プローブとフェッチのタイミングで getaddrinfo を使って IPv4 / IPv6 双方を再解決し、解決された IP 集合をピン留めします。gethostbyname_ex 時代の IPv4 限定実装をやめたことで、デュアルスタックや IPv6 専用ホストでの誤検知も解消されました。

ログからの DSN ユーザー情報の除去

src/recotem/log_redaction.py に DSN userinfo スクラバーを追加し、構造化ログのチェーン先頭に置いています。空ユーザー名・URL エンコードされたパスワード・スキーム別の判定も組み込みで、s3:// / gs:// / az:// / abfs(s):// のようなオブジェクトストア URI は誤検知しないよう除外しています。

また、DataSourceError.code"datasource_error" に固定することで、pipeline.py が exc_info=False でロギングするようになり、psycopg / PyMySQL の例外チェーン経由で DSN userinfo が流出するのを防いでいます。

TLS 設定の Advisory

PostgreSQL / MySQL の DSN で sslmode= / ssl= が指定されていない場合、初期化時に sql_dsn_tls_not_configured を警告として記録します。

GA4 ソースの堅牢化

エラーの正しい分類

GA4 レスポンスの eventCount が非整数だった場合、以前は ValueError として exit code 1 (_EXIT_UNKNOWN) で落ちていましたが、DataSourceError (exit 3 / _EXIT_DATASOURCE) に変更しました。データソース起因の障害が運用上きちんと分類されるようになります。

Prometheus メトリクスの部分初期化耐性

_metrics_ga4 は atomic init とロールバックを実装しており、いずれかのカウンタ登録が失敗するとモジュール全体を no-op フォールバックにラッチします。catch する例外も第三者レジストリ起因の KeyError / ImportError まで広げて、本体の動作を阻害しないようにしています。

バリデーション強化

  • event_names は正規表現と長さ上限でバウンドチェック
  • weight_column が dimensions と衝突する場合はバリデーション時に拒否
  • ローリング XOR バグ修正済みの日付範囲バリデーション

新規追加された環境変数

変数デフォルト用途
RECOTEM_MAX_SQL_ROWS50,000,000SQL の行数ハードキャップ ([1,000, 500,000,000] にクランプ)
RECOTEM_SQL_ALLOW_PRIVATE(空)SQL ソースでプライベート / loopback DSN を許可
RECOTEM_GA4_MAX_PAGES500GA4 のページネーション上限 ([1, 10,000] にクランプ)

エントリーポイント登録とインストールヒント

新規データソースは [project.entry-points."recotem.datasources"] で登録しつつ、optional extras がインストールされていない wheel でもロード自体は通るように、フォールバック辞書と _BUILTIN_INSTALL_HINTS を用意しました。エラー時には適切な extras (postgres / mysql / sqlite / ga4 / all) をインストールするよう案内されます。

pip install 'recotem[postgres]'   # PostgreSQL
pip install 'recotem[mysql]'      # MySQL / MariaDB
pip install 'recotem[sqlite]'     # SQLite
pip install 'recotem[ga4]'        # GA4 Data API
pip install 'recotem[all]'        # 全部入り

テスト

ユニット / 統合 / fuzz の 3 階層で 1,674 ケース をカバーしました。

  • ユニット: 各ソースの設定バリデーション、SSRF の全ルーティング形式のパラメトライズ、IPv4 / IPv6 / デュアルスタックでの DNS リバインディング、PostgreSQL / MySQL / MariaDB / SQLite の読み取り専用 + ステートメントタイムアウトの SQL 完全一致検査、GA4 のリトライ判定 (ResourceExhausted / ServiceUnavailable / PermissionDenied)、weight_column 衝突拒否、非整数 eventCount 拒否、ログリダクションのオブジェクトストア URI 保持
  • 統合: SQLite を使った SQL の train + serve エンドツーエンド
  • Fuzz: 両ソースのレシピ形状に対する hypothesis のバイトミューテーション
  • レジストリ: 正常系とフォールバック経路の両方を検証、autouse フィクスチャでテスト間の LRU キャッシュ漏れを防止

まとめ

Recotem のデータソースに sqlga4 を追加し、運用中の RDB や GA4 のイベントログをそのまま学習データとして取り込めるようになりました。SQL ソースは SSRF / DNS リバインディング / DSN ログ流出対策を全ルーティング形式に対して入れ、読み取り専用も fail-closed に修正しています。GA4 ソースは ADC のみの認証・予算ベースのリトライ・部分初期化耐性のメトリクスといった、運用観点での堅牢化を行いました。既存の csv / parquet / bigquery レシピは無変更で、Recipe.source 判別子への追加だけの後方互換変更です。