diff --git a/keyserver/src/fetchers/upload-fetchers.js b/keyserver/src/fetchers/upload-fetchers.js index fd8e964ee..3a6491a35 100644 --- a/keyserver/src/fetchers/upload-fetchers.js +++ b/keyserver/src/fetchers/upload-fetchers.js @@ -1,408 +1,419 @@ // @flow import ip from 'internal-ip'; import _keyBy from 'lodash/fp/keyBy.js'; import type { Media, Image, EncryptedImage } from 'lib/types/media-types.js'; import { messageTypes } from 'lib/types/message-types-enum.js'; import type { MediaMessageServerDBContent } from 'lib/types/messages/media.js'; import { getUploadIDsFromMediaMessageServerDBContents } from 'lib/types/messages/media.js'; import { threadPermissions } from 'lib/types/thread-permission-types.js'; import type { ThreadFetchMediaResult, ThreadFetchMediaRequest, } from 'lib/types/thread-types.js'; import { makeBlobServiceURI } from 'lib/utils/blob-service.js'; import { isDev } from 'lib/utils/dev-utils.js'; import { ServerError } from 'lib/utils/errors.js'; import { dbQuery, SQL } from '../database/database.js'; import type { Viewer } from '../session/viewer.js'; import { getAndAssertKeyserverURLFacts } from '../utils/urls.js'; type UploadInfo = { content: Buffer, mime: string, }; async function fetchUpload( viewer: Viewer, id: string, secret: string, ): Promise { const query = SQL` SELECT content, mime, extra FROM uploads WHERE id = ${id} AND secret = ${secret} `; const [result] = await dbQuery(query); if (result.length === 0) { throw new ServerError('invalid_parameters'); } const [row] = result; const { content, mime, extra } = row; const { blobHash } = JSON.parse(extra); if (blobHash) { throw new ServerError('resource_unavailable'); } return { content, mime }; } async function fetchUploadChunk( id: string, secret: string, pos: number, len: number, ): Promise { // We use pos + 1 because SQL is 1-indexed whereas js is 0-indexed const query = SQL` SELECT SUBSTRING(content, ${pos + 1}, ${len}) AS content, mime, extra FROM uploads WHERE id = ${id} AND secret = ${secret} `; const [result] = await dbQuery(query); if (result.length === 0) { throw new ServerError('invalid_parameters'); } const [row] = result; const { content, mime, extra } = row; if (extra) { const { blobHash } = JSON.parse(extra); if (blobHash) { throw new ServerError('resource_unavailable'); } } return { content, mime, }; } // Returns total size in bytes. async function getUploadSize(id: string, secret: string): Promise { const query = SQL` SELECT LENGTH(content) AS length, extra FROM uploads WHERE id = ${id} AND secret = ${secret} `; const [result] = await dbQuery(query); if (result.length === 0) { throw new ServerError('invalid_parameters'); } const [row] = result; const { length, extra } = row; if (extra) { const { blobHash } = JSON.parse(extra); if (blobHash) { throw new ServerError('resource_unavailable'); } } return length; } function getUploadURL(id: string, secret: string): string { const { baseDomain, basePath } = getAndAssertKeyserverURLFacts(); const uploadPath = `${basePath}upload/${id}/${secret}`; if (isDev) { const ipV4 = ip.v4.sync() || 'localhost'; const port = parseInt(process.env.PORT, 10) || 3000; return `http://${ipV4}:${port}${uploadPath}`; } return `${baseDomain}${uploadPath}`; } function makeUploadURI(blobHash: ?string, id: string, secret: string): string { if (blobHash) { return makeBlobServiceURI(blobHash); } return getUploadURL(id, secret); } function imagesFromRow(row: Object): Image | EncryptedImage { const uploadExtra = JSON.parse(row.uploadExtra); const { width, height, blobHash, thumbHash } = uploadExtra; const { uploadType: type, uploadSecret: secret } = row; const id = row.uploadID.toString(); const dimensions = { width, height }; const uri = makeUploadURI(blobHash, id, secret); const isEncrypted = !!uploadExtra.encryptionKey; if (type !== 'photo') { throw new ServerError('invalid_parameters'); } if (!isEncrypted) { return { id, type: 'photo', uri, dimensions, thumbHash }; } return { id, type: 'encrypted_photo', blobURI: uri, dimensions, thumbHash, encryptionKey: uploadExtra.encryptionKey, }; } -async function fetchImages( +// This function technically fetches all kinds of unassigned media, +// but it's only called by legacy clients that only support images +async function fetchUnassignedImages( viewer: Viewer, mediaIDs: $ReadOnlyArray, ): Promise<$ReadOnlyArray> { const query = SQL` SELECT id AS uploadID, secret AS uploadSecret, type AS uploadType, extra AS uploadExtra FROM uploads - WHERE id IN (${mediaIDs}) AND uploader = ${viewer.id} AND container IS NULL + WHERE id IN (${mediaIDs}) + AND uploader = ${viewer.id} + AND container IS NULL + AND user_container IS NULL `; const [result] = await dbQuery(query); return result.map(imagesFromRow); } async function fetchMediaForThread( viewer: Viewer, request: ThreadFetchMediaRequest, ): Promise { const visibleExtractString = `$.${threadPermissions.VISIBLE}.value`; const query = SQL` SELECT content.photo AS uploadID, u.secret AS uploadSecret, u.type AS uploadType, u.extra AS uploadExtra, u.container, u.creation_time, NULL AS thumbnailID, NULL AS thumbnailUploadSecret, NULL AS thumbnailUploadExtra FROM messages m LEFT JOIN JSON_TABLE( m.content, "$[*]" COLUMNS(photo INT PATH "$") ) content ON 1 LEFT JOIN uploads u ON u.id = content.photo LEFT JOIN memberships mm ON mm.thread = ${request.threadID} AND mm.user = ${viewer.id} WHERE m.thread = ${request.threadID} AND m.type = ${messageTypes.IMAGES} AND JSON_EXTRACT(mm.permissions, ${visibleExtractString}) IS TRUE UNION SELECT content.media AS uploadID, uv.secret AS uploadSecret, uv.type AS uploadType, uv.extra AS uploadExtra, uv.container, uv.creation_time, content.thumbnail AS thumbnailID, ut.secret AS thumbnailUploadSecret, ut.extra AS thumbnailUploadExtra FROM messages m LEFT JOIN JSON_TABLE( m.content, "$[*]" COLUMNS( media INT PATH "$.uploadID", thumbnail INT PATH "$.thumbnailUploadID" ) ) content ON 1 LEFT JOIN uploads uv ON uv.id = content.media LEFT JOIN uploads ut ON ut.id = content.thumbnail LEFT JOIN memberships mm ON mm.thread = ${request.threadID} AND mm.user = ${viewer.id} WHERE m.thread = ${request.threadID} AND m.type = ${messageTypes.MULTIMEDIA} AND JSON_EXTRACT(mm.permissions, ${visibleExtractString}) IS TRUE ORDER BY creation_time DESC LIMIT ${request.limit} OFFSET ${request.offset} `; const [uploads] = await dbQuery(query); const media = uploads.map(upload => { const { uploadID, uploadType, uploadSecret, uploadExtra } = upload; const { width, height, encryptionKey, blobHash, thumbHash } = JSON.parse(uploadExtra); const dimensions = { width, height }; const uri = makeUploadURI(blobHash, uploadID, uploadSecret); if (uploadType === 'photo') { if (encryptionKey) { return { type: 'encrypted_photo', id: uploadID.toString(), blobURI: uri, encryptionKey, dimensions, thumbHash, }; } return { type: 'photo', id: uploadID.toString(), uri, dimensions, thumbHash, }; } const { thumbnailUploadSecret, thumbnailUploadExtra } = upload; const thumbnailID = upload.thumbnailID?.toString(); const { encryptionKey: thumbnailEncryptionKey, blobHash: thumbnailBlobHash, thumbHash: thumbnailThumbHash, } = JSON.parse(thumbnailUploadExtra); const thumbnailURI = makeUploadURI( thumbnailBlobHash, thumbnailID, thumbnailUploadSecret, ); if (encryptionKey) { return { type: 'encrypted_video', id: uploadID.toString(), blobURI: uri, encryptionKey, dimensions, thumbnailID, thumbnailBlobURI: thumbnailURI, thumbnailEncryptionKey, thumbnailThumbHash, }; } return { type: 'video', id: uploadID.toString(), uri, dimensions, thumbnailID, thumbnailURI, thumbnailThumbHash, }; }); return { media }; } -async function fetchUploadsForMessage( +async function fetchUnassignedUploadsForMessage( viewer: Viewer, mediaMessageContents: $ReadOnlyArray, ): Promise<$ReadOnlyArray> { const uploadIDs = getUploadIDsFromMediaMessageServerDBContents(mediaMessageContents); const query = SQL` SELECT id AS uploadID, secret AS uploadSecret, type AS uploadType, extra AS uploadExtra FROM uploads - WHERE id IN (${uploadIDs}) AND uploader = ${viewer.id} AND container IS NULL + WHERE id IN (${uploadIDs}) + AND uploader = ${viewer.id} + AND container IS NULL + AND user_container IS NULL `; const [uploads] = await dbQuery(query); return uploads; } -async function fetchMediaFromMediaMessageContent( +async function fetchUnassignedMediaFromMediaMessageContent( viewer: Viewer, mediaMessageContents: $ReadOnlyArray, ): Promise<$ReadOnlyArray> { - const uploads = await fetchUploadsForMessage(viewer, mediaMessageContents); + const uploads = await fetchUnassignedUploadsForMessage( + viewer, + mediaMessageContents, + ); return constructMediaFromMediaMessageContentsAndUploadRows( mediaMessageContents, uploads, ); } function constructMediaFromMediaMessageContentsAndUploadRows( mediaMessageContents: $ReadOnlyArray, uploadRows: $ReadOnlyArray, ): $ReadOnlyArray { const uploadMap = _keyBy('uploadID')(uploadRows); const media: Media[] = []; for (const mediaMessageContent of mediaMessageContents) { const primaryUploadID = mediaMessageContent.uploadID; const primaryUpload = uploadMap[primaryUploadID]; const uploadExtra = JSON.parse(primaryUpload.uploadExtra); const { width, height, loop, blobHash, encryptionKey, thumbHash } = uploadExtra; const dimensions = { width, height }; const primaryUploadURI = makeUploadURI( blobHash, primaryUploadID, primaryUpload.uploadSecret, ); if (mediaMessageContent.type === 'photo') { if (encryptionKey) { media.push({ type: 'encrypted_photo', id: primaryUploadID, blobURI: primaryUploadURI, encryptionKey, dimensions, thumbHash, }); } else { media.push({ type: 'photo', id: primaryUploadID, uri: primaryUploadURI, dimensions, thumbHash, }); } continue; } const thumbnailUploadID = mediaMessageContent.thumbnailUploadID; const thumbnailUpload = uploadMap[thumbnailUploadID]; const thumbnailUploadExtra = JSON.parse(thumbnailUpload.uploadExtra); const { blobHash: thumbnailBlobHash, thumbHash: thumbnailThumbHash } = thumbnailUploadExtra; const thumbnailUploadURI = makeUploadURI( thumbnailBlobHash, thumbnailUploadID, thumbnailUpload.uploadSecret, ); if (encryptionKey) { const video = { type: 'encrypted_video', id: primaryUploadID, blobURI: primaryUploadURI, encryptionKey, dimensions, thumbnailID: thumbnailUploadID, thumbnailBlobURI: thumbnailUploadURI, thumbnailEncryptionKey: thumbnailUploadExtra.encryptionKey, thumbnailThumbHash, }; media.push(loop ? { ...video, loop } : video); } else { const video = { type: 'video', id: primaryUploadID, uri: primaryUploadURI, dimensions, thumbnailID: thumbnailUploadID, thumbnailURI: thumbnailUploadURI, thumbnailThumbHash, }; media.push(loop ? { ...video, loop } : video); } } return media; } export { fetchUpload, fetchUploadChunk, getUploadSize, getUploadURL, makeUploadURI, imagesFromRow, - fetchImages, + fetchUnassignedImages, fetchMediaForThread, - fetchMediaFromMediaMessageContent, + fetchUnassignedMediaFromMediaMessageContent, constructMediaFromMediaMessageContentsAndUploadRows, }; diff --git a/keyserver/src/responders/message-responders.js b/keyserver/src/responders/message-responders.js index bafa54cb1..ef78d8d5f 100644 --- a/keyserver/src/responders/message-responders.js +++ b/keyserver/src/responders/message-responders.js @@ -1,423 +1,426 @@ // @flow import invariant from 'invariant'; import t, { type TInterface, type TUnion } from 'tcomb'; import { onlyOneEmojiRegex } from 'lib/shared/emojis.js'; import { createMediaMessageData, trimMessage, } from 'lib/shared/message-utils.js'; import { relationshipBlockedInEitherDirection } from 'lib/shared/relationship-utils.js'; import type { Media } from 'lib/types/media-types.js'; import { messageTypes } from 'lib/types/message-types-enum.js'; import { type SendTextMessageRequest, type SendMultimediaMessageRequest, type SendReactionMessageRequest, type SendEditMessageRequest, type FetchMessageInfosResponse, type FetchMessageInfosRequest, defaultNumberPerThread, type SendMessageResponse, type SendEditMessageResponse, type FetchPinnedMessagesRequest, type FetchPinnedMessagesResult, type SearchMessagesResponse, type SearchMessagesRequest, } from 'lib/types/message-types.js'; import type { EditMessageData } from 'lib/types/messages/edit.js'; import type { ReactionMessageData } from 'lib/types/messages/reaction.js'; import type { TextMessageData } from 'lib/types/messages/text.js'; import { threadPermissions } from 'lib/types/thread-permission-types.js'; import { ServerError } from 'lib/utils/errors.js'; import { values } from 'lib/utils/objects.js'; import { tRegex, tShape, tMediaMessageMedia, tID, } from 'lib/utils/validation-utils.js'; import createMessages from '../creators/message-creator.js'; import { fetchMessageInfos, fetchMessageInfoForLocalID, fetchMessageInfoByID, fetchThreadMessagesCount, fetchPinnedMessageInfos, searchMessagesInSingleChat, } from '../fetchers/message-fetchers.js'; import { fetchServerThreadInfos } from '../fetchers/thread-fetchers.js'; import { checkThreadPermission } from '../fetchers/thread-permission-fetchers.js'; import { - fetchImages, - fetchMediaFromMediaMessageContent, + fetchUnassignedImages, + fetchUnassignedMediaFromMediaMessageContent, } from '../fetchers/upload-fetchers.js'; import { fetchKnownUserInfos } from '../fetchers/user-fetchers.js'; import type { Viewer } from '../session/viewer.js'; import { assignImages, assignMessageContainerToMedia, } from '../updaters/upload-updaters.js'; export const sendTextMessageRequestInputValidator: TInterface = tShape({ threadID: tID, localID: t.maybe(t.String), text: t.String, sidebarCreation: t.maybe(t.Boolean), }); async function textMessageCreationResponder( viewer: Viewer, request: SendTextMessageRequest, ): Promise { const { threadID, localID, text: rawText, sidebarCreation } = request; const text = trimMessage(rawText); if (!text) { throw new ServerError('invalid_parameters'); } const hasPermission = await checkThreadPermission( viewer, threadID, threadPermissions.VOICED, ); if (!hasPermission) { throw new ServerError('invalid_parameters'); } let messageData: TextMessageData = { type: messageTypes.TEXT, threadID, creatorID: viewer.id, time: Date.now(), text, }; if (localID) { messageData = { ...messageData, localID }; } if (sidebarCreation) { const numMessages = await fetchThreadMessagesCount(threadID); if (numMessages === 2) { // sidebarCreation is set below to prevent double notifs from a sidebar // creation. We expect precisely two messages to appear before a // sidebarCreation: a SIDEBAR_SOURCE and a CREATE_SIDEBAR. If two users // attempt to create a sidebar at the same time, then both clients will // attempt to set sidebarCreation here, but we only want to suppress // notifs for the client that won the race. messageData = { ...messageData, sidebarCreation }; } } const rawMessageInfos = await createMessages(viewer, [messageData]); return { newMessageInfo: rawMessageInfos[0] }; } export const fetchMessageInfosRequestInputValidator: TInterface = tShape({ cursors: t.dict(tID, t.maybe(tID)), numberPerThread: t.maybe(t.Number), }); async function messageFetchResponder( viewer: Viewer, request: FetchMessageInfosRequest, ): Promise { const response = await fetchMessageInfos( viewer, { threadCursors: request.cursors }, request.numberPerThread ? request.numberPerThread : defaultNumberPerThread, ); return { ...response, userInfos: {}, }; } export const sendMultimediaMessageRequestInputValidator: TUnion = t.union([ // This option is only used for messageTypes.IMAGES tShape({ threadID: tID, localID: t.String, sidebarCreation: t.maybe(t.Boolean), mediaIDs: t.list(tID), }), tShape({ threadID: tID, localID: t.String, sidebarCreation: t.maybe(t.Boolean), mediaMessageContents: t.list(tMediaMessageMedia), }), ]); async function multimediaMessageCreationResponder( viewer: Viewer, request: SendMultimediaMessageRequest, ): Promise { if ( (request.mediaIDs && request.mediaIDs.length === 0) || (request.mediaMessageContents && request.mediaMessageContents.length === 0) ) { throw new ServerError('invalid_parameters'); } const { threadID, localID, sidebarCreation } = request; const hasPermission = await checkThreadPermission( viewer, threadID, threadPermissions.VOICED, ); if (!hasPermission) { throw new ServerError('invalid_parameters'); } const existingMessageInfoPromise = fetchMessageInfoForLocalID( viewer, localID, ); const mediaPromise: Promise<$ReadOnlyArray> = request.mediaIDs - ? fetchImages(viewer, request.mediaIDs) - : fetchMediaFromMediaMessageContent(viewer, request.mediaMessageContents); + ? fetchUnassignedImages(viewer, request.mediaIDs) + : fetchUnassignedMediaFromMediaMessageContent( + viewer, + request.mediaMessageContents, + ); const [existingMessageInfo, media] = await Promise.all([ existingMessageInfoPromise, mediaPromise, ]); if (media.length === 0 && !existingMessageInfo) { throw new ServerError('invalid_parameters'); } // We use the MULTIMEDIA type for encrypted photos const containsEncryptedMedia = media.some( m => m.type === 'encrypted_photo' || m.type === 'encrypted_video', ); const messageData = createMediaMessageData( { localID, threadID, creatorID: viewer.id, media, sidebarCreation, }, { forceMultimediaMessageType: containsEncryptedMedia }, ); const [newMessageInfo] = await createMessages(viewer, [messageData]); const { id } = newMessageInfo; invariant( id !== null && id !== undefined, 'serverID should be set in createMessages result', ); if (request.mediaIDs) { await assignImages(viewer, request.mediaIDs, id, threadID); } else { await assignMessageContainerToMedia( viewer, request.mediaMessageContents, id, threadID, ); } return { newMessageInfo }; } export const sendReactionMessageRequestInputValidator: TInterface = tShape({ threadID: tID, localID: t.maybe(t.String), targetMessageID: tID, reaction: tRegex(onlyOneEmojiRegex), action: t.enums.of(['add_reaction', 'remove_reaction']), }); async function reactionMessageCreationResponder( viewer: Viewer, request: SendReactionMessageRequest, ): Promise { const { threadID, localID, targetMessageID, reaction, action } = request; if (!targetMessageID || !reaction) { throw new ServerError('invalid_parameters'); } const targetMessageInfo = await fetchMessageInfoByID(viewer, targetMessageID); if (!targetMessageInfo || !targetMessageInfo.id) { throw new ServerError('invalid_parameters'); } const [serverThreadInfos, hasPermission, targetMessageUserInfos] = await Promise.all([ fetchServerThreadInfos({ threadID }), checkThreadPermission( viewer, threadID, threadPermissions.REACT_TO_MESSAGE, ), fetchKnownUserInfos(viewer, [targetMessageInfo.creatorID]), ]); const targetMessageThreadInfo = serverThreadInfos.threadInfos[threadID]; if (targetMessageThreadInfo.sourceMessageID === targetMessageID) { throw new ServerError('invalid_parameters'); } const targetMessageCreator = targetMessageUserInfos[targetMessageInfo.creatorID]; const targetMessageCreatorRelationship = targetMessageCreator?.relationshipStatus; const creatorRelationshipHasBlock = targetMessageCreatorRelationship && relationshipBlockedInEitherDirection(targetMessageCreatorRelationship); if (!hasPermission || creatorRelationshipHasBlock) { throw new ServerError('invalid_parameters'); } let messageData: ReactionMessageData = { type: messageTypes.REACTION, threadID, creatorID: viewer.id, time: Date.now(), targetMessageID, reaction, action, }; if (localID) { messageData = { ...messageData, localID }; } const rawMessageInfos = await createMessages(viewer, [messageData]); return { newMessageInfo: rawMessageInfos[0] }; } export const editMessageRequestInputValidator: TInterface = tShape({ targetMessageID: tID, text: t.String, }); async function editMessageCreationResponder( viewer: Viewer, request: SendEditMessageRequest, ): Promise { const { targetMessageID, text: rawText } = request; const text = trimMessage(rawText); if (!targetMessageID || !text) { throw new ServerError('invalid_parameters'); } const targetMessageInfo = await fetchMessageInfoByID(viewer, targetMessageID); if (!targetMessageInfo || !targetMessageInfo.id) { throw new ServerError('invalid_parameters'); } if (targetMessageInfo.type !== messageTypes.TEXT) { throw new ServerError('invalid_parameters'); } const { threadID } = targetMessageInfo; const [serverThreadInfos, hasPermission, rawSidebarThreadInfos] = await Promise.all([ fetchServerThreadInfos({ threadID }), checkThreadPermission(viewer, threadID, threadPermissions.EDIT_MESSAGE), fetchServerThreadInfos({ parentThreadID: threadID, sourceMessageID: targetMessageID, }), ]); const targetMessageThreadInfo = serverThreadInfos.threadInfos[threadID]; if (targetMessageThreadInfo.sourceMessageID === targetMessageID) { // We are editing first message of the sidebar // If client wants to do that it sends id of the sourceMessage instead throw new ServerError('invalid_parameters'); } if (!hasPermission) { throw new ServerError('invalid_parameters'); } if (targetMessageInfo.creatorID !== viewer.id) { throw new ServerError('invalid_parameters'); } const time = Date.now(); const messagesData = []; let messageData: EditMessageData = { type: messageTypes.EDIT_MESSAGE, threadID, creatorID: viewer.id, time, targetMessageID, text, }; messagesData.push(messageData); const sidebarThreadValues = values(rawSidebarThreadInfos.threadInfos); for (const sidebarThreadValue of sidebarThreadValues) { if (sidebarThreadValue && sidebarThreadValue.id) { messageData = { type: messageTypes.EDIT_MESSAGE, threadID: sidebarThreadValue.id, creatorID: viewer.id, time, targetMessageID, text: text, }; messagesData.push(messageData); } } const newMessageInfos = await createMessages(viewer, messagesData); return { newMessageInfos }; } export const fetchPinnedMessagesResponderInputValidator: TInterface = tShape({ threadID: tID, }); async function fetchPinnedMessagesResponder( viewer: Viewer, request: FetchPinnedMessagesRequest, ): Promise { return await fetchPinnedMessageInfos(viewer, request); } export const searchMessagesResponderInputValidator: TInterface = tShape({ query: t.String, threadID: tID, cursor: t.maybe(tID), }); async function searchMessagesResponder( viewer: Viewer, request: SearchMessagesRequest, ): Promise { return await searchMessagesInSingleChat( request.query, request.threadID, viewer, request.cursor, ); } export { textMessageCreationResponder, messageFetchResponder, multimediaMessageCreationResponder, reactionMessageCreationResponder, editMessageCreationResponder, fetchPinnedMessagesResponder, searchMessagesResponder, };