mirror of
https://github.com/appwrite/appwrite.git
synced 2026-05-26 13:51:13 +00:00
Merge branch '1.9.x' into feat-rust-sdk
This commit is contained in:
@@ -16,7 +16,7 @@ jobs:
|
||||
- name: Build the Docker image
|
||||
run: DOCKER_BUILDKIT=1 docker build . --target production -t appwrite_image:latest
|
||||
- name: Run Trivy vulnerability scanner on image
|
||||
uses: aquasecurity/trivy-action@0.20.0
|
||||
uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 # v0.35.0
|
||||
with:
|
||||
image-ref: 'appwrite_image:latest'
|
||||
format: 'sarif'
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v6
|
||||
- name: Run Trivy vulnerability scanner on filesystem
|
||||
uses: aquasecurity/trivy-action@0.20.0
|
||||
uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 # v0.35.0
|
||||
with:
|
||||
scan-type: 'fs'
|
||||
format: 'sarif'
|
||||
|
||||
@@ -209,6 +209,22 @@ function sendSessionAlert(Locale $locale, Document $user, Document $project, arr
|
||||
|
||||
$createSession = function (string $userId, string $secret, Request $request, Response $response, User $user, Database $dbForProject, Document $project, array $platform, Locale $locale, Reader $geodb, Event $queueForEvents, Mail $queueForMails, Store $store, ProofsToken $proofForToken, ProofsCode $proofForCode, Authorization $authorization) {
|
||||
|
||||
// Attempt to decode secret as a JWT (used by OAuth2 token flow to carry provider info)
|
||||
$oauthProvider = null;
|
||||
try {
|
||||
$jwtDecoder = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', 60, 0);
|
||||
$payload = $jwtDecoder->decode($secret);
|
||||
|
||||
if (empty($payload['provider'])) {
|
||||
throw new Exception(Exception::USER_INVALID_TOKEN);
|
||||
}
|
||||
|
||||
$oauthProvider = $payload['provider'];
|
||||
$secret = $payload['secret'];
|
||||
} catch (\Ahc\Jwt\JWTException) {
|
||||
// Not a JWT — use secret as-is (non-OAuth flows)
|
||||
}
|
||||
|
||||
/** @var Appwrite\Utopia\Database\Documents\User $userFromRequest */
|
||||
$userFromRequest = $authorization->skip(fn () => $dbForProject->getDocument('users', $userId));
|
||||
|
||||
@@ -220,6 +236,12 @@ $createSession = function (string $userId, string $secret, Request $request, Res
|
||||
?: $userFromRequest->tokenVerify(null, $secret, $proofForCode);
|
||||
|
||||
if (!$verifiedToken) {
|
||||
// Could mean invalid/expired JWT, or expired secret
|
||||
throw new Exception(Exception::USER_INVALID_TOKEN);
|
||||
}
|
||||
|
||||
// OAuth2 tokens must have a provider from the JWT
|
||||
if ($verifiedToken->getAttribute('type') === TOKEN_TYPE_OAUTH2 && $oauthProvider === null) {
|
||||
throw new Exception(Exception::USER_INVALID_TOKEN);
|
||||
}
|
||||
|
||||
@@ -245,7 +267,7 @@ $createSession = function (string $userId, string $secret, Request $request, Res
|
||||
TOKEN_TYPE_INVITE => SESSION_PROVIDER_EMAIL,
|
||||
TOKEN_TYPE_MAGIC_URL => SESSION_PROVIDER_MAGIC_URL,
|
||||
TOKEN_TYPE_PHONE => SESSION_PROVIDER_PHONE,
|
||||
TOKEN_TYPE_OAUTH2 => SESSION_PROVIDER_OAUTH2,
|
||||
TOKEN_TYPE_OAUTH2 => $oauthProvider,
|
||||
default => SESSION_PROVIDER_TOKEN,
|
||||
};
|
||||
$session = new Document(array_merge(
|
||||
@@ -1899,7 +1921,12 @@ Http::get('/v1/account/sessions/oauth2/:provider/redirect')
|
||||
->setParam('tokenId', $token->getId())
|
||||
;
|
||||
|
||||
$query['secret'] = $secret;
|
||||
// Wrap secret in a JWT that also carries the provider name
|
||||
$jwtEncoder = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', 60, 0);
|
||||
$query['secret'] = $jwtEncoder->encode([
|
||||
'secret' => $secret,
|
||||
'provider' => $provider,
|
||||
]);
|
||||
$query['userId'] = $user->getId();
|
||||
|
||||
// If the `token` param is not set, we persist the session in a cookie
|
||||
|
||||
+2
-2
@@ -13,9 +13,9 @@
|
||||
"test": "vendor/bin/phpunit",
|
||||
"lint": "vendor/bin/pint --test --config pint.json",
|
||||
"format": "vendor/bin/pint --config pint.json",
|
||||
"analyze": "./vendor/bin/phpstan analyse -c phpstan.neon --memory-limit=1G",
|
||||
"analyze": "./vendor/bin/phpstan analyse -c phpstan.neon --memory-limit=1G",
|
||||
"bench": "vendor/bin/phpbench run --report=benchmark",
|
||||
"check": "./vendor/bin/phpstan analyse -c phpstan.neon",
|
||||
"check": "./vendor/bin/phpstan analyse -c phpstan.neon --memory-limit=1G",
|
||||
"installer:clean": "php src/Appwrite/Platform/Installer/Server.php --clean",
|
||||
"installer:dev": "docker compose build && composer installer:clean && php src/Appwrite/Platform/Installer/Server.php --docker"
|
||||
},
|
||||
|
||||
@@ -19,7 +19,16 @@ class Env
|
||||
foreach ($data as &$row) {
|
||||
$row = explode('=', $row, 2);
|
||||
$key = (isset($row[0])) ? trim($row[0]) : null;
|
||||
$value = (isset($row[1])) ? trim($row[1]) : null;
|
||||
$value = (isset($row[1])) ? (function (string $v): string {
|
||||
$v = trim($v);
|
||||
if (
|
||||
(\str_starts_with($v, '"') && \str_ends_with($v, '"')) ||
|
||||
(\str_starts_with($v, "'") && \str_ends_with($v, "'"))
|
||||
) {
|
||||
return \substr($v, 1, -1);
|
||||
}
|
||||
return $v;
|
||||
})(trim($row[1])) : null;
|
||||
|
||||
if ($key) {
|
||||
$this->vars[$key] = $value;
|
||||
|
||||
@@ -33,6 +33,7 @@ class Install extends Action
|
||||
private const string APPWRITE_API_URL = 'http://appwrite';
|
||||
private const string GROWTH_API_URL = 'https://growth.appwrite.io/v1';
|
||||
|
||||
protected bool $isUpgrade = false;
|
||||
protected string $hostPath = '';
|
||||
protected ?bool $isLocalInstall = null;
|
||||
protected ?array $installerConfig = null;
|
||||
@@ -66,7 +67,7 @@ class Install extends Action
|
||||
bool $noStart,
|
||||
string $database
|
||||
): void {
|
||||
$isUpgrade = false;
|
||||
$isUpgrade = $this->isUpgrade;
|
||||
$defaultHttpPort = '80';
|
||||
$defaultHttpsPort = '443';
|
||||
$config = Config::getParam('variables');
|
||||
@@ -508,7 +509,7 @@ class Install extends Action
|
||||
$this->applyLocalPaths($isLocalInstall, false);
|
||||
|
||||
$isCLI = php_sapi_name() === 'cli';
|
||||
if ($isLocalInstall) {
|
||||
if ($isLocalInstall || $isUpgrade) {
|
||||
$useExistingConfig = false;
|
||||
} else {
|
||||
$useExistingConfig = file_exists($this->path . '/' . $this->getComposeFileName())
|
||||
|
||||
@@ -42,6 +42,7 @@ class Upgrade extends Install
|
||||
bool $noStart,
|
||||
string $database
|
||||
): void {
|
||||
$this->isUpgrade = true;
|
||||
$isLocalInstall = $this->isLocalInstall();
|
||||
$this->applyLocalPaths($isLocalInstall, true);
|
||||
|
||||
@@ -72,7 +73,7 @@ class Upgrade extends Install
|
||||
}
|
||||
|
||||
if ($database === null) {
|
||||
$envData = @file_get_contents($this->path . '/.env');
|
||||
$envData = @file_get_contents($this->path . '/' . $this->getEnvFileName());
|
||||
if ($envData !== false) {
|
||||
$envFile = new Env($envData);
|
||||
$database = $envFile->list()['_APP_DB_ADAPTER'] ?? null;
|
||||
|
||||
@@ -2182,11 +2182,138 @@ class AccountCustomClientTest extends Scope
|
||||
]), [
|
||||
'success' => 'http://localhost/v1/mock/tests/general/oauth2/success',
|
||||
'failure' => 'http://localhost/v1/mock/tests/general/oauth2/failure',
|
||||
]);
|
||||
], followRedirects: false);
|
||||
|
||||
$this->assertEquals(301, $response['headers']['status-code']);
|
||||
$this->assertStringStartsWith('http://localhost/v1/mock/tests/general/oauth2', $response['headers']['location']);
|
||||
|
||||
$oauthClient = new Client();
|
||||
$oauthClient->setEndpoint('');
|
||||
$response = $oauthClient->call(Client::METHOD_GET, $response['headers']['location'], followRedirects: false);
|
||||
|
||||
$this->assertEquals(301, $response['headers']['status-code']);
|
||||
$this->assertStringStartsWith('http://appwrite:/v1/account/sessions/oauth2/callback/mock/' . $this->getProject()['$id'] . '?code=', $response['headers']['location']);
|
||||
|
||||
$response = $oauthClient->call(Client::METHOD_GET, $response['headers']['location'], followRedirects: false);
|
||||
|
||||
$this->assertEquals(301, $response['headers']['status-code']);
|
||||
$this->assertStringStartsWith('http://appwrite:/v1/account/sessions/oauth2/mock/redirect?code=', $response['headers']['location']);
|
||||
|
||||
$response = $oauthClient->call(Client::METHOD_GET, $response['headers']['location'], followRedirects: false);
|
||||
|
||||
$this->assertEquals(301, $response['headers']['status-code']);
|
||||
|
||||
$this->assertArrayHasKey('a_session_' . $this->getProject()['$id'] . '_legacy', $response['cookies']);
|
||||
$this->assertArrayHasKey('a_session_' . $this->getProject()['$id'], $response['cookies']);
|
||||
|
||||
$oauthUserCookie = $response['cookies']['a_session_' . $this->getProject()['$id']];
|
||||
$this->assertNotEmpty($oauthUserCookie);
|
||||
|
||||
$response = $oauthClient->call(Client::METHOD_GET, $response['headers']['location'], followRedirects: false);
|
||||
|
||||
$this->assertEquals(200, $response['headers']['status-code']);
|
||||
$this->assertEquals('success', $response['body']['result']);
|
||||
|
||||
// Ensure user is authenticated
|
||||
$response = $this->client->call(Client::METHOD_GET, '/account', [
|
||||
'x-appwrite-project' => $this->getProject()['$id'],
|
||||
'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $oauthUserCookie,
|
||||
]);
|
||||
$this->assertEquals(200, $response['headers']['status-code']);
|
||||
$this->assertEquals('useroauth@localhost.test', $response['body']['email']);
|
||||
|
||||
$oauthUserId = $response['body']['$id'];
|
||||
$this->assertNotEmpty($oauthUserId);
|
||||
|
||||
// Ensure session looks as expected
|
||||
$response = $this->client->call(Client::METHOD_GET, '/account/sessions/current', [
|
||||
'x-appwrite-project' => $this->getProject()['$id'],
|
||||
'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $oauthUserCookie,
|
||||
]);
|
||||
$this->assertEquals(200, $response['headers']['status-code']);
|
||||
$this->assertEquals($oauthUserId, $response['body']['userId']);
|
||||
$this->assertEquals('mock', $response['body']['provider']);
|
||||
|
||||
// Same sign-in again, but this time with oauth2 token flow
|
||||
$response = $this->client->call(Client::METHOD_GET, '/account/tokens/oauth2/' . $provider, array_merge([
|
||||
'origin' => 'http://localhost',
|
||||
'content-type' => 'application/json',
|
||||
'x-appwrite-project' => $this->getProject()['$id'],
|
||||
]), [
|
||||
'success' => 'http://localhost/v1/mock/tests/general/oauth2/success',
|
||||
'failure' => 'http://localhost/v1/mock/tests/general/oauth2/failure',
|
||||
], followRedirects: false);
|
||||
|
||||
$this->assertEquals(301, $response['headers']['status-code']);
|
||||
$this->assertStringStartsWith('http://localhost/v1/mock/tests/general/oauth2', $response['headers']['location']);
|
||||
|
||||
$oauthClient = new Client();
|
||||
$oauthClient->setEndpoint('');
|
||||
$response = $oauthClient->call(Client::METHOD_GET, $response['headers']['location'], followRedirects: false);
|
||||
|
||||
$this->assertEquals(301, $response['headers']['status-code']);
|
||||
$this->assertStringStartsWith('http://appwrite:/v1/account/sessions/oauth2/callback/mock/' . $this->getProject()['$id'] . '?code=', $response['headers']['location']);
|
||||
|
||||
$response = $oauthClient->call(Client::METHOD_GET, $response['headers']['location'], followRedirects: false);
|
||||
|
||||
$this->assertEquals(301, $response['headers']['status-code']);
|
||||
$this->assertStringStartsWith('http://appwrite:/v1/account/sessions/oauth2/mock/redirect?code=', $response['headers']['location']);
|
||||
|
||||
$response = $oauthClient->call(Client::METHOD_GET, $response['headers']['location'], followRedirects: false);
|
||||
|
||||
$this->assertEquals(301, $response['headers']['status-code']);
|
||||
$this->assertStringStartsWith('http://localhost/v1/mock/tests/general/oauth2/success?secret=', $response['headers']['location']);
|
||||
|
||||
$oauthParamsString = \parse_url($response['headers']['location'], PHP_URL_QUERY);
|
||||
$oauthParams = [];
|
||||
\parse_str($oauthParamsString, $oauthParams);
|
||||
|
||||
$this->assertNotEmpty($oauthParams['secret']);
|
||||
$this->assertNotEmpty($oauthParams['userId']);
|
||||
|
||||
$response = $oauthClient->call(Client::METHOD_GET, $response['headers']['location'], followRedirects: false);
|
||||
|
||||
$this->assertEquals(200, $response['headers']['status-code']);
|
||||
$this->assertEquals('success', $response['body']['result']);
|
||||
|
||||
// Claim session
|
||||
$response = $this->client->call(Client::METHOD_POST, '/account/sessions/token', [
|
||||
'origin' => 'http://localhost',
|
||||
'content-type' => 'application/json',
|
||||
'x-appwrite-project' => $this->getProject()['$id'],
|
||||
], [
|
||||
'userId' => $oauthParams['userId'],
|
||||
'secret' => $oauthParams['secret'],
|
||||
]);
|
||||
|
||||
$this->assertEquals(201, $response['headers']['status-code']);
|
||||
$this->assertEquals('mock', $response['body']['provider']);
|
||||
|
||||
$this->assertArrayHasKey('a_session_' . $this->getProject()['$id'] . '_legacy', $response['cookies']);
|
||||
$this->assertArrayHasKey('a_session_' . $this->getProject()['$id'], $response['cookies']);
|
||||
|
||||
$oauthUserCookie = $response['cookies']['a_session_' . $this->getProject()['$id']];
|
||||
$this->assertNotEmpty($oauthUserCookie);
|
||||
|
||||
$response = $this->client->call(Client::METHOD_GET, '/account', [
|
||||
'x-appwrite-project' => $this->getProject()['$id'],
|
||||
'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $oauthUserCookie,
|
||||
]);
|
||||
$this->assertEquals(200, $response['headers']['status-code']);
|
||||
$this->assertEquals('useroauth@localhost.test', $response['body']['email']);
|
||||
|
||||
$oauthUserId = $response['body']['$id'];
|
||||
$this->assertNotEmpty($oauthUserId);
|
||||
|
||||
// Ensure session looks as expected
|
||||
$response = $this->client->call(Client::METHOD_GET, '/account/sessions/current', [
|
||||
'x-appwrite-project' => $this->getProject()['$id'],
|
||||
'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $oauthUserCookie,
|
||||
]);
|
||||
$this->assertEquals(200, $response['headers']['status-code']);
|
||||
$this->assertEquals($oauthUserId, $response['body']['userId']);
|
||||
$this->assertEquals('mock', $response['body']['provider']);
|
||||
|
||||
/**
|
||||
* Test for Failure when disabled
|
||||
*/
|
||||
|
||||
@@ -3518,6 +3518,62 @@ trait DatabasesBase
|
||||
$this->assertEquals(200, $response['headers']['status-code']);
|
||||
}
|
||||
|
||||
public function testQueryBySequenceType(): void
|
||||
{
|
||||
$data = $this->setupDocuments();
|
||||
$databaseId = $data['databaseId'];
|
||||
|
||||
$documents = $this->client->call(Client::METHOD_GET, $this->getRecordUrl($databaseId, $data['moviesId']), array_merge([
|
||||
'content-type' => 'application/json',
|
||||
'x-appwrite-project' => $this->getProject()['$id'],
|
||||
], $this->getHeaders()), [
|
||||
'queries' => [
|
||||
Query::equal('$id', $data['documentIds'])->toString(),
|
||||
],
|
||||
]);
|
||||
|
||||
$this->assertEquals(200, $documents['headers']['status-code']);
|
||||
$this->assertGreaterThan(0, count($documents['body'][$this->getRecordResource()]));
|
||||
|
||||
$sequence = $documents['body'][$this->getRecordResource()][0]['$sequence'];
|
||||
$this->assertIsString($sequence);
|
||||
|
||||
// Query with string $sequence value (supported by all adapters)
|
||||
$response = $this->client->call(Client::METHOD_GET, $this->getRecordUrl($databaseId, $data['moviesId']), array_merge([
|
||||
'content-type' => 'application/json',
|
||||
'x-appwrite-project' => $this->getProject()['$id'],
|
||||
], $this->getHeaders()), [
|
||||
'queries' => [
|
||||
Query::equal('$sequence', [$sequence])->toString(),
|
||||
],
|
||||
]);
|
||||
|
||||
$this->assertEquals(200, $response['headers']['status-code']);
|
||||
$this->assertCount(1, $response['body'][$this->getRecordResource()]);
|
||||
$this->assertIsString($response['body'][$this->getRecordResource()][0]['$sequence']);
|
||||
$this->assertSame($sequence, $response['body'][$this->getRecordResource()][0]['$sequence']);
|
||||
|
||||
// Query with int $sequence value (supported by SQL adapters, rejected by MongoDB)
|
||||
$intSequence = (int)$sequence;
|
||||
$response = $this->client->call(Client::METHOD_GET, $this->getRecordUrl($databaseId, $data['moviesId']), array_merge([
|
||||
'content-type' => 'application/json',
|
||||
'x-appwrite-project' => $this->getProject()['$id'],
|
||||
], $this->getHeaders()), [
|
||||
'queries' => [
|
||||
Query::equal('$sequence', [$intSequence])->toString(),
|
||||
],
|
||||
]);
|
||||
|
||||
$adapter = getenv('_APP_DB_ADAPTER');
|
||||
if ($adapter === 'mongodb') {
|
||||
$this->assertEquals(400, $response['headers']['status-code']);
|
||||
} else {
|
||||
$this->assertEquals(200, $response['headers']['status-code']);
|
||||
$this->assertCount(1, $response['body'][$this->getRecordResource()]);
|
||||
$this->assertIsString($response['body'][$this->getRecordResource()][0]['$sequence']);
|
||||
}
|
||||
}
|
||||
|
||||
public function testListDocumentsAfterPagination(): void
|
||||
{
|
||||
$data = $this->setupDocuments();
|
||||
|
||||
@@ -430,7 +430,7 @@ class V21Test extends TestCase
|
||||
{
|
||||
$result = $this->filter->parse($content, Response::MODEL_DOCUMENT);
|
||||
|
||||
$this->assertEquals($expected, $result);
|
||||
$this->assertSame($expected, $result);
|
||||
}
|
||||
|
||||
#[DataProvider('documentProvider')]
|
||||
@@ -438,7 +438,76 @@ class V21Test extends TestCase
|
||||
{
|
||||
$result = $this->filter->parse($content, Response::MODEL_ROW);
|
||||
|
||||
$this->assertEquals($expected, $result);
|
||||
$this->assertSame($expected, $result);
|
||||
}
|
||||
|
||||
public static function documentListProvider(): array
|
||||
{
|
||||
return [
|
||||
'cast $sequence in document list' => [
|
||||
[
|
||||
'total' => 2,
|
||||
'documents' => [
|
||||
[
|
||||
'$id' => 'doc1',
|
||||
'$sequence' => '10',
|
||||
'name' => 'first',
|
||||
],
|
||||
[
|
||||
'$id' => 'doc2',
|
||||
'$sequence' => '20',
|
||||
'name' => 'second',
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
'total' => 2,
|
||||
'documents' => [
|
||||
[
|
||||
'$id' => 'doc1',
|
||||
'$sequence' => 10,
|
||||
'name' => 'first',
|
||||
],
|
||||
[
|
||||
'$id' => 'doc2',
|
||||
'$sequence' => 20,
|
||||
'name' => 'second',
|
||||
],
|
||||
],
|
||||
]
|
||||
],
|
||||
'handle empty document list' => [
|
||||
[
|
||||
'total' => 0,
|
||||
'documents' => [],
|
||||
],
|
||||
[
|
||||
'total' => 0,
|
||||
'documents' => [],
|
||||
]
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
#[DataProvider('documentListProvider')]
|
||||
public function testDocumentList(array $content, array $expected): void
|
||||
{
|
||||
$result = $this->filter->parse($content, Response::MODEL_DOCUMENT_LIST);
|
||||
|
||||
$this->assertSame($expected, $result);
|
||||
}
|
||||
|
||||
#[DataProvider('documentListProvider')]
|
||||
public function testRowList(array $content, array $expected): void
|
||||
{
|
||||
$content['rows'] = $content['documents'];
|
||||
unset($content['documents']);
|
||||
$expected['rows'] = $expected['documents'];
|
||||
unset($expected['documents']);
|
||||
|
||||
$result = $this->filter->parse($content, Response::MODEL_ROW_LIST);
|
||||
|
||||
$this->assertSame($expected, $result);
|
||||
}
|
||||
|
||||
public static function defaultPassthroughProvider(): array
|
||||
|
||||
Reference in New Issue
Block a user