diff --git a/.env.example b/.env.example index 456ae94..47c2977 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,6 @@ PORT=3000 DB_PATH=./data/events.sqlite +ADMIN_TOKEN= # Apprise API service apprise_host=apprise diff --git a/README.md b/README.md index ab7058e..5432a60 100644 --- a/README.md +++ b/README.md @@ -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 ` header. If the header is absent, `?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 +``` diff --git a/compose.yaml b/compose.yaml index e4740f6..a75004f 100644 --- a/compose.yaml +++ b/compose.yaml @@ -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: diff --git a/src/index.ts b/src/index.ts index 50aa6e9..36cd397 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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 { diff --git a/src/notifications.ts b/src/notifications.ts index 2259aa0..3f09ea8 100644 --- a/src/notifications.ts +++ b/src/notifications.ts @@ -7,6 +7,8 @@ interface NotificationPayload { format: 'markdown'; } +export type NotificationDestination = 'ntfy' | 'discord'; + function queryString(values: Record): 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 { +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'; }