flask and node.js solution
This commit is contained in:
193
server.js
Normal file
193
server.js
Normal file
@@ -0,0 +1,193 @@
|
||||
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
|
||||
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 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);
|
||||
}
|
||||
|
||||
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,
|
||||
vapidPublicKey,
|
||||
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 = {};
|
||||
}
|
||||
|
||||
// --- 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 {
|
||||
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));
|
||||
app.options('/flic-webhook', cors(corsOptions)); // Enable pre-flight for the webhook route
|
||||
|
||||
// --- Body Parsing Middleware ---
|
||||
app.use(express.json());
|
||||
|
||||
// --- Authentication Middleware (Optional) ---
|
||||
const authenticateFlicRequest = (req, res, next) => {
|
||||
if (!flicSecret) {
|
||||
return next(); // No secret configured, skip authentication
|
||||
}
|
||||
|
||||
const authHeader = req.headers.authorization;
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
console.warn('Auth: 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');
|
||||
return res.status(401).json({ message: 'Unauthorized: Invalid token' });
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
|
||||
// --- Webhook Endpoint ---
|
||||
app.post('/flic-webhook', authenticateFlicRequest, async (req, res) => {
|
||||
const { button_id, click_type, timestamp } = req.body; // Flic might send serialNumber, check Flic docs/logs
|
||||
|
||||
console.log(`Received webhook: Button=${button_id}, Type=${click_type}, Timestamp=${timestamp || 'N/A'}`);
|
||||
|
||||
// Basic validation
|
||||
if (!button_id || !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
|
||||
|
||||
|
||||
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}` });
|
||||
}
|
||||
|
||||
// --- 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,
|
||||
timestamp: timestamp || new Date().toISOString()
|
||||
}
|
||||
// icon: '/path/to/icon.png' // Optional: Add an icon URL accessible by the PWA
|
||||
});
|
||||
|
||||
try {
|
||||
console.log(`Sending push notification to endpoint: ${subscription.endpoint.substring(0, 30)}...`);
|
||||
await webpush.sendNotification(subscription, payload);
|
||||
console.log(`Push notification sent successfully for button ${button_id}.`);
|
||||
res.status(200).json({ message: 'Push notification sent successfully' });
|
||||
} catch (error) {
|
||||
console.error(`Error sending push notification for button ${button_id}:`, 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');
|
||||
res.status(410).json({ message: 'Subscription Gone' });
|
||||
} else {
|
||||
res.status(500).json({ message: 'Internal Server Error: Failed to send push notification' });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// --- Health Check Endpoint (Optional) ---
|
||||
app.get('/health', (req, res) => {
|
||||
res.status(200).json({ status: 'UP', timestamp: new Date().toISOString() });
|
||||
});
|
||||
|
||||
// --- Start Server ---
|
||||
app.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 Methods: ${allowedMethods.join(', ')}`);
|
||||
console.log(`Allowed Headers: ${allowedHeaders.join(', ')}`);
|
||||
console.log(`Authentication: ${flicSecret ? 'Enabled (Bearer Token)' : '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
|
||||
console.log('HTTP server closed');
|
||||
process.exit(0);
|
||||
});
|
||||
// If server.close doesn't exit quickly, force exit after timeout
|
||||
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);
|
||||
});
|
||||
Reference in New Issue
Block a user