From d600317ade92c60decf6e122548ae16830ebd3e9 Mon Sep 17 00:00:00 2001 From: mtvpls Date: Tue, 2 Jun 2026 20:50:33 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E6=B5=8F=E8=A7=88=E5=99=A8?= =?UTF-8?q?=E7=A6=BB=E7=BA=BF=E9=80=9A=E7=9F=A5=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 + migrations/008_web_push_notifications.sql | 52 ++ .../postgres/008_web_push_notifications.sql | 28 + next.config.js | 1 + public/push-sw.js | 58 ++ scripts/init-postgres.js | 71 ++- scripts/init-sqlite.js | 85 ++- src/app/api/auth/devices/route.ts | 5 + src/app/api/change-password/route.ts | 3 + src/app/api/logout/route.ts | 3 + src/app/api/notifications/push/route.ts | 111 ++++ src/app/api/user/email-settings/route.ts | 12 +- src/components/EmailSettingsPanel.tsx | 220 +++++-- src/components/PersonalCenterPanel.tsx | 8 +- src/components/UserMenu.tsx | 212 ++++++- src/lib/d1.db.ts | 134 ++++- src/lib/postgres.db.ts | 136 ++++- src/lib/redis-base.db.ts | 105 +++- src/lib/types.ts | 37 ++ src/lib/web-push.ts | 547 ++++++++++++++++++ 20 files changed, 1739 insertions(+), 91 deletions(-) create mode 100644 migrations/008_web_push_notifications.sql create mode 100644 migrations/postgres/008_web_push_notifications.sql create mode 100644 public/push-sw.js create mode 100644 src/app/api/notifications/push/route.ts create mode 100644 src/lib/web-push.ts diff --git a/README.md b/README.md index 8c18abd..1ba02c2 100644 --- a/README.md +++ b/README.md @@ -448,6 +448,8 @@ dockge/komodo 等 docker compose UI 也有自动更新功能 | DANMAKU_API_TOKEN | 弹幕 API Token | 任意字符串 | 87654321 | | DATA_MIGRATION_CHUNK_SIZE | 数据迁移批处理大小(控制导入导出时每批处理的用户数量和数据条数) | 正整数 | 10 | | QR_LOGIN_STORE_MODE | 电视端扫码登录状态存储模式;serverless环境下多节点内存状态不可靠。 | auto、memory、hybrid、shared | auto | +| WEB_PUSH_PROXY | Web Push 服务端发送代理地址,用于服务器访问 FCM 等 Push endpoint | HTTP/HTTPS 代理 URL | (空) | +| WEB_PUSH_BASEURL | Web Push endpoint 反向代理 Base URL;支持 `{endpoint}`(URL编码)和 `{raw_endpoint}`(不编码)占位符 | URL | (空) | NEXT_PUBLIC_DOUBAN_PROXY_TYPE 选项解释: diff --git a/migrations/008_web_push_notifications.sql b/migrations/008_web_push_notifications.sql new file mode 100644 index 0000000..44818cf --- /dev/null +++ b/migrations/008_web_push_notifications.sql @@ -0,0 +1,52 @@ +-- ============================================ +-- Web Push notifications +-- 版本: 008 +-- 说明: +-- - SQLite/D1 不支持 ALTER TABLE ADD COLUMN IF NOT EXISTS。 +-- - init-sqlite 使用 schema_migrations 保证该迁移只执行一次。 +-- - 本地 init-sqlite 会逐条执行并忽略 duplicate column name。 +-- ============================================ + +CREATE TABLE IF NOT EXISTS notification_push_subscriptions ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL, + token_id TEXT, + endpoint TEXT NOT NULL UNIQUE, + p256dh TEXT NOT NULL, + auth TEXT NOT NULL, + user_agent TEXT, + enabled INTEGER DEFAULT 1, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + last_success_at INTEGER, + last_failure_at INTEGER, + failure_count INTEGER DEFAULT 0, + FOREIGN KEY (username) REFERENCES users(username) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_push_subscriptions_username ON notification_push_subscriptions(username); +CREATE INDEX IF NOT EXISTS idx_push_subscriptions_token ON notification_push_subscriptions(username, token_id); +CREATE INDEX IF NOT EXISTS idx_push_subscriptions_enabled ON notification_push_subscriptions(username, enabled); + +-- Rebuild notifications without type CHECK so TS NotificationType is the source of truth. +CREATE TABLE IF NOT EXISTS notifications_new ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL, + type TEXT NOT NULL, + title TEXT NOT NULL, + message TEXT NOT NULL, + timestamp INTEGER NOT NULL, + read INTEGER DEFAULT 0, + metadata TEXT, + FOREIGN KEY (username) REFERENCES users(username) ON DELETE CASCADE +); + +INSERT OR IGNORE INTO notifications_new (id, username, type, title, message, timestamp, read, metadata) +SELECT id, username, type, title, message, timestamp, read, metadata FROM notifications; + +DROP TABLE notifications; +ALTER TABLE notifications_new RENAME TO notifications; + +CREATE INDEX IF NOT EXISTS idx_notifications_user_time ON notifications(username, timestamp DESC); +CREATE INDEX IF NOT EXISTS idx_notifications_user_read ON notifications(username, read, timestamp DESC); + diff --git a/migrations/postgres/008_web_push_notifications.sql b/migrations/postgres/008_web_push_notifications.sql new file mode 100644 index 0000000..44d7d4c --- /dev/null +++ b/migrations/postgres/008_web_push_notifications.sql @@ -0,0 +1,28 @@ +-- ============================================ +-- Web Push notifications for Postgres +-- 版本: 008 +-- ============================================ + + +CREATE TABLE IF NOT EXISTS notification_push_subscriptions ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL, + token_id TEXT, + endpoint TEXT NOT NULL UNIQUE, + p256dh TEXT NOT NULL, + auth TEXT NOT NULL, + user_agent TEXT, + enabled INTEGER DEFAULT 1, + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL, + last_success_at BIGINT, + last_failure_at BIGINT, + failure_count INTEGER DEFAULT 0, + FOREIGN KEY (username) REFERENCES users(username) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_push_subscriptions_username ON notification_push_subscriptions(username); +CREATE INDEX IF NOT EXISTS idx_push_subscriptions_token ON notification_push_subscriptions(username, token_id); +CREATE INDEX IF NOT EXISTS idx_push_subscriptions_enabled ON notification_push_subscriptions(username, enabled); + +ALTER TABLE notifications DROP CONSTRAINT IF EXISTS notifications_type_check; diff --git a/next.config.js b/next.config.js index 9e8f8b9..39dec99 100644 --- a/next.config.js +++ b/next.config.js @@ -183,6 +183,7 @@ const createNextConfig = (phase) => { dest: 'public', register: true, skipWaiting: true, + importScripts: ['/push-sw.js'], }); return withPWA(nextConfig); diff --git a/public/push-sw.js b/public/push-sw.js new file mode 100644 index 0000000..855cf60 --- /dev/null +++ b/public/push-sw.js @@ -0,0 +1,58 @@ +/* MoonTVPlus Web Push handlers */ + +self.addEventListener('install', (event) => { + event.waitUntil(self.skipWaiting()); +}); + +self.addEventListener('activate', (event) => { + event.waitUntil(self.clients.claim()); +}); + +self.addEventListener('push', (event) => { + if (!event.data) return; + + let payload = {}; + try { + payload = event.data.json(); + } catch (error) { + payload = { title: 'MoonTVPlus', body: event.data.text() }; + } + + const title = payload.title || 'MoonTVPlus'; + const options = { + body: payload.body || payload.message || '', + icon: '/icons/icon-192x192.png', + badge: '/icons/icon-192x192.png', + tag: payload.notificationId || undefined, + data: { + url: payload.url || '/', + notificationId: payload.notificationId, + }, + }; + + event.waitUntil(self.registration.showNotification(title, options)); +}); + +self.addEventListener('notificationclick', (event) => { + event.notification.close(); + + const targetUrl = new URL(event.notification.data?.url || '/', self.location.origin).href; + + event.waitUntil((async () => { + const windowClients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true }); + + for (const client of windowClients) { + if ('focus' in client) { + await client.focus(); + if ('navigate' in client) { + return client.navigate(targetUrl); + } + return; + } + } + + if (self.clients.openWindow) { + return self.clients.openWindow(targetUrl); + } + })()); +}); diff --git a/scripts/init-postgres.js b/scripts/init-postgres.js index b52a71a..dd7d5d9 100644 --- a/scripts/init-postgres.js +++ b/scripts/init-postgres.js @@ -37,27 +37,86 @@ if (migrationFiles.length === 0) { console.log(`📄 Found ${migrationFiles.length} migration file(s):`, migrationFiles.join(', ')); +const MIGRATION_BASELINE_CUTOFF = '008_web_push_notifications.sql'; + +function splitSqlStatements(schemaSql) { + const withoutLineComments = schemaSql + .split('\n') + .filter((line) => !line.trim().startsWith('--')) + .join('\n'); + + return withoutLineComments + .split(';') + .map((statement) => statement.trim()) + .filter((statement) => statement.length > 0); +} + +async function tableExists(tableName) { + const result = await sql.query( + "SELECT to_regclass($1) AS table_name", + [`public.${tableName}`] + ); + return Boolean(result.rows?.[0]?.table_name); +} + +async function ensureMigrationTable() { + await sql.query(` + CREATE TABLE IF NOT EXISTS schema_migrations ( + filename TEXT PRIMARY KEY, + applied_at BIGINT NOT NULL + ) + `); +} + +async function getAppliedMigrations() { + const result = await sql.query('SELECT filename FROM schema_migrations'); + return new Set((result.rows || []).map((row) => row.filename)); +} + +async function markMigrationApplied(filename) { + await sql.query( + 'INSERT INTO schema_migrations (filename, applied_at) VALUES ($1, $2) ON CONFLICT (filename) DO NOTHING', + [filename, Date.now()] + ); +} + +async function seedExistingMigrationBaseline(hadExistingSchema) { + const applied = await getAppliedMigrations(); + if (!hadExistingSchema || applied.size > 0) return; + + for (const file of migrationFiles) { + if (file.localeCompare(MIGRATION_BASELINE_CUTOFF) < 0) { + await markMigrationApplied(file); + } + } +} + async function init() { try { // 执行所有迁移脚本 console.log('🔧 Running database migrations...'); + const hadExistingSchema = await tableExists('users'); + await ensureMigrationTable(); + await seedExistingMigrationBaseline(hadExistingSchema); for (const migrationFile of migrationFiles) { + const applied = await getAppliedMigrations(); + if (applied.has(migrationFile)) { + console.log(` ⏭️ ${migrationFile} already applied`); + continue; + } + const sqlPath = path.join(migrationsDir, migrationFile); console.log(` ⏳ Executing ${migrationFile}...`); const schemaSql = fs.readFileSync(sqlPath, 'utf8'); - - // 将 SQL 脚本按语句分割并逐个执行 - const statements = schemaSql - .split(';') - .map(s => s.trim()) - .filter(s => s.length > 0); + const statements = splitSqlStatements(schemaSql); for (const statement of statements) { await sql.query(statement); } + await markMigrationApplied(migrationFile); console.log(` ✅ ${migrationFile} executed successfully`); } diff --git a/scripts/init-sqlite.js b/scripts/init-sqlite.js index 4c5ac50..49627f0 100644 --- a/scripts/init-sqlite.js +++ b/scripts/init-sqlite.js @@ -4,6 +4,7 @@ const path = require('path'); const crypto = require('crypto'); const MIGRATIONS_DIR = path.join(__dirname, '../migrations'); +const MIGRATION_BASELINE_CUTOFF = '008_web_push_notifications.sql'; function hashPassword(password) { return crypto.createHash('sha256').update(password).digest('hex'); @@ -46,24 +47,88 @@ function isIgnorableMigrationError(error) { ); } -function runMigrations(db) { - const migrationFiles = getMigrationFiles(); +function splitSqlStatements(sql) { + const withoutLineComments = sql + .split('\n') + .filter((line) => !line.trim().startsWith('--')) + .join('\n'); + + return withoutLineComments + .split(';') + .map((statement) => statement.trim()) + .filter((statement) => statement.length > 0); +} + +function tableExists(db, tableName) { + const row = db + .prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?") + .get(tableName); + return Boolean(row); +} + +function ensureMigrationTable(db) { + db.exec(` + CREATE TABLE IF NOT EXISTS schema_migrations ( + filename TEXT PRIMARY KEY, + applied_at INTEGER NOT NULL + ) + `); +} + +function getAppliedMigrations(db) { + return new Set( + db.prepare('SELECT filename FROM schema_migrations').all().map((row) => row.filename) + ); +} + +function markMigrationApplied(db, filename) { + db.prepare( + 'INSERT OR IGNORE INTO schema_migrations (filename, applied_at) VALUES (?, ?)' + ).run(filename, Date.now()); +} + +function seedExistingMigrationBaseline(db, migrationFiles, hadExistingSchema) { + const applied = getAppliedMigrations(db); + if (!hadExistingSchema || applied.size > 0) return; for (const file of migrationFiles) { + if (file.localeCompare(MIGRATION_BASELINE_CUTOFF) < 0) { + markMigrationApplied(db, file); + } + } +} + +function runMigrations(db) { + const migrationFiles = getMigrationFiles(); + const hadExistingSchema = tableExists(db, 'users'); + ensureMigrationTable(db); + seedExistingMigrationBaseline(db, migrationFiles, hadExistingSchema); + + for (const file of migrationFiles) { + const applied = getAppliedMigrations(db); + if (applied.has(file)) { + console.log(`⏭️ Migration already applied: ${file}`); + continue; + } + const migrationPath = path.join(MIGRATIONS_DIR, file); const sql = fs.readFileSync(migrationPath, 'utf8'); + const statements = splitSqlStatements(sql); console.log(`▶️ Applying migration: ${file}`); - try { - db.exec(sql); - console.log(`✅ Migration applied: ${file}`); - } catch (error) { - if (isIgnorableMigrationError(error)) { - console.log(`⏭️ Migration skipped: ${file}`); - continue; + for (const statement of statements) { + try { + db.exec(statement); + } catch (error) { + if (isIgnorableMigrationError(error)) { + console.log(`⏭️ Statement skipped in ${file}: ${error.message}`); + continue; + } + throw error; } - throw error; } + markMigrationApplied(db, file); + console.log(`✅ Migration applied: ${file}`); } } diff --git a/src/app/api/auth/devices/route.ts b/src/app/api/auth/devices/route.ts index 863b9c8..a4e966f 100644 --- a/src/app/api/auth/devices/route.ts +++ b/src/app/api/auth/devices/route.ts @@ -3,6 +3,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { getAuthInfoFromCookie } from '@/lib/auth'; +import { getStorage } from '@/lib/db'; import { getUserDevices, revokeAllRefreshTokens, @@ -51,6 +52,8 @@ export async function DELETE(request: NextRequest) { } await revokeRefreshToken(authInfo.username, tokenId); + const storage = getStorage(); + await storage.deletePushSubscriptionsByTokenId?.(authInfo.username, tokenId); return NextResponse.json({ ok: true }); } catch (error) { @@ -69,6 +72,8 @@ export async function POST(request: NextRequest) { try { await revokeAllRefreshTokens(authInfo.username); + const storage = getStorage(); + await storage.deleteAllPushSubscriptions?.(authInfo.username); const response = NextResponse.json({ ok: true }); diff --git a/src/app/api/change-password/route.ts b/src/app/api/change-password/route.ts index 95e0dc9..82baf50 100644 --- a/src/app/api/change-password/route.ts +++ b/src/app/api/change-password/route.ts @@ -3,6 +3,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { getAuthInfoFromCookie } from '@/lib/auth'; +import { getStorage } from '@/lib/db'; import { db } from '@/lib/db'; import { getUserDevices, revokeRefreshToken } from '@/lib/refresh-token'; @@ -53,11 +54,13 @@ export async function POST(request: NextRequest) { try { const currentTokenId = authInfo.tokenId; const devices = await getUserDevices(username); + const storage = getStorage(); // 撤销所有非当前设备的 token for (const device of devices) { if (device.tokenId !== currentTokenId) { await revokeRefreshToken(username, device.tokenId); + await storage.deletePushSubscriptionsByTokenId?.(username, device.tokenId); console.log(`Revoked token ${device.tokenId} for ${username} after password change`); } } diff --git a/src/app/api/logout/route.ts b/src/app/api/logout/route.ts index 87e37f9..0d46d18 100644 --- a/src/app/api/logout/route.ts +++ b/src/app/api/logout/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { getAuthInfoFromCookie } from '@/lib/auth'; +import { getStorage } from '@/lib/db'; import { revokeRefreshToken } from '@/lib/refresh-token'; export const runtime = 'nodejs'; @@ -12,6 +13,8 @@ export async function POST(request: NextRequest) { if (authInfo && authInfo.username && authInfo.tokenId) { try { await revokeRefreshToken(authInfo.username, authInfo.tokenId); + const storage = getStorage(); + await storage.deletePushSubscriptionsByTokenId?.(authInfo.username, authInfo.tokenId); } catch (error) { console.error('Failed to revoke refresh token:', error); } diff --git a/src/app/api/notifications/push/route.ts b/src/app/api/notifications/push/route.ts new file mode 100644 index 0000000..16112fa --- /dev/null +++ b/src/app/api/notifications/push/route.ts @@ -0,0 +1,111 @@ +import { NextRequest, NextResponse } from 'next/server'; + +import { getAuthInfoFromCookie } from '@/lib/auth'; +import { getStorage } from '@/lib/db'; +import { + createPushSubscriptionRecord, + getVapidPublicKey, + isWebPushConfigured, +} from '@/lib/web-push'; + +export const runtime = 'nodejs'; + +function extractSubscriptionKeys(subscription: any) { + const p256dh = subscription?.keys?.p256dh || subscription?.toJSON?.()?.keys?.p256dh; + const auth = subscription?.keys?.auth || subscription?.toJSON?.()?.keys?.auth; + return { p256dh, auth }; +} + +export async function GET(request: NextRequest) { + const authInfo = getAuthInfoFromCookie(request); + if (!authInfo?.username) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const storage = getStorage(); + + const [configured, publicKey, subscriptions] = await Promise.all([ + isWebPushConfigured(storage), + getVapidPublicKey(storage), + storage.getEnabledPushSubscriptions + ? storage.getEnabledPushSubscriptions(authInfo.username) + : Promise.resolve([]), + ]); + + const currentDeviceSubscriptions = authInfo.tokenId + ? subscriptions.filter((item) => item.tokenId === authInfo.tokenId) + : []; + + return NextResponse.json({ + configured, + publicKey, + pushNotifications: currentDeviceSubscriptions.length > 0, + hasDeviceToken: Boolean(authInfo.tokenId), + subscriptionCount: subscriptions.length, + currentDeviceSubscriptionCount: currentDeviceSubscriptions.length, + }); +} + +export async function POST(request: NextRequest) { + const authInfo = getAuthInfoFromCookie(request); + if (!authInfo?.username) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const body = await request.json(); + const { subscription, enabled } = body; + const storage = getStorage(); + + if (enabled === false) { + if (authInfo.tokenId) { + await storage.deletePushSubscriptionsByTokenId?.(authInfo.username, authInfo.tokenId); + } + return NextResponse.json({ ok: true }); + } + + if (subscription) { + const { p256dh, auth } = extractSubscriptionKeys(subscription); + if (!subscription.endpoint || !p256dh || !auth) { + return NextResponse.json({ error: 'Invalid push subscription' }, { status: 400 }); + } + + if (!authInfo.tokenId) { + return NextResponse.json( + { error: '当前登录模式不支持设备级浏览器通知订阅' }, + { status: 400 } + ); + } + + await storage.upsertPushSubscription?.( + authInfo.username, + createPushSubscriptionRecord({ + username: authInfo.username, + tokenId: authInfo.tokenId, + endpoint: subscription.endpoint, + p256dh, + auth, + userAgent: request.headers.get('user-agent'), + }) + ); + } + + return NextResponse.json({ ok: true }); +} + +export async function DELETE(request: NextRequest) { + const authInfo = getAuthInfoFromCookie(request); + if (!authInfo?.username) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const body = await request.json().catch(() => ({})); + const storage = getStorage(); + + if (body?.endpoint) { + await storage.deletePushSubscriptionByEndpoint?.(authInfo.username, body.endpoint); + } else if (authInfo.tokenId) { + await storage.deletePushSubscriptionsByTokenId?.(authInfo.username, authInfo.tokenId); + } + + return NextResponse.json({ ok: true }); +} diff --git a/src/app/api/user/email-settings/route.ts b/src/app/api/user/email-settings/route.ts index 299b279..0d40f24 100644 --- a/src/app/api/user/email-settings/route.ts +++ b/src/app/api/user/email-settings/route.ts @@ -6,7 +6,7 @@ import { getStorage } from '@/lib/db'; export const runtime = 'nodejs'; /** - * GET - 获取用户邮箱设置 + * GET - 获取用户通知设置 */ export async function GET(request: NextRequest) { const authInfo = getAuthInfoFromCookie(request); @@ -31,7 +31,7 @@ export async function GET(request: NextRequest) { emailNotifications, }); } catch (error) { - console.error('获取用户邮箱设置失败:', error); + console.error('获取用户通知设置失败:', error); return NextResponse.json( { error: (error as Error).message }, { status: 500 } @@ -40,7 +40,7 @@ export async function GET(request: NextRequest) { } /** - * POST - 保存用户邮箱设置 + * POST - 保存用户通知设置 */ export async function POST(request: NextRequest) { const authInfo = getAuthInfoFromCookie(request); @@ -76,12 +76,14 @@ export async function POST(request: NextRequest) { } } + + return NextResponse.json({ success: true, - message: '邮箱设置保存成功', + message: '通知设置保存成功', }); } catch (error) { - console.error('保存用户邮箱设置失败:', error); + console.error('保存用户通知设置失败:', error); return NextResponse.json( { error: (error as Error).message }, { status: 500 } diff --git a/src/components/EmailSettingsPanel.tsx b/src/components/EmailSettingsPanel.tsx index 499d11a..1ea4dc8 100644 --- a/src/components/EmailSettingsPanel.tsx +++ b/src/components/EmailSettingsPanel.tsx @@ -1,6 +1,6 @@ 'use client'; -import { X } from 'lucide-react'; +import { Bell, Info, Mail, MonitorSmartphone, X } from 'lucide-react'; import { createPortal } from 'react-dom'; interface EmailSettingsPanelProps { @@ -11,6 +11,11 @@ interface EmailSettingsPanelProps { onUserEmailChange: (value: string) => void; emailNotifications: boolean; onEmailNotificationsChange: (value: boolean) => void; + pushNotifications: boolean; + onPushNotificationsChange: (value: boolean) => void; + pushNotificationsSupported: boolean; + pushNotificationsConfigured: boolean; + pushNotificationsBusy: boolean; emailSettingsLoading: boolean; emailSettingsSaving: boolean; onSave: () => void; @@ -18,6 +23,44 @@ interface EmailSettingsPanelProps { statusType?: 'success' | 'error' | null; } +function Toggle({ + checked, + disabled, + busy, + label, + onChange, +}: { + checked: boolean; + disabled?: boolean; + busy?: boolean; + label: string; + onChange: () => void; +}) { + return ( + + ); +} + export function EmailSettingsPanel({ isOpen, mounted, @@ -26,6 +69,11 @@ export function EmailSettingsPanel({ onUserEmailChange, emailNotifications, onEmailNotificationsChange, + pushNotifications, + onPushNotificationsChange, + pushNotificationsSupported, + pushNotificationsConfigured, + pushNotificationsBusy, emailSettingsLoading, emailSettingsSaving, onSave, @@ -34,56 +82,81 @@ export function EmailSettingsPanel({ }: EmailSettingsPanelProps) { if (!isOpen || !mounted) return null; + const pushDisabled = + emailSettingsSaving || + pushNotificationsBusy || + (!pushNotifications && (!pushNotificationsConfigured || !pushNotificationsSupported)); + return createPortal( <>
e.preventDefault()} onWheel={(e) => e.preventDefault()} style={{ touchAction: 'none' }} /> -
+
e.stopPropagation()} style={{ touchAction: 'auto' }} > -
-

- 邮件通知设置 -

+
+
+
+ +
+

+ 通知设置 +

+

+ 管理邮件通知和当前设备浏览器系统通知。 +

+
{emailSettingsLoading ? ( -
-
-
-
+
+
+
+
-
-
+
+
+
-
-
-
-
+

加载中... -

+

) : (
-
-
-
-
-

- 接收收藏更新通知 -

-

- 当收藏的影片有更新时发送邮件通知 -

-
- -
+
+ + +
+
+
+ +
+
+

+ 当前设备浏览器系统通知 +

+

+ 当前设备收到站内通知时,通过浏览器推送到系统通知中心。 +

+
+ onPushNotificationsChange(!pushNotifications)} + /> +
+ +
+
+ 当前设备 + + {pushNotificationsSupported ? '可用' : '需支持或授权'} + +
+ {!pushNotificationsConfigured && ( +

+ 系统正在初始化 Web Push 密钥,请稍后重试。 +

+ )} + {pushNotificationsConfigured && !pushNotificationsSupported && ( +

+ 当前浏览器、系统权限或登录模式暂不支持系统通知。 +

+ )} +
+
{statusMessage ? (

)} -

-

- 💡 提示:需要管理员先在管理面板中配置邮件服务 +

+ +

+ 邮件通知需要管理员配置邮件服务;当前设备浏览器系统通知需要当前浏览器授权通知权限。

diff --git a/src/components/PersonalCenterPanel.tsx b/src/components/PersonalCenterPanel.tsx index 3c5af6e..b0408b1 100644 --- a/src/components/PersonalCenterPanel.tsx +++ b/src/components/PersonalCenterPanel.tsx @@ -1,6 +1,6 @@ 'use client'; -import { KeyRound, Mail, Monitor, X } from 'lucide-react'; +import { Bell, KeyRound, Monitor, X } from 'lucide-react'; import { createPortal } from 'react-dom'; interface PersonalCenterPanelProps { @@ -88,14 +88,14 @@ export function PersonalCenterPanel({ className='flex w-full items-center gap-3 rounded-2xl border border-gray-200 bg-gray-50 px-4 py-4 text-left transition-colors hover:bg-gray-100 dark:border-gray-700 dark:bg-gray-800 dark:hover:bg-gray-750' >
- +
- 邮件通知设置 + 通知设置
- 管理接收收藏更新通知的邮箱和开关 + 管理邮件通知和浏览器系统通知
diff --git a/src/components/UserMenu.tsx b/src/components/UserMenu.tsx index dcab099..7bbd5c0 100644 --- a/src/components/UserMenu.tsx +++ b/src/components/UserMenu.tsx @@ -213,9 +213,13 @@ export const UserMenu: React.FC = () => { ); const [filesystemSavePath, setFilesystemSavePath] = useState(''); - // 邮件通知设置 + // 通知设置 const [userEmail, setUserEmail] = useState(''); const [emailNotifications, setEmailNotifications] = useState(false); + const [pushNotifications, setPushNotifications] = useState(false); + const [pushNotificationsConfigured, setPushNotificationsConfigured] = useState(false); + const [pushNotificationsSupported, setPushNotificationsSupported] = useState(false); + const [pushNotificationsBusy, setPushNotificationsBusy] = useState(false); const [emailSettingsLoading, setEmailSettingsLoading] = useState(false); const [emailSettingsSaving, setEmailSettingsSaving] = useState(false); const [emailSettingsMessage, setEmailSettingsMessage] = useState(''); @@ -838,7 +842,7 @@ export const UserMenu: React.FC = () => { } }, []); - // 加载邮件通知设置 + // 加载通知设置 const loadEmailSettings = async () => { setEmailSettingsLoading(true); setEmailSettingsMessage(''); @@ -850,14 +854,207 @@ export const UserMenu: React.FC = () => { setUserEmail(data.email || ''); setEmailNotifications(data.emailNotifications || false); } + + const pushResponse = await fetch('/api/notifications/push'); + if (pushResponse.ok) { + const pushData = await pushResponse.json(); + setPushNotificationsConfigured(Boolean(pushData.configured && pushData.publicKey)); + setPushNotificationsSupported( + Boolean( + pushData.configured && + pushData.publicKey && + pushData.hasDeviceToken && + typeof window !== 'undefined' && + 'Notification' in window && + 'serviceWorker' in navigator && + 'PushManager' in window + ) + ); + setPushNotifications(Boolean(pushData.pushNotifications)); + } } catch (error) { - console.error('加载邮件设置失败:', error); + console.error('加载通知设置失败:', error); } finally { setEmailSettingsLoading(false); } }; - // 保存邮件通知设置 + const urlBase64ToUint8Array = (base64String: string) => { + const padding = '='.repeat((4 - (base64String.length % 4)) % 4); + const base64 = (base64String + padding) + .replace(/-/g, '+') + .replace(/_/g, '/'); + const rawData = window.atob(base64); + const outputArray = new Uint8Array(rawData.length); + for (let i = 0; i < rawData.length; i++) { + outputArray[i] = rawData.charCodeAt(i); + } + return outputArray; + }; + + const arrayBufferToBase64Url = (buffer: ArrayBuffer | null) => { + if (!buffer) return ''; + const bytes = new Uint8Array(buffer); + let binary = ''; + for (let i = 0; i < bytes.byteLength; i++) { + binary += String.fromCharCode(bytes[i]); + } + return window + .btoa(binary) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/g, ''); + }; + + const isSubscriptionUsingPublicKey = ( + subscription: PushSubscription, + publicKey: string + ) => { + const subscriptionKey = arrayBufferToBase64Url( + subscription.options?.applicationServerKey || null + ); + return subscriptionKey === publicKey; + }; + + const waitForServiceWorkerActivation = async ( + registration: ServiceWorkerRegistration + ) => { + let pendingWorker = registration.installing || registration.waiting; + + if (!pendingWorker) { + await registration.update(); + pendingWorker = registration.installing || registration.waiting; + } + + // 没有新的 installing/waiting worker 时,说明当前 active registration 可直接使用。 + if (!pendingWorker) { + if (registration.active) return registration; + throw new Error('Service Worker 注册失败,请刷新页面后重试'); + } + + const activatingWorker = pendingWorker; + if (activatingWorker.state === 'activated') return registration; + + await new Promise((resolve, reject) => { + const handleStateChange = () => { + if (activatingWorker.state === 'activated') { + activatingWorker.removeEventListener('statechange', handleStateChange); + resolve(); + } else if (activatingWorker.state === 'redundant') { + activatingWorker.removeEventListener('statechange', handleStateChange); + reject(new Error('Service Worker 激活失败,请刷新页面后重试')); + } + }; + + activatingWorker.addEventListener('statechange', handleStateChange); + handleStateChange(); + }); + + return registration; + }; + + const getReadyServiceWorkerRegistration = async () => { + if (!('serviceWorker' in navigator)) { + throw new Error('当前浏览器不支持 Service Worker'); + } + + // 开启系统通知时明确使用带 push 事件处理器的 Service Worker。 + // 如果浏览器里已有旧 /sw.js 注册,重新注册同一 scope 的 /push-sw.js 会更新该注册; + // push-sw.js 内部会 skipWaiting + clients.claim,激活后再订阅,确保 Push 到达能展示通知。 + const registration = await navigator.serviceWorker.register('/push-sw.js', { + scope: '/', + updateViaCache: 'none', + }); + + return waitForServiceWorkerActivation(registration); + }; + + const handlePushNotificationsChange = async (enabled: boolean) => { + if (!enabled) { + setPushNotificationsBusy(true); + try { + let endpoint: string | undefined; + const registration = + 'serviceWorker' in navigator + ? await navigator.serviceWorker.getRegistration() + : undefined; + const subscription = await registration?.pushManager.getSubscription(); + endpoint = subscription?.endpoint; + await subscription?.unsubscribe(); + await fetch('/api/notifications/push', { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ endpoint }), + }); + setPushNotifications(false); + } catch (error) { + console.error('关闭浏览器通知失败:', error); + setEmailSettingsMessage('关闭浏览器通知失败,请重试'); + setEmailSettingsMessageType('error'); + } finally { + setPushNotificationsBusy(false); + } + return; + } + + setPushNotificationsBusy(true); + setEmailSettingsMessage(''); + setEmailSettingsMessageType(null); + try { + const statusResponse = await fetch('/api/notifications/push'); + const status = statusResponse.ok ? await statusResponse.json() : null; + if (!status?.configured || !status?.publicKey) { + throw new Error('管理员尚未配置 Web Push VAPID 密钥'); + } + + const permission = await Notification.requestPermission(); + if (permission !== 'granted') { + throw new Error('浏览器通知权限未授权'); + } + + const registration = await getReadyServiceWorkerRegistration(); + + let subscription = await registration.pushManager.getSubscription(); + if (subscription && !isSubscriptionUsingPublicKey(subscription, status.publicKey)) { + await subscription.unsubscribe(); + subscription = null; + } + + if (!subscription) { + subscription = await registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: urlBase64ToUint8Array(status.publicKey), + }); + } + + const response = await fetch('/api/notifications/push', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + enabled: true, + subscription: subscription.toJSON(), + }), + }); + + if (!response.ok) { + const data = await response.json().catch(() => ({})); + throw new Error(data.error || '保存浏览器通知订阅失败'); + } + + setPushNotifications(true); + setEmailSettingsMessage('浏览器系统通知已开启'); + setEmailSettingsMessageType('success'); + } catch (error) { + console.error('开启浏览器通知失败:', error); + setPushNotifications(false); + setEmailSettingsMessage(error instanceof Error ? error.message : '开启浏览器通知失败'); + setEmailSettingsMessageType('error'); + } finally { + setPushNotificationsBusy(false); + } + }; + + // 保存通知设置 const handleSaveEmailSettings = async () => { setEmailSettingsSaving(true); setEmailSettingsMessage(''); @@ -885,7 +1082,7 @@ export const UserMenu: React.FC = () => { setEmailSettingsMessageType('error'); } } catch (error) { - console.error('保存邮件设置失败:', error); + console.error('保存通知设置失败:', error); setEmailSettingsMessage('保存失败,请重试'); setEmailSettingsMessageType('error'); } finally { @@ -4866,6 +5063,11 @@ export const UserMenu: React.FC = () => { onUserEmailChange={setUserEmail} emailNotifications={emailNotifications} onEmailNotificationsChange={setEmailNotifications} + pushNotifications={pushNotifications} + onPushNotificationsChange={handlePushNotificationsChange} + pushNotificationsSupported={pushNotificationsSupported} + pushNotificationsConfigured={pushNotificationsConfigured} + pushNotificationsBusy={pushNotificationsBusy} emailSettingsLoading={emailSettingsLoading} emailSettingsSaving={emailSettingsSaving} onSave={handleSaveEmailSettings} diff --git a/src/lib/d1.db.ts b/src/lib/d1.db.ts index 8937edb..5702777 100644 --- a/src/lib/d1.db.ts +++ b/src/lib/d1.db.ts @@ -14,6 +14,7 @@ import { DanmakuFilterConfig, Notification, MovieRequest, + PushSubscriptionRecord, } from './types'; import { AdminConfig } from './admin.types'; import { MangaReadRecord, MangaShelfItem } from './manga.types'; @@ -25,6 +26,7 @@ import { MusicV2PlaylistRecord, } from './music-v2'; import { userInfoCache } from './user-cache'; +import { dispatchWebPushNotification } from './web-push'; /** * Cloudflare D1 存储实现 @@ -81,6 +83,7 @@ export class D1Storage implements IStorage { } } + // ==================== 播放记录 ==================== async getPlayRecord( @@ -1911,6 +1914,133 @@ export class D1Storage implements IStorage { } } + + async upsertPushSubscription( + userName: string, + subscription: PushSubscriptionRecord + ): Promise { + try { + const result = await this.db + .prepare(` + INSERT INTO notification_push_subscriptions ( + id, username, token_id, endpoint, p256dh, auth, user_agent, enabled, + created_at, updated_at, last_success_at, last_failure_at, failure_count + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, 0) + ON CONFLICT(endpoint) DO UPDATE SET + username = excluded.username, + token_id = excluded.token_id, + p256dh = excluded.p256dh, + auth = excluded.auth, + user_agent = excluded.user_agent, + enabled = 1, + updated_at = excluded.updated_at + `) + .bind( + subscription.id, + userName, + subscription.tokenId || null, + subscription.endpoint, + subscription.p256dh, + subscription.auth, + subscription.userAgent || null, + subscription.enabled ? 1 : 0, + subscription.createdAt, + subscription.updatedAt + ) + .run(); + if (!result.success) { + throw new Error(result.error || '保存浏览器通知订阅失败'); + } + } catch (err) { + console.error('D1Storage.upsertPushSubscription error:', err); + throw err; + } + } + + async getEnabledPushSubscriptions(userName: string): Promise { + try { + const results = await this.db + .prepare('SELECT * FROM notification_push_subscriptions WHERE username = ? AND enabled = 1') + .bind(userName) + .all(); + + return (results.results || []).map((row: any) => ({ + id: row.id as string, + username: row.username as string, + tokenId: (row.token_id as string | null) || null, + endpoint: row.endpoint as string, + p256dh: row.p256dh as string, + auth: row.auth as string, + userAgent: (row.user_agent as string | null) || null, + enabled: row.enabled === 1, + createdAt: Number(row.created_at), + updatedAt: Number(row.updated_at), + lastSuccessAt: row.last_success_at ? Number(row.last_success_at) : null, + lastFailureAt: row.last_failure_at ? Number(row.last_failure_at) : null, + failureCount: Number(row.failure_count || 0), + })); + } catch (err) { + console.error('D1Storage.getEnabledPushSubscriptions error:', err); + return []; + } + } + + async deletePushSubscriptionByEndpoint(userName: string, endpoint: string): Promise { + try { + await this.db + .prepare('DELETE FROM notification_push_subscriptions WHERE username = ? AND endpoint = ?') + .bind(userName, endpoint) + .run(); + } catch (err) { + console.error('D1Storage.deletePushSubscriptionByEndpoint error:', err); + } + } + + async deletePushSubscriptionsByTokenId(userName: string, tokenId: string): Promise { + try { + await this.db + .prepare('DELETE FROM notification_push_subscriptions WHERE username = ? AND token_id = ?') + .bind(userName, tokenId) + .run(); + } catch (err) { + console.error('D1Storage.deletePushSubscriptionsByTokenId error:', err); + } + } + + async deleteAllPushSubscriptions(userName: string): Promise { + try { + await this.db + .prepare('DELETE FROM notification_push_subscriptions WHERE username = ?') + .bind(userName) + .run(); + } catch (err) { + console.error('D1Storage.deleteAllPushSubscriptions error:', err); + } + } + + async updatePushSubscriptionDeliveryStats( + userName: string, + endpoint: string, + success: boolean + ): Promise { + try { + const now = Date.now(); + if (success) { + await this.db + .prepare('UPDATE notification_push_subscriptions SET last_success_at = ?, failure_count = 0, updated_at = ? WHERE username = ? AND endpoint = ?') + .bind(now, now, userName, endpoint) + .run(); + } else { + await this.db + .prepare('UPDATE notification_push_subscriptions SET last_failure_at = ?, failure_count = failure_count + 1, updated_at = ? WHERE username = ? AND endpoint = ?') + .bind(now, now, userName, endpoint) + .run(); + } + } catch (err) { + console.error('D1Storage.updatePushSubscriptionDeliveryStats error:', err); + } + } + // ==================== TVBox订阅token ==================== async getTvboxSubscribeToken?(userName: string): Promise { @@ -2952,6 +3082,8 @@ export class D1Storage implements IStorage { notification.metadata ? JSON.stringify(notification.metadata) : null ) .run(); + + await dispatchWebPushNotification(this, userName, notification); } catch (err) { console.error('D1Storage.addNotification error:', err); throw err; @@ -3059,7 +3191,7 @@ export class D1Storage implements IStorage { requested_by, request_count, status, created_at, updated_at, fulfilled_at, fulfilled_source, fulfilled_id ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ` ) .bind( diff --git a/src/lib/postgres.db.ts b/src/lib/postgres.db.ts index ccedd59..1ea5951 100644 --- a/src/lib/postgres.db.ts +++ b/src/lib/postgres.db.ts @@ -16,6 +16,7 @@ import { DanmakuFilterConfig, Notification, MovieRequest, + PushSubscriptionRecord, } from './types'; import { AdminConfig } from './admin.types'; import { MangaReadRecord, MangaShelfItem } from './manga.types'; @@ -26,6 +27,7 @@ import { MusicV2PlaylistItem, MusicV2PlaylistRecord, } from './music-v2'; +import { dispatchWebPushNotification } from './web-push'; /** * Vercel Postgres 存储实现 @@ -1066,13 +1068,137 @@ export class PostgresStorage implements IStorage { } } + + async upsertPushSubscription( + userName: string, + subscription: PushSubscriptionRecord + ): Promise { + try { + await this.db + .prepare(` + INSERT INTO notification_push_subscriptions ( + id, username, token_id, endpoint, p256dh, auth, user_agent, enabled, + created_at, updated_at, last_success_at, last_failure_at, failure_count + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NULL, NULL, 0) + ON CONFLICT(endpoint) DO UPDATE SET + username = excluded.username, + token_id = excluded.token_id, + p256dh = excluded.p256dh, + auth = excluded.auth, + user_agent = excluded.user_agent, + enabled = 1, + updated_at = excluded.updated_at + `) + .bind( + subscription.id, + userName, + subscription.tokenId || null, + subscription.endpoint, + subscription.p256dh, + subscription.auth, + subscription.userAgent || null, + subscription.enabled ? 1 : 0, + subscription.createdAt, + subscription.updatedAt + ) + .run(); + } catch (err) { + console.error('PostgresStorage.upsertPushSubscription error:', err); + throw err; + } + } + + async getEnabledPushSubscriptions(userName: string): Promise { + try { + const results = await this.db + .prepare('SELECT * FROM notification_push_subscriptions WHERE username = $1 AND enabled = 1') + .bind(userName) + .all(); + + return (results.results || []).map((row: any) => ({ + id: row.id as string, + username: row.username as string, + tokenId: (row.token_id as string | null) || null, + endpoint: row.endpoint as string, + p256dh: row.p256dh as string, + auth: row.auth as string, + userAgent: (row.user_agent as string | null) || null, + enabled: row.enabled === 1, + createdAt: Number(row.created_at), + updatedAt: Number(row.updated_at), + lastSuccessAt: row.last_success_at ? Number(row.last_success_at) : null, + lastFailureAt: row.last_failure_at ? Number(row.last_failure_at) : null, + failureCount: Number(row.failure_count || 0), + })); + } catch (err) { + console.error('PostgresStorage.getEnabledPushSubscriptions error:', err); + return []; + } + } + + async deletePushSubscriptionByEndpoint(userName: string, endpoint: string): Promise { + try { + await this.db + .prepare('DELETE FROM notification_push_subscriptions WHERE username = $1 AND endpoint = $2') + .bind(userName, endpoint) + .run(); + } catch (err) { + console.error('PostgresStorage.deletePushSubscriptionByEndpoint error:', err); + } + } + + async deletePushSubscriptionsByTokenId(userName: string, tokenId: string): Promise { + try { + await this.db + .prepare('DELETE FROM notification_push_subscriptions WHERE username = $1 AND token_id = $2') + .bind(userName, tokenId) + .run(); + } catch (err) { + console.error('PostgresStorage.deletePushSubscriptionsByTokenId error:', err); + } + } + + async deleteAllPushSubscriptions(userName: string): Promise { + try { + await this.db + .prepare('DELETE FROM notification_push_subscriptions WHERE username = $1') + .bind(userName) + .run(); + } catch (err) { + console.error('PostgresStorage.deleteAllPushSubscriptions error:', err); + } + } + + async updatePushSubscriptionDeliveryStats( + userName: string, + endpoint: string, + success: boolean + ): Promise { + try { + const now = Date.now(); + if (success) { + await this.db + .prepare('UPDATE notification_push_subscriptions SET last_success_at = $1, failure_count = 0, updated_at = $2 WHERE username = $3 AND endpoint = $4') + .bind(now, now, userName, endpoint) + .run(); + } else { + await this.db + .prepare('UPDATE notification_push_subscriptions SET last_failure_at = $1, failure_count = failure_count + 1, updated_at = $2 WHERE username = $3 AND endpoint = $4') + .bind(now, now, userName, endpoint) + .run(); + } + } catch (err) { + console.error('PostgresStorage.updatePushSubscriptionDeliveryStats error:', err); + } + } + // ==================== TVBox订阅token ==================== async getTvboxSubscribeToken(userName: string): Promise { try { const result = await this.db .prepare( - 'SELECT tvbox_subscribe_token FROM users_v2 WHERE username = $1' + 'SELECT tvbox_subscribe_token FROM users WHERE username = $1' ) .bind(userName) .first(); @@ -1088,7 +1214,7 @@ export class PostgresStorage implements IStorage { try { await this.db .prepare( - 'UPDATE users_v2 SET tvbox_subscribe_token = $1 WHERE username = $2' + 'UPDATE users SET tvbox_subscribe_token = $1 WHERE username = $2' ) .bind(token, userName) .run(); @@ -1106,7 +1232,7 @@ export class PostgresStorage implements IStorage { try { const result = await this.db .prepare( - 'SELECT username FROM users_v2 WHERE tvbox_subscribe_token = $1' + 'SELECT username FROM users WHERE tvbox_subscribe_token = $1' ) .bind(token) .first(); @@ -2937,6 +3063,8 @@ export class PostgresStorage implements IStorage { notification.metadata ? JSON.stringify(notification.metadata) : null ) .run(); + + await dispatchWebPushNotification(this, userName, notification); } catch (err) { console.error('PostgresStorage.addNotification error:', err); throw err; @@ -3044,7 +3172,7 @@ export class PostgresStorage implements IStorage { requested_by, request_count, status, created_at, updated_at, fulfilled_at, fulfilled_source, fulfilled_id ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16) ` ) .bind( diff --git a/src/lib/redis-base.db.ts b/src/lib/redis-base.db.ts index 33dd528..e123048 100644 --- a/src/lib/redis-base.db.ts +++ b/src/lib/redis-base.db.ts @@ -11,8 +11,9 @@ import { MusicV2PlaylistRecord, } from './music-v2'; import { RedisAdapter } from './redis-adapter'; -import { Favorite, IStorage, PlayRecord, SkipConfig } from './types'; +import { Favorite, IStorage, Notification, PlayRecord, PushSubscriptionRecord, SkipConfig } from './types'; import { userInfoCache } from './user-cache'; +import { dispatchWebPushNotification } from './web-push'; // 搜索历史最大条数 const SEARCH_HISTORY_LIMIT = 20; @@ -2268,6 +2269,8 @@ export abstract class BaseRedisStorage implements IStorage { JSON.stringify(notifications) ) ); + + await dispatchWebPushNotification(this, userName, notification); } async markNotificationAsRead( @@ -2465,6 +2468,106 @@ export abstract class BaseRedisStorage implements IStorage { userInfoCache?.delete(userName); } + + private pushSubscriptionsKey(userName: string): string { + return `u:${userName}:push_subscriptions`; + } + + async upsertPushSubscription( + userName: string, + subscription: PushSubscriptionRecord + ): Promise { + await this.withRetry(() => + this.adapter.hSet( + this.pushSubscriptionsKey(userName), + subscription.id, + JSON.stringify({ ...subscription, username: userName, updatedAt: Date.now() }) + ) + ); + } + + async getEnabledPushSubscriptions(userName: string): Promise { + const all = await this.withRetry(() => + this.adapter.hGetAll(this.pushSubscriptionsKey(userName)) + ); + if (!all || typeof all !== 'object') return []; + + return Object.values(all) + .map((raw) => { + try { + return JSON.parse(raw as string) as PushSubscriptionRecord; + } catch { + return null; + } + }) + .filter((item): item is PushSubscriptionRecord => Boolean(item?.enabled)); + } + + async deletePushSubscriptionByEndpoint(userName: string, endpoint: string): Promise { + const subscriptions = await this.getEnabledPushSubscriptions(userName); + const target = subscriptions.find((item) => item.endpoint === endpoint); + if (!target) return; + await this.withRetry(() => + this.adapter.hDel(this.pushSubscriptionsKey(userName), target.id) + ); + } + + async deletePushSubscriptionsByTokenId(userName: string, tokenId: string): Promise { + const all = await this.withRetry(() => + this.adapter.hGetAll(this.pushSubscriptionsKey(userName)) + ); + if (!all || typeof all !== 'object') return; + + for (const [id, raw] of Object.entries(all)) { + try { + const subscription = JSON.parse(raw as string) as PushSubscriptionRecord; + if (subscription.tokenId === tokenId) { + await this.withRetry(() => + this.adapter.hDel(this.pushSubscriptionsKey(userName), id) + ); + } + } catch { + // ignore malformed record + } + } + } + + async deleteAllPushSubscriptions(userName: string): Promise { + await this.withRetry(() => this.adapter.del(this.pushSubscriptionsKey(userName))); + } + + async updatePushSubscriptionDeliveryStats( + userName: string, + endpoint: string, + success: boolean + ): Promise { + const all = await this.withRetry(() => + this.adapter.hGetAll(this.pushSubscriptionsKey(userName)) + ); + if (!all || typeof all !== 'object') return; + + for (const [id, raw] of Object.entries(all)) { + try { + const subscription = JSON.parse(raw as string) as PushSubscriptionRecord; + if (subscription.endpoint !== endpoint) continue; + const now = Date.now(); + const next = { + ...subscription, + updatedAt: now, + lastSuccessAt: success ? now : subscription.lastSuccessAt || null, + lastFailureAt: success ? subscription.lastFailureAt || null : now, + failureCount: success ? 0 : (subscription.failureCount || 0) + 1, + }; + await this.withRetry(() => + this.adapter.hSet(this.pushSubscriptionsKey(userName), id, JSON.stringify(next)) + ); + return; + } catch { + // ignore malformed record + } + } + } + // ---------- TVBox订阅token相关 ---------- async getTvboxSubscribeToken(userName: string): Promise { // 直接从数据库读取,不使用缓存 diff --git a/src/lib/types.ts b/src/lib/types.ts index 846d5d9..de358d9 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -233,6 +233,26 @@ export interface IStorage { userName: string, enabled: boolean ): Promise; + // Web Push订阅相关 + upsertPushSubscription?( + userName: string, + subscription: PushSubscriptionRecord + ): Promise; + getEnabledPushSubscriptions?(userName: string): Promise; + deletePushSubscriptionByEndpoint?( + userName: string, + endpoint: string + ): Promise; + deletePushSubscriptionsByTokenId?( + userName: string, + tokenId: string + ): Promise; + deleteAllPushSubscriptions?(userName: string): Promise; + updatePushSubscriptionDeliveryStats?( + userName: string, + endpoint: string, + success: boolean + ): Promise; // TVBox订阅token相关 getTvboxSubscribeToken?(userName: string): Promise; @@ -314,6 +334,23 @@ export interface EpisodeFilterConfig { reverseMode?: boolean; // 反向模式:开启后仅显示符合规则的集数 } + +export interface PushSubscriptionRecord { + id: string; + username?: string; + tokenId?: string | null; + endpoint: string; + p256dh: string; + auth: string; + userAgent?: string | null; + enabled: boolean; + createdAt: number; + updatedAt: number; + lastSuccessAt?: number | null; + lastFailureAt?: number | null; + failureCount?: number; +} + // 通知类型枚举 export type NotificationType = | 'favorite_update' // 收藏更新 diff --git a/src/lib/web-push.ts b/src/lib/web-push.ts new file mode 100644 index 0000000..f0d4840 --- /dev/null +++ b/src/lib/web-push.ts @@ -0,0 +1,547 @@ +/* eslint-disable no-console */ + +import crypto from 'crypto'; + +import { HttpsProxyAgent } from 'https-proxy-agent'; +import nodeFetch from 'node-fetch'; + +import { lockManager } from './lock'; +import { IStorage, Notification, PushSubscriptionRecord } from './types'; + +const DEFAULT_TTL_SECONDS = 60 * 60 * 24; +const WEB_PUSH_MAX_RETRIES = 2; +const SUBJECT_ENV = 'WEB_PUSH_SUBJECT'; +const PROXY_ENV = 'WEB_PUSH_PROXY'; +const BASE_URL_ENV = 'WEB_PUSH_BASEURL'; +const VAPID_KEYS_GLOBAL_CONFIG_KEY = 'web_push_vapid_keys'; + +interface VapidKeys { + publicKey: string; + privateKey: string; +} + +export interface WebPushDeliveryResult { + endpointHost: string; + ok: boolean; + status?: number; + error?: string; + removed?: boolean; +} + +export interface WebPushDispatchResult { + configured: boolean; + preferenceEnabled: boolean; + subscriptionCount: number; + deliveries: WebPushDeliveryResult[]; +} + +const globalVapidCacheKey = Symbol.for('__MOONTV_WEB_PUSH_VAPID_KEYS__'); +const globalVapidPromiseKey = Symbol.for('__MOONTV_WEB_PUSH_VAPID_KEYS_PROMISE__'); + +function getCachedVapidKeys(): VapidKeys | null { + return ((globalThis as any)[globalVapidCacheKey] as VapidKeys | undefined) || null; +} + +function setCachedVapidKeys(keys: VapidKeys): void { + (globalThis as any)[globalVapidCacheKey] = keys; +} + +function getCachedVapidKeysPromise(): Promise | null { + return ((globalThis as any)[globalVapidPromiseKey] as Promise | undefined) || null; +} + +function setCachedVapidKeysPromise(promise: Promise | null): void { + if (promise) { + (globalThis as any)[globalVapidPromiseKey] = promise; + } else { + delete (globalThis as any)[globalVapidPromiseKey]; + } +} + +function base64UrlEncode(input: Buffer | Uint8Array | string): string { + const buffer = Buffer.isBuffer(input) ? input : Buffer.from(input); + return buffer + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/g, ''); +} + +function base64UrlDecode(input: string): Buffer { + const normalized = input.replace(/-/g, '+').replace(/_/g, '/'); + const padding = '='.repeat((4 - (normalized.length % 4)) % 4); + return Buffer.from(normalized + padding, 'base64'); +} + +function isCloudflareEnvironment(): boolean { + return process.env.CF_PAGES === '1' || process.env.BUILD_TARGET === 'cloudflare'; +} + +function getWebPushProxy(): string | null { + const proxy = process.env[PROXY_ENV]?.trim(); + return proxy || null; +} + +function normalizeBaseUrl(input: string): string { + return input.trim().replace(/\/+$/, ''); +} + +function getWebPushRequestUrl(endpoint: string): string { + const baseUrl = process.env[BASE_URL_ENV]?.trim(); + if (!baseUrl) return endpoint; + + const endpointUrl = new URL(endpoint); + const normalizedBase = normalizeBaseUrl(baseUrl); + + // 支持自定义转发服务格式: + // - {endpoint}: URL 编码后的完整原始 endpoint,适合放在 query 参数里 + // - {raw_endpoint}: 未编码的完整原始 endpoint,适合路径重写或代理服务自行解析 + if (normalizedBase.includes('{raw_endpoint}')) { + return normalizedBase.replace('{raw_endpoint}', endpoint); + } + if (normalizedBase.includes('{endpoint}')) { + return normalizedBase.replace('{endpoint}', encodeURIComponent(endpoint)); + } + + const base = new URL(normalizedBase); + const basePath = base.pathname.replace(/\/+$/, ''); + const endpointPath = endpointUrl.pathname.startsWith('/') + ? endpointUrl.pathname + : `/${endpointUrl.pathname}`; + + base.pathname = `${basePath}${endpointPath}`.replace(/\/+/g, '/'); + base.search = endpointUrl.search; + return base.toString(); +} + +async function fetchWebPushEndpoint( + endpoint: string, + init: { + method: string; + headers: Record; + body: Buffer; + } +): Promise { + const requestUrl = getWebPushRequestUrl(endpoint); + const proxy = getWebPushProxy(); + + if (isCloudflareEnvironment()) { + if (proxy) { + console.warn('WEB_PUSH_PROXY is ignored in Cloudflare runtime; use WEB_PUSH_BASEURL instead.'); + } + return fetch(requestUrl, init) as Promise; + } + + const fetchOptions: any = { + method: init.method, + headers: init.headers, + body: init.body, + }; + + if (proxy) { + fetchOptions.agent = new HttpsProxyAgent(proxy, { + timeout: 30000, + keepAlive: false, + }); + } + + return nodeFetch(requestUrl, fetchOptions) as unknown as Response; +} + +function generateVapidKeys(): VapidKeys { + const ecdh = crypto.createECDH('prime256v1'); + ecdh.generateKeys(); + + return { + publicKey: base64UrlEncode(ecdh.getPublicKey(undefined, 'uncompressed')), + privateKey: base64UrlEncode(ecdh.getPrivateKey()), + }; +} + +function parseStoredVapidKeys(raw: string | null): VapidKeys | null { + if (!raw) return null; + + try { + const parsed = JSON.parse(raw) as Partial; + if (parsed.publicKey && parsed.privateKey) { + return { publicKey: parsed.publicKey, privateKey: parsed.privateKey }; + } + } catch (error) { + console.error('Failed to parse stored Web Push VAPID keys:', error); + } + + return null; +} + +async function loadOrCreateDatabaseVapidKeys(storage: IStorage): Promise { + if (!storage?.getGlobalValue || !storage?.setGlobalValue) { + throw new Error('当前存储类型不支持保存 Web Push VAPID 密钥'); + } + + const existing = parseStoredVapidKeys( + await storage.getGlobalValue(VAPID_KEYS_GLOBAL_CONFIG_KEY) + ); + if (existing) return existing; + + const release = await lockManager.acquire('web-push-vapid-keys'); + try { + const latest = parseStoredVapidKeys( + await storage.getGlobalValue(VAPID_KEYS_GLOBAL_CONFIG_KEY) + ); + if (latest) return latest; + + const keys = generateVapidKeys(); + await storage.setGlobalValue( + VAPID_KEYS_GLOBAL_CONFIG_KEY, + JSON.stringify(keys) + ); + return keys; + } finally { + release(); + } +} + +export async function getVapidKeys(storage: IStorage): Promise { + const cached = getCachedVapidKeys(); + if (cached) return cached; + + const cachedPromise = getCachedVapidKeysPromise(); + if (cachedPromise) return cachedPromise; + + const promise = loadOrCreateDatabaseVapidKeys(storage) + .then((keys) => { + setCachedVapidKeys(keys); + return keys; + }) + .finally(() => setCachedVapidKeysPromise(null)); + + setCachedVapidKeysPromise(promise); + return promise; +} + +export async function getVapidPublicKey(storage: IStorage): Promise { + try { + return (await getVapidKeys(storage)).publicKey; + } catch (error) { + console.error('Failed to get Web Push VAPID public key:', error); + return null; + } +} + +export async function isWebPushConfigured(storage: IStorage): Promise { + try { + await getVapidKeys(storage); + return true; + } catch (error) { + console.error('Web Push VAPID keys are not configured:', error); + return false; + } +} + +function getVapidSubject(): string { + return process.env[SUBJECT_ENV] || process.env.NEXT_PUBLIC_SITE_URL || 'mailto:admin@example.com'; +} + +function getPublicKeyFromPrivate(privateKeyBase64Url: string): Buffer { + const ecdh = crypto.createECDH('prime256v1'); + ecdh.setPrivateKey(base64UrlDecode(privateKeyBase64Url)); + return ecdh.getPublicKey(undefined, 'uncompressed'); +} + +function createVapidJwt(endpoint: string, privateKeyBase64Url: string): string { + const audience = new URL(endpoint).origin; + const publicKey = getPublicKeyFromPrivate(privateKeyBase64Url); + const x = publicKey.subarray(1, 33); + const y = publicKey.subarray(33, 65); + const d = base64UrlDecode(privateKeyBase64Url); + + const header = { typ: 'JWT', alg: 'ES256' }; + const payload = { + aud: audience, + exp: Math.floor(Date.now() / 1000) + 12 * 60 * 60, + sub: getVapidSubject(), + }; + + const signingInput = `${base64UrlEncode(JSON.stringify(header))}.${base64UrlEncode(JSON.stringify(payload))}`; + const key = crypto.createPrivateKey({ + key: { + kty: 'EC', + crv: 'P-256', + x: base64UrlEncode(x), + y: base64UrlEncode(y), + d: base64UrlEncode(d), + }, + format: 'jwk', + }); + + const signature = crypto.sign('sha256', Buffer.from(signingInput), { + key, + dsaEncoding: 'ieee-p1363', + }); + + return `${signingInput}.${base64UrlEncode(signature)}`; +} + +function hkdf(secret: Buffer, salt: Buffer, info: Buffer | string, length: number): Buffer { + const prk = crypto.createHmac('sha256', salt).update(secret).digest(); + const infoBuffer = Buffer.isBuffer(info) ? info : Buffer.from(info); + const blocks: Buffer[] = []; + let previous = Buffer.alloc(0); + let counter = 1; + + while (Buffer.concat(blocks).length < length) { + previous = crypto + .createHmac('sha256', prk) + .update(Buffer.concat([previous, infoBuffer, Buffer.from([counter])])) + .digest(); + blocks.push(previous); + counter += 1; + } + + return Buffer.concat(blocks).subarray(0, length); +} + +function encryptPayload(payload: string, subscription: PushSubscriptionRecord) { + const receiverPublicKey = base64UrlDecode(subscription.p256dh); + const authSecret = base64UrlDecode(subscription.auth); + const salt = crypto.randomBytes(16); + const localEcdh = crypto.createECDH('prime256v1'); + localEcdh.generateKeys(); + const senderPublicKey = localEcdh.getPublicKey(undefined, 'uncompressed'); + const sharedSecret = localEcdh.computeSecret(receiverPublicKey); + + const keyInfo = Buffer.concat([ + Buffer.from('WebPush: info\0'), + receiverPublicKey, + senderPublicKey, + ]); + const ikm = hkdf(sharedSecret, authSecret, keyInfo, 32); + const cek = hkdf(ikm, salt, 'Content-Encoding: aes128gcm\0', 16); + const nonce = hkdf(ikm, salt, 'Content-Encoding: nonce\0', 12); + + const plaintext = Buffer.concat([Buffer.from(payload), Buffer.from([0x02])]); + const cipher = crypto.createCipheriv('aes-128-gcm', cek, nonce); + const encrypted = Buffer.concat([cipher.update(plaintext), cipher.final()]); + const tag = cipher.getAuthTag(); + const ciphertext = Buffer.concat([encrypted, tag]); + + const recordSize = Buffer.alloc(4); + recordSize.writeUInt32BE(4096, 0); + + const body = Buffer.concat([ + salt, + recordSize, + Buffer.from([senderPublicKey.length]), + senderPublicKey, + ciphertext, + ]); + + return body; +} + +export function getNotificationClickUrl(notification: Notification): string { + const metadata = notification.metadata || {}; + + if (notification.type === 'favorite_update' && metadata.source && metadata.id) { + const title = encodeURIComponent(String(metadata.title || '')); + return `/play?source=${encodeURIComponent(String(metadata.source))}&id=${encodeURIComponent(String(metadata.id))}&title=${title}`; + } + + if (notification.type === 'manga_update' && metadata.sourceId && metadata.mangaId) { + const params = new URLSearchParams({ + sourceId: String(metadata.sourceId), + mangaId: String(metadata.mangaId), + title: String(metadata.title || ''), + cover: String(metadata.cover || ''), + sourceName: String(metadata.sourceName || ''), + }); + return `/manga/detail?${params.toString()}`; + } + + if (notification.type === 'movie_request') { + return '/admin'; + } + + if (notification.type === 'request_fulfilled') { + if (metadata.source && metadata.id) { + return `/play?source=${encodeURIComponent(String(metadata.source))}&id=${encodeURIComponent(String(metadata.id))}&title=${encodeURIComponent(notification.title)}`; + } + return '/movie-request'; + } + + if (notification.type === 'anime_subscription_update') { + return '/private-library'; + } + + return '/'; +} + +function buildPayload(notification: Notification): string { + return JSON.stringify({ + notificationId: notification.id, + type: notification.type, + title: notification.title, + message: notification.message, + body: notification.message, + url: getNotificationClickUrl(notification), + timestamp: notification.timestamp, + }); +} + +async function sendToSubscription(storage: IStorage, subscription: PushSubscriptionRecord, payload: string): Promise { + const keys = await getVapidKeys(storage); + + const jwt = createVapidJwt(subscription.endpoint, keys.privateKey); + const encryptedBody = encryptPayload(payload, subscription); + + return fetchWebPushEndpoint(subscription.endpoint, { + method: 'POST', + headers: { + Authorization: `vapid t=${jwt}, k=${keys.publicKey}`, + 'Content-Encoding': 'aes128gcm', + 'Content-Type': 'application/octet-stream', + TTL: String(DEFAULT_TTL_SECONDS), + Urgency: 'normal', + }, + body: encryptedBody, + }); +} + +function shouldRetryWebPushResponse(response: Response): boolean { + if (response.status === 404 || response.status === 410) return false; + return !response.ok; +} + +async function sendToSubscriptionWithRetry( + storage: IStorage, + subscription: PushSubscriptionRecord, + payload: string +): Promise { + let lastError: unknown = null; + + for (let attempt = 0; attempt <= WEB_PUSH_MAX_RETRIES; attempt++) { + try { + const response = await sendToSubscription(storage, subscription, payload); + if (!shouldRetryWebPushResponse(response) || attempt === WEB_PUSH_MAX_RETRIES) { + return response; + } + + console.warn( + `Web Push send failed with ${response.status}, retrying (${attempt + 1}/${WEB_PUSH_MAX_RETRIES})...` + ); + } catch (error) { + lastError = error; + if (attempt === WEB_PUSH_MAX_RETRIES) break; + console.warn( + `Web Push send error, retrying (${attempt + 1}/${WEB_PUSH_MAX_RETRIES}):`, + error + ); + } + } + + throw lastError instanceof Error ? lastError : new Error(String(lastError || 'Web Push send failed')); +} + +export async function dispatchWebPushNotificationWithResult( + storage: IStorage, + userName: string, + notification: Notification +): Promise { + const configured = await isWebPushConfigured(storage); + if (!configured || !storage.getEnabledPushSubscriptions) { + return { + configured, + preferenceEnabled: false, + subscriptionCount: 0, + deliveries: [], + }; + } + + const subscriptions = await storage.getEnabledPushSubscriptions(userName); + if (!subscriptions.length) { + return { + configured, + preferenceEnabled: true, + subscriptionCount: 0, + deliveries: [], + }; + } + + const payload = buildPayload(notification); + const settled = await Promise.allSettled( + subscriptions.map(async (subscription): Promise => { + const endpointHost = new URL(subscription.endpoint).host; + try { + const response = await sendToSubscriptionWithRetry(storage, subscription, payload); + + if (response.ok) { + await storage.updatePushSubscriptionDeliveryStats?.(userName, subscription.endpoint, true); + return { endpointHost, ok: true, status: response.status }; + } + + if (response.status === 404 || response.status === 410) { + await storage.deletePushSubscriptionByEndpoint?.(userName, subscription.endpoint); + return { endpointHost, ok: false, status: response.status, removed: true }; + } + + await storage.updatePushSubscriptionDeliveryStats?.(userName, subscription.endpoint, false); + const errorText = await response.text().catch(() => ''); + console.warn(`Web Push failed (${response.status}) for ${userName}: ${errorText}`); + return { endpointHost, ok: false, status: response.status, error: errorText || response.statusText }; + } catch (error) { + await storage.updatePushSubscriptionDeliveryStats?.(userName, subscription.endpoint, false); + console.error('Web Push delivery error:', error); + return { + endpointHost, + ok: false, + error: error instanceof Error ? error.message : String(error), + }; + } + }) + ); + + return { + configured, + preferenceEnabled: true, + subscriptionCount: subscriptions.length, + deliveries: settled.map((item) => + item.status === 'fulfilled' + ? item.value + : { endpointHost: 'unknown', ok: false, error: item.reason instanceof Error ? item.reason.message : String(item.reason) } + ), + }; +} + +export async function dispatchWebPushNotification( + storage: IStorage, + userName: string, + notification: Notification +): Promise { + await dispatchWebPushNotificationWithResult(storage, userName, notification); +} + +export function createPushSubscriptionRecord(input: { + username: string; + tokenId?: string | null; + endpoint: string; + p256dh: string; + auth: string; + userAgent?: string | null; +}): PushSubscriptionRecord { + const now = Date.now(); + return { + id: base64UrlEncode(crypto.createHash('sha256').update(input.endpoint).digest()), + username: input.username, + tokenId: input.tokenId || null, + endpoint: input.endpoint, + p256dh: input.p256dh, + auth: input.auth, + userAgent: input.userAgent || null, + enabled: true, + createdAt: now, + updatedAt: now, + lastSuccessAt: null, + lastFailureAt: null, + failureCount: 0, + }; +}