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
@@ -1,5 +1,6 @@
|
||||
PORT=3000
|
||||
DB_PATH=./data/events.sqlite
|
||||
ADMIN_TOKEN=
|
||||
|
||||
# Apprise API service
|
||||
apprise_host=apprise
|
||||
|
||||
@@ -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
@@ -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
@@ -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