292 lines
10 KiB
TypeScript
292 lines
10 KiB
TypeScript
import { type Application, type Request, type Response, type NextFunction } from 'express';
|
|
import express from 'express';
|
|
import { logger } from '../logger.js';
|
|
import type { Repository, NotifyEventType, NotificationPrefsUpdate } from '../db/repository.js';
|
|
import type { VapidKeyStore } from '../vapid-store.js';
|
|
import type { PushService } from '../push-service.js';
|
|
|
|
/**
|
|
* `/api/notifications/*` routes for Web Push V2.
|
|
* Spec: docs/superpowers/specs/2026-05-28-browser-notifications-v2-webpush.md.
|
|
*
|
|
* When `pushService` is null (push.enabled === false), all POST/DELETE/test
|
|
* endpoints return 503; read endpoints still work so the UI can show
|
|
* "管理者により無効化されています".
|
|
*/
|
|
|
|
type AuthedUser = { id: string; role?: string };
|
|
|
|
function getUser(req: Request): AuthedUser | undefined {
|
|
return (req as unknown as { user?: AuthedUser }).user;
|
|
}
|
|
|
|
function requireUser(req: Request, res: Response): AuthedUser | null {
|
|
const user = getUser(req);
|
|
if (!user) {
|
|
res.status(401).json({ error: 'auth required' });
|
|
return null;
|
|
}
|
|
return user;
|
|
}
|
|
|
|
// ── Per-user in-memory sliding-window rate limiter ─────────────────────
|
|
// Maps `${key}:${userId}` → array of recent request timestamps (ms).
|
|
// Window is fixed at 1 hour; sufficient for low-frequency notification ops.
|
|
const rateBuckets = new Map<string, number[]>();
|
|
const ONE_HOUR_MS = 60 * 60 * 1000;
|
|
|
|
function rateLimit(key: string, maxPerHour: number) {
|
|
return (req: Request, res: Response, next: NextFunction): void => {
|
|
const user = getUser(req);
|
|
if (!user) {
|
|
res.status(401).json({ error: 'auth required' });
|
|
return;
|
|
}
|
|
const bucketKey = `${key}:${user.id}`;
|
|
const now = Date.now();
|
|
const cutoff = now - ONE_HOUR_MS;
|
|
const bucket = (rateBuckets.get(bucketKey) ?? []).filter(t => t > cutoff);
|
|
if (bucket.length >= maxPerHour) {
|
|
res.status(429).json({
|
|
error: 'rate limit exceeded',
|
|
retryAfter: Math.ceil((bucket[0]! + ONE_HOUR_MS - now) / 1000),
|
|
});
|
|
return;
|
|
}
|
|
bucket.push(now);
|
|
rateBuckets.set(bucketKey, bucket);
|
|
next();
|
|
};
|
|
}
|
|
|
|
/** Test hook — only used by unit tests. */
|
|
export function resetRateLimitsForTest(): void {
|
|
rateBuckets.clear();
|
|
}
|
|
|
|
// ── Input validation ──────────────────────────────────────────────────
|
|
|
|
function validateSubscriptionInput(body: unknown): {
|
|
endpoint: string;
|
|
p256dh: string;
|
|
auth: string;
|
|
userAgent?: string;
|
|
} | { error: string } {
|
|
if (!body || typeof body !== 'object') return { error: 'body required' };
|
|
const b = body as Record<string, unknown>;
|
|
if (typeof b.endpoint !== 'string') return { error: 'endpoint required' };
|
|
if (!b.endpoint.startsWith('https://')) return { error: 'endpoint must be https' };
|
|
if (b.endpoint.length > 2048) return { error: 'endpoint too long' };
|
|
if (typeof b.p256dh !== 'string' || b.p256dh.length === 0 || b.p256dh.length > 200) {
|
|
return { error: 'p256dh must be 1..200 chars' };
|
|
}
|
|
if (typeof b.auth !== 'string' || b.auth.length === 0 || b.auth.length > 200) {
|
|
return { error: 'auth must be 1..200 chars' };
|
|
}
|
|
const ua = typeof b.userAgent === 'string' ? b.userAgent.slice(0, 200) : undefined;
|
|
return { endpoint: b.endpoint, p256dh: b.p256dh, auth: b.auth, ...(ua ? { userAgent: ua } : {}) };
|
|
}
|
|
|
|
function validatePrefsInput(body: unknown): NotificationPrefsUpdate | { error: string } {
|
|
if (!body || typeof body !== 'object') return { error: 'body required' };
|
|
const b = body as Record<string, unknown>;
|
|
const update: NotificationPrefsUpdate = {};
|
|
if (b.enabled !== undefined) {
|
|
if (typeof b.enabled !== 'boolean') return { error: 'enabled must be boolean' };
|
|
update.enabled = b.enabled;
|
|
}
|
|
if (b.events !== undefined) {
|
|
if (!b.events || typeof b.events !== 'object') return { error: 'events must be object' };
|
|
const events = b.events as Record<string, unknown>;
|
|
const eventUpdate: Partial<Record<NotifyEventType, boolean>> = {};
|
|
for (const key of ['running', 'succeeded', 'failed', 'waiting_human'] as const) {
|
|
if (events[key] !== undefined) {
|
|
if (typeof events[key] !== 'boolean') return { error: `events.${key} must be boolean` };
|
|
eventUpdate[key] = events[key] as boolean;
|
|
}
|
|
}
|
|
update.events = eventUpdate as Record<NotifyEventType, boolean>;
|
|
}
|
|
if (b.includeDetails !== undefined) {
|
|
if (typeof b.includeDetails !== 'boolean') return { error: 'includeDetails must be boolean' };
|
|
update.includeDetails = b.includeDetails;
|
|
}
|
|
return update;
|
|
}
|
|
|
|
// ── Public DTOs (do NOT leak p256dh / auth / privateKey / etc.) ────────
|
|
|
|
function toPublicSubscription(sub: {
|
|
id: string;
|
|
endpoint: string;
|
|
userAgent: string | null;
|
|
createdAt: string;
|
|
lastSuccessAt: string | null;
|
|
lastFailureAt: string | null;
|
|
failureCount: number;
|
|
}) {
|
|
return {
|
|
id: sub.id,
|
|
// Truncate endpoint to scheme + host for UI display; full URL is sensitive.
|
|
endpointHost: (() => {
|
|
try { return new URL(sub.endpoint).host; } catch { return 'unknown'; }
|
|
})(),
|
|
userAgent: sub.userAgent,
|
|
createdAt: sub.createdAt,
|
|
lastSuccessAt: sub.lastSuccessAt,
|
|
lastFailureAt: sub.lastFailureAt,
|
|
failureCount: sub.failureCount,
|
|
};
|
|
}
|
|
|
|
// ── Mount ──────────────────────────────────────────────────────────────
|
|
|
|
export interface NotificationsApiDeps {
|
|
repo: Repository;
|
|
pushService: PushService | null;
|
|
vapidStore: VapidKeyStore | null;
|
|
/** Plugged in by server.ts when auth is active; identity transform otherwise. */
|
|
requireAuth: (req: Request, res: Response, next: NextFunction) => void;
|
|
}
|
|
|
|
export function mountNotificationsApi(app: Application, deps: NotificationsApiDeps): void {
|
|
const { repo, pushService, vapidStore, requireAuth } = deps;
|
|
const json = express.json({ limit: '64kb' });
|
|
|
|
// GET /vapid-public-key — always 200 when push is enabled; required before subscribe.
|
|
app.get('/api/notifications/vapid-public-key', requireAuth, (req, res) => {
|
|
const user = requireUser(req, res); if (!user) return;
|
|
if (!pushService || !vapidStore) {
|
|
res.status(503).json({ error: 'push disabled' });
|
|
return;
|
|
}
|
|
const k = vapidStore.getCurrent();
|
|
res.json({ publicKey: k.publicKey, keyId: k.keyId });
|
|
});
|
|
|
|
// GET /subscriptions — caller's own devices.
|
|
app.get('/api/notifications/subscriptions', requireAuth, (req, res) => {
|
|
const user = requireUser(req, res); if (!user) return;
|
|
const subs = repo.listPushSubscriptionsForUser(user.id);
|
|
res.json({ subscriptions: subs.map(toPublicSubscription) });
|
|
});
|
|
|
|
// POST /subscriptions — register/upsert (endpoint UNIQUE moves ownership).
|
|
app.post(
|
|
'/api/notifications/subscriptions',
|
|
requireAuth,
|
|
json,
|
|
rateLimit('push:subscribe', 10),
|
|
(req, res) => {
|
|
const user = requireUser(req, res); if (!user) return;
|
|
if (!pushService || !vapidStore) {
|
|
res.status(503).json({ error: 'push disabled' });
|
|
return;
|
|
}
|
|
const parsed = validateSubscriptionInput(req.body);
|
|
if ('error' in parsed) {
|
|
res.status(400).json({ error: parsed.error });
|
|
return;
|
|
}
|
|
const current = vapidStore.getCurrent();
|
|
const { id } = repo.upsertPushSubscription({
|
|
userId: user.id,
|
|
endpoint: parsed.endpoint,
|
|
p256dh: parsed.p256dh,
|
|
auth: parsed.auth,
|
|
userAgent: parsed.userAgent ?? null,
|
|
vapidKeyId: current.keyId,
|
|
});
|
|
res.json({ id });
|
|
},
|
|
);
|
|
|
|
// DELETE /subscriptions/:id — only delete your own.
|
|
app.delete(
|
|
'/api/notifications/subscriptions/:id',
|
|
requireAuth,
|
|
rateLimit('push:unsubscribe', 30),
|
|
(req, res) => {
|
|
const user = requireUser(req, res); if (!user) return;
|
|
const sub = repo.getPushSubscriptionById(req.params.id!);
|
|
if (!sub || sub.userId !== user.id) {
|
|
res.status(404).json({ error: 'not found' });
|
|
return;
|
|
}
|
|
repo.deletePushSubscription(sub.id);
|
|
res.json({ ok: true });
|
|
},
|
|
);
|
|
|
|
// GET /preferences — auto-creates a default row on first access.
|
|
app.get('/api/notifications/preferences', requireAuth, (req, res) => {
|
|
const user = requireUser(req, res); if (!user) return;
|
|
res.json(repo.getUserNotificationPrefs(user.id));
|
|
});
|
|
|
|
// PUT /preferences — partial update.
|
|
app.put(
|
|
'/api/notifications/preferences',
|
|
requireAuth,
|
|
json,
|
|
rateLimit('push:prefs', 30),
|
|
(req, res) => {
|
|
const user = requireUser(req, res); if (!user) return;
|
|
const parsed = validatePrefsInput(req.body);
|
|
if ('error' in parsed) {
|
|
res.status(400).json({ error: parsed.error });
|
|
return;
|
|
}
|
|
repo.upsertUserNotificationPrefs(user.id, parsed);
|
|
res.json(repo.getUserNotificationPrefs(user.id));
|
|
},
|
|
);
|
|
|
|
// POST /preferences/migrate-from-localstorage — one-shot V1 → V2.
|
|
app.post(
|
|
'/api/notifications/preferences/migrate-from-localstorage',
|
|
requireAuth,
|
|
json,
|
|
rateLimit('push:migrate', 3),
|
|
(req, res) => {
|
|
const user = requireUser(req, res); if (!user) return;
|
|
const parsed = validatePrefsInput(req.body);
|
|
if ('error' in parsed) {
|
|
res.status(400).json({ error: parsed.error });
|
|
return;
|
|
}
|
|
const flipped = repo.markV1MigrationComplete(user.id);
|
|
if (!flipped) {
|
|
res.status(409).json({ error: 'already migrated' });
|
|
return;
|
|
}
|
|
repo.upsertUserNotificationPrefs(user.id, parsed);
|
|
res.json({ ok: true, prefs: repo.getUserNotificationPrefs(user.id) });
|
|
},
|
|
);
|
|
|
|
// POST /test — send a test push.
|
|
app.post(
|
|
'/api/notifications/test',
|
|
requireAuth,
|
|
rateLimit('push:test', 5),
|
|
(req, res) => {
|
|
const user = requireUser(req, res); if (!user) return;
|
|
if (!pushService) {
|
|
res.status(503).json({ error: 'push disabled' });
|
|
return;
|
|
}
|
|
pushService.enqueue({
|
|
event: 'succeeded',
|
|
taskId: 0,
|
|
taskTitle: 'テスト通知',
|
|
pieceName: 'V2 Web Push 動作確認',
|
|
ownerId: user.id,
|
|
});
|
|
res.json({ ok: true });
|
|
},
|
|
);
|
|
|
|
logger.info('[notifications-api] mounted (/api/notifications/*)');
|
|
}
|