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/`,
+ );
+ });
+});