const express = require('express'); const webpush = require('web-push'); const cors = require('cors'); const fs = require('fs'); const path = require('path'); // 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); // --- 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 ); // --- 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 --- // 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; console.log(`Received webhook: Button=${button_id}, Type=${click_type}, 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' }); } const normalizedButtonId = button_id.toLowerCase(); // Use lowercase for lookup consistency // Find the subscription associated with this button ID const subscription = subscriptions[normalizedButtonId]; 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}` }); } // --- Send Web Push Notification --- const payload = JSON.stringify({ title: 'Flic Button Action', body: `Button ${click_type}`, // Simplified body data: { action: click_type, button: normalizedButtonId, // Send normalized ID timestamp: timestamp || new Date().toISOString() } // 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}.`); 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); if (error.statusCode === 404 || error.statusCode === 410) { console.warn(`Subscription for button ${normalizedButtonId} is invalid or expired (404/410). Removing it.`); // Optionally remove the stale subscription delete subscriptions[normalizedButtonId]; 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' }); } } }); // --- 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}`); }); // --- 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 } process.on('SIGTERM', () => closeGracefully('SIGTERM')); process.on('SIGINT', () => closeGracefully('SIGINT')); // Handle Ctrl+C