From 328b7badc1d248b386b9bc86835773dbf2c2bbf2 Mon Sep 17 00:00:00 2001 From: Purple Date: Sat, 17 Jan 2026 20:43:29 +0000 Subject: [PATCH] update webapp --- database/schema.sql | 21 ++- n8n/geofeed-export-workflow.json | 85 ++++++++-- webapp/api.php | 264 +++++++++++++++++++++++++++++-- webapp/config.php | 204 ++++++++++++++++++++++++ webapp/index.php | 253 +++++++++++++++++++++++++++++ 5 files changed, 794 insertions(+), 33 deletions(-) diff --git a/database/schema.sql b/database/schema.sql index bd9c0d2..8407b1e 100644 --- a/database/schema.sql +++ b/database/schema.sql @@ -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; diff --git a/n8n/geofeed-export-workflow.json b/n8n/geofeed-export-workflow.json index 8f23242..6615943 100644 --- a/n8n/geofeed-export-workflow.json +++ b/n8n/geofeed-export-workflow.json @@ -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" } diff --git a/webapp/api.php b/webapp/api.php index d4ee329..28181e7 100644 --- a/webapp/api.php +++ b/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); } @@ -244,7 +268,10 @@ 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); } @@ -329,7 +356,10 @@ 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']); } @@ -368,7 +398,10 @@ 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']); } @@ -570,14 +603,20 @@ function handleImport($db) { 'updated' => $updated, 'failed' => $failed ]); - + + // Queue webhook notification for bulk import + $totalAffected = $inserted + $updated; + if ($totalAffected > 0) { + queueWebhookNotification($db, 'bulk_import', $totalAffected); + } + jsonResponse([ 'success' => true, 'inserted' => $inserted, 'updated' => $updated, 'failed' => $failed ]); - + } catch (Exception $e) { $db->rollBack(); jsonResponse(['error' => 'Import failed: ' . $e->getMessage()], 500); @@ -723,14 +762,20 @@ function handleImportUrl($db) { 'updated' => $updated, 'failed' => $failed ]); - + + // Queue webhook notification for URL import + $totalAffected = $inserted + $updated; + if ($totalAffected > 0) { + queueWebhookNotification($db, 'url_import', $totalAffected); + } + jsonResponse([ 'success' => true, 'inserted' => $inserted, 'updated' => $updated, 'failed' => $failed ]); - + } catch (Exception $e) { $db->rollBack(); jsonResponse(['error' => 'Import failed: ' . $e->getMessage()], 500); @@ -744,30 +789,35 @@ function handleClearAll($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); } - + try { // Get count before deletion $countStmt = $db->query("SELECT COUNT(*) as count FROM geofeed_entries"); $count = $countStmt->fetch()['count']; - + // Delete all entries $db->exec("DELETE FROM geofeed_entries"); - + // Reset auto increment $db->exec("ALTER TABLE geofeed_entries AUTO_INCREMENT = 1"); - + // 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) { jsonResponse(['error' => 'Failed to clear entries: ' . $e->getMessage()], 500); } @@ -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) + ] + ] + ]); +} diff --git a/webapp/config.php b/webapp/config.php index ecd0fe9..bf59100 100644 --- a/webapp/config.php +++ b/webapp/config.php @@ -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); +} diff --git a/webapp/index.php b/webapp/index.php index 0a8ccc7..c27847d 100644 --- a/webapp/index.php +++ b/webapp/index.php @@ -1566,6 +1566,81 @@ if (!function_exists('generateCSRFToken')) { + +
+

+ + + + + n8n Webhook Integration +

+

Configure webhooks to notify n8n when geofeed data changes. Updates are debounced to batch multiple changes and reduce API calls.

+ +
+ +
+ +
+ + +
The n8n webhook URL to receive notifications
+
+ +
+ + +
Wait this many minutes after the last change before triggering the webhook (1-60 minutes)
+
+ +
+ + + +
+ + +
+
+

Webhook Queue

+
+ +
+
+
+
+
+
+
+
+
+

@@ -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 = '

Failed to load queue status

'; + } + } catch (error) { + container.innerHTML = '

Network error

'; + } + } + + // Render webhook queue status + function renderWebhookQueueStatus(data) { + const container = document.getElementById('webhookQueueContainer'); + + // Stats row + let html = ` +
+
+ ${data.counts.pending} + Pending +
+
+ ${data.counts.completed_24h} + Completed (24h) +
+
+ ${data.counts.failed_24h} + Failed (24h) +
+
+ `; + + // Pending webhooks + if (data.pending.length > 0) { + html += '

Pending Webhooks

'; + html += '
'; + data.pending.forEach(item => { + const scheduledFor = new Date(item.scheduled_for); + const timeUntil = getTimeUntil(scheduledFor); + html += ` +
+ + + + +
+
${escapeHtml(item.trigger_reason || 'Queued update')}
+
${item.entries_affected} entries affected • fires ${timeUntil}
+
+
+ `; + }); + html += '
'; + } + + // Recent webhooks + if (data.recent.length > 0) { + html += '

Recent Webhooks

'; + data.recent.forEach(item => { + const isSuccess = item.status === 'completed'; + const processedAt = item.processed_at ? getTimeAgo(new Date(item.processed_at)) : '-'; + html += ` +
+ ${isSuccess + ? '' + : '' + } +
+
${escapeHtml(item.trigger_reason || 'Update')}
+
${item.entries_affected} entries • HTTP ${item.response_code || '-'} • ${processedAt}
+
+
+ `; + }); + } + + if (data.pending.length === 0 && data.recent.length === 0) { + html += '

No webhook activity yet

'; + } + + 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);