Files
WebTools/server/index.mjs
T

204 lines
7.4 KiB
JavaScript

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
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'],
['.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'
}
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 || 8080)
const server = await createWebToolsServer()
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
})
}