This commit is contained in:
Purple
2026-01-18 00:53:17 +00:00
parent d5f3e4ebed
commit 9326d78c3b
3 changed files with 509 additions and 340 deletions

View File

@@ -152,7 +152,8 @@ INSERT INTO geofeed_settings (setting_key, setting_value) VALUES
('aws_secret_access_key', ''),
('aws_region', 'us-east-1'),
('aws_hosted_zones', ''),
('whitelabel_company_name', 'Geofeed Manager'),
('whitelabel_app_name', 'ISP IP Manager'),
('whitelabel_company_name', ''),
('whitelabel_icon_url', ''),
('whitelabel_favicon_url', ''),
('whitelabel_default_import_url', '')

View File

@@ -2974,7 +2974,8 @@ function handleWhitelabelGet($db) {
jsonResponse([
'success' => true,
'settings' => [
'company_name' => getSetting($db, 'whitelabel_company_name', 'Geofeed Manager'),
'app_name' => getSetting($db, 'whitelabel_app_name', 'ISP IP Manager'),
'company_name' => getSetting($db, 'whitelabel_company_name', ''),
'icon_url' => getSetting($db, 'whitelabel_icon_url', ''),
'favicon_url' => getSetting($db, 'whitelabel_favicon_url', ''),
'default_import_url' => getSetting($db, 'whitelabel_default_import_url', '')
@@ -3002,7 +3003,8 @@ function handleWhitelabelSave($db) {
try {
$settings = [
'whitelabel_company_name' => $input['company_name'] ?? 'Geofeed Manager',
'whitelabel_app_name' => $input['app_name'] ?? 'ISP IP Manager',
'whitelabel_company_name' => $input['company_name'] ?? '',
'whitelabel_icon_url' => $input['icon_url'] ?? '',
'whitelabel_favicon_url' => $input['favicon_url'] ?? '',
'whitelabel_default_import_url' => $input['default_import_url'] ?? ''

View File

@@ -32,7 +32,7 @@ if (function_exists('requireAuth')) {
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="theme-color" content="#6B2D7B" media="(prefers-color-scheme: light)">
<meta name="theme-color" content="#1a1a2e" media="(prefers-color-scheme: dark)">
<title>Geofeed Manager | Purple Computing</title>
<title>ISP IP Manager</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
@@ -1599,8 +1599,8 @@ if (function_exists('requireAuth')) {
</svg>
</div>
<div class="logo-text">
<span class="logo-title">Geofeed Manager</span>
<span class="logo-subtitle">PURPLE COMPUTING</span>
<span class="logo-title">ISP IP Manager</span>
<span class="logo-subtitle"></span>
</div>
</div>
<div class="header-actions">
@@ -1620,6 +1620,24 @@ if (function_exists('requireAuth')) {
</svg>
</button>
<div class="settings-menu" id="settingsMenu">
<button class="settings-menu-item" onclick="switchTab('integrations'); closeSettingsMenu();">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 20V10"/>
<path d="M12 20V4"/>
<path d="M6 20v-6"/>
</svg>
Integrations
</button>
<button class="settings-menu-item" onclick="switchTab('audit'); closeSettingsMenu();">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<line x1="16" y1="13" x2="8" y2="13"/>
<line x1="16" y1="17" x2="8" y2="17"/>
<polyline points="10 9 9 9 8 9"/>
</svg>
Audit Log
</button>
<button class="settings-menu-item" onclick="switchTab('advanced'); closeSettingsMenu();">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="3"/>
@@ -1666,6 +1684,20 @@ if (function_exists('requireAuth')) {
</svg>
PTR Records
</button>
<button class="tab" onclick="switchTab('audit')">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 20h9"/>
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/>
</svg>
Audit Log
</button>
<button class="tab" onclick="switchTab('integrations')">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
</svg>
Integrations
</button>
</div>
<!-- Entries Tab -->
@@ -1751,147 +1783,6 @@ if (function_exists('requireAuth')) {
<h1 style="font-size: 20px; font-weight: 600; color: var(--text-primary);">Advanced Settings</h1>
</div>
<!-- Audit Log Section -->
<div class="advanced-section">
<h2 class="advanced-section-title">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="display: inline; vertical-align: middle; margin-right: 8px;">
<path d="M12 20h9"/>
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/>
</svg>
Audit Log
</h2>
<p class="advanced-section-desc">View all changes made to geofeed entries including creates, updates, and deletes.</p>
<div class="table-container" style="margin-top: 16px;">
<div class="audit-log-container" id="auditLogContainer">
<div class="loading">
<div class="spinner"></div>
</div>
</div>
<div class="pagination" id="auditPagination" style="display: none;"></div>
</div>
</div>
<!-- Webhook Settings Section -->
<div class="advanced-section">
<h2 class="advanced-section-title">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="display: inline; vertical-align: middle; margin-right: 8px;">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
</svg>
n8n Webhook Integration
</h2>
<p class="advanced-section-desc">Configure webhooks to notify n8n when geofeed data changes. Updates are debounced to batch multiple changes and reduce API calls.</p>
<div class="form-group">
<label class="form-label">
<input type="checkbox" id="webhookEnabled" style="margin-right: 8px; vertical-align: middle;">
Enable Webhook Notifications
</label>
</div>
<div class="form-group">
<label class="form-label">Webhook URL</label>
<input type="url" class="form-input" id="webhookUrl" placeholder="https://your-n8n-instance.com/webhook/xxx">
<div class="form-hint">The n8n webhook URL to receive notifications</div>
</div>
<div class="form-group">
<label class="form-label">Debounce Delay (minutes)</label>
<input type="number" class="form-input" id="webhookDelay" min="1" max="60" value="3" style="max-width: 120px;">
<div class="form-hint">Wait this many minutes after the last change before triggering the webhook (1-60 minutes)</div>
</div>
<div style="display: flex; gap: 12px; flex-wrap: wrap; margin-bottom: 20px;">
<button class="btn btn-primary" onclick="saveWebhookSettings()">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/>
<polyline points="17 21 17 13 7 13 7 21"/>
<polyline points="7 3 7 8 15 8"/>
</svg>
Save Settings
</button>
<button class="btn btn-secondary" onclick="testWebhook()">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polygon points="5 3 19 12 5 21 5 3"/>
</svg>
Test Connection
</button>
<button class="btn btn-secondary" onclick="triggerWebhookNow()">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/>
</svg>
Trigger Now
</button>
</div>
<!-- Webhook Queue Status -->
<div class="table-container" style="margin-top: 16px;">
<div class="table-header">
<h3 class="table-title">Webhook Queue</h3>
<div class="table-actions">
<button class="btn btn-ghost btn-sm" onclick="loadWebhookQueueStatus()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="23 4 23 10 17 10"/>
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/>
</svg>
Refresh
</button>
</div>
</div>
<div id="webhookQueueContainer" style="padding: 20px;">
<div class="loading">
<div class="spinner"></div>
</div>
</div>
</div>
</div>
<!-- IP Registry Settings Section -->
<div class="advanced-section">
<h2 class="advanced-section-title">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="display: inline; vertical-align: middle; margin-right: 8px;">
<circle cx="12" cy="12" r="10"/>
<line x1="2" y1="12" x2="22" y2="12"/>
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>
</svg>
IP Registry Integration
</h2>
<p class="advanced-section-desc">Enrich IP entries with ISP, organization, and security flag data from <a href="https://ipregistry.co" target="_blank" rel="noopener" style="color: var(--purple-primary);">ipregistry.co</a>. When enabled, new IPs are automatically enriched on creation.</p>
<div class="form-group">
<label class="form-label">
<input type="checkbox" id="ipRegistryEnabled" style="margin-right: 8px; vertical-align: middle;">
Enable IP Registry Auto-Enrichment
</label>
</div>
<div class="form-group">
<label class="form-label">API Key</label>
<input type="password" class="form-input" id="ipRegistryApiKey" placeholder="Enter your ipregistry.co API key">
<div class="form-hint">Get your API key from <a href="https://ipregistry.co" target="_blank" rel="noopener" style="color: var(--purple-primary);">ipregistry.co</a>. Leave blank to use environment variable.</div>
</div>
<div style="display: flex; gap: 12px; flex-wrap: wrap;">
<button class="btn btn-primary" onclick="saveIpRegistrySettings()">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/>
<polyline points="17 21 17 13 7 13 7 21"/>
<polyline points="7 3 7 8 15 8"/>
</svg>
Save Settings
</button>
<button class="btn btn-secondary" onclick="enrichAllIps(this)">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<line x1="2" y1="12" x2="22" y2="12"/>
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>
</svg>
Enrich All Un-enriched IPs
</button>
</div>
</div>
<!-- Client Logos Section -->
<div class="advanced-section">
<h2 class="advanced-section-title">
@@ -1930,162 +1821,6 @@ if (function_exists('requireAuth')) {
</div>
</div>
<!-- Import Section -->
<div class="advanced-section">
<h2 class="advanced-section-title">Import Geofeed Data</h2>
<p class="advanced-section-desc">Import geofeed entries from a CSV file or a remote URL. The data should follow RFC 8805 format: <code>ip_prefix,country_code,region_code,city,postal_code</code></p>
<div class="import-options">
<!-- File Upload -->
<div class="import-card">
<div class="import-card-icon">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="17 8 12 3 7 8"/>
<line x1="12" y1="3" x2="12" y2="15"/>
</svg>
</div>
<h3 class="import-card-title">Upload CSV File</h3>
<p class="import-card-desc">Upload a geofeed CSV file from your computer</p>
<div class="file-input-wrapper">
<input type="file" class="file-input" id="csvFileInput" accept=".csv,.txt" onchange="handleFileSelect(this)">
<label class="file-input-label" for="csvFileInput">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="17 8 12 3 7 8"/>
<line x1="12" y1="3" x2="12" y2="15"/>
</svg>
Choose file or drag here
</label>
</div>
<div class="file-name" id="fileName"></div>
<div class="progress-container" id="fileProgress">
<div class="progress-bar">
<div class="progress-fill" id="fileProgressFill"></div>
</div>
<div class="progress-text" id="fileProgressText">Processing...</div>
</div>
<button class="btn btn-primary" style="width: 100%; margin-top: 16px;" id="uploadBtn" onclick="importFromFile()" disabled>
Import from File
</button>
<div class="import-results" id="fileResults">
<div class="import-results-title">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="20 6 9 17 4 12"/>
</svg>
Import Complete
</div>
<div class="import-results-text" id="fileResultsText"></div>
</div>
</div>
<!-- URL Import -->
<div class="import-card">
<div class="import-card-icon">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<line x1="2" y1="12" x2="22" y2="12"/>
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>
</svg>
</div>
<h3 class="import-card-title">Import from URL</h3>
<p class="import-card-desc">Fetch and import a geofeed from a remote URL</p>
<div class="form-group" style="margin-bottom: 0;">
<input type="url" class="form-input" id="importUrl" placeholder="https://example.com/geofeed.csv" value="https://store.prpl.uk/geofeed.csv">
</div>
<div class="progress-container" id="urlProgress">
<div class="progress-bar">
<div class="progress-fill" id="urlProgressFill"></div>
</div>
<div class="progress-text" id="urlProgressText">Fetching...</div>
</div>
<button class="btn btn-primary" style="width: 100%; margin-top: 16px;" id="urlImportBtn" onclick="importFromUrl()">
Import from URL
</button>
<div class="import-results" id="urlResults">
<div class="import-results-title">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="20 6 9 17 4 12"/>
</svg>
Import Complete
</div>
<div class="import-results-text" id="urlResultsText"></div>
</div>
</div>
</div>
</div>
<!-- AWS Route53 Settings Section -->
<div class="advanced-section">
<h2 class="advanced-section-title">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/>
<polyline points="3.27 6.96 12 12.01 20.73 6.96"/>
<line x1="12" y1="22.08" x2="12" y2="12"/>
</svg>
AWS Route53 Settings
</h2>
<p class="advanced-section-desc">Configure AWS credentials and hosted zones for PTR record management.</p>
<div class="form-grid" style="margin-top: 16px;">
<div class="form-group">
<label class="form-label">AWS Access Key ID</label>
<input type="text" class="form-input" id="awsAccessKeyId" placeholder="AKIAIOSFODNN7EXAMPLE">
</div>
<div class="form-group">
<label class="form-label">AWS Secret Access Key</label>
<input type="password" class="form-input" id="awsSecretAccessKey" placeholder="••••••••••••••••">
</div>
</div>
<div class="form-group">
<label class="form-label">AWS Region</label>
<select class="form-select" id="awsRegion">
<option value="us-east-1">US East (N. Virginia)</option>
<option value="us-east-2">US East (Ohio)</option>
<option value="us-west-1">US West (N. California)</option>
<option value="us-west-2">US West (Oregon)</option>
<option value="eu-west-1">EU (Ireland)</option>
<option value="eu-west-2">EU (London)</option>
<option value="eu-central-1">EU (Frankfurt)</option>
<option value="ap-southeast-1">Asia Pacific (Singapore)</option>
<option value="ap-southeast-2">Asia Pacific (Sydney)</option>
<option value="ap-northeast-1">Asia Pacific (Tokyo)</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Route53 Hosted Zone IDs (comma separated)</label>
<input type="text" class="form-input" id="awsHostedZones" placeholder="Z1234567890ABC, Z0987654321DEF">
<small style="color: var(--text-tertiary); font-size: 12px; margin-top: 4px; display: block;">Enter the hosted zone IDs for your forward DNS zones (A records)</small>
</div>
<div style="display: flex; gap: 12px; flex-wrap: wrap; margin-top: 16px;">
<button class="btn btn-primary" onclick="saveAwsSettings()">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/>
<polyline points="17 21 17 13 7 13 7 21"/>
<polyline points="7 3 7 8 15 8"/>
</svg>
Save AWS Settings
</button>
<button class="btn btn-secondary" onclick="testAwsConnection()">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/>
</svg>
Test Connection
</button>
</div>
<div id="awsTestResult" style="margin-top: 16px; display: none;"></div>
</div>
<div class="advanced-section">
<h2 class="advanced-section-title">Danger Zone</h2>
<p class="advanced-section-desc">Irreversible actions - please proceed with caution.</p>
@@ -2233,6 +1968,238 @@ if (function_exists('requireAuth')) {
</div>
</div>
<!-- Audit Log Tab -->
<div class="tab-content" id="tab-audit">
<div style="display: flex; align-items: center; gap: 12px; margin-bottom: 24px;">
<button class="btn btn-secondary btn-sm" onclick="switchTab('entries')">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="15 18 9 12 15 6"/>
</svg>
Back to Entries
</button>
<h1 style="font-size: 20px; font-weight: 600; color: var(--text-primary);">Audit Log</h1>
</div>
<div class="advanced-section">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;">
<p class="advanced-section-desc" style="margin: 0;">View all changes made to geofeed entries including creates, updates, and deletes.</p>
<button class="btn btn-secondary btn-sm" onclick="exportAuditLog()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="7 10 12 15 17 10"/>
<line x1="12" y1="15" x2="12" y2="3"/>
</svg>
Export CSV
</button>
</div>
<div class="table-container">
<div class="audit-log-container" id="auditLogContainer">
<div class="loading">
<div class="spinner"></div>
</div>
</div>
<div class="pagination" id="auditPagination" style="display: none;"></div>
</div>
</div>
</div>
<!-- Integrations Tab -->
<div class="tab-content" id="tab-integrations">
<div style="display: flex; align-items: center; gap: 12px; margin-bottom: 24px;">
<button class="btn btn-secondary btn-sm" onclick="switchTab('entries')">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="15 18 9 12 15 6"/>
</svg>
Back to Entries
</button>
<h1 style="font-size: 20px; font-weight: 600; color: var(--text-primary);">Integrations</h1>
</div>
<!-- AWS Route53 Settings Section -->
<div class="advanced-section">
<h2 class="advanced-section-title">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/>
<polyline points="3.27 6.96 12 12.01 20.73 6.96"/>
<line x1="12" y1="22.08" x2="12" y2="12"/>
</svg>
AWS Route53 Settings
</h2>
<p class="advanced-section-desc">Configure AWS credentials and hosted zones for PTR record management.</p>
<div class="form-grid" style="margin-top: 16px;">
<div class="form-group">
<label class="form-label">AWS Access Key ID</label>
<input type="text" class="form-input" id="awsAccessKeyId" placeholder="AKIAIOSFODNN7EXAMPLE">
</div>
<div class="form-group">
<label class="form-label">AWS Secret Access Key</label>
<input type="password" class="form-input" id="awsSecretAccessKey" placeholder="••••••••••••••••">
</div>
</div>
<div class="form-group">
<label class="form-label">AWS Region</label>
<select class="form-select" id="awsRegion">
<option value="us-east-1">US East (N. Virginia)</option>
<option value="us-east-2">US East (Ohio)</option>
<option value="us-west-1">US West (N. California)</option>
<option value="us-west-2">US West (Oregon)</option>
<option value="eu-west-1">EU (Ireland)</option>
<option value="eu-west-2">EU (London)</option>
<option value="eu-central-1">EU (Frankfurt)</option>
<option value="ap-southeast-1">Asia Pacific (Singapore)</option>
<option value="ap-southeast-2">Asia Pacific (Sydney)</option>
<option value="ap-northeast-1">Asia Pacific (Tokyo)</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Route53 Hosted Zone IDs (comma separated)</label>
<input type="text" class="form-input" id="awsHostedZones" placeholder="Z1234567890ABC, Z0987654321DEF">
<small style="color: var(--text-tertiary); font-size: 12px; margin-top: 4px; display: block;">Enter the hosted zone IDs for your forward DNS zones (A records)</small>
</div>
<div style="display: flex; gap: 12px; flex-wrap: wrap; margin-top: 16px;">
<button class="btn btn-primary" onclick="saveAwsSettings()">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/>
<polyline points="17 21 17 13 7 13 7 21"/>
<polyline points="7 3 7 8 15 8"/>
</svg>
Save AWS Settings
</button>
<button class="btn btn-secondary" onclick="testAwsConnection()">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/>
</svg>
Test Connection
</button>
</div>
<div id="awsTestResult" style="margin-top: 16px; display: none;"></div>
</div>
<!-- IP Registry Settings Section -->
<div class="advanced-section">
<h2 class="advanced-section-title">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="display: inline; vertical-align: middle; margin-right: 8px;">
<circle cx="12" cy="12" r="10"/>
<line x1="2" y1="12" x2="22" y2="12"/>
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>
</svg>
IP Registry Integration
</h2>
<p class="advanced-section-desc">Enrich IP entries with ISP, organization, and security flag data from <a href="https://ipregistry.co" target="_blank" rel="noopener" style="color: var(--purple-primary);">ipregistry.co</a>. When enabled, new IPs are automatically enriched on creation.</p>
<div class="form-group">
<label class="form-label">
<input type="checkbox" id="ipRegistryEnabled" style="margin-right: 8px; vertical-align: middle;">
Enable IP Registry Auto-Enrichment
</label>
</div>
<div class="form-group">
<label class="form-label">API Key</label>
<input type="password" class="form-input" id="ipRegistryApiKey" placeholder="Enter your ipregistry.co API key">
<div class="form-hint">Get your API key from <a href="https://ipregistry.co" target="_blank" rel="noopener" style="color: var(--purple-primary);">ipregistry.co</a>. Leave blank to use environment variable.</div>
</div>
<div style="display: flex; gap: 12px; flex-wrap: wrap;">
<button class="btn btn-primary" onclick="saveIpRegistrySettings()">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/>
<polyline points="17 21 17 13 7 13 7 21"/>
<polyline points="7 3 7 8 15 8"/>
</svg>
Save Settings
</button>
<button class="btn btn-secondary" onclick="enrichAllIps(this)">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<line x1="2" y1="12" x2="22" y2="12"/>
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>
</svg>
Enrich All Un-enriched IPs
</button>
</div>
</div>
<!-- Webhook Settings Section -->
<div class="advanced-section">
<h2 class="advanced-section-title">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="display: inline; vertical-align: middle; margin-right: 8px;">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
</svg>
n8n Webhook Integration
</h2>
<p class="advanced-section-desc">Configure webhooks to notify n8n when geofeed data changes. Updates are debounced to batch multiple changes and reduce API calls.</p>
<div class="form-group">
<label class="form-label">
<input type="checkbox" id="webhookEnabled" style="margin-right: 8px; vertical-align: middle;">
Enable Webhook Notifications
</label>
</div>
<div class="form-group">
<label class="form-label">Webhook URL</label>
<input type="url" class="form-input" id="webhookUrl" placeholder="https://your-n8n-instance.com/webhook/xxx">
<div class="form-hint">The n8n webhook URL to receive notifications</div>
</div>
<div class="form-group">
<label class="form-label">Debounce Delay (minutes)</label>
<input type="number" class="form-input" id="webhookDelay" min="1" max="60" value="3" style="max-width: 120px;">
<div class="form-hint">Wait this many minutes after the last change before triggering the webhook (1-60 minutes)</div>
</div>
<div style="display: flex; gap: 12px; flex-wrap: wrap; margin-bottom: 20px;">
<button class="btn btn-primary" onclick="saveWebhookSettings()">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/>
<polyline points="17 21 17 13 7 13 7 21"/>
<polyline points="7 3 7 8 15 8"/>
</svg>
Save Settings
</button>
<button class="btn btn-secondary" onclick="testWebhook()">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polygon points="5 3 19 12 5 21 5 3"/>
</svg>
Test Connection
</button>
<button class="btn btn-secondary" onclick="triggerWebhookNow()">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/>
</svg>
Trigger Now
</button>
</div>
<!-- Webhook Queue Status -->
<div class="table-container" style="margin-top: 16px;">
<div class="table-header">
<h3 class="table-title">Webhook Queue</h3>
<div class="table-actions">
<button class="btn btn-ghost btn-sm" onclick="loadWebhookQueueStatus()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="23 4 23 10 17 10"/>
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/>
</svg>
Refresh
</button>
</div>
</div>
<div id="webhookQueueContainer" style="padding: 20px;">
<div class="loading">
<div class="spinner"></div>
</div>
</div>
</div>
</div>
</div>
<!-- Developer Tab -->
<div class="tab-content" id="tab-developer">
<!-- Developer Header -->
@@ -2246,6 +2213,99 @@ if (function_exists('requireAuth')) {
<h1 style="font-size: 20px; font-weight: 600; color: var(--text-primary);">Developer Tools</h1>
</div>
<!-- Import Section -->
<div class="advanced-section">
<h2 class="advanced-section-title">Import Geofeed Data</h2>
<p class="advanced-section-desc">Import geofeed entries from a CSV file or a remote URL. The data should follow RFC 8805 format: <code>ip_prefix,country_code,region_code,city,postal_code</code></p>
<div class="import-options">
<!-- File Upload -->
<div class="import-card">
<div class="import-card-icon">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="17 8 12 3 7 8"/>
<line x1="12" y1="3" x2="12" y2="15"/>
</svg>
</div>
<h3 class="import-card-title">Upload CSV File</h3>
<p class="import-card-desc">Upload a geofeed CSV file from your computer</p>
<div class="file-input-wrapper">
<input type="file" class="file-input" id="csvFileInput" accept=".csv,.txt" onchange="handleFileSelect(this)">
<label class="file-input-label" for="csvFileInput">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="17 8 12 3 7 8"/>
<line x1="12" y1="3" x2="12" y2="15"/>
</svg>
Choose file or drag here
</label>
</div>
<div class="file-name" id="fileName"></div>
<div class="progress-container" id="fileProgress">
<div class="progress-bar">
<div class="progress-fill" id="fileProgressFill"></div>
</div>
<div class="progress-text" id="fileProgressText">Processing...</div>
</div>
<button class="btn btn-primary" style="width: 100%; margin-top: 16px;" id="uploadBtn" onclick="importFromFile()" disabled>
Import from File
</button>
<div class="import-results" id="fileResults">
<div class="import-results-title">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="20 6 9 17 4 12"/>
</svg>
Import Complete
</div>
<div class="import-results-text" id="fileResultsText"></div>
</div>
</div>
<!-- URL Import -->
<div class="import-card">
<div class="import-card-icon">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<line x1="2" y1="12" x2="22" y2="12"/>
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>
</svg>
</div>
<h3 class="import-card-title">Import from URL</h3>
<p class="import-card-desc">Fetch and import a geofeed from a remote URL</p>
<div class="form-group" style="margin-bottom: 0;">
<input type="url" class="form-input" id="importUrl" placeholder="https://example.com/geofeed.csv">
</div>
<div class="progress-container" id="urlProgress">
<div class="progress-bar">
<div class="progress-fill" id="urlProgressFill"></div>
</div>
<div class="progress-text" id="urlProgressText">Fetching...</div>
</div>
<button class="btn btn-primary" style="width: 100%; margin-top: 16px;" id="urlImportBtn" onclick="importFromUrl()">
Import from URL
</button>
<div class="import-results" id="urlResults">
<div class="import-results-title">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="20 6 9 17 4 12"/>
</svg>
Import Complete
</div>
<div class="import-results-text" id="urlResultsText"></div>
</div>
</div>
</div>
</div>
<!-- Database Backup Section -->
<div class="advanced-section">
<h2 class="advanced-section-title">
@@ -2427,10 +2487,15 @@ if (function_exists('requireAuth')) {
<div class="form-grid" style="margin-top: 16px;">
<div class="form-group">
<label class="form-label">Company Name</label>
<input type="text" class="form-input" id="whitelabelCompanyName" placeholder="Geofeed Manager">
<label class="form-label">App Name</label>
<input type="text" class="form-input" id="whitelabelAppName" placeholder="ISP IP Manager">
<div class="form-hint">Displayed in the header and browser tab title</div>
</div>
<div class="form-group">
<label class="form-label">Company Name</label>
<input type="text" class="form-input" id="whitelabelCompanyName" placeholder="Your Company">
<div class="form-hint">Displayed as subtitle in the header</div>
</div>
<div class="form-group">
<label class="form-label">Webapp Icon URL</label>
<input type="text" class="form-input" id="whitelabelIconUrl" placeholder="https://example.com/logo.svg">
@@ -2444,7 +2509,7 @@ if (function_exists('requireAuth')) {
<div class="form-group">
<label class="form-label">Default Geofeed Import URL</label>
<input type="text" class="form-input" id="whitelabelDefaultImportUrl" placeholder="https://example.com/geofeed.csv">
<div class="form-hint">Pre-populated URL when importing from URL in the Advanced tab</div>
<div class="form-hint">Pre-populated URL when importing from URL in Developer tab</div>
</div>
</div>
@@ -2470,8 +2535,8 @@ if (function_exists('requireAuth')) {
</svg>
</div>
<div>
<span id="whitelabelPreviewName" style="font-weight: 600; color: var(--text-primary);">Geofeed Manager</span>
<div style="font-size: 11px; color: var(--text-tertiary); text-transform: uppercase; letter-spacing: 0.5px;">PURPLE COMPUTING</div>
<span id="whitelabelPreviewAppName" style="font-weight: 600; color: var(--text-primary);">ISP IP Manager</span>
<div id="whitelabelPreviewCompanyName" style="font-size: 11px; color: var(--text-tertiary); text-transform: uppercase; letter-spacing: 0.5px;"></div>
</div>
</div>
</div>
@@ -2480,7 +2545,7 @@ if (function_exists('requireAuth')) {
</main>
<footer class="footer">
<p>Geofeed Manager &copy; 2025 <a href="https://purplecomputing.com" target="_blank">Purple Computing</a> &middot; RFC 8805 Compliant</p>
<p>ISP IP Manager &copy; 2025 <a href="https://purplecomputing.com" target="_blank">Purple Computing</a> &middot; RFC 8805 Compliant</p>
</footer>
<!-- Add/Edit Modal -->
@@ -3756,6 +3821,85 @@ if (function_exists('requireAuth')) {
window.location.href = 'api.php?action=export&format=download';
}
// Export Audit Log as CSV
async function exportAuditLog() {
showToast('Preparing audit log export...', 'info');
try {
// Fetch all audit log entries (high limit to get all)
const result = await api('audit_log', { page: 1, limit: 10000 });
if (!result.success || !result.data || result.data.length === 0) {
showToast('No audit log entries to export', 'error');
return;
}
// Build CSV content
const headers = ['Date/Time', 'Action', 'IP Prefix', 'Details', 'Changed By'];
const rows = result.data.map(entry => {
const date = new Date(entry.changed_at).toISOString();
const action = entry.action;
let ipPrefix = '';
let details = '';
if (entry.new_values?.type === 'bulk_import') {
details = `Bulk import: ${entry.new_values.inserted} inserted, ${entry.new_values.updated} updated`;
} else if (entry.new_values?.type === 'url_import') {
details = `URL import from ${entry.new_values.url}: ${entry.new_values.inserted} inserted, ${entry.new_values.updated} updated`;
} else if (entry.new_values?.type === 'clear_all') {
details = `Cleared ${entry.old_values?.count || 0} entries`;
} else {
ipPrefix = entry.ip_prefix || entry.old_values?.ip_prefix || entry.new_values?.ip_prefix || '';
// Build details from old/new values
if (entry.action === 'UPDATE' && entry.old_values && entry.new_values) {
const changes = [];
for (const key of Object.keys(entry.new_values)) {
if (entry.old_values[key] !== entry.new_values[key]) {
changes.push(`${key}: ${entry.old_values[key] || '(empty)'} → ${entry.new_values[key] || '(empty)'}`);
}
}
details = changes.join('; ');
} else if (entry.action === 'INSERT' && entry.new_values) {
details = Object.entries(entry.new_values)
.filter(([k, v]) => v && k !== 'ip_prefix')
.map(([k, v]) => `${k}: ${v}`)
.join('; ');
} else if (entry.action === 'DELETE' && entry.old_values) {
details = Object.entries(entry.old_values)
.filter(([k, v]) => v && k !== 'ip_prefix')
.map(([k, v]) => `${k}: ${v}`)
.join('; ');
}
}
const changedBy = entry.changed_by || 'Unknown';
return [date, action, ipPrefix, details, changedBy];
});
// Create CSV string
const csvContent = [
headers.join(','),
...rows.map(row => row.map(cell => `"${String(cell).replace(/"/g, '""')}"`).join(','))
].join('\n');
// Download file
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = `audit-log-${new Date().toISOString().split('T')[0]}.csv`;
link.click();
URL.revokeObjectURL(link.href);
showToast(`Exported ${result.data.length} audit log entries`, 'success');
} catch (error) {
console.error('Audit log export error:', error);
showToast('Failed to export audit log', 'error');
}
}
// File handling
function handleFileSelect(input) {
if (input.files && input.files[0]) {
@@ -4600,11 +4744,13 @@ if (function_exists('requireAuth')) {
try {
const result = await api('whitelabel_get');
if (result.success && result.settings) {
document.getElementById('whitelabelAppName').value = result.settings.app_name || '';
document.getElementById('whitelabelCompanyName').value = result.settings.company_name || '';
document.getElementById('whitelabelIconUrl').value = result.settings.icon_url || '';
document.getElementById('whitelabelFaviconUrl').value = result.settings.favicon_url || '';
document.getElementById('whitelabelDefaultImportUrl').value = result.settings.default_import_url || '';
updateWhitelabelPreview();
applyWhitelabelSettings(result.settings);
}
} catch (error) {
console.error('Failed to load whitelabel settings:', error);
@@ -4614,6 +4760,7 @@ if (function_exists('requireAuth')) {
// Save whitelabel settings
async function saveWhitelabelSettings() {
const data = {
app_name: document.getElementById('whitelabelAppName').value,
company_name: document.getElementById('whitelabelCompanyName').value,
icon_url: document.getElementById('whitelabelIconUrl').value,
favicon_url: document.getElementById('whitelabelFaviconUrl').value,
@@ -4635,10 +4782,12 @@ if (function_exists('requireAuth')) {
// Update the preview in the whitelabel tab
function updateWhitelabelPreview() {
const companyName = document.getElementById('whitelabelCompanyName').value || 'Geofeed Manager';
const appName = document.getElementById('whitelabelAppName').value || 'ISP IP Manager';
const companyName = document.getElementById('whitelabelCompanyName').value || '';
const iconUrl = document.getElementById('whitelabelIconUrl').value;
document.getElementById('whitelabelPreviewName').textContent = companyName;
document.getElementById('whitelabelPreviewAppName').textContent = appName;
document.getElementById('whitelabelPreviewCompanyName').textContent = companyName;
const previewIcon = document.getElementById('whitelabelPreviewIcon');
if (iconUrl) {
@@ -4650,10 +4799,16 @@ if (function_exists('requireAuth')) {
// Apply whitelabel settings to the page
function applyWhitelabelSettings(settings) {
// Update page title
if (settings.company_name) {
document.title = settings.company_name;
document.querySelector('.logo-title').textContent = settings.company_name;
// Update page title and header
if (settings.app_name) {
document.title = settings.app_name;
document.querySelector('.logo-title').textContent = settings.app_name;
}
// Update company name subtitle
const logoSubtitle = document.querySelector('.logo-subtitle');
if (logoSubtitle) {
logoSubtitle.textContent = settings.company_name || '';
}
// Update favicon
@@ -4676,8 +4831,10 @@ if (function_exists('requireAuth')) {
// Add event listeners for preview updates
document.addEventListener('DOMContentLoaded', function() {
const appNameInput = document.getElementById('whitelabelAppName');
const companyNameInput = document.getElementById('whitelabelCompanyName');
const iconUrlInput = document.getElementById('whitelabelIconUrl');
if (appNameInput) appNameInput.addEventListener('input', updateWhitelabelPreview);
if (companyNameInput) companyNameInput.addEventListener('input', updateWhitelabelPreview);
if (iconUrlInput) iconUrlInput.addEventListener('input', updateWhitelabelPreview);
});
@@ -5100,17 +5257,13 @@ if (function_exists('requireAuth')) {
return;
}
// Group records by subnet (calculate /24 for IPv4)
// Group records by /24 subnet
const subnets = {};
mismatchedOrMissing.forEach(record => {
const ip = record.ip_address;
// Calculate /24 subnet (for IPv4)
const parts = ip.split('.');
if (parts.length === 4) {
// Try /23 first by checking if this is an odd or even third octet
const thirdOctet = parseInt(parts[2]);
const subnetBase = thirdOctet % 2 === 0 ? thirdOctet : thirdOctet - 1;
const subnet = `${parts[0]}.${parts[1]}.${subnetBase}.0/23`;
const subnet = `${parts[0]}.${parts[1]}.${parts[2]}.0/24`;
if (!subnets[subnet]) {
subnets[subnet] = [];
@@ -5123,27 +5276,40 @@ if (function_exists('requireAuth')) {
}
});
const ipxoData = {
subnets: Object.keys(subnets).map(prefix => ({
prefix: prefix,
records: subnets[prefix]
}))
};
// Download a separate JSON file for each /24 subnet
const subnetKeys = Object.keys(subnets).sort();
let downloadCount = 0;
const jsonStr = JSON.stringify(ipxoData, null, 2);
subnetKeys.forEach((subnet, index) => {
const ipxoData = {
subnets: [{
prefix: subnet,
records: subnets[subnet]
}]
};
// Copy to clipboard
navigator.clipboard.writeText(jsonStr).then(() => {
showToast(`Copied ${mismatchedOrMissing.length} PTR record(s) to clipboard in IPXO format`, 'success');
}).catch(err => {
// Fallback for browsers that don't support clipboard API
const textArea = document.createElement('textarea');
textArea.value = jsonStr;
document.body.appendChild(textArea);
textArea.select();
document.execCommand('copy');
document.body.removeChild(textArea);
showToast(`Copied ${mismatchedOrMissing.length} PTR record(s) to clipboard in IPXO format`, 'success');
const jsonStr = JSON.stringify(ipxoData, null, 2);
const blob = new Blob([jsonStr], { type: 'application/json' });
const url = URL.createObjectURL(blob);
// Create filename from subnet (replace / and . for valid filename)
const filename = `ipxo-ptr-${subnet.replace(/\//g, '-').replace(/\./g, '_')}.json`;
// Stagger downloads slightly to avoid browser blocking
setTimeout(() => {
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
downloadCount++;
if (downloadCount === subnetKeys.length) {
showToast(`Downloaded ${subnetKeys.length} IPXO JSON file(s) for ${mismatchedOrMissing.length} PTR record(s)`, 'success');
}
}, index * 100);
});
}