import { Hono } from 'hono'; import { z } from 'zod'; import { env } from '../../config/env.js'; 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(); const webhookSchema = z.object({ alias: z.string().trim().min(1).max(120), url: z.string().trim().min(1).max(2048), }); const pushSubscriptionSchema = z.object({ endpoint: z.string().trim().url().max(2048), keys: z.object({ p256dh: z.string().trim().min(1).max(512), auth: z.string().trim().min(1).max(512), }), }); 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, alias: row.alias, url: row.url, createdAt: row.created_at, updatedAt: row.updated_at, }; } router.use('*', requireAuth); router.get('/', async (c) => { const user = c.get('user'); 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: webhookResult.rows.map(serializeWebhook), vapidPublicKey: env.vapidPublicKey, webhookMessageSettings: serializeWebhookMessageSettings(settingsResult.rows[0]), }); }); router.post('/webhooks', async (c) => { const body = webhookSchema.safeParse(await c.req.json().catch(() => null)); if (!body.success) { throw badRequest('入力内容を確認してください', body.error.flatten()); } let normalizedUrl; try { normalizedUrl = normalizeHttpsUrl(body.data.url); } catch (error) { throw badRequest(error.message); } const result = await query( `INSERT INTO notification_methods (user_id, notification_type, alias, url) VALUES ($1, 'webhook', $2, $3) RETURNING notification_method_id, alias, url, created_at, updated_at`, [c.get('user').user_id, body.data.alias, normalizedUrl], ); return c.json({ webhook: serializeWebhook(result.rows[0]) }, 201); }); router.patch('/webhooks/:methodId', async (c) => { const methodId = z.string().uuid().safeParse(c.req.param('methodId')); if (!methodId.success) { throw badRequest('通知方法 ID が不正です'); } const body = webhookSchema.safeParse(await c.req.json().catch(() => null)); if (!body.success) { throw badRequest('入力内容を確認してください', body.error.flatten()); } let normalizedUrl; try { normalizedUrl = normalizeHttpsUrl(body.data.url); } catch (error) { throw badRequest(error.message); } const result = await query( `UPDATE notification_methods SET alias = $3, url = $4 WHERE user_id = $1 AND notification_method_id = $2 AND notification_type = 'webhook' RETURNING notification_method_id, alias, url, created_at, updated_at`, [c.get('user').user_id, methodId.data, body.data.alias, normalizedUrl], ); if (!result.rows[0]) { throw notFound('Webhook が見つかりません'); } return c.json({ webhook: serializeWebhook(result.rows[0]) }); }); router.delete('/webhooks/:methodId', async (c) => { const methodId = z.string().uuid().safeParse(c.req.param('methodId')); if (!methodId.success) { throw badRequest('通知方法 ID が不正です'); } const result = await query( `DELETE FROM notification_methods WHERE user_id = $1 AND notification_method_id = $2 AND notification_type = 'webhook' RETURNING notification_method_id`, [c.get('user').user_id, methodId.data], ); if (!result.rows[0]) { throw notFound('Webhook が見つかりません'); } 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) { throw badRequest('購読状態を確認できません', body.error.flatten()); } const endpoint = new URL(body.data.endpoint); if (endpoint.protocol !== 'https:') { throw badRequest('Push endpoint は HTTPS である必要があります'); } const result = await query( `SELECT notification_method_id FROM notification_methods WHERE user_id = $1 AND notification_type = 'push' AND push_endpoint = $2 LIMIT 1`, [c.get('user').user_id, body.data.endpoint], ); return c.json({ registered: Boolean(result.rows[0]) }); }); router.post('/push-subscriptions', async (c) => { const body = pushSubscriptionSchema.safeParse(await c.req.json().catch(() => null)); if (!body.success) { throw badRequest('購読情報を確認してください', body.error.flatten()); } const endpoint = new URL(body.data.endpoint); if (endpoint.protocol !== 'https:') { throw badRequest('Push endpoint は HTTPS である必要があります'); } const user = c.get('user'); await query( `DELETE FROM notification_methods WHERE user_id = $1 AND notification_type = 'push' AND push_endpoint = $2`, [user.user_id, body.data.endpoint], ); const result = await query( `INSERT INTO notification_methods (user_id, notification_type, alias, push_endpoint, push_p256dh, push_auth) VALUES ($1, 'push', 'Browser Push', $2, $3, $4) RETURNING notification_method_id, push_endpoint, created_at, updated_at`, [user.user_id, body.data.endpoint, body.data.keys.p256dh, body.data.keys.auth], ); return c.json( { pushSubscription: { notificationMethodId: result.rows[0].notification_method_id, createdAt: result.rows[0].created_at, updatedAt: result.rows[0].updated_at, }, }, 201, ); }); router.delete('/push-subscriptions', async (c) => { const body = pushSubscriptionStatusSchema.safeParse(await c.req.json().catch(() => null)); if (!body.success) { throw badRequest('購読情報を確認してください', body.error.flatten()); } const endpoint = new URL(body.data.endpoint); if (endpoint.protocol !== 'https:') { throw badRequest('Push endpoint は HTTPS である必要があります'); } const result = await query( `DELETE FROM notification_methods WHERE user_id = $1 AND notification_type = 'push' AND push_endpoint = $2 RETURNING notification_method_id`, [c.get('user').user_id, body.data.endpoint], ); if (!result.rows[0]) { throw notFound('Push 購読情報が見つかりません'); } return c.json({ ok: true }); }); export default router;