mirror of
https://github.com/movixcorp/MovixOpenSource.git
synced 2026-08-09 08:47:32 +00:00
479 lines
20 KiB
JavaScript
479 lines
20 KiB
JavaScript
const dns = require('node:dns');
|
|
const https = require('node:https');
|
|
const net = require('node:net');
|
|
const { PassThrough } = require('node:stream');
|
|
const zlib = require('node:zlib');
|
|
const { SocksProxyAgent } = require('socks-proxy-agent');
|
|
const { createBundleRegistry } = require('./bundleRegistry');
|
|
const { KisskhError } = require('./errors');
|
|
const { computeKkey } = require('./kkey');
|
|
|
|
const MAX_JSON_BYTES = 2 * 1024 * 1024;
|
|
const MAX_REDIRECTS = 5;
|
|
const DEFAULT_TIMEOUT_MS = 10_000;
|
|
const KISSKH_METADATA_MAX_ATTEMPTS = 3;
|
|
const MAX_SEARCH_LENGTH = 200;
|
|
|
|
function getHeader(headers, name) {
|
|
if (!headers) return null;
|
|
if (typeof headers.get === 'function') return headers.get(name);
|
|
const key = Object.keys(headers).find((candidate) => candidate.toLowerCase() === name.toLowerCase());
|
|
return key ? headers[key] : null;
|
|
}
|
|
|
|
function isPublicIpv4(address) {
|
|
const octets = address.split('.').map(Number);
|
|
if (octets.length !== 4 || octets.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) return false;
|
|
const [a, b, c] = octets;
|
|
if (a === 0 || a === 10 || a === 127 || a >= 224) return false;
|
|
if (a === 100 && b >= 64 && b <= 127) return false;
|
|
if (a === 169 && b === 254) return false;
|
|
if (a === 172 && b >= 16 && b <= 31) return false;
|
|
if (a === 192 && (b === 0 || b === 168)) return false;
|
|
if (a === 198 && (b === 18 || b === 19)) return false;
|
|
if (a === 192 && b === 0 && c === 2) return false;
|
|
if (a === 198 && b === 51 && c === 100) return false;
|
|
if (a === 203 && b === 0 && c === 113) return false;
|
|
return true;
|
|
}
|
|
|
|
function expandIpv6(address) {
|
|
let input = address.toLowerCase().split('%')[0];
|
|
if (input.includes('.')) {
|
|
const lastColon = input.lastIndexOf(':');
|
|
const ipv4 = input.slice(lastColon + 1);
|
|
if (!net.isIPv4(ipv4)) return null;
|
|
const octets = ipv4.split('.').map(Number);
|
|
input = `${input.slice(0, lastColon)}:${((octets[0] << 8) | octets[1]).toString(16)}:${((octets[2] << 8) | octets[3]).toString(16)}`;
|
|
}
|
|
const halves = input.split('::');
|
|
if (halves.length > 2) return null;
|
|
const left = halves[0] ? halves[0].split(':') : [];
|
|
const right = halves.length === 2 && halves[1] ? halves[1].split(':') : [];
|
|
if (halves.length === 1 && left.length !== 8) return null;
|
|
const zeros = 8 - left.length - right.length;
|
|
if (zeros < 0 || (halves.length === 2 && zeros < 1)) return null;
|
|
const words = [...left, ...Array(zeros).fill('0'), ...right];
|
|
if (words.length !== 8 || words.some((word) => !/^[0-9a-f]{1,4}$/.test(word))) return null;
|
|
return words.map((word) => Number.parseInt(word, 16));
|
|
}
|
|
|
|
function isPublicIpv6(address) {
|
|
const words = expandIpv6(address);
|
|
if (!words) return false;
|
|
if (words.every((word) => word === 0)) return false;
|
|
if (words.slice(0, 7).every((word) => word === 0) && words[7] === 1) return false;
|
|
if ((words[0] & 0xfe00) === 0xfc00 || (words[0] & 0xffc0) === 0xfe80 || (words[0] & 0xff00) === 0xff00) return false;
|
|
if (words[0] === 0x2001 && words[1] === 0x0db8) return false;
|
|
if (words.slice(0, 5).every((word) => word === 0) && words[5] === 0xffff) {
|
|
return isPublicIpv4(`${words[6] >> 8}.${words[6] & 255}.${words[7] >> 8}.${words[7] & 255}`);
|
|
}
|
|
return (words[0] & 0xe000) === 0x2000;
|
|
}
|
|
|
|
function isPublicIp(address) {
|
|
if (net.isIPv4(address)) return isPublicIpv4(address);
|
|
if (net.isIPv6(address)) return isPublicIpv6(address);
|
|
return false;
|
|
}
|
|
|
|
function normalizeDnsAnswers(answers) {
|
|
return (Array.isArray(answers) ? answers : [answers])
|
|
.map((answer) => typeof answer === 'string' ? answer : answer?.address)
|
|
.filter(Boolean);
|
|
}
|
|
|
|
async function defaultResolveDns(hostname) {
|
|
return dns.promises.lookup(hostname, { all: true, verbatim: true });
|
|
}
|
|
|
|
function validateMetadataUrl(value, allowedHosts) {
|
|
if (typeof value !== 'string' || /[\r\n]/.test(value)) {
|
|
throw new KisskhError('provider_security', 'URL KissKH non autorisee');
|
|
}
|
|
let url;
|
|
try {
|
|
url = new URL(value);
|
|
} catch {
|
|
throw new KisskhError('provider_security', 'URL KissKH non autorisee');
|
|
}
|
|
if (url.protocol !== 'https:' || !allowedHosts.has(url.hostname)
|
|
|| (url.port && url.port !== '443') || url.username || url.password || url.hash) {
|
|
throw new KisskhError('provider_security', 'URL KissKH non autorisee');
|
|
}
|
|
return url;
|
|
}
|
|
|
|
function createPinnedLookup(addresses) {
|
|
const pins = addresses.map((address) => ({ address, family: net.isIP(address) }));
|
|
return (_hostname, options, callback) => {
|
|
const settings = typeof options === 'object' && options ? options : { family: Number(options) || 0 };
|
|
const candidates = settings.family ? pins.filter((pin) => pin.family === settings.family) : pins;
|
|
if (!candidates.length) {
|
|
const error = new Error('Adresse KissKH indisponible');
|
|
error.code = 'ENOTFOUND';
|
|
callback(error);
|
|
return;
|
|
}
|
|
if (settings.all) callback(null, candidates.map((pin) => ({ ...pin })));
|
|
else callback(null, candidates[0].address, candidates[0].family);
|
|
};
|
|
}
|
|
|
|
function createMetadataSocksAgent(proxy, timeout, Agent = SocksProxyAgent) {
|
|
if (!proxy || typeof proxy !== 'object') throw new TypeError('proxy KissKH invalide');
|
|
const proxyType = String(proxy.type || 'socks5').trim().toLowerCase().replace(/:$/, '');
|
|
const host = String(proxy.host || '').trim();
|
|
const port = Number(proxy.port);
|
|
if (!['socks', 'socks5', 'socks5h'].includes(proxyType)
|
|
|| !host || /[\s/?#@]/.test(host)
|
|
|| !Number.isSafeInteger(port) || port <= 0 || port > 65_535
|
|
|| typeof Agent !== 'function') {
|
|
throw new TypeError('proxy KissKH invalide');
|
|
}
|
|
const proxyHost = net.isIPv6(host) ? `[${host}]` : host;
|
|
const proxyUrl = new URL(`socks5h://${proxyHost}:${port}`);
|
|
if (proxy.auth) {
|
|
const auth = String(proxy.auth);
|
|
const separator = auth.indexOf(':');
|
|
proxyUrl.username = separator === -1 ? auth : auth.slice(0, separator);
|
|
proxyUrl.password = separator === -1 ? '' : auth.slice(separator + 1);
|
|
}
|
|
return new Agent(proxyUrl, { timeout });
|
|
}
|
|
|
|
function decoderFor(contentEncoding) {
|
|
const encoding = String(contentEncoding || 'identity').trim().toLowerCase();
|
|
if (!encoding || encoding === 'identity') return new PassThrough();
|
|
if (encoding === 'gzip' || encoding === 'x-gzip') return zlib.createGunzip();
|
|
if (encoding === 'deflate') return zlib.createInflate();
|
|
if (encoding === 'br') return zlib.createBrotliDecompress();
|
|
throw new KisskhError('provider_security', 'Encodage KissKH non autorise');
|
|
}
|
|
|
|
function createDefaultRequest(deps = {}) {
|
|
const requestHttps = deps.request || https.request;
|
|
const Agent = deps.SocksProxyAgent || SocksProxyAgent;
|
|
if (typeof requestHttps !== 'function' || typeof Agent !== 'function') {
|
|
throw new TypeError('transport KissKH invalide');
|
|
}
|
|
return (options) => new Promise((resolve, reject) => {
|
|
const url = new URL(options.url);
|
|
const maxCompressedBytes = options.maxCompressedBytes;
|
|
const maxDecompressedBytes = options.maxDecompressedBytes;
|
|
const contentChunks = [];
|
|
let compressedBytes = 0;
|
|
let decompressedBytes = 0;
|
|
let response;
|
|
let settled = false;
|
|
let timer;
|
|
if (!Array.isArray(options.addresses) || !options.addresses.length
|
|
|| options.addresses.some((address) => !isPublicIp(address))) {
|
|
throw new KisskhError('provider_security', 'Adresse KissKH non publique');
|
|
}
|
|
const destinationAddress = options.addresses[0];
|
|
const agent = options.proxy ? createMetadataSocksAgent(options.proxy, options.timeout, Agent) : null;
|
|
const headers = { ...options.headers };
|
|
for (const name of Object.keys(headers)) {
|
|
if (name.toLowerCase() === 'host') delete headers[name];
|
|
}
|
|
headers.Host = url.hostname;
|
|
const requestOptions = {
|
|
protocol: 'https:',
|
|
hostname: agent ? url.hostname : destinationAddress,
|
|
port: 443,
|
|
method: 'GET',
|
|
path: `${url.pathname}${url.search}`,
|
|
servername: url.hostname,
|
|
rejectUnauthorized: true,
|
|
minVersion: 'TLSv1.2',
|
|
agent: agent || false,
|
|
headers,
|
|
};
|
|
if (!agent) requestOptions.lookup = createPinnedLookup([destinationAddress]);
|
|
const finishReject = (error) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
if (timer) clearTimeout(timer);
|
|
if (response && !response.destroyed) response.destroy();
|
|
if (request && !request.destroyed) request.destroy();
|
|
reject(error);
|
|
};
|
|
const request = requestHttps(requestOptions, (incoming) => {
|
|
response = incoming;
|
|
const declared = getHeader(response.headers, 'content-length');
|
|
if (declared !== null && (!/^\d+$/.test(String(declared)) || Number(declared) > maxCompressedBytes)) {
|
|
finishReject(new KisskhError('provider_security', 'Reponse KissKH trop volumineuse'));
|
|
return;
|
|
}
|
|
let decoder;
|
|
try {
|
|
decoder = decoderFor(getHeader(response.headers, 'content-encoding'));
|
|
} catch (error) {
|
|
finishReject(error);
|
|
return;
|
|
}
|
|
response.on('data', (chunk) => {
|
|
compressedBytes += Buffer.byteLength(chunk);
|
|
if (compressedBytes > maxCompressedBytes) {
|
|
finishReject(new KisskhError('provider_security', 'Reponse KissKH trop volumineuse'));
|
|
}
|
|
});
|
|
response.on('error', finishReject);
|
|
decoder.on('data', (chunk) => {
|
|
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
decompressedBytes += buffer.length;
|
|
if (decompressedBytes > maxDecompressedBytes) {
|
|
finishReject(new KisskhError('provider_security', 'Reponse KissKH trop volumineuse'));
|
|
return;
|
|
}
|
|
contentChunks.push(buffer);
|
|
});
|
|
decoder.on('error', finishReject);
|
|
decoder.on('end', () => {
|
|
if (settled) return;
|
|
settled = true;
|
|
if (timer) clearTimeout(timer);
|
|
resolve({
|
|
status: Number(response.statusCode),
|
|
headers: response.headers,
|
|
data: Buffer.concat(contentChunks, decompressedBytes).toString('utf8'),
|
|
compressedBytes,
|
|
decompressedBytes,
|
|
});
|
|
});
|
|
response.pipe(decoder);
|
|
});
|
|
request.on('error', finishReject);
|
|
request.setTimeout(options.timeout, () => finishReject(new Error('metadata timeout')));
|
|
timer = setTimeout(() => finishReject(new Error('metadata timeout')), options.timeout);
|
|
request.end();
|
|
});
|
|
}
|
|
|
|
function readBoundedJson(response, { allowImagePng = false } = {}) {
|
|
for (const value of [response?.compressedBytes, response?.decompressedBytes]) {
|
|
if (value !== undefined && (!Number.isSafeInteger(value) || value < 0 || value > MAX_JSON_BYTES)) {
|
|
throw new KisskhError('provider_security', 'Reponse KissKH trop volumineuse');
|
|
}
|
|
}
|
|
const declared = getHeader(response?.headers, 'content-length');
|
|
if (declared !== null && declared !== undefined
|
|
&& (!/^\d+$/.test(String(declared)) || Number(declared) > MAX_JSON_BYTES)) {
|
|
throw new KisskhError('provider_security', 'Reponse KissKH trop volumineuse');
|
|
}
|
|
const contentType = getHeader(response?.headers, 'content-type');
|
|
const normalizedContentType = String(contentType || '');
|
|
if (contentType && !/^application\/json(?:\s*;|$)/i.test(normalizedContentType)
|
|
&& !(allowImagePng && /^image\/png(?:\s*;|$)/i.test(normalizedContentType))) {
|
|
throw new KisskhError('provider_security', 'Reponse KissKH invalide');
|
|
}
|
|
if (typeof response?.data === 'string' || Buffer.isBuffer(response?.data)) {
|
|
const buffer = Buffer.isBuffer(response.data) ? response.data : Buffer.from(response.data, 'utf8');
|
|
if (buffer.length > MAX_JSON_BYTES) throw new KisskhError('provider_security', 'Reponse KissKH trop volumineuse');
|
|
try {
|
|
return JSON.parse(buffer.toString('utf8'));
|
|
} catch {
|
|
throw new KisskhError('provider_unavailable', 'Reponse KissKH invalide');
|
|
}
|
|
}
|
|
let serialized;
|
|
try {
|
|
serialized = JSON.stringify(response?.data);
|
|
} catch {
|
|
throw new KisskhError('provider_unavailable', 'Reponse KissKH invalide');
|
|
}
|
|
if (serialized === undefined || Buffer.byteLength(serialized, 'utf8') > MAX_JSON_BYTES) {
|
|
throw new KisskhError('provider_security', 'Reponse KissKH trop volumineuse');
|
|
}
|
|
return response.data;
|
|
}
|
|
|
|
function invalidInput() {
|
|
return new KisskhError('invalid_input', 'Parametre KissKH invalide');
|
|
}
|
|
|
|
function assertPositiveId(value) {
|
|
if (!Number.isSafeInteger(value) || value <= 0) throw invalidInput();
|
|
}
|
|
|
|
function createKisskhClient(deps = {}) {
|
|
const allowedHostValues = deps.allowedHosts || ['kisskh.nl'];
|
|
if (!Array.isArray(allowedHostValues) || !allowedHostValues.length || allowedHostValues.length > 16) {
|
|
throw new TypeError('allowlist KissKH invalide');
|
|
}
|
|
const allowedHosts = new Set(allowedHostValues.map((host) => String(host).toLowerCase()));
|
|
if (allowedHosts.size !== allowedHostValues.length
|
|
|| [...allowedHosts].some((host) => !/^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/.test(host))) {
|
|
throw new TypeError('allowlist KissKH invalide');
|
|
}
|
|
const baseUrl = validateMetadataUrl(deps.baseUrl || 'https://kisskh.nl', allowedHosts);
|
|
if (baseUrl.pathname !== '/' || baseUrl.search) throw new TypeError('baseUrl KissKH invalide');
|
|
const proxyPolicy = deps.proxyPolicy;
|
|
if (!proxyPolicy || ['allowsDirectTransport', 'reserve', 'reserveGlobal', 'recordSuccess', 'recordFailure', 'record429', 'assertCircuitClosed']
|
|
.some((name) => typeof proxyPolicy[name] !== 'function')) {
|
|
throw new TypeError('policy proxy KissKH invalide');
|
|
}
|
|
const request = deps.request || createDefaultRequest();
|
|
const resolveDns = deps.resolveDns || defaultResolveDns;
|
|
const bundleRegistry = deps.bundleRegistry || createBundleRegistry();
|
|
const calculateKkey = deps.computeKkey || computeKkey;
|
|
const timeout = deps.timeout === undefined ? DEFAULT_TIMEOUT_MS : deps.timeout;
|
|
const maxAttempts = deps.maxAttempts === undefined ? KISSKH_METADATA_MAX_ATTEMPTS : deps.maxAttempts;
|
|
if (typeof request !== 'function' || typeof resolveDns !== 'function'
|
|
|| !Number.isSafeInteger(timeout) || timeout <= 0
|
|
|| !Number.isSafeInteger(maxAttempts) || maxAttempts <= 0 || maxAttempts > KISSKH_METADATA_MAX_ATTEMPTS
|
|
|| typeof bundleRegistry?.resolveApprovedAlgorithm !== 'function' || typeof calculateKkey !== 'function') {
|
|
throw new TypeError('client KissKH invalide');
|
|
}
|
|
const referer = `${baseUrl.origin}/`;
|
|
async function validateDns(url) {
|
|
let answers;
|
|
try {
|
|
answers = normalizeDnsAnswers(await resolveDns(url.hostname));
|
|
} catch {
|
|
throw new KisskhError('provider_unavailable', 'KissKH indisponible');
|
|
}
|
|
if (!answers.length || answers.some((address) => !isPublicIp(address))) {
|
|
throw new KisskhError('provider_security', 'Adresse KissKH non publique');
|
|
}
|
|
return answers;
|
|
}
|
|
|
|
async function requestWithRedirects(initialUrl, proxy) {
|
|
let current = validateMetadataUrl(initialUrl, allowedHosts);
|
|
for (let redirectCount = 0; redirectCount <= MAX_REDIRECTS; redirectCount += 1) {
|
|
const addresses = await validateDns(current);
|
|
await proxyPolicy.reserveGlobal();
|
|
await proxyPolicy.assertCircuitClosed();
|
|
const response = await request({
|
|
url: current.href,
|
|
method: 'GET',
|
|
headers: {
|
|
Accept: 'application/json',
|
|
'Accept-Encoding': 'gzip, deflate, br',
|
|
Referer: referer,
|
|
'User-Agent': 'Movix-KissKH-Metadata/1.0',
|
|
},
|
|
timeout,
|
|
redirect: 'manual',
|
|
maxCompressedBytes: MAX_JSON_BYTES,
|
|
maxDecompressedBytes: MAX_JSON_BYTES,
|
|
addresses,
|
|
proxy,
|
|
});
|
|
const status = Number(response?.status);
|
|
if (status >= 300 && status < 400) {
|
|
if (redirectCount === MAX_REDIRECTS) throw new KisskhError('provider_security', 'Trop de redirections KissKH');
|
|
const location = getHeader(response.headers, 'location');
|
|
if (typeof location !== 'string' || !location || /[\r\n]/.test(location)) {
|
|
throw new KisskhError('provider_security', 'Redirection KissKH invalide');
|
|
}
|
|
current = validateMetadataUrl(new URL(location, current).href, allowedHosts);
|
|
continue;
|
|
}
|
|
return response;
|
|
}
|
|
throw new KisskhError('provider_security', 'Trop de redirections KissKH');
|
|
}
|
|
|
|
async function execute(pathname) {
|
|
const allowImagePng = /^\/api\/DramaList\/Episode\/\d+\.png(?:\?|$)/.test(pathname);
|
|
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
|
|
const proxy = await proxyPolicy.reserve();
|
|
const useDirectTransport = !proxy && proxyPolicy.allowsDirectTransport();
|
|
if (!proxy && !useDirectTransport) {
|
|
throw new KisskhError('provider_unavailable', 'Proxy KissKH indisponible');
|
|
}
|
|
let response;
|
|
try {
|
|
response = await requestWithRedirects(
|
|
new URL(pathname, baseUrl).href,
|
|
useDirectTransport ? undefined : proxy,
|
|
);
|
|
} catch (error) {
|
|
if (error instanceof KisskhError
|
|
&& ['provider_rate_limited', 'provider_security'].includes(error.code)) throw error;
|
|
const kind = error?.code === 'ETIMEDOUT' || /timeout/i.test(String(error?.message || '')) ? 'timeout' : 'transport';
|
|
if (proxy) await proxyPolicy.recordFailure(proxy, kind);
|
|
if (attempt + 1 === maxAttempts) throw new KisskhError('provider_unavailable', 'KissKH indisponible');
|
|
continue;
|
|
}
|
|
const status = Number(response?.status);
|
|
if (status === 429) {
|
|
if (proxy) await proxyPolicy.record429(proxy, response.headers || {});
|
|
if (attempt + 1 === maxAttempts) {
|
|
throw new KisskhError('provider_rate_limited', 'KissKH temporairement limite');
|
|
}
|
|
continue;
|
|
}
|
|
if (status === 408 || (status >= 500 && status < 600)) {
|
|
if (proxy) await proxyPolicy.recordFailure(proxy, 'transport');
|
|
if (attempt + 1 === maxAttempts) throw new KisskhError('provider_unavailable', 'KissKH indisponible');
|
|
continue;
|
|
}
|
|
if (status < 200 || status >= 300) {
|
|
throw new KisskhError('provider_unavailable', 'KissKH indisponible');
|
|
}
|
|
const data = readBoundedJson(response, { allowImagePng });
|
|
if (proxy) await proxyPolicy.recordSuccess(proxy);
|
|
return data;
|
|
}
|
|
throw new KisskhError('provider_unavailable', 'KissKH indisponible');
|
|
}
|
|
|
|
async function episodePath(context, episodeId) {
|
|
assertPositiveId(episodeId);
|
|
let algorithm;
|
|
try {
|
|
algorithm = await bundleRegistry.resolveApprovedAlgorithm();
|
|
} catch (error) {
|
|
if (error instanceof KisskhError) throw error;
|
|
throw new KisskhError('provider_unavailable', 'KissKH indisponible');
|
|
}
|
|
let kkey;
|
|
try {
|
|
kkey = calculateKkey({ context, episodeId, algorithm });
|
|
} catch {
|
|
throw new KisskhError('provider_changed', 'Version KissKH non approuvee');
|
|
}
|
|
if (context === 'episode') {
|
|
return `/api/DramaList/Episode/${episodeId}.png?err=false&ts=null&time=null&kkey=${encodeURIComponent(kkey)}`;
|
|
}
|
|
return `/api/Sub/${episodeId}?kkey=${encodeURIComponent(kkey)}`;
|
|
}
|
|
|
|
return Object.freeze({
|
|
async list(page = 1, pageSize = 100, type = 0) {
|
|
if (!Number.isSafeInteger(page) || page <= 0
|
|
|| !Number.isSafeInteger(pageSize) || pageSize <= 0 || pageSize > 100
|
|
|| !Number.isSafeInteger(type) || type < 0 || type > 4) throw invalidInput();
|
|
const search = new URLSearchParams({
|
|
type: String(type),
|
|
page: String(page),
|
|
pageSize: String(pageSize),
|
|
});
|
|
return execute(`/api/DramaList/List?${search}`);
|
|
},
|
|
async search(query, type = 0) {
|
|
if (typeof query !== 'string' || !query.trim() || query.length > MAX_SEARCH_LENGTH || /[\r\n]/.test(query)) {
|
|
throw invalidInput();
|
|
}
|
|
if (!Number.isSafeInteger(type) || type < 0 || type > 4) throw invalidInput();
|
|
const search = new URLSearchParams({ q: query.trim(), type: String(type) });
|
|
return execute(`/api/DramaList/Search?${search}`);
|
|
},
|
|
async getDrama(dramaId) {
|
|
assertPositiveId(dramaId);
|
|
return execute(`/api/DramaList/Drama/${dramaId}?isq=false`);
|
|
},
|
|
async getEpisode(episodeId) {
|
|
return execute(await episodePath('episode', episodeId));
|
|
},
|
|
async getSubtitles(episodeId) {
|
|
return execute(await episodePath('sub', episodeId));
|
|
},
|
|
});
|
|
}
|
|
|
|
module.exports = { createDefaultRequest, createKisskhClient };
|