1
0
Fork 0
mirror of synced 2024-06-27 18:50:47 +12:00

Track a user's last activity

A user will have an accessedAt timestamp that will update at most once
per day if they make some API call. This timestamp can then be used
find active users and calculate daily, weekly, and monthly active users.

To ensure consistent updates to the user the $user from the resource
is always updated to the user making the request, including requests
like Create Account, Update Team Membership Status, and Create Phone
Session (confirmation). This ensures the shutdown can update the
accessedAt timestamp if there was a $user set.
This commit is contained in:
Steven Nguyen 2023-07-06 17:12:39 -07:00
parent 5c3f96289d
commit 2befa60350
No known key found for this signature in database
11 changed files with 215 additions and 105 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

@ -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']);