Files
session-desktop/ts/session/apis/session_backend_server.ts

262 lines
7.5 KiB
TypeScript

import AbortController from 'abort-controller';
import type { ZodError } from 'zod';
import z, { zodSafeParse } from '../../util/zod';
import { BlindingActions } from '../../webworker/workers/browser/libsession_worker_interface';
import { isOnionV4JSONSnodeResponse, OnionSending } from '../onions/onionSend';
import { fromUInt8ArrayToBase64 } from '../utils/String';
import { NetworkTime } from '../../util/NetworkTime';
import {
batchGlobalIsSuccess,
parseBatchGlobalStatusCode,
} from './open_group_api/sogsv3/sogsV3BatchPoll';
import { UserUtils } from '../utils';
import { timeoutWithAbort } from '../utils/Promise';
import { DURATION } from '../constants';
import { getFeatureFlag } from '../../state/ducks/types/releasedFeaturesReduxTypes';
import { FetchDestination } from '../utils/InsecureNodeFetch';
export type SessionServerConfigType = {
name: string;
url: `${'http' | 'https'}://${string}`;
edPkHex: string;
xPkHex: string;
requestTimeoutMs: number;
abortControllerTimeoutMs: number;
};
export type SessionBackendServerApiOptions = Omit<
SessionServerConfigType,
'requestTimeoutMs' | 'abortControllerTimeoutMs'
> & {
requestTimeoutMs?: number;
abortControllerTimeoutMs?: number;
};
type HTTPMethod = 'GET' | 'POST';
type SessionBackendServerMakeRequestParams = {
path: `/${string}`;
method: HTTPMethod;
bodyGetter?: () => Promise<string | null>;
blindSignRequest?: boolean;
};
type BlindSignedHeaders = {
'X-FS-Pubkey': string;
'X-FS-Timestamp': string;
'X-FS-Signature': string;
};
export const SessionBackendBaseResponseSchema = z.object({
status_code: z.number(),
error: z.string().optional(),
t: z.number(),
});
export type SessionBackendBaseResponseType = z.infer<typeof SessionBackendBaseResponseSchema>;
export type SessionBackendServerApiResponse = SessionBackendBaseResponseType &
Record<string, unknown>;
export default class SessionBackendServerApi {
readonly server: SessionServerConfigType;
constructor(
server: SessionBackendServerApiOptions,
requestTimeoutMsOverride?: number,
abortControllerTimeoutMs?: number
) {
this.server = {
...server,
requestTimeoutMs:
server.requestTimeoutMs || requestTimeoutMsOverride || 10 * DURATION.SECONDS,
abortControllerTimeoutMs:
server.abortControllerTimeoutMs || abortControllerTimeoutMs || 30 * DURATION.SECONDS,
};
}
private static getGenericErrorResponse(error?: string) {
return {
status_code: 500,
error,
t: NetworkTime.nowSeconds(),
};
}
public logInfo(msg: string, path?: string) {
window?.log?.info(`[${this.server.name}] ${path?.length ? `${path}: ` : ''}${msg}`);
}
public logError(msg: string, path?: string) {
window?.log?.error(`[${this.server.name}] ${path?.length ? `${path}: ` : ''}${msg}`);
}
public logZodError(zodError: ZodError, path?: string) {
window?.log?.error(`[${this.server.name}] ${path?.length ? `${path}: ` : ''}`, zodError);
}
// NOTE: returns null if the ed25519 key pair is not found
private async getBlindSignedHeaders({
method,
path,
body,
}: {
method: HTTPMethod;
path: string;
body: string | null;
}): Promise<BlindSignedHeaders | null> {
const ed25519SecretKey = (await UserUtils.getUserED25519KeyPairBytes()).privKeyBytes;
if (!ed25519SecretKey) {
this.logError(`${path} error: ed25519Secret key was not found when creating headers!`);
return null;
}
const blindedPkHex = await BlindingActions.blindVersionPubkey({
ed25519SecretKey,
});
const sigTimestampSeconds = NetworkTime.nowSeconds();
const signedData = await BlindingActions.blindVersionSignRequest({
ed25519SecretKey,
sigTimestampSeconds,
sigMethod: method,
sigPath: path,
sigBody: body ? new TextEncoder().encode(body) : null,
});
return {
'X-FS-Pubkey': blindedPkHex,
'X-FS-Timestamp': sigTimestampSeconds.toString(),
'X-FS-Signature': fromUInt8ArrayToBase64(signedData),
};
}
private handleError(message: string, path: string) {
this.logError(message, path);
return SessionBackendServerApi.getGenericErrorResponse(message);
}
private async _makeRequest({
path,
method,
bodyGetter,
blindSignRequest,
}: SessionBackendServerMakeRequestParams): Promise<SessionBackendServerApiResponse> {
const body = typeof bodyGetter === 'function' ? await bodyGetter() : null;
const headers = blindSignRequest
? await this.getBlindSignedHeaders({
method,
path,
body,
})
: {};
if (headers === null) {
return this.handleError('failed to blind sign request parameters', path);
}
const url = new URL(path, this.server.url);
const controller = new AbortController();
const result = await timeoutWithAbort(
OnionSending.sendViaOnionV4ToNonSnodeWithRetries(
this.server.xPkHex,
url,
{
method,
headers,
body,
useV4: true,
},
false,
controller.signal,
this.server.requestTimeoutMs,
FetchDestination.SESSION_SERVER,
'SessionBackendServerApi._makeRequest'
),
this.server.abortControllerTimeoutMs,
controller
);
if (!result || !isOnionV4JSONSnodeResponse(result) || typeof result.body !== 'object') {
return this.handleError(`returned a non json response: ${JSON.stringify(result)}`, path);
}
if (!batchGlobalIsSuccess(result)) {
return this.handleError(`failed with status ${parseBatchGlobalStatusCode(result)}`, path);
}
return {
...result.body,
status_code: result.status_code,
t:
't' in result.body && typeof result.body.t === 'number'
? result.body.t
: NetworkTime.nowSeconds(),
};
}
private parseSchema<T>({
path,
response,
schema,
}: {
path: string;
response: SessionBackendServerApiResponse;
schema: z.ZodType<T>;
}): T | null {
const result = zodSafeParse(schema, response);
if (result.success) {
return result.data;
}
if (result.error) {
this.logZodError(result.error, path);
} else {
this.logError('Failed to parse response', path);
}
return null;
}
public async makeRequest(
params: SessionBackendServerMakeRequestParams
): Promise<SessionBackendServerApiResponse> {
if (getFeatureFlag('debugServerRequests')) {
this.logInfo(`\nrequest: ${JSON.stringify({ url: this.server.url, params })}`, params.path);
}
const response = await this._makeRequest(params);
if (getFeatureFlag('debugServerRequests')) {
this.logInfo(`\nresponse: ${JSON.stringify(response)}`, params.path);
}
return response;
}
public async makeRequestWithSchema<T extends SessionBackendBaseResponseType>({
withZodSchema,
...requestParams
}: SessionBackendServerMakeRequestParams & {
withZodSchema: z.ZodType<T>;
}): Promise<T | null> {
const response = await this.makeRequest(requestParams);
if (getFeatureFlag('debugServerRequests')) {
this.logInfo(
`\nfrom ${requestParams.method} on "${requestParams.path}: response: ${JSON.stringify(response)}`,
requestParams.path
);
}
if (response.status_code !== 200) {
return this.parseSchema({
path: requestParams.path,
response,
schema: SessionBackendBaseResponseSchema,
}) as T | null;
}
return this.parseSchema({ path: requestParams.path, response, schema: withZodSchema });
}
}