fix: 修复NapCat WebUI资源路径代理
This commit is contained in:
parent
e88bf7449c
commit
f46cc5cb3b
@ -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<Request, Response, NextFunction> {
|
||||
return createProxyMiddleware<Request, Response, NextFunction>({
|
||||
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<string, number | string | string[] | undefined>,
|
||||
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.
|
||||
|
||||
@ -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: [
|
||||
'<script type="module" src="/webui/assets/index.js"></script>',
|
||||
'<link rel="stylesheet" href="/webui/assets/index.css">',
|
||||
'<script>const baseURL="/api"; const file="/File/font/upload/webui";</script>',
|
||||
'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);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user