diff --git a/.changeset/crisp-corners-itch.md b/.changeset/crisp-corners-itch.md new file mode 100644 index 0000000000000..1a02cccba6f9a --- /dev/null +++ b/.changeset/crisp-corners-itch.md @@ -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 diff --git a/.changeset/fluffy-poets-check.md b/.changeset/fluffy-poets-check.md new file mode 100644 index 0000000000000..4ecdb9e7788ac --- /dev/null +++ b/.changeset/fluffy-poets-check.md @@ -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 diff --git a/.changeset/full-results-share.md b/.changeset/full-results-share.md new file mode 100644 index 0000000000000..2b681e3cecebe --- /dev/null +++ b/.changeset/full-results-share.md @@ -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. diff --git a/.changeset/old-cities-enjoy.md b/.changeset/old-cities-enjoy.md new file mode 100644 index 0000000000000..581cafa174ceb --- /dev/null +++ b/.changeset/old-cities-enjoy.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +fixes issue that caused threads to sometimes not scroll when sending messages diff --git a/.github/actions/update-version-durability/package-lock.json b/.github/actions/update-version-durability/package-lock.json index 3465de9655c90..c48e783a33d09 100644 --- a/.github/actions/update-version-durability/package-lock.json +++ b/.github/actions/update-version-durability/package-lock.json @@ -422,16 +422,16 @@ } }, "node_modules/form-data": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" + "hasown": "^2.0.4", + "mime-types": "^2.1.35" }, "engines": { "node": ">= 6" @@ -523,9 +523,9 @@ } }, "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "license": "MIT", "dependencies": { "function-bind": "^1.1.2" diff --git a/apps/meteor/app/api/server/helpers/getUserFromParams.ts b/apps/meteor/app/api/server/helpers/getUserFromParams.ts index 984658371da2c..2ac656c1ecb76 100644 --- a/apps/meteor/app/api/server/helpers/getUserFromParams.ts +++ b/apps/meteor/app/api/server/helpers/getUserFromParams.ts @@ -10,10 +10,16 @@ export async function getUserFromParams( user?: string; }, full?: T, -): Promise> { +): Promise< + T extends true + ? IUser + : Pick +> { 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()) { diff --git a/apps/meteor/app/api/server/v1/users.ts b/apps/meteor/app/api/server/v1/users.ts index fbc46b9579a67..e1d56b97cb738 100644 --- a/apps/meteor/app/api/server/v1/users.ts +++ b/apps/meteor/app/api/server/v1/users.ts @@ -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 { @@ -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'; @@ -1539,6 +1538,8 @@ API.v1.get( status: 1, utcOffset: 1, statusText: 1, + statusSource: 1, + statusExpiresAt: 1, avatarETag: 1, }, }; @@ -1943,6 +1944,7 @@ API.v1 body: ajv.compile<{ status?: UserStatus; message?: string; + expiresAt?: string; userId?: string; username?: string; user?: string; @@ -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' }, @@ -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(); }, @@ -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'], @@ -2068,9 +2063,10 @@ 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() }), }); } @@ -2078,8 +2074,9 @@ API.v1 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() }), }); }, ); diff --git a/apps/meteor/app/apps/server/bridges/users.ts b/apps/meteor/app/apps/server/bridges/users.ts index 816416fa0002c..1caa744316e7b 100644 --- a/apps/meteor/app/apps/server/bridges/users.ts +++ b/apps/meteor/app/apps/server/bridges/users.ts @@ -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'; @@ -179,6 +179,27 @@ export class AppUserBridge extends UserBridge { return true; } + protected async setActiveState( + userId: IUser['id'], + state: Pick, + appId: string, + ): Promise { + 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 { + this.orch.debugLog(`The App ${appId} is ending active state for user ${userId}`); + + await Presence.endActiveState(userId); + } + protected async getActiveUserCount(): Promise { return Users.getActiveLocalUserCount(); } diff --git a/apps/meteor/app/file-upload/server/config/AmazonS3.ts b/apps/meteor/app/file-upload/server/config/AmazonS3.ts index 77a554e46b7af..f5129b1a8daca 100644 --- a/apps/meteor/app/file-upload/server/config/AmazonS3.ts +++ b/apps/meteor/app/file-upload/server/config/AmazonS3.ts @@ -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); @@ -94,6 +97,14 @@ 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, @@ -101,8 +112,13 @@ const configure = _.debounce(() => { }; } + // 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); diff --git a/apps/meteor/app/lib/server/functions/getFullUserData.ts b/apps/meteor/app/lib/server/functions/getFullUserData.ts index 6dcbe771e9bb3..08f9df9f83716 100644 --- a/apps/meteor/app/lib/server/functions/getFullUserData.ts +++ b/apps/meteor/app/lib/server/functions/getFullUserData.ts @@ -18,6 +18,8 @@ export const defaultFields = { bio: 1, reason: 1, statusText: 1, + statusSource: 1, + statusExpiresAt: 1, avatarETag: 1, federated: 1, statusLivechat: 1, diff --git a/apps/meteor/app/slashcommands-status/server/status.ts b/apps/meteor/app/slashcommands-status/server/status.ts index 0a1f02b182571..89bad86a4a373 100644 --- a/apps/meteor/app/slashcommands-status/server/status.ts +++ b/apps/meteor/app/slashcommands-status/server/status.ts @@ -14,12 +14,11 @@ slashCommands.add({ return; } - const user = await Users.findOneById>( - userId, - { - projection: { language: 1, username: 1, name: 1, status: 1, roles: 1, statusText: 1 }, - }, - ); + const user = await Users.findOneById< + Pick + >(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) { diff --git a/apps/meteor/app/user-status/server/methods/setUserStatus.ts b/apps/meteor/app/user-status/server/methods/setUserStatus.ts index 0235c4b681853..5ad743df9fb7b 100644 --- a/apps/meteor/app/user-status/server/methods/setUserStatus.ts +++ b/apps/meteor/app/user-status/server/methods/setUserStatus.ts @@ -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' { @@ -16,30 +14,25 @@ declare module '@rocket.chat/ddp-client' { } export const setUserStatusMethod = async ( - user: Pick, + user: Pick, statusType: IUser['status'], statusText: IUser['statusText'], ): Promise => { - 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({ diff --git a/apps/meteor/app/utils/server/functions/getBaseUserFields.ts b/apps/meteor/app/utils/server/functions/getBaseUserFields.ts index 6ee9992826727..acd5233047ddc 100644 --- a/apps/meteor/app/utils/server/functions/getBaseUserFields.ts +++ b/apps/meteor/app/utils/server/functions/getBaseUserFields.ts @@ -10,6 +10,8 @@ export const getBaseUserFields = (allowServiceKeys = false): UserFields => ({ 'status': 1, 'statusDefault': 1, 'statusText': 1, + 'statusSource': 1, + 'statusExpiresAt': 1, 'statusConnection': 1, 'bio': 1, 'avatarOrigin': 1, diff --git a/apps/meteor/client/components/CreateDiscussion/__snapshots__/CreateDiscussion.spec.tsx.snap b/apps/meteor/client/components/CreateDiscussion/__snapshots__/CreateDiscussion.spec.tsx.snap index f79ddc71ebcc7..915ce0fc0ed33 100644 --- a/apps/meteor/client/components/CreateDiscussion/__snapshots__/CreateDiscussion.spec.tsx.snap +++ b/apps/meteor/client/components/CreateDiscussion/__snapshots__/CreateDiscussion.spec.tsx.snap @@ -215,6 +215,7 @@ exports[`CreateDiscussion renders Default without crashing 1`] = ` aria-haspopup="listbox" aria-invalid="false" aria-labelledby="react-aria-:r4:-label" + autocomplete="off" class="rcx-box rcx-box--full rcx-box--animated rcx-input-box--undecorated rcx-input-box rcx-css-trljwa rcx-css-pqpp8q" id="react-aria-:r4:" name="usernames" diff --git a/apps/meteor/client/components/message/toolbar/items/actions/Timestamp/TimestampPicker/__snapshots__/TimestampPicker.spec.tsx.snap b/apps/meteor/client/components/message/toolbar/items/actions/Timestamp/TimestampPicker/__snapshots__/TimestampPicker.spec.tsx.snap index a504f39d3e63c..433706e37a582 100644 --- a/apps/meteor/client/components/message/toolbar/items/actions/Timestamp/TimestampPicker/__snapshots__/TimestampPicker.spec.tsx.snap +++ b/apps/meteor/client/components/message/toolbar/items/actions/Timestamp/TimestampPicker/__snapshots__/TimestampPicker.spec.tsx.snap @@ -213,54 +213,58 @@ exports[`Story Tests renders Default without crashing 1`] = ` class="rcx-box rcx-box--full rcx-field__row" >