diff --git a/keyserver/src/creators/message-report-creator.js b/keyserver/src/creators/message-report-creator.js new file mode 100644 index 000000000..b1d827766 --- /dev/null +++ b/keyserver/src/creators/message-report-creator.js @@ -0,0 +1,141 @@ +// @flow + +import bots from 'lib/facts/bots'; +import { createMessageQuote } from 'lib/shared/message-utils'; +import { type MessageReportCreationRequest } from 'lib/types/message-report-types'; +import { messageTypes } from 'lib/types/message-types'; +import type { RawMessageInfo } from 'lib/types/message-types'; +import type { ServerThreadInfo } from 'lib/types/thread-types'; +import { ServerError } from 'lib/utils/errors'; +import { promiseAll } from 'lib/utils/promises'; + +import { createCommbotThread } from '../bots/commbot'; +import { fetchMessageInfoByID } from '../fetchers/message-fetchers'; +import { + fetchPersonalThreadID, + serverThreadInfoFromMessageInfo, +} from '../fetchers/thread-fetchers'; +import { + fetchUsername, + fetchKeyserverAdminID, +} from '../fetchers/user-fetchers'; +import type { Viewer } from '../session/viewer'; +import createMessages from './message-creator'; + +const { commbot } = bots; + +type MessageReportData = { + +reportedMessageText: ?string, + +reporterUsername: ?string, + +commbotThreadID: string, + +reportedThread: ?ServerThreadInfo, + +reportedMessageAuthor: ?string, +}; + +async function createMessageReport( + viewer: Viewer, + request: MessageReportCreationRequest, +): Promise { + const { + reportedMessageText, + reporterUsername, + commbotThreadID, + reportedThread, + reportedMessageAuthor, + } = await fetchMessageReportData(viewer, request); + + const reportMessage = getCommbotMessage( + reporterUsername, + reportedMessageAuthor, + reportedThread?.name, + reportedMessageText, + ); + const time = Date.now(); + const result = await createMessages(viewer, [ + { + type: messageTypes.TEXT, + threadID: commbotThreadID, + creatorID: commbot.userID, + time, + text: reportMessage, + }, + ]); + if (result.length === 0) { + throw new ServerError('message_report_failed'); + } + return result; +} + +async function fetchMessageReportData( + viewer: Viewer, + request: MessageReportCreationRequest, +): Promise { + const keyserverAdminIDPromise = fetchKeyserverAdminID(); + const reportedMessagePromise = fetchMessageInfoByID( + viewer, + request.messageID, + ); + const promises = {}; + + promises.viewerUsername = fetchUsername(viewer.id); + + const keyserverAdminID = await keyserverAdminIDPromise; + if (!keyserverAdminID) { + throw new ServerError('keyserver_admin_not_found'); + } + promises.commbotThreadID = getCommbotThreadID(keyserverAdminID); + + const reportedMessage = await reportedMessagePromise; + + if (reportedMessage) { + promises.reportedThread = serverThreadInfoFromMessageInfo(reportedMessage); + } + + const reportedMessageAuthorID = reportedMessage?.creatorID; + if (reportedMessageAuthorID) { + promises.reportedMessageAuthor = fetchUsername(reportedMessageAuthorID); + } + + const reportedMessageText = + reportedMessage?.type === 0 ? reportedMessage.text : null; + + const { + viewerUsername, + commbotThreadID, + reportedThread, + reportedMessageAuthor, + } = await promiseAll(promises); + + return { + reportedMessageText, + reporterUsername: viewerUsername, + commbotThreadID, + reportedThread, + reportedMessageAuthor, + }; +} + +async function getCommbotThreadID(userID: string): Promise { + const commbotThreadID = await fetchPersonalThreadID(userID, commbot.userID); + return commbotThreadID ?? createCommbotThread(userID); +} + +function getCommbotMessage( + reporterUsername: ?string, + messageAuthorUsername: ?string, + threadName: ?string, + message: ?string, +): string { + reporterUsername = reporterUsername ?? '[null]'; + const messageAuthor = messageAuthorUsername + ? `${messageAuthorUsername}’s` + : 'this'; + const thread = threadName ? `chat titled "${threadName}"` : 'chat'; + const reply = message ? createMessageQuote(message) : 'non-text message'; + return ( + `${reporterUsername} reported ${messageAuthor} message in ${thread}\n` + + reply + ); +} + +export default createMessageReport; diff --git a/keyserver/src/fetchers/thread-fetchers.js b/keyserver/src/fetchers/thread-fetchers.js index 204c9694d..f1756b31e 100644 --- a/keyserver/src/fetchers/thread-fetchers.js +++ b/keyserver/src/fetchers/thread-fetchers.js @@ -1,252 +1,262 @@ // @flow import { getAllThreadPermissions } from 'lib/permissions/thread-permissions'; import { rawThreadInfoFromServerThreadInfo, getContainingThreadID, getCommunity, } from 'lib/shared/thread-utils'; import { hasMinCodeVersion } from 'lib/shared/version-utils'; +import type { RawMessageInfo, MessageInfo } from 'lib/types/message-types'; import { threadTypes, type ThreadType, type RawThreadInfo, type ServerThreadInfo, } from 'lib/types/thread-types'; import { ServerError } from 'lib/utils/errors'; import { dbQuery, SQL } from '../database/database'; import type { SQLStatementType } from '../database/types'; import type { Viewer } from '../session/viewer'; type FetchServerThreadInfosResult = { +threadInfos: { +[id: string]: ServerThreadInfo }, }; async function fetchServerThreadInfos( condition?: SQLStatementType, ): Promise { const whereClause = condition ? SQL`WHERE `.append(condition) : ''; const query = 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.default_role, t.source_message, t.replies_count, r.id AS role, r.name AS role_name, r.permissions AS role_permissions, m.user, m.permissions, m.subscription, m.last_read_message < m.last_message AS unread, m.sender FROM threads t LEFT JOIN ( SELECT thread, id, name, permissions FROM roles UNION SELECT id AS thread, 0 AS id, NULL AS name, NULL AS permissions FROM threads ) r ON r.thread = t.id LEFT JOIN memberships m ON m.role = r.id AND m.thread = t.id AND m.role >= 0 ` .append(whereClause) .append(SQL` ORDER BY m.user ASC`); const [result] = await dbQuery(query); const threadInfos = {}; for (const row of result) { const threadID = row.id.toString(); if (!threadInfos[threadID]) { threadInfos[threadID] = { id: threadID, type: row.type, name: row.name ? row.name : '', description: row.description ? row.description : '', color: row.color, creationTime: row.creation_time, parentThreadID: row.parent_thread_id ? row.parent_thread_id.toString() : null, containingThreadID: row.containing_thread_id ? row.containing_thread_id.toString() : null, depth: row.depth, community: row.community ? row.community.toString() : null, members: [], roles: {}, repliesCount: row.replies_count, }; } const sourceMessageID = row.source_message?.toString(); if (sourceMessageID) { threadInfos[threadID].sourceMessageID = sourceMessageID; } const role = row.role.toString(); if (row.role && !threadInfos[threadID].roles[role]) { threadInfos[threadID].roles[role] = { id: role, name: row.role_name, permissions: JSON.parse(row.role_permissions), isDefault: role === row.default_role.toString(), }; } if (row.user) { const userID = row.user.toString(); const allPermissions = getAllThreadPermissions( JSON.parse(row.permissions), threadID, ); threadInfos[threadID].members.push({ id: userID, permissions: allPermissions, role: row.role ? role : null, subscription: JSON.parse(row.subscription), unread: row.role ? !!row.unread : null, isSender: !!row.sender, }); } } return { threadInfos }; } export type FetchThreadInfosResult = { +threadInfos: { +[id: string]: RawThreadInfo }, }; async function fetchThreadInfos( viewer: Viewer, condition?: SQLStatementType, ): Promise { const serverResult = await fetchServerThreadInfos(condition); return rawThreadInfosFromServerThreadInfos(viewer, serverResult); } const shimCommunityRoot = { [threadTypes.COMMUNITY_ROOT]: threadTypes.COMMUNITY_SECRET_SUBTHREAD, [threadTypes.COMMUNITY_ANNOUNCEMENT_ROOT]: threadTypes.COMMUNITY_SECRET_SUBTHREAD, [threadTypes.COMMUNITY_OPEN_ANNOUNCEMENT_SUBTHREAD]: threadTypes.COMMUNITY_OPEN_SUBTHREAD, [threadTypes.COMMUNITY_SECRET_ANNOUNCEMENT_SUBTHREAD]: threadTypes.COMMUNITY_SECRET_SUBTHREAD, [threadTypes.GENESIS]: threadTypes.COMMUNITY_SECRET_SUBTHREAD, }; function rawThreadInfosFromServerThreadInfos( viewer: Viewer, serverResult: FetchServerThreadInfosResult, ): FetchThreadInfosResult { const viewerID = viewer.id; const hasCodeVersionBelow70 = !hasMinCodeVersion(viewer.platformDetails, 70); const hasCodeVersionBelow87 = !hasMinCodeVersion(viewer.platformDetails, 87); const hasCodeVersionBelow102 = !hasMinCodeVersion( viewer.platformDetails, 102, ); const hasCodeVersionBelow104 = !hasMinCodeVersion( viewer.platformDetails, 104, ); const threadInfos = {}; for (const threadID in serverResult.threadInfos) { const serverThreadInfo = serverResult.threadInfos[threadID]; const threadInfo = rawThreadInfoFromServerThreadInfo( serverThreadInfo, viewerID, { includeVisibilityRules: hasCodeVersionBelow70, filterMemberList: hasCodeVersionBelow70, shimThreadTypes: hasCodeVersionBelow87 ? shimCommunityRoot : null, hideThreadStructure: hasCodeVersionBelow102, filterDetailedThreadEditPermissions: hasCodeVersionBelow104, }, ); 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( SQL`t.id = ${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(SQL`t.id = ${threadID}`); + return threads.threadInfos[threadID]; +} + export { fetchServerThreadInfos, fetchThreadInfos, rawThreadInfosFromServerThreadInfos, verifyThreadIDs, verifyThreadID, determineThreadAncestry, personalThreadQuery, fetchPersonalThreadID, + serverThreadInfoFromMessageInfo, }; diff --git a/lib/types/message-report-types.js b/lib/types/message-report-types.js new file mode 100644 index 000000000..b19414985 --- /dev/null +++ b/lib/types/message-report-types.js @@ -0,0 +1,11 @@ +// @flow + +import type { RawMessageInfo } from './message-types'; + +export type MessageReportCreationRequest = { + +messageID: string, +}; + +export type MessageReportCreationResult = { + +messageInfo: RawMessageInfo, +};