added subscription route
This commit is contained in:
32
.env
32
.env
@@ -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"
|
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -6,7 +6,7 @@ node_modules/
|
|||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
*.env
|
.env
|
||||||
subscriptions.json
|
subscriptions.json
|
||||||
labels
|
labels
|
||||||
|
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ It's designed to be run as a Docker container and integrated with Traefik v3 for
|
|||||||
--network traefik \
|
--network traefik \
|
||||||
--env-file .env \
|
--env-file .env \
|
||||||
--label-file labels \
|
--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
|
flic-webhook-webpush:latest
|
||||||
```
|
```
|
||||||
* `--network traefik`: Connects to the Traefik network.
|
* `--network traefik`: Connects to the Traefik network.
|
||||||
|
|||||||
187
server.js
187
server.js
@@ -13,9 +13,11 @@ const vapidPublicKey = process.env.VAPID_PUBLIC_KEY;
|
|||||||
const vapidPrivateKey = process.env.VAPID_PRIVATE_KEY;
|
const vapidPrivateKey = process.env.VAPID_PRIVATE_KEY;
|
||||||
const vapidSubject = process.env.VAPID_SUBJECT; // mailto: or https:
|
const vapidSubject = process.env.VAPID_SUBJECT; // mailto: or https:
|
||||||
const subscriptionsFilePath = process.env.SUBSCRIPTIONS_FILE || path.join(__dirname, 'subscriptions.json');
|
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 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);
|
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);
|
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 ---
|
// --- Web Push Setup ---
|
||||||
webpush.setVapidDetails(
|
webpush.setVapidDetails(
|
||||||
vapidSubject,
|
vapidSubject,
|
||||||
@@ -42,17 +34,46 @@ webpush.setVapidDetails(
|
|||||||
vapidPrivateKey
|
vapidPrivateKey
|
||||||
);
|
);
|
||||||
|
|
||||||
// --- Subscription Loading ---
|
// --- Subscription Loading and Management ---
|
||||||
let subscriptions = {};
|
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 {
|
try {
|
||||||
const data = fs.readFileSync(subscriptionsFilePath, 'utf8');
|
const data = fs.readFileSync(subscriptionsFilePath, 'utf8');
|
||||||
subscriptions = JSON.parse(data);
|
subscriptions = JSON.parse(data || '{}'); // Handle empty file case
|
||||||
console.log(`Loaded ${Object.keys(subscriptions).length} subscriptions from ${subscriptionsFilePath}`);
|
console.log(`Loaded ${Object.keys(subscriptions).length} subscriptions from ${subscriptionsFilePath}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`Error reading or parsing subscriptions file at ${subscriptionsFilePath}. Please ensure it's valid JSON.`, 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
|
// Continue with empty subscriptions, but log the error
|
||||||
subscriptions = {};
|
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 ---
|
// --- Express App Setup ---
|
||||||
const app = express();
|
const app = express();
|
||||||
@@ -60,7 +81,6 @@ const app = express();
|
|||||||
// --- CORS Middleware ---
|
// --- CORS Middleware ---
|
||||||
const corsOptions = {
|
const corsOptions = {
|
||||||
origin: (origin, callback) => {
|
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)) {
|
if (!origin || allowedOrigins.length === 0 || allowedOrigins.includes(origin)) {
|
||||||
callback(null, true);
|
callback(null, true);
|
||||||
} else {
|
} else {
|
||||||
@@ -73,77 +93,118 @@ const corsOptions = {
|
|||||||
optionsSuccessStatus: 204 // For pre-flight requests
|
optionsSuccessStatus: 204 // For pre-flight requests
|
||||||
};
|
};
|
||||||
app.use(cors(corsOptions));
|
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 ---
|
// --- Body Parsing Middleware ---
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
|
|
||||||
// --- Authentication Middleware (Optional) ---
|
// --- Authentication Middleware (For Flic Webhook Only) ---
|
||||||
const authenticateFlicRequest = (req, res, next) => {
|
const authenticateFlicRequest = (req, res, next) => {
|
||||||
|
// Only apply auth if flicSecret is configured
|
||||||
if (!flicSecret) {
|
if (!flicSecret) {
|
||||||
return next(); // No secret configured, skip authentication
|
return next();
|
||||||
}
|
}
|
||||||
|
|
||||||
const authHeader = req.headers.authorization;
|
const authHeader = req.headers.authorization;
|
||||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
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' });
|
return res.status(401).json({ message: 'Unauthorized: Missing or malformed Bearer token' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const token = authHeader.split(' ')[1];
|
const token = authHeader.split(' ')[1];
|
||||||
if (token !== flicSecret) {
|
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' });
|
return res.status(401).json({ message: 'Unauthorized: Invalid token' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log('Auth (Flic): Request authenticated successfully.');
|
||||||
next();
|
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) => {
|
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'}`);
|
console.log(`Received webhook: Button=${button_id}, Type=${click_type}, Timestamp=${timestamp || 'N/A'}`);
|
||||||
|
|
||||||
// Basic validation
|
// Basic validation
|
||||||
if (!button_id || !click_type) {
|
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' });
|
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 normalizedButtonId = button_id.toLowerCase(); // Use lowercase for lookup consistency
|
||||||
const subscription = subscriptions[button_id.toLowerCase()] || subscriptions[button_id]; // Check both cases just in case
|
|
||||||
|
|
||||||
|
// Find the subscription associated with this button ID
|
||||||
|
const subscription = subscriptions[normalizedButtonId];
|
||||||
|
|
||||||
if (!subscription) {
|
if (!subscription) {
|
||||||
console.warn(`No subscription found for button ID: ${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 ${button_id}` });
|
return res.status(404).json({ message: `Not Found: No subscription configured for button ${normalizedButtonId}` });
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Send Web Push Notification ---
|
// --- Send Web Push Notification ---
|
||||||
const payload = JSON.stringify({
|
const payload = JSON.stringify({
|
||||||
title: 'Flic Button Action',
|
title: 'Flic Button Action',
|
||||||
body: `Button ${button_id} - ${click_type}`,
|
body: `Button ${click_type}`, // Simplified body
|
||||||
data: { // Send structured data to the PWA
|
data: {
|
||||||
action: click_type, // e.g., "SingleClick", "DoubleClick", "Hold"
|
action: click_type,
|
||||||
button: button_id,
|
button: normalizedButtonId, // Send normalized ID
|
||||||
timestamp: timestamp || new Date().toISOString()
|
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 {
|
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);
|
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' });
|
res.status(200).json({ message: 'Push notification sent successfully' });
|
||||||
} catch (error) {
|
} 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) {
|
if (error.statusCode === 404 || error.statusCode === 410) {
|
||||||
console.warn(`Subscription for button ${button_id} is invalid or expired (404/410). Consider removing it.`);
|
console.warn(`Subscription for button ${normalizedButtonId} is invalid or expired (404/410). Removing it.`);
|
||||||
// Optionally, you could implement logic here to remove the stale subscription
|
// Optionally remove the stale subscription
|
||||||
// delete subscriptions[button_id];
|
delete subscriptions[normalizedButtonId];
|
||||||
// fs.writeFileSync(subscriptionsFilePath, JSON.stringify(subscriptions, null, 2), 'utf8');
|
saveSubscriptions(); // Attempt to save the updated list
|
||||||
res.status(410).json({ message: 'Subscription Gone' });
|
res.status(410).json({ message: 'Subscription Gone' });
|
||||||
} else {
|
} else {
|
||||||
res.status(500).json({ message: 'Internal Server Error: Failed to send push notification' });
|
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) => {
|
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 ---
|
// --- 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(`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 Methods: ${allowedMethods.join(', ')}`);
|
||||||
console.log(`Allowed Headers: ${allowedHeaders.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}`);
|
console.log(`Subscriptions File: ${subscriptionsFilePath}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- Graceful Shutdown (Optional but Recommended) ---
|
// --- Graceful Shutdown ---
|
||||||
process.on('SIGTERM', () => {
|
const closeGracefully = (signal) => {
|
||||||
console.log('SIGTERM signal received: closing HTTP server');
|
console.log(`${signal} signal received: closing HTTP server`);
|
||||||
app.close(() => { // Doesn't work directly with app.listen, need http.createServer
|
server.close(() => {
|
||||||
console.log('HTTP server closed');
|
console.log('HTTP server closed');
|
||||||
|
// Perform any other cleanup here if needed
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
});
|
});
|
||||||
// If server.close doesn't exit quickly, force exit after timeout
|
|
||||||
|
// Force close server after 10 seconds
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
console.error('Could not close connections in time, forcefully shutting down');
|
console.error('Could not close connections in time, forcefully shutting down');
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}, 10000); // 10 seconds timeout
|
}, 10000); // 10 seconds timeout
|
||||||
});
|
}
|
||||||
|
|
||||||
process.on('SIGINT', () => {
|
process.on('SIGTERM', () => closeGracefully('SIGTERM'));
|
||||||
console.log('SIGINT signal received: closing HTTP server');
|
process.on('SIGINT', () => closeGracefully('SIGINT')); // Handle Ctrl+C
|
||||||
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