session-desktop/ts/session/apis/open_group_api/sogsv3/sogsV3BatchPoll.ts

417 lines
11 KiB
TypeScript

import { OpenGroupData } from '../../../../data/opengroups';
import _, { flatten, isEmpty, isNumber, isObject } from 'lodash';
import { OnionSending, OnionV4JSONSnodeResponse } from '../../../onions/onionSend';
import {
OpenGroupPollingUtils,
OpenGroupRequestHeaders,
} from '../opengroupV2/OpenGroupPollingUtils';
import { addJsonContentTypeToHeaders } from './sogsV3SendMessage';
import { AbortSignal } from 'abort-controller';
import { roomHasBlindEnabled } from './sogsV3Capabilities';
type BatchFetchRequestOptions = {
method: 'POST' | 'PUT' | 'GET' | 'DELETE';
path: string;
headers?: any;
};
/**
* Should only have this or the json field but not both at the same time
*/
type BatchBodyRequestSharedOptions = {
method: 'POST' | 'PUT' | 'GET' | 'DELETE';
path: string;
headers?: any;
};
interface BatchJsonSubrequestOptions extends BatchBodyRequestSharedOptions {
json: object;
}
type BatchBodyRequest = BatchJsonSubrequestOptions;
type BatchSubRequest = BatchBodyRequest | BatchFetchRequestOptions;
type BatchRequest = {
/** Used by server to process request */
endpoint: string;
/** Used by server to process request */
method: string;
/** Used by server to process request */
body: string;
/** Used by server to process request and authentication */
headers: OpenGroupRequestHeaders;
};
export type BatchSogsReponse = {
status_code: number;
body?: Array<{ body: object; code: number; headers?: Record<string, string> }>;
};
export const sogsBatchSend = async (
serverUrl: string,
roomInfos: Set<string>,
abortSignal: AbortSignal,
batchRequestOptions: Array<OpenGroupBatchRow>,
batchType: 'batch' | 'sequence'
): Promise<BatchSogsReponse | null> => {
// getting server pk for room
const [roomId] = roomInfos;
const fetchedRoomInfo = OpenGroupData.getV2OpenGroupRoomByRoomId({
serverUrl,
roomId,
});
if (!fetchedRoomInfo || !fetchedRoomInfo?.serverPublicKey) {
window?.log?.warn('Couldnt get fetched info or server public key -- aborting batch request');
return null;
}
const { serverPublicKey } = fetchedRoomInfo;
// send with blinding if we need to
const requireBlinding = Boolean(roomHasBlindEnabled(fetchedRoomInfo));
// creating batch request
const batchRequest = await getBatchRequest(
serverPublicKey,
batchRequestOptions,
requireBlinding,
batchType
);
if (!batchRequest) {
window?.log?.error('Could not generate batch request. Aborting request');
return null;
}
const result = await sendSogsBatchRequestOnionV4(
serverUrl,
serverPublicKey,
batchRequest,
abortSignal
);
if (abortSignal.aborted) {
window.log.info('sendSogsBatchRequestOnionV4 aborted.');
return null;
}
return result || null;
};
export function parseBatchGlobalStatusCode(
response?: BatchSogsReponse | OnionV4JSONSnodeResponse | null
): number | undefined {
return response?.status_code;
}
export function batchGlobalIsSuccess(
response?: BatchSogsReponse | OnionV4JSONSnodeResponse | null
): boolean {
const status = parseBatchGlobalStatusCode(response);
return Boolean(status && isNumber(status) && status >= 200 && status <= 300);
}
function parseBatchFirstSubStatusCode(response?: BatchSogsReponse | null): number | undefined {
return response?.body?.[0].code;
}
export function batchFirstSubIsSuccess(response?: BatchSogsReponse | null): boolean {
const status = parseBatchFirstSubStatusCode(response);
return Boolean(status && isNumber(status) && status >= 200 && status <= 300);
}
export type SubrequestOptionType = 'capabilities' | 'messages' | 'pollInfo' | 'inbox';
export type SubRequestCapabilitiesType = { type: 'capabilities' };
export type SubRequestMessagesObjectType =
| {
roomId: string;
sinceSeqNo?: number;
}
| undefined;
export type SubRequestMessagesType = {
type: 'messages';
messages?: SubRequestMessagesObjectType;
};
export type SubRequestPollInfoType = {
type: 'pollInfo';
pollInfo: {
roomId: string;
infoUpdated?: number;
};
};
export type SubRequestInboxType = {
type: 'inbox';
inboxSince?: {
id?: number;
};
};
export type SubRequestOutboxType = {
type: 'outbox';
outboxSince?: {
id?: number;
};
};
export type SubRequestDeleteMessageType = {
type: 'deleteMessage';
deleteMessage: {
messageId: number;
roomId: string;
};
};
export type SubRequestAddRemoveModeratorType = {
type: 'addRemoveModerators';
addRemoveModerators: {
type: 'add_mods' | 'remove_mods';
sessionIds: Array<string>; // can be blinded id or not
roomId: string; // for now we support only granting/removing mods to single rooms from session
};
};
export type SubRequestBanUnbanUserType = {
type: 'banUnbanUser';
banUnbanUser: {
type: 'ban' | 'unban';
sessionId: string; // can be blinded id or not
roomId: string;
};
};
export type SubRequestDeleteAllUserPostsType = {
type: 'deleteAllPosts';
deleteAllPosts: {
sessionId: string; // can be blinded id or not
roomId: string;
};
};
export type SubRequestUpdateRoomType = {
type: 'updateRoom';
updateRoom: {
roomId: string;
imageId: number; // the fileId uploaded to this sogs and to be referenced as preview/room image
// name and other options are unsupported for now
};
};
export type SubRequestDeleteReactionType = {
type: 'deleteReaction';
deleteReaction: {
reaction: string;
messageId: number;
roomId: string;
};
};
export type OpenGroupBatchRow =
| SubRequestCapabilitiesType
| SubRequestMessagesType
| SubRequestPollInfoType
| SubRequestInboxType
| SubRequestOutboxType
| SubRequestDeleteMessageType
| SubRequestAddRemoveModeratorType
| SubRequestBanUnbanUserType
| SubRequestDeleteAllUserPostsType
| SubRequestUpdateRoomType
| SubRequestDeleteReactionType;
/**
*
* @param options Array of subrequest options to be made.
*/
const makeBatchRequestPayload = (
options: OpenGroupBatchRow
): BatchSubRequest | Array<BatchSubRequest> | null => {
switch (options.type) {
case 'capabilities':
return {
method: 'GET',
path: '/capabilities',
};
case 'messages':
if (options.messages) {
return {
method: 'GET',
// TODO Consistency across platforms with fetching reactors
path: isNumber(options.messages.sinceSeqNo)
? `/room/${options.messages.roomId}/messages/since/${options.messages.sinceSeqNo}?t=r`
: `/room/${options.messages.roomId}/messages/recent`,
};
}
break;
case 'inbox':
return {
method: 'GET',
path:
options?.inboxSince?.id && isNumber(options.inboxSince.id)
? `/inbox/since/${options.inboxSince.id}`
: '/inbox',
};
case 'outbox':
return {
method: 'GET',
path:
options?.outboxSince?.id && isNumber(options.outboxSince.id)
? `/outbox/since/${options.outboxSince.id}`
: '/outbox',
};
case 'pollInfo':
return {
method: 'GET',
path: `/room/${options.pollInfo.roomId}/pollInfo/${options.pollInfo.infoUpdated}`,
};
case 'deleteMessage':
return {
method: 'DELETE',
path: `/room/${options.deleteMessage.roomId}/message/${options.deleteMessage.messageId}`,
};
case 'addRemoveModerators':
const isAddMod = Boolean(options.addRemoveModerators.type === 'add_mods');
return options.addRemoveModerators.sessionIds.map(sessionId => ({
method: 'POST',
path: `/user/${sessionId}/moderator`,
json: {
rooms: [options.addRemoveModerators.roomId],
global: false,
// moderator: isAddMod, // currently we only support adding/removing visible admins
visible: true,
admin: isAddMod,
},
}));
case 'banUnbanUser':
const isBan = Boolean(options.banUnbanUser.type === 'ban');
return {
method: 'POST',
path: `/user/${options.banUnbanUser.sessionId}/${isBan ? 'ban' : 'unban'}`,
json: {
rooms: [options.banUnbanUser.roomId],
// watch out ban and unban user do not allow the same args
// global: false, // for now we do not support the global argument, rooms cannot be set if we use it
// timeout: null, // for now we do not support the timeout argument
},
};
case 'deleteAllPosts':
return {
method: 'DELETE',
path: `/room/${options.deleteAllPosts.roomId}/all/${options.deleteAllPosts.sessionId}`,
};
case 'updateRoom':
return {
method: 'PUT',
path: `/room/${options.updateRoom.roomId}`,
json: { image: options.updateRoom.imageId },
};
case 'deleteReaction':
return {
method: 'DELETE',
path: `/room/${options.deleteReaction.roomId}/reactions/${options.deleteReaction.messageId}/${options.deleteReaction.reaction}`,
};
default:
throw new Error('Invalid batch request row');
}
return null;
};
/**
* Get the request to get all of the details we care from an opengroup, accross all rooms.
* Only compatible with v4 onion requests.
*
* if isSequence is set to true, each rows will be run in order until the first one fails
*/
const getBatchRequest = async (
serverPublicKey: string,
batchOptions: Array<OpenGroupBatchRow>,
requireBlinding: boolean,
batchType: 'batch' | 'sequence'
): Promise<BatchRequest | undefined> => {
const batchEndpoint = batchType === 'sequence' ? '/sequence' : '/batch';
const batchMethod = 'POST';
if (!batchOptions || isEmpty(batchOptions)) {
return undefined;
}
const batchBody = flatten(
batchOptions.map(options => {
return makeBatchRequestPayload(options);
})
);
const stringBody = JSON.stringify(batchBody);
const headers = await OpenGroupPollingUtils.getOurOpenGroupHeaders(
serverPublicKey,
batchEndpoint,
batchMethod,
requireBlinding,
stringBody
);
if (!headers) {
window?.log?.error('Unable to create headers for batch request - aborting');
return;
}
return {
endpoint: batchEndpoint,
method: batchMethod,
body: stringBody,
headers: addJsonContentTypeToHeaders(headers),
};
};
const sendSogsBatchRequestOnionV4 = async (
serverUrl: string,
serverPubkey: string,
request: BatchRequest,
abortSignal: AbortSignal
): Promise<null | BatchSogsReponse> => {
const { endpoint, headers, method, body } = request;
if (!endpoint.startsWith('/')) {
throw new Error('endpoint needs a leading /');
}
const builtUrl = new URL(`${serverUrl}${endpoint}`);
// this function extracts the body and status_code and JSON.parse it already
const batchResponse = await OnionSending.sendViaOnionV4ToNonSnodeWithRetries(
serverPubkey,
builtUrl,
{
method,
headers,
body,
useV4: true,
},
false,
abortSignal
);
if (abortSignal.aborted) {
return null;
}
if (!batchResponse) {
window?.log?.error('sogsbatch: Undefined batch response - cancelling batch request');
return null;
}
if (isObject(batchResponse.body)) {
return batchResponse as BatchSogsReponse;
}
window?.log?.warn('sogsbatch: batch response decoded body is not object. Returning null');
return null;
};