Enhance goals_criteria::fromJson with input sanitization, deduplication, and validation

- Add robust validation for JSON input, including error handling for malformed or invalid data.
- Sanitize and validate all fields (`type`, `target`, `label`, `start`, `end`) with additional constraints like trimming, length limits, and proper date handling.
- Implement deduplication for `users`, `departments`, and `products` to ensure unique and valid entries.
- Ensure `end` date does not precede `start` by swapping when necessary.
This commit is contained in:
Jeppe Bundgaard
2026-01-26 19:27:31 +01:00
parent 0491a2ad1b
commit da65d0decd
@@ -55,26 +55,68 @@ class goals_criteria implements goals_criteria_i
public static function fromJson(string $json): goals_criteria
{
$data = json_decode($json, true);
if (!is_array($data) || json_last_error() !== JSON_ERROR_NONE) {
throw new \InvalidArgumentException('Invalid JSON for goals criteria');
}
$criteria = new goals_criteria();
// Sanitize type (ignore unknowns)
if (isset($data['type'])) {
$criteria->type = goals_criteria_type::tryFrom($data['type']);
$type = goals_criteria_type::tryFrom((string)$data['type']);
if ($type !== null) {
$criteria->type = $type;
}
}
// Sanitize target: numeric and non-negative
if (isset($data['target'])) {
$criteria->target = $data['target'];
if (is_numeric($data['target'])) {
$target = (float)$data['target'];
$criteria->target = max(0, $target);
}
}
if (isset($data['label'])) {
$criteria->label = is_string($data['label']) ? $data['label'] : null;
// Sanitize label: trim, strip tags, collapse whitespace, max length 255
if (isset($data['label']) && is_string($data['label'])) {
$label = trim($data['label']);
$label = strip_tags($label);
$label = preg_replace('/\s+/', ' ', $label);
if ($label !== null) {
$label = mb_substr($label, 0, 255);
}
$criteria->label = ($label === '') ? null : $label;
}
if (isset($data['start'])) {
$criteria->start = new \DateTime($data['start']);
// Parse timeframe with validation
if (isset($data['start']) && is_string($data['start'])) {
try {
$criteria->start = new \DateTime($data['start']);
} catch (\Exception) {
// keep default
}
}
if (isset($data['end'])) {
$criteria->end = new \DateTime($data['end']);
if (isset($data['end']) && is_string($data['end'])) {
try {
$criteria->end = new \DateTime($data['end']);
} catch (\Exception) {
// keep default
}
}
// Ensure end is not before start (swap if needed)
if (($criteria->start instanceof \DateTimeInterface) && ($criteria->end instanceof \DateTimeInterface)) {
if ($criteria->end < $criteria->start) {
$tmp = $criteria->start;
$criteria->start = $criteria->end;
$criteria->end = $tmp;
}
}
// Parse users (expects array of customer_numbers or objects with customer_number)
if (isset($data['users']) && is_array($data['users'])) {
$users = array_map(function ($item) {
$seen = [];
$users = array_map(function ($item) use (&$seen) {
$customerNumber = null;
if (is_array($item)) {
if (isset($item['customer_number'])) {
@@ -85,9 +127,13 @@ class goals_criteria implements goals_criteria_i
} elseif (is_numeric($item)) {
$customerNumber = (int)$item;
}
if ($customerNumber === null) {
if ($customerNumber === null || $customerNumber <= 0) {
return null;
}
if (isset($seen[$customerNumber])) {
return null; // dedupe early
}
$seen[$customerNumber] = true;
return (new \objects\users_o())->getUserByCustomerNumber($customerNumber);
}, $data['users']);
// Filter out nulls in case of malformed entries
@@ -97,7 +143,8 @@ class goals_criteria implements goals_criteria_i
// Parse departments (expects array of IDs or objects with id)
if (isset($data['departments']) && is_array($data['departments'])) {
$departments = array_map(function ($item) {
$seenDept = [];
$departments = array_map(function ($item) use (&$seenDept) {
$id = null;
if (is_array($item)) {
if (isset($item['id'])) {
@@ -106,9 +153,13 @@ class goals_criteria implements goals_criteria_i
} elseif (is_numeric($item)) {
$id = (int)$item;
}
if ($id === null) {
if ($id === null || $id <= 0) {
return null;
}
if (isset($seenDept[$id])) {
return null;
}
$seenDept[$id] = true;
return (new \objects\departments_o())->select($id);
}, $data['departments']);
$departments = array_values(array_filter($departments));
@@ -117,7 +168,8 @@ class goals_criteria implements goals_criteria_i
// Parse products (expects array of IDs or objects with id)
if (isset($data['products']) && is_array($data['products'])) {
$products = array_map(function ($item) {
$seenProd = [];
$products = array_map(function ($item) use (&$seenProd) {
$id = null;
if (is_array($item)) {
if (isset($item['id'])) {
@@ -126,9 +178,13 @@ class goals_criteria implements goals_criteria_i
} elseif (is_numeric($item)) {
$id = (int)$item;
}
if ($id === null) {
if ($id === null || $id <= 0) {
return null;
}
if (isset($seenProd[$id])) {
return null;
}
$seenProd[$id] = true;
return (new \objects\products_o())->select($id);
}, $data['products']);
$products = array_values(array_filter($products));