feat: 增加QQBot插件受限重定向能力
This commit is contained in:
parent
f21d31c4e4
commit
fc12e608a6
@ -9,6 +9,7 @@ import type { QqbotPluginPackageDescriptor } from '@/modules/qqbot/plugin-platfo
|
|||||||
import {
|
import {
|
||||||
QqbotPluginHttpClientService,
|
QqbotPluginHttpClientService,
|
||||||
type QqbotPluginHttpClientRequest,
|
type QqbotPluginHttpClientRequest,
|
||||||
|
type QqbotPluginResolveRedirectRequest,
|
||||||
} from '../sdk/plugin-http-client.service';
|
} from '../sdk/plugin-http-client.service';
|
||||||
import type {
|
import type {
|
||||||
QqbotPluginHostCallRequest,
|
QqbotPluginHostCallRequest,
|
||||||
@ -108,6 +109,8 @@ export class QqbotPluginHostBridgeService {
|
|||||||
return this.httpClient.requestBuffer(getHttpRequestOptions(args));
|
return this.httpClient.requestBuffer(getHttpRequestOptions(args));
|
||||||
case 'requestJson':
|
case 'requestJson':
|
||||||
return this.httpClient.requestJson(getHttpRequestOptions(args));
|
return this.httpClient.requestJson(getHttpRequestOptions(args));
|
||||||
|
case 'resolveRedirect':
|
||||||
|
return this.httpClient.resolveRedirect(getRedirectRequestOptions(args));
|
||||||
case 'sendText':
|
case 'sendText':
|
||||||
return this.sendService.sendText(
|
return this.sendService.sendText(
|
||||||
args.input as Parameters<QqbotSendService['sendText']>[0],
|
args.input as Parameters<QqbotSendService['sendText']>[0],
|
||||||
@ -350,6 +353,21 @@ function getHttpRequestOptions(
|
|||||||
return request;
|
return request;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalizes host-call redirect options from `{ input }`, `{ options }`, or raw option arguments.
|
||||||
|
* @param args - Worker-supplied host-call arguments.
|
||||||
|
* @returns Redirect resolver options safe to pass to QqbotPluginHttpClientService.
|
||||||
|
*/
|
||||||
|
function getRedirectRequestOptions(
|
||||||
|
args: Record<string, unknown>,
|
||||||
|
): QqbotPluginResolveRedirectRequest {
|
||||||
|
const candidate = args.input || args.options || args;
|
||||||
|
if (!isRecord(candidate)) {
|
||||||
|
throw new Error('Plugin host redirect options must be an object');
|
||||||
|
}
|
||||||
|
return candidate as QqbotPluginResolveRedirectRequest;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks whether an unknown value can be treated as a record of host-call options.
|
* Checks whether an unknown value can be treated as a record of host-call options.
|
||||||
* @param value - Worker-supplied value that may contain named host-call arguments.
|
* @param value - Worker-supplied value that may contain named host-call arguments.
|
||||||
|
|||||||
@ -90,6 +90,7 @@ const HOST_ARGUMENT_MAPPERS: Record<string, HostArgumentMapper> = {
|
|||||||
renameFile: (from, to) => ({ from, to }),
|
renameFile: (from, to) => ({ from, to }),
|
||||||
requestBuffer: (options) => ({ options }),
|
requestBuffer: (options) => ({ options }),
|
||||||
requestJson: (options) => ({ options }),
|
requestJson: (options) => ({ options }),
|
||||||
|
resolveRedirect: (input) => ({ input }),
|
||||||
sendText: (input) => ({ input }),
|
sendText: (input) => ({ input }),
|
||||||
sleep: (ms) => ({ ms }),
|
sleep: (ms) => ({ ms }),
|
||||||
unbindEventPlugin: (selfId, pluginKey) => ({ pluginKey, selfId }),
|
unbindEventPlugin: (selfId, pluginKey) => ({ pluginKey, selfId }),
|
||||||
|
|||||||
@ -14,8 +14,52 @@ export type QqbotPluginHttpClientRequest = {
|
|||||||
url: string | URL;
|
url: string | URL;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type QqbotPluginResolveRedirectRequest = {
|
||||||
|
context?: string;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
maxRedirects?: number;
|
||||||
|
timeoutMessage?: string;
|
||||||
|
timeoutMs?: number;
|
||||||
|
url: string | URL;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type QqbotPluginRedirectResult = {
|
||||||
|
finalUrl: string;
|
||||||
|
redirects: string[];
|
||||||
|
};
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class QqbotPluginHttpClientService {
|
export class QqbotPluginHttpClientService {
|
||||||
|
/**
|
||||||
|
* Resolves an HTTP(S) URL through bounded 3xx redirects for plugin host calls without platform-specific URL rules.
|
||||||
|
* @param input - Initial URL, optional headers, timeout, and redirect limit supplied by plugin runtime code.
|
||||||
|
* @returns Final URL and the ordered chain of redirect target URLs.
|
||||||
|
*/
|
||||||
|
async resolveRedirect(
|
||||||
|
input: QqbotPluginResolveRedirectRequest,
|
||||||
|
): Promise<QqbotPluginRedirectResult> {
|
||||||
|
let currentUrl = normalizePluginHttpUrl(input.url);
|
||||||
|
const redirects: string[] = [];
|
||||||
|
const maxRedirects = normalizeMaxRedirects(input.maxRedirects);
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const location = await this.requestRedirectLocation(currentUrl, input);
|
||||||
|
if (!location) {
|
||||||
|
return {
|
||||||
|
finalUrl: currentUrl.toString(),
|
||||||
|
redirects,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (redirects.length >= maxRedirects) {
|
||||||
|
throw new Error('插件 HTTP 重定向超过上限');
|
||||||
|
}
|
||||||
|
|
||||||
|
currentUrl = normalizePluginHttpUrl(new URL(location, currentUrl));
|
||||||
|
redirects.push(currentUrl.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 执行 QQBot 插件平台流程。
|
* 执行 QQBot 插件平台流程。
|
||||||
* @param input - input 输入;使用 `invalidJsonMessage`、`context` 字段生成结果。
|
* @param input - input 输入;使用 `invalidJsonMessage`、`context` 字段生成结果。
|
||||||
@ -145,6 +189,62 @@ export class QqbotPluginHttpClientService {
|
|||||||
request.end();
|
request.end();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Requests one URL and returns its redirect Location after the response body is drained.
|
||||||
|
* @param url - Validated HTTP(S) URL to request.
|
||||||
|
* @param input - Headers and timeout options shared across the redirect chain.
|
||||||
|
* @returns Redirect Location header when the response is 3xx, otherwise `undefined`.
|
||||||
|
*/
|
||||||
|
private requestRedirectLocation(
|
||||||
|
url: URL,
|
||||||
|
input: QqbotPluginResolveRedirectRequest,
|
||||||
|
): Promise<string | undefined> {
|
||||||
|
const timeoutMs = input.timeoutMs || 8000;
|
||||||
|
const context = input.context || '插件 HTTP 重定向';
|
||||||
|
|
||||||
|
return new Promise<string | undefined>((resolve, reject) => {
|
||||||
|
const client = getPluginHttpModule(url);
|
||||||
|
const request = client.request(
|
||||||
|
url,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Accept: '*/*',
|
||||||
|
'User-Agent': 'kt-template-online-api/qqbot-plugin',
|
||||||
|
...(input.headers || {}),
|
||||||
|
},
|
||||||
|
method: 'GET',
|
||||||
|
timeout: timeoutMs,
|
||||||
|
},
|
||||||
|
(response) => {
|
||||||
|
const statusCode = response.statusCode || 0;
|
||||||
|
const location = response.headers.location;
|
||||||
|
|
||||||
|
response.on('error', reject);
|
||||||
|
response.on('end', () => {
|
||||||
|
if (
|
||||||
|
statusCode >= 300 &&
|
||||||
|
statusCode < 400 &&
|
||||||
|
typeof location === 'string' &&
|
||||||
|
location.trim()
|
||||||
|
) {
|
||||||
|
resolve(location);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve(undefined);
|
||||||
|
});
|
||||||
|
response.resume();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
request.on('timeout', () => {
|
||||||
|
request.destroy(
|
||||||
|
new Error(input.timeoutMessage || `${context}请求超时`),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
request.on('error', reject);
|
||||||
|
request.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -160,3 +260,37 @@ function createPluginHttpError(message: string, statusCode: number) {
|
|||||||
statusCode,
|
statusCode,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a URL and enforces the plugin redirect resolver's HTTP(S)-only protocol boundary.
|
||||||
|
* @param value - Worker-supplied string or URL value.
|
||||||
|
* @returns URL instance safe to request with node:http or node:https.
|
||||||
|
*/
|
||||||
|
function normalizePluginHttpUrl(value: string | URL) {
|
||||||
|
const url = value instanceof URL ? value : new URL(value);
|
||||||
|
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
||||||
|
throw new Error('插件 HTTP 重定向仅支持 http/https');
|
||||||
|
}
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalizes the redirect limit used to stop infinite or overly long redirect chains.
|
||||||
|
* @param value - Optional max redirect count supplied by plugin runtime code.
|
||||||
|
* @returns Non-negative redirect count limit.
|
||||||
|
*/
|
||||||
|
function normalizeMaxRedirects(value: number | undefined) {
|
||||||
|
const maxRedirects = value ?? 5;
|
||||||
|
return Number.isFinite(maxRedirects) && maxRedirects >= 0
|
||||||
|
? Math.floor(maxRedirects)
|
||||||
|
: 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Selects the Node HTTP module for a validated redirect URL.
|
||||||
|
* @param url - HTTP(S) URL accepted by `normalizePluginHttpUrl`.
|
||||||
|
* @returns Node request module matching the URL protocol.
|
||||||
|
*/
|
||||||
|
function getPluginHttpModule(url: URL) {
|
||||||
|
return url.protocol === 'http:' ? http : https;
|
||||||
|
}
|
||||||
|
|||||||
@ -13,7 +13,11 @@ describe('QQBot plugin host bridge', () => {
|
|||||||
let tempRoot: string;
|
let tempRoot: string;
|
||||||
let bridge: QqbotPluginHostBridgeService;
|
let bridge: QqbotPluginHostBridgeService;
|
||||||
let configValues: Record<string, string | undefined>;
|
let configValues: Record<string, string | undefined>;
|
||||||
let httpClient: { requestBuffer: jest.Mock; requestJson: jest.Mock };
|
let httpClient: {
|
||||||
|
requestBuffer: jest.Mock;
|
||||||
|
requestJson: jest.Mock;
|
||||||
|
resolveRedirect: jest.Mock;
|
||||||
|
};
|
||||||
let accountService: {
|
let accountService: {
|
||||||
bindEventPlugin: jest.Mock;
|
bindEventPlugin: jest.Mock;
|
||||||
getBoundEventPluginKeys: jest.Mock;
|
getBoundEventPluginKeys: jest.Mock;
|
||||||
@ -38,6 +42,7 @@ describe('QQBot plugin host bridge', () => {
|
|||||||
httpClient = {
|
httpClient = {
|
||||||
requestBuffer: jest.fn(),
|
requestBuffer: jest.fn(),
|
||||||
requestJson: jest.fn().mockResolvedValue({ ok: true }),
|
requestJson: jest.fn().mockResolvedValue({ ok: true }),
|
||||||
|
resolveRedirect: jest.fn(),
|
||||||
};
|
};
|
||||||
accountService = {
|
accountService = {
|
||||||
bindEventPlugin: jest.fn().mockResolvedValue(true),
|
bindEventPlugin: jest.fn().mockResolvedValue(true),
|
||||||
@ -123,6 +128,33 @@ describe('QQBot plugin host bridge', () => {
|
|||||||
expect(httpClient.requestJson).toHaveBeenCalledWith(options);
|
expect(httpClient.requestJson).toHaveBeenCalledWith(options);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('delegates resolveRedirect host calls to the plugin HTTP client', async () => {
|
||||||
|
const options = {
|
||||||
|
maxRedirects: 3,
|
||||||
|
timeoutMs: 1000,
|
||||||
|
url: 'https://b23.tv/abc123',
|
||||||
|
};
|
||||||
|
httpClient.resolveRedirect.mockResolvedValue({
|
||||||
|
finalUrl: 'https://www.bilibili.com/video/BV1xx411c7mD',
|
||||||
|
redirects: ['https://www.bilibili.com/video/BV1xx411c7mD'],
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
bridge.handleHostCall(createDescriptor(), {
|
||||||
|
args: { input: options },
|
||||||
|
method: 'resolveRedirect',
|
||||||
|
pluginKey: 'sample',
|
||||||
|
}),
|
||||||
|
).resolves.toEqual({
|
||||||
|
ok: true,
|
||||||
|
value: {
|
||||||
|
finalUrl: 'https://www.bilibili.com/video/BV1xx411c7mD',
|
||||||
|
redirects: ['https://www.bilibili.com/video/BV1xx411c7mD'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(httpClient.resolveRedirect).toHaveBeenCalledWith(options);
|
||||||
|
});
|
||||||
|
|
||||||
it('delegates event binding and text sends to core QQBot services', async () => {
|
it('delegates event binding and text sends to core QQBot services', async () => {
|
||||||
const descriptor = createDescriptor();
|
const descriptor = createDescriptor();
|
||||||
const sendInput = {
|
const sendInput = {
|
||||||
|
|||||||
@ -0,0 +1,70 @@
|
|||||||
|
import * as http from 'node:http';
|
||||||
|
import type { AddressInfo } from 'node:net';
|
||||||
|
import { QqbotPluginHttpClientService } from '../../../../src/modules/qqbot/plugin-platform/infrastructure/integration/sdk/plugin-http-client.service';
|
||||||
|
|
||||||
|
describe('QQBot plugin HTTP client redirect resolver', () => {
|
||||||
|
let server: http.Server;
|
||||||
|
let baseUrl: string;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
server = http.createServer((request, response) => {
|
||||||
|
if (request.url === '/short') {
|
||||||
|
response.writeHead(302, { Location: '/video/BV1xx411c7mD' });
|
||||||
|
response.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (request.url === '/loop') {
|
||||||
|
response.writeHead(302, { Location: '/loop2' });
|
||||||
|
response.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (request.url === '/loop2') {
|
||||||
|
response.writeHead(302, { Location: '/loop' });
|
||||||
|
response.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
response.writeHead(200, { 'Content-Type': 'text/plain' });
|
||||||
|
response.end('ok');
|
||||||
|
});
|
||||||
|
await new Promise<void>((resolveListen) => {
|
||||||
|
server.listen(0, '127.0.0.1', resolveListen);
|
||||||
|
});
|
||||||
|
const address = server.address() as AddressInfo;
|
||||||
|
baseUrl = `http://127.0.0.1:${address.port}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await new Promise<void>((resolveClose) => server.close(() => resolveClose()));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the final URL and redirect chain for relative Location headers', async () => {
|
||||||
|
await expect(
|
||||||
|
new QqbotPluginHttpClientService().resolveRedirect({
|
||||||
|
maxRedirects: 3,
|
||||||
|
timeoutMs: 1000,
|
||||||
|
url: `${baseUrl}/short`,
|
||||||
|
}),
|
||||||
|
).resolves.toEqual({
|
||||||
|
finalUrl: `${baseUrl}/video/BV1xx411c7mD`,
|
||||||
|
redirects: [`${baseUrl}/video/BV1xx411c7mD`],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects redirect loops after the configured limit', async () => {
|
||||||
|
await expect(
|
||||||
|
new QqbotPluginHttpClientService().resolveRedirect({
|
||||||
|
maxRedirects: 1,
|
||||||
|
timeoutMs: 1000,
|
||||||
|
url: `${baseUrl}/loop`,
|
||||||
|
}),
|
||||||
|
).rejects.toThrow('插件 HTTP 重定向超过上限');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects non-http protocols before requesting them', async () => {
|
||||||
|
await expect(
|
||||||
|
new QqbotPluginHttpClientService().resolveRedirect({
|
||||||
|
url: 'file:///etc/passwd',
|
||||||
|
}),
|
||||||
|
).rejects.toThrow('插件 HTTP 重定向仅支持 http/https');
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user