Initial Commit

This commit is contained in:
Hosteroid
2025-10-08 14:23:07 +03:00
commit b3b3ac66ff
78 changed files with 14248 additions and 0 deletions

53
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@@ -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

View File

@@ -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)

89
.github/pull_request_template.md vendored Normal file
View File

@@ -0,0 +1,89 @@
## 📝 Description
<!-- Provide a clear and concise description of your changes -->
## 🎯 Type of Change
<!-- Mark the relevant option with an 'x' -->
- [ ] 🐛 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
<!-- Link related issues using #issue_number -->
Fixes #
Closes #
Related to #
## 🧪 Testing
<!-- Describe the tests you ran and how to reproduce them -->
- [ ] 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
<!-- If applicable, add screenshots to demonstrate the changes -->
### Before
<!-- Screenshot before changes -->
### After
<!-- Screenshot after changes -->
## ✅ Checklist
<!-- Mark completed items with an 'x' -->
- [ ] 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
<!-- List any documentation that needs to be updated -->
- [ ] README.md updated
- [ ] CHANGELOG.md updated
- [ ] Wiki updated (if applicable)
- [ ] Code comments added
- [ ] API documentation updated (if applicable)
## 🔐 Security Considerations
<!-- Describe any security implications of your changes -->
## ⚠️ Breaking Changes
<!-- If this is a breaking change, describe the impact and migration path -->
## 📝 Additional Notes
<!-- Add any other context or information about the PR here -->
---
<!-- Thank you for contributing to Domain Monitor! 🚀 -->

61
.gitignore vendored Normal file
View File

@@ -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/

149
CHANGELOG.md Normal file
View File

@@ -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

316
CONTRIBUTING.md Normal file
View File

@@ -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.
---
<div align="center">
**Thank you for contributing to Domain Monitor!** 🚀
A project by [Hosteroid](https://www.hosteroid.uk) - Premium Hosting Solutions
</div>

22
LICENSE Normal file
View File

@@ -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.

453
README.md Normal file
View File

@@ -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
<VirtualHost *:80>
ServerName domainmonitor.local
DocumentRoot "D:/Cursor/Domain Monitor/public"
<Directory "D:/Cursor/Domain Monitor/public">
AllowOverride All
Require all granted
</Directory>
</VirtualHost>
```
#### 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
<div align="center">
### [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)
</div>
---
## 🙏 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)
---
<div align="center">
**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)
</div>

View File

@@ -0,0 +1,93 @@
<?php
namespace App\Controllers;
use Core\Controller;
use App\Models\User;
class AuthController extends Controller
{
private User $userModel;
public function __construct()
{
$this->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');
}
}

View File

@@ -0,0 +1,39 @@
<?php
namespace App\Controllers;
use Core\Controller;
use App\Models\Domain;
use App\Models\NotificationGroup;
use App\Models\NotificationLog;
class DashboardController extends Controller
{
private Domain $domainModel;
private NotificationGroup $groupModel;
private NotificationLog $logModel;
public function __construct()
{
$this->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'
]);
}
}

View File

@@ -0,0 +1,304 @@
<?php
namespace App\Controllers;
use Core\Controller;
use App\Services\WhoisService;
class DebugController extends Controller
{
/**
* Show raw WHOIS data for a domain
*/
public function whois()
{
$domain = $_GET['domain'] ?? '';
if (empty($domain)) {
$this->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
]);
}
}

View File

@@ -0,0 +1,569 @@
<?php
namespace App\Controllers;
use Core\Controller;
use App\Models\Domain;
use App\Models\NotificationGroup;
use App\Services\WhoisService;
class DomainController extends Controller
{
private Domain $domainModel;
private NotificationGroup $groupModel;
private WhoisService $whoisService;
public function __construct()
{
$this->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');
}
}

View File

@@ -0,0 +1,194 @@
<?php
namespace App\Controllers;
use Core\Controller;
use App\Models\NotificationGroup;
use App\Models\NotificationChannel;
class NotificationGroupController extends Controller
{
private NotificationGroup $groupModel;
private NotificationChannel $channelModel;
public function __construct()
{
$this->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;
}
}
}

View File

@@ -0,0 +1,172 @@
<?php
namespace App\Controllers;
use Core\Controller;
use App\Models\Domain;
use App\Services\WhoisService;
class SearchController extends Controller
{
private Domain $domainModel;
private WhoisService $whoisService;
public function __construct()
{
$this->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);
}
}

View File

@@ -0,0 +1,515 @@
<?php
namespace App\Controllers;
use Core\Controller;
use App\Models\TldRegistry;
use App\Models\TldImportLog;
use App\Services\TldRegistryService;
class TldRegistryController extends Controller
{
private TldRegistry $tldModel;
private TldImportLog $importLogModel;
private TldRegistryService $tldService;
public function __construct()
{
$this->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;
}
}

143
app/Models/Domain.php Normal file
View File

@@ -0,0 +1,143 @@
<?php
namespace App\Models;
use Core\Model;
class Domain extends Model
{
protected static string $table = 'domains';
/**
* Get all domains with their notification group
*/
public function getAllWithGroups(): array
{
$sql = "SELECT d.*, ng.name as group_name
FROM domains d
LEFT JOIN notification_groups ng ON d.notification_group_id = ng.id
ORDER BY d.status DESC, d.expiration_date ASC";
$stmt = $this->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;
}
}

View File

@@ -0,0 +1,68 @@
<?php
namespace App\Models;
use Core\Model;
class NotificationChannel extends Model
{
protected static string $table = 'notification_channels';
/**
* Get channels by notification group ID
*/
public function getByGroupId(int $groupId): array
{
return $this->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]);
}
}

View File

@@ -0,0 +1,65 @@
<?php
namespace App\Models;
use Core\Model;
class NotificationGroup extends Model
{
protected static string $table = 'notification_groups';
/**
* Get all groups with channel count
*/
public function getAllWithChannelCount(): array
{
$sql = "SELECT ng.*,
COUNT(DISTINCT nc.id) as channel_count,
COUNT(DISTINCT d.id) as domain_count
FROM notification_groups ng
LEFT JOIN notification_channels nc ON ng.id = nc.notification_group_id
LEFT JOIN domains d ON ng.id = d.notification_group_id
GROUP BY ng.id
ORDER BY ng.name ASC";
$stmt = $this->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);
}
}

View File

@@ -0,0 +1,75 @@
<?php
namespace App\Models;
use Core\Model;
class NotificationLog extends Model
{
protected static string $table = 'notification_logs';
/**
* Log a notification
*/
public function log(int $domainId, string $type, string $channel, string $message, bool $success, ?string $error = null): int
{
return $this->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;
}
}

155
app/Models/TldImportLog.php Normal file
View File

@@ -0,0 +1,155 @@
<?php
namespace App\Models;
use Core\Model;
class TldImportLog extends Model
{
protected static string $table = 'tld_import_logs';
/**
* Create a new import log entry
*/
public function startImport(string $importType, ?string $ianaPublicationDate = null): int
{
return $this->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)
]
];
}
}

253
app/Models/TldRegistry.php Normal file
View File

@@ -0,0 +1,253 @@
<?php
namespace App\Models;
use Core\Model;
class TldRegistry extends Model
{
protected static string $table = 'tld_registry';
/**
* Get TLD by domain extension
*/
public function getByTld(string $tld): ?array
{
// Ensure TLD starts with dot
if (!str_starts_with($tld, '.')) {
$tld = '.' . $tld;
}
$stmt = $this->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();
}
}

65
app/Models/User.php Normal file
View File

@@ -0,0 +1,65 @@
<?php
namespace App\Models;
use Core\Model;
class User extends Model
{
protected static string $table = 'users';
/**
* Find user by username
*/
public function findByUsername(string $username): ?array
{
$stmt = $this->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]);
}
}

View File

@@ -0,0 +1,100 @@
<?php
namespace App\Services\Channels;
use GuzzleHttp\Client;
class DiscordChannel implements NotificationChannelInterface
{
private Client $client;
public function __construct()
{
$this->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
}
}

View File

@@ -0,0 +1,91 @@
<?php
namespace App\Services\Channels;
use PHPMailer\PHPMailer\PHPMailer;
use PHPMailer\PHPMailer\Exception;
class EmailChannel implements NotificationChannelInterface
{
public function send(array $config, string $message, array $data = []): bool
{
$mail = new PHPMailer(true);
try {
// Server settings
$mail->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 "
<html>
<head>
<style>
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; }
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
.header { background: #4A90E2; color: white; padding: 20px; border-radius: 5px 5px 0 0; }
.content { background: #f9f9f9; padding: 20px; border: 1px solid #ddd; }
.footer { background: #333; color: white; padding: 10px; text-align: center; font-size: 12px; border-radius: 0 0 5px 5px; }
.button { display: inline-block; padding: 10px 20px; background: #4A90E2; color: white; text-decoration: none; border-radius: 5px; margin-top: 10px; }
</style>
</head>
<body>
<div class='container'>
<div class='header'>
<h2>🔔 Domain Monitor Alert</h2>
</div>
<div class='content'>
<p>$messageHtml</p>
</div>
<div class='footer'>
<p>This is an automated message from Domain Monitor</p>
</div>
</div>
</body>
</html>
";
}
}

View File

@@ -0,0 +1,17 @@
<?php
namespace App\Services\Channels;
interface NotificationChannelInterface
{
/**
* Send notification through the channel
*
* @param array $config Channel-specific configuration
* @param string $message Message to send
* @param array $data Additional data for formatting
* @return bool Success status
*/
public function send(array $config, string $message, array $data = []): bool;
}

View File

@@ -0,0 +1,85 @@
<?php
namespace App\Services\Channels;
use GuzzleHttp\Client;
class SlackChannel implements NotificationChannelInterface
{
private Client $client;
public function __construct()
{
$this->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;
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace App\Services\Channels;
use GuzzleHttp\Client;
class TelegramChannel implements NotificationChannelInterface
{
private Client $client;
public function __construct()
{
$this->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;
}
}
}

155
app/Services/Logger.php Normal file
View File

@@ -0,0 +1,155 @@
<?php
namespace App\Services;
class Logger
{
private string $logDir;
private string $currentLogFile;
private bool $enabled;
public function __construct(string $logName = 'app', bool $enabled = true)
{
$this->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);
}
}

View File

@@ -0,0 +1,153 @@
<?php
namespace App\Services;
use App\Services\Channels\EmailChannel;
use App\Services\Channels\TelegramChannel;
use App\Services\Channels\DiscordChannel;
use App\Services\Channels\SlackChannel;
class NotificationService
{
private array $channels = [];
public function __construct()
{
$this->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);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,729 @@
<?php
namespace App\Services;
use Exception;
use App\Models\TldRegistry;
class WhoisService
{
// Cache for discovered TLD servers to avoid repeated IANA queries
private static array $tldCache = [];
private TldRegistry $tldModel;
public function __construct()
{
$this->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: <b>RDAP Server:</b> https://rdap.example.com/
if (preg_match('/<b>RDAP Server:<\/b>\s*<a[^>]*>(https?:\/\/[^<]+)<\/a>/i', $html, $matches)) {
$result['rdap_url'] = rtrim(trim($matches[1]), '/') . '/';
} elseif (preg_match('/<b>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'
];
}
}

156
app/Views/auth/login.php Normal file
View File

@@ -0,0 +1,156 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login - Domain Monitor</title>
<!-- Tailwind CSS -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- Font Awesome -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" crossorigin="anonymous" referrerpolicy="no-referrer" />
<script>
tailwind.config = {
theme: {
extend: {
colors: {
primary: {
DEFAULT: '#4A90E2',
dark: '#357ABD',
light: '#6BA3E8',
}
}
}
}
}
</script>
<style>
body {
background-color: #f8f9fa;
}
</style>
</head>
<body class="min-h-screen flex items-center justify-center p-4">
<div class="max-w-md w-full">
<!-- Login Card -->
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-8">
<!-- Logo and Title -->
<div class="text-center mb-8">
<div class="inline-flex items-center justify-center w-14 h-14 bg-primary rounded-lg mb-4">
<i class="fas fa-globe text-white text-2xl"></i>
</div>
<h1 class="text-2xl font-semibold text-gray-900 mb-1">Welcome Back</h1>
<p class="text-sm text-gray-500">Sign in to access your account</p>
</div>
<!-- Error Alert -->
<?php if (isset($_SESSION['error'])): ?>
<div class="mb-6 bg-red-50 border border-red-200 p-3 rounded-lg">
<div class="flex items-center">
<i class="fas fa-exclamation-circle text-red-500 mr-2"></i>
<span class="text-sm text-red-700"><?= htmlspecialchars($_SESSION['error']) ?></span>
</div>
</div>
<?php unset($_SESSION['error']); ?>
<?php endif; ?>
<!-- Login Form -->
<form method="POST" action="/login" class="space-y-5">
<!-- Username Field -->
<div>
<label for="username" class="block text-sm font-medium text-gray-700 mb-1.5">
Username
</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-user text-gray-400 text-sm"></i>
</div>
<input
type="text"
id="username"
name="username"
required
autofocus
class="w-full pl-10 pr-3 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary transition-colors text-sm"
placeholder="Enter your username">
</div>
</div>
<!-- Password Field -->
<div>
<label for="password" class="block text-sm font-medium text-gray-700 mb-1.5">
Password
</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-lock text-gray-400 text-sm"></i>
</div>
<input
type="password"
id="password"
name="password"
required
class="w-full pl-10 pr-10 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary transition-colors text-sm"
placeholder="Enter your password">
<button
type="button"
onclick="togglePassword()"
class="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600 focus:outline-none">
<i class="fas fa-eye text-sm" id="toggleIcon"></i>
</button>
</div>
</div>
<!-- Remember Me -->
<div class="flex items-center justify-between">
<label class="flex items-center cursor-pointer">
<input
type="checkbox"
name="remember"
class="w-4 h-4 text-primary border-gray-300 rounded focus:ring-primary">
<span class="ml-2 text-sm text-gray-600">Remember me</span>
</label>
<a href="#" class="text-sm text-primary hover:text-primary-dark">
Forgot password?
</a>
</div>
<!-- Submit Button -->
<button
type="submit"
class="w-full bg-primary hover:bg-primary-dark text-white py-2.5 rounded-lg font-medium transition-colors duration-200 flex items-center justify-center text-sm">
<i class="fas fa-sign-in-alt mr-2"></i>
Sign In
</button>
</form>
</div>
<!-- Footer -->
<div class="text-center mt-6">
<p class="text-gray-500 text-xs">
© <?= date('Y') ?> Domain Monitor. All rights reserved.
</p>
</div>
</div>
<script>
function togglePassword() {
const passwordInput = document.getElementById('password');
const toggleIcon = document.getElementById('toggleIcon');
if (passwordInput.type === 'password') {
passwordInput.type = 'text';
toggleIcon.classList.remove('fa-eye');
toggleIcon.classList.add('fa-eye-slash');
} else {
passwordInput.type = 'password';
toggleIcon.classList.remove('fa-eye-slash');
toggleIcon.classList.add('fa-eye');
}
}
</script>
</body>
</html>

View File

@@ -0,0 +1,232 @@
<?php
$title = 'Dashboard';
$pageTitle = 'Dashboard Overview';
$pageDescription = 'Monitor your domains and expiration dates';
$pageIcon = 'fas fa-chart-line';
ob_start();
?>
<!-- Statistics Cards -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<!-- Total Domains Card -->
<div class="bg-white rounded-lg border border-gray-200 p-5 hover:shadow-md transition-shadow duration-200">
<div class="flex items-center justify-between">
<div>
<p class="text-xs font-medium text-gray-500 uppercase tracking-wide">Total Domains</p>
<p class="text-2xl font-semibold text-gray-900 mt-1"><?= $stats['total'] ?? 0 ?></p>
</div>
<div class="w-12 h-12 bg-blue-50 rounded-lg flex items-center justify-center">
<i class="fas fa-globe text-blue-600 text-lg"></i>
</div>
</div>
</div>
<!-- Active Domains Card -->
<div class="bg-white rounded-lg border border-gray-200 p-5 hover:shadow-md transition-shadow duration-200">
<div class="flex items-center justify-between">
<div>
<p class="text-xs font-medium text-gray-500 uppercase tracking-wide">Active</p>
<p class="text-2xl font-semibold text-gray-900 mt-1"><?= $stats['active'] ?? 0 ?></p>
</div>
<div class="w-12 h-12 bg-green-50 rounded-lg flex items-center justify-center">
<i class="fas fa-check-circle text-green-600 text-lg"></i>
</div>
</div>
</div>
<!-- Expiring Soon Card -->
<div class="bg-white rounded-lg border border-gray-200 p-5 hover:shadow-md transition-shadow duration-200">
<div class="flex items-center justify-between">
<div>
<p class="text-xs font-medium text-gray-500 uppercase tracking-wide">Expiring Soon</p>
<p class="text-2xl font-semibold text-gray-900 mt-1"><?= $stats['expiring_soon'] ?? 0 ?></p>
</div>
<div class="w-12 h-12 bg-orange-50 rounded-lg flex items-center justify-center">
<i class="fas fa-exclamation-triangle text-orange-600 text-lg"></i>
</div>
</div>
</div>
<!-- Inactive Domains Card -->
<div class="bg-white rounded-lg border border-gray-200 p-5 hover:shadow-md transition-shadow duration-200">
<div class="flex items-center justify-between">
<div>
<p class="text-xs font-medium text-gray-500 uppercase tracking-wide">Inactive</p>
<p class="text-2xl font-semibold text-gray-900 mt-1"><?= $stats['inactive'] ?? 0 ?></p>
</div>
<div class="w-12 h-12 bg-gray-50 rounded-lg flex items-center justify-center">
<i class="fas fa-times-circle text-gray-600 text-lg"></i>
</div>
</div>
</div>
</div>
<!-- Content Grid -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4">
<!-- Recent Domains -->
<div class="lg:col-span-2 bg-white rounded-lg border border-gray-200 overflow-hidden">
<div class="px-6 py-4 border-b border-gray-200">
<h2 class="text-lg font-semibold text-gray-900 flex items-center">
<i class="fas fa-clock text-gray-400 mr-2 text-sm"></i>
Recent Domains
</h2>
</div>
<div class="p-6">
<?php if (!empty($recentDomains)): ?>
<div class="space-y-3">
<?php foreach ($recentDomains as $domain): ?>
<div class="flex items-center justify-between p-3 border border-gray-100 rounded-lg hover:border-gray-300 hover:shadow-sm transition-all duration-200">
<div class="flex items-center space-x-3 flex-1 min-w-0">
<div class="w-10 h-10 bg-gray-50 rounded-lg flex items-center justify-center flex-shrink-0">
<i class="fas fa-globe text-gray-400"></i>
</div>
<div class="flex-1 min-w-0">
<h3 class="font-medium text-gray-900 truncate"><?= htmlspecialchars($domain['domain_name']) ?></h3>
<div class="flex items-center space-x-3 text-xs text-gray-500 mt-1">
<span class="flex items-center">
<i class="far fa-calendar mr-1"></i>
<?php if ($domain['expiration_date']): ?>
<?= date('M d, Y', strtotime($domain['expiration_date'])) ?>
<?php else: ?>
Not set
<?php endif; ?>
</span>
<?php if ($domain['registrar']): ?>
<span class="flex items-center truncate">
<i class="fas fa-building mr-1"></i>
<?= htmlspecialchars($domain['registrar']) ?>
</span>
<?php endif; ?>
</div>
</div>
</div>
<div class="flex items-center space-x-2 flex-shrink-0">
<?php
$statusClass = $domain['status'] === 'active' ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-700';
?>
<span class="px-2 py-1 rounded text-xs font-medium <?= $statusClass ?>">
<?= ucfirst($domain['status']) ?>
</span>
<a href="/domains/<?= $domain['id'] ?>" class="text-gray-400 hover:text-primary">
<i class="fas fa-chevron-right text-sm"></i>
</a>
</div>
</div>
<?php endforeach; ?>
</div>
<div class="mt-4 pt-4 border-t border-gray-100 text-center">
<a href="/domains" class="text-sm text-primary hover:text-primary-dark font-medium inline-flex items-center">
View All Domains
<i class="fas fa-arrow-right ml-2 text-xs"></i>
</a>
</div>
<?php else: ?>
<div class="text-center py-10">
<i class="fas fa-globe text-gray-300 text-5xl mb-3"></i>
<p class="text-gray-500">No domains added yet</p>
<a href="/domains/create" class="mt-3 inline-flex items-center px-5 py-2 bg-primary text-white text-sm rounded-lg hover:bg-primary-dark transition-colors duration-200">
<i class="fas fa-plus mr-2"></i>
Add Your First Domain
</a>
</div>
<?php endif; ?>
</div>
</div>
<!-- Sidebar: Quick Actions & Stats -->
<div class="space-y-4">
<!-- Quick Actions -->
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
<div class="px-5 py-3 border-b border-gray-200">
<h2 class="text-sm font-semibold text-gray-900 flex items-center">
<i class="fas fa-bolt text-gray-400 mr-2 text-xs"></i>
Quick Actions
</h2>
</div>
<div class="p-4 space-y-2">
<a href="/domains/create" class="flex items-center p-3 border border-gray-200 hover:border-primary hover:bg-blue-50 rounded-lg transition-all duration-200 group">
<div class="w-9 h-9 bg-blue-50 group-hover:bg-primary rounded-lg flex items-center justify-center group-hover:text-white text-blue-600 transition-colors duration-200">
<i class="fas fa-plus text-sm"></i>
</div>
<span class="ml-3 text-sm font-medium text-gray-700 group-hover:text-primary">Add New Domain</span>
</a>
<a href="/groups/create" class="flex items-center p-3 border border-gray-200 hover:border-green-500 hover:bg-green-50 rounded-lg transition-all duration-200 group">
<div class="w-9 h-9 bg-green-50 group-hover:bg-green-500 rounded-lg flex items-center justify-center group-hover:text-white text-green-600 transition-colors duration-200">
<i class="fas fa-bell text-sm"></i>
</div>
<span class="ml-3 text-sm font-medium text-gray-700 group-hover:text-green-700">Create Group</span>
</a>
<a href="/debug/whois" class="flex items-center p-3 border border-gray-200 hover:border-purple-500 hover:bg-purple-50 rounded-lg transition-all duration-200 group">
<div class="w-9 h-9 bg-purple-50 group-hover:bg-purple-500 rounded-lg flex items-center justify-center group-hover:text-white text-purple-600 transition-colors duration-200">
<i class="fas fa-search text-sm"></i>
</div>
<span class="ml-3 text-sm font-medium text-gray-700 group-hover:text-purple-700">WHOIS Lookup</span>
</a>
</div>
</div>
<!-- System Status -->
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
<div class="px-5 py-3 border-b border-gray-200">
<h2 class="text-sm font-semibold text-gray-900 flex items-center">
<i class="fas fa-server text-gray-400 mr-2 text-xs"></i>
System Status
</h2>
</div>
<div class="p-4 space-y-3">
<div class="flex items-center justify-between text-sm">
<span class="text-gray-600">Database</span>
<span class="flex items-center text-green-600 font-medium">
<i class="fas fa-circle text-xs mr-1.5"></i>
Online
</span>
</div>
<div class="flex items-center justify-between text-sm">
<span class="text-gray-600">WHOIS Service</span>
<span class="flex items-center text-green-600 font-medium">
<i class="fas fa-circle text-xs mr-1.5"></i>
Active
</span>
</div>
<div class="flex items-center justify-between text-sm">
<span class="text-gray-600">Notifications</span>
<span class="flex items-center text-green-600 font-medium">
<i class="fas fa-circle text-xs mr-1.5"></i>
Enabled
</span>
</div>
</div>
</div>
<!-- Expiring This Month -->
<?php if (!empty($expiringThisMonth)): ?>
<div class="bg-white rounded-lg border-l-4 border-orange-500 border-t border-r border-b border-gray-200 overflow-hidden">
<div class="bg-orange-50 px-5 py-3 border-b border-orange-100">
<h2 class="text-sm font-semibold text-orange-900 flex items-center">
<i class="fas fa-exclamation-triangle mr-2 text-xs"></i>
Expiring This Month
</h2>
</div>
<div class="p-4 space-y-2.5">
<?php foreach ($expiringThisMonth as $domain): ?>
<div class="flex items-center justify-between p-2 hover:bg-gray-50 rounded transition-colors duration-150">
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-gray-900 truncate"><?= htmlspecialchars($domain['domain_name']) ?></p>
<p class="text-xs text-gray-500"><?= date('M d, Y', strtotime($domain['expiration_date'])) ?></p>
</div>
<a href="/domains/<?= $domain['id'] ?>" class="ml-2 text-gray-400 hover:text-primary flex-shrink-0">
<i class="fas fa-chevron-right text-xs"></i>
</a>
</div>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
</div>
</div>
<?php
$content = ob_get_clean();
include __DIR__ . '/../layout/base.php';
?>

318
app/Views/debug/whois.php Normal file
View File

@@ -0,0 +1,318 @@
<?php
$title = 'WHOIS Debug Tool';
$pageTitle = 'WHOIS Debug Tool';
$pageDescription = 'Test and debug WHOIS data extraction';
$pageIcon = 'fas fa-search';
ob_start();
?>
<?php if (empty($domain)): ?>
<!-- Search Form -->
<div class="max-w-2xl mx-auto">
<div class="bg-white rounded-lg border border-gray-200 p-6">
<form method="GET" action="/debug/whois" class="space-y-4">
<div>
<label for="domain" class="block text-sm font-medium text-gray-700 mb-1.5">
Domain Name
</label>
<input type="text"
id="domain"
name="domain"
class="w-full px-3 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary transition-colors text-sm"
placeholder="Enter domain (e.g., google.com)"
required
autofocus>
<p class="mt-1.5 text-xs text-gray-500">
Enter a domain name without http:// or www.
</p>
</div>
<button type="submit"
class="w-full inline-flex items-center justify-center px-5 py-2.5 bg-primary hover:bg-primary-dark text-white rounded-lg font-medium transition-colors text-sm">
<i class="fas fa-search mr-2"></i>
Check WHOIS
</button>
</form>
</div>
<!-- Info Card -->
<div class="mt-4 bg-blue-50 border border-blue-200 rounded-lg p-4">
<div class="flex items-start">
<div class="flex-shrink-0">
<i class="fas fa-info-circle text-blue-500 text-lg"></i>
</div>
<div class="ml-3">
<h3 class="text-sm font-semibold text-gray-900 mb-1">What is this tool?</h3>
<p class="text-xs text-gray-600 leading-relaxed">
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.
</p>
</div>
</div>
</div>
</div>
<?php else: ?>
<!-- Back Button & Copy Report -->
<div class="mb-4 flex justify-between items-center">
<a href="/debug/whois" class="inline-flex items-center px-4 py-2 border border-gray-300 text-gray-700 text-sm rounded-lg hover:bg-gray-50 transition-colors font-medium">
<i class="fas fa-arrow-left mr-2"></i>
Check Another Domain
</a>
<button onclick="copyDebugReport()" class="inline-flex items-center px-4 py-2 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-700 transition-colors font-medium">
<i class="fas fa-copy mr-2"></i>
Copy Debug Report
</button>
</div>
<!-- Domain Info Card -->
<div class="bg-white rounded-lg border border-gray-200 p-5 mb-4">
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<p class="text-xs font-medium text-gray-500 uppercase tracking-wide">Domain</p>
<p class="text-sm font-semibold text-gray-900 mt-1"><?= htmlspecialchars($domain) ?></p>
</div>
<div>
<p class="text-xs font-medium text-gray-500 uppercase tracking-wide">WHOIS Server</p>
<p class="text-sm font-semibold text-gray-900 mt-1"><?= htmlspecialchars($server) ?></p>
</div>
<div>
<p class="text-xs font-medium text-gray-500 uppercase tracking-wide">TLD</p>
<p class="text-sm font-semibold text-gray-900 mt-1"><?= htmlspecialchars($tld) ?></p>
</div>
</div>
</div>
<!-- Main Content Grid -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<!-- Parsed Data -->
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
<div class="px-5 py-3 border-b border-gray-200 bg-green-50">
<h2 class="text-sm font-semibold text-gray-900 flex items-center">
<i class="fas fa-check-circle text-green-600 mr-2 text-sm"></i>
Extracted Data (What We Save)
</h2>
</div>
<div class="p-5">
<div class="space-y-3">
<div class="flex justify-between py-2 border-b border-gray-100">
<span class="text-xs font-medium text-gray-600">Domain</span>
<span class="text-xs text-gray-900 font-mono"><?= htmlspecialchars($info['domain'] ?? 'N/A') ?></span>
</div>
<div class="flex justify-between py-2 border-b border-gray-100">
<span class="text-xs font-medium text-gray-600">Registrar</span>
<span class="text-xs text-gray-900 font-mono"><?= htmlspecialchars($info['registrar'] ?? 'N/A') ?></span>
</div>
<div class="flex justify-between py-2 border-b border-gray-100">
<span class="text-xs font-medium text-gray-600">Expiration Date</span>
<span class="text-xs text-gray-900 font-mono"><?= htmlspecialchars($info['expiration_date'] ?? 'N/A') ?></span>
</div>
<div class="flex justify-between py-2 border-b border-gray-100">
<span class="text-xs font-medium text-gray-600">Creation Date</span>
<span class="text-xs text-gray-900 font-mono"><?= htmlspecialchars($info['creation_date'] ?? 'N/A') ?></span>
</div>
<div class="flex justify-between py-2 border-b border-gray-100">
<span class="text-xs font-medium text-gray-600">Updated Date</span>
<span class="text-xs text-gray-900 font-mono"><?= htmlspecialchars($info['updated_date'] ?? 'N/A') ?></span>
</div>
<div class="flex justify-between py-2 border-b border-gray-100">
<span class="text-xs font-medium text-gray-600">Registrar URL</span>
<span class="text-xs text-gray-900 font-mono"><?= htmlspecialchars($info['registrar_url'] ?? 'N/A') ?></span>
</div>
<div class="flex justify-between py-2 border-b border-gray-100">
<span class="text-xs font-medium text-gray-600">Abuse Email</span>
<span class="text-xs text-gray-900 font-mono"><?= htmlspecialchars($info['abuse_email'] ?? 'N/A') ?></span>
</div>
<div class="py-2">
<span class="text-xs font-medium text-gray-600 block mb-2">Nameservers</span>
<div class="space-y-1">
<?php if (!empty($info['nameservers'])): ?>
<?php foreach ($info['nameservers'] as $ns): ?>
<div class="text-xs text-gray-900 font-mono bg-gray-50 px-2 py-1 rounded"><?= htmlspecialchars($ns) ?></div>
<?php endforeach; ?>
<?php else: ?>
<span class="text-xs text-gray-400">N/A</span>
<?php endif; ?>
</div>
</div>
</div>
</div>
</div>
<!-- Key-Value Pairs -->
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
<div class="px-5 py-3 border-b border-gray-200 bg-blue-50">
<h2 class="text-sm font-semibold text-gray-900 flex items-center">
<i class="fas fa-table text-blue-600 mr-2 text-sm"></i>
All Key-Value Pairs
</h2>
</div>
<div class="overflow-y-auto" style="max-height: 500px;">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50 sticky top-0">
<tr>
<th class="px-4 py-2 text-left text-xs font-medium text-gray-600 uppercase">Key</th>
<th class="px-4 py-2 text-left text-xs font-medium text-gray-600 uppercase">Value</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-100">
<?php foreach ($parsedData as $item): ?>
<?php if (!empty($item['value'])): ?>
<tr class="hover:bg-gray-50">
<td class="px-4 py-2 text-xs font-medium text-gray-700"><?= htmlspecialchars($item['key']) ?></td>
<td class="px-4 py-2 text-xs text-gray-900 font-mono"><?= htmlspecialchars($item['value']) ?></td>
</tr>
<?php endif; ?>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
</div>
<!-- Raw Response -->
<div class="mt-4 bg-white rounded-lg border border-gray-200 overflow-hidden">
<div class="px-5 py-3 border-b border-gray-200 bg-gray-50">
<h2 class="text-sm font-semibold text-gray-900 flex items-center">
<i class="fas fa-file-code text-gray-600 mr-2 text-sm"></i>
Raw WHOIS Response
</h2>
</div>
<div class="p-5">
<pre class="text-xs font-mono bg-gray-50 p-4 rounded border border-gray-200 overflow-x-auto"><?= htmlspecialchars($response) ?></pre>
</div>
</div>
<!-- Hidden data for JS -->
<script id="debug-data" type="application/json">
{
"domain": <?= json_encode($domain) ?>,
"tld": <?= json_encode($tld) ?>,
"server": <?= json_encode($server) ?>,
"extractedData": <?= json_encode($info) ?>,
"rawResponse": <?= json_encode($response) ?>,
"parsedKeyValuePairs": <?= json_encode($parsedData) ?>
}
</script>
<script>
function copyDebugReport() {
const data = JSON.parse(document.getElementById('debug-data').textContent);
let report = `=== WHOIS DEBUG REPORT ===\n\n`;
report += `Domain: ${data.domain}\n`;
report += `TLD: ${data.tld}\n`;
report += `WHOIS Server: ${data.server}\n`;
report += `Date: ${new Date().toISOString()}\n\n`;
report += `--- EXTRACTED DATA (What We Save) ---\n`;
report += `Domain: ${data.extractedData.domain || 'N/A'}\n`;
report += `Registrar: ${data.extractedData.registrar || 'N/A'}\n`;
report += `Registrar URL: ${data.extractedData.registrar_url || 'N/A'}\n`;
report += `Expiration Date: ${data.extractedData.expiration_date || 'N/A'}\n`;
report += `Creation Date: ${data.extractedData.creation_date || 'N/A'}\n`;
report += `Updated Date: ${data.extractedData.updated_date || 'N/A'}\n`;
report += `Abuse Email: ${data.extractedData.abuse_email || 'N/A'}\n`;
report += `Nameservers: ${data.extractedData.nameservers && data.extractedData.nameservers.length > 0 ? data.extractedData.nameservers.join(', ') : 'N/A'}\n`;
report += `Status: ${data.extractedData.status && data.extractedData.status.length > 0 ? data.extractedData.status.join(', ') : 'N/A'}\n\n`;
report += `--- ALL KEY-VALUE PAIRS ---\n`;
if (data.parsedKeyValuePairs && data.parsedKeyValuePairs.length > 0) {
data.parsedKeyValuePairs.forEach(item => {
if (item.value) {
report += `${item.key}: ${item.value}\n`;
}
});
} else {
report += 'No key-value pairs found\n';
}
report += `\n--- RAW WHOIS RESPONSE ---\n`;
report += data.rawResponse;
report += `\n\n=== END OF REPORT ===`;
// Copy to clipboard with fallback
copyToClipboard(report);
}
// Robust clipboard copy function with fallback
function copyToClipboard(text) {
// Try modern clipboard API first
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(text).then(() => {
showCopySuccess();
}).catch(err => {
console.error('Modern clipboard API failed:', err);
// Fallback to legacy method
fallbackCopyTextToClipboard(text);
});
} else {
// Use fallback for non-HTTPS or older browsers
fallbackCopyTextToClipboard(text);
}
}
function fallbackCopyTextToClipboard(text) {
// Create a temporary textarea
const textArea = document.createElement('textarea');
textArea.value = text;
// Make it invisible but accessible
textArea.style.position = 'fixed';
textArea.style.top = '0';
textArea.style.left = '0';
textArea.style.width = '2em';
textArea.style.height = '2em';
textArea.style.padding = '0';
textArea.style.border = 'none';
textArea.style.outline = 'none';
textArea.style.boxShadow = 'none';
textArea.style.background = 'transparent';
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
try {
const successful = document.execCommand('copy');
if (successful) {
showCopySuccess();
} else {
showCopyError();
}
} catch (err) {
console.error('Fallback copy failed:', err);
showCopyError();
}
document.body.removeChild(textArea);
}
function showCopySuccess() {
const btn = event.target.closest('button');
if (!btn) return;
const originalHTML = btn.innerHTML;
btn.innerHTML = '<i class="fas fa-check mr-2"></i>Copied!';
btn.classList.remove('bg-blue-600', 'hover:bg-blue-700');
btn.classList.add('bg-green-600', 'hover:bg-green-700');
setTimeout(() => {
btn.innerHTML = originalHTML;
btn.classList.remove('bg-green-600', 'hover:bg-green-700');
btn.classList.add('bg-blue-600', 'hover:bg-blue-700');
}, 2000);
}
function showCopyError() {
alert('Failed to copy to clipboard.\n\nYour browser may not support this feature, or the site needs HTTPS.\n\nPlease manually select and copy the text from the Raw WHOIS Response section below.');
}
</script>
<?php endif; ?>
<?php
$content = ob_get_clean();
include __DIR__ . '/../layout/base.php';
?>

View File

@@ -0,0 +1,128 @@
<?php
$title = 'Bulk Add Domains';
$pageTitle = 'Bulk Add Domains';
$pageDescription = 'Add multiple domains at once';
$pageIcon = 'fas fa-layer-group';
ob_start();
?>
<!-- Main Form -->
<div class="max-w-4xl mx-auto">
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
<div class="px-6 py-4 border-b border-gray-200">
<h2 class="text-lg font-semibold text-gray-900 flex items-center">
<i class="fas fa-layer-group text-gray-400 mr-2 text-sm"></i>
Bulk Add Domains
</h2>
</div>
<div class="p-6">
<form method="POST" action="/domains/bulk-add" class="space-y-5">
<!-- Domains Textarea -->
<div>
<label for="domains" class="block text-sm font-medium text-gray-700 mb-1.5">
Domain Names *
</label>
<textarea
id="domains"
name="domains"
rows="10"
class="w-full px-3 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary transition-colors text-sm font-mono"
placeholder="example.com&#10;google.com&#10;github.com&#10;..."
required
autofocus></textarea>
<p class="mt-1.5 text-xs text-gray-500">
Enter one domain per line. Domains without http:// or www.
</p>
</div>
<!-- Notification Group -->
<div>
<label for="notification_group_id" class="block text-sm font-medium text-gray-700 mb-1.5">
Notification Group (Optional)
</label>
<select id="notification_group_id"
name="notification_group_id"
class="w-full px-3 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary transition-colors text-sm">
<option value="">-- No Group (No notifications) --</option>
<?php foreach ($groups as $group): ?>
<option value="<?= $group['id'] ?>"><?= htmlspecialchars($group['name']) ?></option>
<?php endforeach; ?>
</select>
<p class="mt-1.5 text-xs text-gray-500">
Assign all domains to this notification group
</p>
</div>
<!-- Action Buttons -->
<div class="flex flex-col sm:flex-row gap-3 pt-3">
<button type="submit"
class="inline-flex items-center justify-center px-5 py-2.5 bg-primary hover:bg-primary-dark text-white rounded-lg font-medium transition-colors text-sm">
<i class="fas fa-plus-circle mr-2"></i>
Add All Domains
</button>
<a href="/domains"
class="inline-flex items-center justify-center px-5 py-2.5 border border-gray-300 text-gray-700 rounded-lg font-medium hover:bg-gray-50 transition-colors text-sm">
<i class="fas fa-times mr-2"></i>
Cancel
</a>
</div>
</form>
</div>
</div>
<!-- Info Cards -->
<div class="mt-4 grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- How it works -->
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4">
<div class="flex items-start">
<div class="flex-shrink-0">
<div class="w-10 h-10 bg-blue-500 rounded-lg flex items-center justify-center">
<i class="fas fa-info-circle text-white"></i>
</div>
</div>
<div class="ml-3">
<h3 class="text-sm font-semibold text-gray-900 mb-1">How It Works</h3>
<p class="text-xs text-gray-600 leading-relaxed">
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.
</p>
</div>
</div>
</div>
<!-- Important notes -->
<div class="bg-orange-50 border border-orange-200 rounded-lg p-4">
<div class="flex items-start">
<div class="flex-shrink-0">
<div class="w-10 h-10 bg-orange-500 rounded-lg flex items-center justify-center">
<i class="fas fa-exclamation-triangle text-white"></i>
</div>
</div>
<div class="ml-3">
<h3 class="text-sm font-semibold text-gray-900 mb-1">Important Notes</h3>
<ul class="text-xs text-gray-600 space-y-1">
<li class="flex items-start">
<i class="fas fa-circle text-orange-500 mt-1 mr-2" style="font-size: 6px;"></i>
<span>Duplicate domains will be skipped</span>
</li>
<li class="flex items-start">
<i class="fas fa-circle text-orange-500 mt-1 mr-2" style="font-size: 6px;"></i>
<span>Invalid domains will be reported</span>
</li>
<li class="flex items-start">
<i class="fas fa-circle text-orange-500 mt-1 mr-2" style="font-size: 6px;"></i>
<span>Large batches may take several minutes</span>
</li>
</ul>
</div>
</div>
</div>
</div>
</div>
<?php
$content = ob_get_clean();
include __DIR__ . '/../layout/base.php';
?>

View File

@@ -0,0 +1,130 @@
<?php
$title = 'Add New Domain';
$pageTitle = 'Add New Domain';
$pageDescription = 'Start monitoring a new domain';
$pageIcon = 'fas fa-plus-circle';
ob_start();
?>
<!-- Main Form -->
<div class="max-w-3xl mx-auto">
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
<div class="px-6 py-4 border-b border-gray-200">
<h2 class="text-lg font-semibold text-gray-900 flex items-center">
<i class="fas fa-globe text-gray-400 mr-2 text-sm"></i>
Domain Information
</h2>
</div>
<div class="p-6">
<form method="POST" action="/domains/store" class="space-y-5">
<!-- Domain Name -->
<div>
<label for="domain_name" class="block text-sm font-medium text-gray-700 mb-1.5">
Domain Name *
</label>
<input type="text"
id="domain_name"
name="domain_name"
class="w-full px-3 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary transition-colors text-sm"
placeholder="example.com"
required
autofocus>
<p class="mt-1.5 text-xs text-gray-500">
Enter the domain name without http:// or https://
</p>
</div>
<!-- Notification Group -->
<div>
<label for="notification_group_id" class="block text-sm font-medium text-gray-700 mb-1.5">
Notification Group
</label>
<select id="notification_group_id"
name="notification_group_id"
class="w-full px-3 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary transition-colors text-sm">
<option value="">-- No Group (No notifications) --</option>
<?php foreach ($groups as $group): ?>
<option value="<?= $group['id'] ?>"><?= htmlspecialchars($group['name']) ?></option>
<?php endforeach; ?>
</select>
<p class="mt-1.5 text-xs text-gray-500">
Optional: Assign to a notification group to receive expiry alerts
</p>
</div>
<!-- Action Buttons -->
<div class="flex flex-col sm:flex-row gap-3 pt-3">
<button type="submit"
class="inline-flex items-center justify-center px-5 py-2.5 bg-primary hover:bg-primary-dark text-white rounded-lg font-medium transition-colors text-sm">
<i class="fas fa-plus-circle mr-2"></i>
Add Domain
</button>
<a href="/domains"
class="inline-flex items-center justify-center px-5 py-2.5 border border-gray-300 text-gray-700 rounded-lg font-medium hover:bg-gray-50 transition-colors text-sm">
<i class="fas fa-times mr-2"></i>
Cancel
</a>
</div>
</form>
</div>
</div>
<!-- Info Cards -->
<div class="mt-4 grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- How it works -->
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4">
<div class="flex items-start">
<div class="flex-shrink-0">
<div class="w-10 h-10 bg-blue-500 rounded-lg flex items-center justify-center">
<i class="fas fa-info-circle text-white"></i>
</div>
</div>
<div class="ml-3">
<h3 class="text-sm font-semibold text-gray-900 mb-1">How It Works</h3>
<p class="text-xs text-gray-600 leading-relaxed">
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.
</p>
</div>
</div>
</div>
<!-- What we track -->
<div class="bg-green-50 border border-green-200 rounded-lg p-4">
<div class="flex items-start">
<div class="flex-shrink-0">
<div class="w-10 h-10 bg-green-500 rounded-lg flex items-center justify-center">
<i class="fas fa-check-circle text-white"></i>
</div>
</div>
<div class="ml-3">
<h3 class="text-sm font-semibold text-gray-900 mb-1">What We Track</h3>
<ul class="text-xs text-gray-600 space-y-1">
<li class="flex items-center">
<i class="fas fa-circle text-green-500" style="font-size: 6px;"></i>
<span class="ml-2">Domain expiration date</span>
</li>
<li class="flex items-center">
<i class="fas fa-circle text-green-500" style="font-size: 6px;"></i>
<span class="ml-2">Registrar information</span>
</li>
<li class="flex items-center">
<i class="fas fa-circle text-green-500" style="font-size: 6px;"></i>
<span class="ml-2">Nameservers</span>
</li>
<li class="flex items-center">
<i class="fas fa-circle text-green-500" style="font-size: 6px;"></i>
<span class="ml-2">Domain status</span>
</li>
</ul>
</div>
</div>
</div>
</div>
</div>
<?php
$content = ob_get_clean();
include __DIR__ . '/../layout/base.php';
?>

120
app/Views/domains/edit.php Normal file
View File

@@ -0,0 +1,120 @@
<?php
$title = 'Edit Domain';
$pageTitle = 'Edit Domain';
$pageDescription = htmlspecialchars($domain['domain_name']);
$pageIcon = 'fas fa-edit';
ob_start();
?>
<!-- Main Form -->
<div class="max-w-3xl mx-auto">
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
<div class="px-6 py-4 border-b border-gray-200">
<h2 class="text-lg font-semibold text-gray-900 flex items-center">
<i class="fas fa-cog text-gray-400 mr-2 text-sm"></i>
Domain Settings
</h2>
</div>
<div class="p-6">
<form method="POST" action="/domains/<?= $domain['id'] ?>/update" class="space-y-5">
<!-- Domain Name (Read-only) -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1.5">
Domain Name
</label>
<div class="relative">
<input type="text"
class="w-full px-3 py-2.5 border border-gray-300 rounded-lg bg-gray-50 text-gray-600 cursor-not-allowed text-sm"
value="<?= htmlspecialchars($domain['domain_name']) ?>"
disabled>
<div class="absolute right-3 top-1/2 transform -translate-y-1/2">
<i class="fas fa-lock text-gray-400 text-xs"></i>
</div>
</div>
<p class="mt-1.5 text-xs text-gray-500">
Domain name cannot be changed after creation
</p>
</div>
<!-- Notification Group -->
<div>
<label for="notification_group_id" class="block text-sm font-medium text-gray-700 mb-1.5">
Notification Group
</label>
<select id="notification_group_id"
name="notification_group_id"
class="w-full px-3 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary transition-colors text-sm">
<option value="">-- No Group (No notifications) --</option>
<?php foreach ($groups as $group): ?>
<option value="<?= $group['id'] ?>"
<?= $domain['notification_group_id'] == $group['id'] ? 'selected' : '' ?>>
<?= htmlspecialchars($group['name']) ?>
</option>
<?php endforeach; ?>
</select>
<p class="mt-1.5 text-xs text-gray-500">
Change the notification group or remove it to stop receiving alerts
</p>
</div>
<!-- Active Monitoring -->
<div class="bg-gray-50 rounded-lg p-4 border border-gray-200">
<label class="flex items-center cursor-pointer">
<input type="checkbox"
name="is_active"
<?= $domain['is_active'] ? 'checked' : '' ?>
class="w-4 h-4 text-primary border-gray-300 rounded focus:ring-primary cursor-pointer">
<div class="ml-3">
<span class="text-sm font-medium text-gray-900">Enable Active Monitoring</span>
<p class="text-xs text-gray-600 mt-0.5">When enabled, this domain will be checked regularly and notifications will be sent</p>
</div>
</label>
</div>
<!-- Action Buttons -->
<div class="flex flex-col sm:flex-row gap-3 pt-3">
<button type="submit"
class="inline-flex items-center justify-center px-5 py-2.5 bg-primary hover:bg-primary-dark text-white rounded-lg font-medium transition-colors text-sm">
<i class="fas fa-save mr-2"></i>
Update Domain
</button>
<a href="/domains/<?= $domain['id'] ?>"
class="inline-flex items-center justify-center px-5 py-2.5 border border-gray-300 text-gray-700 rounded-lg font-medium hover:bg-gray-50 transition-colors text-sm">
<i class="fas fa-times mr-2"></i>
Cancel
</a>
</div>
</form>
</div>
</div>
<!-- Quick Actions -->
<div class="mt-4 grid grid-cols-1 md:grid-cols-3 gap-3">
<a href="/domains/<?= $domain['id'] ?>"
class="flex items-center justify-center p-3 bg-white border border-gray-200 rounded-lg hover:border-blue-300 hover:bg-blue-50 transition-colors group">
<i class="fas fa-eye text-blue-600 mr-2 text-sm"></i>
<span class="text-sm font-medium text-gray-700">View Details</span>
</a>
<form method="POST" action="/domains/<?= $domain['id'] ?>/refresh" class="m-0">
<button type="submit"
class="w-full flex items-center justify-center p-3 bg-white border border-gray-200 rounded-lg hover:border-green-300 hover:bg-green-50 transition-colors group">
<i class="fas fa-sync-alt text-green-600 mr-2 text-sm"></i>
<span class="text-sm font-medium text-gray-700">Refresh WHOIS</span>
</button>
</form>
<form method="POST" action="/domains/<?= $domain['id'] ?>/delete" onsubmit="return confirm('Delete this domain permanently?')" class="m-0">
<button type="submit"
class="w-full flex items-center justify-center p-3 bg-white border border-gray-200 rounded-lg hover:border-red-300 hover:bg-red-50 transition-colors group">
<i class="fas fa-trash text-red-600 mr-2 text-sm"></i>
<span class="text-sm font-medium text-gray-700">Delete Domain</span>
</button>
</form>
</div>
</div>
<?php
$content = ob_get_clean();
include __DIR__ . '/../layout/base.php';
?>

688
app/Views/domains/index.php Normal file
View File

@@ -0,0 +1,688 @@
<?php
$title = 'Domains';
$pageTitle = 'Domain Management';
$pageDescription = 'Monitor and manage your domain portfolio';
$pageIcon = 'fas fa-globe';
ob_start();
// Helper function to generate sort URL
function sortUrl($column, $currentSort, $currentOrder) {
$newOrder = ($currentSort === $column && $currentOrder === 'asc') ? 'desc' : 'asc';
$params = $_GET;
$params['sort'] = $column;
$params['order'] = $newOrder;
return '/domains?' . http_build_query($params);
}
// Helper function for sort icon
function sortIcon($column, $currentSort, $currentOrder) {
if ($currentSort !== $column) {
return '<i class="fas fa-sort text-gray-400 ml-1 text-xs"></i>';
}
$icon = $currentOrder === 'asc' ? 'fa-sort-up' : 'fa-sort-down';
return '<i class="fas ' . $icon . ' text-primary ml-1 text-xs"></i>';
}
// Get current filters
$currentFilters = $filters ?? ['search' => '', 'status' => '', 'group' => '', 'sort' => 'domain_name', 'order' => 'asc'];
?>
<!-- Action Buttons -->
<div class="mb-4 flex flex-wrap gap-2 justify-between items-center">
<div class="flex gap-2">
<!-- Bulk Actions Toolbar (Hidden by default, shown when domains are selected) -->
<div id="bulk-actions" class="hidden items-center gap-2">
<span id="selected-count" class="text-sm font-medium text-gray-700"></span>
<button type="button" onclick="bulkRefresh()" class="inline-flex items-center px-4 py-2 bg-green-600 text-white text-sm rounded-lg hover:bg-green-700 transition-colors font-medium">
<i class="fas fa-sync-alt mr-2"></i>
Refresh
</button>
<div class="relative inline-block">
<button type="button" onclick="toggleAssignGroupDropdown()" class="inline-flex items-center px-4 py-2 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-700 transition-colors font-medium">
<i class="fas fa-bell mr-2"></i>
Assign Group
<i class="fas fa-chevron-down ml-2 text-xs"></i>
</button>
<div id="assign-group-dropdown" class="hidden absolute left-0 mt-2 w-64 bg-white rounded-lg shadow-lg border border-gray-200 z-10">
<form method="POST" action="/domains/bulk-assign-group" id="bulk-assign-form">
<input type="hidden" name="domain_ids" id="bulk-assign-ids">
<div class="p-3">
<select name="group_id" class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm">
<option value="">-- No Group --</option>
<?php foreach ($groups as $group): ?>
<option value="<?= $group['id'] ?>"><?= htmlspecialchars($group['name']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="border-t border-gray-200 p-2 flex gap-2">
<button type="submit" class="flex-1 px-3 py-1.5 bg-primary text-white text-xs rounded hover:bg-primary-dark">
Assign
</button>
<button type="button" onclick="toggleAssignGroupDropdown()" class="flex-1 px-3 py-1.5 bg-gray-200 text-gray-700 text-xs rounded hover:bg-gray-300">
Cancel
</button>
</div>
</form>
</div>
</div>
<button type="button" onclick="bulkDelete()" class="inline-flex items-center px-4 py-2 bg-red-600 text-white text-sm rounded-lg hover:bg-red-700 transition-colors font-medium">
<i class="fas fa-trash mr-2"></i>
Delete
</button>
<button type="button" onclick="clearSelection()" class="inline-flex items-center px-4 py-2 border border-gray-300 text-gray-700 text-sm rounded-lg hover:bg-gray-50 transition-colors font-medium">
<i class="fas fa-times mr-2"></i>
Clear
</button>
</div>
</div>
<div class="flex gap-2">
<?php if (!empty($domains)): ?>
<form method="POST" action="/domains/bulk-refresh" id="refresh-all-form">
<?php foreach ($domains as $domain): ?>
<input type="hidden" name="domain_ids[]" value="<?= $domain['id'] ?>">
<?php endforeach; ?>
<button type="submit" class="inline-flex items-center px-4 py-2 bg-green-600 text-white text-sm rounded-lg hover:bg-green-700 transition-colors font-medium" title="Refresh all domains on this page">
<i class="fas fa-sync-alt mr-2"></i>
Refresh Page (<?= count($domains) ?>)
</button>
</form>
<?php endif; ?>
<a href="/domains/bulk-add" class="inline-flex items-center px-4 py-2 bg-purple-600 text-white text-sm rounded-lg hover:bg-purple-700 transition-colors font-medium">
<i class="fas fa-layer-group mr-2"></i>
Bulk Add
</a>
<a href="/domains/create" class="inline-flex items-center px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors text-sm font-medium">
<i class="fas fa-plus mr-2"></i>
Add Domain
</a>
</div>
</div>
<!-- Filters & Search -->
<div class="bg-white rounded-lg border border-gray-200 p-5 mb-4">
<form method="GET" action="/domains" id="filter-form">
<div class="grid grid-cols-1 md:grid-cols-4 gap-3">
<div>
<label class="block text-xs font-medium text-gray-700 mb-1.5">Search</label>
<div class="relative">
<input type="text" name="search" id="domainSearch" value="<?= htmlspecialchars($currentFilters['search']) ?>" placeholder="Search domains..." class="w-full pl-9 pr-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary text-sm">
<i class="fas fa-search absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 text-xs"></i>
</div>
</div>
<div>
<label class="block text-xs font-medium text-gray-700 mb-1.5">Status</label>
<select name="status" id="statusFilter" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary text-sm">
<option value="">All Statuses</option>
<option value="active" <?= $currentFilters['status'] === 'active' ? 'selected' : '' ?>>Active</option>
<option value="expiring_soon" <?= $currentFilters['status'] === 'expiring_soon' ? 'selected' : '' ?>>Expiring Soon</option>
<option value="inactive" <?= $currentFilters['status'] === 'inactive' ? 'selected' : '' ?>>Inactive</option>
</select>
</div>
<div>
<label class="block text-xs font-medium text-gray-700 mb-1.5">Group</label>
<select name="group" id="groupFilter" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary text-sm">
<option value="">All Groups</option>
<?php foreach ($groups as $group): ?>
<option value="<?= $group['id'] ?>" <?= $currentFilters['group'] == $group['id'] ? 'selected' : '' ?>><?= htmlspecialchars($group['name']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="flex items-end">
<button type="submit" class="w-full px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors text-sm font-medium">
<i class="fas fa-filter mr-2"></i>
Apply Filters
</button>
</div>
</div>
<input type="hidden" name="sort" value="<?= htmlspecialchars($currentFilters['sort']) ?>">
<input type="hidden" name="order" value="<?= htmlspecialchars($currentFilters['order']) ?>">
</form>
</div>
<!-- Pagination Info & Per Page Selector -->
<div class="mb-4 flex justify-between items-center">
<div class="text-sm text-gray-600">
Showing <span class="font-semibold text-gray-900"><?= $pagination['showing_from'] ?></span> to
<span class="font-semibold text-gray-900"><?= $pagination['showing_to'] ?></span> of
<span class="font-semibold text-gray-900"><?= $pagination['total'] ?></span> domain(s)
</div>
<form method="GET" action="/domains" class="flex items-center gap-2">
<!-- Preserve current filters -->
<input type="hidden" name="search" value="<?= htmlspecialchars($currentFilters['search']) ?>">
<input type="hidden" name="status" value="<?= htmlspecialchars($currentFilters['status']) ?>">
<input type="hidden" name="group" value="<?= htmlspecialchars($currentFilters['group']) ?>">
<input type="hidden" name="sort" value="<?= htmlspecialchars($currentFilters['sort']) ?>">
<input type="hidden" name="order" value="<?= htmlspecialchars($currentFilters['order']) ?>">
<label for="per_page" class="text-sm text-gray-600">Show:</label>
<select name="per_page" id="per_page" onchange="this.form.submit()" class="px-3 py-1.5 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-primary focus:border-primary">
<option value="10" <?= $pagination['per_page'] == 10 ? 'selected' : '' ?>>10</option>
<option value="25" <?= $pagination['per_page'] == 25 ? 'selected' : '' ?>>25</option>
<option value="50" <?= $pagination['per_page'] == 50 ? 'selected' : '' ?>>50</option>
<option value="100" <?= $pagination['per_page'] == 100 ? 'selected' : '' ?>>100</option>
</select>
</form>
</div>
<!-- Domains List -->
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
<?php if (!empty($domains)): ?>
<!-- Table View (Desktop) -->
<div class="hidden lg:block overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-4 py-3 text-left w-12">
<input type="checkbox" id="select-all" onclick="toggleSelectAll(this)" class="w-4 h-4 text-primary border-gray-300 rounded focus:ring-primary cursor-pointer">
</th>
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">
<a href="<?= sortUrl('domain_name', $currentFilters['sort'], $currentFilters['order']) ?>" class="hover:text-primary flex items-center">
Domain <?= sortIcon('domain_name', $currentFilters['sort'], $currentFilters['order']) ?>
</a>
</th>
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">
<a href="<?= sortUrl('registrar', $currentFilters['sort'], $currentFilters['order']) ?>" class="hover:text-primary flex items-center">
Registrar <?= sortIcon('registrar', $currentFilters['sort'], $currentFilters['order']) ?>
</a>
</th>
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">
<a href="<?= sortUrl('expiration_date', $currentFilters['sort'], $currentFilters['order']) ?>" class="hover:text-primary flex items-center">
Expiration <?= sortIcon('expiration_date', $currentFilters['sort'], $currentFilters['order']) ?>
</a>
</th>
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">
<a href="<?= sortUrl('status', $currentFilters['sort'], $currentFilters['order']) ?>" class="hover:text-primary flex items-center">
Status <?= sortIcon('status', $currentFilters['sort'], $currentFilters['order']) ?>
</a>
</th>
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">
<a href="<?= sortUrl('group_name', $currentFilters['sort'], $currentFilters['order']) ?>" class="hover:text-primary flex items-center">
Group <?= sortIcon('group_name', $currentFilters['sort'], $currentFilters['order']) ?>
</a>
</th>
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">
<a href="<?= sortUrl('last_checked', $currentFilters['sort'], $currentFilters['order']) ?>" class="hover:text-primary flex items-center">
Last Checked <?= sortIcon('last_checked', $currentFilters['sort'], $currentFilters['order']) ?>
</a>
</th>
<th class="px-6 py-3 text-right text-xs font-semibold text-gray-600 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
<?php foreach ($domains as $domain): ?>
<?php
// Calculate days until expiry and determine status color
$daysLeft = !empty($domain['expiration_date']) ? floor((strtotime($domain['expiration_date']) - time()) / 86400) : null;
$expiryClass = '';
if ($daysLeft !== null) {
if ($daysLeft < 0) {
$expiryClass = 'text-red-600 font-semibold';
} elseif ($daysLeft <= 30) {
$expiryClass = 'text-orange-600 font-semibold';
} elseif ($daysLeft <= 90) {
$expiryClass = 'text-yellow-600';
}
}
// Recalculate domain status if it's empty or error (for backward compatibility)
$domainStatus = $domain['status'];
if (empty($domainStatus) || $domainStatus === 'error') {
$whoisData = json_decode($domain['whois_data'] ?? '{}', true);
$statusArray = $whoisData['status'] ?? [];
$isAvailable = false;
foreach ($statusArray as $status) {
if (stripos($status, 'AVAILABLE') !== false || stripos($status, 'FREE') !== false) {
$isAvailable = true;
break;
}
}
if ($isAvailable) {
$domainStatus = 'available';
} elseif ($daysLeft !== null) {
if ($daysLeft < 0) {
$domainStatus = 'expired';
} elseif ($daysLeft <= 30) {
$domainStatus = 'expiring_soon';
} else {
$domainStatus = 'active';
}
} else {
$domainStatus = 'error';
}
}
// Status badge color
if ($domainStatus === 'available') {
$statusClass = 'bg-blue-100 text-blue-700 border-blue-200';
$statusText = 'Available';
$statusIcon = 'fa-info-circle';
} elseif ($daysLeft !== null && $daysLeft <= 30 && $daysLeft >= 0) {
$statusClass = 'bg-orange-100 text-orange-700 border-orange-200';
$statusText = 'Expiring Soon';
$statusIcon = 'fa-exclamation-triangle';
} elseif ($domainStatus === 'active') {
$statusClass = 'bg-green-100 text-green-700 border-green-200';
$statusText = 'Active';
$statusIcon = 'fa-check-circle';
} elseif ($domainStatus === 'expired') {
$statusClass = 'bg-red-100 text-red-700 border-red-200';
$statusText = 'Expired';
$statusIcon = 'fa-times-circle';
} elseif ($domainStatus === 'error') {
$statusClass = 'bg-gray-100 text-gray-700 border-gray-200';
$statusText = 'Error';
$statusIcon = 'fa-exclamation-circle';
} else {
$statusClass = 'bg-gray-100 text-gray-700 border-gray-200';
$statusText = ucfirst($domainStatus);
$statusIcon = 'fa-times-circle';
}
?>
<tr class="hover:bg-gray-50 transition-colors duration-150 domain-row">
<td class="px-4 py-4">
<input type="checkbox" class="domain-checkbox w-4 h-4 text-primary border-gray-300 rounded focus:ring-primary cursor-pointer" value="<?= $domain['id'] ?>">
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div class="flex items-center">
<div class="flex-shrink-0 h-10 w-10 bg-primary bg-opacity-10 rounded-lg flex items-center justify-center">
<i class="fas fa-globe text-primary"></i>
</div>
<div class="ml-4">
<a href="/domains/<?= $domain['id'] ?>" class="text-sm font-semibold text-gray-900 hover:text-primary"><?= htmlspecialchars($domain['domain_name']) ?></a>
<?php if (!empty($domain['nameservers'])): ?>
<div class="text-xs text-gray-500">NS: <?= htmlspecialchars(explode(',', $domain['nameservers'])[0]) ?></div>
<?php endif; ?>
</div>
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<?php if (!empty($domain['registrar'])): ?>
<div class="flex items-center">
<i class="fas fa-building text-gray-400 mr-2"></i>
<span class="text-sm text-gray-900"><?= htmlspecialchars($domain['registrar']) ?></span>
</div>
<?php else: ?>
<span class="text-sm text-gray-400">Unknown</span>
<?php endif; ?>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<?php if (!empty($domain['expiration_date'])): ?>
<div class="text-sm">
<div class="font-medium text-gray-900"><?= date('M d, Y', strtotime($domain['expiration_date'])) ?></div>
<div class="text-xs <?= $expiryClass ?>">
<?= $daysLeft ?> days
</div>
</div>
<?php else: ?>
<span class="text-sm text-gray-400">Not set</span>
<?php endif; ?>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold border <?= $statusClass ?>">
<i class="fas <?= $statusIcon ?> mr-1"></i>
<?= $statusText ?>
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
<?php if (!empty($domain['group_name'])): ?>
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
<i class="fas fa-bell mr-1"></i>
<?= htmlspecialchars($domain['group_name']) ?>
</span>
<?php else: ?>
<span class="text-gray-400">No Group</span>
<?php endif; ?>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
<?php if (!empty($domain['last_checked'])): ?>
<div class="flex items-center">
<i class="far fa-clock mr-2"></i>
<?= date('M d, H:i', strtotime($domain['last_checked'])) ?>
</div>
<?php else: ?>
<span class="text-gray-400">Never</span>
<?php endif; ?>
</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<div class="flex items-center justify-end space-x-2">
<a href="/domains/<?= $domain['id'] ?>" class="text-blue-600 hover:text-blue-800" title="View">
<i class="fas fa-eye"></i>
</a>
<form method="POST" action="/domains/<?= $domain['id'] ?>/refresh" class="inline">
<button type="submit" class="text-green-600 hover:text-green-800" title="Refresh WHOIS">
<i class="fas fa-sync-alt"></i>
</button>
</form>
<a href="/domains/<?= $domain['id'] ?>/edit" class="text-yellow-600 hover:text-yellow-800" title="Edit">
<i class="fas fa-edit"></i>
</a>
<form method="POST" action="/domains/<?= $domain['id'] ?>/delete" class="inline" onsubmit="return confirm('Delete this domain?')">
<button type="submit" class="text-red-600 hover:text-red-800" title="Delete">
<i class="fas fa-trash"></i>
</button>
</form>
</div>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<!-- Card View (Mobile) - Simplified for brevity -->
<div class="lg:hidden divide-y divide-gray-200">
<?php foreach ($domains as $domain): ?>
<div class="p-6 hover:bg-gray-50 transition-colors duration-150">
<div class="flex items-center mb-3">
<input type="checkbox" class="domain-checkbox-mobile w-4 h-4 text-primary border-gray-300 rounded focus:ring-primary cursor-pointer mr-3" value="<?= $domain['id'] ?>">
<a href="/domains/<?= $domain['id'] ?>" class="text-lg font-semibold text-gray-900 hover:text-primary"><?= htmlspecialchars($domain['domain_name']) ?></a>
</div>
<!-- Add mobile view content here if needed -->
</div>
<?php endforeach; ?>
</div>
<?php else: ?>
<div class="text-center py-12 px-6">
<div class="mb-4">
<i class="fas fa-globe text-gray-300 text-6xl"></i>
</div>
<h3 class="text-lg font-semibold text-gray-700 mb-1">No Domains Yet</h3>
<p class="text-sm text-gray-500 mb-4">Start monitoring your domains by adding your first one</p>
<a href="/domains/create" class="inline-flex items-center px-5 py-2.5 bg-primary text-white text-sm rounded-lg hover:bg-primary-dark transition-colors font-medium">
<i class="fas fa-plus mr-2"></i>
<span>Add Your First Domain</span>
</a>
</div>
<?php endif; ?>
</div>
<!-- Pagination Controls -->
<?php if ($pagination['total_pages'] > 1): ?>
<div class="mt-4 flex flex-col sm:flex-row items-center justify-between gap-4">
<!-- Page Info -->
<div class="text-sm text-gray-600">
Page <span class="font-semibold text-gray-900"><?= $pagination['current_page'] ?></span> of
<span class="font-semibold text-gray-900"><?= $pagination['total_pages'] ?></span>
</div>
<!-- Pagination Buttons -->
<div class="flex items-center gap-1">
<?php
$currentPage = $pagination['current_page'];
$totalPages = $pagination['total_pages'];
// Helper function to build pagination URL
function paginationUrl($page, $filters, $perPage) {
$params = $filters;
$params['page'] = $page;
$params['per_page'] = $perPage;
return '/domains?' . http_build_query($params);
}
?>
<!-- First Page -->
<?php if ($currentPage > 1): ?>
<a href="<?= paginationUrl(1, $currentFilters, $pagination['per_page']) ?>" class="px-3 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors">
<i class="fas fa-angle-double-left"></i>
</a>
<?php endif; ?>
<!-- Previous Page -->
<?php if ($currentPage > 1): ?>
<a href="<?= paginationUrl($currentPage - 1, $currentFilters, $pagination['per_page']) ?>" class="px-3 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors">
<i class="fas fa-angle-left"></i> Previous
</a>
<?php endif; ?>
<!-- Page Numbers -->
<?php
$range = 2; // Show 2 pages on each side of current page
$start = max(1, $currentPage - $range);
$end = min($totalPages, $currentPage + $range);
// Show first page + ellipsis if needed
if ($start > 1) {
echo '<a href="' . paginationUrl(1, $currentFilters, $pagination['per_page']) . '" class="px-3 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors">1</a>';
if ($start > 2) {
echo '<span class="px-2 text-gray-500">...</span>';
}
}
// Page numbers
for ($i = $start; $i <= $end; $i++) {
if ($i == $currentPage) {
echo '<span class="px-3 py-2 text-sm bg-primary text-white rounded-lg font-semibold">' . $i . '</span>';
} else {
echo '<a href="' . paginationUrl($i, $currentFilters, $pagination['per_page']) . '" class="px-3 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors">' . $i . '</a>';
}
}
// Show last page + ellipsis if needed
if ($end < $totalPages) {
if ($end < $totalPages - 1) {
echo '<span class="px-2 text-gray-500">...</span>';
}
echo '<a href="' . paginationUrl($totalPages, $currentFilters, $pagination['per_page']) . '" class="px-3 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors">' . $totalPages . '</a>';
}
?>
<!-- Next Page -->
<?php if ($currentPage < $totalPages): ?>
<a href="<?= paginationUrl($currentPage + 1, $currentFilters, $pagination['per_page']) ?>" class="px-3 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors">
Next <i class="fas fa-angle-right"></i>
</a>
<?php endif; ?>
<!-- Last Page -->
<?php if ($currentPage < $totalPages): ?>
<a href="<?= paginationUrl($totalPages, $currentFilters, $pagination['per_page']) ?>" class="px-3 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors">
<i class="fas fa-angle-double-right"></i>
</a>
<?php endif; ?>
</div>
</div>
<?php endif; ?>
<script>
// Multi-select functionality
function toggleSelectAll(checkbox) {
// Only select checkboxes that are currently visible
const desktopCheckboxes = document.querySelectorAll('.domain-checkbox');
const mobileCheckboxes = document.querySelectorAll('.domain-checkbox-mobile');
// Check if desktop view is visible (lg:block class)
const desktopTable = document.querySelector('.hidden.lg\\:block');
const isDesktopVisible = desktopTable && !desktopTable.classList.contains('hidden');
if (isDesktopVisible) {
// Desktop view is visible, select desktop checkboxes
desktopCheckboxes.forEach(cb => cb.checked = checkbox.checked);
} else {
// Mobile view is visible, select mobile checkboxes
mobileCheckboxes.forEach(cb => cb.checked = checkbox.checked);
}
updateBulkActions();
}
function updateBulkActions() {
// Only count checkboxes that are currently visible
const desktopCheckboxes = document.querySelectorAll('.domain-checkbox:checked');
const mobileCheckboxes = document.querySelectorAll('.domain-checkbox-mobile:checked');
// Check if desktop view is visible
const desktopTable = document.querySelector('.hidden.lg\\:block');
const isDesktopVisible = desktopTable && !desktopTable.classList.contains('hidden');
const checkboxes = isDesktopVisible ? desktopCheckboxes : mobileCheckboxes;
const bulkActions = document.getElementById('bulk-actions');
const selectedCount = document.getElementById('selected-count');
if (checkboxes.length > 0) {
bulkActions.classList.remove('hidden');
bulkActions.classList.add('flex');
selectedCount.textContent = `${checkboxes.length} selected`;
} else {
bulkActions.classList.add('hidden');
bulkActions.classList.remove('flex');
}
}
function clearSelection() {
const desktopCheckboxes = document.querySelectorAll('.domain-checkbox');
const mobileCheckboxes = document.querySelectorAll('.domain-checkbox-mobile');
// Check if desktop view is visible
const desktopTable = document.querySelector('.hidden.lg\\:block');
const isDesktopVisible = desktopTable && !desktopTable.classList.contains('hidden');
if (isDesktopVisible) {
desktopCheckboxes.forEach(cb => cb.checked = false);
} else {
mobileCheckboxes.forEach(cb => cb.checked = false);
}
document.getElementById('select-all').checked = false;
updateBulkActions();
}
function getSelectedIds() {
// Only get IDs from currently visible checkboxes
const desktopCheckboxes = document.querySelectorAll('.domain-checkbox:checked');
const mobileCheckboxes = document.querySelectorAll('.domain-checkbox-mobile:checked');
// Check if desktop view is visible
const desktopTable = document.querySelector('.hidden.lg\\:block');
const isDesktopVisible = desktopTable && !desktopTable.classList.contains('hidden');
const checkboxes = isDesktopVisible ? desktopCheckboxes : mobileCheckboxes;
return Array.from(checkboxes).map(cb => cb.value);
}
function bulkRefresh() {
const ids = getSelectedIds();
if (ids.length === 0) return;
const form = document.createElement('form');
form.method = 'POST';
form.action = '/domains/bulk-refresh';
ids.forEach(id => {
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'domain_ids[]';
input.value = id;
form.appendChild(input);
});
document.body.appendChild(form);
form.submit();
}
function bulkDelete() {
const ids = getSelectedIds();
if (ids.length === 0) return;
if (!confirm(`Delete ${ids.length} domain(s)? This action cannot be undone.`)) {
return;
}
const form = document.createElement('form');
form.method = 'POST';
form.action = '/domains/bulk-delete';
ids.forEach(id => {
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'domain_ids[]';
input.value = id;
form.appendChild(input);
});
document.body.appendChild(form);
form.submit();
}
function toggleAssignGroupDropdown() {
const dropdown = document.getElementById('assign-group-dropdown');
dropdown.classList.toggle('hidden');
}
// Update bulk assign form with selected IDs
document.getElementById('bulk-assign-form')?.addEventListener('submit', function(e) {
const ids = getSelectedIds();
const container = this;
// Clear existing hidden inputs
container.querySelectorAll('input[name="domain_ids[]"]').forEach(el => el.remove());
// Add selected IDs
ids.forEach(id => {
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'domain_ids[]';
input.value = id;
container.appendChild(input);
});
});
// Listen to checkbox changes
document.querySelectorAll('.domain-checkbox, .domain-checkbox-mobile').forEach(checkbox => {
checkbox.addEventListener('change', function() {
// Update the select-all checkbox state
const desktopCheckboxes = document.querySelectorAll('.domain-checkbox');
const mobileCheckboxes = document.querySelectorAll('.domain-checkbox-mobile');
// Check if desktop view is visible
const desktopTable = document.querySelector('.hidden.lg\\:block');
const isDesktopVisible = desktopTable && !desktopTable.classList.contains('hidden');
const checkboxes = isDesktopVisible ? desktopCheckboxes : mobileCheckboxes;
const checkedBoxes = isDesktopVisible ?
document.querySelectorAll('.domain-checkbox:checked') :
document.querySelectorAll('.domain-checkbox-mobile:checked');
const selectAllCheckbox = document.getElementById('select-all');
if (checkedBoxes.length === 0) {
selectAllCheckbox.checked = false;
selectAllCheckbox.indeterminate = false;
} else if (checkedBoxes.length === checkboxes.length) {
selectAllCheckbox.checked = true;
selectAllCheckbox.indeterminate = false;
} else {
selectAllCheckbox.checked = false;
selectAllCheckbox.indeterminate = true;
}
updateBulkActions();
});
});
// Close dropdown when clicking outside
document.addEventListener('click', function(event) {
const dropdown = document.getElementById('assign-group-dropdown');
const button = event.target.closest('button[onclick="toggleAssignGroupDropdown()"]');
if (!button && !dropdown.contains(event.target)) {
dropdown?.classList.add('hidden');
}
});
// Handle window resize to sync checkboxes when switching between desktop/mobile views
window.addEventListener('resize', function() {
// Small delay to allow CSS classes to update
setTimeout(updateBulkActions, 100);
});
</script>
<?php
$content = ob_get_clean();
include __DIR__ . '/../layout/base.php';
?>

461
app/Views/domains/view.php Normal file
View File

@@ -0,0 +1,461 @@
<?php
$title = 'Domain Details';
$pageTitle = htmlspecialchars($domain['domain_name']);
$pageDescription = 'Domain information and monitoring status';
$pageIcon = 'fas fa-globe';
$whoisData = json_decode($domain['whois_data'] ?? '{}', true);
$daysLeft = !empty($domain['expiration_date']) ? floor((strtotime($domain['expiration_date']) - time()) / 86400) : null;
// Recalculate domain status if it's empty or error (for backward compatibility)
$domainStatus = $domain['status'];
if (empty($domainStatus) || $domainStatus === 'error') {
// Check WHOIS data for AVAILABLE status
$statusArray = $whoisData['status'] ?? [];
$isAvailable = false;
foreach ($statusArray as $status) {
if (stripos($status, 'AVAILABLE') !== false || stripos($status, 'FREE') !== false) {
$isAvailable = true;
break;
}
}
if ($isAvailable) {
$domainStatus = 'available';
} elseif ($daysLeft !== null) {
if ($daysLeft < 0) {
$domainStatus = 'expired';
} elseif ($daysLeft <= 30) {
$domainStatus = 'expiring_soon';
} else {
$domainStatus = 'active';
}
} else {
$domainStatus = 'error';
}
}
// Determine expiry color
$expiryColor = 'green';
if ($daysLeft !== null) {
if ($daysLeft < 0) $expiryColor = 'red';
elseif ($daysLeft <= 30) $expiryColor = 'orange';
elseif ($daysLeft <= 90) $expiryColor = 'yellow';
}
ob_start();
?>
<!-- Top Action Bar -->
<div class="mb-3 flex flex-wrap gap-2 justify-between items-center">
<div class="flex gap-2">
<?php
// Determine domain status badge
if ($domainStatus === 'available') {
$statusClass = 'bg-blue-100 text-blue-700 border-blue-200';
$statusText = 'Available (Not Registered)';
$statusIcon = 'fa-info-circle';
} elseif ($domainStatus === 'expired') {
$statusClass = 'bg-red-100 text-red-700 border-red-200';
$statusText = 'Expired';
$statusIcon = 'fa-times-circle';
} elseif ($domainStatus === 'expiring_soon' || ($daysLeft !== null && $daysLeft <= 30 && $daysLeft >= 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';
}
?>
<span class="inline-flex items-center px-3 py-1.5 rounded-lg text-xs font-semibold <?= $statusClass ?>">
<i class="fas <?= $statusIcon ?> mr-1.5"></i>
<?= $statusText ?>
</span>
<?php if ($domainStatus !== 'available'): ?>
<span class="inline-flex items-center px-3 py-1.5 rounded-lg text-xs font-semibold bg-<?= $expiryColor ?>-100 text-<?= $expiryColor ?>-800 border border-<?= $expiryColor ?>-200">
<i class="fas fa-calendar-alt mr-1.5"></i>
<?= $daysLeft !== null ? $daysLeft . ' days left' : 'No expiry date' ?>
</span>
<?php endif; ?>
<span class="inline-flex items-center px-3 py-1.5 rounded-lg text-xs font-semibold bg-purple-100 text-purple-800 border border-purple-200">
<i class="fas fa-<?= $domain['is_active'] ? 'check-circle' : 'pause-circle' ?> mr-1.5"></i>
<?= $domain['is_active'] ? 'Monitoring Active' : 'Monitoring Paused' ?>
</span>
</div>
<div class="flex gap-2 items-center">
<form method="POST" action="/domains/<?= $domain['id'] ?>/refresh" class="inline">
<button type="submit" class="inline-flex items-center justify-center px-3 py-2 bg-green-600 text-white text-xs rounded-lg hover:bg-green-700 transition-colors font-medium min-w-[80px] h-[32px]">
<i class="fas fa-sync-alt mr-1.5"></i>
Refresh
</button>
</form>
<a href="/domains/<?= $domain['id'] ?>/edit" class="inline-flex items-center justify-center px-3 py-2 bg-blue-600 text-white text-xs rounded-lg hover:bg-blue-700 transition-colors font-medium min-w-[80px] h-[32px]">
<i class="fas fa-edit mr-1.5"></i>
Edit
</a>
<form method="POST" action="/domains/<?= $domain['id'] ?>/delete" onsubmit="return confirm('Delete this domain?')" class="inline">
<button type="submit" class="inline-flex items-center justify-center px-3 py-2 bg-red-600 text-white text-xs rounded-lg hover:bg-red-700 transition-colors font-medium min-w-[80px] h-[32px]">
<i class="fas fa-trash mr-1.5"></i>
Delete
</button>
</form>
<a href="/domains" class="inline-flex items-center justify-center px-3 py-2 border border-gray-300 text-gray-700 text-xs rounded-lg hover:bg-gray-50 transition-colors font-medium min-w-[80px] h-[32px]">
<i class="fas fa-arrow-left mr-1.5"></i>
Back
</a>
</div>
</div>
<!-- Main 2-Column Layout -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-3">
<!-- LEFT COLUMN -->
<div class="space-y-3">
<!-- Registration Details -->
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
<div class="px-4 py-2 border-b border-gray-200 bg-gray-50">
<h3 class="text-xs font-semibold text-gray-700 uppercase tracking-wider flex items-center">
<i class="fas fa-building text-gray-400 mr-2" style="font-size: 10px;"></i>
Registration Details
</h3>
</div>
<div class="p-4">
<div class="grid grid-cols-2 gap-x-4 gap-y-3 text-xs">
<div>
<label class="text-gray-500 font-medium block mb-0.5">Registrar</label>
<p class="text-gray-900 font-semibold"><?= htmlspecialchars($domain['registrar'] ?? 'Unknown') ?></p>
</div>
<?php if (!empty($domain['registrar_url'])): ?>
<div>
<label class="text-gray-500 font-medium block mb-0.5">Registrar URL</label>
<a href="<?= htmlspecialchars($domain['registrar_url']) ?>" target="_blank" class="text-blue-600 hover:text-blue-800 flex items-center">
<i class="fas fa-external-link-alt mr-1" style="font-size: 9px;"></i>
Visit
</a>
</div>
<?php endif; ?>
<?php if (!empty($domain['abuse_email'])): ?>
<div>
<label class="text-gray-500 font-medium block mb-0.5">Abuse Contact</label>
<a href="mailto:<?= htmlspecialchars($domain['abuse_email']) ?>" class="text-blue-600 hover:text-blue-800">
<?= htmlspecialchars($domain['abuse_email']) ?>
</a>
</div>
<?php endif; ?>
<?php if (isset($whoisData['whois_server'])): ?>
<div>
<label class="text-gray-500 font-medium block mb-0.5">WHOIS Server</label>
<p class="text-gray-900 font-mono"><?= htmlspecialchars($whoisData['whois_server']) ?></p>
</div>
<?php endif; ?>
<?php if (isset($whoisData['owner'])): ?>
<div class="col-span-2">
<label class="text-gray-500 font-medium block mb-0.5">Owner</label>
<p class="text-gray-900"><?= htmlspecialchars($whoisData['owner']) ?></p>
</div>
<?php endif; ?>
</div>
</div>
</div>
<!-- Important Dates -->
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
<div class="px-4 py-2 border-b border-gray-200 bg-gray-50">
<h3 class="text-xs font-semibold text-gray-700 uppercase tracking-wider flex items-center">
<i class="fas fa-calendar text-gray-400 mr-2" style="font-size: 10px;"></i>
Important Dates
</h3>
</div>
<div class="p-4">
<div class="space-y-2">
<?php if (!empty($domain['expiration_date'])): ?>
<div class="flex items-center justify-between p-2 bg-<?= $expiryColor ?>-50 rounded border border-<?= $expiryColor ?>-200">
<div class="flex items-center">
<div class="w-7 h-7 bg-<?= $expiryColor ?>-500 rounded flex items-center justify-center mr-2">
<i class="fas fa-exclamation-triangle text-white text-xs"></i>
</div>
<div>
<p class="text-xs text-gray-600 font-medium">Expiration</p>
<p class="text-xs font-semibold text-gray-900"><?= date('M j, Y', strtotime($domain['expiration_date'])) ?></p>
</div>
</div>
<span class="px-2 py-1 bg-<?= $expiryColor ?>-100 text-<?= $expiryColor ?>-800 rounded text-xs font-bold">
<?= $daysLeft ?> days
</span>
</div>
<?php endif; ?>
<?php if (!empty($domain['updated_date'])): ?>
<div class="flex items-center p-2 bg-blue-50 rounded border border-blue-200">
<div class="w-7 h-7 bg-blue-500 rounded flex items-center justify-center mr-2">
<i class="fas fa-clock text-white text-xs"></i>
</div>
<div>
<p class="text-xs text-gray-600 font-medium">Last Updated</p>
<p class="text-xs font-semibold text-gray-900"><?= date('M j, Y', strtotime($domain['updated_date'])) ?></p>
</div>
</div>
<?php endif; ?>
<?php if (isset($whoisData['creation_date'])): ?>
<div class="flex items-center p-2 bg-green-50 rounded border border-green-200">
<div class="w-7 h-7 bg-green-500 rounded flex items-center justify-center mr-2">
<i class="fas fa-calendar-plus text-white text-xs"></i>
</div>
<div>
<p class="text-xs text-gray-600 font-medium">Created</p>
<p class="text-xs font-semibold text-gray-900"><?= date('M j, Y', strtotime($whoisData['creation_date'])) ?></p>
</div>
</div>
<?php endif; ?>
<div class="flex items-center p-2 bg-purple-50 rounded border border-purple-200">
<div class="w-7 h-7 bg-purple-500 rounded flex items-center justify-center mr-2">
<i class="fas fa-sync text-white text-xs"></i>
</div>
<div>
<p class="text-xs text-gray-600 font-medium">Last Checked</p>
<p class="text-xs font-semibold text-gray-900"><?= date('M j, Y H:i', strtotime($domain['last_checked'])) ?></p>
</div>
</div>
</div>
</div>
</div>
<!-- Nameservers -->
<?php if (!empty($whoisData['nameservers'])): ?>
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
<div class="px-4 py-2 border-b border-gray-200 bg-gray-50">
<h3 class="text-xs font-semibold text-gray-700 uppercase tracking-wider flex items-center">
<i class="fas fa-server text-gray-400 mr-2" style="font-size: 10px;"></i>
Nameservers (<?= count($whoisData['nameservers']) ?>)
</h3>
</div>
<div class="p-4">
<div class="space-y-1.5">
<?php foreach ($whoisData['nameservers'] as $index => $ns): ?>
<div class="flex items-center p-2 bg-gray-50 rounded hover:bg-gray-100 transition-colors">
<div class="w-6 h-6 bg-teal-500 rounded flex items-center justify-center text-white font-bold text-xs mr-2">
<?= $index + 1 ?>
</div>
<p class="font-mono text-xs text-gray-800"><?= htmlspecialchars($ns) ?></p>
</div>
<?php endforeach; ?>
</div>
</div>
</div>
<?php endif; ?>
<!-- Domain Status -->
<?php if (!empty($whoisData['status']) && is_array($whoisData['status'])): ?>
<?php
// Pre-filter to count only valid statuses
$validStatuses = [];
foreach ($whoisData['status'] as $status) {
$cleanStatus = trim($status);
// Skip if it's just a URL or starts with http/https or //
if (empty($cleanStatus) ||
strpos($cleanStatus, 'http') === 0 ||
strpos($cleanStatus, '//') === 0 ||
strpos($cleanStatus, 'www.') === 0) {
continue;
}
// Keep the full status text, don't split by spaces
// Skip if after cleaning it's empty or just a URL
if (empty($cleanStatus) || strpos($cleanStatus, 'http') === 0 || strpos($cleanStatus, '//') === 0) {
continue;
}
$validStatuses[] = $cleanStatus;
}
?>
<?php if (!empty($validStatuses)): ?>
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
<div class="px-4 py-2 border-b border-gray-200 bg-gray-50">
<h3 class="text-xs font-semibold text-gray-700 uppercase tracking-wider flex items-center">
<i class="fas fa-info-circle text-gray-400 mr-2" style="font-size: 10px;"></i>
Domain Status (<?= count($validStatuses) ?>)
</h3>
</div>
<div class="p-4">
<div class="flex flex-wrap gap-1.5">
<?php foreach ($validStatuses as $cleanStatus): ?>
<?php
// Convert to readable format
$readableStatus = $cleanStatus;
// Convert camelCase to readable format (for cases like "clientTransferProhibited")
$readableStatus = preg_replace('/([a-z])([A-Z])/', '$1 $2', $readableStatus);
// Convert underscores to spaces and capitalize words
$readableStatus = str_replace('_', ' ', $readableStatus);
$readableStatus = ucwords(strtolower($readableStatus));
?>
<span class="px-2 py-1 bg-blue-100 text-blue-800 rounded text-xs font-medium" title="<?= htmlspecialchars($cleanStatus) ?>">
<?= htmlspecialchars($readableStatus) ?>
</span>
<?php endforeach; ?>
</div>
</div>
</div>
<?php endif; ?>
<?php endif; ?>
</div>
<!-- RIGHT COLUMN -->
<div class="space-y-3">
<!-- Notification Group -->
<?php if (!empty($domain['group_name'])): ?>
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
<div class="px-4 py-2 border-b border-gray-200 bg-gray-50">
<h3 class="text-xs font-semibold text-gray-700 uppercase tracking-wider flex items-center">
<i class="fas fa-bell text-gray-400 mr-2" style="font-size: 10px;"></i>
Notification Group
</h3>
</div>
<div class="p-4">
<div class="flex items-center mb-3">
<div class="w-10 h-10 bg-green-100 rounded-lg flex items-center justify-center mr-3">
<i class="fas fa-users text-green-600"></i>
</div>
<div>
<p class="font-semibold text-sm text-gray-900"><?= htmlspecialchars($domain['group_name']) ?></p>
<?php if (!empty($domain['channels'])): ?>
<?php
$activeChannels = array_filter($domain['channels'], fn($ch) => $ch['is_active']);
?>
<p class="text-xs text-gray-600">
<?= count($activeChannels) ?> / <?= count($domain['channels']) ?> channels active
</p>
<?php endif; ?>
</div>
</div>
<?php if (!empty($domain['channels'])): ?>
<div class="grid grid-cols-2 gap-2">
<?php foreach ($domain['channels'] as $channel): ?>
<div class="flex items-center p-2 rounded <?= $channel['is_active'] ? 'bg-green-50 border border-green-200' : 'bg-gray-50 border border-gray-200' ?>">
<i class="fas fa-<?= $channel['is_active'] ? 'check-circle text-green-600' : 'times-circle text-gray-400' ?> mr-2 text-xs"></i>
<span class="text-xs font-medium text-gray-700"><?= ucfirst($channel['channel_type']) ?></span>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
</div>
<?php else: ?>
<div class="bg-orange-50 rounded-lg border border-orange-200 p-4">
<div class="flex items-start mb-2">
<i class="fas fa-exclamation-triangle text-orange-500 mr-2 mt-0.5"></i>
<div>
<h3 class="text-xs font-semibold text-gray-900">No Group Assigned</h3>
<p class="text-xs text-gray-600 mt-0.5">Won't receive notifications</p>
</div>
</div>
<a href="/domains/<?= $domain['id'] ?>/edit" class="block w-full text-center px-3 py-1.5 bg-orange-500 text-white text-xs rounded-lg hover:bg-orange-600 transition-colors font-medium">
<i class="fas fa-plus mr-1"></i>
Assign Group
</a>
</div>
<?php endif; ?>
<!-- Notification History -->
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
<div class="px-4 py-2 border-b border-gray-200 bg-gray-50">
<h3 class="text-xs font-semibold text-gray-700 uppercase tracking-wider flex items-center">
<i class="fas fa-history text-gray-400 mr-2" style="font-size: 10px;"></i>
Notification History (<?= count($logs) ?>)
</h3>
</div>
<div class="overflow-hidden">
<?php if (empty($logs)): ?>
<div class="p-8 text-center">
<i class="fas fa-bell-slash text-gray-300 text-3xl mb-2"></i>
<p class="text-xs text-gray-500">No notifications sent yet</p>
</div>
<?php else: ?>
<div class="max-h-96 overflow-y-auto">
<table class="min-w-full divide-y divide-gray-200 text-xs">
<thead class="bg-gray-50 sticky top-0">
<tr>
<th class="px-3 py-2 text-left text-xs font-semibold text-gray-600 uppercase">Channel</th>
<th class="px-3 py-2 text-left text-xs font-semibold text-gray-600 uppercase">Status</th>
<th class="px-3 py-2 text-left text-xs font-semibold text-gray-600 uppercase">Date</th>
<th class="px-3 py-2 text-left text-xs font-semibold text-gray-600 uppercase">Message</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
<?php foreach ($logs as $log): ?>
<tr class="hover:bg-gray-50">
<td class="px-3 py-2 whitespace-nowrap">
<span class="px-2 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800">
<?= ucfirst($log['channel_type']) ?>
</span>
</td>
<td class="px-3 py-2 whitespace-nowrap">
<?php $statusClass = $log['status'] === 'sent' ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'; ?>
<span class="px-2 py-0.5 rounded text-xs font-medium <?= $statusClass ?>">
<?= ucfirst($log['status']) ?>
</span>
</td>
<td class="px-3 py-2 whitespace-nowrap text-gray-600"><?= date('M j, H:i', strtotime($log['sent_at'])) ?></td>
<td class="px-3 py-2 text-gray-700 max-w-xs truncate" title="<?= htmlspecialchars($log['message']) ?>">
<?= htmlspecialchars($log['message']) ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</div>
</div>
<!-- Raw WHOIS Data (Collapsible) -->
<?php if (!empty($domain['whois_data'])): ?>
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
<button onclick="toggleWhoisData()" class="w-full px-4 py-2 border-b border-gray-200 bg-gray-50 text-left hover:bg-gray-100 transition-colors">
<h3 class="text-xs font-semibold text-gray-700 uppercase tracking-wider flex items-center justify-between">
<span class="flex items-center">
<i class="fas fa-code text-gray-400 mr-2" style="font-size: 10px;"></i>
Raw WHOIS Data
</span>
<i class="fas fa-chevron-down text-gray-400 text-xs transition-transform" id="whois-chevron"></i>
</h3>
</button>
<div id="whois-data" class="hidden p-4 bg-gray-900 max-h-64 overflow-y-auto">
<pre class="text-xs text-green-400 font-mono"><?= htmlspecialchars(json_encode($whoisData, JSON_PRETTY_PRINT)) ?></pre>
</div>
</div>
<?php endif; ?>
</div>
</div>
<script>
function toggleWhoisData() {
const dataDiv = document.getElementById('whois-data');
const chevron = document.getElementById('whois-chevron');
dataDiv.classList.toggle('hidden');
chevron.classList.toggle('rotate-180');
}
</script>
<?php
$content = ob_get_clean();
include __DIR__ . '/../layout/base.php';
?>

85
app/Views/errors/404.php Normal file
View File

@@ -0,0 +1,85 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>404 - Page Not Found</title>
<!-- Tailwind CSS -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- Font Awesome -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" crossorigin="anonymous" referrerpolicy="no-referrer" />
<script>
tailwind.config = {
theme: {
extend: {
colors: {
primary: {
DEFAULT: '#4A90E2',
dark: '#357ABD',
}
}
}
}
}
</script>
</head>
<body class="bg-gradient-to-br from-blue-50 to-indigo-100 min-h-screen flex items-center justify-center p-6">
<div class="max-w-2xl w-full">
<div class="bg-white rounded-2xl shadow-2xl p-12 text-center">
<!-- 404 Icon -->
<div class="mb-8">
<i class="fas fa-exclamation-triangle text-yellow-500 text-8xl mb-4 animate-pulse"></i>
</div>
<!-- Error Message -->
<h1 class="text-9xl font-bold text-gray-800 mb-4">404</h1>
<h2 class="text-3xl font-bold text-gray-700 mb-4">Page Not Found</h2>
<p class="text-gray-600 text-lg mb-8 leading-relaxed">
Oops! The page you're looking for doesn't exist. It might have been moved or deleted.
</p>
<!-- Action Buttons -->
<div class="flex flex-col sm:flex-row gap-4 justify-center">
<a href="/" class="inline-flex items-center justify-center px-8 py-4 bg-primary text-white rounded-lg hover:bg-primary-dark transition-all duration-200 shadow-lg hover:shadow-xl transform hover:-translate-y-0.5">
<i class="fas fa-home mr-2"></i>
Go to Dashboard
</a>
<button onclick="history.back()" class="inline-flex items-center justify-center px-8 py-4 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 transition-all duration-200 shadow-md hover:shadow-lg">
<i class="fas fa-arrow-left mr-2"></i>
Go Back
</button>
</div>
<!-- Helpful Links -->
<div class="mt-12 pt-8 border-t border-gray-200">
<p class="text-sm text-gray-500 mb-4">Quick Links:</p>
<div class="flex flex-wrap justify-center gap-4">
<a href="/domains" class="text-primary hover:text-primary-dark transition-colors duration-150">
<i class="fas fa-globe mr-1"></i>
Domains
</a>
<a href="/groups" class="text-primary hover:text-primary-dark transition-colors duration-150">
<i class="fas fa-bell mr-1"></i>
Notification Groups
</a>
<a href="/debug/whois" class="text-primary hover:text-primary-dark transition-colors duration-150">
<i class="fas fa-search mr-1"></i>
WHOIS Lookup
</a>
</div>
</div>
</div>
<!-- Footer -->
<div class="text-center mt-8">
<p class="text-gray-600">
<i class="fas fa-globe text-primary"></i>
<span class="ml-2">Domain Monitor © <?= date('Y') ?></span>
</p>
</div>
</div>
</body>
</html>

102
app/Views/groups/create.php Normal file
View File

@@ -0,0 +1,102 @@
<?php
$title = 'Create Notification Group';
$pageTitle = 'Create Notification Group';
$pageDescription = 'Set up a new notification group for your domains';
$pageIcon = 'fas fa-plus-circle';
ob_start();
?>
<!-- Main Form -->
<div class="max-w-3xl mx-auto">
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
<div class="px-6 py-4 border-b border-gray-200">
<h2 class="text-lg font-semibold text-gray-900 flex items-center">
<i class="fas fa-bell text-gray-400 mr-2 text-sm"></i>
Group Information
</h2>
</div>
<div class="p-6">
<form method="POST" action="/groups/store" class="space-y-5">
<!-- Group Name -->
<div>
<label for="name" class="block text-sm font-medium text-gray-700 mb-1.5">
Group Name *
</label>
<input type="text"
id="name"
name="name"
class="w-full px-3 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary transition-colors text-sm"
placeholder="e.g., Production Alerts, Team Notifications"
required
autofocus>
<p class="mt-1.5 text-xs text-gray-500">
Choose a descriptive name for this notification group
</p>
</div>
<!-- Description -->
<div>
<label for="description" class="block text-sm font-medium text-gray-700 mb-1.5">
Description (Optional)
</label>
<textarea id="description"
name="description"
class="w-full px-3 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary transition-colors text-sm"
rows="4"
placeholder="Add details about this notification group, its purpose, or who should be notified..."></textarea>
<p class="mt-1.5 text-xs text-gray-500">
Optional: Add notes to help identify this group's purpose
</p>
</div>
<!-- Action Buttons -->
<div class="flex flex-col sm:flex-row gap-3 pt-3">
<button type="submit"
class="inline-flex items-center justify-center px-5 py-2.5 bg-primary hover:bg-primary-dark text-white rounded-lg font-medium transition-colors text-sm">
<i class="fas fa-plus-circle mr-2"></i>
Create Group
</button>
<a href="/groups"
class="inline-flex items-center justify-center px-5 py-2.5 border border-gray-300 text-gray-700 rounded-lg font-medium hover:bg-gray-50 transition-colors text-sm">
<i class="fas fa-times mr-2"></i>
Cancel
</a>
</div>
</form>
</div>
</div>
<!-- Info Section -->
<div class="mt-4 bg-blue-50 border border-blue-200 rounded-lg p-4">
<div class="flex items-start">
<div class="flex-shrink-0">
<div class="w-10 h-10 bg-blue-500 rounded-lg flex items-center justify-center">
<i class="fas fa-info-circle text-white"></i>
</div>
</div>
<div class="ml-3">
<h3 class="text-sm font-semibold text-gray-900 mb-1">Next Steps</h3>
<ul class="text-xs text-gray-600 space-y-1">
<li class="flex items-center">
<i class="fas fa-circle text-blue-500" style="font-size: 6px;"></i>
<span class="ml-2">After creating the group, you'll be able to add notification channels (Email, Telegram, Discord, Slack)</span>
</li>
<li class="flex items-center">
<i class="fas fa-circle text-blue-500" style="font-size: 6px;"></i>
<span class="ml-2">Configure each channel with the necessary credentials and settings</span>
</li>
<li class="flex items-center">
<i class="fas fa-circle text-blue-500" style="font-size: 6px;"></i>
<span class="ml-2">Assign domains to this group to start receiving notifications</span>
</li>
</ul>
</div>
</div>
</div>
</div>
<?php
$content = ob_get_clean();
include __DIR__ . '/../layout/base.php';
?>

304
app/Views/groups/edit.php Normal file
View File

@@ -0,0 +1,304 @@
<?php
$title = 'Edit Notification Group';
$pageTitle = 'Edit Notification Group';
$pageDescription = htmlspecialchars($group['name']);
$pageIcon = 'fas fa-edit';
ob_start();
?>
<div class="max-w-7xl mx-auto space-y-4">
<!-- Group Details Form -->
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
<div class="px-6 py-4 border-b border-gray-200">
<h2 class="text-lg font-semibold text-gray-900 flex items-center">
<i class="fas fa-info-circle text-gray-400 mr-2 text-sm"></i>
Group Details
</h2>
</div>
<div class="p-6">
<form method="POST" action="/groups/update" class="space-y-5">
<input type="hidden" name="id" value="<?= $group['id'] ?>">
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
<!-- Group Name -->
<div>
<label for="name" class="block text-sm font-medium text-gray-700 mb-1.5">
Group Name *
</label>
<input type="text"
id="name"
name="name"
class="w-full px-3 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary transition-colors text-sm"
value="<?= htmlspecialchars($group['name']) ?>"
required>
</div>
<!-- Description -->
<div>
<label for="description" class="block text-sm font-medium text-gray-700 mb-1.5">
Description (Optional)
</label>
<textarea id="description"
name="description"
class="w-full px-3 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary transition-colors text-sm"
rows="3"><?= htmlspecialchars($group['description'] ?? '') ?></textarea>
</div>
</div>
<div class="flex gap-3">
<button type="submit"
class="inline-flex items-center px-5 py-2.5 bg-primary hover:bg-primary-dark text-white rounded-lg font-medium transition-colors text-sm">
<i class="fas fa-save mr-2"></i>
Update Group
</button>
</div>
</form>
</div>
</div>
<!-- Notification Channels -->
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
<div class="px-6 py-4 border-b border-gray-200">
<h2 class="text-lg font-semibold text-gray-900 flex items-center">
<i class="fas fa-plug text-gray-400 mr-2 text-sm"></i>
Notification Channels
</h2>
</div>
<div class="p-6">
<?php if (empty($group['channels'])): ?>
<div class="text-center py-10">
<i class="fas fa-plug text-gray-300 text-5xl mb-3"></i>
<p class="text-gray-500">No channels configured yet</p>
<p class="text-sm text-gray-400 mt-1">Add your first channel below to start receiving notifications</p>
</div>
<?php else: ?>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-6">
<?php foreach ($group['channels'] as $channel):
$config = json_decode($channel['channel_config'], true);
$icons = ['email' => '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';
?>
<div class="bg-gray-50 border border-gray-200 rounded-lg p-6 hover:shadow-md transition-shadow duration-200">
<div class="flex items-start justify-between mb-4">
<div class="w-12 h-12 bg-<?= $color ?>-100 rounded-lg flex items-center justify-center">
<i class="fab <?= $icon ?> text-<?= $color ?>-600 text-xl"></i>
</div>
<span class="px-3 py-1 rounded-full text-xs font-semibold <?= $channel['is_active'] ? 'bg-green-100 text-green-800' : 'bg-gray-200 text-gray-600' ?>">
<?= $channel['is_active'] ? 'Active' : 'Disabled' ?>
</span>
</div>
<h3 class="font-semibold text-gray-800 mb-2"><?= ucfirst($channel['channel_type']) ?></h3>
<p class="text-sm text-gray-600 mb-4 truncate">
<?php
if ($channel['channel_type'] === 'email') {
echo htmlspecialchars($config['email'] ?? 'No email');
} elseif ($channel['channel_type'] === 'telegram') {
echo "Chat: " . htmlspecialchars($config['chat_id'] ?? 'N/A');
} else {
echo "Webhook configured";
}
?>
</p>
<div class="flex gap-2">
<a href="/channels/toggle?id=<?= $channel['id'] ?>&group_id=<?= $group['id'] ?>"
class="flex-1 px-3 py-2 bg-yellow-50 text-yellow-700 rounded text-center text-sm hover:bg-yellow-100 transition-colors duration-150">
<i class="fas fa-<?= $channel['is_active'] ? 'pause' : 'play' ?> mr-1"></i>
<?= $channel['is_active'] ? 'Disable' : 'Enable' ?>
</a>
<a href="/channels/delete?id=<?= $channel['id'] ?>&group_id=<?= $group['id'] ?>"
class="flex-1 px-3 py-2 bg-red-50 text-red-700 rounded text-center text-sm hover:bg-red-100 transition-colors duration-150"
onclick="return confirm('Delete this channel?')">
<i class="fas fa-trash mr-1"></i>
Delete
</a>
</div>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
<!-- Add Channel Form -->
<div class="bg-gray-50 rounded-lg p-5 border border-gray-200">
<h3 class="text-sm font-semibold text-gray-900 mb-4 flex items-center">
<i class="fas fa-plus-circle text-gray-400 mr-2 text-sm"></i>
Add New Channel
</h3>
<form method="POST" action="/channels/add" id="channelForm" class="space-y-5">
<input type="hidden" name="group_id" value="<?= $group['id'] ?>">
<!-- Channel Type -->
<div>
<label for="channel_type" class="block text-sm font-medium text-gray-700 mb-1.5">Channel Type</label>
<select id="channel_type"
name="channel_type"
class="w-full px-3 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary transition-colors text-sm"
onchange="toggleChannelFields()">
<option value="">-- Select Channel Type --</option>
<option value="email">Email</option>
<option value="telegram">Telegram</option>
<option value="discord">Discord</option>
<option value="slack">Slack</option>
</select>
</div>
<!-- Email Fields -->
<div id="email_fields" class="hidden space-y-4">
<div>
<label for="email" class="block text-sm font-medium text-gray-700 mb-1.5">
Email Address
</label>
<input type="email"
id="email"
name="email"
class="w-full px-3 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary transition-colors text-sm"
placeholder="user@example.com">
</div>
</div>
<!-- Telegram Fields -->
<div id="telegram_fields" class="hidden space-y-4">
<div>
<label for="bot_token" class="block text-sm font-medium text-gray-700 mb-1.5">
Bot Token
</label>
<input type="text"
id="bot_token"
name="bot_token"
class="w-full px-3 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary transition-colors text-sm"
placeholder="123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11">
<p class="mt-1.5 text-xs text-gray-500">
Get from @BotFather on Telegram
</p>
</div>
<div>
<label for="chat_id" class="block text-sm font-medium text-gray-700 mb-1.5">
Chat ID
</label>
<input type="text"
id="chat_id"
name="chat_id"
class="w-full px-3 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary transition-colors text-sm"
placeholder="123456789">
<p class="mt-1.5 text-xs text-gray-500">
Use @userinfobot to get your chat ID
</p>
</div>
</div>
<!-- Discord Fields -->
<div id="discord_fields" class="hidden space-y-4">
<div>
<label for="discord_webhook" class="block text-sm font-medium text-gray-700 mb-1.5">
Webhook URL
</label>
<input type="url"
id="discord_webhook"
name="webhook_url"
class="w-full px-3 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary transition-colors text-sm"
placeholder="https://discord.com/api/webhooks/...">
<p class="mt-1.5 text-xs text-gray-500">
Create in Discord Server Settings → Integrations → Webhooks
</p>
</div>
</div>
<!-- Slack Fields -->
<div id="slack_fields" class="hidden space-y-4">
<div>
<label for="slack_webhook" class="block text-sm font-medium text-gray-700 mb-1.5">
Webhook URL
</label>
<input type="url"
id="slack_webhook"
name="webhook_url"
class="w-full px-3 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary transition-colors text-sm"
placeholder="https://hooks.slack.com/services/...">
<p class="mt-1.5 text-xs text-gray-500">
Create in Slack App Settings → Incoming Webhooks
</p>
</div>
</div>
<button type="submit"
class="inline-flex items-center px-5 py-2.5 bg-primary hover:bg-primary-dark text-white rounded-lg font-medium transition-colors text-sm">
<i class="fas fa-plus mr-2"></i>
Add Channel
</button>
</form>
</div>
</div>
</div>
<!-- Assigned Domains -->
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
<div class="px-6 py-4 border-b border-gray-200">
<h2 class="text-lg font-semibold text-gray-900 flex items-center">
<i class="fas fa-globe text-gray-400 mr-2 text-sm"></i>
Assigned Domains (<?= count($group['domains']) ?>)
</h2>
</div>
<div class="p-6">
<?php if (empty($group['domains'])): ?>
<div class="text-center py-10">
<i class="fas fa-globe text-gray-300 text-5xl mb-3"></i>
<p class="text-gray-500">No domains assigned to this group yet</p>
<a href="/domains/create" class="mt-3 inline-flex items-center px-5 py-2.5 bg-primary text-white text-sm rounded-lg hover:bg-primary-dark transition-colors font-medium">
<i class="fas fa-plus mr-2"></i>
Add a Domain
</a>
</div>
<?php else: ?>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<?php foreach ($group['domains'] as $domain): ?>
<a href="/domains/<?= $domain['id'] ?>" class="block bg-gray-50 border border-gray-200 rounded-lg p-6 hover:shadow-md hover:border-primary transition-all duration-200">
<div class="flex items-start justify-between mb-3">
<div class="w-12 h-12 bg-primary bg-opacity-10 rounded-lg flex items-center justify-center">
<i class="fas fa-globe text-primary text-xl"></i>
</div>
<?php
$statusClass = $domain['status'] === 'active' ? 'bg-green-100 text-green-800' : 'bg-gray-200 text-gray-600';
?>
<span class="px-3 py-1 rounded-full text-xs font-semibold <?= $statusClass ?>">
<?= ucfirst($domain['status']) ?>
</span>
</div>
<h3 class="font-semibold text-gray-800 mb-2 truncate"><?= htmlspecialchars($domain['domain_name']) ?></h3>
<p class="text-sm text-gray-600 flex items-center">
<i class="far fa-calendar mr-2"></i>
Expires: <?= date('M j, Y', strtotime($domain['expiration_date'])) ?>
</p>
</a>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
</div>
</div>
<script>
function toggleChannelFields() {
const channelType = document.getElementById('channel_type').value;
// Hide all fields
document.getElementById('email_fields').classList.add('hidden');
document.getElementById('telegram_fields').classList.add('hidden');
document.getElementById('discord_fields').classList.add('hidden');
document.getElementById('slack_fields').classList.add('hidden');
// Show selected field
if (channelType) {
document.getElementById(channelType + '_fields').classList.remove('hidden');
}
}
</script>
<?php
$content = ob_get_clean();
include __DIR__ . '/../layout/base.php';
?>

156
app/Views/groups/index.php Normal file
View File

@@ -0,0 +1,156 @@
<?php
$title = 'Notification Groups';
$pageTitle = 'Notification Groups';
$pageDescription = 'Manage notification channels and assignments';
$pageIcon = 'fas fa-bell';
ob_start();
?>
<!-- Quick Actions -->
<div class="mb-4 flex justify-end">
<a href="/groups/create" class="inline-flex items-center px-4 py-2.5 bg-primary text-white text-sm rounded-lg hover:bg-primary-dark transition-colors font-medium">
<i class="fas fa-plus mr-2"></i>
Create New Group
</a>
</div>
<!-- Info Card -->
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-4">
<div class="flex items-start">
<div class="flex-shrink-0">
<i class="fas fa-info-circle text-blue-500 text-lg"></i>
</div>
<div class="ml-3">
<h3 class="text-sm font-semibold text-gray-900 mb-1">About Notification Groups</h3>
<p class="text-xs text-gray-600 leading-relaxed">
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.
</p>
</div>
</div>
</div>
<!-- Groups List -->
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
<?php if (!empty($groups)): ?>
<!-- Table View (Desktop) -->
<div class="hidden md:block overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-4 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">Group Name</th>
<th class="px-6 py-4 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">Description</th>
<th class="px-6 py-4 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">Channels</th>
<th class="px-6 py-4 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">Domains</th>
<th class="px-6 py-4 text-right text-xs font-semibold text-gray-600 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
<?php foreach ($groups as $group): ?>
<tr class="hover:bg-gray-50 transition-colors duration-150">
<td class="px-6 py-4 whitespace-nowrap">
<div class="flex items-center">
<div class="flex-shrink-0 h-10 w-10 bg-primary bg-opacity-10 rounded-lg flex items-center justify-center">
<i class="fas fa-bell text-primary"></i>
</div>
<div class="ml-4">
<div class="text-sm font-semibold text-gray-900"><?= htmlspecialchars($group['name']) ?></div>
</div>
</div>
</td>
<td class="px-6 py-4">
<div class="text-sm text-gray-700 max-w-xs truncate">
<?= htmlspecialchars($group['description'] ?? 'No description') ?>
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold bg-blue-100 text-blue-800">
<i class="fas fa-plug mr-1"></i>
<?= $group['channel_count'] ?> channel<?= $group['channel_count'] != 1 ? 's' : '' ?>
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold bg-green-100 text-green-800">
<i class="fas fa-globe mr-1"></i>
<?= $group['domain_count'] ?> domain<?= $group['domain_count'] != 1 ? 's' : '' ?>
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<div class="flex items-center justify-end space-x-2">
<a href="/groups/edit?id=<?= $group['id'] ?>" class="text-blue-600 hover:text-blue-800" title="Manage">
<i class="fas fa-cog"></i>
</a>
<a href="/groups/delete?id=<?= $group['id'] ?>"
class="text-red-600 hover:text-red-800"
title="Delete"
onclick="return confirm('Are you sure? Domains will be unassigned from this group.')">
<i class="fas fa-trash"></i>
</a>
</div>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<!-- Card View (Mobile) -->
<div class="md:hidden divide-y divide-gray-200">
<?php foreach ($groups as $group): ?>
<div class="p-6 hover:bg-gray-50 transition-colors duration-150">
<div class="flex items-start justify-between mb-3">
<div class="flex items-center">
<div class="flex-shrink-0 h-10 w-10 bg-primary bg-opacity-10 rounded-lg flex items-center justify-center">
<i class="fas fa-bell text-primary"></i>
</div>
<div class="ml-3">
<h3 class="font-semibold text-gray-900"><?= htmlspecialchars($group['name']) ?></h3>
<p class="text-sm text-gray-500"><?= htmlspecialchars($group['description'] ?? 'No description') ?></p>
</div>
</div>
</div>
<div class="flex space-x-3 mb-3">
<span class="px-2 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
<i class="fas fa-plug mr-1"></i>
<?= $group['channel_count'] ?> channels
</span>
<span class="px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800">
<i class="fas fa-globe mr-1"></i>
<?= $group['domain_count'] ?> domains
</span>
</div>
<div class="flex space-x-2">
<a href="/groups/edit?id=<?= $group['id'] ?>" class="flex-1 px-3 py-1.5 bg-blue-50 text-blue-600 rounded text-center text-sm hover:bg-blue-100 transition-colors">
<i class="fas fa-cog mr-1"></i> Manage
</a>
<a href="/groups/delete?id=<?= $group['id'] ?>"
class="flex-1 px-3 py-1.5 bg-red-50 text-red-600 rounded text-center text-sm hover:bg-red-100 transition-colors"
onclick="return confirm('Are you sure? Domains will be unassigned from this group.')">
<i class="fas fa-trash mr-1"></i> Delete
</a>
</div>
</div>
<?php endforeach; ?>
</div>
<?php else: ?>
<div class="text-center py-12 px-6">
<div class="mb-4">
<i class="fas fa-bell-slash text-gray-300 text-6xl"></i>
</div>
<h3 class="text-lg font-semibold text-gray-700 mb-1">No Notification Groups</h3>
<p class="text-sm text-gray-500 mb-4">Create your first notification group to start receiving alerts</p>
<a href="/groups/create" class="inline-flex items-center px-5 py-2.5 bg-primary text-white text-sm rounded-lg hover:bg-primary-dark transition-colors font-medium">
<i class="fas fa-plus mr-2"></i>
Create Your First Group
</a>
</div>
<?php endif; ?>
</div>
<?php
$content = ob_get_clean();
include __DIR__ . '/../layout/base.php';
?>

310
app/Views/layout/base.php Normal file
View File

@@ -0,0 +1,310 @@
<?php
/**
* Base Layout Template
* Contains: HTML structure, meta tags, CSS/JS includes, global stats
*/
// Fetch global stats for sidebar (available on all pages)
if (!isset($globalStats)) {
try {
$pdo = \Core\Database::getConnection();
// Get total domains
$totalStmt = $pdo->query("SELECT COUNT(*) as count FROM domains");
$totalResult = $totalStmt->fetch(\PDO::FETCH_ASSOC);
$total = $totalResult['count'] ?? 0;
// Get active domains
$activeStmt = $pdo->query("SELECT COUNT(*) as count FROM domains WHERE is_active = 1");
$activeResult = $activeStmt->fetch(\PDO::FETCH_ASSOC);
$active = $activeResult['count'] ?? 0;
// Get expiring soon (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
];
}
}
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="description" content="Domain Monitor - Track and monitor your domain expiration dates">
<meta name="author" content="Domain Monitor">
<meta name="robots" content="noindex, nofollow">
<!-- Title -->
<title><?= $title ?? 'Domain Monitor' ?> - <?= $_ENV['APP_NAME'] ?? 'Domain Monitor' ?></title>
<!-- Favicon -->
<link rel="icon" type="image/x-icon" href="/assets/favicon.ico">
<!-- Tailwind CSS -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- Flag Icons -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/flag-icons/7.5.0/css/flag-icons.min.css" referrerpolicy="no-referrer" />
<!-- Font Awesome -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" crossorigin="anonymous" referrerpolicy="no-referrer" />
<!-- Tailwind Configuration -->
<script>
tailwind.config = {
theme: {
extend: {
colors: {
primary: {
DEFAULT: '#4A90E2',
dark: '#357ABD',
light: '#6BA3E8',
},
sidebar: {
DEFAULT: '#1F2937',
light: '#374151',
}
}
}
}
}
</script>
<!-- Custom Styles -->
<link rel="stylesheet" href="/assets/style.css">
<!-- Custom Page Styles (optional) -->
<?php if (isset($customStyles)): ?>
<style><?= $customStyles ?></style>
<?php endif; ?>
<style>
/* Sidebar full height */
.sidebar {
height: 100vh;
transition: transform 0.3s ease-in-out;
}
@media (max-width: 768px) {
.sidebar {
transform: translateX(-100%);
}
.sidebar.open {
transform: translateX(0);
}
}
/* Dropdown animation */
.dropdown-menu {
display: none;
opacity: 0;
transform: translateY(-10px);
transition: all 0.2s ease-in-out;
}
.dropdown-menu.show {
display: block;
opacity: 1;
transform: translateY(0);
}
/* Active sidebar link */
.sidebar-link.active {
background: #374151;
border-left: 4px solid #4A90E2;
}
</style>
</head>
<body class="bg-gray-50">
<?php include __DIR__ . '/top-nav.php'; ?>
<?php include __DIR__ . '/sidebar.php'; ?>
<!-- Main Content Area -->
<main class="md:ml-64 pt-16 min-h-screen bg-gray-50">
<div class="p-6">
<!-- Flash Messages -->
<?php include __DIR__ . '/messages.php'; ?>
<!-- Page Content -->
<?php if (isset($content)): ?>
<?= $content ?>
<?php endif; ?>
</div>
</main>
<!-- Global Scripts -->
<script>
// Toggle sidebar on mobile
function toggleSidebar() {
document.getElementById('sidebar').classList.toggle('open');
}
// Toggle user dropdown
function toggleDropdown() {
document.getElementById('userDropdown').classList.toggle('show');
}
// Close dropdown when clicking outside
document.addEventListener('click', function(event) {
const dropdown = document.getElementById('userDropdown');
const isClickInside = event.target.closest('[onclick="toggleDropdown()"]') || event.target.closest('#userDropdown');
if (!isClickInside && dropdown && dropdown.classList.contains('show')) {
dropdown.classList.remove('show');
}
});
// Live Search Functionality
let searchTimeout;
const searchInput = document.getElementById('globalSearchInput');
const searchDropdown = document.getElementById('searchDropdown');
const searchResults = document.getElementById('searchResults');
const searchLoading = document.getElementById('searchLoading');
if (searchInput) {
searchInput.addEventListener('input', function(e) {
const query = e.target.value.trim();
clearTimeout(searchTimeout);
if (query.length < 2) {
searchDropdown.classList.add('hidden');
return;
}
// Show loading
searchDropdown.classList.remove('hidden');
searchLoading.classList.remove('hidden');
searchResults.innerHTML = '';
// Debounce search
searchTimeout = setTimeout(() => {
fetch(`/api/search/suggest?q=${encodeURIComponent(query)}`)
.then(response => response.json())
.then(data => {
searchLoading.classList.add('hidden');
renderSearchResults(data);
})
.catch(error => {
searchLoading.classList.add('hidden');
searchResults.innerHTML = '<div class="p-4 text-red-600 text-sm">Error loading results</div>';
});
}, 300);
});
// Handle form submission
const searchForm = document.getElementById('globalSearchForm');
if (searchForm) {
searchForm.addEventListener('submit', function(e) {
searchDropdown.classList.add('hidden');
});
}
// Close dropdown when clicking outside
document.addEventListener('click', function(event) {
if (searchDropdown && !searchDropdown.contains(event.target) && event.target !== searchInput) {
searchDropdown.classList.add('hidden');
}
});
}
function renderSearchResults(data) {
let html = '';
if (data.domains && data.domains.length > 0) {
html += '<div class="p-2">';
html += '<p class="px-3 py-2 text-xs font-semibold text-gray-500 uppercase">Your Domains</p>';
data.domains.forEach(domain => {
const statusColors = {
'red': 'text-red-600',
'orange': 'text-orange-600',
'yellow': 'text-yellow-600',
'green': 'text-green-600',
'gray': 'text-gray-400'
};
const colorClass = statusColors[domain.status_color] || 'text-gray-600';
html += `
<a href="/domains/${domain.id}" class="block px-3 py-2 hover:bg-gray-50 rounded-lg transition-colors">
<div class="flex items-center justify-between">
<div class="flex-1 min-w-0">
<p class="text-sm font-semibold text-gray-900 truncate">${escapeHtml(domain.domain_name)}</p>
<p class="text-xs text-gray-500">${escapeHtml(domain.registrar || 'Unknown registrar')}</p>
</div>
${domain.days_left !== null ? `
<div class="ml-3 text-right">
<p class="text-xs font-semibold ${colorClass}">${domain.days_left} days</p>
</div>
` : ''}
</div>
</a>
`;
});
html += '</div>';
}
// Show WHOIS lookup option if no results and looks like a domain
if (data.domains.length === 0 && data.isDomainLike) {
html += `
<div class="p-4 border-t border-gray-200">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-gray-900">Domain not in portfolio</p>
<p class="text-xs text-gray-500 mt-0.5">Perform WHOIS lookup for ${escapeHtml(data.query)}</p>
</div>
<button onclick="window.location.href='/search?q=${encodeURIComponent(data.query)}'" class="px-3 py-1.5 bg-primary text-white text-xs rounded-lg hover:bg-primary-dark">
Lookup
</button>
</div>
</div>
`;
} else if (data.domains.length === 0) {
html += '<div class="p-4 text-center text-sm text-gray-500">No results found</div>';
}
// Add "View all results" link if there are results
if (data.domains.length > 0) {
html += `
<div class="border-t border-gray-200 p-2">
<a href="/search?q=${encodeURIComponent(data.query)}" class="block px-3 py-2 text-center text-sm font-medium text-primary hover:bg-gray-50 rounded-lg">
View all results →
</a>
</div>
`;
}
searchResults.innerHTML = html;
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
</script>
<!-- Custom Page Scripts (optional) -->
<?php if (isset($customScripts)): ?>
<script><?= $customScripts ?></script>
<?php endif; ?>
</body>
</html>

View File

@@ -0,0 +1,119 @@
<!-- Toast Notifications Container -->
<div id="toast-container" class="fixed bottom-4 right-4 z-50 space-y-3 max-w-sm">
<!-- Success Toast -->
<?php if (isset($_SESSION['success'])): ?>
<div class="toast bg-white border-l-4 border-green-500 rounded-lg shadow-lg p-4 flex items-start animate-slide-in">
<div class="flex-shrink-0">
<div class="w-8 h-8 bg-green-100 rounded-full flex items-center justify-center">
<i class="fas fa-check text-green-600 text-sm"></i>
</div>
</div>
<div class="ml-3 flex-1">
<p class="text-sm font-medium text-gray-900">Success</p>
<p class="text-sm text-gray-600 mt-0.5"><?= htmlspecialchars($_SESSION['success']) ?></p>
</div>
<button onclick="this.parentElement.remove()" class="ml-3 flex-shrink-0 text-gray-400 hover:text-gray-600 transition-colors">
<i class="fas fa-times text-sm"></i>
</button>
</div>
<?php unset($_SESSION['success']); ?>
<?php endif; ?>
<!-- Error Toast -->
<?php if (isset($_SESSION['error'])): ?>
<div class="toast bg-white border-l-4 border-red-500 rounded-lg shadow-lg p-4 flex items-start animate-slide-in">
<div class="flex-shrink-0">
<div class="w-8 h-8 bg-red-100 rounded-full flex items-center justify-center">
<i class="fas fa-times text-red-600 text-sm"></i>
</div>
</div>
<div class="ml-3 flex-1">
<p class="text-sm font-medium text-gray-900">Error</p>
<p class="text-sm text-gray-600 mt-0.5"><?= htmlspecialchars($_SESSION['error']) ?></p>
</div>
<button onclick="this.parentElement.remove()" class="ml-3 flex-shrink-0 text-gray-400 hover:text-gray-600 transition-colors">
<i class="fas fa-times text-sm"></i>
</button>
</div>
<?php unset($_SESSION['error']); ?>
<?php endif; ?>
<!-- Warning Toast -->
<?php if (isset($_SESSION['warning'])): ?>
<div class="toast bg-white border-l-4 border-orange-500 rounded-lg shadow-lg p-4 flex items-start animate-slide-in">
<div class="flex-shrink-0">
<div class="w-8 h-8 bg-orange-100 rounded-full flex items-center justify-center">
<i class="fas fa-exclamation-triangle text-orange-600 text-sm"></i>
</div>
</div>
<div class="ml-3 flex-1">
<p class="text-sm font-medium text-gray-900">Warning</p>
<p class="text-sm text-gray-600 mt-0.5"><?= htmlspecialchars($_SESSION['warning']) ?></p>
</div>
<button onclick="this.parentElement.remove()" class="ml-3 flex-shrink-0 text-gray-400 hover:text-gray-600 transition-colors">
<i class="fas fa-times text-sm"></i>
</button>
</div>
<?php unset($_SESSION['warning']); ?>
<?php endif; ?>
<!-- Info Toast -->
<?php if (isset($_SESSION['info'])): ?>
<div class="toast bg-white border-l-4 border-blue-500 rounded-lg shadow-lg p-4 flex items-start animate-slide-in">
<div class="flex-shrink-0">
<div class="w-8 h-8 bg-blue-100 rounded-full flex items-center justify-center">
<i class="fas fa-info text-blue-600 text-sm"></i>
</div>
</div>
<div class="ml-3 flex-1">
<p class="text-sm font-medium text-gray-900">Info</p>
<p class="text-sm text-gray-600 mt-0.5"><?= htmlspecialchars($_SESSION['info']) ?></p>
</div>
<button onclick="this.parentElement.remove()" class="ml-3 flex-shrink-0 text-gray-400 hover:text-gray-600 transition-colors">
<i class="fas fa-times text-sm"></i>
</button>
</div>
<?php unset($_SESSION['info']); ?>
<?php endif; ?>
</div>
<!-- Toast Auto-Dismiss Script -->
<script>
// Auto-dismiss toasts after 5 seconds
document.addEventListener('DOMContentLoaded', function() {
const toasts = document.querySelectorAll('.toast');
toasts.forEach(toast => {
// Add fade-out animation after 5 seconds
setTimeout(() => {
toast.style.transition = 'opacity 0.3s ease-out, transform 0.3s ease-out';
toast.style.opacity = '0';
toast.style.transform = 'translateX(100%)';
// Remove from DOM after animation
setTimeout(() => {
toast.remove();
}, 300);
}, 5000);
});
});
</script>
<style>
@keyframes slideIn {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
.animate-slide-in {
animation: slideIn 0.3s ease-out;
}
</style>

View File

@@ -0,0 +1,102 @@
<!-- Sidebar Navigation -->
<aside id="sidebar" class="sidebar fixed left-0 top-0 w-64 bg-gray-900 text-white z-30">
<div class="h-full overflow-y-auto flex flex-col">
<!-- Logo Section -->
<div class="h-16 px-5 border-b border-gray-800 flex items-center">
<div class="flex items-center">
<div class="w-9 h-9 bg-primary rounded-lg flex items-center justify-center mr-3">
<i class="fas fa-globe text-white text-sm"></i>
</div>
<h1 class="text-sm font-semibold text-white"><?= $_ENV['APP_NAME'] ?? 'Domain Monitor' ?></h1>
</div>
</div>
<!-- Navigation Links -->
<nav class="px-4 py-3">
<div class="space-y-0.5">
<a href="/" class="sidebar-link flex items-center px-3 py-2 rounded-md text-gray-400 hover:bg-gray-800 hover:text-white transition-colors duration-150 <?= $_SERVER['REQUEST_URI'] === '/' ? 'bg-primary text-white' : '' ?>">
<i class="fas fa-chart-line text-xs mr-3 w-4"></i>
<span class="text-sm">Dashboard</span>
</a>
<a href="/domains" class="sidebar-link flex items-center px-3 py-2 rounded-md text-gray-400 hover:bg-gray-800 hover:text-white transition-colors duration-150 <?= strpos($_SERVER['REQUEST_URI'], '/domains') !== false ? 'bg-primary text-white' : '' ?>">
<i class="fas fa-globe text-xs mr-3 w-4"></i>
<span class="text-sm">Domains</span>
</a>
<a href="/groups" class="sidebar-link flex items-center px-3 py-2 rounded-md text-gray-400 hover:bg-gray-800 hover:text-white transition-colors duration-150 <?= strpos($_SERVER['REQUEST_URI'], '/groups') !== false ? 'bg-primary text-white' : '' ?>">
<i class="fas fa-bell text-xs mr-3 w-4"></i>
<span class="text-sm">Notification Groups</span>
</a>
<a href="/tld-registry" class="sidebar-link flex items-center px-3 py-2 rounded-md text-gray-400 hover:bg-gray-800 hover:text-white transition-colors duration-150 <?= strpos($_SERVER['REQUEST_URI'], '/tld-registry') !== false ? 'bg-primary text-white' : '' ?>">
<i class="fas fa-database text-xs mr-3 w-4"></i>
<span class="text-sm">TLD Registry</span>
</a>
</div>
<!-- Tools Section -->
<div class="mt-4 pt-3 border-t border-gray-800">
<p class="text-xs font-semibold text-gray-500 uppercase tracking-wider px-3 mb-1">Tools</p>
<div class="space-y-0.5">
<a href="/debug/whois" class="sidebar-link flex items-center px-3 py-2 rounded-md text-gray-400 hover:bg-gray-800 hover:text-white transition-colors duration-150">
<i class="fas fa-search text-xs mr-3 w-4"></i>
<span class="text-sm">WHOIS Lookup</span>
</a>
</div>
</div>
</nav>
<!-- Quick Stats Cards - Pinned to Bottom -->
<div class="mt-auto px-4 pb-3 border-t border-gray-800 pt-3">
<div class="text-xs font-semibold text-gray-500 uppercase tracking-wider px-3 mb-2">Quick Stats</div>
<div class="space-y-1.5">
<div class="bg-gray-800 rounded-md p-2.5 border border-gray-700">
<div class="flex items-center justify-between">
<div class="flex items-center">
<div class="w-7 h-7 bg-blue-500/20 rounded flex items-center justify-center mr-2.5">
<i class="fas fa-globe text-blue-400 text-xs"></i>
</div>
<span class="text-gray-400 text-xs">Total</span>
</div>
<span class="text-white font-semibold text-sm"><?= $globalStats['total'] ?? 0 ?></span>
</div>
</div>
<div class="bg-gray-800 rounded-md p-2.5 border border-gray-700">
<div class="flex items-center justify-between">
<div class="flex items-center">
<div class="w-7 h-7 bg-orange-500/20 rounded flex items-center justify-center mr-2.5">
<i class="fas fa-exclamation-triangle text-orange-400 text-xs"></i>
</div>
<span class="text-gray-400 text-xs">Expiring</span>
</div>
<span class="text-orange-400 font-semibold text-sm"><?= $globalStats['expiring_soon'] ?? 0 ?></span>
</div>
</div>
<div class="bg-gray-800 rounded-md p-2.5 border border-gray-700">
<div class="flex items-center justify-between">
<div class="flex items-center">
<div class="w-7 h-7 bg-green-500/20 rounded flex items-center justify-center mr-2.5">
<i class="fas fa-check-circle text-green-400 text-xs"></i>
</div>
<span class="text-gray-400 text-xs">Active</span>
</div>
<span class="text-green-400 font-semibold text-sm"><?= $globalStats['active'] ?? 0 ?></span>
</div>
</div>
</div>
</div>
<!-- Footer -->
<div class="px-4 py-3 border-t border-gray-800">
<div class="text-center">
<p class="text-xs text-gray-500">© <?= date('Y') ?> Domain Monitor</p>
<p class="text-xs text-gray-600 mt-0.5">v1.0.0</p>
</div>
</div>
</div>
</aside>

View File

@@ -0,0 +1,133 @@
<!-- Top Navigation Bar -->
<nav class="bg-white border-b border-gray-200 fixed top-0 left-0 md:left-64 right-0 z-20">
<div class="px-4 sm:px-6 lg:px-8">
<div class="flex items-center justify-between h-16">
<!-- Left: Menu button and Page Header -->
<div class="flex items-center min-w-0">
<button onclick="toggleSidebar()" class="text-gray-500 hover:text-gray-700 focus:outline-none focus:text-gray-700 md:hidden mr-4">
<i class="fas fa-bars text-xl"></i>
</button>
<!-- Page Title & Description -->
<div class="hidden md:block">
<h2 class="text-xl font-bold text-gray-800 flex items-center">
<?php if (isset($pageIcon)): ?>
<i class="<?= $pageIcon ?> text-primary mr-2"></i>
<?php endif; ?>
<?= $pageTitle ?? $title ?? 'Dashboard' ?>
</h2>
<?php if (isset($pageDescription)): ?>
<p class="text-sm text-gray-600 mt-0.5"><?= $pageDescription ?></p>
<?php endif; ?>
</div>
</div>
<!-- Center: Search Bar -->
<div class="flex-1 max-w-2xl mx-8">
<form action="/search" method="GET" class="relative hidden md:block" id="globalSearchForm">
<input type="text"
name="q"
placeholder="Search domains or lookup WHOIS..."
class="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent text-sm"
id="globalSearchInput"
autocomplete="off">
<i class="fas fa-search absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400"></i>
<!-- Search Results Dropdown -->
<div id="searchDropdown" class="hidden absolute top-full left-0 right-0 mt-2 bg-white rounded-lg shadow-xl border border-gray-200 max-h-96 overflow-y-auto z-50">
<!-- Loading state -->
<div id="searchLoading" class="hidden p-4 text-center">
<i class="fas fa-spinner fa-spin text-primary"></i>
<p class="text-sm text-gray-600 mt-2">Searching...</p>
</div>
<!-- Results will be inserted here -->
<div id="searchResults"></div>
</div>
</form>
</div>
<!-- Right: Actions & User -->
<div class="flex items-center space-x-2">
<!-- Quick Add Domain -->
<a href="/domains/create" title="Add Domain" class="flex items-center justify-center w-9 h-9 bg-primary hover:bg-primary-dark text-white rounded-lg transition-colors duration-150">
<i class="fas fa-plus"></i>
</a>
<!-- Notifications -->
<button title="Notifications" class="relative flex items-center justify-center w-9 h-9 text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-lg transition-colors duration-150">
<i class="fas fa-bell"></i>
<?php if (($globalStats['expiring_soon'] ?? 0) > 0): ?>
<span class="absolute top-1 right-1 flex h-2 w-2">
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-red-400 opacity-75"></span>
<span class="relative inline-flex rounded-full h-2 w-2 bg-red-500"></span>
</span>
<?php endif; ?>
</button>
<!-- Settings -->
<button title="Settings" class="flex items-center justify-center w-9 h-9 text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-lg transition-colors duration-150">
<i class="fas fa-cog"></i>
</button>
<!-- Divider -->
<div class="hidden md:block h-8 w-px bg-gray-300"></div>
<!-- User Dropdown -->
<div class="relative">
<button onclick="toggleDropdown()" class="flex items-center space-x-3 p-2 hover:bg-gray-100 rounded-lg transition-colors duration-150 focus:outline-none">
<div class="w-9 h-9 rounded-full bg-primary flex items-center justify-center text-white font-semibold">
<?= strtoupper(substr($_SESSION['username'] ?? 'A', 0, 1)) ?>
</div>
<div class="hidden lg:block text-left">
<p class="text-sm font-medium text-gray-700"><?= htmlspecialchars($_SESSION['username'] ?? 'User') ?></p>
<p class="text-xs text-gray-500">Administrator</p>
</div>
<i class="fas fa-chevron-down text-gray-400 text-xs hidden md:block"></i>
</button>
<!-- Dropdown Menu -->
<div id="userDropdown" class="dropdown-menu absolute right-0 mt-2 w-56 bg-white rounded-lg shadow-lg py-2 border border-gray-200">
<div class="px-4 py-3 border-b border-gray-200">
<p class="text-sm font-medium text-gray-900"><?= htmlspecialchars($_SESSION['full_name'] ?? $_SESSION['username'] ?? 'User') ?></p>
<p class="text-xs text-gray-500 mt-1"><?= htmlspecialchars($_SESSION['email'] ?? 'admin@example.com') ?></p>
<span class="inline-block mt-2 px-2 py-1 bg-green-100 text-green-800 text-xs font-semibold rounded">
<i class="fas fa-circle text-xs mr-1"></i>Online
</span>
</div>
<a href="#" class="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 transition-colors duration-150">
<i class="fas fa-user-circle w-5 text-gray-400 mr-3"></i>
My Profile
</a>
<a href="#" class="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 transition-colors duration-150">
<i class="fas fa-cog w-5 text-gray-400 mr-3"></i>
Account Settings
</a>
<a href="#" class="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 transition-colors duration-150">
<i class="fas fa-bell w-5 text-gray-400 mr-3"></i>
Notifications
</a>
<div class="border-t border-gray-200 my-1"></div>
<a href="#" class="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 transition-colors duration-150">
<i class="fas fa-question-circle w-5 text-gray-400 mr-3"></i>
Help & Support
</a>
<div class="border-t border-gray-200 my-1"></div>
<a href="/logout" class="flex items-center px-4 py-2 text-sm text-red-600 hover:bg-red-50 transition-colors duration-150">
<i class="fas fa-sign-out-alt w-5 mr-3"></i>
Logout
</a>
</div>
</div>
</div>
</div>
</div>
</nav>

View File

@@ -0,0 +1,304 @@
<?php
$title = 'Search Results';
$pageTitle = 'Search Results';
$pageDescription = 'Results for "' . htmlspecialchars($query) . '"';
$pageIcon = 'fas fa-search';
ob_start();
?>
<!-- Search Query Display -->
<div class="mb-4 bg-white rounded-lg border border-gray-200 p-4">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-600">Searching for:</p>
<h3 class="text-lg font-semibold text-gray-900"><?= htmlspecialchars($query) ?></h3>
</div>
<form action="/search" method="GET" class="flex gap-2">
<input type="text"
name="q"
value="<?= htmlspecialchars($query) ?>"
placeholder="Search again..."
class="px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary text-sm"
autofocus>
<button type="submit" class="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-dark text-sm font-medium">
<i class="fas fa-search mr-2"></i>
Search
</button>
</form>
</div>
</div>
<!-- Pagination Info & Per Page Selector -->
<?php if (!empty($existingDomains) && $pagination['total'] > 0): ?>
<div class="mb-4 flex justify-between items-center">
<div class="text-sm text-gray-600">
Showing <span class="font-semibold text-gray-900"><?= $pagination['showing_from'] ?></span> to
<span class="font-semibold text-gray-900"><?= $pagination['showing_to'] ?></span> of
<span class="font-semibold text-gray-900"><?= $pagination['total'] ?></span> result(s)
</div>
<form method="GET" action="/search" class="flex items-center gap-2">
<input type="hidden" name="q" value="<?= htmlspecialchars($query) ?>">
<label for="per_page" class="text-sm text-gray-600">Show:</label>
<select name="per_page" id="per_page" onchange="this.form.submit()" class="px-3 py-1.5 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-primary focus:border-primary">
<option value="10" <?= $pagination['per_page'] == 10 ? 'selected' : '' ?>>10</option>
<option value="25" <?= $pagination['per_page'] == 25 ? 'selected' : '' ?>>25</option>
<option value="50" <?= $pagination['per_page'] == 50 ? 'selected' : '' ?>>50</option>
<option value="100" <?= $pagination['per_page'] == 100 ? 'selected' : '' ?>>100</option>
</select>
</form>
</div>
<?php endif; ?>
<?php if (!empty($existingDomains)): ?>
<!-- Existing Domains Found -->
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden mb-4">
<div class="px-6 py-4 border-b border-gray-200 bg-green-50">
<h2 class="text-lg font-semibold text-green-900 flex items-center">
<i class="fas fa-check-circle text-green-600 mr-2"></i>
Found <?= $pagination['total'] ?> Matching Domain(s) in Your Portfolio
</h2>
</div>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 uppercase">Domain</th>
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 uppercase">Registrar</th>
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 uppercase">Expiration</th>
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 uppercase">Status</th>
<th class="px-6 py-3 text-right text-xs font-semibold text-gray-600 uppercase">Actions</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
<?php foreach ($existingDomains as $domain): ?>
<?php
$daysLeft = !empty($domain['expiration_date']) ? floor((strtotime($domain['expiration_date']) - time()) / 86400) : null;
$expiryClass = '';
if ($daysLeft !== null) {
if ($daysLeft < 0) $expiryClass = 'text-red-600 font-semibold';
elseif ($daysLeft <= 30) $expiryClass = 'text-orange-600 font-semibold';
elseif ($daysLeft <= 90) $expiryClass = 'text-yellow-600';
}
?>
<tr class="hover:bg-gray-50">
<td class="px-6 py-4">
<a href="/domains/<?= $domain['id'] ?>" class="text-sm font-semibold text-primary hover:text-primary-dark">
<?= htmlspecialchars($domain['domain_name']) ?>
</a>
</td>
<td class="px-6 py-4 text-sm text-gray-900">
<?= htmlspecialchars($domain['registrar'] ?? 'Unknown') ?>
</td>
<td class="px-6 py-4">
<?php if (!empty($domain['expiration_date'])): ?>
<div class="text-sm">
<div class="font-medium text-gray-900"><?= date('M d, Y', strtotime($domain['expiration_date'])) ?></div>
<div class="text-xs <?= $expiryClass ?>">
<?= $daysLeft ?> days
</div>
</div>
<?php else: ?>
<span class="text-sm text-gray-400">Not set</span>
<?php endif; ?>
</td>
<td class="px-6 py-4">
<?php
$statusClass = $domain['status'] === 'active'
? 'bg-green-100 text-green-800'
: 'bg-gray-100 text-gray-800';
?>
<span class="px-3 py-1 rounded-full text-xs font-semibold <?= $statusClass ?>">
<?= ucfirst($domain['status']) ?>
</span>
</td>
<td class="px-6 py-4 text-right">
<a href="/domains/<?= $domain['id'] ?>" class="text-blue-600 hover:text-blue-800 text-sm font-medium">
View Details →
</a>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<!-- Pagination Controls -->
<?php if ($pagination['total_pages'] > 1): ?>
<div class="mt-4 flex flex-col sm:flex-row items-center justify-between gap-4">
<!-- Page Info -->
<div class="text-sm text-gray-600">
Page <span class="font-semibold text-gray-900"><?= $pagination['current_page'] ?></span> of
<span class="font-semibold text-gray-900"><?= $pagination['total_pages'] ?></span>
</div>
<!-- Pagination Buttons -->
<div class="flex items-center gap-1">
<?php
$currentPage = $pagination['current_page'];
$totalPages = $pagination['total_pages'];
// Helper function to build pagination URL
function paginationUrl($page, $query, $perPage) {
return '/search?q=' . urlencode($query) . '&page=' . $page . '&per_page=' . $perPage;
}
?>
<!-- First Page -->
<?php if ($currentPage > 1): ?>
<a href="<?= paginationUrl(1, $query, $pagination['per_page']) ?>" class="px-3 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors">
<i class="fas fa-angle-double-left"></i>
</a>
<?php endif; ?>
<!-- Previous Page -->
<?php if ($currentPage > 1): ?>
<a href="<?= paginationUrl($currentPage - 1, $query, $pagination['per_page']) ?>" class="px-3 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors">
<i class="fas fa-angle-left"></i> Previous
</a>
<?php endif; ?>
<!-- Page Numbers -->
<?php
$range = 2; // Show 2 pages on each side of current page
$start = max(1, $currentPage - $range);
$end = min($totalPages, $currentPage + $range);
// Show first page + ellipsis if needed
if ($start > 1) {
echo '<a href="' . paginationUrl(1, $query, $pagination['per_page']) . '" class="px-3 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors">1</a>';
if ($start > 2) {
echo '<span class="px-2 text-gray-500">...</span>';
}
}
// Page numbers
for ($i = $start; $i <= $end; $i++) {
if ($i == $currentPage) {
echo '<span class="px-3 py-2 text-sm bg-primary text-white rounded-lg font-semibold">' . $i . '</span>';
} else {
echo '<a href="' . paginationUrl($i, $query, $pagination['per_page']) . '" class="px-3 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors">' . $i . '</a>';
}
}
// Show last page + ellipsis if needed
if ($end < $totalPages) {
if ($end < $totalPages - 1) {
echo '<span class="px-2 text-gray-500">...</span>';
}
echo '<a href="' . paginationUrl($totalPages, $query, $pagination['per_page']) . '" class="px-3 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors">' . $totalPages . '</a>';
}
?>
<!-- Next Page -->
<?php if ($currentPage < $totalPages): ?>
<a href="<?= paginationUrl($currentPage + 1, $query, $pagination['per_page']) ?>" class="px-3 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors">
Next <i class="fas fa-angle-right"></i>
</a>
<?php endif; ?>
<!-- Last Page -->
<?php if ($currentPage < $totalPages): ?>
<a href="<?= paginationUrl($totalPages, $query, $pagination['per_page']) ?>" class="px-3 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors">
<i class="fas fa-angle-double-right"></i>
</a>
<?php endif; ?>
</div>
</div>
<?php endif; ?>
<?php endif; ?>
<?php if ($isDomainLike && $pagination['total'] == 0): ?>
<!-- WHOIS Lookup Results -->
<?php if ($whoisData): ?>
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden mb-4">
<div class="px-6 py-4 border-b border-gray-200 bg-blue-50">
<h2 class="text-lg font-semibold text-blue-900 flex items-center">
<i class="fas fa-search text-blue-600 mr-2"></i>
WHOIS Lookup Results
</h2>
<p class="text-sm text-blue-700 mt-1">Domain not found in your portfolio - showing WHOIS information</p>
</div>
<div class="p-6">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label class="block text-sm font-semibold text-gray-600 mb-1">Domain</label>
<p class="text-lg font-semibold text-gray-900"><?= htmlspecialchars($whoisData['domain']) ?></p>
</div>
<div>
<label class="block text-sm font-semibold text-gray-600 mb-1">Registrar</label>
<p class="text-lg text-gray-900"><?= htmlspecialchars($whoisData['registrar']) ?></p>
</div>
<div>
<label class="block text-sm font-semibold text-gray-600 mb-1">Expiration Date</label>
<p class="text-lg text-gray-900">
<?= $whoisData['expiration_date'] ? date('M d, Y', strtotime($whoisData['expiration_date'])) : 'N/A' ?>
</p>
</div>
<div>
<label class="block text-sm font-semibold text-gray-600 mb-1">Creation Date</label>
<p class="text-lg text-gray-900">
<?= $whoisData['creation_date'] ? date('M d, Y', strtotime($whoisData['creation_date'])) : 'N/A' ?>
</p>
</div>
<?php if (!empty($whoisData['nameservers'])): ?>
<div class="md:col-span-2">
<label class="block text-sm font-semibold text-gray-600 mb-2">Nameservers</label>
<div class="flex flex-wrap gap-2">
<?php foreach ($whoisData['nameservers'] as $ns): ?>
<span class="px-3 py-1 bg-gray-100 text-gray-700 rounded text-sm font-mono"><?= htmlspecialchars($ns) ?></span>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
</div>
<!-- Add Domain Button -->
<div class="mt-6 pt-6 border-t border-gray-200">
<form method="POST" action="/domains/store" class="flex items-center justify-between">
<input type="hidden" name="domain_name" value="<?= htmlspecialchars($whoisData['domain']) ?>">
<p class="text-sm text-gray-600">Want to monitor this domain?</p>
<button type="submit" class="inline-flex items-center px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors text-sm font-medium">
<i class="fas fa-plus mr-2"></i>
Add to Portfolio
</button>
</form>
</div>
</div>
</div>
<?php elseif ($whoisError): ?>
<!-- WHOIS Error -->
<div class="bg-red-50 border border-red-200 rounded-lg p-6">
<div class="flex items-start">
<i class="fas fa-exclamation-circle text-red-500 text-xl mr-3 mt-0.5"></i>
<div>
<h3 class="text-sm font-semibold text-red-900">WHOIS Lookup Failed</h3>
<p class="text-sm text-red-700 mt-1"><?= htmlspecialchars($whoisError) ?></p>
</div>
</div>
</div>
<?php endif; ?>
<?php endif; ?>
<?php if ($pagination['total'] == 0 && !$isDomainLike): ?>
<!-- No Results -->
<div class="bg-yellow-50 border border-yellow-200 rounded-lg p-6">
<div class="flex items-start">
<i class="fas fa-info-circle text-yellow-500 text-xl mr-3 mt-0.5"></i>
<div>
<h3 class="text-sm font-semibold text-yellow-900">No Results Found</h3>
<p class="text-sm text-yellow-700 mt-1">
No domains match your search. Try a different search term or enter a domain name to perform a WHOIS lookup.
</p>
</div>
</div>
</div>
<?php endif; ?>
<?php
$content = ob_get_clean();
include __DIR__ . '/../layout/base.php';
?>

View File

@@ -0,0 +1,562 @@
<?php
$title = 'TLD Import Logs';
$pageTitle = 'TLD Import Logs';
$pageDescription = 'History of TLD registry import operations';
$pageIcon = 'fas fa-history';
ob_start();
?>
<!-- Header with Actions -->
<div class="mb-4 flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
<div>
<h1 class="text-2xl font-bold text-gray-900">Import Logs</h1>
<p class="text-gray-600 mt-1">History of TLD registry import operations</p>
</div>
<div class="flex gap-2">
<a href="/tld-registry" class="inline-flex items-center px-4 py-2.5 border border-gray-300 text-gray-700 text-sm rounded-lg hover:bg-gray-50 transition-colors font-medium">
<i class="fas fa-arrow-left mr-2"></i>
Back to Registry
</a>
</div>
</div>
<!-- Statistics Cards -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<!-- Total Imports Card -->
<div class="bg-white rounded-lg border border-gray-200 p-5 hover:shadow-md transition-shadow duration-200">
<div class="flex items-center justify-between">
<div>
<p class="text-xs font-medium text-gray-500 uppercase tracking-wide">Total Imports</p>
<p class="text-2xl font-semibold text-gray-900 mt-1"><?= $stats['total_imports'] ?? 0 ?></p>
</div>
<div class="w-12 h-12 bg-blue-50 rounded-lg flex items-center justify-center">
<i class="fas fa-download text-blue-600 text-lg"></i>
</div>
</div>
</div>
<!-- Successful Imports Card -->
<div class="bg-white rounded-lg border border-gray-200 p-5 hover:shadow-md transition-shadow duration-200">
<div class="flex items-center justify-between">
<div>
<p class="text-xs font-medium text-gray-500 uppercase tracking-wide">Successful</p>
<p class="text-2xl font-semibold text-gray-900 mt-1"><?= $stats['successful_imports'] ?? 0 ?></p>
</div>
<div class="w-12 h-12 bg-green-50 rounded-lg flex items-center justify-center">
<i class="fas fa-check-circle text-green-600 text-lg"></i>
</div>
</div>
</div>
<!-- Failed Imports Card -->
<div class="bg-white rounded-lg border border-gray-200 p-5 hover:shadow-md transition-shadow duration-200">
<div class="flex items-center justify-between">
<div>
<p class="text-xs font-medium text-gray-500 uppercase tracking-wide">Failed</p>
<p class="text-2xl font-semibold text-gray-900 mt-1"><?= $stats['failed_imports'] ?? 0 ?></p>
</div>
<div class="w-12 h-12 bg-red-50 rounded-lg flex items-center justify-center">
<i class="fas fa-times-circle text-red-600 text-lg"></i>
</div>
</div>
</div>
<!-- Last Import Card -->
<div class="bg-white rounded-lg border border-gray-200 p-5 hover:shadow-md transition-shadow duration-200">
<div class="flex items-center justify-between">
<div>
<p class="text-xs font-medium text-gray-500 uppercase tracking-wide">Last Import</p>
<p class="text-sm font-semibold text-gray-900 mt-1">
<?php if (!empty($stats['last_import'])): ?>
<?= date('M j, H:i', strtotime($stats['last_import'])) ?>
<?php else: ?>
Never
<?php endif; ?>
</p>
</div>
<div class="w-12 h-12 bg-purple-50 rounded-lg flex items-center justify-center">
<i class="fas fa-clock text-purple-600 text-lg"></i>
</div>
</div>
</div>
</div>
<!-- Import Logs Table -->
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
<?php if (!empty($imports)): ?>
<!-- Table View (Desktop) -->
<div class="hidden lg:block overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">Import Type</th>
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">Status</th>
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">Results</th>
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">Publication Date</th>
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">Started</th>
<th class="px-6 py-3 text-right text-xs font-semibold text-gray-600 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
<?php foreach ($imports as $import): ?>
<tr class="hover:bg-gray-50 transition-colors duration-150"
data-import-id="<?= $import['id'] ?>"
data-import-data="<?= htmlspecialchars(json_encode($import)) ?>">
<td class="px-6 py-4 whitespace-nowrap">
<div class="flex items-center">
<?php
$typeIcons = [
'tld_list' => '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'
];
$typeDescriptions = [
'tld_list' => 'IANA TLD list import',
'rdap' => 'RDAP server bootstrap data',
'whois' => 'WHOIS server & registry URLs',
'complete_workflow' => 'Full import (TLD List → RDAP → WHOIS)',
'check_updates' => 'IANA update verification',
'manual' => 'Manual data import'
];
$icon = $typeIcons[$import['import_type']] ?? 'fa-file-import';
$label = $typeLabels[$import['import_type']] ?? ucfirst($import['import_type']);
$description = $typeDescriptions[$import['import_type']] ?? 'Import operation';
?>
<div class="flex-shrink-0 h-10 w-10 bg-primary bg-opacity-10 rounded-lg flex items-center justify-center">
<i class="fas <?= $icon ?> text-primary"></i>
</div>
<div class="ml-4">
<div class="text-sm font-semibold text-gray-900"><?= $label ?></div>
<div class="text-sm text-gray-500"><?= $description ?></div>
</div>
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<?php
$statusClass = '';
$statusIcon = '';
$statusText = '';
if ($import['status'] === 'completed') {
$statusClass = 'bg-green-100 text-green-700 border-green-200';
$statusIcon = 'fa-check-circle';
$statusText = 'Completed';
} elseif ($import['status'] === 'failed') {
$statusClass = 'bg-red-100 text-red-700 border-red-200';
$statusIcon = 'fa-times-circle';
$statusText = 'Failed';
} else {
$statusClass = 'bg-yellow-100 text-yellow-700 border-yellow-200';
$statusIcon = 'fa-clock';
$statusText = 'In Progress';
}
?>
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold border <?= $statusClass ?>">
<i class="fas <?= $statusIcon ?> mr-1"></i>
<?= $statusText ?>
</span>
</td>
<td class="px-6 py-4">
<div class="text-sm text-gray-900">
<div class="flex items-center space-x-4">
<span class="flex items-center">
<i class="fas fa-globe text-gray-400 mr-1"></i>
<?= $import['total_tlds'] ?> total
</span>
<span class="flex items-center text-green-600">
<i class="fas fa-plus mr-1"></i>
<?= $import['new_tlds'] ?> new
</span>
<span class="flex items-center text-blue-600">
<i class="fas fa-sync mr-1"></i>
<?= $import['updated_tlds'] ?> updated
</span>
<?php if ($import['failed_tlds'] > 0): ?>
<span class="flex items-center text-red-600">
<i class="fas fa-exclamation-triangle mr-1"></i>
<?= $import['failed_tlds'] ?> failed
</span>
<?php endif; ?>
</div>
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
<?php if ($import['iana_publication_date']): ?>
<div class="flex items-center">
<i class="far fa-calendar mr-2"></i>
<?php
$date = $import['iana_publication_date'];
// Try to parse the date, if it fails, display as-is
$parsedDate = strtotime($date);
if ($parsedDate && $parsedDate > 0) {
echo date('M j, Y', $parsedDate);
} else {
echo htmlspecialchars($date);
}
?>
</div>
<?php else: ?>
<span class="text-gray-400">N/A</span>
<?php endif; ?>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
<div class="flex items-center">
<i class="far fa-clock mr-2"></i>
<?= date('M j, H:i', strtotime($import['started_at'])) ?>
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<button onclick="showImportDetails(<?= $import['id'] ?>)" class="text-primary hover:text-primary-dark">
<i class="fas fa-eye"></i>
</button>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<!-- Card View (Mobile) -->
<div class="lg:hidden divide-y divide-gray-200">
<?php foreach ($imports as $import): ?>
<div class="p-6 hover:bg-gray-50 transition-colors duration-150"
data-import-id="<?= $import['id'] ?>"
data-import-data="<?= htmlspecialchars(json_encode($import)) ?>">
<div class="flex items-center justify-between mb-3">
<div class="flex items-center">
<?php
$typeIcons = [
'tld_list' => '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']);
?>
<div class="w-10 h-10 bg-primary bg-opacity-10 rounded-lg flex items-center justify-center mr-3">
<i class="fas <?= $icon ?> text-primary"></i>
</div>
<div>
<h3 class="font-semibold text-gray-900"><?= $label ?></h3>
<p class="text-sm text-gray-500"><?= date('M j, Y H:i', strtotime($import['started_at'])) ?></p>
</div>
</div>
<?php
$statusClass = '';
$statusIcon = '';
$statusText = '';
if ($import['status'] === 'completed') {
$statusClass = 'bg-green-100 text-green-700';
$statusIcon = 'fa-check-circle';
$statusText = 'Completed';
} elseif ($import['status'] === 'failed') {
$statusClass = 'bg-red-100 text-red-700';
$statusIcon = 'fa-times-circle';
$statusText = 'Failed';
} else {
$statusClass = 'bg-yellow-100 text-yellow-700';
$statusIcon = 'fa-clock';
$statusText = 'In Progress';
}
?>
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-semibold <?= $statusClass ?>">
<i class="fas <?= $statusIcon ?> mr-1"></i>
<?= $statusText ?>
</span>
</div>
<div class="space-y-2 text-sm">
<div class="flex items-center justify-between">
<span class="text-gray-600">Total TLDs:</span>
<span class="font-semibold"><?= $import['total_tlds'] ?></span>
</div>
<div class="flex items-center justify-between">
<span class="text-gray-600">New:</span>
<span class="font-semibold text-green-600"><?= $import['new_tlds'] ?></span>
</div>
<div class="flex items-center justify-between">
<span class="text-gray-600">Updated:</span>
<span class="font-semibold text-blue-600"><?= $import['updated_tlds'] ?></span>
</div>
<?php if ($import['failed_tlds'] > 0): ?>
<div class="flex items-center justify-between">
<span class="text-gray-600">Failed:</span>
<span class="font-semibold text-red-600"><?= $import['failed_tlds'] ?></span>
</div>
<?php endif; ?>
</div>
<div class="flex space-x-2 mt-3">
<button onclick="showImportDetails(<?= $import['id'] ?>)" class="flex-1 px-3 py-1.5 bg-blue-50 text-blue-600 rounded text-center text-sm hover:bg-blue-100 transition-colors">
<i class="fas fa-eye mr-1"></i> Details
</button>
</div>
</div>
<?php endforeach; ?>
</div>
<!-- Pagination -->
<?php if ($pagination['total_pages'] > 1): ?>
<div class="bg-white px-4 py-3 flex items-center justify-between border-t border-gray-200 sm:px-6">
<div class="flex-1 flex justify-between sm:hidden">
<?php if ($pagination['current_page'] > 1): ?>
<a href="?page=<?= $pagination['current_page'] - 1 ?>"
class="relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">
Previous
</a>
<?php endif; ?>
<?php if ($pagination['current_page'] < $pagination['total_pages']): ?>
<a href="?page=<?= $pagination['current_page'] + 1 ?>"
class="ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">
Next
</a>
<?php endif; ?>
</div>
<div class="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
<div>
<p class="text-sm text-gray-700">
Showing <span class="font-medium"><?= $pagination['showing_from'] ?></span> to
<span class="font-medium"><?= $pagination['showing_to'] ?></span> of
<span class="font-medium"><?= $pagination['total'] ?></span> results
</p>
</div>
<div>
<nav class="relative z-0 inline-flex rounded-md shadow-sm -space-x-px" aria-label="Pagination">
<?php for ($i = 1; $i <= $pagination['total_pages']; $i++): ?>
<a href="?page=<?= $i ?>"
class="relative inline-flex items-center px-4 py-2 border text-sm font-medium <?= $i === $pagination['current_page'] ? 'z-10 bg-primary border-primary text-white' : 'bg-white border-gray-300 text-gray-500 hover:bg-gray-50' ?> <?= $i === 1 ? 'rounded-l-md' : '' ?> <?= $i === $pagination['total_pages'] ? 'rounded-r-md' : '' ?>">
<?= $i ?>
</a>
<?php endfor; ?>
</nav>
</div>
</div>
</div>
<?php endif; ?>
<?php else: ?>
<div class="text-center py-12 px-6">
<div class="mb-4">
<i class="fas fa-history text-gray-300 text-6xl"></i>
</div>
<h3 class="text-lg font-semibold text-gray-700 mb-1">No Import Logs</h3>
<p class="text-sm text-gray-500 mb-4">No TLD imports have been performed yet.</p>
<a href="/tld-registry" class="inline-flex items-center px-5 py-2.5 bg-primary text-white text-sm rounded-lg hover:bg-primary-dark transition-colors font-medium">
<i class="fas fa-arrow-left mr-2"></i>
Back to Registry
</a>
</div>
<?php endif; ?>
</div>
<!-- Import Details Modal -->
<div id="importDetailsModal" class="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full hidden z-50">
<div class="relative top-20 mx-auto p-5 border w-11/12 md:w-3/4 lg:w-1/2 shadow-lg rounded-md bg-white">
<div class="mt-3">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-900">Import Details</h3>
<button onclick="closeImportDetails()" class="text-gray-400 hover:text-gray-600">
<i class="fas fa-times text-xl"></i>
</button>
</div>
<div id="importDetailsContent" class="text-sm text-gray-600">
<!-- Content will be loaded here -->
</div>
</div>
</div>
</div>
<script>
function showImportDetails(importId) {
// Find the import data from the current page
const importData = findImportData(importId);
if (!importData) {
document.getElementById('importDetailsContent').innerHTML = `
<div class="text-center text-gray-500">
<p>Import details not found</p>
</div>
`;
document.getElementById('importDetailsModal').classList.remove('hidden');
return;
}
// Type labels mapping
const typeLabels = {
'tld_list': 'TLD List',
'rdap': 'RDAP Servers',
'whois': 'WHOIS Data',
'complete_workflow': 'Complete Workflow',
'check_updates': 'Update Check',
'manual': 'Manual Import'
};
const typeDescriptions = {
'tld_list': 'IANA TLD list import',
'rdap': 'RDAP server bootstrap data',
'whois': 'WHOIS server & registry URLs',
'complete_workflow': 'Full import (TLD List → RDAP → WHOIS)',
'check_updates': 'IANA update verification',
'manual': 'Manual data import'
};
const typeLabel = typeLabels[importData.import_type] || importData.import_type;
const typeDescription = typeDescriptions[importData.import_type] || 'Import operation';
// Calculate duration if we have both start and completion times
let duration = 'Unknown';
if (importData.started_at && importData.completed_at) {
const start = new Date(importData.started_at);
const end = new Date(importData.completed_at);
const diffMs = end - start;
const minutes = Math.floor(diffMs / 60000);
const seconds = Math.floor((diffMs % 60000) / 1000);
// If duration is very short (< 5 seconds), it might be manually completed
// Try to estimate from the log if it's a complete workflow
if (diffMs < 5000 && importData.import_type === 'complete_workflow') {
// Estimate: ~1 second per TLD for complete workflow
const estimatedSeconds = Math.round((importData.total_tlds || 0) * 1.1);
const estMinutes = Math.floor(estimatedSeconds / 60);
const estSeconds = estimatedSeconds % 60;
duration = `~${estMinutes} minutes ${estSeconds} seconds (estimated)`;
} else if (minutes === 0 && seconds === 0) {
duration = 'Less than 1 second';
} else {
duration = `${minutes} minutes ${seconds} seconds`;
}
}
// Determine status color
let statusClass = 'bg-gray-100 text-gray-800';
let statusText = 'Unknown';
if (importData.status === 'completed') {
statusClass = 'bg-green-100 text-green-800';
statusText = 'Completed';
} else if (importData.status === 'failed') {
statusClass = 'bg-red-100 text-red-800';
statusText = 'Failed';
} else if (importData.status === 'running') {
statusClass = 'bg-yellow-100 text-yellow-800';
statusText = 'Running';
}
document.getElementById('importDetailsContent').innerHTML = `
<div class="space-y-3">
<div class="flex justify-between">
<span class="font-medium">Import ID:</span>
<span>${importData.id}</span>
</div>
<div class="flex justify-between">
<span class="font-medium">Type:</span>
<span>${typeLabel}</span>
</div>
<div class="flex justify-between">
<span class="font-medium">Description:</span>
<span class="text-gray-600">${typeDescription}</span>
</div>
<div class="flex justify-between">
<span class="font-medium">Status:</span>
<span class="px-2 py-1 rounded text-xs ${statusClass}">${statusText}</span>
</div>
<div class="flex justify-between">
<span class="font-medium">Duration:</span>
<span>${duration}</span>
</div>
<div class="flex justify-between">
<span class="font-medium">Started:</span>
<span>${new Date(importData.started_at).toLocaleString()}</span>
</div>
${importData.completed_at ? `
<div class="flex justify-between">
<span class="font-medium">Completed:</span>
<span>${new Date(importData.completed_at).toLocaleString()}</span>
</div>
` : ''}
${importData.iana_publication_date ? `
<div class="flex justify-between">
<span class="font-medium">IANA Publication:</span>
<span>${importData.iana_publication_date}</span>
</div>
` : ''}
<div class="mt-4">
<h4 class="font-medium mb-2">Import Results:</h4>
<div class="bg-gray-100 p-3 rounded text-xs font-mono space-y-1">
<div>Total TLDs: ${importData.total_tlds || 0}</div>
<div>New TLDs: ${importData.new_tlds || 0}</div>
<div>Updated TLDs: ${importData.updated_tlds || 0}</div>
<div>Failed TLDs: ${importData.failed_tlds || 0}</div>
${importData.error_message ? `
<div class="text-red-600 mt-2">
<strong>Error:</strong> ${importData.error_message}
</div>
` : ''}
</div>
</div>
</div>
`;
document.getElementById('importDetailsModal').classList.remove('hidden');
}
function findImportData(importId) {
// Look for import data in the current page
const importRows = document.querySelectorAll('tr[data-import-id]');
for (let row of importRows) {
if (row.getAttribute('data-import-id') == importId) {
return JSON.parse(row.getAttribute('data-import-data'));
}
}
// Fallback: look for data in mobile view
const importCards = document.querySelectorAll('[data-import-id]');
for (let card of importCards) {
if (card.getAttribute('data-import-id') == importId) {
return JSON.parse(card.getAttribute('data-import-data'));
}
}
return null;
}
function closeImportDetails() {
document.getElementById('importDetailsModal').classList.add('hidden');
}
// Close modal when clicking outside
document.getElementById('importDetailsModal').addEventListener('click', function(e) {
if (e.target === this) {
closeImportDetails();
}
});
</script>
<?php
$content = ob_get_clean();
include __DIR__ . '/../layout/base.php';
?>

View File

@@ -0,0 +1,302 @@
<?php
$title = $title ?? 'Import Progress';
$pageTitle = $title;
$pageDescription = 'Progressive data import with real-time progress';
$pageIcon = 'fas fa-tasks';
ob_start();
?>
<div class="max-w-4xl mx-auto">
<!-- Header -->
<div class="mb-6">
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold text-gray-900"><?= htmlspecialchars($title) ?></h1>
<p class="text-gray-600 mt-1">
<?php
$descriptions = [
'tld_list' => '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';
?>
</p>
</div>
<a href="/tld-registry" class="inline-flex items-center px-4 py-2 border border-gray-300 text-gray-700 text-sm rounded-lg hover:bg-gray-50 transition-colors font-medium">
<i class="fas fa-arrow-left mr-2"></i>
Back to TLD Registry
</a>
</div>
</div>
<!-- Progress Card -->
<div class="bg-white rounded-lg border border-gray-200 p-6 mb-6">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold text-gray-900">Import Status</h2>
<div id="status-badge" class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-yellow-100 text-yellow-800">
<i class="fas fa-clock mr-2"></i>
<span id="status-text">Starting...</span>
</div>
</div>
<!-- Progress Bar -->
<div class="mb-4">
<div class="flex justify-between text-sm text-gray-600 mb-2">
<span id="progress-text">0 of 0 items processed</span>
<span id="percentage-text">0%</span>
</div>
<div class="w-full bg-gray-200 rounded-full h-3">
<div id="progress-bar" class="bg-blue-600 h-3 rounded-full transition-all duration-300" style="width: 0%"></div>
</div>
</div>
<!-- Step Progress (for complete workflow) -->
<div id="step-progress" class="mb-4" style="display: none;">
<div class="text-sm text-gray-600 mb-2">Workflow Steps:</div>
<div class="grid grid-cols-3 gap-2">
<div class="step-item bg-gray-100 rounded-lg p-2 text-center">
<div class="text-xs font-medium text-gray-600">Step 1</div>
<div class="text-xs text-gray-500">TLD List</div>
<div id="step-1-status" class="text-xs text-gray-400">Pending</div>
</div>
<div class="step-item bg-gray-100 rounded-lg p-2 text-center">
<div class="text-xs font-medium text-gray-600">Step 2</div>
<div class="text-xs text-gray-500">RDAP</div>
<div id="step-2-status" class="text-xs text-gray-400">Pending</div>
</div>
<div class="step-item bg-gray-100 rounded-lg p-2 text-center">
<div class="text-xs font-medium text-gray-600">Step 3</div>
<div class="text-xs text-gray-500">WHOIS & Registry</div>
<div id="step-3-status" class="text-xs text-gray-400">Pending</div>
</div>
</div>
</div>
<!-- Statistics -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
<div class="bg-gray-50 rounded-lg p-4">
<div class="flex items-center">
<div class="w-10 h-10 bg-blue-100 rounded-lg flex items-center justify-center mr-3">
<i class="fas fa-list text-blue-600"></i>
</div>
<div>
<p class="text-sm text-gray-500">Total</p>
<p id="total-count" class="text-xl font-semibold text-gray-900">0</p>
</div>
</div>
</div>
<div class="bg-gray-50 rounded-lg p-4">
<div class="flex items-center">
<div class="w-10 h-10 bg-green-100 rounded-lg flex items-center justify-center mr-3">
<i class="fas fa-check text-green-600"></i>
</div>
<div>
<p class="text-sm text-gray-500">Processed</p>
<p id="processed-count" class="text-xl font-semibold text-gray-900">0</p>
</div>
</div>
</div>
<div class="bg-gray-50 rounded-lg p-4">
<div class="flex items-center">
<div class="w-10 h-10 bg-red-100 rounded-lg flex items-center justify-center mr-3">
<i class="fas fa-times text-red-600"></i>
</div>
<div>
<p class="text-sm text-gray-500">Failed</p>
<p id="failed-count" class="text-xl font-semibold text-gray-900">0</p>
</div>
</div>
</div>
<div class="bg-gray-50 rounded-lg p-4">
<div class="flex items-center">
<div class="w-10 h-10 bg-orange-100 rounded-lg flex items-center justify-center mr-3">
<i class="fas fa-hourglass-half text-orange-600"></i>
</div>
<div>
<p class="text-sm text-gray-500">Remaining</p>
<p id="remaining-count" class="text-xl font-semibold text-gray-900">0</p>
</div>
</div>
</div>
</div>
</div>
<!-- Log Output -->
<div class="bg-white rounded-lg border border-gray-200 p-6">
<h3 class="text-lg font-semibold text-gray-900 mb-4">Import Log</h3>
<div id="log-output" class="bg-gray-900 text-green-400 p-4 rounded-lg font-mono text-sm h-64 overflow-y-auto">
<div class="text-gray-500">Initializing import process...</div>
</div>
</div>
</div>
<script>
let logId = <?= json_encode($log_id) ?>;
let importType = <?= json_encode($import_type) ?>;
let isComplete = false;
let totalProcessed = 0;
let totalFailed = 0;
// Show step progress for complete workflow
if (importType === 'complete_workflow') {
document.getElementById('step-progress').style.display = 'block';
}
function addLogMessage(message, type = 'info') {
const logOutput = document.getElementById('log-output');
const timestamp = new Date().toLocaleTimeString();
const colorClass = type === 'error' ? 'text-red-400' : type === 'success' ? 'text-green-400' : 'text-blue-400';
const logEntry = document.createElement('div');
logEntry.className = colorClass;
logEntry.innerHTML = `[${timestamp}] ${message}`;
logOutput.appendChild(logEntry);
logOutput.scrollTop = logOutput.scrollHeight;
}
function updateProgress(data) {
const total = data.total || 0;
const processed = data.processed || 0;
const failed = data.failed || 0;
const remaining = data.remaining || 0;
// Update counts (use absolute values, not cumulative)
document.getElementById('total-count').textContent = total;
document.getElementById('processed-count').textContent = processed;
document.getElementById('failed-count').textContent = failed;
document.getElementById('remaining-count').textContent = remaining;
// Update progress bar
const totalToProcess = processed + remaining;
const percentage = totalToProcess > 0 ? Math.round((processed / totalToProcess) * 100) : 0;
document.getElementById('progress-bar').style.width = percentage + '%';
document.getElementById('progress-text').textContent = `${processed} of ${totalToProcess} items processed`;
document.getElementById('percentage-text').textContent = percentage + '%';
// Update step progress for complete workflow
if (importType === 'complete_workflow' && data.message) {
updateStepProgress(data.message, processed, total);
}
// Update status
const statusBadge = document.getElementById('status-badge');
const statusText = document.getElementById('status-text');
if (data.status === 'complete') {
statusBadge.className = 'inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-green-100 text-green-800';
statusText.innerHTML = '<i class="fas fa-check mr-2"></i>Complete';
isComplete = true;
addLogMessage('Import completed successfully!', 'success');
// Mark all steps as completed for complete workflow
if (importType === 'complete_workflow') {
for (let i = 1; i <= 3; i++) {
updateStepStatus(i, 'completed');
}
}
} else if (data.status === 'in_progress') {
statusBadge.className = 'inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-blue-100 text-blue-800';
statusText.innerHTML = '<i class="fas fa-spinner fa-spin mr-2"></i>In Progress';
addLogMessage(data.message || 'Processing batch...', 'info');
} else if (data.status === 'error') {
statusBadge.className = 'inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-red-100 text-red-800';
statusText.innerHTML = '<i class="fas fa-exclamation-triangle mr-2"></i>Error';
addLogMessage(data.message || 'An error occurred', 'error');
isComplete = true;
}
}
function checkProgress() {
if (isComplete) {
return;
}
fetch(`/tld-registry/api/import-progress?log_id=${logId}`)
.then(response => response.json())
.then(data => {
if (data.error) {
addLogMessage('Error: ' + data.error, 'error');
isComplete = true;
return;
}
updateProgress(data);
if (data.status !== 'complete' && data.status !== 'error') {
setTimeout(checkProgress, 2000); // Check again in 2 seconds
}
})
.catch(error => {
addLogMessage('Network error: ' + error.message, 'error');
isComplete = true;
});
}
function updateStepProgress(message, currentStep, totalSteps) {
// Extract step number from message (handle both /3 and /4 formats)
const stepMatch = message.match(/Step (\d+)\/(\d+)/);
if (stepMatch) {
const stepNumber = parseInt(stepMatch[1]);
const totalSteps = parseInt(stepMatch[2]);
// Check if this step is completed
const isCompleted = message.toLowerCase().includes('completed');
if (isCompleted) {
// Mark all steps up to and including this one as completed
for (let i = 1; i <= stepNumber; i++) {
updateStepStatus(i, 'completed');
}
// Mark next step as in progress if not the last step
if (stepNumber < totalSteps) {
updateStepStatus(stepNumber + 1, 'in_progress');
}
} else {
// Step is in progress
// Mark previous steps as completed
for (let i = 1; i < stepNumber; i++) {
updateStepStatus(i, 'completed');
}
// Mark current step as in progress
updateStepStatus(stepNumber, 'in_progress');
}
}
}
function updateStepStatus(stepNumber, status) {
const stepElement = document.getElementById(`step-${stepNumber}-status`);
const stepItem = stepElement.closest('.step-item');
if (status === 'completed') {
stepElement.textContent = 'Completed';
stepElement.className = 'text-xs text-green-600';
stepItem.className = 'step-item bg-green-100 rounded-lg p-2 text-center';
} else if (status === 'in_progress') {
stepElement.textContent = 'In Progress';
stepElement.className = 'text-xs text-blue-600';
stepItem.className = 'step-item bg-blue-100 rounded-lg p-2 text-center';
} else if (status === 'failed') {
stepElement.textContent = 'Failed';
stepElement.className = 'text-xs text-red-600';
stepItem.className = 'step-item bg-red-100 rounded-lg p-2 text-center';
}
}
// Start checking progress
checkProgress();
</script>
<?php
$content = ob_get_clean();
include __DIR__ . '/../layout/base.php';
?>

View File

@@ -0,0 +1,578 @@
<?php
$title = 'TLD Registry';
$pageTitle = 'TLD Registry';
$pageDescription = 'Manage Top-Level Domain registry information';
$pageIcon = 'fas fa-database';
ob_start();
// Helper function to generate sort URL
function sortUrl($column, $currentSort, $currentOrder) {
$newOrder = ($currentSort === $column && $currentOrder === 'asc') ? 'desc' : 'asc';
$params = $_GET;
$params['sort'] = $column;
$params['order'] = $newOrder;
return '/tld-registry?' . http_build_query($params);
}
// Helper function for sort icon
function sortIcon($column, $currentSort, $currentOrder) {
if ($currentSort !== $column) {
return '<i class="fas fa-sort text-gray-400 ml-1 text-xs"></i>';
}
$icon = $currentOrder === 'asc' ? 'fa-sort-up' : 'fa-sort-down';
return '<i class="fas ' . $icon . ' text-primary ml-1 text-xs"></i>';
}
// Get current filters
$currentFilters = $filters ?? ['search' => '', 'sort' => 'tld', 'order' => 'asc'];
?>
<!-- Action Buttons -->
<div class="mb-4">
<div class="flex flex-wrap gap-2 justify-between items-center">
<div class="flex flex-wrap gap-2">
<form method="POST" action="/tld-registry/start-progressive-import" class="inline">
<input type="hidden" name="import_type" value="complete_workflow">
<button type="submit" class="inline-flex items-center px-4 py-2.5 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-700 transition-colors font-medium" title="Complete TLD import workflow: TLD List → RDAP → WHOIS → Registry URLs">
<i class="fas fa-rocket mr-2"></i>
Import TLDs
</button>
</form>
<form method="POST" action="/tld-registry/start-progressive-import" class="inline">
<input type="hidden" name="import_type" value="check_updates">
<button type="submit" <?= $stats['total'] == 0 ? 'disabled' : '' ?> class="inline-flex items-center px-4 py-2.5 <?= $stats['total'] == 0 ? 'bg-gray-400 cursor-not-allowed' : 'bg-purple-600 hover:bg-purple-700' ?> text-white text-sm rounded-lg transition-colors font-medium" title="<?= $stats['total'] == 0 ? 'Import TLDs first' : 'Check for IANA updates' ?>">
<i class="fas fa-sync-alt mr-2"></i>
Check Updates
</button>
</form>
</div>
<div class="flex gap-2">
<a href="/tld-registry/import-logs" class="inline-flex items-center px-4 py-2.5 border border-gray-300 text-gray-700 text-sm rounded-lg hover:bg-gray-50 transition-colors font-medium">
<i class="fas fa-history mr-2"></i>
Import Logs
</a>
</div>
</div>
</div>
<!-- Statistics Cards -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<!-- Total TLDs Card -->
<div class="bg-white rounded-lg border border-gray-200 p-5 hover:shadow-md transition-shadow duration-200">
<div class="flex items-center justify-between">
<div>
<p class="text-xs font-medium text-gray-500 uppercase tracking-wide">Total TLDs</p>
<p class="text-2xl font-semibold text-gray-900 mt-1"><?= $stats['total'] ?? 0 ?></p>
</div>
<div class="w-12 h-12 bg-blue-50 rounded-lg flex items-center justify-center">
<i class="fas fa-globe text-blue-600 text-lg"></i>
</div>
</div>
</div>
<!-- Active TLDs Card -->
<div class="bg-white rounded-lg border border-gray-200 p-5 hover:shadow-md transition-shadow duration-200">
<div class="flex items-center justify-between">
<div>
<p class="text-xs font-medium text-gray-500 uppercase tracking-wide">Active</p>
<p class="text-2xl font-semibold text-gray-900 mt-1"><?= $stats['active'] ?? 0 ?></p>
</div>
<div class="w-12 h-12 bg-green-50 rounded-lg flex items-center justify-center">
<i class="fas fa-check-circle text-green-600 text-lg"></i>
</div>
</div>
</div>
<!-- With RDAP Card -->
<div class="bg-white rounded-lg border border-gray-200 p-5 hover:shadow-md transition-shadow duration-200">
<div class="flex items-center justify-between">
<div>
<p class="text-xs font-medium text-gray-500 uppercase tracking-wide">With RDAP</p>
<p class="text-2xl font-semibold text-gray-900 mt-1"><?= $stats['with_rdap'] ?? 0 ?></p>
</div>
<div class="w-12 h-12 bg-purple-50 rounded-lg flex items-center justify-center">
<i class="fas fa-database text-purple-600 text-lg"></i>
</div>
</div>
</div>
<!-- With WHOIS Card -->
<div class="bg-white rounded-lg border border-gray-200 p-5 hover:shadow-md transition-shadow duration-200">
<div class="flex items-center justify-between">
<div>
<p class="text-xs font-medium text-gray-500 uppercase tracking-wide">With WHOIS</p>
<p class="text-2xl font-semibold text-gray-900 mt-1"><?= $stats['with_whois'] ?? 0 ?></p>
</div>
<div class="w-12 h-12 bg-orange-50 rounded-lg flex items-center justify-center">
<i class="fas fa-server text-orange-600 text-lg"></i>
</div>
</div>
</div>
</div>
<!-- Search and Filters -->
<div class="bg-white rounded-lg border border-gray-200 p-5 mb-4">
<form method="GET" action="/tld-registry" id="filter-form">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<!-- Search -->
<div>
<label class="block text-xs font-medium text-gray-700 mb-1.5">Search TLDs</label>
<div class="relative">
<input type="text" name="search" id="tldSearch" value="<?= htmlspecialchars($currentFilters['search']) ?>" placeholder="Search TLDs..." class="w-full pl-9 pr-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary text-sm">
<i class="fas fa-search absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 text-xs"></i>
</div>
</div>
<!-- Status Filter -->
<div>
<label class="block text-xs font-medium text-gray-700 mb-1.5">Status</label>
<select name="status" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary text-sm">
<option value="">All Status</option>
<option value="active" <?= ($_GET['status'] ?? '') === 'active' ? 'selected' : '' ?>>Active</option>
<option value="inactive" <?= ($_GET['status'] ?? '') === 'inactive' ? 'selected' : '' ?>>Inactive</option>
</select>
</div>
<!-- Data Type Filter -->
<div>
<label class="block text-xs font-medium text-gray-700 mb-1.5">Data Type</label>
<select name="data_type" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary text-sm">
<option value="">All Types</option>
<option value="with_rdap" <?= ($_GET['data_type'] ?? '') === 'with_rdap' ? 'selected' : '' ?>>With RDAP</option>
<option value="with_whois" <?= ($_GET['data_type'] ?? '') === 'with_whois' ? 'selected' : '' ?>>With WHOIS</option>
<option value="with_registry" <?= ($_GET['data_type'] ?? '') === 'with_registry' ? 'selected' : '' ?>>With Registry URL</option>
<option value="missing_data" <?= ($_GET['data_type'] ?? '') === 'missing_data' ? 'selected' : '' ?>>Missing Data</option>
</select>
</div>
<!-- Actions -->
<div class="flex items-end space-x-2">
<button type="submit" class="flex-1 px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors text-sm font-medium">
<i class="fas fa-filter mr-2"></i>
Apply
</button>
<a href="/tld-registry" class="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors text-sm font-medium">
<i class="fas fa-times mr-2"></i>
Clear
</a>
</div>
</div>
<input type="hidden" name="sort" value="<?= htmlspecialchars($currentFilters['sort']) ?>">
<input type="hidden" name="order" value="<?= htmlspecialchars($currentFilters['order']) ?>">
</form>
</div>
<!-- Pagination Info & Per Page Selector -->
<div class="mb-4 flex justify-between items-center">
<div class="text-sm text-gray-600">
Showing <span class="font-semibold text-gray-900"><?= $pagination['showing_from'] ?></span> to
<span class="font-semibold text-gray-900"><?= $pagination['showing_to'] ?></span> of
<span class="font-semibold text-gray-900"><?= $pagination['total'] ?></span> TLD(s)
</div>
<form method="GET" action="/tld-registry" class="flex items-center gap-2">
<!-- Preserve current filters -->
<input type="hidden" name="search" value="<?= htmlspecialchars($currentFilters['search']) ?>">
<input type="hidden" name="sort" value="<?= htmlspecialchars($currentFilters['sort']) ?>">
<input type="hidden" name="order" value="<?= htmlspecialchars($currentFilters['order']) ?>">
<label for="per_page" class="text-sm text-gray-600">Show:</label>
<select name="per_page" id="per_page" onchange="this.form.submit()" class="px-3 py-1.5 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-primary focus:border-primary">
<option value="10" <?= $pagination['per_page'] == 10 ? 'selected' : '' ?>>10</option>
<option value="25" <?= $pagination['per_page'] == 25 ? 'selected' : '' ?>>25</option>
<option value="50" <?= $pagination['per_page'] == 50 ? 'selected' : '' ?>>50</option>
<option value="100" <?= $pagination['per_page'] == 100 ? 'selected' : '' ?>>100</option>
</select>
</form>
</div>
<!-- Bulk Actions -->
<?php if (!empty($tlds)): ?>
<div class="bg-white rounded-lg border border-gray-200 p-4 mb-4">
<div class="flex items-center justify-between">
<div class="flex items-center space-x-4">
<span class="text-sm text-gray-600">Bulk Actions:</span>
<form method="POST" action="/tld-registry/bulk-delete" id="bulk-delete-form" class="inline">
<button type="button" onclick="confirmBulkDelete()" class="inline-flex items-center px-3 py-2 border border-red-300 text-red-700 text-sm rounded-lg hover:bg-red-50 transition-colors font-medium">
<i class="fas fa-trash mr-2"></i>
Delete Selected
</button>
</form>
</div>
<div class="text-sm text-gray-500">
<span id="selected-count">0</span> selected
</div>
</div>
</div>
<?php endif; ?>
<!-- TLD Registry Table -->
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
<?php if (!empty($tlds)): ?>
<!-- Table View (Desktop) -->
<div class="hidden lg:block overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">
<input type="checkbox" id="select-all" class="rounded border-gray-300 text-primary focus:ring-primary" onchange="toggleAllCheckboxes(this)">
</th>
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">
<a href="<?= sortUrl('tld', $currentFilters['sort'], $currentFilters['order']) ?>" class="hover:text-primary flex items-center">
TLD <?= sortIcon('tld', $currentFilters['sort'], $currentFilters['order']) ?>
</a>
</th>
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">
<a href="<?= sortUrl('rdap_servers', $currentFilters['sort'], $currentFilters['order']) ?>" class="hover:text-primary flex items-center">
RDAP Servers <?= sortIcon('rdap_servers', $currentFilters['sort'], $currentFilters['order']) ?>
</a>
</th>
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">
<a href="<?= sortUrl('whois_server', $currentFilters['sort'], $currentFilters['order']) ?>" class="hover:text-primary flex items-center">
WHOIS Server <?= sortIcon('whois_server', $currentFilters['sort'], $currentFilters['order']) ?>
</a>
</th>
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">
<a href="<?= sortUrl('updated_at', $currentFilters['sort'], $currentFilters['order']) ?>" class="hover:text-primary flex items-center">
Last Updated <?= sortIcon('updated_at', $currentFilters['sort'], $currentFilters['order']) ?>
</a>
</th>
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">
<a href="<?= sortUrl('is_active', $currentFilters['sort'], $currentFilters['order']) ?>" class="hover:text-primary flex items-center">
Status <?= sortIcon('is_active', $currentFilters['sort'], $currentFilters['order']) ?>
</a>
</th>
<th class="px-6 py-3 text-right text-xs font-semibold text-gray-600 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
<?php foreach ($tlds as $tld): ?>
<tr class="hover:bg-gray-50 transition-colors duration-150">
<td class="px-6 py-4 whitespace-nowrap">
<input type="checkbox" name="tld_ids[]" value="<?= $tld['id'] ?>" class="tld-checkbox rounded border-gray-300 text-primary focus:ring-primary">
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div class="flex items-center">
<div class="flex-shrink-0 h-10 w-10 bg-primary bg-opacity-10 rounded-lg flex items-center justify-center">
<i class="fas fa-globe text-primary"></i>
</div>
<div class="ml-4">
<div class="text-sm font-semibold text-gray-900"><?= htmlspecialchars($tld['tld']) ?></div>
<?php if ($tld['registry_url']): ?>
<div class="text-sm text-gray-500">
<a href="<?= htmlspecialchars($tld['registry_url']) ?>" target="_blank" class="text-primary hover:text-primary-dark">
<i class="fas fa-external-link-alt mr-1"></i>
Registry
</a>
</div>
<?php endif; ?>
</div>
</div>
</td>
<td class="px-6 py-4">
<?php if ($tld['rdap_servers']): ?>
<?php
$rdapServers = json_decode($tld['rdap_servers'], true);
if (is_array($rdapServers) && !empty($rdapServers)):
?>
<div class="text-sm text-gray-900">
<?php foreach (array_slice($rdapServers, 0, 2) as $server): ?>
<div class="font-mono text-xs bg-gray-50 px-2 py-1 rounded mb-1"><?= htmlspecialchars($server) ?></div>
<?php endforeach; ?>
<?php if (count($rdapServers) > 2): ?>
<div class="text-xs text-gray-500">+<?= count($rdapServers) - 2 ?> more</div>
<?php endif; ?>
</div>
<?php else: ?>
<span class="text-sm text-gray-400">None</span>
<?php endif; ?>
<?php else: ?>
<span class="text-sm text-gray-400">None</span>
<?php endif; ?>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<?php if ($tld['whois_server']): ?>
<div class="text-sm font-mono text-gray-900 bg-gray-50 px-2 py-1 rounded"><?= htmlspecialchars($tld['whois_server']) ?></div>
<?php else: ?>
<span class="text-sm text-gray-400">None</span>
<?php endif; ?>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
<?php if ($tld['updated_at']): ?>
<div class="flex items-center">
<i class="far fa-clock mr-2"></i>
<?= date('M d, H:i', strtotime($tld['updated_at'])) ?>
</div>
<?php else: ?>
<span class="text-gray-400">Never</span>
<?php endif; ?>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold border <?= $tld['is_active'] ? 'bg-green-100 text-green-700 border-green-200' : 'bg-gray-100 text-gray-700 border-gray-200' ?>">
<i class="fas <?= $tld['is_active'] ? 'fa-check-circle' : 'fa-pause-circle' ?> mr-1"></i>
<?= $tld['is_active'] ? 'Active' : 'Inactive' ?>
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<div class="flex items-center justify-end space-x-2">
<a href="/tld-registry/<?= $tld['id'] ?>" class="text-blue-600 hover:text-blue-800" title="View">
<i class="fas fa-eye"></i>
</a>
<a href="/tld-registry/<?= $tld['id'] ?>/refresh" class="text-green-600 hover:text-green-800" title="Refresh" onclick="return confirm('Refresh TLD data from IANA?')">
<i class="fas fa-sync-alt"></i>
</a>
<a href="/tld-registry/<?= $tld['id'] ?>/toggle-active" class="text-orange-600 hover:text-orange-800" title="Toggle Status" onclick="return confirm('Toggle TLD status?')">
<i class="fas fa-power-off"></i>
</a>
</div>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<!-- Card View (Mobile) -->
<div class="lg:hidden divide-y divide-gray-200">
<?php foreach ($tlds as $tld): ?>
<div class="p-6 hover:bg-gray-50 transition-colors duration-150">
<div class="flex items-center justify-between mb-3">
<div class="flex items-center">
<div class="w-10 h-10 bg-primary bg-opacity-10 rounded-lg flex items-center justify-center mr-3">
<i class="fas fa-globe text-primary"></i>
</div>
<div>
<h3 class="font-semibold text-gray-900"><?= htmlspecialchars($tld['tld']) ?></h3>
<?php if ($tld['registry_url']): ?>
<a href="<?= htmlspecialchars($tld['registry_url']) ?>" target="_blank" class="text-xs text-primary hover:text-primary-dark">
<i class="fas fa-external-link-alt mr-1"></i>
Registry
</a>
<?php endif; ?>
</div>
</div>
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-semibold <?= $tld['is_active'] ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-700' ?>">
<?= $tld['is_active'] ? 'Active' : 'Inactive' ?>
</span>
</div>
<div class="space-y-2 text-sm">
<?php if ($tld['rdap_servers']): ?>
<?php
$rdapServers = json_decode($tld['rdap_servers'], true);
if (is_array($rdapServers) && !empty($rdapServers)):
?>
<div class="flex items-start">
<i class="fas fa-database text-gray-400 mr-2 w-4 mt-0.5"></i>
<div class="flex-1">
<div class="font-mono text-xs bg-gray-50 px-2 py-1 rounded mb-1"><?= htmlspecialchars($rdapServers[0]) ?></div>
<?php if (count($rdapServers) > 1): ?>
<div class="text-xs text-gray-500">+<?= count($rdapServers) - 1 ?> more RDAP server(s)</div>
<?php endif; ?>
</div>
</div>
<?php endif; ?>
<?php endif; ?>
<?php if ($tld['whois_server']): ?>
<div class="flex items-center">
<i class="fas fa-server text-gray-400 mr-2 w-4"></i>
<span class="font-mono text-xs bg-gray-50 px-2 py-1 rounded"><?= htmlspecialchars($tld['whois_server']) ?></span>
</div>
<?php endif; ?>
<div class="flex items-center">
<i class="far fa-clock text-gray-400 mr-2 w-4"></i>
<span class="text-gray-500"><?= $tld['updated_at'] ? date('M d, H:i', strtotime($tld['updated_at'])) : 'Never updated' ?></span>
</div>
</div>
<div class="flex space-x-2 mt-3">
<a href="/tld-registry/<?= $tld['id'] ?>" class="flex-1 px-3 py-1.5 bg-blue-50 text-blue-600 rounded text-center text-sm hover:bg-blue-100 transition-colors">
<i class="fas fa-eye mr-1"></i> View
</a>
<a href="/tld-registry/<?= $tld['id'] ?>/refresh" class="flex-1 px-3 py-1.5 bg-green-50 text-green-600 rounded text-center text-sm hover:bg-green-100 transition-colors" onclick="return confirm('Refresh TLD data?')">
<i class="fas fa-sync-alt mr-1"></i> Refresh
</a>
</div>
</div>
<?php endforeach; ?>
</div>
<?php else: ?>
<div class="text-center py-12 px-6">
<div class="mb-4">
<i class="fas fa-globe text-gray-300 text-6xl"></i>
</div>
<h3 class="text-lg font-semibold text-gray-700 mb-1">No TLDs Found</h3>
<p class="text-sm text-gray-500 mb-4">
<?php if (!empty($currentFilters['search'])): ?>
No TLDs match your search criteria.
<?php else: ?>
Start by importing the TLD list from IANA.
<?php endif; ?>
</p>
<?php if (empty($currentFilters['search'])): ?>
<form method="POST" action="/tld-registry/start-progressive-import" class="inline">
<input type="hidden" name="import_type" value="complete_workflow">
<button type="submit" class="inline-flex items-center px-5 py-2.5 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-700 transition-colors font-medium">
<i class="fas fa-rocket mr-2"></i>
Import TLDs
</button>
</form>
<?php endif; ?>
</div>
<?php endif; ?>
</div>
<!-- Pagination Controls -->
<?php if ($pagination['total_pages'] > 1): ?>
<div class="mt-4 flex flex-col sm:flex-row items-center justify-between gap-4">
<!-- Page Info -->
<div class="text-sm text-gray-600">
Page <span class="font-semibold text-gray-900"><?= $pagination['current_page'] ?></span> of
<span class="font-semibold text-gray-900"><?= $pagination['total_pages'] ?></span>
</div>
<!-- Pagination Buttons -->
<div class="flex items-center gap-1">
<?php
$currentPage = $pagination['current_page'];
$totalPages = $pagination['total_pages'];
// Helper function to build pagination URL
function paginationUrl($page, $filters, $perPage) {
$params = $filters;
$params['page'] = $page;
$params['per_page'] = $perPage;
return '/tld-registry?' . http_build_query($params);
}
?>
<!-- First Page -->
<?php if ($currentPage > 1): ?>
<a href="<?= paginationUrl(1, $currentFilters, $pagination['per_page']) ?>" class="px-3 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors">
<i class="fas fa-angle-double-left"></i>
</a>
<?php endif; ?>
<!-- Previous Page -->
<?php if ($currentPage > 1): ?>
<a href="<?= paginationUrl($currentPage - 1, $currentFilters, $pagination['per_page']) ?>" class="px-3 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors">
<i class="fas fa-angle-left"></i> Previous
</a>
<?php endif; ?>
<!-- Page Numbers -->
<?php
$range = 2; // Show 2 pages on each side of current page
$start = max(1, $currentPage - $range);
$end = min($totalPages, $currentPage + $range);
// Show first page + ellipsis if needed
if ($start > 1) {
echo '<a href="' . paginationUrl(1, $currentFilters, $pagination['per_page']) . '" class="px-3 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors">1</a>';
if ($start > 2) {
echo '<span class="px-2 text-gray-500">...</span>';
}
}
// Page numbers
for ($i = $start; $i <= $end; $i++) {
if ($i == $currentPage) {
echo '<span class="px-3 py-2 text-sm bg-primary text-white rounded-lg font-semibold">' . $i . '</span>';
} else {
echo '<a href="' . paginationUrl($i, $currentFilters, $pagination['per_page']) . '" class="px-3 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors">' . $i . '</a>';
}
}
// Show last page + ellipsis if needed
if ($end < $totalPages) {
if ($end < $totalPages - 1) {
echo '<span class="px-2 text-gray-500">...</span>';
}
echo '<a href="' . paginationUrl($totalPages, $currentFilters, $pagination['per_page']) . '" class="px-3 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors">' . $totalPages . '</a>';
}
?>
<!-- Next Page -->
<?php if ($currentPage < $totalPages): ?>
<a href="<?= paginationUrl($currentPage + 1, $currentFilters, $pagination['per_page']) ?>" class="px-3 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors">
Next <i class="fas fa-angle-right"></i>
</a>
<?php endif; ?>
<!-- Last Page -->
<?php if ($currentPage < $totalPages): ?>
<a href="<?= paginationUrl($totalPages, $currentFilters, $pagination['per_page']) ?>" class="px-3 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors">
<i class="fas fa-angle-double-right"></i>
</a>
<?php endif; ?>
</div>
</div>
<?php endif; ?>
<script>
function toggleAllCheckboxes(selectAllCheckbox) {
const checkboxes = document.querySelectorAll('.tld-checkbox');
checkboxes.forEach(checkbox => {
checkbox.checked = selectAllCheckbox.checked;
});
updateSelectedCount();
}
function updateSelectedCount() {
const checkboxes = document.querySelectorAll('.tld-checkbox:checked');
const count = checkboxes.length;
document.getElementById('selected-count').textContent = count;
// Update select all checkbox state
const selectAllCheckbox = document.getElementById('select-all');
const allCheckboxes = document.querySelectorAll('.tld-checkbox');
if (count === 0) {
selectAllCheckbox.indeterminate = false;
selectAllCheckbox.checked = false;
} else if (count === allCheckboxes.length) {
selectAllCheckbox.indeterminate = false;
selectAllCheckbox.checked = true;
} else {
selectAllCheckbox.indeterminate = true;
}
}
function confirmBulkDelete() {
const checkboxes = document.querySelectorAll('.tld-checkbox:checked');
if (checkboxes.length === 0) {
alert('Please select TLDs to delete');
return;
}
if (confirm(`Are you sure you want to delete ${checkboxes.length} selected TLD(s)? This action cannot be undone.`)) {
// Add selected checkboxes to form
const form = document.getElementById('bulk-delete-form');
checkboxes.forEach(checkbox => {
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'tld_ids[]';
input.value = checkbox.value;
form.appendChild(input);
});
form.submit();
}
}
// Add event listeners to checkboxes
document.addEventListener('DOMContentLoaded', function() {
const checkboxes = document.querySelectorAll('.tld-checkbox');
checkboxes.forEach(checkbox => {
checkbox.addEventListener('change', updateSelectedCount);
});
updateSelectedCount();
});
</script>
<?php
$content = ob_get_clean();
include __DIR__ . '/../layout/base.php';
?>

View File

@@ -0,0 +1,258 @@
<?php
$title = 'TLD Details';
$pageTitle = htmlspecialchars($tld['tld']);
$pageDescription = 'TLD registry information and server details';
$pageIcon = 'fas fa-globe';
ob_start();
?>
<!-- Top Action Bar -->
<div class="mb-3 flex flex-wrap gap-2 justify-between items-center">
<div class="flex gap-2">
<span class="inline-flex items-center px-3 py-1.5 rounded-lg text-xs font-semibold bg-primary text-white">
<i class="fas fa-globe mr-1.5"></i>
TLD Registry
</span>
<span class="inline-flex items-center px-3 py-1.5 rounded-lg text-xs font-semibold <?= $tld['is_active'] ? 'bg-green-100 text-green-700 border border-green-200' : 'bg-gray-100 text-gray-700 border border-gray-200' ?>">
<i class="fas <?= $tld['is_active'] ? 'fa-check-circle' : 'fa-pause-circle' ?> mr-1.5"></i>
<?= $tld['is_active'] ? 'Active' : 'Inactive' ?>
</span>
</div>
<div class="flex gap-2 items-center">
<a href="/tld-registry/<?= $tld['id'] ?>/refresh" class="inline-flex items-center justify-center px-3 py-2 bg-green-600 text-white text-xs rounded-lg hover:bg-green-700 transition-colors font-medium min-w-[80px] h-[32px]" onclick="return confirm('Refresh TLD data from IANA?')">
<i class="fas fa-sync-alt mr-1.5"></i>
Refresh
</a>
<a href="/tld-registry/<?= $tld['id'] ?>/toggle-active" class="inline-flex items-center justify-center px-3 py-2 bg-orange-600 text-white text-xs rounded-lg hover:bg-orange-700 transition-colors font-medium min-w-[80px] h-[32px]" onclick="return confirm('Toggle TLD status?')">
<i class="fas fa-power-off mr-1.5"></i>
Toggle
</a>
<a href="/tld-registry" class="inline-flex items-center justify-center px-3 py-2 border border-gray-300 text-gray-700 text-xs rounded-lg hover:bg-gray-50 transition-colors font-medium min-w-[80px] h-[32px]">
<i class="fas fa-arrow-left mr-1.5"></i>
Back
</a>
</div>
</div>
<!-- Main 2-Column Layout -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-3">
<!-- LEFT COLUMN -->
<div class="space-y-3">
<!-- TLD Information -->
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
<div class="px-4 py-2 border-b border-gray-200 bg-gray-50">
<h3 class="text-xs font-semibold text-gray-700 uppercase tracking-wider flex items-center">
<i class="fas fa-info-circle text-gray-400 mr-2" style="font-size: 10px;"></i>
TLD Information
</h3>
</div>
<div class="p-4">
<div class="grid grid-cols-2 gap-x-4 gap-y-3 text-xs">
<div>
<label class="text-gray-500 font-medium block mb-0.5">TLD</label>
<p class="text-gray-900 font-semibold"><?= htmlspecialchars($tld['tld']) ?></p>
</div>
<?php if ($tld['registry_url']): ?>
<div>
<label class="text-gray-500 font-medium block mb-0.5">Registry URL</label>
<a href="<?= htmlspecialchars($tld['registry_url']) ?>" target="_blank" class="text-blue-600 hover:text-blue-800 flex items-center">
<i class="fas fa-external-link-alt mr-1" style="font-size: 9px;"></i>
Visit Registry
</a>
</div>
<?php endif; ?>
<?php if ($tld['registration_date']): ?>
<div>
<label class="text-gray-500 font-medium block mb-0.5">Registration Date</label>
<p class="text-gray-900"><?= date('M j, Y', strtotime($tld['registration_date'])) ?></p>
</div>
<?php endif; ?>
<?php if ($tld['record_last_updated']): ?>
<div>
<label class="text-gray-500 font-medium block mb-0.5">Record Last Updated</label>
<p class="text-gray-900"><?= date('M j, Y', strtotime($tld['record_last_updated'])) ?></p>
</div>
<?php endif; ?>
</div>
</div>
</div>
<!-- RDAP Servers -->
<?php if ($tld['rdap_servers']): ?>
<?php
$rdapServers = json_decode($tld['rdap_servers'], true);
if (is_array($rdapServers) && !empty($rdapServers)):
?>
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
<div class="px-4 py-2 border-b border-gray-200 bg-gray-50">
<h3 class="text-xs font-semibold text-gray-700 uppercase tracking-wider flex items-center">
<i class="fas fa-database text-gray-400 mr-2" style="font-size: 10px;"></i>
RDAP Servers (<?= count($rdapServers) ?>)
</h3>
</div>
<div class="p-4">
<div class="space-y-1.5">
<?php foreach ($rdapServers as $index => $server): ?>
<div class="flex items-center p-2 bg-gray-50 rounded hover:bg-gray-100 transition-colors">
<div class="w-6 h-6 bg-purple-500 rounded flex items-center justify-center text-white font-bold text-xs mr-2">
<?= $index + 1 ?>
</div>
<p class="font-mono text-xs text-gray-800"><?= htmlspecialchars($server) ?></p>
</div>
<?php endforeach; ?>
</div>
</div>
</div>
<?php endif; ?>
<?php endif; ?>
<!-- WHOIS Server -->
<?php if ($tld['whois_server']): ?>
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
<div class="px-4 py-2 border-b border-gray-200 bg-gray-50">
<h3 class="text-xs font-semibold text-gray-700 uppercase tracking-wider flex items-center">
<i class="fas fa-server text-gray-400 mr-2" style="font-size: 10px;"></i>
WHOIS Server
</h3>
</div>
<div class="p-4">
<div class="flex items-center p-2 bg-gray-50 rounded">
<div class="w-6 h-6 bg-orange-500 rounded flex items-center justify-center text-white font-bold text-xs mr-2">
<i class="fas fa-server"></i>
</div>
<p class="font-mono text-xs text-gray-800"><?= htmlspecialchars($tld['whois_server']) ?></p>
</div>
</div>
</div>
<?php endif; ?>
</div>
<!-- RIGHT COLUMN -->
<div class="space-y-3">
<!-- Import History -->
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
<div class="px-4 py-2 border-b border-gray-200 bg-gray-50">
<h3 class="text-xs font-semibold text-gray-700 uppercase tracking-wider flex items-center">
<i class="fas fa-history text-gray-400 mr-2" style="font-size: 10px;"></i>
Import History
</h3>
</div>
<div class="p-4">
<div class="space-y-2">
<div class="flex items-center p-2 bg-blue-50 rounded border border-blue-200">
<div class="w-7 h-7 bg-blue-500 rounded flex items-center justify-center mr-2">
<i class="fas fa-plus text-white text-xs"></i>
</div>
<div>
<p class="text-xs text-gray-600 font-medium">Created</p>
<p class="text-xs font-semibold text-gray-900"><?= date('M j, Y H:i', strtotime($tld['created_at'])) ?></p>
</div>
</div>
<?php if ($tld['updated_at']): ?>
<div class="flex items-center p-2 bg-green-50 rounded border border-green-200">
<div class="w-7 h-7 bg-green-500 rounded flex items-center justify-center mr-2">
<i class="fas fa-sync text-white text-xs"></i>
</div>
<div>
<p class="text-xs text-gray-600 font-medium">Last Updated</p>
<p class="text-xs font-semibold text-gray-900"><?= date('M j, Y H:i', strtotime($tld['updated_at'])) ?></p>
</div>
</div>
<?php endif; ?>
<?php if ($tld['iana_publication_date']): ?>
<div class="flex items-center p-2 bg-purple-50 rounded border border-purple-200">
<div class="w-7 h-7 bg-purple-500 rounded flex items-center justify-center mr-2">
<i class="fas fa-calendar text-white text-xs"></i>
</div>
<div>
<p class="text-xs text-gray-600 font-medium">IANA Publication</p>
<p class="text-xs font-semibold text-gray-900"><?= date('M j, Y H:i', strtotime($tld['iana_publication_date'])) ?></p>
</div>
</div>
<?php endif; ?>
</div>
</div>
</div>
<!-- Quick Actions -->
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
<div class="px-4 py-2 border-b border-gray-200 bg-gray-50">
<h3 class="text-xs font-semibold text-gray-700 uppercase tracking-wider flex items-center">
<i class="fas fa-bolt text-gray-400 mr-2" style="font-size: 10px;"></i>
Quick Actions
</h3>
</div>
<div class="p-4 space-y-2">
<a href="/tld-registry/<?= $tld['id'] ?>/refresh" class="flex items-center p-3 border border-gray-200 hover:border-green-500 hover:bg-green-50 rounded-lg transition-all duration-200 group" onclick="return confirm('Refresh TLD data from IANA?')">
<div class="w-9 h-9 bg-green-50 group-hover:bg-green-500 rounded-lg flex items-center justify-center group-hover:text-white text-green-600 transition-colors duration-200">
<i class="fas fa-sync-alt text-sm"></i>
</div>
<span class="ml-3 text-sm font-medium text-gray-700 group-hover:text-green-700">Refresh from IANA</span>
</a>
<a href="/tld-registry/<?= $tld['id'] ?>/toggle-active" class="flex items-center p-3 border border-gray-200 hover:border-orange-500 hover:bg-orange-50 rounded-lg transition-all duration-200 group" onclick="return confirm('Toggle TLD status?')">
<div class="w-9 h-9 bg-orange-50 group-hover:bg-orange-500 rounded-lg flex items-center justify-center group-hover:text-white text-orange-600 transition-colors duration-200">
<i class="fas fa-power-off text-sm"></i>
</div>
<span class="ml-3 text-sm font-medium text-gray-700 group-hover:text-orange-700">Toggle Status</span>
</a>
<?php if ($tld['registry_url']): ?>
<a href="<?= htmlspecialchars($tld['registry_url']) ?>" target="_blank" class="flex items-center p-3 border border-gray-200 hover:border-blue-500 hover:bg-blue-50 rounded-lg transition-all duration-200 group">
<div class="w-9 h-9 bg-blue-50 group-hover:bg-blue-500 rounded-lg flex items-center justify-center group-hover:text-white text-blue-600 transition-colors duration-200">
<i class="fas fa-external-link-alt text-sm"></i>
</div>
<span class="ml-3 text-sm font-medium text-gray-700 group-hover:text-blue-700">Visit Registry</span>
</a>
<?php endif; ?>
</div>
</div>
<!-- Raw Data (Collapsible) -->
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
<button onclick="toggleRawData()" class="w-full px-4 py-2 border-b border-gray-200 bg-gray-50 text-left hover:bg-gray-100 transition-colors">
<h3 class="text-xs font-semibold text-gray-700 uppercase tracking-wider flex items-center justify-between">
<span class="flex items-center">
<i class="fas fa-code text-gray-400 mr-2" style="font-size: 10px;"></i>
Raw TLD Data
</span>
<i class="fas fa-chevron-down text-gray-400 text-xs transition-transform" id="raw-data-chevron"></i>
</h3>
</button>
<div id="raw-data" class="hidden p-4 bg-gray-900 max-h-64 overflow-y-auto">
<pre class="text-xs text-green-400 font-mono"><?= htmlspecialchars(json_encode([
'tld' => $tld['tld'],
'rdap_servers' => $tld['rdap_servers'] ? json_decode($tld['rdap_servers'], true) : null,
'whois_server' => $tld['whois_server'],
'registry_url' => $tld['registry_url'],
'registration_date' => $tld['registration_date'],
'record_last_updated' => $tld['record_last_updated'],
'iana_publication_date' => $tld['iana_publication_date'],
'is_active' => $tld['is_active'],
'created_at' => $tld['created_at'],
'updated_at' => $tld['updated_at']
], JSON_PRETTY_PRINT)) ?></pre>
</div>
</div>
</div>
</div>
<script>
function toggleRawData() {
const dataDiv = document.getElementById('raw-data');
const chevron = document.getElementById('raw-data-chevron');
dataDiv.classList.toggle('hidden');
chevron.classList.toggle('rotate-180');
}
</script>
<?php
$content = ob_get_clean();
include __DIR__ . '/../layout/base.php';
?>

0
cache/.gitkeep vendored Normal file
View File

44
composer.json Normal file
View File

@@ -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
}
}

32
core/Application.php Normal file
View File

@@ -0,0 +1,32 @@
<?php
namespace Core;
class Application
{
public static Router $router;
public static Database $db;
public function __construct()
{
self::$router = new Router();
self::$db = new Database();
}
public function run()
{
try {
self::$router->resolve();
} catch (\Exception $e) {
http_response_code(500);
if ($_ENV['APP_ENV'] === 'development') {
echo '<h1>Error</h1>';
echo '<pre>' . $e->getMessage() . '</pre>';
echo '<pre>' . $e->getTraceAsString() . '</pre>';
} else {
echo '<h1>500 - Internal Server Error</h1>';
}
}
}
}

59
core/Auth.php Normal file
View File

@@ -0,0 +1,59 @@
<?php
namespace Core;
class Auth
{
/**
* Check if user is authenticated
*/
public static function check(): bool
{
return isset($_SESSION['user_id']);
}
/**
* Get current user ID
*/
public static function id(): ?int
{
return $_SESSION['user_id'] ?? null;
}
/**
* Get current username
*/
public static function username(): ?string
{
return $_SESSION['username'] ?? null;
}
/**
* Get current user's full name
*/
public static function fullName(): ?string
{
return $_SESSION['full_name'] ?? null;
}
/**
* Require authentication (redirect to login if not authenticated)
*/
public static function require(): void
{
// Get current path
$currentPath = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH);
// Don't redirect if already on login page or logout
if ($currentPath === '/login' || $currentPath === '/logout') {
return;
}
if (!self::check()) {
$_SESSION['error'] = 'Please login to continue';
header('Location: /login');
exit;
}
}
}

33
core/Controller.php Normal file
View File

@@ -0,0 +1,33 @@
<?php
namespace Core;
abstract class Controller
{
protected function view(string $view, array $data = []): void
{
extract($data);
$viewPath = __DIR__ . "/../app/Views/$view.php";
if (!file_exists($viewPath)) {
throw new \Exception("View not found: $view");
}
require_once $viewPath;
}
protected function json($data, int $status = 200): void
{
http_response_code($status);
header('Content-Type: application/json');
echo json_encode($data);
exit;
}
protected function redirect(string $path): void
{
header("Location: $path");
exit;
}
}

54
core/Database.php Normal file
View File

@@ -0,0 +1,54 @@
<?php
namespace Core;
use PDO;
use PDOException;
class Database
{
private static ?PDO $pdo = null;
public function __construct()
{
if (self::$pdo === null) {
$this->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;
}
}

65
core/Model.php Normal file
View File

@@ -0,0 +1,65 @@
<?php
namespace Core;
use PDO;
abstract class Model
{
protected static string $table;
protected PDO $db;
public function __construct()
{
$this->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();
}
}

76
core/Router.php Normal file
View File

@@ -0,0 +1,76 @@
<?php
namespace Core;
class Router
{
protected array $routes = [];
public function get(string $path, $callback)
{
$this->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);
}
}
}

192
cron/check_domains.php Normal file
View File

@@ -0,0 +1,192 @@
#!/usr/bin/env php
<?php
/**
* Domain Expiration Check Cron Job
*
* This script should be run periodically (recommended: daily) to check domain expirations
* and send notifications when domains are approaching expiration.
*
* Usage: php cron/check_domains.php
* Crontab: 0 9 * * * /usr/bin/php /path/to/project/cron/check_domains.php
*/
require_once __DIR__ . '/../vendor/autoload.php';
use Dotenv\Dotenv;
use App\Models\Domain;
use App\Models\NotificationChannel;
use App\Models\NotificationLog;
use App\Services\WhoisService;
use App\Services\NotificationService;
use Core\Database;
// Load environment variables
$dotenv = Dotenv::createImmutable(__DIR__ . '/..');
$dotenv->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);

View File

@@ -0,0 +1,206 @@
#!/usr/bin/env php
<?php
/**
* TLD Registry Import Script
*
* This script imports TLD registry data from IANA sources:
* - RDAP servers from https://data.iana.org/rdap/dns.json
* - WHOIS servers from individual IANA TLD pages
*
* Usage: php cron/import_tld_registry.php [options]
*
* Options:
* --tld-list-only Import only TLD list from IANA
* --rdap-only Import only RDAP data
* --whois-only Import only WHOIS data for missing TLDs
* --tlds=LIST Import WHOIS data for specific TLDs (comma-separated, e.g., --tlds=ro,de,fr)
* --check-updates Check for IANA updates without importing
* --force Force import even if no updates available
* --verbose Enable verbose output
*/
require_once __DIR__ . '/../vendor/autoload.php';
use Dotenv\Dotenv;
use App\Services\TldRegistryService;
use Core\Database;
// Load environment variables
$dotenv = Dotenv::createImmutable(__DIR__ . '/..');
$dotenv->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";
}

106
database/migrate.php Normal file
View File

@@ -0,0 +1,106 @@
#!/usr/bin/env php
<?php
require_once __DIR__ . '/../vendor/autoload.php';
use Dotenv\Dotenv;
// Load environment variables
$dotenv = Dotenv::createImmutable(__DIR__ . '/..');
$dotenv->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);
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

32
env.example.txt Normal file
View File

@@ -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

0
logs/.gitkeep Normal file
View File

60
logs/QUICK_START.md Normal file
View File

@@ -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.

193
logs/README.md Normal file
View File

@@ -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

7
public/.htaccess Normal file
View File

@@ -0,0 +1,7 @@
RewriteEngine On
# Redirect to index.php
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(.*)$ index.php [QSA,L]

145
public/assets/style.css Normal file
View File

@@ -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;
}
}

24
public/index.php Normal file
View File

@@ -0,0 +1,24 @@
<?php
require_once __DIR__ . '/../vendor/autoload.php';
use Core\Application;
use Core\Router;
use Dotenv\Dotenv;
// Load environment variables
$dotenv = Dotenv::createImmutable(__DIR__ . '/..');
$dotenv->load();
// Start session
session_start();
// Initialize application
$app = new Application();
// Load routes
require_once __DIR__ . '/../routes/web.php';
// Run application
$app->run();

3
public/robots.txt Normal file
View File

@@ -0,0 +1,3 @@
User-agent: *
Disallow: /

78
routes/web.php Normal file
View File

@@ -0,0 +1,78 @@
<?php
use Core\Application;
use Core\Auth;
use App\Controllers\DashboardController;
use App\Controllers\DomainController;
use App\Controllers\NotificationGroupController;
use App\Controllers\AuthController;
use App\Controllers\DebugController;
use App\Controllers\SearchController;
use App\Controllers\TldRegistryController;
$router = Application::$router;
// Authentication routes (public)
$router->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']);