fix: 修复NapCat WebUI资源路径代理
This commit is contained in:
parent
e88bf7449c
commit
f46cc5cb3b
@ -5,6 +5,7 @@ import type { NextFunction, Request, Response } from 'express';
|
|||||||
import {
|
import {
|
||||||
createProxyMiddleware,
|
createProxyMiddleware,
|
||||||
fixRequestBody,
|
fixRequestBody,
|
||||||
|
responseInterceptor,
|
||||||
type RequestHandler,
|
type RequestHandler,
|
||||||
} from 'http-proxy-middleware';
|
} from 'http-proxy-middleware';
|
||||||
import { NapcatWebuiGatewaySessionService } from '../../application/napcat-webui-gateway-session.service';
|
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';
|
import { NapcatWebuiCredentialClient } from '../napcat-webui-credential.client';
|
||||||
|
|
||||||
const GATEWAY_WEBUI_PREFIX = '/napcat-webui/session';
|
const GATEWAY_WEBUI_PREFIX = '/napcat-webui/session';
|
||||||
|
const TEXT_REWRITE_EXTENSIONS = ['.css', '.html', '.js', '.mjs'] as const;
|
||||||
const STRIPPED_UPSTREAM_HEADERS = [
|
const STRIPPED_UPSTREAM_HEADERS = [
|
||||||
'authorization',
|
'authorization',
|
||||||
'cookie',
|
'cookie',
|
||||||
@ -35,6 +37,11 @@ type CookiePathRewriteInput = {
|
|||||||
sessionId: string;
|
sessionId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type RewriteTextResponseInput = {
|
||||||
|
body: string;
|
||||||
|
sessionId: string;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Normalizes a Gateway route tail into a safe upstream pathname.
|
* Normalizes a Gateway route tail into a safe upstream pathname.
|
||||||
* @param input - Route parameter from Nest/path-to-regexp.
|
* @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.
|
* Decodes path text until stable so nested encoded traversal cannot pass through.
|
||||||
* @param value - Raw route path text.
|
* @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()
|
@Injectable()
|
||||||
export class NapcatWebuiProxyService {
|
export class NapcatWebuiProxyService {
|
||||||
/**
|
/**
|
||||||
@ -190,7 +236,11 @@ export class NapcatWebuiProxyService {
|
|||||||
this.stripBrowserHeaders(req);
|
this.stripBrowserHeaders(req);
|
||||||
req.url = upstreamPath;
|
req.url = upstreamPath;
|
||||||
|
|
||||||
const proxy = this.createProxy(session, credential);
|
const proxy = this.createProxy(
|
||||||
|
session,
|
||||||
|
credential,
|
||||||
|
shouldRewriteNapcatTextResponse(upstreamPath),
|
||||||
|
);
|
||||||
return proxy(req, res, next);
|
return proxy(req, res, next);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -240,6 +290,7 @@ export class NapcatWebuiProxyService {
|
|||||||
private createProxy(
|
private createProxy(
|
||||||
session: NapcatWebuiGatewaySession,
|
session: NapcatWebuiGatewaySession,
|
||||||
credential: string,
|
credential: string,
|
||||||
|
rewriteTextResponse = false,
|
||||||
): RequestHandler<Request, Response, NextFunction> {
|
): RequestHandler<Request, Response, NextFunction> {
|
||||||
return createProxyMiddleware<Request, Response, NextFunction>({
|
return createProxyMiddleware<Request, Response, NextFunction>({
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
@ -259,23 +310,44 @@ export class NapcatWebuiProxyService {
|
|||||||
proxyReq.removeHeader('cookie');
|
proxyReq.removeHeader('cookie');
|
||||||
proxyReq.setHeader('Authorization', `Bearer ${credential}`);
|
proxyReq.setHeader('Authorization', `Bearer ${credential}`);
|
||||||
},
|
},
|
||||||
proxyRes: (proxyRes) => {
|
proxyRes: rewriteTextResponse
|
||||||
const location = proxyRes.headers.location;
|
? responseInterceptor(async (responseBuffer, proxyRes) => {
|
||||||
if (typeof location === 'string') {
|
this.rewriteLocationHeader(proxyRes.headers, session);
|
||||||
proxyRes.headers.location = rewriteNapcatLocationHeader({
|
return rewriteNapcatTextResponse({
|
||||||
location,
|
body: responseBuffer.toString('utf8'),
|
||||||
sessionId: session.sessionId,
|
sessionId: session.sessionId,
|
||||||
upstreamBaseUrl: session.upstreamBaseUrl,
|
});
|
||||||
});
|
})
|
||||||
}
|
: (proxyRes) => {
|
||||||
},
|
this.rewriteLocationHeader(proxyRes.headers, session);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
secure: false,
|
secure: false,
|
||||||
|
selfHandleResponse: rewriteTextResponse,
|
||||||
target: session.upstreamBaseUrl,
|
target: session.upstreamBaseUrl,
|
||||||
ws: true,
|
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.
|
* Builds the upstream URL path while preserving the original query string.
|
||||||
* @param proxyPath - Gateway route tail to sanitize.
|
* @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