diff --git a/keyserver/src/creators/invite-link-creator.js b/keyserver/src/creators/invite-link-creator.js index 8ad27d62a..f549a74f5 100644 --- a/keyserver/src/creators/invite-link-creator.js +++ b/keyserver/src/creators/invite-link-creator.js @@ -1,246 +1,248 @@ // @flow import Filter from 'bad-words'; import uuid from 'uuid'; -import { inviteLinkBlobHash } from 'lib/shared/invite-links.js'; +import { + inviteLinkBlobHash, + inviteSecretRegex, +} from 'lib/shared/invite-links.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 secretRegex = /^[a-zA-Z0-9]+$/; const badWordsFilter = new Filter(); async function createOrUpdatePublicLink( viewer: Viewer, request: CreateOrUpdatePublicLinkRequest, ): Promise { - if (!secretRegex.test(request.name)) { + if (!inviteSecretRegex.test(request.name)) { throw new ServerError('invalid_characters'); } if (badWordsFilter.isProfane(request.name)) { throw new ServerError('offensive_words'); } if (reservedUsernamesSet.has(request.name)) { throw new ServerError('link_reserved'); } const permissionPromise = checkThreadPermission( viewer, request.communityID, threadPermissions.MANAGE_INVITE_LINKS, ); const existingPrimaryLinksPromise = fetchPrimaryInviteLinks(viewer); const fetchThreadInfoPromise = fetchServerThreadInfos({ threadID: request.communityID, }); const blobDownloadPromise = getInviteLinkBlob(request.name); const [ hasPermission, existingPrimaryLinks, { threadInfos }, blobDownloadResult, ] = await Promise.all([ permissionPromise, existingPrimaryLinksPromise, fetchThreadInfoPromise, blobDownloadPromise, ]); if (!hasPermission) { 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 existingPrimaryLink = existingPrimaryLinks.find( link => link.communityID === request.communityID && link.primary, ); 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} `; 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, 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, blobHolder, ]; const createLinkQuery = SQL` INSERT INTO invite_links(id, name, \`primary\`, community, role, blob_holder) SELECT ${row} WHERE NOT EXISTS ( SELECT i.id FROM invite_links i WHERE i.\`primary\` = 1 AND i.community = ${request.communityID} ) `; 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, 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/responders/website-responders.js b/keyserver/src/responders/website-responders.js index 256f468f2..cdb57b560 100644 --- a/keyserver/src/responders/website-responders.js +++ b/keyserver/src/responders/website-responders.js @@ -1,408 +1,407 @@ // @flow import html from 'common-tags/lib/html/index.js'; import { detect as detectBrowser } from 'detect-browser'; import type { $Response, $Request } from 'express'; import fs from 'fs'; import * as React from 'react'; // eslint-disable-next-line import/extensions import ReactDOMServer from 'react-dom/server'; import { promisify } from 'util'; import stores from 'lib/facts/stores.js'; +import { inviteSecretRegex } from 'lib/shared/invite-links.js'; import getTitle from 'web/title/get-title.js'; import { waitForStream } from '../utils/json-stream.js'; import { getAndAssertKeyserverURLFacts, getAppURLFactsFromRequestURL, getWebAppURLFacts, } from '../utils/urls.js'; // eslint-disable-next-line react/no-deprecated const { renderToNodeStream } = ReactDOMServer; const access = promisify(fs.access); const readFile = promisify(fs.readFile); const googleFontsURL = 'https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600&family=Inter:wght@400;500;600&display=swap'; const localFontsURL = 'fonts/local-fonts.css'; async function getFontsURL() { try { await access(localFontsURL); return localFontsURL; } catch { return googleFontsURL; } } type AssetInfo = { +jsURL: string, +fontsURL: string, +cssInclude: string, +olmFilename: string, +commQueryExecutorFilename: string, +backupClientFilename: string, +webworkersOpaqueFilename: string, }; let assetInfo: ?AssetInfo = null; async function getAssetInfo() { if (assetInfo) { return assetInfo; } if (process.env.NODE_ENV === 'development') { const fontsURL = await getFontsURL(); assetInfo = { jsURL: 'http://localhost:8080/dev.build.js', fontsURL, cssInclude: '', olmFilename: '', commQueryExecutorFilename: '', backupClientFilename: '', webworkersOpaqueFilename: '', }; return assetInfo; } try { const manifestString = await readFile('../web/dist/manifest.json', 'utf8'); const manifest = JSON.parse(manifestString); const webworkersManifestString = await readFile( '../web/dist/webworkers/manifest.json', 'utf8', ); const webworkersManifest = JSON.parse(webworkersManifestString); assetInfo = { jsURL: `compiled/${manifest['browser.js']}`, fontsURL: googleFontsURL, cssInclude: html` `, olmFilename: manifest['olm.wasm'], commQueryExecutorFilename: webworkersManifest['comm_query_executor.wasm'], backupClientFilename: webworkersManifest['backup-client-wasm_bg.wasm'], webworkersOpaqueFilename: webworkersManifest['comm_opaque2_wasm_bg.wasm'], }; return assetInfo; } catch (e) { console.warn(e); throw new Error( 'Could not load manifest.json for web build. ' + 'Did you forget to run `yarn dev` in the web folder?', ); } } let webpackCompiledRootComponent: ?React.ComponentType<{}> = null; async function getWebpackCompiledRootComponentForSSR() { if (webpackCompiledRootComponent) { return webpackCompiledRootComponent; } try { // $FlowFixMe web/dist doesn't always exist const webpackBuild = await import('web/dist/app.build.cjs'); webpackCompiledRootComponent = webpackBuild.app.default; return webpackCompiledRootComponent; } catch (e) { console.warn(e); throw new Error( 'Could not load app.build.cjs. ' + 'Did you forget to run `yarn dev` in the web folder?', ); } } function stripLastSlash(input: string): string { return input.replace(/\/$/, ''); } async function websiteResponder(req: $Request, res: $Response): Promise { const { basePath } = getAppURLFactsFromRequestURL(req.originalUrl); const baseURL = stripLastSlash(basePath); const keyserverURLFacts = getAndAssertKeyserverURLFacts(); const keyserverURL = `${keyserverURLFacts.baseDomain}${stripLastSlash( keyserverURLFacts.basePath, )}`; const loadingPromise = getWebpackCompiledRootComponentForSSR(); const assetInfoPromise = getAssetInfo(); const { jsURL, fontsURL, cssInclude, olmFilename, commQueryExecutorFilename, backupClientFilename, webworkersOpaqueFilename, } = await assetInfoPromise; // prettier-ignore res.write(html` ${getTitle(0)} ${cssInclude}
`); const Loading = await loadingPromise; const reactStream = renderToNodeStream(); reactStream.pipe(res, { end: false }); await waitForStream(reactStream); res.end(html`
`); } -const inviteSecretRegex = /^[a-z0-9]+$/i; - // On native, if this responder is called, it means that the app isn't // installed. async function inviteResponder(req: $Request, res: $Response): Promise { const { secret } = req.params; const userAgent = req.get('User-Agent'); const detectionResult = detectBrowser(userAgent); if (detectionResult.os === 'Android OS') { const isSecretValid = inviteSecretRegex.test(secret); const referrer = isSecretValid ? `&referrer=${encodeURIComponent(`utm_source=invite/${secret}`)}` : ''; const redirectUrl = `${stores.googlePlayUrl}${referrer}`; res.writeHead(301, { Location: redirectUrl, }); res.end(); return; } else if (detectionResult.os !== 'iOS') { const urlFacts = getWebAppURLFacts(); const baseDomain = urlFacts?.baseDomain ?? ''; const basePath = urlFacts?.basePath ?? '/'; const redirectUrl = `${baseDomain}${basePath}handle/invite/${secret}`; res.writeHead(301, { Location: redirectUrl, }); res.end(); return; } const fontsURL = await getFontsURL(); res.end(html` Comm

Comm

To join this community, download the Comm app and reopen this invite link

Download Comm Invite Link
Visit Comm’s website arrow up right `); } export { websiteResponder, inviteResponder }; diff --git a/lib/shared/invite-links.js b/lib/shared/invite-links.js index 707ce5972..37b8b54f0 100644 --- a/lib/shared/invite-links.js +++ b/lib/shared/invite-links.js @@ -1,53 +1,61 @@ // @flow import blobService from '../facts/blob-service.js'; import { getBlobFetchableURL } from '../utils/blob-service.js'; const inviteLinkErrorMessages: { +[string]: string } = { invalid_characters: 'Link cannot contain any spaces or special characters.', offensive_words: 'No offensive or abusive words allowed.', already_in_use: 'Public link URL already in use.', link_reserved: 'This public link is currently reserved. Please contact support@' + 'comm.app if you would like to claim this link.', }; const defaultErrorMessage = 'Unknown error.'; function inviteLinkBlobHash(secret: string): string { return `invite_${secret}`; } export type KeyserverOverride = { +keyserverID: string, +keyserverURL: string, }; async function getKeyserverOverrideForAnInviteLink( secret: string, ): Promise { const blobURL = getBlobFetchableURL(inviteLinkBlobHash(secret)); const result = await fetch(blobURL, { method: blobService.httpEndpoints.GET_BLOB.method, }); if (result.status !== 200) { return null; } const resultText = await result.text(); const resultObject = JSON.parse(resultText); if (resultObject.keyserverID && resultObject.keyserverURL) { const keyserverURL: string = resultObject.keyserverURL; return { keyserverID: resultObject.keyserverID, keyserverURL: keyserverURL.replace(/\/$/, ''), }; } return null; } +const inviteSecretRegexString = '[a-zA-Z0-9]+'; +const inviteSecretRegex: RegExp = new RegExp( + `^${inviteSecretRegexString}$`, + 'i', +); + export { inviteLinkErrorMessages, defaultErrorMessage, inviteLinkBlobHash, getKeyserverOverrideForAnInviteLink, + inviteSecretRegexString, + inviteSecretRegex, }; diff --git a/lib/utils/url-utils.js b/lib/utils/url-utils.js index 6bcab4c19..e293bdb5b 100644 --- a/lib/utils/url-utils.js +++ b/lib/utils/url-utils.js @@ -1,165 +1,166 @@ // @flow import t, { type TInterface } from 'tcomb'; import { idSchemaRegex, tID, tShape, pendingThreadIDRegex, tUserID, } from './validation-utils.js'; +import { inviteSecretRegexString } from '../shared/invite-links.js'; type MutableURLInfo = { year?: number, month?: number, // 1-indexed verify?: string, calendar?: boolean, chat?: boolean, thread?: string, settings?: | 'account' | 'friend-list' | 'block-list' | 'keyservers' | 'build-info' | 'danger-zone', threadCreation?: boolean, selectedUserList?: $ReadOnlyArray, inviteSecret?: string, qrCode?: boolean, ... }; export type URLInfo = $ReadOnly; export const urlInfoValidator: TInterface = tShape({ year: t.maybe(t.Number), month: t.maybe(t.Number), verify: t.maybe(t.String), calendar: t.maybe(t.Boolean), chat: t.maybe(t.Boolean), thread: t.maybe(tID), settings: t.maybe( t.enums.of([ 'account', 'friend-list', 'block-list', 'keyservers', 'build-info', 'danger-zone', ]), ), threadCreation: t.maybe(t.Boolean), selectedUserList: t.maybe(t.list(tUserID)), inviteSecret: t.maybe(t.String), qrCode: t.maybe(t.Boolean), }); // We use groups to capture parts of the URL and any changes // to regexes must be reflected in infoFromURL. const yearRegex = new RegExp('(/|^)year/([0-9]+)(/|$)', 'i'); const monthRegex = new RegExp('(/|^)month/([0-9]+)(/|$)', 'i'); const threadRegex = new RegExp(`(/|^)thread/(${idSchemaRegex})(/|$)`, 'i'); const verifyRegex = new RegExp('(/|^)verify/([a-f0-9]+)(/|$)', 'i'); const calendarRegex = new RegExp('(/|^)calendar(/|$)', 'i'); const chatRegex = new RegExp('(/|^)chat(/|$)', 'i'); const accountSettingsRegex = new RegExp('(/|^)settings/account(/|$)', 'i'); const friendListRegex = new RegExp('(/|^)settings/friend-list(/|$)', 'i'); const blockListRegex = new RegExp('(/|^)settings/block-list(/|$)', 'i'); const keyserversRegex = new RegExp('(/|^)settings/keyservers(/|$)', 'i'); const buildInfoRegex = new RegExp('(/|^)settings/build-info(/|$)', 'i'); const dangerZoneRegex = new RegExp('(/|^)settings/danger-zone(/|$)', 'i'); const threadPendingRegex = new RegExp( `(/|^)thread/(${pendingThreadIDRegex})(/|$)`, 'i', ); const threadCreationRegex = new RegExp( '(/|^)thread/new(/([0-9]+([+][0-9]+)*))?(/|$)', 'i', ); const inviteLinkRegex = new RegExp( - '(/|^)handle/invite/([a-zA-Z0-9]+)(/|$)', + `(/|^)handle/invite/(${inviteSecretRegexString})(/|$)`, 'i', ); const qrCodeLoginRegex = new RegExp('(/|^)qr-code(/|$)', 'i'); function infoFromURL(url: string): URLInfo { const yearMatches = yearRegex.exec(url); const monthMatches = monthRegex.exec(url); const threadMatches = threadRegex.exec(url); const verifyMatches = verifyRegex.exec(url); const calendarTest = calendarRegex.test(url); const chatTest = chatRegex.test(url); const accountSettingsTest = accountSettingsRegex.test(url); const friendListTest = friendListRegex.test(url); const blockListTest = blockListRegex.test(url); const keyserversSettingsTest = keyserversRegex.test(url); const buildInfoTest = buildInfoRegex.test(url); const dangerZoneTest = dangerZoneRegex.test(url); const threadPendingMatches = threadPendingRegex.exec(url); const threadCreateMatches = threadCreationRegex.exec(url); const inviteLinkMatches = inviteLinkRegex.exec(url); const qrCodeLoginMatches = qrCodeLoginRegex.exec(url); const returnObj: MutableURLInfo = {}; if (yearMatches) { returnObj.year = parseInt(yearMatches[2], 10); } if (monthMatches) { const month = parseInt(monthMatches[2], 10); if (month < 1 || month > 12) { throw new Error('invalid_month'); } returnObj.month = month; } if (threadMatches) { returnObj.thread = threadMatches[2]; } if (threadPendingMatches) { returnObj.thread = threadPendingMatches[2]; } if (threadCreateMatches) { returnObj.threadCreation = true; returnObj.selectedUserList = threadCreateMatches[3]?.split('+') ?? []; } if (verifyMatches) { returnObj.verify = verifyMatches[2]; } if (inviteLinkMatches) { returnObj.inviteSecret = inviteLinkMatches[2]; } if (calendarTest) { returnObj.calendar = true; } else if (chatTest) { returnObj.chat = true; } else if (accountSettingsTest) { returnObj.settings = 'account'; } else if (friendListTest) { returnObj.settings = 'friend-list'; } else if (blockListTest) { returnObj.settings = 'block-list'; } else if (keyserversSettingsTest) { returnObj.settings = 'keyservers'; } else if (buildInfoTest) { returnObj.settings = 'build-info'; } else if (dangerZoneTest) { returnObj.settings = 'danger-zone'; } else if (qrCodeLoginMatches) { returnObj.qrCode = true; } return returnObj; } const setURLPrefix = 'SET_URL_PREFIX'; export type URLPathParams = { +[name: string]: string }; function replacePathParams(path: string, params: URLPathParams = {}): string { for (const name in params) { path = path.replace(`:${name}`, params[name]); } return path; } export { infoFromURL, setURLPrefix, replacePathParams };