增加浏览器离线通知功能

This commit is contained in:
mtvpls
2026-06-02 20:50:33 +08:00
parent 16e48bef8b
commit d600317ade
20 changed files with 1739 additions and 91 deletions
+5
View File
@@ -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
View File
@@ -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`);
}
}
+3
View File
@@ -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);
}
+111
View File
@@ -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 });
}
+7 -5
View File
@@ -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 }
+165 -55
View File
@@ -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>
+4 -4
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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> {
// 直接从数据库读取,不使用缓存
+37
View File
@@ -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' // 收藏更新
+547
View File
@@ -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,
};
}