const express = require('express'); const webpush = require('web-push'); const cors = require('cors'); const fs = require('fs'); const path = require('path'); const dns = require('dns'); // Add DNS module // Load environment variables from .env file require('dotenv').config(); // --- Configuration --- const port = process.env.PORT || 3000; const vapidPublicKey = process.env.VAPID_PUBLIC_KEY; const vapidPrivateKey = process.env.VAPID_PRIVATE_KEY; const vapidSubject = process.env.VAPID_SUBJECT; // mailto: or https: const subscriptionsFilePath = process.env.SUBSCRIPTIONS_FILE || path.join(__dirname, 'subscriptions.json'); // Basic Authentication Credentials const basicAuthUsername = process.env.BASIC_AUTH_USERNAME; const basicAuthPassword = process.env.BASIC_AUTH_PASSWORD; // Note: We are NOT adding specific authentication for the /subscribe endpoint in this version. // Consider adding API key or other auth if exposing this publicly. const allowedOrigins = (process.env.ALLOWED_ORIGINS || "").split(',').map(origin => origin.trim()).filter(origin => origin); const allowedMethods = (process.env.ALLOWED_METHODS || "POST,OPTIONS,GET").split(',').map(method => method.trim()).filter(method => method); const allowedHeaders = (process.env.ALLOWED_HEADERS || "Content-Type,Authorization").split(',').map(header => header.trim()).filter(header => header); // Retry configuration for DNS resolution issues const maxRetries = parseInt(process.env.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'); 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 = initialRetryDelay) { 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) { console.log(`DNS resolution failed (${error.code}). Retrying notification in ${delay}ms (attempt ${retryCount + 1}/${maxRetries})...`); // Wait for the delay await new Promise(resolve => setTimeout(resolve, delay)); // Exponential backoff with jitter const nextDelay = delay * (1.5 + Math.random() * 0.5); // Retry recursively with increased count and delay return sendWebPushWithRetry(subscription, payload, retryCount + 1, nextDelay); } // If we've exhausted retries or it's not a DNS error, rethrow throw error; } } // --- Subscription Loading and Management --- let subscriptions = {}; // In-memory cache of subscriptions function loadSubscriptions() { if (!fs.existsSync(subscriptionsFilePath)) { logger.warn(`Warning: Subscriptions file not found at ${subscriptionsFilePath}. Creating an empty file.`); try { fs.writeFileSync(subscriptionsFilePath, '{}', 'utf8'); subscriptions = {}; } catch (err) { logger.error(`Error: Could not create subscriptions file at ${subscriptionsFilePath}.`, err); // Exit or continue with empty object depending on desired robustness process.exit(1); // Exit if we can't even create the file } } else { try { const data = fs.readFileSync(subscriptionsFilePath, 'utf8'); subscriptions = JSON.parse(data || '{}'); // Handle empty file case logger.info(`Loaded ${Object.keys(subscriptions).length} subscriptions from ${subscriptionsFilePath}`); } catch (err) { logger.error(`Error reading or parsing subscriptions file at ${subscriptionsFilePath}. Please ensure it's valid JSON. Using empty cache.`, err); // Continue with empty subscriptions, but log the error subscriptions = {}; } } } function saveSubscriptions() { try { fs.writeFileSync(subscriptionsFilePath, JSON.stringify(subscriptions, null, 2), 'utf8'); // Pretty print JSON logger.info(`Subscriptions successfully saved to ${subscriptionsFilePath}`); } catch (err) { logger.error(`Error writing subscriptions file: ${subscriptionsFilePath}`, err); // Note: The in-memory object is updated, but persistence failed. } } // Initial load loadSubscriptions(); // --- Express App Setup --- const app = express(); // --- CORS Middleware --- const corsOptions = { origin: (origin, callback) => { if (!origin || allowedOrigins.length === 0 || allowedOrigins.includes(origin)) { callback(null, true); } else { console.warn(`CORS: Blocked origin: ${origin}`); callback(new Error('Not allowed by CORS')); } }, methods: allowedMethods, allowedHeaders: allowedHeaders, optionsSuccessStatus: 204 // For pre-flight requests }; app.use(cors(corsOptions)); // Enable pre-flight requests for all relevant routes app.options('/webhook', cors(corsOptions)); app.options('/subscribe', cors(corsOptions)); // --- Body Parsing Middleware --- app.use(express.json()); // --- Basic Authentication Middleware --- const authenticateBasic = (req, res, next) => { // Skip authentication for OPTIONS requests (CORS preflight) if (req.method === 'OPTIONS') { return next(); } // Skip authentication if username or password are not set in environment if (!basicAuthUsername || !basicAuthPassword) { logger.warn('Auth: Basic Auth username or password not configured. Skipping authentication.'); return next(); } const authHeader = req.headers.authorization; if (!authHeader || !authHeader.startsWith('Basic ')) { logger.warn('Auth: Missing or malformed Basic Authorization header'); res.setHeader('WWW-Authenticate', 'Basic realm="Restricted Area"'); return res.status(401).json({ message: 'Unauthorized: Basic Authentication required' }); } const credentials = authHeader.split(' ')[1]; const decodedCredentials = Buffer.from(credentials, 'base64').toString('utf8'); const [username, password] = decodedCredentials.split(':'); // Note: Use constant-time comparison for production environments if possible, // but for this scope, direct comparison is acceptable. if (username === basicAuthUsername && password === basicAuthPassword) { logger.debug('Auth: Basic Authentication successful.'); return next(); } else { logger.warn('Auth: Invalid Basic Authentication credentials received.'); res.setHeader('WWW-Authenticate', 'Basic realm="Restricted Area"'); return res.status(401).json({ message: 'Unauthorized: Invalid credentials' }); } }; // --- Routes --- // Subscribe endpoint: Add a new button->subscription mapping // Apply Basic Authentication app.post('/subscribe', authenticateBasic, async (req, res) => { const { button_id, subscription } = req.body; logger.debug('All headers received:'); Object.keys(req.headers).forEach(headerName => { logger.debug(` ${headerName}: ${req.headers[headerName]}`); }); logger.info(`Received subscription request for button: ${button_id}`); // Basic Validation if (!button_id || typeof button_id !== 'string' || button_id.trim() === '') { 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) { 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']; const timestamp = req.headers['timestamp']; // Get click_type from URL path const click_type = req.params.click_type; // Get battery level from Header 'Button-Battery-Level' const batteryLevelHeader = req.headers['button-battery-level']; // Use 'N/A' if header is missing or empty, otherwise parse as integer (or keep as string if parsing fails) const batteryLevel = batteryLevelHeader ? parseInt(batteryLevelHeader, 10) || batteryLevelHeader : 'N/A'; // Log all headers received from Flic logger.debug('All headers received:'); Object.keys(req.headers).forEach(headerName => { logger.debug(` ${headerName}: ${req.headers[headerName]}`); }); logger.info(`Received GET webhook: Button=${buttonName}, Type=${click_type}, Timestamp=${timestamp || 'N/A'}`); // Basic validation if (!buttonName || !click_type) { 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' }); } const normalizedButtonName = buttonName.toLowerCase(); // Use lowercase for lookup consistency // Find the subscription associated with this normalized button name const subscription = subscriptions[normalizedButtonName]; if (!subscription) { logger.warn(`Webhook: No subscription found for button ID: ${normalizedButtonName} (original: ${buttonName})`); return res.status(404).json({ message: `Not Found: No subscription configured for button ${normalizedButtonName}` }); } // --- Send Web Push Notification --- const payload = JSON.stringify({ title: 'Flic Button Action', body: `Button ${normalizedButtonName}: ${click_type}`, // Simplified body data: { action: click_type, button: normalizedButtonName, // Send normalized button name timestamp: timestamp || new Date().toISOString(), batteryLevel: batteryLevel // Use the extracted value } // icon: '/path/to/icon.png' }); try { logger.debug(`Subscription endpoint: ${subscription.endpoint}`); logger.info(`Sending push notification for ${normalizedButtonName} to endpoint: ${subscription.endpoint.substring(0, 40)}...`); await sendWebPushWithRetry(subscription, payload); logger.info(`Push notification sent successfully for button ${normalizedButtonName}.`); res.status(200).json({ message: 'Push notification sent successfully' }); } catch (error) { logger.error(`Error sending push notification for button ${normalizedButtonName}:`, error.body || error.message || error); if (error.statusCode === 404 || error.statusCode === 410) { logger.warn(`Subscription for button ${normalizedButtonName} is invalid or expired (404/410). Removing it.`); // Optionally remove the stale subscription delete subscriptions[normalizedButtonName]; saveSubscriptions(); // Attempt to save the updated list res.status(410).json({ message: 'Subscription Gone' }); } else { res.status(500).json({ message: 'Internal Server Error: Failed to send push notification' }); } } }); // --- Start Server --- // Use http.createServer to allow graceful shutdown const server = http.createServer(app); server.listen(port, () => { logger.info(`Flic Webhook to WebPush server listening on port ${port}`); logger.info(`Allowed Origins: ${allowedOrigins.length > 0 ? allowedOrigins.join(', ') : '(Any)'}`); logger.info(`Allowed Methods: ${allowedMethods.join(', ')}`); logger.info(`Allowed Headers: ${allowedHeaders.join(', ')}`); // Log Basic Auth status instead of Flic Secret if (basicAuthUsername && basicAuthPassword) { logger.info('Authentication: Basic Auth Enabled'); } else { logger.info('Authentication: Basic Auth Disabled (username/password not set)'); } logger.info(`Subscription Endpoint Auth: ${basicAuthUsername && basicAuthPassword ? 'Enabled (Basic)' : 'Disabled'}`); logger.info(`Subscriptions File: ${subscriptionsFilePath}`); logger.info(`Push Notification Retry Config: ${maxRetries} retries, ${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) => { logger.info(`${signal} signal received: closing HTTP server`); server.close(() => { logger.info('HTTP server closed'); // Perform any other cleanup here if needed process.exit(0); }); // Force close server after 10 seconds setTimeout(() => { logger.error('Could not close connections in time, forcefully shutting down'); process.exit(1); }, 10000); // 10 seconds timeout - This is a literal so no need to parse } process.on('SIGTERM', () => closeGracefully('SIGTERM')); process.on('SIGINT', () => closeGracefully('SIGINT')); // Handle Ctrl+C