update webapp
This commit is contained in:
173
README.md
173
README.md
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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:
|
||||
|
||||
311
webapp/api.php
311
webapp/api.php
@@ -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']);
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
235
webapp/index.php
235
webapp/index.php
@@ -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
434
webapp/login.php
Normal 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>
|
||||
Reference in New Issue
Block a user