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/dull-trams-follow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@rocket.chat/meteor': patch
---

Security Hotfix (https://docs.rocket.chat/docs/security-fixes-and-updates)
6 changes: 6 additions & 0 deletions .changeset/eight-areas-see.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@rocket.chat/core-services': patch
'@rocket.chat/meteor': patch
---

Fixes an issue where updating an app in quick succession could crash the server.
6 changes: 6 additions & 0 deletions .changeset/itchy-years-learn.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@rocket.chat/apps': patch
'@rocket.chat/meteor': patch
---

Fixes an issue where an app's bot user presence was incorrectly shown as Offline after updating it.
6 changes: 6 additions & 0 deletions .changeset/libretranslate-autotranslate-provider.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@rocket.chat/meteor': minor
'@rocket.chat/i18n': patch
---

Added LibreTranslate as a message auto-translation provider, alongside Google, DeepL and Microsoft. LibreTranslate can be self-hosted, enabling fully on-premise / offline message auto-translation. Configure the instance URL (and optional API key) under **Admin → Settings → Message → Auto-Translate → LibreTranslate** and select it as the Service Provider.
7 changes: 7 additions & 0 deletions .changeset/upset-rats-work.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@rocket.chat/meteor": minor
"@rocket.chat/i18n": minor
"@rocket.chat/ui-voip": minor
---

Introduces popout functionality for voice calls
539 changes: 529 additions & 10 deletions .yarn/patches/@react-aria-overlays-npm-3.25.0-2628866e6e.patch

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions apps/meteor/app/autotranslate/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import './methods/saveSettings';
import './methods/translateMessage';
import './googleTranslate';
import './deeplTranslate';
import './libreTranslate';
import './msTranslate';
import './methods/getProviderUiMetadata';

Expand Down
179 changes: 179 additions & 0 deletions apps/meteor/app/autotranslate/server/libreTranslate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
import type { IMessage, MessageAttachment, IProviderMetadata, ITranslationResult, ISupportedLanguage } from '@rocket.chat/core-typings';
import { serverFetch as fetch } from '@rocket.chat/server-fetch';

import { TranslationProviderRegistry, AutoTranslate } from './autotranslate';
import { libreLogger } from './logger';
import { i18n } from '../../../server/lib/i18n';
import { settings } from '../../settings/server';

interface ILibreTranslateLanguage {
code: string;
name: string;
targets?: string[];
}

const REQUEST_TIMEOUT = 10000;

const toLanguageTag = (code: string): string => {
try {
return new Intl.Locale(code).toString();
} catch (err) {
libreLogger.error({ msg: 'Invalid language code returned by LibreTranslate', code, err });
return code;
}
};

/**
* LibreTranslate translation service provider class representation.
* @class
* @augments AutoTranslate
*/
class LibreTranslateAutoTranslate extends AutoTranslate {
apiKey: string;

apiEndPointUrl: string;

constructor() {
super();
this.name = 'libre-translate';
this.apiEndPointUrl = '';
this.apiKey = '';

settings.watch<string>('AutoTranslate_LibreTranslateAPIURL', (value) => {
this.apiEndPointUrl = (value || '').replace(/\/$/, '');
});

settings.watch<string>('AutoTranslate_LibreTranslateAPIKey', (value) => {
this.apiKey = value;
});
}

_getProviderMetadata(): IProviderMetadata {
return {
name: this.name,
displayName: i18n.t('AutoTranslate_LibreTranslate'),
settings: this._getSettings(),
};
}

_getSettings(): IProviderMetadata['settings'] {
return {
apiKey: this.apiKey,
apiEndPointUrl: this.apiEndPointUrl,
};
}

async getSupportedLanguages(target: string): Promise<ISupportedLanguage[]> {
if (!this.apiEndPointUrl) {
return [];
}

if (this.supportedLanguages[target]) {
return this.supportedLanguages[target];
}

try {
// SECURITY: the URL comes from a setting only an admin can change, so disabling SSRF validation is safe here.
const request = await fetch(`${this.apiEndPointUrl}/languages`, {
ignoreSsrfValidation: true,
timeout: REQUEST_TIMEOUT,
});
if (!request.ok) {
throw new Error('Failed to fetch supported languages');
}

const result = (await request.json()) as ILibreTranslateLanguage[];

this.supportedLanguages[target || 'en'] = result.map(({ code, name }) => ({
language: toLanguageTag(code),
name,
}));
return this.supportedLanguages[target || 'en'];
} catch (err) {
libreLogger.error({ msg: 'Error fetching supported languages', err });
return [];
}
}

private resolveTargetLanguage(language: string, supportedLanguages: ISupportedLanguage[]): string {
if (language.includes('-') && !supportedLanguages.find((supported) => supported.language === language)) {
return language.slice(0, 2);
}
return language;
}

// `q` is sent as an array of lines; LibreTranslate returns `translatedText` as an array (one entry per line).
private async _query(lines: string[], targetLanguage: string): Promise<string | null> {
const body: Record<string, unknown> = {
q: lines,
source: 'auto',
target: targetLanguage,
format: 'text',
...(this.apiKey && { api_key: this.apiKey }),
};

// SECURITY: the URL comes from a setting only an admin can change, so disabling SSRF validation is safe here.
const result = await fetch(`${this.apiEndPointUrl}/translate`, {
method: 'POST',
ignoreSsrfValidation: true,
timeout: REQUEST_TIMEOUT,
headers: { 'Content-Type': 'application/json' },
body,
});

if (!result.ok) {
throw new Error(result.statusText);
}

const json = (await result.json()) as { translatedText?: string | string[] };
if (json.translatedText === undefined) {
return null;
}
return Array.isArray(json.translatedText) ? json.translatedText.join('\n') : json.translatedText;
}

async _translateMessage(message: IMessage, targetLanguages: string[]): Promise<ITranslationResult> {
const translations: { [k: string]: string } = {};
if (!this.apiEndPointUrl) {
return translations;
}

const msgs = message.msg.split('\n');
const supportedLanguages = await this.getSupportedLanguages('en');
for (const targetLanguage of targetLanguages) {
const language = this.resolveTargetLanguage(targetLanguage, supportedLanguages);
try {
const translatedText = await this._query(msgs, language);
if (translatedText !== null) {
translations[language] = this.deTokenize(Object.assign({}, message, { msg: translatedText }));
}
} catch (err) {
libreLogger.error({ msg: 'Error translating message', err });
}
}
return translations;
}

async _translateAttachmentDescriptions(attachment: MessageAttachment, targetLanguages: string[]): Promise<ITranslationResult> {
const translations: { [k: string]: string } = {};
if (!this.apiEndPointUrl) {
return translations;
}

const supportedLanguages = await this.getSupportedLanguages('en');
for (const targetLanguage of targetLanguages) {
const language = this.resolveTargetLanguage(targetLanguage, supportedLanguages);
try {
const translatedText = await this._query([attachment.description || attachment.text || ''], language);
if (translatedText !== null) {
translations[language] = translatedText;
}
} catch (err) {
libreLogger.error({ msg: 'Error translating message attachment', err });
}
}
return translations;
}
}

TranslationProviderRegistry.registerProvider(new LibreTranslateAutoTranslate());
2 changes: 2 additions & 0 deletions apps/meteor/app/autotranslate/server/logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,5 @@ import { Logger } from '@rocket.chat/logger';
const logger = new Logger('AutoTranslate');

export const msLogger = logger.section('Microsoft');

export const libreLogger = logger.section('LibreTranslate');
10 changes: 10 additions & 0 deletions apps/meteor/app/integrations/server/api/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { loggerMiddleware } from '../../../api/server/middlewares/logger';
import { metricsMiddleware } from '../../../api/server/middlewares/metrics';
import { tracerSpanMiddleware } from '../../../api/server/middlewares/tracer';
import type { APIActionContext } from '../../../api/server/router';
import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission';
import type { WebhookResponseItem } from '../../../lib/server/functions/processWebhookMessage';
import { processWebhookMessage } from '../../../lib/server/functions/processWebhookMessage';
import { metrics } from '../../../metrics/server';
Expand Down Expand Up @@ -168,6 +169,15 @@ async function executeIntegrationRest(
return API.v1.unavailable('Service Unavailable');
}

if (!(await hasPermissionAsync(this.user._id, 'message-impersonate'))) {
incomingLogger.error({
msg: 'Error trying to execute integration',
integration: this.request.integration.name,
error: 'The assigned user from the integration must have the message-impersonate permission.',
});
return API.v1.failure('error-user-lacks-message-impersonate-permission');
}

const defaultValues = {
channel: this.request.integration.channel,
alias: this.request.integration.alias || '',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import { removeEmpty } from '@rocket.chat/tools';
import { Match, check } from 'meteor/check';
import { Meteor } from 'meteor/meteor';

import { addUserRolesAsync } from '../../../../../server/lib/roles/addUserRoles';
import { hasPermissionAsync, hasAllPermissionAsync } from '../../../../authorization/server/functions/hasPermission';
import { methodDeprecationLogger } from '../../../../lib/server/lib/deprecationWarningLogger';
import { notifyOnIntegrationChanged } from '../../../../lib/server/lib/notifyListener';
Expand Down Expand Up @@ -83,9 +82,9 @@ export const addIncomingIntegration = async (userId: string, integration: INewIn
validateScriptEngine(integration.scriptEngine ?? 'isolated-vm');
}

const user = await Users.findOne({ username: integration.username });
const user = await Users.findOneByUsername(integration.username, { projection: { _id: 1 } });

if (!user) {
if (!user || !(await hasPermissionAsync(user._id, 'message-impersonate'))) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', {
method: 'addIncomingIntegration',
});
Expand Down Expand Up @@ -158,8 +157,6 @@ export const addIncomingIntegration = async (userId: string, integration: INewIn
}
}

await addUserRolesAsync(user._id, ['bot']);

const strippedIntegrationData = removeEmpty(integrationData);

const { insertedId } = await Integrations.insertOne(strippedIntegrationData);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import { Integrations, Subscriptions, Users, Rooms } from '@rocket.chat/models';
import { wrapExceptions } from '@rocket.chat/tools';
import { Meteor } from 'meteor/meteor';

import { addUserRolesAsync } from '../../../../../server/lib/roles/addUserRoles';
import { hasAllPermissionAsync, hasPermissionAsync } from '../../../../authorization/server/functions/hasPermission';
import { methodDeprecationLogger } from '../../../../lib/server/lib/deprecationWarningLogger';
import { notifyOnIntegrationChanged } from '../../../../lib/server/lib/notifyListener';
Expand Down Expand Up @@ -144,16 +143,14 @@ export const updateIncomingIntegration = async (
}

const username = 'username' in integration ? integration.username : currentIntegration.username;
const user = await Users.findOne({ username });
const user = await Users.findOneByUsername(username, { projection: { _id: 1, username: 1 } });

if (!user?._id) {
if (!user || !(await hasPermissionAsync(user._id, 'message-impersonate'))) {
throw new Meteor.Error('error-invalid-post-as-user', 'Invalid Post As User', {
method: 'updateIncomingIntegration',
});
}

await addUserRolesAsync(user._id, ['bot']);

const updatedIntegration = await Integrations.findOneAndUpdate(
{ _id: integrationId },
{
Expand Down
5 changes: 3 additions & 2 deletions apps/meteor/client/providers/MediaCallProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,9 @@ const MediaCallProvider = ({ children }: { children: ReactNode }) => {

const unauthorizedContextValue = useMemo(
() => ({
inRoomView: false,
setInRoomView: () => undefined,
currentViews: [],
registerView: () => undefined,
unregisterView: () => undefined,
instance: undefined,
signalEmitter: new Emitter<any>(),
audioElement: undefined,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,9 @@ const IncomingWebhookForm = ({ webhookData }: { webhookData?: Serialized<IIncomi
/>
</FieldRow>
<FieldHint id={`${usernameField}-hint-1`}>{t('Choose_the_username_that_this_integration_will_post_as')}</FieldHint>
<FieldHint id={`${usernameField}-hint-2`}>{t('Should_exists_a_user_with_this_username')}</FieldHint>
<FieldHint id={`${usernameField}-hint-2`}>
{t('User_must_exist_and_have_permission', { permission: 'message-impersonate' })}
</FieldHint>
{errors?.username && (
<FieldError aria-live='assertive' id={`${usernameField}-error`}>
{errors.username.message}
Expand Down
3 changes: 2 additions & 1 deletion apps/meteor/ee/server/apps/communication/websockets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { AppStatus } from '@rocket.chat/apps-engine/definition/AppStatus';
import { AppStatusUtils } from '@rocket.chat/apps-engine/definition/AppStatus';
import type { ISetting as AppsSetting } from '@rocket.chat/apps-engine/definition/settings';
import { api } from '@rocket.chat/core-services';
import { InstanceStatus } from '@rocket.chat/instance-status';

import { AppEvents } from './events';
import notifications from '../../../../app/notifications/server/lib/Notifications';
Expand Down Expand Up @@ -167,7 +168,7 @@ export class AppServerNotifier {
return;
}

void api.broadcast('apps.updated', appId);
void api.broadcast('apps.updated', appId, InstanceStatus.id());
}

async appStatusUpdated(appId: string, status: AppStatus): Promise<void> {
Expand Down
2 changes: 2 additions & 0 deletions apps/meteor/packages/meteor-inject-initial/lib/inject-core.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ Inject.appUrl = function (url) {
// Avoid serving app HTML for declared routes such as /sockjs/.
if (typeof RoutePolicy !== 'undefined' && RoutePolicy.classify(url)) return false;

if (url === '/voice-call-popup.html') return false;

// we currently return app HTML on all URLs by default
return true;
};
1 change: 1 addition & 0 deletions apps/meteor/public/voice-call-popup.html
12 changes: 11 additions & 1 deletion apps/meteor/server/services/apps-engine/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { AppStatusUtils } from '@rocket.chat/apps-engine/definition/AppStatus';
import type { IAppInfo } from '@rocket.chat/apps-engine/definition/metadata';
import type { AppStatusReport, IAppsEngineService } from '@rocket.chat/core-services';
import { ServiceClassInternal } from '@rocket.chat/core-services';
import { InstanceStatus } from '@rocket.chat/instance-status';

import { isRunningMs } from '../../lib/isRunningMs';
import { SystemLogger } from '../../lib/logger/system';
Expand Down Expand Up @@ -66,11 +67,20 @@ export class AppsEngineService extends ServiceClassInternal implements IAppsEngi
await Apps.self?.getManager()?.removeLocal(appId);
});

this.onEvent('apps.updated', async (appId: string): Promise<void> => {
this.onEvent('apps.updated', async (appId: string, originInstanceId?: string): Promise<void> => {
Apps.self?.getRocketChatLogger().debug({
msg: '"apps.updated" event received for app',
appId,
});

if (originInstanceId && originInstanceId === InstanceStatus.id()) {
Apps.self?.getRocketChatLogger().debug({
msg: '"apps.updated" event ignored: originated from this instance',
appId,
});
return;
}

const storageItem = await Apps.self?.getStorage()?.retrieveOne(appId);
if (!storageItem) {
Apps.self?.getRocketChatLogger().info({
Expand Down
Loading
Loading