增加浏览器离线通知功能
This commit is contained in:
@@ -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 });
|
||||
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 (
|
||||
<button
|
||||
type='button'
|
||||
role='switch'
|
||||
aria-checked={checked}
|
||||
aria-label={label}
|
||||
onClick={onChange}
|
||||
disabled={disabled}
|
||||
className={`relative inline-flex h-7 w-12 shrink-0 items-center rounded-full transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-white disabled:cursor-not-allowed disabled:opacity-50 dark:focus:ring-offset-gray-900 ${
|
||||
checked ? 'bg-blue-600' : 'bg-gray-200 dark:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-flex h-5 w-5 transform items-center justify-center rounded-full bg-white shadow-sm transition-transform duration-200 ${
|
||||
checked ? 'translate-x-6' : 'translate-x-1'
|
||||
}`}
|
||||
>
|
||||
{busy ? (
|
||||
<span className='h-3 w-3 animate-spin rounded-full border-2 border-blue-600 border-t-transparent' />
|
||||
) : null}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
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(
|
||||
<>
|
||||
<div
|
||||
className='fixed inset-0 bg-black/50 backdrop-blur-sm z-[1000]'
|
||||
className='fixed inset-0 z-[1000] bg-black/50 backdrop-blur-sm'
|
||||
onClick={onClose}
|
||||
onTouchMove={(e) => e.preventDefault()}
|
||||
onWheel={(e) => e.preventDefault()}
|
||||
style={{ touchAction: 'none' }}
|
||||
/>
|
||||
|
||||
<div className='fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-full max-w-md bg-white dark:bg-gray-900 rounded-xl shadow-xl z-[1001] overflow-hidden'>
|
||||
<div className='fixed left-1/2 top-1/2 z-[1001] w-[calc(100%-2rem)] max-w-lg -translate-x-1/2 -translate-y-1/2 overflow-hidden rounded-2xl bg-white shadow-xl dark:bg-gray-900'>
|
||||
<div
|
||||
className='h-full p-6'
|
||||
className='max-h-[85vh] overflow-y-auto p-6'
|
||||
data-panel-content
|
||||
onTouchMove={(e) => e.stopPropagation()}
|
||||
style={{ touchAction: 'auto' }}
|
||||
>
|
||||
<div className='flex items-center justify-between mb-6'>
|
||||
<h3 className='text-xl font-bold text-gray-800 dark:text-gray-200'>
|
||||
邮件通知设置
|
||||
</h3>
|
||||
<div className='mb-6 flex items-start justify-between gap-4'>
|
||||
<div>
|
||||
<div className='mb-2 inline-flex h-10 w-10 items-center justify-center rounded-xl bg-blue-100 text-blue-600 dark:bg-blue-900/30 dark:text-blue-300'>
|
||||
<Bell className='h-5 w-5' />
|
||||
</div>
|
||||
<h3 className='text-xl font-bold text-gray-900 dark:text-gray-100'>
|
||||
通知设置
|
||||
</h3>
|
||||
<p className='mt-1 text-sm text-gray-500 dark:text-gray-400'>
|
||||
管理邮件通知和当前设备浏览器系统通知。
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className='w-8 h-8 p-1 rounded-full flex items-center justify-center text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors'
|
||||
aria-label='Close'
|
||||
className='flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-gray-500 transition-colors hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:hover:bg-gray-800'
|
||||
aria-label='关闭通知设置'
|
||||
>
|
||||
<X className='w-full h-full' />
|
||||
<X className='h-5 w-5' />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{emailSettingsLoading ? (
|
||||
<div className='space-y-4'>
|
||||
<div className='animate-pulse'>
|
||||
<div className='h-4 bg-gray-200 dark:bg-gray-700 rounded w-20 mb-2'></div>
|
||||
<div className='h-10 bg-gray-200 dark:bg-gray-700 rounded'></div>
|
||||
<div className='space-y-4' aria-live='polite'>
|
||||
<div className='animate-pulse rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-800'>
|
||||
<div className='mb-3 h-5 w-28 rounded bg-gray-200 dark:bg-gray-700' />
|
||||
<div className='h-10 rounded bg-gray-200 dark:bg-gray-700' />
|
||||
</div>
|
||||
<div className='animate-pulse'>
|
||||
<div className='h-20 bg-gray-200 dark:bg-gray-700 rounded'></div>
|
||||
<div className='animate-pulse rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-800'>
|
||||
<div className='mb-3 h-5 w-32 rounded bg-gray-200 dark:bg-gray-700' />
|
||||
<div className='h-16 rounded bg-gray-200 dark:bg-gray-700' />
|
||||
</div>
|
||||
<div className='animate-pulse'>
|
||||
<div className='h-10 bg-gray-200 dark:bg-gray-700 rounded'></div>
|
||||
</div>
|
||||
<div className='text-center text-sm text-gray-500 dark:text-gray-400'>
|
||||
<p className='text-center text-sm text-gray-500 dark:text-gray-400'>
|
||||
加载中...
|
||||
</div>
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className='space-y-4'>
|
||||
<div>
|
||||
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
||||
<section className='rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-800'>
|
||||
<div className='mb-4 flex items-start gap-3'>
|
||||
<div className='flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-sky-100 text-sky-600 dark:bg-sky-900/30 dark:text-sky-300'>
|
||||
<Mail className='h-5 w-5' />
|
||||
</div>
|
||||
<div className='min-w-0 flex-1'>
|
||||
<h4 className='text-base font-semibold text-gray-900 dark:text-gray-100'>
|
||||
邮件通知
|
||||
</h4>
|
||||
<p className='mt-1 text-sm text-gray-500 dark:text-gray-400'>
|
||||
用于接收收藏影视更新等异步提醒,可独立于系统通知关闭。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'>
|
||||
邮箱地址
|
||||
</label>
|
||||
<input
|
||||
@@ -92,52 +165,88 @@ export function EmailSettingsPanel({
|
||||
onChange={(e) => onUserEmailChange(e.target.value)}
|
||||
placeholder='输入您的邮箱地址'
|
||||
disabled={emailSettingsSaving}
|
||||
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed'
|
||||
className='mb-4 w-full rounded-xl border border-gray-300 bg-white px-3 py-2.5 text-sm text-gray-900 transition-colors placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-600 dark:bg-gray-900 dark:text-white'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-800 rounded-lg'>
|
||||
<div>
|
||||
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300'>
|
||||
接收收藏更新通知
|
||||
</h4>
|
||||
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
|
||||
当收藏的影片有更新时发送邮件通知
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onEmailNotificationsChange(!emailNotifications)}
|
||||
disabled={emailSettingsSaving}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${
|
||||
emailNotifications ? 'bg-blue-600' : 'bg-gray-200 dark:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||
emailNotifications ? 'translate-x-6' : 'translate-x-1'
|
||||
}`}
|
||||
<div className='flex items-center justify-between gap-4 rounded-xl bg-white p-3 dark:bg-gray-900/70'>
|
||||
<div>
|
||||
<h5 className='text-sm font-medium text-gray-800 dark:text-gray-200'>
|
||||
收藏更新邮件
|
||||
</h5>
|
||||
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
|
||||
当收藏的影片有更新时发送邮件通知。
|
||||
</p>
|
||||
</div>
|
||||
<Toggle
|
||||
checked={emailNotifications}
|
||||
disabled={emailSettingsSaving}
|
||||
label='切换收藏更新邮件通知'
|
||||
onChange={() => onEmailNotificationsChange(!emailNotifications)}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className='rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-800'>
|
||||
<div className='mb-4 flex items-start gap-3'>
|
||||
<div className='flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-indigo-100 text-indigo-600 dark:bg-indigo-900/30 dark:text-indigo-300'>
|
||||
<MonitorSmartphone className='h-5 w-5' />
|
||||
</div>
|
||||
<div className='min-w-0 flex-1'>
|
||||
<h4 className='text-base font-semibold text-gray-900 dark:text-gray-100'>
|
||||
当前设备浏览器系统通知
|
||||
</h4>
|
||||
<p className='mt-1 text-sm text-gray-500 dark:text-gray-400'>
|
||||
当前设备收到站内通知时,通过浏览器推送到系统通知中心。
|
||||
</p>
|
||||
</div>
|
||||
<Toggle
|
||||
checked={pushNotifications}
|
||||
disabled={pushDisabled}
|
||||
busy={pushNotificationsBusy}
|
||||
label='切换当前设备浏览器系统通知'
|
||||
onChange={() => onPushNotificationsChange(!pushNotifications)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='space-y-2 rounded-xl bg-white p-3 dark:bg-gray-900/70'>
|
||||
<div className='flex items-center justify-between gap-3 text-sm'>
|
||||
<span className='text-gray-600 dark:text-gray-400'>当前设备</span>
|
||||
<span className={`font-medium ${pushNotificationsSupported ? 'text-green-600 dark:text-green-400' : 'text-amber-600 dark:text-amber-400'}`}>
|
||||
{pushNotificationsSupported ? '可用' : '需支持或授权'}
|
||||
</span>
|
||||
</div>
|
||||
{!pushNotificationsConfigured && (
|
||||
<p className='text-xs text-amber-600 dark:text-amber-400' role='alert'>
|
||||
系统正在初始化 Web Push 密钥,请稍后重试。
|
||||
</p>
|
||||
)}
|
||||
{pushNotificationsConfigured && !pushNotificationsSupported && (
|
||||
<p className='text-xs text-amber-600 dark:text-amber-400' role='alert'>
|
||||
当前浏览器、系统权限或登录模式暂不支持系统通知。
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<button
|
||||
onClick={onSave}
|
||||
disabled={emailSettingsSaving}
|
||||
className='w-full px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 dark:disabled:bg-blue-500 text-white text-sm font-medium rounded-lg transition-colors disabled:cursor-not-allowed flex items-center justify-center gap-2'
|
||||
className='flex w-full items-center justify-center gap-2 rounded-xl bg-blue-600 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:bg-blue-400 dark:focus:ring-offset-gray-900 dark:disabled:bg-blue-500'
|
||||
>
|
||||
{emailSettingsSaving ? (
|
||||
<>
|
||||
<div className='w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin'></div>
|
||||
<span className='h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent' />
|
||||
<span>保存中...</span>
|
||||
</>
|
||||
) : (
|
||||
'保存设置'
|
||||
'保存通知设置'
|
||||
)}
|
||||
</button>
|
||||
|
||||
{statusMessage ? (
|
||||
<p
|
||||
className={`text-xs text-center ${
|
||||
role={statusType === 'error' ? 'alert' : 'status'}
|
||||
className={`text-center text-xs ${
|
||||
statusType === 'success'
|
||||
? 'text-green-600 dark:text-green-400'
|
||||
: 'text-red-600 dark:text-red-400'
|
||||
@@ -149,9 +258,10 @@ export function EmailSettingsPanel({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='mt-6 p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg'>
|
||||
<p className='text-xs text-blue-800 dark:text-blue-200'>
|
||||
💡 提示:需要管理员先在管理面板中配置邮件服务
|
||||
<div className='mt-6 flex gap-2 rounded-xl border border-blue-200 bg-blue-50 p-3 dark:border-blue-800 dark:bg-blue-900/20'>
|
||||
<Info className='mt-0.5 h-4 w-4 shrink-0 text-blue-600 dark:text-blue-300' />
|
||||
<p className='text-xs leading-5 text-blue-800 dark:text-blue-200'>
|
||||
邮件通知需要管理员配置邮件服务;当前设备浏览器系统通知需要当前浏览器授权通知权限。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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'
|
||||
>
|
||||
<div className='flex h-12 w-12 items-center justify-center rounded-xl bg-blue-100 text-blue-600 dark:bg-blue-900/30 dark:text-blue-300'>
|
||||
<Mail className='w-6 h-6' />
|
||||
<Bell className='w-6 h-6' />
|
||||
</div>
|
||||
<div>
|
||||
<div className='text-base font-semibold text-gray-900 dark:text-gray-100'>
|
||||
邮件通知设置
|
||||
通知设置
|
||||
</div>
|
||||
<div className='mt-1 text-sm text-gray-500 dark:text-gray-400'>
|
||||
管理接收收藏更新通知的邮箱和开关
|
||||
管理邮件通知和浏览器系统通知
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
+207
-5
@@ -213,9 +213,13 @@ export const UserMenu: React.FC = () => {
|
||||
);
|
||||
const [filesystemSavePath, setFilesystemSavePath] = useState<string>('');
|
||||
|
||||
// 邮件通知设置
|
||||
// 通知设置
|
||||
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<void>((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}
|
||||
|
||||
+133
-1
@@ -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<void> {
|
||||
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<PushSubscriptionRecord[]> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<string | null> {
|
||||
@@ -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(
|
||||
|
||||
+132
-4
@@ -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<void> {
|
||||
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<PushSubscriptionRecord[]> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<string | null> {
|
||||
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(
|
||||
|
||||
+104
-1
@@ -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<void> {
|
||||
await this.withRetry(() =>
|
||||
this.adapter.hSet(
|
||||
this.pushSubscriptionsKey(userName),
|
||||
subscription.id,
|
||||
JSON.stringify({ ...subscription, username: userName, updatedAt: Date.now() })
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
async getEnabledPushSubscriptions(userName: string): Promise<PushSubscriptionRecord[]> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
await this.withRetry(() => this.adapter.del(this.pushSubscriptionsKey(userName)));
|
||||
}
|
||||
|
||||
async updatePushSubscriptionDeliveryStats(
|
||||
userName: string,
|
||||
endpoint: string,
|
||||
success: boolean
|
||||
): Promise<void> {
|
||||
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<string | null> {
|
||||
// 直接从数据库读取,不使用缓存
|
||||
|
||||
@@ -233,6 +233,26 @@ export interface IStorage {
|
||||
userName: string,
|
||||
enabled: boolean
|
||||
): Promise<void>;
|
||||
// Web Push订阅相关
|
||||
upsertPushSubscription?(
|
||||
userName: string,
|
||||
subscription: PushSubscriptionRecord
|
||||
): Promise<void>;
|
||||
getEnabledPushSubscriptions?(userName: string): Promise<PushSubscriptionRecord[]>;
|
||||
deletePushSubscriptionByEndpoint?(
|
||||
userName: string,
|
||||
endpoint: string
|
||||
): Promise<void>;
|
||||
deletePushSubscriptionsByTokenId?(
|
||||
userName: string,
|
||||
tokenId: string
|
||||
): Promise<void>;
|
||||
deleteAllPushSubscriptions?(userName: string): Promise<void>;
|
||||
updatePushSubscriptionDeliveryStats?(
|
||||
userName: string,
|
||||
endpoint: string,
|
||||
success: boolean
|
||||
): Promise<void>;
|
||||
|
||||
// TVBox订阅token相关
|
||||
getTvboxSubscribeToken?(userName: string): Promise<string | null>;
|
||||
@@ -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' // 收藏更新
|
||||
|
||||
@@ -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<VapidKeys> | null {
|
||||
return ((globalThis as any)[globalVapidPromiseKey] as Promise<VapidKeys> | undefined) || null;
|
||||
}
|
||||
|
||||
function setCachedVapidKeysPromise(promise: Promise<VapidKeys> | 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<string, string>;
|
||||
body: Buffer;
|
||||
}
|
||||
): Promise<Response> {
|
||||
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<Response>;
|
||||
}
|
||||
|
||||
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<VapidKeys>;
|
||||
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<VapidKeys> {
|
||||
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<VapidKeys> {
|
||||
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<string | null> {
|
||||
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<boolean> {
|
||||
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:[email protected]';
|
||||
}
|
||||
|
||||
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<Response> {
|
||||
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<Response> {
|
||||
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<WebPushDispatchResult> {
|
||||
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<WebPushDeliveryResult> => {
|
||||
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<void> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user