/* global log, textsecure, libloki, Signal, Whisper, clearTimeout, getMessageController, libsignal, StringView, window, _, dcodeIO, Buffer, process */ const { URL } = require('url'); const FormData = require('form-data'); const path = require('path'); const DataMessage = require('../../ts/receiver/dataMessage'); const OnionSend = require('../../ts/session/onions/onionSend'); // Can't be less than 1200 if we have unauth'd requests const PUBLICCHAT_MSG_POLL_EVERY = 1.5 * 1000; // 1.5s const PUBLICCHAT_CHAN_POLL_EVERY = 20 * 1000; // 20s const PUBLICCHAT_DELETION_POLL_EVERY = 5 * 1000; // 5s const PUBLICCHAT_MOD_POLL_EVERY = 30 * 1000; // 30s const LOKIFOUNDATION_DEVFILESERVER_PUBKEY = 'BSZiMVxOco/b3sYfaeyiMWv/JnqokxGXkHoclEx8TmZ6'; const LOKIFOUNDATION_FILESERVER_PUBKEY = 'BWJQnVm97sQE3Q1InB4Vuo+U/T1hmwHBv0ipkiv8tzEc'; const LOKIFOUNDATION_APNS_PUBKEY = 'BWQqZYWRl0LlotTcUSRJZPvNi8qyt1YSQH3li4EHQNBJ'; const urlPubkeyMap = { 'https://file-dev.getsession.org': LOKIFOUNDATION_DEVFILESERVER_PUBKEY, 'https://file-dev.lokinet.org': LOKIFOUNDATION_DEVFILESERVER_PUBKEY, 'https://file.getsession.org': LOKIFOUNDATION_FILESERVER_PUBKEY, 'https://file.lokinet.org': LOKIFOUNDATION_FILESERVER_PUBKEY, 'https://dev.apns.getsession.org': LOKIFOUNDATION_APNS_PUBKEY, 'https://live.apns.getsession.org': LOKIFOUNDATION_APNS_PUBKEY, }; const HOMESERVER_USER_ANNOTATION_TYPE = 'network.loki.messenger.homeserver'; const AVATAR_USER_ANNOTATION_TYPE = 'network.loki.messenger.avatar'; const SETTINGS_CHANNEL_ANNOTATION_TYPE = 'net.patter-app.settings'; const MESSAGE_ATTACHMENT_TYPE = 'net.app.core.oembed'; const LOKI_ATTACHMENT_TYPE = 'attachment'; const LOKI_PREVIEW_TYPE = 'preview'; // the core ADN class that handles all communication with a specific server class LokiAppDotNetServerAPI { constructor(ourKey, url) { this.ourKey = ourKey; this.channels = []; this.tokenPromise = null; this.baseServerUrl = url; log.info(`LokiAppDotNetAPI registered server ${url}`); } async open() { // check token, we're not sure how long we were asleep, token may have expired await this.getOrRefreshServerToken(); // now that we have a working token, start up pollers this.channels.forEach(channel => channel.open()); } async close() { this.channels.forEach(channel => channel.stop()); // match sure our pending requests are finished // in case it's still starting up if (this.tokenPromise) { await this.tokenPromise; } } // channel getter/factory async findOrCreateChannel(chatAPI, channelId, conversationId) { let thisChannel = this.channels.find(channel => channel.channelId === channelId); if (!thisChannel) { // make sure we're subscribed // eventually we'll need to move to account registration/add server await this.serverRequest(`channels/${channelId}/subscribe`, { method: 'POST', }); thisChannel = new LokiPublicChannelAPI(chatAPI, this, channelId, conversationId); log.info('LokiPublicChannelAPI started for', channelId, 'on', this.baseServerUrl); this.channels.push(thisChannel); } return thisChannel; } async partChannel(channelId) { log.info('partChannel', channelId, 'from', this.baseServerUrl); await this.serverRequest(`channels/${channelId}/subscribe`, { method: 'DELETE', }); this.unregisterChannel(channelId); } // deallocate resources channel uses unregisterChannel(channelId) { log.info('unregisterChannel', channelId, 'from', this.baseServerUrl); 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; } thisChannel.stop(); this.channels.splice(i, 1); } // set up pubKey & pubKeyHex properties // optionally called for mainly file server comms getPubKeyForUrl() { if (!window.lokiFeatureFlags.useOnionRequests) { // pubkeys don't matter return ''; } // Hard coded let pubKeyAB; if (urlPubkeyMap && urlPubkeyMap[this.baseServerUrl]) { pubKeyAB = window.Signal.Crypto.base64ToArrayBuffer(urlPubkeyMap[this.baseServerUrl]); } // do we have their pubkey locally? // FIXME: this._server won't be set yet... // can't really do this for the file server because we'll need the key // before we can communicate with lsrpc if (window.lokiFeatureFlags.useFileOnionRequests) { if ( window.lokiPublicChatAPI && window.lokiPublicChatAPI.openGroupPubKeys && window.lokiPublicChatAPI.openGroupPubKeys[this.baseServerUrl] ) { pubKeyAB = window.lokiPublicChatAPI.openGroupPubKeys[this.baseServerUrl]; } } // else will fail validation later // now that key is loaded, lets verify if (pubKeyAB && pubKeyAB.byteLength && pubKeyAB.byteLength !== 33) { log.error('FILESERVER PUBKEY is invalid, length:', pubKeyAB.byteLength); process.exit(1); } this.pubKey = pubKeyAB; this.pubKeyHex = StringView.arrayBufferToHex(pubKeyAB); return pubKeyAB; } async setProfileName(profileName) { // when we add an annotation, may need this /* const privKey = await this.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) ); */ // You cannot use null to clear the profile name // the name key has to be set to know what value we want changed const pName = profileName || ''; const res = await this.serverRequest('users/me', { method: 'PATCH', objBody: { name: pName, }, }); // no big deal if it fails... if (res.err || !res.response || !res.response.data) { if (res.err) { log.error(`setProfileName Error ${res.err} ${res.statusCode}`, this.baseServerUrl); } return []; } // expecting a user object return res.response.data.annotations || []; // if no profileName should we update the local from the server? // no because there will be multiple public chat servers } async setHomeServer(homeServer) { const res = await this.serverRequest('users/me', { method: 'PATCH', objBody: { annotations: [ { type: HOMESERVER_USER_ANNOTATION_TYPE, value: homeServer, }, ], }, }); if (res.err || !res.response || !res.response.data) { if (res.err) { log.error(`setHomeServer Error ${res.err}`); } return []; } // expecting a user object return res.response.data.annotations || []; } async setAvatar(url, profileKey) { let value; // undefined will save bandwidth on the annotation if we don't need it (no avatar) if (url && profileKey) { value = { url, profileKey }; } return this.setSelfAnnotation(AVATAR_USER_ANNOTATION_TYPE, value); } // 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; // if no token to verify, just bail now if (!token) { // if we haven't forced it if (!forceRefresh) { // try one more time with requesting a fresh token token = await this.getOrRefreshServerToken(true); } return token; } // verify token info const tokenRes = await this.serverRequest('token'); // if no problems and we have data if ( !tokenRes.err && tokenRes.response && tokenRes.response.data && tokenRes.response.data.user ) { // get our profile name // this should be primaryDevicePubKey // because the rest of the profile system uses that... const ourNumber = window.libsession.Utils.UserUtils.getOurPubKeyStrFromCache(); const profileConvo = window.getConversationController().get(ourNumber); const profile = profileConvo && profileConvo.getLokiProfile(); const profileName = profile && profile.displayName; // if doesn't match, write it to the network if (tokenRes.response.data.user.name !== profileName) { // update our profile name if it got out of sync this.setProfileName(profileName); } } if (tokenRes.err) { log.error(`token err`, tokenRes); // didn't already try && this specific error if ( !forceRefresh && tokenRes.response && tokenRes.response.meta && tokenRes.response.meta.code === 401 ) { // this token is not good this.token = ''; // remove from object await Signal.Data.savePublicServerToken({ serverUrl: this.baseServerUrl, token: '', }); token = await this.getOrRefreshServerToken(true); } } 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) { // FIXME: add timeout // a broken/stuck token endpoint can prevent you from removing channels // set lock this.tokenPromise = new Promise(async res => { // request the token 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() { let res; try { const params = { pubKey: this.ourKey, }; res = await this.serverRequest('loki/v1/get_challenge', { method: 'GET', params, }); } catch (e) { // should we retry here? // no, this is the low level function // not really an error, from a client's pov, network servers can fail... if (e.code === 'ECONNREFUSED') { // down log.warn('requestToken request can not connect', this.baseServerUrl, e.message); } else if (e.code === 'ECONNRESET') { // got disconnected log.warn('requestToken request lost connection', this.baseServerUrl, e.message); } else { log.error('requestToken request failed', this.baseServerUrl, e.code, e.message); } return null; } if (!res.ok) { log.error('requestToken request failed'); return null; } const body = res.response; const token = await libloki.crypto.decryptToken(body); return token; } // activate token async submitToken(token) { try { const res = await this.serverRequest('loki/v1/submit_challenge', { method: 'POST', objBody: { pubKey: this.ourKey, token, }, noJson: true, }); return res.ok; } catch (e) { log.error('submitToken serverRequest failure', e.code, e.message); return false; } } // make a request to the server async serverRequest(endpoint, options = {}) { if (options.forceFreshToken) { await this.getOrRefreshServerToken(true); } return OnionSend.serverRequest(`${this.baseServerUrl}/${endpoint}`, { ...options, token: this.token, srvPubKey: this.pubKey, }); } async getUserAnnotations(pubKey) { if (!pubKey) { log.warn('No pubkey provided to getUserAnnotations!'); return []; } const res = await this.serverRequest(`users/@${pubKey}`, { method: 'GET', params: { include_user_annotations: 1, }, }); if (res.err || !res.response || !res.response.data) { if (res.err) { log.error(`getUserAnnotations Error ${res.err}`); } return []; } return res.response.data.annotations || []; } async getModerators(channelId) { if (!channelId) { log.warn('No channelId provided to getModerators!'); return []; } const res = await this.serverRequest(`loki/v1/channels/${channelId}/moderators`); return (!res.err && res.response && res.response.moderators) || []; } async addModerator(pubKeyStr) { const pubkey = `@${pubKeyStr}`; const users = await this.getUsers([pubkey]); const validUsers = users.filter(user => !!user.id); if (!validUsers || validUsers.length === 0) { return false; } const results = await Promise.all( validUsers.map(async user => { log.info(`POSTing loki/v1/moderators/${user.id}`); const res = await this.serverRequest(`loki/v1/moderators/${user.id}`, { method: 'POST', }); return !!(!res.err && res.response && res.response.data); }) ); const anyFailures = results.some(test => !test); if (anyFailures) { window.log.info('failed to add moderator:', results); } return !anyFailures; } async removeModerators(pubKeysParam) { let pubKeys = pubKeysParam; if (!Array.isArray(pubKeys)) { pubKeys = [pubKeys]; } pubKeys = pubKeys.map(key => `@${key}`); const users = await this.getUsers(pubKeys); const validUsers = users.filter(user => !!user.id); const results = await Promise.all( validUsers.map(async user => { const res = await this.serverRequest(`loki/v1/moderators/${user.id}`, { method: 'DELETE', }); return !!(!res.err && res.response && res.response.data); }) ); const anyFailures = results.some(test => !test); if (anyFailures) { window.log.info('failed to remove moderator:', results); } return !anyFailures; } async getSubscribers(channelId, wantObjects) { if (!channelId) { log.warn('No channelId provided to getSubscribers!'); return []; } let res = {}; if (!Array.isArray(channelId) && wantObjects) { res = await this.serverRequest(`channels/${channelId}/subscribers`, { method: 'GET', params: { include_user_annotations: 1, }, }); } else { // not deployed on all backends yet res.err = 'array subscribers endpoint not yet implemented'; /* var list = channelId; if (!Array.isArray(list)) { list = [channelId]; } const idres = await this.serverRequest(`channels/subscribers/ids`, { method: 'GET', params: { ids: list.join(','), include_user_annotations: 1, }, }); if (wantObjects) { if (idres.err || !idres.response || !idres.response.data) { if (idres.err) { log.error(`Error ${idres.err}`); } return []; } const userList = []; await Promise.all(idres.response.data.map(async channelId => { const channelUserObjs = await this.getUsers(idres.response.data[channelId]); userList.push(...channelUserObjs); })); res = { response: { meta: { code: 200, }, data: userList } } } else { res = idres; } */ } if (res.err || !res.response || !res.response.data) { if (res.err) { log.error(`getSubscribers Error ${res.err}`); } return []; } return res.response.data || []; } async getUsers(pubKeys) { if (!pubKeys) { log.warn('No pubKeys provided to getUsers!'); return []; } // ok to call without if (!pubKeys.length) { return []; } if (pubKeys.length > 200) { log.warn('Too many pubKeys given to getUsers!'); } const res = await this.serverRequest('users', { method: 'GET', params: { ids: pubKeys.join(','), include_user_annotations: 1, }, }); if (res.err || !res.response || !res.response.data) { if (res.err) { log.error(`loki_app_dot_net:::getUsers - Error: ${res.err} for ${pubKeys.join(',')}`); } return []; } return res.response.data || []; } // Only one annotation at a time async setSelfAnnotation(type, value) { const annotation = { type }; // to delete annotation, omit the "value" field if (value) { annotation.value = value; } const res = await this.serverRequest('users/me', { method: 'PATCH', objBody: { annotations: [annotation], }, }); if (!res.err && res.response) { return res.response; } return false; } async uploadAvatar(data) { const endpoint = 'users/me/avatar'; const options = { method: 'POST', rawBody: data, }; const { response, ok } = await this.serverRequest(endpoint, options); if (!ok) { throw new Error(`Failed to upload avatar to ${this.baseServerUrl}`); } const url = response.data && response.data.avatar_image && response.data.avatar_image.url; if (!url) { throw new Error(`Failed to upload data: Invalid url.`); } // We don't use the server id for avatars return { url, id: undefined, }; } // for avatar async uploadData(data) { const endpoint = 'files'; const options = { method: 'POST', rawBody: data, }; const { ok, response } = await this.serverRequest(endpoint, options); if (!ok) { throw new Error(`Failed to upload data to server: ${this.baseServerUrl}`); } const url = response.data && response.data.url; const id = response.data && response.data.id; if (!url || !id) { throw new Error(`Failed to upload data: Invalid url or id returned.`); } return { url, id, }; } // for files putAttachment(attachmentBin) { const formData = new FormData(); const buffer = Buffer.from(attachmentBin); formData.append('type', 'network.loki'); formData.append('content', buffer, { contentType: 'application/octet-stream', name: 'content', filename: 'attachment', knownLength: buffer.byteLength, }); return this.uploadData(formData); } putAvatar(buf) { const formData = new FormData(); const buffer = Buffer.from(buf); formData.append('avatar', buffer, { contentType: 'application/octet-stream', name: 'avatar', filename: 'attachment', }); return this.uploadAvatar(formData); } // This should return Uint8Array in response.data async downloadAttachment(url) { const endpoint = new URL(url).pathname; // With the new protocol, there is no json in body, we shouldn't try to parse it const noJson = window.lokiFeatureFlags.useFileOnionRequestsV2; const res = await this.serverRequest(`loki/v1${endpoint}`, { method: 'GET', noJson, }); if (window.lokiFeatureFlags.useFileOnionRequestsV2) { const buffer = dcodeIO.ByteBuffer.fromBase64(res.response).toArrayBuffer(); return buffer; } return new Uint8Array(res.response.data).buffer; } } // functions to a specific ADN channel on an ADN server class LokiPublicChannelAPI { constructor(_, serverAPI, channelId, conversationId) { // properties this.serverAPI = serverAPI; this.channelId = channelId; this.baseChannelUrl = `channels/${this.channelId}`; this.conversationId = conversationId; this.conversation = window.getConversationController().getOrThrow(conversationId); this.lastMessageServerID = null; this.modStatus = false; this.deleteLastId = 1; this.timers = {}; this.myPrivateKey = false; this.messagesPollLock = false; // can escalated to SQL if it start uses too much memory this.logMop = {}; // Cache for duplicate checking this.lastMessagesCache = []; // end properties log.info(`registered LokiPublicChannel ${channelId} on ${this.serverAPI.baseServerUrl}`); // start polling this.open(); } async getPrivateKey() { if (!this.myPrivateKey) { const item = await window.Signal.Data.getItemById('identityKey'); const keyPair = (item && item.value) || undefined; if (!keyPair) { window.log.error('Could not get our Keypair from getItemById'); } this.myPrivateKey = keyPair.privKey; } return this.myPrivateKey; } async banUser(pubkey) { const res = await this.serverRequest(`loki/v1/moderation/blacklist/@${pubkey}`, { method: 'POST', }); if (res.err || !res.response || !res.response.data) { if (res.err) { log.error(`banUser Error ${res.err}`); } return false; } return true; } open() { log.info(`LokiPublicChannel open ${this.channelId} on ${this.serverAPI.baseServerUrl}`); if (this.running) { log.warn( `LokiPublicChannel already open ${this.channelId} on ${this.serverAPI.baseServerUrl}` ); } this.running = true; if (!this.timers.channel) { this.pollForChannel(); } if (!this.timers.moderator) { this.pollForModerators(); } if (!this.timers.delete) { this.pollForDeletions(); } if (!this.timers.message) { this.pollForMessages(); } // TODO: poll for group members here? } stop() { log.info(`LokiPublicChannel close ${this.channelId} on ${this.serverAPI.baseServerUrl}`); if (!this.running) { log.warn( `LokiPublicChannel already open ${this.channelId} on ${this.serverAPI.baseServerUrl}` ); } this.running = false; if (this.timers.channel) { clearTimeout(this.timers.channel); this.timers.channel = false; } if (this.timers.moderator) { clearTimeout(this.timers.moderator); this.timers.moderator = false; } if (this.timers.delete) { clearTimeout(this.timers.delete); this.timers.delete = false; } if (this.timers.message) { clearTimeout(this.timers.message); this.timers.message = false; } } serverRequest(endpoint, options = {}) { return this.serverAPI.serverRequest(endpoint, options); } getSubscribers() { return this.serverAPI.getSubscribers(this.channelId, true); } getModerators() { return this.serverAPI.getModerators(this.channelId); } // get moderation actions async pollForModerators() { try { await this.pollOnceForModerators(); } catch (e) { log.warn('Error while polling for public chat moderators:', e.code, e.message); } if (this.running) { this.timers.moderator = setTimeout(() => { this.pollForModerators(); }, PUBLICCHAT_MOD_POLL_EVERY); } } // get moderator status async pollOnceForModerators() { // get moderator status const res = await this.serverRequest(`loki/v1/channels/${this.channelId}/moderators`); const ourNumberDevice = window.libsession.Utils.UserUtils.getOurPubKeyStrFromCache(); // Get the list of moderators if no errors occurred const moderators = !res.err && res.response && res.response.moderators; // if we encountered problems then we'll keep the old mod status if (moderators) { this.modStatus = moderators.includes(ourNumberDevice); } if (this.running) { await this.conversation.updateGroupAdmins(moderators || []); } } async setChannelSettings(settings) { if (!this.modStatus) { // need moderator access to set this log.warn('Need moderator access to setChannelName'); return false; } // racy! const res = await this.serverRequest(this.baseChannelUrl, { params: { include_annotations: 1 }, }); if (res.err) { // state unknown log.warn(`public chat channel state unknown, skipping set: ${res.err}`); return false; } let notes = res.response && res.response.data && res.response.data.annotations; if (!notes) { // ok if nothing is set yet notes = []; } let settingNotes = notes.filter(note => note.type === SETTINGS_CHANNEL_ANNOTATION_TYPE); if (!settingNotes) { // default name, description, avatar settingNotes = [ { type: SETTINGS_CHANNEL_ANNOTATION_TYPE, value: { name: 'Your Public Chat', description: 'Your public chat room', avatar: null, }, }, ]; } // update settings settingNotes[0].value = Object.assign(settingNotes[0].value, settings); // commit settings const updateRes = await this.serverRequest(`loki/v1/${this.baseChannelUrl}`, { method: 'PUT', objBody: { annotations: settingNotes }, }); if (updateRes.err || !updateRes.response || !updateRes.response.data) { if (updateRes.err) { log.error(`setChannelSettings Error ${updateRes.err}`); } return false; } return true; } // Do we need this? They definitely make it more clear... setChannelName(name) { return this.setChannelSettings({ name }); } setChannelDescription(description) { return this.setChannelSettings({ description }); } setChannelAvatar(avatar) { return this.setChannelSettings({ avatar }); } // delete messages on the server async deleteMessages(serverIds, canThrow = false) { const res = await this.serverRequest( this.modStatus ? `loki/v1/moderation/messages` : `loki/v1/messages`, { method: 'DELETE', params: { ids: serverIds } } ); if (!res.err) { const deletedIds = res.response.data.filter(d => d.is_deleted).map(d => d.id); if (deletedIds.length > 0) { log.info(`deleted ${serverIds} on ${this.baseChannelUrl}`); } const failedIds = res.response.data.filter(d => !d.is_deleted).map(d => d.id); if (failedIds.length > 0) { log.warn(`failed to delete ${failedIds} on ${this.baseChannelUrl}`); } // Note: if there is no entry for message, we assume it wasn't found // on the server, so it is not treated as explicitly failed const ignoredIds = _.difference(serverIds, _.union(failedIds, deletedIds)); if (ignoredIds.length > 0) { log.warn(`No response for ${ignoredIds} on ${this.baseChannelUrl}`); } return { deletedIds, ignoredIds }; } if (canThrow) { throw new textsecure.PublicChatError('Failed to delete public chat message'); } return { deletedIds: [], ignoredIds: [] }; } // used for sending messages getEndpoint() { const endpoint = `${this.serverAPI.baseServerUrl}/${this.baseChannelUrl}/messages`; return endpoint; } // get moderation actions async pollForChannel() { try { await this.pollForChannelOnce(); } catch (e) { log.warn('Error while polling for public chat room details', e.code, e.message); } if (this.running) { this.timers.channel = setTimeout(() => { this.pollForChannel(); }, PUBLICCHAT_CHAN_POLL_EVERY); } } // update room details async pollForChannelOnce() { const res = await this.serverRequest(`${this.baseChannelUrl}`, { params: { include_annotations: 1, }, }); if (res.err || !res.response || !res.response.data) { if (res.statusCode === 403) { // token is now invalid this.serverAPI.getOrRefreshServerToken(true); } return; } if (!this.running) { return; } const { data } = res.response; if (data.annotations && data.annotations.length) { // get our setting note const settingNotes = data.annotations.filter( note => note.type === SETTINGS_CHANNEL_ANNOTATION_TYPE ); const note = settingNotes && settingNotes.length ? settingNotes[0] : {}; // setting_note.value.description only needed for directory if (note.value && note.value.name) { this.conversation.setGroupName(note.value.name); } if (note.value && note.value.avatar) { if (note.value.avatar.match(/^images\//)) { // local file avatar const resolvedAvatar = path.normalize(note.value.avatar); const base = path.normalize('images/'); const re = new RegExp(`^${base}`); // do we at least ends up inside images/ somewhere? if (re.test(resolvedAvatar)) { this.conversation.set('avatar', resolvedAvatar); } } else { // relative URL avatar const avatarAbsUrl = this.serverAPI.baseServerUrl + note.value.avatar; const { writeNewAttachmentData, deleteAttachmentData } = window.Signal.Migrations; // do we already have this image? no, then // download a copy and save it const imageData = await this.serverAPI.downloadAttachment(avatarAbsUrl); const newAttributes = await window.Signal.Types.Conversation.maybeUpdateAvatar( this.conversation.attributes, imageData, { writeNewAttachmentData, deleteAttachmentData, } ); // update group this.conversation.set('avatar', newAttributes.avatar); } } // is it mutable? // who are the moderators? // else could set a default in case of server problems... } if (data.counts && Number.isInteger(data.counts.subscribers)) { this.conversation.setSubscriberCount(data.counts.subscribers); } await this.conversation.commit(); } // get moderation actions async pollForDeletions() { try { await this.pollOnceForDeletions(); } catch (e) { log.warn('Error while polling for public chat deletions:', e.code, e.message); } if (this.running) { this.timers.delete = setTimeout(() => { this.pollForDeletions(); }, PUBLICCHAT_DELETION_POLL_EVERY); } } async pollOnceForDeletions() { // 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 }); // if any problems, abort out if (res.err || !res.response || !res.response.data || !res.response.meta) { if (res.statusCode === 403) { // token is now invalid this.serverAPI.getOrRefreshServerToken(true); } if (res.err) { log.error(`pollOnceForDeletions Error ${res.err}`); } else { log.error(`pollOnceForDeletions Error: Received incorrect response ${res.response}`); } break; } // Process results const entries = res.response.data || []; if (entries.length > 0) { Whisper.events.trigger('deleteLocalPublicMessages', { messageServerIds: entries.reverse().map(e => e.message_id), conversationId: this.conversationId, }); } // update where we last checked this.deleteLastId = res.response.meta.max_id; more = res.response.meta.more && res.response.data.length >= params.count && this.running; } } static getSigData(sigVer, noteValue, attachmentAnnotations, previewAnnotations, adnMessage) { let sigString = ''; sigString += adnMessage.text.trim(); sigString += noteValue.timestamp; if (noteValue.quote) { sigString += noteValue.quote.id; sigString += noteValue.quote.author; sigString += noteValue.quote.text.trim(); if (adnMessage.reply_to) { sigString += adnMessage.reply_to; } } sigString += [...attachmentAnnotations, ...previewAnnotations] .map(data => data.id || (data.image && data.image.id)) .sort() .join(''); sigString += sigVer; return dcodeIO.ByteBuffer.wrap(sigString, 'utf8').toArrayBuffer(); } 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 || typeof noteValue.sig !== 'string') { return false; } // timestamp is the only required field we've had since the first deployed version const { timestamp, quote } = noteValue; let profileKey = null; let avatar = null; const avatarNote = adnMessage.user.annotations.find( note => note.type === AVATAR_USER_ANNOTATION_TYPE ); if (avatarNote) { ({ profileKey, url: avatar } = avatarNote.value); } if (quote) { // Disable quote attachments quote.attachments = []; } // try to verify signature const { sig, sigver } = noteValue; const annoCopy = [...adnMessage.annotations]; const attachments = annoCopy .filter(anno => anno.value.lokiType === LOKI_ATTACHMENT_TYPE) .map(attachment => ({ isRaw: true, ...attachment.value })); const preview = annoCopy .filter(anno => anno.value.lokiType === LOKI_PREVIEW_TYPE) .map(LokiPublicChannelAPI.getPreviewFromAnnotation); // strip out sig and sigver annoCopy[0] = _.omit(annoCopy[0], ['value.sig', 'value.sigver']); const sigData = LokiPublicChannelAPI.getSigData( sigver, noteValue, attachments, preview, adnMessage ); const pubKeyBin = StringView.hexToArrayBuffer(adnMessage.user.username); const sigBin = StringView.hexToArrayBuffer(sig); try { await libsignal.Curve.async.verifySignature(pubKeyBin, sigData, 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; } // any error should cause problem log.error(`Unhandled message signature validation error ${e.message}`); return false; } return { timestamp, serverTimestamp: new Date(`${adnMessage.created_at}`).getTime() || timestamp, attachments, preview, quote, avatar, text: adnMessage.text, profileKey, }; } // get channel messages async pollForMessages() { try { await this.pollOnceForMessages(); } catch (e) { log.warn('Error while polling for public chat messages:', e.code, e.message); } if (this.running) { this.timers.message = setTimeout(() => { this.pollForMessages(); }, PUBLICCHAT_MSG_POLL_EVERY); } } async pollOnceForMessages() { if (this.messagesPollLock) { // TODO: check if lock is stale log.warn( 'pollOnceForModerators locked', 'on', this.channelId, 'at', this.serverAPI.baseServerUrl ); return; } // disable locking system for now as it's not quite perfect yet // this.messagesPollLock = Date.now(); const params = { include_annotations: 1, include_user_annotations: 1, // to get the home server include_deleted: false, }; if (!this.conversation) { log.warn('Trying to poll for non-existing public conversation'); this.lastMessageServerID = 0; } else if (!this.lastMessageServerID) { this.lastMessageServerID = this.conversation.getLastRetrievedMessage(); } // If lastMessageServerID is not set, it's the first pull of messages for this open group. // We just pull 100 messages (server sends the most recent ones) if (!this.lastMessageServerID || this.lastMessageServerID === 0) { params.count = 100; } else { // if lastMessageServerID is set, we pull 200 messages per 200 messages, giving the since_id parameter set to our last received message id. params.count = 200; params.since_id = this.lastMessageServerID; } // log.info(`Getting ${params.count} from ${this.lastMessageServerID} on ${this.baseChannelUrl}`); const res = await this.serverRequest(`${this.baseChannelUrl}/messages`, { params, }); if (res.err || !res.response) { if (res.statusCode === 403) { // token is now invalid this.serverAPI.getOrRefreshServerToken(true); } log.error( `app_dot_net:::pollOnceForMessages - Could not get messages from`, this.serverAPI.baseServerUrl, this.baseChannelUrl ); if (res.err) { log.error(`app_dot_net:::pollOnceForMessages - receive error`, res.err); } this.messagesPollLock = false; return; } let receivedAt = new Date().getTime(); const homeServerPubKeys = {}; let pendingMessages = []; // get our profile name const ourNumberDevice = window.libsession.Utils.UserUtils.getOurPubKeyStrFromCache(); // if no primaryDevicePubKey fall back to ourNumberDevice const ourNumberProfile = window.storage.get('primaryDevicePubKey') || ourNumberDevice; let lastProfileName = false; // the signature forces this to be async pendingMessages = await Promise.all( // process these in chronological order res.response.data.reverse().map(async adnMessage => { // still update our last received if deleted, not signed or not valid this.lastMessageServerID = !this.lastMessageServerID ? adnMessage.id : Math.max(this.lastMessageServerID, adnMessage.id); if ( !adnMessage.id || !adnMessage.user || !adnMessage.user.username || // pubKey lives in the username field !adnMessage.text || adnMessage.is_deleted ) { return false; // Invalid or delete message } const pubKey = adnMessage.user.username; try { const messengerData = await this.getMessengerData(adnMessage); if (messengerData === false) { return false; } // eslint-disable-next-line no-param-reassign adnMessage.timestamp = messengerData.timestamp; // eslint-disable-next-line no-param-reassign adnMessage.body = messengerData.text; const { timestamp, serverTimestamp, quote, attachments, preview, avatar, profileKey, } = messengerData; if (!timestamp) { return false; // Invalid message } // Duplicate check // message is one of the object of this.lastMessagesCache // testedMessage is the adnMessage object const isDuplicate = (message, testedMessage) => DataMessage.isDuplicate(message, testedMessage, testedMessage.user.username); const isThisMessageDuplicate = this.lastMessagesCache.some(m => isDuplicate(m, adnMessage) ); // Filter out any messages that we got previously if (isThisMessageDuplicate) { return false; // Duplicate message } // Add the message to the lastMessage cache and keep the last 5 recent messages this.lastMessagesCache = [ ...this.lastMessagesCache, { attributes: { source: pubKey, body: adnMessage.text, sent_at: timestamp, serverId: adnMessage.id, }, }, ].splice(-5); const from = adnMessage.user.name || 'Anonymous'; // profileName // if us if (pubKey === ourNumberProfile || pubKey === ourNumberDevice) { // update the last name we saw from ourself lastProfileName = from; } // track sources for multidevice support // sort it by home server let homeServer = window.getDefaultFileServer(); if (adnMessage.user && adnMessage.user.annotations.length) { const homeNotes = adnMessage.user.annotations.filter( note => note.type === HOMESERVER_USER_ANNOTATION_TYPE ); // FIXME: this annotation should probably be signed and verified... homeServer = homeNotes.reduce( (curVal, note) => (note.value ? note.value : curVal), homeServer ); } if (homeServerPubKeys[homeServer] === undefined) { homeServerPubKeys[homeServer] = []; } if (homeServerPubKeys[homeServer].indexOf(`@${pubKey}`) === -1) { homeServerPubKeys[homeServer].push(`@${pubKey}`); } // generate signal message object const messageData = { serverId: adnMessage.id, clientVerified: true, source: pubKey, sourceDevice: 1, timestamp, // sender timestamp serverTimestamp, // server created_at, used to order messages receivedAt, isPublic: true, message: { body: adnMessage.text === timestamp.toString() ? '' : adnMessage.text, attachments, group: { id: this.conversationId, type: textsecure.protobuf.GroupContext.Type.DELIVER, }, flags: 0, expireTimer: 0, profileKey, timestamp, received_at: receivedAt, sent_at: timestamp, // sender timestamp inner quote, contact: [], preview, profile: { displayName: from, avatar, }, }, }; receivedAt += 1; // Ensure different arrival times // now process any user meta data updates // - update their conversation with a potentially new avatar return messageData; } catch (e) { window.log.error('pollOnceForMessages: caught error:', e); return false; } }) ); // return early if we should stop processing if (!pendingMessages.length || !this.running) { this.conversation.setLastRetrievedMessage(this.lastMessageServerID); this.messagesPollLock = false; return; } // filter out invalid messages pendingMessages = pendingMessages.filter(messageData => !!messageData); // process all messages in the order received // trigger the handling of those messages sequentially // eslint-disable-next-line no-plusplus for (let index = 0; index < pendingMessages.length; index++) { if (this.running) { // log.info( // 'emitting pending public message', // pendingMessages[index].serverId, // 'on', // this.channelId, // 'at', // this.serverAPI.baseServerUrl // ); // eslint-disable-next-line no-await-in-loop window.NewReceiver.handlePublicMessage(pendingMessages[index]); } } /* eslint-enable no-param-reassign */ // if we received one of our own messages if (lastProfileName !== false) { // get current profileName const profileConvo = window.getConversationController().get(ourNumberProfile); const profileName = profileConvo.getProfileName(); // check to see if it out of sync if (profileName !== lastProfileName) { // out of sync, update this server this.serverAPI.setProfileName(profileName); } } // finally update our position this.conversation.setLastRetrievedMessage(this.lastMessageServerID); this.messagesPollLock = false; } static getPreviewFromAnnotation(annotation) { const preview = { title: annotation.value.linkPreviewTitle, url: annotation.value.linkPreviewUrl, image: { isRaw: true, caption: annotation.value.caption, contentType: annotation.value.contentType, digest: annotation.value.digest, fileName: annotation.value.fileName, flags: annotation.value.flags, height: annotation.value.height, id: annotation.value.id, key: annotation.value.key, size: annotation.value.size, thumbnail: annotation.value.thumbnail, url: annotation.value.url, width: annotation.value.width, }, }; return preview; } static getAnnotationFromPreview(preview) { const annotation = { type: MESSAGE_ATTACHMENT_TYPE, value: { // Mandatory ADN fields version: '1.0', lokiType: LOKI_PREVIEW_TYPE, // Signal stuff we actually care about linkPreviewTitle: preview.title, linkPreviewUrl: preview.url, caption: (preview.image && preview.image.caption) || undefined, contentType: (preview.image && preview.image.contentType) || undefined, digest: (preview.image && preview.image.digest) || undefined, fileName: (preview.image && preview.image.fileName) || undefined, flags: (preview.image && preview.image.flags) || undefined, height: (preview.image && preview.image.height) || undefined, id: (preview.image && preview.image.id) || undefined, key: (preview.image && preview.image.key) || undefined, size: (preview.image && preview.image.size) || undefined, thumbnail: (preview.image && preview.image.thumbnail) || undefined, url: (preview.image && preview.image.url) || undefined, width: (preview.image && preview.image.width) || undefined, }, }; return annotation; } static getAnnotationFromAttachment(attachment) { let type; if (attachment.contentType.match(/^image/)) { type = 'photo'; } else if (attachment.contentType.match(/^video/)) { type = 'video'; } else if (attachment.contentType.match(/^audio/)) { type = 'audio'; } else { type = 'other'; } const annotation = { type: MESSAGE_ATTACHMENT_TYPE, value: { // Mandatory ADN fields version: '1.0', type, lokiType: LOKI_ATTACHMENT_TYPE, // Signal stuff we actually care about ...attachment, }, }; return annotation; } // create a message in the channel async sendMessage(data, messageTimeStamp) { const { quote, attachments, preview } = data; const text = data.body || messageTimeStamp.toString(); const attachmentAnnotations = attachments.map(LokiPublicChannelAPI.getAnnotationFromAttachment); const previewAnnotations = preview.map(LokiPublicChannelAPI.getAnnotationFromPreview); const payload = { text, annotations: [ { type: 'network.loki.messenger.publicChat', value: { timestamp: messageTimeStamp, }, }, ...attachmentAnnotations, ...previewAnnotations, ], }; if (quote && quote.id) { payload.annotations[0].value.quote = quote; // copied from model/message.js copyFromQuotedMessage const collection = await Signal.Data.getMessagesBySentAt(quote.id); const found = collection.find(item => { const messageAuthor = item.getContact(); return messageAuthor && quote.author === messageAuthor.id; }); if (found) { const queryMessage = getMessageController().register(found.id, found); const replyTo = queryMessage.get('serverId'); if (replyTo) { payload.reply_to = replyTo; } } } const privKey = await this.getPrivateKey(); const sigVer = 1; const mockAdnMessage = { text }; if (payload.reply_to) { mockAdnMessage.reply_to = payload.reply_to; } const sigData = LokiPublicChannelAPI.getSigData( sigVer, payload.annotations[0].value, attachmentAnnotations.map(anno => anno.value), previewAnnotations.map(anno => anno.value), mockAdnMessage ); const sig = await libsignal.Curve.async.calculateSignature(privKey, sigData); payload.annotations[0].value.sig = StringView.arrayBufferToHex(sig); payload.annotations[0].value.sigver = sigVer; const res = await this.serverRequest(`${this.baseChannelUrl}/messages`, { method: 'POST', objBody: payload, }); if (!res.err && res.response) { return { serverId: res.response.data.id, serverTimestamp: new Date(`${res.response.data.created_at}`).getTime(), }; } if (res.err) { log.error(`POST ${this.baseChannelUrl}/messages failed`); if (res.response && res.response.meta && res.response.meta.code === 401) { log.error(`Got invalid token for ${this.serverAPI.token}`); } log.error(res.err); log.error(res.response); } else { log.warn(res.response); } return { serverId: -1, serverTimestamp: -1 }; } } LokiAppDotNetServerAPI.serverRequest = OnionSend.serverRequest; // These files are expected to be in commonjs so we can't use es6 syntax :( // If we move these to TS then we should be able to use es6 module.exports = LokiAppDotNetServerAPI;