kt-template-online-api/src/modules/qqbot/napcat/webui-gateway/infrastructure/qqbot-napcat-webui-gateway.client.ts

303 lines
9.8 KiB
TypeScript

import axios, { type AxiosRequestConfig } from 'axios';
import { HttpStatus, Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { throwVbenError } from '@/common';
const DEFAULT_GATEWAY_BASE_URL = 'http://127.0.0.1:48086';
const DEFAULT_GATEWAY_TIMEOUT_MS = 5000;
const GATEWAY_PUBLIC_SESSION_PREFIX = '/napcat-webui/session/';
const SESSION_ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/;
const SAFE_BOOTSTRAP_TICKET_PATTERN = /^[A-Za-z0-9._~-]+$/;
const UNSAFE_GATEWAY_RESULT_PATTERN =
/(\bCredential\b|\bBearer\s+\S+|webui[_-]?token|(?:^|[?&\s])(token|secret|password|credential|captcha)=|https?:\/\/|\/\/|127\.0\.0\.1|localhost|10\.|172\.(?:1[6-9]|2\d|3[01])\.|192\.168\.|:\d{2,5}\b|\bdocker\b|\bnas\b|\/vol\d\b|\/internal\/sessions\b)/i;
export type QqbotNapcatWebuiGatewayCreateSessionRequest = {
accountId: string;
adminUserId: string;
clientIp?: string;
containerId: string;
containerName: string;
selfId: string;
upstreamBaseUrl: string;
userAgent?: string;
webuiToken: string;
};
export type QqbotNapcatWebuiGatewayLifecycleRequest = {
adminUserId: string;
clientIp?: string;
sessionId: string;
userAgent?: string;
};
export type QqbotNapcatWebuiGatewaySessionResult = {
expiresAt: number;
iframeUrl: string;
sessionId: string;
};
export type QqbotNapcatWebuiGatewayLifecycleResult = Record<string, unknown>;
type GatewayResponseBody<T> = T | { data: T };
@Injectable()
export class QqbotNapcatWebuiGatewayClient {
/**
* Creates the internal Gateway client backed by bounded axios requests.
* @param configService - Nest config source for Gateway base URL, internal secret, and timeout.
*/
constructor(private readonly configService: ConfigService) {}
/**
* Creates a proxied NapCat WebUI session through the internal Gateway.
* @param input - Server-only target metadata, including WebUI token and upstream endpoint.
* @returns Browser-safe Gateway session metadata.
*/
async createSession(input: QqbotNapcatWebuiGatewayCreateSessionRequest) {
return this.validateSessionResult(
await this.post<QqbotNapcatWebuiGatewaySessionResult>(
'/internal/sessions',
input,
),
);
}
/**
* Refreshes one Gateway session heartbeat without exposing internal target data.
* @param input - Gateway session id plus Admin actor and request evidence.
* @returns Gateway lifecycle response body.
*/
heartbeat(input: QqbotNapcatWebuiGatewayLifecycleRequest) {
const { sessionId, ...data } = input;
return this.post<QqbotNapcatWebuiGatewayLifecycleResult>(
`/internal/sessions/${encodeURIComponent(sessionId)}/heartbeat`,
data,
);
}
/**
* Revokes one Gateway session without exposing internal target data.
* @param input - Gateway session id plus Admin actor and request evidence.
* @returns Gateway lifecycle response body.
*/
revoke(input: QqbotNapcatWebuiGatewayLifecycleRequest) {
const { sessionId, ...data } = input;
return this.post<QqbotNapcatWebuiGatewayLifecycleResult>(
`/internal/sessions/${encodeURIComponent(sessionId)}/revoke`,
data,
);
}
/**
* Sends one bounded POST request to the internal Gateway and strips raw axios errors.
* @param path - Internal Gateway path starting with `/internal`.
* @param data - Optional JSON payload sent only server-to-server.
* @returns Unwrapped Gateway response data.
*/
private async post<T>(path: string, data?: unknown): Promise<T> {
const config: AxiosRequestConfig = {
data,
headers: this.getHeaders(),
method: 'POST',
timeout: this.getTimeoutMs(),
url: this.buildUrl(path),
};
try {
const response = await axios.request<GatewayResponseBody<T>>(config);
return this.unwrapGatewayBody<T>(response.data);
} catch {
throwVbenError(
'NapCat WebUI Gateway 请求失败',
HttpStatus.BAD_GATEWAY,
);
}
}
/**
* Builds the complete Gateway URL from a configured base URL and fixed internal path.
* @param path - Internal Gateway path supplied by the service method.
* @returns Absolute Gateway URL without duplicate slashes.
*/
private buildUrl(path: string) {
return `${this.getBaseUrl()}${path.startsWith('/') ? path : `/${path}`}`;
}
/**
* Reads and normalizes the internal Gateway base URL.
* @returns Configured base URL or the local Gateway default.
*/
private getBaseUrl() {
const configured = this.configService.get<string>(
'NAPCAT_WEBUI_GATEWAY_INTERNAL_BASE_URL',
);
return (configured || DEFAULT_GATEWAY_BASE_URL).replace(/\/+$/, '');
}
/**
* Reads the optional Gateway shared secret and maps it to the internal header.
* @returns Header map when a secret is configured, otherwise undefined.
*/
private getHeaders() {
const secret = this.getInternalSecret();
return { 'x-kt-gateway-secret': secret };
}
/**
* Reads the required internal Gateway secret and fails closed when it is missing.
* @returns Configured non-empty shared secret.
*/
private getInternalSecret() {
const secret = String(
this.configService.get<string>('NAPCAT_WEBUI_GATEWAY_INTERNAL_SECRET') ||
'',
).trim();
if (!secret) {
throwVbenError(
'NapCat WebUI Gateway 内部密钥未配置',
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
return secret;
}
/**
* Reads and validates the Gateway request timeout.
* @returns Positive timeout in milliseconds.
*/
private getTimeoutMs() {
const configured = Number(
this.configService.get<string>('NAPCAT_WEBUI_GATEWAY_TIMEOUT_MS') || '',
);
return Number.isFinite(configured) && configured > 0
? configured
: DEFAULT_GATEWAY_TIMEOUT_MS;
}
/**
* Accepts both raw Gateway bodies and Vben-like `{ data }` wrappers.
* @param body - Axios response body returned by the internal Gateway.
* @returns The unwrapped data payload expected by API callers.
*/
private unwrapGatewayBody<T>(body: GatewayResponseBody<T>): T {
if (body && typeof body === 'object' && 'data' in body) {
return (body as { data: T }).data;
}
return body as T;
}
/**
* Validates the create-session result before returning it to Admin callers.
* @param result - Raw Gateway create-session result.
* @returns Browser-safe session result.
*/
private validateSessionResult(
result: QqbotNapcatWebuiGatewaySessionResult,
): QqbotNapcatWebuiGatewaySessionResult {
if (!result || typeof result !== 'object') {
this.throwInvalidSessionResult();
}
const sessionId = String(result.sessionId || '').trim();
if (!SESSION_ID_PATTERN.test(sessionId)) {
this.throwInvalidSessionResult();
}
if (!Number.isFinite(result.expiresAt)) {
this.throwInvalidSessionResult();
}
if (!this.isSafeIframeUrl(result.iframeUrl, sessionId)) {
this.throwInvalidSessionResult();
}
return {
expiresAt: result.expiresAt,
iframeUrl: result.iframeUrl,
sessionId,
};
}
/**
* Ensures the iframe URL is a relative Gateway-owned route with only an optional bootstrap ticket.
* @param iframeUrl - Raw iframe URL returned by Gateway.
* @param sessionId - Validated Gateway session id.
* @returns Whether the URL is safe for the browser response.
*/
private isSafeIframeUrl(iframeUrl: unknown, sessionId: string) {
if (typeof iframeUrl !== 'string' || iframeUrl.trim() !== iframeUrl) {
return false;
}
if (!iframeUrl.startsWith(GATEWAY_PUBLIC_SESSION_PREFIX)) return false;
if (iframeUrl.startsWith('//') || /^[a-z][a-z0-9+.-]*:/i.test(iframeUrl)) {
return false;
}
if (iframeUrl.includes('\\')) return false;
const queryStart = iframeUrl.indexOf('?');
const path =
queryStart >= 0 ? iframeUrl.slice(0, queryStart) : iframeUrl;
const query = queryStart >= 0 ? iframeUrl.slice(queryStart + 1) : '';
const expectedPrefix = `${GATEWAY_PUBLIC_SESSION_PREFIX}${sessionId}/`;
if (!path.startsWith(expectedPrefix)) return false;
const isBootstrapRoute = path === `${expectedPrefix}bootstrap`;
if (query.includes('?') || /%3f/i.test(query)) return false;
if (query && this.hasUnsafeGatewayEvidence(query)) return false;
const params = new URLSearchParams(query);
const entries = [...params.entries()];
const ticketValues = params.getAll('ticket');
if (query) {
if (!isBootstrapRoute) return false;
if (entries.length !== 1 || ticketValues.length !== 1) return false;
const [key, ticket] = entries[0];
if (key !== 'ticket') return false;
if (!SAFE_BOOTSTRAP_TICKET_PATTERN.test(ticket)) return false;
} else if (/ticket/i.test(iframeUrl)) {
return false;
}
const unsafeScanValue = query
? `${path}?ticket=`
: iframeUrl;
return !this.hasUnsafeGatewayEvidence(unsafeScanValue);
}
/**
* Detects host, secret, and internal-route evidence in Gateway browser-facing URLs.
* @param value - Candidate iframe URL with allowed bootstrap ticket value stripped.
* @returns Whether the string contains unsafe evidence.
*/
private hasUnsafeGatewayEvidence(value: string) {
const decoded = this.tryDecodeURIComponent(value);
return UNSAFE_GATEWAY_RESULT_PATTERN.test(decoded);
}
/**
* Decodes URL text for security scanning without leaking parsing errors to callers.
* @param value - URL text to decode.
* @returns Decoded value when possible, otherwise the original text.
*/
private tryDecodeURIComponent(value: string) {
try {
return decodeURIComponent(value);
} catch {
return value;
}
}
/**
* Throws the sanitized error used for invalid Gateway create-session responses.
*/
private throwInvalidSessionResult(): never {
return throwVbenError(
'NapCat WebUI Gateway 返回无效会话',
HttpStatus.BAD_GATEWAY,
);
}
}