feat: Phase1 完整交付——Web 看板(frontend-design) + MCP server(12 工具) + CLI/导入器
- web/: phosphor 调度台风格纯静态看板,审批闸内联 accept/reject(拒绝必填意见),WS 实时 - src/api/static.ts: 手写静态服务(路径穿越防护),daemon 接入 - src/mcp/: stdio MCP server,@modelcontextprotocol/sdk 1.29.0,12 工具薄封装 REST - src/cli/: 零依赖 CLI(project/task/next/approvals/import-todo),旧 todo.json 导入器 - 实测: pangolin 18 条旧任务导入 45 条;端到端验证通过 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, extname, join, resolve, sep } from 'node:path';
|
||||
import type { FastifyInstance, FastifyReply } from 'fastify';
|
||||
|
||||
/**
|
||||
* Web 看板静态文件服务(零依赖,手写)。
|
||||
* web/ 不参与 tsc 构建,直接相对仓库根定位:
|
||||
* dev 时本文件在 src/api/,build 后在 dist/api/,两者到仓库根都是 ../..(与 store/db.ts 同思路)。
|
||||
*/
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const WEB_ROOT = resolve(HERE, '..', '..', 'web');
|
||||
|
||||
const MIME: Record<string, string> = {
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
'.js': 'text/javascript; charset=utf-8',
|
||||
'.mjs': 'text/javascript; charset=utf-8',
|
||||
'.css': 'text/css; charset=utf-8',
|
||||
'.json': 'application/json; charset=utf-8',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.gif': 'image/gif',
|
||||
'.ico': 'image/x-icon',
|
||||
'.webp': 'image/webp',
|
||||
'.woff': 'font/woff',
|
||||
'.woff2': 'font/woff2',
|
||||
'.map': 'application/json',
|
||||
'.txt': 'text/plain; charset=utf-8',
|
||||
};
|
||||
|
||||
async function serveFile(reply: FastifyReply, rawPath: string): Promise<FastifyReply> {
|
||||
let rel: string;
|
||||
try {
|
||||
rel = decodeURIComponent(rawPath);
|
||||
} catch {
|
||||
return reply.code(400).type('text/plain; charset=utf-8').send('Bad Request');
|
||||
}
|
||||
if (rel.includes('\0')) {
|
||||
return reply.code(400).type('text/plain; charset=utf-8').send('Bad Request');
|
||||
}
|
||||
|
||||
// 路径穿越防护:解析后必须仍落在 WEB_ROOT 之内
|
||||
const abs = resolve(WEB_ROOT, '.' + sep + rel);
|
||||
if (abs !== WEB_ROOT && !abs.startsWith(WEB_ROOT + sep)) {
|
||||
return reply.code(403).type('text/plain; charset=utf-8').send('Forbidden');
|
||||
}
|
||||
|
||||
const file = abs === WEB_ROOT ? join(WEB_ROOT, 'index.html') : abs;
|
||||
try {
|
||||
const buf = await readFile(file);
|
||||
const type = MIME[extname(file).toLowerCase()] ?? 'application/octet-stream';
|
||||
return reply.type(type).send(buf);
|
||||
} catch {
|
||||
return reply.code(404).type('text/plain; charset=utf-8').send('Not Found');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 在 Fastify 上注册看板静态路由。
|
||||
* `/` → web/index.html;`/*` → web/ 下对应文件(/api/**、/ws 等更具体路由优先匹配,不受影响)。
|
||||
*/
|
||||
export function registerStatic(app: FastifyInstance): void {
|
||||
app.get('/', (_req, reply) => serveFile(reply, 'index.html'));
|
||||
app.get('/*', (req, reply) => {
|
||||
const params = req.params as { '*': string };
|
||||
return serveFile(reply, params['*'] ?? '');
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user