You cannot select more than 25 topics
			Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
		
		
		
		
		
			
		
			
				
	
	
		
			2116 lines
		
	
	
		
			59 KiB
		
	
	
	
		
			JavaScript
		
	
			
		
		
	
	
			2116 lines
		
	
	
		
			59 KiB
		
	
	
	
		
			JavaScript
		
	
/* global log, textsecure, libloki, Signal, Whisper, ConversationController,
 | 
						|
clearTimeout, MessageController, libsignal, StringView, window, _,
 | 
						|
dcodeIO, Buffer, lokiSnodeAPI, TextDecoder, process */
 | 
						|
const nodeFetch = require('node-fetch');
 | 
						|
const { URL, URLSearchParams } = require('url');
 | 
						|
const FormData = require('form-data');
 | 
						|
const https = require('https');
 | 
						|
const path = require('path');
 | 
						|
 | 
						|
// 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 PUBLICCHAT_MIN_TIME_BETWEEN_DUPLICATE_MESSAGES = 10 * 1000; // 10s
 | 
						|
 | 
						|
const FILESERVER_HOSTS = [
 | 
						|
  'file-dev.lokinet.org',
 | 
						|
  'file.lokinet.org',
 | 
						|
  'file-dev.getsession.org',
 | 
						|
  'file.getsession.org',
 | 
						|
];
 | 
						|
 | 
						|
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';
 | 
						|
 | 
						|
const snodeHttpsAgent = new https.Agent({
 | 
						|
  rejectUnauthorized: false,
 | 
						|
});
 | 
						|
 | 
						|
const timeoutDelay = ms => new Promise(resolve => setTimeout(resolve, ms));
 | 
						|
 | 
						|
const sendToProxy = async (
 | 
						|
  srvPubKey,
 | 
						|
  endpoint,
 | 
						|
  pFetchOptions,
 | 
						|
  options = {}
 | 
						|
) => {
 | 
						|
  if (!srvPubKey) {
 | 
						|
    log.error(
 | 
						|
      'loki_app_dot_net:::sendToProxy - called without a server public key'
 | 
						|
    );
 | 
						|
    return {};
 | 
						|
  }
 | 
						|
 | 
						|
  const fetchOptions = pFetchOptions; // make lint happy
 | 
						|
  // safety issue with file server, just safer to have this
 | 
						|
  if (fetchOptions.headers === undefined) {
 | 
						|
    fetchOptions.headers = {};
 | 
						|
  }
 | 
						|
 | 
						|
  const payloadObj = {
 | 
						|
    body: fetchOptions.body, // might need to b64 if binary...
 | 
						|
    endpoint,
 | 
						|
    method: fetchOptions.method,
 | 
						|
    headers: fetchOptions.headers,
 | 
						|
  };
 | 
						|
 | 
						|
  // from https://github.com/sindresorhus/is-stream/blob/master/index.js
 | 
						|
  let fileUpload = false;
 | 
						|
  if (
 | 
						|
    payloadObj.body &&
 | 
						|
    typeof payloadObj.body === 'object' &&
 | 
						|
    typeof payloadObj.body.pipe === 'function'
 | 
						|
  ) {
 | 
						|
    const fData = payloadObj.body.getBuffer();
 | 
						|
    const fHeaders = payloadObj.body.getHeaders();
 | 
						|
    // update headers for boundary
 | 
						|
    payloadObj.headers = { ...payloadObj.headers, ...fHeaders };
 | 
						|
    // update body with base64 chunk
 | 
						|
    payloadObj.body = {
 | 
						|
      fileUpload: fData.toString('base64'),
 | 
						|
    };
 | 
						|
    fileUpload = true;
 | 
						|
  }
 | 
						|
 | 
						|
  // use nodes that support more than 1mb
 | 
						|
  const randomFunc = fileUpload
 | 
						|
    ? 'getRandomProxySnodeAddress'
 | 
						|
    : 'getRandomSnodeAddress';
 | 
						|
  const randSnode = await lokiSnodeAPI[randomFunc]();
 | 
						|
  if (randSnode === false) {
 | 
						|
    log.warn('proxy random snode pool is not ready, retrying 10s', endpoint);
 | 
						|
    // no nodes in the pool yet, give it some time and retry
 | 
						|
    await timeoutDelay(1000);
 | 
						|
    return sendToProxy(srvPubKey, endpoint, pFetchOptions, options);
 | 
						|
  }
 | 
						|
  const url = `https://${randSnode.ip}:${randSnode.port}/file_proxy`;
 | 
						|
 | 
						|
  // convert our payload to binary buffer
 | 
						|
  const payloadData = Buffer.from(
 | 
						|
    dcodeIO.ByteBuffer.wrap(JSON.stringify(payloadObj)).toArrayBuffer()
 | 
						|
  );
 | 
						|
  payloadObj.body = false; // free memory
 | 
						|
 | 
						|
  // make temporary key for this request/response
 | 
						|
  const ephemeralKey = libsignal.Curve.generateKeyPair();
 | 
						|
 | 
						|
  // mix server pub key with our priv key
 | 
						|
  const symKey = libsignal.Curve.calculateAgreement(
 | 
						|
    srvPubKey, // server's pubkey
 | 
						|
    ephemeralKey.privKey // our privkey
 | 
						|
  );
 | 
						|
 | 
						|
  const ivAndCiphertext = await libloki.crypto.DHEncrypt(symKey, payloadData);
 | 
						|
 | 
						|
  // convert final buffer to base64
 | 
						|
  const cipherText64 = dcodeIO.ByteBuffer.wrap(ivAndCiphertext).toString(
 | 
						|
    'base64'
 | 
						|
  );
 | 
						|
 | 
						|
  const ephemeralPubKey64 = dcodeIO.ByteBuffer.wrap(
 | 
						|
    ephemeralKey.pubKey
 | 
						|
  ).toString('base64');
 | 
						|
 | 
						|
  const finalRequestHeader = {
 | 
						|
    'X-Loki-File-Server-Ephemeral-Key': ephemeralPubKey64,
 | 
						|
  };
 | 
						|
 | 
						|
  const firstHopOptions = {
 | 
						|
    method: 'POST',
 | 
						|
    // not sure why I can't use anything but json...
 | 
						|
    // text/plain would be preferred...
 | 
						|
    body: JSON.stringify({ cipherText64 }),
 | 
						|
    headers: {
 | 
						|
      'Content-Type': 'application/json',
 | 
						|
      'X-Loki-File-Server-Target': '/loki/v1/secure_rpc',
 | 
						|
      'X-Loki-File-Server-Verb': 'POST',
 | 
						|
      'X-Loki-File-Server-Headers': JSON.stringify(finalRequestHeader),
 | 
						|
    },
 | 
						|
    // we are talking to a snode...
 | 
						|
    agent: snodeHttpsAgent,
 | 
						|
  };
 | 
						|
  // weird this doesn't need NODE_TLS_REJECT_UNAUTHORIZED = '0'
 | 
						|
  const result = await nodeFetch(url, firstHopOptions);
 | 
						|
 | 
						|
  const txtResponse = await result.text();
 | 
						|
  if (txtResponse.match(/^Service node is not ready: not in any swarm/i)) {
 | 
						|
    // mark snode bad
 | 
						|
    const randomPoolRemainingCount = lokiSnodeAPI.markRandomNodeUnreachable(
 | 
						|
      randSnode
 | 
						|
    );
 | 
						|
    log.warn(
 | 
						|
      `loki_app_dot_net:::sendToProxy - Marking random snode bad, internet address ${
 | 
						|
        randSnode.ip
 | 
						|
      }:${
 | 
						|
        randSnode.port
 | 
						|
      }. ${randomPoolRemainingCount} snodes remaining in randomPool`
 | 
						|
    );
 | 
						|
    // retry (hopefully with new snode)
 | 
						|
    // FIXME: max number of retries...
 | 
						|
    return sendToProxy(srvPubKey, endpoint, fetchOptions, options);
 | 
						|
  }
 | 
						|
 | 
						|
  let response = {};
 | 
						|
  try {
 | 
						|
    response = JSON.parse(txtResponse);
 | 
						|
  } catch (e) {
 | 
						|
    log.warn(
 | 
						|
      `loki_app_dot_net:::sendToProxy - Could not parse outer JSON [${txtResponse}]`,
 | 
						|
      endpoint,
 | 
						|
      'on',
 | 
						|
      url
 | 
						|
    );
 | 
						|
  }
 | 
						|
 | 
						|
  if (response.meta && response.meta.code === 200) {
 | 
						|
    // convert base64 in response to binary
 | 
						|
    const ivAndCiphertextResponse = dcodeIO.ByteBuffer.wrap(
 | 
						|
      response.data,
 | 
						|
      'base64'
 | 
						|
    ).toArrayBuffer();
 | 
						|
    const decrypted = await libloki.crypto.DHDecrypt(
 | 
						|
      symKey,
 | 
						|
      ivAndCiphertextResponse
 | 
						|
    );
 | 
						|
    const textDecoder = new TextDecoder();
 | 
						|
    const respStr = textDecoder.decode(decrypted);
 | 
						|
    // replace response
 | 
						|
    try {
 | 
						|
      response = options.textResponse ? respStr : JSON.parse(respStr);
 | 
						|
    } catch (e) {
 | 
						|
      log.warn(
 | 
						|
        `loki_app_dot_net:::sendToProxy - Could not parse inner JSON [${respStr}]`,
 | 
						|
        endpoint,
 | 
						|
        'on',
 | 
						|
        url
 | 
						|
      );
 | 
						|
    }
 | 
						|
  } else {
 | 
						|
    log.warn(
 | 
						|
      'loki_app_dot_net:::sendToProxy - file server secure_rpc gave an non-200 response: ',
 | 
						|
      response,
 | 
						|
      ` txtResponse[${txtResponse}]`,
 | 
						|
      endpoint
 | 
						|
    );
 | 
						|
  }
 | 
						|
  return { result, txtResponse, response };
 | 
						|
};
 | 
						|
 | 
						|
const serverRequest = async (endpoint, options = {}) => {
 | 
						|
  const {
 | 
						|
    params = {},
 | 
						|
    method,
 | 
						|
    rawBody,
 | 
						|
    objBody,
 | 
						|
    token,
 | 
						|
    srvPubKey,
 | 
						|
    forceFreshToken = false,
 | 
						|
  } = options;
 | 
						|
 | 
						|
  const url = new URL(endpoint);
 | 
						|
  if (params) {
 | 
						|
    url.search = new URLSearchParams(params);
 | 
						|
  }
 | 
						|
  const fetchOptions = {};
 | 
						|
  const headers = {};
 | 
						|
  try {
 | 
						|
    if (token) {
 | 
						|
      headers.Authorization = `Bearer ${token}`;
 | 
						|
    }
 | 
						|
    if (method) {
 | 
						|
      fetchOptions.method = method;
 | 
						|
    }
 | 
						|
    if (objBody) {
 | 
						|
      headers['Content-Type'] = 'application/json';
 | 
						|
      fetchOptions.body = JSON.stringify(objBody);
 | 
						|
    } else if (rawBody) {
 | 
						|
      fetchOptions.body = rawBody;
 | 
						|
    }
 | 
						|
    fetchOptions.headers = headers;
 | 
						|
 | 
						|
    // domain ends in .loki
 | 
						|
    if (url.host.match(/\.loki$/i)) {
 | 
						|
      fetchOptions.agent = snodeHttpsAgent;
 | 
						|
    }
 | 
						|
  } catch (e) {
 | 
						|
    log.error(
 | 
						|
      'loki_app_dot_net:::serverRequest - set up error:',
 | 
						|
      e.code,
 | 
						|
      e.message
 | 
						|
    );
 | 
						|
    return {
 | 
						|
      err: e,
 | 
						|
    };
 | 
						|
  }
 | 
						|
 | 
						|
  let response;
 | 
						|
  let result;
 | 
						|
  let txtResponse;
 | 
						|
  let mode = 'nodeFetch';
 | 
						|
  try {
 | 
						|
    const host = url.host.toLowerCase();
 | 
						|
    // log.info('host', host, FILESERVER_HOSTS);
 | 
						|
    if (
 | 
						|
      window.lokiFeatureFlags.useSnodeProxy &&
 | 
						|
      FILESERVER_HOSTS.includes(host)
 | 
						|
    ) {
 | 
						|
      mode = 'sendToProxy';
 | 
						|
      // url.search automatically includes the ? part
 | 
						|
      const search = url.search || '';
 | 
						|
      // strip first slash
 | 
						|
      const endpointWithQS = `${url.pathname}${search}`.replace(/^\//, '');
 | 
						|
      ({ response, txtResponse, result } = await sendToProxy(
 | 
						|
        srvPubKey,
 | 
						|
        endpointWithQS,
 | 
						|
        fetchOptions,
 | 
						|
        options
 | 
						|
      ));
 | 
						|
    } else {
 | 
						|
      // disable check for .loki
 | 
						|
      process.env.NODE_TLS_REJECT_UNAUTHORIZED = host.match(/\.loki$/i)
 | 
						|
        ? '0'
 | 
						|
        : '1';
 | 
						|
      result = await nodeFetch(url, fetchOptions);
 | 
						|
      // always make sure this check is enabled
 | 
						|
      process.env.NODE_TLS_REJECT_UNAUTHORIZED = '1';
 | 
						|
      txtResponse = await result.text();
 | 
						|
      // cloudflare timeouts (504s) will be html...
 | 
						|
      response = options.textResponse ? txtResponse : JSON.parse(txtResponse);
 | 
						|
      // result.status will always be 200
 | 
						|
      // emulate the correct http code if available
 | 
						|
      if (response && response.meta && response.meta.code) {
 | 
						|
        result.status = response.meta.code;
 | 
						|
      }
 | 
						|
    }
 | 
						|
  } catch (e) {
 | 
						|
    if (txtResponse) {
 | 
						|
      log.error(
 | 
						|
        `loki_app_dot_net:::serverRequest - ${mode} error`,
 | 
						|
        e.code,
 | 
						|
        e.message,
 | 
						|
        `json: ${txtResponse}`,
 | 
						|
        'attempting connection to',
 | 
						|
        url
 | 
						|
      );
 | 
						|
    } else {
 | 
						|
      log.error(
 | 
						|
        `loki_app_dot_net:::serverRequest - ${mode} error`,
 | 
						|
        e.code,
 | 
						|
        e.message,
 | 
						|
        'attempting connection to',
 | 
						|
        url
 | 
						|
      );
 | 
						|
    }
 | 
						|
    if (mode === 'sendToProxy') {
 | 
						|
      // if we can detect, certain types of failures, we can retry...
 | 
						|
      if (e.code === 'ECONNRESET') {
 | 
						|
        // retry with counter?
 | 
						|
      }
 | 
						|
    }
 | 
						|
    return {
 | 
						|
      err: e,
 | 
						|
    };
 | 
						|
  }
 | 
						|
  // if it's a response style with a meta
 | 
						|
  if (result.status !== 200) {
 | 
						|
    if (!forceFreshToken && (!response.meta || response.meta.code === 401)) {
 | 
						|
      // retry with forcing a fresh token
 | 
						|
      return serverRequest(endpoint, {
 | 
						|
        ...options,
 | 
						|
        forceFreshToken: true,
 | 
						|
      });
 | 
						|
    }
 | 
						|
    return {
 | 
						|
      err: 'statusCode',
 | 
						|
      statusCode: result.status,
 | 
						|
      response,
 | 
						|
    };
 | 
						|
  }
 | 
						|
  return {
 | 
						|
    statusCode: result.status,
 | 
						|
    response,
 | 
						|
  };
 | 
						|
};
 | 
						|
 | 
						|
// 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);
 | 
						|
  }
 | 
						|
 | 
						|
  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.storage.get('primaryDevicePubKey') ||
 | 
						|
        textsecure.storage.user.getNumber();
 | 
						|
      const profileConvo = ConversationController.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 url = new URL(`${this.baseServerUrl}/loki/v1/get_challenge`);
 | 
						|
      const params = {
 | 
						|
        pubKey: this.ourKey,
 | 
						|
      };
 | 
						|
      url.search = new URLSearchParams(params);
 | 
						|
 | 
						|
      res = await this.proxyFetch(url);
 | 
						|
    } 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 = await res.json();
 | 
						|
    const token = await libloki.crypto.decryptToken(body);
 | 
						|
    return token;
 | 
						|
  }
 | 
						|
 | 
						|
  // activate token
 | 
						|
  async submitToken(token) {
 | 
						|
    const fetchOptions = {
 | 
						|
      method: 'POST',
 | 
						|
      headers: {
 | 
						|
        'Content-Type': 'application/json',
 | 
						|
      },
 | 
						|
      body: JSON.stringify({
 | 
						|
        pubKey: this.ourKey,
 | 
						|
        token,
 | 
						|
      }),
 | 
						|
    };
 | 
						|
 | 
						|
    try {
 | 
						|
      const res = await this.proxyFetch(
 | 
						|
        new URL(`${this.baseServerUrl}/loki/v1/submit_challenge`),
 | 
						|
        fetchOptions,
 | 
						|
        { textResponse: true }
 | 
						|
      );
 | 
						|
      return res.ok;
 | 
						|
    } catch (e) {
 | 
						|
      log.error('submitToken proxyFetch failure', e.code, e.message);
 | 
						|
      return false;
 | 
						|
    }
 | 
						|
  }
 | 
						|
 | 
						|
  async proxyFetch(urlObj, fetchOptions = { method: 'GET' }, options = {}) {
 | 
						|
    if (
 | 
						|
      window.lokiFeatureFlags.useSnodeProxy &&
 | 
						|
      (this.baseServerUrl === 'https://file-dev.lokinet.org' ||
 | 
						|
        this.baseServerUrl === 'https://file.lokinet.org' ||
 | 
						|
        this.baseServerUrl === 'https://file-dev.getsession.org' ||
 | 
						|
        this.baseServerUrl === 'https://file.getsession.org')
 | 
						|
    ) {
 | 
						|
      const finalOptions = { ...fetchOptions };
 | 
						|
      if (!fetchOptions.method) {
 | 
						|
        finalOptions.method = 'GET';
 | 
						|
      }
 | 
						|
      const urlStr = urlObj.toString();
 | 
						|
      const endpoint = urlStr.replace(`${this.baseServerUrl}/`, '');
 | 
						|
      const { response, result } = await sendToProxy(
 | 
						|
        this.pubKey,
 | 
						|
        endpoint,
 | 
						|
        finalOptions,
 | 
						|
        options
 | 
						|
      );
 | 
						|
      // emulate nodeFetch response...
 | 
						|
      return {
 | 
						|
        ok: result.status === 200,
 | 
						|
        json: () => response,
 | 
						|
      };
 | 
						|
    }
 | 
						|
    const host = urlObj.host.toLowerCase();
 | 
						|
    if (host.match(/\.loki$/)) {
 | 
						|
      process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
 | 
						|
    }
 | 
						|
    const result = nodeFetch(urlObj, fetchOptions, options);
 | 
						|
    process.env.NODE_TLS_REJECT_UNAUTHORIZED = '1';
 | 
						|
    return result;
 | 
						|
  }
 | 
						|
 | 
						|
  // make a request to the server
 | 
						|
  async serverRequest(endpoint, options = {}) {
 | 
						|
    if (options.forceFreshToken) {
 | 
						|
      await this.getOrRefreshServerToken(true);
 | 
						|
    }
 | 
						|
    return 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 addModerators(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 => {
 | 
						|
        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);
 | 
						|
    return anyFailures ? results : true; // return failures or total success
 | 
						|
  }
 | 
						|
 | 
						|
  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);
 | 
						|
    return anyFailures ? results : true; // return failures or total success
 | 
						|
  }
 | 
						|
 | 
						|
  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(`getUsers Error ${res.err}`);
 | 
						|
      }
 | 
						|
      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 { statusCode, response } = await this.serverRequest(
 | 
						|
      endpoint,
 | 
						|
      options
 | 
						|
    );
 | 
						|
 | 
						|
    if (statusCode !== 200) {
 | 
						|
      log.warn('Failed to upload avatar to fileserver');
 | 
						|
      return null;
 | 
						|
    }
 | 
						|
 | 
						|
    const url =
 | 
						|
      response.data &&
 | 
						|
      response.data.avatar_image &&
 | 
						|
      response.data.avatar_image.url;
 | 
						|
 | 
						|
    // We don't use the server id for avatars
 | 
						|
    return {
 | 
						|
      url,
 | 
						|
      id: null,
 | 
						|
    };
 | 
						|
  }
 | 
						|
 | 
						|
  // for avatar
 | 
						|
  async uploadData(data) {
 | 
						|
    const endpoint = 'files';
 | 
						|
    const options = {
 | 
						|
      method: 'POST',
 | 
						|
      rawBody: data,
 | 
						|
    };
 | 
						|
 | 
						|
    const { statusCode, response } = await this.serverRequest(
 | 
						|
      endpoint,
 | 
						|
      options
 | 
						|
    );
 | 
						|
    if (statusCode !== 200) {
 | 
						|
      log.warn('Failed to upload data to server', this.baseServerUrl);
 | 
						|
      return null;
 | 
						|
    }
 | 
						|
 | 
						|
    const url = response.data && response.data.url;
 | 
						|
    const id = response.data && response.data.id;
 | 
						|
    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);
 | 
						|
  }
 | 
						|
}
 | 
						|
 | 
						|
// functions to a specific ADN channel on an ADN server
 | 
						|
class LokiPublicChannelAPI {
 | 
						|
  constructor(chatAPI, serverAPI, channelId, conversationId) {
 | 
						|
    // properties
 | 
						|
    this.chatAPI = chatAPI;
 | 
						|
    this.serverAPI = serverAPI;
 | 
						|
    this.channelId = channelId;
 | 
						|
    this.baseChannelUrl = `channels/${this.channelId}`;
 | 
						|
    this.conversationId = conversationId;
 | 
						|
    this.conversation = ConversationController.get(conversationId);
 | 
						|
    this.lastGot = 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 myKeyPair = await textsecure.storage.protocol.getIdentityKeyPair();
 | 
						|
      this.myPrivateKey = myKeyPair.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 = textsecure.storage.user.getNumber();
 | 
						|
    const ourNumberProfile = window.storage.get('primaryDevicePubKey');
 | 
						|
 | 
						|
    // 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 =
 | 
						|
        (ourNumberProfile && moderators.includes(ourNumberProfile)) ||
 | 
						|
        moderators.includes(ourNumberDevice);
 | 
						|
    }
 | 
						|
 | 
						|
    await this.conversation.setModerators(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: 'images/group_default.png',
 | 
						|
          },
 | 
						|
        },
 | 
						|
      ];
 | 
						|
    }
 | 
						|
    // 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) {
 | 
						|
      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 nodeFetch(avatarAbsUrl);
 | 
						|
          // eslint-disable-next-line no-inner-declarations
 | 
						|
          function toArrayBuffer(buf) {
 | 
						|
            const ab = new ArrayBuffer(buf.length);
 | 
						|
            const view = new Uint8Array(ab);
 | 
						|
            // eslint-disable-next-line no-plusplus
 | 
						|
            for (let i = 0; i < buf.length; i++) {
 | 
						|
              view[i] = buf[i];
 | 
						|
            }
 | 
						|
            return ab;
 | 
						|
          }
 | 
						|
          // eslint-enable-next-line no-inner-declarations
 | 
						|
 | 
						|
          const buffer = await imageData.buffer();
 | 
						|
          const newAttributes = await window.Signal.Types.Conversation.maybeUpdateAvatar(
 | 
						|
            this.conversation.attributes,
 | 
						|
            toArrayBuffer(buffer),
 | 
						|
            {
 | 
						|
              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 window.Signal.Data.updateConversation(
 | 
						|
      this.conversation.id,
 | 
						|
      this.conversation.attributes,
 | 
						|
      {
 | 
						|
        Conversation: Whisper.Conversation,
 | 
						|
      }
 | 
						|
    );
 | 
						|
  }
 | 
						|
 | 
						|
  // 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) {
 | 
						|
        if (res.err) {
 | 
						|
          log.error(`pollOnceForDeletions Error ${res.err}`);
 | 
						|
        }
 | 
						|
        break;
 | 
						|
      }
 | 
						|
 | 
						|
      // 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,
 | 
						|
        });
 | 
						|
      });
 | 
						|
 | 
						|
      // update where we last checked
 | 
						|
      this.deleteLastId = res.response.meta.max_id;
 | 
						|
      more = res.response.meta.more && res.response.data.length >= params.count;
 | 
						|
    }
 | 
						|
  }
 | 
						|
 | 
						|
  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.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,
 | 
						|
      attachments,
 | 
						|
      preview,
 | 
						|
      quote,
 | 
						|
      avatar,
 | 
						|
      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.lastGot = 0;
 | 
						|
    } else if (!this.lastGot) {
 | 
						|
      this.lastGot = this.conversation.getLastRetrievedMessage();
 | 
						|
    }
 | 
						|
    params.since_id = this.lastGot;
 | 
						|
    // Just grab the most recent 100 messages if you don't have a valid lastGot
 | 
						|
    params.count = this.lastGot === 0 ? -100 : 20;
 | 
						|
    // log.info(`Getting ${params.count} from ${this.lastGot} on ${this.baseChannelUrl}`);
 | 
						|
    const res = await this.serverRequest(`${this.baseChannelUrl}/messages`, {
 | 
						|
      params,
 | 
						|
    });
 | 
						|
 | 
						|
    if (res.err || !res.response) {
 | 
						|
      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 = textsecure.storage.user.getNumber();
 | 
						|
    // 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.lastGot = !this.lastGot
 | 
						|
          ? adnMessage.id
 | 
						|
          : Math.max(this.lastGot, 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;
 | 
						|
 | 
						|
        const messengerData = await this.getMessengerData(adnMessage);
 | 
						|
        if (messengerData === false) {
 | 
						|
          return false;
 | 
						|
        }
 | 
						|
 | 
						|
        const {
 | 
						|
          timestamp,
 | 
						|
          quote,
 | 
						|
          attachments,
 | 
						|
          preview,
 | 
						|
          avatar,
 | 
						|
          profileKey,
 | 
						|
        } = messengerData;
 | 
						|
        if (!timestamp) {
 | 
						|
          return false; // Invalid message
 | 
						|
        }
 | 
						|
 | 
						|
        // Duplicate check
 | 
						|
        const isDuplicate = message => {
 | 
						|
          // The username in this case is the users pubKey
 | 
						|
          const sameUsername = message.username === pubKey;
 | 
						|
          const sameText = message.text === adnMessage.text;
 | 
						|
          // Don't filter out messages that are too far apart from each other
 | 
						|
          const timestampsSimilar =
 | 
						|
            Math.abs(message.timestamp - timestamp) <=
 | 
						|
            PUBLICCHAT_MIN_TIME_BETWEEN_DUPLICATE_MESSAGES;
 | 
						|
 | 
						|
          return sameUsername && sameText && timestampsSimilar;
 | 
						|
        };
 | 
						|
 | 
						|
        // Filter out any messages that we got previously
 | 
						|
        if (this.lastMessagesCache.some(isDuplicate)) {
 | 
						|
          return false; // Duplicate message
 | 
						|
        }
 | 
						|
 | 
						|
        // FIXME: maybe move after the de-multidev-decode
 | 
						|
        // Add the message to the lastMessage cache and keep the last 5 recent messages
 | 
						|
        this.lastMessagesCache = [
 | 
						|
          ...this.lastMessagesCache,
 | 
						|
          {
 | 
						|
            username: pubKey,
 | 
						|
            text: adnMessage.text,
 | 
						|
            timestamp,
 | 
						|
          },
 | 
						|
        ].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,
 | 
						|
          friendRequest: false,
 | 
						|
          source: pubKey,
 | 
						|
          sourceDevice: 1,
 | 
						|
          timestamp,
 | 
						|
 | 
						|
          serverTimestamp: timestamp,
 | 
						|
          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,
 | 
						|
            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;
 | 
						|
      })
 | 
						|
    );
 | 
						|
 | 
						|
    // do we really need this?
 | 
						|
    if (!pendingMessages.length) {
 | 
						|
      this.conversation.setLastRetrievedMessage(this.lastGot);
 | 
						|
      this.messagesPollLock = false;
 | 
						|
      return;
 | 
						|
    }
 | 
						|
 | 
						|
    // slave to primary map for this group of messages
 | 
						|
    let slavePrimaryMap = {};
 | 
						|
 | 
						|
    // reduce list of servers into verified maps and keys
 | 
						|
    const verifiedPrimaryPKs = await Object.keys(homeServerPubKeys).reduce(
 | 
						|
      async (curVal, serverUrl) => {
 | 
						|
        // get an API to this server
 | 
						|
        const serverAPI = await window.lokiFileServerAPIFactory.establishConnection(
 | 
						|
          serverUrl
 | 
						|
        );
 | 
						|
 | 
						|
        // get list of verified primary PKs
 | 
						|
        const result = await serverAPI.verifyPrimaryPubKeys(
 | 
						|
          homeServerPubKeys[serverUrl]
 | 
						|
        );
 | 
						|
 | 
						|
        // merged these device mappings into our slavePrimaryMap
 | 
						|
        // should not be any collisions, since each pubKey can only have one home server
 | 
						|
        slavePrimaryMap = { ...slavePrimaryMap, ...result.slaveMap };
 | 
						|
 | 
						|
        // copy verified pub keys into result
 | 
						|
        return curVal.concat(result.verifiedPrimaryPKs);
 | 
						|
      },
 | 
						|
      []
 | 
						|
    );
 | 
						|
 | 
						|
    // filter out invalid messages
 | 
						|
    pendingMessages = pendingMessages.filter(messageData => !!messageData);
 | 
						|
    // separate messages coming from primary and secondary devices
 | 
						|
    let [primaryMessages, slaveMessages] = _.partition(
 | 
						|
      pendingMessages,
 | 
						|
      message => !(message.source in slavePrimaryMap)
 | 
						|
    );
 | 
						|
    // get minimum ID for primaryMessages and slaveMessages
 | 
						|
    const firstPrimaryId = _.min(primaryMessages.map(msg => msg.serverId));
 | 
						|
    const firstSlaveId = _.min(slaveMessages.map(msg => msg.serverId));
 | 
						|
    if (firstPrimaryId < firstSlaveId) {
 | 
						|
      // early send
 | 
						|
      // split off count from pendingMessages
 | 
						|
      let sendNow = [];
 | 
						|
      [sendNow, pendingMessages] = _.partition(
 | 
						|
        pendingMessages,
 | 
						|
        message => message.serverId < firstSlaveId
 | 
						|
      );
 | 
						|
      sendNow.forEach(message => {
 | 
						|
        // send them out now
 | 
						|
        log.info(
 | 
						|
          'emitting primary message',
 | 
						|
          message.serverId,
 | 
						|
          'on',
 | 
						|
          this.channelId,
 | 
						|
          'at',
 | 
						|
          this.serverAPI.baseServerUrl
 | 
						|
        );
 | 
						|
        this.chatAPI.emit('publicMessage', {
 | 
						|
          message,
 | 
						|
        });
 | 
						|
      });
 | 
						|
      sendNow = false;
 | 
						|
    }
 | 
						|
    primaryMessages = false; // free memory
 | 
						|
 | 
						|
    // get actual chat server data (mainly the name rn) of primary device
 | 
						|
    const verifiedDeviceResults = await this.serverAPI.getUsers(
 | 
						|
      verifiedPrimaryPKs
 | 
						|
    );
 | 
						|
 | 
						|
    // build map of userProfileName to primaryKeys
 | 
						|
    /* eslint-disable no-param-reassign */
 | 
						|
    this.primaryUserProfileName = verifiedDeviceResults.reduce(
 | 
						|
      (mapOut, user) => {
 | 
						|
        let avatar = null;
 | 
						|
        let profileKey = null;
 | 
						|
        const avatarNote = user.annotations.find(
 | 
						|
          note => note.type === AVATAR_USER_ANNOTATION_TYPE
 | 
						|
        );
 | 
						|
        if (avatarNote) {
 | 
						|
          ({ profileKey, url: avatar } = avatarNote.value);
 | 
						|
        }
 | 
						|
        mapOut[user.username] = {
 | 
						|
          name: user.name,
 | 
						|
          avatar,
 | 
						|
          profileKey,
 | 
						|
        };
 | 
						|
        return mapOut;
 | 
						|
      },
 | 
						|
      {}
 | 
						|
    );
 | 
						|
    /* eslint-enable no-param-reassign */
 | 
						|
 | 
						|
    // process remaining messages
 | 
						|
    /* eslint-disable no-param-reassign */
 | 
						|
    slaveMessages.forEach(messageData => {
 | 
						|
      const slaveKey = messageData.source;
 | 
						|
 | 
						|
      // prevent our own device sent messages from coming back in
 | 
						|
      if (slaveKey === ourNumberDevice) {
 | 
						|
        // we originally sent these
 | 
						|
        return;
 | 
						|
      }
 | 
						|
 | 
						|
      // look up primary device once
 | 
						|
      const primaryPubKey = slavePrimaryMap[slaveKey];
 | 
						|
 | 
						|
      // send out remaining messages for this merged identity
 | 
						|
      /* eslint-disable no-param-reassign */
 | 
						|
      if (slavePrimaryMap[slaveKey]) {
 | 
						|
        // rewrite source, profile
 | 
						|
        messageData.source = primaryPubKey;
 | 
						|
        const primaryProfile = this.primaryUserProfileName[primaryPubKey];
 | 
						|
        if (primaryProfile) {
 | 
						|
          const { name, avatar, profileKey } = primaryProfile;
 | 
						|
          messageData.message.profile.displayName = name;
 | 
						|
          messageData.message.profile.avatar = avatar;
 | 
						|
          messageData.message.profileKey = profileKey;
 | 
						|
        }
 | 
						|
      }
 | 
						|
    });
 | 
						|
    slaveMessages = false; // free memory
 | 
						|
 | 
						|
    // process all messages in the order received
 | 
						|
    pendingMessages.forEach(message => {
 | 
						|
      // if slave device
 | 
						|
      if (message.source in slavePrimaryMap) {
 | 
						|
        // prevent our own device sent messages from coming back in
 | 
						|
        if (message.source === ourNumberDevice) {
 | 
						|
          // we originally sent these
 | 
						|
          return;
 | 
						|
        }
 | 
						|
      }
 | 
						|
      log.info(
 | 
						|
        'emitting pending message',
 | 
						|
        message.serverId,
 | 
						|
        'on',
 | 
						|
        this.channelId,
 | 
						|
        'at',
 | 
						|
        this.serverAPI.baseServerUrl
 | 
						|
      );
 | 
						|
      this.chatAPI.emit('publicMessage', {
 | 
						|
        message,
 | 
						|
      });
 | 
						|
    });
 | 
						|
 | 
						|
    /* eslint-enable no-param-reassign */
 | 
						|
 | 
						|
    // if we received one of our own messages
 | 
						|
    if (lastProfileName !== false) {
 | 
						|
      // get current profileName
 | 
						|
      const profileConvo = ConversationController.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.lastGot);
 | 
						|
    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.caption,
 | 
						|
        contentType: preview.image.contentType,
 | 
						|
        digest: preview.image.digest,
 | 
						|
        fileName: preview.image.fileName,
 | 
						|
        flags: preview.image.flags,
 | 
						|
        height: preview.image.height,
 | 
						|
        id: preview.image.id,
 | 
						|
        key: preview.image.key,
 | 
						|
        size: preview.image.size,
 | 
						|
        thumbnail: preview.image.thumbnail,
 | 
						|
        url: preview.image.url,
 | 
						|
        width: preview.image.width,
 | 
						|
      },
 | 
						|
    };
 | 
						|
    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, {
 | 
						|
        MessageCollection: Whisper.MessageCollection,
 | 
						|
      });
 | 
						|
      const found = collection.find(item => {
 | 
						|
        const messageAuthor = item.getContact();
 | 
						|
 | 
						|
        return messageAuthor && quote.author === messageAuthor.id;
 | 
						|
      });
 | 
						|
 | 
						|
      if (found) {
 | 
						|
        const queryMessage = MessageController.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 res.response.data.id;
 | 
						|
    }
 | 
						|
    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);
 | 
						|
    }
 | 
						|
    // there's no retry on desktop
 | 
						|
    // this is supposed to be after retries
 | 
						|
    return false;
 | 
						|
  }
 | 
						|
}
 | 
						|
 | 
						|
module.exports = LokiAppDotNetServerAPI;
 |