| 1 |
<?php |
| 2 |
/** |
| 3 |
* Declarative rich-observation sanitizer. |
| 4 |
* |
| 5 |
* @package signls-sdk |
| 6 |
* @license GPL-3.0-or-later |
| 7 |
*/ |
| 8 |
|
| 9 |
namespace Signls\Sdk\V1; |
| 10 |
|
| 11 |
defined( 'ABSPATH' ) || exit; |
| 12 |
|
| 13 |
final class ObservationSanitizer { |
| 14 |
|
| 15 |
private const TYPES = array( |
| 16 |
'object', |
| 17 |
'list', |
| 18 |
'map', |
| 19 |
'bool', |
| 20 |
'uint', |
| 21 |
'int', |
| 22 |
'decimal', |
| 23 |
'enum', |
| 24 |
'slug', |
| 25 |
'version', |
| 26 |
'text', |
| 27 |
'url', |
| 28 |
'sha256', |
| 29 |
); |
| 30 |
|
| 31 |
public static function sanitize( array $source, array $schema ): array { |
| 32 |
if ( 'object' !== self::type( $schema ) ) { |
| 33 |
return array(); |
| 34 |
} |
| 35 |
$value = self::node( $source, $schema ); |
| 36 |
return is_array( $value ) ? $value : array(); |
| 37 |
} |
| 38 |
|
| 39 |
private static function node( $value, array $schema ) { |
| 40 |
$type = self::type( $schema ); |
| 41 |
$nullable = ! empty( $schema['nullable'] ); |
| 42 |
if ( null === $value && $nullable ) { |
| 43 |
return null; |
| 44 |
} |
| 45 |
|
| 46 |
switch ( $type ) { |
| 47 |
case 'object': |
| 48 |
return self::object( $value, $schema ); |
| 49 |
case 'list': |
| 50 |
return self::list_value( $value, $schema ); |
| 51 |
case 'map': |
| 52 |
return self::map_value( $value, $schema ); |
| 53 |
case 'bool': |
| 54 |
return Sanitizer::bool( $value ); |
| 55 |
case 'uint': |
| 56 |
return Sanitizer::int( $value, 0, self::integer_bound( $schema, 'max', 4294967295 ) ); |
| 57 |
case 'int': |
| 58 |
return Sanitizer::int( |
| 59 |
$value, |
| 60 |
self::integer_bound( $schema, 'min', -2147483648 ), |
| 61 |
self::integer_bound( $schema, 'max', 2147483647 ) |
| 62 |
); |
| 63 |
case 'decimal': |
| 64 |
return Sanitizer::decimal( |
| 65 |
$value, |
| 66 |
self::float_bound( $schema, 'min', 0.0 ), |
| 67 |
self::float_bound( $schema, 'max', 4294967295.0 ), |
| 68 |
self::integer_bound( $schema, 'scale', 4 ) |
| 69 |
); |
| 70 |
case 'enum': |
| 71 |
$allowed = self::allowed( $schema ); |
| 72 |
$fallback = isset( $schema['fallback'] ) && is_string( $schema['fallback'] ) ? $schema['fallback'] : ''; |
| 73 |
return Sanitizer::enum( $value, $allowed, $fallback ); |
| 74 |
case 'slug': |
| 75 |
return Sanitizer::slug( $value, self::length( $schema, 48 ), self::fallback( $schema ) ); |
| 76 |
case 'version': |
| 77 |
return Sanitizer::version( $value, self::length( $schema, 20 ) ); |
| 78 |
case 'text': |
| 79 |
return Sanitizer::text( $value, self::length( $schema, 191 ) ); |
| 80 |
case 'url': |
| 81 |
return Sanitizer::url( $value, self::length( $schema, 500 ) ); |
| 82 |
case 'sha256': |
| 83 |
return Sanitizer::sha256( $value ); |
| 84 |
default: |
| 85 |
return $nullable ? null : ''; |
| 86 |
} |
| 87 |
} |
| 88 |
|
| 89 |
private static function object( $value, array $schema ): array { |
| 90 |
$source = is_array( $value ) ? $value : array(); |
| 91 |
$properties = isset( $schema['properties'] ) && is_array( $schema['properties'] ) ? $schema['properties'] : array(); |
| 92 |
$result = array(); |
| 93 |
foreach ( $properties as $key => $property ) { |
| 94 |
if ( ! is_string( $key ) || ! is_array( $property ) || ! array_key_exists( $key, $source ) ) { |
| 95 |
continue; |
| 96 |
} |
| 97 |
$result[ $key ] = self::node( $source[ $key ], $property ); |
| 98 |
} |
| 99 |
return $result; |
| 100 |
} |
| 101 |
|
| 102 |
private static function list_value( $value, array $schema ) { |
| 103 |
$accounted = ! empty( $schema['with_accounting'] ); |
| 104 |
$source = is_array( $value ) ? $value : array(); |
| 105 |
$rows = $accounted && isset( $source['items'] ) && is_array( $source['items'] ) ? $source['items'] : $source; |
| 106 |
$item = isset( $schema['items'] ) && is_array( $schema['items'] ) ? $schema['items'] : array(); |
| 107 |
$maximum = max( 1, min( 1000, self::integer_bound( $schema, 'max_items', 1 ) ) ); |
| 108 |
$clean = array(); |
| 109 |
foreach ( array_slice( $rows, 0, $maximum ) as $row ) { |
| 110 |
$clean[] = self::node( $row, $item ); |
| 111 |
} |
| 112 |
|
| 113 |
$sort_key = isset( $schema['sort_key'] ) && is_string( $schema['sort_key'] ) ? $schema['sort_key'] : ''; |
| 114 |
if ( '' !== $sort_key ) { |
| 115 |
usort( |
| 116 |
$clean, |
| 117 |
static function ( $left, $right ) use ( $sort_key ) { |
| 118 |
$left_candidate = is_array( $left ) && isset( $left[ $sort_key ] ) ? $left[ $sort_key ] : ''; |
| 119 |
$right_candidate = is_array( $right ) && isset( $right[ $sort_key ] ) ? $right[ $sort_key ] : ''; |
| 120 |
$left_value = is_scalar( $left_candidate ) ? (string) $left_candidate : ''; |
| 121 |
$right_value = is_scalar( $right_candidate ) ? (string) $right_candidate : ''; |
| 122 |
return strcmp( $left_value, $right_value ); |
| 123 |
} |
| 124 |
); |
| 125 |
} |
| 126 |
|
| 127 |
if ( ! $accounted ) { |
| 128 |
return $clean; |
| 129 |
} |
| 130 |
$reported = isset( $source['reported_total'] ) ? Sanitizer::counter( $source['reported_total'] ) : count( $rows ); |
| 131 |
$reported = max( $reported, count( $rows ) ); |
| 132 |
return array( |
| 133 |
'items' => $clean, |
| 134 |
'reported_total' => $reported, |
| 135 |
'truncated' => ! empty( $source['truncated'] ) || $reported > count( $clean ), |
| 136 |
); |
| 137 |
} |
| 138 |
|
| 139 |
private static function map_value( $value, array $schema ): array { |
| 140 |
$source = is_array( $value ) ? $value : array(); |
| 141 |
$value_rule = isset( $schema['values'] ) && is_array( $schema['values'] ) ? $schema['values'] : array(); |
| 142 |
$maximum = max( 1, min( 1000, self::integer_bound( $schema, 'max_items', 1 ) ) ); |
| 143 |
$key_length = max( 1, min( 100, self::integer_bound( $schema, 'max_key_length', 48 ) ) ); |
| 144 |
$result = array(); |
| 145 |
ksort( $source, SORT_STRING ); |
| 146 |
foreach ( $source as $key => $item ) { |
| 147 |
$key = Sanitizer::slug( $key, $key_length ); |
| 148 |
if ( '' === $key || isset( $result[ $key ] ) ) { |
| 149 |
continue; |
| 150 |
} |
| 151 |
$result[ $key ] = self::node( $item, $value_rule ); |
| 152 |
if ( count( $result ) >= $maximum ) { |
| 153 |
break; |
| 154 |
} |
| 155 |
} |
| 156 |
return $result; |
| 157 |
} |
| 158 |
|
| 159 |
private static function type( array $schema ): string { |
| 160 |
$type = isset( $schema['type'] ) && is_string( $schema['type'] ) ? $schema['type'] : ''; |
| 161 |
return in_array( $type, self::TYPES, true ) ? $type : ''; |
| 162 |
} |
| 163 |
|
| 164 |
private static function allowed( array $schema ): array { |
| 165 |
$values = isset( $schema['allowed'] ) && is_array( $schema['allowed'] ) ? $schema['allowed'] : array(); |
| 166 |
return array_values( |
| 167 |
array_filter( |
| 168 |
$values, |
| 169 |
static function ( $value ) { |
| 170 |
return is_string( $value ); |
| 171 |
} |
| 172 |
) |
| 173 |
); |
| 174 |
} |
| 175 |
|
| 176 |
private static function fallback( array $schema ): string { |
| 177 |
return isset( $schema['fallback'] ) && is_string( $schema['fallback'] ) ? $schema['fallback'] : ''; |
| 178 |
} |
| 179 |
|
| 180 |
private static function length( array $schema, int $fallback ): int { |
| 181 |
return max( 1, min( 4096, self::integer_bound( $schema, 'max_length', $fallback ) ) ); |
| 182 |
} |
| 183 |
|
| 184 |
private static function integer_bound( array $schema, string $key, int $fallback ): int { |
| 185 |
return isset( $schema[ $key ] ) && is_int( $schema[ $key ] ) ? $schema[ $key ] : $fallback; |
| 186 |
} |
| 187 |
|
| 188 |
private static function float_bound( array $schema, string $key, float $fallback ): float { |
| 189 |
return isset( $schema[ $key ] ) && is_numeric( $schema[ $key ] ) ? (float) $schema[ $key ] : $fallback; |
| 190 |
} |
| 191 |
} |
| 192 |
|