From df2942b3566bda5003d08105d28238745cd1de38 Mon Sep 17 00:00:00 2001
From: Hosteroid
Date: Sun, 12 Oct 2025 12:46:16 +0300
Subject: [PATCH] Add tags support for domains with filtering and bulk actions
Introduces a 'tags' field to the domains table and UI, allowing users to organize domains with custom tags. Adds tag input and display to create, edit, bulk-add, and view pages, as well as tag-based filtering and bulk tag management (add/remove) in the domain list. Updates backend validation, controller logic, and migrations to support tags, including a new migration and index for efficient tag searches.
---
app/Controllers/DomainController.php | 120 ++++++++++-
app/Controllers/InstallerController.php | 4 +-
app/Helpers/InputValidator.php | 47 +++++
app/Models/Domain.php | 33 +++
app/Views/domains/bulk-add.php | 141 +++++++++++++
app/Views/domains/create.php | 149 +++++++++++++
app/Views/domains/edit.php | 151 +++++++++++++
app/Views/domains/index.php | 199 ++++++++++++++++--
app/Views/domains/view.php | 23 +-
.../migrations/000_initial_schema_v1.1.0.sql | 4 +-
.../migrations/016_add_tags_to_domains.sql | 10 +
database/migrations/README.md | 1 +
routes/web.php | 2 +
13 files changed, 868 insertions(+), 16 deletions(-)
create mode 100644 database/migrations/016_add_tags_to_domains.sql
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