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/app/sql.js b/app/sql.js index ac5d5ec2c..bd1e4f1e6 100644 --- a/app/sql.js +++ b/app/sql.js @@ -94,9 +94,14 @@ module.exports = { saveConversation, saveConversations, getConversationById, + savePublicServerToken, + getPublicServerTokenByServerUrl, updateConversation, removeConversation, getAllConversations, + getAllRssFeedConversations, + getAllPublicConversations, + getPublicConversationsByServer, getPubKeysWithFriendStatus, getAllConversationIds, getAllPrivateConversations, @@ -117,6 +122,7 @@ module.exports = { removeMessage, getUnreadByConversation, getMessageBySender, + getMessageByServerId, getMessageById, getAllMessages, getAllMessageIds, @@ -770,6 +776,188 @@ async function updateSchema(instance) { // eslint-disable-next-line no-await-in-loop await runSchemaUpdate(schemaVersion, instance); } + await updateLokiSchema(instance); +} + +const LOKI_SCHEMA_VERSIONS = [updateToLokiSchemaVersion1]; + +async function updateToLokiSchemaVersion1(currentVersion, instance) { + if (currentVersion >= 1) { + return; + } + console.log('updateToLokiSchemaVersion1: starting...'); + await instance.run('BEGIN TRANSACTION;'); + + await instance.run( + `ALTER TABLE messages + ADD COLUMN serverId INTEGER;` + ); + + await instance.run( + `CREATE TABLE servers( + serverUrl STRING PRIMARY KEY ASC, + token TEXT + );` + ); + + const initConversation = async data => { + const { id, type, name, friendRequestStatus } = data; + await instance.run( + `INSERT INTO conversations ( + id, + json, + + type, + members, + name, + friendRequestStatus + ) values ( + $id, + $json, + + $type, + $members, + $name, + $friendRequestStatus + );`, + { + $id: id, + $json: objectToJSON(data), + + $type: type, + $members: null, + $name: name, + $friendRequestStatus: friendRequestStatus, + } + ); + }; + + const lokiPublicServerData = { + serverUrl: 'https://chat.lokinet.org', + token: null, + }; + + const baseData = { + friendRequestStatus: 4, // Friends + sealedSender: 0, + sessionResetStatus: 0, + swarmNodes: [], + type: 'group', + unlockTimestamp: null, + unreadCount: 0, + verified: 0, + version: 2, + }; + + const publicChatData = { + ...baseData, + id: 'publicChat:1@chat.lokinet.org', + server: lokiPublicServerData.serverUrl, + name: 'Loki Public Chat', + channelId: '1', + }; + + const { serverUrl, token } = lokiPublicServerData; + + await instance.run( + `INSERT INTO servers ( + serverUrl, + token + ) values ( + $serverUrl, + $token + );`, + { + $serverUrl: serverUrl, + $token: token, + } + ); + + const newsRssFeedData = { + ...baseData, + id: 'rss://loki.network/feed/', + rssFeed: 'https://loki.network/feed/', + closable: true, + name: 'Loki.network News', + profileAvatar: 'images/loki/loki_icon.png', + }; + + const updatesRssFeedData = { + ...baseData, + id: 'rss://loki.network/category/messenger-updates/feed/', + rssFeed: 'https://loki.network/category/messenger-updates/feed/', + closable: false, + name: 'Messenger updates', + profileAvatar: 'images/loki/loki_icon.png', + }; + + await initConversation(publicChatData); + await initConversation(newsRssFeedData); + await initConversation(updatesRssFeedData); + + await instance.run( + `INSERT INTO loki_schema ( + version + ) values ( + 1 + );` + ); + await instance.run('COMMIT TRANSACTION;'); + console.log('updateToLokiSchemaVersion1: success!'); +} + +async function updateLokiSchema(instance) { + const result = await instance.get( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name='loki_schema';" + ); + if (!result) { + await createLokiSchemaTable(instance); + } + const lokiSchemaVersion = await getLokiSchemaVersion(instance); + console.log( + 'updateLokiSchema:', + `Current loki schema version: ${lokiSchemaVersion};`, + `Most recent schema version: ${LOKI_SCHEMA_VERSIONS.length};` + ); + for ( + let index = 0, max = LOKI_SCHEMA_VERSIONS.length; + index < max; + index += 1 + ) { + const runSchemaUpdate = LOKI_SCHEMA_VERSIONS[index]; + + // Yes, we really want to do this asynchronously, in order + // eslint-disable-next-line no-await-in-loop + await runSchemaUpdate(lokiSchemaVersion, instance); + } +} + +async function getLokiSchemaVersion(instance) { + const result = await instance.get( + 'SELECT MAX(version) as version FROM loki_schema;' + ); + if (!result || !result.version) { + return 0; + } + return result.version; +} + +async function createLokiSchemaTable(instance) { + await instance.run('BEGIN TRANSACTION;'); + await instance.run( + `CREATE TABLE loki_schema( + id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + version INTEGER + );` + ); + await instance.run( + `INSERT INTO loki_schema ( + version + ) values ( + 0 + );` + ); + await instance.run('COMMIT TRANSACTION;'); } let db; @@ -1433,6 +1621,38 @@ async function removeConversation(id) { ); } +async function savePublicServerToken(data) { + const { serverUrl, token } = data; + await db.run( + `INSERT OR REPLACE INTO servers ( + serverUrl, + token + ) values ( + $serverUrl, + $token + )`, + { + $serverUrl: serverUrl, + $token: token, + } + ); +} + +async function getPublicServerTokenByServerUrl(serverUrl) { + const row = await db.get( + 'SELECT * FROM servers WHERE serverUrl = $serverUrl;', + { + $serverUrl: serverUrl, + } + ); + + if (!row) { + return null; + } + + return row.token; +} + async function getConversationById(id) { const row = await db.get('SELECT * FROM conversations WHERE id = $id;', { $id: id, @@ -1477,6 +1697,41 @@ async function getAllPrivateConversations() { return map(rows, row => jsonToObject(row.json)); } +async function getAllRssFeedConversations() { + const rows = await db.all( + `SELECT json FROM conversations WHERE + type = 'group' AND + id LIKE 'rss://%' + ORDER BY id ASC;` + ); + + return map(rows, row => jsonToObject(row.json)); +} + +async function getAllPublicConversations() { + const rows = await db.all( + `SELECT json FROM conversations WHERE + type = 'group' AND + id LIKE 'publicChat:%' + ORDER BY id ASC;` + ); + + return map(rows, row => jsonToObject(row.json)); +} + +async function getPublicConversationsByServer(server) { + const rows = await db.all( + `SELECT * FROM conversations WHERE + server = $server + ORDER BY id ASC;`, + { + $server: server, + } + ); + + return map(rows, row => jsonToObject(row.json)); +} + async function getAllGroupsInvolvingId(id) { const rows = await db.all( `SELECT json FROM conversations WHERE @@ -1584,6 +1839,7 @@ async function saveMessage(data, { forceSave } = {}) { hasFileAttachments, hasVisualMediaAttachments, id, + serverId, // eslint-disable-next-line camelcase received_at, schemaVersion, @@ -1602,6 +1858,7 @@ async function saveMessage(data, { forceSave } = {}) { $id: id, $json: objectToJSON(data), + $serverId: serverId, $body: body, $conversationId: conversationId, $expirationStartTimestamp: expirationStartTimestamp, @@ -1624,6 +1881,7 @@ async function saveMessage(data, { forceSave } = {}) { await db.run( `UPDATE messages SET json = $json, + serverId = $serverId, body = $body, conversationId = $conversationId, expirationStartTimestamp = $expirationStartTimestamp, @@ -1658,6 +1916,7 @@ async function saveMessage(data, { forceSave } = {}) { id, json, + serverId, body, conversationId, expirationStartTimestamp, @@ -1678,6 +1937,7 @@ async function saveMessage(data, { forceSave } = {}) { $id, $json, + $serverId, $body, $conversationId, $expirationStartTimestamp, @@ -1800,6 +2060,24 @@ async function removeMessage(id) { ); } +async function getMessageByServerId(serverId, conversationId) { + const row = await db.get( + `SELECT * FROM messages WHERE + serverId = $serverId AND + conversationId = $conversationId;`, + { + $serverId: serverId, + $conversationId: conversationId, + } + ); + + if (!row) { + return null; + } + + return jsonToObject(row.json); +} + async function getMessageById(id) { const row = await db.get('SELECT * FROM messages WHERE id = $id;', { $id: id, diff --git a/config/default.json b/config/default.json index c6ead5686..8740d6b93 100644 --- a/config/default.json +++ b/config/default.json @@ -4,7 +4,7 @@ "cdnUrl": "random.snode", "contentProxyUrl": "", "localServerPort": "8081", - "defaultPoWDifficulty": "100", + "defaultPoWDifficulty": "1", "seedNodeList": [ { "ip": "storage.testnetseed1.loki.network", diff --git a/js/background.js b/js/background.js index 3cd81dcbd..85f53af2f 100644 --- a/js/background.js +++ b/js/background.js @@ -204,9 +204,36 @@ window.log.info('Storage fetch'); storage.fetch(); - const initAPIs = () => { + const initSpecialConversations = async () => { + const rssFeedConversations = await window.Signal.Data.getAllRssFeedConversations( + { + ConversationCollection: Whisper.ConversationCollection, + } + ); + rssFeedConversations.forEach(conversation => { + window.feeds.push(new window.LokiRssAPI(conversation.getRssSettings())); + }); + const publicConversations = await window.Signal.Data.getAllPublicConversations( + { + ConversationCollection: Whisper.ConversationCollection, + } + ); + publicConversations.forEach(conversation => { + const settings = conversation.getPublicSource(); + const channel = window.lokiPublicChatAPI.findOrCreateChannel( + settings.server, + settings.channelId, + conversation.id + ); + channel.refreshModStatus(); + }); + }; + + const initAPIs = async () => { const ourKey = textsecure.storage.user.getNumber(); + window.feeds = []; window.lokiMessageAPI = new window.LokiMessageAPI(ourKey); + window.lokiPublicChatAPI = new window.LokiPublicChatAPI(ourKey); window.lokiP2pAPI = new window.LokiP2pAPI(ourKey); window.lokiP2pAPI.on('pingContact', pubKey => { const isPing = true; @@ -244,11 +271,6 @@ } first = false; - if (Whisper.Registration.isDone()) { - startLocalLokiServer(); - initAPIs(); - } - const currentPoWDifficulty = storage.get('PoWDifficulty', null); if (!currentPoWDifficulty) { storage.put('PoWDifficulty', window.getDefaultPoWDifficulty()); @@ -464,6 +486,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) { @@ -556,11 +600,9 @@ window.log.info('Cleanup: complete'); window.log.info('listening for registration events'); - Whisper.events.on('registration_done', () => { + Whisper.events.on('registration_done', async () => { window.log.info('handling registration event'); - startLocalLokiServer(); - // listeners Whisper.RotateSignedPreKeyListener.init(Whisper.events, newVersion); // window.Signal.RefreshSenderCertificate.initialize({ @@ -570,7 +612,6 @@ // logger: window.log, // }); - initAPIs(); connect(true); }); @@ -729,6 +770,18 @@ } }); + Whisper.events.on( + 'publicMessageSent', + ({ pubKey, timestamp, serverId }) => { + try { + const conversation = ConversationController.get(pubKey); + conversation.onPublicMessageSent(pubKey, timestamp, serverId); + } catch (e) { + window.log.error('Error setting public on message'); + } + } + ); + Whisper.events.on('password-updated', () => { if (appView && appView.inboxView) { appView.inboxView.trigger('password-updated'); @@ -832,6 +885,9 @@ Whisper.Notifications.disable(); // avoid notification flood until empty // initialize the socket and start listening for messages + startLocalLokiServer(); + await initAPIs(); + await initSpecialConversations(); messageReceiver = new textsecure.MessageReceiver( USERNAME, PASSWORD, @@ -1245,6 +1301,18 @@ return handleProfileUpdate({ data, confirm, messageDescriptor }); } + const ourNumber = textsecure.storage.user.getNumber(); + const descriptorId = await textsecure.MessageReceiver.arrayBufferToString( + messageDescriptor.id + ); + if ( + messageDescriptor.type === 'group' && + descriptorId.match(/^publicChat:/) && + data.source === ourNumber + ) { + // Remove public chat messages to ourselves + return event.confirm(); + } const message = await createMessage(data); const isDuplicate = await isMessageDuplicate(message); if (isDuplicate) { @@ -1371,6 +1439,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, @@ -1378,6 +1447,8 @@ type: 'incoming', unread: 1, isP2p: data.isP2p, + isPublic: data.isPublic, + isRss: data.isRss, }; if (data.friendRequest) { diff --git a/js/conversation_controller.js b/js/conversation_controller.js index fe2a45074..50b3aae33 100644 --- a/js/conversation_controller.js +++ b/js/conversation_controller.js @@ -1,4 +1,4 @@ -/* global _, Whisper, Backbone, storage, textsecure, libsignal */ +/* global _, Whisper, Backbone, storage, textsecure, libsignal, lokiPublicChatAPI */ /* eslint-disable more/no-then */ @@ -159,6 +159,10 @@ if (!conversation) { return; } + if (conversation.isPublic()) { + const server = conversation.getPublicSource(); + lokiPublicChatAPI.unregisterChannel(server.server, server.channelId); + } await conversation.destroyMessages(); const deviceIds = await textsecure.storage.protocol.getDeviceIds(id); await Promise.all( diff --git a/js/models/conversations.js b/js/models/conversations.js index 891a345d5..0829a5a8e 100644 --- a/js/models/conversations.js +++ b/js/models/conversations.js @@ -11,6 +11,7 @@ clipboard, BlockedNumberController, lokiP2pAPI, + lokiPublicChatAPI, JobQueue */ @@ -167,7 +168,7 @@ if (this.id === this.ourNumber) { this.set({ friendRequestStatus: FriendRequestStatusEnum.friends }); - } else if (lokiP2pAPI) { + } else if (typeof lokiP2pAPI !== 'undefined') { // Online status handling, only for contacts that aren't us this.set({ isOnline: lokiP2pAPI.isOnline(this.id) }); } else { @@ -193,6 +194,15 @@ isMe() { return this.id === this.ourNumber; }, + isPublic() { + return this.id.match(/^publicChat:/); + }, + isClosable() { + return !this.isRss() || this.get('closable'); + }, + isRss() { + return this.id && this.id.match(/^rss:/); + }, isBlocked() { return BlockedNumberController.isBlocked(this.id); }, @@ -299,6 +309,7 @@ }, async updateProfileAvatar() { + if (this.isRss()) return; const path = profileImages.getOrCreateImagePath(this.id); await this.setProfileAvatar(path); }, @@ -365,6 +376,16 @@ await Promise.all(messages.map(m => m.setIsP2p(true))); }, + async onPublicMessageSent(pubKey, timestamp, serverId) { + const messages = this._getMessagesWithTimestamp(pubKey, timestamp); + await Promise.all( + messages.map(message => [ + message.setIsPublic(true), + message.setServerId(serverId), + ]) + ); + }, + async onNewMessage(message) { await this.updateLastMessage(); @@ -424,6 +445,7 @@ color, type: this.isPrivate() ? 'direct' : 'group', isMe: this.isMe(), + isClosable: this.isClosable(), isTyping: typingKeys.length > 0, lastUpdated: this.get('timestamp'), name: this.getName(), @@ -440,6 +462,7 @@ lastMessage: { status: this.get('lastMessageStatus'), text: this.get('lastMessage'), + isRss: this.isRss(), }, isOnline: this.isOnline(), hasNickname: !!this.getNickname(), @@ -629,6 +652,11 @@ ); }, updateTextInputState() { + if (this.isRss()) { + // or if we're an rss conversation, disable it + this.trigger('disable:input', true); + return; + } switch (this.get('friendRequestStatus')) { case FriendRequestStatusEnum.none: case FriendRequestStatusEnum.requestExpired: @@ -1347,6 +1375,10 @@ const options = this.getSendOptions(); options.messageType = message.get('type'); + options.isPublic = this.isPublic(); + if (options.isPublic) { + options.publicSendData = await this.getPublicSendData(); + } const groupNumbers = this.getRecipients(); @@ -1946,7 +1978,7 @@ return; } - if (read.length && options.sendReadReceipts) { + if (!this.isPublic() && read.length && options.sendReadReceipts) { window.log.info(`Sending ${read.length} read receipts`); // Because syncReadMessages sends to our other devices, and sendReadReceipts goes // to a contact, we need accessKeys for both. @@ -2015,6 +2047,73 @@ getNickname() { return this.get('nickname'); }, + getRssSettings() { + if (!this.isRss()) { + return null; + } + return { + RSS_FEED: this.get('rssFeed'), + CONVO_ID: this.id, + title: this.get('name'), + closeable: this.get('closable'), + }; + }, + // maybe "Backend" instead of "Source"? + getPublicSource() { + if (!this.isPublic()) { + return null; + } + return { + server: this.get('server'), + channelId: this.get('channelId'), + conversationId: this.get('id'), + }; + }, + async getPublicSendData() { + const serverAPI = lokiPublicChatAPI.findOrCreateServer( + this.get('server') + ); + const channelAPI = serverAPI.findOrCreateChannel( + this.get('channelId'), + this.id + ); + return channelAPI; + }, + getLastRetrievedMessage() { + if (!this.isPublic()) { + return null; + } + const lastMessageId = this.get('lastPublicMessage') || 0; + return lastMessageId; + }, + async setLastRetrievedMessage(newLastMessageId) { + if (!this.isPublic()) { + return; + } + if (this.get('lastPublicMessage') !== newLastMessageId) { + this.set({ lastPublicMessage: newLastMessageId }); + await window.Signal.Data.updateConversation(this.id, this.attributes, { + Conversation: Whisper.Conversation, + }); + } + }, + isModerator() { + 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 @@ -2052,6 +2151,23 @@ }); } }, + async setGroupNameAndAvatar(name, avatarPath) { + const currentName = this.get('name'); + const profileAvatar = this.get('profileAvatar'); + if (profileAvatar !== avatarPath || currentName !== name) { + // only update changed items + if (profileAvatar !== avatarPath) { + this.set({ profileAvatar: avatarPath }); + } + if (currentName !== name) { + this.set({ name }); + } + // save + await window.Signal.Data.updateConversation(this.id, this.attributes, { + Conversation: Whisper.Conversation, + }); + } + }, async setProfileAvatar(avatarPath) { const profileAvatar = this.get('profileAvatar'); if (profileAvatar !== avatarPath) { @@ -2201,6 +2317,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/js/models/messages.js b/js/models/messages.js index 8f91658a8..e584b95a6 100644 --- a/js/models/messages.js +++ b/js/models/messages.js @@ -670,8 +670,15 @@ expirationLength, expirationTimestamp, isP2p: !!this.get('isP2p'), + isPublic: !!this.get('isPublic'), + isRss: !!this.get('isRss'), + isDeletable: + !this.get('isPublic') || + this.getConversation().isModerator() || + this.getSource() === this.OUR_NUMBER, onCopyText: () => this.copyText(), + onCopyPubKey: () => this.copyPubKey(), onReply: () => this.trigger('reply', this), onRetrySend: () => this.retrySend(), onShowDetail: () => this.trigger('show-message-detail', this), @@ -958,6 +965,17 @@ }; }, + copyPubKey() { + if (this.isIncoming()) { + clipboard.writeText(this.get('source')); + } else { + clipboard.writeText(this.OUR_NUMBER); + } + window.Whisper.events.trigger('showToast', { + message: i18n('copiedPublicKey'), + }); + }, + copyText() { clipboard.writeText(this.get('body')); window.Whisper.events.trigger('showToast', { @@ -1238,6 +1256,31 @@ Message: Whisper.Message, }); }, + getServerId() { + return this.get('serverId'); + }, + async setServerId(serverId) { + if (_.isEqual(this.get('serverId'), serverId)) return; + + this.set({ + serverId, + }); + + await window.Signal.Data.saveMessage(this.attributes, { + Message: Whisper.Message, + }); + }, + async setIsPublic(isPublic) { + if (_.isEqual(this.get('isPublic'), isPublic)) return; + + this.set({ + isPublic: !!isPublic, + }); + + await window.Signal.Data.saveMessage(this.attributes, { + Message: Whisper.Message, + }); + }, send(promise) { this.trigger('pending'); return promise @@ -1960,6 +2003,12 @@ } ); } + } else if (dataMessage.profile) { + ConversationController.getOrCreateAndWait(source, 'private').then( + sender => { + sender.setProfile(dataMessage.profile); + } + ); } let autoAccept = false; diff --git a/js/modules/data.js b/js/modules/data.js index e5a9e1af1..c410f9464 100644 --- a/js/modules/data.js +++ b/js/modules/data.js @@ -118,6 +118,11 @@ module.exports = { getPubKeysWithFriendStatus, getAllConversationIds, getAllPrivateConversations, + getAllRssFeedConversations, + getAllPublicConversations, + getPublicConversationsByServer, + savePublicServerToken, + getPublicServerTokenByServerUrl, getAllGroupsInvolvingId, searchConversations, @@ -140,6 +145,7 @@ module.exports = { removeAllMessagesInConversation, getMessageBySender, + getMessageByServerId, getMessageById, getAllMessages, getAllUnsentMessages, @@ -739,6 +745,22 @@ async function getAllConversationIds() { return ids; } +async function getAllRssFeedConversations({ ConversationCollection }) { + const conversations = await channels.getAllRssFeedConversations(); + + const collection = new ConversationCollection(); + collection.add(conversations); + return collection; +} + +async function getAllPublicConversations({ ConversationCollection }) { + const conversations = await channels.getAllPublicConversations(); + + const collection = new ConversationCollection(); + collection.add(conversations); + return collection; +} + async function getAllPrivateConversations({ ConversationCollection }) { const conversations = await channels.getAllPrivateConversations(); @@ -747,6 +769,26 @@ async function getAllPrivateConversations({ ConversationCollection }) { return collection; } +async function savePublicServerToken(data) { + await channels.savePublicServerToken(data); +} + +async function getPublicServerTokenByServerUrl(serverUrl) { + const token = await channels.getPublicServerTokenByServerUrl(serverUrl); + return token; +} + +async function getPublicConversationsByServer( + server, + { ConversationCollection } +) { + const conversations = await channels.getPublicConversationsByServer(server); + + const collection = new ConversationCollection(); + collection.add(conversations); + return collection; +} + async function getAllGroupsInvolvingId(id, { ConversationCollection }) { const conversations = await channels.getAllGroupsInvolvingId(id); @@ -866,6 +908,15 @@ async function _removeMessages(ids) { await channels.removeMessage(ids); } +async function getMessageByServerId(serverId, conversationId, { Message }) { + const message = await channels.getMessageByServerId(serverId, conversationId); + if (!message) { + return null; + } + + return new Message(message); +} + async function getMessageById(id, { Message }) { const message = await channels.getMessageById(id); if (!message) { diff --git a/js/modules/loki_message_api.js b/js/modules/loki_message_api.js index 199448a57..227541929 100644 --- a/js/modules/loki_message_api.js +++ b/js/modules/loki_message_api.js @@ -75,13 +75,40 @@ class LokiMessageAPI { } async sendMessage(pubKey, data, messageTimeStamp, ttl, options = {}) { - const { isPing = false, numConnections = DEFAULT_CONNECTIONS } = options; + const { + isPing = false, + isPublic = false, + numConnections = DEFAULT_CONNECTIONS, + publicSendData = null, + } = options; // Data required to identify a message in a conversation const messageEventData = { pubKey, timestamp: messageTimeStamp, }; + if (isPublic) { + const { profile } = data; + let displayName = 'Anonymous'; + if (profile && profile.displayName) { + ({ displayName } = profile); + } + const res = await publicSendData.sendMessage( + data.body, + messageTimeStamp, + displayName, + this.ourKey + ); + if (res === false) { + throw new window.textsecure.PublicChatError( + 'Failed to send public chat message' + ); + } + messageEventData.serverId = res; + window.Whisper.events.trigger('publicMessageSent', messageEventData); + return; + } + const data64 = dcodeIO.ByteBuffer.wrap(data).toString('base64'); const p2pSuccess = await trySendP2p( pubKey, diff --git a/js/modules/loki_public_chat_api.js b/js/modules/loki_public_chat_api.js new file mode 100644 index 000000000..7a959c316 --- /dev/null +++ b/js/modules/loki_public_chat_api.js @@ -0,0 +1,530 @@ +/* global log, textsecure, libloki, Signal, Whisper, Headers, ConversationController */ +const EventEmitter = require('events'); +const nodeFetch = require('node-fetch'); +const { URL, URLSearchParams } = require('url'); + +// 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.servers = []; + } + + // server getter/factory + findOrCreateServer(serverUrl) { + let thisServer = this.servers.find( + server => server.baseServerUrl === serverUrl + ); + if (!thisServer) { + log.info(`LokiPublicChatAPI creating ${serverUrl}`); + thisServer = new LokiPublicServerAPI(this, serverUrl); + this.servers.push(thisServer); + } + return thisServer; + } + + // channel getter/factory + findOrCreateChannel(serverUrl, channelId, conversationId) { + const server = this.findOrCreateServer(serverUrl); + return server.findOrCreateChannel(channelId, conversationId); + } + + // deallocate resources server uses + unregisterChannel(serverUrl, channelId) { + let thisServer; + let i = 0; + for (; i < this.servers.length; i += 1) { + if (this.servers[i].server === serverUrl) { + thisServer = this.servers[i]; + break; + } + } + + if (!thisServer) { + log.warn(`Tried to unregister from nonexistent server ${serverUrl}`); + return; + } + thisServer.unregisterChannel(channelId); + this.servers.splice(i, 1); + } +} + +class LokiPublicServerAPI { + constructor(chatAPI, url) { + this.chatAPI = chatAPI; + this.channels = []; + this.tokenPromise = null; + this.baseServerUrl = url; + const ref = this; + (async function justToEnableAsyncToGetToken() { + ref.token = await ref.getOrRefreshServerToken(); + log.info(`set token ${ref.token}`); + })(); + } + + // channel getter/factory + findOrCreateChannel(channelId, conversationId) { + let thisChannel = this.channels.find( + channel => channel.channelId === channelId + ); + if (!thisChannel) { + log.info(`LokiPublicChatAPI creating channel ${conversationId}`); + thisChannel = new LokiPublicChannelAPI(this, channelId, conversationId); + this.channels.push(thisChannel); + } + return thisChannel; + } + + // deallocate resources channel uses + unregisterChannel(channelId) { + let thisChannel; + let i = 0; + for (; i < this.channels.length; i += 1) { + if (this.channels[i].channelId === channelId) { + thisChannel = this.channels[i]; + break; + } + } + if (!thisChannel) { + return; + } + this.channels.splice(i, 1); + thisChannel.stopPolling = true; + } + + // get active token for this server + async getOrRefreshServerToken(forceRefresh = false) { + let token; + if (!forceRefresh) { + if (this.token) { + return this.token; + } + token = await Signal.Data.getPublicServerTokenByServerUrl( + this.baseServerUrl + ); + } + if (!token) { + token = await this.refreshServerToken(); + if (token) { + await Signal.Data.savePublicServerToken({ + serverUrl: this.baseServerUrl, + token, + }); + } + } + this.token = token; + return token; + } + + // get active token from server (but only allow one request at a time) + async refreshServerToken() { + // if currently not in progress + if (this.tokenPromise === null) { + // set lock + this.tokenPromise = new Promise(async res => { + // request the oken + const token = await this.requestToken(); + if (!token) { + res(null); + return; + } + // activate the token + const registered = await this.submitToken(token); + if (!registered) { + res(null); + return; + } + // resolve promise to release lock + res(token); + }); + } + // wait until we have it set + const token = await this.tokenPromise; + // clear lock + this.tokenPromise = null; + return token; + } + + // request an token from the server + async requestToken() { + const url = new URL(`${this.baseServerUrl}/loki/v1/get_challenge`); + const params = { + pubKey: this.chatAPI.ourKey, + }; + url.search = new URLSearchParams(params); + + let res; + try { + res = await nodeFetch(url); + } catch (e) { + return null; + } + if (!res.ok) { + return null; + } + const body = await res.json(); + const token = await libloki.crypto.decryptToken(body); + return token; + } + + // activate token + async submitToken(token) { + const options = { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + pubKey: this.chatAPI.ourKey, + token, + }), + }; + + try { + const res = await nodeFetch( + `${this.baseServerUrl}/loki/v1/submit_challenge`, + options + ); + return res.ok; + } catch (e) { + return false; + } + } +} + +class LokiPublicChannelAPI { + constructor(serverAPI, channelId, conversationId) { + // properties + this.serverAPI = serverAPI; + this.channelId = channelId; + this.baseChannelUrl = `channels/${this.channelId}`; + this.groupName = 'unknown'; + this.conversationId = conversationId; + this.lastGot = null; + this.stopPolling = false; + this.modStatus = false; + this.deleteLastId = 1; + // end properties + + log.info(`registered LokiPublicChannel ${channelId}`); + // start polling + this.pollForMessages(); + this.pollForDeletions(); + } + + // make a request to the server + async serverRequest(endpoint, options = {}) { + const { params = {}, method, objBody, forceFreshToken = false } = options; + const url = new URL(`${this.serverAPI.baseServerUrl}/${endpoint}`); + if (params) { + url.search = new URLSearchParams(params); + } + let result; + let { token } = this.serverAPI; + if (!token) { + token = await this.serverAPI.getOrRefreshServerToken(); + if (!token) { + log.error('NO TOKEN'); + return { + err: 'noToken', + }; + } + } + try { + const fetchOptions = {}; + const headers = { + Authorization: `Bearer ${this.serverAPI.token}`, + }; + if (method) { + fetchOptions.method = method; + } + if (objBody) { + headers['Content-Type'] = 'application/json'; + fetchOptions.body = JSON.stringify(objBody); + } + fetchOptions.headers = new Headers(headers); + result = await nodeFetch(url, fetchOptions || undefined); + } catch (e) { + log.info(`e ${e}`); + return { + err: e, + }; + } + let response = null; + try { + response = await result.json(); + } catch (e) { + log.info(`serverRequest json arpse ${e}`); + return { + err: e, + statusCode: result.status, + }; + } + + // if it's a response style with a meta + if (result.status !== 200) { + if (!forceFreshToken && response.meta.code === 401) { + // copy options because lint complains if we modify this directly + const updatedOptions = options; + // force it this time + updatedOptions.forceFreshToken = true; + // retry with updated options + return this.serverRequest(endpoint, updatedOptions); + } + return { + err: 'statusCode', + statusCode: result.status, + response, + }; + } + return { + statusCode: result.status, + response, + }; + } + + // get moderator status + async refreshModStatus() { + const res = this.serverRequest('loki/v1/user_info'); + // if no problems and we have data + if (!res.err && res.response && res.response.data) { + this.modStatus = res.response.data.moderator_status; + } + + const conversation = ConversationController.get(this.conversationId); + await conversation.setModStatus(this.modStatus); + } + + // delete a message on the server + async deleteMessage(serverId) { + const res = await this.serverRequest( + this.modStatus + ? `loki/v1/moderation/message/${serverId}` + : `${this.baseChannelUrl}/messages/${serverId}`, + { method: 'DELETE' } + ); + if (!res.err && res.response) { + log.info(`deleted ${serverId} on ${this.baseChannelUrl}`); + return true; + } + log.warn(`failed to delete ${serverId} on ${this.baseChannelUrl}`); + return false; + } + + // used for sending messages + getEndpoint() { + const endpoint = `${this.serverAPI.baseServerUrl}/${ + this.baseChannelUrl + }/messages`; + return endpoint; + } + + // update room details + async pollForChannel() { + // groupName will be loaded from server + const url = new URL(this.baseChannelUrl); + let res; + const token = await this.serverAPI.getOrRefreshServerToken(); + if (!token) { + log.error('NO TOKEN'); + return { + err: 'noToken', + }; + } + try { + // eslint-disable-next-line no-await-in-loop + const options = { + headers: new Headers({ + Authorization: `Bearer ${token}`, + }), + }; + res = await nodeFetch(url, options || undefined); + } catch (e) { + 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) { + return { + err: 'statusCode', + response, + }; + } + return { + response, + }; + } + + // get moderation actions + async pollForDeletions() { + // grab the last 200 deletions + const params = { + count: 200, + }; + + // start loop + let more = true; + while (more) { + // set params to from where we last checked + params.since_id = this.deleteLastId; + + // grab the next 200 deletions from where we last checked + // eslint-disable-next-line no-await-in-loop + const res = await this.serverRequest( + `loki/v1/channel/${this.channelId}/deletes`, + { params } + ); + + // Process results + res.response.data.reverse().forEach(deleteEntry => { + // Escalate it up to the subsystem that can check to see if this has + // been processed + Whisper.events.trigger('deleteLocalPublicMessage', { + messageServerId: deleteEntry.message_id, + conversationId: this.conversationId, + }); + }); + + // if we had a problem break the loop + if (res.response.data.length < 200) { + break; + } + + // update where we last checked + this.deleteLastId = res.response.meta.max_id; + ({ more } = res.response); + } + + // set up next poll + setTimeout(() => { + this.pollForDeletions(); + }, DELETION_POLL_EVERY); + } + + // get channel messages + async pollForMessages() { + const params = { + include_annotations: 1, + count: -20, + include_deleted: false, + }; + const conversation = ConversationController.get(this.conversationId); + if (!conversation) { + log.warn('Trying to poll for non-existing public conversation'); + this.lastGot = 0; + } else if (!this.lastGot) { + this.lastGot = conversation.getLastRetrievedMessage(); + } + params.since_id = this.lastGot; + const res = await this.serverRequest(`${this.baseChannelUrl}/messages`, { + params, + }); + + if (!res.err && res.response) { + let receivedAt = new Date().getTime(); + res.response.data.reverse().forEach(adnMessage => { + let timestamp = new Date(adnMessage.created_at).getTime(); + let from = adnMessage.user.username; + let source; + if (adnMessage.is_deleted) { + return; + } + if (adnMessage.annotations !== []) { + const noteValue = adnMessage.annotations[0].value; + ({ from, timestamp, source } = noteValue); + } + + if ( + !from || + !timestamp || + !source || + !adnMessage.id || + !adnMessage.text + ) { + return; // Invalid message + } + + const messageData = { + serverId: adnMessage.id, + friendRequest: false, + source, + sourceDevice: 1, + timestamp, + serverTimestamp: timestamp, + receivedAt, + isPublic: true, + message: { + body: adnMessage.text, + attachments: [], + group: { + id: this.conversationId, + type: textsecure.protobuf.GroupContext.Type.DELIVER, + }, + flags: 0, + expireTimer: 0, + profileKey: null, + timestamp, + received_at: receivedAt, + sent_at: timestamp, + quote: null, + contact: [], + preview: [], + profile: { + displayName: from, + }, + }, + }; + receivedAt += 1; // Ensure different arrival times + + this.serverAPI.chatAPI.emit('publicMessage', { + message: messageData, + }); + this.lastGot = !this.lastGot + ? adnMessage.id + : Math.max(this.lastGot, adnMessage.id); + }); + conversation.setLastRetrievedMessage(this.lastGot); + } + + setTimeout(() => { + this.pollForMessages(); + }, GROUPCHAT_POLL_EVERY); + } + + // create a message in the channel + async sendMessage(text, messageTimeStamp, displayName, pubKey) { + const payload = { + text, + annotations: [ + { + type: 'network.loki.messenger.publicChat', + value: { + timestamp: messageTimeStamp, + // will deprecated + from: displayName, + // will deprecated + source: pubKey, + }, + }, + ], + }; + const res = await this.serverRequest(`${this.baseChannelUrl}/messages`, { + method: 'POST', + objBody: payload, + }); + if (!res.err && res.response) { + return res.response.data.id; + } + return false; + } +} + +module.exports = LokiPublicChatAPI; diff --git a/js/modules/loki_rss_api.js b/js/modules/loki_rss_api.js new file mode 100644 index 000000000..ee5486d53 --- /dev/null +++ b/js/modules/loki_rss_api.js @@ -0,0 +1,137 @@ +/* eslint-disable no-await-in-loop */ +/* eslint-disable no-loop-func */ +/* global log, window, textsecure */ + +const EventEmitter = require('events'); +const nodeFetch = require('node-fetch'); + +const PER_MIN = 60 * 1000; +const PER_HR = 60 * PER_MIN; +const RSS_POLL_EVERY = 1 * PER_HR; // once an hour + +function xml2json(xml) { + try { + let obj = {}; + if (xml.children.length > 0) { + for (let i = 0; i < xml.children.length; i += 1) { + const item = xml.children.item(i); + const { nodeName } = item; + + if (typeof obj[nodeName] === 'undefined') { + obj[nodeName] = xml2json(item); + } else { + if (typeof obj[nodeName].push === 'undefined') { + const old = obj[nodeName]; + + obj[nodeName] = []; + obj[nodeName].push(old); + } + obj[nodeName].push(xml2json(item)); + } + } + } else { + obj = xml.textContent; + } + return obj; + } catch (e) { + log.error(e.message); + } + return {}; +} + +class LokiRssAPI extends EventEmitter { + constructor(settings) { + super(); + // properties + this.feedUrl = settings.RSS_FEED; + this.groupId = settings.CONVO_ID; + this.feedTitle = settings.title; + this.closeable = settings.closeable; + // non configureable options + this.feedTimer = null; + // initial set up + this.getFeed(); + } + + async getFeed() { + let response; + let success = true; + try { + response = await nodeFetch(this.feedUrl); + } catch (e) { + log.error('fetcherror', e); + success = false; + } + const responseXML = await response.text(); + let feedDOM = {}; + try { + feedDOM = await new window.DOMParser().parseFromString( + responseXML, + 'text/xml' + ); + } catch (e) { + log.error('xmlerror', e); + success = false; + } + if (!success) return; + const feedObj = xml2json(feedDOM); + let receivedAt = new Date().getTime(); + + if (!feedObj || !feedObj.rss || !feedObj.rss.channel) { + log.error('rsserror', feedObj, feedDOM, responseXML); + return; + } + if (!feedObj.rss.channel.item) { + // no records + return; + } + if (feedObj.rss.channel.item.constructor !== Array) { + // Treat single record as array for consistency + feedObj.rss.channel.item = [feedObj.rss.channel.item]; + } + feedObj.rss.channel.item.reverse().forEach(item => { + // log.debug('item', item) + + const pubDate = new Date(item.pubDate); + + // if we use group style, we can put the title in the source + const messageData = { + friendRequest: false, + source: this.groupId, + sourceDevice: 1, + timestamp: pubDate.getTime(), + serverTimestamp: pubDate.getTime(), + receivedAt, + isRss: true, + message: { + body: `