Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 2s
2338 lines
66 KiB
TypeScript
2338 lines
66 KiB
TypeScript
import { MeteorError, Presence, Team } from '@rocket.chat/core-services';
|
|
import type { IExportOperation, ILoginToken, IPersonalAccessToken, IUser, UserStatus } from '@rocket.chat/core-typings';
|
|
import { Users, Subscriptions, Sessions, OAuthAccessTokens, OAuthRefreshTokens, OAuthAuthCodes } from '@rocket.chat/models';
|
|
import {
|
|
isUserCreateParamsPOST,
|
|
isUserSetActiveStatusParamsPOST,
|
|
isUserDeactivateIdleParamsPOST,
|
|
isUsersInfoParamsGetProps,
|
|
isUsersListStatusProps,
|
|
isUsersSendWelcomeEmailProps,
|
|
isUserRegisterParamsPOST,
|
|
isUserLogoutParamsPOST,
|
|
isUsersListTeamsProps,
|
|
isUsersAutocompleteProps,
|
|
isUsersSetAvatarProps,
|
|
isUsersUpdateParamsPOST,
|
|
isUsersUpdateOwnBasicInfoParamsPOST,
|
|
isUsersSetPreferencesParamsPOST,
|
|
isUsersCheckUsernameAvailabilityParamsGET,
|
|
isUsersSendConfirmationEmailParamsPOST,
|
|
isUsersListParamsGET,
|
|
isUsersPresenceParamsGET,
|
|
isUsersRequestDataDownloadParamsGET,
|
|
isUsersGetPresenceParamsGET,
|
|
isUsersGetStatusParamsGET,
|
|
ajv,
|
|
validateBadRequestErrorResponse,
|
|
validateUnauthorizedErrorResponse,
|
|
validateForbiddenErrorResponse,
|
|
} from '@rocket.chat/rest-typings';
|
|
import { escapeRegExp, getLoginExpirationInMs } from '@rocket.chat/tools';
|
|
import { Accounts } from 'meteor/accounts-base';
|
|
import { Match, check } from 'meteor/check';
|
|
import { Meteor } from 'meteor/meteor';
|
|
import type { Filter } from 'mongodb';
|
|
|
|
import { generatePersonalAccessTokenOfUser } from '../../../imports/personal-access-tokens/server/api/methods/generateToken';
|
|
import { regeneratePersonalAccessTokenOfUser } from '../../../imports/personal-access-tokens/server/api/methods/regenerateToken';
|
|
import { removePersonalAccessTokenOfUser } from '../../../imports/personal-access-tokens/server/api/methods/removeToken';
|
|
import { runUserLogoutCleanUp } from '../../hooks/userLogoutCleanUp';
|
|
import { getUserForCheck, emailCheck } from '../../lib/2fa/code';
|
|
import { resetTOTP } from '../../lib/2fa/functions/resetTOTP';
|
|
import { codesRemainingTotp, disableTotp, enableTotp, regenerateTotpCodes, validateTotpTempToken } from '../../lib/2fa/functions/totp';
|
|
import { UserChangedAuditStore } from '../../lib/auditServerEvents/userChanged';
|
|
import { hasPermissionAsync } from '../../lib/authorization/hasPermission';
|
|
import { i18n } from '../../lib/i18n';
|
|
import { SystemLogger } from '../../lib/logger/system';
|
|
import { notifyOnUserChange, notifyOnUserChangeAsync } from '../../lib/notifyListener';
|
|
import { resetUserE2EEncriptionKey } from '../../lib/resetUserE2EKey';
|
|
import { validateNameChars } from '../../lib/shared/validateNameChars';
|
|
import { getUsersHiddenFrom, filterHiddenUsers, redactHiddenUsers } from '../../lib/statusVisibility/hiddenUsers';
|
|
import { redactStatus } from '../../lib/statusVisibility/redactStatus';
|
|
import { resolveUsersByIds } from '../../lib/statusVisibility/resolveUsers';
|
|
import { checkEmailAvailability } from '../../lib/users/checkEmailAvailability';
|
|
import { checkUsernameAvailability, checkUsernameAvailabilityWithValidation } from '../../lib/users/checkUsernameAvailability';
|
|
import { deleteUser } from '../../lib/users/deleteUser';
|
|
import { getAvatarSuggestionForUser } from '../../lib/users/getAvatarSuggestionForUser';
|
|
import { getFullUserDataByUniqueSearchTerm, defaultFields, fullFields } from '../../lib/users/getFullUserData';
|
|
import { generateUsernameSuggestion } from '../../lib/users/getUsernameSuggestion';
|
|
import { runAfterVerifyEmail } from '../../lib/users/runAfterVerifyEmail';
|
|
import { saveCustomFields } from '../../lib/users/saveCustomFields';
|
|
import { saveCustomFieldsWithoutValidation } from '../../lib/users/saveCustomFieldsWithoutValidation';
|
|
import { saveUser } from '../../lib/users/saveUser';
|
|
import { sendWelcomeEmail } from '../../lib/users/saveUser/sendUserEmail';
|
|
import { canEditExtension } from '../../lib/users/saveUser/validateUserEditing';
|
|
import { setUserAvatar } from '../../lib/users/setUserAvatar';
|
|
import { setUsernameWithValidation } from '../../lib/users/setUsername';
|
|
import { validateCustomFields } from '../../lib/users/validateCustomFields';
|
|
import { validateUsername } from '../../lib/users/validateUsername';
|
|
import { verifyEmail } from '../../lib/users/verifyEmail';
|
|
import { isSMTPConfigured } from '../../lib/utils/functions/isSMTPConfigured';
|
|
import { getURL } from '../../lib/utils/getURL';
|
|
import { generateAccessToken } from '../../meteor-methods/auth/createToken';
|
|
import { sendConfirmationEmail } from '../../meteor-methods/auth/sendConfirmationEmail';
|
|
import { sendForgotPasswordEmail } from '../../meteor-methods/auth/sendForgotPasswordEmail';
|
|
import { requestDataDownload } from '../../meteor-methods/platform/requestDataDownload';
|
|
import { deleteUserOwnAccount } from '../../meteor-methods/users/deleteUserOwnAccount';
|
|
import { registerUser } from '../../meteor-methods/users/registerUser';
|
|
import { resetAvatar } from '../../meteor-methods/users/resetAvatar';
|
|
import { saveUserPreferences } from '../../meteor-methods/users/saveUserPreferences';
|
|
import { executeSaveUserProfile } from '../../meteor-methods/users/saveUserProfile';
|
|
import { executeSetUserActiveStatus } from '../../meteor-methods/users/setUserActiveStatus';
|
|
import { settings } from '../../settings';
|
|
import type { ExtractRoutesFromAPI } from '../ApiClass';
|
|
import { API } from '../api';
|
|
import { getPaginationItems } from '../lib/getPaginationItems';
|
|
import { getUploadFormData } from '../lib/getUploadFormData';
|
|
import { getUserFromParams } from '../lib/getUserFromParams';
|
|
import { getUserInfo } from '../lib/getUserInfo';
|
|
import { isUserFromParams } from '../lib/isUserFromParams';
|
|
import { isValidQuery } from '../lib/isValidQuery';
|
|
import { queryFiltersStatus } from '../lib/queryFiltersStatus';
|
|
import { findPaginatedUsersByStatus, findUsersToAutocomplete, getInclusiveFields, getNonEmptyFields, getNonEmptyQuery } from '../lib/users';
|
|
|
|
API.v1.addRoute(
|
|
'users.getAvatar',
|
|
{ authRequired: true },
|
|
{
|
|
async get() {
|
|
const user = await getUserFromParams(this.queryParams);
|
|
|
|
const url = getURL(`/avatar/${user.username}`, { cdn: false, full: true });
|
|
this.response.headers.set('Location', url);
|
|
|
|
return {
|
|
statusCode: 307,
|
|
body: url,
|
|
};
|
|
},
|
|
},
|
|
);
|
|
|
|
const voidSuccessResponse = ajv.compile<void>({
|
|
type: 'object',
|
|
properties: {
|
|
success: { type: 'boolean', enum: [true] },
|
|
},
|
|
required: ['success'],
|
|
additionalProperties: false,
|
|
});
|
|
|
|
// user shape varies by projection and permissions — use $ref when IUser is available in typia
|
|
const userObjectResponse = ajv.compile<{ user: object }>({
|
|
type: 'object',
|
|
properties: {
|
|
user: { type: 'object' },
|
|
success: { type: 'boolean', enum: [true] },
|
|
},
|
|
required: ['user', 'success'],
|
|
additionalProperties: false,
|
|
});
|
|
|
|
API.v1.post(
|
|
'users.update',
|
|
{
|
|
authRequired: true,
|
|
twoFactorRequired: true,
|
|
body: isUsersUpdateParamsPOST,
|
|
response: {
|
|
200: userObjectResponse,
|
|
400: validateBadRequestErrorResponse,
|
|
401: validateUnauthorizedErrorResponse,
|
|
},
|
|
},
|
|
async function action() {
|
|
const userData = { _id: this.bodyParams.userId, ...this.bodyParams.data };
|
|
|
|
if (userData.name && !validateNameChars(userData.name)) {
|
|
return API.v1.failure('Name contains invalid characters');
|
|
}
|
|
const auditStore = new UserChangedAuditStore({
|
|
_id: this.user._id,
|
|
ip: this.requestIp || '',
|
|
useragent: this.request.headers.get('user-agent') || '',
|
|
username: this.user.username,
|
|
});
|
|
|
|
await saveUser(this.userId, userData, { auditStore });
|
|
|
|
if (typeof this.bodyParams.data.active !== 'undefined') {
|
|
const {
|
|
userId,
|
|
data: { active },
|
|
confirmRelinquish,
|
|
} = this.bodyParams;
|
|
await executeSetUserActiveStatus(this.userId, userId, active, Boolean(confirmRelinquish));
|
|
}
|
|
|
|
const { fields } = await this.parseJsonQuery();
|
|
|
|
const user = await Users.findOneById(this.bodyParams.userId, { projection: fields });
|
|
if (!user) {
|
|
return API.v1.failure('User not found');
|
|
}
|
|
|
|
return API.v1.success({ user });
|
|
},
|
|
);
|
|
|
|
API.v1
|
|
.post(
|
|
'users.updateOwnBasicInfo',
|
|
{
|
|
authRequired: true,
|
|
userWithoutUsername: true,
|
|
body: isUsersUpdateOwnBasicInfoParamsPOST,
|
|
rateLimiterOptions: {
|
|
numRequestsAllowed: 1,
|
|
intervalTimeInMS: 60000,
|
|
},
|
|
response: {
|
|
200: userObjectResponse,
|
|
400: validateBadRequestErrorResponse,
|
|
401: validateUnauthorizedErrorResponse,
|
|
},
|
|
},
|
|
async function action() {
|
|
const userData = {
|
|
email: this.bodyParams.data.email,
|
|
realname: this.bodyParams.data.name,
|
|
username: this.bodyParams.data.username,
|
|
nickname: this.bodyParams.data.nickname,
|
|
bio: this.bodyParams.data.bio,
|
|
statusText: this.bodyParams.data.statusText,
|
|
statusType: this.bodyParams.data.statusType,
|
|
newPassword: this.bodyParams.data.newPassword,
|
|
typedPassword: this.bodyParams.data.currentPassword,
|
|
};
|
|
|
|
if (userData.realname && !validateNameChars(userData.realname)) {
|
|
return API.v1.failure('Name contains invalid characters');
|
|
}
|
|
|
|
// saveUserProfile now uses the default two factor authentication procedures, so we need to provide that
|
|
const twoFactorOptions = !userData.typedPassword
|
|
? null
|
|
: {
|
|
twoFactorCode: userData.typedPassword,
|
|
twoFactorMethod: 'password',
|
|
};
|
|
|
|
await executeSaveUserProfile.call(this, this.user, userData, this.bodyParams.customFields, twoFactorOptions);
|
|
|
|
return API.v1.success({
|
|
user: await getUserInfo((await Users.findOneById(this.userId, { projection: API.v1.defaultFieldsToExclude })) as IUser, false),
|
|
});
|
|
},
|
|
)
|
|
.post(
|
|
'users.setPreferences',
|
|
{
|
|
authRequired: true,
|
|
body: isUsersSetPreferencesParamsPOST,
|
|
response: {
|
|
200: userObjectResponse,
|
|
400: validateBadRequestErrorResponse,
|
|
401: validateUnauthorizedErrorResponse,
|
|
},
|
|
},
|
|
async function action() {
|
|
if (
|
|
this.bodyParams.userId &&
|
|
this.bodyParams.userId !== this.userId &&
|
|
!(await hasPermissionAsync(this.user, 'edit-other-user-info'))
|
|
) {
|
|
throw new Meteor.Error('error-action-not-allowed', 'Editing user is not allowed');
|
|
}
|
|
const userId = this.bodyParams.userId ? this.bodyParams.userId : this.userId;
|
|
if (!(await Users.findOneById(userId))) {
|
|
throw new Meteor.Error('error-invalid-user', 'The optional "userId" param provided does not match any users');
|
|
}
|
|
|
|
const { statusVisibilityDenied: _ownBlockList, ...preferences } = this.bodyParams.data;
|
|
|
|
await saveUserPreferences(userId === this.userId ? this.bodyParams.data : preferences, userId);
|
|
const user = await Users.findOneById(userId, {
|
|
projection: {
|
|
'settings.preferences': 1,
|
|
'language': 1,
|
|
},
|
|
});
|
|
|
|
if (!user) {
|
|
return API.v1.failure('User not found');
|
|
}
|
|
|
|
const { statusVisibilityDenied, ...savedPreferences } = user.settings?.preferences ?? {};
|
|
|
|
return API.v1.success({
|
|
user: {
|
|
_id: user._id,
|
|
settings: {
|
|
preferences: {
|
|
...savedPreferences,
|
|
...(userId === this.userId &&
|
|
settings.get<boolean>('Accounts_StatusVisibility_Enabled') &&
|
|
statusVisibilityDenied?.length && {
|
|
statusVisibilityDenied: (await resolveUsersByIds(statusVisibilityDenied)).usernames,
|
|
}),
|
|
language: user.language,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
},
|
|
)
|
|
.post(
|
|
'users.setAvatar',
|
|
{
|
|
authRequired: true,
|
|
body: isUsersSetAvatarProps,
|
|
response: {
|
|
200: voidSuccessResponse,
|
|
400: validateBadRequestErrorResponse,
|
|
401: validateUnauthorizedErrorResponse,
|
|
403: validateForbiddenErrorResponse,
|
|
},
|
|
},
|
|
async function action() {
|
|
const canEditOtherUserAvatar = await hasPermissionAsync(this.user, 'edit-other-user-avatar');
|
|
|
|
if (!settings.get('Accounts_AllowUserAvatarChange') && !canEditOtherUserAvatar) {
|
|
throw new Meteor.Error('error-not-allowed', 'Change avatar is not allowed', {
|
|
method: 'users.setAvatar',
|
|
});
|
|
}
|
|
|
|
let user = await (async (): Promise<Pick<IUser, '_id' | 'username'> | undefined | null> => {
|
|
if (isUserFromParams(this.bodyParams, this.userId, this.user)) {
|
|
return Users.findOneById(this.userId);
|
|
}
|
|
if (canEditOtherUserAvatar) {
|
|
return getUserFromParams(this.bodyParams);
|
|
}
|
|
})();
|
|
|
|
if (!user) {
|
|
return API.v1.forbidden();
|
|
}
|
|
|
|
if (this.bodyParams.avatarUrl) {
|
|
await setUserAvatar(user, this.bodyParams.avatarUrl, '', 'url');
|
|
return API.v1.success();
|
|
}
|
|
|
|
const image = await getUploadFormData(
|
|
{
|
|
request: this.request,
|
|
},
|
|
{ field: 'image', sizeLimit: settings.get('FileUpload_MaxFileSize') },
|
|
);
|
|
|
|
if (!image) {
|
|
return API.v1.failure("The 'image' param is required");
|
|
}
|
|
|
|
const { fields, fileBuffer, mimetype } = image;
|
|
|
|
const sentTheUserByFormData = fields.userId || fields.username;
|
|
if (sentTheUserByFormData) {
|
|
if (fields.userId) {
|
|
user = await Users.findOneById<Pick<IUser, '_id' | 'username'>>(fields.userId, { projection: { username: 1 } });
|
|
} else if (fields.username) {
|
|
user = await Users.findOneByUsernameIgnoringCase<Pick<IUser, '_id' | 'username'>>(fields.username, {
|
|
projection: { username: 1 },
|
|
});
|
|
}
|
|
|
|
if (!user) {
|
|
throw new Meteor.Error('error-invalid-user', 'The optional "userId" or "username" param provided does not match any users');
|
|
}
|
|
|
|
const isAnotherUser = this.userId !== user._id;
|
|
if (isAnotherUser && !(await hasPermissionAsync(this.user, 'edit-other-user-avatar'))) {
|
|
throw new Meteor.Error('error-not-allowed', 'Not allowed');
|
|
}
|
|
}
|
|
|
|
// an avatar coming from an OAuth service suggestion carries the provider data URI (a string) in the
|
|
// image field; setUserAvatar parses it and records the provider name as the avatar origin. A regular
|
|
// upload has no service and is stored from the binary buffer.
|
|
if (typeof fields.service === 'string' && fields.service.length > 0) {
|
|
await setUserAvatar(user, fileBuffer.toString('utf8'), mimetype, fields.service);
|
|
return API.v1.success();
|
|
}
|
|
|
|
await setUserAvatar(user, fileBuffer, mimetype, 'rest');
|
|
|
|
return API.v1.success();
|
|
},
|
|
)
|
|
.post(
|
|
'users.create',
|
|
{
|
|
authRequired: true,
|
|
body: isUserCreateParamsPOST,
|
|
response: {
|
|
200: userObjectResponse,
|
|
400: validateBadRequestErrorResponse,
|
|
401: validateUnauthorizedErrorResponse,
|
|
},
|
|
},
|
|
async function action() {
|
|
// New change made by pull request #5152
|
|
if (typeof this.bodyParams.joinDefaultChannels === 'undefined') {
|
|
this.bodyParams.joinDefaultChannels = true;
|
|
}
|
|
|
|
if (this.bodyParams.name && !validateNameChars(this.bodyParams.name)) {
|
|
return API.v1.failure('Name contains invalid characters');
|
|
}
|
|
|
|
if (this.bodyParams.customFields) {
|
|
validateCustomFields(this.bodyParams.customFields);
|
|
}
|
|
|
|
if (this.bodyParams.freeSwitchExtension && !(await canEditExtension(this.bodyParams.freeSwitchExtension))) {
|
|
return API.v1.failure('Setting user voice call extension is not allowed', 'error-action-not-allowed');
|
|
}
|
|
|
|
const newUserId = await saveUser(this.userId, this.bodyParams);
|
|
const userId = typeof newUserId !== 'string' ? this.userId : newUserId;
|
|
|
|
if (this.bodyParams.customFields) {
|
|
await saveCustomFieldsWithoutValidation(userId, this.bodyParams.customFields);
|
|
}
|
|
|
|
if (typeof this.bodyParams.active !== 'undefined') {
|
|
await executeSetUserActiveStatus(this.userId, userId, this.bodyParams.active);
|
|
}
|
|
|
|
const { fields } = await this.parseJsonQuery();
|
|
|
|
const user = await Users.findOneById(userId, { projection: fields });
|
|
if (!user) {
|
|
return API.v1.failure('User not found');
|
|
}
|
|
|
|
return API.v1.success({ user });
|
|
},
|
|
);
|
|
|
|
API.v1.post(
|
|
'users.delete',
|
|
{
|
|
authRequired: true,
|
|
permissionsRequired: ['delete-user'],
|
|
body: ajv.compile<{ userId?: string; username?: string; user?: string; confirmRelinquish?: boolean }>({
|
|
type: 'object',
|
|
properties: {
|
|
userId: { type: 'string' },
|
|
username: { type: 'string' },
|
|
user: { type: 'string' },
|
|
confirmRelinquish: { type: 'boolean', nullable: true },
|
|
},
|
|
anyOf: [{ required: ['userId'] }, { required: ['username'] }, { required: ['user'] }],
|
|
additionalProperties: false,
|
|
}),
|
|
response: {
|
|
200: ajv.compile<{ deletedRooms: string[] }>({
|
|
type: 'object',
|
|
properties: {
|
|
deletedRooms: { type: 'array', items: { type: 'string' } },
|
|
success: { type: 'boolean', enum: [true] },
|
|
},
|
|
required: ['deletedRooms', 'success'],
|
|
additionalProperties: false,
|
|
}),
|
|
400: validateBadRequestErrorResponse,
|
|
401: validateUnauthorizedErrorResponse,
|
|
},
|
|
},
|
|
async function action() {
|
|
const user = await getUserFromParams(this.bodyParams);
|
|
const { confirmRelinquish = false } = this.bodyParams;
|
|
|
|
const { deletedRooms } = await deleteUser(user._id, confirmRelinquish, this.userId);
|
|
|
|
return API.v1.success({ deletedRooms });
|
|
},
|
|
);
|
|
|
|
API.v1.post(
|
|
'users.deleteOwnAccount',
|
|
{
|
|
authRequired: true,
|
|
body: ajv.compile<{ password: string; confirmRelinquish?: boolean }>({
|
|
type: 'object',
|
|
properties: {
|
|
password: { type: 'string' },
|
|
confirmRelinquish: { type: 'boolean', nullable: true },
|
|
},
|
|
required: ['password'],
|
|
additionalProperties: false,
|
|
}),
|
|
response: {
|
|
200: ajv.compile<void>({
|
|
type: 'object',
|
|
properties: {
|
|
success: { type: 'boolean', enum: [true] },
|
|
},
|
|
required: ['success'],
|
|
additionalProperties: false,
|
|
}),
|
|
400: validateBadRequestErrorResponse,
|
|
401: validateUnauthorizedErrorResponse,
|
|
},
|
|
},
|
|
async function action() {
|
|
if (!settings.get('Accounts_AllowDeleteOwnAccount')) {
|
|
throw new Meteor.Error('error-not-allowed', 'Not allowed');
|
|
}
|
|
|
|
const { confirmRelinquish = false } = this.bodyParams;
|
|
|
|
await deleteUserOwnAccount(this.userId, this.bodyParams.password, confirmRelinquish);
|
|
|
|
return API.v1.success();
|
|
},
|
|
);
|
|
|
|
API.v1.post(
|
|
'users.setActiveStatus',
|
|
{
|
|
authRequired: true,
|
|
body: isUserSetActiveStatusParamsPOST,
|
|
permissionsRequired: {
|
|
POST: { permissions: ['edit-other-user-active-status', 'manage-moderation-actions'], operation: 'hasAny' },
|
|
},
|
|
response: {
|
|
200: ajv.compile<{ user: Pick<IUser, '_id' | 'active'> }>({
|
|
type: 'object',
|
|
properties: {
|
|
user: {
|
|
type: 'object',
|
|
properties: {
|
|
_id: { type: 'string' },
|
|
active: { type: 'boolean' },
|
|
},
|
|
required: ['_id', 'active'],
|
|
additionalProperties: false,
|
|
},
|
|
success: { type: 'boolean', enum: [true] },
|
|
},
|
|
required: ['user', 'success'],
|
|
additionalProperties: false,
|
|
}),
|
|
400: validateBadRequestErrorResponse,
|
|
401: validateUnauthorizedErrorResponse,
|
|
},
|
|
},
|
|
async function action() {
|
|
const { userId, activeStatus, confirmRelinquish = false } = this.bodyParams;
|
|
await executeSetUserActiveStatus(this.userId, userId, activeStatus, confirmRelinquish);
|
|
|
|
const user = await Users.findOneById(this.bodyParams.userId, { projection: { active: 1 } });
|
|
if (!user) {
|
|
return API.v1.failure('User not found');
|
|
}
|
|
return API.v1.success({
|
|
user,
|
|
});
|
|
},
|
|
);
|
|
|
|
API.v1.post(
|
|
'users.deactivateIdle',
|
|
{
|
|
authRequired: true,
|
|
body: isUserDeactivateIdleParamsPOST,
|
|
permissionsRequired: ['edit-other-user-active-status'],
|
|
response: {
|
|
200: ajv.compile<{ count: number }>({
|
|
type: 'object',
|
|
properties: {
|
|
count: { type: 'number' },
|
|
success: { type: 'boolean', enum: [true] },
|
|
},
|
|
required: ['count', 'success'],
|
|
additionalProperties: false,
|
|
}),
|
|
400: validateBadRequestErrorResponse,
|
|
401: validateUnauthorizedErrorResponse,
|
|
},
|
|
},
|
|
async function action() {
|
|
const { daysIdle, role = 'user' } = this.bodyParams;
|
|
|
|
const lastLoggedIn = new Date();
|
|
lastLoggedIn.setDate(lastLoggedIn.getDate() - daysIdle);
|
|
|
|
const ids = await Users.findActiveNotLoggedInAfterWithRole(lastLoggedIn, role, { projection: { _id: 1 } })
|
|
.map(({ _id }: { _id: string }) => _id)
|
|
.toArray();
|
|
|
|
const { modifiedCount: count } = await Users.setActiveNotLoggedInAfterWithRole(lastLoggedIn, role, false);
|
|
|
|
await Promise.all([
|
|
OAuthAccessTokens.deleteByUserIds(ids),
|
|
OAuthRefreshTokens.deleteByUserIds(ids),
|
|
OAuthAuthCodes.deleteByUserIds(ids),
|
|
]);
|
|
|
|
ids.forEach((_id) => {
|
|
void notifyOnUserChange({
|
|
clientAction: 'updated',
|
|
id: _id,
|
|
diff: { 'services.resume.loginTokens': [], 'active': false },
|
|
});
|
|
});
|
|
|
|
return API.v1.success({
|
|
count,
|
|
});
|
|
},
|
|
);
|
|
|
|
API.v1.get(
|
|
'users.info',
|
|
{
|
|
authRequired: true,
|
|
query: isUsersInfoParamsGetProps,
|
|
response: {
|
|
// user shape varies by projection, permissions, and includeUserRooms
|
|
200: userObjectResponse,
|
|
400: validateBadRequestErrorResponse,
|
|
401: validateUnauthorizedErrorResponse,
|
|
},
|
|
},
|
|
async function action() {
|
|
const searchTerms: [string, 'id' | 'username' | 'importId' | 'email' | 'freeSwitchExtension'] | false =
|
|
('userId' in this.queryParams && !!this.queryParams.userId && [this.queryParams.userId, 'id']) ||
|
|
('username' in this.queryParams && !!this.queryParams.username && [this.queryParams.username, 'username']) ||
|
|
('importId' in this.queryParams && !!this.queryParams.importId && [this.queryParams.importId, 'importId']) ||
|
|
('email' in this.queryParams && !!this.queryParams.email && [this.queryParams.email, 'email']) ||
|
|
('freeSwitchExtension' in this.queryParams &&
|
|
!!this.queryParams.freeSwitchExtension && [this.queryParams.freeSwitchExtension, 'freeSwitchExtension']);
|
|
|
|
if (!searchTerms) {
|
|
return API.v1.failure('Invalid search query.');
|
|
}
|
|
|
|
const user = await getFullUserDataByUniqueSearchTerm(this.userId, ...searchTerms);
|
|
|
|
if (!user) {
|
|
return API.v1.failure('User not found.');
|
|
}
|
|
|
|
const myself = user._id === this.userId;
|
|
|
|
if (this.queryParams.includeUserRooms === 'true' && (myself || (await hasPermissionAsync(this.user, 'view-other-user-channels')))) {
|
|
return API.v1.success({
|
|
user: {
|
|
...user,
|
|
rooms: await Subscriptions.findByUserId(user._id, {
|
|
projection: {
|
|
rid: 1,
|
|
name: 1,
|
|
t: 1,
|
|
roles: 1,
|
|
unread: 1,
|
|
federated: 1,
|
|
},
|
|
sort: {
|
|
t: 1,
|
|
name: 1,
|
|
},
|
|
}).toArray(),
|
|
},
|
|
});
|
|
}
|
|
|
|
return API.v1.success({
|
|
user,
|
|
});
|
|
},
|
|
);
|
|
|
|
// users.list accepts arbitrary query filter fields (name, username, etc.)
|
|
// that cannot be statically defined — keeping as addRoute until params are known
|
|
API.v1.addRoute(
|
|
'users.list',
|
|
{
|
|
authRequired: true,
|
|
queryOperations: ['$or', '$and'],
|
|
permissionsRequired: ['view-d-room'],
|
|
query: isUsersListParamsGET,
|
|
},
|
|
{
|
|
async get() {
|
|
if (
|
|
settings.get('API_Apply_permission_view-outside-room_on_users-list') &&
|
|
!(await hasPermissionAsync(this.user, 'view-outside-room'))
|
|
) {
|
|
return API.v1.forbidden();
|
|
}
|
|
const canViewFullOtherUserInfo = await hasPermissionAsync(this.user, 'view-full-other-user-info');
|
|
|
|
const { offset, count } = await getPaginationItems(this.queryParams);
|
|
const { sort, fields, query } = await this.parseJsonQuery();
|
|
|
|
const nonEmptyFields = getNonEmptyFields(fields);
|
|
|
|
const inclusiveFields = getInclusiveFields(nonEmptyFields);
|
|
|
|
const inclusiveFieldsKeys = Object.keys(inclusiveFields);
|
|
|
|
const nonEmptyQuery = getNonEmptyQuery(query, canViewFullOtherUserInfo);
|
|
|
|
if ('email' in this.queryParams && this.queryParams.email) {
|
|
if (!canViewFullOtherUserInfo) {
|
|
return API.v1.forbidden();
|
|
}
|
|
const escapedEmail = escapeRegExp(this.queryParams.email);
|
|
nonEmptyQuery['emails.address'] = {
|
|
$regex: `^${escapedEmail}$`,
|
|
$options: 'i',
|
|
};
|
|
}
|
|
|
|
// if user provided a query, validate it with their allowed operators
|
|
// otherwise we use the default query (with $regex and $options)
|
|
if (
|
|
!isValidQuery(
|
|
nonEmptyQuery,
|
|
[
|
|
...inclusiveFieldsKeys,
|
|
inclusiveFieldsKeys.includes('emails') && 'emails.address.*',
|
|
inclusiveFieldsKeys.includes('username') && 'username.*',
|
|
inclusiveFieldsKeys.includes('name') && 'name.*',
|
|
inclusiveFieldsKeys.includes('type') && 'type.*',
|
|
inclusiveFieldsKeys.includes('customFields') && 'customFields.*',
|
|
].filter(Boolean) as string[],
|
|
// At this point, we have already validated the user query not containing malicious fields
|
|
// On here we are using our own query so we can allow some extra fields
|
|
[...this.queryOperations, '$regex', '$options'],
|
|
)
|
|
) {
|
|
throw new Meteor.Error('error-invalid-query', isValidQuery.errors.join('\n'));
|
|
}
|
|
|
|
const hidden = await getUsersHiddenFrom(this.userId);
|
|
|
|
if (hidden && queryFiltersStatus(query)) {
|
|
nonEmptyQuery.$and = [...(nonEmptyQuery.$and ?? []), { _id: { $nin: [...hidden] } }];
|
|
}
|
|
|
|
const actualSort = sort || { username: 1 };
|
|
|
|
if (sort?.status) {
|
|
actualSort.active = sort.status;
|
|
}
|
|
|
|
if (sort?.name) {
|
|
actualSort.nameInsensitive = sort.name;
|
|
}
|
|
|
|
const limit =
|
|
count !== 0
|
|
? [
|
|
{
|
|
$limit: count,
|
|
},
|
|
]
|
|
: [];
|
|
|
|
const result = await Users.col
|
|
.aggregate<{ sortedResults: IUser[]; totalCount: { total: number }[] }>([
|
|
{
|
|
$match: nonEmptyQuery,
|
|
},
|
|
{
|
|
$project: inclusiveFields,
|
|
},
|
|
{
|
|
$addFields: {
|
|
nameInsensitive: {
|
|
$toLower: '$name',
|
|
},
|
|
},
|
|
},
|
|
{
|
|
$facet: {
|
|
sortedResults: [
|
|
{
|
|
$sort: actualSort,
|
|
},
|
|
{
|
|
$skip: offset,
|
|
},
|
|
...limit,
|
|
],
|
|
totalCount: [{ $group: { _id: null, total: { $sum: 1 } } }],
|
|
},
|
|
},
|
|
])
|
|
.toArray();
|
|
|
|
const {
|
|
sortedResults: users,
|
|
totalCount: [{ total } = { total: 0 }],
|
|
} = result[0];
|
|
|
|
return API.v1.success({
|
|
users: redactHiddenUsers(users, hidden),
|
|
count: users.length,
|
|
offset,
|
|
total,
|
|
});
|
|
},
|
|
},
|
|
);
|
|
|
|
API.v1.get(
|
|
'users.listByStatus',
|
|
{
|
|
authRequired: true,
|
|
query: isUsersListStatusProps,
|
|
permissionsRequired: ['view-d-room'],
|
|
response: {
|
|
200: ajv.compile<{ users: IUser[]; count: number; offset: number; total: number }>({
|
|
type: 'object',
|
|
properties: {
|
|
// user shape varies by projection and permissions
|
|
users: { type: 'array' },
|
|
count: { type: 'number' },
|
|
offset: { type: 'number' },
|
|
total: { type: 'number' },
|
|
success: { type: 'boolean', enum: [true] },
|
|
},
|
|
required: ['users', 'count', 'offset', 'total', 'success'],
|
|
additionalProperties: false,
|
|
}),
|
|
400: validateBadRequestErrorResponse,
|
|
401: validateUnauthorizedErrorResponse,
|
|
403: validateForbiddenErrorResponse,
|
|
},
|
|
},
|
|
async function action() {
|
|
if (
|
|
settings.get('API_Apply_permission_view-outside-room_on_users-list') &&
|
|
!(await hasPermissionAsync(this.user, 'view-outside-room'))
|
|
) {
|
|
return API.v1.forbidden();
|
|
}
|
|
|
|
const { offset, count } = await getPaginationItems(this.queryParams);
|
|
const { sort } = await this.parseJsonQuery();
|
|
const { status, hasLoggedIn, type, roles, searchTerm, inactiveReason } = this.queryParams;
|
|
|
|
const result = await findPaginatedUsersByStatus({
|
|
uid: this.userId,
|
|
offset,
|
|
count,
|
|
sort,
|
|
status,
|
|
roles,
|
|
searchTerm,
|
|
hasLoggedIn,
|
|
type,
|
|
inactiveReason,
|
|
});
|
|
|
|
const hidden = await getUsersHiddenFrom(this.userId);
|
|
|
|
return API.v1.success({
|
|
...result,
|
|
users: redactHiddenUsers(result.users, hidden),
|
|
});
|
|
},
|
|
);
|
|
|
|
API.v1.post(
|
|
'users.sendWelcomeEmail',
|
|
{
|
|
authRequired: true,
|
|
body: isUsersSendWelcomeEmailProps,
|
|
permissionsRequired: ['send-mail'],
|
|
response: {
|
|
200: ajv.compile<void>({
|
|
type: 'object',
|
|
properties: {
|
|
success: { type: 'boolean', enum: [true] },
|
|
},
|
|
required: ['success'],
|
|
additionalProperties: false,
|
|
}),
|
|
400: validateBadRequestErrorResponse,
|
|
401: validateUnauthorizedErrorResponse,
|
|
},
|
|
},
|
|
async function action() {
|
|
const { email } = this.bodyParams;
|
|
|
|
if (!isSMTPConfigured()) {
|
|
throw new MeteorError('error-email-send-failed', 'SMTP is not configured', {
|
|
method: 'sendWelcomeEmail',
|
|
});
|
|
}
|
|
|
|
const user = await Users.findOneByEmailAddress(email.trim(), { projection: { name: 1 } });
|
|
|
|
if (!user) {
|
|
throw new MeteorError('error-invalid-user', 'Invalid user', {
|
|
method: 'sendWelcomeEmail',
|
|
});
|
|
}
|
|
|
|
await sendWelcomeEmail({ ...user, email });
|
|
|
|
return API.v1.success();
|
|
},
|
|
);
|
|
|
|
API.v1.post(
|
|
'users.register',
|
|
{
|
|
authRequired: false,
|
|
rateLimiterOptions: {
|
|
numRequestsAllowed: settings.get('Rate_Limiter_Limit_RegisterUser') ?? 1,
|
|
intervalTimeInMS: settings.get('API_Enable_Rate_Limiter_Limit_Time_Default') ?? 60000,
|
|
},
|
|
body: isUserRegisterParamsPOST,
|
|
response: {
|
|
200: userObjectResponse,
|
|
400: validateBadRequestErrorResponse,
|
|
},
|
|
},
|
|
async function action() {
|
|
const { secret: secretURL, ...params } = this.bodyParams;
|
|
|
|
if (this.userId) {
|
|
return API.v1.failure('Logged in users can not register again.');
|
|
}
|
|
|
|
if (params.name && !validateNameChars(params.name)) {
|
|
return API.v1.failure('Name contains invalid characters');
|
|
}
|
|
|
|
if (!validateUsername(this.bodyParams.username)) {
|
|
return API.v1.failure(`The username provided is not valid`);
|
|
}
|
|
|
|
if (!(await checkUsernameAvailability(this.bodyParams.username))) {
|
|
return API.v1.failure('Username is already in use');
|
|
}
|
|
if (!(await checkEmailAvailability(this.bodyParams.email))) {
|
|
return API.v1.failure('Email already exists');
|
|
}
|
|
if (this.bodyParams.customFields) {
|
|
try {
|
|
validateCustomFields(this.bodyParams.customFields);
|
|
} catch (e) {
|
|
return API.v1.failure(e);
|
|
}
|
|
}
|
|
|
|
// Register the user
|
|
const userId = await registerUser({
|
|
...params,
|
|
...(secretURL && { secretURL }),
|
|
});
|
|
|
|
if (typeof userId !== 'string') {
|
|
return API.v1.failure('Error creating user');
|
|
}
|
|
|
|
// Now set their username
|
|
const { fields } = await this.parseJsonQuery();
|
|
await setUsernameWithValidation(userId, this.bodyParams.username);
|
|
|
|
const user = await Users.findOneById(userId, { projection: fields });
|
|
if (!user) {
|
|
return API.v1.failure('User not found');
|
|
}
|
|
|
|
if (this.bodyParams.customFields) {
|
|
await saveCustomFields(userId, this.bodyParams.customFields);
|
|
}
|
|
|
|
return API.v1.success({ user });
|
|
},
|
|
);
|
|
|
|
API.v1.post(
|
|
'users.resetAvatar',
|
|
{
|
|
authRequired: true,
|
|
body: ajv.compile<{ userId?: string; username?: string; user?: string }>({
|
|
type: 'object',
|
|
properties: {
|
|
userId: { type: 'string' },
|
|
username: { type: 'string' },
|
|
user: { type: 'string' },
|
|
},
|
|
additionalProperties: false,
|
|
}),
|
|
response: {
|
|
200: voidSuccessResponse,
|
|
400: validateBadRequestErrorResponse,
|
|
401: validateUnauthorizedErrorResponse,
|
|
},
|
|
},
|
|
async function action() {
|
|
const user = await getUserFromParams(this.bodyParams);
|
|
|
|
if (settings.get('Accounts_AllowUserAvatarChange') && user._id === this.userId) {
|
|
await resetAvatar(this.userId, this.userId);
|
|
} else if (
|
|
(await hasPermissionAsync(this.user, 'edit-other-user-avatar')) ||
|
|
(await hasPermissionAsync(this.user, 'manage-moderation-actions'))
|
|
) {
|
|
await resetAvatar(this.userId, user._id);
|
|
} else {
|
|
throw new Meteor.Error('error-not-allowed', 'Reset avatar is not allowed', {
|
|
method: 'users.resetAvatar',
|
|
});
|
|
}
|
|
|
|
return API.v1.success();
|
|
},
|
|
);
|
|
|
|
const usersEndpoints = API.v1
|
|
.post(
|
|
'users.createToken',
|
|
{
|
|
authRequired: true,
|
|
body: ajv.compile<{ userId: string; secret: string }>({
|
|
type: 'object',
|
|
properties: {
|
|
userId: {
|
|
type: 'string',
|
|
minLength: 1,
|
|
},
|
|
secret: {
|
|
type: 'string',
|
|
minLength: 1,
|
|
},
|
|
},
|
|
required: ['userId', 'secret'],
|
|
additionalProperties: false,
|
|
}),
|
|
response: {
|
|
200: ajv.compile<{ data: { userId: string; authToken: string } }>({
|
|
type: 'object',
|
|
properties: {
|
|
data: {
|
|
type: 'object',
|
|
properties: {
|
|
userId: {
|
|
type: 'string',
|
|
minLength: 1,
|
|
},
|
|
authToken: {
|
|
type: 'string',
|
|
minLength: 1,
|
|
},
|
|
},
|
|
required: ['userId', 'authToken'],
|
|
additionalProperties: false,
|
|
},
|
|
success: {
|
|
type: 'boolean',
|
|
enum: [true],
|
|
},
|
|
},
|
|
required: ['data', 'success'],
|
|
additionalProperties: false,
|
|
}),
|
|
400: ajv.compile({
|
|
type: 'object',
|
|
properties: {
|
|
success: { type: 'boolean', enum: [false] },
|
|
error: { type: 'string' },
|
|
errorType: { type: 'string' },
|
|
},
|
|
required: ['success'],
|
|
additionalProperties: false,
|
|
}),
|
|
},
|
|
},
|
|
async function action() {
|
|
const user = await getUserFromParams(this.bodyParams);
|
|
|
|
const data = await generateAccessToken(user._id, this.bodyParams.secret, this.user);
|
|
|
|
return API.v1.success({ data });
|
|
},
|
|
)
|
|
.get(
|
|
'users.getAvatarSuggestion',
|
|
{
|
|
authRequired: true,
|
|
response: {
|
|
400: validateBadRequestErrorResponse,
|
|
401: validateUnauthorizedErrorResponse,
|
|
200: ajv.compile<{
|
|
suggestions: Record<
|
|
string,
|
|
{
|
|
blob: string;
|
|
contentType: string;
|
|
service: string;
|
|
url: string;
|
|
}
|
|
>;
|
|
}>({
|
|
type: 'object',
|
|
properties: {
|
|
success: {
|
|
type: 'boolean',
|
|
enum: [true],
|
|
},
|
|
suggestions: {
|
|
type: 'object',
|
|
additionalProperties: {
|
|
type: 'object',
|
|
properties: {
|
|
blob: {
|
|
type: 'string',
|
|
},
|
|
contentType: {
|
|
type: 'string',
|
|
},
|
|
service: {
|
|
type: 'string',
|
|
},
|
|
url: {
|
|
type: 'string',
|
|
format: 'uri',
|
|
},
|
|
},
|
|
required: ['blob', 'contentType', 'service', 'url'],
|
|
additionalProperties: false,
|
|
},
|
|
},
|
|
},
|
|
required: ['success', 'suggestions'],
|
|
additionalProperties: false,
|
|
}),
|
|
},
|
|
},
|
|
async function action() {
|
|
const suggestions = await getAvatarSuggestionForUser(this.user);
|
|
|
|
return API.v1.success({ suggestions });
|
|
},
|
|
);
|
|
|
|
API.v1.get(
|
|
'users.getPreferences',
|
|
{
|
|
authRequired: true,
|
|
response: {
|
|
200: ajv.compile<{ preferences: Record<string, unknown> }>({
|
|
type: 'object',
|
|
properties: {
|
|
// preferences is a dynamic key-value object that varies per user
|
|
preferences: { type: 'object' },
|
|
success: { type: 'boolean', enum: [true] },
|
|
},
|
|
required: ['preferences', 'success'],
|
|
additionalProperties: false,
|
|
}),
|
|
400: validateBadRequestErrorResponse,
|
|
401: validateUnauthorizedErrorResponse,
|
|
},
|
|
},
|
|
async function action() {
|
|
const user = await Users.findOneById(this.userId);
|
|
if (user?.settings) {
|
|
const { preferences = {} } = user?.settings;
|
|
preferences.language = user?.language;
|
|
|
|
return API.v1.success({
|
|
preferences,
|
|
});
|
|
}
|
|
return API.v1.failure(i18n.t('Accounts_Default_User_Preferences_not_available').toUpperCase());
|
|
},
|
|
);
|
|
|
|
API.v1
|
|
.post(
|
|
'users.forgotPassword',
|
|
{
|
|
authRequired: false,
|
|
body: ajv.compile<{ email: string }>({
|
|
type: 'object',
|
|
properties: {
|
|
email: { type: 'string' },
|
|
},
|
|
required: ['email'],
|
|
additionalProperties: false,
|
|
}),
|
|
response: {
|
|
200: ajv.compile<void>({
|
|
type: 'object',
|
|
properties: {
|
|
success: { type: 'boolean', enum: [true] },
|
|
},
|
|
required: ['success'],
|
|
additionalProperties: false,
|
|
}),
|
|
400: validateBadRequestErrorResponse,
|
|
},
|
|
},
|
|
async function action() {
|
|
const isPasswordResetEnabled = settings.get('Accounts_PasswordReset');
|
|
|
|
if (!isPasswordResetEnabled) {
|
|
return API.v1.failure('Password reset is not enabled');
|
|
}
|
|
|
|
await sendForgotPasswordEmail(this.bodyParams.email.toLowerCase());
|
|
return API.v1.success();
|
|
},
|
|
)
|
|
.get(
|
|
'users.getUsernameSuggestion',
|
|
{
|
|
authRequired: true,
|
|
userWithoutUsername: true,
|
|
response: {
|
|
200: ajv.compile<{ result: string }>({
|
|
type: 'object',
|
|
properties: {
|
|
result: { type: 'string' },
|
|
success: { type: 'boolean', enum: [true] },
|
|
},
|
|
required: ['result', 'success'],
|
|
additionalProperties: false,
|
|
}),
|
|
400: validateBadRequestErrorResponse,
|
|
401: validateUnauthorizedErrorResponse,
|
|
},
|
|
},
|
|
async function action() {
|
|
const result = await generateUsernameSuggestion(this.user);
|
|
|
|
if (!result) {
|
|
return API.v1.failure('No username suggestion found');
|
|
}
|
|
|
|
return API.v1.success({ result });
|
|
},
|
|
);
|
|
|
|
const tokenNameBodySchema = ajv.compile<{ tokenName: string; bypassTwoFactor?: boolean }>({
|
|
type: 'object',
|
|
properties: {
|
|
tokenName: { type: 'string' },
|
|
bypassTwoFactor: { type: 'boolean', nullable: true },
|
|
},
|
|
required: ['tokenName'],
|
|
additionalProperties: false,
|
|
});
|
|
|
|
const tokenResponseSchema = ajv.compile<{ token: string }>({
|
|
type: 'object',
|
|
properties: {
|
|
token: { type: 'string' },
|
|
success: { type: 'boolean', enum: [true] },
|
|
},
|
|
required: ['token', 'success'],
|
|
additionalProperties: false,
|
|
});
|
|
|
|
API.v1
|
|
.get(
|
|
'users.checkUsernameAvailability',
|
|
{
|
|
authRequired: true,
|
|
query: isUsersCheckUsernameAvailabilityParamsGET,
|
|
response: {
|
|
200: ajv.compile<{ result: boolean }>({
|
|
type: 'object',
|
|
properties: {
|
|
result: { type: 'boolean' },
|
|
success: { type: 'boolean', enum: [true] },
|
|
},
|
|
required: ['result', 'success'],
|
|
additionalProperties: false,
|
|
}),
|
|
400: validateBadRequestErrorResponse,
|
|
401: validateUnauthorizedErrorResponse,
|
|
},
|
|
},
|
|
async function action() {
|
|
const { username } = this.queryParams;
|
|
|
|
const result = await checkUsernameAvailabilityWithValidation(this.userId, username);
|
|
|
|
return API.v1.success({ result });
|
|
},
|
|
)
|
|
.post(
|
|
'users.generatePersonalAccessToken',
|
|
{
|
|
authRequired: true,
|
|
twoFactorRequired: true,
|
|
body: tokenNameBodySchema,
|
|
response: {
|
|
200: tokenResponseSchema,
|
|
400: validateBadRequestErrorResponse,
|
|
401: validateUnauthorizedErrorResponse,
|
|
},
|
|
},
|
|
async function action() {
|
|
const { tokenName, bypassTwoFactor = false } = this.bodyParams;
|
|
const token = await generatePersonalAccessTokenOfUser({ tokenName, userId: this.userId, bypassTwoFactor });
|
|
|
|
return API.v1.success({ token });
|
|
},
|
|
)
|
|
.post(
|
|
'users.regeneratePersonalAccessToken',
|
|
{
|
|
authRequired: true,
|
|
twoFactorRequired: true,
|
|
body: ajv.compile<{ tokenName: string }>({
|
|
type: 'object',
|
|
properties: {
|
|
tokenName: { type: 'string' },
|
|
},
|
|
required: ['tokenName'],
|
|
additionalProperties: false,
|
|
}),
|
|
response: {
|
|
200: tokenResponseSchema,
|
|
400: validateBadRequestErrorResponse,
|
|
401: validateUnauthorizedErrorResponse,
|
|
},
|
|
},
|
|
async function action() {
|
|
const { tokenName } = this.bodyParams;
|
|
const token = await regeneratePersonalAccessTokenOfUser(tokenName, this.userId);
|
|
|
|
return API.v1.success({ token });
|
|
},
|
|
)
|
|
.get(
|
|
'users.getPersonalAccessTokens',
|
|
{
|
|
authRequired: true,
|
|
permissionsRequired: ['create-personal-access-tokens'],
|
|
response: {
|
|
200: ajv.compile<{ tokens: { name: string; createdAt: string; lastTokenPart: string; bypassTwoFactor: boolean }[] }>({
|
|
type: 'object',
|
|
properties: {
|
|
tokens: {
|
|
type: 'array',
|
|
items: {
|
|
type: 'object',
|
|
properties: {
|
|
name: { type: 'string' },
|
|
createdAt: { type: 'string' },
|
|
lastTokenPart: { type: 'string' },
|
|
bypassTwoFactor: { type: 'boolean' },
|
|
},
|
|
required: ['name', 'createdAt', 'lastTokenPart', 'bypassTwoFactor'],
|
|
additionalProperties: false,
|
|
},
|
|
},
|
|
success: { type: 'boolean', enum: [true] },
|
|
},
|
|
required: ['tokens', 'success'],
|
|
additionalProperties: false,
|
|
}),
|
|
401: validateUnauthorizedErrorResponse,
|
|
},
|
|
},
|
|
async function action() {
|
|
const user = (await Users.getLoginTokensByUserId(this.userId).toArray())[0] as unknown as IUser | undefined;
|
|
|
|
const isPersonalAccessToken = (loginToken: ILoginToken | IPersonalAccessToken): loginToken is IPersonalAccessToken =>
|
|
'type' in loginToken && loginToken.type === 'personalAccessToken';
|
|
|
|
return API.v1.success({
|
|
tokens:
|
|
user?.services?.resume?.loginTokens?.filter(isPersonalAccessToken).map((loginToken) => ({
|
|
name: loginToken.name,
|
|
createdAt: loginToken.createdAt.toISOString(),
|
|
lastTokenPart: loginToken.lastTokenPart,
|
|
bypassTwoFactor: Boolean(loginToken.bypassTwoFactor),
|
|
})) || [],
|
|
});
|
|
},
|
|
)
|
|
.post(
|
|
'users.removePersonalAccessToken',
|
|
{
|
|
authRequired: true,
|
|
twoFactorRequired: true,
|
|
body: ajv.compile<{ tokenName: string }>({
|
|
type: 'object',
|
|
properties: {
|
|
tokenName: { type: 'string' },
|
|
},
|
|
required: ['tokenName'],
|
|
additionalProperties: false,
|
|
}),
|
|
response: {
|
|
200: voidSuccessResponse,
|
|
400: validateBadRequestErrorResponse,
|
|
401: validateUnauthorizedErrorResponse,
|
|
},
|
|
},
|
|
async function action() {
|
|
await removePersonalAccessTokenOfUser(this.bodyParams.tokenName, this.userId);
|
|
|
|
return API.v1.success();
|
|
},
|
|
);
|
|
|
|
API.v1
|
|
.post(
|
|
'users.2fa.enableEmail',
|
|
{
|
|
authRequired: true,
|
|
response: {
|
|
200: voidSuccessResponse,
|
|
400: validateBadRequestErrorResponse,
|
|
401: validateUnauthorizedErrorResponse,
|
|
},
|
|
},
|
|
async function action() {
|
|
const hasUnverifiedEmail = this.user.emails?.some((email) => !email.verified);
|
|
if (hasUnverifiedEmail) {
|
|
throw new MeteorError('error-invalid-user', 'You need to verify your emails before setting up 2FA');
|
|
}
|
|
|
|
await Users.enableEmail2FAByUserId(this.userId);
|
|
|
|
// When 2FA is enable we logout all other clients
|
|
const xAuthToken = this.request.headers.get('x-auth-token') as string;
|
|
if (!xAuthToken) {
|
|
return API.v1.success();
|
|
}
|
|
|
|
const hashedToken = Accounts._hashLoginToken(xAuthToken);
|
|
|
|
if (!(await Users.removeNonPATLoginTokensExcept(this.userId, hashedToken))) {
|
|
throw new MeteorError('error-logging-out-other-clients', 'Error logging out other clients');
|
|
}
|
|
|
|
// TODO this can be optmized so places that care about loginTokens being removed are invoked directly
|
|
// instead of having to listen to every watch.users event
|
|
void notifyOnUserChangeAsync(async () => {
|
|
const user = await Users.findOneById(this.userId, { projection: { 'services.resume.loginTokens': 1, 'services.email2fa': 1 } });
|
|
if (!user) {
|
|
return;
|
|
}
|
|
|
|
return {
|
|
clientAction: 'updated',
|
|
id: this.user._id,
|
|
diff: { 'services.resume.loginTokens': user.services?.resume?.loginTokens, 'services.email2fa': user.services?.email2fa },
|
|
};
|
|
});
|
|
|
|
return API.v1.success();
|
|
},
|
|
)
|
|
.post(
|
|
'users.2fa.disableEmail',
|
|
{
|
|
authRequired: true,
|
|
twoFactorRequired: true,
|
|
twoFactorOptions: { disableRememberMe: true },
|
|
response: {
|
|
200: voidSuccessResponse,
|
|
400: validateBadRequestErrorResponse,
|
|
401: validateUnauthorizedErrorResponse,
|
|
},
|
|
},
|
|
async function action() {
|
|
await Users.disableEmail2FAByUserId(this.userId);
|
|
|
|
void notifyOnUserChangeAsync(async () => {
|
|
const user = await Users.findOneById(this.userId, { projection: { 'services.email2fa': 1 } });
|
|
if (!user) {
|
|
return;
|
|
}
|
|
|
|
return {
|
|
clientAction: 'updated',
|
|
id: this.user._id,
|
|
diff: { 'services.email2fa': user.services?.email2fa },
|
|
};
|
|
});
|
|
|
|
return API.v1.success();
|
|
},
|
|
)
|
|
.post(
|
|
'users.2fa.sendEmailCode',
|
|
{
|
|
body: ajv.compile<{ emailOrUsername: string }>({
|
|
type: 'object',
|
|
properties: {
|
|
emailOrUsername: { type: 'string' },
|
|
},
|
|
required: ['emailOrUsername'],
|
|
additionalProperties: false,
|
|
}),
|
|
response: {
|
|
200: voidSuccessResponse,
|
|
400: validateBadRequestErrorResponse,
|
|
},
|
|
},
|
|
async function action() {
|
|
const { emailOrUsername } = this.bodyParams;
|
|
|
|
const method = emailOrUsername.includes('@') ? 'findOneByEmailAddress' : 'findOneByUsername';
|
|
const userId = this.userId || (await Users[method](emailOrUsername, { projection: { _id: 1 } }))?._id;
|
|
|
|
if (!userId) {
|
|
// this.logger.error('[2fa] User was not found when requesting 2fa email code');
|
|
return API.v1.success();
|
|
}
|
|
const user = await getUserForCheck(userId);
|
|
if (!user) {
|
|
// this.logger.error('[2fa] User was not found when requesting 2fa email code');
|
|
return API.v1.success();
|
|
}
|
|
|
|
await emailCheck.sendEmailCode(user);
|
|
|
|
return API.v1.success();
|
|
},
|
|
);
|
|
|
|
API.v1.post(
|
|
'users.sendConfirmationEmail',
|
|
{
|
|
authRequired: false,
|
|
body: isUsersSendConfirmationEmailParamsPOST,
|
|
rateLimiterOptions: {
|
|
numRequestsAllowed: 1,
|
|
intervalTimeInMS: 60000,
|
|
},
|
|
response: {
|
|
200: voidSuccessResponse,
|
|
400: validateBadRequestErrorResponse,
|
|
},
|
|
},
|
|
async function action() {
|
|
void sendConfirmationEmail(this.bodyParams.email).catch((err) => SystemLogger.error({ msg: 'sendConfirmationEmail failed', err }));
|
|
return API.v1.success();
|
|
},
|
|
);
|
|
|
|
API.v1.get(
|
|
'users.presence',
|
|
{
|
|
authRequired: true,
|
|
query: isUsersPresenceParamsGET,
|
|
response: {
|
|
200: ajv.compile<{ users: object[]; full: boolean }>({
|
|
type: 'object',
|
|
properties: {
|
|
// user shape varies by projection and permissions
|
|
users: { type: 'array' },
|
|
full: { type: 'boolean' },
|
|
success: { type: 'boolean', enum: [true] },
|
|
},
|
|
required: ['users', 'full', 'success'],
|
|
additionalProperties: false,
|
|
}),
|
|
401: validateUnauthorizedErrorResponse,
|
|
},
|
|
},
|
|
async function action() {
|
|
// if presence broadcast is disabled, return an empty array (all users are "offline")
|
|
if (settings.get('Presence_broadcast_disabled')) {
|
|
return API.v1.success({
|
|
users: [],
|
|
full: true,
|
|
});
|
|
}
|
|
|
|
const { from, ids } = this.queryParams;
|
|
|
|
const options = {
|
|
projection: {
|
|
username: 1,
|
|
name: 1,
|
|
status: 1,
|
|
utcOffset: 1,
|
|
statusText: 1,
|
|
statusSource: 1,
|
|
statusExpiresAt: 1,
|
|
avatarETag: 1,
|
|
},
|
|
};
|
|
|
|
const hidden = await getUsersHiddenFrom(this.userId);
|
|
|
|
if (ids) {
|
|
const requested = Array.isArray(ids) ? ids : ids.split(',');
|
|
const users = await Users.findPresenceUsersByIds(requested, options).toArray();
|
|
|
|
return API.v1.success({
|
|
users: filterHiddenUsers(users, hidden),
|
|
full: false,
|
|
});
|
|
}
|
|
|
|
if (from) {
|
|
const ts = new Date(from);
|
|
const diff = (Date.now() - Number(ts)) / 1000 / 60;
|
|
|
|
if (diff < 10) {
|
|
const users = await Users.findNotIdUpdatedFrom(this.userId, ts, options).toArray();
|
|
|
|
return API.v1.success({
|
|
users: filterHiddenUsers(users, hidden),
|
|
full: false,
|
|
});
|
|
}
|
|
}
|
|
|
|
const users = await Users.findUsersNotOffline(options).toArray();
|
|
|
|
return API.v1.success({
|
|
users: filterHiddenUsers(users, hidden),
|
|
full: true,
|
|
});
|
|
},
|
|
);
|
|
|
|
API.v1
|
|
.get(
|
|
'users.requestDataDownload',
|
|
{
|
|
authRequired: true,
|
|
query: isUsersRequestDataDownloadParamsGET,
|
|
response: {
|
|
200: ajv.compile<{
|
|
requested: boolean;
|
|
exportOperation: IExportOperation;
|
|
url: string | null;
|
|
pendingOperationsBeforeMyRequest: number;
|
|
}>({
|
|
type: 'object',
|
|
properties: {
|
|
requested: { type: 'boolean' },
|
|
// IExportOperation has complex/dynamic shape not yet in typia
|
|
exportOperation: { type: 'object' },
|
|
url: { type: 'string', nullable: true },
|
|
pendingOperationsBeforeMyRequest: { type: 'number' },
|
|
success: { type: 'boolean', enum: [true] },
|
|
},
|
|
required: ['requested', 'exportOperation', 'pendingOperationsBeforeMyRequest', 'success'],
|
|
additionalProperties: false,
|
|
}),
|
|
401: validateUnauthorizedErrorResponse,
|
|
},
|
|
},
|
|
async function action() {
|
|
const { fullExport = false } = this.queryParams;
|
|
const result = await requestDataDownload({ userData: this.user, fullExport: fullExport === 'true' });
|
|
|
|
return API.v1.success({
|
|
requested: Boolean(result.requested),
|
|
exportOperation: result.exportOperation,
|
|
url: result.url,
|
|
pendingOperationsBeforeMyRequest: result.pendingOperationsBeforeMyRequest,
|
|
});
|
|
},
|
|
)
|
|
.post(
|
|
'users.logoutOtherClients',
|
|
{
|
|
authRequired: true,
|
|
response: {
|
|
200: ajv.compile<{ token: string; tokenExpires: string }>({
|
|
type: 'object',
|
|
properties: {
|
|
token: { type: 'string' },
|
|
tokenExpires: { type: 'string' },
|
|
success: { type: 'boolean', enum: [true] },
|
|
},
|
|
required: ['token', 'tokenExpires', 'success'],
|
|
additionalProperties: false,
|
|
}),
|
|
401: validateUnauthorizedErrorResponse,
|
|
},
|
|
},
|
|
async function action() {
|
|
const xAuthToken = this.request.headers.get('x-auth-token') as string;
|
|
|
|
if (!xAuthToken) {
|
|
throw new Meteor.Error('error-parameter-required', 'x-auth-token is required');
|
|
}
|
|
const hashedToken = Accounts._hashLoginToken(xAuthToken);
|
|
|
|
if (!(await Users.removeNonPATLoginTokensExcept(this.userId, hashedToken))) {
|
|
throw new Meteor.Error('error-invalid-user-id', 'Invalid user id');
|
|
}
|
|
|
|
const me = (await Users.findOneById(this.userId, { projection: { 'services.resume.loginTokens': 1 } })) as Pick<IUser, 'services'>;
|
|
|
|
void notifyOnUserChange({
|
|
clientAction: 'updated',
|
|
id: this.userId,
|
|
diff: { 'services.resume.loginTokens': me.services?.resume?.loginTokens },
|
|
});
|
|
|
|
const token = me.services?.resume?.loginTokens?.find((token) => token.hashedToken === hashedToken);
|
|
|
|
const loginExp = settings.get<number>('Accounts_LoginExpiration');
|
|
|
|
const tokenExpires = (token && 'when' in token && new Date(token.when.getTime() + getLoginExpirationInMs(loginExp))) || undefined;
|
|
|
|
return API.v1.success({
|
|
token: xAuthToken,
|
|
tokenExpires: tokenExpires?.toISOString() || '',
|
|
});
|
|
},
|
|
);
|
|
|
|
API.v1.get(
|
|
'users.autocomplete',
|
|
{
|
|
authRequired: true,
|
|
query: isUsersAutocompleteProps,
|
|
response: {
|
|
200: ajv.compile<{ items: object[] }>({
|
|
type: 'object',
|
|
properties: {
|
|
// autocomplete items shape varies by permissions
|
|
items: { type: 'array' },
|
|
success: { type: 'boolean', enum: [true] },
|
|
},
|
|
required: ['items', 'success'],
|
|
additionalProperties: false,
|
|
}),
|
|
400: validateBadRequestErrorResponse,
|
|
401: validateUnauthorizedErrorResponse,
|
|
},
|
|
},
|
|
async function action() {
|
|
const { selector: selectorRaw } = this.queryParams;
|
|
|
|
const selector: { exceptions: Required<IUser>['username'][]; conditions: Filter<IUser>; term: string } = JSON.parse(selectorRaw);
|
|
|
|
try {
|
|
if (selector?.conditions) {
|
|
const canViewFullInfo = await hasPermissionAsync(this.user, 'view-full-other-user-info');
|
|
const allowedFields = canViewFullInfo ? [...Object.keys(defaultFields), ...Object.keys(fullFields)] : Object.keys(defaultFields);
|
|
|
|
if (!isValidQuery(selector.conditions, allowedFields, ['$and', '$ne', '$exists'])) {
|
|
throw new Error('error-invalid-query');
|
|
}
|
|
}
|
|
} catch (e) {
|
|
return API.v1.failure(e);
|
|
}
|
|
|
|
const hidden = await getUsersHiddenFrom(this.userId);
|
|
|
|
if (hidden && queryFiltersStatus(selector.conditions)) {
|
|
selector.conditions = { $and: [selector.conditions, { _id: { $nin: [...hidden] } }] };
|
|
}
|
|
|
|
const { items } = await findUsersToAutocomplete({ uid: this.userId, selector });
|
|
|
|
return API.v1.success({ items: redactHiddenUsers(items, hidden) });
|
|
},
|
|
);
|
|
|
|
API.v1
|
|
.post(
|
|
'users.removeOtherTokens',
|
|
{
|
|
authRequired: true,
|
|
response: {
|
|
200: voidSuccessResponse,
|
|
401: validateUnauthorizedErrorResponse,
|
|
},
|
|
},
|
|
async function action() {
|
|
await Users.removeNonLoginTokensExcept(this.userId, this.token);
|
|
return API.v1.success();
|
|
},
|
|
)
|
|
.post(
|
|
'users.resetE2EKey',
|
|
{
|
|
authRequired: true,
|
|
twoFactorRequired: true,
|
|
twoFactorOptions: { disableRememberMe: true },
|
|
body: ajv.compile<{ userId?: string; username?: string; user?: string }>({
|
|
type: 'object',
|
|
properties: {
|
|
userId: { type: 'string' },
|
|
username: { type: 'string' },
|
|
user: { type: 'string' },
|
|
},
|
|
additionalProperties: false,
|
|
}),
|
|
response: {
|
|
200: voidSuccessResponse,
|
|
400: validateBadRequestErrorResponse,
|
|
401: validateUnauthorizedErrorResponse,
|
|
},
|
|
},
|
|
async function action() {
|
|
if ('userId' in this.bodyParams || 'username' in this.bodyParams || 'user' in this.bodyParams) {
|
|
const user = await getUserFromParams(this.bodyParams);
|
|
if (!user) {
|
|
throw new Meteor.Error('error-invalid-user-id', 'Invalid user id');
|
|
}
|
|
|
|
if (!(await hasPermissionAsync(this.user, 'edit-other-user-e2ee'))) {
|
|
throw new Meteor.Error('error-not-allowed', 'Not allowed');
|
|
}
|
|
|
|
if (!(await resetUserE2EEncriptionKey(user._id, true))) {
|
|
return API.v1.failure();
|
|
}
|
|
|
|
return API.v1.success();
|
|
}
|
|
await resetUserE2EEncriptionKey(this.userId, false);
|
|
return API.v1.success();
|
|
},
|
|
)
|
|
.post(
|
|
'users.resetTOTP',
|
|
{
|
|
authRequired: true,
|
|
twoFactorRequired: true,
|
|
twoFactorOptions: { disableRememberMe: true },
|
|
body: ajv.compile<{ userId?: string; username?: string; user?: string }>({
|
|
type: 'object',
|
|
properties: {
|
|
userId: { type: 'string' },
|
|
username: { type: 'string' },
|
|
user: { type: 'string' },
|
|
},
|
|
additionalProperties: false,
|
|
}),
|
|
response: {
|
|
200: voidSuccessResponse,
|
|
400: validateBadRequestErrorResponse,
|
|
401: validateUnauthorizedErrorResponse,
|
|
},
|
|
},
|
|
async function action() {
|
|
if ('userId' in this.bodyParams || 'username' in this.bodyParams || 'user' in this.bodyParams) {
|
|
if (!(await hasPermissionAsync(this.user, 'edit-other-user-totp'))) {
|
|
throw new Meteor.Error('error-not-allowed', 'Not allowed');
|
|
}
|
|
|
|
if (!settings.get('Accounts_TwoFactorAuthentication_Enabled')) {
|
|
throw new Meteor.Error('error-two-factor-not-enabled', 'Two factor authentication is not enabled');
|
|
}
|
|
|
|
const user = await getUserFromParams(this.bodyParams);
|
|
if (!user) {
|
|
throw new Meteor.Error('error-invalid-user-id', 'Invalid user id');
|
|
}
|
|
|
|
await resetTOTP(user._id, true);
|
|
|
|
return API.v1.success();
|
|
}
|
|
await resetTOTP(this.userId, false);
|
|
return API.v1.success();
|
|
},
|
|
);
|
|
|
|
API.v1
|
|
.get(
|
|
'users.listTeams',
|
|
{
|
|
authRequired: true,
|
|
query: isUsersListTeamsProps,
|
|
response: {
|
|
200: ajv.compile<{ teams: unknown[] }>({
|
|
type: 'object',
|
|
properties: {
|
|
teams: { type: 'array' },
|
|
success: { type: 'boolean', enum: [true] },
|
|
},
|
|
required: ['teams', 'success'],
|
|
additionalProperties: false,
|
|
}),
|
|
400: validateBadRequestErrorResponse,
|
|
401: validateUnauthorizedErrorResponse,
|
|
},
|
|
},
|
|
async function action() {
|
|
check(
|
|
this.queryParams,
|
|
Match.ObjectIncluding({
|
|
userId: Match.Maybe(String),
|
|
}),
|
|
);
|
|
|
|
const { userId } = this.queryParams;
|
|
|
|
// If the caller has permission to view all teams, there's no need to filter the teams
|
|
const adminId = (await hasPermissionAsync(this.user, 'view-all-teams')) ? undefined : this.userId;
|
|
|
|
const teams = await Team.findBySubscribedUserIds(userId, adminId);
|
|
|
|
return API.v1.success({
|
|
teams,
|
|
});
|
|
},
|
|
)
|
|
.post(
|
|
'users.logout',
|
|
{
|
|
authRequired: true,
|
|
body: isUserLogoutParamsPOST,
|
|
response: {
|
|
200: ajv.compile<{ message: string }>({
|
|
type: 'object',
|
|
properties: {
|
|
message: { type: 'string' },
|
|
success: { type: 'boolean', enum: [true] },
|
|
},
|
|
required: ['message', 'success'],
|
|
additionalProperties: false,
|
|
}),
|
|
400: validateBadRequestErrorResponse,
|
|
401: validateUnauthorizedErrorResponse,
|
|
403: validateForbiddenErrorResponse,
|
|
},
|
|
},
|
|
async function action() {
|
|
const userId = this.bodyParams.userId || this.userId;
|
|
|
|
if (userId !== this.userId && !(await hasPermissionAsync(this.user, 'logout-other-user'))) {
|
|
return API.v1.forbidden();
|
|
}
|
|
|
|
const user = await Users.findOneById(userId);
|
|
|
|
// this method logs the user out automatically, if successful returns 1, otherwise 0
|
|
if (!(await Users.unsetLoginTokens(userId))) {
|
|
throw new Meteor.Error('error-invalid-user-id', 'Invalid user id');
|
|
}
|
|
|
|
await Sessions.logoutAllByUserId(userId, this.userId);
|
|
|
|
void notifyOnUserChange({ clientAction: 'updated', id: userId, diff: { 'services.resume.loginTokens': [] } });
|
|
|
|
if (user) {
|
|
await runUserLogoutCleanUp(user);
|
|
}
|
|
|
|
return API.v1.success({
|
|
message: `User ${userId} has been logged out!`,
|
|
});
|
|
},
|
|
);
|
|
|
|
const statusType = { type: 'string', enum: ['online', 'offline', 'away', 'busy'] } as const;
|
|
|
|
API.v1
|
|
.get(
|
|
'users.getPresence',
|
|
{
|
|
authRequired: true,
|
|
query: isUsersGetPresenceParamsGET,
|
|
response: {
|
|
200: ajv.compile<{ presence: UserStatus; connectionStatus?: string; lastLogin?: Date }>({
|
|
type: 'object',
|
|
properties: {
|
|
presence: statusType,
|
|
connectionStatus: { type: 'string', nullable: true },
|
|
lastLogin: { type: 'string', nullable: true },
|
|
success: { type: 'boolean', enum: [true] },
|
|
},
|
|
required: ['presence', 'success'],
|
|
additionalProperties: false,
|
|
}),
|
|
400: validateBadRequestErrorResponse,
|
|
401: validateUnauthorizedErrorResponse,
|
|
},
|
|
},
|
|
async function action() {
|
|
if (isUserFromParams(this.queryParams, this.userId, this.user)) {
|
|
const user = await Users.findOneById(this.userId);
|
|
return API.v1.success({
|
|
presence: (user?.status || 'offline') as UserStatus,
|
|
connectionStatus: user?.statusConnection || 'offline',
|
|
...(user?.lastLogin && { lastLogin: user?.lastLogin }),
|
|
});
|
|
}
|
|
|
|
const user = await getUserFromParams(this.queryParams);
|
|
const hidden = await getUsersHiddenFrom(this.userId);
|
|
|
|
return API.v1.success({
|
|
presence: (hidden?.has(user._id) ? 'offline' : user.status || 'offline') as UserStatus,
|
|
});
|
|
},
|
|
)
|
|
.post(
|
|
'users.setStatus',
|
|
{
|
|
authRequired: true,
|
|
rateLimiterOptions: {
|
|
numRequestsAllowed: 5,
|
|
intervalTimeInMS: 60000,
|
|
},
|
|
body: ajv.compile<{
|
|
status?: Exclude<UserStatus, UserStatus.DISABLED>;
|
|
message?: string;
|
|
expiresAt?: string;
|
|
userId?: string;
|
|
username?: string;
|
|
user?: string;
|
|
}>({
|
|
type: 'object',
|
|
properties: {
|
|
status: { type: 'string', enum: ['online', 'away', 'offline', 'busy'] },
|
|
message: { type: 'string', nullable: true },
|
|
expiresAt: { type: 'string', nullable: true },
|
|
userId: { type: 'string' },
|
|
username: { type: 'string' },
|
|
user: { type: 'string' },
|
|
},
|
|
additionalProperties: false,
|
|
}),
|
|
response: {
|
|
200: voidSuccessResponse,
|
|
400: validateBadRequestErrorResponse,
|
|
401: validateUnauthorizedErrorResponse,
|
|
403: validateForbiddenErrorResponse,
|
|
},
|
|
},
|
|
async function action() {
|
|
check(
|
|
this.bodyParams,
|
|
Match.OneOf(
|
|
Match.ObjectIncluding({
|
|
status: Match.Maybe(String),
|
|
message: String,
|
|
}),
|
|
Match.ObjectIncluding({
|
|
status: String,
|
|
message: Match.Maybe(String),
|
|
}),
|
|
),
|
|
);
|
|
|
|
const user = await (async () => {
|
|
if (isUserFromParams(this.bodyParams, this.userId, this.user)) {
|
|
return Users.findOneById(this.userId);
|
|
}
|
|
if (await hasPermissionAsync(this.user, 'edit-other-user-info')) {
|
|
return getUserFromParams(this.bodyParams);
|
|
}
|
|
})();
|
|
|
|
if (!user) {
|
|
return API.v1.forbidden();
|
|
}
|
|
|
|
const { status, message, expiresAt } = this.bodyParams;
|
|
|
|
if (message && !settings.get('Accounts_AllowUserStatusMessageChange')) {
|
|
throw new Meteor.Error('error-not-allowed', 'Change status is not allowed', {
|
|
method: 'users.setStatus',
|
|
});
|
|
}
|
|
|
|
const statusExpiresAt = expiresAt ? new Date(expiresAt) : undefined;
|
|
if (statusExpiresAt && Number.isNaN(statusExpiresAt.getTime())) {
|
|
throw new Meteor.Error('error-invalid-date', 'Invalid expiresAt date string', {
|
|
method: 'users.setStatus',
|
|
});
|
|
}
|
|
|
|
if (statusExpiresAt && statusExpiresAt.getTime() <= Date.now()) {
|
|
throw new Meteor.Error('error-invalid-date', 'expiresAt must be a future date', {
|
|
method: 'users.setStatus',
|
|
});
|
|
}
|
|
|
|
// If status is missing (message-only update), keep the user's chosen status (statusDefault),
|
|
// not the computed status — otherwise a transient auto-away/offline gets pinned as a manual claim.
|
|
const effectiveStatus = status || user.statusDefault || ('online' as UserStatus);
|
|
|
|
if (effectiveStatus === 'offline' && !settings.get('Accounts_AllowInvisibleStatusOption')) {
|
|
throw new Meteor.Error('error-status-not-allowed', 'Invisible status is disabled', {
|
|
method: 'users.setStatus',
|
|
});
|
|
}
|
|
|
|
await Presence.setStatus(user._id, effectiveStatus, message, statusExpiresAt);
|
|
|
|
return API.v1.success();
|
|
},
|
|
)
|
|
.get(
|
|
'users.getStatus',
|
|
{
|
|
authRequired: true,
|
|
query: isUsersGetStatusParamsGET,
|
|
response: {
|
|
200: ajv.compile<{ _id: string; status: string; connectionStatus?: string; statusSource?: string; statusExpiresAt?: string }>({
|
|
type: 'object',
|
|
properties: {
|
|
_id: { type: 'string' },
|
|
status: statusType,
|
|
connectionStatus: { type: 'string', nullable: true },
|
|
statusSource: { type: 'string', nullable: true },
|
|
statusExpiresAt: { type: 'string', nullable: true },
|
|
success: { type: 'boolean', enum: [true] },
|
|
},
|
|
required: ['_id', 'status', 'success'],
|
|
additionalProperties: false,
|
|
}),
|
|
400: validateBadRequestErrorResponse,
|
|
401: validateUnauthorizedErrorResponse,
|
|
},
|
|
},
|
|
async function action() {
|
|
if (isUserFromParams(this.queryParams, this.userId, this.user)) {
|
|
return API.v1.success({
|
|
_id: this.userId,
|
|
connectionStatus: (this.user.statusConnection || 'offline') as 'online' | 'offline' | 'away' | 'busy',
|
|
status: (this.user.status || 'offline') as 'online' | 'offline' | 'away' | 'busy',
|
|
...(this.user.statusSource && { statusSource: this.user.statusSource }),
|
|
...(this.user.statusExpiresAt && { statusExpiresAt: this.user.statusExpiresAt.toISOString() }),
|
|
});
|
|
}
|
|
|
|
const user = await getUserFromParams(this.queryParams);
|
|
const hidden = await getUsersHiddenFrom(this.userId);
|
|
const visible = hidden?.has(user._id) ? redactStatus(user) : user;
|
|
|
|
return API.v1.success({
|
|
_id: visible._id,
|
|
status: (visible.status || 'offline') as 'online' | 'offline' | 'away' | 'busy',
|
|
...(visible.statusSource && { statusSource: visible.statusSource }),
|
|
...(visible.statusExpiresAt && { statusExpiresAt: visible.statusExpiresAt.toISOString() }),
|
|
});
|
|
},
|
|
);
|
|
|
|
API.v1.post(
|
|
'users.verifyEmail',
|
|
{
|
|
authRequired: false,
|
|
body: ajv.compile<{ token: string }>({
|
|
type: 'object',
|
|
properties: {
|
|
token: { type: 'string', minLength: 1 },
|
|
},
|
|
required: ['token'],
|
|
additionalProperties: false,
|
|
}),
|
|
response: {
|
|
200: voidSuccessResponse,
|
|
400: validateBadRequestErrorResponse,
|
|
403: validateForbiddenErrorResponse,
|
|
},
|
|
},
|
|
async function action() {
|
|
const { token } = this.bodyParams;
|
|
|
|
const user = await Users.findOneByEmailVerificationToken<Pick<IUser, '_id' | 'services' | 'emails'>>(token);
|
|
if (!user) {
|
|
return API.v1.forbidden('Verify email link expired');
|
|
}
|
|
|
|
await verifyEmail(user, token);
|
|
|
|
await runAfterVerifyEmail(user._id);
|
|
|
|
return API.v1.success();
|
|
},
|
|
);
|
|
API.v1
|
|
.post(
|
|
'users.enableTotp',
|
|
{
|
|
authRequired: true,
|
|
twoFactorRequired: true,
|
|
twoFactorOptions: { disableRememberMe: true },
|
|
rateLimiterOptions: {
|
|
numRequestsAllowed: 5,
|
|
intervalTimeInMS: 60000,
|
|
},
|
|
response: {
|
|
200: ajv.compile<{ secret: string; url: string }>({
|
|
type: 'object',
|
|
properties: {
|
|
secret: { type: 'string' },
|
|
url: { type: 'string' },
|
|
success: { type: 'boolean', enum: [true] },
|
|
},
|
|
required: ['secret', 'url', 'success'],
|
|
additionalProperties: false,
|
|
}),
|
|
400: validateBadRequestErrorResponse,
|
|
401: validateUnauthorizedErrorResponse,
|
|
},
|
|
},
|
|
async function action() {
|
|
return API.v1.success(await enableTotp(this.userId));
|
|
},
|
|
)
|
|
.post(
|
|
'users.disableTotp',
|
|
{
|
|
authRequired: true,
|
|
rateLimiterOptions: {
|
|
numRequestsAllowed: 5,
|
|
intervalTimeInMS: 60000,
|
|
},
|
|
body: ajv.compile<{ code: string }>({
|
|
type: 'object',
|
|
properties: { code: { type: 'string', minLength: 1 } },
|
|
required: ['code'],
|
|
additionalProperties: false,
|
|
}),
|
|
response: {
|
|
200: ajv.compile<{ disabled: boolean }>({
|
|
type: 'object',
|
|
properties: {
|
|
disabled: { type: 'boolean' },
|
|
success: { type: 'boolean', enum: [true] },
|
|
},
|
|
required: ['disabled', 'success'],
|
|
additionalProperties: false,
|
|
}),
|
|
400: validateBadRequestErrorResponse,
|
|
401: validateUnauthorizedErrorResponse,
|
|
},
|
|
},
|
|
async function action() {
|
|
const disabled = await disableTotp(this.userId, this.bodyParams.code);
|
|
return API.v1.success({ disabled });
|
|
},
|
|
)
|
|
.post(
|
|
'users.validateTotp',
|
|
{
|
|
authRequired: true,
|
|
twoFactorRequired: true,
|
|
twoFactorOptions: { disableRememberMe: true },
|
|
rateLimiterOptions: {
|
|
numRequestsAllowed: 5,
|
|
intervalTimeInMS: 60000,
|
|
},
|
|
body: ajv.compile<{ code: string }>({
|
|
type: 'object',
|
|
properties: { code: { type: 'string', minLength: 1 } },
|
|
required: ['code'],
|
|
additionalProperties: false,
|
|
}),
|
|
response: {
|
|
200: ajv.compile<{ codes: string[] }>({
|
|
type: 'object',
|
|
properties: {
|
|
codes: { type: 'array', items: { type: 'string' } },
|
|
success: { type: 'boolean', enum: [true] },
|
|
},
|
|
required: ['codes', 'success'],
|
|
additionalProperties: false,
|
|
}),
|
|
400: validateBadRequestErrorResponse,
|
|
401: validateUnauthorizedErrorResponse,
|
|
},
|
|
},
|
|
async function action() {
|
|
const result = await validateTotpTempToken(this.userId, this.bodyParams.code, this.request.headers.get('x-auth-token') ?? undefined);
|
|
return API.v1.success(result);
|
|
},
|
|
)
|
|
.post(
|
|
'users.regenerateTotpCodes',
|
|
{
|
|
authRequired: true,
|
|
rateLimiterOptions: {
|
|
numRequestsAllowed: 5,
|
|
intervalTimeInMS: 60000,
|
|
},
|
|
body: ajv.compile<{ code: string }>({
|
|
type: 'object',
|
|
properties: { code: { type: 'string', minLength: 1 } },
|
|
required: ['code'],
|
|
additionalProperties: false,
|
|
}),
|
|
response: {
|
|
200: ajv.compile<{ codes: string[] }>({
|
|
type: 'object',
|
|
properties: {
|
|
codes: { type: 'array', items: { type: 'string' } },
|
|
success: { type: 'boolean', enum: [true] },
|
|
},
|
|
required: ['codes', 'success'],
|
|
additionalProperties: false,
|
|
}),
|
|
400: validateBadRequestErrorResponse,
|
|
401: validateUnauthorizedErrorResponse,
|
|
},
|
|
},
|
|
async function action() {
|
|
const result = await regenerateTotpCodes(this.userId, this.bodyParams.code);
|
|
if (!result) {
|
|
return API.v1.failure('invalid-totp');
|
|
}
|
|
return API.v1.success(result);
|
|
},
|
|
)
|
|
.get(
|
|
'users.totpCodesRemaining',
|
|
{
|
|
authRequired: true,
|
|
rateLimiterOptions: {
|
|
numRequestsAllowed: 5,
|
|
intervalTimeInMS: 60000,
|
|
},
|
|
response: {
|
|
200: ajv.compile<{ remaining: number }>({
|
|
type: 'object',
|
|
properties: {
|
|
remaining: { type: 'number' },
|
|
success: { type: 'boolean', enum: [true] },
|
|
},
|
|
required: ['remaining', 'success'],
|
|
additionalProperties: false,
|
|
}),
|
|
400: validateBadRequestErrorResponse,
|
|
401: validateUnauthorizedErrorResponse,
|
|
},
|
|
},
|
|
async function action() {
|
|
return API.v1.success(await codesRemainingTotp(this.userId));
|
|
},
|
|
);
|
|
|
|
settings.watch<number>('Rate_Limiter_Limit_RegisterUser', (value) => {
|
|
const userRegisterRoute = '/api/v1/users.registerpost';
|
|
|
|
API.v1.updateRateLimiterDictionaryForRoute(userRegisterRoute, value);
|
|
});
|
|
|
|
type UsersEndpoints = ExtractRoutesFromAPI<typeof usersEndpoints>;
|
|
|
|
declare module '@rocket.chat/rest-typings' {
|
|
// eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface
|
|
interface Endpoints extends UsersEndpoints {}
|
|
}
|