From 682dc6942a74c93094e2246c4c52cbdc7ffe4eda Mon Sep 17 00:00:00 2001 From: cpu Date: Wed, 26 Mar 2025 20:31:31 +0100 Subject: [PATCH] added subscription route --- .env | 32 --------- .gitignore | 2 +- README.md | 2 +- server.js | 199 +++++++++++++++++++++++++++++++++++------------------ 4 files changed, 133 insertions(+), 102 deletions(-) delete mode 100644 .env diff --git a/.env b/.env deleted file mode 100644 index 1869866..0000000 --- a/.env +++ /dev/null @@ -1,32 +0,0 @@ -# --- Application Configuration --- - -# --- VAPID Keys (REQUIRED for Web Push) --- -# Generate these once using npx web-push generate-vapid-keys (or other tools) -# Keep the private key SECRET! -VAPID_PUBLIC_KEY="BKfRJXjSQmAJ452gLwlK_8scGrW6qMU1mBRp39ONtcQHkSsQgmLAaODIyGbgHyRpnDEv3HfXV1oGh3SC0fHxY0E" -VAPID_PRIVATE_KEY="ErEgsDKYQi5j2KPERC_gCtrEALAD0k-dWSwrrcD0-JU" - -# Subject claim for VAPID. Use a 'mailto:' URI or an 'https:' URL identifying your application. -# Example: mailto:admin@yourdomain.com or https://yourdomain.com/contact -VAPID_SUBJECT="mailto:admin@virtonline.eu" - -# Flic Button Configuration -FLIC_BUTTON1_SERIAL=your_button1_serial -FLIC_BUTTON2_SERIAL=your_button2_serial -FLIC_BUTTON3_SERIAL=your_button3_serial - -# Subscription Storage -SUBSCRIPTIONS_FILE=subscriptions.json - -# CORS -ALLOWED_ORIGINS=https://game-timer.virtonline.eu -ALLOWED_METHODS=POST,OPTIONS -ALLOWED_HEADERS=Content-Type,Authorization - -# Logging Configuration -LOG_LEVEL=DEBUG - -# --- Security (Optional) --- -# If you want to add a simple security layer between Flic and this app. -# If set, configure Flic's HTTP request to include an "Authorization: Bearer YOUR_SECRET_VALUE" header. -# FLIC_SECRET="replace_with_a_strong_secret_if_needed" \ No newline at end of file diff --git a/.gitignore b/.gitignore index 2d96022..115d993 100644 --- a/.gitignore +++ b/.gitignore @@ -6,7 +6,7 @@ node_modules/ npm-debug.log* yarn-debug.log* yarn-error.log* -*.env +.env subscriptions.json labels diff --git a/README.md b/README.md index 50d3601..a9f4b2a 100644 --- a/README.md +++ b/README.md @@ -119,7 +119,7 @@ It's designed to be run as a Docker container and integrated with Traefik v3 for --network traefik \ --env-file .env \ --label-file labels \ - --mount type=bind,src=./subscriptions.json,dst=/app/subscriptions.json,readonly \ + --mount type=bind,src=./subscriptions.json,dst=/app/subscriptions.json \ flic-webhook-webpush:latest ``` * `--network traefik`: Connects to the Traefik network. diff --git a/server.js b/server.js index 903f081..2973f47 100644 --- a/server.js +++ b/server.js @@ -13,9 +13,11 @@ 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 +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").split(',').map(method => method.trim()).filter(method => method); +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); @@ -25,16 +27,6 @@ if (!vapidPublicKey || !vapidPrivateKey || !vapidSubject) { process.exit(1); } -if (!fs.existsSync(subscriptionsFilePath)) { - console.warn(`Warning: Subscriptions file not found at ${subscriptionsFilePath}. Creating an empty file.`); - try { - fs.writeFileSync(subscriptionsFilePath, '{}', 'utf8'); - } catch (err) { - console.error(`Error: Could not create subscriptions file at ${subscriptionsFilePath}.`, err); - process.exit(1); - } -} - // --- Web Push Setup --- webpush.setVapidDetails( vapidSubject, @@ -42,25 +34,53 @@ webpush.setVapidDetails( vapidPrivateKey ); -// --- Subscription Loading --- -let subscriptions = {}; -try { - const data = fs.readFileSync(subscriptionsFilePath, 'utf8'); - subscriptions = JSON.parse(data); - 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.`, err); - // Continue with empty subscriptions, but log the error - subscriptions = {}; +// --- 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) => { - // Allow requests with no origin (like curl requests, mobile apps, etc) or from allowed list if (!origin || allowedOrigins.length === 0 || allowedOrigins.includes(origin)) { callback(null, true); } else { @@ -73,77 +93,118 @@ const corsOptions = { optionsSuccessStatus: 204 // For pre-flight requests }; app.use(cors(corsOptions)); -app.options('/flic-webhook', cors(corsOptions)); // Enable pre-flight for the webhook route +// 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 (Optional) --- +// --- Authentication Middleware (For Flic Webhook Only) --- const authenticateFlicRequest = (req, res, next) => { + // Only apply auth if flicSecret is configured if (!flicSecret) { - return next(); // No secret configured, skip authentication + return next(); } const authHeader = req.headers.authorization; if (!authHeader || !authHeader.startsWith('Bearer ')) { - console.warn('Auth: Missing or malformed Authorization header'); + 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: Invalid Bearer token received'); + 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(); }; -// --- Webhook Endpoint --- +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) => { - const { button_id, click_type, timestamp } = req.body; // Flic might send serialNumber, check Flic docs/logs + // 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' }); } - // Find the subscription associated with this button ID (case-insensitive compare might be safer) - const subscription = subscriptions[button_id.toLowerCase()] || subscriptions[button_id]; // Check both cases just in case + 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(`No subscription found for button ID: ${button_id}`); - return res.status(404).json({ message: `Not Found: No subscription configured for button ${button_id}` }); + 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 ${button_id} - ${click_type}`, - data: { // Send structured data to the PWA - action: click_type, // e.g., "SingleClick", "DoubleClick", "Hold" - button: button_id, + 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' // Optional: Add an icon URL accessible by the PWA + // icon: '/path/to/icon.png' }); try { - console.log(`Sending push notification to endpoint: ${subscription.endpoint.substring(0, 30)}...`); + 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 ${button_id}.`); + 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 ${button_id}:`, 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 ${button_id} is invalid or expired (404/410). Consider removing it.`); - // Optionally, you could implement logic here to remove the stale subscription - // delete subscriptions[button_id]; - // fs.writeFileSync(subscriptionsFilePath, JSON.stringify(subscriptions, null, 2), 'utf8'); + 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' }); @@ -151,43 +212,45 @@ app.post('/flic-webhook', authenticateFlicRequest, async (req, res) => { } }); -// --- Health Check Endpoint (Optional) --- +// --- Health Check Endpoint --- app.get('/health', (req, res) => { - res.status(200).json({ status: 'UP', timestamp: new Date().toISOString() }); + res.status(200).json({ + status: 'UP', + timestamp: new Date().toISOString(), + subscription_count: Object.keys(subscriptions).length + }); }); // --- Start Server --- -app.listen(port, () => { +// 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(', ') : '*'}`); + console.log(`Allowed Origins: ${allowedOrigins.length > 0 ? allowedOrigins.join(', ') : '(Any)'}`); console.log(`Allowed Methods: ${allowedMethods.join(', ')}`); console.log(`Allowed Headers: ${allowedHeaders.join(', ')}`); - console.log(`Authentication: ${flicSecret ? 'Enabled (Bearer Token)' : 'Disabled'}`); + console.log(`Flic Webhook Auth: ${flicSecret ? 'Enabled (Bearer Token)' : 'Disabled'}`); + console.log(`Subscription Endpoint Auth: Disabled`); console.log(`Subscriptions File: ${subscriptionsFilePath}`); }); -// --- Graceful Shutdown (Optional but Recommended) --- -process.on('SIGTERM', () => { - console.log('SIGTERM signal received: closing HTTP server'); - app.close(() => { // Doesn't work directly with app.listen, need http.createServer +// --- 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); }); - // If server.close doesn't exit quickly, force exit after timeout + + // 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('SIGINT', () => { - console.log('SIGINT signal received: closing HTTP server'); - app.close(() => { - console.log('HTTP server closed'); - process.exit(0); - }); - setTimeout(() => { - console.error('Could not close connections in time, forcefully shutting down'); - process.exit(1); - }, 10000); -}); \ No newline at end of file +process.on('SIGTERM', () => closeGracefully('SIGTERM')); +process.on('SIGINT', () => closeGracefully('SIGINT')); // Handle Ctrl+C \ No newline at end of file