1<?php
2
3namespace GuzzleHttp\Cookie;
4
5use Psr\Http\Message\RequestInterface;
6use Psr\Http\Message\ResponseInterface;
7
8/**
9 * Cookie jar that stores cookies as an array
10 */
11class CookieJar implements CookieJarInterface
12{
13    /**
14     * @var SetCookie[] Loaded cookie data
15     */
16    private $cookies = [];
17
18    /**
19     * @var bool
20     */
21    private $strictMode;
22
23    /**
24     * @param bool  $strictMode  Set to true to throw exceptions when invalid
25     *                           cookies are added to the cookie jar.
26     * @param array $cookieArray Array of SetCookie objects or a hash of
27     *                           arrays that can be used with the SetCookie
28     *                           constructor
29     */
30    public function __construct(bool $strictMode = false, array $cookieArray = [])
31    {
32        $this->strictMode = $strictMode;
33
34        foreach ($cookieArray as $cookie) {
35            if (!($cookie instanceof SetCookie)) {
36                $cookie = new SetCookie($cookie);
37            }
38            $this->setCookie($cookie);
39        }
40    }
41
42    /**
43     * Create a new Cookie jar from an associative array and domain.
44     *
45     * @param array  $cookies Cookies to create the jar from
46     * @param string $domain  Domain to set the cookies to
47     */
48    public static function fromArray(array $cookies, string $domain): self
49    {
50        $cookieJar = new self();
51        foreach ($cookies as $name => $value) {
52            $cookieJar->setCookie(new SetCookie([
53                'Domain' => $domain,
54                'Name' => $name,
55                'Value' => $value,
56                'Discard' => true,
57            ]));
58        }
59
60        return $cookieJar;
61    }
62
63    /**
64     * Evaluate if this cookie should be persisted to storage
65     * that survives between requests.
66     *
67     * @param SetCookie $cookie              Being evaluated.
68     * @param bool      $allowSessionCookies If we should persist session cookies
69     */
70    public static function shouldPersist(SetCookie $cookie, bool $allowSessionCookies = false): bool
71    {
72        if ($cookie->getExpires() || $allowSessionCookies) {
73            if (!$cookie->getDiscard()) {
74                return true;
75            }
76        }
77
78        return false;
79    }
80
81    /**
82     * Finds and returns the cookie based on the name
83     *
84     * @param string $name cookie name to search for
85     *
86     * @return SetCookie|null cookie that was found or null if not found
87     */
88    public function getCookieByName(string $name): ?SetCookie
89    {
90        foreach ($this->cookies as $cookie) {
91            if ($cookie->getName() !== null && \strcasecmp($cookie->getName(), $name) === 0) {
92                return $cookie;
93            }
94        }
95
96        return null;
97    }
98
99    public function toArray(): array
100    {
101        return \array_map(static function (SetCookie $cookie): array {
102            return $cookie->toArray();
103        }, $this->getIterator()->getArrayCopy());
104    }
105
106    public function clear(string $domain = null, string $path = null, string $name = null): void
107    {
108        if (!$domain) {
109            $this->cookies = [];
110
111            return;
112        } elseif (!$path) {
113            $this->cookies = \array_filter(
114                $this->cookies,
115                static function (SetCookie $cookie) use ($domain): bool {
116                    return !$cookie->matchesDomain($domain);
117                }
118            );
119        } elseif (!$name) {
120            $this->cookies = \array_filter(
121                $this->cookies,
122                static function (SetCookie $cookie) use ($path, $domain): bool {
123                    return !($cookie->matchesPath($path)
124                        && $cookie->matchesDomain($domain));
125                }
126            );
127        } else {
128            $this->cookies = \array_filter(
129                $this->cookies,
130                static function (SetCookie $cookie) use ($path, $domain, $name) {
131                    return !($cookie->getName() == $name
132                        && $cookie->matchesPath($path)
133                        && $cookie->matchesDomain($domain));
134                }
135            );
136        }
137    }
138
139    public function clearSessionCookies(): void
140    {
141        $this->cookies = \array_filter(
142            $this->cookies,
143            static function (SetCookie $cookie): bool {
144                return !$cookie->getDiscard() && $cookie->getExpires();
145            }
146        );
147    }
148
149    public function setCookie(SetCookie $cookie): bool
150    {
151        // If the name string is empty (but not 0), ignore the set-cookie
152        // string entirely.
153        $name = $cookie->getName();
154        if (!$name && $name !== '0') {
155            return false;
156        }
157
158        // Only allow cookies with set and valid domain, name, value
159        $result = $cookie->validate();
160        if ($result !== true) {
161            if ($this->strictMode) {
162                throw new \RuntimeException('Invalid cookie: '.$result);
163            }
164            $this->removeCookieIfEmpty($cookie);
165
166            return false;
167        }
168
169        // Resolve conflicts with previously set cookies
170        foreach ($this->cookies as $i => $c) {
171            // Two cookies are identical, when their path, and domain are
172            // identical.
173            if ($c->getPath() != $cookie->getPath()
174                || $c->getDomain() != $cookie->getDomain()
175                || $c->getName() != $cookie->getName()
176            ) {
177                continue;
178            }
179
180            // The previously set cookie is a discard cookie and this one is
181            // not so allow the new cookie to be set
182            if (!$cookie->getDiscard() && $c->getDiscard()) {
183                unset($this->cookies[$i]);
184                continue;
185            }
186
187            // If the new cookie's expiration is further into the future, then
188            // replace the old cookie
189            if ($cookie->getExpires() > $c->getExpires()) {
190                unset($this->cookies[$i]);
191                continue;
192            }
193
194            // If the value has changed, we better change it
195            if ($cookie->getValue() !== $c->getValue()) {
196                unset($this->cookies[$i]);
197                continue;
198            }
199
200            // The cookie exists, so no need to continue
201            return false;
202        }
203
204        $this->cookies[] = $cookie;
205
206        return true;
207    }
208
209    public function count(): int
210    {
211        return \count($this->cookies);
212    }
213
214    /**
215     * @return \ArrayIterator<int, SetCookie>
216     */
217    public function getIterator(): \ArrayIterator
218    {
219        return new \ArrayIterator(\array_values($this->cookies));
220    }
221
222    public function extractCookies(RequestInterface $request, ResponseInterface $response): void
223    {
224        if ($cookieHeader = $response->getHeader('Set-Cookie')) {
225            foreach ($cookieHeader as $cookie) {
226                $sc = SetCookie::fromString($cookie);
227                if (!$sc->getDomain()) {
228                    $sc->setDomain($request->getUri()->getHost());
229                }
230                if (0 !== \strpos($sc->getPath(), '/')) {
231                    $sc->setPath($this->getCookiePathFromRequest($request));
232                }
233                if (!$sc->matchesDomain($request->getUri()->getHost())) {
234                    continue;
235                }
236                // Note: At this point `$sc->getDomain()` being a public suffix should
237                // be rejected, but we don't want to pull in the full PSL dependency.
238                $this->setCookie($sc);
239            }
240        }
241    }
242
243    /**
244     * Computes cookie path following RFC 6265 section 5.1.4
245     *
246     * @see https://datatracker.ietf.org/doc/html/rfc6265#section-5.1.4
247     */
248    private function getCookiePathFromRequest(RequestInterface $request): string
249    {
250        $uriPath = $request->getUri()->getPath();
251        if ('' === $uriPath) {
252            return '/';
253        }
254        if (0 !== \strpos($uriPath, '/')) {
255            return '/';
256        }
257        if ('/' === $uriPath) {
258            return '/';
259        }
260        $lastSlashPos = \strrpos($uriPath, '/');
261        if (0 === $lastSlashPos || false === $lastSlashPos) {
262            return '/';
263        }
264
265        return \substr($uriPath, 0, $lastSlashPos);
266    }
267
268    public function withCookieHeader(RequestInterface $request): RequestInterface
269    {
270        $values = [];
271        $uri = $request->getUri();
272        $scheme = $uri->getScheme();
273        $host = $uri->getHost();
274        $path = $uri->getPath() ?: '/';
275
276        foreach ($this->cookies as $cookie) {
277            if ($cookie->matchesPath($path)
278                && $cookie->matchesDomain($host)
279                && !$cookie->isExpired()
280                && (!$cookie->getSecure() || $scheme === 'https')
281            ) {
282                $values[] = $cookie->getName().'='
283                    .$cookie->getValue();
284            }
285        }
286
287        return $values
288            ? $request->withHeader('Cookie', \implode('; ', $values))
289            : $request;
290    }
291
292    /**
293     * If a cookie already exists and the server asks to set it again with a
294     * null value, the cookie must be deleted.
295     */
296    private function removeCookieIfEmpty(SetCookie $cookie): void
297    {
298        $cookieValue = $cookie->getValue();
299        if ($cookieValue === null || $cookieValue === '') {
300            $this->clear(
301                $cookie->getDomain(),
302                $cookie->getPath(),
303                $cookie->getName()
304            );
305        }
306    }
307}
308