fix: 修复NapCat WebUI跳转路径

This commit is contained in:
sunlei 2026-06-24 18:39:58 +08:00
parent f46cc5cb3b
commit 7e52972830
2 changed files with 142 additions and 11 deletions

View File

@ -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.

View File

@ -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('<a href="/webui/">Redirecting</a>');
return;
}
res.statusCode = HttpStatus.OK;
res.setHeader('content-type', 'text/html; charset=UTF-8');
res.end('<script type="module" src="/webui/assets/index.js"></script>');
});
await new Promise<void>((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<void>((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/`,
);
});
});