Files
flic-webhook-webpush/server.js
cpu 267d262fa6 first version
config

CORS

fixed key to one line

helper prints

clean up

logs

improved validations

again validations

fix

rewritten

flask and node.js solution

added subscription route

auth

flow diagrams
2025-03-31 23:00:03 +02:00

381 lines
16 KiB
JavaScript

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