grav-lecampus/system/src/Grav/Common/Data/BlueprintSchema.php
2024-06-07 14:19:08 +02:00

430 lines
14 KiB
PHP

<?php
/**
* @package Grav\Common\Data
*
* @copyright Copyright (c) 2015 - 2024 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
namespace Grav\Common\Data;
use Grav\Common\Config\Config;
use Grav\Common\Grav;
use RocketTheme\Toolbox\ArrayTraits\Export;
use RocketTheme\Toolbox\ArrayTraits\ExportInterface;
use RocketTheme\Toolbox\Blueprints\BlueprintSchema as BlueprintSchemaBase;
use RuntimeException;
use function is_array;
use function is_string;
/**
* Class BlueprintSchema
* @package Grav\Common\Data
*/
class BlueprintSchema extends BlueprintSchemaBase implements ExportInterface
{
use Export;
/** @var array */
protected $filter = ['validation' => true, 'xss_check' => true];
/** @var array */
protected $ignoreFormKeys = [
'title' => true,
'help' => true,
'placeholder' => true,
'placeholder_key' => true,
'placeholder_value' => true,
'fields' => true
];
/**
* @return array
*/
public function getTypes()
{
return $this->types;
}
/**
* @param string $name
* @return array
*/
public function getType($name)
{
return $this->types[$name] ?? [];
}
/**
* @param string $name
* @return array|null
*/
public function getNestedRules(string $name)
{
return $this->getNested($name);
}
/**
* Validate data against blueprints.
*
* @param array $data
* @param array $options
* @return void
* @throws RuntimeException
*/
public function validate(array $data, array $options = [])
{
try {
$validation = $this->items['']['form']['validation'] ?? 'loose';
$messages = $this->validateArray($data, $this->nested, $validation === 'strict', $options['xss_check'] ?? true);
} catch (RuntimeException $e) {
throw (new ValidationException($e->getMessage(), $e->getCode(), $e))->setMessages();
}
if (!empty($messages)) {
throw (new ValidationException('', 400))->setMessages($messages);
}
}
/**
* @param array $data
* @param array $toggles
* @return array
*/
public function processForm(array $data, array $toggles = [])
{
return $this->processFormRecursive($data, $toggles, $this->nested) ?? [];
}
/**
* Filter data by using blueprints.
*
* @param array $data Incoming data, for example from a form.
* @param bool $missingValuesAsNull Include missing values as nulls.
* @param bool $keepEmptyValues Include empty values.
* @return array
*/
public function filter(array $data, $missingValuesAsNull = false, $keepEmptyValues = false)
{
$this->buildIgnoreNested($this->nested);
return $this->filterArray($data, $this->nested, '', $missingValuesAsNull, $keepEmptyValues) ?? [];
}
/**
* Flatten data by using blueprints.
*
* @param array $data Data to be flattened.
* @param bool $includeAll True if undefined properties should also be included.
* @param string $name Property which will be flattened, useful for flattening repeating data.
* @return array
*/
public function flattenData(array $data, bool $includeAll = false, string $name = '')
{
$prefix = $name !== '' ? $name . '.' : '';
$list = [];
if ($includeAll) {
$items = $name !== '' ? $this->getProperty($name)['fields'] ?? [] : $this->items;
foreach ($items as $key => $rules) {
$type = $rules['type'] ?? '';
$ignore = (bool) array_filter((array)($rules['validate']['ignore'] ?? [])) ?? false;
if (!str_starts_with($type, '_') && !str_contains($key, '*') && $ignore !== true) {
$list[$prefix . $key] = null;
}
}
}
$nested = $this->getNestedRules($name);
return array_replace($list, $this->flattenArray($data, $nested, $prefix));
}
/**
* @param array $data
* @param array $rules
* @param string $prefix
* @return array
*/
protected function flattenArray(array $data, array $rules, string $prefix)
{
$array = [];
foreach ($data as $key => $field) {
$val = $rules[$key] ?? $rules['*'] ?? null;
$rule = is_string($val) ? $this->items[$val] : null;
if ($rule || isset($val['*'])) {
// Item has been defined in blueprints.
$array[$prefix.$key] = $field;
} elseif (is_array($field) && is_array($val)) {
// Array has been defined in blueprints.
$array += $this->flattenArray($field, $val, $prefix . $key . '.');
} else {
// Undefined/extra item.
$array[$prefix.$key] = $field;
}
}
return $array;
}
/**
* @param array $data
* @param array $rules
* @param bool $strict
* @param bool $xss
* @return array
* @throws RuntimeException
*/
protected function validateArray(array $data, array $rules, bool $strict, bool $xss = true)
{
$messages = $this->checkRequired($data, $rules);
foreach ($data as $key => $child) {
$val = $rules[$key] ?? $rules['*'] ?? null;
$rule = is_string($val) ? $this->items[$val] : null;
$checkXss = $xss;
if ($rule) {
// Item has been defined in blueprints.
if (!empty($rule['disabled']) || !empty($rule['validate']['ignore'])) {
// Skip validation in the ignored field.
continue;
}
$messages += Validation::validate($child, $rule);
} elseif (is_array($child) && is_array($val)) {
// Array has been defined in blueprints.
$messages += $this->validateArray($child, $val, $strict);
$checkXss = false;
} elseif ($strict) {
// Undefined/extra item in strict mode.
/** @var Config $config */
$config = Grav::instance()['config'];
if (!$config->get('system.strict_mode.blueprint_strict_compat', true)) {
throw new RuntimeException(sprintf('%s is not defined in blueprints', $key), 400);
}
user_error(sprintf('Having extra key %s in your data is deprecated with blueprint having \'validation: strict\'', $key), E_USER_DEPRECATED);
}
if ($checkXss) {
$messages += Validation::checkSafety($child, $rule ?: ['name' => $key]);
}
}
return $messages;
}
/**
* @param array $data
* @param array $rules
* @param string $parent
* @param bool $missingValuesAsNull
* @param bool $keepEmptyValues
* @return array|null
*/
protected function filterArray(array $data, array $rules, string $parent, bool $missingValuesAsNull, bool $keepEmptyValues)
{
$results = [];
foreach ($data as $key => $field) {
$val = $rules[$key] ?? $rules['*'] ?? null;
$rule = is_string($val) ? $this->items[$val] : $this->items[$parent . $key] ?? null;
if (!empty($rule['disabled']) || !empty($rule['validate']['ignore'])) {
// Skip any data in the ignored field.
unset($results[$key]);
continue;
}
if (null === $field) {
if ($missingValuesAsNull) {
$results[$key] = null;
} else {
unset($results[$key]);
}
continue;
}
$isParent = isset($val['*']);
$type = $rule['type'] ?? null;
if (!$isParent && $type && $type !== '_parent') {
$field = Validation::filter($field, $rule);
} elseif (is_array($field) && is_array($val)) {
// Array has been defined in blueprints.
$k = $isParent ? '*' : $key;
$field = $this->filterArray($field, $val, $parent . $k . '.', $missingValuesAsNull, $keepEmptyValues);
if (null === $field) {
// Nested parent has no values.
unset($results[$key]);
continue;
}
} elseif (isset($rules['validation']) && $rules['validation'] === 'strict') {
// Skip any extra data.
continue;
}
if ($keepEmptyValues || (null !== $field && (!is_array($field) || !empty($field)))) {
$results[$key] = $field;
}
}
return $results ?: null;
}
/**
* @param array $nested
* @param string $parent
* @return bool
*/
protected function buildIgnoreNested(array $nested, $parent = '')
{
$ignore = true;
foreach ($nested as $key => $val) {
$key = $parent . $key;
if (is_array($val)) {
$ignore = $this->buildIgnoreNested($val, $key . '.') && $ignore; // Keep the order!
} else {
$child = $this->items[$key] ?? null;
$ignore = $ignore && (!$child || !empty($child['disabled']) || !empty($child['validate']['ignore']));
}
}
if ($ignore) {
$key = trim($parent, '.');
$this->items[$key]['validate']['ignore'] = true;
}
return $ignore;
}
/**
* @param array|null $data
* @param array $toggles
* @param array $nested
* @return array|null
*/
protected function processFormRecursive(?array $data, array $toggles, array $nested)
{
foreach ($nested as $key => $value) {
if ($key === '') {
continue;
}
if ($key === '*') {
// TODO: Add support to collections.
continue;
}
if (is_array($value)) {
// Special toggle handling for all the nested data.
$toggle = $toggles[$key] ?? [];
if (!is_array($toggle)) {
if (!$toggle) {
$data[$key] = null;
continue;
}
$toggle = [];
}
// Recursively fetch the items.
$childData = $data[$key] ?? null;
if (null !== $childData && !is_array($childData)) {
throw new \RuntimeException(sprintf("Bad form data for field collection '%s': %s used instead of an array", $key, gettype($childData)));
}
$data[$key] = $this->processFormRecursive($data[$key] ?? null, $toggle, $value);
} else {
$field = $this->get($value);
// Do not add the field if:
if (
// Not an input field
!$field
// Field has been disabled
|| !empty($field['disabled'])
// Field validation is set to be ignored
|| !empty($field['validate']['ignore'])
// Field is overridable and the toggle is turned off
|| (!empty($field['overridable']) && empty($toggles[$key]))
) {
continue;
}
if (!isset($data[$key])) {
$data[$key] = null;
}
}
}
return $data;
}
/**
* @param array $data
* @param array $fields
* @return array
*/
protected function checkRequired(array $data, array $fields)
{
$messages = [];
foreach ($fields as $name => $field) {
if (!is_string($field)) {
continue;
}
$field = $this->items[$field];
// Skip ignored field, it will not be required.
if (!empty($field['disabled']) || !empty($field['validate']['ignore'])) {
continue;
}
// Skip overridable fields without value.
// TODO: We need better overridable support, which is not just ignoring required values but also looking if defaults are good.
if (!empty($field['overridable']) && !isset($data[$name])) {
continue;
}
// Check if required.
if (isset($field['validate']['required'])
&& $field['validate']['required'] === true) {
if (isset($data[$name])) {
continue;
}
if ($field['type'] === 'file' && isset($data['data']['name'][$name])) { //handle case of file input fields required
continue;
}
$value = $field['label'] ?? $field['name'];
$language = Grav::instance()['language'];
$message = sprintf($language->translate('GRAV.FORM.MISSING_REQUIRED_FIELD', null, true) . ' %s', $language->translate($value));
$messages[$field['name']][] = $message;
}
}
return $messages;
}
/**
* @param array $field
* @param string $property
* @param array $call
* @return void
*/
protected function dynamicConfig(array &$field, $property, array &$call)
{
$value = $call['params'];
$default = $field[$property] ?? null;
$config = Grav::instance()['config']->get($value, $default);
if (null !== $config) {
$field[$property] = $config;
}
}
}