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:
+