From 76700845fe6b8b7bfd7c90122da45ccc7c827bd6 Mon Sep 17 00:00:00 2001 From: Kenichiro NOGI Date: Sat, 19 Sep 2026 11:06:02 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20get-voice-mail=20app-portal=E3=82=AD?= =?UTF-8?q?=E3=83=83=E3=82=AB=E3=83=BC=E8=BF=BD=E5=8A=A0=E3=80=81app-porta?= =?UTF-8?q?l=20UI=E3=82=92Bootstrap=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit get-voice-mailはn8n Webhookへの中継専用アプリ(SCP取得・プリザンター書込はn8n側で実施)。 app-portalはBootstrap 5導入で見た目を刷新(カード/モーダル/トースト化)。 dokploy-webapp SKILLはGitea統合(NodeSrv→ken_nogi)後の設定変更を反映。 --- .claude/skills/dokploy-webapp/SKILL.md | 125 ++++++++++++++++++ NodeSrv/apps/app-portal/public/portal.js | 28 +++- NodeSrv/apps/app-portal/src/adminView.js | 79 +++++++---- NodeSrv/apps/app-portal/src/dashboard.js | 87 +++++++++--- NodeSrv/apps/get-voice-mail/.dockerignore | 4 + NodeSrv/apps/get-voice-mail/.env.example | 14 ++ NodeSrv/apps/get-voice-mail/Dockerfile | 13 ++ .../get-voice-mail/docker-compose.local.yml | 7 + .../apps/get-voice-mail/docker-compose.yml | 14 ++ NodeSrv/apps/get-voice-mail/package.json | 14 ++ NodeSrv/apps/get-voice-mail/spec.md | 59 +++++++++ NodeSrv/apps/get-voice-mail/src/index.js | 39 ++++++ .../apps/get-voice-mail/src/triggerAuth.js | 5 + .../get-voice-mail/test/triggerAuth.test.js | 11 ++ 14 files changed, 452 insertions(+), 47 deletions(-) create mode 100644 .claude/skills/dokploy-webapp/SKILL.md create mode 100644 NodeSrv/apps/get-voice-mail/.dockerignore create mode 100644 NodeSrv/apps/get-voice-mail/.env.example create mode 100644 NodeSrv/apps/get-voice-mail/Dockerfile create mode 100644 NodeSrv/apps/get-voice-mail/docker-compose.local.yml create mode 100644 NodeSrv/apps/get-voice-mail/docker-compose.yml create mode 100644 NodeSrv/apps/get-voice-mail/package.json create mode 100644 NodeSrv/apps/get-voice-mail/spec.md create mode 100644 NodeSrv/apps/get-voice-mail/src/index.js create mode 100644 NodeSrv/apps/get-voice-mail/src/triggerAuth.js create mode 100644 NodeSrv/apps/get-voice-mail/test/triggerAuth.test.js diff --git a/.claude/skills/dokploy-webapp/SKILL.md b/.claude/skills/dokploy-webapp/SKILL.md new file mode 100644 index 00000000..ab13d42e --- /dev/null +++ b/.claude/skills/dokploy-webapp/SKILL.md @@ -0,0 +1,125 @@ +--- +name: dokploy-webapp +description: NodeSrv配下(apps/)にNode.js WEBアプリを新規作成し、ローカル開発から既存Dokploy環境(AWS Lightsail上で稼働中のセルフホストPaaS)へのデプロイ・運用までを行う。「新しいアプリを作って」「Dokployにデプロイして」「アプリをNodeSrvに追加して」「動作検証して」等の依頼で使う。 +--- + +# NodeSrv アプリ開発 & Dokployデプロイ + +`NodeSrv/apps/` を1アプリ1フォルダ1Dokploy Composeとして管理する。アプリ同士は完全に独立し、片方の開発・再デプロイがもう片方に影響しない設計。 + +## 新規アプリ作成 + +1. `NodeSrv/apps/_template` を `NodeSrv/apps/` としてコピー +2. `package.json` の `name` を `` に変更 +3. 必要な依存関係を追加、`src/index.js` に実装を追加していく +4. `/health` エンドポイントは削除・変更しない(Dokployのヘルスチェック対象) + +## ローカル開発 + +``` +cd NodeSrv/apps/ +npm install +cp .env.example .env +npm run dev +``` + +`http://localhost:/` と `/health` で動作確認。 + +## デプロイ前のDocker動作確認(必須) + +Dokployは本番でDockerfileから直接ビルドするため、pushする前にローカルでも同じ手順でビルド・起動できることを確認する。 + +``` +docker compose -f docker-compose.local.yml up --build +``` + +エラー無く起動し `/health` が200を返すことを確認してから次に進む。 + +## Gitソース: Gitea + +GitHub直接連携ではなく、Dokploy上に自前ホストしたGitea(`https://git73.next-hd.net/mygit-admin/ken_nogi.git`、リモート名`gitea`)を経由する。理由: モノレポ(`ken_nogi`)のプライベートリポジトリをDokployのGit Provider連携で直接使う設定がうまくいかず、Gitea自前ホストに切り替えた経緯がある(詳細はプロジェクトメモリ参照)。 + +**注意(2026-09-19判明)**: 当初はGitea上に`NodeSrv`という単体リポジトリを作って運用していたが、その後`dev`配下(NodeSrv/Pleasanter等)を`ken_nogi`という1つのGiteaリポジトリへ統合した。`NodeSrv`単体リポジトリは既に削除済み(`git73.next-hd.net/mygit-admin/NodeSrv`は404)。Dokploy側の既存Compose(app-portal/n8n等)は統合後もしばらく古い`giteaRepository: "NodeSrv"`のままになっていて、pushしても反映されない状態だった。2026-09-19に全Compose(app-portal/authgw-poc/auth-redirect/portal-sample-batch/n8n/keycloak-monitor/lineworks-user-auth)を`giteaRepository: "ken_nogi"`・`composePath`の先頭に`NodeSrv/`を付ける形へ`dokploy compose update`で修正済み。**新規Compose作成時は`giteaRepository`に`ken_nogi`、`composePath`は必ず`NodeSrv/apps//docker-compose.yml`(先頭に`NodeSrv/`が要る)を指定すること。** + +``` +git remote -v # "gitea" が登録済みか確認 +git push gitea main +``` + +**注意**: Windows環境でBashツールからpushすると、Git Credential Manager (GCM) がGUIダイアログを出そうとして `tty` が無い環境では失敗する(`fatal: helper error (-1): User cancelled dialog.`)。その場合は一時的な `GIT_ASKPASS` スクリプトで認証情報(`AUTH_SECRETS.md` の Gitea項目)を渡し、push後は必ずスクリプトを削除する。認証情報を `git push` のURLやBashコマンド引数に直接書かない。 + +## Dokployへの接続 + +`@dokploy/cli`(`npm install -g @dokploy/cli`)をインストール済みで使う。認証は `dokploy auth -u https://dokploy45.next-hd.net -t ` 済みでセッションが保持されている前提(**トークンは絶対にチャットに貼らせない・貼られたら即Revoke指示**。認証はユーザー自身のターミナルで実行させる)。以後は `dokploy ` をそのまま実行してよい(本番操作なので実行前に毎回内容を提示し確認を取る)。 + +**既存環境の実態**(2026-07-25確認、CLI v0.29.4): +- NodeSrv用アプリは `webapps` Project配下に **Compose**(Docker Compose、Provider: Gitea、`sourceType: gitea`)として追加する。Dokploy標準の「Application」type・「Domains」タブは使わない(README.md「共通ルール」参照) +- `projectId: 3T-drY1Q377JcLeTiNTDK` / `environmentId: Cm0HjMIFyl11UdIcIGRy8`(`production`環境) +- **CLIのGET系コマンドはほぼ全滅**(`compose one`, `docker get-containers*` 等が軒並み `Request failed with status code 400`)。POST/PUT系(`compose deploy`等)は動く。状態確認はCLIに頼らずSSH直接調査(下記)を使う + - **原因特定済み(2026-07-25)**: `@dokploy/cli`(`dist/client.js`の`apiGet`関数)がtRPCのGETクエリを組み立てる際、`apiPost`と違い`{json: params}`でラップせず生の`params`を`?input=`にJSON化しているだけの実装バグ。DokployのtRPCサーバーは`{"json": {...}}`形式のラップを要求するため、これがない全てのGETリクエストが400になる + - Dokploy tRPC API自体は正常。`x-api-key`ヘッダー付きで`GET {DOKPLOY_URL}/api/trpc/?input=`の形式で直接叩けば200が返ることを確認済み(`compose.one`で実証)。ポータル等でDokploy API連携が必要な場合は、CLIを介さずこの正しい形式で自前クライアントを実装すること + - CLI自体へのパッチは行わない方針(グローバルnpmパッケージの一時修正は`npm update`等で消えるため、恒常的な対応にならない) + +## Dokployへのデプロイ(CLI手順) + +1アプリ = 1 Dokploy "Compose"。`webapps` Project配下に、`apps//docker-compose.yml` をCompose Pathとして指定する。 + +### 新規Compose作成(CLI手順、2026-07-25実地検証済み) + +`dokploy compose create`はGitHub向けのデフォルト値で作成されるため、直後に`dokploy compose update`でGitea連携用の値へ切り替える2段階が必要。 + +``` +# 1. Compose自体を作成(webapps projectのenvironmentIdを指定) +dokploy compose create \ + --name "" \ + --environmentId "Cm0HjMIFyl11UdIcIGRy8" \ + --composeType "docker-compose" \ + --appName "" \ + --json +# → composeId が返る + +# 2. sourceTypeをgiteaへ切替、リポジトリ・パスを指定 +dokploy compose update \ + --composeId "<上で得たcomposeId>" \ + --sourceType "gitea" \ + --giteaId "O5-CqLQwVdlzXw3KfmN-8" \ + --giteaOwner "mygit-admin" \ + --giteaRepository "ken_nogi" \ + --giteaBranch "main" \ + --composePath "NodeSrv/apps//docker-compose.yml" \ + --json + +# 3. デプロイ実行 +dokploy compose deploy --composeId "" --title "<変更内容>" --json +``` + +`serverId`は省略可(単一サーバー構成のため)。 + +## デプロイ後の検証 + +CLIの `compose one` 等GET系コマンドは400エラーで使えないため、以下のいずれかで代替する。 + +``` +# SSH経由でコンテナ状態・ラベルを直接確認 +ssh -i NodeSrv/Keys/LightsailDefaultKey-ap-northeast-1.pem ubuntu@dokploy45.next-hd.net \ + "sudo docker ps --filter name=" +ssh -i NodeSrv/Keys/LightsailDefaultKey-ap-northeast-1.pem ubuntu@dokploy45.next-hd.net \ + "sudo docker inspect --format '{{json .Config.Labels}}'" + +# 外部疎通確認 +curl -I https://.next-hd.net/health +``` + +**SSHが自動モード分類器にブロックされる場合**(2026-07-26確認、`sudo`付きコマンド等でブロックされることがある)は、上記57行目の自前fetchクライアント方式でDokploy側の状態(デプロイ履歴の`status`、compose設定等)を確認する。認証情報はCLIの`config.json`(`C:/Users/k.nogi/AppData/Roaming/npm/node_modules/@dokploy/cli/config.json`)から読み取り、結果は必ずファイルへ書き出してから必要な値だけ抽出する(レスポンスに機密情報が含まれるため画面に出さない)。デプロイ完了確認は`deployment.allByCompose`エンドポイントの`status`(`done`か)を見る。 + +- 割り当てたドメインへ `curl` し `/health` が200 +- 別アプリを同時にredeployし、対象アプリ以外が無停止であることを確認(独立性の担保) + +## アプリポータルへの登録(任意) + +新規アプリを`NodeSrv/apps/app-portal`のダッシュボードに登録したい場合、`.env.example`にPORTAL_*変数を追加する(詳細は`NodeSrv/apps/_template/README.md`「アプリポータルへの登録」章、実装は`NodeSrv/apps/app-portal/src/dokployClient.js`参照)。 + +## 参考 + +- 全体の運用方針・認証ゲートウェイの詳細: [../../../NodeSrv/README.md](../../../NodeSrv/README.md) +- Dokployインフラの経緯: プロジェクトメモリ `project_nodesrv-dokploy-lightsail` diff --git a/NodeSrv/apps/app-portal/public/portal.js b/NodeSrv/apps/app-portal/public/portal.js index b21316d1..5d4fdba3 100644 --- a/NodeSrv/apps/app-portal/public/portal.js +++ b/NodeSrv/apps/app-portal/public/portal.js @@ -1,3 +1,22 @@ +const logModalEl = document.getElementById('logModal'); +const logModalBody = document.getElementById('logModalBody'); +const logModal = logModalEl ? new bootstrap.Modal(logModalEl) : null; + +const toastEl = document.getElementById('actionToast'); +const toastBody = document.getElementById('actionToastBody'); +const toast = toastEl ? new bootstrap.Toast(toastEl) : null; + +function showToast(message, isError) { + if (!toast) { + alert(message); + return; + } + toastBody.textContent = message; + toastEl.classList.toggle('text-bg-danger', Boolean(isError)); + toastEl.classList.toggle('text-bg-primary', !isError); + toast.show(); +} + document.getElementById('cards').addEventListener('click', async (event) => { const button = event.target.closest('button[data-action]'); if (!button) return; @@ -6,18 +25,19 @@ document.getElementById('cards').addEventListener('click', async (event) => { if (action === 'logs') { const res = await fetch(`/api/compose/${composeId}/logs`); if (!res.ok) { - alert(`ログ取得に失敗しました (status: ${res.status})`); + showToast(`ログ取得に失敗しました (status: ${res.status})`, true); return; } const data = await res.json(); - alert(data.logs || 'ログなし'); + logModalBody.textContent = data.logs || 'ログなし'; + logModal.show(); return; } const res = await fetch(`/api/compose/${composeId}/${action}`, { method: 'POST' }); if (res.ok) { - alert(`${action} を実行しました`); + showToast(`${action} を実行しました`, false); } else { - alert(`${action} に失敗しました (status: ${res.status})`); + showToast(`${action} に失敗しました (status: ${res.status})`, true); } }); diff --git a/NodeSrv/apps/app-portal/src/adminView.js b/NodeSrv/apps/app-portal/src/adminView.js index 86f5c455..825d94dc 100644 --- a/NodeSrv/apps/app-portal/src/adminView.js +++ b/NodeSrv/apps/app-portal/src/adminView.js @@ -6,55 +6,84 @@ function escapeHtml(value) { .replace(/"/g, '"'); } +const BOOTSTRAP_CSS = + ''; +const BOOTSTRAP_JS = + ''; + function renderLoginPage(errorMessage) { - const errorHtml = errorMessage ? `

${escapeHtml(errorMessage)}

` : ''; + const errorHtml = errorMessage + ? `` + : ''; return ` - + + App Portal Admin Login +${BOOTSTRAP_CSS} - -

Admin Login

-${errorHtml} -
- - -
+ +
+
+
+

Admin Login

+ ${errorHtml} +
+
+ +
+ +
+
+
+
`; } function renderAdminPage(emails, errorMessage) { - const errorHtml = errorMessage ? `

${escapeHtml(errorMessage)}

` : ''; + const errorHtml = errorMessage + ? `` + : ''; const rows = emails .map( - (email) => `
  • + (email) => `
  • ${escapeHtml(email)} -
    + - +
  • ` ) .join('\n'); return ` - + + Allowlist Admin +${BOOTSTRAP_CSS} - -

    Allowlist Admin

    -${errorHtml} -
      ${rows}
    -
    - - -
    -
    - -
    + + +
    + ${errorHtml} +
      ${rows}
    +
    + + +
    +
    + +
    +
    +${BOOTSTRAP_JS} `; } diff --git a/NodeSrv/apps/app-portal/src/dashboard.js b/NodeSrv/apps/app-portal/src/dashboard.js index 3a16e461..b68d9a40 100644 --- a/NodeSrv/apps/app-portal/src/dashboard.js +++ b/NodeSrv/apps/app-portal/src/dashboard.js @@ -37,30 +37,50 @@ async function buildCards(environmentId) { return cards; } +const STATE_BADGE = { + running: 'bg-success', + stopped: 'bg-secondary', + unknown: 'bg-warning text-dark', +}; + function renderCard(card) { if (card.error) { - return `
    -

    ${escapeHtml(card.name || card.composeId)}

    -

    状態取得失敗

    + return `
    +
    +
    +

    ${escapeHtml(card.name || card.composeId)}

    +

    状態取得失敗

    +
    +
    `; } const { composeId, composeStatus, containerState, meta } = card; + const badgeClass = STATE_BADGE[containerState] || 'bg-warning text-dark'; const openLink = meta.appType === 'web' && meta.url - ? `開く` + ? `開く` : ''; const actionButtons = meta.appType === 'web' - ? ` - - ` - : ``; - return `
    -

    ${escapeHtml(meta.label)}

    -

    compose: ${escapeHtml(composeStatus)} / container: ${escapeHtml(containerState)}

    - ${openLink} - ${actionButtons} - + ? ` + + ` + : ``; + return `
    +
    +
    +
    +

    ${escapeHtml(meta.label)}

    + ${escapeHtml(containerState)} +
    +

    compose: ${escapeHtml(composeStatus)}

    +
    + ${openLink} + ${actionButtons} + +
    +
    +
    `; } @@ -68,14 +88,45 @@ async function renderDashboard(environmentId) { const cards = await buildCards(environmentId); const cardsHtml = cards.map(renderCard).join('\n'); return ` - + + App Portal + - -

    App Portal

    -
    ${cardsHtml}
    + + +
    +
    ${cardsHtml}
    +
    + +
    +
    +
    +
    + +
    +
    +
    + `; diff --git a/NodeSrv/apps/get-voice-mail/.dockerignore b/NodeSrv/apps/get-voice-mail/.dockerignore new file mode 100644 index 00000000..cab67ef4 --- /dev/null +++ b/NodeSrv/apps/get-voice-mail/.dockerignore @@ -0,0 +1,4 @@ +node_modules +npm-debug.log +.env +.git diff --git a/NodeSrv/apps/get-voice-mail/.env.example b/NodeSrv/apps/get-voice-mail/.env.example new file mode 100644 index 00000000..30322f34 --- /dev/null +++ b/NodeSrv/apps/get-voice-mail/.env.example @@ -0,0 +1,14 @@ +PORT=3000 +NODE_ENV=development + +# app-portalキッカー登録用 +PORTAL_APP_TYPE=batch +PORTAL_APP_LABEL=ボイスメール取得 +PORTAL_APP_URL=http://get-voice-mail:3000 +PORTAL_TRIGGER_PATH=/trigger +PORTAL_SECRET= + +# n8nワークフロー入口(Webhookノード)のURL。/trigger起動時にPOSTする先 +N8N_WEBHOOK_URL=https://n8n32.next-hd.net/webhook/get-voice-mail +# n8n Webhookノード側のHeader Auth Credential値(X-Api-Keyヘッダーに設定) +N8N_WEBHOOK_SECRET= diff --git a/NodeSrv/apps/get-voice-mail/Dockerfile b/NodeSrv/apps/get-voice-mail/Dockerfile new file mode 100644 index 00000000..927b3f2f --- /dev/null +++ b/NodeSrv/apps/get-voice-mail/Dockerfile @@ -0,0 +1,13 @@ +FROM node:22-alpine AS deps +WORKDIR /app +COPY package.json package-lock.json* ./ +RUN npm ci --omit=dev + +FROM node:22-alpine +WORKDIR /app +ENV NODE_ENV=production +COPY --from=deps /app/node_modules ./node_modules +COPY . . +USER node +EXPOSE 3000 +CMD ["node", "src/index.js"] diff --git a/NodeSrv/apps/get-voice-mail/docker-compose.local.yml b/NodeSrv/apps/get-voice-mail/docker-compose.local.yml new file mode 100644 index 00000000..26199853 --- /dev/null +++ b/NodeSrv/apps/get-voice-mail/docker-compose.local.yml @@ -0,0 +1,7 @@ +services: + get-voice-mail: + build: . + ports: + - "3002:3000" + env_file: + - .env diff --git a/NodeSrv/apps/get-voice-mail/docker-compose.yml b/NodeSrv/apps/get-voice-mail/docker-compose.yml new file mode 100644 index 00000000..fe44b317 --- /dev/null +++ b/NodeSrv/apps/get-voice-mail/docker-compose.yml @@ -0,0 +1,14 @@ +services: + get-voice-mail: + build: . + expose: + - 3000 + env_file: + - .env + networks: + - dokploy-network + restart: unless-stopped + +networks: + dokploy-network: + external: true diff --git a/NodeSrv/apps/get-voice-mail/package.json b/NodeSrv/apps/get-voice-mail/package.json new file mode 100644 index 00000000..5573c373 --- /dev/null +++ b/NodeSrv/apps/get-voice-mail/package.json @@ -0,0 +1,14 @@ +{ + "name": "get-voice-mail", + "version": "0.1.0", + "private": true, + "type": "commonjs", + "scripts": { + "dev": "node --watch src/index.js", + "start": "node src/index.js", + "test": "node --test" + }, + "dependencies": { + "express": "^4.21.0" + } +} diff --git a/NodeSrv/apps/get-voice-mail/spec.md b/NodeSrv/apps/get-voice-mail/spec.md new file mode 100644 index 00000000..c878754d --- /dev/null +++ b/NodeSrv/apps/get-voice-mail/spec.md @@ -0,0 +1,59 @@ +# get-voice-mail +## 仕様 + + +- n8nを利用 +- SCPでサーバーから録音データ取得 +- プリザンターに音声保存 + +## サーバー情報 +pbx-server-secret.md 参照 + +## voiceメールのパス +/var/spool/asterisk/monitor + +この下に、年フォルダ、月フォルダ、日フォルダが階層化されている + +取得ファイルルール ※あとで変わるかもだがいまは固定ルール +- ファイル名の先頭がin-0420002706-で始まるもの +{方向}-{番号A}-{番号B}-{日付}-{時刻}-{uniqueid}.wav + +- 方向: in=着信 / out=発信 / generic=内線間 or 方向判定不能時 +- 番号A: 着信の場合は着信先(DID/内線番号)、発信の場合は発信元内線 +- 番号B: 相手側番号(外部番号 or 内線) +- 日付: YYYYMMDD +- 時刻: HHMMSS +- uniqueid: Asteriskチャンネル固有ID (epoch秒.マイクロ秒)。通話開始時刻ベース、重複なし識別子 + +例: +- in-0420002706-05057998188-20260918-165846-1789718326.114385.wav +→ 着信、着信先0420002706、相手05057998188、2026-09-18 16:58:46 +- generic-0425400505-05017446045-20260918-170343-1789718623.114390.wav +→ 内線間 or 方向不明、番号0425400505⇔05017446045 + +ファイル名からいつの音声データなのか日付情報も算出 +ファイル名の日付はJST + + +## プリザンター +取得した音声ファイルと内容をプリザンターのテーブルへ書き出し +https://nextoffice.next-hd.co.jp/pleasanter/items/515888/index + +- ClassA:番号A +- ClassB:番号B +- ClassC:WAVファイル名 ※すでに同じファイルが登録済みの場合はスキップ +- AttachmentA:WAV本体保存 + + +## n8nワークフロー起動方式 +入口=Webhookノード固定。cron等の自前スケジューラは使わない。 +起動元=app-portal(`NodeSrv/apps/app-portal`)のキッカー(batch型トリガー)。 + +実装=`NodeSrv/apps/get-voice-mail`直下のNode.jsアプリ(他アプリと同じ1フォルダ1Dokploy Compose)。 +役割=中継のみ。`/trigger`受信→n8n Webhook URLへPOST→202即返却。SCP取得・WAV判定・プリザンター書込は全部n8n側workflow内で実施。 + +- POST `/trigger`(ヘッダ`X-Portal-Secret`必須、`PORTAL_SECRET`と一致時のみ許可) +- 認証通過後、環境変数`N8N_WEBHOOK_URL`(n8n Webhookノードの入口URL)へPOST、結果を待たず202返却 +- `.env.example`にPORTAL_APP_TYPE=batch等のポータル登録変数一式を用意済み。Dokploy Environment画面で`PORTAL_SECRET`・`N8N_WEBHOOK_URL`を設定してから使う +- ポータル管理・トリガー実装パターンの詳細は`NodeSrv/apps/_template/README.md`「アプリポータルへの登録」章、実装例は`NodeSrv/apps/portal-sample-batch`参照 + diff --git a/NodeSrv/apps/get-voice-mail/src/index.js b/NodeSrv/apps/get-voice-mail/src/index.js new file mode 100644 index 00000000..c17f4586 --- /dev/null +++ b/NodeSrv/apps/get-voice-mail/src/index.js @@ -0,0 +1,39 @@ +const express = require('express'); +const { isTriggerAuthorized } = require('./triggerAuth'); + +const app = express(); +const PORT = process.env.PORT || 3000; +const PORTAL_SECRET = process.env.PORTAL_SECRET; +const N8N_WEBHOOK_URL = process.env.N8N_WEBHOOK_URL; +const N8N_WEBHOOK_SECRET = process.env.N8N_WEBHOOK_SECRET; + +app.get('/', (req, res) => { + res.json({ app: 'get-voice-mail', status: 'ok' }); +}); + +app.get('/health', (req, res) => { + res.status(200).json({ status: 'healthy' }); +}); + +app.post('/trigger', (req, res) => { + if (!isTriggerAuthorized(req.headers['x-portal-secret'], PORTAL_SECRET)) { + res.sendStatus(403); + return; + } + if (!N8N_WEBHOOK_URL) { + console.error('get-voice-mail: N8N_WEBHOOK_URL未設定'); + res.sendStatus(500); + return; + } + fetch(N8N_WEBHOOK_URL, { + method: 'POST', + headers: N8N_WEBHOOK_SECRET ? { 'X-Api-Key': N8N_WEBHOOK_SECRET } : {}, + }).catch((err) => { + console.error('get-voice-mail: n8n webhook起動失敗', err.message); + }); + res.sendStatus(202); +}); + +app.listen(PORT, () => { + console.log(`get-voice-mail listening on port ${PORT}`); +}); diff --git a/NodeSrv/apps/get-voice-mail/src/triggerAuth.js b/NodeSrv/apps/get-voice-mail/src/triggerAuth.js new file mode 100644 index 00000000..2b533f2f --- /dev/null +++ b/NodeSrv/apps/get-voice-mail/src/triggerAuth.js @@ -0,0 +1,5 @@ +function isTriggerAuthorized(headerValue, expectedSecret) { + return Boolean(expectedSecret) && headerValue === expectedSecret; +} + +module.exports = { isTriggerAuthorized }; diff --git a/NodeSrv/apps/get-voice-mail/test/triggerAuth.test.js b/NodeSrv/apps/get-voice-mail/test/triggerAuth.test.js new file mode 100644 index 00000000..25555d71 --- /dev/null +++ b/NodeSrv/apps/get-voice-mail/test/triggerAuth.test.js @@ -0,0 +1,11 @@ +const { test } = require('node:test'); +const assert = require('node:assert'); +const { isTriggerAuthorized } = require('../src/triggerAuth'); + +test('isTriggerAuthorized returns true only when the header matches a non-empty expected secret', () => { + assert.strictEqual(isTriggerAuthorized('correct', 'correct'), true); + assert.strictEqual(isTriggerAuthorized('wrong', 'correct'), false); + assert.strictEqual(isTriggerAuthorized(undefined, 'correct'), false); + assert.strictEqual(isTriggerAuthorized('anything', undefined), false); + assert.strictEqual(isTriggerAuthorized('anything', ''), false); +});