1
0
Fork 0
mirror of synced 2024-09-17 10:00:07 +12:00

Merge branch 'feat-update-delete-authenticator' into feat-implement-webauthn

This commit is contained in:
Bradley Schofield 2024-07-08 13:11:55 +09:00
commit c935ff9ec2
67 changed files with 2930 additions and 360 deletions

6
.env
View file

@ -4,6 +4,7 @@ _APP_LOCALE=en
_APP_WORKER_PER_CORE=6
_APP_CONSOLE_WHITELIST_ROOT=disabled
_APP_CONSOLE_WHITELIST_EMAILS=
_APP_CONSOLE_SESSION_ALERTS=enabled
_APP_CONSOLE_WHITELIST_IPS=
_APP_CONSOLE_COUNTRIES_DENYLIST=AQ
_APP_CONSOLE_HOSTNAMES=localhost,appwrite.io,*.appwrite.io
@ -17,7 +18,7 @@ _APP_OPTIONS_ROUTER_PROTECTION=disabled
_APP_OPTIONS_FORCE_HTTPS=disabled
_APP_OPTIONS_FUNCTIONS_FORCE_HTTPS=disabled
_APP_OPENSSL_KEY_V1=your-secret-key
_APP_DOMAIN=localhost
_APP_DOMAIN=traefik
_APP_DOMAIN_FUNCTIONS=functions.localhost
_APP_DOMAIN_TARGET=localhost
_APP_REDIS_HOST=redis
@ -85,7 +86,6 @@ _APP_USAGE_AGGREGATION_INTERVAL=30
_APP_MAINTENANCE_RETENTION_USAGE_HOURLY=8640000
_APP_MAINTENANCE_RETENTION_SCHEDULES=86400
_APP_USAGE_STATS=enabled
_APP_LOGGING_PROVIDER=
_APP_LOGGING_CONFIG=
_APP_GRAPHQL_MAX_BATCH_SIZE=10
_APP_GRAPHQL_MAX_COMPLEXITY=250
@ -105,4 +105,4 @@ _APP_MESSAGE_SMS_TEST_DSN=
_APP_MESSAGE_EMAIL_TEST_DSN=
_APP_MESSAGE_PUSH_TEST_DSN=
_APP_WEBHOOK_MAX_FAILED_ATTEMPTS=10
_APP_PROJECT_REGIONS=default
_APP_PROJECT_REGIONS=default

2
.gitmodules vendored
View file

@ -1,4 +1,4 @@
[submodule "app/console"]
path = app/console
url = https://github.com/appwrite/console
branch = 4.3.14
branch = 1.6.x

View file

@ -79,6 +79,7 @@ RUN chmod +x /usr/local/bin/doctor && \
chmod +x /usr/local/bin/migrate && \
chmod +x /usr/local/bin/realtime && \
chmod +x /usr/local/bin/schedule-functions && \
chmod +x /usr/local/bin/schedule-executions && \
chmod +x /usr/local/bin/schedule-messages && \
chmod +x /usr/local/bin/sdks && \
chmod +x /usr/local/bin/specs && \

View file

@ -3029,7 +3029,7 @@ $projectCollections = array_merge([
'size' => 8,
'signed' => true,
'required' => false,
'default' => 'v3',
'default' => 'v4',
'array' => false,
'filters' => [],
],
@ -3054,7 +3054,18 @@ $projectCollections = array_merge([
'required' => false,
'default' => null,
'filters' => [],
]
],
[
'$id' => ID::custom('scopes'),
'type' => Database::VAR_STRING,
'format' => '',
'size' => Database::LENGTH_KEY,
'signed' => true,
'required' => true,
'default' => null,
'array' => true,
'filters' => [],
],
],
'indexes' => [
[
@ -3867,6 +3878,27 @@ $projectCollections = array_merge([
'lengths' => [32],
'orders' => [Database::ORDER_ASC],
],
[
'$id' => ID::custom('_key_requestMethod'),
'type' => Database::INDEX_KEY,
'attributes' => ['requestMethod'],
'lengths' => [128],
'orders' => [Database::ORDER_ASC],
],
[
'$id' => ID::custom('_key_requestPath'),
'type' => Database::INDEX_KEY,
'attributes' => ['requestPath'],
'lengths' => [Database::LENGTH_KEY],
'orders' => [Database::ORDER_ASC],
],
[
'$id' => ID::custom('_key_deployment'),
'type' => Database::INDEX_KEY,
'attributes' => ['deploymentId'],
'lengths' => [Database::LENGTH_KEY],
'orders' => [Database::ORDER_ASC],
],
[
'$id' => ID::custom('_key_responseStatusCode'),
'type' => Database::INDEX_KEY,
@ -4518,6 +4550,17 @@ $consoleCollections = array_merge([
'array' => false,
'filters' => [],
],
[
'$id' => ID::custom('data'),
'type' => Database::VAR_STRING,
'format' => '',
'size' => 65535,
'signed' => true,
'required' => false,
'default' => new \stdClass(),
'array' => false,
'filters' => ['json', 'encrypt'],
],
[
'$id' => ID::custom('active'),
'type' => Database::VAR_BOOLEAN,

View file

@ -332,6 +332,11 @@ return [
'description' => 'API key and session used in the same request. Use either `setSession` or `setKey`. Learn about which authentication method to use in the SSR docs: https://appwrite.io/docs/products/auth/server-side-rendering',
'code' => 403,
],
Exception::API_KEY_EXPIRED => [
'name' => Exception::API_KEY_EXPIRED,
'description' => 'The dynamic API key has expired. Please don\'t use dynamic API keys for more than duration of the execution.',
'code' => 401,
],
/** Teams */
Exception::TEAM_NOT_FOUND => [
@ -541,6 +546,11 @@ return [
'description' => 'Build with the requested ID is already in progress. Please wait before you can retry.',
'code' => 400,
],
Exception::BUILD_ALREADY_COMPLETED => [
'name' => Exception::BUILD_ALREADY_COMPLETED,
'description' => 'Build with the requested ID is already completed and cannot be canceled.',
'code' => 400,
],
/** Deployments */
Exception::DEPLOYMENT_NOT_FOUND => [
@ -556,6 +566,12 @@ return [
'code' => 404,
],
Exception::EXECUTION_IN_PROGRESS => [
'name' => Exception::EXECUTION_IN_PROGRESS,
'description' => 'Can\'t delete ongoing execution. Please wait for execution to finish before deleting it.',
'code' => 400,
],
/** Databases */
Exception::DATABASE_NOT_FOUND => [
'name' => Exception::DATABASE_NOT_FOUND,

View file

@ -0,0 +1,14 @@
<p>{{hello}},</p>
<p>{{body}}</p>
<ol>
<li>{{listDevice}}</li>
<li>{{listIpAddress}}</li>
<li>{{listCountry}}</li>
</ol>
<p>{{footer}}</p>
<p style="margin-bottom: 0px;">{{thanks}}</p>
<p style="margin-top: 0px;">{{signature}}</p>

View file

@ -18,6 +18,15 @@
"emails.magicSession.securityPhrase": "Security phrase for this email is {{b}}{{phrase}}{{/b}}. You can trust this email if this phrase matches the phrase shown during sign in.",
"emails.magicSession.thanks": "Thanks,",
"emails.magicSession.signature": "{{project}} team",
"emails.sessionAlert.subject": "New session alert for {{project}}",
"emails.sessionAlert.hello":"Hello {{user}}",
"emails.sessionAlert.body": "We're writing to inform you that a new session has been initiated on your {{b}}{{project}}{{/b}} account, on {{b}}{{dateTime}}{{/b}}. \nHere are the details of the new session: ",
"emails.sessionAlert.listDevice": "Device: {{b}}{{device}}{{/b}}",
"emails.sessionAlert.listIpAddress": "IP Address: {{b}}{{ipAddress}}{{/b}}",
"emails.sessionAlert.listCountry": "Country: {{b}}{{country}}{{/b}}",
"emails.sessionAlert.footer": "If you didn't request the sign in, you can safely ignore this email. If you suspect unauthorized activity, please secure your account immediately.",
"emails.sessionAlert.thanks": "Thanks,",
"emails.sessionAlert.signature": "{{project}} team",
"emails.otpSession.subject": "OTP for {{project}} Login",
"emails.otpSession.hello": "Hello {{user}}",
"emails.otpSession.description": "Enter the following verification code when prompted to securely sign in to your {{b}}{{project}}{{/b}} account. This code will expire in 15 minutes.",
@ -34,7 +43,7 @@
"emails.recovery.subject": "Password Reset",
"emails.recovery.hello": "Hello {{user}}",
"emails.recovery.body": "Follow this link to reset your {{b}}{{project}}{{/b}} password.",
"emails.recovery.footer": "If you didnt ask to reset your password, you can ignore this message.",
"emails.recovery.footer": "If you didn't ask to reset your password, you can ignore this message.",
"emails.recovery.thanks": "Thanks",
"emails.recovery.signature": "{{project}} team",
"emails.invitation.subject": "Invitation to %s Team at %s",

View file

@ -6,4 +6,4 @@
use Appwrite\Runtimes\Runtimes;
return (new Runtimes('v3'))->getAll();
return (new Runtimes('v4'))->getAll();

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -198,7 +198,7 @@ return [
],
[
'name' => '_APP_LOGGING_PROVIDER',
'description' => 'This variable allows you to enable logging errors to 3rd party providers. This value is empty by default, set the value to one of \'sentry\', \'raygun\', \'appSignal\', \'logOwl\' to enable the logger.',
'description' => 'Deprecated since 1.6.0, use `_APP_LOGGING_CONFIG` with DSN value instead. This variable allows you to enable logging errors to 3rd party providers. This value is empty by default, set the value to one of \'sentry\', \'raygun\', \'appSignal\', \'logOwl\' to enable the logger.',
'introduction' => '0.12.0',
'default' => '',
'required' => false,
@ -207,7 +207,7 @@ return [
],
[
'name' => '_APP_LOGGING_CONFIG',
'description' => 'This variable configures authentication to 3rd party error logging providers. If using Sentry, this should be \'SENTRY_API_KEY;SENTRY_APP_ID\'. If using Raygun, this should be Raygun API key. If using AppSignal, this should be AppSignal API key. If using LogOwl, this should be LogOwl Service Ticket.',
'description' => 'This variable allows you to enable logging errors to third party providers. This value is empty by default, set a DSN value to one of the following `sentry://PROJECT_ID:SENTRY_API_KEY@SENTRY_HOST/`, , `logowl://SERVICE_TICKET@SERIVCE_HOST/` `raygun://RAYGUN_API_KEY/`, `appSignal://API_KEY/` to enable the logger.\n\nFor versions prior `1.5.6` you can use the old syntax.\n\nOld syntax: If using Sentry, this should be \'SENTRY_API_KEY;SENTRY_APP_ID\'. If using Raygun, this should be Raygun API key. If using AppSignal, this should be AppSignal API key. If using LogOwl, this should be LogOwl Service Ticket.',
'introduction' => '0.12.0',
'default' => '',
'required' => false,
@ -250,6 +250,15 @@ return [
'question' => '',
'filter' => ''
],
[
'name' => '_APP_CONSOLE_SESSION_ALERTS',
'description' => 'This option allows you configure if a new login in the Appwrite Console should send an alert email to the user. It\'s disabled by default with value "disabled", and to enable it, pass value "enabled".',
'introduction' => '1.6.0',
'default' => 'disabled',
'required' => false,
'question' => '',
'filter' => ''
],
],
],
[

View file

@ -59,7 +59,92 @@ use Utopia\Validator\WhiteList;
$oauthDefaultSuccess = '/auth/oauth2/success';
$oauthDefaultFailure = '/auth/oauth2/failure';
$createSession = function (string $userId, string $secret, Request $request, Response $response, Document $user, Database $dbForProject, Document $project, Locale $locale, Reader $geodb, Event $queueForEvents) {
function sendSessionAlert(Locale $locale, Document $user, Document $project, Document $session, Mail $queueForMails)
{
$subject = $locale->getText("emails.sessionAlert.subject");
$customTemplate = $project->getAttribute('templates', [])['email.sessionAlert-' . $locale->default] ?? [];
$message = Template::fromFile(__DIR__ . '/../../config/locale/templates/email-session-alert.tpl');
$message
->setParam('{{hello}}', $locale->getText("emails.sessionAlert.hello"))
->setParam('{{body}}', $locale->getText("emails.sessionAlert.body"))
->setParam('{{listDevice}}', $locale->getText("emails.sessionAlert.listDevice"))
->setParam('{{listIpAddress}}', $locale->getText("emails.sessionAlert.listIpAddress"))
->setParam('{{listCountry}}', $locale->getText("emails.sessionAlert.listCountry"))
->setParam('{{footer}}', $locale->getText("emails.sessionAlert.footer"))
->setParam('{{thanks}}', $locale->getText("emails.sessionAlert.thanks"))
->setParam('{{signature}}', $locale->getText("emails.sessionAlert.signature"));
$body = $message->render();
$smtp = $project->getAttribute('smtp', []);
$smtpEnabled = $smtp['enabled'] ?? false;
$senderEmail = System::getEnv('_APP_SYSTEM_EMAIL_ADDRESS', APP_EMAIL_TEAM);
$senderName = System::getEnv('_APP_SYSTEM_EMAIL_NAME', APP_NAME . ' Server');
$replyTo = "";
if ($smtpEnabled) {
if (!empty($smtp['senderEmail'])) {
$senderEmail = $smtp['senderEmail'];
}
if (!empty($smtp['senderName'])) {
$senderName = $smtp['senderName'];
}
if (!empty($smtp['replyTo'])) {
$replyTo = $smtp['replyTo'];
}
$queueForMails
->setSmtpHost($smtp['host'] ?? '')
->setSmtpPort($smtp['port'] ?? '')
->setSmtpUsername($smtp['username'] ?? '')
->setSmtpPassword($smtp['password'] ?? '')
->setSmtpSecure($smtp['secure'] ?? '');
if (!empty($customTemplate)) {
if (!empty($customTemplate['senderEmail'])) {
$senderEmail = $customTemplate['senderEmail'];
}
if (!empty($customTemplate['senderName'])) {
$senderName = $customTemplate['senderName'];
}
if (!empty($customTemplate['replyTo'])) {
$replyTo = $customTemplate['replyTo'];
}
$body = $customTemplate['message'] ?? '';
$subject = $customTemplate['subject'] ?? $subject;
}
$queueForMails
->setSmtpReplyTo($replyTo)
->setSmtpSenderEmail($senderEmail)
->setSmtpSenderName($senderName);
}
$emailVariables = [
'direction' => $locale->getText('settings.direction'),
'dateTime' => DateTime::format(new \DateTime(), 'Y-m-d H:i:s'),
'user' => $user->getAttribute('name'),
'project' => $project->getAttribute('name'),
'device' => $session->getAttribute('clientName'),
'ipAddress' => $session->getAttribute('ip'),
'country' => $locale->getText('countries.' . $session->getAttribute('countryCode'), $locale->getText('locale.country.unknown')),
];
$email = $user->getAttribute('email');
$queueForMails
->setSubject($subject)
->setBody($body)
->setVariables($emailVariables)
->setRecipient($email)
->trigger();
};
$createSession = function (string $userId, string $secret, Request $request, Response $response, Document $user, Database $dbForProject, Document $project, Locale $locale, Reader $geodb, Event $queueForEvents, Mail $queueForMails) {
$roles = Authorization::getRoles();
$isPrivilegedUser = Auth::isPrivilegedUser($roles);
$isAppUser = Auth::isAppUser($roles);
@ -139,6 +224,10 @@ $createSession = function (string $userId, string $secret, Request $request, Res
throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed saving user to DB');
}
if ($project->getAttribute('auths', [])['sessionAlerts'] ?? false) {
sendSessionAlert($locale, $user, $project, $session, $queueForMails);
}
$queueForEvents
->setParam('userId', $user->getId())
->setParam('sessionId', $session->getId());
@ -915,8 +1004,9 @@ App::post('/v1/account/sessions/email')
->inject('locale')
->inject('geodb')
->inject('queueForEvents')
->inject('queueForMails')
->inject('hooks')
->action(function (string $email, string $password, Request $request, Response $response, Document $user, Database $dbForProject, Document $project, Locale $locale, Reader $geodb, Event $queueForEvents, Hooks $hooks) {
->action(function (string $email, string $password, Request $request, Response $response, Document $user, Database $dbForProject, Document $project, Locale $locale, Reader $geodb, Event $queueForEvents, Mail $queueForMails, Hooks $hooks) {
$email = \strtolower($email);
$protocol = $request->getProtocol();
@ -1009,6 +1099,10 @@ App::post('/v1/account/sessions/email')
->setParam('sessionId', $session->getId())
;
if ($project->getAttribute('auths', [])['sessionAlerts'] ?? false) {
sendSessionAlert($locale, $user, $project, $session, $queueForMails);
}
$response->dynamic($session, Response::MODEL_SESSION);
});
@ -1177,6 +1271,7 @@ App::post('/v1/account/sessions/token')
->inject('locale')
->inject('geodb')
->inject('queueForEvents')
->inject('queueForMails')
->action($createSession);
App::get('/v1/account/sessions/oauth2/:provider')
@ -2338,6 +2433,7 @@ App::put('/v1/account/sessions/magic-url')
->inject('locale')
->inject('geodb')
->inject('queueForEvents')
->inject('queueForMails')
->action($createSession);
App::put('/v1/account/sessions/phone')
@ -2368,6 +2464,7 @@ App::put('/v1/account/sessions/phone')
->inject('locale')
->inject('geodb')
->inject('queueForEvents')
->inject('queueForMails')
->action($createSession);
App::post('/v1/account/tokens/phone')
@ -2470,7 +2567,18 @@ App::post('/v1/account/tokens/phone')
$dbForProject->purgeCachedDocument('users', $user->getId());
}
$secret = Auth::codeGenerator();
$secret = null;
$sendSMS = true;
$mockNumbers = $project->getAttribute('auths', [])['mockNumbers'] ?? [];
foreach ($mockNumbers as $mockNumber) {
if ($mockNumber['phone'] === $phone) {
$secret = $mockNumber['otp'];
$sendSMS = false;
break;
}
}
$secret ??= Auth::codeGenerator();
$expire = DateTime::formatTz(DateTime::addSeconds(new \DateTime(), Auth::TOKEN_EXPIRATION_OTP));
$token = new Document([
@ -2495,35 +2603,37 @@ App::post('/v1/account/tokens/phone')
$dbForProject->purgeCachedDocument('users', $user->getId());
$message = Template::fromFile(__DIR__ . '/../../config/locale/templates/sms-base.tpl');
if ($sendSMS) {
$message = Template::fromFile(__DIR__ . '/../../config/locale/templates/sms-base.tpl');
$customTemplate = $project->getAttribute('templates', [])['sms.login-' . $locale->default] ?? [];
if (!empty($customTemplate)) {
$message = $customTemplate['message'] ?? $message;
$customTemplate = $project->getAttribute('templates', [])['sms.login-' . $locale->default] ?? [];
if (!empty($customTemplate)) {
$message = $customTemplate['message'] ?? $message;
}
$messageContent = Template::fromString($locale->getText("sms.verification.body"));
$messageContent
->setParam('{{project}}', $project->getAttribute('name'))
->setParam('{{secret}}', $secret);
$messageContent = \strip_tags($messageContent->render());
$message = $message->setParam('{{token}}', $messageContent);
$message = $message->render();
$messageDoc = new Document([
'$id' => $token->getId(),
'data' => [
'content' => $message,
],
]);
$queueForMessaging
->setType(MESSAGE_SEND_TYPE_INTERNAL)
->setMessage($messageDoc)
->setRecipients([$phone])
->setProviderType(MESSAGE_TYPE_SMS);
}
$messageContent = Template::fromString($locale->getText("sms.verification.body"));
$messageContent
->setParam('{{project}}', $project->getAttribute('name'))
->setParam('{{secret}}', $secret);
$messageContent = \strip_tags($messageContent->render());
$message = $message->setParam('{{token}}', $messageContent);
$message = $message->render();
$messageDoc = new Document([
'$id' => $token->getId(),
'data' => [
'content' => $message,
],
]);
$queueForMessaging
->setType(MESSAGE_SEND_TYPE_INTERNAL)
->setMessage($messageDoc)
->setRecipients([$phone])
->setProviderType(MESSAGE_TYPE_SMS);
// Set to unhashed secret for events and server responses
$token->setAttribute('secret', $secret);
@ -2538,7 +2648,8 @@ App::post('/v1/account/tokens/phone')
->dynamic($token, Response::MODEL_TOKEN);
});
App::post('/v1/account/jwt')
App::post('/v1/account/jwts')
->alias('/v1/account/jwt')
->desc('Create JWT')
->groups(['api', 'account', 'auth'])
->label('scope', 'account')
@ -2571,15 +2682,11 @@ App::post('/v1/account/jwt')
throw new Exception(Exception::USER_SESSION_NOT_FOUND);
}
$jwt = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', 900, 10); // Instantiate with key, algo, maxAge and leeway.
$jwt = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', 900, 0);
$response
->setStatusCode(Response::STATUS_CODE_CREATED)
->dynamic(new Document(['jwt' => $jwt->encode([
// 'uid' => 1,
// 'aud' => 'http://site.com',
// 'scopes' => ['user'],
// 'iss' => 'http://api.mysite.com',
'userId' => $user->getId(),
'sessionId' => $current->getId(),
])]), Response::MODEL_JWT);
@ -3541,7 +3648,8 @@ App::post('/v1/account/verification/phone')
throw new Exception(Exception::GENERAL_PHONE_DISABLED, 'Phone provider not configured');
}
if (empty($user->getAttribute('phone'))) {
$phone = $user->getAttribute('phone');
if (empty($phone)) {
throw new Exception(Exception::USER_PHONE_NOT_FOUND);
}
@ -3552,7 +3660,19 @@ App::post('/v1/account/verification/phone')
$roles = Authorization::getRoles();
$isPrivilegedUser = Auth::isPrivilegedUser($roles);
$isAppUser = Auth::isAppUser($roles);
$secret = Auth::codeGenerator();
$secret = null;
$sendSMS = true;
$mockNumbers = $project->getAttribute('auths', [])['mockNumbers'] ?? [];
foreach ($mockNumbers as $mockNumber) {
if ($mockNumber['phone'] === $phone) {
$secret = $mockNumber['otp'];
$sendSMS = false;
break;
}
}
$secret ??= Auth::codeGenerator();
$expire = DateTime::addSeconds(new \DateTime(), Auth::TOKEN_EXPIRATION_CONFIRM);
$verification = new Document([
@ -3577,35 +3697,37 @@ App::post('/v1/account/verification/phone')
$dbForProject->purgeCachedDocument('users', $user->getId());
$message = Template::fromFile(__DIR__ . '/../../config/locale/templates/sms-base.tpl');
if ($sendSMS) {
$message = Template::fromFile(__DIR__ . '/../../config/locale/templates/sms-base.tpl');
$customTemplate = $project->getAttribute('templates', [])['sms.verification-' . $locale->default] ?? [];
if (!empty($customTemplate)) {
$message = $customTemplate['message'] ?? $message;
$customTemplate = $project->getAttribute('templates', [])['sms.verification-' . $locale->default] ?? [];
if (!empty($customTemplate)) {
$message = $customTemplate['message'] ?? $message;
}
$messageContent = Template::fromString($locale->getText("sms.verification.body"));
$messageContent
->setParam('{{project}}', $project->getAttribute('name'))
->setParam('{{secret}}', $secret);
$messageContent = \strip_tags($messageContent->render());
$message = $message->setParam('{{token}}', $messageContent);
$message = $message->render();
$messageDoc = new Document([
'$id' => $verification->getId(),
'data' => [
'content' => $message,
],
]);
$queueForMessaging
->setType(MESSAGE_SEND_TYPE_INTERNAL)
->setMessage($messageDoc)
->setRecipients([$user->getAttribute('phone')])
->setProviderType(MESSAGE_TYPE_SMS);
}
$messageContent = Template::fromString($locale->getText("sms.verification.body"));
$messageContent
->setParam('{{project}}', $project->getAttribute('name'))
->setParam('{{secret}}', $secret);
$messageContent = \strip_tags($messageContent->render());
$message = $message->setParam('{{token}}', $messageContent);
$message = $message->render();
$messageDoc = new Document([
'$id' => $verification->getId(),
'data' => [
'content' => $message,
],
]);
$queueForMessaging
->setType(MESSAGE_SEND_TYPE_INTERNAL)
->setMessage($messageDoc)
->setRecipients([$user->getAttribute('phone')])
->setProviderType(MESSAGE_TYPE_SMS);
// Set to unhashed secret for events and server responses
$verification->setAttribute('secret', $secret);
@ -4156,7 +4278,7 @@ App::get('/v1/account/mfa/recovery-codes')
App::delete('/v1/account/mfa/authenticators/:type')
->desc('Delete Authenticator')
->groups(['api', 'account'])
->groups(['api', 'account', 'mfaProtected'])
->label('event', 'users.[userId].delete.mfa')
->label('scope', 'account')
->label('audits.event', 'user.update')
@ -4169,12 +4291,11 @@ App::delete('/v1/account/mfa/authenticators/:type')
->label('sdk.response.code', Response::STATUS_CODE_NOCONTENT)
->label('sdk.response.model', Response::MODEL_NONE)
->param('type', null, new WhiteList([Type::TOTP]), 'Type of authenticator.')
->param('otp', '', new Text(256), 'Valid verification token.')
->inject('response')
->inject('user')
->inject('dbForProject')
->inject('queueForEvents')
->action(function (string $type, string $otp, Response $response, Document $user, Database $dbForProject, Event $queueForEvents) {
->action(function (string $type, Response $response, Document $user, Database $dbForProject, Event $queueForEvents) {
$authenticator = (match ($type) {
Type::TOTP => TOTP::getAuthenticatorFromUser($user),
@ -4185,27 +4306,6 @@ App::delete('/v1/account/mfa/authenticators/:type')
throw new Exception(Exception::USER_AUTHENTICATOR_NOT_FOUND);
}
$success = (match ($type) {
Type::TOTP => Challenge\TOTP::verify($user, $otp),
default => false
});
if (!$success) {
$mfaRecoveryCodes = $user->getAttribute('mfaRecoveryCodes', []);
if (in_array($otp, $mfaRecoveryCodes)) {
$mfaRecoveryCodes = array_diff($mfaRecoveryCodes, [$otp]);
$mfaRecoveryCodes = array_values($mfaRecoveryCodes);
$user->setAttribute('mfaRecoveryCodes', $mfaRecoveryCodes);
$dbForProject->updateDocument('users', $user->getId(), $user);
$success = true;
}
}
if (!$success) {
throw new Exception(Exception::USER_INVALID_TOKEN);
}
$dbForProject->deleteDocument('authenticators', $authenticator->getId());
$dbForProject->purgeCachedDocument('users', $user->getId());

View file

@ -11,6 +11,7 @@ use Appwrite\Event\Validator\FunctionEvent;
use Appwrite\Extend\Exception;
use Appwrite\Extend\Exception as AppwriteException;
use Appwrite\Messaging\Adapter\Realtime;
use Appwrite\Platform\Tasks\ScheduleExecutions;
use Appwrite\Task\Validator\Cron;
use Appwrite\Utopia\Database\Validator\CustomId;
use Appwrite\Utopia\Database\Validator\Queries\Deployments;
@ -33,6 +34,7 @@ use Utopia\Database\Helpers\Permission;
use Utopia\Database\Helpers\Role;
use Utopia\Database\Query;
use Utopia\Database\Validator\Authorization;
use Utopia\Database\Validator\Datetime as DatetimeValidator;
use Utopia\Database\Validator\Roles;
use Utopia\Database\Validator\UID;
use Utopia\Storage\Device;
@ -151,6 +153,7 @@ App::post('/v1/functions')
->param('logging', true, new Boolean(), 'Whether executions will be logged. When set to false, executions will not be logged, but will reduce resource used by your Appwrite project.', true)
->param('entrypoint', '', new Text(1028, 0), 'Entrypoint File. This path is relative to the "providerRootDirectory".', true)
->param('commands', '', new Text(8192, 0), 'Build Commands.', true)
->param('scopes', [], new ArrayList(new WhiteList(array_keys(Config::getParam('scopes')), true), APP_LIMIT_ARRAY_PARAMS_SIZE), 'List of scopes allowed for API key auto-generated for every execution. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' scopes are allowed.', true)
->param('installationId', '', new Text(128, 0), 'Appwrite Installation ID for VCS (Version Control System) deployment.', true)
->param('providerRepositoryId', '', new Text(128, 0), 'Repository ID of the repo linked to the function.', true)
->param('providerBranch', '', new Text(128, 0), 'Production branch for the repo linked to the function.', true)
@ -169,7 +172,7 @@ App::post('/v1/functions')
->inject('queueForBuilds')
->inject('dbForConsole')
->inject('gitHub')
->action(function (string $functionId, string $name, string $runtime, array $execute, array $events, string $schedule, int $timeout, bool $enabled, bool $logging, string $entrypoint, string $commands, string $installationId, string $providerRepositoryId, string $providerBranch, bool $providerSilentMode, string $providerRootDirectory, string $templateRepository, string $templateOwner, string $templateRootDirectory, string $templateBranch, Request $request, Response $response, Database $dbForProject, Document $project, Document $user, Event $queueForEvents, Build $queueForBuilds, Database $dbForConsole, GitHub $github) use ($redeployVcs) {
->action(function (string $functionId, string $name, string $runtime, array $execute, array $events, string $schedule, int $timeout, bool $enabled, bool $logging, string $entrypoint, string $commands, array $scopes, string $installationId, string $providerRepositoryId, string $providerBranch, bool $providerSilentMode, string $providerRootDirectory, string $templateRepository, string $templateOwner, string $templateRootDirectory, string $templateBranch, Request $request, Response $response, Database $dbForProject, Document $project, Document $user, Event $queueForEvents, Build $queueForBuilds, Database $dbForConsole, GitHub $github) use ($redeployVcs) {
$functionId = ($functionId == 'unique()') ? ID::unique() : $functionId;
$allowList = \array_filter(\explode(',', System::getEnv('_APP_FUNCTIONS_RUNTIMES', '')));
@ -219,8 +222,9 @@ App::post('/v1/functions')
'timeout' => $timeout,
'entrypoint' => $entrypoint,
'commands' => $commands,
'scopes' => $scopes,
'search' => implode(' ', [$functionId, $name, $runtime]),
'version' => 'v3',
'version' => 'v4',
'installationId' => $installation->getId(),
'installationInternalId' => $installation->getInternalId(),
'providerRepositoryId' => $providerRepositoryId,
@ -682,6 +686,7 @@ App::put('/v1/functions/:functionId')
->param('logging', true, new Boolean(), 'Whether executions will be logged. When set to false, executions will not be logged, but will reduce resource used by your Appwrite project.', true)
->param('entrypoint', '', new Text(1028, 0), 'Entrypoint File. This path is relative to the "providerRootDirectory".', true)
->param('commands', '', new Text(8192, 0), 'Build Commands.', true)
->param('scopes', [], new ArrayList(new WhiteList(array_keys(Config::getParam('scopes')), true), APP_LIMIT_ARRAY_PARAMS_SIZE), 'List of scopes allowed for API Key auto-generated for every execution. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' scopes are allowed.', true)
->param('installationId', '', new Text(128, 0), 'Appwrite Installation ID for VCS (Version Controle System) deployment.', true)
->param('providerRepositoryId', '', new Text(128, 0), 'Repository ID of the repo linked to the function', true)
->param('providerBranch', '', new Text(128, 0), 'Production branch for the repo linked to the function', true)
@ -695,7 +700,7 @@ App::put('/v1/functions/:functionId')
->inject('queueForBuilds')
->inject('dbForConsole')
->inject('gitHub')
->action(function (string $functionId, string $name, string $runtime, array $execute, array $events, string $schedule, int $timeout, bool $enabled, bool $logging, string $entrypoint, string $commands, string $installationId, string $providerRepositoryId, string $providerBranch, bool $providerSilentMode, string $providerRootDirectory, Request $request, Response $response, Database $dbForProject, Document $project, Event $queueForEvents, Build $queueForBuilds, Database $dbForConsole, GitHub $github) use ($redeployVcs) {
->action(function (string $functionId, string $name, string $runtime, array $execute, array $events, string $schedule, int $timeout, bool $enabled, bool $logging, string $entrypoint, string $commands, array $scopes, string $installationId, string $providerRepositoryId, string $providerBranch, bool $providerSilentMode, string $providerRootDirectory, Request $request, Response $response, Database $dbForProject, Document $project, Event $queueForEvents, Build $queueForBuilds, Database $dbForConsole, GitHub $github) use ($redeployVcs) {
// TODO: If only branch changes, re-deploy
$function = $dbForProject->getDocument('functions', $functionId);
@ -807,6 +812,7 @@ App::put('/v1/functions/:functionId')
'logging' => $logging,
'entrypoint' => $entrypoint,
'commands' => $commands,
'scopes' => $scopes,
'installationId' => $installation->getId(),
'installationInternalId' => $installation->getInternalId(),
'providerRepositoryId' => $providerRepositoryId,
@ -1160,6 +1166,7 @@ App::post('/v1/functions/:functionId/deployments')
}
$activate = (bool) filter_var($activate, FILTER_VALIDATE_BOOLEAN);
$type = $request->getHeader('x-sdk-language') === 'cli' ? 'cli' : 'manual';
if ($chunksUploaded === $chunks) {
if ($activate) {
@ -1197,7 +1204,7 @@ App::post('/v1/functions/:functionId/deployments')
'search' => implode(' ', [$deploymentId, $entrypoint]),
'activate' => $activate,
'metadata' => $metadata,
'type' => 'manual'
'type' => $type
]));
} else {
$deployment = $dbForProject->updateDocument('deployments', $deploymentId, $deployment->setAttribute('size', $fileSize)->setAttribute('metadata', $metadata));
@ -1230,7 +1237,7 @@ App::post('/v1/functions/:functionId/deployments')
'search' => implode(' ', [$deploymentId, $entrypoint]),
'activate' => $activate,
'metadata' => $metadata,
'type' => 'manual'
'type' => $type
]));
} else {
$deployment = $dbForProject->updateDocument('deployments', $deploymentId, $deployment->setAttribute('chunksUploaded', $chunksUploaded)->setAttribute('metadata', $metadata));
@ -1430,9 +1437,10 @@ App::delete('/v1/functions/:functionId/deployments/:deploymentId')
$response->noContent();
});
App::post('/v1/functions/:functionId/deployments/:deploymentId/builds/:buildId')
App::post('/v1/functions/:functionId/deployments/:deploymentId/build')
->alias('/v1/functions/:functionId/deployments/:deploymentId/builds/:buildId')
->groups(['api', 'functions'])
->desc('Create build')
->desc('Rebuild deployment')
->label('scope', 'functions.write')
->label('event', 'functions.[functionId].deployments.[deploymentId].update')
->label('audits.event', 'deployment.update')
@ -1440,12 +1448,11 @@ App::post('/v1/functions/:functionId/deployments/:deploymentId/builds/:buildId')
->label('sdk.auth', [APP_AUTH_TYPE_KEY])
->label('sdk.namespace', 'functions')
->label('sdk.method', 'createBuild')
->label('sdk.description', '/docs/references/functions/create-build.md')
->label('sdk.response.code', Response::STATUS_CODE_NOCONTENT)
->label('sdk.response.model', Response::MODEL_NONE)
->param('functionId', '', new UID(), 'Function ID.')
->param('deploymentId', '', new UID(), 'Deployment ID.')
->param('buildId', '', new UID(), 'Build unique ID.')
->param('buildId', '', new UID(), 'Build unique ID.', true) // added as optional param for backward compatibility
->inject('request')
->inject('response')
->inject('dbForProject')
@ -1453,25 +1460,17 @@ App::post('/v1/functions/:functionId/deployments/:deploymentId/builds/:buildId')
->inject('queueForEvents')
->inject('queueForBuilds')
->action(function (string $functionId, string $deploymentId, string $buildId, Request $request, Response $response, Database $dbForProject, Document $project, Event $queueForEvents, Build $queueForBuilds) {
$function = $dbForProject->getDocument('functions', $functionId);
if ($function->isEmpty()) {
throw new Exception(Exception::FUNCTION_NOT_FOUND);
}
$deployment = $dbForProject->getDocument('deployments', $deploymentId);
if ($deployment->isEmpty()) {
throw new Exception(Exception::DEPLOYMENT_NOT_FOUND);
}
$build = Authorization::skip(fn () => $dbForProject->getDocument('builds', $buildId));
if ($build->isEmpty()) {
throw new Exception(Exception::BUILD_NOT_FOUND);
}
$deploymentId = ID::unique();
$deployment->removeAttribute('$internalId');
@ -1496,6 +1495,86 @@ App::post('/v1/functions/:functionId/deployments/:deploymentId/builds/:buildId')
$response->noContent();
});
App::patch('/v1/functions/:functionId/deployments/:deploymentId/build')
->groups(['api', 'functions'])
->desc('Cancel deployment')
->label('scope', 'functions.write')
->label('audits.event', 'deployment.update')
->label('audits.resource', 'function/{request.functionId}')
->label('sdk.auth', [APP_AUTH_TYPE_KEY])
->label('sdk.namespace', 'functions')
->label('sdk.method', 'updateDeploymentBuild')
->label('sdk.response.code', Response::STATUS_CODE_OK)
->label('sdk.response.type', Response::CONTENT_TYPE_JSON)
->label('sdk.response.model', Response::MODEL_BUILD)
->param('functionId', '', new UID(), 'Function ID.')
->param('deploymentId', '', new UID(), 'Deployment ID.')
->inject('response')
->inject('dbForProject')
->inject('project')
->inject('queueForEvents')
->action(function (string $functionId, string $deploymentId, Response $response, Database $dbForProject, Document $project, Event $queueForEvents) {
$function = $dbForProject->getDocument('functions', $functionId);
if ($function->isEmpty()) {
throw new Exception(Exception::FUNCTION_NOT_FOUND);
}
$deployment = $dbForProject->getDocument('deployments', $deploymentId);
if ($deployment->isEmpty()) {
throw new Exception(Exception::DEPLOYMENT_NOT_FOUND);
}
$build = Authorization::skip(fn () => $dbForProject->getDocument('builds', $deployment->getAttribute('buildId', '')));
if ($build->isEmpty()) {
$buildId = ID::unique();
$build = $dbForProject->createDocument('builds', new Document([
'$id' => $buildId,
'$permissions' => [],
'startTime' => DateTime::now(),
'deploymentInternalId' => $deployment->getInternalId(),
'deploymentId' => $deployment->getId(),
'status' => 'canceled',
'path' => '',
'runtime' => $function->getAttribute('runtime'),
'source' => $deployment->getAttribute('path', ''),
'sourceType' => '',
'logs' => '',
'duration' => 0,
'size' => 0
]));
$deployment->setAttribute('buildId', $build->getId());
$deployment->setAttribute('buildInternalId', $build->getInternalId());
$deployment = $dbForProject->updateDocument('deployments', $deployment->getId(), $deployment);
} else {
if (\in_array($build->getAttribute('status'), ['ready', 'failed'])) {
throw new Exception(Exception::BUILD_ALREADY_COMPLETED);
}
$startTime = new \DateTime($build->getAttribute('startTime'));
$endTime = new \DateTime('now');
$duration = $endTime->getTimestamp() - $startTime->getTimestamp();
$build = $dbForProject->updateDocument('builds', $build->getId(), $build->setAttributes([
'endTime' => DateTime::now(),
'duration' => $duration,
'status' => 'canceled'
]));
}
$executor = new Executor(App::getEnv('_APP_EXECUTOR_HOST'));
$deleteBuild = $executor->deleteRuntime($project->getId(), $deploymentId . "-build");
$queueForEvents
->setParam('functionId', $function->getId())
->setParam('deploymentId', $deployment->getId());
$response->dynamic($build, Response::MODEL_BUILD);
});
App::post('/v1/functions/:functionId/executions')
->groups(['api', 'functions'])
->desc('Create execution')
@ -1514,16 +1593,21 @@ App::post('/v1/functions/:functionId/executions')
->param('path', '/', new Text(2048), 'HTTP path of execution. Path can include query params. Default value is /', true)
->param('method', 'POST', new Whitelist(['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], true), 'HTTP method of execution. Default value is GET.', true)
->param('headers', [], new Assoc(), 'HTTP headers of execution. Defaults to empty.', true)
->param('scheduledAt', null, new DatetimeValidator(requireDateInFuture: true), 'Scheduled execution time in [ISO 8601](https://www.iso.org/iso-8601-date-and-time-format.html) format. DateTime value must be in future.', true)
->inject('response')
->inject('project')
->inject('dbForProject')
->inject('dbForConsole')
->inject('user')
->inject('queueForEvents')
->inject('queueForUsage')
->inject('mode')
->inject('queueForFunctions')
->inject('geodb')
->action(function (string $functionId, string $body, bool $async, string $path, string $method, array $headers, Response $response, Document $project, Database $dbForProject, Document $user, Event $queueForEvents, Usage $queueForUsage, string $mode, Func $queueForFunctions, Reader $geodb) {
->action(function (string $functionId, string $body, bool $async, string $path, string $method, array $headers, ?string $scheduledAt, Response $response, Document $project, Database $dbForProject, Database $dbForConsole, Document $user, Event $queueForEvents, Usage $queueForUsage, Func $queueForFunctions, Reader $geodb) {
if(!$async && !is_null($scheduledAt)) {
throw new Exception(Exception::GENERAL_BAD_REQUEST, 'Scheduled executions must run asynchronously. Set scheduledAt to a future date, or set async to true.');
}
$function = Authorization::skip(fn () => $dbForProject->getDocument('functions', $functionId));
@ -1582,7 +1666,8 @@ App::post('/v1/functions/:functionId/executions')
}
if (!$current->isEmpty()) {
$jwtObj = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', 900, 10); // Instantiate with key, algo, maxAge and leeway.
$jwtExpiry = $function->getAttribute('timeout', 900);
$jwtObj = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', $jwtExpiry, 0);
$jwt = $jwtObj->encode([
'userId' => $user->getId(),
'sessionId' => $current->getId(),
@ -1590,6 +1675,14 @@ App::post('/v1/functions/:functionId/executions')
}
}
$jwtExpiry = $function->getAttribute('timeout', 900);
$jwtObj = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', $jwtExpiry, 0);
$apiKey = $jwtObj->encode([
'projectId' => $project->getId(),
'scopes' => $function->getAttribute('scopes', [])
]);
$headers['x-appwrite-key'] = API_KEY_DYNAMIC . '_' . $apiKey;
$headers['x-appwrite-trigger'] = 'http';
$headers['x-appwrite-user-id'] = $user->getId() ?? '';
$headers['x-appwrite-user-jwt'] = $jwt ?? '';
@ -1619,6 +1712,12 @@ App::post('/v1/functions/:functionId/executions')
$executionId = ID::unique();
$status = $async ? 'waiting' : 'processing';
if(!is_null($scheduledAt)) {
$status = 'scheduled';
}
$execution = new Document([
'$id' => $executionId,
'$permissions' => !$user->isEmpty() ? [Permission::read(Role::user($user->getId()))] : [],
@ -1626,8 +1725,8 @@ App::post('/v1/functions/:functionId/executions')
'functionId' => $function->getId(),
'deploymentInternalId' => $deployment->getInternalId(),
'deploymentId' => $deployment->getId(),
'trigger' => 'http', // http / schedule / event
'status' => $async ? 'waiting' : 'processing', // waiting / processing / completed / failed
'trigger' => (!is_null($scheduledAt)) ? 'schedule' : 'http',
'status' => $status, // waiting / processing / completed / failed / scheduled
'responseStatusCode' => 0,
'responseHeaders' => [],
'requestPath' => $path,
@ -1645,25 +1744,44 @@ App::post('/v1/functions/:functionId/executions')
->setContext('function', $function);
if ($async) {
if ($function->getAttribute('logging')) {
/** @var Document $execution */
$execution = Authorization::skip(fn () => $dbForProject->createDocument('executions', $execution));
}
$execution = Authorization::skip(fn () => $dbForProject->createDocument('executions', $execution));
$queueForFunctions
->setType('http')
->setExecution($execution)
->setFunction($function)
->setBody($body)
->setHeaders($headers)
->setPath($path)
->setMethod($method)
->setJWT($jwt)
->setProject($project)
->setUser($user)
->setParam('functionId', $function->getId())
->setParam('executionId', $execution->getId())
->trigger();
if(is_null($scheduledAt)) {
$queueForFunctions
->setType('http')
->setExecution($execution)
->setFunction($function)
->setBody($body)
->setHeaders($headers)
->setPath($path)
->setMethod($method)
->setJWT($jwt)
->setProject($project)
->setUser($user)
->setParam('functionId', $function->getId())
->setParam('executionId', $execution->getId())
->trigger();
} else {
$data = [
'headers' => $headers,
'path' => $path,
'method' => $method,
'body' => $body,
'jwt' => $jwt,
];
$dbForConsole->createDocument('schedules', new Document([
'region' => System::getEnv('_APP_REGION', 'default'),
'resourceType' => ScheduleExecutions::getSupportedResource(),
'resourceId' => $execution->getId(),
'resourceInternalId' => $execution->getInternalId(),
'resourceUpdatedAt' => DateTime::now(),
'projectId' => $project->getId(),
'schedule' => $scheduledAt,
'data' => $data,
'active' => true,
]));
}
return $response
->setStatusCode(Response::STATUS_CODE_ACCEPTED)
@ -1694,14 +1812,20 @@ App::post('/v1/functions/:functionId/executions')
$vars[$var->getAttribute('key')] = $var->getAttribute('value', '');
}
$protocol = System::getEnv('_APP_OPTIONS_FORCE_HTTPS') == 'disabled' ? 'http' : 'https';
$hostname = System::getEnv('_APP_DOMAIN');
$endpoint = $protocol . '://' . $hostname . "/v1";
// Appwrite vars
$vars = \array_merge($vars, [
'APPWRITE_FUNCTION_API_ENDPOINT' => $endpoint,
'APPWRITE_FUNCTION_ID' => $functionId,
'APPWRITE_FUNCTION_NAME' => $function->getAttribute('name'),
'APPWRITE_FUNCTION_DEPLOYMENT' => $deployment->getId(),
'APPWRITE_FUNCTION_PROJECT_ID' => $project->getId(),
'APPWRITE_FUNCTION_RUNTIME_NAME' => $runtime['name'] ?? '',
'APPWRITE_FUNCTION_RUNTIME_VERSION' => $runtime['version'] ?? '',
'APPWRITE_VERSION' => APP_VERSION_STABLE
]);
/** Execute function */
@ -1724,6 +1848,7 @@ App::post('/v1/functions/:functionId/executions')
method: $method,
headers: $headers,
runtimeEntrypoint: $command,
logging: $function->getAttribute('logging', true),
requestTimeout: 30
);
@ -1763,10 +1888,7 @@ App::post('/v1/functions/:functionId/executions')
->addMetric(str_replace('{functionInternalId}', $function->getInternalId(), METRIC_FUNCTION_ID_EXECUTIONS_COMPUTE), (int)($execution->getAttribute('duration') * 1000)) // per function
;
if ($function->getAttribute('logging')) {
/** @var Document $execution */
$execution = Authorization::skip(fn () => $dbForProject->createDocument('executions', $execution));
}
$execution = Authorization::skip(fn () => $dbForProject->createDocument('executions', $execution));
}
$roles = Authorization::getRoles();
@ -1919,6 +2041,74 @@ App::get('/v1/functions/:functionId/executions/:executionId')
$response->dynamic($execution, Response::MODEL_EXECUTION);
});
App::delete('/v1/functions/:functionId/executions/:executionId')
->groups(['api', 'functions'])
->desc('Delete execution')
->label('scope', 'execution.write')
->label('event', 'functions.[functionId].executions.[executionId].delete')
->label('audits.event', 'executions.delete')
->label('audits.resource', 'function/{request.functionId}')
->label('sdk.auth', [APP_AUTH_TYPE_KEY])
->label('sdk.namespace', 'functions')
->label('sdk.method', 'deleteExecution')
->label('sdk.description', '/docs/references/functions/delete-execution.md')
->label('sdk.response.code', Response::STATUS_CODE_NOCONTENT)
->label('sdk.response.model', Response::MODEL_NONE)
->param('functionId', '', new UID(), 'Function ID.')
->param('executionId', '', new UID(), 'Execution ID.')
->inject('response')
->inject('dbForProject')
->inject('dbForConsole')
->inject('queueForEvents')
->action(function (string $functionId, string $executionId, Response $response, Database $dbForProject, Database $dbForConsole, Event $queueForEvents) {
$function = $dbForProject->getDocument('functions', $functionId);
if ($function->isEmpty()) {
throw new Exception(Exception::FUNCTION_NOT_FOUND);
}
$execution = $dbForProject->getDocument('executions', $executionId);
if ($execution->isEmpty()) {
throw new Exception(Exception::EXECUTION_NOT_FOUND);
}
if ($execution->getAttribute('functionId') !== $function->getId()) {
throw new Exception(Exception::EXECUTION_NOT_FOUND);
}
$status = $execution->getAttribute('status');
if (!in_array($status, ['completed', 'failed', 'scheduled'])) {
throw new Exception(Exception::EXECUTION_IN_PROGRESS);
}
if (!$dbForProject->deleteDocument('executions', $execution->getId())) {
throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed to remove execution from DB');
}
if ($status === 'scheduled') {
$schedule = $dbForConsole->findOne('schedules', [
Query::equal('resourceId', [$execution->getId()]),
Query::equal('resourceType', [ScheduleExecutions::getSupportedResource()]),
Query::equal('active', [true]),
]);
if ($schedule && !$schedule->isEmpty()) {
$schedule
->setAttribute('resourceUpdatedAt', DateTime::now())
->setAttribute('active', false);
Authorization::skip(fn () => $dbForConsole->updateDocument('schedules', $schedule->getId(), $schedule));
}
}
$queueForEvents
->setParam('functionId', $function->getId())
->setParam('executionId', $execution->getId())
->setPayload($response->output($execution, Response::MODEL_EXECUTION));
$response->noContent();
});
// Variables
App::post('/v1/functions/:functionId/variables')

View file

@ -2697,7 +2697,7 @@ App::post('/v1/messaging/messages/email')
'resourceInternalId' => $message->getInternalId(),
'resourceUpdatedAt' => DateTime::now(),
'projectId' => $project->getId(),
'schedule' => $scheduledAt,
'schedule' => $scheduledAt,
'active' => true,
]));
@ -2813,7 +2813,7 @@ App::post('/v1/messaging/messages/sms')
'resourceInternalId' => $message->getInternalId(),
'resourceUpdatedAt' => DateTime::now(),
'projectId' => $project->getId(),
'schedule' => $scheduledAt,
'schedule' => $scheduledAt,
'active' => true,
]));
@ -2939,11 +2939,9 @@ App::post('/v1/messaging/messages/push')
$expiry = (new \DateTime())->add(new \DateInterval('P15D'))->format('U');
}
$encoder = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'));
$encoder = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', \intval($expiry), 0);
$jwt = $encoder->encode([
'iat' => \time(),
'exp' => $expiry,
'bucketId' => $bucket->getId(),
'fileId' => $file->getId(),
'projectId' => $project->getId(),
@ -2991,7 +2989,7 @@ App::post('/v1/messaging/messages/push')
'resourceInternalId' => $message->getInternalId(),
'resourceUpdatedAt' => DateTime::now(),
'projectId' => $project->getId(),
'schedule' => $scheduledAt,
'schedule' => $scheduledAt,
'active' => true,
]));
@ -3801,11 +3799,9 @@ App::patch('/v1/messaging/messages/push/:messageId')
$expiry = (new \DateTime())->add(new \DateInterval('P15D'))->format('U');
}
$encoder = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'));
$encoder = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', \intval($expiry), 0);
$jwt = $encoder->encode([
'iat' => \time(),
'exp' => $expiry,
'bucketId' => $bucket->getId(),
'fileId' => $file->getId(),
'projectId' => $project->getId(),

View file

@ -1,6 +1,8 @@
<?php
use Ahc\Jwt\JWT;
use Appwrite\Auth\Auth;
use Appwrite\Auth\Validator\MockNumber;
use Appwrite\Event\Delete;
use Appwrite\Event\Mail;
use Appwrite\Event\Validator\Event;
@ -103,8 +105,11 @@ App::post('/v1/projects')
'passwordHistory' => 0,
'passwordDictionary' => false,
'duration' => Auth::TOKEN_EXPIRATION_LOGIN_LONG,
'personalDataCheck' => false
'personalDataCheck' => false,
'mockNumbers' => [],
'sessionAlerts' => false,
];
foreach ($auth as $method) {
$auths[$method['key'] ?? ''] = true;
}
@ -361,7 +366,7 @@ App::patch('/v1/projects/:projectId')
});
App::patch('/v1/projects/:projectId/team')
->desc('Update Project Team')
->desc('Update project team')
->groups(['api', 'projects'])
->label('scope', 'projects.write')
->label('sdk.auth', [APP_AUTH_TYPE_ADMIN])
@ -602,6 +607,37 @@ App::patch('/v1/projects/:projectId/oauth2')
$response->dynamic($project, Response::MODEL_PROJECT);
});
App::patch('/v1/projects/:projectId/auth/session-alerts')
->desc('Update project sessions emails')
->groups(['api', 'projects'])
->label('scope', 'projects.write')
->label('sdk.auth', [APP_AUTH_TYPE_ADMIN])
->label('sdk.namespace', 'projects')
->label('sdk.method', 'updateSessionAlerts')
->label('sdk.response.code', Response::STATUS_CODE_OK)
->label('sdk.response.type', Response::CONTENT_TYPE_JSON)
->label('sdk.response.model', Response::MODEL_PROJECT)
->param('projectId', '', new UID(), 'Project unique ID.')
->param('alerts', false, new Boolean(true), 'Set to true to enable session emails.')
->inject('response')
->inject('dbForConsole')
->action(function (string $projectId, bool $alerts, Response $response, Database $dbForConsole) {
$project = $dbForConsole->getDocument('projects', $projectId);
if ($project->isEmpty()) {
throw new Exception(Exception::PROJECT_NOT_FOUND);
}
$auths = $project->getAttribute('auths', []);
$auths['sessionAlerts'] = $alerts;
$dbForConsole->updateDocument('projects', $project->getId(), $project
->setAttribute('auths', $auths));
$response->dynamic($project, Response::MODEL_PROJECT);
});
App::patch('/v1/projects/:projectId/auth/limit')
->desc('Update project users limit')
->groups(['api', 'projects'])
@ -822,6 +858,37 @@ App::patch('/v1/projects/:projectId/auth/max-sessions')
$response->dynamic($project, Response::MODEL_PROJECT);
});
App::patch('/v1/projects/:projectId/auth/mock-numbers')
->desc('Update the mock numbers for the project')
->groups(['api', 'projects'])
->label('scope', 'projects.write')
->label('sdk.auth', [APP_AUTH_TYPE_ADMIN])
->label('sdk.namespace', 'projects')
->label('sdk.method', 'updateMockNumbers')
->label('sdk.response.code', Response::STATUS_CODE_OK)
->label('sdk.response.type', Response::CONTENT_TYPE_JSON)
->label('sdk.response.model', Response::MODEL_PROJECT)
->param('projectId', '', new UID(), 'Project unique ID.')
->param('numbers', '', new ArrayList(new MockNumber(), 10), 'An array of mock numbers and their corresponding verification codes (OTPs). Each number should be a valid E.164 formatted phone number. Maximum of 10 numbers are allowed.')
->inject('response')
->inject('dbForConsole')
->action(function (string $projectId, array $numbers, Response $response, Database $dbForConsole) {
$project = $dbForConsole->getDocument('projects', $projectId);
if ($project->isEmpty()) {
throw new Exception(Exception::PROJECT_NOT_FOUND);
}
$auths = $project->getAttribute('auths', []);
$auths['mockNumbers'] = $numbers;
$project = $dbForConsole->updateDocument('projects', $project->getId(), $project->setAttribute('auths', $auths));
$response->dynamic($project, Response::MODEL_PROJECT);
});
App::delete('/v1/projects/:projectId')
->desc('Delete project')
->groups(['api', 'projects'])
@ -1155,7 +1222,7 @@ App::post('/v1/projects/:projectId/keys')
'expire' => $expire,
'sdks' => [],
'accessedAt' => null,
'secret' => \bin2hex(\random_bytes(128)),
'secret' => API_KEY_STANDARD . '_' . \bin2hex(\random_bytes(128)),
]);
$key = $dbForConsole->createDocument('keys', $key);
@ -1316,6 +1383,41 @@ App::delete('/v1/projects/:projectId/keys/:keyId')
$response->noContent();
});
// JWT Keys
App::post('/v1/projects/:projectId/jwts')
->groups(['api', 'projects'])
->desc('Create JWT')
->label('scope', 'projects.write')
->label('sdk.auth', [APP_AUTH_TYPE_ADMIN])
->label('sdk.namespace', 'projects')
->label('sdk.method', 'createJWT')
->label('sdk.response.code', Response::STATUS_CODE_CREATED)
->label('sdk.response.type', Response::CONTENT_TYPE_JSON)
->label('sdk.response.model', Response::MODEL_JWT)
->param('projectId', '', new UID(), 'Project unique ID.')
->param('scopes', [], new ArrayList(new WhiteList(array_keys(Config::getParam('scopes')), true), APP_LIMIT_ARRAY_PARAMS_SIZE), 'List of scopes allowed for JWT key. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' scopes are allowed.')
->param('duration', 900, new Range(0, 3600), 'Time in seconds before JWT expires. Default duration is 900 seconds, and maximum is 3600 seconds.', true)
->inject('response')
->inject('dbForConsole')
->action(function (string $projectId, array $scopes, int $duration, Response $response, Database $dbForConsole) {
$project = $dbForConsole->getDocument('projects', $projectId);
if ($project->isEmpty()) {
throw new Exception(Exception::PROJECT_NOT_FOUND);
}
$jwt = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', $duration, 0);
$response
->setStatusCode(Response::STATUS_CODE_CREATED)
->dynamic(new Document(['jwt' => API_KEY_DYNAMIC . '_' . $jwt->encode([
'projectId' => $project->getId(),
'scopes' => $scopes
])]), Response::MODEL_JWT);
});
// Platforms
App::post('/v1/projects/:projectId/platforms')

View file

@ -1309,7 +1309,7 @@ App::get('/v1/storage/buckets/:bucketId/files/:fileId/push')
->action(function (string $bucketId, string $fileId, string $jwt, Response $response, Request $request, Database $dbForProject, Document $project, string $mode, Device $deviceForFiles) {
$bucket = Authorization::skip(fn () => $dbForProject->getDocument('buckets', $bucketId));
$decoder = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'));
$decoder = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', 3600, 0);
try {
$decoded = $decoder->decode($jwt);
@ -1320,8 +1320,7 @@ App::get('/v1/storage/buckets/:bucketId/files/:fileId/push')
if (
$decoded['projectId'] !== $project->getId() ||
$decoded['bucketId'] !== $bucketId ||
$decoded['fileId'] !== $fileId ||
$decoded['exp'] < \time()
$decoded['fileId'] !== $fileId
) {
throw new Exception(Exception::USER_UNAUTHORIZED);
}

View file

@ -1,5 +1,6 @@
<?php
use Ahc\Jwt\JWT;
use Appwrite\Auth\Auth;
use Appwrite\Auth\MFA\Type;
use Appwrite\Auth\MFA\Type\TOTP;
@ -39,6 +40,7 @@ use Utopia\Database\Validator\Query\Limit;
use Utopia\Database\Validator\Query\Offset;
use Utopia\Database\Validator\UID;
use Utopia\Locale\Locale;
use Utopia\System\System;
use Utopia\Validator\ArrayList;
use Utopia\Validator\Assoc;
use Utopia\Validator\Boolean;
@ -2095,6 +2097,60 @@ App::delete('/v1/users/identities/:identityId')
return $response->noContent();
});
App::post('/v1/users/:userId/jwts')
->desc('Create user JWT')
->groups(['api', 'users'])
->label('scope', 'users.write')
->label('sdk.auth', [APP_AUTH_TYPE_KEY])
->label('sdk.namespace', 'users')
->label('sdk.method', 'createJWT')
->label('sdk.description', '/docs/references/users/create-user-jwt.md')
->label('sdk.response.code', Response::STATUS_CODE_CREATED)
->label('sdk.response.type', Response::CONTENT_TYPE_JSON)
->label('sdk.response.model', Response::MODEL_JWT)
->param('userId', '', new UID(), 'User ID.')
->param('sessionId', 'recent', new UID(), 'Session ID. Use the string \'recent\' to use the most recent session. Defaults to the most recent session.', true)
->param('duration', 900, new Range(0, 3600), 'Time in seconds before JWT expires. Default duration is 900 seconds, and maximum is 3600 seconds.', true)
->inject('response')
->inject('dbForProject')
->action(function (string $userId, string $sessionId, int $duration, Response $response, Database $dbForProject) {
$user = $dbForProject->getDocument('users', $userId);
if ($user->isEmpty()) {
throw new Exception(Exception::USER_NOT_FOUND);
}
$sessions = $user->getAttribute('sessions', []);
$session = new Document();
if($sessionId === 'recent') {
// Get most recent
$session = \count($sessions) > 0 ? $sessions[\count($sessions) - 1] : new Document();
} else {
// Find by ID
foreach ($sessions as $loopSession) { /** @var Utopia\Database\Document $loopSession */
if ($loopSession->getId() == $sessionId) {
$session = $loopSession;
break;
}
}
}
if ($session->isEmpty()) {
throw new Exception(Exception::USER_SESSION_NOT_FOUND);
}
$jwt = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', $duration, 0);
$response
->setStatusCode(Response::STATUS_CODE_CREATED)
->dynamic(new Document(['jwt' => $jwt->encode([
'userId' => $user->getId(),
'sessionId' => $session->getId()
])]), Response::MODEL_JWT);
});
App::get('/v1/users/usage')
->desc('Get users usage stats')
->groups(['api', 'users'])

View file

@ -464,6 +464,67 @@ App::get('/v1/vcs/github/callback')
->redirect($redirectSuccess);
});
App::get('/v1/vcs/github/installations/:installationId/providerRepositories/:providerRepositoryId/contents')
->desc('Get files and directories of a VCS repository')
->groups(['api', 'vcs'])
->label('scope', 'vcs.read')
->label('sdk.namespace', 'vcs')
->label('sdk.auth', [APP_AUTH_TYPE_ADMIN])
->label('sdk.method', 'getRepositoryContents')
->label('sdk.description', '')
->label('sdk.response.code', Response::STATUS_CODE_OK)
->label('sdk.response.type', Response::CONTENT_TYPE_JSON)
->label('sdk.response.model', Response::MODEL_VCS_CONTENT_LIST)
->param('installationId', '', new Text(256), 'Installation Id')
->param('providerRepositoryId', '', new Text(256), 'Repository Id')
->param('providerRootDirectory', '', new Text(256, 0), 'Path to get contents of nested directory', true)
->inject('gitHub')
->inject('response')
->inject('project')
->inject('dbForConsole')
->action(function (string $installationId, string $providerRepositoryId, string $providerRootDirectory, GitHub $github, Response $response, Document $project, Database $dbForConsole) {
$installation = $dbForConsole->getDocument('installations', $installationId);
if ($installation->isEmpty()) {
throw new Exception(Exception::INSTALLATION_NOT_FOUND);
}
$providerInstallationId = $installation->getAttribute('providerInstallationId');
$privateKey = System::getEnv('_APP_VCS_GITHUB_PRIVATE_KEY');
$githubAppId = System::getEnv('_APP_VCS_GITHUB_APP_ID');
$github->initializeVariables($providerInstallationId, $privateKey, $githubAppId);
$owner = $github->getOwnerName($providerInstallationId);
try {
$repositoryName = $github->getRepositoryName($providerRepositoryId) ?? '';
if (empty($repositoryName)) {
throw new Exception(Exception::PROVIDER_REPOSITORY_NOT_FOUND);
}
} catch (RepositoryNotFound $e) {
throw new Exception(Exception::PROVIDER_REPOSITORY_NOT_FOUND);
}
$contents = $github->listRepositoryContents($owner, $repositoryName, $providerRootDirectory);
$vcsContents = [];
foreach ($contents as $content) {
$isDirectory = false;
if($content['type'] === GitHub::CONTENTS_DIRECTORY) {
$isDirectory = true;
}
$vcsContents[] = new Document([
'isDirectory' => $isDirectory,
'name' => $content['name'] ?? '',
'size' => $content['size'] ?? 0
]);
}
$response->dynamic(new Document([
'contents' => $vcsContents
]), Response::MODEL_VCS_CONTENT_LIST);
});
App::post('/v1/vcs/github/installations/:installationId/providerRepositories/:providerRepositoryId/detection')
->desc('Detect runtime settings from source code')
->groups(['api', 'vcs'])
@ -505,6 +566,7 @@ App::post('/v1/vcs/github/installations/:installationId/providerRepositories/:pr
}
$files = $github->listRepositoryContents($owner, $repositoryName, $providerRootDirectory);
$files = \array_column($files, 'name');
$languages = $github->listRepositoryLanguages($owner, $repositoryName);
$detectorFactory = new Detector($files, $languages);
@ -586,6 +648,7 @@ App::get('/v1/vcs/github/installations/:installationId/providerRepositories')
return function () use ($repo, $github) {
try {
$files = $github->listRepositoryContents($repo['organization'], $repo['name'], '');
$files = \array_column($files, 'name');
$languages = $github->listRepositoryLanguages($repo['organization'], $repo['name']);
$detectorFactory = new Detector($files, $languages);

View file

@ -250,6 +250,7 @@ function router(App $utopia, Database $dbForConsole, callable $getProjectDB, Swo
'APPWRITE_FUNCTION_PROJECT_ID' => $project->getId(),
'APPWRITE_FUNCTION_RUNTIME_NAME' => $runtime['name'] ?? '',
'APPWRITE_FUNCTION_RUNTIME_VERSION' => $runtime['version'] ?? '',
'APPWRITE_VERSION' => APP_VERSION_STABLE
]);
/** Execute function */
@ -272,6 +273,7 @@ function router(App $utopia, Database $dbForConsole, callable $getProjectDB, Swo
method: $method,
headers: $headers,
runtimeEntrypoint: $command,
logging: $function->getAttribute('logging', true),
requestTimeout: 30
);
@ -331,13 +333,6 @@ function router(App $utopia, Database $dbForConsole, callable $getProjectDB, Swo
$body = $execution['responseBody'] ?? '';
$encodingKey = \array_search('x-open-runtimes-encoding', \array_column($execution['responseHeaders'], 'name'));
if ($encodingKey !== false) {
if (($execution['responseHeaders'][$encodingKey]['value'] ?? '') === 'base64') {
$body = \base64_decode($body);
}
}
$contentType = 'text/plain';
foreach ($execution['responseHeaders'] as $header) {
if (\strtolower($header['name']) === 'content-type') {
@ -901,7 +896,7 @@ App::get('/robots.txt')
$host = $request->getHostname() ?? '';
$mainDomain = System::getEnv('_APP_DOMAIN', '');
if ($host === $mainDomain) {
if ($host === $mainDomain || $host === 'localhost') {
$template = new View(__DIR__ . '/../views/general/robots.phtml');
$response->text($template->render(false));
} else {
@ -926,7 +921,7 @@ App::get('/humans.txt')
$host = $request->getHostname() ?? '';
$mainDomain = System::getEnv('_APP_DOMAIN', '');
if ($host === $mainDomain) {
if ($host === $mainDomain || $host === 'localhost') {
$template = new View(__DIR__ . '/../views/general/humans.phtml');
$response->text($template->render(false));
} else {

View file

@ -6,6 +6,7 @@ use Appwrite\Extend\Exception;
use Appwrite\Utopia\Request;
use Appwrite\Utopia\Response;
use Utopia\App;
use Utopia\Config\Config;
use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Database\Helpers\ID;
@ -154,6 +155,55 @@ App::patch('/v1/mock/functions-v2')
$response->noContent();
});
App::post('/v1/mock/api-key-unprefixed')
->desc('Create API Key (without standard prefix)')
->groups(['mock', 'api', 'projects'])
->label('scope', 'projects.write')
->label('docs', false)
->param('projectId', '', new UID(), 'Project ID.')
->inject('response')
->inject('dbForConsole')
->action(function (string $projectId, Response $response, Database $dbForConsole) {
$isDevelopment = System::getEnv('_APP_ENV', 'development') === 'development';
if (!$isDevelopment) {
throw new Exception(Exception::GENERAL_NOT_IMPLEMENTED);
}
$project = $dbForConsole->getDocument('projects', $projectId);
if ($project->isEmpty()) {
throw new Exception(Exception::PROJECT_NOT_FOUND);
}
$scopes = array_keys(Config::getParam('scopes'));
$key = new Document([
'$id' => ID::unique(),
'$permissions' => [
Permission::read(Role::any()),
Permission::update(Role::any()),
Permission::delete(Role::any()),
],
'projectInternalId' => $project->getInternalId(),
'projectId' => $project->getId(),
'name' => 'Outdated key',
'scopes' => $scopes,
'expire' => null,
'sdks' => [],
'accessedAt' => null,
'secret' => \bin2hex(\random_bytes(128)),
]);
$key = $dbForConsole->createDocument('keys', $key);
$dbForConsole->purgeCachedDocument('projects', $project->getId());
$response
->setStatusCode(Response::STATUS_CODE_CREATED)
->dynamic($key, Response::MODEL_KEY);
});
App::get('/v1/mock/github/callback')
->desc('Create installation document using GitHub installation id')
->groups(['mock', 'api', 'vcs'])

View file

@ -1,5 +1,7 @@
<?php
use Ahc\Jwt\JWT;
use Ahc\Jwt\JWTException;
use Appwrite\Auth\Auth;
use Appwrite\Auth\MFA\Type\TOTP;
use Appwrite\Auth\MFA\Type\WebAuthn;
@ -196,56 +198,99 @@ App::init()
$scope = $route->getLabel('scope', 'none'); // Allowed scope for chosen route
$scopes = $roles[$role]['scopes']; // Allowed scopes for user role
$authKey = $request->getHeader('x-appwrite-key', '');
$apiKey = $request->getHeader('x-appwrite-key', '');
if (!empty($authKey)) { // API Key authentication
// API Key authentication
if (!empty($apiKey)) {
// Do not allow API key and session to be set at the same time
if (!$user->isEmpty()) {
throw new Exception(Exception::USER_API_KEY_AND_SESSION_SET);
}
// Check if given key match project API keys
$key = $project->find('secret', $authKey, 'keys');
if ($key) {
$user = new Document([
'$id' => '',
'status' => true,
'email' => 'app.' . $project->getId() . '@service.' . $request->getHostname(),
'password' => '',
'name' => $project->getAttribute('name', 'Untitled'),
]);
if(!\str_contains($apiKey, '_')) {
$keyType = API_KEY_STANDARD;
$authKey = $apiKey;
} else {
[ $keyType, $authKey ] = \explode('_', $apiKey, 2);
}
$role = Auth::USER_ROLE_APPS;
$scopes = \array_merge($roles[$role]['scopes'], $key->getAttribute('scopes', []));
if($keyType === API_KEY_DYNAMIC) {
// Dynamic key
$expire = $key->getAttribute('expire');
if (!empty($expire) && $expire < DateTime::formatTz(DateTime::now())) {
throw new Exception(Exception::PROJECT_KEY_EXPIRED);
$jwtObj = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', 3600, 0);
try {
$payload = $jwtObj->decode($authKey);
} catch (JWTException $error) {
throw new Exception(Exception::API_KEY_EXPIRED);
}
Authorization::setRole(Auth::USER_ROLE_APPS);
Authorization::setDefaultStatus(false); // Cancel security segmentation for API keys.
$projectId = $payload['projectId'] ?? '';
$tokenScopes = $payload['scopes'] ?? [];
$accessedAt = $key->getAttribute('accessedAt', '');
if (DateTime::formatTz(DateTime::addSeconds(new \DateTime(), -APP_KEY_ACCCESS)) > $accessedAt) {
$key->setAttribute('accessedAt', DateTime::now());
$dbForConsole->updateDocument('keys', $key->getId(), $key);
$dbForConsole->purgeCachedDocument('projects', $project->getId());
// JWT includes project ID for better security
if ($projectId === $project->getId()) {
$user = new Document([
'$id' => '',
'status' => true,
'email' => 'app.' . $project->getId() . '@service.' . $request->getHostname(),
'password' => '',
'name' => $project->getAttribute('name', 'Untitled'),
]);
$role = Auth::USER_ROLE_APPS;
$scopes = \array_merge($roles[$role]['scopes'], $tokenScopes);
Authorization::setRole(Auth::USER_ROLE_APPS);
Authorization::setDefaultStatus(false); // Cancel security segmentation for API keys.
}
} elseif($keyType === API_KEY_STANDARD) {
// No underline means no prefix. Backwards compatibility.
// Regular key
$sdkValidator = new WhiteList($servers, true);
$sdk = $request->getHeader('x-sdk-name', 'UNKNOWN');
if ($sdkValidator->isValid($sdk)) {
$sdks = $key->getAttribute('sdks', []);
if (!in_array($sdk, $sdks)) {
array_push($sdks, $sdk);
$key->setAttribute('sdks', $sdks);
// Check if given key match project API keys
$key = $project->find('secret', $apiKey, 'keys');
if ($key) {
$user = new Document([
'$id' => '',
'status' => true,
'email' => 'app.' . $project->getId() . '@service.' . $request->getHostname(),
'password' => '',
'name' => $project->getAttribute('name', 'Untitled'),
]);
/** Update access time as well */
$key->setAttribute('accessedAt', Datetime::now());
$role = Auth::USER_ROLE_APPS;
$scopes = \array_merge($roles[$role]['scopes'], $key->getAttribute('scopes', []));
$expire = $key->getAttribute('expire');
if (!empty($expire) && $expire < DateTime::formatTz(DateTime::now())) {
throw new Exception(Exception::PROJECT_KEY_EXPIRED);
}
Authorization::setRole(Auth::USER_ROLE_APPS);
Authorization::setDefaultStatus(false); // Cancel security segmentation for API keys.
$accessedAt = $key->getAttribute('accessedAt', '');
if (DateTime::formatTz(DateTime::addSeconds(new \DateTime(), -APP_KEY_ACCCESS)) > $accessedAt) {
$key->setAttribute('accessedAt', DateTime::now());
$dbForConsole->updateDocument('keys', $key->getId(), $key);
$dbForConsole->purgeCachedDocument('projects', $project->getId());
}
$sdkValidator = new WhiteList($servers, true);
$sdk = $request->getHeader('x-sdk-name', 'UNKNOWN');
if ($sdkValidator->isValid($sdk)) {
$sdks = $key->getAttribute('sdks', []);
if (!in_array($sdk, $sdks)) {
array_push($sdks, $sdk);
$key->setAttribute('sdks', $sdks);
/** Update access time as well */
$key->setAttribute('accessedAt', Datetime::now());
$dbForConsole->updateDocument('keys', $key->getId(), $key);
$dbForConsole->purgeCachedDocument('projects', $project->getId());
}
}
}
}
}

View file

@ -62,6 +62,10 @@ use Utopia\Database\Validator\Structure;
use Utopia\Domains\Validator\PublicDomain;
use Utopia\DSN\DSN;
use Utopia\Locale\Locale;
use Utopia\Logger\Adapter\AppSignal;
use Utopia\Logger\Adapter\LogOwl;
use Utopia\Logger\Adapter\Raygun;
use Utopia\Logger\Adapter\Sentry;
use Utopia\Logger\Log;
use Utopia\Logger\Logger;
use Utopia\Pools\Group;
@ -208,6 +212,9 @@ const FUNCTION_ALLOWLIST_HEADERS_RESPONSE = ['content-type', 'content-length'];
const MESSAGE_TYPE_EMAIL = 'email';
const MESSAGE_TYPE_SMS = 'sms';
const MESSAGE_TYPE_PUSH = 'push';
// API key types
const API_KEY_STANDARD = 'standard';
const API_KEY_DYNAMIC = 'dynamic';
// Usage metrics
const METRIC_TEAMS = 'teams';
const METRIC_USERS = 'users';
@ -229,11 +236,19 @@ const METRIC_FUNCTIONS = 'functions';
const METRIC_DEPLOYMENTS = 'deployments';
const METRIC_DEPLOYMENTS_STORAGE = 'deployments.storage';
const METRIC_BUILDS = 'builds';
const METRIC_BUILDS_SUCCESS = 'builds.success';
const METRIC_BUILDS_FAILED = 'builds.failed';
const METRIC_BUILDS_STORAGE = 'builds.storage';
const METRIC_BUILDS_COMPUTE = 'builds.compute';
const METRIC_BUILDS_COMPUTE_SUCCESS = 'builds.compute.success';
const METRIC_BUILDS_COMPUTE_FAILED = 'builds.compute.failed';
const METRIC_FUNCTION_ID_BUILDS = '{functionInternalId}.builds';
const METRIC_FUNCTION_ID_BUILDS_SUCCESS = '{functionInternalId}.builds.success';
const METRIC_FUNCTION_ID_BUILDS_FAILED = '{functionInternalId}.builds.failed';
const METRIC_FUNCTION_ID_BUILDS_STORAGE = '{functionInternalId}.builds.storage';
const METRIC_FUNCTION_ID_BUILDS_COMPUTE = '{functionInternalId}.builds.compute';
const METRIC_FUNCTION_ID_BUILDS_COMPUTE_SUCCESS = '{functionInternalId}.builds.compute.success';
const METRIC_FUNCTION_ID_BUILDS_COMPUTE_FAILED = '{functionInternalId}.builds.compute.failed';
const METRIC_FUNCTION_ID_DEPLOYMENTS = '{resourceType}.{resourceInternalId}.deployments';
const METRIC_FUNCTION_ID_DEPLOYMENTS_STORAGE = '{resourceType}.{resourceInternalId}.deployments.storage';
const METRIC_EXECUTIONS = 'executions';
@ -341,8 +356,7 @@ Database::addFilter(
if (isset($formatOptions['min']) || isset($formatOptions['max'])) {
$attribute
->setAttribute('min', $formatOptions['min'])
->setAttribute('max', $formatOptions['max'])
;
->setAttribute('max', $formatOptions['max']);
}
return $value;
@ -725,6 +739,26 @@ $register->set('logger', function () {
$providerName = System::getEnv('_APP_LOGGING_PROVIDER', '');
$providerConfig = System::getEnv('_APP_LOGGING_CONFIG', '');
try {
$loggingProvider = new DSN($providerConfig ?? '');
$providerName = $loggingProvider->getScheme();
$providerConfig = match ($providerName) {
'sentry' => ['key' => $loggingProvider->getPassword(), 'projectId' => $loggingProvider->getUser() ?? '', 'host' => $loggingProvider->getHost()],
'logowl' => ['ticket' => $loggingProvider->getUser() ?? '', 'host' => $loggingProvider->getHost()],
default => ['key' => $loggingProvider->getHost()],
};
} catch (Throwable) {
// Fallback for older Appwrite versions up to 1.5.x that use _APP_LOGGING_PROVIDER and _APP_LOGGING_CONFIG environment variables
$configChunks = \explode(";", $providerConfig);
$providerConfig = match ($providerName) {
'sentry' => [ 'key' => $configChunks[0], 'projectId' => $configChunks[1] ?? '', 'host' => '',],
'logowl' => ['ticket' => $configChunks[0] ?? '', 'host' => ''],
default => ['key' => $providerConfig],
};
}
if (empty($providerName) || empty($providerConfig)) {
return;
}
@ -733,20 +767,17 @@ $register->set('logger', function () {
throw new Exception(Exception::GENERAL_SERVER_ERROR, "Logging provider not supported. Logging is disabled");
}
// Old Sentry Format conversion. Fallback until the old syntax is completely deprecated.
if (str_contains($providerConfig, ';') && strtolower($providerName) == 'sentry') {
$configChunks = \explode(";", $providerConfig);
$adapter = match ($providerName) {
'sentry' => new Sentry($providerConfig['projectId'], $providerConfig['key'], $providerConfig['host']),
'logowl' => new LogOwl($providerConfig['ticket'], $providerConfig['host']),
'raygun' => new Raygun($providerConfig['key']),
'appsignal' => new AppSignal($providerConfig['key']),
default => throw new Exception('Provider "' . $providerName . '" not supported.')
};
$sentryKey = $configChunks[0];
$projectId = $configChunks[1];
$providerConfig = 'https://' . $sentryKey . '@sentry.io/' . $projectId;
}
$classname = '\\Utopia\\Logger\\Adapter\\' . \ucfirst($providerName);
$adapter = new $classname($providerConfig);
return new Logger($adapter);
});
$register->set('pools', function () {
$group = new Group();
@ -1201,7 +1232,7 @@ App::setResource('user', function ($mode, $project, $console, $request, $respons
$authJWT = $request->getHeader('x-appwrite-jwt', '');
if (!empty($authJWT) && !$project->isEmpty()) { // JWT authentication
$jwt = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', 900, 10); // Instantiate with key, algo, maxAge and leeway.
$jwt = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', 3600, 0);
try {
$payload = $jwt->decode($authJWT);
@ -1290,9 +1321,11 @@ App::setResource('console', function () {
'legalAddress' => '',
'legalTaxId' => '',
'auths' => [
'mockNumbers' => [],
'invites' => System::getEnv('_APP_CONSOLE_INVITES', 'enabled') === 'enabled',
'limit' => (System::getEnv('_APP_CONSOLE_WHITELIST_ROOT', 'enabled') === 'enabled') ? 1 : 0, // limit signup to 1 user
'duration' => Auth::TOKEN_EXPIRATION_LOGIN_LONG, // 1 Year in seconds
'sessionAlerts' => System::getEnv('_APP_CONSOLE_SESSION_ALERTS', 'disabled') === 'enabled'
],
'authWhitelistEmails' => (!empty(System::getEnv('_APP_CONSOLE_WHITELIST_EMAILS', null))) ? \explode(',', System::getEnv('_APP_CONSOLE_WHITELIST_EMAILS', null)) : [],
'authWhitelistIPs' => (!empty(System::getEnv('_APP_CONSOLE_WHITELIST_IPS', null))) ? \explode(',', System::getEnv('_APP_CONSOLE_WHITELIST_IPS', null)) : [],

View file

@ -75,6 +75,7 @@ $image = $this->getParam('image', '');
- _APP_LOCALE
- _APP_CONSOLE_WHITELIST_ROOT
- _APP_CONSOLE_WHITELIST_EMAILS
- _APP_CONSOLE_SESSION_ALERTS
- _APP_CONSOLE_WHITELIST_IPS
- _APP_CONSOLE_HOSTNAMES
- _APP_SYSTEM_EMAIL_NAME
@ -138,7 +139,6 @@ $image = $this->getParam('image', '');
- _APP_FUNCTIONS_RUNTIMES
- _APP_EXECUTOR_SECRET
- _APP_EXECUTOR_HOST
- _APP_LOGGING_PROVIDER
- _APP_LOGGING_CONFIG
- _APP_MAINTENANCE_INTERVAL
- _APP_MAINTENANCE_DELAY
@ -204,7 +204,6 @@ $image = $this->getParam('image', '');
- _APP_DB_USER
- _APP_DB_PASS
- _APP_USAGE_STATS
- _APP_LOGGING_PROVIDER
- _APP_LOGGING_CONFIG
appwrite-worker-audits:
@ -231,7 +230,6 @@ $image = $this->getParam('image', '');
- _APP_DB_SCHEMA
- _APP_DB_USER
- _APP_DB_PASS
- _APP_LOGGING_PROVIDER
- _APP_LOGGING_CONFIG
appwrite-worker-webhooks:
@ -260,7 +258,6 @@ $image = $this->getParam('image', '');
- _APP_REDIS_PORT
- _APP_REDIS_USER
- _APP_REDIS_PASS
- _APP_LOGGING_PROVIDER
- _APP_LOGGING_CONFIG
appwrite-worker-deletes:
@ -314,7 +311,6 @@ $image = $this->getParam('image', '');
- _APP_STORAGE_WASABI_SECRET
- _APP_STORAGE_WASABI_REGION
- _APP_STORAGE_WASABI_BUCKET
- _APP_LOGGING_PROVIDER
- _APP_LOGGING_CONFIG
- _APP_EXECUTOR_SECRET
- _APP_EXECUTOR_HOST
@ -343,7 +339,6 @@ $image = $this->getParam('image', '');
- _APP_DB_SCHEMA
- _APP_DB_USER
- _APP_DB_PASS
- _APP_LOGGING_PROVIDER
- _APP_LOGGING_CONFIG
appwrite-worker-builds:
@ -375,7 +370,6 @@ $image = $this->getParam('image', '');
- _APP_DB_SCHEMA
- _APP_DB_USER
- _APP_DB_PASS
- _APP_LOGGING_PROVIDER
- _APP_LOGGING_CONFIG
- _APP_VCS_GITHUB_APP_NAME
- _APP_VCS_GITHUB_PRIVATE_KEY
@ -441,7 +435,6 @@ $image = $this->getParam('image', '');
- _APP_DB_SCHEMA
- _APP_DB_USER
- _APP_DB_PASS
- _APP_LOGGING_PROVIDER
- _APP_LOGGING_CONFIG
appwrite-worker-functions:
@ -479,7 +472,6 @@ $image = $this->getParam('image', '');
- _APP_DOCKER_HUB_USERNAME
- _APP_DOCKER_HUB_PASSWORD
- _APP_LOGGING_CONFIG
- _APP_LOGGING_PROVIDER
appwrite-worker-mails:
image: <?php echo $organization; ?>/<?php echo $image; ?>:<?php echo $version."\n"; ?>
@ -511,7 +503,6 @@ $image = $this->getParam('image', '');
- _APP_SMTP_SECURE
- _APP_SMTP_USERNAME
- _APP_SMTP_PASSWORD
- _APP_LOGGING_PROVIDER
- _APP_LOGGING_CONFIG
appwrite-worker-messaging:
@ -539,7 +530,6 @@ $image = $this->getParam('image', '');
- _APP_DB_SCHEMA
- _APP_DB_USER
- _APP_DB_PASS
- _APP_LOGGING_PROVIDER
- _APP_LOGGING_CONFIG
- _APP_SMS_FROM
- _APP_SMS_PROVIDER
@ -591,7 +581,6 @@ $image = $this->getParam('image', '');
- _APP_DB_SCHEMA
- _APP_DB_USER
- _APP_DB_PASS
- _APP_LOGGING_PROVIDER
- _APP_LOGGING_CONFIG
- _APP_MIGRATIONS_FIREBASE_CLIENT_ID
- _APP_MIGRATIONS_FIREBASE_CLIENT_SECRET
@ -655,7 +644,6 @@ $image = $this->getParam('image', '');
- _APP_REDIS_USER
- _APP_REDIS_PASS
- _APP_USAGE_STATS
- _APP_LOGGING_PROVIDER
- _APP_LOGGING_CONFIG
- _APP_USAGE_AGGREGATION_INTERVAL
@ -683,7 +671,6 @@ $image = $this->getParam('image', '');
- _APP_REDIS_USER
- _APP_REDIS_PASS
- _APP_USAGE_STATS
- _APP_LOGGING_PROVIDER
- _APP_LOGGING_CONFIG
- _APP_USAGE_AGGREGATION_INTERVAL
@ -712,6 +699,31 @@ $image = $this->getParam('image', '');
- _APP_DB_USER
- _APP_DB_PASS
appwrite-task-scheduler-executions:
image: <?php echo $organization; ?>/<?php echo $image; ?>:<?php echo $version."\n"; ?>
entrypoint: schedule-executions
container_name: appwrite-task-scheduler-executions
<<: *x-logging
restart: unless-stopped
networks:
- appwrite
depends_on:
- mariadb
- redis
environment:
- _APP_ENV
- _APP_WORKER_PER_CORE
- _APP_OPENSSL_KEY_V1
- _APP_REDIS_HOST
- _APP_REDIS_PORT
- _APP_REDIS_USER
- _APP_REDIS_PASS
- _APP_DB_HOST
- _APP_DB_PORT
- _APP_DB_SCHEMA
- _APP_DB_USER
- _APP_DB_PASS
appwrite-task-scheduler-messages:
image: <?php echo $organization; ?>/<?php echo $image; ?>:<?php echo $version."\n"; ?>
entrypoint: schedule-messages
@ -773,7 +785,6 @@ $image = $this->getParam('image', '');
- OPR_EXECUTOR_ENV=$_APP_ENV
- OPR_EXECUTOR_RUNTIMES=$_APP_FUNCTIONS_RUNTIMES
- OPR_EXECUTOR_SECRET=$_APP_EXECUTOR_SECRET
- OPR_EXECUTOR_LOGGING_PROVIDER=$_APP_LOGGING_PROVIDER
- OPR_EXECUTOR_LOGGING_CONFIG=$_APP_LOGGING_CONFIG
- OPR_EXECUTOR_STORAGE_DEVICE=$_APP_STORAGE_DEVICE
- OPR_EXECUTOR_STORAGE_S3_ACCESS_KEY=$_APP_STORAGE_S3_ACCESS_KEY

3
bin/schedule-executions Normal file
View file

@ -0,0 +1,3 @@
#!/bin/sh
php /usr/src/code/app/cli.php schedule-executions $@

View file

@ -42,7 +42,7 @@
"ext-openssl": "*",
"ext-zlib": "*",
"ext-sockets": "*",
"appwrite/php-runtimes": "0.13.*",
"appwrite/php-runtimes": "0.14.*",
"appwrite/php-clamav": "2.0.*",
"utopia-php/abuse": "0.38.*",
"utopia-php/analytics": "0.10.*",
@ -69,7 +69,7 @@
"utopia-php/storage": "0.18.*",
"utopia-php/swoole": "0.8.*",
"utopia-php/system": "0.8.*",
"utopia-php/vcs": "0.6.*",
"utopia-php/vcs": "0.7.*",
"utopia-php/websocket": "0.1.*",
"matomo/device-detector": "6.1.*",
"dragonmantank/cron-expression": "3.3.2",

31
composer.lock generated
View file

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "b9d369872767c1af0db14ae6d8fea0f5",
"content-hash": "77a2eac158245fb198f23817b6c12aec",
"packages": [
{
"name": "adhocore/jwt",
@ -156,16 +156,16 @@
},
{
"name": "appwrite/php-runtimes",
"version": "0.13.5",
"version": "0.14.0",
"source": {
"type": "git",
"url": "https://github.com/appwrite/runtimes.git",
"reference": "ba24c3a163f1a1da6cd355db92def508d05e59f7"
"reference": "9bae5bebe7c7cb3e4bf3a05a1d36c9d6ce3a2d3e"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/appwrite/runtimes/zipball/ba24c3a163f1a1da6cd355db92def508d05e59f7",
"reference": "ba24c3a163f1a1da6cd355db92def508d05e59f7",
"url": "https://api.github.com/repos/appwrite/runtimes/zipball/9bae5bebe7c7cb3e4bf3a05a1d36c9d6ce3a2d3e",
"reference": "9bae5bebe7c7cb3e4bf3a05a1d36c9d6ce3a2d3e",
"shasum": ""
},
"require": {
@ -173,8 +173,9 @@
"utopia-php/system": "0.8.*"
},
"require-dev": {
"phpunit/phpunit": "^9.3",
"vimeo/psalm": "4.0.1"
"laravel/pint": "^1.15",
"phpstan/phpstan": "^1.10",
"phpunit/phpunit": "^9.3"
},
"type": "library",
"autoload": {
@ -204,9 +205,9 @@
],
"support": {
"issues": "https://github.com/appwrite/runtimes/issues",
"source": "https://github.com/appwrite/runtimes/tree/0.13.5"
"source": "https://github.com/appwrite/runtimes/tree/0.14.0"
},
"time": "2024-04-01T10:35:02+00:00"
"time": "2024-07-03T10:18:32+00:00"
},
{
"name": "beberlei/assert",
@ -3602,16 +3603,16 @@
},
{
"name": "utopia-php/vcs",
"version": "0.6.7",
"version": "0.7.0",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/vcs.git",
"reference": "8d8ff1ac68e991b95adb6f91fcde8f9bb8f24974"
"reference": "4745fcf385cb8f5b645be447cc1631930853c8af"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/utopia-php/vcs/zipball/8d8ff1ac68e991b95adb6f91fcde8f9bb8f24974",
"reference": "8d8ff1ac68e991b95adb6f91fcde8f9bb8f24974",
"url": "https://api.github.com/repos/utopia-php/vcs/zipball/4745fcf385cb8f5b645be447cc1631930853c8af",
"reference": "4745fcf385cb8f5b645be447cc1631930853c8af",
"shasum": ""
},
"require": {
@ -3645,9 +3646,9 @@
],
"support": {
"issues": "https://github.com/utopia-php/vcs/issues",
"source": "https://github.com/utopia-php/vcs/tree/0.6.7"
"source": "https://github.com/utopia-php/vcs/tree/0.7.0"
},
"time": "2024-06-05T17:38:29+00:00"
"time": "2024-06-26T09:44:52+00:00"
},
{
"name": "utopia-php/websocket",

View file

@ -40,6 +40,7 @@ services:
networks:
- gateway
- appwrite
- runtimes
appwrite:
container_name: appwrite
@ -97,6 +98,7 @@ services:
- _APP_LOCALE
- _APP_CONSOLE_WHITELIST_ROOT
- _APP_CONSOLE_WHITELIST_EMAILS
- _APP_CONSOLE_SESSION_ALERTS
- _APP_CONSOLE_WHITELIST_IPS
- _APP_CONSOLE_HOSTNAMES
- _APP_SYSTEM_EMAIL_NAME
@ -160,7 +162,6 @@ services:
- _APP_FUNCTIONS_RUNTIMES
- _APP_EXECUTOR_SECRET
- _APP_EXECUTOR_HOST
- _APP_LOGGING_PROVIDER
- _APP_LOGGING_CONFIG
- _APP_MAINTENANCE_INTERVAL
- _APP_MAINTENANCE_RETENTION_EXECUTION
@ -237,7 +238,6 @@ services:
- _APP_DB_USER
- _APP_DB_PASS
- _APP_USAGE_STATS
- _APP_LOGGING_PROVIDER
- _APP_LOGGING_CONFIG
- _APP_DATABASE_SHARED_TABLES
@ -267,7 +267,6 @@ services:
- _APP_DB_SCHEMA
- _APP_DB_USER
- _APP_DB_PASS
- _APP_LOGGING_PROVIDER
- _APP_LOGGING_CONFIG
- _APP_DATABASE_SHARED_TABLES
@ -299,7 +298,6 @@ services:
- _APP_REDIS_PORT
- _APP_REDIS_USER
- _APP_REDIS_PASS
- _APP_LOGGING_PROVIDER
- _APP_LOGGING_CONFIG
- _APP_WEBHOOK_MAX_FAILED_ATTEMPTS
- _APP_DATABASE_SHARED_TABLES
@ -356,7 +354,6 @@ services:
- _APP_STORAGE_WASABI_SECRET
- _APP_STORAGE_WASABI_REGION
- _APP_STORAGE_WASABI_BUCKET
- _APP_LOGGING_PROVIDER
- _APP_LOGGING_CONFIG
- _APP_EXECUTOR_SECRET
- _APP_EXECUTOR_HOST
@ -388,7 +385,6 @@ services:
- _APP_DB_SCHEMA
- _APP_DB_USER
- _APP_DB_PASS
- _APP_LOGGING_PROVIDER
- _APP_LOGGING_CONFIG
- _APP_WORKERS_NUM
- _APP_QUEUE_NAME
@ -424,7 +420,6 @@ services:
- _APP_DB_SCHEMA
- _APP_DB_USER
- _APP_DB_PASS
- _APP_LOGGING_PROVIDER
- _APP_LOGGING_CONFIG
- _APP_VCS_GITHUB_APP_NAME
- _APP_VCS_GITHUB_PRIVATE_KEY
@ -492,7 +487,6 @@ services:
- _APP_DB_SCHEMA
- _APP_DB_USER
- _APP_DB_PASS
- _APP_LOGGING_PROVIDER
- _APP_LOGGING_CONFIG
- _APP_DATABASE_SHARED_TABLES
@ -514,6 +508,8 @@ services:
- _APP_ENV
- _APP_WORKER_PER_CORE
- _APP_OPENSSL_KEY_V1
- _APP_DOMAIN
- _APP_OPTIONS_FORCE_HTTPS
- _APP_REDIS_HOST
- _APP_REDIS_PORT
- _APP_REDIS_USER
@ -565,7 +561,6 @@ services:
- _APP_SMTP_SECURE
- _APP_SMTP_USERNAME
- _APP_SMTP_PASSWORD
- _APP_LOGGING_PROVIDER
- _APP_LOGGING_CONFIG
- _APP_DOMAIN
- _APP_OPTIONS_FORCE_HTTPS
@ -598,7 +593,6 @@ services:
- _APP_DB_SCHEMA
- _APP_DB_USER
- _APP_DB_PASS
- _APP_LOGGING_PROVIDER
- _APP_LOGGING_CONFIG
- _APP_SMS_FROM
- _APP_SMS_PROVIDER
@ -656,7 +650,6 @@ services:
- _APP_DB_SCHEMA
- _APP_DB_USER
- _APP_DB_PASS
- _APP_LOGGING_PROVIDER
- _APP_LOGGING_CONFIG
- _APP_MIGRATIONS_FIREBASE_CLIENT_ID
- _APP_MIGRATIONS_FIREBASE_CLIENT_SECRET
@ -727,7 +720,6 @@ services:
- _APP_REDIS_USER
- _APP_REDIS_PASS
- _APP_USAGE_STATS
- _APP_LOGGING_PROVIDER
- _APP_LOGGING_CONFIG
- _APP_USAGE_AGGREGATION_INTERVAL
- _APP_DATABASE_SHARED_TABLES
@ -759,7 +751,6 @@ services:
- _APP_REDIS_USER
- _APP_REDIS_PASS
- _APP_USAGE_STATS
- _APP_LOGGING_PROVIDER
- _APP_LOGGING_CONFIG
- _APP_USAGE_AGGREGATION_INTERVAL
- _APP_DATABASE_SHARED_TABLES
@ -792,6 +783,33 @@ services:
- _APP_DB_PASS
- _APP_DATABASE_SHARED_TABLES
appwrite-task-scheduler-executions:
entrypoint: schedule-executions
<<: *x-logging
container_name: appwrite-task-scheduler-executions
image: appwrite-dev
networks:
- appwrite
volumes:
- ./app:/usr/src/code/app
- ./src:/usr/src/code/src
depends_on:
- mariadb
- redis
environment:
- _APP_ENV
- _APP_WORKER_PER_CORE
- _APP_OPENSSL_KEY_V1
- _APP_REDIS_HOST
- _APP_REDIS_PORT
- _APP_REDIS_USER
- _APP_REDIS_PASS
- _APP_DB_HOST
- _APP_DB_PORT
- _APP_DB_SCHEMA
- _APP_DB_USER
- _APP_DB_PASS
appwrite-task-scheduler-messages:
entrypoint: schedule-messages
<<: *x-logging
@ -833,7 +851,7 @@ services:
hostname: exc1
<<: *x-logging
stop_signal: SIGINT
image: openruntimes/executor:0.5.5
image: openruntimes/executor:0.6.1
restart: unless-stopped
networks:
- appwrite
@ -854,8 +872,7 @@ services:
- OPR_EXECUTOR_ENV=$_APP_ENV
- OPR_EXECUTOR_RUNTIMES=$_APP_FUNCTIONS_RUNTIMES
- OPR_EXECUTOR_SECRET=$_APP_EXECUTOR_SECRET
- OPR_EXECUTOR_RUNTIME_VERSIONS=v2,v3
- OPR_EXECUTOR_LOGGING_PROVIDER=$_APP_LOGGING_PROVIDER
- OPR_EXECUTOR_RUNTIME_VERSIONS=v2,v4
- OPR_EXECUTOR_LOGGING_CONFIG=$_APP_LOGGING_CONFIG
- OPR_EXECUTOR_STORAGE_DEVICE=$_APP_STORAGE_DEVICE
- OPR_EXECUTOR_STORAGE_S3_ACCESS_KEY=$_APP_STORAGE_S3_ACCESS_KEY
@ -893,7 +910,6 @@ services:
- OPR_PROXY_ENV=$_APP_ENV
- OPR_PROXY_EXECUTOR_SECRET=$_APP_EXECUTOR_SECRET
- OPR_PROXY_SECRET=$_APP_EXECUTOR_SECRET
- OPR_PROXY_LOGGING_PROVIDER=$_APP_LOGGING_PROVIDER
- OPR_PROXY_LOGGING_CONFIG=$_APP_LOGGING_CONFIG
- OPR_PROXY_ALGORITHM=random
- OPR_PROXY_EXECUTORS=exc1

View file

@ -5,7 +5,3 @@ X-Appwrite-Response-Format: 1.5.0
X-Appwrite-Project: 5df5acd0d48c2
X-Appwrite-Session:
X-Appwrite-JWT: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ...
{
"otp": "<OTP>"
}

View file

@ -1 +0,0 @@
Create a new build for an Appwrite Function deployment. This endpoint can be used to retry a failed build.

View file

@ -0,0 +1 @@
Delete a function execution by its unique ID.

View file

@ -0,0 +1 @@
Use this endpoint to create a JSON Web Token for user by its unique ID. You can use the resulting JWT to authenticate on behalf of the user. The JWT secret will become invalid if the session it uses gets deleted.

View file

@ -0,0 +1,81 @@
<?php
namespace Appwrite\Auth\Validator;
use Utopia\Validator;
use Utopia\Validator\Text;
/**
* MockNumber.
*
* Validates if a given object represents a valid phone and OTP pair
*/
class MockNumber extends Validator
{
private $message = '';
/**
* Get Description.
*
* Returns validator description
*
* @return string
*/
public function getDescription(): string
{
return $this->message;
}
/**
* Is valid.
*
* @param mixed $value
*
* @return bool
*/
public function isValid($value): bool
{
if (!\is_array($value) || !isset($value['phone']) || !isset($value['otp'])) {
$this->message = 'Invalid payload structure. Please check the "phone" and "otp" fields';
return false;
}
$phone = new Phone();
if (!$phone->isValid($value['phone'])) {
$this->message = $phone->getDescription();
return false;
}
$otp = new Text(6, 6);
if (!$otp->isValid($value['otp'])) {
$this->message = 'OTP must be a valid string and exactly 6 characters.';
return false;
}
return true;
}
/**
* Is array
*
* Function will return true if object is array.
*
* @return bool
*/
public function isArray(): bool
{
return false;
}
/**
* Get Type
*
* Returns validator type.
*
* @return string
*/
public function getType(): string
{
return self::TYPE_OBJECT;
}
}

View file

@ -14,6 +14,7 @@ class Func extends Event
protected string $path = '';
protected string $method = '';
protected array $headers = [];
protected ?string $functionId = null;
protected ?Document $function = null;
protected ?Document $execution = null;
@ -49,6 +50,28 @@ class Func extends Event
return $this->function;
}
/**
* Sets function id for the function event.
*
* @param string $functionId
*/
public function setFunctionId(string $functionId): self
{
$this->functionId = $functionId;
return $this;
}
/**
* Returns set function id for the function event.
*
* @return string|null
*/
public function getFunctionId(): ?string
{
return $this->functionId;
}
/**
* Sets execution for the function event.
*
@ -200,6 +223,7 @@ class Func extends Event
'project' => $this->project,
'user' => $this->user,
'function' => $this->function,
'functionId' => $this->functionId,
'execution' => $this->execution,
'type' => $this->type,
'jwt' => $this->jwt,

View file

@ -107,6 +107,8 @@ class Exception extends \Exception
public const USER_TARGET_ALREADY_EXISTS = 'user_target_already_exists';
public const USER_API_KEY_AND_SESSION_SET = 'user_key_and_session_set';
public const API_KEY_EXPIRED = 'api_key_expired';
/** Teams */
public const TEAM_NOT_FOUND = 'team_not_found';
public const TEAM_INVITE_ALREADY_EXISTS = 'team_invite_already_exists';
@ -162,9 +164,11 @@ class Exception extends \Exception
public const BUILD_NOT_FOUND = 'build_not_found';
public const BUILD_NOT_READY = 'build_not_ready';
public const BUILD_IN_PROGRESS = 'build_in_progress';
public const BUILD_ALREADY_COMPLETED = 'build_already_completed';
/** Execution */
public const EXECUTION_NOT_FOUND = 'execution_not_found';
public const EXECUTION_IN_PROGRESS = 'execution_in_progress';
/** Databases */
public const DATABASE_NOT_FOUND = 'database_not_found';

View file

@ -8,6 +8,7 @@ use Appwrite\Platform\Tasks\Maintenance;
use Appwrite\Platform\Tasks\Migrate;
use Appwrite\Platform\Tasks\QueueCount;
use Appwrite\Platform\Tasks\QueueRetry;
use Appwrite\Platform\Tasks\ScheduleExecutions;
use Appwrite\Platform\Tasks\ScheduleFunctions;
use Appwrite\Platform\Tasks\ScheduleMessages;
use Appwrite\Platform\Tasks\SDKs;
@ -33,6 +34,7 @@ class Tasks extends Service
->addAction(SDKs::getName(), new SDKs())
->addAction(SSL::getName(), new SSL())
->addAction(ScheduleFunctions::getName(), new ScheduleFunctions())
->addAction(ScheduleExecutions::getName(), new ScheduleExecutions())
->addAction(ScheduleMessages::getName(), new ScheduleMessages())
->addAction(Specs::getName(), new Specs())
->addAction(Upgrade::getName(), new Upgrade())

View file

@ -7,6 +7,7 @@ use Utopia\App;
use Utopia\CLI\Console;
use Utopia\Config\Config;
use Utopia\Domains\Domain;
use Utopia\DSN\DSN;
use Utopia\Logger\Logger;
use Utopia\Platform\Action;
use Utopia\Registry\Registry;
@ -100,13 +101,20 @@ class Doctor extends Action
Console::log('🟢 HTTPS force option is enabled for function domains');
}
$providerName = System::getEnv('_APP_LOGGING_PROVIDER', '');
$providerConfig = System::getEnv('_APP_LOGGING_CONFIG', '');
if (empty($providerName) || empty($providerConfig) || !Logger::hasProvider($providerName)) {
Console::log('🔴 Logging adapter is disabled');
} else {
Console::log('🟢 Logging adapter is enabled (' . $providerName . ')');
try {
$loggingProvider = new DSN($providerConfig ?? '');
$providerName = $loggingProvider->getScheme();
if (empty($providerName) || !Logger::hasProvider($providerName)) {
Console::log('🔴 Logging adapter is disabled');
} else {
Console::log('🟢 Logging adapter is enabled (' . $providerName . ')');
}
} catch (\Throwable $th) {
Console::log('🔴 Logging adapter is misconfigured');
}
\usleep(200 * 1000); // Sleep for 0.2 seconds

View file

@ -64,7 +64,8 @@ abstract class ScheduleBase extends Action
$collectionId = match ($schedule->getAttribute('resourceType')) {
'function' => 'functions',
'message' => 'messages'
'message' => 'messages',
'execution' => 'executions'
};
$resource = $getProjectDB($project)->getDocument(
@ -113,7 +114,8 @@ abstract class ScheduleBase extends Action
} catch (\Throwable $th) {
$collectionId = match ($document->getAttribute('resourceType')) {
'function' => 'functions',
'message' => 'messages'
'message' => 'messages',
'execution' => 'executions'
};
Console::error("Failed to load schedule for project {$document['projectId']} {$collectionId} {$document['resourceId']}");

View file

@ -0,0 +1,71 @@
<?php
namespace Appwrite\Platform\Tasks;
use Appwrite\Event\Func;
use Utopia\Database\Database;
use Utopia\Pools\Group;
class ScheduleExecutions extends ScheduleBase
{
public const UPDATE_TIMER = 3; // seconds
public const ENQUEUE_TIMER = 4; // seconds
public static function getName(): string
{
return 'schedule-executions';
}
public static function getSupportedResource(): string
{
return 'execution';
}
protected function enqueueResources(Group $pools, Database $dbForConsole): void
{
$queue = $pools->get('queue')->pop();
$connection = $queue->getResource();
$queueForFunctions = new Func($connection);
foreach ($this->schedules as $schedule) {
if (!$schedule['active']) {
$dbForConsole->deleteDocument(
'schedules',
$schedule['$id'],
);
unset($this->schedules[$schedule['resourceId']]);
continue;
}
$now = new \DateTime();
$scheduledAt = new \DateTime($schedule['schedule']);
if ($scheduledAt > $now) {
continue;
}
$queueForFunctions
->setType('schedule')
// Set functionId instead of function as we don't have $dbForProject
// TODO: Refactor to use function instead of functionId
->setFunctionId($schedule['resource']['functionId'])
->setExecution($schedule['resource'])
->setMethod($schedule['data']['method'] ?? 'POST')
->setPath($schedule['data']['path'] ?? '/')
->setHeaders($schedule['data']['headers'] ?? [])
->setBody($schedule['data']['body'] ?? '')
->setProject($schedule['project'])
->trigger();
$dbForConsole->deleteDocument(
'schedules',
$schedule['$id'],
);
unset($this->schedules[$schedule['resourceId']]);
}
$queue->reclaim();
}
}

View file

@ -35,7 +35,7 @@ class ScheduleMessages extends ScheduleBase
continue;
}
\go(function () use ($now, $schedule, $pools, $dbForConsole) {
\go(function () use ($schedule, $pools, $dbForConsole) {
$queue = $pools->get('queue')->pop();
$connection = $queue->getResource();
$queueForMessaging = new Messaging($connection);

View file

@ -8,6 +8,7 @@ use Appwrite\Event\Usage;
use Appwrite\Messaging\Adapter\Realtime;
use Appwrite\Utopia\Response\Model\Deployment;
use Appwrite\Vcs\Comment;
use Exception;
use Executor\Executor;
use Swoole\Coroutine as Co;
use Utopia\Cache\Cache;
@ -156,9 +157,9 @@ class Builds extends Action
$startTime = DateTime::now();
$durationStart = \microtime(true);
$buildId = $deployment->getAttribute('buildId', '');
$build = $dbForProject->getDocument('builds', $buildId);
$isNewBuild = empty($buildId);
if ($isNewBuild) {
if ($build->isEmpty()) {
$buildId = ID::unique();
$build = $dbForProject->createDocument('builds', new Document([
'$id' => $buildId,
@ -180,6 +181,9 @@ class Builds extends Action
$deployment->setAttribute('buildId', $build->getId());
$deployment->setAttribute('buildInternalId', $build->getInternalId());
$deployment = $dbForProject->updateDocument('deployments', $deployment->getId(), $deployment);
} elseif ($build->getAttribute('status') === 'canceled') {
Console::info('Build has been canceled');
return;
} else {
$build = $dbForProject->getDocument('builds', $buildId);
}
@ -221,6 +225,12 @@ class Builds extends Action
$stdout = '';
$stderr = '';
Console::execute('mkdir -p /tmp/builds/' . \escapeshellcmd($buildId), '', $stdout, $stderr);
if ($dbForProject->getDocument('builds', $buildId)->getAttribute('status') === 'canceled') {
Console::info('Build has been canceled');
return;
}
$exit = Console::execute($gitCloneCommand, '', $stdout, $stderr);
if ($exit !== 0) {
@ -339,14 +349,13 @@ class Builds extends Action
$deploymentModel = new Deployment();
$deploymentUpdate =
$queueForEvents
->setQueue(Event::WEBHOOK_QUEUE_NAME)
->setClass(Event::WEBHOOK_CLASS_NAME)
->setProject($project)
->setEvent('functions.[functionId].deployments.[deploymentId].update')
->setParam('functionId', $function->getId())
->setParam('deploymentId', $deployment->getId())
->setPayload($deployment->getArrayCopy(array_keys($deploymentModel->getRules())))
;
->setQueue(Event::WEBHOOK_QUEUE_NAME)
->setClass(Event::WEBHOOK_CLASS_NAME)
->setProject($project)
->setEvent('functions.[functionId].deployments.[deploymentId].update')
->setParam('functionId', $function->getId())
->setParam('deploymentId', $deployment->getId())
->setPayload($deployment->getArrayCopy(array_keys($deploymentModel->getRules())));
$deploymentUpdate->trigger();
@ -391,6 +400,7 @@ class Builds extends Action
'APPWRITE_FUNCTION_PROJECT_ID' => $project->getId(),
'APPWRITE_FUNCTION_RUNTIME_NAME' => $runtime['name'] ?? '',
'APPWRITE_FUNCTION_RUNTIME_VERSION' => $runtime['version'] ?? '',
'APPWRITE_VERSION' => APP_VERSION_STABLE
]);
$command = $deployment->getAttribute('commands', '');
@ -398,6 +408,11 @@ class Builds extends Action
$response = null;
$err = null;
if ($dbForProject->getDocument('builds', $buildId)->getAttribute('status') === 'canceled') {
Console::info('Build has been canceled');
return;
}
Co::join([
Co\go(function () use ($executor, &$response, $project, $deployment, $source, $function, $runtime, $vars, $command, &$err) {
try {
@ -439,8 +454,8 @@ class Builds extends Action
$build = $dbForProject->updateDocument('builds', $build->getId(), $build);
/**
* Send realtime Event
*/
* Send realtime Event
*/
$target = Realtime::fromPayload(
// Pass first, most verbose event pattern
event: $allEvents[0],
@ -466,6 +481,10 @@ class Builds extends Action
]);
if ($err) {
if ($dbForProject->getDocument('builds', $buildId)->getAttribute('status') === 'canceled') {
Console::info('Build has been canceled');
return;
}
throw $err;
}
@ -495,6 +514,11 @@ class Builds extends Action
$function = $dbForProject->updateDocument('functions', $function->getId(), $function);
}
if ($dbForProject->getDocument('builds', $buildId)->getAttribute('status') === 'canceled') {
Console::info('Build has been canceled');
return;
}
/** Update function schedule */
// Inform scheduler if function is still active
@ -505,6 +529,11 @@ class Builds extends Action
->setAttribute('active', !empty($function->getAttribute('schedule')) && !empty($function->getAttribute('deployment')));
Authorization::skip(fn () => $dbForConsole->updateDocument('schedules', $schedule->getId(), $schedule));
} catch (\Throwable $th) {
if ($dbForProject->getDocument('builds', $buildId)->getAttribute('status') === 'canceled') {
Console::info('Build has been canceled');
return;
}
$endTime = DateTime::now();
$durationEnd = \microtime(true);
$build->setAttribute('endTime', $endTime);
@ -536,6 +565,20 @@ class Builds extends Action
);
/** Trigger usage queue */
if ($build->getAttribute('status') === 'ready') {
$queueForUsage
->addMetric(METRIC_BUILDS_SUCCESS, 1) // per project
->addMetric(METRIC_BUILDS_COMPUTE_SUCCESS, (int)$build->getAttribute('duration', 0) * 1000)
->addMetric(str_replace('{functionInternalId}', $function->getInternalId(), METRIC_FUNCTION_ID_BUILDS_SUCCESS), 1) // per function
->addMetric(str_replace('{functionInternalId}', $function->getInternalId(), METRIC_FUNCTION_ID_BUILDS_COMPUTE_SUCCESS), (int)$build->getAttribute('duration', 0) * 1000);
} elseif ($build->getAttribute('status') === 'failed') {
$queueForUsage
->addMetric(METRIC_BUILDS_FAILED, 1) // per project
->addMetric(METRIC_BUILDS_COMPUTE_FAILED, (int)$build->getAttribute('duration', 0) * 1000)
->addMetric(str_replace('{functionInternalId}', $function->getInternalId(), METRIC_FUNCTION_ID_BUILDS_FAILED), 1) // per function
->addMetric(str_replace('{functionInternalId}', $function->getInternalId(), METRIC_FUNCTION_ID_BUILDS_COMPUTE_FAILED), (int)$build->getAttribute('duration', 0) * 1000);
}
$queueForUsage
->addMetric(METRIC_BUILDS, 1) // per project
->addMetric(METRIC_BUILDS_STORAGE, $build->getAttribute('size', 0))

View file

@ -2,6 +2,7 @@
namespace Appwrite\Platform\Workers;
use Ahc\Jwt\JWT;
use Appwrite\Event\Event;
use Appwrite\Event\Func;
use Appwrite\Event\Usage;
@ -82,6 +83,7 @@ class Functions extends Action
$eventData = $payload['payload'] ?? '';
$project = new Document($payload['project'] ?? []);
$function = new Document($payload['function'] ?? []);
$functionId = $payload['functionId'] ?? '';
$user = new Document($payload['user'] ?? []);
$method = $payload['method'] ?? 'POST';
$headers = $payload['headers'] ?? [];
@ -91,6 +93,10 @@ class Functions extends Action
return;
}
if ($function->isEmpty() && !empty($functionId)) {
$function = $dbForProject->getDocument('functions', $functionId);
}
$log->addTag('functionId', $function->getId());
$log->addTag('projectId', $project->getId());
$log->addTag('type', $type);
@ -175,6 +181,7 @@ class Functions extends Action
);
break;
case 'schedule':
$execution = new Document($payload['execution'] ?? []);
$this->execute(
log: $log,
dbForProject: $dbForProject,
@ -192,7 +199,7 @@ class Functions extends Action
jwt: null,
event: null,
eventData: null,
executionId: null,
executionId: $execution->getId() ?? null
);
break;
}
@ -354,6 +361,14 @@ class Functions extends Action
$runtime = $runtimes[$function->getAttribute('runtime')];
$jwtExpiry = $function->getAttribute('timeout', 900);
$jwtObj = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', $jwtExpiry, 0);
$apiKey = $jwtObj->encode([
'projectId' => $project->getId(),
'scopes' => $function->getAttribute('scopes', [])
]);
$headers['x-appwrite-key'] = API_KEY_DYNAMIC . '_' . $apiKey;
$headers['x-appwrite-trigger'] = $trigger;
$headers['x-appwrite-event'] = $event ?? '';
$headers['x-appwrite-user-id'] = $user->getId() ?? '';
@ -390,9 +405,7 @@ class Functions extends Action
'search' => implode(' ', [$functionId, $executionId]),
]);
if ($function->getAttribute('logging')) {
$execution = $dbForProject->createDocument('executions', $execution);
}
$execution = $dbForProject->createDocument('executions', $execution);
// TODO: @Meldiron Trigger executions.create event here
@ -404,9 +417,7 @@ class Functions extends Action
if ($execution->getAttribute('status') !== 'processing') {
$execution->setAttribute('status', 'processing');
if ($function->getAttribute('logging')) {
$execution = $dbForProject->updateDocument('executions', $executionId, $execution);
}
$execution = $dbForProject->updateDocument('executions', $executionId, $execution);
}
$durationStart = \microtime(true);
@ -440,14 +451,20 @@ 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_ID' => $functionId,
'APPWRITE_FUNCTION_NAME' => $function->getAttribute('name'),
'APPWRITE_FUNCTION_DEPLOYMENT' => $deploymentId,
'APPWRITE_FUNCTION_PROJECT_ID' => $project->getId(),
'APPWRITE_FUNCTION_RUNTIME_NAME' => $runtime['name'] ?? '',
'APPWRITE_FUNCTION_RUNTIME_VERSION' => $runtime['version'] ?? '',
'APPWRITE_VERSION' => APP_VERSION_STABLE
]);
/** Execute function */
@ -469,7 +486,8 @@ class Functions extends Action
path: $path,
method: $method,
headers: $headers,
runtimeEntrypoint: $command
runtimeEntrypoint: $command,
logging: $function->getAttribute('logging', true),
);
$status = $executionResponse['statusCode'] >= 400 ? 'failed' : 'completed';
@ -511,9 +529,9 @@ class Functions extends Action
;
}
if ($function->getAttribute('logging')) {
$execution = $dbForProject->updateDocument('executions', $executionId, $execution);
}
$execution = $dbForProject->updateDocument('executions', $executionId, $execution);
/** Trigger Webhook */
$executionModel = new Execution();
$queueForEvents

View file

@ -184,8 +184,12 @@ class Usage extends Action
$deployments = $dbForProject->getDocument('stats', md5(self::INFINITY_PERIOD . str_replace(['{resourceType}', '{resourceInternalId}'], ['functions', $document->getInternalId()], METRIC_FUNCTION_ID_DEPLOYMENTS)));
$deploymentsStorage = $dbForProject->getDocument('stats', md5(self::INFINITY_PERIOD . str_replace(['{resourceType}', '{resourceInternalId}'], ['functions', $document->getInternalId()], METRIC_FUNCTION_ID_DEPLOYMENTS_STORAGE)));
$builds = $dbForProject->getDocument('stats', md5(self::INFINITY_PERIOD . str_replace('{functionInternalId}', $document->getInternalId(), METRIC_FUNCTION_ID_BUILDS)));
$buildsSuccess = $dbForProject->getDocument('stats', md5(self::INFINITY_PERIOD . str_replace('{functionInternalId}', $document->getInternalId(), METRIC_FUNCTION_ID_BUILDS_SUCCESS)));
$buildsFailed = $dbForProject->getDocument('stats', md5(self::INFINITY_PERIOD . str_replace('{functionInternalId}', $document->getInternalId(), METRIC_FUNCTION_ID_BUILDS_FAILED)));
$buildsStorage = $dbForProject->getDocument('stats', md5(self::INFINITY_PERIOD . str_replace('{functionInternalId}', $document->getInternalId(), METRIC_FUNCTION_ID_BUILDS_STORAGE)));
$buildsCompute = $dbForProject->getDocument('stats', md5(self::INFINITY_PERIOD . str_replace('{functionInternalId}', $document->getInternalId(), METRIC_FUNCTION_ID_BUILDS_COMPUTE)));
$buildsComputeSuccess = $dbForProject->getDocument('stats', md5(self::INFINITY_PERIOD . str_replace('{functionInternalId}', $document->getInternalId(), METRIC_FUNCTION_ID_BUILDS_COMPUTE_SUCCESS)));
$buildsComputeFailed = $dbForProject->getDocument('stats', md5(self::INFINITY_PERIOD . str_replace('{functionInternalId}', $document->getInternalId(), METRIC_FUNCTION_ID_BUILDS_COMPUTE_FAILED)));
$executions = $dbForProject->getDocument('stats', md5(self::INFINITY_PERIOD . str_replace('{functionInternalId}', $document->getInternalId(), METRIC_FUNCTION_ID_EXECUTIONS)));
$executionsCompute = $dbForProject->getDocument('stats', md5(self::INFINITY_PERIOD . str_replace('{functionInternalId}', $document->getInternalId(), METRIC_FUNCTION_ID_EXECUTIONS_COMPUTE)));
@ -210,6 +214,20 @@ class Usage extends Action
];
}
if (!empty($buildsSuccess['value'])) {
$metrics[] = [
'key' => METRIC_BUILDS_SUCCESS,
'value' => ($buildsSuccess['value'] * -1),
];
}
if (!empty($buildsFailed['value'])) {
$metrics[] = [
'key' => METRIC_BUILDS_FAILED,
'value' => ($buildsFailed['value'] * -1),
];
}
if (!empty($buildsStorage['value'])) {
$metrics[] = [
'key' => METRIC_BUILDS_STORAGE,
@ -224,6 +242,20 @@ class Usage extends Action
];
}
if (!empty($buildsComputeSuccess['value'])) {
$metrics[] = [
'key' => METRIC_BUILDS_COMPUTE_SUCCESS,
'value' => ($buildsComputeSuccess['value'] * -1),
];
}
if (!empty($buildsComputeFailed['value'])) {
$metrics[] = [
'key' => METRIC_BUILDS_COMPUTE_FAILED,
'value' => ($buildsComputeFailed['value'] * -1),
];
}
if (!empty($executions['value'])) {
$metrics[] = [
'key' => METRIC_EXECUTIONS,

View file

@ -8,7 +8,10 @@ class Executions extends Base
'trigger',
'status',
'responseStatusCode',
'duration'
'duration',
'requestMethod',
'requestPath',
'deploymentId'
];
/**

View file

@ -0,0 +1,147 @@
<?php
namespace Appwrite\Utopia\Fetch;
class BodyMultipart
{
/**
* @var array<string, mixed> $parts
*/
private array $parts = [];
private string $boundary = "";
public function __construct(string $boundary = null)
{
if (is_null($boundary)) {
$this->boundary = self::generateBoundary();
} else {
$this->boundary = $boundary;
}
}
public static function generateBoundary(): string
{
return '-----------------------------' . \uniqid();
}
public function load(string $body): self
{
$eol = "\r\n";
$sections = \explode('--' . $this->boundary, $body);
foreach ($sections as $section) {
if (empty($section)) {
continue;
}
if (strpos($section, $eol) === 0) {
$section = substr($section, \strlen($eol));
}
if (substr($section, -2) === $eol) {
$section = substr($section, 0, -1 * \strlen($eol));
}
if ($section == '--') {
continue;
}
$partChunks = \explode($eol . $eol, $section, 2);
if (\count($partChunks) < 2) {
continue; // Broken part
}
[ $partHeaders, $partBody ] = $partChunks;
$partHeaders = \explode($eol, $partHeaders);
$partName = "";
foreach ($partHeaders as $partHeader) {
if (!empty($partName)) {
break;
}
$partHeaderArray = \explode(':', $partHeader, 2);
$partHeaderName = \strtolower($partHeaderArray[0] ?? '');
$partHeaderValue = $partHeaderArray[1] ?? '';
if ($partHeaderName == "content-disposition") {
$dispositionChunks = \explode("; ", $partHeaderValue);
foreach ($dispositionChunks as $dispositionChunk) {
$dispositionChunkValues = \explode("=", $dispositionChunk, 2);
if (\count($dispositionChunkValues) >= 2) {
if ($dispositionChunkValues[0] === "name") {
$partName = \trim($dispositionChunkValues[1], "\"");
break;
}
}
}
}
}
if (!empty($partName)) {
$this->parts[$partName] = $partBody;
}
}
return $this;
}
/**
* @return array<string, mixed>
*/
public function getParts(): array
{
return $this->parts ?? [];
}
public function getPart(string $key, mixed $default = ''): mixed
{
return $this->parts[$key] ?? $default;
}
public function setPart(string $key, mixed $value): self
{
$this->parts[$key] = $value;
return $this;
}
public function getBoundary(): string
{
return $this->boundary;
}
public function setBoundary(string $boundary): self
{
$this->boundary = $boundary;
return $this;
}
public function exportHeader(): string
{
return 'multipart/form-data; boundary=' . $this->boundary;
}
public function exportBody(): string
{
$eol = "\r\n";
$query = '--' . $this->boundary;
foreach ($this->parts as $key => $value) {
$query .= $eol . 'Content-Disposition: form-data; name="' . $key . '"';
if (\is_array($value)) {
$query .= $eol . 'Content-Type: application/json';
$value = \json_encode($value);
}
$query .= $eol . $eol;
$query .= $value . $eol;
$query .= '--' . $this->boundary;
}
$query .= "--" . $eol;
return $query;
}
}

View file

@ -72,6 +72,7 @@ use Appwrite\Utopia\Response\Model\Migration;
use Appwrite\Utopia\Response\Model\MigrationFirebaseProject;
use Appwrite\Utopia\Response\Model\MigrationReport;
use Appwrite\Utopia\Response\Model\Mock;
use Appwrite\Utopia\Response\Model\MockNumber;
use Appwrite\Utopia\Response\Model\None;
use Appwrite\Utopia\Response\Model\Phone;
use Appwrite\Utopia\Response\Model\Platform;
@ -102,6 +103,7 @@ use Appwrite\Utopia\Response\Model\User;
use Appwrite\Utopia\Response\Model\Variable;
use Appwrite\Utopia\Response\Model\WebauthnLoginChallenge;
use Appwrite\Utopia\Response\Model\WebauthnRegisterChallenge;
use Appwrite\Utopia\Response\Model\VcsContent;
use Appwrite\Utopia\Response\Model\Webhook;
use Exception;
use Swoole\Http\Response as SwooleHTTPResponse;
@ -237,6 +239,8 @@ class Response extends SwooleResponse
public const MODEL_BRANCH = 'branch';
public const MODEL_BRANCH_LIST = 'branchList';
public const MODEL_DETECTION = 'detection';
public const MODEL_VCS_CONTENT = 'vcsContent';
public const MODEL_VCS_CONTENT_LIST = 'vcsContentList';
// Functions
public const MODEL_FUNCTION = 'function';
@ -270,6 +274,7 @@ class Response extends SwooleResponse
public const MODEL_WEBHOOK_LIST = 'webhookList';
public const MODEL_KEY = 'key';
public const MODEL_KEY_LIST = 'keyList';
public const MODEL_MOCK_NUMBER = 'mockNumber';
public const MODEL_AUTH_PROVIDER = 'authProvider';
public const MODEL_AUTH_PROVIDER_LIST = 'authProviderList';
public const MODEL_PLATFORM = 'platform';
@ -368,6 +373,7 @@ class Response extends SwooleResponse
->setModel(new BaseList('Target list', self::MODEL_TARGET_LIST, 'targets', self::MODEL_TARGET))
->setModel(new BaseList('Migrations List', self::MODEL_MIGRATION_LIST, 'migrations', self::MODEL_MIGRATION))
->setModel(new BaseList('Migrations Firebase Projects List', self::MODEL_MIGRATION_FIREBASE_PROJECT_LIST, 'projects', self::MODEL_MIGRATION_FIREBASE_PROJECT))
->setModel(new BaseList('VCS Content List', self::MODEL_VCS_CONTENT_LIST, 'contents', self::MODEL_VCS_CONTENT))
// Entities
->setModel(new Database())
->setModel(new Collection())
@ -410,6 +416,7 @@ class Response extends SwooleResponse
->setModel(new Installation())
->setModel(new ProviderRepository())
->setModel(new Detection())
->setModel(new VcsContent())
->setModel(new Branch())
->setModel(new Runtime())
->setModel(new Deployment())
@ -418,6 +425,7 @@ class Response extends SwooleResponse
->setModel(new Project())
->setModel(new Webhook())
->setModel(new Key())
->setModel(new MockNumber())
->setModel(new AuthProvider())
->setModel(new Platform())
->setModel(new Variable())

View file

@ -71,6 +71,13 @@ class Func extends Model
'default' => '',
'example' => '5e5ea5c16897e',
])
->addRule('scopes', [
'type' => self::TYPE_STRING,
'description' => 'Allowed permission scopes.',
'default' => [],
'example' => 'users.read',
'array' => true,
])
->addRule('vars', [
'type' => Response::MODEL_VARIABLE,
'description' => 'Function variables.',
@ -112,7 +119,7 @@ class Func extends Model
->addRule('version', [
'type' => self::TYPE_STRING,
'description' => 'Version of Open Runtimes used for the function.',
'default' => 'v3',
'default' => 'v4',
'example' => 'v2',
])
->addRule('installationId', [

View file

@ -0,0 +1,47 @@
<?php
namespace Appwrite\Utopia\Response\Model;
use Appwrite\Utopia\Response;
use Appwrite\Utopia\Response\Model;
class MockNumber extends Model
{
public function __construct()
{
$this
->addRule('phone', [
'type' => self::TYPE_STRING,
'description' => 'Mock phone number for testing phone authentication. Useful for testing phone authentication without sending an SMS.',
'default' => '',
'example' => '+1612842323',
])
->addRule('otp', [
'type' => self::TYPE_STRING,
'description' => 'Mock OTP for the number. ',
'default' => '',
'example' => '123456',
])
;
}
/**
* Get Name
*
* @return string
*/
public function getName(): string
{
return 'Mock Number';
}
/**
* Get Type
*
* @return string
*/
public function getType(): string
{
return Response::MODEL_MOCK_NUMBER;
}
}

View file

@ -138,6 +138,19 @@ class Project extends Model
'default' => false,
'example' => true,
])
->addRule('authMockNumbers', [
'type' => Response::MODEL_MOCK_NUMBER,
'description' => 'An array of mock numbers and their corresponding verification codes (OTPs).',
'default' => [],
'array' => true,
'example' => [new \stdClass()],
])
->addRule('authSessionAlerts', [
'type' => self::TYPE_BOOLEAN,
'description' => 'Whether or not to send session alert emails to users.',
'default' => false,
'example' => true,
])
->addRule('oAuthProviders', [
'type' => Response::MODEL_AUTH_PROVIDER,
'description' => 'List of Auth Providers.',
@ -321,6 +334,8 @@ class Project extends Model
$document->setAttribute('authPasswordHistory', $authValues['passwordHistory'] ?? 0);
$document->setAttribute('authPasswordDictionary', $authValues['passwordDictionary'] ?? false);
$document->setAttribute('authPersonalDataCheck', $authValues['personalDataCheck'] ?? false);
$document->setAttribute('authMockNumbers', $authValues['mockNumbers'] ?? []);
$document->setAttribute('authSessionAlerts', $authValues['sessionAlerts'] ?? false);
foreach ($auth as $index => $method) {
$key = $method['key'];

View file

@ -0,0 +1,55 @@
<?php
namespace Appwrite\Utopia\Response\Model;
use Appwrite\Utopia\Response;
use Appwrite\Utopia\Response\Model;
class VcsContent extends Model
{
public function __construct()
{
$this
->addRule('size', [
'type' => self::TYPE_INTEGER,
'description' => 'Content size in bytes. Only files have size, and for directories, 0 is returned.',
'default' => 0,
'required' => false,
'example' => 1523,
])
->addRule('isDirectory', [
'type' => self::TYPE_BOOLEAN,
'description' => 'If a content is a directory. Directories can be used to check nested contents.',
'default' => false,
'required' => false,
'example' => true
])
->addRule('name', [
'type' => self::TYPE_STRING,
'description' => 'Name of directory or file.',
'default' => "",
'example' => 'Main.java',
'array' => false,
]);
}
/**
* Get Name
*
* @return string
*/
public function getName(): string
{
return 'VcsContents';
}
/**
* Get Type
*
* @return string
*/
public function getType(): string
{
return Response::MODEL_VCS_CONTENT;
}
}

View file

@ -3,6 +3,7 @@
namespace Executor;
use Appwrite\Extend\Exception as AppwriteException;
use Appwrite\Utopia\Fetch\BodyMultipart;
use Exception;
use Utopia\System\System;
@ -178,6 +179,7 @@ class Executor
string $method,
array $headers,
string $runtimeEntrypoint = null,
bool $logging,
int $requestTimeout = null
) {
if (empty($headers['host'])) {
@ -189,7 +191,6 @@ class Executor
$params = [
'runtimeId' => $runtimeId,
'variables' => $variables,
'body' => $body,
'timeout' => $timeout,
'path' => $path,
'method' => $method,
@ -201,15 +202,20 @@ class Executor
'memory' => $this->memory,
'version' => $version,
'runtimeEntrypoint' => $runtimeEntrypoint,
'logging' => $logging,
];
if(!empty($body)) {
$params['body'] = $body;
}
// Safety timeout. Executor has timeout, and open runtime has soft timeout.
// This one shouldn't really happen, but prevents from unexpected networking behaviours.
if ($requestTimeout == null) {
$requestTimeout = $timeout + 15;
}
$response = $this->call(self::METHOD_POST, $route, [ 'x-opr-runtime-id' => $runtimeId ], $params, true, $requestTimeout);
$response = $this->call(self::METHOD_POST, $route, [ 'x-opr-runtime-id' => $runtimeId, 'content-type' => 'multipart/form-data', 'accept' => 'multipart/form-data' ], $params, true, $requestTimeout);
$status = $response['headers']['status-code'];
if ($status >= 400) {
@ -217,6 +223,11 @@ class Executor
throw new \Exception($message, $status);
}
$response['body']['headers'] = \json_decode($response['body']['headers'] ?? '{}', true);
$response['body']['statusCode'] = \intval($response['body']['statusCode'] ?? 500);
$response['body']['duration'] = \floatval($response['body']['duration'] ?? 0);
$response['body']['startTime'] = \floatval($response['body']['startTime'] ?? \microtime(true));
return $response['body'];
}
@ -248,7 +259,13 @@ class Executor
break;
case 'multipart/form-data':
$query = $this->flatten($params);
$multipart = new BodyMultipart();
foreach ($params as $key => $value) {
$multipart->setPart($key, $value);
}
$headers['content-type'] = $multipart->exportHeader();
$query = $multipart->exportBody();
break;
default:
@ -315,7 +332,16 @@ class Executor
$curlErrorMessage = curl_error($ch);
if ($decode) {
switch (substr($responseType, 0, strpos($responseType, ';'))) {
$strpos = strpos($responseType, ';');
$strpos = \is_bool($strpos) ? \strlen($responseType) : $strpos;
switch (substr($responseType, 0, $strpos)) {
case 'multipart/form-data':
$boundary = \explode('boundary=', $responseHeaders['content-type'] ?? '')[1] ?? '';
$multipartResponse = new BodyMultipart($boundary);
$multipartResponse->load(\is_bool($responseBody) ? '' : $responseBody);
$responseBody = $multipartResponse->getParts();
break;
case 'application/json':
$json = json_decode($responseBody, true);

View file

@ -163,7 +163,7 @@ class Client
* @return array
* @throws Exception
*/
public function call(string $method, string $path = '', array $headers = [], array $params = [], bool $decode = true): array
public function call(string $method, string $path = '', array $headers = [], mixed $params = [], bool $decode = true): array
{
$headers = array_merge($this->headers, $headers);
$ch = curl_init($this->endpoint . $path . (($method == self::METHOD_GET && !empty($params)) ? '?' . http_build_query($params) : ''));
@ -174,6 +174,7 @@ class Client
'application/json' => json_encode($params),
'multipart/form-data' => $this->flatten($params),
'application/graphql' => $params[0],
'text/plain' => $params,
default => http_build_query($params),
};

View file

@ -1190,6 +1190,64 @@ class AccountCustomClientTest extends Scope
return $data;
}
/**
* @depends testCreateAccountSession
*/
public function testSessionAlert($data): void
{
$email = uniqid() . 'session-alert@appwrite.io';
$password = 'password123';
$name = 'Session Alert Tester';
// Enable session alerts
$response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $this->getProject()['$id'] . '/auth/session-alerts', array_merge([
'origin' => 'http://localhost',
'content-type' => 'application/json',
'x-appwrite-project' => 'console',
'cookie' => 'a_session_console=' . $this->getRoot()['session'],
]), [
'alerts' => true,
]);
$this->assertEquals(200, $response['headers']['status-code']);
// Create a new account
$response = $this->client->call(Client::METHOD_POST, '/account', array_merge([
'origin' => 'http://localhost',
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
]), [
'userId' => ID::unique(),
'email' => $email,
'password' => $password,
'name' => $name,
]);
$this->assertEquals(201, $response['headers']['status-code']);
// Create a session for the new account
$response = $this->client->call(Client::METHOD_POST, '/account/sessions/email', array_merge([
'origin' => 'http://localhost',
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'user-agent' => 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36',
]), [
'email' => $email,
'password' => $password,
]);
$this->assertEquals(201, $response['headers']['status-code']);
// Check the alert email
$lastEmail = $this->getLastEmail();
$this->assertEquals($email, $lastEmail['to'][0]['address']);
$this->assertStringContainsString('New session alert', $lastEmail['subject']);
$this->assertStringContainsString($response['body']['ip'], $lastEmail['text']); // IP Address
$this->assertStringContainsString('Unknown', $lastEmail['text']); // Country
$this->assertStringContainsString($response['body']['clientName'], $lastEmail['text']); // Client name
}
/**
* @depends testCreateAccountSession
*/

View file

@ -182,6 +182,7 @@ class FunctionsCustomClientTest extends Scope
$this->assertEquals($executions['body']['executions'][1]['responseStatusCode'], 200);
$this->assertEquals($executions['body']['executions'][1]['responseBody'], '');
$this->assertEquals($executions['body']['executions'][1]['logs'], '');
$this->assertGreaterThan(0, $executions['body']['executions'][1]['duration']);
// Cleanup : Delete function
$response = $this->client->call(Client::METHOD_DELETE, '/functions/' . $function['body']['$id'], [
@ -195,6 +196,138 @@ class FunctionsCustomClientTest extends Scope
return [];
}
public function testCreateScheduledExecution(): void
{
/**
* Test for SUCCESS
*/
$function = $this->client->call(Client::METHOD_POST, '/functions', [
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey'],
], [
'functionId' => ID::unique(),
'name' => 'Test',
'execute' => [Role::user($this->getUser()['$id'])->toString()],
'runtime' => 'php-8.0',
'entrypoint' => 'index.php',
'events' => [
'users.*.create',
'users.*.delete',
],
'timeout' => 10,
]);
$this->assertEquals(201, $function['headers']['status-code']);
$folder = 'php';
$code = realpath(__DIR__ . '/../../../resources/functions') . "/$folder/code.tar.gz";
$this->packageCode($folder);
$deployment = $this->client->call(Client::METHOD_POST, '/functions/' . $function['body']['$id'] . '/deployments', [
'content-type' => 'multipart/form-data',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey'],
], [
'entrypoint' => 'index.php',
'code' => new CURLFile($code, 'application/x-gzip', \basename($code)),
'activate' => true
]);
$deploymentId = $deployment['body']['$id'] ?? '';
$this->assertEquals(202, $deployment['headers']['status-code']);
// Poll until deployment is built
while (true) {
$deployment = $this->client->call(Client::METHOD_GET, '/functions/' . $function['body']['$id'] . '/deployments/' . $deploymentId, [
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey'],
]);
if (
$deployment['headers']['status-code'] >= 400
|| \in_array($deployment['body']['status'], ['ready', 'failed'])
) {
break;
}
\sleep(1);
}
$this->assertEquals('ready', $deployment['body']['status']);
$function = $this->client->call(Client::METHOD_PATCH, '/functions/' . $function['body']['$id'] . '/deployments/' . $deploymentId, [
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey'],
], []);
$this->assertEquals(200, $function['headers']['status-code']);
// Schedule execution for the future
\date_default_timezone_set('UTC');
$futureTime = (new \DateTime())->add(new \DateInterval('PT10S'))->format('Y-m-d H:i:s');
$execution = $this->client->call(Client::METHOD_POST, '/functions/' . $function['body']['$id'] . '/executions', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), [
'async' => true,
'scheduledAt' => $futureTime,
'path' => '/custom',
'method' => 'GET',
'body' => 'hello',
'headers' => [
'content-type' => 'application/plain',
],
]);
$this->assertEquals(202, $execution['headers']['status-code']);
$this->assertEquals('scheduled', $execution['body']['status']);
$executionId = $execution['body']['$id'];
sleep(20);
$execution = $this->client->call(Client::METHOD_GET, '/functions/' . $function['body']['$id'] . '/executions/' . $executionId, [
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey'],
]);
$this->assertEquals(200, $execution['headers']['status-code']);
$this->assertEquals(200, $execution['body']['responseStatusCode']);
$this->assertEquals('completed', $execution['body']['status']);
$this->assertEquals('/custom', $execution['body']['requestPath']);
$this->assertEquals('GET', $execution['body']['requestMethod']);
$this->assertGreaterThan(0, $execution['body']['duration']);
/* Test for FAILURE */
// Schedule synchronous execution
$execution = $this->client->call(Client::METHOD_POST, '/functions/' . $function['body']['$id'] . '/executions', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), [
'async' => false,
'scheduledAt' => $futureTime,
]);
$this->assertEquals(400, $execution['headers']['status-code']);
// Cleanup : Delete function
$response = $this->client->call(Client::METHOD_DELETE, '/functions/' . $function['body']['$id'], [
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey'],
], []);
$this->assertEquals(204, $response['headers']['status-code']);
}
public function testCreateCustomExecution(): array
{
/**
@ -307,6 +440,7 @@ class FunctionsCustomClientTest extends Scope
$output = json_decode($execution['body']['responseBody'], true);
$this->assertEquals(201, $execution['headers']['status-code']);
$this->assertEquals(200, $execution['body']['responseStatusCode']);
$this->assertGreaterThan(0, $execution['body']['duration']);
$this->assertEquals('completed', $execution['body']['status']);
$this->assertEquals($functionId, $output['APPWRITE_FUNCTION_ID']);
$this->assertEquals('Test', $output['APPWRITE_FUNCTION_NAME']);
@ -314,6 +448,7 @@ class FunctionsCustomClientTest extends Scope
$this->assertEquals('http', $output['APPWRITE_FUNCTION_TRIGGER']);
$this->assertEquals('PHP', $output['APPWRITE_FUNCTION_RUNTIME_NAME']);
$this->assertEquals('8.0', $output['APPWRITE_FUNCTION_RUNTIME_VERSION']);
$this->assertEquals(APP_VERSION_STABLE, $output['APPWRITE_VERSION']);
$this->assertEquals('', $output['APPWRITE_FUNCTION_EVENT']);
$this->assertEquals('foobar', $output['APPWRITE_FUNCTION_DATA']);
$this->assertEquals($this->getUser()['$id'], $output['APPWRITE_FUNCTION_USER_ID']);
@ -698,6 +833,7 @@ class FunctionsCustomClientTest extends Scope
$this->assertEquals('http', $output['APPWRITE_FUNCTION_TRIGGER']);
$this->assertEquals('PHP', $output['APPWRITE_FUNCTION_RUNTIME_NAME']);
$this->assertEquals('8.0', $output['APPWRITE_FUNCTION_RUNTIME_VERSION']);
$this->assertEquals(APP_VERSION_STABLE, $output['APPWRITE_VERSION']);
$this->assertEquals('', $output['APPWRITE_FUNCTION_EVENT']);
$this->assertEquals('foobar', $output['APPWRITE_FUNCTION_DATA']);
$this->assertEquals($this->getUser()['$id'], $output['APPWRITE_FUNCTION_USER_ID']);

View file

@ -10,6 +10,7 @@ use Tests\E2E\Scopes\Scope;
use Tests\E2E\Scopes\SideServer;
use Utopia\Database\Document;
use Utopia\Database\Helpers\ID;
use Utopia\Database\Helpers\Role;
use Utopia\Database\Query;
use Utopia\Database\Validator\Datetime as DatetimeValidator;
@ -383,6 +384,78 @@ class FunctionsCustomServerTest extends Scope
return $data;
}
public function testCreateDeploymentFromCLI()
{
$function = $this->client->call(Client::METHOD_POST, '/functions', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), [
'functionId' => ID::unique(),
'name' => 'Test',
'execute' => [Role::user($this->getUser()['$id'])->toString()],
'runtime' => 'php-8.0',
'entrypoint' => 'index.php',
'events' => [
'users.*.create',
'users.*.delete',
],
'schedule' => '0 0 1 1 *',
'timeout' => 10,
]);
$this->assertEquals(201, $function['headers']['status-code']);
$functionId = $function['body']['$id'];
$folder = 'php';
$code = realpath(__DIR__ . '/../../../resources/functions') . "/$folder/code.tar.gz";
$this->packageCode($folder);
$deployment = $this->client->call(Client::METHOD_POST, '/functions/' . $function['body']['$id'] . '/deployments', [
'content-type' => 'multipart/form-data',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey'],
'x-sdk-language' => 'cli',
], [
'entrypoint' => 'index.php',
'code' => new CURLFile($code, 'application/x-gzip', \basename($code)),
'activate' => true
]);
$deploymentId = $deployment['body']['$id'] ?? '';
$this->assertEquals(202, $deployment['headers']['status-code']);
// Poll until deployment is built
while (true) {
$deployment = $this->client->call(Client::METHOD_GET, '/functions/' . $function['body']['$id'] . '/deployments/' . $deploymentId, [
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey'],
]);
if (
$deployment['headers']['status-code'] >= 400
|| \in_array($deployment['body']['status'], ['ready', 'failed'])
) {
break;
}
\sleep(1);
}
$this->assertEquals('ready', $deployment['body']['status'], \json_encode($deployment['body']));
$functionDetails = $this->client->call(Client::METHOD_GET, '/functions/' . $functionId . '/deployments/' . $deploymentId, [
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey'],
], []);
$this->assertEquals(200, $functionDetails['headers']['status-code']);
$this->assertEquals('cli', $functionDetails['body']['type']);
}
/**
* @depends testUpdate
*/
@ -434,6 +507,73 @@ class FunctionsCustomServerTest extends Scope
return array_merge($data, ['deploymentId' => $deploymentId]);
}
/**
* @depends testUpdate
*/
public function testCancelDeploymentBuild($data): void
{
// Create a new deployment to cancel
$folder = 'php';
$code = realpath(__DIR__ . '/../../../resources/functions') . "/$folder/code.tar.gz";
$this->packageCode($folder);
$deployment = $this->client->call(Client::METHOD_POST, '/functions/' . $data['functionId'] . '/deployments', array_merge([
'content-type' => 'multipart/form-data',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), [
'code' => new CURLFile($code, 'application/x-gzip', \basename($code)),
'activate' => true
]);
$deploymentId = $deployment['body']['$id'] ?? '';
$this->assertEquals(202, $deployment['headers']['status-code']);
$this->assertNotEmpty($deployment['body']['$id']);
$this->assertEquals(true, (new DatetimeValidator())->isValid($deployment['body']['$createdAt']));
$this->assertEquals('index.php', $deployment['body']['entrypoint']);
// Poll until deployment is in progress
while (true) {
$deployment = $this->client->call(Client::METHOD_GET, '/functions/' . $data['functionId'] . '/deployments/' . $deploymentId, [
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey'],
]);
if (
$deployment['headers']['status-code'] >= 400
|| $deployment['body']['status'] === 'building'
) {
break;
}
\sleep(1);
}
$this->assertEquals(200, $deployment['headers']['status-code']);
$this->assertEquals('building', $deployment['body']['status']);
// Cancel the deployment build
$cancel = $this->client->call(Client::METHOD_PATCH, '/functions/' . $data['functionId'] . '/deployments/' . $deploymentId . '/build', [
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey'],
]);
$this->assertEquals(200, $cancel['headers']['status-code']);
$this->assertEquals('canceled', $cancel['body']['status']);
// Confirm the deployment is canceled
$deployment = $this->client->call(Client::METHOD_GET, '/functions/' . $data['functionId'] . '/deployments/' . $deploymentId, [
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey'],
]);
$this->assertEquals(200, $deployment['headers']['status-code']);
$this->assertEquals('canceled', $deployment['body']['status']);
}
/**
* @depends testUpdate
*/
@ -523,24 +663,28 @@ class FunctionsCustomServerTest extends Scope
], $this->getHeaders()));
$this->assertEquals($function['headers']['status-code'], 200);
$this->assertEquals($function['body']['total'], 2);
$this->assertEquals($function['body']['total'], 3);
$this->assertIsArray($function['body']['deployments']);
$this->assertCount(2, $function['body']['deployments']);
$this->assertCount(3, $function['body']['deployments']);
/**
* Test search queries
*/
$function = $this->client->call(Client::METHOD_GET, '/functions/' . $data['functionId'] . '/deployments', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders(), [
'search' => $data['functionId']
]));
$function = $this->client->call(
Client::METHOD_GET,
'/functions/' . $data['functionId'] . '/deployments',
array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders(), [
'search' => $data['functionId']
])
);
$this->assertEquals($function['headers']['status-code'], 200);
$this->assertEquals(2, $function['body']['total']);
$this->assertEquals(3, $function['body']['total']);
$this->assertIsArray($function['body']['deployments']);
$this->assertCount(2, $function['body']['deployments']);
$this->assertCount(3, $function['body']['deployments']);
$this->assertEquals($function['body']['deployments'][0]['$id'], $data['deploymentId']);
$function = $this->client->call(Client::METHOD_GET, '/functions/' . $data['functionId'] . '/deployments', array_merge([
@ -565,7 +709,7 @@ class FunctionsCustomServerTest extends Scope
]);
$this->assertEquals($function['headers']['status-code'], 200);
$this->assertCount(1, $function['body']['deployments']);
$this->assertCount(2, $function['body']['deployments']);
$function = $this->client->call(Client::METHOD_GET, '/functions/' . $data['functionId'] . '/deployments', array_merge([
'content-type' => 'application/json',
@ -577,7 +721,7 @@ class FunctionsCustomServerTest extends Scope
]);
$this->assertEquals($function['headers']['status-code'], 200);
$this->assertCount(2, $function['body']['deployments']);
$this->assertCount(3, $function['body']['deployments']);
$function = $this->client->call(Client::METHOD_GET, '/functions/' . $data['functionId'] . '/deployments', array_merge([
'content-type' => 'application/json',
@ -591,30 +735,38 @@ class FunctionsCustomServerTest extends Scope
$this->assertEquals($function['headers']['status-code'], 200);
$this->assertCount(0, $function['body']['deployments']);
$function = $this->client->call(Client::METHOD_GET, '/functions/' . $data['functionId'] . '/deployments', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders(), [
'search' => 'Test'
]));
$function = $this->client->call(
Client::METHOD_GET,
'/functions/' . $data['functionId'] . '/deployments',
array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders(), [
'search' => 'Test'
])
);
$this->assertEquals($function['headers']['status-code'], 200);
$this->assertEquals(2, $function['body']['total']);
$this->assertEquals(3, $function['body']['total']);
$this->assertIsArray($function['body']['deployments']);
$this->assertCount(2, $function['body']['deployments']);
$this->assertCount(3, $function['body']['deployments']);
$this->assertEquals($function['body']['deployments'][0]['$id'], $data['deploymentId']);
$function = $this->client->call(Client::METHOD_GET, '/functions/' . $data['functionId'] . '/deployments', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders(), [
'search' => 'php-8.0'
]));
$function = $this->client->call(
Client::METHOD_GET,
'/functions/' . $data['functionId'] . '/deployments',
array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders(), [
'search' => 'php-8.0'
])
);
$this->assertEquals($function['headers']['status-code'], 200);
$this->assertEquals(2, $function['body']['total']);
$this->assertEquals(3, $function['body']['total']);
$this->assertIsArray($function['body']['deployments']);
$this->assertCount(2, $function['body']['deployments']);
$this->assertCount(3, $function['body']['deployments']);
$this->assertEquals($function['body']['deployments'][0]['$id'], $data['deploymentId']);
return $data;
@ -836,6 +988,85 @@ class FunctionsCustomServerTest extends Scope
return $data;
}
/**
* @depends testGetExecution
*/
public function testDeleteExecution($data): array
{
/**
* Test for SUCCESS
*/
$execution = $this->client->call(Client::METHOD_DELETE, '/functions/' . $data['functionId'] . '/executions/' . $data['executionId'], array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()));
$this->assertEquals(204, $execution['headers']['status-code']);
$this->assertEmpty($execution['body']);
$execution = $this->client->call(Client::METHOD_DELETE, '/functions/' . $data['functionId'] . '/executions/' . $data['executionId'], array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()));
$this->assertEquals(404, $execution['headers']['status-code']);
$this->assertStringContainsString('Execution with the requested ID could not be found', $execution['body']['message']);
/**
* Test for FAILURE
*/
$execution = $this->client->call(Client::METHOD_POST, '/functions/' . $data['functionId'] . '/executions', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), [
'async' => true,
]);
$executionId = $execution['body']['$id'] ?? '';
$this->assertEquals(202, $execution['headers']['status-code']);
$execution = $this->client->call(Client::METHOD_DELETE, '/functions/' . $data['functionId'] . '/executions/' . $executionId, array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()));
$this->assertEquals(400, $execution['headers']['status-code']);
$this->assertStringContainsString('execution_in_progress', $execution['body']['type']);
$this->assertStringContainsString('Can\'t delete ongoing execution.', $execution['body']['message']);
return $data;
}
/**
* @depends testGetExecution
*/
public function testDeleteScheduledExecution($data): array
{
$futureTime = (new \DateTime())->add(new \DateInterval('PT10H'))->format('Y-m-d H:i:s');
$execution = $this->client->call(Client::METHOD_POST, '/functions/' . $data['functionId'] . '/executions', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), [
'async' => true,
'scheduledAt' => $futureTime,
]);
$executionId = $execution['body']['$id'] ?? '';
$this->assertEquals(202, $execution['headers']['status-code']);
sleep(5);
$execution = $this->client->call(Client::METHOD_DELETE, '/functions/' . $data['functionId'] . '/executions/' . $executionId, array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()));
$this->assertEquals(204, $execution['headers']['status-code']);
$this->assertEmpty($execution['body']);
return $data;
}
/**
* @depends testGetExecution
*/
@ -1017,10 +1248,10 @@ class FunctionsCustomServerTest extends Scope
public function provideCustomExecutions(): array
{
return [
[ 'folder' => 'php-fn', 'name' => 'php-8.0', 'entrypoint' => 'index.php', 'runtimeName' => 'PHP', 'runtimeVersion' => '8.0' ],
[ 'folder' => 'node', 'name' => 'node-18.0', 'entrypoint' => 'index.js', 'runtimeName' => 'Node.js', 'runtimeVersion' => '18.0' ],
[ 'folder' => 'python', 'name' => 'python-3.9', 'entrypoint' => 'main.py', 'runtimeName' => 'Python', 'runtimeVersion' => '3.9' ],
[ 'folder' => 'ruby', 'name' => 'ruby-3.1', 'entrypoint' => 'main.rb', 'runtimeName' => 'Ruby', 'runtimeVersion' => '3.1' ],
['folder' => 'php-fn', 'name' => 'php-8.0', 'entrypoint' => 'index.php', 'runtimeName' => 'PHP', 'runtimeVersion' => '8.0'],
['folder' => 'node', 'name' => 'node-18.0', 'entrypoint' => 'index.js', 'runtimeName' => 'Node.js', 'runtimeVersion' => '18.0'],
['folder' => 'python', 'name' => 'python-3.9', 'entrypoint' => 'main.py', 'runtimeName' => 'Python', 'runtimeVersion' => '3.9'],
['folder' => 'ruby', 'name' => 'ruby-3.1', 'entrypoint' => 'main.rb', 'runtimeName' => 'Ruby', 'runtimeVersion' => '3.1'],
// Swift and Dart disabled as it's very slow.
// [ 'folder' => 'dart', 'name' => 'dart-2.15', 'entrypoint' => 'main.dart', 'runtimeName' => 'Dart', 'runtimeVersion' => '2.15' ],
// [ 'folder' => 'swift', 'name' => 'swift-5.5', 'entrypoint' => 'index.swift', 'runtimeName' => 'Swift', 'runtimeVersion' => '5.5' ],
@ -1037,7 +1268,7 @@ class FunctionsCustomServerTest extends Scope
*/
public function testCreateCustomExecution(string $folder, string $name, string $entrypoint, string $runtimeName, string $runtimeVersion)
{
$timeout = 5;
$timeout = 15;
$code = realpath(__DIR__ . '/../../../resources/functions') . "/$folder/code.tar.gz";
$this->packageCode($folder);
@ -1158,7 +1389,7 @@ class FunctionsCustomServerTest extends Scope
public function testv2Function()
{
$timeout = 5;
$timeout = 15;
$code = realpath(__DIR__ . '/../../../resources/functions') . "/php-v2/code.tar.gz";
$this->packageCode('php-v2');
@ -1278,7 +1509,7 @@ class FunctionsCustomServerTest extends Scope
public function testEventTrigger()
{
$timeout = 5;
$timeout = 15;
$code = realpath(__DIR__ . '/../../../resources/functions') . "/php-event/code.tar.gz";
$this->packageCode('php-event');
@ -1388,9 +1619,95 @@ class FunctionsCustomServerTest extends Scope
$this->assertEquals(204, $response['headers']['status-code']);
}
public function testScopes()
{
$timeout = 15;
$code = realpath(__DIR__ . '/../../../resources/functions') . "/php-scopes/code.tar.gz";
$this->packageCode('php-scopes');
$function = $this->client->call(Client::METHOD_POST, '/functions', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), [
'functionId' => ID::unique(),
'name' => 'Test PHP Scopes executions',
'commands' => 'composer update --no-interaction --ignore-platform-reqs --optimize-autoloader --prefer-dist --no-dev',
'runtime' => 'php-8.0',
'entrypoint' => 'index.php',
'scopes' => ['users.read'],
'timeout' => $timeout,
]);
$functionId = $function['body']['$id'] ?? '';
$this->assertEquals(201, $function['headers']['status-code']);
$deployment = $this->client->call(Client::METHOD_POST, '/functions/' . $functionId . '/deployments', array_merge([
'content-type' => 'multipart/form-data',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), [
'entrypoint' => 'index.php',
'code' => new CURLFile($code, 'application/x-gzip', basename($code)),
'activate' => true
]);
$deploymentId = $deployment['body']['$id'] ?? '';
$this->assertEquals(202, $deployment['headers']['status-code']);
// Poll until deployment is built
while (true) {
$deployment = $this->client->call(Client::METHOD_GET, '/functions/' . $function['body']['$id'] . '/deployments/' . $deploymentId, [
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey'],
]);
if (
$deployment['headers']['status-code'] >= 400
|| \in_array($deployment['body']['status'], ['ready', 'failed'])
) {
break;
}
\sleep(1);
}
$deployment = $this->client->call(Client::METHOD_PATCH, '/functions/' . $functionId . '/deployments/' . $deploymentId, array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), []);
$this->assertEquals(200, $deployment['headers']['status-code']);
// Wait a little for activation to finish
sleep(5);
$execution = $this->client->call(Client::METHOD_POST, '/functions/' . $functionId . '/executions', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), [
'async' => false
]);
$this->assertEquals(201, $execution['headers']['status-code']);
$this->assertEquals('completed', $execution['body']['status']);
$this->assertEquals(200, $execution['body']['responseStatusCode']);
$this->assertNotEmpty($execution['body']['responseBody']);
$this->assertGreaterThan(0, $execution['body']['duration']);
// Cleanup : Delete function
$response = $this->client->call(Client::METHOD_DELETE, '/functions/' . $functionId, [
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey'],
], []);
$this->assertEquals(204, $response['headers']['status-code']);
}
public function testCookieExecution()
{
$timeout = 5;
$timeout = 15;
$code = realpath(__DIR__ . '/../../../resources/functions') . "/php-cookie/code.tar.gz";
$this->packageCode('php-cookie');
@ -1465,6 +1782,7 @@ class FunctionsCustomServerTest extends Scope
$this->assertEquals('completed', $execution['body']['status']);
$this->assertEquals(200, $execution['body']['responseStatusCode']);
$this->assertEquals($cookie, $execution['body']['responseBody']);
$this->assertGreaterThan(0, $execution['body']['duration']);
// Cleanup : Delete function
$response = $this->client->call(Client::METHOD_DELETE, '/functions/' . $functionId, [
@ -1478,7 +1796,7 @@ class FunctionsCustomServerTest extends Scope
public function testFunctionsDomain()
{
$timeout = 5;
$timeout = 15;
$code = realpath(__DIR__ . '/../../../resources/functions') . "/php-cookie/code.tar.gz";
$this->packageCode('php-cookie');
@ -1578,4 +1896,205 @@ class FunctionsCustomServerTest extends Scope
$this->assertEquals(204, $response['headers']['status-code']);
}
public function testFunctionsDomainBianryResponse()
{
$timeout = 15;
$code = realpath(__DIR__ . '/../../../resources/functions') . "/php-binary-response/code.tar.gz";
$this->packageCode('php-binary-response');
$function = $this->client->call(Client::METHOD_POST, '/functions', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), [
'functionId' => ID::unique(),
'name' => 'Test PHP Binary executions',
'runtime' => 'php-8.0',
'entrypoint' => 'index.php',
'timeout' => $timeout,
'execute' => ['any']
]);
$functionId = $function['body']['$id'] ?? '';
$this->assertEquals(201, $function['headers']['status-code']);
$rules = $this->client->call(Client::METHOD_GET, '/proxy/rules', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), [
'queries' => [
Query::equal('resourceId', [$functionId])->toString(),
Query::equal('resourceType', ['function'])->toString(),
],
]);
$this->assertEquals(200, $rules['headers']['status-code']);
$this->assertEquals(1, $rules['body']['total']);
$this->assertCount(1, $rules['body']['rules']);
$this->assertNotEmpty($rules['body']['rules'][0]['domain']);
$domain = $rules['body']['rules'][0]['domain'];
$deployment = $this->client->call(Client::METHOD_POST, '/functions/' . $functionId . '/deployments', array_merge([
'content-type' => 'multipart/form-data',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), [
'entrypoint' => 'index.php',
'code' => new CURLFile($code, 'application/x-gzip', basename($code)),
'activate' => true
]);
$deploymentId = $deployment['body']['$id'] ?? '';
$this->assertEquals(202, $deployment['headers']['status-code']);
// Poll until deployment is built
while (true) {
$deployment = $this->client->call(Client::METHOD_GET, '/functions/' . $function['body']['$id'] . '/deployments/' . $deploymentId, [
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey'],
]);
if (
$deployment['headers']['status-code'] >= 400
|| \in_array($deployment['body']['status'], ['ready', 'failed'])
) {
break;
}
\sleep(1);
}
$deployment = $this->client->call(Client::METHOD_PATCH, '/functions/' . $functionId . '/deployments/' . $deploymentId, array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), []);
$this->assertEquals(200, $deployment['headers']['status-code']);
// Wait a little for activation to finish
sleep(5);
$proxyClient = new Client();
$proxyClient->setEndpoint('http://' . $domain);
$response = $proxyClient->call(Client::METHOD_GET, '/', [], [], false);
$this->assertEquals(200, $response['headers']['status-code']);
$this->assertNotEmpty($response['body']);
$bytes = unpack('C*byte', $response['body']);
$this->assertCount(3, $bytes);
$this->assertEquals(0, $bytes['byte1']);
$this->assertEquals(10, $bytes['byte2']);
$this->assertEquals(255, $bytes['byte3']);
// Cleanup : Delete function
$response = $this->client->call(Client::METHOD_DELETE, '/functions/' . $functionId, [
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey'],
], []);
$this->assertEquals(204, $response['headers']['status-code']);
}
public function testFunctionsDomainBianryRequest()
{
$timeout = 15;
$code = realpath(__DIR__ . '/../../../resources/functions') . "/php-binary-request/code.tar.gz";
$this->packageCode('php-binary-request');
$function = $this->client->call(Client::METHOD_POST, '/functions', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), [
'functionId' => ID::unique(),
'name' => 'Test PHP Binary executions',
'runtime' => 'php-8.0',
'entrypoint' => 'index.php',
'timeout' => $timeout,
'execute' => ['any']
]);
$functionId = $function['body']['$id'] ?? '';
$this->assertEquals(201, $function['headers']['status-code']);
$rules = $this->client->call(Client::METHOD_GET, '/proxy/rules', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), [
'queries' => [
Query::equal('resourceId', [$functionId])->toString(),
Query::equal('resourceType', ['function'])->toString(),
],
]);
$this->assertEquals(200, $rules['headers']['status-code']);
$this->assertEquals(1, $rules['body']['total']);
$this->assertCount(1, $rules['body']['rules']);
$this->assertNotEmpty($rules['body']['rules'][0]['domain']);
$domain = $rules['body']['rules'][0]['domain'];
$deployment = $this->client->call(Client::METHOD_POST, '/functions/' . $functionId . '/deployments', array_merge([
'content-type' => 'multipart/form-data',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), [
'entrypoint' => 'index.php',
'code' => new CURLFile($code, 'application/x-gzip', basename($code)),
'activate' => true
]);
$deploymentId = $deployment['body']['$id'] ?? '';
$this->assertEquals(202, $deployment['headers']['status-code']);
// Poll until deployment is built
while (true) {
$deployment = $this->client->call(Client::METHOD_GET, '/functions/' . $function['body']['$id'] . '/deployments/' . $deploymentId, [
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey'],
]);
if (
$deployment['headers']['status-code'] >= 400
|| \in_array($deployment['body']['status'], ['ready', 'failed'])
) {
break;
}
\sleep(1);
}
$deployment = $this->client->call(Client::METHOD_PATCH, '/functions/' . $functionId . '/deployments/' . $deploymentId, array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), []);
$this->assertEquals(200, $deployment['headers']['status-code']);
// Wait a little for activation to finish
sleep(5);
$proxyClient = new Client();
$proxyClient->setEndpoint('http://' . $domain);
$bytes = pack('C*', ...[0,20,255]);
$response = $proxyClient->call(Client::METHOD_POST, '/', [ 'content-type' => 'text/plain' ], $bytes, false);
$this->assertEquals(200, $response['headers']['status-code']);
$this->assertEquals(\md5($bytes), $response['body']);
// Cleanup : Delete function
$response = $this->client->call(Client::METHOD_DELETE, '/functions/' . $functionId, [
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey'],
], []);
$this->assertEquals(204, $response['headers']['status-code']);
}
}

View file

@ -610,7 +610,7 @@ class ProjectsConsoleClientTest extends Scope
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), [
'emails' => [ 'testuser@appwrite.io', 'testusertwo@appwrite.io' ],
'emails' => ['testuser@appwrite.io', 'testusertwo@appwrite.io'],
'senderEmail' => 'custommailer@appwrite.io',
'senderName' => 'Custom Mailer',
'replyTo' => 'reply@appwrite.io',
@ -647,7 +647,7 @@ class ProjectsConsoleClientTest extends Scope
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), [
'emails' => [ 'u1@appwrite.io', 'u2@appwrite.io', 'u3@appwrite.io', 'u4@appwrite.io', 'u5@appwrite.io', 'u6@appwrite.io', 'u7@appwrite.io', 'u8@appwrite.io', 'u9@appwrite.io', 'u10@appwrite.io' ],
'emails' => ['u1@appwrite.io', 'u2@appwrite.io', 'u3@appwrite.io', 'u4@appwrite.io', 'u5@appwrite.io', 'u6@appwrite.io', 'u7@appwrite.io', 'u8@appwrite.io', 'u9@appwrite.io', 'u10@appwrite.io'],
'senderEmail' => 'custommailer@appwrite.io',
'senderName' => 'Custom Mailer',
'replyTo' => 'reply@appwrite.io',
@ -663,7 +663,7 @@ class ProjectsConsoleClientTest extends Scope
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), [
'emails' => [ 'u1@appwrite.io', 'u2@appwrite.io', 'u3@appwrite.io', 'u4@appwrite.io', 'u5@appwrite.io', 'u6@appwrite.io', 'u7@appwrite.io', 'u8@appwrite.io', 'u9@appwrite.io', 'u10@appwrite.io', 'u11@appwrite.io' ],
'emails' => ['u1@appwrite.io', 'u2@appwrite.io', 'u3@appwrite.io', 'u4@appwrite.io', 'u5@appwrite.io', 'u6@appwrite.io', 'u7@appwrite.io', 'u8@appwrite.io', 'u9@appwrite.io', 'u10@appwrite.io', 'u11@appwrite.io'],
'senderEmail' => 'custommailer@appwrite.io',
'senderName' => 'Custom Mailer',
'replyTo' => 'reply@appwrite.io',
@ -1529,8 +1529,8 @@ class ProjectsConsoleClientTest extends Scope
/**
* Reset
*/
* Reset
*/
$response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/password-history', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
@ -1543,6 +1543,178 @@ class ProjectsConsoleClientTest extends Scope
return $data;
}
/**
* @group smtpAndTemplates
* @group projectsCRUD
*
* @depends testCreateProject
* */
public function testUpdateMockNumbers($data)
{
$id = $data['projectId'] ?? '';
/**
* Test for Failure
*/
/** Trying to pass an empty body to the endpoint */
$response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/mock-numbers', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), []);
$this->assertEquals(400, $response['headers']['status-code']);
$this->assertEquals('Param "numbers" is not optional.', $response['body']['message']);
/** Trying to pass body with incorrect structure */
$response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/mock-numbers', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), [
'numbers' => [
'phone' => '+1655513432',
'otp' => '123456'
]
]);
$this->assertEquals(400, $response['headers']['status-code']);
$this->assertEquals('Invalid `numbers` param: Value must a valid array no longer than 10 items and Invalid payload structure. Please check the "phone" and "otp" fields', $response['body']['message']);
/** Trying to pass an OTP longer than 6 characters*/
$response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/mock-numbers', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), [
'numbers' => [
[
'phone' => '+1655513432',
'otp' => '12345678'
]
]
]);
$this->assertEquals(400, $response['headers']['status-code']);
$this->assertEquals('Invalid `numbers` param: Value must a valid array no longer than 10 items and OTP must be a valid string and exactly 6 characters.', $response['body']['message']);
/** Trying to pass an OTP shorter than 6 characters*/
$response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/mock-numbers', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), [
'numbers' => [
[
'phone' => '+1655513432',
'otp' => '123'
]
]
]);
$this->assertEquals(400, $response['headers']['status-code']);
$this->assertEquals('Invalid `numbers` param: Value must a valid array no longer than 10 items and OTP must be a valid string and exactly 6 characters.', $response['body']['message']);
/** Trying to pass an invalid phone number */
$response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/mock-numbers', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), [
'numbers' => [
[
'phone' => '1655234',
'otp' => '123456'
]
]
]);
$this->assertEquals(400, $response['headers']['status-code']);
$this->assertEquals('Invalid `numbers` param: Value must a valid array no longer than 10 items and Phone number must start with a \'+\' can have a maximum of fifteen digits.', $response['body']['message']);
/** Trying to pass a number longer than 15 digits */
$response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/mock-numbers', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), [
'numbers' => [
[
'phone' => '+1234567890987654',
'otp' => '123456'
]
]
]);
$this->assertEquals(400, $response['headers']['status-code']);
$this->assertEquals('Invalid `numbers` param: Value must a valid array no longer than 10 items and Phone number must start with a \'+\' can have a maximum of fifteen digits.', $response['body']['message']);
$numbers = [];
for ($i = 0; $i < 11; $i++) {
$numbers[] = [
'phone' => '+1655513432',
'otp' => '123456'
];
}
/** Trying to pass more than 10 values */
$response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/mock-numbers', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), [
'numbers' => $numbers
]);
$this->assertEquals(400, $response['headers']['status-code']);
$this->assertEquals('Invalid `numbers` param: Value must a valid array no longer than 10 items and Phone number must start with a \'+\' can have a maximum of fifteen digits.', $response['body']['message']);
/**
* Test for success
*/
$response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/mock-numbers', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), [
'numbers' => []
]);
$this->assertEquals(200, $response['headers']['status-code']);
$response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/mock-numbers', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), [
'numbers' => [
[
'phone' => '+1655513432',
'otp' => '123456'
]
]
]);
$this->assertEquals(200, $response['headers']['status-code']);
// Create phone session for this project and check if the mock number is used
$response = $this->client->call(Client::METHOD_POST, '/account/sessions/phone', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $id,
]), [
'userId' => 'unique()',
'phone' => '+1655513432',
]);
$this->assertEquals(201, $response['headers']['status-code']);
$userId = $response['body']['userId'];
$response = $this->client->call(Client::METHOD_PUT, '/account/sessions/phone', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $id,
]), [
'userId' => $userId,
'secret' => '654321', // Try a random code
]);
$this->assertEquals(401, $response['headers']['status-code']);
$response = $this->client->call(Client::METHOD_PUT, '/account/sessions/phone', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $id,
]), [
'userId' => $userId,
'secret' => '123456',
]);
$this->assertEquals(201, $response['headers']['status-code']);
}
/**
* @depends testUpdateProjectAuthLimit
*/
@ -1646,8 +1818,8 @@ class ProjectsConsoleClientTest extends Scope
/**
* Reset
*/
* Reset
*/
$response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/password-history', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
@ -2736,6 +2908,99 @@ class ProjectsConsoleClientTest extends Scope
return $data;
}
/**
* @depends testCreateProject
*/
public function testCreateProjectKeyOutdated($data): void
{
$id = $data['projectId'] ?? '';
$response = $this->client->call(Client::METHOD_POST, '/mock/api-key-unprefixed', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), [
'projectId' => $id
]);
$this->assertEquals(201, $response['headers']['status-code']);
$this->assertContains('users.read', $response['body']['scopes']);
$this->assertNotEmpty($response['body']['secret']);
$this->assertStringStartsNotWith(API_KEY_STANDARD . '_', $response['body']['secret']);
$keyId = $response['body']['$id'];
$secret = $response['body']['secret'];
$response = $this->client->call(Client::METHOD_GET, '/users', [
'content-type' => 'application/json',
'x-appwrite-project' => $id,
'x-appwrite-key' => $secret
], []);
$this->assertEquals(200, $response['headers']['status-code']);
$response = $this->client->call(Client::METHOD_DELETE, '/projects/' . $id . '/keys/' . $keyId, array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), []);
$this->assertEquals(204, $response['headers']['status-code']);
$this->assertEmpty($response['body']);
}
// JWT Keys
/**
* @depends testCreateProject
*/
public function testJWTKey($data): void
{
$id = $data['projectId'] ?? '';
// Create JWT key
$response = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/jwts', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), [
'duration' => 5,
'scopes' => ['users.read'],
]);
$this->assertEquals(201, $response['headers']['status-code']);
$this->assertNotEmpty($response['body']['jwt']);
$jwt = $response['body']['jwt'];
// Ensure JWT key works
$response = $this->client->call(Client::METHOD_GET, '/users', [
'content-type' => 'application/json',
'x-appwrite-project' => $id,
'x-appwrite-key' => $jwt,
]);
$this->assertEquals(200, $response['headers']['status-code']);
$this->assertArrayHasKey('users', $response['body']);
// Ensure JWT key respect scopes
$response = $this->client->call(Client::METHOD_GET, '/functions', [
'content-type' => 'application/json',
'x-appwrite-project' => $id,
'x-appwrite-key' => $jwt,
]);
$this->assertEquals(401, $response['headers']['status-code']);
// Ensure JWT key expires
\sleep(10);
$response = $this->client->call(Client::METHOD_GET, '/users', [
'content-type' => 'application/json',
'x-appwrite-project' => $id,
'x-appwrite-key' => $jwt,
]);
$this->assertEquals(401, $response['headers']['status-code']);
}
// Platforms
/**

View file

@ -1575,6 +1575,137 @@ trait UsersBase
return $data;
}
public function testUserJWT()
{
// Create user
$userId = ID::unique();
$user = $this->client->call(Client::METHOD_POST, '/users', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), [
'userId' => $userId,
'email' => 'jwtuser@appwrite.io',
'password' => 'password',
], false);
$this->assertEquals($user['headers']['status-code'], 201);
// Create two sessions
$response = $this->client->call(Client::METHOD_POST, '/account/sessions/email', array_merge([
'origin' => 'http://localhost',
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
]), [
'email' => 'jwtuser@appwrite.io',
'password' => 'password',
]);
$this->assertEquals(201, $response['headers']['status-code']);
$this->assertEquals($userId, $response['body']['userId']);
$this->assertNotEmpty($response['body']['$id']);
$session1Id = $response['body']['$id'];
$response = $this->client->call(Client::METHOD_POST, '/account/sessions/email', array_merge([
'origin' => 'http://localhost',
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
]), [
'email' => 'jwtuser@appwrite.io',
'password' => 'password',
]);
$this->assertEquals(201, $response['headers']['status-code']);
$this->assertEquals($userId, $response['body']['userId']);
$this->assertNotEmpty($response['body']['$id']);
$session2Id = $response['body']['$id'];
// Create JWT 1 for older session by ID
$response = $this->client->call(Client::METHOD_POST, '/users/' . $userId . '/jwts', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), [
'sessionId' => $session1Id
]);
$this->assertEquals(201, $response['headers']['status-code']);
$this->assertNotEmpty($response['body']['jwt']);
$jwt1 = $response['body']['jwt'];
// Ensure JWT 1 works
$response = $this->client->call(Client::METHOD_GET, '/account', array_merge([
'origin' => 'http://localhost',
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-jwt' => $jwt1,
]));
$this->assertEquals(200, $response['headers']['status-code']);
$this->assertEquals($userId, $response['body']['$id']);
// Create JWT 2 for latest session using default param
$response = $this->client->call(Client::METHOD_POST, '/users/' . $userId . '/jwts', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), [
'duration' => 5
]);
$this->assertEquals(201, $response['headers']['status-code']);
$this->assertNotEmpty($response['body']['jwt']);
$jwt2 = $response['body']['jwt'];
// Ensure JWT 2 works
$response = $this->client->call(Client::METHOD_GET, '/account', array_merge([
'origin' => 'http://localhost',
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-jwt' => $jwt2,
]));
$this->assertEquals(200, $response['headers']['status-code']);
$this->assertEquals($userId, $response['body']['$id']);
// Wait, ensure JWT 2 no longer works because of short duration
\sleep(10);
$response = $this->client->call(Client::METHOD_GET, '/account', array_merge([
'origin' => 'http://localhost',
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-jwt' => $jwt2,
]));
$this->assertEquals(401, $response['headers']['status-code']);
// Delete session, ensure JWT 1 no longer works because of session missing
$response = $this->client->call(Client::METHOD_DELETE, '/users/' . $userId . '/sessions', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), [
'sessionId' => $session1Id
]);
$this->assertEquals(204, $response['headers']['status-code']);
$response = $this->client->call(Client::METHOD_GET, '/account', array_merge([
'origin' => 'http://localhost',
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-jwt' => $jwt1,
]));
$this->assertEquals(401, $response['headers']['status-code']);
// Cleanup after test
$response = $this->client->call(Client::METHOD_DELETE, '/users/' . $userId, array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()));
$this->assertEquals($response['headers']['status-code'], 204);
}
// TODO add test for session delete
// TODO add test for all sessions delete
}

View file

@ -19,9 +19,9 @@ class VCSConsoleClientTest extends Scope
use ProjectCustom;
use SideConsole;
public string $providerInstallationId = '42954928';
public string $providerRepositoryId = '705764267';
public string $providerRepositoryId2 = '708688544';
public string $providerInstallationId = '42954928'; // appwrite-test
public string $providerRepositoryId = '705764267'; // ruby-starter (public)
public string $providerRepositoryId2 = '708688544'; // function1.4 (private)
public function testGitHubAuthorize(): string
{
@ -85,6 +85,78 @@ class VCSConsoleClientTest extends Scope
$this->assertEquals(404, $runtime['headers']['status-code']);
}
/**
* @depends testGitHubAuthorize
*/
public function testContents(string $installationId): void
{
/**
* Test for SUCCESS
*/
$runtime = $this->client->call(Client::METHOD_POST, '/vcs/github/installations/' . $installationId . '/providerRepositories/' . $this->providerRepositoryId . '/contents', array_merge([
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()));
$this->assertEquals(200, $runtime['headers']['status-code']);
$this->assertGreaterThan(0, $runtime['body']['total']);
$this->assertIsArray($runtime['body']['contents']);
$this->assertGreaterThan(0, \count($runtime['body']['contents']));
$gemfileContent = null;
foreach ($runtime['body']['contents'] as $content) {
if ($content['name'] === "Gemfile") {
$gemfileContent = $content;
break;
}
}
$this->assertNotNull($gemfileContent);
$this->assertFalse($gemfileContent['isDirectory']);
$this->assertGreaterThan(0, $gemfileContent['size']); // Should be ~50 bytes
$this->assertLessThan(100, $gemfileContent['size']);
$libContent = null;
foreach ($runtime['body']['contents'] as $content) {
if ($content['name'] === "lib") {
$libContent = $content;
break;
}
}
$this->assertNotNull($libContent);
$this->assertTrue($libContent['isDirectory']);
$this->assertEquals(0, $gemfileContent['size']);
$runtime = $this->client->call(Client::METHOD_POST, '/vcs/github/installations/' . $installationId . '/providerRepositories/' . $this->providerRepositoryId . '/contents?providerRootDirectory=lib', array_merge([
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()));
$this->assertEquals(200, $runtime['headers']['status-code']);
$this->assertGreaterThan(0, $runtime['body']['total']);
$this->assertIsArray($runtime['body']['contents']);
$this->assertGreaterThan(0, \count($runtime['body']['contents']));
$mainRbContent = null;
foreach ($runtime['body']['contents'] as $content) {
if ($content['name'] === "main.rb") {
$mainRbContent = $content;
break;
}
}
$this->assertNotNull($mainRbContent);
$this->assertFalse($mainRbContent['isDirectory']);
$this->assertGreaterThan(0, $gemfileContent['size']);
/**
* Test for FAILURE
*/
$runtime = $this->client->call(Client::METHOD_POST, '/vcs/github/installations/' . $installationId . '/providerRepositories/randomRepositoryId/contents', array_merge([
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()));
$this->assertEquals(404, $runtime['headers']['status-code']);
}
/**
* @depends testGitHubAuthorize
*/

View file

@ -25,7 +25,7 @@ services:
networks:
- gateway
- appwrite
appwrite:
container_name: appwrite
build:
@ -289,7 +289,6 @@ services:
- _APP_DB_SCHEMA
- _APP_DB_USER
- _APP_DB_PASS
- _APP_LOGGING_PROVIDER
- _APP_LOGGING_CONFIG
- _APP_EXECUTOR_SECRET
- _APP_EXECUTOR_HOST
@ -393,4 +392,4 @@ volumes:
appwrite-uploads:
appwrite-certificates:
appwrite-functions:
appwrite-config:
appwrite-config:

View file

@ -0,0 +1,6 @@
<?php
return function ($context) {
$hash = md5($context->req->bodyBinary);
return $context->res->send($hash);
};

View file

@ -0,0 +1,6 @@
<?php
return function ($context) {
$bytes = pack('C*', ...[0, 10, 255]);
return $context->res->binary($bytes);
};

View file

@ -10,6 +10,7 @@ return function ($context) {
'APPWRITE_FUNCTION_TRIGGER' => $context->req->headers['x-appwrite-trigger'] ?? '',
'APPWRITE_FUNCTION_RUNTIME_NAME' => \getenv('APPWRITE_FUNCTION_RUNTIME_NAME') ?: '',
'APPWRITE_FUNCTION_RUNTIME_VERSION' => \getenv('APPWRITE_FUNCTION_RUNTIME_VERSION') ?: '',
'APPWRITE_VERSION' => \getenv('APPWRITE_VERSION') ?: '',
'APPWRITE_FUNCTION_EVENT' => $context->req->headers['x-appwrite-event'] ?? '',
'APPWRITE_FUNCTION_EVENT_DATA' => $context->req->bodyRaw ?? '',
'APPWRITE_FUNCTION_DATA' => $context->req->bodyRaw ?? '',

View file

@ -0,0 +1,18 @@
{
"name": "appwrite/php-scopes",
"description": "PHP scopes test script",
"type": "library",
"license": "BSD-3-Clause",
"authors": [
{
"name": "Team Appwrite",
"email": "team@appwrite.io"
}
],
"require": {
"php": ">=7.4.0",
"ext-curl": "*",
"ext-json": "*",
"appwrite/appwrite": "11.0.*"
}
}

View file

@ -0,0 +1,16 @@
<?php
require 'vendor/autoload.php';
use Appwrite\Client;
use Appwrite\Services\Users;
return function ($context) {
$client = new Client();
$client
->setEndpoint(getenv('APPWRITE_FUNCTION_API_ENDPOINT'))
->setProject(getenv('APPWRITE_FUNCTION_PROJECT_ID'))
->setKey($context->req->headers['x-appwrite-key']);
$users = new Users($client);
return $context->res->json($users->list());
};