mirror of
https://github.com/appwrite/appwrite.git
synced 2026-05-26 13:51:13 +00:00
Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a5fa09b4ce | ||
|
|
10e2edac90 | ||
|
|
340081a67d | ||
|
|
f0191202a0 | ||
|
|
44ec8de6bf | ||
|
|
9fb1aaadaf | ||
|
|
d9ac4f2f2d | ||
|
|
e61f3c7a42 | ||
|
|
1b6d51a116 | ||
|
|
0058e88217 | ||
|
|
81b4065921 | ||
|
|
d094d6a081 | ||
|
|
bae194e866 | ||
|
|
144e88452e | ||
|
|
89980b1f0e | ||
|
|
80ad740d36 |
+57
-35
@@ -111,7 +111,7 @@ $ git push origin [name_of_your_new_branch]
|
||||
|
||||
## Setup From Source
|
||||
|
||||
To set up a working **development environment**, just fork the project git repository and install the backend and frontend dependencies using the proper package manager and create run the docker-compose stack.
|
||||
To set up a working **development environment**, just fork the project git repository and install the backend and frontend dependencies using the proper package manager and run the docker-compose stack.
|
||||
|
||||
> If you just want to install Appwrite for day-to-day use and not as a contributor, you can reference the [installation guide](https://github.com/appwrite/appwrite#installation), the [getting started guide](https://appwrite.io/docs/quick-starts), or the main [README](README.md) file.
|
||||
|
||||
@@ -173,12 +173,12 @@ Learn more at our [Technology Stack](#technology-stack) section.
|
||||
- [MVVM](https://en.wikipedia.org/wiki/Model%E2%80%93view%E2%80%93viewmodel) - Appwrite console architecture
|
||||
|
||||
##### Container Namespace Conventions
|
||||
To keep our services easy to understand within Docker we follow a naming convention for all our containers depending on it's intended use.
|
||||
To keep our services easy to understand within Docker we follow a naming convention for all our containers depending on its intended use.
|
||||
|
||||
`appwrite-worker-X` - Workers (`src/Appwrite/Platform/Workers/*`)
|
||||
`appwrite-task-X` - Tasks (`src/Appwrite/Platform/Tasks/*`)
|
||||
|
||||
Other containes should be named the same as their service, for example `redis` should just be called `redis`.
|
||||
Other containers should be named the same as their service, for example `redis` should just be called `redis`.
|
||||
|
||||
##### Security
|
||||
|
||||
@@ -189,7 +189,7 @@ Other containes should be named the same as their service, for example `redis` s
|
||||
|
||||
## Modules
|
||||
|
||||
As Appwrite grows, we noticed approach of having all service endpoints in `app/controllers/api/[service].php` is not maintainable. Not only it creates massive files, it also doesnt contain all product's features such as workers or tasks. While there might still be some occurances of those controller files, we avoid it in all new development, and gradually migrate existing controllers to **HTTP modules**.
|
||||
As Appwrite grows, we noticed approach of having all service endpoints in `app/controllers/api/[service].php` is not maintainable. Not only it creates massive files, it also doesn't contain all product's features such as workers or tasks. While there might still be some occurrences of those controller files, we avoid it in all new development, and gradually migrate existing controllers to **HTTP modules**.
|
||||
|
||||
### HTTP Endpoints
|
||||
|
||||
@@ -204,7 +204,7 @@ Tips and tricks:
|
||||
1. If endpoint doesn't have resource, use service name as resource name too
|
||||
> Example: `Modules/Sites/Http/Sites/Get.php`
|
||||
|
||||
2. If there are multiple resources, use then all in folder structure
|
||||
2. If there are multiple resources, use them all in folder structure
|
||||
> Example: `Modules/Sites/Http/Deployments/Builds/Create.php`
|
||||
|
||||
3. Action can only be `Get`, `Create`, `Update`, `Delete` or `XList`
|
||||
@@ -222,51 +222,73 @@ Appwrite's current structure is a combination of both [Monolithic](https://en.wi
|
||||
```bash
|
||||
.
|
||||
├── app # Main application
|
||||
│ ├── assets
|
||||
│ │ ├── dbip
|
||||
│ │ ├── fonts
|
||||
│ │ └── security
|
||||
│ ├── config # Config files
|
||||
│ │ ├── avatars
|
||||
│ │ ├── collections
|
||||
│ │ ├── locale
|
||||
│ │ ├── specs
|
||||
│ │ ├── storage
|
||||
│ │ └── templates
|
||||
│ ├── controllers # API & dashboard controllers
|
||||
│ │ ├── api
|
||||
│ │ ├── shared
|
||||
│ │ └── web
|
||||
│ ├── db # DB schemas
|
||||
│ ├── sdks # SDKs generated copies (used for generating code examples)
|
||||
│ ├── tasks # Server CLI commands
|
||||
│ ├── views # HTML server-side templates
|
||||
│ └── workers # Background workers
|
||||
│ ├── init # DB schemas
|
||||
│ │ └── database
|
||||
│ └── views # HTML server-side templates
|
||||
│ ├── general
|
||||
│ └── install
|
||||
├── bin # Server executables (tasks & workers)
|
||||
├── docker # Docker related resources and configs
|
||||
├── dev # Debugger config
|
||||
├── docs # Docs and tutorials
|
||||
│ ├── examples
|
||||
│ ├── lists
|
||||
│ ├── references
|
||||
│ ├── sdks
|
||||
│ ├── services
|
||||
│ ├── specs
|
||||
│ └── tutorials
|
||||
├── public # Public files
|
||||
│ ├── dist
|
||||
│ ├── fonts
|
||||
│ ├── images
|
||||
│ ├── scripts
|
||||
│ └── styles
|
||||
├── src # Supporting libraries (each lib has one role, common libs are released as individual projects)
|
||||
│ └── Appwrite
|
||||
│ ├── Auth
|
||||
│ ├── Detector
|
||||
│ ├── Docker
|
||||
| ├── DSN
|
||||
│ ├── Event
|
||||
│ ├── Extend
|
||||
│ ├── GraphQL
|
||||
│ ├── Messaging
|
||||
│ ├── Migration
|
||||
│ ├── Network
|
||||
│ ├── OpenSSL
|
||||
│ ├── Promises
|
||||
│ ├── Specification
|
||||
│ ├── Task
|
||||
│ ├── Template
|
||||
│ ├── URL
|
||||
│ └── Utopia
|
||||
│ ├── sdk-console
|
||||
│ ├── sdk-project
|
||||
│ └── sdk-web
|
||||
├── src # Supporting libraries (each lib has one role, common libs are released as
|
||||
│ ├── Appwrite
|
||||
│ │ ├── Auth
|
||||
│ │ ├── Certificates
|
||||
│ │ ├── Deletes
|
||||
│ │ ├── Detector
|
||||
│ │ ├── Docker
|
||||
│ │ ├── Event
|
||||
│ │ ├── Extend
|
||||
│ │ ├── Functions/Validator
|
||||
│ │ ├── GraphQL
|
||||
│ │ ├── Hooks
|
||||
│ │ ├── Messaging
|
||||
│ │ ├── Migration
|
||||
│ │ ├── Network
|
||||
│ │ ├── OpenSSL
|
||||
│ │ ├── Platform
|
||||
│ │ ├── Promises
|
||||
│ │ ├── PubSub
|
||||
│ │ ├── SDK
|
||||
│ │ ├── Task/Validator
|
||||
│ │ ├── Template
|
||||
│ │ ├── Transformation
|
||||
│ │ ├── URL
|
||||
│ │ ├── Utopia
|
||||
│ │ └── Vcs
|
||||
│ └── Executor
|
||||
└── tests # End to end & unit tests
|
||||
├── benchmarks
|
||||
├── e2e
|
||||
├── extensions
|
||||
├── resources
|
||||
└── unit
|
||||
```
|
||||
@@ -395,7 +417,7 @@ These are the current metrics we collect usage stats for:
|
||||
|
||||
> Note: The curly brackets in the metric name represents a template and is replaced with a value when the metric is processed.
|
||||
|
||||
Metrics are collected within 3 scopes Daily, monthly, an infinity. Adding new usage metric in order to aggregate usage stats is very simple, but very much dependent on where do you want to collect
|
||||
Metrics are collected within 3 scopes Daily, monthly, and infinity. Adding new usage metric in order to aggregate usage stats is very simple, but very much dependent on where do you want to collect
|
||||
statistics ,via API or via background worker. For both cases you will need to add a `const` variable in `app/init.php` under the usage metrics list using the naming convention `METRIC_<RESOURCE_NAME>` as shown below.
|
||||
|
||||
```php
|
||||
@@ -661,7 +683,7 @@ docker compose exec redis redis-cli FLUSHALL
|
||||
|
||||
## Using preview domains locally
|
||||
|
||||
Appwrite Functions are automatically given a domain you can visit to execute the function. This domain has format `[SOMETHING].functions.localhost` unless you changed `_APP_DOMAIN_FUNCTIONS` environment variable. This default value works great when running Appwrite locally, but it can be impossible to use preview domains with Cloud woekspaces such as Gitpod or GitHub Codespaces.
|
||||
Appwrite Functions are automatically given a domain you can visit to execute the function. This domain has format `[SOMETHING].functions.localhost` unless you changed `_APP_DOMAIN_FUNCTIONS` environment variable. This default value works great when running Appwrite locally, but it can be impossible to use preview domains with Cloud workspaces such as Gitpod or GitHub Codespaces.
|
||||
|
||||
To use preview domains on Cloud workspaces, you can visit hostname provided by them, and supply function's preview domain as URL parameter:
|
||||
|
||||
|
||||
@@ -462,4 +462,15 @@ return [
|
||||
'mock' => true,
|
||||
'class' => 'Appwrite\\Auth\\OAuth2\\Mock',
|
||||
],
|
||||
'mock-unverified' => [
|
||||
'name' => 'MockUnverified',
|
||||
'developers' => 'https://appwrite.io',
|
||||
'icon' => 'icon-appwrite',
|
||||
'enabled' => true,
|
||||
'sandbox' => false,
|
||||
'form' => false,
|
||||
'beta' => false,
|
||||
'mock' => true,
|
||||
'class' => 'Appwrite\\Auth\\OAuth2\\MockUnverified',
|
||||
],
|
||||
];
|
||||
|
||||
@@ -1639,9 +1639,6 @@ App::get('/v1/account/sessions/oauth2/:provider/redirect')
|
||||
$failureRedirect(Exception::USER_UNAUTHORIZED, 'OAuth provider failed to return email.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Is verified is not used yet, since we don't know after an account is created anymore if it was verified or not.
|
||||
*/
|
||||
$isVerified = $oauth2->isEmailVerified($accessToken);
|
||||
|
||||
$identity = $dbForProject->findOne('identities', [
|
||||
@@ -1653,16 +1650,32 @@ App::get('/v1/account/sessions/oauth2/:provider/redirect')
|
||||
$user = $dbForProject->getDocument('users', $identity->getAttribute('userId'));
|
||||
}
|
||||
|
||||
// If user is not found, check if there is an identity with the same provider user ID
|
||||
// If user is not found, check if there is a user with the same email
|
||||
if ($user === false || $user->isEmpty()) {
|
||||
$userWithEmail = $dbForProject->findOne('users', [
|
||||
Query::equal('email', [$email]),
|
||||
]);
|
||||
if (!$userWithEmail->isEmpty()) {
|
||||
if (!$isVerified) {
|
||||
$failureRedirect(Exception::GENERAL_BAD_REQUEST);
|
||||
}
|
||||
$user->setAttributes($userWithEmail->getArrayCopy());
|
||||
}
|
||||
}
|
||||
|
||||
// If user is not found, check if there is an identity with the same email
|
||||
if ($user === false || $user->isEmpty()) {
|
||||
$identityWithMatchingEmail = $dbForProject->findOne('identities', [
|
||||
Query::equal('providerEmail', [$email]),
|
||||
]);
|
||||
if (!$identityWithMatchingEmail->isEmpty()) {
|
||||
if (!$isVerified) {
|
||||
$failureRedirect(Exception::GENERAL_BAD_REQUEST);
|
||||
}
|
||||
$user->setAttributes($dbForProject->getDocument('users', $identityWithMatchingEmail->getAttribute('userId'))->getArrayCopy());
|
||||
}
|
||||
}
|
||||
|
||||
if ($user === false || $user->isEmpty()) { // Last option -> create the user
|
||||
$limit = $project->getAttribute('auths', [])['limit'] ?? 0;
|
||||
|
||||
@@ -1674,14 +1687,6 @@ App::get('/v1/account/sessions/oauth2/:provider/redirect')
|
||||
}
|
||||
}
|
||||
|
||||
// Makes sure this email is not already used in another identity
|
||||
$identityWithMatchingEmail = $dbForProject->findOne('identities', [
|
||||
Query::equal('providerEmail', [$email]),
|
||||
]);
|
||||
if (!$identityWithMatchingEmail->isEmpty()) {
|
||||
$failureRedirect(Exception::GENERAL_BAD_REQUEST); /** Return a generic bad request to prevent exposing existing accounts */
|
||||
}
|
||||
|
||||
try {
|
||||
$emailCanonical = new Email($email);
|
||||
} catch (Throwable) {
|
||||
@@ -1735,7 +1740,6 @@ App::get('/v1/account/sessions/oauth2/:provider/redirect')
|
||||
'providerType' => MESSAGE_TYPE_EMAIL,
|
||||
'identifier' => $email,
|
||||
]));
|
||||
|
||||
} catch (Duplicate) {
|
||||
$failureRedirect(Exception::USER_ALREADY_EXISTS);
|
||||
}
|
||||
|
||||
@@ -1034,7 +1034,8 @@ App::init()
|
||||
->inject('dbForPlatform')
|
||||
->inject('queueForCertificates')
|
||||
->inject('platform')
|
||||
->action(function (Request $request, Document $console, Database $dbForPlatform, Certificate $queueForCertificates, array $platform) {
|
||||
->inject('authorization')
|
||||
->action(function (Request $request, Document $console, Database $dbForPlatform, Certificate $queueForCertificates, array $platform, Authorization $authorization) {
|
||||
$hostname = $request->getHostname();
|
||||
$cache = Config::getParam('hostnames', []);
|
||||
$platformHostnames = $platform['hostnames'] ?? [];
|
||||
@@ -1065,7 +1066,7 @@ App::init()
|
||||
}
|
||||
|
||||
// 4. Check/create rule (requires DB access)
|
||||
Authorization::disable();
|
||||
$authorization->disable();
|
||||
try {
|
||||
// TODO: (@Meldiron) Remove after 1.7.x migration
|
||||
$isMd5 = System::getEnv('_APP_RULES_FORMAT') === 'md5';
|
||||
@@ -1121,7 +1122,7 @@ App::init()
|
||||
} finally {
|
||||
$cache[$domain->get()] = true;
|
||||
Config::setParam('hostnames', $cache);
|
||||
Authorization::reset();
|
||||
$authorization->reset();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -117,6 +117,28 @@ App::get('/v1/mock/tests/general/oauth2/user')
|
||||
'id' => 1,
|
||||
'name' => 'User Name',
|
||||
'email' => 'useroauth@localhost.test',
|
||||
'verified' => true,
|
||||
]);
|
||||
});
|
||||
|
||||
App::get('/v1/mock/tests/general/oauth2/user-unverified')
|
||||
->desc('OAuth2 User Unverified')
|
||||
->groups(['mock'])
|
||||
->label('scope', 'public')
|
||||
->label('docs', false)
|
||||
->param('token', '', new Text(100), 'OAuth2 Access Token.')
|
||||
->inject('response')
|
||||
->action(function (string $token, Response $response) {
|
||||
|
||||
if ($token != '123456') {
|
||||
throw new Exception(Exception::GENERAL_MOCK, 'Invalid token');
|
||||
}
|
||||
|
||||
$response->json([
|
||||
'id' => 2,
|
||||
'name' => 'User Name Unverified',
|
||||
'email' => 'useroauthunverified@localhost.test',
|
||||
'verified' => false,
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -130,7 +130,9 @@ class Mock extends OAuth2
|
||||
*/
|
||||
public function isEmailVerified(string $accessToken): bool
|
||||
{
|
||||
return true;
|
||||
$user = $this->getUser($accessToken);
|
||||
|
||||
return $user['verified'] ?? true;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace Appwrite\Auth\OAuth2;
|
||||
|
||||
class MockUnverified extends Mock
|
||||
{
|
||||
/**
|
||||
* @return string
|
||||
*/
|
||||
public function getName(): string
|
||||
{
|
||||
return 'mock-unverified';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $accessToken
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
protected function getUser(string $accessToken): array
|
||||
{
|
||||
if (empty($this->user)) {
|
||||
$user = $this->request('GET', 'http://localhost/' . $this->version . '/mock/tests/general/oauth2/user-unverified?token=' . \urlencode($accessToken));
|
||||
|
||||
$this->user = \json_decode($user, true);
|
||||
}
|
||||
|
||||
return $this->user;
|
||||
}
|
||||
}
|
||||
@@ -2183,6 +2183,157 @@ class AccountCustomClientTest extends Scope
|
||||
$this->assertEquals('tuvwxyz', $response['body']['providerRefreshToken']);
|
||||
$this->assertNotEquals($initialExpiry, $response['body']['providerAccessTokenExpiry']);
|
||||
|
||||
// Clean up - delete the user
|
||||
$response = $this->client->call(Client::METHOD_DELETE, '/users/' . $userId, array_merge([
|
||||
'origin' => 'http://localhost',
|
||||
'content-type' => 'application/json',
|
||||
'x-appwrite-project' => $this->getProject()['$id'],
|
||||
'x-appwrite-key' => $this->getProject()['apiKey'],
|
||||
]));
|
||||
|
||||
$this->assertEquals(204, $response['headers']['status-code']);
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
public function testOAuthUnverifiedEmailCannotLinkToExistingAccount()
|
||||
{
|
||||
$provider = 'mock-unverified';
|
||||
$appId = '1';
|
||||
$secret = '123456';
|
||||
|
||||
// First, create a user with the same email that the unverified OAuth will try to use
|
||||
$email = 'useroauthunverified@localhost.test';
|
||||
$password = 'password';
|
||||
|
||||
$response = $this->client->call(Client::METHOD_POST, '/account', [
|
||||
'origin' => 'http://localhost',
|
||||
'content-type' => 'application/json',
|
||||
'x-appwrite-project' => $this->getProject()['$id'],
|
||||
], [
|
||||
'userId' => ID::unique(),
|
||||
'email' => $email,
|
||||
'password' => $password,
|
||||
]);
|
||||
|
||||
$this->assertEquals(201, $response['headers']['status-code']);
|
||||
$existingUserId = $response['body']['$id'];
|
||||
|
||||
// Enable the mock-unverified provider
|
||||
$response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $this->getProject()['$id'] . '/oauth2', array_merge([
|
||||
'origin' => 'http://localhost',
|
||||
'content-type' => 'application/json',
|
||||
'x-appwrite-project' => 'console',
|
||||
'cookie' => 'a_session_console=' . $this->getRoot()['session'],
|
||||
]), [
|
||||
'provider' => $provider,
|
||||
'appId' => $appId,
|
||||
'secret' => $secret,
|
||||
'enabled' => true,
|
||||
]);
|
||||
|
||||
$this->assertEquals(200, $response['headers']['status-code']);
|
||||
|
||||
// Attempt OAuth login with unverified email - should fail because existing user has same email
|
||||
$response = $this->client->call(Client::METHOD_GET, '/account/sessions/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',
|
||||
]);
|
||||
|
||||
$this->assertEquals(400, $response['headers']['status-code']);
|
||||
$this->assertEquals('failure', $response['body']['result']);
|
||||
|
||||
// Clean up - delete the user
|
||||
$response = $this->client->call(Client::METHOD_DELETE, '/users/' . $existingUserId, array_merge([
|
||||
'origin' => 'http://localhost',
|
||||
'content-type' => 'application/json',
|
||||
'x-appwrite-project' => $this->getProject()['$id'],
|
||||
'x-appwrite-key' => $this->getProject()['apiKey'],
|
||||
]));
|
||||
|
||||
$this->assertEquals(204, $response['headers']['status-code']);
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
public function testOAuthVerifiedEmailCanLinkToExistingAccount()
|
||||
{
|
||||
$provider = 'mock';
|
||||
$appId = '1';
|
||||
$secret = '123456';
|
||||
$email = 'useroauth@localhost.test';
|
||||
|
||||
// Create a user with the same email that the verified OAuth will try to use
|
||||
$response = $this->client->call(Client::METHOD_POST, '/account', [
|
||||
'origin' => 'http://localhost',
|
||||
'content-type' => 'application/json',
|
||||
'x-appwrite-project' => $this->getProject()['$id'],
|
||||
], [
|
||||
'userId' => ID::unique(),
|
||||
'email' => $email,
|
||||
'password' => 'password',
|
||||
]);
|
||||
|
||||
$this->assertEquals(201, $response['headers']['status-code']);
|
||||
$existingUserId = $response['body']['$id'];
|
||||
|
||||
// Enable the mock provider
|
||||
$response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $this->getProject()['$id'] . '/oauth2', array_merge([
|
||||
'origin' => 'http://localhost',
|
||||
'content-type' => 'application/json',
|
||||
'x-appwrite-project' => 'console',
|
||||
'cookie' => 'a_session_console=' . $this->getRoot()['session'],
|
||||
]), [
|
||||
'provider' => $provider,
|
||||
'appId' => $appId,
|
||||
'secret' => $secret,
|
||||
'enabled' => true,
|
||||
]);
|
||||
|
||||
$this->assertEquals(200, $response['headers']['status-code']);
|
||||
|
||||
// Attempt OAuth login with verified email - should succeed and link to existing account
|
||||
$response = $this->client->call(Client::METHOD_GET, '/account/sessions/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',
|
||||
]);
|
||||
|
||||
$this->assertEquals(200, $response['headers']['status-code']);
|
||||
$this->assertEquals('success', $response['body']['result']);
|
||||
|
||||
// Verify the OAuth identity was linked to the existing user
|
||||
$sessionCookieKey = 'a_session_' . $this->getProject()['$id'];
|
||||
$session = $response['cookies'][$sessionCookieKey];
|
||||
|
||||
$response = $this->client->call(Client::METHOD_GET, '/account', array_merge([
|
||||
'origin' => 'http://localhost',
|
||||
'content-type' => 'application/json',
|
||||
'x-appwrite-project' => $this->getProject()['$id'],
|
||||
'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session,
|
||||
]));
|
||||
|
||||
$this->assertEquals(200, $response['headers']['status-code']);
|
||||
$this->assertEquals($existingUserId, $response['body']['$id']);
|
||||
$this->assertEquals($email, $response['body']['email']);
|
||||
|
||||
// Clean up - delete the user
|
||||
$response = $this->client->call(Client::METHOD_DELETE, '/users/' . $existingUserId, array_merge([
|
||||
'origin' => 'http://localhost',
|
||||
'content-type' => 'application/json',
|
||||
'x-appwrite-project' => $this->getProject()['$id'],
|
||||
'x-appwrite-key' => $this->getProject()['apiKey'],
|
||||
]));
|
||||
|
||||
$this->assertEquals(204, $response['headers']['status-code']);
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user