| 1 | <?php |
| 2 | |
| 3 | /* |
| 4 | * PHP-Cookie (https://github.com/delight-im/PHP-Cookie) |
| 5 | * Copyright (c) delight.im (https://www.delight.im/) |
| 6 | * Licensed under the MIT License (https://opensource.org/licenses/MIT) |
| 7 | */ |
| 8 | |
| 9 | // Unsupported by old versions of PHP |
| 10 | // namespace Delight\Cookie; |
| 11 | |
| 12 | /** |
| 13 | * Modern cookie management for PHP |
| 14 | * |
| 15 | * Cookies are a mechanism for storing data in the client's web browser and identifying returning clients on subsequent visits |
| 16 | * |
| 17 | * All cookies that have successfully been set will automatically be included in the global `$_COOKIE` array with future requests |
| 18 | * |
| 19 | * You can set a new cookie using the static method `Cookie::setcookie(...)` which is compatible to PHP's built-in `setcookie(...)` function |
| 20 | * |
| 21 | * Alternatively, you can construct an instance of this class, set properties individually, and finally call `save()` |
| 22 | * |
| 23 | * Note that cookies must always be set before the HTTP headers are sent to the client, i.e. before the actual output starts |
| 24 | */ |
| 25 | final class Cookie { |
| 26 | |
| 27 | /** @var string name prefix indicating that the cookie must be from a secure origin (i.e. HTTPS) and the 'secure' attribute must be set */ |
| 28 | const PREFIX_SECURE = '__Secure-'; |
| 29 | /** @var string name prefix indicating that the 'domain' attribute must *not* be set, the 'path' attribute must be '/' and the effects of {@see PREFIX_SECURE} apply as well */ |
| 30 | const PREFIX_HOST = '__Host-'; |
| 31 | const HEADER_PREFIX = 'Set-Cookie: '; |
| 32 | const SAME_SITE_RESTRICTION_LAX = 'Lax'; |
| 33 | const SAME_SITE_RESTRICTION_STRICT = 'Strict'; |
| 34 | |
| 35 | /** @var string the name of the cookie which is also the key for future accesses via `$_COOKIE[...]` */ |
| 36 | private $name; |
| 37 | /** @var mixed|null the value of the cookie that will be stored on the client's machine */ |
| 38 | private $value; |
| 39 | /** @var int the Unix timestamp indicating the time that the cookie will expire at, i.e. usually `time() + $seconds` */ |
| 40 | private $expiryTime; |
| 41 | /** @var string the path on the server that the cookie will be valid for (including all sub-directories), e.g. an empty string for the current directory or `/` for the root directory */ |
| 42 | private $path; |
| 43 | /** @var string|null the domain that the cookie will be valid for (including subdomains) or `null` for the current host (excluding subdomains) */ |
| 44 | private $domain; |
| 45 | /** @var bool indicates that the cookie should be accessible through the HTTP protocol only and not through scripting languages */ |
| 46 | private $httpOnly; |
| 47 | /** @var bool indicates that the cookie should be sent back by the client over secure HTTPS connections only */ |
| 48 | private $secureOnly; |
| 49 | /** @var string|null indicates that the cookie should not be sent along with cross-site requests (either `null`, `Lax` or `Strict`) */ |
| 50 | private $sameSiteRestriction; |
| 51 | |
| 52 | /** |
| 53 | * Prepares a new cookie |
| 54 | * |
| 55 | * @param string $name the name of the cookie which is also the key for future accesses via `$_COOKIE[...]` |
| 56 | */ |
| 57 | public function __construct($name) { |
| 58 | $this->name = $name; |
| 59 | $this->value = null; |
| 60 | $this->expiryTime = 0; |
| 61 | $this->path = '/'; |
| 62 | $this->domain = null; |
| 63 | $this->httpOnly = true; |
| 64 | $this->secureOnly = false; |
| 65 | $this->sameSiteRestriction = self::SAME_SITE_RESTRICTION_LAX; |
| 66 | } |
| 67 | |
| 68 | /** |
| 69 | * Returns the name of the cookie |
| 70 | * |
| 71 | * @return string the name of the cookie which is also the key for future accesses via `$_COOKIE[...]` |
| 72 | */ |
| 73 | public function getName() { |
| 74 | return $this->name; |
| 75 | } |
| 76 | |
| 77 | /** |
| 78 | * Returns the value of the cookie |
| 79 | * |
| 80 | * @return mixed|null the value of the cookie that will be stored on the client's machine |
| 81 | */ |
| 82 | public function getValue() { |
| 83 | return $this->value; |
| 84 | } |
| 85 | |
| 86 | /** |
| 87 | * Sets the value for the cookie |
| 88 | * |
| 89 | * @param mixed|null $value the value of the cookie that will be stored on the client's machine |
| 90 | * @return static this instance for chaining |
| 91 | */ |
| 92 | public function setValue($value) { |
| 93 | $this->value = $value; |
| 94 | |
| 95 | return $this; |
| 96 | } |
| 97 | |
| 98 | /** |
| 99 | * Returns the expiry time of the cookie |
| 100 | * |
| 101 | * @return int the Unix timestamp indicating the time that the cookie will expire at, i.e. usually `time() + $seconds` |
| 102 | */ |
| 103 | public function getExpiryTime() { |
| 104 | return $this->expiryTime; |
| 105 | } |
| 106 | |
| 107 | /** |
| 108 | * Sets the expiry time for the cookie |
| 109 | * |
| 110 | * @param int $expiryTime the Unix timestamp indicating the time that the cookie will expire at, i.e. usually `time() + $seconds` |
| 111 | * @return static this instance for chaining |
| 112 | */ |
| 113 | public function setExpiryTime($expiryTime) { |
| 114 | $this->expiryTime = $expiryTime; |
| 115 | |
| 116 | return $this; |
| 117 | } |
| 118 | |
| 119 | /** |
| 120 | * Returns the maximum age of the cookie (i.e. the remaining lifetime) |
| 121 | * |
| 122 | * @return int the maximum age of the cookie in seconds |
| 123 | */ |
| 124 | public function getMaxAge() { |
| 125 | return $this->expiryTime - \time(); |
| 126 | } |
| 127 | |
| 128 | /** |
| 129 | * Sets the expiry time for the cookie based on the specified maximum age (i.e. the remaining lifetime) |
| 130 | * |
| 131 | * @param int $maxAge the maximum age for the cookie in seconds |
| 132 | * @return static this instance for chaining |
| 133 | */ |
| 134 | public function setMaxAge($maxAge) { |
| 135 | $this->expiryTime = \time() + $maxAge; |
| 136 | |
| 137 | return $this; |
| 138 | } |
| 139 | |
| 140 | /** |
| 141 | * Returns the path of the cookie |
| 142 | * |
| 143 | * @return string the path on the server that the cookie will be valid for (including all sub-directories), e.g. an empty string for the current directory or `/` for the root directory |
| 144 | */ |
| 145 | public function getPath() { |
| 146 | return $this->path; |
| 147 | } |
| 148 | |
| 149 | /** |
| 150 | * Sets the path for the cookie |
| 151 | * |
| 152 | * @param string $path the path on the server that the cookie will be valid for (including all sub-directories), e.g. an empty string for the current directory or `/` for the root directory |
| 153 | * @return static this instance for chaining |
| 154 | */ |
| 155 | public function setPath($path) { |
| 156 | $this->path = $path; |
| 157 | |
| 158 | return $this; |
| 159 | } |
| 160 | |
| 161 | /** |
| 162 | * Returns the domain of the cookie |
| 163 | * |
| 164 | * @return string|null the domain that the cookie will be valid for (including subdomains) or `null` for the current host (excluding subdomains) |
| 165 | */ |
| 166 | public function getDomain() { |
| 167 | return $this->domain; |
| 168 | } |
| 169 | |
| 170 | /** |
| 171 | * Sets the domain for the cookie |
| 172 | * |
| 173 | * @param string|null $domain the domain that the cookie will be valid for (including subdomains) or `null` for the current host (excluding subdomains) |
| 174 | * @return static this instance for chaining |
| 175 | */ |
| 176 | public function setDomain($domain = null) { |
| 177 | $this->domain = self::normalizeDomain($domain); |
| 178 | |
| 179 | return $this; |
| 180 | } |
| 181 | |
| 182 | /** |
| 183 | * Returns whether the cookie should be accessible through HTTP only |
| 184 | * |
| 185 | * @return bool whether the cookie should be accessible through the HTTP protocol only and not through scripting languages |
| 186 | */ |
| 187 | public function isHttpOnly() { |
| 188 | return $this->httpOnly; |
| 189 | } |
| 190 | |
| 191 | /** |
| 192 | * Sets whether the cookie should be accessible through HTTP only |
| 193 | * |
| 194 | * @param bool $httpOnly indicates that the cookie should be accessible through the HTTP protocol only and not through scripting languages |
| 195 | * @return static this instance for chaining |
| 196 | */ |
| 197 | public function setHttpOnly($httpOnly) { |
| 198 | $this->httpOnly = $httpOnly; |
| 199 | |
| 200 | return $this; |
| 201 | } |
| 202 | |
| 203 | /** |
| 204 | * Returns whether the cookie should be sent over HTTPS only |
| 205 | * |
| 206 | * @return bool whether the cookie should be sent back by the client over secure HTTPS connections only |
| 207 | */ |
| 208 | public function isSecureOnly() { |
| 209 | return $this->secureOnly; |
| 210 | } |
| 211 | |
| 212 | /** |
| 213 | * Sets whether the cookie should be sent over HTTPS only |
| 214 | * |
| 215 | * @param bool $secureOnly indicates that the cookie should be sent back by the client over secure HTTPS connections only |
| 216 | * @return static this instance for chaining |
| 217 | */ |
| 218 | public function setSecureOnly($secureOnly) { |
| 219 | $this->secureOnly = $secureOnly; |
| 220 | |
| 221 | return $this; |
| 222 | } |
| 223 | |
| 224 | /** |
| 225 | * Returns the same-site restriction of the cookie |
| 226 | * |
| 227 | * @return string|null whether the cookie should not be sent along with cross-site requests (either `null`, `Lax` or `Strict`) |
| 228 | */ |
| 229 | public function getSameSiteRestriction() { |
| 230 | return $this->sameSiteRestriction; |
| 231 | } |
| 232 | |
| 233 | /** |
| 234 | * Sets the same-site restriction for the cookie |
| 235 | * |
| 236 | * @param string|null $sameSiteRestriction indicates that the cookie should not be sent along with cross-site requests (either `null`, `Lax` or `Strict`) |
| 237 | * @return static this instance for chaining |
| 238 | */ |
| 239 | public function setSameSiteRestriction($sameSiteRestriction) { |
| 240 | $this->sameSiteRestriction = $sameSiteRestriction; |
| 241 | |
| 242 | return $this; |
| 243 | } |
| 244 | |
| 245 | /** |
| 246 | * Saves the cookie |
| 247 | * |
| 248 | * @return bool whether the cookie header has successfully been sent (and will *probably* cause the client to set the cookie) |
| 249 | */ |
| 250 | public function save() { |
| 251 | return self::addHttpHeader((string) $this); |
| 252 | } |
| 253 | |
| 254 | /** |
| 255 | * Deletes the cookie |
| 256 | * |
| 257 | * @return bool whether the cookie header has successfully been sent (and will *probably* cause the client to delete the cookie) |
| 258 | */ |
| 259 | public function delete() { |
| 260 | // create a temporary copy of this cookie so that it isn't corrupted |
| 261 | $copiedCookie = clone $this; |
| 262 | // set the copied cookie's value to an empty string which internally sets the required options for a deletion |
| 263 | $copiedCookie->setValue(''); |
| 264 | |
| 265 | // save the copied "deletion" cookie |
| 266 | return $copiedCookie->save(); |
| 267 | } |
| 268 | |
| 269 | public function __toString() { |
| 270 | return self::buildCookieHeader($this->name, $this->value, $this->expiryTime, $this->path, $this->domain, $this->secureOnly, $this->httpOnly, $this->sameSiteRestriction); |
| 271 | } |
| 272 | |
| 273 | /** |
| 274 | * Sets a new cookie in a way compatible to PHP's `setcookie(...)` function |
| 275 | * |
| 276 | * @param string $name the name of the cookie which is also the key for future accesses via `$_COOKIE[...]` |
| 277 | * @param mixed|null $value the value of the cookie that will be stored on the client's machine |
| 278 | * @param int $expiryTime the Unix timestamp indicating the time that the cookie will expire at, i.e. usually `time() + $seconds` |
| 279 | * @param string|null $path the path on the server that the cookie will be valid for (including all sub-directories), e.g. an empty string for the current directory or `/` for the root directory |
| 280 | * @param string|null $domain the domain that the cookie will be valid for (including subdomains) or `null` for the current host (excluding subdomains) |
| 281 | * @param bool $secureOnly indicates that the cookie should be sent back by the client over secure HTTPS connections only |
| 282 | * @param bool $httpOnly indicates that the cookie should be accessible through the HTTP protocol only and not through scripting languages |
| 283 | * @param string|null $sameSiteRestriction indicates that the cookie should not be sent along with cross-site requests (either `null`, `Lax` or `Strict`) |
| 284 | * @return bool whether the cookie header has successfully been sent (and will *probably* cause the client to set the cookie) |
| 285 | */ |
| 286 | public static function setcookie($name, $value = null, $expiryTime = 0, $path = null, $domain = null, $secureOnly = false, $httpOnly = false, $sameSiteRestriction = null) { |
| 287 | return self::addHttpHeader( |
| 288 | self::buildCookieHeader($name, $value, $expiryTime, $path, $domain, $secureOnly, $httpOnly, $sameSiteRestriction) |
| 289 | ); |
| 290 | } |
| 291 | |
| 292 | /** |
| 293 | * Builds the HTTP header that can be used to set a cookie with the specified options |
| 294 | * |
| 295 | * @param string $name the name of the cookie which is also the key for future accesses via `$_COOKIE[...]` |
| 296 | * @param mixed|null $value the value of the cookie that will be stored on the client's machine |
| 297 | * @param int $expiryTime the Unix timestamp indicating the time that the cookie will expire at, i.e. usually `time() + $seconds` |
| 298 | * @param string|null $path the path on the server that the cookie will be valid for (including all sub-directories), e.g. an empty string for the current directory or `/` for the root directory |
| 299 | * @param string|null $domain the domain that the cookie will be valid for (including subdomains) or `null` for the current host (excluding subdomains) |
| 300 | * @param bool $secureOnly indicates that the cookie should be sent back by the client over secure HTTPS connections only |
| 301 | * @param bool $httpOnly indicates that the cookie should be accessible through the HTTP protocol only and not through scripting languages |
| 302 | * @param string|null $sameSiteRestriction indicates that the cookie should not be sent along with cross-site requests (either `null`, `Lax` or `Strict`) |
| 303 | * @return string the HTTP header |
| 304 | */ |
| 305 | public static function buildCookieHeader($name, $value = null, $expiryTime = 0, $path = null, $domain = null, $secureOnly = false, $httpOnly = false, $sameSiteRestriction = null) { |
| 306 | if (self::isNameValid($name)) { |
| 307 | $name = (string) $name; |
| 308 | } |
| 309 | else { |
| 310 | return null; |
| 311 | } |
| 312 | |
| 313 | if (self::isExpiryTimeValid($expiryTime)) { |
| 314 | $expiryTime = (int) $expiryTime; |
| 315 | } |
| 316 | else { |
| 317 | return null; |
| 318 | } |
| 319 | |
| 320 | $forceShowExpiry = false; |
| 321 | |
| 322 | if (\is_null($value) || $value === false || $value === '') { |
| 323 | $value = 'deleted'; |
| 324 | $expiryTime = 0; |
| 325 | $forceShowExpiry = true; |
| 326 | } |
| 327 | |
| 328 | $maxAgeStr = self::formatMaxAge($expiryTime, $forceShowExpiry); |
| 329 | $expiryTimeStr = self::formatExpiryTime($expiryTime, $forceShowExpiry); |
| 330 | |
| 331 | $headerStr = self::HEADER_PREFIX . $name . '=' . \urlencode($value); |
| 332 | |
| 333 | if (!\is_null($expiryTimeStr)) { |
| 334 | $headerStr .= '; expires=' . $expiryTimeStr; |
| 335 | } |
| 336 | |
| 337 | // The `Max-Age` property is supported on PHP 5.5+ only (https://bugs.php.net/bug.php?id=23955). |
| 338 | if (\PHP_VERSION_ID >= 50500) { |
| 339 | if (!\is_null($maxAgeStr)) { |
| 340 | $headerStr .= '; Max-Age=' . $maxAgeStr; |
| 341 | } |
| 342 | } |
| 343 | |
| 344 | if (!empty($path) || $path === 0) { |
| 345 | $headerStr .= '; path=' . $path; |
| 346 | } |
| 347 | |
| 348 | if (!empty($domain) || $domain === 0) { |
| 349 | $headerStr .= '; domain=' . $domain; |
| 350 | } |
| 351 | |
| 352 | if ($secureOnly) { |
| 353 | $headerStr .= '; secure'; |
| 354 | } |
| 355 | |
| 356 | if ($httpOnly) { |
| 357 | $headerStr .= '; httponly'; |
| 358 | } |
| 359 | |
| 360 | if ($sameSiteRestriction === self::SAME_SITE_RESTRICTION_LAX) { |
| 361 | $headerStr .= '; SameSite=Lax'; |
| 362 | } |
| 363 | elseif ($sameSiteRestriction === self::SAME_SITE_RESTRICTION_STRICT) { |
| 364 | $headerStr .= '; SameSite=Strict'; |
| 365 | } |
| 366 | |
| 367 | return $headerStr; |
| 368 | } |
| 369 | |
| 370 | /** |
| 371 | * Parses the given cookie header and returns an equivalent cookie instance |
| 372 | * |
| 373 | * @param string $cookieHeader the cookie header to parse |
| 374 | * @return \Delight\Cookie\Cookie|null the cookie instance or `null` |
| 375 | */ |
| 376 | public static function parse($cookieHeader) { |
| 377 | if (empty($cookieHeader)) { |
| 378 | return null; |
| 379 | } |
| 380 | |
| 381 | if (\preg_match('/^' . self::HEADER_PREFIX . '(.*?)=(.*?)(?:; (.*?))?$/i', $cookieHeader, $matches)) { |
| 382 | if (\count($matches) >= 4) { |
| 383 | $attributes = \explode('; ', $matches[3]); |
| 384 | |
| 385 | $cookie = new self($matches[1]); |
| 386 | $cookie->setPath(null); |
| 387 | $cookie->setHttpOnly(false); |
| 388 | $cookie->setValue( |
| 389 | \urldecode($matches[2]) |
| 390 | ); |
| 391 | |
| 392 | foreach ($attributes as $attribute) { |
| 393 | if (\strcasecmp($attribute, 'HttpOnly') === 0) { |
| 394 | $cookie->setHttpOnly(true); |
| 395 | } |
| 396 | elseif (\strcasecmp($attribute, 'Secure') === 0) { |
| 397 | $cookie->setSecureOnly(true); |
| 398 | } |
| 399 | elseif (\stripos($attribute, 'Expires=') === 0) { |
| 400 | $cookie->setExpiryTime((int) \strtotime(\substr($attribute, 8))); |
| 401 | } |
| 402 | elseif (\stripos($attribute, 'Domain=') === 0) { |
| 403 | $cookie->setDomain(\substr($attribute, 7)); |
| 404 | } |
| 405 | elseif (\stripos($attribute, 'Path=') === 0) { |
| 406 | $cookie->setPath(\substr($attribute, 5)); |
| 407 | } |
| 408 | } |
| 409 | |
| 410 | return $cookie; |
| 411 | } |
| 412 | else { |
| 413 | return null; |
| 414 | } |
| 415 | } |
| 416 | else { |
| 417 | return null; |
| 418 | } |
| 419 | } |
| 420 | |
| 421 | /** |
| 422 | * Checks whether a cookie with the specified name exists |
| 423 | * |
| 424 | * @param string $name the name of the cookie to check |
| 425 | * @return bool whether there is a cookie with the specified name |
| 426 | */ |
| 427 | public static function exists($name) { |
| 428 | return isset($_COOKIE[$name]); |
| 429 | } |
| 430 | |
| 431 | /** |
| 432 | * Returns the value from the requested cookie or, if not found, the specified default value |
| 433 | * |
| 434 | * @param string $name the name of the cookie to retrieve the value from |
| 435 | * @param mixed $defaultValue the default value to return if the requested cookie cannot be found |
| 436 | * @return mixed the value from the requested cookie or the default value |
| 437 | */ |
| 438 | public static function get($name, $defaultValue = null) { |
| 439 | if (isset($_COOKIE[$name])) { |
| 440 | return $_COOKIE[$name]; |
| 441 | } |
| 442 | else { |
| 443 | return $defaultValue; |
| 444 | } |
| 445 | } |
| 446 | |
| 447 | private static function isNameValid($name) { |
| 448 | $name = (string) $name; |
| 449 | |
| 450 | // The name of a cookie must not be empty on PHP 7+ (https://bugs.php.net/bug.php?id=69523). |
| 451 | if ($name !== '' || \PHP_VERSION_ID < 70000) { |
| 452 | if (!\preg_match('/[=,; \\t\\r\\n\\013\\014]/', $name)) { |
| 453 | return true; |
| 454 | } |
| 455 | } |
| 456 | |
| 457 | return false; |
| 458 | } |
| 459 | |
| 460 | private static function isExpiryTimeValid($expiryTime) { |
| 461 | return \is_numeric($expiryTime) || \is_null($expiryTime) || \is_bool($expiryTime); |
| 462 | } |
| 463 | |
| 464 | private static function calculateMaxAge($expiryTime) { |
| 465 | if ($expiryTime === 0) { |
| 466 | return 0; |
| 467 | } |
| 468 | else { |
| 469 | $maxAge = $expiryTime - \time(); |
| 470 | |
| 471 | // The value of the `Max-Age` property must not be negative on PHP 7.0.19+ (< 7.1) and |
| 472 | // PHP 7.1.5+ (https://bugs.php.net/bug.php?id=72071). |
| 473 | if ((\PHP_VERSION_ID >= 70019 && \PHP_VERSION_ID < 70100) || \PHP_VERSION_ID >= 70105) { |
| 474 | if ($maxAge < 0) { |
| 475 | $maxAge = 0; |
| 476 | } |
| 477 | } |
| 478 | |
| 479 | return $maxAge; |
| 480 | } |
| 481 | } |
| 482 | |
| 483 | private static function formatExpiryTime($expiryTime, $forceShow = false) { |
| 484 | if ($expiryTime > 0 || $forceShow) { |
| 485 | if ($forceShow) { |
| 486 | $expiryTime = 1; |
| 487 | } |
| 488 | |
| 489 | return \gmdate('D, d-M-Y H:i:s T', $expiryTime); |
| 490 | } |
| 491 | else { |
| 492 | return null; |
| 493 | } |
| 494 | } |
| 495 | |
| 496 | private static function formatMaxAge($expiryTime, $forceShow = false) { |
| 497 | if ($expiryTime > 0 || $forceShow) { |
| 498 | return (string) self::calculateMaxAge($expiryTime); |
| 499 | } |
| 500 | else { |
| 501 | return null; |
| 502 | } |
| 503 | } |
| 504 | |
| 505 | private static function normalizeDomain($domain = null) { |
| 506 | // make sure that the domain is a string |
| 507 | $domain = (string) $domain; |
| 508 | |
| 509 | // if the cookie should be valid for the current host only |
| 510 | if ($domain === '') { |
| 511 | // no need for further normalization |
| 512 | return null; |
| 513 | } |
| 514 | |
| 515 | // if the provided domain is actually an IP address |
| 516 | if (\filter_var($domain, \FILTER_VALIDATE_IP) !== false) { |
| 517 | // let the cookie be valid for the current host |
| 518 | return null; |
| 519 | } |
| 520 | |
| 521 | // for local hostnames (which either have no dot at all or a leading dot only) |
| 522 | if (\strpos($domain, '.') === false || \strrpos($domain, '.') === 0) { |
| 523 | // let the cookie be valid for the current host while ensuring maximum compatibility |
| 524 | return null; |
| 525 | } |
| 526 | |
| 527 | // unless the domain already starts with a dot |
| 528 | if ($domain[0] !== '.') { |
| 529 | // prepend a dot for maximum compatibility (e.g. with RFC 2109) |
| 530 | $domain = '.' . $domain; |
| 531 | } |
| 532 | |
| 533 | // return the normalized domain |
| 534 | return $domain; |
| 535 | } |
| 536 | |
| 537 | private static function addHttpHeader($header) { |
| 538 | if (!\headers_sent()) { |
| 539 | if (!empty($header)) { |
| 540 | \header($header, false); |
| 541 | |
| 542 | return true; |
| 543 | } |
| 544 | } |
| 545 | |
| 546 | return false; |
| 547 | } |
| 548 | |
| 549 | } |