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 (