added Admin Token for some Endpoints. Moved some Endpoints to /api/admin. Added a Notification Test Endpoint
This commit is contained in:
1 parent
ae3384bd71
commit
6fffecd19c
5 files changed
+82
-11
No files matched your search
+49
-1
@@ -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
@@ -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';
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user