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テーマをベースに、自分のサイトに合わせた検索画面を作ってみてください。

Fessのパスワードハッシュ化をBCryptに強化

Fessではユーザーパスワードの保存形式を、これまでのソルトなしSHA-256(1ラウンド)から、Spring Security v5.8互換のBCryptベースの形式へと刷新しました。既存の環境を止めることなく、ログインをきっかけに新形式へ段階的に移行できるようになっています。

変更の背景

これまでのFessはパスワードを sha256(plain) の16進文字列としてそのまま保存していました。ソルトがなくラウンド数も1回のため、万が一パスワードハッシュが漏洩した場合にレインボーテーブルやGPUによる総当たりで平文を復元されるリスクがありました。今回の対応では、保存形式を {bcrypt}$2a$10$... に切り替え、ログインごとに古い形式を新形式へ置き換える仕組みを導入しています。

パスワードハッシュ化の刷新

BCryptへの移行

新たに PasswordHashHelperorg.codelibs.fess.helper.PasswordHashHelper)を追加しました。ComponentUtil.getPasswordHashHelper() 経由で取得し、encode / matches / upgradeEncoding という公開APIを通して利用します。

保存形式にはSpring Securityの DelegatingPasswordEncoder と互換性のある {bcrypt} プレフィクス付きの文字列を採用しました。将来的にアルゴリズムを切り替える場合も、プレフィクスを見ることで旧方式と新方式を混在させたまま検証できます。

jBCryptの同梱

新規のMaven依存は追加せず、jBCrypt v0.4(ISCライセンス)を org.codelibs.fess.crypto.bcrypt.BCrypt としてソースに同梱しました。NOTICEファイルおよびlicense-plugin・formatter-pluginの除外リストもあわせて更新しています。jBCrypt 0.4は $2a$ のみをサポートし、他システムで使われる $2b$ / $2y$ は受け付けません。

既存ハッシュの互換性

プレフィクスのないレガシーな値は、app.digest.algorithm の設定(sha256 / sha512 / md5)に従って小文字16進で比較します。比較は MessageDigest.isEqual による定数時間比較で行い、未知の {id} プレフィクスや不正な値は常に false を返します。

認証フローの変更

FessLoginAssist.doAuthenticateLocal では、これまで「ユーザー名+ハッシュ済みパスワード」でDBを引いていた処理を、「ユーザー名のみで引いて PasswordHashHelper.matches で照合する」形に変更しました。BCryptはレコードごとにソルトを持つため、事前にハッシュを計算してWHERE句に入れるやり方は使えないためです。

ユーザー不在・null・レガシーhex・未知プレフィクスなどの失敗経路はすべて、applyTimingPadding でダミーのBCrypt検証を1回走らせ、成功時と同じ処理コストがかかるようにしています。これによりユーザーの存在有無が応答時間の差から推測されるタイミング攻撃を防いでいます。

ログイン時の遅延リハッシュ

既存ユーザーの旧形式パスワードは、ログイン成功のタイミングで自動的にBCryptへ置き換わります。この処理は app.password.upgrade.enabled=true(デフォルト有効)かつ upgradeEncoding が true を返した場合のみ走ります。

書き込みには UserService.updateStoredPasswordHash(username, expectedCurrentHash, newEncodedPassword) を新設し、AuthenticationManager を経由しない専用のリハッシュパスとしました。LDAPやSSOなど外部で管理されているユーザーには影響を与えないためです。

更新はOpenSearchの楽観的並行制御で原子性を担保しており、読み出し時の _seq_no / _primary_termIndexRequestBuilder.setIfSeqNo / setIfPrimaryTerm に伝搬させています。バージョン競合はDEBUGレベルでログ出力するにとどめ、ログインそのものは成功扱いのままとします。

書き込みパスの整理

UserService.changePasswordAdminUserAction.getUser、初期管理ユーザーをブートストラップする SearchEngineClient では、ComponentUtil.getPasswordHashHelper().encode(plain) を直接呼ぶように変更しました。これにより UserService から FessLoginAssist への不自然な依存を取り除いています。

新たなコードからパスワードを書き込む場合も、FessLoginAssist.encryptPassword は使わず PasswordHashHelper.encode を呼ぶようにしてください。

新しい設定項目

fess_config.properties に以下の設定を追加しました。

  • app.password.algorithm=bcrypt : 新規書き込み時のアルゴリズム
  • app.password.bcrypt.cost=10 : BCryptのコストパラメータ
  • app.password.upgrade.enabled=true : 旧形式からの遅延リハッシュの有効化

app.digest.algorithm はレガシーハッシュの検証用として残していますが、今後は新規書き込みには使われません。

ダウングレード時の注意

本変更以降、保存されるパスワードは {bcrypt}$2a$10$... の形式になります。BCrypt対応前のFessへロールバックするとこれらの値は検証できなくなるため、管理者パスワードのリセットが必要になります。運用でダウングレードを想定する場合はリリースノートでの周知と、手順の準備をおすすめします。

スキーマやAPI、i18nリソースへの変更はありません。

詳細

詳細はPR #3116を参照してください。

Fessのクリックログ・お気に入りログの自動パージに対応する

FessではPurgeLogJobによって古い検索ログが定期的にパージされる仕組みがあるが、クリックログとお気に入りログは対象外で、OpenSearch上に無制限に蓄積され続けていた。今回、これらのログも検索ログと同じ保持日数でパージされるようにした。

背景

Fessは検索体験の分析のために、以下の3種類のログをOpenSearchに記録する。

  • 検索ログ(Search Log) — 検索クエリやヒット件数などの情報
  • クリックログ(Click Log) — 検索結果がクリックされた際の情報
  • お気に入りログ(Favorite Log) — 検索結果がお気に入り登録された際の情報

クリックログとお気に入りログは、検索ログを親データとする子データとして扱われる。つまり、親となる検索ログが削除されても、子であるクリックログ・お気に入りログは残り続けるため、親を参照できない孤立したデータが蓄積していく状態となっていた。

PurgeLogJobはこれまで検索ログ・ジョブログ・ユーザー情報ログ・クロール情報をパージしていたが、クリックログとお気に入りログは対象外だったため、長期運用しているFess環境ではインデックスが無制限に膨らむ問題があった。

変更内容

SearchLogServiceに削除メソッドを追加

SearchLogServiceに、クリックログとお気に入りログを日数指定で削除するメソッドを追加した。

public void deleteClickLogBefore(final int days) {
    clickLogBhv.queryDelete(cb -> {
        cb.query().setRequestedAt_LessEqual(
            systemHelper.getCurrentTimeAsLocalDateTime().minusDays(days));
    });
}

public void deleteFavoriteLogBefore(final int days) {
    favoriteLogBhv.queryDelete(cb -> {
        cb.query().setCreatedAt_LessEqual(
            systemHelper.getCurrentTimeAsLocalDateTime().minusDays(days));
    });
}

クリックログはrequestedAt(検索リクエスト日時)、お気に入りログはcreatedAt(作成日時)を基準に、指定日数より古いドキュメントを削除する。

PurgeLogJobにパージ処理を追加

PurgeLogJob.execute()の検索ログパージの直後に、クリックログとお気に入りログのパージ処理を追加した。

// purge click logs
try {
    final int days = ComponentUtil.getFessConfig().getPurgeSearchLogDay();
    if (days >= 0) {
        searchLogService.deleteClickLogBefore(days);
    } else {
        resultBuf.append("Skipped to purge click logs.\n");
    }
} catch (final Exception e) {
    logger.error("Failed to purge click logs.", e);
    resultBuf.append(e.getMessage()).append("\n");
}

お気に入りログ側もほぼ同じ実装となっている。例外が発生しても他のパージ処理に影響しないよう、それぞれ独立したtry-catchで囲んでいる。

保持日数は検索ログと共有

クリックログ・お気に入りログの保持日数には、専用の設定は新設せず、既存のpurge.searchlog.dayを共用する設計とした。

これは、前述のとおりクリックログとお気に入りログが検索ログの子データだからである。親の検索ログが消えた時点で参照元を失うため、検索ログと同じ保持期間でパージするのが自然な振る舞いとなる。

将来的に保持期間を個別に設定したくなった場合は、専用の設定プロパティを追加し、未指定時はpurge.searchlog.dayにフォールバックする形で拡張できる。

まとめ

Fess 15.6.0から、purge.searchlog.dayで指定した日数に基づいて、検索ログ・クリックログ・お気に入りログが同時にパージされるようになる。これまで長期運用でクリックログやお気に入りログが肥大化していた環境では、アップグレード後に古いデータが自動的に整理されるため、OpenSearchのストレージ負荷軽減が期待できる。