diff --git a/.DS_Store b/.DS_Store index 154ebd2..c887ea0 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/database/schema.sql b/database/schema.sql index 3c11b29..bd9c0d2 100644 --- a/database/schema.sql +++ b/database/schema.sql @@ -12,13 +12,15 @@ CREATE TABLE IF NOT EXISTS geofeed_entries ( region_code VARCHAR(10) DEFAULT NULL, city VARCHAR(255) DEFAULT NULL, postal_code VARCHAR(50) DEFAULT NULL, + client_short_name VARCHAR(100) DEFAULT NULL, notes TEXT DEFAULT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY unique_prefix (ip_prefix), INDEX idx_country (country_code), INDEX idx_region (region_code), - INDEX idx_city (city) + INDEX idx_city (city), + INDEX idx_client (client_short_name) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; -- Audit log for tracking changes @@ -42,6 +44,17 @@ CREATE TABLE IF NOT EXISTS geofeed_settings ( updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +-- Client logos table for storing logo URLs per shortname +CREATE TABLE IF NOT EXISTS client_logos ( + id INT AUTO_INCREMENT PRIMARY KEY, + short_name VARCHAR(100) NOT NULL, + logo_url VARCHAR(500) NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY unique_short_name (short_name), + INDEX idx_short_name (short_name) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + -- Insert default settings INSERT INTO geofeed_settings (setting_key, setting_value) VALUES ('bunny_cdn_storage_zone', ''), diff --git a/docker-compose.yml b/docker-compose.yml index a8f1466..42e4ba0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -116,4 +116,4 @@ volumes: networks: geofeed-network: - driver: bridge \ No newline at end of file + driver: bridge diff --git a/webapp/api.php b/webapp/api.php index e224dbe..d4ee329 100644 --- a/webapp/api.php +++ b/webapp/api.php @@ -65,7 +65,27 @@ try { 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; + default: jsonResponse(['error' => 'Invalid action'], 400); } @@ -90,12 +110,18 @@ function handleList($db) { $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)'; + $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); @@ -105,8 +131,13 @@ function handleList($db) { $countStmt->execute($params); $total = $countStmt->fetch()['total']; - // Get entries - $sql = "SELECT * FROM geofeed_entries WHERE $whereClause ORDER BY created_at DESC LIMIT :limit OFFSET :offset"; + // Get entries - sorted by IP prefix using INET_ATON for proper IP sorting + $sql = "SELECT * FROM geofeed_entries WHERE $whereClause + ORDER BY + CASE WHEN ip_prefix LIKE '%:%' THEN 1 ELSE 0 END, + INET_ATON(SUBSTRING_INDEX(ip_prefix, '/', 1)), + ip_prefix + LIMIT :limit OFFSET :offset"; $stmt = $db->prepare($sql); foreach ($params as $key => $value) { @@ -195,8 +226,8 @@ function handleCreate($db) { // Insert entry $stmt = $db->prepare(" - INSERT INTO geofeed_entries (ip_prefix, country_code, region_code, city, postal_code, notes) - VALUES (:ip_prefix, :country_code, :region_code, :city, :postal_code, :notes) + 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([ @@ -205,6 +236,7 @@ function handleCreate($db) { ':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 ]); @@ -279,6 +311,7 @@ function handleUpdate($db) { region_code = :region_code, city = :city, postal_code = :postal_code, + client_short_name = :client_short_name, notes = :notes WHERE id = :id "); @@ -290,6 +323,7 @@ function handleUpdate($db) { ':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 ]); @@ -747,7 +781,7 @@ function logAction($db, $entryId, $action, $oldValues, $newValues) { 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, @@ -756,3 +790,147 @@ function logAction($db, $entryId, $action, $oldValues, $newValues) { ':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]); +} diff --git a/webapp/index.php b/webapp/index.php index 51c56a2..0a8ccc7 100644 --- a/webapp/index.php +++ b/webapp/index.php @@ -22,11 +22,17 @@ if (!function_exists('generateCSRFToken')) {
- + + + + +View all changes made to geofeed entries including creates, updates, and deletes.
+ +Manage logo images for client shortnames. Logos will appear in the entries table as 1:1 square icons.
+ +Import geofeed entries from a CSV file or a remote URL. The data should follow RFC 8805 format: ip_prefix,country_code,region_code,city,postal_code
| IP Prefix | -Country | -Region | -City | -Postal | -Actions | -
|---|
| ${escapeHtml(entry.ip_prefix)} | -- ${entry.country_code ? ` - - ${getFlagEmoji(entry.country_code)} - ${escapeHtml(entry.country_code)} - - ` : '—'} - | -${entry.region_code ? escapeHtml(entry.region_code) : '—'} | -${entry.city ? escapeHtml(entry.city) : '—'} | -${entry.postal_code ? escapeHtml(entry.postal_code) : '—'} | -
-
-
- |
+ IP Prefix | +Country | + + + +Client | +Actions |
|---|
Failed to load audit log
Network error
No audit log entries yet
No logos configured yet. Add a logo above to get started.
'; + } + } catch (error) { + grid.innerHTML = 'Failed to load logos
'; + } + } + + // Save logo + async function saveLogo() { + const shortName = document.getElementById('logoShortName').value.trim(); + const logoUrl = document.getElementById('logoUrl').value.trim(); + + if (!shortName || !logoUrl) { + showToast('Please enter both short name and logo URL', 'error'); + return; + } + + try { + const result = await api('logo_save', {}, 'POST', { short_name: shortName, logo_url: logoUrl }); + + if (result.success) { + showToast('Logo saved successfully', 'success'); + document.getElementById('logoShortName').value = ''; + document.getElementById('logoUrl').value = ''; + loadLogosGrid(); + loadClientLogos(); + loadEntries(currentPage); + } else { + showToast(result.error || 'Failed to save logo', 'error'); + } + } catch (error) { + showToast('Network error', 'error'); + } + } + + // Edit logo + function editLogo(shortName, logoUrl) { + document.getElementById('logoShortName').value = shortName; + document.getElementById('logoUrl').value = logoUrl; + document.getElementById('logoShortName').scrollIntoView({ behavior: 'smooth', block: 'center' }); + } + + // Delete logo + async function deleteLogo(shortName) { + if (!confirm(`Delete logo for "${shortName}"?`)) return; + + try { + const result = await api('logo_delete', {}, 'POST', { short_name: shortName }); + + if (result.success) { + showToast('Logo deleted successfully', 'success'); + loadLogosGrid(); + loadClientLogos(); + loadEntries(currentPage); + } else { + showToast(result.error || 'Failed to delete logo', 'error'); + } + } catch (error) { + showToast('Network error', 'error'); + } + } + + // Time ago helper + function getTimeAgo(date) { + const seconds = Math.floor((new Date() - date) / 1000); + + if (seconds < 60) return 'just now'; + if (seconds < 3600) return Math.floor(seconds / 60) + 'm ago'; + if (seconds < 86400) return Math.floor(seconds / 3600) + 'h ago'; + if (seconds < 604800) return Math.floor(seconds / 86400) + 'd ago'; + + return date.toLocaleDateString(); + } + // Search with debounce function debounceSearch() { clearTimeout(searchTimeout); @@ -1547,6 +2280,7 @@ if (!function_exists('generateCSRFToken')) { document.getElementById('regionCode').value = entry.region_code || ''; document.getElementById('city').value = entry.city || ''; document.getElementById('postalCode').value = entry.postal_code || ''; + document.getElementById('clientShortName').value = entry.client_short_name || ''; document.getElementById('notes').value = entry.notes || ''; } else { title.textContent = 'Add Entry'; @@ -1554,7 +2288,7 @@ if (!function_exists('generateCSRFToken')) { } modal.classList.add('active'); - document.getElementById('ipPrefix').focus(); + setTimeout(() => document.getElementById('ipPrefix').focus(), 100); } function closeModal() { @@ -1590,6 +2324,7 @@ if (!function_exists('generateCSRFToken')) { region_code: document.getElementById('regionCode').value.toUpperCase(), city: document.getElementById('city').value, postal_code: document.getElementById('postalCode').value, + client_short_name: document.getElementById('clientShortName').value, notes: document.getElementById('notes').value }; @@ -1884,6 +2619,11 @@ if (!function_exists('generateCSRFToken')) { } }); }); + + // Prevent zoom on iOS + document.addEventListener('gesturestart', function(e) { + e.preventDefault(); + });