import _ from 'lodash'; import { getV2OpenGroupRoomByRoomId, saveV2OpenGroupRoom, } from '../../data/opengroups'; import { ConversationController } from '../../session/conversations'; import { getSodium } from '../../session/crypto'; import { sendViaOnion } from '../../session/onions/onionSend'; import { PubKey } from '../../session/types'; import { allowOnlyOneAtATime } from '../../session/utils/Promise'; import { fromBase64ToArray, fromBase64ToArrayBuffer, fromHexToArray, toHex, } from '../../session/utils/String'; import { getIdentityKeyPair, getOurPubKeyStrFromCache, } from '../../session/utils/User'; import { getOpenGroupV2ConversationId } from '../utils/OpenGroupUtils'; import { buildUrl, cachedModerators, OpenGroupRequestCommonType, OpenGroupV2Info, OpenGroupV2Request, parseMessages, setCachedModerators, } from './ApiUtil'; import { OpenGroupMessageV2 } from './OpenGroupMessageV2'; // This function might throw async function sendOpenGroupV2Request( request: OpenGroupV2Request ): Promise<Object> { const builtUrl = buildUrl(request); if (!builtUrl) { throw new Error('Invalid request'); } // set the headers sent by the caller, and the roomId. const headers = request.headers || {}; headers.Room = request.room; console.warn(`sending request: ${builtUrl}`); let body = ''; if (request.method !== 'GET') { body = JSON.stringify(request.queryParams); } // request.useOnionRouting === undefined defaults to true if (request.useOnionRouting || request.useOnionRouting === undefined) { const roomDetails = await getV2OpenGroupRoomByRoomId({ serverUrl: request.server, roomId: request.room, }); if (!roomDetails?.serverPublicKey) { throw new Error('PublicKey not found for this server.'); } // Because auth happens on a per-room basis, we need both to make an authenticated request if (request.isAuthRequired && request.room) { // this call will either return the token on the db, // or the promise currently fetching a new token for that same room // or fetch from the open group a new token for that room. const token = await getAuthToken({ roomId: request.room, serverUrl: request.server, }); if (!token) { throw new Error('Failed to get token for open group v2'); } headers.Authorization = token; const res = await sendViaOnion(roomDetails.serverPublicKey, builtUrl, { method: request.method, headers, body, }); const statusCode = res?.result?.status_code; if (!statusCode) { window.log.warn( 'sendOpenGroupV2Request Got unknown status code; res:', res ); return res; } // A 401 means that we didn't provide a (valid) auth token for a route that required one. We use this as an // indication that the token we're using has expired. // Note that a 403 has a different meaning; it means that // we provided a valid token but it doesn't have a high enough permission level for the route in question. if (statusCode === 401) { roomDetails.token = undefined; // we might need to retry doing the request here, but how to make sure we don't retry indefinetely? await saveV2OpenGroupRoom(roomDetails); } return res; } else { // no need for auth, just do the onion request const res = await sendViaOnion(roomDetails.serverPublicKey, builtUrl, { method: request.method, headers, body, }); return res; } return {}; } else { throw new Error( "It's currently not allowed to send non onion routed requests." ); } } // tslint:disable: member-ordering export async function requestNewAuthToken({ serverUrl, roomId, }: OpenGroupRequestCommonType): Promise<string> { const userKeyPair = await getIdentityKeyPair(); if (!userKeyPair) { throw new Error('Failed to fetch user keypair'); } const ourPubkey = getOurPubKeyStrFromCache(); const parameters = {} as Record<string, string>; parameters.public_key = ourPubkey; const request: OpenGroupV2Request = { method: 'GET', room: roomId, server: serverUrl, queryParams: parameters, isAuthRequired: false, endpoint: 'auth_token_challenge', }; const json = (await sendOpenGroupV2Request(request)) as any; // parse the json if (!json || !json?.result?.challenge) { throw new Error('Parsing failed'); } const { ciphertext: base64EncodedCiphertext, ephemeral_public_key: base64EncodedEphemeralPublicKey, } = json?.result?.challenge; if (!base64EncodedCiphertext || !base64EncodedEphemeralPublicKey) { throw new Error('Parsing failed'); } const ciphertext = fromBase64ToArrayBuffer(base64EncodedCiphertext); const ephemeralPublicKey = fromBase64ToArrayBuffer( base64EncodedEphemeralPublicKey ); try { const symmetricKey = await window.libloki.crypto.deriveSymmetricKey( ephemeralPublicKey, userKeyPair.privKey ); const plaintextBuffer = await window.libloki.crypto.DecryptAESGCM( symmetricKey, ciphertext ); const token = toHex(plaintextBuffer); console.warn('token', token); return token; } catch (e) { window.log.error('Failed to decrypt token open group v2'); throw e; } } /** * This function might throw * */ export async function openGroupV2GetRoomInfo({ serverUrl, roomId, }: { roomId: string; serverUrl: string; }): Promise<OpenGroupV2Info> { const request: OpenGroupV2Request = { method: 'GET', server: serverUrl, room: roomId, isAuthRequired: false, endpoint: `rooms/${roomId}`, }; const result = (await sendOpenGroupV2Request(request)) as any; if (result?.result?.room) { const { id, name, image_id: imageId } = result?.result?.room; if (!id || !name) { throw new Error('Parsing failed'); } const info: OpenGroupV2Info = { id, name, imageId, }; return info; } throw new Error('getInfo failed'); } async function claimAuthToken( authToken: string, serverUrl: string, roomId: string ): Promise<string> { // Set explicitly here because is isn't in the database yet at this point const headers = { Authorization: authToken }; const request: OpenGroupV2Request = { method: 'POST', headers, room: roomId, server: serverUrl, queryParams: { public_key: getOurPubKeyStrFromCache() }, isAuthRequired: false, endpoint: 'claim_auth_token', }; const result = (await sendOpenGroupV2Request(request)) as any; if (result?.result?.status_code !== 200) { throw new Error( `Could not claim token, status code: ${result?.result?.status_code}` ); } return authToken; } export async function getAuthToken({ serverUrl, roomId, }: OpenGroupRequestCommonType): Promise<string> { // first try to fetch from db a saved token. const roomDetails = await getV2OpenGroupRoomByRoomId({ serverUrl, roomId }); if (!roomDetails) { throw new Error('getAuthToken Room does not exist.'); } if (roomDetails?.token) { return roomDetails?.token; } await allowOnlyOneAtATime( `getAuthTokenV2${serverUrl}:${roomId}`, async () => { try { const token = await requestNewAuthToken({ serverUrl, roomId }); // claimAuthToken throws if the status code is not valid const claimedToken = await claimAuthToken(token, serverUrl, roomId); roomDetails.token = token; await saveV2OpenGroupRoom(roomDetails); } catch (e) { window.log.error('Failed to getAuthToken', e); throw e; } } ); return 'token'; } export const deleteAuthToken = async ({ serverUrl, roomId, }: OpenGroupRequestCommonType) => { const request: OpenGroupV2Request = { method: 'DELETE', room: roomId, server: serverUrl, isAuthRequired: false, endpoint: 'auth_token', }; const result = (await sendOpenGroupV2Request(request)) as any; if (result?.result?.status_code !== 200) { throw new Error( `Could not deleteAuthToken, status code: ${result?.result?.status_code}` ); } }; export const getMessages = async ({ serverUrl, roomId, }: OpenGroupRequestCommonType): Promise<Array<OpenGroupMessageV2>> => { const roomInfos = await getV2OpenGroupRoomByRoomId({ serverUrl, roomId }); if (!roomInfos) { throw new Error('Could not find this room getMessages'); } const { lastMessageFetchedServerID } = roomInfos; const queryParams = {} as Record<string, any>; if (lastMessageFetchedServerID) { queryParams.from_server_id = lastMessageFetchedServerID; } const request: OpenGroupV2Request = { method: 'GET', room: roomId, server: serverUrl, isAuthRequired: true, endpoint: 'messages', }; const result = (await sendOpenGroupV2Request(request)) as any; if (result?.result?.status_code !== 200) { throw new Error( `Could not getMessages, status code: ${result?.result?.status_code}` ); } // we have a 200 const rawMessages = result?.result?.messages as Array<Record<string, any>>; if (!rawMessages) { window.log.info('no new messages'); return []; } const validMessages = await parseMessages(rawMessages); console.warn('validMessages', validMessages); return validMessages; }; export const postMessage = async ( message: OpenGroupMessageV2, room: OpenGroupRequestCommonType ) => { try { const signedMessage = await message.sign(); const json = signedMessage.toJson(); console.warn('posting message json', json); const request: OpenGroupV2Request = { method: 'POST', room: room.roomId, server: room.serverUrl, queryParams: json, isAuthRequired: true, endpoint: 'messages', }; const result = (await sendOpenGroupV2Request(request)) as any; if (result?.result?.status_code !== 200) { throw new Error( `Could not postMessage, status code: ${result?.result?.status_code}` ); } const rawMessage = result?.result?.message; if (!rawMessage) { throw new Error('postMessage parsing failed'); } // this will throw if the json is not valid return OpenGroupMessageV2.fromJson(rawMessage); } catch (e) { window.log.error('Failed to post message to open group v2', e); throw e; } }; /** Those functions are related to moderators management */ export const getModerators = async ({ serverUrl, roomId, }: OpenGroupRequestCommonType): Promise<Array<string>> => { const request: OpenGroupV2Request = { method: 'GET', room: roomId, server: serverUrl, isAuthRequired: true, endpoint: 'moderators', }; const result = (await sendOpenGroupV2Request(request)) as any; if (result?.result?.status_code !== 200) { throw new Error( `Could not getModerators, status code: ${result?.result?.status_code}` ); } const moderatorsGot = result?.result?.moderators; if (moderatorsGot === undefined) { throw new Error( 'Could not getModerators, got no moderatorsGot at all in json.' ); } setCachedModerators(serverUrl, roomId, moderatorsGot || []); return moderatorsGot || []; }; export const isUserModerator = ( publicKey: string, roomInfos: OpenGroupRequestCommonType ): boolean => { return ( cachedModerators ?.get(roomInfos.serverUrl) ?.get(roomInfos.roomId) ?.has(publicKey) || false ); }; export const banUser = async ( publicKey: string, roomInfos: OpenGroupRequestCommonType ): Promise<void> => { const queryParams = { public_key: publicKey }; const request: OpenGroupV2Request = { method: 'POST', room: roomInfos.roomId, server: roomInfos.serverUrl, isAuthRequired: true, queryParams, endpoint: 'block_list', }; await sendOpenGroupV2Request(request); }; export const unbanUser = async ( publicKey: string, roomInfos: OpenGroupRequestCommonType ): Promise<void> => { const request: OpenGroupV2Request = { method: 'DELETE', room: roomInfos.roomId, server: roomInfos.serverUrl, isAuthRequired: true, endpoint: `block_list/${publicKey}`, }; await sendOpenGroupV2Request(request); }; export const getAllRoomInfos = async ( roomInfos: OpenGroupRequestCommonType ) => { // room should not be required here const request: OpenGroupV2Request = { method: 'GET', room: roomInfos.roomId, server: roomInfos.serverUrl, isAuthRequired: false, endpoint: 'rooms', }; const result = (await sendOpenGroupV2Request(request)) as any; if (result?.result?.status_code !== 200) { window.log.warn('getAllRoomInfos failed invalid status code'); return; } const rooms = result?.result?.rooms as Array<any>; if (!rooms || !rooms.length) { window.log.warn('getAllRoomInfos failed invalid infos'); return; } return _.compact( rooms.map(room => { // check that the room is correctly filled const { id, name, image_id: imageId } = room; if (!id || !name) { window.log.info('getAllRoomInfos: Got invalid room details, skipping'); return null; } return { id, name, imageId } as OpenGroupV2Info; }) ); }; export const getMemberCount = async ( roomInfos: OpenGroupRequestCommonType ): Promise<void> => { const request: OpenGroupV2Request = { method: 'GET', room: roomInfos.roomId, server: roomInfos.serverUrl, isAuthRequired: true, endpoint: 'member_count', }; const result = (await sendOpenGroupV2Request(request)) as any; if (result?.result?.status_code !== 200) { window.log.warn('getMemberCount failed invalid status code'); return; } const count = result?.result?.member_count as number; if (count === undefined) { window.log.warn('getMemberCount failed invalid count'); return; } const conversationId = getOpenGroupV2ConversationId( roomInfos.serverUrl, roomInfos.roomId ); const convo = ConversationController.getInstance().get(conversationId); if (!convo) { window.log.warn( 'cannot update conversation memberCount as it does not exist' ); return; } if (convo.get('subscriberCount') !== count) { convo.set({ subscriberCount: count }); // triggers the save to db and the refresh of the UI await convo.commit(); } };