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
44 changes: 41 additions & 3 deletions apps/meteor/app/api/server/v1/invites.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,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 @@ -72,6 +107,9 @@ const invites = API.v1
_id: {
type: 'string',
},
inviteToken: {
type: 'string',
},
rid: {
type: 'string',
},
Expand Down Expand Up @@ -105,7 +143,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;
}

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
5 changes: 4 additions & 1 deletion apps/meteor/client/views/admin/invites/InvitesPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ const InvitesPage = (): ReactElement => {
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 @@ -100,6 +100,7 @@ const InvitesPage = (): ReactElement => {
</GenericTableBody>
</GenericTable>
)}

{isSuccess && data && data.length > 0 && (
<GenericTable>
<GenericTableHeader>{headers}</GenericTableHeader>
Expand All @@ -111,7 +112,9 @@ const InvitesPage = (): ReactElement => {
</GenericTableBody>
</GenericTable>
)}

{isSuccess && data && data.length === 0 && <GenericNoResults />}

{isError && (
<States>
<StatesIcon name='warning' variation='danger' />
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 @@ -139,8 +139,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 @@ -1536,8 +1536,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
46 changes: 41 additions & 5 deletions apps/meteor/tests/end-to-end/api/invites.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { getCredentials, api, request, credentials } from '../../data/api-data';

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

before((done) => getCredentials(done));
describe('POST [/findOrCreateInvite]', () => {
Expand Down Expand Up @@ -58,7 +59,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 @@ -79,6 +83,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 @@ -96,13 +101,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 @@ -148,19 +154,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 @@ -179,12 +200,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 @@ -193,6 +214,21 @@ 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('DELETE [/removeInvite]', () => {
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 @@ -1258,6 +1258,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 @@ -1328,18 +1329,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
1 change: 1 addition & 0 deletions packages/core-typings/src/IInvite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { IRocketChatRecord } from './IRocketChatRecord';

export interface IInvite extends IRocketChatRecord {
days: number;
inviteToken: string;
maxUses: number;
rid: string;
userId: string;
Expand Down
2 changes: 2 additions & 0 deletions packages/model-typings/src/models/IInvitesModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import type { IBaseModel } from './IBaseModel';

export interface IInvitesModel extends IBaseModel<IInvite> {
findOneByUserRoomMaxUsesAndExpiration(userId: string, rid: string, maxUses: number, daysToExpire: number): Promise<IInvite | null>;
findOneByInviteToken(inviteToken: string): Promise<IInvite | null>;
increaseUsageById(_id: string, uses: number): Promise<UpdateResult>;
countUses(): Promise<number>;
ensureInviteToken(_id: string): Promise<string>;
}
Loading
Loading