diff --git a/cp/app/Controllers/DapiController.php b/cp/app/Controllers/DapiController.php new file mode 100644 index 0000000..09802ea --- /dev/null +++ b/cp/app/Controllers/DapiController.php @@ -0,0 +1,189 @@ +getQueryParams(); + $db = $this->container->get('db'); + + // Map fields to fully qualified columns + $allowedFieldsMap = [ + 'name' => 'd.name', + 'crdate' => 'd.crdate', + 'exdate' => 'd.exdate', + 'registrant_identifier' => 'c.identifier' + ]; + + // --- SORTING --- + $sortField = 'd.crdate'; // default + $sortDir = 'desc'; + if (!empty($params['order'])) { + $orderParts = explode(',', $params['order']); + if (count($orderParts) === 2) { + $fieldCandidate = preg_replace('/[^a-zA-Z0-9_]/', '', $orderParts[0]); + if (array_key_exists($fieldCandidate, $allowedFieldsMap)) { + $sortField = $allowedFieldsMap[$fieldCandidate]; + } + $sortDir = strtolower($orderParts[1]) === 'asc' ? 'asc' : 'desc'; + } + } + + // --- PAGINATION --- + $page = 1; + $size = 10; + if (!empty($params['page'])) { + $pageParts = explode(',', $params['page']); + if (count($pageParts) === 2) { + $pageNum = (int)$pageParts[0]; + $pageSize = (int)$pageParts[1]; + if ($pageNum > 0) { + $page = $pageNum; + } + if ($pageSize > 0) { + $size = $pageSize; + } + } + } + $offset = ($page - 1) * $size; + + // --- FILTERING --- + $whereClauses = []; + $bindParams = []; + foreach ($params as $key => $value) { + if (preg_match('/^filter\d+$/', $key)) { + $fParts = explode(',', $value); + if (count($fParts) === 3) { + list($fField, $fOp, $fVal) = $fParts; + $fField = preg_replace('/[^a-zA-Z0-9_]/', '', $fField); + + // Ensure the field is allowed and fully qualify it + if (!array_key_exists($fField, $allowedFieldsMap)) { + // Skip unknown fields + continue; + } + $column = $allowedFieldsMap[$fField]; + + switch ($fOp) { + case 'eq': + $whereClauses[] = "$column = :f_{$key}"; + $bindParams["f_{$key}"] = $fVal; + break; + case 'cs': + // If searching in 'name' and user might enter Cyrillic + if ($fField === 'name') { + // Convert to punycode + $punyVal = idn_to_ascii($fVal, IDNA_NONTRANSITIONAL_TO_ASCII, INTL_IDNA_VARIANT_UTS46); + if ($punyVal !== false && $punyVal !== $fVal) { + // Search for both punycode and original term + // (d.name LIKE '%cyrillic%' OR d.name LIKE '%punycode%') + $whereClauses[] = "($column LIKE :f_{$key}_original OR $column LIKE :f_{$key}_puny)"; + $bindParams["f_{$key}_original"] = "%$fVal%"; + $bindParams["f_{$key}_puny"] = "%$punyVal%"; + } else { + // Just search normally + $whereClauses[] = "$column LIKE :f_{$key}"; + $bindParams["f_{$key}"] = "%$fVal%"; + } + } else { + // Non-name field, just search as usual + $whereClauses[] = "$column LIKE :f_{$key}"; + $bindParams["f_{$key}"] = "%$fVal%"; + } + break; + case 'sw': + $whereClauses[] = "$column LIKE :f_{$key}"; + $bindParams["f_{$key}"] = "$fVal%"; + break; + case 'ew': + $whereClauses[] = "$column LIKE :f_{$key}"; + $bindParams["f_{$key}"] = "%$fVal"; + break; + // Add other cases if needed + } + } + } + } + + // Base SQL + $sqlBase = " + FROM domain d + LEFT JOIN contact c ON d.registrant = c.id + LEFT JOIN domain_status ds ON d.id = ds.domain_id + "; + + $sqlWhere = ''; + if (!empty($whereClauses)) { + $sqlWhere = "WHERE " . implode(" OR ", $whereClauses); + } + + // Count total results + $totalSql = "SELECT COUNT(DISTINCT d.id) AS total $sqlBase $sqlWhere"; + $totalCount = $db->selectValue($totalSql, $bindParams); + + // Data query + $selectFields = " + d.id, + d.name, + d.crdate, + d.exdate, + d.rgpstatus, + c.identifier AS registrant_identifier, + GROUP_CONCAT(ds.status) AS domain_status + "; + + $dataSql = " + SELECT $selectFields + $sqlBase + $sqlWhere + GROUP BY d.id + ORDER BY $sortField $sortDir + LIMIT $offset, $size + "; + + $records = $db->select($dataSql, $bindParams); + + // Ensure records is always an array + if (!$records) { + $records = []; + } + + // Format API results + foreach ($records as &$row) { + // Check if name is punycode by checking if it starts with 'xn--' + if (stripos($row['name'], 'xn--') === 0) { + // Convert punycode to Unicode and store it in 'name' + $unicode_name = idn_to_utf8($row['name'], IDNA_NONTRANSITIONAL_TO_ASCII, INTL_IDNA_VARIANT_UTS46); + $row['name_o'] = $row['name']; // Keep the original punycode in 'name_o' + $row['name'] = $unicode_name; // Store the Unicode version in 'name' + } else { + // For regular names, both 'name' and 'name_o' are the same + $row['name_o'] = $row['name']; + } + + // Format domain_status as array of {status: '...'} objects + if (!empty($row['domain_status'])) { + $statuses = explode(',', $row['domain_status']); + $row['domain_status'] = array_map(function($status) { + return ['status' => $status]; + }, $statuses); + } else { + $row['domain_status'] = []; + } + } + + $payload = [ + 'records' => $records, + 'results' => $totalCount + ]; + + $response = $response->withHeader('Content-Type', 'application/json; charset=UTF-8'); + $response->getBody()->write(json_encode($payload, JSON_UNESCAPED_UNICODE)); + return $response; + } +} \ No newline at end of file diff --git a/cp/resources/views/partials/js-contacts.twig b/cp/resources/views/partials/js-contacts.twig index 0c3c2ba..928eeab 100644 --- a/cp/resources/views/partials/js-contacts.twig +++ b/cp/resources/views/partials/js-contacts.twig @@ -20,24 +20,6 @@ `; } - - function statusFormatter(cell) { - var statusArray = cell.getValue(); - var rowData = cell.getRow().getData(); // Get the entire row data - - // Function to create a badge - function createBadge(text, badgeClass) { - return `${text}`; - } - - // Check if statusArray is empty or not - if (statusArray && Array.isArray(statusArray) && statusArray.length > 0) { - return statusArray.map(item => createBadge(item.status, 'azure')).join(' '); - } else { - // Fallback to rgpstatus column if statusArray is empty - return rowData.rgpstatus ? createBadge(rowData.rgpstatus, 'lime') : ""; - } - } var searchTerm = ""; // global variable to hold the search term @@ -50,21 +32,29 @@ pagination: true, paginationMode: "remote", paginationSize: 10, + sortMode: "remote", ajaxURL: "/api/records/contact", - ajaxParams: { - join: "contact_status" - }, ajaxURLGenerator: function(url, config, params) { - var queryParts = ["join=contact_status"]; + var queryParts = []; // Handle search term if (searchTerm) { queryParts.push("filter1=identifier,cs," + encodeURIComponent(searchTerm)); queryParts.push("filter2=email,cs," + encodeURIComponent(searchTerm)); queryParts.push("filter3=voice,cs," + encodeURIComponent(searchTerm)); + queryParts.push("filter4=crdate,cs," + encodeURIComponent(searchTerm)); } - queryParts.push("order=id"); + // Handle sorting from Tabulator + if (params.sort && params.sort.length > 0) { + var sorter = params.sort[0]; // single-column sorting + var sortField = encodeURIComponent(sorter.field); + var sortDir = (sorter.dir === "asc" ? "asc" : "desc"); + queryParts.push("order=" + sortField + "," + sortDir); + } else { + // fallback default order if no sorters + queryParts.push("order=id,desc"); + } // Include pagination parameters if (params.page) { @@ -85,9 +75,6 @@ return { last_page: 1, data: [] }; } }, - dataReceiveParams: { - "last_page": "results", // Mapping 'results' to 'last_page' - }, layout:"fitDataFill", responsiveLayout: "collapse", responsiveLayoutCollapseStartOpen:false, @@ -95,10 +82,10 @@ placeholder: "{{ __('No Data') }}", columns:[ {formatter:"responsiveCollapse", width:30, minWidth:30, hozAlign:"center", resizable:false, headerSort:false, responsive:0}, - {title:"{{ __('Identifier') }}", field:"identifier", width:250, resizable:false, headerSort:false, formatter: contactLinkFormatter, responsive:0}, - {title:"{{ __('Email') }}", field:"email", width:300, minWidth:200, resizable:false, headerSort:false, responsive:2}, - {title:"{{ __('Phone') }}", field:"voice", width:300, minWidth:200, resizable:false, headerSort:false, responsive:2}, - {title:"{{ __('Status') }}", field:"contact_status", width:200, minWidth:100, formatter: statusFormatter, resizable:false, headerSort:false, download:false, responsive:2}, + {title:"{{ __('Identifier') }}", field:"identifier", width:250, resizable:false, headerSort:true, formatter: contactLinkFormatter, responsive:0}, + {title:"{{ __('Email') }}", field:"email", width:300, minWidth:200, resizable:false, headerSort:true, responsive:2}, + {title:"{{ __('Phone') }}", field:"voice", width:200, minWidth:100, resizable:false, headerSort:true, responsive:2}, + {title:"{{ __('Creation Date') }}", field:"crdate", width:280, minWidth:100, resizable:true, headerSort:true, responsive:2}, {title: "{{ __('Actions') }}", formatter: actionsFormatter, resizable:false, headerSort:false, download:false, hozAlign: "center", responsive:0, cellClick: function(e, cell){ if (e.target.closest('.delete-btn')) { e.preventDefault(); // Prevent the default link behavior @@ -117,8 +104,13 @@ ] }); var searchInput = document.getElementById("search-input"); + let searchTimeout; + searchInput.addEventListener("input", function () { + clearTimeout(searchTimeout); + searchTimeout = setTimeout(() => { updateSearchTerm(searchInput.value); + }, 300); // 300ms delay }); }); diff --git a/cp/resources/views/partials/js-domains.twig b/cp/resources/views/partials/js-domains.twig index 2655fac..9305459 100644 --- a/cp/resources/views/partials/js-domains.twig +++ b/cp/resources/views/partials/js-domains.twig @@ -47,10 +47,10 @@ // Check if statusArray is empty or not if (statusArray && Array.isArray(statusArray) && statusArray.length > 0) { - return statusArray.map(item => createBadge(item.status, 'azure')).join(' '); + return statusArray.map(item => createBadge(item.status, 'green')).join(' '); } else if (rowData.rgpstatus) { // Fallback to rgpstatus column if statusArray is empty - return createBadge(rowData.rgpstatus, 'lime'); + return createBadge(rowData.rgpstatus, 'info'); } else { // Display 'ok' status with info badge if both statusArray and rgpstatus are empty return createBadge('ok', 'info'); @@ -68,22 +68,30 @@ pagination: true, paginationMode: "remote", paginationSize: 10, - ajaxURL: "/api/records/domain", - ajaxParams: { - join: "contact", - join: "domain_status" - }, + sortMode: "remote", + ajaxURL: "/dapi/domains", ajaxURLGenerator: function(url, config, params) { - var queryParts = ["join=contact", "join=domain_status"]; + var queryParts = []; // Handle search term if (searchTerm) { queryParts.push("filter1=name,cs," + encodeURIComponent(searchTerm)); queryParts.push("filter2=crdate,cs," + encodeURIComponent(searchTerm)); queryParts.push("filter3=exdate,cs," + encodeURIComponent(searchTerm)); + queryParts.push("filter4=registrant_identifier,cs," + encodeURIComponent(searchTerm)); + queryParts.push("filter5=name_o,cs," + encodeURIComponent(searchTerm)); } - queryParts.push("order=crdate,desc"); + // Handle sorting from Tabulator + if (params.sort && params.sort.length > 0) { + var sorter = params.sort[0]; // single-column sorting + var sortField = encodeURIComponent(sorter.field); + var sortDir = (sorter.dir === "asc" ? "asc" : "desc"); + queryParts.push("order=" + sortField + "," + sortDir); + } else { + // fallback default order if no sorters + queryParts.push("order=crdate,desc"); + } // Include pagination parameters if (params.page) { @@ -104,9 +112,6 @@ return { last_page: 1, data: [] }; } }, - dataReceiveParams: { - "last_page": "results", // Mapping 'results' to 'last_page' - }, layout:"fitDataFill", responsiveLayout: "collapse", responsiveLayoutCollapseStartOpen:false, @@ -114,11 +119,11 @@ placeholder: "{{ __('No Data') }}", columns:[ {formatter:"responsiveCollapse", width:30, minWidth:30, hozAlign:"center", resizable:false, headerSort:false, responsive:0}, - {title:"{{ __('Name') }}", field:"name", width:200, resizable:false, headerSort:false, formatter: domainLinkFormatter, responsive:0}, - {title:"{{ __('Registrant') }}", width:200, field:"registrant.identifier", resizable:false, headerSort:false, responsive:2}, - {title:"{{ __('Creation Date') }}", width:250, minWidth:150, field:"crdate", resizable:false, headerSort:false, responsive:2}, - {title:"{{ __('Expiration Date') }}", width:250, minWidth:150, field:"exdate", resizable:false, headerSort:false, responsive:2}, - {title:"{{ __('Status') }}", width:150, field:"domain_status", formatter: statusFormatter, resizable:false, headerSort:false, download:false, responsive:2}, + {title:"{{ __('Name') }}", field:"name", width:200, resizable:false, headerSort:true, formatter: domainLinkFormatter, responsive:0}, + {title:"{{ __('Registrant') }}", width:200, field:"registrant_identifier", resizable:false, headerSort:true, responsive:2}, + {title:"{{ __('Creation Date') }}", width:250, minWidth:150, field:"crdate", resizable:false, headerSort:true, responsive:2}, + {title:"{{ __('Expiration Date') }}", width:250, minWidth:150, field:"exdate", resizable:false, headerSort:true, responsive:2}, + {title:"{{ __('Status') }}", width:150, field:"domain_status", formatter: statusFormatter, resizable:false, headerSort:true, download:false, responsive:2}, {title: "{{ __('Actions') }}", formatter: actionsFormatter, resizable:false, headerSort:false, download:false, hozAlign: "center", responsive:0, cellClick: function(e, cell){ if (e.target.closest('.delete-btn')) { e.preventDefault(); // Prevent the default link behavior @@ -161,8 +166,13 @@ ] }); var searchInput = document.getElementById("search-input"); + let searchTimeout; + searchInput.addEventListener("input", function () { + clearTimeout(searchTimeout); + searchTimeout = setTimeout(() => { updateSearchTerm(searchInput.value); + }, 300); // 300ms delay }); }); diff --git a/cp/resources/views/partials/js-hosts.twig b/cp/resources/views/partials/js-hosts.twig index 1aac453..a87ecc6 100644 --- a/cp/resources/views/partials/js-hosts.twig +++ b/cp/resources/views/partials/js-hosts.twig @@ -21,24 +21,6 @@ `; } - function statusFormatter(cell) { - var statusArray = cell.getValue(); - var rowData = cell.getRow().getData(); // Get the entire row data - - // Function to create a badge - function createBadge(text, badgeClass) { - return `${text}`; - } - - // Check if statusArray is empty or not - if (statusArray && Array.isArray(statusArray) && statusArray.length > 0) { - return statusArray.map(item => createBadge(item.status, 'azure')).join(' '); - } else { - // Fallback to rgpstatus column if statusArray is empty - return rowData.rgpstatus ? createBadge(rowData.rgpstatus, 'lime') : ""; - } - } - var searchTerm = ""; // global variable to hold the search term function updateSearchTerm(term) { @@ -50,20 +32,28 @@ pagination: true, paginationMode: "remote", paginationSize: 10, + sortMode: "remote", ajaxURL: "/api/records/host", - ajaxParams: { - join: "host_status" - }, ajaxURLGenerator: function(url, config, params) { - var queryParts = ["join=host_status"]; + var queryParts = []; // Handle search term if (searchTerm) { queryParts.push("filter1=name,cs," + encodeURIComponent(searchTerm)); queryParts.push("filter2=crdate,cs," + encodeURIComponent(searchTerm)); + queryParts.push("filter3=lastupdate,cs," + encodeURIComponent(searchTerm)); } - queryParts.push("order=id"); + // Handle sorting from Tabulator + if (params.sort && params.sort.length > 0) { + var sorter = params.sort[0]; // single-column sorting + var sortField = encodeURIComponent(sorter.field); + var sortDir = (sorter.dir === "asc" ? "asc" : "desc"); + queryParts.push("order=" + sortField + "," + sortDir); + } else { + // fallback default order if no sorters + queryParts.push("order=name,asc"); + } // Include pagination parameters if (params.page) { @@ -84,9 +74,6 @@ return { last_page: 1, data: [] }; } }, - dataReceiveParams: { - "last_page": "results", // Mapping 'results' to 'last_page' - }, layout:"fitDataFill", responsiveLayout: "collapse", responsiveLayoutCollapseStartOpen:false, @@ -94,9 +81,9 @@ placeholder: "{{ __('No Data') }}", columns:[ {formatter:"responsiveCollapse", width:30, minWidth:30, hozAlign:"center", resizable:false, headerSort:false, responsive:0}, - {title:"{{ __('Host Name') }}", field:"name", width:300, resizable:false, headerSort:false, formatter: hostLinkFormatter, responsive:0}, - {title:"{{ __('Creation Date') }}", field:"crdate", width:300, minWidth:200, resizable:false, headerSort:false, responsive:2}, - {title:"{{ __('Status') }}", field:"host_status", width:300, minWidth:200, formatter: statusFormatter, resizable:false, headerSort:false, download:false, responsive:2}, + {title:"{{ __('Host Name') }}", field:"name", width:300, minWidth:150, resizable:false, headerSort:true, formatter: hostLinkFormatter, responsive:0}, + {title:"{{ __('Creation Date') }}", field:"crdate", width:300, minWidth:200, resizable:false, headerSort:true, responsive:2}, + {title:"{{ __('Last Updated') }}", field:"lastupdate", width:300, minWidth:200, resizable:false, headerSort:true, responsive:2}, {title: "{{ __('Actions') }}", formatter: actionsFormatter, resizable:false, headerSort:false, download:false, hozAlign: "center", responsive:0, cellClick: function(e, cell){ if (e.target.closest('.delete-btn')) { e.preventDefault(); // Prevent the default link behavior @@ -116,8 +103,13 @@ }); var searchInput = document.getElementById("search-input"); + let searchTimeout; + searchInput.addEventListener("input", function () { + clearTimeout(searchTimeout); + searchTimeout = setTimeout(() => { updateSearchTerm(searchInput.value); + }, 300); // 300ms delay }); }); diff --git a/cp/routes/web.php b/cp/routes/web.php index 9c61453..63635e4 100644 --- a/cp/routes/web.php +++ b/cp/routes/web.php @@ -14,6 +14,7 @@ use App\Controllers\ReportsController; use App\Controllers\ProfileController; use App\Controllers\SystemController; use App\Controllers\SupportController; +use App\Controllers\DapiController; use App\Middleware\AuthMiddleware; use App\Middleware\GuestMiddleware; use Slim\Exception\HttpNotFoundException; @@ -147,6 +148,8 @@ $app->group('', function ($route) { $route->get('/lang', HomeController::class .':lang')->setName('lang'); $route->get('/logout', AuthController::class . ':logout')->setName('logout'); $route->post('/change-password', PasswordController::class . ':changePassword')->setName('change.password'); + + $route->get('/dapi/domains', [DapiController::class, 'listDomains']); })->add(new AuthMiddleware($container)); $app->any('/api[/{params:.*}]', function (