diff --git a/.env b/.env index 73283cb4ff..0c60f99880 100644 --- a/.env +++ b/.env @@ -23,7 +23,7 @@ _APP_OPENSSL_KEY_V1=your-secret-key _APP_DOMAIN=traefik _APP_DOMAIN_FUNCTIONS=functions.localhost _APP_DOMAIN_SITES=sites.localhost -_APP_DOMAIN_TARGET=localhost +_APP_DOMAIN_TARGET=test.appwrite.io _APP_RULES_FORMAT=md5 _APP_REDIS_HOST=redis _APP_REDIS_PORT=6379 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index fef758a613..f7c5fe7c5c 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -146,6 +146,7 @@ jobs: Projects, Realtime, Sites, + Proxy, Storage, Teams, Users, @@ -212,6 +213,7 @@ jobs: Projects, Realtime, Sites, + Proxy, Storage, Teams, Users, diff --git a/app/config/collections/platform.php b/app/config/collections/platform.php index 8a46bfd3ec..58867bf2ba 100644 --- a/app/config/collections/platform.php +++ b/app/config/collections/platform.php @@ -1013,10 +1013,10 @@ return [ 'filters' => [], ], [ - '$id' => ID::custom('resourceType'), + '$id' => ID::custom('type'), // 'api', 'redirect', 'deployment' (site or function) 'type' => Database::VAR_STRING, 'format' => '', - 'size' => 100, + 'size' => 32, 'signed' => true, 'required' => true, 'default' => null, @@ -1024,24 +1024,28 @@ return [ 'filters' => [], ], [ - '$id' => ID::custom('resourceInternalId'), + // If 'api', then (empty) + // If 'redirect', then URL + // If 'deployment', then deployment ID + '$id' => ID::custom('value'), 'type' => Database::VAR_STRING, 'format' => '', - 'size' => Database::LENGTH_KEY, + 'size' => 512, 'signed' => true, 'required' => false, - 'default' => null, + 'default' => '', 'array' => false, 'filters' => [], ], [ - '$id' => ID::custom('resourceId'), + // Examples: branch=main + '$id' => ID::custom('automation'), 'type' => Database::VAR_STRING, 'format' => '', 'size' => Database::LENGTH_KEY, 'signed' => true, 'required' => false, - 'default' => null, + 'default' => '', 'array' => false, 'filters' => [], ], @@ -1066,9 +1070,27 @@ return [ 'default' => null, 'array' => false, 'filters' => [], - ] + ], + [ + '$id' => ID::custom('search'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => 16384, + 'signed' => true, + 'required' => false, + 'default' => null, + 'array' => false, + 'filters' => [], + ], ], 'indexes' => [ + [ + '$id' => ID::custom('_key_search'), + 'type' => Database::INDEX_FULLTEXT, + 'attributes' => ['search'], + 'lengths' => [], + 'orders' => [], + ], [ '$id' => ID::custom('_key_domain'), 'type' => Database::INDEX_UNIQUE, @@ -1091,24 +1113,24 @@ return [ 'orders' => [Database::ORDER_ASC], ], [ - '$id' => '_key_resourceInternalId', + '$id' => '_key_type', 'type' => Database::INDEX_KEY, - 'attributes' => ['resourceInternalId'], - 'lengths' => [Database::LENGTH_KEY], + 'attributes' => ['type'], + 'lengths' => [32], 'orders' => [Database::ORDER_ASC], ], [ - '$id' => '_key_resourceId', + '$id' => '_key_value', 'type' => Database::INDEX_KEY, - 'attributes' => ['resourceId'], - 'lengths' => [Database::LENGTH_KEY], + 'attributes' => ['value'], + 'lengths' => [512], 'orders' => [Database::ORDER_ASC], ], [ - '$id' => '_key_resourceType', + '$id' => '_key_automation', 'type' => Database::INDEX_KEY, - 'attributes' => ['resourceType'], - 'lengths' => [], + 'attributes' => ['automation'], + 'lengths' => [Database::LENGTH_KEY], 'orders' => [Database::ORDER_ASC], ], ], diff --git a/app/config/services.php b/app/config/services.php index 32eac3d324..43e80387f1 100644 --- a/app/config/services.php +++ b/app/config/services.php @@ -191,7 +191,7 @@ return [ 'name' => 'Functions', 'subtitle' => 'The Functions Service allows you view, create and manage your Cloud Functions.', 'description' => '/docs/services/functions.md', - 'controller' => 'api/functions.php', + 'controller' => '', // Uses modules 'sdk' => true, 'docs' => true, 'docsUrl' => 'https://appwrite.io/docs/functions', diff --git a/app/config/specs/open-api3-latest-console.json b/app/config/specs/open-api3-latest-console.json index dc4df59cf2..6be9d6e5e0 100644 --- a/app/config/specs/open-api3-latest-console.json +++ b/app/config/specs/open-api3-latest-console.json @@ -4398,7 +4398,7 @@ "enum": [ "rules" ], - "x-enum-name": null, + "x-enum-name": "ConsoleResourceType", "x-enum-keys": [] }, "in": "query" @@ -23683,7 +23683,7 @@ "parameters": [ { "name": "queries", - "description": "Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https:\/\/appwrite.io\/docs\/databases#querying-documents). Maximum of 100 queries are allowed, each 4096 characters long. You may filter on the following attributes: domain, resourceType, resourceId, url", + "description": "Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https:\/\/appwrite.io\/docs\/databases#querying-documents). Maximum of 100 queries are allowed, each 4096 characters long. You may filter on the following attributes: domain, type, value, automation, url", "required": false, "schema": { "type": "array", @@ -23706,10 +23706,12 @@ "in": "query" } ] - }, + } + }, + "\/proxy\/rules\/api": { "post": { - "summary": "Create rule", - "operationId": "proxyCreateRule", + "summary": "Create API rule", + "operationId": "proxyCreateAPIRule", "tags": [ "proxy" ], @@ -23727,13 +23729,79 @@ } }, "x-appwrite": { - "method": "createRule", + "method": "createAPIRule", "weight": 423, "cookies": false, "type": "", "deprecated": false, - "demo": "proxy\/create-rule.md", - "edit": "https:\/\/github.com\/appwrite\/appwrite\/edit\/masterCreate a new proxy rule.", + "demo": "proxy\/create-a-p-i-rule.md", + "edit": "https:\/\/github.com\/appwrite\/appwrite\/edit\/masterCreate a new proxy rule for serving Appwrite's API on custom domain.", + "rate-limit": 10, + "rate-time": 60, + "rate-key": "userId:{userId}, url:{url}", + "scope": "rules.write", + "platforms": [ + "console" + ], + "packaging": false, + "auth": { + "Project": [] + } + }, + "security": [ + { + "Project": [] + } + ], + "requestBody": { + "content": { + "application\/json": { + "schema": { + "type": "object", + "properties": { + "domain": { + "type": "string", + "description": "Domain name.", + "x-example": null + } + }, + "required": [ + "domain" + ] + } + } + } + } + } + }, + "\/proxy\/rules\/function": { + "post": { + "summary": "Create function rule", + "operationId": "proxyCreateFunctionRule", + "tags": [ + "proxy" + ], + "description": "", + "responses": { + "201": { + "description": "Rule", + "content": { + "application\/json": { + "schema": { + "$ref": "#\/components\/schemas\/proxyRule" + } + } + } + } + }, + "x-appwrite": { + "method": "createFunctionRule", + "weight": 425, + "cookies": false, + "type": "", + "deprecated": false, + "demo": "proxy\/create-function-rule.md", + "edit": "https:\/\/github.com\/appwrite\/appwrite\/edit\/masterCreate a new proxy rule for executing Appwrite Function on custom domain.", "rate-limit": 10, "rate-time": 60, "rate-key": "userId:{userId}, url:{url}", @@ -23762,27 +23830,169 @@ "description": "Domain name.", "x-example": null }, - "resourceType": { + "functionId": { "type": "string", - "description": "Action definition for the rule. Possible values are \"api\", \"function\" and \"site\"", - "x-example": "api", - "enum": [ - "api", - "function", - "site" - ], - "x-enum-name": null, - "x-enum-keys": [] + "description": "ID of function to be executed.", + "x-example": "" }, - "resourceId": { + "branch": { "type": "string", - "description": "ID of resource for the action type. If resourceType is \"api\", leave empty. If resourceType is \"function\", provide ID of the function.", - "x-example": "" + "description": "Name of VCS branch to deploy changes automatically", + "x-example": "" } }, "required": [ "domain", - "resourceType" + "functionId" + ] + } + } + } + } + } + }, + "\/proxy\/rules\/redirect": { + "post": { + "summary": "Create Redirect rule", + "operationId": "proxyCreateRedirectRule", + "tags": [ + "proxy" + ], + "description": "", + "responses": { + "201": { + "description": "Rule", + "content": { + "application\/json": { + "schema": { + "$ref": "#\/components\/schemas\/proxyRule" + } + } + } + } + }, + "x-appwrite": { + "method": "createRedirectRule", + "weight": 426, + "cookies": false, + "type": "", + "deprecated": false, + "demo": "proxy\/create-redirect-rule.md", + "edit": "https:\/\/github.com\/appwrite\/appwrite\/edit\/masterCreate a new proxy rule for to redirect from custom domain to another domain.", + "rate-limit": 10, + "rate-time": 60, + "rate-key": "userId:{userId}, url:{url}", + "scope": "rules.write", + "platforms": [ + "console" + ], + "packaging": false, + "auth": { + "Project": [] + } + }, + "security": [ + { + "Project": [] + } + ], + "requestBody": { + "content": { + "application\/json": { + "schema": { + "type": "object", + "properties": { + "domain": { + "type": "string", + "description": "Domain name.", + "x-example": null + }, + "target": { + "type": "string", + "description": "Target domain (hostname) of redirection", + "x-example": null + } + }, + "required": [ + "domain", + "target" + ] + } + } + } + } + } + }, + "\/proxy\/rules\/site": { + "post": { + "summary": "Create site rule", + "operationId": "proxyCreateSiteRule", + "tags": [ + "proxy" + ], + "description": "", + "responses": { + "201": { + "description": "Rule", + "content": { + "application\/json": { + "schema": { + "$ref": "#\/components\/schemas\/proxyRule" + } + } + } + } + }, + "x-appwrite": { + "method": "createSiteRule", + "weight": 424, + "cookies": false, + "type": "", + "deprecated": false, + "demo": "proxy\/create-site-rule.md", + "edit": "https:\/\/github.com\/appwrite\/appwrite\/edit\/masterCreate a new proxy rule for serving Appwrite Site on custom domain.", + "rate-limit": 10, + "rate-time": 60, + "rate-key": "userId:{userId}, url:{url}", + "scope": "rules.write", + "platforms": [ + "console" + ], + "packaging": false, + "auth": { + "Project": [] + } + }, + "security": [ + { + "Project": [] + } + ], + "requestBody": { + "content": { + "application\/json": { + "schema": { + "type": "object", + "properties": { + "domain": { + "type": "string", + "description": "Domain name.", + "x-example": null + }, + "siteId": { + "type": "string", + "description": "ID of site to be executed.", + "x-example": "" + }, + "branch": { + "type": "string", + "description": "Name of VCS branch to deploy changes automatically", + "x-example": "" + } + }, + "required": [ + "domain", + "siteId" ] } } @@ -24205,8 +24415,14 @@ }, "adapter": { "type": "string", - "description": "Framework adapter. Allows: static, ssr", - "x-example": "" + "description": "Framework adapter defining rendering strategy. Allowed values are: static, ssr", + "x-example": "static", + "enum": [ + "static", + "ssr" + ], + "x-enum-name": null, + "x-enum-keys": [] }, "installationId": { "type": "string", @@ -24772,8 +24988,14 @@ }, "adapter": { "type": "string", - "description": "Framework adapter. Usuallly allows: static, ssr", - "x-example": "" + "description": "Framework adapter defining rendering strategy. Allowed values are: static, ssr", + "x-example": "static", + "enum": [ + "static", + "ssr" + ], + "x-enum-name": null, + "x-enum-keys": [] }, "fallbackFile": { "type": "string", @@ -37167,11 +37389,6 @@ "x-example": 128, "format": "int32" }, - "domain": { - "type": "string", - "description": "Preview domain.", - "x-example": "deploy1-project1.appwrite.site" - }, "providerRepositoryName": { "type": "string", "description": "The name of the vcs provider repository", @@ -37240,7 +37457,6 @@ "status", "buildLogs", "buildTime", - "domain", "providerRepositoryName", "providerRepositoryOwner", "providerRepositoryUrl", @@ -39780,15 +39996,20 @@ "description": "Domain name.", "x-example": "appwrite.company.com" }, - "resourceType": { + "type": { "type": "string", - "description": "Action definition for the rule. Possible values are \"api\", \"function\", or \"redirect\"", - "x-example": "function" + "description": "Action definition for the rule. Possible values are \"api\", \"deployment\", or \"redirect\"", + "x-example": "deployment" }, - "resourceId": { + "value": { "type": "string", - "description": "ID of resource for the action type. If resourceType is \"api\" or \"url\", it is empty. If resourceType is \"function\", it is ID of the function.", - "x-example": "myAwesomeFunction" + "description": "Detail specification for the type. If type is \"api\", this is empty. If type is \"redirect\", this is URL. If type is \"deployment\", this is deployment ID.", + "x-example": "67a9cf1a00150ee93abd" + }, + "automation": { + "type": "string", + "description": "Action that results in a rule update. If VCS branch, value can be of syntax \"branch=[name]\"", + "x-example": "branch=dev" }, "status": { "type": "string", @@ -39811,8 +40032,9 @@ "$createdAt", "$updatedAt", "domain", - "resourceType", - "resourceId", + "type", + "value", + "automation", "status", "logs", "renewAt" @@ -39944,6 +40166,11 @@ "type": "string", "description": "Defines if HTTPS is enforced for all requests.", "x-example": "enabled" + }, + "_APP_DOMAINS_NAMESERVERS": { + "type": "string", + "description": "Comma-separated list of nameservers.", + "x-example": "ns1.example.com,ns2.example.com" } }, "required": [ @@ -39955,7 +40182,8 @@ "_APP_DOMAIN_ENABLED", "_APP_ASSISTANT_ENABLED", "_APP_DOMAIN_SITES", - "_APP_OPTIONS_FORCE_HTTPS" + "_APP_OPTIONS_FORCE_HTTPS", + "_APP_DOMAINS_NAMESERVERS" ] }, "mfaChallenge": { diff --git a/app/config/specs/open-api3-latest-server.json b/app/config/specs/open-api3-latest-server.json index 2c0375e357..9bc803d777 100644 --- a/app/config/specs/open-api3-latest-server.json +++ b/app/config/specs/open-api3-latest-server.json @@ -16526,8 +16526,14 @@ }, "adapter": { "type": "string", - "description": "Framework adapter. Allows: static, ssr", - "x-example": "" + "description": "Framework adapter defining rendering strategy. Allowed values are: static, ssr", + "x-example": "static", + "enum": [ + "static", + "ssr" + ], + "x-enum-name": null, + "x-enum-keys": [] }, "installationId": { "type": "string", @@ -16871,8 +16877,14 @@ }, "adapter": { "type": "string", - "description": "Framework adapter. Usuallly allows: static, ssr", - "x-example": "" + "description": "Framework adapter defining rendering strategy. Allowed values are: static, ssr", + "x-example": "static", + "enum": [ + "static", + "ssr" + ], + "x-enum-name": null, + "x-enum-keys": [] }, "fallbackFile": { "type": "string", @@ -27488,11 +27500,6 @@ "x-example": 128, "format": "int32" }, - "domain": { - "type": "string", - "description": "Preview domain.", - "x-example": "deploy1-project1.appwrite.site" - }, "providerRepositoryName": { "type": "string", "description": "The name of the vcs provider repository", @@ -27561,7 +27568,6 @@ "status", "buildLogs", "buildTime", - "domain", "providerRepositoryName", "providerRepositoryOwner", "providerRepositoryUrl", diff --git a/app/config/specs/swagger2-latest-console.json b/app/config/specs/swagger2-latest-console.json index 849a1fde91..8b6dfb954d 100644 --- a/app/config/specs/swagger2-latest-console.json +++ b/app/config/specs/swagger2-latest-console.json @@ -4604,7 +4604,7 @@ "enum": [ "rules" ], - "x-enum-name": null, + "x-enum-name": "ConsoleResourceType", "x-enum-keys": [], "in": "query" } @@ -24168,7 +24168,7 @@ "parameters": [ { "name": "queries", - "description": "Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https:\/\/appwrite.io\/docs\/databases#querying-documents). Maximum of 100 queries are allowed, each 4096 characters long. You may filter on the following attributes: domain, resourceType, resourceId, url", + "description": "Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https:\/\/appwrite.io\/docs\/databases#querying-documents). Maximum of 100 queries are allowed, each 4096 characters long. You may filter on the following attributes: domain, type, value, automation, url", "required": false, "type": "array", "collectionFormat": "multi", @@ -24188,10 +24188,12 @@ "in": "query" } ] - }, + } + }, + "\/proxy\/rules\/api": { "post": { - "summary": "Create rule", - "operationId": "proxyCreateRule", + "summary": "Create API rule", + "operationId": "proxyCreateAPIRule", "consumes": [ "application\/json" ], @@ -24211,13 +24213,82 @@ } }, "x-appwrite": { - "method": "createRule", + "method": "createAPIRule", "weight": 423, "cookies": false, "type": "", "deprecated": false, - "demo": "proxy\/create-rule.md", - "edit": "https:\/\/github.com\/appwrite\/appwrite\/edit\/masterCreate a new proxy rule.", + "demo": "proxy\/create-a-p-i-rule.md", + "edit": "https:\/\/github.com\/appwrite\/appwrite\/edit\/masterCreate a new proxy rule for serving Appwrite's API on custom domain.", + "rate-limit": 10, + "rate-time": 60, + "rate-key": "userId:{userId}, url:{url}", + "scope": "rules.write", + "platforms": [ + "console" + ], + "packaging": false, + "auth": { + "Project": [] + } + }, + "security": [ + { + "Project": [] + } + ], + "parameters": [ + { + "name": "payload", + "in": "body", + "schema": { + "type": "object", + "properties": { + "domain": { + "type": "string", + "description": "Domain name.", + "default": null, + "x-example": null + } + }, + "required": [ + "domain" + ] + } + } + ] + } + }, + "\/proxy\/rules\/function": { + "post": { + "summary": "Create function rule", + "operationId": "proxyCreateFunctionRule", + "consumes": [ + "application\/json" + ], + "produces": [ + "application\/json" + ], + "tags": [ + "proxy" + ], + "description": "", + "responses": { + "201": { + "description": "Rule", + "schema": { + "$ref": "#\/definitions\/proxyRule" + } + } + }, + "x-appwrite": { + "method": "createFunctionRule", + "weight": 425, + "cookies": false, + "type": "", + "deprecated": false, + "demo": "proxy\/create-function-rule.md", + "edit": "https:\/\/github.com\/appwrite\/appwrite\/edit\/masterCreate a new proxy rule for executing Appwrite Function on custom domain.", "rate-limit": 10, "rate-time": 60, "rate-key": "userId:{userId}, url:{url}", @@ -24248,29 +24319,180 @@ "default": null, "x-example": null }, - "resourceType": { + "functionId": { "type": "string", - "description": "Action definition for the rule. Possible values are \"api\", \"function\" and \"site\"", + "description": "ID of function to be executed.", "default": null, - "x-example": "api", - "enum": [ - "api", - "function", - "site" - ], - "x-enum-name": null, - "x-enum-keys": [] + "x-example": "" }, - "resourceId": { + "branch": { "type": "string", - "description": "ID of resource for the action type. If resourceType is \"api\", leave empty. If resourceType is \"function\", provide ID of the function.", + "description": "Name of VCS branch to deploy changes automatically", "default": "", - "x-example": "" + "x-example": "" } }, "required": [ "domain", - "resourceType" + "functionId" + ] + } + } + ] + } + }, + "\/proxy\/rules\/redirect": { + "post": { + "summary": "Create Redirect rule", + "operationId": "proxyCreateRedirectRule", + "consumes": [ + "application\/json" + ], + "produces": [ + "application\/json" + ], + "tags": [ + "proxy" + ], + "description": "", + "responses": { + "201": { + "description": "Rule", + "schema": { + "$ref": "#\/definitions\/proxyRule" + } + } + }, + "x-appwrite": { + "method": "createRedirectRule", + "weight": 426, + "cookies": false, + "type": "", + "deprecated": false, + "demo": "proxy\/create-redirect-rule.md", + "edit": "https:\/\/github.com\/appwrite\/appwrite\/edit\/masterCreate a new proxy rule for to redirect from custom domain to another domain.", + "rate-limit": 10, + "rate-time": 60, + "rate-key": "userId:{userId}, url:{url}", + "scope": "rules.write", + "platforms": [ + "console" + ], + "packaging": false, + "auth": { + "Project": [] + } + }, + "security": [ + { + "Project": [] + } + ], + "parameters": [ + { + "name": "payload", + "in": "body", + "schema": { + "type": "object", + "properties": { + "domain": { + "type": "string", + "description": "Domain name.", + "default": null, + "x-example": null + }, + "target": { + "type": "string", + "description": "Target domain (hostname) of redirection", + "default": null, + "x-example": null + } + }, + "required": [ + "domain", + "target" + ] + } + } + ] + } + }, + "\/proxy\/rules\/site": { + "post": { + "summary": "Create site rule", + "operationId": "proxyCreateSiteRule", + "consumes": [ + "application\/json" + ], + "produces": [ + "application\/json" + ], + "tags": [ + "proxy" + ], + "description": "", + "responses": { + "201": { + "description": "Rule", + "schema": { + "$ref": "#\/definitions\/proxyRule" + } + } + }, + "x-appwrite": { + "method": "createSiteRule", + "weight": 424, + "cookies": false, + "type": "", + "deprecated": false, + "demo": "proxy\/create-site-rule.md", + "edit": "https:\/\/github.com\/appwrite\/appwrite\/edit\/masterCreate a new proxy rule for serving Appwrite Site on custom domain.", + "rate-limit": 10, + "rate-time": 60, + "rate-key": "userId:{userId}, url:{url}", + "scope": "rules.write", + "platforms": [ + "console" + ], + "packaging": false, + "auth": { + "Project": [] + } + }, + "security": [ + { + "Project": [] + } + ], + "parameters": [ + { + "name": "payload", + "in": "body", + "schema": { + "type": "object", + "properties": { + "domain": { + "type": "string", + "description": "Domain name.", + "default": null, + "x-example": null + }, + "siteId": { + "type": "string", + "description": "ID of site to be executed.", + "default": null, + "x-example": "" + }, + "branch": { + "type": "string", + "description": "Name of VCS branch to deploy changes automatically", + "default": "", + "x-example": "" + } + }, + "required": [ + "domain", + "siteId" ] } } @@ -24708,9 +24930,15 @@ }, "adapter": { "type": "string", - "description": "Framework adapter. Allows: static, ssr", + "description": "Framework adapter defining rendering strategy. Allowed values are: static, ssr", "default": "", - "x-example": "" + "x-example": "static", + "enum": [ + "static", + "ssr" + ], + "x-enum-name": null, + "x-enum-keys": [] }, "installationId": { "type": "string", @@ -25287,9 +25515,15 @@ }, "adapter": { "type": "string", - "description": "Framework adapter. Usuallly allows: static, ssr", + "description": "Framework adapter defining rendering strategy. Allowed values are: static, ssr", "default": "", - "x-example": "" + "x-example": "static", + "enum": [ + "static", + "ssr" + ], + "x-enum-name": null, + "x-enum-keys": [] }, "fallbackFile": { "type": "string", @@ -37750,11 +37984,6 @@ "x-example": 128, "format": "int32" }, - "domain": { - "type": "string", - "description": "Preview domain.", - "x-example": "deploy1-project1.appwrite.site" - }, "providerRepositoryName": { "type": "string", "description": "The name of the vcs provider repository", @@ -37823,7 +38052,6 @@ "status", "buildLogs", "buildTime", - "domain", "providerRepositoryName", "providerRepositoryOwner", "providerRepositoryUrl", @@ -40434,15 +40662,20 @@ "description": "Domain name.", "x-example": "appwrite.company.com" }, - "resourceType": { + "type": { "type": "string", - "description": "Action definition for the rule. Possible values are \"api\", \"function\", or \"redirect\"", - "x-example": "function" + "description": "Action definition for the rule. Possible values are \"api\", \"deployment\", or \"redirect\"", + "x-example": "deployment" }, - "resourceId": { + "value": { "type": "string", - "description": "ID of resource for the action type. If resourceType is \"api\" or \"url\", it is empty. If resourceType is \"function\", it is ID of the function.", - "x-example": "myAwesomeFunction" + "description": "Detail specification for the type. If type is \"api\", this is empty. If type is \"redirect\", this is URL. If type is \"deployment\", this is deployment ID.", + "x-example": "67a9cf1a00150ee93abd" + }, + "automation": { + "type": "string", + "description": "Action that results in a rule update. If VCS branch, value can be of syntax \"branch=[name]\"", + "x-example": "branch=dev" }, "status": { "type": "string", @@ -40465,8 +40698,9 @@ "$createdAt", "$updatedAt", "domain", - "resourceType", - "resourceId", + "type", + "value", + "automation", "status", "logs", "renewAt" @@ -40598,6 +40832,11 @@ "type": "string", "description": "Defines if HTTPS is enforced for all requests.", "x-example": "enabled" + }, + "_APP_DOMAINS_NAMESERVERS": { + "type": "string", + "description": "Comma-separated list of nameservers.", + "x-example": "ns1.example.com,ns2.example.com" } }, "required": [ @@ -40609,7 +40848,8 @@ "_APP_DOMAIN_ENABLED", "_APP_ASSISTANT_ENABLED", "_APP_DOMAIN_SITES", - "_APP_OPTIONS_FORCE_HTTPS" + "_APP_OPTIONS_FORCE_HTTPS", + "_APP_DOMAINS_NAMESERVERS" ] }, "mfaChallenge": { diff --git a/app/config/specs/swagger2-latest-server.json b/app/config/specs/swagger2-latest-server.json index d0dace624e..8d7586da05 100644 --- a/app/config/specs/swagger2-latest-server.json +++ b/app/config/specs/swagger2-latest-server.json @@ -17008,9 +17008,15 @@ }, "adapter": { "type": "string", - "description": "Framework adapter. Allows: static, ssr", + "description": "Framework adapter defining rendering strategy. Allowed values are: static, ssr", "default": "", - "x-example": "" + "x-example": "static", + "enum": [ + "static", + "ssr" + ], + "x-enum-name": null, + "x-enum-keys": [] }, "installationId": { "type": "string", @@ -17369,9 +17375,15 @@ }, "adapter": { "type": "string", - "description": "Framework adapter. Usuallly allows: static, ssr", + "description": "Framework adapter defining rendering strategy. Allowed values are: static, ssr", "default": "", - "x-example": "" + "x-example": "static", + "enum": [ + "static", + "ssr" + ], + "x-enum-name": null, + "x-enum-keys": [] }, "fallbackFile": { "type": "string", @@ -28054,11 +28066,6 @@ "x-example": 128, "format": "int32" }, - "domain": { - "type": "string", - "description": "Preview domain.", - "x-example": "deploy1-project1.appwrite.site" - }, "providerRepositoryName": { "type": "string", "description": "The name of the vcs provider repository", @@ -28127,7 +28134,6 @@ "status", "buildLogs", "buildTime", - "domain", "providerRepositoryName", "providerRepositoryOwner", "providerRepositoryUrl", diff --git a/app/controllers/api/console.php b/app/controllers/api/console.php index d83cdc79f8..d4b191cd62 100644 --- a/app/controllers/api/console.php +++ b/app/controllers/api/console.php @@ -62,7 +62,8 @@ App::get('/v1/console/variables') '_APP_DOMAIN_ENABLED' => $isDomainEnabled, '_APP_ASSISTANT_ENABLED' => $isAssistantEnabled, '_APP_DOMAIN_SITES' => System::getEnv('_APP_DOMAIN_SITES'), - '_APP_OPTIONS_FORCE_HTTPS' => System::getEnv('_APP_OPTIONS_FORCE_HTTPS') + '_APP_OPTIONS_FORCE_HTTPS' => System::getEnv('_APP_OPTIONS_FORCE_HTTPS'), + '_APP_DOMAINS_NAMESERVERS' => System::getEnv('_APP_DOMAINS_NAMESERVERS'), ]); $response->dynamic($variables, Response::MODEL_CONSOLE_VARIABLES); diff --git a/app/controllers/api/functions.php b/app/controllers/api/functions.php deleted file mode 100644 index c2909eabba..0000000000 --- a/app/controllers/api/functions.php +++ /dev/null @@ -1,1907 +0,0 @@ -groups(['api', 'functions']) - ->desc('List available function runtime specifications') - ->label('scope', 'functions.read') - ->label('resourceType', RESOURCE_TYPE_FUNCTIONS) - ->label('sdk', new Method( - namespace: 'functions', - name: 'listSpecifications', - description: '/docs/references/functions/list-specifications.md', - auth: [AuthType::KEY, AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_SPECIFICATION_LIST, - ) - ] - )) - ->inject('response') - ->inject('plan') - ->action(function (Response $response, array $plan) { - $allRuntimeSpecs = Config::getParam('runtime-specifications', []); - - $runtimeSpecs = []; - foreach ($allRuntimeSpecs as $spec) { - $spec['enabled'] = true; - - if (array_key_exists('runtimeSpecifications', $plan)) { - $spec['enabled'] = in_array($spec['slug'], $plan['runtimeSpecifications']); - } - - // Only add specs that are within the limits set by environment variables - if ($spec['cpus'] <= System::getEnv('_APP_COMPUTE_CPUS', 1) && $spec['memory'] <= System::getEnv('_APP_COMPUTE_MEMORY', 512)) { - $runtimeSpecs[] = $spec; - } - } - - $response->dynamic(new Document([ - 'specifications' => $runtimeSpecs, - 'total' => count($runtimeSpecs) - ]), Response::MODEL_SPECIFICATION_LIST); - }); - -App::get('/v1/functions/:functionId') - ->groups(['api', 'functions']) - ->desc('Get function') - ->label('scope', 'functions.read') - ->label('resourceType', RESOURCE_TYPE_FUNCTIONS) - ->label('sdk', new Method( - namespace: 'functions', - name: 'get', - description: '/docs/references/functions/get-function.md', - auth: [AuthType::KEY], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_FUNCTION, - ) - ] - )) - ->param('functionId', '', new UID(), 'Function ID.') - ->inject('response') - ->inject('dbForProject') - ->action(function (string $functionId, Response $response, Database $dbForProject) { - $function = $dbForProject->getDocument('functions', $functionId); - - if ($function->isEmpty()) { - throw new Exception(Exception::FUNCTION_NOT_FOUND); - } - - $response->dynamic($function, Response::MODEL_FUNCTION); - }); - -App::get('/v1/functions/:functionId/usage') - ->desc('Get function usage') - ->groups(['api', 'functions', 'usage']) - ->label('scope', 'functions.read') - ->label('resourceType', RESOURCE_TYPE_FUNCTIONS) - ->label('sdk', new Method( - namespace: 'functions', - name: 'getFunctionUsage', - description: '/docs/references/functions/get-function-usage.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_USAGE_FUNCTION, - ) - ] - )) - ->param('functionId', '', new UID(), 'Function ID.') - ->param('range', '30d', new WhiteList(['24h', '30d', '90d']), 'Date range.', true) - ->inject('response') - ->inject('dbForProject') - ->action(function (string $functionId, string $range, Response $response, Database $dbForProject) { - - $function = $dbForProject->getDocument('functions', $functionId); - - if ($function->isEmpty()) { - throw new Exception(Exception::FUNCTION_NOT_FOUND); - } - - $periods = Config::getParam('usage', []); - $stats = $usage = []; - $days = $periods[$range]; - $metrics = [ - str_replace(['{resourceType}', '{resourceInternalId}'], ['functions', $function->getInternalId()], METRIC_FUNCTION_ID_DEPLOYMENTS), - str_replace(['{resourceType}', '{resourceInternalId}'], ['functions', $function->getInternalId()], METRIC_FUNCTION_ID_DEPLOYMENTS_STORAGE), - str_replace('{functionInternalId}', $function->getInternalId(), METRIC_FUNCTION_ID_BUILDS), - str_replace('{functionInternalId}', $function->getInternalId(), METRIC_FUNCTION_ID_BUILDS_STORAGE), - str_replace('{functionInternalId}', $function->getInternalId(), METRIC_FUNCTION_ID_BUILDS_COMPUTE), - str_replace('{functionInternalId}', $function->getInternalId(), METRIC_FUNCTION_ID_EXECUTIONS), - str_replace('{functionInternalId}', $function->getInternalId(), METRIC_FUNCTION_ID_EXECUTIONS_COMPUTE), - str_replace('{functionInternalId}', $function->getInternalId(), METRIC_FUNCTION_ID_BUILDS_MB_SECONDS), - str_replace('{functionInternalId}', $function->getInternalId(), METRIC_FUNCTION_ID_EXECUTIONS_MB_SECONDS) - ]; - - Authorization::skip(function () use ($dbForProject, $days, $metrics, &$stats) { - foreach ($metrics as $metric) { - $result = $dbForProject->findOne('stats', [ - Query::equal('metric', [$metric]), - Query::equal('period', ['inf']) - ]); - - $stats[$metric]['total'] = $result['value'] ?? 0; - $limit = $days['limit']; - $period = $days['period']; - $results = $dbForProject->find('stats', [ - Query::equal('metric', [$metric]), - Query::equal('period', [$period]), - Query::limit($limit), - Query::orderDesc('time'), - ]); - $stats[$metric]['data'] = []; - foreach ($results as $result) { - $stats[$metric]['data'][$result->getAttribute('time')] = [ - 'value' => $result->getAttribute('value'), - ]; - } - } - }); - - $format = match ($days['period']) { - '1h' => 'Y-m-d\TH:00:00.000P', - '1d' => 'Y-m-d\T00:00:00.000P', - }; - - foreach ($metrics as $metric) { - $usage[$metric]['total'] = $stats[$metric]['total']; - $usage[$metric]['data'] = []; - $leap = time() - ($days['limit'] * $days['factor']); - while ($leap < time()) { - $leap += $days['factor']; - $formatDate = date($format, $leap); - $usage[$metric]['data'][] = [ - 'value' => $stats[$metric]['data'][$formatDate]['value'] ?? 0, - 'date' => $formatDate, - ]; - } - } - - $response->dynamic(new Document([ - 'range' => $range, - 'deploymentsTotal' => $usage[$metrics[0]]['total'], - 'deploymentsStorageTotal' => $usage[$metrics[1]]['total'], - 'buildsTotal' => $usage[$metrics[2]]['total'], - 'buildsStorageTotal' => $usage[$metrics[3]]['total'], - 'buildsTimeTotal' => $usage[$metrics[4]]['total'], - 'executionsTotal' => $usage[$metrics[5]]['total'], - 'executionsTimeTotal' => $usage[$metrics[6]]['total'], - 'deployments' => $usage[$metrics[0]]['data'], - 'deploymentsStorage' => $usage[$metrics[1]]['data'], - 'builds' => $usage[$metrics[2]]['data'], - 'buildsStorage' => $usage[$metrics[3]]['data'], - 'buildsTime' => $usage[$metrics[4]]['data'], - 'executions' => $usage[$metrics[5]]['data'], - 'executionsTime' => $usage[$metrics[6]]['data'], - 'buildsMbSecondsTotal' => $usage[$metrics[7]]['total'], - 'buildsMbSeconds' => $usage[$metrics[7]]['data'], - 'executionsMbSeconds' => $usage[$metrics[8]]['data'], - 'executionsMbSecondsTotal' => $usage[$metrics[8]]['total'] - ]), Response::MODEL_USAGE_FUNCTION); - }); - -App::get('/v1/functions/usage') - ->desc('Get functions usage') - ->groups(['api', 'functions', 'usage']) - ->label('scope', 'functions.read') - ->label('resourceType', RESOURCE_TYPE_FUNCTIONS) - ->label('sdk', new Method( - namespace: 'functions', - name: 'getUsage', - description: '/docs/references/functions/get-functions-usage.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_USAGE_FUNCTIONS, - ) - ] - )) - ->param('range', '30d', new WhiteList(['24h', '30d', '90d']), 'Date range.', true) - ->inject('response') - ->inject('dbForProject') - ->action(function (string $range, Response $response, Database $dbForProject) { - - $periods = Config::getParam('usage', []); - $stats = $usage = []; - $days = $periods[$range]; - $metrics = [ - METRIC_FUNCTIONS, - METRIC_DEPLOYMENTS, - METRIC_DEPLOYMENTS_STORAGE, - METRIC_BUILDS, - METRIC_BUILDS_STORAGE, - METRIC_BUILDS_COMPUTE, - METRIC_EXECUTIONS, - METRIC_EXECUTIONS_COMPUTE, - METRIC_BUILDS_MB_SECONDS, - METRIC_EXECUTIONS_MB_SECONDS, - ]; - - Authorization::skip(function () use ($dbForProject, $days, $metrics, &$stats) { - foreach ($metrics as $metric) { - $result = $dbForProject->findOne('stats', [ - Query::equal('metric', [$metric]), - Query::equal('period', ['inf']) - ]); - - $stats[$metric]['total'] = $result['value'] ?? 0; - $limit = $days['limit']; - $period = $days['period']; - $results = $dbForProject->find('stats', [ - Query::equal('metric', [$metric]), - Query::equal('period', [$period]), - Query::limit($limit), - Query::orderDesc('time'), - ]); - $stats[$metric]['data'] = []; - foreach ($results as $result) { - $stats[$metric]['data'][$result->getAttribute('time')] = [ - 'value' => $result->getAttribute('value'), - ]; - } - } - }); - - $format = match ($days['period']) { - '1h' => 'Y-m-d\TH:00:00.000P', - '1d' => 'Y-m-d\T00:00:00.000P', - }; - - foreach ($metrics as $metric) { - $usage[$metric]['total'] = $stats[$metric]['total']; - $usage[$metric]['data'] = []; - $leap = time() - ($days['limit'] * $days['factor']); - while ($leap < time()) { - $leap += $days['factor']; - $formatDate = date($format, $leap); - $usage[$metric]['data'][] = [ - 'value' => $stats[$metric]['data'][$formatDate]['value'] ?? 0, - 'date' => $formatDate, - ]; - } - } - $response->dynamic(new Document([ - 'range' => $range, - 'functionsTotal' => $usage[$metrics[0]]['total'], - 'deploymentsTotal' => $usage[$metrics[1]]['total'], - 'deploymentsStorageTotal' => $usage[$metrics[2]]['total'], - 'buildsTotal' => $usage[$metrics[3]]['total'], - 'buildsStorageTotal' => $usage[$metrics[4]]['total'], - 'buildsTimeTotal' => $usage[$metrics[5]]['total'], - 'executionsTotal' => $usage[$metrics[6]]['total'], - 'executionsTimeTotal' => $usage[$metrics[7]]['total'], - 'functions' => $usage[$metrics[0]]['data'], - 'deployments' => $usage[$metrics[1]]['data'], - 'deploymentsStorage' => $usage[$metrics[2]]['data'], - 'builds' => $usage[$metrics[3]]['data'], - 'buildsStorage' => $usage[$metrics[4]]['data'], - 'buildsTime' => $usage[$metrics[5]]['data'], - 'executions' => $usage[$metrics[6]]['data'], - 'executionsTime' => $usage[$metrics[7]]['data'], - 'buildsMbSecondsTotal' => $usage[$metrics[8]]['total'], - 'buildsMbSeconds' => $usage[$metrics[8]]['data'], - 'executionsMbSeconds' => $usage[$metrics[9]]['data'], - 'executionsMbSecondsTotal' => $usage[$metrics[9]]['total'], - ]), Response::MODEL_USAGE_FUNCTIONS); - }); - -App::get('/v1/functions/:functionId/deployments/:deploymentId/download') - ->groups(['api', 'functions']) - ->desc('Download deployment') - ->label('scope', 'functions.read') - ->label('resourceType', RESOURCE_TYPE_FUNCTIONS) - ->label('sdk', new Method( - namespace: 'functions', - name: 'getDeploymentDownload', - description: '/docs/references/functions/get-deployment-download.md', - auth: [AuthType::KEY, AuthType::JWT], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_NONE, - ) - ], - contentType: ContentType::ANY, - type: MethodType::LOCATION - )) - ->param('functionId', '', new UID(), 'Function ID.') - ->param('deploymentId', '', new UID(), 'Deployment ID.') - ->inject('response') - ->inject('request') - ->inject('dbForProject') - ->inject('deviceForFunctions') - ->action(function (string $functionId, string $deploymentId, Response $response, Request $request, Database $dbForProject, Device $deviceForFunctions) { - - $function = $dbForProject->getDocument('functions', $functionId); - if ($function->isEmpty()) { - throw new Exception(Exception::FUNCTION_NOT_FOUND); - } - - $deployment = $dbForProject->getDocument('deployments', $deploymentId); - if ($deployment->isEmpty()) { - throw new Exception(Exception::DEPLOYMENT_NOT_FOUND); - } - - if ($deployment->getAttribute('resourceId') !== $function->getId()) { - throw new Exception(Exception::DEPLOYMENT_NOT_FOUND); - } - - $path = $deployment->getAttribute('path', ''); - if (!$deviceForFunctions->exists($path)) { - throw new Exception(Exception::DEPLOYMENT_NOT_FOUND); - } - - $response - ->setContentType('application/gzip') - ->addHeader('Cache-Control', 'private, max-age=3888000') // 45 days - ->addHeader('X-Peak', \memory_get_peak_usage()) - ->addHeader('Content-Disposition', 'attachment; filename="' . $deploymentId . '.tar.gz"'); - - $size = $deviceForFunctions->getFileSize($path); - $rangeHeader = $request->getHeader('range'); - - if (!empty($rangeHeader)) { - $start = $request->getRangeStart(); - $end = $request->getRangeEnd(); - $unit = $request->getRangeUnit(); - - if ($end === null) { - $end = min(($start + MAX_OUTPUT_CHUNK_SIZE - 1), ($size - 1)); - } - - if ($unit !== 'bytes' || $start >= $end || $end >= $size) { - throw new Exception(Exception::STORAGE_INVALID_RANGE); - } - - $response - ->addHeader('Accept-Ranges', 'bytes') - ->addHeader('Content-Range', 'bytes ' . $start . '-' . $end . '/' . $size) - ->addHeader('Content-Length', $end - $start + 1) - ->setStatusCode(Response::STATUS_CODE_PARTIALCONTENT); - - $response->send($deviceForFunctions->read($path, $start, ($end - $start + 1))); - } - - if ($size > APP_STORAGE_READ_BUFFER) { - for ($i = 0; $i < ceil($size / MAX_OUTPUT_CHUNK_SIZE); $i++) { - $response->chunk( - $deviceForFunctions->read( - $path, - ($i * MAX_OUTPUT_CHUNK_SIZE), - min(MAX_OUTPUT_CHUNK_SIZE, $size - ($i * MAX_OUTPUT_CHUNK_SIZE)) - ), - (($i + 1) * MAX_OUTPUT_CHUNK_SIZE) >= $size - ); - } - } else { - $response->send($deviceForFunctions->read($path)); - } - }); - -App::patch('/v1/functions/:functionId/deployments/:deploymentId') - ->groups(['api', 'functions']) - ->desc('Update deployment') - ->label('scope', 'functions.write') - ->label('resourceType', RESOURCE_TYPE_FUNCTIONS) - ->label('event', 'functions.[functionId].deployments.[deploymentId].update') - ->label('audits.event', 'deployment.update') - ->label('audits.resource', 'function/{request.functionId}') - ->label('sdk.auth', [APP_AUTH_TYPE_KEY]) - ->label('sdk.namespace', 'functions') - ->label('sdk.method', 'updateDeployment') - ->label('sdk.description', '/docs/references/functions/update-function-deployment.md') - ->label('sdk.response.code', Response::STATUS_CODE_OK) - ->label('sdk.response.type', Response::CONTENT_TYPE_JSON) - ->label('sdk.response.model', Response::MODEL_FUNCTION) - ->param('functionId', '', new UID(), 'Function ID.') - ->param('deploymentId', '', new UID(), 'Deployment ID.') - ->inject('response') - ->inject('dbForProject') - ->inject('queueForEvents') - ->inject('dbForPlatform') - ->action(function (string $functionId, string $deploymentId, Response $response, Database $dbForProject, Event $queueForEvents, Database $dbForPlatform) { - - $function = $dbForProject->getDocument('functions', $functionId); - $deployment = $dbForProject->getDocument('deployments', $deploymentId); - $build = $dbForProject->getDocument('builds', $deployment->getAttribute('buildId', '')); - - if ($function->isEmpty()) { - throw new Exception(Exception::FUNCTION_NOT_FOUND); - } - - if ($deployment->isEmpty()) { - throw new Exception(Exception::DEPLOYMENT_NOT_FOUND); - } - - if ($build->isEmpty()) { - throw new Exception(Exception::BUILD_NOT_FOUND); - } - - if ($build->getAttribute('status') !== 'ready') { - throw new Exception(Exception::BUILD_NOT_READY); - } - - $function = $dbForProject->updateDocument('functions', $function->getId(), new Document(array_merge($function->getArrayCopy(), [ - 'deploymentInternalId' => $deployment->getInternalId(), - 'deployment' => $deployment->getId(), - ]))); - - // Inform scheduler if function is still active - $schedule = $dbForPlatform->getDocument('schedules', $function->getAttribute('scheduleId')); - $schedule - ->setAttribute('resourceUpdatedAt', DateTime::now()) - ->setAttribute('schedule', $function->getAttribute('schedule')) - ->setAttribute('active', !empty($function->getAttribute('schedule')) && !empty($function->getAttribute('deployment'))); - Authorization::skip(fn () => $dbForPlatform->updateDocument('schedules', $schedule->getId(), $schedule)); - - $queueForEvents - ->setParam('functionId', $function->getId()) - ->setParam('deploymentId', $deployment->getId()); - - $response->dynamic($function, Response::MODEL_FUNCTION); - }); - -App::delete('/v1/functions/:functionId') - ->groups(['api', 'functions']) - ->desc('Delete function') - ->label('scope', 'functions.write') - ->label('resourceType', RESOURCE_TYPE_FUNCTIONS) - ->label('event', 'functions.[functionId].delete') - ->label('audits.event', 'function.delete') - ->label('audits.resource', 'function/{request.functionId}') - ->label('sdk', new Method( - namespace: 'functions', - name: 'delete', - description: '/docs/references/functions/delete-function.md', - auth: [AuthType::KEY], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_NOCONTENT, - model: Response::MODEL_NONE, - ) - ], - contentType: ContentType::NONE - )) - ->param('functionId', '', new UID(), 'Function ID.') - ->inject('response') - ->inject('dbForProject') - ->inject('queueForDeletes') - ->inject('queueForEvents') - ->inject('dbForPlatform') - ->action(function (string $functionId, Response $response, Database $dbForProject, Delete $queueForDeletes, Event $queueForEvents, Database $dbForPlatform) { - - $function = $dbForProject->getDocument('functions', $functionId); - - if ($function->isEmpty()) { - throw new Exception(Exception::FUNCTION_NOT_FOUND); - } - - if (!$dbForProject->deleteDocument('functions', $function->getId())) { - throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed to remove function from DB'); - } - - // Inform scheduler to no longer run function - $schedule = $dbForPlatform->getDocument('schedules', $function->getAttribute('scheduleId')); - $schedule - ->setAttribute('resourceUpdatedAt', DateTime::now()) - ->setAttribute('active', false); - Authorization::skip(fn () => $dbForPlatform->updateDocument('schedules', $schedule->getId(), $schedule)); - - $queueForDeletes - ->setType(DELETE_TYPE_DOCUMENT) - ->setDocument($function); - - $queueForEvents->setParam('functionId', $function->getId()); - - $response->noContent(); - }); - -App::get('/v1/functions/:functionId/deployments') - ->groups(['api', 'functions']) - ->desc('List deployments') - ->label('scope', 'functions.read') - ->label('resourceType', RESOURCE_TYPE_FUNCTIONS) - ->label('sdk', new Method( - namespace: 'functions', - name: 'listDeployments', - description: '/docs/references/functions/list-deployments.md', - auth: [AuthType::KEY], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_DEPLOYMENT_LIST, - ) - ] - )) - ->param('functionId', '', new UID(), 'Function ID.') - ->param('queries', [], new Deployments(), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long. You may filter on the following attributes: ' . implode(', ', Deployments::ALLOWED_ATTRIBUTES), true) - ->param('search', '', new Text(256), 'Search term to filter your list results. Max length: 256 chars.', true) - ->inject('response') - ->inject('dbForProject') - ->action(function (string $functionId, array $queries, string $search, Response $response, Database $dbForProject) { - - $function = $dbForProject->getDocument('functions', $functionId); - - if ($function->isEmpty()) { - throw new Exception(Exception::FUNCTION_NOT_FOUND); - } - - try { - $queries = Query::parseQueries($queries); - } catch (QueryException $e) { - throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage()); - } - - if (!empty($search)) { - $queries[] = Query::search('search', $search); - } - - // Set resource queries - $queries[] = Query::equal('resourceInternalId', [$function->getInternalId()]); - $queries[] = Query::equal('resourceType', ['functions']); - - /** - * Get cursor document if there was a cursor query, we use array_filter and reset for reference $cursor to $queries - */ - $cursor = \array_filter($queries, function ($query) { - return \in_array($query->getMethod(), [Query::TYPE_CURSOR_AFTER, Query::TYPE_CURSOR_BEFORE]); - }); - $cursor = reset($cursor); - if ($cursor) { - /** @var Query $cursor */ - - $validator = new Cursor(); - if (!$validator->isValid($cursor)) { - throw new Exception(Exception::GENERAL_QUERY_INVALID, $validator->getDescription()); - } - - $deploymentId = $cursor->getValue(); - $cursorDocument = $dbForProject->getDocument('deployments', $deploymentId); - - if ($cursorDocument->isEmpty()) { - throw new Exception(Exception::GENERAL_CURSOR_NOT_FOUND, "Deployment '{$deploymentId}' for the 'cursor' value not found."); - } - - $cursor->setValue($cursorDocument); - } - - $filterQueries = Query::groupByType($queries)['filters']; - - $results = $dbForProject->find('deployments', $queries); - $total = $dbForProject->count('deployments', $filterQueries, APP_LIMIT_COUNT); - - foreach ($results as $result) { - $build = $dbForProject->getDocument('builds', $result->getAttribute('buildId', '')); - $result->setAttribute('status', $build->getAttribute('status', 'processing')); - $result->setAttribute('buildLogs', $build->getAttribute('logs', '')); - $result->setAttribute('buildTime', $build->getAttribute('duration', 0)); - $result->setAttribute('buildSize', $build->getAttribute('size', 0)); - $result->setAttribute('size', $result->getAttribute('size', 0)); - } - - $response->dynamic(new Document([ - 'deployments' => $results, - 'total' => $total, - ]), Response::MODEL_DEPLOYMENT_LIST); - }); - -App::get('/v1/functions/:functionId/deployments/:deploymentId') - ->groups(['api', 'functions']) - ->desc('Get deployment') - ->label('scope', 'functions.read') - ->label('resourceType', RESOURCE_TYPE_FUNCTIONS) - ->label('sdk', new Method( - namespace: 'functions', - name: 'getDeployment', - description: '/docs/references/functions/get-deployment.md', - auth: [AuthType::KEY], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_DEPLOYMENT, - ) - ] - )) - ->param('functionId', '', new UID(), 'Function ID.') - ->param('deploymentId', '', new UID(), 'Deployment ID.') - ->inject('response') - ->inject('dbForProject') - ->action(function (string $functionId, string $deploymentId, Response $response, Database $dbForProject) { - - $function = $dbForProject->getDocument('functions', $functionId); - - if ($function->isEmpty()) { - throw new Exception(Exception::FUNCTION_NOT_FOUND); - } - - $deployment = $dbForProject->getDocument('deployments', $deploymentId); - - if ($deployment->getAttribute('resourceId') !== $function->getId()) { - throw new Exception(Exception::DEPLOYMENT_NOT_FOUND); - } - - if ($deployment->isEmpty()) { - throw new Exception(Exception::DEPLOYMENT_NOT_FOUND); - } - - $build = $dbForProject->getDocument('builds', $deployment->getAttribute('buildId', '')); - $deployment->setAttribute('status', $build->getAttribute('status', 'waiting')); - $deployment->setAttribute('buildLogs', $build->getAttribute('logs', '')); - $deployment->setAttribute('buildTime', $build->getAttribute('duration', 0)); - $deployment->setAttribute('buildSize', $build->getAttribute('size', 0)); - $deployment->setAttribute('size', $deployment->getAttribute('size', 0)); - - $response->dynamic($deployment, Response::MODEL_DEPLOYMENT); - }); - -App::delete('/v1/functions/:functionId/deployments/:deploymentId') - ->groups(['api', 'functions']) - ->desc('Delete deployment') - ->label('scope', 'functions.write') - ->label('resourceType', RESOURCE_TYPE_FUNCTIONS) - ->label('event', 'functions.[functionId].deployments.[deploymentId].delete') - ->label('audits.event', 'deployment.delete') - ->label('audits.resource', 'function/{request.functionId}') - ->label('sdk', new Method( - namespace: 'functions', - name: 'deleteDeployment', - description: '/docs/references/functions/delete-deployment.md', - auth: [AuthType::KEY], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_NOCONTENT, - model: Response::MODEL_NONE, - ) - ], - contentType: ContentType::NONE - )) - ->param('functionId', '', new UID(), 'Function ID.') - ->param('deploymentId', '', new UID(), 'Deployment ID.') - ->inject('response') - ->inject('dbForProject') - ->inject('queueForDeletes') - ->inject('queueForEvents') - ->inject('deviceForFunctions') - ->action(function (string $functionId, string $deploymentId, Response $response, Database $dbForProject, Delete $queueForDeletes, Event $queueForEvents, Device $deviceForFunctions) { - - $function = $dbForProject->getDocument('functions', $functionId); - if ($function->isEmpty()) { - throw new Exception(Exception::FUNCTION_NOT_FOUND); - } - - $deployment = $dbForProject->getDocument('deployments', $deploymentId); - if ($deployment->isEmpty()) { - throw new Exception(Exception::DEPLOYMENT_NOT_FOUND); - } - - if ($deployment->getAttribute('resourceId') !== $function->getId()) { - throw new Exception(Exception::DEPLOYMENT_NOT_FOUND); - } - - if (!$dbForProject->deleteDocument('deployments', $deployment->getId())) { - throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed to remove deployment from DB'); - } - - if (!empty($deployment->getAttribute('path', ''))) { - if (!($deviceForFunctions->delete($deployment->getAttribute('path', '')))) { - throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed to remove deployment from storage'); - } - } - - if ($function->getAttribute('deployment') === $deployment->getId()) { // Reset function deployment - $function = $dbForProject->updateDocument('functions', $function->getId(), new Document(array_merge($function->getArrayCopy(), [ - 'deployment' => '', - 'deploymentInternalId' => '', - ]))); - } - - $queueForEvents - ->setParam('functionId', $function->getId()) - ->setParam('deploymentId', $deployment->getId()); - - $queueForDeletes - ->setType(DELETE_TYPE_DOCUMENT) - ->setDocument($deployment); - - $response->noContent(); - }); - -App::post('/v1/functions/:functionId/deployments/:deploymentId/build') - ->alias('/v1/functions/:functionId/deployments/:deploymentId/builds/:buildId') - ->groups(['api', 'functions']) - ->desc('Rebuild deployment') - ->label('scope', 'functions.write') - ->label('resourceType', RESOURCE_TYPE_FUNCTIONS) - ->label('event', 'functions.[functionId].deployments.[deploymentId].update') - ->label('audits.event', 'deployment.update') - ->label('audits.resource', 'function/{request.functionId}') - ->label('sdk', new Method( - namespace: 'functions', - name: 'createBuild', - description: '/docs/references/functions/create-build.md', - auth: [AuthType::KEY], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_NOCONTENT, - model: Response::MODEL_NONE, - ) - ] - )) - ->param('functionId', '', new UID(), 'Function ID.') - ->param('deploymentId', '', new UID(), 'Deployment ID.') - ->param('buildId', '', new UID(), 'Build unique ID.', true) // added as optional param for backward compatibility - ->inject('request') - ->inject('response') - ->inject('dbForProject') - ->inject('project') - ->inject('queueForEvents') - ->inject('queueForBuilds') - ->inject('deviceForFunctions') - ->action(function (string $functionId, string $deploymentId, string $buildId, Request $request, Response $response, Database $dbForProject, Document $project, Event $queueForEvents, Build $queueForBuilds, Device $deviceForFunctions) { - $function = $dbForProject->getDocument('functions', $functionId); - - if ($function->isEmpty()) { - throw new Exception(Exception::FUNCTION_NOT_FOUND); - } - $deployment = $dbForProject->getDocument('deployments', $deploymentId); - - if ($deployment->isEmpty()) { - throw new Exception(Exception::DEPLOYMENT_NOT_FOUND); - } - - $path = $deployment->getAttribute('path'); - if (empty($path) || !$deviceForFunctions->exists($path)) { - throw new Exception(Exception::DEPLOYMENT_NOT_FOUND); - } - - $deploymentId = ID::unique(); - - $destination = $deviceForFunctions->getPath($deploymentId . '.' . \pathinfo('code.tar.gz', PATHINFO_EXTENSION)); - $deviceForFunctions->transfer($path, $destination, $deviceForFunctions); - - $deployment->removeAttribute('$internalId'); - $deployment = $dbForProject->createDocument('deployments', $deployment->setAttributes([ - '$internalId' => '', - '$id' => $deploymentId, - 'buildId' => '', - 'buildInternalId' => '', - 'path' => $destination, - 'entrypoint' => $function->getAttribute('entrypoint'), - 'commands' => $function->getAttribute('commands', ''), - 'search' => implode(' ', [$deploymentId, $function->getAttribute('entrypoint')]), - ])); - - $queueForBuilds - ->setType(BUILD_TYPE_DEPLOYMENT) - ->setResource($function) - ->setDeployment($deployment); - - $queueForEvents - ->setParam('functionId', $function->getId()) - ->setParam('deploymentId', $deployment->getId()); - - $response->noContent(); - }); - -App::patch('/v1/functions/:functionId/deployments/:deploymentId/build') - ->groups(['api', 'functions']) - ->desc('Cancel deployment') - ->label('scope', 'functions.write') - ->label('resourceType', RESOURCE_TYPE_FUNCTIONS) - ->label('audits.event', 'deployment.update') - ->label('audits.resource', 'function/{request.functionId}') - ->label('sdk.auth', [APP_AUTH_TYPE_KEY]) - ->label('sdk.namespace', 'functions') - ->label('sdk.method', 'updateDeploymentBuild') - ->label('sdk.response.code', Response::STATUS_CODE_OK) - ->label('sdk.response.type', Response::CONTENT_TYPE_JSON) - ->label('sdk.response.model', Response::MODEL_BUILD) - ->param('functionId', '', new UID(), 'Function ID.') - ->param('deploymentId', '', new UID(), 'Deployment ID.') - ->inject('response') - ->inject('dbForProject') - ->inject('project') - ->inject('queueForEvents') - ->action(function (string $functionId, string $deploymentId, Response $response, Database $dbForProject, Document $project, Event $queueForEvents) { - $function = $dbForProject->getDocument('functions', $functionId); - - if ($function->isEmpty()) { - throw new Exception(Exception::FUNCTION_NOT_FOUND); - } - - $deployment = $dbForProject->getDocument('deployments', $deploymentId); - - if ($deployment->isEmpty()) { - throw new Exception(Exception::DEPLOYMENT_NOT_FOUND); - } - - $build = Authorization::skip(fn () => $dbForProject->getDocument('builds', $deployment->getAttribute('buildId', ''))); - - if ($build->isEmpty()) { - $buildId = ID::unique(); - $build = $dbForProject->createDocument('builds', new Document([ - '$id' => $buildId, - '$permissions' => [], - 'startTime' => DateTime::now(), - 'deploymentInternalId' => $deployment->getInternalId(), - 'deploymentId' => $deployment->getId(), - 'status' => 'canceled', - 'path' => '', - 'runtime' => $function->getAttribute('runtime'), - 'source' => $deployment->getAttribute('path', ''), - 'sourceType' => '', - 'logs' => '', - 'duration' => 0, - 'size' => 0 - ])); - - $deployment->setAttribute('buildId', $build->getId()); - $deployment->setAttribute('buildInternalId', $build->getInternalId()); - $deployment = $dbForProject->updateDocument('deployments', $deployment->getId(), $deployment); - } else { - if (\in_array($build->getAttribute('status'), ['ready', 'failed'])) { - throw new Exception(Exception::BUILD_ALREADY_COMPLETED); - } - - $startTime = new \DateTime($build->getAttribute('startTime')); - $endTime = new \DateTime('now'); - $duration = $endTime->getTimestamp() - $startTime->getTimestamp(); - - $build = $dbForProject->updateDocument('builds', $build->getId(), $build->setAttributes([ - 'endTime' => DateTime::now(), - 'duration' => $duration, - 'status' => 'canceled' - ])); - } - - $dbForProject->purgeCachedDocument('deployments', $deployment->getId()); - - try { - $executor = new Executor(App::getEnv('_APP_EXECUTOR_HOST')); - $executor->deleteRuntime($project->getId(), $deploymentId . "-build"); - } catch (\Throwable $th) { - // Don't throw if the deployment doesn't exist - if ($th->getCode() !== 404) { - throw $th; - } - } - - $queueForEvents - ->setParam('functionId', $function->getId()) - ->setParam('deploymentId', $deployment->getId()); - - $response->dynamic($build, Response::MODEL_BUILD); - }); - -App::post('/v1/functions/:functionId/executions') - ->groups(['api', 'functions']) - ->desc('Create execution') - ->label('scope', 'execution.write') - ->label('resourceType', RESOURCE_TYPE_FUNCTIONS) - ->label('event', 'functions.[functionId].executions.[executionId].create') - ->label('resourceType', RESOURCE_TYPE_FUNCTIONS) - ->label('sdk', new Method( - namespace: 'functions', - name: 'createExecution', - description: '/docs/references/functions/create-execution.md', - auth: [AuthType::SESSION, AuthType::KEY, AuthType::JWT], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_CREATED, - model: Response::MODEL_EXECUTION, - ) - ], - contentType: ContentType::MULTIPART, - requestType: 'application/json', - )) - ->param('functionId', '', new UID(), 'Function ID.') - ->param('body', '', new Text(10485760, 0), 'HTTP body of execution. Default value is empty string.', true) - ->param('async', false, new Boolean(true), '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 AnyOf([new Assoc(), new Text(65535)], AnyOf::TYPE_MIXED), 'HTTP headers of execution. Defaults to empty.', true) - ->param('scheduledAt', null, new DatetimeValidator(requireDateInFuture: true, precision: DateTimeValidator::PRECISION_MINUTES, offset: 60), 'Scheduled execution time in [ISO 8601](https://www.iso.org/iso-8601-date-and-time-format.html) format. DateTime value must be in future with precision in minutes.', true) - ->inject('response') - ->inject('request') - ->inject('project') - ->inject('dbForProject') - ->inject('dbForPlatform') - ->inject('user') - ->inject('queueForEvents') - ->inject('queueForStatsUsage') - ->inject('queueForFunctions') - ->inject('geodb') - ->action(function (string $functionId, string $body, mixed $async, string $path, string $method, mixed $headers, ?string $scheduledAt, Response $response, Request $request, Document $project, Database $dbForProject, Database $dbForPlatform, Document $user, Event $queueForEvents, StatsUsage $queueForStatsUsage, Func $queueForFunctions, Reader $geodb) { - $async = \strval($async) === 'true' || \strval($async) === '1'; - - 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()); - $isPrivilegedUser = Auth::isPrivilegedUser(Authorization::getRoles()); - - if ($function->isEmpty() || (!$function->getAttribute('enabled') && !$isAPIKey && !$isPrivilegedUser)) { - throw new Exception(Exception::FUNCTION_NOT_FOUND); - } - - $version = $function->getAttribute('version', 'v2'); - $runtimes = Config::getParam($version === 'v2' ? 'runtimes-v2' : 'runtimes', []); - $spec = Config::getParam('runtime-specifications')[$function->getAttribute('specification', APP_COMPUTE_SPECIFICATION_DEFAULT)]; - - $runtime = (isset($runtimes[$function->getAttribute('runtime', '')])) ? $runtimes[$function->getAttribute('runtime', '')] : null; - - if (\is_null($runtime)) { - throw new Exception(Exception::FUNCTION_RUNTIME_UNSUPPORTED, 'Runtime "' . $function->getAttribute('runtime', '') . '" is not supported'); - } - - $deployment = Authorization::skip(fn () => $dbForProject->getDocument('deployments', $function->getAttribute('deployment', ''))); - - if ($deployment->getAttribute('resourceId') !== $function->getId()) { - throw new Exception(Exception::DEPLOYMENT_NOT_FOUND, 'Deployment not found. Create a deployment before trying to execute a function'); - } - - if ($deployment->isEmpty()) { - throw new Exception(Exception::DEPLOYMENT_NOT_FOUND, 'Deployment not found. Create a deployment before trying to execute a function'); - } - - /** Check if build has completed */ - $build = Authorization::skip(fn () => $dbForProject->getDocument('builds', $deployment->getAttribute('buildId', ''))); - if ($build->isEmpty()) { - throw new Exception(Exception::BUILD_NOT_FOUND); - } - - if ($build->getAttribute('status') !== 'ready') { - throw new Exception(Exception::BUILD_NOT_READY); - } - - $validator = new Authorization('execute'); - - if (!$validator->isValid($function->getAttribute('execute'))) { // Check if user has write access to execute function - throw new Exception(Exception::USER_UNAUTHORIZED, $validator->getDescription()); - } - - $jwt = ''; // initialize - if (!$user->isEmpty()) { // If userId exists, generate a JWT for function - $sessions = $user->getAttribute('sessions', []); - $current = new Document(); - - foreach ($sessions as $session) { - /** @var Utopia\Database\Document $session */ - if ($session->getAttribute('secret') == Auth::hash(Auth::$secret)) { // If current session delete the cookies too - $current = $session; - } - } - - if (!$current->isEmpty()) { - $jwtExpiry = $function->getAttribute('timeout', 900); - $jwtObj = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', $jwtExpiry, 0); - $jwt = $jwtObj->encode([ - 'userId' => $user->getId(), - 'sessionId' => $current->getId(), - ]); - } - } - - $jwtExpiry = $function->getAttribute('timeout', 900); - $jwtObj = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', $jwtExpiry, 0); - $apiKey = $jwtObj->encode([ - 'projectId' => $project->getId(), - 'scopes' => $function->getAttribute('scopes', []) - ]); - - $headers['x-appwrite-key'] = API_KEY_DYNAMIC . '_' . $apiKey; - $headers['x-appwrite-trigger'] = 'http'; - $headers['x-appwrite-user-id'] = $user->getId() ?? ''; - $headers['x-appwrite-user-jwt'] = $jwt ?? ''; - $headers['x-appwrite-country-code'] = ''; - $headers['x-appwrite-continent-code'] = ''; - $headers['x-appwrite-continent-eu'] = 'false'; - - $ip = $headers['x-real-ip'] ?? ''; - if (!empty($ip)) { - $record = $geodb->get($ip); - - if ($record) { - $eu = Config::getParam('locale-eu'); - - $headers['x-appwrite-country-code'] = $record['country']['iso_code'] ?? ''; - $headers['x-appwrite-continent-code'] = $record['continent']['code'] ?? ''; - $headers['x-appwrite-continent-eu'] = (\in_array($record['country']['iso_code'], $eu)) ? 'true' : 'false'; - } - } - - $headersFiltered = []; - foreach ($headers as $key => $value) { - if (\in_array(\strtolower($key), FUNCTION_ALLOWLIST_HEADERS_REQUEST)) { - $headersFiltered[] = ['name' => $key, 'value' => $value]; - } - } - - $executionId = ID::unique(); - - $status = $async ? 'waiting' : 'processing'; - - if (!is_null($scheduledAt)) { - $status = 'scheduled'; - } - - $execution = new Document([ - '$id' => $executionId, - '$permissions' => !$user->isEmpty() ? [Permission::read(Role::user($user->getId()))] : [], - 'resourceInternalId' => $function->getInternalId(), - 'resourceId' => $function->getId(), - 'resourceType' => 'functions', - 'deploymentInternalId' => $deployment->getInternalId(), - 'deploymentId' => $deployment->getId(), - 'trigger' => (!is_null($scheduledAt)) ? 'schedule' : 'http', - 'status' => $status, // waiting / processing / completed / failed / scheduled - 'responseStatusCode' => 0, - 'responseHeaders' => [], - 'requestPath' => $path, - 'requestMethod' => $method, - 'requestHeaders' => $headersFiltered, - 'errors' => '', - 'logs' => '', - 'duration' => 0.0, - 'search' => implode(' ', [$functionId, $executionId]), - ]); - - $queueForEvents - ->setParam('functionId', $function->getId()) - ->setParam('executionId', $execution->getId()) - ->setContext('function', $function); - - if ($async) { - if (is_null($scheduledAt)) { - $execution = Authorization::skip(fn () => $dbForProject->createDocument('executions', $execution)); - $queueForFunctions - ->setType('http') - ->setExecution($execution) - ->setFunction($function) - ->setBody($body) - ->setHeaders($headers) - ->setPath($path) - ->setMethod($method) - ->setJWT($jwt) - ->setProject($project) - ->setUser($user) - ->setParam('functionId', $function->getId()) - ->setParam('executionId', $execution->getId()) - ->trigger(); - } else { - $data = [ - 'headers' => $headers, - 'path' => $path, - 'method' => $method, - 'body' => $body, - 'userId' => $user->getId() - ]; - - $schedule = $dbForPlatform->createDocument('schedules', new Document([ - 'region' => System::getEnv('_APP_REGION', 'default'), - 'resourceType' => ScheduleExecutions::getSupportedResource(), - 'resourceId' => $execution->getId(), - 'resourceInternalId' => $execution->getInternalId(), - 'resourceUpdatedAt' => DateTime::now(), - 'projectId' => $project->getId(), - 'schedule' => $scheduledAt, - 'data' => $data, - 'active' => true, - ])); - - $execution = $execution - ->setAttribute('scheduleId', $schedule->getId()) - ->setAttribute('scheduleInternalId', $schedule->getInternalId()) - ->setAttribute('scheduledAt', $scheduledAt); - - $execution = Authorization::skip(fn () => $dbForProject->createDocument('executions', $execution)); - } - - return $response - ->setStatusCode(Response::STATUS_CODE_ACCEPTED) - ->dynamic($execution, Response::MODEL_EXECUTION); - } - - $durationStart = \microtime(true); - - $vars = []; - - // V2 vars - if ($version === 'v2') { - $vars = \array_merge($vars, [ - 'APPWRITE_FUNCTION_TRIGGER' => $headers['x-appwrite-trigger'] ?? '', - 'APPWRITE_FUNCTION_DATA' => $body ?? '', - 'APPWRITE_FUNCTION_USER_ID' => $headers['x-appwrite-user-id'] ?? '', - 'APPWRITE_FUNCTION_JWT' => $headers['x-appwrite-user-jwt'] ?? '' - ]); - } - - // Shared vars - foreach ($function->getAttribute('varsProject', []) as $var) { - $vars[$var->getAttribute('key')] = $var->getAttribute('value', ''); - } - - // Function vars - foreach ($function->getAttribute('vars', []) as $var) { - $vars[$var->getAttribute('key')] = $var->getAttribute('value', ''); - } - - $protocol = System::getEnv('_APP_OPTIONS_FORCE_HTTPS') == 'disabled' ? 'http' : 'https'; - $hostname = System::getEnv('_APP_DOMAIN'); - $endpoint = $protocol . '://' . $hostname . "/v1"; - - // Appwrite vars - $vars = \array_merge($vars, [ - 'APPWRITE_FUNCTION_API_ENDPOINT' => $endpoint, - 'APPWRITE_FUNCTION_ID' => $functionId, - 'APPWRITE_FUNCTION_NAME' => $function->getAttribute('name'), - 'APPWRITE_FUNCTION_DEPLOYMENT' => $deployment->getId(), - 'APPWRITE_FUNCTION_PROJECT_ID' => $project->getId(), - 'APPWRITE_FUNCTION_RUNTIME_NAME' => $runtime['name'] ?? '', - 'APPWRITE_FUNCTION_RUNTIME_VERSION' => $runtime['version'] ?? '', - 'APPWRITE_FUNCTION_CPUS' => $spec['cpus'] ?? APP_COMPUTE_CPUS_DEFAULT, - 'APPWRITE_FUNCTION_MEMORY' => $spec['memory'] ?? APP_COMPUTE_MEMORY_DEFAULT, - 'APPWRITE_VERSION' => APP_VERSION_STABLE, - 'APPWRITE_REGION' => $project->getAttribute('region'), - 'APPWRITE_DEPLOYMENT_TYPE' => $deployment->getAttribute('type', ''), - 'APPWRITE_VCS_REPOSITORY_ID' => $deployment->getAttribute('providerRepositoryId', ''), - 'APPWRITE_VCS_REPOSITORY_NAME' => $deployment->getAttribute('providerRepositoryName', ''), - 'APPWRITE_VCS_REPOSITORY_OWNER' => $deployment->getAttribute('providerRepositoryOwner', ''), - 'APPWRITE_VCS_REPOSITORY_URL' => $deployment->getAttribute('providerRepositoryUrl', ''), - 'APPWRITE_VCS_REPOSITORY_BRANCH' => $deployment->getAttribute('providerBranch', ''), - 'APPWRITE_VCS_REPOSITORY_BRANCH_URL' => $deployment->getAttribute('providerBranchUrl', ''), - 'APPWRITE_VCS_COMMIT_HASH' => $deployment->getAttribute('providerCommitHash', ''), - 'APPWRITE_VCS_COMMIT_MESSAGE' => $deployment->getAttribute('providerCommitMessage', ''), - 'APPWRITE_VCS_COMMIT_URL' => $deployment->getAttribute('providerCommitUrl', ''), - 'APPWRITE_VCS_COMMIT_AUTHOR_NAME' => $deployment->getAttribute('providerCommitAuthor', ''), - 'APPWRITE_VCS_COMMIT_AUTHOR_URL' => $deployment->getAttribute('providerCommitAuthorUrl', ''), - 'APPWRITE_VCS_ROOT_DIRECTORY' => $deployment->getAttribute('providerRootDirectory', ''), - ]); - - /** Execute function */ - $executor = new Executor(System::getEnv('_APP_EXECUTOR_HOST')); - try { - $version = $function->getAttribute('version', 'v2'); - $command = $runtime['startCommand']; - $command = $version === 'v2' ? '' : 'cp /tmp/code.tar.gz /mnt/code/code.tar.gz && nohup helpers/start.sh "' . $command . '"'; - $executionResponse = $executor->createExecution( - projectId: $project->getId(), - deploymentId: $deployment->getId(), - body: \strlen($body) > 0 ? $body : null, - variables: $vars, - timeout: $function->getAttribute('timeout', 0), - image: $runtime['image'], - source: $build->getAttribute('path', ''), - entrypoint: $deployment->getAttribute('entrypoint', ''), - version: $version, - path: $path, - method: $method, - headers: $headers, - runtimeEntrypoint: $command, - cpus: $spec['cpus'] ?? APP_COMPUTE_CPUS_DEFAULT, - memory: $spec['memory'] ?? APP_COMPUTE_MEMORY_DEFAULT, - logging: $function->getAttribute('logging', true), - requestTimeout: 30 - ); - - $headersFiltered = []; - foreach ($executionResponse['headers'] as $key => $value) { - if (\in_array(\strtolower($key), FUNCTION_ALLOWLIST_HEADERS_RESPONSE)) { - $headersFiltered[] = ['name' => $key, 'value' => $value]; - } - } - - /** Update execution status */ - $status = $executionResponse['statusCode'] >= 500 ? 'failed' : 'completed'; - $execution->setAttribute('status', $status); - $execution->setAttribute('responseStatusCode', $executionResponse['statusCode']); - $execution->setAttribute('responseHeaders', $headersFiltered); - $execution->setAttribute('logs', $executionResponse['logs']); - $execution->setAttribute('errors', $executionResponse['errors']); - $execution->setAttribute('duration', $executionResponse['duration']); - } catch (\Throwable $th) { - $durationEnd = \microtime(true); - - $execution - ->setAttribute('duration', $durationEnd - $durationStart) - ->setAttribute('status', 'failed') - ->setAttribute('responseStatusCode', 500) - ->setAttribute('errors', $th->getMessage() . '\nError Code: ' . $th->getCode()); - Console::error($th->getMessage()); - - if ($th instanceof AppwriteException) { - throw $th; - } - } finally { - $queueForStatsUsage - ->addMetric(METRIC_EXECUTIONS, 1) - ->addMetric(str_replace('{functionInternalId}', $function->getInternalId(), METRIC_FUNCTION_ID_EXECUTIONS), 1) - ->addMetric(METRIC_EXECUTIONS_COMPUTE, (int)($execution->getAttribute('duration') * 1000)) // per project - ->addMetric(str_replace('{functionInternalId}', $function->getInternalId(), METRIC_FUNCTION_ID_EXECUTIONS_COMPUTE), (int)($execution->getAttribute('duration') * 1000)) // per function - ->addMetric(METRIC_EXECUTIONS_MB_SECONDS, (int)(($spec['memory'] ?? APP_COMPUTE_MEMORY_DEFAULT) * $execution->getAttribute('duration', 0) * ($spec['cpus'] ?? APP_COMPUTE_CPUS_DEFAULT))) - ->addMetric(str_replace('{functionInternalId}', $function->getInternalId(), METRIC_FUNCTION_ID_EXECUTIONS_MB_SECONDS), (int)(($spec['memory'] ?? APP_COMPUTE_MEMORY_DEFAULT) * $execution->getAttribute('duration', 0) * ($spec['cpus'] ?? APP_COMPUTE_CPUS_DEFAULT))) - ; - - $execution = Authorization::skip(fn () => $dbForProject->createDocument('executions', $execution)); - } - - $roles = Authorization::getRoles(); - $isPrivilegedUser = Auth::isPrivilegedUser($roles); - $isAppUser = Auth::isAppUser($roles); - - if (!$isPrivilegedUser && !$isAppUser) { - $execution->setAttribute('logs', ''); - $execution->setAttribute('errors', ''); - } - - $headers = []; - foreach (($executionResponse['headers'] ?? []) as $key => $value) { - $headers[] = ['name' => $key, 'value' => $value]; - } - - $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); - }); - -App::get('/v1/functions/:functionId/executions') - ->groups(['api', 'functions']) - ->desc('List executions') - ->label('scope', 'execution.read') - ->label('resourceType', RESOURCE_TYPE_FUNCTIONS) - ->label('sdk', new Method( - namespace: 'functions', - name: 'listExecutions', - description: '/docs/references/functions/list-executions.md', - auth: [AuthType::SESSION, AuthType::KEY, AuthType::JWT], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_EXECUTION_LIST, - ) - ] - )) - ->param('functionId', '', new UID(), 'Function ID.') - ->param('queries', [], new Executions(), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long. You may filter on the following attributes: ' . implode(', ', Executions::ALLOWED_ATTRIBUTES), true) - ->param('search', '', new Text(256), 'Search term to filter your list results. Max length: 256 chars.', true) - ->inject('response') - ->inject('dbForProject') - ->inject('mode') - ->action(function (string $functionId, array $queries, string $search, Response $response, Database $dbForProject, string $mode) { - $function = Authorization::skip(fn () => $dbForProject->getDocument('functions', $functionId)); - - $isAPIKey = Auth::isAppUser(Authorization::getRoles()); - $isPrivilegedUser = Auth::isPrivilegedUser(Authorization::getRoles()); - - if ($function->isEmpty() || (!$function->getAttribute('enabled') && !$isAPIKey && !$isPrivilegedUser)) { - throw new Exception(Exception::FUNCTION_NOT_FOUND); - } - - try { - $queries = Query::parseQueries($queries); - } catch (QueryException $e) { - throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage()); - } - - if (!empty($search)) { - $queries[] = Query::search('search', $search); - } - - // Set internal queries - $queries[] = Query::equal('resourceInternalId', [$function->getInternalId()]); - $queries[] = Query::equal('resourceType', ['functions']); - - /** - * Get cursor document if there was a cursor query, we use array_filter and reset for reference $cursor to $queries - */ - $cursor = \array_filter($queries, function ($query) { - return \in_array($query->getMethod(), [Query::TYPE_CURSOR_AFTER, Query::TYPE_CURSOR_BEFORE]); - }); - $cursor = reset($cursor); - if ($cursor) { - /** @var Query $cursor */ - - $validator = new Cursor(); - if (!$validator->isValid($cursor)) { - throw new Exception(Exception::GENERAL_QUERY_INVALID, $validator->getDescription()); - } - - $executionId = $cursor->getValue(); - $cursorDocument = $dbForProject->getDocument('executions', $executionId); - - if ($cursorDocument->isEmpty()) { - throw new Exception(Exception::GENERAL_CURSOR_NOT_FOUND, "Execution '{$executionId}' for the 'cursor' value not found."); - } - - $cursor->setValue($cursorDocument); - } - - $filterQueries = Query::groupByType($queries)['filters']; - - $results = $dbForProject->find('executions', $queries); - $total = $dbForProject->count('executions', $filterQueries, APP_LIMIT_COUNT); - - $roles = Authorization::getRoles(); - $isPrivilegedUser = Auth::isPrivilegedUser($roles); - $isAppUser = Auth::isAppUser($roles); - if (!$isPrivilegedUser && !$isAppUser) { - $results = array_map(function ($execution) { - $execution->setAttribute('logs', ''); - $execution->setAttribute('errors', ''); - return $execution; - }, $results); - } - - $response->dynamic(new Document([ - 'executions' => $results, - 'total' => $total, - ]), Response::MODEL_EXECUTION_LIST); - }); - -App::get('/v1/functions/:functionId/executions/:executionId') - ->groups(['api', 'functions']) - ->desc('Get execution') - ->label('scope', 'execution.read') - ->label('resourceType', RESOURCE_TYPE_FUNCTIONS) - ->label('sdk', new Method( - namespace: 'functions', - name: 'getExecution', - description: '/docs/references/functions/get-execution.md', - auth: [AuthType::SESSION, AuthType::KEY, AuthType::JWT], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_EXECUTION, - ) - ] - )) - ->param('functionId', '', new UID(), 'Function ID.') - ->param('executionId', '', new UID(), 'Execution ID.') - ->inject('response') - ->inject('dbForProject') - ->inject('mode') - ->action(function (string $functionId, string $executionId, Response $response, Database $dbForProject, string $mode) { - $function = Authorization::skip(fn () => $dbForProject->getDocument('functions', $functionId)); - - $isAPIKey = Auth::isAppUser(Authorization::getRoles()); - $isPrivilegedUser = Auth::isPrivilegedUser(Authorization::getRoles()); - - if ($function->isEmpty() || (!$function->getAttribute('enabled') && !$isAPIKey && !$isPrivilegedUser)) { - throw new Exception(Exception::FUNCTION_NOT_FOUND); - } - - $execution = $dbForProject->getDocument('executions', $executionId); - - if ($execution->getAttribute('resourceType') !== 'functions' && $execution->getAttribute('resourceInternalId') !== $function->getInternalId()) { - throw new Exception(Exception::EXECUTION_NOT_FOUND); - } - - if ($execution->isEmpty()) { - throw new Exception(Exception::EXECUTION_NOT_FOUND); - } - - $roles = Authorization::getRoles(); - $isPrivilegedUser = Auth::isPrivilegedUser($roles); - $isAppUser = Auth::isAppUser($roles); - if (!$isPrivilegedUser && !$isAppUser) { - $execution->setAttribute('logs', ''); - $execution->setAttribute('errors', ''); - } - - $response->dynamic($execution, Response::MODEL_EXECUTION); - }); - -App::delete('/v1/functions/:functionId/executions/:executionId') - ->groups(['api', 'functions']) - ->desc('Delete execution') - ->label('scope', 'execution.write') - ->label('resourceType', RESOURCE_TYPE_FUNCTIONS) - ->label('event', 'functions.[functionId].executions.[executionId].delete') - ->label('audits.event', 'executions.delete') - ->label('audits.resource', 'function/{request.functionId}') - ->label('sdk', new Method( - namespace: 'functions', - name: 'deleteExecution', - description: '/docs/references/functions/delete-execution.md', - auth: [AuthType::KEY], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_NOCONTENT, - model: Response::MODEL_NONE, - ) - ], - contentType: ContentType::NONE - )) - ->param('functionId', '', new UID(), 'Function ID.') - ->param('executionId', '', new UID(), 'Execution ID.') - ->inject('response') - ->inject('dbForProject') - ->inject('dbForPlatform') - ->inject('queueForEvents') - ->action(function (string $functionId, string $executionId, Response $response, Database $dbForProject, Database $dbForPlatform, Event $queueForEvents) { - $function = $dbForProject->getDocument('functions', $functionId); - - if ($function->isEmpty()) { - throw new Exception(Exception::FUNCTION_NOT_FOUND); - } - - $execution = $dbForProject->getDocument('executions', $executionId); - if ($execution->isEmpty()) { - throw new Exception(Exception::EXECUTION_NOT_FOUND); - } - - if ($execution->getAttribute('resourceType') !== 'functions' && $execution->getAttribute('resourceInternalId') !== $function->getInternalId()) { - throw new Exception(Exception::EXECUTION_NOT_FOUND); - } - $status = $execution->getAttribute('status'); - - if (!in_array($status, ['completed', 'failed', 'scheduled'])) { - throw new Exception(Exception::EXECUTION_IN_PROGRESS); - } - - if (!$dbForProject->deleteDocument('executions', $execution->getId())) { - throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed to remove execution from DB'); - } - - if ($status === 'scheduled') { - $schedule = $dbForPlatform->findOne('schedules', [ - Query::equal('resourceId', [$execution->getId()]), - Query::equal('resourceType', [ScheduleExecutions::getSupportedResource()]), - Query::equal('active', [true]), - ]); - - if (!$schedule->isEmpty()) { - $schedule - ->setAttribute('resourceUpdatedAt', DateTime::now()) - ->setAttribute('active', false); - - Authorization::skip(fn () => $dbForPlatform->updateDocument('schedules', $schedule->getId(), $schedule)); - } - } - - $queueForEvents - ->setParam('functionId', $function->getId()) - ->setParam('executionId', $execution->getId()) - ->setPayload($response->output($execution, Response::MODEL_EXECUTION)); - - $response->noContent(); - }); - -// Variables - -App::post('/v1/functions/:functionId/variables') - ->desc('Create variable') - ->groups(['api', 'functions']) - ->label('scope', 'functions.write') - ->label('resourceType', RESOURCE_TYPE_FUNCTIONS) - ->label('audits.event', 'variable.create') - ->label('audits.resource', 'function/{request.functionId}') - ->label('sdk', new Method( - namespace: 'functions', - name: 'createVariable', - description: '/docs/references/functions/create-variable.md', - auth: [AuthType::KEY], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_CREATED, - model: Response::MODEL_VARIABLE, - ) - ] - )) - ->param('functionId', '', new UID(), 'Function unique ID.', false) - ->param('key', null, new Text(Database::LENGTH_KEY), 'Variable key. Max length: ' . Database::LENGTH_KEY . ' chars.', false) - ->param('value', null, new Text(8192, 0), 'Variable value. Max length: 8192 chars.', false) - ->param('secret', true, new Boolean(), 'Secret variables can be updated or deleted, but only functions can read them during build and runtime.', true) - ->inject('response') - ->inject('dbForProject') - ->inject('dbForPlatform') - ->action(function (string $functionId, string $key, string $value, bool $secret, Response $response, Database $dbForProject, Database $dbForPlatform) { - $function = $dbForProject->getDocument('functions', $functionId); - - if ($function->isEmpty()) { - throw new Exception(Exception::FUNCTION_NOT_FOUND); - } - - $variableId = ID::unique(); - - $variable = new Document([ - '$id' => $variableId, - '$permissions' => [ - Permission::read(Role::any()), - Permission::update(Role::any()), - Permission::delete(Role::any()), - ], - 'resourceInternalId' => $function->getInternalId(), - 'resourceId' => $function->getId(), - 'resourceType' => 'function', - 'key' => $key, - 'value' => $value, - 'secret' => $secret, - 'search' => implode(' ', [$variableId, $function->getId(), $key, 'function']), - ]); - - try { - $variable = $dbForProject->createDocument('variables', $variable); - } catch (DuplicateException $th) { - throw new Exception(Exception::VARIABLE_ALREADY_EXISTS); - } - - $dbForProject->updateDocument('functions', $function->getId(), $function->setAttribute('live', false)); - - // Inform scheduler to pull the latest changes - $schedule = $dbForPlatform->getDocument('schedules', $function->getAttribute('scheduleId')); - $schedule - ->setAttribute('resourceUpdatedAt', DateTime::now()) - ->setAttribute('schedule', $function->getAttribute('schedule')) - ->setAttribute('active', !empty($function->getAttribute('schedule')) && !empty($function->getAttribute('deployment'))); - Authorization::skip(fn () => $dbForPlatform->updateDocument('schedules', $schedule->getId(), $schedule)); - - $response - ->setStatusCode(Response::STATUS_CODE_CREATED) - ->dynamic($variable, Response::MODEL_VARIABLE); - }); - -App::get('/v1/functions/:functionId/variables') - ->desc('List variables') - ->groups(['api', 'functions']) - ->label('scope', 'functions.read') - ->label('resourceType', RESOURCE_TYPE_FUNCTIONS) - ->label( - 'sdk', - new Method( - namespace: 'functions', - name: 'listVariables', - description: '/docs/references/functions/list-variables.md', - auth: [AuthType::KEY], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_VARIABLE_LIST, - ) - ], - ) - ) - ->param('functionId', '', new UID(), 'Function unique ID.', false) - ->inject('response') - ->inject('dbForProject') - ->action(function (string $functionId, Response $response, Database $dbForProject) { - $function = $dbForProject->getDocument('functions', $functionId); - - if ($function->isEmpty()) { - throw new Exception(Exception::FUNCTION_NOT_FOUND); - } - - $response->dynamic(new Document([ - 'variables' => $function->getAttribute('vars', []), - 'total' => \count($function->getAttribute('vars', [])), - ]), Response::MODEL_VARIABLE_LIST); - }); - -App::get('/v1/functions/:functionId/variables/:variableId') - ->desc('Get variable') - ->groups(['api', 'functions']) - ->label('scope', 'functions.read') - ->label('resourceType', RESOURCE_TYPE_FUNCTIONS) - ->label('sdk.auth', [APP_AUTH_TYPE_KEY]) - ->label('sdk.namespace', 'functions') - ->label('sdk.method', 'getVariable') - ->label('sdk.description', '/docs/references/functions/get-variable.md') - ->label('sdk.response.code', Response::STATUS_CODE_OK) - ->label('sdk.response.type', Response::CONTENT_TYPE_JSON) - ->label('sdk.response.model', Response::MODEL_VARIABLE) - ->param('functionId', '', new UID(), 'Function unique ID.', false) - ->param('variableId', '', new UID(), 'Variable unique ID.', false) - ->inject('response') - ->inject('dbForProject') - ->action(function (string $functionId, string $variableId, Response $response, Database $dbForProject) { - $function = $dbForProject->getDocument('functions', $functionId); - - if ($function->isEmpty()) { - throw new Exception(Exception::FUNCTION_NOT_FOUND); - } - - $variable = $dbForProject->getDocument('variables', $variableId); - if ( - $variable === false || - $variable->isEmpty() || - $variable->getAttribute('resourceInternalId') !== $function->getInternalId() || - $variable->getAttribute('resourceType') !== 'function' - ) { - throw new Exception(Exception::VARIABLE_NOT_FOUND); - } - - if ($variable === false || $variable->isEmpty()) { - throw new Exception(Exception::VARIABLE_NOT_FOUND); - } - - $response->dynamic($variable, Response::MODEL_VARIABLE); - }); - -App::put('/v1/functions/:functionId/variables/:variableId') - ->desc('Update variable') - ->groups(['api', 'functions']) - ->label('scope', 'functions.write') - ->label('resourceType', RESOURCE_TYPE_FUNCTIONS) - ->label('audits.event', 'variable.update') - ->label('audits.resource', 'function/{request.functionId}') - ->label('sdk.auth', [APP_AUTH_TYPE_KEY]) - ->label('sdk.namespace', 'functions') - ->label('sdk.method', 'updateVariable') - ->label('sdk.description', '/docs/references/functions/update-variable.md') - ->label('sdk.response.code', Response::STATUS_CODE_OK) - ->label('sdk.response.type', Response::CONTENT_TYPE_JSON) - ->label('sdk.response.model', Response::MODEL_VARIABLE) - ->param('functionId', '', new UID(), 'Function unique ID.', false) - ->param('variableId', '', new UID(), 'Variable unique ID.', false) - ->param('key', null, new Text(255), 'Variable key. Max length: 255 chars.', false) - ->param('value', null, new Text(8192, 0), 'Variable value. Max length: 8192 chars.', true) - ->param('secret', null, new Boolean(), 'Secret variables can be updated or deleted, but only functions can read them during build and runtime.', true) - ->inject('response') - ->inject('dbForProject') - ->inject('dbForPlatform') - ->action(function (string $functionId, string $variableId, string $key, ?string $value, ?bool $secret, Response $response, Database $dbForProject, Database $dbForPlatform) { - - $function = $dbForProject->getDocument('functions', $functionId); - - if ($function->isEmpty()) { - throw new Exception(Exception::FUNCTION_NOT_FOUND); - } - - $variable = $dbForProject->getDocument('variables', $variableId); - if ($variable === false || $variable->isEmpty() || $variable->getAttribute('resourceInternalId') !== $function->getInternalId() || $variable->getAttribute('resourceType') !== 'function') { - throw new Exception(Exception::VARIABLE_NOT_FOUND); - } - - if ($variable === false || $variable->isEmpty()) { - throw new Exception(Exception::VARIABLE_NOT_FOUND); - } - - if ($variable->getAttribute('secret') === true && $secret === false) { - throw new Exception(Exception::VARIABLE_CANNOT_UNSET_SECRET); - } - - $variable - ->setAttribute('key', $key) - ->setAttribute('value', $value ?? $variable->getAttribute('value')) - ->setAttribute('secret', $secret ?? $variable->getAttribute('secret')) - ->setAttribute('search', implode(' ', [$variableId, $function->getId(), $key, 'function'])); - - try { - $dbForProject->updateDocument('variables', $variable->getId(), $variable); - } catch (DuplicateException $th) { - throw new Exception(Exception::VARIABLE_ALREADY_EXISTS); - } - - $dbForProject->updateDocument('functions', $function->getId(), $function->setAttribute('live', false)); - - // Inform scheduler to pull the latest changes - $schedule = $dbForPlatform->getDocument('schedules', $function->getAttribute('scheduleId')); - $schedule - ->setAttribute('resourceUpdatedAt', DateTime::now()) - ->setAttribute('schedule', $function->getAttribute('schedule')) - ->setAttribute('active', !empty($function->getAttribute('schedule')) && !empty($function->getAttribute('deployment'))); - Authorization::skip(fn () => $dbForPlatform->updateDocument('schedules', $schedule->getId(), $schedule)); - - $response->dynamic($variable, Response::MODEL_VARIABLE); - }); - -App::delete('/v1/functions/:functionId/variables/:variableId') - ->desc('Delete variable') - ->groups(['api', 'functions']) - ->label('scope', 'functions.write') - ->label('resourceType', RESOURCE_TYPE_FUNCTIONS) - ->label('audits.event', 'variable.delete') - ->label('audits.resource', 'function/{request.functionId}') - ->label('sdk.auth', [APP_AUTH_TYPE_KEY]) - ->label('sdk.namespace', 'functions') - ->label('sdk.method', 'deleteVariable') - ->label('sdk.description', '/docs/references/functions/delete-variable.md') - ->label('sdk.response.code', Response::STATUS_CODE_NOCONTENT) - ->label('sdk.response.model', Response::MODEL_NONE) - ->param('functionId', '', new UID(), 'Function unique ID.', false) - ->param('variableId', '', new UID(), 'Variable unique ID.', false) - ->inject('response') - ->inject('dbForProject') - ->inject('dbForPlatform') - ->action(function (string $functionId, string $variableId, Response $response, Database $dbForProject, Database $dbForPlatform) { - $function = $dbForProject->getDocument('functions', $functionId); - - if ($function->isEmpty()) { - throw new Exception(Exception::FUNCTION_NOT_FOUND); - } - - $variable = $dbForProject->getDocument('variables', $variableId); - if ($variable === false || $variable->isEmpty() || $variable->getAttribute('resourceInternalId') !== $function->getInternalId() || $variable->getAttribute('resourceType') !== 'function') { - throw new Exception(Exception::VARIABLE_NOT_FOUND); - } - - if ($variable === false || $variable->isEmpty()) { - throw new Exception(Exception::VARIABLE_NOT_FOUND); - } - - $dbForProject->deleteDocument('variables', $variable->getId()); - - $dbForProject->updateDocument('functions', $function->getId(), $function->setAttribute('live', false)); - - // Inform scheduler to pull the latest changes - $schedule = $dbForPlatform->getDocument('schedules', $function->getAttribute('scheduleId')); - $schedule - ->setAttribute('resourceUpdatedAt', DateTime::now()) - ->setAttribute('schedule', $function->getAttribute('schedule')) - ->setAttribute('active', !empty($function->getAttribute('schedule')) && !empty($function->getAttribute('deployment'))); - Authorization::skip(fn () => $dbForPlatform->updateDocument('schedules', $schedule->getId(), $schedule)); - - $response->noContent(); - }); - -App::get('/v1/functions/templates') - ->groups(['api']) - ->desc('List function templates') - ->label('scope', 'public') - ->label('resourceType', RESOURCE_TYPE_FUNCTIONS) - ->label('sdk', new Method( - namespace: 'functions', - name: 'listTemplates', - description: '/docs/references/functions/list-templates.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_TEMPLATE_FUNCTION_LIST, - ) - ] - )) - ->param('runtimes', [], new ArrayList(new WhiteList(array_keys(Config::getParam('runtimes')), true), APP_LIMIT_ARRAY_PARAMS_SIZE), 'List of runtimes allowed for filtering function templates. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' runtimes are allowed.', true) - ->param('useCases', [], new ArrayList(new WhiteList(['dev-tools','starter','databases','ai','messaging','utilities']), APP_LIMIT_ARRAY_PARAMS_SIZE), 'List of use cases allowed for filtering function templates. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' use cases are allowed.', true) - ->param('limit', 25, new Range(1, 5000), 'Limit the number of templates returned in the response. Default limit is 25, and maximum limit is 5000.', true) - ->param('offset', 0, new Range(0, 5000), 'Offset the list of returned templates. Maximum offset is 5000.', true) - ->inject('response') - ->action(function (array $runtimes, array $usecases, int $limit, int $offset, Response $response) { - $templates = Config::getParam('function-templates', []); - - if (!empty($runtimes)) { - $templates = \array_filter($templates, function ($template) use ($runtimes) { - return \count(\array_intersect($runtimes, \array_column($template['runtimes'], 'name'))) > 0; - }); - } - - if (!empty($usecases)) { - $templates = \array_filter($templates, function ($template) use ($usecases) { - return \count(\array_intersect($usecases, $template['useCases'])) > 0; - }); - } - - $responseTemplates = \array_slice($templates, $offset, $limit); - $response->dynamic(new Document([ - 'templates' => $responseTemplates, - 'total' => \count($responseTemplates), - ]), Response::MODEL_TEMPLATE_FUNCTION_LIST); - }); - -App::get('/v1/functions/templates/:templateId') - ->desc('Get function template') - ->label('scope', 'public') - ->label('resourceType', RESOURCE_TYPE_FUNCTIONS) - ->label('sdk', new Method( - namespace: 'functions', - name: 'getTemplate', - description: '/docs/references/functions/get-template.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_TEMPLATE_FUNCTION, - ) - ] - )) - ->param('templateId', '', new Text(128), 'Template ID.') - ->inject('response') - ->action(function (string $templateId, Response $response) { - $templates = Config::getParam('function-templates', []); - - $filtered = \array_filter($templates, function ($template) use ($templateId) { - return $template['id'] === $templateId; - }); - - $template = array_shift($filtered); - - if (empty($template)) { - throw new Exception(Exception::FUNCTION_TEMPLATE_NOT_FOUND); - } - - $response->dynamic(new Document($template), Response::MODEL_TEMPLATE_FUNCTION); - }); diff --git a/app/controllers/api/health.php b/app/controllers/api/health.php index e5336067c8..94ec2059c9 100644 --- a/app/controllers/api/health.php +++ b/app/controllers/api/health.php @@ -857,9 +857,10 @@ App::get('/v1/health/storage') ->inject('response') ->inject('deviceForFiles') ->inject('deviceForFunctions') + ->inject('deviceForSites') ->inject('deviceForBuilds') - ->action(function (Response $response, Device $deviceForFiles, Device $deviceForFunctions, Device $deviceForBuilds) { - $devices = [$deviceForFiles, $deviceForFunctions, $deviceForBuilds]; + ->action(function (Response $response, Device $deviceForFiles, Device $deviceForFunctions, Device $deviceForSites, Device $deviceForBuilds) { + $devices = [$deviceForFiles, $deviceForFunctions, $deviceForSites, $deviceForBuilds]; $checkStart = \microtime(true); foreach ($devices as $device) { diff --git a/app/controllers/api/vcs.php b/app/controllers/api/vcs.php index ff4232ccda..481171be18 100644 --- a/app/controllers/api/vcs.php +++ b/app/controllers/api/vcs.php @@ -19,6 +19,7 @@ use Utopia\Config\Config; use Utopia\Database\Database; use Utopia\Database\DateTime; use Utopia\Database\Document; +use Utopia\Database\Exception\Duplicate; use Utopia\Database\Exception\Query as QueryException; use Utopia\Database\Helpers\ID; use Utopia\Database\Helpers\Permission; @@ -244,10 +245,10 @@ $createGitDeployments = function (GitHub $github, string $providerInstallationId 'activate' => $activate, ])); - // Preview deployments for sites if ($resource->getCollection() === 'sites') { $projectId = $project->getId(); + // Deployment preview $sitesDomain = System::getEnv('_APP_DOMAIN_SITES', ''); $domain = ID::unique() . "." . $sitesDomain; $ruleId = md5($domain); @@ -257,13 +258,61 @@ $createGitDeployments = function (GitHub $github, string $providerInstallationId 'projectId' => $project->getId(), 'projectInternalId' => $project->getInternalId(), 'domain' => $domain, - 'resourceType' => 'deployment', - 'resourceId' => $deploymentId, - 'resourceInternalId' => $deployment->getInternalId(), + 'type' => 'deployment', + 'value' => $deployment->getId(), 'status' => 'verified', 'certificateId' => '', + 'search' => implode(' ', [$ruleId, $domain]), ])) ); + + // VCS branch preview + if (!empty($providerBranch)) { + $domain = "branch-{$providerBranch}-{$resource->getId()}-{$project->getId()}.{$sitesDomain}"; + $ruleId = md5($domain); + try { + Authorization::skip( + fn () => $dbForPlatform->createDocument('rules', new Document([ + '$id' => $ruleId, + 'projectId' => $project->getId(), + 'projectInternalId' => $project->getInternalId(), + 'domain' => $domain, + 'type' => 'deployment', + 'value' => $deployment->getId(), + 'automation' => 'branch=' . $providerBranch, + 'status' => 'verified', + 'certificateId' => '', + 'search' => implode(' ', [$ruleId, $domain]), + ])) + ); + } catch (Duplicate $err) { + // Ignore, rule already exists; will be updated by builds worker + } + } + + // VCS commit preview + if (!empty($providerCommitHash)) { + $domain = "commit-{$providerCommitHash}-{$resource->getId()}-{$project->getId()}.{$sitesDomain}"; + $ruleId = md5($domain); + try { + Authorization::skip( + fn () => $dbForPlatform->createDocument('rules', new Document([ + '$id' => $ruleId, + 'projectId' => $project->getId(), + 'projectInternalId' => $project->getInternalId(), + 'domain' => $domain, + 'type' => 'deployment', + 'value' => $deployment->getId(), + 'automation' => 'commit=' . $providerCommitHash, + 'status' => 'verified', + 'certificateId' => '', + 'search' => implode(' ', [$ruleId, $domain]), + ])) + ); + } catch (Duplicate $err) { + // Ignore, rule already exists; will be updated by builds worker + } + } } if (!empty($providerCommitHash) && $resource->getAttribute('providerSilentMode', false) === false) { diff --git a/app/controllers/general.php b/app/controllers/general.php index 6907a869e4..f2362544c6 100644 --- a/app/controllers/general.php +++ b/app/controllers/general.php @@ -12,10 +12,7 @@ use Appwrite\Event\StatsUsage; use Appwrite\Extend\Exception as AppwriteException; use Appwrite\Network\Validator\Origin; use Appwrite\Platform\Appwrite; -use Appwrite\SDK\AuthType; -use Appwrite\SDK\ContentType; use Appwrite\SDK\Method; -use Appwrite\SDK\Response as SDKResponse; use Appwrite\Transformation\Adapter\Preview; use Appwrite\Transformation\Transformation; use Appwrite\Utopia\Request; @@ -123,41 +120,9 @@ function router(App $utopia, Database $dbForPlatform, callable $getProjectDB, Sw return false; } - $type = $rule->getAttribute('resourceType'); - - if ($type === 'function' || $type === 'site' || $type === 'deployment') { - $resourceCollection = match ($type) { - 'function' => 'functions', - 'site' => 'sites', - 'deployment' => 'deployments', - }; - } - - if ($type === 'function' || $type === 'site' || $type === 'deployment') { - $method = $utopia->getRoute()?->getLabel('sdk', null); - - if (empty($method)) { - $utopia->getRoute()?->label('sdk', new Method( - namespace: 'functions', - name: 'createExecution', - description: '/docs/references/functions/create-execution.md', - auth: [AuthType::SESSION, AuthType::KEY, AuthType::JWT], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_CREATED, - model: Response::MODEL_EXECUTION, - ) - ], - contentType: ContentType::MULTIPART, - requestType: 'application/json', - )); - } else { - /** @var Method $method */ - $method->setNamespace('functions'); - $method->setMethodName('createExecution'); - $utopia->getRoute()?->label('sdk', $method); - } + $type = $rule->getAttribute('type', ''); + if ($type === 'deployment') { if (System::getEnv('_APP_OPTIONS_COMPUTE_FORCE_HTTPS', 'disabled') === 'enabled') { // Force HTTPS if ($request->getProtocol() !== 'https' && $request->getHostname() !== APP_HOSTNAME_INTERNAL) { if ($request->getMethod() !== Request::METHOD_GET) { @@ -167,8 +132,22 @@ function router(App $utopia, Database $dbForPlatform, callable $getProjectDB, Sw } } - $resourceId = $rule->getAttribute('resourceId'); - $projectId = $rule->getAttribute('projectId'); + /** @var Database $dbForProject */ + $dbForProject = $getProjectDB($project); + + $deployment = Authorization::skip(fn () => $dbForProject->getDocument('deployments', $rule->getAttribute('value'))); + + if ($deployment->getAttribute('resourceType', '') === 'functions') { + $type = 'function'; + } elseif ($deployment->getAttribute('resourceType', '') === 'sites') { + $type = 'site'; + } + + $resource = $type === 'function' ? + Authorization::skip(fn () => $dbForProject->getDocument('functions', $deployment->getAttribute('resourceId', ''))) : + Authorization::skip(fn () => $dbForProject->getDocument('sites', $deployment->getAttribute('resourceId', ''))); + + $isPreview = $type === 'function' ? false : (!\str_starts_with($rule->getAttribute('automation', ''), 'site=')); $path = ($swooleRequest->server['request_uri'] ?? '/'); $query = ($swooleRequest->server['query_string'] ?? ''); @@ -181,30 +160,17 @@ function router(App $utopia, Database $dbForPlatform, callable $getProjectDB, Sw $requestHeaders = $request->getHeaders(); - $project = Authorization::skip(fn () => $dbForPlatform->getDocument('projects', $projectId)); - - /** @var Database $dbForProject */ - $dbForProject = $getProjectDB($project); - - if ($resourceCollection === 'deployments') { - $subResource = Authorization::skip(fn () => $dbForProject->getDocument($resourceCollection, $resourceId)); - $resource = Authorization::skip(fn () => $dbForProject->getDocument($subResource->getAttribute('resourceType'), $subResource->getAttribute('resourceId'))); - } else { - $resource = Authorization::skip(fn () => $dbForProject->getDocument($resourceCollection, $resourceId)); - } - if ($resource->isEmpty() || !$resource->getAttribute('enabled')) { throw new AppwriteException(AppwriteException::FUNCTION_NOT_FOUND); } - if ($isResourceBlocked($project, RESOURCE_TYPE_FUNCTIONS, $resourceId)) { + if ($isResourceBlocked($project, $type === 'function' ? RESOURCE_TYPE_FUNCTIONS : RESOURCE_TYPE_SITES, $resource->getId())) { throw new AppwriteException(AppwriteException::GENERAL_RESOURCE_BLOCKED); } $version = match ($type) { 'function' => $resource->getAttribute('version', 'v2'), 'site' => 'v4', - 'deployment' => 'v4' }; $runtimes = Config::getParam($version === 'v2' ? 'runtimes-v2' : 'runtimes', []); @@ -213,10 +179,10 @@ function router(App $utopia, Database $dbForPlatform, callable $getProjectDB, Sw $runtime = match ($type) { 'function' => $runtimes[$resource->getAttribute('runtime')] ?? null, 'site' => $runtimes[$resource->getAttribute('buildRuntime')] ?? null, - 'deployment' => $runtimes[$resource->getAttribute('buildRuntime')] ?? null, default => null }; + // Static site enforced runtime if ($resource->getAttribute('adapter', '') === 'static') { $runtime = $runtimes['static-1'] ?? null; } @@ -225,22 +191,6 @@ function router(App $utopia, Database $dbForPlatform, callable $getProjectDB, Sw throw new AppwriteException(AppwriteException::FUNCTION_RUNTIME_UNSUPPORTED, 'Runtime "' . $resource->getAttribute('runtime', '') . '" is not supported'); } - $deploymentId = match ($type) { - 'function' => $resource->getAttribute('deployment', ''), - 'site' => $resource->getAttribute('deploymentId', ''), - 'deployment' => $subResource->getId() - }; - - $deployment = Authorization::skip(fn () => $dbForProject->getDocument('deployments', $deploymentId)); - - if ($deployment->getAttribute('resourceId') !== $resource->getId()) { - throw new AppwriteException(AppwriteException::DEPLOYMENT_NOT_FOUND, 'Deployment not found. Create a deployment before trying to execute a function'); - } - - if ($deployment->isEmpty()) { - throw new AppwriteException(AppwriteException::DEPLOYMENT_NOT_FOUND, 'Deployment not found. Create a deployment before trying to execute a function'); - } - /** Check if build has completed */ $build = Authorization::skip(fn () => $dbForProject->getDocument('builds', $deployment->getAttribute('buildId', ''))); if ($build->isEmpty()) { @@ -251,10 +201,8 @@ function router(App $utopia, Database $dbForPlatform, callable $getProjectDB, Sw throw new AppwriteException(AppwriteException::BUILD_NOT_READY); } - //todo: figure out for sites/functions if ($type === 'function') { $permissions = $resource->getAttribute('execute'); - if (!(\in_array('any', $permissions)) && !(\in_array('guests', $permissions))) { throw new AppwriteException(AppwriteException::USER_UNAUTHORIZED, 'To execute function using domain, execute permissions must include "any" or "guests"'); } @@ -266,18 +214,15 @@ function router(App $utopia, Database $dbForPlatform, callable $getProjectDB, Sw $headers['x-appwrite-continent-code'] = ''; $headers['x-appwrite-continent-eu'] = 'false'; - //todo: check if this would work for sites - if ($type === 'function') { - $jwtExpiry = $resource->getAttribute('timeout', 900); - $jwtObj = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', $jwtExpiry, 0); - $jwtKey = $jwtObj->encode([ - 'projectId' => $project->getId(), - 'scopes' => $resource->getAttribute('scopes', []) - ]); - $headers['x-appwrite-key'] = API_KEY_DYNAMIC . '_' . $jwtKey; - $headers['x-appwrite-trigger'] = 'http'; - $headers['x-appwrite-user-jwt'] = ''; - } + $jwtExpiry = $resource->getAttribute('timeout', 900); + $jwtObj = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', $jwtExpiry, 0); + $jwtKey = $jwtObj->encode([ + 'projectId' => $project->getId(), + 'scopes' => $resource->getAttribute('scopes', []) + ]); + $headers['x-appwrite-key'] = API_KEY_DYNAMIC . '_' . $jwtKey; + $headers['x-appwrite-trigger'] = 'http'; + $headers['x-appwrite-user-jwt'] = ''; $ip = $headers['x-real-ip'] ?? ''; if (!empty($ip)) { @@ -316,21 +261,26 @@ function router(App $utopia, Database $dbForPlatform, callable $getProjectDB, Sw 'errors' => '', 'logs' => '', 'duration' => 0.0, - 'search' => implode(' ', [$resourceId, $executionId]), + 'search' => implode(' ', [$resource->getId(), $executionId]), ]); if ($type === 'function') { $execution->setAttribute('resourceType', 'functions'); $execution->setAttribute('trigger', 'http'); // http / schedule / event $execution->setAttribute('status', 'processing'); // waiting / processing / completed / failed + + $queueForEvents + ->setParam('functionId', $resource->getId()) + ->setParam('executionId', $execution->getId()) + ->setContext('function', $resource); } elseif ($type === 'site') { $execution->setAttribute('resourceType', 'sites'); - } - $queueForEvents - ->setParam('functionId', $resource->getId()) - ->setParam('executionId', $execution->getId()) - ->setContext('function', $resource); + $queueForEvents + ->setParam('siteId', $resource->getId()) + ->setParam('executionId', $execution->getId()) + ->setContext('site', $resource); + } $durationStart = \microtime(true); @@ -363,7 +313,7 @@ function router(App $utopia, Database $dbForPlatform, callable $getProjectDB, Sw // Appwrite vars $vars = \array_merge($vars, [ 'APPWRITE_FUNCTION_API_ENDPOINT' => $endpoint, - 'APPWRITE_FUNCTION_ID' => $resourceId, + 'APPWRITE_FUNCTION_ID' => $resource->getId(), 'APPWRITE_FUNCTION_NAME' => $resource->getAttribute('name'), 'APPWRITE_FUNCTION_DEPLOYMENT' => $deployment->getId(), 'APPWRITE_FUNCTION_PROJECT_ID' => $project->getId(), @@ -388,18 +338,21 @@ function router(App $utopia, Database $dbForPlatform, callable $getProjectDB, Sw 'APPWRITE_VCS_ROOT_DIRECTORY' => $deployment->getAttribute('providerRootDirectory', ''), ]); + // SPA fallbackFile override + if ($resource->getAttribute('adapter', '') === 'static' && $resource->getAttribute('fallbackFile', '') !== '') { + $vars['OPEN_RUNTIMES_STATIC_FALLBACK'] = $resource->getAttribute('fallbackFile', ''); + } + /** Execute function */ $executor = new Executor(System::getEnv('_APP_EXECUTOR_HOST')); try { $version = match ($type) { 'function' => $resource->getAttribute('version', 'v2'), 'site' => 'v4', - 'deployment' => 'v4' }; $entrypoint = match ($type) { 'function' => $deployment->getAttribute('entrypoint', ''), 'site' => '', - 'deployment' => '' }; if ($type === 'function') { @@ -407,7 +360,7 @@ function router(App $utopia, Database $dbForPlatform, callable $getProjectDB, Sw 'v2' => '', default => 'cp /tmp/code.tar.gz /mnt/code/code.tar.gz && nohup helpers/start.sh "' . $runtime['startCommand'] . '"' }; - } elseif ($type === 'site' || $type === 'deployment') { + } elseif ($type === 'site') { $frameworks = Config::getParam('frameworks', []); $framework = $frameworks[$resource->getAttribute('framework', '')] ?? null; @@ -426,7 +379,6 @@ function router(App $utopia, Database $dbForPlatform, callable $getProjectDB, Sw $entrypoint = match ($type) { 'function' => $deployment->getAttribute('entrypoint', ''), 'site' => '', - 'deployment' => '' }; $executionResponse = $executor->createExecution( @@ -449,13 +401,20 @@ function router(App $utopia, Database $dbForPlatform, callable $getProjectDB, Sw requestTimeout: 30 ); + // Branded 404 override + if ($executionResponse['statusCode'] === 404 && $resource->getAttribute('adapter', '') === 'static') { + $layout = new View(__DIR__ . '/../views/general/404.phtml'); + $executionResponse['body'] = $layout->render(); + $executionResponse['headers']['content-length'] = \strlen($executionResponse['body']); + } + // Branded banner for previews if (\is_null($apiKey) || $apiKey->isBannerDisabled() === false) { $transformation = new Transformation(); $transformation->addAdapter(new Preview()); $transformation->setInput($executionResponse['body']); $transformation->setTraits($executionResponse['headers']); - if ($type === 'deployment' && $transformation->transform()) { + if ($isPreview && $transformation->transform()) { $executionResponse['body'] = $transformation->getOutput(); foreach ($executionResponse['headers'] as $key => $value) { @@ -475,9 +434,7 @@ function router(App $utopia, Database $dbForPlatform, callable $getProjectDB, Sw /** Update execution status */ $status = $executionResponse['statusCode'] >= 500 ? 'failed' : 'completed'; - if ($type === 'function') { - $execution->setAttribute('status', $status); - } + $execution->setAttribute('status', $status); $execution->setAttribute('logs', $executionResponse['logs']); $execution->setAttribute('errors', $executionResponse['errors']); $execution->setAttribute('responseStatusCode', $executionResponse['statusCode']); @@ -566,6 +523,17 @@ function router(App $utopia, Database $dbForPlatform, callable $getProjectDB, Sw } elseif ($type === 'api') { $utopia->getRoute()?->label('error', ''); return false; + } elseif ($type === 'redirect') { + $path = ($swooleRequest->server['request_uri'] ?? '/'); + $query = ($swooleRequest->server['query_string'] ?? ''); + if (!empty($query)) { + $path .= '?' . $query; + } + + $url = 'https://' . $rule->getAttribute('value', '') . $path; + + $response->redirect($url); + return true; } else { throw new AppwriteException(AppwriteException::GENERAL_SERVER_ERROR, 'Unknown resource type ' . $type); } @@ -698,14 +666,16 @@ App::init() } if ($domainDocument->isEmpty()) { + $ruleId = System::getEnv('_APP_RULES_FORMAT') === 'md5' ? md5($domain->get()) : ID::unique(); $domainDocument = new Document([ // TODO: @christyjacob remove once we migrate the rules in 1.7.x - '$id' => System::getEnv('_APP_RULES_FORMAT') === 'md5' ? md5($domain->get()) : ID::unique(), + '$id' => $ruleId, 'domain' => $domain->get(), - 'resourceType' => 'api', + 'type' => 'api', 'status' => 'verifying', 'projectId' => 'console', - 'projectInternalId' => 'console' + 'projectInternalId' => 'console', + 'search' => implode(' ', [$ruleId, $domain->get()]), ]); $domainDocument = $dbForPlatform->createDocument('rules', $domainDocument); @@ -742,7 +712,7 @@ App::init() } elseif (!empty($origin)) { // Auto-allow domains with linked rule if (System::getEnv('_APP_RULES_FORMAT') === 'md5') { - $rule = Authorization::skip(fn () => $dbForPlatform->getDocument('rules', md5($origin))); + $rule = Authorization::skip(fn () => $dbForPlatform->getDocument('rules', md5($origin ?? ''))); } else { $rule = Authorization::skip( fn () => $dbForPlatform->find('rules', [ @@ -1311,13 +1281,7 @@ App::get('/v1/ping') App::wildcard() ->groups(['api']) ->label('scope', 'global') - ->inject('utopia') - ->action(function (App $utopia) { - $handeledByRouter = $utopia->getRoute()?->getLabel('router', false); - if ($handeledByRouter === true) { - return; - } - + ->action(function () { throw new AppwriteException(AppwriteException::GENERAL_ROUTE_NOT_FOUND); }); diff --git a/app/http.php b/app/http.php index 2b1f038777..83f56aeb94 100644 --- a/app/http.php +++ b/app/http.php @@ -303,9 +303,9 @@ $http->on(Constant::EVENT_START, function (Server $http) use ($payloadSize, $reg $dbForPlatform->createCollection('bucket_' . $bucket->getInternalId(), $attributes, $indexes); } - if ($dbForPlatform->getDocument('buckets', 'screenshots')->isEmpty()) { + if (Authorization::skip(fn () => $dbForPlatform->getDocument('buckets', 'screenshots')->isEmpty())) { Console::info(" └── Creating screenshots bucket..."); - $dbForPlatform->createDocument('buckets', new Document([ + Authorization::skip(fn () => $dbForPlatform->createDocument('buckets', new Document([ '$id' => ID::custom('screenshots'), '$collection' => ID::custom('buckets'), 'name' => 'Screenshots', @@ -316,16 +316,11 @@ $http->on(Constant::EVENT_START, function (Server $http) use ($payloadSize, $reg 'encryption' => false, 'antivirus' => false, 'fileSecurity' => true, - '$permissions' => [ - Permission::create(Role::any()), - Permission::read(Role::any()), - Permission::update(Role::any()), - Permission::delete(Role::any()), - ], + '$permissions' => [], 'search' => 'buckets Screenshots', - ])); + ]))); - $bucket = $dbForPlatform->getDocument('buckets', 'screenshots'); + $bucket = Authorization::skip(fn () => $dbForPlatform->getDocument('buckets', 'screenshots')); Console::info(" └── Creating files collection for screenshots bucket..."); $files = $collections['buckets']['files'] ?? []; @@ -353,7 +348,7 @@ $http->on(Constant::EVENT_START, function (Server $http) use ($payloadSize, $reg 'orders' => $index['orders'], ]), $files['indexes']); - $dbForPlatform->createCollection('bucket_' . $bucket->getInternalId(), $attributes, $indexes); + Authorization::skip(fn () => $dbForPlatform->createCollection('bucket_' . $bucket->getInternalId(), $attributes, $indexes)); } }); @@ -519,7 +514,6 @@ $http->on('Task', function () use ($register, $domains) { if ($lastSyncUpdate != null) { $queries[] = Query::greaterThanEqual('$updatedAt', $lastSyncUpdate); } - $queries[] = Query::equal('resourceType', ['function']); $results = []; try { $results = Authorization::skip(fn () => $dbForPlatform->find('rules', $queries)); diff --git a/app/views/general/404.phtml b/app/views/general/404.phtml new file mode 100644 index 0000000000..7ec1cfbf21 --- /dev/null +++ b/app/views/general/404.phtml @@ -0,0 +1,185 @@ + + + + + + + 404 Not Found + + + + + +
+
+
Page not found
+

The page you’re looking for doesn’t exist.

+ +
+
+ +
+

Powered by

+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + \ No newline at end of file diff --git a/composer.json b/composer.json index d5a10bf962..c67abffafb 100644 --- a/composer.json +++ b/composer.json @@ -56,7 +56,7 @@ "utopia-php/detector": "dev-feat-pseudocode-draft2 as 0.1.99", "utopia-php/domains": "0.5.*", "utopia-php/dsn": "0.2.1", - "utopia-php/framework": "dev-fix-prevent-duplicate-compression as 0.33.99", + "utopia-php/framework": "0.33.*", "utopia-php/fetch": "0.3.*", "utopia-php/image": "0.7.*", "utopia-php/locale": "0.4.*", diff --git a/composer.lock b/composer.lock index ea939c0aed..7c53b343a8 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "81769c65caf560ea69f2c13fc8b04e7d", + "content-hash": "6d3952c126526006ed5a7d5a4741b755", "packages": [ { "name": "adhocore/jwt", @@ -279,16 +279,16 @@ }, { "name": "brick/math", - "version": "0.12.1", + "version": "0.12.2", "source": { "type": "git", "url": "https://github.com/brick/math.git", - "reference": "f510c0a40911935b77b86859eb5223d58d660df1" + "reference": "901eddb1e45a8e0f689302e40af871c181ecbe40" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/brick/math/zipball/f510c0a40911935b77b86859eb5223d58d660df1", - "reference": "f510c0a40911935b77b86859eb5223d58d660df1", + "url": "https://api.github.com/repos/brick/math/zipball/901eddb1e45a8e0f689302e40af871c181ecbe40", + "reference": "901eddb1e45a8e0f689302e40af871c181ecbe40", "shasum": "" }, "require": { @@ -297,7 +297,7 @@ "require-dev": { "php-coveralls/php-coveralls": "^2.2", "phpunit/phpunit": "^10.1", - "vimeo/psalm": "5.16.0" + "vimeo/psalm": "6.8.8" }, "type": "library", "autoload": { @@ -327,7 +327,7 @@ ], "support": { "issues": "https://github.com/brick/math/issues", - "source": "https://github.com/brick/math/tree/0.12.1" + "source": "https://github.com/brick/math/tree/0.12.2" }, "funding": [ { @@ -335,7 +335,7 @@ "type": "github" } ], - "time": "2023-11-29T23:19:16+00:00" + "time": "2025-02-26T10:21:45+00:00" }, { "name": "chillerlan/php-qrcode", @@ -2694,16 +2694,16 @@ }, { "name": "symfony/http-client", - "version": "v7.2.3", + "version": "v7.2.4", "source": { "type": "git", "url": "https://github.com/symfony/http-client.git", - "reference": "7ce6078c79a4a7afff931c413d2959d3bffbfb8d" + "reference": "78981a2ffef6437ed92d4d7e2a86a82f256c6dc6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-client/zipball/7ce6078c79a4a7afff931c413d2959d3bffbfb8d", - "reference": "7ce6078c79a4a7afff931c413d2959d3bffbfb8d", + "url": "https://api.github.com/repos/symfony/http-client/zipball/78981a2ffef6437ed92d4d7e2a86a82f256c6dc6", + "reference": "78981a2ffef6437ed92d4d7e2a86a82f256c6dc6", "shasum": "" }, "require": { @@ -2769,7 +2769,7 @@ "http" ], "support": { - "source": "https://github.com/symfony/http-client/tree/v7.2.3" + "source": "https://github.com/symfony/http-client/tree/v7.2.4" }, "funding": [ { @@ -2785,7 +2785,7 @@ "type": "tidelift" } ], - "time": "2025-01-28T15:51:35+00:00" + "time": "2025-02-13T10:27:23+00:00" }, { "name": "symfony/http-client-contracts", @@ -3982,16 +3982,16 @@ }, { "name": "utopia-php/framework", - "version": "dev-fix-prevent-duplicate-compression", + "version": "0.33.17", "source": { "type": "git", "url": "https://github.com/utopia-php/http.git", - "reference": "a1efe3e10038afe4109af833ce7a25a8ec4b5ed2" + "reference": "73fac6fbce9f56282dba4e52a58cf836ec434644" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/http/zipball/a1efe3e10038afe4109af833ce7a25a8ec4b5ed2", - "reference": "a1efe3e10038afe4109af833ce7a25a8ec4b5ed2", + "url": "https://api.github.com/repos/utopia-php/http/zipball/73fac6fbce9f56282dba4e52a58cf836ec434644", + "reference": "73fac6fbce9f56282dba4e52a58cf836ec434644", "shasum": "" }, "require": { @@ -4023,9 +4023,9 @@ ], "support": { "issues": "https://github.com/utopia-php/http/issues", - "source": "https://github.com/utopia-php/http/tree/fix-prevent-duplicate-compression" + "source": "https://github.com/utopia-php/http/tree/0.33.17" }, - "time": "2025-02-03T12:02:35+00:00" + "time": "2025-02-24T17:35:48+00:00" }, { "name": "utopia-php/image", @@ -5114,16 +5114,16 @@ "packages-dev": [ { "name": "appwrite/sdk-generator", - "version": "0.40.0", + "version": "0.40.1", "source": { "type": "git", "url": "https://github.com/appwrite/sdk-generator.git", - "reference": "d2880132c900f64108d3e4484a6c1ed1bed2303c" + "reference": "df180676b6fbde7832ae1495af3e2f3e8f700837" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/appwrite/sdk-generator/zipball/d2880132c900f64108d3e4484a6c1ed1bed2303c", - "reference": "d2880132c900f64108d3e4484a6c1ed1bed2303c", + "url": "https://api.github.com/repos/appwrite/sdk-generator/zipball/df180676b6fbde7832ae1495af3e2f3e8f700837", + "reference": "df180676b6fbde7832ae1495af3e2f3e8f700837", "shasum": "" }, "require": { @@ -5159,9 +5159,9 @@ "description": "Appwrite PHP library for generating API SDKs for multiple programming languages and platforms", "support": { "issues": "https://github.com/appwrite/sdk-generator/issues", - "source": "https://github.com/appwrite/sdk-generator/tree/0.40.0" + "source": "https://github.com/appwrite/sdk-generator/tree/0.40.1" }, - "time": "2025-02-04T12:47:33+00:00" + "time": "2025-02-26T07:07:10+00:00" }, { "name": "doctrine/annotations", @@ -8493,16 +8493,16 @@ }, { "name": "symfony/process", - "version": "v7.2.0", + "version": "v7.2.4", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "d34b22ba9390ec19d2dd966c40aa9e8462f27a7e" + "reference": "d8f411ff3c7ddc4ae9166fb388d1190a2df5b5cf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/d34b22ba9390ec19d2dd966c40aa9e8462f27a7e", - "reference": "d34b22ba9390ec19d2dd966c40aa9e8462f27a7e", + "url": "https://api.github.com/repos/symfony/process/zipball/d8f411ff3c7ddc4ae9166fb388d1190a2df5b5cf", + "reference": "d8f411ff3c7ddc4ae9166fb388d1190a2df5b5cf", "shasum": "" }, "require": { @@ -8534,7 +8534,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v7.2.0" + "source": "https://github.com/symfony/process/tree/v7.2.4" }, "funding": [ { @@ -8550,7 +8550,7 @@ "type": "tidelift" } ], - "time": "2024-11-06T14:24:19+00:00" + "time": "2025-02-05T08:33:46+00:00" }, { "name": "symfony/string", @@ -8873,18 +8873,11 @@ "version": "dev-feat-pseudocode-draft2", "alias": "0.1.99", "alias_normalized": "0.1.99.0" - }, - { - "package": "utopia-php/framework", - "version": "dev-fix-prevent-duplicate-compression", - "alias": "0.33.99", - "alias_normalized": "0.33.99.0" } ], "minimum-stability": "stable", "stability-flags": { - "utopia-php/detector": 20, - "utopia-php/framework": 20 + "utopia-php/detector": 20 }, "prefer-stable": false, "prefer-lowest": false, diff --git a/docker-compose.yml b/docker-compose.yml index c58360454c..410862ca72 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -76,6 +76,7 @@ services: - appwrite-config:/storage/config:rw - appwrite-certificates:/storage/certificates:rw - appwrite-functions:/storage/functions:rw + - appwrite-sites:/storage/sites:rw - appwrite-builds:/storage/builds:rw - ./phpunit.xml:/usr/src/code/phpunit.xml - ./tests:/usr/src/code/tests @@ -205,7 +206,7 @@ services: appwrite-console: <<: *x-logging container_name: appwrite-console - image: appwrite/console:5.3.0-sites-rc.13 + image: appwrite/console:5.3.0-sites-rc.15 restart: unless-stopped networks: - appwrite @@ -348,6 +349,7 @@ services: - appwrite-uploads:/storage/uploads:rw - appwrite-cache:/storage/cache:rw - appwrite-functions:/storage/functions:rw + - appwrite-sites:/storage/sites:rw - appwrite-builds:/storage/builds:rw - appwrite-certificates:/storage/certificates:rw - ./app:/usr/src/code/app @@ -434,6 +436,7 @@ services: - appwrite volumes: - appwrite-functions:/storage/functions:rw + - appwrite-sites:/storage/sites:rw - appwrite-builds:/storage/builds:rw - appwrite-uploads:/storage/uploads:rw - ./app:/usr/src/code/app @@ -968,6 +971,7 @@ services: - /var/run/docker.sock:/var/run/docker.sock - appwrite-builds:/storage/builds:rw - appwrite-functions:/storage/functions:rw + - appwrite-sites:/storage/sites:rw # Host mount nessessary to share files between executor and runtimes. # It's not possible to share mount file between 2 containers without host mount (copying is too slow) - /tmp:/tmp:rw @@ -1139,5 +1143,6 @@ volumes: appwrite-uploads: appwrite-certificates: appwrite-functions: + appwrite-sites: appwrite-builds: appwrite-config: \ No newline at end of file diff --git a/src/Appwrite/Platform/Modules/Compute/Base.php b/src/Appwrite/Platform/Modules/Compute/Base.php index 81e20b240f..157654c486 100644 --- a/src/Appwrite/Platform/Modules/Compute/Base.php +++ b/src/Appwrite/Platform/Modules/Compute/Base.php @@ -181,11 +181,11 @@ class Base extends Action 'projectId' => $project->getId(), 'projectInternalId' => $project->getInternalId(), 'domain' => $domain, - 'resourceType' => 'deployment', - 'resourceId' => $deploymentId, - 'resourceInternalId' => $deployment->getInternalId(), + 'type' => 'deployment', + 'value' => $deployment->getId(), 'status' => 'verified', 'certificateId' => '', + 'search' => implode(' ', [$ruleId, $domain]), ])) ); diff --git a/src/Appwrite/Platform/Modules/Console/Http/Resources/Get.php b/src/Appwrite/Platform/Modules/Console/Http/Resources/Get.php index dc13e717f0..28e7194b2c 100644 --- a/src/Appwrite/Platform/Modules/Console/Http/Resources/Get.php +++ b/src/Appwrite/Platform/Modules/Console/Http/Resources/Get.php @@ -23,7 +23,7 @@ class Get extends Action public static function getName() { - return 'getResources'; + return 'getResource'; } public function __construct() diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Builds/Create.php b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Builds/Create.php new file mode 100644 index 0000000000..1d85bcb11d --- /dev/null +++ b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Builds/Create.php @@ -0,0 +1,112 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_POST) + ->setHttpPath('/v1/functions/:functionId/deployments/:deploymentId/build') + ->httpAlias('/v1/functions/:functionId/deployments/:deploymentId/builds/:buildId') + ->desc('Rebuild deployment') + ->groups(['api', 'functions']) + ->label('scope', 'functions.write') + ->label('resourceType', RESOURCE_TYPE_FUNCTIONS) + ->label('event', 'functions.[functionId].deployments.[deploymentId].update') + ->label('audits.event', 'deployment.update') + ->label('audits.resource', 'function/{request.functionId}') + ->label('sdk', new Method( + namespace: 'functions', + name: 'createBuild', + description: <<param('functionId', '', new UID(), 'Function ID.') + ->param('deploymentId', '', new UID(), 'Deployment ID.') + ->param('buildId', '', new UID(), 'Build unique ID.', true) // added as optional param for backward compatibility + ->inject('response') + ->inject('dbForProject') + ->inject('queueForEvents') + ->inject('queueForBuilds') + ->inject('deviceForFunctions') + ->callback([$this, 'action']); + } + + public function action(string $functionId, string $deploymentId, string $buildId, Response $response, Database $dbForProject, Event $queueForEvents, Build $queueForBuilds, Device $deviceForFunctions) + { + $function = $dbForProject->getDocument('functions', $functionId); + + if ($function->isEmpty()) { + throw new Exception(Exception::FUNCTION_NOT_FOUND); + } + $deployment = $dbForProject->getDocument('deployments', $deploymentId); + + if ($deployment->isEmpty()) { + throw new Exception(Exception::DEPLOYMENT_NOT_FOUND); + } + + $path = $deployment->getAttribute('path'); + if (empty($path) || !$deviceForFunctions->exists($path)) { + throw new Exception(Exception::DEPLOYMENT_NOT_FOUND); + } + + $deploymentId = ID::unique(); + + $destination = $deviceForFunctions->getPath($deploymentId . '.' . \pathinfo('code.tar.gz', PATHINFO_EXTENSION)); + $deviceForFunctions->transfer($path, $destination, $deviceForFunctions); + + $deployment->removeAttribute('$internalId'); + $deployment = $dbForProject->createDocument('deployments', $deployment->setAttributes([ + '$internalId' => '', + '$id' => $deploymentId, + 'buildId' => '', + 'buildInternalId' => '', + 'path' => $destination, + 'entrypoint' => $function->getAttribute('entrypoint'), + 'commands' => $function->getAttribute('commands', ''), + 'search' => implode(' ', [$deploymentId, $function->getAttribute('entrypoint')]), + ])); + + $queueForBuilds + ->setType(BUILD_TYPE_DEPLOYMENT) + ->setResource($function) + ->setDeployment($deployment); + + $queueForEvents + ->setParam('functionId', $function->getId()) + ->setParam('deploymentId', $deployment->getId()); + + $response->noContent(); + } +} diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Builds/Update.php b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Builds/Update.php new file mode 100644 index 0000000000..85edfd42ca --- /dev/null +++ b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Builds/Update.php @@ -0,0 +1,136 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/functions/:functionId/deployments/:deploymentId/build') + ->desc('Cancel deployment') + ->groups(['api', 'functions']) + ->label('scope', 'functions.write') + ->label('resourceType', RESOURCE_TYPE_FUNCTIONS) + ->label('audits.event', 'deployment.update') + ->label('audits.resource', 'function/{request.functionId}') + ->label('sdk', new Method( + namespace: 'functions', + name: 'updateDeploymentBuild', + description: <<param('functionId', '', new UID(), 'Function ID.') + ->param('deploymentId', '', new UID(), 'Deployment ID.') + ->inject('response') + ->inject('dbForProject') + ->inject('project') + ->inject('queueForEvents') + ->callback([$this, 'action']); + } + + public function action(string $functionId, string $deploymentId, Response $response, Database $dbForProject, Document $project, Event $queueForEvents) + { + $function = $dbForProject->getDocument('functions', $functionId); + + if ($function->isEmpty()) { + throw new Exception(Exception::FUNCTION_NOT_FOUND); + } + + $deployment = $dbForProject->getDocument('deployments', $deploymentId); + + if ($deployment->isEmpty()) { + throw new Exception(Exception::DEPLOYMENT_NOT_FOUND); + } + + $build = Authorization::skip(fn () => $dbForProject->getDocument('builds', $deployment->getAttribute('buildId', ''))); + + if ($build->isEmpty()) { + $buildId = ID::unique(); + $build = $dbForProject->createDocument('builds', new Document([ + '$id' => $buildId, + '$permissions' => [], + 'startTime' => DateTime::now(), + 'deploymentInternalId' => $deployment->getInternalId(), + 'deploymentId' => $deployment->getId(), + 'status' => 'canceled', + 'path' => '', + 'runtime' => $function->getAttribute('runtime'), + 'source' => $deployment->getAttribute('path', ''), + 'sourceType' => '', + 'logs' => '', + 'duration' => 0, + 'size' => 0 + ])); + + $deployment->setAttribute('buildId', $build->getId()); + $deployment->setAttribute('buildInternalId', $build->getInternalId()); + $deployment = $dbForProject->updateDocument('deployments', $deployment->getId(), $deployment); + } else { + if (\in_array($build->getAttribute('status'), ['ready', 'failed'])) { + throw new Exception(Exception::BUILD_ALREADY_COMPLETED); + } + + $startTime = new \DateTime($build->getAttribute('startTime')); + $endTime = new \DateTime('now'); + $duration = $endTime->getTimestamp() - $startTime->getTimestamp(); + + $build = $dbForProject->updateDocument('builds', $build->getId(), $build->setAttributes([ + 'endTime' => DateTime::now(), + 'duration' => $duration, + 'status' => 'canceled' + ])); + } + + $dbForProject->purgeCachedDocument('deployments', $deployment->getId()); + + try { + $executor = new Executor(App::getEnv('_APP_EXECUTOR_HOST')); + $executor->deleteRuntime($project->getId(), $deploymentId . "-build"); + } catch (\Throwable $th) { + // Don't throw if the deployment doesn't exist + if ($th->getCode() !== 404) { + throw $th; + } + } + + $queueForEvents + ->setParam('functionId', $function->getId()) + ->setParam('deploymentId', $deployment->getId()); + + $response->dynamic($build, Response::MODEL_BUILD); + } +} diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Delete.php b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Delete.php new file mode 100644 index 0000000000..5bde7903d9 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Delete.php @@ -0,0 +1,109 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_DELETE) + ->setHttpPath('/v1/functions/:functionId/deployments/:deploymentId') + ->desc('Delete deployment') + ->groups(['api', 'functions']) + ->label('scope', 'functions.write') + ->label('resourceType', RESOURCE_TYPE_FUNCTIONS) + ->label('event', 'functions.[functionId].deployments.[deploymentId].delete') + ->label('audits.event', 'deployment.delete') + ->label('audits.resource', 'function/{request.functionId}') + ->label('sdk', new Method( + namespace: 'functions', + name: 'deleteDeployment', + description: <<param('functionId', '', new UID(), 'Function ID.') + ->param('deploymentId', '', new UID(), 'Deployment ID.') + ->inject('response') + ->inject('dbForProject') + ->inject('queueForDeletes') + ->inject('queueForEvents') + ->inject('deviceForFunctions') + ->callback([$this, 'action']); + } + + public function action(string $functionId, string $deploymentId, Response $response, Database $dbForProject, DeleteEvent $queueForDeletes, Event $queueForEvents, Device $deviceForFunctions) + { + $function = $dbForProject->getDocument('functions', $functionId); + if ($function->isEmpty()) { + throw new Exception(Exception::FUNCTION_NOT_FOUND); + } + + $deployment = $dbForProject->getDocument('deployments', $deploymentId); + if ($deployment->isEmpty()) { + throw new Exception(Exception::DEPLOYMENT_NOT_FOUND); + } + + if ($deployment->getAttribute('resourceId') !== $function->getId()) { + throw new Exception(Exception::DEPLOYMENT_NOT_FOUND); + } + + if (!$dbForProject->deleteDocument('deployments', $deployment->getId())) { + throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed to remove deployment from DB'); + } + + if (!empty($deployment->getAttribute('path', ''))) { + if (!($deviceForFunctions->delete($deployment->getAttribute('path', '')))) { + throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed to remove deployment from storage'); + } + } + + if ($function->getAttribute('deployment') === $deployment->getId()) { // Reset function deployment + $function = $dbForProject->updateDocument('functions', $function->getId(), new Document(array_merge($function->getArrayCopy(), [ + 'deployment' => '', + 'deploymentInternalId' => '', + ]))); + } + + $queueForEvents + ->setParam('functionId', $function->getId()) + ->setParam('deploymentId', $deployment->getId()); + + $queueForDeletes + ->setType(DELETE_TYPE_DOCUMENT) + ->setDocument($deployment); + + $response->noContent(); + } +} diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Download/Get.php b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Download/Get.php new file mode 100644 index 0000000000..1a1ff9b938 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Download/Get.php @@ -0,0 +1,129 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/functions/:functionId/deployments/:deploymentId/download') + ->groups(['api', 'functions']) + ->desc('Download deployment') + ->label('scope', 'functions.read') + ->label('resourceType', RESOURCE_TYPE_FUNCTIONS) + ->label('sdk', new Method( + namespace: 'functions', + name: 'getDeploymentDownload', + description: <<param('functionId', '', new UID(), 'Function ID.') + ->param('deploymentId', '', new UID(), 'Deployment ID.') + ->inject('response') + ->inject('request') + ->inject('dbForProject') + ->inject('deviceForFunctions') + ->callback([$this, 'action']); + } + + public function action(string $functionId, string $deploymentId, Response $response, Request $request, Database $dbForProject, Device $deviceForFunctions) + { + $function = $dbForProject->getDocument('functions', $functionId); + if ($function->isEmpty()) { + throw new Exception(Exception::FUNCTION_NOT_FOUND); + } + + $deployment = $dbForProject->getDocument('deployments', $deploymentId); + if ($deployment->isEmpty()) { + throw new Exception(Exception::DEPLOYMENT_NOT_FOUND); + } + + if ($deployment->getAttribute('resourceId') !== $function->getId()) { + throw new Exception(Exception::DEPLOYMENT_NOT_FOUND); + } + + $path = $deployment->getAttribute('path', ''); + if (!$deviceForFunctions->exists($path)) { + throw new Exception(Exception::DEPLOYMENT_NOT_FOUND); + } + + $response + ->setContentType('application/gzip') + ->addHeader('Cache-Control', 'private, max-age=3888000') // 45 days + ->addHeader('X-Peak', \memory_get_peak_usage()) + ->addHeader('Content-Disposition', 'attachment; filename="' . $deploymentId . '.tar.gz"'); + + $size = $deviceForFunctions->getFileSize($path); + $rangeHeader = $request->getHeader('range'); + + if (!empty($rangeHeader)) { + $start = $request->getRangeStart(); + $end = $request->getRangeEnd(); + $unit = $request->getRangeUnit(); + + if ($end === null) { + $end = min(($start + MAX_OUTPUT_CHUNK_SIZE - 1), ($size - 1)); + } + + if ($unit !== 'bytes' || $start >= $end || $end >= $size) { + throw new Exception(Exception::STORAGE_INVALID_RANGE); + } + + $response + ->addHeader('Accept-Ranges', 'bytes') + ->addHeader('Content-Range', 'bytes ' . $start . '-' . $end . '/' . $size) + ->addHeader('Content-Length', $end - $start + 1) + ->setStatusCode(Response::STATUS_CODE_PARTIALCONTENT); + + $response->send($deviceForFunctions->read($path, $start, ($end - $start + 1))); + } + + if ($size > APP_STORAGE_READ_BUFFER) { + for ($i = 0; $i < ceil($size / MAX_OUTPUT_CHUNK_SIZE); $i++) { + $response->chunk( + $deviceForFunctions->read( + $path, + ($i * MAX_OUTPUT_CHUNK_SIZE), + min(MAX_OUTPUT_CHUNK_SIZE, $size - ($i * MAX_OUTPUT_CHUNK_SIZE)) + ), + (($i + 1) * MAX_OUTPUT_CHUNK_SIZE) >= $size + ); + } + } else { + $response->send($deviceForFunctions->read($path)); + } + } +} diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Get.php b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Get.php new file mode 100644 index 0000000000..1d074e9e2a --- /dev/null +++ b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Get.php @@ -0,0 +1,81 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/functions/:functionId/deployments/:deploymentId') + ->desc('Get deployment') + ->groups(['api', 'functions']) + ->label('scope', 'functions.read') + ->label('resourceType', RESOURCE_TYPE_FUNCTIONS) + ->label('sdk', new Method( + namespace: 'functions', + name: 'getDeployment', + description: <<param('functionId', '', new UID(), 'Function ID.') + ->param('deploymentId', '', new UID(), 'Deployment ID.') + ->inject('response') + ->inject('dbForProject') + ->callback([$this, 'action']); + } + + public function action(string $functionId, string $deploymentId, Response $response, Database $dbForProject) + { + $function = $dbForProject->getDocument('functions', $functionId); + + if ($function->isEmpty()) { + throw new Exception(Exception::FUNCTION_NOT_FOUND); + } + + $deployment = $dbForProject->getDocument('deployments', $deploymentId); + + if ($deployment->getAttribute('resourceId') !== $function->getId()) { + throw new Exception(Exception::DEPLOYMENT_NOT_FOUND); + } + + if ($deployment->isEmpty()) { + throw new Exception(Exception::DEPLOYMENT_NOT_FOUND); + } + + $build = $dbForProject->getDocument('builds', $deployment->getAttribute('buildId', '')); + $deployment->setAttribute('status', $build->getAttribute('status', 'waiting')); + $deployment->setAttribute('buildLogs', $build->getAttribute('logs', '')); + $deployment->setAttribute('buildTime', $build->getAttribute('duration', 0)); + $deployment->setAttribute('buildSize', $build->getAttribute('size', 0)); + $deployment->setAttribute('size', $deployment->getAttribute('size', 0)); + + $response->dynamic($deployment, Response::MODEL_DEPLOYMENT); + } +} diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Update.php b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Update.php new file mode 100644 index 0000000000..75144deefe --- /dev/null +++ b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Update.php @@ -0,0 +1,103 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/functions/:functionId/deployments/:deploymentId') + ->desc('Update deployment') + ->groups(['api', 'functions']) + ->label('scope', 'functions.write') + ->label('event', 'functions.[functionId].deployments.[deploymentId].update') + ->label('audits.event', 'deployment.update') + ->label('audits.resource', 'function/{request.functionId}') + ->label('sdk', new Method( + namespace: 'functions', + name: 'updateDeployment', + description: <<param('functionId', '', new UID(), 'Function ID.') + ->param('deploymentId', '', new UID(), 'Deployment ID.') + ->inject('response') + ->inject('dbForProject') + ->inject('queueForEvents') + ->inject('dbForPlatform') + ->callback([$this, 'action']); + } + + public function action(string $functionId, string $deploymentId, Response $response, Database $dbForProject, Event $queueForEvents, Database $dbForPlatform) + { + $function = $dbForProject->getDocument('functions', $functionId); + $deployment = $dbForProject->getDocument('deployments', $deploymentId); + $build = $dbForProject->getDocument('builds', $deployment->getAttribute('buildId', '')); + + if ($function->isEmpty()) { + throw new Exception(Exception::FUNCTION_NOT_FOUND); + } + + if ($deployment->isEmpty()) { + throw new Exception(Exception::DEPLOYMENT_NOT_FOUND); + } + + if ($build->isEmpty()) { + throw new Exception(Exception::BUILD_NOT_FOUND); + } + + if ($build->getAttribute('status') !== 'ready') { + throw new Exception(Exception::BUILD_NOT_READY); + } + + $function = $dbForProject->updateDocument('functions', $function->getId(), new Document(array_merge($function->getArrayCopy(), [ + 'deploymentInternalId' => $deployment->getInternalId(), + 'deployment' => $deployment->getId(), + ]))); + + // Inform scheduler if function is still active + $schedule = $dbForPlatform->getDocument('schedules', $function->getAttribute('scheduleId')); + $schedule + ->setAttribute('resourceUpdatedAt', DateTime::now()) + ->setAttribute('schedule', $function->getAttribute('schedule')) + ->setAttribute('active', !empty($function->getAttribute('schedule')) && !empty($function->getAttribute('deployment'))); + Authorization::skip(fn () => $dbForPlatform->updateDocument('schedules', $schedule->getId(), $schedule)); + + $queueForEvents + ->setParam('functionId', $function->getId()) + ->setParam('deploymentId', $deployment->getId()); + + $response->dynamic($function, Response::MODEL_FUNCTION); + } +} diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Deployments/XList.php b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/XList.php new file mode 100644 index 0000000000..9e03a86bc7 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/XList.php @@ -0,0 +1,127 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/functions/:functionId/deployments') + ->desc('List deployments') + ->groups(['api', 'functions']) + ->label('scope', 'functions.read') + ->label('resourceType', RESOURCE_TYPE_FUNCTIONS) + ->label('sdk', new Method( + namespace: 'functions', + name: 'listDeployments', + description: <<param('functionId', '', new UID(), 'Function ID.') + ->param('queries', [], new Deployments(), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long. You may filter on the following attributes: ' . implode(', ', Deployments::ALLOWED_ATTRIBUTES), true) + ->param('search', '', new Text(256), 'Search term to filter your list results. Max length: 256 chars.', true) + ->inject('response') + ->inject('dbForProject') + ->callback([$this, 'action']); + } + + public function action(string $functionId, array $queries, string $search, Response $response, Database $dbForProject) + { + $function = $dbForProject->getDocument('functions', $functionId); + + if ($function->isEmpty()) { + throw new Exception(Exception::FUNCTION_NOT_FOUND); + } + + try { + $queries = Query::parseQueries($queries); + } catch (QueryException $e) { + throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage()); + } + + if (!empty($search)) { + $queries[] = Query::search('search', $search); + } + + // Set resource queries + $queries[] = Query::equal('resourceInternalId', [$function->getInternalId()]); + $queries[] = Query::equal('resourceType', ['functions']); + + /** + * Get cursor document if there was a cursor query, we use array_filter and reset for reference $cursor to $queries + */ + $cursor = \array_filter($queries, function ($query) { + return \in_array($query->getMethod(), [Query::TYPE_CURSOR_AFTER, Query::TYPE_CURSOR_BEFORE]); + }); + $cursor = reset($cursor); + if ($cursor) { + /** @var Query $cursor */ + + $validator = new Cursor(); + if (!$validator->isValid($cursor)) { + throw new Exception(Exception::GENERAL_QUERY_INVALID, $validator->getDescription()); + } + + $deploymentId = $cursor->getValue(); + $cursorDocument = $dbForProject->getDocument('deployments', $deploymentId); + + if ($cursorDocument->isEmpty()) { + throw new Exception(Exception::GENERAL_CURSOR_NOT_FOUND, "Deployment '{$deploymentId}' for the 'cursor' value not found."); + } + + $cursor->setValue($cursorDocument); + } + + $filterQueries = Query::groupByType($queries)['filters']; + + $results = $dbForProject->find('deployments', $queries); + $total = $dbForProject->count('deployments', $filterQueries, APP_LIMIT_COUNT); + + foreach ($results as $result) { + $build = $dbForProject->getDocument('builds', $result->getAttribute('buildId', '')); + $result->setAttribute('status', $build->getAttribute('status', 'processing')); + $result->setAttribute('buildLogs', $build->getAttribute('logs', '')); + $result->setAttribute('buildTime', $build->getAttribute('duration', 0)); + $result->setAttribute('buildSize', $build->getAttribute('size', 0)); + $result->setAttribute('size', $result->getAttribute('size', 0)); + } + + $response->dynamic(new Document([ + 'deployments' => $results, + 'total' => $total, + ]), Response::MODEL_DEPLOYMENT_LIST); + } +} diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php b/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php new file mode 100644 index 0000000000..f522e2dc62 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php @@ -0,0 +1,474 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_POST) + ->setHttpPath('/v1/functions/:functionId/executions') + ->desc('Create execution') + ->groups(['api', 'functions']) + ->label('scope', 'execution.write') + ->label('resourceType', RESOURCE_TYPE_FUNCTIONS) + ->label('event', 'functions.[functionId].executions.[executionId].create') + ->label('resourceType', RESOURCE_TYPE_FUNCTIONS) + ->label('sdk', new Method( + namespace: 'functions', + name: 'createExecution', + description: <<param('functionId', '', new UID(), 'Function ID.') + ->param('body', '', new Text(10485760, 0), 'HTTP body of execution. Default value is empty string.', true) + ->param('async', false, new Boolean(true), '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 AnyOf([new Assoc(), new Text(65535)], AnyOf::TYPE_MIXED), 'HTTP headers of execution. Defaults to empty.', true) + ->param('scheduledAt', null, new DatetimeValidator(requireDateInFuture: true, precision: DateTimeValidator::PRECISION_MINUTES, offset: 60), 'Scheduled execution time in [ISO 8601](https://www.iso.org/iso-8601-date-and-time-format.html) format. DateTime value must be in future with precision in minutes.', true) + ->inject('response') + ->inject('request') + ->inject('project') + ->inject('dbForProject') + ->inject('dbForPlatform') + ->inject('user') + ->inject('queueForEvents') + ->inject('queueForStatsUsage') + ->inject('queueForFunctions') + ->inject('geodb') + ->callback([$this, 'action']); + } + + public function action(string $functionId, string $body, mixed $async, string $path, string $method, mixed $headers, ?string $scheduledAt, Response $response, Request $request, Document $project, Database $dbForProject, Database $dbForPlatform, Document $user, Event $queueForEvents, StatsUsage $queueForStatsUsage, Func $queueForFunctions, Reader $geodb) + { + $async = \strval($async) === 'true' || \strval($async) === '1'; + + 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()); + $isPrivilegedUser = Auth::isPrivilegedUser(Authorization::getRoles()); + + if ($function->isEmpty() || (!$function->getAttribute('enabled') && !$isAPIKey && !$isPrivilegedUser)) { + throw new Exception(Exception::FUNCTION_NOT_FOUND); + } + + $version = $function->getAttribute('version', 'v2'); + $runtimes = Config::getParam($version === 'v2' ? 'runtimes-v2' : 'runtimes', []); + $spec = Config::getParam('runtime-specifications')[$function->getAttribute('specification', APP_COMPUTE_SPECIFICATION_DEFAULT)]; + + $runtime = (isset($runtimes[$function->getAttribute('runtime', '')])) ? $runtimes[$function->getAttribute('runtime', '')] : null; + + if (\is_null($runtime)) { + throw new Exception(Exception::FUNCTION_RUNTIME_UNSUPPORTED, 'Runtime "' . $function->getAttribute('runtime', '') . '" is not supported'); + } + + $deployment = Authorization::skip(fn () => $dbForProject->getDocument('deployments', $function->getAttribute('deployment', ''))); + + if ($deployment->getAttribute('resourceId') !== $function->getId()) { + throw new Exception(Exception::DEPLOYMENT_NOT_FOUND, 'Deployment not found. Create a deployment before trying to execute a function'); + } + + if ($deployment->isEmpty()) { + throw new Exception(Exception::DEPLOYMENT_NOT_FOUND, 'Deployment not found. Create a deployment before trying to execute a function'); + } + + /** Check if build has completed */ + $build = Authorization::skip(fn () => $dbForProject->getDocument('builds', $deployment->getAttribute('buildId', ''))); + if ($build->isEmpty()) { + throw new Exception(Exception::BUILD_NOT_FOUND); + } + + if ($build->getAttribute('status') !== 'ready') { + throw new Exception(Exception::BUILD_NOT_READY); + } + + $validator = new Authorization('execute'); + + if (!$validator->isValid($function->getAttribute('execute'))) { // Check if user has write access to execute function + throw new Exception(Exception::USER_UNAUTHORIZED, $validator->getDescription()); + } + + $jwt = ''; // initialize + if (!$user->isEmpty()) { // If userId exists, generate a JWT for function + $sessions = $user->getAttribute('sessions', []); + $current = new Document(); + + foreach ($sessions as $session) { + /** @var Utopia\Database\Document $session */ + if ($session->getAttribute('secret') == Auth::hash(Auth::$secret)) { // If current session delete the cookies too + $current = $session; + } + } + + if (!$current->isEmpty()) { + $jwtExpiry = $function->getAttribute('timeout', 900); + $jwtObj = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', $jwtExpiry, 0); + $jwt = $jwtObj->encode([ + 'userId' => $user->getId(), + 'sessionId' => $current->getId(), + ]); + } + } + + $jwtExpiry = $function->getAttribute('timeout', 900); + $jwtObj = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', $jwtExpiry, 0); + $apiKey = $jwtObj->encode([ + 'projectId' => $project->getId(), + 'scopes' => $function->getAttribute('scopes', []) + ]); + + $headers['x-appwrite-key'] = API_KEY_DYNAMIC . '_' . $apiKey; + $headers['x-appwrite-trigger'] = 'http'; + $headers['x-appwrite-user-id'] = $user->getId() ?? ''; + $headers['x-appwrite-user-jwt'] = $jwt ?? ''; + $headers['x-appwrite-country-code'] = ''; + $headers['x-appwrite-continent-code'] = ''; + $headers['x-appwrite-continent-eu'] = 'false'; + + $ip = $headers['x-real-ip'] ?? ''; + if (!empty($ip)) { + $record = $geodb->get($ip); + + if ($record) { + $eu = Config::getParam('locale-eu'); + + $headers['x-appwrite-country-code'] = $record['country']['iso_code'] ?? ''; + $headers['x-appwrite-continent-code'] = $record['continent']['code'] ?? ''; + $headers['x-appwrite-continent-eu'] = (\in_array($record['country']['iso_code'], $eu)) ? 'true' : 'false'; + } + } + + $headersFiltered = []; + foreach ($headers as $key => $value) { + if (\in_array(\strtolower($key), FUNCTION_ALLOWLIST_HEADERS_REQUEST)) { + $headersFiltered[] = ['name' => $key, 'value' => $value]; + } + } + + $executionId = ID::unique(); + + $status = $async ? 'waiting' : 'processing'; + + if (!is_null($scheduledAt)) { + $status = 'scheduled'; + } + + $execution = new Document([ + '$id' => $executionId, + '$permissions' => !$user->isEmpty() ? [Permission::read(Role::user($user->getId()))] : [], + 'resourceInternalId' => $function->getInternalId(), + 'resourceId' => $function->getId(), + 'resourceType' => 'functions', + 'deploymentInternalId' => $deployment->getInternalId(), + 'deploymentId' => $deployment->getId(), + 'trigger' => (!is_null($scheduledAt)) ? 'schedule' : 'http', + 'status' => $status, // waiting / processing / completed / failed / scheduled + 'responseStatusCode' => 0, + 'responseHeaders' => [], + 'requestPath' => $path, + 'requestMethod' => $method, + 'requestHeaders' => $headersFiltered, + 'errors' => '', + 'logs' => '', + 'duration' => 0.0, + 'search' => implode(' ', [$functionId, $executionId]), + ]); + + $queueForEvents + ->setParam('functionId', $function->getId()) + ->setParam('executionId', $execution->getId()) + ->setContext('function', $function); + + if ($async) { + if (is_null($scheduledAt)) { + $execution = Authorization::skip(fn () => $dbForProject->createDocument('executions', $execution)); + $queueForFunctions + ->setType('http') + ->setExecution($execution) + ->setFunction($function) + ->setBody($body) + ->setHeaders($headers) + ->setPath($path) + ->setMethod($method) + ->setJWT($jwt) + ->setProject($project) + ->setUser($user) + ->setParam('functionId', $function->getId()) + ->setParam('executionId', $execution->getId()) + ->trigger(); + } else { + $data = [ + 'headers' => $headers, + 'path' => $path, + 'method' => $method, + 'body' => $body, + 'userId' => $user->getId() + ]; + + $schedule = $dbForPlatform->createDocument('schedules', new Document([ + 'region' => System::getEnv('_APP_REGION', 'default'), + 'resourceType' => ScheduleExecutions::getSupportedResource(), + 'resourceId' => $execution->getId(), + 'resourceInternalId' => $execution->getInternalId(), + 'resourceUpdatedAt' => DateTime::now(), + 'projectId' => $project->getId(), + 'schedule' => $scheduledAt, + 'data' => $data, + 'active' => true, + ])); + + $execution = $execution + ->setAttribute('scheduleId', $schedule->getId()) + ->setAttribute('scheduleInternalId', $schedule->getInternalId()) + ->setAttribute('scheduledAt', $scheduledAt); + + $execution = Authorization::skip(fn () => $dbForProject->createDocument('executions', $execution)); + } + + return $response + ->setStatusCode(Response::STATUS_CODE_ACCEPTED) + ->dynamic($execution, Response::MODEL_EXECUTION); + } + + $durationStart = \microtime(true); + + $vars = []; + + // V2 vars + if ($version === 'v2') { + $vars = \array_merge($vars, [ + 'APPWRITE_FUNCTION_TRIGGER' => $headers['x-appwrite-trigger'] ?? '', + 'APPWRITE_FUNCTION_DATA' => $body ?? '', + 'APPWRITE_FUNCTION_USER_ID' => $headers['x-appwrite-user-id'] ?? '', + 'APPWRITE_FUNCTION_JWT' => $headers['x-appwrite-user-jwt'] ?? '' + ]); + } + + // Shared vars + foreach ($function->getAttribute('varsProject', []) as $var) { + $vars[$var->getAttribute('key')] = $var->getAttribute('value', ''); + } + + // Function vars + foreach ($function->getAttribute('vars', []) as $var) { + $vars[$var->getAttribute('key')] = $var->getAttribute('value', ''); + } + + $protocol = System::getEnv('_APP_OPTIONS_FORCE_HTTPS') == 'disabled' ? 'http' : 'https'; + $hostname = System::getEnv('_APP_DOMAIN'); + $endpoint = $protocol . '://' . $hostname . "/v1"; + + // Appwrite vars + $vars = \array_merge($vars, [ + 'APPWRITE_FUNCTION_API_ENDPOINT' => $endpoint, + 'APPWRITE_FUNCTION_ID' => $functionId, + 'APPWRITE_FUNCTION_NAME' => $function->getAttribute('name'), + 'APPWRITE_FUNCTION_DEPLOYMENT' => $deployment->getId(), + 'APPWRITE_FUNCTION_PROJECT_ID' => $project->getId(), + 'APPWRITE_FUNCTION_RUNTIME_NAME' => $runtime['name'] ?? '', + 'APPWRITE_FUNCTION_RUNTIME_VERSION' => $runtime['version'] ?? '', + 'APPWRITE_FUNCTION_CPUS' => $spec['cpus'] ?? APP_COMPUTE_CPUS_DEFAULT, + 'APPWRITE_FUNCTION_MEMORY' => $spec['memory'] ?? APP_COMPUTE_MEMORY_DEFAULT, + 'APPWRITE_VERSION' => APP_VERSION_STABLE, + 'APPWRITE_REGION' => $project->getAttribute('region'), + 'APPWRITE_DEPLOYMENT_TYPE' => $deployment->getAttribute('type', ''), + 'APPWRITE_VCS_REPOSITORY_ID' => $deployment->getAttribute('providerRepositoryId', ''), + 'APPWRITE_VCS_REPOSITORY_NAME' => $deployment->getAttribute('providerRepositoryName', ''), + 'APPWRITE_VCS_REPOSITORY_OWNER' => $deployment->getAttribute('providerRepositoryOwner', ''), + 'APPWRITE_VCS_REPOSITORY_URL' => $deployment->getAttribute('providerRepositoryUrl', ''), + 'APPWRITE_VCS_REPOSITORY_BRANCH' => $deployment->getAttribute('providerBranch', ''), + 'APPWRITE_VCS_REPOSITORY_BRANCH_URL' => $deployment->getAttribute('providerBranchUrl', ''), + 'APPWRITE_VCS_COMMIT_HASH' => $deployment->getAttribute('providerCommitHash', ''), + 'APPWRITE_VCS_COMMIT_MESSAGE' => $deployment->getAttribute('providerCommitMessage', ''), + 'APPWRITE_VCS_COMMIT_URL' => $deployment->getAttribute('providerCommitUrl', ''), + 'APPWRITE_VCS_COMMIT_AUTHOR_NAME' => $deployment->getAttribute('providerCommitAuthor', ''), + 'APPWRITE_VCS_COMMIT_AUTHOR_URL' => $deployment->getAttribute('providerCommitAuthorUrl', ''), + 'APPWRITE_VCS_ROOT_DIRECTORY' => $deployment->getAttribute('providerRootDirectory', ''), + ]); + + /** Execute function */ + $executor = new Executor(System::getEnv('_APP_EXECUTOR_HOST')); + try { + $version = $function->getAttribute('version', 'v2'); + $command = $runtime['startCommand']; + $command = $version === 'v2' ? '' : 'cp /tmp/code.tar.gz /mnt/code/code.tar.gz && nohup helpers/start.sh "' . $command . '"'; + $executionResponse = $executor->createExecution( + projectId: $project->getId(), + deploymentId: $deployment->getId(), + body: \strlen($body) > 0 ? $body : null, + variables: $vars, + timeout: $function->getAttribute('timeout', 0), + image: $runtime['image'], + source: $build->getAttribute('path', ''), + entrypoint: $deployment->getAttribute('entrypoint', ''), + version: $version, + path: $path, + method: $method, + headers: $headers, + runtimeEntrypoint: $command, + cpus: $spec['cpus'] ?? APP_COMPUTE_CPUS_DEFAULT, + memory: $spec['memory'] ?? APP_COMPUTE_MEMORY_DEFAULT, + logging: $function->getAttribute('logging', true), + requestTimeout: 30 + ); + + $headersFiltered = []; + foreach ($executionResponse['headers'] as $key => $value) { + if (\in_array(\strtolower($key), FUNCTION_ALLOWLIST_HEADERS_RESPONSE)) { + $headersFiltered[] = ['name' => $key, 'value' => $value]; + } + } + + /** Update execution status */ + $status = $executionResponse['statusCode'] >= 500 ? 'failed' : 'completed'; + $execution->setAttribute('status', $status); + $execution->setAttribute('responseStatusCode', $executionResponse['statusCode']); + $execution->setAttribute('responseHeaders', $headersFiltered); + $execution->setAttribute('logs', $executionResponse['logs']); + $execution->setAttribute('errors', $executionResponse['errors']); + $execution->setAttribute('duration', $executionResponse['duration']); + } catch (\Throwable $th) { + $durationEnd = \microtime(true); + + $execution + ->setAttribute('duration', $durationEnd - $durationStart) + ->setAttribute('status', 'failed') + ->setAttribute('responseStatusCode', 500) + ->setAttribute('errors', $th->getMessage() . '\nError Code: ' . $th->getCode()); + Console::error($th->getMessage()); + + if ($th instanceof AppwriteException) { + throw $th; + } + } finally { + $queueForStatsUsage + ->addMetric(METRIC_EXECUTIONS, 1) + ->addMetric(str_replace('{functionInternalId}', $function->getInternalId(), METRIC_FUNCTION_ID_EXECUTIONS), 1) + ->addMetric(METRIC_EXECUTIONS_COMPUTE, (int)($execution->getAttribute('duration') * 1000)) // per project + ->addMetric(str_replace('{functionInternalId}', $function->getInternalId(), METRIC_FUNCTION_ID_EXECUTIONS_COMPUTE), (int)($execution->getAttribute('duration') * 1000)) // per function + ->addMetric(METRIC_EXECUTIONS_MB_SECONDS, (int)(($spec['memory'] ?? APP_COMPUTE_MEMORY_DEFAULT) * $execution->getAttribute('duration', 0) * ($spec['cpus'] ?? APP_COMPUTE_CPUS_DEFAULT))) + ->addMetric(str_replace('{functionInternalId}', $function->getInternalId(), METRIC_FUNCTION_ID_EXECUTIONS_MB_SECONDS), (int)(($spec['memory'] ?? APP_COMPUTE_MEMORY_DEFAULT) * $execution->getAttribute('duration', 0) * ($spec['cpus'] ?? APP_COMPUTE_CPUS_DEFAULT))) + ; + + $execution = Authorization::skip(fn () => $dbForProject->createDocument('executions', $execution)); + } + + $roles = Authorization::getRoles(); + $isPrivilegedUser = Auth::isPrivilegedUser($roles); + $isAppUser = Auth::isAppUser($roles); + + if (!$isPrivilegedUser && !$isAppUser) { + $execution->setAttribute('logs', ''); + $execution->setAttribute('errors', ''); + } + + $headers = []; + foreach (($executionResponse['headers'] ?? []) as $key => $value) { + $headers[] = ['name' => $key, 'value' => $value]; + } + + $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/src/Appwrite/Platform/Modules/Functions/Http/Executions/Delete.php b/src/Appwrite/Platform/Modules/Functions/Http/Executions/Delete.php new file mode 100644 index 0000000000..7b9fd77a25 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Functions/Http/Executions/Delete.php @@ -0,0 +1,116 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_DELETE) + ->setHttpPath('/v1/functions/:functionId/executions/:executionId') + ->desc('Delete execution') + ->groups(['api', 'functions']) + ->label('scope', 'execution.write') + ->label('resourceType', RESOURCE_TYPE_FUNCTIONS) + ->label('event', 'functions.[functionId].executions.[executionId].delete') + ->label('audits.event', 'executions.delete') + ->label('audits.resource', 'function/{request.functionId}') + ->label('sdk', new Method( + namespace: 'functions', + name: 'deleteExecution', + description: <<param('functionId', '', new UID(), 'Function ID.') + ->param('executionId', '', new UID(), 'Execution ID.') + ->inject('response') + ->inject('dbForProject') + ->inject('dbForPlatform') + ->inject('queueForEvents') + ->callback([$this, 'action']); + } + + public function action(string $functionId, string $executionId, Response $response, Database $dbForProject, Database $dbForPlatform, Event $queueForEvents) + { + $function = $dbForProject->getDocument('functions', $functionId); + + if ($function->isEmpty()) { + throw new Exception(Exception::FUNCTION_NOT_FOUND); + } + + $execution = $dbForProject->getDocument('executions', $executionId); + if ($execution->isEmpty()) { + throw new Exception(Exception::EXECUTION_NOT_FOUND); + } + + if ($execution->getAttribute('resourceType') !== 'functions' && $execution->getAttribute('resourceInternalId') !== $function->getInternalId()) { + throw new Exception(Exception::EXECUTION_NOT_FOUND); + } + $status = $execution->getAttribute('status'); + + if (!in_array($status, ['completed', 'failed', 'scheduled'])) { + throw new Exception(Exception::EXECUTION_IN_PROGRESS); + } + + if (!$dbForProject->deleteDocument('executions', $execution->getId())) { + throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed to remove execution from DB'); + } + + if ($status === 'scheduled') { + $schedule = $dbForPlatform->findOne('schedules', [ + Query::equal('resourceId', [$execution->getId()]), + Query::equal('resourceType', [ScheduleExecutions::getSupportedResource()]), + Query::equal('active', [true]), + ]); + + if (!$schedule->isEmpty()) { + $schedule + ->setAttribute('resourceUpdatedAt', DateTime::now()) + ->setAttribute('active', false); + + Authorization::skip(fn () => $dbForPlatform->updateDocument('schedules', $schedule->getId(), $schedule)); + } + } + + $queueForEvents + ->setParam('functionId', $function->getId()) + ->setParam('executionId', $execution->getId()) + ->setPayload($response->output($execution, Response::MODEL_EXECUTION)); + + $response->noContent(); + } +} diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Executions/Get.php b/src/Appwrite/Platform/Modules/Functions/Http/Executions/Get.php new file mode 100644 index 0000000000..da58d86414 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Functions/Http/Executions/Get.php @@ -0,0 +1,88 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/functions/:functionId/executions/:executionId') + ->desc('Get execution') + ->groups(['api', 'functions']) + ->label('scope', 'execution.read') + ->label('resourceType', RESOURCE_TYPE_FUNCTIONS) + ->label('sdk', new Method( + namespace: 'functions', + name: 'getExecution', + description: <<param('functionId', '', new UID(), 'Function ID.') + ->param('executionId', '', new UID(), 'Execution ID.') + ->inject('response') + ->inject('dbForProject') + ->callback([$this, 'action']); + } + + public function action(string $functionId, string $executionId, Response $response, Database $dbForProject) + { + $function = Authorization::skip(fn () => $dbForProject->getDocument('functions', $functionId)); + + $isAPIKey = Auth::isAppUser(Authorization::getRoles()); + $isPrivilegedUser = Auth::isPrivilegedUser(Authorization::getRoles()); + + if ($function->isEmpty() || (!$function->getAttribute('enabled') && !$isAPIKey && !$isPrivilegedUser)) { + throw new Exception(Exception::FUNCTION_NOT_FOUND); + } + + $execution = $dbForProject->getDocument('executions', $executionId); + + if ($execution->getAttribute('resourceType') !== 'functions' && $execution->getAttribute('resourceInternalId') !== $function->getInternalId()) { + throw new Exception(Exception::EXECUTION_NOT_FOUND); + } + + if ($execution->isEmpty()) { + throw new Exception(Exception::EXECUTION_NOT_FOUND); + } + + $roles = Authorization::getRoles(); + $isPrivilegedUser = Auth::isPrivilegedUser($roles); + $isAppUser = Auth::isAppUser($roles); + if (!$isPrivilegedUser && !$isAppUser) { + $execution->setAttribute('logs', ''); + $execution->setAttribute('errors', ''); + } + + $response->dynamic($execution, Response::MODEL_EXECUTION); + } +} diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Executions/XList.php b/src/Appwrite/Platform/Modules/Functions/Http/Executions/XList.php new file mode 100644 index 0000000000..597e830dd3 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Functions/Http/Executions/XList.php @@ -0,0 +1,135 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/functions/:functionId/executions') + ->desc('List executions') + ->groups(['api', 'functions']) + ->label('scope', 'execution.read') + ->label('resourceType', RESOURCE_TYPE_FUNCTIONS) + ->label('sdk', new Method( + namespace: 'functions', + name: 'listExecutions', + description: <<param('functionId', '', new UID(), 'Function ID.') + ->param('queries', [], new Executions(), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long. You may filter on the following attributes: ' . implode(', ', Executions::ALLOWED_ATTRIBUTES), true) + ->param('search', '', new Text(256), 'Search term to filter your list results. Max length: 256 chars.', true) + ->inject('response') + ->inject('dbForProject') + ->callback([$this, 'action']); + } + + public function action(string $functionId, array $queries, string $search, Response $response, Database $dbForProject) + { + $function = Authorization::skip(fn () => $dbForProject->getDocument('functions', $functionId)); + + $isAPIKey = Auth::isAppUser(Authorization::getRoles()); + $isPrivilegedUser = Auth::isPrivilegedUser(Authorization::getRoles()); + + if ($function->isEmpty() || (!$function->getAttribute('enabled') && !$isAPIKey && !$isPrivilegedUser)) { + throw new Exception(Exception::FUNCTION_NOT_FOUND); + } + + try { + $queries = Query::parseQueries($queries); + } catch (QueryException $e) { + throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage()); + } + + if (!empty($search)) { + $queries[] = Query::search('search', $search); + } + + // Set internal queries + $queries[] = Query::equal('resourceInternalId', [$function->getInternalId()]); + $queries[] = Query::equal('resourceType', ['functions']); + + /** + * Get cursor document if there was a cursor query, we use array_filter and reset for reference $cursor to $queries + */ + $cursor = \array_filter($queries, function ($query) { + return \in_array($query->getMethod(), [Query::TYPE_CURSOR_AFTER, Query::TYPE_CURSOR_BEFORE]); + }); + $cursor = reset($cursor); + if ($cursor) { + /** @var Query $cursor */ + + $validator = new Cursor(); + if (!$validator->isValid($cursor)) { + throw new Exception(Exception::GENERAL_QUERY_INVALID, $validator->getDescription()); + } + + $executionId = $cursor->getValue(); + $cursorDocument = $dbForProject->getDocument('executions', $executionId); + + if ($cursorDocument->isEmpty()) { + throw new Exception(Exception::GENERAL_CURSOR_NOT_FOUND, "Execution '{$executionId}' for the 'cursor' value not found."); + } + + $cursor->setValue($cursorDocument); + } + + $filterQueries = Query::groupByType($queries)['filters']; + + $results = $dbForProject->find('executions', $queries); + $total = $dbForProject->count('executions', $filterQueries, APP_LIMIT_COUNT); + + $roles = Authorization::getRoles(); + $isPrivilegedUser = Auth::isPrivilegedUser($roles); + $isAppUser = Auth::isAppUser($roles); + if (!$isPrivilegedUser && !$isAppUser) { + $results = array_map(function ($execution) { + $execution->setAttribute('logs', ''); + $execution->setAttribute('errors', ''); + return $execution; + }, $results); + } + + $response->dynamic(new Document([ + 'executions' => $results, + 'total' => $total, + ]), Response::MODEL_EXECUTION_LIST); + } +} diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Functions/Delete.php b/src/Appwrite/Platform/Modules/Functions/Http/Functions/Delete.php new file mode 100644 index 0000000000..92c99133b7 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Functions/Http/Functions/Delete.php @@ -0,0 +1,93 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_DELETE) + ->setHttpPath('/v1/functions/:functionId') + ->desc('Delete function') + ->groups(['api', 'functions']) + ->label('scope', 'functions.write') + ->label('resourceType', RESOURCE_TYPE_FUNCTIONS) + ->label('event', 'functions.[functionId].delete') + ->label('audits.event', 'function.delete') + ->label('audits.resource', 'function/{request.functionId}') + ->label('sdk', new Method( + namespace: 'functions', + name: 'delete', + description: <<param('functionId', '', new UID(), 'Function ID.') + ->inject('response') + ->inject('dbForProject') + ->inject('queueForDeletes') + ->inject('queueForEvents') + ->inject('dbForPlatform') + ->callback([$this, 'action']); + } + + public function action(string $functionId, Response $response, Database $dbForProject, DeleteEvent $queueForDeletes, Event $queueForEvents, Database $dbForPlatform) + { + $function = $dbForProject->getDocument('functions', $functionId); + + if ($function->isEmpty()) { + throw new Exception(Exception::FUNCTION_NOT_FOUND); + } + + if (!$dbForProject->deleteDocument('functions', $function->getId())) { + throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed to remove function from DB'); + } + + // Inform scheduler to no longer run function + $schedule = $dbForPlatform->getDocument('schedules', $function->getAttribute('scheduleId')); + $schedule + ->setAttribute('resourceUpdatedAt', DateTime::now()) + ->setAttribute('active', false); + Authorization::skip(fn () => $dbForPlatform->updateDocument('schedules', $schedule->getId(), $schedule)); + + $queueForDeletes + ->setType(DELETE_TYPE_DOCUMENT) + ->setDocument($function); + + $queueForEvents->setParam('functionId', $function->getId()); + + $response->noContent(); + } +} diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Functions/Get.php b/src/Appwrite/Platform/Modules/Functions/Http/Functions/Get.php new file mode 100644 index 0000000000..77c673b614 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Functions/Http/Functions/Get.php @@ -0,0 +1,64 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/functions/:functionId') + ->desc('Get function') + ->groups(['api', 'functions']) + ->label('scope', 'functions.read') + ->label('resourceType', RESOURCE_TYPE_FUNCTIONS) + ->label('sdk', new Method( + namespace: 'functions', + name: 'get', + description: <<param('functionId', '', new UID(), 'Function ID.') + ->inject('response') + ->inject('dbForProject') + ->callback([$this, 'action']); + } + + public function action(string $functionId, Response $response, Database $dbForProject) + { + $function = $dbForProject->getDocument('functions', $functionId); + + if ($function->isEmpty()) { + throw new Exception(Exception::FUNCTION_NOT_FOUND); + } + + $response->dynamic($function, Response::MODEL_FUNCTION); + } +} diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Specifications/XList.php b/src/Appwrite/Platform/Modules/Functions/Http/Specifications/XList.php new file mode 100644 index 0000000000..dbaf3daa04 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Functions/Http/Specifications/XList.php @@ -0,0 +1,76 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/functions/specifications') + ->groups(['api', 'functions']) + ->desc('List available function runtime specifications') + ->label('scope', 'functions.read') + ->label('resourceType', RESOURCE_TYPE_FUNCTIONS) + ->label('sdk', new Method( + namespace: 'functions', + name: 'listSpecifications', + description: <<inject('response') + ->inject('plan') + ->callback([$this, 'action']); + } + + public function action(Response $response, array $plan) + { + $allRuntimeSpecs = Config::getParam('runtime-specifications', []); + + $runtimeSpecs = []; + foreach ($allRuntimeSpecs as $spec) { + $spec['enabled'] = true; + + if (array_key_exists('runtimeSpecifications', $plan)) { + $spec['enabled'] = in_array($spec['slug'], $plan['runtimeSpecifications']); + } + + // Only add specs that are within the limits set by environment variables + if ($spec['cpus'] <= System::getEnv('_APP_COMPUTE_CPUS', 1) && $spec['memory'] <= System::getEnv('_APP_COMPUTE_MEMORY', 512)) { + $runtimeSpecs[] = $spec; + } + } + + $response->dynamic(new Document([ + 'specifications' => $runtimeSpecs, + 'total' => count($runtimeSpecs) + ]), Response::MODEL_SPECIFICATION_LIST); + } +} diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Templates/Get.php b/src/Appwrite/Platform/Modules/Functions/Http/Templates/Get.php new file mode 100644 index 0000000000..e624b26de6 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Functions/Http/Templates/Get.php @@ -0,0 +1,69 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/functions/templates/:templateId') + ->desc('Get function template') + ->label('scope', 'public') + ->label('resourceType', RESOURCE_TYPE_FUNCTIONS) + ->label('sdk', new Method( + namespace: 'functions', + name: 'getTemplate', + description: <<param('templateId', '', new Text(128), 'Template ID.') + ->inject('response') + ->callback([$this, 'action']); + } + + public function action(string $templateId, Response $response) + { + $templates = Config::getParam('function-templates', []); + + $filtered = \array_filter($templates, function ($template) use ($templateId) { + return $template['id'] === $templateId; + }); + + $template = array_shift($filtered); + + if (empty($template)) { + throw new Exception(Exception::FUNCTION_TEMPLATE_NOT_FOUND); + } + + $response->dynamic(new Document($template), Response::MODEL_TEMPLATE_FUNCTION); + } +} diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Templates/XList.php b/src/Appwrite/Platform/Modules/Functions/Http/Templates/XList.php new file mode 100644 index 0000000000..310ed89fdf --- /dev/null +++ b/src/Appwrite/Platform/Modules/Functions/Http/Templates/XList.php @@ -0,0 +1,79 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/functions/templates') + ->desc('List function templates') + ->label('scope', 'public') + ->label('resourceType', RESOURCE_TYPE_FUNCTIONS) + ->label('sdk', new Method( + namespace: 'functions', + name: 'listTemplates', + description: <<param('runtimes', [], new ArrayList(new WhiteList(array_keys(Config::getParam('runtimes')), true), APP_LIMIT_ARRAY_PARAMS_SIZE), 'List of runtimes allowed for filtering function templates. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' runtimes are allowed.', true) + ->param('useCases', [], new ArrayList(new WhiteList(['dev-tools','starter','databases','ai','messaging','utilities']), APP_LIMIT_ARRAY_PARAMS_SIZE), 'List of use cases allowed for filtering function templates. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' use cases are allowed.', true) + ->param('limit', 25, new Range(1, 5000), 'Limit the number of templates returned in the response. Default limit is 25, and maximum limit is 5000.', true) + ->param('offset', 0, new Range(0, 5000), 'Offset the list of returned templates. Maximum offset is 5000.', true) + ->inject('response') + ->callback([$this, 'action']); + } + + public function action(array $runtimes, array $usecases, int $limit, int $offset, Response $response) + { + $templates = Config::getParam('function-templates', []); + + if (!empty($runtimes)) { + $templates = \array_filter($templates, function ($template) use ($runtimes) { + return \count(\array_intersect($runtimes, \array_column($template['runtimes'], 'name'))) > 0; + }); + } + + if (!empty($usecases)) { + $templates = \array_filter($templates, function ($template) use ($usecases) { + return \count(\array_intersect($usecases, $template['useCases'])) > 0; + }); + } + + $responseTemplates = \array_slice($templates, $offset, $limit); + $response->dynamic(new Document([ + 'templates' => $responseTemplates, + 'total' => \count($responseTemplates), + ]), Response::MODEL_TEMPLATE_FUNCTION_LIST); + } +} diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Usage/Get.php b/src/Appwrite/Platform/Modules/Functions/Http/Usage/Get.php new file mode 100644 index 0000000000..88221ccac0 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Functions/Http/Usage/Get.php @@ -0,0 +1,149 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/functions/:functionId/usage') + ->desc('Get function usage') + ->groups(['api', 'functions', 'usage']) + ->label('scope', 'functions.read') + ->label('resourceType', RESOURCE_TYPE_FUNCTIONS) + ->label('sdk', new Method( + namespace: 'functions', + name: 'getUsage', + description: <<param('functionId', '', new UID(), 'Function ID.') + ->param('range', '30d', new WhiteList(['24h', '30d', '90d']), 'Date range.', true) + ->inject('response') + ->inject('dbForProject') + ->callback([$this, 'action']); + } + + public function action(string $functionId, string $range, Response $response, Database $dbForProject) + { + $function = $dbForProject->getDocument('functions', $functionId); + + if ($function->isEmpty()) { + throw new Exception(Exception::FUNCTION_NOT_FOUND); + } + + $periods = Config::getParam('usage', []); + $stats = $usage = []; + $days = $periods[$range]; + $metrics = [ + str_replace(['{resourceType}', '{resourceInternalId}'], ['functions', $function->getInternalId()], METRIC_FUNCTION_ID_DEPLOYMENTS), + str_replace(['{resourceType}', '{resourceInternalId}'], ['functions', $function->getInternalId()], METRIC_FUNCTION_ID_DEPLOYMENTS_STORAGE), + str_replace('{functionInternalId}', $function->getInternalId(), METRIC_FUNCTION_ID_BUILDS), + str_replace('{functionInternalId}', $function->getInternalId(), METRIC_FUNCTION_ID_BUILDS_STORAGE), + str_replace('{functionInternalId}', $function->getInternalId(), METRIC_FUNCTION_ID_BUILDS_COMPUTE), + str_replace('{functionInternalId}', $function->getInternalId(), METRIC_FUNCTION_ID_EXECUTIONS), + str_replace('{functionInternalId}', $function->getInternalId(), METRIC_FUNCTION_ID_EXECUTIONS_COMPUTE), + str_replace('{functionInternalId}', $function->getInternalId(), METRIC_FUNCTION_ID_BUILDS_MB_SECONDS), + str_replace('{functionInternalId}', $function->getInternalId(), METRIC_FUNCTION_ID_EXECUTIONS_MB_SECONDS) + ]; + + Authorization::skip(function () use ($dbForProject, $days, $metrics, &$stats) { + foreach ($metrics as $metric) { + $result = $dbForProject->findOne('stats', [ + Query::equal('metric', [$metric]), + Query::equal('period', ['inf']) + ]); + + $stats[$metric]['total'] = $result['value'] ?? 0; + $limit = $days['limit']; + $period = $days['period']; + $results = $dbForProject->find('stats', [ + Query::equal('metric', [$metric]), + Query::equal('period', [$period]), + Query::limit($limit), + Query::orderDesc('time'), + ]); + $stats[$metric]['data'] = []; + foreach ($results as $result) { + $stats[$metric]['data'][$result->getAttribute('time')] = [ + 'value' => $result->getAttribute('value'), + ]; + } + } + }); + + $format = match ($days['period']) { + '1h' => 'Y-m-d\TH:00:00.000P', + '1d' => 'Y-m-d\T00:00:00.000P', + }; + + foreach ($metrics as $metric) { + $usage[$metric]['total'] = $stats[$metric]['total']; + $usage[$metric]['data'] = []; + $leap = time() - ($days['limit'] * $days['factor']); + while ($leap < time()) { + $leap += $days['factor']; + $formatDate = date($format, $leap); + $usage[$metric]['data'][] = [ + 'value' => $stats[$metric]['data'][$formatDate]['value'] ?? 0, + 'date' => $formatDate, + ]; + } + } + + $response->dynamic(new Document([ + 'range' => $range, + 'deploymentsTotal' => $usage[$metrics[0]]['total'], + 'deploymentsStorageTotal' => $usage[$metrics[1]]['total'], + 'buildsTotal' => $usage[$metrics[2]]['total'], + 'buildsStorageTotal' => $usage[$metrics[3]]['total'], + 'buildsTimeTotal' => $usage[$metrics[4]]['total'], + 'executionsTotal' => $usage[$metrics[5]]['total'], + 'executionsTimeTotal' => $usage[$metrics[6]]['total'], + 'deployments' => $usage[$metrics[0]]['data'], + 'deploymentsStorage' => $usage[$metrics[1]]['data'], + 'builds' => $usage[$metrics[2]]['data'], + 'buildsStorage' => $usage[$metrics[3]]['data'], + 'buildsTime' => $usage[$metrics[4]]['data'], + 'executions' => $usage[$metrics[5]]['data'], + 'executionsTime' => $usage[$metrics[6]]['data'], + 'buildsMbSecondsTotal' => $usage[$metrics[7]]['total'], + 'buildsMbSeconds' => $usage[$metrics[7]]['data'], + 'executionsMbSeconds' => $usage[$metrics[8]]['data'], + 'executionsMbSecondsTotal' => $usage[$metrics[8]]['total'] + ]), Response::MODEL_USAGE_FUNCTION); + } +} diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Usage/XList.php b/src/Appwrite/Platform/Modules/Functions/Http/Usage/XList.php new file mode 100644 index 0000000000..ca94b59a96 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Functions/Http/Usage/XList.php @@ -0,0 +1,142 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/functions/usage') + ->desc('Get functions usage') + ->groups(['api', 'functions', 'usage']) + ->label('scope', 'functions.read') + ->label('resourceType', RESOURCE_TYPE_FUNCTIONS) + ->label('sdk', new Method( + namespace: 'functions', + name: 'listUsage', + description: <<param('range', '30d', new WhiteList(['24h', '30d', '90d']), 'Date range.', true) + ->inject('response') + ->inject('dbForProject') + ->callback([$this, 'action']); + } + + public function action(string $range, Response $response, Database $dbForProject) + { + $periods = Config::getParam('usage', []); + $stats = $usage = []; + $days = $periods[$range]; + $metrics = [ + METRIC_FUNCTIONS, + METRIC_DEPLOYMENTS, + METRIC_DEPLOYMENTS_STORAGE, + METRIC_BUILDS, + METRIC_BUILDS_STORAGE, + METRIC_BUILDS_COMPUTE, + METRIC_EXECUTIONS, + METRIC_EXECUTIONS_COMPUTE, + METRIC_BUILDS_MB_SECONDS, + METRIC_EXECUTIONS_MB_SECONDS, + ]; + + Authorization::skip(function () use ($dbForProject, $days, $metrics, &$stats) { + foreach ($metrics as $metric) { + $result = $dbForProject->findOne('stats', [ + Query::equal('metric', [$metric]), + Query::equal('period', ['inf']) + ]); + + $stats[$metric]['total'] = $result['value'] ?? 0; + $limit = $days['limit']; + $period = $days['period']; + $results = $dbForProject->find('stats', [ + Query::equal('metric', [$metric]), + Query::equal('period', [$period]), + Query::limit($limit), + Query::orderDesc('time'), + ]); + $stats[$metric]['data'] = []; + foreach ($results as $result) { + $stats[$metric]['data'][$result->getAttribute('time')] = [ + 'value' => $result->getAttribute('value'), + ]; + } + } + }); + + $format = match ($days['period']) { + '1h' => 'Y-m-d\TH:00:00.000P', + '1d' => 'Y-m-d\T00:00:00.000P', + }; + + foreach ($metrics as $metric) { + $usage[$metric]['total'] = $stats[$metric]['total']; + $usage[$metric]['data'] = []; + $leap = time() - ($days['limit'] * $days['factor']); + while ($leap < time()) { + $leap += $days['factor']; + $formatDate = date($format, $leap); + $usage[$metric]['data'][] = [ + 'value' => $stats[$metric]['data'][$formatDate]['value'] ?? 0, + 'date' => $formatDate, + ]; + } + } + $response->dynamic(new Document([ + 'range' => $range, + 'functionsTotal' => $usage[$metrics[0]]['total'], + 'deploymentsTotal' => $usage[$metrics[1]]['total'], + 'deploymentsStorageTotal' => $usage[$metrics[2]]['total'], + 'buildsTotal' => $usage[$metrics[3]]['total'], + 'buildsStorageTotal' => $usage[$metrics[4]]['total'], + 'buildsTimeTotal' => $usage[$metrics[5]]['total'], + 'executionsTotal' => $usage[$metrics[6]]['total'], + 'executionsTimeTotal' => $usage[$metrics[7]]['total'], + 'functions' => $usage[$metrics[0]]['data'], + 'deployments' => $usage[$metrics[1]]['data'], + 'deploymentsStorage' => $usage[$metrics[2]]['data'], + 'builds' => $usage[$metrics[3]]['data'], + 'buildsStorage' => $usage[$metrics[4]]['data'], + 'buildsTime' => $usage[$metrics[5]]['data'], + 'executions' => $usage[$metrics[6]]['data'], + 'executionsTime' => $usage[$metrics[7]]['data'], + 'buildsMbSecondsTotal' => $usage[$metrics[8]]['total'], + 'buildsMbSeconds' => $usage[$metrics[8]]['data'], + 'executionsMbSeconds' => $usage[$metrics[9]]['data'], + 'executionsMbSecondsTotal' => $usage[$metrics[9]]['total'], + ]), Response::MODEL_USAGE_FUNCTIONS); + } +} diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Variables/Create.php b/src/Appwrite/Platform/Modules/Functions/Http/Variables/Create.php new file mode 100644 index 0000000000..ba48a5c4bc --- /dev/null +++ b/src/Appwrite/Platform/Modules/Functions/Http/Variables/Create.php @@ -0,0 +1,115 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_POST) + ->setHttpPath('/v1/functions/:functionId/variables') + ->desc('Create variable') + ->groups(['api', 'functions']) + ->label('scope', 'functions.write') + ->label('resourceType', RESOURCE_TYPE_FUNCTIONS) + ->label('audits.event', 'variable.create') + ->label('audits.resource', 'function/{request.functionId}') + ->label('sdk', new Method( + namespace: 'functions', + name: 'createVariable', + description: <<param('functionId', '', new UID(), 'Function unique ID.', false) + ->param('key', null, new Text(Database::LENGTH_KEY), 'Variable key. Max length: ' . Database::LENGTH_KEY . ' chars.', false) + ->param('value', null, new Text(8192, 0), 'Variable value. Max length: 8192 chars.', false) + ->param('secret', true, new Boolean(), 'Secret variables can be updated or deleted, but only functions can read them during build and runtime.', true) + ->inject('response') + ->inject('dbForProject') + ->inject('dbForPlatform') + ->callback([$this, 'action']); + } + + public function action(string $functionId, string $key, string $value, bool $secret, Response $response, Database $dbForProject, Database $dbForPlatform) + { + $function = $dbForProject->getDocument('functions', $functionId); + + if ($function->isEmpty()) { + throw new Exception(Exception::FUNCTION_NOT_FOUND); + } + + $variableId = ID::unique(); + + $variable = new Document([ + '$id' => $variableId, + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + 'resourceInternalId' => $function->getInternalId(), + 'resourceId' => $function->getId(), + 'resourceType' => 'function', + 'key' => $key, + 'value' => $value, + 'secret' => $secret, + 'search' => implode(' ', [$variableId, $function->getId(), $key, 'function']), + ]); + + try { + $variable = $dbForProject->createDocument('variables', $variable); + } catch (DuplicateException $th) { + throw new Exception(Exception::VARIABLE_ALREADY_EXISTS); + } + + $dbForProject->updateDocument('functions', $function->getId(), $function->setAttribute('live', false)); + + // Inform scheduler to pull the latest changes + $schedule = $dbForPlatform->getDocument('schedules', $function->getAttribute('scheduleId')); + $schedule + ->setAttribute('resourceUpdatedAt', DateTime::now()) + ->setAttribute('schedule', $function->getAttribute('schedule')) + ->setAttribute('active', !empty($function->getAttribute('schedule')) && !empty($function->getAttribute('deployment'))); + Authorization::skip(fn () => $dbForPlatform->updateDocument('schedules', $schedule->getId(), $schedule)); + + $response + ->setStatusCode(Response::STATUS_CODE_CREATED) + ->dynamic($variable, Response::MODEL_VARIABLE); + } +} diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Variables/Delete.php b/src/Appwrite/Platform/Modules/Functions/Http/Variables/Delete.php new file mode 100644 index 0000000000..650eaf4010 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Functions/Http/Variables/Delete.php @@ -0,0 +1,93 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_DELETE) + ->setHttpPath('/v1/functions/:functionId/variables/:variableId') + ->desc('Delete variable') + ->groups(['api', 'functions']) + ->label('scope', 'functions.write') + ->label('resourceType', RESOURCE_TYPE_FUNCTIONS) + ->label('audits.event', 'variable.delete') + ->label('audits.resource', 'function/{request.functionId}') + ->label('sdk', new Method( + namespace: 'functions', + name: 'deleteVariable', + description: <<param('functionId', '', new UID(), 'Function unique ID.', false) + ->param('variableId', '', new UID(), 'Variable unique ID.', false) + ->inject('response') + ->inject('dbForProject') + ->inject('dbForPlatform') + ->callback([$this, 'action']); + } + + public function action(string $functionId, string $variableId, Response $response, Database $dbForProject, Database $dbForPlatform) + { + $function = $dbForProject->getDocument('functions', $functionId); + + if ($function->isEmpty()) { + throw new Exception(Exception::FUNCTION_NOT_FOUND); + } + + $variable = $dbForProject->getDocument('variables', $variableId); + if ($variable === false || $variable->isEmpty() || $variable->getAttribute('resourceInternalId') !== $function->getInternalId() || $variable->getAttribute('resourceType') !== 'function') { + throw new Exception(Exception::VARIABLE_NOT_FOUND); + } + + if ($variable === false || $variable->isEmpty()) { + throw new Exception(Exception::VARIABLE_NOT_FOUND); + } + + $dbForProject->deleteDocument('variables', $variable->getId()); + + $dbForProject->updateDocument('functions', $function->getId(), $function->setAttribute('live', false)); + + // Inform scheduler to pull the latest changes + $schedule = $dbForPlatform->getDocument('schedules', $function->getAttribute('scheduleId')); + $schedule + ->setAttribute('resourceUpdatedAt', DateTime::now()) + ->setAttribute('schedule', $function->getAttribute('schedule')) + ->setAttribute('active', !empty($function->getAttribute('schedule')) && !empty($function->getAttribute('deployment'))); + Authorization::skip(fn () => $dbForPlatform->updateDocument('schedules', $schedule->getId(), $schedule)); + + $response->noContent(); + } +} diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Variables/Get.php b/src/Appwrite/Platform/Modules/Functions/Http/Variables/Get.php new file mode 100644 index 0000000000..eeb5c51f33 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Functions/Http/Variables/Get.php @@ -0,0 +1,82 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/functions/:functionId/variables/:variableId') + ->desc('Get variable') + ->groups(['api', 'functions']) + ->label('scope', 'functions.read') + ->label('resourceType', RESOURCE_TYPE_FUNCTIONS) + ->label( + 'sdk', + new Method( + namespace: 'functions', + name: 'getVariable', + description: <<param('functionId', '', new UID(), 'Function unique ID.', false) + ->param('variableId', '', new UID(), 'Variable unique ID.', false) + ->inject('response') + ->inject('dbForProject') + ->callback([$this, 'action']); + } + + public function action(string $functionId, string $variableId, Response $response, Database $dbForProject) + { + $function = $dbForProject->getDocument('functions', $functionId); + + if ($function->isEmpty()) { + throw new Exception(Exception::FUNCTION_NOT_FOUND); + } + + $variable = $dbForProject->getDocument('variables', $variableId); + if ( + $variable === false || + $variable->isEmpty() || + $variable->getAttribute('resourceInternalId') !== $function->getInternalId() || + $variable->getAttribute('resourceType') !== 'function' + ) { + throw new Exception(Exception::VARIABLE_NOT_FOUND); + } + + if ($variable === false || $variable->isEmpty()) { + throw new Exception(Exception::VARIABLE_NOT_FOUND); + } + + $response->dynamic($variable, Response::MODEL_VARIABLE); + } +} diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Variables/Update.php b/src/Appwrite/Platform/Modules/Functions/Http/Variables/Update.php new file mode 100644 index 0000000000..8911dc8abb --- /dev/null +++ b/src/Appwrite/Platform/Modules/Functions/Http/Variables/Update.php @@ -0,0 +1,111 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_PUT) + ->setHttpPath('/v1/functions/:functionId/variables/:variableId') + ->desc('Update variable') + ->groups(['api', 'functions']) + ->label('scope', 'functions.write') + ->label('resourceType', RESOURCE_TYPE_FUNCTIONS) + ->label('audits.event', 'variable.update') + ->label('audits.resource', 'function/{request.functionId}') + ->label('sdk', new Method( + namespace: 'functions', + name: 'updateVariable', + description: <<param('functionId', '', new UID(), 'Function unique ID.', false) + ->param('variableId', '', new UID(), 'Variable unique ID.', false) + ->param('key', null, new Text(255), 'Variable key. Max length: 255 chars.', false) + ->param('value', null, new Text(8192, 0), 'Variable value. Max length: 8192 chars.', true) + ->param('secret', null, new Boolean(), 'Secret variables can be updated or deleted, but only functions can read them during build and runtime.', true) + ->inject('response') + ->inject('dbForProject') + ->inject('dbForPlatform') + ->callback([$this, 'action']); + } + + public function action(string $functionId, string $variableId, string $key, ?string $value, ?bool $secret, Response $response, Database $dbForProject, Database $dbForPlatform) + { + $function = $dbForProject->getDocument('functions', $functionId); + + if ($function->isEmpty()) { + throw new Exception(Exception::FUNCTION_NOT_FOUND); + } + + $variable = $dbForProject->getDocument('variables', $variableId); + if ($variable === false || $variable->isEmpty() || $variable->getAttribute('resourceInternalId') !== $function->getInternalId() || $variable->getAttribute('resourceType') !== 'function') { + throw new Exception(Exception::VARIABLE_NOT_FOUND); + } + + if ($variable === false || $variable->isEmpty()) { + throw new Exception(Exception::VARIABLE_NOT_FOUND); + } + + if ($variable->getAttribute('secret') === true && $secret === false) { + throw new Exception(Exception::VARIABLE_CANNOT_UNSET_SECRET); + } + + $variable + ->setAttribute('key', $key) + ->setAttribute('value', $value ?? $variable->getAttribute('value')) + ->setAttribute('secret', $secret ?? $variable->getAttribute('secret')) + ->setAttribute('search', implode(' ', [$variableId, $function->getId(), $key, 'function'])); + + try { + $dbForProject->updateDocument('variables', $variable->getId(), $variable); + } catch (DuplicateException $th) { + throw new Exception(Exception::VARIABLE_ALREADY_EXISTS); + } + + $dbForProject->updateDocument('functions', $function->getId(), $function->setAttribute('live', false)); + + // Inform scheduler to pull the latest changes + $schedule = $dbForPlatform->getDocument('schedules', $function->getAttribute('scheduleId')); + $schedule + ->setAttribute('resourceUpdatedAt', DateTime::now()) + ->setAttribute('schedule', $function->getAttribute('schedule')) + ->setAttribute('active', !empty($function->getAttribute('schedule')) && !empty($function->getAttribute('deployment'))); + Authorization::skip(fn () => $dbForPlatform->updateDocument('schedules', $schedule->getId(), $schedule)); + + $response->dynamic($variable, Response::MODEL_VARIABLE); + } +} diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Variables/XList.php b/src/Appwrite/Platform/Modules/Functions/Http/Variables/XList.php new file mode 100644 index 0000000000..000e83a0c9 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Functions/Http/Variables/XList.php @@ -0,0 +1,71 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/functions/:functionId/variables') + ->desc('List variables') + ->groups(['api', 'functions']) + ->label('scope', 'functions.read') + ->label('resourceType', RESOURCE_TYPE_FUNCTIONS) + ->label( + 'sdk', + new Method( + namespace: 'functions', + name: 'listVariables', + description: <<param('functionId', '', new UID(), 'Function unique ID.', false) + ->inject('response') + ->inject('dbForProject') + ->callback([$this, 'action']); + } + + public function action(string $functionId, Response $response, Database $dbForProject) + { + $function = $dbForProject->getDocument('functions', $functionId); + + if ($function->isEmpty()) { + throw new Exception(Exception::FUNCTION_NOT_FOUND); + } + + $response->dynamic(new Document([ + 'variables' => $function->getAttribute('vars', []), + 'total' => \count($function->getAttribute('vars', [])), + ]), Response::MODEL_VARIABLE_LIST); + } +} diff --git a/src/Appwrite/Platform/Modules/Functions/Services/Http.php b/src/Appwrite/Platform/Modules/Functions/Services/Http.php index 629817827c..5ae77c5d6b 100644 --- a/src/Appwrite/Platform/Modules/Functions/Services/Http.php +++ b/src/Appwrite/Platform/Modules/Functions/Services/Http.php @@ -2,13 +2,36 @@ namespace Appwrite\Platform\Modules\Functions\Services; +use Appwrite\Platform\Modules\Functions\Http\Deployments\Builds\Create as CreateBuild; +use Appwrite\Platform\Modules\Functions\Http\Deployments\Builds\Update as UpdateBuild; use Appwrite\Platform\Modules\Functions\Http\Deployments\Create as CreateDeployment; +use Appwrite\Platform\Modules\Functions\Http\Deployments\Delete as DeleteDeployment; +use Appwrite\Platform\Modules\Functions\Http\Deployments\Download\Get as DownloadDeployment; +use Appwrite\Platform\Modules\Functions\Http\Deployments\Get as GetDeployment; use Appwrite\Platform\Modules\Functions\Http\Deployments\Template\Create as CreateTemplateDeployment; +use Appwrite\Platform\Modules\Functions\Http\Deployments\Update as UpdateDeployment; use Appwrite\Platform\Modules\Functions\Http\Deployments\Vcs\Create as CreateVcsDeployment; +use Appwrite\Platform\Modules\Functions\Http\Deployments\XList as ListDeployments; +use Appwrite\Platform\Modules\Functions\Http\Executions\Create as CreateExecution; +use Appwrite\Platform\Modules\Functions\Http\Executions\Delete as DeleteExecution; +use Appwrite\Platform\Modules\Functions\Http\Executions\Get as GetExecution; +use Appwrite\Platform\Modules\Functions\Http\Executions\XList as ListExecutions; use Appwrite\Platform\Modules\Functions\Http\Functions\Create as CreateFunction; +use Appwrite\Platform\Modules\Functions\Http\Functions\Delete as DeleteFunction; +use Appwrite\Platform\Modules\Functions\Http\Functions\Get as GetFunction; use Appwrite\Platform\Modules\Functions\Http\Functions\Update as UpdateFunction; use Appwrite\Platform\Modules\Functions\Http\Functions\XList as ListFunctions; use Appwrite\Platform\Modules\Functions\Http\Runtimes\XList as ListRuntimes; +use Appwrite\Platform\Modules\Functions\Http\Specifications\XList as ListSpecifications; +use Appwrite\Platform\Modules\Functions\Http\Templates\Get as GetTemplate; +use Appwrite\Platform\Modules\Functions\Http\Templates\XList as ListTemplates; +use Appwrite\Platform\Modules\Functions\Http\Usage\Get as GetUsage; +use Appwrite\Platform\Modules\Functions\Http\Usage\XList as ListUsage; +use Appwrite\Platform\Modules\Functions\Http\Variables\Create as CreateVariable; +use Appwrite\Platform\Modules\Functions\Http\Variables\Delete as DeleteVariable; +use Appwrite\Platform\Modules\Functions\Http\Variables\Get as GetVariable; +use Appwrite\Platform\Modules\Functions\Http\Variables\Update as UpdateVariable; +use Appwrite\Platform\Modules\Functions\Http\Variables\XList as ListVariables; use Utopia\Platform\Service; class Http extends Service @@ -16,12 +39,51 @@ class Http extends Service public function __construct() { $this->type = Service::TYPE_HTTP; + + // Functions $this->addAction(CreateFunction::getName(), new CreateFunction()); + $this->addAction(GetFunction::getName(), new GetFunction()); $this->addAction(UpdateFunction::getName(), new UpdateFunction()); $this->addAction(ListFunctions::getName(), new ListFunctions()); + $this->addAction(DeleteFunction::getName(), new DeleteFunction()); + + // Runtimes $this->addAction(ListRuntimes::getName(), new ListRuntimes()); + + // Specifications + $this->addAction(ListSpecifications::getName(), new ListSpecifications()); + + // Deployments $this->addAction(CreateDeployment::getName(), new CreateDeployment()); + $this->addAction(GetDeployment::getName(), new GetDeployment()); + $this->addAction(UpdateDeployment::getName(), new UpdateDeployment()); + $this->addAction(ListDeployments::getName(), new ListDeployments()); + $this->addAction(DeleteDeployment::getName(), new DeleteDeployment()); $this->addAction(CreateTemplateDeployment::getName(), new CreateTemplateDeployment()); $this->addAction(CreateVcsDeployment::getName(), new CreateVcsDeployment()); + $this->addAction(DownloadDeployment::getName(), new DownloadDeployment()); + $this->addAction(CreateBuild::getName(), new CreateBuild()); + $this->addAction(UpdateBuild::getName(), new UpdateBuild()); + + // Executions + $this->addAction(CreateExecution::getName(), new CreateExecution()); + $this->addAction(GetExecution::getName(), new GetExecution()); + $this->addAction(ListExecutions::getName(), new ListExecutions()); + $this->addAction(DeleteExecution::getName(), new DeleteExecution()); + + // Usage + $this->addAction(GetUsage::getName(), new GetUsage()); + $this->addAction(ListUsage::getName(), new ListUsage()); + + // Variables + $this->addAction(CreateVariable::getName(), new CreateVariable()); + $this->addAction(GetVariable::getName(), new GetVariable()); + $this->addAction(ListVariables::getName(), new ListVariables()); + $this->addAction(UpdateVariable::getName(), new UpdateVariable()); + $this->addAction(DeleteVariable::getName(), new DeleteVariable()); + + // Templates + $this->addAction(GetTemplate::getName(), new GetTemplate()); + $this->addAction(ListTemplates::getName(), new ListTemplates()); } } diff --git a/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php b/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php index 90c78b8454..298f1d4496 100644 --- a/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php +++ b/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php @@ -62,11 +62,12 @@ class Builds extends Action ->inject('cache') ->inject('dbForProject') ->inject('deviceForFunctions') + ->inject('deviceForSites') ->inject('isResourceBlocked') ->inject('deviceForFiles') ->inject('log') - ->callback(fn ($message, Document $project, Database $dbForPlatform, Event $queueForEvents, Func $queueForFunctions, StatsUsage $usage, Cache $cache, Database $dbForProject, Device $deviceForFunctions, callable $isResourceBlocked, Device $deviceForFiles, Log $log) => - $this->action($message, $project, $dbForPlatform, $queueForEvents, $queueForFunctions, $usage, $cache, $dbForProject, $deviceForFunctions, $isResourceBlocked, $deviceForFiles, $log)); + ->callback(fn ($message, Document $project, Database $dbForPlatform, Event $queueForEvents, Func $queueForFunctions, StatsUsage $usage, Cache $cache, Database $dbForProject, Device $deviceForFunctions, Device $deviceForSites, callable $isResourceBlocked, Device $deviceForFiles, Log $log) => + $this->action($message, $project, $dbForPlatform, $queueForEvents, $queueForFunctions, $usage, $cache, $dbForProject, $deviceForFunctions, $deviceForSites, $isResourceBlocked, $deviceForFiles, $log)); } /** @@ -79,12 +80,13 @@ class Builds extends Action * @param Cache $cache * @param Database $dbForProject * @param Device $deviceForFunctions + * @param Device $deviceForSites * @param Device $deviceForFiles * @param Log $log * @return void * @throws \Utopia\Database\Exception */ - public function action(Message $message, Document $project, Database $dbForPlatform, Event $queueForEvents, Func $queueForFunctions, StatsUsage $queueForStatsUsage, Cache $cache, Database $dbForProject, Device $deviceForFunctions, callable $isResourceBlocked, Device $deviceForFiles, Log $log): void + public function action(Message $message, Document $project, Database $dbForPlatform, Event $queueForEvents, Func $queueForFunctions, StatsUsage $queueForStatsUsage, Cache $cache, Database $dbForProject, Device $deviceForFunctions, Device $deviceForSites, callable $isResourceBlocked, Device $deviceForFiles, Log $log): void { $payload = $message->getPayload() ?? []; @@ -105,7 +107,7 @@ class Builds extends Action case BUILD_TYPE_RETRY: Console::info('Creating build for deployment: ' . $deployment->getId()); $github = new GitHub($cache); - $this->buildDeployment($deviceForFunctions, $deviceForFiles, $queueForFunctions, $queueForEvents, $queueForStatsUsage, $dbForPlatform, $dbForProject, $github, $project, $resource, $deployment, $template, $isResourceBlocked, $log); + $this->buildDeployment($deviceForFunctions, $deviceForSites, $deviceForFiles, $queueForFunctions, $queueForEvents, $queueForStatsUsage, $dbForPlatform, $dbForProject, $github, $project, $resource, $deployment, $template, $isResourceBlocked, $log); break; default: @@ -115,6 +117,7 @@ class Builds extends Action /** * @param Device $deviceForFunctions + * @param Device $deviceForSites * @param Device $deviceForFiles * @param Func $queueForFunctions * @param Event $queueForEvents @@ -132,7 +135,7 @@ class Builds extends Action * * @throws Exception */ - protected function buildDeployment(Device $deviceForFunctions, Device $deviceForFiles, Func $queueForFunctions, Event $queueForEvents, StatsUsage $queueForStatsUsage, Database $dbForPlatform, Database $dbForProject, GitHub $github, Document $project, Document $resource, Document $deployment, Document $template, callable $isResourceBlocked, Log $log): void + protected function buildDeployment(Device $deviceForFunctions, Device $deviceForSites, Device $deviceForFiles, Func $queueForFunctions, Event $queueForEvents, StatsUsage $queueForStatsUsage, Database $dbForPlatform, Database $dbForProject, GitHub $github, Document $project, Document $resource, Document $deployment, Document $template, callable $isResourceBlocked, Log $log): void { $resourceKey = match($resource->getCollection()) { 'functions' => 'functionId', @@ -140,6 +143,11 @@ class Builds extends Action default => throw new \Exception('Invalid resource type') }; + $device = match ($resource->getCollection()) { + 'sites' => $deviceForSites, + 'functions' => $deviceForFunctions, + }; + $executor = new Executor(System::getEnv('_APP_EXECUTOR_HOST')); $log->addTag($resourceKey, $resource->getId()); @@ -149,8 +157,7 @@ class Builds extends Action throw new \Exception('Resource not found'); } - // TODO: Sites support - if ($isResourceBlocked($project, RESOURCE_TYPE_FUNCTIONS, $resource->getId())) { + if ($isResourceBlocked($project, $resourceKey === 'functions' ? RESOURCE_TYPE_FUNCTIONS : RESOURCE_TYPE_SITES, $resource->getId())) { throw new \Exception('Resource is blocked'); } @@ -272,8 +279,8 @@ class Builds extends Action $tarParamDirectory = \escapeshellarg($tmpTemplateDirectory . (empty($templateRootDirectory) ? '' : '/' . $templateRootDirectory)); Console::execute('tar --exclude code.tar.gz -czf ' . \escapeshellarg($tmpPathFile) . ' -C ' . \escapeshellcmd($tarParamDirectory) . ' .', '', $stdout, $stderr); // TODO: Replace escapeshellcmd with escapeshellarg if we find a way that doesnt break syntax - $source = $deviceForFunctions->getPath($deployment->getId() . '.' . \pathinfo('code.tar.gz', PATHINFO_EXTENSION)); - $result = $localDevice->transfer($tmpPathFile, $source, $deviceForFunctions); + $source = $device->getPath($deployment->getId() . '.' . \pathinfo('code.tar.gz', PATHINFO_EXTENSION)); + $result = $localDevice->transfer($tmpPathFile, $source, $device); if (!$result) { throw new \Exception("Unable to move file"); @@ -281,7 +288,7 @@ class Builds extends Action Console::execute('rm -rf ' . \escapeshellarg($tmpTemplateDirectory), '', $stdout, $stderr); - $directorySize = $deviceForFunctions->getFileSize($source); + $directorySize = $device->getFileSize($source); $build = $dbForProject->updateDocument('builds', $build->getId(), $build->setAttribute('source', $source)); $deployment = $dbForProject->updateDocument('deployments', $deployment->getId(), $deployment->setAttribute('path', $source)->setAttribute('size', $directorySize)); } @@ -429,8 +436,8 @@ class Builds extends Action $tarParamDirectory = '/tmp/builds/' . $buildId . '/code' . (empty($rootDirectory) ? '' : '/' . $rootDirectory); Console::execute('tar --exclude code.tar.gz -czf ' . \escapeshellarg($tmpPathFile) . ' -C ' . \escapeshellcmd($tarParamDirectory) . ' .', '', $stdout, $stderr); // TODO: Replace escapeshellcmd with escapeshellarg if we find a way that doesnt break syntax - $source = $deviceForFunctions->getPath($deployment->getId() . '.' . \pathinfo('code.tar.gz', PATHINFO_EXTENSION)); - $result = $localDevice->transfer($tmpPathFile, $source, $deviceForFunctions); + $source = $device->getPath($deployment->getId() . '.' . \pathinfo('code.tar.gz', PATHINFO_EXTENSION)); + $result = $localDevice->transfer($tmpPathFile, $source, $device); if (!$result) { throw new \Exception("Unable to move file"); @@ -440,7 +447,7 @@ class Builds extends Action $build = $dbForProject->updateDocument('builds', $build->getId(), $build->setAttribute('source', $source)); - $directorySize = $deviceForFunctions->getFileSize($source); + $directorySize = $device->getFileSize($source); $deployment = $dbForProject->updateDocument('deployments', $deployment->getId(), $deployment->setAttribute('path', $source)->setAttribute('size', $directorySize)); $this->runGitAction('processing', $github, $providerCommitHash, $owner, $repositoryName, $project, $resource, $deployment->getId(), $dbForProject, $dbForPlatform); @@ -749,8 +756,8 @@ class Builds extends Action try { $rule = Authorization::skip(fn () => $dbForPlatform->findOne('rules', [ Query::equal("projectInternalId", [$project->getInternalId()]), - Query::equal("resourceType", ["deployment"]), - Query::equal("resourceInternalId", [$deployment->getInternalId()]) + Query::equal("type", ["deployment"]), + Query::equal("value", [$deployment->getId()]) ])); if ($rule->isEmpty()) { @@ -760,7 +767,7 @@ class Builds extends Action $client = new FetchClient(); $client->addHeader('content-type', FetchClient::CONTENT_TYPE_APPLICATION_JSON); - $bucket = $dbForPlatform->getDocument('buckets', 'screenshots'); + $bucket = Authorization::skip(fn () => $dbForPlatform->getDocument('buckets', 'screenshots')); $configs = [ 'screenshotLight' => [ @@ -856,14 +863,51 @@ class Builds extends Action case 'functions': $resource->setAttribute('deployment', $deployment->getId()); $resource = $dbForProject->updateDocument('functions', $resource->getId(), $resource); + + $this->listRules($project, [ + Query::equal("automation", ["function=" . $resource->getId()]), + ], $dbForPlatform, function (Document $rule) use ($dbForPlatform, $deployment) { + $rule = $rule->setAttribute('value', $deployment->getId()); + $dbForPlatform->updateDocument('rules', $rule->getId(), $rule); + }); break; case 'sites': $resource->setAttribute('deploymentId', $deployment->getId()); $resource = $dbForProject->updateDocument('sites', $resource->getId(), $resource); + + $this->listRules($project, [ + Query::equal("automation", ["site=" . $resource->getId()]), + ], $dbForPlatform, function (Document $rule) use ($dbForPlatform, $deployment) { + $rule = $rule->setAttribute('value', $deployment->getId()); + $dbForPlatform->updateDocument('rules', $rule->getId(), $rule); + }); + + // VCS branch + $branchName = $deployment->getAttribute('providerBranch'); + if (!empty($branchName)) { + $this->listRules($project, [ + Query::equal("automation", ["branch=" . $branchName]), + ], $dbForPlatform, function (Document $rule) use ($dbForPlatform, $deployment) { + $rule = $rule->setAttribute('value', $deployment->getId()); + $dbForPlatform->updateDocument('rules', $rule->getId(), $rule); + }); + } + + // VCS commit + $commitHash = $deployment->getAttribute('providerCommitHash', ''); + if (!empty($commitHash)) { + $this->listRules($project, [ + Query::equal("automation", ["commit=" . $commitHash]), + ], $dbForPlatform, function (Document $rule) use ($dbForPlatform, $deployment) { + $rule = $rule->setAttribute('value', $deployment->getId()); + $dbForPlatform->updateDocument('rules', $rule->getId(), $rule); + }); + } break; } } + if ($dbForProject->getDocument('builds', $buildId)->getAttribute('status') === 'canceled') { Console::info('Build has been canceled'); return; @@ -1130,8 +1174,8 @@ class Builds extends Action $rule = Authorization::skip(fn () => $dbForPlatform->findOne('rules', [ Query::equal("projectInternalId", [$project->getInternalId()]), - Query::equal("resourceType", ["deployment"]), - Query::equal("resourceInternalId", [$deployment->getInternalId()]) + Query::equal("type", ["deployment"]), + Query::equal("value", [$deployment->getId()]) ])); $protocol = System::getEnv('_APP_OPTIONS_FORCE_HTTPS') == 'disabled' ? 'http' : 'https'; @@ -1156,4 +1200,38 @@ class Builds extends Action } } } + + protected function listRules(Document $project, array $queries, Database $database, callable $callback = null): void + { + $limit = 100; + $cursor = null; + + do { + $queries = \array_merge([ + Query::limit($limit), + Query::equal("projectInternalId", [$project->getInternalId()]) + ], $queries); + + if ($cursor !== null) { + $queries[] = Query::cursorAfter($cursor); + } + + $results = $database->find('rules', $queries); + + $total = \count($results); + if ($total > 0) { + $cursor = $results[$total - 1]; + } + + if ($total < $limit) { + $cursor = null; + } + + foreach ($results as $document) { + if (is_callable($callback)) { + $callback($document); + } + } + } while (!\is_null($cursor)); + } } diff --git a/src/Appwrite/Platform/Modules/Proxy/Http/Rules/API/Create.php b/src/Appwrite/Platform/Modules/Proxy/Http/Rules/API/Create.php new file mode 100644 index 0000000000..36e2bcdf12 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Proxy/Http/Rules/API/Create.php @@ -0,0 +1,152 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_POST) + ->setHttpPath('/v1/proxy/rules/api') + ->groups(['api', 'proxy']) + ->desc('Create API rule') + ->label('scope', 'rules.write') + ->label('event', 'rules.[ruleId].create') + ->label('audits.event', 'rule.create') + ->label('audits.resource', 'rule/{response.$id}') + ->label('sdk', new Method( + namespace: 'proxy', + name: 'createAPIRule', + description: <<label('abuse-limit', 10) + ->label('abuse-key', 'userId:{userId}, url:{url}') + ->label('abuse-time', 60) + ->param('domain', null, new ValidatorDomain(), 'Domain name.') + ->inject('response') + ->inject('project') + ->inject('queueForCertificates') + ->inject('queueForEvents') + ->inject('dbForPlatform') + ->callback([$this, 'action']); + } + + public function action(string $domain, Response $response, Document $project, Certificate $queueForCertificates, Event $queueForEvents, Database $dbForPlatform) + { + $mainDomain = System::getEnv('_APP_DOMAIN', ''); + $sitesDomain = System::getEnv('_APP_DOMAIN_SITES', ''); + $functionsDomain = System::getEnv('_APP_DOMAIN_FUNCTIONS', ''); + + $deniedDomains = [ + $mainDomain, + $sitesDomain, + $functionsDomain, + 'localhost', + APP_HOSTNAME_INTERNAL, + ]; + + if (\in_array($domain, $deniedDomains)) { + throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'This domain name is not allowed. Please pick another one.'); + } + + if (\str_starts_with($domain, 'commit-') || \str_starts_with($domain, 'branch-')) { + throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'This domain name is not allowed. Please pick another one.'); + } + + try { + $domain = new Domain($domain); + } catch (\Throwable) { + throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Domain may not start with http:// or https://.'); + } + + // Apex domain prevention due to CNAME limitations + if (empty(App::getEnv('_APP_DOMAINS_NAMESERVERS', ''))) { + if ($domain->get() === $domain->getRegisterable()) { + throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'The instance does not allow root-level (apex) domains.'); + } + } + + // TODO: @christyjacob remove once we migrate the rules in 1.7.x + $ruleId = System::getEnv('_APP_RULES_FORMAT') === 'md5' ? md5($domain->get()) : ID::unique(); + + $status = 'created'; + if (\str_ends_with($domain->get(), $functionsDomain) || \str_ends_with($domain->get(), $sitesDomain)) { + $status = 'verified'; + } + if ($status === 'created') { + $target = new Domain(System::getEnv('_APP_DOMAIN_TARGET', '')); + $validator = new CNAME($target->get()); + if ($validator->isValid($domain->get())) { + $status = 'verifying'; + } + } + + $rule = new Document([ + '$id' => $ruleId, + 'projectId' => $project->getId(), + 'projectInternalId' => $project->getInternalId(), + 'domain' => $domain->get(), + 'status' => $status, + 'type' => 'api', + 'value' => '', + 'certificateId' => '', + 'search' => implode(' ', [$ruleId, $domain->get()]), + ]); + + try { + $rule = $dbForPlatform->createDocument('rules', $rule); + } catch (Duplicate $e) { + throw new Exception(Exception::RULE_ALREADY_EXISTS); + } + + if ($rule->getAttribute('status', '') === 'verifying') { + $queueForCertificates + ->setDomain(new Document([ + 'domain' => $rule->getAttribute('domain') + ])) + ->trigger(); + } + + $queueForEvents->setParam('ruleId', $rule->getId()); + + $response + ->setStatusCode(Response::STATUS_CODE_CREATED) + ->dynamic($rule, Response::MODEL_PROXY_RULE); + } +} diff --git a/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Create.php b/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Function/Create.php similarity index 50% rename from src/Appwrite/Platform/Modules/Proxy/Http/Rules/Create.php rename to src/Appwrite/Platform/Modules/Proxy/Http/Rules/Function/Create.php index b989310a3d..bcc3b91e8f 100644 --- a/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Create.php +++ b/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Function/Create.php @@ -1,6 +1,6 @@ setHttpMethod(Action::HTTP_REQUEST_METHOD_POST) - ->setHttpPath('/v1/proxy/rules') + ->setHttpPath('/v1/proxy/rules/function') ->groups(['api', 'proxy']) - ->desc('Create rule') + ->desc('Create function rule') ->label('scope', 'rules.write') ->label('event', 'rules.[ruleId].create') ->label('audits.event', 'rule.create') ->label('audits.resource', 'rule/{response.$id}') ->label('sdk', new Method( namespace: 'proxy', - name: 'createRule', + name: 'createFunctionRule', description: <<label('abuse-key', 'userId:{userId}, url:{url}') ->label('abuse-time', 60) ->param('domain', null, new ValidatorDomain(), 'Domain name.') - ->param('resourceType', null, new WhiteList(['api', 'function', 'site']), 'Action definition for the rule. Possible values are "api", "function" and "site"') - ->param('resourceId', '', new UID(), 'ID of resource for the action type. If resourceType is "api", leave empty. If resourceType is "function", provide ID of the function.', true) + ->param('functionId', '', new UID(), 'ID of function to be executed.') + ->param('branch', '', new Text(255, 0), 'Name of VCS branch to deploy changes automatically', true) ->inject('response') ->inject('project') ->inject('queueForCertificates') @@ -70,7 +72,7 @@ class Create extends Action ->callback([$this, 'action']); } - public function action(string $domain, string $resourceType, string $resourceId, Response $response, Document $project, Certificate $queueForCertificates, Event $queueForEvents, Database $dbForPlatform, Database $dbForProject) + public function action(string $domain, string $functionId, string $branch, Response $response, Document $project, Certificate $queueForCertificates, Event $queueForEvents, Database $dbForPlatform, Database $dbForProject) { $mainDomain = System::getEnv('_APP_DOMAIN', ''); $sitesDomain = System::getEnv('_APP_DOMAIN_SITES', ''); @@ -84,91 +86,70 @@ class Create extends Action APP_HOSTNAME_INTERNAL, ]; - if (in_array($domain, $deniedDomains, true)) { + if (\in_array($domain, $deniedDomains)) { throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'This domain name is not allowed. Please pick another one.'); } - $resourceInternalId = ''; - - switch ($resourceType) { - case 'function': - case 'site': - if (empty($resourceId)) { - throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'resourceId cannot be empty for resourceType "' . $resourceType . '".'); - } - - $expectedDomain = ($resourceType === 'function') ? $functionsDomain : $sitesDomain; - if (!\str_ends_with($domain, $expectedDomain)) { - throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Domain must end with ' . $expectedDomain . ' for resourceType "' . $resourceType . '".'); - } - - $collection = ($resourceType === 'function') ? 'functions' : 'sites'; - $document = $dbForProject->getDocument($collection, $resourceId); - - if ($document->isEmpty()) { - throw new Exception(Exception::RULE_RESOURCE_NOT_FOUND); - } - - $resourceInternalId = $document->getInternalId(); - break; - case 'api': - if (\str_ends_with($domain, $functionsDomain) || \str_ends_with($domain, $sitesDomain)) { - throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Domain must not end with ' . $functionsDomain . ' or ' . $sitesDomain . ' for resourceType "api".'); - } - break; - } - try { $domain = new Domain($domain); } catch (\Throwable) { throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Domain may not start with http:// or https://.'); } + // Apex domain prevention due to CNAME limitations + if (empty(App::getEnv('_APP_DOMAINS_NAMESERVERS', ''))) { + if ($domain->get() === $domain->getRegisterable()) { + throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'The instance does not allow root-level (apex) domains.'); + } + } + + $function = $dbForProject->getDocument('functions', $functionId); + if ($function->isEmpty()) { + throw new Exception(Exception::RULE_RESOURCE_NOT_FOUND); + } + // TODO: @christyjacob remove once we migrate the rules in 1.7.x $ruleId = System::getEnv('_APP_RULES_FORMAT') === 'md5' ? md5($domain->get()) : ID::unique(); - try { - $rule = new Document([ - '$id' => $ruleId, - 'projectId' => $project->getId(), - 'projectInternalId' => $project->getInternalId(), - 'domain' => $domain->get(), - 'resourceType' => $resourceType, - 'resourceId' => $resourceId, - 'resourceInternalId' => $resourceInternalId, - 'certificateId' => '', - ]); - } catch (\Throwable $e) { - if ($e->getCode() === Exception::DOCUMENT_ALREADY_EXISTS) { - throw new Exception(Exception::RULE_ALREADY_EXISTS); - } - - throw new Exception(Exception::GENERAL_SERVER_ERROR, 'An unexpected error occurred: ' . $e->getMessage()); - } - $status = 'created'; - if (\str_ends_with($domain->get(), $functionsDomain) || \str_ends_with($domain->get(), $sitesDomain)) { $status = 'verified'; } - if ($status === 'created') { $target = new Domain(System::getEnv('_APP_DOMAIN_TARGET', '')); - $validator = new CNAME($target->get()); // Verify Domain with DNS records - + $validator = new CNAME($target->get()); if ($validator->isValid($domain->get())) { $status = 'verifying'; - - $queueForCertificates - ->setDomain(new Document([ - 'domain' => $rule->getAttribute('domain') - ])) - ->trigger(); } } - $rule->setAttribute('status', $status); - $rule = $dbForPlatform->createDocument('rules', $rule); + $rule = new Document([ + '$id' => $ruleId, + 'projectId' => $project->getId(), + 'projectInternalId' => $project->getInternalId(), + 'domain' => $domain->get(), + 'status' => $status, + 'type' => 'deployment', + 'value' => $function->getAttribute('deployment', ''), + 'certificateId' => '', + 'automation' => 'function=' . $function->getId(), + 'automation' => !empty($branch) ? ('branch=' . $branch) : ('function=' . $function->getId()), + 'search' => implode(' ', [$ruleId, $domain->get(), $branch]), + ]); + + try { + $rule = $dbForPlatform->createDocument('rules', $rule); + } catch (Duplicate $e) { + throw new Exception(Exception::RULE_ALREADY_EXISTS); + } + + if ($rule->getAttribute('status', '') === 'verifying') { + $queueForCertificates + ->setDomain(new Document([ + 'domain' => $rule->getAttribute('domain') + ])) + ->trigger(); + } $queueForEvents->setParam('ruleId', $rule->getId()); diff --git a/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Redirect/Create.php b/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Redirect/Create.php new file mode 100644 index 0000000000..ac23cca168 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Redirect/Create.php @@ -0,0 +1,155 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_POST) + ->setHttpPath('/v1/proxy/rules/redirect') + ->groups(['api', 'proxy']) + ->desc('Create Redirect rule') + ->label('scope', 'rules.write') + ->label('event', 'rules.[ruleId].create') + ->label('audits.event', 'rule.create') + ->label('audits.resource', 'rule/{response.$id}') + ->label('sdk', new Method( + namespace: 'proxy', + name: 'createRedirectRule', + description: <<label('abuse-limit', 10) + ->label('abuse-key', 'userId:{userId}, url:{url}') + ->label('abuse-time', 60) + ->param('domain', null, new ValidatorDomain(), 'Domain name.') + ->param('target', null, new ValidatorDomain(), 'Target domain (hostname) of redirection') + ->inject('response') + ->inject('project') + ->inject('queueForCertificates') + ->inject('queueForEvents') + ->inject('dbForPlatform') + ->callback([$this, 'action']); + } + + public function action(string $domain, string $target, Response $response, Document $project, Certificate $queueForCertificates, Event $queueForEvents, Database $dbForPlatform) + { + $mainDomain = System::getEnv('_APP_DOMAIN', ''); + $sitesDomain = System::getEnv('_APP_DOMAIN_SITES', ''); + $functionsDomain = System::getEnv('_APP_DOMAIN_FUNCTIONS', ''); + + $deniedDomains = [ + $mainDomain, + $sitesDomain, + $functionsDomain, + 'localhost', + APP_HOSTNAME_INTERNAL, + ]; + + if (\in_array($domain, $deniedDomains)) { + throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'This domain name is not allowed. Please pick another one.'); + } + + try { + $domain = new Domain($domain); + } catch (\Throwable) { + throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Domain may not start with http:// or https://.'); + } + + try { + $target = new Domain($target); + } catch (\Throwable) { + throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Target may not start with http:// or https://.'); + } + + // Apex domain prevention due to CNAME limitations + if (empty(App::getEnv('_APP_DOMAINS_NAMESERVERS', ''))) { + if ($domain->get() === $domain->getRegisterable()) { + throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'The instance does not allow root-level (apex) domains.'); + } + } + + // TODO: @christyjacob remove once we migrate the rules in 1.7.x + $ruleId = System::getEnv('_APP_RULES_FORMAT') === 'md5' ? md5($domain->get()) : ID::unique(); + + $status = 'created'; + if (\str_ends_with($domain->get(), $functionsDomain) || \str_ends_with($domain->get(), $sitesDomain)) { + $status = 'verified'; + } + if ($status === 'created') { + $dnsTarget = new Domain(System::getEnv('_APP_DOMAIN_TARGET', '')); + $validator = new CNAME($dnsTarget->get()); + if ($validator->isValid($domain->get())) { + $status = 'verifying'; + } + } + + $rule = new Document([ + '$id' => $ruleId, + 'projectId' => $project->getId(), + 'projectInternalId' => $project->getInternalId(), + 'domain' => $domain->get(), + 'status' => $status, + 'type' => 'redirect', + 'value' => $target->get(), + 'certificateId' => '', + 'search' => implode(' ', [$ruleId, $domain->get()]), + ]); + + try { + $rule = $dbForPlatform->createDocument('rules', $rule); + } catch (Duplicate $e) { + throw new Exception(Exception::RULE_ALREADY_EXISTS); + } + + if ($rule->getAttribute('status', '') === 'verifying') { + $queueForCertificates + ->setDomain(new Document([ + 'domain' => $rule->getAttribute('domain') + ])) + ->trigger(); + } + + $queueForEvents->setParam('ruleId', $rule->getId()); + + $response + ->setStatusCode(Response::STATUS_CODE_CREATED) + ->dynamic($rule, Response::MODEL_PROXY_RULE); + } +} diff --git a/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Site/Create.php b/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Site/Create.php new file mode 100644 index 0000000000..b6e88be00c --- /dev/null +++ b/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Site/Create.php @@ -0,0 +1,159 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_POST) + ->setHttpPath('/v1/proxy/rules/site') + ->groups(['api', 'proxy']) + ->desc('Create site rule') + ->label('scope', 'rules.write') + ->label('event', 'rules.[ruleId].create') + ->label('audits.event', 'rule.create') + ->label('audits.resource', 'rule/{response.$id}') + ->label('sdk', new Method( + namespace: 'proxy', + name: 'createSiteRule', + description: <<label('abuse-limit', 10) + ->label('abuse-key', 'userId:{userId}, url:{url}') + ->label('abuse-time', 60) + ->param('domain', null, new ValidatorDomain(), 'Domain name.') + ->param('siteId', '', new UID(), 'ID of site to be executed.') + ->param('branch', '', new Text(255, 0), 'Name of VCS branch to deploy changes automatically', true) + ->inject('response') + ->inject('project') + ->inject('queueForCertificates') + ->inject('queueForEvents') + ->inject('dbForPlatform') + ->inject('dbForProject') + ->callback([$this, 'action']); + } + + public function action(string $domain, string $siteId, string $branch, Response $response, Document $project, Certificate $queueForCertificates, Event $queueForEvents, Database $dbForPlatform, Database $dbForProject) + { + $mainDomain = System::getEnv('_APP_DOMAIN', ''); + $sitesDomain = System::getEnv('_APP_DOMAIN_SITES', ''); + $functionsDomain = System::getEnv('_APP_DOMAIN_FUNCTIONS', ''); + + $deniedDomains = [ + $mainDomain, + $sitesDomain, + $functionsDomain, + 'localhost', + APP_HOSTNAME_INTERNAL, + ]; + + if (\in_array($domain, $deniedDomains)) { + throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'This domain name is not allowed. Please pick another one.'); + } + + try { + $domain = new Domain($domain); + } catch (\Throwable) { + throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Domain may not start with http:// or https://.'); + } + + // Apex domain prevention due to CNAME limitations + if (empty(App::getEnv('_APP_DOMAINS_NAMESERVERS', ''))) { + if ($domain->get() === $domain->getRegisterable()) { + throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'The instance does not allow root-level (apex) domains.'); + } + } + + $site = $dbForProject->getDocument('sites', $siteId); + if ($site->isEmpty()) { + throw new Exception(Exception::RULE_RESOURCE_NOT_FOUND); + } + + // TODO: @christyjacob remove once we migrate the rules in 1.7.x + $ruleId = System::getEnv('_APP_RULES_FORMAT') === 'md5' ? md5($domain->get()) : ID::unique(); + + $status = 'created'; + if (\str_ends_with($domain->get(), $functionsDomain) || \str_ends_with($domain->get(), $sitesDomain)) { + $status = 'verified'; + } + if ($status === 'created') { + $target = new Domain(System::getEnv('_APP_DOMAIN_TARGET', '')); + $validator = new CNAME($target->get()); + if ($validator->isValid($domain->get())) { + $status = 'verifying'; + } + } + + $rule = new Document([ + '$id' => $ruleId, + 'projectId' => $project->getId(), + 'projectInternalId' => $project->getInternalId(), + 'domain' => $domain->get(), + 'status' => $status, + 'type' => 'deployment', + 'value' => $site->getAttribute('deploymentId', ''), + 'certificateId' => '', + 'automation' => !empty($branch) ? ('branch=' . $branch) : ('site=' . $site->getId()), + 'search' => implode(' ', [$ruleId, $domain->get(), $branch]), + ]); + + try { + $rule = $dbForPlatform->createDocument('rules', $rule); + } catch (Duplicate $e) { + throw new Exception(Exception::RULE_ALREADY_EXISTS); + } + + if ($rule->getAttribute('status', '') === 'verifying') { + $queueForCertificates + ->setDomain(new Document([ + 'domain' => $rule->getAttribute('domain') + ])) + ->trigger(); + } + + $queueForEvents->setParam('ruleId', $rule->getId()); + + $response + ->setStatusCode(Response::STATUS_CODE_CREATED) + ->dynamic($rule, Response::MODEL_PROXY_RULE); + } +} diff --git a/src/Appwrite/Platform/Modules/Proxy/Services/Http.php b/src/Appwrite/Platform/Modules/Proxy/Services/Http.php index bc564f3714..c5f11ad5be 100644 --- a/src/Appwrite/Platform/Modules/Proxy/Services/Http.php +++ b/src/Appwrite/Platform/Modules/Proxy/Services/Http.php @@ -2,7 +2,10 @@ namespace Appwrite\Platform\Modules\Proxy\Services; -use Appwrite\Platform\Modules\Proxy\Http\Rules\Create as CreateRule; +use Appwrite\Platform\Modules\Proxy\Http\Rules\API\Create as CreateAPIRule; +use Appwrite\Platform\Modules\Proxy\Http\Rules\Function\Create as CreateFunctionRule; +use Appwrite\Platform\Modules\Proxy\Http\Rules\Redirect\Create as CreateRedirectRule; +use Appwrite\Platform\Modules\Proxy\Http\Rules\Site\Create as CreateSiteRule; use Utopia\Platform\Service; class Http extends Service @@ -10,7 +13,11 @@ class Http extends Service public function __construct() { $this->type = Service::TYPE_HTTP; + // Rules - $this->addAction(CreateRule::getName(), new CreateRule()); + $this->addAction(CreateAPIRule::getName(), new CreateAPIRule()); + $this->addAction(CreateSiteRule::getName(), new CreateSiteRule()); + $this->addAction(CreateFunctionRule::getName(), new CreateFunctionRule()); + $this->addAction(CreateRedirectRule::getName(), new CreateRedirectRule()); } } diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Builds/Create.php b/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Builds/Create.php index c76a4c3ffe..2f4da2d289 100644 --- a/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Builds/Create.php +++ b/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Builds/Create.php @@ -62,11 +62,10 @@ class Create extends Action ->inject('queueForEvents') ->inject('queueForBuilds') ->inject('deviceForSites') - ->inject('deviceForFunctions') //TODO: remove it later ->callback([$this, 'action']); } - public function action(string $siteId, string $deploymentId, Response $response, Document $project, Database $dbForProject, Database $dbForPlatform, Event $queueForEvents, Build $queueForBuilds, Device $deviceForSites, Device $deviceForFunctions) + public function action(string $siteId, string $deploymentId, Response $response, Document $project, Database $dbForProject, Database $dbForPlatform, Event $queueForEvents, Build $queueForBuilds, Device $deviceForSites) { $site = $dbForProject->getDocument('sites', $siteId); @@ -80,14 +79,14 @@ class Create extends Action } $path = $deployment->getAttribute('path'); - if (empty($path) || !$deviceForFunctions->exists($path)) { + if (empty($path) || !$deviceForSites->exists($path)) { throw new Exception(Exception::DEPLOYMENT_NOT_FOUND); } $deploymentId = ID::unique(); - $destination = $deviceForFunctions->getPath($deploymentId . '.' . \pathinfo('code.tar.gz', PATHINFO_EXTENSION)); - $deviceForFunctions->transfer($path, $destination, $deviceForFunctions); + $destination = $deviceForSites->getPath($deploymentId . '.' . \pathinfo('code.tar.gz', PATHINFO_EXTENSION)); + $deviceForSites->transfer($path, $destination, $deviceForSites); $deployment->removeAttribute('$internalId'); $deployment = $dbForProject->createDocument('deployments', $deployment->setAttributes([ @@ -100,6 +99,8 @@ class Create extends Action 'installCommand' => $site->getAttribute('installCommand', ''), 'outputDirectory' => $site->getAttribute('outputDirectory', ''), 'search' => implode(' ', [$deploymentId]), + 'screenshotLight' => '', + 'screenshotDark' => '' ])); // Preview deployments for sites @@ -112,11 +113,11 @@ class Create extends Action 'projectId' => $project->getId(), 'projectInternalId' => $project->getInternalId(), 'domain' => $domain, - 'resourceType' => 'deployment', - 'resourceId' => $deploymentId, - 'resourceInternalId' => $deployment->getInternalId(), + 'type' => 'deployment', + 'value' => $deployment->getId(), 'status' => 'verified', 'certificateId' => '', + 'search' => implode(' ', [$ruleId, $domain]), ])) ); diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Create.php b/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Create.php index fb536a3123..62a548dfba 100644 --- a/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Create.php +++ b/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Create.php @@ -80,13 +80,12 @@ class Create extends Action ->inject('project') ->inject('queueForEvents') ->inject('deviceForSites') - ->inject('deviceForFunctions') // TODO: Remove this later once volume is added to executor ->inject('deviceForLocal') ->inject('queueForBuilds') ->callback([$this, 'action']); } - public function action(string $siteId, ?string $installCommand, ?string $buildCommand, ?string $outputDirectory, mixed $code, mixed $activate, Request $request, Response $response, Database $dbForProject, Database $dbForPlatform, Document $project, Event $queueForEvents, Device $deviceForSites, Device $deviceForFunctions, Device $deviceForLocal, Build $queueForBuilds) + public function action(string $siteId, ?string $installCommand, ?string $buildCommand, ?string $outputDirectory, mixed $code, mixed $activate, Request $request, Response $response, Database $dbForProject, Database $dbForPlatform, Document $project, Event $queueForEvents, Device $deviceForSites, Device $deviceForLocal, Build $queueForBuilds) { $activate = \strval($activate) === 'true' || \strval($activate) === '1'; @@ -168,7 +167,7 @@ class Create extends Action // Save to storage $fileSize ??= $deviceForLocal->getFileSize($fileTmpName); - $path = $deviceForFunctions->getPath($deploymentId . '.' . \pathinfo($fileName, PATHINFO_EXTENSION)); + $path = $deviceForSites->getPath($deploymentId . '.' . \pathinfo($fileName, PATHINFO_EXTENSION)); $deployment = $dbForProject->getDocument('deployments', $deploymentId); $metadata = ['content_type' => $deviceForLocal->getFileMimeType($fileTmpName)]; @@ -180,7 +179,7 @@ class Create extends Action } } - $chunksUploaded = $deviceForFunctions->upload($fileTmpName, $path, $chunk, $chunks, $metadata); + $chunksUploaded = $deviceForSites->upload($fileTmpName, $path, $chunk, $chunks, $metadata); if (empty($chunksUploaded)) { throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed moving file'); @@ -203,7 +202,7 @@ class Create extends Action } } - $fileSize = $deviceForFunctions->getFileSize($path); + $fileSize = $deviceForSites->getFileSize($path); if ($deployment->isEmpty()) { $deployment = $dbForProject->createDocument('deployments', new Document([ @@ -237,11 +236,11 @@ class Create extends Action 'projectId' => $project->getId(), 'projectInternalId' => $project->getInternalId(), 'domain' => $domain, - 'resourceType' => 'deployment', - 'resourceId' => $deploymentId, - 'resourceInternalId' => $deployment->getInternalId(), + 'type' => 'deployment', + 'value' => $deployment->getId(), 'status' => 'verified', 'certificateId' => '', + 'search' => implode(' ', [$ruleId, $domain]), ])) ); } else { @@ -288,11 +287,11 @@ class Create extends Action 'projectId' => $project->getId(), 'projectInternalId' => $project->getInternalId(), 'domain' => $domain, - 'resourceType' => 'deployment', - 'resourceId' => $deploymentId, - 'resourceInternalId' => $deployment->getInternalId(), + 'type' => 'deployment', + 'value' => $deployment->getId(), 'status' => 'verified', 'certificateId' => '', + 'search' => implode(' ', [$ruleId, $domain]), ])) ); } else { diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Delete.php b/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Delete.php index 133e9a1906..eebc53dff0 100644 --- a/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Delete.php +++ b/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Delete.php @@ -59,11 +59,10 @@ class Delete extends Action ->inject('queueForDeletes') ->inject('queueForEvents') ->inject('deviceForSites') - ->inject('deviceForFunctions') //TODO: remove it later ->callback([$this, 'action']); } - public function action(string $siteId, string $deploymentId, Response $response, Database $dbForProject, DeleteEvent $queueForDeletes, Event $queueForEvents, Device $deviceForSites, Device $deviceForFunctions) + public function action(string $siteId, string $deploymentId, Response $response, Database $dbForProject, DeleteEvent $queueForDeletes, Event $queueForEvents, Device $deviceForSites) { $site = $dbForProject->getDocument('sites', $siteId); if ($site->isEmpty()) { @@ -84,14 +83,14 @@ class Delete extends Action } if (!empty($deployment->getAttribute('path', ''))) { - if (!($deviceForFunctions->delete($deployment->getAttribute('path', '')))) { + if (!($deviceForSites->delete($deployment->getAttribute('path', '')))) { throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed to remove deployment from storage'); } } if ($site->getAttribute('deployment') === $deployment->getId()) { // Reset site deployment $site = $dbForProject->updateDocument('sites', $site->getId(), new Document(array_merge($site->getArrayCopy(), [ - 'deployment' => '', + 'deploymentId' => '', 'deploymentInternalId' => '', ]))); } diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Download/Get.php b/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Download/Get.php index 8e1235c5ee..2d38e619a6 100644 --- a/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Download/Get.php +++ b/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Download/Get.php @@ -55,11 +55,10 @@ class Get extends Action ->inject('request') ->inject('dbForProject') ->inject('deviceForSites') - ->inject('deviceForFunctions') //TODO: Remove this later ->callback([$this, 'action']); } - public function action(string $siteId, string $deploymentId, Response $response, Request $request, Database $dbForProject, Device $deviceForSites, Device $deviceForFunctions) + public function action(string $siteId, string $deploymentId, Response $response, Request $request, Database $dbForProject, Device $deviceForSites) { $site = $dbForProject->getDocument('sites', $siteId); if ($site->isEmpty()) { @@ -76,7 +75,7 @@ class Get extends Action } $path = $deployment->getAttribute('path', ''); - if (!$deviceForFunctions->exists($path)) { + if (!$deviceForSites->exists($path)) { throw new Exception(Exception::DEPLOYMENT_NOT_FOUND); } @@ -86,7 +85,7 @@ class Get extends Action ->addHeader('X-Peak', \memory_get_peak_usage()) ->addHeader('Content-Disposition', 'attachment; filename="' . $deploymentId . '.tar.gz"'); - $size = $deviceForFunctions->getFileSize($path); + $size = $deviceForSites->getFileSize($path); $rangeHeader = $request->getHeader('range'); if (!empty($rangeHeader)) { @@ -108,13 +107,13 @@ class Get extends Action ->addHeader('Content-Length', $end - $start + 1) ->setStatusCode(Response::STATUS_CODE_PARTIALCONTENT); - $response->send($deviceForFunctions->read($path, $start, ($end - $start + 1))); + $response->send($deviceForSites->read($path, $start, ($end - $start + 1))); } if ($size > APP_STORAGE_READ_BUFFER) { for ($i = 0; $i < ceil($size / MAX_OUTPUT_CHUNK_SIZE); $i++) { $response->chunk( - $deviceForFunctions->read( + $deviceForSites->read( $path, ($i * MAX_OUTPUT_CHUNK_SIZE), min(MAX_OUTPUT_CHUNK_SIZE, $size - ($i * MAX_OUTPUT_CHUNK_SIZE)) @@ -123,7 +122,7 @@ class Get extends Action ); } } else { - $response->send($deviceForFunctions->read($path)); + $response->send($deviceForSites->read($path)); } } } diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Get.php b/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Get.php index 3864fdcac6..4f83cb7ff3 100644 --- a/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Get.php +++ b/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Get.php @@ -8,9 +8,6 @@ use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; use Appwrite\Utopia\Response; use Utopia\Database\Database; -use Utopia\Database\Document; -use Utopia\Database\Query; -use Utopia\Database\Validator\Authorization; use Utopia\Database\Validator\UID; use Utopia\Platform\Action; use Utopia\Platform\Scope\HTTP; @@ -49,13 +46,11 @@ class Get extends Action ->param('siteId', '', new UID(), 'Site ID.') ->param('deploymentId', '', new UID(), 'Deployment ID.') ->inject('response') - ->inject('project') ->inject('dbForProject') - ->inject('dbForPlatform') ->callback([$this, 'action']); } - public function action(string $siteId, string $deploymentId, Response $response, Document $project, Database $dbForProject, Database $dbForPlatform) + public function action(string $siteId, string $deploymentId, Response $response, Database $dbForProject) { $site = $dbForProject->getDocument('sites', $siteId); @@ -80,16 +75,6 @@ class Get extends Action $deployment->setAttribute('buildSize', $build->getAttribute('size', 0)); $deployment->setAttribute('size', $deployment->getAttribute('size', 0)); - $rule = Authorization::skip(fn () => $dbForPlatform->findOne('rules', [ - Query::equal("projectInternalId", [$project->getInternalId()]), - Query::equal("resourceType", ["deployment"]), - Query::equal("resourceInternalId", [$deployment->getInternalId()]) - ])); - - if (!empty($rule)) { - $deployment->setAttribute('domain', $rule->getAttribute('domain', '')); - } - $response->dynamic($deployment, Response::MODEL_DEPLOYMENT); } } diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Template/Create.php b/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Template/Create.php index 08be94776d..992a20150f 100644 --- a/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Template/Create.php +++ b/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Template/Create.php @@ -148,11 +148,11 @@ class Create extends Base 'projectId' => $project->getId(), 'projectInternalId' => $project->getInternalId(), 'domain' => $domain, - 'resourceType' => 'deployment', - 'resourceId' => $deploymentId, - 'resourceInternalId' => $deployment->getInternalId(), + 'type' => 'deployment', + 'value' => $deployment->getId(), 'status' => 'verified', 'certificateId' => '', + 'search' => implode(' ', [$ruleId, $domain]), ])) ); diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Update.php b/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Update.php index 9e1c516956..846b552eb0 100644 --- a/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Update.php +++ b/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Update.php @@ -44,7 +44,7 @@ class Update extends Action responses: [ new SDKResponse( code: Response::STATUS_CODE_OK, - model: Response::MODEL_FUNCTION, + model: Response::MODEL_SITE, ) ] )) diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Deployments/XList.php b/src/Appwrite/Platform/Modules/Sites/Http/Deployments/XList.php index 8a79561e2b..64f7c7c91c 100644 --- a/src/Appwrite/Platform/Modules/Sites/Http/Deployments/XList.php +++ b/src/Appwrite/Platform/Modules/Sites/Http/Deployments/XList.php @@ -12,7 +12,6 @@ use Utopia\Database\Database; use Utopia\Database\Document; use Utopia\Database\Exception\Query as QueryException; use Utopia\Database\Query; -use Utopia\Database\Validator\Authorization; use Utopia\Database\Validator\Query\Cursor; use Utopia\Database\Validator\UID; use Utopia\Platform\Action; @@ -54,13 +53,11 @@ class XList extends Action ->param('queries', [], new Deployments(), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long. You may filter on the following attributes: ' . implode(', ', Deployments::ALLOWED_ATTRIBUTES), true) ->param('search', '', new Text(256), 'Search term to filter your list results. Max length: 256 chars.', true) ->inject('response') - ->inject('project') ->inject('dbForProject') - ->inject('dbForPlatform') ->callback([$this, 'action']); } - public function action(string $siteId, array $queries, string $search, Response $response, Document $project, Database $dbForProject, Database $dbForPlatform) + public function action(string $siteId, array $queries, string $search, Response $response, Database $dbForProject) { $site = $dbForProject->getDocument('sites', $siteId); @@ -119,16 +116,6 @@ class XList extends Action $result->setAttribute('buildTime', $build->getAttribute('duration', 0)); $result->setAttribute('buildSize', $build->getAttribute('size', 0)); $result->setAttribute('size', $result->getAttribute('size', 0)); - - $rule = Authorization::skip(fn () => $dbForPlatform->findOne('rules', [ - Query::equal("projectInternalId", [$project->getInternalId()]), - Query::equal("resourceType", ["deployment"]), - Query::equal("resourceInternalId", [$result->getInternalId()]) - ])); - - if (!empty($rule)) { - $result->setAttribute('domain', $rule->getAttribute('domain', '')); - } } $response->dynamic(new Document([ diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Sites/Create.php b/src/Appwrite/Platform/Modules/Sites/Http/Sites/Create.php index 6072489310..edf05a2d08 100644 --- a/src/Appwrite/Platform/Modules/Sites/Http/Sites/Create.php +++ b/src/Appwrite/Platform/Modules/Sites/Http/Sites/Create.php @@ -69,7 +69,7 @@ class Create extends Base ->param('buildCommand', '', new Text(8192, 0), 'Build Command.', true) ->param('outputDirectory', '', new Text(8192, 0), 'Output Directory for site.', true) ->param('buildRuntime', '', new WhiteList(array_keys(Config::getParam('runtimes')), true), 'Runtime to use during build step.') - ->param('adapter', '', new Text(8192, 0), 'Framework adapter. Allows: static, ssr', true) + ->param('adapter', '', new WhiteList(['static', 'ssr']), 'Framework adapter defining rendering strategy. Allowed values are: static, ssr', true) ->param('installationId', '', new Text(128, 0), 'Appwrite Installation ID for VCS (Version Control System) deployment.', true) ->param('fallbackFile', '', new Text(255, 0), 'Fallback file for single page application sites.', true) ->param('providerRepositoryId', '', new Text(128, 0), 'Repository ID of the repo linked to the site.', true) diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Sites/Update.php b/src/Appwrite/Platform/Modules/Sites/Http/Sites/Update.php index c9b72983c2..2a4a7bb5c0 100644 --- a/src/Appwrite/Platform/Modules/Sites/Http/Sites/Update.php +++ b/src/Appwrite/Platform/Modules/Sites/Http/Sites/Update.php @@ -73,7 +73,7 @@ class Update extends Base ->param('buildCommand', '', new Text(8192, 0), 'Build Command.', true) ->param('outputDirectory', '', new Text(8192, 0), 'Output Directory for site.', true) ->param('buildRuntime', '', new WhiteList(array_keys(Config::getParam('runtimes')), true), 'Runtime to use during build step.', true) - ->param('adapter', '', new Text(8192, 0), 'Framework adapter. Usuallly allows: static, ssr', true) + ->param('adapter', '', new WhiteList(['static', 'ssr']), 'Framework adapter defining rendering strategy. Allowed values are: static, ssr', true) ->param('fallbackFile', '', new Text(255, 0), 'Fallback file for single page application sites.', true) ->param('installationId', '', new Text(128, 0), 'Appwrite Installation ID for VCS (Version Control System) deployment.', true) ->param('providerRepositoryId', '', new Text(128, 0), 'Repository ID of the repo linked to the site.', true) diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Templates/Get.php b/src/Appwrite/Platform/Modules/Sites/Http/Templates/Get.php index 977bfe7fd1..9d2991bce9 100644 --- a/src/Appwrite/Platform/Modules/Sites/Http/Templates/Get.php +++ b/src/Appwrite/Platform/Modules/Sites/Http/Templates/Get.php @@ -29,7 +29,6 @@ class Get extends Base ->setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) ->setHttpPath('/v1/sites/templates/:templateId') ->desc('Get site template') - ->groups(['api']) ->label('scope', 'public') ->label('sdk', new Method( namespace: 'sites', diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Templates/XList.php b/src/Appwrite/Platform/Modules/Sites/Http/Templates/XList.php index 277cc2c615..3c27b75578 100644 --- a/src/Appwrite/Platform/Modules/Sites/Http/Templates/XList.php +++ b/src/Appwrite/Platform/Modules/Sites/Http/Templates/XList.php @@ -30,7 +30,6 @@ class XList extends Base ->setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) ->setHttpPath('/v1/sites/templates') ->desc('List templates') - ->groups(['api']) ->label('scope', 'public') ->label('sdk', new Method( namespace: 'sites', diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Variables/Delete.php b/src/Appwrite/Platform/Modules/Sites/Http/Variables/Delete.php index 461bcdfdd9..3689343831 100644 --- a/src/Appwrite/Platform/Modules/Sites/Http/Variables/Delete.php +++ b/src/Appwrite/Platform/Modules/Sites/Http/Variables/Delete.php @@ -31,6 +31,7 @@ class Delete extends Base ->desc('Delete variable') ->groups(['api', 'sites']) ->label('scope', 'sites.write') + ->label('resourceType', 'sites') ->label('audits.event', 'variable.delete') ->label('audits.resource', 'site/{request.siteId}') ->label('sdk', new Method( diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Variables/Get.php b/src/Appwrite/Platform/Modules/Sites/Http/Variables/Get.php index ca5ad09663..1e56cc5b15 100644 --- a/src/Appwrite/Platform/Modules/Sites/Http/Variables/Get.php +++ b/src/Appwrite/Platform/Modules/Sites/Http/Variables/Get.php @@ -30,6 +30,7 @@ class Get extends Base ->desc('Get variable') ->groups(['api', 'sites']) ->label('scope', 'sites.read') + ->label('resourceType', RESOURCE_TYPE_SITES) ->label( 'sdk', new Method( diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Variables/Update.php b/src/Appwrite/Platform/Modules/Sites/Http/Variables/Update.php index 4040a8ae71..d64af650e3 100644 --- a/src/Appwrite/Platform/Modules/Sites/Http/Variables/Update.php +++ b/src/Appwrite/Platform/Modules/Sites/Http/Variables/Update.php @@ -35,6 +35,7 @@ class Update extends Base ->label('scope', 'sites.write') ->label('audits.event', 'variable.update') ->label('audits.resource', 'site/{request.siteId}') + ->label('resourceType', RESOURCE_TYPE_SITES) ->label('sdk', new Method( namespace: 'sites', name: 'updateVariable', diff --git a/src/Appwrite/Platform/Workers/Deletes.php b/src/Appwrite/Platform/Workers/Deletes.php index cfc9a3ff79..09fe98dd7f 100644 --- a/src/Appwrite/Platform/Workers/Deletes.php +++ b/src/Appwrite/Platform/Workers/Deletes.php @@ -93,13 +93,13 @@ class Deletes extends Action $this->deleteProject($dbForPlatform, $getProjectDB, $deviceForFiles, $deviceForSites, $deviceForFunctions, $deviceForBuilds, $deviceForCache, $certificates, $document); break; case DELETE_TYPE_SITES: - $this->deleteSite($dbForPlatform, $getProjectDB, $deviceForSites, $deviceForFunctions, $deviceForBuilds, $deviceForFiles, $document, $certificates, $project); + $this->deleteSite($dbForPlatform, $getProjectDB, $deviceForSites, $deviceForBuilds, $deviceForFiles, $document, $certificates, $project); break; case DELETE_TYPE_FUNCTIONS: $this->deleteFunction($dbForPlatform, $getProjectDB, $deviceForFunctions, $deviceForBuilds, $certificates, $document, $project); break; case DELETE_TYPE_DEPLOYMENTS: - $this->deleteDeployment($dbForPlatform, $getProjectDB, $deviceForFunctions, $deviceForBuilds, $deviceForFiles, $document, $certificates, $project); + $this->deleteDeployment($dbForPlatform, $getProjectDB, $deviceForFunctions, $deviceForSites, $deviceForBuilds, $deviceForFiles, $document, $certificates, $project); break; case DELETE_TYPE_USERS: $this->deleteUser($getProjectDB, $document, $project); @@ -743,14 +743,13 @@ class Deletes extends Action /** * @param callable $getProjectDB * @param Device $deviceForSites - * @param Device $deviceForFunctions * @param Device $deviceForBuilds * @param Document $document function document * @param Document $project * @return void * @throws Exception */ - private function deleteSite(Database $dbForPlatform, callable $getProjectDB, Device $deviceForSites, Device $deviceForFunctions, Device $deviceForBuilds, Device $deviceForFiles, Document $document, CertificatesAdapter $certificates, Document $project): void + private function deleteSite(Database $dbForPlatform, callable $getProjectDB, Device $deviceForSites, Device $deviceForBuilds, Device $deviceForFiles, Document $document, CertificatesAdapter $certificates, Document $project): void { $dbForProject = $getProjectDB($project); $siteId = $document->getId(); @@ -761,8 +760,8 @@ class Deletes extends Action */ Console::info("Deleting rules for site " . $siteId); $this->deleteByGroup('rules', [ - Query::equal('resourceType', ['site']), - Query::equal('resourceInternalId', [$siteInternalId]), + Query::equal('type', ['deployment']), + Query::equal('automation', ['site=' . $siteId]), Query::equal('projectInternalId', [$project->getInternalId()]) ], $dbForPlatform, function (Document $document) use ($dbForPlatform, $certificates) { $this->deleteRule($dbForPlatform, $document, $certificates); @@ -782,23 +781,25 @@ class Deletes extends Action */ Console::info("Deleting deployments for site " . $siteId); $deploymentInternalIds = []; + $deploymentIds = []; $this->deleteByGroup('deployments', [ Query::equal('resourceInternalId', [$siteInternalId]) - ], $dbForProject, function (Document $document) use ($deviceForFunctions, $deviceForFiles, $dbForPlatform, &$deploymentInternalIds) { + ], $dbForProject, function (Document $document) use ($project, $certificates, $deviceForSites, $deviceForFiles, $dbForPlatform, &$deploymentInternalIds) { $deploymentInternalIds[] = $document->getInternalId(); - $this->deleteDeploymentFiles($deviceForFunctions, $document); + $deploymentIds[] = $document->getId(); + $this->deleteDeploymentFiles($deviceForSites, $document); $this->deleteDeploymentScreenshots($deviceForFiles, $dbForPlatform, $document); + $this->deleteDeploymentRules($dbForPlatform, $document, $project, $certificates); }); /** * Delete rules for all deployments of the site */ - //TODO: If functions also have previews in the future, change the logic here to use unique identifier for sites and functions - foreach ($deploymentInternalIds as $deploymentInternalId) { - Console::info("Deleting rules for site " . $siteId . "'s deployment " . $deploymentInternalId); + foreach ($deploymentIds as $deploymentId) { + Console::info("Deleting rules for site " . $siteId . "'s deployment " . $deploymentId); $this->deleteByGroup('rules', [ - Query::equal('resourceType', ['deployment']), - Query::equal('resourceInternalId', [$deploymentInternalId]), + Query::equal('type', ['deployment']), + Query::equal('value', [$deploymentId]), Query::equal('projectInternalId', [$project->getInternalId()]) ], $dbForPlatform, function (Document $document) use ($dbForPlatform, $certificates) { $this->deleteRule($dbForPlatform, $document, $certificates); @@ -856,8 +857,8 @@ class Deletes extends Action */ Console::info("Deleting rules for function " . $functionId); $this->deleteByGroup('rules', [ - Query::equal('resourceType', ['function']), - Query::equal('resourceInternalId', [$functionInternalId]), + Query::equal('type', ['deployment']), + Query::equal('automation', ['function=' . $functionId]), Query::equal('projectInternalId', [$project->getInternalId()]) ], $dbForPlatform, function (Document $document) use ($project, $dbForPlatform, $certificates) { $this->deleteRule($dbForPlatform, $document, $certificates); @@ -880,9 +881,10 @@ class Deletes extends Action $deploymentInternalIds = []; $this->deleteByGroup('deployments', [ Query::equal('resourceInternalId', [$functionInternalId]) - ], $dbForProject, function (Document $document) use ($deviceForFunctions, &$deploymentInternalIds) { + ], $dbForProject, function (Document $document) use ($dbForPlatform, $project, $certificates, $deviceForFunctions, &$deploymentInternalIds) { $deploymentInternalIds[] = $document->getInternalId(); $this->deleteDeploymentFiles($deviceForFunctions, $document); + $this->deleteDeploymentRules($dbForPlatform, $document, $project, $certificates); }); /** @@ -930,6 +932,18 @@ class Deletes extends Action $this->deleteRuntimes($getProjectDB, $document, $project); } + private function deleteDeploymentRules(Database $dbForPlatform, Document $deployment, Document $project, CertificatesAdapter $certificates): void + { + Console::info("Deleting rules for site " . $deployment->getId()); + $this->deleteByGroup('rules', [ + Query::equal('type', ['deployment']), + Query::equal('value', [$deployment->getId()]), + Query::equal('projectInternalId', [$project->getInternalId()]) + ], $dbForPlatform, function (Document $document) use ($dbForPlatform, $certificates) { + $this->deleteRule($dbForPlatform, $document, $certificates); + }); + } + private function deleteDeploymentScreenshots(Device $deviceForFiles, Database $dbForPlatform, Document $deployment): void { $screenshotIds = []; @@ -1042,13 +1056,14 @@ class Deletes extends Action /** * @param callable $getProjectDB * @param Device $deviceForFunctions + * @param Device $deviceForSites * @param Device $deviceForBuilds * @param Document $document * @param Document $project * @return void * @throws Exception */ - private function deleteDeployment(Database $dbForPlatform, callable $getProjectDB, Device $deviceForFunctions, Device $deviceForBuilds, Device $deviceForFiles, Document $document, CertificatesAdapter $certificates, Document $project): void + private function deleteDeployment(Database $dbForPlatform, callable $getProjectDB, Device $deviceForFunctions, Device $deviceForSites, Device $deviceForBuilds, Device $deviceForFiles, Document $document, CertificatesAdapter $certificates, Document $project): void { $projectId = $project->getId(); $dbForProject = $getProjectDB($project); @@ -1058,7 +1073,11 @@ class Deletes extends Action /** * Delete deployment files */ - $this->deleteDeploymentFiles($deviceForFunctions, $document); //TODO: For sites, this should be deviceForSites + match ($document->getAttribute('resourceType')) { + 'functions' => $this->deleteDeploymentFiles($deviceForFunctions, $document), + 'sites' => $this->deleteDeploymentFiles($deviceForSites, $document), + default => throw new Exception('Invalid resource type') + }; /** * Delete deployment screenshots @@ -1081,8 +1100,8 @@ class Deletes extends Action */ Console::info("Deleting rules for deployment " . $deploymentId); $this->deleteByGroup('rules', [ - Query::equal('resourceType', ['deployment']), - Query::equal('resourceInternalId', [$deploymentInternalId]), + Query::equal('type', ['deployment']), + Query::equal('value', [$deploymentId]), Query::equal('projectInternalId', [$project->getInternalId()]) ], $dbForPlatform, function (Document $document) use ($dbForPlatform, $certificates) { $this->deleteRule($dbForPlatform, $document, $certificates); diff --git a/src/Appwrite/Specification/Format.php b/src/Appwrite/Specification/Format.php index 038f5369f5..f4bec326e7 100644 --- a/src/Appwrite/Specification/Format.php +++ b/src/Appwrite/Specification/Format.php @@ -113,6 +113,18 @@ abstract class Format protected function getEnumName(string $service, string $method, string $param): ?string { switch ($service) { + case 'console': + switch ($method) { + case 'getResource': + switch ($param) { + case 'type': + return 'ConsoleResourceType'; + case 'value': + return 'ConsoleResourceValue'; + } + break; + } + break; case 'account': switch ($method) { case 'createOAuth2Session': diff --git a/src/Appwrite/Utopia/Database/Validator/Queries/Rules.php b/src/Appwrite/Utopia/Database/Validator/Queries/Rules.php index 24cb4475f2..61701f0b2c 100644 --- a/src/Appwrite/Utopia/Database/Validator/Queries/Rules.php +++ b/src/Appwrite/Utopia/Database/Validator/Queries/Rules.php @@ -6,8 +6,9 @@ class Rules extends Base { public const ALLOWED_ATTRIBUTES = [ 'domain', - 'resourceType', - 'resourceId', + 'type', + 'value', + 'automation', 'url' ]; diff --git a/src/Appwrite/Utopia/Response/Model/ConsoleVariables.php b/src/Appwrite/Utopia/Response/Model/ConsoleVariables.php index 59d22296d1..2c1688969d 100644 --- a/src/Appwrite/Utopia/Response/Model/ConsoleVariables.php +++ b/src/Appwrite/Utopia/Response/Model/ConsoleVariables.php @@ -66,6 +66,15 @@ class ConsoleVariables extends Model 'default' => '', 'example' => 'enabled', ] + ) + ->addRule( + '_APP_DOMAINS_NAMESERVERS', + [ + 'type' => self::TYPE_STRING, + 'description' => 'Comma-separated list of nameservers.', + 'default' => '', + 'example' => 'ns1.example.com,ns2.example.com', + ] ); } diff --git a/src/Appwrite/Utopia/Response/Model/Deployment.php b/src/Appwrite/Utopia/Response/Model/Deployment.php index 6c13fd4b9e..f42ce6d334 100644 --- a/src/Appwrite/Utopia/Response/Model/Deployment.php +++ b/src/Appwrite/Utopia/Response/Model/Deployment.php @@ -106,12 +106,6 @@ class Deployment extends Model 'default' => 0, 'example' => 128, ]) - ->addRule('domain', [ - 'type' => self::TYPE_STRING, - 'description' => 'Preview domain.', - 'default' => '', - 'example' => 'deploy1-project1.appwrite.site', - ]) ->addRule('providerRepositoryName', [ 'type' => self::TYPE_STRING, 'description' => 'The name of the vcs provider repository', diff --git a/src/Appwrite/Utopia/Response/Model/Rule.php b/src/Appwrite/Utopia/Response/Model/Rule.php index 932591b90f..c365f241f8 100644 --- a/src/Appwrite/Utopia/Response/Model/Rule.php +++ b/src/Appwrite/Utopia/Response/Model/Rule.php @@ -34,17 +34,24 @@ class Rule extends Model 'default' => '', 'example' => 'appwrite.company.com', ]) - ->addRule('resourceType', [ + ->addRule('type', [ 'type' => self::TYPE_STRING, - 'description' => 'Action definition for the rule. Possible values are "api", "function", or "redirect"', + 'description' => 'Action definition for the rule. Possible values are "api", "deployment", or "redirect"', 'default' => '', - 'example' => 'function', + 'example' => 'deployment', ]) - ->addRule('resourceId', [ + ->addRule('value', [ 'type' => self::TYPE_STRING, - 'description' => 'ID of resource for the action type. If resourceType is "api" or "url", it is empty. If resourceType is "function", it is ID of the function.', + 'description' => 'Detail specification for the type. If type is "api", this is empty. If type is "redirect", this is URL. If type is "deployment", this is deployment ID.', 'default' => '', - 'example' => 'myAwesomeFunction', + 'example' => '67a9cf1a00150ee93abd', + ]) + ->addRule('automation', [ + 'type' => self::TYPE_STRING, + 'description' => 'Action that results in a rule update. If VCS branch, value can be of syntax "branch=[name]"', + 'array' => false, + 'default' => '', + 'example' => 'branch=dev', ]) ->addRule('status', [ 'type' => self::TYPE_STRING, diff --git a/tests/e2e/General/UsageTest.php b/tests/e2e/General/UsageTest.php index 504f0a696d..00dc790869 100644 --- a/tests/e2e/General/UsageTest.php +++ b/tests/e2e/General/UsageTest.php @@ -1101,15 +1101,14 @@ class UsageTest extends Scope $rule = $this->client->call( Client::METHOD_POST, - '/proxy/rules', + '/proxy/rules/function', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders()), [ 'domain' => 'test-' . ID::unique() . System::getEnv('_APP_DOMAIN_FUNCTIONS'), - 'resourceType' => 'function', - 'resourceId' => $functionId, + 'functionId' => $functionId, ], ); diff --git a/tests/e2e/Services/Console/ConsoleConsoleClientTest.php b/tests/e2e/Services/Console/ConsoleConsoleClientTest.php index fb65adc299..f70eafc8e0 100644 --- a/tests/e2e/Services/Console/ConsoleConsoleClientTest.php +++ b/tests/e2e/Services/Console/ConsoleConsoleClientTest.php @@ -24,7 +24,7 @@ class ConsoleConsoleClientTest extends Scope ], $this->getHeaders())); $this->assertEquals(200, $response['headers']['status-code']); - $this->assertCount(9, $response['body']); + $this->assertCount(10, $response['body']); $this->assertIsString($response['body']['_APP_DOMAIN_TARGET']); $this->assertIsInt($response['body']['_APP_STORAGE_LIMIT']); $this->assertIsInt($response['body']['_APP_COMPUTE_SIZE_LIMIT']); @@ -34,5 +34,7 @@ class ConsoleConsoleClientTest extends Scope $this->assertIsBool($response['body']['_APP_ASSISTANT_ENABLED']); $this->assertIsString($response['body']['_APP_DOMAIN_SITES']); $this->assertIsString($response['body']['_APP_OPTIONS_FORCE_HTTPS']); + $this->assertIsString($response['body']['_APP_DOMAINS_NAMESERVERS']); + // When adding new keys, dont forget to update count a few lines above } } diff --git a/tests/e2e/Services/Functions/FunctionsBase.php b/tests/e2e/Services/Functions/FunctionsBase.php index b7624c24a1..167094aec7 100644 --- a/tests/e2e/Services/Functions/FunctionsBase.php +++ b/tests/e2e/Services/Functions/FunctionsBase.php @@ -274,13 +274,12 @@ trait FunctionsBase protected function setupFunctionDomain(string $functionId, string $subdomain = ''): string { $subdomain = $subdomain ? $subdomain : ID::unique(); - $rule = $this->client->call(Client::METHOD_POST, '/proxy/rules', array_merge([ + $rule = $this->client->call(Client::METHOD_POST, '/proxy/rules/function', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders()), [ 'domain' => $subdomain . '.' . System::getEnv('_APP_DOMAIN_FUNCTIONS', ''), - 'resourceType' => 'function', - 'resourceId' => $functionId, + 'functionId' => $functionId, ]); $this->assertEquals(201, $rule['headers']['status-code']); @@ -299,8 +298,8 @@ trait FunctionsBase 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders()), [ 'queries' => [ - Query::equal('resourceId', [$functionId])->toString(), - Query::equal('resourceType', ['function'])->toString(), + Query::equal('automation', ['function=' . $functionId])->toString(), + Query::equal('type', ['deployment'])->toString(), ], ]); diff --git a/tests/e2e/Services/GraphQL/FunctionsServerTest.php b/tests/e2e/Services/GraphQL/FunctionsServerTest.php index e49ac43619..abd86b30ec 100644 --- a/tests/e2e/Services/GraphQL/FunctionsServerTest.php +++ b/tests/e2e/Services/GraphQL/FunctionsServerTest.php @@ -130,7 +130,7 @@ class FunctionsServerTest extends Scope $deployment = $deployment['body']['data']['functionsGetDeployment']; $this->assertEquals('ready', $deployment['status']); - }); + }, 30000); return $deployment; } diff --git a/tests/e2e/Services/Projects/ProjectsCustomServerTest.php b/tests/e2e/Services/Projects/ProjectsCustomServerTest.php index 60ae7e0bbb..6d9431290f 100644 --- a/tests/e2e/Services/Projects/ProjectsCustomServerTest.php +++ b/tests/e2e/Services/Projects/ProjectsCustomServerTest.php @@ -24,14 +24,13 @@ class ProjectsCustomServerTest extends Scope 'cookie' => 'a_session_console=' . $this->getRoot()['session'], ]); - $response = $this->client->call(Client::METHOD_POST, '/proxy/rules', $headers, [ - 'resourceType' => 'api', + $response = $this->client->call(Client::METHOD_POST, '/proxy/rules/api', $headers, [ 'domain' => 'api.appwrite.test', ]); $this->assertEquals(201, $response['headers']['status-code']); - $response = $this->client->call(Client::METHOD_POST, '/proxy/rules', $headers, [ + $response = $this->client->call(Client::METHOD_POST, '/proxy/rules/api', $headers, [ 'resourceType' => 'api', 'domain' => 'abc.test.io', ]); @@ -39,8 +38,7 @@ class ProjectsCustomServerTest extends Scope $this->assertEquals(201, $response['headers']['status-code']); // duplicate rule - $response2 = $this->client->call(Client::METHOD_POST, '/proxy/rules', $headers, [ - 'resourceType' => 'api', + $response2 = $this->client->call(Client::METHOD_POST, '/proxy/rules/api', $headers, [ 'domain' => 'abc.test.io', ]); @@ -52,8 +50,7 @@ class ProjectsCustomServerTest extends Scope $functionsDomain = System::getEnv('_APP_DOMAIN_FUNCTIONS', ''); - $response = $this->client->call(Client::METHOD_POST, '/proxy/rules', $headers, [ - 'resourceType' => 'api', + $response = $this->client->call(Client::METHOD_POST, '/proxy/rules/api', $headers, [ 'domain' => $functionsDomain, ]); @@ -62,24 +59,21 @@ class ProjectsCustomServerTest extends Scope $sitesDomain = System::getEnv('_APP_DOMAIN_SITES', ''); - $response = $this->client->call(Client::METHOD_POST, '/proxy/rules', $headers, [ - 'resourceType' => 'api', + $response = $this->client->call(Client::METHOD_POST, '/proxy/rules/api', $headers, [ 'domain' => $sitesDomain, ]); $this->assertEquals(400, $response['headers']['status-code']); // prevent functions domain - $response = $this->client->call(Client::METHOD_POST, '/proxy/rules', $headers, [ - 'resourceType' => 'function', + $response = $this->client->call(Client::METHOD_POST, '/proxy/rules/function', $headers, [ 'domain' => $functionsDomain, ]); $this->assertEquals(400, $response['headers']['status-code']); // prevent sites domain - $response = $this->client->call(Client::METHOD_POST, '/proxy/rules', $headers, [ - 'resourceType' => 'site', + $response = $this->client->call(Client::METHOD_POST, '/proxy/rules/site', $headers, [ 'domain' => $sitesDomain, ]); @@ -98,8 +92,7 @@ class ProjectsCustomServerTest extends Scope ]; foreach ($deniedDomains as $deniedDomain) { - $response = $this->client->call(Client::METHOD_POST, '/proxy/rules', $headers, [ - 'resourceType' => 'api', + $response = $this->client->call(Client::METHOD_POST, '/proxy/rules/api', $headers, [ 'domain' => $deniedDomain, ]); diff --git a/tests/e2e/Services/Proxy/ProxyBase.php b/tests/e2e/Services/Proxy/ProxyBase.php new file mode 100644 index 0000000000..ed289ea8a5 --- /dev/null +++ b/tests/e2e/Services/Proxy/ProxyBase.php @@ -0,0 +1,296 @@ +client->call(Client::METHOD_GET, '/proxy/rules', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), $params); + + return $rule; + } + + protected function createAPIRule(string $domain): mixed + { + $rule = $this->client->call(Client::METHOD_POST, '/proxy/rules/api', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'domain' => $domain, + ]); + + return $rule; + } + + protected function updateRuleVerification(string $ruleId): mixed + { + $rule = $this->client->call(Client::METHOD_PATCH, '/proxy/rules/' . $ruleId . '/verification', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), []); + + return $rule; + } + + protected function createSiteRule(string $domain, string $siteId, string $branch = ''): mixed + { + $rule = $this->client->call(Client::METHOD_POST, '/proxy/rules/site', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'domain' => $domain, + 'siteId' => $siteId, + 'branch' => $branch, + ]); + + return $rule; + } + + protected function getRule(string $ruleId): mixed + { + $rule = $this->client->call(Client::METHOD_GET, '/proxy/rules/' . $ruleId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), []); + + return $rule; + } + + protected function createRedirectRule(string $domain, string $target): mixed + { + $rule = $this->client->call(Client::METHOD_POST, '/proxy/rules/redirect', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'domain' => $domain, + 'target' => $target, + ]); + + return $rule; + } + + protected function createFunctionRule(string $domain, string $functionId, string $branch = ''): mixed + { + $rule = $this->client->call(Client::METHOD_POST, '/proxy/rules/function', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'domain' => $domain, + 'functionId' => $functionId, + 'branch' => $branch, + ]); + + return $rule; + } + + protected function deleteRule(string $ruleId): mixed + { + $rule = $this->client->call(Client::METHOD_DELETE, '/proxy/rules/' . $ruleId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), []); + + return $rule; + } + + protected function setupAPIRule(string $domain): string + { + $rule = $this->createAPIRule($domain); + + $this->assertEquals(201, $rule['headers']['status-code'], 'Failed to setup rule: ' . \json_encode($rule)); + + return $rule['body']['$id']; + } + + protected function setupRedirectRule(string $domain, string $target): string + { + $rule = $this->createRedirectRule($domain, $target); + + $this->assertEquals(201, $rule['headers']['status-code'], 'Failed to setup rule: ' . \json_encode($rule)); + + return $rule['body']['$id']; + } + + protected function setupFunctionRule(string $domain, string $functionId, string $branch = ''): string + { + $rule = $this->createFunctionRule($domain, $functionId, $branch); + + $this->assertEquals(201, $rule['headers']['status-code'], 'Failed to setup rule: ' . \json_encode($rule)); + + return $rule['body']['$id']; + } + + protected function setupSiteRule(string $domain, string $siteId, string $branch = ''): string + { + $rule = $this->createSiteRule($domain, $siteId, $branch); + + $this->assertEquals(201, $rule['headers']['status-code'], 'Failed to setup rule: ' . \json_encode($rule)); + + return $rule['body']['$id']; + } + + protected function cleanupRule(string $ruleId): void + { + $rule = $this->deleteRule($ruleId); + $this->assertEquals(204, $rule['headers']['status-code'], 'Failed to cleanup rule: ' . \json_encode($rule)); + } + + protected function cleanupSite(string $siteId): void + { + $site = $this->client->call(Client::METHOD_DELETE, '/sites/' . $siteId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), []); + + $this->assertEquals(204, $site['headers']['status-code'], 'Failed to cleanup site: ' . \json_encode($site)); + } + + protected function cleanupFunction(string $functionId): void + { + $function = $this->client->call(Client::METHOD_DELETE, '/functions/' . $functionId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), []); + + $this->assertEquals(204, $function['headers']['status-code'], 'Failed to cleanup function: ' . \json_encode($function)); + } + + protected function setupSite(): mixed + { + // Site + $site = $this->client->call(Client::METHOD_POST, '/sites', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]), [ + 'siteId' => ID::unique(), + 'name' => 'Proxy site', + 'framework' => 'other', + 'adapter' => 'static', + 'buildRuntime' => 'static-1', + 'outputDirectory' => './', + 'buildCommand' => '', + 'installCommand' => '', + 'fallbackFile' => '', + ]); + + $this->assertEquals($site['headers']['status-code'], 201, 'Setup site failed with status code: ' . $site['headers']['status-code'] . ' and response: ' . json_encode($site['body'], JSON_PRETTY_PRINT)); + + $siteId = $site['body']['$id']; + + // Deployment + $deployment = $this->client->call(Client::METHOD_POST, '/sites/' . $siteId . '/deployments', array_merge([ + 'content-type' => 'multipart/form-data', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]), [ + 'code' => $this->packageSite('static'), + 'activate' => 'true' + ]); + + $this->assertEquals($deployment['headers']['status-code'], 202, 'Setup deployment failed with status code: ' . $deployment['headers']['status-code'] . ' and response: ' . json_encode($deployment['body'], JSON_PRETTY_PRINT)); + $deploymentId = $deployment['body']['$id'] ?? ''; + + $this->assertEventually(function () use ($siteId, $deploymentId) { + $site = $this->client->call(Client::METHOD_GET, '/sites/' . $siteId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ])); + $this->assertEquals($deploymentId, $site['body']['deploymentId'], 'Deployment is not activated, deployment: ' . json_encode($site['body'], JSON_PRETTY_PRINT)); + }, 100000, 500); + + return ['siteId' => $siteId, 'deploymentId' => $deploymentId]; + } + + protected function setupFunction(): mixed + { + // Function + $function = $this->client->call(Client::METHOD_POST, '/functions', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]), [ + 'functionId' => ID::unique(), + 'runtime' => 'node-18.0', + 'name' => 'Proxy Function', + 'entrypoint' => 'index.js', + 'commands' => '', + 'execute' => ['any'] + ]); + + $this->assertEquals($function['headers']['status-code'], 201, 'Setup function failed with status code: ' . $function['headers']['status-code'] . ' and response: ' . json_encode($function['body'], JSON_PRETTY_PRINT)); + + $functionId = $function['body']['$id']; + + // Deployment + $deployment = $this->client->call(Client::METHOD_POST, '/functions/' . $functionId . '/deployments', array_merge([ + 'content-type' => 'multipart/form-data', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]), [ + 'code' => $this->packageFunction('node'), + 'activate' => 'true' + ]); + + $this->assertEquals($deployment['headers']['status-code'], 202, 'Setup deployment failed with status code: ' . $deployment['headers']['status-code'] . ' and response: ' . json_encode($deployment['body'], JSON_PRETTY_PRINT)); + $deploymentId = $deployment['body']['$id'] ?? ''; + + $this->assertEventually(function () use ($functionId, $deploymentId) { + $function = $this->client->call(Client::METHOD_GET, '/functions/' . $functionId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ])); + $this->assertEquals($deploymentId, $function['body']['deployment'], 'Deployment is not activated, deployment: ' . json_encode($function['body'], JSON_PRETTY_PRINT)); + }, 100000, 500); + + return ['functionId' => $functionId, 'deploymentId' => $deploymentId]; + } + + private function packageSite(string $site): CURLFile + { + $stdout = ''; + $stderr = ''; + + $folderPath = realpath(__DIR__ . '/../../../resources/sites') . "/$site"; + $tarPath = "$folderPath/code.tar.gz"; + + Console::execute("cd $folderPath && tar --exclude code.tar.gz -czf code.tar.gz .", '', $stdout, $stderr); + + if (filesize($tarPath) > 1024 * 1024 * 5) { + throw new \Exception('Code package is too large. Use the chunked upload method instead.'); + } + + return new CURLFile($tarPath, 'application/x-gzip', \basename($tarPath)); + } + + private function packageFunction(string $function): CURLFile + { + $stdout = ''; + $stderr = ''; + + $folderPath = realpath(__DIR__ . '/../../../resources/functions') . "/$function"; + $tarPath = "$folderPath/code.tar.gz"; + + Console::execute("cd $folderPath && tar --exclude code.tar.gz -czf code.tar.gz .", '', $stdout, $stderr); + + if (filesize($tarPath) > 1024 * 1024 * 5) { + throw new \Exception('Code package is too large. Use the chunked upload method instead.'); + } + + return new CURLFile($tarPath, 'application/x-gzip', \basename($tarPath)); + } +} diff --git a/tests/e2e/Services/Proxy/ProxyCustomServerTest.php b/tests/e2e/Services/Proxy/ProxyCustomServerTest.php new file mode 100644 index 0000000000..78faffebd0 --- /dev/null +++ b/tests/e2e/Services/Proxy/ProxyCustomServerTest.php @@ -0,0 +1,457 @@ +createAPIRule($domain); + + $this->assertEquals(201, $rule['headers']['status-code']); + $this->assertEquals($domain, $rule['body']['domain']); + $this->assertArrayHasKey('$id', $rule['body']); + $this->assertArrayHasKey('type', $rule['body']); + $this->assertArrayHasKey('value', $rule['body']); + $this->assertArrayHasKey('automation', $rule['body']); + $this->assertArrayHasKey('status', $rule['body']); + $this->assertArrayHasKey('logs', $rule['body']); + $this->assertArrayHasKey('renewAt', $rule['body']); + + $ruleId = $rule['body']['$id']; + + $rule = $this->createAPIRule($domain); + $this->assertEquals(409, $rule['headers']['status-code']); + + $rule = $this->deleteRule($ruleId); + + $this->assertEquals(204, $rule['headers']['status-code']); + } + + public function testCreateRuleSetup(): void + { + $ruleId = $this->setupAPIRule(\uniqid() . '-api2.myapp.com'); + $this->cleanupRule($ruleId); + } + + public function testCreateRuleApex(): void + { + $rule = $this->createAPIRule('myapp.com'); + $this->assertEquals(400, $rule['headers']['status-code']); + } + + public function testCreateRuleVcs(): void + { + $domain = \uniqid() . '-vcs.myapp.com'; + + $rule = $this->createAPIRule('commit-' . $domain); + $this->assertEquals(400, $rule['headers']['status-code']); + + $rule = $this->createAPIRule('branch-' . $domain); + $this->assertEquals(400, $rule['headers']['status-code']); + + $rule = $this->createAPIRule('anything-' . $domain); + $this->assertEquals(201, $rule['headers']['status-code']); + $this->cleanupRule($rule['body']['$id']); + } + + public function testCreateAPIRule(): void + { + $domain = \uniqid() . '-api.custom.localhost'; + + $proxyClient = new Client(); + $proxyClient->setEndpoint('http://' . $domain); + + // We should ideally assert 400, but server allows unknown domains, and serves API by default + $response = $proxyClient->call(Client::METHOD_GET, '/versions'); + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals(APP_VERSION_STABLE, $response['body']['server']); + + $ruleId = $this->setupAPIRule($domain); + + $this->assertNotEmpty($ruleId); + + $response = $proxyClient->call(Client::METHOD_GET, '/versions'); + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals(APP_VERSION_STABLE, $response['body']['server']); + + $this->cleanupRule($ruleId); + + $rule = $this->createAPIRule('http://' . $domain); + $this->assertEquals(400, $rule['headers']['status-code']); + + $rule = $this->createAPIRule('https://' . $domain); + $this->assertEquals(400, $rule['headers']['status-code']); + + // Unexpected I would say, but it is the current behaviour + $rule = $this->createAPIRule('wss://' . $domain); + $this->assertEquals(201, $rule['headers']['status-code']); + $this->cleanupRule($rule['body']['$id']); + + // Unexpected I would say, but it is the current behaviour + $rule = $this->createAPIRule($domain . '/some-path'); + $this->assertEquals(201, $rule['headers']['status-code']); + $this->cleanupRule($rule['body']['$id']); + } + + public function testCreateRedirectRule(): void + { + $domain = \uniqid() . '-redirect.custom.localhost'; + + $proxyClient = new Client(); + $proxyClient->setEndpoint('http://appwrite'); + $proxyClient->addHeader('x-appwrite-hostname', $domain); + + $response = $proxyClient->call(Client::METHOD_GET, '/todos/1'); + $this->assertEquals(404, $response['headers']['status-code']); + + $ruleId = $this->setupRedirectRule($domain, 'jsonplaceholder.typicode.com'); + $this->assertNotEmpty($ruleId); + + $response = $proxyClient->call(Client::METHOD_GET, '/todos/1'); + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals(1, $response['body']['id']); + + $this->cleanupRule($ruleId); + } + + public function testCreateFunctionRule(): void + { + $domain = \uniqid() . '-function.custom.localhost'; + + $proxyClient = new Client(); + $proxyClient->setEndpoint('http://appwrite'); + $proxyClient->addHeader('x-appwrite-hostname', $domain); + + $response = $proxyClient->call(Client::METHOD_GET, '/ping'); + $this->assertEquals(404, $response['headers']['status-code']); + + $setup = $this->setupFunction(); + $functionId = $setup['functionId']; + $deploymentId = $setup['deploymentId']; + + $this->assertNotEmpty($functionId); + $this->assertNotEmpty($deploymentId); + + $ruleId = $this->setupFunctionRule($domain, $functionId); + $this->assertNotEmpty($ruleId); + + $response = $proxyClient->call(Client::METHOD_GET, '/ping'); + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals($functionId, $response['body']['APPWRITE_FUNCTION_ID']); + + $this->cleanupRule($ruleId); + + $this->cleanupFunction($functionId); + + $this->assertEventually(function () use ($functionId, $deploymentId) { + $rules = $this->listRules([ + 'queries' => [ + Query::limit(1)->toString(), + Query::equal('type', ['deployment'])->toString(), + Query::equal('automation', ['function=' . $functionId])->toString() + ] + ]); + $this->assertEquals(200, $rules['headers']['status-code']); + $this->assertEquals(0, $rules['body']['total']); + $this->assertCount(0, $rules['body']['rules']); + + $rules = $this->listRules([ + 'queries' => [ + Query::limit(1)->toString(), + Query::equal('type', ['deployment'])->toString(), + Query::equal('value', [$deploymentId])->toString() + ] + ]); + $this->assertEquals(200, $rules['headers']['status-code']); + $this->assertEquals(0, $rules['body']['total']); + $this->assertCount(0, $rules['body']['rules']); + }); + } + + public function testCreateSiteRule(): void + { + $domain = \uniqid() . '-site.custom.localhost'; + + $proxyClient = new Client(); + $proxyClient->setEndpoint('http://appwrite'); + $proxyClient->addHeader('x-appwrite-hostname', $domain); + + $response = $proxyClient->call(Client::METHOD_GET, '/contact'); + $this->assertEquals(404, $response['headers']['status-code']); + + $setup = $this->setupSite(); + $siteId = $setup['siteId']; + $deploymentId = $setup['deploymentId']; + + $this->assertNotEmpty($siteId); + $this->assertNotEmpty($deploymentId); + + $ruleId = $this->setupSiteRule($domain, $siteId); + $this->assertNotEmpty($ruleId); + + $response = $proxyClient->call(Client::METHOD_GET, '/contact'); + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertStringContainsString('Contact page', $response['body']); + + $this->cleanupRule($ruleId); + + $this->cleanupSite($siteId); + + $this->assertEventually(function () use ($siteId, $deploymentId) { + $rules = $this->listRules([ + 'queries' => [ + Query::limit(1)->toString(), + Query::equal('type', ['deployment'])->toString(), + Query::equal('automation', ['site=' . $siteId])->toString() + ] + ]); + $this->assertEquals(200, $rules['headers']['status-code']); + $this->assertEquals(0, $rules['body']['total']); + $this->assertCount(0, $rules['body']['rules']); + + $rules = $this->listRules([ + 'queries' => [ + Query::limit(1)->toString(), + Query::equal('type', ['deployment'])->toString(), + Query::equal('value', [$deploymentId])->toString() + ] + ]); + $this->assertEquals(200, $rules['headers']['status-code']); + $this->assertEquals(0, $rules['body']['total']); + $this->assertCount(0, $rules['body']['rules']); + }); + } + + public function testCreatSiteBranchRule(): void + { + $domain = \uniqid() . '-site-branch.custom.localhost'; + + $setup = $this->setupSite(); + $siteId = $setup['siteId']; + $deploymentId = $setup['deploymentId']; + + $this->assertNotEmpty($siteId); + $this->assertNotEmpty($deploymentId); + + $ruleId = $this->setupSiteRule($domain, $siteId, 'dev'); + $this->assertNotEmpty($ruleId); + + $rule = $this->getRule($ruleId); + $this->assertEquals(200, $rule['headers']['status-code']); + $this->assertEquals('branch=dev', $rule['body']['automation']); + + $this->cleanupRule($ruleId); + } + + public function testCreatFunctionBranchRule(): void + { + $domain = \uniqid() . '-function-branch.custom.localhost'; + + $setup = $this->setupFunction(); + $functionId = $setup['functionId']; + $deploymentId = $setup['deploymentId']; + + $this->assertNotEmpty($functionId); + $this->assertNotEmpty($deploymentId); + + $ruleId = $this->setupFunctionRule($domain, $functionId, 'dev'); + $this->assertNotEmpty($ruleId); + + $rule = $this->getRule($ruleId); + $this->assertEquals(200, $rule['headers']['status-code']); + $this->assertEquals('branch=dev', $rule['body']['automation']); + + $this->cleanupRule($ruleId); + } + + public function testUpdateRule(): void + { + // Create function appwrite-network domain + $domain = \uniqid() . '-cname-api.' . App::getEnv('_APP_DOMAIN_FUNCTIONS'); + + $rule = $this->createAPIRule($domain); + $this->assertEquals(201, $rule['headers']['status-code']); + $this->assertEquals('verified', $rule['body']['status']); + + $this->cleanupRule($rule['body']['$id']); + + // Create site appwrite-network domain + $domain = \uniqid() . '-cname-api.' . App::getEnv('_APP_DOMAIN_SITES'); + + $rule = $this->createAPIRule($domain); + $this->assertEquals(201, $rule['headers']['status-code']); + $this->assertEquals('verified', $rule['body']['status']); + + $this->cleanupRule($rule['body']['$id']); + + // Create + update + $domain = \uniqid() . '-cname-api.custom.localhost'; + + $rule = $this->createAPIRule($domain); + $this->assertEquals(201, $rule['headers']['status-code']); + $this->assertEquals('created', $rule['body']['status']); + + $ruleId = $rule['body']['$id']; + + $rule = $this->updateRuleVerification($ruleId); + $this->assertEquals(401, $rule['headers']['status-code']); + + $this->cleanupRule($ruleId); + } + + public function testGetRule() + { + $domain = \uniqid() . '-get.custom.localhost'; + $ruleId = $this->setupAPIRule($domain); + + $this->assertNotEmpty($ruleId); + + $rule = $this->getRule($ruleId); + $this->assertEquals(200, $rule['headers']['status-code']); + $this->assertEquals($domain, $rule['body']['domain']); + $this->assertArrayHasKey('$id', $rule['body']); + $this->assertArrayHasKey('type', $rule['body']); + $this->assertArrayHasKey('value', $rule['body']); + $this->assertArrayHasKey('automation', $rule['body']); + $this->assertArrayHasKey('status', $rule['body']); + $this->assertArrayHasKey('logs', $rule['body']); + $this->assertArrayHasKey('renewAt', $rule['body']); + + $this->cleanupRule($ruleId); + } + + public function testListRules() + { + $rules = $this->listRules(); + $this->assertEquals(200, $rules['headers']['status-code']); + foreach ($rules['body']['rules'] as $rule) { + $rule = $this->deleteRule($rule['$id']); + $this->assertEquals(204, $rule['headers']['status-code']); + } + + $rules = $this->listRules(); + $this->assertEquals(200, $rules['headers']['status-code']); + $this->assertEquals(0, $rules['body']['total']); + $this->assertCount(0, $rules['body']['rules']); + + $rule1Domain = \uniqid() . '-list1.custom.localhost'; + $rule1Id = $this->setupAPIRule($rule1Domain); + $this->assertNotEmpty($rule1Id); + + $rules = $this->listRules(); + $this->assertEquals(200, $rules['headers']['status-code']); + $this->assertEquals(1, $rules['body']['total']); + $this->assertCount(1, $rules['body']['rules']); + $this->assertEquals($rule1Domain, $rules['body']['rules'][0]['domain']); + $this->assertArrayHasKey('$id', $rules['body']['rules'][0]); + $this->assertArrayHasKey('type', $rules['body']['rules'][0]); + $this->assertArrayHasKey('value', $rules['body']['rules'][0]); + $this->assertArrayHasKey('automation', $rules['body']['rules'][0]); + $this->assertArrayHasKey('status', $rules['body']['rules'][0]); + $this->assertArrayHasKey('logs', $rules['body']['rules'][0]); + $this->assertArrayHasKey('renewAt', $rules['body']['rules'][0]); + + $rule2Domain = \uniqid() . '-list1.custom.localhost'; + $rule2Id = $this->setupAPIRule($rule2Domain); + $this->assertNotEmpty($rule2Id); + + $rules = $this->listRules(); + $this->assertEquals(200, $rules['headers']['status-code']); + $this->assertEquals(2, $rules['body']['total']); + $this->assertCount(2, $rules['body']['rules']); + + $rules = $this->listRules([ + 'queries' => [ + Query::limit(1)->toString() + ] + ]); + $this->assertEquals(200, $rules['headers']['status-code']); + $this->assertEquals(2, $rules['body']['total']); + $this->assertCount(1, $rules['body']['rules']); + + $rules = $this->listRules([ + 'queries' => [ + Query::equal('$id', [$rule1Id])->toString() + ] + ]); + $this->assertEquals(200, $rules['headers']['status-code']); + $this->assertCount(1, $rules['body']['rules']); + $this->assertEquals($rule1Domain, $rules['body']['rules'][0]['domain']); + + $rules = $this->listRules([ + 'queries' => [ + Query::orderDesc('$id')->toString() + ] + ]); + $this->assertEquals(200, $rules['headers']['status-code']); + $this->assertCount(2, $rules['body']['rules']); + $this->assertEquals($rule2Id, $rules['body']['rules'][0]['$id']); + + $rules = $this->listRules([ + 'queries' => [ + Query::equal('domain', [$rule2Domain])->toString() + ] + ]); + $this->assertEquals(200, $rules['headers']['status-code']); + $this->assertCount(1, $rules['body']['rules']); + $this->assertEquals($rule2Id, $rules['body']['rules'][0]['$id']); + + $rules = $this->listRules([ + 'search' => $rule1Domain, + 'queries' => [ Query::orderDesc('$createdAt') ] + ]); + + $this->assertEquals(200, $rules['headers']['status-code']); + $ruleIds = \array_column($rules['body']['rules'], '$id'); + $this->assertContains($rule1Id, $ruleIds); + + $rules = $this->listRules([ + 'search' => $rule2Domain, + 'queries' => [ Query::orderDesc('$createdAt') ] + ]); + $this->assertEquals(200, $rules['headers']['status-code']); + $ruleIds = \array_column($rules['body']['rules'], '$id'); + $this->assertContains($rule2Id, $ruleIds); + + $rules = $this->listRules([ + 'search' => $rule1Id, + 'queries' => [ Query::orderDesc('$createdAt') ] + ]); + $this->assertEquals(200, $rules['headers']['status-code']); + $ruleDomains = \array_column($rules['body']['rules'], 'domain'); + $this->assertContains($rule1Domain, $ruleDomains); + + $rules = $this->listRules([ + 'search' => $rule2Id, + 'queries' => [ Query::orderDesc('$createdAt') ] + ]); + $this->assertEquals(200, $rules['headers']['status-code']); + $ruleDomains = \array_column($rules['body']['rules'], 'domain'); + $this->assertContains($rule2Domain, $ruleDomains); + + $rules = $this->listRules(); + $this->assertEquals(200, $rules['headers']['status-code']); + foreach ($rules['body']['rules'] as $rule) { + $rule = $this->deleteRule($rule['$id']); + $this->assertEquals(204, $rule['headers']['status-code']); + } + + $rules = $this->listRules(); + $this->assertEquals(200, $rules['headers']['status-code']); + $this->assertEquals(0, $rules['body']['total']); + $this->assertCount(0, $rules['body']['rules']); + } +} diff --git a/tests/e2e/Services/Sites/SitesBase.php b/tests/e2e/Services/Sites/SitesBase.php index 4c11e78d76..7d65e40db6 100644 --- a/tests/e2e/Services/Sites/SitesBase.php +++ b/tests/e2e/Services/Sites/SitesBase.php @@ -284,13 +284,12 @@ trait SitesBase protected function setupSiteDomain(string $siteId, string $subdomain = ''): string { $subdomain = $subdomain ? $subdomain : ID::unique(); - $rule = $this->client->call(Client::METHOD_POST, '/proxy/rules', array_merge([ + $rule = $this->client->call(Client::METHOD_POST, '/proxy/rules/site', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders()), [ 'domain' => $subdomain . '.' . System::getEnv('_APP_DOMAIN_SITES', ''), - 'resourceType' => 'site', - 'resourceId' => $siteId, + 'siteId' => $siteId, ]); $this->assertEquals(201, $rule['headers']['status-code']); @@ -309,8 +308,8 @@ trait SitesBase 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders()), [ 'queries' => [ - Query::equal('resourceId', [$siteId])->toString(), - Query::equal('resourceType', ['site'])->toString(), + Query::equal('automation', ['site=' . $siteId])->toString(), + Query::equal('type', ['deployment'])->toString(), ], ]); @@ -324,7 +323,6 @@ trait SitesBase return $domain; } - protected function getDeploymentDomain(string $deploymentId): string { $rules = $this->client->call(Client::METHOD_GET, '/proxy/rules', array_merge([ @@ -332,8 +330,9 @@ trait SitesBase 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders()), [ 'queries' => [ - Query::equal('resourceId', [$deploymentId])->toString(), - Query::equal('resourceType', ['deployment'])->toString(), + Query::equal('value', [$deploymentId])->toString(), + Query::equal('type', ['deployment'])->toString(), + Query::equal('automation', [''])->toString(), ], ]); diff --git a/tests/e2e/Services/Sites/SitesCustomServerTest.php b/tests/e2e/Services/Sites/SitesCustomServerTest.php index 1961e40ce5..7e941f1717 100644 --- a/tests/e2e/Services/Sites/SitesCustomServerTest.php +++ b/tests/e2e/Services/Sites/SitesCustomServerTest.php @@ -79,7 +79,7 @@ class SitesCustomServerTest extends Scope $this->assertNotEmpty($siteId); - $rule = $this->setupSiteDomain($siteId); + $domain = $this->setupSiteDomain($siteId); $response = $this->client->call(Client::METHOD_GET, '/console/resources', [ 'origin' => 'http://localhost', @@ -88,7 +88,7 @@ class SitesCustomServerTest extends Scope 'x-appwrite-project' => 'console', ], [ 'type' => 'rules', - 'value' => $rule, + 'value' => $domain, ]); $this->assertEquals(409, $response['headers']['status-code']); // domain unavailable @@ -115,7 +115,7 @@ class SitesCustomServerTest extends Scope 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders()), [ 'queries' => [ - Query::equal('resourceId', [$siteId]) + Query::equal('automation', ['site=' . $siteId]) ] ]); @@ -130,7 +130,7 @@ class SitesCustomServerTest extends Scope 'x-appwrite-project' => 'console', ], [ 'type' => 'rules', - 'value' => $rule, + 'value' => $domain, ]); $this->assertEquals(204, $response['headers']['status-code']); // domain available as site is deleted @@ -273,6 +273,8 @@ class SitesCustomServerTest extends Scope $this->cleanupSite($siteId); } + // This is first Sites test with Proxy + // If this fails, it may not be related to variables; but Router flow failing public function testVariablesE2E(): void { $siteId = $this->setupSite([ @@ -1008,41 +1010,6 @@ class SitesCustomServerTest extends Scope $this->cleanupSite($siteId); } - // public function testLoadSite(): void - // { - // $site = $this->createSite([ - // 'buildRuntime' => 'ssr-22', - // 'fallbackFile' => null, - // 'framework' => 'other', - // 'name' => 'Test Site', - // 'outputDirectory' => './', - // 'providerBranch' => 'main', - // 'providerRootDirectory' => './', - // 'siteId' => ID::unique() - // ]); - - // $siteId = $site['body']['$id'] ?? ''; - // $this->assertNotEmpty($siteId); - - // $deployment = $this->createDeployment($siteId, [ - // 'code' => $this->packageSite('static'), - // 'activate' => 'false' - // ]); - - // $deploymentId = $deployment['body']['$id'] ?? ''; - - // $this->assertEventually(function () use ($siteId, $deploymentId) { - // $deployment = $this->getDeployment($siteId, $deploymentId); - - // $this->assertEquals('ready', $deployment['body']['status']); - // }, 30000, 300); - - // // get rule for this site from rules collection - - // $response = $this->client->call(Client::METHOD_GET, $domain); - // var_dump($response); - // } - public function testUpdateSpecs(): void { $siteId = $this->setupSite([ @@ -1205,6 +1172,118 @@ class SitesCustomServerTest extends Scope $this->assertArrayHasKey('adapters', $framework); } + public function testSiteStatic(): void + { + $siteId = $this->setupSite([ + 'siteId' => ID::unique(), + 'name' => 'Non-SPA site', + 'framework' => 'other', + 'adapter' => 'static', + 'buildRuntime' => 'static-1', + 'outputDirectory' => './', + 'buildCommand' => '', + 'installCommand' => '', + 'fallbackFile' => '', + ]); + + $this->assertNotEmpty($siteId); + + $deploymentId = $this->setupDeployment($siteId, [ + 'code' => $this->packageSite('static-spa'), + 'activate' => 'true' + ]); + + $this->assertNotEmpty($deploymentId); + + $domain = $this->setupSiteDomain($siteId); + $proxyClient = new Client(); + $proxyClient->setEndpoint('http://' . $domain); + + $response = $proxyClient->call(Client::METHOD_GET, '/', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ])); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertStringContainsString("Index page", $response['body']); + + $response = $proxyClient->call(Client::METHOD_GET, '/contact', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ])); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertStringContainsString("Contact page", $response['body']); + + $response = $proxyClient->call(Client::METHOD_GET, '/non-existing', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ])); + + $this->assertEquals(404, $response['headers']['status-code']); + $this->assertStringContainsString("Page not found", $response['body']); // Title + $this->assertStringContainsString("Go to homepage", $response['body']); // Button + $this->assertStringContainsString("Powered by", $response['body']); // Brand + + $this->cleanupSite($siteId); + } + + public function testSiteStaticSPA(): void + { + $siteId = $this->setupSite([ + 'siteId' => ID::unique(), + 'name' => 'SPA site', + 'framework' => 'other', + 'adapter' => 'static', + 'buildRuntime' => 'static-1', + 'outputDirectory' => './', + 'buildCommand' => '', + 'installCommand' => '', + 'fallbackFile' => '404.html', + ]); + + $this->assertNotEmpty($siteId); + + $deploymentId = $this->setupDeployment($siteId, [ + 'code' => $this->packageSite('static-spa'), + 'activate' => 'true' + ]); + + $this->assertNotEmpty($deploymentId); + + $domain = $this->setupSiteDomain($siteId); + + $proxyClient = new Client(); + $proxyClient->setEndpoint('http://' . $domain); + + $response = $proxyClient->call(Client::METHOD_GET, '/', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ])); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertStringContainsString("Index page", $response['body']); + + $response = $proxyClient->call(Client::METHOD_GET, '/contact', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ])); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertStringContainsString("Contact page", $response['body']); + + $response = $proxyClient->call(Client::METHOD_GET, '/non-existing', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ])); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertStringContainsString("Customized 404 page", $response['body']); + $this->assertStringNotContainsString("Powered by", $response['body']); // Brand + + $this->cleanupSite($siteId); + } + public function testSiteTemplate(): void { $template = $this->getTemplate('astro-starter'); @@ -1310,17 +1389,15 @@ class SitesCustomServerTest extends Scope $siteId2 = $site2['body']['$id']; - $rule = $this->client->call(Client::METHOD_POST, '/proxy/rules', array_merge([ + $rule = $this->client->call(Client::METHOD_POST, '/proxy/rules/site', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders()), [ 'domain' => $subdomain . '.' . System::getEnv('_APP_DOMAIN_SITES', ''), - 'resourceType' => 'site', - 'resourceId' => $siteId2, + 'siteId' => $siteId2, ]); $this->assertEquals(409, $rule['headers']['status-code']); - $this->assertStringContainsString("Document with the requested ID already exists. Try again with a different ID or use ID.unique() to generate a unique ID.", $rule['body']['message']); $this->cleanupSite($siteId); @@ -1548,6 +1625,12 @@ class SitesCustomServerTest extends Scope $this->assertNotEquals($screenshotDarkHash, $screenshotHash); + $file = $this->client->call(Client::METHOD_GET, "/storage/buckets/screenshots/files/$screenshotId/view?project=console&mode=admin"); + $this->assertEquals(404, $file['headers']['status-code']); + + $file = $this->client->call(Client::METHOD_GET, "/storage/buckets/screenshots/files/$screenshotId/view?project=console&mode=admin"); + $this->assertEquals(404, $file['headers']['status-code']); + $this->cleanupSite($siteId); } diff --git a/tests/resources/sites/static-spa/404.html b/tests/resources/sites/static-spa/404.html new file mode 100644 index 0000000000..2a51f36d22 --- /dev/null +++ b/tests/resources/sites/static-spa/404.html @@ -0,0 +1,10 @@ + + + + + + + +

Customized 404 page

+ + diff --git a/tests/resources/sites/static-spa/contact.html b/tests/resources/sites/static-spa/contact.html new file mode 100644 index 0000000000..1ef7dc9497 --- /dev/null +++ b/tests/resources/sites/static-spa/contact.html @@ -0,0 +1,10 @@ + + + + + + + +

Contact page

+ + diff --git a/tests/resources/sites/static-spa/index.html b/tests/resources/sites/static-spa/index.html new file mode 100644 index 0000000000..3fd2262803 --- /dev/null +++ b/tests/resources/sites/static-spa/index.html @@ -0,0 +1,10 @@ + + + + + + + +

Index page

+ + diff --git a/tests/resources/sites/static/contact.html b/tests/resources/sites/static/contact.html new file mode 100644 index 0000000000..b2c16fc471 --- /dev/null +++ b/tests/resources/sites/static/contact.html @@ -0,0 +1,11 @@ + + + + + + Contact page + + +

Contact page

+ + \ No newline at end of file