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 = " +
Hello {$fullName},
+Thank you for registering. Please click the link below to verify your 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 = " +Hello {$fullName},
+We received a request to reset your password. Click the link below to create a new 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 @@ + + + + + ++ © = date('Y') ?> Domain Monitor. All rights reserved. +
+No worries, we'll send you reset instructions
+Sign in to access your account
-Sign in to access your account
+- © = date('Y') ?> Domain Monitor. All rights reserved. -
+ + + + ++ Already have an account? + + Sign In + +
+Enter your new password below
+Your email address has been successfully verified.
+ + + + Sign In to Your Account + += htmlspecialchars($errorMessage ?? 'Invalid or expired verification link.') ?>
+ + ++ We've sent a verification link to = htmlspecialchars($email ?? 'your email') ?>. + Please check your inbox and click the link to verify your account. +
+ ++ + Didn't receive the email? +
+= htmlspecialchars($domain['group_name']) ?>
- $ch['is_active']); - ?>- = count($activeChannels) ?> / = count($domain['channels']) ?> channels active + = $domain['activeChannelCount'] ?? 0 ?> / = count($domain['channels']) ?> channels active
Domain Monitor is ready to use
+This password will not be shown again. Save it to a secure password manager.
+ +© = date('Y') ?> Domain Monitor
+New database migrations are available
+Please backup your database before running updates.
++ + Total: = count($migrations) ?> migration(s) +
+© = date('Y') ?> Domain Monitor
+Welcome! Let's set up your monitoring system
+Create tables and structure
+Set your credentials below
+Begin tracking your domains
+© = date('Y') ?> Domain Monitor
+System
© = date('Y') ?> Domain Monitor
-v1.0.0
+v= $appVersion ?>