diff --git a/.env.example b/.env.example index b618dbe..7fb422f 100644 --- a/.env.example +++ b/.env.example @@ -20,14 +20,6 @@ DEFAULT_BUTTON_NAME=game-button BASIC_AUTH_USERNAME=user12345 BASIC_AUTH_PASSWORD=password -# --- CORS Configuration (Optional but Recommended) --- -# Comma-separated list of allowed origins for requests (e.g., your PWA frontend URL) -# If blank or not set, CORS might block browser requests (like from a setup page). -# Use '*' carefully, preferably list specific domains. -ALLOWED_ORIGINS=https://game-timer.virtonline.eu,http://localhost -ALLOWED_METHODS=POST,GET -ALLOWED_HEADERS=Content-Type,Authorization,button-name,button-battery-level,timestamp - # --- Web Push Retry Configuration (Optional) --- # Number of retries on failure (e.g., DNS issues) NOTIFICATION_MAX_RETRIES=3 diff --git a/README.md b/README.md index 99ce048..e4d0d47 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,6 @@ It's designed to be run as a Docker container and integrated with Traefik v3 for * Integrates with Traefik v3 via Docker labels. * Configurable via environment variables (`.env` file). * Optional Basic Authentication for securing the `/webhook` and `/subscribe` endpoints. -* CORS configuration for allowing requests (needed if your PWA management interface interacts with the `/subscribe` endpoint). ## Prerequisites @@ -61,21 +60,16 @@ It's designed to be run as a Docker container and integrated with Traefik v3 for * `VAPID_PRIVATE_KEY`: The private key generated in step 2. **Keep this secret!** * `VAPID_SUBJECT`: A `mailto:` or `https:` URL identifying you or your application (e.g., `mailto:admin@yourdomain.com`). Used by push services to contact you. * `PORT`: (Default: `3000`) The internal port the Node.js app listens on. Traefik will map to this. - * `SUBSCRIPTIONS_FILE`: (Default: `/app/subscriptions.json`) The path *inside the container* where the button-to-subscription mapping is stored. + * `SUBSCRIPTIONS_FILE`: (Default: `subscriptions.json`) The path *inside the container* where the button-to-subscription mapping is stored. * `DEFAULT_BUTTON_NAME`: (Default: `game-button`) The default button name to use when the `Button-Name` header is not provided in the webhook request. * `BASIC_AUTH_USERNAME`: (Optional) Username for Basic Authentication. If set along with `BASIC_AUTH_PASSWORD`, authentication will be enabled for `/webhook` and `/subscribe`. * `BASIC_AUTH_PASSWORD`: (Optional) Password for Basic Authentication. If set along with `BASIC_AUTH_USERNAME`, authentication will be enabled. - * `ALLOWED_ORIGINS`: Comma-separated list of domains allowed by CORS. Include your PWA's domain if it needs to interact directly (e.g., for setup). Example: `https://my-pwa.com`. - * `ALLOWED_METHODS`: (Default: `POST,OPTIONS`) Standard methods needed. - * `ALLOWED_HEADERS`: (Default: `Content-Type,Authorization`) Standard headers needed. * `NOTIFICATION_MAX_RETRIES`: (Default: `3`) Number of retry attempts for failed push notifications. Must be a number. * `NOTIFICATION_FIRST_RETRY_DELAY_MS`: (Default: `10`) Delay in milliseconds for the first retry attempt. Setting to 0-10ms provides near-immediate first retry for transient DNS issues. Must be a number. * `NOTIFICATION_SUBSEQUENT_RETRY_DELAY_MS`: (Default: `1000`) Base delay in milliseconds for subsequent retries. Each additional retry uses this value with exponential backoff and jitter. Must be a number. * `DNS_TIMEOUT_MS`: (Default: `5000`) DNS resolution timeout in milliseconds. Must be a number. * `HTTP_TIMEOUT_MS`: (Default: `10000`) HTTP request timeout in milliseconds. Must be a number. * `LOG_LEVEL`: (Default: `info`) Controls verbosity of logs. Valid values are `error`, `warn`, `info`, or `debug`. Use `debug` to see detailed header information and other diagnostic messages. - * `TRAEFIK_SERVICE_HOST`: Your public domain for this service (e.g., `webpush.virtonline.eu`). - * `TRAEFIK_CERT_RESOLVER`: The name of your TLS certificate resolver configured in Traefik (e.g., `le`, `myresolver`). 4. **Configure Traefik Labels:** * Copy the example `labels` file: @@ -83,24 +77,6 @@ It's designed to be run as a Docker container and integrated with Traefik v3 for cp labels.example labels ``` -5. **Prepare Subscription Mapping File:** - * Edit the `subscriptions.json` file - * Add entries mapping your Flic button's serial number (as a lowercase string key) to the PWA `PushSubscription` object. - ```json - { - "game": { // <-- Replace with your actual Flic Button name (lowercase recommended) - "endpoint": "https://your_pwa_push_endpoint...", - "expirationTime": null, - "keys": { - "p256dh": "YOUR_PWA_SUBSCRIPTION_P256DH_KEY", - "auth": "YOUR_PWA_SUBSCRIPTION_AUTH_KEY" - } - } - // Add more entries for other buttons if needed - } - ``` - * Ensure this file contains valid JSON. - ## Running the Service 1. **Build the Docker Image:** @@ -113,7 +89,7 @@ It's designed to be run as a Docker container and integrated with Traefik v3 for This command runs the container in detached mode (`-d`), names it, connects it to the `traefik` network, passes environment variables from the `.env` file, applies the Traefik labels from the `labels` file, and mounts the `subscriptions.json` file into the container. ```bash - docker run -d --name flic-webhook-webpush \ + docker run -rm -d --name flic-webhook-webpush \ --network traefik \ --env-file .env \ --label-file labels \ @@ -137,15 +113,19 @@ In your Flic app or Flic Hub SDK interface: 1. Select your Flic button. 2. Add an "Internet Request" action. 3. Fill in the following details: - * Set `GET` method. + * Select the `GET` method. * Set URL with query parameter: `https:///webhook/SingleClick` (Replace `` with your actual service domain, e.g., `webpush.virtonline.eu`). * **If Basic Authentication is enabled:** - * Set the `Username` and `Password` fields to the values from your `BASIC_AUTH_USERNAME` and `BASIC_AUTH_PASSWORD` environment variables. - * **If Basic Authentication is disabled:** - * Leave the `Username` and `Password` fields empty. - * Tap on `Save action`. -4. Repeat for Double Click and/or Hold events, changing the `click_type` parameter accordingly (e.g., `/DoubleClick`). + * Set the Headers: + * Set the `Key` fields to `Authorization`. + * Set the `Value` fields to `Basic `. + * Click `ADD`. + * Tap on `SAVE ACTION`. +4. Repeat for Double Click (i.e., `/DoubleClick`) and Hold (i.e., `/Hold`) events. + The request for the Hold event should look like this: +Flic Button Request + ## API Endpoints * **`POST /subscribe`** @@ -184,13 +164,12 @@ In your Flic app or Flic Hub SDK interface: * `Timestamp`: Timestamp of the button event (sent by the Flic system). * `Button-Battery-Level`: The battery level percentage of the button (sent by the Flic system). * **Push Notification Payload (`data` field):** The service sends a JSON payload within the push notification. The client-side Service Worker can access this data via `event.data.json()`. The structure is: - ```json - { - "action": "SingleClick", // or DoubleClick, Hold - "button": "game-button", // Normalized button name (lowercase) - "timestamp": "2024-03-28T15:00:00.000Z", // ISO 8601 timestamp or current server time - "batteryLevel": 100 // Integer percentage (0-100) or 'N/A' if not provided - } + ```bash + curl -X GET https://webpush.virtonline.eu/webhook/SingleClick \ + -H 'Authorization: Basic cGxheWVyOlNldmVuT2ZOaW5l' \ + -H "Button-Name: Game-button" \ + -H "Timestamp: 2025-03-26T01:10:20Z" \ + -H "Button-Battery-Level: 100" ``` * **Responses:** * `200 OK`: Webhook received, push notification sent successfully. @@ -204,25 +183,14 @@ In your Flic app or Flic Hub SDK interface: Once your service is up and running, you can test the webhook endpoint using curl or any API testing tool. This example assumes Basic Authentication is enabled. -**Note:** Replace ``, ``, ``, and `` with your actual values. The `Button-Name` header is optional and will default to the value of `DEFAULT_BUTTON_NAME` if not provided. +**Note:** Replace ``, `` with your actual values. The `Button-Name` header is optional and will default to the value of `DEFAULT_BUTTON_NAME` if not provided. ```bash -# Generate Base64 credentials (run this once) -# echo -n ':' | base64 - -# Example using generated Base64 string (replace YOUR_BASE64_CREDS) -curl -X GET "https:///webhook/SingleClick" \ - -H "Authorization: Basic YOUR_BASE64_CREDS" \ - -H "Button-Name: " \ - -H "Timestamp: $(date -u +%Y-%m-%dT%H:%M:%SZ)" \ - -H "Button-Battery-Level: 100" \ - -# Example using curl's built-in Basic Auth (-u) -curl -X GET "https:///webhook/SingleClick" \ - -u ":" \ - -H "Button-Name: " \ - -H "Timestamp: $(date -u +%Y-%m-%dT%H:%M:%SZ)" \ - -H "Button-Battery-Level: 100" \ +curl -X GET "https://webpush.virtonline.eu/webhook/SingleClick" \ + -H "Authorization: Basic $(echo -n 'user:password' | base64)" \ + -H "Button-Name: game-button" \ + -H "Timestamp: $(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + -H "Button-Battery-Level: 100" ``` The expected response should be: @@ -232,7 +200,7 @@ The expected response should be: If successful, the above response indicates that: 1. Your webhook endpoint is properly configured -2. The button ID was found in your subscriptions.json file +2. The button name was found in your subscriptions.json file 3. The web push notification was successfully sent to the registered PUSH API endpoint (e.g. https://jmt17.google.com/fcm/send/cf907M...) If you receive a different response, refer to the Troubleshooting section below. diff --git a/images/flic-button-request.png b/images/flic-button-request.png index b7ff5ce..087b4f8 100644 Binary files a/images/flic-button-request.png and b/images/flic-button-request.png differ diff --git a/package-lock.json b/package-lock.json index f0ce88d..4960405 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,6 @@ "version": "1.0.0", "license": "MIT", "dependencies": { - "cors": "^2.8.5", "dotenv": "^16.4.5", "express": "^4.19.2", "web-push": "^3.6.7" @@ -151,18 +150,6 @@ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==" }, - "node_modules/cors": { - "version": "2.8.5", - "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", - "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", - "dependencies": { - "object-assign": "^4", - "vary": "^1" - }, - "engines": { - "node": ">= 0.10" - } - }, "node_modules/debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", @@ -614,14 +601,6 @@ "node": ">= 0.6" } }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", diff --git a/package.json b/package.json index 81120fa..a1e453d 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,6 @@ "author": "Your Name", "license": "MIT", "dependencies": { - "cors": "^2.8.5", "dotenv": "^16.4.5", "express": "^4.19.2", "web-push": "^3.6.7" diff --git a/server.js b/server.js index 0135351..64453c6 100644 --- a/server.js +++ b/server.js @@ -1,6 +1,5 @@ const express = require('express'); const webpush = require('web-push'); -const cors = require('cors'); const fs = require('fs'); const path = require('path'); const dns = require('dns'); // Add DNS module @@ -20,9 +19,6 @@ const basicAuthUsername = process.env.BASIC_AUTH_USERNAME; const basicAuthPassword = process.env.BASIC_AUTH_PASSWORD; // Note: We are NOT adding specific authentication for the /subscribe endpoint in this version. // Consider adding API key or other auth if exposing this publicly. -const allowedOrigins = (process.env.ALLOWED_ORIGINS || "").split(',').map(origin => origin.trim()).filter(origin => origin); -const allowedMethods = (process.env.ALLOWED_METHODS || "POST,OPTIONS,GET").split(',').map(method => method.trim()).filter(method => method); -const allowedHeaders = (process.env.ALLOWED_HEADERS || "Content-Type,Authorization").split(',').map(header => header.trim()).filter(header => header); // Retry configuration for DNS resolution issues const maxRetries = parseInt(process.env.NOTIFICATION_MAX_RETRIES || 3, 10); const subsequentRetryDelay = parseInt(process.env.NOTIFICATION_SUBSEQUENT_RETRY_DELAY_MS || 1000, 10); // 1 second base delay for subsequent retries @@ -104,35 +100,35 @@ async function sendWebPushWithRetry(subscription, payload, retryCount = 0, delay return await webpush.sendNotification(subscription, payload); } catch (error) { // Check if the error is a DNS resolution error that might be temporary - const isDnsError = error.code === 'EAI_AGAIN' || - error.code === 'ENOTFOUND' || + const isDnsError = error.code === 'EAI_AGAIN' || + error.code === 'ENOTFOUND' || error.code === 'ETIMEDOUT'; - + if (isDnsError && retryCount < maxRetries) { // For first retry (retryCount = 0), use minimal delay or no delay const actualDelay = retryCount === 0 ? firstRetryDelay : delay; - + if (retryCount === 0) { logger.info(`DNS resolution failed (${error.code}). Retrying notification immediately or with minimal delay of ${firstRetryDelay}ms (attempt ${retryCount + 1}/${maxRetries})...`); } else { logger.info(`DNS resolution failed (${error.code}). Retrying notification in ${actualDelay}ms (attempt ${retryCount + 1}/${maxRetries})...`); } - + // Wait for the delay (minimal or none for first retry) if (actualDelay > 0) { await new Promise(resolve => setTimeout(resolve, actualDelay)); } - + // Calculate next delay with exponential backoff + jitter // First retry uses subsequentRetryDelay, subsequent retries use exponential increase - const nextDelay = retryCount === 0 ? - subsequentRetryDelay : + const nextDelay = retryCount === 0 ? + subsequentRetryDelay : delay * (1.5 + Math.random() * 0.5); - + // Retry recursively with increased count and delay return sendWebPushWithRetry(subscription, payload, retryCount + 1, nextDelay); } - + // If we've exhausted retries or it's not a DNS error, rethrow throw error; } @@ -182,34 +178,18 @@ loadSubscriptions(); // --- Express App Setup --- const app = express(); -// --- CORS Middleware --- -const corsOptions = { - origin: (origin, callback) => { - if (!origin || allowedOrigins.length === 0 || allowedOrigins.includes(origin)) { - callback(null, true); - } else { - console.warn(`CORS: Blocked origin: ${origin}`); - callback(new Error('Not allowed by CORS')); - } - }, - methods: allowedMethods, - allowedHeaders: allowedHeaders, - optionsSuccessStatus: 204 // For pre-flight requests -}; -app.use(cors(corsOptions)); -// Enable pre-flight requests for all relevant routes -app.options('/webhook', cors(corsOptions)); -app.options('/subscribe', cors(corsOptions)); - - // --- Body Parsing Middleware --- app.use(express.json()); // --- Basic Authentication Middleware --- const authenticateBasic = (req, res, next) => { - // Skip authentication for OPTIONS requests (CORS preflight) + // Skip authentication for OPTIONS requests (CORS preflight - Traefik might still forward them or handle them) + // It's safe to keep this check. if (req.method === 'OPTIONS') { - return next(); + logger.debug('Auth: Skipping auth for OPTIONS request.'); + // Traefik should ideally respond to OPTIONS, but if it forwards, we just proceed without auth check. + // We don't need to send CORS headers here anymore. + return res.sendStatus(204); // Standard practice for preflight response if it reaches the backend } // Skip authentication if username or password are not set in environment @@ -248,8 +228,8 @@ const authenticateBasic = (req, res, next) => { // Apply Basic Authentication app.post('/subscribe', authenticateBasic, async (req, res) => { let { button_id, subscription } = req.body; - - logger.debug('All headers received:'); + + logger.debug('All headers received on /subscribe:'); Object.keys(req.headers).forEach(headerName => { logger.debug(` ${headerName}: ${req.headers[headerName]}`); }); @@ -299,7 +279,7 @@ app.get('/webhook/:click_type', authenticateBasic, async (req, res) => { const batteryLevel = batteryLevelHeader ? parseInt(batteryLevelHeader, 10) || batteryLevelHeader : 'N/A'; // Log all headers received from Flic - logger.debug('All headers received:'); + logger.debug('All headers received on /webhook:'); Object.keys(req.headers).forEach(headerName => { logger.debug(` ${headerName}: ${req.headers[headerName]}`); }); @@ -362,9 +342,7 @@ const server = http.createServer(app); server.listen(port, () => { logger.info(`Flic Webhook to WebPush server listening on port ${port}`); - logger.info(`Allowed Origins: ${allowedOrigins.length > 0 ? allowedOrigins.join(', ') : '(Any)'}`); - logger.info(`Allowed Methods: ${allowedMethods.join(', ')}`); - logger.info(`Allowed Headers: ${allowedHeaders.join(', ')}`); + logger.info('CORS: Handled by Traefik (expected)'); // Log Basic Auth status instead of Flic Secret if (basicAuthUsername && basicAuthPassword) { logger.info('Authentication: Basic Auth Enabled');