diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..06749c9 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,16 @@ +node_modules +npm-debug.log +Dockerfile +.dockerignore +.git +.gitignore +README.md +*.example +images +.env +env +labels +subscriptions.json + + + diff --git a/.env b/.env new file mode 100644 index 0000000..5088e87 --- /dev/null +++ b/.env @@ -0,0 +1,39 @@ +# Flic to PWA WebPush Configuration + +# --- VAPID Keys (Required) --- +# Generate using: npx web-push generate-vapid-keys +VAPID_PUBLIC_KEY=BKfRJXjSQmAJ452gLwlK_8scGrW6qMU1mBRp39ONtcQHkSsQgmLAaODIyGbgHyRpnDEv3HfXV1oGh3SC0fHxY0E +VAPID_PRIVATE_KEY=ErEgsDKYQi5j2KPERC_gCtrEALAD0k-dWSwrrcD0-JU +VAPID_SUBJECT=mailto:admin@virtonline.eu + +# --- Server Configuration --- +# Internal port for the Node.js app +PORT=3000 +SUBSCRIPTIONS_FILE=subscriptions.json +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=player +BASIC_AUTH_PASSWORD=SevenOfNine + +# --- 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 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..7fb422f --- /dev/null +++ b/.env.example @@ -0,0 +1,39 @@ +# 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 +SUBSCRIPTIONS_FILE=/app/subscriptions.json +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 + +# --- 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 \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7df275b --- /dev/null +++ b/.gitignore @@ -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 \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..43ad70b --- /dev/null +++ b/Dockerfile @@ -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" ] \ No newline at end of file diff --git a/README.md b/README.md index 2c60306..0e60fcb 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,247 @@ -# 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. \ No newline at end of file +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. + +## 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 +Subscription Flow + +### Interaction Flow +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: `subscriptions.json`) The path *inside the container* where the button-to-subscription mapping is stored. + * `DEFAULT_BUTTON_NAME`: (Default: `game-button`) The default button name to use when the `Button-Name` header is not provided in the webhook request. + * `BASIC_AUTH_USERNAME`: (Optional) Username for Basic Authentication. If set along with `BASIC_AUTH_PASSWORD`, authentication will be enabled for `/webhook` and `/subscribe`. + * `BASIC_AUTH_PASSWORD`: (Optional) Password for Basic Authentication. If set along with `BASIC_AUTH_USERNAME`, authentication will be enabled. + * `NOTIFICATION_MAX_RETRIES`: (Default: `3`) Number of retry attempts for failed push notifications. Must be a number. + * `NOTIFICATION_FIRST_RETRY_DELAY_MS`: (Default: `10`) Delay in milliseconds for the first retry attempt. Setting to 0-10ms provides near-immediate first retry for transient DNS issues. Must be a number. + * `NOTIFICATION_SUBSEQUENT_RETRY_DELAY_MS`: (Default: `1000`) Base delay in milliseconds for subsequent retries. Each additional retry uses this value with exponential backoff and jitter. Must be a number. + * `DNS_TIMEOUT_MS`: (Default: `5000`) DNS resolution timeout in milliseconds. Must be a number. + * `HTTP_TIMEOUT_MS`: (Default: `10000`) HTTP request timeout in milliseconds. Must be a number. + * `LOG_LEVEL`: (Default: `info`) Controls verbosity of logs. Valid values are `error`, `warn`, `info`, or `debug`. Use `debug` to see detailed header information and other diagnostic messages. + +4. **Configure Traefik Labels:** + * Copy the example `labels` file: + ```bash + cp labels.example labels + ``` + +## 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 --rm -d --name flic-webhook-webpush \ + --network traefik \ + --env-file .env \ + --label-file labels \ + --mount type=bind,src=./subscriptions.json,dst=/app/subscriptions.json \ + flic-webhook-webpush:latest + ``` + +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: + * Select the `GET` method. + * Set URL with query parameter: `https:///webhook/SingleClick` (Replace `` with your actual service domain, e.g., `webpush.virtonline.eu`). + * **If Basic Authentication is enabled:** + * Set the Headers: + * Set the `Key` fields to `Authorization`. + * Set the `Value` fields to `Basic `. + * Click `ADD`. + * Tap on `SAVE ACTION`. +4. Repeat for Double Click (i.e., `/DoubleClick`) and Hold (i.e., `/Hold`) events. + The request for the Hold event should look like this: + +Flic Button Request + +## App Example: "HTTP Shortcuts" by waboodoo + +Search the Play Store - there might be others with similar names. + +1. Install the App: Download and install "HTTP Shortcuts" or a similar app from the Google Play Store. +2. Create a New Shortcut within the App: + * Open the app and usually tap a '+' or 'Add' button. + * Give your shortcut a Name (e.g., "Turn on Office Light", "Log Water Intake"). + * Choose an Icon. + * Enter the URL you want the request sent to (your webhook URL, IFTTT URL, Home Assistant webhook trigger, etc.). + * Select the HTTP Method (GET, POST, PUT, DELETE, etc. - often GET or POST for simple triggers). + * For POST/PUT: You'll likely need to configure the Request Body (e.g., JSON data) and Content Type (e.g., application/json). + * Authentication: Configure Basic Auth, Bearer Tokens, or custom Headers if your endpoint requires authentication. + * Other Options: Explore settings for response handling (show message on success/failure), timeouts, etc. + * Save the shortcut configuration within the app. +3. Add the Widget/Shortcut to your Home Screen: + * Go to your Android Home Screen. + * Long-press on an empty space. + * Select "Widgets". + * Scroll through the list to find the "HTTP Shortcuts" app (or the app you installed). + * Drag the app's widget or shortcut option onto your home screen. + * The app will likely ask you to choose which specific shortcut (the one you just created) this widget should trigger. Select it. +4. Test: Tap the newly created button on your home screen. It should trigger the internet request you configured. + +## API Endpoints + +* **`POST /subscribe`** + * **Description:** Adds or updates the Web Push subscription associated with a specific Flic button ID. + * **Authentication:** Optional Basic Authentication via `Authorization` header if `BASIC_AUTH_USERNAME` and `BASIC_AUTH_PASSWORD` are configured. + * **Request Body:** JSON object containing: + * `button_id` (string, optional): The unique identifier for the Flic button (lowercase recommended, e.g., "game-button", "lights-button"). If not provided, the value of `DEFAULT_BUTTON_NAME` environment variable will be used as a fallback. + * `subscription` (object, required): The [PushSubscription object](https://developer.mozilla.org/en-US/docs/Web/API/PushSubscription) obtained from the browser's Push API. + ```json + { + "button_id": "game-button", // Optional, defaults to DEFAULT_BUTTON_NAME environment variable + "subscription": { + "endpoint": "https://your_pwa_push_endpoint...", + "expirationTime": null, + "keys": { + "p256dh": "YOUR_PWA_SUBSCRIPTION_P256DH_KEY", + "auth": "YOUR_PWA_SUBSCRIPTION_AUTH_KEY" + } + } + } + ``` + * **Responses:** + * `201 Created`: Subscription saved successfully. + * `400 Bad Request`: Missing or invalid `subscription` object in the request body. + * `401 Unauthorized`: Missing or invalid Basic Authentication credentials (if authentication is enabled). + * `500 Internal Server Error`: Failed to save the subscription to the file. + +* **`GET /webhook/:click_type`** + * **Description:** Receives Flic button events. + * **Authentication:** Optional Basic Authentication via `Authorization` header if `BASIC_AUTH_USERNAME` and `BASIC_AUTH_PASSWORD` are configured. + * **URL Parameters:** + * `click_type` (required): The type of button press (e.g., `SingleClick`, `DoubleClick`, or `Hold`). + * **Required Headers:** + * `Button-Name`: The identifier of the Flic button (sent by the Flic system). If not provided, the value of `DEFAULT_BUTTON_NAME` environment variable will be used as a fallback. + * **Optional Headers:** + * `Timestamp`: Timestamp of the button event (sent by the Flic system). + * `Button-Battery-Level`: The battery level percentage of the button (sent by the Flic system). + * **Push Notification Payload (`data` field):** The service sends a JSON payload within the push notification. The client-side Service Worker can access this data via `event.data.json()`. The structure is: + ```bash + curl -X GET https://webpush.virtonline.eu/webhook/SingleClick \ + -H 'Authorization: Basic cGxheWVyOlNldmVuT2ZOaW5l' \ + -H "Button-Name: Game-button" \ + -H "Timestamp: 2025-03-26T01:10:20Z" \ + -H "Button-Battery-Level: 100" + ``` + * **Responses:** + * `200 OK`: Webhook received, push notification sent successfully. + * `400 Bad Request`: Missing `Button-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 ``, `` with your actual values. The `Button-Name` header is optional and will default to the value of `DEFAULT_BUTTON_NAME` if not provided. + +```bash +curl -X GET "https://webpush.virtonline.eu/webhook/SingleClick" \ + -H "Authorization: Basic $(echo -n 'user:password' | base64)" \ + -H "Button-Name: game-button" \ + -H "Timestamp: $(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + -H "Button-Battery-Level: 100" +``` + +The expected response should be: +```json +{"message":"Push notification sent successfully"} +``` + +If successful, the above response indicates that: +1. Your webhook endpoint is properly configured +2. The button name was found in your subscriptions.json file +3. The web push notification was successfully sent to the registered PUSH API endpoint (e.g. https://jmt17.google.com/fcm/send/cf907M...) + +If you receive a different response, refer to the Troubleshooting section below. + +## Troubleshooting + +* **Check Backend Logs:** `docker logs flic-webhook-webpush`. Look for errors related to configuration, file access, JSON parsing, authentication, or sending push notifications. + * To see detailed debug information including all headers received from the Flic button, set `LOG_LEVEL=debug` in your .env file. +* **Check Traefik Logs:** `docker logs traefik`. Look for routing errors or certificate issues. +* **Verify `.env`:** Ensure all required variables are set correctly, especially VAPID keys and Traefik settings. +* **Verify `labels`:** Double-check that variables were correctly substituted manually and match your `.env` and Traefik setup. +* **Verify `subscriptions.json`:** Ensure it's valid JSON and the button serial number (key) matches exactly what Flic sends (check backend logs for "Received webhook: Button=..."). Check if the subscription details are correct. Case sensitivity matters for the JSON keys (button serials). +* **Check Flic Configuration:** Ensure the URL, Method, `click_type` parameter, and authentication details (Username/Password if enabled) are correct in the Flic action setup. Use `curl` or Postman to test the endpoint manually first. +* **PWA Service Worker:** Remember that the PWA needs a correctly registered Service Worker to receive and handle the incoming push messages. Ensure the PWA subscribes using the *same* `VAPID_PUBLIC_KEY` configured in the backend's `.env`. +* **Push Notification Retry Mechanism:** The service includes an optimized retry mechanism for handling temporary DNS resolution issues: + * First retry happens immediately or with minimal delay (controlled by `NOTIFICATION_FIRST_RETRY_DELAY_MS`, default 10ms) + * Subsequent retries use exponential backoff with jitter (starting from `NOTIFICATION_SUBSEQUENT_RETRY_DELAY_MS`, default 1000ms) + * Maximum number of retries is controlled by `NOTIFICATION_MAX_RETRIES` (default 3) + * This approach minimizes latency for transient DNS issues while preventing excessive requests for persistent problems + * Adjust these values in your `.env` file based on your network conditions and reliability requirements diff --git a/app.py b/app.py new file mode 100644 index 0000000..379d5ab --- /dev/null +++ b/app.py @@ -0,0 +1,289 @@ +import asyncio +import json +import logging +import os +import base64 +from typing import Dict, List +import signal +import pathlib + +import aiohttp +from aiohttp import web +from dotenv import load_dotenv +from pywebpush import webpush, WebPushException +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import ec + +# Load environment variables +load_dotenv() + +# Configure logging +logging.basicConfig( + level=getattr(logging, os.getenv('LOG_LEVEL', 'INFO')), + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' +) +logger = logging.getLogger(__name__) + +# CORS Configuration +ALLOWED_ORIGINS = [ + "https://game-timer.virtonline.eu", + # Add other allowed origins if needed +] +ALLOWED_METHODS = ["POST", "OPTIONS"] +ALLOWED_HEADERS = ["Content-Type"] + +class FlicButtonHandler: + def __init__(self): + # Load button configurations + self.button_configs = { + os.getenv('FLIC_BUTTON1_SERIAL'): self.handle_button1, + os.getenv('FLIC_BUTTON2_SERIAL'): self.handle_button2, + os.getenv('FLIC_BUTTON3_SERIAL'): self.handle_button3 + } + + # Ensure subscriptions file and directory exist + self.subscriptions_file = os.getenv('SUBSCRIPTIONS_FILE', 'app/subscriptions.json') + self._ensure_subscriptions_file() + + # Load subscriptions + self.subscriptions = self.load_subscriptions() + + # Prepare VAPID keys + self.vapid_private_key = self._decode_vapid_private_key() + + def _ensure_subscriptions_file(self): + """ + Ensure the subscriptions file and its parent directory exist. + Create them if they don't. + """ + try: + # Create parent directory if it doesn't exist + pathlib.Path(self.subscriptions_file).parent.mkdir(parents=True, exist_ok=True) + + # Create file if it doesn't exist + if not os.path.exists(self.subscriptions_file): + with open(self.subscriptions_file, 'w') as f: + json.dump([], f) + except Exception as e: + logger.error(f"Error ensuring subscriptions file: {e}") + raise + + def _decode_vapid_private_key(self): + """Load and strictly validate VAPID private key.""" + try: + # Get and clean the key + env_key = os.getenv('VAPID_PRIVATE_KEY', '').strip() + + # Reconstruct PEM format if missing headers + if not env_key.startswith('-----BEGIN PRIVATE KEY-----'): + env_key = f"-----BEGIN PRIVATE KEY-----\n{env_key}\n-----END PRIVATE KEY-----" + + # Strict validation and key preparation + key = serialization.load_pem_private_key( + env_key.encode('utf-8'), + password=None, + backend=default_backend() + ) + + # Return in strict PEM format + return key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption() + ).decode('utf-8') + + except Exception as e: + logger.error(f"CRITICAL: Invalid VAPID key - {str(e)}") + raise RuntimeError("Invalid VAPID private key configuration") from e + + def load_subscriptions(self) -> List[Dict]: + """Load web push subscriptions from file.""" + try: + with open(self.subscriptions_file, 'r') as f: + # Handle empty file case + content = f.read().strip() + return json.loads(content) if content else [] + except json.JSONDecodeError: + logger.error(f"Error decoding subscriptions from {self.subscriptions_file}") + return [] + + def save_subscriptions(self): + """Save web push subscriptions to file.""" + try: + with open(self.subscriptions_file, 'w') as f: + json.dump(self.subscriptions, f, indent=2) + except Exception as e: + logger.error(f"Error saving subscriptions: {e}") + + async def send_push_notification(self, subscription: Dict, message: str): + try: + # Determine audience (aud) claim for VAPID + endpoint = subscription['endpoint'] + aud = (endpoint.split('/send')[0] if '/send' in endpoint + else endpoint.split('/fcm/send')[0]) + + logger.debug(f"Sending to: {endpoint[:50]}...") + logger.debug(f"Using aud: {aud}") + + # Perform web push + result = webpush( + subscription_info=subscription, + data=message, + vapid_private_key=self.vapid_private_key, + vapid_claims={ + "sub": os.getenv('VAPID_CLAIM_EMAIL'), + "aud": aud + "/" # Ensure trailing slash + }, + ttl=86400 # 24 hour expiration + ) + logger.info("Push notification sent successfully") + return True + except Exception as e: + logger.error(f"Push failed: {str(e)}") + logger.error(f"Endpoint details: {subscription['endpoint']}") + logger.error(f"Keys: {subscription.get('keys', 'No keys found')}") + return False + + async def handle_button1(self): + """Handle first button action - e.g., Home Lights On""" + logger.info("Button 1 pressed: Home Lights On") + message = json.dumps({"action": "home_lights_on"}) + await self.broadcast_notification(message) + + async def handle_button2(self): + """Handle second button action - e.g., Security System Arm""" + logger.info("Button 2 pressed: Security System Arm") + message = json.dumps({"action": "security_arm"}) + await self.broadcast_notification(message) + + async def handle_button3(self): + """Handle third button action - e.g., Panic Button""" + logger.info("Button 3 pressed: Panic Alert") + message = json.dumps({"action": "panic_alert"}) + await self.broadcast_notification(message) + + async def broadcast_notification(self, message: str): + """Broadcast notification to all subscriptions with error handling.""" + if not self.subscriptions: + logger.warning("No subscriptions to broadcast to") + return + + success_count = 0 + for subscription in self.subscriptions: + try: + success = await self.send_push_notification(subscription, message) + if success: + success_count += 1 + except Exception as e: + logger.error(f"Failed to send to {subscription['endpoint'][:30]}...: {str(e)}") + + logger.info(f"Notifications sent: {success_count}/{len(self.subscriptions)}") + + async def handle_flic_webhook(self, request): + """Webhook endpoint for Flic button events.""" + try: + data = await request.json() + button_serial = data.get('serial') + + # Validate button serial + if button_serial not in self.button_configs: + logger.warning(f"Unknown button serial: {button_serial}") + return web.Response(status=400) + + # Call the corresponding button handler + handler = self.button_configs[button_serial] + await handler() + + return web.Response(status=200) + except Exception as e: + logger.error(f"Error processing Flic webhook: {e}") + return web.Response(status=500) + + async def handle_subscribe(self, request): + """Add a new web push subscription.""" + try: + subscription = await request.json() + + # Check if subscription already exists + if subscription not in self.subscriptions: + self.subscriptions.append(subscription) + self.save_subscriptions() + logger.info("New subscription added") + + return web.Response(status=200) + except Exception as e: + logger.error(f"Subscription error: {e}") + return web.Response(status=500) + +def create_app(): + """Create and configure the aiohttp application.""" + app = web.Application() + handler = FlicButtonHandler() + + async def options_handler(request): + """Handle OPTIONS requests for CORS preflight.""" + origin = request.headers.get('Origin', '') + if origin in ALLOWED_ORIGINS: + headers = { + 'Access-Control-Allow-Origin': origin, + 'Access-Control-Allow-Methods': ', '.join(ALLOWED_METHODS), + 'Access-Control-Allow-Headers': ', '.join(ALLOWED_HEADERS), + 'Access-Control-Max-Age': '86400', # 24 hours + } + return web.Response(status=200, headers=headers) + return web.Response(status=403) # Forbidden origin + + async def add_cors_headers(request, response): + """Add CORS headers to normal responses.""" + origin = request.headers.get('Origin', '') + if origin in ALLOWED_ORIGINS: + response.headers['Access-Control-Allow-Origin'] = origin + response.headers['Access-Control-Expose-Headers'] = 'Content-Type' + return response + + # Register middleware + app.on_response_prepare.append(add_cors_headers) + + # Setup routes with OPTIONS handlers + app.router.add_route('OPTIONS', '/flic-webhook', options_handler) + app.router.add_route('OPTIONS', '/subscribe', options_handler) + + # Original routes + app.router.add_post('/flic-webhook', handler.handle_flic_webhook) + app.router.add_post('/subscribe', handler.handle_subscribe) + + return app + +async def main(): + """Main application entry point.""" + app = create_app() + runner = web.AppRunner(app) + await runner.setup() + site = web.TCPSite(runner, '0.0.0.0', 8080) + await site.start() + + logger.info("Application started on port 8080") + + # Create an event to keep the application running + stop_event = asyncio.Event() + + def signal_handler(): + """Handle interrupt signals to gracefully stop the application.""" + logger.info("Received shutdown signal") + stop_event.set() + + # Register signal handlers + loop = asyncio.get_running_loop() + for sig in (signal.SIGINT, signal.SIGTERM): + loop.add_signal_handler(sig, signal_handler) + + # Wait until stop event is set + await stop_event.wait() + + # Cleanup + await runner.cleanup() + logger.info("Application shutting down") + +if __name__ == '__main__': + asyncio.run(main()) diff --git a/generate_vapid_keys.py b/generate_vapid_keys.py new file mode 100644 index 0000000..b0d01cf --- /dev/null +++ b/generate_vapid_keys.py @@ -0,0 +1,101 @@ +#!/usr/bin/env python3 +import os +import base64 +from cryptography.hazmat.primitives.asymmetric import ec +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.backends import default_backend + +def generate_vapid_keys(): + """ + Generate VAPID keys and update .env file, preserving existing variables. + Only regenerates keys if they are missing or empty. + """ + # Read existing .env file if it exists + env_vars = {} + if os.path.exists('.env'): + with open('.env', 'r') as f: + for line in f: + line = line.strip() + if line and not line.startswith('#') and '=' in line: + key, value = line.split('=', 1) + env_vars[key.strip()] = value.strip() + + # Check if we need to generate new keys + need_new_keys = ( + 'VAPID_PRIVATE_KEY' not in env_vars or + 'VAPID_PUBLIC_KEY' not in env_vars or + not env_vars.get('VAPID_PRIVATE_KEY') or + not env_vars.get('VAPID_PUBLIC_KEY') + ) + + if need_new_keys: + # Generate EC private key + private_key = ec.generate_private_key(ec.SECP256R1(), backend=default_backend()) + + # Serialize private key to PEM format, but keep it clean + private_pem = private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption() + ).decode('utf-8') + + # Clean up PEM formatting for .env file + private_pem_clean = private_pem.replace('-----BEGIN PRIVATE KEY-----\n', '').replace('\n-----END PRIVATE KEY-----\n', '').replace('\n', '') + + # Get public key + public_key = private_key.public_key() + public_key_bytes = public_key.public_bytes( + encoding=serialization.Encoding.X962, + format=serialization.PublicFormat.UncompressedPoint + ) + + # Store keys + env_vars['VAPID_PRIVATE_KEY'] = private_pem_clean + env_vars['VAPID_PUBLIC_KEY'] = base64.urlsafe_b64encode(public_key_bytes).decode('utf-8') + + print("New VAPID keys generated in .env-compatible format.") + else: + print("Existing VAPID keys found - no changes made.") + + # Ensure we have all required configuration variables with defaults if missing + defaults = { + # Flic Button Configuration + 'FLIC_BUTTON1_SERIAL': env_vars.get('FLIC_BUTTON1_SERIAL', 'your_button1_serial'), + 'FLIC_BUTTON2_SERIAL': env_vars.get('FLIC_BUTTON2_SERIAL', 'your_button2_serial'), + 'FLIC_BUTTON3_SERIAL': env_vars.get('FLIC_BUTTON3_SERIAL', 'your_button3_serial'), + + # Subscription Storage + 'SUBSCRIPTIONS_FILE': env_vars.get('SUBSCRIPTIONS_FILE', 'data/subscriptions.json'), + + # Logging Configuration + 'LOG_LEVEL': env_vars.get('LOG_LEVEL', 'INFO'), + + # VAPID Claim (email) + 'VAPID_CLAIM_EMAIL': env_vars.get('VAPID_CLAIM_EMAIL', 'mailto:your-email@example.com') + } + + # Update env_vars with defaults for any missing keys + env_vars.update({k: v for k, v in defaults.items() if k not in env_vars}) + + # Write back to .env file + with open('.env', 'w') as f: + f.write("# VAPID Keys for Web Push\n") + f.write(f"VAPID_PRIVATE_KEY={env_vars['VAPID_PRIVATE_KEY']}\n") + f.write(f"VAPID_PUBLIC_KEY={env_vars['VAPID_PUBLIC_KEY']}\n\n") + + f.write("# Flic Button Configuration\n") + f.write(f"FLIC_BUTTON1_SERIAL={env_vars['FLIC_BUTTON1_SERIAL']}\n") + f.write(f"FLIC_BUTTON2_SERIAL={env_vars['FLIC_BUTTON2_SERIAL']}\n") + f.write(f"FLIC_BUTTON3_SERIAL={env_vars['FLIC_BUTTON3_SERIAL']}\n\n") + + f.write("# Subscription Storage\n") + f.write(f"SUBSCRIPTIONS_FILE={env_vars['SUBSCRIPTIONS_FILE']}\n\n") + + f.write("# Logging Configuration\n") + f.write(f"LOG_LEVEL={env_vars['LOG_LEVEL']}\n\n") + + f.write("# VAPID Claim Email\n") + f.write(f"VAPID_CLAIM_EMAIL={env_vars['VAPID_CLAIM_EMAIL']}\n") + +if __name__ == '__main__': + generate_vapid_keys() diff --git a/images/flic-button-request.png b/images/flic-button-request.png new file mode 100644 index 0000000..0f609d9 Binary files /dev/null and b/images/flic-button-request.png differ diff --git a/images/interaction-flow.mmd b/images/interaction-flow.mmd new file mode 100644 index 0000000..700d01e --- /dev/null +++ b/images/interaction-flow.mmd @@ -0,0 +1,8 @@ +stateDiagram-v2 + [*] --> FlicButton : Button Press + FlicButton --> Bluetooth : Transmit Signal + Bluetooth --> Phone : Receive Signal + Phone --> WebhookWebPushService : Send Webhook + WebhookWebPushService --> GooglePushAPI : Forward Notification + GooglePushAPI --> PWA : Push Notification + PWA --> [*] : Handle Notification \ No newline at end of file diff --git a/images/interaction-flow.png b/images/interaction-flow.png new file mode 100644 index 0000000..6141bb9 Binary files /dev/null and b/images/interaction-flow.png differ diff --git a/images/subscription-flow.mmd b/images/subscription-flow.mmd new file mode 100644 index 0000000..cca194e --- /dev/null +++ b/images/subscription-flow.mmd @@ -0,0 +1,7 @@ +stateDiagram-v2 + [*] --> PWA : User Initiates + PWA --> GooglePushAPI : Request Subscription + GooglePushAPI --> PWA : Return Subscription Info + PWA --> WebhookWebPushService : Store Subscription Info + WebhookWebPushService --> PWA : Confirmation + [*] --> Complete \ No newline at end of file diff --git a/images/subscription-flow.png b/images/subscription-flow.png new file mode 100644 index 0000000..1a9f594 Binary files /dev/null and b/images/subscription-flow.png differ diff --git a/labels.example b/labels.example new file mode 100644 index 0000000..b01410c --- /dev/null +++ b/labels.example @@ -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 +traefik.http.middlewares.cors-headers.headers.accesscontrolalloworiginlist=https://game-timer.virtonline.eu +traefik.http.middlewares.cors-headers.headers.accesscontrolallowheaders=Content-Type,Authorization,button-name,button-battery-level,timestamp +traefik.http.middlewares.cors-headers.headers.accesscontrolallowcredentials=true +traefik.http.middlewares.cors-headers.headers.accesscontrolmaxage=600 +traefik.http.middlewares.cors-headers.headers.addvaryheader=true + +# Middleware Rate Limiting +traefik.http.middlewares.flic-ratelimit.ratelimit.average=10 +traefik.http.middlewares.flic-ratelimit.ratelimit.burst=20 + +# Apply both middlewares to the router (comma-separated list) +traefik.http.routers.flic-webhook-webpush.middlewares=cors-headers,flic-ratelimit diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..4960405 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,905 @@ +{ + "name": "flic-webhook-webpush", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "flic-webhook-webpush", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "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/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-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" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..a1e453d --- /dev/null +++ b/package.json @@ -0,0 +1,24 @@ +{ + "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": { + "dotenv": "^16.4.5", + "express": "^4.19.2", + "web-push": "^3.6.7" + } + } \ No newline at end of file diff --git a/server.js b/server.js new file mode 100644 index 0000000..16ba3e9 --- /dev/null +++ b/server.js @@ -0,0 +1,381 @@ +const express = require('express'); +const webpush = require('web-push'); +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. +// 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(); + +// --- Body Parsing Middleware --- +app.use(express.json()); + +// --- Basic Authentication Middleware --- +const authenticateBasic = (req, res, next) => { + // Skip authentication for OPTIONS requests (CORS preflight - Traefik might still forward them or handle them) + // It's safe to keep this check. + if (req.method === 'OPTIONS') { + logger.debug('Auth: Skipping auth for OPTIONS request.'); + // Traefik should ideally respond to OPTIONS, but if it forwards, we just proceed without auth check. + // We don't need to send CORS headers here anymore. + return res.sendStatus(204); // Standard practice for preflight response if it reaches the backend + } + + // Skip authentication if username or password are not set in environment + if (!basicAuthUsername || !basicAuthPassword) { + logger.warn('Auth: Basic Auth username or password not configured. Skipping authentication.'); + return next(); + } + + const authHeader = req.headers.authorization; + + if (!authHeader || !authHeader.startsWith('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 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 + 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' + // 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('CORS: Handled by Traefik'); + // Log Basic Auth status instead of Flic Secret + if (basicAuthUsername && basicAuthPassword) { + logger.info('Authentication: Basic Auth Enabled'); + } else { + logger.info('Authentication: Basic Auth Disabled (username/password not set)'); + } + logger.info(`Subscription Endpoint Auth: ${basicAuthUsername && basicAuthPassword ? 'Enabled (Basic)' : 'Disabled'}`); + logger.info(`Webhook Endpoint Auth: ${basicAuthUsername && basicAuthPassword ? 'Enabled (Basic)' : 'Disabled'}`); + logger.info(`Subscriptions File: ${subscriptionsFilePath}`); + logger.info(`Push Notification Retry Config: ${maxRetries} retries, first retry: ${firstRetryDelay}ms, subsequent retries: ${subsequentRetryDelay}ms base delay`); + logger.info(`DNS Config: IPv4 first, timeout ${dnsTimeout}ms`); + logger.info(`HTTP Timeout: ${httpTimeout}ms`); + logger.info(`Log Level: ${LOG_LEVEL.toUpperCase()}`); +}); + +// --- Graceful Shutdown --- +const closeGracefully = (signal) => { + 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 + + // 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 \ No newline at end of file diff --git a/virt-flic-webhook-webpush.service.example b/virt-flic-webhook-webpush.service.example new file mode 100644 index 0000000..4bc8444 --- /dev/null +++ b/virt-flic-webhook-webpush.service.example @@ -0,0 +1,32 @@ +[Unit] +Description=flic-webhook-webpush (virt-flic-webhook-webpush) +Requires=docker.service +After=docker.service +DefaultDependencies=no + +[Service] +Type=simple +Environment="HOME=/root" +ExecStartPre=-/usr/bin/env sh -c '/usr/bin/env docker kill virt-flic-webhook-webpush 2>/dev/null || true' +ExecStartPre=-/usr/bin/env sh -c '/usr/bin/env docker rm virt-flic-webhook-webpush 2>/dev/null || true' +ExecStartPre=/usr/bin/env sh -c 'touch /virt/flic-webhook-webpush/subscriptions.json' + +ExecStart=/usr/bin/env docker run \ + --rm \ + --name=virt-flic-webhook-webpush \ + --log-driver=none \ + --network=traefik \ + --env-file=/virt/flic-webhook-webpush/env \ + --label-file=/virt/flic-webhook-webpush/labels \ + --mount type=bind,src=/virt/flic-webhook-webpush/subscriptions.json,dst=/app/subscriptions.json \ + flic-webhook-webpush + +ExecStop=-/usr/bin/env sh -c '/usr/bin/env docker kill virt-flic-webhook-webpush 2>/dev/null || true' +ExecStop=-/usr/bin/env sh -c '/usr/bin/env docker rm virt-flic-webhook-webpush 2>/dev/null || true' + +Restart=always +RestartSec=30 +SyslogIdentifier=virt-flic-webhook-webpush + +[Install] +WantedBy=multi-user.target