mirror of
https://github.com/appwrite/appwrite.git
synced 2026-05-26 13:51:13 +00:00
Merge branch '1.9.x' into feat-public-platform-api
This commit is contained in:
@@ -161,6 +161,27 @@ jobs:
|
||||
- name: Run PHPStan
|
||||
run: composer analyze -- --no-progress
|
||||
|
||||
specs:
|
||||
name: Checks / Specs
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: '8.3'
|
||||
extensions: swoole
|
||||
tools: composer:v2
|
||||
coverage: none
|
||||
|
||||
- name: Install dependencies
|
||||
run: composer install --prefer-dist --no-progress --ignore-platform-reqs
|
||||
|
||||
- name: Generate specs
|
||||
run: _APP_STORAGE_LIMIT=5368709120 php app/cli.php specs --version=latest --git=no
|
||||
|
||||
locale:
|
||||
name: Checks / Locale
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -44,7 +44,7 @@ Table of Contents:
|
||||
|
||||
## Products
|
||||
|
||||
- **[Appwrite Auth](https://appwrite.io/docs/products/authentication)** - Secure user authentication with multiple login methods including email/password, SMS, OAuth, anonymous sessions, and magic links. Includes session management, multi-factor authentication, and user verification flows.
|
||||
- **[Appwrite Auth](https://appwrite.io/docs/products/auth)** - Secure user authentication with multiple login methods including email/password, SMS, OAuth, anonymous sessions, and magic links. Includes session management, multi-factor authentication, and user verification flows.
|
||||
|
||||
- **[Appwrite Databases](https://appwrite.io/docs/products/databases)** - Scalable structured data storage with support for databases, tables, and rows. Includes querying, pagination, indexing, and relationships to model complex application data.
|
||||
|
||||
|
||||
@@ -1103,14 +1103,14 @@ Http::post('/v1/account/sessions/email')
|
||||
]));
|
||||
}
|
||||
|
||||
$dbForProject->purgeCachedDocument('users', $user->getId());
|
||||
|
||||
$session = $dbForProject->createDocument('sessions', $session->setAttribute('$permissions', [
|
||||
Permission::read(Role::user($user->getId())),
|
||||
Permission::update(Role::user($user->getId())),
|
||||
Permission::delete(Role::user($user->getId())),
|
||||
]));
|
||||
|
||||
$dbForProject->purgeCachedDocument('users', $user->getId());
|
||||
|
||||
$encoded = $store
|
||||
->setProperty('id', $user->getId())
|
||||
->setProperty('secret', $secret)
|
||||
|
||||
@@ -639,29 +639,15 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
||||
} catch (\Throwable) {
|
||||
}
|
||||
|
||||
// Checkout dev branch (or create if it doesn't exist)
|
||||
// Create or checkout dev branch from the base branch
|
||||
// This ensures dev always starts from the latest base branch,
|
||||
// avoiding history divergence caused by squash merges.
|
||||
try {
|
||||
$repo->execute('checkout', '-f', $gitBranch);
|
||||
$repo->execute('checkout', '-B', $gitBranch, $repoBranch);
|
||||
} catch (\Throwable) {
|
||||
$repo->execute('checkout', '-b', $gitBranch);
|
||||
}
|
||||
|
||||
// Fetch dev branch, or push to create it on remote
|
||||
try {
|
||||
$repo->execute('fetch', 'origin', $gitBranch, '--quiet', '--no-tags', '--depth', '1');
|
||||
} catch (\Throwable) {
|
||||
try {
|
||||
$repo->execute('push', '-u', 'origin', $gitBranch, '--quiet');
|
||||
} catch (\Throwable) {
|
||||
}
|
||||
}
|
||||
|
||||
// Sync with remote dev branch
|
||||
try {
|
||||
$repo->execute('reset', '--hard', "origin/{$gitBranch}");
|
||||
} catch (\Throwable) {
|
||||
}
|
||||
|
||||
// Backup .github before cleaning working tree
|
||||
$githubDir = $target . '/.github';
|
||||
$githubBackup = \sys_get_temp_dir() . '/.github-backup-' . \getmypid();
|
||||
@@ -699,7 +685,7 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
||||
return true;
|
||||
}
|
||||
|
||||
$repo->execute('push', '-u', 'origin', $gitBranch, '--quiet');
|
||||
$repo->execute('push', '--force-with-lease', '-u', 'origin', $gitBranch, '--quiet');
|
||||
} catch (\Throwable $e) {
|
||||
Console::warning(" Git push failed: " . $e->getMessage());
|
||||
return false;
|
||||
|
||||
@@ -482,7 +482,12 @@ class Specs extends Action
|
||||
? $specsDir . '/' . $format . '-mocks-' . $platform . '.json'
|
||||
: $specsDir . '/' . $format . '-' . $version . '-' . $platform . '.json';
|
||||
|
||||
$parsedSpecs = $specs->parse();
|
||||
try {
|
||||
$parsedSpecs = $specs->parse();
|
||||
} catch (\RuntimeException $e) {
|
||||
throw new \RuntimeException("Spec generation failed for {$platform} ({$format}): " . $e->getMessage(), 0, $e);
|
||||
}
|
||||
|
||||
$encodedSpecs = \json_encode($parsedSpecs, JSON_PRETTY_PRINT);
|
||||
|
||||
unset($parsedSpecs);
|
||||
|
||||
@@ -278,6 +278,18 @@ class OpenAPI3 extends Format
|
||||
}
|
||||
}
|
||||
|
||||
if (\is_string($model)) {
|
||||
throw new \RuntimeException("Unresolved response model '{$model}' for method '{$sdk->getNamespace()}.{$sdk->getMethodName()}'. Ensure the model is registered.");
|
||||
}
|
||||
|
||||
if (\is_array($model)) {
|
||||
foreach ($model as $m) {
|
||||
if (\is_string($m)) {
|
||||
throw new \RuntimeException("Unresolved response model '{$m}' for method '{$sdk->getNamespace()}.{$sdk->getMethodName()}'. Ensure the model is registered.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!(\is_array($model)) && $model->isNone()) {
|
||||
$temp['responses'][(string)$response->getCode() ?? '500'] = [
|
||||
'description' => in_array($produces, [
|
||||
|
||||
@@ -285,6 +285,18 @@ class Swagger2 extends Format
|
||||
}
|
||||
}
|
||||
|
||||
if (\is_string($model)) {
|
||||
throw new \RuntimeException("Unresolved response model '{$model}' for method '{$sdk->getNamespace()}.{$sdk->getMethodName()}'. Ensure the model is registered.");
|
||||
}
|
||||
|
||||
if (\is_array($model)) {
|
||||
foreach ($model as $m) {
|
||||
if (\is_string($m)) {
|
||||
throw new \RuntimeException("Unresolved response model '{$m}' for method '{$sdk->getNamespace()}.{$sdk->getMethodName()}'. Ensure the model is registered.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!(\is_array($model)) && $model->isNone()) {
|
||||
$temp['responses'][(string)$response->getCode() ?? '500'] = [
|
||||
'description' => in_array($produces, [
|
||||
|
||||
@@ -4150,4 +4150,178 @@ class AccountCustomClientTest extends Scope
|
||||
|
||||
$this->assertEquals(401, $verification3['headers']['status-code']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that a new email/password session is immediately usable even when
|
||||
* a concurrent request re-populates the user cache between the cache purge
|
||||
* and session creation.
|
||||
*
|
||||
* Regression test for: purging the user cache BEFORE persisting the session
|
||||
* allows a concurrent request (from a different Swoole worker) to re-cache
|
||||
* a stale user document that lacks the new session, causing sessionVerify
|
||||
* to fail with 401 on subsequent requests using the new session.
|
||||
*/
|
||||
public function testEmailPasswordSessionNotCorruptedByConcurrentRequests(): void
|
||||
{
|
||||
$projectId = $this->getProject()['$id'];
|
||||
$endpoint = $this->client->getEndpoint();
|
||||
|
||||
$email = uniqid('race_', true) . getmypid() . '@localhost.test';
|
||||
$password = 'password123!';
|
||||
|
||||
// Create user
|
||||
$response = $this->client->call(Client::METHOD_POST, '/account', [
|
||||
'origin' => 'http://localhost',
|
||||
'content-type' => 'application/json',
|
||||
'x-appwrite-project' => $projectId,
|
||||
], [
|
||||
'userId' => ID::unique(),
|
||||
'email' => $email,
|
||||
'password' => $password,
|
||||
'name' => 'Race Test User',
|
||||
]);
|
||||
$this->assertEquals(201, $response['headers']['status-code']);
|
||||
|
||||
// Login to get session A
|
||||
$responseA = $this->client->call(Client::METHOD_POST, '/account/sessions/email', [
|
||||
'origin' => 'http://localhost',
|
||||
'content-type' => 'application/json',
|
||||
'x-appwrite-project' => $projectId,
|
||||
], [
|
||||
'email' => $email,
|
||||
'password' => $password,
|
||||
]);
|
||||
$this->assertEquals(201, $responseA['headers']['status-code']);
|
||||
$sessionA = $responseA['cookies']['a_session_' . $projectId];
|
||||
|
||||
// Verify session A works
|
||||
$verifyA = $this->client->call(Client::METHOD_GET, '/account', [
|
||||
'origin' => 'http://localhost',
|
||||
'content-type' => 'application/json',
|
||||
'x-appwrite-project' => $projectId,
|
||||
'cookie' => 'a_session_' . $projectId . '=' . $sessionA,
|
||||
]);
|
||||
$this->assertEquals(200, $verifyA['headers']['status-code']);
|
||||
|
||||
/**
|
||||
* Race condition scenario:
|
||||
* 1. Start login B via curl_multi (non-blocking)
|
||||
* 2. Drive the transfer for ~150ms so login B reaches purgeCachedDocument
|
||||
* (findOne ~15ms + Argon2 hash verify ~60ms + middleware overhead)
|
||||
* 3. THEN add GET requests to curl_multi - these hit different workers and
|
||||
* re-cache a stale user document (without session B) during the window
|
||||
* between purgeCachedDocument and createDocument
|
||||
* 4. After all complete, verify session B is usable
|
||||
*/
|
||||
for ($attempt = 0; $attempt < 5; $attempt++) {
|
||||
$loginCookies = [];
|
||||
|
||||
$multi = curl_multi_init();
|
||||
|
||||
// Start login B first (alone)
|
||||
$loginHandle = curl_init("{$endpoint}/account/sessions/email");
|
||||
curl_setopt_array($loginHandle, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
'origin: http://localhost',
|
||||
'content-type: application/json',
|
||||
"x-appwrite-project: {$projectId}",
|
||||
],
|
||||
CURLOPT_POSTFIELDS => \json_encode([
|
||||
'email' => $email,
|
||||
'password' => $password,
|
||||
]),
|
||||
CURLOPT_HEADERFUNCTION => function ($curl, $header) use (&$loginCookies) {
|
||||
if (\stripos($header, 'set-cookie:') === 0) {
|
||||
$cookiePart = \trim(\substr($header, 11));
|
||||
$eqPos = \strpos($cookiePart, '=');
|
||||
if ($eqPos !== false) {
|
||||
$name = \substr($cookiePart, 0, $eqPos);
|
||||
$rest = \substr($cookiePart, $eqPos + 1);
|
||||
$semiPos = \strpos($rest, ';');
|
||||
$loginCookies[$name] = $semiPos !== false
|
||||
? \substr($rest, 0, $semiPos)
|
||||
: $rest;
|
||||
}
|
||||
}
|
||||
return \strlen($header);
|
||||
},
|
||||
]);
|
||||
curl_multi_add_handle($multi, $loginHandle);
|
||||
|
||||
// Drive the login transfer forward and wait for the server to start
|
||||
// processing the login (past hash verification + cache purge).
|
||||
$deadline = \microtime(true) + 0.15; // 150ms
|
||||
do {
|
||||
curl_multi_exec($multi, $active);
|
||||
curl_multi_select($multi, 0.005);
|
||||
} while (\microtime(true) < $deadline && $active);
|
||||
|
||||
// NOW add GET requests - they arrive after the cache purge
|
||||
// but before session creation (which is delayed by the usleep or I/O).
|
||||
$getHandles = [];
|
||||
for ($i = 0; $i < 10; $i++) {
|
||||
$gh = curl_init("{$endpoint}/account");
|
||||
curl_setopt_array($gh, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
'origin: http://localhost',
|
||||
'content-type: application/json',
|
||||
"x-appwrite-project: {$projectId}",
|
||||
"cookie: a_session_{$projectId}={$sessionA}",
|
||||
],
|
||||
]);
|
||||
curl_multi_add_handle($multi, $gh);
|
||||
$getHandles[] = $gh;
|
||||
}
|
||||
|
||||
// Drive all to completion
|
||||
do {
|
||||
$status = curl_multi_exec($multi, $active);
|
||||
if ($active) {
|
||||
curl_multi_select($multi, 0.05);
|
||||
}
|
||||
} while ($active && $status === CURLM_OK);
|
||||
|
||||
$loginStatus = curl_getinfo($loginHandle, CURLINFO_HTTP_CODE);
|
||||
|
||||
curl_multi_remove_handle($multi, $loginHandle);
|
||||
curl_close($loginHandle);
|
||||
foreach ($getHandles as $gh) {
|
||||
curl_multi_remove_handle($multi, $gh);
|
||||
curl_close($gh);
|
||||
}
|
||||
curl_multi_close($multi);
|
||||
|
||||
$this->assertEquals(201, $loginStatus, 'Login for session B should succeed');
|
||||
|
||||
$sessionBCookie = $loginCookies["a_session_{$projectId}"] ?? null;
|
||||
$this->assertNotNull($sessionBCookie, 'Session B cookie should be set');
|
||||
|
||||
// THE CRITICAL CHECK: verify session B is usable immediately
|
||||
$verifyB = $this->client->call(Client::METHOD_GET, '/account', [
|
||||
'origin' => 'http://localhost',
|
||||
'content-type' => 'application/json',
|
||||
'x-appwrite-project' => $projectId,
|
||||
'cookie' => "a_session_{$projectId}={$sessionBCookie}",
|
||||
]);
|
||||
|
||||
$this->assertEquals(
|
||||
200,
|
||||
$verifyB['headers']['status-code'],
|
||||
'Session B must be immediately usable after login. '
|
||||
. 'A 401 here means a stale user cache (without the new session) was served. '
|
||||
. 'The fix is to create the session document BEFORE purging the user cache.'
|
||||
);
|
||||
|
||||
// Clean up session B for next iteration
|
||||
$this->client->call(Client::METHOD_DELETE, '/account/sessions/current', [
|
||||
'origin' => 'http://localhost',
|
||||
'content-type' => 'application/json',
|
||||
'x-appwrite-project' => $projectId,
|
||||
'cookie' => "a_session_{$projectId}={$sessionBCookie}",
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user