diff --git a/keyserver/src/fetchers/thread-fetchers.js b/keyserver/src/fetchers/thread-fetchers.js index 1f739fd28..1870a72a5 100644 --- a/keyserver/src/fetchers/thread-fetchers.js +++ b/keyserver/src/fetchers/thread-fetchers.js @@ -1,390 +1,408 @@ // @flow import invariant from 'invariant'; import { getAllThreadPermissions } from 'lib/permissions/thread-permissions.js'; import { rawThreadInfoFromServerThreadInfo, getContainingThreadID, getCommunity, } from 'lib/shared/thread-utils.js'; import { hasMinCodeVersion } from 'lib/shared/version-utils.js'; import type { AvatarDBContent, ClientAvatar } from 'lib/types/avatar-types.js'; import type { RawMessageInfo, MessageInfo } from 'lib/types/message-types.js'; import { threadTypes, type ThreadType } from 'lib/types/thread-types-enum.js'; import { type RawThreadInfos, type ServerThreadInfo, } from 'lib/types/thread-types.js'; import { ServerError } from 'lib/utils/errors.js'; -import { getUploadURL } from './upload-fetchers.js'; +import { getUploadURL, makeUploadURI } from './upload-fetchers.js'; import { dbQuery, SQL, mergeAndConditions } from '../database/database.js'; import type { SQLStatementType } from '../database/types.js'; import type { Viewer } from '../session/viewer.js'; type FetchThreadInfosFilter = $Shape<{ +accessibleToUserID: string, +threadID: string, +threadIDs: $ReadOnlySet, +parentThreadID: string, +sourceMessageID: string, }>; function constructWhereClause( filter: FetchThreadInfosFilter, ): SQLStatementType { const fromTable = filter.accessibleToUserID ? 'memberships' : 'threads'; const conditions = []; if (filter.accessibleToUserID) { conditions.push( SQL`mm.user = ${filter.accessibleToUserID} AND mm.role > -1`, ); } if (filter.threadID && fromTable === 'memberships') { conditions.push(SQL`mm.thread = ${filter.threadID}`); } else if (filter.threadID) { conditions.push(SQL`t.id = ${filter.threadID}`); } if (filter.threadIDs && fromTable === 'memberships') { conditions.push(SQL`mm.thread IN (${[...filter.threadIDs]})`); } else if (filter.threadIDs) { conditions.push(SQL`t.id IN (${[...filter.threadIDs]})`); } if (filter.parentThreadID) { conditions.push(SQL`t.parent_thread_id = ${filter.parentThreadID}`); } if (filter.sourceMessageID) { conditions.push(SQL`t.source_message = ${filter.sourceMessageID}`); } if (conditions.length === 0) { return SQL``; } const clause = mergeAndConditions(conditions); return SQL`WHERE `.append(clause); } type FetchServerThreadInfosResult = { +threadInfos: { +[id: string]: ServerThreadInfo }, }; async function fetchServerThreadInfos( filter?: FetchThreadInfosFilter, ): Promise { if (filter?.threadIDs?.size === 0) { return { threadInfos: {} }; } let primaryFetchClause; if (filter?.accessibleToUserID) { primaryFetchClause = SQL` FROM memberships mm LEFT JOIN threads t ON t.id = mm.thread `; } else { primaryFetchClause = SQL` FROM threads t `; } const whereClause = filter ? constructWhereClause(filter) : ''; const rolesQuery = SQL` SELECT t.id, t.default_role, r.id AS role, r.name, r.permissions ` .append(primaryFetchClause) .append( SQL` LEFT JOIN roles r ON r.thread = t.id `, ) .append(whereClause); const threadsQuery = SQL` SELECT t.id, t.name, t.parent_thread_id, t.containing_thread_id, t.community, t.depth, t.color, t.description, t.type, t.creation_time, t.source_message, t.replies_count, t.avatar, t.pinned_count, m.user, m.role, m.permissions, m.subscription, m.last_read_message < m.last_message AS unread, m.sender, - up.id AS upload_id, up.secret AS upload_secret + up.id AS upload_id, up.secret AS upload_secret, up.extra AS upload_extra ` .append(primaryFetchClause) .append( SQL` LEFT JOIN memberships m ON m.thread = t.id AND m.role >= 0 LEFT JOIN uploads up ON up.container = t.id `, ) .append(whereClause) .append(SQL` ORDER BY m.user ASC`); const [[threadsResult], [rolesResult]] = await Promise.all([ dbQuery(threadsQuery), dbQuery(rolesQuery), ]); const threadInfos = {}; for (const threadsRow of threadsResult) { const threadID = threadsRow.id.toString(); if (!threadInfos[threadID]) { threadInfos[threadID] = { id: threadID, type: threadsRow.type, name: threadsRow.name ? threadsRow.name : '', description: threadsRow.description ? threadsRow.description : '', color: threadsRow.color, creationTime: threadsRow.creation_time, parentThreadID: threadsRow.parent_thread_id ? threadsRow.parent_thread_id.toString() : null, containingThreadID: threadsRow.containing_thread_id ? threadsRow.containing_thread_id.toString() : null, depth: threadsRow.depth, community: threadsRow.community ? threadsRow.community.toString() : null, members: [], roles: {}, repliesCount: threadsRow.replies_count, pinnedCount: threadsRow.pinned_count, }; if (threadsRow.avatar) { const avatar: AvatarDBContent = JSON.parse(threadsRow.avatar); let clientAvatar: ?ClientAvatar; - if (avatar && avatar.type !== 'image') { + if ( + avatar && + avatar.type !== 'image' && + avatar.type !== 'encrypted_image' + ) { clientAvatar = avatar; } else if ( avatar && - avatar.type === 'image' && + (avatar.type === 'image' || avatar.type === 'encrypted_image') && threadsRow.upload_id && threadsRow.upload_secret ) { const uploadID = threadsRow.upload_id.toString(); invariant( uploadID === avatar.uploadID, `uploadID of upload should match uploadID of image avatar`, ); - clientAvatar = { - type: 'image', - uri: getUploadURL(uploadID, threadsRow.upload_secret), - }; + if (avatar.type === 'encrypted_image' && threadsRow.upload_extra) { + const uploadExtra = JSON.parse(threadsRow.upload_extra); + clientAvatar = { + type: 'encrypted_image', + blobURI: makeUploadURI( + uploadExtra.blobHash, + uploadID, + threadsRow.upload_secret, + ), + encryptionKey: uploadExtra.encryptionKey, + thumbHash: uploadExtra.thumbHash, + }; + } else { + clientAvatar = { + type: 'image', + uri: getUploadURL(uploadID, threadsRow.upload_secret), + }; + } } threadInfos[threadID] = { ...threadInfos[threadID], avatar: clientAvatar, }; } } const sourceMessageID = threadsRow.source_message?.toString(); if (sourceMessageID) { threadInfos[threadID].sourceMessageID = sourceMessageID; } if (threadsRow.user) { const userID = threadsRow.user.toString(); const allPermissions = getAllThreadPermissions( JSON.parse(threadsRow.permissions), threadID, ); threadInfos[threadID].members.push({ id: userID, permissions: allPermissions, role: threadsRow.role ? threadsRow.role.toString() : null, subscription: JSON.parse(threadsRow.subscription), unread: threadsRow.role ? !!threadsRow.unread : null, isSender: !!threadsRow.sender, }); } } for (const rolesRow of rolesResult) { const threadID = rolesRow.id.toString(); if (!rolesRow.role) { continue; } const role = rolesRow.role.toString(); if (!threadInfos[threadID].roles[role]) { threadInfos[threadID].roles[role] = { id: role, name: rolesRow.name, permissions: JSON.parse(rolesRow.permissions), isDefault: role === rolesRow.default_role.toString(), }; } } return { threadInfos }; } type FetchThreadInfosResult = { +threadInfos: RawThreadInfos, }; async function fetchThreadInfos( viewer: Viewer, inputFilter?: FetchThreadInfosFilter, ): Promise { const filter = { accessibleToUserID: viewer.id, ...inputFilter, }; const serverResult = await fetchServerThreadInfos(filter); return rawThreadInfosFromServerThreadInfos(viewer, serverResult); } function rawThreadInfosFromServerThreadInfos( viewer: Viewer, serverResult: FetchServerThreadInfosResult, ): FetchThreadInfosResult { const viewerID = viewer.id; const codeVersionBelow209 = !hasMinCodeVersion(viewer.platformDetails, { native: 209, }); const codeVersionBelow213 = !hasMinCodeVersion(viewer.platformDetails, { native: 213, }); const codeVersionBelow221 = !hasMinCodeVersion(viewer.platformDetails, { native: 221, }); const threadInfos = {}; for (const threadID in serverResult.threadInfos) { const serverThreadInfo = serverResult.threadInfos[threadID]; const threadInfo = rawThreadInfoFromServerThreadInfo( serverThreadInfo, viewerID, { filterThreadEditAvatarPermission: codeVersionBelow213, excludePinInfo: codeVersionBelow209, filterManageInviteLinksPermission: codeVersionBelow221, }, ); if (threadInfo) { threadInfos[threadID] = threadInfo; } } return { threadInfos }; } async function verifyThreadIDs( threadIDs: $ReadOnlyArray, ): Promise<$ReadOnlyArray> { if (threadIDs.length === 0) { return []; } const query = SQL`SELECT id FROM threads WHERE id IN (${threadIDs})`; const [result] = await dbQuery(query); const verified = []; for (const row of result) { verified.push(row.id.toString()); } return verified; } async function verifyThreadID(threadID: string): Promise { const result = await verifyThreadIDs([threadID]); return result.length !== 0; } type ThreadAncestry = { +containingThreadID: ?string, +community: ?string, +depth: number, }; async function determineThreadAncestry( parentThreadID: ?string, threadType: ThreadType, ): Promise { if (!parentThreadID) { return { containingThreadID: null, community: null, depth: 0 }; } const parentThreadInfos = await fetchServerThreadInfos({ threadID: parentThreadID, }); const parentThreadInfo = parentThreadInfos.threadInfos[parentThreadID]; if (!parentThreadInfo) { throw new ServerError('invalid_parameters'); } const containingThreadID = getContainingThreadID( parentThreadInfo, threadType, ); const community = getCommunity(parentThreadInfo); const depth = parentThreadInfo.depth + 1; return { containingThreadID, community, depth }; } function personalThreadQuery( firstMemberID: string, secondMemberID: string, ): SQLStatementType { return SQL` SELECT t.id FROM threads t INNER JOIN memberships m1 ON m1.thread = t.id AND m1.user = ${firstMemberID} INNER JOIN memberships m2 ON m2.thread = t.id AND m2.user = ${secondMemberID} WHERE t.type = ${threadTypes.PERSONAL} AND m1.role > 0 AND m2.role > 0 `; } async function fetchPersonalThreadID( viewerID: string, otherMemberID: string, ): Promise { const query = personalThreadQuery(viewerID, otherMemberID); const [threads] = await dbQuery(query); return threads[0]?.id.toString(); } async function serverThreadInfoFromMessageInfo( message: RawMessageInfo | MessageInfo, ): Promise { const threadID = message.threadID; const threads = await fetchServerThreadInfos({ threadID }); return threads.threadInfos[threadID]; } async function fetchContainedThreadIDs( parentThreadID: string, ): Promise> { const query = SQL` WITH RECURSIVE thread_tree AS ( SELECT id, containing_thread_id FROM threads WHERE id = ${parentThreadID} UNION ALL SELECT t.id, t.containing_thread_id FROM threads t JOIN thread_tree tt ON t.containing_thread_id = tt.id ) SELECT id FROM thread_tree `; const [result] = await dbQuery(query); return result.map(row => row.id.toString()); } export { fetchServerThreadInfos, fetchThreadInfos, rawThreadInfosFromServerThreadInfos, verifyThreadIDs, verifyThreadID, determineThreadAncestry, personalThreadQuery, fetchPersonalThreadID, serverThreadInfoFromMessageInfo, fetchContainedThreadIDs, }; diff --git a/keyserver/src/fetchers/user-fetchers.js b/keyserver/src/fetchers/user-fetchers.js index 07bf86324..41f87a75a 100644 --- a/keyserver/src/fetchers/user-fetchers.js +++ b/keyserver/src/fetchers/user-fetchers.js @@ -1,431 +1,496 @@ // @flow import invariant from 'invariant'; import { hasMinCodeVersion, FUTURE_CODE_VERSION, } from 'lib/shared/version-utils.js'; import type { AvatarDBContent, ClientAvatar } from 'lib/types/avatar-types.js'; import { undirectedStatus, directedStatus, userRelationshipStatus, } from 'lib/types/relationship-types.js'; import { threadPermissions } from 'lib/types/thread-permission-types.js'; import { communityThreadTypes } from 'lib/types/thread-types-enum.js'; import type { UserInfos, CurrentUserInfo, LoggedInUserInfo, GlobalUserInfo, } from 'lib/types/user-types.js'; import { ServerError } from 'lib/utils/errors.js'; -import { getUploadURL } from './upload-fetchers.js'; +import { getUploadURL, makeUploadURI } from './upload-fetchers.js'; import { dbQuery, SQL } from '../database/database.js'; import type { Viewer } from '../session/viewer.js'; async function fetchUserInfos( userIDs: $ReadOnlyArray, ): Promise<{ [id: string]: GlobalUserInfo }> { if (userIDs.length <= 0) { return {}; } const query = SQL` SELECT u.id, u.username, u.avatar, - up.id AS upload_id, up.secret AS upload_secret + up.id AS upload_id, up.secret AS upload_secret, up.extra AS upload_extra FROM users u LEFT JOIN uploads up ON up.container = u.id WHERE u.id IN (${userIDs}) `; const [result] = await dbQuery(query); const userInfos = {}; for (const row of result) { const id = row.id.toString(); const avatar: ?AvatarDBContent = row.avatar ? JSON.parse(row.avatar) : null; let clientAvatar: ?ClientAvatar; - if (avatar && avatar.type !== 'image') { + if ( + avatar && + avatar.type !== 'image' && + avatar.type !== 'encrypted_image' + ) { clientAvatar = avatar; } else if ( avatar && - avatar.type === 'image' && + (avatar.type === 'image' || avatar.type === 'encrypted_image') && row.upload_id && row.upload_secret ) { const uploadID = row.upload_id.toString(); invariant( uploadID === avatar.uploadID, 'uploadID of upload should match uploadID of image avatar', ); - clientAvatar = { - type: 'image', - uri: getUploadURL(uploadID, row.upload_secret), - }; + if (avatar.type === 'encrypted_image' && row.upload_extra) { + const uploadExtra = JSON.parse(row.upload_extra); + clientAvatar = { + type: 'encrypted_image', + blobURI: makeUploadURI( + uploadExtra.blobHash, + uploadID, + row.upload_secret, + ), + encryptionKey: uploadExtra.encryptionKey, + thumbHash: uploadExtra.thumbHash, + }; + } else { + clientAvatar = { + type: 'image', + uri: getUploadURL(uploadID, row.upload_secret), + }; + } } userInfos[id] = clientAvatar ? { id, username: row.username, avatar: clientAvatar, } : { id, username: row.username, }; } for (const userID of userIDs) { if (!userInfos[userID]) { userInfos[userID] = { id: userID, username: null, }; } } return userInfos; } async function fetchKnownUserInfos( viewer: Viewer, userIDs?: $ReadOnlyArray, ): Promise { if (!viewer.loggedIn) { return {}; } if (userIDs && userIDs.length === 0) { return {}; } const query = SQL` SELECT ru.user1, ru.user2, u.username, u.avatar, ru.status AS undirected_status, rd1.status AS user1_directed_status, rd2.status AS user2_directed_status, - up1.id AS user1_upload_id, up1.secret AS user1_upload_secret, - up2.id AS user2_upload_id, up2.secret AS user2_upload_secret + up1.id AS user1_upload_id, up1.secret AS user1_upload_secret, up1.extra AS user1_upload_extra, + up2.id AS user2_upload_id, up2.secret AS user2_upload_secret, up2.extra AS user2_upload_extra FROM relationships_undirected ru LEFT JOIN relationships_directed rd1 ON rd1.user1 = ru.user1 AND rd1.user2 = ru.user2 LEFT JOIN relationships_directed rd2 ON rd2.user1 = ru.user2 AND rd2.user2 = ru.user1 LEFT JOIN users u ON u.id != ${viewer.userID} AND (u.id = ru.user1 OR u.id = ru.user2) LEFT JOIN uploads up1 ON up1.container != ${viewer.userID} AND up1.container = ru.user1 LEFT JOIN uploads up2 ON up2.container != ${viewer.userID} AND up2.container = ru.user2 `; if (userIDs) { query.append(SQL` WHERE (ru.user1 = ${viewer.userID} AND ru.user2 IN (${userIDs})) OR (ru.user1 IN (${userIDs}) AND ru.user2 = ${viewer.userID}) `); } else { query.append(SQL` WHERE ru.user1 = ${viewer.userID} OR ru.user2 = ${viewer.userID} `); } query.append(SQL` UNION SELECT u.id AS user1, NULL AS user2, u.username, u.avatar, CAST(NULL AS UNSIGNED) AS undirected_status, CAST(NULL AS UNSIGNED) AS user1_directed_status, CAST(NULL AS UNSIGNED) AS user2_directed_status, - up.id AS user1_upload_id, up.secret AS user1_upload_secret, - NULL AS user2_upload_id, NULL AS user2_upload_secret + up.id AS user1_upload_id, up.secret AS user1_upload_secret, up.extra AS user1_upload_extra, + NULL AS user2_upload_id, NULL AS user2_upload_secret, NULL AS user2_upload_extra FROM users u LEFT JOIN uploads up ON up.container = u.id WHERE u.id = ${viewer.userID} `); const [result] = await dbQuery(query); const userInfos = {}; for (const row of result) { const user1 = row.user1.toString(); const user2 = row.user2 ? row.user2.toString() : null; const id = user1 === viewer.userID && user2 ? user2 : user1; const avatar: ?AvatarDBContent = row.avatar ? JSON.parse(row.avatar) : null; let clientAvatar: ?ClientAvatar; - if (avatar && avatar.type !== 'image') { + if ( + avatar && + avatar.type !== 'image' && + avatar.type !== 'encrypted_image' + ) { clientAvatar = avatar; } else if ( avatar && - avatar.type === 'image' && + (avatar.type === 'image' || avatar.type === 'encrypted_image') && row.user1_upload_id && row.user1_upload_secret ) { const uploadID = row.user1_upload_id.toString(); invariant( uploadID === avatar.uploadID, 'uploadID of upload should match uploadID of image avatar', ); - clientAvatar = { - type: 'image', - uri: getUploadURL(uploadID, row.user1_upload_secret), - }; + if (avatar.type === 'encrypted_image' && row.user1_upload_extra) { + const uploadExtra = JSON.parse(row.user1_upload_extra); + clientAvatar = { + type: 'encrypted_image', + blobURI: makeUploadURI( + uploadExtra.blobHash, + uploadID, + row.user1_upload_secret, + ), + encryptionKey: uploadExtra.encryptionKey, + thumbHash: uploadExtra.thumbHash, + }; + } else { + clientAvatar = { + type: 'image', + uri: getUploadURL(uploadID, row.user1_upload_secret), + }; + } } else if ( avatar && - avatar.type === 'image' && + (avatar.type === 'image' || avatar.type === 'encrypted_image') && row.user2_upload_id && row.user2_upload_secret ) { const uploadID = row.user2_upload_id.toString(); invariant( uploadID === avatar.uploadID, 'uploadID of upload should match uploadID of image avatar', ); - clientAvatar = { - type: 'image', - uri: getUploadURL(uploadID, row.user2_upload_secret), - }; + if (avatar.type === 'encrypted_image' && row.user2_upload_extra) { + const uploadExtra = JSON.parse(row.user2_upload_extra); + clientAvatar = { + type: 'encrypted_image', + blobURI: makeUploadURI( + uploadExtra.blobHash, + uploadID, + row.user2_upload_secret, + ), + encryptionKey: uploadExtra.encryptionKey, + thumbHash: uploadExtra.thumbHash, + }; + } else { + clientAvatar = { + type: 'image', + uri: getUploadURL(uploadID, row.user2_upload_secret), + }; + } } const userInfo = clientAvatar ? { id, username: row.username, avatar: clientAvatar, } : { id, username: row.username, }; if (!user2) { userInfos[id] = userInfo; continue; } let viewerDirectedStatus; let targetDirectedStatus; if (user1 === viewer.userID) { viewerDirectedStatus = row.user1_directed_status; targetDirectedStatus = row.user2_directed_status; } else { viewerDirectedStatus = row.user2_directed_status; targetDirectedStatus = row.user1_directed_status; } const viewerBlockedTarget = viewerDirectedStatus === directedStatus.BLOCKED; const targetBlockedViewer = targetDirectedStatus === directedStatus.BLOCKED; const friendshipExists = row.undirected_status === undirectedStatus.FRIEND; const viewerRequestedTargetFriendship = viewerDirectedStatus === directedStatus.PENDING_FRIEND; const targetRequestedViewerFriendship = targetDirectedStatus === directedStatus.PENDING_FRIEND; let relationshipStatus; if (viewerBlockedTarget && targetBlockedViewer) { relationshipStatus = userRelationshipStatus.BOTH_BLOCKED; } else if (targetBlockedViewer) { relationshipStatus = userRelationshipStatus.BLOCKED_VIEWER; } else if (viewerBlockedTarget) { relationshipStatus = userRelationshipStatus.BLOCKED_BY_VIEWER; } else if (friendshipExists) { relationshipStatus = userRelationshipStatus.FRIEND; } else if (targetRequestedViewerFriendship) { relationshipStatus = userRelationshipStatus.REQUEST_RECEIVED; } else if (viewerRequestedTargetFriendship) { relationshipStatus = userRelationshipStatus.REQUEST_SENT; } userInfos[id] = userInfo; if (relationshipStatus) { userInfos[id].relationshipStatus = relationshipStatus; } if (relationshipStatus && !row.username) { console.warn( `user ${viewer.userID} has ${relationshipStatus} relationship with ` + `anonymous user ${id}`, ); } } return userInfos; } async function verifyUserIDs( userIDs: $ReadOnlyArray, ): Promise { if (userIDs.length === 0) { return []; } const query = SQL`SELECT id FROM users WHERE id IN (${userIDs})`; const [result] = await dbQuery(query); return result.map(row => row.id.toString()); } async function verifyUserOrCookieIDs( ids: $ReadOnlyArray, ): Promise { if (ids.length === 0) { return []; } const query = SQL` SELECT id FROM users WHERE id IN (${ids}) UNION SELECT id FROM cookies WHERE id IN (${ids}) `; const [result] = await dbQuery(query); return result.map(row => row.id.toString()); } async function fetchCurrentUserInfo(viewer: Viewer): Promise { if (!viewer.loggedIn) { return ({ anonymous: true }: CurrentUserInfo); } const currentUserInfo = await fetchLoggedInUserInfo(viewer); return currentUserInfo; } async function fetchLoggedInUserInfo( viewer: Viewer, ): Promise { const userQuery = SQL` SELECT u.id, u.username, u.avatar, - up.id AS upload_id, up.secret AS upload_secret + up.id AS upload_id, up.secret AS upload_secret, up.extra AS upload_extra FROM users u LEFT JOIN uploads up ON up.container = u.id WHERE u.id = ${viewer.userID} `; const settingsQuery = SQL` SELECT name, data FROM settings WHERE user = ${viewer.userID} `; const [[userResult], [settingsResult]] = await Promise.all([ dbQuery(userQuery), dbQuery(settingsQuery), ]); const [userRow] = userResult; if (!userRow) { throw new ServerError('unknown_error'); } const id = userRow.id.toString(); - const { username, upload_id, upload_secret } = userRow; + const { username, upload_id, upload_secret, upload_extra } = userRow; let loggedInUserInfo: LoggedInUserInfo = { id, username, }; const avatar: ?AvatarDBContent = userRow.avatar ? JSON.parse(userRow.avatar) : null; let clientAvatar: ?ClientAvatar; - if (avatar && avatar.type !== 'image') { + if (avatar && avatar.type !== 'image' && avatar.type !== 'encrypted_image') { clientAvatar = avatar; - } else if (avatar && avatar.type === 'image' && upload_id && upload_secret) { + } else if ( + avatar && + (avatar.type === 'image' || avatar.type === 'encrypted_image') && + upload_id && + upload_secret + ) { const uploadID = upload_id.toString(); invariant( uploadID === avatar.uploadID, 'uploadID of upload should match uploadID of image avatar', ); - clientAvatar = { - type: 'image', - uri: getUploadURL(uploadID, upload_secret), - }; + if (avatar.type === 'encrypted_image' && upload_extra) { + const uploadExtra = JSON.parse(upload_extra); + clientAvatar = { + type: 'encrypted_image', + blobURI: makeUploadURI(uploadExtra.blobHash, uploadID, upload_secret), + encryptionKey: uploadExtra.encryptionKey, + thumbHash: uploadExtra.thumbHash, + }; + } else { + clientAvatar = { + type: 'image', + uri: getUploadURL(uploadID, upload_secret), + }; + } } if (avatar) { loggedInUserInfo = { ...loggedInUserInfo, avatar: clientAvatar }; } const featureGateSettings = !hasMinCodeVersion(viewer.platformDetails, { native: FUTURE_CODE_VERSION, }); if (featureGateSettings) { return loggedInUserInfo; } const settings = settingsResult.reduce((prev, curr) => { prev[curr.name] = curr.data; return prev; }, {}); loggedInUserInfo = { ...loggedInUserInfo, settings }; return loggedInUserInfo; } async function fetchAllUserIDs(): Promise { const query = SQL`SELECT id FROM users`; const [result] = await dbQuery(query); return result.map(row => row.id.toString()); } async function fetchUsername(id: string): Promise { const query = SQL`SELECT username FROM users WHERE id = ${id}`; const [result] = await dbQuery(query); if (result.length === 0) { return null; } const row = result[0]; return row.username; } async function fetchAllUsernames(): Promise { const query = SQL`SELECT username FROM users`; const [result] = await dbQuery(query); return result.map(row => row.username); } async function fetchKeyserverAdminID(): Promise { const changeRoleExtractString = `$.${threadPermissions.CHANGE_ROLE}`; const query = SQL` SELECT m.user FROM memberships m INNER JOIN roles r ON m.role = r.id INNER JOIN threads t ON r.thread = t.id WHERE r.name = "Admins" AND t.type IN (${communityThreadTypes}) AND JSON_EXTRACT(r.permissions, ${changeRoleExtractString}) IS TRUE `; const [result] = await dbQuery(query); if (result.length === 0) { return null; } if (result.length > 1) { console.warn('more than one community admin found'); } return result[0].user; } async function fetchUserIDForEthereumAddress( address: string, ): Promise { const query = SQL` SELECT id FROM users WHERE LCASE(ethereum_address) = LCASE(${address}) `; const [result] = await dbQuery(query); return result.length === 0 ? null : result[0].id.toString(); } export { fetchUserInfos, fetchLoggedInUserInfo, verifyUserIDs, verifyUserOrCookieIDs, fetchCurrentUserInfo, fetchAllUserIDs, fetchUsername, fetchAllUsernames, fetchKnownUserInfos, fetchKeyserverAdminID, fetchUserIDForEthereumAddress, }; diff --git a/keyserver/src/updaters/account-updaters.js b/keyserver/src/updaters/account-updaters.js index c0396958f..5b168c016 100644 --- a/keyserver/src/updaters/account-updaters.js +++ b/keyserver/src/updaters/account-updaters.js @@ -1,254 +1,265 @@ // @flow import invariant from 'invariant'; import bcrypt from 'twin-bcrypt'; import { hasMinCodeVersion } from 'lib/shared/version-utils.js'; import type { ResetPasswordRequest, UpdatePasswordRequest, UpdateUserSettingsRequest, LogInResponse, } 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 } from '../fetchers/upload-fetchers.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 accountUpdater( 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); const updateDatas = [ { type: updateTypes.UPDATE_CURRENT_USER, userID: viewer.userID, time: Date.now(), }, ]; await createUpdates(updateDatas, { viewer, updatesForCurrentSession: 'broadcast', }); } // 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'); } const newAvatarValue = request.type === 'remove' ? null : JSON.stringify(request); - const mediaID = request.type === 'image' ? request.uploadID : null; + const mediaID = + request.type === 'image' || request.type === 'encrypted_image' + ? request.uploadID + : null; const query = SQL` START TRANSACTION; UPDATE uploads SET container = NULL WHERE uploader = ${viewer.userID} AND 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 thread IS NULL ) ); UPDATE uploads SET container = ${viewer.userID} WHERE id = ${mediaID} AND uploader = ${viewer.userID} AND 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 container = ${viewer.userID} AND thread IS NULL ) ); COMMIT; - SELECT id AS upload_id, secret AS upload_secret + SELECT id AS upload_id, secret AS upload_secret, extra AS upload_extra FROM uploads WHERE id = ${mediaID} AND uploader = ${viewer.userID} AND 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') { + } else if (request.type !== 'image' && request.type !== 'encrypted_image') { return request; } else { - const [{ upload_id, upload_secret }] = selectResult; + 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 { accountUpdater, checkAndSendVerificationEmail, checkAndSendPasswordResetEmail, updateUserSettings, updatePassword, updateUserAvatar, }; diff --git a/keyserver/src/updaters/thread-updaters.js b/keyserver/src/updaters/thread-updaters.js index 6af5a4e22..7b77953b5 100644 --- a/keyserver/src/updaters/thread-updaters.js +++ b/keyserver/src/updaters/thread-updaters.js @@ -1,968 +1,970 @@ // @flow import { getRolePermissionBlobs } from 'lib/permissions/thread-permissions.js'; import { filteredThreadIDs } from 'lib/selectors/calendar-filter-selectors.js'; import { getPinnedContentFromMessage } from 'lib/shared/message-utils.js'; import { threadHasAdminRole, roleIsAdminRole, viewerIsMember, getThreadTypeParentRequirement, } from 'lib/shared/thread-utils.js'; import type { Shape } from 'lib/types/core.js'; import { messageTypes } from 'lib/types/message-types-enum.js'; import { threadPermissions } from 'lib/types/thread-permission-types.js'; import { threadTypes } from 'lib/types/thread-types-enum.js'; import { type RoleChangeRequest, type ChangeThreadSettingsResult, type RemoveMembersRequest, type LeaveThreadRequest, type LeaveThreadResult, type UpdateThreadRequest, type ServerThreadJoinRequest, type ThreadJoinResult, type ToggleMessagePinRequest, type ToggleMessagePinResult, } from 'lib/types/thread-types.js'; import { updateTypes } from 'lib/types/update-types-enum.js'; import { ServerError } from 'lib/utils/errors.js'; import { promiseAll } from 'lib/utils/promises.js'; import { firstLine } from 'lib/utils/string-utils.js'; import { canToggleMessagePin } from 'lib/utils/toggle-pin-utils.js'; import { validChatNameRegex } from 'lib/utils/validation-utils.js'; import { reportLinkUsage } from './link-updaters.js'; import { updateRoles } from './role-updaters.js'; import { changeRole, recalculateThreadPermissions, commitMembershipChangeset, } from './thread-permission-updaters.js'; import createMessages from '../creators/message-creator.js'; import { createUpdates } from '../creators/update-creator.js'; import { dbQuery, SQL } from '../database/database.js'; import { checkIfInviteLinkIsValid } from '../fetchers/link-fetchers.js'; import { fetchMessageInfoByID } from '../fetchers/message-fetchers.js'; import { fetchThreadInfos, fetchServerThreadInfos, determineThreadAncestry, rawThreadInfosFromServerThreadInfos, } from '../fetchers/thread-fetchers.js'; import { checkThreadPermission, viewerIsMember as fetchViewerIsMember, checkThread, validateCandidateMembers, } from '../fetchers/thread-permission-fetchers.js'; import { verifyUserIDs, verifyUserOrCookieIDs, } from '../fetchers/user-fetchers.js'; import { handleAsyncPromise } from '../responders/handlers.js'; import type { Viewer } from '../session/viewer.js'; import RelationshipChangeset from '../utils/relationship-changeset.js'; type UpdateRoleOptions = { +silenceNewMessages?: boolean, +forcePermissionRecalculation?: boolean, }; async function updateRole( viewer: Viewer, request: RoleChangeRequest, options?: UpdateRoleOptions, ): Promise { const silenceNewMessages = options?.silenceNewMessages; const forcePermissionRecalculation = options?.forcePermissionRecalculation; if (!viewer.loggedIn) { throw new ServerError('not_logged_in'); } const [memberIDs, hasPermission, fetchThreadResult] = await Promise.all([ verifyUserIDs(request.memberIDs), checkThreadPermission( viewer, request.threadID, threadPermissions.CHANGE_ROLE, ), fetchThreadInfos(viewer, { threadID: request.threadID }), ]); if (memberIDs.length === 0) { throw new ServerError('invalid_parameters'); } if (!hasPermission) { throw new ServerError('invalid_credentials'); } const threadInfo = fetchThreadResult.threadInfos[request.threadID]; if (!threadInfo) { throw new ServerError('invalid_parameters'); } const adminRoleID = Object.keys(threadInfo.roles).find( roleID => threadInfo.roles[roleID].name === 'Admins', ); // Ensure that there will always still be at least one admin in a community if (adminRoleID) { const memberRoles = memberIDs.map( memberID => threadInfo.members.find(member => member.id === memberID)?.role, ); const communityAdminsCount = threadInfo.members.filter( member => member.role === adminRoleID, ).length; const changedAdminsCount = memberRoles.filter( memberRole => memberRole === adminRoleID, ).length; if (changedAdminsCount >= communityAdminsCount) { throw new ServerError('invalid_parameters'); } } const query = SQL` SELECT user, role FROM memberships WHERE user IN (${memberIDs}) AND thread = ${request.threadID} `; const [result] = await dbQuery(query); let nonMemberUser = false; let numResults = 0; for (const row of result) { if (row.role <= 0) { nonMemberUser = true; break; } numResults++; } if (nonMemberUser || numResults < memberIDs.length) { throw new ServerError('invalid_parameters'); } const changeset = await changeRole( request.threadID, memberIDs, request.role, { forcePermissionRecalculation: !!forcePermissionRecalculation, }, ); const { viewerUpdates } = await commitMembershipChangeset( viewer, changeset, forcePermissionRecalculation ? { changedThreadIDs: new Set([request.threadID]) } : undefined, ); let newMessageInfos = []; if (!silenceNewMessages) { const messageData = { type: messageTypes.CHANGE_ROLE, threadID: request.threadID, creatorID: viewer.userID, time: Date.now(), userIDs: memberIDs, newRole: request.role, roleName: threadInfo.roles[request.role].name, }; newMessageInfos = await createMessages(viewer, [messageData]); } return { updatesResult: { newUpdates: viewerUpdates }, newMessageInfos }; } async function removeMembers( viewer: Viewer, request: RemoveMembersRequest, ): Promise { const viewerID = viewer.userID; if (request.memberIDs.includes(viewerID)) { throw new ServerError('invalid_parameters'); } const [memberIDs, hasPermission] = await Promise.all([ verifyUserOrCookieIDs(request.memberIDs), checkThreadPermission( viewer, request.threadID, threadPermissions.REMOVE_MEMBERS, ), ]); if (memberIDs.length === 0) { throw new ServerError('invalid_parameters'); } if (!hasPermission) { throw new ServerError('invalid_credentials'); } const query = SQL` SELECT m.user, m.role, t.default_role FROM memberships m LEFT JOIN threads t ON t.id = m.thread WHERE m.user IN (${memberIDs}) AND m.thread = ${request.threadID} `; const [result] = await dbQuery(query); let nonDefaultRoleUser = false; const actualMemberIDs = []; for (const row of result) { if (row.role <= 0) { continue; } actualMemberIDs.push(row.user.toString()); if (row.role !== row.default_role) { nonDefaultRoleUser = true; } } if (nonDefaultRoleUser) { const hasChangeRolePermission = await checkThreadPermission( viewer, request.threadID, threadPermissions.CHANGE_ROLE, ); if (!hasChangeRolePermission) { throw new ServerError('invalid_credentials'); } } const changeset = await changeRole(request.threadID, actualMemberIDs, 0); const { viewerUpdates } = await commitMembershipChangeset(viewer, changeset); const newMessageInfos = await (async () => { if (actualMemberIDs.length === 0) { return []; } const messageData = { type: messageTypes.REMOVE_MEMBERS, threadID: request.threadID, creatorID: viewerID, time: Date.now(), removedUserIDs: actualMemberIDs, }; return await createMessages(viewer, [messageData]); })(); return { updatesResult: { newUpdates: viewerUpdates }, newMessageInfos }; } async function leaveThread( viewer: Viewer, request: LeaveThreadRequest, ): Promise { if (!viewer.loggedIn) { throw new ServerError('not_logged_in'); } const [fetchThreadResult, hasPermission] = await Promise.all([ fetchThreadInfos(viewer, { threadID: request.threadID }), checkThreadPermission( viewer, request.threadID, threadPermissions.LEAVE_THREAD, ), ]); const threadInfo = fetchThreadResult.threadInfos[request.threadID]; if (!viewerIsMember(threadInfo)) { return { updatesResult: { newUpdates: [] }, }; } if (!hasPermission) { throw new ServerError('invalid_parameters'); } const viewerID = viewer.userID; if (threadHasAdminRole(threadInfo)) { let otherUsersExist = false; let otherAdminsExist = false; for (const member of threadInfo.members) { const role = member.role; if (!role || member.id === viewerID) { continue; } otherUsersExist = true; if (roleIsAdminRole(threadInfo.roles[role])) { otherAdminsExist = true; break; } } if (otherUsersExist && !otherAdminsExist) { throw new ServerError('invalid_parameters'); } } const changeset = await changeRole(request.threadID, [viewerID], 0); const { viewerUpdates } = await commitMembershipChangeset(viewer, changeset); const messageData = { type: messageTypes.LEAVE_THREAD, threadID: request.threadID, creatorID: viewerID, time: Date.now(), }; await createMessages(viewer, [messageData]); return { updatesResult: { newUpdates: viewerUpdates } }; } type UpdateThreadOptions = Shape<{ +forceAddMembers: boolean, +forceUpdateRoot: boolean, +silenceMessages: boolean, +ignorePermissions: boolean, }>; async function updateThread( viewer: Viewer, request: UpdateThreadRequest, options?: UpdateThreadOptions, ): Promise { if (!viewer.loggedIn) { throw new ServerError('not_logged_in'); } const forceAddMembers = options?.forceAddMembers ?? false; const forceUpdateRoot = options?.forceUpdateRoot ?? false; const silenceMessages = options?.silenceMessages ?? false; const ignorePermissions = (options?.ignorePermissions && viewer.isScriptViewer) ?? false; const validationPromises = {}; const changedFields = {}; const sqlUpdate = {}; const untrimmedName = request.changes.name; if (untrimmedName !== undefined && untrimmedName !== null) { const name = firstLine(untrimmedName); if (name.search(validChatNameRegex) === -1) { throw new ServerError('invalid_chat_name'); } changedFields.name = name; sqlUpdate.name = name ?? null; } const { description } = request.changes; if (description !== undefined && description !== null) { changedFields.description = description; sqlUpdate.description = description ?? null; } if (request.changes.color) { const color = request.changes.color.toLowerCase(); changedFields.color = color; sqlUpdate.color = color; } const { parentThreadID } = request.changes; if (parentThreadID !== undefined) { // TODO some sort of message when this changes sqlUpdate.parent_thread_id = parentThreadID; } const { avatar } = request.changes; if (avatar) { changedFields.avatar = avatar.type !== 'remove' ? JSON.stringify(avatar) : ''; sqlUpdate.avatar = avatar.type !== 'remove' ? JSON.stringify(avatar) : null; } const threadType = request.changes.type; if (threadType !== null && threadType !== undefined) { changedFields.type = threadType; sqlUpdate.type = threadType; } if ( !ignorePermissions && threadType !== null && threadType !== undefined && threadType !== threadTypes.COMMUNITY_OPEN_SUBTHREAD && threadType !== threadTypes.COMMUNITY_SECRET_SUBTHREAD ) { throw new ServerError('invalid_parameters'); } const newMemberIDs = request.changes.newMemberIDs && request.changes.newMemberIDs.length > 0 ? [...new Set(request.changes.newMemberIDs)] : null; if ( Object.keys(sqlUpdate).length === 0 && !newMemberIDs && !forceUpdateRoot ) { throw new ServerError('invalid_parameters'); } validationPromises.serverThreadInfos = fetchServerThreadInfos({ threadID: request.threadID, }); validationPromises.hasNecessaryPermissions = (async () => { if (ignorePermissions) { return; } const checks = []; if (sqlUpdate.name !== undefined) { checks.push({ check: 'permission', permission: threadPermissions.EDIT_THREAD_NAME, }); } if (sqlUpdate.description !== undefined) { checks.push({ check: 'permission', permission: threadPermissions.EDIT_THREAD_DESCRIPTION, }); } if (sqlUpdate.color !== undefined) { checks.push({ check: 'permission', permission: threadPermissions.EDIT_THREAD_COLOR, }); } if (sqlUpdate.avatar !== undefined) { checks.push({ check: 'permission', permission: threadPermissions.EDIT_THREAD_AVATAR, }); } if (parentThreadID !== undefined || sqlUpdate.type !== undefined) { checks.push({ check: 'permission', permission: threadPermissions.EDIT_PERMISSIONS, }); } if (newMemberIDs) { checks.push({ check: 'permission', permission: threadPermissions.ADD_MEMBERS, }); } const hasNecessaryPermissions = await checkThread( viewer, request.threadID, checks, ); if (!hasNecessaryPermissions) { throw new ServerError('invalid_credentials'); } })(); const { serverThreadInfos } = await promiseAll(validationPromises); const serverThreadInfo = serverThreadInfos.threadInfos[request.threadID]; if (!serverThreadInfo) { throw new ServerError('internal_error'); } // Threads with source message should be visible to everyone, but we can't // guarantee it for COMMUNITY_SECRET_SUBTHREAD threads so we forbid it for // now. In the future, if we want to support this, we would need to unlink the // source message. if ( threadType !== null && threadType !== undefined && threadType !== threadTypes.SIDEBAR && threadType !== threadTypes.COMMUNITY_OPEN_SUBTHREAD && serverThreadInfo.sourceMessageID ) { throw new ServerError('invalid_parameters'); } // You can't change the parent thread of a current or former SIDEBAR if (parentThreadID !== undefined && serverThreadInfo.sourceMessageID) { throw new ServerError('invalid_parameters'); } const oldThreadType = serverThreadInfo.type; const oldParentThreadID = serverThreadInfo.parentThreadID; const oldContainingThreadID = serverThreadInfo.containingThreadID; const oldCommunity = serverThreadInfo.community; const oldDepth = serverThreadInfo.depth; const nextThreadType = threadType !== null && threadType !== undefined ? threadType : oldThreadType; let nextParentThreadID = parentThreadID !== undefined ? parentThreadID : oldParentThreadID; // Does the new thread type preclude a parent? if ( threadType !== undefined && threadType !== null && getThreadTypeParentRequirement(threadType) === 'disabled' && nextParentThreadID !== null ) { nextParentThreadID = null; sqlUpdate.parent_thread_id = null; } // Does the new thread type require a parent? if ( threadType !== undefined && threadType !== null && getThreadTypeParentRequirement(threadType) === 'required' && nextParentThreadID === null ) { throw new ServerError('no_parent_thread_specified'); } const determineThreadAncestryPromise = determineThreadAncestry( nextParentThreadID, nextThreadType, ); const confirmParentPermissionPromise = (async () => { if (ignorePermissions || !nextParentThreadID) { return; } if ( nextParentThreadID === oldParentThreadID && (nextThreadType === threadTypes.SIDEBAR) === (oldThreadType === threadTypes.SIDEBAR) ) { return; } const hasParentPermission = await checkThreadPermission( viewer, nextParentThreadID, nextThreadType === threadTypes.SIDEBAR ? threadPermissions.CREATE_SIDEBARS : threadPermissions.CREATE_SUBCHANNELS, ); if (!hasParentPermission) { throw new ServerError('invalid_parameters'); } })(); const rolesNeedUpdate = forceUpdateRoot || nextThreadType !== oldThreadType; const validateNewMembersPromise = (async () => { if (!newMemberIDs || ignorePermissions) { return; } const defaultRolePermissionsPromise = (async () => { let rolePermissions; if (!rolesNeedUpdate) { const rolePermissionsQuery = SQL` SELECT r.permissions FROM threads t LEFT JOIN roles r ON r.id = t.default_role WHERE t.id = ${request.threadID} `; const [result] = await dbQuery(rolePermissionsQuery); if (result.length > 0) { rolePermissions = JSON.parse(result[0].permissions); } } if (!rolePermissions) { rolePermissions = getRolePermissionBlobs(nextThreadType).Members; } return rolePermissions; })(); const [defaultRolePermissions, nextThreadAncestry] = await Promise.all([ defaultRolePermissionsPromise, determineThreadAncestryPromise, ]); const { newMemberIDs: validatedIDs } = await validateCandidateMembers( viewer, { newMemberIDs }, { threadType: nextThreadType, parentThreadID: nextParentThreadID, containingThreadID: nextThreadAncestry.containingThreadID, defaultRolePermissions, }, { requireRelationship: !forceAddMembers }, ); if ( validatedIDs && Number(validatedIDs?.length) < Number(newMemberIDs?.length) ) { throw new ServerError('invalid_credentials'); } })(); const { nextThreadAncestry } = await promiseAll({ nextThreadAncestry: determineThreadAncestryPromise, confirmParentPermissionPromise, validateNewMembersPromise, }); if (nextThreadAncestry.containingThreadID !== oldContainingThreadID) { sqlUpdate.containing_thread_id = nextThreadAncestry.containingThreadID; } if (nextThreadAncestry.community !== oldCommunity) { if (!ignorePermissions) { throw new ServerError('invalid_parameters'); } sqlUpdate.community = nextThreadAncestry.community; } if (nextThreadAncestry.depth !== oldDepth) { sqlUpdate.depth = nextThreadAncestry.depth; } const updateQueryPromise = (async () => { if (Object.keys(sqlUpdate).length === 0) { return; } const { avatar: avatarUpdate, ...nonAvatarUpdates } = sqlUpdate; const updatePromises = []; if (Object.keys(nonAvatarUpdates).length > 0) { const nonAvatarUpdateQuery = SQL` UPDATE threads SET ${nonAvatarUpdates} WHERE id = ${request.threadID} `; updatePromises.push(dbQuery(nonAvatarUpdateQuery)); } if (avatarUpdate !== undefined) { const avatarUploadID = - avatar && avatar.type === 'image' ? avatar.uploadID : null; + avatar && (avatar.type === 'image' || avatar.type === 'encrypted_image') + ? avatar.uploadID + : null; const avatarUpdateQuery = SQL` START TRANSACTION; UPDATE uploads SET container = NULL WHERE container = ${request.threadID} AND ( ${avatarUploadID} IS NULL OR EXISTS ( SELECT 1 FROM uploads WHERE id = ${avatarUploadID} AND ${avatarUploadID} IS NOT NULL AND uploader = ${viewer.userID} AND container IS NULL AND thread IS NULL ) ); UPDATE uploads SET container = ${request.threadID} WHERE id = ${avatarUploadID} AND ${avatarUploadID} IS NOT NULL AND uploader = ${viewer.userID} AND container IS NULL AND thread IS NULL; UPDATE threads SET avatar = ${avatarUpdate} WHERE id = ${request.threadID} AND ( ${avatarUploadID} IS NULL OR EXISTS ( SELECT 1 FROM uploads WHERE id = ${avatarUploadID} AND ${avatarUploadID} IS NOT NULL AND uploader = ${viewer.userID} AND container = ${request.threadID} AND thread IS NULL ) ); COMMIT; `; updatePromises.push( dbQuery(avatarUpdateQuery, { multipleStatements: true }), ); } await Promise.all(updatePromises); })(); const updateRolesPromise = (async () => { if (rolesNeedUpdate) { await updateRoles(viewer, request.threadID, nextThreadType); } })(); const intermediatePromises = {}; intermediatePromises.updateQuery = updateQueryPromise; intermediatePromises.updateRoles = updateRolesPromise; if (newMemberIDs) { intermediatePromises.addMembersChangeset = (async () => { await Promise.all([updateQueryPromise, updateRolesPromise]); return await changeRole(request.threadID, newMemberIDs, null, { setNewMembersToUnread: true, }); })(); } const threadRootChanged = rolesNeedUpdate || nextParentThreadID !== oldParentThreadID; if (threadRootChanged) { intermediatePromises.recalculatePermissionsChangeset = (async () => { await Promise.all([updateQueryPromise, updateRolesPromise]); return await recalculateThreadPermissions(request.threadID); })(); } const { addMembersChangeset, recalculatePermissionsChangeset } = await promiseAll(intermediatePromises); const membershipRows = []; const relationshipChangeset = new RelationshipChangeset(); if (recalculatePermissionsChangeset) { const { membershipRows: recalculateMembershipRows, relationshipChangeset: recalculateRelationshipChangeset, } = recalculatePermissionsChangeset; membershipRows.push(...recalculateMembershipRows); relationshipChangeset.addAll(recalculateRelationshipChangeset); } let addedMemberIDs; if (addMembersChangeset) { const { membershipRows: addMembersMembershipRows, relationshipChangeset: addMembersRelationshipChangeset, } = addMembersChangeset; addedMemberIDs = addMembersMembershipRows .filter( row => row.operation === 'save' && row.threadID === request.threadID && Number(row.role) > 0, ) .map(row => row.userID); membershipRows.push(...addMembersMembershipRows); relationshipChangeset.addAll(addMembersRelationshipChangeset); } const changeset = { membershipRows, relationshipChangeset }; const { viewerUpdates } = await commitMembershipChangeset(viewer, changeset, { // This forces an update for this thread, // regardless of whether any membership rows are changed changedThreadIDs: Object.keys(sqlUpdate).length > 0 ? new Set([request.threadID]) : new Set(), // last_message will be updated automatically if we send a message, // so we only need to handle it here when we silence new messages updateMembershipsLastMessage: silenceMessages, }); let newMessageInfos = []; if (!silenceMessages) { const time = Date.now(); const messageDatas = []; for (const fieldName in changedFields) { const newValue = changedFields[fieldName]; messageDatas.push({ type: messageTypes.CHANGE_SETTINGS, threadID: request.threadID, creatorID: viewer.userID, time, field: fieldName, value: newValue, }); } if (addedMemberIDs && addedMemberIDs.length > 0) { messageDatas.push({ type: messageTypes.ADD_MEMBERS, threadID: request.threadID, creatorID: viewer.userID, time, addedUserIDs: addedMemberIDs, }); } newMessageInfos = await createMessages(viewer, messageDatas); } return { updatesResult: { newUpdates: viewerUpdates }, newMessageInfos }; } async function joinThread( viewer: Viewer, request: ServerThreadJoinRequest, ): Promise { if (!viewer.loggedIn) { throw new ServerError('not_logged_in'); } const permissionCheck = request.inviteLinkSecret ? checkIfInviteLinkIsValid(request.inviteLinkSecret, request.threadID) : checkThreadPermission( viewer, request.threadID, threadPermissions.JOIN_THREAD, ); const [isMember, hasPermission] = await Promise.all([ fetchViewerIsMember(viewer, request.threadID), permissionCheck, ]); if (!hasPermission) { throw new ServerError('invalid_parameters'); } const { calendarQuery } = request; if (isMember) { const response: ThreadJoinResult = { rawMessageInfos: [], truncationStatuses: {}, userInfos: {}, updatesResult: { newUpdates: [], }, }; return response; } if (calendarQuery) { const threadFilterIDs = filteredThreadIDs(calendarQuery.filters); if ( !threadFilterIDs || threadFilterIDs.size !== 1 || threadFilterIDs.values().next().value !== request.threadID ) { throw new ServerError('invalid_parameters'); } } const changeset = await changeRole(request.threadID, [viewer.userID], null); const membershipResult = await commitMembershipChangeset(viewer, changeset, { calendarQuery, }); if (request.inviteLinkSecret) { handleAsyncPromise(reportLinkUsage(request.inviteLinkSecret)); } const messageData = { type: messageTypes.JOIN_THREAD, threadID: request.threadID, creatorID: viewer.userID, time: Date.now(), }; const newMessages = await createMessages(viewer, [messageData]); return { rawMessageInfos: newMessages, truncationStatuses: {}, userInfos: membershipResult.userInfos, updatesResult: { newUpdates: membershipResult.viewerUpdates, }, }; } async function toggleMessagePinForThread( viewer: Viewer, request: ToggleMessagePinRequest, ): Promise { const { messageID, action } = request; const targetMessage = await fetchMessageInfoByID(viewer, messageID); if (!targetMessage) { throw new ServerError('invalid_parameters'); } const { threadID } = targetMessage; const fetchServerThreadInfosResult = await fetchServerThreadInfos({ threadID, }); const { threadInfos: rawThreadInfos } = rawThreadInfosFromServerThreadInfos( viewer, fetchServerThreadInfosResult, ); const rawThreadInfo = rawThreadInfos[threadID]; const canTogglePin = canToggleMessagePin(targetMessage, rawThreadInfo); if (!canTogglePin) { throw new ServerError('invalid_parameters'); } const pinnedValue = action === 'pin' ? 1 : 0; const pinTimeValue = action === 'pin' ? Date.now() : null; const pinnedCountValue = action === 'pin' ? 1 : -1; const query = SQL` UPDATE messages AS m, threads AS t SET m.pinned = ${pinnedValue}, m.pin_time = ${pinTimeValue}, t.pinned_count = t.pinned_count + ${pinnedCountValue} WHERE m.id = ${messageID} AND m.thread = ${threadID} AND t.id = ${threadID} AND m.pinned != ${pinnedValue} `; const [result] = await dbQuery(query); if (result.affectedRows === 0) { return { newMessageInfos: [], threadID, }; } const createMessagesAsync = async () => { const messageData = { type: messageTypes.TOGGLE_PIN, threadID, targetMessageID: messageID, action, pinnedContent: getPinnedContentFromMessage(targetMessage), creatorID: viewer.userID, time: Date.now(), }; const newMessageInfos = await createMessages(viewer, [messageData]); return newMessageInfos; }; const createUpdatesAsync = async () => { const { threadInfos: serverThreadInfos } = fetchServerThreadInfosResult; const time = Date.now(); const updates = []; for (const member of serverThreadInfos[threadID].members) { updates.push({ userID: member.id, time, threadID, type: updateTypes.UPDATE_THREAD, }); } await createUpdates(updates); }; const [newMessageInfos] = await Promise.all([ createMessagesAsync(), createUpdatesAsync(), ]); return { newMessageInfos, threadID, }; } export { updateRole, removeMembers, leaveThread, updateThread, joinThread, toggleMessagePinForThread, }; diff --git a/lib/components/base-edit-thread-avatar-provider.react.js b/lib/components/base-edit-thread-avatar-provider.react.js index 8bbe1abe8..2c567bad2 100644 --- a/lib/components/base-edit-thread-avatar-provider.react.js +++ b/lib/components/base-edit-thread-avatar-provider.react.js @@ -1,115 +1,118 @@ // @flow import * as React from 'react'; import { useChangeThreadSettings, changeThreadSettingsActionTypes, } from '../actions/thread-actions.js'; import { createLoadingStatusSelector } from '../selectors/loading-selectors.js'; import type { UpdateUserAvatarRequest } from '../types/avatar-types.js'; import type { LoadingStatus } from '../types/loading-types.js'; import type { UpdateThreadRequest } from '../types/thread-types.js'; import { useDispatchActionPromise } from '../utils/action-utils.js'; import { useSelector } from '../utils/redux-utils.js'; export type EditThreadAvatarContextType = { +updateThreadAvatarMediaUploadInProgress: (inProgress: boolean) => void, +threadAvatarSaveInProgress: boolean, +baseSetThreadAvatar: ( threadID: string, avatarRequest: UpdateUserAvatarRequest, ) => Promise, }; const EditThreadAvatarContext: React.Context = React.createContext(); type Props = { +activeThreadID: string, +children: React.Node, }; function BaseEditThreadAvatarProvider(props: Props): React.Node { const { activeThreadID, children } = props; const updateThreadAvatarLoadingStatus: LoadingStatus = useSelector( createLoadingStatusSelector( changeThreadSettingsActionTypes, `${changeThreadSettingsActionTypes.started}:${activeThreadID}:avatar`, ), ); const dispatchActionPromise = useDispatchActionPromise(); const changeThreadSettingsCall = useChangeThreadSettings(); const [ threadAvatarMediaUploadInProgress, setThreadAvatarMediaUploadInProgress, ] = React.useState<$ReadOnlySet>(new Set()); const updateThreadAvatarMediaUploadInProgress = React.useCallback( (inProgress: boolean) => setThreadAvatarMediaUploadInProgress(prevState => { const updatedSet = new Set(prevState); if (inProgress) { updatedSet.add(activeThreadID); } else { updatedSet.delete(activeThreadID); } return updatedSet; }), [activeThreadID], ); const threadAvatarSaveInProgress = threadAvatarMediaUploadInProgress.has(activeThreadID) || updateThreadAvatarLoadingStatus === 'loading'; // NOTE: Do NOT consume `baseSetThreadAvatar` directly. // Use platform-specific `[web/native]SetThreadAvatar` instead. const baseSetThreadAvatar = React.useCallback( async (threadID: string, avatarRequest: UpdateUserAvatarRequest) => { const updateThreadRequest: UpdateThreadRequest = { threadID, changes: { avatar: avatarRequest, }, }; const action = changeThreadSettingsActionTypes.started; - if (avatarRequest.type === 'image') { + if ( + avatarRequest.type === 'image' || + avatarRequest.type === 'encrypted_image' + ) { updateThreadAvatarMediaUploadInProgress(false); } const promise = changeThreadSettingsCall(updateThreadRequest); dispatchActionPromise(changeThreadSettingsActionTypes, promise, { customKeyName: `${action}:${threadID}:avatar`, }); await promise; }, [ changeThreadSettingsCall, dispatchActionPromise, updateThreadAvatarMediaUploadInProgress, ], ); const context = React.useMemo( () => ({ updateThreadAvatarMediaUploadInProgress, threadAvatarSaveInProgress, baseSetThreadAvatar, }), [ updateThreadAvatarMediaUploadInProgress, threadAvatarSaveInProgress, baseSetThreadAvatar, ], ); return ( {children} ); } export { EditThreadAvatarContext, BaseEditThreadAvatarProvider }; diff --git a/lib/components/edit-user-avatar-provider.react.js b/lib/components/edit-user-avatar-provider.react.js index 11408f69f..750a642b5 100644 --- a/lib/components/edit-user-avatar-provider.react.js +++ b/lib/components/edit-user-avatar-provider.react.js @@ -1,127 +1,127 @@ // @flow import * as React from 'react'; import { updateUserAvatar, updateUserAvatarActionTypes, } from '../actions/user-actions.js'; import { createLoadingStatusSelector } from '../selectors/loading-selectors.js'; import type { UpdateUserAvatarRequest } from '../types/avatar-types.js'; import type { SetState } from '../types/hook-types.js'; import type { LoadingStatus } from '../types/loading-types.js'; import type { NativeMediaSelection } from '../types/media-types.js'; import { useDispatchActionPromise, useServerCall, } from '../utils/action-utils.js'; import { useSelector } from '../utils/redux-utils.js'; export type UserAvatarSelection = | { +needsUpload: true, +mediaSelection: NativeMediaSelection } | { +needsUpload: false, +updateUserAvatarRequest: UpdateUserAvatarRequest }; type RegistrationMode = | { +registrationMode: 'off' } | { +registrationMode: 'on', +successCallback: UserAvatarSelection => mixed, }; const registrationModeOff = { registrationMode: 'off' }; export type EditUserAvatarContextType = { +setUserAvatarMediaUploadInProgress: SetState, +userAvatarSaveInProgress: boolean, +baseSetUserAvatar: (avatarRequest: UpdateUserAvatarRequest) => Promise, +setRegistrationMode: (registrationMode: RegistrationMode) => void, +getRegistrationModeEnabled: () => boolean, +getRegistrationModeSuccessCallback: () => ?(UserAvatarSelection) => mixed, }; const EditUserAvatarContext: React.Context = React.createContext(); const updateUserAvatarLoadingStatusSelector = createLoadingStatusSelector( updateUserAvatarActionTypes, ); type Props = { +children: React.Node, }; function EditUserAvatarProvider(props: Props): React.Node { const { children } = props; const registrationModeRef = React.useRef(registrationModeOff); const dispatchActionPromise = useDispatchActionPromise(); const updateUserAvatarCall = useServerCall(updateUserAvatar); const [userAvatarMediaUploadInProgress, setUserAvatarMediaUploadInProgress] = React.useState(false); const updateUserAvatarLoadingStatus: LoadingStatus = useSelector( updateUserAvatarLoadingStatusSelector, ); const userAvatarSaveInProgress = userAvatarMediaUploadInProgress || updateUserAvatarLoadingStatus === 'loading'; // NOTE: Do **NOT** consume `baseSetUserAvatar` directly. // Use platform-specific `[web/native]SetUserAvatar` instead. const baseSetUserAvatar = React.useCallback( async (request: UpdateUserAvatarRequest) => { const promise = (async () => { - if (request.type === 'image') { + if (request.type === 'image' || request.type === 'encrypted_image') { setUserAvatarMediaUploadInProgress(false); } return await updateUserAvatarCall(request); })(); dispatchActionPromise(updateUserAvatarActionTypes, promise); await promise; }, [dispatchActionPromise, updateUserAvatarCall], ); const setRegistrationMode = React.useCallback((mode: RegistrationMode) => { registrationModeRef.current = mode; }, []); const getRegistrationModeEnabled = React.useCallback( () => registrationModeRef.current.registrationMode === 'on', [], ); const getRegistrationModeSuccessCallback = React.useCallback( () => registrationModeRef.current.registrationMode === 'on' ? registrationModeRef.current.successCallback : null, [], ); const context = React.useMemo( () => ({ setUserAvatarMediaUploadInProgress, userAvatarSaveInProgress, baseSetUserAvatar, setRegistrationMode, getRegistrationModeEnabled, getRegistrationModeSuccessCallback, }), [ setUserAvatarMediaUploadInProgress, userAvatarSaveInProgress, baseSetUserAvatar, setRegistrationMode, getRegistrationModeEnabled, getRegistrationModeSuccessCallback, ], ); return ( {children} ); } export { EditUserAvatarContext, EditUserAvatarProvider }; diff --git a/native/account/registration/avatar-selection.react.js b/native/account/registration/avatar-selection.react.js index d230475bc..0c7ae34dc 100644 --- a/native/account/registration/avatar-selection.react.js +++ b/native/account/registration/avatar-selection.react.js @@ -1,203 +1,204 @@ // @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 RegistrationButton from './registration-button.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 EditUserAvatar from '../../avatars/edit-user-avatar.react.js'; import { useCurrentLeafRouteName } from '../../navigation/nav-selectors.js'; import { type NavigationRoute, RegistrationTermsRouteName, AvatarSelectionRouteName, EmojiAvatarSelectionRouteName, RegistrationUserAvatarCameraModalRouteName, } from '../../navigation/route-names.js'; import { useStyles } from '../../themes/colors.js'; export type AvatarSelectionParams = { +userSelections: { +coolOrNerdMode: CoolOrNerdMode, +keyserverURL: string, +accountSelection: AccountSelection, }, }; 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 !== 'image' && + clientRequest.type !== 'encrypted_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, }; 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;