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
|
PORT=3000
|
||||||
DB_PATH=./data/events.sqlite
|
DB_PATH=./data/events.sqlite
|
||||||
|
ADMIN_TOKEN=
|
||||||
|
|
||||||
# Apprise API service
|
# Apprise API service
|
||||||
apprise_host=apprise
|
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)
|
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`.
|
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.
|
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 /health` — service and polling status
|
||||||
- `GET /api/events` — latest active events 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
|
- `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
|
timeout: 3s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
|
||||||
calendar-service:
|
77thevcalntfysrv:
|
||||||
build: .
|
build: .
|
||||||
container_name: 77th-ev-cal-ntfy-srv
|
container_name: 77thEvCalNtfySrv
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
env_file:
|
env_file:
|
||||||
- path: .env
|
- path: .env
|
||||||
@@ -32,10 +32,10 @@ services:
|
|||||||
- apprise
|
- apprise
|
||||||
links:
|
links:
|
||||||
- apprise
|
- apprise
|
||||||
ports:
|
# ports:
|
||||||
- "${HOST_PORT:-3000}:3000"
|
# - "${HOST_PORT:-3000}:3000"
|
||||||
volumes:
|
volumes:
|
||||||
- event-data:/app/data
|
- ./data:/app/data
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
event-data:
|
event-data:
|
||||||
+49
-1
@@ -3,6 +3,9 @@ import { getEvents, getPollStatus, refresh, startPolling } from './poller.js';
|
|||||||
import type { StoredCalendarEvent } from './types.js';
|
import type { StoredCalendarEvent } from './types.js';
|
||||||
import { getCalendarEntries } from './storage.js';
|
import { getCalendarEntries } from './storage.js';
|
||||||
import { formatEventDetails } from './eventText.js';
|
import { formatEventDetails } from './eventText.js';
|
||||||
|
import { sendNotification } from './notifications.js';
|
||||||
|
|
||||||
|
import { timingSafeEqual } from 'node:crypto';
|
||||||
|
|
||||||
const port = Number(process.env.PORT ?? 3000);
|
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));
|
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 {
|
function escapeIcs(value: string): string {
|
||||||
return value.replace(/\\/g, '\\\\').replace(/\r?\n/g, '\\n').replace(/,/g, '\\,').replace(/;/g, '\\;');
|
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() });
|
sendJson(response, 200, { events: getEvents(), ...getPollStatus() });
|
||||||
return;
|
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();
|
await refresh();
|
||||||
const status = getPollStatus();
|
const status = getPollStatus();
|
||||||
sendJson(response, status.lastError ? 502 : 200, status);
|
sendJson(response, status.lastError ? 502 : 200, status);
|
||||||
return;
|
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') {
|
if ((request.method === 'GET' || request.method === 'HEAD') && url.pathname === '/calendar.ics') {
|
||||||
const body = calendarBody();
|
const body = calendarBody();
|
||||||
response.writeHead(200, {
|
response.writeHead(200, {
|
||||||
@@ -104,6 +144,14 @@ const pollTimer = startPolling();
|
|||||||
server.listen(port, () => {
|
server.listen(port, () => {
|
||||||
console.log(`HTTP server listening on port ${port}`);
|
console.log(`HTTP server listening on port ${port}`);
|
||||||
console.log(`Calendar feed: http://localhost:${port}/calendar.ics`);
|
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 {
|
function shutdown(): void {
|
||||||
|
|||||||
+15
-4
@@ -7,6 +7,8 @@ interface NotificationPayload {
|
|||||||
format: 'markdown';
|
format: 'markdown';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type NotificationDestination = 'ntfy' | 'discord';
|
||||||
|
|
||||||
function queryString(values: Record<string, string | undefined>): string {
|
function queryString(values: Record<string, string | undefined>): string {
|
||||||
const query = new URLSearchParams();
|
const query = new URLSearchParams();
|
||||||
for (const [key, value] of Object.entries(values)) {
|
for (const [key, value] of Object.entries(values)) {
|
||||||
@@ -65,14 +67,19 @@ function appriseEndpoint(): string {
|
|||||||
return `${protocol}://${host}:${port}/notify`;
|
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') {
|
if (process.env.notification_mock === 'true') {
|
||||||
console.info('[notification mock]', notification.title, notification.body);
|
console.info('[notification mock]', notification.title, notification.body);
|
||||||
return;
|
return 'mocked';
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload: NotificationPayload = {
|
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,
|
title: notification.title,
|
||||||
body: notification.body,
|
body: notification.body,
|
||||||
format: 'markdown',
|
format: 'markdown',
|
||||||
@@ -83,6 +90,10 @@ export async function sendNotification(notification: PendingNotification): Promi
|
|||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
signal: AbortSignal.timeout(20_000),
|
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