Webhookのメッセージをカスタマイズできるように

This commit is contained in:
CyberRex
2026-05-27 10:59:58 +09:00
parent 2a4050d442
commit 38acbd35bb
14 changed files with 994 additions and 49 deletions
+42
View 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),
);
}
+68 -6
View File
@@ -1,11 +1,15 @@
import { useEffect, useState } from 'react';
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 { request } from '../api/client.js';
import { ConfirmDialog } from '../components/ConfirmDialog.jsx';
import { Field } from '../components/Field.jsx';
import { useToast } from '../components/Toast.jsx';
import {
filterTimezoneOptions,
timezoneOptionLabel,
} from '../constants/timezones.js';
function requireValue(value, label) {
if (!value.trim()) {
@@ -32,7 +36,9 @@ function validateOtp(value) {
export function AccountView({ onBack, onSignedOut }) {
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 [totpSetup, setTotpSetup] = useState(null);
const [totpStep, setTotpStep] = useState(1);
@@ -46,7 +52,7 @@ export function AccountView({ onBack, onSignedOut }) {
async function loadAccount() {
const data = await request('/api/account');
setAccount(data.account);
setProfile({ displayName: data.account.displayName });
setProfile({ displayName: data.account.displayName, timezone: data.account.timezone });
}
useEffect(() => {
@@ -63,10 +69,13 @@ export function AccountView({ onBack, onSignedOut }) {
}
const data = await request('/api/account/profile', {
method: 'PATCH',
body: JSON.stringify({ displayName: profile.displayName.trim() }),
body: JSON.stringify({
displayName: profile.displayName.trim(),
timezone: profile.timezone,
}),
});
setAccount(data.account);
showToast({ type: 'success', message: '表示名を更新しました' });
showToast({ type: 'success', message: 'ユーザー情報を更新しました' });
} catch (err) {
showToast({ type: 'error', message: err.message });
} finally {
@@ -171,6 +180,8 @@ export function AccountView({ onBack, onSignedOut }) {
return <div className="loading">CertRemind</div>;
}
const filteredTimezoneOptions = filterTimezoneOptions(timezoneSearch);
return (
<main className="app-shell">
<header className="topbar">
@@ -193,11 +204,62 @@ export function AccountView({ onBack, onSignedOut }) {
<Field label="表示名">
<input
value={profile.displayName}
onChange={(event) => setProfile({ displayName: event.target.value })}
onChange={(event) => setProfile({ ...profile, displayName: event.target.value })}
maxLength="80"
required
/>
</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>
+147 -6
View File
@@ -1,6 +1,17 @@
import { useCallback, useEffect, useState } from 'react';
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 { ConfirmDialog } from '../components/ConfirmDialog.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) {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
const base64 = `${base64String}${padding}`.replaceAll('-', '+').replaceAll('_', '/');
@@ -55,11 +84,16 @@ async function getPushRegistration() {
export function NotificationMethodsView({ onBack }) {
const [webhooks, setWebhooks] = useState([]);
const [vapidPublicKey, setVapidPublicKey] = useState('');
const [webhookMessageSettings, setWebhookMessageSettings] = useState(null);
const [currentPushStatus, setCurrentPushStatus] = useState('unchecked');
const [form, setForm] = useState({ alias: '', url: '' });
const [editingWebhook, setEditingWebhook] = useState(null);
const [editForm, setEditForm] = useState({ alias: '', url: '' });
const [editDialogOpen, setEditDialogOpen] = useState(false);
const [messageDialogOpen, setMessageDialogOpen] = useState(false);
const [messageForm, setMessageForm] = useState({
webhookMessageTemplate: '',
});
const [permission, setPermission] = useState(
typeof Notification === 'undefined' ? 'unsupported' : Notification.permission,
);
@@ -112,6 +146,7 @@ export function NotificationMethodsView({ onBack }) {
const data = await request('/api/notification-methods');
setWebhooks(data.webhooks);
setVapidPublicKey(data.vapidPublicKey);
setWebhookMessageSettings(data.webhookMessageSettings);
await refreshCurrentPushStatus(data.vapidPublicKey);
}, [refreshCurrentPushStatus]);
@@ -144,6 +179,13 @@ export function NotificationMethodsView({ onBack }) {
setEditDialogOpen(true);
}
function openMessageDialog() {
setMessageForm({
webhookMessageTemplate: webhookMessageSettings?.webhookMessageTemplate ?? '',
});
setMessageDialogOpen(true);
}
function handleEditDialogOpenChange(open) {
setEditDialogOpen(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) {
setBusy(true);
try {
@@ -270,12 +347,18 @@ export function NotificationMethodsView({ onBack }) {
<section className="workspace notification-layout">
<form className="panel" onSubmit={submitWebhook}>
<div className="panel-heading">
<Link aria-hidden="true" size={20} />
<div>
<h2>Webhook</h2>
<p>Slack互換Webhookとして送信します</p>
<div className="panel-heading-with-actions">
<div className="panel-heading">
<Link aria-hidden="true" size={20} />
<div>
<h2>Webhook</h2>
<p>Slack互換Webhookとして送信します</p>
</div>
</div>
<button className="secondary fit-button" type="button" onClick={openMessageDialog}>
<MessageSquareText aria-hidden="true" size={18} />
メッセージの編集
</button>
</div>
<div className="webhook-form">
@@ -304,6 +387,64 @@ export function NotificationMethodsView({ onBack }) {
</div>
</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.Portal>
<Dialog.Overlay className="dialog-overlay" />
+172 -3
View File
@@ -23,7 +23,8 @@ body {
button,
input,
select {
select,
textarea {
font: inherit;
}
@@ -132,7 +133,8 @@ h1 {
}
input,
select {
select,
textarea {
width: 100%;
min-height: 42px;
border: 1px solid #c9d4d0;
@@ -142,12 +144,18 @@ select {
background: #ffffff;
}
textarea {
resize: vertical;
line-height: 1.5;
}
input:focus {
outline: 3px solid rgba(39, 103, 97, 0.18);
border-color: #276761;
}
select:focus {
select:focus,
textarea:focus {
outline: 3px solid rgba(39, 103, 97, 0.18);
border-color: #276761;
}
@@ -607,6 +615,13 @@ select:focus {
align-items: center;
}
.panel-heading-with-actions {
display: flex;
gap: 12px;
align-items: center;
justify-content: space-between;
}
.panel-heading svg {
color: #276761;
flex: 0 0 auto;
@@ -767,6 +782,150 @@ select:focus {
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 {
display: grid;
gap: 12px;
@@ -1083,10 +1242,20 @@ select:focus {
}
.webhook-form,
.template-variable-list,
.method-row {
grid-template-columns: 1fr;
}
.panel-heading-with-actions {
align-items: flex-start;
flex-direction: column;
}
.split-actions {
justify-content: flex-start;
}
.alert-filter,
.alert-row {
grid-template-columns: 1fr;
+52 -14
View File
@@ -5,11 +5,13 @@ import { z } from 'zod';
import { pool, query } from '../../db/pool.js';
import { destroySession, requireAuth } from '../../middleware/auth.js';
import { badRequest, unauthorized } from '../../utils/httpErrors.js';
import { DEFAULT_TIMEZONE, validateTimezone } from '../notificationMethods/webhookMessageSettings.js';
const router = new Hono();
const profileSchema = z.object({
displayName: z.string().trim().min(1).max(80),
timezone: z.string().trim().min(1).max(80),
});
const passwordSchema = z.object({
@@ -42,15 +44,22 @@ function publicAccount(row) {
userId: row.user_id,
username: row.username,
displayName: row.display_name,
timezone: row.timezone ?? DEFAULT_TIMEZONE,
totpEnabled: Boolean(row.otp_secret),
};
}
async function getAccount(userId) {
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
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`,
[userId],
);
@@ -82,20 +91,49 @@ router.patch('/profile', async (c) => {
throw badRequest('入力内容を確認してください', body.error.flatten());
}
const result = await query(
`WITH updated AS (
UPDATE users
SET display_name = $2
WHERE user_id = $1
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],
);
let timezone;
try {
timezone = validateTimezone(body.data.timezone);
} catch (error) {
throw badRequest(error.message);
}
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) => {
+21
View File
@@ -1,4 +1,5 @@
import { pool } from '../../db/pool.js';
import { renderWebhookExpiryMessage } from '../notificationMethods/webhookMessageSettings.js';
import { getCertificateExpiry } from './certificate.js';
import { deliverNotifications } from './notifications.js';
@@ -98,6 +99,16 @@ async function loadPushSubscriptions(client, userId) {
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(
client,
{ site, alertType, message, deliveryChannels, deliveryResult, dedupeKey },
@@ -181,6 +192,15 @@ async function processMatchingCondition(client, site, condition, certificate) {
const message = buildExpiryMessage(site, condition, certificate);
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
? await loadPushSubscriptions(client, site.user_id)
: [];
@@ -189,6 +209,7 @@ async function processMatchingCondition(client, site, condition, certificate) {
pushSubscriptions,
pushEnabled: condition.push_enabled,
message,
webhookMessageBody,
});
const dedupeKey = `certificate-expiring:${site.site_id}:${condition.threshold_hours}:${certificate.expiresAt.toISOString()}`;
const alert = await createAlert(client, {
+10 -4
View File
@@ -11,7 +11,7 @@ function configureWebPush() {
return true;
}
export async function sendWebhookNotification(webhook, message) {
export async function sendWebhookNotification(webhook, message, webhookMessageBody = message.body) {
const response = await fetch(webhook.url, {
method: 'POST',
headers: { 'content-type': 'application/json' },
@@ -20,7 +20,7 @@ export async function sendWebhookNotification(webhook, message) {
attachments: [
{
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 = {
app: { ok: true },
webhooks: [],
@@ -60,7 +66,7 @@ export async function deliverNotifications({ webhooks, pushSubscriptions, pushEn
for (const webhook of webhooks) {
try {
await sendWebhookNotification(webhook, message);
await sendWebhookNotification(webhook, message, webhookMessageBody);
results.webhooks.push({ notificationMethodId: webhook.notification_method_id, ok: true });
} catch (error) {
results.webhooks.push({
@@ -5,6 +5,10 @@ import { query } from '../../db/pool.js';
import { requireAuth } from '../../middleware/auth.js';
import { badRequest, notFound } from '../../utils/httpErrors.js';
import { normalizeHttpsUrl } from '../../utils/urlPolicy.js';
import {
serializeWebhookMessageSettings,
validateWebhookMessageTemplate,
} from './webhookMessageSettings.js';
const router = new Hono();
@@ -25,6 +29,10 @@ const pushSubscriptionStatusSchema = z.object({
endpoint: z.string().trim().url().max(2048),
});
const webhookMessageSettingsSchema = z.object({
webhookMessageTemplate: z.string().max(2000),
});
function serializeWebhook(row) {
return {
notificationMethodId: row.notification_method_id,
@@ -39,22 +47,31 @@ router.use('*', requireAuth);
router.get('/', async (c) => {
const user = c.get('user');
const result = await query(
`SELECT notification_method_id,
alias,
url,
created_at,
updated_at
FROM notification_methods
WHERE user_id = $1
AND notification_type = 'webhook'
ORDER BY created_at DESC`,
[user.user_id],
);
const [webhookResult, settingsResult] = await Promise.all([
query(
`SELECT notification_method_id,
alias,
url,
created_at,
updated_at
FROM notification_methods
WHERE user_id = $1
AND notification_type = 'webhook'
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({
webhooks: result.rows.map(serializeWebhook),
webhooks: webhookResult.rows.map(serializeWebhook),
vapidPublicKey: env.vapidPublicKey,
webhookMessageSettings: serializeWebhookMessageSettings(settingsResult.rows[0]),
});
});
@@ -139,6 +156,42 @@ router.delete('/webhooks/:methodId', async (c) => {
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) => {
const body = pushSubscriptionStatusSchema.safeParse(await c.req.json().catch(() => null));
if (!body.success) {
@@ -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);
}