Merge branch '1.8.x' into CLO-3704-utopia-request-extend

This commit is contained in:
Levi van Noort
2025-12-15 15:32:51 +01:00
committed by GitHub
26 changed files with 323 additions and 95 deletions
+1
View File
@@ -42,6 +42,7 @@ jobs:
with:
context: .
platforms: linux/amd64,linux/arm64
target: production
build-args: |
VERSION=${{ steps.meta.outputs.version }}
VITE_APPWRITE_GROWTH_ENDPOINT=https://growth.appwrite.io/v1
+3 -2
View File
@@ -20,10 +20,10 @@ jobs:
submodules: recursive
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
uses: docker/login-action@v2
@@ -46,6 +46,7 @@ jobs:
with:
context: .
platforms: linux/amd64,linux/arm64
target: production
build-args: |
VERSION=${{ steps.meta.outputs.version }}
push: true
+23 -10
View File
@@ -12,7 +12,7 @@ RUN composer install --ignore-platform-reqs --optimize-autoloader \
--no-plugins --no-scripts --prefer-dist \
`if [ "$TESTING" != "true" ]; then echo "--no-dev"; fi`
FROM appwrite/base:0.10.6 AS final
FROM appwrite/base:0.10.6 AS base
LABEL maintainer="team@appwrite.io"
@@ -36,9 +36,7 @@ COPY --from=composer /usr/local/src/vendor /usr/src/code/vendor
COPY ./app /usr/src/code/app
COPY ./public /usr/src/code/public
COPY ./bin /usr/local/bin
COPY ./docs /usr/src/code/docs
COPY ./src /usr/src/code/src
COPY ./dev /usr/src/code/dev
# Set Volumes
RUN mkdir -p /storage/uploads && \
@@ -90,15 +88,30 @@ RUN chmod +x /usr/local/bin/doctor && \
chmod +x /usr/local/bin/stats-resources && \
chmod +x /usr/local/bin/worker-stats-resources
# Letsencrypt Permissions
RUN mkdir -p /etc/letsencrypt/live/ && chmod -Rf 755 /etc/letsencrypt/live/
# Enable Extensions
RUN if [ "$DEBUG" = "true" ]; then cp /usr/src/code/dev/xdebug.ini /usr/local/etc/php/conf.d/xdebug.ini; fi
RUN if [ "$DEBUG" = "true" ]; then mkdir -p /tmp/xdebug; fi
RUN if [ "$DEBUG" = "true" ]; then apk add --update --no-cache openssh-client github-cli; fi
RUN if [ "$DEBUG" = "false" ]; then rm -rf /usr/src/code/dev; fi
RUN if [ "$DEBUG" = "false" ]; then rm -f /usr/local/lib/php/extensions/no-debug-non-zts-20230831/xdebug.so; fi
FROM base AS production
RUN rm -rf /usr/src/code/app/config/specs && \
rm -f /usr/local/lib/php/extensions/no-debug-non-zts-20240924/xdebug.so && \
find /usr -name '*.a' -delete 2>/dev/null || true && \
find /usr -type d -name '__pycache__' -exec rm -rf {} + 2>/dev/null || true && \
find /usr -name '*.pyc' -delete 2>/dev/null || true
EXPOSE 80
CMD [ "php", "app/http.php" ]
FROM base AS development
COPY ./docs /usr/src/code/docs
COPY ./dev /usr/src/code/dev
RUN if [ "$DEBUG" = "true" ]; then \
cp /usr/src/code/dev/xdebug.ini /usr/local/etc/php/conf.d/xdebug.ini && \
mkdir -p /tmp/xdebug && \
apk add --update --no-cache openssh-client github-cli; \
fi
EXPOSE 80
+6 -2
View File
@@ -6,8 +6,12 @@ use Utopia\System\System;
* Platform configuration
*/
return [
'domain' => System::getEnv('_APP_DOMAIN', 'localhost'),
'consoleDomain' => System::getEnv('_APP_CONSOLE_DOMAIN', System::getEnv('_APP_DOMAIN', 'localhost')),
'apiHostname' => System::getEnv('_APP_DOMAIN', 'localhost'),
'consoleHostname' => System::getEnv('_APP_CONSOLE_DOMAIN', System::getEnv('_APP_DOMAIN', 'localhost')),
'hostnames' => array_filter(array_unique([
System::getEnv('_APP_DOMAIN', 'localhost'),
System::getEnv('_APP_CONSOLE_DOMAIN', 'localhost'),
])),
'platformName' => APP_EMAIL_PLATFORM_NAME,
'logoUrl' => APP_EMAIL_LOGO_URL,
'accentColor' => APP_EMAIL_ACCENT_COLOR,
+1 -1
View File
@@ -9,7 +9,7 @@ use Utopia\System\System;
$protocol = System::getEnv('_APP_OPTIONS_FORCE_HTTPS') === 'disabled' ? 'http' : 'https';
$platform = Config::getParam('platform', []);
$hostname = $platform['consoleDomain'] ?? '';
$hostname = $platform['consoleHostname'] ?? '';
$url = $protocol . '://' . $hostname;
+3 -3
View File
@@ -1325,7 +1325,7 @@ App::get('/v1/account/sessions/oauth2/:provider')
throw new Exception(Exception::PROJECT_PROVIDER_UNSUPPORTED);
}
$host = $platform['consoleDomain'] ?? '';
$host = $platform['consoleHostname'] ?? '';
$redirectBase = $protocol . '://' . $host;
if ($protocol === 'https' && $port !== '443') {
$redirectBase .= ':' . $port;
@@ -1978,7 +1978,7 @@ App::get('/v1/account/tokens/oauth2/:provider')
throw new Exception(Exception::PROJECT_PROVIDER_UNSUPPORTED);
}
$host = $platform['consoleDomain'] ?? '';
$host = $platform['consoleHostname'] ?? '';
$protocol = System::getEnv('_APP_OPTIONS_FORCE_HTTPS') == 'disabled' ? 'http' : 'https';
$port = $request->getPort();
$redirectBase = $protocol . '://' . $host;
@@ -2153,7 +2153,7 @@ App::post('/v1/account/tokens/magic-url')
if (empty($url)) {
$protocol = System::getEnv('_APP_OPTIONS_FORCE_HTTPS') === 'disabled' ? 'http' : 'https';
$host = $platform['consoleDomain'] ?? '';
$host = $platform['consoleHostname'] ?? '';
$port = $request->getPort();
$callbackBase = $protocol . '://' . $host;
if ($protocol === 'https' && $port !== '443') {
+3 -3
View File
@@ -132,7 +132,7 @@ $createGitDeployments = function (GitHub $github, string $providerInstallationId
$commentStatus = $isAuthorized ? 'waiting' : 'failed';
$protocol = System::getEnv('_APP_OPTIONS_FORCE_HTTPS') === 'disabled' ? 'http' : 'https';
$hostname = $platform['consoleDomain'] ?? '';
$hostname = $platform['consoleHostname'] ?? '';
$authorizeUrl = $protocol . '://' . $hostname . "/console/git/authorize-contributor?projectId={$projectId}&installationId={$installationId}&repositoryId={$repositoryId}&providerPullRequestId={$providerPullRequestId}";
@@ -555,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 = $platform['consoleDomain'] ?? '';
$hostname = $platform['consoleHostname'] ?? '';
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.');
@@ -614,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 = $platform['consoleDomain'] ?? '';
$hostname = $platform['consoleHostname'] ?? '';
$defaultState = [
'success' => $protocol . '://' . $hostname . "/console/project-$region-$projectId/settings/git-installations",
+25 -23
View File
@@ -59,7 +59,7 @@ 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, array $platform, string $previewHostname, ?Key $apiKey, array $domains)
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)
{
$host = $request->getHostname() ?? '';
if (!empty($previewHostname)) {
@@ -80,7 +80,8 @@ function router(App $utopia, Database $dbForPlatform, callable $getProjectDB, Sw
$errorView = __DIR__ . '/../views/general/error.phtml';
$protocol = System::getEnv('_APP_OPTIONS_FORCE_HTTPS') == 'disabled' ? 'http' : 'https';
$url = $protocol . '://' . $platform['consoleDomain'];
$url = $protocol . '://' . $platform['consoleHostname'];
$platformHostnames = $platform['hostnames'] ?? [];
if ($rule->isEmpty()) {
$appDomainFunctionsFallback = System::getEnv('_APP_DOMAIN_FUNCTIONS_FALLBACK', '');
@@ -101,7 +102,7 @@ function router(App $utopia, Database $dbForPlatform, callable $getProjectDB, Sw
throw $exception;
}
if (!in_array($host, $domains)) {
if (!in_array($host, $platformHostnames)) {
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);
}
@@ -269,7 +270,7 @@ function router(App $utopia, Database $dbForPlatform, callable $getProjectDB, Sw
}
if (!$authorized) {
$url = $protocol . "://" . $platform['consoleDomain'];
$url = $protocol . "://" . $platform['consoleHostname'];
$response
->addHeader('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0')
->addHeader('Pragma', 'no-cache')
@@ -858,15 +859,15 @@ App::init()
->inject('devKey')
->inject('apiKey')
->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) {
->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) {
/*
* Appwrite Router
*/
$hostname = $request->getHostname() ?? '';
$platformHostnames = $platform['hostnames'] ?? [];
// Only run Router when external domain
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)) {
if (!in_array($hostname, $platformHostnames) || !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);
}
}
@@ -1028,10 +1029,11 @@ App::init()
->inject('console')
->inject('dbForPlatform')
->inject('queueForCertificates')
->inject('domains')
->action(function (Request $request, Document $console, Database $dbForPlatform, Certificate $queueForCertificates, array $domains) {
->inject('platform')
->action(function (Request $request, Document $console, Database $dbForPlatform, Certificate $queueForCertificates, array $platform) {
$hostname = $request->getHostname();
$cache = Config::getParam('domains', []);
$cache = Config::getParam('hostnames', []);
$platformHostnames = $platform['hostnames'] ?? [];
// 1. Cache hit
if (array_key_exists($hostname, $cache)) {
@@ -1042,7 +1044,7 @@ App::init()
$domain = new Domain(!empty($hostname) ? $hostname : '');
if (empty($domain->get()) || !$domain->isKnown() || $domain->isTest()) {
$cache[$domain->get()] = false;
Config::setParam('domains', $cache);
Config::setParam('hostnames', $cache);
Console::warning($domain->get() . ' is not a publicly accessible domain. Skipping SSL certificate generation.');
return;
}
@@ -1053,7 +1055,7 @@ App::init()
}
// 3. Check if domain is a main domain
if (!in_array($domain->get(), $domains)) {
if (!in_array($domain->get(), $platformHostnames)) {
Console::warning($domain->get() . ' is not a main domain. Skipping SSL certificate generation.');
return;
}
@@ -1114,7 +1116,7 @@ App::init()
Console::info('Certificate already exists');
} finally {
$cache[$domain->get()] = true;
Config::setParam('domains', $cache);
Config::setParam('hostnames', $cache);
Authorization::reset();
}
});
@@ -1138,14 +1140,14 @@ App::options()
->inject('project')
->inject('devKey')
->inject('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) {
->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, Cors $cors) {
/*
* Appwrite Router
*/
$platformHostnames = $platform['hostnames'] ?? [];
// Only run Router when external domain
if (!in_array($request->getHostname(), $domains) || !empty($previewHostname)) {
if (!in_array($request->getHostname(), $platformHostnames) || !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);
}
@@ -1448,9 +1450,9 @@ App::get('/robots.txt')
->inject('platform')
->inject('previewHostname')
->inject('apiKey')
->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)) {
->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) {
$platformHostnames = $platform['hostnames'] ?? [];
if (in_array($request->getHostname(), $platformHostnames) || !empty($previewHostname)) {
$template = new View(__DIR__ . '/../views/general/robots.phtml');
$response->text($template->render(false));
} else {
@@ -1480,9 +1482,9 @@ App::get('/humans.txt')
->inject('platform')
->inject('previewHostname')
->inject('apiKey')
->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)) {
->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) {
$platformHostnames = $platform['hostnames'] ?? [];
if (in_array($request->getHostname(), $platformHostnames) || !empty($previewHostname)) {
$template = new View(__DIR__ . '/../views/general/humans.phtml');
$response->text($template->render(false));
} else {
+11 -30
View File
@@ -161,14 +161,6 @@ App::setResource('queueForStatsResources', function (Publisher $publisher) {
return new StatsResources($publisher);
}, ['publisher']);
/**
* 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
*/
@@ -183,33 +175,16 @@ App::setResource('platform', function (Request $request) {
if ($request->getPort() === '80' && $protocol !== 'http') {
$port = ':80';
}
$platform['endpoint'] = "$protocol://{$platform['domain']}{$port}/v1";
$platform['endpoint'] = "$protocol://{$platform['apiHostname']}{$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';
}
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];
App::setResource('allowedHostnames', function (array $platform, Document $project, Document $rule, Document $devKey, Request $request) {
$allowed = [...($platform['hostnames'] ?? [])];
/* Add platform configured hostnames */
if (!$project->isEmpty() && $project->getId() !== 'console') {
@@ -223,14 +198,20 @@ App::setResource('allowedHostnames', function (array $domains, Document $project
$allowed[] = $request->getHostname();
}
/* Allow the request origin if a dev key or rule is found */
$originHostname = parse_url($request->getOrigin(), PHP_URL_HOST);
/* Add request hostname for preflight requests */
if ($request->getMethod() === 'OPTIONS') {
$allowed[] = $originHostname;
}
/* Allow the request origin if a dev key or rule is found */
if ((!$rule->isEmpty() || !$devKey->isEmpty()) && !empty($originHostname)) {
$allowed[] = $originHostname;
}
return array_unique($allowed);
}, ['domains', 'project', 'rule', 'devKey', 'request']);
}, ['platform', 'project', 'rule', 'devKey', 'request']);
/**
* List of allowed request schemes for the request.
+1 -1
View File
@@ -17,7 +17,7 @@ $buttons = [];
$protocol = System::getEnv('_APP_OPTIONS_FORCE_HTTPS') == 'disabled' ? 'http' : 'https';
$platform = Config::getParam('platform', []);
$hostname = $platform['consoleDomain'] ?? '';
$hostname = $platform['consoleHostname'] ?? '';
// TODO: remove this later
if (System::getEnv('_APP_ENV') === 'development') {
$hostname = 'localhost';
+1
View File
@@ -54,6 +54,7 @@ services:
image: appwrite-dev
build:
context: .
target: development
args:
DEBUG: false
TESTING: true
+92
View File
@@ -16,6 +16,8 @@ class Mail extends Event
protected string $bodyTemplate = '';
protected array $attachment = [];
protected array $customMailOptions = [];
public function __construct(protected Publisher $publisher)
{
parent::__construct($publisher);
@@ -400,6 +402,94 @@ class Mail extends Event
return $this;
}
/**
* Set sender email
*
* @param string $email
* @return self
*/
public function setSenderEmail(string $email): self
{
$this->customMailOptions['senderEmail'] = $email;
return $this;
}
/**
* Get sender email
*
* @return string
*/
public function getSenderEmail(): string
{
return $this->customMailOptions['senderEmail'] ?? '';
}
/**
* Set sender name
*
* @param string $name
* @return self
*/
public function setSenderName(string $name): self
{
$this->customMailOptions['senderName'] = $name;
return $this;
}
/**
* Get sender name
*
* @return string
*/
public function getSenderName(): string
{
return $this->customMailOptions['senderName'] ?? '';
}
/**
* Set reply-to email
*
* @param string $email
* @return self
*/
public function setReplyToEmail(string $email): self
{
$this->customMailOptions['replyToEmail'] = $email;
return $this;
}
/**
* Get reply-to email
*
* @return string
*/
public function getReplyToEmail(): string
{
return $this->customMailOptions['replyToEmail'] ?? '';
}
/**
* Set reply-to name
*
* @param string $name
* @return self
*/
public function setReplyToName(string $name): self
{
$this->customMailOptions['replyToName'] = $name;
return $this;
}
/**
* Get reply-to name
*
* @return string
*/
public function getReplyToName(): string
{
return $this->customMailOptions['replyToName'] ?? '';
}
/**
* Reset
*
@@ -415,6 +505,7 @@ class Mail extends Event
$this->variables = [];
$this->bodyTemplate = '';
$this->attachment = [];
$this->customMailOptions = [];
return $this;
}
@@ -436,6 +527,7 @@ class Mail extends Event
'smtp' => $this->smtp,
'variables' => $this->variables,
'attachment' => $this->attachment,
'customMailOptions' => $this->customMailOptions,
'events' => Event::generateEvents($this->getEvent(), $this->getParams())
];
}
+4 -1
View File
@@ -2,6 +2,8 @@
namespace Appwrite\Network;
use Utopia\Validator\Hostname;
/**
* Generate CORS response headers for an incoming request.
*
@@ -76,7 +78,8 @@ final class Cors
}
// Match only by host
if (!\in_array($host, $this->allowedHosts, true)) {
$validator = new Hostname($this->allowedHosts);
if (!$validator->isValid($host)) {
return $headers;
}
@@ -333,6 +333,8 @@ class Create extends Action
->setParam('userId', $user->getId())
->setParam('challengeId', $challenge->getId());
$response->dynamic($challenge, Response::MODEL_MFA_CHALLENGE);
$response
->setStatusCode(Response::STATUS_CODE_CREATED)
->dynamic($challenge, Response::MODEL_MFA_CHALLENGE);
}
}
@@ -110,7 +110,7 @@ class Update extends Action
$recoveryCodeChallenge = function (Document $challenge, Document $user, string $otp) use ($dbForProject) {
if (
$challenge->isSet('type') &&
$challenge->getAttribute('type') === \strtolower(Type::RECOVERY_CODE)
$challenge->getAttribute('type') === Type::RECOVERY_CODE
) {
$mfaRecoveryCodes = $user->getAttribute('mfaRecoveryCodes', []);
if (\in_array($otp, $mfaRecoveryCodes)) {
@@ -132,7 +132,7 @@ class Update extends Action
Type::TOTP => Challenge\TOTP::challenge($challenge, $user, $otp),
Type::PHONE => Challenge\Phone::challenge($challenge, $user, $otp),
Type::EMAIL => Challenge\Email::challenge($challenge, $user, $otp),
\strtolower(Type::RECOVERY_CODE) => $recoveryCodeChallenge($challenge, $user, $otp),
Type::RECOVERY_CODE => $recoveryCodeChallenge($challenge, $user, $otp),
default => false
});
@@ -101,6 +101,8 @@ class Create extends Action
'recoveryCodes' => $mfaRecoveryCodes
]);
$response->dynamic($document, Response::MODEL_MFA_RECOVERY_CODES);
$response
->setStatusCode(Response::STATUS_CODE_CREATED)
->dynamic($document, Response::MODEL_MFA_RECOVERY_CODES);
}
}
@@ -59,7 +59,7 @@ class Get extends Action
->param('type', '', new WhiteList(['rules']), 'Resource type.')
->inject('response')
->inject('dbForPlatform')
->inject('domains')
->inject('platform')
->callback($this->action(...));
}
@@ -68,8 +68,9 @@ class Get extends Action
string $type,
Response $response,
Database $dbForPlatform,
array $domains
array $platform
) {
$domains = $platform['hostnames'] ?? [];
if ($type === 'rules') {
$sitesDomain = System::getEnv('_APP_DOMAIN_SITES', '');
$functionsDomain = System::getEnv('_APP_DOMAIN_FUNCTIONS', '');
@@ -67,12 +67,13 @@ class Create extends Action
->inject('queueForCertificates')
->inject('queueForEvents')
->inject('dbForPlatform')
->inject('domains')
->inject('platform')
->callback($this->action(...));
}
public function action(string $domain, Response $response, Document $project, Certificate $queueForCertificates, Event $queueForEvents, Database $dbForPlatform, array $domains)
public function action(string $domain, Response $response, Document $project, Certificate $queueForCertificates, Event $queueForEvents, Database $dbForPlatform, array $platform)
{
$domains = $platform['hostnames'] ?? [];
$sitesDomain = System::getEnv('_APP_DOMAIN_SITES', '');
$functionsDomain = System::getEnv('_APP_DOMAIN_FUNCTIONS', '');
@@ -72,12 +72,13 @@ class Create extends Action
->inject('queueForEvents')
->inject('dbForPlatform')
->inject('dbForProject')
->inject('domains')
->inject('platform')
->callback($this->action(...));
}
public function action(string $domain, string $functionId, string $branch, Response $response, Document $project, Certificate $queueForCertificates, Event $queueForEvents, Database $dbForPlatform, Database $dbForProject, array $domains)
public function action(string $domain, string $functionId, string $branch, Response $response, Document $project, Certificate $queueForCertificates, Event $queueForEvents, Database $dbForPlatform, Database $dbForProject, array $platform)
{
$domains = $platform['hostnames'] ?? [];
$sitesDomain = System::getEnv('_APP_DOMAIN_SITES', '');
$functionsDomain = System::getEnv('_APP_DOMAIN_FUNCTIONS', '');
@@ -75,12 +75,13 @@ class Create extends Action
->inject('queueForEvents')
->inject('dbForPlatform')
->inject('dbForProject')
->inject('domains')
->inject('platform')
->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, array $domains)
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 $platform)
{
$domains = $platform['hostnames'] ?? [];
$sitesDomain = System::getEnv('_APP_DOMAIN_SITES', '');
$functionsDomain = System::getEnv('_APP_DOMAIN_FUNCTIONS', '');
@@ -72,12 +72,13 @@ class Create extends Action
->inject('queueForEvents')
->inject('dbForPlatform')
->inject('dbForProject')
->inject('domains')
->inject('platform')
->callback($this->action(...));
}
public function action(string $domain, string $siteId, string $branch, Response $response, Document $project, Certificate $queueForCertificates, Event $queueForEvents, Database $dbForPlatform, Database $dbForProject, array $domains)
public function action(string $domain, string $siteId, string $branch, Response $response, Document $project, Certificate $queueForCertificates, Event $queueForEvents, Database $dbForPlatform, Database $dbForProject, array $platform)
{
$domains = $platform['hostnames'] ?? [];
$sitesDomain = System::getEnv('_APP_DOMAIN_SITES', '');
$functionsDomain = System::getEnv('_APP_DOMAIN_FUNCTIONS', '');
+15 -3
View File
@@ -152,9 +152,21 @@ class Mails extends Action
$replyTo = System::getEnv('_APP_SYSTEM_EMAIL_ADDRESS', APP_EMAIL_TEAM);
$replyToName = \urldecode(System::getEnv('_APP_SYSTEM_EMAIL_NAME', APP_NAME . ' Server'));
if (!empty($smtp)) {
$replyTo = !empty($smtp['replyTo']) ? $smtp['replyTo'] : $smtp['senderEmail'];
$replyToName = $smtp['senderName'];
$customMailOptions = $payload['customMailOptions'] ?? [];
// fallback hierarchy: Custom options > SMTP config > Defaults.
if (!empty($customMailOptions['senderEmail']) || !empty($customMailOptions['senderName'])) {
$fromEmail = $customMailOptions['senderEmail'] ?? $mail->From;
$fromName = $customMailOptions['senderName'] ?? $mail->FromName;
$mail->setFrom($fromEmail, $fromName);
}
if (!empty($customMailOptions['replyToEmail']) || !empty($customMailOptions['replyToName'])) {
$replyTo = $customMailOptions['replyToEmail'] ?? $replyTo;
$replyToName = $customMailOptions['replyToName'] ?? $replyToName;
} elseif (!empty($smtp)) {
$replyTo = !empty($smtp['replyTo']) ? $smtp['replyTo'] : ($smtp['senderEmail'] ?? $replyTo);
$replyToName = $smtp['senderName'] ?? $replyToName;
}
$mail->addReplyTo($replyTo, $replyToName);
+2 -2
View File
@@ -119,7 +119,7 @@ class Comment
$i = 0;
foreach ($projects as $projectId => $project) {
$protocol = System::getEnv('_APP_OPTIONS_FORCE_HTTPS') === 'disabled' ? 'http' : 'https';
$hostname = $this->platform['consoleDomain'] ?? '';
$hostname = $this->platform['consoleHostname'] ?? '';
$text .= "## {$project['name']}\n\n";
$text .= "Project ID: `{$projectId}`\n\n";
@@ -233,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 = $this->platform['consoleDomain'] ?? '';
$hostname = $this->platform['consoleHostname'] ?? '';
$imageLight = $protocol . '://' . $hostname . $pathLight;
$imageDark = $protocol . '://' . $hostname . $pathDark;
+15
View File
@@ -184,7 +184,22 @@ class HTTPTest extends Scope
'origin' => 'http://google.com',
]);
$this->assertNull($response['headers']['access-control-allow-origin'] ?? null);
}
public function testPreflight()
{
$endpoint = '/v1/projects'; // Can be any non-404 route
/**
* Test for SUCCESS
*/
$response = $this->client->call(Client::METHOD_OPTIONS, $endpoint, [
'origin' => 'http://random.com',
'access-control-request-headers' => 'X-Appwrite-Project',
'access-control-request-method' => 'GET'
]);
$this->assertEquals('http://random.com', $response['headers']['access-control-allow-origin']);
}
public function testConsoleRedirect()
@@ -3095,4 +3095,83 @@ class AccountCustomClientTest extends Scope
$this->assertEquals('test-identifier-updated', $response['body']['identifier']);
$this->assertEquals(false, $response['body']['expired']);
}
public function testMFARecoveryCodeChallenge(): void
{
// Generate recovery codes using existing authenticated session
$response = $this->client->call(Client::METHOD_POST, '/account/mfa/recovery-codes', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), []);
$this->assertEquals(201, $response['headers']['status-code']);
$this->assertNotEmpty($response['body']['recoveryCodes']);
$recoveryCodes = $response['body']['recoveryCodes'];
$this->assertGreaterThan(0, count($recoveryCodes));
// Create recovery code challenge
$challenge = $this->client->call(Client::METHOD_POST, '/account/mfa/challenge', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), [
'factor' => 'recoveryCode'
]);
$this->assertEquals(201, $challenge['headers']['status-code']);
$this->assertNotEmpty($challenge['body']['$id']);
$challengeId = $challenge['body']['$id'];
// Test SUCCESS: Verify with valid recovery code (this tests the bug fix)
$verification = $this->client->call(Client::METHOD_PUT, '/account/mfa/challenge', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), [
'challengeId' => $challengeId,
'otp' => $recoveryCodes[0]
]);
$this->assertEquals(200, $verification['headers']['status-code']);
$this->assertArrayHasKey('factors', $verification['body']);
$this->assertContains('recoveryCode', $verification['body']['factors']);
// Test that the code was consumed (can't use again)
$challenge2 = $this->client->call(Client::METHOD_POST, '/account/mfa/challenge', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), [
'factor' => 'recoveryCode'
]);
$this->assertEquals(201, $challenge2['headers']['status-code']);
$verification2 = $this->client->call(Client::METHOD_PUT, '/account/mfa/challenge', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), [
'challengeId' => $challenge2['body']['$id'],
'otp' => $recoveryCodes[0] // Same code should fail
]);
$this->assertEquals(401, $verification2['headers']['status-code']);
// Test FAILURE: Invalid recovery code
$challenge3 = $this->client->call(Client::METHOD_POST, '/account/mfa/challenge', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), [
'factor' => 'recoveryCode'
]);
$this->assertEquals(201, $challenge3['headers']['status-code']);
$verification3 = $this->client->call(Client::METHOD_PUT, '/account/mfa/challenge', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), [
'challengeId' => $challenge3['body']['$id'],
'otp' => 'invalid-code-123'
]);
$this->assertEquals(401, $verification3['headers']['status-code']);
}
}
+15
View File
@@ -36,6 +36,21 @@ final class CorsTest extends TestCase
$this->assertSame('https://foo.com', $result[Cors::HEADER_ALLOW_ORIGIN]);
}
public function testSubdomainWildcardAllowsAnySubdomain(): void
{
$cors = new Cors(
allowedHosts: ['*.example.com'],
allowedMethods: ['GET'],
allowedHeaders: ['X-Test'],
exposedHeaders: [],
allowCredentials: false
);
$result = $cors->headers('https://foo.example.com');
$this->assertSame('https://foo.example.com', $result[Cors::HEADER_ALLOW_ORIGIN]);
}
public function testEmptyOriginReturnsStaticHeadersOnly(): void
{
$cors = new Cors(