diff --git a/app/controllers/api/functions.php b/app/controllers/api/functions.php index cdf90ce449..38b4721f34 100644 --- a/app/controllers/api/functions.php +++ b/app/controllers/api/functions.php @@ -10,6 +10,7 @@ use Appwrite\Event\Usage; use Appwrite\Event\Validator\FunctionEvent; use Appwrite\Extend\Exception; use Appwrite\Extend\Exception as AppwriteException; +use Appwrite\Functions\Validator\Headers; use Appwrite\Messaging\Adapter\Realtime; use Appwrite\Platform\Tasks\ScheduleExecutions; use Appwrite\Task\Validator\Cron; @@ -44,6 +45,7 @@ use Utopia\Storage\Validator\FileSize; use Utopia\Storage\Validator\Upload; use Utopia\Swoole\Request; use Utopia\System\System; +use Utopia\Validator\AnyOf; use Utopia\Validator\ArrayList; use Utopia\Validator\Assoc; use Utopia\Validator\Boolean; @@ -1600,13 +1602,14 @@ App::post('/v1/functions/:functionId/executions') ->label('sdk.response.type', Response::CONTENT_TYPE_JSON) ->label('sdk.response.model', Response::MODEL_EXECUTION) ->param('functionId', '', new UID(), 'Function ID.') - ->param('body', '', new Text(0, 0), 'HTTP body of execution. Default value is empty string.', true) + ->param('body', '', new Text(10485760, 0), 'HTTP body of execution. Default value is empty string.', true) ->param('async', false, new Boolean(), 'Execute code in the background. Default value is false.', true) ->param('path', '/', new Text(2048), 'HTTP path of execution. Path can include query params. Default value is /', true) ->param('method', 'POST', new Whitelist(['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], true), 'HTTP method of execution. Default value is GET.', true) - ->param('headers', [], new Assoc(), 'HTTP headers of execution. Defaults to empty.', true) + ->param('headers', [], new AnyOf([new Text(65535), new Assoc()], AnyOf::TYPE_MIXED), 'HTTP headers of execution. Defaults to empty.', true) ->param('scheduledAt', null, new DatetimeValidator(requireDateInFuture: true), 'Scheduled execution time in [ISO 8601](https://www.iso.org/iso-8601-date-and-time-format.html) format. DateTime value must be in future.', true) ->inject('response') + ->inject('request') ->inject('project') ->inject('dbForProject') ->inject('dbForConsole') @@ -1615,12 +1618,35 @@ App::post('/v1/functions/:functionId/executions') ->inject('queueForUsage') ->inject('queueForFunctions') ->inject('geodb') - ->action(function (string $functionId, string $body, bool $async, string $path, string $method, array $headers, ?string $scheduledAt, Response $response, Document $project, Database $dbForProject, Database $dbForConsole, Document $user, Event $queueForEvents, Usage $queueForUsage, Func $queueForFunctions, Reader $geodb) { + ->action(function (string $functionId, string $body, bool $async, string $path, string $method, mixed $headers, ?string $scheduledAt, Response $response, Request $request, Document $project, Database $dbForProject, Database $dbForConsole, Document $user, Event $queueForEvents, Usage $queueForUsage, Func $queueForFunctions, Reader $geodb) { if(!$async && !is_null($scheduledAt)) { throw new Exception(Exception::GENERAL_BAD_REQUEST, 'Scheduled executions must run asynchronously. Set scheduledAt to a future date, or set async to true.'); } + /** + * @var array $headers + */ + $assocParams = ['headers']; + foreach ($assocParams as $assocParam) { + if (!empty('headers') && !is_array($$assocParam)) { + $$assocParam = \json_decode($$assocParam, true); + } + } + + $booleanParams = ['async']; + foreach ($booleanParams as $booleamParam) { + if (!empty($$booleamParam) && !is_bool($$booleamParam)) { + $$booleamParam = $$booleamParam === "true" ? true : false; + } + } + + // 'headers' validator + $validator = new Headers(); + if (!$validator->isValid($headers)) { + throw new Exception($validator->getDescription(), 400); + } + $function = Authorization::skip(fn () => $dbForProject->getDocument('functions', $functionId)); $isAPIKey = Auth::isAppUser(Authorization::getRoles()); @@ -1927,6 +1953,17 @@ App::post('/v1/functions/:functionId/executions') $execution->setAttribute('responseBody', $executionResponse['body'] ?? ''); $execution->setAttribute('responseHeaders', $headers); + $acceptTypes = \explode(', ', $request->getHeader('accept')); + foreach ($acceptTypes as $acceptType) { + if(\str_starts_with($acceptType, 'application/json') || \str_starts_with($acceptType, 'application/*')) { + $response->setContentType(Response::CONTENT_TYPE_JSON); + break; + } elseif (\str_starts_with($acceptType, 'multipart/form-data') || \str_starts_with($acceptType, 'multipart/*')) { + $response->setContentType(Response::CONTENT_TYPE_MULTIPART); + break; + } + } + $response ->setStatusCode(Response::STATUS_CODE_CREATED) ->dynamic($execution, Response::MODEL_EXECUTION); diff --git a/composer.lock b/composer.lock index 1f74e0c159..8fc3445b60 100644 --- a/composer.lock +++ b/composer.lock @@ -5617,5 +5617,5 @@ "platform-overrides": { "php": "8.3" }, - "plugin-api-version": "2.6.0" + "plugin-api-version": "2.3.0" } diff --git a/docker-compose.yml b/docker-compose.yml index 8fbad2f652..53f6509161 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -923,7 +923,7 @@ services: hostname: proxy <<: *x-logging stop_signal: SIGINT - image: openruntimes/proxy:0.3.1 + image: openruntimes/proxy:0.5.4 networks: - appwrite - runtimes diff --git a/src/Appwrite/Functions/Validator/Headers.php b/src/Appwrite/Functions/Validator/Headers.php new file mode 100644 index 0000000000..c63fb04549 --- /dev/null +++ b/src/Appwrite/Functions/Validator/Headers.php @@ -0,0 +1,103 @@ +allowEmpty = $allowEmpty; + } + + /** + * Get Description. + * + * Returns validator description + * + * @return string + */ + public function getDescription(): string + { + return 'Invalid header format. Header keys can only contain alphanumeric characters, underscores, and hyphens. Header keys cannot start with "x-appwrite-" prefix.'; + } + + /** + * Is valid. + * + * @param mixed $value + * + * @return bool + */ + public function isValid($value): bool + { + if ($this->allowEmpty && empty($value)) { + return true; + } + + if (!\is_array($value)) { + return false; + } + + if (\is_array($value)) { + foreach ($value as $key => $val) { + $length = \strlen($key); + // Reject non-string keys + if (!\is_string($key) || $length === 0) { + return false; + } + + // Check first and last character + if (!ctype_alnum($key[0]) || !ctype_alnum($key[$length - 1])) { + return false; + } + + // Check middle characters + for ($i = 1; $i < $length - 1; $i++) { + if (!ctype_alnum($key[$i]) && $key[$i] !== '-') { + return false; + } + } + + // Check for x-appwrite- prefix + if (str_starts_with($key, 'x-appwrite-')) { + return false; + } + } + return true; + } + return false; + } + + /** + * Is array + * + * Function will return true if object is array. + * + * @return bool + */ + public function isArray(): bool + { + return false; + } + + /** + * Get Type + * + * Returns validator type. + * + * @return string + */ + public function getType(): string + { + return self::TYPE_OBJECT; + } +} diff --git a/src/Appwrite/Utopia/Response.php b/src/Appwrite/Utopia/Response.php index d2e78bb310..a71e9a1e8d 100644 --- a/src/Appwrite/Utopia/Response.php +++ b/src/Appwrite/Utopia/Response.php @@ -2,6 +2,7 @@ namespace Appwrite\Utopia; +use Appwrite\Utopia\Fetch\BodyMultipart; use Appwrite\Utopia\Response\Filter; use Appwrite\Utopia\Response\Model; use Appwrite\Utopia\Response\Model\Account; @@ -107,6 +108,7 @@ use Appwrite\Utopia\Response\Model\Variable; use Appwrite\Utopia\Response\Model\VcsContent; use Appwrite\Utopia\Response\Model\Webhook; use Exception; +use JsonException; use Swoole\Http\Response as SwooleHTTPResponse; // Keep last use Utopia\Database\Document; @@ -486,6 +488,7 @@ class Response extends SwooleResponse */ public const CONTENT_TYPE_YAML = 'application/x-yaml'; public const CONTENT_TYPE_NULL = 'null'; + public const CONTENT_TYPE_MULTIPART = 'multipart/form-data'; /** * List of defined output objects @@ -556,7 +559,11 @@ class Response extends SwooleResponse switch ($this->getContentType()) { case self::CONTENT_TYPE_JSON: - $this->json(!empty($output) ? $output : new \stdClass()); + try { + $this->json(!empty($output) ? $output : new \stdClass()); + } catch (JsonException $e) { + throw new Exception('Failed to parse response: ' . $e->getMessage(), 400); + } break; case self::CONTENT_TYPE_YAML: @@ -566,6 +573,10 @@ class Response extends SwooleResponse case self::CONTENT_TYPE_NULL: break; + case self::CONTENT_TYPE_MULTIPART: + $this->multipart(!empty($output) ? $output : new \stdClass()); + break; + default: if ($model === self::MODEL_NONE) { $this->noContent(); @@ -697,6 +708,49 @@ class Response extends SwooleResponse ->send(\yaml_emit($data, YAML_UTF8_ENCODING)); } + /** + * Multipart + * + * This helper is for sending multipart/form-data HTTP response. + * It sets relevant content type header ('multipart/form-data') and convert a PHP array ($data) to valid Multipart using BodyMultipart + * + * @param array $data + * + * @return void + */ + public function multipart(array $data): void + { + $multipart = new BodyMultipart(); + foreach ($data as $key => $value) { + $multipart->setPart($key, $value); + } + + $this + ->send($multipart->exportBody()); + } + + /** + * JSON + * + * This helper is for sending JSON HTTP response. + * It sets relevant content type header ('application/json') and convert a PHP array ($data) to valid JSON using native json_encode + * + * @see http://en.wikipedia.org/wiki/JSON + * + * @param mixed $data + * @return void + */ + public function json($data): void + { + if (!is_array($data) && !$data instanceof \stdClass) { + throw new \Exception('Response body is not a valid JSON object.'); + } + + $this + ->setContentType(Response::CONTENT_TYPE_JSON, self::CHARSET_UTF8) + ->send(\json_encode($data, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR)); + } + /** * @return array */ diff --git a/tests/e2e/Client.php b/tests/e2e/Client.php index c8bf36a2ef..0774f1c6fd 100644 --- a/tests/e2e/Client.php +++ b/tests/e2e/Client.php @@ -2,6 +2,7 @@ namespace Tests\E2E; +use Appwrite\Utopia\Fetch\BodyMultipart; use Exception; class Client @@ -224,18 +225,35 @@ class Client $responseType = $responseHeaders['content-type'] ?? ''; $responseStatus = curl_getinfo($ch, CURLINFO_HTTP_CODE); - if ($decode && substr($responseType, 0, strpos($responseType, ';')) == 'application/json') { - $json = json_decode($responseBody, true); + if ($decode) { + $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); - if ($json === null) { - throw new Exception('Failed to parse response: ' . $responseBody); + $responseBody = $multipartResponse->getParts(); + break; + case 'application/json': + if (\is_bool($responseBody)) { + throw new Exception('Response is not a valid JSON.'); + } + + $json = json_decode($responseBody, true); + + if ($json === null) { + throw new Exception('Failed to parse response: ' . $responseBody); + } + + $responseBody = $json; + $json = null; + break; } - - $responseBody = $json; - $json = null; } - if ((curl_errno($ch))) { + if ((curl_errno($ch)/* || 200 != $responseStatus*/)) { throw new Exception(curl_error($ch) . ' with status code ' . $responseStatus, $responseStatus); } @@ -244,7 +262,7 @@ class Client $responseHeaders['status-code'] = $responseStatus; if ($responseStatus === 500) { - echo 'Server error(' . $method . ': ' . $path . '. Params: ' . json_encode($params) . '): ' . json_encode($responseBody) . "\n"; + echo 'Server error(' . $method . ': ' . $path . '. Params: ' . json_encode($params) . '): ' . json_encode($responseBody) . '\n'; } return [ diff --git a/tests/e2e/General/HTTPTest.php b/tests/e2e/General/HTTPTest.php index 5c3b10a9ca..b6dd3543c6 100644 --- a/tests/e2e/General/HTTPTest.php +++ b/tests/e2e/General/HTTPTest.php @@ -131,7 +131,6 @@ class HTTPTest extends Scope 'content-type' => 'application/json', ], json_decode(file_get_contents($directory . $file), true)); - $response['body'] = json_decode($response['body'], true); $this->assertEquals(200, $response['headers']['status-code']); // looks like recent change in the validator $this->assertTrue(empty($response['body']['schemaValidationMessages'])); diff --git a/tests/e2e/Services/Functions/FunctionsCustomServerTest.php b/tests/e2e/Services/Functions/FunctionsCustomServerTest.php index 2c20d46ccb..086b63c21c 100644 --- a/tests/e2e/Services/Functions/FunctionsCustomServerTest.php +++ b/tests/e2e/Services/Functions/FunctionsCustomServerTest.php @@ -1435,6 +1435,176 @@ class FunctionsCustomServerTest extends Scope $this->assertEquals(204, $response['headers']['status-code']); } + public function testCreateCustomExecutionBinaryResponse() + { + $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']); + + $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']); + + $this->awaitDeploymentIsBuilt($function['body']['$id'], $deploymentId, checkForSuccess: false); + + $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); + + $execution = $this->client->call(Client::METHOD_POST, '/functions/' . $functionId . '/executions', array_merge([ + 'x-appwrite-project' => $this->getProject()['$id'], + 'accept' => 'multipart/form-data', + ], $this->getHeaders()), [ + 'body' => null, + ]); + + $this->assertEquals(201, $execution['headers']['status-code']); + $this->assertStringContainsString('multipart/form-data', $execution['headers']['content-type']); + $bytes = unpack('C*byte', $execution['body']['responseBody']); + $this->assertCount(3, $bytes); + $this->assertEquals(0, $bytes['byte1']); + $this->assertEquals(10, $bytes['byte2']); + $this->assertEquals(255, $bytes['byte3']); + + /** + * Test for FAILURE + */ + $execution = $this->client->call(Client::METHOD_POST, '/functions/' . $functionId . '/executions', array_merge([ + 'x-appwrite-project' => $this->getProject()['$id'], + 'accept' => 'application/json', + ], $this->getHeaders()), [ + 'body' => null, + ]); + + $this->assertEquals(400, $execution['headers']['status-code']); + $this->assertStringContainsString('Failed to parse response', $execution['body']['message']); + + // 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 testCreateCustomExecutionBinaryRequest() + { + $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']); + + $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']); + + $this->awaitDeploymentIsBuilt($function['body']['$id'], $deploymentId, checkForSuccess: false); + + $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); + + $bytes = pack('C*', ...[0, 20, 255]); + + $execution = $this->client->call(Client::METHOD_POST, '/functions/' . $functionId . '/executions', array_merge([ + 'content-type' => 'multipart/form-data', + 'x-appwrite-project' => $this->getProject()['$id'], + 'accept' => 'application/json', + ], $this->getHeaders()), [ + 'body' => $bytes, + ], false); + + $executionBody = json_decode($execution['body'], true); + + $this->assertEquals(201, $execution['headers']['status-code']); + $this->assertEquals(\md5($bytes), $executionBody['responseBody']); + $this->assertStringStartsWith('application/json', $execution['headers']['content-type']); + + /** + * Test for FAILURE + */ + $execution = $this->client->call(Client::METHOD_POST, '/functions/' . $functionId . '/executions', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'accept' => 'application/json', + ], $this->getHeaders()), [ + 'body' => $bytes, + ], false); + + $executionBody = json_decode($execution['body'], true); + $this->assertNotEquals(\md5($bytes), $executionBody['responseBody']); + + // 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 testv2Function() { $timeout = 15; @@ -1877,7 +2047,7 @@ class FunctionsCustomServerTest extends Scope $this->assertEquals(204, $response['headers']['status-code']); } - public function testFunctionsDomainBianryResponse() + public function testFunctionsDomainBinaryResponse() { $timeout = 15; $code = realpath(__DIR__ . '/../../../resources/functions') . "/php-binary-response/code.tar.gz"; @@ -1963,7 +2133,7 @@ class FunctionsCustomServerTest extends Scope $this->assertEquals(204, $response['headers']['status-code']); } - public function testFunctionsDomainBianryRequest() + public function testFunctionsDomainBinaryRequest() { $timeout = 15; $code = realpath(__DIR__ . '/../../../resources/functions') . "/php-binary-request/code.tar.gz"; diff --git a/tests/unit/Functions/Validator/HeadersTest.php b/tests/unit/Functions/Validator/HeadersTest.php new file mode 100644 index 0000000000..b1f60a9aad --- /dev/null +++ b/tests/unit/Functions/Validator/HeadersTest.php @@ -0,0 +1,108 @@ +object = new Headers(); + } + + public function testValues(): void + { + $headers = [ + 'headerKey' => 'headerValue', + ]; + $this->assertEquals($this->object->isValid($headers), true); + + $headers = [ + 'headerKey' => 'headerValue', + 'x-appwrite-key' => 'headerValue', + ]; + $this->assertEquals($this->object->isValid($headers), false); + + $headers = [ + 'headerKey' => 'headerValue', + 'headerKey2' => 'headerValue2', + ]; + $this->assertEquals($this->object->isValid($headers), true); + + $headers = [ + 'headerKey' => 'headerValue', + 'x-appwrite-project' => 'headerValue', + 'headerKey2' => 'headerValue2', + ]; + $this->assertEquals($this->object->isValid($headers), false); + + $headers = [ + 'header/////Key' => 'headerValue', + ]; + $this->assertEquals($this->object->isValid($headers), false); + + $headers = [ + 'Content-Type' => 'application/json', + 'X-Custom-Header' => 'value' + ]; + $this->assertEquals($this->object->isValid($headers), true); + + $headers = [ + 'X-Custom-Header_With-Hyphens_and_Underscores' => 'value' + ]; + $this->assertFalse($this->object->isValid($headers)); + + $headers = [ + 'X-Header-123' => 'value' + ]; + $this->assertTrue($this->object->isValid($headers)); + + $headers = [ + 'X-Header<>' => 'value' + ]; + $this->assertFalse($this->object->isValid($headers)); + + $headers = [ + 'X Header' => 'value' + ]; + $this->assertFalse($this->object->isValid($headers)); + + $headers = [ + '' => 'value' + ]; + $this->assertFalse($this->object->isValid($headers)); + + $headers = [ + null => 'value', + ]; + $this->assertFalse($this->object->isValid($headers)); + + $headers = [ + 'X-Header' => null, + ]; + $this->assertTrue($this->object->isValid($headers)); + + $headers = [ + true => 'value', + ]; + $this->assertFalse($this->object->isValid($headers)); + + $headers = [ + 'a' => 'b', + ]; + $this->assertTrue($this->object->isValid($headers)); + + $headers = 123; + $this->assertFalse($this->object->isValid($headers)); + + $headers = true; + $this->assertFalse($this->object->isValid($headers)); + + $headers = 'string'; + $this->assertFalse($this->object->isValid($headers)); + } +}