diff --git a/.changeset/metal-regions-yell.md b/.changeset/metal-regions-yell.md new file mode 100644 index 0000000000000..626b327792526 --- /dev/null +++ b/.changeset/metal-regions-yell.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Fixes an issue where temporary AD/LDAP lockouts would deactivate users on rocket.chat. diff --git a/.changeset/rich-bananas-shine.md b/.changeset/rich-bananas-shine.md new file mode 100644 index 0000000000000..eacb88108a0f7 --- /dev/null +++ b/.changeset/rich-bananas-shine.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Security Hotfix (https://docs.rocket.chat/docs/security-fixes-and-updates) diff --git a/.changeset/slick-hats-arrive.md b/.changeset/slick-hats-arrive.md new file mode 100644 index 0000000000000..4ce84a4a831ee --- /dev/null +++ b/.changeset/slick-hats-arrive.md @@ -0,0 +1,7 @@ +--- +'@rocket.chat/federation-matrix': patch +'@rocket.chat/core-typings': patch +'@rocket.chat/meteor': patch +--- + +Fixes an issue where `description` was incorrectly being used as alternative text for image attachments diff --git a/apps/meteor/app/apple/lib/handleIdentityToken.spec.ts b/apps/meteor/app/apple/lib/handleIdentityToken.spec.ts new file mode 100644 index 0000000000000..b63c3f9da6e4e --- /dev/null +++ b/apps/meteor/app/apple/lib/handleIdentityToken.spec.ts @@ -0,0 +1,133 @@ +import { generateKeyPairSync, sign } from 'node:crypto'; + +import { serverFetch } from '@rocket.chat/server-fetch'; +import { Response } from 'node-fetch'; + +import { handleIdentityToken } from './handleIdentityToken'; + +jest.mock('@rocket.chat/server-fetch', () => ({ + serverFetch: jest.fn(), +})); + +const { publicKey, privateKey } = generateKeyPairSync('rsa', { + modulusLength: 2048, +}); + +const jwkPublicKey = publicKey.export({ format: 'jwk' }); + +const toBase64Url = (obj: unknown) => Buffer.from(JSON.stringify(obj)).toString('base64url'); + +describe('handleIdentityToken', () => { + const mockClientId = 'com.yourcompany.app'; + + beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers().setSystemTime(new Date('2024-01-01T00:00:00Z')); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('should throw an error if the token has the wrong audience', async () => { + const header = toBase64Url({ alg: 'RS256', kid: 'mock-key-id' }); + const payload = toBase64Url({ + iss: 'https://appleid.apple.com', + aud: 'wrong.client.id', + exp: Math.floor(Date.now() / 1000) + 3600, + sub: 'user123', + }); + + const mockToken = `${header}.${payload}.dummySignature`; + + await expect(handleIdentityToken(mockToken, mockClientId)).rejects.toThrow('identityToken is not a valid Apple JWT or has expired'); + }); + + it('should successfully validate a valid token', async () => { + const headerB64 = toBase64Url({ alg: 'RS256', kid: 'mock-key-id' }); + const payloadB64 = toBase64Url({ + iss: 'https://appleid.apple.com', + aud: mockClientId, + exp: Math.floor(Date.now() / 1000) + 3600, + sub: 'user123', + }); + + const signatureBytes = sign('RSA-SHA256', Buffer.from(`${headerB64}.${payloadB64}`), privateKey); + const signatureB64 = signatureBytes.toString('base64url'); + + const validMockToken = `${headerB64}.${payloadB64}.${signatureB64}`; + + if (!jwkPublicKey.n || !jwkPublicKey.e) { + throw new Error('Generated test key is missing modulus or exponent'); + } + + const mockJwksPayload = { + keys: [ + { + kty: 'RSA', + kid: 'mock-key-id', + use: 'sig', + alg: 'RS256', + n: jwkPublicKey.n, + e: jwkPublicKey.e, + }, + ], + }; + + jest.mocked(serverFetch).mockResolvedValue( + new Response(JSON.stringify(mockJwksPayload), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ); + + const result = await handleIdentityToken(validMockToken, mockClientId); + + expect(result.id).toBe('user123'); + expect(result.iss).toBe('https://appleid.apple.com'); + }); + + it('should accept default mobile audience when client id setting is empty', async () => { + const headerB64 = toBase64Url({ alg: 'RS256', kid: 'mock-key-id' }); + const payloadB64 = toBase64Url({ + iss: 'https://appleid.apple.com', + aud: 'chat.rocket.ios', + exp: Math.floor(Date.now() / 1000) + 3600, + sub: 'user123', + }); + + const signatureBytes = sign('RSA-SHA256', Buffer.from(`${headerB64}.${payloadB64}`), privateKey); + const signatureB64 = signatureBytes.toString('base64url'); + + const validMockToken = `${headerB64}.${payloadB64}.${signatureB64}`; + + if (!jwkPublicKey.n || !jwkPublicKey.e) { + throw new Error('Generated test key is missing modulus or exponent'); + } + + const mockJwksPayload = { + keys: [ + { + kty: 'RSA', + kid: 'mock-key-id', + use: 'sig', + alg: 'RS256', + n: jwkPublicKey.n, + e: jwkPublicKey.e, + }, + ], + }; + + jest.mocked(serverFetch).mockResolvedValue( + new Response(JSON.stringify(mockJwksPayload), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ); + + const result = await handleIdentityToken(validMockToken, ''); + + expect(result.id).toBe('user123'); + expect(result.aud).toBe('chat.rocket.ios'); + }); +}); diff --git a/apps/meteor/app/apple/lib/handleIdentityToken.ts b/apps/meteor/app/apple/lib/handleIdentityToken.ts index f3ab1c8f9e66f..ab4a9378b906d 100644 --- a/apps/meteor/app/apple/lib/handleIdentityToken.ts +++ b/apps/meteor/app/apple/lib/handleIdentityToken.ts @@ -1,52 +1,165 @@ +import { createPublicKey, verify } from 'node:crypto'; + import { serverFetch as fetch } from '@rocket.chat/server-fetch'; -import { KJUR } from 'jsrsasign'; -import NodeRSA from 'node-rsa'; -async function isValidAppleJWT(identityToken: string, header: any): Promise { - const request = await fetch('https://appleid.apple.com/auth/keys', { - method: 'GET', - // SECURITY: Hardcoded URL, no SSRF protection needed - ignoreSsrfValidation: true, - }); - const applePublicKeys = ((await request.json()) as { keys: { kid: string; e: string; n: string }[] }).keys; - const { kid } = header; +type AppleJWK = { + kty: string; + kid: string; + use: string; + alg: string; + n: string; + e: string; +}; + +type AppleJWTPayload = { + iss: string; + sub: string; + aud: string | string[]; + exp: number; + iat: number; + email?: string; + email_verified?: string | boolean; + is_private_email?: string | boolean; +}; + +const DEFAULT_APPLE_AUDIENCES = ['chat.rocket.ios']; + +let cachedKeys: AppleJWK[] | null = null; +let lastFetchTime = 0; +const CACHE_TTL_MS = 1000 * 60 * 60 * 24; // 24 hours + +async function getApplePublicKeys(forceRefresh = false): Promise { + const now = Date.now(); - const key = applePublicKeys.find((k: any) => k.kid === kid); - if (!key) { - return false; + if (!forceRefresh && cachedKeys && now - lastFetchTime < CACHE_TTL_MS) { + return cachedKeys; } - const pubKey = new NodeRSA(); - pubKey.importKey({ n: Buffer.from(key.n, 'base64'), e: Buffer.from(key.e, 'base64') }, 'components-public'); - const userKey = pubKey.exportKey('public'); + try { + const response = await fetch('https://appleid.apple.com/auth/keys', { + method: 'GET', + // SECURITY: Hardcoded URL, no SSRF protection needed + ignoreSsrfValidation: true, + }); + + if (!response.ok) { + throw new Error(`Failed to fetch Apple keys: ${response.status} ${response.statusText}`); + } + + const data = (await response.json()) as { keys: AppleJWK[] }; + cachedKeys = data.keys; + lastFetchTime = now; + + return cachedKeys; + } catch (error) { + if (cachedKeys) { + console.warn('Failed to refresh Apple public keys, using stale cache', error); + return cachedKeys; + } + throw new Error('Could not retrieve Apple public keys', { cause: error }); + } +} + +function decodeBase64Url(str: string): string { + return Buffer.from(str, 'base64url').toString('utf8'); +} + +async function verifyAppleJWT( + headerB64: string, + payloadB64: string, + signatureB64: string, + clientId: string, +): Promise { + const header = JSON.parse(decodeBase64Url(headerB64)); + const payload = JSON.parse(decodeBase64Url(payloadB64)) as AppleJWTPayload; + + const nowInSeconds = Math.floor(Date.now() / 1000); + + if (payload.exp < nowInSeconds) { + console.error('Apple JWT has expired'); + return null; + } + + if (payload.iss !== 'https://appleid.apple.com') { + console.error('Invalid issuer. Expected https://appleid.apple.com'); + return null; + } + + const audArray = Array.isArray(payload.aud) ? payload.aud : [payload.aud]; + + const configuredAudiences = clientId + .split(',') + .map((id) => id.trim()) + .filter(Boolean); + + const allowedAudiences = Array.from(new Set([...DEFAULT_APPLE_AUDIENCES, ...configuredAudiences])); + + const isAudienceValid = allowedAudiences.some((allowedAud) => audArray.includes(allowedAud)); + + if (!isAudienceValid) { + console.error(`Invalid audience. Expected one of: ${allowedAudiences.join(', ')}`); + return null; + } + + let applePublicKeys = await getApplePublicKeys(); + let keyData = applePublicKeys.find((k) => k.kid === header.kid); + + if (!keyData) { + applePublicKeys = await getApplePublicKeys(true); // Force refresh + keyData = applePublicKeys.find((k) => k.kid === header.kid); + + if (!keyData) { + console.error('Matching Key ID (kid) not found in Apple JWKS'); + return null; + } + } try { - return KJUR.jws.JWS.verify(identityToken, userKey, ['RS256']); - } catch { - return false; + const publicKey = createPublicKey({ + key: { + kty: keyData.kty, + n: keyData.n, + e: keyData.e, + }, + format: 'jwk', + }); + + const isSignatureValid = verify( + 'RSA-SHA256', + Buffer.from(`${headerB64}.${payloadB64}`), + publicKey, + Buffer.from(signatureB64, 'base64url'), + ); + + return isSignatureValid ? payload : null; + } catch (error) { + console.error('Cryptographic signature verification failed:', error); + return null; } } -export async function handleIdentityToken(identityToken: string): Promise<{ id: string; email: string; name: string }> { - const decodedToken = KJUR.jws.JWS.parse(identityToken); +export async function handleIdentityToken(identityToken: string, clientId: string): Promise> { + const parts = identityToken.split('.'); - if (!(await isValidAppleJWT(identityToken, decodedToken.headerObj))) { - throw new Error('identityToken is not a valid JWT'); + if (parts.length !== 3) { + throw new Error('Malformed identityToken: JWT must have 3 parts'); } - if (!decodedToken.payloadObj) { - throw new Error('identityToken does not have a payload'); + const [headerB64, payloadB64, signatureB64] = parts; + + const payload = await verifyAppleJWT(headerB64, payloadB64, signatureB64, clientId); + + if (!payload) { + throw new Error('identityToken is not a valid Apple JWT or has expired'); } - const { iss, sub, email } = decodedToken.payloadObj as any; - if (!iss) { - throw new Error('Insufficient data in auth response token'); + if (!payload.sub) { + throw new Error('Insufficient data: Missing subject (sub) in auth response token'); } const serviceData = { - id: sub, - email, - name: '', + id: payload.sub, + ...payload, }; return serviceData; diff --git a/apps/meteor/app/apple/server/AppleCustomOAuth.ts b/apps/meteor/app/apple/server/AppleCustomOAuth.ts index d6617b7b5e81d..ac43edf1e3254 100644 --- a/apps/meteor/app/apple/server/AppleCustomOAuth.ts +++ b/apps/meteor/app/apple/server/AppleCustomOAuth.ts @@ -2,6 +2,7 @@ import { MeteorError } from '@rocket.chat/core-services'; import { Accounts } from 'meteor/accounts-base'; import { CustomOAuth } from '../../custom-oauth/server/custom_oauth_server'; +import { settings } from '../../settings/server'; import { handleIdentityToken } from '../lib/handleIdentityToken'; export class AppleCustomOAuth extends CustomOAuth { @@ -16,7 +17,9 @@ export class AppleCustomOAuth extends CustomOAuth { } try { - const serviceData = await handleIdentityToken(identityToken); + const clientId = settings.get('Accounts_OAuth_Apple_id') || ''; + + const serviceData = await handleIdentityToken(identityToken, clientId); if (usrObj?.name) { serviceData.name = `${usrObj.name.firstName}${usrObj.name.middleName ? ` ${usrObj.name.middleName}` : ''}${ diff --git a/apps/meteor/app/apple/server/appleOauthRegisterService.ts b/apps/meteor/app/apple/server/appleOauthRegisterService.ts index 7cb748c7ab917..f236b9598b23f 100644 --- a/apps/meteor/app/apple/server/appleOauthRegisterService.ts +++ b/apps/meteor/app/apple/server/appleOauthRegisterService.ts @@ -1,4 +1,5 @@ -import { KJUR } from 'jsrsasign'; +import { createPrivateKey, sign } from 'node:crypto'; + import { ServiceConfiguration } from 'meteor/service-configuration'; import { AppleCustomOAuth } from './AppleCustomOAuth'; @@ -7,6 +8,29 @@ import { config } from '../lib/config'; new AppleCustomOAuth('apple', config); +const toBase64Url = (obj: Record) => Buffer.from(JSON.stringify(obj)).toString('base64url'); + +function generateAppleClientSecret(header: Record, payload: Record, privateKeyString: string): string { + const headerB64 = toBase64Url(header); + const payloadB64 = toBase64Url(payload); + const dataToSign = `${headerB64}.${payloadB64}`; + + const privateKey = createPrivateKey({ + key: privateKeyString, + format: 'pem', + type: 'pkcs8', + }); + + const signature = sign('sha256', Buffer.from(dataToSign), { + key: privateKey, + dsaEncoding: 'ieee-p1363', + }); + + const signatureB64 = signature.toString('base64url'); + + return `${dataToSign}.${signatureB64}`; +} + settings.watchMultiple( [ 'Accounts_OAuth_Apple', @@ -22,8 +46,14 @@ settings.watchMultiple( }); } - // if everything is empty but Apple login is enabled, don't show the login button - if (!clientId && !serverSecret && !iss && !kid) { + const [normalizedClientId, normalizedServerSecret, normalizedIss, normalizedKid] = [clientId, serverSecret, iss, kid].map((value) => + typeof value === 'string' ? value.trim() : '', + ); + + const hasAllFields = [normalizedClientId, normalizedServerSecret, normalizedIss, normalizedKid].every(Boolean); + + // Hide web button if settings are incomplete, but preserve mobile-only setup if enabled. + if (!hasAllFields) { await ServiceConfiguration.configurations.upsertAsync( { service: 'apple', @@ -39,42 +69,57 @@ settings.watchMultiple( } const HEADER = { - kid, + kid: normalizedKid, alg: 'ES256', }; const now = new Date(); const exp = new Date(); - exp.setMonth(exp.getMonth() + 5); // from Apple docs expiration time must no be greater than 6 months - - const secret = KJUR.jws.JWS.sign( - null, - HEADER, - { - iss, - iat: Math.floor(now.getTime() / 1000), - exp: Math.floor(exp.getTime() / 1000), - aud: 'https://appleid.apple.com', - sub: clientId, - }, - serverSecret as string, - ); + exp.setMonth(exp.getMonth() + 5); - await ServiceConfiguration.configurations.upsertAsync( - { - service: 'apple', - }, - { - $set: { - showButton: true, - secret, - enabled: settings.get('Accounts_OAuth_Apple'), - loginStyle: 'popup', - clientId: clientId as string, - buttonColor: '#000', - buttonLabelColor: '#FFF', + try { + const secret = generateAppleClientSecret( + HEADER, + { + iss: normalizedIss, + iat: Math.floor(now.getTime() / 1000), + exp: Math.floor(exp.getTime() / 1000), + aud: 'https://appleid.apple.com', + sub: normalizedClientId, }, - }, - ); + normalizedServerSecret, + ); + + await ServiceConfiguration.configurations.upsertAsync( + { + service: 'apple', + }, + { + $set: { + showButton: true, + secret, + enabled: settings.get('Accounts_OAuth_Apple'), + loginStyle: 'popup', + clientId: normalizedClientId, + buttonColor: '#000', + buttonLabelColor: '#FFF', + }, + }, + ); + } catch (error) { + console.error('Failed to configure Apple OAuth service', error); + + await ServiceConfiguration.configurations.upsertAsync( + { + service: 'apple', + }, + { + $set: { + showButton: false, + enabled: settings.get('Accounts_OAuth_Apple'), + }, + }, + ); + } }, ); diff --git a/apps/meteor/app/apple/server/loginHandler.spec.ts b/apps/meteor/app/apple/server/loginHandler.spec.ts new file mode 100644 index 0000000000000..ffa50a952d4cf --- /dev/null +++ b/apps/meteor/app/apple/server/loginHandler.spec.ts @@ -0,0 +1,124 @@ +import { Accounts } from 'meteor/accounts-base'; + +import { settings } from '../../settings/server'; +import { handleIdentityToken } from '../lib/handleIdentityToken'; + +jest.mock( + 'meteor/accounts-base', + () => ({ + Accounts: { + registerLoginHandler: jest.fn(), + updateOrCreateUserFromExternalService: jest.fn(), + LoginCancelledError: { numericError: 400 }, + }, + }), + { virtual: true }, +); + +jest.mock( + 'meteor/meteor', + () => ({ + Meteor: { + Error: class extends Error { + constructor( + public error: number, + public reason: string, + ) { + super(reason); + } + }, + }, + }), + { virtual: true }, +); + +jest.mock('../../settings/server', () => ({ + settings: { + get: jest.fn(), + }, +})); + +jest.mock('../lib/handleIdentityToken', () => ({ + handleIdentityToken: jest.fn(), +})); + +describe('Apple OAuth loginHandler', () => { + let loginHandlerCallback: Parameters[1]; + + beforeAll(() => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + require('./loginHandler'); + loginHandlerCallback = jest.mocked(Accounts.registerLoginHandler).mock.calls[0][1]; + }); + + beforeEach(() => { + jest.clearAllMocks(); + jest.mocked(settings.get).mockImplementation((key) => { + if (key === 'Accounts_OAuth_Apple') return true; + if (key === 'Accounts_OAuth_Apple_id') return 'com.yourcompany.app'; + return null; + }); + }); + + it('should not use the client-provided email if Apple does not provide one', async () => { + jest.mocked(handleIdentityToken).mockResolvedValue({ + id: 'apple-sub-123', + }); + + const maliciousLoginRequest = { + identityToken: 'valid.token.without_email', + email: 'alice@email.tld', + fullName: { givenName: 'Alice', familyName: 'Sender' }, + }; + + jest.mocked(Accounts.updateOrCreateUserFromExternalService).mockResolvedValue({ userId: 'new-user-id' }); + + await loginHandlerCallback(maliciousLoginRequest); + + expect(Accounts.updateOrCreateUserFromExternalService).toHaveBeenCalledWith( + 'apple', + { id: 'apple-sub-123' }, + { profile: { name: 'Alice Sender' } }, + ); + }); + + it('should successfully pass the email if Apple natively provides it in the signed JWT', async () => { + jest.mocked(handleIdentityToken).mockResolvedValue({ + id: 'apple-sub-123', + email: 'legit@email.tld', + }); + + const legitLoginRequest = { + identityToken: 'valid.token.with_email', + fullName: { givenName: 'John', familyName: 'Doe' }, + }; + + jest.mocked(Accounts.updateOrCreateUserFromExternalService).mockResolvedValue({ userId: 'user-id' }); + + await loginHandlerCallback(legitLoginRequest); + + expect(Accounts.updateOrCreateUserFromExternalService).toHaveBeenCalledWith( + 'apple', + { id: 'apple-sub-123', email: 'legit@email.tld' }, + { profile: { name: 'John Doe' } }, + ); + }); + + it('should pass empty client id to token validation when setting is not configured', async () => { + jest.mocked(settings.get).mockImplementation((key) => { + if (key === 'Accounts_OAuth_Apple') return true; + if (key === 'Accounts_OAuth_Apple_id') return ''; + return null; + }); + + jest.mocked(handleIdentityToken).mockResolvedValue({ id: 'apple-sub-123' }); + jest.mocked(Accounts.updateOrCreateUserFromExternalService).mockResolvedValue({ userId: 'user-id' }); + + await loginHandlerCallback({ + identityToken: 'valid.token.with_mobile_default_audience', + fullName: { givenName: 'Mobile', familyName: 'User' }, + }); + + expect(handleIdentityToken).toHaveBeenCalledWith('valid.token.with_mobile_default_audience', ''); + }); +}); diff --git a/apps/meteor/app/apple/server/loginHandler.ts b/apps/meteor/app/apple/server/loginHandler.ts index 18ac7ddd75268..96ccd53323b32 100644 --- a/apps/meteor/app/apple/server/loginHandler.ts +++ b/apps/meteor/app/apple/server/loginHandler.ts @@ -13,26 +13,23 @@ Accounts.registerLoginHandler('apple', async (loginRequest) => { return; } - const { identityToken, fullName, email } = loginRequest; + const { identityToken, fullName } = loginRequest; try { - const serviceData = await handleIdentityToken(identityToken); + const clientId = settings.get('Accounts_OAuth_Apple_id') || ''; - if (!serviceData.email && email) { - serviceData.email = email; - } + const serviceData = await handleIdentityToken(identityToken, clientId); const profile: { name?: string } = {}; - const { givenName, familyName } = fullName; + const { givenName, familyName } = fullName || {}; if (givenName && familyName) { profile.name = `${givenName} ${familyName}`; } - const result = Accounts.updateOrCreateUserFromExternalService('apple', serviceData, { profile }); + const result = await Accounts.updateOrCreateUserFromExternalService('apple', serviceData, { profile }); - // Ensure processing succeeded - if (result === undefined || result.userId === undefined) { + if (result?.userId === undefined) { return { type: 'apple', error: new Meteor.Error(Accounts.LoginCancelledError.numericError, 'User creation failed from Apple response token'), diff --git a/apps/meteor/app/file-upload/server/lib/FileUpload.spec.ts b/apps/meteor/app/file-upload/server/lib/FileUpload.spec.ts index 7266a7d8187bc..ddec9231b3bb2 100644 --- a/apps/meteor/app/file-upload/server/lib/FileUpload.spec.ts +++ b/apps/meteor/app/file-upload/server/lib/FileUpload.spec.ts @@ -287,5 +287,185 @@ describe('FileUpload', () => { expect(result).to.be.true; expect(validateAndDecodeJWTStub.calledOnceWith('valid-token', 'test-secret')).to.be.true; }); + + describe('livechat room-based authorization (rc_room_type=l)', () => { + it('should allow access when livechat credentials are valid and file belongs to the same room', async () => { + settingsGetMap.set('FileUpload_ProtectFiles', true); + const canAccessUploadedFileStub = sinon.stub().resolves(true); + roomCoordinatorStub.getRoomDirectives.returns({ canAccessUploadedFile: canAccessUploadedFileStub }); + + const request = { + headers: {}, + url: '/file-upload/test-file-id/test-file.png?rc_room_type=l&rc_rid=room-1&rc_token=visitor-token', + } as any; + + const file = { _id: 'test-file-id', rid: 'room-1' } as any; + + const result = await FileUpload.requestCanAccessFiles(request, file); + expect(result).to.be.true; + expect(canAccessUploadedFileStub.calledOnce).to.be.true; + }); + + it('should deny access when livechat credentials are valid but file belongs to a different room', async () => { + settingsGetMap.set('FileUpload_ProtectFiles', true); + const canAccessUploadedFileStub = sinon.stub().resolves(false); + roomCoordinatorStub.getRoomDirectives.returns({ canAccessUploadedFile: canAccessUploadedFileStub }); + + const request = { + headers: {}, + url: '/file-upload/victim-file-id/secret.txt?rc_room_type=l&rc_rid=room-attacker&rc_token=attacker-token', + } as any; + + // File belongs to victim's room, not the attacker's room + const file = { _id: 'victim-file-id', rid: 'room-victim' } as any; + + const result = await FileUpload.requestCanAccessFiles(request, file); + expect(result).to.be.false; + }); + + it('should pass the file object to canAccessUploadedFile', async () => { + settingsGetMap.set('FileUpload_ProtectFiles', true); + const canAccessUploadedFileStub = sinon.stub().resolves(true); + roomCoordinatorStub.getRoomDirectives.returns({ canAccessUploadedFile: canAccessUploadedFileStub }); + + const request = { + headers: {}, + url: '/file-upload/test-file-id/test-file.png?rc_room_type=l&rc_rid=room-1&rc_token=visitor-token', + } as any; + + const file = { _id: 'test-file-id', rid: 'room-1' } as any; + + await FileUpload.requestCanAccessFiles(request, file); + + const callArgs = canAccessUploadedFileStub.firstCall.args; + expect(callArgs[1]).to.deep.equal(file); + }); + + it('should deny access when rc_room_type is provided but canAccessUploadedFile returns false', async () => { + settingsGetMap.set('FileUpload_ProtectFiles', true); + const canAccessUploadedFileStub = sinon.stub().resolves(false); + roomCoordinatorStub.getRoomDirectives.returns({ canAccessUploadedFile: canAccessUploadedFileStub }); + + const request = { + headers: {}, + url: '/file-upload/test-file-id/test-file.png?rc_room_type=l&rc_rid=room-1&rc_token=invalid-token', + } as any; + + const file = { _id: 'test-file-id', rid: 'room-1' } as any; + + const result = await FileUpload.requestCanAccessFiles(request, file); + expect(result).to.be.false; + }); + }); + }); + + describe('getRequestUserId', () => { + it('should return undefined when no url is provided', async () => { + const request = { headers: {}, url: undefined } as any; + + const result = await FileUpload.getRequestUserId(request); + expect(result).to.be.undefined; + expect(usersModelStub.findOneByIdAndLoginToken.called).to.be.false; + }); + + it('should return undefined when no credentials are provided', async () => { + const request = { headers: {}, url: '/ufs/UserDataFiles/file-id' } as any; + + const result = await FileUpload.getRequestUserId(request); + expect(result).to.be.undefined; + expect(usersModelStub.findOneByIdAndLoginToken.called).to.be.false; + }); + + it('should return undefined when a uid is provided without a token', async () => { + const request = { headers: { 'x-user-id': 'user-1' }, url: '/ufs/UserDataFiles/file-id' } as any; + + const result = await FileUpload.getRequestUserId(request); + expect(result).to.be.undefined; + expect(usersModelStub.findOneByIdAndLoginToken.called).to.be.false; + }); + + it('should return undefined when the login token is invalid', async () => { + usersModelStub.findOneByIdAndLoginToken.resolves(null); + + const request = { headers: { 'x-user-id': 'user-1', 'x-auth-token': 'bad-token' }, url: '/ufs/UserDataFiles/file-id' } as any; + + const result = await FileUpload.getRequestUserId(request); + expect(result).to.be.undefined; + expect(usersModelStub.findOneByIdAndLoginToken.calledOnceWith('user-1', 'hashed_bad-token')).to.be.true; + }); + + it('should return the user id when credentials are valid via headers', async () => { + usersModelStub.findOneByIdAndLoginToken.resolves({ _id: 'user-1' }); + + const request = { headers: { 'x-user-id': 'user-1', 'x-auth-token': 'good-token' }, url: '/ufs/UserDataFiles/file-id' } as any; + + const result = await FileUpload.getRequestUserId(request); + expect(result).to.equal('user-1'); + expect(usersModelStub.findOneByIdAndLoginToken.calledOnceWith('user-1', 'hashed_good-token')).to.be.true; + }); + + it('should return the user id when credentials are valid via query string', async () => { + usersModelStub.findOneByIdAndLoginToken.resolves({ _id: 'user-1' }); + + const request = { headers: {}, url: '/ufs/UserDataFiles/file-id?rc_uid=user-1&rc_token=good-token' } as any; + + const result = await FileUpload.getRequestUserId(request); + expect(result).to.equal('user-1'); + expect(usersModelStub.findOneByIdAndLoginToken.calledOnceWith('user-1', 'hashed_good-token')).to.be.true; + }); + }); + + describe('UserDataFiles.onRead', () => { + // eslint-disable-next-line new-cap + const getOnRead = () => FileUpload.defaults.UserDataFiles().onRead; + + const createResponse = () => { + const res = { writeHead: sinon.stub(), setHeader: sinon.stub() }; + res.writeHead.returns(res); + return res as any; + }; + + it('should deny access to an unauthenticated request', async () => { + const res = createResponse(); + const file = { _id: 'file-id', userId: 'owner-1', name: 'export.zip' } as any; + const request = { headers: {}, url: '/ufs/UserDataFiles/file-id' } as any; + + const result = await getOnRead()('file-id', file, request, res); + expect(result).to.be.false; + expect(res.writeHead.calledOnceWith(403)).to.be.true; + expect(res.setHeader.called).to.be.false; + }); + + it('should deny access to an authenticated user who is not the owner', async () => { + usersModelStub.findOneByIdAndLoginToken.resolves({ _id: 'attacker-1' }); + + const res = createResponse(); + const file = { _id: 'file-id', userId: 'owner-1', name: 'export.zip' } as any; + const request = { + headers: { 'x-user-id': 'attacker-1', 'x-auth-token': 'attacker-token' }, + url: '/ufs/UserDataFiles/file-id', + } as any; + + const result = await getOnRead()('file-id', file, request, res); + expect(result).to.be.false; + expect(res.writeHead.calledOnceWith(403)).to.be.true; + expect(res.setHeader.called).to.be.false; + }); + + it('should allow access to the owner of the export', async () => { + usersModelStub.findOneByIdAndLoginToken.resolves({ _id: 'owner-1' }); + + const res = createResponse(); + const file = { _id: 'file-id', userId: 'owner-1', name: 'export.zip' } as any; + const request = { + headers: { 'x-user-id': 'owner-1', 'x-auth-token': 'owner-token' }, + url: '/ufs/UserDataFiles/file-id', + } as any; + + const result = await getOnRead()('file-id', file, request, res); + expect(result).to.be.true; + expect(res.writeHead.called).to.be.false; + expect(res.setHeader.calledOnceWith('content-disposition', 'attachment; filename="export.zip"')).to.be.true; + }); }); }); diff --git a/apps/meteor/app/file-upload/server/lib/FileUpload.ts b/apps/meteor/app/file-upload/server/lib/FileUpload.ts index 43dfc089e2e4f..adc8ec70dd5e2 100644 --- a/apps/meteor/app/file-upload/server/lib/FileUpload.ts +++ b/apps/meteor/app/file-upload/server/lib/FileUpload.ts @@ -98,7 +98,9 @@ const defaults: Record Partial> = { }, onValidate: FileUpload.uploadsOnValidate, async onRead(_fileId: string, file: IUpload, req: http.IncomingMessage, res: http.ServerResponse) { - if (!(await FileUpload.requestCanAccessFiles(req))) { + // UserDataFiles are GDPR data exports — only the owner of the export may download it. + const uid = await FileUpload.getRequestUserId(req); + if (!uid || uid !== file.userId) { res.writeHead(403); return false; } @@ -448,6 +450,31 @@ export const FileUpload = { await Avatars.updateFileNameById(file._id, user.username); }, + async getRequestUserId({ headers = {}, url }: http.IncomingMessage): Promise { + if (!url) { + return undefined; + } + + const { query } = URL.parse(url, true); + // eslint-disable-next-line @typescript-eslint/naming-convention + let { rc_uid, rc_token } = query as Record; + + if (!rc_uid && headers.cookie) { + rc_uid = cookie.get('rc_uid', headers.cookie); + rc_token = cookie.get('rc_token', headers.cookie); + } + + const uid = rc_uid || (headers['x-user-id'] as string); + const authToken = rc_token || (headers['x-auth-token'] as string); + + if (!uid || !authToken) { + return undefined; + } + + const user = await Users.findOneByIdAndLoginToken(uid, hashLoginToken(authToken), { projection: { _id: 1 } }); + return user?._id; + }, + async requestCanAccessFiles({ headers = {}, url }: http.IncomingMessage, file?: IUpload) { if (!url || !settings.get('FileUpload_ProtectFiles')) { return true; @@ -469,7 +496,7 @@ export const FileUpload = { rc_room_type && roomCoordinator .getRoomDirectives(rc_room_type) - .canAccessUploadedFile({ rc_uid: rc_uid || '', rc_rid: rc_rid || '', rc_token: rc_token || '' }); + .canAccessUploadedFile({ rc_uid: rc_uid || '', rc_rid: rc_rid || '', rc_token: rc_token || '' }, file); const isAuthorizedByJWT: () => boolean = () => { if (!token || typeof token !== 'string' || !settings.get('FileUpload_Enable_json_web_token_for_files')) { diff --git a/apps/meteor/app/file-upload/server/methods/sendFileMessage.ts b/apps/meteor/app/file-upload/server/methods/sendFileMessage.ts index ec08a5d835a74..fe0fc015db704 100644 --- a/apps/meteor/app/file-upload/server/methods/sendFileMessage.ts +++ b/apps/meteor/app/file-upload/server/methods/sendFileMessage.ts @@ -73,7 +73,7 @@ export const parseFileIntoMessageAttachments = async ( const attachment: FileAttachmentProps = { title: file.name, type: 'file', - description: file?.description, + image_alt: file?.description, title_link: fileUrl, title_link_download: true, image_url: fileUrl, diff --git a/apps/meteor/client/components/message/content/attachments/DefaultAttachment.tsx b/apps/meteor/client/components/message/content/attachments/DefaultAttachment.tsx index 2d6ae957f7209..d763362275959 100644 --- a/apps/meteor/client/components/message/content/attachments/DefaultAttachment.tsx +++ b/apps/meteor/client/components/message/content/attachments/DefaultAttachment.tsx @@ -98,7 +98,7 @@ const DefaultAttachment = (attachment: DefaultAttachmentProps) => { /> )} {attachment.image_url && ( - + )} {/* DEPRECATED */} {isActionAttachment(attachment) && } diff --git a/apps/meteor/client/components/message/content/attachments/file/ImageAttachment.tsx b/apps/meteor/client/components/message/content/attachments/file/ImageAttachment.tsx index 29c1bf5b7c2f3..f653806208656 100644 --- a/apps/meteor/client/components/message/content/attachments/file/ImageAttachment.tsx +++ b/apps/meteor/client/components/message/content/attachments/file/ImageAttachment.tsx @@ -17,6 +17,7 @@ const ImageAttachment = ({ width: 368, height: 368, }, + image_alt: altText, description, descriptionMd, title_link: link, @@ -38,7 +39,7 @@ const ImageAttachment = ({ src={getURL(url)} previewUrl={`data:image/png;base64,${imagePreview}`} id={id} - alt={description} + alt={altText} /> diff --git a/apps/meteor/client/lib/chats/ChatAPI.ts b/apps/meteor/client/lib/chats/ChatAPI.ts index 2826f3f56de58..e7cc2cf0fb6dd 100644 --- a/apps/meteor/client/lib/chats/ChatAPI.ts +++ b/apps/meteor/client/lib/chats/ChatAPI.ts @@ -122,7 +122,7 @@ export type UploadsAPI = { cancel(id: Upload['id']): void; removeUpload(id: Upload['id']): void; editUploadFileName: (id: Upload['id'], fileName: string) => void; - editUploadDescription: (id: Upload['id'], description: string) => void; + editUploadAltText: (id: Upload['id'], altText: string) => void; send(file: File, encrypted?: never): Promise; send(file: File, encrypted: EncryptedFileUploadContent): Promise; }; diff --git a/apps/meteor/client/lib/chats/Upload.ts b/apps/meteor/client/lib/chats/Upload.ts index 6916d90b9101c..7e48a9733dc12 100644 --- a/apps/meteor/client/lib/chats/Upload.ts +++ b/apps/meteor/client/lib/chats/Upload.ts @@ -6,7 +6,7 @@ export type NonEncryptedUpload = { readonly url?: string; readonly percentage: number; readonly error?: Error; - readonly description?: string; + readonly altText?: string; }; export type EncryptedUpload = NonEncryptedUpload & { diff --git a/apps/meteor/client/lib/chats/flows/processMessageUploads.ts b/apps/meteor/client/lib/chats/flows/processMessageUploads.ts index 10ac33f930e52..2c3cb00ec41b6 100644 --- a/apps/meteor/client/lib/chats/flows/processMessageUploads.ts +++ b/apps/meteor/client/lib/chats/flows/processMessageUploads.ts @@ -59,7 +59,7 @@ const getAttachmentForFile = async (fileToUpload: EncryptedUpload): Promise { + editUploadAltText = (uploadId: Upload['id'], altText: string) => { this.set( this.uploads.map((upload) => { if (upload.id !== uploadId) { @@ -72,9 +72,9 @@ class UploadsStore extends Emitter<{ update: void; [x: `cancelling-${Upload['id' return { ...upload, - description, + altText, ...(isEncryptedUpload(upload) && { - metadataForEncryption: { ...upload.metadataForEncryption, description }, + metadataForEncryption: { ...upload.metadataForEncryption, altText }, }), }; }), diff --git a/apps/meteor/client/views/room/composer/messageBox/MessageComposerFileItem.tsx b/apps/meteor/client/views/room/composer/messageBox/MessageComposerFileItem.tsx index 6ceece6a89f59..fcd87b9d3e37a 100644 --- a/apps/meteor/client/views/room/composer/messageBox/MessageComposerFileItem.tsx +++ b/apps/meteor/client/views/room/composer/messageBox/MessageComposerFileItem.tsx @@ -7,7 +7,7 @@ import { isPreviewableImage } from '../../../../lib/utils/isPreviewableImage'; export type MessageComposerFileItemProps = { upload: Upload; onRemove: (id: string) => void; - onEdit: (id: Upload['id'], fileName: string, description?: string) => void; + onEdit: (id: Upload['id'], fileName: string, altText?: string) => void; onCancel: (id: Upload['id']) => void; disabled: boolean; shouldPreview?: boolean; diff --git a/apps/meteor/client/views/room/composer/messageBox/MessageComposerFiles.tsx b/apps/meteor/client/views/room/composer/messageBox/MessageComposerFiles.tsx index 84f0056513d77..c8e6d60f30ed0 100644 --- a/apps/meteor/client/views/room/composer/messageBox/MessageComposerFiles.tsx +++ b/apps/meteor/client/views/room/composer/messageBox/MessageComposerFiles.tsx @@ -11,10 +11,10 @@ const MessageComposerFiles = () => { const { uploads, uploadsStore, isProcessingUploads, hasUploads } = useFileUpload(); const handleEdit = useCallback( - (id: Upload['id'], fileName: string, description?: string) => { + (id: Upload['id'], fileName: string, altText?: string) => { uploadsStore?.editUploadFileName(id, fileName); - if (description !== undefined) { - uploadsStore?.editUploadDescription(id, description); + if (altText !== undefined) { + uploadsStore?.editUploadAltText(id, altText); } }, [uploadsStore], diff --git a/apps/meteor/client/views/room/composer/messageBox/MessageComposerGenericFile.tsx b/apps/meteor/client/views/room/composer/messageBox/MessageComposerGenericFile.tsx index d24e22b54c404..d68119b58dc6b 100644 --- a/apps/meteor/client/views/room/composer/messageBox/MessageComposerGenericFile.tsx +++ b/apps/meteor/client/views/room/composer/messageBox/MessageComposerGenericFile.tsx @@ -39,13 +39,13 @@ const MessageComposerGenericFile = ({ setModal( { - onEdit(upload.id, name, description); + onSubmit={(name, altText) => { + onEdit(upload.id, name, altText); setModal(null); chat?.composer?.focus(); }} fileName={upload.file.name} - fileDescription={upload.description} + fileAltText={upload.altText} file={upload.file} onClose={() => setModal(null)} />, @@ -86,7 +86,7 @@ const MessageComposerGenericFile = ({ fileTitle={upload.file.name} fileSubtitle={`${fileSize} - ${fileExtension}`} previewUrl={shouldPreview ? previewUrl : undefined} - alt={upload.description} + alt={upload.altText} fileFormat={getFileExtension(upload.file.name)} showPreview={shouldPreview} actionIcon={actionIcon} diff --git a/apps/meteor/client/views/room/modals/FileUploadModal/FilePreview.tsx b/apps/meteor/client/views/room/modals/FileUploadModal/FilePreview.tsx index 6254c5740f329..35af01a288aa6 100644 --- a/apps/meteor/client/views/room/modals/FileUploadModal/FilePreview.tsx +++ b/apps/meteor/client/views/room/modals/FileUploadModal/FilePreview.tsx @@ -39,14 +39,14 @@ const shouldShowMediaPreview = (file: File, fileType: FilePreviewType | undefine type FilePreviewProps = { file: File; - description?: string; + altText?: string; }; -const FilePreview = ({ file, description }: FilePreviewProps) => { +const FilePreview = ({ file, altText }: FilePreviewProps) => { const fileType = getFileType(file.type); if (shouldShowMediaPreview(file, fileType)) { - return ; + return ; } return ; diff --git a/apps/meteor/client/views/room/modals/FileUploadModal/FileUploadModal.tsx b/apps/meteor/client/views/room/modals/FileUploadModal/FileUploadModal.tsx index 29e13e1cb3963..f970f92cbdcb9 100644 --- a/apps/meteor/client/views/room/modals/FileUploadModal/FileUploadModal.tsx +++ b/apps/meteor/client/views/room/modals/FileUploadModal/FileUploadModal.tsx @@ -30,13 +30,13 @@ import { getMimeTypeFromFileName } from '../../../../../app/utils/lib/mimeTypes' type FileUploadModalProps = { onClose: () => void; - onSubmit: (name: string, description?: string) => void; + onSubmit: (name: string, altText?: string) => void; file: File; fileName: string; - fileDescription?: string; + fileAltText?: string; }; -const FileUploadModal = ({ onClose, file, fileName, fileDescription = '', onSubmit }: FileUploadModalProps) => { +const FileUploadModal = ({ onClose, file, fileName, fileAltText = '', onSubmit }: FileUploadModalProps) => { const { t } = useTranslation(); const fileUploadFormId = useId(); const isImage = file.type.startsWith('image/'); @@ -45,7 +45,7 @@ const FileUploadModal = ({ onClose, file, fileName, fileDescription = '', onSubm control, handleSubmit, formState: { errors, isDirty, isSubmitting }, - } = useForm({ mode: 'onBlur', defaultValues: { name: fileName, description: fileDescription } }); + } = useForm({ mode: 'onBlur', defaultValues: { name: fileName, altText: fileAltText } }); const validateFileName = useCallback( (fieldValue: string) => { @@ -63,12 +63,7 @@ const FileUploadModal = ({ onClose, file, fileName, fileDescription = '', onSubm ) => ( - onSubmit(name, description?.trim() || undefined))} - {...props} - /> + onSubmit(name, altText?.trim()))} {...props} /> )} > @@ -78,7 +73,7 @@ const FileUploadModal = ({ onClose, file, fileName, fileDescription = '', onSubm - + @@ -101,7 +96,7 @@ const FileUploadModal = ({ onClose, file, fileName, fileDescription = '', onSubm {t('Alternative_text')} {t('Alt_text_description')} - } /> + } /> )} diff --git a/apps/meteor/client/views/room/modals/FileUploadModal/ImagePreview.tsx b/apps/meteor/client/views/room/modals/FileUploadModal/ImagePreview.tsx index e00cd4cdfe6b1..635c7d4429e2b 100644 --- a/apps/meteor/client/views/room/modals/FileUploadModal/ImagePreview.tsx +++ b/apps/meteor/client/views/room/modals/FileUploadModal/ImagePreview.tsx @@ -7,10 +7,10 @@ import PreviewSkeleton from './PreviewSkeleton'; type ImagePreviewProps = { url: string; file: File; - alt?: string; + altText?: string; }; -const ImagePreview = ({ url, file, alt = '' }: ImagePreviewProps) => { +const ImagePreview = ({ url, file, altText = '' }: ImagePreviewProps) => { const [error, setError] = useState(false); const [loading, setLoading] = useState(true); @@ -30,7 +30,7 @@ const ImagePreview = ({ url, file, alt = '' }: ImagePreviewProps) => { { +const MediaPreview = ({ file, fileType, altText }: MediaPreviewProps) => { const [loaded, url] = useFileAsDataURL(file); const { t } = useTranslation(); @@ -32,7 +32,7 @@ const MediaPreview = ({ file, fileType, description }: MediaPreviewProps) => { } if (fileType === FilePreviewType.IMAGE) { - return ; + return ; } if (fileType === FilePreviewType.VIDEO) { diff --git a/apps/meteor/definition/IRoomTypeConfig.ts b/apps/meteor/definition/IRoomTypeConfig.ts index 2edb7cef27038..bdb03a3d946b0 100644 --- a/apps/meteor/definition/IRoomTypeConfig.ts +++ b/apps/meteor/definition/IRoomTypeConfig.ts @@ -1,4 +1,14 @@ -import type { IRoom, RoomType, IUser, IMessage, ValueOf, AtLeast, ISubscription, IOmnichannelRoom } from '@rocket.chat/core-typings'; +import type { + IRoom, + RoomType, + IUser, + IMessage, + ValueOf, + AtLeast, + ISubscription, + IOmnichannelRoom, + IUpload, +} from '@rocket.chat/core-typings'; import type { Keys as IconName } from '@rocket.chat/icons'; import type { IRouterPaths, RouteName } from '@rocket.chat/ui-contexts'; @@ -88,7 +98,7 @@ export interface IRoomTypeServerDirectives { canBeDeleted: (hasPermission: (permissionId: string, rid?: string) => Promise | boolean, room: IRoom) => Promise; preventRenaming: () => boolean; getDiscussionType: (room?: AtLeast) => Promise; - canAccessUploadedFile: (params: { rc_uid: string; rc_rid: string; rc_token: string }) => Promise; + canAccessUploadedFile: (params: { rc_uid: string; rc_rid: string; rc_token: string }, file?: IUpload) => Promise; getNotificationDetails: ( room: IRoom, sender: AtLeast, diff --git a/apps/meteor/definition/externals/meteor/accounts-base.d.ts b/apps/meteor/definition/externals/meteor/accounts-base.d.ts index 39ea55788e9a7..becb48a4a8137 100644 --- a/apps/meteor/definition/externals/meteor/accounts-base.d.ts +++ b/apps/meteor/definition/externals/meteor/accounts-base.d.ts @@ -41,7 +41,7 @@ declare module 'meteor/accounts-base' { serviceName: string, serviceData: Record, options: Record, - ): Record; + ): Promise>; function _clearAllLoginTokens(userId: string | null): void; diff --git a/apps/meteor/ee/server/lib/ldap/Manager.ts b/apps/meteor/ee/server/lib/ldap/Manager.ts index 3fbf664aa083b..16d8594d9f668 100644 --- a/apps/meteor/ee/server/lib/ldap/Manager.ts +++ b/apps/meteor/ee/server/lib/ldap/Manager.ts @@ -342,7 +342,7 @@ export class LDAPEEManager extends LDAPManager { return; } - const roles = (await Roles.find( + const roles = await Roles.find( {}, { projection: { @@ -350,7 +350,7 @@ export class LDAPEEManager extends LDAPManager { name: 1, }, }, - ).toArray()) as Array; + ).toArray(); if (!roles) { return; @@ -635,12 +635,6 @@ export class LDAPEEManager extends LDAPManager { } private static isUserDeactivated(ldapUser: ILDAPEntry): boolean { - // Account locked by "Draft-behera-ldap-password-policy" - if (ldapUser.pwdAccountLockedTime) { - mapLogger.debug('User account is locked by password policy (attribute pwdAccountLockedTime)'); - return true; - } - // EDirectory: Account manually disabled by an admin if (ldapUser.loginDisabled) { mapLogger.debug('User account was manually disabled by an admin (attribute loginDisabled)'); @@ -653,26 +647,6 @@ export class LDAPEEManager extends LDAPManager { return true; } - // Active Directory - Account locked automatically by security policies - if (ldapUser.lockoutTime && ldapUser.lockoutTime !== '0') { - const lockoutTimeValue = Number(ldapUser.lockoutTime); - if (lockoutTimeValue && !isNaN(lockoutTimeValue)) { - // Automatic unlock is disabled - if (!ldapUser.lockoutDuration) { - mapLogger.debug('User account locked indefinitely by security policy (attribute lockoutTime)'); - return true; - } - - const lockoutTime = new Date(lockoutTimeValue); - lockoutTime.setMinutes(lockoutTime.getMinutes() + Number(ldapUser.lockoutDuration)); - // Account has not unlocked itself yet - if (lockoutTime.valueOf() > Date.now()) { - mapLogger.debug('User account locked temporarily by security policy (attribute lockoutTime)'); - return true; - } - } - } - // Active Directory - Account disabled by an Admin if (ldapUser.userAccountControl && (ldapUser.userAccountControl & 2) === 2) { mapLogger.debug('User account disabled by an admin (attribute userAccountControl)'); diff --git a/apps/meteor/jest.config.ts b/apps/meteor/jest.config.ts index 1c2439c4c7837..8de98b0718cf2 100644 --- a/apps/meteor/jest.config.ts +++ b/apps/meteor/jest.config.ts @@ -50,6 +50,8 @@ export default { '/app/api/server/helpers/**.spec.ts', '/app/api/server/middlewares/**.spec.ts', '/app/version-check/server/**/*.spec.ts', + '/app/apple/lib/**.spec.ts', + '/app/apple/server/**.spec.ts', ], coveragePathIgnorePatterns: ['/node_modules/'], }, diff --git a/apps/meteor/server/lib/rooms/roomCoordinator.ts b/apps/meteor/server/lib/rooms/roomCoordinator.ts index 941883b132f2e..b258ef2513d5f 100644 --- a/apps/meteor/server/lib/rooms/roomCoordinator.ts +++ b/apps/meteor/server/lib/rooms/roomCoordinator.ts @@ -1,5 +1,5 @@ import { getUserDisplayName } from '@rocket.chat/core-typings'; -import type { IRoom, RoomType, IUser, IMessage, ValueOf, AtLeast } from '@rocket.chat/core-typings'; +import type { IRoom, RoomType, IUser, IMessage, ValueOf, AtLeast, IUpload } from '@rocket.chat/core-typings'; import { Users } from '@rocket.chat/models'; import { settings } from '../../../app/settings/server'; @@ -33,7 +33,7 @@ class RoomCoordinatorServer extends RoomCoordinator { async getDiscussionType(): Promise { return 'p'; }, - async canAccessUploadedFile(_params: { rc_uid: string; rc_rid: string; rc_token: string }): Promise { + async canAccessUploadedFile(_params: { rc_uid: string; rc_rid: string; rc_token: string }, _file?: IUpload): Promise { return false; }, async getNotificationDetails( diff --git a/apps/meteor/server/lib/rooms/roomTypes/livechat.ts b/apps/meteor/server/lib/rooms/roomTypes/livechat.ts index 350b8d0714f8e..7c1bc3c965cee 100644 --- a/apps/meteor/server/lib/rooms/roomTypes/livechat.ts +++ b/apps/meteor/server/lib/rooms/roomTypes/livechat.ts @@ -24,11 +24,17 @@ roomCoordinator.add(LivechatRoomType, { }, async roomName(room, _userId?) { - return room.name || room.fname || (room as any).label; + return (room.name || room.fname || (room as any).label) as string; }, - async canAccessUploadedFile({ rc_token: token, rc_rid: rid }) { - return token && rid && !!(await LivechatRooms.findOneByIdAndVisitorToken(rid, token)); + async canAccessUploadedFile({ rc_token: token, rc_rid: rid }, file) { + if (!token || !rid) { + return false; + } + if (file?.rid && file.rid !== rid) { + return false; + } + return !!(await LivechatRooms.findOneByIdAndVisitorToken(rid, token)); }, async getNotificationDetails(room, _sender, notificationMessage, userId) { diff --git a/apps/meteor/server/methods/deleteFileMessage.ts b/apps/meteor/server/methods/deleteFileMessage.ts index 7fbf8650bd2a5..39cfca1ae5dad 100644 --- a/apps/meteor/server/methods/deleteFileMessage.ts +++ b/apps/meteor/server/methods/deleteFileMessage.ts @@ -1,5 +1,6 @@ +import { Upload } from '@rocket.chat/core-services'; import type { ServerMethods } from '@rocket.chat/ddp-client'; -import { Messages } from '@rocket.chat/models'; +import { Messages, Users, Uploads } from '@rocket.chat/models'; import { check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; import type { DeleteResult } from 'mongodb'; @@ -16,14 +17,40 @@ declare module '@rocket.chat/ddp-client' { Meteor.methods({ async deleteFileMessage(fileID) { + const userId = Meteor.userId(); + if (!userId) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'deleteFileMessage', + }); + } check(fileID, String); const msg = await Messages.getMessageByFileId(fileID); - const userId = Meteor.userId(); - if (msg && userId) { + + if (msg) { return deleteMessageValidatingPermission(msg, userId); } + const user = await Users.findOneById(userId, { projection: { username: 1 } }); + if (!user) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'deleteFileMessage', + }); + } + + const file = await Uploads.findOneById(fileID, { projection: { userId: 1, rid: 1, expiresAt: 1, uploadedAt: 1 } }); + if (!file) { + throw new Meteor.Error('error-invalid-file', 'Invalid file', { + method: 'deleteFileMessage', + }); + } + + if (!(await Upload.canDeleteFile(user, file, null))) { + throw new Meteor.Error('error-not-authorized', 'Not authorized', { + method: 'deleteFileMessage', + }); + } + return FileUpload.getStore('Uploads').deleteById(fileID); }, }); diff --git a/apps/meteor/tests/end-to-end/api/rooms.ts b/apps/meteor/tests/end-to-end/api/rooms.ts index f677aae918196..350c31c190454 100644 --- a/apps/meteor/tests/end-to-end/api/rooms.ts +++ b/apps/meteor/tests/end-to-end/api/rooms.ts @@ -641,7 +641,7 @@ describe('[Rooms]', () => { expect(res.body.message.files).to.be.an('array').of.length(2); expect(res.body.message.files[0]).to.have.property('type', 'image/png'); expect(res.body.message.files[0]).to.have.property('name', '1024x1024.png'); - expect(res.body.message.attachments[0]).to.have.property('description', 'some_file_description'); + expect(res.body.message.attachments[0]).to.have.property('image_alt', 'some_file_description'); }); }); diff --git a/apps/meteor/tests/unit/server/methods/deleteFileMessage.spec.ts b/apps/meteor/tests/unit/server/methods/deleteFileMessage.spec.ts new file mode 100644 index 0000000000000..68649f5d5aeef --- /dev/null +++ b/apps/meteor/tests/unit/server/methods/deleteFileMessage.spec.ts @@ -0,0 +1,124 @@ +import { MeteorError } from '@rocket.chat/core-services'; +import { expect } from 'chai'; +import { beforeEach, describe, it } from 'mocha'; +import p from 'proxyquire'; +import sinon from 'sinon'; + +const checkMock = sinon.stub(); +const meteorUserIdMock = sinon.stub(); +const meteorMethodsMock = sinon.stub(); +const deleteMessageValidatingPermissionMock = sinon.stub(); +const canDeleteFileMock = sinon.stub(); +const deleteByIdMock = sinon.stub(); +const fileUploadGetStoreMock = sinon.stub().returns({ deleteById: deleteByIdMock }); + +const modelsMock = { + Messages: { + getMessageByFileId: sinon.stub(), + }, + Users: { + findOneById: sinon.stub(), + }, + Uploads: { + findOneById: sinon.stub(), + }, +}; + +p.noCallThru().load('../../../../server/methods/deleteFileMessage', { + 'meteor/meteor': { + Meteor: { + userId: meteorUserIdMock, + Error: MeteorError, + methods: meteorMethodsMock, + }, + }, + 'meteor/check': { + check: checkMock, + }, + '@rocket.chat/models': modelsMock, + '@rocket.chat/core-services': { + Upload: { canDeleteFile: canDeleteFileMock }, + }, + '../../app/file-upload/server': { + FileUpload: { getStore: fileUploadGetStoreMock }, + }, + '../../app/lib/server/functions/deleteMessage': { + deleteMessageValidatingPermission: deleteMessageValidatingPermissionMock, + }, +}); + +const deleteFileMessageMethod = meteorMethodsMock.firstCall.args[0].deleteFileMessage; + +describe('deleteFileMessage', () => { + beforeEach(() => { + checkMock.resetHistory(); + meteorUserIdMock.reset(); + deleteMessageValidatingPermissionMock.reset(); + canDeleteFileMock.reset(); + deleteByIdMock.reset(); + fileUploadGetStoreMock.resetHistory(); + modelsMock.Messages.getMessageByFileId.reset(); + modelsMock.Users.findOneById.reset(); + modelsMock.Uploads.findOneById.reset(); + }); + + it('should throw if user is not authenticated', async () => { + meteorUserIdMock.returns(null); + + await expect(deleteFileMessageMethod('file123')).to.be.rejectedWith('Invalid user'); + }); + + it('should delete message validating permission if file has an associated message', async () => { + meteorUserIdMock.returns('user123'); + const mockMsg = { _id: 'msg123', file: { _id: 'file123' } }; + modelsMock.Messages.getMessageByFileId.resolves(mockMsg); + deleteMessageValidatingPermissionMock.resolves(); + + await deleteFileMessageMethod('file123'); + + expect(checkMock.calledOnceWith('file123', String)).to.be.true; + expect(deleteMessageValidatingPermissionMock.calledOnceWith(mockMsg, 'user123')).to.be.true; + expect(modelsMock.Users.findOneById.called).to.be.false; + }); + + it('should throw if it is an orphan file but user is not found in DB', async () => { + meteorUserIdMock.returns('user123'); + modelsMock.Messages.getMessageByFileId.resolves(null); + modelsMock.Users.findOneById.resolves(null); + + await expect(deleteFileMessageMethod('file123')).to.be.rejectedWith('Invalid user'); + }); + + it('should throw if it is an orphan file but file is not found in DB', async () => { + meteorUserIdMock.returns('user123'); + modelsMock.Messages.getMessageByFileId.resolves(null); + modelsMock.Users.findOneById.resolves({ _id: 'user123', username: 'test' }); + modelsMock.Uploads.findOneById.resolves(null); + + await expect(deleteFileMessageMethod('file123')).to.be.rejectedWith('Invalid file'); + }); + + it('should not delete orphan file if user does not have permissions', async () => { + meteorUserIdMock.returns('user123'); + modelsMock.Messages.getMessageByFileId.resolves(null); + modelsMock.Users.findOneById.resolves({ _id: 'user123', username: 'test' }); + modelsMock.Uploads.findOneById.resolves({ _id: 'file123', userId: 'user123' }); + canDeleteFileMock.resolves(false); + + await expect(deleteFileMessageMethod('file123')).to.be.rejectedWith('Not authorized'); + }); + + it('should delete orphan file if user has permissions', async () => { + meteorUserIdMock.returns('user123'); + modelsMock.Messages.getMessageByFileId.resolves(null); + modelsMock.Users.findOneById.resolves({ _id: 'user123', username: 'test' }); + modelsMock.Uploads.findOneById.resolves({ _id: 'file123', userId: 'user123' }); + canDeleteFileMock.resolves(true); + deleteByIdMock.resolves(); + + await deleteFileMessageMethod('file123'); + + expect(fileUploadGetStoreMock.calledOnceWith('Uploads')).to.be.true; + expect(deleteByIdMock.calledOnceWith('file123')).to.be.true; + }); +}); diff --git a/ee/packages/federation-matrix/tests/end-to-end/messaging.spec.ts b/ee/packages/federation-matrix/tests/end-to-end/messaging.spec.ts index ffb0248a4a086..db88965674933 100644 --- a/ee/packages/federation-matrix/tests/end-to-end/messaging.spec.ts +++ b/ee/packages/federation-matrix/tests/end-to-end/messaging.spec.ts @@ -758,7 +758,7 @@ import { SynapseClient } from '../helper/synapse-client'; expect(rcMessage?.attachments?.[0]?.title_link).toMatch(/^\/file-upload\/[^/]+\/.+$/); expect(rcMessage?.attachments?.[0]?.title_link_download).toBe(true); expect((rcMessage?.attachments?.[0] as any)?.type).toBe('file'); - expect(rcMessage?.attachments?.[0]?.description).toBe(fileInfo.description); + expect((rcMessage?.attachments?.[0] as any)?.image_alt).toBe(fileInfo.description); expect((rcMessage?.attachments?.[0] as any)?.image_url).toMatch(/^\/file-upload\/[^/]+\/.+$/); expect((rcMessage?.attachments?.[0] as any)?.image_type).toBe('image/webp'); expect((rcMessage?.attachments?.[0] as any)?.image_size).toBe(uploadResponse.message.files?.[0]?.size); diff --git a/packages/core-typings/src/IMessage/MessageAttachment/Files/ImageAttachmentProps.ts b/packages/core-typings/src/IMessage/MessageAttachment/Files/ImageAttachmentProps.ts index 20d1d17390109..b94689a47dfee 100644 --- a/packages/core-typings/src/IMessage/MessageAttachment/Files/ImageAttachmentProps.ts +++ b/packages/core-typings/src/IMessage/MessageAttachment/Files/ImageAttachmentProps.ts @@ -11,6 +11,8 @@ export type ImageAttachmentProps = { image_url: string; image_type?: string; image_size?: number; + /** Accessibility alternative text for the image. Kept separate from `description` so it is not rendered as a visible caption. */ + image_alt?: string; file?: FileProp; } & MessageAttachmentBase; diff --git a/packages/core-typings/src/IMessage/MessageAttachment/MessageAttachmentDefault.ts b/packages/core-typings/src/IMessage/MessageAttachment/MessageAttachmentDefault.ts index 395c5f437962c..5c9f4cda338ee 100644 --- a/packages/core-typings/src/IMessage/MessageAttachment/MessageAttachmentDefault.ts +++ b/packages/core-typings/src/IMessage/MessageAttachment/MessageAttachmentDefault.ts @@ -20,6 +20,8 @@ export type MessageAttachmentDefault = { // footer_icon image_url?: string; + /** Accessibility alternative text for the image. Kept separate from `description` so it is not rendered as a visible caption. */ + image_alt?: string; image_dimensions?: { width: number; height: number; diff --git a/packages/i18n/src/locales/ar.i18n.json b/packages/i18n/src/locales/ar.i18n.json index cf5fcea19b6aa..ccd42740a468c 100644 --- a/packages/i18n/src/locales/ar.i18n.json +++ b/packages/i18n/src/locales/ar.i18n.json @@ -2070,7 +2070,7 @@ "LDAP_Sync_Now_Description": "سيبدأ هذا عملية **مزامنة الخلفية** الآن، من دون انتظار المزامنة المجدولة التالية. \nهذا الإجراء غير متزامن، يرجى الاطلاع على السجلات للحصول على مزيد من المعلومات.", "LDAP_Sync_User_Active_State": "مزامنة الحالة النشطة للمستخدم", "LDAP_Sync_User_Active_State_Both": "تمكين وتعطيل المستخدمين", - "LDAP_Sync_User_Active_State_Description": "حدد ما إذا كان يجب تمكين المستخدمين أو تعطيلهم على Rocket.Chat بناءً على حالة LDAP. سيتم استخدام السمة \"pwdAccountLockedTime\" لتحديد ما إذا كان المستخدم معطلاً أم لا.", + "LDAP_Sync_User_Active_State_Description": "حدد ما إذا كان يجب تمكين المستخدمين أو تعطيلهم على Rocket.Chat بناءً على حالة LDAP.", "LDAP_Sync_User_Active_State_Disable": "تعطيل المستخدمين", "LDAP_Sync_User_Active_State_Nothing": "عدم القيام بشيء", "LDAP_Sync_User_Avatar": "مزامنة الصورة الرمزية للمستخدم", diff --git a/packages/i18n/src/locales/ca.i18n.json b/packages/i18n/src/locales/ca.i18n.json index 29bc40737f4a9..7843639a76e98 100644 --- a/packages/i18n/src/locales/ca.i18n.json +++ b/packages/i18n/src/locales/ca.i18n.json @@ -2045,7 +2045,7 @@ "LDAP_Sync_Now_Description": "Això iniciarà una operació ** Sincronització en segon pla ** ara, sense esperar a la propera sincronització programada. \nAquesta acció és asincrònica; consulteu els registres per obtenir més informació.", "LDAP_Sync_User_Active_State": "Sincronitzar l'estat d'activitat de l'usuari", "LDAP_Sync_User_Active_State_Both": "Habilitar i deshabilitar usuaris", - "LDAP_Sync_User_Active_State_Description": "Determineu si els usuaris han d'estar habilitats o inhabilitat les Rocket.Chat segons l'estat de LDAP. L'atribut 'pwdAccountLockedTime' s'utilitzarà per determinar si l'usuari està deshabilitat.", + "LDAP_Sync_User_Active_State_Description": "Determineu si els usuaris han d'estar habilitats o inhabilitat les Rocket.Chat segons l'estat de LDAP.", "LDAP_Sync_User_Active_State_Disable": "Habilitar usuaris", "LDAP_Sync_User_Active_State_Nothing": "No fer res", "LDAP_Sync_User_Avatar": "Sincronitzar avatar de l'usuari", diff --git a/packages/i18n/src/locales/cs.i18n.json b/packages/i18n/src/locales/cs.i18n.json index 04ad5e3940af2..ab291a09924d0 100644 --- a/packages/i18n/src/locales/cs.i18n.json +++ b/packages/i18n/src/locales/cs.i18n.json @@ -1757,7 +1757,7 @@ "LDAP_Sync_Now_Description": "Spustí **Synchronizaci na pozadí ** nyní místo vyčkání **Interval synchronizace na pozadí** i pokud je **Synchronizaci na pozadí** zakázána. \n Tato akce je asynchroní. Pro více informací o jejím průběhu zkontrolujte log", "LDAP_Sync_User_Active_State": "Synchronizovat stavy aktivity uživatelů", "LDAP_Sync_User_Active_State_Both": "Povolit a zakázat uživatele", - "LDAP_Sync_User_Active_State_Description": "Rozlišuje zda by měli být uživatelé povolení nebo zakázaní na základě jejich stavu v LDAP. Pro zjištění se používá atribut 'pwdAccountLockedTime'.", + "LDAP_Sync_User_Active_State_Description": "Rozlišuje zda by měli být uživatelé povolení nebo zakázaní na základě jejich stavu v LDAP.", "LDAP_Sync_User_Active_State_Disable": "Zakázat uživatele", "LDAP_Sync_User_Active_State_Nothing": "Nedělat nic", "LDAP_Sync_User_Avatar": "Synchronizace uživatelských avatarů", diff --git a/packages/i18n/src/locales/da.i18n.json b/packages/i18n/src/locales/da.i18n.json index d7747b9566e68..4aaf872f0b3a5 100644 --- a/packages/i18n/src/locales/da.i18n.json +++ b/packages/i18n/src/locales/da.i18n.json @@ -1834,7 +1834,7 @@ "LDAP_Sync_Now_Description": "Vil udføre ** Background Sync ** nu i stedet for at vente ** Sync Interval **, selvom ** Background Sync ** er False. \n Denne handling er asynkron, se logfilerne for at få flere oplysninger om behandle", "LDAP_Sync_User_Active_State": "Aktive brugeres synkroniseringsstatus", "LDAP_Sync_User_Active_State_Both": "Aktivér og deaktiver brugere", - "LDAP_Sync_User_Active_State_Description": "Beslut om brugere skal være aktiveret eller deaktiveret i Rocket.Chat baseret på deres LDAP-status. Attributten 'pwdAccountLockedTime' vil blive brugt til at vurdere om brugeren er deaktiveret.", + "LDAP_Sync_User_Active_State_Description": "Beslut om brugere skal være aktiveret eller deaktiveret i Rocket.Chat baseret på deres LDAP-status.", "LDAP_Sync_User_Active_State_Disable": "Deaktiver brugere", "LDAP_Sync_User_Active_State_Nothing": "Gør ikke noget", "LDAP_Sync_User_Avatar": "Synkroniser brugerens Avatar", diff --git a/packages/i18n/src/locales/de.i18n.json b/packages/i18n/src/locales/de.i18n.json index 9b86a2beb79f9..431802a07719b 100644 --- a/packages/i18n/src/locales/de.i18n.json +++ b/packages/i18n/src/locales/de.i18n.json @@ -2280,7 +2280,7 @@ "LDAP_Sync_Now_Description": "Damit wird eine **Hintergrundsynchronisation** jetzt gestartet, ohne auf die nächste geplante Synchronisation zu warten. \nDiese Aktion ist asynchron, weitere Informationen entnehmen Sie bitte den Protokollen.", "LDAP_Sync_User_Active_State": "Benutzer aktiver Status synchronisieren", "LDAP_Sync_User_Active_State_Both": "Aktivieren und Deaktivieren von Benutzern", - "LDAP_Sync_User_Active_State_Description": "Bestimmen Sie, ob Benutzer auf Rocket.Chat basierend auf dem LDAP-Status aktiviert oder deaktiviert werden sollen. Das Attribut 'pwdAccountLockedTime' wird verwendet, um festzustellen, ob der Benutzer deaktiviert ist.", + "LDAP_Sync_User_Active_State_Description": "Bestimmen Sie, ob Benutzer auf Rocket.Chat basierend auf dem LDAP-Status aktiviert oder deaktiviert werden sollen.", "LDAP_Sync_User_Active_State_Disable": "Benutzer deaktivieren", "LDAP_Sync_User_Active_State_Nothing": "Nichts unternehmen", "LDAP_Sync_User_Avatar": "Profilbilder synchronisieren", diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index 2ca1a3a512464..ad0223251b0e2 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -2992,7 +2992,7 @@ "LDAP_Sync_Now_Description": "This will start a **Background Sync** operation now, without waiting for the next scheduled Sync. \nThis action is asynchronous, please see the logs for more information.", "LDAP_Sync_User_Active_State": "Sync User Active State", "LDAP_Sync_User_Active_State_Both": "Enable and Disable Users", - "LDAP_Sync_User_Active_State_Description": "Determine if users should be enabled or disabled on Rocket.Chat based on the LDAP status. The 'pwdAccountLockedTime' attribute will be used to determine if the user is disabled.", + "LDAP_Sync_User_Active_State_Description": "Determine if users should be enabled or disabled on Rocket.Chat based on the LDAP status.", "LDAP_Sync_User_Active_State_Disable": "Disable Users", "LDAP_Sync_User_Active_State_Enable": "Enable Users", "LDAP_Sync_User_Active_State_Nothing": "Do Nothing", diff --git a/packages/i18n/src/locales/es.i18n.json b/packages/i18n/src/locales/es.i18n.json index 197837da1583e..3f52e1087eed4 100644 --- a/packages/i18n/src/locales/es.i18n.json +++ b/packages/i18n/src/locales/es.i18n.json @@ -2102,7 +2102,7 @@ "LDAP_Sync_Now_Description": "Esta acción iniciará una operación de **Sincronización en segundo plano** ahora, sin esperar a la próxima sincronización programada. \nEsta acción es asincrónica; consulta los registros para obtener más información.", "LDAP_Sync_User_Active_State": "Sincronizar estado de actividad del usuario", "LDAP_Sync_User_Active_State_Both": "Habilitar y deshabilitar usuarios", - "LDAP_Sync_User_Active_State_Description": "Determina si los usuarios deben estar habilitados o deshabilitados en Rocket.Chat según el estado de LDAP. El atributo \"pwdAccountLockedTime\" se usará para determinar si el usuario está deshabilitado.", + "LDAP_Sync_User_Active_State_Description": "Determina si los usuarios deben estar habilitados o deshabilitados en Rocket.Chat según el estado de LDAP.", "LDAP_Sync_User_Active_State_Disable": "Habilitar usuarios", "LDAP_Sync_User_Active_State_Nothing": "No hacer nada", "LDAP_Sync_User_Avatar": "Sincronizar avatar de usuario", diff --git a/packages/i18n/src/locales/fi.i18n.json b/packages/i18n/src/locales/fi.i18n.json index af627375e1b4c..3ffde9c4a7f19 100644 --- a/packages/i18n/src/locales/fi.i18n.json +++ b/packages/i18n/src/locales/fi.i18n.json @@ -2339,7 +2339,7 @@ "LDAP_Sync_Now_Description": "Tämä käynnistää **taustasynkronoinnin** nyt odottamatta seuraavaa ajoitettua synkronointia. \nTämä toiminto on asynkroninen, lisätietoja on lokeissa.", "LDAP_Sync_User_Active_State": "Synkronoi käyttäjän aktiivinen tila", "LDAP_Sync_User_Active_State_Both": "Ota käyttöön ja poista käytöstä käyttäjiä", - "LDAP_Sync_User_Active_State_Description": "Määritä, otetaanko käyttäjät käyttöön vai poistetaanko heidät käytöstä Rocket.Chatissa LDAP-tilan perusteella. Määritteen 'pwdAccountLockedTime' avulla määritetään, onko käyttäjä poistettu käytöstä.", + "LDAP_Sync_User_Active_State_Description": "Määritä, otetaanko käyttäjät käyttöön vai poistetaanko heidät käytöstä Rocket.Chatissa LDAP-tilan perusteella.", "LDAP_Sync_User_Active_State_Disable": "Poista käyttäjät käytöstä", "LDAP_Sync_User_Active_State_Nothing": "Älä tee mitään", "LDAP_Sync_User_Avatar": "Synkronoi käyttäjän avatar", diff --git a/packages/i18n/src/locales/fr.i18n.json b/packages/i18n/src/locales/fr.i18n.json index fbf193c276a8b..3574a52e951a9 100644 --- a/packages/i18n/src/locales/fr.i18n.json +++ b/packages/i18n/src/locales/fr.i18n.json @@ -2067,7 +2067,7 @@ "LDAP_Sync_Now_Description": "Une opération **Synchronisation en arrière-plan** est lancée immédiatement, sans attendre la prochaine synchronisation planifiée. \nCette action est asynchrone, consultez les journaux pour plus d'informations.", "LDAP_Sync_User_Active_State": "Synchroniser l'état actif de l'utilisateur", "LDAP_Sync_User_Active_State_Both": "Activer et désactiver les utilisateurs", - "LDAP_Sync_User_Active_State_Description": "Indiquez si les utilisateurs doivent être activés ou désactivés dans Rocket.Chat en fonction de leur statut LDAP. L'attribut 'pwdAccountLockedTime' est utilisé pour déterminer si l'utilisateur est désactivé.", + "LDAP_Sync_User_Active_State_Description": "Indiquez si les utilisateurs doivent être activés ou désactivés dans Rocket.Chat en fonction de leur statut LDAP.", "LDAP_Sync_User_Active_State_Disable": "Désactiver les utilisateurs", "LDAP_Sync_User_Active_State_Nothing": "Ne rien faire", "LDAP_Sync_User_Avatar": "Synchronisation de l'avatar utilisateur", diff --git a/packages/i18n/src/locales/hi-IN.i18n.json b/packages/i18n/src/locales/hi-IN.i18n.json index 7e5e83079f01e..4d10b662da824 100644 --- a/packages/i18n/src/locales/hi-IN.i18n.json +++ b/packages/i18n/src/locales/hi-IN.i18n.json @@ -2425,7 +2425,7 @@ "LDAP_Sync_Now_Description": "यह अगले शेड्यूल किए गए सिंक की प्रतीक्षा किए बिना, अब **बैकग्राउंड सिंक** ऑपरेशन शुरू कर देगा।\nयह क्रिया अतुल्यकालिक है, कृपया अधिक जानकारी के लिए लॉग देखें।", "LDAP_Sync_User_Active_State": "उपयोगकर्ता सक्रिय स्थिति सिंक करें", "LDAP_Sync_User_Active_State_Both": "उपयोगकर्ताओं को सक्षम और अक्षम करें", - "LDAP_Sync_User_Active_State_Description": "एलडीएपी स्थिति के आधार पर निर्धारित करें कि उपयोगकर्ताओं को Rocket.Chat पर सक्षम या अक्षम किया जाना चाहिए या नहीं। 'pwdAccountLockedTime' विशेषता का उपयोग यह निर्धारित करने के लिए किया जाएगा कि उपयोगकर्ता अक्षम है या नहीं।", + "LDAP_Sync_User_Active_State_Description": "एलडीएपी स्थिति के आधार पर निर्धारित करें कि उपयोगकर्ताओं को Rocket.Chat पर सक्षम या अक्षम किया जाना चाहिए या नहीं।", "LDAP_Sync_User_Active_State_Disable": "उपयोगकर्ताओं को अक्षम करें", "LDAP_Sync_User_Active_State_Nothing": "कुछ भी नहीं है", "LDAP_Sync_User_Avatar": "उपयोगकर्ता अवतार सिंक करें", diff --git a/packages/i18n/src/locales/hu.i18n.json b/packages/i18n/src/locales/hu.i18n.json index 1c3a5bd71538f..8652477b5536f 100644 --- a/packages/i18n/src/locales/hu.i18n.json +++ b/packages/i18n/src/locales/hu.i18n.json @@ -2237,7 +2237,7 @@ "LDAP_Sync_Now_Description": "Ez elindítja a **Háttérben történő szinkronizálás** műveletet most, anélkül hogy a következő ütemezett szinkronizálásra várna. \nEz a művelet aszinkron, további információkért nézze meg a naplókat.", "LDAP_Sync_User_Active_State": "Felhasználó aktív állapotának szinkronizálása", "LDAP_Sync_User_Active_State_Both": "Felhasználók engedélyezése és letiltása", - "LDAP_Sync_User_Active_State_Description": "Annak meghatározása, hogy a felhasználókat engedélyezni vagy letiltani kell-e a Rocket.Chaten az LDAP-állapot alapján. A „pwdAccountLockedTime” attribútum lesz használva annak meghatározásához, hogy a felhasználó le van-e tiltva.", + "LDAP_Sync_User_Active_State_Description": "Annak meghatározása, hogy a felhasználókat engedélyezni vagy letiltani kell-e a Rocket.Chaten az LDAP-állapot alapján.", "LDAP_Sync_User_Active_State_Disable": "Felhasználók letiltása", "LDAP_Sync_User_Active_State_Nothing": "Ne csináljon semmit", "LDAP_Sync_User_Avatar": "Felhasználó profilképének szinkronizálása", diff --git a/packages/i18n/src/locales/ja.i18n.json b/packages/i18n/src/locales/ja.i18n.json index 7606f6ef0acf3..902982d4c637d 100644 --- a/packages/i18n/src/locales/ja.i18n.json +++ b/packages/i18n/src/locales/ja.i18n.json @@ -2047,7 +2047,7 @@ "LDAP_Sync_Now_Description": "次回の同期予定を待たずに**バックグラウンド同期**を今すぐ実行します。 \nこのアクションは非同期です。詳細についてはログを参照してください。", "LDAP_Sync_User_Active_State": "ユーザーのアクティブ状態の同期", "LDAP_Sync_User_Active_State_Both": "ユーザーの有効化と無効化", - "LDAP_Sync_User_Active_State_Description": "LDAPステータスに基づいて、Rocket.Chatでユーザーを有効にするか無効にするかを決定します。「pwdAccountLockedTime」属性は、ユーザーが無効になっているかどうかを判断するために使用されます。", + "LDAP_Sync_User_Active_State_Description": "LDAPステータスに基づいて、Rocket.Chatでユーザーを有効にするか無効にするかを決定します。", "LDAP_Sync_User_Active_State_Disable": "ユーザーを無効にする", "LDAP_Sync_User_Active_State_Nothing": "何もしない", "LDAP_Sync_User_Avatar": "ユーザーのアバターの同期", diff --git a/packages/i18n/src/locales/ka-GE.i18n.json b/packages/i18n/src/locales/ka-GE.i18n.json index 2af9aff75c64e..76d251fdccd52 100644 --- a/packages/i18n/src/locales/ka-GE.i18n.json +++ b/packages/i18n/src/locales/ka-GE.i18n.json @@ -1679,7 +1679,7 @@ "LDAP_Sync_Now_Description": "** შეასრულებს ** ფონის სინქრონიზაციას ახლა, ვიდრე დაელოდება ** სინქრონიზაციის ინტერვალს ** მაშინაც კი, თუ ** ფონი სინქრონიზაცია ** გამორთულია. \n ეს მოქმედება ასინქრონულია, ნახეთ ლოგები მეტი ინფორმაციისთვის", "LDAP_Sync_User_Active_State": "მომხმარებლის აქტიური მდგომარეობის სინქრონიზაცია", "LDAP_Sync_User_Active_State_Both": "მომხმარებლების ჩართვა და გამორთვა", - "LDAP_Sync_User_Active_State_Description": "დაადგინეთ, ჩართულია თუ არა მომხმარებლები Rocket.Chat– ში LDAP სტატუსის საფუძველზე. 'PwdAccountLockedTime' ატრიბუტი გამოყენებული იქნება, თუ მომხმარებელი გამორთულია.", + "LDAP_Sync_User_Active_State_Description": "დაადგინეთ, ჩართულია თუ არა მომხმარებლები Rocket.Chat– ში LDAP სტატუსის საფუძველზე.", "LDAP_Sync_User_Active_State_Disable": "მომხმარებლების გამორთვა", "LDAP_Sync_User_Active_State_Nothing": "Არაფრის კეთება", "LDAP_Sync_User_Avatar": "მომხმარებლის ავატარის სინქრონიზაცია", diff --git a/packages/i18n/src/locales/ko.i18n.json b/packages/i18n/src/locales/ko.i18n.json index 3d360b5ac15de..f82017f98ed68 100644 --- a/packages/i18n/src/locales/ko.i18n.json +++ b/packages/i18n/src/locales/ko.i18n.json @@ -1816,7 +1816,7 @@ "LDAP_Sync_Now_Description": "다음번 동기화를 기다리지 않고, 지금 **백그라운드 동기화** 작업을 시작합니다. \n이 작업은 비동기로 이뤄지며, 자세한 내용은 로그를 참조하십시오.", "LDAP_Sync_User_Active_State": "사용자 활성 상태 동기화", "LDAP_Sync_User_Active_State_Both": "사용자 활성화 및 비활성화", - "LDAP_Sync_User_Active_State_Description": "LDAP 상태를 기반으로 Rocket.Chat에서 사용자의 활성화 또는 비활성화 여부를 결정하십시오. 'pwdAccountLockedTime'속성은 사용자의 비활성화 여부를 결정하는 데 사용됩니다.", + "LDAP_Sync_User_Active_State_Description": "LDAP 상태를 기반으로 Rocket.Chat에서 사용자의 활성화 또는 비활성화 여부를 결정하십시오.", "LDAP_Sync_User_Active_State_Disable": "사용자 비활성화", "LDAP_Sync_User_Active_State_Nothing": "없음", "LDAP_Sync_User_Avatar": "사용자 아바타 동기화", diff --git a/packages/i18n/src/locales/nb.i18n.json b/packages/i18n/src/locales/nb.i18n.json index 25745513119aa..1931510ecfae4 100644 --- a/packages/i18n/src/locales/nb.i18n.json +++ b/packages/i18n/src/locales/nb.i18n.json @@ -2900,7 +2900,7 @@ "LDAP_Sync_Now_Description": "Vil utføre **Background Sync** nå i stedet for å vente **Sync Interval** selv om **Bakgrunnssynkronisering** er False. \n Denne handlingen er asynkron, se loggene for mer informasjon om prosess", "LDAP_Sync_User_Active_State": "Synkroniser brukerens aktiv tilstand", "LDAP_Sync_User_Active_State_Both": "Aktiver og deaktiver brukere", - "LDAP_Sync_User_Active_State_Description": "Bestem om brukere skal aktiveres eller deaktiveres på Rocket.Chat basert på LDAP-statusen. 'pwdAccountLockedTime'-attributtet vil bli brukt til å avgjøre om brukeren er deaktivert.", + "LDAP_Sync_User_Active_State_Description": "Bestem om brukere skal aktiveres eller deaktiveres på Rocket.Chat basert på LDAP-statusen.", "LDAP_Sync_User_Active_State_Disable": "Deaktiver brukere", "LDAP_Sync_User_Active_State_Enable": "Aktiver brukere", "LDAP_Sync_User_Active_State_Nothing": "Ikke gjør noe", diff --git a/packages/i18n/src/locales/nl.i18n.json b/packages/i18n/src/locales/nl.i18n.json index 168ee680816ec..dc24ac7c34536 100644 --- a/packages/i18n/src/locales/nl.i18n.json +++ b/packages/i18n/src/locales/nl.i18n.json @@ -2069,7 +2069,7 @@ "LDAP_Sync_Now_Description": "Hierdoor wordt nu een **Achtergrondsynchronisatie**-operatie gestart, zonder op de volgende geplande synchronisatie te wachten. \nDeze actie is asynchroon, zie de logs voor meer informatie.", "LDAP_Sync_User_Active_State": "Synchroniseer de actieve status van de gebruiker", "LDAP_Sync_User_Active_State_Both": "Schakel gebruikers in en uit", - "LDAP_Sync_User_Active_State_Description": "Bepaal of gebruikers moeten worden in- of uitgeschakeld op Rocket.Chat op basis van de LDAP-status. Het kenmerk 'pwdAccountLockedTime' wordt gebruikt om te bepalen of de gebruiker is uitgeschakeld.", + "LDAP_Sync_User_Active_State_Description": "Bepaal of gebruikers moeten worden in- of uitgeschakeld op Rocket.Chat op basis van de LDAP-status.", "LDAP_Sync_User_Active_State_Disable": "Schakel gebruikers uit", "LDAP_Sync_User_Active_State_Nothing": "Niets doen", "LDAP_Sync_User_Avatar": "Synchroniseer gebruikersavatar", diff --git a/packages/i18n/src/locales/nn.i18n.json b/packages/i18n/src/locales/nn.i18n.json index 144a65df13b86..42ef356902a98 100644 --- a/packages/i18n/src/locales/nn.i18n.json +++ b/packages/i18n/src/locales/nn.i18n.json @@ -2686,7 +2686,7 @@ "LDAP_Sync_Now_Description": "Vil utføre **Background Sync** nå i stedet for å vente **Sync Interval** selv om **Bakgrunnssynkronisering** er False. \n Denne handlingen er asynkron, se loggene for mer informasjon om prosess", "LDAP_Sync_User_Active_State": "Synkroniser brukerens aktiv tilstand", "LDAP_Sync_User_Active_State_Both": "Aktiver og deaktiver brukere", - "LDAP_Sync_User_Active_State_Description": "Bestem om brukere skal aktiveres eller deaktiveres på Rocket.Chat basert på LDAP-statusen. 'pwdAccountLockedTime'-attributtet vil bli brukt til å avgjøre om brukeren er deaktivert.", + "LDAP_Sync_User_Active_State_Description": "Bestem om brukere skal aktiveres eller deaktiveres på Rocket.Chat basert på LDAP-statusen.", "LDAP_Sync_User_Active_State_Disable": "Deaktiver brukere", "LDAP_Sync_User_Active_State_Enable": "Aktiver brukere", "LDAP_Sync_User_Active_State_Nothing": "Ikke gjør noe", diff --git a/packages/i18n/src/locales/pl.i18n.json b/packages/i18n/src/locales/pl.i18n.json index 6746eed32442c..94a876e4a788d 100644 --- a/packages/i18n/src/locales/pl.i18n.json +++ b/packages/i18n/src/locales/pl.i18n.json @@ -2251,7 +2251,7 @@ "LDAP_Sync_Now_Description": "Spowoduje to rozpoczęcie operacji **Background Sync** teraz, bez czekania na następną zaplanowaną synchronizację. \nTo działanie jest asynchroniczne. Więcej informacji można znaleźć w dziennikach.", "LDAP_Sync_User_Active_State": "Synchronizuj status aktywności użytkownika", "LDAP_Sync_User_Active_State_Both": "Aktywuj i dezaktywuj użytkowników", - "LDAP_Sync_User_Active_State_Description": "Określ, czy użytkownicy powinni być włączeni lub wyłączeni w Rocket.Chat na podstawie statusu LDAP. Atrybut \"pwdAccountLockedTime\" będzie wykorzystywany do określenia, czy użytkownik jest wyłączony.", + "LDAP_Sync_User_Active_State_Description": "Określ, czy użytkownicy powinni być włączeni lub wyłączeni w Rocket.Chat na podstawie statusu LDAP.", "LDAP_Sync_User_Active_State_Disable": "Dezaktywuj użytkowników", "LDAP_Sync_User_Active_State_Nothing": "Nic nie rób", "LDAP_Sync_User_Avatar": "Synchronizacja User Avatar", diff --git a/packages/i18n/src/locales/pt-BR.i18n.json b/packages/i18n/src/locales/pt-BR.i18n.json index fab7c64fff1a2..a8f501c0b405c 100644 --- a/packages/i18n/src/locales/pt-BR.i18n.json +++ b/packages/i18n/src/locales/pt-BR.i18n.json @@ -2993,7 +2993,7 @@ "LDAP_Sync_Now_Description": "Iniciará uma operação de **Sincronização em segundo plano** agora, em vez de aguardar a próxima sincronização programada. \nEsta ação é assíncrona. Veja os logs para obter mais informações.", "LDAP_Sync_User_Active_State": "Sincronizar estado ativo do usuário", "LDAP_Sync_User_Active_State_Both": "Habilitar e desabilitar usuários", - "LDAP_Sync_User_Active_State_Description": "Determina se usuários devem ser habilitados ou desabilitados no Rocket.Chat baseado nos status do LDAP. O atributo 'pwdAccountLockedTime' será usado para determinar se o usuário está desativado.", + "LDAP_Sync_User_Active_State_Description": "Determina se usuários devem ser habilitados ou desabilitados no Rocket.Chat baseado nos status do LDAP.", "LDAP_Sync_User_Active_State_Disable": "Desabilitar usuários", "LDAP_Sync_User_Active_State_Enable": "Habilitar usuários", "LDAP_Sync_User_Active_State_Nothing": "Não fazer nada", diff --git a/packages/i18n/src/locales/ru.i18n.json b/packages/i18n/src/locales/ru.i18n.json index d0ecdc864077a..7f7f902574de0 100644 --- a/packages/i18n/src/locales/ru.i18n.json +++ b/packages/i18n/src/locales/ru.i18n.json @@ -2177,7 +2177,7 @@ "LDAP_Sync_Now_Description": "Выполнить **Фоновую синхронизацию** сейчас, не дожидаясь **Интервала синхронизации**, даже если **Фоновая синхронизация** отключена. \n Действие выполняется асинхронно, см. журналы для получения дополнительной информации о процессе", "LDAP_Sync_User_Active_State": "Синхронизация Активного состояния пользователя", "LDAP_Sync_User_Active_State_Both": "Включить и отключить пользователей", - "LDAP_Sync_User_Active_State_Description": "Определить, должны ли пользователи быть включены или отключены в Rocket.Chat на основе их статуса LDAP. Атрибут 'pwdAccountLockedTime' будет использоваться для определения того, отключен ли пользователь.", + "LDAP_Sync_User_Active_State_Description": "Определить, должны ли пользователи быть включены или отключены в Rocket.Chat на основе их статуса LDAP.", "LDAP_Sync_User_Active_State_Disable": "Отключить пользователей", "LDAP_Sync_User_Active_State_Nothing": "Ничего не делать", "LDAP_Sync_User_Avatar": "Синхронизация пользовательских аватаров", diff --git a/packages/i18n/src/locales/sv.i18n.json b/packages/i18n/src/locales/sv.i18n.json index b8b89a51855f0..023d5161462d4 100644 --- a/packages/i18n/src/locales/sv.i18n.json +++ b/packages/i18n/src/locales/sv.i18n.json @@ -2985,7 +2985,7 @@ "LDAP_Sync_Now_Description": "Detta kommer att starta **Bakgrundssynkronisering** nu istället för att vänta på **Synkroniseringsintervall**. \nDenna åtgärd är asynkron, se loggarna för mer information.", "LDAP_Sync_User_Active_State": "Synkronisera aktiv användarstatus", "LDAP_Sync_User_Active_State_Both": "Aktivera och inaktivera användare", - "LDAP_Sync_User_Active_State_Description": "Bestäm om användare ska aktiveras eller inaktiveras i Rocket.Chat baserat på LDAP-status. Attributet \"'pwdAccountLockedTime\" används till att fastställa om användaren är inaktiverad.", + "LDAP_Sync_User_Active_State_Description": "Bestäm om användare ska aktiveras eller inaktiveras i Rocket.Chat baserat på LDAP-status.", "LDAP_Sync_User_Active_State_Disable": "Inaktivera användare", "LDAP_Sync_User_Active_State_Enable": "Aktivera användare", "LDAP_Sync_User_Active_State_Nothing": "Gör ingenting", diff --git a/packages/i18n/src/locales/zh-TW.i18n.json b/packages/i18n/src/locales/zh-TW.i18n.json index c2bbd09abf3d8..387cf254b070c 100644 --- a/packages/i18n/src/locales/zh-TW.i18n.json +++ b/packages/i18n/src/locales/zh-TW.i18n.json @@ -2024,7 +2024,7 @@ "LDAP_Sync_Now_Description": "將立即執行**背景同步**,而不是等待下一次排程同步。 \n這個操作是不同步的,請參閱日誌以取得更多資訊。", "LDAP_Sync_User_Active_State": "同步使用者活動狀態", "LDAP_Sync_User_Active_State_Both": "啟用和停用使用者", - "LDAP_Sync_User_Active_State_Description": "根據 LDAP 狀態確定應在 Rocket.Chat 上啟用還是停用使用者。 “ pwdAccountLockedTime”屬性將用於確定使用者是否被停用。", + "LDAP_Sync_User_Active_State_Description": "根據 LDAP 狀態確定應在 Rocket.Chat 上啟用還是停用使用者。", "LDAP_Sync_User_Active_State_Disable": "停用使用者", "LDAP_Sync_User_Active_State_Nothing": "不做任何事", "LDAP_Sync_User_Avatar": "同步使用者大頭貼", diff --git a/packages/i18n/src/locales/zh.i18n.json b/packages/i18n/src/locales/zh.i18n.json index 3050a8ac9b76a..c8bea1f19164f 100644 --- a/packages/i18n/src/locales/zh.i18n.json +++ b/packages/i18n/src/locales/zh.i18n.json @@ -2897,7 +2897,7 @@ "LDAP_Sync_Now_Description": "将立即执行 **后台同步**,而不是等待 **同步间隔**,即使 **后台同步** 为 False。 \n 此操作是异步的,请参阅日志以获取有关处理", "LDAP_Sync_User_Active_State": "同步用户激活状态", "LDAP_Sync_User_Active_State_Both": "启用和禁用用户", - "LDAP_Sync_User_Active_State_Description": "根据 LDAP 状态确定用户在 Rocket.Chat 应该启用还是禁用。其中`pwdAccountLockedTime`属性将会被用于确认用户是否被禁用。", + "LDAP_Sync_User_Active_State_Description": "根据 LDAP 状态确定用户在 Rocket.Chat 应该启用还是禁用。", "LDAP_Sync_User_Active_State_Disable": "禁用用户", "LDAP_Sync_User_Active_State_Enable": "启用用户", "LDAP_Sync_User_Active_State_Nothing": "什么也不做", diff --git a/packages/media-signaling/src/lib/Call.ts b/packages/media-signaling/src/lib/Call.ts index 184a3675b19a4..4ae5d3765eb68 100644 --- a/packages/media-signaling/src/lib/Call.ts +++ b/packages/media-signaling/src/lib/Call.ts @@ -185,7 +185,11 @@ export class ClientMediaCall implements IClientMediaCall { private hasRemoteData: boolean; - private initialized: boolean; + private _initialized: boolean; + + public get initialized(): boolean { + return this._initialized; + } private acknowledged: boolean; @@ -296,7 +300,7 @@ export class ClientMediaCall implements IClientMediaCall { this.acceptedRemotely = false; this.endedLocally = false; this.hasRemoteData = false; - this.initialized = false; + this._initialized = false; this.acknowledged = false; this.contractState = 'proposed'; this.serviceStates = new Map(); @@ -340,7 +344,7 @@ export class ClientMediaCall implements IClientMediaCall { const wasInitialized = this.initialized; - this.initialized = true; + this._initialized = true; this.acceptedLocally = true; if (this.hasRemoteData) { this.changeContact(contact, { prioritizeExisting: true }); @@ -388,7 +392,7 @@ export class ClientMediaCall implements IClientMediaCall { this.remoteCallId = signal.callId; const wasInitialized = this.initialized; - this.initialized = true; + this._initialized = true; this.hasRemoteData = true; this._service = signal.service; this._role = signal.role; diff --git a/packages/media-signaling/src/lib/Session.ts b/packages/media-signaling/src/lib/Session.ts index 64dd3d4592786..039e0a08ec209 100644 --- a/packages/media-signaling/src/lib/Session.ts +++ b/packages/media-signaling/src/lib/Session.ts @@ -173,7 +173,7 @@ export class MediaSignalingSession extends Emitter { let pendingCall: ClientMediaCall | null = null; for (const call of this.knownCalls.values()) { - if (call.state === 'hangup' || call.ignored) { + if (call.state === 'hangup' || call.ignored || !call.initialized) { continue; } if (skipLocal && !call.confirmed) {