From c49bf2ed31e6738b03b1831cdadb2c2fc84a22c1 Mon Sep 17 00:00:00 2001 From: Purple Date: Sat, 17 Jan 2026 20:33:16 +0000 Subject: [PATCH] update webapp --- .DS_Store | Bin 6148 -> 6148 bytes database/schema.sql | 15 +- docker-compose.yml | 2 +- webapp/api.php | 192 +++++++- webapp/index.php | 1030 +++++++++++++++++++++++++++++++++++++------ 5 files changed, 1085 insertions(+), 154 deletions(-) diff --git a/.DS_Store b/.DS_Store index 154ebd22dbd85c366a108aafa234766f9b23397d..c887ea03fd607530645840c47a889890132c5173 100644 GIT binary patch delta 62 zcmZoMXffFEok^3KA&%+B$b9{@Uu8TJ4G 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')) { - + + + + + Geofeed Manager | Purple Computing + + @@ -1032,10 +1436,10 @@ if (!function_exists('generateCSRFToken')) {
- -
@@ -1141,6 +1545,66 @@ if (!function_exists('generateCSRFToken')) {
+ +
+

+ + + + + Audit Log +

+

View all changes made to geofeed entries including creates, updates, and deletes.

+ +
+
+
+
+
+
+ +
+
+ + +
+

+ + + + + + Client Logos +

+

Manage logo images for client shortnames. Logos will appear in the entries table as 1:1 square icons.

+ +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+ +

Import Geofeed Data

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

@@ -1289,6 +1753,11 @@ if (!function_exists('generateCSRFToken')) {
+
+ + +
Internal reference only - not exported to CSV
+
@@ -1352,12 +1821,15 @@ if (!function_exists('generateCSRFToken')) { let searchTimeout = null; let deleteEntryId = null; let selectedFile = null; + let clientLogos = {}; + let auditPage = 1; const csrfToken = ''; // Initialize document.addEventListener('DOMContentLoaded', () => { loadEntries(); loadStats(); + loadClientLogos(); // Enable clear all button when "DELETE" is typed document.getElementById('confirmClearInput').addEventListener('input', (e) => { @@ -1372,6 +1844,13 @@ if (!function_exists('generateCSRFToken')) { document.querySelector(`.tab[onclick="switchTab('${tab}')"]`).classList.add('active'); document.getElementById(`tab-${tab}`).classList.add('active'); + + // Load data for advanced tab + if (tab === 'advanced') { + loadAuditLog(); + loadShortnames(); + loadLogosGrid(); + } } // API Helper @@ -1390,6 +1869,21 @@ if (!function_exists('generateCSRFToken')) { return response.json(); } + // Load client logos + async function loadClientLogos() { + try { + const result = await api('logos_list'); + if (result.success) { + clientLogos = {}; + result.data.forEach(logo => { + clientLogos[logo.short_name] = logo.logo_url; + }); + } + } catch (error) { + console.error('Failed to load logos:', error); + } + } + // Load entries async function loadEntries(page = 1) { currentPage = page; @@ -1433,52 +1927,66 @@ if (!function_exists('generateCSRFToken')) { } const tableHTML = ` - - - - - - - - - - - - - ${entries.map(entry => ` +
+
IP PrefixCountryRegionCityPostalActions
+ - - - - - - + + + + + + + - `).join('')} - -
${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 PrefixCountryRegionCityPostalClientActions
+ + + ${entries.map(entry => ` + + ${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) : '-'} + + ${entry.client_short_name ? ` +
+ ${clientLogos[entry.client_short_name] + ? `` + : `${escapeHtml(entry.client_short_name.charAt(0).toUpperCase())}` + } + ${escapeHtml(entry.client_short_name)} +
+ ` : '-'} + + +
+ + +
+ + + `).join('')} + + +