diff --git a/server/src/bots/app-version-update.js b/server/src/bots/app-version-update.js index 3138288c5..26db16048 100644 --- a/server/src/bots/app-version-update.js +++ b/server/src/bots/app-version-update.js @@ -1,98 +1,98 @@ // @flow import { messageTypes } from 'lib/types/message-types'; import invariant from 'invariant'; import bots from 'lib/facts/bots'; import { promiseAll } from 'lib/utils/promises'; import { dbQuery, SQL } from '../database'; import { createSquadbotThread } from './squadbot'; import createMessages from '../creators/message-creator'; import { createBotViewer } from '../session/bots'; const thirtyDays = 30 * 24 * 60 * 60 * 1000; const { squadbot } = bots; async function botherMonthlyActivesToUpdateAppVersion(): Promise { const cutoff = Date.now() - thirtyDays; const query = SQL` SELECT x.user, MIN(x.max_code_version) AS code_version, MIN(t.id) AS squadbot_thread FROM ( SELECT s.user, c.platform, MAX(JSON_EXTRACT(c.versions, "$.codeVersion")) AS max_code_version FROM sessions s LEFT JOIN cookies c ON c.id = s.cookie WHERE s.last_update > ${cutoff} AND c.platform != "web" AND JSON_EXTRACT(c.versions, "$.codeVersion") IS NOT NULL GROUP BY s.user, c.platform ) x LEFT JOIN versions v ON v.platform = x.platform AND v.code_version = ( SELECT MAX(code_version) FROM versions WHERE platform = x.platform AND deploy_time IS NOT NULL ) LEFT JOIN ( SELECT t.id, m1.user, COUNT(m3.user) AS user_count FROM threads t LEFT JOIN memberships m1 ON m1.thread = t.id - AND m1.user != ${squadbot.userID} + AND m1.user != ${squadbot.userID} AND m1.role >= 0 LEFT JOIN memberships m2 ON m2.thread = t.id - AND m2.user = ${squadbot.userID} + AND m2.user = ${squadbot.userID} AND m2.role >= 0 LEFT JOIN memberships m3 ON m3.thread = t.id - WHERE m1.user IS NOT NULL AND m2.user IS NOT NULL + WHERE m1.user IS NOT NULL AND m2.user IS NOT NULL AND m3.role >= 0 GROUP BY t.id, m1.user ) t ON t.user = x.user AND t.user_count = 2 WHERE v.id IS NOT NULL AND x.max_code_version < v.code_version GROUP BY x.user `; const [result] = await dbQuery(query); const codeVersions = new Map(); const squadbotThreads = new Map(); const usersToSquadbotThreadPromises = {}; for (let row of result) { const userID = row.user.toString(); const codeVersion = row.code_version; codeVersions.set(userID, codeVersion); if (row.squadbot_thread) { const squadbotThread = row.squadbot_thread.toString(); squadbotThreads.set(userID, squadbotThread); } else { usersToSquadbotThreadPromises[userID] = createSquadbotThread(userID); } } const newSquadbotThreads = await promiseAll(usersToSquadbotThreadPromises); for (let userID in newSquadbotThreads) { squadbotThreads.set(userID, newSquadbotThreads[userID]); } const time = Date.now(); const newMessageDatas = []; for (let [userID, threadID] of squadbotThreads) { const codeVersion = codeVersions.get(userID); invariant(codeVersion, 'should be set'); newMessageDatas.push({ type: messageTypes.TEXT, threadID, creatorID: squadbot.userID, time, text: `beep boop, I'm a bot! one or more of your devices is on an old ` + `version (v${codeVersion}). any chance you could update it? on ` + `Android you do this using the Play Store, same as any other app. on ` + `iOS, you need to open up the Testflight app and update from there. ` + `thanks for helping test!`, }); } const squadbotViewer = createBotViewer(squadbot.userID); await createMessages(squadbotViewer, newMessageDatas); } export { botherMonthlyActivesToUpdateAppVersion }; diff --git a/server/src/creators/message-creator.js b/server/src/creators/message-creator.js index f363b538b..bbe5b9990 100644 --- a/server/src/creators/message-creator.js +++ b/server/src/creators/message-creator.js @@ -1,393 +1,393 @@ // @flow import { messageTypes, messageDataLocalID, type MessageData, type RawMessageInfo, } from 'lib/types/message-types'; import { threadPermissions } from 'lib/types/thread-types'; import { updateTypes } from 'lib/types/update-types'; import { redisMessageTypes } from 'lib/types/redis-types'; import type { Viewer } from '../session/viewer'; import invariant from 'invariant'; import { rawMessageInfoFromMessageData, messageTypeGeneratesNotifs, shimUnsupportedRawMessageInfos, stripLocalIDs, } from 'lib/shared/message-utils'; import { permissionLookup } from 'lib/permissions/thread-permissions'; import { dbQuery, SQL, appendSQLArray, mergeOrConditions } from '../database'; import createIDs from './id-creator'; import { sendPushNotifs } from '../push/send'; import { createUpdates } from './update-creator'; import { handleAsyncPromise } from '../responders/handlers'; import { earliestFocusedTimeConsideredCurrent } from '../shared/focused-times'; import { fetchOtherSessionsForViewer } from '../fetchers/session-fetchers'; import { publisher } from '../socket/redis'; import { fetchMessageInfoForLocalID } from '../fetchers/message-fetchers'; import { creationString } from '../utils/idempotent'; // Does not do permission checks! (checkThreadPermission) async function createMessages( viewer: Viewer, messageDatas: $ReadOnlyArray, ): Promise { if (messageDatas.length === 0) { return []; } const messageInfos: RawMessageInfo[] = []; const newMessageDatas: MessageData[] = []; const existingMessages = await Promise.all( messageDatas.map(messageData => fetchMessageInfoForLocalID(viewer, messageDataLocalID(messageData)), ), ); for (let i = 0; i < existingMessages.length; i++) { const existingMessage = existingMessages[i]; if (existingMessage) { messageInfos.push(existingMessage); } else { newMessageDatas.push(messageDatas[i]); } } if (newMessageDatas.length === 0) { return shimUnsupportedRawMessageInfos(messageInfos, viewer.platformDetails); } const ids = await createIDs('messages', newMessageDatas.length); const subthreadPermissionsToCheck: Set = new Set(); const threadsToMessageIndices: Map = new Map(); const messageInsertRows = []; for (let i = 0; i < newMessageDatas.length; i++) { const messageData = newMessageDatas[i]; const threadID = messageData.threadID; const creatorID = messageData.creatorID; if (messageData.type === messageTypes.CREATE_SUB_THREAD) { subthreadPermissionsToCheck.add(messageData.childThreadID); } let messageIndices = threadsToMessageIndices.get(threadID); if (!messageIndices) { messageIndices = []; threadsToMessageIndices.set(threadID, messageIndices); } messageIndices.push(i); let content; if (messageData.type === messageTypes.CREATE_THREAD) { content = JSON.stringify(messageData.initialThreadState); } else if (messageData.type === messageTypes.CREATE_SUB_THREAD) { content = messageData.childThreadID; } else if (messageData.type === messageTypes.TEXT) { content = messageData.text; } else if (messageData.type === messageTypes.ADD_MEMBERS) { content = JSON.stringify(messageData.addedUserIDs); } else if (messageData.type === messageTypes.CHANGE_SETTINGS) { content = JSON.stringify({ [messageData.field]: messageData.value, }); } else if (messageData.type === messageTypes.REMOVE_MEMBERS) { content = JSON.stringify(messageData.removedUserIDs); } else if (messageData.type === messageTypes.CHANGE_ROLE) { content = JSON.stringify({ userIDs: messageData.userIDs, newRole: messageData.newRole, }); } else if ( messageData.type === messageTypes.CREATE_ENTRY || messageData.type === messageTypes.EDIT_ENTRY || messageData.type === messageTypes.DELETE_ENTRY || messageData.type === messageTypes.RESTORE_ENTRY ) { content = JSON.stringify({ entryID: messageData.entryID, date: messageData.date, text: messageData.text, }); } else if ( messageData.type === messageTypes.IMAGES || messageData.type === messageTypes.MULTIMEDIA ) { const mediaIDs = []; for (let { id } of messageData.media) { mediaIDs.push(parseInt(id, 10)); } content = JSON.stringify(mediaIDs); } const creation = messageData.localID && viewer.hasSessionInfo ? creationString(viewer, messageData.localID) : null; messageInsertRows.push([ ids[i], threadID, creatorID, messageData.type, content, messageData.time, creation, ]); messageInfos.push(rawMessageInfoFromMessageData(messageData, ids[i])); } handleAsyncPromise( postMessageSend( viewer, threadsToMessageIndices, subthreadPermissionsToCheck, stripLocalIDs(messageInfos), ), ); const messageInsertQuery = SQL` INSERT INTO messages(id, thread, user, type, content, time, creation) VALUES ${messageInsertRows} `; await dbQuery(messageInsertQuery); return shimUnsupportedRawMessageInfos(messageInfos, viewer.platformDetails); } // Handles: // (1) Sending push notifs // (2) Setting threads to unread and generating corresponding UpdateInfos // (3) Publishing to Redis so that active sockets pass on new messages async function postMessageSend( viewer: Viewer, threadsToMessageIndices: Map, subthreadPermissionsToCheck: Set, messageInfos: RawMessageInfo[], ) { let joinIndex = 0; let subthreadSelects = ''; const subthreadJoins = []; for (let subthread of subthreadPermissionsToCheck) { const index = joinIndex++; subthreadSelects += ` , stm${index}.permissions AS subthread${subthread}_permissions, stm${index}.role AS subthread${subthread}_role `; const join = SQL`LEFT JOIN memberships `; join.append(`stm${index} ON stm${index}.`); join.append(SQL`thread = ${subthread} AND `); join.append(`stm${index}.user = m.user`); subthreadJoins.push(join); } const time = earliestFocusedTimeConsideredCurrent(); const visibleExtractString = `$.${threadPermissions.VISIBLE}.value`; const query = SQL` SELECT m.user, m.thread, c.platform, c.device_token, c.versions, f.user AS focused_user `; query.append(subthreadSelects); query.append(SQL` FROM memberships m LEFT JOIN cookies c ON c.user = m.user AND c.device_token IS NOT NULL LEFT JOIN focused f ON f.user = m.user AND f.thread = m.thread AND f.time > ${time} `); appendSQLArray(query, subthreadJoins, SQL` `); query.append(SQL` - WHERE (m.role != 0 OR f.user IS NOT NULL) AND + WHERE (m.role > 0 OR f.user IS NOT NULL) AND JSON_EXTRACT(m.permissions, ${visibleExtractString}) IS TRUE AND m.thread IN (${[...threadsToMessageIndices.keys()]}) `); const perUserInfo = new Map(); const [result] = await dbQuery(query); for (let row of result) { const userID = row.user.toString(); const threadID = row.thread.toString(); const deviceToken = row.device_token; const focusedUser = !!row.focused_user; const { platform, versions } = row; let thisUserInfo = perUserInfo.get(userID); if (!thisUserInfo) { thisUserInfo = { devices: new Map(), threadIDs: new Set(), notFocusedThreadIDs: new Set(), subthreadsCanNotify: new Set(), subthreadsCanSetToUnread: new Set(), }; perUserInfo.set(userID, thisUserInfo); // Subthread info will be the same for each subthread, so we only parse // it once for (let subthread of subthreadPermissionsToCheck) { const isSubthreadMember = !!row[`subthread${subthread}_role`]; const permissions = row[`subthread${subthread}_permissions`]; const canSeeSubthread = permissionLookup( permissions, threadPermissions.KNOW_OF, ); if (!canSeeSubthread) { continue; } thisUserInfo.subthreadsCanSetToUnread.add(subthread); // Only include the notification from the superthread if there is no // notification from the subthread if ( !isSubthreadMember || !permissionLookup(permissions, threadPermissions.VISIBLE) ) { thisUserInfo.subthreadsCanNotify.add(subthread); } } } if (deviceToken) { thisUserInfo.devices.set(deviceToken, { deviceType: platform, deviceToken, codeVersion: versions ? versions.codeVersion : null, }); } thisUserInfo.threadIDs.add(threadID); if (!focusedUser) { thisUserInfo.notFocusedThreadIDs.add(threadID); } } const pushInfo = {}, setUnreadPairs = [], messageInfosPerUser = {}; for (let pair of perUserInfo) { const [userID, preUserPushInfo] = pair; const { subthreadsCanSetToUnread, subthreadsCanNotify } = preUserPushInfo; const userPushInfo = { devices: [...preUserPushInfo.devices.values()], messageInfos: [], }; const threadIDsToSetToUnread = new Set(); for (let threadID of preUserPushInfo.notFocusedThreadIDs) { const messageIndices = threadsToMessageIndices.get(threadID); invariant(messageIndices, `indices should exist for thread ${threadID}`); for (let messageIndex of messageIndices) { const messageInfo = messageInfos[messageIndex]; if (messageInfo.creatorID === userID) { continue; } if ( messageInfo.type !== messageTypes.CREATE_SUB_THREAD || subthreadsCanSetToUnread.has(messageInfo.childThreadID) ) { threadIDsToSetToUnread.add(threadID); } if (!messageTypeGeneratesNotifs(messageInfo.type)) { continue; } if ( messageInfo.type !== messageTypes.CREATE_SUB_THREAD || subthreadsCanNotify.has(messageInfo.childThreadID) ) { userPushInfo.messageInfos.push(messageInfo); } } } if ( userPushInfo.devices.length > 0 && userPushInfo.messageInfos.length > 0 ) { pushInfo[userID] = userPushInfo; } for (let threadID of threadIDsToSetToUnread) { setUnreadPairs.push({ userID, threadID }); } const userMessageInfos = []; for (let threadID of preUserPushInfo.threadIDs) { const messageIndices = threadsToMessageIndices.get(threadID); invariant(messageIndices, `indices should exist for thread ${threadID}`); for (let messageIndex of messageIndices) { const messageInfo = messageInfos[messageIndex]; userMessageInfos.push(messageInfo); } } if (userMessageInfos.length > 0) { messageInfosPerUser[userID] = userMessageInfos; } } await Promise.all([ updateUnreadStatus(setUnreadPairs), redisPublish(viewer, messageInfosPerUser), ]); await sendPushNotifs(pushInfo); } async function updateUnreadStatus( setUnreadPairs: $ReadOnlyArray<{| userID: string, threadID: string |}>, ) { if (setUnreadPairs.length === 0) { return; } const updateConditions = setUnreadPairs.map( pair => SQL`(user = ${pair.userID} AND thread = ${pair.threadID})`, ); const updateQuery = SQL` UPDATE memberships SET unread = 1 WHERE `; updateQuery.append(mergeOrConditions(updateConditions)); const now = Date.now(); await Promise.all([ dbQuery(updateQuery), createUpdates( setUnreadPairs.map(pair => ({ type: updateTypes.UPDATE_THREAD_READ_STATUS, userID: pair.userID, time: now, threadID: pair.threadID, unread: true, })), ), ]); } async function redisPublish( viewer: Viewer, messageInfosPerUser: { [userID: string]: $ReadOnlyArray }, ) { for (let userID in messageInfosPerUser) { if (userID === viewer.userID && viewer.hasSessionInfo) { continue; } const messageInfos = messageInfosPerUser[userID]; publisher.sendMessage( { userID }, { type: redisMessageTypes.NEW_MESSAGES, messages: messageInfos, }, ); } const viewerMessageInfos = messageInfosPerUser[viewer.userID]; if (!viewerMessageInfos || !viewer.hasSessionInfo) { return; } const sessionIDs = await fetchOtherSessionsForViewer(viewer); for (let sessionID of sessionIDs) { publisher.sendMessage( { userID: viewer.userID, sessionID }, { type: redisMessageTypes.NEW_MESSAGES, messages: viewerMessageInfos, }, ); } } export default createMessages; diff --git a/server/src/fetchers/session-fetchers.js b/server/src/fetchers/session-fetchers.js index 327565b01..1e5aff784 100644 --- a/server/src/fetchers/session-fetchers.js +++ b/server/src/fetchers/session-fetchers.js @@ -1,44 +1,45 @@ // @flow import type { Viewer } from '../session/viewer'; import type { CalendarQuery } from 'lib/types/entry-types'; import { dbQuery, SQL } from '../database'; type CalendarSessionResult = {| userID: string, session: string, calendarQuery: CalendarQuery, |}; async function fetchActiveSessionsForThread( threadID: string, ): Promise { const query = SQL` SELECT s.id, s.user, s.query FROM memberships m LEFT JOIN sessions s ON s.user = m.user - WHERE m.thread = ${threadID} AND s.query IS NOT NULL + WHERE m.thread = ${threadID} AND m.role >= 0 + AND s.query IS NOT NULL `; const [result] = await dbQuery(query); const filters = []; for (let row of result) { filters.push({ userID: row.user.toString(), session: row.id.toString(), calendarQuery: row.query, }); } return filters; } async function fetchOtherSessionsForViewer(viewer: Viewer): Promise { const query = SQL` SELECT id FROM sessions WHERE user = ${viewer.userID} AND id != ${viewer.session} `; const [result] = await dbQuery(query); return result.map(row => row.id.toString()); } export { fetchActiveSessionsForThread, fetchOtherSessionsForViewer }; diff --git a/server/src/fetchers/thread-fetchers.js b/server/src/fetchers/thread-fetchers.js index 62dc37d8a..8568b55ae 100644 --- a/server/src/fetchers/thread-fetchers.js +++ b/server/src/fetchers/thread-fetchers.js @@ -1,228 +1,229 @@ // @flow import type { RawThreadInfo, ServerThreadInfo, ThreadPermission, ThreadPermissionsBlob, } from 'lib/types/thread-types'; import type { Viewer } from '../session/viewer'; import { getAllThreadPermissions, permissionLookup, } from 'lib/permissions/thread-permissions'; import { rawThreadInfoFromServerThreadInfo } from 'lib/shared/thread-utils'; import { dbQuery, SQL, SQLStatement } from '../database'; type FetchServerThreadInfosResult = {| threadInfos: { [id: string]: ServerThreadInfo }, |}; async function fetchServerThreadInfos( condition?: SQLStatement, ): Promise { const whereClause = condition ? SQL`WHERE `.append(condition) : ''; const query = SQL` SELECT t.id, t.name, t.parent_thread_id, t.color, t.description, t.type, t.creation_time, t.default_role, r.id AS role, r.name AS role_name, r.permissions AS role_permissions, m.user, m.permissions, m.subscription, m.unread 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 + 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 (let row of result) { const threadID = row.id.toString(); if (!threadInfos[threadID]) { threadInfos[threadID] = { id: threadID, type: row.type, visibilityRules: 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, members: [], roles: {}, }; } 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(row.permissions, threadID); threadInfos[threadID].members.push({ id: userID, permissions: allPermissions, role: row.role ? role : null, subscription: row.subscription, unread: row.role ? !!row.unread : null, }); } } return { threadInfos }; } export type FetchThreadInfosResult = {| threadInfos: { [id: string]: RawThreadInfo }, |}; async function fetchThreadInfos( viewer: Viewer, condition?: SQLStatement, ): Promise { const serverResult = await fetchServerThreadInfos(condition); return rawThreadInfosFromServerThreadInfos(viewer, serverResult); } function rawThreadInfosFromServerThreadInfos( viewer: Viewer, serverResult: FetchServerThreadInfosResult, ): FetchThreadInfosResult { const viewerID = viewer.id; const threadInfos = {}; for (let threadID in serverResult.threadInfos) { const serverThreadInfo = serverResult.threadInfos[threadID]; const threadInfo = rawThreadInfoFromServerThreadInfo( serverThreadInfo, viewerID, ); 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 (let 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; } async function fetchThreadPermissionsBlob( viewer: Viewer, threadID: string, ): Promise { const viewerID = viewer.id; const query = SQL` SELECT permissions FROM memberships WHERE thread = ${threadID} AND user = ${viewerID} `; const [result] = await dbQuery(query); if (result.length === 0) { return null; } const row = result[0]; return row.permissions; } async function checkThreadPermission( viewer: Viewer, threadID: string, permission: ThreadPermission, ): Promise { const permissionsBlob = await fetchThreadPermissionsBlob(viewer, threadID); return permissionLookup(permissionsBlob, permission); } async function checkThreadPermissions( viewer: Viewer, threadIDs: $ReadOnlyArray, permission: ThreadPermission, ): Promise<{ [threadID: string]: boolean }> { const viewerID = viewer.id; const query = SQL` SELECT thread, permissions FROM memberships WHERE thread IN (${threadIDs}) AND user = ${viewerID} `; const [result] = await dbQuery(query); const permissionsBlobs = new Map(); for (let row of result) { const threadID = row.thread.toString(); permissionsBlobs.set(threadID, row.permissions); } const permissionByThread = {}; for (let threadID of threadIDs) { const permissionsBlob = permissionsBlobs.get(threadID); permissionByThread[threadID] = permissionLookup( permissionsBlob, permission, ); } return permissionByThread; } async function viewerIsMember( viewer: Viewer, threadID: string, ): Promise { const viewerID = viewer.id; const query = SQL` SELECT role FROM memberships WHERE user = ${viewerID} AND thread = ${threadID} `; const [result] = await dbQuery(query); if (result.length === 0) { return false; } const row = result[0]; - return !!row.role; + return row.role > 0; } export { fetchServerThreadInfos, fetchThreadInfos, rawThreadInfosFromServerThreadInfos, verifyThreadIDs, verifyThreadID, fetchThreadPermissionsBlob, checkThreadPermission, checkThreadPermissions, viewerIsMember, }; diff --git a/server/src/push/rescind.js b/server/src/push/rescind.js index 981003990..59b16e878 100644 --- a/server/src/push/rescind.js +++ b/server/src/push/rescind.js @@ -1,183 +1,183 @@ // @flow import { threadPermissions } from 'lib/types/thread-types'; import { threadSubscriptions } from 'lib/types/subscription-types'; import apn from 'apn'; import invariant from 'invariant'; import { promiseAll } from 'lib/utils/promises'; import { dbQuery, SQL, SQLStatement } from '../database'; import { apnPush, fcmPush } from './utils'; import createIDs from '../creators/id-creator'; async function rescindPushNotifs( notifCondition: SQLStatement, inputCountCondition?: SQLStatement, ) { const notificationExtractString = `$.${threadSubscriptions.home}`; const visPermissionExtractString = `$.${threadPermissions.VISIBLE}.value`; const fetchQuery = SQL` SELECT n.id, n.user, n.thread, n.message, n.delivery, n.collapse_key, COUNT( `; fetchQuery.append(inputCountCondition ? inputCountCondition : SQL`m.thread`); fetchQuery.append(SQL` ) AS unread_count FROM notifications n - LEFT JOIN memberships m ON m.user = n.user AND m.unread = 1 AND m.role != 0 + LEFT JOIN memberships m ON m.user = n.user AND m.unread = 1 AND m.role > 0 AND JSON_EXTRACT(subscription, ${notificationExtractString}) AND JSON_EXTRACT(permissions, ${visPermissionExtractString}) WHERE n.rescinded = 0 AND `); fetchQuery.append(notifCondition); fetchQuery.append(SQL` GROUP BY n.id, m.user`); const [fetchResult] = await dbQuery(fetchQuery); const deliveryPromises = {}; const notifInfo = {}; const rescindedIDs = []; for (let row of fetchResult) { const deliveries = Array.isArray(row.delivery) ? row.delivery : [row.delivery]; const id = row.id.toString(); const threadID = row.thread.toString(); notifInfo[id] = { userID: row.user.toString(), threadID, messageID: row.message.toString(), }; for (let delivery of deliveries) { if (delivery.iosID && delivery.iosDeviceTokens) { // Old iOS const notification = prepareIOSNotification( delivery.iosID, row.unread_count, ); deliveryPromises[id] = apnPush(notification, delivery.iosDeviceTokens); } else if (delivery.androidID) { // Old Android const notification = prepareAndroidNotification( row.collapse_key ? row.collapse_key : id, row.unread_count, threadID, null, ); deliveryPromises[id] = fcmPush( notification, delivery.androidDeviceTokens, null, ); } else if (delivery.deviceType === 'ios') { // New iOS const { iosID, deviceTokens } = delivery; const notification = prepareIOSNotification(iosID, row.unread_count); deliveryPromises[id] = apnPush(notification, deviceTokens); } else if (delivery.deviceType === 'android') { // New Android const { deviceTokens, codeVersion } = delivery; const notification = prepareAndroidNotification( row.collapse_key ? row.collapse_key : id, row.unread_count, threadID, codeVersion, ); deliveryPromises[id] = fcmPush(notification, deviceTokens, null); } } rescindedIDs.push(row.id); } const numRescinds = Object.keys(deliveryPromises).length; const promises = [promiseAll(deliveryPromises)]; if (numRescinds > 0) { promises.push(createIDs('notifications', numRescinds)); } if (rescindedIDs.length > 0) { const rescindQuery = SQL` UPDATE notifications SET rescinded = 1 WHERE id IN (${rescindedIDs}) `; promises.push(dbQuery(rescindQuery)); } const [deliveryResults, dbIDs] = await Promise.all(promises); const newNotifRows = []; if (numRescinds > 0) { invariant(dbIDs, 'dbIDs should be set'); for (const rescindedID in deliveryResults) { const delivery = {}; delivery.type = 'rescind'; delivery.rescindedID = rescindedID; const { errors } = deliveryResults[rescindedID]; if (errors) { delivery.errors = errors; } const dbID = dbIDs.shift(); const { userID, threadID, messageID } = notifInfo[rescindedID]; newNotifRows.push([ dbID, userID, threadID, messageID, null, JSON.stringify([delivery]), 1, ]); } } if (newNotifRows.length > 0) { const insertQuery = SQL` INSERT INTO notifications (id, user, thread, message, collapse_key, delivery, rescinded) VALUES ${newNotifRows} `; await dbQuery(insertQuery); } } function prepareIOSNotification( iosID: string, unreadCount: number, ): apn.Notification { const notification = new apn.Notification(); notification.contentAvailable = true; notification.badge = unreadCount; notification.topic = 'org.squadcal.app'; notification.payload = { managedAps: { action: 'CLEAR', notificationId: iosID, }, }; return notification; } function prepareAndroidNotification( notifID: string, unreadCount: number, threadID: string, codeVersion: ?number, ): Object { if (!codeVersion || codeVersion < 31) { return { data: { badge: unreadCount.toString(), custom_notification: JSON.stringify({ rescind: 'true', notifID, }), }, }; } return { data: { badge: unreadCount.toString(), rescind: 'true', rescindID: notifID, threadID, }, }; } export { rescindPushNotifs }; diff --git a/server/src/push/utils.js b/server/src/push/utils.js index 45067eae4..ffb6e1129 100644 --- a/server/src/push/utils.js +++ b/server/src/push/utils.js @@ -1,202 +1,202 @@ // @flow import { threadPermissions } from 'lib/types/thread-types'; import { threadSubscriptions } from 'lib/types/subscription-types'; import apn from 'apn'; import fcmAdmin from 'firebase-admin'; import invariant from 'invariant'; import { dbQuery, SQL } from '../database'; let cachedAPNProvider = undefined; async function getAPNProvider() { if (cachedAPNProvider !== undefined) { return cachedAPNProvider; } try { // $FlowFixMe const apnConfig = await import('../../secrets/apn_config'); if (cachedAPNProvider === undefined) { cachedAPNProvider = new apn.Provider(apnConfig.default); } } catch { if (cachedAPNProvider === undefined) { cachedAPNProvider = null; } } return cachedAPNProvider; } let fcmAppInitialized = undefined; async function initializeFCMApp() { if (fcmAppInitialized !== undefined) { return fcmAppInitialized; } try { // $FlowFixMe const fcmConfig = await import('../../secrets/fcm_config'); if (fcmAppInitialized === undefined) { fcmAppInitialized = true; fcmAdmin.initializeApp({ credential: fcmAdmin.credential.cert(fcmConfig.default), }); } } catch { if (cachedAPNProvider === undefined) { fcmAppInitialized = false; } } return fcmAppInitialized; } const fcmTokenInvalidationErrors = new Set([ 'messaging/registration-token-not-registered', 'messaging/invalid-registration-token', ]); const apnTokenInvalidationErrorCode = 410; const apnBadRequestErrorCode = 400; const apnBadTokenErrorString = 'BadDeviceToken'; async function apnPush( notification: apn.Notification, deviceTokens: $ReadOnlyArray, ) { const apnProvider = await getAPNProvider(); if (!apnProvider && process.env.NODE_ENV === 'dev') { console.log('no server/secrets/apn_config.json so ignoring notifs'); return { success: true }; } invariant(apnProvider, 'server/secrets/apn_config.json should exist'); const result = await apnProvider.send(notification, deviceTokens); const errors = []; const invalidTokens = []; for (let error of result.failed) { errors.push(error); if ( error.status == apnTokenInvalidationErrorCode || (error.status == apnBadRequestErrorCode && error.response.reason === apnBadTokenErrorString) ) { invalidTokens.push(error.device); } } if (invalidTokens.length > 0) { return { errors, invalidTokens }; } else if (errors.length > 0) { return { errors }; } else { return { success: true }; } } async function fcmPush( notification: Object, deviceTokens: $ReadOnlyArray, collapseKey: ?string, ) { const initialized = await initializeFCMApp(); if (!initialized && process.env.NODE_ENV === 'dev') { console.log('no server/secrets/fcm_config.json so ignoring notifs'); return { success: true }; } invariant(initialized, 'server/secrets/fcm_config.json should exist'); const options: Object = { priority: 'high', }; if (collapseKey) { options.collapseKey = collapseKey; } // firebase-admin is extremely barebones and has a lot of missing or poorly // thought-out functionality. One of the issues is that if you send a // multicast messages and one of the device tokens is invalid, the resultant // won't explain which of the device tokens is invalid. So we're forced to // avoid the multicast functionality and call it once per deviceToken. const promises = []; for (let deviceToken of deviceTokens) { promises.push(fcmSinglePush(notification, deviceToken, options)); } const pushResults = await Promise.all(promises); const errors = []; const ids = []; const invalidTokens = []; for (let i = 0; i < pushResults.length; i++) { const pushResult = pushResults[i]; for (let error of pushResult.errors) { errors.push(error); if (fcmTokenInvalidationErrors.has(error.errorInfo.code)) { invalidTokens.push(deviceTokens[i]); } } for (let id of pushResult.fcmIDs) { ids.push(id); } } const result = {}; if (ids.length > 0) { result.fcmIDs = ids; } if (errors.length > 0) { result.errors = errors; } else { result.success = true; } if (invalidTokens.length > 0) { result.invalidTokens = invalidTokens; } return result; } async function fcmSinglePush( notification: Object, deviceToken: string, options: Object, ) { try { const deliveryResult = await fcmAdmin .messaging() .sendToDevice(deviceToken, notification, options); const errors = []; const ids = []; for (let fcmResult of deliveryResult.results) { if (fcmResult.error) { errors.push(fcmResult.error); } else if (fcmResult.messageId) { ids.push(fcmResult.messageId); } } return { fcmIDs: ids, errors }; } catch (e) { return { fcmIDs: [], errors: [e] }; } } async function getUnreadCounts( userIDs: string[], ): Promise<{ [userID: string]: number }> { const visPermissionExtractString = `$.${threadPermissions.VISIBLE}.value`; const notificationExtractString = `$.${threadSubscriptions.home}`; const query = SQL` SELECT user, COUNT(thread) AS unread_count FROM memberships - WHERE user IN (${userIDs}) AND unread = 1 AND role != 0 + WHERE user IN (${userIDs}) AND unread = 1 AND role > 0 AND JSON_EXTRACT(permissions, ${visPermissionExtractString}) AND JSON_EXTRACT(subscription, ${notificationExtractString}) GROUP BY user `; const [result] = await dbQuery(query); const usersToUnreadCounts = {}; for (let row of result) { usersToUnreadCounts[row.user.toString()] = row.unread_count; } for (let userID of userIDs) { if (usersToUnreadCounts[userID] === undefined) { usersToUnreadCounts[userID] = 0; } } return usersToUnreadCounts; } export { apnPush, fcmPush, getUnreadCounts }; diff --git a/server/src/scripts/create-db.js b/server/src/scripts/create-db.js index b39d74ac9..db066734e 100644 --- a/server/src/scripts/create-db.js +++ b/server/src/scripts/create-db.js @@ -1,375 +1,375 @@ // @flow import { threadTypes } from 'lib/types/thread-types'; import { undirectedStatus } from 'lib/types/relationship-types'; import ashoat from 'lib/facts/ashoat'; import bots from 'lib/facts/bots'; import { makePermissionsBlob, makePermissionsForChildrenBlob, } from 'lib/permissions/thread-permissions'; import { sortIDs } from 'lib/shared/relationship-utils'; import { setScriptContext } from './script-context'; import { endScript } from './utils'; import { dbQuery, SQL } from '../database'; import { getRolePermissionBlobsForChat } from '../creators/role-creator'; setScriptContext({ allowMultiStatementSQLQueries: true, }); async function main() { try { await createTables(); await createUsers(); await createThreads(); endScript(); } catch (e) { endScript(); console.warn(e); } } async function createTables() { await dbQuery(SQL` CREATE TABLE cookies ( id bigint(20) NOT NULL, hash char(60) NOT NULL, user bigint(20) DEFAULT NULL, platform varchar(255) DEFAULT NULL, creation_time bigint(20) NOT NULL, last_used bigint(20) NOT NULL, device_token varchar(255) DEFAULT NULL, versions json DEFAULT NULL ) ENGINE=InnoDB DEFAULT CHARSET=latin1; CREATE TABLE days ( id bigint(20) NOT NULL, date date NOT NULL, thread bigint(20) NOT NULL ) ENGINE=InnoDB DEFAULT CHARSET=latin1; CREATE TABLE entries ( id bigint(20) NOT NULL, day bigint(20) NOT NULL, text mediumtext COLLATE utf8mb4_bin NOT NULL, creator bigint(20) NOT NULL, creation_time bigint(20) NOT NULL, last_update bigint(20) NOT NULL, deleted tinyint(1) UNSIGNED NOT NULL, creation varchar(255) COLLATE utf8mb4_bin DEFAULT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; CREATE TABLE focused ( user bigint(20) NOT NULL, session bigint(20) NOT NULL, thread bigint(20) NOT NULL, time bigint(20) NOT NULL ) ENGINE=InnoDB DEFAULT CHARSET=latin1; CREATE TABLE ids ( id bigint(20) NOT NULL, table_name varchar(255) NOT NULL ) ENGINE=InnoDB DEFAULT CHARSET=latin1; CREATE TABLE memberships ( thread bigint(20) NOT NULL, user bigint(20) NOT NULL, role bigint(20) NOT NULL, - permissions json NOT NULL, + permissions json DEFAULT NULL, permissions_for_children json DEFAULT NULL, creation_time bigint(20) NOT NULL, subscription json NOT NULL, unread tinyint(1) NOT NULL ) ENGINE=InnoDB DEFAULT CHARSET=latin1; CREATE TABLE messages ( id bigint(20) NOT NULL, thread bigint(20) NOT NULL, user bigint(20) NOT NULL, type tinyint(3) UNSIGNED NOT NULL, content mediumtext COLLATE utf8mb4_bin, time bigint(20) NOT NULL, creation varchar(255) COLLATE utf8mb4_bin DEFAULT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; CREATE TABLE notifications ( id bigint(20) NOT NULL, user bigint(20) NOT NULL, thread bigint(20) NOT NULL, message bigint(20) NOT NULL, collapse_key varchar(255) DEFAULT NULL, delivery json NOT NULL, rescinded tinyint(1) NOT NULL ) ENGINE=InnoDB DEFAULT CHARSET=latin1; CREATE TABLE reports ( id bigint(20) NOT NULL, user bigint(20) NOT NULL, type tinyint(3) UNSIGNED NOT NULL, platform varchar(255) NOT NULL, report json NOT NULL, creation_time bigint(20) NOT NULL ) ENGINE=InnoDB DEFAULT CHARSET=latin1; CREATE TABLE revisions ( id bigint(20) NOT NULL, entry bigint(20) NOT NULL, author bigint(20) NOT NULL, text mediumtext COLLATE utf8mb4_bin NOT NULL, creation_time bigint(20) NOT NULL, session bigint(20) NOT NULL, last_update bigint(20) NOT NULL, deleted tinyint(1) UNSIGNED NOT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; CREATE TABLE roles ( id bigint(20) NOT NULL, thread bigint(20) NOT NULL, name varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, permissions json NOT NULL, creation_time bigint(20) NOT NULL ) ENGINE=InnoDB DEFAULT CHARSET=latin1; CREATE TABLE sessions ( id bigint(20) NOT NULL, user bigint(20) NOT NULL, cookie bigint(20) NOT NULL, query json NOT NULL, creation_time bigint(20) NOT NULL, last_update bigint(20) NOT NULL, last_validated bigint(20) NOT NULL ) ENGINE=InnoDB DEFAULT CHARSET=latin1; CREATE TABLE threads ( id bigint(20) NOT NULL, type tinyint(3) NOT NULL, name varchar(191) COLLATE utf8mb4_bin DEFAULT NULL, description mediumtext COLLATE utf8mb4_bin, parent_thread_id bigint(20) DEFAULT NULL, default_role bigint(20) NOT NULL, creator bigint(20) NOT NULL, creation_time bigint(20) NOT NULL, color char(6) COLLATE utf8mb4_bin NOT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; CREATE TABLE updates ( id bigint(20) NOT NULL, user bigint(20) NOT NULL, type tinyint(3) UNSIGNED NOT NULL, \`key\` bigint(20) DEFAULT NULL, updater bigint(20) DEFAULT NULL, target bigint(20) DEFAULT NULL, content mediumtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin, time bigint(20) NOT NULL ) ENGINE=InnoDB DEFAULT CHARSET=latin1; CREATE TABLE uploads ( id bigint(20) NOT NULL, uploader bigint(20) NOT NULL, container bigint(20) DEFAULT NULL, type varchar(255) NOT NULL, filename varchar(255) NOT NULL, mime varchar(255) NOT NULL, content longblob NOT NULL, secret varchar(255) NOT NULL, creation_time bigint(20) NOT NULL, extra json DEFAULT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8; CREATE TABLE users ( id bigint(20) NOT NULL, username varchar(191) COLLATE utf8mb4_bin NOT NULL, hash char(60) COLLATE utf8mb4_bin NOT NULL, email varchar(191) COLLATE utf8mb4_bin NOT NULL, email_verified tinyint(1) UNSIGNED NOT NULL DEFAULT '0', avatar varchar(191) COLLATE utf8mb4_bin DEFAULT NULL, creation_time bigint(20) NOT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; CREATE TABLE relationships_undirected ( user1 bigint(20) NOT NULL, user2 bigint(20) NOT NULL, status tinyint(1) UNSIGNED NOT NULL ) ENGINE=InnoDB DEFAULT CHARSET=latin1; CREATE TABLE relationships_directed ( user1 bigint(20) NOT NULL, user2 bigint(20) NOT NULL, status tinyint(1) UNSIGNED NOT NULL ) ENGINE=InnoDB DEFAULT CHARSET=latin1; CREATE TABLE verifications ( id bigint(20) NOT NULL, user bigint(20) NOT NULL, field tinyint(1) UNSIGNED NOT NULL, hash char(60) NOT NULL, creation_time bigint(20) NOT NULL ) ENGINE=InnoDB DEFAULT CHARSET=latin1; CREATE TABLE versions ( id bigint(20) NOT NULL, code_version int(11) NOT NULL, platform varchar(255) NOT NULL, creation_time bigint(20) NOT NULL, deploy_time bigint(20) DEFAULT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8; ALTER TABLE cookies ADD PRIMARY KEY (id), ADD UNIQUE KEY device_token (device_token), ADD KEY user_device_token (user,device_token); ALTER TABLE days ADD PRIMARY KEY (id), ADD UNIQUE KEY date_thread (date,thread) USING BTREE; ALTER TABLE entries ADD PRIMARY KEY (id), ADD UNIQUE KEY creator_creation (creator,creation), ADD KEY day (day); ALTER TABLE focused ADD UNIQUE KEY user_cookie_thread (user,session,thread), ADD KEY thread_user (thread,user); ALTER TABLE ids ADD PRIMARY KEY (id); ALTER TABLE memberships ADD UNIQUE KEY thread_user (thread,user) USING BTREE, ADD KEY role (role) USING BTREE; ALTER TABLE messages ADD PRIMARY KEY (id), ADD UNIQUE KEY user_creation (user,creation), ADD KEY thread (thread); ALTER TABLE notifications ADD PRIMARY KEY (id), ADD KEY rescinded_user_collapse_key (rescinded,user,collapse_key) USING BTREE, ADD KEY thread (thread), ADD KEY rescinded_user_thread_message (rescinded,user,thread,message) USING BTREE; ALTER TABLE reports ADD PRIMARY KEY (id); ALTER TABLE revisions ADD PRIMARY KEY (id), ADD KEY entry (entry); ALTER TABLE roles ADD PRIMARY KEY (id), ADD KEY thread (thread); ALTER TABLE sessions ADD PRIMARY KEY (id), ADD KEY user (user); ALTER TABLE threads ADD PRIMARY KEY (id); ALTER TABLE updates ADD PRIMARY KEY (id), ADD KEY user_time (user,time), ADD KEY user_key_type (user,\`key\`,type); ALTER TABLE uploads ADD PRIMARY KEY (id); ALTER TABLE users ADD PRIMARY KEY (id), ADD UNIQUE KEY username (username), ADD UNIQUE KEY email (email); ALTER TABLE relationships_undirected ADD UNIQUE KEY user1_user2 (user1,user2), ADD UNIQUE KEY user2_user1 (user2,user1); ALTER TABLE relationships_directed ADD UNIQUE KEY user1_user2 (user1,user2); ALTER TABLE verifications ADD PRIMARY KEY (id), ADD KEY user_field (user,field); ALTER TABLE versions ADD PRIMARY KEY (id), ADD UNIQUE KEY code_version_platform (code_version,platform); ALTER TABLE ids MODIFY id bigint(20) NOT NULL AUTO_INCREMENT; `); } async function createUsers() { const [user1, user2] = sortIDs(bots.squadbot.userID, ashoat.id); await dbQuery(SQL` INSERT INTO ids (id, table_name) VALUES (${bots.squadbot.userID}, 'users'), (${ashoat.id}, 'users'); INSERT INTO users (id, username, hash, email, email_verified, avatar, creation_time) VALUES (${bots.squadbot.userID}, 'squadbot', '', 'squadbot@squadcal.org', 1, NULL, 1530049900980), (${ashoat.id}, 'ashoat', '', ${ashoat.email}, 1, NULL, 1463588881886); INSERT INTO relationships_undirected (user1, user2, status) VALUES (${user1}, ${user2}, ${undirectedStatus.KNOW_OF}); `); } async function createThreads() { const staffSquadbotThreadRoleID = 118821; const defaultRolePermissions = getRolePermissionBlobsForChat( threadTypes.CHAT_SECRET, ).Members; const membershipPermissions = makePermissionsBlob( defaultRolePermissions, null, bots.squadbot.staffThreadID, threadTypes.CHAT_SECRET, ); const membershipPermissionsString = JSON.stringify(membershipPermissions); const membershipChildPermissionsString = JSON.stringify( makePermissionsForChildrenBlob(membershipPermissions), ); const subscriptionString = JSON.stringify({ home: true, pushNotifs: true }); await dbQuery(SQL` INSERT INTO ids (id, table_name) VALUES (${bots.squadbot.staffThreadID}, 'threads'), (${staffSquadbotThreadRoleID}, 'roles'); INSERT INTO roles (id, thread, name, permissions, creation_time) VALUES (${staffSquadbotThreadRoleID}, ${bots.squadbot.staffThreadID}, 'Members', ${JSON.stringify(defaultRolePermissions)}, 1530049901882); INSERT INTO threads (id, type, name, description, parent_thread_id, default_role, creator, creation_time, color) VALUES (${bots.squadbot.staffThreadID}, ${threadTypes.CHAT_SECRET}, NULL, NULL, NULL, ${staffSquadbotThreadRoleID}, ${bots.squadbot.userID}, 1530049901942, 'ef1a63'); INSERT INTO memberships (thread, user, role, permissions, permissions_for_children, creation_time, subscription, unread) VALUES (${bots.squadbot.staffThreadID}, ${bots.squadbot.userID}, ${staffSquadbotThreadRoleID}, ${membershipPermissionsString}, ${membershipChildPermissionsString}, 1530049902080, ${subscriptionString}, 0), (${bots.squadbot.staffThreadID}, ${ashoat.id}, ${staffSquadbotThreadRoleID}, ${membershipPermissionsString}, ${membershipChildPermissionsString}, 1530049902080, ${subscriptionString}, 0); `); } main(); diff --git a/server/src/scripts/create-relationships.js b/server/src/scripts/create-relationships.js index 11d573ce5..727f89698 100644 --- a/server/src/scripts/create-relationships.js +++ b/server/src/scripts/create-relationships.js @@ -1,51 +1,87 @@ // @flow import { undirectedStatus } from 'lib/types/relationship-types'; import _flow from 'lodash/fp/flow'; import _groupBy from 'lodash/fp/groupBy'; import _mapValues from 'lodash/fp/mapValues'; import _map from 'lodash/fp/map'; import _values from 'lodash/fp/values'; import _flatten from 'lodash/fp/flatten'; import _uniqWith from 'lodash/fp/uniqWith'; import _isEqual from 'lodash/fp/isEqual'; import { getAllTuples } from 'lib/utils/array'; import { updateUndirectedRelationships } from '../updaters/relationship-updaters'; +import { saveMemberships } from '../updaters/thread-permission-updaters'; import { dbQuery, SQL } from '../database'; import { endScript } from './utils'; async function main() { try { + await alterMemberships(); + await createMembershipsForFormerMembers(); await createKnowOfRelationships(); endScript(); } catch (e) { endScript(); console.warn(e); } } +async function alterMemberships() { + await dbQuery( + SQL`ALTER TABLE memberships CHANGE permissions permissions json DEFAULT NULL`, + ); +} + +async function createMembershipsForFormerMembers() { + const [result] = await dbQuery(SQL` + SELECT DISTINCT thread, user + FROM messages m + WHERE NOT EXISTS ( + SELECT thread, user FROM memberships mm + WHERE m.thread = mm.thread AND m.user = mm.user + ) + `); + + const rowsToSave = []; + for (const row of result) { + rowsToSave.push({ + operation: 'update', + userID: row.user.toString(), + threadID: row.thread.toString(), + permissions: null, + permissionsForChildren: null, + role: '-1', + }); + } + + await saveMemberships(rowsToSave); +} + async function createKnowOfRelationships() { const [result] = await dbQuery(SQL` - SELECT thread, user FROM memberships ORDER BY user ASC + SELECT thread, user FROM memberships + UNION SELECT thread, user FROM messages + ORDER BY user ASC `); const changeset = _flow([ _groupBy(membership => membership.thread), _mapValues(_flow([_map(membership => membership.user), getAllTuples])), _values, _flatten, _uniqWith(_isEqual), _map(([user1, user2]) => ({ user1, user2, status: undirectedStatus.KNOW_OF, })), ])(result); await updateUndirectedRelationships(changeset); } main(); diff --git a/server/src/updaters/activity-updaters.js b/server/src/updaters/activity-updaters.js index 28737bd0d..a1d6c19cc 100644 --- a/server/src/updaters/activity-updaters.js +++ b/server/src/updaters/activity-updaters.js @@ -1,302 +1,302 @@ // @flow import type { Viewer } from '../session/viewer'; import type { UpdateActivityResult, UpdateActivityRequest, } from 'lib/types/activity-types'; import { messageTypes } from 'lib/types/message-types'; import { threadPermissions } from 'lib/types/thread-types'; import { updateTypes } from 'lib/types/update-types'; import invariant from 'invariant'; import _difference from 'lodash/fp/difference'; import { ServerError } from 'lib/utils/errors'; import { dbQuery, SQL, mergeOrConditions } from '../database'; import { rescindPushNotifs } from '../push/rescind'; import { createUpdates } from '../creators/update-creator'; import { deleteActivityForViewerSession } from '../deleters/activity-deleters'; import { earliestFocusedTimeConsideredCurrent } from '../shared/focused-times'; import { checkThreadPermissions } from '../fetchers/thread-fetchers'; async function activityUpdater( viewer: Viewer, request: UpdateActivityRequest, ): Promise { if (!viewer.loggedIn) { throw new ServerError('not_logged_in'); } const unverifiedThreadIDs = new Set(); const focusUpdatesByThreadID = new Map(); for (let activityUpdate of request.updates) { if (activityUpdate.closing) { // This was deprecated, but old clients are still sending it continue; } const threadID = activityUpdate.threadID; unverifiedThreadIDs.add(threadID); let updatesForThreadID = focusUpdatesByThreadID.get(threadID); if (!updatesForThreadID) { updatesForThreadID = []; focusUpdatesByThreadID.set(threadID, updatesForThreadID); } updatesForThreadID.push(activityUpdate); } const permissionResults = await checkThreadPermissions( viewer, [...unverifiedThreadIDs], threadPermissions.VISIBLE, ); const verifiedThreadIDs = Object.keys(permissionResults).filter( key => permissionResults[key], ); if (verifiedThreadIDs.length === 0) { return { unfocusedToUnread: [] }; } const viewerMemberThreadsPromise = (async () => { const membershipQuery = SQL` SELECT thread FROM memberships - WHERE role != 0 + WHERE role > 0 AND thread IN (${verifiedThreadIDs}) AND user = ${viewer.userID} `; const [membershipResult] = await dbQuery(membershipQuery); const viewerMemberThreads = new Set(); for (let row of membershipResult) { const threadID = row.thread.toString(); viewerMemberThreads.add(threadID); } return viewerMemberThreads; })(); const currentlyFocused = []; const unfocusedLatestMessages = new Map(); const rescindConditions = []; for (let threadID of verifiedThreadIDs) { const focusUpdates = focusUpdatesByThreadID.get(threadID); invariant(focusUpdates, `no focusUpdate for thread ID ${threadID}`); let focusEndedAt = null; for (let focusUpdate of focusUpdates) { if (focusUpdate.focus === false) { focusEndedAt = focusUpdate.latestMessage ? focusUpdate.latestMessage : '0'; // There should only ever be one of these in a request anyways break; } } if (!focusEndedAt) { currentlyFocused.push(threadID); rescindConditions.push(SQL`n.thread = ${threadID}`); } else { unfocusedLatestMessages.set(threadID, focusEndedAt); rescindConditions.push( SQL`(n.thread = ${threadID} AND n.message <= ${focusEndedAt})`, ); } } const focusUpdatePromise = updateFocusedRows(viewer, currentlyFocused); const [viewerMemberThreads, unfocusedToUnread] = await Promise.all([ viewerMemberThreadsPromise, determineUnfocusedThreadsReadStatus(viewer, unfocusedLatestMessages), ]); const setToRead = [...currentlyFocused]; const setToUnread = []; for (let [threadID] of unfocusedLatestMessages) { if (!unfocusedToUnread.includes(threadID)) { setToRead.push(threadID); } else { setToUnread.push(threadID); } } const filterFunc = threadID => viewerMemberThreads.has(threadID); const memberSetToRead = setToRead.filter(filterFunc); const memberSetToUnread = setToUnread.filter(filterFunc); const time = Date.now(); const updateDatas = []; const appendUpdateDatas = ( threadIDs: $ReadOnlyArray, unread: boolean, ) => { for (let threadID of threadIDs) { updateDatas.push({ type: updateTypes.UPDATE_THREAD_READ_STATUS, userID: viewer.userID, time, threadID, unread, }); } }; const promises = [focusUpdatePromise]; if (memberSetToRead.length > 0) { promises.push( dbQuery(SQL` UPDATE memberships SET unread = 0 WHERE thread IN (${memberSetToRead}) AND user = ${viewer.userID} `), ); appendUpdateDatas(memberSetToRead, false); } if (memberSetToUnread.length > 0) { promises.push( dbQuery(SQL` UPDATE memberships SET unread = 1 WHERE thread IN (${memberSetToUnread}) AND user = ${viewer.userID} `), ); appendUpdateDatas(memberSetToUnread, true); } promises.push( createUpdates(updateDatas, { viewer, updatesForCurrentSession: 'ignore' }), ); await Promise.all(promises); // We do this afterwards so the badge count is correct const rescindCondition = SQL`n.user = ${viewer.userID} AND `; rescindCondition.append(mergeOrConditions(rescindConditions)); await rescindPushNotifs(rescindCondition); return { unfocusedToUnread }; } async function updateFocusedRows( viewer: Viewer, threadIDs: $ReadOnlyArray, ): Promise { const time = Date.now(); if (threadIDs.length > 0) { const focusedInsertRows = threadIDs.map(threadID => [ viewer.userID, viewer.session, threadID, time, ]); await dbQuery(SQL` INSERT INTO focused (user, session, thread, time) VALUES ${focusedInsertRows} ON DUPLICATE KEY UPDATE time = VALUES(time) `); } await deleteActivityForViewerSession(viewer, time); } // To protect against a possible race condition, we reset the thread to unread // if the latest message ID on the client at the time that focus was dropped // is no longer the latest message ID. // Returns the set of unfocused threads that should be set to unread on // the client because a new message arrived since they were unfocused. async function determineUnfocusedThreadsReadStatus( viewer: Viewer, unfocusedLatestMessages: Map, ): Promise { if (unfocusedLatestMessages.size === 0 || !viewer.loggedIn) { return []; } const unfocusedThreadIDs = [...unfocusedLatestMessages.keys()]; const focusedElsewhereThreadIDs = await checkThreadsFocused( viewer, unfocusedThreadIDs, ); const unreadCandidates = _difference(unfocusedThreadIDs)( focusedElsewhereThreadIDs, ); if (unreadCandidates.length === 0) { return []; } const knowOfExtractString = `$.${threadPermissions.KNOW_OF}.value`; const query = SQL` SELECT m.thread, MAX(m.id) AS latest_message FROM messages m LEFT JOIN memberships stm ON m.type = ${messageTypes.CREATE_SUB_THREAD} AND stm.thread = m.content AND stm.user = ${viewer.userID} WHERE m.thread IN (${unreadCandidates}) AND ( m.type != ${messageTypes.CREATE_SUB_THREAD} OR JSON_EXTRACT(stm.permissions, ${knowOfExtractString}) IS TRUE ) GROUP BY m.thread `; const [result] = await dbQuery(query); const resetToUnread = []; for (let row of result) { const threadID = row.thread.toString(); const serverLatestMessage = row.latest_message.toString(); const clientLatestMessage = unfocusedLatestMessages.get(threadID); invariant( clientLatestMessage, 'latest message should be set for all provided threads', ); if ( !clientLatestMessage.startsWith('local') && clientLatestMessage !== serverLatestMessage ) { resetToUnread.push(threadID); } } return resetToUnread; } async function checkThreadsFocused( viewer: Viewer, threadIDs: $ReadOnlyArray, ): Promise { const time = earliestFocusedTimeConsideredCurrent(); const query = SQL` SELECT thread FROM focused WHERE time > ${time} AND user = ${viewer.userID} AND thread IN (${threadIDs}) GROUP BY thread `; const [result] = await dbQuery(query); const focusedThreadIDs = []; for (let row of result) { focusedThreadIDs.push(row.thread.toString()); } return focusedThreadIDs; } // The `focused` table tracks which chat threads are currently in view for a // given cookie. We track this so that if a user is currently viewing a thread's // messages, then notifications on that thread are not sent. This function does // not add new rows to the `focused` table, but instead extends currently active // rows for the current cookie. async function updateActivityTime(viewer: Viewer): Promise { if (!viewer.loggedIn) { return; } const time = Date.now(); const focusedQuery = SQL` UPDATE focused SET time = ${time} WHERE user = ${viewer.userID} AND session = ${viewer.session} `; await dbQuery(focusedQuery); } export { activityUpdater, updateActivityTime }; diff --git a/server/src/updaters/thread-permission-updaters.js b/server/src/updaters/thread-permission-updaters.js index 910f6d28e..4772d3671 100644 --- a/server/src/updaters/thread-permission-updaters.js +++ b/server/src/updaters/thread-permission-updaters.js @@ -1,738 +1,743 @@ // @flow import { type ThreadPermissionsBlob, type ThreadRolePermissionsBlob, type ThreadType, assertThreadType, } from 'lib/types/thread-types'; import type { ThreadSubscription } from 'lib/types/subscription-types'; import type { Viewer } from '../session/viewer'; import { updateTypes, type UpdateInfo } from 'lib/types/update-types'; import type { CalendarQuery } from 'lib/types/entry-types'; import type { AccountUserInfo } from 'lib/types/user-types'; import { type UndirectedRelationshipRow, undirectedStatus, } from 'lib/types/relationship-types'; import invariant from 'invariant'; import _isEqual from 'lodash/fp/isEqual'; import _uniqWith from 'lodash/fp/uniqWith'; import { makePermissionsBlob, makePermissionsForChildrenBlob, } from 'lib/permissions/thread-permissions'; import { sortIDs } from 'lib/shared/relationship-utils'; import { ServerError } from 'lib/utils/errors'; import { cartesianProduct } from 'lib/utils/array'; import { fetchServerThreadInfos, rawThreadInfosFromServerThreadInfos, type FetchThreadInfosResult, } from '../fetchers/thread-fetchers'; import { createUpdates } from '../creators/update-creator'; import { updateDatasForUserPairs, updateUndirectedRelationships, } from '../updaters/relationship-updaters'; import { rescindPushNotifs } from '../push/rescind'; import { dbQuery, SQL, mergeOrConditions } from '../database'; -type MembershipRowToSave = {| +export type MembershipRowToSave = {| operation: 'update' | 'join', userID: string, threadID: string, - permissions: ThreadPermissionsBlob, + permissions: ?ThreadPermissionsBlob, permissionsForChildren: ?ThreadPermissionsBlob, // null role represents by "0" role: string, subscription?: ThreadSubscription, unread?: boolean, |}; type MembershipRowToDelete = {| operation: 'delete', userID: string, threadID: string, |}; type MembershipRow = MembershipRowToSave | MembershipRowToDelete; type Changeset = {| membershipRows: MembershipRow[], relationshipRows: UndirectedRelationshipRow[], |}; // 0 role means to remove the user from the thread // null role means to set the user to the default role // string role means to set the user to the role with that ID async function changeRole( threadID: string, userIDs: $ReadOnlyArray, role: string | 0 | null, ): Promise { const membershipQuery = SQL` SELECT m.user, m.role, m.permissions_for_children, pm.permissions_for_children AS permissions_from_parent FROM memberships m LEFT JOIN threads t ON t.id = m.thread LEFT JOIN memberships pm ON pm.thread = t.parent_thread_id AND pm.user = m.user WHERE m.thread = ${threadID} `; const [[membershipResult], roleThreadResult] = await Promise.all([ dbQuery(membershipQuery), changeRoleThreadQuery(threadID, role), ]); if (!roleThreadResult) { return null; } const roleInfo = new Map(); for (let row of membershipResult) { const userID = row.user.toString(); const oldPermissionsForChildren = row.permissions_for_children; const permissionsFromParent = row.permissions_from_parent; roleInfo.set(userID, { oldRole: row.role.toString(), oldPermissionsForChildren, permissionsFromParent, }); } const relationshipRows = []; const membershipRows = []; const toUpdateDescendants = new Map(); const memberIDs = new Set(roleInfo.keys()); for (let userID of userIDs) { let oldPermissionsForChildren = null; let permissionsFromParent = null; let hadMembershipRow = false; const userRoleInfo = roleInfo.get(userID); if (userRoleInfo) { const oldRole = userRoleInfo.oldRole; if (oldRole === roleThreadResult.roleColumnValue) { // If the old role is the same as the new one, we have nothing to update continue; } else if (Number(oldRole) > 0 && role === null) { // In the case where we're just trying to add somebody to a thread, if // they already have a role with a nonzero role then we don't need to do // anything continue; } oldPermissionsForChildren = userRoleInfo.oldPermissionsForChildren; permissionsFromParent = userRoleInfo.permissionsFromParent; hadMembershipRow = true; } const permissions = makePermissionsBlob( roleThreadResult.rolePermissions, permissionsFromParent, threadID, roleThreadResult.threadType, ); const permissionsForChildren = makePermissionsForChildrenBlob(permissions); if (permissions) { membershipRows.push({ operation: roleThreadResult.roleColumnValue !== '0' && (!userRoleInfo || Number(userRoleInfo.oldRole) <= 0) ? 'join' : 'update', userID, threadID, permissions, permissionsForChildren, role: roleThreadResult.roleColumnValue, }); } else { membershipRows.push({ operation: 'delete', userID, threadID, }); } if (permissions && !hadMembershipRow) { for (const currentUserID of memberIDs) { if (userID !== currentUserID) { const [user1, user2] = sortIDs(userID, currentUserID); relationshipRows.push({ user1, user2, status: undirectedStatus.KNOW_OF, }); } } memberIDs.add(userID); } if (!_isEqual(permissionsForChildren)(oldPermissionsForChildren)) { toUpdateDescendants.set(userID, permissionsForChildren); } } if (toUpdateDescendants.size > 0) { const { membershipRows: descendantMembershipRows, relationshipRows: descendantRelationshipRows, } = await updateDescendantPermissions(threadID, toUpdateDescendants); membershipRows.push(...descendantMembershipRows); relationshipRows.push(...descendantRelationshipRows); } return { membershipRows, relationshipRows }; } type RoleThreadResult = {| roleColumnValue: string, threadType: ThreadType, rolePermissions: ?ThreadRolePermissionsBlob, |}; async function changeRoleThreadQuery( threadID: string, role: string | 0 | null, ): Promise { if (role === 0) { const query = SQL`SELECT type FROM threads WHERE id = ${threadID}`; const [result] = await dbQuery(query); if (result.length === 0) { return null; } const row = result[0]; return { roleColumnValue: '0', threadType: assertThreadType(row.type), rolePermissions: null, }; } else if (role !== null) { const query = SQL` SELECT t.type, r.permissions FROM threads t LEFT JOIN roles r ON r.id = ${role} WHERE t.id = ${threadID} `; const [result] = await dbQuery(query); if (result.length === 0) { return null; } const row = result[0]; return { roleColumnValue: role, threadType: assertThreadType(row.type), rolePermissions: row.permissions, }; } else { const query = SQL` SELECT t.type, t.default_role, r.permissions FROM threads t LEFT JOIN roles r ON r.id = t.default_role WHERE t.id = ${threadID} `; const [result] = await dbQuery(query); if (result.length === 0) { return null; } const row = result[0]; return { roleColumnValue: row.default_role.toString(), threadType: assertThreadType(row.type), rolePermissions: row.permissions, }; } } async function updateDescendantPermissions( initialParentThreadID: string, initialUsersToPermissionsFromParent: Map, ): Promise { const stack = [[initialParentThreadID, initialUsersToPermissionsFromParent]]; const membershipRows = []; const relationshipRows = []; while (stack.length > 0) { const [parentThreadID, usersToPermissionsFromParent] = stack.shift(); const query = SQL` SELECT t.id, m.user, t.type, r.permissions AS role_permissions, m.permissions, m.permissions_for_children, m.role FROM threads t LEFT JOIN memberships m ON m.thread = t.id LEFT JOIN roles r ON r.id = m.role WHERE t.parent_thread_id = ${parentThreadID} `; const [result] = await dbQuery(query); const childThreadInfos = new Map(); for (let row of result) { const threadID = row.id.toString(); if (!childThreadInfos.has(threadID)) { childThreadInfos.set(threadID, { threadType: assertThreadType(row.type), userInfos: new Map(), }); } if (!row.user) { continue; } const childThreadInfo = childThreadInfos.get(threadID); invariant(childThreadInfo, `value should exist for key ${threadID}`); const userID = row.user.toString(); childThreadInfo.userInfos.set(userID, { role: row.role.toString(), rolePermissions: row.role_permissions, permissions: row.permissions, permissionsForChildren: row.permissions_for_children, }); } for (let [threadID, childThreadInfo] of childThreadInfos) { const userInfos = childThreadInfo.userInfos; const usersForNextLayer = new Map(); for (const [ userID, permissionsFromParent, ] of usersToPermissionsFromParent) { const userInfo = userInfos.get(userID); const role = userInfo ? userInfo.role : '0'; const rolePermissions = userInfo ? userInfo.rolePermissions : null; const oldPermissions = userInfo ? userInfo.permissions : null; const oldPermissionsForChildren = userInfo ? userInfo.permissionsForChildren : null; const permissions = makePermissionsBlob( rolePermissions, permissionsFromParent, threadID, childThreadInfo.threadType, ); if (_isEqual(permissions)(oldPermissions)) { // This thread and all of its children need no updates, since its // permissions are unchanged by this operation continue; } const permissionsForChildren = makePermissionsForChildrenBlob( permissions, ); if (permissions) { membershipRows.push({ operation: 'update', userID, threadID, permissions, permissionsForChildren, role, }); } else { membershipRows.push({ operation: 'delete', userID, threadID, }); } if (permissions && !oldPermissions) { for (const [childUserID] of userInfos) { if (childUserID !== userID) { const [user1, user2] = sortIDs(childUserID, userID); const status = undirectedStatus.KNOW_OF; relationshipRows.push({ user1, user2, status }); } } } if (!_isEqual(permissionsForChildren)(oldPermissionsForChildren)) { usersForNextLayer.set(userID, permissionsForChildren); } } if (usersForNextLayer.size > 0) { stack.push([threadID, usersForNextLayer]); } } } return { membershipRows, relationshipRows }; } async function recalculateAllPermissions( threadID: string, threadType: ThreadType, ): Promise { const selectQuery = SQL` SELECT m.user, m.role, m.permissions, m.permissions_for_children, pm.permissions_for_children AS permissions_from_parent, r.permissions AS role_permissions FROM memberships m LEFT JOIN threads t ON t.id = m.thread LEFT JOIN roles r ON r.id = m.role LEFT JOIN memberships pm ON pm.thread = t.parent_thread_id AND pm.user = m.user WHERE m.thread = ${threadID} UNION SELECT pm.user, 0 AS role, NULL AS permissions, NULL AS permissions_for_children, pm.permissions_for_children AS permissions_from_parent, NULL AS role_permissions FROM threads t LEFT JOIN memberships pm ON pm.thread = t.parent_thread_id LEFT JOIN memberships m ON m.thread = t.id AND m.user = pm.user WHERE t.id = ${threadID} AND m.thread IS NULL `; const [selectResult] = await dbQuery(selectQuery); const relationshipRows = []; const membershipRows = []; const toUpdateDescendants = new Map(); const parentIDs = new Set(); const childIDs = selectResult.reduce((acc, row) => { if (row.user && row.role !== 0) { acc.push(row.user.toString()); return acc; } return acc; }, []); for (let row of selectResult) { if (!row.user) { continue; } const userID = row.user.toString(); const role = row.role.toString(); const oldPermissions = JSON.parse(row.permissions); const oldPermissionsForChildren = JSON.parse(row.permissions_for_children); const permissionsFromParent = JSON.parse(row.permissions_from_parent); const rolePermissions = JSON.parse(row.role_permissions); const permissions = makePermissionsBlob( rolePermissions, permissionsFromParent, threadID, threadType, ); if (_isEqual(permissions)(oldPermissions)) { // This thread and all of its children need no updates, since its // permissions are unchanged by this operation continue; } const permissionsForChildren = makePermissionsForChildrenBlob(permissions); if (permissions) { membershipRows.push({ operation: 'update', userID, threadID, permissions, permissionsForChildren, role, }); } else { membershipRows.push({ operation: 'delete', userID, threadID, }); } if (permissions && !oldPermissions) { parentIDs.add(userID); for (const childID of childIDs) { const [user1, user2] = sortIDs(userID, childID); relationshipRows.push({ user1, user2, status: undirectedStatus.KNOW_OF, }); } } if (!_isEqual(permissionsForChildren)(oldPermissionsForChildren)) { toUpdateDescendants.set(userID, permissionsForChildren); } } if (toUpdateDescendants.size > 0) { const { membershipRows: descendantMembershipRows, relationshipRows: descendantRelationshipRows, } = await updateDescendantPermissions(threadID, toUpdateDescendants); membershipRows.push(...descendantMembershipRows); relationshipRows.push( ...descendantRelationshipRows.filter(({ user1, user2 }) => { return ( parentIDs.has(user1.toString()) || parentIDs.has(user2.toString()) ); }), ); } return { membershipRows, relationshipRows }; } const defaultSubscriptionString = JSON.stringify({ home: false, pushNotifs: false, }); const joinSubscriptionString = JSON.stringify({ home: true, pushNotifs: true }); async function saveMemberships(toSave: $ReadOnlyArray) { if (toSave.length === 0) { return; } const time = Date.now(); const insertRows = []; for (let rowToSave of toSave) { let subscription; if (rowToSave.subscription) { subscription = JSON.stringify(rowToSave.subscription); } else if (rowToSave.operation === 'join') { subscription = joinSubscriptionString; } else { subscription = defaultSubscriptionString; } insertRows.push([ rowToSave.userID, rowToSave.threadID, rowToSave.role, time, subscription, - JSON.stringify(rowToSave.permissions), + rowToSave.permissions ? JSON.stringify(rowToSave.permissions) : null, rowToSave.permissionsForChildren ? JSON.stringify(rowToSave.permissionsForChildren) : null, rowToSave.unread ? '1' : '0', ]); } // Logic below will only update an existing membership row's `subscription` // column if the user is either joining or leaving the thread. That means // there's no way to use this function to update a user's subscription without // also making them join or leave the thread. The reason we do this is because // we need to specify a value for `subscription` here, as it's a non-null // column and this is an INSERT, but we don't want to require people to have // to know the current `subscription` when they're just using this function to // update the permissions of an existing membership row. const query = SQL` INSERT INTO memberships (user, thread, role, creation_time, subscription, permissions, permissions_for_children, unread) VALUES ${insertRows} ON DUPLICATE KEY UPDATE subscription = IF( (role <= 0 AND VALUES(role) > 0) OR (role > 0 AND VALUES(role) <= 0), VALUES(subscription), subscription ), role = VALUES(role), permissions = VALUES(permissions), permissions_for_children = VALUES(permissions_for_children) `; await dbQuery(query); } async function deleteMemberships( toDelete: $ReadOnlyArray, ) { if (toDelete.length === 0) { return; } const deleteRows = toDelete.map( rowToDelete => SQL`(user = ${rowToDelete.userID} AND thread = ${rowToDelete.threadID})`, ); const conditions = mergeOrConditions(deleteRows); - const query = SQL`UPDATE memberships SET role = -1 WHERE `; + const query = SQL` + UPDATE memberships + SET role = -1, permissions = NULL, permissions_for_children = NULL, + unread = 0, subscription = ${defaultSubscriptionString} + WHERE `; query.append(conditions); await dbQuery(query); } // Specify non-empty changedThreadIDs to force updates to be generated for those // threads, presumably for reasons not covered in the changeset. calendarQuery // only needs to be specified if a JOIN_THREAD update will be generated for the // viewer, in which case it's necessary for knowing the set of entries to fetch. type ChangesetCommitResult = {| ...FetchThreadInfosResult, viewerUpdates: $ReadOnlyArray, userInfos: { [id: string]: AccountUserInfo }, |}; async function commitMembershipChangeset( viewer: Viewer, changeset: Changeset, changedThreadIDs?: Set = new Set(), calendarQuery?: ?CalendarQuery, ): Promise { if (!viewer.loggedIn) { throw new ServerError('not_logged_in'); } const { membershipRows, relationshipRows } = changeset; const membershipRowMap = new Map(); for (let row of membershipRows) { const { userID, threadID } = row; changedThreadIDs.add(threadID); const pairString = `${userID}|${threadID}`; const existing = membershipRowMap.get(pairString); if ( !existing || (existing.operation !== 'join' && (row.operation === 'join' || (row.operation === 'delete' && existing.operation === 'update'))) ) { membershipRowMap.set(pairString, row); } } const toSave = [], toDelete = [], rescindPromises = []; for (let row of membershipRowMap.values()) { if ( row.operation === 'delete' || (row.operation === 'update' && Number(row.role) <= 0) ) { const { userID, threadID } = row; rescindPromises.push( rescindPushNotifs( SQL`n.thread = ${threadID} AND n.user = ${userID}`, SQL`IF(m.thread = ${threadID}, NULL, m.thread)`, ), ); } if (row.operation === 'delete') { toDelete.push(row); } else { toSave.push(row); } } const uniqueRelationshipRows = _uniqWith(_isEqual)(relationshipRows); await Promise.all([ saveMemberships(toSave), deleteMemberships(toDelete), updateUndirectedRelationships(uniqueRelationshipRows), ...rescindPromises, ]); // We fetch all threads here because old clients still expect the full list of // threads on most thread operations. Once verifyClientSupported gates on // codeVersion 62, we can add a WHERE clause on changedThreadIDs here const serverThreadInfoFetchResult = await fetchServerThreadInfos(); const { threadInfos: serverThreadInfos } = serverThreadInfoFetchResult; const time = Date.now(); const updateDatas = updateDatasForUserPairs( uniqueRelationshipRows.map(({ user1, user2 }) => [user1, user2]), ); for (let changedThreadID of changedThreadIDs) { const serverThreadInfo = serverThreadInfos[changedThreadID]; for (let memberInfo of serverThreadInfo.members) { const pairString = `${memberInfo.id}|${serverThreadInfo.id}`; const membershipRow = membershipRowMap.get(pairString); if (membershipRow && membershipRow.operation !== 'update') { continue; } updateDatas.push({ type: updateTypes.UPDATE_THREAD, userID: memberInfo.id, time, threadID: changedThreadID, }); } } for (let row of membershipRowMap.values()) { const { userID, threadID } = row; if (row.operation === 'join') { updateDatas.push({ type: updateTypes.JOIN_THREAD, userID, time, threadID, }); } else if (row.operation === 'delete') { updateDatas.push({ type: updateTypes.DELETE_THREAD, userID, time, threadID, }); } } const threadInfoFetchResult = rawThreadInfosFromServerThreadInfos( viewer, serverThreadInfoFetchResult, ); const { viewerUpdates, userInfos } = await createUpdates(updateDatas, { viewer, calendarQuery, ...threadInfoFetchResult, updatesForCurrentSession: 'return', }); return { ...threadInfoFetchResult, userInfos, viewerUpdates, }; } function setJoinsToUnread( rows: MembershipRow[], exceptViewerID: string, exceptThreadID: string, ) { for (let row of rows) { if ( row.operation === 'join' && (row.userID !== exceptViewerID || row.threadID !== exceptThreadID) ) { row.unread = true; } } } function getRelationshipRowsForUsers( viewerID: string, userIDs: $ReadOnlyArray, ): UndirectedRelationshipRow[] { return cartesianProduct([viewerID], userIDs).map(pair => { const [user1, user2] = sortIDs(...pair); const status = undirectedStatus.KNOW_OF; return { user1, user2, status }; }); } function getParentThreadRelationshipRowsForNewUsers( threadID: string, recalculateMembershipRows: MembershipRow[], newMemberIDs: $ReadOnlyArray, ): UndirectedRelationshipRow[] { const parentMemberIDs = recalculateMembershipRows .map(rowToSave => rowToSave.userID) .filter(userID => !newMemberIDs.includes(userID)); const newUserIDs = newMemberIDs.filter( memberID => !recalculateMembershipRows.find( rowToSave => rowToSave.userID === memberID && rowToSave.threadID === threadID && rowToSave.operation !== 'delete', ), ); return cartesianProduct(parentMemberIDs, newUserIDs).map(pair => { const [user1, user2] = sortIDs(...pair); const status = undirectedStatus.KNOW_OF; return { user1, user2, status }; }); } export { changeRole, recalculateAllPermissions, + saveMemberships, commitMembershipChangeset, setJoinsToUnread, getRelationshipRowsForUsers, getParentThreadRelationshipRowsForNewUsers, }; diff --git a/server/src/updaters/thread-updaters.js b/server/src/updaters/thread-updaters.js index 635658953..60699fe63 100644 --- a/server/src/updaters/thread-updaters.js +++ b/server/src/updaters/thread-updaters.js @@ -1,628 +1,628 @@ // @flow import { type RoleChangeRequest, type ChangeThreadSettingsResult, type RemoveMembersRequest, type LeaveThreadRequest, type LeaveThreadResult, type UpdateThreadRequest, type ServerThreadJoinRequest, type ThreadJoinResult, threadPermissions, threadTypes, assertThreadType, } from 'lib/types/thread-types'; import type { Viewer } from '../session/viewer'; import { messageTypes, defaultNumberPerThread } from 'lib/types/message-types'; import bcrypt from 'twin-bcrypt'; import _find from 'lodash/fp/find'; import invariant from 'invariant'; import { ServerError } from 'lib/utils/errors'; import { promiseAll } from 'lib/utils/promises'; import { permissionLookup } from 'lib/permissions/thread-permissions'; import { filteredThreadIDs } from 'lib/selectors/calendar-filter-selectors'; import { hasMinCodeVersion } from 'lib/shared/version-utils'; import { dbQuery, SQL } from '../database'; import { verifyUserIDs, verifyUserOrCookieIDs, fetchKnownUserInfos, } from '../fetchers/user-fetchers'; import { checkThreadPermission, fetchServerThreadInfos, viewerIsMember, fetchThreadPermissionsBlob, } from '../fetchers/thread-fetchers'; import { changeRole, recalculateAllPermissions, commitMembershipChangeset, setJoinsToUnread, getParentThreadRelationshipRowsForNewUsers, } from './thread-permission-updaters'; import createMessages from '../creators/message-creator'; import { fetchMessageInfos } from '../fetchers/message-fetchers'; import { fetchEntryInfos } from '../fetchers/entry-fetchers'; import { updateRoles } from './role-updaters'; async function updateRole( viewer: Viewer, request: RoleChangeRequest, ): Promise { if (!viewer.loggedIn) { throw new ServerError('not_logged_in'); } const [memberIDs, hasPermission] = await Promise.all([ verifyUserIDs(request.memberIDs), checkThreadPermission( viewer, request.threadID, threadPermissions.CHANGE_ROLE, ), ]); if (memberIDs.length === 0) { throw new ServerError('invalid_parameters'); } if (!hasPermission) { throw new ServerError('invalid_credentials'); } 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 (let row of result) { - if (!row.role) { + 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); if (!changeset) { throw new ServerError('unknown_error'); } const messageData = { type: messageTypes.CHANGE_ROLE, threadID: request.threadID, creatorID: viewer.userID, time: Date.now(), userIDs: memberIDs, newRole: request.role, }; const [newMessageInfos, { threadInfos, viewerUpdates }] = await Promise.all([ createMessages(viewer, [messageData]), commitMembershipChangeset(viewer, changeset), ]); if (hasMinCodeVersion(viewer.platformDetails, 62)) { return { updatesResult: { newUpdates: viewerUpdates }, newMessageInfos }; } return { threadInfo: threadInfos[request.threadID], threadInfos, 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 (let row of result) { - if (!row.role) { + 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); if (!changeset) { throw new ServerError('unknown_error'); } const messageData = { type: messageTypes.REMOVE_MEMBERS, threadID: request.threadID, creatorID: viewerID, time: Date.now(), removedUserIDs: actualMemberIDs, }; const [newMessageInfos, { threadInfos, viewerUpdates }] = await Promise.all([ createMessages(viewer, [messageData]), commitMembershipChangeset(viewer, changeset), ]); if (hasMinCodeVersion(viewer.platformDetails, 62)) { return { updatesResult: { newUpdates: viewerUpdates }, newMessageInfos }; } return { threadInfo: threadInfos[request.threadID], threadInfos, updatesResult: { newUpdates: viewerUpdates, }, newMessageInfos, }; } async function leaveThread( viewer: Viewer, request: LeaveThreadRequest, ): Promise { if (!viewer.loggedIn) { throw new ServerError('not_logged_in'); } const [isMember, { threadInfos: serverThreadInfos }] = await Promise.all([ viewerIsMember(viewer, request.threadID), fetchServerThreadInfos(SQL`t.id = ${request.threadID}`), ]); if (!isMember) { throw new ServerError('invalid_parameters'); } const serverThreadInfo = serverThreadInfos[request.threadID]; const viewerID = viewer.userID; if (_find({ name: 'Admins' })(serverThreadInfo.roles)) { let otherUsersExist = false; let otherAdminsExist = false; for (let member of serverThreadInfo.members) { const role = member.role; if (!role || member.id === viewerID) { continue; } otherUsersExist = true; if (serverThreadInfo.roles[role].name === 'Admins') { otherAdminsExist = true; break; } } if (otherUsersExist && !otherAdminsExist) { throw new ServerError('invalid_parameters'); } } const changeset = await changeRole(request.threadID, [viewerID], 0); if (!changeset) { throw new ServerError('unknown_error'); } const messageData = { type: messageTypes.LEAVE_THREAD, threadID: request.threadID, creatorID: viewerID, time: Date.now(), }; const [{ threadInfos, viewerUpdates }] = await Promise.all([ commitMembershipChangeset(viewer, changeset), createMessages(viewer, [messageData]), ]); if (hasMinCodeVersion(viewer.platformDetails, 62)) { return { updatesResult: { newUpdates: viewerUpdates } }; } return { threadInfos, updatesResult: { newUpdates: viewerUpdates, }, }; } async function updateThread( viewer: Viewer, request: UpdateThreadRequest, ): Promise { if (!viewer.loggedIn) { throw new ServerError('not_logged_in'); } const validationPromises = {}; const changedFields = {}; const sqlUpdate = {}; const name = request.changes.name; if (name !== undefined && name !== null) { changedFields.name = request.changes.name; sqlUpdate.name = request.changes.name ? request.changes.name : null; } const description = request.changes.description; if (description !== undefined && description !== null) { changedFields.description = request.changes.description; sqlUpdate.description = request.changes.description ? request.changes.description : null; } if (request.changes.color) { const color = request.changes.color.toLowerCase(); changedFields.color = color; sqlUpdate.color = color; } const parentThreadID = request.changes.parentThreadID; if (parentThreadID !== undefined) { if (parentThreadID !== null) { validationPromises.canMoveThread = checkThreadPermission( viewer, parentThreadID, threadPermissions.CREATE_SUBTHREADS, ); } // TODO some sort of message when this changes sqlUpdate.parent_thread_id = parentThreadID; } const threadType = request.changes.type; if (threadType !== null && threadType !== undefined) { changedFields.type = threadType; sqlUpdate.type = threadType; } const newMemberIDs = request.changes.newMemberIDs && request.changes.newMemberIDs.length > 0 ? [...request.changes.newMemberIDs] : null; if (newMemberIDs) { validationPromises.fetchNewMembers = fetchKnownUserInfos( viewer, newMemberIDs, ); } validationPromises.threadPermissionsBlob = fetchThreadPermissionsBlob( viewer, request.threadID, ); // Two unrelated purposes for this query: // - get hash for viewer password check (users table) // - get current value of type, parent_thread_id (threads table) const validationQuery = SQL` SELECT u.hash, t.type, t.parent_thread_id FROM users u LEFT JOIN threads t ON t.id = ${request.threadID} WHERE u.id = ${viewer.userID} `; validationPromises.validationQuery = dbQuery(validationQuery); const { canMoveThread, threadPermissionsBlob, fetchNewMembers, validationQuery: [validationResult], } = await promiseAll(validationPromises); if (canMoveThread === false) { throw new ServerError('invalid_credentials'); } if (fetchNewMembers) { invariant(newMemberIDs, 'should be set'); for (const newMemberID of newMemberIDs) { if (!fetchNewMembers[newMemberID]) { throw new ServerError('invalid_credentials'); } } } if (Object.keys(sqlUpdate).length === 0 && !newMemberIDs) { throw new ServerError('invalid_parameters'); } if (validationResult.length === 0 || validationResult[0].type === null) { throw new ServerError('internal_error'); } const validationRow = validationResult[0]; if (sqlUpdate.name || sqlUpdate.description || sqlUpdate.color) { const canEditThread = permissionLookup( threadPermissionsBlob, threadPermissions.EDIT_THREAD, ); if (!canEditThread) { throw new ServerError('invalid_credentials'); } } if (sqlUpdate.parent_thread_id || sqlUpdate.type) { const canEditPermissions = permissionLookup( threadPermissionsBlob, threadPermissions.EDIT_PERMISSIONS, ); if (!canEditPermissions) { throw new ServerError('invalid_credentials'); } if ( !request.accountPassword || !bcrypt.compareSync(request.accountPassword, validationRow.hash) ) { throw new ServerError('invalid_credentials'); } } if (newMemberIDs) { const canAddMembers = permissionLookup( threadPermissionsBlob, threadPermissions.ADD_MEMBERS, ); if (!canAddMembers) { throw new ServerError('invalid_credentials'); } } const oldThreadType = assertThreadType(validationRow.type); const oldParentThreadID = validationRow.parentThreadID ? validationRow.parentThreadID.toString() : null; // If the thread is being switched to nested, a parent must be specified if ( oldThreadType === threadTypes.CHAT_SECRET && threadType !== threadTypes.CHAT_SECRET && oldParentThreadID === null && parentThreadID === null ) { throw new ServerError('no_parent_thread_specified'); } const nextThreadType = threadType !== null && threadType !== undefined ? threadType : oldThreadType; const nextParentThreadID = parentThreadID ? parentThreadID : oldParentThreadID; const intermediatePromises = {}; if (Object.keys(sqlUpdate).length > 0) { const updateQuery = SQL` UPDATE threads SET ${sqlUpdate} WHERE id = ${request.threadID} `; intermediatePromises.updateQuery = dbQuery(updateQuery); } if (newMemberIDs) { intermediatePromises.addMembersChangeset = changeRole( request.threadID, newMemberIDs, null, ); } if ( nextThreadType !== oldThreadType || nextParentThreadID !== oldParentThreadID ) { intermediatePromises.recalculatePermissionsChangeset = (async () => { if (nextThreadType !== oldThreadType) { await updateRoles(viewer, request.threadID, nextThreadType); } return await recalculateAllPermissions(request.threadID, nextThreadType); })(); } const { addMembersChangeset, recalculatePermissionsChangeset, } = await promiseAll(intermediatePromises); const membershipRows = []; const relationshipRows = []; if (recalculatePermissionsChangeset && newMemberIDs) { const { membershipRows: recalculateMembershipRows, relationshipRows: recalculateRelationshipRows, } = recalculatePermissionsChangeset; membershipRows.push(...recalculateMembershipRows); const parentRelationshipRows = getParentThreadRelationshipRowsForNewUsers( request.threadID, recalculateMembershipRows, newMemberIDs, ); relationshipRows.push( ...recalculateRelationshipRows, ...parentRelationshipRows, ); } else if (recalculatePermissionsChangeset) { const { membershipRows: recalculateMembershipRows, relationshipRows: recalculateRelationshipRows, } = recalculatePermissionsChangeset; membershipRows.push(...recalculateMembershipRows); relationshipRows.push(...recalculateRelationshipRows); } if (addMembersChangeset) { const { membershipRows: addMembersMembershipRows, relationshipRows: addMembersRelationshipRows, } = addMembersChangeset; relationshipRows.push(...addMembersRelationshipRows); setJoinsToUnread(addMembersMembershipRows, viewer.userID, request.threadID); membershipRows.push(...addMembersMembershipRows); } const time = Date.now(); const messageDatas = []; for (let 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 (newMemberIDs) { messageDatas.push({ type: messageTypes.ADD_MEMBERS, threadID: request.threadID, creatorID: viewer.userID, time, addedUserIDs: newMemberIDs, }); } const changeset = { membershipRows, relationshipRows }; const [newMessageInfos, { threadInfos, viewerUpdates }] = await Promise.all([ createMessages(viewer, messageDatas), commitMembershipChangeset( viewer, changeset, // This forces an update for this thread, // regardless of whether any membership rows are changed Object.keys(sqlUpdate).length > 0 ? new Set([request.threadID]) : new Set(), ), ]); if (hasMinCodeVersion(viewer.platformDetails, 62)) { return { updatesResult: { newUpdates: viewerUpdates }, newMessageInfos }; } return { threadInfo: threadInfos[request.threadID], threadInfos, updatesResult: { newUpdates: viewerUpdates, }, newMessageInfos, }; } async function joinThread( viewer: Viewer, request: ServerThreadJoinRequest, ): Promise { if (!viewer.loggedIn) { throw new ServerError('not_logged_in'); } const [isMember, hasPermission] = await Promise.all([ viewerIsMember(viewer, request.threadID), checkThreadPermission( viewer, request.threadID, threadPermissions.JOIN_THREAD, ), ]); if (isMember || !hasPermission) { throw new ServerError('invalid_parameters'); } const { calendarQuery } = request; 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); if (!changeset) { throw new ServerError('unknown_error'); } setJoinsToUnread(changeset.membershipRows, viewer.userID, request.threadID); const messageData = { type: messageTypes.JOIN_THREAD, threadID: request.threadID, creatorID: viewer.userID, time: Date.now(), }; const [membershipResult] = await Promise.all([ commitMembershipChangeset(viewer, changeset, new Set(), calendarQuery), createMessages(viewer, [messageData]), ]); const threadSelectionCriteria = { threadCursors: { [request.threadID]: false }, }; const [fetchMessagesResult, fetchEntriesResult] = await Promise.all([ fetchMessageInfos(viewer, threadSelectionCriteria, defaultNumberPerThread), calendarQuery ? fetchEntryInfos(viewer, [calendarQuery]) : undefined, ]); const rawEntryInfos = fetchEntriesResult && fetchEntriesResult.rawEntryInfos; const response: ThreadJoinResult = { rawMessageInfos: fetchMessagesResult.rawMessageInfos, truncationStatuses: fetchMessagesResult.truncationStatuses, userInfos: membershipResult.userInfos, updatesResult: { newUpdates: membershipResult.viewerUpdates, }, }; if (!hasMinCodeVersion(viewer.platformDetails, 62)) { response.threadInfos = membershipResult.threadInfos; } if (rawEntryInfos) { response.rawEntryInfos = rawEntryInfos; } return response; } export { updateRole, removeMembers, leaveThread, updateThread, joinThread };