Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/crisp-corners-itch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@rocket.chat/meteor': patch
---

Fixes S3 file upload failing when the region setting is empty or the endpoint is configured without a URL scheme
5 changes: 5 additions & 0 deletions .changeset/fluffy-poets-check.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@rocket.chat/media-signaling': minor
---

Fixes a soft lock issue where the call negotiation doesn't proceed if the call is accepted while still being initialized
11 changes: 11 additions & 0 deletions .changeset/full-results-share.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@rocket.chat/presence-service': minor
'@rocket.chat/core-services': patch
'@rocket.chat/model-typings': patch
'@rocket.chat/core-typings': patch
'@rocket.chat/presence': minor
'@rocket.chat/models': patch
'@rocket.chat/meteor': minor
---

Adds the backend foundation for a unified presence engine with a priority-based claim system (internal > manual > external), status expiration, and previous state restore.
5 changes: 5 additions & 0 deletions .changeset/old-cities-enjoy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@rocket.chat/meteor": patch
---

fixes issue that caused threads to sometimes not scroll when sending messages
16 changes: 8 additions & 8 deletions .github/actions/update-version-durability/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

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' | 'statusDefault' | 'statusText' | 'statusSource' | '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, statusDefault: 1, statusText: 1, statusSource: 1, statusExpiresAt: 1, roles: 1 };
if (params.userId?.trim()) {
user = await Users.findOneById(params.userId, { projection });
} else if (params.username?.trim()) {
Expand Down
75 changes: 36 additions & 39 deletions apps/meteor/app/api/server/v1/users.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { MeteorError, Team, api, Calendar } from '@rocket.chat/core-services';
import { MeteorError, Presence, Team, Calendar } 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 {
Expand Down Expand Up @@ -67,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 @@ -1539,6 +1538,8 @@ API.v1.get(
status: 1,
utcOffset: 1,
statusText: 1,
statusSource: 1,
statusExpiresAt: 1,
avatarETag: 1,
},
};
Expand Down Expand Up @@ -1943,6 +1944,7 @@ API.v1
body: ajv.compile<{
status?: UserStatus;
message?: string;
expiresAt?: string;
userId?: string;
username?: string;
user?: string;
Expand All @@ -1951,6 +1953,7 @@ API.v1
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' },
Expand Down Expand Up @@ -1998,47 +2001,37 @@ 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;
const validStatus = ['online', 'away', 'offline', 'busy'];
if (this.bodyParams.status && !validStatus.includes(this.bodyParams.status)) {
throw new Meteor.Error('error-invalid-status', 'Valid status types include online, away, offline, and busy.', {
method: 'users.setStatus',
});
}

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

if (status === 'offline' && !settings.get('Accounts_AllowInvisibleStatusOption')) {
throw new Meteor.Error('error-status-not-allowed', 'Invisible status is disabled', {
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',
});
}

await Users.updateOne(
{ _id: user._id },
{
$set: {
status,
statusDefault: status,
},
},
);
// 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);

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 (effectiveStatus === '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, effectiveStatus, message, statusExpiresAt);

if (status) {
void wrapExceptions(() => Calendar.cancelUpcomingStatusChanges(user._id)).suppress();
}

return API.v1.success();
},
Expand All @@ -2049,12 +2042,14 @@ API.v1
authRequired: true,
query: isUsersGetStatusParamsGET,
response: {
200: ajv.compile<{ _id: string; status: string; connectionStatus?: string }>({
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'],
Expand All @@ -2068,18 +2063,20 @@ 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',
...(this.user.statusSource && { statusSource: this.user.statusSource }),
...(this.user.statusExpiresAt && { 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',
...(user.statusSource && { statusSource: user.statusSource }),
...(user.statusExpiresAt && { statusExpiresAt: user.statusExpiresAt.toISOString() }),
});
},
);
Expand Down
23 changes: 22 additions & 1 deletion apps/meteor/app/apps/server/bridges/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { IAppServerOrchestrator } from '@rocket.chat/apps';
import { UserBridge } from '@rocket.chat/apps/dist/server/bridges/UserBridge';
import type { IUserCreationOptions, IUser, UserType } from '@rocket.chat/apps-engine/definition/users';
import { Presence } from '@rocket.chat/core-services';
import type { UserStatus } from '@rocket.chat/core-typings';
import type { PresenceSource, UserStatus } from '@rocket.chat/core-typings';
import { Subscriptions, Users } from '@rocket.chat/models';
import { Random } from '@rocket.chat/random';

Expand Down Expand Up @@ -179,6 +179,27 @@ export class AppUserBridge extends UserBridge {
return true;
}

protected async setActiveState(
userId: IUser['id'],
state: Pick<IUser, 'statusDefault' | 'statusSource' | 'statusText' | 'statusExpiresAt'>,
appId: string,
): Promise<void> {
this.orch.debugLog(`The App ${appId} is setting active state for user ${userId}`);

await Presence.setActiveState(userId, {
statusDefault: state.statusDefault as UserStatus,
statusText: state.statusText,
statusSource: state.statusSource as PresenceSource,
...(state.statusExpiresAt && { statusExpiresAt: state.statusExpiresAt }),
});
}

protected async endActiveState(userId: IUser['id'], appId: string): Promise<void> {
this.orch.debugLog(`The App ${appId} is ending active state for user ${userId}`);

await Presence.endActiveState(userId);
}

protected async getActiveUserCount(): Promise<number> {
return Users.getActiveLocalUserCount();
}
Expand Down
18 changes: 17 additions & 1 deletion apps/meteor/app/file-upload/server/config/AmazonS3.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,14 @@ import https from 'node:https';
import _ from 'underscore';

import { forceDownload } from './helper';
import { SystemLogger } from '../../../../server/lib/logger/system';
import { settings } from '../../../settings/server';
import type { S3Options } from '../../ufs/AmazonS3/server';
import { FileUploadClass, FileUpload } from '../lib/FileUpload';
import '../../ufs/AmazonS3/server';

const hasScheme = (value: string) => /^[a-z][a-z0-9+.-]*:\/\//i.test(value);

const get: FileUploadClass['get'] = async function (this: FileUploadClass, file, req, res) {
const forcedDownload = forceDownload(req);

Expand Down Expand Up @@ -94,15 +97,28 @@ const configure = _.debounce(() => {
config.connection.region = Region;
}

// Back-compat: AWS SDK v2 defaulted unset region to us-east-1; v3 throws.
if (!Region && !process.env.AWS_REGION) {
config.connection.region = 'us-east-1';
SystemLogger.warn(
'FileUpload_S3_Region is empty and AWS_REGION is not set; defaulting to us-east-1. Set FileUpload_S3_Region or AWS_REGION to silence this warning.',
);
}

if (AWSAccessKeyId && AWSSecretAccessKey) {
config.connection.credentials = {
accessKeyId: AWSAccessKeyId,
secretAccessKey: AWSSecretAccessKey,
};
}

// Back-compat: AWS SDK v2 accepted scheme-less endpoints; v3 throws.
if (BucketURL) {
config.connection.endpoint = BucketURL;
const isValidScheme = hasScheme(BucketURL);
config.connection.endpoint = isValidScheme ? BucketURL : `https://${BucketURL}`;
if (!isValidScheme) {
SystemLogger.warn(`FileUpload_S3_BucketURL "${BucketURL}" has no scheme; defaulting to "https://${BucketURL}".`);
}
}

AmazonS3Uploads.store = FileUpload.configureUploadsStore('AmazonS3', AmazonS3Uploads.name, config);
Expand Down
2 changes: 2 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,8 @@ export const defaultFields = {
bio: 1,
reason: 1,
statusText: 1,
statusSource: 1,
statusExpiresAt: 1,
avatarETag: 1,
federated: 1,
statusLivechat: 1,
Expand Down
11 changes: 5 additions & 6 deletions apps/meteor/app/slashcommands-status/server/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,11 @@ slashCommands.add({
return;
}

const user = await Users.findOneById<Pick<IUser, '_id' | 'username' | 'name' | 'status' | 'roles' | 'statusText' | 'language'>>(
userId,
{
projection: { language: 1, username: 1, name: 1, status: 1, roles: 1, statusText: 1 },
},
);
const user = await Users.findOneById<
Pick<IUser, '_id' | 'username' | 'name' | 'status' | 'statusDefault' | 'roles' | 'statusText' | 'language'>
>(userId, {
projection: { language: 1, username: 1, name: 1, status: 1, statusDefault: 1, roles: 1, statusText: 1 },
});
const lng = user?.language || settings.get('Language') || 'en';

if (!user) {
Expand Down
33 changes: 13 additions & 20 deletions apps/meteor/app/user-status/server/methods/setUserStatus.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
import { Presence } from '@rocket.chat/core-services';
import type { IUser } from '@rocket.chat/core-typings';
import { UserStatus, type IUser } 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' {
Expand All @@ -16,30 +14,25 @@ declare module '@rocket.chat/ddp-client' {
}

export const setUserStatusMethod = async (
user: Pick<IUser, '_id' | 'username' | 'name' | 'status' | 'roles' | 'statusText'>,
user: Pick<IUser, '_id' | 'username' | 'name' | 'status' | 'statusDefault' | 'roles' | 'statusText'>,
statusType: IUser['status'],
statusText: IUser['statusText'],
): 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 (statusText != null && !settings.get('Accounts_AllowUserStatusMessageChange')) {
throw new Meteor.Error('error-not-allowed', 'Not allowed', {
method: 'setUserStatus',
});
}

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

if (!settings.get('Accounts_AllowUserStatusMessageChange')) {
throw new Meteor.Error('error-not-allowed', 'Not allowed', {
method: 'setUserStatus',
});
}
const effectiveStatus = statusType || user.statusDefault || UserStatus.ONLINE;

await setStatusText(user, statusText);
if (effectiveStatus === UserStatus.OFFLINE && !settings.get('Accounts_AllowInvisibleStatusOption')) {
throw new Meteor.Error('error-status-not-allowed', 'Invisible status is disabled', {
method: 'setUserStatus',
});
}

await Presence.setStatus(user._id, effectiveStatus, statusText);
};

Meteor.methods<ServerMethods>({
Expand Down
Loading
Loading