'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); } } /** * Get PHP error logs */ function handleErrorLogs($db) { if ($_SERVER['REQUEST_METHOD'] !== 'GET') { jsonResponse(['error' => 'Method not allowed'], 405); } $lines = max(10, min(1000, intval($_GET['lines'] ?? 100))); try { // Try to find the error log file $logFile = ini_get('error_log'); $logContent = ''; $logPath = ''; $logSize = 0; // Common log file locations to check $possiblePaths = [ $logFile, __DIR__ . '/error.log', __DIR__ . '/php_errors.log', __DIR__ . '/../logs/error.log', __DIR__ . '/../logs/php_errors.log', '/var/log/php_errors.log', '/var/log/apache2/error.log', '/var/log/httpd/error_log', '/var/log/nginx/error.log', sys_get_temp_dir() . '/php_errors.log', ]; foreach ($possiblePaths as $path) { if (!empty($path) && file_exists($path) && is_readable($path)) { $logPath = $path; $logSize = filesize($path); break; } } if (empty($logPath)) { jsonResponse([ 'success' => true, 'log_path' => $logFile ?: 'Not configured', 'log_size' => 0, 'lines' => [], 'message' => 'No readable error log file found. Check your PHP error_log configuration.' ]); return; } // Read the last N lines from the file $logLines = []; // Use tail-like approach for efficiency with large files $fp = fopen($logPath, 'r'); if ($fp) { // For small files, just read the whole thing if ($logSize < 1024 * 1024) { // Less than 1MB $content = fread($fp, $logSize); $allLines = explode("\n", $content); $logLines = array_slice($allLines, -$lines); } else { // For larger files, read from the end $chunkSize = min($logSize, $lines * 500); // Estimate ~500 bytes per line fseek($fp, -$chunkSize, SEEK_END); $content = fread($fp, $chunkSize); $allLines = explode("\n", $content); // Skip first line as it may be partial array_shift($allLines); $logLines = array_slice($allLines, -$lines); } fclose($fp); } // Filter out empty lines and parse entries $parsedLines = []; foreach ($logLines as $line) { $line = trim($line); if (empty($line)) continue; // Try to parse the log entry $entry = [ 'raw' => $line, 'type' => 'info', 'timestamp' => null, 'message' => $line ]; // Detect error type if (stripos($line, 'fatal') !== false) { $entry['type'] = 'fatal'; } elseif (stripos($line, 'error') !== false) { $entry['type'] = 'error'; } elseif (stripos($line, 'warning') !== false) { $entry['type'] = 'warning'; } elseif (stripos($line, 'notice') !== false) { $entry['type'] = 'notice'; } elseif (stripos($line, 'deprecated') !== false) { $entry['type'] = 'deprecated'; } // Try to extract timestamp if (preg_match('/^\[([^\]]+)\]/', $line, $matches)) { $entry['timestamp'] = $matches[1]; $entry['message'] = trim(substr($line, strlen($matches[0]))); } $parsedLines[] = $entry; } jsonResponse([ 'success' => true, 'log_path' => $logPath, 'log_size' => $logSize, 'log_size_formatted' => formatBytes($logSize), 'lines' => $parsedLines, 'total_lines' => count($parsedLines) ]); } catch (Exception $e) { jsonResponse(['error' => 'Failed to read error logs: ' . $e->getMessage()], 500); } } /** * Clear PHP error logs */ function handleErrorLogsClear($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 { $logFile = ini_get('error_log'); $logPath = ''; // Common log file locations to check $possiblePaths = [ $logFile, __DIR__ . '/error.log', __DIR__ . '/php_errors.log', __DIR__ . '/../logs/error.log', __DIR__ . '/../logs/php_errors.log', ]; foreach ($possiblePaths as $path) { if (!empty($path) && file_exists($path) && is_writable($path)) { $logPath = $path; break; } } if (empty($logPath)) { jsonResponse([ 'success' => false, 'error' => 'No writable error log file found' ], 400); return; } // Clear the log file $fp = fopen($logPath, 'w'); if ($fp) { fwrite($fp, "# Log cleared at " . date('c') . "\n"); fclose($fp); // Log the action logAudit($db, null, 'error_log_cleared', null, ['log_path' => $logPath]); jsonResponse([ 'success' => true, 'message' => 'Error log cleared successfully', 'log_path' => $logPath ]); } else { jsonResponse(['error' => 'Failed to clear error log'], 500); } } catch (Exception $e) { jsonResponse(['error' => 'Failed to clear error logs: ' . $e->getMessage()], 500); } } /** * Format bytes to human readable */ function formatBytes($bytes, $precision = 2) { $units = ['B', 'KB', 'MB', 'GB', 'TB']; $bytes = max($bytes, 0); $pow = floor(($bytes ? log($bytes) : 0) / log(1024)); $pow = min($pow, count($units) - 1); $bytes /= pow(1024, $pow); return round($bytes, $precision) . ' ' . $units[$pow]; } /** * Get AWS settings */ function handleAwsSettingsGet($db) { if ($_SERVER['REQUEST_METHOD'] !== 'GET') { jsonResponse(['error' => 'Method not allowed'], 405); } jsonResponse([ 'success' => true, 'data' => [ 'aws_access_key_id' => getSetting($db, 'aws_access_key_id', ''), 'aws_secret_access_key' => getSetting($db, 'aws_secret_access_key', '') ? '••••••••' : '', 'aws_region' => getSetting($db, 'aws_region', 'us-east-1'), 'aws_hosted_zones' => getSetting($db, 'aws_hosted_zones', ''), 'is_configured' => !empty(getSetting($db, 'aws_access_key_id', '')) && !empty(getSetting($db, 'aws_secret_access_key', '')) ] ]); } /** * Save AWS settings */ function handleAwsSettingsSave($db) { if ($_SERVER['REQUEST_METHOD'] !== 'POST') { jsonResponse(['error' => 'Method not allowed'], 405); } $input = json_decode(file_get_contents('php://input'), true); if (!validateCSRFToken($input['csrf_token'] ?? '')) { jsonResponse(['error' => 'Invalid CSRF token'], 403); } saveSetting($db, 'aws_access_key_id', $input['aws_access_key_id'] ?? ''); // Only update secret if it's not masked if (!empty($input['aws_secret_access_key']) && strpos($input['aws_secret_access_key'], '••••') === false) { saveSetting($db, 'aws_secret_access_key', $input['aws_secret_access_key']); } saveSetting($db, 'aws_region', $input['aws_region'] ?? 'us-east-1'); saveSetting($db, 'aws_hosted_zones', $input['aws_hosted_zones'] ?? ''); jsonResponse(['success' => true, 'message' => 'AWS settings saved successfully']); } /** * Test AWS connection */ function handleAwsTest($db) { // Allow both GET and POST for testing connection $accessKeyId = getSetting($db, 'aws_access_key_id', ''); $secretAccessKey = getSetting($db, 'aws_secret_access_key', ''); $region = getSetting($db, 'aws_region', 'us-east-1'); if (empty($accessKeyId) || empty($secretAccessKey)) { jsonResponse(['error' => 'AWS credentials not configured'], 400); } try { $result = awsRoute53Request('GET', '2013-04-01/hostedzone', [], $accessKeyId, $secretAccessKey, $region); if (isset($result['error'])) { jsonResponse(['success' => false, 'error' => $result['error']]); } $zoneCount = 0; if (isset($result['HostedZones']['HostedZone'])) { $zones = $result['HostedZones']['HostedZone']; $zoneCount = isset($zones['Id']) ? 1 : count($zones); } jsonResponse([ 'success' => true, 'zones_count' => $zoneCount, 'message' => "Connection successful! Found {$zoneCount} hosted zone(s)." ]); } catch (Exception $e) { jsonResponse(['success' => false, 'error' => $e->getMessage()]); } } /** * Get AWS hosted zones with details */ function handleAwsZones($db) { if ($_SERVER['REQUEST_METHOD'] !== 'GET') { jsonResponse(['error' => 'Method not allowed'], 405); } $accessKeyId = getSetting($db, 'aws_access_key_id', ''); $secretAccessKey = getSetting($db, 'aws_secret_access_key', ''); $region = getSetting($db, 'aws_region', 'us-east-1'); $configuredZones = array_map('trim', explode(',', getSetting($db, 'aws_hosted_zones', ''))); if (empty($accessKeyId) || empty($secretAccessKey)) { jsonResponse(['error' => 'AWS credentials not configured'], 400); } try { $result = awsRoute53Request('GET', '2013-04-01/hostedzone', [], $accessKeyId, $secretAccessKey, $region); if (isset($result['error'])) { jsonResponse(['success' => false, 'error' => $result['error']]); } $zones = []; if (isset($result['HostedZones']['HostedZone'])) { $hostedZones = $result['HostedZones']['HostedZone']; // Handle single zone vs array of zones if (isset($hostedZones['Id'])) { $hostedZones = [$hostedZones]; } foreach ($hostedZones as $zone) { $zoneId = str_replace('/hostedzone/', '', $zone['Id']); // Only include configured zones if any are specified if (empty($configuredZones[0]) || in_array($zoneId, $configuredZones)) { $zones[] = [ 'id' => $zoneId, 'name' => rtrim($zone['Name'], '.'), 'record_count' => $zone['ResourceRecordSetCount'] ?? 0, 'private' => ($zone['Config']['PrivateZone'] ?? 'false') === 'true' ]; } } } jsonResponse(['success' => true, 'zones' => $zones]); } catch (Exception $e) { jsonResponse(['success' => false, 'error' => $e->getMessage()]); } } /** * Get A records from a hosted zone */ function handleAwsRecords($db) { if ($_SERVER['REQUEST_METHOD'] !== 'GET') { jsonResponse(['error' => 'Method not allowed'], 405); } $zoneId = $_GET['zone_id'] ?? ''; if (empty($zoneId)) { jsonResponse(['error' => 'Zone ID required'], 400); } $accessKeyId = getSetting($db, 'aws_access_key_id', ''); $secretAccessKey = getSetting($db, 'aws_secret_access_key', ''); $region = getSetting($db, 'aws_region', 'us-east-1'); if (empty($accessKeyId) || empty($secretAccessKey)) { jsonResponse(['error' => 'AWS credentials not configured'], 400); } try { $result = awsRoute53Request( 'GET', "2013-04-01/hostedzone/{$zoneId}/rrset?type=A&maxitems=300", [], $accessKeyId, $secretAccessKey, $region ); if (isset($result['error'])) { jsonResponse(['success' => false, 'error' => $result['error']]); } $records = []; if (isset($result['ResourceRecordSets']['ResourceRecordSet'])) { $recordSets = $result['ResourceRecordSets']['ResourceRecordSet']; // Handle single record vs array if (isset($recordSets['Name'])) { $recordSets = [$recordSets]; } foreach ($recordSets as $record) { if (($record['Type'] ?? '') !== 'A') continue; $ips = []; if (isset($record['ResourceRecords']['ResourceRecord'])) { $rrs = $record['ResourceRecords']['ResourceRecord']; if (isset($rrs['Value'])) { $ips[] = $rrs['Value']; } else { foreach ($rrs as $rr) { $ips[] = $rr['Value']; } } } foreach ($ips as $ip) { $records[] = [ 'hostname' => rtrim($record['Name'], '.'), 'ip' => $ip, 'ttl' => $record['TTL'] ?? 300, 'ptr' => null, 'ptr_status' => 'unknown' ]; } } } jsonResponse(['success' => true, 'records' => $records]); } catch (Exception $e) { jsonResponse(['success' => false, 'error' => $e->getMessage()]); } } /** * Lookup PTR record for an IP */ function handlePtrLookup($db) { if ($_SERVER['REQUEST_METHOD'] !== 'GET') { jsonResponse(['error' => 'Method not allowed'], 405); } $ip = $_GET['ip'] ?? ''; $expectedHostname = $_GET['hostname'] ?? ''; if (empty($ip)) { jsonResponse(['error' => 'IP address required'], 400); } if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { jsonResponse(['error' => 'Invalid IPv4 address'], 400); } try { // Perform reverse DNS lookup $ptr = gethostbyaddr($ip); // If gethostbyaddr returns the IP, no PTR was found if ($ptr === $ip) { $ptr = null; } // Determine status $status = 'unknown'; if ($ptr === null) { $status = 'missing'; } elseif (!empty($expectedHostname)) { // Normalize hostnames for comparison (remove trailing dots, lowercase) $normalizedPtr = strtolower(rtrim($ptr, '.')); $normalizedExpected = strtolower(rtrim($expectedHostname, '.')); if ($normalizedPtr === $normalizedExpected) { $status = 'match'; } else { $status = 'mismatch'; } } else { $status = 'found'; } jsonResponse([ 'success' => true, 'ip' => $ip, 'ptr' => $ptr, 'status' => $status, 'expected' => $expectedHostname ]); } catch (Exception $e) { jsonResponse(['success' => false, 'error' => $e->getMessage()]); } } /** * Make AWS Route53 API request with Signature Version 4 * Note: Route53 is a global service, always uses us-east-1 for signing */ function awsRoute53Request($method, $path, $body, $accessKeyId, $secretAccessKey, $region) { $service = 'route53'; $host = 'route53.amazonaws.com'; // Route53 is a global service, always use us-east-1 for signing $signingRegion = 'us-east-1'; $algorithm = 'AWS4-HMAC-SHA256'; $timestamp = gmdate('Ymd\THis\Z'); $datestamp = gmdate('Ymd'); // Parse path and query string $canonicalUri = '/' . ltrim($path, '/'); $canonicalQuerystring = ''; if (strpos($path, '?') !== false) { list($pathPart, $queryString) = explode('?', $path, 2); $canonicalUri = '/' . ltrim($pathPart, '/'); parse_str($queryString, $queryParams); ksort($queryParams); // Build query string with proper encoding $pairs = []; foreach ($queryParams as $k => $v) { $pairs[] = rawurlencode($k) . '=' . rawurlencode($v); } $canonicalQuerystring = implode('&', $pairs); } $endpoint = "https://{$host}{$canonicalUri}" . ($canonicalQuerystring ? "?{$canonicalQuerystring}" : ''); // For GET requests, body is empty $payload = ''; if ($method === 'POST' && !empty($body)) { $payload = is_array($body) ? json_encode($body) : $body; } $payloadHash = hash('sha256', $payload); // Build canonical headers - must be sorted alphabetically and lowercase $canonicalHeaders = "host:{$host}\n" . "x-amz-date:{$timestamp}\n"; $signedHeaders = 'host;x-amz-date'; // Build canonical request $canonicalRequest = implode("\n", [ $method, $canonicalUri, $canonicalQuerystring, $canonicalHeaders, $signedHeaders, $payloadHash ]); // Create string to sign $credentialScope = "{$datestamp}/{$signingRegion}/{$service}/aws4_request"; $stringToSign = implode("\n", [ $algorithm, $timestamp, $credentialScope, hash('sha256', $canonicalRequest) ]); // Calculate signature using HMAC-SHA256 $kSecret = 'AWS4' . $secretAccessKey; $kDate = hash_hmac('sha256', $datestamp, $kSecret, true); $kRegion = hash_hmac('sha256', $signingRegion, $kDate, true); $kService = hash_hmac('sha256', $service, $kRegion, true); $kSigning = hash_hmac('sha256', 'aws4_request', $kService, true); $signature = hash_hmac('sha256', $stringToSign, $kSigning); // Create authorization header $authorizationHeader = "{$algorithm} Credential={$accessKeyId}/{$credentialScope}, SignedHeaders={$signedHeaders}, Signature={$signature}"; // Make request $ch = curl_init(); curl_setopt_array($ch, [ CURLOPT_URL => $endpoint, CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 30, CURLOPT_HTTPHEADER => [ "Host: {$host}", "X-Amz-Date: {$timestamp}", "Authorization: {$authorizationHeader}" ] ]); if ($method === 'POST') { curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_POSTFIELDS, $payload); } $response = curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); $error = curl_error($ch); curl_close($ch); if ($error) { return ['error' => "cURL error: {$error}"]; } if ($httpCode >= 400) { // Parse error from XML response if (preg_match('/(.+?)<\/Message>/s', $response, $matches)) { return ['error' => $matches[1]]; } return ['error' => "HTTP {$httpCode}: {$response}"]; } // Parse XML response $xml = simplexml_load_string($response); if ($xml === false) { return ['error' => 'Failed to parse XML response']; } return json_decode(json_encode($xml), true); } /** * Get cached PTR records for a zone */ function handlePtrCacheGet($db) { $zoneId = $_GET['zone_id'] ?? ''; if (empty($zoneId)) { jsonResponse(['error' => 'Zone ID required'], 400); } try { $stmt = $db->prepare(" SELECT id, zone_id, zone_name, hostname, ip_address, ttl, ptr_record, ptr_status, ptr_checked_at, aws_synced_at FROM ptr_records_cache WHERE zone_id = ? ORDER BY hostname ASC "); $stmt->execute([$zoneId]); $records = $stmt->fetchAll(PDO::FETCH_ASSOC); // Get last sync time $stmt = $db->prepare("SELECT MAX(aws_synced_at) as last_sync FROM ptr_records_cache WHERE zone_id = ?"); $stmt->execute([$zoneId]); $lastSync = $stmt->fetch(PDO::FETCH_ASSOC)['last_sync']; jsonResponse([ 'success' => true, 'records' => $records, 'count' => count($records), 'last_sync' => $lastSync ]); } catch (Exception $e) { jsonResponse(['success' => false, 'error' => $e->getMessage()]); } } /** * Refresh PTR cache from AWS Route53 */ function handlePtrCacheRefresh($db) { $zoneId = $_GET['zone_id'] ?? ''; if (empty($zoneId)) { jsonResponse(['error' => 'Zone ID required'], 400); } $accessKeyId = getSetting($db, 'aws_access_key_id', ''); $secretAccessKey = getSetting($db, 'aws_secret_access_key', ''); $region = getSetting($db, 'aws_region', 'us-east-1'); if (empty($accessKeyId) || empty($secretAccessKey)) { jsonResponse(['error' => 'AWS credentials not configured'], 400); } try { // Clean zone ID (remove /hostedzone/ prefix if present) $cleanZoneId = preg_replace('/^\/hostedzone\//', '', $zoneId); // Get zone name first $zoneResult = awsRoute53Request('GET', "2013-04-01/hostedzone/{$cleanZoneId}", [], $accessKeyId, $secretAccessKey, $region); $zoneName = ''; if (isset($zoneResult['HostedZone']['Name'])) { $zoneName = rtrim($zoneResult['HostedZone']['Name'], '.'); } // Get records from Route53 $result = awsRoute53Request('GET', "2013-04-01/hostedzone/{$cleanZoneId}/rrset?type=A", [], $accessKeyId, $secretAccessKey, $region); if (isset($result['error'])) { jsonResponse(['success' => false, 'error' => $result['error']]); } $records = []; $syncTime = date('Y-m-d H:i:s'); if (isset($result['ResourceRecordSets']['ResourceRecordSet'])) { $rrsets = $result['ResourceRecordSets']['ResourceRecordSet']; // Handle single record case if (isset($rrsets['Name'])) { $rrsets = [$rrsets]; } foreach ($rrsets as $rrset) { if (($rrset['Type'] ?? '') !== 'A') continue; $hostname = rtrim($rrset['Name'] ?? '', '.'); $ttl = $rrset['TTL'] ?? null; // Get IPs from ResourceRecords $ips = []; if (isset($rrset['ResourceRecords']['ResourceRecord'])) { $rrs = $rrset['ResourceRecords']['ResourceRecord']; if (isset($rrs['Value'])) { $ips[] = $rrs['Value']; } else { foreach ($rrs as $rr) { if (isset($rr['Value'])) { $ips[] = $rr['Value']; } } } } foreach ($ips as $ip) { $records[] = [ 'zone_id' => $cleanZoneId, 'zone_name' => $zoneName, 'hostname' => $hostname, 'ip_address' => $ip, 'ttl' => $ttl ]; } } } // Update database - use INSERT ... ON DUPLICATE KEY UPDATE $inserted = 0; $updated = 0; foreach ($records as $record) { $stmt = $db->prepare(" INSERT INTO ptr_records_cache (zone_id, zone_name, hostname, ip_address, ttl, aws_synced_at) VALUES (?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE zone_name = VALUES(zone_name), ip_address = VALUES(ip_address), ttl = VALUES(ttl), aws_synced_at = VALUES(aws_synced_at) "); $stmt->execute([ $record['zone_id'], $record['zone_name'], $record['hostname'], $record['ip_address'], $record['ttl'], $syncTime ]); if ($stmt->rowCount() === 1) { $inserted++; } else { $updated++; } } // Remove stale records that no longer exist in Route53 $stmt = $db->prepare("DELETE FROM ptr_records_cache WHERE zone_id = ? AND aws_synced_at < ?"); $stmt->execute([$cleanZoneId, $syncTime]); $deleted = $stmt->rowCount(); jsonResponse([ 'success' => true, 'message' => "Synced {$inserted} new, {$updated} updated, {$deleted} removed", 'count' => count($records), 'inserted' => $inserted, 'updated' => $updated, 'deleted' => $deleted, 'last_sync' => $syncTime ]); } catch (Exception $e) { jsonResponse(['success' => false, 'error' => $e->getMessage()]); } } /** * Check all PTR records for a zone */ function handlePtrCheckAll($db) { $zoneId = $_GET['zone_id'] ?? ''; if (empty($zoneId)) { jsonResponse(['error' => 'Zone ID required'], 400); } try { $cleanZoneId = preg_replace('/^\/hostedzone\//', '', $zoneId); // Get all records for this zone $stmt = $db->prepare("SELECT id, hostname, ip_address FROM ptr_records_cache WHERE zone_id = ?"); $stmt->execute([$cleanZoneId]); $records = $stmt->fetchAll(PDO::FETCH_ASSOC); $checked = 0; $matches = 0; $mismatches = 0; $missing = 0; $errors = 0; foreach ($records as $record) { $ip = $record['ip_address']; $expectedHostname = $record['hostname']; // Perform PTR lookup $ptr = @gethostbyaddr($ip); $ptrRecord = null; $status = 'unknown'; if ($ptr === false || $ptr === $ip) { $status = 'missing'; $missing++; } else { $ptrRecord = $ptr; // Normalize for comparison $normalizedPtr = strtolower(rtrim($ptr, '.')); $normalizedExpected = strtolower(rtrim($expectedHostname, '.')); if ($normalizedPtr === $normalizedExpected) { $status = 'match'; $matches++; } else { $status = 'mismatch'; $mismatches++; } } // Update the record $stmt = $db->prepare(" UPDATE ptr_records_cache SET ptr_record = ?, ptr_status = ?, ptr_checked_at = NOW() WHERE id = ? "); $stmt->execute([$ptrRecord, $status, $record['id']]); $checked++; } jsonResponse([ 'success' => true, 'checked' => $checked, 'matches' => $matches, 'mismatches' => $mismatches, 'missing' => $missing, 'errors' => $errors ]); } catch (Exception $e) { jsonResponse(['success' => false, 'error' => $e->getMessage()]); } } /** * Get PTR cache stats */ function handlePtrCacheStats($db) { try { $stmt = $db->query(" SELECT COUNT(*) as total, SUM(CASE WHEN ptr_status = 'match' THEN 1 ELSE 0 END) as matches, SUM(CASE WHEN ptr_status = 'mismatch' THEN 1 ELSE 0 END) as mismatches, SUM(CASE WHEN ptr_status = 'missing' THEN 1 ELSE 0 END) as missing, SUM(CASE WHEN ptr_status = 'unknown' THEN 1 ELSE 0 END) as unchecked, MAX(aws_synced_at) as last_aws_sync, MAX(ptr_checked_at) as last_ptr_check FROM ptr_records_cache "); $stats = $stmt->fetch(PDO::FETCH_ASSOC); // Get per-zone breakdown $stmt = $db->query(" SELECT zone_id, zone_name, COUNT(*) as count, MAX(aws_synced_at) as last_sync FROM ptr_records_cache GROUP BY zone_id, zone_name ORDER BY zone_name "); $zones = $stmt->fetchAll(PDO::FETCH_ASSOC); jsonResponse([ 'success' => true, 'stats' => $stats, 'zones' => $zones ]); } catch (Exception $e) { jsonResponse(['success' => false, 'error' => $e->getMessage()]); } }