v0.2.7: 修复Redis连接 + 启动管理后台

- 修复Redis认证 (配置密码)
- 启动Python管理后台 (端口9531, 15个功能开关)
- 统一版本号 0.2.7
- 更新docker-compose.yml (镜像版本/Redis URL/Admin服务)
This commit is contained in:
2026-05-17 02:22:18 +08:00
commit 83cbfaf03f
164 changed files with 25195 additions and 0 deletions

View File

@@ -0,0 +1,137 @@
import { Request, Response, NextFunction } from 'express';
// Native fetch available in Node 20+
import { getSystemConfig } from '../admin/system-config.service';
const PANSOU_UPSTREAM = 'http://pansou:80';
// Content types that need sub_filter path rewriting
const TEXT_TYPES = ['text/html', 'application/javascript', 'text/javascript'];
// Hop-by-hop headers that should not be forwarded
const HOP_HEADERS = new Set([
'host', 'connection', 'content-length', 'transfer-encoding',
'keep-alive', 'proxy-authenticate', 'proxy-authorization',
'te', 'trailer', 'upgrade',
]);
/**
* Apply sub_filter string replacements to HTML/JS content.
* This matches what the nginx pansou.conf sub_filter does.
*/
function applySubFilter(text: string): string {
return text
// Replace HTML/JS path references: /api/ -> /pansou/api/
.replace(/\/api\//g, '/pansou/api/')
// baseURL rewrite (Vue SPA config)
.replace(/baseURL:"\/api"/g, 'baseURL:"/pansou/api"')
.replace(/baseURL:'\/api'/g, "baseURL:'/pansou/api'")
// Static asset path rewrites
.replace(/src="\/assets\//g, 'src="/pansou/assets/')
.replace(/src='\/assets\//g, "src='/pansou/assets/")
.replace(/href="\/assets\//g, 'href="/pansou/assets/')
.replace(/href='\/assets\//g, "href='/pansou/assets/")
// Favicon path rewrite
.replace(/href="\/favicon\.ico/g, 'href="/pansou/favicon.ico')
.replace(/href='\/favicon\.ico/g, "href='/pansou/favicon.ico");
}
/**
* Express middleware that proxies /pansou/* requests to the PanSou web container.
*
* How it works:
* 1. Strips the /pansou prefix from the request path
* 2. Forwards the request to http://pansou:80/{path}
* 3. For HTML/JS responses, applies sub_filter path rewriting
* so that /api/ becomes /pansou/api/ and /assets/ becomes /pansou/assets/
* 4. For static assets (CSS, images, fonts), pipes through as-is
*
* Controlled by system config key 'pansou_web_enabled' (true/false).
*/
export async function pansouWebProxy(req: Request, res: Response, _next: NextFunction): Promise<void> {
try {
// Check if PanSou web is enabled
const enabled = getSystemConfig('pansou_web_enabled');
if (enabled !== 'true') {
res.status(404).send('PanSou Web UI is disabled by administrator');
return;
}
// Build upstream URL: strip /pansou prefix
let targetPath = req.path;
targetPath = targetPath.replace(/^\/pansou/, '') || '/';
// Preserve query string
const queryIndex = req.url.indexOf('?');
const query = queryIndex >= 0 ? req.url.substring(queryIndex) : '';
const upstreamUrl = `${PANSOU_UPSTREAM}${targetPath}${query}`;
// Build forwarded headers (filter out hop-by-hop headers)
const forwardHeaders: Record<string, string> = {};
for (const [key, value] of Object.entries(req.headers)) {
if (!HOP_HEADERS.has(key.toLowerCase()) && value !== undefined) {
forwardHeaders[key] = Array.isArray(value) ? value.join(', ') : value;
}
}
// Override Host header to target the upstream
forwardHeaders['Host'] = 'pansou';
// Remove Accept-Encoding so we get uncompressed content for text rewriting
forwardHeaders['accept-encoding'] = '';
// Forward the request
const response = await fetch(upstreamUrl, {
method: req.method as any,
headers: forwardHeaders,
body: ['GET', 'HEAD'].includes(req.method) ? undefined : JSON.stringify(req.body),
redirect: 'manual',
signal: AbortSignal.timeout(30000),
});
const contentType = response.headers.get('content-type') || '';
// Set response status
res.status(response.status);
// Handle redirects - rewrite Location header to include /pansou prefix
if (response.status >= 300 && response.status < 400) {
const location = response.headers.get('location');
if (location) {
if (location.startsWith('/')) {
res.setHeader('location', '/pansou' + location);
} else {
res.setHeader('location', location);
}
}
}
// For HTML/JS content, apply sub_filter string replacements
if (TEXT_TYPES.some(t => contentType.includes(t))) {
const text = await response.text();
const modified = applySubFilter(text);
res.setHeader('content-type', contentType);
// Remove content-encoding since we decompressed
res.setHeader('content-length', Buffer.byteLength(modified, 'utf-8').toString());
res.send(modified);
return;
}
// For other content (CSS, images, fonts, etc.), pipe through as-is
const excludedHeaders = new Set([
'content-encoding', 'content-length', 'transfer-encoding',
'keep-alive', 'connection',
]);
response.headers.forEach((value, key) => {
if (!excludedHeaders.has(key.toLowerCase())) {
res.setHeader(key, value);
}
});
// Use buffer for reliability
const buffer = await response.arrayBuffer().then(buf => Buffer.from(buf));
res.end(buffer);
} catch (err: any) {
console.error(`[PanSou Web Proxy] Error proxying ${req.path}:`, err.message);
if (!res.headersSent) {
res.status(502).send(`PanSou Web proxy error: ${err.message}`);
}
}
}