diff --git a/keyserver/src/updaters/account-updaters.js b/keyserver/src/updaters/account-updaters.js index 5d2578585..b67c78020 100644 --- a/keyserver/src/updaters/account-updaters.js +++ b/keyserver/src/updaters/account-updaters.js @@ -1,255 +1,260 @@ // @flow import invariant from 'invariant'; import bcrypt from 'twin-bcrypt'; import { hasMinCodeVersion } from 'lib/shared/version-utils.js'; import type { ResetPasswordRequest, UpdatePasswordRequest, UpdateUserSettingsRequest, ServerLogInResponse, } from 'lib/types/account-types.js'; import type { ClientAvatar, UpdateUserAvatarRequest, UpdateUserAvatarResponse, } from 'lib/types/avatar-types.js'; import { updateTypes } from 'lib/types/update-types-enum.js'; import type { CreateUpdatesResult, UpdateData, } from 'lib/types/update-types.js'; import type { PasswordUpdate, UserInfo, UserInfos, } from 'lib/types/user-types.js'; import { ServerError } from 'lib/utils/errors.js'; import { values } from 'lib/utils/objects.js'; import { createUpdates } from '../creators/update-creator.js'; import { dbQuery, SQL } from '../database/database.js'; import { getUploadURL, makeUploadURI } from '../fetchers/upload-fetchers.js'; import { fetchKnownUserInfos } from '../fetchers/user-fetchers.js'; import type { Viewer } from '../session/viewer.js'; async function passwordUpdater( viewer: Viewer, update: PasswordUpdate, ): Promise { if (!viewer.loggedIn) { throw new ServerError('not_logged_in'); } const newPassword = update.updatedFields.password; if (!newPassword) { // If it's an old client it may have given us an email, // but we don't store those anymore return; } const verifyQuery = SQL` SELECT username, hash FROM users WHERE id = ${viewer.userID} `; const [verifyResult] = await dbQuery(verifyQuery); if (verifyResult.length === 0) { throw new ServerError('internal_error'); } const verifyRow = verifyResult[0]; if (!bcrypt.compareSync(update.currentPassword, verifyRow.hash)) { throw new ServerError('invalid_credentials'); } const changedFields = { hash: bcrypt.hashSync(newPassword) }; const saveQuery = SQL` UPDATE users SET ${changedFields} WHERE id = ${viewer.userID} `; await dbQuery(saveQuery); } // eslint-disable-next-line no-unused-vars async function checkAndSendVerificationEmail(viewer: Viewer): Promise { // We don't want to crash old clients that call this, // but we have nothing we can do because we no longer store email addresses } async function checkAndSendPasswordResetEmail( // eslint-disable-next-line no-unused-vars request: ResetPasswordRequest, ): Promise { // We don't want to crash old clients that call this, // but we have nothing we can do because we no longer store email addresses } /* eslint-disable no-unused-vars */ async function updatePassword( viewer: Viewer, request: UpdatePasswordRequest, ): Promise { /* eslint-enable no-unused-vars */ // We have no way to handle this request anymore throw new ServerError('deprecated'); } async function updateUserSettings( viewer: Viewer, request: UpdateUserSettingsRequest, ) { if (!viewer.loggedIn) { throw new ServerError('not_logged_in'); } const createOrUpdateSettingsQuery = SQL` INSERT INTO settings (user, name, data) VALUES ${[[viewer.id, request.name, request.data]]} ON DUPLICATE KEY UPDATE data = VALUE(data) `; await dbQuery(createOrUpdateSettingsQuery); } async function updateUserAvatar( viewer: Viewer, request: UpdateUserAvatarRequest, ): Promise { if (!viewer.loggedIn) { throw new ServerError('not_logged_in'); } + // keyserver shouldn't support thick-thread-specific requests + if (request.type === 'non_keyserver_image') { + throw new ServerError('invalid_parameters'); + } + const newAvatarValue = request.type === 'remove' ? null : JSON.stringify(request); const mediaID = request.type === 'image' || request.type === 'encrypted_image' ? request.uploadID : null; const query = SQL` START TRANSACTION; UPDATE uploads SET user_container = NULL WHERE uploader = ${viewer.userID} AND user_container = ${viewer.userID} AND ( ${mediaID} IS NULL OR EXISTS ( SELECT 1 FROM uploads WHERE id = ${mediaID} AND uploader = ${viewer.userID} AND container IS NULL AND user_container IS NULL AND thread IS NULL ) ); UPDATE uploads SET user_container = ${viewer.userID} WHERE id = ${mediaID} AND uploader = ${viewer.userID} AND container IS NULL AND user_container IS NULL AND thread IS NULL; UPDATE users SET avatar = ${newAvatarValue} WHERE id = ${viewer.userID} AND ( ${mediaID} IS NULL OR EXISTS ( SELECT 1 FROM uploads WHERE id = ${mediaID} AND uploader = ${viewer.userID} AND user_container = ${viewer.userID} AND thread IS NULL ) ); COMMIT; SELECT id AS upload_id, secret AS upload_secret, extra AS upload_extra FROM uploads WHERE id = ${mediaID} AND uploader = ${viewer.userID} AND user_container = ${viewer.userID}; `; const [resultSet] = await dbQuery(query, { multipleStatements: true }); const selectResult = resultSet.pop(); const knownUserInfos: UserInfos = await fetchKnownUserInfos(viewer); const updates: CreateUpdatesResult = await createUserAvatarUpdates( viewer, knownUserInfos, ); if (hasMinCodeVersion(viewer.platformDetails, { native: 215 })) { const updateUserAvatarResponse: UpdateUserAvatarResponse = { updates, }; return updateUserAvatarResponse; } if (request.type === 'remove') { return null; } else if (request.type !== 'image' && request.type !== 'encrypted_image') { return request; } else { const [{ upload_id, upload_secret, upload_extra }] = selectResult; const uploadID = upload_id.toString(); invariant( uploadID === request.uploadID, 'uploadID of upload should match uploadID of UpdateUserAvatarRequest', ); if (request.type === 'encrypted_image') { const uploadExtra = JSON.parse(upload_extra); return { type: 'encrypted_image', blobURI: makeUploadURI(uploadExtra.blobHash, uploadID, upload_secret), encryptionKey: uploadExtra.encryptionKey, thumbHash: uploadExtra.thumbHash, }; } return { type: 'image', uri: getUploadURL(uploadID, upload_secret), }; } } async function createUserAvatarUpdates( viewer: Viewer, knownUserInfos: UserInfos, ): Promise { const time = Date.now(); const userUpdates: $ReadOnlyArray = values(knownUserInfos).map( (user: UserInfo): UpdateData => ({ type: updateTypes.UPDATE_USER, userID: user.id, time, updatedUserID: viewer.userID, }), ); const currentUserUpdate: UpdateData = { type: updateTypes.UPDATE_CURRENT_USER, userID: viewer.userID, time, }; return await createUpdates([...userUpdates, currentUserUpdate], { viewer, updatesForCurrentSession: 'return', }); } export { passwordUpdater, checkAndSendVerificationEmail, checkAndSendPasswordResetEmail, updateUserSettings, updatePassword, updateUserAvatar, }; diff --git a/lib/types/avatar-types.js b/lib/types/avatar-types.js index e669f63bc..a2e917b1d 100644 --- a/lib/types/avatar-types.js +++ b/lib/types/avatar-types.js @@ -1,105 +1,113 @@ // @flow import t, { type TUnion, type TInterface } from 'tcomb'; import type { CreateUpdatesResult } from './update-types.js'; import { validHexColorRegex } from '../shared/account-utils.js'; import { onlyOneEmojiRegex } from '../shared/emojis.js'; import { tRegex, tShape, tString } from '../utils/validation-utils.js'; export type EmojiAvatarDBContent = { +type: 'emoji', +emoji: string, +color: string, // hex, without "#" or "0x" }; export const emojiAvatarDBContentValidator: TInterface = tShape({ type: tString('emoji'), emoji: tRegex(onlyOneEmojiRegex), color: tRegex(validHexColorRegex), }); export type ImageAvatarDBContent = { +type: 'image', +uploadID: string, }; export type EncryptedImageAvatarDBContent = { +type: 'encrypted_image', +uploadID: string, }; export type ENSAvatarDBContent = { +type: 'ens', }; export const ensAvatarDBContentValidator: TInterface = tShape({ type: tString('ens') }); export type AvatarDBContent = | EmojiAvatarDBContent | ImageAvatarDBContent | EncryptedImageAvatarDBContent | ENSAvatarDBContent; export type UpdateUserAvatarRemoveRequest = { +type: 'remove' }; +export type NonKeyserverImageAvatarContent = { + +type: 'non_keyserver_image', + +blobURI: string, + +encryptionKey: string, + +thumbHash: ?string, +}; + export type UpdateUserAvatarRequest = | AvatarDBContent + | NonKeyserverImageAvatarContent | UpdateUserAvatarRemoveRequest; export type ClientEmojiAvatar = EmojiAvatarDBContent; const clientEmojiAvatarValidator = emojiAvatarDBContentValidator; export type ClientImageAvatar = { +type: 'image', +uri: string, }; const clientImageAvatarValidator = tShape({ type: tString('image'), uri: t.String, }); export type ClientEncryptedImageAvatar = { +type: 'encrypted_image', +blobURI: string, +encryptionKey: string, +thumbHash: ?string, }; const clientEncryptedImageAvatarValidator = tShape({ type: tString('encrypted_image'), blobURI: t.String, encryptionKey: t.String, thumbHash: t.maybe(t.String), }); export type ClientENSAvatar = ENSAvatarDBContent; const clientENSAvatarValidator = ensAvatarDBContentValidator; export type ClientAvatar = | ClientEmojiAvatar | ClientImageAvatar | ClientEncryptedImageAvatar | ClientENSAvatar; export const clientAvatarValidator: TUnion = t.union([ clientEmojiAvatarValidator, clientImageAvatarValidator, clientENSAvatarValidator, clientEncryptedImageAvatarValidator, ]); export type ResolvedClientAvatar = | ClientEmojiAvatar | ClientImageAvatar | ClientEncryptedImageAvatar; export type UpdateUserAvatarResponse = { +updates: CreateUpdatesResult, }; export type GenericUserInfoWithAvatar = { +username?: ?string, +avatar?: ?ClientAvatar, ... }; export type AvatarSize = 'XS' | 'S' | 'M' | 'L' | 'XL' | 'XXL'; diff --git a/native/account/registration/avatar-selection.react.js b/native/account/registration/avatar-selection.react.js index a71d92430..f80735a90 100644 --- a/native/account/registration/avatar-selection.react.js +++ b/native/account/registration/avatar-selection.react.js @@ -1,217 +1,218 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { Text, View } from 'react-native'; import { EditUserAvatarContext, type UserAvatarSelection, } from 'lib/components/edit-user-avatar-provider.react.js'; import RegistrationButtonContainer from './registration-button-container.react.js'; import RegistrationContainer from './registration-container.react.js'; import RegistrationContentContainer from './registration-content-container.react.js'; import { RegistrationContext } from './registration-context.js'; import type { RegistrationNavigationProp } from './registration-navigator.react.js'; import { type CoolOrNerdMode, type AccountSelection, type AvatarData, ensAvatarSelection, } from './registration-types.js'; import { enableSIWEBackupCreation } from './registration-types.js'; import EditUserAvatar from '../../avatars/edit-user-avatar.react.js'; import PrimaryButton from '../../components/primary-button.react.js'; import { useCurrentLeafRouteName } from '../../navigation/nav-selectors.js'; import { type NavigationRoute, RegistrationTermsRouteName, CreateSIWEBackupMessageRouteName, AvatarSelectionRouteName, EmojiAvatarSelectionRouteName, RegistrationUserAvatarCameraModalRouteName, } from '../../navigation/route-names.js'; import { useStyles } from '../../themes/colors.js'; export type AvatarSelectionParams = { +userSelections: { +coolOrNerdMode?: ?CoolOrNerdMode, +keyserverURL?: ?string, +accountSelection: AccountSelection, +farcasterID: ?string, }, }; type Props = { +navigation: RegistrationNavigationProp<'AvatarSelection'>, +route: NavigationRoute<'AvatarSelection'>, }; function AvatarSelection(props: Props): React.Node { const { userSelections } = props.route.params; const { accountSelection } = userSelections; const usernameOrETHAddress = accountSelection.accountType === 'username' ? accountSelection.username : accountSelection.address; const registrationContext = React.useContext(RegistrationContext); invariant(registrationContext, 'registrationContext should be set'); const { cachedSelections, setCachedSelections } = registrationContext; const editUserAvatarContext = React.useContext(EditUserAvatarContext); invariant(editUserAvatarContext, 'editUserAvatarContext should be set'); const { setRegistrationMode } = editUserAvatarContext; const prefetchedAvatarURI = accountSelection.accountType === 'ethereum' ? accountSelection.avatarURI : undefined; let initialAvatarData = cachedSelections.avatarData; if (!initialAvatarData && prefetchedAvatarURI) { initialAvatarData = ensAvatarSelection; } const [avatarData, setAvatarData] = React.useState(initialAvatarData); const setClientAvatarFromSelection = React.useCallback( (selection: UserAvatarSelection) => { if (selection.needsUpload) { const newAvatarData = { ...selection, clientAvatar: { type: 'image', uri: selection.mediaSelection.uri, }, }; setAvatarData(newAvatarData); setCachedSelections(oldUserSelections => ({ ...oldUserSelections, avatarData: newAvatarData, })); } else if (selection.updateUserAvatarRequest.type !== 'remove') { const clientRequest = selection.updateUserAvatarRequest; invariant( clientRequest.type !== 'image' && - clientRequest.type !== 'encrypted_image', + clientRequest.type !== 'encrypted_image' && + clientRequest.type !== 'non_keyserver_image', 'image avatars need to be uploaded', ); const newAvatarData = { ...selection, clientAvatar: clientRequest, }; setAvatarData(newAvatarData); setCachedSelections(oldUserSelections => ({ ...oldUserSelections, avatarData: newAvatarData, })); } else { setAvatarData(undefined); setCachedSelections(oldUserSelections => ({ ...oldUserSelections, avatarData: undefined, })); } }, [setCachedSelections], ); const currentRouteName = useCurrentLeafRouteName(); const avatarSelectionHappening = currentRouteName === AvatarSelectionRouteName || currentRouteName === EmojiAvatarSelectionRouteName || currentRouteName === RegistrationUserAvatarCameraModalRouteName; React.useEffect(() => { if (!avatarSelectionHappening) { return undefined; } setRegistrationMode({ registrationMode: 'on', successCallback: setClientAvatarFromSelection, }); return () => { setRegistrationMode({ registrationMode: 'off' }); }; }, [ avatarSelectionHappening, setRegistrationMode, setClientAvatarFromSelection, ]); const { navigate } = props.navigation; const onProceed = React.useCallback(async () => { const newUserSelections = { ...userSelections, avatarData, }; if ( userSelections.accountSelection.accountType === 'ethereum' && enableSIWEBackupCreation ) { navigate<'CreateSIWEBackupMessage'>({ name: CreateSIWEBackupMessageRouteName, params: { userSelections: newUserSelections }, }); return; } navigate<'RegistrationTerms'>({ name: RegistrationTermsRouteName, params: { userSelections: newUserSelections }, }); }, [userSelections, avatarData, navigate]); const clientAvatar = avatarData?.clientAvatar; const userInfoOverride = React.useMemo( () => ({ username: usernameOrETHAddress, avatar: clientAvatar, }), [usernameOrETHAddress, clientAvatar], ); const styles = useStyles(unboundStyles); return ( Pick an avatar ); } const unboundStyles = { scrollViewContentContainer: { paddingHorizontal: 0, }, header: { fontSize: 24, color: 'panelForegroundLabel', paddingBottom: 16, paddingHorizontal: 16, }, stagedAvatarSection: { marginTop: 16, backgroundColor: 'panelForeground', paddingVertical: 24, alignItems: 'center', }, editUserAvatar: { alignItems: 'center', justifyContent: 'center', }, }; export default AvatarSelection;