update webapp

This commit is contained in:
Purple
2026-01-17 20:43:29 +00:00
parent c49bf2ed31
commit 328b7badc1
5 changed files with 794 additions and 33 deletions

View File

@@ -55,10 +55,29 @@ CREATE TABLE IF NOT EXISTS client_logos (
INDEX idx_short_name (short_name) INDEX idx_short_name (short_name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ) 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 default settings
INSERT INTO geofeed_settings (setting_key, setting_value) VALUES INSERT INTO geofeed_settings (setting_key, setting_value) VALUES
('bunny_cdn_storage_zone', ''), ('bunny_cdn_storage_zone', ''),
('bunny_cdn_api_key', ''), ('bunny_cdn_api_key', ''),
('bunny_cdn_file_path', '/geofeed.csv'), ('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; ON DUPLICATE KEY UPDATE setting_key = setting_key;

View File

@@ -1,34 +1,58 @@
{ {
"name": "Geofeed Export to BunnyCDN", "name": "Geofeed Export to BunnyCDN",
"nodes": [ "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": { "parameters": {
"rule": { "rule": {
"interval": [ "interval": [
{ {
"field": "hours", "field": "hours",
"hoursInterval": 1 "hoursInterval": 24
} }
] ]
} }
}, },
"id": "schedule-trigger", "id": "schedule-trigger",
"name": "Hourly Trigger", "name": "Daily Backup Trigger",
"type": "n8n-nodes-base.scheduleTrigger", "type": "n8n-nodes-base.scheduleTrigger",
"typeVersion": 1.1, "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": { "parameters": {
"operation": "executeQuery", "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": {} "options": {}
}, },
"id": "mysql-query", "id": "mysql-query",
"name": "Query Geofeed Entries", "name": "Query Geofeed Entries",
"type": "n8n-nodes-base.mySql", "type": "n8n-nodes-base.mySql",
"typeVersion": 2.3, "typeVersion": 2.3,
"position": [460, 300], "position": [680, 300],
"credentials": { "credentials": {
"mySql": { "mySql": {
"id": "YOUR_MYSQL_CREDENTIAL_ID", "id": "YOUR_MYSQL_CREDENTIAL_ID",
@@ -44,7 +68,7 @@
"name": "Build CSV Content", "name": "Build CSV Content",
"type": "n8n-nodes-base.code", "type": "n8n-nodes-base.code",
"typeVersion": 2, "typeVersion": 2,
"position": [680, 300] "position": [900, 300]
}, },
{ {
"parameters": { "parameters": {
@@ -79,7 +103,7 @@
"name": "Upload to BunnyCDN", "name": "Upload to BunnyCDN",
"type": "n8n-nodes-base.httpRequest", "type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2, "typeVersion": 4.2,
"position": [900, 300] "position": [1120, 300]
}, },
{ {
"parameters": { "parameters": {
@@ -108,19 +132,19 @@
"name": "Check Upload Success", "name": "Check Upload Success",
"type": "n8n-nodes-base.if", "type": "n8n-nodes-base.if",
"typeVersion": 2, "typeVersion": 2,
"position": [1120, 300] "position": [1340, 300]
}, },
{ {
"parameters": { "parameters": {
"operation": "executeQuery", "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": {} "options": {}
}, },
"id": "mysql-log-success", "id": "mysql-log-success",
"name": "Log Export Success", "name": "Log Export Success",
"type": "n8n-nodes-base.mySql", "type": "n8n-nodes-base.mySql",
"typeVersion": 2.3, "typeVersion": 2.3,
"position": [1340, 200], "position": [1560, 200],
"credentials": { "credentials": {
"mySql": { "mySql": {
"id": "YOUR_MYSQL_CREDENTIAL_ID", "id": "YOUR_MYSQL_CREDENTIAL_ID",
@@ -138,7 +162,7 @@
"name": "Update Last Export Time", "name": "Update Last Export Time",
"type": "n8n-nodes-base.mySql", "type": "n8n-nodes-base.mySql",
"typeVersion": 2.3, "typeVersion": 2.3,
"position": [1560, 200], "position": [1780, 200],
"credentials": { "credentials": {
"mySql": { "mySql": {
"id": "YOUR_MYSQL_CREDENTIAL_ID", "id": "YOUR_MYSQL_CREDENTIAL_ID",
@@ -149,14 +173,14 @@
{ {
"parameters": { "parameters": {
"operation": "executeQuery", "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": {} "options": {}
}, },
"id": "mysql-log-failure", "id": "mysql-log-failure",
"name": "Log Export Failure", "name": "Log Export Failure",
"type": "n8n-nodes-base.mySql", "type": "n8n-nodes-base.mySql",
"typeVersion": 2.3, "typeVersion": 2.3,
"position": [1340, 400], "position": [1560, 400],
"credentials": { "credentials": {
"mySql": { "mySql": {
"id": "YOUR_MYSQL_CREDENTIAL_ID", "id": "YOUR_MYSQL_CREDENTIAL_ID",
@@ -172,7 +196,7 @@
"name": "Stop and Error", "name": "Stop and Error",
"type": "n8n-nodes-base.stopAndError", "type": "n8n-nodes-base.stopAndError",
"typeVersion": 1, "typeVersion": 1,
"position": [1560, 400] "position": [1780, 400]
}, },
{ {
"parameters": {}, "parameters": {},
@@ -180,11 +204,33 @@
"name": "Export Complete", "name": "Export Complete",
"type": "n8n-nodes-base.noOp", "type": "n8n-nodes-base.noOp",
"typeVersion": 1, "typeVersion": 1,
"position": [1780, 200] "position": [2000, 200]
} }
], ],
"connections": { "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": [ "main": [
[ [
{ {
@@ -295,9 +341,14 @@
"name": "bunnycdn", "name": "bunnycdn",
"createdAt": "2024-01-01T00:00:00.000Z", "createdAt": "2024-01-01T00:00:00.000Z",
"updatedAt": "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, "triggerCount": 0,
"updatedAt": "2024-01-01T00:00:00.000Z", "updatedAt": "2024-01-01T00:00:00.000Z",
"versionId": "1" "versionId": "2"
} }

View File

@@ -86,6 +86,30 @@ try {
handleShortnamesList($db); handleShortnamesList($db);
break; 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: default:
jsonResponse(['error' => 'Invalid action'], 400); jsonResponse(['error' => 'Invalid action'], 400);
} }
@@ -244,7 +268,10 @@ function handleCreate($db) {
// Log the action // Log the action
logAction($db, $id, 'INSERT', null, $input); logAction($db, $id, 'INSERT', null, $input);
// Queue webhook notification
queueWebhookNotification($db, 'entry_created', 1);
jsonResponse(['success' => true, 'id' => $id, 'message' => 'Entry created successfully'], 201); jsonResponse(['success' => true, 'id' => $id, 'message' => 'Entry created successfully'], 201);
} }
@@ -329,7 +356,10 @@ function handleUpdate($db) {
// Log the action // Log the action
logAction($db, $id, 'UPDATE', $oldEntry, $input); logAction($db, $id, 'UPDATE', $oldEntry, $input);
// Queue webhook notification
queueWebhookNotification($db, 'entry_updated', 1);
jsonResponse(['success' => true, 'message' => 'Entry updated successfully']); jsonResponse(['success' => true, 'message' => 'Entry updated successfully']);
} }
@@ -368,7 +398,10 @@ function handleDelete($db) {
// Log the action // Log the action
logAction($db, $id, 'DELETE', $oldEntry, null); logAction($db, $id, 'DELETE', $oldEntry, null);
// Queue webhook notification
queueWebhookNotification($db, 'entry_deleted', 1);
jsonResponse(['success' => true, 'message' => 'Entry deleted successfully']); jsonResponse(['success' => true, 'message' => 'Entry deleted successfully']);
} }
@@ -570,14 +603,20 @@ function handleImport($db) {
'updated' => $updated, 'updated' => $updated,
'failed' => $failed 'failed' => $failed
]); ]);
// Queue webhook notification for bulk import
$totalAffected = $inserted + $updated;
if ($totalAffected > 0) {
queueWebhookNotification($db, 'bulk_import', $totalAffected);
}
jsonResponse([ jsonResponse([
'success' => true, 'success' => true,
'inserted' => $inserted, 'inserted' => $inserted,
'updated' => $updated, 'updated' => $updated,
'failed' => $failed 'failed' => $failed
]); ]);
} catch (Exception $e) { } catch (Exception $e) {
$db->rollBack(); $db->rollBack();
jsonResponse(['error' => 'Import failed: ' . $e->getMessage()], 500); jsonResponse(['error' => 'Import failed: ' . $e->getMessage()], 500);
@@ -723,14 +762,20 @@ function handleImportUrl($db) {
'updated' => $updated, 'updated' => $updated,
'failed' => $failed 'failed' => $failed
]); ]);
// Queue webhook notification for URL import
$totalAffected = $inserted + $updated;
if ($totalAffected > 0) {
queueWebhookNotification($db, 'url_import', $totalAffected);
}
jsonResponse([ jsonResponse([
'success' => true, 'success' => true,
'inserted' => $inserted, 'inserted' => $inserted,
'updated' => $updated, 'updated' => $updated,
'failed' => $failed 'failed' => $failed
]); ]);
} catch (Exception $e) { } catch (Exception $e) {
$db->rollBack(); $db->rollBack();
jsonResponse(['error' => 'Import failed: ' . $e->getMessage()], 500); jsonResponse(['error' => 'Import failed: ' . $e->getMessage()], 500);
@@ -744,30 +789,35 @@ function handleClearAll($db) {
if ($_SERVER['REQUEST_METHOD'] !== 'POST') { if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
jsonResponse(['error' => 'Method not allowed'], 405); jsonResponse(['error' => 'Method not allowed'], 405);
} }
$input = json_decode(file_get_contents('php://input'), true); $input = json_decode(file_get_contents('php://input'), true);
// Validate CSRF // Validate CSRF
if (!validateCSRFToken($input['csrf_token'] ?? '')) { if (!validateCSRFToken($input['csrf_token'] ?? '')) {
jsonResponse(['error' => 'Invalid CSRF token'], 403); jsonResponse(['error' => 'Invalid CSRF token'], 403);
} }
try { try {
// Get count before deletion // Get count before deletion
$countStmt = $db->query("SELECT COUNT(*) as count FROM geofeed_entries"); $countStmt = $db->query("SELECT COUNT(*) as count FROM geofeed_entries");
$count = $countStmt->fetch()['count']; $count = $countStmt->fetch()['count'];
// Delete all entries // Delete all entries
$db->exec("DELETE FROM geofeed_entries"); $db->exec("DELETE FROM geofeed_entries");
// Reset auto increment // Reset auto increment
$db->exec("ALTER TABLE geofeed_entries AUTO_INCREMENT = 1"); $db->exec("ALTER TABLE geofeed_entries AUTO_INCREMENT = 1");
// Log the action // Log the action
logAction($db, null, 'DELETE', ['count' => $count], ['type' => 'clear_all']); 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]); jsonResponse(['success' => true, 'deleted' => $count]);
} catch (Exception $e) { } catch (Exception $e) {
jsonResponse(['error' => 'Failed to clear entries: ' . $e->getMessage()], 500); jsonResponse(['error' => 'Failed to clear entries: ' . $e->getMessage()], 500);
} }
@@ -934,3 +984,187 @@ function handleShortnamesList($db) {
jsonResponse(['success' => true, 'data' => $shortnames]); 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)
]
]
]);
}

View File

@@ -107,3 +107,207 @@ function isValidRegionCode($code) {
if (empty($code)) return true; if (empty($code)) return true;
return preg_match('/^[A-Z]{2}-[A-Z0-9]{1,3}$/i', $code); 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);
}

View File

@@ -1566,6 +1566,81 @@ if (!function_exists('generateCSRFToken')) {
</div> </div>
</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 --> <!-- Client Logos Section -->
<div class="advanced-section"> <div class="advanced-section">
<h2 class="advanced-section-title"> <h2 class="advanced-section-title">
@@ -1850,6 +1925,8 @@ if (!function_exists('generateCSRFToken')) {
loadAuditLog(); loadAuditLog();
loadShortnames(); loadShortnames();
loadLogosGrid(); 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 // Time ago helper
function getTimeAgo(date) { function getTimeAgo(date) {
const seconds = Math.floor((new Date() - date) / 1000); const seconds = Math.floor((new Date() - date) / 1000);