flow diagrams

This commit is contained in:
cpu
2025-03-27 20:17:35 +01:00
parent c89eaacd42
commit 2da6061795
14 changed files with 479 additions and 271 deletions

320
server.js
View File

@@ -1,8 +1,8 @@
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();
@@ -12,18 +12,69 @@ 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');
const flicSecret = process.env.FLIC_SECRET; // Optional Bearer token secret for Flic webhook
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.
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.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) {
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);
}
@@ -34,17 +85,66 @@ webpush.setVapidDetails(
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)) {
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
}
@@ -52,9 +152,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 = {};
}
@@ -64,9 +164,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.
}
}
@@ -78,64 +178,73 @@ 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('/flic-webhook', cors(corsOptions));
app.options('/subscribe', cors(corsOptions));
// --- Body Parsing Middleware ---
app.use(express.json());
// --- Authentication Middleware (For Flic Webhook Only) ---
const authenticateFlicRequest = (req, res, next) => {
// Only apply auth if flicSecret is configured
if (!flicSecret) {
// --- 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('Bearer ')) {
console.warn('Auth (Flic): Missing or malformed Authorization header');
return res.status(401).json({ message: 'Unauthorized: Missing or malformed Bearer token' });
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 token = authHeader.split(' ')[1];
if (token !== flicSecret) {
console.warn('Auth (Flic): Invalid Bearer token received');
return res.status(401).json({ message: 'Unauthorized: Invalid token' });
}
const credentials = authHeader.split(' ')[1];
const decodedCredentials = Buffer.from(credentials, 'base64').toString('utf8');
const [username, password] = decodedCredentials.split(':');
console.log('Auth (Flic): Request authenticated successfully.');
next();
// 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' });
}
};
app.post('/subscribe', async (req, res) => {
const { button_id, subscription } = req.body;
// --- Routes ---
console.log(`Received subscription request for button: ${button_id}`);
// Subscribe endpoint: Add a new button->subscription mapping
// Apply Basic Authentication
app.post('/subscribe', authenticateBasic, async (req, res) => {
let { button_id, subscription } = req.body;
// Basic Validation
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() === '') {
console.warn('Subscription Error: Missing or invalid button_id');
return res.status(400).json({ message: 'Bad Request: Missing or invalid button_id' });
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) {
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' });
}
@@ -143,7 +252,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 {
@@ -156,54 +265,69 @@ app.post('/subscribe', async (req, res) => {
}
});
// --- Flic Webhook Endpoint ---
// Apply Flic-specific authentication ONLY to this route
app.post('/flic-webhook', authenticateFlicRequest, async (req, res) => {
// Assuming Flic sends 'button_id' which is the serial number
const { button_id, click_type, timestamp } = req.body;
// --- 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';
console.log(`Received webhook: Button=${button_id}, Type=${click_type}, Timestamp=${timestamp || '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 (!button_id || !click_type) {
console.warn(`Webhook Error: Missing button_id or click_type`);
return res.status(400).json({ message: 'Bad Request: Missing button_id or click_type' });
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 normalizedButtonId = button_id.toLowerCase(); // Use lowercase for lookup consistency
const normalizedButtonName = buttonName.toLowerCase(); // Use lowercase for lookup consistency
// Find the subscription associated with this button ID
const subscription = subscriptions[normalizedButtonId];
// Find the subscription associated with this normalized button name
const subscription = subscriptions[normalizedButtonName];
if (!subscription) {
console.warn(`Webhook: No subscription found for button ID: ${normalizedButtonId} (original: ${button_id})`);
return res.status(404).json({ message: `Not Found: No subscription configured for button ${normalizedButtonId}` });
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 ${click_type}`, // Simplified body
body: `Button ${normalizedButtonName}: ${click_type}`, // Simplified body
data: {
action: click_type,
button: normalizedButtonId, // Send normalized ID
timestamp: timestamp || new Date().toISOString()
button: normalizedButtonName, // Send normalized button name
timestamp: timestamp || new Date().toISOString(),
batteryLevel: batteryLevel // Use the extracted value
}
// icon: '/path/to/icon.png'
});
try {
console.log(`Sending push notification for ${normalizedButtonId} to endpoint: ${subscription.endpoint.substring(0, 40)}...`);
await webpush.sendNotification(subscription, payload);
console.log(`Push notification sent successfully for button ${normalizedButtonId}.`);
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) {
console.error(`Error sending push notification for button ${normalizedButtonId}:`, 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 ${normalizedButtonId} 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[normalizedButtonId];
delete subscriptions[normalizedButtonName];
saveSubscriptions(); // Attempt to save the updated list
res.status(410).json({ message: 'Subscription Gone' });
} else {
@@ -212,44 +336,42 @@ app.post('/flic-webhook', authenticateFlicRequest, async (req, res) => {
}
});
// --- Health Check Endpoint ---
app.get('/health', (req, res) => {
res.status(200).json({
status: 'UP',
timestamp: new Date().toISOString(),
subscription_count: Object.keys(subscriptions).length
});
});
// --- Start Server ---
// Use http.createServer to allow graceful shutdown
const http = require('http');
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}`);
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) => {
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
}, 10000); // 10 seconds timeout - This is a literal so no need to parse
}
process.on('SIGTERM', () => closeGracefully('SIGTERM'));