Compare commits

...

29 Commits

Author SHA1 Message Date
cpu
675c0a2d87 beter variable names 2025-03-28 21:06:41 +01:00
cpu
1a241e5355 clean up 2025-03-28 20:46:11 +01:00
cpu
43cf8801a0 clean up 2025-03-28 20:38:27 +01:00
cpu
b9199cd964 clean up 2025-03-28 19:49:47 +01:00
cpu
fc25c4a000 labels 2025-03-28 19:21:40 +01:00
cpu
4f76bae187 cleanup 2025-03-28 19:18:32 +01:00
cpu
058441b6e6 default button name 2025-03-28 17:51:25 +01:00
cpu
5a3b4974c4 changed the route name 2025-03-28 17:28:24 +01:00
cpu
fda9178264 fixed cors 2025-03-28 15:45:20 +01:00
cpu
b6f9e07d70 enable debug 2025-03-28 03:11:14 +01:00
cpu
13a16d71f8 added debug for headers received 2025-03-28 02:35:07 +01:00
cpu
4f46c1be99 change GET to POST, removed /health 2025-03-28 02:12:52 +01:00
cpu
228f4984d8 dns retry 2025-03-28 01:47:05 +01:00
cpu
398c6473a4 flow diagrams 2025-03-27 20:17:35 +01:00
cpu
c89eaacd42 auth 2025-03-26 23:57:03 +01:00
cpu
682dc6942a added subscription route 2025-03-26 20:31:31 +01:00
cpu
064f803784 Merge branch 'one_line_key' 2025-03-26 19:22:19 +01:00
cpu
9b4bf1e255 flask and node.js solution 2025-03-26 19:12:43 +01:00
cpu
fc7f4f4b7a rewritten 2025-03-26 09:36:58 +01:00
cpu
b87e30f6b4 fix 2025-03-26 09:25:28 +01:00
cpu
faa32510df again validations 2025-03-26 08:59:03 +01:00
cpu
ce7ab594e2 improved validations 2025-03-26 08:52:30 +01:00
cpu
f500c00896 logs 2025-03-26 08:34:51 +01:00
cpu
b246923283 clean up 2025-03-26 08:21:33 +01:00
cpu
907ad382dc helper prints 2025-03-26 08:16:56 +01:00
cpu
102d2e2748 fixed key to one line 2025-03-26 06:38:33 +01:00
cpu
f2de1e55d0 CORS 2025-03-26 05:33:53 +01:00
cpu
ba9704d3c2 config 2025-03-26 03:57:54 +01:00
cpu
95a5b893ec first version 2025-03-26 03:09:49 +01:00
15 changed files with 1791 additions and 2 deletions

16
.dockerignore Normal file
View File

@@ -0,0 +1,16 @@
node_modules
npm-debug.log
Dockerfile
.dockerignore
.git
.gitignore
README.md
*.example
images
.env
env
labels
subscriptions.json

49
.env.example Normal file
View File

@@ -0,0 +1,49 @@
# Flic to PWA WebPush Configuration
# --- VAPID Keys (Required) ---
# Generate using: npx web-push generate-vapid-keys
VAPID_PUBLIC_KEY=
VAPID_PRIVATE_KEY=
VAPID_SUBJECT=mailto:mailto:user@example.org
# --- Server Configuration ---
# Internal port for the Node.js app
PORT=3000
# Path inside the container
SUBSCRIPTIONS_FILE=/app/subscriptions.json
# Default button name to use when not specified
DEFAULT_BUTTON_NAME=game-button
# --- 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
# --- CORS Configuration (Optional but Recommended) ---
# Comma-separated list of allowed origins for requests (e.g., your PWA frontend URL)
# If blank or not set, CORS might block browser requests (like from a setup page).
# Use '*' carefully, preferably list specific domains.
ALLOWED_ORIGINS=https://game-timer.virtonline.eu
ALLOWED_METHODS=POST,GET,OPTIONS
ALLOWED_HEADERS=Content-Type,Authorization
# --- 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
# --- 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

20
.gitignore vendored Normal file
View File

@@ -0,0 +1,20 @@
myenv
.vscode
.cursor
subscriptions.json
# Node.js
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.env
env
# OS generated files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db

23
Dockerfile Normal file
View File

@@ -0,0 +1,23 @@
# Use an official Node.js runtime as a parent image
# Alpine Linux is chosen for its small size
FROM node:20-alpine
# Set the working directory in the container
WORKDIR /app
# Copy package.json and package-lock.json (if available)
COPY package*.json ./
# Install app dependencies using npm ci for faster, reliable builds
# Use --only=production to avoid installing devDependencies
RUN npm ci --only=production
# Copy the rest of the application code
COPY . .
# Make port 3000 available to the world outside this container
# This is the port our Node.js app will listen on
EXPOSE 3000
# Define the command to run your app using CMD which defines your runtime
CMD [ "node", "server.js" ]

256
README.md
View File

@@ -1,3 +1,255 @@
# flic-webpush-localstack
# Flic to PWA WebPush Backend
AWS Lambda function using LocalStack in Docker that receives HTTP requests from Flic buttons and sends WebPush notifications.
This project provides a self-hosted backend service that listens for HTTP requests from Flic smart buttons and triggers Web Push notifications to specific Progressive Web App (PWA) instances. The goal is to allow a Flic button press (Single Click, Double Click, Hold) to trigger actions within the PWA via push messages handled by a Service Worker.
It's designed to be run as a Docker container and integrated with Traefik v3 for SSL termination and routing.
## Features
* 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 Basic Authentication for securing the `/webhook` and `/subscribe` endpoints.
* CORS configuration for allowing requests (needed if your PWA management interface interacts with the `/subscribe` endpoint).
## Prerequisites
* **Docker:** [Install Docker](https://docs.docker.com/engine/install/)
* **Traefik:** A running Traefik v3 instance configured with SSL (Let's Encrypt recommended) and connected to a Docker network named `traefik`. You need to know your certificate resolver name.
* **Domain Name:** A domain or subdomain pointing to your Traefik instance (e.g., `webpush.virtonline.eu`). This will be used for the webhook URL.
* **Flic Hub/Service:** Configured to send HTTP requests for button actions. You'll need the serial number(s) of your Flic button(s).
* **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
<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
## Setup
1. **Clone the Repository:**
```bash
git clone https://gitea.virtonline.eu/2HoursProject/flic-webhook-webpush.git
cd flic-webhook-webpush
```
2. **Generate VAPID Keys:**
Web Push requires VAPID keys for security. Generate them once and store them into `.env`. You can use `npx`:
```bash
npx web-push generate-vapid-keys
```
This will output a Public Key and a Private Key.
3. **Configure Environment Variables:**
* Copy the example `.env` file:
```bash
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.
* `DEFAULT_BUTTON_NAME`: (Default: `game-button`) The default button name to use when the `Button-Name` header is not provided in the webhook request.
* `BASIC_AUTH_USERNAME`: (Optional) Username for Basic Authentication. If set along with `BASIC_AUTH_PASSWORD`, authentication will be enabled for `/webhook` and `/subscribe`.
* `BASIC_AUTH_PASSWORD`: (Optional) Password for Basic Authentication. If set along with `BASIC_AUTH_USERNAME`, authentication will be enabled.
* `ALLOWED_ORIGINS`: Comma-separated list of domains allowed by CORS. Include your PWA's domain if it needs to interact directly (e.g., for setup). Example: `https://my-pwa.com`.
* `ALLOWED_METHODS`: (Default: `POST,OPTIONS`) Standard methods needed.
* `ALLOWED_HEADERS`: (Default: `Content-Type,Authorization`) Standard headers needed.
* `NOTIFICATION_MAX_RETRIES`: (Default: `3`) Number of retry attempts for failed push notifications. Must be a number.
* `NOTIFICATION_FIRST_RETRY_DELAY_MS`: (Default: `10`) Delay in milliseconds for the first retry attempt. Setting to 0-10ms provides near-immediate first retry for transient DNS issues. Must be a number.
* `NOTIFICATION_SUBSEQUENT_RETRY_DELAY_MS`: (Default: `1000`) Base delay in milliseconds for subsequent retries. Each additional retry uses this value with exponential backoff and jitter. Must be a number.
* `DNS_TIMEOUT_MS`: (Default: `5000`) DNS resolution timeout in milliseconds. Must be a number.
* `HTTP_TIMEOUT_MS`: (Default: `10000`) HTTP request timeout in milliseconds. Must be a number.
* `LOG_LEVEL`: (Default: `info`) Controls verbosity of logs. Valid values are `error`, `warn`, `info`, or `debug`. Use `debug` to see detailed header information and other diagnostic messages.
* `TRAEFIK_SERVICE_HOST`: Your public domain for this service (e.g., `webpush.virtonline.eu`).
* `TRAEFIK_CERT_RESOLVER`: The name of your TLS certificate resolver configured in Traefik (e.g., `le`, `myresolver`).
4. **Configure Traefik Labels:**
* Copy the example `labels` file:
```bash
cp labels.example labels
```
5. **Prepare Subscription Mapping File:**
* Edit the `subscriptions.json` file
* Add entries mapping your Flic button's serial number (as a lowercase string key) to the PWA `PushSubscription` object.
```json
{
"game": { // <-- Replace with your actual Flic Button name (lowercase recommended)
"endpoint": "https://your_pwa_push_endpoint...",
"expirationTime": null,
"keys": {
"p256dh": "YOUR_PWA_SUBSCRIPTION_P256DH_KEY",
"auth": "YOUR_PWA_SUBSCRIPTION_AUTH_KEY"
}
}
// Add more entries for other buttons if needed
}
```
* Ensure this file contains valid JSON.
## Running the Service
1. **Build the Docker Image:**
Make sure you are in the `flic-webhook-webpush` directory.
```bash
docker build -t flic-webhook-webpush:latest .
```
2. **Run 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
docker run -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
```
3. **Check Logs:**
Monitor the container logs to ensure it started correctly and to see incoming webhook requests or errors.
```bash
docker logs -f flic-webhook-webpush
```
You should see messages indicating the server started, configuration details, and subscription loading status.
4. **Verify Traefik:** Check your Traefik dashboard to ensure the `flic-webhook-webpush` service and router are discovered and healthy.
## Flic Button Configuration
In your Flic app or Flic Hub SDK interface:
1. Select your Flic button.
2. Add an "Internet Request" action.
3. Fill in the following details:
* Set `GET` method.
* Set URL with query parameter: `https://<your_domain>/webhook/SingleClick` (Replace `<your_domain>` with your actual service domain, e.g., `webpush.virtonline.eu`).
* **If Basic Authentication is enabled:**
* Set the `Username` and `Password` fields to the values from your `BASIC_AUTH_USERNAME` and `BASIC_AUTH_PASSWORD` environment variables.
* **If Basic Authentication is disabled:**
* Leave the `Username` and `Password` fields empty.
* Tap on `Save action`.
4. Repeat for Double Click and/or Hold events, changing the `click_type` parameter accordingly (e.g., `/DoubleClick`).
## 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": "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:
```json
{
"action": "SingleClick", // or DoubleClick, Hold
"button": "game-button", // Normalized button name (lowercase)
"timestamp": "2024-03-28T15:00:00.000Z", // ISO 8601 timestamp or current server time
"batteryLevel": 100 // Integer percentage (0-100) or 'N/A' if not provided
}
```
* **Responses:**
* `200 OK`: Webhook received, push notification sent successfully.
* `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.
## 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 `<username>`, `<password>`, `<your_domain>`, and `<button_name>` with your actual values. The `Button-Name` header is optional and will default to the value of `DEFAULT_BUTTON_NAME` if not provided.
```bash
# Generate Base64 credentials (run this once)
# echo -n '<username>:<password>' | base64
# Example using generated Base64 string (replace YOUR_BASE64_CREDS)
curl -X GET "https://<your_domain>/webhook/SingleClick" \
-H "Authorization: Basic YOUR_BASE64_CREDS" \
-H "Button-Name: <button_name>" \
-H "Timestamp: $(date -u +%Y-%m-%dT%H:%M:%SZ)" \
-H "Button-Battery-Level: 100" \
# Example using curl's built-in Basic Auth (-u)
curl -X GET "https://<your_domain>/webhook/SingleClick" \
-u "<username>:<password>" \
-H "Button-Name: <button_name>" \
-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 ID 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, `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

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

View File

@@ -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

BIN
images/interaction-flow.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

View File

@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

32
labels.example Normal file
View File

@@ -0,0 +1,32 @@
# Enable Traefik for this container
traefik.enable=true
# Docker Network
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,OPTIONS
traefik.http.middlewares.cors-headers.headers.accesscontrolalloworiginlist=https://game-timer.virtonline.eu
traefik.http.middlewares.cors-headers.headers.accesscontrolallowheaders=Content-Type,Authorization
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

926
package-lock.json generated Normal file
View File

@@ -0,0 +1,926 @@
{
"name": "flic-webhook-webpush",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "flic-webhook-webpush",
"version": "1.0.0",
"license": "MIT",
"dependencies": {
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.19.2",
"web-push": "^3.6.7"
}
},
"node_modules/accepts": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
"integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
"dependencies": {
"mime-types": "~2.1.34",
"negotiator": "0.6.3"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/agent-base": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz",
"integrity": "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==",
"engines": {
"node": ">= 14"
}
},
"node_modules/array-flatten": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg=="
},
"node_modules/asn1.js": {
"version": "5.4.1",
"resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz",
"integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==",
"dependencies": {
"bn.js": "^4.0.0",
"inherits": "^2.0.1",
"minimalistic-assert": "^1.0.0",
"safer-buffer": "^2.1.0"
}
},
"node_modules/bn.js": {
"version": "4.12.1",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.1.tgz",
"integrity": "sha512-k8TVBiPkPJT9uHLdOKfFpqcfprwBFOAAXXozRubr7R7PfIuKvQlzcI4M0pALeqXN09vdaMbUdUj+pass+uULAg=="
},
"node_modules/body-parser": {
"version": "1.20.3",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz",
"integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==",
"dependencies": {
"bytes": "3.1.2",
"content-type": "~1.0.5",
"debug": "2.6.9",
"depd": "2.0.0",
"destroy": "1.2.0",
"http-errors": "2.0.0",
"iconv-lite": "0.4.24",
"on-finished": "2.4.1",
"qs": "6.13.0",
"raw-body": "2.5.2",
"type-is": "~1.6.18",
"unpipe": "1.0.0"
},
"engines": {
"node": ">= 0.8",
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/call-bound": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"get-intrinsic": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/content-disposition": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
"integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
"dependencies": {
"safe-buffer": "5.2.1"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/content-type": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie": {
"version": "0.7.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz",
"integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie-signature": {
"version": "1.0.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",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"dependencies": {
"ms": "2.0.0"
}
},
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/destroy": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
"integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
"engines": {
"node": ">= 0.8",
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/dotenv": {
"version": "16.4.7",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz",
"integrity": "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/ecdsa-sig-formatter": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
"dependencies": {
"safe-buffer": "^5.0.1"
}
},
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="
},
"node_modules/encodeurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/escape-html": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="
},
"node_modules/etag": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/express": {
"version": "4.21.2",
"resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
"integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==",
"dependencies": {
"accepts": "~1.3.8",
"array-flatten": "1.1.1",
"body-parser": "1.20.3",
"content-disposition": "0.5.4",
"content-type": "~1.0.4",
"cookie": "0.7.1",
"cookie-signature": "1.0.6",
"debug": "2.6.9",
"depd": "2.0.0",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"etag": "~1.8.1",
"finalhandler": "1.3.1",
"fresh": "0.5.2",
"http-errors": "2.0.0",
"merge-descriptors": "1.0.3",
"methods": "~1.1.2",
"on-finished": "2.4.1",
"parseurl": "~1.3.3",
"path-to-regexp": "0.1.12",
"proxy-addr": "~2.0.7",
"qs": "6.13.0",
"range-parser": "~1.2.1",
"safe-buffer": "5.2.1",
"send": "0.19.0",
"serve-static": "1.16.2",
"setprototypeof": "1.2.0",
"statuses": "2.0.1",
"type-is": "~1.6.18",
"utils-merge": "1.0.1",
"vary": "~1.1.2"
},
"engines": {
"node": ">= 0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/finalhandler": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz",
"integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==",
"dependencies": {
"debug": "2.6.9",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"on-finished": "2.4.1",
"parseurl": "~1.3.3",
"statuses": "2.0.1",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
"integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/fresh": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
"integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/http_ece": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz",
"integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==",
"engines": {
"node": ">=16"
}
},
"node_modules/http-errors": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz",
"integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==",
"dependencies": {
"depd": "2.0.0",
"inherits": "2.0.4",
"setprototypeof": "1.2.0",
"statuses": "2.0.1",
"toidentifier": "1.0.1"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/https-proxy-agent": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
"dependencies": {
"agent-base": "^7.1.2",
"debug": "4"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/https-proxy-agent/node_modules/debug": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz",
"integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/https-proxy-agent/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
},
"node_modules/iconv-lite": {
"version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
"engines": {
"node": ">= 0.10"
}
},
"node_modules/jwa": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.0.tgz",
"integrity": "sha512-jrZ2Qx916EA+fq9cEAeCROWPTfCwi1IVHqT2tapuqLEVVDKFDENFw1oL+MwrTvH6msKxsd1YTDVw6uKEcsrLEA==",
"dependencies": {
"buffer-equal-constant-time": "1.0.1",
"ecdsa-sig-formatter": "1.0.11",
"safe-buffer": "^5.0.1"
}
},
"node_modules/jws": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.0.tgz",
"integrity": "sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==",
"dependencies": {
"jwa": "^2.0.0",
"safe-buffer": "^5.0.1"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/media-typer": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
"integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/merge-descriptors": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
"integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/methods": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
"integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
"integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
"bin": {
"mime": "cli.js"
},
"engines": {
"node": ">=4"
}
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/minimalistic-assert": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A=="
},
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="
},
"node_modules/negotiator": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
"integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
"engines": {
"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",
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/on-finished": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
"dependencies": {
"ee-first": "1.1.1"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
"integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/path-to-regexp": {
"version": "0.1.12",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ=="
},
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
"dependencies": {
"forwarded": "0.2.0",
"ipaddr.js": "1.9.1"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/qs": {
"version": "6.13.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz",
"integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==",
"dependencies": {
"side-channel": "^1.0.6"
},
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/range-parser": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
"integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/raw-body": {
"version": "2.5.2",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz",
"integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==",
"dependencies": {
"bytes": "3.1.2",
"http-errors": "2.0.0",
"iconv-lite": "0.4.24",
"unpipe": "1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
]
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
},
"node_modules/send": {
"version": "0.19.0",
"resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz",
"integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==",
"dependencies": {
"debug": "2.6.9",
"depd": "2.0.0",
"destroy": "1.2.0",
"encodeurl": "~1.0.2",
"escape-html": "~1.0.3",
"etag": "~1.8.1",
"fresh": "0.5.2",
"http-errors": "2.0.0",
"mime": "1.6.0",
"ms": "2.1.3",
"on-finished": "2.4.1",
"range-parser": "~1.2.1",
"statuses": "2.0.1"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/send/node_modules/encodeurl": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz",
"integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/send/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
},
"node_modules/serve-static": {
"version": "1.16.2",
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz",
"integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==",
"dependencies": {
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"parseurl": "~1.3.3",
"send": "0.19.0"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="
},
"node_modules/side-channel": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.3",
"side-channel-list": "^1.0.0",
"side-channel-map": "^1.0.1",
"side-channel-weakmap": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-list": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
"integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-map": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-weakmap": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3",
"side-channel-map": "^1.0.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/statuses": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
"integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
"engines": {
"node": ">=0.6"
}
},
"node_modules/type-is": {
"version": "1.6.18",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
"dependencies": {
"media-typer": "0.3.0",
"mime-types": "~2.1.24"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
"integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/utils-merge": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
"integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
"engines": {
"node": ">= 0.4.0"
}
},
"node_modules/vary": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/web-push": {
"version": "3.6.7",
"resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz",
"integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==",
"dependencies": {
"asn1.js": "^5.3.0",
"http_ece": "1.2.0",
"https-proxy-agent": "^7.0.0",
"jws": "^4.0.0",
"minimist": "^1.2.5"
},
"bin": {
"web-push": "src/cli.js"
},
"engines": {
"node": ">= 16"
}
}
}
}

25
package.json Normal file
View File

@@ -0,0 +1,25 @@
{
"name": "flic-webhook-webpush",
"version": "1.0.0",
"description": "Backend to receive Flic webhooks and send Web Push notifications",
"main": "server.js",
"scripts": {
"start": "node server.js",
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [
"flic",
"webpush",
"pwa",
"webhook",
"docker"
],
"author": "Your Name",
"license": "MIT",
"dependencies": {
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.19.2",
"web-push": "^3.6.7"
}
}

399
server.js Normal file
View File

@@ -0,0 +1,399 @@
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();
// --- Configuration ---
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, '/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) {
logger.error('Error: VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY, and VAPID_SUBJECT must be set in the environment variables.');
process.exit(1);
}
// --- Web Push Setup ---
webpush.setVapidDetails(
vapidSubject,
vapidPublicKey,
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)) {
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 ---
const app = express();
// --- CORS Middleware ---
const corsOptions = {
origin: (origin, callback) => {
if (!origin || allowedOrigins.length === 0 || allowedOrigins.includes(origin)) {
callback(null, true);
} else {
console.warn(`CORS: Blocked origin: ${origin}`);
callback(new Error('Not allowed by CORS'));
}
},
methods: allowedMethods,
allowedHeaders: allowedHeaders,
optionsSuccessStatus: 204 // For pre-flight requests
};
app.use(cors(corsOptions));
// Enable pre-flight requests for all relevant routes
app.options('/webhook', cors(corsOptions));
app.options('/subscribe', cors(corsOptions));
// --- Body Parsing Middleware ---
app.use(express.json());
// --- Basic Authentication Middleware ---
const authenticateBasic = (req, res, next) => {
// Skip authentication for OPTIONS requests (CORS preflight)
if (req.method === 'OPTIONS') {
return next();
}
// 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('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 credentials = authHeader.split(' ')[1];
const decodedCredentials = Buffer.from(credentials, 'base64').toString('utf8');
const [username, password] = decodedCredentials.split(':');
// 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' });
}
};
// --- Routes ---
// Subscribe endpoint: Add a new button->subscription mapping
// Apply Basic Authentication
app.post('/subscribe', authenticateBasic, async (req, res) => {
let { button_id, subscription } = req.body;
logger.debug('All headers received:');
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:');
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 (!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 normalizedButtonName = buttonName.toLowerCase(); // Use lowercase for lookup consistency
// Find the subscription associated with this normalized button name
const subscription = subscriptions[normalizedButtonName];
if (!subscription) {
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 ${normalizedButtonName}: ${click_type}`, // Simplified body
data: {
action: click_type,
button: normalizedButtonName, // Send normalized button name
timestamp: timestamp || new Date().toISOString(),
batteryLevel: batteryLevel // Use the extracted value
}
// icon: '/path/to/icon.png'
});
try {
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) {
logger.error(`Error sending push notification for button ${normalizedButtonName}:`, error.body || error.message || error);
if (error.statusCode === 404 || error.statusCode === 410) {
logger.warn(`Subscription for button ${normalizedButtonName} is invalid or expired (404/410). Removing it.`);
// Optionally remove the stale subscription
delete subscriptions[normalizedButtonName];
saveSubscriptions(); // Attempt to save the updated list
res.status(410).json({ message: 'Subscription Gone' });
} else {
res.status(500).json({ message: 'Internal Server Error: Failed to send push notification' });
}
}
});
// --- Start Server ---
// Use http.createServer to allow graceful shutdown
const server = http.createServer(app);
server.listen(port, () => {
logger.info(`Flic Webhook to WebPush server listening on port ${port}`);
logger.info(`Allowed Origins: ${allowedOrigins.length > 0 ? allowedOrigins.join(', ') : '(Any)'}`);
logger.info(`Allowed Methods: ${allowedMethods.join(', ')}`);
logger.info(`Allowed Headers: ${allowedHeaders.join(', ')}`);
// 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(`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) => {
logger.info(`${signal} signal received: closing HTTP server`);
server.close(() => {
logger.info('HTTP server closed');
// Perform any other cleanup here if needed
process.exit(0);
});
// Force close server after 10 seconds
setTimeout(() => {
logger.error('Could not close connections in time, forcefully shutting down');
process.exit(1);
}, 10000); // 10 seconds timeout - This is a literal so no need to parse
}
process.on('SIGTERM', () => closeGracefully('SIGTERM'));
process.on('SIGINT', () => closeGracefully('SIGINT')); // Handle Ctrl+C

View File

@@ -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