diff --git a/app/controllers/general.php b/app/controllers/general.php index b150729a86..10c9eb8e18 100644 --- a/app/controllers/general.php +++ b/app/controllers/general.php @@ -333,13 +333,6 @@ function router(App $utopia, Database $dbForConsole, callable $getProjectDB, Swo $body = $execution['responseBody'] ?? ''; - $encodingKey = \array_search('x-open-runtimes-encoding', \array_column($execution['responseHeaders'], 'name')); - if ($encodingKey !== false) { - if (($execution['responseHeaders'][$encodingKey]['value'] ?? '') === 'base64') { - $body = \base64_decode($body); - } - } - $contentType = 'text/plain'; foreach ($execution['responseHeaders'] as $header) { if (\strtolower($header['name']) === 'content-type') { diff --git a/composer.lock b/composer.lock index ea17b4e892..341342455c 100644 --- a/composer.lock +++ b/composer.lock @@ -1126,86 +1126,6 @@ }, "time": "2022-03-17T08:00:35+00:00" }, - { - "name": "symfony/polyfill-mbstring", - "version": "v1.30.0", - "source": { - "type": "git", - "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "fd22ab50000ef01661e2a31d850ebaa297f8e03c" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/fd22ab50000ef01661e2a31d850ebaa297f8e03c", - "reference": "fd22ab50000ef01661e2a31d850ebaa297f8e03c", - "shasum": "" - }, - "require": { - "php": ">=7.1" - }, - "provide": { - "ext-mbstring": "*" - }, - "suggest": { - "ext-mbstring": "For best performance" - }, - "type": "library", - "extra": { - "thanks": { - "name": "symfony/polyfill", - "url": "https://github.com/symfony/polyfill" - } - }, - "autoload": { - "files": [ - "bootstrap.php" - ], - "psr-4": { - "Symfony\\Polyfill\\Mbstring\\": "" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Symfony polyfill for the Mbstring extension", - "homepage": "https://symfony.com", - "keywords": [ - "compatibility", - "mbstring", - "polyfill", - "portable", - "shim" - ], - "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.30.0" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2024-06-19T12:30:46+00:00" - }, { "name": "symfony/polyfill-php80", "version": "v1.30.0", @@ -5417,6 +5337,86 @@ ], "time": "2024-05-31T15:07:36+00:00" }, + { + "name": "symfony/polyfill-mbstring", + "version": "v1.30.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-mbstring.git", + "reference": "fd22ab50000ef01661e2a31d850ebaa297f8e03c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/fd22ab50000ef01661e2a31d850ebaa297f8e03c", + "reference": "fd22ab50000ef01661e2a31d850ebaa297f8e03c", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "provide": { + "ext-mbstring": "*" + }, + "suggest": { + "ext-mbstring": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "name": "symfony/polyfill", + "url": "https://github.com/symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Mbstring\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for the Mbstring extension", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "mbstring", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.30.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-06-19T12:30:46+00:00" + }, { "name": "textalk/websocket", "version": "1.5.7", diff --git a/src/Appwrite/Utopia/Fetch/BodyMultipart.php b/src/Appwrite/Utopia/Fetch/BodyMultipart.php new file mode 100644 index 0000000000..3869150758 --- /dev/null +++ b/src/Appwrite/Utopia/Fetch/BodyMultipart.php @@ -0,0 +1,147 @@ + $parts + */ + private array $parts = []; + private string $boundary = ""; + + public function __construct(string $boundary = null) + { + if (is_null($boundary)) { + $this->boundary = self::generateBoundary(); + } else { + $this->boundary = $boundary; + } + } + + public static function generateBoundary(): string + { + return '-----------------------------' . \uniqid(); + } + + public function load(string $body): self + { + $eol = "\r\n"; + + $sections = \explode('--' . $this->boundary, $body); + + foreach ($sections as $section) { + if (empty($section)) { + continue; + } + + if (strpos($section, $eol) === 0) { + $section = substr($section, \strlen($eol)); + } + + if (substr($section, -2) === $eol) { + $section = substr($section, 0, -1 * \strlen($eol)); + } + + if ($section == '--') { + continue; + } + + $partChunks = \explode($eol . $eol, $section, 2); + + if (\count($partChunks) < 2) { + continue; // Broken part + } + + [ $partHeaders, $partBody ] = $partChunks; + $partHeaders = \explode($eol, $partHeaders); + + $partName = ""; + foreach ($partHeaders as $partHeader) { + if (!empty($partName)) { + break; + } + + $partHeaderArray = \explode(':', $partHeader, 2); + + $partHeaderName = \strtolower($partHeaderArray[0] ?? ''); + $partHeaderValue = $partHeaderArray[1] ?? ''; + if ($partHeaderName == "content-disposition") { + $dispositionChunks = \explode("; ", $partHeaderValue); + foreach ($dispositionChunks as $dispositionChunk) { + $dispositionChunkValues = \explode("=", $dispositionChunk, 2); + if (\count($dispositionChunkValues) >= 2) { + if ($dispositionChunkValues[0] === "name") { + $partName = \trim($dispositionChunkValues[1], "\""); + break; + } + } + } + } + } + + if (!empty($partName)) { + $this->parts[$partName] = $partBody; + } + } + return $this; + } + + /** + * @return array + */ + public function getParts(): array + { + return $this->parts ?? []; + } + + public function getPart(string $key, mixed $default = ''): mixed + { + return $this->parts[$key] ?? $default; + } + + public function setPart(string $key, mixed $value): self + { + $this->parts[$key] = $value; + return $this; + } + + public function getBoundary(): string + { + return $this->boundary; + } + + public function setBoundary(string $boundary): self + { + $this->boundary = $boundary; + return $this; + } + + public function exportHeader(): string + { + return 'multipart/form-data; boundary=' . $this->boundary; + } + + public function exportBody(): string + { + $eol = "\r\n"; + $query = '--' . $this->boundary; + + foreach ($this->parts as $key => $value) { + $query .= $eol . 'Content-Disposition: form-data; name="' . $key . '"'; + + if (\is_array($value)) { + $query .= $eol . 'Content-Type: application/json'; + $value = \json_encode($value); + } + + $query .= $eol . $eol; + $query .= $value . $eol; + $query .= '--' . $this->boundary; + } + + $query .= "--" . $eol; + + return $query; + } +} diff --git a/src/Executor/Executor.php b/src/Executor/Executor.php index 43f9a3d73e..37c2963be9 100644 --- a/src/Executor/Executor.php +++ b/src/Executor/Executor.php @@ -3,6 +3,7 @@ namespace Executor; use Appwrite\Extend\Exception as AppwriteException; +use Appwrite\Utopia\Fetch\BodyMultipart; use Exception; use Utopia\System\System; @@ -211,7 +212,7 @@ class Executor $requestTimeout = $timeout + 15; } - $response = $this->call(self::METHOD_POST, $route, [ 'x-opr-runtime-id' => $runtimeId ], $params, true, $requestTimeout); + $response = $this->call(self::METHOD_POST, $route, [ 'x-opr-runtime-id' => $runtimeId, 'content-type' => 'multipart/form-data', 'accept' => 'multipart/form-data' ], $params, true, $requestTimeout); $status = $response['headers']['status-code']; if ($status >= 400) { @@ -219,6 +220,11 @@ class Executor throw new \Exception($message, $status); } + $response['body']['headers'] = \json_decode($response['body']['headers'] ?? '{}', true); + $response['body']['statusCode'] = \intval($response['body']['statusCode'] ?? 500); + $response['body']['duration'] = \intval($response['body']['duration'] ?? 0); + $response['body']['startTime'] = \intval($response['body']['startTime'] ?? \microtime(true)); + return $response['body']; } @@ -250,7 +256,13 @@ class Executor break; case 'multipart/form-data': - $query = $this->flatten($params); + $multipart = new BodyMultipart(); + foreach ($params as $key => $value) { + $multipart->setPart($key, $value); + } + + $headers['content-type'] = $multipart->exportHeader(); + $query = $multipart->exportBody(); break; default: @@ -317,7 +329,16 @@ class Executor $curlErrorMessage = curl_error($ch); if ($decode) { - switch (substr($responseType, 0, strpos($responseType, ';'))) { + $strpos = strpos($responseType, ';'); + $strpos = \is_bool($strpos) ? \strlen($responseType) : $strpos; + switch (substr($responseType, 0, $strpos)) { + case 'multipart/form-data': + $boundary = \explode('boundary=', $responseHeaders['content-type'] ?? '')[1] ?? ''; + $multipartResponse = new BodyMultipart($boundary); + $multipartResponse->load(\is_bool($responseBody) ? '' : $responseBody); + + $responseBody = $multipartResponse->getParts(); + break; case 'application/json': $json = json_decode($responseBody, true); diff --git a/tests/e2e/Client.php b/tests/e2e/Client.php index 7083095da1..c8bf36a2ef 100644 --- a/tests/e2e/Client.php +++ b/tests/e2e/Client.php @@ -163,7 +163,7 @@ class Client * @return array * @throws Exception */ - public function call(string $method, string $path = '', array $headers = [], array $params = [], bool $decode = true): array + public function call(string $method, string $path = '', array $headers = [], mixed $params = [], bool $decode = true): array { $headers = array_merge($this->headers, $headers); $ch = curl_init($this->endpoint . $path . (($method == self::METHOD_GET && !empty($params)) ? '?' . http_build_query($params) : '')); @@ -174,6 +174,7 @@ class Client 'application/json' => json_encode($params), 'multipart/form-data' => $this->flatten($params), 'application/graphql' => $params[0], + 'text/plain' => $params, default => http_build_query($params), }; diff --git a/tests/e2e/Services/Functions/FunctionsCustomServerTest.php b/tests/e2e/Services/Functions/FunctionsCustomServerTest.php index dfbfaa6c01..7864a52ac2 100644 --- a/tests/e2e/Services/Functions/FunctionsCustomServerTest.php +++ b/tests/e2e/Services/Functions/FunctionsCustomServerTest.php @@ -1805,4 +1805,205 @@ class FunctionsCustomServerTest extends Scope $this->assertEquals(204, $response['headers']['status-code']); } + + public function testFunctionsDomainBianryResponse() + { + $timeout = 15; + $code = realpath(__DIR__ . '/../../../resources/functions') . "/php-binary-response/code.tar.gz"; + $this->packageCode('php-binary-response'); + + $function = $this->client->call(Client::METHOD_POST, '/functions', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'functionId' => ID::unique(), + 'name' => 'Test PHP Binary executions', + 'runtime' => 'php-8.0', + 'entrypoint' => 'index.php', + 'timeout' => $timeout, + 'execute' => ['any'] + ]); + + $functionId = $function['body']['$id'] ?? ''; + + $this->assertEquals(201, $function['headers']['status-code']); + + $rules = $this->client->call(Client::METHOD_GET, '/proxy/rules', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => [ + Query::equal('resourceId', [$functionId])->toString(), + Query::equal('resourceType', ['function'])->toString(), + ], + ]); + + $this->assertEquals(200, $rules['headers']['status-code']); + $this->assertEquals(1, $rules['body']['total']); + $this->assertCount(1, $rules['body']['rules']); + $this->assertNotEmpty($rules['body']['rules'][0]['domain']); + + $domain = $rules['body']['rules'][0]['domain']; + + $deployment = $this->client->call(Client::METHOD_POST, '/functions/' . $functionId . '/deployments', array_merge([ + 'content-type' => 'multipart/form-data', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'entrypoint' => 'index.php', + 'code' => new CURLFile($code, 'application/x-gzip', basename($code)), + 'activate' => true + ]); + + $deploymentId = $deployment['body']['$id'] ?? ''; + $this->assertEquals(202, $deployment['headers']['status-code']); + + // Poll until deployment is built + while (true) { + $deployment = $this->client->call(Client::METHOD_GET, '/functions/' . $function['body']['$id'] . '/deployments/' . $deploymentId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]); + + if ( + $deployment['headers']['status-code'] >= 400 + || \in_array($deployment['body']['status'], ['ready', 'failed']) + ) { + break; + } + + \sleep(1); + } + + $deployment = $this->client->call(Client::METHOD_PATCH, '/functions/' . $functionId . '/deployments/' . $deploymentId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), []); + + $this->assertEquals(200, $deployment['headers']['status-code']); + + // Wait a little for activation to finish + sleep(5); + + $proxyClient = new Client(); + $proxyClient->setEndpoint('http://' . $domain); + + $response = $proxyClient->call(Client::METHOD_GET, '/', [], [], false); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']); + $bytes = unpack('C*byte', $response['body']); + $this->assertCount(3, $bytes); + $this->assertEquals(0, $bytes['byte1']); + $this->assertEquals(10, $bytes['byte2']); + $this->assertEquals(255, $bytes['byte3']); + + // Cleanup : Delete function + $response = $this->client->call(Client::METHOD_DELETE, '/functions/' . $functionId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], []); + + $this->assertEquals(204, $response['headers']['status-code']); + } + + public function testFunctionsDomainBianryRequest() + { + $timeout = 15; + $code = realpath(__DIR__ . '/../../../resources/functions') . "/php-binary-request/code.tar.gz"; + $this->packageCode('php-binary-request'); + + $function = $this->client->call(Client::METHOD_POST, '/functions', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'functionId' => ID::unique(), + 'name' => 'Test PHP Binary executions', + 'runtime' => 'php-8.0', + 'entrypoint' => 'index.php', + 'timeout' => $timeout, + 'execute' => ['any'] + ]); + + $functionId = $function['body']['$id'] ?? ''; + + $this->assertEquals(201, $function['headers']['status-code']); + + $rules = $this->client->call(Client::METHOD_GET, '/proxy/rules', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => [ + Query::equal('resourceId', [$functionId])->toString(), + Query::equal('resourceType', ['function'])->toString(), + ], + ]); + + $this->assertEquals(200, $rules['headers']['status-code']); + $this->assertEquals(1, $rules['body']['total']); + $this->assertCount(1, $rules['body']['rules']); + $this->assertNotEmpty($rules['body']['rules'][0]['domain']); + + $domain = $rules['body']['rules'][0]['domain']; + + $deployment = $this->client->call(Client::METHOD_POST, '/functions/' . $functionId . '/deployments', array_merge([ + 'content-type' => 'multipart/form-data', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'entrypoint' => 'index.php', + 'code' => new CURLFile($code, 'application/x-gzip', basename($code)), + 'activate' => true + ]); + + $deploymentId = $deployment['body']['$id'] ?? ''; + $this->assertEquals(202, $deployment['headers']['status-code']); + + // Poll until deployment is built + while (true) { + $deployment = $this->client->call(Client::METHOD_GET, '/functions/' . $function['body']['$id'] . '/deployments/' . $deploymentId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]); + + if ( + $deployment['headers']['status-code'] >= 400 + || \in_array($deployment['body']['status'], ['ready', 'failed']) + ) { + break; + } + + \sleep(1); + } + + $deployment = $this->client->call(Client::METHOD_PATCH, '/functions/' . $functionId . '/deployments/' . $deploymentId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), []); + + $this->assertEquals(200, $deployment['headers']['status-code']); + + // Wait a little for activation to finish + sleep(5); + + $proxyClient = new Client(); + $proxyClient->setEndpoint('http://' . $domain); + + $bytes = pack('C*', ...[0,20,255]); + + $response = $proxyClient->call(Client::METHOD_POST, '/', [ 'content-type' => 'text/plain' ], $bytes, false); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals(\md5($bytes), $response['body']); + + // Cleanup : Delete function + $response = $this->client->call(Client::METHOD_DELETE, '/functions/' . $functionId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], []); + + $this->assertEquals(204, $response['headers']['status-code']); + } } diff --git a/tests/resources/functions/php-binary-request/index.php b/tests/resources/functions/php-binary-request/index.php new file mode 100644 index 0000000000..53df8705e5 --- /dev/null +++ b/tests/resources/functions/php-binary-request/index.php @@ -0,0 +1,6 @@ +req->bodyBinary); + return $context->res->send($hash); +}; diff --git a/tests/resources/functions/php-binary-response/index.php b/tests/resources/functions/php-binary-response/index.php new file mode 100644 index 0000000000..7715663388 --- /dev/null +++ b/tests/resources/functions/php-binary-response/index.php @@ -0,0 +1,6 @@ +res->binary($bytes); +};