Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
90.97% covered (success)
90.97%
131 / 144
85.71% covered (success)
85.71%
24 / 28
CRAP
0.00% covered (danger)
0.00%
0 / 1
Value
90.97% covered (success)
90.97%
131 / 144
85.71% covered (success)
85.71%
24 / 28
89.19
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
5
 parse
100.00% covered (success)
100.00%
64 / 64
100.00% covered (success)
100.00%
1 / 1
30
 trimFws
80.00% covered (success)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
5.20
 renderSegment
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 spliceSegment
83.33% covered (success)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
2.02
 isInsideEncodedWord
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
4
 parseParameter
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
42
 setScheme
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getScheme
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasScheme
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setValue
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getValue
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getDecodedValue
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 addParameters
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 addParameter
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getParameters
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getParametersAsString
88.89% covered (success)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
5.03
 getParameter
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasParameters
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasParameter
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setDelimiter
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getDelimiter
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasDelimiter
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setForceQuote
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 isForceQuote
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 render
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 encodeValueForHeader
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 __toString
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2declare(strict_types=1);
3/**
4 * Pop PHP Framework (https://www.popphp.org/)
5 *
6 * @link       https://github.com/popphp/popphp-framework
7 * @author     Nick Sagona, III <dev@noladev.com>
8 * @copyright  Copyright (c) 2009-2027 NOLA Interactive, LLC.
9 * @license    https://www.popphp.org/license     New BSD License
10 */
11
12/**
13 * @namespace
14 */
15namespace Pop\Mime\Part\Header;
16
17/**
18 * MIME part header value class
19 *
20 * @category   Pop
21 * @package    Pop\Mime
22 * @author     Nick Sagona, III <dev@noladev.com>
23 * @copyright  Copyright (c) 2009-2027 NOLA Interactive, LLC.
24 * @license    https://www.popphp.org/license     New BSD License
25 * @version    3.0.0
26 */
27class Value
28{
29
30    /**
31     * Header value scheme
32     * @var ?string
33     */
34    protected ?string $scheme = null;
35
36    /**
37     * Header value
38     * @var ?string
39     */
40    protected ?string $value = null;
41
42    /**
43     * Header value parameters
44     * @var array
45     */
46    protected array $parameters = [];
47
48    /**
49     * Header value delimiter
50     * @var string
51     */
52    protected string $delimiter = ';';
53
54    /**
55     * Force quotes for parameter values
56     * @var bool
57     */
58    protected bool $forceQuote = false;
59
60    /**
61     * Constructor
62     *
63     * Instantiate the header value object
64     *
65     * @param ?string $value
66     * @param ?string $scheme
67     * @param array   $parameters
68     * @param bool    $forceQuote
69     */
70    public function __construct(?string $value = null, ?string $scheme = null, array $parameters = [], bool $forceQuote = false)
71    {
72        if ($value !== null) {
73            $this->setValue($value);
74        }
75        if ($scheme !== null) {
76            $this->setScheme($scheme);
77        }
78        if (!empty($parameters)) {
79            $this->addParameters($parameters);
80        }
81        if ($forceQuote) {
82            $this->setForceQuote($forceQuote);
83        }
84    }
85
86    /**
87     * Parse header value
88     *
89     * @param  string $value
90     * @return Value
91     */
92    public static function parse(string $value): Value
93    {
94        $valueObject = new Value();
95        $trimmed     = trim($value);
96
97        foreach (['Basic', 'Bearer', 'Digest'] as $schemeName) {
98            $schemeLength = strlen($schemeName);
99            if ((strncasecmp($trimmed, $schemeName, $schemeLength) === 0) &&
100                ((strlen($trimmed) === $schemeLength) || ($trimmed[$schemeLength] === ' '))) {
101                $valueObject->setScheme($schemeName . ' ');
102                $trimmed = ltrim(substr($trimmed, $schemeLength));
103                break;
104            }
105        }
106
107        $source             = $trimmed;
108        $tokens             = (new Lexer($source))->tokenize();
109        $encodedWordRanges  = EncodedWord::findRanges($source);
110
111        $segments   = [[]];
112        $delimiters = [];
113
114        foreach ($tokens as $token) {
115            if (($token->type === Lexer::DELIMITER) && (($token->value === ';') || ($token->value === ','))) {
116                $delimiters[] = $token->value;
117                $segments[]   = [];
118            } else {
119                $segments[count($segments) - 1][] = $token;
120            }
121        }
122
123        $firstSegment    = self::trimFws($segments[0]);
124        $hasLeadingValue = true;
125        if (count($segments) > 1) {
126            foreach ($firstSegment as $token) {
127                if (($token->type === Lexer::DELIMITER) && ($token->value === '=') &&
128                    !self::isInsideEncodedWord($token, $encodedWordRanges)) {
129                    $hasLeadingValue = false;
130                    break;
131                }
132            }
133        }
134
135        $valueParts      = [];
136        $valuePartsDelim = [];
137        $parameters      = [];
138
139        foreach ($segments as $i => $segmentTokens) {
140            $segmentTokens = self::trimFws($segmentTokens);
141
142            if (($i === 0) && $hasLeadingValue) {
143                $spliced = self::spliceSegment($source, $segmentTokens);
144                if ($spliced !== '') {
145                    $valueParts[] = $spliced;
146                }
147                continue;
148            }
149
150            $eqIndex = null;
151            foreach ($segmentTokens as $j => $token) {
152                if (($token->type === Lexer::DELIMITER) && ($token->value === '=') &&
153                    !self::isInsideEncodedWord($token, $encodedWordRanges)) {
154                    $eqIndex = $j;
155                    break;
156                }
157            }
158
159            if ($eqIndex === null) {
160                $spliced = self::spliceSegment($source, $segmentTokens);
161                if ($spliced !== '') {
162                    if (!empty($valueParts)) {
163                        $valuePartsDelim[] = $delimiters[$i - 1];
164                    }
165                    $valueParts[] = $spliced;
166                }
167                continue;
168            }
169
170            $paramName  = self::renderSegment(array_slice($segmentTokens, 0, $eqIndex));
171            $paramValue = self::renderSegment(array_slice($segmentTokens, $eqIndex + 1));
172            if ($paramName !== '') {
173                $parameters[$paramName] = $paramValue;
174            }
175        }
176
177        if (!empty($valueParts)) {
178            $mainValue = array_shift($valueParts);
179            foreach ($valueParts as $k => $part) {
180                $mainValue .= $valuePartsDelim[$k] . ' ' . $part;
181            }
182            $valueObject->setValue($mainValue);
183        }
184
185        if (!empty($parameters)) {
186            $valueObject->addParameters($parameters);
187        }
188        if (!empty($delimiters)) {
189            $valueObject->setDelimiter($delimiters[count($delimiters) - 1]);
190        }
191
192        return $valueObject;
193    }
194
195    /**
196     * Trim leading/trailing FWS tokens from a token list
197     *
198     * @param  Token[] $tokens
199     * @return Token[]
200     */
201    protected static function trimFws(array $tokens): array
202    {
203        while (!empty($tokens) && ($tokens[array_key_first($tokens)]->type === Lexer::FWS)) {
204            array_shift($tokens);
205        }
206        while (!empty($tokens) && ($tokens[array_key_last($tokens)]->type === Lexer::FWS)) {
207            array_pop($tokens);
208        }
209        return array_values($tokens);
210    }
211
212    /**
213     * Concatenate a token list's values back into a string
214     *
215     * @param  Token[] $tokens
216     * @return string
217     */
218    protected static function renderSegment(array $tokens): string
219    {
220        $value = '';
221        foreach (self::trimFws($tokens) as $token) {
222            $value .= $token->value;
223        }
224        return $value;
225    }
226
227    /**
228     * Splice a token span's exact original text out of the source string,
229     * by first/last token offset - preserves comments, escapes, and
230     * whitespace exactly as written, unlike reconstructing from token values.
231     *
232     * @param  string  $source
233     * @param  Token[] $tokens
234     * @return string
235     */
236    protected static function spliceSegment(string $source, array $tokens): string
237    {
238        $tokens = self::trimFws($tokens);
239        if (empty($tokens)) {
240            return '';
241        }
242        $first = $tokens[array_key_first($tokens)];
243        $last  = $tokens[array_key_last($tokens)];
244        return substr($source, $first->start, $last->end - $first->start);
245    }
246
247    /**
248     * @param  Token $token
249     * @param  array $ranges
250     * @return bool
251     */
252    protected static function isInsideEncodedWord(Token $token, array $ranges): bool
253    {
254        foreach ($ranges as $range) {
255            if (($token->start >= $range['start']) && ($token->start < $range['end'])) {
256                return true;
257            }
258        }
259        return false;
260    }
261
262    /**
263     * Parse a parameter value
264     *
265     * @param  string $parameter
266     * @return array
267     */
268    public static function parseParameter(string $parameter): array
269    {
270        $paramName  = substr($parameter, 0, strpos($parameter, '='));
271        $paramValue = substr($parameter, (strpos($parameter, '=') + 1));
272        $delimiter  = null;
273        if (str_ends_with($paramValue, ';') || str_ends_with($paramValue, ',')) {
274            $delimiter = (str_ends_with($paramValue, ';')) ? ';' : ',';
275            $paramValue = substr($paramValue, 0, -1);
276        }
277        if ((str_starts_with($paramValue, '"')) && (str_ends_with($paramValue, '"'))) {
278            $paramValue = substr($paramValue, 1);
279            $paramValue = substr($paramValue, 0, -1);
280        }
281        return [$paramName, $paramValue, $delimiter];
282    }
283
284    /**
285     * Set the header value scheme
286     *
287     * @param  string $scheme
288     * @return Value
289     */
290    public function setScheme(string $scheme): Value
291    {
292        $this->scheme = $scheme;
293        return $this;
294    }
295
296    /**
297     * Get the header value scheme
298     *
299     * @return string|null
300     */
301    public function getScheme(): string|null
302    {
303        return $this->scheme;
304    }
305
306    /**
307     * Has a header value scheme
308     *
309     * @return bool
310     */
311    public function hasScheme(): bool
312    {
313        return ($this->scheme !== null);
314    }
315
316    /**
317     * Set the header value
318     *
319     * @param  string $value
320     * @return Value
321     */
322    public function setValue(string $value): Value
323    {
324        $this->value = $value;
325        return $this;
326    }
327
328    /**
329     * Get the header value
330     *
331     * @return string|null
332     */
333    public function getValue(): string|null
334    {
335        return $this->value;
336    }
337
338    /**
339     * Get the header value, decoded of any RFC 2047 encoded-words
340     *
341     * @return string|null
342     */
343    public function getDecodedValue(): string|null
344    {
345        return ($this->value !== null) ? EncodedWord::decode($this->value) : null;
346    }
347
348    /**
349     * Add the header value parameters
350     *
351     * @param  array $parameters
352     * @return Value
353     */
354    public function addParameters(array $parameters): Value
355    {
356        $this->parameters = $parameters;
357        return $this;
358    }
359
360    /**
361     * Set a header value parameter
362     *
363     * @param string $name
364     * @param string $value
365     * @return Value
366     */
367    public function addParameter(string $name, string $value): Value
368    {
369        $this->parameters[$name] = $value;
370        return $this;
371    }
372
373    /**
374     * Get the header value parameters
375     *
376     * @return array
377     */
378    public function getParameters(): array
379    {
380        return $this->parameters;
381    }
382
383    /**
384     * Get the header value parameters as string
385     *
386     * @throws Exception
387     * @return string
388     */
389    public function getParametersAsString(): string
390    {
391        if (!$this->hasDelimiter()) {
392            throw new Exception('Error: No delimiter has been set.');
393        }
394
395        $parameters = [];
396
397        foreach ($this->parameters as $name => $value) {
398            $needsQuoting = $this->forceQuote || (bool)preg_match('/[\s;,="\\\\]/', $value);
399            if ($needsQuoting) {
400                $value = '"' . addcslashes($value, '"\\') . '"';
401            }
402            $parameters[] = $name . '=' . $value;
403        }
404
405        return implode($this->delimiter . ' ', $parameters);
406    }
407
408    /**
409     * Get a header value parameter
410     *
411     * @param  string $name
412     * @return string|null
413     */
414    public function getParameter(string $name): string|null
415    {
416        return $this->parameters[$name] ?? null;
417    }
418
419    /**
420     * Has header value parameters
421     *
422     * @return bool
423     */
424    public function hasParameters(): bool
425    {
426        return (count($this->parameters) > 0);
427    }
428
429    /**
430     * Has a header value parameter
431     *
432     * @param  string $name
433     * @return bool
434     */
435    public function hasParameter(string $name): bool
436    {
437        return (isset($this->parameters[$name]));
438    }
439
440    /**
441     * Set the header value delimiter
442     *
443     * @param  string $delimiter
444     * @return Value
445     */
446    public function setDelimiter(string $delimiter): Value
447    {
448        $this->delimiter = $delimiter;
449        return $this;
450    }
451
452    /**
453     * Get the header value delimiter
454     *
455     * @return string
456     */
457    public function getDelimiter(): string
458    {
459        return $this->delimiter;
460    }
461
462    /**
463     * Has a header value delimiter
464     *
465     * @return bool
466     */
467    public function hasDelimiter(): bool
468    {
469        return ($this->delimiter !== '');
470    }
471
472    /**
473     * Set the header value delimiter
474     *
475     * @param  bool $forceQuote
476     * @return Value
477     */
478    public function setForceQuote(bool $forceQuote = false): Value
479    {
480        $this->forceQuote = $forceQuote;
481        return $this;
482    }
483
484    /**
485     * Is set to force quote
486     *
487     * @return bool
488     */
489    public function isForceQuote(): bool
490    {
491        return $this->forceQuote;
492    }
493
494    /**
495     * Render the header value string
496     *
497     * @throws Exception
498     * @return string
499     */
500    public function render(?string $headerName = null): string
501    {
502        $value = $this->scheme . self::encodeValueForHeader((string)$this->value, $headerName);
503
504        if (count($this->parameters) > 0) {
505            $parameters = $this->getParametersAsString();
506            if (!str_ends_with($value, ' ')) {
507                $value .= $this->delimiter . ' ';
508            }
509            $value .= $parameters;
510        }
511
512        return $value;
513    }
514
515    /**
516     * Header names whose value is a structured address (mailbox-list or
517     * address-list per RFC 5322) - for these, the value is parsed via
518     * AddressList and each address's display name is RFC 2047-encoded
519     * independently, never the address itself. Groups and obs-* forms
520     * aren't parsed structurally - see AddressList::parse().
521     *
522     * @var array
523     */
524    protected const ADDRESS_HEADER_NAMES = [
525        'to', 'from', 'cc', 'bcc', 'reply-to', 'sender',
526        'resent-to', 'resent-from', 'resent-cc', 'resent-bcc', 'resent-sender',
527    ];
528
529    /**
530     * @param  string  $value
531     * @param  ?string $headerName
532     * @return string
533     */
534    protected static function encodeValueForHeader(string $value, ?string $headerName): string
535    {
536        if (($headerName !== null) && in_array(strtolower($headerName), self::ADDRESS_HEADER_NAMES, true)) {
537            return AddressList::parse($value)->render();
538        }
539        return EncodedWord::encode($value);
540    }
541
542    /**
543     * Render the header value string
544     *
545     * @return string
546     */
547    public function __toString(): string
548    {
549        return $this->render();
550    }
551
552}