/ * g l o b a l l o g , t e x t s e c u r e , l i b l o k i , S i g n a l , W h i s p e r , C o n v e r s a t i o n C o n t r o l l e r ,
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
// FIXME: replace with something on urlPubkeyMap...
const FILESERVER _HOSTS = [
'file-dev.lokinet.org' ,
'file.lokinet.org' ,
'file-dev.getsession.org' ,
'file.getsession.org' ,
] ;
const LOKIFOUNDATION _DEVFILESERVER _PUBKEY =
'BSZiMVxOco/b3sYfaeyiMWv/JnqokxGXkHoclEx8TmZ6' ;
const LOKIFOUNDATION _FILESERVER _PUBKEY =
'BWJQnVm97sQE3Q1InB4Vuo+U/T1hmwHBv0ipkiv8tzEc' ;
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 ,
} ;
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 sendViaOnion = async ( srvPubKey , url , fetchOptions , options = { } ) => {
if ( ! srvPubKey ) {
log . error (
'loki_app_dot_net:::sendViaOnion - called without a server public key'
) ;
return { } ;
}
// set retry count
if ( options . retry === undefined ) {
// eslint-disable-next-line no-param-reassign
options . retry = 0 ;
// eslint-disable-next-line no-param-reassign
options . requestNumber = window . lokiSnodeAPI . assignOnionRequestNumber ( ) ;
}
const payloadObj = {
method : fetchOptions . method || 'GET' ,
body : fetchOptions . body || '' ,
// safety issue with file server, just safer to have this
headers : fetchOptions . headers || { } ,
// no initial /
endpoint : url . pathname . replace ( /^\// , '' ) ,
} ;
if ( url . search ) {
payloadObj . endpoint += url . search ;
}
// from https://github.com/sindresorhus/is-stream/blob/master/index.js
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' ) ,
} ;
}
let pathNodes = [ ] ;
try {
pathNodes = await lokiSnodeAPI . getOnionPath ( ) ;
} catch ( e ) {
log . error (
` loki_app_dot_net:::sendViaOnion # ${ options . requestNumber } - getOnionPath Error ${ e . code } ${ e . message } `
) ;
}
if ( ! pathNodes || ! pathNodes . length ) {
log . warn (
` loki_app_dot_net:::sendViaOnion # ${ options . requestNumber } - failing, no path available `
) ;
// should we retry?
return { } ;
}
// do the request
let result ;
try {
result = await window . NewSnodeAPI . sendOnionRequestLsrpcDest (
0 ,
pathNodes ,
srvPubKey ,
url . host ,
payloadObj ,
options . requestNumber
) ;
} catch ( e ) {
log . error (
'loki_app_dot_net:::sendViaOnion - lokiRpcUtils error' ,
e . code ,
e . message
) ;
return { } ;
}
// handle error/retries
if ( ! result . status ) {
log . error (
` loki_app_dot_net:::sendViaOnion # ${ options . requestNumber } - Retry # ${ options . retry } Couldnt handle onion request, retrying ` ,
payloadObj
) ;
return sendViaOnion ( srvPubKey , url , fetchOptions , {
... options ,
retry : options . retry + 1 ,
counter : options . requestNumber ,
} ) ;
}
if ( options . noJson ) {
return {
result ,
txtResponse : result . body ,
response : {
data : result . body ,
headers : result . headers ,
} ,
} ;
}
// get the return variables we need
let response = { } ;
let txtResponse = '' ;
let { body } = result ;
if ( typeof body === 'string' ) {
// adn does uses this path
// log.info(`loki_app_dot_net:::sendViaOnion - got text response ${url.toString()}`);
txtResponse = result . body ;
try {
body = JSON . parse ( result . body ) ;
} catch ( e ) {
log . error (
` loki_app_dot_net:::sendViaOnion # ${ options . requestNumber } - Cant decode JSON body ` ,
typeof result . body ,
result . body
) ;
}
} else {
// why is
// https://chat-dev.lokinet.org/loki/v1/channel/1/deletes?count=200&since_id=
// difference in response than all the other calls....
log . info (
` loki_app_dot_net:::sendViaOnion # ${
options . requestNumber
} - got object response $ { url . toString ( ) } `
) ;
}
// result.status has the http response code
if ( ! txtResponse ) {
txtResponse = JSON . stringify ( body ) ;
}
response = body ;
response . headers = result . headers ;
return { result , txtResponse , response } ;
} ;
const sendToProxy = async ( srvPubKey , endpoint , fetchOptions , options = { } ) => {
if ( ! srvPubKey ) {
log . error (
'loki_app_dot_net:::sendToProxy - called without a server public key'
) ;
return { } ;
}
const payloadObj = {
body : fetchOptions . body , // might need to b64 if binary...
endpoint ,
method : fetchOptions . method ,
// safety issue with file server, just safer to have this
headers : fetchOptions . headers || { } ,
} ;
// from https://github.com/sindresorhus/is-stream/blob/master/index.js
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' ) ,
} ;
}
const randSnode = await window . SnodePool . getRandomSnodeAddress ( ) ;
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 , fetchOptions , 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
// async maybe preferable to avoid cpu spikes
// but I think sync might be more apt in cases like sending...
const ephemeralKey = await libloki . crypto . generateEphemeralKeyPair ( ) ;
// mix server pub key with our priv key
const symKey = await libsignal . Curve . async . 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 = window . SnodePool . markNodeUnreachable (
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 . useFileOnionRequests &&
FILESERVER _HOSTS . includes ( host )
) {
mode = 'sendViaOnion' ;
( { response , txtResponse , result } = await sendViaOnion (
srvPubKey ,
url ,
fetchOptions ,
options
) ) ;
} else if ( window . lokiFeatureFlags . useFileOnionRequests && srvPubKey ) {
mode = 'sendViaOnionOG' ;
( { response , txtResponse , result } = await sendViaOnion (
srvPubKey ,
url ,
fetchOptions ,
options
) ) ;
} else 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 ( ! result ) {
return {
err : 'noResult' ,
response ,
} ;
}
// 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 ) ;
}
// set up pubKey & pubKeyHex properties
// optionally called for mainly file server comms
getPubKeyForUrl ( ) {
if (
! window . lokiFeatureFlags . useSnodeProxy &&
! 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 if ( window . lokiFeatureFlags . useSnodeProxy ) {
// if in proxy mode, replace with "file."...
// it only supports this host...
pubKeyAB = window . Signal . Crypto . base64ToArrayBuffer (
LOKIFOUNDATION _FILESERVER _PUBKEY
) ;
}
// 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 . 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 (
` 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 { statusCode , response } = await this . serverRequest (
endpoint ,
options
) ;
if ( statusCode !== 200 ) {
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 { statusCode , response } = await this . serverRequest (
endpoint ,
options
) ;
if ( statusCode !== 200 ) {
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 ) ;
}
}
// 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 ||
! res . response . data ||
! res . response . meta
) {
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 ;
}
}
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 ,
isSessionRequest : 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 ;
}
}
LokiAppDotNetServerAPI . serverRequest = serverRequest ;
LokiAppDotNetServerAPI . sendViaOnion = sendViaOnion ;
// 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 ;