|
| 1 | +<?php |
| 2 | + |
| 3 | +declare(strict_types=1); |
| 4 | + |
| 5 | +/** |
| 6 | + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors |
| 7 | + * SPDX-License-Identifier: AGPL-3.0-or-later |
| 8 | + */ |
| 9 | + |
| 10 | +namespace OCA\AppAPI\Service; |
| 11 | + |
| 12 | +use InvalidArgumentException; |
| 13 | + |
| 14 | +/** |
| 15 | + * Normalize and validate ExApp routes from info.xml / --json-info before they are persisted. |
| 16 | + * |
| 17 | + * Two input shapes feed this helper: |
| 18 | + * - JSON (`--json-info`): values are already typed (access_level: int, bruteforce_protection: int[], headers_to_exclude: string[]). |
| 19 | + * - XML (`--info-xml` / appstore): JSON-encoded lists arrive as strings inside element bodies |
| 20 | + * (`<bruteforce_protection>[401]</bruteforce_protection>`), access_level as `PUBLIC|USER|ADMIN`. |
| 21 | + * |
| 22 | + * The helper produces a canonical structure: access_level as 0/1/2, bruteforce_protection as int[], |
| 23 | + * headers_to_exclude as string[]. Anything that cannot be reconciled to that shape is rejected with a |
| 24 | + * descriptive message — devs see the actual problem instead of the values being silently coerced to `[]`. |
| 25 | + */ |
| 26 | +class ExAppRouteHelper { |
| 27 | + private const ACCESS_LEVEL_BY_NAME = [ |
| 28 | + 'PUBLIC' => 0, |
| 29 | + 'USER' => 1, |
| 30 | + 'ADMIN' => 2, |
| 31 | + ]; |
| 32 | + |
| 33 | + /** |
| 34 | + * @param array $routes raw route entries from getAppInfo's shape-collapse step |
| 35 | + * @return array normalized routes ready for ExAppMapper::registerExAppRoutes |
| 36 | + * @throws InvalidArgumentException on the first malformed field; message identifies the route and field |
| 37 | + */ |
| 38 | + public static function normalizeAndValidate(array $routes): array { |
| 39 | + $normalized = []; |
| 40 | + foreach ($routes as $index => $route) { |
| 41 | + if (!is_array($route)) { |
| 42 | + throw new InvalidArgumentException(sprintf('route #%d: entry must be an object, got %s', $index, get_debug_type($route))); |
| 43 | + } |
| 44 | + $normalized[] = self::normalizeRoute($route, $index); |
| 45 | + } |
| 46 | + return $normalized; |
| 47 | + } |
| 48 | + |
| 49 | + private static function normalizeRoute(array $route, int $index): array { |
| 50 | + $url = $route['url'] ?? null; |
| 51 | + if (!is_string($url) || trim($url) === '') { |
| 52 | + throw new InvalidArgumentException(sprintf("route #%d: 'url' must be a non-empty string, got %s", $index, self::describe($url))); |
| 53 | + } |
| 54 | + $ident = sprintf("route '%s'", $url); |
| 55 | + |
| 56 | + $verb = $route['verb'] ?? null; |
| 57 | + if (!is_string($verb) || trim($verb) === '') { |
| 58 | + throw new InvalidArgumentException(sprintf("%s: 'verb' must be a non-empty string (e.g. 'GET' or 'GET,POST'), got %s", $ident, self::describe($verb))); |
| 59 | + } |
| 60 | + |
| 61 | + return [ |
| 62 | + 'url' => $url, |
| 63 | + 'verb' => $verb, |
| 64 | + 'access_level' => self::normalizeAccessLevel($route['access_level'] ?? null, $ident), |
| 65 | + 'bruteforce_protection' => self::normalizeIntList($route['bruteforce_protection'] ?? null, $ident, 'bruteforce_protection'), |
| 66 | + 'headers_to_exclude' => self::normalizeStringList($route['headers_to_exclude'] ?? null, $ident, 'headers_to_exclude'), |
| 67 | + ]; |
| 68 | + } |
| 69 | + |
| 70 | + private static function normalizeAccessLevel(mixed $raw, string $ident): int { |
| 71 | + if (is_string($raw)) { |
| 72 | + if (!array_key_exists($raw, self::ACCESS_LEVEL_BY_NAME)) { |
| 73 | + throw new InvalidArgumentException(sprintf("%s: invalid 'access_level' '%s' (allowed: PUBLIC, USER, ADMIN)", $ident, $raw)); |
| 74 | + } |
| 75 | + return self::ACCESS_LEVEL_BY_NAME[$raw]; |
| 76 | + } |
| 77 | + if (is_int($raw)) { |
| 78 | + if (!in_array($raw, self::ACCESS_LEVEL_BY_NAME, true)) { |
| 79 | + throw new InvalidArgumentException(sprintf("%s: invalid 'access_level' %d (allowed: 0=PUBLIC, 1=USER, 2=ADMIN)", $ident, $raw)); |
| 80 | + } |
| 81 | + return $raw; |
| 82 | + } |
| 83 | + throw new InvalidArgumentException(sprintf("%s: 'access_level' is required and must be one of PUBLIC|USER|ADMIN (or 0|1|2), got %s", $ident, self::describe($raw))); |
| 84 | + } |
| 85 | + |
| 86 | + /** |
| 87 | + * Accept array<int>, a JSON-encoded array of ints (from XML body), null, or empty string. |
| 88 | + * Reject anything else. |
| 89 | + */ |
| 90 | + private static function normalizeIntList(mixed $raw, string $ident, string $field): array { |
| 91 | + $list = self::decodeListOrNull($raw, $ident, $field); |
| 92 | + if ($list === null) { |
| 93 | + return []; |
| 94 | + } |
| 95 | + $out = []; |
| 96 | + foreach ($list as $index => $value) { |
| 97 | + if (!is_int($value)) { |
| 98 | + throw new InvalidArgumentException(sprintf("%s: '%s' must contain only integers (e.g. HTTP status codes), entry at index %d is %s", $ident, $field, $index, self::describe($value))); |
| 99 | + } |
| 100 | + $out[] = $value; |
| 101 | + } |
| 102 | + return $out; |
| 103 | + } |
| 104 | + |
| 105 | + /** |
| 106 | + * Accept array<string>, a JSON-encoded array of strings (from XML body), null, or empty string. |
| 107 | + * Reject anything else. |
| 108 | + */ |
| 109 | + private static function normalizeStringList(mixed $raw, string $ident, string $field): array { |
| 110 | + $list = self::decodeListOrNull($raw, $ident, $field); |
| 111 | + if ($list === null) { |
| 112 | + return []; |
| 113 | + } |
| 114 | + $out = []; |
| 115 | + foreach ($list as $index => $value) { |
| 116 | + if (!is_string($value)) { |
| 117 | + throw new InvalidArgumentException(sprintf("%s: '%s' must contain only strings (header names), entry at index %d is %s", $ident, $field, $index, self::describe($value))); |
| 118 | + } |
| 119 | + $out[] = $value; |
| 120 | + } |
| 121 | + return $out; |
| 122 | + } |
| 123 | + |
| 124 | + /** |
| 125 | + * Resolve the raw list field to either a PHP list (caller validates element types) |
| 126 | + * or null (= field is unset / explicitly empty). Throw for anything else, including |
| 127 | + * associative arrays / JSON objects — those usually indicate the developer authored XML |
| 128 | + * sub-elements (`<bruteforce_protection><status>401</status></...>`) instead of the |
| 129 | + * documented JSON-string body (`<bruteforce_protection>[401]</...>`), and dropping the |
| 130 | + * keys silently would hide that mistake. |
| 131 | + */ |
| 132 | + private static function decodeListOrNull(mixed $raw, string $ident, string $field): ?array { |
| 133 | + if ($raw === null || $raw === '' || $raw === []) { |
| 134 | + return null; |
| 135 | + } |
| 136 | + if (is_array($raw)) { |
| 137 | + if (!array_is_list($raw)) { |
| 138 | + throw new InvalidArgumentException(sprintf("%s: '%s' must be a JSON array (list), got an associative object with keys %s — use a JSON-encoded array body in info.xml (e.g. '[401,429]')", $ident, $field, json_encode(array_keys($raw)))); |
| 139 | + } |
| 140 | + return $raw; |
| 141 | + } |
| 142 | + if (is_string($raw)) { |
| 143 | + $decoded = json_decode($raw, true); |
| 144 | + if (!is_array($decoded) || !array_is_list($decoded)) { |
| 145 | + throw new InvalidArgumentException(sprintf("%s: '%s' must be a JSON array, got string '%s'", $ident, $field, $raw)); |
| 146 | + } |
| 147 | + return $decoded; |
| 148 | + } |
| 149 | + throw new InvalidArgumentException(sprintf("%s: '%s' must be an array (or a JSON-encoded array string), got %s", $ident, $field, self::describe($raw))); |
| 150 | + } |
| 151 | + |
| 152 | + private static function describe(mixed $value): string { |
| 153 | + if (is_string($value)) { |
| 154 | + return sprintf("'%s' (string)", $value); |
| 155 | + } |
| 156 | + return get_debug_type($value); |
| 157 | + } |
| 158 | +} |
0 commit comments