From 7e52972830bbed90adb89d0b33250fc8ab687c59 Mon Sep 17 00:00:00 2001 From: sunlei Date: Wed, 24 Jun 2026 18:39:58 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8DNapCat=20WebUI?= =?UTF-8?q?=E8=B7=B3=E8=BD=AC=E8=B7=AF=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../proxy/napcat-webui-proxy.service.ts | 45 ++++++-- .../proxy-rewrite.spec.ts | 108 ++++++++++++++++++ 2 files changed, 142 insertions(+), 11 deletions(-) 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 80756cd..56ec959 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 @@ -310,17 +310,10 @@ export class NapcatWebuiProxyService { proxyReq.removeHeader('cookie'); proxyReq.setHeader('Authorization', `Bearer ${credential}`); }, - 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); - }, + proxyRes: this.createProxyResponseHandler( + session, + rewriteTextResponse, + ), }, secure: false, selfHandleResponse: rewriteTextResponse, @@ -329,6 +322,36 @@ export class NapcatWebuiProxyService { }); } + /** + * Builds the upstream response handler and rewrites redirect headers before HPM copies them to Express. + * @param session - Active Gateway session whose route prefix owns browser redirects. + * @param rewriteTextResponse - Whether this response is buffered for body path rewriting. + * @returns HPM proxy response handler. + */ + private createProxyResponseHandler( + session: NapcatWebuiGatewaySession, + rewriteTextResponse: boolean, + ) { + if (!rewriteTextResponse) { + return (proxyRes: IncomingMessage) => { + this.rewriteLocationHeader(proxyRes.headers, session); + }; + } + + const interceptTextResponse = responseInterceptor( + async (responseBuffer) => + rewriteNapcatTextResponse({ + body: responseBuffer.toString('utf8'), + sessionId: session.sessionId, + }), + ); + + return (proxyRes: IncomingMessage, req: Request, res: Response) => { + this.rewriteLocationHeader(proxyRes.headers, session); + return interceptTextResponse(proxyRes, req, res); + }; + } + /** * Rewrites one upstream Location header in-place so browser redirects remain inside the Gateway route. * @param headers - Upstream response headers exposed by HPM. diff --git a/test/apps/napcat-webui-gateway/proxy-rewrite.spec.ts b/test/apps/napcat-webui-gateway/proxy-rewrite.spec.ts index 0ed33ec..fca189d 100644 --- a/test/apps/napcat-webui-gateway/proxy-rewrite.spec.ts +++ b/test/apps/napcat-webui-gateway/proxy-rewrite.spec.ts @@ -1,4 +1,5 @@ import { createHash } from 'node:crypto'; +import { createServer, type Server } from 'node:http'; import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { HttpStatus, type INestApplication } from '@nestjs/common'; @@ -339,3 +340,110 @@ describe('PublicWebuiController bootstrap', () => { expect(sessionService.markActive).not.toHaveBeenCalled(); }); }); + +describe('NapcatWebuiProxyService redirect rewriting', () => { + let app: INestApplication; + let upstream: Server; + let upstreamBaseUrl: string; + let sessionService: { + requireProxySession: jest.Mock; + }; + let credentialClient: { + getCredential: jest.Mock; + }; + + /** + * Starts a tiny NapCat-like upstream that redirects `/webui` to `/webui/`. + * @returns Upstream server plus the loopback base URL assigned by the OS. + */ + async function startRedirectUpstream() { + const server = createServer((req, res) => { + if (req.url === '/webui') { + res.statusCode = HttpStatus.MOVED_PERMANENTLY; + res.setHeader('content-type', 'text/html; charset=UTF-8'); + res.setHeader('location', '/webui/'); + res.end('Redirecting'); + return; + } + + res.statusCode = HttpStatus.OK; + res.setHeader('content-type', 'text/html; charset=UTF-8'); + res.end(''); + }); + + await new Promise((resolveListen) => { + server.listen(0, '127.0.0.1', resolveListen); + }); + const address = server.address(); + if (!address || typeof address === 'string') { + throw new Error('proxy rewrite test upstream did not expose a port'); + } + + return { + baseUrl: `http://127.0.0.1:${address.port}`, + server, + }; + } + + beforeEach(async () => { + const started = await startRedirectUpstream(); + upstream = started.server; + upstreamBaseUrl = started.baseUrl; + + sessionService = { + requireProxySession: jest.fn(), + }; + credentialClient = { + getCredential: jest.fn().mockResolvedValue('credential-1'), + }; + + sessionService.requireProxySession.mockResolvedValue( + createGatewaySession({ + status: 'active', + upstreamBaseUrl, + }), + ); + + const moduleRef = await Test.createTestingModule({ + controllers: [PublicWebuiController], + providers: [ + { + provide: NapcatWebuiGatewaySessionService, + useValue: sessionService, + }, + { + provide: NapcatWebuiGatewayTicketService, + useValue: { + redeem: jest.fn(), + }, + }, + { + provide: NapcatWebuiCredentialClient, + useValue: credentialClient, + }, + NapcatWebuiProxyService, + ], + }).compile(); + + app = moduleRef.createNestApplication(); + await app.init(); + }); + + afterEach(async () => { + await app?.close(); + await new Promise((resolveClose) => + upstream.close(() => resolveClose()), + ); + }); + + it('rewrites upstream WebUI redirects before intercepted headers are copied to the browser', async () => { + const response = await request(app.getHttpServer()) + .get(`/napcat-webui/session/${SESSION_ID}/webui/webui`) + .redirects(0) + .expect(HttpStatus.MOVED_PERMANENTLY); + + expect(response.headers.location).toBe( + `/napcat-webui/session/${SESSION_ID}/webui/webui/`, + ); + }); +});