├── src ├── PunycodeException.php ├── Punycode.php ├── UriTemplate.php └── Uri.php ├── composer.json ├── README.md ├── autoload.php └── LICENSE /src/PunycodeException.php: -------------------------------------------------------------------------------- 1 | = $n && $input[$i] < $m) { 84 | $m = $input[$i]; 85 | } 86 | } 87 | 88 | if (($m - $n) > intdiv(self::MAX_INT - $delta, $handled + 1)) { 89 | throw new PunycodeException("Punycode overflow"); 90 | } 91 | 92 | $delta += ($m - $n) * ($handled + 1); 93 | 94 | $n = $m; 95 | 96 | for ($i = 0; $i < $input_len; $i++) { 97 | if ($input[$i] < $n && (++$delta === 0)) { 98 | throw new PunycodeException("Punycode overflow"); 99 | } 100 | 101 | if ($input[$i] === $n) { 102 | $q = $delta; 103 | for ($k = self::BASE; ; $k += self::BASE) { 104 | $t = self::threshold($k, $bias); 105 | if ($q < $t) { 106 | break; 107 | } 108 | 109 | $base_minus_t = self::BASE - $t; 110 | 111 | $q -= $t; 112 | 113 | $output[] = self::encodeDigit($t + ($q % $base_minus_t)); 114 | 115 | $q = intdiv($q, $base_minus_t); 116 | } 117 | 118 | $output[] = self::encodeDigit($q); 119 | 120 | $bias = self::adapt($delta, $handled + 1, $handled === $basic_length); 121 | $delta = 0; 122 | $handled++; 123 | } 124 | } 125 | 126 | $delta++; $n++; 127 | } 128 | 129 | return self::PREFIX . UnicodeString::getStringFromCodePoints($output); 130 | } 131 | 132 | public static function decodePart(string $input): string 133 | { 134 | if (stripos($input, self::PREFIX) !== 0) { 135 | return $input; 136 | } 137 | 138 | $input = UnicodeString::getCodePointsFromString(substr($input, self::PREFIX_LEN), UnicodeString::LOWER_CASE); 139 | $input_len = count($input); 140 | 141 | $pos = array_keys($input, self::DELIMITER, true); 142 | if ($pos) { 143 | $pos = end($pos); 144 | } else { 145 | $pos = -1; 146 | } 147 | 148 | /** @var int $pos */ 149 | 150 | if ($pos === -1) { 151 | $output = []; 152 | $pos = $output_len = 0; 153 | } else { 154 | $output = array_slice($input, 0, ++$pos); 155 | $output_len = $pos; 156 | for ($i = 0; $i < $pos; $i++) { 157 | if ($output[$i] >= 0x80) { 158 | throw new PunycodeException("Non-basic code point is not allowed: {$output[$i]}"); 159 | } 160 | } 161 | } 162 | 163 | $i = 0; 164 | $n = self::INITIAL_N; 165 | $bias = self::INITIAL_BIAS; 166 | 167 | while ($pos < $input_len) { 168 | $old_i = $i; 169 | 170 | for ($w = 1, $k = self::BASE; ; $k += self::BASE) { 171 | if ($pos >= $input_len) { 172 | throw new PunycodeException("Punycode bad input"); 173 | } 174 | 175 | $digit = self::decodeDigit($input[$pos++]); 176 | 177 | if ($digit >= self::BASE || $digit > intdiv(self::MAX_INT - $i, $w)) { 178 | throw new PunycodeException("Punycode overflow"); 179 | } 180 | 181 | $i += $digit * $w; 182 | 183 | $t = self::threshold($k, $bias); 184 | if ($digit < $t) { 185 | break; 186 | } 187 | 188 | $t = self::BASE - $t; 189 | 190 | if ($w > intdiv(self::MAX_INT, $t)) { 191 | throw new PunycodeException("Punycode overflow"); 192 | } 193 | 194 | $w *= $t; 195 | } 196 | 197 | $output_len++; 198 | 199 | if (intdiv($i, $output_len) > self::MAX_INT - $n) { 200 | throw new PunycodeException("Punycode overflow"); 201 | } 202 | 203 | $n += intdiv($i, $output_len); 204 | 205 | $bias = self::adapt($i - $old_i, $output_len, $old_i === 0); 206 | 207 | $i %= $output_len; 208 | 209 | array_splice($output, $i, 0, $n); 210 | 211 | $i++; 212 | } 213 | 214 | return UnicodeString::getStringFromCodePoints($output); 215 | } 216 | 217 | public static function normalizePart(string $input): string 218 | { 219 | $input = strtolower($input); 220 | 221 | if (strpos($input, self::DELIMITER) === 0) { 222 | self::decodePart($input); // just validate 223 | return $input; 224 | } 225 | 226 | return self::encodePart($input); 227 | } 228 | 229 | private static function encodeDigit(int $digit): int 230 | { 231 | return $digit + 0x16 + ($digit < 0x1A ? 0x4B: 0x00); 232 | } 233 | 234 | private static function decodeDigit(int $code): int 235 | { 236 | if ($code < 0x3A) { 237 | return $code - 0x16; 238 | } 239 | if ($code < 0x5B) { 240 | return $code - 0x41; 241 | } 242 | if ($code < 0x7B) { 243 | return $code - 0x61; 244 | } 245 | 246 | return self::BASE; 247 | } 248 | 249 | private static function threshold(int $k, int $bias): int 250 | { 251 | $d = $k - $bias; 252 | 253 | if ($d <= self::TMIN) { 254 | return self::TMIN; 255 | } 256 | 257 | if ($d >= self::TMAX) { 258 | return self::TMAX; 259 | } 260 | 261 | return $d; 262 | } 263 | 264 | private static function adapt(int $delta, int $num_points, bool $first_time = false): int 265 | { 266 | $delta = intdiv($delta, $first_time ? self::DAMP : 2); 267 | $delta += intdiv($delta, $num_points); 268 | 269 | $k = 0; 270 | $base_tmin_diff = self::BASE - self::TMIN; 271 | $lim = $base_tmin_diff * self::TMAX / 2; 272 | 273 | while ($delta > $lim) { 274 | $delta = intdiv($delta, $base_tmin_diff); 275 | $k += self::BASE; 276 | } 277 | 278 | $k += intdiv(($base_tmin_diff + 1) * $delta, $delta + self::SKEW); 279 | 280 | return $k; 281 | } 282 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | -------------------------------------------------------------------------------- /src/UriTemplate.php: -------------------------------------------------------------------------------- 1 | [a-zA-Z0-9\_\%\.]+)(?:(?\*)?|\:(?\d+))?$~'; 26 | 27 | /** @var string */ 28 | protected const TEMPLATE_REGEX = <<<'REGEX' 29 | ~\{ 30 | (?[+#./;&=,!@|\?])? 31 | (? 32 | (?:(?P>varspec),)* 33 | (?(?: 34 | [a-zA-Z0-9\_\%\.]+ 35 | (?:\*|\:\d+)? 36 | )) 37 | ) 38 | \}~x 39 | REGEX; 40 | 41 | /** @var array */ 42 | protected const TEMPLATE_TABLE = [ 43 | '' => [ 44 | 'first' => '', 45 | 'sep' => ',', 46 | 'named' => false, 47 | 'ifemp' => '', 48 | 'allow' => false, 49 | ], 50 | '+' => [ 51 | 'first' => '', 52 | 'sep' => ',', 53 | 'named' => false, 54 | 'ifemp' => '', 55 | 'allow' => true, 56 | ], 57 | '.' => [ 58 | 'first' => '.', 59 | 'sep' => '.', 60 | 'named' => false, 61 | 'ifemp' => '', 62 | 'allow' => false, 63 | ], 64 | '/' => [ 65 | 'first' => '/', 66 | 'sep' => '/', 67 | 'named' => false, 68 | 'ifemp' => '', 69 | 'allow' => false, 70 | ], 71 | ';' => [ 72 | 'first' => ';', 73 | 'sep' => ';', 74 | 'named' => true, 75 | 'ifemp' => '', 76 | 'allow' => false, 77 | ], 78 | '?' => [ 79 | 'first' => '?', 80 | 'sep' => '&', 81 | 'named' => true, 82 | 'ifemp' => '=', 83 | 'allow' => false, 84 | ], 85 | '&' => [ 86 | 'first' => '&', 87 | 'sep' => '&', 88 | 'named' => true, 89 | 'ifemp' => '=', 90 | 'allow' => false, 91 | ], 92 | '#' => [ 93 | 'first' => '#', 94 | 'sep' => ',', 95 | 'named' => false, 96 | 'ifemp' => '', 97 | 'allow' => true, 98 | ], 99 | ]; 100 | 101 | protected string $uri; 102 | 103 | /** @var bool|null|array */ 104 | protected $parsed = false; 105 | 106 | /** 107 | * UriTemplate constructor. 108 | * @param string $uri_template 109 | */ 110 | public function __construct(string $uri_template) 111 | { 112 | $this->uri = $uri_template; 113 | } 114 | 115 | /** 116 | * @param array $vars 117 | * @return string 118 | */ 119 | public function resolve(array $vars): string 120 | { 121 | if ($this->parsed === false) { 122 | $this->parsed = $this->parse($this->uri); 123 | } 124 | if ($this->parsed === null || !$vars) { 125 | return $this->uri; 126 | } 127 | 128 | $data = ''; 129 | $vars = $this->prepareVars($vars); 130 | 131 | foreach ($this->parsed as $item) { 132 | if (!is_array($item)) { 133 | $data .= $item; 134 | continue; 135 | } 136 | 137 | $data .= $this->parseTemplateExpression( 138 | self::TEMPLATE_TABLE[$item['operator']], 139 | $this->resolveVars($item['vars'], $vars) 140 | ); 141 | } 142 | 143 | return $data; 144 | } 145 | 146 | /** 147 | * @return bool 148 | */ 149 | public function hasPlaceholders(): bool 150 | { 151 | if ($this->parsed === false) { 152 | $this->parse($this->uri); 153 | } 154 | 155 | return $this->parsed !== null; 156 | } 157 | 158 | /** 159 | * @param string $uri 160 | * @return array|null 161 | */ 162 | protected function parse(string $uri): ?array 163 | { 164 | $placeholders = null; 165 | preg_match_all(self::TEMPLATE_REGEX, $uri, $placeholders, PREG_SET_ORDER | PREG_OFFSET_CAPTURE); 166 | 167 | if (!$placeholders) { 168 | return null; 169 | } 170 | 171 | $dataIndex = -1; 172 | $data = []; 173 | 174 | $hasVars = false; 175 | $nextOffset = 0; 176 | foreach ($placeholders as &$p) { 177 | $offset = $p[0][1]; 178 | if ($nextOffset < $offset) { 179 | $data[] = substr($uri, $nextOffset, $offset - $nextOffset); 180 | $dataIndex++; 181 | } 182 | $matched = $p[0][0]; 183 | $nextOffset = $offset + strlen($matched); 184 | 185 | $operator = $p['operator'][0] ?? null; 186 | if ($operator === null || !isset(self::TEMPLATE_TABLE[$operator])) { 187 | if ($dataIndex >= 0 && is_string($data[$dataIndex])) { 188 | $data[$dataIndex] .= $matched; 189 | } else { 190 | $data[] = $matched; 191 | $dataIndex++; 192 | } 193 | continue; 194 | } 195 | 196 | $varList = $p['varlist'][0] ?? ''; 197 | $varList = $varList === '' ? [] : explode(',', $varList); 198 | $p = null; 199 | 200 | $varData = []; 201 | 202 | foreach ($varList as $var) { 203 | if (!preg_match(self::TEMPLATE_VARSPEC_REGEX, $var, $spec)) { 204 | continue; 205 | } 206 | 207 | $varData[] = [ 208 | 'name' => $spec['varname'], 209 | 'explode' => isset($spec['explode']) && $spec['explode'] === '*', 210 | 'prefix' => isset($spec['prefix']) ? (int)$spec['prefix'] : 0, 211 | ]; 212 | 213 | unset($var, $spec); 214 | } 215 | 216 | if ($varData) { 217 | $hasVars = true; 218 | $data[] = [ 219 | 'operator' => $operator, 220 | 'vars' => $varData, 221 | ]; 222 | $dataIndex++; 223 | } else { 224 | if ($dataIndex >= 0 && is_string($data[$dataIndex])) { 225 | $data[$dataIndex] .= $matched; 226 | } else { 227 | $data[] = $matched; 228 | $dataIndex++; 229 | } 230 | } 231 | 232 | unset($varData, $varList, $operator); 233 | } 234 | 235 | if (!$hasVars) { 236 | return null; 237 | } 238 | 239 | $matched = substr($uri, $nextOffset); 240 | if ($matched !== false && $matched !== '') { 241 | if ($dataIndex >= 0 && is_string($data[$dataIndex])) { 242 | $data[$dataIndex] .= $matched; 243 | } else { 244 | $data[] = $matched; 245 | } 246 | } 247 | 248 | return $data; 249 | } 250 | 251 | /** 252 | * Convert assoc arrays to objects 253 | * @param array $vars 254 | * @return array 255 | */ 256 | protected function prepareVars(array $vars): array 257 | { 258 | foreach ($vars as &$value) { 259 | if (is_scalar($value)) { 260 | if (!is_string($value)) { 261 | $value = (string)$value; 262 | } 263 | continue; 264 | } 265 | 266 | if (!is_array($value)) { 267 | continue; 268 | } 269 | 270 | $len = count($value); 271 | for ($i = 0; $i < $len; $i++) { 272 | if (!array_key_exists($i, $value)) { 273 | $value = (object)$value; 274 | break; 275 | } 276 | } 277 | } 278 | 279 | return $vars; 280 | } 281 | 282 | /** 283 | * @param array $vars 284 | * @param array $data 285 | * @return array 286 | */ 287 | protected function resolveVars(array $vars, array $data): array 288 | { 289 | $resolved = []; 290 | 291 | foreach ($vars as $info) { 292 | $name = $info['name']; 293 | 294 | if (!isset($data[$name])) { 295 | continue; 296 | } 297 | 298 | $resolved[] = $info + ['value' => &$data[$name]]; 299 | } 300 | 301 | return $resolved; 302 | } 303 | 304 | /** 305 | * @param array $table 306 | * @param array $data 307 | * @return string 308 | */ 309 | protected function parseTemplateExpression(array $table, array $data): string 310 | { 311 | $result = []; 312 | foreach ($data as $var) { 313 | $str = ""; 314 | if (is_string($var['value'])) { 315 | if ($table['named']) { 316 | $str .= $var['name']; 317 | if ($var['value'] === '') { 318 | $str .= $table['ifemp']; 319 | } else { 320 | $str .= '='; 321 | } 322 | } 323 | if ($var['prefix']) { 324 | $str .= $this->encodeTemplateString(self::prefix($var['value'], $var['prefix']), $table['allow']); 325 | } else { 326 | $str .= $this->encodeTemplateString($var['value'], $table['allow']); 327 | } 328 | } elseif ($var['explode']) { 329 | $list = []; 330 | if ($table['named']) { 331 | if (is_array($var['value'])) { 332 | foreach ($var['value'] as $v) { 333 | if (is_null($v) || !is_scalar($v)) { 334 | continue; 335 | } 336 | $v = $this->encodeTemplateString((string)$v, $table['allow']); 337 | if ($v === '') { 338 | $list[] = $var['name'] . $table['ifemp']; 339 | } else { 340 | $list[] = $var['name'] . '=' . $v; 341 | } 342 | } 343 | } elseif (is_object($var['value'])) { 344 | foreach ($var['value'] as $prop => $v) { 345 | if (is_null($v) || !is_scalar($v)) { 346 | continue; 347 | } 348 | $v = $this->encodeTemplateString((string)$v, $table['allow']); 349 | $prop = $this->encodeTemplateString((string)$prop, $table['allow']); 350 | if ($v === '') { 351 | $list[] = $prop . $table['ifemp']; 352 | } else { 353 | $list[] = $prop . '=' . $v; 354 | } 355 | } 356 | } 357 | } else { 358 | if (is_array($var['value'])) { 359 | foreach ($var['value'] as $v) { 360 | if (is_null($v) || !is_scalar($v)) { 361 | continue; 362 | } 363 | $list[] = $this->encodeTemplateString($v, $table['allow']); 364 | } 365 | } elseif (is_object($var['value'])) { 366 | foreach ($var['value'] as $prop => $v) { 367 | if (is_null($v) || !is_scalar($v)) { 368 | continue; 369 | } 370 | $v = $this->encodeTemplateString((string)$v, $table['allow']); 371 | $prop = $this->encodeTemplateString((string)$prop, $table['allow']); 372 | $list[] = $prop . '=' . $v; 373 | } 374 | } 375 | } 376 | 377 | if ($list) { 378 | $str .= implode($table['sep'], $list); 379 | } 380 | unset($list); 381 | } else { 382 | if ($table['named']) { 383 | $str .= $var['name']; 384 | if ($var['value'] === '') { 385 | $str .= $table['ifemp']; 386 | } else { 387 | $str .= '='; 388 | } 389 | } 390 | $list = []; 391 | if (is_array($var['value'])) { 392 | foreach ($var['value'] as $v) { 393 | $list[] = $this->encodeTemplateString($v, $table['allow']); 394 | } 395 | } elseif (is_object($var['value'])) { 396 | foreach ($var['value'] as $prop => $v) { 397 | $list[] = $this->encodeTemplateString((string)$prop, $table['allow']); 398 | $list[] = $this->encodeTemplateString((string)$v, $table['allow']); 399 | } 400 | } 401 | if ($list) { 402 | $str .= implode(',', $list); 403 | } 404 | unset($list); 405 | } 406 | 407 | if ($str !== '') { 408 | $result[] = $str; 409 | } 410 | } 411 | 412 | if (!$result) { 413 | return ''; 414 | } 415 | 416 | $result = implode($table['sep'], $result); 417 | 418 | if ($result !== '') { 419 | $result = $table['first'] . $result; 420 | } 421 | 422 | return $result; 423 | } 424 | 425 | /** 426 | * @param string $data 427 | * @param bool $reserved 428 | * @return string 429 | */ 430 | protected function encodeTemplateString(string $data, bool $reserved): string 431 | { 432 | $skip = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~'; 433 | 434 | if ($reserved) { 435 | $skip .= ':/?#[]@!$&\'()*+,;='; 436 | } 437 | 438 | $result = ''; 439 | $temp = ''; 440 | for ($i = 0, $len = strlen($data); $i < $len; $i++) { 441 | if (strpos($skip, $data[$i]) !== false) { 442 | if ($temp !== '') { 443 | $result .= Uri::encodeComponent($temp); 444 | $temp = ''; 445 | } 446 | $result .= $data[$i]; 447 | continue; 448 | } 449 | if ($reserved && $data[$i] === '%') { 450 | if (isset($data[$i + 1]) && isset($data[$i + 2]) 451 | && strpos('ABCDEF0123456789', $data[$i + 1]) !== false 452 | && strpos('ABCDEF0123456789', $data[$i + 2]) !== false) { 453 | if ($temp !== '') { 454 | $result .= Uri::encodeComponent($temp); 455 | } 456 | $result .= '%' . $data[$i + 1] . $data[$i + 2]; 457 | $i += 3; 458 | continue; 459 | } 460 | } 461 | $temp .= $data[$i]; 462 | } 463 | 464 | if ($temp !== '') { 465 | $result .= Uri::encodeComponent($temp); 466 | } 467 | 468 | return $result; 469 | } 470 | 471 | /** 472 | * @return string 473 | */ 474 | public function value(): string 475 | { 476 | return $this->uri; 477 | } 478 | 479 | public function __toString(): string 480 | { 481 | return $this->uri; 482 | } 483 | 484 | /** 485 | * @param string $uri 486 | * @return bool 487 | */ 488 | public static function isTemplate(string $uri): bool 489 | { 490 | $open = substr_count($uri, '{'); 491 | if ($open === 0) { 492 | return false; 493 | } 494 | $close = substr_count($uri, '}'); 495 | if ($open !== $close) { 496 | return false; 497 | } 498 | 499 | return (bool)preg_match(self::TEMPLATE_REGEX, $uri); 500 | } 501 | 502 | /** 503 | * @param string $str 504 | * @param int $len 505 | * @return string 506 | */ 507 | protected static function prefix(string $str, int $len): string 508 | { 509 | if ($len === 0) { 510 | return ''; 511 | } 512 | 513 | if ($len >= strlen($str)) { 514 | // Prefix is longer than string length 515 | return $str; 516 | } 517 | 518 | return (string)UnicodeString::from($str)->substring(0, $len); 519 | } 520 | } -------------------------------------------------------------------------------- /src/Uri.php: -------------------------------------------------------------------------------- 1 | [^:]+)(?::(?.*))?$`'; 31 | 32 | protected const HOST_LABEL_REGEX = '`^(?:(?:%[a-f0-9]{2})+|[a-z0-9-]+)*$`i'; 33 | 34 | protected const AUTHORITY_REGEX = '`^(?:(?[^@]+)\@)?(?(\[[a-f0-9:]+\]|[^:]+))(?::(?\d+))?$`i'; 35 | 36 | protected const PATH_REGEX = '`^(?:(?:%[a-f0-9]{2})+|[a-z0-9-._~!$&\'()*+,;=:@/]+)*$`i'; 37 | 38 | protected const QUERY_OR_FRAGMENT_REGEX = '`^(?:(?:%[a-f0-9]{2})+|[a-z0-9-._~!$&\'"()\[\]*+,;=:@?/%]+)*$`i'; 39 | 40 | protected array $components; 41 | 42 | protected ?string $str = null; 43 | 44 | /** 45 | * @param array $components An array of normalized components 46 | */ 47 | public function __construct(array $components) 48 | { 49 | $this->components = $components + [ 50 | 'scheme' => null, 51 | 'user' => null, 52 | 'pass' => null, 53 | 'host' => null, 54 | 'port' => null, 55 | 'path' => null, 56 | 'query' => null, 57 | 'fragment' => null, 58 | ]; 59 | } 60 | 61 | /** 62 | * @return string|null 63 | */ 64 | public function scheme(): ?string 65 | { 66 | return $this->components['scheme']; 67 | } 68 | 69 | /** 70 | * @return string|null 71 | */ 72 | public function user(): ?string 73 | { 74 | return $this->components['user']; 75 | } 76 | 77 | /** 78 | * @return string|null 79 | */ 80 | public function pass(): ?string 81 | { 82 | return $this->components['pass']; 83 | } 84 | 85 | /** 86 | * @return string|null 87 | */ 88 | public function userInfo(): ?string 89 | { 90 | if ($this->components['user'] === null) { 91 | return null; 92 | } 93 | 94 | if ($this->components['pass'] === null) { 95 | return $this->components['user']; 96 | } 97 | 98 | return $this->components['user'] . ':' . $this->components['pass']; 99 | } 100 | 101 | /** 102 | * @return string|null 103 | */ 104 | public function host(): ?string 105 | { 106 | return $this->components['host']; 107 | } 108 | 109 | /** 110 | * @return int|null 111 | */ 112 | public function port(): ?int 113 | { 114 | return $this->components['port']; 115 | } 116 | 117 | /** 118 | * @return string|null 119 | */ 120 | public function authority(): ?string 121 | { 122 | if ($this->components['host'] === null) { 123 | return null; 124 | } 125 | 126 | $authority = $this->userInfo(); 127 | if ($authority !== null) { 128 | $authority .= '@'; 129 | } 130 | 131 | $authority .= $this->components['host']; 132 | 133 | if ($this->components['port'] !== null) { 134 | $authority .= ':' . $this->components['port']; 135 | } 136 | 137 | return $authority; 138 | } 139 | 140 | /** 141 | * @return string|null 142 | */ 143 | public function path(): ?string 144 | { 145 | return $this->components['path']; 146 | } 147 | 148 | /** 149 | * @return string|null 150 | */ 151 | public function query(): ?string 152 | { 153 | return $this->components['query']; 154 | } 155 | 156 | /** 157 | * @return string|null 158 | */ 159 | public function fragment(): ?string 160 | { 161 | return $this->components['fragment']; 162 | } 163 | 164 | /** 165 | * @return array|null[] 166 | */ 167 | public function components(): array 168 | { 169 | return $this->components; 170 | } 171 | 172 | /** 173 | * @return bool 174 | */ 175 | public function isAbsolute(): bool 176 | { 177 | return $this->components['scheme'] !== null; 178 | } 179 | 180 | /** 181 | * Use this URI as base to resolve the reference 182 | * @param static|string|array $ref 183 | * @param bool $normalize 184 | * @return $this|null 185 | */ 186 | public function resolveRef($ref, bool $normalize = false): ?self 187 | { 188 | $ref = self::resolveComponents($ref); 189 | if ($ref === null) { 190 | return $this; 191 | } 192 | 193 | return new static(self::mergeComponents($ref, $this->components, $normalize)); 194 | } 195 | 196 | /** 197 | * Resolve this URI reference using a base URI 198 | * @param static|string|array $base 199 | * @param bool $normalize 200 | * @return static 201 | */ 202 | public function resolve($base, bool $normalize = false): self 203 | { 204 | if ($this->isAbsolute()) { 205 | return $this; 206 | } 207 | 208 | $base = self::resolveComponents($base); 209 | 210 | if ($base === null) { 211 | return $this; 212 | } 213 | 214 | return new static(self::mergeComponents($this->components, $base, $normalize)); 215 | } 216 | 217 | /** 218 | * @return string 219 | */ 220 | public function __toString(): string 221 | { 222 | if ($this->str !== null) { 223 | return $this->str; 224 | } 225 | 226 | $str = ''; 227 | 228 | if ($this->components['scheme'] !== null) { 229 | $str .= $this->components['scheme'] . ':'; 230 | } 231 | 232 | if ($this->components['host'] !== null) { 233 | $str .= '//' . $this->authority(); 234 | } 235 | 236 | $str .= $this->components['path']; 237 | 238 | if ($this->components['query'] !== null) { 239 | $str .= '?' . $this->components['query']; 240 | } 241 | 242 | if ($this->components['fragment'] !== null) { 243 | $str .= '#' . $this->components['fragment']; 244 | } 245 | 246 | return $this->str = $str; 247 | } 248 | 249 | /** 250 | * @param string $uri 251 | * @param bool $normalize 252 | * @return static|null 253 | */ 254 | public static function create(string $uri, bool $normalize = false): ?self 255 | { 256 | $comp = self::parseComponents($uri); 257 | if (!$comp) { 258 | return null; 259 | } 260 | 261 | if ($normalize) { 262 | $comp = self::normalizeComponents($comp); 263 | } 264 | 265 | return new static($comp); 266 | } 267 | 268 | /** 269 | * Checks if the scheme contains valid chars 270 | * @param string $scheme 271 | * @return bool 272 | */ 273 | public static function isValidScheme(string $scheme): bool 274 | { 275 | return (bool)preg_match(self::SCHEME_REGEX, $scheme); 276 | } 277 | 278 | /** 279 | * Checks if user contains valid chars 280 | * @param string $user 281 | * @return bool 282 | */ 283 | public static function isValidUser(string $user): bool 284 | { 285 | return (bool)preg_match(self::USER_OR_PASS_REGEX, $user); 286 | } 287 | 288 | /** 289 | * Checks if pass contains valid chars 290 | * @param string $pass 291 | * @return bool 292 | */ 293 | public static function isValidPass(string $pass): bool 294 | { 295 | return (bool)preg_match(self::USER_OR_PASS_REGEX, $pass); 296 | } 297 | 298 | /** 299 | * @param string $userInfo 300 | * @return bool 301 | */ 302 | public static function isValidUserInfo(string $userInfo): bool 303 | { 304 | /** @var array|string $userInfo */ 305 | 306 | if (!preg_match(self::USERINFO_REGEX, $userInfo, $userInfo)) { 307 | return false; 308 | } 309 | 310 | if (!self::isValidUser($userInfo['user'])) { 311 | return false; 312 | } 313 | 314 | if (isset($userInfo['pass'])) { 315 | return self::isValidPass($userInfo['pass']); 316 | } 317 | 318 | return true; 319 | } 320 | 321 | /** 322 | * Checks if host is valid 323 | * @param string $host 324 | * @return bool 325 | */ 326 | public static function isValidHost(string $host): bool 327 | { 328 | // min and max length 329 | if ($host === '' || isset($host[253])) { 330 | return false; 331 | } 332 | 333 | // check ipv6 334 | if ($host[0] === '[') { 335 | if ($host[-1] !== ']') { 336 | return false; 337 | } 338 | 339 | return filter_var( 340 | substr($host, 1, -1), 341 | \FILTER_VALIDATE_IP, 342 | \FILTER_FLAG_IPV6 343 | ) !== false; 344 | } 345 | 346 | // check ipv4 347 | if (preg_match('`^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\$`', $host)) { 348 | return \filter_var($host, \FILTER_VALIDATE_IP, \FILTER_FLAG_IPV4) !== false; 349 | } 350 | 351 | foreach (explode('.', $host) as $host) { 352 | // empty or too long label 353 | if ($host === '' || isset($host[63])) { 354 | return false; 355 | } 356 | if ($host[0] === '-' || $host[-1] === '-') { 357 | return false; 358 | } 359 | if (!preg_match(self::HOST_LABEL_REGEX, $host)) { 360 | return false; 361 | } 362 | } 363 | 364 | return true; 365 | } 366 | 367 | /** 368 | * Checks if the port is valid 369 | * @param int $port 370 | * @return bool 371 | */ 372 | public static function isValidPort(int $port): bool 373 | { 374 | return $port >= 0 && $port <= 65535; 375 | } 376 | 377 | /** 378 | * Checks if authority contains valid chars 379 | * @param string $authority 380 | * @return bool 381 | */ 382 | public static function isValidAuthority(string $authority): bool 383 | { 384 | if ($authority === '') { 385 | return true; 386 | } 387 | 388 | /** @var array|string $authority */ 389 | 390 | if (!preg_match(self::AUTHORITY_REGEX, $authority, $authority)) { 391 | return false; 392 | } 393 | 394 | if (isset($authority['port']) && !self::isValidPort((int)$authority['port'])) { 395 | return false; 396 | } 397 | 398 | if (isset($authority['userinfo']) && !self::isValidUserInfo($authority['userinfo'])) { 399 | return false; 400 | } 401 | 402 | return self::isValidHost($authority['host']); 403 | } 404 | 405 | /** 406 | * Checks if the path contains valid chars 407 | * @param string $path 408 | * @return bool 409 | */ 410 | public static function isValidPath(string $path): bool 411 | { 412 | return $path === '' || (bool)preg_match(self::PATH_REGEX, $path); 413 | } 414 | 415 | /** 416 | * Checks if the query string contains valid chars 417 | * @param string $query 418 | * @return bool 419 | */ 420 | public static function isValidQuery(string $query): bool 421 | { 422 | return $query === '' || (bool)preg_match(self::QUERY_OR_FRAGMENT_REGEX, $query); 423 | } 424 | 425 | /** 426 | * Checks if the fragment contains valid chars 427 | * @param string $fragment 428 | * @return bool 429 | */ 430 | public static function isValidFragment(string $fragment): bool 431 | { 432 | return $fragment === '' || (bool)preg_match(self::QUERY_OR_FRAGMENT_REGEX, $fragment); 433 | } 434 | 435 | /** 436 | * @param string $uri 437 | * @param bool $expand_authority 438 | * @param bool $validate 439 | * @return array|null 440 | */ 441 | public static function parseComponents(string $uri, bool $expand_authority = true, bool $validate = true): ?array 442 | { 443 | if (!preg_match(self::URI_REGEX, $uri, $uri)) { 444 | return null; 445 | } 446 | 447 | $comp = []; 448 | 449 | // scheme 450 | if (isset($uri[2]) && $uri[2] !== '') { 451 | if ($validate && !self::isValidScheme($uri[2])) { 452 | return null; 453 | } 454 | $comp['scheme'] = $uri[2]; 455 | } 456 | 457 | // authority 458 | if (isset($uri[4]) && isset($uri[3][0])) { 459 | if ($uri[4] === '') { 460 | if ($expand_authority) { 461 | $comp['host'] = ''; 462 | } else { 463 | $comp['authority'] = ''; 464 | } 465 | } elseif ($expand_authority) { 466 | $au = self::parseAuthorityComponents($uri[4], $validate); 467 | if ($au === null) { 468 | return null; 469 | } 470 | $comp += $au; 471 | unset($au); 472 | } else { 473 | if ($validate && !self::isValidAuthority($uri[4])) { 474 | return null; 475 | } 476 | $comp['authority'] = $uri[4]; 477 | } 478 | } 479 | 480 | // path 481 | if (isset($uri[5])) { 482 | if ($validate && !self::isValidPath($uri[5])) { 483 | return null; 484 | } 485 | $comp['path'] = $uri[5]; 486 | // not a relative uri, remove dot segments 487 | if (isset($comp['scheme']) || isset($comp['authority']) || isset($comp['host'])) { 488 | $comp['path'] = self::removeDotSegmentsFromPath($comp['path']); 489 | } 490 | } 491 | 492 | // query 493 | if (isset($uri[7]) && isset($uri[6][0])) { 494 | if ($validate && !self::isValidQuery($uri[7])) { 495 | return null; 496 | } 497 | $comp['query'] = $uri[7]; 498 | } 499 | 500 | // fragment 501 | if (isset($uri[9]) && isset($uri[8][0])) { 502 | if ($validate && !self::isValidFragment($uri[9])) { 503 | return null; 504 | } 505 | $comp['fragment'] = $uri[9]; 506 | } 507 | 508 | return $comp; 509 | } 510 | 511 | /** 512 | * @param self|string|array $uri 513 | * @return array|null 514 | */ 515 | public static function resolveComponents($uri): ?array 516 | { 517 | if ($uri instanceof self) { 518 | return $uri->components; 519 | } 520 | 521 | if (is_string($uri)) { 522 | return self::parseComponents($uri); 523 | } 524 | 525 | if (is_array($uri)) { 526 | if (isset($uri['host'])) { 527 | unset($uri['authority']); 528 | } elseif (isset($uri['authority'])) { 529 | $au = self::parseAuthorityComponents($uri['authority']); 530 | unset($uri['authority']); 531 | if ($au !== null) { 532 | unset($uri['user'], $uri['pass'], $uri['host'], $uri['port']); 533 | $uri += $au; 534 | } 535 | } 536 | return $uri; 537 | } 538 | 539 | return null; 540 | } 541 | 542 | /** 543 | * @param string $authority 544 | * @param bool $validate 545 | * @return array|null 546 | */ 547 | public static function parseAuthorityComponents(string $authority, bool $validate = true): ?array 548 | { 549 | /** @var array|string $authority */ 550 | 551 | if (!preg_match(self::AUTHORITY_REGEX, $authority, $authority)) { 552 | return null; 553 | } 554 | 555 | $comp = []; 556 | 557 | // userinfo 558 | if (isset($authority['userinfo']) && $authority['userinfo'] !== '') { 559 | if (!preg_match(self::USERINFO_REGEX, $authority['userinfo'], $ui)) { 560 | return null; 561 | } 562 | 563 | // user 564 | if ($validate && !self::isValidUser($ui['user'])) { 565 | return null; 566 | } 567 | $comp['user'] = $ui['user']; 568 | 569 | // pass 570 | if (isset($ui['pass']) && $ui['pass'] !== '') { 571 | if ($validate && !self::isValidPass($ui['pass'])) { 572 | return null; 573 | } 574 | $comp['pass'] = $ui['pass']; 575 | } 576 | 577 | unset($ui); 578 | } 579 | 580 | // host 581 | if ($validate && !self::isValidHost($authority['host'])) { 582 | return null; 583 | } 584 | $comp['host'] = $authority['host']; 585 | 586 | 587 | // port 588 | if (isset($authority['port'])) { 589 | $authority['port'] = (int)$authority['port']; 590 | if (!self::isValidPort($authority['port'])) { 591 | return null; 592 | } 593 | $comp['port'] = $authority['port']; 594 | } 595 | 596 | return $comp; 597 | } 598 | 599 | /** 600 | * @param array $ref 601 | * @param array $base 602 | * @param bool $normalize 603 | * @return array 604 | */ 605 | public static function mergeComponents(array $ref, array $base, bool $normalize = false): array 606 | { 607 | if (isset($ref['scheme'])) { 608 | $dest = $ref; 609 | } else { 610 | $dest = []; 611 | 612 | $dest['scheme'] = $base['scheme'] ?? null; 613 | 614 | if (isset($ref['authority']) || isset($ref['host'])) { 615 | $dest += $ref; 616 | } else { 617 | if (isset($base['authority'])) { 618 | $dest['authority'] = $base['authority']; 619 | } else { 620 | $dest['user'] = $base['user'] ?? null; 621 | $dest['pass'] = $base['pass'] ?? null; 622 | $dest['host'] = $base['host'] ?? null; 623 | $dest['port'] = $base['port'] ?? null; 624 | } 625 | 626 | if (!isset($ref['path'])) { 627 | $ref['path'] = ''; 628 | } 629 | if (!isset($base['path'])) { 630 | $base['path'] = ''; 631 | } 632 | 633 | if ($ref['path'] === '') { 634 | $dest['path'] = $base['path']; 635 | $dest['query'] = $ref['query'] ?? $base['query'] ?? null; 636 | } else { 637 | if ($ref['path'][0] === '/') { 638 | $dest['path'] = $ref['path']; 639 | } else { 640 | if ((isset($base['authority']) || isset($base['host'])) && $base['path'] === '') { 641 | $dest['path'] = '/' . $ref['path']; 642 | } else { 643 | $dest['path'] = $base['path']; 644 | 645 | if ($dest['path'] !== '') { 646 | $pos = strrpos($dest['path'], '/'); 647 | if ($pos === false) { 648 | $dest['path'] = ''; 649 | } else { 650 | $dest['path'] = substr($dest['path'], 0, $pos); 651 | } 652 | 653 | unset($pos); 654 | } 655 | $dest['path'] .= '/' . $ref['path']; 656 | } 657 | } 658 | 659 | $dest['query'] = $ref['query'] ?? null; 660 | } 661 | } 662 | } 663 | 664 | $dest['fragment'] = $ref['fragment'] ?? null; 665 | 666 | if ($normalize) { 667 | return self::normalizeComponents($dest); 668 | } 669 | 670 | if (isset($dest['path'])) { 671 | $dest['path'] = self::removeDotSegmentsFromPath($dest['path']); 672 | } 673 | 674 | return $dest; 675 | } 676 | 677 | public static function normalizeComponents(array $components): array 678 | { 679 | if (isset($components['scheme'])) { 680 | $components['scheme'] = strtolower($components['scheme']); 681 | // Remove default port 682 | if (isset($components['port']) && self::getSchemePort($components['scheme']) === $components['port']) { 683 | $components['port'] = null; 684 | } 685 | } 686 | 687 | if (isset($components['host'])) { 688 | $components['host'] = strtolower($components['host']); 689 | } 690 | 691 | if (isset($components['path'])) { 692 | $components['path'] = self::removeDotSegmentsFromPath($components['path']); 693 | } 694 | 695 | if (isset($components['query'])) { 696 | $components['query'] = self::normalizeQueryString($components['query']); 697 | } 698 | 699 | return $components; 700 | } 701 | 702 | /** 703 | * Removes dot segments from path 704 | * @param string $path 705 | * @return string 706 | */ 707 | public static function removeDotSegmentsFromPath(string $path): string 708 | { 709 | // Fast check common simple paths 710 | if ($path === '' || $path === '/') { 711 | return $path; 712 | } 713 | 714 | $output = ''; 715 | $last_slash = 0; 716 | 717 | $len = strlen($path); 718 | $i = 0; 719 | 720 | while ($i < $len) { 721 | if ($path[$i] === '.') { 722 | $j = $i + 1; 723 | // search for . 724 | if ($j >= $len) { 725 | break; 726 | } 727 | 728 | // search for ./ 729 | if ($path[$j] === '/') { 730 | $i = $j + 1; 731 | continue; 732 | } 733 | 734 | // search for ../ 735 | if ($path[$j] === '.') { 736 | $k = $j + 1; 737 | if ($k >= $len) { 738 | break; 739 | } 740 | if ($path[$k] === '/') { 741 | $i = $k + 1; 742 | continue; 743 | } 744 | } 745 | } elseif ($path[$i] === '/') { 746 | $j = $i + 1; 747 | if ($j >= $len) { 748 | $output .= '/'; 749 | break; 750 | } 751 | 752 | // search for /. 753 | if ($path[$j] === '.') { 754 | $k = $j + 1; 755 | if ($k >= $len) { 756 | $output .= '/'; 757 | break; 758 | } 759 | // search for /./ 760 | if ($path[$k] === '/') { 761 | $i = $k; 762 | continue; 763 | } 764 | // search for /.. 765 | if ($path[$k] === '.') { 766 | $n = $k + 1; 767 | if ($n >= $len) { 768 | // keep the slash 769 | $output = substr($output, 0, $last_slash + 1); 770 | break; 771 | } 772 | // search for /../ 773 | if ($path[$n] === '/') { 774 | $output = substr($output, 0, $last_slash); 775 | $last_slash = (int)strrpos($output, '/'); 776 | $i = $n; 777 | continue; 778 | } 779 | } 780 | } 781 | } 782 | 783 | $pos = strpos($path, '/', $i + 1); 784 | 785 | if ($pos === false) { 786 | $output .= substr($path, $i); 787 | break; 788 | } 789 | 790 | $last_slash = strlen($output); 791 | $output .= substr($path, $i, $pos - $i); 792 | 793 | $i = $pos; 794 | } 795 | 796 | return $output; 797 | } 798 | 799 | /** 800 | * @param string|null $query 801 | * @return array 802 | */ 803 | public static function parseQueryString(?string $query): array 804 | { 805 | if ($query === null) { 806 | return []; 807 | } 808 | 809 | $list = []; 810 | 811 | foreach (explode('&', $query) as $name) { 812 | $value = null; 813 | if (($pos = strpos($name, '=')) !== false) { 814 | $value = self::decodeComponent(substr($name, $pos + 1)); 815 | $name = self::decodeComponent(substr($name, 0, $pos)); 816 | } else { 817 | $name = self::decodeComponent($name); 818 | } 819 | $list[$name] = $value; 820 | } 821 | 822 | return $list; 823 | } 824 | 825 | /** 826 | * @param array $qs 827 | * @param string|null $prefix 828 | * @param string $separator 829 | * @param bool $sort 830 | * @return string 831 | */ 832 | public static function buildQueryString(array $qs, ?string $prefix = null, 833 | string $separator = '&', bool $sort = false): string 834 | { 835 | $isIndexed = static function (array $array): bool { 836 | for ($i = 0, $max = count($array); $i < $max; $i++) { 837 | if (!array_key_exists($i, $array)) { 838 | return false; 839 | } 840 | } 841 | return true; 842 | }; 843 | 844 | $f = static function (array $arr, ?string $prefix = null) use (&$f, &$isIndexed): iterable { 845 | $indexed = $prefix !== null && $isIndexed($arr); 846 | 847 | foreach ($arr as $key => $value) { 848 | if ($prefix !== null) { 849 | $key = $prefix . ($indexed ? "[]" : "[{$key}]"); 850 | } 851 | if (is_array($value)) { 852 | yield from $f($value, $key); 853 | } else { 854 | yield $key => $value; 855 | } 856 | } 857 | }; 858 | 859 | $data = []; 860 | 861 | foreach ($f($qs, $prefix) as $key => $value) { 862 | $item = is_string($key) ? self::encodeComponent($key) : $key; 863 | if ($value !== null) { 864 | $item .= '='; 865 | $item .= is_string($value) ? self::encodeComponent($value) : $value; 866 | } 867 | if ($item === '' || $item === '=') { 868 | continue; 869 | } 870 | $data[] = $item; 871 | } 872 | 873 | if (!$data) { 874 | return ''; 875 | } 876 | 877 | if ($sort) { 878 | sort($data); 879 | } 880 | 881 | return implode($separator, $data); 882 | } 883 | 884 | /** 885 | * @param string $query 886 | * @return string 887 | */ 888 | public static function normalizeQueryString(string $query): string 889 | { 890 | return static::buildQueryString(self::parseQueryString($query), null, '&', true); 891 | } 892 | 893 | public static function decodeComponent(string $component): string 894 | { 895 | return rawurldecode($component); 896 | } 897 | 898 | public static function encodeComponent(string $component, ?array $skip = null): string 899 | { 900 | if (!$skip) { 901 | return rawurlencode($component); 902 | } 903 | 904 | $str = ''; 905 | 906 | foreach (UnicodeString::walkString($component) as [$cp, $chars]) { 907 | if ($cp < 0x80) { 908 | if ($cp === 0x2D || $cp === 0x2E || 909 | $cp === 0x5F || $cp === 0x7E || 910 | ($cp >= 0x41 && $cp <= 0x5A) || 911 | ($cp >= 0x61 && $cp <= 0x7A) || 912 | ($cp >= 0x30 && $cp <= 0x39) || 913 | in_array($cp, $skip, true) 914 | ) { 915 | $str .= chr($cp); 916 | } else { 917 | $str .= '%' . strtoupper(dechex($cp)); 918 | } 919 | } else { 920 | $i = 0; 921 | while (isset($chars[$i])) { 922 | $str .= '%' . strtoupper(dechex($chars[$i++])); 923 | } 924 | } 925 | } 926 | 927 | return $str; 928 | } 929 | 930 | public static function setSchemePort(string $scheme, ?int $port): void 931 | { 932 | $scheme = strtolower($scheme); 933 | 934 | if ($port === null) { 935 | unset(self::$KNOWN_PORTS[$scheme]); 936 | } else { 937 | self::$KNOWN_PORTS[$scheme] = $port; 938 | } 939 | } 940 | 941 | public static function getSchemePort(string $scheme): ?int 942 | { 943 | return self::$KNOWN_PORTS[strtolower($scheme)] ?? null; 944 | } 945 | 946 | protected static array $KNOWN_PORTS = [ 947 | 'ftp' => 21, 948 | 'ssh' => 22, 949 | 'telnet' => 23, 950 | 'smtp' => 25, 951 | 'tftp' => 69, 952 | 'http' => 80, 953 | 'pop' => 110, 954 | 'sftp' => 115, 955 | 'imap' => 143, 956 | 'irc' => 194, 957 | 'ldap' => 389, 958 | 'https' => 443, 959 | 'ldaps' => 636, 960 | 'telnets' => 992, 961 | 'imaps' => 993, 962 | 'ircs' => 994, 963 | 'pops' => 995, 964 | ]; 965 | } --------------------------------------------------------------------------------