diff --git a/.env b/.env index 960405111a..55ec662f24 100644 --- a/.env +++ b/.env @@ -22,7 +22,7 @@ _APP_OPTIONS_FORCE_HTTPS=disabled _APP_OPTIONS_ROUTER_FORCE_HTTPS=disabled _APP_OPENSSL_KEY_V1=your-secret-key _APP_DNS=8.8.8.8 -_APP_DOMAIN=traefik +_APP_DOMAIN=appwrite.test _APP_CONSOLE_DOMAIN=localhost _APP_DOMAIN_FUNCTIONS=functions.localhost _APP_DOMAIN_SITES=sites.localhost @@ -124,4 +124,4 @@ _APP_MESSAGE_PUSH_TEST_DSN= _APP_WEBHOOK_MAX_FAILED_ATTEMPTS=10 _APP_PROJECT_REGIONS=default _APP_FUNCTIONS_CREATION_ABUSE_LIMIT=5000 -_APP_STATS_USAGE_DUAL_WRITING_DBS=database_db_main \ No newline at end of file +_APP_STATS_USAGE_DUAL_WRITING_DBS=database_db_main diff --git a/app/config/platform.php b/app/config/platform.php new file mode 100644 index 0000000000..4c5a88d87e --- /dev/null +++ b/app/config/platform.php @@ -0,0 +1,21 @@ + System::getEnv('_APP_DOMAIN', 'localhost'), + 'consoleDomain' => System::getEnv('_APP_CONSOLE_DOMAIN', System::getEnv('_APP_DOMAIN', 'localhost')), + 'platformName' => APP_EMAIL_PLATFORM_NAME, + 'logoUrl' => APP_EMAIL_LOGO_URL, + 'accentColor' => APP_EMAIL_ACCENT_COLOR, + 'footerImageUrl' => APP_EMAIL_FOOTER_IMAGE_URL, + 'twitterUrl' => APP_SOCIAL_TWITTER, + 'discordUrl' => APP_SOCIAL_DISCORD, + 'githubUrl' => APP_SOCIAL_GITHUB, + 'termsUrl' => APP_EMAIL_TERMS_URL, + 'privacyUrl' => APP_EMAIL_PRIVACY_URL, + 'websiteUrl' => 'https://' . APP_DOMAIN, +]; diff --git a/app/config/platforms.php b/app/config/sdks.php similarity index 100% rename from app/config/platforms.php rename to app/config/sdks.php diff --git a/app/config/templates/site.php b/app/config/templates/site.php index c8bb019123..ea84367dbd 100644 --- a/app/config/templates/site.php +++ b/app/config/templates/site.php @@ -1,5 +1,6 @@ '_APP_DOMAIN', - 'description' => 'Your Appwrite domain address. When setting a public suffix domain, Appwrite will attempt to issue a valid SSL certificate automatically. When used with a dev domain, Appwrite will assign a self-signed SSL certificate. The default value is \'localhost\'.', + 'description' => 'Your Appwrite domain address. When setting a public suffix domain, Appwrite will attempt to issue a valid SSL certificate automatically. When used with a dev domain, Appwrite will assign a self-signed SSL certificate. The default value is \'localhost\'. Multiple domains can be separated by commas.', 'introduction' => '', 'default' => 'localhost', 'required' => true, diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index 67ec3dccbc..1c9537a783 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -64,11 +64,11 @@ use Utopia\Emails\Email; use Utopia\Locale\Locale; use Utopia\Storage\Validator\FileName; use Utopia\System\System; +use Utopia\Validator; use Utopia\Validator\ArrayList; use Utopia\Validator\Assoc; use Utopia\Validator\Boolean; use Utopia\Validator\Text; -use Utopia\Validator\URL; use Utopia\Validator\WhiteList; $oauthDefaultSuccess = '/console/auth/oauth2/success'; @@ -1283,13 +1283,14 @@ App::get('/v1/account/sessions/oauth2/:provider') ->label('abuse-limit', 50) ->label('abuse-key', 'ip:{ip}') ->param('provider', '', new WhiteList(\array_keys(Config::getParam('oAuthProviders')), true), 'OAuth2 Provider. Currently, supported providers are: ' . \implode(', ', \array_keys(\array_filter(Config::getParam('oAuthProviders'), fn ($node) => (!$node['mock'])))) . '.') - ->param('success', '', fn ($platforms, $devKey) => $devKey->isEmpty() ? new Redirect($platforms) : new URL(), 'URL to redirect back to your app after a successful login attempt. Only URLs from hostnames in your project\'s platform list are allowed. This requirement helps to prevent an [open redirect](https://cheatsheetseries.owasp.org/cheatsheets/Unvalidated_Redirects_and_Forwards_Cheat_Sheet.html) attack against your project API.', true, ['platforms', 'devKey']) - ->param('failure', '', fn ($platforms, $devKey) => $devKey->isEmpty() ? new Redirect($platforms) : new URL(), 'URL to redirect back to your app after a failed login attempt. Only URLs from hostnames in your project\'s platform list are allowed. This requirement helps to prevent an [open redirect](https://cheatsheetseries.owasp.org/cheatsheets/Unvalidated_Redirects_and_Forwards_Cheat_Sheet.html) attack against your project API.', true, ['platforms', 'devKey']) + ->param('success', '', fn ($redirectValidator) => $redirectValidator, 'URL to redirect back to your app after a successful login attempt. Only URLs from hostnames in your project\'s platform list are allowed. This requirement helps to prevent an [open redirect](https://cheatsheetseries.owasp.org/cheatsheets/Unvalidated_Redirects_and_Forwards_Cheat_Sheet.html) attack against your project API.', true, ['redirectValidator']) + ->param('failure', '', fn ($redirectValidator) => $redirectValidator, 'URL to redirect back to your app after a failed login attempt. Only URLs from hostnames in your project\'s platform list are allowed. This requirement helps to prevent an [open redirect](https://cheatsheetseries.owasp.org/cheatsheets/Unvalidated_Redirects_and_Forwards_Cheat_Sheet.html) attack against your project API.', true, ['redirectValidator']) ->param('scopes', [], new ArrayList(new Text(APP_LIMIT_ARRAY_ELEMENT_SIZE), APP_LIMIT_ARRAY_PARAMS_SIZE), 'A list of custom OAuth2 scopes. Check each provider internal docs for a list of supported scopes. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' scopes are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long.', true) ->inject('request') ->inject('response') ->inject('project') - ->action(function (string $provider, string $success, string $failure, array $scopes, Request $request, Response $response, Document $project) use ($oauthDefaultSuccess, $oauthDefaultFailure) { + ->inject('platform') + ->action(function (string $provider, string $success, string $failure, array $scopes, Request $request, Response $response, Document $project, array $platform) use ($oauthDefaultSuccess, $oauthDefaultFailure) { $protocol = System::getEnv('_APP_OPTIONS_FORCE_HTTPS') === 'disabled' ? 'http' : 'https'; $port = $request->getPort(); $callbackBase = $protocol . '://' . $request->getHostname(); @@ -1324,7 +1325,7 @@ App::get('/v1/account/sessions/oauth2/:provider') throw new Exception(Exception::PROJECT_PROVIDER_UNSUPPORTED); } - $host = System::getEnv('_APP_CONSOLE_DOMAIN', System::getEnv('_APP_DOMAIN', '')); + $host = $platform['consoleDomain'] ?? ''; $redirectBase = $protocol . '://' . $host; if ($protocol === 'https' && $port !== '443') { $redirectBase .= ':' . $port; @@ -1443,7 +1444,7 @@ App::get('/v1/account/sessions/oauth2/:provider/redirect') ->inject('request') ->inject('response') ->inject('project') - ->inject('platforms') + ->inject('redirectValidator') ->inject('devKey') ->inject('user') ->inject('dbForProject') @@ -1452,7 +1453,7 @@ App::get('/v1/account/sessions/oauth2/:provider/redirect') ->inject('store') ->inject('proofForPassword') ->inject('proofForToken') - ->action(function (string $provider, string $code, string $state, string $error, string $error_description, Request $request, Response $response, Document $project, array $platforms, Document $devKey, User $user, Database $dbForProject, Reader $geodb, Event $queueForEvents, Store $store, ProofsPassword $proofForPassword, ProofsToken $proofForToken) use ($oauthDefaultSuccess) { + ->action(function (string $provider, string $code, string $state, string $error, string $error_description, Request $request, Response $response, Document $project, Validator $redirectValidator, Document $devKey, User $user, Database $dbForProject, Reader $geodb, Event $queueForEvents, Store $store, ProofsPassword $proofForPassword, ProofsToken $proofForToken) use ($oauthDefaultSuccess) { $protocol = System::getEnv('_APP_OPTIONS_FORCE_HTTPS') === 'disabled' ? 'http' : 'https'; $port = $request->getPort(); $callbackBase = $protocol . '://' . $request->getHostname(); @@ -1463,7 +1464,6 @@ App::get('/v1/account/sessions/oauth2/:provider/redirect') } $callback = $callbackBase . '/v1/account/sessions/oauth2/callback/' . $provider . '/' . $project->getId(); $defaultState = ['success' => $project->getAttribute('url', ''), 'failure' => '']; - $redirect = new Redirect($platforms); $appId = $project->getAttribute('oAuthProviders', [])[$provider . 'Appid'] ?? ''; $appSecret = $project->getAttribute('oAuthProviders', [])[$provider . 'Secret'] ?? '{}'; $providerEnabled = $project->getAttribute('oAuthProviders', [])[$provider . 'Enabled'] ?? false; @@ -1490,11 +1490,11 @@ App::get('/v1/account/sessions/oauth2/:provider/redirect') $state = $defaultState; } - if ($devKey->isEmpty() && !$redirect->isValid($state['success'])) { + if ($devKey->isEmpty() && !$redirectValidator->isValid($state['success'])) { throw new Exception(Exception::PROJECT_INVALID_SUCCESS_URL); } - if ($devKey->isEmpty() && !empty($state['failure']) && !$redirect->isValid($state['failure'])) { + if ($devKey->isEmpty() && !empty($state['failure']) && !$redirectValidator->isValid($state['failure'])) { throw new Exception(Exception::PROJECT_INVALID_FAILURE_URL); } $failure = []; @@ -1945,23 +1945,15 @@ App::get('/v1/account/tokens/oauth2/:provider') ->label('abuse-limit', 50) ->label('abuse-key', 'ip:{ip}') ->param('provider', '', new WhiteList(\array_keys(Config::getParam('oAuthProviders')), true), 'OAuth2 Provider. Currently, supported providers are: ' . \implode(', ', \array_keys(\array_filter(Config::getParam('oAuthProviders'), fn ($node) => (!$node['mock'])))) . '.') - ->param('success', '', fn ($platforms, $devKey) => $devKey->isEmpty() ? new Redirect($platforms) : new URL(), 'URL to redirect back to your app after a successful login attempt. Only URLs from hostnames in your project\'s platform list are allowed. This requirement helps to prevent an [open redirect](https://cheatsheetseries.owasp.org/cheatsheets/Unvalidated_Redirects_and_Forwards_Cheat_Sheet.html) attack against your project API.', true, ['platforms', 'devKey']) - ->param('failure', '', fn ($platforms, $devKey) => $devKey->isEmpty() ? new Redirect($platforms) : new URL(), 'URL to redirect back to your app after a failed login attempt. Only URLs from hostnames in your project\'s platform list are allowed. This requirement helps to prevent an [open redirect](https://cheatsheetseries.owasp.org/cheatsheets/Unvalidated_Redirects_and_Forwards_Cheat_Sheet.html) attack against your project API.', true, ['platforms', 'devKey']) + ->param('success', '', fn ($redirectValidator) => $redirectValidator, 'URL to redirect back to your app after a successful login attempt. Only URLs from hostnames in your project\'s platform list are allowed. This requirement helps to prevent an [open redirect](https://cheatsheetseries.owasp.org/cheatsheets/Unvalidated_Redirects_and_Forwards_Cheat_Sheet.html) attack against your project API.', true, ['redirectValidator']) + ->param('failure', '', fn ($redirectValidator) => $redirectValidator, 'URL to redirect back to your app after a failed login attempt. Only URLs from hostnames in your project\'s platform list are allowed. This requirement helps to prevent an [open redirect](https://cheatsheetseries.owasp.org/cheatsheets/Unvalidated_Redirects_and_Forwards_Cheat_Sheet.html) attack against your project API.', true, ['redirectValidator']) ->param('scopes', [], new ArrayList(new Text(APP_LIMIT_ARRAY_ELEMENT_SIZE), APP_LIMIT_ARRAY_PARAMS_SIZE), 'A list of custom OAuth2 scopes. Check each provider internal docs for a list of supported scopes. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' scopes are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long.', true) ->inject('request') ->inject('response') ->inject('project') - ->action(function (string $provider, string $success, string $failure, array $scopes, Request $request, Response $response, Document $project) use ($oauthDefaultSuccess, $oauthDefaultFailure) { - $protocol = System::getEnv('_APP_OPTIONS_FORCE_HTTPS') === 'disabled' ? 'http' : 'https'; - $port = $request->getPort(); - $callbackBase = $protocol . '://' . $request->getHostname(); - if ($protocol === 'https' && $port !== '443') { - $callbackBase .= ':' . $port; - } elseif ($protocol === 'http' && $port !== '80') { - $callbackBase .= ':' . $port; - } - - $callback = $callbackBase . '/v1/account/sessions/oauth2/callback/' . $provider . '/' . $project->getId(); + ->inject('platform') + ->action(function (string $provider, string $success, string $failure, array $scopes, Request $request, Response $response, Document $project, array $platform) use ($oauthDefaultSuccess, $oauthDefaultFailure) { + $callback = $platform['endpoint'] . '/account/sessions/oauth2/callback/' . $provider . '/' . $project->getId(); $providerEnabled = $project->getAttribute('oAuthProviders', [])[$provider . 'Enabled'] ?? false; if (!$providerEnabled) { @@ -1986,7 +1978,9 @@ App::get('/v1/account/tokens/oauth2/:provider') throw new Exception(Exception::PROJECT_PROVIDER_UNSUPPORTED); } - $host = System::getEnv('_APP_CONSOLE_DOMAIN', System::getEnv('_APP_DOMAIN', '')); + $host = $platform['consoleDomain'] ?? ''; + $protocol = System::getEnv('_APP_OPTIONS_FORCE_HTTPS') == 'disabled' ? 'http' : 'https'; + $port = $request->getPort(); $redirectBase = $protocol . '://' . $host; if ($protocol === 'https' && $port !== '443') { $redirectBase .= ':' . $port; @@ -2041,7 +2035,7 @@ App::post('/v1/account/tokens/magic-url') ->label('abuse-key', ['url:{url},email:{param-email}', 'url:{url},ip:{ip}']) ->param('userId', '', new CustomId(), 'Unique Id. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char. Max length is 36 chars. If the email address has never been used, a new account is created using the provided userId. Otherwise, if the email address is already attached to an account, the user ID is ignored.') ->param('email', '', new EmailValidator(), 'User email.') - ->param('url', '', fn ($platforms, $devKey) => $devKey->isEmpty() ? new Redirect($platforms) : new URL(), 'URL to redirect the user back to your app from the magic URL login. Only URLs from hostnames in your project platform list are allowed. This requirement helps to prevent an [open redirect](https://cheatsheetseries.owasp.org/cheatsheets/Unvalidated_Redirects_and_Forwards_Cheat_Sheet.html) attack against your project API.', true, ['platforms', 'devKey']) + ->param('url', '', fn ($redirectValidator) => $redirectValidator, 'URL to redirect the user back to your app from the magic URL login. Only URLs from hostnames in your project platform list are allowed. This requirement helps to prevent an [open redirect](https://cheatsheetseries.owasp.org/cheatsheets/Unvalidated_Redirects_and_Forwards_Cheat_Sheet.html) attack against your project API.', true, ['redirectValidator']) ->param('phrase', false, new Boolean(), 'Toggle for security phrase. If enabled, email will be send with a randomly generated phrase and the phrase will also be included in the response. Confirming phrases match increases the security of your authentication flow.', true) ->inject('request') ->inject('response') @@ -2052,7 +2046,8 @@ App::post('/v1/account/tokens/magic-url') ->inject('queueForEvents') ->inject('queueForMails') ->inject('proofForPassword') - ->action(function (string $userId, string $email, string $url, bool $phrase, Request $request, Response $response, User $user, Document $project, Database $dbForProject, Locale $locale, Event $queueForEvents, Mail $queueForMails, ProofsPassword $proofForPassword) { + ->inject('platform') + ->action(function (string $userId, string $email, string $url, bool $phrase, Request $request, Response $response, User $user, Document $project, Database $dbForProject, Locale $locale, Event $queueForEvents, Mail $queueForMails, ProofsPassword $proofForPassword, array $platform) { if (empty(System::getEnv('_APP_SMTP_HOST'))) { throw new Exception(Exception::GENERAL_SMTP_DISABLED, 'SMTP disabled'); } @@ -2158,7 +2153,7 @@ App::post('/v1/account/tokens/magic-url') if (empty($url)) { $protocol = System::getEnv('_APP_OPTIONS_FORCE_HTTPS') === 'disabled' ? 'http' : 'https'; - $host = System::getEnv('_APP_CONSOLE_DOMAIN', System::getEnv('_APP_DOMAIN', '')); + $host = $platform['consoleDomain'] ?? ''; $port = $request->getPort(); $callbackBase = $protocol . '://' . $host; if ($protocol === 'https' && $port !== '443') { @@ -3463,7 +3458,7 @@ App::post('/v1/account/recovery') ->label('abuse-limit', 10) ->label('abuse-key', ['url:{url},email:{param-email}', 'url:{url},ip:{ip}']) ->param('email', '', new EmailValidator(), 'User email.') - ->param('url', '', fn ($platforms, $devKey) => $devKey->isEmpty() ? new Redirect($platforms) : new URL(), 'URL to redirect the user back to your app from the recovery email. Only URLs from hostnames in your project platform list are allowed. This requirement helps to prevent an [open redirect](https://cheatsheetseries.owasp.org/cheatsheets/Unvalidated_Redirects_and_Forwards_Cheat_Sheet.html) attack against your project API.', false, ['platforms', 'devKey']) + ->param('url', '', fn ($redirectValidator) => $redirectValidator, 'URL to redirect the user back to your app from the recovery email. Only URLs from hostnames in your project platform list are allowed. This requirement helps to prevent an [open redirect](https://cheatsheetseries.owasp.org/cheatsheets/Unvalidated_Redirects_and_Forwards_Cheat_Sheet.html) attack against your project API.', false, ['redirectValidator']) ->inject('request') ->inject('response') ->inject('user') @@ -3759,7 +3754,7 @@ App::post('/v1/account/verifications/email') ]) ->label('abuse-limit', 10) ->label('abuse-key', 'url:{url},userId:{userId}') - ->param('url', '', fn ($platforms, $devKey) => $devKey->isEmpty() ? new Redirect($platforms) : new URL(), 'URL to redirect the user back to your app from the verification email. Only URLs from hostnames in your project platform list are allowed. This requirement helps to prevent an [open redirect](https://cheatsheetseries.owasp.org/cheatsheets/Unvalidated_Redirects_and_Forwards_Cheat_Sheet.html) attack against your project API.', false, ['platforms', 'devKey']) // TODO add built-in confirm page + ->param('url', '', fn ($redirectValidator) => $redirectValidator, 'URL to redirect the user back to your app from the verification email. Only URLs from hostnames in your project platform list are allowed. This requirement helps to prevent an [open redirect](https://cheatsheetseries.owasp.org/cheatsheets/Unvalidated_Redirects_and_Forwards_Cheat_Sheet.html) attack against your project API.', false, ['redirectValidator']) // TODO add built-in confirm page ->inject('request') ->inject('response') ->inject('project') diff --git a/app/controllers/api/console.php b/app/controllers/api/console.php index ec68f1050c..5bc8325794 100644 --- a/app/controllers/api/console.php +++ b/app/controllers/api/console.php @@ -43,9 +43,6 @@ App::get('/v1/console/variables') )) ->inject('response') ->action(function (Response $response) { - $validator = new Domain(System::getEnv('_APP_DOMAIN')); - $isDomainValid = !empty(System::getEnv('_APP_DOMAIN', '')) && $validator->isKnown() && !$validator->isTest(); - $validator = new Domain(System::getEnv('_APP_DOMAIN_TARGET_CNAME')); $isCNAMEValid = !empty(System::getEnv('_APP_DOMAIN_TARGET_CNAME', '')) && $validator->isKnown() && !$validator->isTest(); @@ -55,9 +52,7 @@ App::get('/v1/console/variables') $validator = new IP(IP::V6); $isAAAAValid = !empty(System::getEnv('_APP_DOMAIN_TARGET_AAAA', '')) && $validator->isValid(System::getEnv('_APP_DOMAIN_TARGET_AAAA')); - $isDomainEnabled = $isDomainValid && ( - $isAAAAValid || $isAValid || $isCNAMEValid - ); + $isDomainEnabled = $isAAAAValid || $isAValid || $isCNAMEValid; $isVcsEnabled = !empty(System::getEnv('_APP_VCS_GITHUB_APP_NAME', '')) && !empty(System::getEnv('_APP_VCS_GITHUB_PRIVATE_KEY', '')) diff --git a/app/controllers/api/messaging.php b/app/controllers/api/messaging.php index dc53e1cf35..771dd0e6a5 100644 --- a/app/controllers/api/messaging.php +++ b/app/controllers/api/messaging.php @@ -3495,7 +3495,8 @@ App::post('/v1/messaging/messages/push') ->inject('project') ->inject('queueForMessaging') ->inject('response') - ->action(function (string $messageId, string $title, string $body, ?array $topics, ?array $users, ?array $targets, ?array $data, string $action, string $image, string $icon, string $sound, string $color, string $tag, int $badge, bool $draft, ?string $scheduledAt, bool $contentAvailable, bool $critical, string $priority, Event $queueForEvents, Database $dbForProject, Database $dbForPlatform, Document $project, Messaging $queueForMessaging, Response $response) { + ->inject('platform') + ->action(function (string $messageId, string $title, string $body, ?array $topics, ?array $users, ?array $targets, ?array $data, string $action, string $image, string $icon, string $sound, string $color, string $tag, int $badge, bool $draft, ?string $scheduledAt, bool $contentAvailable, bool $critical, string $priority, Event $queueForEvents, Database $dbForProject, Database $dbForPlatform, Document $project, Messaging $queueForMessaging, Response $response, array $platform) { $messageId = $messageId == 'unique()' ? ID::unique() : $messageId; @@ -3551,7 +3552,6 @@ App::post('/v1/messaging/messages/push') throw new Exception(Exception::STORAGE_FILE_TYPE_UNSUPPORTED); } - $host = System::getEnv('_APP_DOMAIN', 'localhost'); $protocol = System::getEnv('_APP_OPTIONS_FORCE_HTTPS') === 'disabled' ? 'http' : 'https'; $scheduleTime = $currentScheduledAt ?? $scheduledAt; @@ -3572,7 +3572,7 @@ App::post('/v1/messaging/messages/push') $image = [ 'bucketId' => $bucket->getId(), 'fileId' => $file->getId(), - 'url' => "{$protocol}://{$host}/v1/storage/buckets/{$bucket->getId()}/files/{$file->getId()}/push?project={$project->getId()}&jwt={$jwt}", + 'url' => "{$platform['endpoint']}/storage/buckets/{$bucket->getId()}/files/{$file->getId()}/push?project={$project->getId()}&jwt={$jwt}", ]; } @@ -4378,7 +4378,8 @@ App::patch('/v1/messaging/messages/push/:messageId') ->inject('project') ->inject('queueForMessaging') ->inject('response') - ->action(function (string $messageId, ?array $topics, ?array $users, ?array $targets, ?string $title, ?string $body, ?array $data, ?string $action, ?string $image, ?string $icon, ?string $sound, ?string $color, ?string $tag, ?int $badge, ?bool $draft, ?string $scheduledAt, ?bool $contentAvailable, ?bool $critical, ?string $priority, Event $queueForEvents, Database $dbForProject, Database $dbForPlatform, Document $project, Messaging $queueForMessaging, Response $response) { + ->inject('platform') + ->action(function (string $messageId, ?array $topics, ?array $users, ?array $targets, ?string $title, ?string $body, ?array $data, ?string $action, ?string $image, ?string $icon, ?string $sound, ?string $color, ?string $tag, ?int $badge, ?bool $draft, ?string $scheduledAt, ?bool $contentAvailable, ?bool $critical, ?string $priority, Event $queueForEvents, Database $dbForProject, Database $dbForPlatform, Document $project, Messaging $queueForMessaging, Response $response, array $platform) { $message = $dbForProject->getDocument('messages', $messageId); if ($message->isEmpty()) { @@ -4546,9 +4547,6 @@ App::patch('/v1/messaging/messages/push/:messageId') throw new Exception(Exception::STORAGE_FILE_TYPE_UNSUPPORTED); } - $host = System::getEnv('_APP_DOMAIN', 'localhost'); - $protocol = System::getEnv('_APP_OPTIONS_FORCE_HTTPS') === 'disabled' ? 'http' : 'https'; - $scheduleTime = $currentScheduledAt ?? $scheduledAt; if (!\is_null($scheduleTime)) { $expiry = (new \DateTime($scheduleTime))->add(new \DateInterval('P15D'))->format('U'); @@ -4567,7 +4565,7 @@ App::patch('/v1/messaging/messages/push/:messageId') $pushData['image'] = [ 'bucketId' => $bucket->getId(), 'fileId' => $file->getId(), - 'url' => "{$protocol}://{$host}/v1/storage/buckets/{$bucket->getId()}/files/{$file->getId()}/push?project={$project->getId()}&jwt={$jwt}" + 'url' => "{$platform['endpoint']}/storage/buckets/{$bucket->getId()}/files/{$file->getId()}/push?project={$project->getId()}&jwt={$jwt}", ]; } diff --git a/app/controllers/api/teams.php b/app/controllers/api/teams.php index b24e3d40cf..5f45c38fed 100644 --- a/app/controllers/api/teams.php +++ b/app/controllers/api/teams.php @@ -58,7 +58,6 @@ use Utopia\Validator\ArrayList; use Utopia\Validator\Assoc; use Utopia\Validator\Boolean; use Utopia\Validator\Text; -use Utopia\Validator\URL; use Utopia\Validator\WhiteList; App::post('/v1/teams') @@ -486,7 +485,7 @@ App::post('/v1/teams/:teamId/memberships') } return new ArrayList(new Key(), APP_LIMIT_ARRAY_PARAMS_SIZE); }, 'Array of strings. Use this param to set the user roles in the team. A role can be any string. Learn more about [roles and permissions](https://appwrite.io/docs/permissions). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' roles are allowed, each 32 characters long.', false, ['project']) - ->param('url', '', fn ($platforms, $devKey) => $devKey->isEmpty() ? new Redirect($platforms) : new URL(), 'URL to redirect the user back to your app from the invitation email. This parameter is not required when an API key is supplied. Only URLs from hostnames in your project platform list are allowed. This requirement helps to prevent an [open redirect](https://cheatsheetseries.owasp.org/cheatsheets/Unvalidated_Redirects_and_Forwards_Cheat_Sheet.html) attack against your project API.', true, ['platforms', 'devKey']) // TODO add our own built-in confirm page + ->param('url', '', fn ($redirectValidator) => $redirectValidator, 'URL to redirect the user back to your app from the invitation email. This parameter is not required when an API key is supplied. Only URLs from hostnames in your project platform list are allowed. This requirement helps to prevent an [open redirect](https://cheatsheetseries.owasp.org/cheatsheets/Unvalidated_Redirects_and_Forwards_Cheat_Sheet.html) attack against your project API.', true, ['redirectValidator']) // TODO add our own built-in confirm page ->param('name', '', new Text(128), 'Name of the new team member. Max length: 128 chars.', true) ->inject('response') ->inject('project') diff --git a/app/controllers/api/vcs.php b/app/controllers/api/vcs.php index c424e6884f..abe5a449e1 100644 --- a/app/controllers/api/vcs.php +++ b/app/controllers/api/vcs.php @@ -4,7 +4,6 @@ use Appwrite\Auth\OAuth2\Github as OAuth2Github; use Appwrite\Event\Build; use Appwrite\Event\Delete; use Appwrite\Extend\Exception; -use Appwrite\Network\Validator\Redirect; use Appwrite\SDK\AuthType; use Appwrite\SDK\ContentType; use Appwrite\SDK\Method; @@ -77,7 +76,7 @@ use Utopia\VCS\Exception\RepositoryNotFound; use function Swoole\Coroutine\batch; -$createGitDeployments = function (GitHub $github, string $providerInstallationId, array $repositories, string $providerBranch, string $providerBranchUrl, string $providerRepositoryName, string $providerRepositoryUrl, string $providerRepositoryOwner, string $providerCommitHash, string $providerCommitAuthor, string $providerCommitAuthorUrl, string $providerCommitMessage, string $providerCommitUrl, string $providerPullRequestId, bool $external, Database $dbForPlatform, Build $queueForBuilds, callable $getProjectDB, Request $request) { +$createGitDeployments = function (GitHub $github, string $providerInstallationId, array $repositories, string $providerBranch, string $providerBranchUrl, string $providerRepositoryName, string $providerRepositoryUrl, string $providerRepositoryOwner, string $providerCommitHash, string $providerCommitAuthor, string $providerCommitAuthorUrl, string $providerCommitMessage, string $providerCommitUrl, string $providerPullRequestId, bool $external, Database $dbForPlatform, Build $queueForBuilds, callable $getProjectDB, array $platform) { $errors = []; foreach ($repositories as $repository) { try { @@ -133,7 +132,7 @@ $createGitDeployments = function (GitHub $github, string $providerInstallationId $commentStatus = $isAuthorized ? 'waiting' : 'failed'; $protocol = System::getEnv('_APP_OPTIONS_FORCE_HTTPS') === 'disabled' ? 'http' : 'https'; - $hostname = System::getEnv('_APP_CONSOLE_DOMAIN', System::getEnv('_APP_DOMAIN', '')); + $hostname = $platform['consoleDomain'] ?? ''; $authorizeUrl = $protocol . '://' . $hostname . "/console/git/authorize-contributor?projectId={$projectId}&installationId={$installationId}&repositoryId={$repositoryId}&providerPullRequestId={$providerPullRequestId}"; @@ -175,7 +174,7 @@ $createGitDeployments = function (GitHub $github, string $providerInstallationId if ($lockAcquired) { // Wrap in try/finally to ensure lock file gets deleted try { - $comment = new Comment(); + $comment = new Comment($platform); $comment->parseComment($github->getComment($owner, $repositoryName, $latestCommentId)); $comment->addBuild($project, $resource, $resourceType, $commentStatus, $deploymentId, $action, ''); @@ -185,7 +184,7 @@ $createGitDeployments = function (GitHub $github, string $providerInstallationId } } } else { - $comment = new Comment(); + $comment = new Comment($platform); $comment->addBuild($project, $resource, $resourceType, $commentStatus, $deploymentId, $action, ''); $latestCommentId = \strval($github->createComment($owner, $repositoryName, $providerPullRequestId, $comment->generateComment())); @@ -246,7 +245,7 @@ $createGitDeployments = function (GitHub $github, string $providerInstallationId if ($lockAcquired) { // Wrap in try/finally to ensure lock file gets deleted try { - $comment = new Comment(); + $comment = new Comment($platform); $comment->parseComment($github->getComment($owner, $repositoryName, $latestCommentId)); $comment->addBuild($project, $resource, $resourceType, $commentStatus, $deploymentId, $action, ''); @@ -467,7 +466,7 @@ $createGitDeployments = function (GitHub $github, string $providerInstallationId $previewUrl = !empty($rule) ? ("{$protocol}://" . $rule->getAttribute('domain', '')) : ''; if (!empty($previewUrl)) { - $comment = new Comment(); + $comment = new Comment($platform); $comment->parseComment($github->getComment($owner, $repositoryName, $latestCommentId)); $comment->addBuild($project, $resource, $resourceType, $commentStatus, $deploymentId, $action, $previewUrl); $github->updateComment($owner, $repositoryName, $latestCommentId, $comment->generateComment()); @@ -542,12 +541,12 @@ App::get('/v1/vcs/github/authorize') type: MethodType::WEBAUTH, hide: true, )) - ->param('success', '', fn ($platforms) => new Redirect($platforms), 'URL to redirect back to console after a successful installation attempt.', true, ['platforms']) - ->param('failure', '', fn ($platforms) => new Redirect($platforms), 'URL to redirect back to console after a failed installation attempt.', true, ['platforms']) - ->inject('request') + ->param('success', '', fn ($redirectValidator) => $redirectValidator, 'URL to redirect back to console after a successful installation attempt.', true, ['redirectValidator']) + ->param('failure', '', fn ($redirectValidator) => $redirectValidator, 'URL to redirect back to console after a failed installation attempt.', true, ['redirectValidator']) ->inject('response') ->inject('project') - ->action(function (string $success, string $failure, Request $request, Response $response, Document $project) { + ->inject('platform') + ->action(function (string $success, string $failure, Response $response, Document $project, array $platform) { $state = \json_encode([ 'projectId' => $project->getId(), 'success' => $success, @@ -556,7 +555,7 @@ App::get('/v1/vcs/github/authorize') $appName = System::getEnv('_APP_VCS_GITHUB_APP_NAME'); $protocol = System::getEnv('_APP_OPTIONS_FORCE_HTTPS') === 'disabled' ? 'http' : 'https'; - $hostname = System::getEnv('_APP_CONSOLE_DOMAIN', System::getEnv('_APP_DOMAIN', '')); + $hostname = $platform['consoleDomain'] ?? ''; if (empty($appName)) { throw new Exception(Exception::GENERAL_SERVER_ERROR, 'GitHub App name is not configured. Please configure VCS (Version Control System) variables in .env file.'); @@ -585,10 +584,10 @@ App::get('/v1/vcs/github/callback') ->inject('gitHub') ->inject('user') ->inject('project') - ->inject('request') ->inject('response') ->inject('dbForPlatform') - ->action(function (string $providerInstallationId, string $setupAction, string $state, string $code, GitHub $github, Document $user, Document $project, Request $request, Response $response, Database $dbForPlatform) { + ->inject('platform') + ->action(function (string $providerInstallationId, string $setupAction, string $state, string $code, GitHub $github, Document $user, Document $project, Response $response, Database $dbForPlatform, array $platform) { if (empty($state)) { $error = 'Installation requests from organisation members for the Appwrite GitHub App are currently unsupported. To proceed with the installation, login to the Appwrite Console and install the GitHub App.'; throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, $error); @@ -615,7 +614,7 @@ App::get('/v1/vcs/github/callback') $region = $project->getAttribute('region', 'default'); $protocol = System::getEnv('_APP_OPTIONS_FORCE_HTTPS') === 'disabled' ? 'http' : 'https'; - $hostname = System::getEnv('_APP_CONSOLE_DOMAIN', System::getEnv('_APP_DOMAIN', '')); + $hostname = $platform['consoleDomain'] ?? ''; $defaultState = [ 'success' => $protocol . '://' . $hostname . "/console/project-$region-$projectId/settings/git-installations", @@ -1479,8 +1478,9 @@ App::post('/v1/vcs/github/events') ->inject('dbForPlatform') ->inject('getProjectDB') ->inject('queueForBuilds') + ->inject('platform') ->action( - function (GitHub $github, Request $request, Response $response, Database $dbForPlatform, callable $getProjectDB, Build $queueForBuilds) use ($createGitDeployments) { + function (GitHub $github, Request $request, Response $response, Database $dbForPlatform, callable $getProjectDB, Build $queueForBuilds, array $platform) use ($createGitDeployments) { $payload = $request->getRawPayload(); $signatureRemote = $request->getHeader('x-hub-signature-256', ''); $signatureLocal = System::getEnv('_APP_VCS_GITHUB_WEBHOOK_SECRET', ''); @@ -1523,7 +1523,7 @@ App::post('/v1/vcs/github/events') // create new deployment only on push (not committed by us) and not when branch is created or deleted if ($providerCommitAuthorEmail !== APP_VCS_GITHUB_EMAIL && !$providerBranchCreated && !$providerBranchDeleted) { - $createGitDeployments($github, $providerInstallationId, $repositories, $providerBranch, $providerBranchUrl, $providerRepositoryName, $providerRepositoryUrl, $providerRepositoryOwner, $providerCommitHash, $providerCommitAuthorName, $providerCommitAuthorUrl, $providerCommitMessage, $providerCommitUrl, '', false, $dbForPlatform, $queueForBuilds, $getProjectDB, $request); + $createGitDeployments($github, $providerInstallationId, $repositories, $providerBranch, $providerBranchUrl, $providerRepositoryName, $providerRepositoryUrl, $providerRepositoryOwner, $providerCommitHash, $providerCommitAuthorName, $providerCommitAuthorUrl, $providerCommitMessage, $providerCommitUrl, '', false, $dbForPlatform, $queueForBuilds, $getProjectDB, $platform); } } elseif ($event == $github::EVENT_INSTALLATION) { if ($parsedPayload["action"] == "deleted") { @@ -1579,7 +1579,7 @@ App::post('/v1/vcs/github/events') Query::orderDesc('$createdAt') ])); - $createGitDeployments($github, $providerInstallationId, $repositories, $providerBranch, $providerBranchUrl, $providerRepositoryName, $providerRepositoryUrl, $providerRepositoryOwner, $providerCommitHash, $providerCommitAuthor, $providerCommitAuthorUrl, $providerCommitMessage, $providerCommitUrl, $providerPullRequestId, $external, $dbForPlatform, $queueForBuilds, $getProjectDB, $request); + $createGitDeployments($github, $providerInstallationId, $repositories, $providerBranch, $providerBranchUrl, $providerRepositoryName, $providerRepositoryUrl, $providerRepositoryOwner, $providerCommitHash, $providerCommitAuthor, $providerCommitAuthorUrl, $providerCommitMessage, $providerCommitUrl, $providerPullRequestId, $external, $dbForPlatform, $queueForBuilds, $getProjectDB, $platform); } elseif ($parsedPayload["action"] == "closed") { // Allowed external contributions cleanup @@ -1783,13 +1783,13 @@ App::patch('/v1/vcs/github/installations/:installationId/repositories/:repositor ->param('repositoryId', '', new Text(256), 'VCS Repository Id') ->param('providerPullRequestId', '', new Text(256), 'GitHub Pull Request Id') ->inject('gitHub') - ->inject('request') ->inject('response') ->inject('project') ->inject('dbForPlatform') ->inject('getProjectDB') ->inject('queueForBuilds') - ->action(function (string $installationId, string $repositoryId, string $providerPullRequestId, GitHub $github, Request $request, Response $response, Document $project, Database $dbForPlatform, callable $getProjectDB, Build $queueForBuilds) use ($createGitDeployments) { + ->inject('platform') + ->action(function (string $installationId, string $repositoryId, string $providerPullRequestId, GitHub $github, Response $response, Document $project, Database $dbForPlatform, callable $getProjectDB, Build $queueForBuilds, array $platform) use ($createGitDeployments) { $installation = $dbForPlatform->getDocument('installations', $installationId); if ($installation->isEmpty()) { @@ -1837,8 +1837,16 @@ App::patch('/v1/vcs/github/installations/:installationId/repositories/:repositor $providerBranch = \explode(':', $pullRequestResponse['head']['label'])[1] ?? ''; $providerCommitHash = $pullRequestResponse['head']['sha'] ?? ''; + $providerBranchUrl = $pullRequestResponse['head']['repo']['html_url'] ?? ''; + $providerRepositoryName = $pullRequestResponse['head']['repo']['name'] ?? ''; + $providerRepositoryUrl = $pullRequestResponse['head']['repo']['html_url'] ?? ''; + $providerRepositoryOwner = $pullRequestResponse['head']['repo']['owner']['login'] ?? ''; + $providerCommitAuthor = $pullRequestResponse['head']['user']['login'] ?? ''; + $providerCommitAuthorUrl = $pullRequestResponse['head']['user']['html_url'] ?? ''; + $providerCommitMessage = $pullRequestResponse['title'] ?? ''; + $providerCommitUrl = $pullRequestResponse['html_url'] ?? ''; - $createGitDeployments($github, $providerInstallationId, $repositories, $providerBranch, $providerCommitHash, $providerPullRequestId, true, $dbForPlatform, $queueForBuilds, $getProjectDB, $request); + $createGitDeployments($github, $providerInstallationId, $repositories, $providerBranch, $providerBranchUrl, $providerRepositoryName, $providerRepositoryUrl, $providerRepositoryOwner, $providerCommitHash, $providerCommitAuthor, $providerCommitAuthorUrl, $providerCommitMessage, $providerCommitUrl, $providerPullRequestId, true, $dbForPlatform, $queueForBuilds, $getProjectDB, $platform); $response->noContent(); }); diff --git a/app/controllers/general.php b/app/controllers/general.php index f034da6b24..70ae3540d1 100644 --- a/app/controllers/general.php +++ b/app/controllers/general.php @@ -10,7 +10,7 @@ use Appwrite\Event\Event; use Appwrite\Event\Func; use Appwrite\Event\StatsUsage; use Appwrite\Extend\Exception as AppwriteException; -use Appwrite\Network\Validator\Origin; +use Appwrite\Network\Cors; use Appwrite\Platform\Appwrite; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; @@ -39,6 +39,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\Helpers\ID; use Utopia\Database\Query; use Utopia\Database\Validator\Authorization; @@ -51,33 +52,35 @@ use Utopia\Logger\Log\User; use Utopia\Logger\Logger; use Utopia\Platform\Service; use Utopia\System\System; +use Utopia\Validator; use Utopia\Validator\Text; Config::setParam('domainVerification', false); Config::setParam('cookieDomain', 'localhost'); Config::setParam('cookieSamesite', Response::COOKIE_SAMESITE_NONE); -function router(App $utopia, Database $dbForPlatform, callable $getProjectDB, SwooleRequest $swooleRequest, Request $request, Response $response, Log $log, Event $queueForEvents, StatsUsage $queueForStatsUsage, Func $queueForFunctions, Executor $executor, Reader $geodb, callable $isResourceBlocked, string $previewHostname, ?Key $apiKey) +function router(App $utopia, Database $dbForPlatform, callable $getProjectDB, SwooleRequest $swooleRequest, Request $request, Response $response, Log $log, Event $queueForEvents, StatsUsage $queueForStatsUsage, Func $queueForFunctions, Executor $executor, Reader $geodb, callable $isResourceBlocked, array $platform, string $previewHostname, ?Key $apiKey, array $domains) { $host = $request->getHostname() ?? ''; if (!empty($previewHostname)) { $host = $previewHostname; } - // TODO: @christyjacob remove once we migrate the rules in 1.7.x - if (System::getEnv('_APP_RULES_FORMAT') === 'md5') { - $rule = Authorization::skip(fn () => $dbForPlatform->getDocument('rules', md5($host))); - } else { - $rule = Authorization::skip( - fn () => $dbForPlatform->find('rules', [ - Query::equal('domain', [$host]), - Query::limit(1) - ]) - )[0] ?? new Document(); - } + // TODO: (@Meldiron) Remove after 1.7.x migration + $isMd5 = System::getEnv('_APP_RULES_FORMAT') === 'md5'; + $rule = Authorization::skip(function () use ($dbForPlatform, $host, $isMd5) { + if ($isMd5) { + return $dbForPlatform->getDocument('rules', md5($host)); + } + + return $dbForPlatform->findOne('rules', [ + Query::equal('domain', [$host]), + ]) ?? new Document(); + }); $errorView = __DIR__ . '/../views/general/error.phtml'; - $url = (System::getEnv('_APP_OPTIONS_FORCE_HTTPS') == 'disabled' ? 'http' : 'https') . '://' . System::getEnv('_APP_CONSOLE_DOMAIN', System::getEnv('_APP_DOMAIN', '')); + $protocol = System::getEnv('_APP_OPTIONS_FORCE_HTTPS') == 'disabled' ? 'http' : 'https'; + $url = $protocol . '://' . $platform['consoleDomain']; if ($rule->isEmpty()) { $appDomainFunctionsFallback = System::getEnv('_APP_DOMAIN_FUNCTIONS_FALLBACK', ''); @@ -98,10 +101,8 @@ function router(App $utopia, Database $dbForPlatform, callable $getProjectDB, Sw throw $exception; } - if (System::getEnv('_APP_OPTIONS_ROUTER_PROTECTION', 'disabled') === 'enabled') { - if ($host !== 'localhost' && $host !== APP_HOSTNAME_INTERNAL && $host !== System::getEnv('_APP_CONSOLE_DOMAIN', '')) { - throw new AppwriteException(AppwriteException::GENERAL_ACCESS_FORBIDDEN, 'Router protection does not allow accessing Appwrite over this domain. Please add it as custom domain to your project or disable _APP_OPTIONS_ROUTER_PROTECTION environment variable.', view: $errorView); - } + if (!in_array($host, $domains)) { + throw new AppwriteException(AppwriteException::GENERAL_ACCESS_FORBIDDEN, 'Router protection does not allow accessing Appwrite over this domain. Please add it as custom domain to your project or disable _APP_OPTIONS_ROUTER_PROTECTION environment variable.', view: $errorView); } // Act as API - no Proxy logic @@ -143,7 +144,7 @@ function router(App $utopia, Database $dbForPlatform, callable $getProjectDB, Sw if ($type === 'deployment') { if (System::getEnv('_APP_OPTIONS_ROUTER_FORCE_HTTPS', 'disabled') === 'enabled') { // Force HTTPS - if ($request->getProtocol() !== 'https' && $request->getHostname() !== APP_HOSTNAME_INTERNAL) { + if ($request->getProtocol() !== 'https') { if ($request->getMethod() !== Request::METHOD_GET) { throw new AppwriteException(AppwriteException::GENERAL_PROTOCOL_UNSUPPORTED, 'Method unsupported over HTTP. Please use HTTPS instead.', view: $errorView); } @@ -268,7 +269,7 @@ function router(App $utopia, Database $dbForPlatform, callable $getProjectDB, Sw } if (!$authorized) { - $url = (System::getEnv('_APP_OPTIONS_FORCE_HTTPS') == 'disabled' ? 'http' : 'https') . "://" . System::getEnv('_APP_CONSOLE_DOMAIN', System::getEnv('_APP_DOMAIN', '')); + $url = $protocol . "://" . $platform['consoleDomain']; $response ->addHeader('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0') ->addHeader('Pragma', 'no-cache') @@ -456,14 +457,10 @@ function router(App $utopia, Database $dbForPlatform, callable $getProjectDB, Sw $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 if ($type === 'function') { $vars = \array_merge($vars, [ - 'APPWRITE_FUNCTION_API_ENDPOINT' => $endpoint, + 'APPWRITE_FUNCTION_API_ENDPOINT' => $platform['endpoint'], 'APPWRITE_FUNCTION_ID' => $resource->getId(), 'APPWRITE_FUNCTION_NAME' => $resource->getAttribute('name'), 'APPWRITE_FUNCTION_DEPLOYMENT' => $deployment->getId(), @@ -475,7 +472,7 @@ function router(App $utopia, Database $dbForPlatform, callable $getProjectDB, Sw ]); } elseif ($type === 'site') { $vars = \array_merge($vars, [ - 'APPWRITE_SITE_API_ENDPOINT' => $endpoint, + 'APPWRITE_SITE_API_ENDPOINT' => $platform['endpoint'], 'APPWRITE_SITE_ID' => $resource->getId(), 'APPWRITE_SITE_NAME' => $resource->getAttribute('name'), 'APPWRITE_SITE_DEPLOYMENT' => $deployment->getId(), @@ -845,34 +842,31 @@ App::init() ->inject('request') ->inject('response') ->inject('log') - ->inject('console') ->inject('project') ->inject('dbForPlatform') ->inject('getProjectDB') ->inject('locale') ->inject('localeCodes') - ->inject('platforms') ->inject('geodb') ->inject('queueForStatsUsage') ->inject('queueForEvents') - ->inject('queueForCertificates') ->inject('queueForFunctions') ->inject('executor') + ->inject('platform') ->inject('isResourceBlocked') ->inject('previewHostname') ->inject('devKey') ->inject('apiKey') - ->inject('httpReferrer') - ->inject('httpReferrerSafe') - ->action(function (App $utopia, SwooleRequest $swooleRequest, Request $request, Response $response, Log $log, Document $console, Document $project, Database $dbForPlatform, callable $getProjectDB, Locale $locale, array $localeCodes, array $platforms, Reader $geodb, StatsUsage $queueForStatsUsage, Event $queueForEvents, Certificate $queueForCertificates, Func $queueForFunctions, Executor $executor, callable $isResourceBlocked, string $previewHostname, Document $devKey, ?Key $apiKey, string $httpReferrer, string $httpReferrerSafe) { + ->inject('cors') + ->inject('domains') + ->action(function (App $utopia, SwooleRequest $swooleRequest, Request $request, Response $response, Log $log, Document $project, Database $dbForPlatform, callable $getProjectDB, Locale $locale, array $localeCodes, Reader $geodb, StatsUsage $queueForStatsUsage, Event $queueForEvents, Func $queueForFunctions, Executor $executor, array $platform, callable $isResourceBlocked, string $previewHostname, Document $devKey, ?Key $apiKey, Cors $cors, array $domains) { /* * Appwrite Router */ - $host = $request->getHostname() ?? ''; - $mainDomain = System::getEnv('_APP_DOMAIN', ''); + $hostname = $request->getHostname() ?? ''; // Only run Router when external domain - if ($host !== $mainDomain || !empty($previewHostname)) { - if (router($utopia, $dbForPlatform, $getProjectDB, $swooleRequest, $request, $response, $log, $queueForEvents, $queueForStatsUsage, $queueForFunctions, $executor, $geodb, $isResourceBlocked, $previewHostname, $apiKey)) { + if (!in_array($hostname, $domains) || !empty($previewHostname)) { + if (router($utopia, $dbForPlatform, $getProjectDB, $swooleRequest, $request, $response, $log, $queueForEvents, $queueForStatsUsage, $queueForFunctions, $executor, $geodb, $isResourceBlocked, $platform, $previewHostname, $apiKey, $domains)) { $utopia->getRoute()?->label('router', true); } } @@ -912,98 +906,12 @@ App::init() } } - $domain = $request->getHostname(); - $domains = Config::getParam('domains', []); - if (!array_key_exists($domain, $domains)) { - $domain = new Domain(!empty($domain) ? $domain : ''); - - if (empty($domain->get()) || !$domain->isKnown() || $domain->isTest()) { - $domains[$domain->get()] = false; - Console::warning($domain->get() . ' is not a publicly accessible domain. Skipping SSL certificate generation.'); - } elseif (str_starts_with($request->getURI(), '/.well-known/acme-challenge')) { - Console::warning('Skipping SSL certificates generation on ACME challenge.'); - } else { - Authorization::disable(); - - $envDomain = System::getEnv('_APP_DOMAIN', ''); - $mainDomain = null; - if (!empty($envDomain) && $envDomain !== 'localhost') { - $mainDomain = $envDomain; - } else { - // TODO: @christyjacob remove once we migrate the rules in 1.7.x - if (System::getEnv('_APP_RULES_FORMAT') === 'md5') { - $domainDocument = $dbForPlatform->getDocument('rules', md5($envDomain)); - } else { - $domainDocument = $dbForPlatform->findOne('rules', [Query::orderAsc('$id')]); - } - $mainDomain = !$domainDocument->isEmpty() ? $domainDocument->getAttribute('domain') : $domain->get(); - } - - if ($mainDomain !== $domain->get()) { - Console::warning($domain->get() . ' is not a main domain. Skipping SSL certificate generation.'); - } else { - // TODO: @christyjacob remove once we migrate the rules in 1.7.x - if (System::getEnv('_APP_RULES_FORMAT') === 'md5') { - $domainDocument = $dbForPlatform->getDocument('rules', md5($domain->get())); - } else { - $domainDocument = $dbForPlatform->findOne('rules', [ - Query::equal('domain', [$domain->get()]) - ]); - } - - $owner = ''; - $functionsDomainFallback = System::getEnv('_APP_DOMAIN_FUNCTIONS_FALLBACK', ''); - $functionsDomain = System::getEnv('_APP_DOMAIN_FUNCTIONS', ''); - $siteDomain = System::getEnv('_APP_DOMAIN_SITES', ''); - if (!empty($functionsDomainFallback) && \str_ends_with($host, $functionsDomainFallback)) { - $functionsDomain = $functionsDomainFallback; - } - - if ( - (!empty($functionsDomain) && \str_ends_with($domain->get(), $functionsDomain)) || - (!empty($siteDomain) && \str_ends_with($domain->get(), $siteDomain)) - ) { - $owner = 'Appwrite'; - } - - 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' => $ruleId, - 'domain' => $domain->get(), - 'type' => 'api', - 'status' => 'verifying', - 'projectId' => $console->getId(), - 'projectInternalId' => $console->getSequence(), - 'search' => implode(' ', [$ruleId, $domain->get()]), - 'owner' => $owner, - 'region' => $console->getAttribute('region') - ]); - - $domainDocument = $dbForPlatform->createDocument('rules', $domainDocument); - - Console::info('Issuing a TLS certificate for the main domain (' . $domain->get() . ') in a few seconds...'); - - $queueForCertificates - ->setDomain($domainDocument) - ->setSkipRenewCheck(true) - ->trigger(); - } - } - $domains[$domain->get()] = true; - - Authorization::reset(); // ensure authorization is re-enabled - } - Config::setParam('domains', $domains); - } - $localeParam = (string) $request->getParam('locale', $request->getHeader('x-appwrite-locale', '')); if (\in_array($localeParam, $localeCodes)) { $locale->setDefault($localeParam); } - $origin = \parse_url($request->getOrigin($httpReferrer), PHP_URL_HOST); + $origin = \parse_url($request->getOrigin($request->getReferer('')), PHP_URL_HOST); $selfDomain = new Domain($request->getHostname()); $endDomain = new Domain((string)$origin); Config::setParam( @@ -1032,8 +940,8 @@ App::init() $warnings = []; /* - * Response format - */ + * Response format + */ $responseFormat = $request->getHeader('x-appwrite-response-format', System::getEnv('_APP_SYSTEM_RESPONSE_FORMAT', '')); if ($responseFormat) { if (version_compare($responseFormat, '1.4.0', '<')) { @@ -1053,14 +961,13 @@ App::init() } } - /* - * Security Headers - * - * As recommended at: - * @see https://www.owasp.org/index.php/List_of_useful_HTTP_headers - */ + // Add Appwrite warning headers + if (!empty($warnings)) { + $response->addHeader('X-Appwrite-Warning', implode(';', $warnings)); + } + if (System::getEnv('_APP_OPTIONS_FORCE_HTTPS', 'disabled') === 'enabled') { // Force HTTPS - if ($request->getProtocol() !== 'https' && ($swooleRequest->header['host'] ?? '') !== 'localhost' && ($swooleRequest->header['host'] ?? '') !== APP_HOSTNAME_INTERNAL) { // localhost allowed for proxy, APP_HOSTNAME_INTERNAL allowed for migrations + if ($request->getProtocol() !== 'https' && ($swooleRequest->header['host'] ?? '') !== 'localhost') { // localhost allowed for proxy, APP_HOSTNAME_INTERNAL allowed for migrations if ($request->getMethod() !== Request::METHOD_GET) { throw new AppwriteException(AppwriteException::GENERAL_PROTOCOL_UNSUPPORTED, 'Method unsupported over HTTP. Please use HTTPS instead.'); } @@ -1068,49 +975,150 @@ App::init() return $response->redirect('https://' . $request->getHostname() . $request->getURI()); } } + }); - if ($request->getProtocol() === 'https') { - $response->addHeader('Strict-Transport-Security', 'max-age=' . (60 * 60 * 24 * 126)); // 126 days +/** + * Security headers + * + * @see https://www.owasp.org/index.php/List_of_useful_HTTP_headers + */ +App::init() + ->groups(['api', 'web']) + ->inject('request') + ->inject('response') + ->inject('cors') + ->inject('devKey') + ->inject('originValidator') + ->action(function (Request $request, Response $response, Cors $cors, Document $devKey, Validator $originValidator) { + // CORS headers + foreach ($cors->headers($request->getOrigin()) as $name => $value) { + $response->addHeader($name, $value); } + // Security headers $response ->addHeader('Server', 'Appwrite') - ->addHeader('X-Content-Type-Options', 'nosniff') - ->addHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE') - ->addHeader('Access-Control-Allow-Headers', 'Origin, Cookie, Set-Cookie, X-Requested-With, Content-Type, Access-Control-Allow-Origin, Access-Control-Request-Headers, Accept, X-Appwrite-Project, X-Appwrite-Key, X-Appwrite-Dev-Key, X-Appwrite-Locale, X-Appwrite-Mode, X-Appwrite-JWT, X-Appwrite-Response-Format, X-Appwrite-Timeout, X-SDK-Version, X-SDK-Name, X-SDK-Language, X-SDK-Platform, X-SDK-GraphQL, X-Appwrite-ID, X-Appwrite-Timestamp, Content-Range, Range, Cache-Control, Expires, Pragma, X-Forwarded-For, X-Forwarded-User-Agent') - ->addHeader('Access-Control-Expose-Headers', 'X-Appwrite-Session, X-Fallback-Cookies') - ->addHeader('Access-Control-Allow-Origin', $httpReferrerSafe) - ->addHeader('Access-Control-Allow-Credentials', 'true'); + ->addHeader('X-Content-Type-Options', 'nosniff'); - if (!$devKey->isEmpty()) { - $response->addHeader('Access-Control-Allow-Origin', '*'); + if ($request->getProtocol() === 'https') { + $maxAge = 60 * 60 * 24 * 126; // 126 days + $response->addHeader('Strict-Transport-Security', "max-age=$maxAge"); } - if (!empty($warnings)) { - $response->addHeader('X-Appwrite-Warning', implode(';', $warnings)); + // Application level CSRF protection + $origin = $request->getOrigin(); + if (empty($origin) || !$devKey->isEmpty() || !empty($request->getHeader('x-appwrite-key'))) { + return; } - - /* - * Validate Client Domain - Check to avoid CSRF attack - * Adding Appwrite API domains to allow XDOMAIN communication - * Skip this check for non-web platforms which are not required to send an origin header - */ - $origin = $request->getOrigin($request->getReferer('')); - $originValidator = new Origin($platforms); - - if ( - $devKey->isEmpty() - && !empty($origin) - && !$originValidator->isValid($origin) - && \in_array($request->getMethod(), [Request::METHOD_POST, Request::METHOD_PUT, Request::METHOD_PATCH, Request::METHOD_DELETE]) - && $route->getLabel('origin', false) !== '*' - && empty($request->getHeader('x-appwrite-key', '')) - && \parse_url($httpReferrerSafe, PHP_URL_HOST) === 'localhost' - ) { + $route = $request->getRoute(); + if ($route->getLabel('origin', false) === '*') { + return; + } + if (!$originValidator->isValid($origin)) { throw new AppwriteException(AppwriteException::GENERAL_UNKNOWN_ORIGIN, $originValidator->getDescription()); } }); +/** + * Automatic certificate generation + */ +App::init() + ->groups(['api', 'web']) + ->inject('request') + ->inject('console') + ->inject('dbForPlatform') + ->inject('queueForCertificates') + ->inject('domains') + ->action(function (Request $request, Document $console, Database $dbForPlatform, Certificate $queueForCertificates, array $domains) { + $hostname = $request->getHostname(); + $cache = Config::getParam('domains', []); + + // 1. Cache hit + if (array_key_exists($hostname, $cache)) { + return; + } + + // 2. Domain validation + $domain = new Domain(!empty($hostname) ? $hostname : ''); + if (empty($domain->get()) || !$domain->isKnown() || $domain->isTest()) { + $cache[$domain->get()] = false; + Config::setParam('domains', $cache); + Console::warning($domain->get() . ' is not a publicly accessible domain. Skipping SSL certificate generation.'); + return; + } + + if (str_starts_with($request->getURI(), '/.well-known/acme-challenge')) { + Console::warning('Skipping SSL certificates generation on ACME challenge.'); + return; + } + + // 3. Check if domain is a main domain + if (!in_array($domain->get(), $domains)) { + Console::warning($domain->get() . ' is not a main domain. Skipping SSL certificate generation.'); + return; + } + + // 4. Check/create rule (requires DB access) + Authorization::disable(); + try { + // TODO: (@Meldiron) Remove after 1.7.x migration + $isMd5 = System::getEnv('_APP_RULES_FORMAT') === 'md5'; + $document = $isMd5 + ? $dbForPlatform->getDocument('rules', md5($domain->get())) + : $dbForPlatform->findOne('rules', [ + Query::equal('domain', [$domain->get()]), + ]); + + if (!$document->isEmpty()) { + return; + } + + // 5. Create new rule + $owner = ''; + $fallback = System::getEnv('_APP_DOMAIN_FUNCTIONS_FALLBACK', ''); + $funcDomain = System::getEnv('_APP_DOMAIN_FUNCTIONS', ''); + $siteDomain = System::getEnv('_APP_DOMAIN_SITES', ''); + + if (!empty($fallback) && \str_ends_with($domain->get(), $fallback)) { + $funcDomain = $fallback; + } + + if ( + (!empty($funcDomain) && \str_ends_with($domain->get(), $funcDomain)) || + (!empty($siteDomain) && \str_ends_with($domain->get(), $siteDomain)) + ) { + $owner = 'Appwrite'; + } + + $ruleId = $isMd5 ? md5($domain->get()) : ID::unique(); + $document = new Document([ + '$id' => $ruleId, + 'domain' => $domain->get(), + 'type' => 'api', + 'status' => 'verifying', + 'projectId' => $console->getId(), + 'projectInternalId' => $console->getSequence(), + 'search' => implode(' ', [$ruleId, $domain->get()]), + 'owner' => $owner, + 'region' => $console->getAttribute('region') + ]); + + $dbForPlatform->createDocument('rules', $document); + + Console::info('Issuing a TLS certificate for the main domain (' . $domain->get() . ') in a few seconds...'); + $queueForCertificates + ->setDomain($document) + ->setSkipRenewCheck(true) + ->trigger(); + } catch (Duplicate $e) { + Console::info('Certificate already exists'); + } finally { + $cache[$domain->get()] = true; + Config::setParam('domains', $cache); + Authorization::reset(); + } + }); + App::options() ->inject('utopia') ->inject('swooleRequest') @@ -1125,38 +1133,32 @@ App::options() ->inject('executor') ->inject('geodb') ->inject('isResourceBlocked') + ->inject('platform') ->inject('previewHostname') ->inject('project') ->inject('devKey') ->inject('apiKey') - ->action(function (App $utopia, SwooleRequest $swooleRequest, Request $request, Response $response, Log $log, Database $dbForPlatform, callable $getProjectDB, Event $queueForEvents, StatsUsage $queueForStatsUsage, Func $queueForFunctions, Executor $executor, Reader $geodb, callable $isResourceBlocked, string $previewHostname, Document $project, Document $devKey, ?Key $apiKey) { + ->inject('domains') + ->inject('cors') + ->action(function (App $utopia, SwooleRequest $swooleRequest, Request $request, Response $response, Log $log, Database $dbForPlatform, callable $getProjectDB, Event $queueForEvents, StatsUsage $queueForStatsUsage, Func $queueForFunctions, Executor $executor, Reader $geodb, callable $isResourceBlocked, array $platform, string $previewHostname, Document $project, Document $devKey, ?Key $apiKey, array $domains, Cors $cors) { /* * Appwrite Router */ - $host = $request->getHostname() ?? ''; - $mainDomain = System::getEnv('_APP_DOMAIN', ''); // Only run Router when external domain - if ($host !== $mainDomain || !empty($previewHostname)) { - if (router($utopia, $dbForPlatform, $getProjectDB, $swooleRequest, $request, $response, $log, $queueForEvents, $queueForStatsUsage, $queueForFunctions, $executor, $geodb, $isResourceBlocked, $previewHostname, $apiKey)) { + if (!in_array($request->getHostname(), $domains) || !empty($previewHostname)) { + if (router($utopia, $dbForPlatform, $getProjectDB, $swooleRequest, $request, $response, $log, $queueForEvents, $queueForStatsUsage, $queueForFunctions, $executor, $geodb, $isResourceBlocked, $platform, $previewHostname, $apiKey)) { $utopia->getRoute()?->label('router', true); } } - $origin = $request->getOrigin(); + foreach ($cors->headers($request->getOrigin()) as $name => $value) { + $response->addHeader($name, $value); + } $response ->addHeader('Server', 'Appwrite') - ->addHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE') - ->addHeader('Access-Control-Allow-Headers', 'Origin, Cookie, Set-Cookie, X-Requested-With, Content-Type, Access-Control-Allow-Origin, Access-Control-Request-Headers, Accept, X-Appwrite-Project, X-Appwrite-Key, X-Appwrite-Dev-Key, X-Appwrite-Locale, X-Appwrite-Mode, X-Appwrite-JWT, X-Appwrite-Response-Format, X-Appwrite-Timeout, X-SDK-Version, X-SDK-Name, X-SDK-Language, X-SDK-Platform, X-SDK-GraphQL, X-Appwrite-ID, X-Appwrite-Timestamp, Content-Range, Range, Cache-Control, Expires, Pragma, X-Appwrite-Session, X-Fallback-Cookies, X-Forwarded-For, X-Forwarded-User-Agent') - ->addHeader('Access-Control-Expose-Headers', 'X-Appwrite-Session, X-Fallback-Cookies') - ->addHeader('Access-Control-Allow-Origin', $origin) - ->addHeader('Access-Control-Allow-Credentials', 'true') ->noContent(); - if (!$devKey->isEmpty()) { - $response->addHeader('Access-Control-Allow-Origin', '*'); - } - /** OPTIONS requests in utopia do not execute shutdown handlers, as a result we need to track the OPTIONS requests explicitly * @see https://github.com/utopia-php/http/blob/0.33.16/src/App.php#L825-L855 */ @@ -1443,18 +1445,16 @@ App::get('/robots.txt') ->inject('executor') ->inject('geodb') ->inject('isResourceBlocked') + ->inject('platform') ->inject('previewHostname') ->inject('apiKey') - ->action(function (App $utopia, SwooleRequest $swooleRequest, Request $request, Response $response, Log $log, Database $dbForPlatform, callable $getProjectDB, Event $queueForEvents, StatsUsage $queueForStatsUsage, Func $queueForFunctions, Executor $executor, Reader $geodb, callable $isResourceBlocked, string $previewHostname, ?Key $apiKey) { - $host = $request->getHostname() ?? ''; - $consoleDomain = System::getEnv('_APP_CONSOLE_DOMAIN', ''); - $mainDomain = System::getEnv('_APP_DOMAIN', ''); - - if (($host === $consoleDomain || $host === $mainDomain || $host === 'localhost') && empty($previewHostname)) { + ->inject('domains') + ->action(function (App $utopia, SwooleRequest $swooleRequest, Request $request, Response $response, Log $log, Database $dbForPlatform, callable $getProjectDB, Event $queueForEvents, StatsUsage $queueForStatsUsage, Func $queueForFunctions, Executor $executor, Reader $geodb, callable $isResourceBlocked, array $platform, string $previewHostname, ?Key $apiKey, array $domains) { + if (in_array($request->getHostname(), $domains) || !empty($previewHostname)) { $template = new View(__DIR__ . '/../views/general/robots.phtml'); $response->text($template->render(false)); } else { - if (router($utopia, $dbForPlatform, $getProjectDB, $swooleRequest, $request, $response, $log, $queueForEvents, $queueForStatsUsage, $queueForFunctions, $executor, $geodb, $isResourceBlocked, $previewHostname, $apiKey)) { + if (router($utopia, $dbForPlatform, $getProjectDB, $swooleRequest, $request, $response, $log, $queueForEvents, $queueForStatsUsage, $queueForFunctions, $executor, $geodb, $isResourceBlocked, $platform, $previewHostname, $apiKey)) { $utopia->getRoute()?->label('router', true); } } @@ -1477,18 +1477,16 @@ App::get('/humans.txt') ->inject('executor') ->inject('geodb') ->inject('isResourceBlocked') + ->inject('platform') ->inject('previewHostname') ->inject('apiKey') - ->action(function (App $utopia, SwooleRequest $swooleRequest, Request $request, Response $response, Log $log, Database $dbForPlatform, callable $getProjectDB, Event $queueForEvents, StatsUsage $queueForStatsUsage, Func $queueForFunctions, Executor $executor, Reader $geodb, callable $isResourceBlocked, string $previewHostname, ?Key $apiKey) { - $host = $request->getHostname() ?? ''; - $consoleDomain = System::getEnv('_APP_CONSOLE_DOMAIN', ''); - $mainDomain = System::getEnv('_APP_DOMAIN', ''); - - if (($host === $consoleDomain || $host === $mainDomain || $host === 'localhost') && empty($previewHostname)) { + ->inject('domains') + ->action(function (App $utopia, SwooleRequest $swooleRequest, Request $request, Response $response, Log $log, Database $dbForPlatform, callable $getProjectDB, Event $queueForEvents, StatsUsage $queueForStatsUsage, Func $queueForFunctions, Executor $executor, Reader $geodb, callable $isResourceBlocked, array $platform, string $previewHostname, ?Key $apiKey, array $domains) { + if (in_array($request->getHostname(), $domains) || !empty($previewHostname)) { $template = new View(__DIR__ . '/../views/general/humans.phtml'); $response->text($template->render(false)); } else { - if (router($utopia, $dbForPlatform, $getProjectDB, $swooleRequest, $request, $response, $log, $queueForEvents, $queueForStatsUsage, $queueForFunctions, $executor, $geodb, $isResourceBlocked, $previewHostname, $apiKey)) { + if (router($utopia, $dbForPlatform, $getProjectDB, $swooleRequest, $request, $response, $log, $queueForEvents, $queueForStatsUsage, $queueForFunctions, $executor, $geodb, $isResourceBlocked, $platform, $previewHostname, $apiKey)) { $utopia->getRoute()?->label('router', true); } } diff --git a/app/controllers/mock.php b/app/controllers/mock.php index 40ddae8f30..d78bb61481 100644 --- a/app/controllers/mock.php +++ b/app/controllers/mock.php @@ -15,7 +15,6 @@ use Utopia\Database\Helpers\Role; use Utopia\Database\Validator\UID; use Utopia\Locale\Locale; use Utopia\System\System; -use Utopia\Validator\Host; use Utopia\Validator\Text; use Utopia\Validator\WhiteList; use Utopia\VCS\Adapter\Git\GitHub; @@ -27,7 +26,7 @@ App::get('/v1/mock/tests/general/oauth2') ->label('docs', false) ->label('mock', true) ->param('client_id', '', new Text(100), 'OAuth2 Client ID.') - ->param('redirect_uri', '', new Host(['localhost']), 'OAuth2 Redirect URI.') // Important to deny an open redirect attack + ->param('redirect_uri', '', fn ($redirectValidator) => $redirectValidator, 'URL to redirect back to your app after a failed login attempt. Only URLs from hostnames in your project\'s platform list are allowed. This requirement helps to prevent an [open redirect](https://cheatsheetseries.owasp.org/cheatsheets/Unvalidated_Redirects_and_Forwards_Cheat_Sheet.html) attack against your project API.', true, ['redirectValidator']) // Important to deny an open redirect attack ->param('scope', '', new Text(100), 'OAuth2 scope list.') ->param('state', '', new Text(1024), 'OAuth2 state.') ->inject('response') @@ -64,7 +63,7 @@ App::get('/v1/mock/tests/general/oauth2/token') ->param('client_id', '', new Text(100), 'OAuth2 Client ID.') ->param('client_secret', '', new Text(100), 'OAuth2 scope list.') ->param('grant_type', 'authorization_code', new WhiteList(['refresh_token', 'authorization_code']), 'OAuth2 Grant Type.', true) - ->param('redirect_uri', '', new Host(['localhost']), 'OAuth2 Redirect URI.', true) + ->param('redirect_uri', '', fn ($redirectValidator) => $redirectValidator, 'URL to redirect back to your app after a successful login attempt. Only URLs from hostnames in your project\'s platform list are allowed. This requirement helps to prevent an [open redirect](https://cheatsheetseries.owasp.org/cheatsheets/Unvalidated_Redirects_and_Forwards_Cheat_Sheet.html) attack against your project API.', true, ['redirectValidator']) ->param('code', '', new Text(100), 'OAuth2 state.', true) ->param('refresh_token', '', new Text(100), 'OAuth2 refresh token.', true) ->inject('response') diff --git a/app/controllers/shared/api.php b/app/controllers/shared/api.php index 16d44481b6..8467468ed6 100644 --- a/app/controllers/shared/api.php +++ b/app/controllers/shared/api.php @@ -8,7 +8,9 @@ use Appwrite\Event\Database as EventDatabase; use Appwrite\Event\Delete; use Appwrite\Event\Event; use Appwrite\Event\Func; +use Appwrite\Event\Mail; use Appwrite\Event\Messaging; +use Appwrite\Event\Migration; use Appwrite\Event\Realtime; use Appwrite\Event\StatsUsage; use Appwrite\Event\Webhook; @@ -495,6 +497,9 @@ App::init() ->inject('queueForDatabase') ->inject('queueForBuilds') ->inject('queueForStatsUsage') + ->inject('queueForFunctions') + ->inject('queueForMails') + ->inject('queueForMigrations') ->inject('dbForProject') ->inject('timelimit') ->inject('resourceToken') @@ -503,7 +508,8 @@ App::init() ->inject('plan') ->inject('devKey') ->inject('telemetry') - ->action(function (App $utopia, Request $request, Response $response, Document $project, Document $user, Publisher $publisher, Publisher $publisherFunctions, Publisher $publisherWebhooks, Event $queueForEvents, Messaging $queueForMessaging, Audit $queueForAudits, Delete $queueForDeletes, EventDatabase $queueForDatabase, Build $queueForBuilds, StatsUsage $queueForStatsUsage, Database $dbForProject, callable $timelimit, Document $resourceToken, string $mode, ?Key $apiKey, array $plan, Document $devKey, Telemetry $telemetry) use ($usageDatabaseListener, $eventDatabaseListener) { + ->inject('platform') + ->action(function (App $utopia, Request $request, Response $response, Document $project, Document $user, Publisher $publisher, Publisher $publisherFunctions, Publisher $publisherWebhooks, Event $queueForEvents, Messaging $queueForMessaging, Audit $queueForAudits, Delete $queueForDeletes, EventDatabase $queueForDatabase, Build $queueForBuilds, StatsUsage $queueForStatsUsage, Func $queueForFunctions, Mail $queueForMails, Migration $queueForMigrations, Database $dbForProject, callable $timelimit, Document $resourceToken, string $mode, ?Key $apiKey, array $plan, Document $devKey, Telemetry $telemetry, array $platform) use ($usageDatabaseListener, $eventDatabaseListener) { $route = $utopia->getRoute(); @@ -577,6 +583,10 @@ App::init() } } + /** + * TODO: (@loks0n) + * Avoid mutating the message across file boundaries - it's difficult to reason about at scale. + */ /* * Background Jobs */ @@ -607,10 +617,18 @@ App::init() } } + /* Auto-set projects */ $queueForDeletes->setProject($project); $queueForDatabase->setProject($project); - $queueForBuilds->setProject($project); $queueForMessaging->setProject($project); + $queueForFunctions->setProject($project); + $queueForBuilds->setProject($project); + + /* Auto-set platforms */ + $queueForFunctions->setPlatform($platform); + $queueForBuilds->setPlatform($platform); + $queueForMails->setPlatform($platform); + $queueForMigrations->setPlatform($platform); // Clone the queues, to prevent events triggered by the database listener // from overwriting the events that are supposed to be triggered in the shutdown hook. diff --git a/app/controllers/web/home.php b/app/controllers/web/home.php index 27b2614c37..63dbed8d32 100644 --- a/app/controllers/web/home.php +++ b/app/controllers/web/home.php @@ -10,7 +10,7 @@ App::get('/versions') ->label('scope', 'public') ->inject('response') ->action(function (Response $response) { - $platforms = Config::getParam('platforms'); + $platforms = Config::getParam('sdks'); $versions = [ 'server' => APP_VERSION_STABLE, diff --git a/app/init/configs.php b/app/init/configs.php index 6fa3b576d5..19be7755dd 100644 --- a/app/init/configs.php +++ b/app/init/configs.php @@ -15,7 +15,8 @@ Config::load('auth', __DIR__ . '/../config/auth.php', $configAdapter); Config::load('apis', __DIR__ . '/../config/apis.php', $configAdapter); // List of APIs Config::load('errors', __DIR__ . '/../config/errors.php', $configAdapter); Config::load('oAuthProviders', __DIR__ . '/../config/oAuthProviders.php', $configAdapter); -Config::load('platforms', __DIR__ . '/../config/platforms.php', $configAdapter); +Config::load('sdks', __DIR__ . '/../config/sdks.php', $configAdapter); +Config::load('platform', __DIR__ . '/../config/platform.php', $configAdapter); Config::load('console', __DIR__ . '/../config/console.php', $configAdapter); Config::load('collections', __DIR__ . '/../config/collections.php', $configAdapter); Config::load('frameworks', __DIR__ . '/../config/frameworks.php', $configAdapter); diff --git a/app/init/constants.php b/app/init/constants.php index ea5c0fb2c5..9c771edb0a 100644 --- a/app/init/constants.php +++ b/app/init/constants.php @@ -4,12 +4,17 @@ use Appwrite\Platform\Modules\Compute\Specification; const APP_NAME = 'Appwrite'; const APP_DOMAIN = 'appwrite.io'; + +// Email const APP_EMAIL_TEAM = 'team@localhost.test'; // Default email address const APP_EMAIL_SECURITY = ''; // Default security email address const APP_EMAIL_LOGO_URL = 'https://cloud.appwrite.io/images/mails/logo.png'; const APP_EMAIL_ACCENT_COLOR = '#fd366e'; const APP_EMAIL_TERMS_URL = 'https://appwrite.io/terms'; const APP_EMAIL_PRIVACY_URL = 'https://appwrite.io/privacy'; +const APP_EMAIL_PLATFORM_NAME = 'Appwrite'; +const APP_EMAIL_FOOTER_IMAGE_URL = 'https://appwrite.io/email/footer.png'; + const APP_USERAGENT = APP_NAME . '-Server v%s. Please report abuse at %s'; const APP_MODE_DEFAULT = 'default'; const APP_MODE_ADMIN = 'admin'; @@ -81,7 +86,6 @@ const APP_SOCIAL_DISCORD_CHANNEL = '564160730845151244'; const APP_SOCIAL_DEV = 'https://dev.to/appwrite'; const APP_SOCIAL_STACKSHARE = 'https://stackshare.io/appwrite'; const APP_SOCIAL_YOUTUBE = 'https://www.youtube.com/c/appwrite?sub_confirmation=1'; -const APP_HOSTNAME_INTERNAL = 'appwrite'; const APP_COMPUTE_CPUS_DEFAULT = 0.5; const APP_COMPUTE_MEMORY_DEFAULT = 512; const APP_COMPUTE_SPECIFICATION_DEFAULT = Specification::S_1VCPU_512MB; diff --git a/app/init/resources.php b/app/init/resources.php index 98162d3a2b..39a7a3048e 100644 --- a/app/init/resources.php +++ b/app/init/resources.php @@ -20,8 +20,10 @@ use Appwrite\Event\StatsUsage; use Appwrite\Event\Webhook; use Appwrite\Extend\Exception; use Appwrite\GraphQL\Schema; +use Appwrite\Network\Cors; use Appwrite\Network\Platform; use Appwrite\Network\Validator\Origin; +use Appwrite\Network\Validator\Redirect; use Appwrite\Utopia\Database\Documents\User; use Appwrite\Utopia\Request; use Appwrite\Utopia\Response; @@ -43,7 +45,6 @@ use Utopia\Database\Adapter\Pool as DatabasePool; use Utopia\Database\Database; use Utopia\Database\DateTime as DatabaseDateTime; use Utopia\Database\Document; -use Utopia\Database\Helpers\ID; use Utopia\Database\Query; use Utopia\Database\Validator\Authorization; use Utopia\DSN\DSN; @@ -64,7 +65,7 @@ use Utopia\Storage\Storage; use Utopia\System\System; use Utopia\Telemetry\Adapter as Telemetry; use Utopia\Telemetry\Adapter\None as NoTelemetry; -use Utopia\Validator\Hostname; +use Utopia\Validator\URL; use Utopia\Validator\WhiteList; use Utopia\VCS\Adapter\Git\GitHub as VcsGitHub; @@ -159,79 +160,189 @@ App::setResource('queueForMigrations', function (Publisher $publisher) { App::setResource('queueForStatsResources', function (Publisher $publisher) { return new StatsResources($publisher); }, ['publisher']); -App::setResource('platforms', function (Request $request, Document $console, Document $project, Database $dbForPlatform) { - $console->setAttribute('platforms', [ // Always allow current host - '$collection' => ID::custom('platforms'), - 'name' => 'Current Host', - 'type' => Platform::TYPE_WEB, - 'hostname' => $request->getHostname(), - ], Document::SET_TYPE_APPEND); - $hostnames = explode(',', System::getEnv('_APP_CONSOLE_HOSTNAMES', '')); - $validator = new Hostname(); - foreach ($hostnames as $hostname) { - $hostname = trim($hostname); - if (!$validator->isValid($hostname)) { - continue; - } - $console->setAttribute('platforms', [ - '$collection' => ID::custom('platforms'), - 'type' => Platform::TYPE_WEB, - 'name' => $hostname, - 'hostname' => $hostname, - ], Document::SET_TYPE_APPEND); +/** + * List of domains served by the application. + */ +App::setResource('domains', fn () => array_unique(array_filter([ + ...\explode(',', System::getEnv('_APP_DOMAIN', 'localhost')), + ...\explode(',', System::getEnv('_APP_CONSOLE_DOMAIN', 'localhost')) +]))); + +/** + * Platform configuration + */ +App::setResource('platform', function (Request $request) { + $platform = Config::getParam('platform', []); + $protocol = System::getEnv('_APP_OPTIONS_FORCE_HTTPS') == 'disabled' ? 'http' : 'https'; + + $port = ''; + if ($request->getPort() === '443' && $protocol !== 'https') { + $port = ':443'; + } + if ($request->getPort() === '80' && $protocol !== 'http') { + $port = ':80'; + } + $platform['endpoint'] = "$protocol://{$platform['domain']}{$port}/v1"; + + return $platform; +}, ['request']); + +/** + * Safe request origin used to construct urls + */ +App::setResource('origin', function (Request $request) { + $protocol = System::getEnv('_APP_OPTIONS_FORCE_HTTPS') == 'disabled' ? 'http' : 'https'; + + $port = ''; + if ($request->getPort() === '443' && $protocol !== 'https') { + $port = ':443'; + } + if ($request->getPort() === '80' && $protocol !== 'http') { + $port = ':80'; } - // Add `exp` and `appwrite-callback-{projectId}` schemes + return "$protocol://{$request->getHostname()}{$port}"; +}, ['request']); + +/** + * List of allowed request hostnames for the request. + */ +App::setResource('allowedHostnames', function (array $domains, Document $project, Document $rule, Document $devKey, Request $request) { + $allowed = [...$domains]; + + /* Add platform configured hostnames */ if (!$project->isEmpty() && $project->getId() !== 'console') { - $project->setAttribute('platforms', [ - '$collection' => ID::custom('platforms'), - 'type' => Platform::TYPE_SCHEME, - 'name' => 'Expo', - 'key' => 'exp', - ], Document::SET_TYPE_APPEND); - $project->setAttribute('platforms', [ - '$collection' => ID::custom('platforms'), - 'type' => Platform::TYPE_SCHEME, - 'name' => 'Appwrite Callback', - 'key' => 'appwrite-callback-' . $project->getId(), - ], Document::SET_TYPE_APPEND); + $platforms = $project->getAttribute('platforms', []); + $hostnames = Platform::getHostnames($platforms); + $allowed = [...$allowed, ...$hostnames]; } - $origin = \parse_url($request->getOrigin(), PHP_URL_HOST); - - if (empty($origin)) { - $origin = \parse_url($request->getReferer(), PHP_URL_HOST); + /* Add the request hostname if a dev key is found */ + if (!$devKey->isEmpty()) { + $allowed[] = $request->getHostname(); } - // Safe if rule with same project ID exists - if (!empty($origin)) { - if (System::getEnv('_APP_RULES_FORMAT') === 'md5') { - $rule = Authorization::skip(fn () => $dbForPlatform->getDocument('rules', md5($origin ?? ''))); - } else { - $rule = Authorization::skip( - fn () => $dbForPlatform->find('rules', [ - Query::equal('domain', [$origin]), - Query::limit(1) - ]) - )[0] ?? new Document(); + /* Allow the request origin if a dev key or rule is found */ + $originHostname = parse_url($request->getOrigin(), PHP_URL_HOST); + if ((!$rule->isEmpty() || !$devKey->isEmpty()) && !empty($originHostname)) { + $allowed[] = $originHostname; + } + + return array_unique($allowed); +}, ['domains', 'project', 'rule', 'devKey', 'request']); + +/** + * List of allowed request schemes for the request. + */ +App::setResource('allowedSchemes', function (Document $project) { + $allowed = []; + + if (!$project->isEmpty() && $project->getId() !== 'console') { + /* Add hardcoded schemes */ + $allowed[] = 'exp'; + $allowed[] = 'appwrite-callback-' . $project->getId(); + + /* Add platform configured schemes */ + $platforms = $project->getAttribute('platforms', []); + $schemes = Platform::getSchemes($platforms); + $allowed = [...$allowed, ...$schemes]; + } + + return array_unique($allowed); +}, ['project']); + +/** + * Rule associated with a request origin. + */ +App::setResource('rule', function (Request $request, Database $dbForPlatform, Document $project) { + $domain = \parse_url($request->getOrigin(), PHP_URL_HOST); + if (empty($domain)) { + return new Document(); + } + + // TODO: (@Meldiron) Remove after 1.7.x migration + $isMd5 = System::getEnv('_APP_RULES_FORMAT') === 'md5'; + $rule = Authorization::skip(function () use ($dbForPlatform, $domain, $isMd5) { + if ($isMd5) { + return $dbForPlatform->getDocument('rules', md5($domain)); } - if (!$rule->isEmpty() && $rule->getAttribute('projectInternalId') === $project->getSequence()) { - $project->setAttribute('platforms', [ - '$collection' => ID::custom('platforms'), - 'type' => Platform::TYPE_WEB, - 'name' => $origin, - 'hostname' => $origin, - ], Document::SET_TYPE_APPEND); - } + return $dbForPlatform->findOne('rules', [ + Query::equal('domain', [$domain]), + ]) ?? new Document(); + }); + + if ($rule->getAttribute('projectInternalId') !== $project->getSequence()) { + return new Document(); } - return [ - ...$console->getAttribute('platforms', []), - ...$project->getAttribute('platforms', []), - ]; -}, ['request', 'console', 'project', 'dbForPlatform']); + return $rule; +}, ['request', 'dbForPlatform', 'project']); + +/** + * CORS service + */ +App::setResource('cors', fn (array $allowedHostnames) => new Cors( + $allowedHostnames, + allowedMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'], + allowedHeaders: [ + 'Accept', + 'Origin', + 'Cookie', + 'Set-Cookie', + // Content + 'Content-Type', + 'Content-Range', + // Appwrite + 'X-Appwrite-Project', + 'X-Appwrite-Key', + 'X-Appwrite-Dev-Key', + 'X-Appwrite-Locale', + 'X-Appwrite-Mode', + 'X-Appwrite-JWT', + 'X-Appwrite-Response-Format', + 'X-Appwrite-Timeout', + 'X-Appwrite-ID', + 'X-Appwrite-Timestamp', + 'X-Appwrite-Session', + // SDK generator + 'X-SDK-Version', + 'X-SDK-Name', + 'X-SDK-Language', + 'X-SDK-Platform', + 'X-SDK-GraphQL', + // Caching + 'Range', + 'Cache-Control', + 'Expires', + 'Pragma', + // Server to server + 'X-Fallback-Cookies', + 'X-Requested-With', + 'X-Forwarded-For', + 'X-Forwarded-User-Agent', + ], + allowCredentials: true, + exposedHeaders: [ + 'X-Appwrite-Session', + 'X-Fallback-Cookies', + ], +), ['allowedHostnames']); + +App::setResource('originValidator', function (Document $devKey, array $allowedHostnames, array $allowedSchemes) { + if (!$devKey->isEmpty()) { + return new URL(); + } + return new Origin($allowedHostnames, $allowedSchemes); +}, ['devKey', 'allowedHostnames', 'allowedSchemes']); + +App::setResource('redirectValidator', function (Document $devKey, array $allowedHostnames, array $allowedSchemes) { + if (!$devKey->isEmpty()) { + return new URL(); + } + return new Redirect($allowedHostnames, $allowedSchemes); +}, ['devKey', 'allowedHostnames', 'allowedSchemes']); App::setResource('user', function (string $mode, Document $project, Document $console, Request $request, Response $response, Database $dbForProject, Database $dbForPlatform, Store $store, Token $proofForToken) { /** @@ -738,7 +849,7 @@ App::setResource('passwordsDictionary', function ($register) { App::setResource('servers', function () { - $platforms = Config::getParam('platforms'); + $platforms = Config::getParam('sdks'); $server = $platforms[APP_PLATFORM_SERVER]; $languages = array_map(function ($language) { @@ -839,24 +950,6 @@ App::setResource('schema', function ($utopia, $dbForProject) { ); }, ['utopia', 'dbForProject']); -App::setResource('contributors', function () { - $path = 'app/config/contributors.json'; - $list = (file_exists($path)) ? json_decode(file_get_contents($path), true) : []; - return $list; -}); - -App::setResource('employees', function () { - $path = 'app/config/employees.json'; - $list = (file_exists($path)) ? json_decode(file_get_contents($path), true) : []; - return $list; -}); - -App::setResource('heroes', function () { - $path = 'app/config/heroes.json'; - $list = (file_exists($path)) ? json_decode(file_get_contents($path), true) : []; - return $list; -}); - App::setResource('gitHub', function (Cache $cache) { return new VcsGitHub($cache); }, ['cache']); @@ -923,6 +1016,7 @@ App::setResource('devKey', function (Request $request, Document $project, array $dbForPlatform->purgeCachedDocument('projects', $project->getId()); } } + return $key; }, ['request', 'project', 'servers', 'dbForPlatform']); @@ -1063,37 +1157,6 @@ App::setResource('resourceToken', function ($project, $dbForProject, $request) { return new Document([]); }, ['project', 'dbForProject', 'request']); -App::setResource('httpReferrer', function (Request $request): string { - $referrer = $request->getReferer(); - return $referrer; -}, ['request']); - -App::setResource('httpReferrerSafe', function (Request $request, string $httpReferrer, array $platforms, Database $dbForPlatform, Document $project, App $utopia): string { - $origin = \parse_url($request->getOrigin($httpReferrer), PHP_URL_HOST); - $protocol = \parse_url($request->getOrigin($httpReferrer), PHP_URL_SCHEME); - $port = \parse_url($request->getOrigin($httpReferrer), PHP_URL_PORT); - $referrer = (!empty($protocol) ? $protocol : $request->getProtocol()) . '://' . $origin . (!empty($port) ? ':' . $port : ''); - - // Safe if route is publicly accessible - $route = $utopia->getRoute(); - if ($route->getLabel('origin', false)) { - return $referrer; - } - - // Safe if added as web platform - $originValidator = new Origin($platforms); - if ($originValidator->isValid($request->getOrigin($httpReferrer))) { - return $referrer; - } - - // Unsafe; Localhost is always safe for ease of local development - $origin = 'localhost'; - $protocol = \parse_url($request->getOrigin($httpReferrer), PHP_URL_SCHEME); - $port = \parse_url($request->getOrigin($httpReferrer), PHP_URL_PORT); - $referrer = (!empty($protocol) ? $protocol : $request->getProtocol()) . '://' . $origin . (!empty($port) ? ':' . $port : ''); - return $referrer; -}, ['request', 'httpReferrer', 'platforms', 'dbForPlatform', 'project', 'utopia']); - App::setResource('transactionState', function (Database $dbForProject) { return new TransactionState($dbForProject); }, ['dbForProject']); diff --git a/app/realtime.php b/app/realtime.php index 734dcd70bb..fab0ce7561 100644 --- a/app/realtime.php +++ b/app/realtime.php @@ -543,7 +543,6 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, } $timelimit = $app->getResource('timelimit'); - $platforms = $app->getResource('platforms'); $user = $app->getResource('user'); /** @var User $user */ /* @@ -568,7 +567,7 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, * Skip this check for non-web platforms which are not required to send an origin header. */ $origin = $request->getOrigin(); - $originValidator = new Origin($platforms); + $originValidator = $app->getResource('originValidator'); if (!empty($origin) && !$originValidator->isValid($origin) && $project->getId() !== 'console') { throw new Exception(Exception::REALTIME_POLICY_VIOLATION, $originValidator->getDescription()); diff --git a/app/views/general/error.phtml b/app/views/general/error.phtml index 9e74be67e2..bfb9872b93 100644 --- a/app/views/general/error.phtml +++ b/app/views/general/error.phtml @@ -1,4 +1,5 @@ getParam('development', false); @@ -15,7 +16,8 @@ $labelClass = ''; $buttons = []; $protocol = System::getEnv('_APP_OPTIONS_FORCE_HTTPS') == 'disabled' ? 'http' : 'https'; -$hostname = System::getEnv('_APP_CONSOLE_DOMAIN', System::getEnv('_APP_DOMAIN', '')); +$platform = Config::getParam('platform', []); +$hostname = $platform['consoleDomain'] ?? ''; // TODO: remove this later if (System::getEnv('_APP_ENV') === 'development') { $hostname = 'localhost'; @@ -537,4 +539,4 @@ switch ($type) { - \ No newline at end of file + diff --git a/docker-compose.yml b/docker-compose.yml index 4e31913723..f246dbb456 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -38,9 +38,15 @@ services: depends_on: - appwrite networks: - - gateway - - appwrite - - runtimes + appwrite: + aliases: + - appwrite.test + gateway: + aliases: + - appwrite.test + runtimes: + aliases: + - appwrite.test appwrite: container_name: appwrite diff --git a/src/Appwrite/Event/Build.php b/src/Appwrite/Event/Build.php index 9ea163174f..79437c3e58 100644 --- a/src/Appwrite/Event/Build.php +++ b/src/Appwrite/Event/Build.php @@ -115,7 +115,8 @@ class Build extends Event 'resource' => $this->resource, 'deployment' => $this->deployment, 'type' => $this->type, - 'template' => $this->template + 'template' => $this->template, + 'platform' => $this->platform ]; } @@ -130,6 +131,7 @@ class Build extends Event $this->resource = null; $this->deployment = null; $this->template = null; + $this->platform = []; parent::reset(); return $this; diff --git a/src/Appwrite/Event/Event.php b/src/Appwrite/Event/Event.php index 16fe76bf8a..f8fb012075 100644 --- a/src/Appwrite/Event/Event.php +++ b/src/Appwrite/Event/Event.php @@ -52,9 +52,11 @@ class Event protected array $sensitive = []; protected array $payload = []; protected array $context = []; + protected array $platform = []; protected ?Document $project = null; protected ?Document $user = null; protected ?string $userId = null; + protected bool $paused = false; /** @var bool Non-critical events will not throw an exception when enqueuing of the event fails. */ @@ -153,6 +155,28 @@ class Event return $this->project; } + /** + * Set platform for this event. + * + * @param array $platform + * @return self + */ + public function setPlatform(array $platform): self + { + $this->platform = $platform; + return $this; + } + + /** + * Get platform for this event. + * + * @return array + */ + public function getPlatform(): array + { + return $this->platform; + } + /** * Set user for this event. * diff --git a/src/Appwrite/Event/Func.php b/src/Appwrite/Event/Func.php index ae316c84e5..380a28f1db 100644 --- a/src/Appwrite/Event/Func.php +++ b/src/Appwrite/Event/Func.php @@ -161,7 +161,7 @@ class Func extends Event /** * Sets custom headers for the function event. * - * @param string $headers + * @param array $headers * @return self */ public function setHeaders(array $headers): self @@ -217,6 +217,7 @@ class Func extends Event 'path' => $this->path, 'headers' => $this->headers, 'method' => $this->method, + 'platform' => $this->platform ]; } } diff --git a/src/Appwrite/Event/Migration.php b/src/Appwrite/Event/Migration.php index bbb8d77c73..ca54310ce6 100644 --- a/src/Appwrite/Event/Migration.php +++ b/src/Appwrite/Event/Migration.php @@ -77,6 +77,7 @@ class Migration extends Event 'project' => $this->project, 'user' => $this->user, 'migration' => $this->migration, + 'platform' => $this->platform, ]; } } diff --git a/src/Appwrite/Network/Cors.php b/src/Appwrite/Network/Cors.php new file mode 100644 index 0000000000..88d0158379 --- /dev/null +++ b/src/Appwrite/Network/Cors.php @@ -0,0 +1,88 @@ + $allowedHosts Array of allowed hosts + * @param array $allowedMethods Array of allowed methods + * @param array $allowedHeaders Array of allowed header + * @param array $exposedHeaders Array of exposed headers + * @param bool $allowCredentials Whether to allow credentials (default: false) + * @param int $maxAge Maximum age of the preflight response (default: 86400 seconds) + */ + public function __construct( + private array $allowedHosts, + private array $allowedMethods, + private array $allowedHeaders, + private array $exposedHeaders, + private bool $allowCredentials = false, + private int $maxAge = 86400, + ) { + $this->allowedHosts = \array_map('strtolower', $this->allowedHosts); + + if ($this->allowedHosts === ['*'] && $allowCredentials === true) { + throw new \InvalidArgumentException( + 'CORS invariant violated: cannot use wildcard origin "*" when credentials are enabled.' + ); + } + } + + /** + * Build CORS headers for a given request origin. + * + * @return array + */ + public function headers(string $origin): array + { + $headers = [ + self::HEADER_ALLOW_METHODS => implode(', ', $this->allowedMethods), + self::HEADER_ALLOW_HEADERS => implode(', ', $this->allowedHeaders), + self::HEADER_EXPOSE_HEADERS => implode(', ', $this->exposedHeaders), + self::HEADER_ALLOW_CREDENTIALS => $this->allowCredentials ? 'true' : 'false', + self::HEADER_MAX_AGE => $this->maxAge, + ]; + + // Wildcard allow-all + if ($this->allowedHosts === ['*']) { + $headers[self::HEADER_ALLOW_ORIGIN] = $origin; + return $headers; + } + + // Normal origin handling + $origin = strtolower(trim($origin)); + if ($origin === '') { + return $headers; + } + + $host = parse_url($origin, PHP_URL_HOST); + if (!\is_string($host) || $host === '') { + return $headers; + } + + // Match only by host + if (!\in_array($host, $this->allowedHosts, true)) { + return $headers; + } + + // Accepted + $headers[self::HEADER_ALLOW_ORIGIN] = $origin; + + return $headers; + } +} diff --git a/src/Appwrite/Network/Validator/Origin.php b/src/Appwrite/Network/Validator/Origin.php index 7843a17f1c..02d5d8e83d 100644 --- a/src/Appwrite/Network/Validator/Origin.php +++ b/src/Appwrite/Network/Validator/Origin.php @@ -8,8 +8,6 @@ use Utopia\Validator\Hostname; class Origin extends Validator { - protected array $hostnames = []; - protected array $schemes = []; protected ?string $scheme = null; protected ?string $host = null; protected string $origin = ''; @@ -17,12 +15,11 @@ class Origin extends Validator /** * Constructor * - * @param array<\Utopia\Database\Document> $platforms + * @param array $allowedHostnames + * @param array $allowedSchemes */ - public function __construct(array $platforms) + public function __construct(protected array $allowedHostnames, protected array $allowedSchemes) { - $this->hostnames = Platform::getHostnames($platforms); - $this->schemes = Platform::getSchemes($platforms); } @@ -53,11 +50,11 @@ class Origin extends Validator Platform::SCHEME_EDGE_EXTENSION, ]; if (in_array($this->scheme, $webPlatforms, true)) { - $validator = new Hostname($this->hostnames); + $validator = new Hostname($this->allowedHostnames); return $validator->isValid($this->host); } - if (!empty($this->scheme) && in_array($this->scheme, $this->schemes, true)) { + if (!empty($this->scheme) && in_array($this->scheme, $this->allowedSchemes, true)) { return true; } diff --git a/src/Appwrite/Platform/Modules/Compute/Base.php b/src/Appwrite/Platform/Modules/Compute/Base.php index a538eb1497..47afc90986 100644 --- a/src/Appwrite/Platform/Modules/Compute/Base.php +++ b/src/Appwrite/Platform/Modules/Compute/Base.php @@ -235,8 +235,9 @@ class Base extends Action $sitesDomain = System::getEnv('_APP_DOMAIN_SITES', ''); $domain = ID::unique() . "." . $sitesDomain; - // TODO: @christyjacob remove once we migrate the rules in 1.7.x - $ruleId = System::getEnv('_APP_RULES_FORMAT') === 'md5' ? md5($domain) : ID::unique(); + // TODO: (@Meldiron) Remove after 1.7.x migration + $isMd5 = System::getEnv('_APP_RULES_FORMAT') === 'md5'; + $ruleId = $isMd5 ? md5($domain) : ID::unique(); Authorization::skip( fn () => $dbForPlatform->createDocument('rules', new Document([ diff --git a/src/Appwrite/Platform/Modules/Console/Http/Resources/Get.php b/src/Appwrite/Platform/Modules/Console/Http/Resources/Get.php index b67a42adb1..1b8a81cdc6 100644 --- a/src/Appwrite/Platform/Modules/Console/Http/Resources/Get.php +++ b/src/Appwrite/Platform/Modules/Console/Http/Resources/Get.php @@ -59,6 +59,7 @@ class Get extends Action ->param('type', '', new WhiteList(['rules']), 'Resource type.') ->inject('response') ->inject('dbForPlatform') + ->inject('domains') ->callback($this->action(...)); } @@ -66,7 +67,8 @@ class Get extends Action string $value, string $type, Response $response, - Database $dbForPlatform + Database $dbForPlatform, + array $domains ) { if ($type === 'rules') { $sitesDomain = System::getEnv('_APP_DOMAIN_SITES', ''); @@ -89,13 +91,7 @@ class Get extends Action throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'This domain name is not allowed. Please use a different domain.'); } - $deniedDomains = [ - 'localhost', - APP_HOSTNAME_INTERNAL - ]; - - $mainDomain = System::getEnv('_APP_DOMAIN', ''); - $deniedDomains[] = $mainDomain; + $deniedDomains = [...$domains]; if (!empty($sitesDomain)) { $deniedDomains[] = $sitesDomain; diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php b/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php index 1367cf337f..5467d88641 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php @@ -98,6 +98,7 @@ class Create extends Base ->inject('store') ->inject('proofForToken') ->inject('executor') + ->inject('platform') ->callback($this->action(...)); } @@ -121,7 +122,8 @@ class Create extends Base Reader $geodb, Store $store, Token $proofForToken, - Executor $executor + Executor $executor, + array $platform ) { $async = \strval($async) === 'true' || \strval($async) === '1'; @@ -366,13 +368,9 @@ class Create extends Base $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_API_ENDPOINT' => $platform['endpoint'], 'APPWRITE_FUNCTION_ID' => $functionId, 'APPWRITE_FUNCTION_NAME' => $function->getAttribute('name'), 'APPWRITE_FUNCTION_DEPLOYMENT' => $deployment->getId(), diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Functions/Create.php b/src/Appwrite/Platform/Modules/Functions/Http/Functions/Create.php index ec2a4baac5..09d471515d 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Functions/Create.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Functions/Create.php @@ -362,8 +362,9 @@ class Create extends Base if (!empty($functionsDomain)) { $routeSubdomain = ID::unique(); $domain = "{$routeSubdomain}.{$functionsDomain}"; - // TODO: @christyjacob remove once we migrate the rules in 1.7.x - $ruleId = System::getEnv('_APP_RULES_FORMAT') === 'md5' ? md5($domain) : ID::unique(); + // TODO: (@Meldiron) Remove after 1.7.x migration + $isMd5 = System::getEnv('_APP_RULES_FORMAT') === 'md5'; + $ruleId = $isMd5 ? md5($domain) : ID::unique(); $rule = Authorization::skip( fn () => $dbForPlatform->createDocument('rules', new Document([ diff --git a/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php b/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php index d46a3b4986..43e28b011b 100644 --- a/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php +++ b/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php @@ -129,6 +129,8 @@ class Builds extends Action $resource = new Document($payload['resource'] ?? []); $deployment = new Document($payload['deployment'] ?? []); $template = new Document($payload['template'] ?? []); + $platform = $payload['platform'] ?? []; + $log->addTag('projectId', $project->getId()); $log->addTag('type', $type); @@ -157,7 +159,8 @@ class Builds extends Action $isResourceBlocked, $log, $executor, - $plan + $plan, + $platform ); break; @@ -209,7 +212,8 @@ class Builds extends Action callable $isResourceBlocked, Log $log, Executor $executor, - array $plan + array $plan, + array $platform ): void { Console::info('Deployment action started'); @@ -532,7 +536,7 @@ class Builds extends Action Console::log('Git source uploaded'); - $this->runGitAction('processing', $github, $providerCommitHash, $owner, $repositoryName, $project, $resource, $deployment->getId(), $dbForProject, $dbForPlatform, $queueForRealtime); + $this->runGitAction('processing', $github, $providerCommitHash, $owner, $repositoryName, $project, $resource, $deployment->getId(), $dbForProject, $dbForPlatform, $queueForRealtime, $platform); } Console::log('Status marked as building'); @@ -550,7 +554,7 @@ class Builds extends Action ->trigger(); if ($isVcsEnabled) { - $this->runGitAction('building', $github, $providerCommitHash, $owner, $repositoryName, $project, $resource, $deployment->getId(), $dbForProject, $dbForPlatform, $queueForRealtime); + $this->runGitAction('building', $github, $providerCommitHash, $owner, $repositoryName, $project, $resource, $deployment->getId(), $dbForProject, $dbForPlatform, $queueForRealtime, $platform); } $deploymentModel = new Deployment(); @@ -612,10 +616,6 @@ class Builds extends Action 'scopes' => $resource->getAttribute('scopes', []) ]); - $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_VERSION' => APP_VERSION_STABLE, @@ -639,7 +639,7 @@ class Builds extends Action case 'functions': $vars = [ ...$vars, - 'APPWRITE_FUNCTION_API_ENDPOINT' => $endpoint, + 'APPWRITE_FUNCTION_API_ENDPOINT' => $platform['endpoint'], 'APPWRITE_FUNCTION_API_KEY' => API_KEY_DYNAMIC . '_' . $apiKey, 'APPWRITE_FUNCTION_ID' => $resource->getId(), 'APPWRITE_FUNCTION_NAME' => $resource->getAttribute('name'), @@ -654,7 +654,7 @@ class Builds extends Action case 'sites': $vars = [ ...$vars, - 'APPWRITE_SITE_API_ENDPOINT' => $endpoint, + 'APPWRITE_SITE_API_ENDPOINT' => $platform['endpoint'], 'APPWRITE_SITE_API_KEY' => API_KEY_DYNAMIC . '_' . $apiKey, 'APPWRITE_SITE_ID' => $resource->getId(), 'APPWRITE_SITE_NAME' => $resource->getAttribute('name'), @@ -1108,7 +1108,7 @@ class Builds extends Action } if ($isVcsEnabled) { - $this->runGitAction('ready', $github, $providerCommitHash, $owner, $repositoryName, $project, $resource, $deployment->getId(), $dbForProject, $dbForPlatform, $queueForRealtime); + $this->runGitAction('ready', $github, $providerCommitHash, $owner, $repositoryName, $project, $resource, $deployment->getId(), $dbForProject, $dbForPlatform, $queueForRealtime, $platform); } /** Set auto deploy */ @@ -1337,7 +1337,7 @@ class Builds extends Action ->trigger(); if ($isVcsEnabled) { - $this->runGitAction('failed', $github, $providerCommitHash, $owner, $repositoryName, $project, $resource, $deployment->getId(), $dbForProject, $dbForPlatform, $queueForRealtime); + $this->runGitAction('failed', $github, $providerCommitHash, $owner, $repositoryName, $project, $resource, $deployment->getId(), $dbForProject, $dbForPlatform, $queueForRealtime, $platform); } } finally { $queueForRealtime @@ -1489,6 +1489,8 @@ class Builds extends Action * @param string $deploymentId * @param Database $dbForProject * @param Database $dbForPlatform + * @param Realtime $queueForRealtime + * @param array $platform * @return void * @throws Structure * @throws \Utopia\Database\Exception @@ -1508,6 +1510,7 @@ class Builds extends Action Database $dbForProject, Database $dbForPlatform, Realtime $queueForRealtime, + array $platform ): void { try { if ($resource->getAttribute('providerSilentMode', false) === true) { @@ -1593,7 +1596,7 @@ class Builds extends Action default => throw new \Exception('Invalid resource type') }; - $comment = new Comment(); + $comment = new Comment($platform); $comment->parseComment($github->getComment($owner, $repositoryName, $commentId)); $comment->addBuild($project, $resource, $resourceType, $status, $deployment->getId(), ['type' => 'logs'], $previewUrl); $github->updateComment($owner, $repositoryName, $commentId, $comment->generateComment()); diff --git a/src/Appwrite/Platform/Modules/Proxy/Http/Rules/API/Create.php b/src/Appwrite/Platform/Modules/Proxy/Http/Rules/API/Create.php index ff92b3a408..ed061b4c89 100644 --- a/src/Appwrite/Platform/Modules/Proxy/Http/Rules/API/Create.php +++ b/src/Appwrite/Platform/Modules/Proxy/Http/Rules/API/Create.php @@ -67,10 +67,11 @@ class Create extends Action ->inject('queueForCertificates') ->inject('queueForEvents') ->inject('dbForPlatform') + ->inject('domains') ->callback($this->action(...)); } - public function action(string $domain, Response $response, Document $project, Certificate $queueForCertificates, Event $queueForEvents, Database $dbForPlatform) + public function action(string $domain, Response $response, Document $project, Certificate $queueForCertificates, Event $queueForEvents, Database $dbForPlatform, array $domains) { $sitesDomain = System::getEnv('_APP_DOMAIN_SITES', ''); $functionsDomain = System::getEnv('_APP_DOMAIN_FUNCTIONS', ''); @@ -90,13 +91,7 @@ class Create extends Action throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'This domain name is not allowed. Please use a different domain.'); } - $deniedDomains = [ - 'localhost', - APP_HOSTNAME_INTERNAL - ]; - - $mainDomain = System::getEnv('_APP_DOMAIN', ''); - $deniedDomains[] = $mainDomain; + $deniedDomains = [...$domains]; if (!empty($sitesDomain)) { $deniedDomains[] = $sitesDomain; @@ -125,8 +120,9 @@ class Create extends Action throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Domain may not start with http:// or https://.'); } - // TODO: @christyjacob remove once we migrate the rules in 1.7.x - $ruleId = System::getEnv('_APP_RULES_FORMAT') === 'md5' ? md5($domain->get()) : ID::unique(); + // TODO: (@Meldiron) Remove after 1.7.x migration + $isMd5 = System::getEnv('_APP_RULES_FORMAT') === 'md5'; + $ruleId = $isMd5 ? md5($domain->get()) : ID::unique(); $status = 'created'; if (\str_ends_with($domain->get(), $functionsDomain) || \str_ends_with($domain->get(), $sitesDomain)) { diff --git a/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Function/Create.php b/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Function/Create.php index 6e6d9905a8..6d436f2f44 100644 --- a/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Function/Create.php +++ b/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Function/Create.php @@ -72,10 +72,11 @@ class Create extends Action ->inject('queueForEvents') ->inject('dbForPlatform') ->inject('dbForProject') + ->inject('domains') ->callback($this->action(...)); } - public function action(string $domain, string $functionId, string $branch, 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, array $domains) { $sitesDomain = System::getEnv('_APP_DOMAIN_SITES', ''); $functionsDomain = System::getEnv('_APP_DOMAIN_FUNCTIONS', ''); @@ -95,13 +96,7 @@ class Create extends Action throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'This domain name is not allowed. Please use a different domain.'); } - $deniedDomains = [ - 'localhost', - APP_HOSTNAME_INTERNAL - ]; - - $mainDomain = System::getEnv('_APP_DOMAIN', ''); - $deniedDomains[] = $mainDomain; + $deniedDomains = [...$domains]; if (!empty($sitesDomain)) { $deniedDomains[] = $sitesDomain; @@ -137,8 +132,9 @@ class Create extends Action $deployment = $dbForProject->getDocument('deployments', $function->getAttribute('deploymentId', '')); - // TODO: @christyjacob remove once we migrate the rules in 1.7.x - $ruleId = System::getEnv('_APP_RULES_FORMAT') === 'md5' ? md5($domain->get()) : ID::unique(); + // TODO: (@Meldiron) Remove after 1.7.x migration + $isMd5 = System::getEnv('_APP_RULES_FORMAT') === 'md5'; + $ruleId = $isMd5 ? md5($domain->get()) : ID::unique(); $status = 'created'; if (\str_ends_with($domain->get(), $functionsDomain) || \str_ends_with($domain->get(), $sitesDomain)) { diff --git a/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Redirect/Create.php b/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Redirect/Create.php index e2cc51d91f..77856620fe 100644 --- a/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Redirect/Create.php +++ b/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Redirect/Create.php @@ -75,10 +75,11 @@ class Create extends Action ->inject('queueForEvents') ->inject('dbForPlatform') ->inject('dbForProject') + ->inject('domains') ->callback($this->action(...)); } - public function action(string $domain, string $url, int $statusCode, string $resourceId, string $resourceType, Response $response, Document $project, Certificate $queueForCertificates, Event $queueForEvents, Database $dbForPlatform, Database $dbForProject) + public function action(string $domain, string $url, int $statusCode, string $resourceId, string $resourceType, Response $response, Document $project, Certificate $queueForCertificates, Event $queueForEvents, Database $dbForPlatform, Database $dbForProject, array $domains) { $sitesDomain = System::getEnv('_APP_DOMAIN_SITES', ''); $functionsDomain = System::getEnv('_APP_DOMAIN_FUNCTIONS', ''); @@ -98,13 +99,7 @@ class Create extends Action throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'This domain name is not allowed. Please use a different domain.'); } - $deniedDomains = [ - 'localhost', - APP_HOSTNAME_INTERNAL - ]; - - $mainDomain = System::getEnv('_APP_DOMAIN', ''); - $deniedDomains[] = $mainDomain; + $deniedDomains = [...$domains]; if (!empty($sitesDomain)) { $deniedDomains[] = $sitesDomain; @@ -142,8 +137,9 @@ class Create extends Action 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(); + // TODO: (@Meldiron) Remove after 1.7.x migration + $isMd5 = System::getEnv('_APP_RULES_FORMAT') === 'md5'; + $ruleId = $isMd5 ? md5($domain->get()) : ID::unique(); $status = 'created'; if (\str_ends_with($domain->get(), $functionsDomain) || \str_ends_with($domain->get(), $sitesDomain)) { diff --git a/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Site/Create.php b/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Site/Create.php index 5154a82e16..ce22dd805a 100644 --- a/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Site/Create.php +++ b/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Site/Create.php @@ -72,10 +72,11 @@ class Create extends Action ->inject('queueForEvents') ->inject('dbForPlatform') ->inject('dbForProject') + ->inject('domains') ->callback($this->action(...)); } - public function action(string $domain, string $siteId, string $branch, Response $response, Document $project, Certificate $queueForCertificates, Event $queueForEvents, Database $dbForPlatform, Database $dbForProject) + public function action(string $domain, string $siteId, string $branch, Response $response, Document $project, Certificate $queueForCertificates, Event $queueForEvents, Database $dbForPlatform, Database $dbForProject, array $domains) { $sitesDomain = System::getEnv('_APP_DOMAIN_SITES', ''); $functionsDomain = System::getEnv('_APP_DOMAIN_FUNCTIONS', ''); @@ -95,13 +96,7 @@ class Create extends Action throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'This domain name is not allowed. Please use a different domain.'); } - $deniedDomains = [ - 'localhost', - APP_HOSTNAME_INTERNAL - ]; - - $mainDomain = System::getEnv('_APP_DOMAIN', ''); - $deniedDomains[] = $mainDomain; + $deniedDomains = [...$domains]; if (!empty($sitesDomain)) { $deniedDomains[] = $sitesDomain; @@ -137,8 +132,9 @@ class Create extends Action $deployment = $dbForProject->getDocument('deployments', $site->getAttribute('deploymentId', '')); - // TODO: @christyjacob remove once we migrate the rules in 1.7.x - $ruleId = System::getEnv('_APP_RULES_FORMAT') === 'md5' ? md5($domain->get()) : ID::unique(); + // TODO: (@Meldiron) Remove after 1.7.x migration + $isMd5 = System::getEnv('_APP_RULES_FORMAT') === 'md5'; + $ruleId = $isMd5 ? md5($domain->get()) : ID::unique(); $status = 'created'; if (\str_ends_with($domain->get(), $functionsDomain) || \str_ends_with($domain->get(), $sitesDomain)) { diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Create.php b/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Create.php index aa622d8d84..7d4717c205 100644 --- a/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Create.php +++ b/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Create.php @@ -272,8 +272,9 @@ class Create extends Action $sitesDomain = System::getEnv('_APP_DOMAIN_SITES', ''); $domain = ID::unique() . "." . $sitesDomain; - // TODO: @christyjacob remove once we migrate the rules in 1.7.x - $ruleId = System::getEnv('_APP_RULES_FORMAT') === 'md5' ? md5($domain) : ID::unique(); + // TODO: (@Meldiron) Remove after 1.7.x migration + $isMd5 = System::getEnv('_APP_RULES_FORMAT') === 'md5'; + $ruleId = $isMd5 ? md5($domain) : ID::unique(); Authorization::skip( fn () => $dbForPlatform->createDocument('rules', new Document([ diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Duplicate/Create.php b/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Duplicate/Create.php index 065dd13e88..f80f643fae 100644 --- a/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Duplicate/Create.php +++ b/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Duplicate/Create.php @@ -143,8 +143,9 @@ class Create extends Action $sitesDomain = System::getEnv('_APP_DOMAIN_SITES', ''); $domain = ID::unique() . "." . $sitesDomain; - // TODO: @christyjacob remove once we migrate the rules in 1.7.x - $ruleId = System::getEnv('_APP_RULES_FORMAT') === 'md5' ? md5($domain) : ID::unique(); + // TODO: (@Meldiron) Remove after 1.7.x migration + $isMd5 = System::getEnv('_APP_RULES_FORMAT') === 'md5'; + $ruleId = $isMd5 ? md5($domain) : ID::unique(); Authorization::skip( fn () => $dbForPlatform->createDocument('rules', new Document([ 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 dc7d4c4ace..480a0cae3b 100644 --- a/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Template/Create.php +++ b/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Template/Create.php @@ -53,7 +53,7 @@ class Create extends Base name: 'createTemplateDeployment', description: << $dbForPlatform->createDocument('rules', new Document([ diff --git a/src/Appwrite/Platform/Tasks/Maintenance.php b/src/Appwrite/Platform/Tasks/Maintenance.php index f5785d0bb4..9c88bc4d4e 100644 --- a/src/Appwrite/Platform/Tasks/Maintenance.php +++ b/src/Appwrite/Platform/Tasks/Maintenance.php @@ -129,15 +129,16 @@ class Maintenance extends Action if (\count($certificates) > 0) { Console::info("[{$time}] Found " . \count($certificates) . " certificates for renewal, scheduling jobs."); + // TODO: (@Meldiron) Remove after 1.7.x migration + $isMd5 = System::getEnv('_APP_RULES_FORMAT') === 'md5'; + foreach ($certificates as $certificate) { $domain = $certificate->getAttribute('domain'); - if (System::getEnv('_APP_RULES_FORMAT') === 'md5') { - $rule = $dbForPlatform->getDocument('rules', md5($domain)); - } else { - $rule = $dbForPlatform->findOne('rules', [ + $rule = $isMd5 + ? $dbForPlatform->getDocument('rules', md5($domain)) + : $dbForPlatform->findOne('rules', [ Query::equal('domain', [$domain]), ]); - } if ($rule->isEmpty() || $rule->getAttribute('region') !== System::getEnv('_APP_REGION', 'default')) { continue; diff --git a/src/Appwrite/Platform/Tasks/SDKs.php b/src/Appwrite/Platform/Tasks/SDKs.php index d3c605655f..b1dcc522e6 100644 --- a/src/Appwrite/Platform/Tasks/SDKs.php +++ b/src/Appwrite/Platform/Tasks/SDKs.php @@ -107,7 +107,7 @@ class SDKs extends Action throw new \Exception('Unknown version given'); } - $platforms = Config::getParam('platforms'); + $platforms = Config::getParam('sdks'); foreach ($platforms as $key => $platform) { if ($selectedPlatform !== $key && $selectedPlatform !== '*' && ($sdks === null)) { continue; diff --git a/src/Appwrite/Platform/Workers/Certificates.php b/src/Appwrite/Platform/Workers/Certificates.php index ac3deb31af..9dc6322163 100644 --- a/src/Appwrite/Platform/Workers/Certificates.php +++ b/src/Appwrite/Platform/Workers/Certificates.php @@ -430,14 +430,13 @@ class Certificates extends Action Func $queueForFunctions, Realtime $queueForRealtime ): void { - // TODO: @christyjacob remove once we migrate the rules in 1.7.x - if (System::getEnv('_APP_RULES_FORMAT') === 'md5') { - $rule = $dbForPlatform->getDocument('rules', md5($domain)); - } else { - $rule = $dbForPlatform->findOne('rules', [ + // TODO: (@Meldiron) Remove after 1.7.x migration + $isMd5 = System::getEnv('_APP_RULES_FORMAT') === 'md5'; + $rule = $isMd5 + ? $dbForPlatform->getDocument('rules', md5($domain)) + : $dbForPlatform->findOne('rules', [ Query::equal('domain', [$domain]), ]); - } if (!$rule->isEmpty()) { $rule->setAttribute('certificateId', $certificateId); diff --git a/src/Appwrite/Platform/Workers/Functions.php b/src/Appwrite/Platform/Workers/Functions.php index df1833ad33..4922ce0372 100644 --- a/src/Appwrite/Platform/Workers/Functions.php +++ b/src/Appwrite/Platform/Workers/Functions.php @@ -87,6 +87,7 @@ class Functions extends Action $events = $payload['events'] ?? []; $data = $payload['body'] ?? ''; $eventData = $payload['payload'] ?? ''; + $platform = $payload['platform'] ?? ''; $function = new Document($payload['function'] ?? []); $functionId = $payload['functionId'] ?? ''; $user = new Document($payload['user'] ?? []); @@ -166,6 +167,7 @@ class Functions extends Action 'user-agent' => 'Appwrite/' . APP_VERSION_STABLE, 'content-type' => 'application/json' ], + platform: $platform, data: null, user: $user, jwt: null, @@ -206,6 +208,7 @@ class Functions extends Action path: $path, method: $method, headers: $headers, + platform: $platform, data: $data, user: $user, jwt: $jwt, @@ -231,6 +234,7 @@ class Functions extends Action path: $path, method: $method, headers: $headers, + platform: $platform, data: $data, user: $user, jwt: $jwt, @@ -346,6 +350,7 @@ class Functions extends Action string $path, string $method, array $headers, + array $platform, string $data = null, ?Document $user = null, string $jwt = null, @@ -486,13 +491,9 @@ class Functions extends Action $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_API_ENDPOINT' => $platform['endpoint'], 'APPWRITE_FUNCTION_ID' => $functionId, 'APPWRITE_FUNCTION_NAME' => $function->getAttribute('name'), 'APPWRITE_FUNCTION_DEPLOYMENT' => $deploymentId, diff --git a/src/Appwrite/Platform/Workers/Mails.php b/src/Appwrite/Platform/Workers/Mails.php index efca484ebf..65a295c170 100644 --- a/src/Appwrite/Platform/Workers/Mails.php +++ b/src/Appwrite/Platform/Workers/Mails.php @@ -71,7 +71,7 @@ class Mails extends Action $log->addTag('type', empty($smtp) ? 'cloud' : 'smtp'); $protocol = System::getEnv('_APP_OPTIONS_FORCE_HTTPS') == 'disabled' ? 'http' : 'https'; - $hostname = System::getEnv('_APP_DOMAIN'); + $hostname = System::getEnv('_APP_CONSOLE_DOMAIN'); $recipient = $payload['recipient']; $subject = $payload['subject']; diff --git a/src/Appwrite/Platform/Workers/Migrations.php b/src/Appwrite/Platform/Workers/Migrations.php index e7b12c5d9d..a10ddc4904 100644 --- a/src/Appwrite/Platform/Workers/Migrations.php +++ b/src/Appwrite/Platform/Workers/Migrations.php @@ -52,6 +52,8 @@ class Migrations extends Action protected array $plan; + protected array $platform; + /** * @var array */ @@ -106,6 +108,7 @@ class Migrations extends Action $this->deviceForMigrations = $deviceForMigrations; $this->deviceForFiles = $deviceForFiles; $this->plan = $plan; + $this->platform = $payload['platform'] ?? []; if (empty($payload)) { throw new Exception('Missing payload'); @@ -141,6 +144,7 @@ class Migrations extends Action $credentials = $migration->getAttribute('credentials'); $migrationOptions = $migration->getAttribute('options'); $dataSource = Appwrite::SOURCE_API; + $endpoint = $this->platform['endpoint'] ?: ($credentials['endpoint'] ?? 'http://appwrite.test/v1'); $database = null; $queries = []; @@ -174,7 +178,7 @@ class Migrations extends Action ), SourceAppwrite::getName() => new SourceAppwrite( $credentials['projectId'], - $credentials['endpoint'] === 'http://localhost/v1' ? 'http://appwrite/v1' : $credentials['endpoint'], + $endpoint, $credentials['apiKey'], $dataSource, $database, @@ -205,7 +209,7 @@ class Migrations extends Action return match ($destination) { DestinationAppwrite::getName() => new DestinationAppwrite( $this->project->getId(), - 'http://appwrite/v1', + $this->platform['endpoint'], $apiKey, $this->dbForProject, Config::getParam('collections', [])['databases']['collections'], @@ -309,7 +313,7 @@ class Migrations extends Action ) { $credentials = $migration->getAttribute('credentials', []); $credentials['projectId'] = $credentials['projectId'] ?? $project->getId(); - $credentials['endpoint'] = $credentials['endpoint'] ?? 'http://appwrite/v1'; + $credentials['endpoint'] = $credentials['endpoint'] ?? $this->platform['endpoint']; $credentials['apiKey'] = $credentials['apiKey'] ?? $tempAPIKey; $migration->setAttribute('credentials', $credentials); } diff --git a/src/Appwrite/Vcs/Comment.php b/src/Appwrite/Vcs/Comment.php index 57a36ec164..39bc42f07d 100644 --- a/src/Appwrite/Vcs/Comment.php +++ b/src/Appwrite/Vcs/Comment.php @@ -9,6 +9,11 @@ use Utopia\System\System; class Comment { + public function __construct( + private array $platform + ) { + } + // TODO: Add more tips protected array $tips = [ 'Appwrite has crossed the 50K GitHub stars milestone with hundreds of active contributors', @@ -114,7 +119,7 @@ class Comment $i = 0; foreach ($projects as $projectId => $project) { $protocol = System::getEnv('_APP_OPTIONS_FORCE_HTTPS') === 'disabled' ? 'http' : 'https'; - $hostname = System::getEnv('_APP_CONSOLE_DOMAIN', System::getEnv('_APP_DOMAIN', '')); + $hostname = $this->platform['consoleDomain'] ?? ''; $text .= "## {$project['name']}\n\n"; $text .= "Project ID: `{$projectId}`\n\n"; @@ -228,7 +233,7 @@ class Comment public function generatImage(string $pathLight, string $pathDark, string $alt, int $width): string { $protocol = System::getEnv('_APP_OPTIONS_FORCE_HTTPS') === 'disabled' ? 'http' : 'https'; - $hostname = System::getEnv('_APP_CONSOLE_DOMAIN', System::getEnv('_APP_DOMAIN', '')); + $hostname = $this->platform['consoleDomain'] ?? ''; $imageLight = $protocol . '://' . $hostname . $pathLight; $imageDark = $protocol . '://' . $hostname . $pathDark; diff --git a/src/Executor/Executor.php b/src/Executor/Executor.php index 30c6132b76..c891fb82e0 100644 --- a/src/Executor/Executor.php +++ b/src/Executor/Executor.php @@ -200,10 +200,6 @@ class Executor ?int $requestTimeout = null, string $responseFormat = self::RESPONSE_FORMAT_OBJECT_HEADERS ) { - if (empty($headers['host'])) { - $headers['host'] = System::getEnv('_APP_DOMAIN', ''); - } - $runtimeId = "$projectId-$deploymentId"; $route = '/runtimes/' . $runtimeId . '/executions'; diff --git a/tests/e2e/General/CompressionTest.php b/tests/e2e/General/CompressionTest.php index 9affacfe0a..cff23b1201 100644 --- a/tests/e2e/General/CompressionTest.php +++ b/tests/e2e/General/CompressionTest.php @@ -75,7 +75,9 @@ class CompressionTest extends Scope $this->assertEquals(200, $response['headers']['status-code']); $this->assertArrayHasKey('content-encoding', $response['headers'], 'Content encoding should be gzip, headers received: ' . json_encode($response['headers'], JSON_PRETTY_PRINT)); - $this->assertLessThan(2000, intval($response['headers']['content-length'])); + $this->assertArrayHasKey('content-length', $response['headers'], 'Compressed response should provide content length, headers received: ' . json_encode($response['headers'], JSON_PRETTY_PRINT)); + $this->assertLessThan(2000, \intval($response['headers']['content-length'])); + $this->assertArrayNotHasKey('transfer-encoding', $response['headers'], 'Compressed response should not be chunked, headers received: ' . json_encode($response['headers'], JSON_PRETTY_PRINT)); // get prefs without compression $response = $this->client->call(Client::METHOD_GET, '/users/' . $userId . '/prefs', array_merge([ @@ -83,8 +85,15 @@ class CompressionTest extends Scope ], $this->getHeaders())); $this->assertEquals(200, $response['headers']['status-code']); - $this->assertGreaterThanOrEqual(2000, intval($response['headers']['content-length'])); $this->assertArrayNotHasKey('content-encoding', $response['headers']); + $this->assertEquals('chunked', $response['headers']['transfer-encoding'] ?? null, 'Uncompressed response should use chunked transfer, headers received: ' . json_encode($response['headers'], JSON_PRETTY_PRINT)); + $this->assertArrayNotHasKey('content-length', $response['headers'], 'Uncompressed response should not send content length when chunked.'); + + $this->assertArrayHasKey('longValue', $response['body'], 'Prefs payload should expose longValue at the top level, body received: ' . json_encode($response['body'], JSON_PRETTY_PRINT)); + + $prefsPayload = $response['body']['longValue']; + $payloadLength = \strlen($prefsPayload); + $this->assertGreaterThanOrEqual(2000, $payloadLength, 'Prefs payload should be at least 2000 bytes.'); } public function testImageResponse() diff --git a/tests/e2e/General/HTTPTest.php b/tests/e2e/General/HTTPTest.php index 4657e6f2ad..35d7ad0919 100644 --- a/tests/e2e/General/HTTPTest.php +++ b/tests/e2e/General/HTTPTest.php @@ -15,7 +15,7 @@ class HTTPTest extends Scope public function setUp(): void { parent::setUp(); - $this->client->setEndpoint('http://traefik'); + $this->client->setEndpoint('http://appwrite.test'); } public function testOptions() @@ -31,7 +31,7 @@ class HTTPTest extends Scope $this->assertEquals(204, $response['headers']['status-code']); $this->assertEquals('Appwrite', $response['headers']['server']); $this->assertEquals('GET, POST, PUT, PATCH, DELETE', $response['headers']['access-control-allow-methods']); - $this->assertEquals('Origin, Cookie, Set-Cookie, X-Requested-With, Content-Type, Access-Control-Allow-Origin, Access-Control-Request-Headers, Accept, X-Appwrite-Project, X-Appwrite-Key, X-Appwrite-Dev-Key, X-Appwrite-Locale, X-Appwrite-Mode, X-Appwrite-JWT, X-Appwrite-Response-Format, X-Appwrite-Timeout, X-SDK-Version, X-SDK-Name, X-SDK-Language, X-SDK-Platform, X-SDK-GraphQL, X-Appwrite-ID, X-Appwrite-Timestamp, Content-Range, Range, Cache-Control, Expires, Pragma, X-Appwrite-Session, X-Fallback-Cookies, X-Forwarded-For, X-Forwarded-User-Agent', $response['headers']['access-control-allow-headers']); + $this->assertEquals('Accept, Origin, Cookie, Set-Cookie, Content-Type, Content-Range, X-Appwrite-Project, X-Appwrite-Key, X-Appwrite-Dev-Key, X-Appwrite-Locale, X-Appwrite-Mode, X-Appwrite-JWT, X-Appwrite-Response-Format, X-Appwrite-Timeout, X-Appwrite-ID, X-Appwrite-Timestamp, X-Appwrite-Session, X-SDK-Version, X-SDK-Name, X-SDK-Language, X-SDK-Platform, X-SDK-GraphQL, Range, Cache-Control, Expires, Pragma, X-Fallback-Cookies, X-Requested-With, X-Forwarded-For, X-Forwarded-User-Agent', $response['headers']['access-control-allow-headers']); $this->assertEquals('X-Appwrite-Session, X-Fallback-Cookies', $response['headers']['access-control-expose-headers']); $this->assertEquals('http://localhost', $response['headers']['access-control-allow-origin']); $this->assertEquals('true', $response['headers']['access-control-allow-credentials']); @@ -66,16 +66,10 @@ class HTTPTest extends Scope public function testAcmeChallenge() { - // Preparation - $previousEndpoint = $this->client->getEndpoint(); - $this->client->setEndpoint("http://localhost"); - /** * Test for SUCCESS */ - $response = $this->client->call(Client::METHOD_GET, '/.well-known/acme-challenge/8DdIKX257k6Dih5s_saeVMpTnjPJdKO5Ase0OCiJrIg', \array_merge([ - 'origin' => 'http://localhost', - ])); + $response = $this->client->call(Client::METHOD_GET, '/.well-known/acme-challenge/8DdIKX257k6Dih5s_saeVMpTnjPJdKO5Ase0OCiJrIg'); // 'Unknown path', but validation passed $this->assertEquals(404, $response['headers']['status-code']); @@ -83,15 +77,10 @@ class HTTPTest extends Scope /** * Test for FAILURE */ - $response = $this->client->call(Client::METHOD_GET, '/.well-known/acme-challenge/../../../../../../../etc/passwd', \array_merge([ - 'origin' => 'http://localhost', - ])); + $response = $this->client->call(Client::METHOD_GET, '/.well-known/acme-challenge/../../../../../../../etc/passwd'); - // Check for too many path segments - $this->assertEquals(400, $response['headers']['status-code']); - - // Cleanup - $this->client->setEndpoint($previousEndpoint); + // 'Unknown path', but validation passed + $this->assertEquals(404, $response['headers']['status-code']); } public function testSpecs() @@ -172,48 +161,30 @@ class HTTPTest extends Scope public function testCors() { - /** - * Test for SUCCESS - */ $endpoint = '/v1/projects'; // Can be any non-404 route - $response = $this->client->call(Client::METHOD_GET, $endpoint); - - $this->assertEquals('http://localhost', $response['headers']['access-control-allow-origin']); - + /** + * Test for SUCCESS + */ $response = $this->client->call(Client::METHOD_GET, $endpoint, [ 'origin' => 'http://localhost', ]); - $this->assertEquals('http://localhost', $response['headers']['access-control-allow-origin']); - $response = $this->client->call(Client::METHOD_GET, $endpoint, [ - 'origin' => 'http://appwrite.io', - ]); - - $this->assertEquals('http://appwrite.io', $response['headers']['access-control-allow-origin']); - - $response = $this->client->call(Client::METHOD_GET, $endpoint, [ - 'origin' => 'https://appwrite.io', - ]); - - $this->assertEquals('https://appwrite.io', $response['headers']['access-control-allow-origin']); - - $response = $this->client->call(Client::METHOD_GET, $endpoint, [ - 'origin' => 'http://cloud.appwrite.io', - ]); - - $this->assertEquals('http://cloud.appwrite.io', $response['headers']['access-control-allow-origin']); - /** * Test for FAILURE */ + // you should not return a fallback origin for a no host + $response = $this->client->call(Client::METHOD_GET, $endpoint); + $this->assertNull($response['headers']['access-control-allow-origin'] ?? null); + + // you should not return a fallback origin for a no host $response = $this->client->call(Client::METHOD_GET, $endpoint, [ 'origin' => 'http://google.com', ]); + $this->assertNull($response['headers']['access-control-allow-origin'] ?? null); - $this->assertEquals('http://localhost', $response['headers']['access-control-allow-origin']); } public function testConsoleRedirect() diff --git a/tests/e2e/General/HooksTest.php b/tests/e2e/General/HooksTest.php index af6ccab0d5..1e7d87608f 100644 --- a/tests/e2e/General/HooksTest.php +++ b/tests/e2e/General/HooksTest.php @@ -17,7 +17,7 @@ class HooksTest extends Scope public function setUp(): void { parent::setUp(); - $this->client->setEndpoint('http://traefik'); + $this->client->setEndpoint('http://appwrite.test'); } public function testProjectHooks() diff --git a/tests/e2e/Scopes/Scope.php b/tests/e2e/Scopes/Scope.php index 5b7f1a8771..8731a29672 100644 --- a/tests/e2e/Scopes/Scope.php +++ b/tests/e2e/Scopes/Scope.php @@ -18,7 +18,7 @@ abstract class Scope extends TestCase public const REQUEST_TYPE_SMS = 'sms'; protected ?Client $client = null; - protected string $endpoint = 'http://localhost/v1'; + protected string $endpoint = 'http://appwrite.test/v1'; protected function setUp(): void { diff --git a/tests/e2e/Services/Account/AccountBase.php b/tests/e2e/Services/Account/AccountBase.php index b217608395..13b5015241 100644 --- a/tests/e2e/Services/Account/AccountBase.php +++ b/tests/e2e/Services/Account/AccountBase.php @@ -50,21 +50,6 @@ trait AccountBase /** * Test for FAILURE */ - // Deny request from blocked IP - $response = $this->client->call(Client::METHOD_POST, '/account', array_merge([ - 'origin' => 'http://localhost', - 'content-type' => 'application/json', - 'x-appwrite-project' => 'console', - 'x-forwarded-for' => '31.6.14.220' // Test IP for denied access region - ]), [ - 'userId' => ID::unique(), - 'email' => $email, - 'password' => $password, - 'name' => $name, - ]); - - $this->assertEquals(451, $response['headers']['status-code']); - $response = $this->client->call(Client::METHOD_POST, '/account', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', diff --git a/tests/e2e/Services/Account/AccountCustomClientTest.php b/tests/e2e/Services/Account/AccountCustomClientTest.php index 0163f1b842..32232bab51 100644 --- a/tests/e2e/Services/Account/AccountCustomClientTest.php +++ b/tests/e2e/Services/Account/AccountCustomClientTest.php @@ -2130,10 +2130,15 @@ class AccountCustomClientTest extends Scope 'failure' => 'http://localhost/v1/mock/tests/general/oauth2/failure', ]); - $session = $response['cookies']['a_session_' . $this->getProject()['$id']]; - $this->assertEquals(200, $response['headers']['status-code']); - $this->assertEquals('success', $response['body']['result']); + + $sessionCookieKey = 'a_session_' . $this->getProject()['$id']; + $this->assertArrayHasKey( + $sessionCookieKey, + $response['cookies'], + "Failed asserting that session cookie '$sessionCookieKey' is set. Cookies: " . json_encode($response['cookies']) + ); + $session = $response['cookies'][$sessionCookieKey]; $response = $this->client->call(Client::METHOD_GET, '/account', array_merge([ 'origin' => 'http://localhost', diff --git a/tests/e2e/Services/Functions/FunctionsCustomServerTest.php b/tests/e2e/Services/Functions/FunctionsCustomServerTest.php index 8cc986b072..35bdf90347 100644 --- a/tests/e2e/Services/Functions/FunctionsCustomServerTest.php +++ b/tests/e2e/Services/Functions/FunctionsCustomServerTest.php @@ -459,7 +459,7 @@ class FunctionsCustomServerTest extends Scope $this->assertEquals("completed", $execution['body']['status']); $this->assertEquals(200, $execution['body']['responseStatusCode']); $this->assertEquals("Pong", $execution['body']['responseBody']); - $this->assertEmpty($execution['body']['errors']); + $this->assertEmpty($execution['body']['errors'], 'Failed to execute function, ' . json_encode($execution['body']['errors'])); // Test execution logged correct total users $users = $this->client->call(Client::METHOD_GET, '/users', array_merge([ diff --git a/tests/e2e/Services/GraphQL/ScopeTest.php b/tests/e2e/Services/GraphQL/ScopeTest.php index 4020e8330a..f3c80a7418 100644 --- a/tests/e2e/Services/GraphQL/ScopeTest.php +++ b/tests/e2e/Services/GraphQL/ScopeTest.php @@ -33,7 +33,7 @@ class ScopeTest extends Scope 'x-appwrite-key' => $apiKey, ], $gqlPayload); - $message = "app.{$projectId}@service.localhost (role: applications) missing scopes ([\"databases.write\"])"; + $message = "app.{$projectId}@service.appwrite.test (role: applications) missing scopes ([\"databases.write\"])"; $this->assertArrayHasKey('errors', $database['body']); $this->assertEquals($message, $database['body']['errors'][0]['message']); } diff --git a/tests/e2e/Services/Migrations/MigrationsBase.php b/tests/e2e/Services/Migrations/MigrationsBase.php index 490ac026b5..bed7a7e542 100644 --- a/tests/e2e/Services/Migrations/MigrationsBase.php +++ b/tests/e2e/Services/Migrations/MigrationsBase.php @@ -89,7 +89,7 @@ trait MigrationsBase { $response = $this->performMigrationSync([ 'resources' => Appwrite::getSupportedResources(), - 'endpoint' => 'http://localhost/v1', + 'endpoint' => $this->endpoint, 'projectId' => $this->getProject()['$id'], 'apiKey' => $this->getProject()['apiKey'], ]); @@ -126,7 +126,7 @@ trait MigrationsBase 'resources' => [ Resource::TYPE_USER, ], - 'endpoint' => 'http://localhost/v1', + 'endpoint' => $this->endpoint, 'projectId' => $this->getProject()['$id'], 'apiKey' => $this->getProject()['apiKey'], ]); @@ -188,7 +188,7 @@ trait MigrationsBase 'resources' => [ Resource::TYPE_USER, ], - 'endpoint' => 'http://localhost/v1', + 'endpoint' => $this->endpoint, 'projectId' => $this->getProject()['$id'], 'apiKey' => $this->getProject()['apiKey'], ]); @@ -277,7 +277,7 @@ trait MigrationsBase Resource::TYPE_TEAM, Resource::TYPE_MEMBERSHIP, ], - 'endpoint' => 'http://localhost/v1', + 'endpoint' => $this->endpoint, 'projectId' => $this->getProject()['$id'], 'apiKey' => $this->getProject()['apiKey'], ]); @@ -393,7 +393,7 @@ trait MigrationsBase 'resources' => [ Resource::TYPE_DATABASE, ], - 'endpoint' => 'http://localhost/v1', + 'endpoint' => $this->endpoint, 'projectId' => $this->getProject()['$id'], 'apiKey' => $this->getProject()['apiKey'], ]); @@ -484,7 +484,7 @@ trait MigrationsBase Resource::TYPE_TABLE, Resource::TYPE_COLUMN, ], - 'endpoint' => 'http://localhost/v1', + 'endpoint' => $this->endpoint, 'projectId' => $this->getProject()['$id'], 'apiKey' => $this->getProject()['apiKey'], ]); @@ -571,7 +571,7 @@ trait MigrationsBase Resource::TYPE_COLUMN, Resource::TYPE_ROW, ], - 'endpoint' => 'http://localhost/v1', + 'endpoint' => $this->endpoint, 'projectId' => $this->getProject()['$id'], 'apiKey' => $this->getProject()['apiKey'], ]); @@ -651,7 +651,7 @@ trait MigrationsBase 'resources' => [ Resource::TYPE_BUCKET ], - 'endpoint' => 'http://localhost/v1', + 'endpoint' => $this->endpoint, 'projectId' => $this->getProject()['$id'], 'apiKey' => $this->getProject()['apiKey'], ]); @@ -747,7 +747,7 @@ trait MigrationsBase Resource::TYPE_BUCKET, Resource::TYPE_FILE ], - 'endpoint' => 'http://localhost/v1', + 'endpoint' => $this->endpoint, 'projectId' => $this->getProject()['$id'], 'apiKey' => $this->getProject()['apiKey'], ]); @@ -818,7 +818,7 @@ trait MigrationsBase Resource::TYPE_FUNCTION, Resource::TYPE_DEPLOYMENT ], - 'endpoint' => 'http://localhost/v1', + 'endpoint' => $this->endpoint, 'projectId' => $this->getProject()['$id'], 'apiKey' => $this->getProject()['apiKey'], ]); @@ -1119,7 +1119,7 @@ trait MigrationsBase // all data exists, pass. $migration = $this->performCsvMigration( [ - 'endpoint' => 'http://localhost/v1', + 'endpoint' => $this->endpoint, 'fileId' => $fileIds['default'], 'bucketId' => $bucketIds['default'], 'resourceId' => $databaseId . ':' . $tableId, @@ -1161,7 +1161,7 @@ trait MigrationsBase // all data exists and includes internals, pass. $migration = $this->performCsvMigration( [ - 'endpoint' => 'http://localhost/v1', + 'endpoint' => $this->endpoint, 'fileId' => $fileIds['documents-internals'], 'bucketId' => $bucketIds['documents-internals'], 'resourceId' => $databaseId . ':' . $tableId, diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index 91dce5c09c..9526c5a4da 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -4889,7 +4889,8 @@ class ProjectsConsoleClientTest extends Scope $this->assertEquals(403, $response['headers']['status-code']); $this->assertNotEquals($origin, $response['headers']['access-control-allow-origin'] ?? null); - $this->assertEquals('http://localhost', $response['headers']['access-control-allow-origin'] ?? null); + // you should not return a fallback origin for a disallowed host + $this->assertNull($response['headers']['access-control-allow-origin'] ?? null); /** @@ -4906,7 +4907,7 @@ class ProjectsConsoleClientTest extends Scope ]); $this->assertEquals(401, $response['headers']['status-code']); - $this->assertEquals('*', $response['headers']['access-control-allow-origin'] ?? null); + $this->assertEquals($origin, $response['headers']['access-control-allow-origin'] ?? null); } /** diff --git a/tests/e2e/Services/Projects/ProjectsCustomServerTest.php b/tests/e2e/Services/Projects/ProjectsCustomServerTest.php index 68ff53ae55..b2cf57ddc4 100644 --- a/tests/e2e/Services/Projects/ProjectsCustomServerTest.php +++ b/tests/e2e/Services/Projects/ProjectsCustomServerTest.php @@ -81,16 +81,11 @@ class ProjectsCustomServerTest extends Scope $this->assertEquals(400, $response['headers']['status-code']); - $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, + 'sites.localhost', + 'functions.localhost', + 'appwrite.test', + 'localhost' ]; foreach ($deniedDomains as $deniedDomain) { diff --git a/tests/e2e/Services/Proxy/ProxyCustomServerTest.php b/tests/e2e/Services/Proxy/ProxyCustomServerTest.php index 5a1cd1dea6..3fbbb7d5e9 100644 --- a/tests/e2e/Services/Proxy/ProxyCustomServerTest.php +++ b/tests/e2e/Services/Proxy/ProxyCustomServerTest.php @@ -103,12 +103,11 @@ class ProxyCustomServerTest extends Scope $domain = \uniqid() . '-api.custom.localhost'; $proxyClient = new Client(); - $proxyClient->setEndpoint('http://' . $domain); + $proxyClient->setEndpoint('http://appwrite.test'); + $proxyClient->addHeader('x-appwrite-hostname', $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']); + $this->assertEquals(401, $response['headers']['status-code']); $ruleId = $this->setupAPIRule($domain); @@ -138,11 +137,11 @@ class ProxyCustomServerTest extends Scope $domain = \uniqid() . '-redirect.custom.localhost'; $proxyClient = new Client(); - $proxyClient->setEndpoint('http://appwrite'); + $proxyClient->setEndpoint('http://appwrite.test'); $proxyClient->addHeader('x-appwrite-hostname', $domain); $response = $proxyClient->call(Client::METHOD_GET, '/todos/1'); - $this->assertEquals(404, $response['headers']['status-code']); + $this->assertEquals(401, $response['headers']['status-code']); $siteId = $this->setupSite()['siteId']; @@ -166,7 +165,7 @@ class ProxyCustomServerTest extends Scope $this->assertNotEmpty($ruleId); $proxyClient = new Client(); - $proxyClient->setEndpoint('http://appwrite'); + $proxyClient->setEndpoint('http://appwrite.test'); $proxyClient->addHeader('x-appwrite-hostname', $domain); $response = $proxyClient->call(Client::METHOD_GET, '/', followRedirects: false); @@ -193,11 +192,11 @@ class ProxyCustomServerTest extends Scope $domain = \uniqid() . '-function.custom.localhost'; $proxyClient = new Client(); - $proxyClient->setEndpoint('http://appwrite'); + $proxyClient->setEndpoint('http://appwrite.test'); $proxyClient->addHeader('x-appwrite-hostname', $domain); $response = $proxyClient->call(Client::METHOD_GET, '/ping'); - $this->assertEquals(404, $response['headers']['status-code']); + $this->assertEquals(401, $response['headers']['status-code']); $setup = $this->setupFunction(); $functionId = $setup['functionId']; @@ -248,11 +247,11 @@ class ProxyCustomServerTest extends Scope $domain = \uniqid() . '-site.custom.localhost'; $proxyClient = new Client(); - $proxyClient->setEndpoint('http://appwrite'); + $proxyClient->setEndpoint('http://appwrite.test'); $proxyClient->addHeader('x-appwrite-hostname', $domain); $response = $proxyClient->call(Client::METHOD_GET, '/contact'); - $this->assertEquals(404, $response['headers']['status-code']); + $this->assertEquals(401, $response['headers']['status-code']); $setup = $this->setupSite(); $siteId = $setup['siteId']; diff --git a/tests/e2e/Services/Realtime/RealtimeBase.php b/tests/e2e/Services/Realtime/RealtimeBase.php index e9b60c4067..89bd1898c4 100644 --- a/tests/e2e/Services/Realtime/RealtimeBase.php +++ b/tests/e2e/Services/Realtime/RealtimeBase.php @@ -22,7 +22,7 @@ trait RealtimeBase ]; return new WebSocketClient( - "ws://appwrite-traefik/v1/realtime?" . http_build_query($query), + "ws://appwrite.test/v1/realtime?" . http_build_query($query), [ "headers" => $headers, "timeout" => 30, diff --git a/tests/e2e/Services/Sites/SitesCustomClientTest.php b/tests/e2e/Services/Sites/SitesCustomClientTest.php index 0434e4338b..d576062c8f 100644 --- a/tests/e2e/Services/Sites/SitesCustomClientTest.php +++ b/tests/e2e/Services/Sites/SitesCustomClientTest.php @@ -6,7 +6,6 @@ use Tests\E2E\Client; use Tests\E2E\Scopes\ProjectCustom; use Tests\E2E\Scopes\Scope; use Tests\E2E\Scopes\SideClient; -use Utopia\System\System; class SitesCustomClientTest extends Scope { @@ -122,7 +121,6 @@ class SitesCustomClientTest extends Scope * Test for SUCCESS */ $template = $this->getTemplate('starter-for-react'); - $hostname = System::getEnv('_APP_DOMAIN') ?: ''; $this->assertEquals(200, $template['headers']['status-code']); $this->assertIsArray($template['body']); $this->assertEquals('starter-for-react', $template['body']['key']); diff --git a/tests/e2e/Services/Sites/SitesCustomServerTest.php b/tests/e2e/Services/Sites/SitesCustomServerTest.php index b7dc9e7334..22a33fbf4d 100644 --- a/tests/e2e/Services/Sites/SitesCustomServerTest.php +++ b/tests/e2e/Services/Sites/SitesCustomServerTest.php @@ -1962,7 +1962,7 @@ class SitesCustomServerTest extends Scope 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'referer' => $url, - 'origin' => $url + 'origin' => $url, ])); $this->assertEquals($url, $response['headers']['access-control-allow-origin']); @@ -1971,11 +1971,10 @@ class SitesCustomServerTest extends Scope 'content-type' => 'application/json', 'x-appwrite-project' => 'unknown', 'referer' => $url, - 'origin' => $url + 'origin' => $url, ])); - $this->assertNotEquals($url, $response['headers']['access-control-allow-origin']); - $this->assertEquals('http://localhost', $response['headers']['access-control-allow-origin']); + $this->assertArrayNotHasKey('access-control-allow-origin', $response['headers']); $response = $this->client->call(Client::METHOD_GET, '/account', array_merge([ 'content-type' => 'application/json', @@ -1984,8 +1983,7 @@ class SitesCustomServerTest extends Scope 'origin' => 'http://unknown.com' ])); - $this->assertNotEquals($url, $response['headers']['access-control-allow-origin']); - $this->assertEquals('http://localhost', $response['headers']['access-control-allow-origin']); + $this->assertArrayNotHasKey('access-control-allow-origin', $response['headers']); } public function testSiteDownload(): void diff --git a/tests/e2e/Services/Webhooks/WebhooksCustomClientTest.php b/tests/e2e/Services/Webhooks/WebhooksCustomClientTest.php index bcc4ede30a..0ffdf50e76 100644 --- a/tests/e2e/Services/Webhooks/WebhooksCustomClientTest.php +++ b/tests/e2e/Services/Webhooks/WebhooksCustomClientTest.php @@ -197,7 +197,6 @@ class WebhooksCustomClientTest extends Scope $this->assertNotEmpty($webhook['data']['$id']); $this->assertNotEmpty($webhook['data']['userId']); $this->assertEquals(true, (new DatetimeValidator())->isValid($webhook['data']['expire'])); - $this->assertEquals($webhook['data']['ip'], '127.0.0.1'); $this->assertNotEmpty($webhook['data']['osCode']); $this->assertIsString($webhook['data']['osCode']); $this->assertNotEmpty($webhook['data']['osName']); @@ -286,7 +285,6 @@ class WebhooksCustomClientTest extends Scope $this->assertNotEmpty($webhook['data']['$id']); $this->assertNotEmpty($webhook['data']['userId']); $this->assertIsString($webhook['data']['expire']); - $this->assertEquals($webhook['data']['ip'], '127.0.0.1'); $this->assertNotEmpty($webhook['data']['osCode']); $this->assertIsString($webhook['data']['osCode']); $this->assertNotEmpty($webhook['data']['osName']); @@ -372,7 +370,6 @@ class WebhooksCustomClientTest extends Scope $this->assertNotEmpty($webhook['data']['$id']); $this->assertNotEmpty($webhook['data']['userId']); $this->assertEquals(true, (new DatetimeValidator())->isValid($webhook['data']['expire'])); - $this->assertEquals($webhook['data']['ip'], '127.0.0.1'); $this->assertNotEmpty($webhook['data']['osCode']); $this->assertIsString($webhook['data']['osCode']); $this->assertNotEmpty($webhook['data']['osName']); diff --git a/tests/unit/Network/CorsTest.php b/tests/unit/Network/CorsTest.php new file mode 100644 index 0000000000..521bf21f1e --- /dev/null +++ b/tests/unit/Network/CorsTest.php @@ -0,0 +1,150 @@ +expectException(InvalidArgumentException::class); + + new Cors( + allowedHosts: ['*'], + allowedMethods: ['GET'], + allowedHeaders: ['X-Test'], + exposedHeaders: [], + allowCredentials: true + ); + } + + public function testWildcardAllowsAnyOrigin(): void + { + $cors = new Cors( + allowedHosts: ['*'], + allowedMethods: ['GET'], + allowedHeaders: ['X-Test'], + exposedHeaders: [], + allowCredentials: false + ); + + $result = $cors->headers('https://foo.com'); + + $this->assertSame('https://foo.com', $result[Cors::HEADER_ALLOW_ORIGIN]); + } + + public function testEmptyOriginReturnsStaticHeadersOnly(): void + { + $cors = new Cors( + allowedHosts: ['example.com'], + allowedMethods: ['GET'], + allowedHeaders: ['X-Test'], + exposedHeaders: [], + allowCredentials: false + ); + + $result = $cors->headers(''); + + $this->assertArrayNotHasKey(Cors::HEADER_ALLOW_ORIGIN, $result); + $this->assertSame('false', $result[Cors::HEADER_ALLOW_CREDENTIALS]); + $this->assertSame('GET', $result[Cors::HEADER_ALLOW_METHODS]); + } + + public function testInvalidOriginReturnsStaticHeadersOnly(): void + { + $cors = new Cors( + allowedHosts: ['example.com'], + allowedMethods: ['GET'], + allowedHeaders: ['X-Test'], + exposedHeaders: [], + allowCredentials: false + ); + + $result = $cors->headers('%%%not-a-url%%%'); + + $this->assertArrayNotHasKey(Cors::HEADER_ALLOW_ORIGIN, $result); + } + + public function testUnlistedOriginReturnsStaticHeadersOnly(): void + { + $cors = new Cors( + allowedHosts: ['allowed.com'], + allowedMethods: ['GET'], + allowedHeaders: ['X-Test'], + exposedHeaders: [], + allowCredentials: false + ); + + $result = $cors->headers('https://forbidden.com'); + + $this->assertArrayNotHasKey(Cors::HEADER_ALLOW_ORIGIN, $result); + } + + public function testAllowedOriginIsReturned(): void + { + $cors = new Cors( + allowedHosts: ['example.com'], + allowedMethods: ['POST'], + allowedHeaders: ['X-Test'], + exposedHeaders: [], + allowCredentials: true + ); + + $result = $cors->headers('https://example.com'); + + $this->assertSame('https://example.com', $result[Cors::HEADER_ALLOW_ORIGIN]); + } + + public function testOriginIsLowercasedForMatching(): void + { + $cors = new Cors( + allowedHosts: ['example.com'], + allowedMethods: ['GET'], + allowedHeaders: ['X-Test'], + exposedHeaders: [], + allowCredentials: false + ); + + $result = $cors->headers('HTTPS://EXAMPLE.COM'); + + // Lowercase logic is in the class + $this->assertSame('https://example.com', $result[Cors::HEADER_ALLOW_ORIGIN]); + } + + public function testHeaderFormatting(): void + { + $cors = new Cors( + allowedHosts: ['example.com'], + allowedMethods: ['GET', 'POST'], + allowedHeaders: ['X-A', 'X-B'], + exposedHeaders: ['E1', 'E2'], + allowCredentials: true + ); + + $result = $cors->headers('https://example.com'); + + $this->assertSame('GET, POST', $result[Cors::HEADER_ALLOW_METHODS]); + $this->assertSame('X-A, X-B', $result[Cors::HEADER_ALLOW_HEADERS]); + $this->assertSame('E1, E2', $result[Cors::HEADER_EXPOSE_HEADERS]); + $this->assertSame('true', $result[Cors::HEADER_ALLOW_CREDENTIALS]); + } + + public function testMaxAgeIncluded(): void + { + $cors = new Cors( + allowedHosts: ['example.com'], + allowedMethods: ['GET'], + allowedHeaders: ['X-Test'], + exposedHeaders: [], + allowCredentials: false, + maxAge: 999 + ); + + $result = $cors->headers('https://example.com'); + + $this->assertSame(999, $result[Cors::HEADER_MAX_AGE]); + } +} diff --git a/tests/unit/Network/Validators/OriginTest.php b/tests/unit/Network/Validators/OriginTest.php index d312f8c5a5..a4c235f755 100644 --- a/tests/unit/Network/Validators/OriginTest.php +++ b/tests/unit/Network/Validators/OriginTest.php @@ -2,53 +2,17 @@ namespace Tests\Unit\Network\Validators; -use Appwrite\Network\Platform; use Appwrite\Network\Validator\Origin; use PHPUnit\Framework\TestCase; -use Utopia\Database\Helpers\ID; class OriginTest extends TestCase { public function testValues(): void { - $validator = new Origin([ - [ - '$collection' => ID::custom('platforms'), - 'name' => 'Production', - 'type' => Platform::TYPE_WEB, - 'hostname' => 'appwrite.io', - ], - [ - '$collection' => ID::custom('platforms'), - 'name' => 'Development', - 'type' => Platform::TYPE_WEB, - 'hostname' => 'appwrite.test', - ], - [ - '$collection' => ID::custom('platforms'), - 'name' => 'Localhost', - 'type' => Platform::TYPE_WEB, - 'hostname' => 'localhost', - ], - [ - '$collection' => ID::custom('platforms'), - 'name' => 'Flutter', - 'type' => Platform::TYPE_FLUTTER_WEB, - 'hostname' => 'appwrite.flutter', - ], - [ - '$collection' => ID::custom('platforms'), - 'name' => 'Expo', - 'type' => Platform::TYPE_SCHEME, - 'key' => 'exp', - ], - [ - '$collection' => ID::custom('platforms'), - 'name' => 'Appwrite Callback', - 'type' => Platform::TYPE_SCHEME, - 'key' => 'appwrite-callback-123', - ], - ]); + $validator = new Origin( + allowedHostnames: ['appwrite.io', 'appwrite.test', 'localhost', 'appwrite.flutter'], + allowedSchemes: ['exp', 'appwrite-callback-123'] + ); $this->assertEquals(false, $validator->isValid('')); $this->assertEquals(false, $validator->isValid('/'));