diff --git a/keyserver/src/creators/day-creator.js b/keyserver/src/creators/day-creator.js index 058a2ad8f..6be583725 100644 --- a/keyserver/src/creators/day-creator.js +++ b/keyserver/src/creators/day-creator.js @@ -1,57 +1,59 @@ // @flow import { ServerError } from 'lib/utils/errors.js'; import createIDs from './id-creator.js'; -import { dbQuery, SQL } from '../database/database.js'; - -const MYSQL_DUPLICATE_ENTRY_FOR_KEY_ERROR_CODE = 1062; +import { + dbQuery, + MYSQL_DUPLICATE_ENTRY_FOR_KEY_ERROR_CODE, + SQL, +} from '../database/database.js'; async function fetchOrCreateDayID( threadID: string, date: string, ): Promise { if (!threadID || !date) { throw new ServerError('invalid_parameters'); } const existingQuery = SQL` SELECT id FROM days WHERE date = ${date} AND thread = ${threadID} `; const [existingResult] = await dbQuery(existingQuery); if (existingResult.length > 0) { const existingRow = existingResult[0]; return existingRow.id.toString(); } const [id] = await createIDs('days', 1); const insertQuery = SQL` INSERT INTO days(id, date, thread) VALUES ${[[id, date, threadID]]} `; try { await dbQuery(insertQuery); return id; } catch (e) { if (e.errno !== MYSQL_DUPLICATE_ENTRY_FOR_KEY_ERROR_CODE) { throw new ServerError('unknown_error'); } // There's a race condition that can happen if two people start editing // the same date at the same time, and two IDs are created for the same // row. If this happens, the UNIQUE constraint `date_thread` should be // triggered on the second racer, and for that execution path our last // query will have failed. We will recover by re-querying for the ID here, // and deleting the extra ID we created from the `ids` table. const deleteIDQuery = SQL`DELETE FROM ids WHERE id = ${id}`; const [[raceResult]] = await Promise.all([ dbQuery(existingQuery), dbQuery(deleteIDQuery), ]); if (raceResult.length === 0) { throw new ServerError('unknown_error'); } const raceRow = raceResult[0]; return raceRow.id.toString(); } } export default fetchOrCreateDayID; diff --git a/keyserver/src/creators/invite-link-creator.js b/keyserver/src/creators/invite-link-creator.js index 3470a11c4..fda755dbd 100644 --- a/keyserver/src/creators/invite-link-creator.js +++ b/keyserver/src/creators/invite-link-creator.js @@ -1,121 +1,132 @@ // @flow 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 createIDs from './id-creator.js'; -import { dbQuery, SQL } from '../database/database.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 { Viewer } from '../session/viewer.js'; const secretRegex = /^[a-zA-Z0-9]+$/; async function createOrUpdatePublicLink( viewer: Viewer, request: CreateOrUpdatePublicLinkRequest, ): Promise { if (!secretRegex.test(request.name)) { - throw new ServerError('invalid_parameters'); + throw new ServerError('invalid_characters'); } const permissionPromise = checkThreadPermission( viewer, request.communityID, threadPermissions.MANAGE_INVITE_LINKS, ); const existingPrimaryLinksPromise = fetchPrimaryInviteLinks(viewer); const fetchThreadInfoPromise = fetchServerThreadInfos({ threadID: request.communityID, }); const [hasPermission, existingPrimaryLinks, { threadInfos }] = await Promise.all([ permissionPromise, existingPrimaryLinksPromise, fetchThreadInfoPromise, ]); if (!hasPermission) { throw new ServerError('invalid_credentials'); } 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, ); if (existingPrimaryLink) { const query = SQL` UPDATE invite_links SET name = ${request.name} WHERE \`primary\` = 1 AND community = ${request.communityID} `; try { await dbQuery(query); - } catch { + } catch (e) { + 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]; const createLinkQuery = SQL` INSERT INTO invite_links(id, name, \`primary\`, community, role) 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 { + } catch (e) { + await dbQuery(deleteIDs); + 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) { - const deleteIDs = SQL` - DELETE FROM ids - WHERE id = ${id} - `; await dbQuery(deleteIDs); throw new ServerError('invalid_parameters'); } return { name: request.name, primary: true, role: defaultRoleID, communityID: request.communityID, expirationTime: null, limitOfUses: null, numberOfUses: 0, }; } export { createOrUpdatePublicLink }; diff --git a/keyserver/src/database/database.js b/keyserver/src/database/database.js index 72f66714f..8928c103d 100644 --- a/keyserver/src/database/database.js +++ b/keyserver/src/database/database.js @@ -1,206 +1,209 @@ // @flow import type { ConnectionOptions, QueryResults, PoolOptions } from 'mysql'; import mysql from 'mysql2'; import mysqlPromise from 'mysql2/promise.js'; import SQL from 'sql-template-strings'; import { connectionLimit, queryWarnTime } from './consts.js'; import { getDBConfig } from './db-config.js'; import DatabaseMonitor from './monitor.js'; import type { Pool, SQLOrString, SQLStatementType } from './types.js'; import { getScriptContext } from '../scripts/script-context.js'; const SQLStatement: SQLStatementType = SQL.SQLStatement; +const MYSQL_DUPLICATE_ENTRY_FOR_KEY_ERROR_CODE = 1062; + let migrationConnection; async function getMigrationConnection() { if (migrationConnection) { return migrationConnection; } const { dbType, ...dbConfig } = await getDBConfig(); const options: ConnectionOptions = dbConfig; migrationConnection = await mysqlPromise.createConnection(options); return migrationConnection; } let pool, databaseMonitor; async function loadPool(): Promise { if (pool) { return pool; } const scriptContext = getScriptContext(); const { dbType, ...dbConfig } = await getDBConfig(); const options: PoolOptions = { ...dbConfig, connectionLimit, multipleStatements: !!( scriptContext && scriptContext.allowMultiStatementSQLQueries ), }; // This function can be run asynchronously multiple times, // the previous check is not enough because the function will await // on `getDBConfig()` and as result we might get there // while the pool is already defined, which will result with // creating a new pool and losing the previous one which will stay open if (pool) { return pool; } pool = mysqlPromise.createPool(options); databaseMonitor = new DatabaseMonitor(pool); return pool; } function endPool() { pool?.end(); } function appendSQLArray( sql: SQLStatementType, sqlArray: $ReadOnlyArray, delimeter: SQLOrString, ): SQLStatementType { if (sqlArray.length === 0) { return sql; } const [first, ...rest] = sqlArray; sql.append(first); if (rest.length === 0) { return sql; } return rest.reduce( (prev: SQLStatementType, curr: SQLStatementType) => prev.append(delimeter).append(curr), sql, ); } function mergeConditions( conditions: $ReadOnlyArray, delimiter: SQLStatementType, ): SQLStatementType { const sql = SQL` (`; appendSQLArray(sql, conditions, delimiter); sql.append(SQL`) `); return sql; } function mergeAndConditions( andConditions: $ReadOnlyArray, ): SQLStatementType { return mergeConditions(andConditions, SQL` AND `); } function mergeOrConditions( andConditions: $ReadOnlyArray, ): SQLStatementType { return mergeConditions(andConditions, SQL` OR `); } // We use this fake result for dry runs const fakeResult: QueryResults = (() => { const result: any = []; result.insertId = -1; return result; })(); const MYSQL_DEADLOCK_ERROR_CODE = 1213; type ConnectionContext = { +migrationsActive?: boolean, }; let connectionContext = { migrationsActive: false, }; function setConnectionContext(newContext: ConnectionContext) { connectionContext = { ...connectionContext, ...newContext, }; if (!connectionContext.migrationsActive && migrationConnection) { migrationConnection.end(); migrationConnection = undefined; } } type QueryOptions = { +triesLeft?: number, +multipleStatements?: boolean, }; async function dbQuery( statement: SQLStatementType, options?: QueryOptions, ): Promise { const triesLeft = options?.triesLeft ?? 2; const multipleStatements = options?.multipleStatements ?? false; let connection; if (connectionContext.migrationsActive) { connection = await getMigrationConnection(); } if (multipleStatements) { connection = await getMultipleStatementsConnection(); } if (!connection) { connection = await loadPool(); } const timeoutID = setTimeout( () => databaseMonitor.reportLaggingQuery(statement.sql), queryWarnTime, ); const scriptContext = getScriptContext(); try { const sql = statement.sql.trim(); if ( scriptContext && scriptContext.dryRun && (sql.startsWith('INSERT') || sql.startsWith('DELETE') || sql.startsWith('UPDATE')) ) { console.log(rawSQL(statement)); return ([fakeResult]: any); } return await connection.query(statement); } catch (e) { if (e.errno === MYSQL_DEADLOCK_ERROR_CODE && triesLeft > 0) { console.log('deadlock occurred, trying again', e); return await dbQuery(statement, { ...options, triesLeft: triesLeft - 1 }); } e.query = statement.sql; throw e; } finally { clearTimeout(timeoutID); if (multipleStatements) { connection.end(); } } } function rawSQL(statement: SQLStatementType): string { return mysql.format(statement.sql, statement.values); } async function getMultipleStatementsConnection() { const { dbType, ...dbConfig } = await getDBConfig(); const options: ConnectionOptions = { ...dbConfig, multipleStatements: true, }; return await mysqlPromise.createConnection(options); } export { endPool, SQL, SQLStatement, appendSQLArray, mergeAndConditions, mergeOrConditions, setConnectionContext, dbQuery, rawSQL, + MYSQL_DUPLICATE_ENTRY_FOR_KEY_ERROR_CODE, }; diff --git a/lib/shared/invite-links.js b/lib/shared/invite-links.js new file mode 100644 index 000000000..c9276b328 --- /dev/null +++ b/lib/shared/invite-links.js @@ -0,0 +1,11 @@ +// @flow + +const inviteLinkErrorMessages = { + 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.', +}; + +const defaultErrorMessage = 'Unknown error.'; + +export { inviteLinkErrorMessages, defaultErrorMessage }; diff --git a/native/invite-links/manage-public-link-screen.react.js b/native/invite-links/manage-public-link-screen.react.js index 9a87ecfb8..8b53ca5f7 100644 --- a/native/invite-links/manage-public-link-screen.react.js +++ b/native/invite-links/manage-public-link-screen.react.js @@ -1,214 +1,222 @@ // @flow import * as React from 'react'; import { Text, View, Alert } from 'react-native'; import { inviteLinkUrl } from 'lib/facts/links.js'; import { useInviteLinksActions } from 'lib/hooks/invite-links.js'; import { primaryInviteLinksSelector } from 'lib/selectors/invite-links-selectors.js'; +import { + defaultErrorMessage, + inviteLinkErrorMessages, +} from 'lib/shared/invite-links.js'; import type { ThreadInfo } from 'lib/types/thread-types.js'; import Button from '../components/button.react.js'; import TextInput from '../components/text-input.react.js'; import type { RootNavigationProp } from '../navigation/root-navigator.react.js'; import type { NavigationRoute } from '../navigation/route-names.js'; import { useSelector } from '../redux/redux-utils.js'; import { useStyles } from '../themes/colors.js'; export type ManagePublicLinkScreenParams = { +community: ThreadInfo, }; type Props = { +navigation: RootNavigationProp<'ManagePublicLink'>, +route: NavigationRoute<'ManagePublicLink'>, }; function ManagePublicLinkScreen(props: Props): React.Node { const { community } = props.route.params; const inviteLink = useSelector(primaryInviteLinksSelector)[community.id]; const { error, isLoading, name, setName, createOrUpdateInviteLink, disableInviteLink, } = useInviteLinksActions(community.id, inviteLink); const styles = useStyles(unboundStyles); let errorComponent = null; if (error) { - errorComponent = {error}; + errorComponent = ( + + {inviteLinkErrorMessages[error] ?? defaultErrorMessage} + + ); } const onDisableButtonClick = React.useCallback(() => { Alert.alert( 'Disable public link', 'Are you sure you want to disable your public link?\n' + '\n' + 'Other communities will be able to claim the same URL.', [ { text: 'Confirm disable', style: 'destructive', onPress: disableInviteLink, }, { text: 'Cancel', }, ], { cancelable: true, }, ); }, [disableInviteLink]); let disablePublicLinkSection = null; if (inviteLink) { disablePublicLinkSection = ( You may also disable the community public link. ); } return ( Invite links make it easy for your friends to join your community. Anybody who knows your community’s invite link will be able to join it. Note that if you change your public link’s URL, other communities will be able to claim the old URL. INVITE URL {inviteLinkUrl('')} {errorComponent} {disablePublicLinkSection} ); } const unboundStyles = { sectionTitle: { fontSize: 14, fontWeight: '400', lineHeight: 20, color: 'modalBackgroundLabel', paddingHorizontal: 16, paddingBottom: 4, }, section: { borderBottomColor: 'modalSeparator', borderBottomWidth: 1, borderTopColor: 'modalSeparator', borderTopWidth: 1, backgroundColor: 'modalForeground', padding: 16, marginBottom: 24, }, disableLinkSection: { marginTop: 16, }, sectionText: { fontSize: 14, fontWeight: '400', lineHeight: 22, color: 'modalBackgroundLabel', }, withMargin: { marginBottom: 12, }, inviteLink: { flexDirection: 'row', alignItems: 'center', marginBottom: 8, }, inviteLinkPrefix: { fontSize: 14, fontWeight: '400', lineHeight: 22, color: 'disabledButtonText', marginRight: 2, }, input: { color: 'panelForegroundLabel', borderColor: 'panelSecondaryForegroundBorder', borderWidth: 1, borderRadius: 8, paddingVertical: 13, paddingHorizontal: 16, flex: 1, }, button: { borderRadius: 8, paddingVertical: 12, paddingHorizontal: 24, marginTop: 8, }, buttonPrimary: { backgroundColor: 'purpleButton', }, destructiveButton: { borderWidth: 1, borderRadius: 8, borderColor: 'vibrantRedButton', }, destructiveButtonText: { fontSize: 16, fontWeight: '500', lineHeight: 24, color: 'vibrantRedButton', textAlign: 'center', }, buttonText: { color: 'whiteText', textAlign: 'center', fontWeight: '500', fontSize: 16, lineHeight: 24, }, error: { fontSize: 12, fontWeight: '400', lineHeight: 18, textAlign: 'center', color: 'redText', }, }; export default ManagePublicLinkScreen; diff --git a/web/invite-links/manage/edit-link-modal.react.js b/web/invite-links/manage/edit-link-modal.react.js index cdd94e76f..ec011a165 100644 --- a/web/invite-links/manage/edit-link-modal.react.js +++ b/web/invite-links/manage/edit-link-modal.react.js @@ -1,110 +1,118 @@ // @flow import classnames from 'classnames'; import * as React from 'react'; import { useModalContext } from 'lib/components/modal-provider.react.js'; import { inviteLinkUrl } from 'lib/facts/links.js'; import { useInviteLinksActions } from 'lib/hooks/invite-links.js'; +import { + defaultErrorMessage, + inviteLinkErrorMessages, +} from 'lib/shared/invite-links.js'; import type { InviteLink } from 'lib/types/link-types.js'; import type { ThreadInfo } from 'lib/types/thread-types.js'; import css from './manage-invite-links-modal.css'; import Button from '../../components/button.react.js'; import Input from '../../modals/input.react.js'; import Modal from '../../modals/modal.react.js'; type Props = { +inviteLink: ?InviteLink, +enterViewMode: () => mixed, +enterDisableMode: () => mixed, +community: ThreadInfo, }; const disableButtonColor = { color: 'var(--error-primary)', borderColor: 'var(--error-primary)', }; function EditLinkModal(props: Props): React.Node { const { inviteLink, enterViewMode, enterDisableMode, community } = props; const { popModal } = useModalContext(); const { error, isLoading, name, setName, createOrUpdateInviteLink } = useInviteLinksActions(community.id, inviteLink); const onChangeName = React.useCallback( (event: SyntheticEvent) => { setName(event.currentTarget.value); }, [setName], ); let errorComponent = null; if (error) { - errorComponent =
{error}
; + errorComponent = ( +
+ {inviteLinkErrorMessages[error] ?? defaultErrorMessage} +
+ ); } let disableLinkComponent = null; if (inviteLink) { disableLinkComponent = ( <>
You may also disable the community public link
); } return (

Invite links make it easy for your friends to join your community. Anybody who knows your community’s invite link will be able to join it.

Note that if you change your public link’s URL, other communities will be able to claim the old URL.


Invite URL
{inviteLinkUrl('')}
{errorComponent}
{disableLinkComponent}
); } export default EditLinkModal;