diff --git a/.dockerignore b/.dockerignore
index 8b30b24..06749c9 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -5,4 +5,12 @@ Dockerfile
.git
.gitignore
README.md
-*.example
\ No newline at end of file
+*.example
+images
+.env
+env
+labels
+subscriptions.json
+
+
+
diff --git a/.env.example b/.env.example
index e9cb250..7fb422f 100644
--- a/.env.example
+++ b/.env.example
@@ -1,33 +1,39 @@
-# --- Application Configuration ---
+# Flic to PWA WebPush Configuration
-# --- VAPID Keys (REQUIRED for Web Push) ---
-# Generate these once using npx web-push generate-vapid-keys (or other tools)
-# Keep the private key SECRET!
+# --- VAPID Keys (Required) ---
+# Generate using: npx web-push generate-vapid-keys
VAPID_PUBLIC_KEY=
VAPID_PRIVATE_KEY=
+VAPID_SUBJECT=mailto:mailto:user@example.org
-# Subject claim for VAPID. Use a 'mailto:' URI or an 'https:' URL identifying your application.
-# Example: mailto:admin@yourdomain.com or https://yourdomain.com/contact
-VAPID_SUBJECT=mailto:admin@virtonline.eu
+# --- Server Configuration ---
+# Internal port for the Node.js app
+PORT=3000
+SUBSCRIPTIONS_FILE=/app/subscriptions.json
+DEFAULT_BUTTON_NAME=game-button
-# Flic Button Configuration
-FLIC_BUTTON1_SERIAL=your_button1_serial
-FLIC_BUTTON2_SERIAL=your_button2_serial
-FLIC_BUTTON3_SERIAL=your_button3_serial
+# --- Authentication (Optional) ---
+# If both USERNAME and PASSWORD are set, Basic Auth will be enabled for:
+# - POST /subscribe
+# - GET /webhook
+# Leave blank to disable authentication.
+BASIC_AUTH_USERNAME=user12345
+BASIC_AUTH_PASSWORD=password
-# Subscription Storage
-SUBSCRIPTIONS_FILE=subscriptions.json
+# --- Web Push Retry Configuration (Optional) ---
+# Number of retries on failure (e.g., DNS issues)
+NOTIFICATION_MAX_RETRIES=3
+# First retry delay in milliseconds (minimal delay for immediate retry)
+NOTIFICATION_FIRST_RETRY_DELAY_MS=10
+# Base delay in milliseconds for subsequent retries (used for exponential backoff)
+NOTIFICATION_SUBSEQUENT_RETRY_DELAY_MS=1000
-# CORS
-ALLOWED_ORIGINS=https://game-timer.virtonline.eu
-ALLOWED_METHODS=POST,OPTIONS,GET
-ALLOWED_HEADERS=Content-Type,Authorization
+# --- Network Configuration (Optional) ---
+# Timeout for DNS lookups (ms)
+DNS_TIMEOUT_MS=5000
+# Timeout for outgoing HTTP requests (ms)
+HTTP_TIMEOUT_MS=10000
-# Logging Configuration
-LOG_LEVEL=INFO
-
-# --- Security (Optional) ---
-# If you want to add a simple security layer between Flic and this app.
-# If set, configure Flic's HTTP request to include an "Authorization: Bearer YOUR_SECRET_VALUE" header.
-# use e.g.: openssl rand -hex 32
-FLIC_SECRET=
\ No newline at end of file
+# --- Logging ---
+# Controls log verbosity: error, warn, info, debug
+LOG_LEVEL=info
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index 115d993..7df275b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,5 +1,7 @@
myenv
.vscode
+.cursor
+subscriptions.json
# Node.js
node_modules/
@@ -7,9 +9,7 @@ npm-debug.log*
yarn-debug.log*
yarn-error.log*
.env
-subscriptions.json
-labels
-
+env
# OS generated files
.DS_Store
.DS_Store?
diff --git a/README.md b/README.md
index a9f4b2a..0e60fcb 100644
--- a/README.md
+++ b/README.md
@@ -6,14 +6,15 @@ It's designed to be run as a Docker container and integrated with Traefik v3 for
## Features
-* Receives POST requests on `/flic-webhook`.
-* Parses `button_id` and `click_type` from the Flic request body.
-* Looks up the target PWA push subscription based on `button_id` in a JSON file.
+* Receives GET requests on `/webhook`.
+* Receives POST requests on `/subscribe` to manage button-PWA mappings.
+* Uses HTTP headers `Button-Name` and `Timestamp` from the Flic request.
+* Gets `click_type` from URL path.
+* Looks up the target PWA push subscription based on the `Button-Name` header in a JSON file.
* Sends a Web Push notification containing the click details (action, button, timestamp) to the corresponding PWA subscription.
* Integrates with Traefik v3 via Docker labels.
* Configurable via environment variables (`.env` file).
-* Optional bearer token authentication for securing the Flic webhook endpoint.
-* CORS configuration for allowing requests (needed if your PWA management interface interacts with this service, although not strictly necessary for the Flic->Backend->PWA push flow itself).
+* Optional Basic Authentication for securing the `/webhook` and `/subscribe` endpoints.
## Prerequisites
@@ -24,6 +25,14 @@ It's designed to be run as a Docker container and integrated with Traefik v3 for
* **Node.js & npm/npx (Optional):** Needed only locally to generate VAPID keys easily. Not required for running the container.
* **PWA Push Subscription Details:** You need to obtain the Push Subscription object (containing `endpoint`, `keys.p256dh`, `keys.auth`) from your PWA after the user grants notification permission.
+## System Architecture
+
+### Subscription Flow
+
+
+### Interaction Flow
+
+
## Project Structure
## Setup
@@ -41,23 +50,7 @@ It's designed to be run as a Docker container and integrated with Traefik v3 for
```
This will output a Public Key and a Private Key.
-3. **Obtain PWA Push Subscription Details:**
- * Your PWA needs to use the Push API to request notification permission from the user.
- * When permission is granted, the browser's push service provides a `PushSubscription` object.
- * This object typically looks like:
- ```json
- {
- "endpoint": "https://updates.push.services.mozilla.com/...",
- "expirationTime": null,
- "keys": {
- "p256dh": "...",
- "auth": "..."
- }
- }
- ```
- * You need to get this JSON object from your PWA (e.g., display it to the user to copy, send it to a setup endpoint - though that's more complex).
-
-4. **Configure Environment Variables:**
+3. **Configure Environment Variables:**
* Copy the example `.env` file:
```bash
cp .env.example .env
@@ -67,41 +60,22 @@ 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.
- * `FLIC_SECRET`: (Optional) Set a strong, random secret string if you want to secure the webhook endpoint using Bearer token authentication. Generate with `openssl rand -hex 32` or a password manager.
- * `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.
- * `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`).
+ * `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.
+ * `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.
-5. **Configure Traefik Labels:**
+4. **Configure Traefik Labels:**
* Copy the example `labels` file:
```bash
cp labels.example labels
```
- * **Important:** Edit the `labels` file. Replace `${TRAEFIK_SERVICE_HOST}`, `${TRAEFIK_CERT_RESOLVER}`, and `${PORT}` with the *actual values* from your `.env` file, as `docker run` does not substitute variables in label files.
- * Example replacement: `Host(\`${TRAEFIK_SERVICE_HOST}\`)` becomes `Host(`webpush.virtonline.eu`)`.
- * `traefik.http.routers.flic-webhook.tls.certresolver=${TRAEFIK_CERT_RESOLVER}` becomes `traefik.http.routers.flic-webhook.tls.certresolver=myresolver`.
- * `traefik.http.services.flic-webhook.loadbalancer.server.port=${PORT}` becomes `traefik.http.services.flic-webhook.loadbalancer.server.port=3000`.
-
-6. **Prepare Subscription Mapping File:**
- * Create the `subscriptions.json` file (or edit the template provided).
- * Add entries mapping your Flic button's serial number (as a lowercase string key) to the PWA `PushSubscription` object obtained in step 3.
- ```json
- {
- "80:e4:da:70:xx:xx:xx:xx": { // <-- Replace with your actual Flic Button Serial (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
@@ -115,18 +89,13 @@ 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 \
--mount type=bind,src=./subscriptions.json,dst=/app/subscriptions.json \
flic-webhook-webpush:latest
```
- * `--network traefik`: Connects to the Traefik network.
- * `--env-file .env`: Loads configuration from your `.env` file.
- * `--label-file labels`: Applies the Traefik routing rules from your edited `labels` file.
- * `--mount ...`: Makes your local `subscriptions.json` available inside the container at `/app/subscriptions.json`. `readonly` is recommended as the app only reads it.
- * `flic-webhook-webpush:latest`: The image built in the previous step.
3. **Check Logs:**
Monitor the container logs to ensure it started correctly and to see incoming webhook requests or errors.
@@ -142,62 +111,137 @@ It's designed to be run as a Docker container and integrated with Traefik v3 for
In your Flic app or Flic Hub SDK interface:
1. Select your Flic button.
-2. Add an "Internet Request" action (or similar HTTP request action) for Single Click, Double Click, and/or Hold events.
-3. **URL:** `https:///flic-webhook` (e.g., `https://webpush.virtonline.eu/flic-webhook`)
-4. **Method:** `POST`
-5. **Body Type:** `JSON` (or `application/json`)
-6. **Body:** Configure the JSON body to include the button's serial number and the click type. Flic usually provides variables for these. The backend expects `button_id` and `click_type`. Adapt the keys if needed, or modify `server.js` to expect different keys (e.g., `serialNumber`).
- ```json
- {
- "button_id": "{serialNumber}",
- "click_type": "{clickType}",
- "timestamp": "{timestamp}"
- }
- ```
- *(Verify the exact variable names like `{serialNumber}`, `{clickType}`, `{timestamp}` within your specific Flic interface.)*
-7. **Headers:**
- * Add `Content-Type: application/json`.
- * **(Optional - if `FLIC_SECRET` is set):** Add an `Authorization` header:
- * Key: `Authorization`
- * Value: `Bearer ` (Replace `` with the actual secret from your `.env` file).
+2. Add an "Internet Request" action.
+3. Fill in the following details:
+ * 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 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:
+
+
-## API Endpoint
+## App Example: "HTTP Shortcuts" by waboodoo
-* **`POST /flic-webhook`**
- * **Description:** Receives Flic button events.
- * **Authentication:** Optional Bearer token via `Authorization` header if `FLIC_SECRET` is configured.
- * **Request Body (JSON):**
+Search the Play Store - there might be others with similar names.
+
+1. Install the App: Download and install "HTTP Shortcuts" or a similar app from the Google Play Store.
+2. Create a New Shortcut within the App:
+ * Open the app and usually tap a '+' or 'Add' button.
+ * Give your shortcut a Name (e.g., "Turn on Office Light", "Log Water Intake").
+ * Choose an Icon.
+ * Enter the URL you want the request sent to (your webhook URL, IFTTT URL, Home Assistant webhook trigger, etc.).
+ * Select the HTTP Method (GET, POST, PUT, DELETE, etc. - often GET or POST for simple triggers).
+ * For POST/PUT: You'll likely need to configure the Request Body (e.g., JSON data) and Content Type (e.g., application/json).
+ * Authentication: Configure Basic Auth, Bearer Tokens, or custom Headers if your endpoint requires authentication.
+ * Other Options: Explore settings for response handling (show message on success/failure), timeouts, etc.
+ * Save the shortcut configuration within the app.
+3. Add the Widget/Shortcut to your Home Screen:
+ * Go to your Android Home Screen.
+ * Long-press on an empty space.
+ * Select "Widgets".
+ * Scroll through the list to find the "HTTP Shortcuts" app (or the app you installed).
+ * Drag the app's widget or shortcut option onto your home screen.
+ * The app will likely ask you to choose which specific shortcut (the one you just created) this widget should trigger. Select it.
+4. Test: Tap the newly created button on your home screen. It should trigger the internet request you configured.
+
+## API Endpoints
+
+* **`POST /subscribe`**
+ * **Description:** Adds or updates the Web Push subscription associated with a specific Flic button ID.
+ * **Authentication:** Optional Basic Authentication via `Authorization` header if `BASIC_AUTH_USERNAME` and `BASIC_AUTH_PASSWORD` are configured.
+ * **Request Body:** JSON object containing:
+ * `button_id` (string, optional): The unique identifier for the Flic button (lowercase recommended, e.g., "game-button", "lights-button"). If not provided, the value of `DEFAULT_BUTTON_NAME` environment variable will be used as a fallback.
+ * `subscription` (object, required): The [PushSubscription object](https://developer.mozilla.org/en-US/docs/Web/API/PushSubscription) obtained from the browser's Push API.
```json
{
- "button_id": "SERIAL_NUMBER_OF_FLIC_BUTTON",
- "click_type": "SingleClick | DoubleClick | Hold",
- "timestamp": "ISO_8601_TIMESTAMP_STRING (Optional)"
+ "button_id": "game-button", // Optional, defaults to DEFAULT_BUTTON_NAME environment variable
+ "subscription": {
+ "endpoint": "https://your_pwa_push_endpoint...",
+ "expirationTime": null,
+ "keys": {
+ "p256dh": "YOUR_PWA_SUBSCRIPTION_P256DH_KEY",
+ "auth": "YOUR_PWA_SUBSCRIPTION_AUTH_KEY"
+ }
+ }
}
```
+ * **Responses:**
+ * `201 Created`: Subscription saved successfully.
+ * `400 Bad Request`: Missing or invalid `subscription` object in the request body.
+ * `401 Unauthorized`: Missing or invalid Basic Authentication credentials (if authentication is enabled).
+ * `500 Internal Server Error`: Failed to save the subscription to the file.
+
+* **`GET /webhook/:click_type`**
+ * **Description:** Receives Flic button events.
+ * **Authentication:** Optional Basic Authentication via `Authorization` header if `BASIC_AUTH_USERNAME` and `BASIC_AUTH_PASSWORD` are configured.
+ * **URL Parameters:**
+ * `click_type` (required): The type of button press (e.g., `SingleClick`, `DoubleClick`, or `Hold`).
+ * **Required Headers:**
+ * `Button-Name`: The identifier of the Flic button (sent by the Flic system). If not provided, the value of `DEFAULT_BUTTON_NAME` environment variable will be used as a fallback.
+ * **Optional Headers:**
+ * `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:
+ ```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.
- * `400 Bad Request`: Missing `button_id` or `click_type` in the request body.
- * `401 Unauthorized`: Missing or invalid Bearer token (if `FLIC_SECRET` is enabled).
- * `404 Not Found`: No subscription found in `subscriptions.json` for the given `button_id`.
+ * `400 Bad Request`: Missing `Button-Name` header or `click_type` URL parameter.
+ * `401 Unauthorized`: Missing or invalid Basic Authentication credentials (if authentication is enabled).
+ * `404 Not Found`: No subscription found in `subscriptions.json` for the given `Button-Name`.
* `410 Gone`: The push subscription associated with the button was rejected by the push service (likely expired or revoked).
* `500 Internal Server Error`: Failed to send the push notification for other reasons.
-* **`GET /health`** (Optional)
- * **Description:** Simple health check endpoint.
- * **Response:**
- ```json
- {
- "status": "UP",
- "timestamp": "ISO_8601_TIMESTAMP_STRING"
- }
- ```
+## Testing the Webhook
+
+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 ``, `` with your actual values. The `Button-Name` header is optional and will default to the value of `DEFAULT_BUTTON_NAME` if not provided.
+
+```bash
+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:
+```json
+{"message":"Push notification sent successfully"}
+```
+
+If successful, the above response indicates that:
+1. Your webhook endpoint is properly configured
+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.
## Troubleshooting
* **Check Backend Logs:** `docker logs flic-webhook-webpush`. Look for errors related to configuration, file access, JSON parsing, authentication, or sending push notifications.
+ * To see detailed debug information including all headers received from the Flic button, set `LOG_LEVEL=debug` in your .env file.
* **Check Traefik Logs:** `docker logs traefik`. Look for routing errors or certificate issues.
* **Verify `.env`:** Ensure all required variables are set correctly, especially VAPID keys and Traefik settings.
* **Verify `labels`:** Double-check that variables were correctly substituted manually and match your `.env` and Traefik setup.
* **Verify `subscriptions.json`:** Ensure it's valid JSON and the button serial number (key) matches exactly what Flic sends (check backend logs for "Received webhook: Button=..."). Check if the subscription details are correct. Case sensitivity matters for the JSON keys (button serials).
-* **Check Flic Configuration:** Ensure the URL, Method, Body, and Headers (especially `Content-Type` and `Authorization` if used) are correct in the Flic action setup. Use `curl` or Postman to test the endpoint manually first.
-* **PWA Service Worker:** Remember that the PWA needs a correctly registered Service Worker to receive and handle the incoming push messages. Ensure the PWA subscribes using the *same* `VAPID_PUBLIC_KEY` configured in the backend's `.env`.
\ No newline at end of file
+* **Check Flic Configuration:** Ensure the URL, Method, `click_type` parameter, and authentication details (Username/Password if enabled) are correct in the Flic action setup. Use `curl` or Postman to test the endpoint manually first.
+* **PWA Service Worker:** Remember that the PWA needs a correctly registered Service Worker to receive and handle the incoming push messages. Ensure the PWA subscribes using the *same* `VAPID_PUBLIC_KEY` configured in the backend's `.env`.
+* **Push Notification Retry Mechanism:** The service includes an optimized retry mechanism for handling temporary DNS resolution issues:
+ * First retry happens immediately or with minimal delay (controlled by `NOTIFICATION_FIRST_RETRY_DELAY_MS`, default 10ms)
+ * Subsequent retries use exponential backoff with jitter (starting from `NOTIFICATION_SUBSEQUENT_RETRY_DELAY_MS`, default 1000ms)
+ * Maximum number of retries is controlled by `NOTIFICATION_MAX_RETRIES` (default 3)
+ * This approach minimizes latency for transient DNS issues while preventing excessive requests for persistent problems
+ * Adjust these values in your `.env` file based on your network conditions and reliability requirements
diff --git a/images/flic-button-request.png b/images/flic-button-request.png
new file mode 100644
index 0000000..0f609d9
Binary files /dev/null and b/images/flic-button-request.png differ
diff --git a/images/interaction-flow.mmd b/images/interaction-flow.mmd
new file mode 100644
index 0000000..700d01e
--- /dev/null
+++ b/images/interaction-flow.mmd
@@ -0,0 +1,8 @@
+stateDiagram-v2
+ [*] --> FlicButton : Button Press
+ FlicButton --> Bluetooth : Transmit Signal
+ Bluetooth --> Phone : Receive Signal
+ Phone --> WebhookWebPushService : Send Webhook
+ WebhookWebPushService --> GooglePushAPI : Forward Notification
+ GooglePushAPI --> PWA : Push Notification
+ PWA --> [*] : Handle Notification
\ No newline at end of file
diff --git a/images/interaction-flow.png b/images/interaction-flow.png
new file mode 100644
index 0000000..6141bb9
Binary files /dev/null and b/images/interaction-flow.png differ
diff --git a/images/subscription-flow.mmd b/images/subscription-flow.mmd
new file mode 100644
index 0000000..cca194e
--- /dev/null
+++ b/images/subscription-flow.mmd
@@ -0,0 +1,7 @@
+stateDiagram-v2
+ [*] --> PWA : User Initiates
+ PWA --> GooglePushAPI : Request Subscription
+ GooglePushAPI --> PWA : Return Subscription Info
+ PWA --> WebhookWebPushService : Store Subscription Info
+ WebhookWebPushService --> PWA : Confirmation
+ [*] --> Complete
\ No newline at end of file
diff --git a/images/subscription-flow.png b/images/subscription-flow.png
new file mode 100644
index 0000000..1a9f594
Binary files /dev/null and b/images/subscription-flow.png differ
diff --git a/labels.example b/labels.example
index 88fce42..b01410c 100644
--- a/labels.example
+++ b/labels.example
@@ -1,29 +1,32 @@
-# Traefik v3 Labels for flic-webhook-webpush service
-
# Enable Traefik for this container
traefik.enable=true
-# --- HTTP Router Definition ---
-# Define an HTTP router named 'flic-webhook-http'
-# Route requests based on Host and PathPrefix
-traefik.http.routers.flic-webhook.rule=Host(`webpush.virtonline.eu`)
-# Specify the entrypoint (e.g., 'websecure' for HTTPS)
-traefik.http.routers.flic-webhook.entrypoints=websecure
-# Specify the TLS certificate resolver
-traefik.http.routers.flic-webhook.tls.certresolver=default
-# Link this router to the service defined below
-traefik.http.routers.flic-webhook.service=flic-webhook
+# Docker Network
+traefik.docker.network=traefik
-# --- HTTP Service Definition ---
-# Define an HTTP service named 'flic-webhook'
-# Point the service to the container's port (default 3000)
-traefik.http.services.flic-webhook.loadbalancer.server.port=3000
+# Route requests based on Host
+traefik.http.routers.flic-webhook-webpush.rule=Host(`webpush.virtonline.eu`)
+# Specify the entrypoint ('websecure' for HTTPS)
+traefik.http.routers.flic-webhook-webpush.entrypoints=web-secure
+traefik.http.routers.flic-webhook-webpush.tls=true
+traefik.http.routers.flic-webhook-webpush.tls.certResolver=default
+# Link the router to the service defined below
+traefik.http.routers.flic-webhook-webpush.service=flic-webhook-webpush
-# --- Middleware (Optional Example: Rate Limiting - Uncomment to enable) ---
-# traefik.http.middlewares.flic-ratelimit.ratelimit.average=10 # requests per second
-# traefik.http.middlewares.flic-ratelimit.ratelimit.burst=20
-# traefik.http.routers.flic-webhook.middlewares=flic-ratelimit
+# Point the service to the container's port
+traefik.http.services.flic-webhook-webpush.loadbalancer.server.port=3000
-# --- Docker Network ---
-# Ensure Traefik uses the correct network to communicate with the container
-traefik.docker.network=traefik
\ No newline at end of file
+# Middleware CORS
+traefik.http.middlewares.cors-headers.headers.accesscontrolallowmethods=POST,GET
+traefik.http.middlewares.cors-headers.headers.accesscontrolalloworiginlist=https://game-timer.virtonline.eu
+traefik.http.middlewares.cors-headers.headers.accesscontrolallowheaders=Content-Type,Authorization,button-name,button-battery-level,timestamp
+traefik.http.middlewares.cors-headers.headers.accesscontrolallowcredentials=true
+traefik.http.middlewares.cors-headers.headers.accesscontrolmaxage=600
+traefik.http.middlewares.cors-headers.headers.addvaryheader=true
+
+# Middleware Rate Limiting
+traefik.http.middlewares.flic-ratelimit.ratelimit.average=10
+traefik.http.middlewares.flic-ratelimit.ratelimit.burst=20
+
+# Apply both middlewares to the router (comma-separated list)
+traefik.http.routers.flic-webhook-webpush.middlewares=cors-headers,flic-ratelimit
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 2973f47..fc24ffe 100644
--- a/server.js
+++ b/server.js
@@ -1,8 +1,8 @@
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
// Load environment variables from .env file
require('dotenv').config();
@@ -12,18 +12,69 @@ const port = process.env.PORT || 3000;
const vapidPublicKey = process.env.VAPID_PUBLIC_KEY;
const vapidPrivateKey = process.env.VAPID_PRIVATE_KEY;
const vapidSubject = process.env.VAPID_SUBJECT; // mailto: or https:
-const subscriptionsFilePath = process.env.SUBSCRIPTIONS_FILE || path.join(__dirname, 'subscriptions.json');
-const flicSecret = process.env.FLIC_SECRET; // Optional Bearer token secret for Flic webhook
+const subscriptionsFilePath = process.env.SUBSCRIPTIONS_FILE || path.join(__dirname, '/app/subscriptions.json');
+const defaultButtonName = process.env.DEFAULT_BUTTON_NAME || 'game-button';
+// Basic Authentication Credentials
+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
+const firstRetryDelay = parseInt(process.env.NOTIFICATION_FIRST_RETRY_DELAY_MS || 10, 10); // 10 milliseconds - minimal delay for first retry
+// HTTP request timeout configuration
+const httpTimeout = parseInt(process.env.HTTP_TIMEOUT_MS || 10000, 10); // 10 seconds
+// Logging level configuration
+const LOG_LEVEL = (process.env.LOG_LEVEL || 'info').toLowerCase();
+// --- Logging Utility ---
+const LogLevels = {
+ error: 0,
+ warn: 1,
+ info: 2,
+ debug: 3
+};
+
+const logger = {
+ error: (...args) => {
+ console.error(...args);
+ },
+ warn: (...args) => {
+ if (LogLevels[LOG_LEVEL] >= LogLevels.warn) {
+ console.warn(...args);
+ }
+ },
+ info: (...args) => {
+ if (LogLevels[LOG_LEVEL] >= LogLevels.info) {
+ console.log(...args);
+ }
+ },
+ debug: (...args) => {
+ if (LogLevels[LOG_LEVEL] >= LogLevels.debug) {
+ console.log('DEBUG -', ...args);
+ }
+ }
+};
+
+// Configure global HTTP agent with timeouts to prevent hanging requests
+const https = require('https');
+const http = require('http');
+
+// Custom HTTPS agent with timeout
+const httpsAgent = new https.Agent({
+ keepAlive: true,
+ timeout: httpTimeout,
+ maxSockets: 50, // Limit concurrent connections
+});
+
+// Apply the agent to the webpush module if possible
+// Note: The web-push library might use its own agent, but this is a precaution
+https.globalAgent = httpsAgent;
// --- Validation ---
if (!vapidPublicKey || !vapidPrivateKey || !vapidSubject) {
- console.error('Error: VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY, and VAPID_SUBJECT must be set in the environment variables.');
+ logger.error('Error: VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY, and VAPID_SUBJECT must be set in the environment variables.');
process.exit(1);
}
@@ -34,17 +85,66 @@ webpush.setVapidDetails(
vapidPrivateKey
);
+// Configure DNS settings for more reliable resolution in containerized environments
+// These settings can help with temporary DNS resolution failures
+dns.setDefaultResultOrder('ipv4first'); // Prefer IPv4 to avoid some IPv6 issues in containers
+const dnsTimeout = parseInt(process.env.DNS_TIMEOUT_MS || 5000, 10);
+dns.setServers(dns.getServers()); // Reset DNS servers (can help in some Docker environments)
+
+// You can optionally configure a specific DNS server if needed:
+// Example: dns.setServers(['8.8.8.8', '1.1.1.1']);
+
+// --- Utility function for retrying web push notifications with exponential backoff ---
+async function sendWebPushWithRetry(subscription, payload, retryCount = 0, delay = subsequentRetryDelay) {
+ try {
+ 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' ||
+ 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 :
+ 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;
+ }
+}
+
// --- Subscription Loading and Management ---
let subscriptions = {}; // In-memory cache of subscriptions
function loadSubscriptions() {
if (!fs.existsSync(subscriptionsFilePath)) {
- console.warn(`Warning: Subscriptions file not found at ${subscriptionsFilePath}. Creating an empty file.`);
+ logger.warn(`Warning: Subscriptions file not found at ${subscriptionsFilePath}. Creating an empty file.`);
try {
fs.writeFileSync(subscriptionsFilePath, '{}', 'utf8');
subscriptions = {};
} catch (err) {
- console.error(`Error: Could not create subscriptions file at ${subscriptionsFilePath}.`, err);
+ logger.error(`Error: Could not create subscriptions file at ${subscriptionsFilePath}.`, err);
// Exit or continue with empty object depending on desired robustness
process.exit(1); // Exit if we can't even create the file
}
@@ -52,9 +152,9 @@ function loadSubscriptions() {
try {
const data = fs.readFileSync(subscriptionsFilePath, 'utf8');
subscriptions = JSON.parse(data || '{}'); // Handle empty file case
- console.log(`Loaded ${Object.keys(subscriptions).length} subscriptions from ${subscriptionsFilePath}`);
+ logger.info(`Loaded ${Object.keys(subscriptions).length} subscriptions from ${subscriptionsFilePath}`);
} catch (err) {
- console.error(`Error reading or parsing subscriptions file at ${subscriptionsFilePath}. Please ensure it's valid JSON. Using empty cache.`, err);
+ logger.error(`Error reading or parsing subscriptions file at ${subscriptionsFilePath}. Please ensure it's valid JSON. Using empty cache.`, err);
// Continue with empty subscriptions, but log the error
subscriptions = {};
}
@@ -64,9 +164,9 @@ function loadSubscriptions() {
function saveSubscriptions() {
try {
fs.writeFileSync(subscriptionsFilePath, JSON.stringify(subscriptions, null, 2), 'utf8'); // Pretty print JSON
- console.log(`Subscriptions successfully saved to ${subscriptionsFilePath}`);
+ logger.info(`Subscriptions successfully saved to ${subscriptionsFilePath}`);
} catch (err) {
- console.error(`Error writing subscriptions file: ${subscriptionsFilePath}`, err);
+ logger.error(`Error writing subscriptions file: ${subscriptionsFilePath}`, err);
// Note: The in-memory object is updated, but persistence failed.
}
}
@@ -78,64 +178,73 @@ 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('/flic-webhook', cors(corsOptions));
-app.options('/subscribe', cors(corsOptions));
-
-
// --- Body Parsing Middleware ---
app.use(express.json());
-// --- Authentication Middleware (For Flic Webhook Only) ---
-const authenticateFlicRequest = (req, res, next) => {
- // Only apply auth if flicSecret is configured
- if (!flicSecret) {
+// --- Basic Authentication Middleware ---
+const authenticateBasic = (req, res, next) => {
+ // 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') {
+ 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
+ if (!basicAuthUsername || !basicAuthPassword) {
+ logger.warn('Auth: Basic Auth username or password not configured. Skipping authentication.');
return next();
}
const authHeader = req.headers.authorization;
- if (!authHeader || !authHeader.startsWith('Bearer ')) {
- console.warn('Auth (Flic): Missing or malformed Authorization header');
- return res.status(401).json({ message: 'Unauthorized: Missing or malformed Bearer token' });
+
+ if (!authHeader || !authHeader.startsWith('Basic ')) {
+ logger.warn('Auth: Missing or malformed Basic Authorization header');
+ res.setHeader('WWW-Authenticate', 'Basic realm="Restricted Area"');
+ return res.status(401).json({ message: 'Unauthorized: Basic Authentication required' });
}
- const token = authHeader.split(' ')[1];
- if (token !== flicSecret) {
- console.warn('Auth (Flic): Invalid Bearer token received');
- return res.status(401).json({ message: 'Unauthorized: Invalid token' });
- }
+ const credentials = authHeader.split(' ')[1];
+ const decodedCredentials = Buffer.from(credentials, 'base64').toString('utf8');
+ const [username, password] = decodedCredentials.split(':');
- console.log('Auth (Flic): Request authenticated successfully.');
- next();
+ // Note: Use constant-time comparison for production environments if possible,
+ // but for this scope, direct comparison is acceptable.
+ if (username === basicAuthUsername && password === basicAuthPassword) {
+ logger.debug('Auth: Basic Authentication successful.');
+ return next();
+ } else {
+ logger.warn('Auth: Invalid Basic Authentication credentials received.');
+ res.setHeader('WWW-Authenticate', 'Basic realm="Restricted Area"');
+ return res.status(401).json({ message: 'Unauthorized: Invalid credentials' });
+ }
};
-app.post('/subscribe', async (req, res) => {
- const { button_id, subscription } = req.body;
+// --- Routes ---
- console.log(`Received subscription request for button: ${button_id}`);
+// Subscribe endpoint: Add a new button->subscription mapping
+// Apply Basic Authentication
+app.post('/subscribe', authenticateBasic, async (req, res) => {
+ let { button_id, subscription } = req.body;
- // Basic Validation
+ logger.debug('All headers received on /subscribe:');
+ Object.keys(req.headers).forEach(headerName => {
+ logger.debug(` ${headerName}: ${req.headers[headerName]}`);
+ });
+
+ // If button_id is not provided, use defaultButtonName from environment variable
if (!button_id || typeof button_id !== 'string' || button_id.trim() === '') {
- console.warn('Subscription Error: Missing or invalid button_id');
- return res.status(400).json({ message: 'Bad Request: Missing or invalid button_id' });
+ button_id = defaultButtonName;
+ logger.info(`No button_id provided, using default button name: ${button_id}`);
}
+
+ logger.info(`Received subscription request for button: ${button_id}`);
+
+ // Basic Validation - now we only validate subscription since button_id will use default if not provided
if (!subscription || typeof subscription !== 'object' || !subscription.endpoint || !subscription.keys || !subscription.keys.p256dh || !subscription.keys.auth) {
- console.warn('Subscription Error: Missing or invalid subscription object structure');
+ logger.warn('Subscription Error: Missing or invalid subscription object structure');
return res.status(400).json({ message: 'Bad Request: Missing or invalid subscription object' });
}
@@ -143,7 +252,7 @@ app.post('/subscribe', async (req, res) => {
// Update in-memory store
subscriptions[normalizedButtonId] = subscription;
- console.log(`Subscription for button ${normalizedButtonId} added/updated in memory.`);
+ logger.info(`Subscription for button ${normalizedButtonId} added/updated in memory.`);
// Persist to file
try {
@@ -156,54 +265,69 @@ app.post('/subscribe', async (req, res) => {
}
});
-// --- Flic Webhook Endpoint ---
-// Apply Flic-specific authentication ONLY to this route
-app.post('/flic-webhook', authenticateFlicRequest, async (req, res) => {
- // Assuming Flic sends 'button_id' which is the serial number
- const { button_id, click_type, timestamp } = req.body;
+// --- Flic Webhook Endpoint (GET only) ---
+// Apply Basic Authentication
+app.get('/webhook/:click_type', authenticateBasic, async (req, res) => {
+ // Get buttonName from Header 'Button-Name' and timestamp from Header 'Timestamp'
+ const buttonName = req.headers['button-name'] || defaultButtonName;
+ const timestamp = req.headers['timestamp'];
+ // Get click_type from URL path
+ const click_type = req.params.click_type;
+ // Get battery level from Header 'Button-Battery-Level'
+ const batteryLevelHeader = req.headers['button-battery-level'];
+ // Use 'N/A' if header is missing or empty, otherwise parse as integer (or keep as string if parsing fails)
+ const batteryLevel = batteryLevelHeader ? parseInt(batteryLevelHeader, 10) || batteryLevelHeader : 'N/A';
- console.log(`Received webhook: Button=${button_id}, Type=${click_type}, Timestamp=${timestamp || 'N/A'}`);
+ // Log all headers received from Flic
+ logger.debug('All headers received on /webhook:');
+ Object.keys(req.headers).forEach(headerName => {
+ logger.debug(` ${headerName}: ${req.headers[headerName]}`);
+ });
+
+ logger.info(`Received GET webhook: Button=${buttonName}, Type=${click_type}, Battery=${batteryLevel}%, Timestamp=${timestamp || 'N/A'}`);
// Basic validation
- if (!button_id || !click_type) {
- console.warn(`Webhook Error: Missing button_id or click_type`);
- return res.status(400).json({ message: 'Bad Request: Missing button_id or click_type' });
+ if (!click_type) {
+ logger.warn(`Webhook Error: Missing click_type query parameter`);
+ return res.status(400).json({ message: 'Bad Request: Missing click_type query parameter' });
}
- const normalizedButtonId = button_id.toLowerCase(); // Use lowercase for lookup consistency
+ const normalizedButtonName = buttonName.toLowerCase(); // Use lowercase for lookup consistency
- // Find the subscription associated with this button ID
- const subscription = subscriptions[normalizedButtonId];
+ // Find the subscription associated with this normalized button name
+ const subscription = subscriptions[normalizedButtonName];
if (!subscription) {
- console.warn(`Webhook: No subscription found for button ID: ${normalizedButtonId} (original: ${button_id})`);
- return res.status(404).json({ message: `Not Found: No subscription configured for button ${normalizedButtonId}` });
+ logger.warn(`Webhook: No subscription found for button ID: ${normalizedButtonName} (original: ${buttonName})`);
+ return res.status(404).json({ message: `Not Found: No subscription configured for button ${normalizedButtonName}` });
}
// --- Send Web Push Notification ---
const payload = JSON.stringify({
title: 'Flic Button Action',
- body: `Button ${click_type}`, // Simplified body
+ body: `Button ${normalizedButtonName}: ${click_type}`, // Simplified body
data: {
action: click_type,
- button: normalizedButtonId, // Send normalized ID
- timestamp: timestamp || new Date().toISOString()
+ button: normalizedButtonName, // Send normalized button name
+ timestamp: timestamp || new Date().toISOString(),
+ batteryLevel: batteryLevel // Use the extracted value
}
// icon: '/path/to/icon.png'
});
try {
- console.log(`Sending push notification for ${normalizedButtonId} to endpoint: ${subscription.endpoint.substring(0, 40)}...`);
- await webpush.sendNotification(subscription, payload);
- console.log(`Push notification sent successfully for button ${normalizedButtonId}.`);
+ logger.debug(`Subscription endpoint: ${subscription.endpoint}`);
+ logger.info(`Sending push notification for ${normalizedButtonName} to endpoint: ${subscription.endpoint.substring(0, 40)}...`);
+ await sendWebPushWithRetry(subscription, payload);
+ logger.info(`Push notification sent successfully for button ${normalizedButtonName}.`);
res.status(200).json({ message: 'Push notification sent successfully' });
} catch (error) {
- console.error(`Error sending push notification for button ${normalizedButtonId}:`, error.body || error.message || error);
+ logger.error(`Error sending push notification for button ${normalizedButtonName}:`, error.body || error.message || error);
if (error.statusCode === 404 || error.statusCode === 410) {
- console.warn(`Subscription for button ${normalizedButtonId} is invalid or expired (404/410). Removing it.`);
+ logger.warn(`Subscription for button ${normalizedButtonName} is invalid or expired (404/410). Removing it.`);
// Optionally remove the stale subscription
- delete subscriptions[normalizedButtonId];
+ delete subscriptions[normalizedButtonName];
saveSubscriptions(); // Attempt to save the updated list
res.status(410).json({ message: 'Subscription Gone' });
} else {
@@ -212,44 +336,42 @@ app.post('/flic-webhook', authenticateFlicRequest, async (req, res) => {
}
});
-// --- Health Check Endpoint ---
-app.get('/health', (req, res) => {
- res.status(200).json({
- status: 'UP',
- timestamp: new Date().toISOString(),
- subscription_count: Object.keys(subscriptions).length
- });
-});
-
// --- Start Server ---
// Use http.createServer to allow graceful shutdown
-const http = require('http');
const server = http.createServer(app);
server.listen(port, () => {
- console.log(`Flic Webhook to WebPush server listening on port ${port}`);
- console.log(`Allowed Origins: ${allowedOrigins.length > 0 ? allowedOrigins.join(', ') : '(Any)'}`);
- console.log(`Allowed Methods: ${allowedMethods.join(', ')}`);
- console.log(`Allowed Headers: ${allowedHeaders.join(', ')}`);
- console.log(`Flic Webhook Auth: ${flicSecret ? 'Enabled (Bearer Token)' : 'Disabled'}`);
- console.log(`Subscription Endpoint Auth: Disabled`);
- console.log(`Subscriptions File: ${subscriptionsFilePath}`);
+ logger.info(`Flic Webhook to WebPush server listening on port ${port}`);
+ logger.info('CORS: Handled by Traefik');
+ // Log Basic Auth status instead of Flic Secret
+ if (basicAuthUsername && basicAuthPassword) {
+ logger.info('Authentication: Basic Auth Enabled');
+ } else {
+ logger.info('Authentication: Basic Auth Disabled (username/password not set)');
+ }
+ logger.info(`Subscription Endpoint Auth: ${basicAuthUsername && basicAuthPassword ? 'Enabled (Basic)' : 'Disabled'}`);
+ logger.info(`Webhook Endpoint Auth: ${basicAuthUsername && basicAuthPassword ? 'Enabled (Basic)' : 'Disabled'}`);
+ logger.info(`Subscriptions File: ${subscriptionsFilePath}`);
+ logger.info(`Push Notification Retry Config: ${maxRetries} retries, first retry: ${firstRetryDelay}ms, subsequent retries: ${subsequentRetryDelay}ms base delay`);
+ logger.info(`DNS Config: IPv4 first, timeout ${dnsTimeout}ms`);
+ logger.info(`HTTP Timeout: ${httpTimeout}ms`);
+ logger.info(`Log Level: ${LOG_LEVEL.toUpperCase()}`);
});
// --- Graceful Shutdown ---
const closeGracefully = (signal) => {
- console.log(`${signal} signal received: closing HTTP server`);
+ logger.info(`${signal} signal received: closing HTTP server`);
server.close(() => {
- console.log('HTTP server closed');
+ logger.info('HTTP server closed');
// Perform any other cleanup here if needed
process.exit(0);
});
// Force close server after 10 seconds
setTimeout(() => {
- console.error('Could not close connections in time, forcefully shutting down');
+ logger.error('Could not close connections in time, forcefully shutting down');
process.exit(1);
- }, 10000); // 10 seconds timeout
+ }, 10000); // 10 seconds timeout - This is a literal so no need to parse
}
process.on('SIGTERM', () => closeGracefully('SIGTERM'));
diff --git a/virt-flic-webhook-webpush.service.example b/virt-flic-webhook-webpush.service.example
new file mode 100644
index 0000000..4bc8444
--- /dev/null
+++ b/virt-flic-webhook-webpush.service.example
@@ -0,0 +1,32 @@
+[Unit]
+Description=flic-webhook-webpush (virt-flic-webhook-webpush)
+Requires=docker.service
+After=docker.service
+DefaultDependencies=no
+
+[Service]
+Type=simple
+Environment="HOME=/root"
+ExecStartPre=-/usr/bin/env sh -c '/usr/bin/env docker kill virt-flic-webhook-webpush 2>/dev/null || true'
+ExecStartPre=-/usr/bin/env sh -c '/usr/bin/env docker rm virt-flic-webhook-webpush 2>/dev/null || true'
+ExecStartPre=/usr/bin/env sh -c 'touch /virt/flic-webhook-webpush/subscriptions.json'
+
+ExecStart=/usr/bin/env docker run \
+ --rm \
+ --name=virt-flic-webhook-webpush \
+ --log-driver=none \
+ --network=traefik \
+ --env-file=/virt/flic-webhook-webpush/env \
+ --label-file=/virt/flic-webhook-webpush/labels \
+ --mount type=bind,src=/virt/flic-webhook-webpush/subscriptions.json,dst=/app/subscriptions.json \
+ flic-webhook-webpush
+
+ExecStop=-/usr/bin/env sh -c '/usr/bin/env docker kill virt-flic-webhook-webpush 2>/dev/null || true'
+ExecStop=-/usr/bin/env sh -c '/usr/bin/env docker rm virt-flic-webhook-webpush 2>/dev/null || true'
+
+Restart=always
+RestartSec=30
+SyslogIdentifier=virt-flic-webhook-webpush
+
+[Install]
+WantedBy=multi-user.target