From 1b4105a3d0c1ac7cef4d643ac4bd4eaca1a86e6a Mon Sep 17 00:00:00 2001 From: Beaudan Brown Date: Fri, 30 Aug 2019 14:58:40 +1000 Subject: [PATCH 1/4] Fix db stuff and remove unneeded message function --- app/sql.js | 9 ++++++--- js/models/messages.js | 6 +++--- js/modules/data.js | 4 ++-- 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/app/sql.js b/app/sql.js index 77bde9694..bd1e4f1e6 100644 --- a/app/sql.js +++ b/app/sql.js @@ -790,7 +790,7 @@ async function updateToLokiSchemaVersion1(currentVersion, instance) { await instance.run( `ALTER TABLE messages - ADD COLUMN serverId STRING;` + ADD COLUMN serverId INTEGER;` ); await instance.run( @@ -2060,11 +2060,14 @@ async function removeMessage(id) { ); } -async function getMessageByServerId(serverId) { +async function getMessageByServerId(serverId, conversationId) { const row = await db.get( - 'SELECT * FROM messages WHERE serverId = $serverId;', + `SELECT * FROM messages WHERE + serverId = $serverId AND + conversationId = $conversationId;`, { $serverId: serverId, + $conversationId: conversationId, } ); diff --git a/js/models/messages.js b/js/models/messages.js index b79307473..1c79f2858 100644 --- a/js/models/messages.js +++ b/js/models/messages.js @@ -357,9 +357,6 @@ onDestroy() { this.cleanup(); }, - deleteMessage() { - this.trigger('delete', this); - }, async cleanup() { MessageController.unregister(this.id); this.unload(); @@ -1243,6 +1240,9 @@ Message: Whisper.Message, }); }, + getServerId() { + return this.get('serverId'); + }, async setServerId(serverId) { if (_.isEqual(this.get('serverId'), serverId)) return; diff --git a/js/modules/data.js b/js/modules/data.js index 80c21bdee..c410f9464 100644 --- a/js/modules/data.js +++ b/js/modules/data.js @@ -908,8 +908,8 @@ async function _removeMessages(ids) { await channels.removeMessage(ids); } -async function getMessageByServerId(id, { Message }) { - const message = await channels.getMessageByServerId(id); +async function getMessageByServerId(serverId, conversationId, { Message }) { + const message = await channels.getMessageByServerId(serverId, conversationId); if (!message) { return null; } From e4ecc5b389944ab56bbcfdfe65e10b4cdcb9aa3d Mon Sep 17 00:00:00 2001 From: Beaudan Brown Date: Fri, 30 Aug 2019 15:01:16 +1000 Subject: [PATCH 2/4] A prepare for message deletion --- _locales/en/messages.json | 4 ++++ js/background.js | 23 +++++++++++++++++++++++ js/models/conversations.js | 25 +++++++++++++++++++++++++ ts/components/conversation/Message.tsx | 2 +- 4 files changed, 53 insertions(+), 1 deletion(-) diff --git a/_locales/en/messages.json b/_locales/en/messages.json index daa221739..729cc1181 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -948,6 +948,10 @@ "delete": { "message": "Delete" }, + "deletePublicWarning": { + "message": + "Are you sure? Clicking 'delete' will permanently remove this message for everyone in this channel." + }, "deleteWarning": { "message": "Are you sure? Clicking 'delete' will permanently remove this message from this device only." diff --git a/js/background.js b/js/background.js index a791a9ccc..705b936ea 100644 --- a/js/background.js +++ b/js/background.js @@ -487,6 +487,28 @@ } }); + Whisper.events.on( + 'deleteLocalPublicMessage', + async ({ messageServerId, conversationId }) => { + const message = await window.Signal.Data.getMessageByServerId( + messageServerId, + conversationId, + { + Message: Whisper.Message, + } + ); + if (message) { + const conversation = ConversationController.get(conversationId); + if (conversation) { + conversation.removeMessage(message.id); + } + await window.Signal.Data.removeMessage(message.id, { + Message: Whisper.Message, + }); + } + } + ); + Whisper.events.on('setupAsNewDevice', () => { const { appView } = window.owsDesktopApp; if (appView) { @@ -1418,6 +1440,7 @@ let messageData = { source: data.source, sourceDevice: data.sourceDevice, + serverId: data.serverId, sent_at: data.timestamp, received_at: data.receivedAt || Date.now(), conversationId: data.source, diff --git a/js/models/conversations.js b/js/models/conversations.js index 4b674503e..e28762909 100644 --- a/js/models/conversations.js +++ b/js/models/conversations.js @@ -2288,6 +2288,31 @@ }); }, + async deletePublicMessage(message) { + const serverAPI = lokiPublicChatAPI.findOrCreateServer( + this.get('server') + ); + const channelAPI = serverAPI.findOrCreateChannel( + this.get('channelId'), + this.id + ); + const success = await channelAPI.deleteMessage(message.getServerId()); + if (success) { + this.removeMessage(message.id); + } + return success; + }, + + removeMessage(messageId) { + const message = this.messageCollection.models.find( + msg => msg.id === messageId + ); + if (message) { + message.trigger('unload'); + this.messageCollection.remove(messageId); + } + }, + deleteMessages() { Whisper.events.trigger('showConfirmationDialog', { message: i18n('deleteConversationConfirmation'), diff --git a/ts/components/conversation/Message.tsx b/ts/components/conversation/Message.tsx index e9489ed96..7fdebbadc 100644 --- a/ts/components/conversation/Message.tsx +++ b/ts/components/conversation/Message.tsx @@ -257,7 +257,7 @@ export class Message extends React.PureComponent { `module-message__metadata__${badgeType}--${direction}` )} > -  • ${badgeText} +  • {badgeText} ) : null} {expirationLength && expirationTimestamp ? ( From 351fa09ad6c9eb6de46083625ebac8d7f6e85ddf Mon Sep 17 00:00:00 2001 From: Beaudan Brown Date: Fri, 30 Aug 2019 15:02:17 +1000 Subject: [PATCH 3/4] Only show delete option if have mod status and show different modal for mod deletion --- js/models/conversations.js | 17 +++++++++++++++++ js/models/messages.js | 2 ++ js/views/conversation_view.js | 23 +++++++++++++++++++++++ ts/components/conversation/Message.tsx | 20 ++++++++++++-------- 4 files changed, 54 insertions(+), 8 deletions(-) diff --git a/js/models/conversations.js b/js/models/conversations.js index e28762909..fc6f4b85f 100644 --- a/js/models/conversations.js +++ b/js/models/conversations.js @@ -2085,6 +2085,23 @@ token, }; }, + getModStatus() { + if (!this.isPublic()) { + return false; + } + return this.get('modStatus'); + }, + async setModStatus(newStatus) { + if (!this.isPublic()) { + return; + } + if (this.get('modStatus') !== newStatus) { + this.set({ modStatus: newStatus }); + await window.Signal.Data.updateConversation(this.id, this.attributes, { + Conversation: Whisper.Conversation, + }); + } + }, // SIGNAL PROFILES diff --git a/js/models/messages.js b/js/models/messages.js index 1c79f2858..cc82e1a0c 100644 --- a/js/models/messages.js +++ b/js/models/messages.js @@ -672,6 +672,8 @@ isP2p: !!this.get('isP2p'), isPublic: !!this.get('isPublic'), isRss: !!this.get('isRss'), + isDeletable: + !this.get('isPublic') || this.getConversation().getModStatus(), onCopyText: () => this.copyText(), onReply: () => this.trigger('reply', this), diff --git a/js/views/conversation_view.js b/js/views/conversation_view.js index edea0036d..055a829a6 100644 --- a/js/views/conversation_view.js +++ b/js/views/conversation_view.js @@ -1291,6 +1291,29 @@ }, deleteMessage(message) { + if (this.model.isPublic()) { + const dialog = new Whisper.ConfirmationDialogView({ + message: i18n('deletePublicWarning'), + okText: i18n('delete'), + resolve: async () => { + const success = await this.model.deletePublicMessage(message); + if (!success) { + // Message failed to delete from server, show error? + return; + } + await window.Signal.Data.removeMessage(message.id, { + Message: Whisper.Message, + }); + message.trigger('unload'); + this.resetPanel(); + this.updateHeader(); + }, + }); + + this.$el.prepend(dialog.el); + dialog.focusCancel(); + return; + } const dialog = new Whisper.ConfirmationDialogView({ message: i18n('deleteWarning'), okText: i18n('delete'), diff --git a/ts/components/conversation/Message.tsx b/ts/components/conversation/Message.tsx index 7fdebbadc..dba0d52de 100644 --- a/ts/components/conversation/Message.tsx +++ b/ts/components/conversation/Message.tsx @@ -48,6 +48,7 @@ interface LinkPreviewType { export interface Props { disableMenu?: boolean; + isDeletable: boolean; text?: string; textPending?: boolean; id?: string; @@ -819,6 +820,7 @@ export class Message extends React.PureComponent { onCopyText, direction, status, + isDeletable, onDelete, onDownload, onReply, @@ -876,14 +878,16 @@ export class Message extends React.PureComponent { {i18n('retrySend')} ) : null} - - {i18n('deleteMessage')} - + {isDeletable ? ( + + {i18n('deleteMessage')} + + ) : null} ); } From 8d77d6fd79d74cd8e02b27e9c9425c1015037f7e Mon Sep 17 00:00:00 2001 From: Beaudan Brown Date: Fri, 30 Aug 2019 15:07:45 +1000 Subject: [PATCH 4/4] All the API updates, enable mod status retrieval and allow for message deletion --- js/background.js | 3 +- js/modules/loki_public_chat_api.js | 222 +++++++++++++++++++++-------- 2 files changed, 163 insertions(+), 62 deletions(-) diff --git a/js/background.js b/js/background.js index 705b936ea..799d8f8f3 100644 --- a/js/background.js +++ b/js/background.js @@ -224,11 +224,12 @@ ); publicConversations.forEach(conversation => { const settings = conversation.getPublicSource(); - window.lokiPublicChatAPI.registerChannel( + const channel = window.lokiPublicChatAPI.findOrCreateChannel( settings.server, settings.channelId, conversation.id ); + channel.refreshModStatus(); }); window.lokiP2pAPI = new window.LokiP2pAPI(ourKey); window.lokiP2pAPI.on('pingContact', pubKey => { diff --git a/js/modules/loki_public_chat_api.js b/js/modules/loki_public_chat_api.js index 89d0fea7c..32bec06d1 100644 --- a/js/modules/loki_public_chat_api.js +++ b/js/modules/loki_public_chat_api.js @@ -1,43 +1,46 @@ -/* global log, textsecure, libloki, Signal */ +/* global log, textsecure, libloki, Signal, Whisper, Headers, ConversationController */ const EventEmitter = require('events'); const nodeFetch = require('node-fetch'); const { URL, URLSearchParams } = require('url'); -const GROUPCHAT_POLL_EVERY = 1000; // 1 second +// Can't be less than 1200 if we have unauth'd requests +const GROUPCHAT_POLL_EVERY = 1500; // 1.5s +const DELETION_POLL_EVERY = 5000; // 1 second // singleton to relay events to libtextsecure/message_receiver class LokiPublicChatAPI extends EventEmitter { constructor(ourKey) { super(); this.ourKey = ourKey; - this.lastGot = {}; this.servers = []; } - findOrCreateServer(hostport) { - log.info(`LokiPublicChatAPI looking for ${hostport}`); - let thisServer = this.servers.find(server => server.server === hostport); + findOrCreateServer(serverUrl) { + let thisServer = this.servers.find( + server => server.baseServerUrl === serverUrl + ); if (!thisServer) { - thisServer = new LokiPublicServerAPI(this, hostport); + log.info(`LokiPublicChatAPI creating ${serverUrl}`); + thisServer = new LokiPublicServerAPI(this, serverUrl); this.servers.push(thisServer); } return thisServer; } - registerChannel(hostport, channelId, conversationId) { - const server = this.findOrCreateServer(hostport); - server.findOrCreateChannel(channelId, conversationId); + findOrCreateChannel(serverUrl, channelId, conversationId) { + const server = this.findOrCreateServer(serverUrl); + return server.findOrCreateChannel(channelId, conversationId); } - unregisterChannel(hostport, channelId) { + unregisterChannel(serverUrl, channelId) { let thisServer; let i = 0; for (; i < this.servers.length; i += 1) { - if (this.servers[i].server === hostport) { + if (this.servers[i].server === serverUrl) { thisServer = this.servers[i]; break; } } if (!thisServer) { - log.warn(`Tried to unregister from nonexistent server ${hostport}`); + log.warn(`Tried to unregister from nonexistent server ${serverUrl}`); return; } thisServer.unregisterChannel(channelId); @@ -57,6 +60,7 @@ class LokiPublicServerAPI { channel => channel.channelId === channelId ); if (!thisChannel) { + log.info(`LokiPublicChatAPI creating channel ${conversationId}`); thisChannel = new LokiPublicChannelAPI(this, channelId, conversationId); this.channels.push(thisChannel); } @@ -79,6 +83,9 @@ class LokiPublicServerAPI { } async getOrRefreshServerToken() { + if (this.token) { + return this.token; + } let token = await Signal.Data.getPublicServerTokenByServerUrl( this.baseServerUrl ); @@ -91,6 +98,7 @@ class LokiPublicServerAPI { }); } } + this.token = token; return token; } @@ -164,9 +172,7 @@ class LokiPublicChannelAPI { constructor(serverAPI, channelId, conversationId) { this.serverAPI = serverAPI; this.channelId = channelId; - this.baseChannelUrl = `${serverAPI.baseServerUrl}/channels/${ - this.channelId - }`; + this.baseChannelUrl = `channels/${this.channelId}`; this.groupName = 'unknown'; this.conversationId = conversationId; this.lastGot = 0; @@ -174,85 +180,169 @@ class LokiPublicChannelAPI { log.info(`registered LokiPublicChannel ${channelId}`); // start polling this.pollForMessages(); + this.deleteLastId = 1; + this.pollForDeletions(); + } + + async refreshModStatus() { + const url = new URL(`${this.serverAPI.baseServerUrl}/loki/v1/user_info`); + const token = await this.serverAPI.getOrRefreshServerToken(); + let modStatus = false; + try { + const result = await nodeFetch(url, { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + }); + if (result.ok) { + const response = await result.json(); + if (response.data.moderator_status) { + modStatus = response.data.moderator_status; + } + } + } catch (e) { + modStatus = false; + } + const conversation = ConversationController.get(this.conversationId); + await conversation.setModStatus(modStatus); + } + + async deleteMessage(messageServerId) { + // TODO: Allow deletion of your own messages without moderator status + const url = new URL( + `${ + this.serverAPI.baseServerUrl + }/loki/v1/moderation/message/${messageServerId}` + ); + const token = await this.serverAPI.getOrRefreshServerToken(); + try { + const result = await nodeFetch(url, { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + }); + // 200 for successful delete + // 404 for trying to delete a message that doesn't exist + // 410 for successful moderator delete + const validResults = [404, 410]; + if (result.ok || validResults.includes(result.status)) { + return true; + } + } catch (e) { + log.warn( + `Failed to delete message from public server with ID ${messageServerId}` + ); + } + return false; } getEndpoint() { - const endpoint = `${this.serverAPI.baseServerUrl}/channels/${ - this.channelId + const endpoint = `${this.serverAPI.baseServerUrl}/${ + this.baseChannelUrl }/messages`; return endpoint; } - async pollForChannel(source, endpoint) { - // groupName will be loaded from server - const url = new URL(this.baseChannelUrl); + async serverRequest(endpoint, params, method) { + const url = new URL(`${this.serverAPI.baseServerUrl}/${endpoint}`); + url.search = new URLSearchParams(params); let res; - let success = true; + const token = await this.serverAPI.getOrRefreshServerToken(); + if (!token) { + log.error('NO TOKEN'); + return { + err: 'noToken', + }; + } try { - res = await nodeFetch(url); + // eslint-disable-next-line no-await-in-loop + const options = { + headers: new Headers({ + Authorization: `Bearer ${token}`, + }), + }; + if (method) { + options.method = method; + } + res = await nodeFetch(url, options || undefined); } catch (e) { - success = false; + log.info(`e ${e}`); + return { + err: e, + }; } - + // eslint-disable-next-line no-await-in-loop const response = await res.json(); if (response.meta.code !== 200) { - success = false; + return { + err: 'statusCode', + response, + }; } - // update this.groupId - return endpoint || success; + return { + response, + }; } async pollForDeletions() { // read all messages from 0 to current // delete local copies if server state has changed to delete // run every minute - const url = new URL(this.baseChannelUrl); - let res; - let success = true; - try { - res = await nodeFetch(url); - } catch (e) { - success = false; - } + const pollAgain = () => { + setTimeout(() => { + this.pollForDeletions(); + }, DELETION_POLL_EVERY); + }; - const response = await res.json(); - if (response.meta.code !== 200) { - success = false; + const params = { + count: 200, + }; + + // full scan + let more = true; + while (more) { + params.since_id = this.deleteLastId; + const res = await this.serverRequest( + `loki/v1/channel/${this.channelId}/deletes`, + params + ); + + // eslint-disable-next-line no-loop-func + res.response.data.reverse().forEach(deleteEntry => { + Whisper.events.trigger('deleteLocalPublicMessage', { + messageServerId: deleteEntry.message_id, + conversationId: this.conversationId, + }); + }); + if (res.response.data.length < 200) { + break; + } + this.deleteLastId = res.response.meta.max_id; + ({ more } = res.response); } - return success; + pollAgain(); } async pollForMessages() { - const url = new URL(`${this.baseChannelUrl}/messages`); const params = { include_annotations: 1, count: -20, + include_deleted: false, }; if (this.lastGot) { params.since_id = this.lastGot; } - url.search = new URLSearchParams(params); - - let res; - let success = true; - try { - res = await nodeFetch(url); - } catch (e) { - success = false; - } - - const response = await res.json(); - if (this.stopPolling) { - // Stop after latest await possible - return; - } - if (response.meta.code !== 200) { - success = false; - } + const res = await this.serverRequest( + `${this.baseChannelUrl}/messages`, + params + ); - if (success) { + if (!res.err && res.response) { let receivedAt = new Date().getTime(); - response.data.reverse().forEach(adnMessage => { + res.response.data.reverse().forEach(adnMessage => { let timestamp = new Date(adnMessage.created_at).getTime(); let from = adnMessage.user.username; let source; @@ -264,6 +354,16 @@ class LokiPublicChannelAPI { ({ from, timestamp, source } = noteValue); } + if ( + !from || + !timestamp || + !source || + !adnMessage.id || + !adnMessage.text + ) { + return; // Invalid message + } + const messageData = { serverId: adnMessage.id, friendRequest: false,