/* This file is part of GNU Taler (C) 2019 Taler Systems S.A. GNU Taler is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation; either version 3, or (at your option) any later version. GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with GNU Taler; see the file COPYING. If not, see SPDX-License-Identifier: AGPL3.0-or-later */ /** * Imports. */ import { j2s, Logger, openPromise } from "@gnu-taler/taler-util"; import { TalerError } from "./errors.js"; import { encodeBody, getDefaultHeaders, HttpLibArgs } from "./http-common.js"; import { Headers, HttpRequestLibrary, HttpRequestOptions, HttpResponse, } from "./http.js"; import { RequestThrottler, TalerErrorCode, URL } from "./index.js"; import { QjsHttpResp, qjsOs } from "./qtart.js"; const logger = new Logger("http-impl.qtart.ts"); const textDecoder = new TextDecoder(); export class RequestTimeoutError extends Error { public constructor() { super("Request timed out"); Object.setPrototypeOf(this, RequestTimeoutError.prototype); } } export class RequestCancelledError extends Error { public constructor() { super("Request cancelled"); Object.setPrototypeOf(this, RequestCancelledError.prototype); } } /** * Implementation of the HTTP request library interface for node. */ export class HttpLibImpl implements HttpRequestLibrary { private throttle = new RequestThrottler(); private throttlingEnabled = true; private requireTls = false; constructor(args?: HttpLibArgs) { this.throttlingEnabled = args?.enableThrottling ?? true; this.requireTls = args?.requireTls ?? false; } /** * Set whether requests should be throttled. */ setThrottling(enabled: boolean): void { this.throttlingEnabled = enabled; } async fetch(url: string, opt?: HttpRequestOptions): Promise { const method = (opt?.method ?? "GET").toUpperCase(); logger.trace(`Requesting ${method} ${url}`); const parsedUrl = new URL(url); if (this.throttlingEnabled && this.throttle.applyThrottle(url)) { throw TalerError.fromDetail( TalerErrorCode.WALLET_HTTP_REQUEST_THROTTLED, { requestMethod: method, requestUrl: url, throttleStats: this.throttle.getThrottleStats(url), }, `request to origin ${parsedUrl.origin} was throttled`, ); } if (this.requireTls && parsedUrl.protocol !== "https:") { throw TalerError.fromDetail( TalerErrorCode.WALLET_NETWORK_ERROR, { requestMethod: method, requestUrl: url, }, `request to ${parsedUrl.origin} is not possible with protocol ${parsedUrl.protocol}`, ); } let data: ArrayBuffer | undefined = undefined; const requestHeadersMap = getDefaultHeaders(method); if (opt?.headers) { Object.entries(opt?.headers).forEach(([key, value]) => { if (value === undefined) return; requestHeadersMap[key] = value; }); } let headersList: string[] = []; for (let headerName of Object.keys(requestHeadersMap)) { headersList.push(`${headerName}: ${requestHeadersMap[headerName]}`); } if (method === "POST") { data = encodeBody(opt?.body); } const cancelPromCap = openPromise(); logger.trace(`calling qtart fetchHttp`); // Just like WHATWG fetch(), the qjs http client doesn't // really support cancellation, so cancellation here just // means that the result is ignored! const { promise: fetchProm, cancelFn } = qjsOs.fetchHttp(url, { method, data, headers: headersList, }); let timeoutHandle: any = undefined; let cancelCancelledHandler: (() => void) | undefined = undefined; if (opt?.timeout && opt.timeout.d_ms !== "forever") { timeoutHandle = setTimeout(() => { cancelPromCap.reject(new RequestTimeoutError()); }, opt.timeout.d_ms); } if (opt?.cancellationToken) { cancelCancelledHandler = opt.cancellationToken.onCancelled(() => { logger.trace(`cancelling quickjs request`); cancelFn(); cancelPromCap.reject(new RequestCancelledError()); }); } let res: QjsHttpResp; try { res = await Promise.race([fetchProm, cancelPromCap.promise]); } catch (e) { logger.trace(`got exception while waiting for qtart http response`); if (e instanceof RequestCancelledError) { throw TalerError.fromDetail( TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR, { requestUrl: url, requestMethod: method, httpStatusCode: 0, }, `Request cancelled`, ); } if (e instanceof RequestTimeoutError) { throw TalerError.fromDetail( TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR, { requestUrl: url, requestMethod: method, httpStatusCode: 0, }, `Request timed out (timeout ${opt?.timeout?.d_ms ?? "none"})`, ); } throw e; } if (logger.shouldLogTrace()) { logger.trace(`got qtart http response, status ${res.status}`); logger.trace(`response headers: ${j2s(res.headers)}`); } if (timeoutHandle != null) { clearTimeout(timeoutHandle); } if (cancelCancelledHandler != null) { cancelCancelledHandler(); } const headers: Headers = new Headers(); if (res.headers) { for (const headerStr of res.headers) { const splitPos = headerStr.indexOf(":"); if (splitPos < 0) { continue; } const headerName = headerStr.slice(0, splitPos).trim().toLowerCase(); let headerValue = headerStr.slice(splitPos + 1).trim(); // FIXME: This is a hotfix for the broken native networking implementation on Android // that sends the content type header value in square brackets if ( headerName === "content-type" && headerValue.startsWith("[") && headerValue.endsWith("]") ) { headerValue = headerValue.substring(1, headerValue.length - 2); } headers.set(headerName, headerValue); } } return { requestMethod: method, headers, async bytes() { return res.data; }, json() { const text = textDecoder.decode(res.data); return JSON.parse(text); }, async text() { const text = textDecoder.decode(res.data); return text; }, requestUrl: url, status: res.status, }; } }