287 lines
8.4 KiB
JavaScript
287 lines
8.4 KiB
JavaScript
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;
|