Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions app/admin-api/notifications/credential-expiry/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { getAdminSessionErrorResponse } from '@/lib/server/admin/session';
import {
type CredentialExpiryNotificationChannel,
subscribeCredentialExpiryNotifications,
type CredentialExpiryNotificationEvent,
} from '@/lib/server/domain/credential-expiry-notifications';
import { createSseHeaders } from '@/lib/server/shared/sse';

export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';

const encoder = new TextEncoder();

const encodeEvent = (event: CredentialExpiryNotificationEvent): Uint8Array =>
encoder.encode(
`event: credential-expiry\ndata: ${JSON.stringify(event)}\n\n`,
);

export const GET = async (request: Request): Promise<Response> => {
const authError = await getAdminSessionErrorResponse(request);

if (authError) return authError;

const channel = new URL(request.url).searchParams.get('channel');
if (channel !== 'web' && channel !== 'desktop') {
return Response.json(
{ error: 'Notification channel is required' },
{ status: 400 },
);
}

let close = (): void => undefined;
const stream = new ReadableStream<Uint8Array>({
start(controller) {
let closed = false;
const finish = (): void => {
if (closed) return;
closed = true;
clearInterval(heartbeat);
unsubscribe();
request.signal.removeEventListener('abort', finish);
try {
controller.close();
} catch {
return;
}
};
const unsubscribe = subscribeCredentialExpiryNotifications(
channel as CredentialExpiryNotificationChannel,
(event) => {
if (closed) return;
try {
controller.enqueue(encodeEvent(event));
} catch {
finish();
}
},
);
const heartbeat = setInterval(() => {
if (closed) return;
try {
controller.enqueue(encoder.encode(': keep-alive\n\n'));
} catch {
finish();
}
}, 25_000);
close = finish;
request.signal.addEventListener('abort', finish);
controller.enqueue(encoder.encode(': connected\n\n'));
},
cancel() {
close();
},
});

return new Response(stream, { headers: createSseHeaders() });
};
34 changes: 34 additions & 0 deletions app/admin-api/notifications/credential-expiry/test/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { getAdminSessionErrorResponse } from '@/lib/server/admin/session';
import { publishCredentialExpiryNotification } from '@/lib/server/domain/credential-expiry-notifications';
import { getJsonBody } from '@/lib/server/shared/http';

export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';

export const POST = async (request: Request): Promise<Response> => {
const authError = await getAdminSessionErrorResponse(request);

if (authError) return authError;

const body = await getJsonBody<{ channel?: unknown }>(request);
const channel = body.channel;
if (channel !== 'web' && channel !== 'desktop') {
return Response.json(
{ error: 'Notification channel is required' },
{ status: 400 },
);
}

const delivered = publishCredentialExpiryNotification(channel, {
body:
channel === 'web'
? '这是一条 CodeBuddy2API Web 端测试通知。'
: '这是一条 CodeBuddy2API 桌面端测试通知。',
credential: 'test',
expiresAt: Date.now() / 1000 + 3 * 24 * 60 * 60,
key: 'three_days',
title: 'CodeBuddy2API 凭据提醒测试',
});

return Response.json({ delivered, message: 'Test notification sent.' });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Report failed test notifications when no subscriber is connected

When the page's EventSource has not connected yet, has been closed, or the browser does not support EventSource, publishCredentialExpiryNotification returns 0, but this route still responds with HTTP 200 and the settings UI treats any 2xx response as success. In those cases the user is told that the test notification was sent even though no notification was delivered; return a failure status (or have the client inspect delivered) when the count is zero.

Useful? React with 👍 / 👎.

};
60 changes: 60 additions & 0 deletions app/page-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,11 @@ interface JsonResult<T> {
status: number;
}

interface CredentialExpiryNotificationEvent {
body?: string;
title?: string;
}

const requestJson = async <T,>(
input: RequestInfo | URL,
init?: RequestInit,
Expand Down Expand Up @@ -404,6 +409,61 @@ const AdminPageLayoutContent = ({
[],
);

useEffect(() => {
if (typeof window === 'undefined' || !('EventSource' in window)) {
return;
}

const desktopBridge = (
window as unknown as {
desktop?: {
notify?: (nextTitle: string, nextBody: string) => Promise<void>;
};
}
).desktop;
const desktopApp = desktop || Boolean(desktopBridge?.notify);
const source = new EventSource(
`/admin-api/notifications/credential-expiry?channel=${desktopApp ? 'desktop' : 'web'}`,
);
const handleEvent = (event: Event): void => {
const messageEvent = event as MessageEvent<string>;
let payload: CredentialExpiryNotificationEvent;

try {
payload = JSON.parse(
messageEvent.data,
) as CredentialExpiryNotificationEvent;
} catch {
return;
}

const body = payload.body?.trim();
const title = payload.title?.trim();

if (!body || !title) return;

if (desktopApp) {
if (desktopBridge?.notify) {
void desktopBridge.notify(title, body);
}
} else if (
'Notification' in window &&
Notification.permission === 'granted'
) {
new Notification(title, { body });
} else {
showNotification('warning', body);
}
};

source.addEventListener('credential-expiry', handleEvent);

return () => {
source.removeEventListener('credential-expiry', handleEvent);
source.close();
};
}, [desktop, showNotification]);

/*
Nothing is dropped into the console. A file or an image dropped on the
window navigates it to that file, or lands in one of the fields it is
Expand Down
Loading
Loading