diff --git a/.gitignore b/.gitignore index 71babbb..81cc723 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ .env .env.local .env.*.local +.installed # Composer /vendor/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 894cc63..258ad18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,10 +5,146 @@ All notable changes to Domain Monitor will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [1.1.0] - 2025-10-09 ### Added -- TLD Registry System with IANA integration +- **User Notifications System** - In-app notification center with filtering and pagination +- **Welcome Notifications** - Automatically sent to new users on registration or fresh install +- **System Upgrade Notifications** - Admins notified when system is upgraded with migration details +- **Notification Types**: + - System: Welcome, Upgrade notifications + - Domain: Expiring, Expired, Updated + - Security: New login detection + - WHOIS: Lookup failures +- **Notification Features**: + - Unread notification count in top navigation + - Dropdown preview of recent notifications + - Full notification page with filtering (status, type, date range) + - Pagination and sorting + - Mark as read / Mark all as read + - Delete individual / Clear all notifications +- **Database-Backed Sessions** - Full session management stored in database +- **Active Session Management** - View, monitor, and control all logged-in devices +- **Geolocation Tracking** - IP-based location detection (country, city, region, ISP) +- **Session Details Display**: + - Country flags with flag-icons library + - City and country name + - ISP/Network provider + - Device type detection (Desktop/Mobile/Tablet) + - Browser detection (Chrome/Firefox/Safari/Edge/Opera) + - Session age and last activity timestamps + - Remember me indicator (cookie badge) +- **Remote Session Control**: + - Terminate individual sessions with delete button + - Logout all other sessions with one click + - Immediate logout validation (deleted sessions can't access anything) +- **Enhanced Profile Page**: + - Sidebar navigation layout + - Four sections: Profile Information, Security, Active Sessions, Danger Zone + - URL hash navigation (#profile, #security, #sessions, #danger) + - Clean design matching application theme +- **Remember Token Security**: + - Remember tokens linked to specific sessions + - Deleting session also invalidates remember token + - Prevents auto-login after remote logout +- **Session Validator Middleware** - Validates sessions on every request +- **Auto-Detected Cron Paths** - Settings page shows actual installation paths (thanks @jadeops) +- **Automatic Session Cleanup** - Multiple cleanup triggers (no cron job needed) +- User registration with email verification +- Password reset via email +- Remember me functionality (30-day cookies) +- User profile management +- Change password +- Email verification with token expiry (24h) +- Password reset tokens (1h expiry) +- Registration enable/disable toggle +- User CRUD management (admin-only) +- Role-based access control (admin/user) +- Centralized app version in database +- Web-based installer (replaces CLI migrate.php) +- Web-based updater for new migrations +- Auto-detection of installation status +- Migration tracking system +- Consolidated database schema for v1.1.0 fresh installs +- Smart migration system (consolidated for new, incremental for upgrades) + +### Changed +- Profile page completely redesigned with sidebar layout +- Session system migrated from file-based to database-backed +- Top navigation dropdown links updated with hash navigation +- Settings → System tab now shows auto-detected cron paths +- Help & Support menu links to GitHub repository +- Auth views refactored with base layout +- System section (Settings/Users) restricted to admins +- TLD Registry read-only for regular users +- Sidebar shows role-based links +- Profile integrated with dashboard layout +- Installation now via web UI instead of CLI +- Auto-redirect to installer on first run + +### Security +- **Database Session Storage** - True session control with remote termination +- **Session Validation** - Every request validates session exists in database +- **Geolocation Logging** - Track suspicious login locations +- **Remember Token Linking** - Tokens tied to sessions, deleted together +- **Immediate Logout** - Deleted sessions invalidated within seconds +- Bcrypt password hashing +- Secure 32-byte tokens +- Time-limited tokens +- One-time use reset tokens +- HttpOnly secure cookies +- Email enumeration protection +- Session-based verification resend +- Admin-only route protection + +### Technical +- **MVC Architecture Refactoring** - Complete separation of concerns + - `LayoutHelper` - Global layout data (notifications, stats, settings) + - `DomainHelper` - Domain formatting and business logic + - `SessionHelper` - Session display formatting + - `NotificationService` - Notification creation and management + - All business logic removed from views (~265 lines cleaned) +- Database session handler implementing SessionHandlerInterface +- IP geolocation via ip-api.com (free, 45 req/min) +- Session validator middleware for real-time validation +- Automatic session cleanup (no cron needed for sessions) +- Flag-icons library integration for country flags +- User-agent parsing for device and browser detection +- Remember token cascade deletion on session termination +- Notification system with 7 notification types +- Welcome notifications on user creation and fresh install +- Upgrade notifications for admins with version tracking + +### Contributors +- Special thanks to @jadeops for auto-detected cron path improvement & XSS protection enhancement (PR #1) + +## [1.0.0] - 2024-10-08 + +### Added +- Initial release of Domain Monitor +- Modern PHP 8.1+ MVC architecture +- Domain management system with CRUD operations +- Automatic WHOIS lookup for domain information +- Multi-channel notification system: + - Email notifications via PHPMailer + - Telegram bot integration + - Discord webhook support + - Slack webhook support +- Notification groups feature +- Assign domains to notification groups +- Dashboard with real-time statistics +- Domain status tracking (active, expiring_soon, expired, error) +- Notification logging system +- Customizable notification intervals +- Cron job for automated domain checks +- Test notification script +- Responsive, modern UI design +- Database migration system +- Comprehensive documentation +- Installation guide +- Basic login/logout authentication +- Security features (prepared statements, session management) +- **TLD Registry System with IANA integration** - Import and manage TLD data (RDAP servers, WHOIS servers, registry URLs) - Progressive import workflow with real-time progress tracking - Support for 1,400+ TLDs with automatic updates @@ -47,38 +183,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Removed hardcoded default credentials - 16-character cryptographically secure admin passwords -## [1.0.0] - 2024-10-08 - -### Added -- Initial release of Domain Monitor -- Modern PHP 8.1+ MVC architecture -- Domain management system with CRUD operations -- Automatic WHOIS lookup for domain information -- Multi-channel notification system: - - Email notifications via PHPMailer - - Telegram bot integration - - Discord webhook support - - Slack webhook support -- Notification groups feature -- Assign domains to notification groups -- Dashboard with real-time statistics -- Domain status tracking (active, expiring_soon, expired, error) -- Notification logging system -- Customizable notification intervals -- Cron job for automated domain checks -- Test notification script -- Responsive, modern UI design -- Database migration system -- Comprehensive documentation -- Installation guide -- User authentication system -- Security features (prepared statements, session management) - ### Features - ✅ Add, edit, delete, and view domains - ✅ Automatic expiration date detection via WHOIS - ✅ Support for multiple notification channels per group -- ✅ Flexible notification scheduling (60,30, 15, 7, 3, 1 days before) +- ✅ Flexible notification scheduling (60, 30, 21, 14, 7, 5, 3, 2, 1 days before) - ✅ Email notifications with HTML templates - ✅ Rich Discord embeds with color coding - ✅ Telegram messages with formatting @@ -110,14 +219,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Documentation - README.md with comprehensive guide -- INSTALL.md with step-by-step installation - Inline code documentation - Configuration examples - Troubleshooting guide -### Future Enhancements (Roadmap) -- [ ] User authentication system -- [ ] Multi-user support with permissions +--- + +## Roadmap - Future Enhancements + +- [x] User authentication system (completed - v1.1.0) +- [x] Session management with geolocation (completed - v1.1.0) +- [x] TLD Registry System (completed - v1.0.0) +- [x] Remote session termination (completed - v1.1.0) +- [x] In-app user notifications (completed - v1.1.0) +- [ ] Multi-user support with advanced permissions and roles - [ ] API for external integrations - [ ] Domain grouping/tagging - [ ] Custom notification templates @@ -146,7 +261,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Version History +### 1.1.0 (2025-10-09) +- **User Notifications System** - In-app notification center with 7 notification types, filtering, pagination +- **Advanced Session Management** - Database-backed sessions with geolocation (country, city, ISP) +- **Remote Session Control** - Terminate any device instantly with immediate logout validation +- **Enhanced Profile Page** - Sidebar navigation with 4 tabs, hash-based routing (#profile, #security, #sessions) +- **MVC Architecture Refactoring** - 3 new Helpers (Layout, Domain, Session), ~265 lines cleaned from views +- **Geolocation Tracking** - IP-based location detection using ip-api.com, country flags with flag-icons +- **Device Detection** - Browser & device type parsing (Chrome/Firefox/Safari, Desktop/Mobile/Tablet) +- **Auto-Detected Cron Paths** - Settings show actual installation paths (thanks @jadeops) +- **Welcome Notifications** - Sent to new users on registration or fresh install +- **Upgrade Notifications** - Admins notified on system updates with version & migration count +- **Web-Based Installer** - Replaces CLI, auto-generates encryption key, one-time password display +- **Web-Based Updater** - `/install/update` for running new migrations with smart detection +- **User Registration** - Full signup flow with email verification, password reset, resend verification +- **User Management** - CRUD for users with filtering, sorting, pagination (admin-only) +- **Remember Me** - 30-day secure tokens linked to sessions, cascade deletion on logout +- **Session Validator** - Middleware validates sessions on every request for instant remote logout +- **Consistent UI/UX** - Unified filtering, sorting, pagination across Domains, Users, Notifications, TLD Registry +- **Smart Migrations** - Consolidated schema for fresh installs, incremental for upgrades +- **XSS Protection** - htmlspecialchars() applied across all user-facing data (thanks @jadeops) + ### 1.0.0 (2024-10-08) - Initial public release - Created by [Hosteroid](https://www.hosteroid.uk) - Premium Hosting Solutions +--- + +## 🙏 Special Thanks + +### Contributors +- **@jadeops** - Auto-detected cron path improvement & XSS protection enhancement (PR #1) + diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d88e376..04a228b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -65,7 +65,7 @@ Use our [Feature Request Template](.github/ISSUE_TEMPLATE/feature_request.md). 3. **Set up your development environment** - Copy `env.example.txt` to `.env` - Configure database settings - - Run migrations: `php database/migrate.php` + - Run web installer: Visit `http://localhost:8000` (or your local domain) 4. **Create a feature branch** ```bash @@ -131,12 +131,15 @@ public function getDomainInfo(string $domain): ?array If your contribution includes database changes: 1. **Create a new migration file** in `database/migrations/` - - Name it: `XXX_descriptive_name.sql` (e.g., `007_add_timezone_column.sql`) + - Name it: `XXX_descriptive_name.sql` (e.g., `014_add_new_feature.sql`) + - Use sequential numbering (next available number) - Include `IF NOT EXISTS` checks where appropriate -2. **Update `database/migrate.php`** to include the new migration +2. **Update `app/Controllers/InstallerController.php`** + - Add your migration to the `$incrementalMigrations` array + - Add it to the appropriate version upgrade path -3. **Test the migration** on a fresh database +3. **Test the migration** using the web updater at `/install/update` ### Frontend Changes diff --git a/README.md b/README.md index 79456ba..41b9ee9 100644 --- a/README.md +++ b/README.md @@ -22,12 +22,17 @@ A modern PHP MVC application for monitoring domain expiration dates and sending - 🎨 **Modern UI** - Clean, responsive design with intuitive interface ### Advanced Features -- 🔐 **Secure by Default** - Random passwords, session management, prepared statements +- 🔐 **Secure by Default** - Random passwords, database-backed sessions, prepared statements +- 🔔 **User Notifications System** - In-app notification center with real-time updates +- 📬 **Smart Notifications** - Welcome messages, upgrade alerts, domain warnings +- 🌐 **Advanced Session Management** - View all active sessions with geolocation and device tracking +- 🚨 **Remote Session Termination** - Logout any device immediately from anywhere - 📈 **Bulk Operations** - Import, refresh, and manage multiple domains at once - 🎯 **Flexible Alerts** - Customizable notification thresholds (60, 30, 21, 14, 7, 5, 3, 2, 1 days) - 🔄 **Auto WHOIS Refresh** - Keep domain data up-to-date automatically - 📱 **Monitoring Controls** - Enable/disable notifications per domain with alerts - 🌍 **RDAP Support** - Modern protocol for faster, structured domain data +- 🏴 **Geolocation Tracking** - See location, ISP, and device info for all sessions ## 📋 Requirements @@ -43,9 +48,12 @@ A modern PHP MVC application for monitoring domain expiration dates and sending The application includes built-in authentication with secure practices: - 🔑 **Random Password Generation** - Unique secure password created on installation -- 🛡️ **Session Management** - Secure session handling with httpOnly cookies +- 🛡️ **Database-Backed Sessions** - True session management with immediate remote logout +- 🌍 **Session Tracking** - Monitor all active sessions with location and device info +- 🚨 **Remote Session Control** - Terminate suspicious sessions from any device - 💉 **SQL Injection Protection** - All queries use prepared statements - 🔒 **One-time Credentials** - Admin password shown only once during setup +- 🍪 **Secure Remember Me** - Cryptographically secure 30-day tokens linked to sessions ⚠️ **Important:** Save your admin password during installation - it won't be shown again! @@ -88,7 +96,7 @@ DB_PASSWORD=your_password ``` **Note:** -- The encryption key (APP_ENCRYPTION_KEY) will be automatically generated during migration +- The encryption key (APP_ENCRYPTION_KEY) will be automatically generated during web installation - Application name, URL, timezone, email settings, and monitoring schedules are configured through the web interface in **Settings** (not .env) ### 4. Create Database @@ -99,48 +107,44 @@ Create a MySQL database: CREATE DATABASE domain_monitor CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; ``` -### 5. Run Migrations +### 5. Run Web Installer + +#### Option A: Apache/Nginx (Recommended) + +Configure your web server (see step 7 below), then visit your domain in a browser: + +``` +http://your-domain.com +``` + +You'll be automatically redirected to the installer. + +#### Option B: PHP Built-in Server ```bash -php database/migrate.php +php -S localhost:8000 -t public ``` -**⚠️ IMPORTANT:** The migration will: -1. **Generate an encryption key** (if not already set) and save it to `.env` -2. **Generate a random admin password** and display it **only once** +Then visit: `http://localhost:8000` -Example output: -``` -🔑 Generating encryption key... -✓ Encryption key generated and saved to .env - Key: base64_encoded_key_here - ⚠️ Keep this key secret and backup securely! +The web installer will: +1. ✅ Create all database tables +2. ✅ Generate encryption key and save to `.env` +3. ✅ Let you set admin email and password +4. ✅ Show credentials on completion (save them!) -... - -🔑 Admin credentials (SAVE THESE!): - ═══════════════════════════════════════ - Username: admin - Password: 3f8a2b9c4d5e6f7a - ═══════════════════════════════════════ - ⚠️ This password will not be shown again! - 💾 Save it to a secure password manager. -``` - -**Save these immediately:** -- The encryption key is needed to decrypt sensitive data (backup securely!) -- The admin password is needed to access the dashboard +**⚠️ IMPORTANT:** The installer will display your admin credentials **only once**. Save them to a secure password manager! ### 6. Import TLD Registry Data (Optional but Recommended) -For enhanced WHOIS lookups with automatic server discovery: +After logging in, go to **TLD Registry** page and click **"Import TLDs"** to download RDAP and WHOIS server data for 1,400+ TLDs from IANA. + +Alternatively, use the CLI: ```bash php cron/import_tld_registry.php ``` -This imports RDAP and WHOIS server data for 1,400+ TLDs from IANA. - ### 7. Configure Web Server #### Apache @@ -217,16 +221,18 @@ All application and email settings are now managed through the **Settings** page The application requires a cron job to check domains periodically. +**💡 Pro Tip:** The cron path is automatically detected! Go to **Settings → System** to copy the exact command for your installation. + ### Linux/Mac ```bash crontab -e ``` -Add this line to run daily at 9 AM: +Add this line (or copy from Settings → System): ```cron -0 9 * * * /usr/bin/php /path/to/project/cron/check_domains.php +0 9 * * * /usr/bin/php /your/actual/path/cron/check_domains.php ``` ### Windows @@ -238,17 +244,16 @@ Use Task Scheduler: 3. Set trigger (e.g., Daily at 9:00 AM) 4. Action: Start a program 5. Program: `C:\php\php.exe` -6. Arguments: `C:\path\to\domain-monitor\cron\check_domains.php` +6. Arguments: Copy from **Settings → System** tab (auto-detected path) ## 🧪 Testing Notifications -Before setting up the cron job, test your notification channels: +Before setting up the cron job, test your notification channels through the web interface: -```bash -php cron/test_notification.php -``` - -Follow the prompts to test Email, Telegram, Discord, or Slack. +1. Go to **Settings → Email** tab +2. Enter a test email address +3. Click **"Send Test Email"** to verify SMTP configuration +4. For Telegram/Discord/Slack, send a test from the notification group settings ## 📖 Usage Guide @@ -298,6 +303,8 @@ All system settings are managed through the **Settings** page (`/settings`) in y - **Application Name**: Customize the display name - **Application URL**: Base URL for links in emails - **Timezone**: Set your preferred timezone +- **User Registration**: Enable/disable new user signups +- **Email Verification**: Require email verification for new users #### Email Settings - **SMTP Configuration**: Host, port, encryption @@ -319,6 +326,60 @@ All system settings are managed through the **Settings** page (`/settings`) in y - Every 2 days - Weekly +#### System Settings +- **Auto-Detected Cron Path**: Copy-paste ready cron commands with your actual installation path +- **Log File Locations**: Find logs for troubleshooting + +### User Notifications + +Stay informed with the in-app notification system: + +#### Notification Center +- **Bell Icon**: Top navigation shows unread count with animated indicator +- **Dropdown Preview**: Quick view of 5 most recent unread notifications +- **Full Page**: `/notifications` with complete history and management + +#### Notification Types +- 📬 **Welcome** - Sent when you create an account or system is installed +- ⬆️ **System Upgrade** - Admins notified when system is updated (includes version & migration count) +- 🔴 **Domain Expiring** - Alerts based on your configured thresholds +- ⚠️ **Domain Expired** - Critical alerts for expired domains +- 🔄 **Domain Updated** - WHOIS data changes detected +- 🔐 **New Login** - Security alerts for new device logins +- ❌ **WHOIS Failed** - Lookup errors and issues + +#### Notification Features +- **Filter by Status**: Unread, Read, or All +- **Filter by Type**: Domain, System, or Security notifications +- **Date Ranges**: Today, This Week, This Month, All Time +- **Pagination**: View 10, 25, 50, or 100 per page +- **Quick Actions**: Mark as read, Delete, Mark all read, Clear all + +### Profile Management + +Access your profile settings via the top-right user menu: + +#### My Profile +- Update full name and email +- View account creation date +- Check last login timestamp +- Email verification status + +#### Security +- Change password securely +- Password strength requirements +- Security best practices + +#### Active Sessions +- **View All Sessions**: See every device where you're logged in +- **Session Details**: Location (country, city), ISP, device type, browser +- **Country Flags**: Visual indicators for each session location +- **Session Age**: See when each session was created +- **Last Activity**: Monitor recent activity per session +- **Remember Me Indicator**: See which sessions have "remember me" enabled +- **Remote Logout**: Terminate individual sessions or all other sessions +- **Instant Termination**: Deleted sessions are logged out immediately + All settings are stored in the database and can be updated at any time through the web interface. ## 📁 Project Structure @@ -327,11 +388,20 @@ All settings are stored in the database and can be updated at any time through t Domain Monitor/ ├── app/ │ ├── Controllers/ # Application controllers -│ ├── Models/ # Database models +│ ├── Models/ # Database models (User, Domain, SessionManager, etc.) │ ├── Services/ # Business logic & services -│ │ └── Channels/ # Notification channel implementations -│ └── Views/ # HTML views +│ │ ├── Channels/ # Notification channel implementations +│ │ └── NotificationService.php # Notification creation & management +│ ├── Helpers/ # Helper classes for formatting & display logic +│ │ ├── LayoutHelper.php # Global layout data (notifications, stats) +│ │ ├── DomainHelper.php # Domain formatting & calculations +│ │ └── SessionHelper.php # Session display formatting +│ └── Views/ # HTML views (pure display, no business logic) ├── core/ # Core MVC framework +│ ├── DatabaseSessionHandler.php # Database session storage +│ ├── SessionValidator.php # Session validation middleware +│ ├── Auth.php # Authentication helpers +│ └── ... ├── cron/ # Cron job scripts ├── database/ │ └── migrations/ # Database migrations @@ -361,9 +431,9 @@ Domain Monitor/ ### Notifications Not Sending 1. Check logs: `logs/cron.log` -2. Verify notification channel configuration -3. Test using: `php cron/test_notification.php` -4. Check SMTP/API credentials +2. Verify notification channel configuration in **Settings → Email** +3. Test email using the built-in test function in Settings +4. Check SMTP/API credentials in Settings ### Database Connection Error diff --git a/app/Controllers/AuthController.php b/app/Controllers/AuthController.php index 2eacc15..9346994 100644 --- a/app/Controllers/AuthController.php +++ b/app/Controllers/AuthController.php @@ -4,14 +4,21 @@ namespace App\Controllers; use Core\Controller; use App\Models\User; +use App\Models\Setting; +use PHPMailer\PHPMailer\PHPMailer; +use PHPMailer\PHPMailer\Exception; class AuthController extends Controller { private User $userModel; + private Setting $settingModel; + private $db; public function __construct() { $this->userModel = new User(); + $this->settingModel = new Setting(); + $this->db = \Core\Database::getConnection(); } /** @@ -23,9 +30,13 @@ class AuthController extends Controller if (isset($_SESSION['user_id'])) { $this->redirect('/'); } + + // Check if registration is enabled + $registrationEnabled = $this->settingModel->getValue('registration_enabled'); $this->view('auth/login', [ - 'title' => 'Login' + 'title' => 'Login', + 'registrationEnabled' => $registrationEnabled ]); } @@ -41,6 +52,7 @@ class AuthController extends Controller $username = trim($_POST['username'] ?? ''); $password = $_POST['password'] ?? ''; + $remember = isset($_POST['remember']); // Validate input if (empty($username) || empty($password)) { @@ -65,10 +77,29 @@ class AuthController extends Controller return; } + // Check if email verification is required + $requireVerification = $this->settingModel->getValue('require_email_verification'); + if ($requireVerification && !$user['email_verified'] && $user['role'] !== 'admin') { + $_SESSION['error'] = 'Please verify your email address before logging in'; + $_SESSION['pending_verification_email'] = $user['email']; + $this->redirect('/verify-email'); + return; + } + // Login successful - create session $_SESSION['user_id'] = $user['id']; $_SESSION['username'] = $user['username']; $_SESSION['full_name'] = $user['full_name']; + $_SESSION['email'] = $user['email']; + $_SESSION['role'] = $user['role']; + + // Session is automatically tracked by DatabaseSessionHandler + // No need to manually create session record + + // Handle remember me + if ($remember) { + $this->createRememberToken($user['id']); + } // Update last login $this->userModel->updateLastLogin($user['id']); @@ -77,12 +108,618 @@ class AuthController extends Controller $this->redirect('/'); } + /** + * Show registration form + */ + public function showRegister() + { + // Check if already logged in + if (isset($_SESSION['user_id'])) { + $this->redirect('/'); + return; + } + + // Check if registration is enabled + $registrationEnabled = $this->settingModel->getValue('registration_enabled'); + if (!$registrationEnabled) { + $_SESSION['error'] = 'Registration is currently disabled'; + $this->redirect('/login'); + return; + } + + $this->view('auth/register', [ + 'title' => 'Register' + ]); + } + + /** + * Process registration + */ + public function register() + { + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + $this->redirect('/register'); + return; + } + + // Check if registration is enabled + $registrationEnabled = $this->settingModel->getValue('registration_enabled'); + if (!$registrationEnabled) { + $_SESSION['error'] = 'Registration is currently disabled'; + $this->redirect('/login'); + return; + } + + $username = trim($_POST['username'] ?? ''); + $email = trim($_POST['email'] ?? ''); + $fullName = trim($_POST['full_name'] ?? ''); + $password = $_POST['password'] ?? ''; + $passwordConfirm = $_POST['password_confirm'] ?? ''; + + // Validate inputs + if (empty($username) || empty($email) || empty($fullName) || empty($password)) { + $_SESSION['error'] = 'All fields are required'; + $this->redirect('/register'); + return; + } + + if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { + $_SESSION['error'] = 'Please enter a valid email address'; + $this->redirect('/register'); + return; + } + + if (!preg_match('/^[a-zA-Z0-9_]+$/', $username)) { + $_SESSION['error'] = 'Username can only contain letters, numbers, and underscores'; + $this->redirect('/register'); + return; + } + + if (strlen($password) < 8) { + $_SESSION['error'] = 'Password must be at least 8 characters long'; + $this->redirect('/register'); + return; + } + + if ($password !== $passwordConfirm) { + $_SESSION['error'] = 'Passwords do not match'; + $this->redirect('/register'); + return; + } + + // Check if username already exists + $existingUser = $this->userModel->findByUsername($username); + if ($existingUser) { + $_SESSION['error'] = 'Username is already taken'; + $this->redirect('/register'); + return; + } + + // Check if email already exists + $existingEmail = $this->userModel->where('email', $email); + if (!empty($existingEmail)) { + $_SESSION['error'] = 'Email address is already registered'; + $this->redirect('/register'); + return; + } + + try { + // Create user account + $userId = $this->userModel->createUser($username, $password, $email, $fullName); + + // Create welcome notification + try { + $notificationService = new \App\Services\NotificationService(); + $notificationService->notifyWelcome($userId, $username); + } catch (\Exception $e) { + // Don't fail registration if notification fails + error_log("Failed to create welcome notification: " . $e->getMessage()); + } + + // Check if email verification is required + $requireVerification = $this->settingModel->getValue('require_email_verification'); + + if ($requireVerification) { + // Generate verification token + $token = bin2hex(random_bytes(32)); + + // Save token to database + $stmt = $this->db->prepare( + "UPDATE users SET email_verification_token = ?, email_verification_sent_at = NOW() WHERE id = ?" + ); + $stmt->execute([$token, $userId]); + + // Send verification email + $this->sendVerificationEmail($email, $fullName, $token); + + $_SESSION['success'] = 'Account created successfully! Please check your email to verify your account.'; + $_SESSION['pending_verification_email'] = $email; + $this->redirect('/verify-email'); + } else { + // Mark as verified and log them in + $stmt = $this->db->prepare("UPDATE users SET email_verified = 1 WHERE id = ?"); + $stmt->execute([$userId]); + + $_SESSION['success'] = 'Account created successfully! You can now log in.'; + $this->redirect('/login'); + } + + } catch (\Exception $e) { + $_SESSION['error'] = 'Failed to create account: ' . $e->getMessage(); + $this->redirect('/register'); + } + } + + /** + * Show email verification page + */ + public function showVerifyEmail() + { + $token = $_GET['token'] ?? null; + + if ($token) { + // Verify the token + $this->verifyEmail($token); + return; + } + + // Show pending verification page + $email = $_SESSION['pending_verification_email'] ?? 'your email'; + $this->view('auth/verify-email', [ + 'title' => 'Verify Email', + 'email' => $email + ]); + } + + /** + * Verify email with token + */ + private function verifyEmail($token) + { + try { + $stmt = $this->db->prepare( + "SELECT * FROM users WHERE email_verification_token = ? AND email_verified = 0" + ); + $stmt->execute([$token]); + $user = $stmt->fetch(); + + if (!$user) { + $this->view('auth/verify-email', [ + 'title' => 'Verification Failed', + 'error' => true, + 'errorMessage' => 'Invalid or expired verification link.' + ]); + return; + } + + // Check if token is expired (24 hours) + $sentAt = strtotime($user['email_verification_sent_at']); + if (time() - $sentAt > 86400) { + $this->view('auth/verify-email', [ + 'title' => 'Verification Failed', + 'error' => true, + 'errorMessage' => 'Verification link has expired. Please request a new one.' + ]); + return; + } + + // Mark email as verified + $stmt = $this->db->prepare( + "UPDATE users SET email_verified = 1, email_verification_token = NULL WHERE id = ?" + ); + $stmt->execute([$user['id']]); + + $this->view('auth/verify-email', [ + 'title' => 'Email Verified', + 'verified' => true + ]); + + } catch (\Exception $e) { + $this->view('auth/verify-email', [ + 'title' => 'Verification Failed', + 'error' => true, + 'errorMessage' => 'An error occurred during verification.' + ]); + } + } + + /** + * Resend verification email + */ + public function resendVerification() + { + // Only allow resend if email is in session (from registration or login attempt) + $email = $_SESSION['pending_verification_email'] ?? ''; + + if (empty($email)) { + $_SESSION['error'] = 'Please try logging in first to resend verification email'; + $this->redirect('/login'); + return; + } + + try { + $users = $this->userModel->where('email', $email); + + if (empty($users)) { + $_SESSION['error'] = 'Email address not found'; + $this->redirect('/verify-email'); + return; + } + + $user = $users[0]; + + if ($user['email_verified']) { + $_SESSION['info'] = 'Email is already verified. You can log in now.'; + $this->redirect('/login'); + return; + } + + // Generate new verification token + $token = bin2hex(random_bytes(32)); + + $stmt = $this->db->prepare( + "UPDATE users SET email_verification_token = ?, email_verification_sent_at = NOW() WHERE id = ?" + ); + $stmt->execute([$token, $user['id']]); + + // Send verification email + $this->sendVerificationEmail($user['email'], $user['full_name'], $token); + + $_SESSION['success'] = 'Verification email sent! Please check your inbox.'; + $_SESSION['pending_verification_email'] = $email; + $this->redirect('/verify-email'); + + } catch (\Exception $e) { + $_SESSION['error'] = 'Failed to resend verification email. Please try again.'; + $this->redirect('/verify-email'); + } + } + + /** + * Show forgot password form + */ + public function showForgotPassword() + { + if (isset($_SESSION['user_id'])) { + $this->redirect('/'); + return; + } + + $this->view('auth/forgot-password', [ + 'title' => 'Forgot Password' + ]); + } + + /** + * Process forgot password request + */ + public function forgotPassword() + { + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + $this->redirect('/forgot-password'); + return; + } + + $email = trim($_POST['email'] ?? ''); + + if (empty($email) || !filter_var($email, FILTER_VALIDATE_EMAIL)) { + $_SESSION['error'] = 'Please enter a valid email address'; + $this->redirect('/forgot-password'); + return; + } + + try { + $users = $this->userModel->where('email', $email); + + // Always show success message to prevent email enumeration + $_SESSION['success'] = 'If an account exists with that email, you will receive password reset instructions.'; + + if (!empty($users)) { + $user = $users[0]; + + // Generate reset token + $token = bin2hex(random_bytes(32)); + $expiresAt = date('Y-m-d H:i:s', strtotime('+1 hour')); + + // Save token to database + $stmt = $this->db->prepare( + "INSERT INTO password_reset_tokens (user_id, token, expires_at) VALUES (?, ?, ?)" + ); + $stmt->execute([$user['id'], $token, $expiresAt]); + + // Send reset email + $this->sendPasswordResetEmail($user['email'], $user['full_name'], $token); + } + + $this->redirect('/forgot-password'); + + } catch (\Exception $e) { + $_SESSION['error'] = 'An error occurred. Please try again.'; + $this->redirect('/forgot-password'); + } + } + + /** + * Show reset password form + */ + public function showResetPassword() + { + $token = $_GET['token'] ?? ''; + + if (empty($token)) { + $_SESSION['error'] = 'Invalid reset link'; + $this->redirect('/login'); + return; + } + + // Verify token exists and is not expired + $stmt = $this->db->prepare( + "SELECT * FROM password_reset_tokens WHERE token = ? AND used = 0 AND expires_at > NOW()" + ); + $stmt->execute([$token]); + $resetToken = $stmt->fetch(); + + if (!$resetToken) { + $_SESSION['error'] = 'Invalid or expired reset link'; + $this->redirect('/forgot-password'); + return; + } + + $this->view('auth/reset-password', [ + 'title' => 'Reset Password', + 'token' => $token + ]); + } + + /** + * Process password reset + */ + public function resetPassword() + { + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + $this->redirect('/login'); + return; + } + + $token = $_POST['token'] ?? ''; + $password = $_POST['password'] ?? ''; + $passwordConfirm = $_POST['password_confirm'] ?? ''; + + // Validate inputs + if (empty($token) || empty($password) || empty($passwordConfirm)) { + $_SESSION['error'] = 'All fields are required'; + $this->redirect('/reset-password?token=' . urlencode($token)); + return; + } + + if (strlen($password) < 8) { + $_SESSION['error'] = 'Password must be at least 8 characters long'; + $this->redirect('/reset-password?token=' . urlencode($token)); + return; + } + + if ($password !== $passwordConfirm) { + $_SESSION['error'] = 'Passwords do not match'; + $this->redirect('/reset-password?token=' . urlencode($token)); + return; + } + + try { + // Verify token + $stmt = $this->db->prepare( + "SELECT * FROM password_reset_tokens WHERE token = ? AND used = 0 AND expires_at > NOW()" + ); + $stmt->execute([$token]); + $resetToken = $stmt->fetch(); + + if (!$resetToken) { + $_SESSION['error'] = 'Invalid or expired reset link'; + $this->redirect('/forgot-password'); + return; + } + + // Update password + $this->userModel->changePassword($resetToken['user_id'], $password); + + // Mark token as used + $stmt = $this->db->prepare("UPDATE password_reset_tokens SET used = 1 WHERE id = ?"); + $stmt->execute([$resetToken['id']]); + + $_SESSION['success'] = 'Password reset successfully! You can now log in.'; + $this->redirect('/login'); + + } catch (\Exception $e) { + $_SESSION['error'] = 'Failed to reset password. Please try again.'; + $this->redirect('/reset-password?token=' . urlencode($token)); + } + } + + /** + * Create remember me token linked to current session + */ + private function createRememberToken($userId) + { + try { + $token = bin2hex(random_bytes(32)); + $expiresAt = date('Y-m-d H:i:s', strtotime('+30 days')); + $sessionId = session_id(); + + $stmt = $this->db->prepare( + "INSERT INTO remember_tokens (user_id, session_id, token, expires_at) VALUES (?, ?, ?, ?)" + ); + $stmt->execute([$userId, $sessionId, $token, $expiresAt]); + + // Set cookie + setcookie('remember_token', $token, [ + 'expires' => strtotime('+30 days'), + 'path' => '/', + 'secure' => isset($_SERVER['HTTPS']), + 'httponly' => true, + 'samesite' => 'Lax' + ]); + + } catch (\Exception $e) { + // Silently fail - remember me is not critical + error_log("Failed to create remember token: " . $e->getMessage()); + } + } + + /** + * Check and process remember me token + */ + public function checkRememberToken() + { + $token = $_COOKIE['remember_token'] ?? null; + + if (!$token) { + return false; + } + + try { + $stmt = $this->db->prepare( + "SELECT user_id FROM remember_tokens WHERE token = ? AND expires_at > NOW()" + ); + $stmt->execute([$token]); + $rememberToken = $stmt->fetch(); + + if ($rememberToken) { + $user = $this->userModel->find($rememberToken['user_id']); + + if ($user && $user['is_active']) { + $_SESSION['user_id'] = $user['id']; + $_SESSION['username'] = $user['username']; + $_SESSION['full_name'] = $user['full_name']; + $_SESSION['email'] = $user['email']; + $_SESSION['role'] = $user['role']; + + // Session is automatically tracked by DatabaseSessionHandler + // No need to manually create session record + + return true; + } + } + + // Invalid token - clear cookie + setcookie('remember_token', '', time() - 3600, '/'); + + } catch (\Exception $e) { + // Silently fail + } + + return false; + } + + /** + * Send verification email + */ + private function sendVerificationEmail($email, $fullName, $token) + { + try { + $emailSettings = $this->settingModel->getEmailSettings(); + $appSettings = $this->settingModel->getAppSettings(); + + $verifyUrl = $appSettings['app_url'] . '/verify-email?token=' . $token; + + $mail = new PHPMailer(true); + $mail->isSMTP(); + $mail->Host = $emailSettings['mail_host']; + $mail->SMTPAuth = !empty($emailSettings['mail_username']); + $mail->Username = $emailSettings['mail_username']; + $mail->Password = $emailSettings['mail_password']; + $mail->SMTPSecure = $emailSettings['mail_encryption']; + $mail->Port = (int)$emailSettings['mail_port']; + $mail->CharSet = 'UTF-8'; + + $mail->setFrom($emailSettings['mail_from_address'], $emailSettings['mail_from_name']); + $mail->addAddress($email, $fullName); + + $mail->isHTML(true); + $mail->Subject = 'Verify Your Email Address'; + $mail->Body = " +

Welcome to Domain Monitor!

+

Hello {$fullName},

+

Thank you for registering. Please click the link below to verify your email address:

+

Verify Email Address

+

Or copy and paste this URL into your browser:

+

{$verifyUrl}

+

This link will expire in 24 hours.

+

If you did not create an account, please ignore this email.

+ "; + + $mail->send(); + + } catch (Exception $e) { + // Log error but don't fail the registration + error_log('Failed to send verification email: ' . $e->getMessage()); + } + } + + /** + * Send password reset email + */ + private function sendPasswordResetEmail($email, $fullName, $token) + { + try { + $emailSettings = $this->settingModel->getEmailSettings(); + $appSettings = $this->settingModel->getAppSettings(); + + $resetUrl = $appSettings['app_url'] . '/reset-password?token=' . $token; + + $mail = new PHPMailer(true); + $mail->isSMTP(); + $mail->Host = $emailSettings['mail_host']; + $mail->SMTPAuth = !empty($emailSettings['mail_username']); + $mail->Username = $emailSettings['mail_username']; + $mail->Password = $emailSettings['mail_password']; + $mail->SMTPSecure = $emailSettings['mail_encryption']; + $mail->Port = (int)$emailSettings['mail_port']; + $mail->CharSet = 'UTF-8'; + + $mail->setFrom($emailSettings['mail_from_address'], $emailSettings['mail_from_name']); + $mail->addAddress($email, $fullName); + + $mail->isHTML(true); + $mail->Subject = 'Reset Your Password'; + $mail->Body = " +

Password Reset Request

+

Hello {$fullName},

+

We received a request to reset your password. Click the link below to create a new password:

+

Reset Password

+

Or copy and paste this URL into your browser:

+

{$resetUrl}

+

This link will expire in 1 hour.

+

If you did not request a password reset, please ignore this email and your password will remain unchanged.

+ "; + + $mail->send(); + + } catch (Exception $e) { + // Log error + error_log('Failed to send password reset email: ' . $e->getMessage()); + } + } + + /** * Logout */ public function logout() { - // Destroy session + // Clear remember me token if exists + if (isset($_COOKIE['remember_token'])) { + $token = $_COOKIE['remember_token']; + + try { + $stmt = $this->db->prepare("DELETE FROM remember_tokens WHERE token = ?"); + $stmt->execute([$token]); + } catch (\Exception $e) { + // Silently fail + } + + setcookie('remember_token', '', time() - 3600, '/'); + } + + // Destroy session (DatabaseSessionHandler automatically deletes from DB) session_destroy(); session_start(); @@ -90,4 +727,3 @@ class AuthController extends Controller $this->redirect('/login'); } } - diff --git a/app/Controllers/DashboardController.php b/app/Controllers/DashboardController.php index 56bab97..33c403a 100644 --- a/app/Controllers/DashboardController.php +++ b/app/Controllers/DashboardController.php @@ -39,11 +39,15 @@ class DashboardController extends Controller // Check system status $systemStatus = $this->checkSystemStatus(); + + // Format domains for display + $formattedRecentDomains = \App\Helpers\DomainHelper::formatMultiple($recentDomains); + $formattedExpiringDomains = \App\Helpers\DomainHelper::formatMultiple($expiringThisMonth); $this->view('dashboard/index', [ 'stats' => $stats, - 'recentDomains' => $recentDomains, - 'expiringThisMonth' => $expiringThisMonth, + 'recentDomains' => $formattedRecentDomains, + 'expiringThisMonth' => $formattedExpiringDomains, 'expiringCount' => count($allExpiringDomains), 'recentLogs' => $recentLogs, 'groups' => $groups, diff --git a/app/Controllers/DomainController.php b/app/Controllers/DomainController.php index 296385f..5e4a630 100644 --- a/app/Controllers/DomainController.php +++ b/app/Controllers/DomainController.php @@ -88,9 +88,12 @@ class DomainController extends Controller $paginatedDomains = array_slice($domains, $offset, $perPage); $groups = $this->groupModel->all(); + + // Format domains for display + $formattedDomains = \App\Helpers\DomainHelper::formatMultiple($paginatedDomains); $this->view('domains/index', [ - 'domains' => $paginatedDomains, + 'domains' => $formattedDomains, 'groups' => $groups, 'filters' => [ 'search' => $search, @@ -353,9 +356,25 @@ class DomainController extends Controller $logModel = new \App\Models\NotificationLog(); $logs = $logModel->getByDomain($id, 20); + + // Format domain for display + $formattedDomain = \App\Helpers\DomainHelper::formatForDisplay($domain); + + // Parse WHOIS data for display + $whoisData = json_decode($domain['whois_data'] ?? '{}', true); + if (!empty($whoisData['status']) && is_array($whoisData['status'])) { + $formattedDomain['parsedStatuses'] = \App\Helpers\DomainHelper::parseWhoisStatuses($whoisData['status']); + } else { + $formattedDomain['parsedStatuses'] = []; + } + + // Calculate active channel count + if (!empty($domain['channels'])) { + $formattedDomain['activeChannelCount'] = \App\Helpers\DomainHelper::getActiveChannelCount($domain['channels']); + } $this->view('domains/view', [ - 'domain' => $domain, + 'domain' => $formattedDomain, 'logs' => $logs, 'title' => $domain['domain_name'] ]); diff --git a/app/Controllers/InstallerController.php b/app/Controllers/InstallerController.php new file mode 100644 index 0000000..7aafcd8 --- /dev/null +++ b/app/Controllers/InstallerController.php @@ -0,0 +1,423 @@ +query("SELECT COUNT(*) FROM users"); + return $stmt->fetchColumn() > 0; + } catch (\Exception $e) { + return false; + } + } + + /** + * Check pending migrations + */ + private function getPendingMigrations(): array + { + // For fresh installs - use consolidated schema + $freshInstallMigration = ['000_initial_schema_v1.1.0.sql']; + + // For incremental updates from v1.0.0 + $incrementalMigrations = [ + '001_create_tables.sql', + '002_create_users_table.sql', + '003_add_whois_fields.sql', + '004_create_tld_registry_table.sql', + '005_update_tld_import_logs.sql', + '006_add_complete_workflow_import_type.sql', + '007_add_app_and_email_settings.sql', + '008_add_notes_to_domains.sql', + '009_add_authentication_features.sql', + '010_add_app_version_setting.sql', + '011_create_sessions_table.sql', + '012_link_remember_tokens_to_sessions.sql', + '013_create_user_notifications_table.sql', + ]; + + try { + $pdo = \Core\Database::getConnection(); + + // Check if this is a v1.0.0 install (has tables but no migrations tracking) + $hasUsers = false; + $hasDomains = false; + + try { + $stmt = $pdo->query("SELECT COUNT(*) FROM users"); + $hasUsers = $stmt->fetchColumn() > 0; + + $stmt = $pdo->query("SELECT COUNT(*) FROM domains"); + $hasDomains = true; // Table exists + } catch (\Exception $e) { + // Tables don't exist - fresh install + } + + // Create migrations table if it doesn't exist + $pdo->exec(" + CREATE TABLE IF NOT EXISTS migrations ( + id INT AUTO_INCREMENT PRIMARY KEY, + migration VARCHAR(255) NOT NULL UNIQUE, + executed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + INDEX idx_migration (migration) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + "); + + // Get executed migrations + $stmt = $pdo->query("SELECT migration FROM migrations"); + $executed = $stmt->fetchAll(\PDO::FETCH_COLUMN); + + // If no migrations executed but has data - v1.0.0 upgrade + if (empty($executed) && ($hasUsers || $hasDomains)) { + // Mark 001-008 as executed (v1.0.0 migrations) + $v1Migrations = [ + '001_create_tables.sql', + '002_create_users_table.sql', + '003_add_whois_fields.sql', + '004_create_tld_registry_table.sql', + '005_update_tld_import_logs.sql', + '006_add_complete_workflow_import_type.sql', + '007_add_app_and_email_settings.sql', + '008_add_notes_to_domains.sql' + ]; + + $stmt = $pdo->prepare("INSERT IGNORE INTO migrations (migration) VALUES (?)"); + foreach ($v1Migrations as $migration) { + $stmt->execute([$migration]); + } + + // Return only new migrations for v1.1.0 + return [ + '009_add_authentication_features.sql', + '010_add_app_version_setting.sql', + '011_create_sessions_table.sql', + '012_link_remember_tokens_to_sessions.sql', + '013_create_user_notifications_table.sql' + ]; + } + + // If no migrations executed and no data - fresh install (use consolidated) + if (empty($executed)) { + return $freshInstallMigration; + } + + // If has executed migrations - check for pending incremental ones + return array_diff($incrementalMigrations, $executed); + + } catch (\Exception $e) { + // If critical error - assume fresh install + return $freshInstallMigration; + } + } + + /** + * Show installer welcome page + */ + public function index() + { + if ($this->isInstalled()) { + $pending = $this->getPendingMigrations(); + if (empty($pending)) { + $_SESSION['info'] = 'System is already installed and up to date'; + $this->redirect('/'); + return; + } + // Has pending migrations - show updater + $this->redirect('/install/update'); + return; + } + + $this->view('installer/welcome', [ + 'title' => 'Install Domain Monitor' + ]); + } + + /** + * Check database connection + */ + public function checkDatabase() + { + try { + $pdo = \Core\Database::getConnection(); + $pdo->query("SELECT 1"); + + $this->view('installer/database-check', [ + 'title' => 'Database Connection', + 'success' => true + ]); + } catch (\Exception $e) { + $this->view('installer/database-check', [ + 'title' => 'Database Connection', + 'success' => false, + 'error' => $e->getMessage() + ]); + } + } + + /** + * Run installation + */ + public function install() + { + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + $this->redirect('/install'); + return; + } + + $adminPassword = trim($_POST['admin_password'] ?? ''); + $adminEmail = trim($_POST['admin_email'] ?? ''); + + // Validate + if (empty($adminPassword) || strlen($adminPassword) < 8) { + $_SESSION['error'] = 'Admin password must be at least 8 characters'; + $this->redirect('/install'); + return; + } + + if (empty($adminEmail) || !filter_var($adminEmail, FILTER_VALIDATE_EMAIL)) { + $_SESSION['error'] = 'Please enter a valid admin email'; + $this->redirect('/install'); + return; + } + + try { + $pdo = \Core\Database::getConnection(); + + // Run all migrations + $migrations = $this->getPendingMigrations(); + $results = []; + + foreach ($migrations as $migration) { + $file = __DIR__ . '/../../database/migrations/' . $migration; + if (!file_exists($file)) continue; + + $sql = file_get_contents($file); + + // Replace password placeholder for user migration + if ($migration === '002_create_users_table.sql') { + $passwordHash = password_hash($adminPassword, PASSWORD_BCRYPT); + $sql = str_replace('{{ADMIN_PASSWORD_HASH}}', $passwordHash, $sql); + } + + // Execute SQL + $statements = array_filter(array_map('trim', explode(';', $sql))); + foreach ($statements as $statement) { + if (!empty($statement)) { + try { + $pdo->exec($statement); + } catch (\PDOException $e) { + // Ignore duplicate/already exists errors + if (strpos($e->getMessage(), 'Duplicate') === false && + strpos($e->getMessage(), 'already exists') === false) { + throw $e; + } + } + } + } + + // Mark as executed + $stmt = $pdo->prepare("INSERT INTO migrations (migration) VALUES (?) ON DUPLICATE KEY UPDATE migration=migration"); + $stmt->execute([$migration]); + + $results[] = $migration; + } + + // Update admin email and ensure admin role and verified status + $stmt = $pdo->prepare("UPDATE users SET email = ?, role = 'admin', email_verified = 1 WHERE username = 'admin'"); + $stmt->execute([$adminEmail]); + + // Generate encryption key if not exists + if (empty($_ENV['APP_ENCRYPTION_KEY'])) { + $this->generateEncryptionKey(); + } + + // Create .installed flag file + $installedFile = __DIR__ . '/../../.installed'; + file_put_contents($installedFile, date('Y-m-d H:i:s')); + + // Create welcome notification for admin + try { + // Get the admin user ID + $stmt = $pdo->query("SELECT id FROM users WHERE username = 'admin' LIMIT 1"); + $adminUser = $stmt->fetch(\PDO::FETCH_ASSOC); + + if ($adminUser) { + $notificationService = new \App\Services\NotificationService(); + $notificationService->notifyWelcome($adminUser['id'], 'admin'); + } + } catch (\Exception $e) { + // Don't fail install if notification fails + error_log("Failed to create welcome notification: " . $e->getMessage()); + } + + // Redirect to complete page + $_SESSION['install_complete'] = true; + $_SESSION['admin_password'] = $adminPassword; + $this->redirect('/install/complete'); + + } catch (\Exception $e) { + $_SESSION['error'] = 'Installation failed: ' . $e->getMessage(); + $this->redirect('/install'); + } + } + + /** + * Show update page + */ + public function showUpdate() + { + $pending = $this->getPendingMigrations(); + + if (empty($pending)) { + $_SESSION['info'] = 'No updates available'; + $this->redirect('/'); + return; + } + + $this->view('installer/update', [ + 'title' => 'System Update', + 'migrations' => $pending + ]); + } + + /** + * Run update + */ + public function runUpdate() + { + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + $this->redirect('/install/update'); + return; + } + + try { + $pdo = \Core\Database::getConnection(); + $migrations = $this->getPendingMigrations(); + $executed = []; + + foreach ($migrations as $migration) { + $file = __DIR__ . '/../../database/migrations/' . $migration; + if (!file_exists($file)) continue; + + $sql = file_get_contents($file); + + // Execute SQL + $statements = array_filter(array_map('trim', explode(';', $sql))); + foreach ($statements as $statement) { + if (!empty($statement)) { + try { + $pdo->exec($statement); + } catch (\PDOException $e) { + // Ignore duplicate/already exists errors + if (strpos($e->getMessage(), 'Duplicate') === false && + strpos($e->getMessage(), 'already exists') === false) { + throw $e; + } + } + } + } + + // Mark as executed + $stmt = $pdo->prepare("INSERT INTO migrations (migration) VALUES (?) ON DUPLICATE KEY UPDATE migration=migration"); + $stmt->execute([$migration]); + + $executed[] = $migration; + } + + // Create .installed flag file if doesn't exist (for v1.0.0 upgrades) + $installedFile = __DIR__ . '/../../.installed'; + if (!file_exists($installedFile)) { + file_put_contents($installedFile, date('Y-m-d H:i:s')); + } + + // Notify admins about upgrade (if migrations were executed) + if (!empty($executed)) { + try { + $settingModel = new \App\Models\Setting(); + $currentVersion = $settingModel->getAppVersion(); + + // Determine from/to versions based on migrations + $fromVersion = '1.0.0'; + $toVersion = '1.1.0'; + + // Detect version based on which migrations were run + if (in_array('011_create_sessions_table.sql', $executed) || + in_array('012_link_remember_tokens_to_sessions.sql', $executed) || + in_array('013_create_user_notifications_table.sql', $executed)) { + $toVersion = '1.1.0'; + } + + $notificationService = new \App\Services\NotificationService(); + $notificationService->notifyAdminsUpgrade($fromVersion, $toVersion, count($executed)); + } catch (\Exception $e) { + // Don't fail upgrade if notification fails + error_log("Failed to create upgrade notification: " . $e->getMessage()); + } + } + + $_SESSION['success'] = count($executed) . ' migration(s) executed successfully'; + $this->redirect('/'); + + } catch (\Exception $e) { + $_SESSION['error'] = 'Update failed: ' . $e->getMessage(); + $this->redirect('/install/update'); + } + } + + /** + * Show installation complete page + */ + public function complete() + { + if (!isset($_SESSION['install_complete'])) { + $this->redirect('/'); + return; + } + + $adminPassword = $_SESSION['admin_password'] ?? null; + unset($_SESSION['admin_password']); + unset($_SESSION['install_complete']); + + $this->view('installer/complete', [ + 'title' => 'Installation Complete', + 'adminPassword' => $adminPassword + ]); + } + + /** + * Generate encryption key + */ + private function generateEncryptionKey() + { + $encryptionKey = base64_encode(random_bytes(32)); + $envFile = __DIR__ . '/../../.env'; + + if (file_exists($envFile)) { + $envContent = file_get_contents($envFile); + + if (strpos($envContent, 'APP_ENCRYPTION_KEY=') !== false) { + $envContent = preg_replace( + '/APP_ENCRYPTION_KEY=.*$/m', + "APP_ENCRYPTION_KEY=$encryptionKey", + $envContent + ); + } else { + $envContent .= "\nAPP_ENCRYPTION_KEY=$encryptionKey\n"; + } + + file_put_contents($envFile, $envContent); + } + } +} + diff --git a/app/Controllers/NotificationController.php b/app/Controllers/NotificationController.php new file mode 100644 index 0000000..68df855 --- /dev/null +++ b/app/Controllers/NotificationController.php @@ -0,0 +1,227 @@ +notificationModel = new Notification(); + } + + /** + * Show all notifications page + */ + public function index() + { + $userId = Auth::id(); + + // Get filter parameters + $statusFilter = $_GET['status'] ?? ''; + $typeFilter = $_GET['type'] ?? ''; + $dateRange = $_GET['date_range'] ?? ''; + $perPage = (int)($_GET['per_page'] ?? 10); + $page = max(1, (int)($_GET['page'] ?? 1)); + + // Build filters array + $filters = [ + 'status' => $statusFilter, + 'type' => $typeFilter, + 'date_range' => $dateRange + ]; + + // Count total records + $totalRecords = $this->notificationModel->countForUser($userId, $filters); + + // Calculate pagination + $totalPages = ceil($totalRecords / $perPage); + $page = min($page, max(1, $totalPages)); + $offset = ($page - 1) * $perPage; + $showingFrom = $totalRecords > 0 ? $offset + 1 : 0; + $showingTo = min($offset + $perPage, $totalRecords); + + // Get notifications + $notifications = $this->notificationModel->getForUser($userId, $filters, $perPage, $offset); + + // Get unread count + $unreadCount = $this->notificationModel->getUnreadCount($userId); + + // Format notifications for display + foreach ($notifications as &$notification) { + $notification['time_ago'] = $this->timeAgo($notification['created_at']); + $notification['icon'] = $this->getNotificationIcon($notification['type']); + $notification['color'] = $this->getNotificationColor($notification['type']); + } + + $this->view('notifications/index', [ + 'title' => 'Notifications', + 'notifications' => $notifications, + 'unreadCount' => $unreadCount, + 'filters' => $filters, + 'pagination' => [ + 'current_page' => $page, + 'total_pages' => $totalPages, + 'per_page' => $perPage, + 'total' => $totalRecords, + 'showing_from' => $showingFrom, + 'showing_to' => $showingTo + ] + ]); + } + + /** + * Mark notification as read + */ + public function markAsRead($params = []) + { + $userId = Auth::id(); + $notificationId = (int)($params['id'] ?? 0); + + if ($notificationId <= 0) { + $_SESSION['error'] = 'Invalid notification'; + $this->redirect('/notifications'); + return; + } + + $this->notificationModel->markAsRead($notificationId, $userId); + $_SESSION['success'] = 'Notification marked as read'; + $this->redirect('/notifications'); + } + + /** + * Mark all notifications as read + */ + public function markAllAsRead() + { + $userId = Auth::id(); + $this->notificationModel->markAllAsRead($userId); + $_SESSION['success'] = 'All notifications marked as read'; + $this->redirect('/notifications'); + } + + /** + * Delete notification + */ + public function delete($params = []) + { + $userId = Auth::id(); + $notificationId = (int)($params['id'] ?? 0); + + if ($notificationId <= 0) { + $_SESSION['error'] = 'Invalid notification'; + $this->redirect('/notifications'); + return; + } + + $this->notificationModel->deleteNotification($notificationId, $userId); + $_SESSION['success'] = 'Notification deleted'; + $this->redirect('/notifications'); + } + + /** + * Clear all notifications + */ + public function clearAll() + { + $userId = Auth::id(); + $this->notificationModel->clearAll($userId); + $_SESSION['success'] = 'All notifications cleared'; + $this->redirect('/notifications'); + } + + /** + * Get unread count (for bell icon badge - AJAX) + */ + public function getUnreadCount() + { + $userId = Auth::id(); + $count = $this->notificationModel->getUnreadCount($userId); + $this->json(['count' => $count]); + } + + /** + * Get recent notifications for dropdown (AJAX) + */ + public function getRecent() + { + $userId = Auth::id(); + $notifications = $this->notificationModel->getRecentUnread($userId, 5); + + // Format for display + foreach ($notifications as &$notification) { + $notification['time_ago'] = $this->timeAgo($notification['created_at']); + $notification['icon'] = $this->getNotificationIcon($notification['type']); + $notification['color'] = $this->getNotificationColor($notification['type']); + } + + $this->json([ + 'notifications' => $notifications, + 'unread_count' => count($notifications) + ]); + } + + /** + * Get notification icon based on type + */ + private function getNotificationIcon(string $type): string + { + return match($type) { + 'domain_expiring' => 'exclamation-triangle', + 'domain_expired' => 'times-circle', + 'domain_updated' => 'sync-alt', + 'session_new' => 'sign-in-alt', + 'whois_failed' => 'exclamation-circle', + 'system_welcome' => 'hand-sparkles', + 'system_upgrade' => 'arrow-up', + default => 'bell' + }; + } + + /** + * Get notification color based on type + */ + private function getNotificationColor(string $type): string + { + return match($type) { + 'domain_expiring' => 'orange', + 'domain_expired' => 'red', + 'domain_updated' => 'green', + 'session_new' => 'blue', + 'whois_failed' => 'gray', + 'system_welcome' => 'purple', + 'system_upgrade' => 'indigo', + default => 'gray' + }; + } + + /** + * Convert timestamp to "time ago" format + */ + private function timeAgo(string $datetime): string + { + $timestamp = strtotime($datetime); + $diff = time() - $timestamp; + + if ($diff < 60) { + return 'just now'; + } elseif ($diff < 3600) { + $mins = floor($diff / 60); + return $mins . ' minute' . ($mins > 1 ? 's' : '') . ' ago'; + } elseif ($diff < 86400) { + $hours = floor($diff / 3600); + return $hours . ' hour' . ($hours > 1 ? 's' : '') . ' ago'; + } elseif ($diff < 604800) { + $days = floor($diff / 86400); + return $days . ' day' . ($days > 1 ? 's' : '') . ' ago'; + } else { + return date('M d, Y', $timestamp); + } + } +} + diff --git a/app/Controllers/ProfileController.php b/app/Controllers/ProfileController.php new file mode 100644 index 0000000..d672cbb --- /dev/null +++ b/app/Controllers/ProfileController.php @@ -0,0 +1,328 @@ +userModel = new User(); + $this->sessionModel = new SessionManager(); + $this->rememberTokenModel = new RememberToken(); + } + + /** + * Show profile page + */ + public function index() + { + $userId = Auth::id(); + $user = $this->userModel->find($userId); + + if (!$user) { + $_SESSION['error'] = 'User not found'; + $this->redirect('/'); + return; + } + + // Clean old sessions when user views their profile (perfect time!) + // This happens naturally when users check their sessions + try { + $this->sessionModel->cleanOldSessions(); + } catch (\Exception $e) { + // Silent fail - don't break the page + error_log("Session cleanup failed: " . $e->getMessage()); + } + + // Get all active sessions + $sessions = $this->sessionModel->getByUserId($userId); + + // Mark current session and check for remember tokens + $currentSessionId = session_id(); + foreach ($sessions as &$session) { + $session['is_current'] = ($session['id'] === $currentSessionId); + // Format timestamps for display + $session['last_activity'] = date('Y-m-d H:i:s', $session['last_activity']); + $session['created_at'] = date('Y-m-d H:i:s', $session['created_at']); + + // Check if this session has a remember token + $rememberToken = $this->rememberTokenModel->getBySessionId($session['id']); + $session['has_remember_token'] = !empty($rememberToken); + } + + // Format sessions for display (adds deviceIcon, browserInfo, timeAgo, sessionAge) + $formattedSessions = \App\Helpers\SessionHelper::formatForDisplay($sessions); + + $this->view('profile/index', [ + 'user' => $user, + 'sessions' => $formattedSessions, + 'title' => 'My Profile' + ]); + } + + /** + * Update profile + */ + public function update() + { + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + $this->redirect('/profile'); + return; + } + + $userId = Auth::id(); + $fullName = trim($_POST['full_name'] ?? ''); + $email = trim($_POST['email'] ?? ''); + + // Validate + if (empty($fullName) || empty($email)) { + $_SESSION['error'] = 'Full name and email are required'; + $this->redirect('/profile'); + return; + } + + if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { + $_SESSION['error'] = 'Please enter a valid email address'; + $this->redirect('/profile'); + return; + } + + // Check if email is already taken by another user + $existingUsers = $this->userModel->where('email', $email); + foreach ($existingUsers as $existingUser) { + if ($existingUser['id'] != $userId) { + $_SESSION['error'] = 'Email address is already in use'; + $this->redirect('/profile'); + return; + } + } + + // Update user + $this->userModel->update($userId, [ + 'full_name' => $fullName, + 'email' => $email, + ]); + + // Update session + $_SESSION['full_name'] = $fullName; + $_SESSION['email'] = $email; + + $_SESSION['success'] = 'Profile updated successfully'; + $this->redirect('/profile'); + } + + /** + * Change password + */ + public function changePassword() + { + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + $this->redirect('/profile'); + return; + } + + $userId = Auth::id(); + $currentPassword = $_POST['current_password'] ?? ''; + $newPassword = $_POST['new_password'] ?? ''; + $newPasswordConfirm = $_POST['new_password_confirm'] ?? ''; + + // Validate + if (empty($currentPassword) || empty($newPassword) || empty($newPasswordConfirm)) { + $_SESSION['error'] = 'All fields are required'; + $this->redirect('/profile'); + return; + } + + if (strlen($newPassword) < 8) { + $_SESSION['error'] = 'Password must be at least 8 characters long'; + $this->redirect('/profile'); + return; + } + + if ($newPassword !== $newPasswordConfirm) { + $_SESSION['error'] = 'New passwords do not match'; + $this->redirect('/profile'); + return; + } + + // Get user + $user = $this->userModel->find($userId); + + // Verify current password + if (!$this->userModel->verifyPassword($currentPassword, $user['password'])) { + $_SESSION['error'] = 'Current password is incorrect'; + $this->redirect('/profile'); + return; + } + + // Update password + $this->userModel->changePassword($userId, $newPassword); + + $_SESSION['success'] = 'Password changed successfully'; + $this->redirect('/profile'); + } + + /** + * Delete account + */ + public function delete() + { + $userId = Auth::id(); + $user = $this->userModel->find($userId); + + // Don't allow admins to delete their own account + if ($user['role'] === 'admin') { + $_SESSION['error'] = 'Admin accounts cannot be deleted'; + $this->redirect('/profile'); + return; + } + + // Delete user (cascade will handle related records) + $this->userModel->delete($userId); + + // Logout + session_destroy(); + session_start(); + + $_SESSION['success'] = 'Your account has been deleted'; + $this->redirect('/login'); + } + + /** + * Resend email verification + */ + public function resendVerification() + { + $userId = Auth::id(); + $user = $this->userModel->find($userId); + + if ($user['email_verified']) { + $_SESSION['info'] = 'Your email is already verified'; + $this->redirect('/profile'); + return; + } + + // Use AuthController logic + $authController = new AuthController(); + + $_SESSION['pending_verification_email'] = $user['email']; + $_SESSION['success'] = 'Verification email sent! Please check your inbox.'; + + $this->redirect('/profile'); + } + + /** + * Logout other sessions (actually terminates them!) + */ + public function logoutOtherSessions() + { + $userId = Auth::id(); + $currentSessionId = session_id(); + + if (!$currentSessionId) { + $_SESSION['error'] = 'No active session found'; + $this->redirect('/profile'); + return; + } + + try { + // Get all other sessions first to delete their remember tokens + $allSessions = $this->sessionModel->getByUserId($userId); + $deletedTokens = 0; + foreach ($allSessions as $session) { + if ($session['id'] !== $currentSessionId) { + $deletedTokens += $this->rememberTokenModel->deleteBySessionId($session['id']); + } + } + + // Delete all other sessions (this actually logs them out!) + $count = $this->sessionModel->deleteOtherSessions($userId, $currentSessionId); + + // Perfect time to clean all old sessions (user is security-conscious) + $this->sessionModel->cleanOldSessions(); + + $message = "Terminated {$count} other session(s) - those devices are now logged out"; + if ($deletedTokens > 0) { + $message .= " ({$deletedTokens} remember tokens removed)"; + } + $_SESSION['success'] = $message; + } catch (\Exception $e) { + $_SESSION['error'] = 'Failed to terminate other sessions'; + } + + $this->redirect('/profile#sessions'); + } + + /** + * Logout specific session (actually terminates it!) + */ + public function logoutSession($params = []) + { + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + $this->redirect('/profile'); + return; + } + + $sessionId = $params['sessionId'] ?? ''; + $userId = Auth::id(); + $currentSessionId = session_id(); + + if (empty($sessionId)) { + $_SESSION['error'] = 'Invalid session'; + $this->redirect('/profile'); + return; + } + + try { + // Get the session to verify ownership + $session = $this->sessionModel->getById($sessionId); + + if (!$session) { + $_SESSION['error'] = 'Session not found'; + $this->redirect('/profile'); + return; + } + + // Verify session belongs to current user + if ($session['user_id'] != $userId) { + $_SESSION['error'] = 'Unauthorized action'; + $this->redirect('/profile'); + return; + } + + // Prevent deleting current session + if ($session['id'] === $currentSessionId) { + $_SESSION['error'] = 'Cannot delete your current session. Use logout instead.'; + $this->redirect('/profile'); + return; + } + + // Delete the session (this actually logs out that device!) + $this->sessionModel->deleteById($sessionId); + + // Also delete any remember token associated with this session + $deletedTokens = $this->rememberTokenModel->deleteBySessionId($sessionId); + + $message = 'Session terminated - that device is now logged out'; + if ($deletedTokens > 0) { + $message .= ' (remember me disabled)'; + } + $_SESSION['success'] = $message; + + } catch (\Exception $e) { + $_SESSION['error'] = 'Failed to terminate session'; + } + + $this->redirect('/profile#sessions'); + } +} diff --git a/app/Controllers/SearchController.php b/app/Controllers/SearchController.php index c3274ad..b74de19 100644 --- a/app/Controllers/SearchController.php +++ b/app/Controllers/SearchController.php @@ -58,9 +58,12 @@ class SearchController extends Controller } } + // Format existing domains for display + $formattedDomains = \App\Helpers\DomainHelper::formatMultiple($existingDomains); + $this->view('search/results', [ 'query' => $query, - 'existingDomains' => $existingDomains, + 'existingDomains' => $formattedDomains, 'whoisData' => $whoisData, 'whoisError' => $whoisError, 'isDomainLike' => $isDomainLike, diff --git a/app/Controllers/SettingsController.php b/app/Controllers/SettingsController.php index 5ad8ab6..5a40747 100644 --- a/app/Controllers/SettingsController.php +++ b/app/Controllers/SettingsController.php @@ -12,6 +12,13 @@ class SettingsController extends Controller public function __construct() { $this->settingModel = new Setting(); + + // Ensure only admins can access settings + if (!isset($_SESSION['role']) || $_SESSION['role'] !== 'admin') { + $_SESSION['error'] = 'Access denied. Admin privileges required.'; + $this->redirect('/'); + exit; + } } public function index() @@ -204,7 +211,16 @@ class SettingsController extends Controller return; } + // Update app settings $this->settingModel->updateAppSettings($appSettings); + + // Update registration settings + $registrationEnabled = isset($_POST['registration_enabled']) ? '1' : '0'; + $requireEmailVerification = isset($_POST['require_email_verification']) ? '1' : '0'; + + $this->settingModel->setValue('registration_enabled', $registrationEnabled); + $this->settingModel->setValue('require_email_verification', $requireEmailVerification); + $_SESSION['success'] = 'Application settings updated successfully'; $this->redirect('/settings#app'); diff --git a/app/Controllers/TldRegistryController.php b/app/Controllers/TldRegistryController.php index 3a0ce0d..f77f185 100644 --- a/app/Controllers/TldRegistryController.php +++ b/app/Controllers/TldRegistryController.php @@ -19,6 +19,18 @@ class TldRegistryController extends Controller $this->importLogModel = new TldImportLog(); $this->tldService = new TldRegistryService(); } + + /** + * Check if current user is admin + */ + private function requireAdmin() + { + if (!isset($_SESSION['role']) || $_SESSION['role'] !== 'admin') { + $_SESSION['error'] = 'Access denied. Admin privileges required.'; + $this->redirect('/tld-registry'); + exit; + } + } /** * Display TLD registry dashboard @@ -76,6 +88,8 @@ class TldRegistryController extends Controller */ public function importTldList() { + $this->requireAdmin(); + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { $this->redirect('/tld-registry'); return; @@ -109,6 +123,8 @@ class TldRegistryController extends Controller */ public function importRdap() { + $this->requireAdmin(); + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { $this->redirect('/tld-registry'); return; @@ -142,6 +158,8 @@ class TldRegistryController extends Controller */ public function importWhois() { + $this->requireAdmin(); + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { $this->redirect('/tld-registry'); return; @@ -179,6 +197,8 @@ class TldRegistryController extends Controller */ public function checkUpdates() { + $this->requireAdmin(); + try { $updateInfo = $this->tldService->checkForUpdates(); @@ -219,6 +239,8 @@ class TldRegistryController extends Controller */ public function startProgressiveImport() { + $this->requireAdmin(); + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { $this->redirect('/tld-registry'); return; @@ -312,6 +334,8 @@ class TldRegistryController extends Controller */ public function bulkDelete() { + $this->requireAdmin(); + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { $this->redirect('/tld-registry'); return; @@ -347,6 +371,8 @@ class TldRegistryController extends Controller */ public function toggleActive($params = []) { + $this->requireAdmin(); + $id = $params['id'] ?? 0; $tld = $this->tldModel->find($id); @@ -369,6 +395,8 @@ class TldRegistryController extends Controller */ public function refresh($params = []) { + $this->requireAdmin(); + $id = $params['id'] ?? 0; $tld = $this->tldModel->find($id); diff --git a/app/Controllers/UserController.php b/app/Controllers/UserController.php new file mode 100644 index 0000000..cb1a8c9 --- /dev/null +++ b/app/Controllers/UserController.php @@ -0,0 +1,352 @@ +userModel = new User(); + + // Ensure only admins can access user management + if (!isset($_SESSION['role']) || $_SESSION['role'] !== 'admin') { + $_SESSION['error'] = 'Access denied. Admin privileges required.'; + $this->redirect('/'); + exit; + } + } + + /** + * List all users + */ + public function index() + { + // Get filter parameters + $search = trim($_GET['search'] ?? ''); + $roleFilter = $_GET['role'] ?? ''; + $statusFilter = $_GET['status'] ?? ''; + $sort = $_GET['sort'] ?? 'username'; + $order = $_GET['order'] ?? 'asc'; + $perPage = (int)($_GET['per_page'] ?? 25); + $page = max(1, (int)($_GET['page'] ?? 1)); + + // Build filters array + $filters = [ + 'search' => $search, + 'role' => $roleFilter, + 'status' => $statusFilter + ]; + + // Count total records + $totalRecords = $this->userModel->countFiltered($filters); + + // Calculate pagination + $totalPages = ceil($totalRecords / $perPage); + $page = min($page, max(1, $totalPages)); // Ensure page is within bounds + $offset = ($page - 1) * $perPage; + $showingFrom = $totalRecords > 0 ? $offset + 1 : 0; + $showingTo = min($offset + $perPage, $totalRecords); + + // Get filtered users + $users = $this->userModel->getFiltered($filters, $sort, strtoupper($order), $perPage, $offset); + + $this->view('users/index', [ + 'users' => $users, + 'title' => 'User Management', + 'filters' => [ + 'search' => $search, + 'role' => $roleFilter, + 'status' => $statusFilter, + 'sort' => $sort, + 'order' => strtolower($order) + ], + 'pagination' => [ + 'current_page' => $page, + 'total_pages' => $totalPages, + 'per_page' => $perPage, + 'total' => $totalRecords, + 'showing_from' => $showingFrom, + 'showing_to' => $showingTo + ] + ]); + } + + /** + * Show create user form + */ + public function create() + { + $this->view('users/create', [ + 'title' => 'Create User' + ]); + } + + /** + * Store new user + */ + public function store() + { + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + $this->redirect('/users'); + return; + } + + $username = trim($_POST['username'] ?? ''); + $email = trim($_POST['email'] ?? ''); + $fullName = trim($_POST['full_name'] ?? ''); + $password = $_POST['password'] ?? ''; + $passwordConfirm = $_POST['password_confirm'] ?? ''; + $role = $_POST['role'] ?? 'user'; + + // Validation + if (empty($username) || empty($email) || empty($fullName) || empty($password)) { + $_SESSION['error'] = 'All fields are required'; + $this->redirect('/users/create'); + return; + } + + if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { + $_SESSION['error'] = 'Invalid email address'; + $this->redirect('/users/create'); + return; + } + + if (!preg_match('/^[a-zA-Z0-9_]+$/', $username)) { + $_SESSION['error'] = 'Username can only contain letters, numbers, and underscores'; + $this->redirect('/users/create'); + return; + } + + if (strlen($password) < 8) { + $_SESSION['error'] = 'Password must be at least 8 characters'; + $this->redirect('/users/create'); + return; + } + + if ($password !== $passwordConfirm) { + $_SESSION['error'] = 'Passwords do not match'; + $this->redirect('/users/create'); + return; + } + + // Check if username exists + if ($this->userModel->findByUsername($username)) { + $_SESSION['error'] = 'Username already exists'; + $this->redirect('/users/create'); + return; + } + + // Check if email exists + if (!empty($this->userModel->where('email', $email))) { + $_SESSION['error'] = 'Email already exists'; + $this->redirect('/users/create'); + return; + } + + try { + $userId = $this->userModel->createUser($username, $password, $email, $fullName); + + // Update role if not default + if ($role !== 'user') { + $this->userModel->update($userId, ['role' => $role]); + } + + // Mark as verified by default (admin created) + $this->userModel->update($userId, ['email_verified' => 1]); + + // Create welcome notification + try { + $notificationService = new \App\Services\NotificationService(); + $notificationService->notifyWelcome($userId, $username); + } catch (\Exception $e) { + // Don't fail user creation if notification fails + error_log("Failed to create welcome notification: " . $e->getMessage()); + } + + $_SESSION['success'] = 'User created successfully'; + $this->redirect('/users'); + + } catch (\Exception $e) { + $_SESSION['error'] = 'Failed to create user: ' . $e->getMessage(); + $this->redirect('/users/create'); + } + } + + /** + * Show edit user form + */ + public function edit() + { + $userId = (int)($_GET['id'] ?? 0); + $user = $this->userModel->find($userId); + + if (!$user) { + $_SESSION['error'] = 'User not found'; + $this->redirect('/users'); + return; + } + + $this->view('users/edit', [ + 'user' => $user, + 'title' => 'Edit User' + ]); + } + + /** + * Update user + */ + public function update() + { + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + $this->redirect('/users'); + return; + } + + $userId = (int)($_POST['id'] ?? 0); + $user = $this->userModel->find($userId); + + if (!$user) { + $_SESSION['error'] = 'User not found'; + $this->redirect('/users'); + return; + } + + $email = trim($_POST['email'] ?? ''); + $fullName = trim($_POST['full_name'] ?? ''); + $role = $_POST['role'] ?? 'user'; + $isActive = isset($_POST['is_active']) ? 1 : 0; + $password = $_POST['password'] ?? ''; + + // Validation + if (empty($email) || empty($fullName)) { + $_SESSION['error'] = 'Email and full name are required'; + $this->redirect('/users/edit?id=' . $userId); + return; + } + + if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { + $_SESSION['error'] = 'Invalid email address'; + $this->redirect('/users/edit?id=' . $userId); + return; + } + + // Check if email is taken by another user + $existingUsers = $this->userModel->where('email', $email); + if (!empty($existingUsers) && $existingUsers[0]['id'] != $userId) { + $_SESSION['error'] = 'Email already in use by another user'; + $this->redirect('/users/edit?id=' . $userId); + return; + } + + try { + $updateData = [ + 'email' => $email, + 'full_name' => $fullName, + 'role' => $role, + 'is_active' => $isActive + ]; + + $this->userModel->update($userId, $updateData); + + // Update password if provided + if (!empty($password)) { + if (strlen($password) < 8) { + $_SESSION['error'] = 'Password must be at least 8 characters'; + $this->redirect('/users/edit?id=' . $userId); + return; + } + $this->userModel->changePassword($userId, $password); + } + + $_SESSION['success'] = 'User updated successfully'; + $this->redirect('/users'); + + } catch (\Exception $e) { + $_SESSION['error'] = 'Failed to update user: ' . $e->getMessage(); + $this->redirect('/users/edit?id=' . $userId); + } + } + + /** + * Delete user + */ + public function delete() + { + $userId = (int)($_GET['id'] ?? 0); + $user = $this->userModel->find($userId); + + if (!$user) { + $_SESSION['error'] = 'User not found'; + $this->redirect('/users'); + return; + } + + // Prevent deleting yourself + if ($userId == Auth::id()) { + $_SESSION['error'] = 'You cannot delete your own account'; + $this->redirect('/users'); + return; + } + + // Prevent deleting the last admin + if ($user['role'] === 'admin') { + $allAdmins = $this->userModel->where('role', 'admin'); + if (count($allAdmins) <= 1) { + $_SESSION['error'] = 'Cannot delete the last admin user'; + $this->redirect('/users'); + return; + } + } + + try { + $this->userModel->delete($userId); + $_SESSION['success'] = 'User deleted successfully'; + $this->redirect('/users'); + + } catch (\Exception $e) { + $_SESSION['error'] = 'Failed to delete user: ' . $e->getMessage(); + $this->redirect('/users'); + } + } + + /** + * Toggle user active status + */ + public function toggleStatus() + { + $userId = (int)($_GET['id'] ?? 0); + $user = $this->userModel->find($userId); + + if (!$user) { + $_SESSION['error'] = 'User not found'; + $this->redirect('/users'); + return; + } + + // Prevent deactivating yourself + if ($userId == Auth::id()) { + $_SESSION['error'] = 'You cannot deactivate your own account'; + $this->redirect('/users'); + return; + } + + try { + $newStatus = $user['is_active'] ? 0 : 1; + $this->userModel->update($userId, ['is_active' => $newStatus]); + + $_SESSION['success'] = 'User status updated successfully'; + $this->redirect('/users'); + + } catch (\Exception $e) { + $_SESSION['error'] = 'Failed to update user status: ' . $e->getMessage(); + $this->redirect('/users'); + } + } +} + diff --git a/app/Helpers/DomainHelper.php b/app/Helpers/DomainHelper.php new file mode 100644 index 0000000..ac39278 --- /dev/null +++ b/app/Helpers/DomainHelper.php @@ -0,0 +1,196 @@ += 0) { + return [ + 'class' => 'bg-orange-100 text-orange-700 border-orange-200', + 'text' => 'Expiring Soon', + 'icon' => 'fa-exclamation-triangle' + ]; + } + + return match($status) { + 'available' => [ + 'class' => 'bg-blue-100 text-blue-700 border-blue-200', + 'text' => 'Available', + 'icon' => 'fa-info-circle' + ], + 'active' => [ + 'class' => 'bg-green-100 text-green-700 border-green-200', + 'text' => 'Active', + 'icon' => 'fa-check-circle' + ], + 'expired' => [ + 'class' => 'bg-red-100 text-red-700 border-red-200', + 'text' => 'Expired', + 'icon' => 'fa-times-circle' + ], + 'error' => [ + 'class' => 'bg-gray-100 text-gray-700 border-gray-200', + 'text' => 'Error', + 'icon' => 'fa-exclamation-circle' + ], + default => [ + 'class' => 'bg-gray-100 text-gray-700 border-gray-200', + 'text' => ucfirst($status), + 'icon' => 'fa-question-circle' + ] + }; + } + + /** + * Format multiple domains for display + */ + public static function formatMultiple(array $domains): array + { + return array_map([self::class, 'formatForDisplay'], $domains); + } + + /** + * Parse and clean WHOIS status array + */ + public static function parseWhoisStatuses(array $statusArray): array + { + $validStatuses = []; + + foreach ($statusArray as $status) { + $cleanStatus = trim($status); + + // Skip if it's just a URL or starts with http/https or // + if (empty($cleanStatus) || + strpos($cleanStatus, 'http') === 0 || + strpos($cleanStatus, '//') === 0 || + strpos($cleanStatus, 'www.') === 0) { + continue; + } + + $validStatuses[] = $cleanStatus; + } + + return $validStatuses; + } + + /** + * Convert status to readable format + * Handles camelCase, underscores, etc. + */ + public static function formatStatusText(string $status): string + { + // Convert camelCase to readable format (e.g., "clientTransferProhibited" -> "client Transfer Prohibited") + $readable = preg_replace('/([a-z])([A-Z])/', '$1 $2', $status); + + // Convert underscores to spaces and capitalize words + $readable = str_replace('_', ' ', $readable); + $readable = ucwords(strtolower($readable)); + + return $readable; + } + + /** + * Get active channel count from domain channels + */ + public static function getActiveChannelCount(array $channels): int + { + return count(array_filter($channels, fn($ch) => $ch['is_active'])); + } +} + diff --git a/app/Helpers/LayoutHelper.php b/app/Helpers/LayoutHelper.php new file mode 100644 index 0000000..a674507 --- /dev/null +++ b/app/Helpers/LayoutHelper.php @@ -0,0 +1,167 @@ +getRecentUnread($userId, 4); + $unreadCount = $notificationModel->getUnreadCount($userId); + + // Format each notification + foreach ($notifications as &$notif) { + $notif['time_ago'] = self::timeAgo($notif['created_at']); + $notif['icon'] = self::getNotificationIcon($notif['type']); + $notif['color'] = self::getNotificationColor($notif['type']); + } + + return [ + 'items' => $notifications, + 'unread_count' => $unreadCount + ]; + } catch (\Exception $e) { + // If table doesn't exist yet + return ['items' => [], 'unread_count' => 0]; + } + } + + /** + * Get global stats for sidebar + */ + public static function getGlobalStats(): array + { + try { + $pdo = \Core\Database::getConnection(); + + // Get total domains + $totalStmt = $pdo->query("SELECT COUNT(*) as count FROM domains"); + $total = $totalStmt->fetch(\PDO::FETCH_ASSOC)['count'] ?? 0; + + // Get active domains + $activeStmt = $pdo->query("SELECT COUNT(*) as count FROM domains WHERE is_active = 1"); + $active = $activeStmt->fetch(\PDO::FETCH_ASSOC)['count'] ?? 0; + + // Get expiring soon + $settingModel = new Setting(); + $notificationDays = $settingModel->getNotificationDays(); + $threshold = !empty($notificationDays) ? max($notificationDays) : 30; + + $expiringSoonStmt = $pdo->prepare( + "SELECT COUNT(*) as count FROM domains + WHERE is_active = 1 + AND expiration_date IS NOT NULL + AND expiration_date <= DATE_ADD(NOW(), INTERVAL ? DAY) + AND expiration_date >= NOW()" + ); + $expiringSoonStmt->execute([$threshold]); + $expiringSoon = $expiringSoonStmt->fetch(\PDO::FETCH_ASSOC)['count'] ?? 0; + + return [ + 'total' => $total, + 'active' => $active, + 'expiring_soon' => $expiringSoon, + 'expiring_threshold' => $threshold + ]; + } catch (\Exception $e) { + return [ + 'total' => 0, + 'active' => 0, + 'expiring_soon' => 0, + 'expiring_threshold' => 30 + ]; + } + } + + /** + * Convert timestamp to "time ago" format + */ + private static function timeAgo(string $datetime): string + { + $timestamp = strtotime($datetime); + $diff = time() - $timestamp; + + if ($diff < 60) return 'just now'; + + if ($diff < 3600) { + $mins = floor($diff / 60); + return $mins . ' min' . ($mins > 1 ? 's' : '') . ' ago'; + } + + if ($diff < 86400) { + $hours = floor($diff / 3600); + return $hours . ' hour' . ($hours > 1 ? 's' : '') . ' ago'; + } + + $days = floor($diff / 86400); + return $days . ' day' . ($days > 1 ? 's' : '') . ' ago'; + } + + /** + * Get notification icon based on type + */ + private static function getNotificationIcon(string $type): string + { + return match($type) { + 'domain_expiring' => 'exclamation-triangle', + 'domain_expired' => 'times-circle', + 'domain_updated' => 'sync-alt', + 'session_new' => 'sign-in-alt', + 'whois_failed' => 'exclamation-circle', + 'system_welcome' => 'hand-sparkles', + 'system_upgrade' => 'arrow-up', + default => 'bell' + }; + } + + /** + * Get notification color based on type + */ + private static function getNotificationColor(string $type): string + { + return match($type) { + 'domain_expiring' => 'orange', + 'domain_expired' => 'red', + 'domain_updated' => 'green', + 'session_new' => 'blue', + 'whois_failed' => 'gray', + 'system_welcome' => 'purple', + 'system_upgrade' => 'indigo', + default => 'gray' + }; + } + + /** + * Get application settings + */ + public static function getAppSettings(): array + { + try { + $settingModel = new Setting(); + $appSettings = $settingModel->getAppSettings(); + + return [ + 'app_name' => htmlspecialchars($appSettings['app_name']), + 'app_timezone' => $appSettings['app_timezone'], + 'app_version' => $appSettings['app_version'] + ]; + } catch (\Exception $e) { + // Fallback defaults + $settingModel = new Setting(); + return [ + 'app_name' => 'Domain Monitor', + 'app_timezone' => 'UTC', + 'app_version' => $settingModel->getAppVersion() + ]; + } + } +} + diff --git a/app/Helpers/SessionHelper.php b/app/Helpers/SessionHelper.php new file mode 100644 index 0000000..eb6aeac --- /dev/null +++ b/app/Helpers/SessionHelper.php @@ -0,0 +1,67 @@ += DATE_SUB(NOW(), INTERVAL 7 DAY)"; + break; + case 'month': + $query .= " AND created_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)"; + break; + } + } + + // Order by newest first + $query .= " ORDER BY created_at DESC"; + + // Apply pagination + $query .= " LIMIT ? OFFSET ?"; + $params[] = $limit; + $params[] = $offset; + + $stmt = $this->db->prepare($query); + $stmt->execute($params); + return $stmt->fetchAll(\PDO::FETCH_ASSOC); + } + + /** + * Count notifications for a user with filters + */ + public function countForUser(int $userId, array $filters = []): int + { + $query = "SELECT COUNT(*) as total FROM user_notifications WHERE user_id = ?"; + $params = [$userId]; + + // Apply status filter + if (isset($filters['status']) && $filters['status'] !== '') { + if ($filters['status'] === 'unread') { + $query .= " AND is_read = 0"; + } elseif ($filters['status'] === 'read') { + $query .= " AND is_read = 1"; + } + } + + // Apply type filter + if (!empty($filters['type'])) { + $query .= " AND type = ?"; + $params[] = $filters['type']; + } + + // Apply date range filter + if (!empty($filters['date_range'])) { + switch ($filters['date_range']) { + case 'today': + $query .= " AND DATE(created_at) = CURDATE()"; + break; + case 'week': + $query .= " AND created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY)"; + break; + case 'month': + $query .= " AND created_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)"; + break; + } + } + + $stmt = $this->db->prepare($query); + $stmt->execute($params); + return (int)$stmt->fetch(\PDO::FETCH_ASSOC)['total']; + } + + /** + * Get unread count for a user + */ + public function getUnreadCount(int $userId): int + { + $stmt = $this->db->prepare("SELECT COUNT(*) as count FROM user_notifications WHERE user_id = ? AND is_read = 0"); + $stmt->execute([$userId]); + return (int)$stmt->fetch(\PDO::FETCH_ASSOC)['count']; + } + + /** + * Mark notification as read + */ + public function markAsRead(int $notificationId, int $userId): bool + { + $stmt = $this->db->prepare("UPDATE user_notifications SET is_read = 1, read_at = NOW() WHERE id = ? AND user_id = ?"); + return $stmt->execute([$notificationId, $userId]); + } + + /** + * Mark all notifications as read for a user + */ + public function markAllAsRead(int $userId): bool + { + $stmt = $this->db->prepare("UPDATE user_notifications SET is_read = 1, read_at = NOW() WHERE user_id = ? AND is_read = 0"); + return $stmt->execute([$userId]); + } + + /** + * Delete notification + */ + public function deleteNotification(int $notificationId, int $userId): bool + { + $stmt = $this->db->prepare("DELETE FROM user_notifications WHERE id = ? AND user_id = ?"); + return $stmt->execute([$notificationId, $userId]); + } + + /** + * Clear all notifications for a user + */ + public function clearAll(int $userId): bool + { + $stmt = $this->db->prepare("DELETE FROM user_notifications WHERE user_id = ?"); + return $stmt->execute([$userId]); + } + + /** + * Create a new notification + */ + public function createNotification(int $userId, string $type, string $title, string $message, ?int $domainId = null): int + { + return $this->create([ + 'user_id' => $userId, + 'type' => $type, + 'title' => $title, + 'message' => $message, + 'domain_id' => $domainId + ]); + } + + /** + * Get recent unread notifications for dropdown (limit 5) + */ + public function getRecentUnread(int $userId, int $limit = 5): array + { + $stmt = $this->db->prepare( + "SELECT * FROM user_notifications + WHERE user_id = ? AND is_read = 0 + ORDER BY created_at DESC + LIMIT ?" + ); + $stmt->execute([$userId, $limit]); + return $stmt->fetchAll(\PDO::FETCH_ASSOC); + } +} + diff --git a/app/Models/RememberToken.php b/app/Models/RememberToken.php new file mode 100644 index 0000000..1f2155d --- /dev/null +++ b/app/Models/RememberToken.php @@ -0,0 +1,42 @@ +db->prepare("DELETE FROM remember_tokens WHERE session_id = ?"); + $stmt->execute([$sessionId]); + return $stmt->rowCount(); + } + + /** + * Get remember token by session ID + */ + public function getBySessionId(string $sessionId): ?array + { + $stmt = $this->db->prepare("SELECT * FROM remember_tokens WHERE session_id = ?"); + $stmt->execute([$sessionId]); + $result = $stmt->fetch(); + return $result ?: null; + } + + /** + * Clean old expired tokens + */ + public function cleanExpired(): int + { + $stmt = $this->db->query("DELETE FROM remember_tokens WHERE expires_at < NOW()"); + return $stmt->rowCount(); + } +} + diff --git a/app/Models/SessionManager.php b/app/Models/SessionManager.php new file mode 100644 index 0000000..3a5688f --- /dev/null +++ b/app/Models/SessionManager.php @@ -0,0 +1,188 @@ +db->prepare( + "SELECT + id, + user_id, + ip_address, + user_agent, + country, + country_code, + region, + city, + isp, + timezone, + last_activity, + created_at + FROM sessions + WHERE user_id = ? + ORDER BY last_activity DESC" + ); + $stmt->execute([$userId]); + return $stmt->fetchAll(); + } + + /** + * Get session by ID + */ + public function getById(string $sessionId): ?array + { + $stmt = $this->db->prepare( + "SELECT + id, + user_id, + ip_address, + user_agent, + country, + country_code, + region, + city, + isp, + timezone, + last_activity, + created_at + FROM sessions + WHERE id = ?" + ); + $stmt->execute([$sessionId]); + $result = $stmt->fetch(); + return $result ?: null; + } + + /** + * Get geolocation data from IP address + * (Moved from old Session model) + */ + public static function getGeolocationData(string $ipAddress): array + { + // Skip for localhost/private IPs + if (in_array($ipAddress, ['127.0.0.1', '::1', 'localhost']) || + preg_match('/^(10|172\.16|192\.168)\./', $ipAddress)) { + return [ + 'country' => 'Local', + 'country_code' => 'xx', + 'region' => 'Local', + 'city' => 'Local', + 'isp' => 'Local Network', + 'timezone' => date_default_timezone_get(), + ]; + } + + try { + // Using ip-api.com (free, no API key needed, 45 requests/minute) + $url = "http://ip-api.com/json/{$ipAddress}?fields=status,country,countryCode,region,city,isp,timezone"; + + $context = stream_context_create([ + 'http' => [ + 'timeout' => 3, + 'user_agent' => 'Domain Monitor/1.0' + ] + ]); + + $response = @file_get_contents($url, false, $context); + + if ($response === false) { + return self::getDefaultGeolocation(); + } + + $data = json_decode($response, true); + + if (!$data || $data['status'] !== 'success') { + return self::getDefaultGeolocation(); + } + + return [ + 'country' => $data['country'] ?? 'Unknown', + 'country_code' => strtolower($data['countryCode'] ?? 'xx'), + 'region' => $data['region'] ?? 'Unknown', + 'city' => $data['city'] ?? 'Unknown', + 'isp' => $data['isp'] ?? 'Unknown ISP', + 'timezone' => $data['timezone'] ?? date_default_timezone_get(), + ]; + + } catch (\Exception $e) { + error_log("Geolocation lookup failed: " . $e->getMessage()); + return self::getDefaultGeolocation(); + } + } + + /** + * Get default geolocation data for fallback + */ + private static function getDefaultGeolocation(): array + { + return [ + 'country' => 'Unknown', + 'country_code' => 'xx', + 'region' => 'Unknown', + 'city' => 'Unknown', + 'isp' => 'Unknown ISP', + 'timezone' => date_default_timezone_get(), + ]; + } + + /** + * Delete session by ID (this actually logs out the user!) + */ + public function deleteById(string $sessionId): bool + { + $stmt = $this->db->prepare("DELETE FROM sessions WHERE id = ?"); + return $stmt->execute([$sessionId]); + } + + /** + * Delete all sessions for user except current + */ + public function deleteOtherSessions(int $userId, string $currentSessionId): int + { + $stmt = $this->db->prepare( + "DELETE FROM sessions WHERE user_id = ? AND id != ?" + ); + $stmt->execute([$userId, $currentSessionId]); + return $stmt->rowCount(); + } + + /** + * Delete all sessions for user + */ + public function deleteAllUserSessions(int $userId): int + { + $stmt = $this->db->prepare("DELETE FROM sessions WHERE user_id = ?"); + $stmt->execute([$userId]); + return $stmt->rowCount(); + } + + /** + * Clean old sessions (older than session lifetime) + */ + public function cleanOldSessions(int $lifetimeMinutes = 1440): int + { + $cutoff = time() - ($lifetimeMinutes * 60); + + $stmt = $this->db->prepare( + "DELETE FROM sessions WHERE last_activity < ?" + ); + $stmt->execute([$cutoff]); + return $stmt->rowCount(); + } +} + diff --git a/app/Models/Setting.php b/app/Models/Setting.php index 6f2f8c2..a35a9af 100644 --- a/app/Models/Setting.php +++ b/app/Models/Setting.php @@ -109,6 +109,14 @@ class Setting extends Model return $this->setValue('last_check_run', date('Y-m-d H:i:s')); } + /** + * Get application version + */ + public function getAppVersion(): string + { + return $this->getValue('app_version', '1.1.0'); + } + /** * Get application settings */ @@ -117,7 +125,8 @@ class Setting extends Model return [ 'app_name' => $this->getValue('app_name', 'Domain Monitor'), 'app_url' => $this->getValue('app_url', 'http://localhost:8000'), - 'app_timezone' => $this->getValue('app_timezone', 'UTC') + 'app_timezone' => $this->getValue('app_timezone', 'UTC'), + 'app_version' => $this->getAppVersion() ]; } diff --git a/app/Models/User.php b/app/Models/User.php index b9e2c2e..a1b2f94 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -61,5 +61,88 @@ class User extends Model $stmt = $this->db->prepare("UPDATE users SET password = ? WHERE id = ?"); return $stmt->execute([$hashedPassword, $userId]); } + + /** + * Get users with filters, sorting, and pagination + */ + public function getFiltered(array $filters = [], string $sort = 'username', string $order = 'ASC', int $limit = 25, int $offset = 0): array + { + $query = "SELECT * FROM users WHERE 1=1"; + $params = []; + + // Apply search filter + if (!empty($filters['search'])) { + $query .= " AND (username LIKE ? OR email LIKE ? OR full_name LIKE ?)"; + $searchTerm = "%{$filters['search']}%"; + $params[] = $searchTerm; + $params[] = $searchTerm; + $params[] = $searchTerm; + } + + // Apply role filter + if (!empty($filters['role'])) { + $query .= " AND role = ?"; + $params[] = $filters['role']; + } + + // Apply status filter + if (isset($filters['status']) && $filters['status'] !== '') { + $isActive = ($filters['status'] === 'active') ? 1 : 0; + $query .= " AND is_active = ?"; + $params[] = $isActive; + } + + // Apply sorting + $allowedSortColumns = ['username', 'email', 'full_name', 'role', 'is_active', 'email_verified', 'last_login', 'created_at']; + if (!in_array($sort, $allowedSortColumns)) { + $sort = 'username'; + } + $order = strtoupper($order) === 'DESC' ? 'DESC' : 'ASC'; + $query .= " ORDER BY {$sort} {$order}"; + + // Apply pagination + $query .= " LIMIT ? OFFSET ?"; + $params[] = $limit; + $params[] = $offset; + + $stmt = $this->db->prepare($query); + $stmt->execute($params); + return $stmt->fetchAll(\PDO::FETCH_ASSOC); + } + + /** + * Count users with filters + */ + public function countFiltered(array $filters = []): int + { + $query = "SELECT COUNT(*) as total FROM users WHERE 1=1"; + $params = []; + + // Apply search filter + if (!empty($filters['search'])) { + $query .= " AND (username LIKE ? OR email LIKE ? OR full_name LIKE ?)"; + $searchTerm = "%{$filters['search']}%"; + $params[] = $searchTerm; + $params[] = $searchTerm; + $params[] = $searchTerm; + } + + // Apply role filter + if (!empty($filters['role'])) { + $query .= " AND role = ?"; + $params[] = $filters['role']; + } + + // Apply status filter + if (isset($filters['status']) && $filters['status'] !== '') { + $isActive = ($filters['status'] === 'active') ? 1 : 0; + $query .= " AND is_active = ?"; + $params[] = $isActive; + } + + $stmt = $this->db->prepare($query); + $stmt->execute($params); + return (int)$stmt->fetch(\PDO::FETCH_ASSOC)['total']; + } } diff --git a/app/Services/NotificationService.php b/app/Services/NotificationService.php index 84bcb9a..acdc0f6 100644 --- a/app/Services/NotificationService.php +++ b/app/Services/NotificationService.php @@ -2,153 +2,156 @@ namespace App\Services; -use App\Services\Channels\EmailChannel; -use App\Services\Channels\TelegramChannel; -use App\Services\Channels\DiscordChannel; -use App\Services\Channels\SlackChannel; +use App\Models\Notification; class NotificationService { - private array $channels = []; + private Notification $notificationModel; public function __construct() { - $this->channels = [ - 'email' => new EmailChannel(), - 'telegram' => new TelegramChannel(), - 'discord' => new DiscordChannel(), - 'slack' => new SlackChannel(), - ]; + $this->notificationModel = new Notification(); } /** - * Send notification to specified channel + * Create a domain expiring notification */ - public function send(string $channelType, array $config, string $message, array $data = []): bool + public function notifyDomainExpiring(int $userId, string $domainName, int $daysLeft, int $domainId): void { - if (!isset($this->channels[$channelType])) { - return false; - } + $this->notificationModel->createNotification( + $userId, + 'domain_expiring', + 'Domain Expiring Soon', + "{$domainName} expires in {$daysLeft} day" . ($daysLeft > 1 ? 's' : ''), + $domainId + ); + } + /** + * Create a domain expired notification + */ + public function notifyDomainExpired(int $userId, string $domainName, int $domainId): void + { + $this->notificationModel->createNotification( + $userId, + 'domain_expired', + 'Domain Expired', + "{$domainName} has expired - renew immediately", + $domainId + ); + } + + /** + * Create a domain WHOIS updated notification + */ + public function notifyDomainUpdated(int $userId, string $domainName, int $domainId, string $changes = ''): void + { + $message = !empty($changes) ? + "{$domainName} - {$changes}" : + "{$domainName} WHOIS data updated"; + + $this->notificationModel->createNotification( + $userId, + 'domain_updated', + 'Domain WHOIS Updated', + $message, + $domainId + ); + } + + /** + * Create a WHOIS lookup failed notification + */ + public function notifyWhoisFailed(int $userId, string $domainName, int $domainId, string $reason = ''): void + { + $message = !empty($reason) ? + "Could not refresh {$domainName} - {$reason}" : + "Could not refresh {$domainName}"; + + $this->notificationModel->createNotification( + $userId, + 'whois_failed', + 'WHOIS Lookup Failed', + $message, + $domainId + ); + } + + /** + * Create a new login notification + */ + public function notifyNewLogin(int $userId, string $location, string $ipAddress): void + { + $this->notificationModel->createNotification( + $userId, + 'session_new', + 'New Login Detected', + "Login from {$location} ({$ipAddress})", + null + ); + } + + /** + * Create welcome notification for new users/fresh install + */ + public function notifyWelcome(int $userId, string $username): void + { + $this->notificationModel->createNotification( + $userId, + 'system_welcome', + 'Welcome to Domain Monitor! 🎉', + "Hi {$username}! Your account is ready. Start by adding your first domain to monitor.", + null + ); + } + + /** + * Create system upgrade notification for admins + */ + public function notifySystemUpgrade(int $userId, string $fromVersion, string $toVersion, int $migrationsCount): void + { + $this->notificationModel->createNotification( + $userId, + 'system_upgrade', + 'System Upgraded Successfully', + "Domain Monitor upgraded from v{$fromVersion} to v{$toVersion} ({$migrationsCount} migration" . ($migrationsCount > 1 ? 's' : '') . " applied)", + null + ); + } + + /** + * Notify all admins about system upgrade + */ + public function notifyAdminsUpgrade(string $fromVersion, string $toVersion, int $migrationsCount): void + { try { - return $this->channels[$channelType]->send($config, $message, $data); - } catch (\Exception $e) { - error_log("Notification send failed [$channelType]: " . $e->getMessage()); - return false; - } - } - - /** - * Send notification to all active channels in a group - */ - public function sendToGroup(int $groupId, string $subject, string $message, array $data = []): array - { - // Get active channels for the group - $channelModel = new \App\Models\NotificationChannel(); - $channels = $channelModel->getByGroupId($groupId); - - $results = []; - - foreach ($channels as $channel) { - if (!$channel['is_active']) { - continue; // Skip inactive channels + $pdo = \Core\Database::getConnection(); + $stmt = $pdo->query("SELECT id FROM users WHERE role = 'admin'"); + $admins = $stmt->fetchAll(\PDO::FETCH_ASSOC); + + foreach ($admins as $admin) { + $this->notifySystemUpgrade($admin['id'], $fromVersion, $toVersion, $migrationsCount); } - - $config = json_decode($channel['channel_config'], true); - - // Add subject to data for channels that support it (like email) - $channelData = array_merge(['subject' => $subject], $data); - - $success = $this->send( - $channel['channel_type'], - $config, - $message, - $channelData + } catch (\Exception $e) { + error_log("Failed to notify admins about upgrade: " . $e->getMessage()); + } + } + + /** + * Delete old read notifications (cleanup) + */ + public function cleanOldNotifications(int $daysOld = 30): void + { + try { + $pdo = \Core\Database::getConnection(); + $stmt = $pdo->prepare( + "DELETE FROM user_notifications + WHERE is_read = 1 + AND read_at < DATE_SUB(NOW(), INTERVAL ? DAY)" ); - - $results[] = [ - 'channel' => $channel['channel_type'], - 'success' => $success - ]; + $stmt->execute([$daysOld]); + } catch (\Exception $e) { + error_log("Failed to clean old notifications: " . $e->getMessage()); } - - return $results; - } - - /** - * Send domain expiration notification - */ - public function sendDomainExpirationAlert(array $domain, array $notificationChannels): array - { - $daysLeft = $this->calculateDaysLeft($domain['expiration_date']); - $message = $this->formatExpirationMessage($domain, $daysLeft); - - $results = []; - - foreach ($notificationChannels as $channel) { - $config = json_decode($channel['channel_config'], true); - $success = $this->send( - $channel['channel_type'], - $config, - $message, - [ - 'domain' => $domain['domain_name'], - 'domain_id' => $domain['id'], - 'days_left' => $daysLeft, - 'expiration_date' => $domain['expiration_date'], - 'registrar' => $domain['registrar'] - ] - ); - - $results[] = [ - 'channel' => $channel['channel_type'], - 'success' => $success - ]; - } - - return $results; - } - - /** - * Format expiration message - */ - private function formatExpirationMessage(array $domain, int $daysLeft): string - { - $domainName = $domain['domain_name']; - $expirationDate = date('F j, Y', strtotime($domain['expiration_date'])); - $registrar = $domain['registrar'] ?? 'Unknown'; - - if ($daysLeft <= 0) { - return "🚨 URGENT: Domain '$domainName' has EXPIRED on $expirationDate!\n\n" . - "Registrar: $registrar\n" . - "Please renew immediately to avoid losing your domain."; - } - - if ($daysLeft == 1) { - return "⚠️ CRITICAL: Domain '$domainName' expires TOMORROW ($expirationDate)!\n\n" . - "Registrar: $registrar\n" . - "Please renew as soon as possible."; - } - - if ($daysLeft <= 7) { - return "⚠️ WARNING: Domain '$domainName' expires in $daysLeft days ($expirationDate)!\n\n" . - "Registrar: $registrar\n" . - "Please renew soon."; - } - - return "ℹ️ REMINDER: Domain '$domainName' expires in $daysLeft days ($expirationDate).\n\n" . - "Registrar: $registrar\n" . - "Please plan for renewal."; - } - - /** - * Calculate days left until expiration - */ - private function calculateDaysLeft(string $expirationDate): int - { - $expiration = strtotime($expirationDate); - $now = time(); - return (int)floor(($expiration - $now) / 86400); } } - diff --git a/app/Views/auth/base-auth.php b/app/Views/auth/base-auth.php new file mode 100644 index 0000000..7d6b50b --- /dev/null +++ b/app/Views/auth/base-auth.php @@ -0,0 +1,56 @@ + + + + + + <?= $title ?? 'Authentication' ?> - Domain Monitor + + + + + + + + + + + + +
+ +
+ +
+ + +
+

+ © Domain Monitor. All rights reserved. +

+
+
+ + + + + + + diff --git a/app/Views/auth/forgot-password.php b/app/Views/auth/forgot-password.php new file mode 100644 index 0000000..655cdeb --- /dev/null +++ b/app/Views/auth/forgot-password.php @@ -0,0 +1,79 @@ + + + +
+
+ +
+

Forgot Password?

+

No worries, we'll send you reset instructions

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

Enter the email associated with your account

+
+ + + +
+ + +
+ + + Back to Login + +
+ + diff --git a/app/Views/auth/login.php b/app/Views/auth/login.php index 1c251ed..b337498 100644 --- a/app/Views/auth/login.php +++ b/app/Views/auth/login.php @@ -1,156 +1,130 @@ - - - - - - Login - Domain Monitor - - - - - - - - - - - - -
- -
- -
-
- -
-

Welcome Back

-

Sign in to access your account

-
+ - - -
-
- - -
-
- - + +
+
+ +
+

Welcome Back

+

Sign in to access your account

+
- -
- -
- -
-
- -
- -
-
- - -
- -
-
- -
- - -
-
- - -
- - - Forgot password? - -
- - - -
+ + +
+
+ +
+
+ + - -
-

- © Domain Monitor. All rights reserved. -

+ +
+ +
+ +
+
+ +
+
- - - + } + +SCRIPT; +require __DIR__ . '/base-auth.php'; +?> diff --git a/app/Views/auth/register.php b/app/Views/auth/register.php new file mode 100644 index 0000000..1e22356 --- /dev/null +++ b/app/Views/auth/register.php @@ -0,0 +1,217 @@ + + + +
+
+ +
+

Create Account

+

Join Domain Monitor today

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

Letters, numbers, and underscores only

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

Minimum 8 characters

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

+ Already have an account? + + Sign In + +

+
+ + + function togglePassword(fieldId) { + const passwordInput = document.getElementById(fieldId); + const toggleIcon = document.getElementById('toggleIcon-' + fieldId); + + if (passwordInput.type === 'password') { + passwordInput.type = 'text'; + toggleIcon.classList.remove('fa-eye'); + toggleIcon.classList.add('fa-eye-slash'); + } else { + passwordInput.type = 'password'; + toggleIcon.classList.remove('fa-eye-slash'); + toggleIcon.classList.add('fa-eye'); + } + } + + // Client-side password match validation + document.querySelector('form').addEventListener('submit', function(e) { + const password = document.getElementById('password').value; + const passwordConfirm = document.getElementById('password_confirm').value; + + if (password !== passwordConfirm) { + e.preventDefault(); + alert('Passwords do not match!'); + } + }); + +SCRIPT; +require __DIR__ . '/base-auth.php'; +?> diff --git a/app/Views/auth/reset-password.php b/app/Views/auth/reset-password.php new file mode 100644 index 0000000..839ec24 --- /dev/null +++ b/app/Views/auth/reset-password.php @@ -0,0 +1,147 @@ + + + +
+
+ +
+

Reset Password

+

Enter your new password below

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

Minimum 8 characters

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

+ + Password Requirements: +

+
    +
  • At least 8 characters long
  • +
  • Mix of uppercase and lowercase letters recommended
  • +
  • Include numbers and special characters for extra security
  • +
+
+ + + +
+ + + + + + function togglePassword(fieldId) { + const passwordInput = document.getElementById(fieldId); + const toggleIcon = document.getElementById('toggleIcon-' + fieldId); + + if (passwordInput.type === 'password') { + passwordInput.type = 'text'; + toggleIcon.classList.remove('fa-eye'); + toggleIcon.classList.add('fa-eye-slash'); + } else { + passwordInput.type = 'password'; + toggleIcon.classList.remove('fa-eye-slash'); + toggleIcon.classList.add('fa-eye'); + } + } + + // Client-side password match validation + document.querySelector('form').addEventListener('submit', function(e) { + const password = document.getElementById('password').value; + const passwordConfirm = document.getElementById('password_confirm').value; + + if (password !== passwordConfirm) { + e.preventDefault(); + alert('Passwords do not match!'); + } + }); + +SCRIPT; +require __DIR__ . '/base-auth.php'; +?> diff --git a/app/Views/auth/verify-email.php b/app/Views/auth/verify-email.php new file mode 100644 index 0000000..fc64874 --- /dev/null +++ b/app/Views/auth/verify-email.php @@ -0,0 +1,79 @@ + + + + +
+
+ +
+

Email Verified!

+

Your email address has been successfully verified.

+ + + + Sign In to Your Account + +
+ + +
+
+ +
+

Verification Failed

+

+ + +
+ + +
+
+ +
+

Check Your Email

+

+ We've sent a verification link to . + Please check your inbox and click the link to verify your account. +

+ +
+

+ + Didn't receive the email? +

+
    +
  • Check your spam or junk folder
  • +
  • Make sure you entered the correct email address
  • +
  • Wait a few minutes for the email to arrive
  • +
+
+ + +
+ + + diff --git a/app/Views/dashboard/index.php b/app/Views/dashboard/index.php index abce125..a1801eb 100644 --- a/app/Views/dashboard/index.php +++ b/app/Views/dashboard/index.php @@ -104,19 +104,12 @@ ob_start();
'bg-green-100 text-green-700', - 'expiring_soon' => 'bg-orange-100 text-orange-700', - 'expired' => 'bg-red-100 text-red-700', - 'error' => 'bg-red-100 text-red-700', - 'available' => 'bg-blue-100 text-blue-700' - ]; - $statusClass = $statusClasses[$status] ?? 'bg-gray-100 text-gray-700'; - $statusLabel = $status === 'expiring_soon' ? 'Expiring Soon' : ($status === 'available' ? 'Available' : ucfirst($status)); + // Display data prepared by DomainHelper in controller + $statusClass = $domain['statusClass']; + $statusText = $domain['statusText']; ?> - + @@ -238,7 +231,8 @@ ob_start(); @@ -217,73 +221,12 @@ $currentFilters = $filters ?? ['search' => '', 'status' => '', 'group' => '', 's = 0) { - $statusClass = 'bg-orange-100 text-orange-700 border-orange-200'; - $statusText = 'Expiring Soon'; - $statusIcon = 'fa-exclamation-triangle'; - } elseif ($domainStatus === 'active') { - $statusClass = 'bg-green-100 text-green-700 border-green-200'; - $statusText = 'Active'; - $statusIcon = 'fa-check-circle'; - } elseif ($domainStatus === 'expired') { - $statusClass = 'bg-red-100 text-red-700 border-red-200'; - $statusText = 'Expired'; - $statusIcon = 'fa-times-circle'; - } elseif ($domainStatus === 'error') { - $statusClass = 'bg-gray-100 text-gray-700 border-gray-200'; - $statusText = 'Error'; - $statusIcon = 'fa-exclamation-circle'; - } else { - $statusClass = 'bg-gray-100 text-gray-700 border-gray-200'; - $statusText = ucfirst($domainStatus); - $statusIcon = 'fa-times-circle'; - } + // Display data prepared by DomainHelper in controller + $daysLeft = $domain['daysLeft']; + $expiryClass = $domain['expiryClass']; + $statusClass = $domain['statusClass']; + $statusText = $domain['statusText']; + $statusIcon = $domain['statusIcon']; ?> diff --git a/app/Views/domains/view.php b/app/Views/domains/view.php index 19fc3f1..13781ad 100644 --- a/app/Views/domains/view.php +++ b/app/Views/domains/view.php @@ -3,44 +3,12 @@ $title = 'Domain Details'; $pageTitle = htmlspecialchars($domain['domain_name']); $pageDescription = 'Domain information and monitoring status'; $pageIcon = 'fas fa-globe'; + +// Data already formatted by controller via DomainHelper $whoisData = json_decode($domain['whois_data'] ?? '{}', true); -$daysLeft = !empty($domain['expiration_date']) ? floor((strtotime($domain['expiration_date']) - time()) / 86400) : null; - -// Recalculate domain status if it's empty or error (for backward compatibility) -$domainStatus = $domain['status']; -if (empty($domainStatus) || $domainStatus === 'error') { - // Check WHOIS data for AVAILABLE status - $statusArray = $whoisData['status'] ?? []; - $isAvailable = false; - foreach ($statusArray as $status) { - if (stripos($status, 'AVAILABLE') !== false || stripos($status, 'FREE') !== false) { - $isAvailable = true; - break; - } - } - - if ($isAvailable) { - $domainStatus = 'available'; - } elseif ($daysLeft !== null) { - if ($daysLeft < 0) { - $domainStatus = 'expired'; - } elseif ($daysLeft <= 30) { - $domainStatus = 'expiring_soon'; - } else { - $domainStatus = 'active'; - } - } else { - $domainStatus = 'error'; - } -} - -// Determine expiry color -$expiryColor = 'green'; -if ($daysLeft !== null) { - if ($daysLeft < 0) $expiryColor = 'red'; - elseif ($daysLeft <= 30) $expiryColor = 'orange'; - elseif ($daysLeft <= 90) $expiryColor = 'yellow'; -} +$daysLeft = $domain['daysLeft']; +$domainStatus = $domain['displayStatus']; +$expiryColor = $domain['expiryColor']; ob_start(); ?> @@ -49,32 +17,10 @@ ob_start();
= 0)) { - $statusClass = 'bg-orange-100 text-orange-700 border-orange-200'; - $statusText = 'Expiring Soon'; - $statusIcon = 'fa-exclamation-triangle'; - } elseif ($domainStatus === 'active') { - $statusClass = 'bg-green-100 text-green-700 border-green-200'; - $statusText = 'Active'; - $statusIcon = 'fa-check-circle'; - } elseif ($domainStatus === 'error') { - $statusClass = 'bg-gray-100 text-gray-700 border-gray-200'; - $statusText = 'Error'; - $statusIcon = 'fa-exclamation-circle'; - } else { - $statusClass = 'bg-gray-100 text-gray-700 border-gray-200'; - $statusText = ucfirst($domainStatus); - $statusIcon = 'fa-question-circle'; - } + // Status badge data prepared by DomainHelper in controller + $statusClass = $domain['statusClass']; + $statusText = $domain['statusText']; + $statusIcon = $domain['statusIcon']; ?> @@ -257,51 +203,20 @@ ob_start(); - - - +

- Domain Status () + Domain Status ()

- + @@ -310,7 +225,6 @@ ob_start();
-
@@ -335,11 +249,8 @@ ob_start();

- $ch['is_active']); - ?>

- / channels active + / channels active

diff --git a/app/Views/installer/complete.php b/app/Views/installer/complete.php new file mode 100644 index 0000000..2857072 --- /dev/null +++ b/app/Views/installer/complete.php @@ -0,0 +1,109 @@ + + + + + + Installation Complete + + + + + + +
+
+ +
+
+ +
+

Installation Complete!

+

Domain Monitor is ready to use

+
+ + +
+
+ +
+

Save Your Credentials!

+

This password will not be shown again. Save it to a secure password manager.

+ +
+
+
+ Username: + admin +
+
+ Password: + +
+
+
+
+
+
+ + +
+

Installation Summary

+
+
+ + Database tables created +
+
+ + Admin account configured +
+
+ + Encryption key generated +
+
+ + All migrations applied +
+
+
+ + +
+

+ Next Steps +

+
    +
  1. Log in with your admin credentials
  2. +
  3. Configure email settings (Settings → Email)
  4. +
  5. Import TLD registry data (TLD Registry → Import TLDs)
  6. +
  7. Add your first domain
  8. +
  9. Set up notification groups
  10. +
  11. Configure cron job for automated monitoring
  12. +
+
+ + + + Go to Login + +
+ +
+

© Domain Monitor

+
+
+ + diff --git a/app/Views/installer/update.php b/app/Views/installer/update.php new file mode 100644 index 0000000..cb9e983 --- /dev/null +++ b/app/Views/installer/update.php @@ -0,0 +1,96 @@ + + + + + + System Update + + + + + + +
+
+ +
+
+ +
+

System Update

+

New database migrations are available

+
+ + +
+
+ +
+

Backup Recommended

+

Please backup your database before running updates.

+
+
+
+ + +
+

Pending Migrations

+
+
    + +
  • + + +
  • + +
+
+

+ + Total: migration(s) +

+
+
+
+ + + +
+
+ + +
+
+ + + +
+ + + + Cancel + +
+
+ +
+

© Domain Monitor

+
+
+ + diff --git a/app/Views/installer/welcome.php b/app/Views/installer/welcome.php new file mode 100644 index 0000000..3814056 --- /dev/null +++ b/app/Views/installer/welcome.php @@ -0,0 +1,150 @@ + + + + + + Install Domain Monitor + + + + + + +
+ +
+ +
+
+ +
+

Domain Monitor Installer

+

Welcome! Let's set up your monitoring system

+
+ + +
+

Installation Steps

+
+
+
1
+
+

Database Setup

+

Create tables and structure

+
+
+
+
2
+
+

Admin Account

+

Set your credentials below

+
+
+
+
3
+
+

Start Monitoring

+

Begin tracking your domains

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

Administrator Account

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

Minimum 8 characters

+
+
+ +
+

+ + Note: These credentials will be used to access the admin panel. Save them securely! +

+
+
+ + +
+
+ + +
+

© Domain Monitor

+
+
+ + + + diff --git a/app/Views/layout/base.php b/app/Views/layout/base.php index 951eec1..870ff47 100644 --- a/app/Views/layout/base.php +++ b/app/Views/layout/base.php @@ -4,61 +4,30 @@ * Contains: HTML structure, meta tags, CSS/JS includes, global stats */ +// Fetch notifications for top nav (available on all pages) +if (isset($_SESSION['user_id'])) { + $notificationData = \App\Helpers\LayoutHelper::getNotifications($_SESSION['user_id']); + $recentNotifications = $notificationData['items']; + $unreadNotifications = $notificationData['unread_count']; +} else { + $recentNotifications = []; + $unreadNotifications = 0; +} + // Fetch global stats for sidebar (available on all pages) if (!isset($globalStats)) { - try { - $pdo = \Core\Database::getConnection(); - - // Get total domains - $totalStmt = $pdo->query("SELECT COUNT(*) as count FROM domains"); - $totalResult = $totalStmt->fetch(\PDO::FETCH_ASSOC); - $total = $totalResult['count'] ?? 0; - - // Get active domains - $activeStmt = $pdo->query("SELECT COUNT(*) as count FROM domains WHERE is_active = 1"); - $activeResult = $activeStmt->fetch(\PDO::FETCH_ASSOC); - $active = $activeResult['count'] ?? 0; - - // Get expiring soon - use the first notification threshold from settings - $settingModel = new \App\Models\Setting(); - $notificationDays = $settingModel->getNotificationDays(); - $expiringThreshold = !empty($notificationDays) ? max($notificationDays) : 30; // Use the largest notification day - - $expiringSoonStmt = $pdo->prepare("SELECT COUNT(*) as count FROM domains WHERE is_active = 1 AND expiration_date IS NOT NULL AND expiration_date <= DATE_ADD(NOW(), INTERVAL ? DAY) AND expiration_date >= NOW()"); - $expiringSoonStmt->execute([$expiringThreshold]); - $expiringSoonResult = $expiringSoonStmt->fetch(\PDO::FETCH_ASSOC); - $expiringSoon = $expiringSoonResult['count'] ?? 0; - - $globalStats = [ - 'total' => $total, - 'active' => $active, - 'expiring_soon' => $expiringSoon, - 'expiring_threshold' => $expiringThreshold - ]; - } catch (\Exception $e) { - $globalStats = [ - 'total' => 0, - 'active' => 0, - 'expiring_soon' => 0, - 'expiring_threshold' => 30 - ]; - } + $globalStats = \App\Helpers\LayoutHelper::getGlobalStats(); } // Get application settings from database if (!isset($appName)) { - try { - $settingModel = new \App\Models\Setting(); - $appSettings = $settingModel->getAppSettings(); - $appName = htmlspecialchars($appSettings['app_name']); - $appTimezone = $appSettings['app_timezone']; - - // Set PHP timezone - date_default_timezone_set($appTimezone); - } catch (\Exception $e) { - $appName = 'Domain Monitor'; - date_default_timezone_set('UTC'); - } + $appSettings = \App\Helpers\LayoutHelper::getAppSettings(); + $appName = $appSettings['app_name']; + $appTimezone = $appSettings['app_timezone']; + $appVersion = $appSettings['app_version']; + + // Set PHP timezone + date_default_timezone_set($appTimezone); } ?> @@ -179,16 +148,44 @@ if (!isset($appName)) { // Toggle user dropdown function toggleDropdown() { - document.getElementById('userDropdown').classList.toggle('show'); + const dropdown = document.getElementById('userDropdown'); + const notifDropdown = document.getElementById('notificationsDropdown'); + + // Close notifications dropdown if open + if (notifDropdown && notifDropdown.classList.contains('show')) { + notifDropdown.classList.remove('show'); + } + + dropdown.classList.toggle('show'); } - // Close dropdown when clicking outside - document.addEventListener('click', function(event) { - const dropdown = document.getElementById('userDropdown'); - const isClickInside = event.target.closest('[onclick="toggleDropdown()"]') || event.target.closest('#userDropdown'); + // Toggle notifications dropdown + function toggleNotifications() { + const dropdown = document.getElementById('notificationsDropdown'); + const userDropdown = document.getElementById('userDropdown'); - if (!isClickInside && dropdown && dropdown.classList.contains('show')) { - dropdown.classList.remove('show'); + // Close user dropdown if open + if (userDropdown && userDropdown.classList.contains('show')) { + userDropdown.classList.remove('show'); + } + + dropdown.classList.toggle('show'); + } + + // Close dropdowns when clicking outside + document.addEventListener('click', function(event) { + const userDropdown = document.getElementById('userDropdown'); + const notifDropdown = document.getElementById('notificationsDropdown'); + + const isUserDropdownClick = event.target.closest('[onclick="toggleDropdown()"]') || event.target.closest('#userDropdown'); + const isNotifDropdownClick = event.target.closest('[onclick="toggleNotifications()"]') || event.target.closest('#notificationsDropdown'); + + if (!isUserDropdownClick && userDropdown && userDropdown.classList.contains('show')) { + userDropdown.classList.remove('show'); + } + + if (!isNotifDropdownClick && notifDropdown && notifDropdown.classList.contains('show')) { + notifDropdown.classList.remove('show'); } }); diff --git a/app/Views/layout/sidebar.php b/app/Views/layout/sidebar.php index 3c70c74..e41c305 100644 --- a/app/Views/layout/sidebar.php +++ b/app/Views/layout/sidebar.php @@ -33,6 +33,9 @@ TLD Registry + + View +
@@ -47,7 +50,8 @@
- + +

System

@@ -55,8 +59,13 @@ Settings + + + Users +
+ @@ -105,7 +114,7 @@

© Domain Monitor

-

v1.0.0

+

v

diff --git a/app/Views/layout/top-nav.php b/app/Views/layout/top-nav.php index 9bf1fe9..b6c76e5 100644 --- a/app/Views/layout/top-nav.php +++ b/app/Views/layout/top-nav.php @@ -1,4 +1,5 @@ + - diff --git a/app/Views/notifications/index.php b/app/Views/notifications/index.php new file mode 100644 index 0000000..c6e5d96 --- /dev/null +++ b/app/Views/notifications/index.php @@ -0,0 +1,287 @@ + + + +
+
+ +
+ +
+ + +
+
+ + +
+
+
+ +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + + + Clear + +
+
+
+
+ + +
+
+ Showing to + of + notification(s) + 0): ?> + + unread + +
+ +
+ + + + + + +
+
+ + +
+ +
+ + +
+
+ +
+ +
+ + +
+
+

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

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

No notifications found

+

Try adjusting your filters

+
+ +
+ + + 1): ?> +
+ +
+ Page of + +
+ + +
+ + + + 1): ?> + + + + + + + 1): ?> + + Previous + + + + + 1) { + echo '1'; + if ($start > 2) { + echo '...'; + } + } + + // Page numbers + for ($i = $start; $i <= $end; $i++) { + if ($i == $page) { + echo '' . $i . ''; + } else { + echo '' . $i . ''; + } + } + + // Show last page + ellipsis if needed + if ($end < $totalPages) { + if ($end < $totalPages - 1) { + echo '...'; + } + echo '' . $totalPages . ''; + } + ?> + + + + + Next + + + + + + + + + +
+
+ + + + + + diff --git a/app/Views/profile/index.php b/app/Views/profile/index.php new file mode 100644 index 0000000..0e4e90e --- /dev/null +++ b/app/Views/profile/index.php @@ -0,0 +1,481 @@ + + + +
+ +
+
+ +
+
+
+ +
+

+

@

+ + + + + + + + +
+
+
Member Since
+
+ +
+
+
+
Status
+
+ Active +
+
+
+
+
+ + + +
+
+ + +
+ + +
+
+
+

Profile Information

+

Update your personal details and account information

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

+ + Email verified +

+ +
+
+
+ +
+

Email Not Verified

+

Verify your email to unlock all features

+
+
+ + + Resend + +
+
+ +
+ + +
+ + +

Username cannot be changed

+
+ + +
+

Account Information

+
+
+ +

+ +

+
+
+ +

+ +

+
+
+
+
+ +
+ + +
+
+
+
+ + + + + + + + + + + + +
+
+ + + + + + diff --git a/app/Views/search/results.php b/app/Views/search/results.php index 5339285..579ac84 100644 --- a/app/Views/search/results.php +++ b/app/Views/search/results.php @@ -74,13 +74,9 @@ ob_start(); diff --git a/app/Views/settings/index.php b/app/Views/settings/index.php index 5d9c05f..d6026c7 100644 --- a/app/Views/settings/index.php +++ b/app/Views/settings/index.php @@ -118,6 +118,41 @@ foreach ($notificationPresets as $key => $preset) {

Application timezone for dates and times

+ + +
+

User Registration

+ +
+
+
+ + class="w-4 h-4 text-primary border-gray-300 rounded focus:ring-primary"> +
+
+ +

Allow new users to create accounts via registration form

+
+
+ +
+
+ + class="w-4 h-4 text-primary border-gray-300 rounded focus:ring-primary"> +
+
+ +

Users must verify their email address before accessing the system

+
+
+
+
diff --git a/app/Views/tld-registry/index.php b/app/Views/tld-registry/index.php index 7a44dc2..d3e6342 100644 --- a/app/Views/tld-registry/index.php +++ b/app/Views/tld-registry/index.php @@ -30,6 +30,7 @@ $currentFilters = $filters ?? ['search' => '', 'sort' => 'tld', 'order' => 'asc'
+
@@ -45,14 +46,23 @@ $currentFilters = $filters ?? ['search' => '', 'sort' => 'tld', 'order' => 'asc' Check Updates
-
- - + +
+

+ + View-only mode. Contact admin to import or modify TLD data. +

+
+ + +
+ +
@@ -188,8 +198,8 @@ $currentFilters = $filters ?? ['search' => '', 'sort' => 'tld', 'order' => 'asc'
- - + +
@@ -216,9 +226,11 @@ $currentFilters = $filters ?? ['search' => '', 'sort' => 'tld', 'order' => 'asc' + + + + @@ -390,12 +406,14 @@ $currentFilters = $filters ?? ['search' => '', 'sort' => 'tld', 'order' => 'asc' diff --git a/app/Views/tld-registry/view.php b/app/Views/tld-registry/view.php index e5aeb09..804f48b 100644 --- a/app/Views/tld-registry/view.php +++ b/app/Views/tld-registry/view.php @@ -19,6 +19,7 @@ ob_start();
+
@@ -201,6 +204,7 @@ ob_start();
Toggle Status
+
diff --git a/app/Views/users/create.php b/app/Views/users/create.php new file mode 100644 index 0000000..2bd7e73 --- /dev/null +++ b/app/Views/users/create.php @@ -0,0 +1,100 @@ + + +
+
+ + + + diff --git a/app/Views/users/edit.php b/app/Views/users/edit.php new file mode 100644 index 0000000..d2d678d --- /dev/null +++ b/app/Views/users/edit.php @@ -0,0 +1,130 @@ + + +
+ + +
+
+

User Information

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

Username cannot be changed

+
+ + +
+ + +
+ + +
+ + +
+ + +
+
+ + class="w-4 h-4 text-primary border-gray-300 rounded focus:ring-primary"> +
+
+ +

Inactive users cannot log in

+
+
+ + +
+

Change Password (Optional)

+ +
+ + +

Leave blank to keep current password. Minimum 8 characters if changing.

+
+
+ + +
+
+
+ Email Verified: + + + +
+
+ Member Since: + + + +
+
+ Last Login: + + + +
+
+
+
+ +
+ + Cancel + + +
+
+ + + + diff --git a/app/Views/users/index.php b/app/Views/users/index.php new file mode 100644 index 0000000..2d10d1d --- /dev/null +++ b/app/Views/users/index.php @@ -0,0 +1,355 @@ +'; + } + $icon = $currentOrder === 'asc' ? 'fa-sort-up' : 'fa-sort-down'; + return ''; +} + +// Get current filters +$currentFilters = $filters ?? ['search' => '', 'role' => '', 'status' => '', 'sort' => 'username', 'order' => 'asc']; + +// Mock pagination for now (will need to be implemented in controller) +$pagination = $pagination ?? [ + 'current_page' => 1, + 'total_pages' => 1, + 'per_page' => 25, + 'total' => count($users), + 'showing_from' => 1, + 'showing_to' => count($users) +]; +?> + + +
+
+ +
+ + +
+ + +
+
+
+ +
+ +
+ + +
+
+ + +
+ + +
+ + +
+ + +
+ + +
+ + + + Clear + +
+
+ + + +
+ + +
+
+ Showing to + of + user(s) +
+ +
+ + + + + + + + + + +
+ + +
+ +
+
TLD @@ -250,9 +262,11 @@ $currentFilters = $filters ?? ['search' => '', 'sort' => 'tld', 'order' => 'asc'
@@ -320,12 +334,14 @@ $currentFilters = $filters ?? ['search' => '', 'sort' => 'tld', 'order' => 'asc' + +
+ + + + + + + + + + + + + + + + + + + + + + + + +
+ + User + + + + Username + + + + Role + + + + Status + + + + Email Verified + + + + Last Login + + Actions
+
+
+ + + +
+
+
+
+
+
+
+
+
+ + + + + + + + + + +
+ + + Verified + + + Not Verified + +
+
+ +
+ + +
+ + Never + +
+
+ + + + + + + + + + + + + + + +
+
+
+ + +
+ +

No Users Yet

+

Start by adding your first user

+ + + Add Your First User + +
+ +
+ + + 1): ?> +
+ +
+ Page of + +
+ + +
+ + + + 1): ?> + + + + + + + 1): ?> + + Previous + + + + + 1) { + echo '1'; + if ($start > 2) { + echo '...'; + } + } + + for ($i = $start; $i <= $end; $i++) { + if ($i == $currentPage) { + echo '' . $i . ''; + } else { + echo '' . $i . ''; + } + } + + if ($end < $totalPages) { + if ($end < $totalPages - 1) { + echo '...'; + } + echo '' . $totalPages . ''; + } + ?> + + + + + Next + + + + + + + + + +
+
+ + + + diff --git a/core/Auth.php b/core/Auth.php index 0fab5dd..1f2541e 100644 --- a/core/Auth.php +++ b/core/Auth.php @@ -44,9 +44,23 @@ class Auth // Get current path $currentPath = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH); - // Don't redirect if already on login page or logout - if ($currentPath === '/login' || $currentPath === '/logout') { - return; + // Public paths that don't require authentication + $publicPaths = [ + '/login', + '/logout', + '/register', + '/forgot-password', + '/reset-password', + '/verify-email', + '/resend-verification', + '/install' + ]; + + // Don't redirect if on a public path + foreach ($publicPaths as $path) { + if (strpos($currentPath, $path) === 0) { + return; + } } if (!self::check()) { diff --git a/core/DatabaseSessionHandler.php b/core/DatabaseSessionHandler.php new file mode 100644 index 0000000..f139cf1 --- /dev/null +++ b/core/DatabaseSessionHandler.php @@ -0,0 +1,200 @@ +db = Database::getConnection(); + $this->lifetime = $lifetime; + } + + /** + * Open session + */ + public function open(string $path, string $name): bool + { + return true; + } + + /** + * Close session + */ + public function close(): bool + { + return true; + } + + /** + * Read session data + */ + public function read(string $id): string|false + { + try { + $stmt = $this->db->prepare( + "SELECT payload FROM sessions WHERE id = ? AND last_activity > ?" + ); + $stmt->execute([$id, time() - ($this->lifetime * 60)]); + $result = $stmt->fetch(PDO::FETCH_ASSOC); + + if ($result) { + // Update last activity + $this->updateActivity($id); + return $result['payload']; + } + + return ''; + } catch (\Exception $e) { + error_log("Session read failed: " . $e->getMessage()); + return ''; + } + } + + /** + * Write session data + */ + public function write(string $id, string $data): bool + { + try { + // Extract user_id from session data + $sessionData = $this->unserializeSession($data); + $userId = $sessionData['user_id'] ?? null; + + // Get IP and user agent + $ipAddress = $_SERVER['REMOTE_ADDR'] ?? '127.0.0.1'; + $userAgent = $_SERVER['HTTP_USER_AGENT'] ?? ''; + + // Check if session exists + $stmt = $this->db->prepare("SELECT id, country FROM sessions WHERE id = ?"); + $stmt->execute([$id]); + $existing = $stmt->fetch(PDO::FETCH_ASSOC); + + if ($existing) { + // Update existing session + $stmt = $this->db->prepare( + "UPDATE sessions SET payload = ?, last_activity = ?, user_id = ? WHERE id = ?" + ); + return $stmt->execute([$data, time(), $userId, $id]); + } else { + // New session - get geolocation data + $geoData = \App\Models\SessionManager::getGeolocationData($ipAddress); + + // Insert new session + $stmt = $this->db->prepare( + "INSERT INTO sessions (id, user_id, ip_address, user_agent, country, country_code, region, city, isp, timezone, payload, last_activity, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)" + ); + + $currentTime = time(); + return $stmt->execute([ + $id, + $userId, + $ipAddress, + $userAgent, + $geoData['country'], + $geoData['country_code'], + $geoData['region'], + $geoData['city'], + $geoData['isp'], + $geoData['timezone'], + $data, + $currentTime, + $currentTime // created_at = same as last_activity initially + ]); + } + } catch (\Exception $e) { + error_log("Session write failed: " . $e->getMessage()); + return false; + } + } + + /** + * Destroy session + */ + public function destroy(string $id): bool + { + try { + $stmt = $this->db->prepare("DELETE FROM sessions WHERE id = ?"); + return $stmt->execute([$id]); + } catch (\Exception $e) { + error_log("Session destroy failed: " . $e->getMessage()); + return false; + } + } + + /** + * Garbage collection (cleanup old sessions) + */ + public function gc(int $max_lifetime): int|false + { + try { + $stmt = $this->db->prepare( + "DELETE FROM sessions WHERE last_activity < ?" + ); + $stmt->execute([time() - ($this->lifetime * 60)]); + return $stmt->rowCount(); + } catch (\Exception $e) { + error_log("Session GC failed: " . $e->getMessage()); + return 0; + } + } + + /** + * Update session activity timestamp + */ + private function updateActivity(string $id): void + { + try { + $stmt = $this->db->prepare( + "UPDATE sessions SET last_activity = ? WHERE id = ?" + ); + $stmt->execute([time(), $id]); + } catch (\Exception $e) { + // Silent fail + } + } + + /** + * Unserialize session data to extract variables + */ + private function unserializeSession(string $data): array + { + $result = []; + $offset = 0; + + while ($offset < strlen($data)) { + // Parse key + if (!preg_match('/(\w+)\|/', substr($data, $offset), $match)) { + break; + } + + $key = $match[1]; + $offset += strlen($match[0]); + + // Parse value + $value = @unserialize(substr($data, $offset)); + if ($value === false && substr($data, $offset, 5) !== 'b:0;') { + break; + } + + $result[$key] = $value; + $offset += strlen(serialize($value)); + } + + return $result; + } +} + diff --git a/core/SessionValidator.php b/core/SessionValidator.php new file mode 100644 index 0000000..78ed13b --- /dev/null +++ b/core/SessionValidator.php @@ -0,0 +1,58 @@ +prepare("SELECT user_id FROM sessions WHERE id = ?"); + $stmt->execute([$sessionId]); + $result = $stmt->fetch(\PDO::FETCH_ASSOC); + + // If session not found in DB, it was deleted remotely + if (!$result) { + // Session was deleted - logout this user + session_destroy(); + session_start(); + $_SESSION['error'] = 'Your session was terminated remotely. Please login again.'; + header('Location: /login'); + exit; + } + + // If session exists but user_id doesn't match, something is wrong + if ($result['user_id'] != $_SESSION['user_id']) { + session_destroy(); + session_start(); + $_SESSION['error'] = 'Session validation failed. Please login again.'; + header('Location: /login'); + exit; + } + + } catch (\Exception $e) { + // If sessions table doesn't exist, allow normal operation (graceful fallback) + error_log("Session validation failed: " . $e->getMessage()); + } + } +} + diff --git a/database/migrate.php b/database/migrate.php deleted file mode 100644 index bf6263a..0000000 --- a/database/migrate.php +++ /dev/null @@ -1,155 +0,0 @@ -#!/usr/bin/env php -load(); - -try { - // Check if encryption key is set, if not generate and save it - if (empty($_ENV['APP_ENCRYPTION_KEY'])) { - echo "🔑 Generating encryption key...\n"; - - // Generate a secure 32-byte (256-bit) key - $encryptionKey = base64_encode(random_bytes(32)); - - // Path to .env file - $envFile = __DIR__ . '/../.env'; - - if (!file_exists($envFile)) { - echo "✗ Error: .env file not found. Please create it first.\n"; - exit(1); - } - - // Read current .env content - $envContent = file_get_contents($envFile); - - // Check if APP_ENCRYPTION_KEY line exists - if (strpos($envContent, 'APP_ENCRYPTION_KEY=') !== false) { - // Replace empty value with generated key - $envContent = preg_replace( - '/APP_ENCRYPTION_KEY=.*$/m', - "APP_ENCRYPTION_KEY=$encryptionKey", - $envContent - ); - } else { - // Append the key to the file - $envContent .= "\nAPP_ENCRYPTION_KEY=$encryptionKey\n"; - } - - // Write updated content back to .env - if (!file_put_contents($envFile, $envContent)) { - echo "✗ Error: Could not write to .env file.\n"; - exit(1); - } - - // Reload environment variables - $dotenv = Dotenv::createImmutable(__DIR__ . '/..'); - $dotenv->load(); - - echo "✓ Encryption key generated and saved to .env\n"; - echo " Key: $encryptionKey\n"; - echo " ⚠️ Keep this key secret and backup securely!\n\n"; - } - - $host = $_ENV['DB_HOST']; - $port = $_ENV['DB_PORT']; - $database = $_ENV['DB_DATABASE']; - $username = $_ENV['DB_USERNAME']; - $password = $_ENV['DB_PASSWORD']; - - // Connect to database - $dsn = "mysql:host=$host;port=$port;dbname=$database;charset=utf8mb4"; - $pdo = new PDO($dsn, $username, $password, [ - PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, - ]); - - echo "Connected to database successfully!\n\n"; - - // Generate random admin password - $adminPassword = bin2hex(random_bytes(8)); // 16 character random password - $adminPasswordHash = password_hash($adminPassword, PASSWORD_BCRYPT); - - // Get all migration files - $migrationFiles = [ - __DIR__ . '/migrations/001_create_tables.sql', - __DIR__ . '/migrations/002_create_users_table.sql', - __DIR__ . '/migrations/003_add_whois_fields.sql', - __DIR__ . '/migrations/004_create_tld_registry_table.sql', - __DIR__ . '/migrations/005_update_tld_import_logs.sql', - __DIR__ . '/migrations/006_add_complete_workflow_import_type.sql', - __DIR__ . '/migrations/007_add_app_and_email_settings.sql', - __DIR__ . '/migrations/008_add_notes_to_domains.sql', - ]; - - foreach ($migrationFiles as $migrationFile) { - if (!file_exists($migrationFile)) { - echo "⚠ Migration file not found: " . basename($migrationFile) . "\n"; - continue; - } - - echo "Running migration: " . basename($migrationFile) . "\n"; - - $sql = file_get_contents($migrationFile); - - // Replace password placeholder in users migration - if (basename($migrationFile) === '002_create_users_table.sql') { - $sql = str_replace('{{ADMIN_PASSWORD_HASH}}', $adminPasswordHash, $sql); - } - - // Split by semicolon and execute each statement - $statements = array_filter(array_map('trim', explode(';', $sql))); - - foreach ($statements as $statement) { - if (!empty($statement)) { - try { - $pdo->exec($statement); - } catch (PDOException $e) { - // Check if it's a "column already exists" error for migrations 003, 005, and 008 - if (strpos($e->getMessage(), 'Duplicate column name') !== false && - (basename($migrationFile) === '003_add_whois_fields.sql' || - basename($migrationFile) === '005_update_tld_import_logs.sql' || - basename($migrationFile) === '008_add_notes_to_domains.sql')) { - echo " ⚠ Column already exists, skipping: " . $e->getMessage() . "\n"; - continue; - } - // Check if it's an enum modification error for migrations 005 and 006 - if (strpos($e->getMessage(), 'Duplicate entry') !== false && - (basename($migrationFile) === '005_update_tld_import_logs.sql' || - basename($migrationFile) === '006_add_complete_workflow_import_type.sql')) { - echo " ⚠ Enum already updated, skipping: " . $e->getMessage() . "\n"; - continue; - } - // Re-throw other errors - throw $e; - } - } - } - - echo "✓ " . basename($migrationFile) . " completed\n"; - } - - echo "\n✓ All migrations completed successfully!\n"; - echo "✓ All tables created.\n"; - echo "\n🔑 Admin credentials (SAVE THESE!):\n"; - echo " ═══════════════════════════════════════\n"; - echo " Username: admin\n"; - echo " Password: $adminPassword\n"; - echo " ═══════════════════════════════════════\n"; - echo " ⚠️ This password will not be shown again!\n"; - echo " 💾 Save it to a secure password manager.\n\n"; - echo "🌐 TLD Registry System:\n"; - echo " • Import RDAP data: php cron/import_tld_registry.php --rdap-only\n"; - echo " • Import WHOIS data: php cron/import_tld_registry.php --whois-only\n"; - echo " • Check for updates: php cron/import_tld_registry.php --check-updates\n"; - echo " • Full import: php cron/import_tld_registry.php\n\n"; - -} catch (PDOException $e) { - echo "✗ Migration failed: " . $e->getMessage() . "\n"; - exit(1); -} - diff --git a/database/migrations/000_initial_schema_v1.1.0.sql b/database/migrations/000_initial_schema_v1.1.0.sql new file mode 100644 index 0000000..b5fbeef --- /dev/null +++ b/database/migrations/000_initial_schema_v1.1.0.sql @@ -0,0 +1,270 @@ +-- Domain Monitor v1.1.0 - Complete Initial Schema +-- This consolidated migration includes all features for fresh installations + +-- ===================================================== +-- CORE TABLES +-- ===================================================== + +-- Domains table +CREATE TABLE IF NOT EXISTS domains ( + id INT AUTO_INCREMENT PRIMARY KEY, + domain_name VARCHAR(255) NOT NULL UNIQUE, + notification_group_id INT NULL, + registrar VARCHAR(255), + registrar_url VARCHAR(255), + expiration_date DATE, + updated_date DATE, + abuse_email VARCHAR(255), + last_checked TIMESTAMP NULL, + status ENUM('active', 'expiring_soon', 'expired', 'error', 'available') DEFAULT 'active', + whois_data JSON, + notes TEXT, + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (notification_group_id) REFERENCES notification_groups(id) ON DELETE SET NULL, + INDEX idx_notification_group_id (notification_group_id), + INDEX idx_domain_name (domain_name), + INDEX idx_expiration_date (expiration_date), + INDEX idx_status (status), + INDEX idx_is_active (is_active) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Notification groups table +CREATE TABLE IF NOT EXISTS notification_groups ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(255) NOT NULL, + description TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Notification channels table +CREATE TABLE IF NOT EXISTS notification_channels ( + id INT AUTO_INCREMENT PRIMARY KEY, + notification_group_id INT NOT NULL, + channel_type ENUM('email', 'telegram', 'discord', 'slack') NOT NULL, + channel_config JSON NOT NULL, + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (notification_group_id) REFERENCES notification_groups(id) ON DELETE CASCADE, + INDEX idx_group_id (notification_group_id), + INDEX idx_channel_type (channel_type) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Notification logs table +CREATE TABLE IF NOT EXISTS notification_logs ( + id INT AUTO_INCREMENT PRIMARY KEY, + domain_id INT NOT NULL, + notification_type VARCHAR(50) NOT NULL, + channel_type VARCHAR(50) NOT NULL, + message TEXT NOT NULL, + sent_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + status ENUM('sent', 'failed') DEFAULT 'sent', + error_message TEXT, + FOREIGN KEY (domain_id) REFERENCES domains(id) ON DELETE CASCADE, + INDEX idx_domain_id (domain_id), + INDEX idx_sent_at (sent_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ===================================================== +-- USER MANAGEMENT & AUTHENTICATION +-- ===================================================== + +-- Users table +CREATE TABLE IF NOT EXISTS users ( + id INT AUTO_INCREMENT PRIMARY KEY, + username VARCHAR(100) NOT NULL UNIQUE, + password VARCHAR(255) NOT NULL, + email VARCHAR(255), + email_verified BOOLEAN DEFAULT FALSE, + email_verification_token VARCHAR(255) NULL, + email_verification_sent_at TIMESTAMP NULL, + full_name VARCHAR(255), + role VARCHAR(50) DEFAULT 'user', + is_active BOOLEAN DEFAULT TRUE, + last_login TIMESTAMP NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_username (username), + INDEX idx_email (email), + INDEX idx_role (role) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Insert default admin user (password will be set during installation) +INSERT INTO users (username, password, email, full_name, is_active, role, email_verified) VALUES +('admin', '{{ADMIN_PASSWORD_HASH}}', 'admin@domainmonitor.local', 'Administrator', 1, 'admin', 1) +ON DUPLICATE KEY UPDATE username=username; + +-- Password reset tokens table +CREATE TABLE IF NOT EXISTS password_reset_tokens ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL, + token VARCHAR(255) NOT NULL UNIQUE, + expires_at TIMESTAMP NOT NULL, + used BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + INDEX idx_token (token), + INDEX idx_user_id (user_id), + INDEX idx_expires_at (expires_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Sessions table (database-backed sessions) +CREATE TABLE IF NOT EXISTS sessions ( + id VARCHAR(128) NOT NULL PRIMARY KEY, + user_id INT DEFAULT NULL, + ip_address VARCHAR(45) NOT NULL, + user_agent TEXT, + country VARCHAR(100) DEFAULT NULL, + country_code VARCHAR(2) DEFAULT NULL, + region VARCHAR(100) DEFAULT NULL, + city VARCHAR(100) DEFAULT NULL, + isp VARCHAR(255) DEFAULT NULL, + timezone VARCHAR(50) DEFAULT NULL, + payload MEDIUMTEXT NOT NULL, + last_activity INT UNSIGNED NOT NULL, + created_at INT UNSIGNED NOT NULL, + INDEX idx_user_id (user_id), + INDEX idx_last_activity (last_activity), + INDEX idx_created_at (created_at), + CONSTRAINT fk_sessions_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Remember me tokens table +CREATE TABLE IF NOT EXISTS remember_tokens ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL, + session_id VARCHAR(128) DEFAULT NULL, + token VARCHAR(255) NOT NULL UNIQUE, + expires_at TIMESTAMP NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE, + INDEX idx_token (token), + INDEX idx_user_id (user_id), + INDEX idx_session_id (session_id), + INDEX idx_expires_at (expires_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- User notifications table (in-app notifications) +CREATE TABLE IF NOT EXISTS user_notifications ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL, + type VARCHAR(50) NOT NULL, + title VARCHAR(255) NOT NULL, + message TEXT NOT NULL, + domain_id INT NULL, + is_read BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + read_at TIMESTAMP NULL, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (domain_id) REFERENCES domains(id) ON DELETE SET NULL, + INDEX idx_user_id (user_id), + INDEX idx_is_read (is_read), + INDEX idx_created_at (created_at), + INDEX idx_type (type) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ===================================================== +-- TLD REGISTRY SYSTEM +-- ===================================================== + +-- TLD registry table +CREATE TABLE IF NOT EXISTS tld_registry ( + id INT AUTO_INCREMENT PRIMARY KEY, + tld VARCHAR(63) NOT NULL UNIQUE, + rdap_servers JSON, + whois_server VARCHAR(255), + registry_url VARCHAR(500), + iana_publication_date TIMESTAMP NULL, + iana_last_updated TIMESTAMP NULL, + record_last_updated TIMESTAMP NULL, + registration_date DATE NULL, + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_tld (tld), + INDEX idx_is_active (is_active), + INDEX idx_iana_publication_date (iana_publication_date) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- TLD import logs table +CREATE TABLE IF NOT EXISTS tld_import_logs ( + id INT AUTO_INCREMENT PRIMARY KEY, + import_type ENUM('tld_list', 'rdap', 'whois', 'manual', 'complete_workflow', 'check_updates') NOT NULL, + total_tlds INT DEFAULT 0, + new_tlds INT DEFAULT 0, + updated_tlds INT DEFAULT 0, + failed_tlds INT DEFAULT 0, + iana_publication_date TIMESTAMP NULL, + version VARCHAR(50) NULL, + started_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + completed_at TIMESTAMP NULL, + status ENUM('running', 'completed', 'failed') DEFAULT 'running', + error_message TEXT, + details JSON, + INDEX idx_started_at (started_at), + INDEX idx_status (status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ===================================================== +-- SYSTEM SETTINGS +-- ===================================================== + +-- Settings table +CREATE TABLE IF NOT EXISTS settings ( + id INT AUTO_INCREMENT PRIMARY KEY, + setting_key VARCHAR(255) NOT NULL UNIQUE, + setting_value TEXT, + `type` VARCHAR(50) DEFAULT 'string', + `description` TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Insert default settings +INSERT INTO settings (setting_key, setting_value, `type`, `description`) VALUES +-- Application settings +('app_name', 'Domain Monitor', 'string', 'Application name'), +('app_url', 'http://localhost:8000', 'string', 'Application URL'), +('app_timezone', 'UTC', 'string', 'Application timezone'), +('app_version', '1.1.0', 'string', 'Application version number'), + +-- Email settings +('mail_host', 'smtp.mailtrap.io', 'string', 'SMTP server host'), +('mail_port', '2525', 'string', 'SMTP server port'), +('mail_username', '', 'string', 'SMTP username'), +('mail_password', '', 'encrypted', 'SMTP password (encrypted)'), +('mail_encryption', 'tls', 'string', 'SMTP encryption (tls/ssl)'), +('mail_from_address', 'noreply@domainmonitor.com', 'string', 'From email address'), +('mail_from_name', 'Domain Monitor', 'string', 'From name'), + +-- Monitoring settings +('notification_days_before', '60,30,21,14,7,5,3,2,1', 'string', 'Notification days before expiration'), +('check_interval_hours', '24', 'string', 'Domain check interval in hours'), +('last_check_run', NULL, 'datetime', 'Last time cron job ran'), + +-- Authentication settings +('registration_enabled', '0', 'boolean', 'Enable user registration'), +('require_email_verification', '1', 'boolean', 'Require email verification for new users') + +ON DUPLICATE KEY UPDATE setting_key=setting_key; + +-- ===================================================== +-- MIGRATION TRACKING +-- ===================================================== + +-- Migrations tracking table +CREATE TABLE IF NOT EXISTS migrations ( + id INT AUTO_INCREMENT PRIMARY KEY, + migration VARCHAR(255) NOT NULL UNIQUE, + executed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + INDEX idx_migration (migration) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Mark this consolidated migration as executed +INSERT INTO migrations (migration) VALUES ('000_initial_schema_v1.1.0.sql') +ON DUPLICATE KEY UPDATE migration=migration; + diff --git a/database/migrations/002_create_users_table.sql b/database/migrations/002_create_users_table.sql index 764bf97..4f4d106 100644 --- a/database/migrations/002_create_users_table.sql +++ b/database/migrations/002_create_users_table.sql @@ -14,8 +14,8 @@ CREATE TABLE IF NOT EXISTS users ( ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; -- Insert default admin user --- Password is randomly generated during migration and displayed in output --- Hash placeholder will be replaced by migrate.php +-- Password is randomly generated during installation and displayed in output +-- Hash placeholder will be replaced by web installer INSERT INTO users (username, password, email, full_name, is_active) VALUES ('admin', '{{ADMIN_PASSWORD_HASH}}', 'admin@domainmonitor.local', 'Administrator', 1) ON DUPLICATE KEY UPDATE username=username; diff --git a/database/migrations/009_add_authentication_features.sql b/database/migrations/009_add_authentication_features.sql new file mode 100644 index 0000000..b709920 --- /dev/null +++ b/database/migrations/009_add_authentication_features.sql @@ -0,0 +1,49 @@ +-- Add authentication features +-- Email verification and password reset tokens + +-- Add email verification fields to users table +ALTER TABLE users +ADD COLUMN email_verified BOOLEAN DEFAULT FALSE AFTER email, +ADD COLUMN email_verification_token VARCHAR(255) NULL AFTER email_verified, +ADD COLUMN email_verification_sent_at TIMESTAMP NULL AFTER email_verification_token; + +-- Create password reset tokens table +CREATE TABLE IF NOT EXISTS password_reset_tokens ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL, + token VARCHAR(255) NOT NULL UNIQUE, + expires_at TIMESTAMP NOT NULL, + used BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + INDEX idx_token (token), + INDEX idx_user_id (user_id), + INDEX idx_expires_at (expires_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Create remember me tokens table +CREATE TABLE IF NOT EXISTS remember_tokens ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL, + token VARCHAR(255) NOT NULL UNIQUE, + expires_at TIMESTAMP NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + INDEX idx_token (token), + INDEX idx_user_id (user_id), + INDEX idx_expires_at (expires_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Add role field to users for future multi-user support +ALTER TABLE users +ADD COLUMN role VARCHAR(50) DEFAULT 'user' AFTER full_name; + +-- Update existing admin user to have admin role +UPDATE users SET role = 'admin' WHERE username = 'admin'; + +-- Add settings for registration +INSERT INTO settings (setting_key, setting_value) VALUES +('registration_enabled', '0'), +('require_email_verification', '1') +ON DUPLICATE KEY UPDATE setting_key=setting_key; + diff --git a/database/migrations/010_add_app_version_setting.sql b/database/migrations/010_add_app_version_setting.sql new file mode 100644 index 0000000..71dced4 --- /dev/null +++ b/database/migrations/010_add_app_version_setting.sql @@ -0,0 +1,5 @@ +-- Add application version to settings +INSERT INTO settings (setting_key, setting_value) VALUES +('app_version', '1.1.0') +ON DUPLICATE KEY UPDATE setting_key=setting_key; + diff --git a/database/migrations/011_create_sessions_table.sql b/database/migrations/011_create_sessions_table.sql new file mode 100644 index 0000000..3e9dcb7 --- /dev/null +++ b/database/migrations/011_create_sessions_table.sql @@ -0,0 +1,21 @@ +-- Create new sessions table compatible with PHP session handler +CREATE TABLE `sessions` ( + `id` VARCHAR(128) NOT NULL PRIMARY KEY, + `user_id` INT DEFAULT NULL, + `ip_address` VARCHAR(45) NOT NULL, + `user_agent` TEXT, + `country` VARCHAR(100) DEFAULT NULL, + `country_code` VARCHAR(2) DEFAULT NULL, + `region` VARCHAR(100) DEFAULT NULL, + `city` VARCHAR(100) DEFAULT NULL, + `isp` VARCHAR(255) DEFAULT NULL, + `timezone` VARCHAR(50) DEFAULT NULL, + `payload` MEDIUMTEXT NOT NULL, + `last_activity` INT UNSIGNED NOT NULL, + `created_at` INT UNSIGNED NOT NULL, + INDEX `idx_user_id` (`user_id`), + INDEX `idx_last_activity` (`last_activity`), + INDEX `idx_created_at` (`created_at`), + CONSTRAINT `fk_sessions_user` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + diff --git a/database/migrations/012_link_remember_tokens_to_sessions.sql b/database/migrations/012_link_remember_tokens_to_sessions.sql new file mode 100644 index 0000000..4ae55dc --- /dev/null +++ b/database/migrations/012_link_remember_tokens_to_sessions.sql @@ -0,0 +1,7 @@ +-- Link remember tokens to sessions +-- This ensures deleting a session also invalidates the remember token + +-- Add session_id column to remember_tokens +ALTER TABLE `remember_tokens` +ADD COLUMN `session_id` VARCHAR(128) DEFAULT NULL AFTER `user_id`, +ADD INDEX `idx_session_id` (`session_id`); diff --git a/database/migrations/013_create_user_notifications_table.sql b/database/migrations/013_create_user_notifications_table.sql new file mode 100644 index 0000000..7f3185b --- /dev/null +++ b/database/migrations/013_create_user_notifications_table.sql @@ -0,0 +1,19 @@ +-- Create user_notifications table for in-app notifications +CREATE TABLE IF NOT EXISTS `user_notifications` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `user_id` INT NOT NULL, + `type` VARCHAR(50) NOT NULL, + `title` VARCHAR(255) NOT NULL, + `message` TEXT NOT NULL, + `domain_id` INT NULL, + `is_read` BOOLEAN DEFAULT FALSE, + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + `read_at` TIMESTAMP NULL, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE, + FOREIGN KEY (`domain_id`) REFERENCES `domains`(`id`) ON DELETE SET NULL, + INDEX `idx_user_id` (`user_id`), + INDEX `idx_is_read` (`is_read`), + INDEX `idx_created_at` (`created_at`), + INDEX `idx_type` (`type`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + diff --git a/database/migrations/README.md b/database/migrations/README.md new file mode 100644 index 0000000..7fbe681 --- /dev/null +++ b/database/migrations/README.md @@ -0,0 +1,36 @@ +# Database Migrations + +## Fresh Installation (v1.1.0+) + +For new installations, use the consolidated schema: + +- **`000_initial_schema_v1.1.0.sql`** - Complete database schema for v1.1.0 + +**Install via:** Web installer at `/install` + +## Incremental Migrations (v1.0.0 → v1.1.0) + +If upgrading from v1.0.0, these incremental migrations will be applied: + +- `001_create_tables.sql` - Core tables (domains, groups, channels, logs) +- `002_create_users_table.sql` - Users table +- `003_add_whois_fields.sql` - WHOIS data fields +- `004_create_tld_registry_table.sql` - TLD registry +- `005_update_tld_import_logs.sql` - Import logs updates +- `006_add_complete_workflow_import_type.sql` - Workflow import type +- `007_add_app_and_email_settings.sql` - Application settings +- `008_add_notes_to_domains.sql` - Domain notes field +- `009_add_authentication_features.sql` - Authentication system +- `010_add_app_version_setting.sql` - Version setting + +**Upgrade via:** Web updater at `/install/update` + +## Migration System + +The installer automatically: +- Detects if this is a fresh install or upgrade +- Uses consolidated schema for fresh installs +- Uses incremental migrations for upgrades +- Tracks executed migrations in `migrations` table +- Prevents re-running completed migrations + diff --git a/public/index.php b/public/index.php index bf74ea5..d64173e 100644 --- a/public/index.php +++ b/public/index.php @@ -12,9 +12,54 @@ define('PATH_ROOT', __DIR__ . '/../'); $dotenv = Dotenv::createImmutable(__DIR__ . '/..'); $dotenv->load(); +// Configure database session handler +try { + // Only use database sessions if sessions table exists + $pdo = new PDO( + "mysql:host={$_ENV['DB_HOST']};dbname={$_ENV['DB_DATABASE']}", + $_ENV['DB_USERNAME'], + $_ENV['DB_PASSWORD'] + ); + + // Check if sessions table exists + $stmt = $pdo->query("SHOW TABLES LIKE 'sessions'"); + if ($stmt->rowCount() > 0) { + // Use database session handler + $sessionLifetime = (int)($_ENV['SESSION_LIFETIME'] ?? 1440); + $handler = new Core\DatabaseSessionHandler($sessionLifetime); + session_set_save_handler($handler, true); + } +} catch (\Exception $e) { + // Fall back to default file-based sessions + error_log("Database session handler not available, using file sessions: " . $e->getMessage()); +} + // Start session session_start(); +// Validate session exists in database (for database-backed sessions) +// This ensures deleted sessions are immediately invalidated +Core\SessionValidator::validate(); + +// Check if system is installed (using flag file - no DB queries!) +$currentPath = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH); +$isInstallerPath = strpos($currentPath, '/install') === 0; +$installedFlagFile = __DIR__ . '/../.installed'; + +if (!$isInstallerPath) { + // Check if .installed flag file exists + if (!file_exists($installedFlagFile)) { + header('Location: /install'); + exit; + } +} + +// Check remember me token if user is not logged in +if (!isset($_SESSION['user_id']) && isset($_COOKIE['remember_token']) && !$isInstallerPath) { + $authController = new \App\Controllers\AuthController(); + $authController->checkRememberToken(); +} + // Initialize application $app = new Application(); diff --git a/routes/web.php b/routes/web.php index 71838ab..a22367c 100644 --- a/routes/web.php +++ b/routes/web.php @@ -10,13 +10,33 @@ use App\Controllers\DebugController; use App\Controllers\SearchController; use App\Controllers\TldRegistryController; use App\Controllers\SettingsController; +use App\Controllers\ProfileController; +use App\Controllers\UserController; +use App\Controllers\InstallerController; +use App\Controllers\NotificationController; $router = Application::$router; +// Installer routes (public - before auth) +$router->get('/install', [InstallerController::class, 'index']); +$router->get('/install/check-database', [InstallerController::class, 'checkDatabase']); +$router->post('/install/run', [InstallerController::class, 'install']); +$router->get('/install/complete', [InstallerController::class, 'complete']); +$router->get('/install/update', [InstallerController::class, 'showUpdate']); +$router->post('/install/update', [InstallerController::class, 'runUpdate']); + // Authentication routes (public) $router->get('/login', [AuthController::class, 'showLogin']); $router->post('/login', [AuthController::class, 'login']); $router->get('/logout', [AuthController::class, 'logout']); +$router->get('/register', [AuthController::class, 'showRegister']); +$router->post('/register', [AuthController::class, 'register']); +$router->get('/verify-email', [AuthController::class, 'showVerifyEmail']); +$router->get('/resend-verification', [AuthController::class, 'resendVerification']); +$router->get('/forgot-password', [AuthController::class, 'showForgotPassword']); +$router->post('/forgot-password', [AuthController::class, 'forgotPassword']); +$router->get('/reset-password', [AuthController::class, 'showResetPassword']); +$router->post('/reset-password', [AuthController::class, 'resetPassword']); // Debug route (public - remove in production!) $router->get('/debug/whois', [DebugController::class, 'whois']); @@ -87,3 +107,30 @@ $router->post('/settings/test-email', [SettingsController::class, 'testEmail']); $router->post('/settings/test-cron', [SettingsController::class, 'testCron']); $router->post('/settings/clear-logs', [SettingsController::class, 'clearLogs']); +// Profile +$router->get('/profile', [ProfileController::class, 'index']); +$router->post('/profile/update', [ProfileController::class, 'update']); +$router->post('/profile/change-password', [ProfileController::class, 'changePassword']); +$router->get('/profile/delete', [ProfileController::class, 'delete']); +$router->get('/profile/resend-verification', [ProfileController::class, 'resendVerification']); +$router->post('/profile/logout-other-sessions', [ProfileController::class, 'logoutOtherSessions']); +$router->post('/profile/logout-session/{sessionId}', [ProfileController::class, 'logoutSession']); + +// Notifications +$router->get('/notifications', [NotificationController::class, 'index']); +$router->get('/notifications/{id}/mark-read', [NotificationController::class, 'markAsRead']); +$router->get('/notifications/mark-all-read', [NotificationController::class, 'markAllAsRead']); +$router->get('/notifications/{id}/delete', [NotificationController::class, 'delete']); +$router->get('/notifications/clear-all', [NotificationController::class, 'clearAll']); +$router->get('/api/notifications/unread-count', [NotificationController::class, 'getUnreadCount']); +$router->get('/api/notifications/recent', [NotificationController::class, 'getRecent']); + +// User Management (Admin Only) +$router->get('/users', [UserController::class, 'index']); +$router->get('/users/create', [UserController::class, 'create']); +$router->post('/users/store', [UserController::class, 'store']); +$router->get('/users/edit', [UserController::class, 'edit']); +$router->post('/users/update', [UserController::class, 'update']); +$router->get('/users/delete', [UserController::class, 'delete']); +$router->get('/users/toggle-status', [UserController::class, 'toggleStatus']); +