Skip to content

Commit 2017183

Browse files
committed
Merge branch '1.9.x' into feat-rust-sdk
2 parents 1a1740a + 7114611 commit 2017183

9 files changed

Lines changed: 302 additions & 12 deletions

File tree

.github/workflows/nightly.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ jobs:
1616
- name: Build the Docker image
1717
run: DOCKER_BUILDKIT=1 docker build . --target production -t appwrite_image:latest
1818
- name: Run Trivy vulnerability scanner on image
19-
uses: aquasecurity/trivy-action@0.20.0
19+
uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 # v0.35.0
2020
with:
2121
image-ref: 'appwrite_image:latest'
2222
format: 'sarif'
@@ -35,7 +35,7 @@ jobs:
3535
- name: Check out code
3636
uses: actions/checkout@v6
3737
- name: Run Trivy vulnerability scanner on filesystem
38-
uses: aquasecurity/trivy-action@0.20.0
38+
uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 # v0.35.0
3939
with:
4040
scan-type: 'fs'
4141
format: 'sarif'

app/controllers/api/account.php

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,22 @@ function sendSessionAlert(Locale $locale, Document $user, Document $project, arr
209209

210210
$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) {
211211

212+
// Attempt to decode secret as a JWT (used by OAuth2 token flow to carry provider info)
213+
$oauthProvider = null;
214+
try {
215+
$jwtDecoder = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', 60, 0);
216+
$payload = $jwtDecoder->decode($secret);
217+
218+
if (empty($payload['provider'])) {
219+
throw new Exception(Exception::USER_INVALID_TOKEN);
220+
}
221+
222+
$oauthProvider = $payload['provider'];
223+
$secret = $payload['secret'];
224+
} catch (\Ahc\Jwt\JWTException) {
225+
// Not a JWT — use secret as-is (non-OAuth flows)
226+
}
227+
212228
/** @var Appwrite\Utopia\Database\Documents\User $userFromRequest */
213229
$userFromRequest = $authorization->skip(fn () => $dbForProject->getDocument('users', $userId));
214230

@@ -220,6 +236,12 @@ function sendSessionAlert(Locale $locale, Document $user, Document $project, arr
220236
?: $userFromRequest->tokenVerify(null, $secret, $proofForCode);
221237

222238
if (!$verifiedToken) {
239+
// Could mean invalid/expired JWT, or expired secret
240+
throw new Exception(Exception::USER_INVALID_TOKEN);
241+
}
242+
243+
// OAuth2 tokens must have a provider from the JWT
244+
if ($verifiedToken->getAttribute('type') === TOKEN_TYPE_OAUTH2 && $oauthProvider === null) {
223245
throw new Exception(Exception::USER_INVALID_TOKEN);
224246
}
225247

@@ -245,7 +267,7 @@ function sendSessionAlert(Locale $locale, Document $user, Document $project, arr
245267
TOKEN_TYPE_INVITE => SESSION_PROVIDER_EMAIL,
246268
TOKEN_TYPE_MAGIC_URL => SESSION_PROVIDER_MAGIC_URL,
247269
TOKEN_TYPE_PHONE => SESSION_PROVIDER_PHONE,
248-
TOKEN_TYPE_OAUTH2 => SESSION_PROVIDER_OAUTH2,
270+
TOKEN_TYPE_OAUTH2 => $oauthProvider,
249271
default => SESSION_PROVIDER_TOKEN,
250272
};
251273
$session = new Document(array_merge(
@@ -1899,7 +1921,12 @@ function sendSessionAlert(Locale $locale, Document $user, Document $project, arr
18991921
->setParam('tokenId', $token->getId())
19001922
;
19011923

1902-
$query['secret'] = $secret;
1924+
// Wrap secret in a JWT that also carries the provider name
1925+
$jwtEncoder = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', 60, 0);
1926+
$query['secret'] = $jwtEncoder->encode([
1927+
'secret' => $secret,
1928+
'provider' => $provider,
1929+
]);
19031930
$query['userId'] = $user->getId();
19041931

19051932
// If the `token` param is not set, we persist the session in a cookie

composer.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,9 @@
1313
"test": "vendor/bin/phpunit",
1414
"lint": "vendor/bin/pint --test --config pint.json",
1515
"format": "vendor/bin/pint --config pint.json",
16-
"analyze": "./vendor/bin/phpstan analyse -c phpstan.neon --memory-limit=1G",
16+
"analyze": "./vendor/bin/phpstan analyse -c phpstan.neon --memory-limit=1G",
1717
"bench": "vendor/bin/phpbench run --report=benchmark",
18-
"check": "./vendor/bin/phpstan analyse -c phpstan.neon",
18+
"check": "./vendor/bin/phpstan analyse -c phpstan.neon --memory-limit=1G",
1919
"installer:clean": "php src/Appwrite/Platform/Installer/Server.php --clean",
2020
"installer:dev": "docker compose build && composer installer:clean && php src/Appwrite/Platform/Installer/Server.php --docker"
2121
},

src/Appwrite/Docker/Env.php

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,16 @@ public function __construct(string $data)
1919
foreach ($data as &$row) {
2020
$row = explode('=', $row, 2);
2121
$key = (isset($row[0])) ? trim($row[0]) : null;
22-
$value = (isset($row[1])) ? trim($row[1]) : null;
22+
$value = (isset($row[1])) ? (function (string $v): string {
23+
$v = trim($v);
24+
if (
25+
(\str_starts_with($v, '"') && \str_ends_with($v, '"')) ||
26+
(\str_starts_with($v, "'") && \str_ends_with($v, "'"))
27+
) {
28+
return \substr($v, 1, -1);
29+
}
30+
return $v;
31+
})(trim($row[1])) : null;
2332

2433
if ($key) {
2534
$this->vars[$key] = $value;

src/Appwrite/Platform/Tasks/Install.php

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ class Install extends Action
3333
private const string APPWRITE_API_URL = 'http://appwrite';
3434
private const string GROWTH_API_URL = 'https://growth.appwrite.io/v1';
3535

36+
protected bool $isUpgrade = false;
3637
protected string $hostPath = '';
3738
protected ?bool $isLocalInstall = null;
3839
protected ?array $installerConfig = null;
@@ -66,7 +67,7 @@ public function action(
6667
bool $noStart,
6768
string $database
6869
): void {
69-
$isUpgrade = false;
70+
$isUpgrade = $this->isUpgrade;
7071
$defaultHttpPort = '80';
7172
$defaultHttpsPort = '443';
7273
$config = Config::getParam('variables');
@@ -508,7 +509,7 @@ public function performInstallation(
508509
$this->applyLocalPaths($isLocalInstall, false);
509510

510511
$isCLI = php_sapi_name() === 'cli';
511-
if ($isLocalInstall) {
512+
if ($isLocalInstall || $isUpgrade) {
512513
$useExistingConfig = false;
513514
} else {
514515
$useExistingConfig = file_exists($this->path . '/' . $this->getComposeFileName())

src/Appwrite/Platform/Tasks/Upgrade.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ public function action(
4242
bool $noStart,
4343
string $database
4444
): void {
45+
$this->isUpgrade = true;
4546
$isLocalInstall = $this->isLocalInstall();
4647
$this->applyLocalPaths($isLocalInstall, true);
4748

@@ -72,7 +73,7 @@ public function action(
7273
}
7374

7475
if ($database === null) {
75-
$envData = @file_get_contents($this->path . '/.env');
76+
$envData = @file_get_contents($this->path . '/' . $this->getEnvFileName());
7677
if ($envData !== false) {
7778
$envFile = new Env($envData);
7879
$database = $envFile->list()['_APP_DB_ADAPTER'] ?? null;

tests/e2e/Services/Account/AccountCustomClientTest.php

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2182,11 +2182,138 @@ public function testCreateOAuth2AccountSession(): void
21822182
]), [
21832183
'success' => 'http://localhost/v1/mock/tests/general/oauth2/success',
21842184
'failure' => 'http://localhost/v1/mock/tests/general/oauth2/failure',
2185+
], followRedirects: false);
2186+
2187+
$this->assertEquals(301, $response['headers']['status-code']);
2188+
$this->assertStringStartsWith('http://localhost/v1/mock/tests/general/oauth2', $response['headers']['location']);
2189+
2190+
$oauthClient = new Client();
2191+
$oauthClient->setEndpoint('');
2192+
$response = $oauthClient->call(Client::METHOD_GET, $response['headers']['location'], followRedirects: false);
2193+
2194+
$this->assertEquals(301, $response['headers']['status-code']);
2195+
$this->assertStringStartsWith('http://appwrite:/v1/account/sessions/oauth2/callback/mock/' . $this->getProject()['$id'] . '?code=', $response['headers']['location']);
2196+
2197+
$response = $oauthClient->call(Client::METHOD_GET, $response['headers']['location'], followRedirects: false);
2198+
2199+
$this->assertEquals(301, $response['headers']['status-code']);
2200+
$this->assertStringStartsWith('http://appwrite:/v1/account/sessions/oauth2/mock/redirect?code=', $response['headers']['location']);
2201+
2202+
$response = $oauthClient->call(Client::METHOD_GET, $response['headers']['location'], followRedirects: false);
2203+
2204+
$this->assertEquals(301, $response['headers']['status-code']);
2205+
2206+
$this->assertArrayHasKey('a_session_' . $this->getProject()['$id'] . '_legacy', $response['cookies']);
2207+
$this->assertArrayHasKey('a_session_' . $this->getProject()['$id'], $response['cookies']);
2208+
2209+
$oauthUserCookie = $response['cookies']['a_session_' . $this->getProject()['$id']];
2210+
$this->assertNotEmpty($oauthUserCookie);
2211+
2212+
$response = $oauthClient->call(Client::METHOD_GET, $response['headers']['location'], followRedirects: false);
2213+
2214+
$this->assertEquals(200, $response['headers']['status-code']);
2215+
$this->assertEquals('success', $response['body']['result']);
2216+
2217+
// Ensure user is authenticated
2218+
$response = $this->client->call(Client::METHOD_GET, '/account', [
2219+
'x-appwrite-project' => $this->getProject()['$id'],
2220+
'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $oauthUserCookie,
21852221
]);
2222+
$this->assertEquals(200, $response['headers']['status-code']);
2223+
$this->assertEquals('[email protected]', $response['body']['email']);
2224+
2225+
$oauthUserId = $response['body']['$id'];
2226+
$this->assertNotEmpty($oauthUserId);
2227+
2228+
// Ensure session looks as expected
2229+
$response = $this->client->call(Client::METHOD_GET, '/account/sessions/current', [
2230+
'x-appwrite-project' => $this->getProject()['$id'],
2231+
'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $oauthUserCookie,
2232+
]);
2233+
$this->assertEquals(200, $response['headers']['status-code']);
2234+
$this->assertEquals($oauthUserId, $response['body']['userId']);
2235+
$this->assertEquals('mock', $response['body']['provider']);
2236+
2237+
// Same sign-in again, but this time with oauth2 token flow
2238+
$response = $this->client->call(Client::METHOD_GET, '/account/tokens/oauth2/' . $provider, array_merge([
2239+
'origin' => 'http://localhost',
2240+
'content-type' => 'application/json',
2241+
'x-appwrite-project' => $this->getProject()['$id'],
2242+
]), [
2243+
'success' => 'http://localhost/v1/mock/tests/general/oauth2/success',
2244+
'failure' => 'http://localhost/v1/mock/tests/general/oauth2/failure',
2245+
], followRedirects: false);
2246+
2247+
$this->assertEquals(301, $response['headers']['status-code']);
2248+
$this->assertStringStartsWith('http://localhost/v1/mock/tests/general/oauth2', $response['headers']['location']);
2249+
2250+
$oauthClient = new Client();
2251+
$oauthClient->setEndpoint('');
2252+
$response = $oauthClient->call(Client::METHOD_GET, $response['headers']['location'], followRedirects: false);
2253+
2254+
$this->assertEquals(301, $response['headers']['status-code']);
2255+
$this->assertStringStartsWith('http://appwrite:/v1/account/sessions/oauth2/callback/mock/' . $this->getProject()['$id'] . '?code=', $response['headers']['location']);
2256+
2257+
$response = $oauthClient->call(Client::METHOD_GET, $response['headers']['location'], followRedirects: false);
2258+
2259+
$this->assertEquals(301, $response['headers']['status-code']);
2260+
$this->assertStringStartsWith('http://appwrite:/v1/account/sessions/oauth2/mock/redirect?code=', $response['headers']['location']);
2261+
2262+
$response = $oauthClient->call(Client::METHOD_GET, $response['headers']['location'], followRedirects: false);
2263+
2264+
$this->assertEquals(301, $response['headers']['status-code']);
2265+
$this->assertStringStartsWith('http://localhost/v1/mock/tests/general/oauth2/success?secret=', $response['headers']['location']);
2266+
2267+
$oauthParamsString = \parse_url($response['headers']['location'], PHP_URL_QUERY);
2268+
$oauthParams = [];
2269+
\parse_str($oauthParamsString, $oauthParams);
2270+
2271+
$this->assertNotEmpty($oauthParams['secret']);
2272+
$this->assertNotEmpty($oauthParams['userId']);
2273+
2274+
$response = $oauthClient->call(Client::METHOD_GET, $response['headers']['location'], followRedirects: false);
21862275

21872276
$this->assertEquals(200, $response['headers']['status-code']);
21882277
$this->assertEquals('success', $response['body']['result']);
21892278

2279+
// Claim session
2280+
$response = $this->client->call(Client::METHOD_POST, '/account/sessions/token', [
2281+
'origin' => 'http://localhost',
2282+
'content-type' => 'application/json',
2283+
'x-appwrite-project' => $this->getProject()['$id'],
2284+
], [
2285+
'userId' => $oauthParams['userId'],
2286+
'secret' => $oauthParams['secret'],
2287+
]);
2288+
2289+
$this->assertEquals(201, $response['headers']['status-code']);
2290+
$this->assertEquals('mock', $response['body']['provider']);
2291+
2292+
$this->assertArrayHasKey('a_session_' . $this->getProject()['$id'] . '_legacy', $response['cookies']);
2293+
$this->assertArrayHasKey('a_session_' . $this->getProject()['$id'], $response['cookies']);
2294+
2295+
$oauthUserCookie = $response['cookies']['a_session_' . $this->getProject()['$id']];
2296+
$this->assertNotEmpty($oauthUserCookie);
2297+
2298+
$response = $this->client->call(Client::METHOD_GET, '/account', [
2299+
'x-appwrite-project' => $this->getProject()['$id'],
2300+
'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $oauthUserCookie,
2301+
]);
2302+
$this->assertEquals(200, $response['headers']['status-code']);
2303+
$this->assertEquals('[email protected]', $response['body']['email']);
2304+
2305+
$oauthUserId = $response['body']['$id'];
2306+
$this->assertNotEmpty($oauthUserId);
2307+
2308+
// Ensure session looks as expected
2309+
$response = $this->client->call(Client::METHOD_GET, '/account/sessions/current', [
2310+
'x-appwrite-project' => $this->getProject()['$id'],
2311+
'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $oauthUserCookie,
2312+
]);
2313+
$this->assertEquals(200, $response['headers']['status-code']);
2314+
$this->assertEquals($oauthUserId, $response['body']['userId']);
2315+
$this->assertEquals('mock', $response['body']['provider']);
2316+
21902317
/**
21912318
* Test for Failure when disabled
21922319
*/

tests/e2e/Services/Databases/DatabasesBase.php

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3518,6 +3518,62 @@ public function testGetDocumentWithQueries(): void
35183518
$this->assertEquals(200, $response['headers']['status-code']);
35193519
}
35203520

3521+
public function testQueryBySequenceType(): void
3522+
{
3523+
$data = $this->setupDocuments();
3524+
$databaseId = $data['databaseId'];
3525+
3526+
$documents = $this->client->call(Client::METHOD_GET, $this->getRecordUrl($databaseId, $data['moviesId']), array_merge([
3527+
'content-type' => 'application/json',
3528+
'x-appwrite-project' => $this->getProject()['$id'],
3529+
], $this->getHeaders()), [
3530+
'queries' => [
3531+
Query::equal('$id', $data['documentIds'])->toString(),
3532+
],
3533+
]);
3534+
3535+
$this->assertEquals(200, $documents['headers']['status-code']);
3536+
$this->assertGreaterThan(0, count($documents['body'][$this->getRecordResource()]));
3537+
3538+
$sequence = $documents['body'][$this->getRecordResource()][0]['$sequence'];
3539+
$this->assertIsString($sequence);
3540+
3541+
// Query with string $sequence value (supported by all adapters)
3542+
$response = $this->client->call(Client::METHOD_GET, $this->getRecordUrl($databaseId, $data['moviesId']), array_merge([
3543+
'content-type' => 'application/json',
3544+
'x-appwrite-project' => $this->getProject()['$id'],
3545+
], $this->getHeaders()), [
3546+
'queries' => [
3547+
Query::equal('$sequence', [$sequence])->toString(),
3548+
],
3549+
]);
3550+
3551+
$this->assertEquals(200, $response['headers']['status-code']);
3552+
$this->assertCount(1, $response['body'][$this->getRecordResource()]);
3553+
$this->assertIsString($response['body'][$this->getRecordResource()][0]['$sequence']);
3554+
$this->assertSame($sequence, $response['body'][$this->getRecordResource()][0]['$sequence']);
3555+
3556+
// Query with int $sequence value (supported by SQL adapters, rejected by MongoDB)
3557+
$intSequence = (int)$sequence;
3558+
$response = $this->client->call(Client::METHOD_GET, $this->getRecordUrl($databaseId, $data['moviesId']), array_merge([
3559+
'content-type' => 'application/json',
3560+
'x-appwrite-project' => $this->getProject()['$id'],
3561+
], $this->getHeaders()), [
3562+
'queries' => [
3563+
Query::equal('$sequence', [$intSequence])->toString(),
3564+
],
3565+
]);
3566+
3567+
$adapter = getenv('_APP_DB_ADAPTER');
3568+
if ($adapter === 'mongodb') {
3569+
$this->assertEquals(400, $response['headers']['status-code']);
3570+
} else {
3571+
$this->assertEquals(200, $response['headers']['status-code']);
3572+
$this->assertCount(1, $response['body'][$this->getRecordResource()]);
3573+
$this->assertIsString($response['body'][$this->getRecordResource()][0]['$sequence']);
3574+
}
3575+
}
3576+
35213577
public function testListDocumentsAfterPagination(): void
35223578
{
35233579
$data = $this->setupDocuments();

0 commit comments

Comments
 (0)