Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions apps/meteor/app/api/server/helpers/getUserFromParams.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,16 @@ export async function getUserFromParams<T extends boolean = false>(
user?: string;
},
full?: T,
): Promise<T extends true ? IUser : Pick<IUser, '_id' | 'username' | 'name' | 'status' | 'statusText' | 'roles'>> {
): Promise<
T extends true
? IUser
: Pick<IUser, '_id' | 'username' | 'name' | 'status' | 'statusText' | 'statusSource' | 'statusEmoji' | 'statusExpiresAt' | 'roles'>
> {
let user;

const projection = full ? {} : { username: 1, name: 1, status: 1, statusText: 1, roles: 1 };
const projection = full
? {}
: { username: 1, name: 1, status: 1, statusText: 1, statusSource: 1, statusEmoji: 1, statusExpiresAt: 1, roles: 1 };
if (params.userId?.trim()) {
user = await Users.findOneById(params.userId, { projection });
} else if (params.username?.trim()) {
Expand Down
125 changes: 52 additions & 73 deletions apps/meteor/app/api/server/v1/users.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { MeteorError, Team, api, Calendar } from '@rocket.chat/core-services';
import type { IExportOperation, ILoginToken, IPersonalAccessToken, IUser, UserStatus } from '@rocket.chat/core-typings';
import { MeteorError, Presence, Team } from '@rocket.chat/core-services';
import type { IExportOperation, ILoginToken, IPersonalAccessToken, IUser } from '@rocket.chat/core-typings';
import { UserStatus } from '@rocket.chat/core-typings';
import { Users, Subscriptions, Sessions } from '@rocket.chat/models';
import {
isUserCreateParamsPOST,
Expand Down Expand Up @@ -29,7 +30,7 @@ import {
validateForbiddenErrorResponse,
} from '@rocket.chat/rest-typings';
import { escapeRegExp } from '@rocket.chat/string-helpers';
import { getLoginExpirationInMs, wrapExceptions } from '@rocket.chat/tools';
import { getLoginExpirationInMs } from '@rocket.chat/tools';
import { Accounts } from 'meteor/accounts-base';
import { Match, check } from 'meteor/check';
import { Meteor } from 'meteor/meteor';
Expand Down Expand Up @@ -66,7 +67,6 @@ import { saveCustomFieldsWithoutValidation } from '../../../lib/server/functions
import { saveUser } from '../../../lib/server/functions/saveUser';
import { sendWelcomeEmail } from '../../../lib/server/functions/saveUser/sendUserEmail';
import { canEditExtension } from '../../../lib/server/functions/saveUser/validateUserEditing';
import { setStatusText } from '../../../lib/server/functions/setStatusText';
import { setUserAvatar } from '../../../lib/server/functions/setUserAvatar';
import { setUsernameWithValidation } from '../../../lib/server/functions/setUsername';
import { validateCustomFields } from '../../../lib/server/functions/validateCustomFields';
Expand Down Expand Up @@ -1870,6 +1870,28 @@ API.v1

const statusType = { type: 'string', enum: ['online', 'offline', 'away', 'busy'] } as const;

const getStatusResponseSchema = ajv.compile<{
_id: string;
status: string;
connectionStatus?: string;
statusSource?: string;
statusEmoji?: string;
statusExpiresAt?: string;
}>({
type: 'object',
properties: {
_id: { type: 'string' },
status: statusType,
connectionStatus: { type: 'string', nullable: true },
statusSource: { type: 'string', nullable: true },
statusEmoji: { type: 'string', nullable: true },
statusExpiresAt: { type: 'string', nullable: true },
success: { type: 'boolean', enum: [true] },
},
required: ['_id', 'status', 'success'],
additionalProperties: false,
});

API.v1
.get(
'users.getPresence',
Expand Down Expand Up @@ -1920,6 +1942,8 @@ API.v1
body: ajv.compile<{
status?: UserStatus;
message?: string;
emoji?: string;
expiresAt?: string;
userId?: string;
username?: string;
user?: string;
Expand All @@ -1928,10 +1952,13 @@ API.v1
properties: {
status: { type: 'string', enum: ['online', 'away', 'offline', 'busy'] },
message: { type: 'string', nullable: true },
emoji: { type: 'string', nullable: true },
expiresAt: { type: 'string', format: 'date-time', nullable: true },
userId: { type: 'string' },
username: { type: 'string' },
user: { type: 'string' },
},
anyOf: [{ required: ['status'] }, { required: ['message'] }],
additionalProperties: false,
}),
response: {
Expand All @@ -1942,20 +1969,6 @@ API.v1
},
},
async function action() {
check(
this.bodyParams,
Match.OneOf(
Match.ObjectIncluding({
status: Match.Maybe(String),
message: String,
}),
Match.ObjectIncluding({
status: String,
message: Match.Maybe(String),
}),
),
);

if (!settings.get('Accounts_AllowUserStatusMessageChange')) {
throw new Meteor.Error('error-not-allowed', 'Change status is not allowed', {
method: 'users.setStatus',
Expand All @@ -1975,47 +1988,19 @@ API.v1
return API.v1.forbidden();
}

const { _id, username, roles, name } = user;
let { statusText, status } = user;

if (this.bodyParams.message || this.bodyParams.message === '') {
await setStatusText(user, this.bodyParams.message, { emit: false });
statusText = this.bodyParams.message;
}

if (this.bodyParams.status) {
const validStatus = ['online', 'away', 'offline', 'busy'];
if (validStatus.includes(this.bodyParams.status)) {
status = this.bodyParams.status;

if (status === 'offline' && !settings.get('Accounts_AllowInvisibleStatusOption')) {
throw new Meteor.Error('error-status-not-allowed', 'Invisible status is disabled', {
method: 'users.setStatus',
});
}

await Users.updateOne(
{ _id: user._id },
{
$set: {
status,
statusDefault: status,
},
},
);

void wrapExceptions(() => Calendar.cancelUpcomingStatusChanges(user._id)).suppress();
} else {
throw new Meteor.Error('error-invalid-status', 'Valid status types include online, away, offline, and busy.', {
method: 'users.setStatus',
});
}
if (this.bodyParams.status === UserStatus.OFFLINE && !settings.get('Accounts_AllowInvisibleStatusOption')) {
throw new Meteor.Error('error-status-not-allowed', 'Invisible status is disabled', {
method: 'users.setStatus',
});
}

void api.broadcast('presence.status', {
user: { status, _id, username, statusText, roles, name },
previousStatus: user.status,
});
await Presence.setStatus(
user._id,
this.bodyParams.status ?? user.status ?? UserStatus.ONLINE,
this.bodyParams.message ?? user.statusText ?? '',
this.bodyParams.emoji,
this.bodyParams.expiresAt ? new Date(this.bodyParams.expiresAt) : undefined,
);

return API.v1.success();
},
Expand All @@ -2026,17 +2011,7 @@ API.v1
authRequired: true,
query: isUsersGetStatusParamsGET,
response: {
200: ajv.compile<{ _id: string; status: string; connectionStatus?: string }>({
type: 'object',
properties: {
_id: { type: 'string' },
status: statusType,
connectionStatus: { type: 'string', nullable: true },
success: { type: 'boolean', enum: [true] },
},
required: ['_id', 'status', 'success'],
additionalProperties: false,
}),
200: getStatusResponseSchema,
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
},
Expand All @@ -2045,18 +2020,22 @@ API.v1
if (isUserFromParams(this.queryParams, this.userId, this.user)) {
return API.v1.success({
_id: this.userId,
// message: user.statusText,
connectionStatus: (this.user.statusConnection || 'offline') as 'online' | 'offline' | 'away' | 'busy',
status: (this.user.status || 'offline') as 'online' | 'offline' | 'away' | 'busy',
connectionStatus: this.user.statusConnection || 'offline',
status: this.user.status || 'offline',
statusSource: this.user.statusSource,
statusEmoji: this.user.statusEmoji,
statusExpiresAt: this.user.statusExpiresAt?.toISOString(),
});
}

const user = await getUserFromParams(this.queryParams);

return API.v1.success({
_id: user._id,
// message: user.statusText,
status: (user.status || 'offline') as 'online' | 'offline' | 'away' | 'busy',
status: user.status || 'offline',
statusSource: user.statusSource,
statusEmoji: user.statusEmoji,
statusExpiresAt: user.statusExpiresAt?.toISOString(),
});
},
);
Expand Down
1 change: 1 addition & 0 deletions apps/meteor/app/apps/server/bridges/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ export class AppUserBridge extends UserBridge {
const { status, statusText, ...updateFields } = fields;

if (status) {
// TODO: pass statusEmoji and statusExpiresAt when Apps Engine IUserBridge supports them
await Presence.setStatus(user.id, status as UserStatus, statusText);
} else if (typeof statusText === 'string') {
await setStatusText(
Expand Down
3 changes: 3 additions & 0 deletions apps/meteor/app/lib/server/functions/getFullUserData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ export const defaultFields = {
bio: 1,
reason: 1,
statusText: 1,
statusEmoji: 1,
statusSource: 1,
statusExpiresAt: 1,
avatarETag: 1,
federated: 1,
statusLivechat: 1,
Expand Down
38 changes: 24 additions & 14 deletions apps/meteor/app/user-status/server/methods/setUserStatus.ts
Original file line number Diff line number Diff line change
@@ -1,55 +1,65 @@
import { Presence } from '@rocket.chat/core-services';
import type { IUser } from '@rocket.chat/core-typings';
import { UserStatus } from '@rocket.chat/core-typings';
import type { ServerMethods } from '@rocket.chat/ddp-client';
import { check } from 'meteor/check';
import { Meteor } from 'meteor/meteor';

import { RateLimiter } from '../../../lib/server';
import { setStatusText } from '../../../lib/server/functions/setStatusText';
import { settings } from '../../../settings/server';

declare module '@rocket.chat/ddp-client' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface ServerMethods {
setUserStatus(statusType: IUser['status'], statusText: IUser['statusText']): void;
setUserStatus(
statusType: IUser['status'],
statusText: IUser['statusText'],
statusEmoji?: IUser['statusEmoji'],
statusExpiresAt?: IUser['statusExpiresAt'],
): void;
}
}

export const setUserStatusMethod = async (
user: Pick<IUser, '_id' | 'username' | 'name' | 'status' | 'roles' | 'statusText'>,
statusType: IUser['status'],
statusText: IUser['statusText'],
statusEmoji?: IUser['statusEmoji'],
statusExpiresAt?: IUser['statusExpiresAt'],
): Promise<void> => {
if (statusType) {
if (statusType === 'offline' && !settings.get('Accounts_AllowInvisibleStatusOption')) {
throw new Meteor.Error('error-status-not-allowed', 'Invisible status is disabled', {
method: 'setUserStatus',
});
}
await Presence.setStatus(user._id, statusType);
if (statusType === UserStatus.OFFLINE && !settings.get('Accounts_AllowInvisibleStatusOption')) {
throw new Meteor.Error('error-status-not-allowed', 'Invisible status is disabled', {
method: 'setUserStatus',
});
}

if (statusText || statusText === '') {
if (statusText != null) {
check(statusText, String);

if (!settings.get('Accounts_AllowUserStatusMessageChange')) {
throw new Meteor.Error('error-not-allowed', 'Not allowed', {
method: 'setUserStatus',
});
}

await setStatusText(user, statusText);
}

await Presence.setStatus(
user._id,
statusType ?? user.status ?? UserStatus.ONLINE,
statusText ?? user.statusText ?? '',
statusEmoji,
statusExpiresAt,
);
};

Meteor.methods<ServerMethods>({
setUserStatus: async (statusType, statusText) => {
setUserStatus: async (statusType, statusText, statusEmoji, statusExpiresAt) => {
const user = (await Meteor.userAsync()) as IUser;
if (!user) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'setUserStatus' });
}

await setUserStatusMethod(user, statusType, statusText);
await setUserStatusMethod(user, statusType, statusText, statusEmoji, statusExpiresAt);
},
});

Expand Down
5 changes: 3 additions & 2 deletions apps/meteor/server/methods/userPresence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { Meteor } from 'meteor/meteor';
declare module '@rocket.chat/ddp-client' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface ServerMethods {
'UserPresence:setDefaultStatus'(status: UserStatus): boolean | undefined;
'UserPresence:setDefaultStatus'(status: UserStatus): void;
'UserPresence:online'(): boolean | undefined;
'UserPresence:away'(): boolean | undefined;
}
Expand All @@ -18,7 +18,8 @@ Meteor.methods<ServerMethods>({
if (!userId) {
return;
}
return Presence.setStatus(userId, status);
// TODO: pass statusEmoji and statusExpiresAt when Meteor method supports them
void Presence.setStatus(userId, status);
},
'UserPresence:online'() {
const { userId, connection } = this;
Expand Down
Loading
Loading