diff --git a/app/Controllers/DomainController.php b/app/Controllers/DomainController.php index a7c2b62..f32991a 100644 --- a/app/Controllers/DomainController.php +++ b/app/Controllers/DomainController.php @@ -26,6 +26,7 @@ class DomainController extends Controller $search = \App\Helpers\InputValidator::sanitizeSearch($_GET['search'] ?? '', 100); $status = $_GET['status'] ?? ''; $groupId = $_GET['group'] ?? ''; + $tag = $_GET['tag'] ?? ''; $sortBy = $_GET['sort'] ?? 'domain_name'; $sortOrder = $_GET['order'] ?? 'asc'; $page = max(1, (int)($_GET['page'] ?? 1)); @@ -40,7 +41,8 @@ class DomainController extends Controller $filters = [ 'search' => $search, 'status' => $status, - 'group' => $groupId + 'group' => $groupId, + 'tag' => $tag ]; // Get filtered and paginated domains using model @@ -48,16 +50,21 @@ class DomainController extends Controller $groups = $this->groupModel->all(); + // Get all unique tags for filter dropdown + $allTags = $this->domainModel->getAllTags(); + // Format domains for display $formattedDomains = \App\Helpers\DomainHelper::formatMultiple($result['domains']); $this->view('domains/index', [ 'domains' => $formattedDomains, 'groups' => $groups, + 'allTags' => $allTags, 'filters' => [ 'search' => $search, 'status' => $status, 'group' => $groupId, + 'tag' => $tag, 'sort' => $sortBy, 'order' => $sortOrder ], @@ -88,6 +95,7 @@ class DomainController extends Controller $domainName = trim($_POST['domain_name'] ?? ''); $groupId = !empty($_POST['notification_group_id']) ? (int)$_POST['notification_group_id'] : null; + $tagsInput = trim($_POST['tags'] ?? ''); // Validate if (empty($domainName)) { @@ -103,6 +111,15 @@ class DomainController extends Controller return; } + // Validate tags + $tagValidation = \App\Helpers\InputValidator::validateTags($tagsInput); + if (!$tagValidation['valid']) { + $_SESSION['error'] = $tagValidation['error']; + $this->redirect('/domains/create'); + return; + } + $tags = $tagValidation['tags']; + // Check if domain already exists if ($this->domainModel->existsByDomain($domainName)) { $_SESSION['error'] = 'Domain already exists'; @@ -130,6 +147,7 @@ class DomainController extends Controller $id = $this->domainModel->create([ 'domain_name' => $domainName, 'notification_group_id' => $groupId, + 'tags' => $tags, 'registrar' => $whoisData['registrar'], 'registrar_url' => $whoisData['registrar_url'] ?? null, 'expiration_date' => $whoisData['expiration_date'], @@ -188,6 +206,16 @@ class DomainController extends Controller $groupId = !empty($_POST['notification_group_id']) ? (int)$_POST['notification_group_id'] : null; $isActive = isset($_POST['is_active']) ? 1 : 0; + $tagsInput = trim($_POST['tags'] ?? ''); + + // Validate tags + $tagValidation = \App\Helpers\InputValidator::validateTags($tagsInput); + if (!$tagValidation['valid']) { + $_SESSION['error'] = $tagValidation['error']; + $this->redirect('/domains/' . $id . '/edit'); + return; + } + $tags = $tagValidation['tags']; // Check if monitoring status changed $statusChanged = ($domain['is_active'] != $isActive); @@ -195,6 +223,7 @@ class DomainController extends Controller $this->domainModel->update($id, [ 'notification_group_id' => $groupId, + 'tags' => $tags, 'is_active' => $isActive ]); @@ -362,6 +391,7 @@ class DomainController extends Controller // POST - Process bulk add $domainsText = trim($_POST['domains'] ?? ''); $groupId = !empty($_POST['notification_group_id']) ? (int)$_POST['notification_group_id'] : null; + $tagsInput = trim($_POST['tags'] ?? ''); if (empty($domainsText)) { $_SESSION['error'] = 'Please enter at least one domain'; @@ -369,6 +399,15 @@ class DomainController extends Controller return; } + // Validate tags + $tagValidation = \App\Helpers\InputValidator::validateTags($tagsInput); + if (!$tagValidation['valid']) { + $_SESSION['error'] = $tagValidation['error']; + $this->redirect('/domains/bulk-add'); + return; + } + $tags = $tagValidation['tags']; + // Split by new lines and clean $domainNames = array_filter(array_map('trim', explode("\n", $domainsText))); @@ -402,6 +441,7 @@ class DomainController extends Controller $this->domainModel->create([ 'domain_name' => $domainName, 'notification_group_id' => $groupId, + 'tags' => $tags, 'registrar' => $whoisData['registrar'], 'registrar_url' => $whoisData['registrar_url'] ?? null, 'expiration_date' => $whoisData['expiration_date'], @@ -638,5 +678,83 @@ class DomainController extends Controller $_SESSION['success'] = 'Notes updated successfully'; $this->redirect('/domains/' . $id); } + + public function bulkAddTags() + { + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + $this->redirect('/domains'); + return; + } + + // CSRF Protection + $this->verifyCsrf('/domains'); + + $domainIds = $_POST['domain_ids'] ?? []; + $tagToAdd = trim($_POST['tag'] ?? ''); + + if (empty($domainIds) || empty($tagToAdd)) { + $_SESSION['error'] = 'Invalid request'; + $this->redirect('/domains'); + return; + } + + // Validate tag format + if (!preg_match('/^[a-z0-9-]+$/', $tagToAdd)) { + $_SESSION['error'] = 'Invalid tag format (use only letters, numbers, and hyphens)'; + $this->redirect('/domains'); + return; + } + + $updated = 0; + foreach ($domainIds as $id) { + $domain = $this->domainModel->find($id); + if (!$domain) continue; + + // Get existing tags + $existingTags = !empty($domain['tags']) ? explode(',', $domain['tags']) : []; + + // Add new tag if it doesn't exist + if (!in_array($tagToAdd, $existingTags)) { + $existingTags[] = $tagToAdd; + $newTags = implode(',', $existingTags); + + if ($this->domainModel->update($id, ['tags' => $newTags])) { + $updated++; + } + } + } + + $_SESSION['success'] = "Tag '$tagToAdd' added to $updated domain(s)"; + $this->redirect('/domains'); + } + + public function bulkRemoveTags() + { + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + $this->redirect('/domains'); + return; + } + + // CSRF Protection + $this->verifyCsrf('/domains'); + + $domainIds = $_POST['domain_ids'] ?? []; + + if (empty($domainIds)) { + $_SESSION['error'] = 'No domains selected'; + $this->redirect('/domains'); + return; + } + + $updated = 0; + foreach ($domainIds as $id) { + if ($this->domainModel->update($id, ['tags' => ''])) { + $updated++; + } + } + + $_SESSION['success'] = "Tags removed from $updated domain(s)"; + $this->redirect('/domains'); + } } diff --git a/app/Controllers/InstallerController.php b/app/Controllers/InstallerController.php index a0611ec..95fc64e 100644 --- a/app/Controllers/InstallerController.php +++ b/app/Controllers/InstallerController.php @@ -47,6 +47,7 @@ class InstallerController extends Controller '013_create_user_notifications_table.sql', '014_add_captcha_settings.sql', '015_create_error_logs_table.sql', + '016_add_tags_to_domains.sql', ]; try { @@ -264,7 +265,8 @@ class InstallerController extends Controller '012_link_remember_tokens_to_sessions.sql', '013_create_user_notifications_table.sql', '014_add_captcha_settings.sql', - '015_create_error_logs_table.sql' + '015_create_error_logs_table.sql', + '016_add_tags_to_domains.sql', ]; $stmt = $pdo->prepare("INSERT INTO migrations (migration) VALUES (?) ON DUPLICATE KEY UPDATE migration=migration"); diff --git a/app/Helpers/InputValidator.php b/app/Helpers/InputValidator.php index 462466c..a896438 100644 --- a/app/Helpers/InputValidator.php +++ b/app/Helpers/InputValidator.php @@ -203,5 +203,52 @@ class InputValidator } return null; } + + /** + * Validate and sanitize tags + * + * @param string $tagsString Comma-separated tags + * @param int $maxTags Maximum number of tags allowed (default 10) + * @param int $maxLength Maximum length per tag (default 50) + * @return array Array with 'valid' (bool), 'tags' (string), and 'error' (string|null) + */ + public static function validateTags(string $tagsString, int $maxTags = 10, int $maxLength = 50): array + { + if (empty($tagsString)) { + return ['valid' => true, 'tags' => '', 'error' => null]; + } + + // Split tags and clean them + $tags = array_filter(array_map('trim', explode(',', $tagsString))); + + // Check tag count + if (count($tags) > $maxTags) { + return ['valid' => false, 'tags' => '', 'error' => "Maximum $maxTags tags allowed"]; + } + + // Validate each tag + $validatedTags = []; + foreach ($tags as $tag) { + $tag = strtolower($tag); + + // Check length + if (strlen($tag) > $maxLength) { + return ['valid' => false, 'tags' => '', 'error' => "Tag '$tag' is too long (maximum $maxLength characters)"]; + } + + // Check format (alphanumeric and hyphens only) + if (!preg_match('/^[a-z0-9-]+$/', $tag)) { + return ['valid' => false, 'tags' => '', 'error' => "Tag '$tag' contains invalid characters (use only letters, numbers, and hyphens)"]; + } + + // Avoid duplicates + if (!in_array($tag, $validatedTags)) { + $validatedTags[] = $tag; + } + } + + return ['valid' => true, 'tags' => implode(',', $validatedTags), 'error' => null]; + } } + diff --git a/app/Models/Domain.php b/app/Models/Domain.php index fed3679..8d90b09 100644 --- a/app/Models/Domain.php +++ b/app/Models/Domain.php @@ -178,6 +178,17 @@ class Domain extends Model }); } + // Apply tag filter + if (!empty($filters['tag'])) { + $domains = array_filter($domains, function($domain) use ($filters) { + if (empty($domain['tags'])) { + return false; + } + $domainTags = array_map('trim', explode(',', $domain['tags'])); + return in_array($filters['tag'], $domainTags); + }); + } + // Get total count after filtering $totalDomains = count($domains); @@ -210,5 +221,27 @@ class Domain extends Model ] ]; } + + /** + * Get all unique tags from all domains + */ + public function getAllTags(): array + { + $stmt = $this->db->query("SELECT DISTINCT tags FROM domains WHERE tags IS NOT NULL AND tags != ''"); + $results = $stmt->fetchAll(); + + $allTags = []; + foreach ($results as $row) { + if (!empty($row['tags'])) { + $tags = array_map('trim', explode(',', $row['tags'])); + $allTags = array_merge($allTags, $tags); + } + } + + // Return unique, sorted tags + $allTags = array_unique($allTags); + sort($allTags); + return $allTags; + } } diff --git a/app/Views/domains/bulk-add.php b/app/Views/domains/bulk-add.php index 24aa161..1f12eaf 100644 --- a/app/Views/domains/bulk-add.php +++ b/app/Views/domains/bulk-add.php @@ -37,6 +37,57 @@ ob_start();

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

+ + All imported domains will be tagged with these tags. Type any custom tag or use suggestions below. +

+ + +
+

💡 Suggestions:

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

+ + Type any custom tag (letters, numbers, hyphens). Press Enter or , to add. +

+ + +
+

💡 Suggestions:

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

+ + Type any custom tag (letters, numbers, hyphens). Press Enter or , to add. +

+ + +
+

💡 Suggestions:

+
+ + + + + +
+
+
+
+ + '', 'status' => '', 'group' => '', 's
-
+
@@ -71,6 +71,29 @@ $currentFilters = $filters ?? ['search' => '', 'status' => '', 'group' => '', 's
+
+ + +
- +
- -
NS:
- +
+ 'bg-green-100 text-green-700 border-green-200', + 'staging' => 'bg-yellow-100 text-yellow-700 border-yellow-200', + 'development' => 'bg-blue-100 text-blue-700 border-blue-200', + 'client' => 'bg-purple-100 text-purple-700 border-purple-200', + 'personal' => 'bg-orange-100 text-orange-700 border-orange-200', + 'archived' => 'bg-gray-100 text-gray-600 border-gray-200' + ]; + foreach ($tags as $tag): + $tag = trim($tag); + $colorClass = $tagColors[$tag] ?? 'bg-gray-100 text-gray-700 border-gray-200'; + ?> + + + + + + + NS: + +
@@ -549,11 +640,89 @@ function bulkDelete() { form.submit(); } +function toggleAssignTagsDropdown() { + const dropdown = document.getElementById('assign-tags-dropdown'); + dropdown.classList.toggle('hidden'); +} + function toggleAssignGroupDropdown() { const dropdown = document.getElementById('assign-group-dropdown'); dropdown.classList.toggle('hidden'); } +function bulkAddTag(tagName) { + const ids = getSelectedIds(); + if (ids.length === 0) { + alert('Please select at least one domain'); + return; + } + + const form = document.createElement('form'); + form.method = 'POST'; + form.action = '/domains/bulk-add-tags'; + + // Add CSRF token + const csrfInput = document.createElement('input'); + csrfInput.type = 'hidden'; + csrfInput.name = 'csrf_token'; + csrfInput.value = ''; + form.appendChild(csrfInput); + + // Add tag to add + const tagInput = document.createElement('input'); + tagInput.type = 'hidden'; + tagInput.name = 'tag'; + tagInput.value = tagName; + form.appendChild(tagInput); + + // Add domain IDs + 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 bulkRemoveAllTags() { + const ids = getSelectedIds(); + if (ids.length === 0) { + alert('Please select at least one domain'); + return; + } + + if (!confirm(`Remove all tags from ${ids.length} domain(s)?`)) { + return; + } + + const form = document.createElement('form'); + form.method = 'POST'; + form.action = '/domains/bulk-remove-tags'; + + // Add CSRF token + const csrfInput = document.createElement('input'); + csrfInput.type = 'hidden'; + csrfInput.name = 'csrf_token'; + csrfInput.value = ''; + form.appendChild(csrfInput); + + // Add domain IDs + 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(); +} + // Update bulk assign form with selected IDs document.getElementById('bulk-assign-form')?.addEventListener('submit', function(e) { const ids = getSelectedIds(); @@ -573,13 +742,19 @@ document.getElementById('bulk-assign-form')?.addEventListener('submit', function }); -// Close dropdown when clicking outside +// Close dropdowns when clicking outside document.addEventListener('click', function(event) { - const dropdown = document.getElementById('assign-group-dropdown'); - const button = event.target.closest('button[onclick="toggleAssignGroupDropdown()"]'); + const groupDropdown = document.getElementById('assign-group-dropdown'); + const tagsDropdown = document.getElementById('assign-tags-dropdown'); + const groupButton = event.target.closest('button[onclick="toggleAssignGroupDropdown()"]'); + const tagsButton = event.target.closest('button[onclick="toggleAssignTagsDropdown()"]'); - if (!button && !dropdown.contains(event.target)) { - dropdown?.classList.add('hidden'); + if (!groupButton && !groupDropdown.contains(event.target)) { + groupDropdown?.classList.add('hidden'); + } + + if (!tagsButton && !tagsDropdown.contains(event.target)) { + tagsDropdown?.classList.add('hidden'); } }); diff --git a/app/Views/domains/view.php b/app/Views/domains/view.php index a138972..48ec9c8 100644 --- a/app/Views/domains/view.php +++ b/app/Views/domains/view.php @@ -15,7 +15,7 @@ ob_start();
-
+
mr-1.5"> + + + 'bg-green-100 text-green-700 border-green-200', + 'staging' => 'bg-yellow-100 text-yellow-700 border-yellow-200', + 'development' => 'bg-blue-100 text-blue-700 border-blue-200', + 'client' => 'bg-purple-100 text-purple-700 border-purple-200', + 'personal' => 'bg-orange-100 text-orange-700 border-orange-200', + 'archived' => 'bg-gray-100 text-gray-600 border-gray-200' + ]; + foreach ($tags as $tag): + $tag = trim($tag); + $colorClass = $tagColors[$tag] ?? 'bg-gray-100 text-gray-700 border-gray-200'; + ?> + + + + +
diff --git a/database/migrations/000_initial_schema_v1.1.0.sql b/database/migrations/000_initial_schema_v1.1.0.sql index ef65d03..3bf3e20 100644 --- a/database/migrations/000_initial_schema_v1.1.0.sql +++ b/database/migrations/000_initial_schema_v1.1.0.sql @@ -28,6 +28,7 @@ CREATE TABLE IF NOT EXISTS domains ( status ENUM('active', 'expiring_soon', 'expired', 'error', 'available') DEFAULT 'active', whois_data JSON, notes TEXT, + tags TEXT NULL COMMENT 'Comma-separated tags for organization', is_active BOOLEAN DEFAULT TRUE, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, @@ -36,7 +37,8 @@ CREATE TABLE IF NOT EXISTS domains ( INDEX idx_domain_name (domain_name), INDEX idx_expiration_date (expiration_date), INDEX idx_status (status), - INDEX idx_is_active (is_active) + INDEX idx_is_active (is_active), + INDEX idx_tags (tags(255)) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; -- Notification channels table diff --git a/database/migrations/016_add_tags_to_domains.sql b/database/migrations/016_add_tags_to_domains.sql new file mode 100644 index 0000000..f35dc6e --- /dev/null +++ b/database/migrations/016_add_tags_to_domains.sql @@ -0,0 +1,10 @@ +-- Add tags column to domains table +-- This allows users to organize domains with custom tags + +ALTER TABLE domains +ADD COLUMN tags TEXT NULL COMMENT 'Comma-separated tags for organization' +AFTER notes; + +-- Add index for tag searches +ALTER TABLE domains +ADD INDEX idx_tags (tags(255)); diff --git a/database/migrations/README.md b/database/migrations/README.md index ea3da12..c57db4d 100644 --- a/database/migrations/README.md +++ b/database/migrations/README.md @@ -27,6 +27,7 @@ If upgrading from v1.0.0, these incremental migrations will be applied: - `013_create_user_notifications_table.sql` - User notifications table - `014_add_captcha_settings.sql` - CAPTCHA settings (v2, v3, Turnstile) - `015_create_error_logs_table.sql` - Error logging and debugging system +- `016_add_tags_to_domains.sql` - Domain tags for organization **Upgrade via:** Web updater at `/install/update` diff --git a/routes/web.php b/routes/web.php index 492ee35..0129e0c 100644 --- a/routes/web.php +++ b/routes/web.php @@ -62,6 +62,8 @@ $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/bulk-add-tags', [DomainController::class, 'bulkAddTags']); +$router->post('/domains/bulk-remove-tags', [DomainController::class, 'bulkRemoveTags']); $router->post('/domains/store', [DomainController::class, 'store']); $router->get('/domains/{id}', [DomainController::class, 'show']); $router->get('/domains/{id}/edit', [DomainController::class, 'edit']);