import { createReadStream } from 'node:fs' import { stat } from 'node:fs/promises' import { createServer } from 'node:http' import path from 'node:path' import { pipeline } from 'node:stream/promises' import { fileURLToPath } from 'node:url' import { loadCatalog, loadDefaultNavigation, readOrCreateNavigation, validateNavigation, writeJsonAtomic, } from './navigation.mjs' const serverFile = fileURLToPath(import.meta.url) const serverDir = path.dirname(serverFile) const projectRoot = path.resolve(serverDir, '..') export const MAX_JSON_BODY_SIZE = 64 * 1024 export const DEFAULT_PORT = 9000 const mimeTypes = new Map([ ['.css', 'text/css; charset=utf-8'], ['.html', 'text/html; charset=utf-8'], ['.ico', 'image/x-icon'], ['.js', 'text/javascript; charset=utf-8'], ['.json', 'application/json; charset=utf-8'], ['.map', 'application/json; charset=utf-8'], ['.png', 'image/png'], ['.svg', 'image/svg+xml'], ['.txt', 'text/plain; charset=utf-8'], ['.webmanifest', 'application/manifest+json; charset=utf-8'], ['.woff2', 'font/woff2'], ]) function applySecurityHeaders(response) { response.setHeader('Content-Security-Policy', "default-src 'self'; img-src 'self' blob: data:; style-src 'self'; script-src 'self'; font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'") response.setHeader('Cross-Origin-Opener-Policy', 'same-origin') response.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=()') response.setHeader('Referrer-Policy', 'no-referrer') response.setHeader('X-Content-Type-Options', 'nosniff') response.setHeader('X-Frame-Options', 'DENY') } function sendJson(response, statusCode, payload, headers = {}) { const body = `${JSON.stringify(payload)}\n` response.statusCode = statusCode response.setHeader('Cache-Control', 'no-store') response.setHeader('Content-Type', 'application/json; charset=utf-8') response.setHeader('Content-Length', Buffer.byteLength(body)) for (const [name, value] of Object.entries(headers)) response.setHeader(name, value) response.end(body) } function sendText(response, statusCode, body, headers = {}) { response.statusCode = statusCode response.setHeader('Cache-Control', 'no-store') response.setHeader('Content-Type', 'text/plain; charset=utf-8') response.setHeader('Content-Length', Buffer.byteLength(body)) for (const [name, value] of Object.entries(headers)) response.setHeader(name, value) response.end(body) } async function readJsonBody(request) { const contentLength = Number(request.headers['content-length']) if (Number.isFinite(contentLength) && contentLength > MAX_JSON_BODY_SIZE) throw new Error('请求内容过大') const chunks = [] let size = 0 for await (const chunk of request) { size += chunk.length if (size > MAX_JSON_BODY_SIZE) throw new Error('请求内容过大') chunks.push(chunk) } return JSON.parse(Buffer.concat(chunks).toString('utf8')) } function isMissingFile(error) { return error?.code === 'ENOENT' || error?.code === 'ENOTDIR' } export async function assertProductionBuild(staticRoot = path.join(projectRoot, 'dist')) { const indexPath = path.join(path.resolve(staticRoot), 'index.html') let details try { details = await stat(indexPath) } catch (error) { if (!isMissingFile(error)) throw error } if (!details?.isFile()) { throw new Error(`Production build not found: ${indexPath}. Run "npm.cmd ci --include=dev" and "npm.cmd run build" before starting the server.`) } } function acceptsHtmlNavigation(request, pathname) { if (path.extname(pathname)) return false return String(request.headers.accept || '').split(',').some((value) => value.trim().toLowerCase().startsWith('text/html')) } async function serveStatic(request, response, staticRoot) { const url = new URL(request.url, 'http://localhost') let pathname try { pathname = decodeURIComponent(url.pathname) } catch { sendText(response, 400, 'Bad request\n') return } const requested = pathname === '/' ? '/index.html' : pathname const candidate = path.resolve(staticRoot, `.${requested}`) if (!candidate.startsWith(`${staticRoot}${path.sep}`)) { sendText(response, 403, 'Forbidden\n') return } let filePath = candidate let details try { details = await stat(filePath) } catch (error) { if (!isMissingFile(error)) throw error } if (!details?.isFile()) { if (!acceptsHtmlNavigation(request, pathname)) { sendText(response, 404, 'Not found\n') return } filePath = path.join(staticRoot, 'index.html') details = await stat(filePath) if (!details.isFile()) throw new Error('静态入口文件不存在') } response.statusCode = 200 response.setHeader('Content-Type', mimeTypes.get(path.extname(filePath).toLowerCase()) || 'application/octet-stream') response.setHeader('Content-Length', details.size) if (path.basename(filePath) === 'index.html') response.setHeader('Cache-Control', 'no-cache') else response.setHeader('Cache-Control', 'public, max-age=31536000, immutable') if (request.method === 'HEAD') { response.end() return } await pipeline(createReadStream(filePath), response) } export async function createWebToolsServer(options = {}) { const staticRoot = path.resolve(options.staticRoot || path.join(projectRoot, 'dist')) const dataDir = path.resolve(options.dataDir || process.env.DATA_DIR || path.join(projectRoot, 'data')) const navigationPath = path.join(dataDir, 'navigation.json') const catalog = options.catalog || await loadCatalog() const defaults = options.defaults || await loadDefaultNavigation() const logger = options.logger || console return createServer(async (request, response) => { applySecurityHeaders(response) try { const url = new URL(request.url, 'http://localhost') if (request.method === 'GET' && url.pathname === '/healthz') { sendJson(response, 200, { status: 'ok' }) return } if (url.pathname === '/api/navigation') { if (request.method === 'GET') { sendJson(response, 200, await readOrCreateNavigation(navigationPath, catalog, defaults)) return } if (request.method === 'PUT') { if (request.headers['sec-fetch-site'] === 'cross-site') { sendJson(response, 403, { error: '不允许跨站写入配置' }) return } let navigation try { navigation = validateNavigation(await readJsonBody(request), catalog) } catch (error) { sendJson(response, 400, { error: error instanceof Error ? error.message : '配置格式无效' }) return } await writeJsonAtomic(navigationPath, navigation) sendJson(response, 200, navigation) return } sendJson(response, 405, { error: '请求方法不支持' }, { Allow: 'GET, PUT' }) return } if (url.pathname.startsWith('/api/')) { sendJson(response, 404, { error: '接口不存在' }) return } if (request.method !== 'GET' && request.method !== 'HEAD') { sendText(response, 405, '', { Allow: 'GET, HEAD' }) return } await serveStatic(request, response, staticRoot) } catch (error) { logger.error(error) if (response.headersSent) response.destroy() else sendJson(response, 500, { error: '服务器错误' }) } }) } export async function startWebToolsServer() { const host = process.env.HOST || '0.0.0.0' const port = Number(process.env.PORT || DEFAULT_PORT) const staticRoot = path.join(projectRoot, 'dist') await assertProductionBuild(staticRoot) const server = await createWebToolsServer({ staticRoot }) server.listen(port, host, () => { console.log(`Web Tools listening on http://${host}:${port}`) console.log(`Navigation config: ${path.resolve(process.env.DATA_DIR || path.join(projectRoot, 'data'), 'navigation.json')}`) }) return server } if (process.argv[1] && path.resolve(process.argv[1]) === serverFile) { startWebToolsServer().catch((error) => { console.error(error) process.exitCode = 1 }) }