diff --git a/keyserver/src/creators/invite-link-creator.js b/keyserver/src/creators/invite-link-creator.js index da9682f6e..8f5baf0cf 100644 --- a/keyserver/src/creators/invite-link-creator.js +++ b/keyserver/src/creators/invite-link-creator.js @@ -1,249 +1,285 @@ // @flow import Filter from 'bad-words'; import uuid from 'uuid'; import { inviteLinkBlobHash, inviteSecretRegex, } from 'lib/shared/invite-links.js'; import { isStaff } from 'lib/shared/staff-utils.js'; import type { CreateOrUpdatePublicLinkRequest, InviteLink, } from 'lib/types/link-types.js'; import { threadPermissions } from 'lib/types/thread-permission-types.js'; import { ServerError } from 'lib/utils/errors.js'; import { reservedUsernamesSet } from 'lib/utils/reserved-users.js'; import createIDs from './id-creator.js'; import { dbQuery, MYSQL_DUPLICATE_ENTRY_FOR_KEY_ERROR_CODE, SQL, } from '../database/database.js'; import { fetchPrimaryInviteLinks } from '../fetchers/link-fetchers.js'; import { fetchServerThreadInfos } from '../fetchers/thread-fetchers.js'; import { checkThreadPermission } from '../fetchers/thread-permission-fetchers.js'; import { download, type BlobDownloadResult, assignHolder, uploadBlob, deleteBlob, type BlobOperationResult, } from '../services/blob.js'; import { Viewer } from '../session/viewer.js'; import { thisKeyserverID } from '../user/identity.js'; import { getAndAssertKeyserverURLFacts } from '../utils/urls.js'; const badWordsFilter = new Filter(); async function createOrUpdatePublicLink( viewer: Viewer, request: CreateOrUpdatePublicLinkRequest, ): Promise { if (!inviteSecretRegex.test(request.name)) { throw new ServerError('invalid_characters'); } if (badWordsFilter.isProfane(request.name)) { throw new ServerError('offensive_words'); } if (!isStaff(viewer.id) && reservedUsernamesSet.has(request.name)) { throw new ServerError('link_reserved'); } const permissionPromise = checkThreadPermission( viewer, request.communityID, threadPermissions.MANAGE_INVITE_LINKS, ); const existingPrimaryLinksPromise = fetchPrimaryInviteLinks(viewer); + const threadIDs = new Set([request.communityID]); + if (request.threadID) { + threadIDs.add(request.threadID); + } const fetchThreadInfoPromise = fetchServerThreadInfos({ - threadID: request.communityID, + threadIDs, }); const blobDownloadPromise = getInviteLinkBlob(request.name); + const canManageThreadLinksPromise = request.threadID + ? checkThreadPermission( + viewer, + request.threadID, + threadPermissions.MANAGE_INVITE_LINKS, + ) + : false; const [ hasPermission, existingPrimaryLinks, { threadInfos }, blobDownloadResult, + canManageThreadLinks, ] = await Promise.all([ permissionPromise, existingPrimaryLinksPromise, fetchThreadInfoPromise, blobDownloadPromise, + canManageThreadLinksPromise, ]); - if (!hasPermission) { + if (!hasPermission || (request.threadID && !canManageThreadLinks)) { throw new ServerError('invalid_credentials'); } if (blobDownloadResult.found) { throw new ServerError('already_in_use'); } - const threadInfo = threadInfos[request.communityID]; - if (!threadInfo) { - throw new ServerError('invalid_parameters'); - } - const defaultRoleID = Object.keys(threadInfo.roles).find( - roleID => threadInfo.roles[roleID].isDefault, - ); - if (!defaultRoleID) { - throw new ServerError('invalid_parameters'); + + const defaultRoleIDs: { [string]: string } = {}; + for (const threadID of threadIDs) { + const threadInfo = threadInfos[threadID]; + if (!threadInfo) { + throw new ServerError('invalid_parameters'); + } + const defaultRoleID = Object.keys(threadInfo.roles).find( + roleID => threadInfo.roles[roleID].isDefault, + ); + if (!defaultRoleID) { + throw new ServerError('invalid_parameters'); + } + defaultRoleIDs[threadID] = defaultRoleID; } const existingPrimaryLink = existingPrimaryLinks.find( - link => link.communityID === request.communityID && link.primary, + link => + link.communityID === request.communityID && + link.primary && + (request.threadID ? link.threadID === request.threadID : !link.threadID), ); const blobHolder = uuid.v4(); const blobResult = await uploadInviteLinkBlob(request.name, blobHolder); if (!blobResult.success) { if (blobResult.reason === 'HASH_IN_USE') { throw new ServerError('already_in_use'); } else { throw new ServerError('unknown_error'); } } if (existingPrimaryLink) { const query = SQL` UPDATE invite_links SET name = ${request.name}, blob_holder = ${blobHolder} - WHERE \`primary\` = 1 AND community = ${request.communityID} + WHERE \`primary\` = 1 AND + community = ${request.communityID} `; + if (request.threadID) { + query.append(SQL`AND thread = ${request.threadID}`); + } else { + query.append(SQL`AND thread IS NULL`); + } try { await dbQuery(query); const holder = existingPrimaryLink.blobHolder; if (holder) { await deleteBlob( { hash: inviteLinkBlobHash(existingPrimaryLink.name), holder, }, true, ); } } catch (e) { await deleteBlob( { hash: inviteLinkBlobHash(request.name), holder: blobHolder, }, true, ); if (e.errno === MYSQL_DUPLICATE_ENTRY_FOR_KEY_ERROR_CODE) { throw new ServerError('already_in_use'); } throw new ServerError('invalid_parameters'); } return { name: request.name, primary: true, - role: defaultRoleID, + role: defaultRoleIDs[request.communityID], communityID: request.communityID, expirationTime: null, limitOfUses: null, numberOfUses: 0, }; } const [id] = await createIDs('invite_links', 1); const row = [ id, request.name, true, request.communityID, - defaultRoleID, + defaultRoleIDs[request.communityID], blobHolder, + request.threadID ?? null, + request.threadID ? defaultRoleIDs[request.threadID] : null, ]; const createLinkQuery = SQL` - INSERT INTO invite_links(id, name, \`primary\`, community, role, blob_holder) + INSERT INTO invite_links(id, name, \`primary\`, community, role, + blob_holder, thread, thread_role) SELECT ${row} WHERE NOT EXISTS ( SELECT i.id FROM invite_links i WHERE i.\`primary\` = 1 AND i.community = ${request.communityID} - ) `; + if (request.threadID) { + createLinkQuery.append(SQL`AND thread = ${request.threadID}`); + } else { + createLinkQuery.append(SQL`AND thread IS NULL`); + } + createLinkQuery.append(SQL`)`); + let result = null; const deleteIDs = SQL` DELETE FROM ids WHERE id = ${id} `; try { result = (await dbQuery(createLinkQuery))[0]; } catch (e) { await Promise.all([ dbQuery(deleteIDs), deleteBlob( { hash: inviteLinkBlobHash(request.name), holder: blobHolder, }, true, ), ]); if (e.errno === MYSQL_DUPLICATE_ENTRY_FOR_KEY_ERROR_CODE) { throw new ServerError('already_in_use'); } throw new ServerError('invalid_parameters'); } if (result.affectedRows === 0) { await Promise.all([ dbQuery(deleteIDs), deleteBlob( { hash: inviteLinkBlobHash(request.name), holder: blobHolder, }, true, ), ]); throw new ServerError('invalid_parameters'); } return { name: request.name, primary: true, - role: defaultRoleID, + role: defaultRoleIDs[request.communityID], communityID: request.communityID, expirationTime: null, limitOfUses: null, numberOfUses: 0, }; } function getInviteLinkBlob(secret: string): Promise { const hash = inviteLinkBlobHash(secret); return download(hash); } async function uploadInviteLinkBlob( linkSecret: string, holder: string, ): Promise { const keyserverID = await thisKeyserverID(); const { baseDomain, basePath } = getAndAssertKeyserverURLFacts(); const keyserverURL = baseDomain + basePath; const payload = { keyserverID, keyserverURL, }; const payloadString = JSON.stringify(payload); const key = inviteLinkBlobHash(linkSecret); const blob = new Blob([payloadString]); const uploadResult = await uploadBlob(blob, key); if (!uploadResult.success) { return uploadResult; } return await assignHolder({ holder, hash: key }); } export { createOrUpdatePublicLink, uploadInviteLinkBlob, getInviteLinkBlob }; diff --git a/keyserver/src/fetchers/link-fetchers.js b/keyserver/src/fetchers/link-fetchers.js index 3816d0e70..5c478e60f 100644 --- a/keyserver/src/fetchers/link-fetchers.js +++ b/keyserver/src/fetchers/link-fetchers.js @@ -1,114 +1,125 @@ // @flow import type { InviteLinkWithHolder, InviteLinkVerificationRequest, InviteLinkVerificationResponse, } from 'lib/types/link-types.js'; import { dbQuery, SQL } from '../database/database.js'; import type { SQLStatementType } from '../database/types.js'; import { Viewer } from '../session/viewer.js'; async function verifyInviteLink( viewer: Viewer, request: InviteLinkVerificationRequest, ): Promise { const query = SQL` SELECT c.name, i.community AS communityID, m.role FROM invite_links i INNER JOIN threads c ON c.id = i.community LEFT JOIN memberships m ON m.thread = i.community AND m.user = ${viewer.loggedIn ? viewer.userID : null} WHERE i.name = ${request.secret} AND c.community IS NULL `; const [result] = await dbQuery(query); if (result.length === 0) { return { status: 'invalid', }; } const { name, communityID, role } = result[0]; const status = role > 0 ? 'already_joined' : 'valid'; return { status, community: { name, id: communityID.toString(), }, }; } async function checkIfInviteLinkIsValid( secret: string, communityID: string, ): Promise { const query = SQL` SELECT i.id FROM invite_links i INNER JOIN threads c ON c.id = i.community WHERE i.name = ${secret} AND i.community = ${communityID} AND c.community IS NULL `; const [result] = await dbQuery(query); return result.length === 1; } async function fetchInviteLinksWithCondition( condition: SQLStatementType, ): Promise<$ReadOnlyArray> { const query = SQL` SELECT i.name, i.role, i.community, i.expiration_time AS expirationTime, i.limit_of_uses AS limitOfUses, i.number_of_uses AS numberOfUses, - i.\`primary\`, blob_holder AS blobHolder + i.\`primary\`, i.blob_holder AS blobHolder, i.thread, + i.thread_role AS threadRole FROM invite_links i `; query.append(condition); const [result] = await dbQuery(query); - return result.map(row => ({ - name: row.name, - primary: row.primary === 1, - role: row.role.toString(), - communityID: row.community.toString(), - expirationTime: row.expirationTime, - limitOfUses: row.limitOfUses, - numberOfUses: row.numberOfUses, - blobHolder: row.blobHolder, - })); + return result.map(row => { + const link = { + name: row.name, + primary: row.primary === 1, + role: row.role.toString(), + communityID: row.community.toString(), + expirationTime: row.expirationTime, + limitOfUses: row.limitOfUses, + numberOfUses: row.numberOfUses, + blobHolder: row.blobHolder, + }; + if (row.thread && row.threadRole) { + return { + ...link, + threadID: row.thread.toString(), + threadRole: row.threadRole.toString(), + }; + } + return link; + }); } function fetchPrimaryInviteLinks( viewer: Viewer, ): Promise<$ReadOnlyArray> { if (!viewer.loggedIn) { return Promise.resolve([]); } const condition = SQL` INNER JOIN memberships m ON i.community = m.thread AND m.user = ${viewer.userID} WHERE i.\`primary\` = 1 AND m.role > 0 `; return fetchInviteLinksWithCondition(condition); } function fetchAllPrimaryInviteLinks(): Promise< $ReadOnlyArray, > { const condition = SQL` WHERE i.\`primary\` = 1 `; return fetchInviteLinksWithCondition(condition); } export { verifyInviteLink, checkIfInviteLinkIsValid, fetchPrimaryInviteLinks, fetchAllPrimaryInviteLinks, }; diff --git a/keyserver/src/responders/link-responders.js b/keyserver/src/responders/link-responders.js index 37c51c6a8..025149446 100644 --- a/keyserver/src/responders/link-responders.js +++ b/keyserver/src/responders/link-responders.js @@ -1,78 +1,79 @@ // @flow import t, { type TInterface } from 'tcomb'; import { type InviteLinkVerificationRequest, type InviteLinkVerificationResponse, type FetchInviteLinksResponse, type InviteLink, type CreateOrUpdatePublicLinkRequest, type DisableInviteLinkRequest, type InviteLinkWithHolder, } from 'lib/types/link-types.js'; import { tShape, tID } from 'lib/utils/validation-utils.js'; import { createOrUpdatePublicLink } from '../creators/invite-link-creator.js'; import { deleteInviteLink } from '../deleters/link-deleters.js'; import { fetchPrimaryInviteLinks, verifyInviteLink, } from '../fetchers/link-fetchers.js'; import { Viewer } from '../session/viewer.js'; export const inviteLinkVerificationRequestInputValidator: TInterface = tShape({ secret: t.String, }); async function inviteLinkVerificationResponder( viewer: Viewer, request: InviteLinkVerificationRequest, ): Promise { return await verifyInviteLink(viewer, request); } async function fetchPrimaryInviteLinksResponder( viewer: Viewer, ): Promise { const primaryLinks = await fetchPrimaryInviteLinks(viewer); return { links: primaryLinks.map( ({ blobHolder, ...rest }: InviteLinkWithHolder) => rest, ), }; } export const createOrUpdatePublicLinkInputValidator: TInterface = tShape({ name: t.String, communityID: tID, + threadID: t.maybe(tID), }); async function createOrUpdatePublicLinkResponder( viewer: Viewer, request: CreateOrUpdatePublicLinkRequest, ): Promise { return await createOrUpdatePublicLink(viewer, request); } export const disableInviteLinkInputValidator: TInterface = tShape({ name: t.String, communityID: tID, }); async function disableInviteLinkResponder( viewer: Viewer, request: DisableInviteLinkRequest, ): Promise { await deleteInviteLink(viewer, request); } export { inviteLinkVerificationResponder, fetchPrimaryInviteLinksResponder, createOrUpdatePublicLinkResponder, disableInviteLinkResponder, }; diff --git a/lib/types/link-types.js b/lib/types/link-types.js index 00b2ab170..34912afd1 100644 --- a/lib/types/link-types.js +++ b/lib/types/link-types.js @@ -1,86 +1,91 @@ // @flow import t, { type TInterface } from 'tcomb'; import { tID, tShape } from '../utils/validation-utils.js'; export type InviteLinkVerificationRequest = { +secret: string, }; export type InviteLinkVerificationResponse = | { +status: 'valid' | 'already_joined', +community: { +name: string, +id: string, }, } | { +status: 'invalid' | 'expired', }; export type InviteLink = { +name: string, +primary: boolean, +role: string, +communityID: string, +expirationTime: ?number, +limitOfUses: ?number, +numberOfUses: number, + +threadID?: string, + +threadRole?: string, }; export type InviteLinkWithHolder = $ReadOnly<{ ...InviteLink, +blobHolder: ?string, }>; export const inviteLinkValidator: TInterface = tShape({ name: t.String, primary: t.Boolean, role: tID, communityID: tID, expirationTime: t.maybe(t.Number), limitOfUses: t.maybe(t.Number), numberOfUses: t.Number, + threadID: t.maybe(tID), + threadRole: t.maybe(tID), }); export type FetchInviteLinksResponse = { +links: $ReadOnlyArray, }; export type CommunityLinks = { +primaryLink: ?InviteLink, }; export type InviteLinks = { +[communityID: string]: CommunityLinks, }; export type InviteLinksStore = { +links: InviteLinks, }; export const inviteLinksStoreValidator: TInterface = tShape({ links: t.dict( tID, tShape({ primaryLink: t.maybe(inviteLinkValidator), }), ), }); export type CreateOrUpdatePublicLinkRequest = { +name: string, +communityID: string, + +threadID?: string, }; export type DisableInviteLinkRequest = { +name: string, +communityID: string, }; export type DisableInviteLinkPayload = { +name: string, +communityID: string, };