From a0b52826e93797199ae1318ee314ff0071101f19 Mon Sep 17 00:00:00 2001 From: Ryan Tharp Date: Wed, 18 Sep 2019 21:42:42 -0700 Subject: [PATCH 01/14] sign and only show verified messages --- js/modules/loki_public_chat_api.js | 112 ++++++++++++++++++++++++++++- 1 file changed, 110 insertions(+), 2 deletions(-) diff --git a/js/modules/loki_public_chat_api.js b/js/modules/loki_public_chat_api.js index 4823b0c1d..f81df9731 100644 --- a/js/modules/loki_public_chat_api.js +++ b/js/modules/loki_public_chat_api.js @@ -1,5 +1,5 @@ /* global log, textsecure, libloki, Signal, Whisper, Headers, ConversationController, -clearTimeout, MessageController */ +clearTimeout, MessageController, libsignal, StringView */ const EventEmitter = require('events'); const nodeFetch = require('node-fetch'); const { URL, URLSearchParams } = require('url'); @@ -16,6 +16,15 @@ class LokiPublicChatAPI extends EventEmitter { super(); this.ourKey = ourKey; this.servers = []; + this.myPrivateKey = false; + } + + async getPrivateKey() { + if (!this.myPrivateKey) { + const myKeyPair = await textsecure.storage.protocol.getIdentityKeyPair(); + this.myPrivateKey = myKeyPair.privKey; + } + return this.myPrivateKey; } // server getter/factory @@ -213,6 +222,7 @@ class LokiPublicChannelAPI { this.deleteLastId = 1; this.timers = {}; this.running = true; + this.logMop = {}; // end properties log.info(`registered LokiPublicChannel ${channelId}`); @@ -358,6 +368,22 @@ class LokiPublicChannelAPI { // update profile name as needed if (tokenRes.response.data.user.name !== profileName) { if (profileName) { + // will need this when we add an annotation + /* + const privKey = await this.serverAPI.chatAPI.getPrivateKey(); + // we might need an annotation that sets the homeserver for media + // better to include this with each attachment... + const ObjToSign = { + name: profileName, + version: 1, + annotations: [], + }; + const sig = await libsignal.Curve.async.calculateSignature( + privKey, + JSON.stringify(ObjToSign) + ); + */ + await this.serverRequest('users/me', { method: 'PATCH', objBody: { @@ -537,10 +563,11 @@ class LokiPublicChannelAPI { if (!res.err && res.response) { let receivedAt = new Date().getTime(); - res.response.data.reverse().forEach(adnMessage => { + res.response.data.reverse().forEach(async adnMessage => { let timestamp = new Date(adnMessage.created_at).getTime(); // pubKey lives in the username field let from = adnMessage.user.name; + let sigValid; let quote = null; if (adnMessage.is_deleted) { return; @@ -561,6 +588,71 @@ class LokiPublicChannelAPI { if (!from) { ({ from } = noteValue); } + + if (noteValue.sig) { + // try to verify signature + const { sig, sigver } = noteValue; + const annoCopy = JSON.parse(JSON.stringify(adnMessage.annotations)); + delete annoCopy[0].value.sig; + delete annoCopy[0].value.sigver; + const verifyObj = { + text: adnMessage.text, + version: sigver, + annotations: annoCopy, + }; + if (adnMessage.reply_to) { + verifyObj.reply_to = adnMessage.reply_to; + } + const pubKeyBin = StringView.hexToArrayBuffer( + adnMessage.user.username + ); + const sigBin = StringView.hexToArrayBuffer(sig); + try { + await libsignal.Curve.async.verifySignature( + pubKeyBin, + JSON.stringify(verifyObj), + sigBin + ); + sigValid = true; + } catch (e) { + if (e.message === 'Invalid signature') { + sigValid = false; + } + } + } + } + + // we now only accept valid messages into the public chat + if (sigValid !== true) { + let sig; + let sigver; + if ( + Array.isArray(adnMessage.annotations) && + adnMessage.annotations.length !== 0 + ) { + if (adnMessage.annotations[0].value.sig) { + ({ sig, sigver } = adnMessage.annotations[0].value); + } + } + // keep noise out of the logs, once per start up is enough + if (this.logMop[adnMessage.id] === undefined) { + log.warn( + 'Invalid or missing signature on ', + this.serverAPI.baseServerUrl, + this.channelId, + adnMessage.id, + 'says', + adnMessage.text, + 'from', + adnMessage.user.username, + 'signature', + sig, + 'signature version', + sigver + ); + this.logMop[adnMessage.id] = true; + } + return; // Invalid signature } if ( @@ -576,6 +668,7 @@ class LokiPublicChannelAPI { const messageData = { serverId: adnMessage.id, + clientVerified: sigValid, friendRequest: false, source: adnMessage.user.username, sourceDevice: 1, @@ -658,6 +751,21 @@ class LokiPublicChannelAPI { } } } + const privKey = await this.serverAPI.chatAPI.getPrivateKey(); + const ObjToSign = { + version: 1, + text, + annotations: payload.annotations, + }; + if (payload.reply_to) { + ObjToSign.reply_to = payload.reply_to; + } + const sig = await libsignal.Curve.async.calculateSignature( + privKey, + JSON.stringify(ObjToSign) + ); + payload.annotations[0].value.sig = StringView.arrayBufferToHex(sig); + payload.annotations[0].value.sigver = 1; const res = await this.serverRequest(`${this.baseChannelUrl}/messages`, { method: 'POST', objBody: payload, From a97788c7d99b9c3b8fd723d8bca21e97ce1a9ef1 Mon Sep 17 00:00:00 2001 From: Ryan Tharp Date: Wed, 18 Sep 2019 21:52:12 -0700 Subject: [PATCH 02/14] modernize annotations since we're making a backward-incomp change --- js/modules/loki_public_chat_api.js | 22 ++++++++-------------- 1 file changed, 8 insertions(+), 14 deletions(-) diff --git a/js/modules/loki_public_chat_api.js b/js/modules/loki_public_chat_api.js index f81df9731..955d1a0b0 100644 --- a/js/modules/loki_public_chat_api.js +++ b/js/modules/loki_public_chat_api.js @@ -577,16 +577,13 @@ class LokiPublicChannelAPI { adnMessage.annotations.length !== 0 ) { const noteValue = adnMessage.annotations[0].value; - ({ timestamp, quote } = noteValue); + ({ timestamp } = noteValue); - if (quote) { - quote.attachments = []; - } - - // if user doesn't have a name set, fallback to annotation - // pubkeys are already there in v1 (first release) - if (!from) { - ({ from } = noteValue); + if (noteValue.quote) { + ({ quote } = noteValue); + if (quote) { + quote.attachments = []; + } } if (noteValue.sig) { @@ -723,16 +720,13 @@ class LokiPublicChannelAPI { type: 'network.loki.messenger.publicChat', value: { timestamp: messageTimeStamp, - // will deprecated - from: displayName, - // will deprecated - source: pubKey, - quote, }, }, ], }; if (quote && quote.id) { + payload.annoations[0].value.quote = quote; + // copied from model/message.js copyFromQuotedMessage const collection = await Signal.Data.getMessagesBySentAt(quote.id, { MessageCollection: Whisper.MessageCollection, From 68186afba22656c07a3ebaef3bcf6cc18c70a5db Mon Sep 17 00:00:00 2001 From: Ryan Tharp Date: Thu, 19 Sep 2019 19:48:04 -0700 Subject: [PATCH 03/14] lint and remove unneeded params --- js/modules/loki_message_api.js | 9 +-------- js/modules/loki_public_chat_api.js | 4 ++-- 2 files changed, 3 insertions(+), 10 deletions(-) diff --git a/js/modules/loki_message_api.js b/js/modules/loki_message_api.js index f9c3610d6..bfe591fbf 100644 --- a/js/modules/loki_message_api.js +++ b/js/modules/loki_message_api.js @@ -88,17 +88,10 @@ class LokiMessageAPI { }; if (isPublic) { - const { profile } = data; - let displayName = 'Anonymous'; - if (profile && profile.displayName) { - ({ displayName } = profile); - } const res = await publicSendData.sendMessage( data.body, data.quote, - messageTimeStamp, - displayName, - this.ourKey + messageTimeStamp ); if (res === false) { throw new window.textsecure.PublicChatError( diff --git a/js/modules/loki_public_chat_api.js b/js/modules/loki_public_chat_api.js index 955d1a0b0..45fe69a0a 100644 --- a/js/modules/loki_public_chat_api.js +++ b/js/modules/loki_public_chat_api.js @@ -566,7 +566,7 @@ class LokiPublicChannelAPI { res.response.data.reverse().forEach(async adnMessage => { let timestamp = new Date(adnMessage.created_at).getTime(); // pubKey lives in the username field - let from = adnMessage.user.name; + const from = adnMessage.user.name; let sigValid; let quote = null; if (adnMessage.is_deleted) { @@ -712,7 +712,7 @@ class LokiPublicChannelAPI { } // create a message in the channel - async sendMessage(text, quote, messageTimeStamp, displayName, pubKey) { + async sendMessage(text, quote, messageTimeStamp) { const payload = { text, annotations: [ From 687e9db77b8ba92567781b594b8fafebbaaa0a52 Mon Sep 17 00:00:00 2001 From: Maxim Shishmarev Date: Mon, 23 Sep 2019 11:00:51 +1000 Subject: [PATCH 04/14] Custom message rendering of mentions --- js/modules/loki_public_chat_api.js | 12 ++ js/views/conversation_view.js | 98 +++++++++++---- preload.js | 4 + stylesheets/_mentions.scss | 20 +++ stylesheets/_modules.scss | 2 +- stylesheets/_variables.scss | 1 + ts/components/conversation/AddMentions.tsx | 140 +++++++++++++++++++++ ts/components/conversation/Emojify.tsx | 14 ++- ts/components/conversation/Message.tsx | 32 +++-- ts/components/conversation/MessageBody.tsx | 31 ++++- ts/components/conversation/Quote.tsx | 10 +- ts/types/Util.ts | 1 + tslint.json | 1 + 13 files changed, 328 insertions(+), 38 deletions(-) create mode 100644 ts/components/conversation/AddMentions.tsx diff --git a/js/modules/loki_public_chat_api.js b/js/modules/loki_public_chat_api.js index 4823b0c1d..396dbb588 100644 --- a/js/modules/loki_public_chat_api.js +++ b/js/modules/loki_public_chat_api.js @@ -55,6 +55,16 @@ class LokiPublicChatAPI extends EventEmitter { thisServer.unregisterChannel(channelId); this.servers.splice(i, 1); } + + getListOfMembers() { + return this.allMembers; + } + + // TODO: make this private (or remove altogether) when + // we switch to polling the server for group members + setListOfMembers(members) { + this.allMembers = members; + } } class LokiPublicServerAPI { @@ -221,6 +231,8 @@ class LokiPublicChannelAPI { this.pollForDeletions(); this.pollForChannel(); this.pollForModerators(); + + // TODO: poll for group members here? } stop() { diff --git a/js/views/conversation_view.js b/js/views/conversation_view.js index 7bf77d5cb..fefc9e06f 100644 --- a/js/views/conversation_view.js +++ b/js/views/conversation_view.js @@ -310,6 +310,26 @@ this.$emojiPanelContainer = this.$('.emoji-panel-container'); this.model.updateTextInputState(); + + this.selectMember = this.selectMember.bind(this); + + const updateMemberList = async () => { + const allMessages = await window.Signal.Data.getMessagesByConversation( + this.model.id, + { + limit: Number.MAX_SAFE_INTEGER, + MessageCollection: Whisper.MessageCollection, + } + ); + + const allMembers = allMessages.models.map(d => d.propsForMessage); + window.lokiPublicChatAPI.setListOfMembers(allMembers); + }; + + if (this.model.id === 'publicChat:1@chat-dev.lokinet.org') { + updateMemberList(); + setInterval(updateMemberList, 10000); + } }, events: { @@ -1563,22 +1583,41 @@ dialog.focusCancel(); }, - selectMember(member) { - const stripQuery = input => { - const pos = input.lastIndexOf('@'); + stripQuery(text, cursorPos) { + const end = text.slice(cursorPos).search(/[^a-fA-F0-9]/); + const mentionEnd = end === -1 ? text.length : cursorPos + end; - // This should never happen, but we check just in case - if (pos === -1) { - return input; - } + const stripped = text.substr(0, mentionEnd); - return input.substr(0, pos); - }; + const mentionStart = stripped.lastIndexOf('@'); - const prev = stripQuery(this.$messageField.val()); - const result = `${prev}@${member.authorPhoneNumber} `; + const query = stripped.substr(mentionStart, mentionEnd - mentionStart); + + return [stripped.substr(0, mentionStart), query, text.substr(mentionEnd)]; + }, + + selectMember(member) { + const cursorPos = this.$messageField[0].selectionStart; + // Note: skipping the middle value here + const [prev, , end] = this.stripQuery( + this.$messageField.val(), + cursorPos + ); + let firstHalf = `${prev}@${member.authorPhoneNumber}`; + let newCursorPos = firstHalf.length; + + const needExtraWhitespace = + end.length === 0 || /[a-fA-F0-9@]/.test(end[0]); + if (needExtraWhitespace) { + firstHalf += ' '; + newCursorPos += 1; + } + + const result = firstHalf + end; this.$messageField.val(result); + this.$messageField[0].selectionStart = newCursorPos; + this.$messageField[0].selectionEnd = newCursorPos; this.$messageField.trigger('input'); }, @@ -2149,7 +2188,10 @@ // Note: not only input, but keypresses too (rename?) handleInputEvent(event) { - this.maybeShowMembers(event); + // Note: schedule the member list handler shortly afterwards, so + // that the input element has time to update its cursor position to + // what the user would expect + window.requestAnimationFrame(this.maybeShowMembers.bind(this, event)); const keyCode = event.which || event.keyCode; @@ -2238,13 +2280,19 @@ return false; }; - const getQuery = input => { + // This is not quite the same as stripQuery + // as this one searches until the current + // cursor position + const getQuery = (srcLine, cursorPos) => { + const input = srcLine.substr(0, cursorPos); + const atPos = input.lastIndexOf('@'); if (atPos === -1) { return null; } - // Whitespace is required right before @ + // Whitespace is required right before @ unless + // the beginning of line if (atPos > 0 && /\w/.test(input.substr(atPos - 1, 1))) { return null; } @@ -2259,24 +2307,28 @@ return query; }; - const query = getQuery(event.target.value); - - // TODO: for now, extract members from the conversation, - // but change to use a server endpoint in the future - let allMembers = this.model.messageCollection.models.map( - d => d.propsForMessage - ); + let allMembers = window.lokiPublicChatAPI.getListOfMembers(); allMembers = allMembers.filter(d => !!d); allMembers = _.uniq(allMembers, true, d => d.authorPhoneNumber); + const cursorPos = event.target.selectionStart; + + // can't use pubkeyPattern here, as we are matching incomplete + // pubkeys (including the single @) + const query = getQuery(event.target.value, cursorPos); + let membersToShow = []; - if (query) { + if (query !== null) { membersToShow = query !== '' ? allMembers.filter(m => filterMembers(query, m)) : allMembers; } - + membersToShow = membersToShow.map(m => ({ + authorPhoneNumber: m.authorPhoneNumber, + authorProfileName: m.authorProfileName, + id: m.id, + })); this.memberView.updateMembers(membersToShow); }, diff --git a/preload.js b/preload.js index fc5b12ca0..89ec4b83e 100644 --- a/preload.js +++ b/preload.js @@ -443,3 +443,7 @@ if (config.environment === 'test') { }; /* eslint-enable global-require, import/no-extraneous-dependencies */ } + +window.shortenPubkey = pubkey => `(...${pubkey.substring(pubkey.length - 6)})`; + +window.pubkeyPattern = /@[a-fA-F0-9]{64,66}\b/g; diff --git a/stylesheets/_mentions.scss b/stylesheets/_mentions.scss index ea6fcaf6e..58c65c5af 100644 --- a/stylesheets/_mentions.scss +++ b/stylesheets/_mentions.scss @@ -30,6 +30,26 @@ } } +.mention-profile-name { + color: rgb(194, 244, 255); + background-color: rgb(66, 121, 150); + text-decoration: underline; + border-radius: 4px; + margin: 2px; + padding: 2px; + user-select: none; +} + +.mention-profile-name-us { + background-color: rgba(255, 197, 50, 1); + color: black; +} + +.message-highlighted { + border-radius: $message-container-border-radius; + background-color: rgba(255, 197, 50, 0.2); +} + .dark-theme { .member-list-container { .member-item { diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index 332f276e4..b109feb2d 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -167,7 +167,7 @@ .module-message__container { position: relative; display: inline-block; - border-radius: 16px; + border-radius: $message-container-border-radius; padding-right: 12px; padding-left: 12px; padding-top: 10px; diff --git a/stylesheets/_variables.scss b/stylesheets/_variables.scss index b2f734362..a3643e9ae 100644 --- a/stylesheets/_variables.scss +++ b/stylesheets/_variables.scss @@ -199,6 +199,7 @@ $header-height: 55px; $button-height: 24px; $border-radius: 5px; +$message-container-border-radius: 16px; $font-size: 14px; $font-size-small: (13/14) + em; diff --git a/ts/components/conversation/AddMentions.tsx b/ts/components/conversation/AddMentions.tsx new file mode 100644 index 000000000..c95efd8ca --- /dev/null +++ b/ts/components/conversation/AddMentions.tsx @@ -0,0 +1,140 @@ +import React from 'react'; + +import { RenderTextCallbackType } from '../../types/Util'; +import classNames from 'classnames'; + +declare global { + interface Window { + lokiPublicChatAPI: any; + shortenPubkey: any; + pubkeyPattern: any; + } +} + +interface MentionProps { + key: number; + text: string; +} + +interface MentionState { + found: any; +} + +class Mention extends React.Component { + constructor(props: any) { + super(props); + + this.tryRenameMention = this.tryRenameMention.bind(this); + } + + public componentWillMount() { + const found = this.findMember(this.props.text.slice(1)); + this.setState({ found }); + + this.tryRenameMention(); + // TODO: give up after some period of time? + const intervalHandle = setInterval(this.tryRenameMention, 30000); + this.clearOurInterval = this.clearOurInterval.bind(this, intervalHandle); + } + + public componentWillUnmount() { + this.clearOurInterval(null); + } + + public render() { + if (this.state.found) { + // TODO: We don't have to search the database of message just to know that the message is for us! + const us = + this.state.found.authorPhoneNumber === window.lokiPublicChatAPI.ourKey; + const className = classNames( + 'mention-profile-name', + us && 'mention-profile-name-us' + ); + + return ( + {this.state.found.authorProfileName} + ); + } else { + return ( + + {window.shortenPubkey(this.props.text)} + + ); + } + } + + private clearOurInterval(handle: any) { + clearInterval(handle); + } + + private tryRenameMention() { + const found = this.findMember(this.props.text.slice(1)); + if (found) { + this.setState({ found }); + this.clearOurInterval(null); + } + } + + private findMember(pubkey: String) { + const members = window.lokiPublicChatAPI.getListOfMembers(); + if (!members) { + return null; + } + const filtered = members.filter((m: any) => !!m); + + return filtered.find( + ({ authorPhoneNumber: pn }: any) => pn && pn === pubkey + ); + } +} + +interface Props { + text: string; + renderOther?: RenderTextCallbackType; +} + +export class AddMentions extends React.Component { + public static defaultProps: Partial = { + renderOther: ({ text }) => text, + }; + + public render() { + const { text, renderOther } = this.props; + const results: Array = []; + const FIND_MENTIONS = window.pubkeyPattern; + + // We have to do this, because renderNonNewLine is not required in our Props object, + // but it is always provided via defaultProps. + if (!renderOther) { + return; + } + + let match = FIND_MENTIONS.exec(text); + let last = 0; + let count = 1000; + + if (!match) { + return renderOther({ text, key: 0 }); + } + + while (match) { + if (last < match.index) { + const otherText = text.slice(last, match.index); + results.push(renderOther({ text: otherText, key: count++ })); + } + + const pubkey = text.slice(match.index, FIND_MENTIONS.lastIndex); + results.push(); + + // @ts-ignore + last = FIND_MENTIONS.lastIndex; + match = FIND_MENTIONS.exec(text); + } + + if (last < text.length) { + results.push(renderOther({ text: text.slice(last), key: count++ })); + } + + return results; + } +} diff --git a/ts/components/conversation/Emojify.tsx b/ts/components/conversation/Emojify.tsx index eb30fbfa5..0439de8df 100644 --- a/ts/components/conversation/Emojify.tsx +++ b/ts/components/conversation/Emojify.tsx @@ -56,15 +56,17 @@ interface Props { /** Allows you to customize now non-newlines are rendered. Simplest is just a . */ renderNonEmoji?: RenderTextCallbackType; i18n: LocalizerType; + isPublic?: boolean; } export class Emojify extends React.Component { public static defaultProps: Partial = { renderNonEmoji: ({ text }) => text || '', + isPublic: false, }; public render() { - const { text, sizeClass, renderNonEmoji, i18n } = this.props; + const { text, sizeClass, renderNonEmoji, i18n, isPublic } = this.props; const results: Array = []; const regex = getRegex(); @@ -79,13 +81,15 @@ export class Emojify extends React.Component { let count = 1; if (!match) { - return renderNonEmoji({ text, key: 0 }); + return renderNonEmoji({ text, key: 0, isPublic }); } while (match) { if (last < match.index) { const textWithNoEmoji = text.slice(last, match.index); - results.push(renderNonEmoji({ text: textWithNoEmoji, key: count++ })); + results.push( + renderNonEmoji({ text: textWithNoEmoji, key: count++, isPublic }) + ); } results.push(getImageTag({ match, sizeClass, key: count++, i18n })); @@ -95,7 +99,9 @@ export class Emojify extends React.Component { } if (last < text.length) { - results.push(renderNonEmoji({ text: text.slice(last), key: count++ })); + results.push( + renderNonEmoji({ text: text.slice(last), key: count++, isPublic }) + ); } return results; diff --git a/ts/components/conversation/Message.tsx b/ts/components/conversation/Message.tsx index 696d21d74..99fbc6904 100644 --- a/ts/components/conversation/Message.tsx +++ b/ts/components/conversation/Message.tsx @@ -32,6 +32,12 @@ import { isFileDangerous } from '../../util/isFileDangerous'; import { ColorType, LocalizerType } from '../../types/Util'; import { ContextMenu, ContextMenuTrigger, MenuItem } from 'react-contextmenu'; +declare global { + interface Window { + shortenPubkey: any; + } +} + interface Trigger { handleContextClick: (event: React.MouseEvent) => void; } @@ -322,9 +328,7 @@ export class Message extends React.PureComponent { return null; } - const shortenedPubkey = `(...${authorPhoneNumber.substring( - authorPhoneNumber.length - 6 - )})`; + const shortenedPubkey = window.shortenPubkey(authorPhoneNumber); const displayedPubkey = authorProfileName ? shortenedPubkey @@ -585,6 +589,7 @@ export class Message extends React.PureComponent { direction, i18n, quote, + isPublic, } = this.props; if (!quote) { @@ -596,9 +601,7 @@ export class Message extends React.PureComponent { const quoteColor = direction === 'incoming' ? authorColor : quote.authorColor; - const shortenedPubkey = `(...${quote.authorPhoneNumber.substring( - quote.authorPhoneNumber.length - 6 - )})`; + const shortenedPubkey = window.shortenPubkey(quote.authorPhoneNumber); const displayedPubkey = quote.authorProfileName ? shortenedPubkey @@ -611,6 +614,7 @@ export class Message extends React.PureComponent { text={quote.text} attachment={quote.attachment} isIncoming={direction === 'incoming'} + isPublic={isPublic} authorPhoneNumber={displayedPubkey} authorProfileName={quote.authorProfileName} authorName={quote.authorName} @@ -741,6 +745,7 @@ export class Message extends React.PureComponent { isRss={isRss} i18n={i18n} textPending={textPending} + isPublic={this.props.isPublic} /> ); @@ -1020,8 +1025,21 @@ export class Message extends React.PureComponent { const width = this.getWidth(); const isShowingImage = this.isShowingImage(); + // We parse the message later, but we still need to do an early check + // to see if the message mentions us, so we can display the entire + // message differently + const mentions = this.props.text + ? this.props.text.match(window.pubkeyPattern) + : []; + const mentionMe = + mentions && + mentions.some(m => m.slice(1) === window.lokiPublicChatAPI.ourKey); + const shouldHightlight = + mentionMe && direction === 'incoming' && this.props.isPublic; + const divClass = shouldHightlight ? 'message-highlighted' : ''; + return ( -
+
` tags. */ disableLinks?: boolean; + isPublic?: boolean; i18n: LocalizerType; } +const renderMentions: RenderTextCallbackType = ({ text, key }) => ( + +); + +const renderDefault: RenderTextCallbackType = ({ text }) => text; + const renderNewLines: RenderTextCallbackType = ({ text: textWithNewLines, key, -}) => ; + isPublic, +}) => { + const renderOther = isPublic ? renderMentions : renderDefault; + + return ( + + ); +}; const renderEmoji = ({ i18n, @@ -29,12 +48,14 @@ const renderEmoji = ({ key, sizeClass, renderNonEmoji, + isPublic, }: { i18n: LocalizerType; text: string; key: number; sizeClass?: SizeClassType; renderNonEmoji: RenderTextCallbackType; + isPublic?: boolean; }) => ( ); @@ -52,6 +74,10 @@ const renderEmoji = ({ * them for you. */ export class MessageBody extends React.Component { + public static defaultProps: Partial = { + isPublic: false, + }; + public addDownloading(jsx: JSX.Element): JSX.Element { const { i18n, textPending } = this.props; @@ -76,6 +102,7 @@ export class MessageBody extends React.Component { disableLinks, isRss, i18n, + isPublic, } = this.props; const sizeClass = disableJumbomoji ? undefined : getSizeClass(text); const textWithPending = textPending ? `${text}...` : text; @@ -88,6 +115,7 @@ export class MessageBody extends React.Component { sizeClass, key: 0, renderNonEmoji: renderNewLines, + isPublic, }) ); } @@ -103,6 +131,7 @@ export class MessageBody extends React.Component { sizeClass, key, renderNonEmoji: renderNewLines, + isPublic, }); }} /> diff --git a/ts/components/conversation/Quote.tsx b/ts/components/conversation/Quote.tsx index 266ae808f..7d7b381fb 100644 --- a/ts/components/conversation/Quote.tsx +++ b/ts/components/conversation/Quote.tsx @@ -19,6 +19,7 @@ interface Props { i18n: LocalizerType; isFromMe: boolean; isIncoming: boolean; + isPublic?: boolean; withContentAbove: boolean; onClick?: () => void; onClose?: () => void; @@ -214,7 +215,7 @@ export class Quote extends React.Component { } public renderText() { - const { i18n, text, attachment, isIncoming } = this.props; + const { i18n, text, attachment, isIncoming, isPublic } = this.props; if (text) { return ( @@ -225,7 +226,12 @@ export class Quote extends React.Component { isIncoming ? 'module-quote__primary__text--incoming' : null )} > - +
); } diff --git a/ts/types/Util.ts b/ts/types/Util.ts index 5b4c9a1b8..cdccb03e7 100644 --- a/ts/types/Util.ts +++ b/ts/types/Util.ts @@ -2,6 +2,7 @@ export type RenderTextCallbackType = ( options: { text: string; key: number; + isPublic?: boolean; } ) => JSX.Element | string; diff --git a/tslint.json b/tslint.json index d875d9f6a..fae7c9a7c 100644 --- a/tslint.json +++ b/tslint.json @@ -137,6 +137,7 @@ "prefer-type-cast": false, // We use || and && shortcutting because we're javascript programmers "strict-boolean-expressions": false, + "no-suspicious-comment": false, "react-no-dangerous-html": [ true, { From 16692696e068356702065b1bd732a913e682b8d4 Mon Sep 17 00:00:00 2001 From: Maxim Shishmarev Date: Tue, 24 Sep 2019 11:39:16 +1000 Subject: [PATCH 05/14] address reviews --- js/views/conversation_view.js | 12 ++++++------ ts/components/conversation/AddMentions.tsx | 20 +++++++++++--------- 2 files changed, 17 insertions(+), 15 deletions(-) diff --git a/js/views/conversation_view.js b/js/views/conversation_view.js index fefc9e06f..355d55617 100644 --- a/js/views/conversation_view.js +++ b/js/views/conversation_view.js @@ -326,7 +326,7 @@ window.lokiPublicChatAPI.setListOfMembers(allMembers); }; - if (this.model.id === 'publicChat:1@chat-dev.lokinet.org') { + if (this.model.isPublic()) { updateMemberList(); setInterval(updateMemberList, 10000); } @@ -2324,11 +2324,11 @@ ? allMembers.filter(m => filterMembers(query, m)) : allMembers; } - membersToShow = membersToShow.map(m => ({ - authorPhoneNumber: m.authorPhoneNumber, - authorProfileName: m.authorProfileName, - id: m.id, - })); + + membersToShow = membersToShow.map(m => + _.pick(m, ['authorPhoneNumber', 'authorProfileName', 'id']) + ); + this.memberView.updateMembers(membersToShow); }, diff --git a/ts/components/conversation/AddMentions.tsx b/ts/components/conversation/AddMentions.tsx index c95efd8ca..2b14e5488 100644 --- a/ts/components/conversation/AddMentions.tsx +++ b/ts/components/conversation/AddMentions.tsx @@ -21,6 +21,7 @@ interface MentionState { } class Mention extends React.Component { + private intervalHandle: any = null; constructor(props: any) { super(props); @@ -33,12 +34,11 @@ class Mention extends React.Component { this.tryRenameMention(); // TODO: give up after some period of time? - const intervalHandle = setInterval(this.tryRenameMention, 30000); - this.clearOurInterval = this.clearOurInterval.bind(this, intervalHandle); + this.intervalHandle = setInterval(this.tryRenameMention, 30000); } public componentWillUnmount() { - this.clearOurInterval(null); + this.clearOurInterval(); } public render() { @@ -51,9 +51,11 @@ class Mention extends React.Component { us && 'mention-profile-name-us' ); - return ( - {this.state.found.authorProfileName} - ); + const profileName = this.state.found.authorProfileName; + const displayedName = + profileName && profileName.length > 0 ? profileName : 'Anonymous'; + + return {displayedName}; } else { return ( @@ -63,15 +65,15 @@ class Mention extends React.Component { } } - private clearOurInterval(handle: any) { - clearInterval(handle); + private clearOurInterval() { + clearInterval(this.intervalHandle); } private tryRenameMention() { const found = this.findMember(this.props.text.slice(1)); if (found) { this.setState({ found }); - this.clearOurInterval(null); + this.clearOurInterval(); } } From d85cd980c2d6625138b1d2eff523d808e197ceba Mon Sep 17 00:00:00 2001 From: Ryan Tharp Date: Tue, 24 Sep 2019 17:18:31 -0700 Subject: [PATCH 06/14] Update js/modules/loki_public_chat_api.js oof Co-Authored-By: sachaaaaa <40749766+sachaaaaa@users.noreply.github.com> --- js/modules/loki_public_chat_api.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/js/modules/loki_public_chat_api.js b/js/modules/loki_public_chat_api.js index 25e081e3a..b2f0eb0d2 100644 --- a/js/modules/loki_public_chat_api.js +++ b/js/modules/loki_public_chat_api.js @@ -758,7 +758,7 @@ class LokiPublicChannelAPI { ], }; if (quote && quote.id) { - payload.annoations[0].value.quote = quote; + payload.annotations[0].value.quote = quote; // copied from model/message.js copyFromQuotedMessage const collection = await Signal.Data.getMessagesBySentAt(quote.id, { From c8cf05558f3fdaf2f314dcdeb2a3ed22cf5b949f Mon Sep 17 00:00:00 2001 From: Ryan Tharp Date: Tue, 24 Sep 2019 17:33:32 -0700 Subject: [PATCH 07/14] pollOnceForMessages() move lastGot up, address Sacha's review --- js/modules/loki_public_chat_api.js | 30 +++++++++++++----------------- 1 file changed, 13 insertions(+), 17 deletions(-) diff --git a/js/modules/loki_public_chat_api.js b/js/modules/loki_public_chat_api.js index b2f0eb0d2..b4b45db9f 100644 --- a/js/modules/loki_public_chat_api.js +++ b/js/modules/loki_public_chat_api.js @@ -223,6 +223,7 @@ class LokiPublicChannelAPI { this.deleteLastId = 1; this.timers = {}; this.running = true; + // can escalated to SQL if it start uses too much memory this.logMop = {}; // Cache for duplicate checking @@ -572,8 +573,16 @@ class LokiPublicChannelAPI { let timestamp = new Date(adnMessage.created_at).getTime(); // pubKey lives in the username field const from = adnMessage.user.name; - let sigValid; + let sigValid = false; + let sig = false; + let sigvar = false; let quote = null; + + // still update our last received if deleted, not signed or not valid + this.lastGot = !this.lastGot + ? adnMessage.id + : Math.max(this.lastGot, adnMessage.id); + if (adnMessage.is_deleted) { return; } @@ -593,8 +602,8 @@ class LokiPublicChannelAPI { if (noteValue.sig) { // try to verify signature - const { sig, sigver } = noteValue; - const annoCopy = JSON.parse(JSON.stringify(adnMessage.annotations)); + { sig, sigver } = noteValue; + const annoCopy = { ...adnMessage.annotations }; delete annoCopy[0].value.sig; delete annoCopy[0].value.sigver; const verifyObj = { @@ -623,19 +632,9 @@ class LokiPublicChannelAPI { } } } - + // we now only accept valid messages into the public chat if (sigValid !== true) { - let sig; - let sigver; - if ( - Array.isArray(adnMessage.annotations) && - adnMessage.annotations.length !== 0 - ) { - if (adnMessage.annotations[0].value.sig) { - ({ sig, sigver } = adnMessage.annotations[0].value); - } - } // keep noise out of the logs, once per start up is enough if (this.logMop[adnMessage.id] === undefined) { log.warn( @@ -736,9 +735,6 @@ class LokiPublicChannelAPI { // now process any user meta data updates // - update their conversation with a potentially new avatar - this.lastGot = !this.lastGot - ? adnMessage.id - : Math.max(this.lastGot, adnMessage.id); }); this.conversation.setLastRetrievedMessage(this.lastGot); } From 968ad9227c97190757c2ab8d1ad85d6d4a579609 Mon Sep 17 00:00:00 2001 From: Ryan Tharp Date: Tue, 24 Sep 2019 18:03:46 -0700 Subject: [PATCH 08/14] refactor pls forgive, using github editor for expedience, untested changes --- js/modules/loki_public_chat_api.js | 191 +++++++++++++++-------------- 1 file changed, 100 insertions(+), 91 deletions(-) diff --git a/js/modules/loki_public_chat_api.js b/js/modules/loki_public_chat_api.js index b4b45db9f..ad712505d 100644 --- a/js/modules/loki_public_chat_api.js +++ b/js/modules/loki_public_chat_api.js @@ -379,14 +379,14 @@ class LokiPublicChannelAPI { const privKey = await this.serverAPI.chatAPI.getPrivateKey(); // we might need an annotation that sets the homeserver for media // better to include this with each attachment... - const ObjToSign = { + const objToSign = { name: profileName, version: 1, annotations: [], }; const sig = await libsignal.Curve.async.calculateSignature( privKey, - JSON.stringify(ObjToSign) + JSON.stringify(objToSign) ); */ @@ -534,6 +534,81 @@ class LokiPublicChannelAPI { more = res.response.meta.more && res.response.data.length >= params.count; } } + + async getMessengerData(adnMessage) { + if (!Array.isArray(adnMessage.annotations) || adnMessage.annotations.length === 0) return false; + const noteValue = adnMessage.annotations[0].value; + + // signatures now required + if (!noteValue.sig!) { + return false; + } + + let timetstamp = 0; + let quote = null; + + // timestamp is the only required field we've had since the first deployed version + ({ timestamp } = noteValue); + + if (noteValue.quote) { + ({ quote } = noteValue); + if (quote) { + quote.attachments = []; + } + } + + // try to verify signature + let { sig, sigver } = noteValue; + const annoCopy = _.omit(adnMessage.annotations, ['value.sig', 'value.sigver']); + const verifyObj = { + text: adnMessage.text, + version: sigver, + annotations: annoCopy, + }; + if (adnMessage.reply_to) { + verifyObj.reply_to = adnMessage.reply_to; + } + + const pubKeyBin = StringView.hexToArrayBuffer( + adnMessage.user.username + ); + const sigBin = StringView.hexToArrayBuffer(sig); + try { + await libsignal.Curve.async.verifySignature( + pubKeyBin, + JSON.stringify(verifyObj), + sigBin + ); + } catch (e) { + if (e.message === 'Invalid signature') { + // keep noise out of the logs, once per start up is enough + if (this.logMop[adnMessage.id] === undefined) { + log.warn( + 'Invalid or missing signature on ', + this.serverAPI.baseServerUrl, + this.channelId, + adnMessage.id, + 'says', + adnMessage.text, + 'from', + adnMessage.user.username, + 'signature', + sig, + 'signature version', + sigver + ); + this.logMop[adnMessage.id] = true; + } + // we now only accept valid messages into the public chat + return false; + } + } + + return { + timestamp, + quote + } + } // get channel messages async pollForMessages() { @@ -570,103 +645,33 @@ class LokiPublicChannelAPI { if (!res.err && res.response) { let receivedAt = new Date().getTime(); res.response.data.reverse().forEach(async adnMessage => { - let timestamp = new Date(adnMessage.created_at).getTime(); - // pubKey lives in the username field - const from = adnMessage.user.name; - let sigValid = false; - let sig = false; - let sigvar = false; - let quote = null; // still update our last received if deleted, not signed or not valid this.lastGot = !this.lastGot ? adnMessage.id : Math.max(this.lastGot, adnMessage.id); - - if (adnMessage.is_deleted) { - return; - } - if ( - Array.isArray(adnMessage.annotations) && - adnMessage.annotations.length !== 0 - ) { - const noteValue = adnMessage.annotations[0].value; - ({ timestamp } = noteValue); - - if (noteValue.quote) { - ({ quote } = noteValue); - if (quote) { - quote.attachments = []; - } - } - - if (noteValue.sig) { - // try to verify signature - { sig, sigver } = noteValue; - const annoCopy = { ...adnMessage.annotations }; - delete annoCopy[0].value.sig; - delete annoCopy[0].value.sigver; - const verifyObj = { - text: adnMessage.text, - version: sigver, - annotations: annoCopy, - }; - if (adnMessage.reply_to) { - verifyObj.reply_to = adnMessage.reply_to; - } - const pubKeyBin = StringView.hexToArrayBuffer( - adnMessage.user.username - ); - const sigBin = StringView.hexToArrayBuffer(sig); - try { - await libsignal.Curve.async.verifySignature( - pubKeyBin, - JSON.stringify(verifyObj), - sigBin - ); - sigValid = true; - } catch (e) { - if (e.message === 'Invalid signature') { - sigValid = false; - } - } - } - } - - // we now only accept valid messages into the public chat - if (sigValid !== true) { - // keep noise out of the logs, once per start up is enough - if (this.logMop[adnMessage.id] === undefined) { - log.warn( - 'Invalid or missing signature on ', - this.serverAPI.baseServerUrl, - this.channelId, - adnMessage.id, - 'says', - adnMessage.text, - 'from', - adnMessage.user.username, - 'signature', - sig, - 'signature version', - sigver - ); - this.logMop[adnMessage.id] = true; - } - return; // Invalid signature - } if ( - !from || - !timestamp || !adnMessage.id || !adnMessage.user || - !adnMessage.user.username || - !adnMessage.text + !adnMessage.user.username || // pubKey lives in the username field + !adnMessage.user.name || // profileName lives in the name field + !adnMessage.text || + adnMessage.is_deleted ) { - return; // Invalid message + return; // Invalid or delete message + } + + const messengerData = this.getMessengerData(adnMessage); + if (messengerData === false) { + return; } + const timestamp = messengerData.timestamp; + if (!timestamp) { + return; // Invalid message + } + // Duplicate check const isDuplicate = message => { // The username in this case is the users pubKey @@ -694,7 +699,11 @@ class LokiPublicChannelAPI { timestamp, }, ].splice(-5); - + + + const quote = messengerData.quote; + const from = adnMessage.user.name; // profileName + const messageData = { serverId: adnMessage.id, clientVerified: sigValid, @@ -775,17 +784,17 @@ class LokiPublicChannelAPI { } } const privKey = await this.serverAPI.chatAPI.getPrivateKey(); - const ObjToSign = { + const objToSign = { version: 1, text, annotations: payload.annotations, }; if (payload.reply_to) { - ObjToSign.reply_to = payload.reply_to; + objToSign.reply_to = payload.reply_to; } const sig = await libsignal.Curve.async.calculateSignature( privKey, - JSON.stringify(ObjToSign) + JSON.stringify(objToSign) ); payload.annotations[0].value.sig = StringView.arrayBufferToHex(sig); payload.annotations[0].value.sigver = 1; From b62801bc2a031f78890f4136ca14f85db358e6de Mon Sep 17 00:00:00 2001 From: Ryan Tharp Date: Tue, 24 Sep 2019 18:18:38 -0700 Subject: [PATCH 09/14] fix typo CI found --- js/modules/loki_public_chat_api.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/js/modules/loki_public_chat_api.js b/js/modules/loki_public_chat_api.js index ad712505d..f99320fe0 100644 --- a/js/modules/loki_public_chat_api.js +++ b/js/modules/loki_public_chat_api.js @@ -540,7 +540,7 @@ class LokiPublicChannelAPI { const noteValue = adnMessage.annotations[0].value; // signatures now required - if (!noteValue.sig!) { + if (!noteValue.sig) { return false; } @@ -558,7 +558,7 @@ class LokiPublicChannelAPI { } // try to verify signature - let { sig, sigver } = noteValue; + const { sig, sigver } = noteValue; const annoCopy = _.omit(adnMessage.annotations, ['value.sig', 'value.sigver']); const verifyObj = { text: adnMessage.text, From a976388315f9d865e6300ab6543da39f7ef713ca Mon Sep 17 00:00:00 2001 From: Ryan Tharp Date: Tue, 24 Sep 2019 18:36:25 -0700 Subject: [PATCH 10/14] Handle annotations omit correctly --- js/modules/loki_public_chat_api.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/js/modules/loki_public_chat_api.js b/js/modules/loki_public_chat_api.js index f99320fe0..0da4cac2a 100644 --- a/js/modules/loki_public_chat_api.js +++ b/js/modules/loki_public_chat_api.js @@ -559,7 +559,9 @@ class LokiPublicChannelAPI { // try to verify signature const { sig, sigver } = noteValue; - const annoCopy = _.omit(adnMessage.annotations, ['value.sig', 'value.sigver']); + const annoCopy = [ ...adnMessage.annotations ]; + delete annoCopy[0].value.sig; + delete annoCopy[0].value.sigver; const verifyObj = { text: adnMessage.text, version: sigver, @@ -602,6 +604,9 @@ class LokiPublicChannelAPI { // we now only accept valid messages into the public chat return false; } + // any error should cause problem + log.error(`Unhandled message signature validation error ${e.message}`); + return false; } return { From ab440a699fa2c3d0b3382465d75bc039ef210f78 Mon Sep 17 00:00:00 2001 From: Ryan Tharp Date: Tue, 24 Sep 2019 18:37:31 -0700 Subject: [PATCH 11/14] Update js/modules/loki_public_chat_api.js Co-Authored-By: sachaaaaa <40749766+sachaaaaa@users.noreply.github.com> --- js/modules/loki_public_chat_api.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/js/modules/loki_public_chat_api.js b/js/modules/loki_public_chat_api.js index 0da4cac2a..da1dc525e 100644 --- a/js/modules/loki_public_chat_api.js +++ b/js/modules/loki_public_chat_api.js @@ -667,7 +667,7 @@ class LokiPublicChannelAPI { return; // Invalid or delete message } - const messengerData = this.getMessengerData(adnMessage); + const messengerData = await this.getMessengerData(adnMessage); if (messengerData === false) { return; } From 6ac6d656a4c9486eae67deabb7fd98e300e1e00b Mon Sep 17 00:00:00 2001 From: Ryan Tharp Date: Tue, 24 Sep 2019 18:41:26 -0700 Subject: [PATCH 12/14] make sure versioning on write can't get out of sync --- js/modules/loki_public_chat_api.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/js/modules/loki_public_chat_api.js b/js/modules/loki_public_chat_api.js index da1dc525e..8175843fc 100644 --- a/js/modules/loki_public_chat_api.js +++ b/js/modules/loki_public_chat_api.js @@ -560,8 +560,8 @@ class LokiPublicChannelAPI { // try to verify signature const { sig, sigver } = noteValue; const annoCopy = [ ...adnMessage.annotations ]; - delete annoCopy[0].value.sig; - delete annoCopy[0].value.sigver; + // strip out sig and sigver + annoCopy[0] = _.omit(annoCopy[0], ['value.sig', 'value.sigver']); const verifyObj = { text: adnMessage.text, version: sigver, @@ -802,7 +802,7 @@ class LokiPublicChannelAPI { JSON.stringify(objToSign) ); payload.annotations[0].value.sig = StringView.arrayBufferToHex(sig); - payload.annotations[0].value.sigver = 1; + payload.annotations[0].value.sigver = objToSign.version; const res = await this.serverRequest(`${this.baseChannelUrl}/messages`, { method: 'POST', objBody: payload, From 8bfb7f297dcb96e6e31ddca1144bbc06c1c143dd Mon Sep 17 00:00:00 2001 From: Ryan Tharp Date: Tue, 24 Sep 2019 19:08:03 -0700 Subject: [PATCH 13/14] lint pass 1 --- js/modules/loki_public_chat_api.js | 26 ++++++++++---------------- 1 file changed, 10 insertions(+), 16 deletions(-) diff --git a/js/modules/loki_public_chat_api.js b/js/modules/loki_public_chat_api.js index 8175843fc..0e04b99b5 100644 --- a/js/modules/loki_public_chat_api.js +++ b/js/modules/loki_public_chat_api.js @@ -536,7 +536,9 @@ class LokiPublicChannelAPI { } async getMessengerData(adnMessage) { - if (!Array.isArray(adnMessage.annotations) || adnMessage.annotations.length === 0) return false; + if (!Array.isArray(adnMessage.annotations) || adnMessage.annotations.length === 0) { + return false; + } const noteValue = adnMessage.annotations[0].value; // signatures now required @@ -544,17 +546,11 @@ class LokiPublicChannelAPI { return false; } - let timetstamp = 0; - let quote = null; - // timestamp is the only required field we've had since the first deployed version - ({ timestamp } = noteValue); + let { timestamp, quote } = noteValue; - if (noteValue.quote) { - ({ quote } = noteValue); - if (quote) { - quote.attachments = []; - } + if (quote) { + quote.attachments = []; } // try to verify signature @@ -611,7 +607,7 @@ class LokiPublicChannelAPI { return { timestamp, - quote + quote, } } @@ -672,7 +668,7 @@ class LokiPublicChannelAPI { return; } - const timestamp = messengerData.timestamp; + const { timestamp, quote } = messengerData; if (!timestamp) { return; // Invalid message } @@ -704,14 +700,12 @@ class LokiPublicChannelAPI { timestamp, }, ].splice(-5); - - - const quote = messengerData.quote; + const from = adnMessage.user.name; // profileName const messageData = { serverId: adnMessage.id, - clientVerified: sigValid, + clientVerified: true, friendRequest: false, source: adnMessage.user.username, sourceDevice: 1, From 34b7117ff725972e4d67cd7bf9fc68f868f1b389 Mon Sep 17 00:00:00 2001 From: Ryan Tharp Date: Tue, 24 Sep 2019 19:16:07 -0700 Subject: [PATCH 14/14] lint pass 2 --- js/modules/loki_public_chat_api.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/js/modules/loki_public_chat_api.js b/js/modules/loki_public_chat_api.js index 0e04b99b5..48d0f26bd 100644 --- a/js/modules/loki_public_chat_api.js +++ b/js/modules/loki_public_chat_api.js @@ -1,5 +1,5 @@ /* global log, textsecure, libloki, Signal, Whisper, Headers, ConversationController, -clearTimeout, MessageController, libsignal, StringView, window */ +clearTimeout, MessageController, libsignal, StringView, window, _ */ const EventEmitter = require('events'); const nodeFetch = require('node-fetch'); const { URL, URLSearchParams } = require('url'); @@ -547,7 +547,7 @@ class LokiPublicChannelAPI { } // timestamp is the only required field we've had since the first deployed version - let { timestamp, quote } = noteValue; + const { timestamp, quote } = noteValue; if (quote) { quote.attachments = [];