update webapp
This commit is contained in:
@@ -55,10 +55,29 @@ CREATE TABLE IF NOT EXISTS client_logos (
|
||||
INDEX idx_short_name (short_name)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- Webhook queue table for debounced notifications
|
||||
CREATE TABLE IF NOT EXISTS webhook_queue (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
webhook_type VARCHAR(50) NOT NULL DEFAULT 'geofeed_update',
|
||||
trigger_reason VARCHAR(255) DEFAULT NULL,
|
||||
entries_affected INT DEFAULT 0,
|
||||
queued_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
scheduled_for TIMESTAMP NULL,
|
||||
processed_at TIMESTAMP NULL,
|
||||
status ENUM('pending', 'processing', 'completed', 'failed') DEFAULT 'pending',
|
||||
response_code INT DEFAULT NULL,
|
||||
response_body TEXT DEFAULT NULL,
|
||||
INDEX idx_status (status),
|
||||
INDEX idx_scheduled (scheduled_for)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- Insert default settings
|
||||
INSERT INTO geofeed_settings (setting_key, setting_value) VALUES
|
||||
('bunny_cdn_storage_zone', ''),
|
||||
('bunny_cdn_api_key', ''),
|
||||
('bunny_cdn_file_path', '/geofeed.csv'),
|
||||
('last_export_at', NULL)
|
||||
('last_export_at', NULL),
|
||||
('n8n_webhook_url', ''),
|
||||
('n8n_webhook_enabled', '0'),
|
||||
('n8n_webhook_delay_minutes', '3')
|
||||
ON DUPLICATE KEY UPDATE setting_key = setting_key;
|
||||
|
||||
@@ -1,34 +1,58 @@
|
||||
{
|
||||
"name": "Geofeed Export to BunnyCDN",
|
||||
"nodes": [
|
||||
{
|
||||
"parameters": {
|
||||
"httpMethod": "POST",
|
||||
"path": "geofeed-update",
|
||||
"responseMode": "onReceived",
|
||||
"options": {}
|
||||
},
|
||||
"id": "webhook-trigger",
|
||||
"name": "Webhook Trigger",
|
||||
"type": "n8n-nodes-base.webhook",
|
||||
"typeVersion": 2,
|
||||
"position": [240, 200],
|
||||
"webhookId": "geofeed-update"
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"rule": {
|
||||
"interval": [
|
||||
{
|
||||
"field": "hours",
|
||||
"hoursInterval": 1
|
||||
"hoursInterval": 24
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"id": "schedule-trigger",
|
||||
"name": "Hourly Trigger",
|
||||
"name": "Daily Backup Trigger",
|
||||
"type": "n8n-nodes-base.scheduleTrigger",
|
||||
"typeVersion": 1.1,
|
||||
"position": [240, 300]
|
||||
"position": [240, 400]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"jsCode": "// Log the trigger source for debugging\nconst webhookData = $('Webhook Trigger').item?.json || null;\nconst scheduleData = $('Daily Backup Trigger').item?.json || null;\n\nlet triggerSource = 'unknown';\nlet triggerReason = '';\nlet entriesAffected = 0;\nlet isImmediate = false;\n\nif (webhookData) {\n triggerSource = 'webhook';\n triggerReason = webhookData.trigger_reason || 'webhook_trigger';\n entriesAffected = webhookData.entries_affected || 0;\n isImmediate = webhookData.immediate || false;\n} else if (scheduleData) {\n triggerSource = 'schedule';\n triggerReason = 'daily_backup';\n}\n\nreturn [{\n json: {\n triggerSource,\n triggerReason,\n entriesAffected,\n isImmediate,\n timestamp: new Date().toISOString()\n }\n}];"
|
||||
},
|
||||
"id": "code-merge-triggers",
|
||||
"name": "Process Trigger",
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"position": [460, 300]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"operation": "executeQuery",
|
||||
"query": "SELECT ip_prefix, IFNULL(country_code, '') as country_code, IFNULL(region_code, '') as region_code, IFNULL(city, '') as city, IFNULL(postal_code, '') as postal_code FROM geofeed_entries ORDER BY ip_prefix",
|
||||
"query": "SELECT ip_prefix, IFNULL(country_code, '') as country_code, IFNULL(region_code, '') as region_code, IFNULL(city, '') as city, IFNULL(postal_code, '') as postal_code FROM geofeed_entries ORDER BY CASE WHEN ip_prefix LIKE '%:%' THEN 1 ELSE 0 END, INET_ATON(SUBSTRING_INDEX(ip_prefix, '/', 1)), ip_prefix",
|
||||
"options": {}
|
||||
},
|
||||
"id": "mysql-query",
|
||||
"name": "Query Geofeed Entries",
|
||||
"type": "n8n-nodes-base.mySql",
|
||||
"typeVersion": 2.3,
|
||||
"position": [460, 300],
|
||||
"position": [680, 300],
|
||||
"credentials": {
|
||||
"mySql": {
|
||||
"id": "YOUR_MYSQL_CREDENTIAL_ID",
|
||||
@@ -44,7 +68,7 @@
|
||||
"name": "Build CSV Content",
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"position": [680, 300]
|
||||
"position": [900, 300]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
@@ -79,7 +103,7 @@
|
||||
"name": "Upload to BunnyCDN",
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.2,
|
||||
"position": [900, 300]
|
||||
"position": [1120, 300]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
@@ -108,19 +132,19 @@
|
||||
"name": "Check Upload Success",
|
||||
"type": "n8n-nodes-base.if",
|
||||
"typeVersion": 2,
|
||||
"position": [1120, 300]
|
||||
"position": [1340, 300]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"operation": "executeQuery",
|
||||
"query": "INSERT INTO geofeed_audit_log (entry_id, action, new_values, changed_by) VALUES (NULL, 'INSERT', JSON_OBJECT('type', 'csv_export', 'status', 'success', 'timestamp', NOW()), 'n8n_workflow')",
|
||||
"query": "INSERT INTO geofeed_audit_log (entry_id, action, new_values, changed_by) VALUES (NULL, 'INSERT', JSON_OBJECT('type', 'csv_export', 'status', 'success', 'trigger', '{{ $('Process Trigger').item.json.triggerSource }}', 'reason', '{{ $('Process Trigger').item.json.triggerReason }}', 'entries', {{ $('Build CSV Content').item.json.entryCount }}, 'timestamp', NOW()), 'n8n_workflow')",
|
||||
"options": {}
|
||||
},
|
||||
"id": "mysql-log-success",
|
||||
"name": "Log Export Success",
|
||||
"type": "n8n-nodes-base.mySql",
|
||||
"typeVersion": 2.3,
|
||||
"position": [1340, 200],
|
||||
"position": [1560, 200],
|
||||
"credentials": {
|
||||
"mySql": {
|
||||
"id": "YOUR_MYSQL_CREDENTIAL_ID",
|
||||
@@ -138,7 +162,7 @@
|
||||
"name": "Update Last Export Time",
|
||||
"type": "n8n-nodes-base.mySql",
|
||||
"typeVersion": 2.3,
|
||||
"position": [1560, 200],
|
||||
"position": [1780, 200],
|
||||
"credentials": {
|
||||
"mySql": {
|
||||
"id": "YOUR_MYSQL_CREDENTIAL_ID",
|
||||
@@ -149,14 +173,14 @@
|
||||
{
|
||||
"parameters": {
|
||||
"operation": "executeQuery",
|
||||
"query": "INSERT INTO geofeed_audit_log (entry_id, action, new_values, changed_by) VALUES (NULL, 'INSERT', JSON_OBJECT('type', 'csv_export', 'status', 'failed', 'timestamp', NOW()), 'n8n_workflow')",
|
||||
"query": "INSERT INTO geofeed_audit_log (entry_id, action, new_values, changed_by) VALUES (NULL, 'INSERT', JSON_OBJECT('type', 'csv_export', 'status', 'failed', 'trigger', '{{ $('Process Trigger').item.json.triggerSource }}', 'timestamp', NOW()), 'n8n_workflow')",
|
||||
"options": {}
|
||||
},
|
||||
"id": "mysql-log-failure",
|
||||
"name": "Log Export Failure",
|
||||
"type": "n8n-nodes-base.mySql",
|
||||
"typeVersion": 2.3,
|
||||
"position": [1340, 400],
|
||||
"position": [1560, 400],
|
||||
"credentials": {
|
||||
"mySql": {
|
||||
"id": "YOUR_MYSQL_CREDENTIAL_ID",
|
||||
@@ -172,7 +196,7 @@
|
||||
"name": "Stop and Error",
|
||||
"type": "n8n-nodes-base.stopAndError",
|
||||
"typeVersion": 1,
|
||||
"position": [1560, 400]
|
||||
"position": [1780, 400]
|
||||
},
|
||||
{
|
||||
"parameters": {},
|
||||
@@ -180,11 +204,33 @@
|
||||
"name": "Export Complete",
|
||||
"type": "n8n-nodes-base.noOp",
|
||||
"typeVersion": 1,
|
||||
"position": [1780, 200]
|
||||
"position": [2000, 200]
|
||||
}
|
||||
],
|
||||
"connections": {
|
||||
"Hourly Trigger": {
|
||||
"Webhook Trigger": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Process Trigger",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Daily Backup Trigger": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Process Trigger",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Process Trigger": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
@@ -295,9 +341,14 @@
|
||||
"name": "bunnycdn",
|
||||
"createdAt": "2024-01-01T00:00:00.000Z",
|
||||
"updatedAt": "2024-01-01T00:00:00.000Z"
|
||||
},
|
||||
{
|
||||
"name": "webhook",
|
||||
"createdAt": "2024-01-01T00:00:00.000Z",
|
||||
"updatedAt": "2024-01-01T00:00:00.000Z"
|
||||
}
|
||||
],
|
||||
"triggerCount": 0,
|
||||
"updatedAt": "2024-01-01T00:00:00.000Z",
|
||||
"versionId": "1"
|
||||
"versionId": "2"
|
||||
}
|
||||
|
||||
234
webapp/api.php
234
webapp/api.php
@@ -86,6 +86,30 @@ try {
|
||||
handleShortnamesList($db);
|
||||
break;
|
||||
|
||||
case 'webhook_settings_get':
|
||||
handleWebhookSettingsGet($db);
|
||||
break;
|
||||
|
||||
case 'webhook_settings_save':
|
||||
handleWebhookSettingsSave($db);
|
||||
break;
|
||||
|
||||
case 'webhook_test':
|
||||
handleWebhookTest($db);
|
||||
break;
|
||||
|
||||
case 'webhook_trigger':
|
||||
handleWebhookTrigger($db);
|
||||
break;
|
||||
|
||||
case 'webhook_process':
|
||||
handleWebhookProcess($db);
|
||||
break;
|
||||
|
||||
case 'webhook_queue_status':
|
||||
handleWebhookQueueStatus($db);
|
||||
break;
|
||||
|
||||
default:
|
||||
jsonResponse(['error' => 'Invalid action'], 400);
|
||||
}
|
||||
@@ -245,6 +269,9 @@ function handleCreate($db) {
|
||||
// Log the action
|
||||
logAction($db, $id, 'INSERT', null, $input);
|
||||
|
||||
// Queue webhook notification
|
||||
queueWebhookNotification($db, 'entry_created', 1);
|
||||
|
||||
jsonResponse(['success' => true, 'id' => $id, 'message' => 'Entry created successfully'], 201);
|
||||
}
|
||||
|
||||
@@ -330,6 +357,9 @@ function handleUpdate($db) {
|
||||
// Log the action
|
||||
logAction($db, $id, 'UPDATE', $oldEntry, $input);
|
||||
|
||||
// Queue webhook notification
|
||||
queueWebhookNotification($db, 'entry_updated', 1);
|
||||
|
||||
jsonResponse(['success' => true, 'message' => 'Entry updated successfully']);
|
||||
}
|
||||
|
||||
@@ -369,6 +399,9 @@ function handleDelete($db) {
|
||||
// Log the action
|
||||
logAction($db, $id, 'DELETE', $oldEntry, null);
|
||||
|
||||
// Queue webhook notification
|
||||
queueWebhookNotification($db, 'entry_deleted', 1);
|
||||
|
||||
jsonResponse(['success' => true, 'message' => 'Entry deleted successfully']);
|
||||
}
|
||||
|
||||
@@ -571,6 +604,12 @@ function handleImport($db) {
|
||||
'failed' => $failed
|
||||
]);
|
||||
|
||||
// Queue webhook notification for bulk import
|
||||
$totalAffected = $inserted + $updated;
|
||||
if ($totalAffected > 0) {
|
||||
queueWebhookNotification($db, 'bulk_import', $totalAffected);
|
||||
}
|
||||
|
||||
jsonResponse([
|
||||
'success' => true,
|
||||
'inserted' => $inserted,
|
||||
@@ -724,6 +763,12 @@ function handleImportUrl($db) {
|
||||
'failed' => $failed
|
||||
]);
|
||||
|
||||
// Queue webhook notification for URL import
|
||||
$totalAffected = $inserted + $updated;
|
||||
if ($totalAffected > 0) {
|
||||
queueWebhookNotification($db, 'url_import', $totalAffected);
|
||||
}
|
||||
|
||||
jsonResponse([
|
||||
'success' => true,
|
||||
'inserted' => $inserted,
|
||||
@@ -766,6 +811,11 @@ function handleClearAll($db) {
|
||||
// Log the action
|
||||
logAction($db, null, 'DELETE', ['count' => $count], ['type' => 'clear_all']);
|
||||
|
||||
// Queue webhook notification for clear all
|
||||
if ($count > 0) {
|
||||
queueWebhookNotification($db, 'clear_all', $count);
|
||||
}
|
||||
|
||||
jsonResponse(['success' => true, 'deleted' => $count]);
|
||||
|
||||
} catch (Exception $e) {
|
||||
@@ -934,3 +984,187 @@ function handleShortnamesList($db) {
|
||||
|
||||
jsonResponse(['success' => true, 'data' => $shortnames]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get webhook settings
|
||||
*/
|
||||
function handleWebhookSettingsGet($db) {
|
||||
$settings = [
|
||||
'webhook_url' => getSetting($db, 'n8n_webhook_url', ''),
|
||||
'webhook_enabled' => getSetting($db, 'n8n_webhook_enabled', '0') === '1',
|
||||
'webhook_delay_minutes' => intval(getSetting($db, 'n8n_webhook_delay_minutes', '3'))
|
||||
];
|
||||
|
||||
jsonResponse(['success' => true, 'data' => $settings]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Save webhook settings
|
||||
*/
|
||||
function handleWebhookSettingsSave($db) {
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
jsonResponse(['error' => 'Method not allowed'], 405);
|
||||
}
|
||||
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
|
||||
// Validate CSRF
|
||||
if (!validateCSRFToken($input['csrf_token'] ?? '')) {
|
||||
jsonResponse(['error' => 'Invalid CSRF token'], 403);
|
||||
}
|
||||
|
||||
$webhookUrl = trim($input['webhook_url'] ?? '');
|
||||
$webhookEnabled = !empty($input['webhook_enabled']) ? '1' : '0';
|
||||
$delayMinutes = max(1, min(60, intval($input['webhook_delay_minutes'] ?? 3)));
|
||||
|
||||
// Validate URL if provided
|
||||
if (!empty($webhookUrl) && !filter_var($webhookUrl, FILTER_VALIDATE_URL)) {
|
||||
jsonResponse(['error' => 'Invalid webhook URL'], 400);
|
||||
}
|
||||
|
||||
saveSetting($db, 'n8n_webhook_url', $webhookUrl);
|
||||
saveSetting($db, 'n8n_webhook_enabled', $webhookEnabled);
|
||||
saveSetting($db, 'n8n_webhook_delay_minutes', (string)$delayMinutes);
|
||||
|
||||
jsonResponse(['success' => true, 'message' => 'Webhook settings saved successfully']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test webhook connection
|
||||
*/
|
||||
function handleWebhookTest($db) {
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
jsonResponse(['error' => 'Method not allowed'], 405);
|
||||
}
|
||||
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
|
||||
// Validate CSRF
|
||||
if (!validateCSRFToken($input['csrf_token'] ?? '')) {
|
||||
jsonResponse(['error' => 'Invalid CSRF token'], 403);
|
||||
}
|
||||
|
||||
$webhookUrl = getSetting($db, 'n8n_webhook_url', '');
|
||||
if (empty($webhookUrl)) {
|
||||
jsonResponse(['error' => 'No webhook URL configured'], 400);
|
||||
}
|
||||
|
||||
$payload = [
|
||||
'event' => 'test',
|
||||
'message' => 'Test webhook from Geofeed Manager',
|
||||
'timestamp' => date('c')
|
||||
];
|
||||
|
||||
$result = sendWebhook($webhookUrl, $payload);
|
||||
|
||||
if ($result['success']) {
|
||||
jsonResponse([
|
||||
'success' => true,
|
||||
'message' => 'Webhook test successful',
|
||||
'http_code' => $result['http_code']
|
||||
]);
|
||||
} else {
|
||||
jsonResponse([
|
||||
'success' => false,
|
||||
'error' => 'Webhook test failed: ' . ($result['error'] ?: "HTTP {$result['http_code']}"),
|
||||
'http_code' => $result['http_code']
|
||||
], 400);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Manually trigger webhook (immediate)
|
||||
*/
|
||||
function handleWebhookTrigger($db) {
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
jsonResponse(['error' => 'Method not allowed'], 405);
|
||||
}
|
||||
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
|
||||
// Validate CSRF
|
||||
if (!validateCSRFToken($input['csrf_token'] ?? '')) {
|
||||
jsonResponse(['error' => 'Invalid CSRF token'], 403);
|
||||
}
|
||||
|
||||
$result = triggerImmediateWebhook($db, 'manual_trigger');
|
||||
|
||||
if ($result['success']) {
|
||||
jsonResponse([
|
||||
'success' => true,
|
||||
'message' => 'Webhook triggered successfully',
|
||||
'http_code' => $result['http_code']
|
||||
]);
|
||||
} else {
|
||||
jsonResponse([
|
||||
'success' => false,
|
||||
'error' => $result['error'] ?: 'Failed to trigger webhook',
|
||||
'http_code' => $result['http_code'] ?? null
|
||||
], 400);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process pending webhooks in the queue
|
||||
* This endpoint can be called by a cron job or manually
|
||||
*/
|
||||
function handleWebhookProcess($db) {
|
||||
// This endpoint can be called without CSRF for cron jobs
|
||||
// but we'll check for an optional API key in the future
|
||||
|
||||
$result = processWebhookQueue($db);
|
||||
|
||||
jsonResponse([
|
||||
'success' => true,
|
||||
'processed' => $result['processed'],
|
||||
'results' => $result['results'] ?? []
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get webhook queue status
|
||||
*/
|
||||
function handleWebhookQueueStatus($db) {
|
||||
// Get pending webhooks
|
||||
$stmt = $db->query("
|
||||
SELECT id, trigger_reason, entries_affected, queued_at, scheduled_for, status
|
||||
FROM webhook_queue
|
||||
WHERE status IN ('pending', 'processing')
|
||||
ORDER BY scheduled_for ASC
|
||||
LIMIT 10
|
||||
");
|
||||
$pending = $stmt->fetchAll();
|
||||
|
||||
// Get recent completed/failed webhooks
|
||||
$stmt = $db->query("
|
||||
SELECT id, trigger_reason, entries_affected, queued_at, processed_at, status, response_code
|
||||
FROM webhook_queue
|
||||
WHERE status IN ('completed', 'failed')
|
||||
ORDER BY processed_at DESC
|
||||
LIMIT 10
|
||||
");
|
||||
$recent = $stmt->fetchAll();
|
||||
|
||||
// Get counts
|
||||
$stmt = $db->query("
|
||||
SELECT
|
||||
SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) as pending_count,
|
||||
SUM(CASE WHEN status = 'completed' AND processed_at > DATE_SUB(NOW(), INTERVAL 24 HOUR) THEN 1 ELSE 0 END) as completed_24h,
|
||||
SUM(CASE WHEN status = 'failed' AND processed_at > DATE_SUB(NOW(), INTERVAL 24 HOUR) THEN 1 ELSE 0 END) as failed_24h
|
||||
FROM webhook_queue
|
||||
");
|
||||
$counts = $stmt->fetch();
|
||||
|
||||
jsonResponse([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'pending' => $pending,
|
||||
'recent' => $recent,
|
||||
'counts' => [
|
||||
'pending' => intval($counts['pending_count'] ?? 0),
|
||||
'completed_24h' => intval($counts['completed_24h'] ?? 0),
|
||||
'failed_24h' => intval($counts['failed_24h'] ?? 0)
|
||||
]
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -107,3 +107,207 @@ function isValidRegionCode($code) {
|
||||
if (empty($code)) return true;
|
||||
return preg_match('/^[A-Z]{2}-[A-Z0-9]{1,3}$/i', $code);
|
||||
}
|
||||
|
||||
// Get a setting value from the database
|
||||
function getSetting($db, $key, $default = null) {
|
||||
static $cache = [];
|
||||
|
||||
if (isset($cache[$key])) {
|
||||
return $cache[$key];
|
||||
}
|
||||
|
||||
try {
|
||||
$stmt = $db->prepare("SELECT setting_value FROM geofeed_settings WHERE setting_key = :key");
|
||||
$stmt->execute([':key' => $key]);
|
||||
$result = $stmt->fetch();
|
||||
$cache[$key] = $result ? $result['setting_value'] : $default;
|
||||
return $cache[$key];
|
||||
} catch (Exception $e) {
|
||||
return $default;
|
||||
}
|
||||
}
|
||||
|
||||
// Save a setting value to the database
|
||||
function saveSetting($db, $key, $value) {
|
||||
$stmt = $db->prepare("
|
||||
INSERT INTO geofeed_settings (setting_key, setting_value)
|
||||
VALUES (:key, :value)
|
||||
ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value), updated_at = CURRENT_TIMESTAMP
|
||||
");
|
||||
$stmt->execute([':key' => $key, ':value' => $value]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue a webhook notification with debouncing
|
||||
* This will schedule a webhook to fire after a delay, consolidating multiple updates
|
||||
*/
|
||||
function queueWebhookNotification($db, $reason = 'manual', $entriesAffected = 1) {
|
||||
// Check if webhooks are enabled
|
||||
$enabled = getSetting($db, 'n8n_webhook_enabled', '0');
|
||||
if ($enabled !== '1') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$webhookUrl = getSetting($db, 'n8n_webhook_url', '');
|
||||
if (empty($webhookUrl)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$delayMinutes = intval(getSetting($db, 'n8n_webhook_delay_minutes', '3'));
|
||||
$scheduledFor = date('Y-m-d H:i:s', strtotime("+{$delayMinutes} minutes"));
|
||||
|
||||
// Check if there's already a pending webhook scheduled
|
||||
$stmt = $db->prepare("
|
||||
SELECT id, entries_affected FROM webhook_queue
|
||||
WHERE status = 'pending' AND scheduled_for > NOW()
|
||||
ORDER BY scheduled_for DESC LIMIT 1
|
||||
");
|
||||
$stmt->execute();
|
||||
$existing = $stmt->fetch();
|
||||
|
||||
if ($existing) {
|
||||
// Update existing pending webhook to consolidate and reschedule
|
||||
$stmt = $db->prepare("
|
||||
UPDATE webhook_queue
|
||||
SET scheduled_for = :scheduled_for,
|
||||
entries_affected = entries_affected + :entries,
|
||||
trigger_reason = CONCAT(IFNULL(trigger_reason, ''), ', ', :reason)
|
||||
WHERE id = :id
|
||||
");
|
||||
$stmt->execute([
|
||||
':scheduled_for' => $scheduledFor,
|
||||
':entries' => $entriesAffected,
|
||||
':reason' => $reason,
|
||||
':id' => $existing['id']
|
||||
]);
|
||||
return $existing['id'];
|
||||
} else {
|
||||
// Create new webhook queue entry
|
||||
$stmt = $db->prepare("
|
||||
INSERT INTO webhook_queue (webhook_type, trigger_reason, entries_affected, scheduled_for, status)
|
||||
VALUES ('geofeed_update', :reason, :entries, :scheduled_for, 'pending')
|
||||
");
|
||||
$stmt->execute([
|
||||
':reason' => $reason,
|
||||
':entries' => $entriesAffected,
|
||||
':scheduled_for' => $scheduledFor
|
||||
]);
|
||||
return $db->lastInsertId();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process pending webhooks that are due
|
||||
* This should be called by a cron job or the webhook processor endpoint
|
||||
*/
|
||||
function processWebhookQueue($db) {
|
||||
$webhookUrl = getSetting($db, 'n8n_webhook_url', '');
|
||||
if (empty($webhookUrl)) {
|
||||
return ['processed' => 0, 'error' => 'No webhook URL configured'];
|
||||
}
|
||||
|
||||
// Get pending webhooks that are due
|
||||
$stmt = $db->prepare("
|
||||
SELECT * FROM webhook_queue
|
||||
WHERE status = 'pending' AND scheduled_for <= NOW()
|
||||
ORDER BY scheduled_for ASC
|
||||
LIMIT 10
|
||||
");
|
||||
$stmt->execute();
|
||||
$webhooks = $stmt->fetchAll();
|
||||
|
||||
$processed = 0;
|
||||
$results = [];
|
||||
|
||||
foreach ($webhooks as $webhook) {
|
||||
// Mark as processing
|
||||
$updateStmt = $db->prepare("UPDATE webhook_queue SET status = 'processing' WHERE id = :id");
|
||||
$updateStmt->execute([':id' => $webhook['id']]);
|
||||
|
||||
// Send the webhook
|
||||
$payload = [
|
||||
'event' => 'geofeed_update',
|
||||
'queue_id' => $webhook['id'],
|
||||
'trigger_reason' => $webhook['trigger_reason'],
|
||||
'entries_affected' => $webhook['entries_affected'],
|
||||
'queued_at' => $webhook['queued_at'],
|
||||
'timestamp' => date('c')
|
||||
];
|
||||
|
||||
$result = sendWebhook($webhookUrl, $payload);
|
||||
|
||||
// Update status
|
||||
$finalStatus = $result['success'] ? 'completed' : 'failed';
|
||||
$updateStmt = $db->prepare("
|
||||
UPDATE webhook_queue
|
||||
SET status = :status, processed_at = NOW(), response_code = :code, response_body = :body
|
||||
WHERE id = :id
|
||||
");
|
||||
$updateStmt->execute([
|
||||
':status' => $finalStatus,
|
||||
':code' => $result['http_code'],
|
||||
':body' => substr($result['response'], 0, 1000),
|
||||
':id' => $webhook['id']
|
||||
]);
|
||||
|
||||
$processed++;
|
||||
$results[] = [
|
||||
'id' => $webhook['id'],
|
||||
'success' => $result['success'],
|
||||
'http_code' => $result['http_code']
|
||||
];
|
||||
}
|
||||
|
||||
return ['processed' => $processed, 'results' => $results];
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a webhook to the configured URL
|
||||
*/
|
||||
function sendWebhook($url, $payload) {
|
||||
$ch = curl_init();
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_URL => $url,
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => json_encode($payload),
|
||||
CURLOPT_HTTPHEADER => [
|
||||
'Content-Type: application/json',
|
||||
'User-Agent: Geofeed-Manager/1.0'
|
||||
],
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 30,
|
||||
CURLOPT_FOLLOWLOCATION => true,
|
||||
CURLOPT_SSL_VERIFYPEER => true
|
||||
]);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$error = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
return [
|
||||
'success' => $httpCode >= 200 && $httpCode < 300,
|
||||
'http_code' => $httpCode,
|
||||
'response' => $response ?: $error,
|
||||
'error' => $error
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger immediate webhook (bypasses queue for manual triggers)
|
||||
*/
|
||||
function triggerImmediateWebhook($db, $reason = 'manual_trigger') {
|
||||
$webhookUrl = getSetting($db, 'n8n_webhook_url', '');
|
||||
if (empty($webhookUrl)) {
|
||||
return ['success' => false, 'error' => 'No webhook URL configured'];
|
||||
}
|
||||
|
||||
$payload = [
|
||||
'event' => 'geofeed_update',
|
||||
'trigger_reason' => $reason,
|
||||
'immediate' => true,
|
||||
'timestamp' => date('c')
|
||||
];
|
||||
|
||||
return sendWebhook($webhookUrl, $payload);
|
||||
}
|
||||
|
||||
253
webapp/index.php
253
webapp/index.php
@@ -1566,6 +1566,81 @@ if (!function_exists('generateCSRFToken')) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Webhook Settings Section -->
|
||||
<div class="advanced-section">
|
||||
<h2 class="advanced-section-title">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="display: inline; vertical-align: middle; margin-right: 8px;">
|
||||
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
|
||||
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
|
||||
</svg>
|
||||
n8n Webhook Integration
|
||||
</h2>
|
||||
<p class="advanced-section-desc">Configure webhooks to notify n8n when geofeed data changes. Updates are debounced to batch multiple changes and reduce API calls.</p>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">
|
||||
<input type="checkbox" id="webhookEnabled" style="margin-right: 8px; vertical-align: middle;">
|
||||
Enable Webhook Notifications
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Webhook URL</label>
|
||||
<input type="url" class="form-input" id="webhookUrl" placeholder="https://your-n8n-instance.com/webhook/xxx">
|
||||
<div class="form-hint">The n8n webhook URL to receive notifications</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Debounce Delay (minutes)</label>
|
||||
<input type="number" class="form-input" id="webhookDelay" min="1" max="60" value="3" style="max-width: 120px;">
|
||||
<div class="form-hint">Wait this many minutes after the last change before triggering the webhook (1-60 minutes)</div>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; gap: 12px; flex-wrap: wrap; margin-bottom: 20px;">
|
||||
<button class="btn btn-primary" onclick="saveWebhookSettings()">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/>
|
||||
<polyline points="17 21 17 13 7 13 7 21"/>
|
||||
<polyline points="7 3 7 8 15 8"/>
|
||||
</svg>
|
||||
Save Settings
|
||||
</button>
|
||||
<button class="btn btn-secondary" onclick="testWebhook()">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polygon points="5 3 19 12 5 21 5 3"/>
|
||||
</svg>
|
||||
Test Connection
|
||||
</button>
|
||||
<button class="btn btn-secondary" onclick="triggerWebhookNow()">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/>
|
||||
</svg>
|
||||
Trigger Now
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Webhook Queue Status -->
|
||||
<div class="table-container" style="margin-top: 16px;">
|
||||
<div class="table-header">
|
||||
<h3 class="table-title">Webhook Queue</h3>
|
||||
<div class="table-actions">
|
||||
<button class="btn btn-ghost btn-sm" onclick="loadWebhookQueueStatus()">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="23 4 23 10 17 10"/>
|
||||
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/>
|
||||
</svg>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="webhookQueueContainer" style="padding: 20px;">
|
||||
<div class="loading">
|
||||
<div class="spinner"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Client Logos Section -->
|
||||
<div class="advanced-section">
|
||||
<h2 class="advanced-section-title">
|
||||
@@ -1850,6 +1925,8 @@ if (!function_exists('generateCSRFToken')) {
|
||||
loadAuditLog();
|
||||
loadShortnames();
|
||||
loadLogosGrid();
|
||||
loadWebhookSettings();
|
||||
loadWebhookQueueStatus();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2246,6 +2323,182 @@ if (!function_exists('generateCSRFToken')) {
|
||||
}
|
||||
}
|
||||
|
||||
// Load webhook settings
|
||||
async function loadWebhookSettings() {
|
||||
try {
|
||||
const result = await api('webhook_settings_get');
|
||||
if (result.success) {
|
||||
document.getElementById('webhookUrl').value = result.data.webhook_url || '';
|
||||
document.getElementById('webhookEnabled').checked = result.data.webhook_enabled;
|
||||
document.getElementById('webhookDelay').value = result.data.webhook_delay_minutes || 3;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load webhook settings:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Save webhook settings
|
||||
async function saveWebhookSettings() {
|
||||
const webhookUrl = document.getElementById('webhookUrl').value.trim();
|
||||
const webhookEnabled = document.getElementById('webhookEnabled').checked;
|
||||
const webhookDelay = parseInt(document.getElementById('webhookDelay').value) || 3;
|
||||
|
||||
try {
|
||||
const result = await api('webhook_settings_save', {}, 'POST', {
|
||||
webhook_url: webhookUrl,
|
||||
webhook_enabled: webhookEnabled,
|
||||
webhook_delay_minutes: webhookDelay
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
showToast('Webhook settings saved successfully', 'success');
|
||||
} else {
|
||||
showToast(result.error || 'Failed to save settings', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('Network error', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Test webhook connection
|
||||
async function testWebhook() {
|
||||
const webhookUrl = document.getElementById('webhookUrl').value.trim();
|
||||
if (!webhookUrl) {
|
||||
showToast('Please enter a webhook URL first', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
showToast('Testing webhook...', 'success');
|
||||
const result = await api('webhook_test', {}, 'POST', {});
|
||||
|
||||
if (result.success) {
|
||||
showToast(`Webhook test successful (HTTP ${result.http_code})`, 'success');
|
||||
} else {
|
||||
showToast(result.error || 'Webhook test failed', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('Network error', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Trigger webhook immediately
|
||||
async function triggerWebhookNow() {
|
||||
if (!confirm('This will immediately trigger the n8n webhook to update the CDN. Continue?')) return;
|
||||
|
||||
try {
|
||||
const result = await api('webhook_trigger', {}, 'POST', {});
|
||||
|
||||
if (result.success) {
|
||||
showToast('Webhook triggered successfully', 'success');
|
||||
loadWebhookQueueStatus();
|
||||
} else {
|
||||
showToast(result.error || 'Failed to trigger webhook', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('Network error', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Load webhook queue status
|
||||
async function loadWebhookQueueStatus() {
|
||||
const container = document.getElementById('webhookQueueContainer');
|
||||
|
||||
try {
|
||||
const result = await api('webhook_queue_status');
|
||||
|
||||
if (result.success) {
|
||||
renderWebhookQueueStatus(result.data);
|
||||
} else {
|
||||
container.innerHTML = '<p style="color: var(--text-secondary);">Failed to load queue status</p>';
|
||||
}
|
||||
} catch (error) {
|
||||
container.innerHTML = '<p style="color: var(--text-secondary);">Network error</p>';
|
||||
}
|
||||
}
|
||||
|
||||
// Render webhook queue status
|
||||
function renderWebhookQueueStatus(data) {
|
||||
const container = document.getElementById('webhookQueueContainer');
|
||||
|
||||
// Stats row
|
||||
let html = `
|
||||
<div style="display: flex; gap: 24px; margin-bottom: 20px; flex-wrap: wrap;">
|
||||
<div>
|
||||
<span style="font-size: 24px; font-weight: 700; color: var(--warning);">${data.counts.pending}</span>
|
||||
<span style="font-size: 13px; color: var(--text-secondary); display: block;">Pending</span>
|
||||
</div>
|
||||
<div>
|
||||
<span style="font-size: 24px; font-weight: 700; color: var(--success);">${data.counts.completed_24h}</span>
|
||||
<span style="font-size: 13px; color: var(--text-secondary); display: block;">Completed (24h)</span>
|
||||
</div>
|
||||
<div>
|
||||
<span style="font-size: 24px; font-weight: 700; color: var(--error);">${data.counts.failed_24h}</span>
|
||||
<span style="font-size: 13px; color: var(--text-secondary); display: block;">Failed (24h)</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Pending webhooks
|
||||
if (data.pending.length > 0) {
|
||||
html += '<h4 style="font-size: 14px; font-weight: 600; margin-bottom: 12px; color: var(--text-primary);">Pending Webhooks</h4>';
|
||||
html += '<div style="margin-bottom: 20px;">';
|
||||
data.pending.forEach(item => {
|
||||
const scheduledFor = new Date(item.scheduled_for);
|
||||
const timeUntil = getTimeUntil(scheduledFor);
|
||||
html += `
|
||||
<div style="display: flex; align-items: center; gap: 12px; padding: 12px; background: var(--warning-bg); border-radius: 8px; margin-bottom: 8px;">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--warning)" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<polyline points="12 6 12 12 16 14"/>
|
||||
</svg>
|
||||
<div style="flex: 1;">
|
||||
<div style="font-weight: 500; font-size: 14px;">${escapeHtml(item.trigger_reason || 'Queued update')}</div>
|
||||
<div style="font-size: 12px; color: var(--text-secondary);">${item.entries_affected} entries affected • fires ${timeUntil}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
// Recent webhooks
|
||||
if (data.recent.length > 0) {
|
||||
html += '<h4 style="font-size: 14px; font-weight: 600; margin-bottom: 12px; color: var(--text-primary);">Recent Webhooks</h4>';
|
||||
data.recent.forEach(item => {
|
||||
const isSuccess = item.status === 'completed';
|
||||
const processedAt = item.processed_at ? getTimeAgo(new Date(item.processed_at)) : '-';
|
||||
html += `
|
||||
<div style="display: flex; align-items: center; gap: 12px; padding: 12px; background: ${isSuccess ? 'var(--success-bg)' : 'var(--error-bg)'}; border-radius: 8px; margin-bottom: 8px;">
|
||||
${isSuccess
|
||||
? '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--success)" stroke-width="2"><polyline points="20 6 9 17 4 12"/></svg>'
|
||||
: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--error)" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>'
|
||||
}
|
||||
<div style="flex: 1;">
|
||||
<div style="font-weight: 500; font-size: 14px;">${escapeHtml(item.trigger_reason || 'Update')}</div>
|
||||
<div style="font-size: 12px; color: var(--text-secondary);">${item.entries_affected} entries • HTTP ${item.response_code || '-'} • ${processedAt}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
}
|
||||
|
||||
if (data.pending.length === 0 && data.recent.length === 0) {
|
||||
html += '<p style="color: var(--text-secondary); font-size: 14px; text-align: center; padding: 20px 0;">No webhook activity yet</p>';
|
||||
}
|
||||
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
// Get time until a future date
|
||||
function getTimeUntil(date) {
|
||||
const seconds = Math.floor((date - new Date()) / 1000);
|
||||
if (seconds < 0) return 'now';
|
||||
if (seconds < 60) return `in ${seconds}s`;
|
||||
if (seconds < 3600) return `in ${Math.floor(seconds / 60)}m`;
|
||||
return `in ${Math.floor(seconds / 3600)}h`;
|
||||
}
|
||||
|
||||
// Time ago helper
|
||||
function getTimeAgo(date) {
|
||||
const seconds = Math.floor((new Date() - date) / 1000);
|
||||
|
||||
Reference in New Issue
Block a user