Webhookのメッセージをカスタマイズできるように
This commit is contained in:
@@ -115,4 +115,5 @@ CAPTCHA is disabled by default. To enable it, set `CAPTCHA_PROVIDER` to `turnsti
|
|||||||
- `pnpm monitor:once` remains available for manual checks or external schedulers.
|
- `pnpm monitor:once` remains available for manual checks or external schedulers.
|
||||||
- The monitor limits concurrent external certificate checks and records per-site failures without stopping the whole run.
|
- The monitor limits concurrent external certificate checks and records per-site failures without stopping the whole run.
|
||||||
- Webhook URLs and monitored site URLs must be HTTPS and reject localhost/private IPv4 targets.
|
- Webhook URLs and monitored site URLs must be HTTPS and reject localhost/private IPv4 targets.
|
||||||
|
- The account profile timezone is used when formatting user-facing dates such as webhook alert expiry times. Webhook alert messages can be customized per user from the notification methods screen; if unset, CertRemind uses the default template.
|
||||||
- Existing browser Push subscriptions require valid VAPID keys to deliver successfully.
|
- Existing browser Push subscriptions require valid VAPID keys to deliver successfully.
|
||||||
|
|||||||
@@ -99,6 +99,23 @@ CREATE TRIGGER notification_methods_set_updated_at
|
|||||||
BEFORE UPDATE ON notification_methods
|
BEFORE UPDATE ON notification_methods
|
||||||
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS user_notification_settings (
|
||||||
|
user_id uuid PRIMARY KEY REFERENCES users(user_id) ON DELETE CASCADE,
|
||||||
|
webhook_message_template text,
|
||||||
|
timezone text NOT NULL DEFAULT 'Asia/Tokyo',
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE user_notification_settings
|
||||||
|
ADD COLUMN IF NOT EXISTS webhook_message_template text,
|
||||||
|
ADD COLUMN IF NOT EXISTS timezone text NOT NULL DEFAULT 'Asia/Tokyo';
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS user_notification_settings_set_updated_at ON user_notification_settings;
|
||||||
|
CREATE TRIGGER user_notification_settings_set_updated_at
|
||||||
|
BEFORE UPDATE ON user_notification_settings
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS site_alert_conditions (
|
CREATE TABLE IF NOT EXISTS site_alert_conditions (
|
||||||
site_alert_condition_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
site_alert_condition_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
site_id uuid NOT NULL REFERENCES sites(site_id) ON DELETE CASCADE,
|
site_id uuid NOT NULL REFERENCES sites(site_id) ON DELETE CASCADE,
|
||||||
|
|||||||
@@ -42,6 +42,7 @@
|
|||||||
- Webhook 登録 API
|
- Webhook 登録 API
|
||||||
- Webhook 更新 API
|
- Webhook 更新 API
|
||||||
- Webhook 削除 API
|
- Webhook 削除 API
|
||||||
|
- Webhook メッセージ設定 API
|
||||||
- 現在ブラウザの Push 購読状態確認 API
|
- 現在ブラウザの Push 購読状態確認 API
|
||||||
- Push 購読情報登録 API
|
- Push 購読情報登録 API
|
||||||
- Push 購読情報解除 API
|
- Push 購読情報解除 API
|
||||||
@@ -52,6 +53,7 @@
|
|||||||
- 通知条件ごとの証明書期限単位の送信済み管理
|
- 通知条件ごとの証明書期限単位の送信済み管理
|
||||||
- 証明書取得失敗時のアラート履歴作成
|
- 証明書取得失敗時のアラート履歴作成
|
||||||
- Webhook 通知送信処理
|
- Webhook 通知送信処理
|
||||||
|
- ユーザー設定テンプレートによる Webhook 通知本文生成
|
||||||
- Push 通知送信処理
|
- Push 通知送信処理
|
||||||
- `/push-sw.js` の明示的な静的配信
|
- `/push-sw.js` の明示的な静的配信
|
||||||
- 二重送信を防ぐ `dedupe_key` 利用
|
- 二重送信を防ぐ `dedupe_key` 利用
|
||||||
@@ -59,7 +61,7 @@
|
|||||||
- 監視ジョブの一回実行スクリプト
|
- 監視ジョブの一回実行スクリプト
|
||||||
- 1 時間ごとに監視ジョブを実行する Node worker
|
- 1 時間ごとに監視ジョブを実行する Node worker
|
||||||
- アカウント情報取得 API
|
- アカウント情報取得 API
|
||||||
- 表示名更新 API
|
- 表示名・タイムゾーン更新 API
|
||||||
- パスワード更新 API
|
- パスワード更新 API
|
||||||
- パスワード更新時の全セッション無効化
|
- パスワード更新時の全セッション無効化
|
||||||
- TOTP セットアップ API
|
- TOTP セットアップ API
|
||||||
@@ -110,6 +112,7 @@
|
|||||||
- `src/server/modules/alerts/routes.js`
|
- `src/server/modules/alerts/routes.js`
|
||||||
- `src/server/modules/auth/routes.js`
|
- `src/server/modules/auth/routes.js`
|
||||||
- `src/server/modules/notificationMethods/routes.js`
|
- `src/server/modules/notificationMethods/routes.js`
|
||||||
|
- `src/server/modules/notificationMethods/webhookMessageSettings.js`
|
||||||
- `src/server/modules/monitoring/certificate.js`
|
- `src/server/modules/monitoring/certificate.js`
|
||||||
- `src/server/modules/monitoring/monitor.js`
|
- `src/server/modules/monitoring/monitor.js`
|
||||||
- `src/server/modules/monitoring/notifications.js`
|
- `src/server/modules/monitoring/notifications.js`
|
||||||
@@ -143,6 +146,7 @@
|
|||||||
- `sessions`
|
- `sessions`
|
||||||
- `sites`
|
- `sites`
|
||||||
- `notification_methods`
|
- `notification_methods`
|
||||||
|
- `user_notification_settings`
|
||||||
- `site_alert_conditions`
|
- `site_alert_conditions`
|
||||||
- `alert_history`
|
- `alert_history`
|
||||||
|
|
||||||
@@ -187,6 +191,8 @@ GET /api/notification-methods
|
|||||||
POST /api/notification-methods/webhooks
|
POST /api/notification-methods/webhooks
|
||||||
PATCH /api/notification-methods/webhooks/:methodId
|
PATCH /api/notification-methods/webhooks/:methodId
|
||||||
DELETE /api/notification-methods/webhooks/:methodId
|
DELETE /api/notification-methods/webhooks/:methodId
|
||||||
|
PUT /api/notification-methods/webhook-message-settings
|
||||||
|
DELETE /api/notification-methods/webhook-message-settings
|
||||||
POST /api/notification-methods/push-subscription-status
|
POST /api/notification-methods/push-subscription-status
|
||||||
POST /api/notification-methods/push-subscriptions
|
POST /api/notification-methods/push-subscriptions
|
||||||
DELETE /api/notification-methods/push-subscriptions
|
DELETE /api/notification-methods/push-subscriptions
|
||||||
@@ -245,6 +251,7 @@ pnpm monitor:worker
|
|||||||
- 通知方法管理画面
|
- 通知方法管理画面
|
||||||
- Webhook 登録
|
- Webhook 登録
|
||||||
- モーダルでの Webhook 編集
|
- モーダルでの Webhook 編集
|
||||||
|
- モーダルでの Webhook メッセージ編集
|
||||||
- 確認ダイアログ付き Webhook 削除
|
- 確認ダイアログ付き Webhook 削除
|
||||||
- ブラウザ Push 通知の許可状態表示
|
- ブラウザ Push 通知の許可状態表示
|
||||||
- 現在のブラウザの Push 登録状態表示
|
- 現在のブラウザの Push 登録状態表示
|
||||||
@@ -252,6 +259,7 @@ pnpm monitor:worker
|
|||||||
- 現在のブラウザが登録済みの場合の Push 購読解除
|
- 現在のブラウザが登録済みの場合の Push 購読解除
|
||||||
- アカウント設定画面
|
- アカウント設定画面
|
||||||
- 表示名更新
|
- 表示名更新
|
||||||
|
- タイムゾーン設定
|
||||||
- ダイアログでのパスワード更新
|
- ダイアログでのパスワード更新
|
||||||
- ステップ式ポップアップでの 2 段階認証セットアップ
|
- ステップ式ポップアップでの 2 段階認証セットアップ
|
||||||
- 2 段階認証 QR コード表示
|
- 2 段階認証 QR コード表示
|
||||||
@@ -293,6 +301,8 @@ pnpm monitor:worker
|
|||||||
- Webhook URL は HTTPS のみ許可。
|
- Webhook URL は HTTPS のみ許可。
|
||||||
- Webhook URL は `normalizeHttpsUrl` を通し、localhost / private IPv4 / loopback IPv4 を拒否。
|
- Webhook URL は `normalizeHttpsUrl` を通し、localhost / private IPv4 / loopback IPv4 を拒否。
|
||||||
- Webhook 更新・削除はログインユーザーの通知方法のみ対象。
|
- Webhook 更新・削除はログインユーザーの通知方法のみ対象。
|
||||||
|
- Webhook メッセージ設定はログインユーザー単位で保存し、未設定時は既定テンプレートを使用。
|
||||||
|
- タイムゾーンはアカウントのユーザー情報として保存し、未設定時は `Asia/Tokyo` を使用。
|
||||||
- Push endpoint は HTTPS のみ許可。
|
- Push endpoint は HTTPS のみ許可。
|
||||||
- Push 設定画面では登録済みデバイス一覧を返さず、現在ブラウザの登録状態のみ確認。
|
- Push 設定画面では登録済みデバイス一覧を返さず、現在ブラウザの登録状態のみ確認。
|
||||||
- Push 購読解除はログインユーザーの現在ブラウザ endpoint のみ対象。
|
- Push 購読解除はログインユーザーの現在ブラウザ endpoint のみ対象。
|
||||||
|
|||||||
42
src/client/constants/timezones.js
Normal file
42
src/client/constants/timezones.js
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
export const timezoneOptions = [
|
||||||
|
{ label: 'Midway, USA (UTC-11:00)', tz: 'Pacific/Midway' },
|
||||||
|
{ label: 'Honolulu, USA (UTC-10:00)', tz: 'Pacific/Honolulu' },
|
||||||
|
{ label: 'Anchorage, USA (UTC-09:00)', tz: 'America/Anchorage' },
|
||||||
|
{ label: 'Los Angeles, USA (UTC-08:00)', tz: 'America/Los_Angeles' },
|
||||||
|
{ label: 'Denver, USA (UTC-07:00)', tz: 'America/Denver' },
|
||||||
|
{ label: 'Chicago, USA (UTC-06:00)', tz: 'America/Chicago' },
|
||||||
|
{ label: 'New York, USA (UTC-05:00)', tz: 'America/New_York' },
|
||||||
|
{ label: 'Santiago, Chile (UTC-04:00)', tz: 'America/Santiago' },
|
||||||
|
{ label: 'Sao Paulo, Brazil (UTC-03:00)', tz: 'America/Sao_Paulo' },
|
||||||
|
{ label: 'South Georgia, UK (UTC-02:00)', tz: 'Atlantic/South_Georgia' },
|
||||||
|
{ label: 'Azores, Portugal (UTC-01:00)', tz: 'Atlantic/Azores' },
|
||||||
|
{ label: 'London, UK (UTC+00:00)', tz: 'Europe/London' },
|
||||||
|
{ label: 'Paris, France (UTC+01:00)', tz: 'Europe/Paris' },
|
||||||
|
{ label: 'Cairo, Egypt (UTC+02:00)', tz: 'Africa/Cairo' },
|
||||||
|
{ label: 'Moscow, Russia (UTC+03:00)', tz: 'Europe/Moscow' },
|
||||||
|
{ label: 'Dubai, UAE (UTC+04:00)', tz: 'Asia/Dubai' },
|
||||||
|
{ label: 'Karachi, Pakistan (UTC+05:00)', tz: 'Asia/Karachi' },
|
||||||
|
{ label: 'Dhaka, Bangladesh (UTC+06:00)', tz: 'Asia/Dhaka' },
|
||||||
|
{ label: 'Bangkok, Thailand (UTC+07:00)', tz: 'Asia/Bangkok' },
|
||||||
|
{ label: 'Singapore, Singapore (UTC+08:00)', tz: 'Asia/Singapore' },
|
||||||
|
{ label: 'Tokyo, Japan (UTC+09:00)', tz: 'Asia/Tokyo' },
|
||||||
|
{ label: 'Sydney, Australia (UTC+10:00)', tz: 'Australia/Sydney' },
|
||||||
|
{ label: 'Noumea, New Caledonia (UTC+11:00)', tz: 'Pacific/Noumea' },
|
||||||
|
{ label: 'Auckland, New Zealand (UTC+12:00)', tz: 'Pacific/Auckland' },
|
||||||
|
{ label: "Nuku'alofa, Tonga (UTC+13:00)", tz: 'Pacific/Tongatapu' },
|
||||||
|
{ label: 'Kiritimati, Kiribati (UTC+14:00)', tz: 'Pacific/Kiritimati' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function timezoneOptionLabel(timezone) {
|
||||||
|
const option = timezoneOptions.find((item) => item.tz === timezone);
|
||||||
|
return option ? `${option.label} - ${option.tz}` : timezone;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterTimezoneOptions(search) {
|
||||||
|
const query = search.trim().toLowerCase();
|
||||||
|
if (!query) return timezoneOptions;
|
||||||
|
return timezoneOptions.filter(
|
||||||
|
(option) =>
|
||||||
|
option.label.toLowerCase().includes(query) || option.tz.toLowerCase().includes(query),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,11 +1,15 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import * as Dialog from '@radix-ui/react-dialog';
|
import * as Dialog from '@radix-ui/react-dialog';
|
||||||
import { ArrowLeft, KeyRound, ShieldCheck, Trash2, UserRound } from 'lucide-react';
|
import { ArrowLeft, ChevronDown, KeyRound, Search, ShieldCheck, Trash2, UserRound } from 'lucide-react';
|
||||||
import { QRCodeSVG } from 'qrcode.react';
|
import { QRCodeSVG } from 'qrcode.react';
|
||||||
import { request } from '../api/client.js';
|
import { request } from '../api/client.js';
|
||||||
import { ConfirmDialog } from '../components/ConfirmDialog.jsx';
|
import { ConfirmDialog } from '../components/ConfirmDialog.jsx';
|
||||||
import { Field } from '../components/Field.jsx';
|
import { Field } from '../components/Field.jsx';
|
||||||
import { useToast } from '../components/Toast.jsx';
|
import { useToast } from '../components/Toast.jsx';
|
||||||
|
import {
|
||||||
|
filterTimezoneOptions,
|
||||||
|
timezoneOptionLabel,
|
||||||
|
} from '../constants/timezones.js';
|
||||||
|
|
||||||
function requireValue(value, label) {
|
function requireValue(value, label) {
|
||||||
if (!value.trim()) {
|
if (!value.trim()) {
|
||||||
@@ -32,7 +36,9 @@ function validateOtp(value) {
|
|||||||
|
|
||||||
export function AccountView({ onBack, onSignedOut }) {
|
export function AccountView({ onBack, onSignedOut }) {
|
||||||
const [account, setAccount] = useState(null);
|
const [account, setAccount] = useState(null);
|
||||||
const [profile, setProfile] = useState({ displayName: '' });
|
const [profile, setProfile] = useState({ displayName: '', timezone: 'Asia/Tokyo' });
|
||||||
|
const [timezoneDropdownOpen, setTimezoneDropdownOpen] = useState(false);
|
||||||
|
const [timezoneSearch, setTimezoneSearch] = useState('');
|
||||||
const [passwordForm, setPasswordForm] = useState({ currentPassword: '', newPassword: '' });
|
const [passwordForm, setPasswordForm] = useState({ currentPassword: '', newPassword: '' });
|
||||||
const [totpSetup, setTotpSetup] = useState(null);
|
const [totpSetup, setTotpSetup] = useState(null);
|
||||||
const [totpStep, setTotpStep] = useState(1);
|
const [totpStep, setTotpStep] = useState(1);
|
||||||
@@ -46,7 +52,7 @@ export function AccountView({ onBack, onSignedOut }) {
|
|||||||
async function loadAccount() {
|
async function loadAccount() {
|
||||||
const data = await request('/api/account');
|
const data = await request('/api/account');
|
||||||
setAccount(data.account);
|
setAccount(data.account);
|
||||||
setProfile({ displayName: data.account.displayName });
|
setProfile({ displayName: data.account.displayName, timezone: data.account.timezone });
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -63,10 +69,13 @@ export function AccountView({ onBack, onSignedOut }) {
|
|||||||
}
|
}
|
||||||
const data = await request('/api/account/profile', {
|
const data = await request('/api/account/profile', {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
body: JSON.stringify({ displayName: profile.displayName.trim() }),
|
body: JSON.stringify({
|
||||||
|
displayName: profile.displayName.trim(),
|
||||||
|
timezone: profile.timezone,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
setAccount(data.account);
|
setAccount(data.account);
|
||||||
showToast({ type: 'success', message: '表示名を更新しました' });
|
showToast({ type: 'success', message: 'ユーザー情報を更新しました' });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showToast({ type: 'error', message: err.message });
|
showToast({ type: 'error', message: err.message });
|
||||||
} finally {
|
} finally {
|
||||||
@@ -171,6 +180,8 @@ export function AccountView({ onBack, onSignedOut }) {
|
|||||||
return <div className="loading">CertRemind</div>;
|
return <div className="loading">CertRemind</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const filteredTimezoneOptions = filterTimezoneOptions(timezoneSearch);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="app-shell">
|
<main className="app-shell">
|
||||||
<header className="topbar">
|
<header className="topbar">
|
||||||
@@ -193,11 +204,62 @@ export function AccountView({ onBack, onSignedOut }) {
|
|||||||
<Field label="表示名">
|
<Field label="表示名">
|
||||||
<input
|
<input
|
||||||
value={profile.displayName}
|
value={profile.displayName}
|
||||||
onChange={(event) => setProfile({ displayName: event.target.value })}
|
onChange={(event) => setProfile({ ...profile, displayName: event.target.value })}
|
||||||
maxLength="80"
|
maxLength="80"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
<Field label="タイムゾーン">
|
||||||
|
<div className="timezone-select">
|
||||||
|
<button
|
||||||
|
className="timezone-select-trigger"
|
||||||
|
type="button"
|
||||||
|
onClick={() => setTimezoneDropdownOpen((open) => !open)}
|
||||||
|
aria-expanded={timezoneDropdownOpen}
|
||||||
|
>
|
||||||
|
<span>{timezoneOptionLabel(profile.timezone)}</span>
|
||||||
|
<ChevronDown aria-hidden="true" size={18} />
|
||||||
|
</button>
|
||||||
|
{timezoneDropdownOpen ? (
|
||||||
|
<div className="timezone-select-menu">
|
||||||
|
<label className="timezone-search">
|
||||||
|
<Search aria-hidden="true" size={16} />
|
||||||
|
<input
|
||||||
|
value={timezoneSearch}
|
||||||
|
onChange={(event) => setTimezoneSearch(event.target.value)}
|
||||||
|
placeholder="都市名、国名、UTC、タイムゾーンで検索"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="timezone-options">
|
||||||
|
{filteredTimezoneOptions.length === 0 ? (
|
||||||
|
<div className="timezone-empty">一致するタイムゾーンがありません。</div>
|
||||||
|
) : (
|
||||||
|
filteredTimezoneOptions.map((option) => (
|
||||||
|
<button
|
||||||
|
className={
|
||||||
|
option.tz === profile.timezone
|
||||||
|
? 'timezone-option selected'
|
||||||
|
: 'timezone-option'
|
||||||
|
}
|
||||||
|
type="button"
|
||||||
|
key={option.tz}
|
||||||
|
onClick={() => {
|
||||||
|
setProfile({ ...profile, timezone: option.tz });
|
||||||
|
setTimezoneDropdownOpen(false);
|
||||||
|
setTimezoneSearch('');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<strong>{option.label}</strong>
|
||||||
|
<span>{option.tz}</span>
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
<button className="primary fit-button" disabled={busy}>
|
<button className="primary fit-button" disabled={busy}>
|
||||||
保存
|
保存
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,6 +1,17 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import * as Dialog from '@radix-ui/react-dialog';
|
import * as Dialog from '@radix-ui/react-dialog';
|
||||||
import { ArrowLeft, BellOff, BellRing, Link, Pencil, Plus, Trash2 } from 'lucide-react';
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
BellOff,
|
||||||
|
BellRing,
|
||||||
|
Link,
|
||||||
|
MessageSquareText,
|
||||||
|
Pencil,
|
||||||
|
Plus,
|
||||||
|
RotateCcw,
|
||||||
|
Save,
|
||||||
|
Trash2,
|
||||||
|
} from 'lucide-react';
|
||||||
import { request } from '../api/client.js';
|
import { request } from '../api/client.js';
|
||||||
import { ConfirmDialog } from '../components/ConfirmDialog.jsx';
|
import { ConfirmDialog } from '../components/ConfirmDialog.jsx';
|
||||||
import { Field } from '../components/Field.jsx';
|
import { Field } from '../components/Field.jsx';
|
||||||
@@ -22,6 +33,24 @@ function validateWebhookForm(form) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function validateWebhookMessageForm(form) {
|
||||||
|
if (!form.webhookMessageTemplate.trim()) {
|
||||||
|
throw new Error('メッセージを入力してください');
|
||||||
|
}
|
||||||
|
if (form.webhookMessageTemplate.trim().length > 2000) {
|
||||||
|
throw new Error('メッセージは2000文字以内で入力してください');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const variableDescriptions = {
|
||||||
|
expiryDate: '有効期限(日付) yyyy/mm/dd',
|
||||||
|
expiryDateTime: '有効期限(日付+日時) yyyy/mm/dd hh:mm:ss',
|
||||||
|
expiryTime: '有効期限(時間) hh:mm:ss',
|
||||||
|
siteName: 'サイトエイリアス名',
|
||||||
|
siteDomain: 'サイトドメイン',
|
||||||
|
condTiming: '通知タイミング',
|
||||||
|
};
|
||||||
|
|
||||||
function urlBase64ToUint8Array(base64String) {
|
function urlBase64ToUint8Array(base64String) {
|
||||||
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
|
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
|
||||||
const base64 = `${base64String}${padding}`.replaceAll('-', '+').replaceAll('_', '/');
|
const base64 = `${base64String}${padding}`.replaceAll('-', '+').replaceAll('_', '/');
|
||||||
@@ -55,11 +84,16 @@ async function getPushRegistration() {
|
|||||||
export function NotificationMethodsView({ onBack }) {
|
export function NotificationMethodsView({ onBack }) {
|
||||||
const [webhooks, setWebhooks] = useState([]);
|
const [webhooks, setWebhooks] = useState([]);
|
||||||
const [vapidPublicKey, setVapidPublicKey] = useState('');
|
const [vapidPublicKey, setVapidPublicKey] = useState('');
|
||||||
|
const [webhookMessageSettings, setWebhookMessageSettings] = useState(null);
|
||||||
const [currentPushStatus, setCurrentPushStatus] = useState('unchecked');
|
const [currentPushStatus, setCurrentPushStatus] = useState('unchecked');
|
||||||
const [form, setForm] = useState({ alias: '', url: '' });
|
const [form, setForm] = useState({ alias: '', url: '' });
|
||||||
const [editingWebhook, setEditingWebhook] = useState(null);
|
const [editingWebhook, setEditingWebhook] = useState(null);
|
||||||
const [editForm, setEditForm] = useState({ alias: '', url: '' });
|
const [editForm, setEditForm] = useState({ alias: '', url: '' });
|
||||||
const [editDialogOpen, setEditDialogOpen] = useState(false);
|
const [editDialogOpen, setEditDialogOpen] = useState(false);
|
||||||
|
const [messageDialogOpen, setMessageDialogOpen] = useState(false);
|
||||||
|
const [messageForm, setMessageForm] = useState({
|
||||||
|
webhookMessageTemplate: '',
|
||||||
|
});
|
||||||
const [permission, setPermission] = useState(
|
const [permission, setPermission] = useState(
|
||||||
typeof Notification === 'undefined' ? 'unsupported' : Notification.permission,
|
typeof Notification === 'undefined' ? 'unsupported' : Notification.permission,
|
||||||
);
|
);
|
||||||
@@ -112,6 +146,7 @@ export function NotificationMethodsView({ onBack }) {
|
|||||||
const data = await request('/api/notification-methods');
|
const data = await request('/api/notification-methods');
|
||||||
setWebhooks(data.webhooks);
|
setWebhooks(data.webhooks);
|
||||||
setVapidPublicKey(data.vapidPublicKey);
|
setVapidPublicKey(data.vapidPublicKey);
|
||||||
|
setWebhookMessageSettings(data.webhookMessageSettings);
|
||||||
await refreshCurrentPushStatus(data.vapidPublicKey);
|
await refreshCurrentPushStatus(data.vapidPublicKey);
|
||||||
}, [refreshCurrentPushStatus]);
|
}, [refreshCurrentPushStatus]);
|
||||||
|
|
||||||
@@ -144,6 +179,13 @@ export function NotificationMethodsView({ onBack }) {
|
|||||||
setEditDialogOpen(true);
|
setEditDialogOpen(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openMessageDialog() {
|
||||||
|
setMessageForm({
|
||||||
|
webhookMessageTemplate: webhookMessageSettings?.webhookMessageTemplate ?? '',
|
||||||
|
});
|
||||||
|
setMessageDialogOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
function handleEditDialogOpenChange(open) {
|
function handleEditDialogOpenChange(open) {
|
||||||
setEditDialogOpen(open);
|
setEditDialogOpen(open);
|
||||||
if (!open) {
|
if (!open) {
|
||||||
@@ -173,6 +215,41 @@ export function NotificationMethodsView({ onBack }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function submitWebhookMessageSettings(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
validateWebhookMessageForm(messageForm);
|
||||||
|
await request('/api/notification-methods/webhook-message-settings', {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({
|
||||||
|
webhookMessageTemplate: messageForm.webhookMessageTemplate.trim(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
showToast({ type: 'success', message: 'Webhookメッセージを更新しました' });
|
||||||
|
setMessageDialogOpen(false);
|
||||||
|
await loadMethods();
|
||||||
|
} catch (err) {
|
||||||
|
showToast({ type: 'error', message: err.message });
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resetWebhookMessageSettings() {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await request('/api/notification-methods/webhook-message-settings', { method: 'DELETE' });
|
||||||
|
showToast({ type: 'success', message: 'Webhookメッセージをデフォルトに戻しました' });
|
||||||
|
setMessageDialogOpen(false);
|
||||||
|
await loadMethods();
|
||||||
|
} catch (err) {
|
||||||
|
showToast({ type: 'error', message: err.message });
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function deleteWebhook(methodId) {
|
async function deleteWebhook(methodId) {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
@@ -270,12 +347,18 @@ export function NotificationMethodsView({ onBack }) {
|
|||||||
|
|
||||||
<section className="workspace notification-layout">
|
<section className="workspace notification-layout">
|
||||||
<form className="panel" onSubmit={submitWebhook}>
|
<form className="panel" onSubmit={submitWebhook}>
|
||||||
<div className="panel-heading">
|
<div className="panel-heading-with-actions">
|
||||||
<Link aria-hidden="true" size={20} />
|
<div className="panel-heading">
|
||||||
<div>
|
<Link aria-hidden="true" size={20} />
|
||||||
<h2>Webhook</h2>
|
<div>
|
||||||
<p>Slack互換Webhookとして送信します。</p>
|
<h2>Webhook</h2>
|
||||||
|
<p>Slack互換Webhookとして送信します。</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<button className="secondary fit-button" type="button" onClick={openMessageDialog}>
|
||||||
|
<MessageSquareText aria-hidden="true" size={18} />
|
||||||
|
メッセージの編集
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="webhook-form">
|
<div className="webhook-form">
|
||||||
@@ -304,6 +387,64 @@ export function NotificationMethodsView({ onBack }) {
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<Dialog.Root open={messageDialogOpen} onOpenChange={setMessageDialogOpen}>
|
||||||
|
<Dialog.Portal>
|
||||||
|
<Dialog.Overlay className="dialog-overlay" />
|
||||||
|
<Dialog.Content className="dialog-content webhook-message-dialog">
|
||||||
|
<Dialog.Title className="dialog-title">Webhookメッセージを編集</Dialog.Title>
|
||||||
|
<Dialog.Description className="dialog-description">
|
||||||
|
Webhookの本文に使うテンプレートを設定します。日時のタイムゾーンはアカウントのユーザー情報で設定します。
|
||||||
|
</Dialog.Description>
|
||||||
|
<form className="dialog-form" onSubmit={submitWebhookMessageSettings}>
|
||||||
|
<Field label="メッセージ">
|
||||||
|
<textarea
|
||||||
|
value={messageForm.webhookMessageTemplate}
|
||||||
|
onChange={(event) =>
|
||||||
|
setMessageForm({
|
||||||
|
...messageForm,
|
||||||
|
webhookMessageTemplate: event.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
maxLength="2000"
|
||||||
|
rows="6"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<div className="template-variable-list">
|
||||||
|
{(webhookMessageSettings?.availableVariables ?? []).map((variable) => (
|
||||||
|
<div className="template-variable" key={variable}>
|
||||||
|
<code>{`{{${variable}}}`}</code>
|
||||||
|
<span>{variableDescriptions[variable]}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="dialog-actions split-actions">
|
||||||
|
<button
|
||||||
|
className="secondary danger-text"
|
||||||
|
type="button"
|
||||||
|
onClick={resetWebhookMessageSettings}
|
||||||
|
disabled={busy || webhookMessageSettings?.usesDefault}
|
||||||
|
>
|
||||||
|
<RotateCcw aria-hidden="true" size={18} />
|
||||||
|
デフォルトに戻す
|
||||||
|
</button>
|
||||||
|
<div className="dialog-actions">
|
||||||
|
<Dialog.Close asChild>
|
||||||
|
<button className="secondary" type="button">
|
||||||
|
キャンセル
|
||||||
|
</button>
|
||||||
|
</Dialog.Close>
|
||||||
|
<button className="primary" disabled={busy}>
|
||||||
|
<Save aria-hidden="true" size={18} />
|
||||||
|
保存
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Portal>
|
||||||
|
</Dialog.Root>
|
||||||
|
|
||||||
<Dialog.Root open={editDialogOpen} onOpenChange={handleEditDialogOpenChange}>
|
<Dialog.Root open={editDialogOpen} onOpenChange={handleEditDialogOpenChange}>
|
||||||
<Dialog.Portal>
|
<Dialog.Portal>
|
||||||
<Dialog.Overlay className="dialog-overlay" />
|
<Dialog.Overlay className="dialog-overlay" />
|
||||||
|
|||||||
@@ -23,7 +23,8 @@ body {
|
|||||||
|
|
||||||
button,
|
button,
|
||||||
input,
|
input,
|
||||||
select {
|
select,
|
||||||
|
textarea {
|
||||||
font: inherit;
|
font: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,7 +133,8 @@ h1 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
input,
|
input,
|
||||||
select {
|
select,
|
||||||
|
textarea {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 42px;
|
min-height: 42px;
|
||||||
border: 1px solid #c9d4d0;
|
border: 1px solid #c9d4d0;
|
||||||
@@ -142,12 +144,18 @@ select {
|
|||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
resize: vertical;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
input:focus {
|
input:focus {
|
||||||
outline: 3px solid rgba(39, 103, 97, 0.18);
|
outline: 3px solid rgba(39, 103, 97, 0.18);
|
||||||
border-color: #276761;
|
border-color: #276761;
|
||||||
}
|
}
|
||||||
|
|
||||||
select:focus {
|
select:focus,
|
||||||
|
textarea:focus {
|
||||||
outline: 3px solid rgba(39, 103, 97, 0.18);
|
outline: 3px solid rgba(39, 103, 97, 0.18);
|
||||||
border-color: #276761;
|
border-color: #276761;
|
||||||
}
|
}
|
||||||
@@ -607,6 +615,13 @@ select:focus {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.panel-heading-with-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
.panel-heading svg {
|
.panel-heading svg {
|
||||||
color: #276761;
|
color: #276761;
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
@@ -767,6 +782,150 @@ select:focus {
|
|||||||
width: fit-content;
|
width: fit-content;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.webhook-message-dialog {
|
||||||
|
width: min(640px, calc(100% - 32px));
|
||||||
|
}
|
||||||
|
|
||||||
|
.timezone-select {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timezone-select-trigger {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 42px;
|
||||||
|
border: 1px solid #c9d4d0;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 9px 11px;
|
||||||
|
background: #ffffff;
|
||||||
|
color: #17201d;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timezone-select-trigger:hover,
|
||||||
|
.timezone-select-trigger[aria-expanded='true'] {
|
||||||
|
border-color: #276761;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timezone-select-trigger span {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timezone-select-trigger svg {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
color: #40504b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timezone-select-menu {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 55;
|
||||||
|
top: calc(100% + 6px);
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
border: 1px solid #c9d4d0;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px;
|
||||||
|
background: #ffffff;
|
||||||
|
box-shadow: 0 18px 42px rgba(26, 41, 37, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.timezone-search {
|
||||||
|
min-height: 38px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr);
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
border: 1px solid #d9e1de;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0 10px;
|
||||||
|
color: #5a6a65;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timezone-search input {
|
||||||
|
min-height: 36px;
|
||||||
|
border: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timezone-search input:focus {
|
||||||
|
outline: 0;
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timezone-options {
|
||||||
|
max-height: 240px;
|
||||||
|
overflow: auto;
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timezone-option {
|
||||||
|
min-height: 46px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
background: transparent;
|
||||||
|
color: #17201d;
|
||||||
|
display: grid;
|
||||||
|
gap: 3px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timezone-option:hover,
|
||||||
|
.timezone-option.selected {
|
||||||
|
background: #eef3f1;
|
||||||
|
color: #276761;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timezone-option span,
|
||||||
|
.timezone-empty {
|
||||||
|
color: #5a6a65;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timezone-empty {
|
||||||
|
padding: 12px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-variable-list {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-variable {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
border: 1px solid #d9e1de;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 10px;
|
||||||
|
background: #f8fbfa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-variable code {
|
||||||
|
color: #276761;
|
||||||
|
font-weight: 800;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-variable span {
|
||||||
|
color: #5a6a65;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.split-actions {
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
.totp-box {
|
.totp-box {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
@@ -1083,10 +1242,20 @@ select:focus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.webhook-form,
|
.webhook-form,
|
||||||
|
.template-variable-list,
|
||||||
.method-row {
|
.method-row {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.panel-heading-with-actions {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.split-actions {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
.alert-filter,
|
.alert-filter,
|
||||||
.alert-row {
|
.alert-row {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
|||||||
@@ -5,11 +5,13 @@ import { z } from 'zod';
|
|||||||
import { pool, query } from '../../db/pool.js';
|
import { pool, query } from '../../db/pool.js';
|
||||||
import { destroySession, requireAuth } from '../../middleware/auth.js';
|
import { destroySession, requireAuth } from '../../middleware/auth.js';
|
||||||
import { badRequest, unauthorized } from '../../utils/httpErrors.js';
|
import { badRequest, unauthorized } from '../../utils/httpErrors.js';
|
||||||
|
import { DEFAULT_TIMEZONE, validateTimezone } from '../notificationMethods/webhookMessageSettings.js';
|
||||||
|
|
||||||
const router = new Hono();
|
const router = new Hono();
|
||||||
|
|
||||||
const profileSchema = z.object({
|
const profileSchema = z.object({
|
||||||
displayName: z.string().trim().min(1).max(80),
|
displayName: z.string().trim().min(1).max(80),
|
||||||
|
timezone: z.string().trim().min(1).max(80),
|
||||||
});
|
});
|
||||||
|
|
||||||
const passwordSchema = z.object({
|
const passwordSchema = z.object({
|
||||||
@@ -42,15 +44,22 @@ function publicAccount(row) {
|
|||||||
userId: row.user_id,
|
userId: row.user_id,
|
||||||
username: row.username,
|
username: row.username,
|
||||||
displayName: row.display_name,
|
displayName: row.display_name,
|
||||||
|
timezone: row.timezone ?? DEFAULT_TIMEZONE,
|
||||||
totpEnabled: Boolean(row.otp_secret),
|
totpEnabled: Boolean(row.otp_secret),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getAccount(userId) {
|
async function getAccount(userId) {
|
||||||
const result = await query(
|
const result = await query(
|
||||||
`SELECT u.user_id, u.username, u.display_name, u.password_hash, t.otp_secret
|
`SELECT u.user_id,
|
||||||
|
u.username,
|
||||||
|
u.display_name,
|
||||||
|
u.password_hash,
|
||||||
|
t.otp_secret,
|
||||||
|
ns.timezone
|
||||||
FROM users u
|
FROM users u
|
||||||
LEFT JOIN user_totp t ON t.user_id = u.user_id
|
LEFT JOIN user_totp t ON t.user_id = u.user_id
|
||||||
|
LEFT JOIN user_notification_settings ns ON ns.user_id = u.user_id
|
||||||
WHERE u.user_id = $1`,
|
WHERE u.user_id = $1`,
|
||||||
[userId],
|
[userId],
|
||||||
);
|
);
|
||||||
@@ -82,20 +91,49 @@ router.patch('/profile', async (c) => {
|
|||||||
throw badRequest('入力内容を確認してください', body.error.flatten());
|
throw badRequest('入力内容を確認してください', body.error.flatten());
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await query(
|
let timezone;
|
||||||
`WITH updated AS (
|
try {
|
||||||
UPDATE users
|
timezone = validateTimezone(body.data.timezone);
|
||||||
SET display_name = $2
|
} catch (error) {
|
||||||
WHERE user_id = $1
|
throw badRequest(error.message);
|
||||||
RETURNING user_id, username, display_name
|
}
|
||||||
)
|
|
||||||
SELECT u.user_id, u.username, u.display_name, t.otp_secret
|
|
||||||
FROM updated u
|
|
||||||
LEFT JOIN user_totp t ON t.user_id = u.user_id`,
|
|
||||||
[c.get('user').user_id, body.data.displayName],
|
|
||||||
);
|
|
||||||
|
|
||||||
return c.json({ account: publicAccount(result.rows[0]) });
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
const result = await client.query(
|
||||||
|
`WITH updated AS (
|
||||||
|
UPDATE users
|
||||||
|
SET display_name = $2
|
||||||
|
WHERE user_id = $1
|
||||||
|
RETURNING user_id, username, display_name
|
||||||
|
),
|
||||||
|
notification_settings AS (
|
||||||
|
INSERT INTO user_notification_settings (user_id, timezone)
|
||||||
|
VALUES ($1, $3)
|
||||||
|
ON CONFLICT (user_id)
|
||||||
|
DO UPDATE SET timezone = EXCLUDED.timezone
|
||||||
|
RETURNING user_id, timezone
|
||||||
|
)
|
||||||
|
SELECT u.user_id,
|
||||||
|
u.username,
|
||||||
|
u.display_name,
|
||||||
|
t.otp_secret,
|
||||||
|
ns.timezone
|
||||||
|
FROM updated u
|
||||||
|
LEFT JOIN user_totp t ON t.user_id = u.user_id
|
||||||
|
LEFT JOIN notification_settings ns ON ns.user_id = u.user_id`,
|
||||||
|
[c.get('user').user_id, body.data.displayName, timezone],
|
||||||
|
);
|
||||||
|
await client.query('COMMIT');
|
||||||
|
|
||||||
|
return c.json({ account: publicAccount(result.rows[0]) });
|
||||||
|
} catch (error) {
|
||||||
|
await client.query('ROLLBACK').catch(() => {});
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.patch('/password', async (c) => {
|
router.patch('/password', async (c) => {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { pool } from '../../db/pool.js';
|
import { pool } from '../../db/pool.js';
|
||||||
|
import { renderWebhookExpiryMessage } from '../notificationMethods/webhookMessageSettings.js';
|
||||||
import { getCertificateExpiry } from './certificate.js';
|
import { getCertificateExpiry } from './certificate.js';
|
||||||
import { deliverNotifications } from './notifications.js';
|
import { deliverNotifications } from './notifications.js';
|
||||||
|
|
||||||
@@ -98,6 +99,16 @@ async function loadPushSubscriptions(client, userId) {
|
|||||||
return result.rows;
|
return result.rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadUserNotificationSettings(client, userId) {
|
||||||
|
const result = await client.query(
|
||||||
|
`SELECT webhook_message_template, timezone
|
||||||
|
FROM user_notification_settings
|
||||||
|
WHERE user_id = $1`,
|
||||||
|
[userId],
|
||||||
|
);
|
||||||
|
return result.rows[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
async function createAlert(
|
async function createAlert(
|
||||||
client,
|
client,
|
||||||
{ site, alertType, message, deliveryChannels, deliveryResult, dedupeKey },
|
{ site, alertType, message, deliveryChannels, deliveryResult, dedupeKey },
|
||||||
@@ -181,6 +192,15 @@ async function processMatchingCondition(client, site, condition, certificate) {
|
|||||||
|
|
||||||
const message = buildExpiryMessage(site, condition, certificate);
|
const message = buildExpiryMessage(site, condition, certificate);
|
||||||
const webhooks = await loadWebhooks(client, site.user_id, condition.webhook_method_ids);
|
const webhooks = await loadWebhooks(client, site.user_id, condition.webhook_method_ids);
|
||||||
|
const webhookMessageBody =
|
||||||
|
webhooks.length > 0
|
||||||
|
? renderWebhookExpiryMessage({
|
||||||
|
site,
|
||||||
|
condition,
|
||||||
|
certificate,
|
||||||
|
settings: await loadUserNotificationSettings(client, site.user_id),
|
||||||
|
})
|
||||||
|
: message.body;
|
||||||
const pushSubscriptions = condition.push_enabled
|
const pushSubscriptions = condition.push_enabled
|
||||||
? await loadPushSubscriptions(client, site.user_id)
|
? await loadPushSubscriptions(client, site.user_id)
|
||||||
: [];
|
: [];
|
||||||
@@ -189,6 +209,7 @@ async function processMatchingCondition(client, site, condition, certificate) {
|
|||||||
pushSubscriptions,
|
pushSubscriptions,
|
||||||
pushEnabled: condition.push_enabled,
|
pushEnabled: condition.push_enabled,
|
||||||
message,
|
message,
|
||||||
|
webhookMessageBody,
|
||||||
});
|
});
|
||||||
const dedupeKey = `certificate-expiring:${site.site_id}:${condition.threshold_hours}:${certificate.expiresAt.toISOString()}`;
|
const dedupeKey = `certificate-expiring:${site.site_id}:${condition.threshold_hours}:${certificate.expiresAt.toISOString()}`;
|
||||||
const alert = await createAlert(client, {
|
const alert = await createAlert(client, {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ function configureWebPush() {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function sendWebhookNotification(webhook, message) {
|
export async function sendWebhookNotification(webhook, message, webhookMessageBody = message.body) {
|
||||||
const response = await fetch(webhook.url, {
|
const response = await fetch(webhook.url, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'content-type': 'application/json' },
|
headers: { 'content-type': 'application/json' },
|
||||||
@@ -20,7 +20,7 @@ export async function sendWebhookNotification(webhook, message) {
|
|||||||
attachments: [
|
attachments: [
|
||||||
{
|
{
|
||||||
color: message.severity === 'error' ? 'danger' : 'warning',
|
color: message.severity === 'error' ? 'danger' : 'warning',
|
||||||
text: message.body,
|
text: webhookMessageBody,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
@@ -51,7 +51,13 @@ export async function sendPushNotification(subscription, message) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deliverNotifications({ webhooks, pushSubscriptions, pushEnabled, message }) {
|
export async function deliverNotifications({
|
||||||
|
webhooks,
|
||||||
|
pushSubscriptions,
|
||||||
|
pushEnabled,
|
||||||
|
message,
|
||||||
|
webhookMessageBody = message.body,
|
||||||
|
}) {
|
||||||
const results = {
|
const results = {
|
||||||
app: { ok: true },
|
app: { ok: true },
|
||||||
webhooks: [],
|
webhooks: [],
|
||||||
@@ -60,7 +66,7 @@ export async function deliverNotifications({ webhooks, pushSubscriptions, pushEn
|
|||||||
|
|
||||||
for (const webhook of webhooks) {
|
for (const webhook of webhooks) {
|
||||||
try {
|
try {
|
||||||
await sendWebhookNotification(webhook, message);
|
await sendWebhookNotification(webhook, message, webhookMessageBody);
|
||||||
results.webhooks.push({ notificationMethodId: webhook.notification_method_id, ok: true });
|
results.webhooks.push({ notificationMethodId: webhook.notification_method_id, ok: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
results.webhooks.push({
|
results.webhooks.push({
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ import { query } from '../../db/pool.js';
|
|||||||
import { requireAuth } from '../../middleware/auth.js';
|
import { requireAuth } from '../../middleware/auth.js';
|
||||||
import { badRequest, notFound } from '../../utils/httpErrors.js';
|
import { badRequest, notFound } from '../../utils/httpErrors.js';
|
||||||
import { normalizeHttpsUrl } from '../../utils/urlPolicy.js';
|
import { normalizeHttpsUrl } from '../../utils/urlPolicy.js';
|
||||||
|
import {
|
||||||
|
serializeWebhookMessageSettings,
|
||||||
|
validateWebhookMessageTemplate,
|
||||||
|
} from './webhookMessageSettings.js';
|
||||||
|
|
||||||
const router = new Hono();
|
const router = new Hono();
|
||||||
|
|
||||||
@@ -25,6 +29,10 @@ const pushSubscriptionStatusSchema = z.object({
|
|||||||
endpoint: z.string().trim().url().max(2048),
|
endpoint: z.string().trim().url().max(2048),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const webhookMessageSettingsSchema = z.object({
|
||||||
|
webhookMessageTemplate: z.string().max(2000),
|
||||||
|
});
|
||||||
|
|
||||||
function serializeWebhook(row) {
|
function serializeWebhook(row) {
|
||||||
return {
|
return {
|
||||||
notificationMethodId: row.notification_method_id,
|
notificationMethodId: row.notification_method_id,
|
||||||
@@ -39,22 +47,31 @@ router.use('*', requireAuth);
|
|||||||
|
|
||||||
router.get('/', async (c) => {
|
router.get('/', async (c) => {
|
||||||
const user = c.get('user');
|
const user = c.get('user');
|
||||||
const result = await query(
|
const [webhookResult, settingsResult] = await Promise.all([
|
||||||
`SELECT notification_method_id,
|
query(
|
||||||
alias,
|
`SELECT notification_method_id,
|
||||||
url,
|
alias,
|
||||||
created_at,
|
url,
|
||||||
updated_at
|
created_at,
|
||||||
FROM notification_methods
|
updated_at
|
||||||
WHERE user_id = $1
|
FROM notification_methods
|
||||||
AND notification_type = 'webhook'
|
WHERE user_id = $1
|
||||||
ORDER BY created_at DESC`,
|
AND notification_type = 'webhook'
|
||||||
[user.user_id],
|
ORDER BY created_at DESC`,
|
||||||
);
|
[user.user_id],
|
||||||
|
),
|
||||||
|
query(
|
||||||
|
`SELECT webhook_message_template, timezone
|
||||||
|
FROM user_notification_settings
|
||||||
|
WHERE user_id = $1`,
|
||||||
|
[user.user_id],
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
return c.json({
|
return c.json({
|
||||||
webhooks: result.rows.map(serializeWebhook),
|
webhooks: webhookResult.rows.map(serializeWebhook),
|
||||||
vapidPublicKey: env.vapidPublicKey,
|
vapidPublicKey: env.vapidPublicKey,
|
||||||
|
webhookMessageSettings: serializeWebhookMessageSettings(settingsResult.rows[0]),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -139,6 +156,42 @@ router.delete('/webhooks/:methodId', async (c) => {
|
|||||||
return c.json({ ok: true });
|
return c.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.put('/webhook-message-settings', async (c) => {
|
||||||
|
const body = webhookMessageSettingsSchema.safeParse(await c.req.json().catch(() => null));
|
||||||
|
if (!body.success) {
|
||||||
|
throw badRequest('入力内容を確認してください', body.error.flatten());
|
||||||
|
}
|
||||||
|
|
||||||
|
let webhookMessageTemplate;
|
||||||
|
try {
|
||||||
|
webhookMessageTemplate = validateWebhookMessageTemplate(body.data.webhookMessageTemplate);
|
||||||
|
} catch (error) {
|
||||||
|
throw badRequest(error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await query(
|
||||||
|
`INSERT INTO user_notification_settings (user_id, webhook_message_template)
|
||||||
|
VALUES ($1, $2)
|
||||||
|
ON CONFLICT (user_id)
|
||||||
|
DO UPDATE SET webhook_message_template = EXCLUDED.webhook_message_template
|
||||||
|
RETURNING webhook_message_template, timezone`,
|
||||||
|
[c.get('user').user_id, webhookMessageTemplate],
|
||||||
|
);
|
||||||
|
|
||||||
|
return c.json({ webhookMessageSettings: serializeWebhookMessageSettings(result.rows[0]) });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete('/webhook-message-settings', async (c) => {
|
||||||
|
const result = await query(
|
||||||
|
`UPDATE user_notification_settings
|
||||||
|
SET webhook_message_template = NULL
|
||||||
|
WHERE user_id = $1
|
||||||
|
RETURNING webhook_message_template, timezone`,
|
||||||
|
[c.get('user').user_id],
|
||||||
|
);
|
||||||
|
return c.json({ webhookMessageSettings: serializeWebhookMessageSettings(result.rows[0]) });
|
||||||
|
});
|
||||||
|
|
||||||
router.post('/push-subscription-status', async (c) => {
|
router.post('/push-subscription-status', async (c) => {
|
||||||
const body = pushSubscriptionStatusSchema.safeParse(await c.req.json().catch(() => null));
|
const body = pushSubscriptionStatusSchema.safeParse(await c.req.json().catch(() => null));
|
||||||
if (!body.success) {
|
if (!body.success) {
|
||||||
|
|||||||
117
src/server/modules/notificationMethods/webhookMessageSettings.js
Normal file
117
src/server/modules/notificationMethods/webhookMessageSettings.js
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
export const DEFAULT_TIMEZONE = 'Asia/Tokyo';
|
||||||
|
|
||||||
|
export const DEFAULT_WEBHOOK_MESSAGE_TEMPLATE = `{{siteDomain}} の有効期限が {{condTiming}} になりました。
|
||||||
|
{{expiryDateTime}} に期限切れになります。`;
|
||||||
|
|
||||||
|
export const WEBHOOK_MESSAGE_VARIABLES = [
|
||||||
|
'expiryDate',
|
||||||
|
'expiryDateTime',
|
||||||
|
'expiryTime',
|
||||||
|
'siteName',
|
||||||
|
'siteDomain',
|
||||||
|
'condTiming',
|
||||||
|
];
|
||||||
|
|
||||||
|
const VARIABLE_PATTERN = /\{\{\s*([A-Za-z][A-Za-z0-9_]*)\s*\}\}/g;
|
||||||
|
|
||||||
|
export function normalizeWebhookMessageTemplate(template) {
|
||||||
|
return String(template ?? '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateWebhookMessageTemplate(template) {
|
||||||
|
const normalized = normalizeWebhookMessageTemplate(template);
|
||||||
|
if (!normalized) {
|
||||||
|
throw new Error('メッセージを入力してください');
|
||||||
|
}
|
||||||
|
if (normalized.length > 2000) {
|
||||||
|
throw new Error('メッセージは2000文字以内で入力してください');
|
||||||
|
}
|
||||||
|
|
||||||
|
const unsupportedVariables = new Set();
|
||||||
|
for (const match of normalized.matchAll(VARIABLE_PATTERN)) {
|
||||||
|
if (!WEBHOOK_MESSAGE_VARIABLES.includes(match[1])) {
|
||||||
|
unsupportedVariables.add(match[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (unsupportedVariables.size > 0) {
|
||||||
|
throw new Error(`未対応のテンプレート変数があります: ${[...unsupportedVariables].join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateTimezone(timezone) {
|
||||||
|
const normalized = String(timezone ?? '').trim();
|
||||||
|
if (!normalized) {
|
||||||
|
throw new Error('タイムゾーンを入力してください');
|
||||||
|
}
|
||||||
|
if (normalized.length > 80) {
|
||||||
|
throw new Error('タイムゾーンは80文字以内で入力してください');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
new Intl.DateTimeFormat('en-US', { timeZone: normalized }).format(new Date());
|
||||||
|
} catch {
|
||||||
|
throw new Error('タイムゾーンが不正です');
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeWebhookMessageSettings(row) {
|
||||||
|
const hasCustomTemplate = Boolean(row?.webhook_message_template?.trim());
|
||||||
|
return {
|
||||||
|
webhookMessageTemplate: row?.webhook_message_template ?? DEFAULT_WEBHOOK_MESSAGE_TEMPLATE,
|
||||||
|
timezone: row?.timezone ?? DEFAULT_TIMEZONE,
|
||||||
|
usesDefault: !hasCustomTemplate,
|
||||||
|
defaultWebhookMessageTemplate: DEFAULT_WEBHOOK_MESSAGE_TEMPLATE,
|
||||||
|
availableVariables: WEBHOOK_MESSAGE_VARIABLES,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function dateParts(date, timezone) {
|
||||||
|
const parts = new Intl.DateTimeFormat('en-US', {
|
||||||
|
timeZone: timezone,
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
hourCycle: 'h23',
|
||||||
|
}).formatToParts(date);
|
||||||
|
|
||||||
|
return Object.fromEntries(parts.filter((part) => part.type !== 'literal').map((part) => [part.type, part.value]));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatConditionTiming(thresholdHours) {
|
||||||
|
if (thresholdHours % 168 === 0) return `${thresholdHours / 168}週間前`;
|
||||||
|
if (thresholdHours % 24 === 0) return `${thresholdHours / 24}日前`;
|
||||||
|
return `${thresholdHours}時間前`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderWebhookExpiryMessage({ site, condition, certificate, settings }) {
|
||||||
|
const template =
|
||||||
|
settings?.webhook_message_template?.trim() || DEFAULT_WEBHOOK_MESSAGE_TEMPLATE;
|
||||||
|
const timezone = validateTimezone(settings?.timezone || DEFAULT_TIMEZONE);
|
||||||
|
const parts = dateParts(certificate.expiresAt, timezone);
|
||||||
|
const siteDomain = (() => {
|
||||||
|
try {
|
||||||
|
return new URL(site.url).hostname;
|
||||||
|
} catch {
|
||||||
|
return site.url;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
const values = {
|
||||||
|
expiryDate: `${parts.year}/${parts.month}/${parts.day}`,
|
||||||
|
expiryDateTime: `${parts.year}/${parts.month}/${parts.day} ${parts.hour}:${parts.minute}:${parts.second}`,
|
||||||
|
expiryTime: `${parts.hour}:${parts.minute}:${parts.second}`,
|
||||||
|
siteName: site.alias,
|
||||||
|
siteDomain,
|
||||||
|
condTiming: formatConditionTiming(condition.threshold_hours),
|
||||||
|
};
|
||||||
|
|
||||||
|
return template.replace(VARIABLE_PATTERN, (match, name) => values[name] ?? match);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { createApp } from '../src/server/app.js';
|
import { createApp } from '../src/server/app.js';
|
||||||
import { query } from '../src/server/db/pool.js';
|
import { pool, query } from '../src/server/db/pool.js';
|
||||||
import { getCertificateExpiry } from '../src/server/modules/monitoring/certificate.js';
|
import { getCertificateExpiry } from '../src/server/modules/monitoring/certificate.js';
|
||||||
|
|
||||||
vi.mock('../src/server/db/pool.js', () => ({
|
vi.mock('../src/server/db/pool.js', () => ({
|
||||||
@@ -50,6 +50,7 @@ function mockAuthenticatedUser() {
|
|||||||
describe('API security boundaries', () => {
|
describe('API security boundaries', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
query.mockReset();
|
query.mockReset();
|
||||||
|
pool.connect.mockReset();
|
||||||
getCertificateExpiry.mockReset();
|
getCertificateExpiry.mockReset();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -161,6 +162,10 @@ describe('API security boundaries', () => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
}).mockImplementationOnce(async (sql, params) => {
|
||||||
|
expect(sql).toContain('FROM user_notification_settings');
|
||||||
|
expect(params).toEqual([USER_ID]);
|
||||||
|
return { rows: [] };
|
||||||
});
|
});
|
||||||
|
|
||||||
const app = createApp();
|
const app = createApp();
|
||||||
@@ -174,6 +179,168 @@ describe('API security boundaries', () => {
|
|||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
expect(data.webhooks).toHaveLength(1);
|
expect(data.webhooks).toHaveLength(1);
|
||||||
expect(data).not.toHaveProperty('pushSubscriptions');
|
expect(data).not.toHaveProperty('pushSubscriptions');
|
||||||
|
expect(data.webhookMessageSettings).toMatchObject({
|
||||||
|
timezone: 'Asia/Tokyo',
|
||||||
|
usesDefault: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires a CSRF token when saving webhook message settings', async () => {
|
||||||
|
const app = createApp();
|
||||||
|
|
||||||
|
const response = await app.request('/api/notification-methods/webhook-message-settings', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
webhookMessageTemplate: '{{siteDomain}}',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(403);
|
||||||
|
expect(query).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects invalid webhook message templates before persistence', async () => {
|
||||||
|
mockAuthenticatedUser();
|
||||||
|
const app = createApp();
|
||||||
|
|
||||||
|
const unsupportedVariable = await app.request('/api/notification-methods/webhook-message-settings', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Cookie: csrfCookie(),
|
||||||
|
'x-csrf-token': 'csrf-token',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
webhookMessageTemplate: '{{unknownValue}}',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(unsupportedVariable.status).toBe(400);
|
||||||
|
expect(query).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects invalid account timezone before persistence', async () => {
|
||||||
|
mockAuthenticatedUser();
|
||||||
|
const app = createApp();
|
||||||
|
|
||||||
|
const invalidTimezone = await app.request('/api/account/profile', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Cookie: csrfCookie(),
|
||||||
|
'x-csrf-token': 'csrf-token',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
displayName: 'Alice',
|
||||||
|
timezone: 'Mars/Base',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(invalidTimezone.status).toBe(400);
|
||||||
|
expect(query).toHaveBeenCalledTimes(1);
|
||||||
|
expect(pool.connect).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('saves account timezone for the session user only', async () => {
|
||||||
|
const client = {
|
||||||
|
query: vi.fn(async (sql, params) => {
|
||||||
|
if (sql === 'BEGIN' || sql === 'COMMIT') return { rows: [] };
|
||||||
|
expect(sql).toContain('INSERT INTO user_notification_settings');
|
||||||
|
expect(params).toEqual([USER_ID, 'Alice', 'America/New_York']);
|
||||||
|
return {
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
user_id: USER_ID,
|
||||||
|
username: 'alice',
|
||||||
|
display_name: 'Alice',
|
||||||
|
otp_secret: null,
|
||||||
|
timezone: 'America/New_York',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
release: vi.fn(),
|
||||||
|
};
|
||||||
|
pool.connect.mockResolvedValue(client);
|
||||||
|
mockAuthenticatedUser();
|
||||||
|
|
||||||
|
const app = createApp();
|
||||||
|
const response = await app.request('/api/account/profile', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Cookie: csrfCookie(),
|
||||||
|
'x-csrf-token': 'csrf-token',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
displayName: 'Alice',
|
||||||
|
timezone: 'America/New_York',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
await expect(response.json()).resolves.toMatchObject({
|
||||||
|
account: { timezone: 'America/New_York' },
|
||||||
|
});
|
||||||
|
expect(client.query).toHaveBeenCalledWith('COMMIT');
|
||||||
|
expect(client.release).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('saves and deletes webhook message settings for the session user only', async () => {
|
||||||
|
mockAuthenticatedUser();
|
||||||
|
query
|
||||||
|
.mockImplementationOnce(query.getMockImplementation())
|
||||||
|
.mockImplementationOnce(async (sql, params) => {
|
||||||
|
expect(sql).toContain('INSERT INTO user_notification_settings');
|
||||||
|
expect(params).toEqual([USER_ID, '{{siteDomain}}']);
|
||||||
|
return {
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
webhook_message_template: '{{siteDomain}}',
|
||||||
|
timezone: 'Asia/Tokyo',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const app = createApp();
|
||||||
|
const saveResponse = await app.request('/api/notification-methods/webhook-message-settings', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Cookie: csrfCookie(),
|
||||||
|
'x-csrf-token': 'csrf-token',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
webhookMessageTemplate: '{{siteDomain}}',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(saveResponse.status).toBe(200);
|
||||||
|
|
||||||
|
query.mockReset();
|
||||||
|
mockAuthenticatedUser();
|
||||||
|
query
|
||||||
|
.mockImplementationOnce(query.getMockImplementation())
|
||||||
|
.mockImplementationOnce(async (sql, params) => {
|
||||||
|
expect(sql).toContain('UPDATE user_notification_settings');
|
||||||
|
expect(sql).toContain('webhook_message_template = NULL');
|
||||||
|
expect(params).toEqual([USER_ID]);
|
||||||
|
return { rows: [{ webhook_message_template: null, timezone: 'Asia/Tokyo' }] };
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteResponse = await app.request('/api/notification-methods/webhook-message-settings', {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
Cookie: csrfCookie(),
|
||||||
|
'x-csrf-token': 'csrf-token',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(deleteResponse.status).toBe(200);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('reports the current browser push subscription as registered for the session user', async () => {
|
it('reports the current browser push subscription as registered for the session user', async () => {
|
||||||
|
|||||||
@@ -102,6 +102,11 @@ describe('certificate monitoring', () => {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (sql.includes('FROM user_notification_settings')) {
|
||||||
|
expect(params).toEqual([USER_ID]);
|
||||||
|
return { rows: [] };
|
||||||
|
}
|
||||||
|
|
||||||
if (sql.includes('FROM notification_methods') && sql.includes("notification_type = 'push'")) {
|
if (sql.includes('FROM notification_methods') && sql.includes("notification_type = 'push'")) {
|
||||||
expect(params).toEqual([USER_ID]);
|
expect(params).toEqual([USER_ID]);
|
||||||
return {
|
return {
|
||||||
@@ -138,7 +143,12 @@ describe('certificate monitoring', () => {
|
|||||||
|
|
||||||
expect(pool.connect).toHaveBeenCalledOnce();
|
expect(pool.connect).toHaveBeenCalledOnce();
|
||||||
expect(getCertificateExpiry).toHaveBeenCalledWith('https://example.com/');
|
expect(getCertificateExpiry).toHaveBeenCalledWith('https://example.com/');
|
||||||
expect(deliverNotifications).toHaveBeenCalledOnce();
|
expect(deliverNotifications).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
webhookMessageBody:
|
||||||
|
'example.com の有効期限が 12時間前 になりました。\n2026/05/25 20:30:00 に期限切れになります。',
|
||||||
|
}),
|
||||||
|
);
|
||||||
expect(mocks.client.release).toHaveBeenCalledOnce();
|
expect(mocks.client.release).toHaveBeenCalledOnce();
|
||||||
expect(result).toMatchObject({
|
expect(result).toMatchObject({
|
||||||
checkedSites: 1,
|
checkedSites: 1,
|
||||||
@@ -490,4 +500,95 @@ describe('certificate monitoring', () => {
|
|||||||
results: [{ siteId: SITE_ID, ok: true, alertsCreated: 1 }],
|
results: [{ siteId: SITE_ID, ok: true, alertsCreated: 1 }],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('renders a custom webhook message with the configured timezone and timing labels', async () => {
|
||||||
|
vi.setSystemTime(new Date('2026-12-24T05:36:07.000Z'));
|
||||||
|
const issuer = 'C = US, O = Example CA, CN = Example Root';
|
||||||
|
const issuedAt = new Date('2026-01-01T00:00:00.000Z');
|
||||||
|
const expiresAt = new Date('2026-12-31T05:06:07.000Z');
|
||||||
|
|
||||||
|
getCertificateExpiry.mockResolvedValue({
|
||||||
|
issuer,
|
||||||
|
issuedAt,
|
||||||
|
expiresAt,
|
||||||
|
hoursUntilExpiry: 168,
|
||||||
|
});
|
||||||
|
deliverNotifications.mockResolvedValue({
|
||||||
|
webhook: [],
|
||||||
|
push: { ok: false, skipped: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
mocks.client.query.mockImplementation(async (sql, params) => {
|
||||||
|
if (sql.includes('FROM sites s') && sql.includes('LEFT JOIN site_alert_conditions')) {
|
||||||
|
return {
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
site_id: SITE_ID,
|
||||||
|
user_id: USER_ID,
|
||||||
|
url: 'https://example.com/',
|
||||||
|
alias: 'Example Site',
|
||||||
|
conditions: [
|
||||||
|
{
|
||||||
|
site_alert_condition_id: CONDITION_ID,
|
||||||
|
threshold_hours: 168,
|
||||||
|
webhook_method_ids: [WEBHOOK_ID],
|
||||||
|
push_enabled: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sql.includes('SET certificate_issuer')) {
|
||||||
|
expect(params).toEqual([SITE_ID, issuer, issuedAt, expiresAt]);
|
||||||
|
return { rows: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sql.includes('FROM notification_methods') && sql.includes("notification_type = 'webhook'")) {
|
||||||
|
return {
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
notification_method_id: WEBHOOK_ID,
|
||||||
|
alias: 'Deploy hook',
|
||||||
|
url: 'https://hooks.example.com/',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sql.includes('FROM user_notification_settings')) {
|
||||||
|
expect(params).toEqual([USER_ID]);
|
||||||
|
return {
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
webhook_message_template:
|
||||||
|
'{{siteName}}/{{siteDomain}}/{{condTiming}}/{{expiryDate}}/{{expiryTime}}/{{expiryDateTime}}',
|
||||||
|
timezone: 'America/New_York',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sql.includes('INSERT INTO alert_history')) {
|
||||||
|
return { rows: [{ alert_id: '77777777-7777-4777-8777-777777777777' }] };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sql.includes('UPDATE site_alert_conditions')) {
|
||||||
|
expect(params).toEqual([CONDITION_ID, expiresAt]);
|
||||||
|
return { rows: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Unexpected query: ${sql}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
await runCertificateMonitoring({ concurrency: 1 });
|
||||||
|
|
||||||
|
expect(deliverNotifications).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
webhookMessageBody:
|
||||||
|
'Example Site/example.com/1週間前/2026/12/31/00:06:07/2026/12/31 00:06:07',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user