Merge pull request #5781 from appwrite/feat-last-user-activity

Track a user's last activity
This commit is contained in:
Eldad A. Fux 2023-07-20 10:10:32 +03:00 committed by GitHub
commit 4e6e3a0551
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 223 additions and 113 deletions

View file

@ -1463,7 +1463,18 @@ $collections = [
'default' => null,
'array' => false,
'filters' => ['userSearch'],
]
],
[
'$id' => ID::custom('accessedAt'),
'type' => Database::VAR_DATETIME,
'format' => '',
'size' => 0,
'signed' => false,
'required' => false,
'default' => null,
'array' => false,
'filters' => ['datetime'],
],
],
'indexes' => [
[
@ -1528,7 +1539,14 @@ $collections = [
'attributes' => ['search'],
'lengths' => [],
'orders' => [],
]
],
[
'$id' => '_key_accessedAt',
'type' => Database::INDEX_KEY,
'attributes' => ['accessedAt'],
'lengths' => [],
'orders' => [],
],
],
],

View file

@ -70,10 +70,11 @@ App::post('/v1/account')
->param('name', '', new Text(128), 'User name. Max length: 128 chars.', true)
->inject('request')
->inject('response')
->inject('user')
->inject('project')
->inject('dbForProject')
->inject('events')
->action(function (string $userId, string $email, string $password, string $name, Request $request, Response $response, Document $project, Database $dbForProject, Event $events) {
->action(function (string $userId, string $email, string $password, string $name, Request $request, Response $response, Document $user, Document $project, Database $dbForProject, Event $events) {
$email = \strtolower($email);
if ('console' === $project->getId()) {
$whitelistEmails = $project->getAttribute('authWhitelistEmails');
@ -102,7 +103,7 @@ App::post('/v1/account')
$password = Auth::passwordHash($password, Auth::DEFAULT_ALGO, Auth::DEFAULT_ALGO_OPTIONS);
try {
$userId = $userId == 'unique()' ? ID::unique() : $userId;
$user = Authorization::skip(fn() => $dbForProject->createDocument('users', new Document([
$user->setAttributes([
'$id' => $userId,
'$permissions' => [
Permission::read(Role::any()),
@ -124,8 +125,10 @@ App::post('/v1/account')
'sessions' => null,
'tokens' => null,
'memberships' => null,
'search' => implode(' ', [$userId, $email, $name])
])));
'search' => implode(' ', [$userId, $email, $name]),
'accessedAt' => DateTime::now(), // Add this here to make sure it's returned in the response
]);
Authorization::skip(fn() => $dbForProject->createDocument('users', $user));
} catch (Duplicate $th) {
throw new Exception(Exception::USER_ALREADY_EXISTS);
}
@ -166,12 +169,13 @@ App::post('/v1/account/sessions/email')
->param('password', '', new Password(), 'User password. Must be at least 8 chars.')
->inject('request')
->inject('response')
->inject('user')
->inject('dbForProject')
->inject('project')
->inject('locale')
->inject('geodb')
->inject('events')
->action(function (string $email, string $password, Request $request, Response $response, Database $dbForProject, Document $project, Locale $locale, Reader $geodb, Event $events) {
->action(function (string $email, string $password, Request $request, Response $response, Document $user, Database $dbForProject, Document $project, Locale $locale, Reader $geodb, Event $events) {
$email = \strtolower($email);
$protocol = $request->getProtocol();
@ -188,6 +192,8 @@ App::post('/v1/account/sessions/email')
throw new Exception(Exception::USER_BLOCKED); // User is in status blocked
}
$user->setAttributes($profile->getArrayCopy());
$duration = $project->getAttribute('auths', [])['duration'] ?? Auth::TOKEN_EXPIRATION_LOGIN_LONG;
$detector = new Detector($request->getUserAgent('UNKNOWN'));
@ -197,8 +203,8 @@ App::post('/v1/account/sessions/email')
$session = new Document(array_merge(
[
'$id' => ID::unique(),
'userId' => $profile->getId(),
'userInternalId' => $profile->getInternalId(),
'userId' => $user->getId(),
'userInternalId' => $user->getInternalId(),
'provider' => Auth::SESSION_PROVIDER_EMAIL,
'providerUid' => $email,
'secret' => Auth::hash($secret), // One way hash encryption to protect DB leak
@ -211,35 +217,35 @@ App::post('/v1/account/sessions/email')
$detector->getDevice()
));
Authorization::setRole(Role::user($profile->getId())->toString());
Authorization::setRole(Role::user($user->getId())->toString());
// Re-hash if not using recommended algo
if ($profile->getAttribute('hash') !== Auth::DEFAULT_ALGO) {
$profile
if ($user->getAttribute('hash') !== Auth::DEFAULT_ALGO) {
$user
->setAttribute('password', Auth::passwordHash($password, Auth::DEFAULT_ALGO, Auth::DEFAULT_ALGO_OPTIONS))
->setAttribute('hash', Auth::DEFAULT_ALGO)
->setAttribute('hashOptions', Auth::DEFAULT_ALGO_OPTIONS);
$dbForProject->updateDocument('users', $profile->getId(), $profile);
$dbForProject->updateDocument('users', $user->getId(), $user);
}
$dbForProject->deleteCachedDocument('users', $profile->getId());
$dbForProject->deleteCachedDocument('users', $user->getId());
$session = $dbForProject->createDocument('sessions', $session->setAttribute('$permissions', [
Permission::read(Role::user($profile->getId())),
Permission::update(Role::user($profile->getId())),
Permission::delete(Role::user($profile->getId())),
Permission::read(Role::user($user->getId())),
Permission::update(Role::user($user->getId())),
Permission::delete(Role::user($user->getId())),
]));
if (!Config::getParam('domainVerification')) {
$response
->addHeader('X-Fallback-Cookies', \json_encode([Auth::$cookieName => Auth::encodeSession($profile->getId(), $secret)]))
->addHeader('X-Fallback-Cookies', \json_encode([Auth::$cookieName => Auth::encodeSession($user->getId(), $secret)]))
;
}
$response
->addCookie(Auth::$cookieName . '_legacy', Auth::encodeSession($profile->getId(), $secret), (new \DateTime($expire))->getTimestamp(), '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, null)
->addCookie(Auth::$cookieName, Auth::encodeSession($profile->getId(), $secret), (new \DateTime($expire))->getTimestamp(), '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, Config::getParam('cookieSamesite'))
->addCookie(Auth::$cookieName . '_legacy', Auth::encodeSession($user->getId(), $secret), (new \DateTime($expire))->getTimestamp(), '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, null)
->addCookie(Auth::$cookieName, Auth::encodeSession($user->getId(), $secret), (new \DateTime($expire))->getTimestamp(), '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, Config::getParam('cookieSamesite'))
->setStatusCode(Response::STATUS_CODE_CREATED)
;
@ -252,7 +258,7 @@ App::post('/v1/account/sessions/email')
;
$events
->setParam('userId', $profile->getId())
->setParam('userId', $user->getId())
->setParam('sessionId', $session->getId())
;
@ -476,10 +482,15 @@ App::get('/v1/account/sessions/oauth2/:provider/redirect')
}
}
$user = ($user->isEmpty()) ? $dbForProject->findOne('sessions', [ // Get user by provider id
Query::equal('provider', [$provider]),
Query::equal('providerUid', [$oauth2ID]),
]) : $user;
if ($user->isEmpty()) {
$session = $dbForProject->findOne('sessions', [ // Get user by provider id
Query::equal('provider', [$provider]),
Query::equal('providerUid', [$oauth2ID]),
]);
if ($session !== false && !$session->isEmpty()) {
$user->setAttributes($dbForProject->getDocument('users', $session->getAttribute('userId'))->getArrayCopy());
}
}
if ($user === false || $user->isEmpty()) { // No user logged in or with OAuth2 provider ID, create new one or connect with account with same email
$name = $oauth2->getUserName($accessToken);
@ -490,9 +501,12 @@ App::get('/v1/account/sessions/oauth2/:provider/redirect')
*/
$isVerified = $oauth2->isEmailVerified($accessToken);
$user = $dbForProject->findOne('users', [
$userWithEmail = $dbForProject->findOne('users', [
Query::equal('email', [$email]),
]);
if ($userWithEmail !== false && !$userWithEmail->isEmpty()) {
$user->setAttributes($userWithEmail->getArrayCopy());
}
if ($user === false || $user->isEmpty()) { // Last option -> create the user, generate random password
$limit = $project->getAttribute('auths', [])['limit'] ?? 0;
@ -510,7 +524,7 @@ App::get('/v1/account/sessions/oauth2/:provider/redirect')
try {
$userId = ID::unique();
$password = Auth::passwordHash(Auth::passwordGenerator(), Auth::DEFAULT_ALGO, Auth::DEFAULT_ALGO_OPTIONS);
$user = Authorization::skip(fn() => $dbForProject->createDocument('users', new Document([
$user->setAttributes([
'$id' => $userId,
'$permissions' => [
Permission::read(Role::any()),
@ -533,7 +547,8 @@ App::get('/v1/account/sessions/oauth2/:provider/redirect')
'tokens' => null,
'memberships' => null,
'search' => implode(' ', [$userId, $email, $name])
])));
]);
Authorization::skip(fn() => $dbForProject->createDocument('users', $user));
} catch (Duplicate $th) {
throw new Exception(Exception::USER_ALREADY_EXISTS);
}
@ -645,12 +660,13 @@ App::post('/v1/account/sessions/magic-url')
->param('url', '', fn($clients) => new Host($clients), 'URL to redirect the user back to your app from the magic URL login. Only URLs from hostnames in your project platform list are allowed. This requirement helps to prevent an [open redirect](https://cheatsheetseries.owasp.org/cheatsheets/Unvalidated_Redirects_and_Forwards_Cheat_Sheet.html) attack against your project API.', true, ['clients'])
->inject('request')
->inject('response')
->inject('user')
->inject('project')
->inject('dbForProject')
->inject('locale')
->inject('events')
->inject('mails')
->action(function (string $userId, string $email, string $url, Request $request, Response $response, Document $project, Database $dbForProject, Locale $locale, Event $events, Mail $mails) {
->action(function (string $userId, string $email, string $url, Request $request, Response $response, Document $user, Document $project, Database $dbForProject, Locale $locale, Event $events, Mail $mails) {
if (empty(App::getEnv('_APP_SMTP_HOST'))) {
throw new Exception(Exception::GENERAL_SMTP_DISABLED, 'SMTP disabled');
@ -660,9 +676,10 @@ App::post('/v1/account/sessions/magic-url')
$isPrivilegedUser = Auth::isPrivilegedUser($roles);
$isAppUser = Auth::isAppUser($roles);
$user = $dbForProject->findOne('users', [Query::equal('email', [$email])]);
if (!$user) {
$result = $dbForProject->findOne('users', [Query::equal('email', [$email])]);
if ($result !== false && !$result->isEmpty()) {
$user->setAttributes($result->getArrayCopy());
} else {
$limit = $project->getAttribute('auths', [])['limit'] ?? 0;
if ($limit !== 0) {
@ -675,7 +692,7 @@ App::post('/v1/account/sessions/magic-url')
$userId = $userId == 'unique()' ? ID::unique() : $userId;
$user = Authorization::skip(fn () => $dbForProject->createDocument('users', new Document([
$user->setAttributes([
'$id' => $userId,
'$permissions' => [
Permission::read(Role::any()),
@ -696,7 +713,9 @@ App::post('/v1/account/sessions/magic-url')
'tokens' => null,
'memberships' => null,
'search' => implode(' ', [$userId, $email])
])));
]);
Authorization::skip(fn () => $dbForProject->createDocument('users', $user));
}
$loginSecret = Auth::tokenGenerator();
@ -808,27 +827,30 @@ App::put('/v1/account/sessions/magic-url')
->param('secret', '', new Text(256), 'Valid verification token.')
->inject('request')
->inject('response')
->inject('user')
->inject('dbForProject')
->inject('project')
->inject('locale')
->inject('geodb')
->inject('events')
->action(function (string $userId, string $secret, Request $request, Response $response, Database $dbForProject, Document $project, Locale $locale, Reader $geodb, Event $events) {
->action(function (string $userId, string $secret, Request $request, Response $response, Document $user, Database $dbForProject, Document $project, Locale $locale, Reader $geodb, Event $events) {
/** @var Utopia\Database\Document $user */
$user = Authorization::skip(fn() => $dbForProject->getDocument('users', $userId));
$userFromRequest = Authorization::skip(fn() => $dbForProject->getDocument('users', $userId));
if ($user->isEmpty()) {
if ($userFromRequest->isEmpty()) {
throw new Exception(Exception::USER_NOT_FOUND);
}
$token = Auth::tokenVerify($user->getAttribute('tokens', []), Auth::TOKEN_TYPE_MAGIC_URL, $secret);
$token = Auth::tokenVerify($userFromRequest->getAttribute('tokens', []), Auth::TOKEN_TYPE_MAGIC_URL, $secret);
if (!$token) {
throw new Exception(Exception::USER_INVALID_TOKEN);
}
$user->setAttributes($userFromRequest->getArrayCopy());
$duration = $project->getAttribute('auths', [])['duration'] ?? Auth::TOKEN_EXPIRATION_LOGIN_LONG;
$detector = new Detector($request->getUserAgent('UNKNOWN'));
$record = $geodb->get($request->getIP());
@ -873,9 +895,9 @@ App::put('/v1/account/sessions/magic-url')
$user->setAttribute('emailVerification', true);
$user = $dbForProject->updateDocument('users', $user->getId(), $user);
if (false === $user) {
try {
$dbForProject->updateDocument('users', $user->getId(), $user);
} catch (\Throwable $th) {
throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed saving user to DB');
}
@ -928,12 +950,13 @@ App::post('/v1/account/sessions/phone')
->param('phone', '', new Phone(), 'Phone number. Format this number with a leading \'+\' and a country code, e.g., +16175551212.')
->inject('request')
->inject('response')
->inject('user')
->inject('project')
->inject('dbForProject')
->inject('events')
->inject('messaging')
->inject('locale')
->action(function (string $userId, string $phone, Request $request, Response $response, Document $project, Database $dbForProject, Event $events, EventPhone $messaging, Locale $locale) {
->action(function (string $userId, string $phone, Request $request, Response $response, Document $user, Document $project, Database $dbForProject, Event $events, EventPhone $messaging, Locale $locale) {
if (empty(App::getEnv('_APP_SMS_PROVIDER'))) {
throw new Exception(Exception::GENERAL_PHONE_DISABLED, 'Phone provider not configured');
@ -943,9 +966,10 @@ App::post('/v1/account/sessions/phone')
$isPrivilegedUser = Auth::isPrivilegedUser($roles);
$isAppUser = Auth::isAppUser($roles);
$user = $dbForProject->findOne('users', [Query::equal('phone', [$phone])]);
if (!$user) {
$result = $dbForProject->findOne('users', [Query::equal('phone', [$phone])]);
if ($result !== false && !$result->isEmpty()) {
$user->setAttributes($result->getArrayCopy());
} else {
$limit = $project->getAttribute('auths', [])['limit'] ?? 0;
if ($limit !== 0) {
@ -957,8 +981,7 @@ App::post('/v1/account/sessions/phone')
}
$userId = $userId == 'unique()' ? ID::unique() : $userId;
$user = Authorization::skip(fn () => $dbForProject->createDocument('users', new Document([
$user->setAttributes([
'$id' => $userId,
'$permissions' => [
Permission::read(Role::any()),
@ -979,7 +1002,9 @@ App::post('/v1/account/sessions/phone')
'tokens' => null,
'memberships' => null,
'search' => implode(' ', [$userId, $phone])
])));
]);
Authorization::skip(fn () => $dbForProject->createDocument('users', $user));
}
$secret = Auth::codeGenerator();
@ -1058,25 +1083,28 @@ App::put('/v1/account/sessions/phone')
->param('secret', '', new Text(256), 'Valid verification token.')
->inject('request')
->inject('response')
->inject('user')
->inject('dbForProject')
->inject('project')
->inject('locale')
->inject('geodb')
->inject('events')
->action(function (string $userId, string $secret, Request $request, Response $response, Database $dbForProject, Document $project, Locale $locale, Reader $geodb, Event $events) {
->action(function (string $userId, string $secret, Request $request, Response $response, Document $user, Database $dbForProject, Document $project, Locale $locale, Reader $geodb, Event $events) {
$user = Authorization::skip(fn() => $dbForProject->getDocument('users', $userId));
$userFromRequest = Authorization::skip(fn() => $dbForProject->getDocument('users', $userId));
if ($user->isEmpty()) {
if ($userFromRequest->isEmpty()) {
throw new Exception(Exception::USER_NOT_FOUND);
}
$token = Auth::phoneTokenVerify($user->getAttribute('tokens', []), $secret);
$token = Auth::phoneTokenVerify($userFromRequest->getAttribute('tokens', []), $secret);
if (!$token) {
throw new Exception(Exception::USER_INVALID_TOKEN);
}
$user->setAttributes($userFromRequest->getArrayCopy());
$duration = $project->getAttribute('auths', [])['duration'] ?? Auth::TOKEN_EXPIRATION_LOGIN_LONG;
$detector = new Detector($request->getUserAgent('UNKNOWN'));
$record = $geodb->get($request->getIP());
@ -1119,7 +1147,7 @@ App::put('/v1/account/sessions/phone')
$user->setAttribute('phoneVerification', true);
$user = $dbForProject->updateDocument('users', $user->getId(), $user);
$dbForProject->updateDocument('users', $user->getId(), $user);
if (false === $user) {
throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed saving user to DB');
@ -1204,7 +1232,7 @@ App::post('/v1/account/sessions/anonymous')
}
$userId = ID::unique();
$user = Authorization::skip(fn() => $dbForProject->createDocument('users', new Document([
$user->setAttributes([
'$id' => $userId,
'$permissions' => [
Permission::read(Role::any()),
@ -1225,8 +1253,9 @@ App::post('/v1/account/sessions/anonymous')
'sessions' => null,
'tokens' => null,
'memberships' => null,
'search' => $userId
])));
'search' => $userId,
]);
Authorization::skip(fn() => $dbForProject->createDocument('users', $user));
// Create session token
$duration = $project->getAttribute('auths', [])['duration'] ?? Auth::TOKEN_EXPIRATION_LOGIN_LONG;
@ -2068,12 +2097,13 @@ App::post('/v1/account/recovery')
->param('url', '', fn ($clients) => new Host($clients), 'URL to redirect the user back to your app from the recovery email. Only URLs from hostnames in your project platform list are allowed. This requirement helps to prevent an [open redirect](https://cheatsheetseries.owasp.org/cheatsheets/Unvalidated_Redirects_and_Forwards_Cheat_Sheet.html) attack against your project API.', false, ['clients'])
->inject('request')
->inject('response')
->inject('user')
->inject('dbForProject')
->inject('project')
->inject('locale')
->inject('mails')
->inject('events')
->action(function (string $email, string $url, Request $request, Response $response, Database $dbForProject, Document $project, Locale $locale, Mail $mails, Event $events) {
->action(function (string $email, string $url, Request $request, Response $response, Document $user, Database $dbForProject, Document $project, Locale $locale, Mail $mails, Event $events) {
if (empty(App::getEnv('_APP_SMTP_HOST'))) {
throw new Exception(Exception::GENERAL_SMTP_DISABLED, 'SMTP Disabled');
@ -2093,6 +2123,8 @@ App::post('/v1/account/recovery')
throw new Exception(Exception::USER_NOT_FOUND);
}
$user->setAttributes($profile->getArrayCopy());
if (false === $profile->getAttribute('status')) { // Account is blocked
throw new Exception(Exception::USER_BLOCKED);
}
@ -2207,9 +2239,10 @@ App::put('/v1/account/recovery')
->param('password', '', new Password(), 'New user password. Must be at least 8 chars.')
->param('passwordAgain', '', new Password(), 'Repeat new user password. Must be at least 8 chars.')
->inject('response')
->inject('user')
->inject('dbForProject')
->inject('events')
->action(function (string $userId, string $secret, string $password, string $passwordAgain, Response $response, Database $dbForProject, Event $events) {
->action(function (string $userId, string $secret, string $password, string $passwordAgain, Response $response, Document $user, Database $dbForProject, Event $events) {
if ($password !== $passwordAgain) {
throw new Exception(Exception::USER_PASSWORD_MISMATCH);
}
@ -2236,6 +2269,8 @@ App::put('/v1/account/recovery')
->setAttribute('hashOptions', Auth::DEFAULT_ALGO_OPTIONS)
->setAttribute('emailVerification', true));
$user->setAttributes($profile->getArrayCopy());
$recoveryDocument = $dbForProject->getDocument('tokens', $recovery);
/**
@ -2417,6 +2452,8 @@ App::put('/v1/account/verification')
$profile = $dbForProject->updateDocument('users', $profile->getId(), $profile->setAttribute('emailVerification', true));
$user->setAttributes($profile->getArrayCopy());
$verificationDocument = $dbForProject->getDocument('tokens', $verification);
/**
@ -2573,6 +2610,8 @@ App::put('/v1/account/verification/phone')
$profile = $dbForProject->updateDocument('users', $profile->getId(), $profile->setAttribute('phoneVerification', true));
$user->setAttributes($profile->getArrayCopy());
$verificationDocument = $dbForProject->getDocument('tokens', $verification);
/**

View file

@ -867,7 +867,7 @@ App::patch('/v1/teams/:teamId/memberships/:membershipId/status')
}
if ($user->isEmpty()) {
$user = $dbForProject->getDocument('users', $userId); // Get user
$user->setAttributes($dbForProject->getDocument('users', $userId)->getArrayCopy()); // Get user
}
if ($membership->getAttribute('userId') !== $user->getId()) {
@ -883,7 +883,7 @@ App::patch('/v1/teams/:teamId/memberships/:membershipId/status')
->setAttribute('confirm', true)
;
$user = Authorization::skip(fn() => $dbForProject->updateDocument('users', $user->getId(), $user->setAttribute('emailVerification', true)));
Authorization::skip(fn() => $dbForProject->updateDocument('users', $user->getId(), $user->setAttribute('emailVerification', true)));
// Log user in

View file

@ -169,9 +169,9 @@ App::init()
}
}
/*
* Background Jobs
*/
/*
* Background Jobs
*/
$events
->setEvent($route->getLabel('event', ''))
->setProject($project)
@ -369,6 +369,7 @@ App::shutdown()
->inject('request')
->inject('response')
->inject('project')
->inject('user')
->inject('events')
->inject('audits')
->inject('usage')
@ -376,8 +377,8 @@ App::shutdown()
->inject('database')
->inject('mode')
->inject('dbForProject')
->action(function (App $utopia, Request $request, Response $response, Document $project, Event $events, Audit $audits, Stats $usage, Delete $deletes, EventDatabase $database, string $mode, Database $dbForProject) use ($parseLabel) {
->inject('dbForConsole')
->action(function (App $utopia, Request $request, Response $response, Document $project, Document $user, Event $events, Audit $audits, Stats $usage, Delete $deletes, EventDatabase $database, string $mode, Database $dbForProject, Database $dbForConsole) use ($parseLabel) {
$responsePayload = $response->getPayload();
if (!empty($events->getEvent())) {
@ -437,7 +438,6 @@ App::shutdown()
$route = $utopia->match($request);
$requestParams = $route->getParamsValues();
$user = $audits->getUser();
/**
* Audit labels
@ -450,10 +450,7 @@ App::shutdown()
}
}
$pattern = $route->getLabel('audits.userId', null);
if (!empty($pattern)) {
$userId = $parseLabel($pattern, $responsePayload, $requestParams, $user);
$user = $dbForProject->getDocument('users', $userId);
if (!$user->isEmpty()) {
$audits->setUser($user);
}
@ -564,6 +561,22 @@ App::shutdown()
->setParam('project.{scope}.network.outbound', $response->getSize())
->submit();
}
/**
* Update user last activity
*/
if (!$user->isEmpty()) {
$accessedAt = $user->getAttribute('accessedAt', '');
if (DateTime::formatTz(DateTime::addSeconds(new \DateTime(), -APP_USER_ACCCESS)) > $accessedAt) {
$user->setAttribute('accessedAt', DateTime::now());
if (APP_MODE_ADMIN !== $mode) {
$dbForProject->updateDocument('users', $user->getId(), $user);
} else {
$dbForConsole->updateDocument('users', $user->getId(), $user);
}
}
}
});
App::init()

View file

@ -100,6 +100,7 @@ const APP_LIMIT_WRITE_RATE_DEFAULT = 60; // Default maximum write rate per rate
const APP_LIMIT_WRITE_RATE_PERIOD_DEFAULT = 60; // Default maximum write rate period in seconds
const APP_LIMIT_LIST_DEFAULT = 25; // Default maximum number of items to return in list API calls
const APP_KEY_ACCCESS = 24 * 60 * 60; // 24 hours
const APP_USER_ACCCESS = 24 * 60 * 60; // 24 hours
const APP_CACHE_UPDATE = 24 * 60 * 60; // 24 hours
const APP_CACHE_BUSTER = 506;
const APP_VERSION_STABLE = '1.3.7';

View file

@ -43,13 +43,13 @@
"ext-sockets": "*",
"appwrite/php-clamav": "1.1.*",
"appwrite/php-runtimes": "0.11.*",
"utopia-php/abuse": "0.26.*",
"utopia-php/abuse": "0.27.*",
"utopia-php/analytics": "0.2.*",
"utopia-php/audit": "0.28.*",
"utopia-php/audit": "0.29.*",
"utopia-php/cache": "0.8.*",
"utopia-php/cli": "0.13.*",
"utopia-php/config": "0.2.*",
"utopia-php/database": "0.37.*",
"utopia-php/database": "0.38.*",
"utopia-php/domains": "1.1.*",
"utopia-php/framework": "0.28.*",
"utopia-php/image": "0.5.*",

56
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": "0f20fb41e9b250b6763af1b734bb8d2d",
"content-hash": "0299a46dacaa5ae6607931f91b459db4",
"packages": [
{
"name": "adhocore/jwt",
@ -1802,23 +1802,23 @@
},
{
"name": "utopia-php/abuse",
"version": "0.26.0",
"version": "0.27.0",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/abuse.git",
"reference": "fb73180f0588bc8826b85d433393b983bdc37cfa"
"reference": "d1115f5843e903ffaba9c23e450b33c0fe265ae0"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/utopia-php/abuse/zipball/fb73180f0588bc8826b85d433393b983bdc37cfa",
"reference": "fb73180f0588bc8826b85d433393b983bdc37cfa",
"url": "https://api.github.com/repos/utopia-php/abuse/zipball/d1115f5843e903ffaba9c23e450b33c0fe265ae0",
"reference": "d1115f5843e903ffaba9c23e450b33c0fe265ae0",
"shasum": ""
},
"require": {
"ext-curl": "*",
"ext-pdo": "*",
"php": ">=8.0",
"utopia-php/database": "0.37.*"
"utopia-php/database": "0.38.*"
},
"require-dev": {
"laravel/pint": "1.5.*",
@ -1845,9 +1845,9 @@
],
"support": {
"issues": "https://github.com/utopia-php/abuse/issues",
"source": "https://github.com/utopia-php/abuse/tree/0.26.0"
"source": "https://github.com/utopia-php/abuse/tree/0.27.0"
},
"time": "2023-06-15T00:53:36+00:00"
"time": "2023-07-15T00:53:50+00:00"
},
{
"name": "utopia-php/analytics",
@ -1906,21 +1906,21 @@
},
{
"name": "utopia-php/audit",
"version": "0.28.0",
"version": "0.29.0",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/audit.git",
"reference": "abf4124bec20b6ab3555869b64afe5b274e37165"
"reference": "5318538f457bf73623629345c98ea06371ca5dd4"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/utopia-php/audit/zipball/abf4124bec20b6ab3555869b64afe5b274e37165",
"reference": "abf4124bec20b6ab3555869b64afe5b274e37165",
"url": "https://api.github.com/repos/utopia-php/audit/zipball/5318538f457bf73623629345c98ea06371ca5dd4",
"reference": "5318538f457bf73623629345c98ea06371ca5dd4",
"shasum": ""
},
"require": {
"php": ">=8.0",
"utopia-php/database": "0.37.*"
"utopia-php/database": "0.38.*"
},
"require-dev": {
"laravel/pint": "1.5.*",
@ -1947,9 +1947,9 @@
],
"support": {
"issues": "https://github.com/utopia-php/audit/issues",
"source": "https://github.com/utopia-php/audit/tree/0.28.0"
"source": "https://github.com/utopia-php/audit/tree/0.29.0"
},
"time": "2023-06-15T00:52:49+00:00"
"time": "2023-07-15T00:51:10+00:00"
},
{
"name": "utopia-php/cache",
@ -2106,16 +2106,16 @@
},
{
"name": "utopia-php/database",
"version": "0.37.1",
"version": "0.38.0",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/database.git",
"reference": "4035d3f7e3393385eabc7816055047659c6fb4d3"
"reference": "59e4684cf87e03c12dab9240158c1dfc6888e534"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/utopia-php/database/zipball/4035d3f7e3393385eabc7816055047659c6fb4d3",
"reference": "4035d3f7e3393385eabc7816055047659c6fb4d3",
"url": "https://api.github.com/repos/utopia-php/database/zipball/59e4684cf87e03c12dab9240158c1dfc6888e534",
"reference": "59e4684cf87e03c12dab9240158c1dfc6888e534",
"shasum": ""
},
"require": {
@ -2156,9 +2156,9 @@
],
"support": {
"issues": "https://github.com/utopia-php/database/issues",
"source": "https://github.com/utopia-php/database/tree/0.37.1"
"source": "https://github.com/utopia-php/database/tree/0.38.0"
},
"time": "2023-06-15T06:36:27+00:00"
"time": "2023-07-14T07:49:38+00:00"
},
{
"name": "utopia-php/domains",
@ -3029,16 +3029,16 @@
"packages-dev": [
{
"name": "appwrite/sdk-generator",
"version": "0.33.6",
"version": "0.33.7",
"source": {
"type": "git",
"url": "https://github.com/appwrite/sdk-generator.git",
"reference": "237fe97b68090a244382c36f96482c352880a38c"
"reference": "9f5db4a637b23879ceacea9ed2d33b0486771ffc"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/appwrite/sdk-generator/zipball/237fe97b68090a244382c36f96482c352880a38c",
"reference": "237fe97b68090a244382c36f96482c352880a38c",
"url": "https://api.github.com/repos/appwrite/sdk-generator/zipball/9f5db4a637b23879ceacea9ed2d33b0486771ffc",
"reference": "9f5db4a637b23879ceacea9ed2d33b0486771ffc",
"shasum": ""
},
"require": {
@ -3074,9 +3074,9 @@
"description": "Appwrite PHP library for generating API SDKs for multiple programming languages and platforms",
"support": {
"issues": "https://github.com/appwrite/sdk-generator/issues",
"source": "https://github.com/appwrite/sdk-generator/tree/0.33.6"
"source": "https://github.com/appwrite/sdk-generator/tree/0.33.7"
},
"time": "2023-07-10T16:27:53+00:00"
"time": "2023-07-12T12:15:43+00:00"
},
{
"name": "doctrine/deprecations",
@ -5674,5 +5674,5 @@
"platform-overrides": {
"php": "8.0"
},
"plugin-api-version": "2.2.0"
"plugin-api-version": "2.3.0"
}

View file

@ -429,7 +429,7 @@ class Response extends SwooleResponse
*/
public function dynamic(Document $document, string $model): void
{
$output = $this->output($document, $model);
$output = $this->output(new Document($document->getArrayCopy()), $model);
// If filter is set, parse the output
if (self::hasFilter()) {
@ -470,14 +470,14 @@ class Response extends SwooleResponse
*/
public function output(Document $document, string $model): array
{
$data = $document;
$data = new Document($document->getArrayCopy());
$model = $this->getModel($model);
$output = [];
$document = $model->filter($document);
$data = $model->filter($document);
if ($model->isAny()) {
$this->payload = $document->getArrayCopy();
$this->payload = $data->getArrayCopy();
return $this->payload;
}
@ -485,18 +485,18 @@ class Response extends SwooleResponse
foreach ($model->getRules() as $key => $rule) {
if (!$document->isSet($key) && $rule['required']) { // do not set attribute in response if not required
if (\array_key_exists('default', $rule)) {
$document->setAttribute($key, $rule['default']);
$data->setAttribute($key, $rule['default']);
} else {
throw new Exception('Model ' . $model->getName() . ' is missing response key: ' . $key);
}
}
if ($rule['array']) {
if (!is_array($data[$key])) {
if (!is_array($document[$key])) {
throw new Exception($key . ' must be an array of type ' . $rule['type']);
}
foreach ($data[$key] as $index => $item) {
foreach ($document[$key] as $index => $item) {
if ($item instanceof Document) {
if (\is_array($rule['type'])) {
foreach ($rule['type'] as $type) {
@ -524,7 +524,7 @@ class Response extends SwooleResponse
}
}
} else {
if ($data[$key] instanceof Document) {
if ($document[$key] instanceof Document) {
$data[$key] = $this->output($data[$key], $rule['type']);
}
}

View file

@ -120,6 +120,12 @@ class User extends Model
'default' => new \stdClass(),
'example' => ['theme' => 'pink', 'timezone' => 'UTC'],
])
->addRule('accessedAt', [
'type' => self::TYPE_DATETIME,
'description' => 'Most recent access date in ISO 8601 format.',
'default' => '',
'example' => self::TYPE_DATETIME_EXAMPLE,
])
;
}

View file

@ -40,6 +40,8 @@ trait AccountBase
$this->assertEquals($response['body']['email'], $email);
$this->assertEquals($response['body']['name'], $name);
$this->assertEquals($response['body']['labels'], []);
$this->assertArrayHasKey('accessedAt', $response['body']);
$this->assertNotEmpty($response['body']['accessedAt']);
/**
* Test for FAILURE
@ -127,6 +129,21 @@ trait AccountBase
$sessionId = $response['body']['$id'];
$session = $this->client->parseCookie((string)$response['headers']['set-cookie'])['a_session_' . $this->getProject()['$id']];
// apiKey is only available in custom client test
$apiKey = $this->getProject()['apiKey'];
if (!empty($apiKey)) {
$userId = $response['body']['userId'];
$response = $this->client->call(Client::METHOD_GET, '/users/' . $userId, array_merge([
'origin' => 'http://localhost',
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $apiKey,
]));
$this->assertEquals($response['headers']['status-code'], 200);
$this->assertArrayHasKey('accessedAt', $response['body']);
$this->assertNotEmpty($response['body']['accessedAt']);
}
$response = $this->client->call(Client::METHOD_POST, '/account/sessions', array_merge([
'origin' => 'http://localhost',
'content-type' => 'application/json',
@ -207,6 +224,8 @@ trait AccountBase
$this->assertEquals(true, $dateValidator->isValid($response['body']['registration']));
$this->assertEquals($response['body']['email'], $email);
$this->assertEquals($response['body']['name'], $name);
$this->assertArrayHasKey('accessedAt', $response['body']);
$this->assertNotEmpty($response['body']['accessedAt']);
/**
* Test for FAILURE

View file

@ -369,6 +369,20 @@ class AccountCustomClientTest extends Scope
$session = $this->client->parseCookie((string)$response['headers']['set-cookie'])['a_session_' . $this->getProject()['$id']];
\usleep(1000 * 30); // wait for 30ms to let the shutdown update accessedAt
$apiKey = $this->getProject()['apiKey'];
$userId = $response['body']['userId'];
$response = $this->client->call(Client::METHOD_GET, '/users/' . $userId, array_merge([
'origin' => 'http://localhost',
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $apiKey,
]));
$this->assertEquals($response['headers']['status-code'], 200);
$this->assertArrayHasKey('accessedAt', $response['body']);
$this->assertNotEmpty($response['body']['accessedAt']);
/**
* Test for FAILURE
*/

View file

@ -57,7 +57,7 @@ class WebhooksCustomClientTest extends Scope
$this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Signature'], $signatureExpected);
$this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Id'] ?? '', $this->getProject()['webhookId']);
$this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Project-Id'] ?? '', $this->getProject()['$id']);
$this->assertEquals(empty($webhook['headers']['X-Appwrite-Webhook-User-Id']), true);
$this->assertEquals($webhook['headers']['X-Appwrite-Webhook-User-Id'], $id);
$this->assertNotEmpty($webhook['data']['$id']);
$this->assertEquals($webhook['data']['name'], $name);
$dateValidator = new DatetimeValidator();
@ -195,7 +195,7 @@ class WebhooksCustomClientTest extends Scope
$this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Signature'], $signatureExpected);
$this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Id'] ?? '', $this->getProject()['webhookId']);
$this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Project-Id'] ?? '', $this->getProject()['$id']);
$this->assertEquals(empty($webhook['headers']['X-Appwrite-Webhook-User-Id']), true);
$this->assertEquals($webhook['headers']['X-Appwrite-Webhook-User-Id'], $id);
$this->assertNotEmpty($webhook['data']['$id']);
$this->assertNotEmpty($webhook['data']['userId']);
$dateValidator = new DatetimeValidator();
@ -744,7 +744,7 @@ class WebhooksCustomClientTest extends Scope
$this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Signature'], $signatureExpected);
$this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Id'] ?? '', $this->getProject()['webhookId']);
$this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Project-Id'] ?? '', $this->getProject()['$id']);
$this->assertEquals(empty($webhook['headers']['X-Appwrite-Webhook-User-Id']), true);
$this->assertEquals($webhook['headers']['X-Appwrite-Webhook-User-Id'], $id);
$this->assertNotEmpty($webhook['data']['$id']);
$this->assertNotEmpty($webhook['data']['userId']);
$this->assertNotEmpty($webhook['data']['secret']);
@ -923,7 +923,7 @@ class WebhooksCustomClientTest extends Scope
$this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Signature'], $signatureExpected);
$this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Id'] ?? '', $this->getProject()['webhookId']);
$this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Project-Id'] ?? '', $this->getProject()['$id']);
$this->assertEquals(empty($webhook['headers']['X-Appwrite-Webhook-User-Id'] ?? ''), true);
$this->assertEquals($webhook['headers']['X-Appwrite-Webhook-User-Id'] ?? '', $userUid);
$this->assertNotEmpty($webhook['data']['$id']);
$this->assertNotEmpty($webhook['data']['userId']);
$this->assertNotEmpty($webhook['data']['teamId']);