Initial Commit
This commit is contained in:
344
dependencies/amphp/http-client/src/Connection/Internal/Http1Parser.php
vendored
Normal file
344
dependencies/amphp/http-client/src/Connection/Internal/Http1Parser.php
vendored
Normal file
@@ -0,0 +1,344 @@
|
||||
<?php
|
||||
|
||||
namespace WP_Ultimo\Dependencies\Amp\Http\Client\Connection\Internal;
|
||||
|
||||
use WP_Ultimo\Dependencies\Amp\ByteStream\InMemoryStream;
|
||||
use WP_Ultimo\Dependencies\Amp\Http\Client\Internal\ForbidCloning;
|
||||
use WP_Ultimo\Dependencies\Amp\Http\Client\Internal\ForbidSerialization;
|
||||
use WP_Ultimo\Dependencies\Amp\Http\Client\ParseException;
|
||||
use WP_Ultimo\Dependencies\Amp\Http\Client\Request;
|
||||
use WP_Ultimo\Dependencies\Amp\Http\Client\Response;
|
||||
use WP_Ultimo\Dependencies\Amp\Http\InvalidHeaderException;
|
||||
use WP_Ultimo\Dependencies\Amp\Http\Rfc7230;
|
||||
use WP_Ultimo\Dependencies\Amp\Http\Status;
|
||||
/** @internal */
|
||||
final class Http1Parser
|
||||
{
|
||||
use ForbidSerialization;
|
||||
use ForbidCloning;
|
||||
private const STATUS_LINE_PATTERN = "#^\n HTTP/(?P<protocol>\\d+\\.\\d+)[ \t]+\n (?P<status>[1-9]\\d\\d)[ \t]*\n (?P<reason>[^\x01-\x08\x10-\x19]*)\n \$#ix";
|
||||
public const AWAITING_HEADERS = 0;
|
||||
public const BODY_IDENTITY = 1;
|
||||
public const BODY_IDENTITY_EOF = 2;
|
||||
public const BODY_CHUNKS = 3;
|
||||
public const TRAILERS_START = 4;
|
||||
public const TRAILERS = 5;
|
||||
/** @var int */
|
||||
private $state = self::AWAITING_HEADERS;
|
||||
/** @var string */
|
||||
private $buffer = '';
|
||||
/** @var string|null */
|
||||
private $protocol;
|
||||
/** @var int|null */
|
||||
private $statusCode;
|
||||
/** @var string|null */
|
||||
private $statusReason;
|
||||
/** @var string[][] */
|
||||
private $headers = [];
|
||||
/** @var int|null */
|
||||
private $remainingBodyBytes;
|
||||
/** @var int */
|
||||
private $bodyBytesConsumed = 0;
|
||||
/** @var bool */
|
||||
private $chunkedEncoding = \false;
|
||||
/** @var int|null */
|
||||
private $chunkLengthRemaining;
|
||||
/** @var bool */
|
||||
private $complete = \false;
|
||||
/** @var Request */
|
||||
private $request;
|
||||
/** @var int */
|
||||
private $maxHeaderBytes;
|
||||
/** @var int */
|
||||
private $maxBodyBytes;
|
||||
/** @var callable */
|
||||
private $bodyDataCallback;
|
||||
/** @var callable */
|
||||
private $trailersCallback;
|
||||
public function __construct(Request $request, callable $bodyDataCallback, callable $trailersCallback)
|
||||
{
|
||||
$this->request = $request;
|
||||
$this->bodyDataCallback = $bodyDataCallback;
|
||||
$this->trailersCallback = $trailersCallback;
|
||||
$this->maxHeaderBytes = $request->getHeaderSizeLimit();
|
||||
$this->maxBodyBytes = $request->getBodySizeLimit();
|
||||
}
|
||||
public function getBuffer() : string
|
||||
{
|
||||
return $this->buffer;
|
||||
}
|
||||
public function getState() : int
|
||||
{
|
||||
return $this->state;
|
||||
}
|
||||
public function buffer(string $data) : void
|
||||
{
|
||||
$this->buffer .= $data;
|
||||
}
|
||||
/**
|
||||
* @param string|null $data
|
||||
*
|
||||
* @return Response|null
|
||||
*
|
||||
* @throws ParseException
|
||||
*/
|
||||
public function parse(string $data = null) : ?Response
|
||||
{
|
||||
if ($data !== null) {
|
||||
$this->buffer .= $data;
|
||||
}
|
||||
if ($this->buffer === '') {
|
||||
return null;
|
||||
}
|
||||
if ($this->complete) {
|
||||
throw new ParseException('Can\'t continue parsing, response is already complete', Status::BAD_REQUEST);
|
||||
}
|
||||
switch ($this->state) {
|
||||
case self::AWAITING_HEADERS:
|
||||
goto headers;
|
||||
case self::BODY_IDENTITY:
|
||||
goto body_identity;
|
||||
case self::BODY_IDENTITY_EOF:
|
||||
goto body_identity_eof;
|
||||
case self::BODY_CHUNKS:
|
||||
goto body_chunks;
|
||||
case self::TRAILERS_START:
|
||||
goto trailers_start;
|
||||
case self::TRAILERS:
|
||||
goto trailers;
|
||||
}
|
||||
headers:
|
||||
$startLineAndHeaders = $this->shiftHeadersFromBuffer();
|
||||
if ($startLineAndHeaders === null) {
|
||||
return null;
|
||||
}
|
||||
$startLineEndPos = \strpos($startLineAndHeaders, "\r\n");
|
||||
\assert($startLineEndPos !== \false);
|
||||
$startLine = \substr($startLineAndHeaders, 0, $startLineEndPos);
|
||||
$rawHeaders = \substr($startLineAndHeaders, $startLineEndPos + 2);
|
||||
if (\preg_match(self::STATUS_LINE_PATTERN, $startLine, $match)) {
|
||||
$this->protocol = $match['protocol'];
|
||||
$this->statusCode = (int) $match['status'];
|
||||
$this->statusReason = \trim($match['reason']);
|
||||
} else {
|
||||
throw new ParseException('Invalid status line: ' . $startLine, Status::BAD_REQUEST);
|
||||
}
|
||||
if ($rawHeaders !== '') {
|
||||
$this->headers = $this->parseRawHeaders($rawHeaders);
|
||||
} else {
|
||||
$this->headers = [];
|
||||
}
|
||||
$requestMethod = $this->request->getMethod();
|
||||
$skipBody = $this->statusCode < Status::OK || $this->statusCode === Status::NOT_MODIFIED || $this->statusCode === Status::NO_CONTENT || $requestMethod === 'HEAD' || $requestMethod === 'CONNECT';
|
||||
if ($skipBody) {
|
||||
$this->complete = \true;
|
||||
} elseif ($this->chunkedEncoding) {
|
||||
$this->state = self::BODY_CHUNKS;
|
||||
} elseif ($this->remainingBodyBytes === null) {
|
||||
$this->state = self::BODY_IDENTITY_EOF;
|
||||
} elseif ($this->remainingBodyBytes > 0) {
|
||||
$this->state = self::BODY_IDENTITY;
|
||||
} else {
|
||||
$this->complete = \true;
|
||||
}
|
||||
$response = new Response($this->protocol, $this->statusCode, $this->statusReason, [], new InMemoryStream(), $this->request);
|
||||
foreach ($this->headers as [$key, $value]) {
|
||||
$response->addHeader($key, $value);
|
||||
}
|
||||
return $response;
|
||||
body_identity:
|
||||
$bufferDataSize = \strlen($this->buffer);
|
||||
if ($bufferDataSize <= $this->remainingBodyBytes) {
|
||||
$chunk = $this->buffer;
|
||||
$this->buffer = '';
|
||||
$this->remainingBodyBytes -= $bufferDataSize;
|
||||
$this->addToBody($chunk);
|
||||
if ($this->remainingBodyBytes === 0) {
|
||||
$this->complete = \true;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
$bodyData = \substr($this->buffer, 0, $this->remainingBodyBytes);
|
||||
$this->addToBody($bodyData);
|
||||
$this->buffer = \substr($this->buffer, $this->remainingBodyBytes);
|
||||
$this->remainingBodyBytes = 0;
|
||||
goto complete;
|
||||
body_identity_eof:
|
||||
$this->addToBody($this->buffer);
|
||||
$this->buffer = '';
|
||||
return null;
|
||||
body_chunks:
|
||||
if ($this->parseChunkedBody()) {
|
||||
$this->state = self::TRAILERS_START;
|
||||
goto trailers_start;
|
||||
}
|
||||
return null;
|
||||
trailers_start:
|
||||
$firstTwoBytes = \substr($this->buffer, 0, 2);
|
||||
if ($firstTwoBytes === "" || $firstTwoBytes === "\r") {
|
||||
return null;
|
||||
}
|
||||
if ($firstTwoBytes === "\r\n") {
|
||||
$this->buffer = \substr($this->buffer, 2);
|
||||
goto complete;
|
||||
}
|
||||
$this->state = self::TRAILERS;
|
||||
goto trailers;
|
||||
trailers:
|
||||
$trailers = $this->shiftHeadersFromBuffer();
|
||||
if ($trailers === null) {
|
||||
return null;
|
||||
}
|
||||
$this->parseTrailers($trailers);
|
||||
goto complete;
|
||||
complete:
|
||||
$this->complete = \true;
|
||||
return null;
|
||||
}
|
||||
public function isComplete() : bool
|
||||
{
|
||||
return $this->complete;
|
||||
}
|
||||
/**
|
||||
* @return string|null
|
||||
*
|
||||
* @throws ParseException
|
||||
*/
|
||||
private function shiftHeadersFromBuffer() : ?string
|
||||
{
|
||||
$this->buffer = \ltrim($this->buffer, "\r\n");
|
||||
if ($headersSize = \strpos($this->buffer, "\r\n\r\n")) {
|
||||
$headers = \substr($this->buffer, 0, $headersSize + 2);
|
||||
$this->buffer = \substr($this->buffer, $headersSize + 4);
|
||||
} else {
|
||||
$headersSize = \strlen($this->buffer);
|
||||
$headers = null;
|
||||
}
|
||||
if ($this->maxHeaderBytes > 0 && $headersSize > $this->maxHeaderBytes) {
|
||||
throw new ParseException("Configured header size exceeded: {$headersSize} bytes received, while the configured limit is {$this->maxHeaderBytes} bytes", Status::REQUEST_HEADER_FIELDS_TOO_LARGE);
|
||||
}
|
||||
return $headers;
|
||||
}
|
||||
/**
|
||||
* @param string $rawHeaders
|
||||
*
|
||||
* @return array
|
||||
*
|
||||
* @throws ParseException
|
||||
*/
|
||||
private function parseRawHeaders(string $rawHeaders) : array
|
||||
{
|
||||
// Legacy support for folded headers
|
||||
if (\strpos($rawHeaders, "\r\n ") || \strpos($rawHeaders, "\r\n\t")) {
|
||||
$rawHeaders = \preg_replace("/\r\n[ \t]++/", ' ', $rawHeaders);
|
||||
}
|
||||
try {
|
||||
$headers = Rfc7230::parseRawHeaders($rawHeaders);
|
||||
$headerMap = [];
|
||||
foreach ($headers as [$key, $value]) {
|
||||
$headerMap[\strtolower($key)][] = $value;
|
||||
}
|
||||
} catch (InvalidHeaderException $e) {
|
||||
throw new ParseException('Invalid headers', Status::BAD_REQUEST, $e);
|
||||
}
|
||||
if (isset($headerMap['transfer-encoding'])) {
|
||||
$transferEncodings = \explode(',', \strtolower(\implode(',', $headerMap['transfer-encoding'])));
|
||||
$transferEncodings = \array_map('trim', $transferEncodings);
|
||||
$this->chunkedEncoding = \in_array('chunked', $transferEncodings, \true);
|
||||
} elseif (isset($headerMap['content-length'])) {
|
||||
if (\count($headerMap['content-length']) > 1) {
|
||||
throw new ParseException('Can\'t determine body length, because multiple content-length headers present in the response', Status::BAD_REQUEST);
|
||||
}
|
||||
$contentLength = $headerMap['content-length'][0];
|
||||
if (!\preg_match('/^(0|[1-9][0-9]*)$/', $contentLength)) {
|
||||
throw new ParseException('Can\'t determine body length, because the content-length header value is invalid', Status::BAD_REQUEST);
|
||||
}
|
||||
$this->remainingBodyBytes = (int) $contentLength;
|
||||
}
|
||||
return $headers;
|
||||
}
|
||||
/**
|
||||
* Decodes a chunked response body.
|
||||
*
|
||||
* @return bool Returns {@code true} if the body is complete, otherwise {@code false}.
|
||||
*
|
||||
* @throws ParseException
|
||||
*/
|
||||
private function parseChunkedBody() : bool
|
||||
{
|
||||
if ($this->chunkLengthRemaining !== null) {
|
||||
goto decode_chunk;
|
||||
}
|
||||
determine_chunk_size:
|
||||
if (\false === ($lineEndPos = \strpos($this->buffer, "\r\n"))) {
|
||||
return \false;
|
||||
}
|
||||
if ($lineEndPos === 0) {
|
||||
throw new ParseException('Invalid line; hexadecimal chunk size expected', Status::BAD_REQUEST);
|
||||
}
|
||||
$line = \substr($this->buffer, 0, $lineEndPos);
|
||||
$hex = \strtolower(\trim(\ltrim($line, '0'))) ?: '0';
|
||||
$dec = \hexdec($hex);
|
||||
if ($hex !== \dechex($dec)) {
|
||||
throw new ParseException('Invalid hexadecimal chunk size', Status::BAD_REQUEST);
|
||||
}
|
||||
$this->chunkLengthRemaining = $dec;
|
||||
$this->buffer = \substr($this->buffer, $lineEndPos + 2);
|
||||
if ($this->chunkLengthRemaining === 0) {
|
||||
return \true;
|
||||
}
|
||||
decode_chunk:
|
||||
$bufferLength = \strlen($this->buffer);
|
||||
// These first two (extreme) edge cases prevent errors where the packet boundary ends after
|
||||
// the \r and before the \n at the end of a chunk.
|
||||
if ($bufferLength === $this->chunkLengthRemaining || $bufferLength === $this->chunkLengthRemaining + 1) {
|
||||
return \false;
|
||||
}
|
||||
if ($bufferLength >= $this->chunkLengthRemaining + 2) {
|
||||
$chunk = \substr($this->buffer, 0, $this->chunkLengthRemaining);
|
||||
$this->buffer = \substr($this->buffer, $this->chunkLengthRemaining + 2);
|
||||
$this->chunkLengthRemaining = null;
|
||||
$this->addToBody($chunk);
|
||||
goto determine_chunk_size;
|
||||
}
|
||||
/** @noinspection SuspiciousAssignmentsInspection */
|
||||
$chunk = $this->buffer;
|
||||
$this->buffer = '';
|
||||
$this->chunkLengthRemaining -= $bufferLength;
|
||||
$this->addToBody($chunk);
|
||||
return \false;
|
||||
}
|
||||
/**
|
||||
* @param string $trailers
|
||||
*
|
||||
* @throws ParseException
|
||||
*/
|
||||
private function parseTrailers(string $trailers) : void
|
||||
{
|
||||
try {
|
||||
$trailers = Rfc7230::parseHeaders($trailers);
|
||||
} catch (InvalidHeaderException $e) {
|
||||
throw new ParseException('Invalid trailers', Status::BAD_REQUEST, $e);
|
||||
}
|
||||
($this->trailersCallback)($trailers);
|
||||
}
|
||||
/**
|
||||
* @param string $data
|
||||
*
|
||||
* @throws ParseException
|
||||
*/
|
||||
private function addToBody(string $data) : void
|
||||
{
|
||||
$length = \strlen($data);
|
||||
if (!$length) {
|
||||
return;
|
||||
}
|
||||
$this->bodyBytesConsumed += $length;
|
||||
if ($this->maxBodyBytes > 0 && $this->bodyBytesConsumed > $this->maxBodyBytes) {
|
||||
throw new ParseException("Configured body size exceeded: {$this->bodyBytesConsumed} bytes received, while the configured limit is {$this->maxBodyBytes} bytes", Status::PAYLOAD_TOO_LARGE);
|
||||
}
|
||||
if ($this->bodyDataCallback) {
|
||||
($this->bodyDataCallback)($data);
|
||||
}
|
||||
}
|
||||
}
|
1143
dependencies/amphp/http-client/src/Connection/Internal/Http2ConnectionProcessor.php
vendored
Normal file
1143
dependencies/amphp/http-client/src/Connection/Internal/Http2ConnectionProcessor.php
vendored
Normal file
File diff suppressed because it is too large
Load Diff
107
dependencies/amphp/http-client/src/Connection/Internal/Http2Stream.php
vendored
Normal file
107
dependencies/amphp/http-client/src/Connection/Internal/Http2Stream.php
vendored
Normal file
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
|
||||
namespace WP_Ultimo\Dependencies\Amp\Http\Client\Connection\Internal;
|
||||
|
||||
use WP_Ultimo\Dependencies\Amp\CancellationToken;
|
||||
use WP_Ultimo\Dependencies\Amp\Deferred;
|
||||
use WP_Ultimo\Dependencies\Amp\Emitter;
|
||||
use WP_Ultimo\Dependencies\Amp\Http\Client\Connection\Stream;
|
||||
use WP_Ultimo\Dependencies\Amp\Http\Client\Internal\ForbidCloning;
|
||||
use WP_Ultimo\Dependencies\Amp\Http\Client\Internal\ForbidSerialization;
|
||||
use WP_Ultimo\Dependencies\Amp\Http\Client\Request;
|
||||
use WP_Ultimo\Dependencies\Amp\Http\Client\Response;
|
||||
use WP_Ultimo\Dependencies\Amp\Loop;
|
||||
use WP_Ultimo\Dependencies\Amp\Promise;
|
||||
use WP_Ultimo\Dependencies\Amp\Struct;
|
||||
/**
|
||||
* Used in Http2Connection.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class Http2Stream
|
||||
{
|
||||
use Struct;
|
||||
use ForbidSerialization;
|
||||
use ForbidCloning;
|
||||
/** @var int */
|
||||
public $id;
|
||||
/** @var Request */
|
||||
public $request;
|
||||
/** @var Response|null */
|
||||
public $response;
|
||||
/** @var Deferred|null */
|
||||
public $pendingResponse;
|
||||
/** @var Promise|null */
|
||||
public $preResponseResolution;
|
||||
/** @var bool */
|
||||
public $responsePending = \true;
|
||||
/** @var Emitter|null */
|
||||
public $body;
|
||||
/** @var Deferred|null */
|
||||
public $trailers;
|
||||
/** @var CancellationToken */
|
||||
public $originalCancellation;
|
||||
/** @var CancellationToken */
|
||||
public $cancellationToken;
|
||||
/** @var int Bytes received on the stream. */
|
||||
public $received = 0;
|
||||
/** @var int */
|
||||
public $serverWindow;
|
||||
/** @var int */
|
||||
public $clientWindow;
|
||||
/** @var int */
|
||||
public $bufferSize;
|
||||
/** @var string */
|
||||
public $requestBodyBuffer = '';
|
||||
/** @var bool */
|
||||
public $requestBodyComplete = \false;
|
||||
/** @var Deferred */
|
||||
public $requestBodyCompletion;
|
||||
/** @var int Integer between 1 and 256 */
|
||||
public $weight = 16;
|
||||
/** @var int */
|
||||
public $dependency = 0;
|
||||
/** @var int|null */
|
||||
public $expectedLength;
|
||||
/** @var Stream */
|
||||
public $stream;
|
||||
/** @var Deferred|null */
|
||||
public $windowSizeIncrease;
|
||||
/** @var string|null */
|
||||
private $watcher;
|
||||
public function __construct(int $id, Request $request, Stream $stream, CancellationToken $cancellationToken, CancellationToken $originalCancellation, ?string $watcher, int $serverSize, int $clientSize)
|
||||
{
|
||||
$this->id = $id;
|
||||
$this->request = $request;
|
||||
$this->stream = $stream;
|
||||
$this->cancellationToken = $cancellationToken;
|
||||
$this->originalCancellation = $originalCancellation;
|
||||
$this->watcher = $watcher;
|
||||
$this->serverWindow = $serverSize;
|
||||
$this->clientWindow = $clientSize;
|
||||
$this->pendingResponse = new Deferred();
|
||||
$this->requestBodyCompletion = new Deferred();
|
||||
$this->bufferSize = 0;
|
||||
}
|
||||
public function __destruct()
|
||||
{
|
||||
if ($this->watcher !== null) {
|
||||
Loop::cancel($this->watcher);
|
||||
}
|
||||
}
|
||||
public function disableInactivityWatcher() : void
|
||||
{
|
||||
if ($this->watcher === null) {
|
||||
return;
|
||||
}
|
||||
Loop::disable($this->watcher);
|
||||
}
|
||||
public function enableInactivityWatcher() : void
|
||||
{
|
||||
if ($this->watcher === null) {
|
||||
return;
|
||||
}
|
||||
Loop::disable($this->watcher);
|
||||
Loop::enable($this->watcher);
|
||||
}
|
||||
}
|
71
dependencies/amphp/http-client/src/Connection/Internal/RequestNormalizer.php
vendored
Normal file
71
dependencies/amphp/http-client/src/Connection/Internal/RequestNormalizer.php
vendored
Normal file
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
namespace WP_Ultimo\Dependencies\Amp\Http\Client\Connection\Internal;
|
||||
|
||||
use WP_Ultimo\Dependencies\Amp\Http\Client\Request;
|
||||
use WP_Ultimo\Dependencies\Amp\Http\Client\RequestBody;
|
||||
use WP_Ultimo\Dependencies\Amp\Promise;
|
||||
use function WP_Ultimo\Dependencies\Amp\call;
|
||||
/** @internal */
|
||||
final class RequestNormalizer
|
||||
{
|
||||
public static function normalizeRequest(Request $request) : Promise
|
||||
{
|
||||
return call(static function () use($request) {
|
||||
/** @var array $headers */
|
||||
$headers = (yield $request->getBody()->getHeaders());
|
||||
foreach ($headers as $name => $header) {
|
||||
if (!$request->hasHeader($name)) {
|
||||
$request->setHeaders([$name => $header]);
|
||||
}
|
||||
}
|
||||
yield from self::normalizeRequestBodyHeaders($request);
|
||||
// Always normalize this as last item, because we need to strip sensitive headers
|
||||
self::normalizeTraceRequest($request);
|
||||
return $request;
|
||||
});
|
||||
}
|
||||
private static function normalizeRequestBodyHeaders(Request $request) : \Generator
|
||||
{
|
||||
if (!$request->hasHeader('host')) {
|
||||
// Though servers are supposed to be able to handle standard port names on the end of the
|
||||
// Host header some fail to do this correctly. Thankfully PSR-7 recommends to strip the port
|
||||
// if it is the standard port for the given scheme.
|
||||
$request->setHeader('host', $request->getUri()->withUserInfo('')->getAuthority());
|
||||
}
|
||||
if ($request->hasHeader("transfer-encoding")) {
|
||||
$request->removeHeader("content-length");
|
||||
return;
|
||||
}
|
||||
if ($request->hasHeader("content-length")) {
|
||||
return;
|
||||
}
|
||||
/** @var RequestBody $body */
|
||||
$body = $request->getBody();
|
||||
$bodyLength = (yield $body->getBodyLength());
|
||||
if ($bodyLength === 0) {
|
||||
if (\in_array($request->getMethod(), ['HEAD', 'GET', 'CONNECT'], \true)) {
|
||||
$request->removeHeader('content-length');
|
||||
} else {
|
||||
$request->setHeader('content-length', '0');
|
||||
}
|
||||
$request->removeHeader('transfer-encoding');
|
||||
} elseif ($bodyLength > 0) {
|
||||
$request->setHeader("content-length", $bodyLength);
|
||||
$request->removeHeader("transfer-encoding");
|
||||
} else {
|
||||
$request->setHeader("transfer-encoding", "chunked");
|
||||
}
|
||||
}
|
||||
private static function normalizeTraceRequest(Request $request) : void
|
||||
{
|
||||
$method = $request->getMethod();
|
||||
if ($method !== 'TRACE') {
|
||||
return;
|
||||
}
|
||||
// https://tools.ietf.org/html/rfc7231#section-4.3.8
|
||||
$request->setBody(null);
|
||||
// Remove all body and sensitive headers
|
||||
$request->setHeaders(["transfer-encoding" => [], "content-length" => [], "authorization" => [], "proxy-authorization" => [], "cookie" => []]);
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user