Skip to content
Open
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
45 changes: 42 additions & 3 deletions apps/meteor/app/api/server/v1/invites.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,10 +69,46 @@ const invites = API.v1
{
authRequired: true,
response: {
200: ajv.compile<IInvite[]>({
200: ajv.compile<Omit<IInvite, 'inviteToken'>[]>({
additionalProperties: false,
type: 'array',
items: {
$ref: '#/components/schemas/IInvite',
additionalProperties: false,
type: 'object',
properties: {
_id: {
type: 'string',
},
days: {
type: 'number',
},
maxUses: {
type: 'number',
},
rid: {
type: 'string',
},
userId: {
type: 'string',
},
createdAt: {
type: 'string',
},
_updatedAt: {
type: 'string',
},
expires: {
type: 'string',
nullable: true,
},
uses: {
type: 'number',
},
url: {
type: 'string',
},
},
required: ['_id', 'days', 'maxUses', 'rid', 'userId', 'createdAt', '_updatedAt', 'uses', 'url'],
},
}),
401: ajv.compile({
Expand Down Expand Up @@ -118,6 +154,9 @@ const invites = API.v1
_id: {
type: 'string',
},
inviteToken: {
type: 'string',
},
rid: {
type: 'string',
},
Expand Down Expand Up @@ -151,7 +190,7 @@ const invites = API.v1
description: 'Indicates if the request was successful.',
},
},
required: ['_id', 'rid', 'createdAt', 'maxUses', 'uses', 'userId', '_updatedAt', 'days', 'success'],
required: ['_id', 'inviteToken', 'rid', 'createdAt', 'maxUses', 'uses', 'userId', '_updatedAt', 'days', 'success'],
}),
400: ajv.compile({
additionalProperties: false,
Expand Down
16 changes: 11 additions & 5 deletions apps/meteor/app/invites/server/functions/findOrCreateInvite.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import crypto from 'node:crypto';

import { api } from '@rocket.chat/core-services';
import type { IInvite } from '@rocket.chat/core-typings';
import { Invites, Subscriptions, Rooms } from '@rocket.chat/models';
import { Random } from '@rocket.chat/random';
import { Meteor } from 'meteor/meteor';

import { RoomMemberActions } from '../../../../definition/IRoomTypeConfig';
Expand All @@ -11,12 +12,12 @@ import { settings } from '../../../settings/server';
import { getURL } from '../../../utils/server/getURL';

function getInviteUrl(invite: Omit<IInvite, '_updatedAt'>) {
const { _id } = invite;
const { inviteToken } = invite;

const useDirectLink = settings.get<string>('Accounts_Registration_InviteUrlType') === 'direct';

return getURL(
`invite/${_id}`,
`invite/${inviteToken}`,
{
full: useDirectLink,
cloud: !useDirectLink,
Expand Down Expand Up @@ -89,13 +90,17 @@ export const findOrCreateInvite = async (userId: string, invite: Pick<IInvite, '
// Before anything, let's check if there's an existing invite with the same settings for the same channel and user and that has not yet expired.
const existing = await Invites.findOneByUserRoomMaxUsesAndExpiration(userId, invite.rid, maxUses, days);

// If an existing invite was found, return it's _id instead of creating a new one.
// If an existing invite was found, ensure it has an inviteToken and return it
if (existing) {
// Ensure the invite has an inviteToken (handles legacy invites atomically)
const inviteToken = await Invites.ensureInviteToken(existing._id);
existing.inviteToken = inviteToken;
existing.url = getInviteUrl(existing);
return existing;
}
Comment thread
julio-rocketchat marked this conversation as resolved.

const _id = Random.id(6);
const _id = crypto.randomBytes(8).toString('hex');
const inviteToken = crypto.randomUUID();

// insert invite
const createdAt = new Date();
Expand All @@ -107,6 +112,7 @@ export const findOrCreateInvite = async (userId: string, invite: Pick<IInvite, '

const createInvite: Omit<IInvite, '_updatedAt'> = {
_id,
inviteToken,
days,
maxUses,
rid: invite.rid,
Expand Down
20 changes: 19 additions & 1 deletion apps/meteor/app/invites/server/functions/listInvites.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { IInvite } from '@rocket.chat/core-typings';
import { Invites } from '@rocket.chat/models';
import { Meteor } from 'meteor/meteor';

Expand All @@ -12,5 +13,22 @@ export const listInvites = async (userId: string) => {
throw new Meteor.Error('not_authorized');
}

return Invites.find({}).toArray();
const invites = await Invites.find({}).toArray();

// Ensure all invites have inviteToken (for legacy invites that might not have it)
for (const invite of invites) {
const inviteWithToken = invite as IInvite & { inviteToken?: string };
if (!inviteWithToken.inviteToken) {
const inviteToken = crypto.randomUUID();
// eslint-disable-next-line no-await-in-loop
await Invites.updateOne({ _id: invite._id }, { $set: { inviteToken } });
inviteWithToken.inviteToken = inviteToken;
}
}

// Remove inviteToken from the response
return invites.map((invite) => {
const { inviteToken, ...inviteWithoutToken } = invite as IInvite & { inviteToken?: string };
return inviteWithoutToken;
});
};
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ export const validateInviteToken = async (token: string) => {
});
}

const inviteData = await Invites.findOneById(token);
const inviteData = await Invites.findOneByInviteToken(token);

if (!inviteData) {
throw new Meteor.Error('error-invalid-token', 'The invite token is invalid.', {
method: 'validateInviteToken',
Expand Down
2 changes: 1 addition & 1 deletion apps/meteor/client/views/admin/invites/InviteRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ const isExpired = (expires: IInvite['expires']): boolean => {
return false;
};

type InviteRowProps = Omit<IInvite, 'createdAt' | 'expires' | '_updatedAt'> & {
type InviteRowProps = Omit<IInvite, 'createdAt' | 'expires' | '_updatedAt' | 'inviteToken'> & {
onRemove: (removeInvite: () => Promise<boolean>) => void;
_updatedAt: string;
createdAt: string;
Expand Down
3 changes: 2 additions & 1 deletion apps/meteor/client/views/admin/invites/InvitesPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ const InvitesPage = () => {
const headers = useMemo(
() => (
<>
<GenericTableHeaderCell w={notSmall ? '20%' : '80%'}>{t('Token')}</GenericTableHeaderCell>
<GenericTableHeaderCell w={notSmall ? '20%' : '80%'}>{t('Invite')}</GenericTableHeaderCell>
{notSmall && (
<>
<GenericTableHeaderCell w='35%'>{t('Created_at')}</GenericTableHeaderCell>
Expand Down Expand Up @@ -99,6 +99,7 @@ const InvitesPage = () => {
</GenericTableBody>
</GenericTable>
)}

{isSuccess && data && data.length > 0 && (
<GenericTable>
<GenericTableHeader>{headers}</GenericTableHeader>
Expand Down
4 changes: 2 additions & 2 deletions apps/meteor/tests/e2e/saml.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,8 +169,8 @@ test.describe('SAML', () => {

const inviteResponse = await api.post('/findOrCreateInvite', { rid: targetInviteGroupId, days: 1, maxUses: 0 });
expect(inviteResponse.status()).toBe(200);
const { _id } = await inviteResponse.json();
inviteId = _id;
const { inviteToken } = await inviteResponse.json();
inviteId = inviteToken;
});

test.afterAll(async ({ api }) => {
Expand Down
4 changes: 2 additions & 2 deletions apps/meteor/tests/end-to-end/api/abac.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1710,8 +1710,8 @@ const addAbacAttributesToUserDirectly = async (userId: string, abacAttributes: I
expect(res.body).to.have.property('rid', plainRoomId);
expect(res.body).to.have.property('days', 1);
expect(res.body).to.have.property('maxUses', 0);
plainRoomInviteToken = res.body._id;
createdInviteIds.push(plainRoomInviteToken);
plainRoomInviteToken = res.body.inviteToken;
createdInviteIds.push(res.body._id);
});
});

Expand Down
54 changes: 45 additions & 9 deletions apps/meteor/tests/end-to-end/api/invites.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { createUser, deleteUser, login } from '../../data/users.helper';

describe('Invites', () => {
let testInviteID: IInvite['_id'];
let testInviteToken: IInvite['inviteToken'];

before((done) => getCredentials(done));
describe('POST [/findOrCreateInvite]', () => {
Expand Down Expand Up @@ -63,7 +64,10 @@ describe('Invites', () => {
expect(res.body).to.have.property('maxUses', 10);
expect(res.body).to.have.property('uses');
expect(res.body).to.have.property('_id');
expect(res.body).to.have.property('inviteToken');
expect(res.body.inviteToken).to.be.a('string');
testInviteID = res.body._id;
testInviteToken = res.body.inviteToken;
})
.end(done);
});
Expand All @@ -84,6 +88,7 @@ describe('Invites', () => {
expect(res.body).to.have.property('maxUses', 10);
expect(res.body).to.have.property('uses');
expect(res.body).to.have.property('_id', testInviteID);
expect(res.body).to.have.property('inviteToken', testInviteToken);
})
.end(done);
});
Expand All @@ -101,13 +106,14 @@ describe('Invites', () => {
.end(done);
});

it('should return the existing invite for GENERAL', (done) => {
it('should return the existing invite for GENERAL without inviteToken', (done) => {
void request
.get(api('listInvites'))
.set(credentials)
.expect(200)
.expect((res) => {
expect(res.body[0]).to.have.property('_id', testInviteID);
expect(res.body[0]).to.not.have.property('inviteToken');
})
.end(done);
});
Expand Down Expand Up @@ -153,19 +159,34 @@ describe('Invites', () => {
.end(done);
});

it('should use the existing invite for GENERAL', (done) => {
it('should use the existing invite for GENERAL with inviteToken', (done) => {
void request
.post(api('useInviteToken'))
.set(credentials)
.send({
token: testInviteID,
token: testInviteToken,
})
.expect(200)
.expect((res) => {
expect(res.body).to.have.property('success', true);
})
.end(done);
});

it('should fail when using _id as token', (done) => {
void request
.post(api('useInviteToken'))
.set(credentials)
.send({
token: testInviteID,
})
.expect(400)
.expect((res) => {
expect(res.body).to.have.property('success', false);
expect(res.body).to.have.property('errorType', 'error-invalid-token');
})
.end(done);
});
});

describe('POST [/validateInviteToken]', () => {
Expand All @@ -184,12 +205,12 @@ describe('Invites', () => {
.end(done);
});

it('should succeed when valid token', (done) => {
it('should succeed when valid inviteToken', (done) => {
void request
.post(api('validateInviteToken'))
.set(credentials)
.send({
token: testInviteID,
token: testInviteToken,
})
.expect(200)
.expect((res) => {
Expand All @@ -198,13 +219,28 @@ describe('Invites', () => {
})
.end(done);
});

it('should fail when using _id as token', (done) => {
void request
.post(api('validateInviteToken'))
.set(credentials)
.send({
token: testInviteID,
})
.expect(200)
.expect((res) => {
expect(res.body).to.have.property('success', true);
expect(res.body).to.have.property('valid', false);
})
.end(done);
});
});

describe('POST [/useInviteToken] - banned user', () => {
let room: IRoom;
let bannedUser: TestUser<IUser>;
let bannedUserCredentials: Credentials;
let inviteId: IInvite['_id'];
let banTestInviteToken: IInvite['inviteToken'];

before(async () => {
bannedUser = await createUser();
Expand All @@ -223,7 +259,7 @@ describe('Invites', () => {
.set(credentials)
.send({ rid: room._id, days: 1, maxUses: 10 })
.expect(200);
inviteId = invite.body._id;
banTestInviteToken = invite.body.inviteToken;
});

after(async () => {
Expand All @@ -235,7 +271,7 @@ describe('Invites', () => {
await request
.post(api('useInviteToken'))
.set(bannedUserCredentials)
.send({ token: inviteId })
.send({ token: banTestInviteToken })
.expect(400)
.expect((res) => {
expect(res.body).to.have.property('success', false);
Expand All @@ -249,7 +285,7 @@ describe('Invites', () => {
await request
.post(api('useInviteToken'))
.set(bannedUserCredentials)
.send({ token: inviteId })
.send({ token: banTestInviteToken })
.expect(200)
.expect((res) => {
expect(res.body).to.have.property('success', true);
Expand Down
17 changes: 9 additions & 8 deletions apps/meteor/tests/end-to-end/api/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1843,6 +1843,7 @@ describe('[Users]', () => {
let user3Credentials: Credentials;
let group: IRoom;
let inviteToken: string;
let inviteId: string;

before(async () => {
const username = `deactivated_${Date.now()}${apiUsername}`;
Expand Down Expand Up @@ -1913,18 +1914,18 @@ describe('[Users]', () => {
});

before('Create invite link', async () => {
inviteToken = (
await request.post(api('findOrCreateInvite')).set(credentials).send({
rid: group._id,
days: 0,
maxUses: 0,
})
).body._id;
const response = await request.post(api('findOrCreateInvite')).set(credentials).send({
rid: group._id,
days: 0,
maxUses: 0,
});
inviteToken = response.body.inviteToken;
inviteId = response.body._id;
});

after('Remove invite link', async () =>
request
.delete(api(`removeInvite/${inviteToken}`))
.delete(api(`removeInvite/${inviteId}`))
.set(credentials)
.send(),
);
Expand Down
Loading
Loading