'Invalid action'], 400); } } catch (Exception $e) { jsonResponse(['error' => $e->getMessage()], 500); } /** * List entries with pagination and filtering */ function handleList($db) { $page = max(1, intval($_GET['page'] ?? 1)); $limit = min(100, max(10, intval($_GET['limit'] ?? ITEMS_PER_PAGE))); $offset = ($page - 1) * $limit; $where = ['1=1']; $params = []; // Filtering if (!empty($_GET['country'])) { $where[] = 'country_code = :country'; $params[':country'] = strtoupper($_GET['country']); } if (!empty($_GET['client'])) { $where[] = 'client_short_name = :client'; $params[':client'] = $_GET['client']; } if (!empty($_GET['search'])) { $where[] = '(ip_prefix LIKE :search OR city LIKE :search2 OR region_code LIKE :search3 OR client_short_name LIKE :search4)'; $searchTerm = '%' . $_GET['search'] . '%'; $params[':search'] = $searchTerm; $params[':search2'] = $searchTerm; $params[':search3'] = $searchTerm; $params[':search4'] = $searchTerm; } $whereClause = implode(' AND ', $where); // Get total count $countStmt = $db->prepare("SELECT COUNT(*) as total FROM geofeed_entries WHERE $whereClause"); $countStmt->execute($params); $total = $countStmt->fetch()['total']; // Determine sort mode $sortMode = $_GET['sort'] ?? 'ip'; if ($sortMode === 'custom') { // Custom sort order $orderBy = "sort_order ASC, id ASC"; } else { // Default: sorted by IP prefix using INET_ATON for proper IP sorting $orderBy = "CASE WHEN ip_prefix LIKE '%:%' THEN 1 ELSE 0 END, INET_ATON(SUBSTRING_INDEX(ip_prefix, '/', 1)), ip_prefix"; } // Get entries $sql = "SELECT * FROM geofeed_entries WHERE $whereClause ORDER BY $orderBy LIMIT :limit OFFSET :offset"; $stmt = $db->prepare($sql); foreach ($params as $key => $value) { $stmt->bindValue($key, $value); } $stmt->bindValue(':limit', $limit, PDO::PARAM_INT); $stmt->bindValue(':offset', $offset, PDO::PARAM_INT); $stmt->execute(); $entries = $stmt->fetchAll(); jsonResponse([ 'success' => true, 'data' => $entries, 'pagination' => [ 'page' => $page, 'limit' => $limit, 'total' => $total, 'pages' => ceil($total / $limit) ] ]); } /** * Get single entry */ function handleGet($db) { $id = intval($_GET['id'] ?? 0); if (!$id) { jsonResponse(['error' => 'Invalid ID'], 400); } $stmt = $db->prepare("SELECT * FROM geofeed_entries WHERE id = :id"); $stmt->execute([':id' => $id]); $entry = $stmt->fetch(); if (!$entry) { jsonResponse(['error' => 'Entry not found'], 404); } jsonResponse(['success' => true, 'data' => $entry]); } /** * Create new entry */ function handleCreate($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); } // Validate required fields if (empty($input['ip_prefix'])) { jsonResponse(['error' => 'IP prefix is required'], 400); } if (!isValidIpPrefix($input['ip_prefix'])) { jsonResponse(['error' => 'Invalid IP prefix format'], 400); } // Validate optional fields $countryCode = strtoupper(trim($input['country_code'] ?? '')); if (!empty($countryCode) && !isValidCountryCode($countryCode)) { jsonResponse(['error' => 'Invalid country code (must be 2 letters)'], 400); } $regionCode = strtoupper(trim($input['region_code'] ?? '')); if (!empty($regionCode) && !isValidRegionCode($regionCode)) { jsonResponse(['error' => 'Invalid region code (format: XX-YYY)'], 400); } // Check for duplicate $checkStmt = $db->prepare("SELECT id FROM geofeed_entries WHERE ip_prefix = :prefix"); $checkStmt->execute([':prefix' => $input['ip_prefix']]); if ($checkStmt->fetch()) { jsonResponse(['error' => 'An entry with this IP prefix already exists'], 409); } // Insert entry $stmt = $db->prepare(" INSERT INTO geofeed_entries (ip_prefix, country_code, region_code, city, postal_code, client_short_name, notes) VALUES (:ip_prefix, :country_code, :region_code, :city, :postal_code, :client_short_name, :notes) "); $stmt->execute([ ':ip_prefix' => trim($input['ip_prefix']), ':country_code' => $countryCode ?: null, ':region_code' => $regionCode ?: null, ':city' => trim($input['city'] ?? '') ?: null, ':postal_code' => trim($input['postal_code'] ?? '') ?: null, ':client_short_name' => trim($input['client_short_name'] ?? '') ?: null, ':notes' => trim($input['notes'] ?? '') ?: null ]); $id = $db->lastInsertId(); // Log the action logAction($db, $id, 'INSERT', null, $input); // Queue webhook notification queueWebhookNotification($db, 'entry_created', 1); // Auto-enrich IP if IP Registry is enabled $ipRegistryEnabled = getSetting($db, 'ipregistry_enabled', '0') === '1'; $hasApiKey = !empty(getSetting($db, 'ipregistry_api_key', '')) || !empty(IPREGISTRY_API_KEY); $enrichResult = null; if ($ipRegistryEnabled && $hasApiKey) { $enrichResult = enrichIpEntry($db, $id, trim($input['ip_prefix'])); } jsonResponse([ 'success' => true, 'id' => $id, 'message' => 'Entry created successfully', 'enriched' => $enrichResult ? $enrichResult['success'] : false ], 201); } /** * Update existing entry */ function handleUpdate($db) { if ($_SERVER['REQUEST_METHOD'] !== 'POST') { jsonResponse(['error' => 'Method not allowed'], 405); } $input = json_decode(file_get_contents('php://input'), true); $id = intval($input['id'] ?? 0); if (!$id) { jsonResponse(['error' => 'Invalid ID'], 400); } // Validate CSRF if (!validateCSRFToken($input['csrf_token'] ?? '')) { jsonResponse(['error' => 'Invalid CSRF token'], 403); } // Get existing entry $checkStmt = $db->prepare("SELECT * FROM geofeed_entries WHERE id = :id"); $checkStmt->execute([':id' => $id]); $oldEntry = $checkStmt->fetch(); if (!$oldEntry) { jsonResponse(['error' => 'Entry not found'], 404); } // Validate fields if (empty($input['ip_prefix'])) { jsonResponse(['error' => 'IP prefix is required'], 400); } if (!isValidIpPrefix($input['ip_prefix'])) { jsonResponse(['error' => 'Invalid IP prefix format'], 400); } $countryCode = strtoupper(trim($input['country_code'] ?? '')); if (!empty($countryCode) && !isValidCountryCode($countryCode)) { jsonResponse(['error' => 'Invalid country code'], 400); } $regionCode = strtoupper(trim($input['region_code'] ?? '')); if (!empty($regionCode) && !isValidRegionCode($regionCode)) { jsonResponse(['error' => 'Invalid region code'], 400); } // Check for duplicate (excluding current entry) $dupStmt = $db->prepare("SELECT id FROM geofeed_entries WHERE ip_prefix = :prefix AND id != :id"); $dupStmt->execute([':prefix' => $input['ip_prefix'], ':id' => $id]); if ($dupStmt->fetch()) { jsonResponse(['error' => 'Another entry with this IP prefix already exists'], 409); } // Update entry $stmt = $db->prepare(" UPDATE geofeed_entries SET ip_prefix = :ip_prefix, country_code = :country_code, region_code = :region_code, city = :city, postal_code = :postal_code, client_short_name = :client_short_name, notes = :notes WHERE id = :id "); $stmt->execute([ ':id' => $id, ':ip_prefix' => trim($input['ip_prefix']), ':country_code' => $countryCode ?: null, ':region_code' => $regionCode ?: null, ':city' => trim($input['city'] ?? '') ?: null, ':postal_code' => trim($input['postal_code'] ?? '') ?: null, ':client_short_name' => trim($input['client_short_name'] ?? '') ?: null, ':notes' => trim($input['notes'] ?? '') ?: null ]); // Log the action logAction($db, $id, 'UPDATE', $oldEntry, $input); // Queue webhook notification queueWebhookNotification($db, 'entry_updated', 1); jsonResponse(['success' => true, 'message' => 'Entry updated successfully']); } /** * Delete entry */ function handleDelete($db) { if ($_SERVER['REQUEST_METHOD'] !== 'POST') { jsonResponse(['error' => 'Method not allowed'], 405); } $input = json_decode(file_get_contents('php://input'), true); $id = intval($input['id'] ?? 0); if (!$id) { jsonResponse(['error' => 'Invalid ID'], 400); } // Validate CSRF if (!validateCSRFToken($input['csrf_token'] ?? '')) { jsonResponse(['error' => 'Invalid CSRF token'], 403); } // Get existing entry for logging $checkStmt = $db->prepare("SELECT * FROM geofeed_entries WHERE id = :id"); $checkStmt->execute([':id' => $id]); $oldEntry = $checkStmt->fetch(); if (!$oldEntry) { jsonResponse(['error' => 'Entry not found'], 404); } // Delete entry $stmt = $db->prepare("DELETE FROM geofeed_entries WHERE id = :id"); $stmt->execute([':id' => $id]); // Log the action logAction($db, $id, 'DELETE', $oldEntry, null); // Queue webhook notification queueWebhookNotification($db, 'entry_deleted', 1); jsonResponse(['success' => true, 'message' => 'Entry deleted successfully']); } /** * Export entries as CSV */ function handleExport($db) { $stmt = $db->query("SELECT ip_prefix, country_code, region_code, city, postal_code FROM geofeed_entries ORDER BY ip_prefix"); $entries = $stmt->fetchAll(); // Build CSV content (RFC 8805 format) $csv = "# Geofeed - Generated by Geofeed Manager\r\n"; $csv .= "# Format: ip_prefix,country_code,region_code,city,postal_code\r\n"; $csv .= "# Generated: " . date('c') . "\r\n"; foreach ($entries as $entry) { $csv .= implode(',', [ $entry['ip_prefix'], $entry['country_code'] ?? '', $entry['region_code'] ?? '', $entry['city'] ?? '', $entry['postal_code'] ?? '' ]) . "\r\n"; } // Return as downloadable or as JSON based on format param if (($_GET['format'] ?? '') === 'download') { header('Content-Type: text/csv; charset=utf-8'); header('Content-Disposition: attachment; filename="geofeed.csv"'); echo $csv; exit; } jsonResponse([ 'success' => true, 'csv' => $csv, 'count' => count($entries) ]); } /** * Get statistics */ function handleStats($db) { $stats = []; // Total entries $stmt = $db->query("SELECT COUNT(*) as count FROM geofeed_entries"); $stats['total_entries'] = $stmt->fetch()['count']; // Entries by country (top 10) $stmt = $db->query(" SELECT country_code, COUNT(*) as count FROM geofeed_entries WHERE country_code IS NOT NULL AND country_code != '' GROUP BY country_code ORDER BY count DESC LIMIT 10 "); $stats['by_country'] = $stmt->fetchAll(); // Recent changes $stmt = $db->query(" SELECT action, COUNT(*) as count, DATE(changed_at) as date FROM geofeed_audit_log WHERE changed_at >= DATE_SUB(NOW(), INTERVAL 7 DAY) GROUP BY action, DATE(changed_at) ORDER BY date DESC "); $stats['recent_changes'] = $stmt->fetchAll(); // IPv4 vs IPv6 $stmt = $db->query(" SELECT SUM(CASE WHEN ip_prefix LIKE '%:%' THEN 1 ELSE 0 END) as ipv6, SUM(CASE WHEN ip_prefix NOT LIKE '%:%' THEN 1 ELSE 0 END) as ipv4 FROM geofeed_entries "); $stats['ip_versions'] = $stmt->fetch(); jsonResponse(['success' => true, 'data' => $stats]); } /** * Search entries */ function handleSearch($db) { $query = trim($_GET['q'] ?? ''); if (strlen($query) < 2) { jsonResponse(['error' => 'Search query too short'], 400); } $searchTerm = '%' . $query . '%'; $stmt = $db->prepare(" SELECT * FROM geofeed_entries WHERE ip_prefix LIKE :q1 OR city LIKE :q2 OR region_code LIKE :q3 OR country_code LIKE :q4 ORDER BY ip_prefix LIMIT 50 "); $stmt->execute([ ':q1' => $searchTerm, ':q2' => $searchTerm, ':q3' => $searchTerm, ':q4' => $searchTerm ]); jsonResponse(['success' => true, 'data' => $stmt->fetchAll()]); } /** * Import entries from parsed CSV data */ function handleImport($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); } $entries = $input['entries'] ?? []; if (empty($entries)) { jsonResponse(['error' => 'No entries provided'], 400); } $inserted = 0; $updated = 0; $failed = 0; $errors = []; $newEntryIds = []; // Check if IP Registry enrichment is enabled $ipRegistryEnabled = getSetting($db, 'ipregistry_enabled', '0') === '1'; $hasApiKey = !empty(getSetting($db, 'ipregistry_api_key', '')) || !empty(IPREGISTRY_API_KEY); $stmt = $db->prepare(" INSERT INTO geofeed_entries (ip_prefix, country_code, region_code, city, postal_code) VALUES (:ip_prefix, :country_code, :region_code, :city, :postal_code) ON DUPLICATE KEY UPDATE country_code = VALUES(country_code), region_code = VALUES(region_code), city = VALUES(city), postal_code = VALUES(postal_code), updated_at = CURRENT_TIMESTAMP "); $db->beginTransaction(); try { foreach ($entries as $entry) { $ipPrefix = trim($entry['ip_prefix'] ?? ''); if (empty($ipPrefix) || !isValidIpPrefix($ipPrefix)) { $failed++; continue; } $countryCode = strtoupper(trim($entry['country_code'] ?? '')); if (!empty($countryCode) && !isValidCountryCode($countryCode)) { $countryCode = null; } $regionCode = strtoupper(trim($entry['region_code'] ?? '')); if (!empty($regionCode) && !isValidRegionCode($regionCode)) { $regionCode = null; } try { $stmt->execute([ ':ip_prefix' => $ipPrefix, ':country_code' => $countryCode ?: null, ':region_code' => $regionCode ?: null, ':city' => trim($entry['city'] ?? '') ?: null, ':postal_code' => trim($entry['postal_code'] ?? '') ?: null ]); if ($stmt->rowCount() === 1) { $inserted++; // Track new entry for enrichment $newEntryIds[] = ['id' => $db->lastInsertId(), 'ip_prefix' => $ipPrefix]; } elseif ($stmt->rowCount() === 2) { $updated++; } } catch (PDOException $e) { $failed++; } } $db->commit(); // Log the import logAction($db, null, 'INSERT', null, [ 'type' => 'bulk_import', 'inserted' => $inserted, 'updated' => $updated, 'failed' => $failed ]); // Queue webhook notification for bulk import $totalAffected = $inserted + $updated; if ($totalAffected > 0) { queueWebhookNotification($db, 'bulk_import', $totalAffected); } // Enrich new entries if IP Registry is enabled (limited to prevent timeout) $enriched = 0; if ($ipRegistryEnabled && $hasApiKey && !empty($newEntryIds)) { $toEnrich = array_slice($newEntryIds, 0, 20); // Limit to 20 per request foreach ($toEnrich as $newEntry) { $result = enrichIpEntry($db, $newEntry['id'], $newEntry['ip_prefix']); if ($result['success']) { $enriched++; } usleep(100000); // 100ms delay between requests } } jsonResponse([ 'success' => true, 'inserted' => $inserted, 'updated' => $updated, 'failed' => $failed, 'enriched' => $enriched, 'pending_enrichment' => max(0, count($newEntryIds) - $enriched) ]); } catch (Exception $e) { $db->rollBack(); jsonResponse(['error' => 'Import failed: ' . $e->getMessage()], 500); } } /** * Import entries from a remote URL */ function handleImportUrl($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); } $url = trim($input['url'] ?? ''); if (empty($url) || !filter_var($url, FILTER_VALIDATE_URL)) { jsonResponse(['error' => 'Invalid URL'], 400); } // Fetch the CSV $ch = curl_init(); curl_setopt_array($ch, [ CURLOPT_URL => $url, CURLOPT_RETURNTRANSFER => true, CURLOPT_FOLLOWLOCATION => true, CURLOPT_TIMEOUT => 30, CURLOPT_SSL_VERIFYPEER => true, CURLOPT_USERAGENT => 'Geofeed-Manager/1.0' ]); $csvData = curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); $error = curl_error($ch); curl_close($ch); if ($error || $httpCode !== 200) { jsonResponse(['error' => 'Failed to fetch URL: ' . ($error ?: "HTTP $httpCode")], 400); } if (empty($csvData)) { jsonResponse(['error' => 'Empty response from URL'], 400); } // Parse CSV $lines = explode("\n", $csvData); $entries = []; foreach ($lines as $line) { $line = trim($line); if (empty($line) || strpos($line, '#') === 0) { continue; } $parts = str_getcsv($line); if (count($parts) < 1 || empty(trim($parts[0]))) { continue; } $entries[] = [ 'ip_prefix' => trim($parts[0] ?? ''), 'country_code' => strtoupper(trim($parts[1] ?? '')), 'region_code' => strtoupper(trim($parts[2] ?? '')), 'city' => trim($parts[3] ?? ''), 'postal_code' => trim($parts[4] ?? '') ]; } if (empty($entries)) { jsonResponse(['error' => 'No valid entries found in CSV'], 400); } // Import entries $inserted = 0; $updated = 0; $failed = 0; $stmt = $db->prepare(" INSERT INTO geofeed_entries (ip_prefix, country_code, region_code, city, postal_code) VALUES (:ip_prefix, :country_code, :region_code, :city, :postal_code) ON DUPLICATE KEY UPDATE country_code = VALUES(country_code), region_code = VALUES(region_code), city = VALUES(city), postal_code = VALUES(postal_code), updated_at = CURRENT_TIMESTAMP "); $db->beginTransaction(); try { foreach ($entries as $entry) { $ipPrefix = $entry['ip_prefix']; if (!isValidIpPrefix($ipPrefix)) { $failed++; continue; } $countryCode = $entry['country_code']; if (!empty($countryCode) && !isValidCountryCode($countryCode)) { $countryCode = null; } $regionCode = $entry['region_code']; if (!empty($regionCode) && !isValidRegionCode($regionCode)) { $regionCode = null; } try { $stmt->execute([ ':ip_prefix' => $ipPrefix, ':country_code' => $countryCode ?: null, ':region_code' => $regionCode ?: null, ':city' => $entry['city'] ?: null, ':postal_code' => $entry['postal_code'] ?: null ]); if ($stmt->rowCount() === 1) { $inserted++; } elseif ($stmt->rowCount() === 2) { $updated++; } } catch (PDOException $e) { $failed++; } } $db->commit(); // Log the import logAction($db, null, 'INSERT', null, [ 'type' => 'url_import', 'url' => $url, 'inserted' => $inserted, '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); } } /** * Clear all entries */ 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); } } /** * Log action to audit table */ function logAction($db, $entryId, $action, $oldValues, $newValues) { $stmt = $db->prepare(" INSERT INTO geofeed_audit_log (entry_id, action, old_values, new_values, changed_by) VALUES (:entry_id, :action, :old_values, :new_values, :changed_by) "); $stmt->execute([ ':entry_id' => $entryId, ':action' => $action, ':old_values' => $oldValues ? json_encode($oldValues) : null, ':new_values' => $newValues ? json_encode($newValues) : null, ':changed_by' => $_SERVER['REMOTE_ADDR'] ?? 'system' ]); } /** * Get audit log entries with pagination */ function handleAuditLog($db) { $page = max(1, intval($_GET['page'] ?? 1)); $limit = min(100, max(10, intval($_GET['limit'] ?? 25))); $offset = ($page - 1) * $limit; // Get total count $countStmt = $db->query("SELECT COUNT(*) as total FROM geofeed_audit_log"); $total = $countStmt->fetch()['total']; // Get audit log entries with entry details $sql = "SELECT a.id, a.entry_id, a.action, a.old_values, a.new_values, a.changed_at, a.changed_by, e.ip_prefix FROM geofeed_audit_log a LEFT JOIN geofeed_entries e ON a.entry_id = e.id ORDER BY a.changed_at DESC LIMIT :limit OFFSET :offset"; $stmt = $db->prepare($sql); $stmt->bindValue(':limit', $limit, PDO::PARAM_INT); $stmt->bindValue(':offset', $offset, PDO::PARAM_INT); $stmt->execute(); $entries = $stmt->fetchAll(); // Parse JSON fields foreach ($entries as &$entry) { $entry['old_values'] = $entry['old_values'] ? json_decode($entry['old_values'], true) : null; $entry['new_values'] = $entry['new_values'] ? json_decode($entry['new_values'], true) : null; } jsonResponse([ 'success' => true, 'data' => $entries, 'pagination' => [ 'page' => $page, 'limit' => $limit, 'total' => $total, 'pages' => ceil($total / $limit) ] ]); } /** * List all client logos */ function handleLogosList($db) { $stmt = $db->query("SELECT * FROM client_logos ORDER BY short_name"); $logos = $stmt->fetchAll(); jsonResponse(['success' => true, 'data' => $logos]); } /** * Save (create or update) a client logo */ function handleLogoSave($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); } $shortName = trim($input['short_name'] ?? ''); $logoUrl = trim($input['logo_url'] ?? ''); if (empty($shortName)) { jsonResponse(['error' => 'Short name is required'], 400); } if (empty($logoUrl) || !filter_var($logoUrl, FILTER_VALIDATE_URL)) { jsonResponse(['error' => 'Valid logo URL is required'], 400); } $stmt = $db->prepare(" INSERT INTO client_logos (short_name, logo_url) VALUES (:short_name, :logo_url) ON DUPLICATE KEY UPDATE logo_url = VALUES(logo_url), updated_at = CURRENT_TIMESTAMP "); $stmt->execute([ ':short_name' => $shortName, ':logo_url' => $logoUrl ]); jsonResponse(['success' => true, 'message' => 'Logo saved successfully']); } /** * Delete a client logo */ function handleLogoDelete($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); } $shortName = trim($input['short_name'] ?? ''); if (empty($shortName)) { jsonResponse(['error' => 'Short name is required'], 400); } $stmt = $db->prepare("DELETE FROM client_logos WHERE short_name = :short_name"); $stmt->execute([':short_name' => $shortName]); jsonResponse(['success' => true, 'message' => 'Logo deleted successfully']); } /** * Get list of unique client short names from entries */ function handleShortnamesList($db) { $stmt = $db->query(" SELECT DISTINCT client_short_name FROM geofeed_entries WHERE client_short_name IS NOT NULL AND client_short_name != '' ORDER BY client_short_name "); $shortnames = $stmt->fetchAll(PDO::FETCH_COLUMN); 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) ] ] ]); } /** * Update sort order for entries */ function handleUpdateSortOrder($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); } $orders = $input['orders'] ?? []; if (empty($orders) || !is_array($orders)) { jsonResponse(['error' => 'Invalid orders data'], 400); } $db->beginTransaction(); try { $stmt = $db->prepare("UPDATE geofeed_entries SET sort_order = :sort_order WHERE id = :id"); foreach ($orders as $order) { $stmt->execute([ ':id' => intval($order['id']), ':sort_order' => intval($order['sort_order']) ]); } $db->commit(); // Queue webhook notification queueWebhookNotification($db, 'sort_order_changed', count($orders)); jsonResponse(['success' => true, 'message' => 'Sort order updated']); } catch (Exception $e) { $db->rollBack(); jsonResponse(['error' => 'Failed to update sort order: ' . $e->getMessage()], 500); } } /** * Get IP Registry settings */ function handleIpRegistrySettingsGet($db) { $settings = [ 'api_key' => getSetting($db, 'ipregistry_api_key', ''), 'enabled' => getSetting($db, 'ipregistry_enabled', '0') === '1', 'has_env_key' => !empty(IPREGISTRY_API_KEY) ]; // Mask the API key for display if (!empty($settings['api_key'])) { $settings['api_key_masked'] = substr($settings['api_key'], 0, 8) . '...' . substr($settings['api_key'], -4); } else { $settings['api_key_masked'] = ''; } jsonResponse(['success' => true, 'data' => $settings]); } /** * Save IP Registry settings */ function handleIpRegistrySettingsSave($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); } $apiKey = trim($input['api_key'] ?? ''); $enabled = !empty($input['enabled']) ? '1' : '0'; saveSetting($db, 'ipregistry_api_key', $apiKey); saveSetting($db, 'ipregistry_enabled', $enabled); jsonResponse(['success' => true, 'message' => 'IP Registry settings saved']); } /** * Enrich a single IP entry */ function handleEnrichIp($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); } $id = intval($input['id'] ?? 0); if (!$id) { jsonResponse(['error' => 'Invalid ID'], 400); } // Get the entry $stmt = $db->prepare("SELECT ip_prefix FROM geofeed_entries WHERE id = :id"); $stmt->execute([':id' => $id]); $entry = $stmt->fetch(); if (!$entry) { jsonResponse(['error' => 'Entry not found'], 404); } $result = enrichIpEntry($db, $id, $entry['ip_prefix']); if ($result['success']) { jsonResponse(['success' => true, 'data' => $result['data'], 'message' => 'IP enriched successfully']); } else { jsonResponse(['success' => false, 'error' => $result['error']], 400); } } /** * Enrich all un-enriched IP entries */ function handleEnrichAll($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); } // Get all un-enriched entries $stmt = $db->query("SELECT id, ip_prefix FROM geofeed_entries WHERE ipr_enriched_at IS NULL LIMIT 50"); $entries = $stmt->fetchAll(); if (empty($entries)) { jsonResponse(['success' => true, 'enriched' => 0, 'message' => 'No entries to enrich']); } $enriched = 0; $failed = 0; $errors = []; foreach ($entries as $entry) { $result = enrichIpEntry($db, $entry['id'], $entry['ip_prefix']); if ($result['success']) { $enriched++; } else { $failed++; $errors[] = ['id' => $entry['id'], 'ip' => $entry['ip_prefix'], 'error' => $result['error']]; } // Small delay to avoid rate limiting usleep(100000); // 100ms } jsonResponse([ 'success' => true, 'enriched' => $enriched, 'failed' => $failed, 'remaining' => max(0, count($entries) - $enriched - $failed), 'errors' => $errors ]); } /** * Logout handler */ function handleLogout() { logoutUser(); jsonResponse(['success' => true, 'redirect' => 'login.php']); } /** * Export full database backup as JSON */ function handleDatabaseBackup($db) { if ($_SERVER['REQUEST_METHOD'] !== 'GET') { jsonResponse(['error' => 'Method not allowed'], 405); } try { // Get all geofeed entries $entries = $db->query("SELECT * FROM geofeed_entries ORDER BY id")->fetchAll(); // Get all settings $settings = $db->query("SELECT * FROM geofeed_settings ORDER BY setting_key")->fetchAll(); // Get audit log (last 1000 entries) $auditLog = $db->query("SELECT * FROM geofeed_audit_log ORDER BY id DESC LIMIT 1000")->fetchAll(); // Get client logos $logos = $db->query("SELECT * FROM client_logos ORDER BY short_name")->fetchAll(); $backup = [ 'backup_info' => [ 'created_at' => date('c'), 'app_version' => APP_VERSION, 'app_name' => APP_NAME, 'entry_count' => count($entries), 'settings_count' => count($settings), 'audit_log_count' => count($auditLog), 'logos_count' => count($logos) ], 'geofeed_entries' => $entries, 'settings' => $settings, 'audit_log' => $auditLog, 'client_logos' => $logos ]; // Set headers for file download header('Content-Type: application/json'); header('Content-Disposition: attachment; filename="geofeed_backup_' . date('Y-m-d_His') . '.json"'); header('Cache-Control: no-cache, must-revalidate'); echo json_encode($backup, JSON_PRETTY_PRINT); exit; } catch (Exception $e) { jsonResponse(['error' => 'Backup failed: ' . $e->getMessage()], 500); } } /** * Import database from JSON backup */ function handleDatabaseImport($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); } if (empty($input['backup_data'])) { jsonResponse(['error' => 'No backup data provided'], 400); } $backup = $input['backup_data']; // Validate backup structure if (!isset($backup['backup_info']) || !isset($backup['geofeed_entries'])) { jsonResponse(['error' => 'Invalid backup file format'], 400); } try { $db->beginTransaction(); $importedEntries = 0; $importedSettings = 0; $importedLogos = 0; // Clear existing entries if backup contains entries if (!empty($backup['geofeed_entries'])) { $db->exec("DELETE FROM geofeed_entries"); // Re-insert entries $stmt = $db->prepare(" INSERT INTO geofeed_entries (ip_prefix, country_code, region_code, city, postal_code, client_short_name, notes, sort_order, ipr_enriched_at, ipr_hostname, ipr_isp, ipr_org, ipr_asn, ipr_asn_name, ipr_connection_type, ipr_country_name, ipr_region_name, ipr_timezone, ipr_latitude, ipr_longitude, flag_abuser, flag_attacker, flag_bogon, flag_cloud_provider, flag_proxy, flag_relay, flag_tor, flag_tor_exit, flag_vpn, flag_anonymous, flag_threat, created_at, updated_at) VALUES (:ip_prefix, :country_code, :region_code, :city, :postal_code, :client_short_name, :notes, :sort_order, :ipr_enriched_at, :ipr_hostname, :ipr_isp, :ipr_org, :ipr_asn, :ipr_asn_name, :ipr_connection_type, :ipr_country_name, :ipr_region_name, :ipr_timezone, :ipr_latitude, :ipr_longitude, :flag_abuser, :flag_attacker, :flag_bogon, :flag_cloud_provider, :flag_proxy, :flag_relay, :flag_tor, :flag_tor_exit, :flag_vpn, :flag_anonymous, :flag_threat, :created_at, :updated_at) "); foreach ($backup['geofeed_entries'] as $entry) { $stmt->execute([ ':ip_prefix' => $entry['ip_prefix'] ?? null, ':country_code' => $entry['country_code'] ?? null, ':region_code' => $entry['region_code'] ?? null, ':city' => $entry['city'] ?? null, ':postal_code' => $entry['postal_code'] ?? null, ':client_short_name' => $entry['client_short_name'] ?? null, ':notes' => $entry['notes'] ?? null, ':sort_order' => $entry['sort_order'] ?? 0, ':ipr_enriched_at' => $entry['ipr_enriched_at'] ?? null, ':ipr_hostname' => $entry['ipr_hostname'] ?? null, ':ipr_isp' => $entry['ipr_isp'] ?? null, ':ipr_org' => $entry['ipr_org'] ?? null, ':ipr_asn' => $entry['ipr_asn'] ?? null, ':ipr_asn_name' => $entry['ipr_asn_name'] ?? null, ':ipr_connection_type' => $entry['ipr_connection_type'] ?? null, ':ipr_country_name' => $entry['ipr_country_name'] ?? null, ':ipr_region_name' => $entry['ipr_region_name'] ?? null, ':ipr_timezone' => $entry['ipr_timezone'] ?? null, ':ipr_latitude' => $entry['ipr_latitude'] ?? null, ':ipr_longitude' => $entry['ipr_longitude'] ?? null, ':flag_abuser' => $entry['flag_abuser'] ?? 0, ':flag_attacker' => $entry['flag_attacker'] ?? 0, ':flag_bogon' => $entry['flag_bogon'] ?? 0, ':flag_cloud_provider' => $entry['flag_cloud_provider'] ?? 0, ':flag_proxy' => $entry['flag_proxy'] ?? 0, ':flag_relay' => $entry['flag_relay'] ?? 0, ':flag_tor' => $entry['flag_tor'] ?? 0, ':flag_tor_exit' => $entry['flag_tor_exit'] ?? 0, ':flag_vpn' => $entry['flag_vpn'] ?? 0, ':flag_anonymous' => $entry['flag_anonymous'] ?? 0, ':flag_threat' => $entry['flag_threat'] ?? 0, ':created_at' => $entry['created_at'] ?? date('Y-m-d H:i:s'), ':updated_at' => $entry['updated_at'] ?? date('Y-m-d H:i:s') ]); $importedEntries++; } } // Import settings if (!empty($backup['settings'])) { foreach ($backup['settings'] as $setting) { saveSetting($db, $setting['setting_key'], $setting['setting_value']); $importedSettings++; } } // Import client logos if (!empty($backup['client_logos'])) { $db->exec("DELETE FROM client_logos"); $stmt = $db->prepare(" INSERT INTO client_logos (short_name, logo_data, mime_type, created_at, updated_at) VALUES (:short_name, :logo_data, :mime_type, :created_at, :updated_at) "); foreach ($backup['client_logos'] as $logo) { $stmt->execute([ ':short_name' => $logo['short_name'], ':logo_data' => $logo['logo_data'], ':mime_type' => $logo['mime_type'] ?? 'image/png', ':created_at' => $logo['created_at'] ?? date('Y-m-d H:i:s'), ':updated_at' => $logo['updated_at'] ?? date('Y-m-d H:i:s') ]); $importedLogos++; } } // Log the import action logAudit($db, null, 'database_import', null, [ 'entries_imported' => $importedEntries, 'settings_imported' => $importedSettings, 'logos_imported' => $importedLogos, 'backup_date' => $backup['backup_info']['created_at'] ?? 'unknown' ]); $db->commit(); jsonResponse([ 'success' => true, 'message' => 'Database restored successfully', 'imported' => [ 'entries' => $importedEntries, 'settings' => $importedSettings, 'logos' => $importedLogos ] ]); } catch (Exception $e) { $db->rollBack(); jsonResponse(['error' => 'Import failed: ' . $e->getMessage()], 500); } } /** * Get system information for developer tab */ function handleSystemInfo($db) { if ($_SERVER['REQUEST_METHOD'] !== 'GET') { jsonResponse(['error' => 'Method not allowed'], 405); } try { // Get database stats $entryCount = $db->query("SELECT COUNT(*) FROM geofeed_entries")->fetchColumn(); $enrichedCount = $db->query("SELECT COUNT(*) FROM geofeed_entries WHERE ipr_enriched_at IS NOT NULL")->fetchColumn(); $settingsCount = $db->query("SELECT COUNT(*) FROM geofeed_settings")->fetchColumn(); $auditCount = $db->query("SELECT COUNT(*) FROM geofeed_audit_log")->fetchColumn(); $logosCount = $db->query("SELECT COUNT(*) FROM client_logos")->fetchColumn(); // Get database size $dbSize = $db->query(" SELECT ROUND(SUM(data_length + index_length) / 1024 / 1024, 2) as size_mb FROM information_schema.tables WHERE table_schema = '" . DB_NAME . "' ")->fetchColumn(); jsonResponse([ 'success' => true, 'data' => [ 'app_version' => APP_VERSION, 'php_version' => PHP_VERSION, 'database' => [ 'name' => DB_NAME, 'host' => DB_HOST, 'size_mb' => $dbSize ?: 0, 'entries' => (int)$entryCount, 'enriched_entries' => (int)$enrichedCount, 'settings' => (int)$settingsCount, 'audit_log_entries' => (int)$auditCount, 'client_logos' => (int)$logosCount ], 'server' => [ 'software' => $_SERVER['SERVER_SOFTWARE'] ?? 'Unknown', 'time' => date('c'), 'timezone' => date_default_timezone_get() ] ] ]); } catch (Exception $e) { jsonResponse(['error' => 'Failed to get system info: ' . $e->getMessage()], 500); } } /** * Check for schema updates from remote repository */ function handleSchemaCheck($db) { if ($_SERVER['REQUEST_METHOD'] !== 'GET') { jsonResponse(['error' => 'Method not allowed'], 405); } $schemaUrl = 'https://git.prpl.tools/PurpleComputing/geofeed-manager/raw/branch/main/database/schema.sql'; try { // Fetch remote schema $ch = curl_init(); curl_setopt_array($ch, [ CURLOPT_URL => $schemaUrl, CURLOPT_RETURNTRANSFER => true, CURLOPT_FOLLOWLOCATION => true, CURLOPT_TIMEOUT => 30, CURLOPT_SSL_VERIFYPEER => true, CURLOPT_USERAGENT => 'Geofeed-Manager/' . APP_VERSION ]); $schemaContent = curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); $error = curl_error($ch); curl_close($ch); if ($error || $httpCode !== 200) { jsonResponse(['error' => 'Failed to fetch schema: ' . ($error ?: "HTTP $httpCode")], 400); } // Parse ALTER TABLE statements for columns $alterStatements = []; preg_match_all('/ALTER TABLE (\w+) ADD COLUMN IF NOT EXISTS (\w+) ([^;]+);/i', $schemaContent, $columnMatches, PREG_SET_ORDER); // Parse CREATE TABLE statements preg_match_all('/CREATE TABLE IF NOT EXISTS (\w+) \(/i', $schemaContent, $tableMatches); // Parse ALTER TABLE for indexes preg_match_all('/ALTER TABLE (\w+) ADD INDEX IF NOT EXISTS (\w+) \(([^)]+)\);/i', $schemaContent, $indexMatches, PREG_SET_ORDER); // Get current database structure $existingTables = []; $result = $db->query("SHOW TABLES"); while ($row = $result->fetch(PDO::FETCH_NUM)) { $existingTables[] = $row[0]; } // Check for missing tables $missingTables = []; foreach ($tableMatches[1] as $tableName) { if (!in_array($tableName, $existingTables)) { $missingTables[] = $tableName; } } // Check for missing columns $missingColumns = []; $tableColumns = []; foreach ($columnMatches as $match) { $tableName = $match[1]; $columnName = $match[2]; $columnDef = trim($match[3]); // Get table columns if not cached if (!isset($tableColumns[$tableName]) && in_array($tableName, $existingTables)) { $tableColumns[$tableName] = []; $cols = $db->query("SHOW COLUMNS FROM `$tableName`"); while ($col = $cols->fetch()) { $tableColumns[$tableName][] = $col['Field']; } } // Check if column exists if (isset($tableColumns[$tableName]) && !in_array($columnName, $tableColumns[$tableName])) { $missingColumns[] = [ 'table' => $tableName, 'column' => $columnName, 'definition' => $columnDef, 'statement' => "ALTER TABLE `$tableName` ADD COLUMN `$columnName` $columnDef" ]; } } // Check for missing indexes $missingIndexes = []; foreach ($indexMatches as $match) { $tableName = $match[1]; $indexName = $match[2]; $indexColumns = $match[3]; if (in_array($tableName, $existingTables)) { // Check if index exists $indexExists = $db->query("SHOW INDEX FROM `$tableName` WHERE Key_name = '$indexName'")->fetch(); if (!$indexExists) { $missingIndexes[] = [ 'table' => $tableName, 'index' => $indexName, 'columns' => $indexColumns, 'statement' => "ALTER TABLE `$tableName` ADD INDEX `$indexName` ($indexColumns)" ]; } } } $hasUpdates = !empty($missingTables) || !empty($missingColumns) || !empty($missingIndexes); jsonResponse([ 'success' => true, 'has_updates' => $hasUpdates, 'missing_tables' => $missingTables, 'missing_columns' => $missingColumns, 'missing_indexes' => $missingIndexes, 'schema_url' => $schemaUrl ]); } catch (Exception $e) { jsonResponse(['error' => 'Schema check failed: ' . $e->getMessage()], 500); } } /** * Apply schema updates from remote repository */ function handleSchemaApply($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); } $schemaUrl = 'https://git.prpl.tools/PurpleComputing/geofeed-manager/raw/branch/main/database/schema.sql'; try { // Fetch remote schema $ch = curl_init(); curl_setopt_array($ch, [ CURLOPT_URL => $schemaUrl, CURLOPT_RETURNTRANSFER => true, CURLOPT_FOLLOWLOCATION => true, CURLOPT_TIMEOUT => 30, CURLOPT_SSL_VERIFYPEER => true, CURLOPT_USERAGENT => 'Geofeed-Manager/' . APP_VERSION ]); $schemaContent = curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); $error = curl_error($ch); curl_close($ch); if ($error || $httpCode !== 200) { jsonResponse(['error' => 'Failed to fetch schema: ' . ($error ?: "HTTP $httpCode")], 400); } // Extract migration section (after the MIGRATION comment) $migrationStart = strpos($schemaContent, '-- MIGRATION:'); if ($migrationStart === false) { $migrationStart = strpos($schemaContent, '-- ============================================'); } $applied = []; $failed = []; // Get existing tables $existingTables = []; $result = $db->query("SHOW TABLES"); while ($row = $result->fetch(PDO::FETCH_NUM)) { $existingTables[] = $row[0]; } // Extract and execute CREATE TABLE IF NOT EXISTS statements preg_match_all('/CREATE TABLE IF NOT EXISTS[^;]+;/is', $schemaContent, $createMatches); foreach ($createMatches[0] as $statement) { // Extract table name if (preg_match('/CREATE TABLE IF NOT EXISTS (\w+)/i', $statement, $tableMatch)) { $tableName = $tableMatch[1]; if (!in_array($tableName, $existingTables)) { try { $db->exec($statement); $applied[] = "Created table: $tableName"; $existingTables[] = $tableName; } catch (Exception $e) { $failed[] = "Failed to create table $tableName: " . $e->getMessage(); } } } } // Execute ALTER TABLE ADD COLUMN IF NOT EXISTS statements preg_match_all('/ALTER TABLE (\w+) ADD COLUMN IF NOT EXISTS (\w+) ([^;]+);/i', $schemaContent, $columnMatches, PREG_SET_ORDER); foreach ($columnMatches as $match) { $tableName = $match[1]; $columnName = $match[2]; $columnDef = trim($match[3]); if (!in_array($tableName, $existingTables)) { continue; } // Check if column exists $colExists = $db->query("SHOW COLUMNS FROM `$tableName` LIKE '$columnName'")->fetch(); if (!$colExists) { try { $db->exec("ALTER TABLE `$tableName` ADD COLUMN `$columnName` $columnDef"); $applied[] = "Added column: $tableName.$columnName"; } catch (Exception $e) { $failed[] = "Failed to add column $tableName.$columnName: " . $e->getMessage(); } } } // Execute ALTER TABLE ADD INDEX IF NOT EXISTS statements preg_match_all('/ALTER TABLE (\w+) ADD INDEX IF NOT EXISTS (\w+) \(([^)]+)\);/i', $schemaContent, $indexMatches, PREG_SET_ORDER); foreach ($indexMatches as $match) { $tableName = $match[1]; $indexName = $match[2]; $indexColumns = $match[3]; if (!in_array($tableName, $existingTables)) { continue; } // Check if index exists $indexExists = $db->query("SHOW INDEX FROM `$tableName` WHERE Key_name = '$indexName'")->fetch(); if (!$indexExists) { try { $db->exec("ALTER TABLE `$tableName` ADD INDEX `$indexName` ($indexColumns)"); $applied[] = "Added index: $tableName.$indexName"; } catch (Exception $e) { // Index might already exist with different name $failed[] = "Failed to add index $tableName.$indexName: " . $e->getMessage(); } } } // Log the schema update logAudit($db, null, 'schema_update', null, [ 'applied' => $applied, 'failed' => $failed ]); jsonResponse([ 'success' => true, 'applied' => $applied, 'failed' => $failed, 'message' => count($applied) > 0 ? 'Schema updates applied successfully' : 'No updates needed' ]); } catch (Exception $e) { jsonResponse(['error' => 'Schema apply failed: ' . $e->getMessage()], 500); } }