diff --git a/actions/FrontpageAction.php b/actions/FrontpageAction.php index 9f06394bf89..2465e275ea3 100644 --- a/actions/FrontpageAction.php +++ b/actions/FrontpageAction.php @@ -184,7 +184,7 @@ private static function renderForm( } if (!isset($parameter['defaultValue'])) { - $parameter['defaultValue'] = ''; + $parameter['defaultValue'] = ($parameter['type'] ?? null) === 'multi-list' ? [] : ''; } $idArg = 'arg-' . urlencode($bridgeClassName) . '-' . urlencode($contextName) . '-' . urlencode($id); @@ -200,6 +200,8 @@ private static function renderForm( $form .= self::getNumberInput($parameter, $idArg, $id) . "\n"; } elseif ($parameter['type'] === 'list') { $form .= self::getListInput($parameter, $idArg, $id) . "\n"; + } elseif ($parameter['type'] === 'multi-list') { + $form .= self::getListInput($parameter, $idArg, $id, true) . "\n"; } elseif ($parameter['type'] === 'checkbox') { $form .= self::getCheckboxInput($parameter, $idArg, $id) . "\n"; } else { @@ -288,32 +290,37 @@ public static function getCheckboxInput(array $parameter, string $id, string $na ]); } - public static function getListInput(array $parameter, string $id, string $name): string + public static function getListInput(array $parameter, string $id, string $name, bool $isMulti = false): string { - $list = sprintf('', $id, $name . ($isMulti ? '[]' : ''), $isMulti ? ' multiple' : '') . "\n"; + + if (!empty($parameter['values'])) { + $default = $parameter['defaultValue']; + // Cast to array, so scalars become single element arrays - `"default value"` becomes `["default value"]`. + // Flip, so the values become keys and we can access the values later with O(1) complexity. + if ($isMulti) { + $default = array_flip((array)($default)); + } + + foreach ($parameter['values'] as $name => $value) { + if (is_array($value)) { + $list .= ''; + foreach ($value as $subname => $subvalue) { + if ($isMulti) { + $selected = isset($default[$subname]) || isset($default[$subvalue]); + } else { + $selected = $default === $subname || $default === $subvalue; + } + $list .= html_option($subname, $subvalue, $selected) . "\n"; } - } - $list .= ''; - } else { - if ( - $parameter['defaultValue'] === $name - || $parameter['defaultValue'] === $value - ) { - $list .= html_option($name, $value, true) . "\n"; + $list .= ''; } else { - $list .= html_option($name, $value) . "\n"; + if ($isMulti) { + $selected = isset($default[$name]) || isset($default[$value]); + } else { + $selected = $default === $name || $default === $value; + } + $list .= html_option($name, $value, $selected) . "\n"; } } } diff --git a/lib/BridgeAbstract.php b/lib/BridgeAbstract.php index 8858200f280..0e3d2f358bc 100644 --- a/lib/BridgeAbstract.php +++ b/lib/BridgeAbstract.php @@ -224,6 +224,12 @@ private function setInputWithContext(array $input, $queriedContext) $this->inputs[$context][$name]['value'] = $parameter['defaultValue']; } break; + case 'multi-list': + if (isset($properties['defaultValue'])) { + // Casting to array makes scalar values, like 'my value', become arrays, like ['my value']. + $this->inputs[$context][$name]['value'] = (array)($properties['defaultValue'] ?? []); + } + break; default: if (isset($parameter['defaultValue'])) { $this->inputs[$context][$name]['value'] = $parameter['defaultValue']; diff --git a/lib/ParameterValidator.php b/lib/ParameterValidator.php index c97f7c9e4b6..036d7431052 100644 --- a/lib/ParameterValidator.php +++ b/lib/ParameterValidator.php @@ -31,6 +31,9 @@ public function validateInput(array &$input, array $parameters): array case 'list': $input[$name] = $this->validateListValue($value, $contextParameters[$name]['values']); break; + case 'multi-list': + $input[$name] = $this->validateMultiListValue($value, $contextParameters[$name]['values']); + break; default: case 'text': if (isset($contextParameters[$name]['pattern'])) { @@ -122,6 +125,9 @@ public function getQueriedContext(array $input, array $parameters) private function validateTextValue($value, $pattern = null) { + if (!is_scalar($value)) { + return null; + } if (is_null($pattern)) { // No filtering taking place $filteredValue = filter_var($value); @@ -136,6 +142,9 @@ private function validateTextValue($value, $pattern = null) private function validateNumberValue($value) { + if (!is_scalar($value)) { + return null; + } $filteredValue = filter_var($value, FILTER_VALIDATE_INT); if ($filteredValue === false) { return null; @@ -145,11 +154,17 @@ private function validateNumberValue($value) private function validateCheckboxValue($value) { + if (!is_scalar($value)) { + return null; + } return filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); } private function validateListValue($value, $expectedValues) { + if (!is_scalar($value)) { + return null; + } $filteredValue = filter_var($value); if ($filteredValue === false) { return null; @@ -165,4 +180,20 @@ private function validateListValue($value, $expectedValues) } return $filteredValue; } + + private function validateMultiListValue($values, $expectedValues) + { + if (!is_array($values)) { + return null; + } + $filteredValues = []; + foreach ($values as $v) { + $filteredValue = $this->validateListValue($v, $expectedValues); + if ($filteredValue === null) { + return null; + } + $filteredValues[] = $filteredValue; + } + return $filteredValues; + } } diff --git a/lib/php8backports.php b/lib/php8backports.php index ccef6016b27..116ef8511cd 100644 --- a/lib/php8backports.php +++ b/lib/php8backports.php @@ -30,3 +30,36 @@ function array_is_list(array $arr) return array_keys($arr) === range(0, count($arr) - 1); } } + +if (!function_exists('array_find_key')) { + function array_find_key(array $array, callable $callback) + { + foreach ($array as $key => $value) { + if ($callback($value, $key)) { + return $key; + } + } + return null; + } +} + +if (!function_exists('array_find')) { + function array_find(array $array, callable $callback) + { + return (($key = array_find_key($array, $callback)) !== null) ? $array[$key] : null; + } +} + +if (!function_exists('array_any')) { + function array_any(array $array, callable $callback): bool + { + return array_find_key($array, $callback) !== null; + } +} + +if (!function_exists('array_all')) { + function array_all(array $array, callable $callback): bool + { + return !array_any($array, fn($v, $k) => !$callback($v, $k)); + } +} diff --git a/middlewares/SecurityMiddleware.php b/middlewares/SecurityMiddleware.php index b07a814487a..b16dba98f8e 100644 --- a/middlewares/SecurityMiddleware.php +++ b/middlewares/SecurityMiddleware.php @@ -3,18 +3,19 @@ declare(strict_types=1); /** - * Make sure that only strings are allowed in GET parameters + * Make sure that only strings and arrays of strings are allowed in GET parameters */ class SecurityMiddleware implements Middleware { public function __invoke(Request $request, $next): Response { foreach ($request->toArray() as $key => $value) { - if (!is_string($value)) { - return new Response(render(__DIR__ . '/../templates/error.html.php', [ - 'message' => "Query parameter \"$key\" is not a string.", - ]), 400); + if (is_string($value) || is_array($value) && array_all($value, fn($v, $k) => is_string($v))) { + continue; } + return new Response(render(__DIR__ . '/../templates/error.html.php', [ + 'message' => "Query parameter \"$key\" is not a string or array of strings.", + ]), 400); } return $next($request); } diff --git a/static/rss-bridge.js b/static/rss-bridge.js index 9cd004cb00c..2ddf9fffcd7 100644 --- a/static/rss-bridge.js +++ b/static/rss-bridge.js @@ -50,7 +50,7 @@ var rssbridge_feed_finder = (function() { */ // Start the Feed search - async function rssbridge_feed_search(event) { + async function rssbridge_feed_search(_) { const input = document.getElementById('searchfield'); let content = encodeURIComponent(input.value); if (content) { @@ -92,7 +92,9 @@ var rssbridge_feed_finder = (function() { // Now display every Feed parameter for (const param in element.bridgeData) { - content += `
  • ${element.bridgeData[param].name} : ${element.bridgeData[param].value}
  • `; + const paramMeta = element.bridgeMeta.parameters[element.bridgeParams.context]?.[param] ?? element.bridgeMeta.parameters['global']?.[param]; + const value = paramMeta?.type === 'multi-list' ? element.bridgeData[param].value.join(', ') : element.bridgeData[param].value; + content += `
  • ${element.bridgeData[param].name} : ${value}
  • `; } content += ` diff --git a/tests/BridgeCardTest.php b/tests/BridgeCardTest.php index 5e15fbf1b68..b2ac3e8d69f 100644 --- a/tests/BridgeCardTest.php +++ b/tests/BridgeCardTest.php @@ -15,6 +15,7 @@ public function test() 'values' => [], ]; $this->assertSame('' . "\n", FrontpageAction::getListInput($entry, 'id', 'name')); + $this->assertSame('' . "\n", FrontpageAction::getListInput($entry, 'id', 'name', true)); $entry = [ 'defaultValue' => 2, @@ -26,6 +27,10 @@ public function test() '' . "\n", FrontpageAction::getListInput($entry, 'id', 'name') ); + $this->assertSame( + '' . "\n", + FrontpageAction::getListInput($entry, 'id', 'name', true) + ); // optgroup $entry = [ @@ -38,6 +43,27 @@ public function test() '' . "\n", FrontpageAction::getListInput($entry, 'id', 'name') ); + $this->assertSame( + '' . "\n", + FrontpageAction::getListInput($entry, 'id', 'name', true) + ); + + // multiple default values + $entry = [ + 'defaultValue' => [ 'a', 'c', ], + 'values' => ['kek' => [ + 'f' => 'b', + 'a' => 'g', + ]], + ]; + $expected = << + + + + +EOT; + $this->assertSame($expected, FrontpageAction::getListInput($entry, 'id', 'name', true)); } public function test2() diff --git a/tests/BridgeImplementationTest.php b/tests/BridgeImplementationTest.php index dd68934edc9..01196dbcda4 100644 --- a/tests/BridgeImplementationTest.php +++ b/tests/BridgeImplementationTest.php @@ -70,6 +70,7 @@ public function testParameters($path) 'text', 'number', 'list', + 'multi-list', 'checkbox', ]; @@ -99,7 +100,7 @@ public function testParameters($path) $this->assertIsString($options['type'], $field . ': invalid type'); $this->assertContains($options['type'], $allowedTypes, $field . ': unknown type'); - if ($options['type'] == 'list') { + if ($options['type'] == 'list' || $options['type'] == 'multi-list') { $this->assertArrayHasKey('values', $options, $field . ': missing list values'); $this->assertIsArray($options['values'], $field . ': invalid list values'); $this->assertNotEmpty($options['values'], $field . ': empty list values'); @@ -116,6 +117,7 @@ public function testParameters($path) if ($options['required'] === true && isset($options['type'])) { switch ($options['type']) { case 'list': + case 'multi-list': case 'checkbox': $this->assertArrayNotHasKey( 'required', diff --git a/tests/ParameterValidatorTest.php b/tests/ParameterValidatorTest.php index 59d7b2b9560..6e850f6e385 100644 --- a/tests/ParameterValidatorTest.php +++ b/tests/ParameterValidatorTest.php @@ -37,4 +37,36 @@ public function test2() ]; $this->assertNotEmpty($sut->validateInput($input, $parameters)); } + + public function test3() + { + $sut = new \ParameterValidator(); + $input = [ 'categories' => [ 'economy', 'politics', 'entertainment', ], ]; + $parameters = [ + [ + 'categories' => [ + 'name' => 'Categories', + 'type' => 'multi-list', + 'values' => [ 'economy', 'politics', 'entertainment', ], + ], + ] + ]; + $this->assertSame([], $sut->validateInput($input, $parameters)); + } + + public function test4() + { + $sut = new \ParameterValidator(); + $input = [ 'categories' => [ 'economy', 'politics', 'entertainment', ], ]; + $parameters = [ + [ + 'category' => [ + 'name' => 'Categories', + 'type' => 'multi-list', + 'values' => [ 'economy', 'politics', 'entertainment', ], + ], + ] + ]; + $this->assertNotEmpty($sut->validateInput($input, $parameters)); + } }