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
381 lines
16 KiB
JavaScript
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
|