diff --git a/README.md b/README.md index ada6e5c..69ad8f2 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,7 @@ It's designed to be run as a Docker container and integrated with Traefik v3 for * `INITIAL_RETRY_DELAY_MS`: (Default: `1000`) Initial delay in milliseconds before first retry. 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`). @@ -191,7 +192,7 @@ 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. - * The server logs all headers received from Flic requests to help with debugging. Look for lines starting with `DEBUG - All headers received:` to see all headers sent by the Flic button. + * 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. diff --git a/labels b/labels index 5451eca..6cb2687 100644 --- a/labels +++ b/labels @@ -8,7 +8,7 @@ traefik.docker.network=traefik 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 # Enable TLS +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 @@ -17,16 +17,16 @@ traefik.http.routers.flic-webhook-webpush.service=flic-webhook-webpush traefik.http.services.flic-webhook-webpush.loadbalancer.server.port=3000 # Middleware CORS -traefik.http.middlewares.cors-headers.headers.accesscontrolallowmethods=POST,GET,OPTIONS # Allow POST, GET, and OPTIONS requests -traefik.http.middlewares.cors-headers.headers.accesscontrolalloworiginlist=https://game-timer.virtonline.eu # Allow requests from game-timer.virtonline.eu -traefik.http.middlewares.cors-headers.headers.accesscontrolallowheaders=Content-Type,Authorization # Allow Content-Type and Authorization headers -traefik.http.middlewares.cors-headers.headers.accesscontrolmaxage=600 # Cache preflight responses for 10 minutes -traefik.http.middlewares.cors-headers.headers.addvaryheader=true # Add Vary header to responses +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.accesscontrolmaxage=600 +traefik.http.middlewares.cors-headers.headers.addvaryheader=true # Apply the middleware to the router traefik.http.routers.flic-webhook-webpush.middlewares=cors-headers # Middleware Rate Limiting -traefik.http.middlewares.flic-ratelimit.ratelimit.average=10 # requests per second +traefik.http.middlewares.flic-ratelimit.ratelimit.average=10 traefik.http.middlewares.flic-ratelimit.ratelimit.burst=20 # Apply the middleware to the router traefik.http.routers.flic-webhook-webpush.middlewares=flic-ratelimit diff --git a/server.js b/server.js index ae215d5..57728cd 100644 --- a/server.js +++ b/server.js @@ -25,6 +25,37 @@ const maxRetries = parseInt(process.env.MAX_NOTIFICATION_RETRIES || 3, 10); const initialRetryDelay = parseInt(process.env.INITIAL_RETRY_DELAY_MS || 1000, 10); // 1 second // 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'); @@ -43,7 +74,7 @@ https.globalAgent = httpsAgent; // --- Validation --- if (!vapidPublicKey || !vapidPrivateKey || !vapidSubject) { - console.error('Error: VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY, and VAPID_SUBJECT must be set in the environment variables.'); + logger.error('Error: VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY, and VAPID_SUBJECT must be set in the environment variables.'); process.exit(1); } @@ -96,12 +127,12 @@ let subscriptions = {}; // In-memory cache of subscriptions function loadSubscriptions() { if (!fs.existsSync(subscriptionsFilePath)) { - console.warn(`Warning: Subscriptions file not found at ${subscriptionsFilePath}. Creating an empty file.`); + logger.warn(`Warning: Subscriptions file not found at ${subscriptionsFilePath}. Creating an empty file.`); try { fs.writeFileSync(subscriptionsFilePath, '{}', 'utf8'); subscriptions = {}; } catch (err) { - console.error(`Error: Could not create subscriptions file at ${subscriptionsFilePath}.`, err); + logger.error(`Error: Could not create subscriptions file at ${subscriptionsFilePath}.`, err); // Exit or continue with empty object depending on desired robustness process.exit(1); // Exit if we can't even create the file } @@ -109,9 +140,9 @@ function loadSubscriptions() { try { const data = fs.readFileSync(subscriptionsFilePath, 'utf8'); subscriptions = JSON.parse(data || '{}'); // Handle empty file case - console.log(`Loaded ${Object.keys(subscriptions).length} subscriptions from ${subscriptionsFilePath}`); + logger.info(`Loaded ${Object.keys(subscriptions).length} subscriptions from ${subscriptionsFilePath}`); } catch (err) { - console.error(`Error reading or parsing subscriptions file at ${subscriptionsFilePath}. Please ensure it's valid JSON. Using empty cache.`, err); + logger.error(`Error reading or parsing subscriptions file at ${subscriptionsFilePath}. Please ensure it's valid JSON. Using empty cache.`, err); // Continue with empty subscriptions, but log the error subscriptions = {}; } @@ -121,9 +152,9 @@ function loadSubscriptions() { function saveSubscriptions() { try { fs.writeFileSync(subscriptionsFilePath, JSON.stringify(subscriptions, null, 2), 'utf8'); // Pretty print JSON - console.log(`Subscriptions successfully saved to ${subscriptionsFilePath}`); + logger.info(`Subscriptions successfully saved to ${subscriptionsFilePath}`); } catch (err) { - console.error(`Error writing subscriptions file: ${subscriptionsFilePath}`, err); + logger.error(`Error writing subscriptions file: ${subscriptionsFilePath}`, err); // Note: The in-memory object is updated, but persistence failed. } } @@ -167,32 +198,37 @@ const authenticateFlicRequest = (req, res, next) => { const authHeader = req.headers.authorization; if (!authHeader || !authHeader.startsWith('Bearer ')) { - console.warn('Auth (Flic): Missing or malformed Authorization header'); + logger.warn('Auth (Flic): Missing or malformed Authorization header'); return res.status(401).json({ message: 'Unauthorized: Missing or malformed Bearer token' }); } const token = authHeader.split(' ')[1]; if (token !== flicSecret) { - console.warn('Auth (Flic): Invalid Bearer token received'); + logger.warn('Auth (Flic): Invalid Bearer token received'); return res.status(401).json({ message: 'Unauthorized: Invalid token' }); } - console.log('Auth (Flic): Request authenticated successfully.'); + logger.debug('Auth (Flic): Request authenticated successfully.'); next(); }; app.post('/subscribe', async (req, res) => { const { button_id, subscription } = req.body; + + logger.debug('All headers received:'); + Object.keys(req.headers).forEach(headerName => { + logger.debug(` ${headerName}: ${req.headers[headerName]}`); + }); - console.log(`Received subscription request for button: ${button_id}`); + logger.info(`Received subscription request for button: ${button_id}`); // Basic Validation if (!button_id || typeof button_id !== 'string' || button_id.trim() === '') { - console.warn('Subscription Error: Missing or invalid button_id'); + logger.warn('Subscription Error: Missing or invalid button_id'); return res.status(400).json({ message: 'Bad Request: Missing or invalid button_id' }); } if (!subscription || typeof subscription !== 'object' || !subscription.endpoint || !subscription.keys || !subscription.keys.p256dh || !subscription.keys.auth) { - console.warn('Subscription Error: Missing or invalid subscription object structure'); + logger.warn('Subscription Error: Missing or invalid subscription object structure'); return res.status(400).json({ message: 'Bad Request: Missing or invalid subscription object' }); } @@ -200,7 +236,7 @@ app.post('/subscribe', async (req, res) => { // Update in-memory store subscriptions[normalizedButtonId] = subscription; - console.log(`Subscription for button ${normalizedButtonId} added/updated in memory.`); + logger.info(`Subscription for button ${normalizedButtonId} added/updated in memory.`); // Persist to file try { @@ -222,17 +258,17 @@ app.get('/flic-webhook', authenticateFlicRequest, async (req, res) => { // Get click_type from query parameter instead of request body const click_type = req.query.click_type; - // Debug: Log all headers received from Flic - console.log('DEBUG - All headers received:'); + // Log all headers received from Flic + logger.debug('All headers received:'); Object.keys(req.headers).forEach(headerName => { - console.log(` ${headerName}: ${req.headers[headerName]}`); + logger.debug(` ${headerName}: ${req.headers[headerName]}`); }); - console.log(`Received GET webhook: Button=${buttonName}, Type=${click_type}, Timestamp=${timestamp || 'N/A'}`); + logger.info(`Received GET webhook: Button=${buttonName}, Type=${click_type}, Timestamp=${timestamp || 'N/A'}`); // Basic validation if (!buttonName || !click_type) { - console.warn(`Webhook Error: Missing Button-Name header or click_type query parameter`); + logger.warn(`Webhook Error: Missing Button-Name header or click_type query parameter`); return res.status(400).json({ message: 'Bad Request: Missing Button-Name header or click_type query parameter' }); } @@ -242,7 +278,7 @@ app.get('/flic-webhook', authenticateFlicRequest, async (req, res) => { const subscription = subscriptions[normalizedButtonName]; if (!subscription) { - console.warn(`Webhook: No subscription found for button ID: ${normalizedButtonName} (original: ${buttonName})`); + 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}` }); } @@ -259,15 +295,16 @@ app.get('/flic-webhook', authenticateFlicRequest, async (req, res) => { }); try { - console.log(`Sending push notification for ${normalizedButtonName} to endpoint: ${subscription.endpoint.substring(0, 40)}...`); + logger.debug(`Subscription endpoint: ${subscription.endpoint}`); + logger.info(`Sending push notification for ${normalizedButtonName} to endpoint: ${subscription.endpoint.substring(0, 40)}...`); await sendWebPushWithRetry(subscription, payload); - console.log(`Push notification sent successfully for button ${normalizedButtonName}.`); + logger.info(`Push notification sent successfully for button ${normalizedButtonName}.`); res.status(200).json({ message: 'Push notification sent successfully' }); } catch (error) { - console.error(`Error sending push notification for button ${normalizedButtonName}:`, error.body || error.message || error); + logger.error(`Error sending push notification for button ${normalizedButtonName}:`, error.body || error.message || error); if (error.statusCode === 404 || error.statusCode === 410) { - console.warn(`Subscription for button ${normalizedButtonName} is invalid or expired (404/410). Removing it.`); + logger.warn(`Subscription for button ${normalizedButtonName} is invalid or expired (404/410). Removing it.`); // Optionally remove the stale subscription delete subscriptions[normalizedButtonName]; saveSubscriptions(); // Attempt to save the updated list @@ -283,30 +320,31 @@ app.get('/flic-webhook', authenticateFlicRequest, async (req, res) => { const server = http.createServer(app); server.listen(port, () => { - console.log(`Flic Webhook to WebPush server listening on port ${port}`); - console.log(`Allowed Origins: ${allowedOrigins.length > 0 ? allowedOrigins.join(', ') : '(Any)'}`); - console.log(`Allowed Methods: ${allowedMethods.join(', ')}`); - console.log(`Allowed Headers: ${allowedHeaders.join(', ')}`); - console.log(`Flic Webhook Auth: ${flicSecret ? 'Enabled (Bearer Token)' : 'Disabled'}`); - console.log(`Subscription Endpoint Auth: Disabled`); - console.log(`Subscriptions File: ${subscriptionsFilePath}`); - console.log(`Push Notification Retry Config: ${maxRetries} retries, ${initialRetryDelay}ms initial delay`); - console.log(`DNS Config: IPv4 first, timeout ${dnsTimeout}ms`); - console.log(`HTTP Timeout: ${httpTimeout}ms`); + logger.info(`Flic Webhook to WebPush server listening on port ${port}`); + logger.info(`Allowed Origins: ${allowedOrigins.length > 0 ? allowedOrigins.join(', ') : '(Any)'}`); + logger.info(`Allowed Methods: ${allowedMethods.join(', ')}`); + logger.info(`Allowed Headers: ${allowedHeaders.join(', ')}`); + logger.info(`Flic Webhook Auth: ${flicSecret ? 'Enabled (Bearer Token)' : 'Disabled'}`); + logger.info(`Subscription Endpoint Auth: Disabled`); + logger.info(`Subscriptions File: ${subscriptionsFilePath}`); + logger.info(`Push Notification Retry Config: ${maxRetries} retries, ${initialRetryDelay}ms initial delay`); + logger.info(`DNS Config: IPv4 first, timeout ${dnsTimeout}ms`); + logger.info(`HTTP Timeout: ${httpTimeout}ms`); + logger.info(`Log Level: ${LOG_LEVEL.toUpperCase()}`); }); // --- Graceful Shutdown --- const closeGracefully = (signal) => { - console.log(`${signal} signal received: closing HTTP server`); + logger.info(`${signal} signal received: closing HTTP server`); server.close(() => { - console.log('HTTP server closed'); + logger.info('HTTP server closed'); // Perform any other cleanup here if needed process.exit(0); }); // Force close server after 10 seconds setTimeout(() => { - console.error('Could not close connections in time, forcefully shutting down'); + logger.error('Could not close connections in time, forcefully shutting down'); process.exit(1); }, 10000); // 10 seconds timeout - This is a literal so no need to parse }