commit b3b3ac66ffe80c2a79132ed2e41e837d8160bc8d Author: Hosteroid Date: Wed Oct 8 14:23:07 2025 +0300 Initial Commit diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..6525acd --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,53 @@ +--- +name: Bug Report +about: Create a report to help us improve +title: '[BUG] ' +labels: bug +assignees: '' +--- + +## πŸ› Bug Description +A clear and concise description of what the bug is. + +## πŸ“‹ Steps to Reproduce +Steps to reproduce the behavior: +1. Go to '...' +2. Click on '...' +3. Scroll down to '...' +4. See error + +## βœ… Expected Behavior +A clear and concise description of what you expected to happen. + +## ❌ Actual Behavior +A clear and concise description of what actually happened. + +## πŸ“Έ Screenshots +If applicable, add screenshots to help explain your problem. + +## πŸ–₯️ Environment +- **OS:** [e.g., Ubuntu 22.04, Windows 11, macOS 13] +- **PHP Version:** [e.g., 8.1.12] +- **Database:** [e.g., MySQL 8.0, MariaDB 10.6] +- **Web Server:** [e.g., Apache 2.4, Nginx 1.21] +- **Browser:** [e.g., Chrome 110, Firefox 109] (if UI issue) + +## πŸ“ Additional Context +Add any other context about the problem here. + +## πŸ” Error Logs +If applicable, paste any error messages from: +- PHP error log +- Application logs (`logs/cron.log`) +- Browser console (for UI issues) + +``` +Paste error logs here +``` + +## βœ”οΈ Checklist +- [ ] I have checked the [documentation](https://github.com/Hosteroid/domain-monitor/wiki) +- [ ] I have searched for similar issues +- [ ] I have provided all required information +- [ ] I can reproduce this bug consistently + diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..cd5fbcf --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,42 @@ +--- +name: Feature Request +about: Suggest an idea for this project +title: '[FEATURE] ' +labels: enhancement +assignees: '' +--- + +## πŸ’‘ Feature Description +A clear and concise description of the feature you'd like to see. + +## 🎯 Problem Statement +Is your feature request related to a problem? Please describe. +Example: I'm always frustrated when [...] + +## πŸš€ Proposed Solution +Describe the solution you'd like to see implemented. + +## πŸ”„ Alternatives Considered +Describe any alternative solutions or features you've considered. + +## πŸ“Š Use Case +Describe how you would use this feature. Include: +- Who would benefit from this feature? +- How often would it be used? +- What problem does it solve? + +## 🎨 Mockups/Examples +If applicable, add mockups, screenshots, or examples from other applications. + +## πŸ› οΈ Implementation Ideas +If you have technical ideas about how this could be implemented, share them here. + +## πŸ“ Additional Context +Add any other context or information about the feature request here. + +## βœ”οΈ Checklist +- [ ] I have searched for similar feature requests +- [ ] This feature aligns with the project's goals +- [ ] I can help test this feature when implemented +- [ ] I would be willing to contribute to implementation (optional) + diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..f3833e6 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,89 @@ +## πŸ“ Description + + + + +## 🎯 Type of Change + + +- [ ] πŸ› Bug fix (non-breaking change that fixes an issue) +- [ ] ✨ New feature (non-breaking change that adds functionality) +- [ ] πŸ’₯ Breaking change (fix or feature that would cause existing functionality to change) +- [ ] πŸ“ Documentation update +- [ ] 🎨 UI/UX improvement +- [ ] ⚑ Performance improvement +- [ ] ♻️ Code refactoring + +## πŸ”— Related Issues + + +Fixes # +Closes # +Related to # + +## πŸ§ͺ Testing + + +- [ ] Tested on PHP 8.1 +- [ ] Tested on PHP 8.2 +- [ ] Tested on PHP 8.3 +- [ ] Tested on PHP 8.4 +- [ ] Tested with MySQL +- [ ] Tested with MariaDB +- [ ] Tested on different browsers (if UI change) +- [ ] Tested on mobile devices (if UI change) + +### Test Steps +1. +2. +3. + +## πŸ“Έ Screenshots + + +### Before + + +### After + + +## βœ… Checklist + + +- [ ] My code follows the project's coding standards (PSR-12) +- [ ] I have performed a self-review of my code +- [ ] I have commented my code, particularly in hard-to-understand areas +- [ ] I have made corresponding changes to the documentation +- [ ] My changes generate no new warnings or errors +- [ ] I have added tests that prove my fix is effective or that my feature works +- [ ] New and existing unit tests pass locally with my changes +- [ ] Any dependent changes have been merged and published + +## πŸ“š Documentation Changes + + +- [ ] README.md updated +- [ ] CHANGELOG.md updated +- [ ] Wiki updated (if applicable) +- [ ] Code comments added +- [ ] API documentation updated (if applicable) + +## πŸ” Security Considerations + + + + +## ⚠️ Breaking Changes + + + + +## πŸ“ Additional Notes + + + + +--- + + + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..71babbb --- /dev/null +++ b/.gitignore @@ -0,0 +1,61 @@ +# Environment & Configuration +.env +.env.local +.env.*.local + +# Composer +/vendor/ +composer.lock + +# Logs +/logs/*.log +/logs/*.txt +!/logs/README.md +!/logs/QUICK_START.md + +# Cache +/cache/* +!/cache/.gitkeep + +# IDE & Editor +.vscode/ +.idea/ +*.swp +*.swo +*~ +.DS_Store +Thumbs.db + +# OS +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Desktop.ini + +# Backup files +*.bak +*.backup +*.old +*.tmp + +# Sensitive data +*.pem +*.key +*.crt +/database/backups/ + +# Development +/tests/coverage/ +.phpunit.result.cache + +# Node modules (if ever added) +node_modules/ +package-lock.json +yarn.lock + +# Build artifacts +dist/ +build/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..2a7c63a --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,149 @@ +# Changelog + +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] + +### Added +- 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 + - Import logs and history tracking +- Advanced domain verification using TLD registry data +- RDAP protocol support for modern domain queries +- Automatic WHOIS server discovery per TLD +- Monitoring status change notifications (activated/deactivated alerts) +- Notification group assignment change alerts +- Enhanced domain detail view with channel status indicators +- Comprehensive notification threshold configuration +- Debug logging for notification thresholds + +### Changed +- Unified design system across all views + - Consistent header styles (bordered instead of gradients) + - Standardized button sizes and padding + - Consistent form input styling + - Unified empty state designs + - Removed emojis from UI elements +- Improved navigation flow (edit page returns to detail view) +- Enhanced cron job logging with threshold display + +### Fixed +- Notification channel type display error in domain view +- Navigation redirect after domain update +- Cancel button redirect in domain edit page +- Design inconsistencies in notification group views + +### Security +- Random secure password generation on installation +- One-time password display during migration +- 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) +- βœ… Email notifications with HTML templates +- βœ… Rich Discord embeds with color coding +- βœ… Telegram messages with formatting +- βœ… Slack blocks for structured messages +- βœ… Notification deduplication (prevent spam) +- βœ… Manual domain refresh +- βœ… Active/inactive domain toggle +- βœ… Comprehensive logging +- βœ… Statistics dashboard +- βœ… Recent notifications view +- βœ… Domain details with WHOIS data +- βœ… Nameserver display +- βœ… Notification history per domain + +### Technical +- PHP 8.1+ with modern features (match expressions, typed properties) +- MySQL/MariaDB database +- PSR-4 autoloading +- Environment-based configuration +- MVC pattern implementation +- Service layer architecture +- Repository pattern for data access +- Interface-based notification channels +- JSON configuration storage +- Prepared statements for SQL injection prevention +- CSRF token support ready +- Responsive CSS with CSS variables +- No JavaScript framework dependencies (vanilla JS where needed) + +### 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 +- [ ] API for external integrations +- [ ] Domain grouping/tagging +- [ ] Custom notification templates +- [ ] SMS notifications (Twilio) +- [ ] WhatsApp notifications +- [ ] Export functionality (CSV, PDF) +- [ ] Import domains from CSV +- [ ] Domain transfer tracking +- [ ] DNS record monitoring +- [ ] SSL certificate monitoring +- [ ] Downtime monitoring +- [ ] 2FA for login +- [ ] Mobile app +- [ ] Docker support +- [ ] Redis caching +- [ ] Rate limiting +- [ ] Webhook support for third-party integrations +- [ ] Dark mode UI toggle +- [ ] Multi-language support +- [ ] Advanced filtering and search +- [ ] Bulk operations +- [ ] Scheduled reports +- [ ] Integration with domain registrars + +--- + +## Version History + +### 1.0.0 (2024-10-08) +- Initial public release +- Created by [Hosteroid](https://www.hosteroid.uk) - Premium Hosting Solutions + diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..d88e376 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,316 @@ +# Contributing to Domain Monitor + +Thank you for your interest in contributing to Domain Monitor! This document provides guidelines and instructions for contributing. + +## 🌟 Ways to Contribute + +- πŸ› **Report bugs** - Help us identify and fix issues +- πŸ’‘ **Suggest features** - Share ideas for improvements +- πŸ“ **Improve documentation** - Help others understand the project +- πŸ”§ **Submit code** - Fix bugs or implement features +- 🎨 **Enhance UI/UX** - Improve the user experience +- 🌍 **Add translations** - Make the project accessible to more users + +## πŸ› Reporting Bugs + +Before submitting a bug report: + +1. **Check existing issues** to avoid duplicates +2. **Update to the latest version** to see if the issue persists +3. **Gather information** about your environment + +When reporting a bug, include: + +- Clear description of the issue +- Steps to reproduce +- Expected vs actual behavior +- Environment details (OS, PHP version, etc.) +- Error messages and logs +- Screenshots if applicable + +Use our [Bug Report Template](.github/ISSUE_TEMPLATE/bug_report.md). + +## πŸ’‘ Suggesting Features + +We welcome feature suggestions! Before submitting: + +1. **Check the roadmap** to see if it's already planned +2. **Search existing issues** to avoid duplicates +3. **Consider the project scope** - does it fit? + +When suggesting a feature, include: + +- Clear description of the feature +- Problem it solves +- Use cases and benefits +- Implementation ideas (if any) + +Use our [Feature Request Template](.github/ISSUE_TEMPLATE/feature_request.md). + +## πŸ”§ Code Contributions + +### Getting Started + +1. **Fork the repository** + ```bash + git clone https://github.com/Hosteroid/domain-monitor.git + cd domain-monitor + ``` + +2. **Install dependencies** + ```bash + composer install + ``` + +3. **Set up your development environment** + - Copy `env.example.txt` to `.env` + - Configure database settings + - Run migrations: `php database/migrate.php` + +4. **Create a feature branch** + ```bash + git checkout -b feature/your-feature-name + ``` + +### Coding Standards + +We follow PSR-12 coding standards. Key points: + +#### PHP Code Style +- Use 4 spaces for indentation (no tabs) +- Follow PSR-12 naming conventions +- Add type hints for parameters and return types +- Use strict typing: `declare(strict_types=1);` + +#### File Organization +- Controllers in `app/Controllers/` +- Models in `app/Models/` +- Services in `app/Services/` +- Views in `app/Views/` + +#### Naming Conventions +```php +// Classes: PascalCase +class DomainController {} + +// Methods: camelCase +public function getDomainInfo() {} + +// Variables: camelCase +$domainName = 'example.com'; + +// Constants: UPPER_SNAKE_CASE +const MAX_DOMAINS = 1000; + +// Database tables: snake_case +domains, notification_groups, notification_logs +``` + +#### Documentation +```php +/** + * Get domain information via WHOIS lookup + * + * @param string $domain The domain name to lookup + * @return array|null Domain info or null if lookup fails + */ +public function getDomainInfo(string $domain): ?array +{ + // Implementation +} +``` + +#### Security +- Always use prepared statements for SQL queries +- Sanitize user input with `htmlspecialchars()` +- Validate and type-check all inputs +- Never expose sensitive data in error messages + +### Database Changes + +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`) + - Include `IF NOT EXISTS` checks where appropriate + +2. **Update `database/migrate.php`** to include the new migration + +3. **Test the migration** on a fresh database + +### Frontend Changes + +If modifying views: + +- Follow the established design patterns +- Use consistent spacing and styling +- Ensure responsive design (mobile-friendly) +- Test in multiple browsers +- Use semantic HTML +- Keep JavaScript minimal and vanilla (no frameworks) + +### Testing + +Before submitting: + +1. **Test your changes** thoroughly + - Test in different environments + - Test edge cases + - Test with different PHP versions (8.1+) + +2. **Check for errors** + - No PHP warnings or notices + - No console errors (for UI changes) + - No SQL errors + +3. **Verify functionality** + - Feature works as expected + - Doesn't break existing functionality + - Handles errors gracefully + +### Commit Messages + +Write clear, descriptive commit messages: + +```bash +# Good commit messages +git commit -m "Add RDAP support for .uk domains" +git commit -m "Fix notification duplicate issue on cron" +git commit -m "Update UI design for consistency" + +# Bad commit messages +git commit -m "fix bug" +git commit -m "changes" +git commit -m "update" +``` + +**Format:** +- Use present tense ("Add feature" not "Added feature") +- Be specific and descriptive +- Reference issues when applicable: "Fix #123: Domain refresh error" + +### Pull Request Process + +1. **Update documentation** if needed + - README.md for new features + - CHANGELOG.md for all changes + - Inline code comments + +2. **Create Pull Request** + - Use a clear title + - Describe what changed and why + - Link related issues + - Add screenshots for UI changes + +3. **PR Template** + ```markdown + ## Description + Brief description of changes + + ## Type of Change + - [ ] Bug fix + - [ ] New feature + - [ ] Breaking change + - [ ] Documentation update + + ## Testing + How has this been tested? + + ## Checklist + - [ ] Code follows project style + - [ ] Self-review completed + - [ ] Comments added for complex code + - [ ] Documentation updated + - [ ] No new warnings + - [ ] Tests pass + ``` + +4. **Respond to feedback** + - Address review comments + - Make requested changes + - Keep the conversation productive + +## πŸ“ Documentation + +Help improve our documentation: + +- Fix typos and unclear explanations +- Add examples and use cases +- Improve installation instructions +- Create tutorials or guides +- Translate documentation + +## 🎨 Design Guidelines + +When contributing UI/UX changes: + +- **Consistency** - Follow existing design patterns +- **Accessibility** - Ensure usability for all users +- **Responsiveness** - Works on all screen sizes +- **Performance** - Optimize images and assets +- **Simplicity** - Keep it clean and intuitive + +## 🌍 Translations + +We welcome translations! To add a new language: + +1. Create language files in appropriate directories +2. Follow existing translation structure +3. Test thoroughly with the interface +4. Ensure all strings are translated + +## πŸ“œ Code of Conduct + +### Our Standards + +- Be respectful and inclusive +- Welcome newcomers +- Accept constructive criticism +- Focus on what's best for the community +- Show empathy towards others + +### Unacceptable Behavior + +- Harassment or discrimination +- Trolling or insulting comments +- Public or private harassment +- Publishing others' private information +- Other unprofessional conduct + +## πŸ† Recognition + +Contributors are recognized in: + +- CHANGELOG.md for significant contributions +- README.md contributors section +- GitHub contributors page + +## πŸ“ž Getting Help + +- πŸ’¬ [GitHub Discussions](https://github.com/Hosteroid/domain-monitor/discussions) - Ask questions +- πŸ› [Issues](https://github.com/Hosteroid/domain-monitor/issues) - Report bugs +- πŸ“– [Wiki](https://github.com/Hosteroid/domain-monitor/wiki) - Documentation + +## πŸ“‹ Project Priorities + +Current focus areas: + +1. Bug fixes and stability +2. Performance improvements +3. Documentation +4. New notification channels +5. API development +6. Multi-user support + +See [CHANGELOG.md](CHANGELOG.md) for the full roadmap. + +--- + +
+ +**Thank you for contributing to Domain Monitor!** πŸš€ + +A project by [Hosteroid](https://www.hosteroid.uk) - Premium Hosting Solutions + +
+ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e041469 --- /dev/null +++ b/LICENSE @@ -0,0 +1,22 @@ +MIT License + +Copyright (c) 2024 Domain Monitor Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + diff --git a/README.md b/README.md new file mode 100644 index 0000000..dc2003f --- /dev/null +++ b/README.md @@ -0,0 +1,453 @@ +# 🌐 Domain Monitor + +> A powerful, self-hosted domain expiration monitoring system with multi-channel notifications + +[![PHP Version](https://img.shields.io/badge/PHP-8.1%2B-blue.svg)](https://www.php.net/) +[![License](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE) +[![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](CONTRIBUTING.md) + +A modern PHP MVC application for monitoring domain expiration dates and sending notifications through multiple channels (Email, Telegram, Discord, Slack). Never lose a domain again with automated monitoring and timely alerts. + +## ✨ Features + +### Core Features +- πŸ“‹ **Domain Management** - Add, edit, and monitor unlimited domains +- πŸ” **Smart WHOIS/RDAP Lookup** - Automatically fetches expiration dates and registrar information +- πŸ—‚οΈ **TLD Registry System** - Built-in support for 1,400+ TLDs with IANA integration +- πŸ”” **Multi-Channel Notifications** - Email, Telegram, Discord, and Slack support +- πŸ‘₯ **Notification Groups** - Organize channels and assign domains flexibly +- ⚑ **Real-time Dashboard** - Overview of all domains and their status +- πŸ“Š **Notification Logs** - Complete history of all sent notifications +- πŸ€– **Automated Monitoring** - Cron-based checks with configurable intervals +- 🎨 **Modern UI** - Clean, responsive design with intuitive interface + +### Advanced Features +- πŸ” **Secure by Default** - Random passwords, session management, prepared statements +- πŸ“ˆ **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 + +## πŸ“‹ Requirements + +- PHP 8.1 or higher +- MySQL 5.7+ or MariaDB 10.3+ +- Composer +- Apache/Nginx with mod_rewrite enabled +- Cron support for automated checks +- SMTP server for email notifications (optional) + +## πŸ” Security + +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 +- πŸ’‰ **SQL Injection Protection** - All queries use prepared statements +- πŸ”’ **One-time Credentials** - Admin password shown only once during setup + +⚠️ **Important:** Save your admin password during installation - it won't be shown again! + +## πŸš€ Quick Start + +### 1. Clone the Repository + +```bash +git clone https://github.com/Hosteroid/domain-monitor.git +cd domain-monitor +``` + +### 2. Install Dependencies + +```bash +composer install +``` + +### 3. Configure Environment + +Copy the example environment file: + +```bash +# Linux/Mac +cp env.example.txt .env + +# Windows +copy env.example.txt .env +``` + +Edit `.env` and configure your settings: + +```ini +# Database +DB_HOST=localhost +DB_PORT=3306 +DB_DATABASE=domain_monitor +DB_USERNAME=root +DB_PASSWORD=your_password + +# Email (if using email notifications) +MAIL_HOST=smtp.mailtrap.io +MAIL_PORT=2525 +MAIL_USERNAME=your_username +MAIL_PASSWORD=your_password +MAIL_FROM_ADDRESS=noreply@domainmonitor.com +``` + +### 4. Create Database + +Create a MySQL database: + +```sql +CREATE DATABASE domain_monitor CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +``` + +### 5. Run Migrations + +```bash +php database/migrate.php +``` + +**⚠️ IMPORTANT:** The migration will generate a random admin password and display it **only once**: + +``` +πŸ”‘ Admin credentials (SAVE THESE!): + ═══════════════════════════════════════ + Username: admin + Password: 3f8a2b9c4d5e6f7a + ═══════════════════════════════════════ + ⚠️ This password will not be shown again! + πŸ’Ύ Save it to a secure password manager. +``` + +**Save this password immediately** - you'll need it to access the dashboard! + +### 6. Import TLD Registry Data (Optional but Recommended) + +For enhanced WHOIS lookups with automatic server discovery: + +```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 + +Make sure `.htaccess` is enabled. Your virtual host should point to the `public` directory. + +Example configuration: + +```apache + + ServerName domainmonitor.local + DocumentRoot "D:/Cursor/Domain Monitor/public" + + + AllowOverride All + Require all granted + + +``` + +#### PHP Built-in Server (Development) + +```bash +php -S localhost:8000 -t public +``` + +Then visit: `http://localhost:8000` + +## πŸ”§ Configuration + +### Notification Channels + +#### πŸ“§ Email + +Configure SMTP settings in `.env`: + +```ini +MAIL_HOST=smtp.gmail.com +MAIL_PORT=587 +MAIL_USERNAME=your-email@gmail.com +MAIL_PASSWORD=your-app-password +MAIL_ENCRYPTION=tls +``` + +#### ✈️ Telegram + +1. Create a bot using [@BotFather](https://t.me/BotFather) +2. Get your Chat ID using [@userinfobot](https://t.me/userinfobot) +3. Add the channel in the notification group settings + +#### πŸ’¬ Discord + +1. Go to Server Settings β†’ Integrations β†’ Webhooks +2. Create a new webhook +3. Copy the webhook URL +4. Add it in the notification group settings + +#### πŸ’Ό Slack + +1. Go to Slack App Settings +2. Enable Incoming Webhooks +3. Create a new webhook +4. Copy the webhook URL +5. Add it in the notification group settings + +## πŸ“… Setting Up Cron Jobs + +The application requires a cron job to check domains periodically. + +### Linux/Mac + +```bash +crontab -e +``` + +Add this line to run daily at 9 AM: + +```cron +0 9 * * * /usr/bin/php /path/to/project/cron/check_domains.php +``` + +### Windows + +Use Task Scheduler: + +1. Open Task Scheduler +2. Create Basic Task +3. Set trigger (e.g., Daily at 9:00 AM) +4. Action: Start a program +5. Program: `C:\php\php.exe` +6. Arguments: `D:\Cursor\Domain Monitor\cron\check_domains.php` + +## πŸ§ͺ Testing Notifications + +Before setting up the cron job, test your notification channels: + +```bash +php cron/test_notification.php +``` + +Follow the prompts to test Email, Telegram, Discord, or Slack. + +## πŸ“– Usage Guide + +### Adding Domains + +1. Navigate to **Domains** β†’ **Add Domain** +2. Enter the domain name (e.g., `example.com`) +3. Optionally assign to a notification group +4. Click **Add Domain** + +The system will automatically fetch WHOIS information. + +### Creating Notification Groups + +1. Navigate to **Notification Groups** β†’ **Create Group** +2. Enter a name and description +3. Click **Create Group** +4. Add notification channels (Email, Telegram, Discord, Slack) +5. Assign domains to the group + +### Monitoring + +The **Dashboard** shows: +- Total domains and their status +- Domains expiring soon +- Recent notifications sent + +### Notification Schedule + +By default, notifications are sent at these intervals before expiration: +- 60 days (2 months) +- 30 days (1 month) +- 21 days (3 weeks) +- 14 days (2 weeks) +- 7 days (1 week) +- 5 days +- 3 days +- 2 days +- 1 day (tomorrow!) +- When expired (immediate alert) + +Configure this in `.env`: + +```ini +NOTIFICATION_DAYS_BEFORE=60,30,21,14,7,5,3,2,1 +``` + +**Customization Examples:** +```ini +# Minimal alerts +NOTIFICATION_DAYS_BEFORE=30,7,1 + +# More frequent alerts +NOTIFICATION_DAYS_BEFORE=90,60,45,30,21,14,10,7,5,3,2,1 + +# Business-focused (weekday planning) +NOTIFICATION_DAYS_BEFORE=60,30,14,7,3,1 +``` + +## πŸ“ Project Structure + +``` +Domain Monitor/ +β”œβ”€β”€ app/ +β”‚ β”œβ”€β”€ Controllers/ # Application controllers +β”‚ β”œβ”€β”€ Models/ # Database models +β”‚ β”œβ”€β”€ Services/ # Business logic & services +β”‚ β”‚ └── Channels/ # Notification channel implementations +β”‚ └── Views/ # HTML views +β”œβ”€β”€ core/ # Core MVC framework +β”œβ”€β”€ cron/ # Cron job scripts +β”œβ”€β”€ database/ +β”‚ └── migrations/ # Database migrations +β”œβ”€β”€ public/ # Web root (index.php, assets) +β”œβ”€β”€ routes/ # Route definitions +β”œβ”€β”€ vendor/ # Composer dependencies +└── .env # Environment configuration +``` + +## πŸ” Security Considerations + +1. **Never commit `.env`** - Contains sensitive credentials +2. **Secure your web server** - Point only the `public` directory to the web +3. **Use strong database passwords** +4. **Enable HTTPS** in production +5. **Protect cron endpoints** - Ensure cron scripts aren't web-accessible +6. **Regular updates** - Keep dependencies updated + +## πŸ› Troubleshooting + +### WHOIS Lookup Fails + +- Some domain TLDs may not be supported +- Check if the domain is valid and registered +- Verify your server can make outbound connections + +### 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 + +### Database Connection Error + +- Verify database credentials in `.env` +- Ensure MySQL service is running +- Check if database exists + +### Cron Job Not Running + +- Verify cron syntax and paths +- Check server logs +- Test manually: `php cron/check_domains.php` + +## πŸ› Bug Reports & Feature Requests + +We welcome bug reports and feature requests! Please use GitHub Issues: + +### 🐞 Report a Bug +Found a bug? [Open an issue](https://github.com/Hosteroid/domain-monitor/issues/new?template=bug_report.md) with: +- Clear description of the issue +- Steps to reproduce +- Expected vs actual behavior +- Environment details (PHP version, OS, etc.) + +### πŸ’‘ Request a Feature +Have an idea? [Submit a feature request](https://github.com/Hosteroid/domain-monitor/issues/new?template=feature_request.md) with: +- Clear description of the feature +- Use case and benefits +- Any implementation ideas + +## 🀝 Contributing + +Contributions are welcome and appreciated! Here's how you can help: + +### How to Contribute + +1. **Fork the repository** +2. **Create a feature branch** (`git checkout -b feature/AmazingFeature`) +3. **Make your changes** +4. **Test thoroughly** +5. **Commit your changes** (`git commit -m 'Add some AmazingFeature'`) +6. **Push to the branch** (`git push origin feature/AmazingFeature`) +7. **Open a Pull Request** + +### Development Guidelines + +- Follow PSR-12 coding standards +- Write clear commit messages +- Add comments for complex logic +- Test your changes before submitting +- Update documentation as needed + +### Areas for Contribution + +- πŸ› Bug fixes +- ✨ New features +- πŸ“ Documentation improvements +- 🌍 Translations +- 🎨 UI/UX enhancements +- ⚑ Performance optimizations + +## πŸ“„ License + +This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. + +**TL;DR:** Free to use for personal and commercial projects. Attribution appreciated but not required. + +## πŸ“§ Support & Community + +- πŸ’¬ **Discussions:** [GitHub Discussions](https://github.com/Hosteroid/domain-monitor/discussions) +- πŸ› **Issues:** [Bug Tracker](https://github.com/Hosteroid/domain-monitor/issues) +- πŸ“– **Documentation:** [Wiki](https://github.com/Hosteroid/domain-monitor/wiki) +- ⭐ **Star the project** if you find it useful! + +## πŸ’Ό Created & Sponsored By + +
+ +### [Hosteroid - Premium Hosting Solutions](https://www.hosteroid.uk) + +This project is proudly created and maintained by **Hosteroid**, a leading provider of premium hosting solutions. + +[![Hosteroid](https://img.shields.io/badge/Powered%20by-Hosteroid-blue?style=for-the-badge)](https://www.hosteroid.uk) + +**Services:** Web Hosting β€’ VPS β€’ Dedicated Servers β€’ Domain Registration + +🌐 **Website:** [hosteroid.uk](https://www.hosteroid.uk) +πŸ“§ **Contact:** [support@hosteroid.uk](mailto:support@hosteroid.uk) + +
+ +--- + +## πŸ™ Acknowledgments + +- Created by [Hosteroid](https://www.hosteroid.uk) +- WHOIS/RDAP data from [IANA](https://www.iana.org/) +- Built with modern PHP and love ❀️ + +## πŸ“Š Project Stats + +![GitHub stars](https://img.shields.io/github/stars/Hosteroid/domain-monitor?style=social) +![GitHub forks](https://img.shields.io/github/forks/Hosteroid/domain-monitor?style=social) +![GitHub issues](https://img.shields.io/github/issues/Hosteroid/domain-monitor) +![GitHub pull requests](https://img.shields.io/github/issues-pr/Hosteroid/domain-monitor) + +--- + +
+ +**Made with ❀️ by [Hosteroid](https://www.hosteroid.uk)** + +[Report Bug](https://github.com/Hosteroid/domain-monitor/issues) β€’ [Request Feature](https://github.com/Hosteroid/domain-monitor/issues) β€’ [Visit Hosteroid](https://www.hosteroid.uk) + +
+ diff --git a/app/Controllers/AuthController.php b/app/Controllers/AuthController.php new file mode 100644 index 0000000..2eacc15 --- /dev/null +++ b/app/Controllers/AuthController.php @@ -0,0 +1,93 @@ +userModel = new User(); + } + + /** + * Show login form + */ + public function showLogin() + { + // If already logged in, redirect to dashboard + if (isset($_SESSION['user_id'])) { + $this->redirect('/'); + } + + $this->view('auth/login', [ + 'title' => 'Login' + ]); + } + + /** + * Process login + */ + public function login() + { + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + $this->redirect('/login'); + return; + } + + $username = trim($_POST['username'] ?? ''); + $password = $_POST['password'] ?? ''; + + // Validate input + if (empty($username) || empty($password)) { + $_SESSION['error'] = 'Username and password are required'; + $this->redirect('/login'); + return; + } + + // Find user + $user = $this->userModel->findByUsername($username); + + if (!$user) { + $_SESSION['error'] = 'Invalid username or password'; + $this->redirect('/login'); + return; + } + + // Verify password + if (!$this->userModel->verifyPassword($password, $user['password'])) { + $_SESSION['error'] = 'Invalid username or password'; + $this->redirect('/login'); + return; + } + + // Login successful - create session + $_SESSION['user_id'] = $user['id']; + $_SESSION['username'] = $user['username']; + $_SESSION['full_name'] = $user['full_name']; + + // Update last login + $this->userModel->updateLastLogin($user['id']); + + // Redirect to dashboard + $this->redirect('/'); + } + + /** + * Logout + */ + public function logout() + { + // Destroy session + session_destroy(); + session_start(); + + $_SESSION['success'] = 'You have been logged out successfully'; + $this->redirect('/login'); + } +} + diff --git a/app/Controllers/DashboardController.php b/app/Controllers/DashboardController.php new file mode 100644 index 0000000..ce740ce --- /dev/null +++ b/app/Controllers/DashboardController.php @@ -0,0 +1,39 @@ +domainModel = new Domain(); + $this->groupModel = new NotificationGroup(); + $this->logModel = new NotificationLog(); + } + + public function index() + { + $stats = $this->domainModel->getStatistics(); + $recentDomains = $this->domainModel->getRecent(5); // Get 5 most recent domains + $expiringThisMonth = $this->domainModel->getExpiringDomains(30); // Domains expiring within 30 days + $recentLogs = $this->logModel->getRecent(10); + + $this->view('dashboard/index', [ + 'stats' => $stats, + 'recentDomains' => $recentDomains, + 'expiringThisMonth' => $expiringThisMonth, + 'recentLogs' => $recentLogs, + 'title' => 'Dashboard' + ]); + } +} + diff --git a/app/Controllers/DebugController.php b/app/Controllers/DebugController.php new file mode 100644 index 0000000..1a4af84 --- /dev/null +++ b/app/Controllers/DebugController.php @@ -0,0 +1,304 @@ +view('debug/whois', [ + 'domain' => '', + 'title' => 'WHOIS Debug Tool' + ]); + return; + } + + // Get TLD + $parts = explode('.', $domain); + $tld = $parts[count($parts) - 1]; + + // Use reflection to access the WhoisService's discovery methods + $whoisService = new WhoisService(); + + // Use reflection to call private discoverTldServers method + $reflection = new \ReflectionClass($whoisService); + $discoverMethod = $reflection->getMethod('discoverTldServers'); + $discoverMethod->setAccessible(true); + + // Handle double TLDs + $doubleTld = null; + if (count($parts) >= 3) { + $doubleTld = $parts[count($parts) - 2] . '.' . $tld; + } + + // Try double TLD first, then single TLD + $discoveryDebug = []; + $discoveryDebug[] = "=== IANA DISCOVERY PROCESS ==="; + $discoveryDebug[] = ""; + $discoveryDebug[] = "Step 1: Querying IANA WHOIS (whois.iana.org) for TLD information"; + $discoveryDebug[] = "Step 2: Querying IANA RDAP Bootstrap (https://data.iana.org/rdap/dns.json)"; + $discoveryDebug[] = "Step 3: Fallback to IANA HTML page if needed"; + $discoveryDebug[] = ""; + + if ($doubleTld) { + $discoveryDebug[] = "Trying double TLD: {$doubleTld}"; + $servers = $discoverMethod->invoke($whoisService, $doubleTld); + $discoveryDebug[] = " -> RDAP: " . ($servers['rdap_url'] ?? 'Not found'); + $discoveryDebug[] = " -> WHOIS: " . ($servers['whois_server'] ?? 'Not found'); + + if (!$servers['rdap_url'] && !$servers['whois_server']) { + $discoveryDebug[] = ""; + $discoveryDebug[] = "Double TLD failed, trying single TLD: {$tld}"; + $servers = $discoverMethod->invoke($whoisService, $tld); + $discoveryDebug[] = " -> RDAP: " . ($servers['rdap_url'] ?? 'Not found'); + $discoveryDebug[] = " -> WHOIS: " . ($servers['whois_server'] ?? 'Not found'); + } + } else { + $discoveryDebug[] = "Trying single TLD: {$tld}"; + $servers = $discoverMethod->invoke($whoisService, $tld); + $discoveryDebug[] = " -> RDAP: " . ($servers['rdap_url'] ?? 'Not found'); + $discoveryDebug[] = " -> WHOIS: " . ($servers['whois_server'] ?? 'Not found'); + } + + $rdapUrl = $servers['rdap_url']; + $whoisServer = $servers['whois_server'] ?? 'whois.iana.org'; + + $discoveryDebug[] = ""; + $discoveryDebug[] = "=== FINAL RESULTS ==="; + $discoveryDebug[] = "RDAP URL: " . ($rdapUrl ?? 'Not available - will use WHOIS fallback'); + $discoveryDebug[] = "WHOIS Server: {$whoisServer}"; + $discoveryDebug[] = ""; + + if (!$rdapUrl) { + $discoveryDebug[] = "NOTE: No RDAP server found in IANA sources. Will use traditional WHOIS."; + } + + // Get raw response - try RDAP first, then WHOIS + $response = ''; + $parsedData = []; + $server = $whoisServer; + $rdapSucceeded = false; + + // Add discovery debug info + $response .= "=== TLD DISCOVERY DEBUG ===\n\n"; + foreach ($discoveryDebug as $debug) { + $response .= $debug . "\n"; + } + $response .= "\n"; + + // Try RDAP first if available + if ($rdapUrl) { + $server = parse_url($rdapUrl, PHP_URL_HOST) . ' (RDAP)'; + + // Construct full RDAP URL + // RDAP standard format: {base_url}domain/{domain_name} + if (!preg_match('/domain\/$/', $rdapUrl)) { + $fullRdapUrl = rtrim($rdapUrl, '/') . '/domain/' . strtolower($domain); + } else { + $fullRdapUrl = rtrim($rdapUrl, '/') . '/' . strtolower($domain); + } + + // Query RDAP + $ch = curl_init(); + curl_setopt($ch, CURLOPT_URL, $fullRdapUrl); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_TIMEOUT, 10); + curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true); + curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true); + curl_setopt($ch, CURLOPT_HTTPHEADER, ['Accept: application/rdap+json']); + + $rdapResponse = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + $curlError = curl_error($ch); + $curlInfo = curl_getinfo($ch); + curl_close($ch); + + if ($httpCode === 200 && $rdapResponse) { + // Pretty print JSON + $rdapData = json_decode($rdapResponse, true); + + // Check if RDAP returned an error in the JSON + if ($rdapData && isset($rdapData['errorCode'])) { + $rdapSucceeded = true; // HTTP succeeded, but domain not found + $response .= "\n=== RDAP QUERY SUCCESS (Domain Not Found) ===\n\n"; + $response .= "RDAP URL: {$fullRdapUrl}\n"; + $response .= "HTTP Status: {$httpCode}\n"; + $response .= "RDAP Error Code: {$rdapData['errorCode']}\n"; + $response .= "Title: " . ($rdapData['title'] ?? 'N/A') . "\n"; + $response .= "Description: " . (isset($rdapData['description']) ? implode(', ', (array)$rdapData['description']) : 'N/A') . "\n\n"; + + if ($rdapData['errorCode'] == 404) { + $response .= "βœ“ Domain is AVAILABLE (not registered)\n\n"; + $parsedData[] = ['key' => 'Status', 'value' => 'AVAILABLE']; + $parsedData[] = ['key' => 'Registrar', 'value' => 'Not Registered']; + } + + $response .= "--- RDAP JSON RESPONSE ---\n\n"; + $response .= json_encode($rdapData, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + } else { + $rdapSucceeded = true; + $response .= "\n=== RDAP QUERY SUCCESS ===\n\n"; + $response .= "RDAP URL: {$fullRdapUrl}\n"; + $response .= "HTTP Status: {$httpCode}\n\n"; + $response .= "--- RDAP JSON RESPONSE ---\n\n"; + + if ($rdapData) { + $response .= json_encode($rdapData, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + + // Parse some key fields for the table + if (isset($rdapData['entities'])) { + foreach ($rdapData['entities'] as $entity) { + if (isset($entity['vcardArray'][1])) { + foreach ($entity['vcardArray'][1] as $field) { + if (is_array($field) && count($field) >= 4) { + $parsedData[] = [ + 'key' => $field[0], + 'value' => is_array($field[3]) ? implode(', ', $field[3]) : $field[3] + ]; + } + } + } + } + } + + if (isset($rdapData['events'])) { + foreach ($rdapData['events'] as $event) { + $parsedData[] = [ + 'key' => ucfirst($event['eventAction'] ?? 'event'), + 'value' => $event['eventDate'] ?? 'N/A' + ]; + } + } + } else { + $response .= $rdapResponse; + } + } + } elseif ($httpCode === 404 && $rdapResponse) { + // Handle 404 responses as domain not found + $rdapData = json_decode($rdapResponse, true); + if ($rdapData && isset($rdapData['errorCode']) && $rdapData['errorCode'] == 404) { + $rdapSucceeded = true; // Treat as successful domain not found + $response .= "\n=== RDAP QUERY SUCCESS (Domain Not Found) ===\n\n"; + $response .= "RDAP URL: {$fullRdapUrl}\n"; + $response .= "HTTP Status: {$httpCode}\n"; + $response .= "RDAP Error Code: {$rdapData['errorCode']}\n"; + $response .= "Title: " . ($rdapData['title'] ?? 'N/A') . "\n"; + $response .= "Description: " . (isset($rdapData['description']) ? implode(', ', (array)$rdapData['description']) : 'N/A') . "\n\n"; + + $response .= "βœ“ Domain is AVAILABLE (not registered)\n\n"; + $parsedData[] = ['key' => 'Status', 'value' => 'AVAILABLE']; + $parsedData[] = ['key' => 'Registrar', 'value' => 'Not Registered']; + + $response .= "--- RDAP JSON RESPONSE ---\n\n"; + $response .= json_encode($rdapData, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + } else { + $response .= "\n=== RDAP QUERY FAILED ===\n\n"; + $response .= "RDAP URL: {$fullRdapUrl}\n"; + $response .= "HTTP Status: {$httpCode}\n"; + $response .= "\nError: Could not retrieve RDAP data\n\n"; + } + } else { + $response .= "\n=== RDAP QUERY FAILED ===\n\n"; + $response .= "RDAP URL: {$fullRdapUrl}\n"; + $response .= "HTTP Status: {$httpCode}\n"; + + if ($curlError) { + $response .= "cURL Error: {$curlError}\n"; + } + + // Show detailed cURL info + $response .= "\ncURL Debug Info:\n"; + $response .= " - Total Time: " . ($curlInfo['total_time'] ?? 'N/A') . "s\n"; + $response .= " - Name Lookup Time: " . ($curlInfo['namelookup_time'] ?? 'N/A') . "s\n"; + $response .= " - Connect Time: " . ($curlInfo['connect_time'] ?? 'N/A') . "s\n"; + $response .= " - Primary IP: " . ($curlInfo['primary_ip'] ?? 'N/A') . "\n"; + + if ($httpCode === 0) { + $response .= "\nNote: HTTP Status 0 usually means:\n"; + $response .= " - SSL certificate verification failed\n"; + $response .= " - Connection timeout\n"; + $response .= " - DNS resolution failed\n"; + $response .= " - URL is malformed\n"; + } + + $response .= "\nError: Could not retrieve RDAP data\n\n"; + } + } + + // If RDAP failed or not available, query WHOIS + if (!$rdapSucceeded && $whoisServer) { + if ($rdapUrl) { + $response .= "\n\n=== WHOIS FALLBACK (RDAP Failed) ===\n\n"; + } else { + $response = "=== WHOIS QUERY ===\n\n"; + $server = $whoisServer; + } + + $response .= "WHOIS Server: {$whoisServer}\n\n"; + $response .= "--- WHOIS TEXT RESPONSE ---\n\n"; + + $fp = @fsockopen($whoisServer, 43, $errno, $errstr, 10); + + if ($fp) { + fputs($fp, $domain . "\r\n"); + $whoisResponse = ''; + while (!feof($fp)) { + $whoisResponse .= fgets($fp, 128); + } + fclose($fp); + + $response .= $whoisResponse; + + // Check if domain is not found/available + $whoisResponseLower = strtolower($whoisResponse); + if (preg_match('/not found|no match|no entries found|no data found|domain not found|no such domain|not registered|available for registration/i', $whoisResponseLower)) { + $response .= "\n\n=== DOMAIN STATUS DETECTED ===\n"; + $response .= "βœ“ Domain is AVAILABLE (not registered)\n"; + $parsedData[] = ['key' => 'Status', 'value' => 'AVAILABLE']; + $parsedData[] = ['key' => 'Registrar', 'value' => 'Not Registered']; + } else { + // Parse key-value pairs from WHOIS + $lines = explode("\n", $whoisResponse); + foreach ($lines as $line) { + $line = trim($line); + if (empty($line) || $line[0] === '%' || $line[0] === '#') { + continue; + } + if (strpos($line, ':') !== false) { + list($key, $value) = explode(':', $line, 2); + $parsedData[] = [ + 'key' => trim($key), + 'value' => trim($value) + ]; + } + } + } + } else { + $response .= "Error: Could not connect to WHOIS server: $errstr ($errno)"; + } + } + + // Get parsed info using WhoisService + $info = $whoisService->getDomainInfo($domain); + + $this->view('debug/whois', [ + 'domain' => $domain, + 'server' => $server, + 'tld' => $tld, + 'response' => $response, + 'parsedData' => $parsedData, + 'info' => $info, + 'title' => 'WHOIS Debug - ' . $domain + ]); + } +} + diff --git a/app/Controllers/DomainController.php b/app/Controllers/DomainController.php new file mode 100644 index 0000000..51ca8c7 --- /dev/null +++ b/app/Controllers/DomainController.php @@ -0,0 +1,569 @@ +domainModel = new Domain(); + $this->groupModel = new NotificationGroup(); + $this->whoisService = new WhoisService(); + } + + public function index() + { + // Get filter parameters + $search = $_GET['search'] ?? ''; + $status = $_GET['status'] ?? ''; + $groupId = $_GET['group'] ?? ''; + $sortBy = $_GET['sort'] ?? 'domain_name'; + $sortOrder = $_GET['order'] ?? 'asc'; + $page = max(1, (int)($_GET['page'] ?? 1)); + $perPage = max(10, min(100, (int)($_GET['per_page'] ?? 25))); // Between 10 and 100 + + // Get all domains with groups + $domains = $this->domainModel->getAllWithGroups(); + + // Apply filters + if (!empty($search)) { + $domains = array_filter($domains, function($domain) use ($search) { + return stripos($domain['domain_name'], $search) !== false || + stripos($domain['registrar'] ?? '', $search) !== false; + }); + } + + if (!empty($status)) { + $domains = array_filter($domains, function($domain) use ($status) { + if ($status === 'expiring_soon') { + // Check if domain expires within 30 days + if (!empty($domain['expiration_date'])) { + $daysLeft = floor((strtotime($domain['expiration_date']) - time()) / 86400); + return $daysLeft <= 30 && $daysLeft >= 0; + } + return false; + } + return $domain['status'] === $status; + }); + } + + if (!empty($groupId)) { + $domains = array_filter($domains, function($domain) use ($groupId) { + return $domain['notification_group_id'] == $groupId; + }); + } + + // Get total count after filtering + $totalDomains = count($domains); + + // Apply sorting + usort($domains, function($a, $b) use ($sortBy, $sortOrder) { + $aVal = $a[$sortBy] ?? ''; + $bVal = $b[$sortBy] ?? ''; + + $comparison = strcasecmp($aVal, $bVal); + return $sortOrder === 'desc' ? -$comparison : $comparison; + }); + + // Calculate pagination + $totalPages = ceil($totalDomains / $perPage); + $page = min($page, max(1, $totalPages)); // Ensure page is within valid range + $offset = ($page - 1) * $perPage; + + // Slice array for current page + $paginatedDomains = array_slice($domains, $offset, $perPage); + + $groups = $this->groupModel->all(); + + $this->view('domains/index', [ + 'domains' => $paginatedDomains, + 'groups' => $groups, + 'filters' => [ + 'search' => $search, + 'status' => $status, + 'group' => $groupId, + 'sort' => $sortBy, + 'order' => $sortOrder + ], + 'pagination' => [ + 'current_page' => $page, + 'per_page' => $perPage, + 'total' => $totalDomains, + 'total_pages' => $totalPages, + 'showing_from' => $totalDomains > 0 ? $offset + 1 : 0, + 'showing_to' => min($offset + $perPage, $totalDomains) + ], + 'title' => 'Domains' + ]); + } + + public function create() + { + $groups = $this->groupModel->all(); + + $this->view('domains/create', [ + 'groups' => $groups, + 'title' => 'Add Domain' + ]); + } + + public function store() + { + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + $this->redirect('/domains/create'); + return; + } + + $domainName = trim($_POST['domain_name'] ?? ''); + $groupId = !empty($_POST['notification_group_id']) ? (int)$_POST['notification_group_id'] : null; + + // Validate + if (empty($domainName)) { + $_SESSION['error'] = 'Domain name is required'; + $this->redirect('/domains/create'); + return; + } + + // Check if domain already exists + if ($this->domainModel->existsByDomain($domainName)) { + $_SESSION['error'] = 'Domain already exists'; + $this->redirect('/domains/create'); + return; + } + + // Get WHOIS information + $whoisData = $this->whoisService->getDomainInfo($domainName); + + if (!$whoisData) { + $_SESSION['error'] = 'Could not retrieve WHOIS information for this domain'; + $this->redirect('/domains/create'); + return; + } + + // Create domain + $status = $this->whoisService->getDomainStatus($whoisData['expiration_date'], $whoisData['status'] ?? []); + + // Warn if domain is available (not registered) + if ($status === 'available') { + $_SESSION['warning'] = "Note: '$domainName' appears to be AVAILABLE (not registered). You're monitoring an unregistered domain."; + } + + $id = $this->domainModel->create([ + 'domain_name' => $domainName, + 'notification_group_id' => $groupId, + 'registrar' => $whoisData['registrar'], + 'registrar_url' => $whoisData['registrar_url'] ?? null, + 'expiration_date' => $whoisData['expiration_date'], + 'updated_date' => $whoisData['updated_date'] ?? null, + 'abuse_email' => $whoisData['abuse_email'] ?? null, + 'last_checked' => date('Y-m-d H:i:s'), + 'status' => $status, + 'whois_data' => json_encode($whoisData), + 'is_active' => 1 + ]); + + if ($status !== 'available') { + $_SESSION['success'] = "Domain '$domainName' added successfully"; + } + $this->redirect('/domains'); + } + + public function edit($params = []) + { + $id = $params['id'] ?? 0; + $domain = $this->domainModel->find($id); + + if (!$domain) { + $_SESSION['error'] = 'Domain not found'; + $this->redirect('/domains'); + return; + } + + $groups = $this->groupModel->all(); + + $this->view('domains/edit', [ + 'domain' => $domain, + 'groups' => $groups, + 'title' => 'Edit Domain' + ]); + } + + public function update($params = []) + { + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + $this->redirect('/domains'); + return; + } + + $id = (int)($params['id'] ?? 0); + $domain = $this->domainModel->find($id); + + if (!$domain) { + $_SESSION['error'] = 'Domain not found'; + $this->redirect('/domains'); + return; + } + + $groupId = !empty($_POST['notification_group_id']) ? (int)$_POST['notification_group_id'] : null; + $isActive = isset($_POST['is_active']) ? 1 : 0; + + // Check if monitoring status changed + $statusChanged = ($domain['is_active'] != $isActive); + $oldGroupId = $domain['notification_group_id']; + + $this->domainModel->update($id, [ + 'notification_group_id' => $groupId, + 'is_active' => $isActive + ]); + + // Send notification if monitoring status changed and has notification group + if ($statusChanged && $groupId) { + $notificationService = new \App\Services\NotificationService(); + + if ($isActive) { + // Monitoring activated + $message = "🟒 Domain monitoring has been ACTIVATED for {$domain['domain_name']}\n\n" . + "The domain will now be monitored regularly and you'll receive expiration alerts."; + $subject = "βœ… Monitoring Activated: {$domain['domain_name']}"; + } else { + // Monitoring deactivated + $message = "πŸ”΄ Domain monitoring has been DEACTIVATED for {$domain['domain_name']}\n\n" . + "You will no longer receive alerts for this domain until monitoring is re-enabled."; + $subject = "⏸️ Monitoring Paused: {$domain['domain_name']}"; + } + + $notificationService->sendToGroup($groupId, $subject, $message); + } + + // Also send notification if group changed and monitoring is active + if (!$statusChanged && $isActive && $oldGroupId != $groupId) { + $notificationService = new \App\Services\NotificationService(); + + if ($groupId) { + // Assigned to new group + $groupModel = new NotificationGroup(); + $group = $groupModel->find($groupId); + $groupName = $group ? $group['name'] : 'Unknown Group'; + + $message = "πŸ”” Notification group updated for {$domain['domain_name']}\n\n" . + "This domain is now assigned to: {$groupName}\n" . + "You will receive expiration alerts through this notification group."; + $subject = "πŸ“¬ Group Changed: {$domain['domain_name']}"; + + $notificationService->sendToGroup($groupId, $subject, $message); + } + } + + $_SESSION['success'] = 'Domain updated successfully'; + $this->redirect('/domains/' . $id); + } + + public function refresh($params = []) + { + $id = $params['id'] ?? 0; + $domain = $this->domainModel->find($id); + + if (!$domain) { + $_SESSION['error'] = 'Domain not found'; + $this->redirect('/domains'); + return; + } + + // Get fresh WHOIS information + $whoisData = $this->whoisService->getDomainInfo($domain['domain_name']); + + if (!$whoisData) { + $_SESSION['error'] = 'Could not retrieve WHOIS information'; + // Check if we came from view page + $referer = $_SERVER['HTTP_REFERER'] ?? ''; + if (strpos($referer, '/domains/' . $id) !== false) { + $this->redirect('/domains/' . $id); + } else { + $this->redirect('/domains'); + } + return; + } + + $status = $this->whoisService->getDomainStatus($whoisData['expiration_date'], $whoisData['status'] ?? []); + + $this->domainModel->update($id, [ + 'registrar' => $whoisData['registrar'], + 'registrar_url' => $whoisData['registrar_url'] ?? null, + 'expiration_date' => $whoisData['expiration_date'], + 'updated_date' => $whoisData['updated_date'] ?? null, + 'abuse_email' => $whoisData['abuse_email'] ?? null, + 'last_checked' => date('Y-m-d H:i:s'), + 'status' => $status, + 'whois_data' => json_encode($whoisData) + ]); + + $_SESSION['success'] = 'Domain information refreshed'; + + // Check if we came from view page or list page + $referer = $_SERVER['HTTP_REFERER'] ?? ''; + if (strpos($referer, '/domains/' . $id) !== false) { + // Came from view page, go back to view page + $this->redirect('/domains/' . $id); + } else { + // Came from list page, stay on list page + $this->redirect('/domains'); + } + } + + public function delete($params = []) + { + $id = $params['id'] ?? 0; + $domain = $this->domainModel->find($id); + + if (!$domain) { + $_SESSION['error'] = 'Domain not found'; + $this->redirect('/domains'); + return; + } + + $this->domainModel->delete($id); + $_SESSION['success'] = 'Domain deleted successfully'; + $this->redirect('/domains'); + } + + public function show($params = []) + { + $id = $params['id'] ?? 0; + $domain = $this->domainModel->getWithChannels($id); + + if (!$domain) { + $_SESSION['error'] = 'Domain not found'; + $this->redirect('/domains'); + return; + } + + $logModel = new \App\Models\NotificationLog(); + $logs = $logModel->getByDomain($id, 20); + + $this->view('domains/view', [ + 'domain' => $domain, + 'logs' => $logs, + 'title' => $domain['domain_name'] + ]); + } + + public function bulkAdd() + { + if ($_SERVER['REQUEST_METHOD'] === 'GET') { + $groups = $this->groupModel->all(); + $this->view('domains/bulk-add', [ + 'groups' => $groups, + 'title' => 'Bulk Add Domains' + ]); + return; + } + + // POST - Process bulk add + $domainsText = trim($_POST['domains'] ?? ''); + $groupId = !empty($_POST['notification_group_id']) ? (int)$_POST['notification_group_id'] : null; + + if (empty($domainsText)) { + $_SESSION['error'] = 'Please enter at least one domain'; + $this->redirect('/domains/bulk-add'); + return; + } + + // Split by new lines and clean + $domainNames = array_filter(array_map('trim', explode("\n", $domainsText))); + + $added = 0; + $skipped = 0; + $availableCount = 0; + $errors = []; + + foreach ($domainNames as $domainName) { + // Skip if already exists + if ($this->domainModel->existsByDomain($domainName)) { + $skipped++; + continue; + } + + // Get WHOIS information + $whoisData = $this->whoisService->getDomainInfo($domainName); + + if (!$whoisData) { + $errors[] = $domainName; + continue; + } + + $status = $this->whoisService->getDomainStatus($whoisData['expiration_date'], $whoisData['status'] ?? []); + + // Track available domains + if ($status === 'available') { + $availableCount++; + } + + $this->domainModel->create([ + 'domain_name' => $domainName, + 'notification_group_id' => $groupId, + 'registrar' => $whoisData['registrar'], + 'registrar_url' => $whoisData['registrar_url'] ?? null, + 'expiration_date' => $whoisData['expiration_date'], + 'updated_date' => $whoisData['updated_date'] ?? null, + 'abuse_email' => $whoisData['abuse_email'] ?? null, + 'last_checked' => date('Y-m-d H:i:s'), + 'status' => $status, + 'whois_data' => json_encode($whoisData), + 'is_active' => 1 + ]); + + $added++; + } + + $message = "Added $added domain(s)"; + if ($skipped > 0) $message .= ", skipped $skipped duplicate(s)"; + if (count($errors) > 0) $message .= ", failed to add " . count($errors) . " domain(s)"; + + if ($availableCount > 0) { + $_SESSION['warning'] = "Note: $availableCount domain(s) appear to be AVAILABLE (not registered)."; + } + + $_SESSION['success'] = $message; + $this->redirect('/domains'); + } + + public function bulkRefresh() + { + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + $this->redirect('/domains'); + return; + } + + $domainIds = $_POST['domain_ids'] ?? []; + + if (empty($domainIds)) { + $_SESSION['error'] = 'No domains selected'; + $this->redirect('/domains'); + return; + } + + $refreshed = 0; + $failed = 0; + + foreach ($domainIds as $id) { + $domain = $this->domainModel->find($id); + if (!$domain) continue; + + $whoisData = $this->whoisService->getDomainInfo($domain['domain_name']); + + if (!$whoisData) { + $failed++; + continue; + } + + $status = $this->whoisService->getDomainStatus($whoisData['expiration_date'], $whoisData['status'] ?? []); + + $this->domainModel->update($id, [ + 'registrar' => $whoisData['registrar'], + 'registrar_url' => $whoisData['registrar_url'] ?? null, + 'expiration_date' => $whoisData['expiration_date'], + 'updated_date' => $whoisData['updated_date'] ?? null, + 'abuse_email' => $whoisData['abuse_email'] ?? null, + 'last_checked' => date('Y-m-d H:i:s'), + 'status' => $status, + 'whois_data' => json_encode($whoisData) + ]); + + $refreshed++; + } + + $_SESSION['success'] = "Refreshed $refreshed domain(s)" . ($failed > 0 ? ", $failed failed" : ''); + $this->redirect('/domains'); + } + + public function bulkDelete() + { + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + $this->redirect('/domains'); + return; + } + + $domainIds = $_POST['domain_ids'] ?? []; + + if (empty($domainIds)) { + $_SESSION['error'] = 'No domains selected'; + $this->redirect('/domains'); + return; + } + + $deleted = 0; + foreach ($domainIds as $id) { + if ($this->domainModel->delete($id)) { + $deleted++; + } + } + + $_SESSION['success'] = "Deleted $deleted domain(s)"; + $this->redirect('/domains'); + } + + public function bulkAssignGroup() + { + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + $this->redirect('/domains'); + return; + } + + $domainIds = $_POST['domain_ids'] ?? []; + $groupId = !empty($_POST['group_id']) ? (int)$_POST['group_id'] : null; + + if (empty($domainIds)) { + $_SESSION['error'] = 'No domains selected'; + $this->redirect('/domains'); + return; + } + + $updated = 0; + foreach ($domainIds as $id) { + if ($this->domainModel->update($id, ['notification_group_id' => $groupId])) { + $updated++; + } + } + + $_SESSION['success'] = "Updated $updated domain(s)"; + $this->redirect('/domains'); + } + + public function bulkToggleStatus() + { + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + $this->redirect('/domains'); + return; + } + + $domainIds = $_POST['domain_ids'] ?? []; + $isActive = isset($_POST['is_active']) ? (int)$_POST['is_active'] : 1; + + if (empty($domainIds)) { + $_SESSION['error'] = 'No domains selected'; + $this->redirect('/domains'); + return; + } + + $updated = 0; + foreach ($domainIds as $id) { + if ($this->domainModel->update($id, ['is_active' => $isActive])) { + $updated++; + } + } + + $status = $isActive ? 'enabled' : 'disabled'; + $_SESSION['success'] = "Monitoring $status for $updated domain(s)"; + $this->redirect('/domains'); + } +} + diff --git a/app/Controllers/NotificationGroupController.php b/app/Controllers/NotificationGroupController.php new file mode 100644 index 0000000..7a58484 --- /dev/null +++ b/app/Controllers/NotificationGroupController.php @@ -0,0 +1,194 @@ +groupModel = new NotificationGroup(); + $this->channelModel = new NotificationChannel(); + } + + public function index() + { + $groups = $this->groupModel->getAllWithChannelCount(); + + $this->view('groups/index', [ + 'groups' => $groups, + 'title' => 'Notification Groups' + ]); + } + + public function create() + { + $this->view('groups/create', [ + 'title' => 'Create Notification Group' + ]); + } + + public function store() + { + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + $this->redirect('/groups/create'); + return; + } + + $name = trim($_POST['name'] ?? ''); + $description = trim($_POST['description'] ?? ''); + + if (empty($name)) { + $_SESSION['error'] = 'Group name is required'; + $this->redirect('/groups/create'); + return; + } + + $id = $this->groupModel->create([ + 'name' => $name, + 'description' => $description + ]); + + $_SESSION['success'] = "Group '$name' created successfully"; + $this->redirect("/groups/edit?id=$id"); + } + + public function edit() + { + $id = $_GET['id'] ?? 0; + $group = $this->groupModel->getWithDetails($id); + + if (!$group) { + $_SESSION['error'] = 'Group not found'; + $this->redirect('/groups'); + return; + } + + $this->view('groups/edit', [ + 'group' => $group, + 'title' => 'Edit Group: ' . $group['name'] + ]); + } + + public function update() + { + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + $this->redirect('/groups'); + return; + } + + $id = (int)$_POST['id']; + $name = trim($_POST['name'] ?? ''); + $description = trim($_POST['description'] ?? ''); + + if (empty($name)) { + $_SESSION['error'] = 'Group name is required'; + $this->redirect("/groups/edit?id=$id"); + return; + } + + $this->groupModel->update($id, [ + 'name' => $name, + 'description' => $description + ]); + + $_SESSION['success'] = 'Group updated successfully'; + $this->redirect("/groups/edit?id=$id"); + } + + public function delete() + { + $id = $_GET['id'] ?? 0; + $group = $this->groupModel->find($id); + + if (!$group) { + $_SESSION['error'] = 'Group not found'; + $this->redirect('/groups'); + return; + } + + $this->groupModel->deleteWithRelations($id); + $_SESSION['success'] = 'Group deleted successfully'; + $this->redirect('/groups'); + } + + public function addChannel() + { + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + $this->redirect('/groups'); + return; + } + + $groupId = (int)$_POST['group_id']; + $channelType = $_POST['channel_type'] ?? ''; + + $config = $this->buildChannelConfig($channelType, $_POST); + + if (!$config) { + $_SESSION['error'] = 'Invalid channel configuration'; + $this->redirect("/groups/edit?id=$groupId"); + return; + } + + $this->channelModel->createChannel($groupId, $channelType, $config); + + $_SESSION['success'] = 'Channel added successfully'; + $this->redirect("/groups/edit?id=$groupId"); + } + + public function deleteChannel() + { + $id = $_GET['id'] ?? 0; + $groupId = $_GET['group_id'] ?? 0; + + $this->channelModel->delete($id); + + $_SESSION['success'] = 'Channel deleted successfully'; + $this->redirect("/groups/edit?id=$groupId"); + } + + public function toggleChannel() + { + $id = $_GET['id'] ?? 0; + $groupId = $_GET['group_id'] ?? 0; + + $this->channelModel->toggleActive($id); + + $_SESSION['success'] = 'Channel status updated'; + $this->redirect("/groups/edit?id=$groupId"); + } + + private function buildChannelConfig(string $type, array $data): ?array + { + switch ($type) { + case 'email': + if (empty($data['email'])) return null; + return ['email' => $data['email']]; + + case 'telegram': + if (empty($data['bot_token']) || empty($data['chat_id'])) return null; + return [ + 'bot_token' => $data['bot_token'], + 'chat_id' => $data['chat_id'] + ]; + + case 'discord': + if (empty($data['webhook_url'])) return null; + return ['webhook_url' => $data['webhook_url']]; + + case 'slack': + if (empty($data['webhook_url'])) return null; + return ['webhook_url' => $data['webhook_url']]; + + default: + return null; + } + } +} + diff --git a/app/Controllers/SearchController.php b/app/Controllers/SearchController.php new file mode 100644 index 0000000..c3274ad --- /dev/null +++ b/app/Controllers/SearchController.php @@ -0,0 +1,172 @@ +domainModel = new Domain(); + $this->whoisService = new WhoisService(); + } + + public function index() + { + $query = trim($_GET['q'] ?? ''); + + if (empty($query)) { + $_SESSION['error'] = 'Please enter a search term'; + $this->redirect('/domains'); + return; + } + + // Pagination parameters + $page = max(1, (int)($_GET['page'] ?? 1)); + $perPage = max(10, min(100, (int)($_GET['per_page'] ?? 25))); + + // Search existing domains in database + $allResults = $this->searchDomains($query); + $totalResults = count($allResults); + + // Calculate pagination + $totalPages = ceil($totalResults / $perPage); + $page = min($page, max(1, $totalPages)); // Ensure page is within valid range + $offset = ($page - 1) * $perPage; + + // Slice results for current page + $existingDomains = array_slice($allResults, $offset, $perPage); + + // Check if query looks like a domain name + $isDomainLike = $this->isDomainFormat($query); + + // If it looks like a domain and not found in database, offer WHOIS lookup + $whoisData = null; + $whoisError = null; + + if ($isDomainLike && empty($allResults)) { + // Do WHOIS lookup + $whoisData = $this->whoisService->getDomainInfo($query); + if (!$whoisData) { + $whoisError = "Could not retrieve WHOIS information for '$query'"; + } + } + + $this->view('search/results', [ + 'query' => $query, + 'existingDomains' => $existingDomains, + 'whoisData' => $whoisData, + 'whoisError' => $whoisError, + 'isDomainLike' => $isDomainLike, + 'pagination' => [ + 'current_page' => $page, + 'per_page' => $perPage, + 'total' => $totalResults, + 'total_pages' => $totalPages, + 'showing_from' => $totalResults > 0 ? $offset + 1 : 0, + 'showing_to' => min($offset + $perPage, $totalResults) + ], + 'title' => 'Search Results' + ]); + } + + /** + * AJAX endpoint for live search suggestions + */ + public function suggest() + { + header('Content-Type: application/json'); + + $query = trim($_GET['q'] ?? ''); + + if (empty($query)) { + echo json_encode(['domains' => [], 'isDomainLike' => false]); + exit; + } + + // Search existing domains (limit to 5 for quick results) + $db = \Core\Database::getConnection(); + $sql = "SELECT d.id, d.domain_name, d.registrar, d.expiration_date, d.status, ng.name as group_name + FROM domains d + LEFT JOIN notification_groups ng ON d.notification_group_id = ng.id + WHERE d.domain_name LIKE ? + OR d.registrar LIKE ? + ORDER BY d.domain_name ASC + LIMIT 5"; + + $searchTerm = '%' . $query . '%'; + $stmt = $db->prepare($sql); + $stmt->execute([$searchTerm, $searchTerm]); + $results = $stmt->fetchAll(); + + // Calculate days left for each domain + foreach ($results as &$domain) { + if (!empty($domain['expiration_date'])) { + $daysLeft = floor((strtotime($domain['expiration_date']) - time()) / 86400); + $domain['days_left'] = $daysLeft; + + // Color coding + if ($daysLeft < 0) { + $domain['status_color'] = 'red'; + } elseif ($daysLeft <= 30) { + $domain['status_color'] = 'orange'; + } elseif ($daysLeft <= 90) { + $domain['status_color'] = 'yellow'; + } else { + $domain['status_color'] = 'green'; + } + } else { + $domain['days_left'] = null; + $domain['status_color'] = 'gray'; + } + } + + // Check if query looks like a domain + $isDomainLike = $this->isDomainFormat($query); + + echo json_encode([ + 'domains' => $results, + 'isDomainLike' => $isDomainLike, + 'query' => $query + ]); + exit; + } + + /** + * Search domains in database + */ + private function searchDomains(string $query): array + { + $db = \Core\Database::getConnection(); + $sql = "SELECT d.*, ng.name as group_name + FROM domains d + LEFT JOIN notification_groups ng ON d.notification_group_id = ng.id + WHERE d.domain_name LIKE ? + OR d.registrar LIKE ? + OR ng.name LIKE ? + ORDER BY d.domain_name ASC + LIMIT 50"; + + $searchTerm = '%' . $query . '%'; + $stmt = $db->prepare($sql); + $stmt->execute([$searchTerm, $searchTerm, $searchTerm]); + + return $stmt->fetchAll(); + } + + /** + * Check if string looks like a domain name + */ + private function isDomainFormat(string $query): bool + { + // Basic domain validation + return preg_match('/^[a-z0-9]+([\-\.]{1}[a-z0-9]+)*\.[a-z]{2,}$/i', $query); + } +} + diff --git a/app/Controllers/TldRegistryController.php b/app/Controllers/TldRegistryController.php new file mode 100644 index 0000000..3a0ce0d --- /dev/null +++ b/app/Controllers/TldRegistryController.php @@ -0,0 +1,515 @@ +tldModel = new TldRegistry(); + $this->importLogModel = new TldImportLog(); + $this->tldService = new TldRegistryService(); + } + + /** + * Display TLD registry dashboard + */ + public function index() + { + $search = $_GET['search'] ?? ''; + $status = $_GET['status'] ?? ''; + $dataType = $_GET['data_type'] ?? ''; + $page = max(1, (int)($_GET['page'] ?? 1)); + $perPage = max(10, min(100, (int)($_GET['per_page'] ?? 50))); + $sort = $_GET['sort'] ?? 'tld'; + $order = $_GET['order'] ?? 'asc'; + + $result = $this->tldModel->getPaginated($page, $perPage, $search, $sort, $order, $status, $dataType); + $stats = $this->tldModel->getStatistics(); + + $this->view('tld-registry/index', [ + 'tlds' => $result['tlds'], + 'pagination' => $result['pagination'], + 'stats' => $stats, + 'filters' => [ + 'search' => $search, + 'status' => $status, + 'data_type' => $dataType, + 'sort' => $sort, + 'order' => $order + ], + 'title' => 'TLD Registry' + ]); + } + + /** + * Show TLD details + */ + public function show($params = []) + { + $id = $params['id'] ?? 0; + $tld = $this->tldModel->find($id); + + if (!$tld) { + $_SESSION['error'] = 'TLD not found'; + $this->redirect('/tld-registry'); + return; + } + + $this->view('tld-registry/view', [ + 'tld' => $tld, + 'title' => 'TLD: ' . $tld['tld'] + ]); + } + + /** + * Import TLD list from IANA + */ + public function importTldList() + { + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + $this->redirect('/tld-registry'); + return; + } + + try { + $stats = $this->tldService->importTldList(); + + $message = "TLD list import completed: "; + $message .= "{$stats['total_tlds']} total, "; + $message .= "{$stats['new_tlds']} new, "; + $message .= "{$stats['updated_tlds']} updated"; + + if ($stats['failed_tlds'] > 0) { + $message .= ", {$stats['failed_tlds']} failed"; + } + + $message .= ". Next: Import RDAP servers for these TLDs."; + + $_SESSION['success'] = $message; + + } catch (\Exception $e) { + $_SESSION['error'] = 'TLD list import failed: ' . $e->getMessage(); + } + + $this->redirect('/tld-registry'); + } + + /** + * Import RDAP data from IANA + */ + public function importRdap() + { + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + $this->redirect('/tld-registry'); + return; + } + + try { + $stats = $this->tldService->importRdapData(); + + $message = "RDAP import completed: "; + $message .= "{$stats['total_tlds']} total, "; + $message .= "{$stats['new_tlds']} new, "; + $message .= "{$stats['updated_tlds']} updated"; + + if ($stats['failed_tlds'] > 0) { + $message .= ", {$stats['failed_tlds']} failed"; + } + + $message .= ". Next: Import WHOIS servers for TLDs missing RDAP."; + + $_SESSION['success'] = $message; + + } catch (\Exception $e) { + $_SESSION['error'] = 'RDAP import failed: ' . $e->getMessage(); + } + + $this->redirect('/tld-registry'); + } + + /** + * Import WHOIS data for missing TLDs + */ + public function importWhois() + { + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + $this->redirect('/tld-registry'); + return; + } + + try { + $stats = $this->tldService->importWhoisDataForMissingTlds(); + $remainingCount = $this->tldService->getTldsNeedingWhoisCount(); + + $message = "WHOIS import completed: "; + $message .= "{$stats['total_tlds']} total, "; + $message .= "{$stats['updated_tlds']} updated"; + + if ($stats['failed_tlds'] > 0) { + $message .= ", {$stats['failed_tlds']} failed"; + } + + if ($remainingCount > 0) { + $message .= ". {$remainingCount} TLDs still need WHOIS data. Run import again to continue."; + } else { + $message .= ". TLD registry setup complete! Use 'Check Updates' to monitor for changes."; + } + + $_SESSION['success'] = $message; + + } catch (\Exception $e) { + $_SESSION['error'] = 'WHOIS import failed: ' . $e->getMessage(); + } + + $this->redirect('/tld-registry'); + } + + /** + * Check for IANA updates + */ + public function checkUpdates() + { + try { + $updateInfo = $this->tldService->checkForUpdates(); + + if ($updateInfo['overall_needs_update']) { + $messages = []; + + if ($updateInfo['tld_list']['needs_update']) { + $messages[] = "TLD list updated: Version " . + ($updateInfo['tld_list']['current_version'] ?? 'Unknown') . + " (was " . ($updateInfo['tld_list']['last_version'] ?? 'None') . ")"; + } + + if ($updateInfo['rdap']['needs_update']) { + $messages[] = "RDAP data updated: " . + ($updateInfo['rdap']['current_publication'] ?? 'Unknown') . + " (was " . ($updateInfo['rdap']['last_publication'] ?? 'None') . ")"; + } + + $_SESSION['info'] = "IANA data has been updated. " . implode(' | ', $messages); + } else { + $_SESSION['success'] = "TLD registry is up to date"; + } + + // Show any errors + if (!empty($updateInfo['errors'])) { + $_SESSION['warning'] = "Some checks failed: " . implode(', ', $updateInfo['errors']); + } + + } catch (\Exception $e) { + $_SESSION['error'] = 'Failed to check for updates: ' . $e->getMessage(); + } + + $this->redirect('/tld-registry'); + } + + /** + * Start progressive import (universal) + */ + public function startProgressiveImport() + { + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + $this->redirect('/tld-registry'); + return; + } + + $importType = $_POST['import_type'] ?? ''; + + if (!in_array($importType, ['tld_list', 'rdap', 'whois', 'check_updates', 'complete_workflow'])) { + $_SESSION['error'] = 'Invalid import type'; + $this->redirect('/tld-registry'); + return; + } + + try { + $result = $this->tldService->startProgressiveImport($importType); + + if ($result['status'] === 'complete') { + $_SESSION['success'] = $result['message']; + $this->redirect('/tld-registry'); + } else { + // Redirect to progress page + $this->redirect('/tld-registry/import-progress/' . $result['log_id']); + } + + } catch (\Exception $e) { + $_SESSION['error'] = 'Failed to start import: ' . $e->getMessage(); + $this->redirect('/tld-registry'); + } + } + + /** + * Show import progress page (universal) + */ + public function importProgress($params = []) + { + $logId = $params['log_id'] ?? 0; + + if (!$logId) { + $_SESSION['error'] = 'Invalid import session'; + $this->redirect('/tld-registry'); + return; + } + + // Get import type from log + $log = $this->importLogModel->find($logId); + if (!$log) { + $_SESSION['error'] = 'Import log not found'; + $this->redirect('/tld-registry'); + return; + } + + $importType = $log['import_type']; + $titles = [ + 'tld_list' => 'TLD List Import Progress', + 'rdap' => 'RDAP Import Progress', + 'whois' => 'WHOIS Import Progress', + 'check_updates' => 'Update Check Progress' + ]; + + $this->view('tld-registry/import-progress', [ + 'log_id' => $logId, + 'import_type' => $importType, + 'title' => $titles[$importType] ?? 'Import Progress' + ]); + } + + /** + * API endpoint to get import progress + */ + public function apiGetImportProgress() + { + $logId = $_GET['log_id'] ?? 0; + + if (!$logId) { + http_response_code(400); + echo json_encode(['error' => 'Log ID required']); + return; + } + + try { + $result = $this->tldService->processNextBatch($logId); + echo json_encode($result); + } catch (\Exception $e) { + http_response_code(500); + echo json_encode(['error' => $e->getMessage()]); + } + } + + /** + * Bulk delete TLDs + */ + public function bulkDelete() + { + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + $this->redirect('/tld-registry'); + return; + } + + $tldIds = $_POST['tld_ids'] ?? []; + + if (empty($tldIds)) { + $_SESSION['error'] = 'No TLDs selected for deletion'; + $this->redirect('/tld-registry'); + return; + } + + try { + $deletedCount = 0; + foreach ($tldIds as $id) { + if ($this->tldModel->delete($id)) { + $deletedCount++; + } + } + + $_SESSION['success'] = "Successfully deleted {$deletedCount} TLD(s)"; + + } catch (\Exception $e) { + $_SESSION['error'] = 'Failed to delete TLDs: ' . $e->getMessage(); + } + + $this->redirect('/tld-registry'); + } + + /** + * Toggle TLD active status + */ + public function toggleActive($params = []) + { + $id = $params['id'] ?? 0; + $tld = $this->tldModel->find($id); + + if (!$tld) { + $_SESSION['error'] = 'TLD not found'; + $this->redirect('/tld-registry'); + return; + } + + $this->tldModel->toggleActive($id); + + $status = $tld['is_active'] ? 'disabled' : 'enabled'; + $_SESSION['success'] = "TLD {$tld['tld']} has been {$status}"; + + $this->redirect('/tld-registry'); + } + + /** + * Refresh TLD data from IANA + */ + public function refresh($params = []) + { + $id = $params['id'] ?? 0; + $tld = $this->tldModel->find($id); + + if (!$tld) { + $_SESSION['error'] = 'TLD not found'; + $this->redirect('/tld-registry'); + return; + } + + try { + // Remove dot from TLD for URL + $tldForUrl = ltrim($tld['tld'], '.'); + $url = "https://www.iana.org/domains/root/db/{$tldForUrl}.html"; + + $client = new \GuzzleHttp\Client(); + $response = $client->get($url); + $html = $response->getBody()->getContents(); + + // Extract data from HTML + $whoisServer = $this->extractWhoisServer($html); + $lastUpdated = $this->extractLastUpdated($html); + $registryUrl = $this->extractRegistryUrl($html); + $registrationDate = $this->extractRegistrationDate($html); + + $updateData = [ + 'updated_at' => date('Y-m-d H:i:s') + ]; + + if ($whoisServer) $updateData['whois_server'] = $whoisServer; + if ($lastUpdated) $updateData['record_last_updated'] = $lastUpdated; + if ($registryUrl) $updateData['registry_url'] = $registryUrl; + if ($registrationDate) $updateData['registration_date'] = $registrationDate; + + $this->tldModel->update($id, $updateData); + + $_SESSION['success'] = "TLD {$tld['tld']} data refreshed successfully"; + + } catch (\Exception $e) { + $_SESSION['error'] = 'Failed to refresh TLD data: ' . $e->getMessage(); + } + + $this->redirect('/tld-registry'); + } + + /** + * Show import logs + */ + public function importLogs() + { + $page = max(1, (int)($_GET['page'] ?? 1)); + $perPage = max(10, min(100, (int)($_GET['per_page'] ?? 20))); + + $result = $this->importLogModel->getPaginated($page, $perPage); + $importStats = $this->importLogModel->getImportStatistics(); + + $this->view('tld-registry/import-logs', [ + 'imports' => $result['logs'], + 'pagination' => $result['pagination'], + 'stats' => $importStats, + 'title' => 'TLD Import Logs' + ]); + } + + /** + * API endpoint to get TLD info for a domain + */ + public function apiGetTldInfo() + { + $domain = $_GET['domain'] ?? ''; + + if (empty($domain)) { + http_response_code(400); + echo json_encode(['error' => 'Domain parameter is required']); + return; + } + + try { + $tldInfo = $this->tldService->getTldInfo($domain); + + if ($tldInfo) { + echo json_encode([ + 'success' => true, + 'data' => $tldInfo + ]); + } else { + echo json_encode([ + 'success' => false, + 'message' => 'TLD information not found' + ]); + } + + } catch (\Exception $e) { + http_response_code(500); + echo json_encode([ + 'success' => false, + 'error' => $e->getMessage() + ]); + } + } + + /** + * Extract WHOIS server from HTML + */ + private function extractWhoisServer(string $html): ?string + { + if (preg_match('/WHOIS Server:\s*([^\s<]+)/i', $html, $matches)) { + return trim($matches[1]); + } + return null; + } + + /** + * Extract last updated date from HTML + */ + private function extractLastUpdated(string $html): ?string + { + if (preg_match('/Record last updated\s+(\d{4}-\d{2}-\d{2})/i', $html, $matches)) { + return $matches[1] . ' 00:00:00'; + } + return null; + } + + /** + * Extract registry URL from HTML + */ + private function extractRegistryUrl(string $html): ?string + { + if (preg_match('/URL for registration services:\s*([^\s<]+)/i', $html, $matches)) { + return trim($matches[1]); + } + return null; + } + + /** + * Extract registration date from HTML + */ + private function extractRegistrationDate(string $html): ?string + { + if (preg_match('/Registration date\s+(\d{4}-\d{2}-\d{2})/i', $html, $matches)) { + return $matches[1]; + } + return null; + } +} diff --git a/app/Models/Domain.php b/app/Models/Domain.php new file mode 100644 index 0000000..4e7778c --- /dev/null +++ b/app/Models/Domain.php @@ -0,0 +1,143 @@ +db->query($sql); + return $stmt->fetchAll(); + } + + /** + * Get domains expiring within days + */ + public function getExpiringDomains(int $days): array + { + $sql = "SELECT d.*, ng.name as group_name + FROM domains d + LEFT JOIN notification_groups ng ON d.notification_group_id = ng.id + WHERE d.is_active = 1 + AND d.expiration_date IS NOT NULL + AND d.expiration_date <= DATE_ADD(CURDATE(), INTERVAL ? DAY) + AND d.expiration_date >= CURDATE() + ORDER BY d.expiration_date ASC"; + + $stmt = $this->db->prepare($sql); + $stmt->execute([$days]); + return $stmt->fetchAll(); + } + + /** + * Get domains by status + */ + public function getByStatus(string $status): array + { + $sql = "SELECT d.*, ng.name as group_name + FROM domains d + LEFT JOIN notification_groups ng ON d.notification_group_id = ng.id + WHERE d.status = ? + ORDER BY d.expiration_date ASC"; + + $stmt = $this->db->prepare($sql); + $stmt->execute([$status]); + return $stmt->fetchAll(); + } + + /** + * Get domain with notification channels + */ + public function getWithChannels(int $id): ?array + { + $sql = "SELECT d.*, ng.name as group_name, ng.id as group_id + FROM domains d + LEFT JOIN notification_groups ng ON d.notification_group_id = ng.id + WHERE d.id = ?"; + + $stmt = $this->db->prepare($sql); + $stmt->execute([$id]); + $domain = $stmt->fetch(); + + if (!$domain) { + return null; + } + + // Get notification channels for this domain's group + if ($domain['group_id']) { + $channelModel = new NotificationChannel(); + $domain['channels'] = $channelModel->getByGroupId($domain['group_id']); + } else { + $domain['channels'] = []; + } + + return $domain; + } + + /** + * Check if domain exists + */ + public function existsByDomain(string $domainName): bool + { + $stmt = $this->db->prepare("SELECT COUNT(*) as count FROM domains WHERE domain_name = ?"); + $stmt->execute([$domainName]); + $result = $stmt->fetch(); + return $result['count'] > 0; + } + + /** + * Get recent domains + */ + public function getRecent(int $limit = 5): array + { + $sql = "SELECT d.*, ng.name as group_name + FROM domains d + LEFT JOIN notification_groups ng ON d.notification_group_id = ng.id + WHERE d.is_active = 1 + ORDER BY d.created_at DESC, d.id DESC + LIMIT ?"; + + $stmt = $this->db->prepare($sql); + $stmt->execute([$limit]); + return $stmt->fetchAll(); + } + + /** + * Get dashboard statistics + */ + public function getStatistics(): array + { + $stats = [ + 'total' => 0, + 'active' => 0, + 'expiring_soon' => 0, + 'expired' => 0, + 'inactive' => 0, + ]; + + $sql = "SELECT status, COUNT(*) as count FROM domains WHERE is_active = 1 GROUP BY status"; + $stmt = $this->db->query($sql); + $results = $stmt->fetchAll(); + + $stats['total'] = array_sum(array_column($results, 'count')); + + foreach ($results as $row) { + $stats[strtolower($row['status'])] = $row['count']; + } + + return $stats; + } +} + diff --git a/app/Models/NotificationChannel.php b/app/Models/NotificationChannel.php new file mode 100644 index 0000000..dce760f --- /dev/null +++ b/app/Models/NotificationChannel.php @@ -0,0 +1,68 @@ +where('notification_group_id', $groupId); + } + + /** + * Get active channels by notification group ID + */ + public function getActiveByGroupId(int $groupId): array + { + $sql = "SELECT * FROM notification_channels + WHERE notification_group_id = ? AND is_active = 1"; + + $stmt = $this->db->prepare($sql); + $stmt->execute([$groupId]); + return $stmt->fetchAll(); + } + + /** + * Create channel with JSON config + */ + public function createChannel(int $groupId, string $type, array $config): int + { + return $this->create([ + 'notification_group_id' => $groupId, + 'channel_type' => $type, + 'channel_config' => json_encode($config), + 'is_active' => 1 + ]); + } + + /** + * Update channel config + */ + public function updateConfig(int $id, array $config): bool + { + $sql = "UPDATE notification_channels SET channel_config = ?, updated_at = NOW() WHERE id = ?"; + $stmt = $this->db->prepare($sql); + return $stmt->execute([json_encode($config), $id]); + } + + /** + * Toggle channel active status + */ + public function toggleActive(int $id): bool + { + $sql = "UPDATE notification_channels + SET is_active = NOT is_active, updated_at = NOW() + WHERE id = ?"; + + $stmt = $this->db->prepare($sql); + return $stmt->execute([$id]); + } +} + diff --git a/app/Models/NotificationGroup.php b/app/Models/NotificationGroup.php new file mode 100644 index 0000000..99cbe05 --- /dev/null +++ b/app/Models/NotificationGroup.php @@ -0,0 +1,65 @@ +db->query($sql); + return $stmt->fetchAll(); + } + + /** + * Get group with channels and domains + */ + public function getWithDetails(int $id): ?array + { + $group = $this->find($id); + + if (!$group) { + return null; + } + + // Get channels + $channelModel = new NotificationChannel(); + $group['channels'] = $channelModel->getByGroupId($id); + + // Get domains + $domainModel = new Domain(); + $group['domains'] = $domainModel->where('notification_group_id', $id); + + return $group; + } + + /** + * Delete group and handle relationships + */ + public function deleteWithRelations(int $id): bool + { + // The database CASCADE will handle channels + // But we need to set domains to NULL + $sql = "UPDATE domains SET notification_group_id = NULL WHERE notification_group_id = ?"; + $stmt = $this->db->prepare($sql); + $stmt->execute([$id]); + + return $this->delete($id); + } +} + diff --git a/app/Models/NotificationLog.php b/app/Models/NotificationLog.php new file mode 100644 index 0000000..905fef0 --- /dev/null +++ b/app/Models/NotificationLog.php @@ -0,0 +1,75 @@ +create([ + 'domain_id' => $domainId, + 'notification_type' => $type, + 'channel_type' => $channel, + 'message' => $message, + 'status' => $success ? 'sent' : 'failed', + 'error_message' => $error + ]); + } + + /** + * Get logs for a domain + */ + public function getByDomain(int $domainId, int $limit = 50): array + { + $sql = "SELECT * FROM notification_logs + WHERE domain_id = ? + ORDER BY sent_at DESC + LIMIT ?"; + + $stmt = $this->db->prepare($sql); + $stmt->execute([$domainId, $limit]); + return $stmt->fetchAll(); + } + + /** + * Get recent logs + */ + public function getRecent(int $limit = 100): array + { + $sql = "SELECT nl.*, d.domain_name + FROM notification_logs nl + JOIN domains d ON nl.domain_id = d.id + ORDER BY nl.sent_at DESC + LIMIT ?"; + + $stmt = $this->db->prepare($sql); + $stmt->execute([$limit]); + return $stmt->fetchAll(); + } + + /** + * Check if notification was sent recently + */ + public function wasSentRecently(int $domainId, string $type, int $hoursAgo = 24): bool + { + $sql = "SELECT COUNT(*) as count FROM notification_logs + WHERE domain_id = ? + AND notification_type = ? + AND status = 'sent' + AND sent_at >= DATE_SUB(NOW(), INTERVAL ? HOUR)"; + + $stmt = $this->db->prepare($sql); + $stmt->execute([$domainId, $type, $hoursAgo]); + $result = $stmt->fetch(); + + return $result['count'] > 0; + } +} + diff --git a/app/Models/TldImportLog.php b/app/Models/TldImportLog.php new file mode 100644 index 0000000..90e8eb8 --- /dev/null +++ b/app/Models/TldImportLog.php @@ -0,0 +1,155 @@ +create([ + 'import_type' => $importType, + 'iana_publication_date' => $ianaPublicationDate, + 'status' => 'running', + 'started_at' => date('Y-m-d H:i:s') + ]); + } + + /** + * Complete an import log entry + */ + public function completeImport(int $logId, array $stats, ?string $status = null, ?string $errorMessage = null, ?array $details = null): bool + { + $data = [ + 'total_tlds' => $stats['total_tlds'] ?? 0, + 'new_tlds' => $stats['new_tlds'] ?? 0, + 'updated_tlds' => $stats['updated_tlds'] ?? 0, + 'failed_tlds' => $stats['failed_tlds'] ?? 0, + 'completed_at' => date('Y-m-d H:i:s'), + 'status' => $status ?? ($errorMessage ? 'failed' : 'completed'), + 'error_message' => $errorMessage + ]; + + if ($details !== null) { + $data['details'] = json_encode($details); + } + + return $this->update($logId, $data); + } + + /** + * Update an import log entry (for progress tracking) + */ + public function update(int $logId, array $data, ?string $status = null, ?string $errorMessage = null, ?array $details = null): bool + { + if ($status !== null) { + $data['status'] = $status; + } + + if ($errorMessage !== null) { + $data['error_message'] = $errorMessage; + } + + if ($details !== null) { + $data['details'] = json_encode($details); + } + + return parent::update($logId, $data); + } + + /** + * Get recent import logs + */ + public function getRecent(int $limit = 10): array + { + $sql = "SELECT *, + COALESCE(new_tlds, 0) as new_tlds + FROM tld_import_logs + ORDER BY started_at DESC + LIMIT ?"; + + $stmt = $this->db->prepare($sql); + $stmt->execute([$limit]); + return $stmt->fetchAll(); + } + + /** + * Get import statistics + */ + public function getImportStatistics(): array + { + $stats = [ + 'total_imports' => 0, + 'successful_imports' => 0, + 'failed_imports' => 0, + 'last_import' => null, + 'total_tlds_imported' => 0 + ]; + + // Total imports + $stmt = $this->db->query("SELECT COUNT(*) as count FROM tld_import_logs"); + $stats['total_imports'] = $stmt->fetch()['count']; + + // Successful imports + $stmt = $this->db->query("SELECT COUNT(*) as count FROM tld_import_logs WHERE status = 'completed'"); + $stats['successful_imports'] = $stmt->fetch()['count']; + + // Failed imports + $stmt = $this->db->query("SELECT COUNT(*) as count FROM tld_import_logs WHERE status = 'failed'"); + $stats['failed_imports'] = $stmt->fetch()['count']; + + // Last import + $stmt = $this->db->query("SELECT * FROM tld_import_logs ORDER BY started_at DESC LIMIT 1"); + $lastImport = $stmt->fetch(); + if ($lastImport) { + $stats['last_import'] = $lastImport['started_at']; + } + + // Total TLDs imported + $stmt = $this->db->query("SELECT SUM(total_tlds) as total FROM tld_import_logs WHERE status = 'completed'"); + $result = $stmt->fetch(); + $stats['total_tlds_imported'] = $result['total'] ?? 0; + + return $stats; + } + + /** + * Get import logs with pagination + */ + public function getPaginated(int $page = 1, int $perPage = 20): array + { + $offset = ($page - 1) * $perPage; + + $sql = "SELECT *, + COALESCE(new_tlds, 0) as new_tlds + FROM tld_import_logs + ORDER BY started_at DESC + LIMIT ? OFFSET ?"; + + $stmt = $this->db->prepare($sql); + $stmt->execute([$perPage, $offset]); + $logs = $stmt->fetchAll(); + + // Get total count + $countStmt = $this->db->query("SELECT COUNT(*) as count FROM tld_import_logs"); + $total = $countStmt->fetch()['count']; + + return [ + 'logs' => $logs, + 'pagination' => [ + 'current_page' => $page, + 'per_page' => $perPage, + 'total' => $total, + 'total_pages' => ceil($total / $perPage), + 'showing_from' => $total > 0 ? $offset + 1 : 0, + 'showing_to' => min($offset + $perPage, $total) + ] + ]; + } +} diff --git a/app/Models/TldRegistry.php b/app/Models/TldRegistry.php new file mode 100644 index 0000000..a0f3902 --- /dev/null +++ b/app/Models/TldRegistry.php @@ -0,0 +1,253 @@ +db->prepare("SELECT * FROM tld_registry WHERE tld = ? AND is_active = 1"); + $stmt->execute([$tld]); + return $stmt->fetch() ?: null; + } + + /** + * Get all active TLDs + */ + public function getAllActive(): array + { + $stmt = $this->db->query("SELECT * FROM tld_registry WHERE is_active = 1 ORDER BY tld ASC"); + return $stmt->fetchAll(); + } + + /** + * Get TLDs that need updating (older than specified days) + */ + public function getTldsNeedingUpdate(int $daysOld = 30): array + { + $sql = "SELECT * FROM tld_registry + WHERE is_active = 1 + AND (updated_at < DATE_SUB(NOW(), INTERVAL ? DAY) + OR updated_at IS NULL) + ORDER BY updated_at ASC"; + + $stmt = $this->db->prepare($sql); + $stmt->execute([$daysOld]); + return $stmt->fetchAll(); + } + + /** + * Create or update TLD registry entry + */ + public function createOrUpdate(array $data): int + { + $tld = $data['tld']; + + // Check if TLD already exists + $existing = $this->getByTld($tld); + + if ($existing) { + // Update existing record + $this->update($existing['id'], $data); + return $existing['id']; + } else { + // Create new record + return $this->create($data); + } + } + + /** + * Get TLD statistics + */ + public function getStatistics(): array + { + $stats = [ + 'total' => 0, + 'active' => 0, + 'with_rdap' => 0, + 'with_whois' => 0, + 'recently_updated' => 0, + 'needs_update' => 0 + ]; + + // Total TLDs + $stmt = $this->db->query("SELECT COUNT(*) as count FROM tld_registry"); + $stats['total'] = $stmt->fetch()['count']; + + // Active TLDs + $stmt = $this->db->query("SELECT COUNT(*) as count FROM tld_registry WHERE is_active = 1"); + $stats['active'] = $stmt->fetch()['count']; + + // TLDs with RDAP servers + $stmt = $this->db->query("SELECT COUNT(*) as count FROM tld_registry WHERE rdap_servers IS NOT NULL AND rdap_servers != '[]' AND is_active = 1"); + $stats['with_rdap'] = $stmt->fetch()['count']; + + // TLDs with WHOIS servers + $stmt = $this->db->query("SELECT COUNT(*) as count FROM tld_registry WHERE whois_server IS NOT NULL AND whois_server != '' AND is_active = 1"); + $stats['with_whois'] = $stmt->fetch()['count']; + + // Recently updated (last 7 days) + $stmt = $this->db->query("SELECT COUNT(*) as count FROM tld_registry WHERE updated_at > DATE_SUB(NOW(), INTERVAL 7 DAY) AND is_active = 1"); + $stats['recently_updated'] = $stmt->fetch()['count']; + + // Needs update (older than 30 days) + $stmt = $this->db->query("SELECT COUNT(*) as count FROM tld_registry WHERE updated_at < DATE_SUB(NOW(), INTERVAL 30 DAY) AND is_active = 1"); + $stats['needs_update'] = $stmt->fetch()['count']; + + return $stats; + } + + /** + * Get TLDs by search term + */ + public function search(string $search): array + { + $search = '%' . $search . '%'; + $sql = "SELECT * FROM tld_registry + WHERE (LOWER(tld) LIKE LOWER(?) OR LOWER(whois_server) LIKE LOWER(?) OR LOWER(registry_url) LIKE LOWER(?)) + ORDER BY tld ASC"; + + $stmt = $this->db->prepare($sql); + $stmt->execute([$search, $search, $search]); + return $stmt->fetchAll(); + } + + /** + * Get TLDs with pagination and sorting + */ + public function getPaginated(int $page = 1, int $perPage = 50, string $search = '', string $sort = 'tld', string $order = 'asc', string $status = '', string $dataType = ''): array + { + $offset = ($page - 1) * $perPage; + + // Validate sort column + $allowedSorts = ['tld', 'rdap_servers', 'whois_server', 'updated_at', 'is_active']; + if (!in_array($sort, $allowedSorts)) { + $sort = 'tld'; + } + + // Validate order + $order = strtolower($order) === 'desc' ? 'DESC' : 'ASC'; + + // Build WHERE clause + $whereConditions = []; + $params = []; + + // Search filter + if (!empty($search)) { + $searchParam = '%' . $search . '%'; + $whereConditions[] = "(LOWER(tld) LIKE LOWER(?) OR LOWER(whois_server) LIKE LOWER(?) OR LOWER(registry_url) LIKE LOWER(?))"; + $params = array_merge($params, [$searchParam, $searchParam, $searchParam]); + } + + // Status filter + if ($status === 'active') { + $whereConditions[] = "is_active = 1"; + } elseif ($status === 'inactive') { + $whereConditions[] = "is_active = 0"; + } + + // Data type filter + if ($dataType === 'with_rdap') { + $whereConditions[] = "(rdap_servers IS NOT NULL AND rdap_servers != '' AND rdap_servers != '[]')"; + } elseif ($dataType === 'with_whois') { + $whereConditions[] = "(whois_server IS NOT NULL AND whois_server != '')"; + } elseif ($dataType === 'with_registry') { + $whereConditions[] = "(registry_url IS NOT NULL AND registry_url != '')"; + } elseif ($dataType === 'missing_data') { + $whereConditions[] = "((rdap_servers IS NULL OR rdap_servers = '' OR rdap_servers = '[]') AND (whois_server IS NULL OR whois_server = '') AND (registry_url IS NULL OR registry_url = ''))"; + } + + $whereClause = !empty($whereConditions) ? 'WHERE ' . implode(' AND ', $whereConditions) : ''; + + // Build ORDER BY clause + $orderBy = "ORDER BY $sort $order"; + if ($sort === 'tld') { + $orderBy .= ", tld ASC"; // Secondary sort for consistent results + } + + // Build main query + $sql = "SELECT * FROM tld_registry $whereClause $orderBy LIMIT ? OFFSET ?"; + $stmt = $this->db->prepare($sql); + $stmt->execute(array_merge($params, [$perPage, $offset])); + $tlds = $stmt->fetchAll(); + + // Get total count + $countSql = "SELECT COUNT(*) as count FROM tld_registry $whereClause"; + $countStmt = $this->db->prepare($countSql); + $countStmt->execute($params); + $total = $countStmt->fetch()['count']; + + return [ + 'tlds' => $tlds, + 'pagination' => [ + 'current_page' => $page, + 'per_page' => $perPage, + 'total' => $total, + 'total_pages' => ceil($total / $perPage), + 'showing_from' => $total > 0 ? $offset + 1 : 0, + 'showing_to' => min($offset + $perPage, $total) + ] + ]; + } + + /** + * Toggle TLD active status + */ + public function toggleActive(int $id): bool + { + $sql = "UPDATE tld_registry SET is_active = NOT is_active, updated_at = NOW() WHERE id = ?"; + $stmt = $this->db->prepare($sql); + return $stmt->execute([$id]); + } + + /** + * Get TLDs that have RDAP servers + */ + public function getTldsWithRdap(): array + { + $sql = "SELECT * FROM tld_registry + WHERE rdap_servers IS NOT NULL + AND rdap_servers != '[]' + AND is_active = 1 + ORDER BY tld ASC"; + + $stmt = $this->db->query($sql); + return $stmt->fetchAll(); + } + + /** + * Get TLDs that have WHOIS servers + */ + public function getTldsWithWhois(): array + { + $sql = "SELECT * FROM tld_registry + WHERE whois_server IS NOT NULL + AND whois_server != '' + AND is_active = 1 + ORDER BY tld ASC"; + + $stmt = $this->db->query($sql); + return $stmt->fetchAll(); + } + + /** + * Execute a custom SQL query + */ + public function query(string $sql): array + { + $stmt = $this->db->query($sql); + return $stmt->fetchAll(); + } +} diff --git a/app/Models/User.php b/app/Models/User.php new file mode 100644 index 0000000..b9e2c2e --- /dev/null +++ b/app/Models/User.php @@ -0,0 +1,65 @@ +db->prepare("SELECT * FROM users WHERE username = ? AND is_active = 1"); + $stmt->execute([$username]); + $result = $stmt->fetch(); + return $result ?: null; + } + + /** + * Verify password + */ + public function verifyPassword(string $password, string $hash): bool + { + return password_verify($password, $hash); + } + + /** + * Update last login timestamp + */ + public function updateLastLogin(int $userId): bool + { + $stmt = $this->db->prepare("UPDATE users SET last_login = NOW() WHERE id = ?"); + return $stmt->execute([$userId]); + } + + /** + * Create user with hashed password + */ + public function createUser(string $username, string $password, ?string $email = null, ?string $fullName = null): int + { + $hashedPassword = password_hash($password, PASSWORD_DEFAULT); + + return $this->create([ + 'username' => $username, + 'password' => $hashedPassword, + 'email' => $email, + 'full_name' => $fullName, + 'is_active' => 1 + ]); + } + + /** + * Change password + */ + public function changePassword(int $userId, string $newPassword): bool + { + $hashedPassword = password_hash($newPassword, PASSWORD_DEFAULT); + $stmt = $this->db->prepare("UPDATE users SET password = ? WHERE id = ?"); + return $stmt->execute([$hashedPassword, $userId]); + } +} + diff --git a/app/Services/Channels/DiscordChannel.php b/app/Services/Channels/DiscordChannel.php new file mode 100644 index 0000000..8b826ea --- /dev/null +++ b/app/Services/Channels/DiscordChannel.php @@ -0,0 +1,100 @@ +client = new Client(['timeout' => 10]); + } + + public function send(array $config, string $message, array $data = []): bool + { + if (!isset($config['webhook_url'])) { + return false; + } + + try { + $embed = $this->createEmbed($message, $data); + + $response = $this->client->post($config['webhook_url'], [ + 'json' => [ + 'embeds' => [$embed] + ] + ]); + + return $response->getStatusCode() === 204; + } catch (\Exception $e) { + error_log("Discord send failed: " . $e->getMessage()); + return false; + } + } + + private function createEmbed(string $message, array $data): array + { + $color = $this->getColorByDaysLeft($data['days_left'] ?? null); + + $embed = [ + 'title' => 'πŸ”” Domain Expiration Alert', + 'description' => $message, + 'color' => $color, + 'timestamp' => date('c'), + 'footer' => [ + 'text' => 'Domain Monitor' + ] + ]; + + if (isset($data['domain'])) { + $embed['fields'] = [ + [ + 'name' => 'Domain', + 'value' => $data['domain'], + 'inline' => true + ], + [ + 'name' => 'Days Left', + 'value' => $data['days_left'], + 'inline' => true + ], + [ + 'name' => 'Expiration Date', + 'value' => $data['expiration_date'], + 'inline' => true + ] + ]; + } + + return $embed; + } + + private function getColorByDaysLeft(?int $daysLeft): int + { + if ($daysLeft === null) { + return 0x808080; // Gray + } + + if ($daysLeft <= 0) { + return 0xFF0000; // Red + } + + if ($daysLeft <= 3) { + return 0xFF4500; // Orange Red + } + + if ($daysLeft <= 7) { + return 0xFFA500; // Orange + } + + if ($daysLeft <= 30) { + return 0xFFFF00; // Yellow + } + + return 0x00FF00; // Green + } +} + diff --git a/app/Services/Channels/EmailChannel.php b/app/Services/Channels/EmailChannel.php new file mode 100644 index 0000000..cb7793a --- /dev/null +++ b/app/Services/Channels/EmailChannel.php @@ -0,0 +1,91 @@ +isSMTP(); + $mail->Host = $_ENV['MAIL_HOST']; + $mail->SMTPAuth = true; + $mail->Username = $_ENV['MAIL_USERNAME']; + $mail->Password = $_ENV['MAIL_PASSWORD']; + $mail->SMTPSecure = $_ENV['MAIL_ENCRYPTION']; + $mail->Port = $_ENV['MAIL_PORT']; + + // Recipients + $mail->setFrom($_ENV['MAIL_FROM_ADDRESS'], $_ENV['MAIL_FROM_NAME']); + $mail->addAddress($config['email']); + + // Content + $mail->isHTML(true); + $mail->Subject = $this->getSubject($data); + $mail->Body = $this->formatHtmlBody($message, $data); + $mail->AltBody = strip_tags($message); + + $mail->send(); + return true; + } catch (Exception $e) { + error_log("Email send failed: {$mail->ErrorInfo}"); + return false; + } + } + + private function getSubject(array $data): string + { + if (isset($data['domain'])) { + $daysLeft = $data['days_left']; + if ($daysLeft <= 0) { + return "🚨 URGENT: Domain {$data['domain']} has EXPIRED"; + } + if ($daysLeft == 1) { + return "⚠️ CRITICAL: Domain {$data['domain']} expires TOMORROW"; + } + return "⚠️ Domain Expiration Alert: {$data['domain']} ({$daysLeft} days)"; + } + + return "Domain Monitor Alert"; + } + + private function formatHtmlBody(string $message, array $data): string + { + $messageHtml = nl2br(htmlspecialchars($message)); + + return " + + + + + +
+
+

πŸ”” Domain Monitor Alert

+
+
+

$messageHtml

+
+ +
+ + + "; + } +} + diff --git a/app/Services/Channels/NotificationChannelInterface.php b/app/Services/Channels/NotificationChannelInterface.php new file mode 100644 index 0000000..90975f0 --- /dev/null +++ b/app/Services/Channels/NotificationChannelInterface.php @@ -0,0 +1,17 @@ +client = new Client(['timeout' => 10]); + } + + public function send(array $config, string $message, array $data = []): bool + { + if (!isset($config['webhook_url'])) { + return false; + } + + try { + $payload = [ + 'text' => $message, + 'blocks' => $this->createBlocks($message, $data) + ]; + + $response = $this->client->post($config['webhook_url'], [ + 'json' => $payload + ]); + + return $response->getStatusCode() === 200; + } catch (\Exception $e) { + error_log("Slack send failed: " . $e->getMessage()); + return false; + } + } + + private function createBlocks(string $message, array $data): array + { + $blocks = [ + [ + 'type' => 'header', + 'text' => [ + 'type' => 'plain_text', + 'text' => 'πŸ”” Domain Expiration Alert' + ] + ], + [ + 'type' => 'section', + 'text' => [ + 'type' => 'mrkdwn', + 'text' => $message + ] + ] + ]; + + if (isset($data['domain'])) { + $blocks[] = [ + 'type' => 'section', + 'fields' => [ + [ + 'type' => 'mrkdwn', + 'text' => "*Domain:*\n{$data['domain']}" + ], + [ + 'type' => 'mrkdwn', + 'text' => "*Days Left:*\n{$data['days_left']}" + ], + [ + 'type' => 'mrkdwn', + 'text' => "*Expiration:*\n{$data['expiration_date']}" + ], + [ + 'type' => 'mrkdwn', + 'text' => "*Registrar:*\n{$data['registrar']}" + ] + ] + ]; + } + + return $blocks; + } +} + diff --git a/app/Services/Channels/TelegramChannel.php b/app/Services/Channels/TelegramChannel.php new file mode 100644 index 0000000..27f8239 --- /dev/null +++ b/app/Services/Channels/TelegramChannel.php @@ -0,0 +1,42 @@ +client = new Client([ + 'base_uri' => 'https://api.telegram.org', + 'timeout' => 10, + ]); + } + + public function send(array $config, string $message, array $data = []): bool + { + if (!isset($config['bot_token']) || !isset($config['chat_id'])) { + return false; + } + + try { + $response = $this->client->post("/bot{$config['bot_token']}/sendMessage", [ + 'json' => [ + 'chat_id' => $config['chat_id'], + 'text' => $message, + 'parse_mode' => 'HTML', + 'disable_web_page_preview' => true, + ] + ]); + + return $response->getStatusCode() === 200; + } catch (\Exception $e) { + error_log("Telegram send failed: " . $e->getMessage()); + return false; + } + } +} + diff --git a/app/Services/Logger.php b/app/Services/Logger.php new file mode 100644 index 0000000..3a4710e --- /dev/null +++ b/app/Services/Logger.php @@ -0,0 +1,155 @@ +logDir = __DIR__ . '/../../logs'; + $this->enabled = $enabled; + + // Create logs directory if it doesn't exist + if (!is_dir($this->logDir)) { + mkdir($this->logDir, 0755, true); + } + + // Set log file name with date + $date = date('Y-m-d'); + $this->currentLogFile = $this->logDir . '/' . $logName . '_' . $date . '.log'; + } + + /** + * Log a message with level + */ + public function log(string $level, string $message, array $context = []): void + { + if (!$this->enabled) { + return; + } + + $timestamp = date('Y-m-d H:i:s'); + $contextStr = !empty($context) ? ' | Context: ' . json_encode($context) : ''; + $logLine = "[{$timestamp}] [{$level}] {$message}{$contextStr}\n"; + + file_put_contents($this->currentLogFile, $logLine, FILE_APPEND | LOCK_EX); + } + + /** + * Log debug message + */ + public function debug(string $message, array $context = []): void + { + $this->log('DEBUG', $message, $context); + } + + /** + * Log info message + */ + public function info(string $message, array $context = []): void + { + $this->log('INFO', $message, $context); + } + + /** + * Log warning message + */ + public function warning(string $message, array $context = []): void + { + $this->log('WARNING', $message, $context); + } + + /** + * Log error message + */ + public function error(string $message, array $context = []): void + { + $this->log('ERROR', $message, $context); + } + + /** + * Log critical message + */ + public function critical(string $message, array $context = []): void + { + $this->log('CRITICAL', $message, $context); + } + + /** + * Log progress with percentage + */ + public function progress(string $message, int $current, int $total, array $context = []): void + { + $percentage = $total > 0 ? round(($current / $total) * 100, 2) : 0; + $progressMessage = "{$message} [{$current}/{$total}] ({$percentage}%)"; + $this->info($progressMessage, $context); + } + + /** + * Log separator for better readability + */ + public function separator(string $title = ''): void + { + $line = str_repeat('=', 80); + if (!empty($title)) { + $titleLine = "=== {$title} " . str_repeat('=', 80 - strlen($title) - 5); + $this->log('INFO', $titleLine); + } else { + $this->log('INFO', $line); + } + } + + /** + * Log start of operation + */ + public function startOperation(string $operation, array $context = []): void + { + $this->separator("START: {$operation}"); + $this->info("Starting operation: {$operation}", $context); + } + + /** + * Log end of operation + */ + public function endOperation(string $operation, array $stats = []): void + { + $this->info("Completed operation: {$operation}", $stats); + $this->separator("END: {$operation}"); + } + + /** + * Get log file path + */ + public function getLogFile(): string + { + return $this->currentLogFile; + } + + /** + * Clear current log file + */ + public function clear(): void + { + if (file_exists($this->currentLogFile)) { + unlink($this->currentLogFile); + } + } + + /** + * Read last N lines from log file + */ + public function tail(int $lines = 100): array + { + if (!file_exists($this->currentLogFile)) { + return []; + } + + $file = file($this->currentLogFile); + return array_slice($file, -$lines); + } +} + diff --git a/app/Services/NotificationService.php b/app/Services/NotificationService.php new file mode 100644 index 0000000..d829fc7 --- /dev/null +++ b/app/Services/NotificationService.php @@ -0,0 +1,153 @@ +channels = [ + 'email' => new EmailChannel(), + 'telegram' => new TelegramChannel(), + 'discord' => new DiscordChannel(), + 'slack' => new SlackChannel(), + ]; + } + + /** + * Send notification to specified channel + */ + public function send(string $channelType, array $config, string $message, array $data = []): bool + { + if (!isset($this->channels[$channelType])) { + return false; + } + + 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 + } + + $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 + ); + + $results[] = [ + 'channel' => $channel['channel_type'], + 'success' => $success + ]; + } + + 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'], + '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/Services/TldRegistryService.php b/app/Services/TldRegistryService.php new file mode 100644 index 0000000..ee212d8 --- /dev/null +++ b/app/Services/TldRegistryService.php @@ -0,0 +1,1866 @@ +httpClient = new Client([ + 'timeout' => 15, // Reduced for faster processing + 'connect_timeout' => 5, // Reduced for faster processing + 'verify' => true, // Enable SSL verification + 'allow_redirects' => [ + 'max' => 5, + 'strict' => false, + 'referer' => true, + 'protocols' => ['http', 'https'] + ], + 'headers' => [ + 'User-Agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + 'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8', + 'Accept-Language' => 'en-US,en;q=0.9', + 'Accept-Encoding' => 'gzip, deflate, br', + 'DNT' => '1', + 'Connection' => 'keep-alive', + 'Upgrade-Insecure-Requests' => '1', + 'Sec-Fetch-Dest' => 'document', + 'Sec-Fetch-Mode' => 'navigate', + 'Sec-Fetch-Site' => 'none', + 'Cache-Control' => 'max-age=0' + ] + ]); + $this->tldModel = new TldRegistry(); + $this->importLogModel = new TldImportLog(); + $this->logger = new Logger('tld_import'); + } + + /** + * Get HTTP client configured for JSON requests + */ + private function getJsonClient(): Client + { + return new Client([ + 'timeout' => 15, // Reduced for faster processing + 'connect_timeout' => 5, // Reduced for faster processing + 'verify' => true, + 'allow_redirects' => [ + 'max' => 3, + 'strict' => true, + 'referer' => false, + 'protocols' => ['https'] + ], + 'headers' => [ + 'User-Agent' => 'DomainMonitor/1.0 (TLD Registry Bot; compatible with IANA RDAP)', + 'Accept' => 'application/json, application/rdap+json, */*;q=0.8', + 'Accept-Language' => 'en-US,en;q=0.9', + 'Accept-Encoding' => 'gzip, deflate, br', + 'Connection' => 'keep-alive', + 'Cache-Control' => 'no-cache' + ], + 'http_errors' => false, // Don't throw exceptions on HTTP error codes + 'retry' => [ + 'max' => 2, // Reduced retries for speed + 'delay' => 500, // 0.5 second delay between retries (reduced) + 'multiplier' => 1.5 + ] + ]); + } + + /** + * Get HTTP client configured for HTML requests + */ + private function getHtmlClient(): Client + { + return new Client([ + 'timeout' => 8, // Further reduced for faster processing + 'connect_timeout' => 3, // Further reduced for faster processing + 'verify' => true, + 'allow_redirects' => [ + 'max' => 3, // Reduced redirects + 'strict' => false, + 'referer' => true, + 'protocols' => ['http', 'https'] + ], + 'headers' => [ + 'User-Agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + 'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8', + 'Accept-Language' => 'en-US,en;q=0.9', + 'Accept-Encoding' => 'gzip, deflate, br', + 'DNT' => '1', + 'Connection' => 'keep-alive', + 'Upgrade-Insecure-Requests' => '1', + 'Sec-Fetch-Dest' => 'document', + 'Sec-Fetch-Mode' => 'navigate', + 'Sec-Fetch-Site' => 'none', + 'Cache-Control' => 'max-age=0' + ], + 'http_errors' => false, // Don't throw exceptions on HTTP error codes + 'retry' => [ + 'max' => 0, // No retries for HTML to avoid timeouts + 'delay' => 0, + 'multiplier' => 1 + ] + ]); + } + + /** + * Import TLD list from IANA + */ + public function importTldList(): array + { + $logId = $this->importLogModel->startImport('tld_list'); + $stats = [ + 'total_tlds' => 0, + 'new_tlds' => 0, + 'updated_tlds' => 0, + 'failed_tlds' => 0 + ]; + + try { + // Fetch TLD list from IANA + $jsonClient = $this->getJsonClient(); + $response = $jsonClient->get(self::IANA_TLD_LIST_URL); + + // Check response status + if ($response->getStatusCode() !== 200) { + throw new \Exception('Failed to fetch TLD list: HTTP ' . $response->getStatusCode()); + } + + $content = $response->getBody()->getContents(); + + // Parse the content to extract version and TLDs + $lines = explode("\n", $content); + $version = null; + $lastUpdated = null; + $tlds = []; + + foreach ($lines as $line) { + $line = trim($line); + + // Skip empty lines + if (empty($line)) { + continue; + } + + // Extract version and timestamp from header + if (strpos($line, '# Version') === 0) { + if (preg_match('/# Version (\d+), Last Updated (.+)/', $line, $matches)) { + $version = $matches[1]; + $lastUpdatedRaw = $matches[2]; + + // Convert the timestamp to a proper format + try { + $lastUpdated = date('Y-m-d H:i:s', strtotime($lastUpdatedRaw)); + if ($lastUpdated === '1970-01-01 00:00:00') { + // If strtotime fails, keep the raw value + $lastUpdated = $lastUpdatedRaw; + } + } catch (\Exception $e) { + $lastUpdated = $lastUpdatedRaw; + } + } + continue; + } + + // Skip comment lines + if (strpos($line, '#') === 0) { + continue; + } + + // Add TLD to list (ensure it starts with dot) + $tld = '.' . strtolower($line); + $tlds[] = $tld; + } + + if (empty($tlds)) { + throw new \Exception('No TLDs found in the response'); + } + + $stats['total_tlds'] = count($tlds); + + // Update log with version and timestamp + $this->importLogModel->update($logId, [ + 'iana_publication_date' => $lastUpdated, + 'version' => $version + ]); + + // Process each TLD + foreach ($tlds as $tld) { + try { + $result = $this->processTldEntry($tld); + + if ($result['is_new']) { + $stats['new_tlds']++; + } else { + $stats['updated_tlds']++; + } + } catch (\Exception $e) { + $stats['failed_tlds']++; + error_log("Failed to process TLD $tld: " . $e->getMessage()); + } + } + + $this->importLogModel->completeImport($logId, $stats); + + } catch (\Exception $e) { + $this->importLogModel->completeImport($logId, $stats, $e->getMessage()); + throw $e; + } + + return $stats; + } + + /** + * Import RDAP data from IANA + */ + public function importRdapData(): array + { + $logId = $this->importLogModel->startImport('rdap'); + $stats = [ + 'total_tlds' => 0, + 'new_tlds' => 0, + 'updated_tlds' => 0, + 'failed_tlds' => 0 + ]; + + try { + // Fetch RDAP data from IANA using JSON client + $jsonClient = $this->getJsonClient(); + $response = $jsonClient->get(self::IANA_RDAP_URL); + + // Check response status + if ($response->getStatusCode() !== 200) { + throw new \Exception('Failed to fetch RDAP data: HTTP ' . $response->getStatusCode()); + } + + $data = json_decode($response->getBody()->getContents(), true); + + if (!$data || !isset($data['services'])) { + throw new \Exception('Invalid RDAP data format or empty response'); + } + + $publicationDate = $data['publication'] ?? null; + $services = $data['services'] ?? []; + + // Update log with publication date + $this->importLogModel->update($logId, ['iana_publication_date' => $publicationDate]); + + foreach ($services as $service) { + $tlds = $service[0] ?? []; // TLD patterns + $rdapServers = $service[1] ?? []; // RDAP servers + + foreach ($tlds as $tld) { + $stats['total_tlds']++; + + try { + $result = $this->processTldRdapData($tld, $rdapServers, $publicationDate); + + if ($result['is_new']) { + $stats['new_tlds']++; + } else { + $stats['updated_tlds']++; + } + } catch (\Exception $e) { + $stats['failed_tlds']++; + error_log("Failed to process TLD $tld: " . $e->getMessage()); + } + } + } + + $this->importLogModel->completeImport($logId, $stats); + + } catch (\Exception $e) { + $this->importLogModel->completeImport($logId, $stats, $e->getMessage()); + throw $e; + } + + return $stats; + } + + /** + * Import WHOIS data for TLDs missing WHOIS servers or needing updates + */ + public function importWhoisDataForMissingTlds(): array + { + $logId = $this->importLogModel->startImport('whois'); + $stats = [ + 'total_tlds' => 0, + 'new_tlds' => 0, + 'updated_tlds' => 0, + 'failed_tlds' => 0 + ]; + + try { + // Get TLDs that need WHOIS data (missing WHOIS server or old data) + $tldsNeedingWhois = $this->getTldsNeedingWhoisData(); + + foreach ($tldsNeedingWhois as $index => $tld) { + $stats['total_tlds']++; + + try { + $result = $this->fetchWhoisDataFromIana($tld['tld']); + + if ($result) { + $this->tldModel->update($tld['id'], $result); + $stats['updated_tlds']++; + } else { + $stats['failed_tlds']++; + } + } catch (\Exception $e) { + $stats['failed_tlds']++; + error_log("Failed to fetch WHOIS data for TLD {$tld['tld']}: " . $e->getMessage()); + } + + // Add delay between requests to be respectful to IANA servers + if ($index < count($tldsNeedingWhois) - 1) { + usleep(500000); // 0.5 second delay + } + } + + $this->importLogModel->completeImport($logId, $stats); + + } catch (\Exception $e) { + $this->importLogModel->completeImport($logId, $stats, $e->getMessage()); + throw $e; + } + + return $stats; + } + + /** + * Get TLDs that need WHOIS data (missing or outdated) + */ + private function getTldsNeedingWhoisData(int $limit = 100, int $startFromId = 0): array + { + // Process ALL TLDs systematically (A to Z, or ID 1 to last ID) + // This ensures we get complete data for every TLD, even if some don't have WHOIS/RDAP data + $sql = "SELECT * FROM tld_registry + WHERE is_active = 1 + AND id > " . intval($startFromId) . " + ORDER BY + CASE + WHEN (whois_server IS NULL OR whois_server = '') AND (registry_url IS NULL OR registry_url = '') THEN 0 + WHEN whois_server IS NULL OR whois_server = '' THEN 1 + WHEN registry_url IS NULL OR registry_url = '' THEN 2 + WHEN registration_date IS NULL OR record_last_updated IS NULL THEN 3 + ELSE 4 + END, + id ASC + LIMIT " . intval($limit); + + // Use the model's database connection through a public method + return $this->tldModel->query($sql); + } + + /** + * Get the highest ID of processed TLDs for this import session + */ + private function getLastProcessedTldId(int $logId): int + { + // Get the last processed TLD ID from the import log details + $log = $this->importLogModel->find($logId); + $details = $log['details'] ? json_decode($log['details'], true) : []; + + $lastId = $details['last_processed_id'] ?? 0; + + $this->logger->debug("Retrieved last_processed_id from database", [ + 'log_id' => $logId, + 'last_processed_id' => $lastId, + 'details_raw' => $log['details'] ?? 'NULL', + 'details_parsed_keys' => array_keys($details) + ]); + + return $lastId; + } + + /** + * Set the last processed TLD ID for this import session + */ + private function setLastProcessedTldId(int $logId, int $lastId): void + { + $log = $this->importLogModel->find($logId); + $details = $log['details'] ? json_decode($log['details'], true) : []; + $details['last_processed_id'] = $lastId; + + $this->logger->debug("Updating last_processed_id", [ + 'log_id' => $logId, + 'last_id' => $lastId, + 'full_details' => $details + ]); + + // Fix: Pass details in the data array directly to avoid empty array issue + $updateResult = $this->importLogModel->update($logId, ['details' => json_encode($details)]); + + // Verify the update worked + if (!$updateResult) { + $this->logger->error("Failed to update last_processed_id in database!", [ + 'log_id' => $logId, + 'last_id' => $lastId + ]); + } else { + // Verify by reading it back + $verifyLog = $this->importLogModel->find($logId); + $verifyDetails = $verifyLog['details'] ? json_decode($verifyLog['details'], true) : []; + $verifiedId = $verifyDetails['last_processed_id'] ?? 0; + + if ($verifiedId !== $lastId) { + $this->logger->critical("Database verification FAILED! last_processed_id mismatch", [ + 'expected' => $lastId, + 'actual' => $verifiedId, + 'log_id' => $logId + ]); + } else { + $this->logger->debug("Database update verified successfully", [ + 'log_id' => $logId, + 'verified_id' => $verifiedId + ]); + } + } + } + + /** + * Fetch registry URL from IANA RDAP API + */ + private function fetchRegistryUrlFromRdap(string $tld): ?string + { + $tldForUrl = ltrim($tld, '.'); + $url = self::IANA_RDAP_DOMAIN_URL . $tldForUrl; + + try { + $jsonClient = $this->getJsonClient(); + $response = $jsonClient->get($url); + + if ($response->getStatusCode() !== 200) { + return null; + } + + $data = json_decode($response->getBody()->getContents(), true); + + if (isset($data['links']) && is_array($data['links'])) { + foreach ($data['links'] as $link) { + if (isset($link['rel']) && $link['rel'] === 'related' && + isset($link['title']) && $link['title'] === 'Registration URL') { + return $link['href'] ?? null; + } + } + } + + } catch (\Exception $e) { + error_log("Failed to fetch RDAP registry URL for TLD $tld: " . $e->getMessage()); + } + + return null; + } + + /** + * Get count of TLDs that need WHOIS data + */ + public function getTldsNeedingWhoisCount(int $logId = null): int + { + if ($logId) { + // For a specific import session, count TLDs that haven't been processed yet + $lastProcessedId = $this->getLastProcessedTldId($logId); + $sql = "SELECT COUNT(*) as count FROM tld_registry WHERE is_active = 1 AND id > " . intval($lastProcessedId); + } else { + // Count ALL active TLDs since we process them all systematically + $sql = "SELECT COUNT(*) as count FROM tld_registry WHERE is_active = 1"; + } + + $result = $this->tldModel->query($sql); + return $result[0]['count'] ?? 0; + } + + /** + * Start progressive import for any type + */ + public function startProgressiveImport(string $importType): array + { + $logId = $this->importLogModel->startImport($importType); + + switch ($importType) { + case 'tld_list': + $total = $this->getTotalTldsFromIana(); + $message = "Started TLD list import"; + break; + + case 'rdap': + $total = $this->tldModel->getStatistics()['total']; + $message = "Started RDAP import for {$total} TLDs"; + break; + + case 'whois': + $total = $this->getTldsNeedingWhoisCount(); + if ($total === 0) { + return [ + 'status' => 'complete', + 'message' => 'All TLDs already have WHOIS data', + 'total' => 0, + 'processed' => 0, + 'remaining' => 0 + ]; + } + $message = "Started WHOIS import for {$total} TLDs"; + break; + + case 'check_updates': + $total = 2; // TLD list + RDAP + $message = "Started update check"; + break; + + case 'complete_workflow': + $total = 4; // TLD list + RDAP + WHOIS + Registry URLs + $message = "Started complete TLD import workflow"; + break; + + default: + throw new \Exception("Unknown import type: {$importType}"); + } + + return [ + 'status' => 'started', + 'log_id' => $logId, + 'import_type' => $importType, + 'total' => $total, + 'processed' => 0, + 'remaining' => $total, + 'message' => $message + ]; + } + + /** + * Get total TLDs from IANA (for TLD list import) + */ + private function getTotalTldsFromIana(): int + { + try { + $jsonClient = $this->getJsonClient(); + $response = $jsonClient->get(self::IANA_TLD_LIST_URL); + + if ($response->getStatusCode() !== 200) { + return 0; + } + + $content = $response->getBody()->getContents(); + $lines = explode("\n", $content); + $count = 0; + + foreach ($lines as $line) { + $line = trim($line); + if (!empty($line) && strpos($line, '#') !== 0) { + $count++; + } + } + + return $count; + } catch (\Exception $e) { + return 0; + } + } + + /** + * Process next batch of imports (universal) + */ + public function processNextBatch(int $logId): array + { + // Get import type from log + $log = $this->importLogModel->find($logId); + if (!$log) { + return ['status' => 'error', 'message' => 'Import log not found']; + } + + $importType = $log['import_type']; + + switch ($importType) { + case 'tld_list': + return $this->processTldListBatch($logId); + case 'rdap': + return $this->processRdapBatch($logId); + case 'whois': + return $this->processWhoisBatch($logId); + case 'check_updates': + return $this->processCheckUpdatesBatch($logId); + case 'complete_workflow': + return $this->processCompleteWorkflowBatch($logId); + default: + return ['status' => 'error', 'message' => 'Unknown import type']; + } + } + + /** + * Process TLD list batch + */ + private function processTldListBatch(int $logId): array + { + // Get current progress from log + $log = $this->importLogModel->find($logId); + $currentProgress = $log['details'] ? json_decode($log['details'], true) : ['processed' => 0, 'failed' => 0]; + + try { + // Process TLD list in one go (it's already fast) + $stats = $this->importTldList(); + + // Update progress + $currentProgress['processed'] += $stats['new_tlds'] + $stats['updated_tlds']; + $currentProgress['failed'] += $stats['failed_tlds']; + + $this->importLogModel->completeImport($logId, $stats, 'completed', null, $currentProgress); + + return [ + 'status' => 'complete', + 'log_id' => $logId, + 'total' => $stats['total_tlds'], + 'processed' => $currentProgress['processed'], + 'failed' => $currentProgress['failed'], + 'remaining' => 0, + 'message' => 'TLD list import completed' + ]; + } catch (\Exception $e) { + $this->importLogModel->completeImport($logId, [ + 'total_tlds' => 0, + 'new_tlds' => 0, + 'updated_tlds' => 0, + 'failed_tlds' => 1 + ], 'failed', $e->getMessage()); + + return [ + 'status' => 'error', + 'message' => 'TLD list import failed: ' . $e->getMessage() + ]; + } + } + + /** + * Process RDAP batch + */ + private function processRdapBatch(int $logId): array + { + // Get current progress from log + $log = $this->importLogModel->find($logId); + $currentProgress = $log['details'] ? json_decode($log['details'], true) : ['processed' => 0, 'failed' => 0, 'total' => 0]; + + // If this is the first batch, get total count + if ($currentProgress['total'] == 0) { + $currentProgress['total'] = $this->tldModel->getStatistics()['total']; + } + + try { + // Process RDAP data in one go (it's already fast) + $stats = $this->importRdapData(); + + // Update progress + $currentProgress['processed'] += $stats['updated_tlds']; + $currentProgress['failed'] += $stats['failed_tlds']; + + $this->importLogModel->completeImport($logId, $stats, 'completed', null, $currentProgress); + + return [ + 'status' => 'complete', + 'log_id' => $logId, + 'total' => $currentProgress['total'], + 'processed' => $currentProgress['processed'], + 'failed' => $currentProgress['failed'], + 'remaining' => 0, + 'message' => 'RDAP import completed' + ]; + } catch (\Exception $e) { + $this->importLogModel->completeImport($logId, [ + 'total_tlds' => 0, + 'new_tlds' => 0, + 'updated_tlds' => 0, + 'failed_tlds' => 1 + ], 'failed', $e->getMessage()); + + return [ + 'status' => 'error', + 'message' => 'RDAP import failed: ' . $e->getMessage() + ]; + } + } + + /** + * Process WHOIS batch + */ + private function processWhoisBatch(int $logId): array + { + $batchStartTime = microtime(true); + $this->logger->startOperation("WHOIS Batch Processing", ['log_id' => $logId]); + + // Get current progress from log + $log = $this->importLogModel->find($logId); + $currentProgress = $log['details'] ? json_decode($log['details'], true) : ['processed' => 0, 'failed' => 0, 'total' => 0]; + + $this->logger->info("Current progress retrieved", $currentProgress); + + // If this is the first batch, get total count + if ($currentProgress['total'] == 0) { + $currentProgress['total'] = $this->getTldsNeedingWhoisCount(); + $this->logger->info("First batch - Total TLDs to process: {$currentProgress['total']}"); + } + + // Get the last processed TLD ID to continue from where we left off + $lastProcessedId = $this->getLastProcessedTldId($logId); + $this->logger->info("Resuming from last processed ID: {$lastProcessedId}"); + + // Get next batch of TLDs (increased to 50 for faster processing) + $tldsNeedingWhois = $this->getTldsNeedingWhoisData(50, $lastProcessedId); + $this->logger->info("Retrieved batch of " . count($tldsNeedingWhois) . " TLDs for processing"); + + if (empty($tldsNeedingWhois)) { + $this->logger->info("No more TLDs to process - Import complete!"); + $this->importLogModel->completeImport($logId, [ + 'total_tlds' => $currentProgress['total'], + 'new_tlds' => 0, + 'updated_tlds' => $currentProgress['processed'], + 'failed_tlds' => $currentProgress['failed'] + ], 'completed', null, $currentProgress); + + $this->logger->endOperation("WHOIS Batch Processing", [ + 'status' => 'complete', + 'total_processed' => $currentProgress['processed'], + 'total_failed' => $currentProgress['failed'] + ]); + + return [ + 'status' => 'complete', + 'log_id' => $logId, + 'total' => $currentProgress['total'], + 'processed' => $currentProgress['processed'], + 'failed' => $currentProgress['failed'], + 'remaining' => 0, + 'message' => 'All TLDs processed successfully (ID 1 to last ID)' + ]; + } + + $batchStats = [ + 'total_tlds' => 0, + 'new_tlds' => 0, + 'updated_tlds' => 0, + 'failed_tlds' => 0 + ]; + + $lastProcessedIdInBatch = 0; + + foreach ($tldsNeedingWhois as $index => $tld) { + $tldStartTime = microtime(true); + $batchStats['total_tlds']++; + $tldNumber = $index + 1; + $totalInBatch = count($tldsNeedingWhois); + + $this->logger->info("Processing TLD [{$tldNumber}/{$totalInBatch}]: {$tld['tld']} (ID: {$tld['id']})"); + + try { + $result = $this->fetchWhoisDataFromRdap($tld['tld']); + $fetchTime = round((microtime(true) - $tldStartTime) * 1000, 2); + + if ($result) { + $updateStartTime = microtime(true); + $this->tldModel->update($tld['id'], $result); + $updateTime = round((microtime(true) - $updateStartTime) * 1000, 2); + + $batchStats['updated_tlds']++; + $currentProgress['processed']++; + + // Log what data we found (or didn't find) + $foundData = []; + if (isset($result['whois_server'])) $foundData[] = 'WHOIS server'; + if (isset($result['registry_url'])) $foundData[] = 'registry URL'; + if (isset($result['registration_date'])) $foundData[] = 'registration date'; + if (isset($result['record_last_updated'])) $foundData[] = 'last updated date'; + + if (empty($foundData)) { + $this->logger->warning("TLD {$tld['tld']}: No data available from IANA", [ + 'fetch_time_ms' => $fetchTime, + 'update_time_ms' => $updateTime + ]); + } else { + $this->logger->info("TLD {$tld['tld']}: SUCCESS - Found " . implode(', ', $foundData), [ + 'fetch_time_ms' => $fetchTime, + 'update_time_ms' => $updateTime, + 'data_fields' => count($foundData) + ]); + } + } else { + // Even if no data found, update the record to mark it as processed + $this->tldModel->update($tld['id'], ['updated_at' => date('Y-m-d H:i:s')]); + $batchStats['updated_tlds']++; + $currentProgress['processed']++; + $this->logger->warning("TLD {$tld['tld']}: No data returned, marked as processed", [ + 'fetch_time_ms' => $fetchTime + ]); + } + + // Track the highest ID processed in this batch + $lastProcessedIdInBatch = max($lastProcessedIdInBatch, $tld['id']); + + } catch (\Exception $e) { + $batchStats['failed_tlds']++; + $currentProgress['failed']++; + $this->logger->error("TLD {$tld['tld']}: FAILED - " . $e->getMessage(), [ + 'exception_type' => get_class($e), + 'file' => $e->getFile(), + 'line' => $e->getLine() + ]); + + // Still track the ID even if it failed + $lastProcessedIdInBatch = max($lastProcessedIdInBatch, $tld['id']); + } + + // Add minimal delay between requests (reduced for speed) + if ($index < count($tldsNeedingWhois) - 1) { + usleep(25000); // 0.025 second delay for even faster processing + } + } + + $batchTime = round(microtime(true) - $batchStartTime, 2); + + // Update the last processed ID in currentProgress (CRITICAL - must be saved!) + if ($lastProcessedIdInBatch > 0) { + $currentProgress['last_processed_id'] = $lastProcessedIdInBatch; + + $this->logger->info("Updated last processed ID", [ + 'previous_id' => $lastProcessedId, + 'new_id' => $lastProcessedIdInBatch, + 'jump' => $lastProcessedIdInBatch - $lastProcessedId + ]); + } + + $remainingCount = $this->getTldsNeedingWhoisCount($logId); + + $this->logger->info("Batch statistics", [ + 'batch_time_seconds' => $batchTime, + 'tlds_in_batch' => count($tldsNeedingWhois), + 'successful' => $batchStats['updated_tlds'], + 'failed' => $batchStats['failed_tlds'], + 'avg_time_per_tld' => count($tldsNeedingWhois) > 0 ? round($batchTime / count($tldsNeedingWhois), 2) : 0, + 'remaining' => $remainingCount + ]); + + if ($remainingCount === 0) { + $this->logger->info("Import complete - No more TLDs remaining"); + $this->importLogModel->completeImport($logId, [ + 'total_tlds' => $currentProgress['total'], + 'new_tlds' => 0, + 'updated_tlds' => $currentProgress['processed'], + 'failed_tlds' => $currentProgress['failed'] + ], 'completed', null, $currentProgress); + $status = 'complete'; + $message = 'All TLDs processed successfully'; + + $this->logger->endOperation("WHOIS Batch Processing", [ + 'status' => 'complete', + 'total_processed' => $currentProgress['processed'], + 'total_failed' => $currentProgress['failed'], + 'total_time_seconds' => $batchTime + ]); + } else { + $this->logger->info("Batch complete, more TLDs remaining", [ + 'processed_in_batch' => count($tldsNeedingWhois), + 'remaining' => $remainingCount, + 'completion_percentage' => round((($currentProgress['total'] - $remainingCount) / $currentProgress['total']) * 100, 2) + ]); + + $this->importLogModel->update($logId, [ + 'total_tlds' => $currentProgress['total'], + 'updated_tlds' => $currentProgress['processed'], + 'failed_tlds' => $currentProgress['failed'] + ], null, null, $currentProgress); + $status = 'in_progress'; + $message = "Processed batch of " . count($tldsNeedingWhois) . " TLDs, {$remainingCount} remaining"; + } + + return [ + 'status' => $status, + 'log_id' => $logId, + 'total' => $currentProgress['total'], + 'processed' => $currentProgress['processed'], + 'failed' => $currentProgress['failed'], + 'remaining' => $remainingCount, + 'message' => $message + ]; + } + + /** + * Process check updates batch + */ + private function processCheckUpdatesBatch(int $logId): array + { + try { + $updateInfo = $this->checkForUpdates(); + $this->importLogModel->completeImport($logId, [ + 'total_tlds' => 0, + 'new_tlds' => 0, + 'updated_tlds' => 0, + 'failed_tlds' => 0 + ]); + + $message = $updateInfo['overall_needs_update'] ? + 'Updates available' : 'TLD registry is up to date'; + + return [ + 'status' => 'complete', + 'log_id' => $logId, + 'total' => 2, + 'processed' => 2, + 'failed' => 0, + 'remaining' => 0, + 'message' => $message + ]; + } catch (\Exception $e) { + $this->importLogModel->completeImport($logId, [ + 'total_tlds' => 0, + 'new_tlds' => 0, + 'updated_tlds' => 0, + 'failed_tlds' => 1 + ], 'failed', $e->getMessage()); + + return [ + 'status' => 'error', + 'message' => 'Update check failed: ' . $e->getMessage() + ]; + } + } + + /** + * Process complete workflow batch (TLD list β†’ RDAP β†’ WHOIS β†’ Registry URLs) + */ + private function processCompleteWorkflowBatch(int $logId): array + { + $workflowStartTime = microtime(true); + $this->logger->startOperation("Complete Workflow Batch", ['log_id' => $logId]); + + // Get current progress from log + $log = $this->importLogModel->find($logId); + $currentProgress = $log['details'] ? json_decode($log['details'], true) : [ + 'current_step' => 0, + 'total_steps' => 3, + 'step_names' => ['Import TLD List', 'Import RDAP Servers', 'Import WHOIS & Registry Data'], + 'step_progress' => [0, 0, 0], + 'overall_processed' => 0, + 'overall_failed' => 0 + ]; + + $this->logger->info("Workflow progress", [ + 'current_step' => $currentProgress['current_step'], + 'total_steps' => $currentProgress['total_steps'], + 'step_name' => $currentProgress['step_names'][$currentProgress['current_step']] ?? 'Unknown' + ]); + + try { + // Step 1: Import TLD List + if ($currentProgress['current_step'] == 0) { + $stats = $this->importTldList(); + $currentProgress['step_progress'][0] = $stats['new_tlds'] + $stats['updated_tlds']; + $currentProgress['overall_processed'] += $currentProgress['step_progress'][0]; + $currentProgress['overall_failed'] += $stats['failed_tlds']; + $currentProgress['current_step'] = 1; + + $this->importLogModel->update($logId, [], 'running', null, $currentProgress); + + return [ + 'status' => 'in_progress', + 'log_id' => $logId, + 'total' => $currentProgress['total_steps'], + 'processed' => $currentProgress['current_step'], + 'failed' => $currentProgress['overall_failed'], + 'remaining' => $currentProgress['total_steps'] - $currentProgress['current_step'], + 'message' => "Step 1/3: {$currentProgress['step_names'][0]} completed. {$currentProgress['step_progress'][0]} TLDs processed." + ]; + } + + // Step 2: Import RDAP Servers + if ($currentProgress['current_step'] == 1) { + $stats = $this->importRdapData(); + $currentProgress['step_progress'][1] = $stats['updated_tlds']; + $currentProgress['overall_processed'] += $currentProgress['step_progress'][1]; + $currentProgress['overall_failed'] += $stats['failed_tlds']; + $currentProgress['current_step'] = 2; + + $this->importLogModel->update($logId, [], 'running', null, $currentProgress); + + return [ + 'status' => 'in_progress', + 'log_id' => $logId, + 'total' => $currentProgress['total_steps'], + 'processed' => $currentProgress['current_step'], + 'failed' => $currentProgress['overall_failed'], + 'remaining' => $currentProgress['total_steps'] - $currentProgress['current_step'], + 'message' => "Step 2/3: {$currentProgress['step_names'][1]} completed. {$currentProgress['step_progress'][1]} TLDs updated." + ]; + } + + // Step 3: Import WHOIS Data (in batches) + if ($currentProgress['current_step'] == 2) { + $this->logger->info("Step 3: Starting WHOIS batch processing"); + + // Get the last processed TLD ID to continue from where we left off + $lastProcessedId = $this->getLastProcessedTldId($logId); + $this->logger->info("Resuming from last processed ID: {$lastProcessedId}"); + + // Get next batch of TLDs needing WHOIS data (increased batch size) + $tldsNeedingWhois = $this->getTldsNeedingWhoisData(50, $lastProcessedId); + $this->logger->info("Retrieved " . count($tldsNeedingWhois) . " TLDs for WHOIS processing"); + + if (empty($tldsNeedingWhois)) { + // No more TLDs to process, complete the workflow + $this->logger->info("Workflow complete - No more TLDs to process"); + $this->importLogModel->completeImport($logId, [ + 'total_tlds' => $currentProgress['overall_processed'], + 'new_tlds' => $currentProgress['step_progress'][0], + 'updated_tlds' => $currentProgress['step_progress'][1] + $currentProgress['step_progress'][2], + 'failed_tlds' => $currentProgress['overall_failed'] + ], 'completed', null, $currentProgress); + + $this->logger->endOperation("Complete Workflow Batch", [ + 'status' => 'complete', + 'total_processed' => $currentProgress['overall_processed'], + 'total_failed' => $currentProgress['overall_failed'] + ]); + + return [ + 'status' => 'complete', + 'log_id' => $logId, + 'total' => $currentProgress['total_steps'], + 'processed' => $currentProgress['total_steps'], + 'failed' => $currentProgress['overall_failed'], + 'remaining' => 0, + 'message' => "Complete workflow finished! All TLDs processed (ID 1 to last ID)." + ]; + } + + // Process WHOIS batch + $batchProcessed = 0; + $batchFailed = 0; + $stepStartTime = microtime(true); + + $lastProcessedIdInBatch = 0; + + $this->logger->info("Starting to process batch of " . count($tldsNeedingWhois) . " TLDs"); + + foreach ($tldsNeedingWhois as $index => $tld) { + $tldStartTime = microtime(true); + $tldNumber = $index + 1; + $totalInBatch = count($tldsNeedingWhois); + + $this->logger->info("Processing TLD [{$tldNumber}/{$totalInBatch}]: {$tld['tld']} (ID: {$tld['id']})"); + + try { + $result = $this->fetchWhoisDataFromRdap($tld['tld']); + $fetchTime = round((microtime(true) - $tldStartTime) * 1000, 2); + + if ($result) { + $updateStartTime = microtime(true); + $this->tldModel->update($tld['id'], $result); + $updateTime = round((microtime(true) - $updateStartTime) * 1000, 2); + $batchProcessed++; + + // Log what data we found (or didn't find) + $foundData = []; + if (isset($result['whois_server'])) $foundData[] = 'WHOIS server'; + if (isset($result['registry_url'])) $foundData[] = 'registry URL'; + if (isset($result['registration_date'])) $foundData[] = 'registration date'; + if (isset($result['record_last_updated'])) $foundData[] = 'last updated date'; + + if (empty($foundData)) { + $this->logger->warning("TLD {$tld['tld']}: No data available", [ + 'fetch_time_ms' => $fetchTime, + 'update_time_ms' => $updateTime + ]); + } else { + $this->logger->info("TLD {$tld['tld']}: SUCCESS - " . implode(', ', $foundData), [ + 'fetch_time_ms' => $fetchTime, + 'update_time_ms' => $updateTime + ]); + } + } else { + // Even if no data found, update the record to mark it as processed + $this->tldModel->update($tld['id'], ['updated_at' => date('Y-m-d H:i:s')]); + $batchProcessed++; + $this->logger->warning("TLD {$tld['tld']}: No data returned, marked as processed", [ + 'fetch_time_ms' => $fetchTime + ]); + } + + // Track the highest ID processed in this batch + $lastProcessedIdInBatch = max($lastProcessedIdInBatch, $tld['id']); + + } catch (\Exception $e) { + $batchFailed++; + $this->logger->error("TLD {$tld['tld']}: FAILED - " . $e->getMessage(), [ + 'exception_type' => get_class($e), + 'file' => $e->getFile(), + 'line' => $e->getLine() + ]); + + // Still track the ID even if it failed + $lastProcessedIdInBatch = max($lastProcessedIdInBatch, $tld['id']); + } + + // Add minimal delay between requests (reduced for speed) + if ($index < count($tldsNeedingWhois) - 1) { + usleep(25000); // 0.025 second delay for even faster processing + } + } + + $stepTime = round(microtime(true) - $stepStartTime, 2); + + // Update progress counters + $currentProgress['step_progress'][2] += $batchProcessed; + $currentProgress['overall_processed'] += $batchProcessed; + $currentProgress['overall_failed'] += $batchFailed; + + // Update the last processed ID in currentProgress (CRITICAL - must be saved!) + if ($lastProcessedIdInBatch > 0) { + $currentProgress['last_processed_id'] = $lastProcessedIdInBatch; + + $this->logger->info("Updated last processed ID", [ + 'previous_id' => $lastProcessedId, + 'new_id' => $lastProcessedIdInBatch, + 'jump' => $lastProcessedIdInBatch - $lastProcessedId + ]); + } + + $this->logger->info("Step 3 batch statistics", [ + 'batch_time_seconds' => $stepTime, + 'processed' => $batchProcessed, + 'failed' => $batchFailed, + 'avg_time_per_tld' => $batchProcessed > 0 ? round($stepTime / $batchProcessed, 2) : 0 + ]); + + // Update the import log with all progress including last_processed_id + $this->importLogModel->update($logId, [], 'running', null, $currentProgress); + + $remainingWhois = $this->getTldsNeedingWhoisCount($logId); + $this->logger->info("Remaining TLDs to process: {$remainingWhois}"); + + if ($remainingWhois > 0) { + // Still TLDs to process - return in_progress status + $completionPercent = round((($currentProgress['step_progress'][2]) / ($currentProgress['step_progress'][2] + $remainingWhois)) * 100, 2); + $totalTldsInStep3 = $currentProgress['step_progress'][2] + $remainingWhois; + + $this->logger->info("Step 3 in progress", [ + 'completion_percentage' => $completionPercent, + 'processed_so_far' => $currentProgress['step_progress'][2], + 'remaining' => $remainingWhois, + 'batch_processed' => $batchProcessed + ]); + + return [ + 'status' => 'in_progress', + 'log_id' => $logId, + 'total' => $totalTldsInStep3, // Total TLDs to process in step 3 + 'processed' => $currentProgress['step_progress'][2], // TLDs processed so far in step 3 + 'failed' => $currentProgress['overall_failed'], + 'remaining' => $remainingWhois, // Fixed: Use actual remaining TLDs, not remaining steps + 'message' => "Step 3/3: {$currentProgress['step_names'][2]} - Processed {$currentProgress['step_progress'][2]} of {$totalTldsInStep3} TLDs ({$completionPercent}% complete, {$remainingWhois} remaining)" + ]; + } else { + // No more TLDs - complete the workflow! + $this->logger->info("Step 3 complete - All TLDs processed!"); + + $this->importLogModel->completeImport($logId, [ + 'total_tlds' => $currentProgress['overall_processed'], + 'new_tlds' => $currentProgress['step_progress'][0], + 'updated_tlds' => $currentProgress['step_progress'][1] + $currentProgress['step_progress'][2], + 'failed_tlds' => $currentProgress['overall_failed'] + ], 'completed', null, $currentProgress); + + $this->logger->endOperation("Complete Workflow Batch", [ + 'status' => 'complete', + 'total_processed' => $currentProgress['overall_processed'], + 'total_failed' => $currentProgress['overall_failed'] + ]); + + return [ + 'status' => 'complete', + 'log_id' => $logId, + 'total' => $currentProgress['step_progress'][2], + 'processed' => $currentProgress['step_progress'][2], + 'failed' => $currentProgress['overall_failed'], + 'remaining' => 0, + 'message' => "Complete workflow finished! All {$currentProgress['step_progress'][2]} TLDs processed successfully." + ]; + } + } + + } catch (\Exception $e) { + $this->logger->critical("Complete workflow failed", [ + 'error' => $e->getMessage(), + 'exception_type' => get_class($e), + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'trace' => $e->getTraceAsString() + ]); + + $this->importLogModel->completeImport($logId, [ + 'total_tlds' => 0, + 'new_tlds' => 0, + 'updated_tlds' => 0, + 'failed_tlds' => 1 + ], 'failed', $e->getMessage()); + + $this->logger->endOperation("Complete Workflow Batch", [ + 'status' => 'failed', + 'error' => $e->getMessage() + ]); + + return [ + 'status' => 'error', + 'message' => 'Complete workflow failed: ' . $e->getMessage() + ]; + } + } + + /** + * Import registry URLs for TLDs missing them + */ + private function importRegistryUrls(): array + { + $stats = [ + 'total_tlds' => 0, + 'new_tlds' => 0, + 'updated_tlds' => 0, + 'failed_tlds' => 0 + ]; + + // Get TLDs missing registry URLs + $sql = "SELECT * FROM tld_registry + WHERE is_active = 1 + AND (registry_url IS NULL OR registry_url = '') + LIMIT 50"; + + $tlds = $this->tldModel->query($sql); + + foreach ($tlds as $tld) { + $stats['total_tlds']++; + + try { + // Try to fetch registry URL from RDAP API + $registryUrl = $this->fetchRegistryUrlFromRdap($tld['tld']); + + if ($registryUrl) { + $this->tldModel->update($tld['id'], [ + 'registry_url' => $registryUrl, + 'updated_at' => date('Y-m-d H:i:s') + ]); + $stats['updated_tlds']++; + } else { + $stats['failed_tlds']++; + } + } catch (\Exception $e) { + $stats['failed_tlds']++; + error_log("Failed to fetch registry URL for TLD {$tld['tld']}: " . $e->getMessage()); + } + + // Add small delay + usleep(100000); // 0.1 second delay + } + + return $stats; + } + + /** + * Process a single TLD entry from the TLD list + */ + private function processTldEntry(string $tld): array + { + // Ensure TLD starts with dot + if (!str_starts_with($tld, '.')) { + $tld = '.' . $tld; + } + + $data = [ + 'tld' => $tld, + 'is_active' => 1, + 'created_at' => date('Y-m-d H:i:s'), + 'updated_at' => date('Y-m-d H:i:s') + ]; + + // Check if TLD already exists + $existing = $this->tldModel->getByTld($tld); + $isNew = !$existing; + + if ($existing) { + // Update existing record (just update the timestamp) + $this->tldModel->update($existing['id'], [ + 'updated_at' => date('Y-m-d H:i:s') + ]); + } else { + // Create new record + $this->tldModel->create($data); + } + + return ['is_new' => $isNew]; + } + + /** + * Process RDAP data for a single TLD + */ + private function processTldRdapData(string $tld, array $rdapServers, ?string $publicationDate): array + { + // Ensure TLD starts with dot + if (!str_starts_with($tld, '.')) { + $tld = '.' . $tld; + } + + $data = [ + 'tld' => $tld, + 'rdap_servers' => json_encode($rdapServers), + 'iana_publication_date' => $publicationDate, + 'iana_last_updated' => date('Y-m-d H:i:s'), + 'is_active' => 1 + ]; + + // Check if TLD already exists + $existing = $this->tldModel->getByTld($tld); + $isNew = !$existing; + + if ($existing) { + // Update existing record + $this->tldModel->update($existing['id'], $data); + } else { + // Create new record + $this->tldModel->create($data); + } + + return ['is_new' => $isNew]; + } + + /** + * Fetch WHOIS and registry data using hybrid approach: RDAP API first, HTML fallback + */ + private function fetchWhoisDataFromRdap(string $tld): ?array + { + $tldForUrl = ltrim($tld, '.'); + $rdapUrl = self::IANA_RDAP_DOMAIN_URL . $tldForUrl; + + try { + // Step 1: Try RDAP API first (fast, structured data) + $jsonClient = $this->getJsonClient(); + $response = $jsonClient->get($rdapUrl); + + if ($response->getStatusCode() !== 200) { + error_log("Failed to fetch RDAP data for TLD $tld: HTTP " . $response->getStatusCode()); + return null; + } + + $responseBody = $response->getBody()->getContents(); + + // Check if response is HTML instead of JSON (common when servers are down) + if (strpos($responseBody, ' date('Y-m-d H:i:s') + ]; + + // Extract WHOIS server from RDAP data (port43 field) + if (isset($data['port43']) && !empty($data['port43'])) { + $result['whois_server'] = $data['port43']; + } + + // Extract registry URL from links array + if (isset($data['links']) && is_array($data['links'])) { + foreach ($data['links'] as $link) { + if (isset($link['rel']) && $link['rel'] === 'related' && + isset($link['title']) && $link['title'] === 'Registration URL' && + isset($link['href'])) { + $result['registry_url'] = $link['href']; + break; + } + } + } + + // Extract dates from events array + if (isset($data['events']) && is_array($data['events'])) { + foreach ($data['events'] as $event) { + if (isset($event['eventAction']) && isset($event['eventDate'])) { + switch ($event['eventAction']) { + case 'registration': + $result['registration_date'] = $event['eventDate']; + break; + case 'last changed': + $result['record_last_updated'] = $event['eventDate']; + break; + } + } + } + } + + // Step 2: If WHOIS server is missing, try HTML fallback + if (!isset($result['whois_server']) || empty($result['whois_server'])) { + $htmlData = $this->fetchWhoisDataFromHtml($tld); + if ($htmlData) { + // Merge HTML data, prioritizing RDAP data but filling gaps with HTML data + if (isset($htmlData['whois_server']) && !empty($htmlData['whois_server'])) { + $result['whois_server'] = $htmlData['whois_server']; + } + if (!isset($result['registry_url']) && isset($htmlData['registry_url'])) { + $result['registry_url'] = $htmlData['registry_url']; + } + if (!isset($result['registration_date']) && isset($htmlData['registration_date'])) { + $result['registration_date'] = $htmlData['registration_date']; + } + if (!isset($result['record_last_updated']) && isset($htmlData['record_last_updated'])) { + $result['record_last_updated'] = $htmlData['record_last_updated']; + } + } + } + + // Always return the result, even if some fields are missing + // This ensures we update the TLD record with whatever data we found + return $result; + + } catch (\Exception $e) { + error_log("Failed to fetch RDAP data for TLD $tld: " . $e->getMessage()); + } + + return null; + } + + /** + * Fallback: Fetch WHOIS data from IANA HTML page + */ + private function fetchWhoisDataFromHtml(string $tld): ?array + { + $tldForUrl = ltrim($tld, '.'); + $url = self::IANA_TLD_BASE_URL . $tldForUrl . '.html'; + + try { + $htmlClient = $this->getHtmlClient(); + $response = $htmlClient->get($url); + + if ($response->getStatusCode() !== 200) { + error_log("HTML fetch failed for TLD $tld: HTTP " . $response->getStatusCode()); + return null; + } + + $html = $response->getBody()->getContents(); + + if (empty($html) || strlen($html) < 100) { + error_log("HTML content too short for TLD $tld: " . strlen($html) . " bytes"); + return null; + } + + $result = []; + + // Parse HTML to extract WHOIS server and other data + $whoisServer = $this->extractWhoisServer($html); + $lastUpdated = $this->extractLastUpdated($html); + $registryUrl = $this->extractRegistryUrl($html); + $registrationDate = $this->extractRegistrationDate($html); + + if ($whoisServer) { + $result['whois_server'] = $whoisServer; + } + if ($lastUpdated) { + $result['record_last_updated'] = $lastUpdated; + } + if ($registryUrl) { + $result['registry_url'] = $registryUrl; + } + if ($registrationDate) { + $result['registration_date'] = $registrationDate; + } + + return !empty($result) ? $result : null; + + } catch (\Exception $e) { + error_log("Failed to fetch HTML data for TLD $tld: " . $e->getMessage()); + } + + return null; + } + + /** + * Extract WHOIS server from IANA HTML + */ + private function extractWhoisServer(string $html): ?string + { + // Look for WHOIS Server pattern with HTML tags + if (preg_match('/WHOIS Server:<\/b>\s*([^\s<]+)/i', $html, $matches)) { + return trim($matches[1]); + } + + // Fallback: Look for WHOIS Server pattern without HTML tags + if (preg_match('/WHOIS Server:\s*([^\s<]+)/i', $html, $matches)) { + return trim($matches[1]); + } + + return null; + } + + /** + * Extract last updated date from IANA HTML + */ + private function extractLastUpdated(string $html): ?string + { + // Look for "Record last updated" pattern + if (preg_match('/Record last updated\s+(\d{4}-\d{2}-\d{2})/i', $html, $matches)) { + return $matches[1] . ' 00:00:00'; + } + return null; + } + + /** + * Extract registry URL from IANA HTML + */ + private function extractRegistryUrl(string $html): ?string + { + // Look for "URL for registration services" pattern with tag + if (preg_match('/URL for registration services:<\/b>\s*]*href="([^"]+)"[^>]*>/i', $html, $matches)) { + return trim($matches[1]); + } + + // Look for "URL for registration services" pattern with HTML tags + if (preg_match('/URL for registration services:<\/b>\s*([^\s<]+)/i', $html, $matches)) { + return trim($matches[1]); + } + + // Fallback: Look for "URL for registration services" pattern without HTML tags + if (preg_match('/URL for registration services:\s*([^\s<]+)/i', $html, $matches)) { + return trim($matches[1]); + } + + return null; + } + + /** + * Extract registration date from IANA HTML + */ + private function extractRegistrationDate(string $html): ?string + { + // Look for "Registration date" pattern + if (preg_match('/Registration date\s+(\d{4}-\d{2}-\d{2})/i', $html, $matches)) { + return $matches[1]; + } + return null; + } + + /** + * Check for IANA updates in both TLD list and RDAP data + */ + public function checkForUpdates(): array + { + $updates = [ + 'tld_list' => ['needs_update' => false, 'current_version' => null, 'last_version' => null], + 'rdap' => ['needs_update' => false, 'current_publication' => null, 'last_publication' => null], + 'overall_needs_update' => false, + 'errors' => [] + ]; + + try { + // Check TLD list for updates + $tldListUpdate = $this->checkTldListUpdates(); + $updates['tld_list'] = $tldListUpdate; + + // Check RDAP data for updates + $rdapUpdate = $this->checkRdapUpdates(); + $updates['rdap'] = $rdapUpdate; + + // Determine if any updates are needed + $updates['overall_needs_update'] = $tldListUpdate['needs_update'] || $rdapUpdate['needs_update']; + + } catch (\Exception $e) { + $updates['errors'][] = $e->getMessage(); + } + + return $updates; + } + + /** + * Check for TLD list updates + */ + private function checkTldListUpdates(): array + { + try { + $jsonClient = $this->getJsonClient(); + $response = $jsonClient->get(self::IANA_TLD_LIST_URL); + + if ($response->getStatusCode() !== 200) { + return [ + 'needs_update' => false, + 'current_version' => null, + 'last_version' => null, + 'error' => 'Failed to fetch TLD list: HTTP ' . $response->getStatusCode() + ]; + } + + $content = $response->getBody()->getContents(); + $lines = explode("\n", $content); + + $currentVersion = null; + $currentLastUpdated = null; + + // Extract version and timestamp from header + foreach ($lines as $line) { + $line = trim($line); + if (strpos($line, '# Version') === 0) { + if (preg_match('/# Version (\d+), Last Updated (.+)/', $line, $matches)) { + $currentVersion = $matches[1]; + $currentLastUpdatedRaw = $matches[2]; + + // Convert the timestamp to a proper format + try { + $currentLastUpdated = date('Y-m-d H:i:s', strtotime($currentLastUpdatedRaw)); + if ($currentLastUpdated === '1970-01-01 00:00:00') { + // If strtotime fails, keep the raw value + $currentLastUpdated = $currentLastUpdatedRaw; + } + } catch (\Exception $e) { + $currentLastUpdated = $currentLastUpdatedRaw; + } + } + break; + } + } + + // Get last TLD list import + $lastTldImport = $this->importLogModel->query( + "SELECT version, iana_publication_date FROM tld_import_logs + WHERE import_type = 'tld_list' AND status = 'completed' + ORDER BY started_at DESC LIMIT 1" + ); + + $lastVersion = $lastTldImport[0]['version'] ?? null; + $lastPublication = $lastTldImport[0]['iana_publication_date'] ?? null; + + $needsUpdate = ($currentVersion !== $lastVersion) || ($currentLastUpdated !== $lastPublication); + + return [ + 'needs_update' => $needsUpdate, + 'current_version' => $currentVersion, + 'current_last_updated' => $currentLastUpdated, + 'last_version' => $lastVersion, + 'last_publication' => $lastPublication + ]; + + } catch (\Exception $e) { + return [ + 'needs_update' => false, + 'current_version' => null, + 'last_version' => null, + 'error' => $e->getMessage() + ]; + } + } + + /** + * Check for RDAP data updates + */ + private function checkRdapUpdates(): array + { + try { + $jsonClient = $this->getJsonClient(); + $response = $jsonClient->get(self::IANA_RDAP_URL); + + if ($response->getStatusCode() !== 200) { + return [ + 'needs_update' => false, + 'current_publication' => null, + 'last_publication' => null, + 'error' => 'Failed to fetch RDAP data: HTTP ' . $response->getStatusCode() + ]; + } + + $data = json_decode($response->getBody()->getContents(), true); + $currentPublication = $data['publication'] ?? null; + + // Get last RDAP import + $lastRdapImport = $this->importLogModel->query( + "SELECT iana_publication_date FROM tld_import_logs + WHERE import_type = 'rdap' AND status = 'completed' + ORDER BY started_at DESC LIMIT 1" + ); + + $lastPublication = $lastRdapImport[0]['iana_publication_date'] ?? null; + + $needsUpdate = $currentPublication !== $lastPublication; + + return [ + 'needs_update' => $needsUpdate, + 'current_publication' => $currentPublication, + 'last_publication' => $lastPublication + ]; + + } catch (\Exception $e) { + return [ + 'needs_update' => false, + 'current_publication' => null, + 'last_publication' => null, + 'error' => $e->getMessage() + ]; + } + } + + /** + * Get TLD registry information for a domain + */ + public function getTldInfo(string $domain): ?array + { + // Extract TLD from domain + $tld = $this->extractTldFromDomain($domain); + if (!$tld) { + return null; + } + + return $this->tldModel->getByTld($tld); + } + + /** + * Extract TLD from domain name + */ + private function extractTldFromDomain(string $domain): ?string + { + $domain = strtolower(trim($domain)); + + // Remove protocol if present + $domain = preg_replace('/^https?:\/\//', '', $domain); + + // Remove www if present + $domain = preg_replace('/^www\./', '', $domain); + + // Remove path if present + $domain = explode('/', $domain)[0]; + + // Split by dots and get the last part (TLD) + $parts = explode('.', $domain); + if (count($parts) < 2) { + return null; + } + + // For domains like example.co.uk, we want .co.uk + if (count($parts) > 2) { + // Check if it's a known multi-part TLD + $lastTwo = '.' . $parts[count($parts) - 2] . '.' . $parts[count($parts) - 1]; + $lastOne = '.' . $parts[count($parts) - 1]; + + // Try to find the TLD in our registry + $tldInfo = $this->tldModel->getByTld($lastTwo); + if ($tldInfo) { + return $lastTwo; + } + + return $lastOne; + } + + return '.' . $parts[count($parts) - 1]; + } + + /** + * Get RDAP servers for a TLD + */ + public function getRdapServers(string $tld): array + { + $tldInfo = $this->tldModel->getByTld($tld); + if (!$tldInfo || empty($tldInfo['rdap_servers'])) { + return []; + } + + $servers = json_decode($tldInfo['rdap_servers'], true); + return is_array($servers) ? $servers : []; + } + + /** + * Get WHOIS server for a TLD + */ + public function getWhoisServer(string $tld): ?string + { + $tldInfo = $this->tldModel->getByTld($tld); + return $tldInfo['whois_server'] ?? null; + } + + /** + * Import WHOIS data for specific TLDs that are known to be missing from RDAP + */ + public function importWhoisForSpecificTlds(array $tlds): array + { + $logId = $this->importLogModel->startImport('whois'); + $stats = [ + 'total_tlds' => 0, + 'new_tlds' => 0, + 'updated_tlds' => 0, + 'failed_tlds' => 0 + ]; + + try { + foreach ($tlds as $index => $tld) { + $stats['total_tlds']++; + + try { + // Ensure TLD starts with dot + if (!str_starts_with($tld, '.')) { + $tld = '.' . $tld; + } + + // Check if TLD exists in our registry + $existing = $this->tldModel->getByTld($tld); + + if (!$existing) { + // Create new TLD entry first + $this->tldModel->create([ + 'tld' => $tld, + 'is_active' => 1, + 'created_at' => date('Y-m-d H:i:s'), + 'updated_at' => date('Y-m-d H:i:s') + ]); + $existing = $this->tldModel->getByTld($tld); + $stats['new_tlds']++; + } + + // Fetch WHOIS data + $result = $this->fetchWhoisDataFromIana($tld); + + if ($result && $existing) { + $this->tldModel->update($existing['id'], $result); + $stats['updated_tlds']++; + } else { + $stats['failed_tlds']++; + } + } catch (\Exception $e) { + $stats['failed_tlds']++; + error_log("Failed to fetch WHOIS data for TLD $tld: " . $e->getMessage()); + } + + // Add delay between requests to be respectful to IANA servers + if ($index < count($tlds) - 1) { + usleep(500000); // 0.5 second delay + } + } + + $this->importLogModel->completeImport($logId, $stats); + + } catch (\Exception $e) { + $this->importLogModel->completeImport($logId, $stats, $e->getMessage()); + throw $e; + } + + return $stats; + } +} diff --git a/app/Services/WhoisService.php b/app/Services/WhoisService.php new file mode 100644 index 0000000..63b7675 --- /dev/null +++ b/app/Services/WhoisService.php @@ -0,0 +1,729 @@ +tldModel = new TldRegistry(); + } + + /** + * Get domain information via WHOIS or RDAP + */ + public function getDomainInfo(string $domain): ?array + { + try { + // Get TLD + $parts = explode('.', $domain); + if (count($parts) < 2) { + return null; + } + + // Handle double TLDs like co.uk + $tld = $parts[count($parts) - 1]; + $doubleTld = null; + if (count($parts) >= 3) { + $doubleTld = $parts[count($parts) - 2] . '.' . $tld; + } + + // Try double TLD first (e.g., co.uk), then single TLD + $servers = null; + if ($doubleTld) { + $servers = $this->discoverTldServers($doubleTld); + // If double TLD lookup failed, try single TLD + if (!$servers['rdap_url'] && !$servers['whois_server']) { + $servers = $this->discoverTldServers($tld); + } + } else { + $servers = $this->discoverTldServers($tld); + } + + $rdapUrl = $servers['rdap_url']; + $whoisServer = $servers['whois_server']; + + // Try RDAP first (modern, structured JSON protocol) + if ($rdapUrl) { + $rdapData = $this->queryRDAPGeneric($domain, $rdapUrl); + if ($rdapData) { + return $rdapData; + } + // If RDAP failed, fall through to WHOIS + } + + // Fallback to WHOIS if RDAP not available or failed + if (!$whoisServer) { + $whoisServer = 'whois.iana.org'; + } + + // Get WHOIS data + $whoisData = $this->queryWhois($domain, $whoisServer); + + if (!$whoisData) { + return null; + } + + // Check if we got a referral to another WHOIS server + $referralServer = $this->extractReferralServer($whoisData); + if ($referralServer && $referralServer !== $whoisServer) { + // Query the referred server + $whoisData = $this->queryWhois($domain, $referralServer); + if (!$whoisData) { + return null; + } + } + + // Parse the response + $info = $this->parseWhoisData($domain, $whoisData, $referralServer ?? $whoisServer); + + return $info; + + } catch (Exception $e) { + error_log("WHOIS lookup failed for $domain: " . $e->getMessage()); + return null; + } + } + + /** + * Discover RDAP and WHOIS servers for a TLD using TLD registry data + * Returns array with 'rdap_url' and 'whois_server' keys + */ + private function discoverTldServers(string $tld): array + { + // Check cache first + if (isset(self::$tldCache[$tld])) { + return self::$tldCache[$tld]; + } + + $result = [ + 'rdap_url' => null, + 'whois_server' => null + ]; + + try { + // First, try to get TLD info from our registry database + $tldInfo = $this->tldModel->getByTld($tld); + + if ($tldInfo) { + // Use WHOIS server from registry + if (!empty($tldInfo['whois_server'])) { + $result['whois_server'] = $tldInfo['whois_server']; + } + + // Use RDAP servers from registry + if (!empty($tldInfo['rdap_servers'])) { + $rdapServers = json_decode($tldInfo['rdap_servers'], true); + if (is_array($rdapServers) && !empty($rdapServers)) { + $result['rdap_url'] = rtrim($rdapServers[0], '/') . '/'; + } + } + + // Cache the result + self::$tldCache[$tld] = $result; + return $result; + } + + // Fallback: Query IANA directly if not in our registry + // This maintains backward compatibility and handles new TLDs + $response = $this->queryWhois($tld, 'whois.iana.org'); + + if (!$response) { + self::$tldCache[$tld] = $result; + return $result; + } + + // Parse IANA response for WHOIS server + $lines = explode("\n", $response); + foreach ($lines as $line) { + $line = trim($line); + + // Look for WHOIS server + if (preg_match('/^whois:\s+(.+)$/i', $line, $matches)) { + $result['whois_server'] = trim($matches[1]); + } + } + + // Special handling for .pro TLD - it doesn't have a WHOIS server in IANA + if ($tld === 'pro' && !$result['whois_server']) { + $result['whois_server'] = 'whois.afilias.net'; + } + + // Try to get RDAP URL from IANA's RDAP bootstrap service + $rdapBootstrapUrl = "https://data.iana.org/rdap/dns.json"; + $bootstrapData = @file_get_contents($rdapBootstrapUrl, false, stream_context_create([ + 'http' => [ + 'timeout' => 5, + 'user_agent' => 'Domain Monitor/1.0' + ], + 'ssl' => [ + 'verify_peer' => true, + 'verify_peer_name' => true + ] + ])); + + if ($bootstrapData) { + $bootstrap = json_decode($bootstrapData, true); + if ($bootstrap && isset($bootstrap['services'])) { + // The services array contains [["tld1", "tld2"], ["url1", "url2"]] + foreach ($bootstrap['services'] as $service) { + if (isset($service[0]) && isset($service[1])) { + $tlds = $service[0]; + $urls = $service[1]; + + // Check if our TLD is in this service's TLD list + if (in_array($tld, $tlds) || in_array('.' . $tld, $tlds)) { + if (!empty($urls[0])) { + $result['rdap_url'] = rtrim($urls[0], '/') . '/'; + break; + } + } + } + } + } + } + + // Fallback: try fetching the HTML page from IANA + if (!$result['rdap_url']) { + $htmlUrl = "https://www.iana.org/domains/root/db/{$tld}.html"; + $html = @file_get_contents($htmlUrl, false, stream_context_create([ + 'http' => [ + 'timeout' => 5, + 'user_agent' => 'Domain Monitor/1.0' + ], + 'ssl' => [ + 'verify_peer' => true, + 'verify_peer_name' => true + ] + ])); + + if ($html) { + // Extract RDAP Server from HTML + // Pattern: RDAP Server: https://rdap.example.com/ + if (preg_match('/RDAP Server:<\/b>\s*]*>(https?:\/\/[^<]+)<\/a>/i', $html, $matches)) { + $result['rdap_url'] = rtrim(trim($matches[1]), '/') . '/'; + } elseif (preg_match('/RDAP Server:<\/b>\s+(https?:\/\/\S+)/i', $html, $matches)) { + $result['rdap_url'] = rtrim(trim($matches[1]), '/') . '/'; + } + } + } + + // DO NOT guess RDAP URLs - they must be from official sources + // Guessing often creates invalid URLs that don't resolve in DNS + + // Cache the result + self::$tldCache[$tld] = $result; + + return $result; + } catch (Exception $e) { + self::$tldCache[$tld] = $result; + return $result; + } + } + + + /** + * Extract referral WHOIS server from response + */ + private function extractReferralServer(string $whoisData): ?string + { + $lines = explode("\n", $whoisData); + + foreach ($lines as $line) { + $line = trim($line); + + // Check for various referral patterns + if (preg_match('/^Registrar WHOIS Server:\s*(.+)$/i', $line, $matches)) { + return trim($matches[1]); + } + if (preg_match('/^ReferralServer:\s*whois:\/\/(.+)$/i', $line, $matches)) { + return trim($matches[1]); + } + if (preg_match('/^refer:\s*(.+)$/i', $line, $matches)) { + return trim($matches[1]); + } + if (preg_match('/^whois server:\s*(.+)$/i', $line, $matches)) { + $server = trim($matches[1]); + // Skip if it's just 'whois.iana.org' (we already queried that) + if ($server !== 'whois.iana.org') { + return $server; + } + } + } + + return null; + } + + /** + * Query generic RDAP server for any domain + */ + private function queryRDAPGeneric(string $domain, string $rdapBaseUrl): ?array + { + // Ensure URL ends with / + if (substr($rdapBaseUrl, -1) !== '/') { + $rdapBaseUrl .= '/'; + } + + // Construct full RDAP URL + // RDAP standard format: {base_url}domain/{domain_name} + // If the base URL doesn't already end with "domain/", add it + if (!preg_match('/domain\/$/', $rdapBaseUrl)) { + $rdapUrl = $rdapBaseUrl . 'domain/' . strtolower($domain); + } else { + $rdapUrl = $rdapBaseUrl . strtolower($domain); + } + + // Use cURL to get RDAP data + $ch = curl_init(); + curl_setopt($ch, CURLOPT_URL, $rdapUrl); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_TIMEOUT, 10); + curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true); + curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true); + curl_setopt($ch, CURLOPT_HTTPHEADER, [ + 'Accept: application/rdap+json' + ]); + + $response = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + // Handle 404 responses as domain not found + if ($httpCode === 404 && $response) { + $data = json_decode($response, true); + if ($data && isset($data['errorCode']) && $data['errorCode'] == 404) { + // Return domain not found response + $rdapHost = parse_url($rdapBaseUrl, PHP_URL_HOST); + return [ + 'domain' => $domain, + 'registrar' => 'Not Registered', + 'registrar_url' => null, + 'expiration_date' => null, + 'updated_date' => null, + 'creation_date' => null, + 'abuse_email' => null, + 'nameservers' => [], + 'status' => ['AVAILABLE'], + 'owner' => 'Unknown', + 'whois_server' => $rdapHost . ' (RDAP)', + 'raw_data' => [ + 'states' => ['AVAILABLE'], + 'nameServers' => [], + ] + ]; + } + } + + if ($httpCode !== 200 || !$response) { + return null; + } + + $data = json_decode($response, true); + if (!$data) { + return null; + } + + // Extract the RDAP host for display + $rdapHost = parse_url($rdapBaseUrl, PHP_URL_HOST); + + return $this->parseRDAPData($domain, $data, $rdapHost); + } + + + /** + * Parse RDAP JSON data into our standard format + */ + private function parseRDAPData(string $domain, array $rdapData, string $rdapHost = 'RDAP'): array + { + $info = [ + 'domain' => $domain, + 'registrar' => null, + 'registrar_url' => null, + 'expiration_date' => null, + 'updated_date' => null, + 'creation_date' => null, + 'abuse_email' => null, + 'nameservers' => [], + 'status' => [], + 'owner' => 'Unknown', + 'whois_server' => $rdapHost . ' (RDAP)', + 'raw_data' => [] + ]; + + // Parse events (dates) + if (isset($rdapData['events']) && is_array($rdapData['events'])) { + foreach ($rdapData['events'] as $event) { + $action = $event['eventAction'] ?? ''; + $date = $event['eventDate'] ?? ''; + + if (!empty($date)) { + $parsedDate = date('Y-m-d', strtotime($date)); + + if ($action === 'registration') { + $info['creation_date'] = $parsedDate; + } elseif ($action === 'expiration') { + $info['expiration_date'] = $parsedDate; + } elseif ($action === 'last changed') { + $info['updated_date'] = $parsedDate; + } + } + } + } + + // Parse status + if (isset($rdapData['status']) && is_array($rdapData['status'])) { + $info['status'] = $rdapData['status']; + } + + // Parse entities (registrar, abuse contact) + if (isset($rdapData['entities']) && is_array($rdapData['entities'])) { + foreach ($rdapData['entities'] as $entity) { + $roles = $entity['roles'] ?? []; + + // Registrar + if (in_array('registrar', $roles)) { + // Get registrar name from vCard + if (isset($entity['vcardArray'][1])) { + foreach ($entity['vcardArray'][1] as $vcardField) { + if ($vcardField[0] === 'fn') { + $info['registrar'] = $vcardField[3]; + } elseif ($vcardField[0] === 'url') { + $info['registrar_url'] = $vcardField[3]; + } + } + } + + // Check for abuse contact in nested entities + if (isset($entity['entities']) && is_array($entity['entities'])) { + foreach ($entity['entities'] as $subEntity) { + if (in_array('abuse', $subEntity['roles'] ?? [])) { + if (isset($subEntity['vcardArray'][1])) { + foreach ($subEntity['vcardArray'][1] as $vcardField) { + if ($vcardField[0] === 'email') { + $info['abuse_email'] = $vcardField[3]; + } + } + } + } + } + } + } + } + } + + // Parse nameservers + if (isset($rdapData['nameservers']) && is_array($rdapData['nameservers'])) { + foreach ($rdapData['nameservers'] as $ns) { + $nsName = $ns['ldhName'] ?? ''; + if (!empty($nsName)) { + // Remove trailing dot if present + $nsName = rtrim($nsName, '.'); + $info['nameservers'][] = strtolower($nsName); + } + } + } + + // Set default registrar if not found + if ($info['registrar'] === null) { + $info['registrar'] = 'Unknown'; + } + + $info['raw_data'] = [ + 'states' => $info['status'], + 'nameServers' => $info['nameservers'], + ]; + + return $info; + } + + /** + * Query WHOIS server + */ + private function queryWhois(string $domain, string $server, int $port = 43): ?string + { + $timeout = 10; + + // Try to connect to WHOIS server + $fp = @fsockopen($server, $port, $errno, $errstr, $timeout); + + if (!$fp) { + error_log("WHOIS connection failed to $server: $errstr ($errno)"); + return null; + } + + // Send query + fputs($fp, $domain . "\r\n"); + + // Get response + $response = ''; + while (!feof($fp)) { + $response .= fgets($fp, 128); + } + + fclose($fp); + + return $response; + } + + /** + * Parse WHOIS data + */ + private function parseWhoisData(string $domain, string $whoisData, string $whoisServer = 'Unknown'): array + { + $lines = explode("\n", $whoisData); + $data = [ + 'domain' => $domain, + 'registrar' => null, + 'registrar_url' => null, + 'expiration_date' => null, + 'updated_date' => null, + 'creation_date' => null, + 'abuse_email' => null, + 'nameservers' => [], + 'status' => [], + 'owner' => 'Unknown', + 'whois_server' => $whoisServer, + 'raw_data' => [] + ]; + + // Check if domain is not found/available + $whoisDataLower = strtolower($whoisData); + if (preg_match('/not found|no match|no entries found|no data found|domain not found|no such domain|not registered|available for registration/i', $whoisDataLower)) { + $data['status'][] = 'AVAILABLE'; + $data['registrar'] = 'Not Registered'; + return $data; + } + + $registrarFound = false; + $currentSection = null; + + foreach ($lines as $index => $line) { + $line = trim($line); + + // Skip empty lines and comments + if (empty($line) || $line[0] === '%' || $line[0] === '#') { + continue; + } + + // Check for section headers (UK format - lines ending with colon, no value) + if (preg_match('/^([^:]+):\s*$/', $line, $matches)) { + $currentSection = strtolower(trim($matches[1])); + + // For UK domains: Registrar section - next line has the actual registrar + if ($currentSection === 'registrar' && !$registrarFound && isset($lines[$index + 1])) { + $nextLine = trim($lines[$index + 1]); + if (!empty($nextLine)) { + // Extract registrar name (remove [Tag = XXX] part) + $registrarName = preg_replace('/\s*\[Tag\s*=\s*[^\]]+\]/i', '', $nextLine); + $registrarName = trim($registrarName); + if (!empty($registrarName)) { + $data['registrar'] = $registrarName; + $registrarFound = true; + } + } + } + continue; + } + + // For multi-line sections (UK format), check if we're in a specific section + if ($currentSection === 'name servers') { + // Extract nameserver (format: "ns1.example.com 192.168.1.1") + if (!preg_match('/^(This|--|\d+\.)/', $line)) { + $ns = preg_split('/\s+/', $line)[0]; // Get first part (nameserver) + if (!empty($ns) && strpos($ns, '.') !== false && !in_array(strtolower($ns), $data['nameservers'])) { + $data['nameservers'][] = strtolower($ns); + } + } + } + + // Parse key-value pairs + if (strpos($line, ':') !== false) { + list($key, $value) = explode(':', $line, 2); + $key = trim(strtolower($key)); + $value = trim($value); + + // For UK format - check for URL in registrar section + if ($key === 'url' && $currentSection === 'registrar' && !empty($value)) { + $data['registrar_url'] = $value; + } + + // Expiration date + if (preg_match('/(expir|expiry|expire|paid-till|renewal)/i', $key) && !empty($value)) { + $data['expiration_date'] = $this->parseDate($value); + } + + // Updated date (UK format: "Last updated") + if (preg_match('/(updated date|last updated)/i', $key) && !empty($value)) { + $data['updated_date'] = $this->parseDate($value); + } + + // Creation date (UK format: "Registered on") + if (preg_match('/(creat|registered|registered on)/i', $key) && !empty($value)) { + $data['creation_date'] = $this->parseDate($value); + } + + // Registrar (only take the first valid one found) - for standard format + if (!$registrarFound && preg_match('/^registrar(?!.*url|.*whois|.*iana|.*phone|.*email|.*fax|.*abuse|.*id|.*contact)/i', $key) && !empty($value)) { + // Skip if it looks like a phone number, email, or ID + if (!preg_match('/^[\+\d\.\s\(\)-]+$/', $value) && + !preg_match('/@/', $value) && + !preg_match('/^\d+$/', $value) && + strlen($value) > 3) { + $data['registrar'] = $value; + $registrarFound = true; + } + } + + // Nameservers (standard format) + if (preg_match('/(name server|nserver|nameserver)/i', $key) && !empty($value)) { + $ns = preg_replace('/\s+.*$/', '', $value); // Remove IP addresses + if (!empty($ns) && !in_array($ns, $data['nameservers'])) { + $data['nameservers'][] = strtolower($ns); + } + } + + // Status (UK format: "Registration status") + if (preg_match('/(status|state|registration status)/i', $key) && !empty($value)) { + if (!in_array($value, $data['status'])) { + $data['status'][] = $value; + } + } + + // Registrar URL (standard format) + if (preg_match('/^registrar url/i', $key) && !empty($value)) { + $data['registrar_url'] = $value; + } + + // WHOIS Server + if (preg_match('/registrar whois server/i', $key) && !empty($value)) { + $data['whois_server'] = $value; + } + + // Abuse Email + if (preg_match('/abuse.*email/i', $key) && !empty($value)) { + $data['abuse_email'] = $value; + } + + // Owner/Registrant + if (preg_match('/(registrant|owner)/i', $key) && !preg_match('/(email|phone|fax)/i', $key) && !empty($value)) { + if ($data['owner'] === 'Unknown') { + $data['owner'] = $value; + } + } + } + } + + // If no registrar found, set default + if ($data['registrar'] === null) { + $data['registrar'] = 'Unknown'; + } + + $data['raw_data'] = [ + 'states' => $data['status'], + 'nameServers' => $data['nameservers'], + ]; + + return $data; + } + + /** + * Parse date from various formats + */ + private function parseDate(?string $dateString): ?string + { + if (empty($dateString)) { + return null; + } + + // Remove common prefixes/suffixes + $dateString = preg_replace('/^(before|after):/i', '', $dateString); + $dateString = trim($dateString); + + // Try to parse the date + $timestamp = strtotime($dateString); + + if ($timestamp === false) { + return null; + } + + return date('Y-m-d', $timestamp); + } + + /** + * Calculate days until domain expiration + */ + public function daysUntilExpiration(?string $expirationDate): ?int + { + if (!$expirationDate) { + return null; + } + + $expiration = strtotime($expirationDate); + $now = time(); + $diff = $expiration - $now; + + return (int)floor($diff / 86400); // 86400 seconds in a day + } + + /** + * Get domain status based on expiration and WHOIS status + */ + public function getDomainStatus(?string $expirationDate, array $statusArray = []): string + { + // Check if domain is available (not registered) + foreach ($statusArray as $status) { + if (stripos($status, 'AVAILABLE') !== false || stripos($status, 'FREE') !== false) { + return 'available'; + } + } + + $days = $this->daysUntilExpiration($expirationDate); + + if ($days === null) { + return 'error'; + } + + if ($days < 0) { + return 'expired'; + } + + if ($days <= 30) { + return 'expiring_soon'; + } + + return 'active'; + } + + /** + * Test domain status detection with a specific domain + * This method is useful for debugging and testing + */ + public function testDomainStatus(string $domain): array + { + $info = $this->getDomainInfo($domain); + + if (!$info) { + return [ + 'domain' => $domain, + 'status' => 'error', + 'message' => 'Failed to retrieve domain information' + ]; + } + + $status = $this->getDomainStatus($info['expiration_date'], $info['status']); + + return [ + 'domain' => $domain, + 'status' => $status, + 'info' => $info, + 'message' => 'Domain status determined successfully' + ]; + } +} diff --git a/app/Views/auth/login.php b/app/Views/auth/login.php new file mode 100644 index 0000000..1c251ed --- /dev/null +++ b/app/Views/auth/login.php @@ -0,0 +1,156 @@ + + + + + + Login - Domain Monitor + + + + + + + + + + + + + + + + + diff --git a/app/Views/dashboard/index.php b/app/Views/dashboard/index.php new file mode 100644 index 0000000..e8932a4 --- /dev/null +++ b/app/Views/dashboard/index.php @@ -0,0 +1,232 @@ + + + +
+ +
+
+
+

Total Domains

+

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

Active

+

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

Expiring Soon

+

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

Inactive

+

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

+ + Recent Domains +

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

+
+ + + + + + Not set + + + + + + + + +
+
+
+
+ + + + + + + +
+
+ +
+ + +
+ +

No domains added yet

+ + + Add Your First Domain + +
+ +
+
+ + +
+ + + + +
+
+

+ + System Status +

+
+
+
+ Database + + + Online + +
+
+ WHOIS Service + + + Active + +
+
+ Notifications + + + Enabled + +
+
+
+ + + +
+
+

+ + Expiring This Month +

+
+
+ +
+
+

+

+
+ + + +
+ +
+
+ +
+
+ + + diff --git a/app/Views/debug/whois.php b/app/Views/debug/whois.php new file mode 100644 index 0000000..6783692 --- /dev/null +++ b/app/Views/debug/whois.php @@ -0,0 +1,318 @@ + + + + +
+
+
+
+ + +

+ Enter a domain name without http:// or www. +

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

What is this tool?

+

+ This debug tool shows you the raw WHOIS data for any domain and how our system parses it. + Use it to troubleshoot issues with domain information extraction. +

+
+
+
+
+ + + +
+ + + Check Another Domain + + +
+ + +
+
+
+

Domain

+

+
+
+

WHOIS Server

+

+
+
+

TLD

+

+
+
+
+ + +
+ +
+
+

+ + Extracted Data (What We Save) +

+
+
+
+
+ Domain + +
+
+ Registrar + +
+
+ Expiration Date + +
+
+ Creation Date + +
+
+ Updated Date + +
+
+ Registrar URL + +
+
+ Abuse Email + +
+
+ Nameservers +
+ + +
+ + + N/A + +
+
+
+
+
+ + +
+
+

+ + All Key-Value Pairs +

+
+
+ + + + + + + + + + + + + + + + + +
KeyValue
+
+
+
+ + +
+
+

+ + Raw WHOIS Response +

+
+
+
+
+
+ + + + + + + + + + diff --git a/app/Views/domains/bulk-add.php b/app/Views/domains/bulk-add.php new file mode 100644 index 0000000..74fd8bc --- /dev/null +++ b/app/Views/domains/bulk-add.php @@ -0,0 +1,128 @@ + + + +
+
+
+

+ + Bulk Add Domains +

+
+ +
+
+ +
+ + +

+ Enter one domain per line. Domains without http:// or www. +

+
+ + +
+ + +

+ Assign all domains to this notification group +

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

How It Works

+

+ Paste multiple domain names, one per line. The system will fetch WHOIS information + for each domain automatically. This may take a few moments depending on how many domains you're adding. +

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

Important Notes

+
    +
  • + + Duplicate domains will be skipped +
  • +
  • + + Invalid domains will be reported +
  • +
  • + + Large batches may take several minutes +
  • +
+
+
+
+
+
+ + + diff --git a/app/Views/domains/create.php b/app/Views/domains/create.php new file mode 100644 index 0000000..2b30d66 --- /dev/null +++ b/app/Views/domains/create.php @@ -0,0 +1,130 @@ + + + +
+
+
+

+ + Domain Information +

+
+ +
+
+ +
+ + +

+ Enter the domain name without http:// or https:// +

+
+ + +
+ + +

+ Optional: Assign to a notification group to receive expiry alerts +

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

How It Works

+

+ When you add a domain, we automatically fetch its WHOIS information including + expiration date, registrar, nameservers, and other important details. This may take a few seconds. +

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

What We Track

+
    +
  • + + Domain expiration date +
  • +
  • + + Registrar information +
  • +
  • + + Nameservers +
  • +
  • + + Domain status +
  • +
+
+
+
+
+
+ + diff --git a/app/Views/domains/edit.php b/app/Views/domains/edit.php new file mode 100644 index 0000000..902799a --- /dev/null +++ b/app/Views/domains/edit.php @@ -0,0 +1,120 @@ + + + +
+
+
+

+ + Domain Settings +

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

+ Domain name cannot be changed after creation +

+
+ + +
+ + +

+ Change the notification group or remove it to stop receiving alerts +

+
+ + +
+ +
+ + +
+ + + + Cancel + +
+
+
+
+ + +
+ + + View Details + +
+ +
+
+ +
+
+
+ + diff --git a/app/Views/domains/index.php b/app/Views/domains/index.php new file mode 100644 index 0000000..68b5a0d --- /dev/null +++ b/app/Views/domains/index.php @@ -0,0 +1,688 @@ +'; + } + $icon = $currentOrder === 'asc' ? 'fa-sort-up' : 'fa-sort-down'; + return ''; +} + +// Get current filters +$currentFilters = $filters ?? ['search' => '', 'status' => '', 'group' => '', 'sort' => 'domain_name', 'order' => 'asc']; +?> + + +
+
+ + +
+ +
+ +
+ + + + +
+ + + + Bulk Add + + + + Add Domain + +
+
+ + +
+
+
+
+ +
+ + +
+
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ Showing to + of + domain(s) +
+ +
+ + + + + + + + + +
+
+ + +
+ + + + + +
+ +
+
+ + +
+ +
+ +
+ +
+
+ +
+

No Domains Yet

+

Start monitoring your domains by adding your first one

+ + + Add Your First Domain + +
+ +
+ + + 1): ?> +
+ +
+ Page of + +
+ + +
+ + + + 1): ?> + + + + + + + 1): ?> + + Previous + + + + + 1) { + echo '1'; + if ($start > 2) { + echo '...'; + } + } + + // Page numbers + for ($i = $start; $i <= $end; $i++) { + if ($i == $currentPage) { + echo '' . $i . ''; + } else { + echo '' . $i . ''; + } + } + + // Show last page + ellipsis if needed + if ($end < $totalPages) { + if ($end < $totalPages - 1) { + echo '...'; + } + echo '' . $totalPages . ''; + } + ?> + + + + + Next + + + + + + + + + +
+
+ + + + + diff --git a/app/Views/domains/view.php b/app/Views/domains/view.php new file mode 100644 index 0000000..6967e52 --- /dev/null +++ b/app/Views/domains/view.php @@ -0,0 +1,461 @@ + + + +
+
+ = 0)) { + $statusClass = 'bg-orange-100 text-orange-700 border-orange-200'; + $statusText = 'Expiring Soon'; + $statusIcon = 'fa-exclamation-triangle'; + } elseif ($domainStatus === 'active') { + $statusClass = 'bg-green-100 text-green-700 border-green-200'; + $statusText = 'Active'; + $statusIcon = 'fa-check-circle'; + } elseif ($domainStatus === 'error') { + $statusClass = 'bg-gray-100 text-gray-700 border-gray-200'; + $statusText = 'Error'; + $statusIcon = 'fa-exclamation-circle'; + } else { + $statusClass = 'bg-gray-100 text-gray-700 border-gray-200'; + $statusText = ucfirst($domainStatus); + $statusIcon = 'fa-question-circle'; + } + ?> + + + + + + + + + + + + + + +
+
+
+ +
+ + + Edit + +
+ +
+ + + Back + +
+
+ + +
+ + +
+ + +
+
+

+ + Registration Details +

+
+
+
+
+ +

+
+ +
+ + + + Visit + +
+ + +
+ + + + +
+ + +
+ +

+
+ + +
+ +

+
+ +
+
+
+ + +
+
+

+ + Important Dates +

+
+
+
+ +
+
+
+ +
+
+

Expiration

+

+
+
+ + days + +
+ + + +
+
+ +
+
+

Last Updated

+

+
+
+ + + +
+
+ +
+
+

Created

+

+
+
+ + +
+
+ +
+
+

Last Checked

+

+
+
+
+
+
+ + + +
+
+

+ + Nameservers () +

+
+
+
+ $ns): ?> +
+
+ +
+

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

+ + Domain Status () +

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

+ + Notification Group +

+
+
+
+
+ +
+
+

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

+ / channels active +

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

No Group Assigned

+

Won't receive notifications

+
+
+ + + Assign Group + +
+ + + +
+
+

+ + Notification History () +

+
+
+ +
+ +

No notifications sent yet

+
+ +
+ + + + + + + + + + + + + + + + + + + +
ChannelStatusDateMessage
+ + + + + + + + + + +
+
+ +
+
+ + + +
+ + +
+ + +
+ +
+ + + + diff --git a/app/Views/errors/404.php b/app/Views/errors/404.php new file mode 100644 index 0000000..6484719 --- /dev/null +++ b/app/Views/errors/404.php @@ -0,0 +1,85 @@ + + + + + + 404 - Page Not Found + + + + + + + + + + +
+
+ +
+ +
+ + +

404

+

Page Not Found

+

+ Oops! The page you're looking for doesn't exist. It might have been moved or deleted. +

+ + +
+ + + Go to Dashboard + + +
+ + + +
+ + +
+

+ + Domain Monitor Β© +

+
+
+ + diff --git a/app/Views/groups/create.php b/app/Views/groups/create.php new file mode 100644 index 0000000..10045d4 --- /dev/null +++ b/app/Views/groups/create.php @@ -0,0 +1,102 @@ + + + +
+
+
+

+ + Group Information +

+
+ +
+
+ +
+ + +

+ Choose a descriptive name for this notification group +

+
+ + +
+ + +

+ Optional: Add notes to help identify this group's purpose +

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

Next Steps

+
    +
  • + + After creating the group, you'll be able to add notification channels (Email, Telegram, Discord, Slack) +
  • +
  • + + Configure each channel with the necessary credentials and settings +
  • +
  • + + Assign domains to this group to start receiving notifications +
  • +
+
+
+
+
+ + diff --git a/app/Views/groups/edit.php b/app/Views/groups/edit.php new file mode 100644 index 0000000..aed22a8 --- /dev/null +++ b/app/Views/groups/edit.php @@ -0,0 +1,304 @@ + + +
+ +
+
+

+ + Group Details +

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

+ + Notification Channels +

+
+ +
+ +
+ +

No channels configured yet

+

Add your first channel below to start receiving notifications

+
+ +
+ 'fa-envelope', 'telegram' => 'fa-telegram', 'discord' => 'fa-discord', 'slack' => 'fa-slack']; + $colors = ['email' => 'blue', 'telegram' => 'blue', 'discord' => 'indigo', 'slack' => 'purple']; + $icon = $icons[$channel['channel_type']] ?? 'fa-bell'; + $color = $colors[$channel['channel_type']] ?? 'gray'; + ?> +
+
+
+ +
+ + + +
+

+

+ +

+ +
+ +
+ + + +
+

+ + Add New Channel +

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

+ + Assigned Domains () +

+
+ +
+ +
+ +

No domains assigned to this group yet

+ + + Add a Domain + +
+ + + +
+
+
+ + + + diff --git a/app/Views/groups/index.php b/app/Views/groups/index.php new file mode 100644 index 0000000..f5c8b7d --- /dev/null +++ b/app/Views/groups/index.php @@ -0,0 +1,156 @@ + + + + + + +
+
+
+ +
+
+

About Notification Groups

+

+ Notification groups allow you to organize your notification channels. You can create multiple channels + (Email, Telegram, Discord, Slack) within each group, then assign domains to the group. When a domain + is about to expire, all active channels in its group will receive notifications. +

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

+

+
+
+
+ +
+ + + channels + + + + domains + +
+ + +
+ +
+ +
+
+ +
+

No Notification Groups

+

Create your first notification group to start receiving alerts

+ + + Create Your First Group + +
+ +
+ + diff --git a/app/Views/layout/base.php b/app/Views/layout/base.php new file mode 100644 index 0000000..95e5113 --- /dev/null +++ b/app/Views/layout/base.php @@ -0,0 +1,310 @@ +query("SELECT COUNT(*) as count FROM domains"); + $totalResult = $totalStmt->fetch(\PDO::FETCH_ASSOC); + $total = $totalResult['count'] ?? 0; + + // Get active domains + $activeStmt = $pdo->query("SELECT COUNT(*) as count FROM domains WHERE is_active = 1"); + $activeResult = $activeStmt->fetch(\PDO::FETCH_ASSOC); + $active = $activeResult['count'] ?? 0; + + // Get expiring soon (within 30 days) + $expiringSoonStmt = $pdo->query("SELECT COUNT(*) as count FROM domains WHERE expiration_date IS NOT NULL AND expiration_date <= DATE_ADD(NOW(), INTERVAL 30 DAY) AND expiration_date >= NOW()"); + $expiringSoonResult = $expiringSoonStmt->fetch(\PDO::FETCH_ASSOC); + $expiringSoon = $expiringSoonResult['count'] ?? 0; + + $globalStats = [ + 'total' => $total, + 'active' => $active, + 'expiring_soon' => $expiringSoon + ]; + } catch (\Exception $e) { + $globalStats = [ + 'total' => 0, + 'active' => 0, + 'expiring_soon' => 0 + ]; + } +} +?> + + + + + + + + + + + + <?= $title ?? 'Domain Monitor' ?> - <?= $_ENV['APP_NAME'] ?? 'Domain Monitor' ?> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + + + + + +
+
+ + + + + + + + + + + + diff --git a/app/Views/layout/messages.php b/app/Views/layout/messages.php new file mode 100644 index 0000000..2cb4349 --- /dev/null +++ b/app/Views/layout/messages.php @@ -0,0 +1,119 @@ + +
+ + + +
+
+
+ +
+
+
+

Success

+

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

Error

+

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

Warning

+

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

Info

+

+
+ +
+ + + +
+ + + + + diff --git a/app/Views/layout/sidebar.php b/app/Views/layout/sidebar.php new file mode 100644 index 0000000..38a40e1 --- /dev/null +++ b/app/Views/layout/sidebar.php @@ -0,0 +1,102 @@ + + + diff --git a/app/Views/layout/top-nav.php b/app/Views/layout/top-nav.php new file mode 100644 index 0000000..9bf1fe9 --- /dev/null +++ b/app/Views/layout/top-nav.php @@ -0,0 +1,133 @@ + + + diff --git a/app/Views/search/results.php b/app/Views/search/results.php new file mode 100644 index 0000000..5339285 --- /dev/null +++ b/app/Views/search/results.php @@ -0,0 +1,304 @@ + + + +
+
+
+

Searching for:

+

+
+
+ + +
+
+
+ + + 0): ?> +
+
+ Showing to + of + result(s) +
+ +
+ + + + +
+
+ + + + +
+
+

+ + Found Matching Domain(s) in Your Portfolio +

+
+
+ + + + + + + + + + + + + + + + + + + + + + +
DomainRegistrarExpirationStatusActions
+ + + + + + + +
+
+
+ days +
+
+ + Not set + +
+ + + + + + + View Details β†’ + +
+
+
+ + + 1): ?> +
+ +
+ Page of + +
+ + +
+ + + + 1): ?> + + + + + + + 1): ?> + + Previous + + + + + 1) { + echo '1'; + if ($start > 2) { + echo '...'; + } + } + + // Page numbers + for ($i = $start; $i <= $end; $i++) { + if ($i == $currentPage) { + echo '' . $i . ''; + } else { + echo '' . $i . ''; + } + } + + // Show last page + ellipsis if needed + if ($end < $totalPages) { + if ($end < $totalPages - 1) { + echo '...'; + } + echo '' . $totalPages . ''; + } + ?> + + + + + Next + + + + + + + + + +
+
+ + + + + + +
+
+

+ + WHOIS Lookup Results +

+

Domain not found in your portfolio - showing WHOIS information

+
+
+
+
+ +

+
+
+ +

+
+
+ +

+ +

+
+
+ +

+ +

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

Want to monitor this domain?

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

WHOIS Lookup Failed

+

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

No Results Found

+

+ No domains match your search. Try a different search term or enter a domain name to perform a WHOIS lookup. +

+
+
+
+ + + + diff --git a/app/Views/tld-registry/import-logs.php b/app/Views/tld-registry/import-logs.php new file mode 100644 index 0000000..d7df8bc --- /dev/null +++ b/app/Views/tld-registry/import-logs.php @@ -0,0 +1,562 @@ + + + +
+
+

Import Logs

+

History of TLD registry import operations

+
+ +
+ + +
+ +
+
+
+

Total Imports

+

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

Successful

+

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

Failed

+

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

Last Import

+

+ + + + Never + +

+
+
+ +
+
+
+
+ + +
+ + + + + +
+ +
+
+
+ 'fa-list', + 'rdap' => 'fa-database', + 'whois' => 'fa-server', + 'complete_workflow' => 'fa-tasks', + 'check_updates' => 'fa-sync-alt', + 'manual' => 'fa-hand-pointer' + ]; + $typeLabels = [ + 'tld_list' => 'TLD List', + 'rdap' => 'RDAP Servers', + 'whois' => 'WHOIS Data', + 'complete_workflow' => 'Complete Workflow', + 'check_updates' => 'Update Check', + 'manual' => 'Manual Import' + ]; + + $icon = $typeIcons[$import['import_type']] ?? 'fa-file-import'; + $label = $typeLabels[$import['import_type']] ?? ucfirst($import['import_type']); + ?> +
+ +
+
+

+

+
+
+ + + + + +
+ +
+
+ Total TLDs: + +
+
+ New: + +
+
+ Updated: + +
+ 0): ?> +
+ Failed: + +
+ +
+ +
+ +
+
+ +
+ + + 1): ?> +
+
+ 1): ?> + + Previous + + + + + + Next + + +
+ + +
+ + +
+
+ +
+

No Import Logs

+

No TLD imports have been performed yet.

+ + + Back to Registry + +
+ +
+ + + + + + + \ No newline at end of file diff --git a/app/Views/tld-registry/import-progress.php b/app/Views/tld-registry/import-progress.php new file mode 100644 index 0000000..a7ce6be --- /dev/null +++ b/app/Views/tld-registry/import-progress.php @@ -0,0 +1,302 @@ + + +
+ +
+
+
+

+

+ 'Importing complete TLD list from IANA', + 'rdap' => 'Importing RDAP servers for existing TLDs', + 'whois' => 'Importing WHOIS & Registry data via RDAP API (with HTML fallback)', + 'check_updates' => 'Checking for IANA updates', + 'complete_workflow' => 'Complete TLD import workflow (TLD List β†’ RDAP β†’ WHOIS & Registry Data)' + ]; + echo $descriptions[$import_type] ?? 'Processing import'; + ?> +

+
+ + + Back to TLD Registry + +
+
+ + +
+
+

Import Status

+
+ + Starting... +
+
+ + +
+
+ 0 of 0 items processed + 0% +
+
+
+
+
+ + + + + +
+
+
+
+ +
+
+

Total

+

0

+
+
+
+ +
+
+
+ +
+
+

Processed

+

0

+
+
+
+ +
+
+
+ +
+
+

Failed

+

0

+
+
+
+ +
+
+
+ +
+
+

Remaining

+

0

+
+
+
+
+
+ + +
+

Import Log

+
+
Initializing import process...
+
+
+
+ + + + diff --git a/app/Views/tld-registry/index.php b/app/Views/tld-registry/index.php new file mode 100644 index 0000000..7a44dc2 --- /dev/null +++ b/app/Views/tld-registry/index.php @@ -0,0 +1,578 @@ +'; + } + $icon = $currentOrder === 'asc' ? 'fa-sort-up' : 'fa-sort-down'; + return ''; +} + +// Get current filters +$currentFilters = $filters ?? ['search' => '', 'sort' => 'tld', 'order' => 'asc']; +?> + + +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+
+

Total TLDs

+

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

Active

+

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

With RDAP

+

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

With WHOIS

+

+
+
+ +
+
+
+
+ + + +
+
+
+ +
+ +
+ + +
+
+ + +
+ + +
+ + +
+ + +
+ + +
+ + + + Clear + +
+
+ + +
+
+ + +
+
+ Showing to + of + TLD(s) +
+ +
+ + + + + + + +
+
+ + + +
+
+
+ Bulk Actions: +
+ +
+
+
+ 0 selected +
+
+
+ + + +
+ + + + + +
+ +
+
+
+
+ +
+ +
+ + + +
+ +
+ + +
+ +
+
+ 1): ?> +
+ more RDAP server(s)
+ +
+
+ + + + +
+ + +
+ + +
+ + +
+
+ + +
+ +
+ +
+
+ +
+

No TLDs Found

+

+ + No TLDs match your search criteria. + + Start by importing the TLD list from IANA. + +

+ +
+ + +
+ +
+ +
+ + + 1): ?> +
+ +
+ Page of + +
+ + +
+ + + + 1): ?> + + + + + + + 1): ?> + + Previous + + + + + 1) { + echo '1'; + if ($start > 2) { + echo '...'; + } + } + + // Page numbers + for ($i = $start; $i <= $end; $i++) { + if ($i == $currentPage) { + echo '' . $i . ''; + } else { + echo '' . $i . ''; + } + } + + // Show last page + ellipsis if needed + if ($end < $totalPages) { + if ($end < $totalPages - 1) { + echo '...'; + } + echo '' . $totalPages . ''; + } + ?> + + + + + Next + + + + + + + + + +
+
+ + + + + \ No newline at end of file diff --git a/app/Views/tld-registry/view.php b/app/Views/tld-registry/view.php new file mode 100644 index 0000000..e5aeb09 --- /dev/null +++ b/app/Views/tld-registry/view.php @@ -0,0 +1,258 @@ + + + +
+
+ + + TLD Registry + + + + + +
+ +
+ + +
+ + +
+ + +
+
+

+ + TLD Information +

+
+
+
+
+ +

+
+ +
+ + + + Visit Registry + +
+ + +
+ +

+
+ + +
+ +

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

+ + RDAP Servers () +

+
+
+
+ $server): ?> +
+
+ +
+

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

+ + WHOIS Server +

+
+
+
+
+ +
+

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

+ + Import History +

+
+
+
+
+
+ +
+
+

Created

+

+
+
+ + +
+
+ +
+
+

Last Updated

+

+
+
+ + + +
+
+ +
+
+

IANA Publication

+

+
+
+ +
+
+
+ + + + + +
+ + +
+ +
+ +
+ + + + \ No newline at end of file diff --git a/cache/.gitkeep b/cache/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..7c9296f --- /dev/null +++ b/composer.json @@ -0,0 +1,44 @@ +{ + "name": "hosteroid/domain-monitor", + "description": "A powerful, self-hosted domain expiration monitoring system with multi-channel notifications", + "type": "project", + "license": "MIT", + "keywords": ["domain", "monitoring", "whois", "rdap", "notifications", "expiration", "alerts", "telegram", "discord", "slack"], + "homepage": "https://github.com/Hosteroid/domain-monitor", + "authors": [ + { + "name": "Hosteroid", + "email": "support@hosteroid.uk", + "homepage": "https://hosteroid.uk" + } + ], + "support": { + "issues": "https://github.com/Hosteroid/domain-monitor/issues", + "source": "https://github.com/Hosteroid/domain-monitor", + "docs": "https://github.com/Hosteroid/domain-monitor/wiki" + }, + "require": { + "php": ">=8.1", + "ext-json": "*", + "ext-mbstring": "*", + "ext-pdo": "*", + "vlucas/phpdotenv": "^5.5", + "phpmailer/phpmailer": "^6.8", + "guzzlehttp/guzzle": "^7.8" + }, + "autoload": { + "psr-4": { + "App\\": "app/", + "Core\\": "core/" + } + }, + "require-dev": { + "symfony/var-dumper": "^6.3" + }, + "config": { + "optimize-autoloader": true, + "preferred-install": "dist", + "sort-packages": true + } +} + diff --git a/core/Application.php b/core/Application.php new file mode 100644 index 0000000..455be1a --- /dev/null +++ b/core/Application.php @@ -0,0 +1,32 @@ +resolve(); + } catch (\Exception $e) { + http_response_code(500); + if ($_ENV['APP_ENV'] === 'development') { + echo '

Error

'; + echo '
' . $e->getMessage() . '
'; + echo '
' . $e->getTraceAsString() . '
'; + } else { + echo '

500 - Internal Server Error

'; + } + } + } +} + diff --git a/core/Auth.php b/core/Auth.php new file mode 100644 index 0000000..0fab5dd --- /dev/null +++ b/core/Auth.php @@ -0,0 +1,59 @@ +connect(); + } + } + + private function connect() + { + $host = $_ENV['DB_HOST']; + $port = $_ENV['DB_PORT']; + $database = $_ENV['DB_DATABASE']; + $username = $_ENV['DB_USERNAME']; + $password = $_ENV['DB_PASSWORD']; + + try { + $dsn = "mysql:host=$host;port=$port;dbname=$database;charset=utf8mb4"; + self::$pdo = new PDO($dsn, $username, $password, [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::ATTR_EMULATE_PREPARES => false, + ]); + } catch (PDOException $e) { + die("Database connection failed: " . $e->getMessage()); + } + } + + public static function getConnection(): PDO + { + if (self::$pdo === null) { + new self(); + } + return self::$pdo; + } + + public function query(string $sql, array $params = []): \PDOStatement + { + $stmt = self::$pdo->prepare($sql); + $stmt->execute($params); + return $stmt; + } +} + diff --git a/core/Model.php b/core/Model.php new file mode 100644 index 0000000..376a169 --- /dev/null +++ b/core/Model.php @@ -0,0 +1,65 @@ +db = Database::getConnection(); + } + + public function all(): array + { + $stmt = $this->db->query("SELECT * FROM " . static::$table . " ORDER BY id DESC"); + return $stmt->fetchAll(); + } + + public function find(int $id): ?array + { + $stmt = $this->db->prepare("SELECT * FROM " . static::$table . " WHERE id = ?"); + $stmt->execute([$id]); + $result = $stmt->fetch(); + return $result ?: null; + } + + public function create(array $data): int + { + $columns = implode(', ', array_keys($data)); + $placeholders = implode(', ', array_fill(0, count($data), '?')); + + $sql = "INSERT INTO " . static::$table . " ($columns) VALUES ($placeholders)"; + $stmt = $this->db->prepare($sql); + $stmt->execute(array_values($data)); + + return (int)$this->db->lastInsertId(); + } + + public function update(int $id, array $data): bool + { + $set = implode(', ', array_map(fn($col) => "$col = ?", array_keys($data))); + $sql = "UPDATE " . static::$table . " SET $set WHERE id = ?"; + + $stmt = $this->db->prepare($sql); + return $stmt->execute([...array_values($data), $id]); + } + + public function delete(int $id): bool + { + $stmt = $this->db->prepare("DELETE FROM " . static::$table . " WHERE id = ?"); + return $stmt->execute([$id]); + } + + public function where(string $column, $value): array + { + $stmt = $this->db->prepare("SELECT * FROM " . static::$table . " WHERE $column = ?"); + $stmt->execute([$value]); + return $stmt->fetchAll(); + } +} + diff --git a/core/Router.php b/core/Router.php new file mode 100644 index 0000000..ade719e --- /dev/null +++ b/core/Router.php @@ -0,0 +1,76 @@ +routes['GET'][$path] = $callback; + } + + public function post(string $path, $callback) + { + $this->routes['POST'][$path] = $callback; + } + + public function resolve() + { + $path = $_SERVER['REQUEST_URI'] ?? '/'; + $method = $_SERVER['REQUEST_METHOD']; + + // Remove query string + $position = strpos($path, '?'); + if ($position !== false) { + $path = substr($path, 0, $position); + } + + // Try exact match first + $callback = $this->routes[$method][$path] ?? null; + $params = []; + + // If no exact match, try pattern matching for dynamic segments + if ($callback === null) { + foreach ($this->routes[$method] ?? [] as $route => $handler) { + // Convert route pattern to regex + $pattern = preg_replace('/\{([a-zA-Z_][a-zA-Z0-9_]*)\}/', '([^/]+)', $route); + $pattern = '#^' . $pattern . '$#'; + + if (preg_match($pattern, $path, $matches)) { + $callback = $handler; + + // Extract parameter names from route + preg_match_all('/\{([a-zA-Z_][a-zA-Z0-9_]*)\}/', $route, $paramNames); + + // Map parameter names to values + array_shift($matches); // Remove full match + foreach ($paramNames[1] as $index => $name) { + $params[$name] = $matches[$index] ?? null; + } + break; + } + } + } + + if ($callback === null) { + http_response_code(404); + require_once __DIR__ . '/../app/Views/errors/404.php'; + return; + } + + if (is_array($callback)) { + $controller = new $callback[0](); + $callback[0] = $controller; + } + + // Pass params to the callback + if (!empty($params)) { + call_user_func($callback, $params); + } else { + call_user_func($callback); + } + } +} + diff --git a/cron/check_domains.php b/cron/check_domains.php new file mode 100644 index 0000000..feb1150 --- /dev/null +++ b/cron/check_domains.php @@ -0,0 +1,192 @@ +#!/usr/bin/env php +load(); + +// Initialize database +new Database(); + +// Initialize services +$domainModel = new Domain(); +$channelModel = new NotificationChannel(); +$logModel = new NotificationLog(); +$whoisService = new WhoisService(); +$notificationService = new NotificationService(); + +// Log file +$logFile = __DIR__ . '/../logs/cron.log'; + +function logMessage(string $message) { + global $logFile; + $timestamp = date('Y-m-d H:i:s'); + file_put_contents($logFile, "[$timestamp] $message\n", FILE_APPEND); + echo "[$timestamp] $message\n"; +} + +logMessage("=== Starting domain check cron job ==="); + +// Get notification days from settings +$notificationDays = explode(',', $_ENV['NOTIFICATION_DAYS_BEFORE'] ?? '30,15,7,3,1'); +$notificationDays = array_map('intval', $notificationDays); + +logMessage("Notification thresholds (days): " . implode(', ', $notificationDays)); + +// Get all active domains +$domains = $domainModel->where('is_active', 1); +logMessage("Found " . count($domains) . " active domains to check"); + +$stats = [ + 'checked' => 0, + 'updated' => 0, + 'notifications_sent' => 0, + 'errors' => 0 +]; + +foreach ($domains as $domain) { + $domainName = $domain['domain_name']; + logMessage("Checking domain: $domainName"); + + try { + // Refresh WHOIS data + $whoisData = $whoisService->getDomainInfo($domainName); + + if (!$whoisData) { + logMessage(" βœ— Failed to get WHOIS data for $domainName"); + $stats['errors']++; + + // Update domain status to error + $domainModel->update($domain['id'], [ + 'status' => 'error', + 'last_checked' => date('Y-m-d H:i:s') + ]); + + continue; + } + + // Update domain information + $status = $whoisService->getDomainStatus($whoisData['expiration_date'], $whoisData['status'] ?? []); + $domainModel->update($domain['id'], [ + 'registrar' => $whoisData['registrar'], + 'expiration_date' => $whoisData['expiration_date'], + 'last_checked' => date('Y-m-d H:i:s'), + 'status' => $status, + 'whois_data' => json_encode($whoisData) + ]); + + $stats['checked']++; + $stats['updated']++; + + logMessage(" βœ“ Updated WHOIS data for $domainName"); + logMessage(" Expiration: {$whoisData['expiration_date']}, Status: $status"); + + // Check if notifications should be sent + $daysLeft = $whoisService->daysUntilExpiration($whoisData['expiration_date']); + + if ($daysLeft === null) { + continue; + } + + // Check if this domain should trigger a notification + $shouldNotify = false; + $notificationType = ''; + + if ($daysLeft <= 0) { + $shouldNotify = true; + $notificationType = 'expired'; + } elseif (in_array($daysLeft, $notificationDays)) { + $shouldNotify = true; + $notificationType = "expiring_in_{$daysLeft}_days"; + } + + if (!$shouldNotify) { + logMessage(" β†’ No notification needed ($daysLeft days left)"); + continue; + } + + // Check if notification was already sent recently (within last 23 hours) + if ($logModel->wasSentRecently($domain['id'], $notificationType, 23)) { + logMessage(" β†’ Notification already sent recently"); + continue; + } + + // Get notification channels for this domain's group + if (!$domain['notification_group_id']) { + logMessage(" β†’ No notification group assigned"); + continue; + } + + $channels = $channelModel->getActiveByGroupId($domain['notification_group_id']); + + if (empty($channels)) { + logMessage(" β†’ No active notification channels configured"); + continue; + } + + logMessage(" πŸ“€ Sending notifications to " . count($channels) . " channel(s)"); + + // Refresh domain data with group info + $domainData = $domainModel->find($domain['id']); + + // Send notifications + $results = $notificationService->sendDomainExpirationAlert($domainData, $channels); + + foreach ($results as $result) { + $success = $result['success']; + $channel = $result['channel']; + + if ($success) { + logMessage(" βœ“ Sent to $channel"); + $stats['notifications_sent']++; + } else { + logMessage(" βœ— Failed to send to $channel"); + } + + // Log the notification attempt + $logModel->log( + $domain['id'], + $notificationType, + $channel, + "Domain $domainName expires in $daysLeft days", + $success, + $success ? null : "Failed to send notification" + ); + } + + } catch (Exception $e) { + logMessage(" βœ— Error processing $domainName: " . $e->getMessage()); + $stats['errors']++; + } +} + +// Summary +logMessage("\n=== Cron job completed ==="); +logMessage("Domains checked: {$stats['checked']}"); +logMessage("Domains updated: {$stats['updated']}"); +logMessage("Notifications sent: {$stats['notifications_sent']}"); +logMessage("Errors: {$stats['errors']}"); +logMessage("==========================\n"); + +exit(0); + diff --git a/cron/import_tld_registry.php b/cron/import_tld_registry.php new file mode 100644 index 0000000..e7562c5 --- /dev/null +++ b/cron/import_tld_registry.php @@ -0,0 +1,206 @@ +#!/usr/bin/env php +load(); + +// Initialize database +new Database(); + +// Parse command line arguments +$options = getopt('', ['tld-list-only', 'rdap-only', 'whois-only', 'tlds:', 'check-updates', 'force', 'verbose', 'help']); + +if (isset($options['help'])) { + showHelp(); + exit(0); +} + +$verbose = isset($options['verbose']); +$force = isset($options['force']); + +// Initialize service +$tldService = new TldRegistryService(); + +// Log file +$logFile = __DIR__ . '/../logs/tld_import.log'; + +function logMessage(string $message, bool $verbose = false) { + global $logFile, $options; + + if ($verbose && !isset($options['verbose'])) { + return; + } + + $timestamp = date('Y-m-d H:i:s'); + $logLine = "[$timestamp] $message\n"; + + file_put_contents($logFile, $logLine, FILE_APPEND); + echo $logLine; +} + +logMessage("=== Starting TLD Registry Import ==="); + +try { + // Check for updates first + if (isset($options['check-updates'])) { + logMessage("Checking for IANA updates..."); + + $updateInfo = $tldService->checkForUpdates(); + + if ($updateInfo['needs_update']) { + logMessage("βœ“ IANA data has been updated!"); + logMessage(" Current publication: " . ($updateInfo['current_publication'] ?? 'Unknown')); + logMessage(" Last publication: " . ($updateInfo['last_publication'] ?? 'None')); + + if (!$force) { + logMessage("Use --force to import the updated data."); + exit(0); + } + } else { + logMessage("βœ“ TLD registry is up to date"); + if (isset($updateInfo['error'])) { + logMessage(" Error: " . $updateInfo['error']); + } + exit(0); + } + } + + $totalStats = [ + 'tld_list' => ['total_tlds' => 0, 'new_tlds' => 0, 'updated_tlds' => 0, 'failed_tlds' => 0], + 'rdap' => ['total_tlds' => 0, 'new_tlds' => 0, 'updated_tlds' => 0, 'failed_tlds' => 0], + 'whois' => ['total_tlds' => 0, 'new_tlds' => 0, 'updated_tlds' => 0, 'failed_tlds' => 0] + ]; + + // Import TLD list + if (!isset($options['rdap-only']) && !isset($options['whois-only'])) { + logMessage("Importing TLD list from IANA..."); + + $tldListStats = $tldService->importTldList(); + $totalStats['tld_list'] = $tldListStats; + + logMessage("TLD list import completed:"); + logMessage(" Total TLDs: {$tldListStats['total_tlds']}"); + logMessage(" New TLDs: {$tldListStats['new_tlds']}"); + logMessage(" Updated TLDs: {$tldListStats['updated_tlds']}"); + if ($tldListStats['failed_tlds'] > 0) { + logMessage(" Failed TLDs: {$tldListStats['failed_tlds']}"); + } + } + + // Import RDAP data + if (!isset($options['tld-list-only']) && !isset($options['whois-only'])) { + logMessage("Importing RDAP data from IANA..."); + + $rdapStats = $tldService->importRdapData(); + $totalStats['rdap'] = $rdapStats; + + logMessage("RDAP import completed:"); + logMessage(" Total TLDs: {$rdapStats['total_tlds']}"); + logMessage(" New TLDs: {$rdapStats['new_tlds']}"); + logMessage(" Updated TLDs: {$rdapStats['updated_tlds']}"); + if ($rdapStats['failed_tlds'] > 0) { + logMessage(" Failed TLDs: {$rdapStats['failed_tlds']}"); + } + } + + // Import WHOIS data for missing TLDs or specific TLDs + if (!isset($options['tld-list-only']) && !isset($options['rdap-only'])) { + if (isset($options['tlds'])) { + // Import specific TLDs + $tldList = array_map('trim', explode(',', $options['tlds'])); + logMessage("Importing WHOIS data for specific TLDs: " . implode(', ', $tldList)); + + $whoisStats = $tldService->importWhoisForSpecificTlds($tldList); + $totalStats['whois'] = $whoisStats; + + logMessage("WHOIS import completed:"); + logMessage(" Total TLDs: {$whoisStats['total_tlds']}"); + logMessage(" New TLDs: {$whoisStats['new_tlds']}"); + logMessage(" Updated TLDs: {$whoisStats['updated_tlds']}"); + if ($whoisStats['failed_tlds'] > 0) { + logMessage(" Failed TLDs: {$whoisStats['failed_tlds']}"); + } + } else { + // Import WHOIS data for missing TLDs + logMessage("Importing WHOIS data for missing TLDs..."); + + $whoisStats = $tldService->importWhoisDataForMissingTlds(); + $totalStats['whois'] = $whoisStats; + + logMessage("WHOIS import completed:"); + logMessage(" Total TLDs: {$whoisStats['total_tlds']}"); + logMessage(" Updated TLDs: {$whoisStats['updated_tlds']}"); + if ($whoisStats['failed_tlds'] > 0) { + logMessage(" Failed TLDs: {$whoisStats['failed_tlds']}"); + } + } + } + + // Summary + logMessage("\n=== Import Summary ==="); + logMessage("TLD List: {$totalStats['tld_list']['total_tlds']} total, {$totalStats['tld_list']['new_tlds']} new, {$totalStats['tld_list']['updated_tlds']} updated"); + logMessage("RDAP: {$totalStats['rdap']['total_tlds']} total, {$totalStats['rdap']['new_tlds']} new, {$totalStats['rdap']['updated_tlds']} updated"); + logMessage("WHOIS: {$totalStats['whois']['total_tlds']} total, {$totalStats['whois']['updated_tlds']} updated"); + + $totalNew = $totalStats['tld_list']['new_tlds'] + $totalStats['rdap']['new_tlds']; + $totalUpdated = $totalStats['tld_list']['updated_tlds'] + $totalStats['rdap']['updated_tlds'] + $totalStats['whois']['updated_tlds']; + $totalFailed = $totalStats['tld_list']['failed_tlds'] + $totalStats['rdap']['failed_tlds'] + $totalStats['whois']['failed_tlds']; + + logMessage("Overall: {$totalNew} new, {$totalUpdated} updated, {$totalFailed} failed"); + logMessage("==========================\n"); + +} catch (Exception $e) { + logMessage("βœ— Import failed: " . $e->getMessage()); + logMessage("Stack trace: " . $e->getTraceAsString()); + exit(1); +} + +logMessage("βœ“ TLD Registry import completed successfully"); +exit(0); + +function showHelp() { + echo "TLD Registry Import Script\n\n"; + echo "Usage: php cron/import_tld_registry.php [options]\n\n"; + echo "Options:\n"; + echo " --tld-list-only Import only TLD list from IANA\n"; + echo " --rdap-only Import only RDAP data from IANA\n"; + echo " --whois-only Import only WHOIS data for missing TLDs\n"; + echo " --tlds=LIST Import WHOIS data for specific TLDs (comma-separated)\n"; + echo " --check-updates Check for IANA updates without importing\n"; + echo " --force Force import even if no updates available\n"; + echo " --verbose Enable verbose output\n"; + echo " --help Show this help message\n\n"; + echo "Examples:\n"; + echo " php cron/import_tld_registry.php # Full import\n"; + echo " php cron/import_tld_registry.php --tld-list-only # TLD list only\n"; + echo " php cron/import_tld_registry.php --rdap-only # RDAP only\n"; + echo " php cron/import_tld_registry.php --tlds=ro,de,fr # Import specific TLDs\n"; + echo " php cron/import_tld_registry.php --check-updates # Check for updates\n"; + echo " php cron/import_tld_registry.php --force --verbose # Force import with verbose output\n\n"; +} diff --git a/database/migrate.php b/database/migrate.php new file mode 100644 index 0000000..d62a255 --- /dev/null +++ b/database/migrate.php @@ -0,0 +1,106 @@ +#!/usr/bin/env php +load(); + +try { + $host = $_ENV['DB_HOST']; + $port = $_ENV['DB_PORT']; + $database = $_ENV['DB_DATABASE']; + $username = $_ENV['DB_USERNAME']; + $password = $_ENV['DB_PASSWORD']; + + // Connect to database + $dsn = "mysql:host=$host;port=$port;dbname=$database;charset=utf8mb4"; + $pdo = new PDO($dsn, $username, $password, [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + ]); + + echo "Connected to database successfully!\n\n"; + + // Generate random admin password + $adminPassword = bin2hex(random_bytes(8)); // 16 character random password + $adminPasswordHash = password_hash($adminPassword, PASSWORD_BCRYPT); + + // Get all migration files + $migrationFiles = [ + __DIR__ . '/migrations/001_create_tables.sql', + __DIR__ . '/migrations/002_create_users_table.sql', + __DIR__ . '/migrations/003_add_whois_fields.sql', + __DIR__ . '/migrations/004_create_tld_registry_table.sql', + __DIR__ . '/migrations/005_update_tld_import_logs.sql', + __DIR__ . '/migrations/006_add_complete_workflow_import_type.sql', + ]; + + foreach ($migrationFiles as $migrationFile) { + if (!file_exists($migrationFile)) { + echo "⚠ Migration file not found: " . basename($migrationFile) . "\n"; + continue; + } + + echo "Running migration: " . basename($migrationFile) . "\n"; + + $sql = file_get_contents($migrationFile); + + // Replace password placeholder in users migration + if (basename($migrationFile) === '002_create_users_table.sql') { + $sql = str_replace('{{ADMIN_PASSWORD_HASH}}', $adminPasswordHash, $sql); + } + + // Split by semicolon and execute each statement + $statements = array_filter(array_map('trim', explode(';', $sql))); + + foreach ($statements as $statement) { + if (!empty($statement)) { + try { + $pdo->exec($statement); + } catch (PDOException $e) { + // Check if it's a "column already exists" error for migrations 003 and 005 + if (strpos($e->getMessage(), 'Duplicate column name') !== false && + (basename($migrationFile) === '003_add_whois_fields.sql' || + basename($migrationFile) === '005_update_tld_import_logs.sql')) { + echo " ⚠ Column already exists, skipping: " . $e->getMessage() . "\n"; + continue; + } + // Check if it's an enum modification error for migrations 005 and 006 + if (strpos($e->getMessage(), 'Duplicate entry') !== false && + (basename($migrationFile) === '005_update_tld_import_logs.sql' || + basename($migrationFile) === '006_add_complete_workflow_import_type.sql')) { + echo " ⚠ Enum already updated, skipping: " . $e->getMessage() . "\n"; + continue; + } + // Re-throw other errors + throw $e; + } + } + } + + echo "βœ“ " . basename($migrationFile) . " completed\n"; + } + + echo "\nβœ“ All migrations completed successfully!\n"; + echo "βœ“ All tables created.\n"; + echo "\nπŸ”‘ Admin credentials (SAVE THESE!):\n"; + echo " ═══════════════════════════════════════\n"; + echo " Username: admin\n"; + echo " Password: $adminPassword\n"; + echo " ═══════════════════════════════════════\n"; + echo " ⚠️ This password will not be shown again!\n"; + echo " πŸ’Ύ Save it to a secure password manager.\n\n"; + echo "🌐 TLD Registry System:\n"; + echo " β€’ Import RDAP data: php cron/import_tld_registry.php --rdap-only\n"; + echo " β€’ Import WHOIS data: php cron/import_tld_registry.php --whois-only\n"; + echo " β€’ Check for updates: php cron/import_tld_registry.php --check-updates\n"; + echo " β€’ Full import: php cron/import_tld_registry.php\n\n"; + +} catch (PDOException $e) { + echo "βœ— Migration failed: " . $e->getMessage() . "\n"; + exit(1); +} + diff --git a/database/migrations/001_create_tables.sql b/database/migrations/001_create_tables.sql new file mode 100644 index 0000000..b875880 --- /dev/null +++ b/database/migrations/001_create_tables.sql @@ -0,0 +1,72 @@ +-- Create notification_groups table +CREATE TABLE IF NOT EXISTS notification_groups ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(255) NOT NULL, + description TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Create notification_channels table +CREATE TABLE IF NOT EXISTS notification_channels ( + id INT AUTO_INCREMENT PRIMARY KEY, + notification_group_id INT NOT NULL, + channel_type ENUM('email', 'telegram', 'discord', 'slack') NOT NULL, + channel_config JSON NOT NULL, + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (notification_group_id) REFERENCES notification_groups(id) ON DELETE CASCADE, + INDEX idx_notification_group_id (notification_group_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Create domains table +CREATE TABLE IF NOT EXISTS domains ( + id INT AUTO_INCREMENT PRIMARY KEY, + domain_name VARCHAR(255) NOT NULL UNIQUE, + notification_group_id INT, + registrar VARCHAR(255), + expiration_date DATE, + last_checked TIMESTAMP NULL, + status ENUM('active', 'expiring_soon', 'expired', 'error') DEFAULT 'active', + whois_data JSON, + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (notification_group_id) REFERENCES notification_groups(id) ON DELETE SET NULL, + INDEX idx_notification_group_id (notification_group_id), + INDEX idx_expiration_date (expiration_date), + INDEX idx_status (status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Create notification_logs table +CREATE TABLE IF NOT EXISTS notification_logs ( + id INT AUTO_INCREMENT PRIMARY KEY, + domain_id INT NOT NULL, + notification_type VARCHAR(50) NOT NULL, + channel_type VARCHAR(50) NOT NULL, + message TEXT NOT NULL, + sent_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + status ENUM('sent', 'failed') DEFAULT 'sent', + error_message TEXT, + FOREIGN KEY (domain_id) REFERENCES domains(id) ON DELETE CASCADE, + INDEX idx_domain_id (domain_id), + INDEX idx_sent_at (sent_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Create settings table +CREATE TABLE IF NOT EXISTS settings ( + id INT AUTO_INCREMENT PRIMARY KEY, + setting_key VARCHAR(255) NOT NULL UNIQUE, + setting_value TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Insert default settings +INSERT INTO settings (setting_key, setting_value) VALUES +('notification_days_before', '30,15,7,3,1'), +('check_interval_hours', '24'), +('last_check_run', NULL) +ON DUPLICATE KEY UPDATE setting_key=setting_key; + diff --git a/database/migrations/002_create_users_table.sql b/database/migrations/002_create_users_table.sql new file mode 100644 index 0000000..764bf97 --- /dev/null +++ b/database/migrations/002_create_users_table.sql @@ -0,0 +1,22 @@ +-- Create users table +CREATE TABLE IF NOT EXISTS users ( + id INT AUTO_INCREMENT PRIMARY KEY, + username VARCHAR(100) NOT NULL UNIQUE, + password VARCHAR(255) NOT NULL, + email VARCHAR(255), + full_name VARCHAR(255), + is_active BOOLEAN DEFAULT TRUE, + last_login TIMESTAMP NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_username (username), + INDEX idx_email (email) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Insert default admin user +-- Password is randomly generated during migration and displayed in output +-- Hash placeholder will be replaced by migrate.php +INSERT INTO users (username, password, email, full_name, is_active) VALUES +('admin', '{{ADMIN_PASSWORD_HASH}}', 'admin@domainmonitor.local', 'Administrator', 1) +ON DUPLICATE KEY UPDATE username=username; + diff --git a/database/migrations/003_add_whois_fields.sql b/database/migrations/003_add_whois_fields.sql new file mode 100644 index 0000000..bdbe547 --- /dev/null +++ b/database/migrations/003_add_whois_fields.sql @@ -0,0 +1,13 @@ + +-- Add WHOIS-related columns to domains table +-- Note: These statements may show warnings if columns already exist, but won't fail + +-- Add registrar_url column +ALTER TABLE domains ADD COLUMN registrar_url VARCHAR(255) AFTER registrar; + +-- Add updated_date column +ALTER TABLE domains ADD COLUMN updated_date DATE AFTER expiration_date; + +-- Add abuse_email column +ALTER TABLE domains ADD COLUMN abuse_email VARCHAR(255) AFTER updated_date; + diff --git a/database/migrations/004_create_tld_registry_table.sql b/database/migrations/004_create_tld_registry_table.sql new file mode 100644 index 0000000..cdf6654 --- /dev/null +++ b/database/migrations/004_create_tld_registry_table.sql @@ -0,0 +1,37 @@ +-- Create tld_registry table for storing TLD registry information +CREATE TABLE IF NOT EXISTS tld_registry ( + id INT AUTO_INCREMENT PRIMARY KEY, + tld VARCHAR(63) NOT NULL UNIQUE, + rdap_servers JSON, + whois_server VARCHAR(255), + registry_url VARCHAR(500), + iana_publication_date TIMESTAMP NULL, + iana_last_updated TIMESTAMP NULL, + record_last_updated TIMESTAMP NULL, + registration_date DATE NULL, + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_tld (tld), + INDEX idx_is_active (is_active), + INDEX idx_iana_publication_date (iana_publication_date) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Create tld_import_logs table for tracking import operations +CREATE TABLE IF NOT EXISTS tld_import_logs ( + id INT AUTO_INCREMENT PRIMARY KEY, + import_type ENUM('tld_list', 'rdap', 'whois', 'manual') NOT NULL, + total_tlds INT DEFAULT 0, + new_tlds INT DEFAULT 0, + updated_tlds INT DEFAULT 0, + failed_tlds INT DEFAULT 0, + iana_publication_date TIMESTAMP NULL, + version VARCHAR(50) NULL, + started_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + completed_at TIMESTAMP NULL, + status ENUM('running', 'completed', 'failed') DEFAULT 'running', + error_message TEXT, + details JSON, + INDEX idx_started_at (started_at), + INDEX idx_status (status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/database/migrations/005_update_tld_import_logs.sql b/database/migrations/005_update_tld_import_logs.sql new file mode 100644 index 0000000..b4f7e22 --- /dev/null +++ b/database/migrations/005_update_tld_import_logs.sql @@ -0,0 +1,11 @@ +-- Update tld_import_logs table to support TLD list imports +-- Add version field and update import_type enum + +-- Add version column (will fail gracefully if column already exists) +ALTER TABLE tld_import_logs +ADD COLUMN version VARCHAR(50) NULL AFTER iana_publication_date; + +-- Update import_type enum to include 'tld_list' +-- Note: This will fail gracefully if the enum already includes 'tld_list' +ALTER TABLE tld_import_logs +MODIFY COLUMN import_type ENUM('tld_list', 'rdap', 'whois', 'manual') NOT NULL; diff --git a/database/migrations/006_add_complete_workflow_import_type.sql b/database/migrations/006_add_complete_workflow_import_type.sql new file mode 100644 index 0000000..7f7fd32 --- /dev/null +++ b/database/migrations/006_add_complete_workflow_import_type.sql @@ -0,0 +1,3 @@ +-- Add complete_workflow to import_type enum +ALTER TABLE tld_import_logs +MODIFY COLUMN import_type ENUM('tld_list', 'rdap', 'whois', 'manual', 'complete_workflow', 'check_updates') NOT NULL; diff --git a/env.example.txt b/env.example.txt new file mode 100644 index 0000000..cc0e852 --- /dev/null +++ b/env.example.txt @@ -0,0 +1,32 @@ +# Application +APP_NAME="Domain Monitor" +APP_ENV=development +APP_URL=http://localhost:8000 +APP_TIMEZONE=UTC + +# Database +DB_HOST=localhost +DB_PORT=3306 +DB_DATABASE=domain_monitor +DB_USERNAME=root +DB_PASSWORD= + +# Session Security (set cookie_secure=1 only if using HTTPS) +SESSION_LIFETIME=1440 +SESSION_COOKIE_HTTPONLY=1 +SESSION_COOKIE_SECURE=0 +SESSION_COOKIE_SAMESITE=Strict + +# Email Configuration +MAIL_DRIVER=smtp +MAIL_HOST=smtp.mailtrap.io +MAIL_PORT=2525 +MAIL_USERNAME= +MAIL_PASSWORD= +MAIL_ENCRYPTION=tls +MAIL_FROM_ADDRESS=noreply@domainmonitor.com +MAIL_FROM_NAME="Domain Monitor" + +# Domain Check Settings +CHECK_INTERVAL_HOURS=24 +NOTIFICATION_DAYS_BEFORE=60,30,21,14,7,5,3,2,1 \ No newline at end of file diff --git a/logs/.gitkeep b/logs/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/logs/QUICK_START.md b/logs/QUICK_START.md new file mode 100644 index 0000000..deb28f4 --- /dev/null +++ b/logs/QUICK_START.md @@ -0,0 +1,60 @@ +# Quick Start: Using the TLD Import Logs + +## πŸ“ Log File Location + +Your TLD import logs are here: +``` +logs/tld_import_2025-10-08.log (today's log) +``` + +## πŸ” Quick Commands + +### Watch import in real-time (Windows PowerShell): +```powershell +Get-Content logs\tld_import_2025-10-08.log -Wait -Tail 50 +``` + +### Check for errors: +```powershell +Select-String -Path logs\tld_import_*.log -Pattern "ERROR|CRITICAL" +``` + +### Find last processed TLD: +```powershell +Select-String -Path logs\tld_import_2025-10-08.log -Pattern "Processing TLD" | Select-Object -Last 1 +``` + +### Check completion status: +```powershell +Select-String -Path logs\tld_import_2025-10-08.log -Pattern "Batch statistics|complete" +``` + +## 🚨 If Import Fails + +1. **Check the log file** for the date of your import +2. **Look for the last line** - it should show which TLD was being processed +3. **Search for "ERROR" or "FAILED"** to find problems +4. **Check "last_processed_id"** to see where it stopped + +## πŸ“Š What the Logs Show + +- βœ… Each TLD being processed with ID +- βœ… Time taken per TLD (in milliseconds) +- βœ… Success/failure status +- βœ… Data found (WHOIS server, registry URL, dates) +- βœ… Progress tracking (processed/remaining) +- βœ… Batch statistics (total time, average per TLD) +- βœ… Detailed error messages with context + +## πŸ”§ Next Steps After Your Import + +1. **Run your import again** - the logging is now active +2. **Watch the logs** while it runs +3. **If it times out** - check the log to see where +4. **Share the log file** if you need help debugging + +## πŸ“– Full Documentation + +See `logs/README.md` for complete documentation and troubleshooting guide. +See `LOGGING_SYSTEM.md` for technical details about the implementation. + diff --git a/logs/README.md b/logs/README.md new file mode 100644 index 0000000..10c9c77 --- /dev/null +++ b/logs/README.md @@ -0,0 +1,193 @@ +# Logs Directory + +This directory contains detailed application logs for debugging and monitoring purposes. + +## Log Files + +### TLD Import Logs (`tld_import_YYYY-MM-DD.log`) + +Comprehensive logs for TLD registry import operations including: + +- **Start/End Operations**: Marked with `=== START:` and `=== END:` separators +- **Batch Processing**: Each batch's start time, TLDs processed, and duration +- **Individual TLD Processing**: + - TLD name and ID + - Fetch time (in milliseconds) + - Database update time + - Data found (WHOIS server, registry URL, dates) + - Success/failure status + - Error messages with exception details +- **Progress Tracking**: + - Current step in workflow + - Last processed ID + - Completion percentage + - Remaining TLDs +- **Statistics**: + - Batch processing time + - Average time per TLD + - Success/failure counts + - Overall progress + +### Cron Job Logs (`cron.log`) + +Logs from automated domain checking cron jobs (created by `cron/check_domains.php`). + +## Log Levels + +- **DEBUG**: Detailed diagnostic information +- **INFO**: General informational messages (normal operations) +- **WARNING**: Warning messages (non-critical issues) +- **ERROR**: Error messages (failures that don't stop execution) +- **CRITICAL**: Critical errors (failures that stop execution) + +## Log Format + +``` +[YYYY-MM-DD HH:MM:SS] [LEVEL] Message | Context: {"key":"value"} +``` + +Example: +``` +[2025-10-08 10:04:23] [INFO] Processing TLD [1/50]: .aaa (ID: 1) +[2025-10-08 10:04:23] [INFO] TLD .aaa: SUCCESS - Found WHOIS server, registry URL | Context: {"fetch_time_ms":245.67,"update_time_ms":12.34,"data_fields":2} +``` + +## Troubleshooting TLD Import Issues + +### Issue: Import Stuck at Same Progress + +**Check the logs for:** +1. Look for "last_processed_id" - is it advancing? +2. Check for FAILED messages - which TLDs are failing? +3. Look at "batch_time_seconds" - is it taking too long? + +**Example log analysis:** +```bash +# Find failed TLDs +grep "FAILED" logs/tld_import_2025-10-08.log + +# Check last processed IDs +grep "Updating last processed ID" logs/tld_import_2025-10-08.log + +# Find slow TLDs (over 5 seconds) +grep "fetch_time_ms" logs/tld_import_2025-10-08.log | grep -E '"fetch_time_ms":[5-9][0-9]{3}|[0-9]{5,}' +``` + +### Issue: FastCGI Timeout + +**Symptoms in logs:** +- Last log entry shows a TLD being processed +- No "Batch statistics" or "END:" marker +- Apache/Nginx error log shows "Connection reset by peer" + +**Solutions:** +1. Reduce batch size in `TldRegistryService.php`: + ```php + $tldsNeedingWhois = $this->getTldsNeedingWhoisData(25, $lastProcessedId); // Reduced from 50 + ``` + +2. Increase PHP/FastCGI timeout: + ```ini + ; php.ini + max_execution_time = 300 + ``` + +### Issue: Repeated Failures on Same TLDs + +**Check logs for:** +```bash +# Find TLDs that consistently fail +grep "FAILED" logs/tld_import_*.log | sort | uniq -c | sort -rn +``` + +**Common causes:** +- TLD doesn't have IANA RDAP/WHOIS data +- Network timeout to IANA servers +- Invalid TLD format + +## Analyzing Performance + +### Average Processing Time +```bash +# Get average fetch time per TLD +grep "fetch_time_ms" logs/tld_import_2025-10-08.log | grep -oP '"fetch_time_ms":\K[0-9.]+' | awk '{sum+=$1; n++} END {print "Average: " sum/n " ms"}' +``` + +### Slowest TLDs +```bash +# Find slowest 10 TLDs +grep "fetch_time_ms" logs/tld_import_2025-10-08.log | grep -oP 'TLD \.\w+.*fetch_time_ms":\K[0-9.]+' | sort -rn | head -10 +``` + +### Batch Performance +```bash +# Show all batch statistics +grep "Batch statistics" logs/tld_import_2025-10-08.log +``` + +## Log Retention + +Logs are organized by date and are not automatically deleted. You may want to: + +1. **Manual cleanup**: Delete old logs periodically + ```bash + find logs/ -name "*.log" -mtime +30 -delete # Delete logs older than 30 days + ``` + +2. **Set up logrotate** (Linux): + ``` + /path/to/Domain Monitor/logs/*.log { + daily + rotate 30 + compress + missingok + notifempty + } + ``` + +## Accessing Logs + +### Via Command Line +```bash +# View latest TLD import log +tail -f logs/tld_import_$(date +%Y-%m-%d).log + +# View last 100 lines +tail -100 logs/tld_import_2025-10-08.log + +# Search for errors +grep ERROR logs/tld_import_2025-10-08.log + +# Watch for specific TLD +grep ".example" logs/tld_import_2025-10-08.log +``` + +### Via PHP +The `Logger` class provides a `tail()` method: +```php +$logger = new Logger('tld_import'); +$lastLines = $logger->tail(100); // Get last 100 lines +``` + +## Important Notes + +- **Log files grow large**: A complete TLD import (~1500 TLDs) generates ~2-5 MB of logs +- **Context data is JSON**: Can be parsed programmatically for analysis +- **Timestamps are in server timezone**: Check your PHP timezone setting +- **Logs are NOT web-accessible**: The `.htaccess` in the project root blocks access to this directory + +## Related Files + +- `app/Services/Logger.php` - Logger implementation +- `app/Services/TldRegistryService.php` - Uses logger for TLD import operations +- `cron/check_domains.php` - Uses file_put_contents for cron.log + +## Support + +If you're experiencing import issues: +1. Check the relevant log file for error messages +2. Look for patterns in failed TLDs +3. Check Apache/Nginx error logs for timeout issues +4. Verify network connectivity to IANA servers +5. Consider reducing batch size for slower servers + diff --git a/public/.htaccess b/public/.htaccess new file mode 100644 index 0000000..4f3b9bb --- /dev/null +++ b/public/.htaccess @@ -0,0 +1,7 @@ +RewriteEngine On + +# Redirect to index.php +RewriteCond %{REQUEST_FILENAME} !-f +RewriteCond %{REQUEST_FILENAME} !-d +RewriteRule ^(.*)$ index.php [QSA,L] + diff --git a/public/assets/style.css b/public/assets/style.css new file mode 100644 index 0000000..3190e69 --- /dev/null +++ b/public/assets/style.css @@ -0,0 +1,145 @@ +/* ================================ + Domain Monitor - Custom Styles + Using Tailwind CSS Utilities + ================================ */ + +/* Custom Button Utilities */ +@layer utilities { + .btn { + @apply px-4 py-2.5 rounded-lg font-medium transition-all duration-300 inline-block text-center; + } + + .btn-primary { + @apply bg-primary hover:bg-primary-dark text-white; + } + + .btn-success { + @apply bg-success hover:bg-green-600 text-white; + } + + .btn-warning { + @apply bg-warning hover:bg-yellow-600 text-white; + } + + .btn-danger { + @apply bg-danger hover:bg-red-600 text-white; + } + + .btn-small { + @apply px-3 py-1.5 text-sm; + } +} + +/* Custom Badge Utilities */ +@layer utilities { + .badge { + @apply inline-block px-3 py-1 rounded-full text-sm font-medium; + } + + .badge-success { + @apply bg-green-100 text-green-800; + } + + .badge-warning { + @apply bg-yellow-100 text-yellow-800; + } + + .badge-danger { + @apply bg-red-100 text-red-800; + } + + .badge-info { + @apply bg-blue-100 text-blue-800; + } +} + +/* ================================ + Additional Custom Styles + Add your own styles below + ================================ */ + +/* Loading Spinner */ +.spinner { + border: 3px solid #f3f3f3; + border-top: 3px solid #4A90E2; + border-radius: 50%; + width: 40px; + height: 40px; + animation: spin 1s linear infinite; + margin: 20px auto; +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +/* Fade In Animation */ +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.fade-in { + animation: fadeIn 0.3s ease-in; +} + +/* Tooltips */ +.tooltip { + position: relative; + display: inline-block; +} + +.tooltip .tooltiptext { + visibility: hidden; + width: 200px; + background-color: #555; + color: #fff; + text-align: center; + border-radius: 6px; + padding: 5px; + position: absolute; + z-index: 1; + bottom: 125%; + left: 50%; + margin-left: -100px; + opacity: 0; + transition: opacity 0.3s; +} + +.tooltip:hover .tooltiptext { + visibility: visible; + opacity: 1; +} + +/* Custom Scrollbar */ +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: #f1f1f1; +} + +::-webkit-scrollbar-thumb { + background: #888; + border-radius: 4px; +} + +::-webkit-scrollbar-thumb:hover { + background: #555; +} + +/* Print Styles */ +@media print { + nav, footer, .btn, .no-print { + display: none !important; + } +} diff --git a/public/index.php b/public/index.php new file mode 100644 index 0000000..8901315 --- /dev/null +++ b/public/index.php @@ -0,0 +1,24 @@ +load(); + +// Start session +session_start(); + +// Initialize application +$app = new Application(); + +// Load routes +require_once __DIR__ . '/../routes/web.php'; + +// Run application +$app->run(); + diff --git a/public/robots.txt b/public/robots.txt new file mode 100644 index 0000000..6ffbc30 --- /dev/null +++ b/public/robots.txt @@ -0,0 +1,3 @@ +User-agent: * +Disallow: / + diff --git a/routes/web.php b/routes/web.php new file mode 100644 index 0000000..60cbd3e --- /dev/null +++ b/routes/web.php @@ -0,0 +1,78 @@ +get('/login', [AuthController::class, 'showLogin']); +$router->post('/login', [AuthController::class, 'login']); +$router->get('/logout', [AuthController::class, 'logout']); + +// Debug route (public - remove in production!) +$router->get('/debug/whois', [DebugController::class, 'whois']); + +// Protected routes - require authentication +Auth::require(); + +// Dashboard +$router->get('/', [DashboardController::class, 'index']); +$router->get('/dashboard', [DashboardController::class, 'index']); + +// Search +$router->get('/search', [SearchController::class, 'index']); +$router->get('/api/search/suggest', [SearchController::class, 'suggest']); + +// Domains +$router->get('/domains', [DomainController::class, 'index']); +$router->get('/domains/create', [DomainController::class, 'create']); +$router->get('/domains/bulk-add', [DomainController::class, 'bulkAdd']); +$router->post('/domains/bulk-add', [DomainController::class, 'bulkAdd']); +$router->post('/domains/bulk-refresh', [DomainController::class, 'bulkRefresh']); +$router->post('/domains/bulk-delete', [DomainController::class, 'bulkDelete']); +$router->post('/domains/bulk-assign-group', [DomainController::class, 'bulkAssignGroup']); +$router->post('/domains/bulk-toggle-status', [DomainController::class, 'bulkToggleStatus']); +$router->post('/domains/store', [DomainController::class, 'store']); +$router->get('/domains/{id}', [DomainController::class, 'show']); +$router->get('/domains/{id}/edit', [DomainController::class, 'edit']); +$router->post('/domains/{id}/update', [DomainController::class, 'update']); +$router->post('/domains/{id}/refresh', [DomainController::class, 'refresh']); +$router->post('/domains/{id}/delete', [DomainController::class, 'delete']); + +// Notification Groups +$router->get('/groups', [NotificationGroupController::class, 'index']); +$router->get('/groups/create', [NotificationGroupController::class, 'create']); +$router->post('/groups/store', [NotificationGroupController::class, 'store']); +$router->get('/groups/edit', [NotificationGroupController::class, 'edit']); +$router->post('/groups/update', [NotificationGroupController::class, 'update']); +$router->get('/groups/delete', [NotificationGroupController::class, 'delete']); + +// Notification Channels +$router->post('/channels/add', [NotificationGroupController::class, 'addChannel']); +$router->get('/channels/delete', [NotificationGroupController::class, 'deleteChannel']); +$router->get('/channels/toggle', [NotificationGroupController::class, 'toggleChannel']); + +// TLD Registry +$router->get('/tld-registry', [TldRegistryController::class, 'index']); +$router->get('/tld-registry/{id}', [TldRegistryController::class, 'show']); +$router->post('/tld-registry/import-tld-list', [TldRegistryController::class, 'importTldList']); +$router->post('/tld-registry/import-rdap', [TldRegistryController::class, 'importRdap']); +$router->post('/tld-registry/import-whois', [TldRegistryController::class, 'importWhois']); +$router->post('/tld-registry/start-progressive-import', [TldRegistryController::class, 'startProgressiveImport']); +$router->get('/tld-registry/import-progress/{log_id}', [TldRegistryController::class, 'importProgress']); +$router->get('/tld-registry/api/import-progress', [TldRegistryController::class, 'apiGetImportProgress']); +$router->post('/tld-registry/bulk-delete', [TldRegistryController::class, 'bulkDelete']); +$router->get('/tld-registry/check-updates', [TldRegistryController::class, 'checkUpdates']); +$router->get('/tld-registry/{id}/toggle-active', [TldRegistryController::class, 'toggleActive']); +$router->get('/tld-registry/{id}/refresh', [TldRegistryController::class, 'refresh']); +$router->get('/tld-registry/import-logs', [TldRegistryController::class, 'importLogs']); +$router->get('/api/tld-info', [TldRegistryController::class, 'apiGetTldInfo']); +