Compare commits
13 Commits
main
...
064f803784
| Author | SHA1 | Date | |
|---|---|---|---|
| 064f803784 | |||
| 9b4bf1e255 | |||
| fc7f4f4b7a | |||
| b87e30f6b4 | |||
| faa32510df | |||
| ce7ab594e2 | |||
| f500c00896 | |||
| b246923283 | |||
| 907ad382dc | |||
| 102d2e2748 | |||
| f2de1e55d0 | |||
| ba9704d3c2 | |||
| 95a5b893ec |
@@ -6,11 +6,3 @@ Dockerfile
|
|||||||
.gitignore
|
.gitignore
|
||||||
README.md
|
README.md
|
||||||
*.example
|
*.example
|
||||||
images
|
|
||||||
.env
|
|
||||||
env
|
|
||||||
labels
|
|
||||||
subscriptions.json
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
59
.env
59
.env
@@ -1,39 +1,32 @@
|
|||||||
# Flic to PWA WebPush Configuration
|
# --- Application Configuration ---
|
||||||
|
|
||||||
# --- VAPID Keys (Required) ---
|
# --- VAPID Keys (REQUIRED for Web Push) ---
|
||||||
# Generate using: npx web-push generate-vapid-keys
|
# Generate these once using npx web-push generate-vapid-keys (or other tools)
|
||||||
VAPID_PUBLIC_KEY=BKfRJXjSQmAJ452gLwlK_8scGrW6qMU1mBRp39ONtcQHkSsQgmLAaODIyGbgHyRpnDEv3HfXV1oGh3SC0fHxY0E
|
# Keep the private key SECRET!
|
||||||
VAPID_PRIVATE_KEY=ErEgsDKYQi5j2KPERC_gCtrEALAD0k-dWSwrrcD0-JU
|
VAPID_PUBLIC_KEY="BKfRJXjSQmAJ452gLwlK_8scGrW6qMU1mBRp39ONtcQHkSsQgmLAaODIyGbgHyRpnDEv3HfXV1oGh3SC0fHxY0E"
|
||||||
VAPID_SUBJECT=mailto:admin@virtonline.eu
|
VAPID_PRIVATE_KEY="ErEgsDKYQi5j2KPERC_gCtrEALAD0k-dWSwrrcD0-JU"
|
||||||
|
|
||||||
# --- Server Configuration ---
|
# Subject claim for VAPID. Use a 'mailto:' URI or an 'https:' URL identifying your application.
|
||||||
# Internal port for the Node.js app
|
# Example: mailto:admin@yourdomain.com or https://yourdomain.com/contact
|
||||||
PORT=3000
|
VAPID_SUBJECT="mailto:admin@virtonline.eu"
|
||||||
|
|
||||||
|
# Flic Button Configuration
|
||||||
|
FLIC_BUTTON1_SERIAL=your_button1_serial
|
||||||
|
FLIC_BUTTON2_SERIAL=your_button2_serial
|
||||||
|
FLIC_BUTTON3_SERIAL=your_button3_serial
|
||||||
|
|
||||||
|
# Subscription Storage
|
||||||
SUBSCRIPTIONS_FILE=subscriptions.json
|
SUBSCRIPTIONS_FILE=subscriptions.json
|
||||||
DEFAULT_BUTTON_NAME=game-button
|
|
||||||
|
|
||||||
# --- Authentication (Optional) ---
|
# CORS
|
||||||
# If both USERNAME and PASSWORD are set, Basic Auth will be enabled for:
|
ALLOWED_ORIGINS=https://game-timer.virtonline.eu
|
||||||
# - POST /subscribe
|
ALLOWED_METHODS=POST,OPTIONS
|
||||||
# - GET /webhook
|
ALLOWED_HEADERS=Content-Type,Authorization
|
||||||
# Leave blank to disable authentication.
|
|
||||||
BASIC_AUTH_USERNAME=player
|
|
||||||
BASIC_AUTH_PASSWORD=SevenOfNine
|
|
||||||
|
|
||||||
# --- Web Push Retry Configuration (Optional) ---
|
# Logging Configuration
|
||||||
# Number of retries on failure (e.g., DNS issues)
|
LOG_LEVEL=DEBUG
|
||||||
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
|
|
||||||
|
|
||||||
# --- Network Configuration (Optional) ---
|
# --- Security (Optional) ---
|
||||||
# Timeout for DNS lookups (ms)
|
# If you want to add a simple security layer between Flic and this app.
|
||||||
DNS_TIMEOUT_MS=5000
|
# If set, configure Flic's HTTP request to include an "Authorization: Bearer YOUR_SECRET_VALUE" header.
|
||||||
# Timeout for outgoing HTTP requests (ms)
|
# FLIC_SECRET="replace_with_a_strong_secret_if_needed"
|
||||||
HTTP_TIMEOUT_MS=10000
|
|
||||||
|
|
||||||
# --- Logging ---
|
|
||||||
# Controls log verbosity: error, warn, info, debug
|
|
||||||
LOG_LEVEL=info
|
|
||||||
55
.env.example
55
.env.example
@@ -1,39 +1,30 @@
|
|||||||
# Flic to PWA WebPush Configuration
|
# --- VAPID Keys ---
|
||||||
|
|
||||||
# --- VAPID Keys (Required) ---
|
|
||||||
# Generate using: npx web-push generate-vapid-keys
|
# Generate using: npx web-push generate-vapid-keys
|
||||||
VAPID_PUBLIC_KEY=
|
# The Public Key is needed by your PWA to subscribe.
|
||||||
VAPID_PRIVATE_KEY=
|
VAPID_PUBLIC_KEY=YOUR_VAPID_PUBLIC_KEY
|
||||||
VAPID_SUBJECT=mailto:mailto:user@example.org
|
# The Private Key MUST be kept secret on the server.
|
||||||
|
VAPID_PRIVATE_KEY=YOUR_VAPID_PRIVATE_KEY
|
||||||
|
# A contact URL for the push service (mailto: or https:)
|
||||||
|
VAPID_SUBJECT=mailto:admin@yourdomain.com
|
||||||
|
|
||||||
# --- Server Configuration ---
|
# --- Application Settings ---
|
||||||
# Internal port for the Node.js app
|
# Port the Node.js server will listen on inside the container
|
||||||
PORT=3000
|
PORT=3000
|
||||||
|
# Path to the JSON file storing Flic button serial -> PWA subscription mappings
|
||||||
SUBSCRIPTIONS_FILE=/app/subscriptions.json
|
SUBSCRIPTIONS_FILE=/app/subscriptions.json
|
||||||
DEFAULT_BUTTON_NAME=game-button
|
|
||||||
|
|
||||||
# --- Authentication (Optional) ---
|
# --- Security ---
|
||||||
# If both USERNAME and PASSWORD are set, Basic Auth will be enabled for:
|
# (Optional) A secret bearer token. If set, Flic requests must include "Authorization: Bearer <YOUR_SECRET>" header.
|
||||||
# - POST /subscribe
|
# Generate a strong secret, e.g., using: openssl rand -hex 32
|
||||||
# - GET /webhook
|
FLIC_SECRET=YOUR_OPTIONAL_FLIC_SECRET_TOKEN
|
||||||
# Leave blank to disable authentication.
|
|
||||||
BASIC_AUTH_USERNAME=user12345
|
|
||||||
BASIC_AUTH_PASSWORD=password
|
|
||||||
|
|
||||||
# --- Web Push Retry Configuration (Optional) ---
|
# --- CORS Settings ---
|
||||||
# Number of retries on failure (e.g., DNS issues)
|
# Comma-separated list of allowed origins for CORS requests (e.g., your PWA's domain)
|
||||||
NOTIFICATION_MAX_RETRIES=3
|
# Leave empty or unset to allow any origin (less secure, useful for testing)
|
||||||
# First retry delay in milliseconds (minimal delay for immediate retry)
|
# Example: ALLOWED_ORIGINS=https://pwa.yourdomain.com,http://localhost:8080
|
||||||
NOTIFICATION_FIRST_RETRY_DELAY_MS=10
|
ALLOWED_ORIGINS=https://game-timer.virtonline.eu
|
||||||
# Base delay in milliseconds for subsequent retries (used for exponential backoff)
|
# Comma-separated list of allowed HTTP methods
|
||||||
NOTIFICATION_SUBSEQUENT_RETRY_DELAY_MS=1000
|
ALLOWED_METHODS=POST,OPTIONS
|
||||||
|
# Comma-separated list of allowed HTTP headers
|
||||||
|
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 ---
|
|
||||||
# Controls log verbosity: error, warn, info, debug
|
|
||||||
LOG_LEVEL=info
|
|
||||||
8
.gitignore
vendored
8
.gitignore
vendored
@@ -1,15 +1,15 @@
|
|||||||
myenv
|
myenv
|
||||||
.vscode
|
.vscode
|
||||||
.cursor
|
|
||||||
subscriptions.json
|
|
||||||
|
|
||||||
# Node.js
|
# Node.js
|
||||||
node_modules/
|
node_modules/
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
.env
|
*.env
|
||||||
env
|
subscriptions.json
|
||||||
|
labels
|
||||||
|
|
||||||
# OS generated files
|
# OS generated files
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.DS_Store?
|
.DS_Store?
|
||||||
|
|||||||
257
README.md
257
README.md
@@ -6,15 +6,14 @@ It's designed to be run as a Docker container and integrated with Traefik v3 for
|
|||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
* Receives GET requests on `/webhook`.
|
* Receives POST requests on `/flic-webhook`.
|
||||||
* Receives POST requests on `/subscribe` to manage button-PWA mappings.
|
* Parses `button_id` and `click_type` from the Flic request body.
|
||||||
* Uses HTTP headers `Button-Name` and `Timestamp` from the Flic request.
|
* Looks up the target PWA push subscription based on `button_id` in a JSON file.
|
||||||
* 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.
|
* Sends a Web Push notification containing the click details (action, button, timestamp) to the corresponding PWA subscription.
|
||||||
* Integrates with Traefik v3 via Docker labels.
|
* Integrates with Traefik v3 via Docker labels.
|
||||||
* Configurable via environment variables (`.env` file).
|
* Configurable via environment variables (`.env` file).
|
||||||
* Optional Basic Authentication for securing the `/webhook` and `/subscribe` endpoints.
|
* 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).
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
@@ -25,14 +24,6 @@ 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.
|
* **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.
|
* **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
|
|
||||||
<img src="images/subscription-flow.png" width="600" alt="Subscription Flow">
|
|
||||||
|
|
||||||
### Interaction Flow
|
|
||||||
<img src="images/interaction-flow.png" width="300" alt="Interaction Flow">
|
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
@@ -50,32 +41,67 @@ 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.
|
This will output a Public Key and a Private Key.
|
||||||
|
|
||||||
3. **Configure Environment Variables:**
|
3. **Obtain PWA Push Subscription Details:**
|
||||||
* Copy the example `.env` file:
|
* Your PWA needs to use the Push API to request notification permission from the user.
|
||||||
```bash
|
* When permission is granted, the browser's push service provides a `PushSubscription` object.
|
||||||
cp .env.example .env
|
* This object typically looks like:
|
||||||
```
|
```json
|
||||||
* Edit the `.env` file with your specific values:
|
{
|
||||||
* `VAPID_PUBLIC_KEY`: The public key generated in step 2. **Your PWA will also need this key** when it subscribes to push notifications.
|
"endpoint": "https://updates.push.services.mozilla.com/...",
|
||||||
* `VAPID_PRIVATE_KEY`: The private key generated in step 2. **Keep this secret!**
|
"expirationTime": null,
|
||||||
* `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.
|
"keys": {
|
||||||
* `PORT`: (Default: `3000`) The internal port the Node.js app listens on. Traefik will map to this.
|
"p256dh": "...",
|
||||||
* `SUBSCRIPTIONS_FILE`: (Default: `subscriptions.json`) The path *inside the container* where the button-to-subscription mapping is stored.
|
"auth": "..."
|
||||||
* `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.
|
* 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).
|
||||||
* `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.
|
|
||||||
|
|
||||||
4. **Configure Traefik Labels:**
|
4. **Configure Environment Variables:**
|
||||||
* Copy the example `labels` file:
|
* Copy the example `.env` file:
|
||||||
```bash
|
```bash
|
||||||
cp labels.example labels
|
cp .env.example .env
|
||||||
```
|
```
|
||||||
|
* Edit the `.env` file with your specific values:
|
||||||
|
* `VAPID_PUBLIC_KEY`: The public key generated in step 2. **Your PWA will also need this key** when it subscribes to push notifications.
|
||||||
|
* `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`).
|
||||||
|
|
||||||
|
5. **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
|
## Running the Service
|
||||||
|
|
||||||
@@ -89,13 +115,18 @@ 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.
|
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
|
```bash
|
||||||
docker run --rm -d --name flic-webhook-webpush \
|
docker run -d --name flic-webhook-webpush \
|
||||||
--network traefik \
|
--network traefik \
|
||||||
--env-file .env \
|
--env-file .env \
|
||||||
--label-file labels \
|
--label-file labels \
|
||||||
--mount type=bind,src=./subscriptions.json,dst=/app/subscriptions.json \
|
--mount type=bind,src=./subscriptions.json,dst=/app/subscriptions.json,readonly \
|
||||||
flic-webhook-webpush:latest
|
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:**
|
3. **Check Logs:**
|
||||||
Monitor the container logs to ensure it started correctly and to see incoming webhook requests or errors.
|
Monitor the container logs to ensure it started correctly and to see incoming webhook requests or errors.
|
||||||
@@ -111,128 +142,62 @@ 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:
|
In your Flic app or Flic Hub SDK interface:
|
||||||
|
|
||||||
1. Select your Flic button.
|
1. Select your Flic button.
|
||||||
2. Add an "Internet Request" action.
|
2. Add an "Internet Request" action (or similar HTTP request action) for Single Click, Double Click, and/or Hold events.
|
||||||
3. Fill in the following details:
|
3. **URL:** `https://<YOUR_TRAEFIK_SERVICE_HOST>/flic-webhook` (e.g., `https://webpush.virtonline.eu/flic-webhook`)
|
||||||
* Select the `GET` method.
|
4. **Method:** `POST`
|
||||||
* Set URL with query parameter: `https://webpush.virtonline.eu/webhook/SingleClick`
|
5. **Body Type:** `JSON` (or `application/json`)
|
||||||
* **If Basic Authentication is enabled:**
|
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`).
|
||||||
* Set the Headers:
|
```json
|
||||||
* Set the `Key` fields to `Authorization`.
|
{
|
||||||
* Set the `Value` fields to `Basic <base64 encoded username:password>` (e.g., `Basic dXNlcm5hbWU6cGFzc3dvcmQ=`). Use `$(echo -n 'user:password' | base64)` to generate the base64 encoded string.
|
"button_id": "{serialNumber}",
|
||||||
* Click `ADD`.
|
"click_type": "{clickType}",
|
||||||
* Tap on `SAVE ACTION`.
|
"timestamp": "{timestamp}"
|
||||||
4. Repeat for Double Click (i.e., `/DoubleClick`) and Hold (i.e., `/Hold`) events.
|
}
|
||||||
The request for the Hold event should look like this:
|
```
|
||||||
|
*(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 <YOUR_FLIC_SECRET_VALUE>` (Replace `<YOUR_FLIC_SECRET_VALUE>` with the actual secret from your `.env` file).
|
||||||
|
|
||||||
<img src="images/flic-button-request.png" width="300" alt="Flic Button Request">
|
## API Endpoint
|
||||||
|
|
||||||
## App Example: "HTTP Shortcuts" by waboodoo
|
* **`POST /flic-webhook`**
|
||||||
|
* **Description:** Receives Flic button events.
|
||||||
Search the Play Store - there might be others with similar names.
|
* **Authentication:** Optional Bearer token via `Authorization` header if `FLIC_SECRET` is configured.
|
||||||
|
* **Request Body (JSON):**
|
||||||
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
|
```json
|
||||||
{
|
{
|
||||||
"button_id": "game-button",
|
"button_id": "SERIAL_NUMBER_OF_FLIC_BUTTON",
|
||||||
"subscription": {
|
"click_type": "SingleClick | DoubleClick | Hold",
|
||||||
"endpoint": "https://your_pwa_push_endpoint...",
|
"timestamp": "ISO_8601_TIMESTAMP_STRING (Optional)"
|
||||||
"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`).
|
|
||||||
* **Optional 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.
|
|
||||||
* `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).
|
|
||||||
* **Responses:**
|
* **Responses:**
|
||||||
* `200 OK`: Webhook received, push notification sent successfully.
|
* `200 OK`: Webhook received, push notification sent successfully.
|
||||||
* `400 Bad Request`: Missing `Button-Name` header or `click_type` URL parameter.
|
* `400 Bad Request`: Missing `button_id` or `click_type` in the request body.
|
||||||
* `401 Unauthorized`: Missing or invalid Basic Authentication credentials (if authentication is enabled).
|
* `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-Name`.
|
* `404 Not Found`: No subscription found in `subscriptions.json` for the given `button_id`.
|
||||||
* `410 Gone`: The push subscription associated with the button was rejected by the push service (likely expired or revoked).
|
* `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.
|
* `500 Internal Server Error`: Failed to send the push notification for other reasons.
|
||||||
|
|
||||||
## Testing the Webhook
|
* **`GET /health`** (Optional)
|
||||||
|
* **Description:** Simple health check endpoint.
|
||||||
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.
|
* **Response:**
|
||||||
|
```json
|
||||||
**Note:** Replace `<username>`, `<password>` with your actual values. The `Button-Name` header is optional and will default to the value of `DEFAULT_BUTTON_NAME` if not provided.
|
{
|
||||||
|
"status": "UP",
|
||||||
```bash
|
"timestamp": "ISO_8601_TIMESTAMP_STRING"
|
||||||
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
|
## Troubleshooting
|
||||||
|
|
||||||
* **Check Backend Logs:** `docker logs flic-webhook-webpush`. Look for errors related to configuration, file access, JSON parsing, authentication, or sending push notifications.
|
* **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.
|
* **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 `.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 `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).
|
* **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, `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.
|
* **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`.
|
* **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
|
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 378 KiB |
@@ -1,8 +0,0 @@
|
|||||||
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
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 49 KiB |
@@ -1,7 +0,0 @@
|
|||||||
stateDiagram-v2
|
|
||||||
[*] --> PWA : User Initiates
|
|
||||||
PWA --> GooglePushAPI : Request Subscription
|
|
||||||
GooglePushAPI --> PWA : Return Subscription Info
|
|
||||||
PWA --> WebhookWebPushService : Store Subscription Info
|
|
||||||
WebhookWebPushService --> PWA : Confirmation
|
|
||||||
[*] --> Complete
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 62 KiB |
@@ -1,32 +1,29 @@
|
|||||||
|
# Traefik v3 Labels for flic-webhook-webpush service
|
||||||
|
|
||||||
# Enable Traefik for this container
|
# Enable Traefik for this container
|
||||||
traefik.enable=true
|
traefik.enable=true
|
||||||
|
|
||||||
# Docker Network
|
# --- 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
|
||||||
|
|
||||||
|
# --- 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
|
||||||
|
|
||||||
|
# --- 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
|
||||||
|
|
||||||
|
# --- Docker Network ---
|
||||||
|
# Ensure Traefik uses the correct network to communicate with the container
|
||||||
traefik.docker.network=traefik
|
traefik.docker.network=traefik
|
||||||
|
|
||||||
# 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
|
|
||||||
|
|
||||||
# Point the service to the container's port
|
|
||||||
traefik.http.services.flic-webhook-webpush.loadbalancer.server.port=3000
|
|
||||||
|
|
||||||
# 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
|
|
||||||
|
|||||||
21
package-lock.json
generated
21
package-lock.json
generated
@@ -9,6 +9,7 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
"express": "^4.19.2",
|
"express": "^4.19.2",
|
||||||
"web-push": "^3.6.7"
|
"web-push": "^3.6.7"
|
||||||
@@ -150,6 +151,18 @@
|
|||||||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
|
||||||
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ=="
|
"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": {
|
"node_modules/debug": {
|
||||||
"version": "2.6.9",
|
"version": "2.6.9",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
||||||
@@ -601,6 +614,14 @@
|
|||||||
"node": ">= 0.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": {
|
"node_modules/object-inspect": {
|
||||||
"version": "1.13.4",
|
"version": "1.13.4",
|
||||||
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
"author": "Your Name",
|
"author": "Your Name",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
"express": "^4.19.2",
|
"express": "^4.19.2",
|
||||||
"web-push": "^3.6.7"
|
"web-push": "^3.6.7"
|
||||||
|
|||||||
415
server.js
415
server.js
@@ -1,8 +1,8 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const webpush = require('web-push');
|
const webpush = require('web-push');
|
||||||
|
const cors = require('cors');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const dns = require('dns'); // Add DNS module
|
|
||||||
|
|
||||||
// Load environment variables from .env file
|
// Load environment variables from .env file
|
||||||
require('dotenv').config();
|
require('dotenv').config();
|
||||||
@@ -12,72 +12,29 @@ const port = process.env.PORT || 3000;
|
|||||||
const vapidPublicKey = process.env.VAPID_PUBLIC_KEY;
|
const vapidPublicKey = process.env.VAPID_PUBLIC_KEY;
|
||||||
const vapidPrivateKey = process.env.VAPID_PRIVATE_KEY;
|
const vapidPrivateKey = process.env.VAPID_PRIVATE_KEY;
|
||||||
const vapidSubject = process.env.VAPID_SUBJECT; // mailto: or https:
|
const vapidSubject = process.env.VAPID_SUBJECT; // mailto: or https:
|
||||||
const subscriptionsFilePath = process.env.SUBSCRIPTIONS_FILE || path.join(__dirname, '/app/subscriptions.json');
|
const subscriptionsFilePath = process.env.SUBSCRIPTIONS_FILE || path.join(__dirname, 'subscriptions.json');
|
||||||
const defaultButtonName = process.env.DEFAULT_BUTTON_NAME || 'game-button';
|
const flicSecret = process.env.FLIC_SECRET; // Optional Bearer token secret
|
||||||
// Basic Authentication Credentials
|
const allowedOrigins = (process.env.ALLOWED_ORIGINS || "").split(',').map(origin => origin.trim()).filter(origin => origin);
|
||||||
const basicAuthUsername = process.env.BASIC_AUTH_USERNAME;
|
const allowedMethods = (process.env.ALLOWED_METHODS || "POST,OPTIONS").split(',').map(method => method.trim()).filter(method => method);
|
||||||
const basicAuthPassword = process.env.BASIC_AUTH_PASSWORD;
|
const allowedHeaders = (process.env.ALLOWED_HEADERS || "Content-Type,Authorization").split(',').map(header => header.trim()).filter(header => header);
|
||||||
// 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.
|
|
||||||
// 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 ---
|
// --- Validation ---
|
||||||
if (!vapidPublicKey || !vapidPrivateKey || !vapidSubject) {
|
if (!vapidPublicKey || !vapidPrivateKey || !vapidSubject) {
|
||||||
logger.error('Error: VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY, and VAPID_SUBJECT must be set in the environment variables.');
|
console.error('Error: VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY, and VAPID_SUBJECT must be set in the environment variables.');
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!fs.existsSync(subscriptionsFilePath)) {
|
||||||
|
console.warn(`Warning: Subscriptions file not found at ${subscriptionsFilePath}. Creating an empty file.`);
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(subscriptionsFilePath, '{}', 'utf8');
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Error: Could not create subscriptions file at ${subscriptionsFilePath}.`, err);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- Web Push Setup ---
|
// --- Web Push Setup ---
|
||||||
webpush.setVapidDetails(
|
webpush.setVapidDetails(
|
||||||
vapidSubject,
|
vapidSubject,
|
||||||
@@ -85,250 +42,108 @@ webpush.setVapidDetails(
|
|||||||
vapidPrivateKey
|
vapidPrivateKey
|
||||||
);
|
);
|
||||||
|
|
||||||
// Configure DNS settings for more reliable resolution in containerized environments
|
// --- Subscription Loading ---
|
||||||
// These settings can help with temporary DNS resolution failures
|
let subscriptions = {};
|
||||||
dns.setDefaultResultOrder('ipv4first'); // Prefer IPv4 to avoid some IPv6 issues in containers
|
try {
|
||||||
const dnsTimeout = parseInt(process.env.DNS_TIMEOUT_MS || 5000, 10);
|
const data = fs.readFileSync(subscriptionsFilePath, 'utf8');
|
||||||
dns.setServers(dns.getServers()); // Reset DNS servers (can help in some Docker environments)
|
subscriptions = JSON.parse(data);
|
||||||
|
console.log(`Loaded ${Object.keys(subscriptions).length} subscriptions from ${subscriptionsFilePath}`);
|
||||||
// You can optionally configure a specific DNS server if needed:
|
} catch (err) {
|
||||||
// Example: dns.setServers(['8.8.8.8', '1.1.1.1']);
|
console.error(`Error reading or parsing subscriptions file at ${subscriptionsFilePath}. Please ensure it's valid JSON.`, err);
|
||||||
|
// Continue with empty subscriptions, but log the error
|
||||||
// --- Utility function for retrying web push notifications with exponential backoff ---
|
subscriptions = {};
|
||||||
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)) {
|
|
||||||
logger.warn(`Warning: Subscriptions file not found at ${subscriptionsFilePath}. Creating an empty file.`);
|
|
||||||
try {
|
|
||||||
fs.writeFileSync(subscriptionsFilePath, '{}', 'utf8');
|
|
||||||
subscriptions = {};
|
|
||||||
} catch (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
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
try {
|
|
||||||
const data = fs.readFileSync(subscriptionsFilePath, 'utf8');
|
|
||||||
subscriptions = JSON.parse(data || '{}'); // Handle empty file case
|
|
||||||
logger.info(`Loaded ${Object.keys(subscriptions).length} subscriptions from ${subscriptionsFilePath}`);
|
|
||||||
} catch (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 = {};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveSubscriptions() {
|
|
||||||
try {
|
|
||||||
fs.writeFileSync(subscriptionsFilePath, JSON.stringify(subscriptions, null, 2), 'utf8'); // Pretty print JSON
|
|
||||||
logger.info(`Subscriptions successfully saved to ${subscriptionsFilePath}`);
|
|
||||||
} catch (err) {
|
|
||||||
logger.error(`Error writing subscriptions file: ${subscriptionsFilePath}`, err);
|
|
||||||
// Note: The in-memory object is updated, but persistence failed.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initial load
|
|
||||||
loadSubscriptions();
|
|
||||||
|
|
||||||
|
|
||||||
// --- Express App Setup ---
|
// --- Express App Setup ---
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
|
// --- CORS Middleware ---
|
||||||
|
const corsOptions = {
|
||||||
|
origin: (origin, callback) => {
|
||||||
|
// Allow requests with no origin (like curl requests, mobile apps, etc) or from allowed list
|
||||||
|
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));
|
||||||
|
app.options('/flic-webhook', cors(corsOptions)); // Enable pre-flight for the webhook route
|
||||||
|
|
||||||
// --- Body Parsing Middleware ---
|
// --- Body Parsing Middleware ---
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
|
|
||||||
// --- Basic Authentication Middleware ---
|
// --- Authentication Middleware (Optional) ---
|
||||||
const authenticateBasic = (req, res, next) => {
|
const authenticateFlicRequest = (req, res, next) => {
|
||||||
// Skip authentication for OPTIONS requests (CORS preflight - Traefik might still forward them or handle them)
|
if (!flicSecret) {
|
||||||
// It's safe to keep this check.
|
return next(); // No secret configured, skip authentication
|
||||||
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;
|
const authHeader = req.headers.authorization;
|
||||||
|
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||||
if (!authHeader || !authHeader.startsWith('Basic ')) {
|
console.warn('Auth: Missing or malformed Authorization header');
|
||||||
logger.warn('Auth: Missing or malformed Basic Authorization header');
|
return res.status(401).json({ message: 'Unauthorized: Missing or malformed Bearer token' });
|
||||||
res.setHeader('WWW-Authenticate', 'Basic realm="Restricted Area"');
|
|
||||||
return res.status(401).json({ message: 'Unauthorized: Basic Authentication required' });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const credentials = authHeader.split(' ')[1];
|
const token = authHeader.split(' ')[1];
|
||||||
const decodedCredentials = Buffer.from(credentials, 'base64').toString('utf8');
|
if (token !== flicSecret) {
|
||||||
const [username, password] = decodedCredentials.split(':');
|
console.warn('Auth: Invalid Bearer token received');
|
||||||
|
return res.status(401).json({ message: 'Unauthorized: Invalid token' });
|
||||||
// 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' });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
next();
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- Routes ---
|
// --- Webhook Endpoint ---
|
||||||
|
app.post('/flic-webhook', authenticateFlicRequest, async (req, res) => {
|
||||||
|
const { button_id, click_type, timestamp } = req.body; // Flic might send serialNumber, check Flic docs/logs
|
||||||
|
|
||||||
// Subscribe endpoint: Add a new button->subscription mapping
|
console.log(`Received webhook: Button=${button_id}, Type=${click_type}, Timestamp=${timestamp || 'N/A'}`);
|
||||||
// Apply Basic Authentication
|
|
||||||
app.post('/subscribe', authenticateBasic, async (req, res) => {
|
|
||||||
let { button_id, subscription } = req.body;
|
|
||||||
|
|
||||||
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() === '') {
|
|
||||||
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) {
|
|
||||||
logger.warn('Subscription Error: Missing or invalid subscription object structure');
|
|
||||||
return res.status(400).json({ message: 'Bad Request: Missing or invalid subscription object' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalizedButtonId = button_id.toLowerCase(); // Use lowercase for consistency
|
|
||||||
|
|
||||||
// Update in-memory store
|
|
||||||
subscriptions[normalizedButtonId] = subscription;
|
|
||||||
logger.info(`Subscription for button ${normalizedButtonId} added/updated in memory.`);
|
|
||||||
|
|
||||||
// Persist to file
|
|
||||||
try {
|
|
||||||
saveSubscriptions(); // Call the save function
|
|
||||||
res.status(201).json({ message: `Subscription saved successfully for button ${normalizedButtonId}` });
|
|
||||||
} catch (err) // Catch potential synchronous errors from saveSubscriptions (though unlikely with writeFileSync)
|
|
||||||
{
|
|
||||||
// saveSubscriptions already logs the error, but we send a 500 response
|
|
||||||
res.status(500).json({ message: 'Internal Server Error: Failed to save subscription to file' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// --- 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';
|
|
||||||
|
|
||||||
// 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
|
// Basic validation
|
||||||
if (!click_type) {
|
if (!button_id || !click_type) {
|
||||||
logger.warn(`Webhook Error: Missing click_type query parameter`);
|
return res.status(400).json({ message: 'Bad Request: Missing button_id or click_type' });
|
||||||
return res.status(400).json({ message: 'Bad Request: Missing click_type query parameter' });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalizedButtonName = buttonName.toLowerCase(); // Use lowercase for lookup consistency
|
// Find the subscription associated with this button ID (case-insensitive compare might be safer)
|
||||||
|
const subscription = subscriptions[button_id.toLowerCase()] || subscriptions[button_id]; // Check both cases just in case
|
||||||
|
|
||||||
// Find the subscription associated with this normalized button name
|
|
||||||
const subscription = subscriptions[normalizedButtonName];
|
|
||||||
|
|
||||||
if (!subscription) {
|
if (!subscription) {
|
||||||
logger.warn(`Webhook: No subscription found for button ID: ${normalizedButtonName} (original: ${buttonName})`);
|
console.warn(`No subscription found for button ID: ${button_id}`);
|
||||||
return res.status(404).json({ message: `Not Found: No subscription configured for button ${normalizedButtonName}` });
|
return res.status(404).json({ message: `Not Found: No subscription configured for button ${button_id}` });
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Send Web Push Notification ---
|
// --- Send Web Push Notification ---
|
||||||
const payload = JSON.stringify({
|
const payload = JSON.stringify({
|
||||||
title: 'Flic Button Action',
|
title: 'Flic Button Action',
|
||||||
body: `Button ${normalizedButtonName}: ${click_type}`, // Simplified body
|
body: `Button ${button_id} - ${click_type}`,
|
||||||
data: {
|
data: { // Send structured data to the PWA
|
||||||
action: click_type,
|
action: click_type, // e.g., "SingleClick", "DoubleClick", "Hold"
|
||||||
button: normalizedButtonName, // Send normalized button name
|
button: button_id,
|
||||||
timestamp: timestamp || new Date().toISOString(),
|
timestamp: timestamp || new Date().toISOString()
|
||||||
batteryLevel: batteryLevel // Use the extracted value
|
|
||||||
}
|
}
|
||||||
// icon: '/path/to/icon.png'
|
// icon: '/path/to/icon.png' // Optional: Add an icon URL accessible by the PWA
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
logger.debug(`Subscription endpoint: ${subscription.endpoint}`);
|
console.log(`Sending push notification to endpoint: ${subscription.endpoint.substring(0, 30)}...`);
|
||||||
logger.info(`Sending push notification for ${normalizedButtonName} to endpoint: ${subscription.endpoint.substring(0, 40)}...`);
|
await webpush.sendNotification(subscription, payload);
|
||||||
await sendWebPushWithRetry(subscription, payload);
|
console.log(`Push notification sent successfully for button ${button_id}.`);
|
||||||
logger.info(`Push notification sent successfully for button ${normalizedButtonName}.`);
|
|
||||||
res.status(200).json({ message: 'Push notification sent successfully' });
|
res.status(200).json({ message: 'Push notification sent successfully' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Error sending push notification for button ${normalizedButtonName}:`, error.body || error.message || error);
|
console.error(`Error sending push notification for button ${button_id}:`, error);
|
||||||
|
|
||||||
if (error.statusCode === 404 || error.statusCode === 410) {
|
if (error.statusCode === 404 || error.statusCode === 410) {
|
||||||
logger.warn(`Subscription for button ${normalizedButtonName} is invalid or expired (404/410). Removing it.`);
|
console.warn(`Subscription for button ${button_id} is invalid or expired (404/410). Consider removing it.`);
|
||||||
// Optionally remove the stale subscription
|
// Optionally, you could implement logic here to remove the stale subscription
|
||||||
delete subscriptions[normalizedButtonName];
|
// delete subscriptions[button_id];
|
||||||
saveSubscriptions(); // Attempt to save the updated list
|
// fs.writeFileSync(subscriptionsFilePath, JSON.stringify(subscriptions, null, 2), 'utf8');
|
||||||
res.status(410).json({ message: 'Subscription Gone' });
|
res.status(410).json({ message: 'Subscription Gone' });
|
||||||
} else {
|
} else {
|
||||||
res.status(500).json({ message: 'Internal Server Error: Failed to send push notification' });
|
res.status(500).json({ message: 'Internal Server Error: Failed to send push notification' });
|
||||||
@@ -336,43 +151,43 @@ app.get('/webhook/:click_type', authenticateBasic, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- Start Server ---
|
// --- Health Check Endpoint (Optional) ---
|
||||||
// Use http.createServer to allow graceful shutdown
|
app.get('/health', (req, res) => {
|
||||||
const server = http.createServer(app);
|
res.status(200).json({ status: 'UP', timestamp: new Date().toISOString() });
|
||||||
|
|
||||||
server.listen(port, () => {
|
|
||||||
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 ---
|
// --- Start Server ---
|
||||||
const closeGracefully = (signal) => {
|
app.listen(port, () => {
|
||||||
logger.info(`${signal} signal received: closing HTTP server`);
|
console.log(`Flic Webhook to WebPush server listening on port ${port}`);
|
||||||
server.close(() => {
|
console.log(`Allowed Origins: ${allowedOrigins.length > 0 ? allowedOrigins.join(', ') : '*'}`);
|
||||||
logger.info('HTTP server closed');
|
console.log(`Allowed Methods: ${allowedMethods.join(', ')}`);
|
||||||
// Perform any other cleanup here if needed
|
console.log(`Allowed Headers: ${allowedHeaders.join(', ')}`);
|
||||||
|
console.log(`Authentication: ${flicSecret ? 'Enabled (Bearer Token)' : 'Disabled'}`);
|
||||||
|
console.log(`Subscriptions File: ${subscriptionsFilePath}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Graceful Shutdown (Optional but Recommended) ---
|
||||||
|
process.on('SIGTERM', () => {
|
||||||
|
console.log('SIGTERM signal received: closing HTTP server');
|
||||||
|
app.close(() => { // Doesn't work directly with app.listen, need http.createServer
|
||||||
|
console.log('HTTP server closed');
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
});
|
});
|
||||||
|
// If server.close doesn't exit quickly, force exit after timeout
|
||||||
// Force close server after 10 seconds
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
logger.error('Could not close connections in time, forcefully shutting down');
|
console.error('Could not close connections in time, forcefully shutting down');
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}, 10000); // 10 seconds timeout - This is a literal so no need to parse
|
}, 10000); // 10 seconds timeout
|
||||||
}
|
});
|
||||||
|
|
||||||
process.on('SIGTERM', () => closeGracefully('SIGTERM'));
|
process.on('SIGINT', () => {
|
||||||
process.on('SIGINT', () => closeGracefully('SIGINT')); // Handle Ctrl+C
|
console.log('SIGINT signal received: closing HTTP server');
|
||||||
|
app.close(() => {
|
||||||
|
console.log('HTTP server closed');
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
setTimeout(() => {
|
||||||
|
console.error('Could not close connections in time, forcefully shutting down');
|
||||||
|
process.exit(1);
|
||||||
|
}, 10000);
|
||||||
|
});
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
[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
|
|
||||||
Reference in New Issue
Block a user