2879 lines
90 KiB
PHP
2879 lines
90 KiB
PHP
<?php
|
|
/**
|
|
* Geofeed Manager API
|
|
* RESTful API for managing geofeed entries
|
|
*/
|
|
|
|
require_once __DIR__ . '/config.php';
|
|
|
|
header('Content-Type: application/json');
|
|
header('X-Content-Type-Options: nosniff');
|
|
|
|
$method = $_SERVER['REQUEST_METHOD'];
|
|
$action = $_GET['action'] ?? '';
|
|
|
|
// Handle preflight requests
|
|
if ($method === 'OPTIONS') {
|
|
http_response_code(204);
|
|
exit;
|
|
}
|
|
|
|
// Actions that don't require authentication (for cron/webhook processing)
|
|
$publicActions = ['webhook_process', 'export'];
|
|
|
|
// Require authentication for most actions
|
|
if (!in_array($action, $publicActions)) {
|
|
requireAuthApi();
|
|
}
|
|
|
|
try {
|
|
$db = getDB();
|
|
|
|
switch ($action) {
|
|
case 'list':
|
|
handleList($db);
|
|
break;
|
|
|
|
case 'get':
|
|
handleGet($db);
|
|
break;
|
|
|
|
case 'create':
|
|
handleCreate($db);
|
|
break;
|
|
|
|
case 'update':
|
|
handleUpdate($db);
|
|
break;
|
|
|
|
case 'delete':
|
|
handleDelete($db);
|
|
break;
|
|
|
|
case 'export':
|
|
handleExport($db);
|
|
break;
|
|
|
|
case 'stats':
|
|
handleStats($db);
|
|
break;
|
|
|
|
case 'search':
|
|
handleSearch($db);
|
|
break;
|
|
|
|
case 'import':
|
|
handleImport($db);
|
|
break;
|
|
|
|
case 'import_url':
|
|
handleImportUrl($db);
|
|
break;
|
|
|
|
case 'clear_all':
|
|
handleClearAll($db);
|
|
break;
|
|
|
|
case 'audit_log':
|
|
handleAuditLog($db);
|
|
break;
|
|
|
|
case 'logos_list':
|
|
handleLogosList($db);
|
|
break;
|
|
|
|
case 'logo_save':
|
|
handleLogoSave($db);
|
|
break;
|
|
|
|
case 'logo_delete':
|
|
handleLogoDelete($db);
|
|
break;
|
|
|
|
case 'shortnames_list':
|
|
handleShortnamesList($db);
|
|
break;
|
|
|
|
case 'webhook_settings_get':
|
|
handleWebhookSettingsGet($db);
|
|
break;
|
|
|
|
case 'webhook_settings_save':
|
|
handleWebhookSettingsSave($db);
|
|
break;
|
|
|
|
case 'webhook_test':
|
|
handleWebhookTest($db);
|
|
break;
|
|
|
|
case 'webhook_trigger':
|
|
handleWebhookTrigger($db);
|
|
break;
|
|
|
|
case 'webhook_process':
|
|
handleWebhookProcess($db);
|
|
break;
|
|
|
|
case 'webhook_queue_status':
|
|
handleWebhookQueueStatus($db);
|
|
break;
|
|
|
|
case 'update_sort_order':
|
|
handleUpdateSortOrder($db);
|
|
break;
|
|
|
|
case 'ipregistry_settings_get':
|
|
handleIpRegistrySettingsGet($db);
|
|
break;
|
|
|
|
case 'ipregistry_settings_save':
|
|
handleIpRegistrySettingsSave($db);
|
|
break;
|
|
|
|
case 'enrich_ip':
|
|
handleEnrichIp($db);
|
|
break;
|
|
|
|
case 'enrich_all':
|
|
handleEnrichAll($db);
|
|
break;
|
|
|
|
case 'logout':
|
|
handleLogout();
|
|
break;
|
|
|
|
case 'database_backup':
|
|
handleDatabaseBackup($db);
|
|
break;
|
|
|
|
case 'database_import':
|
|
handleDatabaseImport($db);
|
|
break;
|
|
|
|
case 'system_info':
|
|
handleSystemInfo($db);
|
|
break;
|
|
|
|
case 'schema_check':
|
|
handleSchemaCheck($db);
|
|
break;
|
|
|
|
case 'schema_apply':
|
|
handleSchemaApply($db);
|
|
break;
|
|
|
|
case 'error_logs':
|
|
handleErrorLogs($db);
|
|
break;
|
|
|
|
case 'error_logs_clear':
|
|
handleErrorLogsClear($db);
|
|
break;
|
|
|
|
case 'aws_settings_get':
|
|
handleAwsSettingsGet($db);
|
|
break;
|
|
|
|
case 'aws_settings_save':
|
|
handleAwsSettingsSave($db);
|
|
break;
|
|
|
|
case 'aws_test':
|
|
handleAwsTest($db);
|
|
break;
|
|
|
|
case 'aws_zones':
|
|
handleAwsZones($db);
|
|
break;
|
|
|
|
case 'aws_records':
|
|
handleAwsRecords($db);
|
|
break;
|
|
|
|
case 'ptr_lookup':
|
|
handlePtrLookup($db);
|
|
break;
|
|
|
|
case 'ptr_cache_get':
|
|
handlePtrCacheGet($db);
|
|
break;
|
|
|
|
case 'ptr_cache_refresh':
|
|
handlePtrCacheRefresh($db);
|
|
break;
|
|
|
|
case 'ptr_check_all':
|
|
handlePtrCheckAll($db);
|
|
break;
|
|
|
|
case 'ptr_cache_stats':
|
|
handlePtrCacheStats($db);
|
|
break;
|
|
|
|
default:
|
|
jsonResponse(['error' => '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>(.+?)<\/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()]);
|
|
}
|
|
}
|