0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

SSOでYellowfinのレポート作成画面を直接開くサンプル

0
Last updated at Posted at 2026-06-05

はじめに

Yellowfin に関して以下のご相談をいただきました。

  • 自社データカタログアプリ画面に Yellowfin のビュー(データソースのメタデータ)一覧を表示する
  • ユーザーがビューを選んだら、そのビューを選択済みの状態で Yellowfin の「レポート作成画面」を開く
  • もちろん 再ログインは不要(SSO)

前に書いた埋め込みサンプルの記事は Yellowfin を <div> に 埋め込む 話でしたが、今回は逆に Yellowfin 本体の画面へ ディープリンク遷移 するパターンです。これも「実際に動くサンプルが欲しい」と言われると渡しにくいので、Yellowfin 本体とカタログ側アプリの両方を Docker で起動できる最小サンプルを作りました。git clone か zip で渡して、start.sh(Windows は start.bat)を1回叩くだけで動きます。

ソース一式は GitHub で公開しています。

:octocat: リポジトリ: https://github.com/hadatuna/yf-isv-catalog-sample

取得
git clone https://github.com/hadatuna/yf-isv-catalog-sample.git

このサンプルで見せているのは次の3つです。

# 内容 実現方法
① カタログアプリへのログイン(自前ユーザー) セッション
② Yellowfin のビュー一覧表示 PostgreSQL(リポジトリDB)直読み
③ ビュー選択 → レポート作成画面へ SSO 遷移 REST(/api/login-tokens) でトークン発行 + logon.i4 ディープリンク

②をあえて REST API ではなく DB 直読みにしているのは、データカタログ製品は「ビュー情報を自社 DB / Yellowfin リポジトリDB に持つ」構成になることが多いためです。③の SSO トークン発行には一つ大事なコツがあります(後述の「ハマりどころ #1」が今回の核心です)。

Yellowfin は Docker Hub の all-in-one イメージ(アプリ + リポジトリDB 同梱)を使い、バージョン 9.17 で実機検証しています。

この記事のコードは yellowfinbi/yellowfin-all-in-one:9.17(REST API のメディアタイプ api-v4.2)で動作確認したものです。別バージョンでは API のメディアタイプや SSO の挙動が変わる可能性があります。後述の「ハマりどころ」もあわせてご確認ください。


動作イメージ

カタログにログインすると、Yellowfin のビュー一覧が表示されます。各行の「レポート作成」を押すと、別タブで Yellowfin のレポート作成画面が、そのビューを選択した状態で開きます。裏で SSO 済みなので Yellowfin のログインは出ません。

カタログにログイン
  └─ Yellowfin のビュー一覧(PostgreSQL から直接取得)を表示
       └─「レポート作成」を押す
            └─ 別タブで Yellowfin のレポート作成画面が(そのビュー選択済みで)開く。裏で SSO 済み。

デモ環境ではチュートリアルのビュー名が既定の「New View」のままです(実環境では作成者が付けた名前が出ます)。


全体構成

2つのコンテナで構成しています。

[ブラウザ] ──→ [app コンテナ(Node/Express)] ──┬─(REST /api/login-tokens)─→ [yellowfin]  SSOトークン発行
                                              │                          (ENTRY/VIEW は loginParameters で保存)
                                              └─(PostgreSQL)────────────→ [yellowfin]  ビュー一覧取得(reportview)
[ブラウザ] ──(別タブ: logon.i4?LoginWebserviceId=<securityToken>)──→ [yellowfin]  レポート作成画面
  • app コンテナ(=データカタログ相当)が、自前のログイン画面で独自ユーザーを認証します。
  • ビュー一覧は、Yellowfin のリポジトリDB(PostgreSQL)を直接 SELECT して取得します(読み取り専用ユーザー)。
  • 「レポート作成」を押すと、サーバ側で Yellowfin のログインセッションを発行し、ブラウザを別タブで Yellowfin 本体(logon.i4)へ飛ばします。

ビュー一覧の取り方(PostgreSQL 直読み)

データカタログ製品はビュー情報を DB で持つ想定なので、ビュー一覧は Yellowfin のリポジトリDB(reportview)を直接 SELECT します。メタデータのマスタビューは parentviewid = 0 の行です(parentviewid <> 0 はテーブル要素などの子行)。

SELECT m.viewid,                       -- SSO の VIEWID(数値ID)
       m.publishuuid AS view_uuid,     -- SSO の VIEWUUID(UUID)
       m.viewname    AS view_name,
       s.sourcename  AS data_source
FROM reportview m
LEFT JOIN reportviewsource s ON s.sourceid = m.sourceid
WHERE m.parentviewid = 0           -- マスタビューのみ
  AND m.viewstatuscode = 'OPEN'    -- 有効なビューのみ
ORDER BY m.viewname;

ここで取れる viewid と publishuuid が、そのまま SSO のログインセッションオプション VIEWID / VIEWUUID になります。

レポート作成画面への SSO 遷移のしくみ(重要)

[ユーザー] --(自前ID/PW)--> [カタログアプリ] --(管理者API資格情報 + ユーザー名 + ENTRY/VIEW)--> [Yellowfin]
                              本人確認            REST /api/login-tokens でトークン発行(パスワード不要=SSO)
                                                          │
[ブラウザ] --(別タブ: logon.i4?LoginWebserviceId=<id>)----┘  → レポート作成画面が SSO 済みで開く
  1. カタログにログイン中のユーザーを Yellowfin ユーザーにマッピングします。
  2. サーバ側で REST POST /api/login-tokens を呼び、loginParameters: ["ENTRY=CREATEREPORT","VIEWID=<id>","VIEWUUID=<uuid>"] と noPassword: true を付けて SSO トークン(securityToken)を発行します。ユーザーのパスワードも管理者資格情報もブラウザには出しません。
  3. ブラウザを logon.i4?LoginWebserviceId=<securityToken> で別タブに開くと、Yellowfin がトークンにひも付けて保存しておいた ENTRY/VIEWID/VIEWUUID を復元し、そのビューを選択済みのレポート作成画面が SSO 済みで開きます。

なぜ ENTRY/VIEW を URL ではなく loginParameters で渡すのかが、この記事のいちばんの肝です。後述の「ハマりどころ #1」で詳しく説明します。

ディレクトリ構成

yf-isv-catalog-sample/
├─ start.sh / start.bat       # ワンショット起動(macOS-Linux / Windows)
├─ docker-compose.yml         # yellowfin(本体) + app(カタログ) の2コンテナ
├─ .env.example
├─ license/                   # ライセンスファイル置き場(yellowfin.lic)
├─ scripts/
│  ├─ load-license.sh         # ライセンスを REST API で投入
│  ├─ enable-db-access.sh/.sql# 読み取り専用ユーザー作成 + listen開放 + pg_hba 追記
│  └─ enable-sso.sh/.sql      # SIMPLE_AUTHENTICATION を投入し Yellowfin 再起動
└─ app/                       # カタログ側アプリ(Node.js + Express)
   ├─ server.js               #   静的配信 + ログイン + /api/views + /api/sso/report-builder
   ├─ lib/yellowfin.js        #   REST でSSOトークン発行 + 遷移URL生成
   ├─ lib/db.js               #   PostgreSQL でビュー一覧取得
   ├─ config/users.js         #   デモ用ユーザー
   ├─ scripts/load-license.js
   └─ public/                 #   カタログ UI(HTML/JS/CSS)

必要なもの

  • Docker Desktop(Docker Engine + Docker Compose 同梱)
  • Yellowfin のライセンスファイル(別途入手してください)
  • メモリ目安 6GB 以上(初期設定では Yellowfin に 4GB 割り当てています。適宜変更可能)

使い方

license/ にライセンスファイル yellowfin.lic (←ファイル名を変更してください)を置いて、起動スクリプトを実行するだけです。

macOS / Linux:

$ ./start.sh

Windows:

start.bat

このスクリプト1つで、次をすべて自動でやります。

  1. docker compose up -d(コンテナ起動)
  2. Yellowfin の起動完了を待機
  3. ライセンスを REST API で投入
  4. ビュー一覧取得用の DB 接続を有効化(読み取り専用ユーザー作成・listen 開放・pg_hba 追記)
  5. パスワードなし SSO を許可(SIMPLE_AUTHENTICATION を投入し Yellowfin を再起動)
  6. 再起動後の起動完了を待機

✓ 準備完了! と出たら、ブラウザで http://localhost:3000 を開きます。デモ用アカウントは alice / alice123 または bob / bob123 です(これはカタログアプリ側のユーザーで、Yellowfin のものではありません)。ビュー一覧の「レポート作成」を押すと、別タブで Yellowfin のレポート作成画面が SSO 済みで開きます。

all-in-one イメージはデータを永続化しません。docker compose down で作り直したら、もう一度 start.sh / start.bat を実行してください(ライセンス投入・DB 接続有効化・SSO 許可を再適用します)。restart では消えません。

初回はイメージ(約3.8GB)の取得+Yellowfin の初回ブートで時間がかかります。start.sh / start.bat は既定で最大30分待ちます(WAIT_MINUTES=45 ./start.sh で変更可)。タイムアウトする場合は Docker Desktop のメモリを 6GB 以上にするのが効きます(Yellowfin は 4GB 必要)。

ポート番号を変えたい場合(.env の作り方)

既定では Yellowfin が 8080、カタログアプリが 3000 を使います。別ポートにしたいときは .env.example をコピーして .env を作り、ポートを書き換えます。

.env を作る
cp .env.example .env
.env(抜粋)
YF_HOST_PORT=9090
APP_HOST_PORT=3001
# ↓ ブラウザから見た Yellowfin の URL。YF_HOST_PORT を変えたら必ずここも合わせる
YF_PUBLIC_URL=http://localhost:9090
  • 重要: YF_HOST_PORT を変えたら YF_PUBLIC_URL も同じポートにしてください。これは別タブで開く Yellowfin の URL なので、ズレると遷移先が見つかりません。
  • 変更後は docker compose down してから ./start.sh(start.bat)を再実行してください。

コードと解説

ここからは各ファイルの全文と簡単な解説です。コア(今回の肝)は app/lib/yellowfin.js(REST でのSSOトークン発行) と app/lib/db.js(ビュー一覧の DB 直読み) です。

docker-compose.yml

yellowfin(本体)と app(カタログ)の2サービスです。app には Yellowfin のリポジトリDB へ接続するための YF_DB_* を渡しています。

docker-compose.yml
services:
  # ----------------------------------------------------------------------------
  # Yellowfin 本体(All-In-One: アプリ + リポジトリDB を1コンテナに同梱)
  #   - ライセンスは起動後に Web UI から投入する(環境変数では渡せない)
  #   - このイメージはデータを永続化しない。停止すると内容は失われる(デモ用途)
  #   - バージョンは 9.17 に固定(api-v4.2 で検証済み)。:latest は各環境の古い
  #     キャッシュが使われ得るため固定タグにしている。.env の YF_IMAGE_TAG で上書き可。
  # ----------------------------------------------------------------------------
  yellowfin:
    image: yellowfinbi/yellowfin-all-in-one:${YF_IMAGE_TAG:-9.17}
    container_name: yf-catalog-yellowfin
    ports:
      - "${YF_HOST_PORT:-8080}:8080"
    environment:
      # Yellowfin アプリに割り当てるメモリ(MB)。未指定だとRAMの約25%。
      APP_MEMORY: "${YF_APP_MEMORY:-4096}"
    volumes:
      # ライセンス投入を補助するために license/ をコンテナ内へマウント(任意)。
      # 実際の適用は Web UI(管理 > ライセンス管理)から行う。
      - ./license:/license:ro
    healthcheck:
      # ログイン画面が応答するまでを healthy とみなす(初回は数分かかる)
      test: ["CMD-SHELL", "curl -sf http://localhost:8080/logon.i4 >/dev/null || exit 1"]
      interval: 15s
      timeout: 10s
      retries: 40
      start_period: 180s

  # ----------------------------------------------------------------------------
  # 埋め込みサンプルアプリ(外部サイト相当)。Node.js + Express。
  #   - サーバ側で REST API による SSO を行い、ブラウザに管理者資格情報を渡さない
  #   - ブラウザは JS API v3 でダッシュボード/レポートを埋め込む
  # ----------------------------------------------------------------------------
  app:
    build: ./app
    container_name: yf-catalog-app
    ports:
      - "${APP_HOST_PORT:-3000}:3000"
    volumes:
      # ライセンス投入(REST)時にコンテナ内 Node が読み取るためマウント(読み取り専用)
      - ./license:/license:ro
    environment:
      PORT: "3000"
      # バックエンド(コンテナ間)から見た Yellowfin の URL
      YF_INTERNAL_URL: "${YF_INTERNAL_URL:-http://yellowfin:8080}"
      # ブラウザ(ホスト)から見た Yellowfin の URL(JS API スクリプトの読込元)
      YF_PUBLIC_URL: "${YF_PUBLIC_URL:-http://localhost:8080}"
      # SSO に使う Yellowfin 管理者の資格情報(サーバ側のみで使用)
      YF_ADMIN_USER: "${YF_ADMIN_USER:-admin@yellowfin.com.au}"
      YF_ADMIN_PASSWORD: "${YF_ADMIN_PASSWORD:-test}"
      # デモユーザー(alice/bob)がマッピングされる Yellowfin ユーザー。既定は管理者を流用。
      YF_EMBED_USER: "${YF_EMBED_USER:-admin@yellowfin.com.au}"
      # ログインセッションの署名鍵(本番では必ず変更する)
      SESSION_SECRET: "${SESSION_SECRET:-yf-isv-catalog-dev-secret}"
      # ビュー一覧取得用:Yellowfin リポジトリDB(PostgreSQL)への接続(読み取り専用ユーザー)
      YF_DB_HOST: "${YF_DB_HOST:-yellowfin}"
      YF_DB_PORT: "${YF_DB_PORT:-5432}"
      YF_DB_NAME: "${YF_DB_NAME:-yellowfin}"
      YF_DB_USER: "${YF_DB_USER:-catalog_ro}"
      YF_DB_PASSWORD: "${YF_DB_PASSWORD:-catalog_ro_pw}"
    depends_on:
      - yellowfin
    restart: unless-stopped

.env.example

設定はすべて環境変数で渡せるようにしています。通常は変更不要です。

.env.example
# ---- ホスト側の公開ポート -------------------------------------------------
YF_HOST_PORT=8080          # Yellowfin を開くポート -> http://localhost:8080
APP_HOST_PORT=3000         # サンプルアプリを開くポート -> http://localhost:3000

# ---- Yellowfin コンテナ ----------------------------------------------------
YF_IMAGE_TAG=9.17          # 使用する Yellowfin イメージのタグ(api-v4.2 で検証済み)
YF_APP_MEMORY=4096         # Yellowfin に割り当てるメモリ(MB)

# ---- サンプルアプリ -> Yellowfin の URL -----------------------------------
# コンテナ間通信用(compose のサービス名で解決)
YF_INTERNAL_URL=http://yellowfin:8080
# ブラウザ(ホスト)から見える URL。JS API スクリプトの読込元になる。
# 別ホストや別ポートに公開する場合はここを合わせて変更する。
YF_PUBLIC_URL=http://localhost:8080

# ---- Yellowfin 認証情報(サーバ側のみで使用)------------------------------
YF_ADMIN_USER=admin@yellowfin.com.au
YF_ADMIN_PASSWORD=test
# デモユーザー(alice/bob)がマッピングされる Yellowfin ユーザー。デモでは管理者を流用。
# 本番では Yellowfin 側に閲覧用ユーザーを作り、app/config/users.js でユーザーごとに割り当てる。
YF_EMBED_USER=admin@yellowfin.com.au

# ---- サンプルアプリのログインセッション ------------------------------------
SESSION_SECRET=yf-isv-sample-dev-secret

# ---- ビュー一覧取得用:Yellowfin リポジトリDB(PostgreSQL) 直読み ----------
# 読み取り専用ユーザー(scripts/enable-db-access.sh で作成)。本番では実際の
# リポジトリDBの接続情報に置き換える。
YF_DB_HOST=yellowfin
YF_DB_PORT=5432
YF_DB_NAME=yellowfin
YF_DB_USER=catalog_ro
YF_DB_PASSWORD=catalog_ro_pw

start.sh(ワンショット起動)

起動からライセンス投入、DB 接続有効化、SSO 許可、再起動待ちまでを順番に流します。DB の listen_addresses 開放と SSO 設定はどちらも Yellowfin の再起動で反映されるため、「DB 有効化 → SSO 許可(このとき再起動)→ 再待機」という順序にしています。

start.sh
#!/usr/bin/env bash
#
# ワンショット起動スクリプト。
#   1) docker compose up -d   (初回はイメージ pull に時間がかかる)
#   2) Yellowfin の起動完了を待つ
#   3) ライセンスを REST API で投入
#   4) ビュー一覧取得用のDB接続を有効化(読み取り専用ユーザー作成・listen開放)
#   5) パスワードなし SSO を許可(SIMPLE_AUTHENTICATION 投入 + Yellowfin 再起動)
#   6) 再起動後の起動完了を待つ
#
# 各ステップは冪等なので、再実行しても問題ない。
#
# 使い方:
#   ./start.sh [ライセンスファイル]      # 省略時は license/yellowfin.lic
#   待ち時間を変える:  WAIT_MINUTES=45 ./start.sh
#
set -euo pipefail
cd "$(dirname "$0")"

# .env があればポート等の設定を読む(無ければ既定値)
[ -f .env ] && set -a && . ./.env && set +a
YF_PORT="${YF_HOST_PORT:-8080}"
APP_PORT="${APP_HOST_PORT:-3000}"
LIC_FILE="${1:-license/yellowfin.lic}"
WAIT_MINUTES="${WAIT_MINUTES:-30}"
WAIT_RETRIES=$(( WAIT_MINUTES * 60 / 5 ))

if [ ! -f "$LIC_FILE" ]; then
  echo "✗ ライセンスが見つかりません: $LIC_FILE" >&2
  echo "  license/ にライセンスファイルを置いてから再実行してください。" >&2
  exit 1
fi

# ポート使用チェック(使用中でも本サンプル自身の可能性があるので警告のみ)
check_port() {
  if command -v lsof >/dev/null 2>&1 && lsof -iTCP:"$1" -sTCP:LISTEN >/dev/null 2>&1; then
    echo "⚠ ポート $1 は既に使用中です。別アプリが使っている場合は .env で $2 を変更してください。" >&2
    echo "  (本サンプルを既に起動している場合はこの警告は無視して構いません)" >&2
  fi
}
check_port "$YF_PORT" YF_HOST_PORT
check_port "$APP_PORT" APP_HOST_PORT

# 失敗時の診断情報を表示
diagnose() {
  echo "" >&2
  echo "---- 診断情報 ----" >&2
  docker compose ps >&2 2>/dev/null || true
  echo "---- yellowfin ログ(末尾60行) ----" >&2
  docker compose logs --tail=60 yellowfin >&2 2>/dev/null || true
  echo "" >&2
  echo "よくある原因:" >&2
  echo "  - Docker のメモリ不足(Yellowfin は 4GB 必要。Docker Desktop の Memory を 6GB 以上に)" >&2
  echo "  - 初回ブートが遅い(遅いマシン/ディスク, Apple Silicon の amd64 エミュレーション)" >&2
  echo "  - ポート競合(${YF_PORT} / ${APP_PORT} が他のアプリで使用中)" >&2
}

# Yellowfin のログイン画面が応答するまで待つ
wait_yellowfin() {
  local label="$1"
  echo "==> Yellowfin の起動を待っています(${label} / 最大 ${WAIT_MINUTES} 分)..."
  for i in $(seq 1 "$WAIT_RETRIES"); do
    if curl -sf -o /dev/null "http://localhost:${YF_PORT}/logon.i4" 2>/dev/null; then
      echo "    起動完了"
      return 0
    fi
    sleep 5
  done
  echo "✗ Yellowfin が起動しませんでした(${WAIT_MINUTES}分でタイムアウト)" >&2
  diagnose
  exit 1
}

echo "==> [1/6] コンテナを起動します"
echo "    ※ 初回はイメージ(約3.8GB)の取得に数分〜十数分かかることがあります。"
docker compose up -d || { echo "✗ コンテナの起動に失敗しました" >&2; diagnose; exit 1; }

echo "==> [2/6] 起動待ち"
wait_yellowfin "初回起動"

echo "==> [3/6] ライセンスを投入します"
# 起動直後はリポジトリDB初期化中のことがあるので数回リトライ
for attempt in 1 2 3 4 5; do
  if ./scripts/load-license.sh "$LIC_FILE"; then
    break
  fi
  echo "    ライセンス投入を再試行します(${attempt}/5)..."
  sleep 10
  [ "$attempt" = 5 ] && { echo "✗ ライセンス投入に失敗しました" >&2; diagnose; exit 1; }
done

echo "==> [4/6] ビュー一覧取得用のDB接続を有効化します(読み取り専用ユーザー・listen開放)"
./scripts/enable-db-access.sh

echo "==> [5/6] パスワードなし SSO を許可します(Yellowfin を再起動します)"
./scripts/enable-sso.sh

echo "==> [6/6] 再起動後の起動待ち"
wait_yellowfin "設定の反映後"

echo ""
echo "✓ 準備完了!"
echo "  サンプルアプリ : http://localhost:${APP_PORT}   (alice/alice123 または bob/bob123)"
echo "  Yellowfin 本体 : http://localhost:${YF_PORT}     (admin@yellowfin.com.au / test)"

start.bat(Windows 用)

中身は start.sh と同じ処理です。docker compose exec で呼ぶコマンドは Unix 版とそろえ、差分をバッチ構文(待機ループ・標準入力リダイレクト)だけに閉じ込めています。

start.bat
@echo off
rem ============================================================================
rem  Windows 用ワンショット起動スクリプト(start.sh の Windows 版)
rem    1) docker compose up -d   (初回はイメージ pull に時間がかかる)
rem    2) Yellowfin の起動完了を待つ
rem    3) ライセンスを REST API で投入
rem    4) ビュー一覧取得用のDB接続を有効化(読み取り専用ユーザー作成・listen開放)
rem    5) パスワードなし SSO を許可(SIMPLE_AUTHENTICATION 投入 + Yellowfin 再起動)
rem    6) 再起動後の起動完了を待つ
rem
rem  使い方:  ダブルクリック、または cmd で  start.bat
rem  ※ Docker Desktop と、Windows 10/11 同梱の curl が必要です。
rem  ※ ポートは .env(YF_HOST_PORT / APP_HOST_PORT)から自動で読み込みます。
rem ============================================================================
setlocal enabledelayedexpansion
cd /d "%~dp0"

set "YF_PORT=8080"
set "APP_PORT=3000"
set "LIC_NAME=yellowfin.lic"
set "WAIT_MINUTES=30"

rem .env があればポート設定を読む(行頭#は無視、インラインコメントは除去)
if exist ".env" (
  for /f "usebackq eol=# tokens=1,* delims==" %%A in (".env") do (
    if /i "%%A"=="YF_HOST_PORT"  for /f "tokens=1" %%V in ("%%B") do set "YF_PORT=%%V"
    if /i "%%A"=="APP_HOST_PORT" for /f "tokens=1" %%V in ("%%B") do set "APP_PORT=%%V"
  )
)
set /a WAIT_RETRIES=%WAIT_MINUTES%*60/5

if not exist "license\%LIC_NAME%" (
  echo [ERROR] ライセンスが見つかりません: license\%LIC_NAME%
  echo         license\ フォルダにライセンスファイルを置いてから再実行してください。
  goto :fail
)

rem ポート使用チェック(警告のみ。本サンプル自身が使用中の場合もあるため)
netstat -ano | findstr "LISTENING" | findstr ":%YF_PORT% " >NUL && echo [WARN] ポート %YF_PORT% は使用中です。別アプリが使っている場合は .env の YF_HOST_PORT を変更してください。
netstat -ano | findstr "LISTENING" | findstr ":%APP_PORT% " >NUL && echo [WARN] ポート %APP_PORT% は使用中です。別アプリが使っている場合は .env の APP_HOST_PORT を変更してください。

echo [1/6] コンテナを起動します(初回はイメージ取得に数分〜十数分かかることがあります)
docker compose up -d || goto :fail

echo [2/6] 起動待ち
call :wait_yellowfin || goto :fail

echo [3/6] ライセンスを投入します
set /a tries=0
:lic_retry
docker compose exec -T -e LICENSE_PATH=/license/%LIC_NAME% app node scripts/load-license.js && goto :lic_done
set /a tries+=1
if !tries! geq 5 ( echo [ERROR] ライセンス投入に失敗しました & goto :fail )
echo     再試行します !tries!/5 ...
timeout /t 10 /nobreak >NUL
goto :lic_retry
:lic_done

echo [4/6] ビュー一覧取得用のDB接続を有効化します(読み取り専用ユーザー・listen開放)
docker compose exec -T -u postgres yellowfin psql -d yellowfin -v ON_ERROR_STOP=1 < scripts\enable-db-access.sql || goto :fail
docker compose exec -T -u postgres yellowfin sh -c "grep -q catalog_ro /etc/postgresql/16/main/pg_hba.conf || echo 'host    yellowfin    catalog_ro    0.0.0.0/0    scram-sha-256' >> /etc/postgresql/16/main/pg_hba.conf" || goto :fail

echo [5/6] パスワードなし SSO を許可します(Yellowfin を再起動します)
docker compose exec -T -u postgres yellowfin psql -d yellowfin -v ON_ERROR_STOP=1 < scripts\enable-sso.sql || goto :fail
docker compose restart yellowfin || goto :fail

echo [6/6] 再起動後の起動待ち
call :wait_yellowfin || goto :fail

echo.
echo [OK] 準備完了!
echo   サンプルアプリ : http://localhost:%APP_PORT%   ^(alice/alice123 または bob/bob123^)
echo   Yellowfin 本体 : http://localhost:%YF_PORT%     ^(admin@yellowfin.com.au / test^)
echo.
pause
endlocal
exit /b 0

rem --- Yellowfin のログイン画面が応答するまで待つサブルーチン -----------------
:wait_yellowfin
echo     Yellowfin の起動を待っています(最大 %WAIT_MINUTES% 分)...
set /a wtries=0
:wl
curl -sf -o NUL "http://localhost:%YF_PORT%/logon.i4" >NUL 2>&1 && ( echo     起動完了 & exit /b 0 )
set /a wtries+=1
if !wtries! gtr %WAIT_RETRIES% (
  echo [ERROR] Yellowfin が起動しませんでした(%WAIT_MINUTES%分でタイムアウト)
  call :diagnose
  exit /b 1
)
timeout /t 5 /nobreak >NUL
goto :wl

rem --- 失敗時の診断情報 -------------------------------------------------------
:diagnose
echo.
echo ---- 診断情報 ----
docker compose ps
echo ---- yellowfin ログ(末尾60行) ----
docker compose logs --tail=60 yellowfin
echo.
echo よくある原因: Docker のメモリ不足(6GB以上推奨) / 初回ブートが遅い / ポート競合
exit /b 0

:fail
echo.
echo 失敗しました。上のメッセージを確認してください。
pause
endlocal
exit /b 1

scripts/load-license.sh

ライセンス投入は、license/ を app コンテナにマウントしておき、コンテナ内の Node がファイルを読んで REST API で送るだけにしています。こうすると Windows/Unix で同じコマンドになり、base64 のような OS 依存コマンドが要りません。

scripts/load-license.sh
#!/usr/bin/env bash
#
# ライセンスファイルを Yellowfin に REST API で投入する。
# license/ は app コンテナの /license にマウントされており、コンテナ内の Node が
# 直接読み込んで投入するため、ホスト OS 依存のコマンド(base64 等)は不要。
#
# 使い方:
#   ./scripts/load-license.sh [ファイル名]
#   (省略時は yellowfin.lic。license/ 内のファイル名を指定する)
#
set -euo pipefail
cd "$(dirname "$0")/.."

NAME="$(basename "${1:-yellowfin.lic}")"
echo "==> license/${NAME} を Yellowfin に REST API で投入します"

docker compose exec -T -e "LICENSE_PATH=/license/${NAME}" app node scripts/load-license.js

scripts/enable-db-access.sh と enable-db-access.sql

カタログアプリが Yellowfin のリポジトリDB を読めるようにします。読み取り専用ユーザー catalog_ro の作成、listen_addresses の開放、pg_hba.conf への接続許可追記をやります(all-in-one の内部 PostgreSQL は既定で localhost のみ待受・peer 認証なので、コンテナ間で接続できるようにします)。listen_addresses の変更は再起動が必要なため、直後の enable-sso.sh の再起動で反映させます。

scripts/enable-db-access.sh
#!/usr/bin/env bash
#
# カタログアプリが Yellowfin リポジトリDB を直接読めるようにする。
#   - 読み取り専用ユーザー catalog_ro を作成(enable-db-access.sql)
#   - listen_addresses を開放(再起動で反映)
#   - pg_hba.conf に Docker ネットワークからの接続許可を追記(reload で反映)
#
# ★ all-in-one は非永続のため、down→up のたびに(ライセンス投入後に)実行が必要。
#   start.sh が enable-sso の直前に呼び出す(listen 開放は enable-sso の再起動で反映)。
#
set -euo pipefail
cd "$(dirname "$0")/.."

SERVICE="${YF_SERVICE:-yellowfin}"
DB_NAME="${YF_DB_NAME:-yellowfin}"
OS_USER="${YF_OS_USER:-postgres}"
ex() { docker compose exec -T -u "$OS_USER" "$SERVICE" "$@"; }

echo "==> 読み取り専用ユーザー作成 + listen 開放(SQL)"
ex psql -d "$DB_NAME" -v ON_ERROR_STOP=1 < scripts/enable-db-access.sql

echo "==> pg_hba.conf にネットワーク許可を追記(重複防止)+ reload"
HBA=$(ex psql -d "$DB_NAME" -tAc "SHOW hba_file;" | tr -d '\r')
ex sh -c "grep -q catalog_ro '$HBA' || echo 'host    ${DB_NAME}    catalog_ro    0.0.0.0/0    scram-sha-256' >> '$HBA'"
ex psql -d "$DB_NAME" -tAc "SELECT pg_reload_conf();" >/dev/null

echo "==> 完了(listen_addresses=* は直後の Yellowfin 再起動で反映されます)"
scripts/enable-db-access.sql
-- カタログアプリが Yellowfin リポジトリDB を読むための読み取り専用ユーザーと、
-- ネットワーク待受の設定。
-- ※ パスワードは docker-compose.yml / .env の YF_DB_PASSWORD と合わせること。

DO $$ BEGIN
  IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'catalog_ro') THEN
    CREATE ROLE catalog_ro LOGIN PASSWORD 'catalog_ro_pw';
  END IF;
END $$;

GRANT CONNECT ON DATABASE yellowfin TO catalog_ro;
GRANT USAGE ON SCHEMA public TO catalog_ro;
GRANT SELECT ON reportview, reportviewsource, reportviewsourceparameter TO catalog_ro;

-- コンテナ内 localhost だけでなく Docker ネットワークからも接続できるようにする
-- (反映には Yellowfin/PostgreSQL の再起動が必要。start.sh では直後の enable-sso の再起動で反映)
ALTER SYSTEM SET listen_addresses = '*';

scripts/enable-sso.sh と enable-sso.sql

パスワードなし SSO を許可するための DB 設定を投入し、Yellowfin を再起動します。内部 PostgreSQL へは OS ユーザー postgres・DB yellowfin(peer 認証)で接続します。

scripts/enable-sso.sh
#!/usr/bin/env bash
#
# Yellowfin で「パスワードなし SSO」を許可するための設定を投入するスクリプト。
# all-in-one イメージ内の PostgreSQL(リポジトリDB)に SIMPLE_AUTHENTICATION=TRUE を入れ、
# 設定を反映させるため Yellowfin を再起動する。
#
# ★ このイメージはデータを永続化しないため、`docker compose up` でコンテナを
#    作り直すたびに、ライセンス投入後に本スクリプトを1回実行する必要がある。
#    (restart では消えないが、down→up では消える)
#
# 使い方:
#   ./scripts/enable-sso.sh
#
# 内部 PostgreSQL は OS ユーザー postgres による peer 認証で接続する(DB: yellowfin)。
# 構成が異なる場合は下の変数を環境変数で上書きする。
#   YF_SERVICE  (default: yellowfin)   docker compose のサービス名
#   YF_DB_NAME  (default: yellowfin)   リポジトリDB名
#   YF_OS_USER  (default: postgres)    psql を実行する OS ユーザー(peer 認証用)
#
set -euo pipefail
cd "$(dirname "$0")/.."

SERVICE="${YF_SERVICE:-yellowfin}"
DB_NAME="${YF_DB_NAME:-yellowfin}"
OS_USER="${YF_OS_USER:-postgres}"

echo "==> ${SERVICE} の PostgreSQL(${DB_NAME}) に SIMPLE_AUTHENTICATION=TRUE を投入します"

# SQL は scripts/enable-sso.sql を標準入力で流し込む(Windows の .bat と同一方式)
docker compose exec -T -u "${OS_USER}" "${SERVICE}" psql -d "${DB_NAME}" -v ON_ERROR_STOP=1 < scripts/enable-sso.sql

echo "==> 投入結果:"
docker compose exec -T -u "${OS_USER}" "${SERVICE}" psql -d "${DB_NAME}" -tAc \
  "select ConfigCode||'='||ConfigData from Configuration where ConfigCode='SIMPLE_AUTHENTICATION';"

echo "==> 設定反映のため Yellowfin を再起動します(数分かかる場合があります)"
docker compose restart "${SERVICE}"

echo "==> 完了。http://localhost:3000 でログイン → SSO タブを試してください。"
scripts/enable-sso.sql
-- Yellowfin で「パスワードなし SSO(noPassword:true)」を許可するための設定。
-- Configuration テーブルに SIMPLE_AUTHENTICATION=TRUE を投入する。
-- 既存行があれば消してから入れ直す(冪等)。
DELETE FROM Configuration
 WHERE IpOrg = 1 AND ConfigTypeCode = 'SYSTEM' AND ConfigCode = 'SIMPLE_AUTHENTICATION';

INSERT INTO Configuration (IpOrg, ConfigTypeCode, ConfigCode, ConfigData)
VALUES (1, 'SYSTEM', 'SIMPLE_AUTHENTICATION', 'TRUE');

app/Dockerfile と package.json

Node 20 なら fetch(undici)が標準で使えます。DB 接続には pg を使うので、依存は express / express-session / pg の3つです。

app/Dockerfile
# Node 20 で fetch(undici) が標準利用できるため追加依存は express のみ
FROM node:20-alpine

WORKDIR /usr/src/app

# 依存だけ先に入れてレイヤキャッシュを効かせる
COPY package.json ./
RUN npm install --omit=dev

# アプリ本体
COPY . .

EXPOSE 3000
CMD ["node", "server.js"]
app/package.json
{
  "name": "yf-isv-catalog-app",
  "version": "1.0.0",
  "description": "Yellowfin ビューカタログ サンプル(カタログ→レポート作成へSSO遷移)",
  "main": "server.js",
  "scripts": {
    "start": "node server.js"
  },
  "dependencies": {
    "express": "^4.19.2",
    "express-session": "^1.18.0",
    "pg": "^8.13.1"
  },
  "engines": {
    "node": ">=20"
  }
}

app/lib/yellowfin.js(★コア:REST でSSOトークン発行)

今回の肝です。ログインセッションオプション(ENTRY/VIEWID/VIEWUUID)を loginParameters として渡し、トークンにひも付けてサーバ側に保存させるのがポイントです(理由は「ハマりどころ #1」)。発行された securityToken を logon.i4?LoginWebserviceId=... に付けて別タブで開くだけです。ライセンス投入(uploadLicence)も同じ REST クライアントを使います。

app/lib/yellowfin.js
'use strict';

/**
 * Yellowfin REST API クライアント(カタログ→レポート作成へのSSO遷移用)
 * -----------------------------------------------------------------------------
 * サーバ側だけで実行する。管理者資格情報やアクセストークンはブラウザに出さない。
 *
 * 認証ヘッダ: Authorization: YELLOWFIN ts=<epoch ms>, nonce=<乱数>[, token=<accessToken>]
 *
 * 流れ:
 *   1) POST /api/login-tokens(signOnUser + noPassword:true + loginParameters:
 *      ENTRY=CREATEREPORT / VIEWID / VIEWUUID)→ securityToken 発行
 *      (loginParameters はトークンにひも付いてサーバ側に保存される)
 *   2) ブラウザを logon.i4?LoginWebserviceId=<securityToken> へ遷移
 *      → SSO 済みでレポート作成画面が開く(パラメータはサーバ側から復元)
 *
 * ★ ログインセッションオプション(ENTRY/VIEW)は URL クエリに付けてはいけない。初回の
 *   中間ページ(Browser Check 等)のリダイレクトで脱落し「システムエラー(action=null)」に
 *   なる。loginParameters として login-token に持たせる(=サーバ側保存)と、リダイレクトを
 *   跨いでも保持され初回から確実に動く。URL には LoginWebserviceId だけを付ける。
 *
 * yellowfin-all-in-one:9.17(API メディアタイプ api-v4.2)で実機検証。
 */

const YF_INTERNAL_URL = (process.env.YF_INTERNAL_URL || 'http://yellowfin:8080').replace(/\/$/, '');
const YF_ADMIN_USER = process.env.YF_ADMIN_USER || 'admin@yellowfin.com.au';
const YF_ADMIN_PASSWORD = process.env.YF_ADMIN_PASSWORD || 'test';
const YF_EMBED_USER = process.env.YF_EMBED_USER || YF_ADMIN_USER;
const API_MEDIA_TYPE = process.env.YF_API_MEDIA_TYPE || 'application/vnd.yellowfin.api-v4.2+json';

let cachedAccessToken = null;
let cachedAccessTokenExpiry = 0;

function authHeader(token) {
  const ts = Date.now();
  const nonce = Math.floor(Math.random() * 1e16);
  let header = `YELLOWFIN ts=${ts}, nonce=${nonce}`;
  if (token) header += `, token=${token}`;
  return header;
}

async function yfFetch(path, { method = 'GET', token, body } = {}) {
  const res = await fetch(`${YF_INTERNAL_URL}${path}`, {
    method,
    headers: { 'Authorization': authHeader(token), 'Accept': API_MEDIA_TYPE, 'Content-Type': API_MEDIA_TYPE },
    body: body !== undefined ? JSON.stringify(body) : undefined,
  });
  const text = await res.text();
  let data; try { data = text ? JSON.parse(text) : null; } catch { data = text; }
  if (!res.ok) {
    const msg = (data && (data.detail || data.title || data.message)) || `HTTP ${res.status}`;
    const err = new Error(`Yellowfin API error: ${method} ${path} -> ${msg}`);
    err.status = res.status; err.body = data; throw err;
  }
  return data;
}

/** 管理者でログインし access トークンを取得(キャッシュ付き) */
async function getAccessToken({ force = false } = {}) {
  const now = Date.now();
  if (!force && cachedAccessToken && now < cachedAccessTokenExpiry) return cachedAccessToken;
  const data = await yfFetch('/api/refresh-tokens', {
    method: 'POST', body: { userName: YF_ADMIN_USER, password: YF_ADMIN_PASSWORD },
  });
  const access = data?._embedded?.accessToken?.securityToken || data?.securityToken;
  if (!access) throw new Error('access トークンを取得できませんでした');
  cachedAccessToken = access;
  const expirySec = Number(data?._embedded?.accessToken?.expiry || data?.expiry);
  const ttlMs = Number.isFinite(expirySec) && expirySec > 0 ? expirySec * 1000 : 4 * 60_000;
  cachedAccessTokenExpiry = now + ttlMs - 30_000;
  return access;
}

async function withAccessToken(fn) {
  let token = await getAccessToken();
  try { return await fn(token); }
  catch (err) {
    if (err.status === 401 || err.status === 403) { token = await getAccessToken({ force: true }); return await fn(token); }
    throw err;
  }
}

// noPassword SSO 用のダミーパスワード。noPassword:true のとき Yellowfin はこの値を
// 検証しないため実パスワードは不要(前提: SIMPLE_AUTHENTICATION=TRUE)。
const SSO_DUMMY_PASSWORD = process.env.SSO_DUMMY_PASSWORD || 'sso-no-password';

/**
 * SSO ログイントークンを発行し securityToken を返す(REST POST /api/login-tokens)。
 * 返ってきた securityToken を logon.i4?LoginWebserviceId=<...> に渡すと、SSO 済みで
 * レポート作成画面が開く(トークンは5分・1回限り有効)。
 *
 * ポイント: ENTRY=CREATEREPORT と選択ビュー(VIEWID/VIEWUUID)は loginParameters として
 *   渡す。これらは「トークンにひも付いてサーバ側に保存」されるため、初回の中間ページの
 *   リダイレクトを跨いでも保持される。URL クエリに付けると脱落するので付けないこと。
 *
 * 前提: Yellowfin 側で SIMPLE_AUTHENTICATION=TRUE(scripts/enable-sso.sh)。
 *   無いと noPassword:true が UNSECURE_LOGON_NOT_ENABLED で弾かれる。
 */
async function createSsoSession(yfUser = YF_EMBED_USER, view) {
  const loginParameters = ['ENTRY=CREATEREPORT'];
  if (view && view.id) loginParameters.push('VIEWID=' + view.id);
  if (view && view.uuid) loginParameters.push('VIEWUUID=' + view.uuid);

  const data = await withAccessToken((token) =>
    yfFetch('/api/login-tokens', {
      method: 'POST',
      token,
      body: {
        signOnUser: { userName: yfUser, password: SSO_DUMMY_PASSWORD },
        noPassword: true,
        loginParameters,
      },
    })
  );
  const securityToken = data?.securityToken || data?._embedded?.loginToken?.securityToken;
  if (!securityToken) throw new Error('SSO ログイントークンを取得できませんでした(securityToken なし)');
  return securityToken;
}

/** securityToken から「レポート作成画面を開く」URL を組み立てる(別タブで飛ばす) */
function buildReportBuilderUrl(publicBase, securityToken) {
  const base = (publicBase || 'http://localhost:8080').replace(/\/$/, '');
  // ENTRY/VIEW は loginParameters でサーバ側に保存済み。URL には載せない(載せると初回に脱落)。
  return `${base}/logon.i4?LoginWebserviceId=${encodeURIComponent(securityToken)}`;
}

/** ライセンスを REST API で投入(load-license 用) */
async function uploadLicence(buffer, filename = 'yellowfin.lic') {
  return withAccessToken(async (token) => {
    const form = new FormData();
    form.append('newLicence', new Blob([buffer]), filename);
    const res = await fetch(`${YF_INTERNAL_URL}/api/rpc/licence-management/upload-licence`, {
      method: 'POST', headers: { 'Authorization': authHeader(token), 'Accept': API_MEDIA_TYPE }, body: form,
    });
    if (!res.ok) { const e = new Error(`upload-licence failed: HTTP ${res.status}`); e.status = res.status; throw e; }
    return res.text();
  });
}

module.exports = {
  YF_INTERNAL_URL, YF_EMBED_USER,
  getAccessToken, createSsoSession, buildReportBuilderUrl, uploadLicence,
};

app/lib/db.js(★コア:ビュー一覧の DB 直読み)

ビュー一覧は Yellowfin のリポジトリDB(reportview)を読み取り専用ユーザーで直接 SELECT します。返す id(= viewid)と uuid(= publishuuid)が、そのまま SSO の VIEWID / VIEWUUID になります。

app/lib/db.js
'use strict';

/**
 * Yellowfin リポジトリDB(PostgreSQL)への直接接続。
 * -----------------------------------------------------------------------------
 * データカタログ製品は、Yellowfin のビュー情報を「自社DB / Yellowfin リポジトリDB」から
 * 直接取得する想定。本サンプルでもビュー一覧はリポジトリDB(reportview)を直読みする。
 *
 * 読み取り専用ユーザー(catalog_ro)で接続する(scripts/enable-db-access.sh で作成)。
 */

const { Pool } = require('pg');

const pool = new Pool({
  host: process.env.YF_DB_HOST || 'yellowfin',
  port: Number(process.env.YF_DB_PORT || 5432),
  database: process.env.YF_DB_NAME || 'yellowfin',
  user: process.env.YF_DB_USER || 'catalog_ro',
  password: process.env.YF_DB_PASSWORD || 'catalog_ro_pw',
  max: 4,
  idleTimeoutMillis: 30_000,
  connectionTimeoutMillis: 8_000,
});

/**
 * Yellowfin のビュー一覧を取得する。
 * メタデータのマスタビューは reportview.parentviewid = 0 の行。
 *   viewid       … 数値ID(SSO の VIEWID)
 *   publishuuid  … UUID(SSO の VIEWUUID)
 *   viewname     … ビュー名
 *   sourcename   … データソース名
 */
async function listViews() {
  const sql = `
    SELECT m.viewid,
           m.publishuuid                AS view_uuid,
           m.viewname                   AS view_name,
           s.sourcename                 AS data_source
    FROM reportview m
    LEFT JOIN reportviewsource s ON s.sourceid = m.sourceid
    WHERE m.parentviewid = 0
      AND m.viewstatuscode = 'OPEN'
    ORDER BY m.viewname`;
  const { rows } = await pool.query(sql);
  return rows.map((r) => ({
    id: r.viewid,
    uuid: r.view_uuid,
    name: r.view_name || '(no name)',
    dataSource: r.data_source || '',
    _source: 'PostgreSQL(reportview)',
  }));
}

module.exports = { pool, listViews };

app/config/users.js(デモ用ユーザー)

「カタログ側」が独自に持っているユーザーの例です。デモなのでハードコードしています。password はこのカタログアプリにログインするためのもので、Yellowfin には渡しません。yfUser が、ログイン後にマッピングする Yellowfin 側のユーザー名です。素のインストールには admin しか居ないので、既定では全員 admin にマッピングして「そのまま動く」ようにしています。

app/config/users.js
'use strict';

/**
 * ホストアプリ(このサンプル)自身のユーザーストア。
 * -----------------------------------------------------------------------------
 * これは「外部サイト側」が独自に管理するユーザーの例(デモ用ハードコード)。
 *
 *  - password : このサンプルアプリにログインするためのパスワード。
 *               ★ Yellowfin には渡さない。アプリ側で本人確認するだけ。
 *  - yfUser   : ログイン成功後に SSO でマッピングする Yellowfin 側のユーザー名。
 *               サーバが管理者 API 資格情報を使い、この yfUser として
 *               Yellowfin のログイントークンを発行する(パスワード不要=SSO)。
 *
 * 既定では全員を admin@yellowfin.com.au にマッピングしている。
 * インストール直後の Yellowfin には admin しか居ないため、これで「そのまま動く」。
 * 実運用では、Yellowfin 側に閲覧用ユーザーを作成し、ここを個別の yfUser に変える。
 */

const DEFAULT_YF_USER = process.env.YF_EMBED_USER || 'admin@yellowfin.com.au';

const users = [
  {
    username: 'alice',
    password: 'alice123',
    displayName: 'Alice(営業部)',
    yfUser: DEFAULT_YF_USER,
  },
  {
    username: 'bob',
    password: 'bob123',
    displayName: 'Bob(経営企画)',
    yfUser: DEFAULT_YF_USER,
  },
];

/** username/password を検証し、一致すればユーザー情報を返す(無ければ null) */
function authenticate(username, password) {
  const u = users.find((x) => x.username === username && x.password === password);
  if (!u) return null;
  // パスワードは外に出さない
  return { username: u.username, displayName: u.displayName, yfUser: u.yfUser };
}

module.exports = { users, authenticate };

app/server.js(Express サーバ)

静的配信、自前ログイン(セッション)、ビュー一覧 API、SSO 遷移 URL 発行 API です。GET /api/views が DB からビュー一覧を返し、POST /api/sso/report-builder がログイン中ユーザーでログインセッションを発行して logon.i4 の URL を返します。

app/server.js
'use strict';

/**
 * ビューカタログ サンプルの Express サーバ。
 *  - public/ の静的配信
 *  - 自前ログイン(セッション)。カタログ独自ユーザーで本人確認
 *  - GET /api/views            … Yellowfin のビュー一覧(PostgreSQL 直読み)
 *  - POST /api/sso/report-builder … ログイン中ユーザーで SSO トークンを発行し、
 *                                   レポート作成画面を開く logon.i4 の URL を返す
 */

const path = require('path');
const express = require('express');
const session = require('express-session');
const yf = require('./lib/yellowfin');
const db = require('./lib/db');
const { authenticate } = require('./config/users');

const app = express();
const PORT = process.env.PORT || 3000;
const YF_PUBLIC_URL = (process.env.YF_PUBLIC_URL || 'http://localhost:8080').replace(/\/$/, '');

app.use(express.json());
app.use(session({
  secret: process.env.SESSION_SECRET || 'yf-isv-catalog-dev-secret',
  resave: false,
  saveUninitialized: false,
  cookie: { httpOnly: true, sameSite: 'lax' },
}));
app.use(express.static(path.join(__dirname, 'public')));

const wrap = (fn) => (req, res) => {
  Promise.resolve(fn(req, res)).catch((err) => {
    console.error(`[API ERROR] ${req.method} ${req.path}:`, err.message);
    res.status(err.status || 500).json({ error: err.message });
  });
};

function requireAuth(req, res, next) {
  if (req.session && req.session.user) return next();
  res.status(401).json({ error: 'ログインが必要です' });
}

// --- 公開 -------------------------------------------------------------------
app.get('/api/config', (req, res) => res.json({ yfPublicUrl: YF_PUBLIC_URL }));

app.post('/api/login', (req, res) => {
  const { username, password } = req.body || {};
  const user = authenticate(username, password);
  if (!user) return res.status(401).json({ error: 'ユーザー名またはパスワードが違います' });
  req.session.user = user;
  res.json({ username: user.username, displayName: user.displayName, yfUser: user.yfUser });
});

app.post('/api/logout', (req, res) => { req.session.destroy(() => res.json({ ok: true })); });

app.get('/api/me', (req, res) => {
  if (req.session && req.session.user) {
    const u = req.session.user;
    return res.json({ username: u.username, displayName: u.displayName, yfUser: u.yfUser });
  }
  res.status(401).json({ error: '未ログイン' });
});

// --- ログイン必須 -----------------------------------------------------------

// ビュー一覧(Yellowfin リポジトリDB を直読み)
app.get('/api/views', requireAuth, wrap(async (req, res) => res.json(await db.listViews())));

// レポート作成画面を開く SSO URL を発行。
// ログイン中ユーザーにマッピングされた Yellowfin ユーザーで SSO トークンを発行し、
// ENTRY=CREATEREPORT と選択ビュー(VIEWID/VIEWUUID)を付けた logon.i4 の URL を返す。
app.post('/api/sso/report-builder', requireAuth, wrap(async (req, res) => {
  const yfUser = req.session.user.yfUser;
  const view = { id: req.body && req.body.viewId, uuid: req.body && req.body.viewUuid };
  if (!view.id && !view.uuid) return res.status(400).json({ error: 'viewId または viewUuid が必要です' });
  // REST /api/login-tokens で ENTRY/VIEW を loginParameters に持たせた securityToken を発行
  // (パラメータはサーバ側保存なので初回の中間ページのリダイレクトを跨いでも保持される)
  const securityToken = await yf.createSsoSession(yfUser, view);
  const url = yf.buildReportBuilderUrl(YF_PUBLIC_URL, securityToken);
  res.json({ url, yfUser });
}));

app.listen(PORT, () => {
  console.log(`yf-isv-catalog app listening on http://localhost:${PORT}`);
  console.log(`  Yellowfin (browser): ${YF_PUBLIC_URL}`);
  console.log(`  Yellowfin (server) : ${yf.YF_INTERNAL_URL}`);
});

app/scripts/load-license.js

ライセンス投入をコンテナ内で実行する小さなスクリプトです(前述の load-license.sh から呼ばれます)。

app/scripts/load-license.js
'use strict';

/**
 * ライセンスを REST API で投入する(コンテナ内で実行)。
 * マウントされた /license のファイルを読み、yf.uploadLicence で送る。
 * ホスト OS に依存しない(base64 等の外部コマンド不要)。
 *
 * 実行例:
 *   docker compose exec -T -e LICENSE_PATH=/license/yellowfin.lic app node scripts/load-license.js
 */

const fs = require('fs');
const path = require('path');
const yf = require('../lib/yellowfin');

const file = process.env.LICENSE_PATH || '/license/yellowfin.lic';

if (!fs.existsSync(file)) {
  console.error('ライセンスが見つかりません(コンテナ内パス):', file);
  console.error('ホストの license/ にファイルを置いているか確認してください。');
  process.exit(1);
}

const buf = fs.readFileSync(file);
yf.uploadLicence(buf, path.basename(file))
  .then(() => console.log('==> ライセンス投入 OK'))
  .catch((e) => { console.error('投入失敗:', e.message); process.exit(1); });

app/public/index.html

ログイン画面と、ビュー一覧テーブルです。このサンプルは JS API を使いません(Yellowfin 本体を別タブで開くため)。app.js は /api/sso/report-builder が返す URL を window.open するだけです。

app/public/index.html
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>Yellowfin ビューカタログ サンプル</title>
  <link rel="stylesheet" href="styles.css" />
</head>
<body>
  <header>
    <h1>Yellowfin ビューカタログ サンプル</h1>
    <p class="sub">データカタログから Yellowfin のビュー一覧を表示 → 選択でレポート作成画面へ SSO 遷移</p>
    <div class="header-right">
      <div id="sso-status" class="badge badge-wait">SSO: 未接続</div>
      <div id="user-area" class="user-area" hidden>
        <span id="user-name"></span>
        <button id="btn-logout" class="link-btn">ログアウト</button>
      </div>
    </div>
  </header>

  <!-- ログイン画面(カタログアプリ自身の認証)。未ログイン時のみ表示 -->
  <section id="login-screen" class="login-screen" hidden>
    <form id="login-form" class="login-card">
      <h2>カタログにログイン</h2>
      <p class="login-note">これは外部サイト(データカタログ)自身のログインです。<br>
        成功すると、このユーザーが Yellowfin ユーザーにマッピングされ、裏側で SSO されます。</p>
      <label>ユーザー名<input id="login-username" type="text" autocomplete="username" value="alice"></label>
      <label>パスワード<input id="login-password" type="password" autocomplete="current-password" value="alice123"></label>
      <button type="submit" class="primary">ログイン</button>
      <div id="login-error" class="login-error"></div>
      <p class="login-hint">デモ用アカウント: <code>alice / alice123</code> ・ <code>bob / bob123</code></p>
    </form>
  </section>

  <!-- ログイン後に表示する本体 -->
  <div id="app-body" hidden>
    <main>
      <h2>Yellowfin ビュー一覧</h2>
      <p>カタログの一部として Yellowfin のビューを一覧表示します。
         「レポート作成」を押すと、<strong>別タブで Yellowfin のレポート作成画面が(そのビュー選択済みで)開きます。</strong>
         裏で SSO 済みのため再ログインは不要です。</p>
      <div class="controls">
        <input id="view-filter" type="text" placeholder="ビュー名でフィルタ..." />
        <button id="btn-reload" class="link-btn">再読み込み</button>
      </div>
      <div id="views-status" class="muted">読み込み中...</div>
      <table id="views-table" class="catalog" hidden>
        <thead>
          <tr><th>ビュー名</th><th>データソース</th><th>VIEW UUID</th><th></th></tr>
        </thead>
        <tbody id="views-body"></tbody>
      </table>
    </main>
  </div>

  <!-- ※このサンプルは JS API を使わない(Yellowfin 本体を別タブで開くため)。
       app.js は /api/sso/report-builder が返す URL を window.open するだけ。 -->
  <script src="app.js"></script>
</body>
</html>

app/public/app.js(フロント)

ビュー一覧を取得して表示し、「レポート作成」で /api/sso/report-builder から SSO 付き URL を取得して別タブで開きます。ポップアップブロック対策として、クリック直後に空タブを開いておき、URL を取得してから差し替えるのがコツです。

app/public/app.js
'use strict';

/**
 * フロント側ロジック(ビューカタログ)
 *  - カタログアプリ自身にログイン(セッション)
 *  - /api/views で Yellowfin のビュー一覧を取得して表示
 *  - 「レポート作成」で /api/sso/report-builder からSSO付きURLを取得 → 別タブで開く
 *
 * ※ JS API は使わない。Yellowfin 本体を別タブで開く「SSOディープリンク」方式。
 */

let currentUser = null;
let allViews = [];

const $ = (sel) => document.querySelector(sel);

async function api(path, opts) {
  const res = await fetch(path, opts);
  const data = await res.json().catch(() => ({}));
  if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
  return data;
}

function setSsoStatus(state, text) {
  const b = $('#sso-status');
  b.className = 'badge ' + (state === 'ok' ? 'badge-ok' : state === 'err' ? 'badge-err' : 'badge-wait');
  b.textContent = 'SSO: ' + text;
}

function escapeHtml(s) {
  return String(s == null ? '' : s).replace(/[&<>"']/g, (c) =>
    ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
}

// --- 起動 ------------------------------------------------------------------
async function boot() {
  wireLogin();
  $('#btn-reload').addEventListener('click', loadViews);
  $('#view-filter').addEventListener('input', renderViews);
  try {
    currentUser = await api('/api/me');
    enterApp();
  } catch {
    showLogin();
  }
}

function showLogin() {
  $('#login-screen').hidden = false;
  $('#app-body').hidden = true;
  $('#user-area').hidden = true;
  setSsoStatus('wait', '未ログイン');
  $('#views-body').innerHTML = '';
  $('#views-table').hidden = true;
}

function enterApp() {
  $('#login-screen').hidden = true;
  $('#app-body').hidden = false;
  $('#user-area').hidden = false;
  $('#user-name').textContent = currentUser.displayName + ' → YF: ' + currentUser.yfUser;
  setSsoStatus('ok', 'ログイン済み (' + currentUser.yfUser + ')');
  loadViews();
}

function wireLogin() {
  $('#login-form').addEventListener('submit', async (ev) => {
    ev.preventDefault();
    const err = $('#login-error');
    err.textContent = '';
    try {
      currentUser = await api('/api/login', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ username: $('#login-username').value, password: $('#login-password').value }),
      });
      enterApp();
    } catch (e) {
      err.textContent = e.message;
    }
  });

  $('#btn-logout').addEventListener('click', async () => {
    await api('/api/logout', { method: 'POST' }).catch(() => {});
    currentUser = null;
    allViews = [];
    showLogin();
  });
}

// --- ビュー一覧 ------------------------------------------------------------
async function loadViews() {
  const status = $('#views-status');
  status.textContent = '読み込み中...';
  $('#views-table').hidden = true;
  try {
    allViews = await api('/api/views');
    status.textContent = allViews.length ? allViews.length + ' 件のビュー(取得元: ' + (allViews[0]._source || '') + ')' : 'ビューがありません';
    renderViews();
  } catch (e) {
    status.textContent = '取得失敗: ' + e.message;
  }
}

function renderViews() {
  const q = $('#view-filter').value.trim().toLowerCase();
  const rows = allViews.filter((v) => !q || (v.name || '').toLowerCase().includes(q));
  const tbody = $('#views-body');
  tbody.innerHTML = '';
  for (const v of rows) {
    const tr = document.createElement('tr');
    tr.innerHTML =
      '<td>' + escapeHtml(v.name) + '</td>' +
      '<td>' + escapeHtml(v.dataSource || '') + '</td>' +
      '<td class="uuid">' + escapeHtml(v.uuid || v.id) + '</td>' +
      '<td></td>';
    const btn = document.createElement('button');
    btn.className = 'primary small';
    btn.textContent = 'レポート作成';
    btn.addEventListener('click', () => openReportBuilder(v, btn));
    tr.lastElementChild.appendChild(btn);
    tbody.appendChild(tr);
  }
  $('#views-table').hidden = rows.length === 0;
}

// --- レポート作成画面へ SSO 遷移(別タブ) --------------------------------
async function openReportBuilder(view, btn) {
  const label = btn.textContent;
  btn.disabled = true;
  btn.textContent = '遷移中...';
  // ポップアップブロック回避のため、クリック直後に空タブを開いておき、URL取得後に差し替える
  const tab = window.open('', '_blank');
  try {
    const data = await api('/api/sso/report-builder', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ viewId: view.id, viewUuid: view.uuid }),
    });
    if (tab) tab.location.href = data.url;
    else window.open(data.url, '_blank'); // ブロックされていた場合の保険
  } catch (e) {
    if (tab) tab.close();
    alert('SSO 遷移に失敗しました: ' + e.message);
  } finally {
    btn.disabled = false;
    btn.textContent = label;
  }
}

boot().catch((e) => { console.error(e); setSsoStatus('err', '初期化失敗'); });

app/public/styles.css

最小限のスタイルです(本筋ではないので折りたたみ感覚でどうぞ)。

app/public/styles.css
* { box-sizing: border-box; }
/* hidden 属性は常に最優先で非表示にする(.login-screen 等の display 指定に勝たせる) */
[hidden] { display: none !important; }
body {
  margin: 0;
  font-family: -apple-system, "Segoe UI", "Hiragino Kaku Gothic ProN", "Yu Gothic", Meiryo, sans-serif;
  color: #1f2933;
  background: #f4f6f8;
}
header {
  background: #1b2733;
  color: #fff;
  padding: 18px 24px;
  position: relative;
}
header h1 { margin: 0 0 4px; font-size: 20px; }
header .sub { margin: 0; font-size: 13px; color: #9fb3c8; }
.header-right {
  position: absolute; top: 16px; right: 24px;
  display: flex; align-items: center; gap: 14px;
}
.badge {
  padding: 5px 12px; border-radius: 14px; font-size: 12px; font-weight: 600;
}
.badge-wait { background: #52606d; color: #cbd2d9; }
.badge-ok   { background: #2f9e44; color: #fff; }
.badge-err  { background: #e03131; color: #fff; }

.user-area { display: flex; align-items: center; gap: 10px; font-size: 13px; color: #cbd2d9; }
.link-btn {
  background: none; border: 1px solid #52606d; color: #cbd2d9;
  padding: 4px 10px; border-radius: 6px; cursor: pointer; font-size: 12px;
}
.link-btn:hover { background: #2d3a47; }

/* ログイン画面 */
.login-screen {
  display: flex; justify-content: center; align-items: flex-start;
  padding: 60px 20px; min-height: 70vh;
}
.login-card {
  background: #fff; border: 1px solid #e4e7eb; border-radius: 10px;
  padding: 28px 32px; width: 100%; max-width: 380px;
  box-shadow: 0 4px 16px rgba(0,0,0,.06);
}
.login-card h2 { margin: 0 0 6px; font-size: 18px; }
.login-note { font-size: 13px; color: #52606d; line-height: 1.6; margin: 0 0 18px; }
.login-card label { display: block; font-size: 13px; color: #3e4c59; margin-bottom: 12px; }
.login-card input {
  display: block; width: 100%; margin-top: 5px; padding: 9px 10px;
  border: 1px solid #cbd2d9; border-radius: 6px; font-size: 14px;
}
.login-card button.primary { width: 100%; margin-top: 6px; padding: 11px; }
.login-error { color: #b91c1c; font-size: 13px; margin-top: 10px; min-height: 18px; }
.login-hint { font-size: 12px; color: #7b8794; margin: 14px 0 0; }
.login-hint code { background: #f0f2f5; padding: 1px 6px; border-radius: 4px; }

.tabs {
  display: flex; gap: 2px; background: #fff;
  border-bottom: 1px solid #e4e7eb; padding: 0 16px; flex-wrap: wrap;
}
.tab {
  background: none; border: none; padding: 14px 16px; cursor: pointer;
  font-size: 14px; color: #52606d; border-bottom: 3px solid transparent;
}
.tab:hover { color: #1f2933; }
.tab.active { color: #1c64f2; border-bottom-color: #1c64f2; font-weight: 600; }

main { padding: 24px; max-width: 1100px; margin: 0 auto; }
.panel { display: none; }
.panel.active { display: block; }
.panel h2 { font-size: 17px; margin-top: 0; }
.panel p { font-size: 14px; line-height: 1.6; color: #3e4c59; }

.controls { display: flex; gap: 10px; margin: 12px 0; flex-wrap: wrap; }
select {
  padding: 8px 10px; border: 1px solid #cbd2d9; border-radius: 6px;
  font-size: 14px; min-width: 280px; background: #fff;
}
button.primary {
  background: #1c64f2; color: #fff; border: none; border-radius: 6px;
  padding: 9px 18px; font-size: 14px; cursor: pointer;
}
button.primary:hover { background: #1751c9; }

.out {
  background: #1b2733; color: #d9e2ec; padding: 14px; border-radius: 8px;
  font-size: 13px; white-space: pre-wrap; word-break: break-all; line-height: 1.5;
}
.embed-container {
  margin-top: 12px; min-height: 480px; background: #fff;
  border: 1px solid #e4e7eb; border-radius: 8px; padding: 4px;
}
.embed-container.story { padding: 20px; min-height: 300px; }
.loading { padding: 40px; text-align: center; color: #7b8794; }
.err-box { padding: 16px; color: #b91c1c; background: #fef2f2; border-radius: 6px; }
.info-box { padding: 16px; color: #1e429f; background: #ebf2ff; border-radius: 6px; }

.signal-card {
  border: 1px solid #e4e7eb; border-left: 4px solid #f59f00;
  border-radius: 6px; padding: 12px 16px; margin-bottom: 10px; background: #fff;
}
.signal-card h4 { margin: 0 0 4px; font-size: 15px; }
.signal-card .signal-date { font-size: 12px; color: #7b8794; margin-bottom: 6px; }
.signal-card p { margin: 0; font-size: 14px; }
.signal-card .signal-value { font-size: 18px; font-weight: 700; color: #2f9e44; }

.embed-container.story { padding: 0; }
.story-frame { width: 100%; height: 70vh; border: 0; display: block; }

/* ===== ビューカタログ用 ===== */
main h2 { font-size: 18px; margin: 0 0 8px; }
.controls { display: flex; gap: 10px; margin: 12px 0; align-items: center; flex-wrap: wrap; }
#view-filter { padding: 8px 10px; border: 1px solid #cbd2d9; border-radius: 6px; font-size: 14px; min-width: 260px; }
.muted { color: #7b8794; font-size: 13px; margin: 6px 0; }
table.catalog { width: 100%; border-collapse: collapse; background: #fff; border: 1px solid #e4e7eb; border-radius: 8px; overflow: hidden; }
table.catalog th, table.catalog td { text-align: left; padding: 10px 14px; border-bottom: 1px solid #eef1f4; font-size: 14px; }
table.catalog th { background: #f4f6f8; color: #52606d; font-weight: 600; font-size: 12px; }
table.catalog tr:last-child td { border-bottom: 0; }
table.catalog td.uuid { font-family: ui-monospace, Menlo, monospace; font-size: 11px; color: #7b8794; }
button.primary.small { padding: 6px 14px; font-size: 13px; }

ハマりどころ(実機で踏んだもの)

このサンプルを作る過程で実際に詰まった点をまとめます。今回の核心は #1 です。

1. ★ ログインセッションオプションは URL に付けると初回に脱落する

これが一番ハマりました。レポート作成画面を開くには、ログインセッションに ENTRY=CREATEREPORT と VIEWID / VIEWUUID を持たせる必要があります。最初は素直に URL クエリに付けてみました。

logon.i4?LoginWebserviceId=<id>&ENTRY=CREATEREPORT&VIEWID=...&VIEWUUID=...

ところが、初回アクセス時だけレポート作成画面が「システムエラー」になります(2回目以降はなぜか動く)。サーバログを見ると NullPointerException(MIForm.getAction() が null)でした。

原因は、Yellowfin の初回アクセス時に挟まる中間ページ(Browser Check や、試用ライセンスの「Not for Re-Sale」透かしページ)のリダイレクトで URL クエリのパラメータが脱落することでした。PreReportData.i4 に届く頃には ENTRY などが消えていて、action が null になります。2回目以降は中間ページを通らないので、たまたま動いていたわけです。

解決策は、パラメータを URL ではなくトークン側(サーバ保存)に持たせることです。POST /api/login-tokens のボディに loginParameters: ["ENTRY=CREATEREPORT","VIEWID=...","VIEWUUID=..."] を付けて発行すると、これらはトークンにひも付いてサーバ側に保存されます。あとは返ってきた securityToken を logon.i4?LoginWebserviceId=<securityToken> に付けるだけ。URL には LoginWebserviceId だけを載せればよく、リダイレクトを跨いでもパラメータが保持されるため、初回から確実に動きます。

// ❌ URL に付ける → 初回の中間ページのリダイレクトで脱落して action=null
//   logon.i4?LoginWebserviceId=<token>&ENTRY=CREATEREPORT&VIEWID=...

// ⭕ /api/login-tokens の loginParameters に渡す(=トークン側に保存)→ リダイレクトを跨いでも保持
//   logon.i4?LoginWebserviceId=<securityToken>   ← URL は LoginWebserviceId だけ

2. REST の /api/login-tokens に loginParameters を渡す

最初は「loginParameters をどう渡すか」「発行した securityToken をどう使うか」で混乱しました(一時は SOAP の AdministrationService も検討しました)。整理すると、REST だけで完結します。

  • 発行: POST /api/login-tokens のボディに、マッピング先ユーザーの signOnUser・noPassword: true・loginParameters: ["ENTRY=CREATEREPORT","VIEWID=...","VIEWUUID=..."] を渡す。レスポンスの securityToken がトークン(5分・1回限り有効)。
  • 利用: その securityToken を logon.i4?LoginWebserviceId=<securityToken> に付けてブラウザを飛ばすだけ。loginParameters はトークン側に保存されているので URL には載せない(#1)。

公式ドキュメントにも、loginParameters の ENTRY で初期画面(CREATEREPORT など)を指定でき、返却される securityToken は「ユーザーを Yellowfin に誘導する際に認証をバイパスするのに使える」と明記されています。

3. パスワードなし SSO には DB 設定(SIMPLE_AUTHENTICATION)が必要

noPassword: true(パスワードなしログイン)は、初期状態では許可されていません。リポジトリDBの Configuration テーブルに SIMPLE_AUTHENTICATION=TRUE を入れ、Yellowfin を再起動すると通るようになります(設定がキャッシュされるため、再起動が必要)。このサンプルでは scripts/enable-sso.sh で自動化しています。

※ この点は先人の記事に助けられました。

4. ビュー一覧は reportview を直読み(parentviewid=0 がマスタ)

ビューのメタデータは reportview テーブルにあります。1つのビューに対して複数行(テーブル要素など)が入るので、マスタビューは parentviewid = 0 で絞ります。あわせて viewstatuscode = 'OPEN' で有効なものだけにします。SSO に使う値は viewid(= VIEWID)と publishuuid(= VIEWUUID)です。データソース名は reportviewsource.sourcename から取れます。

5. リポジトリDB へ外部から接続するための下ごしらえ

all-in-one の内部 PostgreSQL は、既定で localhost のみ待受・peer 認証です。別コンテナ(app)から接続するには、

  • 読み取り専用ユーザー(catalog_ro)を作って必要なテーブルに GRANT SELECT
  • listen_addresses = '*'(再起動で反映)
  • pg_hba.conf に host yellowfin catalog_ro 0.0.0.0/0 scram-sha-256 を追記(reload で反映)

が要ります。listen_addresses の反映に再起動が必要なので、start.sh では DB 有効化 → SSO 許可(このとき再起動) の順にして、1回の再起動で両方を反映させています。本番では、この手順の代わりに実際のリポジトリDB(または自社カタログDB)の接続情報を .env に設定するだけです。

6. REST API のメディアタイプは「バージョン付き」(ライセンス投入で使用)

ライセンス投入などの REST 呼び出しでは、Accept / Content-Type に application/vnd.yellowfin.api-v4.2+json のようなバージョン付きメディアタイプが必要です(application/json だと UNKNOWN_VERSION で弾かれます)。

7. ライセンスも REST API で投入できる

all-in-one は「ライセンスは Web UI から投入」と案内されますが、POST /api/rpc/licence-management/upload-licence(フォーム項目 newLicence)で REST 経由でも投入できます。ライセンス未投入でも管理者の access トークンは取れるので、これで起動を完全自動化できます。

8. all-in-one はデータ非永続

このイメージは停止するとデータが消えます。docker compose down で作り直したら、ライセンス投入・DB 接続有効化・SSO 許可をやり直す必要があります(だから start.sh 一発で再現できるようにしています)。restart(再起動)ではデータは消えません。

9. 古い Yellowfin イメージのキャッシュ

docker compose up は :latest がローカルにあれば 再 pull せず使い回します。古いものが残っていると本サンプル前提の 9.17 と食い違うため、docker-compose.yml ではタグを 9.17 に固定しています(.env の YF_IMAGE_TAG で変更可)。


まとめ

  • データカタログから Yellowfin のビュー一覧(DB 直読み) を表示し、選択で レポート作成画面へ SSO 遷移する、そのまま動くサンプルを Docker 一式 + ワンショット起動で作りました。
  • ビュー一覧は、製品実態に合わせて REST ではなく PostgreSQL 直読みにしています。
  • いちばんの落とし穴は、ログインセッションオプションを URL に付けると初回に脱落すること。/api/login-tokens の loginParameters に持たせ、logon.i4 には LoginWebserviceId だけを付けることで初回から確実に動きます。

ディープリンク遷移の「最初の一歩」として、そのまま使える形を目指しました。どなたかの参考になれば幸いです。


参考リンク

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?