315 lines
13 KiB
JavaScript
315 lines
13 KiB
JavaScript
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');
|
|
const flicSecret = process.env.FLIC_SECRET; // Optional Bearer token secret for Flic webhook
|
|
// 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
|
|
|
|
// 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.');
|
|
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)) {
|
|
console.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);
|
|
// 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
|
|
console.log(`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);
|
|
// Continue with empty subscriptions, but log the error
|
|
subscriptions = {};
|
|
}
|
|
}
|
|
}
|
|
|
|
function saveSubscriptions() {
|
|
try {
|
|
fs.writeFileSync(subscriptionsFilePath, JSON.stringify(subscriptions, null, 2), 'utf8'); // Pretty print JSON
|
|
console.log(`Subscriptions successfully saved to ${subscriptionsFilePath}`);
|
|
} catch (err) {
|
|
console.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('/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) {
|
|
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' });
|
|
}
|
|
|
|
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' });
|
|
}
|
|
|
|
console.log('Auth (Flic): Request authenticated successfully.');
|
|
next();
|
|
};
|
|
|
|
app.post('/subscribe', async (req, res) => {
|
|
const { button_id, subscription } = req.body;
|
|
|
|
console.log(`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');
|
|
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');
|
|
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;
|
|
console.log(`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 Flic-specific authentication to this route
|
|
app.get('/flic-webhook', authenticateFlicRequest, 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 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:');
|
|
Object.keys(req.headers).forEach(headerName => {
|
|
console.log(` ${headerName}: ${req.headers[headerName]}`);
|
|
});
|
|
|
|
console.log(`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`);
|
|
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) {
|
|
console.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()
|
|
}
|
|
// icon: '/path/to/icon.png'
|
|
});
|
|
|
|
try {
|
|
console.log(`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}.`);
|
|
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);
|
|
|
|
if (error.statusCode === 404 || error.statusCode === 410) {
|
|
console.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, () => {
|
|
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`);
|
|
});
|
|
|
|
// --- Graceful Shutdown ---
|
|
const closeGracefully = (signal) => {
|
|
console.log(`${signal} signal received: closing HTTP server`);
|
|
server.close(() => {
|
|
console.log('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');
|
|
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
|