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

+1
View File
@@ -1,5 +1,6 @@
PORT=3000
DB_PATH=./data/events.sqlite
ADMIN_TOKEN=
# Apprise API service
apprise_host=apprise
+12 -1
View File
@@ -25,6 +25,8 @@ Copy `.env.example` to `.env` and set the Apprise host, ntfy host/topic, and Dis
The service posts a JSON payload to Apprise `/notify` with both the ntfy and Discord URLs. Apprise supports multiple destination URLs in one request. [Apprise API](https://github.com/caronc/apprise-api/blob/master/README.md)
Set `ADMIN_TOKEN` to a long random secret to enable the protected admin endpoints. Authenticate with an `Authorization: Bearer <token>` header. If the header is absent, `?token=<token>` is also accepted. A present but invalid header is rejected without falling back to the query parameter. Query parameters may be recorded in browser history and server/proxy logs, so the header is preferred. If `ADMIN_TOKEN` is unset, admin endpoints return `503`.
A `.env` file is optional at startup; without one, the server still runs, but notification sends will remain queued until the notification settings are configured. `PORT` defaults to `3000`; `DB_PATH` defaults to `./data/events.sqlite`.
For production, run `npm run build` followed by `npm start`, and keep the process running with your preferred process manager or service host.
@@ -54,5 +56,14 @@ docker run -d --name 77th-ev-cal-ntfy-srv --restart unless-stopped \
- `GET /health` — service and polling status
- `GET /api/events` — latest active events and polling status
- `POST /api/refresh` — trigger an immediate poll
- `POST /api/admin/refresh` — trigger an immediate poll (Bearer token required)
- `POST /api/admin/notifications/test` — send a test notification to ntfy and Discord (Bearer token required)
- `GET /calendar.ics` — active events and cancellation entries for calendar subscription
On startup, the service sends a notification to both ntfy and Discord. The test endpoint also sends to both configured destinations. If `notification_mock=true`, notifications are only logged and the test endpoint reports `mocked`.
Example admin request:
```sh
curl -X POST -H "Authorization: Bearer $ADMIN_TOKEN" https://YOUR-HOST/api/admin/notifications/test
```
+5 -5
View File
@@ -15,9 +15,9 @@ services:
timeout: 3s
retries: 5
calendar-service:
77thevcalntfysrv:
build: .
container_name: 77th-ev-cal-ntfy-srv
container_name: 77thEvCalNtfySrv
restart: unless-stopped
env_file:
- path: .env
@@ -32,10 +32,10 @@ services:
- apprise
links:
- apprise
ports:
- "${HOST_PORT:-3000}:3000"
# ports:
# - "${HOST_PORT:-3000}:3000"
volumes:
- event-data:/app/data
- ./data:/app/data
volumes:
event-data:
+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';
}