diff --git a/src/apps/napcat-webui-gateway/infrastructure/proxy/napcat-webui-proxy.service.ts b/src/apps/napcat-webui-gateway/infrastructure/proxy/napcat-webui-proxy.service.ts index ab81f78..80756cd 100644 --- a/src/apps/napcat-webui-gateway/infrastructure/proxy/napcat-webui-proxy.service.ts +++ b/src/apps/napcat-webui-gateway/infrastructure/proxy/napcat-webui-proxy.service.ts @@ -5,6 +5,7 @@ import type { NextFunction, Request, Response } from 'express'; import { createProxyMiddleware, fixRequestBody, + responseInterceptor, type RequestHandler, } from 'http-proxy-middleware'; import { NapcatWebuiGatewaySessionService } from '../../application/napcat-webui-gateway-session.service'; @@ -12,6 +13,7 @@ import type { NapcatWebuiGatewaySession } from '../../domain/napcat-webui-gatewa import { NapcatWebuiCredentialClient } from '../napcat-webui-credential.client'; const GATEWAY_WEBUI_PREFIX = '/napcat-webui/session'; +const TEXT_REWRITE_EXTENSIONS = ['.css', '.html', '.js', '.mjs'] as const; const STRIPPED_UPSTREAM_HEADERS = [ 'authorization', 'cookie', @@ -35,6 +37,11 @@ type CookiePathRewriteInput = { sessionId: string; }; +type RewriteTextResponseInput = { + body: string; + sessionId: string; +}; + /** * Normalizes a Gateway route tail into a safe upstream pathname. * @param input - Route parameter from Nest/path-to-regexp. @@ -116,6 +123,23 @@ export function buildGatewayCookiePathRewrite(input: CookiePathRewriteInput) { }; } +/** + * Rewrites absolute NapCat WebUI browser paths so assets, APIs, and plugin resources stay under the active Gateway session. + * @param input - Text response body from NapCat plus the Gateway session id that owns the browser lifecycle. + * @returns Body text whose absolute NapCat root paths point back through the Gateway session prefix. + */ +export function rewriteNapcatTextResponse(input: RewriteTextResponseInput) { + const gatewayWebuiPrefix = `${GATEWAY_WEBUI_PREFIX}/${encodeURIComponent( + input.sessionId, + )}/webui`; + + return input.body.replace( + /(^|[\s"'`(=,:])\/(webui|api|File|plugin)(?=\/|[?#"'`)]|$)/g, + (_match, leader: string, root: string) => + `${leader}${gatewayWebuiPrefix}/${root}`, + ); +} + /** * Decodes path text until stable so nested encoded traversal cannot pass through. * @param value - Raw route path text. @@ -157,6 +181,28 @@ function toGatewayRedirectLocation( } } +/** + * Checks whether a proxied upstream path may contain browser-executable absolute paths that need Gateway prefix rewriting. + * @param upstreamPath - Sanitized upstream pathname with an optional query string. + * @returns Whether the response should be buffered for text rewriting. + */ +export function shouldRewriteNapcatTextResponse(upstreamPath: string) { + const pathname = new URL(upstreamPath, 'http://gateway.local').pathname; + const filename = pathname.split('/').pop() || ''; + const extensionIndex = filename.lastIndexOf('.'); + const extension = + extensionIndex >= 0 ? filename.slice(extensionIndex).toLowerCase() : ''; + + if (TEXT_REWRITE_EXTENSIONS.includes(extension as never)) { + return true; + } + + return ( + pathname === '/webui' || + (pathname.startsWith('/webui/') && extensionIndex < 0) + ); +} + @Injectable() export class NapcatWebuiProxyService { /** @@ -190,7 +236,11 @@ export class NapcatWebuiProxyService { this.stripBrowserHeaders(req); req.url = upstreamPath; - const proxy = this.createProxy(session, credential); + const proxy = this.createProxy( + session, + credential, + shouldRewriteNapcatTextResponse(upstreamPath), + ); return proxy(req, res, next); } @@ -240,6 +290,7 @@ export class NapcatWebuiProxyService { private createProxy( session: NapcatWebuiGatewaySession, credential: string, + rewriteTextResponse = false, ): RequestHandler { return createProxyMiddleware({ changeOrigin: true, @@ -259,23 +310,44 @@ export class NapcatWebuiProxyService { proxyReq.removeHeader('cookie'); proxyReq.setHeader('Authorization', `Bearer ${credential}`); }, - proxyRes: (proxyRes) => { - const location = proxyRes.headers.location; - if (typeof location === 'string') { - proxyRes.headers.location = rewriteNapcatLocationHeader({ - location, - sessionId: session.sessionId, - upstreamBaseUrl: session.upstreamBaseUrl, - }); - } - }, + proxyRes: rewriteTextResponse + ? responseInterceptor(async (responseBuffer, proxyRes) => { + this.rewriteLocationHeader(proxyRes.headers, session); + return rewriteNapcatTextResponse({ + body: responseBuffer.toString('utf8'), + sessionId: session.sessionId, + }); + }) + : (proxyRes) => { + this.rewriteLocationHeader(proxyRes.headers, session); + }, }, secure: false, + selfHandleResponse: rewriteTextResponse, target: session.upstreamBaseUrl, ws: true, }); } + /** + * Rewrites one upstream Location header in-place so browser redirects remain inside the Gateway route. + * @param headers - Upstream response headers exposed by HPM. + * @param session - Active Gateway session whose public prefix should own redirects. + */ + private rewriteLocationHeader( + headers: Record, + session: NapcatWebuiGatewaySession, + ) { + const location = headers.location; + if (typeof location === 'string') { + headers.location = rewriteNapcatLocationHeader({ + location, + sessionId: session.sessionId, + upstreamBaseUrl: session.upstreamBaseUrl, + }); + } + } + /** * Builds the upstream URL path while preserving the original query string. * @param proxyPath - Gateway route tail to sanitize. diff --git a/test/modules/qqbot/napcat-webui-gateway/gateway-proxy.service.spec.ts b/test/modules/qqbot/napcat-webui-gateway/gateway-proxy.service.spec.ts new file mode 100644 index 0000000..3c27128 --- /dev/null +++ b/test/modules/qqbot/napcat-webui-gateway/gateway-proxy.service.spec.ts @@ -0,0 +1,67 @@ +import * as proxyModule from '../../../../src/apps/napcat-webui-gateway/infrastructure/proxy/napcat-webui-proxy.service'; + +describe('NapcatWebuiProxyService response rewriting', () => { + it('keeps NapCat WebUI absolute resource and API paths inside the Gateway session', () => { + const rewriteNapcatTextResponse = ( + proxyModule as { + rewriteNapcatTextResponse?: (input: { + body: string; + sessionId: string; + }) => string; + } + ).rewriteNapcatTextResponse; + + expect(typeof rewriteNapcatTextResponse).toBe('function'); + + const rewritten = rewriteNapcatTextResponse?.({ + body: [ + '', + '', + '', + 'url("/webui/fonts/CustomFont.woff")', + ].join(''), + sessionId: 'sess_1', + }); + + expect(rewritten).toContain( + 'src="/napcat-webui/session/sess_1/webui/webui/assets/index.js"', + ); + expect(rewritten).toContain( + 'href="/napcat-webui/session/sess_1/webui/webui/assets/index.css"', + ); + expect(rewritten).toContain( + 'baseURL="/napcat-webui/session/sess_1/webui/api"', + ); + expect(rewritten).toContain( + 'file="/napcat-webui/session/sess_1/webui/File/font/upload/webui"', + ); + expect(rewritten).toContain( + 'url("/napcat-webui/session/sess_1/webui/webui/fonts/CustomFont.woff")', + ); + expect(rewritten).not.toContain('"/webui/assets'); + expect(rewritten).not.toContain('"/api"'); + }); + + it('does not buffer NapCat API or SSE responses for text rewriting', () => { + const shouldRewriteNapcatTextResponse = ( + proxyModule as { + shouldRewriteNapcatTextResponse?: (upstreamPath: string) => boolean; + } + ).shouldRewriteNapcatTextResponse; + + expect(typeof shouldRewriteNapcatTextResponse).toBe('function'); + expect(shouldRewriteNapcatTextResponse?.('/api/Log/GetLogRealTime')).toBe( + false, + ); + expect( + shouldRewriteNapcatTextResponse?.('/api/base/GetSysStatusRealTime'), + ).toBe(false); + expect(shouldRewriteNapcatTextResponse?.('/webui')).toBe(true); + expect(shouldRewriteNapcatTextResponse?.('/webui/assets/index.js')).toBe( + true, + ); + expect( + shouldRewriteNapcatTextResponse?.('/webui/fonts/CustomFont.woff'), + ).toBe(false); + }); +});