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

codeigniter4 / CodeIgniter4 / 12855700662

19 Jan 2025 05:28PM UTC coverage: 84.546% (+0.08%) from 84.469%
12855700662

push

github

web-flow
Merge pull request #9417 from codeigniter4/4.6

4.6.0 Merge code

720 of 841 new or added lines in 68 files covered. (85.61%)

8 existing lines in 6 files now uncovered.

20811 of 24615 relevant lines covered (84.55%)

191.24 hits per line

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

95.0
/system/HTTP/Negotiate.php
1
<?php
2

3
declare(strict_types=1);
4

5
/**
6
 * This file is part of CodeIgniter 4 framework.
7
 *
8
 * (c) CodeIgniter Foundation <admin@codeigniter.com>
9
 *
10
 * For the full copyright and license information, please view
11
 * the LICENSE file that was distributed with this source code.
12
 */
13

14
namespace CodeIgniter\HTTP;
15

16
use CodeIgniter\HTTP\Exceptions\HTTPException;
17
use Config\Feature;
18

19
/**
20
 * Class Negotiate
21
 *
22
 * Provides methods to negotiate with the HTTP headers to determine the best
23
 * type match between what the application supports and what the requesting
24
 * server wants.
25
 *
26
 * @see http://tools.ietf.org/html/rfc7231#section-5.3
27
 * @see \CodeIgniter\HTTP\NegotiateTest
28
 */
29
class Negotiate
30
{
31
    /**
32
     * Request
33
     *
34
     * @var IncomingRequest
35
     */
36
    protected $request;
37

38
    /**
39
     * Constructor
40
     */
41
    public function __construct(?RequestInterface $request = null)
42
    {
43
        if ($request instanceof RequestInterface) {
22✔
44
            assert($request instanceof IncomingRequest);
45

46
            $this->request = $request;
22✔
47
        }
48
    }
49

50
    /**
51
     * Stores the request instance to grab the headers from.
52
     *
53
     * @return $this
54
     */
55
    public function setRequest(RequestInterface $request)
56
    {
57
        assert($request instanceof IncomingRequest);
58

59
        $this->request = $request;
1✔
60

61
        return $this;
1✔
62
    }
63

64
    /**
65
     * Determines the best content-type to use based on the $supported
66
     * types the application says it supports, and the types requested
67
     * by the client.
68
     *
69
     * If no match is found, the first, highest-ranking client requested
70
     * type is returned.
71
     *
72
     * @param bool $strictMatch If TRUE, will return an empty string when no match found.
73
     *                          If FALSE, will return the first supported element.
74
     */
75
    public function media(array $supported, bool $strictMatch = false): string
76
    {
77
        return $this->getBestMatch($supported, $this->request->getHeaderLine('accept'), true, $strictMatch);
13✔
78
    }
79

80
    /**
81
     * Determines the best charset to use based on the $supported
82
     * types the application says it supports, and the types requested
83
     * by the client.
84
     *
85
     * If no match is found, the first, highest-ranking client requested
86
     * type is returned.
87
     */
88
    public function charset(array $supported): string
89
    {
90
        $match = $this->getBestMatch(
2✔
91
            $supported,
2✔
92
            $this->request->getHeaderLine('accept-charset'),
2✔
93
            false,
2✔
94
            true,
2✔
95
        );
2✔
96

97
        // If no charset is shown as a match, ignore the directive
98
        // as allowed by the RFC, and tell it a default value.
99
        if ($match === '') {
2✔
100
            return 'utf-8';
2✔
101
        }
102

103
        return $match;
1✔
104
    }
105

106
    /**
107
     * Determines the best encoding type to use based on the $supported
108
     * types the application says it supports, and the types requested
109
     * by the client.
110
     *
111
     * If no match is found, the first, highest-ranking client requested
112
     * type is returned.
113
     */
114
    public function encoding(array $supported = []): string
115
    {
116
        $supported[] = 'identity';
3✔
117

118
        return $this->getBestMatch($supported, $this->request->getHeaderLine('accept-encoding'));
3✔
119
    }
120

121
    /**
122
     * Determines the best language to use based on the $supported
123
     * types the application says it supports, and the types requested
124
     * by the client.
125
     *
126
     * If strict locale negotiation is disabled and no match is found, the first, highest-ranking client requested
127
     * type is returned.
128
     */
129
    public function language(array $supported): string
130
    {
131
        if (config(Feature::class)->strictLocaleNegotiation) {
5✔
132
            return $this->getBestLocaleMatch($supported, $this->request->getHeaderLine('accept-language'));
2✔
133
        }
134

135
        return $this->getBestMatch($supported, $this->request->getHeaderLine('accept-language'), false, false, true);
5✔
136
    }
137

138
    // --------------------------------------------------------------------
139
    // Utility Methods
140
    // --------------------------------------------------------------------
141

142
    /**
143
     * Does the grunt work of comparing any of the app-supported values
144
     * against a given Accept* header string.
145
     *
146
     * Portions of this code base on Aura.Accept library.
147
     *
148
     * @param array  $supported    App-supported values
149
     * @param string $header       header string
150
     * @param bool   $enforceTypes If TRUE, will compare media types and sub-types.
151
     * @param bool   $strictMatch  If TRUE, will return empty string on no match.
152
     *                             If FALSE, will return the first supported element.
153
     * @param bool   $matchLocales If TRUE, will match locale sub-types to a broad type (fr-FR = fr)
154
     *
155
     * @return string Best match
156
     */
157
    protected function getBestMatch(
158
        array $supported,
159
        ?string $header = null,
160
        bool $enforceTypes = false,
161
        bool $strictMatch = false,
162
        bool $matchLocales = false,
163
    ): string {
164
        if ($supported === []) {
23✔
165
            throw HTTPException::forEmptySupportedNegotiations();
1✔
166
        }
167

168
        if ($header === null || $header === '') {
22✔
169
            return $strictMatch ? '' : $supported[0];
6✔
170
        }
171

172
        $acceptable = $this->parseHeader($header);
16✔
173

174
        foreach ($acceptable as $accept) {
16✔
175
            // if acceptable quality is zero, skip it.
176
            if ($accept['q'] === 0.0) {
16✔
177
                continue;
1✔
178
            }
179

180
            // if acceptable value is "anything", return the first available
181
            if ($accept['value'] === '*' || $accept['value'] === '*/*') {
15✔
182
                return $supported[0];
1✔
183
            }
184

185
            // If an acceptable value is supported, return it
186
            foreach ($supported as $available) {
15✔
187
                if ($this->match($accept, $available, $enforceTypes, $matchLocales)) {
15✔
188
                    return $available;
11✔
189
                }
190
            }
191
        }
192

193
        // No matches? Return the first supported element.
194
        return $strictMatch ? '' : $supported[0];
6✔
195
    }
196

197
    /**
198
     * Try to find the best matching locale. It supports strict locale comparison.
199
     *
200
     * If Config\App::$supportedLocales have "en-US" and "en-GB" locales, they can be recognized
201
     * as two different locales. This method checks first for the strict match, then fallback
202
     * to the most general locale (in this case "en") ISO 639-1 and finally to the locale variant
203
     * "en-*" (ISO 639-1 plus "wildcard" for ISO 3166-1 alpha-2).
204
     *
205
     * If nothing from above is matched, then it returns the first option from the $supportedLocales array.
206
     *
207
     * @param list<string> $supportedLocales App-supported values
208
     * @param ?string      $header           Compatible 'Accept-Language' header string
209
     */
210
    protected function getBestLocaleMatch(array $supportedLocales, ?string $header): string
211
    {
212
        if ($supportedLocales === []) {
2✔
NEW
213
            throw HTTPException::forEmptySupportedNegotiations();
×
214
        }
215

216
        if ($header === null || $header === '') {
2✔
NEW
217
            return $supportedLocales[0];
×
218
        }
219

220
        $acceptable      = $this->parseHeader($header);
2✔
221
        $fallbackLocales = [];
2✔
222

223
        foreach ($acceptable as $accept) {
2✔
224
            // if acceptable quality is zero, skip it.
225
            if ($accept['q'] === 0.0) {
2✔
NEW
226
                continue;
×
227
            }
228

229
            // if acceptable value is "anything", return the first available
230
            if ($accept['value'] === '*') {
2✔
NEW
231
                return $supportedLocales[0];
×
232
            }
233

234
            // look for exact match
235
            if (in_array($accept['value'], $supportedLocales, true)) {
2✔
236
                return $accept['value'];
2✔
237
            }
238

239
            // set a fallback locale
240
            $fallbackLocales[] = strtok($accept['value'], '-');
2✔
241
        }
242

243
        foreach ($fallbackLocales as $fallbackLocale) {
1✔
244
            // look for exact match
245
            if (in_array($fallbackLocale, $supportedLocales, true)) {
1✔
NEW
246
                return $fallbackLocale;
×
247
            }
248

249
            // look for regional locale match
250
            foreach ($supportedLocales as $locale) {
1✔
251
                if (str_starts_with($locale, $fallbackLocale . '-')) {
1✔
252
                    return $locale;
1✔
253
                }
254
            }
255
        }
256

NEW
257
        return $supportedLocales[0];
×
258
    }
259

260
    /**
261
     * Parses an Accept* header into it's multiple values.
262
     *
263
     * This is based on code from Aura.Accept library.
264
     */
265
    public function parseHeader(string $header): array
266
    {
267
        $results    = [];
17✔
268
        $acceptable = explode(',', $header);
17✔
269

270
        foreach ($acceptable as $value) {
17✔
271
            $pairs = explode(';', $value);
17✔
272

273
            $value = $pairs[0];
17✔
274

275
            unset($pairs[0]);
17✔
276

277
            $parameters = [];
17✔
278

279
            foreach ($pairs as $pair) {
17✔
280
                if (preg_match(
12✔
281
                    '/^(?P<name>.+?)=(?P<quoted>"|\')?(?P<value>.*?)(?:\k<quoted>)?$/',
12✔
282
                    $pair,
12✔
283
                    $param,
12✔
284
                )) {
12✔
285
                    $parameters[trim($param['name'])] = trim($param['value']);
12✔
286
                }
287
            }
288

289
            $quality = 1.0;
17✔
290

291
            if (array_key_exists('q', $parameters)) {
17✔
292
                $quality = $parameters['q'];
10✔
293
                unset($parameters['q']);
10✔
294
            }
295

296
            $results[] = [
17✔
297
                'value'  => trim($value),
17✔
298
                'q'      => (float) $quality,
17✔
299
                'params' => $parameters,
17✔
300
            ];
17✔
301
        }
302

303
        // Sort to get the highest results first
304
        usort($results, static function ($a, $b): int {
17✔
305
            if ($a['q'] === $b['q']) {
14✔
306
                $aAst = substr_count($a['value'], '*');
8✔
307
                $bAst = substr_count($b['value'], '*');
8✔
308

309
                // '*/*' has lower precedence than 'text/*',
310
                // and 'text/*' has lower priority than 'text/plain'
311
                //
312
                // This seems backwards, but needs to be that way
313
                // due to the way PHP7 handles ordering or array
314
                // elements created by reference.
315
                if ($aAst > $bAst) {
8✔
316
                    return 1;
3✔
317
                }
318

319
                // If the counts are the same, but one element
320
                // has more params than another, it has higher precedence.
321
                //
322
                // This seems backwards, but needs to be that way
323
                // due to the way PHP7 handles ordering or array
324
                // elements created by reference.
325
                if ($aAst === $bAst) {
6✔
326
                    return count($b['params']) - count($a['params']);
5✔
327
                }
328

329
                return 0;
2✔
330
            }
331

332
            // Still here? Higher q values have precedence.
333
            return ($a['q'] > $b['q']) ? -1 : 1;
9✔
334
        });
17✔
335

336
        return $results;
17✔
337
    }
338

339
    /**
340
     * Match-maker
341
     *
342
     * @param bool $matchLocales
343
     */
344
    protected function match(array $acceptable, string $supported, bool $enforceTypes = false, $matchLocales = false): bool
345
    {
346
        $supported = $this->parseHeader($supported);
15✔
347
        if (count($supported) === 1) {
15✔
348
            $supported = $supported[0];
15✔
349
        }
350

351
        // Is it an exact match?
352
        if ($acceptable['value'] === $supported['value']) {
15✔
353
            return $this->matchParameters($acceptable, $supported);
8✔
354
        }
355

356
        // Do we need to compare types/sub-types? Only used
357
        // by negotiateMedia().
358
        if ($enforceTypes) {
14✔
359
            return $this->matchTypes($acceptable, $supported);
7✔
360
        }
361

362
        // Do we need to match locales against broader locales?
363
        if ($matchLocales) {
7✔
364
            return $this->matchLocales($acceptable, $supported);
5✔
365
        }
366

367
        return false;
2✔
368
    }
369

370
    /**
371
     * Checks two Accept values with matching 'values' to see if their
372
     * 'params' are the same.
373
     */
374
    protected function matchParameters(array $acceptable, array $supported): bool
375
    {
376
        if (count($acceptable['params']) !== count($supported['params'])) {
8✔
377
            return false;
1✔
378
        }
379

380
        foreach ($supported['params'] as $label => $value) {
8✔
381
            if (! isset($acceptable['params'][$label])
1✔
382
                || $acceptable['params'][$label] !== $value
1✔
383
            ) {
384
                return false;
1✔
385
            }
386
        }
387

388
        return true;
7✔
389
    }
390

391
    /**
392
     * Compares the types/subtypes of an acceptable Media type and
393
     * the supported string.
394
     */
395
    public function matchTypes(array $acceptable, array $supported): bool
396
    {
397
        // PHPDocumentor v2 cannot parse yet the shorter list syntax,
398
        // causing no API generation for the file.
399
        [$aType, $aSubType] = explode('/', $acceptable['value']);
7✔
400
        [$sType, $sSubType] = explode('/', $supported['value']);
7✔
401

402
        // If the types don't match, we're done.
403
        if ($aType !== $sType) {
7✔
404
            return false;
6✔
405
        }
406

407
        // If there's an asterisk, we're cool
408
        if ($aSubType === '*') {
4✔
409
            return true;
2✔
410
        }
411

412
        // Otherwise, subtypes must match also.
413
        return $aSubType === $sSubType;
2✔
414
    }
415

416
    /**
417
     * Will match locales against their broader pairs, so that fr-FR would
418
     * match a supported localed of fr
419
     */
420
    public function matchLocales(array $acceptable, array $supported): bool
421
    {
422
        $aBroad = mb_strpos($acceptable['value'], '-') > 0
5✔
423
            ? mb_substr($acceptable['value'], 0, mb_strpos($acceptable['value'], '-'))
5✔
424
            : $acceptable['value'];
2✔
425
        $sBroad = mb_strpos($supported['value'], '-') > 0
5✔
426
            ? mb_substr($supported['value'], 0, mb_strpos($supported['value'], '-'))
2✔
427
            : $supported['value'];
5✔
428

429
        return strtolower($aBroad) === strtolower($sBroad);
5✔
430
    }
431
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc