Files
ip-manager/webapp/api.php
2026-01-17 22:27:05 +00:00

1695 lines
52 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;
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 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 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);
}
}