• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

JBZoo / Utils / 29770052588

20 Jul 2026 06:55PM UTC coverage: 92.619% (-0.1%) from 92.722%
29770052588

push

github

web-flow
Merge pull request #57 from JBZoo/release/8.0

8.0.0 — PHP 8.3+ floor & lock-step major

15 of 16 new or added lines in 7 files covered. (93.75%)

106 existing lines in 13 files now uncovered.

1669 of 1802 relevant lines covered (92.62%)

41.2 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

98.21
/src/Url.php
1
<?php
2

3
/**
4
 * JBZoo Toolbox - Utils.
5
 *
6
 * This file is part of the JBZoo Toolbox project.
7
 * For the full copyright and license information, please view the LICENSE
8
 * file that was distributed with this source code.
9
 *
10
 * @license    MIT
11
 * @copyright  Copyright (C) JBZoo.com, All rights reserved.
12
 * @see        https://github.com/JBZoo/Utils
13
 */
14

15
declare(strict_types=1);
16

17
namespace JBZoo\Utils;
18

19
use function JBZoo\Data\data;
20

21
/**
22
 * @SuppressWarnings(PHPMD.TooManyPublicMethods)
23
 * @SuppressWarnings(PHPMD.ExcessiveClassComplexity)
24
 * @psalm-suppress UnusedClass
25
 */
26
final class Url
27
{
28
    /**
29
     * URL constants as defined in the PHP Manual under "Constants usable with http_build_url()".
30
     * @see http://us2.php.net/manual/en/http.constants.php#http.constants.url
31
     */
32
    public const URL_REPLACE        = 1;
33
    public const URL_JOIN_PATH      = 2;
34
    public const URL_JOIN_QUERY     = 4;
35
    public const URL_STRIP_USER     = 8;
36
    public const URL_STRIP_PASS     = 16;
37
    public const URL_STRIP_AUTH     = 32;
38
    public const URL_STRIP_PORT     = 64;
39
    public const URL_STRIP_PATH     = 128;
40
    public const URL_STRIP_QUERY    = 256;
41
    public const URL_STRIP_FRAGMENT = 512;
42
    public const URL_STRIP_ALL      = 1024;
43

44
    public const ARG_SEPARATOR = '&';
45

46
    public const PORT_HTTP  = 80;
47
    public const PORT_HTTPS = 443;
48

49
    /**
50
     * Add or remove query arguments to the URL.
51
     * @param array       $newParams Either new key or an associative array
52
     * @param null|string $uri       URI or URL to append the query/queries to
53
     * @SuppressWarnings(PHPMD.NPathComplexity)
54
     * @SuppressWarnings(PHPMD.Superglobals)
55
     * @SuppressWarnings(PHPMD.CyclomaticComplexity)
56
     */
57
    public static function addArg(array $newParams, ?string $uri = null): string
58
    {
59
        $uri ??= ($_SERVER['REQUEST_URI'] ?? '');
12✔
60

61
        // Parse the URI into it's components
62
        $parsedUri = data((array)\parse_url($uri));
12✔
63

64
        $parsedQuery = $parsedUri->getString('query');
12✔
65
        $parsedPath  = $parsedUri->getString('path');
12✔
66

67
        if (!isStrEmpty($parsedQuery)) {
12✔
68
            \parse_str($parsedQuery, $queryParams);
12✔
69
            $queryParams = \array_merge($queryParams, $newParams);
12✔
70
        } elseif (!isStrEmpty($parsedPath) && \str_contains($parsedPath, '=')) {
6✔
71
            $parsedUri['query'] = $parsedUri['path'];
6✔
72
            $parsedUri->remove('path');
6✔
73
            \parse_str((string)$parsedUri['query'], $queryParams);
6✔
74
            $queryParams = \array_merge($queryParams, $newParams);
6✔
75
        } else {
76
            $queryParams = $newParams;
6✔
77
        }
78

79
        // Strip out any query params that are set to false.
80
        // Properly handle valueless parameters.
81
        foreach ($queryParams as $param => $value) {
12✔
82
            if ($value === false) {
12✔
83
                unset($queryParams[$param]);
12✔
84
            } elseif ($value === null) {
12✔
85
                $queryParams[$param] = '';
6✔
86
            }
87
        }
88

89
        // Re-construct the query string
90
        $query = self::build($queryParams);
12✔
91

92
        // Strip = from valueless parameters.
93
        $parsedUri['query'] = (string)\preg_replace('/=(?=&|$)/', '', $query);
12✔
94

95
        // Re-construct the entire URL
96
        $newUri = self::buildAll((array)$parsedUri);
12✔
97

98
        // Make the URI consistent with our input
99
        foreach ([':', '/', '?'] as $char) {
12✔
100
            if ($newUri[0] === $char && !\str_contains($uri, $char)) {
12✔
101
                $newUri = \substr($newUri, 1);
6✔
102
            }
103
        }
104

105
        return \rtrim($newUri, '?');
12✔
106
    }
107

108
    /**
109
     * Returns the current URL.
110
     */
111
    public static function current(bool $addAuth = false): ?string
112
    {
113
        $root   = self::root($addAuth);
12✔
114
        $path   = self::path();
12✔
115
        $result = \trim("{$root}{$path}");
12✔
116

117
        return $result === '' ? null : $result;
12✔
118
    }
119

120
    /**
121
     * Returns the current path.
122
     * @SuppressWarnings(PHPMD.Superglobals)
123
     */
124
    public static function path(): ?string
125
    {
126
        $url = '';
12✔
127

128
        // Get the rest of the URL
129
        if (!\array_key_exists('REQUEST_URI', $_SERVER)) {
12✔
130
            // Microsoft IIS doesn't set REQUEST_URI by default
131
            $queryString = $_SERVER['QUERY_STRING'] ?? null;
6✔
132
            if ($queryString !== null) {
6✔
UNCOV
133
                $url .= '?' . $queryString;
×
134
            }
135
        } else {
136
            $url .= $_SERVER['REQUEST_URI'];
6✔
137
        }
138

139
        return $url === '' ? null : $url;
12✔
140
    }
141

142
    /**
143
     * Returns current root URL.
144
     * @SuppressWarnings(PHPMD.Superglobals)
145
     * @SuppressWarnings(PHPMD.CyclomaticComplexity)
146
     */
147
    public static function root(bool $addAuth = false): ?string
148
    {
149
        $url = '';
36✔
150

151
        // Check to see if it's over https
152
        $isHttps = self::isHttps();
36✔
153

154
        // Was a username or password passed?
155
        if ($addAuth) {
36✔
156
            $url .= self::getAuth() ?? '';
6✔
157
        }
158

159
        $serverData = data($_SERVER);
36✔
160

161
        // We want the user to stay on the same host they are currently on,
162
        // but beware of security issues
163
        // see http://shiflett.org/blog/2006/mar/server-name-versus-http-host
164
        $host = (string)$serverData->get('HTTP_HOST');
36✔
165
        $port = (int)$serverData->get('SERVER_PORT');
36✔
166
        $url .= \str_replace(':' . $port, '', $host);
36✔
167

168
        // Is it on a non-standard port?
169
        if ($isHttps && $port !== self::PORT_HTTPS) {
36✔
170
            $url .= $port > 0 ? ":{$port}" : '';
6✔
171
        } elseif (!$isHttps && $port !== self::PORT_HTTP) {
36✔
172
            $url .= $port > 0 ? ":{$port}" : '';
18✔
173
        }
174

175
        if (!isStrEmpty($url)) {
36✔
176
            if ($isHttps) {
30✔
177
                return 'https://' . $url;
6✔
178
            }
179

180
            /** @noinspection HttpUrlsUsage */
181
            return 'http://' . $url;
30✔
182
        }
183

184
        return null;
6✔
185
    }
186

187
    /**
188
     * Get current auth info.
189
     * @SuppressWarnings(PHPMD.Superglobals)
190
     */
191
    public static function getAuth(): ?string
192
    {
193
        $result = null;
6✔
194

195
        $user = $_SERVER['PHP_AUTH_USER'] ?? '';
6✔
196

197
        if ($user !== '') {
6✔
198
            $result   = $user;
6✔
199
            $password = $_SERVER['PHP_AUTH_PW'] ?? '';
6✔
200

201
            if ($password !== '') {
6✔
202
                $result .= ':' . $password;
6✔
203
            }
204

205
            $result .= '@';
6✔
206
        }
207

208
        return $result;
6✔
209
    }
210

211
    /**
212
     * Builds HTTP query from array.
213
     */
214
    public static function build(array $queryParams): string
215
    {
216
        return \http_build_query($queryParams, '', self::ARG_SEPARATOR);
72✔
217
    }
218

219
    /**
220
     * Build a URL. The parts of the second URL will be merged into the first according to the flags' argument.
221
     *
222
     * @param array|string $sourceUrl (part(s) of) a URL in form of a string
223
     *                                or associative array like parse_url() returns
224
     * @param array|string $destParts Same as the first argument
225
     * @param int          $flags     A bitmask of binary or HTTP_URL constants; HTTP_URL_REPLACE is the default
226
     * @param array        $newUrl    If set, it will be filled with the parts of the composed url like parse_url()
227
     *                                would return
228
     *
229
     * @SuppressWarnings(PHPMD.CyclomaticComplexity)
230
     * @SuppressWarnings(PHPMD.NPathComplexity)
231
     * @SuppressWarnings(PHPMD.Superglobals)
232
     * @SuppressWarnings(PHPMD.ExcessiveMethodLength)
233
     *
234
     * @see    https://github.com/jakeasmith/http_build_url/
235
     * @author Jake Smith <theman@jakeasmith.com>
236
     */
237
    public static function buildAll(
238
        array|string $sourceUrl,
239
        array|string $destParts = [],
240
        int $flags = self::URL_REPLACE,
241
        array &$newUrl = [],
242
    ): string {
243
        if (!\is_array($sourceUrl)) {
72✔
244
            $sourceUrl = \parse_url($sourceUrl);
60✔
245
        }
246

247
        if (!\is_array($destParts)) {
72✔
UNCOV
248
            $destParts = \parse_url($destParts);
×
249
        }
250

251
        $url     = data((array)$sourceUrl);
72✔
252
        $parts   = data((array)$destParts);
72✔
253
        $allKeys = ['user', 'pass', 'port', 'path', 'query', 'fragment'];
72✔
254

255
        // HTTP_URL_STRIP_ALL and HTTP_URL_STRIP_AUTH cover several other flags.
256
        if (($flags & self::URL_STRIP_ALL) > 0) {
72✔
257
            $flags |= self::URL_STRIP_USER | self::URL_STRIP_PASS | self::URL_STRIP_PORT | self::URL_STRIP_PATH
6✔
258
                | self::URL_STRIP_QUERY | self::URL_STRIP_FRAGMENT;
6✔
259
        } elseif (($flags & self::URL_STRIP_AUTH) > 0) {
72✔
260
            $flags |= self::URL_STRIP_USER | self::URL_STRIP_PASS;
6✔
261
        }
262

263
        // Schema and host are always replaced
264
        if ($parts->has('scheme')) {
72✔
265
            $url['scheme'] = $parts->get('scheme');
60✔
266
        }
267

268
        if ($parts->has('host')) {
72✔
269
            $url['host'] = $parts->get('host');
60✔
270
        }
271

272
        if (($flags & self::URL_REPLACE) > 0) {
72✔
273
            foreach ($allKeys as $key) {
72✔
274
                if ($parts->has($key)) {
72✔
275
                    $url[$key] = $parts->get($key);
60✔
276
                }
277
            }
278
        } else {
279
            // PATH
280
            if (($flags & self::URL_JOIN_PATH) > 0 && $parts->has('path')) {
6✔
281
                if ($url->has('path') && $parts->get('path')[0] !== '/') {
6✔
282
                    $url['path'] = \rtrim(\str_replace(\basename((string)$url['path']), '', (string)$url['path']), '/')
6✔
283
                        . '/'
6✔
284
                        . \ltrim((string)$parts['path'], '/');
6✔
285
                } else {
286
                    $url['path'] = $parts['path'];
6✔
287
                }
288
            }
289

290
            // QUERY
291
            if (($flags & self::URL_JOIN_QUERY) > 0 && $parts->has('query')) {
6✔
292
                \parse_str($url->get('query', ''), $urlQuery);
6✔
293
                \parse_str($parts->get('query', ''), $partsQuery);
6✔
294

295
                $queryParams  = \array_replace_recursive($urlQuery, $partsQuery);
6✔
296
                $url['query'] = self::build($queryParams);
6✔
297
            }
298
        }
299

300
        $urlPath = $url->getString('path');
72✔
301
        if (!isStrEmpty($urlPath)) {
72✔
302
            $url['path'] = '/' . \ltrim($urlPath, '/');
72✔
303
        }
304

305
        foreach ($allKeys as $key) {
72✔
306
            $strip = 'URL_STRIP_' . \strtoupper($key);
72✔
307
            if (($flags & \constant(__CLASS__ . '::' . $strip)) > 0) {
72✔
308
                $url->remove($key);
6✔
309
            }
310
        }
311

312
        if ($url->get('port', null, 'int') === self::PORT_HTTPS) {
72✔
313
            $url['scheme'] = 'https';
6✔
314
        } elseif ($url->get('port', null, 'int') === self::PORT_HTTP) {
72✔
315
            $url['scheme'] = 'http';
6✔
316
        }
317

318
        if ($url->getInt('port') === 0) {
72✔
319
            if ($url->is('scheme', 'https')) {
72✔
320
                $url['port'] = 443;
60✔
321
            } elseif ($url->is('scheme', 'http')) {
72✔
322
                $url['port'] = 80;
60✔
323
            }
324
        }
325

326
        $parsedString = $url->has('scheme') ? ($url['scheme'] . '://') : '';
72✔
327

328
        if ($url->getString('user') !== '') {
72✔
329
            $parsedString .= $url['user'];
12✔
330
            $parsedString .= $url->getString('pass') === '' ? '' : (':' . $url->getString('pass'));
12✔
331
            $parsedString .= '@';
12✔
332
        }
333

334
        $parsedString .= $url->has('host') ? $url['host'] : '';
72✔
335

336
        if ((int)$url->get('port') !== self::PORT_HTTP && $url->get('scheme') === 'http') {
72✔
337
            $parsedString .= ':' . $url['port'];
6✔
338
        }
339

340
        if ($url->getString('path') !== '') {
72✔
341
            $parsedString .= $url['path'];
72✔
342
        } else {
343
            $parsedString .= '/';
18✔
344
        }
345

346
        if ($url->getString('query') !== '') {
72✔
347
            $parsedString .= '?' . $url->getString('query');
72✔
348
        }
349

350
        if ($url->getString('fragment') !== '') {
72✔
351
            $parsedString .= '#' . \trim($url->getString('fragment'), '#');
18✔
352
        }
353

354
        $newUrl = $url->getArrayCopy();
72✔
355

356
        return $parsedString;
72✔
357
    }
358

359
    /**
360
     * Checks to see if the page is being server over SSL or not.
361
     * @SuppressWarnings(PHPMD.Superglobals)
362
     */
363
    public static function isHttps(bool $trustProxyHeaders = false): bool
364
    {
365
        // Check standard HTTPS header
366
        if (\array_key_exists('HTTPS', $_SERVER)) {
84✔
367
            return !isStrEmpty($_SERVER['HTTPS'] ?? '') && $_SERVER['HTTPS'] !== 'off';
54✔
368
        }
369

370
        if ($trustProxyHeaders && \array_key_exists('X-FORWARDED-PROTO', $_SERVER)) {
36✔
UNCOV
371
            return $_SERVER['X-FORWARDED-PROTO'] === 'https';
×
372
        }
373

374
        // Default is not SSL
375
        return false;
36✔
376
    }
377

378
    /**
379
     * Removes an item or list from the query string.
380
     * @param array|string $keys query key or keys to remove
381
     * @param null|string  $uri  When null uses the $_SERVER value
382
     */
383
    public static function delArg(array|string $keys, ?string $uri = null): string
384
    {
385
        if (\is_array($keys)) {
6✔
386
            $params = \array_combine($keys, \array_fill(0, \count($keys), false));
6✔
387

388
            return self::addArg($params, (string)$uri);
6✔
389
        }
390

391
        return self::addArg([$keys => false], (string)$uri);
6✔
392
    }
393

394
    /**
395
     * Turns all the links in a string into HTML links.
396
     * Part of the LinkifyURL Project <https://github.com/jmrware/LinkifyURL>.
397
     * @param string $text The string to parse
398
     */
399
    public static function parseLink(string $text): string
400
    {
401
        $text = (string)\preg_replace('/&apos;/', '&#39;', $text); // IE does not handle &apos; entity!
6✔
402

403
        $sectionHtmlPattern = '%            # Rev:20100913_0900 github.com/jmrware/LinkifyURL
6✔
404
                                            # Section text into HTML <A> tags  and everything else.
405
             (                              # $1: Everything not HTML <A> tag.
406
               [^<]+(?:(?!<a\b)<[^<]*)*     # non A tag stuff starting with non-"<".
407
               | (?:(?!<a\b)<[^<]*)+        # non A tag stuff starting with "<".
408
             )                              # End $1.
409
             | (                            # $2: HTML <A...>...</A> tag.
410
                 <a\b[^>]*>                 # <A...> opening tag.
411
                 [^<]*(?:(?!</a\b)<[^<]*)*  # A tag contents.
412
                 </a\s*>                    # </A> closing tag.
413
             )                              # End $2:
414
             %ix';
6✔
415

416
        return (string)\preg_replace_callback(
6✔
417
            $sectionHtmlPattern,
6✔
418
            static fn (array $matches): string => self::linkifyCallback($matches),
6✔
419
            $text,
6✔
420
        );
6✔
421
    }
422

423
    /**
424
     * Convert file path to relative URL.
425
     * @SuppressWarnings(PHPMD.Superglobals)
426
     */
427
    public static function pathToRel(string $path): string
428
    {
429
        $root = FS::clean($_SERVER['DOCUMENT_ROOT'] ?? null);
6✔
430
        $path = FS::clean($path);
6✔
431

432
        $normRoot = \str_replace(\DIRECTORY_SEPARATOR, '/', $root);
6✔
433
        $normPath = \str_replace(\DIRECTORY_SEPARATOR, '/', $path);
6✔
434

435
        $regExp   = '/^' . \preg_quote($normRoot, '/') . '/i';
6✔
436
        $relative = (string)\preg_replace($regExp, '', $normPath);
6✔
437

438
        $relative = \ltrim($relative, '/');
6✔
439

440
        return $relative;
6✔
441
    }
442

443
    /**
444
     * Convert file path to absolute URL.
445
     * @SuppressWarnings(PHPMD.Superglobals)
446
     */
447
    public static function pathToUrl(string $path): string
448
    {
449
        $root = self::root();
6✔
450
        $rel  = self::pathToRel($path);
6✔
451

452
        return "{$root}/{$rel}";
6✔
453
    }
454

455
    /**
456
     * Check if URL is not relative.
457
     */
458
    public static function isAbsolute(string $path): bool
459
    {
460
        return \str_starts_with($path, '//') || \preg_match('#^[a-z-]{3,}://#i', $path) > 0;
6✔
461
    }
462

463
    /**
464
     * Create URL from array params.
465
     */
466
    public static function create(array $parts = []): string
467
    {
468
        $parts = \array_merge([
54✔
469
            'scheme' => 'https',
54✔
470
            'query'  => [],
54✔
471
        ], $parts);
54✔
472

473
        if (\is_array($parts['query'])) {
54✔
474
            $parts['query'] = self::build($parts['query']);
54✔
475
        }
476

477
        return self::buildAll('', $parts, self::URL_REPLACE);
54✔
478
    }
479

480
    /**
481
     * Callback for the preg_replace in the linkify() method.
482
     * Part of the LinkifyURL Project <https://github.com/jmrware/LinkifyURL>.
483
     * @param array $matches Matches from the preg_ function
484
     */
485
    private static function linkifyCallback(array $matches): string
486
    {
487
        return $matches[2] ?? self::linkifyRegex($matches[1]);
6✔
488
    }
489

490
    /**
491
     * Callback for the preg_replace in the linkify() method.
492
     * Part of the LinkifyURL Project <https://github.com/jmrware/LinkifyURL>.
493
     * @param string $text Matches from the preg_ function
494
     */
495
    private static function linkifyRegex(string $text): string
496
    {
497
        $urlPattern = '/                                            # Rev:20100913_0900 github.com\/jmrware\/LinkifyURL
6✔
498
                                                                    # Match http & ftp URL that is not already linkified
499
                                                                    # Alternative 1: URL delimited by (parentheses).
500
            (\()                                                    # $1 "(" start delimiter.
501
            ((?:ht|f)tps?:\/\/[a-z0-9\-._~!$&\'()*+,;=:\/?#[\]@%]+) # $2: URL.
502
            (\))                                                    # $3: ")" end delimiter.
503
            |                                                       # Alternative 2: URL delimited by [square brackets].
504
            (\[)                                                    # $4: "[" start delimiter.
505
            ((?:ht|f)tps?:\/\/[a-z0-9\-._~!$&\'()*+,;=:\/?#[\]@%]+) # $5: URL.
506
            (\])                                                    # $6: "]" end delimiter.
507
            |                                                       # Alternative 3: URL delimited by {curly braces}.
508
            (\{)                                                    # $7: "{" start delimiter.
509
            ((?:ht|f)tps?:\/\/[a-z0-9\-._~!$&\'()*+,;=:\/?#[\]@%]+) # $8: URL.
510
            (\})                                                    # $9: "}" end delimiter.
511
            |                                                       # Alternative 4: URL delimited by <angle brackets>.
512
            (<|&(?:lt|\#60|\#x3c);)                                 # $10: "<" start delimiter (or HTML entity).
513
            ((?:ht|f)tps?:\/\/[a-z0-9\-._~!$&\'()*+,;=:\/?#[\]@%]+) # $11: URL.
514
            (>|&(?:gt|\#62|\#x3e);)                                 # $12: ">" end delimiter (or HTML entity).
515
            |                                                       # Alt. 5: URL not delimited by (), [], {} or <>.
516
            (                                                       # $13: Prefix proving URL not already linked.
517
            (?: ^                                                   # Can be a beginning of line or string, or
518
             | [^=\s\'"\]]                                          # a non-"=", non-quote, non-"]", followed by
519
            ) \s*[\'"]?                                             # optional whitespace and optional quote;
520
              | [^=\s]\s+                                           # or... a non-equals sign followed by whitespace.
521
            )                                                       # End $13. Non-prelinkified-proof prefix.
522
            (\b                                                     # $14: Other non-delimited URL.
523
            (?:ht|f)tps?:\/\/                                       # Required literal http, https, ftp or ftps prefix.
524
            [a-z0-9\-._~!$\'()*+,;=:\/?#[\]@%]+                     # All URI chars except "&" (normal*).
525
            (?:                                                     # Either on a "&" or at the end of URI.
526
            (?!                                                     # Allow a "&" char only if not start of an...
527
            &(?:gt|\#0*62|\#x0*3e);                                 # HTML ">" entity, or
528
            | &(?:amp|apos|quot|\#0*3[49]|\#x0*2[27]);              # a [&\'"] entity if
529
            [.!&\',:?;]?                                            # followed by optional punctuation then
530
            (?:[^a-z0-9\-._~!$&\'()*+,;=:\/?#[\]@%]|$)              # a non-URI char or EOS.
531
           ) &                                                      # If neg-assertion true, match "&" (special).
532
            [a-z0-9\-._~!$\'()*+,;=:\/?#[\]@%]*                     # More non-& URI chars (normal*).
533
           )*                                                       # Unroll-the-loop (special normal*)*.
534
            [a-z0-9\-_~$()*+=\/#[\]@%]                              # Last char can\'t be [.!&\',;:?]
535
           )                                                        # End $14. Other non-delimited URL.
536
            /imx';
6✔
537

538
        $urlReplace = '$1$4$7$10$13<a href="$2$5$8$11$14">$2$5$8$11$14</a>$3$6$9$12';
6✔
539

540
        return (string)\preg_replace($urlPattern, $urlReplace, $text);
6✔
541
    }
542
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc