update webapp

This commit is contained in:
Purple
2026-01-17 21:06:07 +00:00
parent 328b7badc1
commit 23c0bf2ed4
7 changed files with 1376 additions and 57 deletions

173
README.md
View File

@@ -4,14 +4,41 @@ A complete solution for managing RFC 8805 compliant IP geolocation feeds (geofee
## Features
- **Modern Apple-esque UI** - Clean, responsive interface for managing geofeed entries
- **Modern Apple-esque UI** - Clean, responsive interface with dark mode support
- **RFC 8805 Compliant** - Generates valid geofeed CSV files per the specification
- **Authentication** - Secure login with environment-based credentials
- **CRUD Operations** - Create, read, update, and delete geofeed entries
- **Search & Filter** - Find entries by IP prefix, city, region, or country
- **Audit Logging** - Track all changes to your geofeed
- **Automated Export** - n8n workflow exports to BunnyCDN hourly
- **Audit Logging** - Track all changes to your geofeed with detailed history
- **IP Enrichment** - Automatic ISP and security flag data via ipregistry.co
- **Client Logos** - Associate logo images with client shortnames
- **Webhook Integration** - Debounced n8n webhooks for on-demand CDN updates
- **Mobile Optimized** - Full mobile Safari support with PWA capabilities
- **CSRF Protection** - Secure form submissions
## What's New
### Authentication
- Secure login page with session-based authentication
- Credentials configured via environment variables
- Automatic session timeout after 24 hours
### IP Registry Integration
- Automatic IP enrichment when entries are created or imported
- ISP and organization data displayed in the table
- Security flags for: Abuser, Attacker, Bogon, Cloud Provider, Proxy, Relay, Tor, Tor Exit, VPN, Anonymous, Threat
- Manual enrichment option for existing entries
### Webhook System
- On-demand webhook notifications to n8n (replaces hourly polling)
- Debouncing to batch multiple changes and reduce API calls
- Queue status monitoring in the Advanced tab
### UI Improvements
- Dark mode with automatic OS detection
- Mobile Safari optimizations with safe area support
- Client logo management with grid display
## Directory Structure
```
@@ -22,6 +49,7 @@ geofeed-manager/
├── webapp/
│ ├── config.php # Configuration & helpers
│ ├── api.php # RESTful API endpoints
│ ├── login.php # Authentication page
│ └── index.php # Main web interface
├── n8n/
│ └── geofeed-export-workflow.json # n8n workflow
@@ -48,9 +76,15 @@ DB_NAME=geofeed_manager
DB_USER=geofeed
DB_PASSWORD=your_secure_password
# Ports
WEB_PORT=8080
DB_PORT=3306
# Authentication
AUTH_USERNAME=admin
AUTH_PASSWORD=your_secure_admin_password
# IP Registry (optional - for IP enrichment)
IPREGISTRY_API_KEY=your_ipregistry_api_key
# Cloudflare Tunnel (optional)
CLOUDFLARE_TUNNEL_TOKEN=your_tunnel_token
```
2. **Deploy with Docker Compose:**
@@ -61,7 +95,9 @@ docker compose up -d
3. **Access the web interface** at `http://your-server:8080`
4. **Import your geofeed** via the Advanced tab in the UI
4. **Login** with your configured credentials (default: admin/changeme)
5. **Import your geofeed** via the Advanced tab in the UI
### How It Works
@@ -91,27 +127,52 @@ Or in Dokploy, just redeploy the service.
| webapp | 8080 | PHP web interface |
| mariadb | 3306 | MariaDB database (exposed for n8n) |
| git-sync | - | Pulls code on startup, then exits |
| cloudflared | - | Cloudflare Tunnel (optional) |
| phpmyadmin | 8081 | Database admin (optional, use `--profile admin`) |
### Connecting n8n to the Database
## Configuration
Since n8n is on the same Docker host, you can connect using:
### Authentication
**Option A: Shared Network**
- Add the geofeed network to your n8n compose as external
- Host: `geofeed-db`
- Port: `3306`
Authentication is required to access the application. Configure credentials via environment variables:
**Option B: Host Networking**
- Host: `host.docker.internal` or server IP
- Port: `3306` (or your `DB_PORT`)
```env
AUTH_USERNAME=admin
AUTH_PASSWORD=your_secure_password
```
Database credentials:
- Database: `geofeed_manager`
- User: `geofeed`
- Password: Your `DB_PASSWORD`
The login session expires after 24 hours of inactivity.
### 4. n8n Workflow Setup
### IP Registry Integration
To enable automatic IP enrichment:
1. Sign up for a free API key at [ipregistry.co](https://ipregistry.co)
2. Set the API key via environment variable:
```env
IPREGISTRY_API_KEY=your_api_key
```
Or configure it in the Advanced tab of the web interface.
3. Enable auto-enrichment in the Advanced tab
When enabled, new IP entries are automatically enriched with:
- ISP and organization name
- ASN information
- Connection type
- Timezone
- Security flags (proxy, VPN, Tor, threat, etc.)
### Webhook Integration
Configure webhooks in the Advanced tab to notify n8n when data changes:
1. Enter your n8n webhook URL
2. Set the debounce delay (1-60 minutes)
3. Enable webhook notifications
The system batches multiple changes within the debounce window to reduce API calls.
### n8n Workflow Setup
1. In n8n, go to **Settings > Environment Variables** and add:
- `BUNNY_STORAGE_ZONE` - Your BunnyCDN storage zone name
@@ -133,13 +194,16 @@ Database credentials:
- For each MySQL node, select your MySQL credential
- Save the workflow
5. Activate the workflow to start hourly exports
5. Activate the workflow - it will trigger via webhook when data changes
## API Reference
### Authentication
All API endpoints (except `export` and `webhook_process`) require authentication.
### List Entries
```
GET api.php?action=list&page=1&limit=25&search=term&country=GB
GET api.php?action=list&page=1&limit=25&search=term&country=GB&sort=ip|custom
```
### Get Single Entry
@@ -158,6 +222,7 @@ Content-Type: application/json
"region_code": "GB-ENG",
"city": "London",
"postal_code": "EC1A 1BB",
"client_short_name": "acme",
"notes": "Main office",
"csrf_token": "..."
}
@@ -200,6 +265,41 @@ GET api.php?action=export&format=download
GET api.php?action=stats
```
### Enrich Single IP
```
POST api.php?action=enrich_ip
Content-Type: application/json
{
"id": 123,
"csrf_token": "..."
}
```
### Enrich All Un-enriched IPs
```
POST api.php?action=enrich_all
Content-Type: application/json
{
"csrf_token": "..."
}
```
### Update Sort Order
```
POST api.php?action=update_sort_order
Content-Type: application/json
{
"orders": [
{"id": 1, "sort_order": 0},
{"id": 2, "sort_order": 1}
],
"csrf_token": "..."
}
```
## Geofeed Format (RFC 8805)
Each line in the exported CSV follows this format:
@@ -226,27 +326,46 @@ Example:
## Security Considerations
- Always use HTTPS in production
- Always use HTTPS in production (use Cloudflare Tunnel or reverse proxy)
- Change the default admin password immediately
- Keep your database credentials secure
- Consider adding authentication to the web interface
- The CSRF token helps prevent cross-site attacks
- The application uses session-based authentication with CSRF protection
- IP Registry API keys are stored securely and masked in the UI
- Input validation is performed on all fields
## Troubleshooting
### Cannot login
- Verify AUTH_USERNAME and AUTH_PASSWORD environment variables are set
- Check container logs for authentication errors
- Clear browser cookies and try again
### Import fails with "Invalid IP prefix"
Ensure your IP prefixes are in valid CIDR notation (e.g., `192.168.1.0/24`)
### IP enrichment not working
- Verify your ipregistry.co API key is valid
- Check that auto-enrichment is enabled in the Advanced tab
- Review container logs for API errors
### n8n workflow fails
- Check that environment variables are set correctly
- Verify MySQL credentials are configured
- Check BunnyCDN API key permissions
### Web interface shows database error
- Verify database credentials in config.php
- Verify database credentials in environment variables
- Ensure the database and tables exist
- Check MySQL/MariaDB is running
### Dark mode not working
- Ensure your browser/OS has dark mode enabled
- Try clearing browser cache
## License
MIT License - Feel free to use and modify as needed.
---
Built with care by [Purple Computing](https://purplecomputing.com)

View File

@@ -4,7 +4,7 @@
CREATE DATABASE IF NOT EXISTS geofeed_manager;
USE geofeed_manager;
-- Main geofeed entries table
-- Main geofeed entries table with IP enrichment data
CREATE TABLE IF NOT EXISTS geofeed_entries (
id INT AUTO_INCREMENT PRIMARY KEY,
ip_prefix VARCHAR(50) NOT NULL,
@@ -14,13 +14,44 @@ CREATE TABLE IF NOT EXISTS geofeed_entries (
postal_code VARCHAR(50) DEFAULT NULL,
client_short_name VARCHAR(100) DEFAULT NULL,
notes TEXT DEFAULT NULL,
sort_order INT DEFAULT 0,
-- IP Registry enrichment data
ipr_enriched_at TIMESTAMP NULL DEFAULT NULL,
ipr_isp VARCHAR(255) DEFAULT NULL,
ipr_org VARCHAR(255) DEFAULT NULL,
ipr_asn INT DEFAULT NULL,
ipr_asn_name VARCHAR(255) DEFAULT NULL,
ipr_connection_type VARCHAR(50) DEFAULT NULL,
ipr_country_name VARCHAR(100) DEFAULT NULL,
ipr_region_name VARCHAR(100) DEFAULT NULL,
ipr_timezone VARCHAR(100) DEFAULT NULL,
ipr_latitude DECIMAL(10, 7) DEFAULT NULL,
ipr_longitude DECIMAL(10, 7) DEFAULT NULL,
-- Security flags from IP Registry
flag_abuser TINYINT(1) DEFAULT 0,
flag_attacker TINYINT(1) DEFAULT 0,
flag_bogon TINYINT(1) DEFAULT 0,
flag_cloud_provider TINYINT(1) DEFAULT 0,
flag_proxy TINYINT(1) DEFAULT 0,
flag_relay TINYINT(1) DEFAULT 0,
flag_tor TINYINT(1) DEFAULT 0,
flag_tor_exit TINYINT(1) DEFAULT 0,
flag_vpn TINYINT(1) DEFAULT 0,
flag_anonymous TINYINT(1) DEFAULT 0,
flag_threat TINYINT(1) DEFAULT 0,
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_client (client_short_name)
INDEX idx_client (client_short_name),
INDEX idx_sort_order (sort_order),
INDEX idx_isp (ipr_isp),
INDEX idx_asn (ipr_asn)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Audit log for tracking changes
@@ -71,6 +102,19 @@ CREATE TABLE IF NOT EXISTS webhook_queue (
INDEX idx_scheduled (scheduled_for)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- User sessions table for authentication
CREATE TABLE IF NOT EXISTS user_sessions (
id INT AUTO_INCREMENT PRIMARY KEY,
session_token VARCHAR(64) NOT NULL,
username VARCHAR(100) NOT NULL,
ip_address VARCHAR(45) DEFAULT NULL,
user_agent TEXT DEFAULT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMP NOT NULL,
UNIQUE KEY unique_token (session_token),
INDEX idx_expires (expires_at)
) 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', ''),
@@ -79,5 +123,7 @@ INSERT INTO geofeed_settings (setting_key, setting_value) VALUES
('last_export_at', NULL),
('n8n_webhook_url', ''),
('n8n_webhook_enabled', '0'),
('n8n_webhook_delay_minutes', '3')
('n8n_webhook_delay_minutes', '3'),
('ipregistry_api_key', ''),
('ipregistry_enabled', '0')
ON DUPLICATE KEY UPDATE setting_key = setting_key;

View File

@@ -67,6 +67,11 @@ services:
DB_NAME: ${DB_NAME:-geofeed_manager}
DB_USER: ${DB_USER:-geofeed}
DB_PASS: ${DB_PASSWORD:-geofeed_secret}
# Authentication credentials
AUTH_USERNAME: ${AUTH_USERNAME:-admin}
AUTH_PASSWORD: ${AUTH_PASSWORD:-changeme}
# IP Registry API for IP enrichment
IPREGISTRY_API_KEY: ${IPREGISTRY_API_KEY:-}
volumes:
- webapp_code:/app:ro
depends_on:

View File

@@ -18,6 +18,14 @@ if ($method === 'OPTIONS') {
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();
@@ -110,6 +118,30 @@ try {
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;
default:
jsonResponse(['error' => 'Invalid action'], 400);
}
@@ -155,13 +187,21 @@ function handleList($db) {
$countStmt->execute($params);
$total = $countStmt->fetch()['total'];
// 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";
// 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) {
@@ -265,14 +305,28 @@ function handleCreate($db) {
]);
$id = $db->lastInsertId();
// Log the action
logAction($db, $id, 'INSERT', null, $input);
// Queue webhook notification
queueWebhookNotification($db, 'entry_created', 1);
jsonResponse(['success' => true, 'id' => $id, 'message' => 'Entry created successfully'], 201);
// 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);
}
/**
@@ -524,25 +578,30 @@ 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)
@@ -553,28 +612,28 @@ function handleImport($db) {
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,
@@ -583,9 +642,11 @@ function handleImport($db) {
':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++;
}
@@ -593,9 +654,9 @@ function handleImport($db) {
$failed++;
}
}
$db->commit();
// Log the import
logAction($db, null, 'INSERT', null, [
'type' => 'bulk_import',
@@ -610,11 +671,26 @@ function handleImport($db) {
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
'failed' => $failed,
'enriched' => $enriched,
'pending_enrichment' => max(0, count($newEntryIds) - $enriched)
]);
} catch (Exception $e) {
@@ -1168,3 +1244,188 @@ function handleWebhookQueueStatus($db) {
]
]);
}
/**
* 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']);
}

View File

@@ -18,6 +18,14 @@ define('APP_NAME', 'Geofeed Manager');
define('APP_VERSION', '1.0.0');
define('ITEMS_PER_PAGE', 25);
// Authentication configuration
define('AUTH_USERNAME', getenv('AUTH_USERNAME') ?: 'admin');
define('AUTH_PASSWORD', getenv('AUTH_PASSWORD') ?: 'changeme');
define('SESSION_TIMEOUT', 86400); // 24 hours
// IP Registry configuration
define('IPREGISTRY_API_KEY', getenv('IPREGISTRY_API_KEY') ?: '');
// Session configuration
session_start();
@@ -311,3 +319,218 @@ function triggerImmediateWebhook($db, $reason = 'manual_trigger') {
return sendWebhook($webhookUrl, $payload);
}
/**
* Authentication Functions
*/
/**
* Check if user is authenticated
*/
function isAuthenticated() {
if (empty($_SESSION['authenticated']) || empty($_SESSION['auth_token'])) {
return false;
}
// Check session timeout
if (!empty($_SESSION['auth_time']) && (time() - $_SESSION['auth_time']) > SESSION_TIMEOUT) {
logoutUser();
return false;
}
return true;
}
/**
* Authenticate user with username and password
*/
function authenticateUser($username, $password) {
if ($username === AUTH_USERNAME && $password === AUTH_PASSWORD) {
$_SESSION['authenticated'] = true;
$_SESSION['auth_token'] = bin2hex(random_bytes(32));
$_SESSION['auth_time'] = time();
$_SESSION['username'] = $username;
return true;
}
return false;
}
/**
* Logout user
*/
function logoutUser() {
$_SESSION['authenticated'] = false;
unset($_SESSION['auth_token']);
unset($_SESSION['auth_time']);
unset($_SESSION['username']);
}
/**
* Require authentication - redirect to login if not authenticated
*/
function requireAuth() {
if (!isAuthenticated()) {
header('Location: login.php');
exit;
}
}
/**
* Require authentication for API - return JSON error if not authenticated
*/
function requireAuthApi() {
if (!isAuthenticated()) {
jsonResponse(['error' => 'Authentication required', 'redirect' => 'login.php'], 401);
}
}
/**
* IP Registry Functions
*/
/**
* Fetch IP data from ipregistry.co
*/
function fetchIpRegistryData($ipPrefix) {
$apiKey = IPREGISTRY_API_KEY;
if (empty($apiKey)) {
return ['success' => false, 'error' => 'IP Registry API key not configured'];
}
// Extract IP from prefix (remove CIDR notation)
$ip = explode('/', $ipPrefix)[0];
// Validate IP
if (!filter_var($ip, FILTER_VALIDATE_IP)) {
return ['success' => false, 'error' => 'Invalid IP address'];
}
$url = "https://api.ipregistry.co/{$ip}?key={$apiKey}";
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $url,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_HTTPHEADER => [
'Accept: application/json',
'User-Agent: Geofeed-Manager/1.0'
]
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$error = curl_error($ch);
curl_close($ch);
if ($error || $httpCode !== 200) {
return [
'success' => false,
'error' => $error ?: "HTTP {$httpCode}",
'http_code' => $httpCode
];
}
$data = json_decode($response, true);
if (!$data) {
return ['success' => false, 'error' => 'Invalid JSON response'];
}
// Extract relevant fields
return [
'success' => true,
'data' => [
'ipr_isp' => $data['connection']['isp'] ?? null,
'ipr_org' => $data['connection']['organization'] ?? null,
'ipr_asn' => $data['connection']['asn'] ?? null,
'ipr_asn_name' => $data['connection']['domain'] ?? null,
'ipr_connection_type' => $data['connection']['type'] ?? null,
'ipr_country_name' => $data['location']['country']['name'] ?? null,
'ipr_region_name' => $data['location']['region']['name'] ?? null,
'ipr_timezone' => $data['time_zone']['id'] ?? null,
'ipr_latitude' => $data['location']['latitude'] ?? null,
'ipr_longitude' => $data['location']['longitude'] ?? null,
// Security flags
'flag_abuser' => !empty($data['security']['is_abuser']) ? 1 : 0,
'flag_attacker' => !empty($data['security']['is_attacker']) ? 1 : 0,
'flag_bogon' => !empty($data['security']['is_bogon']) ? 1 : 0,
'flag_cloud_provider' => !empty($data['security']['is_cloud_provider']) ? 1 : 0,
'flag_proxy' => !empty($data['security']['is_proxy']) ? 1 : 0,
'flag_relay' => !empty($data['security']['is_relay']) ? 1 : 0,
'flag_tor' => !empty($data['security']['is_tor']) ? 1 : 0,
'flag_tor_exit' => !empty($data['security']['is_tor_exit']) ? 1 : 0,
'flag_vpn' => !empty($data['security']['is_vpn']) ? 1 : 0,
'flag_anonymous' => !empty($data['security']['is_anonymous']) ? 1 : 0,
'flag_threat' => !empty($data['security']['is_threat']) ? 1 : 0,
]
];
}
/**
* Enrich IP entry with IP Registry data
*/
function enrichIpEntry($db, $entryId, $ipPrefix) {
$result = fetchIpRegistryData($ipPrefix);
if (!$result['success']) {
return $result;
}
$data = $result['data'];
$stmt = $db->prepare("
UPDATE geofeed_entries SET
ipr_enriched_at = NOW(),
ipr_isp = :ipr_isp,
ipr_org = :ipr_org,
ipr_asn = :ipr_asn,
ipr_asn_name = :ipr_asn_name,
ipr_connection_type = :ipr_connection_type,
ipr_country_name = :ipr_country_name,
ipr_region_name = :ipr_region_name,
ipr_timezone = :ipr_timezone,
ipr_latitude = :ipr_latitude,
ipr_longitude = :ipr_longitude,
flag_abuser = :flag_abuser,
flag_attacker = :flag_attacker,
flag_bogon = :flag_bogon,
flag_cloud_provider = :flag_cloud_provider,
flag_proxy = :flag_proxy,
flag_relay = :flag_relay,
flag_tor = :flag_tor,
flag_tor_exit = :flag_tor_exit,
flag_vpn = :flag_vpn,
flag_anonymous = :flag_anonymous,
flag_threat = :flag_threat
WHERE id = :id
");
$stmt->execute([
':id' => $entryId,
':ipr_isp' => $data['ipr_isp'],
':ipr_org' => $data['ipr_org'],
':ipr_asn' => $data['ipr_asn'],
':ipr_asn_name' => $data['ipr_asn_name'],
':ipr_connection_type' => $data['ipr_connection_type'],
':ipr_country_name' => $data['ipr_country_name'],
':ipr_region_name' => $data['ipr_region_name'],
':ipr_timezone' => $data['ipr_timezone'],
':ipr_latitude' => $data['ipr_latitude'],
':ipr_longitude' => $data['ipr_longitude'],
':flag_abuser' => $data['flag_abuser'],
':flag_attacker' => $data['flag_attacker'],
':flag_bogon' => $data['flag_bogon'],
':flag_cloud_provider' => $data['flag_cloud_provider'],
':flag_proxy' => $data['flag_proxy'],
':flag_relay' => $data['flag_relay'],
':flag_tor' => $data['flag_tor'],
':flag_tor_exit' => $data['flag_tor_exit'],
':flag_vpn' => $data['flag_vpn'],
':flag_anonymous' => $data['flag_anonymous'],
':flag_threat' => $data['flag_threat']
]);
return ['success' => true, 'data' => $data];
}

View File

@@ -17,6 +17,11 @@ if (!function_exists('generateCSRFToken')) {
return $_SESSION['csrf_token'];
}
}
// Require authentication
if (function_exists('requireAuth')) {
requireAuth();
}
?>
<!DOCTYPE html>
<html lang="en">
@@ -595,6 +600,52 @@ if (!function_exists('generateCSRFToken')) {
flex-shrink: 0;
}
/* Security flags */
.flags-cell {
display: flex;
flex-wrap: wrap;
gap: 4px;
max-width: 160px;
}
.flag-badge {
display: inline-flex;
align-items: center;
gap: 3px;
font-size: 10px;
font-weight: 600;
padding: 2px 6px;
border-radius: 4px;
text-transform: uppercase;
letter-spacing: 0.02em;
}
.flag-badge.danger {
background: var(--error-bg);
color: var(--error);
}
.flag-badge.warning {
background: var(--warning-bg);
color: #856404;
}
.flag-badge.info {
background: var(--info-bg);
color: var(--info);
}
.flag-badge svg {
width: 10px;
height: 10px;
}
@media (prefers-color-scheme: dark) {
.flag-badge.warning {
color: #ffc107;
}
}
.ip-prefix {
font-family: 'SF Mono', SFMono-Regular, ui-monospace, Menlo, Monaco, monospace;
font-size: 12px;
@@ -1463,6 +1514,14 @@ if (!function_exists('generateCSRFToken')) {
</svg>
<span class="hide-mobile">Add Entry</span>
</button>
<button class="btn btn-white btn-sm" onclick="logout()" title="Logout">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
<polyline points="16 17 21 12 16 7"/>
<line x1="21" y1="12" x2="9" y2="12"/>
</svg>
<span class="hide-mobile">Logout</span>
</button>
</div>
</div>
</header>
@@ -1641,6 +1700,51 @@ if (!function_exists('generateCSRFToken')) {
</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()">
<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">
@@ -1927,6 +2031,7 @@ if (!function_exists('generateCSRFToken')) {
loadLogosGrid();
loadWebhookSettings();
loadWebhookQueueStatus();
loadIpRegistrySettings();
}
}
@@ -2012,7 +2117,8 @@ if (!function_exists('generateCSRFToken')) {
<th>Country</th>
<th class="hide-mobile">Region</th>
<th class="hide-mobile">City</th>
<th class="hide-mobile">Postal</th>
<th class="hide-mobile">ISP</th>
<th class="hide-mobile">Flags</th>
<th>Client</th>
<th style="width: 90px;">Actions</th>
</tr>
@@ -2031,7 +2137,12 @@ if (!function_exists('generateCSRFToken')) {
</td>
<td class="hide-mobile">${entry.region_code ? `<span class="cell-truncate">${escapeHtml(entry.region_code)}</span>` : '<span style="color: var(--text-tertiary)">-</span>'}</td>
<td class="hide-mobile">${entry.city ? `<span class="cell-truncate">${escapeHtml(entry.city)}</span>` : '<span style="color: var(--text-tertiary)">-</span>'}</td>
<td class="hide-mobile">${entry.postal_code ? escapeHtml(entry.postal_code) : '<span style="color: var(--text-tertiary)">-</span>'}</td>
<td class="hide-mobile">${entry.ipr_isp ? `<span class="cell-truncate" title="${escapeHtml(entry.ipr_org || entry.ipr_isp)}">${escapeHtml(entry.ipr_isp)}</span>` : '<span style="color: var(--text-tertiary)">-</span>'}</td>
<td class="hide-mobile">
<div class="flags-cell">
${renderSecurityFlags(entry)}
</div>
</td>
<td>
${entry.client_short_name ? `
<div class="client-cell">
@@ -2051,6 +2162,13 @@ if (!function_exists('generateCSRFToken')) {
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
</svg>
</button>
<button class="btn btn-ghost btn-icon" onclick="enrichIp(${entry.id})" title="Enrich IP" ${entry.ipr_enriched_at ? 'style="color: var(--success);"' : ''}>
<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>
</button>
<button class="btn btn-ghost btn-icon" onclick="deleteEntry(${entry.id}, '${escapeHtml(entry.ip_prefix)}')" title="Delete" style="color: var(--error);">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="3 6 5 6 21 6"/>
@@ -2337,6 +2455,46 @@ if (!function_exists('generateCSRFToken')) {
}
}
// Load IP Registry settings
async function loadIpRegistrySettings() {
try {
const result = await api('ipregistry_settings_get');
if (result.success) {
document.getElementById('ipRegistryEnabled').checked = result.data.enabled;
if (result.data.api_key_masked) {
document.getElementById('ipRegistryApiKey').placeholder = `Current: ${result.data.api_key_masked}`;
} else if (result.data.has_env_key) {
document.getElementById('ipRegistryApiKey').placeholder = 'Using environment variable';
}
}
} catch (error) {
console.error('Failed to load IP Registry settings:', error);
}
}
// Save IP Registry settings
async function saveIpRegistrySettings() {
const enabled = document.getElementById('ipRegistryEnabled').checked;
const apiKey = document.getElementById('ipRegistryApiKey').value.trim();
try {
const result = await api('ipregistry_settings_save', {}, 'POST', {
enabled: enabled,
api_key: apiKey
});
if (result.success) {
showToast('IP Registry settings saved successfully', 'success');
document.getElementById('ipRegistryApiKey').value = '';
loadIpRegistrySettings();
} else {
showToast(result.error || 'Failed to save settings', 'error');
}
} catch (error) {
showToast('Network error', 'error');
}
}
// Save webhook settings
async function saveWebhookSettings() {
const webhookUrl = document.getElementById('webhookUrl').value.trim();
@@ -2853,6 +3011,79 @@ if (!function_exists('generateCSRFToken')) {
return String.fromCodePoint(...codePoints);
}
// Render security flags
function renderSecurityFlags(entry) {
const flags = [];
// Danger flags (red)
if (entry.flag_abuser == 1) flags.push({label: 'Abuser', type: 'danger'});
if (entry.flag_attacker == 1) flags.push({label: 'Attacker', type: 'danger'});
if (entry.flag_threat == 1) flags.push({label: 'Threat', type: 'danger'});
if (entry.flag_tor_exit == 1) flags.push({label: 'Tor Exit', type: 'danger'});
// Warning flags (yellow)
if (entry.flag_proxy == 1) flags.push({label: 'Proxy', type: 'warning'});
if (entry.flag_vpn == 1) flags.push({label: 'VPN', type: 'warning'});
if (entry.flag_tor == 1 && entry.flag_tor_exit != 1) flags.push({label: 'Tor', type: 'warning'});
if (entry.flag_relay == 1) flags.push({label: 'Relay', type: 'warning'});
if (entry.flag_anonymous == 1) flags.push({label: 'Anon', type: 'warning'});
// Info flags (blue)
if (entry.flag_cloud_provider == 1) flags.push({label: 'Cloud', type: 'info'});
if (entry.flag_bogon == 1) flags.push({label: 'Bogon', type: 'info'});
if (flags.length === 0) {
return entry.ipr_enriched_at ? '<span style="color: var(--success);">Clean</span>' : '<span style="color: var(--text-tertiary)">-</span>';
}
return flags.map(f => `<span class="flag-badge ${f.type}">${f.label}</span>`).join('');
}
// Enrich single IP
async function enrichIp(id) {
try {
const result = await api('enrich_ip', { id });
if (result.success) {
showToast('IP enriched successfully', 'success');
loadEntries(currentPage);
} else {
showToast(result.error || 'Failed to enrich IP', 'error');
}
} catch (error) {
showToast('Network error', 'error');
}
}
// Enrich all un-enriched IPs
async function enrichAllIps() {
const btn = event.target;
btn.disabled = true;
btn.innerHTML = '<span class="spinner" style="display:inline-block"></span> Enriching...';
try {
const result = await api('enrich_all', {});
if (result.success) {
showToast(`Enriched ${result.enriched} IPs. ${result.pending_enrichment || 0} remaining.`, 'success');
loadEntries(currentPage);
} else {
showToast(result.error || 'Failed to enrich IPs', 'error');
}
} catch (error) {
showToast('Network error', 'error');
} finally {
btn.disabled = false;
btn.innerHTML = 'Enrich All Un-enriched IPs';
}
}
// Logout function
async function logout() {
try {
await api('logout', {});
} catch (e) {}
window.location.href = 'login.php';
}
// Close modals on escape key
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {

434
webapp/login.php Normal file
View File

@@ -0,0 +1,434 @@
<?php
/**
* Geofeed Manager Login Page
*/
require_once __DIR__ . '/config.php';
$error = '';
$success = '';
// Handle logout
if (isset($_GET['logout'])) {
logoutUser();
header('Location: login.php');
exit;
}
// Already authenticated? Redirect to main page
if (isAuthenticated()) {
header('Location: index.php');
exit;
}
// Handle login form submission
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$username = trim($_POST['username'] ?? '');
$password = $_POST['password'] ?? '';
// Validate CSRF
if (!validateCSRFToken($_POST['csrf_token'] ?? '')) {
$error = 'Invalid security token. Please try again.';
} elseif (empty($username) || empty($password)) {
$error = 'Please enter both username and password.';
} elseif (authenticateUser($username, $password)) {
header('Location: index.php');
exit;
} else {
$error = 'Invalid username or password.';
// Add small delay to prevent brute force
usleep(500000);
}
}
$csrfToken = generateCSRFToken();
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, user-scalable=no">
<meta name="apple-mobile-web-app-capable" content="yes">
<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>Login | Geofeed 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">
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 258 258'%3E%3Cpath fill='%23474a4c' d='M241.13 56.2A26.53 26.53 0 11188.07 56.2a26.53 26.53 0 0153.06 0zm-5.34-.05a21.19 21.19 0 10-42.38 0 21.19 21.19 0 0042.38 0z'/%3E%3Cpath fill='%23a23f97' d='M21.42 37.38h55.28a.32.32 0 01.32.32v12.21a.46.46 0 00.8.3c13.2-14.73 32.09-17.47 50.68-12.7 35.19 9.03 47.69 43.89 45.07 77C170.91 148.16 150.93 173.81 115.1 175.14q-22.52.84-37.38-15.22a.65.65 0 00-1.13.47c.06 1.2.49 2.44.49 4.15q-.04 23.9.01 56.37a.42.41 0 01-.42.41H21.66a.88.88 0 01-.88-.88V38.01a.64.63 0 01.64-.63zM77.02 104.64c0 12.43 5.67 26.28 20.24 26.28s20.25-13.85 20.25-26.28-5.67-26.28-20.25-26.28-20.24 13.85-20.24 26.28z'/%3E%3Cpath fill='%23474a4c' d='M221.39 61.32l4.27 7.4a1.09 1.09 0 01-.94 1.63h-.86a3.6 3.59 74.9 01-3.11-1.8l-3.42-5.93a1.73 1.72 74.8 00-1.49-.86h-5.78a.65.65 0 00-.65.65v6.54a1.26 1.26 0 01-1.26 1.26h-1.66a1.51 1.5 0 01-1.51-1.5V43.2a.88.88 0 01.89-.88c4.16.09 11.28-.78 15.02 1.14 5.3 2.72 7.21 7.98 4.13 13.34-.92 1.58-2.43 2.35-3.53 3.56a.82.81 51.2 00-.1.96zm-11.98-14.77l.06 11.22a.61.61 0 00.61.61l5.18-.03a7.25 6.14-.3 006.22-6.17v-.16a7.25 6.14-.3 00-7.28-6.11l-5.18.03a.61.61 0 00-.61.61z'/%3E%3Ccircle fill='%2331b05e' cx='163.95' cy='201.82' r='28.07'/%3E%3C/svg%3E">
<style>
:root {
--purple-primary: #6B2D7B;
--purple-dark: #4A1F55;
--purple-light: #8B4D9B;
--purple-lighter: #F5EDF7;
--purple-gradient: linear-gradient(135deg, #6B2D7B 0%, #8B4D9B 100%);
--bg-primary: #f8f9fa;
--bg-secondary: #ffffff;
--bg-tertiary: #f1f3f4;
--text-primary: #1a1a2e;
--text-secondary: #6c757d;
--text-tertiary: #868e96;
--border: rgba(0, 0, 0, 0.08);
--border-strong: rgba(0, 0, 0, 0.12);
--shadow-sm: 0 1px 3px rgba(107, 45, 123, 0.08), 0 1px 2px rgba(0, 0, 0, 0.06);
--shadow-md: 0 4px 12px rgba(107, 45, 123, 0.1), 0 2px 4px rgba(0, 0, 0, 0.04);
--shadow-lg: 0 10px 40px rgba(107, 45, 123, 0.15), 0 4px 12px rgba(0, 0, 0, 0.05);
--shadow-xl: 0 25px 50px -12px rgba(107, 45, 123, 0.2);
--radius-sm: 8px;
--radius-md: 12px;
--radius-lg: 16px;
--radius-xl: 20px;
--error: #dc3545;
--error-bg: rgba(220, 53, 69, 0.1);
--success: #28a745;
--success-bg: rgba(40, 167, 69, 0.1);
--transition: all 0.2s ease;
--safe-area-top: env(safe-area-inset-top);
--safe-area-bottom: env(safe-area-inset-bottom);
}
@media (prefers-color-scheme: dark) {
:root {
--purple-primary: #9B5FAB;
--purple-dark: #7B3F8B;
--purple-light: #BB7FCB;
--purple-lighter: rgba(155, 95, 171, 0.15);
--purple-gradient: linear-gradient(135deg, #4A1F55 0%, #6B2D7B 100%);
--bg-primary: #0d0d14;
--bg-secondary: #1a1a2e;
--bg-tertiary: #252542;
--text-primary: #f0f0f5;
--text-secondary: #a0a0b0;
--text-tertiary: #707080;
--border: rgba(255, 255, 255, 0.08);
--border-strong: rgba(255, 255, 255, 0.12);
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.3), 0 1px 2px rgba(0, 0, 0, 0.2);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.4), 0 2px 4px rgba(0, 0, 0, 0.2);
--shadow-lg: 0 10px 40px rgba(0, 0, 0, 0.5), 0 4px 12px rgba(0, 0, 0, 0.3);
--shadow-xl: 0 25px 50px -12px rgba(0, 0, 0, 0.6);
--error-bg: rgba(220, 53, 69, 0.2);
--success-bg: rgba(40, 167, 69, 0.2);
}
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html {
-webkit-tap-highlight-color: transparent;
}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
line-height: 1.5;
-webkit-font-smoothing: antialiased;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
padding-top: max(24px, var(--safe-area-top));
padding-bottom: max(24px, var(--safe-area-bottom));
}
.login-container {
width: 100%;
max-width: 420px;
}
.login-card {
background: var(--bg-secondary);
border-radius: var(--radius-xl);
box-shadow: var(--shadow-xl);
border: 1px solid var(--border);
overflow: hidden;
}
.login-header {
background: var(--purple-gradient);
color: white;
padding: 40px 32px;
text-align: center;
}
.logo {
display: flex;
align-items: center;
justify-content: center;
gap: 14px;
margin-bottom: 20px;
}
.logo-icon {
width: 56px;
height: 56px;
background: rgba(255, 255, 255, 0.2);
border-radius: var(--radius-lg);
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
}
.logo-icon svg {
width: 36px;
height: 36px;
}
.login-title {
font-size: 24px;
font-weight: 700;
letter-spacing: -0.02em;
margin-bottom: 8px;
}
.login-subtitle {
font-size: 14px;
opacity: 0.9;
}
.login-form {
padding: 32px;
}
.form-group {
margin-bottom: 20px;
}
.form-label {
display: block;
font-size: 13px;
font-weight: 600;
color: var(--text-secondary);
margin-bottom: 8px;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.form-input {
width: 100%;
padding: 14px 16px;
font-size: 16px;
font-family: inherit;
background: var(--bg-tertiary);
border: 2px solid var(--border);
border-radius: var(--radius-md);
color: var(--text-primary);
transition: var(--transition);
outline: none;
}
.form-input:focus {
border-color: var(--purple-primary);
box-shadow: 0 0 0 4px rgba(107, 45, 123, 0.1);
}
.form-input::placeholder {
color: var(--text-tertiary);
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 100%;
padding: 14px 24px;
font-size: 15px;
font-weight: 600;
border-radius: var(--radius-md);
border: none;
cursor: pointer;
transition: var(--transition);
font-family: inherit;
-webkit-touch-callout: none;
-webkit-user-select: none;
user-select: none;
}
.btn:active {
transform: scale(0.98);
}
.btn-primary {
background: var(--purple-primary);
color: white;
}
.btn-primary:hover {
background: var(--purple-dark);
transform: translateY(-1px);
box-shadow: var(--shadow-md);
}
.btn-primary:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
.alert {
padding: 14px 16px;
border-radius: var(--radius-md);
font-size: 14px;
margin-bottom: 20px;
display: flex;
align-items: center;
gap: 10px;
}
.alert-error {
background: var(--error-bg);
color: var(--error);
border: 1px solid rgba(220, 53, 69, 0.3);
}
.alert-success {
background: var(--success-bg);
color: var(--success);
border: 1px solid rgba(40, 167, 69, 0.3);
}
.alert-icon {
flex-shrink: 0;
}
.login-footer {
text-align: center;
padding: 20px 32px 32px;
color: var(--text-tertiary);
font-size: 12px;
}
.login-footer a {
color: var(--purple-primary);
text-decoration: none;
}
.login-footer a:hover {
text-decoration: underline;
}
/* Loading spinner */
.spinner {
display: none;
width: 18px;
height: 18px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-radius: 50%;
border-top-color: white;
animation: spin 0.8s linear infinite;
margin-right: 8px;
}
.btn.loading .spinner {
display: inline-block;
}
.btn.loading .btn-text {
opacity: 0.7;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Shake animation for errors */
@keyframes shake {
0%, 100% { transform: translateX(0); }
10%, 30%, 50%, 70%, 90% { transform: translateX(-5px); }
20%, 40%, 60%, 80% { transform: translateX(5px); }
}
.shake {
animation: shake 0.5s ease-in-out;
}
</style>
</head>
<body>
<div class="login-container">
<div class="login-card">
<div class="login-header">
<div class="logo">
<div class="logo-icon">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 258 258">
<path fill="white" d="M21.42 37.38h55.28a.32.32 0 01.32.32v12.21a.46.46 0 00.8.3c13.2-14.73 32.09-17.47 50.68-12.7 35.19 9.03 47.69 43.89 45.07 77C170.91 148.16 150.93 173.81 115.1 175.14q-22.52.84-37.38-15.22a.65.65 0 00-1.13.47c.06 1.2.49 2.44.49 4.15q-.04 23.9.01 56.37a.42.41 0 01-.42.41H21.66a.88.88 0 01-.88-.88V38.01a.64.63 0 01.64-.63zM77.02 104.64c0 12.43 5.67 26.28 20.24 26.28s20.25-13.85 20.25-26.28-5.67-26.28-20.25-26.28-20.24 13.85-20.24 26.28z"/>
<circle fill="rgba(255,255,255,0.6)" cx="200" cy="200" r="22"/>
</svg>
</div>
</div>
<h1 class="login-title">Geofeed Manager</h1>
<p class="login-subtitle">Sign in to manage your geofeed entries</p>
</div>
<form class="login-form" method="POST" action="login.php" id="loginForm">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrfToken) ?>">
<?php if ($error): ?>
<div class="alert alert-error shake">
<svg class="alert-icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<line x1="12" y1="8" x2="12" y2="12"/>
<line x1="12" y1="16" x2="12.01" y2="16"/>
</svg>
<span><?= htmlspecialchars($error) ?></span>
</div>
<?php endif; ?>
<div class="form-group">
<label class="form-label" for="username">Username</label>
<input type="text" id="username" name="username" class="form-input" placeholder="Enter your username" required autocomplete="username" autofocus>
</div>
<div class="form-group">
<label class="form-label" for="password">Password</label>
<input type="password" id="password" name="password" class="form-input" placeholder="Enter your password" required autocomplete="current-password">
</div>
<button type="submit" class="btn btn-primary" id="submitBtn">
<span class="spinner"></span>
<span class="btn-text">Sign In</span>
</button>
</form>
<div class="login-footer">
<p>Powered by <a href="https://purplecomputing.com" target="_blank" rel="noopener">Purple Computing</a></p>
</div>
</div>
</div>
<script>
// Add loading state on form submit
document.getElementById('loginForm').addEventListener('submit', function(e) {
const btn = document.getElementById('submitBtn');
btn.classList.add('loading');
btn.disabled = true;
});
// Focus first empty field
const usernameField = document.getElementById('username');
const passwordField = document.getElementById('password');
if (usernameField.value) {
passwordField.focus();
} else {
usernameField.focus();
}
</script>
</body>
</html>