Initial Commit
This commit is contained in:
53
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
53
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal 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
|
||||||
|
|
||||||
42
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
42
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal 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
89
.github/pull_request_template.md
vendored
Normal 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
61
.gitignore
vendored
Normal 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
149
CHANGELOG.md
Normal 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
316
CONTRIBUTING.md
Normal 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
22
LICENSE
Normal 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
453
README.md
Normal file
@@ -0,0 +1,453 @@
|
|||||||
|
# 🌐 Domain Monitor
|
||||||
|
|
||||||
|
> A powerful, self-hosted domain expiration monitoring system with multi-channel notifications
|
||||||
|
|
||||||
|
[](https://www.php.net/)
|
||||||
|
[](LICENSE)
|
||||||
|
[](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.
|
||||||
|
|
||||||
|
[](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
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
93
app/Controllers/AuthController.php
Normal file
93
app/Controllers/AuthController.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
39
app/Controllers/DashboardController.php
Normal file
39
app/Controllers/DashboardController.php
Normal 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'
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
304
app/Controllers/DebugController.php
Normal file
304
app/Controllers/DebugController.php
Normal 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
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
569
app/Controllers/DomainController.php
Normal file
569
app/Controllers/DomainController.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
194
app/Controllers/NotificationGroupController.php
Normal file
194
app/Controllers/NotificationGroupController.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
172
app/Controllers/SearchController.php
Normal file
172
app/Controllers/SearchController.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
515
app/Controllers/TldRegistryController.php
Normal file
515
app/Controllers/TldRegistryController.php
Normal 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
143
app/Models/Domain.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
68
app/Models/NotificationChannel.php
Normal file
68
app/Models/NotificationChannel.php
Normal 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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
65
app/Models/NotificationGroup.php
Normal file
65
app/Models/NotificationGroup.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
75
app/Models/NotificationLog.php
Normal file
75
app/Models/NotificationLog.php
Normal 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
155
app/Models/TldImportLog.php
Normal 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
253
app/Models/TldRegistry.php
Normal 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
65
app/Models/User.php
Normal 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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
100
app/Services/Channels/DiscordChannel.php
Normal file
100
app/Services/Channels/DiscordChannel.php
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
91
app/Services/Channels/EmailChannel.php
Normal file
91
app/Services/Channels/EmailChannel.php
Normal 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>
|
||||||
|
";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
17
app/Services/Channels/NotificationChannelInterface.php
Normal file
17
app/Services/Channels/NotificationChannelInterface.php
Normal 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;
|
||||||
|
}
|
||||||
|
|
||||||
85
app/Services/Channels/SlackChannel.php
Normal file
85
app/Services/Channels/SlackChannel.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
42
app/Services/Channels/TelegramChannel.php
Normal file
42
app/Services/Channels/TelegramChannel.php
Normal 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
155
app/Services/Logger.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
153
app/Services/NotificationService.php
Normal file
153
app/Services/NotificationService.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
1866
app/Services/TldRegistryService.php
Normal file
1866
app/Services/TldRegistryService.php
Normal file
File diff suppressed because it is too large
Load Diff
729
app/Services/WhoisService.php
Normal file
729
app/Services/WhoisService.php
Normal 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
156
app/Views/auth/login.php
Normal 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>
|
||||||
232
app/Views/dashboard/index.php
Normal file
232
app/Views/dashboard/index.php
Normal 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
318
app/Views/debug/whois.php
Normal 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';
|
||||||
|
?>
|
||||||
|
|
||||||
128
app/Views/domains/bulk-add.php
Normal file
128
app/Views/domains/bulk-add.php
Normal 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 google.com github.com ..."
|
||||||
|
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';
|
||||||
|
?>
|
||||||
|
|
||||||
130
app/Views/domains/create.php
Normal file
130
app/Views/domains/create.php
Normal 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
120
app/Views/domains/edit.php
Normal 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
688
app/Views/domains/index.php
Normal 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
461
app/Views/domains/view.php
Normal 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
85
app/Views/errors/404.php
Normal 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
102
app/Views/groups/create.php
Normal 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
304
app/Views/groups/edit.php
Normal 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
156
app/Views/groups/index.php
Normal 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
310
app/Views/layout/base.php
Normal 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>
|
||||||
|
|
||||||
119
app/Views/layout/messages.php
Normal file
119
app/Views/layout/messages.php
Normal 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>
|
||||||
102
app/Views/layout/sidebar.php
Normal file
102
app/Views/layout/sidebar.php
Normal 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>
|
||||||
|
|
||||||
133
app/Views/layout/top-nav.php
Normal file
133
app/Views/layout/top-nav.php
Normal 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>
|
||||||
|
|
||||||
304
app/Views/search/results.php
Normal file
304
app/Views/search/results.php
Normal 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';
|
||||||
|
?>
|
||||||
|
|
||||||
562
app/Views/tld-registry/import-logs.php
Normal file
562
app/Views/tld-registry/import-logs.php
Normal 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';
|
||||||
|
?>
|
||||||
302
app/Views/tld-registry/import-progress.php
Normal file
302
app/Views/tld-registry/import-progress.php
Normal 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';
|
||||||
|
?>
|
||||||
578
app/Views/tld-registry/index.php
Normal file
578
app/Views/tld-registry/index.php
Normal 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';
|
||||||
|
?>
|
||||||
258
app/Views/tld-registry/view.php
Normal file
258
app/Views/tld-registry/view.php
Normal 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
0
cache/.gitkeep
vendored
Normal file
44
composer.json
Normal file
44
composer.json
Normal 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
32
core/Application.php
Normal 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
59
core/Auth.php
Normal 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
33
core/Controller.php
Normal 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
54
core/Database.php
Normal 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
65
core/Model.php
Normal 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
76
core/Router.php
Normal 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
192
cron/check_domains.php
Normal 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);
|
||||||
|
|
||||||
206
cron/import_tld_registry.php
Normal file
206
cron/import_tld_registry.php
Normal 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
106
database/migrate.php
Normal 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);
|
||||||
|
}
|
||||||
|
|
||||||
72
database/migrations/001_create_tables.sql
Normal file
72
database/migrations/001_create_tables.sql
Normal 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;
|
||||||
|
|
||||||
22
database/migrations/002_create_users_table.sql
Normal file
22
database/migrations/002_create_users_table.sql
Normal 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;
|
||||||
|
|
||||||
13
database/migrations/003_add_whois_fields.sql
Normal file
13
database/migrations/003_add_whois_fields.sql
Normal 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;
|
||||||
|
|
||||||
37
database/migrations/004_create_tld_registry_table.sql
Normal file
37
database/migrations/004_create_tld_registry_table.sql
Normal 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;
|
||||||
11
database/migrations/005_update_tld_import_logs.sql
Normal file
11
database/migrations/005_update_tld_import_logs.sql
Normal 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;
|
||||||
@@ -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
32
env.example.txt
Normal 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
0
logs/.gitkeep
Normal file
60
logs/QUICK_START.md
Normal file
60
logs/QUICK_START.md
Normal 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
193
logs/README.md
Normal 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
7
public/.htaccess
Normal 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
145
public/assets/style.css
Normal 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
24
public/index.php
Normal 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
3
public/robots.txt
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
User-agent: *
|
||||||
|
Disallow: /
|
||||||
|
|
||||||
78
routes/web.php
Normal file
78
routes/web.php
Normal 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']);
|
||||||
|
|
||||||
Reference in New Issue
Block a user