mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-08 04:00:43 +00:00
583 lines
21 KiB
TypeScript
583 lines
21 KiB
TypeScript
// BlueBubblesClient — consolidated BB API client.
|
|
//
|
|
// Resolves the BB server URL, auth material, and SSRF policy ONCE at
|
|
// construction, then exposes typed operations that cannot omit any of them.
|
|
//
|
|
// Designed to replace the scattered pattern of each callsite computing its own
|
|
// SsrFPolicy and passing it to `blueBubblesFetchWithTimeout`. Related issues:
|
|
// - #34749 image attachments blocked by SSRF guard (localhost)
|
|
// - #57181 SSRF blocks BB plugin internal API calls
|
|
// - #59722 SSRF allowlist doesn't cover reactions
|
|
// - #60715 BB health check fails on LAN/private serverUrl
|
|
// - #66869 move `?password=` → header auth (future-proofed via AuthStrategy)
|
|
|
|
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/account-id";
|
|
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
|
import { isBlockedHostnameOrIp, type SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime";
|
|
import { resolveBlueBubblesServerAccount } from "./account-resolve.js";
|
|
import { extractAttachments } from "./monitor-normalize.js";
|
|
import { postMultipartFormData } from "./multipart.js";
|
|
import { resolveRequestUrl } from "./request-url.js";
|
|
import type { OpenClawConfig } from "./runtime-api.js";
|
|
import { getBlueBubblesRuntime } from "./runtime.js";
|
|
import {
|
|
blueBubblesFetchWithTimeout,
|
|
normalizeBlueBubblesServerUrl,
|
|
type BlueBubblesAttachment,
|
|
} from "./types.js";
|
|
|
|
const DEFAULT_TIMEOUT_MS = 10_000;
|
|
const DEFAULT_ATTACHMENT_MAX_BYTES = 8 * 1024 * 1024;
|
|
const DEFAULT_MULTIPART_TIMEOUT_MS = 60_000;
|
|
|
|
// --- Auth strategy ---------------------------------------------------------
|
|
|
|
/**
|
|
* Pluggable authentication for BlueBubbles API requests. Mutates the URL/init
|
|
* pair in place before the request is dispatched.
|
|
*
|
|
* Two built-in strategies are provided:
|
|
* - `blueBubblesQueryStringAuth` — today's `?password=...` pattern (default).
|
|
* - `blueBubblesHeaderAuth` — header-based auth; flip the default here when
|
|
* BB Server ships the header-auth change for #66869.
|
|
*/
|
|
interface BlueBubblesAuthStrategy {
|
|
/**
|
|
* Stable identifier for this strategy. Used by the client cache fingerprint
|
|
* so two clients for the same account + credential that differ only in auth
|
|
* strategy don't silently collapse onto the same cached instance.
|
|
* (Greptile #68234 P2)
|
|
*/
|
|
readonly id: string;
|
|
decorate(req: { url: URL; init: RequestInit }): void;
|
|
}
|
|
|
|
export function blueBubblesQueryStringAuth(password: string): BlueBubblesAuthStrategy {
|
|
return {
|
|
id: "query-string",
|
|
decorate({ url }) {
|
|
url.searchParams.set("password", password);
|
|
},
|
|
};
|
|
}
|
|
|
|
export function blueBubblesHeaderAuth(
|
|
password: string,
|
|
headerName = "X-BB-Password",
|
|
): BlueBubblesAuthStrategy {
|
|
return {
|
|
id: `header:${headerName}`,
|
|
decorate({ init }) {
|
|
const headers = new Headers(init.headers ?? undefined);
|
|
headers.set(headerName, password);
|
|
init.headers = headers;
|
|
},
|
|
};
|
|
}
|
|
|
|
// --- Policy resolution -----------------------------------------------------
|
|
|
|
function safeExtractHostname(baseUrl: string): string | undefined {
|
|
try {
|
|
const hostname = new URL(normalizeBlueBubblesServerUrl(baseUrl)).hostname.trim();
|
|
return hostname || undefined;
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Resolve the BB client's SSRF policy at construction time. Three modes —
|
|
* all of which go through `fetchWithSsrFGuard`; we never hand back a policy
|
|
* that skips the guard:
|
|
*
|
|
* 1. `{ allowPrivateNetwork: true }` — user explicitly opted in
|
|
* (`network.dangerouslyAllowPrivateNetwork: true`). Private/loopback
|
|
* addresses are permitted for this client.
|
|
*
|
|
* 2. `{ allowedHostnames: [trustedHostname] }` — narrow allowlist. Applied
|
|
* when we have a parseable hostname AND the user has not explicitly
|
|
* opted out (or the hostname isn't private anyway). This is the case
|
|
* that closes #34749, #57181, #59722, #60715 for self-hosted BB on
|
|
* private/localhost addresses without requiring a full opt-in.
|
|
*
|
|
* 3. `{}` — guarded with the default-deny policy. Applied when we can't
|
|
* produce a valid allowlist (opt-out on a private hostname, or an
|
|
* unparseable baseUrl). Previously returned `undefined` and skipped
|
|
* the guard entirely, which was an SSRF bypass when a user explicitly
|
|
* opted out of private-network access. Aisle #68234 found this.
|
|
*
|
|
* Prior to this helper, the logic lived inline in `attachments.ts` and was
|
|
* inconsistently replicated across 15+ callsites. Resolving once ensures
|
|
* every request from a client instance uses the same policy.
|
|
*/
|
|
export function resolveBlueBubblesClientSsrfPolicy(params: {
|
|
baseUrl: string;
|
|
allowPrivateNetwork: boolean;
|
|
allowPrivateNetworkConfig?: boolean;
|
|
}): {
|
|
ssrfPolicy: SsrFPolicy;
|
|
trustedHostname?: string;
|
|
trustedHostnameIsPrivate: boolean;
|
|
} {
|
|
const trustedHostname = safeExtractHostname(params.baseUrl);
|
|
const trustedHostnameIsPrivate = trustedHostname ? isBlockedHostnameOrIp(trustedHostname) : false;
|
|
|
|
if (params.allowPrivateNetwork) {
|
|
return {
|
|
ssrfPolicy: { allowPrivateNetwork: true },
|
|
trustedHostname,
|
|
trustedHostnameIsPrivate,
|
|
};
|
|
}
|
|
|
|
if (
|
|
trustedHostname &&
|
|
(params.allowPrivateNetworkConfig !== false || !trustedHostnameIsPrivate)
|
|
) {
|
|
return {
|
|
ssrfPolicy: { allowedHostnames: [trustedHostname] },
|
|
trustedHostname,
|
|
trustedHostnameIsPrivate,
|
|
};
|
|
}
|
|
|
|
// Mode 3: default-deny guard. Honors an explicit opt-out on a private
|
|
// hostname and fails-safe on unparseable URLs. Never undefined. (aisle #68234)
|
|
return { ssrfPolicy: {}, trustedHostname, trustedHostnameIsPrivate };
|
|
}
|
|
|
|
// --- Client ----------------------------------------------------------------
|
|
|
|
type BlueBubblesClientOptions = {
|
|
cfg?: OpenClawConfig;
|
|
accountId?: string;
|
|
serverUrl?: string;
|
|
password?: string;
|
|
timeoutMs?: number;
|
|
authStrategy?: (password: string) => BlueBubblesAuthStrategy;
|
|
};
|
|
|
|
type ClientConstructorParams = {
|
|
accountId: string;
|
|
baseUrl: string;
|
|
password: string;
|
|
ssrfPolicy: SsrFPolicy;
|
|
trustedHostname: string | undefined;
|
|
trustedHostnameIsPrivate: boolean;
|
|
defaultTimeoutMs: number;
|
|
authStrategy: BlueBubblesAuthStrategy;
|
|
};
|
|
|
|
type MediaFetchErrorCode = "max_bytes" | "http_error" | "fetch_failed";
|
|
|
|
function readMediaFetchErrorCode(error: unknown): MediaFetchErrorCode | undefined {
|
|
if (!error || typeof error !== "object") {
|
|
return undefined;
|
|
}
|
|
const code = (error as { code?: unknown }).code;
|
|
return code === "max_bytes" || code === "http_error" || code === "fetch_failed"
|
|
? code
|
|
: undefined;
|
|
}
|
|
|
|
export class BlueBubblesClient {
|
|
readonly accountId: string;
|
|
readonly baseUrl: string;
|
|
readonly trustedHostname: string | undefined;
|
|
readonly trustedHostnameIsPrivate: boolean;
|
|
|
|
private readonly password: string;
|
|
private readonly ssrfPolicy: SsrFPolicy;
|
|
private readonly defaultTimeoutMs: number;
|
|
private readonly authStrategy: BlueBubblesAuthStrategy;
|
|
|
|
constructor(params: ClientConstructorParams) {
|
|
this.accountId = params.accountId;
|
|
this.baseUrl = params.baseUrl;
|
|
this.password = params.password;
|
|
this.ssrfPolicy = params.ssrfPolicy;
|
|
this.trustedHostname = params.trustedHostname;
|
|
this.trustedHostnameIsPrivate = params.trustedHostnameIsPrivate;
|
|
this.defaultTimeoutMs = params.defaultTimeoutMs;
|
|
this.authStrategy = params.authStrategy;
|
|
}
|
|
|
|
/**
|
|
* Read the resolved SSRF policy for this client. Exposed primarily for tests
|
|
* and diagnostics; production code should never need to inspect it.
|
|
*/
|
|
getSsrfPolicy(): SsrFPolicy {
|
|
return this.ssrfPolicy;
|
|
}
|
|
|
|
// Build an authorized URL+init pair. Auth is applied exactly once per
|
|
// request; the SSRF policy is attached by `request()` below.
|
|
private buildAuthorizedRequest(params: { path: string; method: string; init?: RequestInit }): {
|
|
url: string;
|
|
init: RequestInit;
|
|
} {
|
|
const normalized = normalizeBlueBubblesServerUrl(this.baseUrl);
|
|
const url = new URL(params.path, `${normalized}/`);
|
|
const init: RequestInit = { ...params.init, method: params.method };
|
|
this.authStrategy.decorate({ url, init });
|
|
return { url: url.toString(), init };
|
|
}
|
|
|
|
/**
|
|
* Core request method. All typed operations on the client route through
|
|
* this method, which handles auth decoration, SSRF policy, and timeout.
|
|
*/
|
|
async request(params: {
|
|
method: string;
|
|
path: string;
|
|
body?: unknown;
|
|
headers?: Record<string, string>;
|
|
timeoutMs?: number;
|
|
}): Promise<Response> {
|
|
const init: RequestInit = {};
|
|
if (params.headers) {
|
|
init.headers = { ...params.headers };
|
|
}
|
|
if (params.body !== undefined) {
|
|
init.headers = {
|
|
"Content-Type": "application/json",
|
|
...(init.headers as Record<string, string> | undefined),
|
|
};
|
|
init.body = JSON.stringify(params.body);
|
|
}
|
|
const prepared = this.buildAuthorizedRequest({
|
|
path: params.path,
|
|
method: params.method,
|
|
init,
|
|
});
|
|
return await blueBubblesFetchWithTimeout(
|
|
prepared.url,
|
|
prepared.init,
|
|
params.timeoutMs ?? this.defaultTimeoutMs,
|
|
this.ssrfPolicy,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* JSON request helper. Returns both the response (for status/headers) and
|
|
* parsed body (null on non-ok or parse failure — callers check both).
|
|
*/
|
|
async requestJson(params: {
|
|
method: string;
|
|
path: string;
|
|
body?: unknown;
|
|
timeoutMs?: number;
|
|
}): Promise<{ response: Response; data: unknown }> {
|
|
const response = await this.request(params);
|
|
if (!response.ok) {
|
|
return { response, data: null };
|
|
}
|
|
const raw: unknown = await response.json().catch(() => null);
|
|
return { response, data: raw };
|
|
}
|
|
|
|
/**
|
|
* Multipart POST (attachment send, group icon set). The caller supplies the
|
|
* boundary and body parts; the client handles URL construction, auth, and
|
|
* SSRF policy. Timeout defaults to 60s because uploads can be large.
|
|
*
|
|
* Auth-decorated headers from `prepared.init` are forwarded via `extraHeaders`
|
|
* so header-auth strategies keep working on multipart paths. (Greptile #68234 P1)
|
|
*/
|
|
async requestMultipart(params: {
|
|
path: string;
|
|
boundary: string;
|
|
parts: Uint8Array[];
|
|
timeoutMs?: number;
|
|
}): Promise<Response> {
|
|
const prepared = this.buildAuthorizedRequest({
|
|
path: params.path,
|
|
method: "POST",
|
|
init: {},
|
|
});
|
|
return await postMultipartFormData({
|
|
url: prepared.url,
|
|
boundary: params.boundary,
|
|
parts: params.parts,
|
|
timeoutMs: params.timeoutMs ?? DEFAULT_MULTIPART_TIMEOUT_MS,
|
|
ssrfPolicy: this.ssrfPolicy,
|
|
extraHeaders: prepared.init.headers,
|
|
});
|
|
}
|
|
|
|
// --- Probe operations ----------------------------------------------------
|
|
|
|
/** GET /api/v1/ping — health check. Raw response for status inspection. */
|
|
async ping(params: { timeoutMs?: number } = {}): Promise<Response> {
|
|
return await this.request({
|
|
method: "GET",
|
|
path: "/api/v1/ping",
|
|
timeoutMs: params.timeoutMs,
|
|
});
|
|
}
|
|
|
|
/** GET /api/v1/server/info — server/OS/Private-API metadata. */
|
|
async getServerInfo(params: { timeoutMs?: number } = {}): Promise<Response> {
|
|
return await this.request({
|
|
method: "GET",
|
|
path: "/api/v1/server/info",
|
|
timeoutMs: params.timeoutMs,
|
|
});
|
|
}
|
|
|
|
// --- Reactions (fixes #59722) -------------------------------------------
|
|
|
|
/**
|
|
* POST /api/v1/message/react. Uses the same SSRF policy as every other
|
|
* operation on this client — closing the gap where `reactions.ts` passed
|
|
* `{}` (always guarded, always blocks private IPs) while other callsites
|
|
* used mode-aware policies.
|
|
*/
|
|
async react(params: {
|
|
chatGuid: string;
|
|
selectedMessageGuid: string;
|
|
reaction: string;
|
|
partIndex?: number;
|
|
timeoutMs?: number;
|
|
}): Promise<Response> {
|
|
return await this.request({
|
|
method: "POST",
|
|
path: "/api/v1/message/react",
|
|
body: {
|
|
chatGuid: params.chatGuid,
|
|
selectedMessageGuid: params.selectedMessageGuid,
|
|
reaction: params.reaction,
|
|
partIndex: typeof params.partIndex === "number" ? params.partIndex : 0,
|
|
},
|
|
timeoutMs: params.timeoutMs,
|
|
});
|
|
}
|
|
|
|
// --- Attachments (fixes #34749) -----------------------------------------
|
|
|
|
/**
|
|
* GET /api/v1/message/{guid} to read attachment metadata. BlueBubbles may
|
|
* fire `new-message` before attachment indexing completes, so this re-reads
|
|
* after a delay. (#65430, #67437)
|
|
*/
|
|
async getMessageAttachments(params: {
|
|
messageGuid: string;
|
|
timeoutMs?: number;
|
|
}): Promise<BlueBubblesAttachment[]> {
|
|
const { response, data } = await this.requestJson({
|
|
method: "GET",
|
|
path: `/api/v1/message/${encodeURIComponent(params.messageGuid)}`,
|
|
timeoutMs: params.timeoutMs,
|
|
});
|
|
if (!response.ok || typeof data !== "object" || data === null) {
|
|
return [];
|
|
}
|
|
const inner = (data as { data?: unknown }).data;
|
|
if (typeof inner !== "object" || inner === null) {
|
|
return [];
|
|
}
|
|
return extractAttachments(inner as Record<string, unknown>);
|
|
}
|
|
|
|
/**
|
|
* Download an attachment via the channel media fetcher. Unlike the legacy
|
|
* helper, the SSRF policy is threaded to BOTH `fetchRemoteMedia` AND the
|
|
* `fetchImpl` callback — closing #34749 where the callback silently fell
|
|
* back to the unguarded fetch path regardless of the outer policy.
|
|
*
|
|
* Note: the actual SSRF check still happens upstream in `fetchRemoteMedia`.
|
|
* Passing `ssrfPolicy` to `blueBubblesFetchWithTimeout` in the callback
|
|
* keeps it in the guarded path if the host needs re-validation (e.g. on a
|
|
* BB Server that issues 302 redirects to a different host).
|
|
*/
|
|
async downloadAttachment(params: {
|
|
attachment: BlueBubblesAttachment;
|
|
maxBytes?: number;
|
|
timeoutMs?: number;
|
|
}): Promise<{ buffer: Uint8Array; contentType?: string }> {
|
|
const guid = params.attachment.guid?.trim();
|
|
if (!guid) {
|
|
throw new Error("BlueBubbles attachment guid is required");
|
|
}
|
|
const maxBytes =
|
|
typeof params.maxBytes === "number" ? params.maxBytes : DEFAULT_ATTACHMENT_MAX_BYTES;
|
|
const prepared = this.buildAuthorizedRequest({
|
|
path: `/api/v1/attachment/${encodeURIComponent(guid)}/download`,
|
|
method: "GET",
|
|
init: {},
|
|
});
|
|
const clientSsrfPolicy = this.ssrfPolicy;
|
|
const effectiveTimeoutMs = params.timeoutMs ?? this.defaultTimeoutMs;
|
|
// Auth-decorated headers from buildAuthorizedRequest (for header-auth
|
|
// strategies) must flow through the fetchImpl callback too, otherwise
|
|
// the runtime might dispatch with only its own default headers. Merge
|
|
// prepared.init.headers with any headers the runtime supplies; runtime
|
|
// headers (typically Range for partial reads) win on conflict.
|
|
// (Greptile #68234 P1)
|
|
const preparedHeaders = prepared.init.headers;
|
|
|
|
try {
|
|
const fetched = await getBlueBubblesRuntime().channel.media.fetchRemoteMedia({
|
|
url: prepared.url,
|
|
filePathHint: params.attachment.transferName ?? params.attachment.guid ?? "attachment",
|
|
maxBytes,
|
|
ssrfPolicy: clientSsrfPolicy,
|
|
fetchImpl: async (input, init) => {
|
|
const mergedHeaders = new Headers(preparedHeaders);
|
|
if (init?.headers) {
|
|
const runtimeHeaders = new Headers(init.headers);
|
|
runtimeHeaders.forEach((value, key) => mergedHeaders.set(key, value));
|
|
}
|
|
return await blueBubblesFetchWithTimeout(
|
|
resolveRequestUrl(input),
|
|
{ ...init, method: init?.method ?? "GET", headers: mergedHeaders },
|
|
effectiveTimeoutMs,
|
|
clientSsrfPolicy,
|
|
);
|
|
},
|
|
});
|
|
return {
|
|
buffer: new Uint8Array(fetched.buffer),
|
|
contentType: fetched.contentType ?? params.attachment.mimeType ?? undefined,
|
|
};
|
|
} catch (error) {
|
|
if (readMediaFetchErrorCode(error) === "max_bytes") {
|
|
throw new Error(`BlueBubbles attachment too large (limit ${maxBytes} bytes)`, {
|
|
cause: error,
|
|
});
|
|
}
|
|
throw new Error(`BlueBubbles attachment download failed: ${formatErrorMessage(error)}`, {
|
|
cause: error,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Factory and cache -----------------------------------------------------
|
|
|
|
type CachedClientEntry = {
|
|
client: BlueBubblesClient;
|
|
/** Fingerprint of auth + SSRF-policy inputs — cache hit requires full match. */
|
|
fingerprint: string;
|
|
};
|
|
const clientFingerprints = new Map<string, CachedClientEntry>();
|
|
|
|
function buildClientFingerprint(params: {
|
|
baseUrl: string;
|
|
password: string;
|
|
authStrategyId: string;
|
|
allowPrivateNetwork: boolean;
|
|
allowPrivateNetworkConfig?: boolean;
|
|
}): string {
|
|
// Keep every construction-time behavior input here. The client stores auth
|
|
// and SSRF policy immutably, so config flips must rebuild without requiring
|
|
// a process restart or an explicit cache invalidation call.
|
|
return JSON.stringify({
|
|
baseUrl: params.baseUrl,
|
|
password: params.password,
|
|
authStrategyId: params.authStrategyId,
|
|
allowPrivateNetwork: params.allowPrivateNetwork,
|
|
allowPrivateNetworkConfig: params.allowPrivateNetworkConfig ?? null,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Get or create a `BlueBubblesClient` for one BB account. The client is cached
|
|
* by `accountId` — the next call with the same account AND same {baseUrl,
|
|
* password} returns the existing instance. Password or URL change rebuilds.
|
|
* Call `invalidateBlueBubblesClient(accountId)` from account config reload
|
|
* paths to evict explicitly.
|
|
*/
|
|
export function createBlueBubblesClient(opts: BlueBubblesClientOptions = {}): BlueBubblesClient {
|
|
const resolved = resolveBlueBubblesServerAccount({
|
|
cfg: opts.cfg,
|
|
accountId: opts.accountId,
|
|
serverUrl: opts.serverUrl,
|
|
password: opts.password,
|
|
});
|
|
const cacheKey = resolved.accountId || DEFAULT_ACCOUNT_ID;
|
|
const authFactory = opts.authStrategy ?? blueBubblesQueryStringAuth;
|
|
const authStrategy = authFactory(resolved.password);
|
|
const fingerprint = buildClientFingerprint({
|
|
baseUrl: resolved.baseUrl,
|
|
password: resolved.password,
|
|
authStrategyId: authStrategy.id,
|
|
allowPrivateNetwork: resolved.allowPrivateNetwork,
|
|
allowPrivateNetworkConfig: resolved.allowPrivateNetworkConfig,
|
|
});
|
|
const cached = clientFingerprints.get(cacheKey);
|
|
if (cached && cached.fingerprint === fingerprint) {
|
|
return cached.client;
|
|
}
|
|
|
|
const policyResult = resolveBlueBubblesClientSsrfPolicy({
|
|
baseUrl: resolved.baseUrl,
|
|
allowPrivateNetwork: resolved.allowPrivateNetwork,
|
|
allowPrivateNetworkConfig: resolved.allowPrivateNetworkConfig,
|
|
});
|
|
|
|
const client = new BlueBubblesClient({
|
|
accountId: cacheKey,
|
|
baseUrl: resolved.baseUrl,
|
|
password: resolved.password,
|
|
ssrfPolicy: policyResult.ssrfPolicy,
|
|
trustedHostname: policyResult.trustedHostname,
|
|
trustedHostnameIsPrivate: policyResult.trustedHostnameIsPrivate,
|
|
defaultTimeoutMs: opts.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
authStrategy,
|
|
});
|
|
clientFingerprints.set(cacheKey, { client, fingerprint });
|
|
return client;
|
|
}
|
|
|
|
/** Evict a cached client by account id. Called from account config reload paths. */
|
|
export function invalidateBlueBubblesClient(accountId?: string): void {
|
|
const key = accountId || DEFAULT_ACCOUNT_ID;
|
|
clientFingerprints.delete(key);
|
|
}
|
|
|
|
/** @internal Clear the whole client cache. Test helper. */
|
|
export function clearBlueBubblesClientCache(): void {
|
|
clientFingerprints.clear();
|
|
}
|
|
|
|
/**
|
|
* Build a BlueBubblesClient from a pre-resolved `{baseUrl, password,
|
|
* allowPrivateNetwork}` tuple, skipping the account/config resolution path.
|
|
*
|
|
* Used by low-level helpers (`probe.ts`, `catchup.ts`, `history.ts`, etc.)
|
|
* that are called with the resolved tuple rather than a full config bag.
|
|
* Migrated callers pass their existing booleans straight through — the
|
|
* three-mode policy resolution then runs exactly once here.
|
|
*
|
|
* Uncached — intended for short-lived callsites. Prefer `createBlueBubblesClient`
|
|
* when a `cfg` + `accountId` are available.
|
|
*/
|
|
export function createBlueBubblesClientFromParts(params: {
|
|
baseUrl: string;
|
|
password: string;
|
|
allowPrivateNetwork: boolean;
|
|
allowPrivateNetworkConfig?: boolean;
|
|
accountId?: string;
|
|
timeoutMs?: number;
|
|
authStrategy?: (password: string) => BlueBubblesAuthStrategy;
|
|
}): BlueBubblesClient {
|
|
const policyResult = resolveBlueBubblesClientSsrfPolicy({
|
|
baseUrl: params.baseUrl,
|
|
allowPrivateNetwork: params.allowPrivateNetwork,
|
|
allowPrivateNetworkConfig: params.allowPrivateNetworkConfig,
|
|
});
|
|
const authFactory = params.authStrategy ?? blueBubblesQueryStringAuth;
|
|
return new BlueBubblesClient({
|
|
accountId: params.accountId || DEFAULT_ACCOUNT_ID,
|
|
baseUrl: params.baseUrl,
|
|
password: params.password,
|
|
ssrfPolicy: policyResult.ssrfPolicy,
|
|
trustedHostname: policyResult.trustedHostname,
|
|
trustedHostnameIsPrivate: policyResult.trustedHostnameIsPrivate,
|
|
defaultTimeoutMs: params.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
authStrategy: authFactory(params.password),
|
|
});
|
|
}
|