added Admin Token for some Endpoints. Moved some Endpoints to /api/admin. Added a Notification Test Endpoint

This commit is contained in:
chiko committed 2026-10-09 18:34:50 +02:00
1 parent ae3384bd71
commit 6fffecd19c
5 files changed
+82 -11

No files matched your search

+49 -1
View File
@@ -3,6 +3,9 @@ import { getEvents, getPollStatus, refresh, startPolling } from './poller.js';
import type { StoredCalendarEvent } from './types.js';
import { getCalendarEntries } from './storage.js';
import { formatEventDetails } from './eventText.js';
import { sendNotification } from './notifications.js';
import { timingSafeEqual } from 'node:crypto';
const port = Number(process.env.PORT ?? 3000);
@@ -11,6 +14,27 @@ function sendJson(response: ServerResponse, status: number, body: unknown): void
response.end(JSON.stringify(body));
}
function authorizeAdmin(request: IncomingMessage, response: ServerResponse, queryToken?: string | null): boolean {
const expectedToken = process.env.ADMIN_TOKEN;
if (!expectedToken) {
sendJson(response, 503, { error: 'Admin API is disabled because ADMIN_TOKEN is not configured' });
return false;
}
const authorization = request.headers.authorization;
const match = authorization ? /^Bearer\s+(.+)$/i.exec(authorization) : undefined;
const expected = Buffer.from(expectedToken);
const suppliedToken = authorization ? (match?.[1] ?? '') : (queryToken ?? '');
const supplied = Buffer.from(suppliedToken);
const valid = expected.length === supplied.length && timingSafeEqual(expected, supplied);
if (!valid) {
response.setHeader('www-authenticate', 'Bearer realm="admin-api"');
sendJson(response, 401, { error: 'Unauthorized' });
return false;
}
return true;
}
function escapeIcs(value: string): string {
return value.replace(/\\/g, '\\\\').replace(/\r?\n/g, '\\n').replace(/,/g, '\\,').replace(/;/g, '\\;');
}
@@ -74,12 +98,28 @@ async function handle(request: IncomingMessage, response: ServerResponse): Promi
sendJson(response, 200, { events: getEvents(), ...getPollStatus() });
return;
}
if (request.method === 'POST' && url.pathname === '/api/refresh') {
if (request.method === 'POST' && url.pathname === '/api/admin/refresh') {
if (!authorizeAdmin(request, response, url.searchParams.get('token'))) return;
await refresh();
const status = getPollStatus();
sendJson(response, status.lastError ? 502 : 200, status);
return;
}
if (request.method === 'POST' && url.pathname === '/api/admin/notifications/test') {
if (!authorizeAdmin(request, response, url.searchParams.get('token'))) return;
try {
const result = await sendNotification({
id: 0,
title: 'Test Notification',
body: 'This is a test notification from the 77th EV Calendar service.',
format: 'markdown',
});
sendJson(response, 200, { status: result, destinations: ['ntfy', 'discord'] });
} catch (error) {
sendJson(response, 502, { error: error instanceof Error ? error.message : 'Notification test failed' });
}
return;
}
if ((request.method === 'GET' || request.method === 'HEAD') && url.pathname === '/calendar.ics') {
const body = calendarBody();
response.writeHead(200, {
@@ -104,6 +144,14 @@ const pollTimer = startPolling();
server.listen(port, () => {
console.log(`HTTP server listening on port ${port}`);
console.log(`Calendar feed: http://localhost:${port}/calendar.ics`);
void sendNotification({
id: 0,
title: '77th Event Calendar Started',
body: `The event calendar service started at ${new Date().toISOString()}.`,
format: 'markdown',
}).catch((error: unknown) => {
console.error(`Startup notification failed: ${error instanceof Error ? error.message : String(error)}`);
});
});
function shutdown(): void {
+15 -4
View File
@@ -7,6 +7,8 @@ interface NotificationPayload {
format: 'markdown';
}
export type NotificationDestination = 'ntfy' | 'discord';
function queryString(values: Record<string, string | undefined>): string {
const query = new URLSearchParams();
for (const [key, value] of Object.entries(values)) {
@@ -65,14 +67,19 @@ function appriseEndpoint(): string {
return `${protocol}://${host}:${port}/notify`;
}
export async function sendNotification(notification: PendingNotification): Promise<void> {
export async function sendNotification(
notification: PendingNotification,
destinations: NotificationDestination[] = ['ntfy', 'discord'],
): Promise<'sent' | 'mocked'> {
if (process.env.notification_mock === 'true') {
console.info('[notification mock]', notification.title, notification.body);
return;
return 'mocked';
}
const payload: NotificationPayload = {
urls: [ntfyUrl(notification.link), discordUrl(notification.link)],
urls: destinations.map((destination) => destination === 'ntfy'
? ntfyUrl(notification.link)
: discordUrl(notification.link)),
title: notification.title,
body: notification.body,
format: 'markdown',
@@ -83,6 +90,10 @@ export async function sendNotification(notification: PendingNotification): Promi
body: JSON.stringify(payload),
signal: AbortSignal.timeout(20_000),
});
if (!response.ok) throw new Error(`Apprise returned HTTP ${response.status}`);
if (!response.ok) {
const details = (await response.text()).slice(0, 1000);
throw new Error(`Apprise returned HTTP ${response.status}${details ? `: ${details}` : ''}`);
}
return 'sent';
}