Initial release of Web Tools 0.0.5
This commit is contained in:
@@ -0,0 +1,203 @@
|
||||
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
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { mkdir, open, readFile, rename, unlink } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const serverDir = path.dirname(fileURLToPath(import.meta.url))
|
||||
const projectRoot = path.resolve(serverDir, '..')
|
||||
|
||||
async function readJson(filePath) {
|
||||
return JSON.parse(await readFile(filePath, 'utf8'))
|
||||
}
|
||||
|
||||
export async function loadCatalog() {
|
||||
return readJson(path.join(projectRoot, 'shared', 'tool-catalog.json'))
|
||||
}
|
||||
|
||||
export async function loadDefaultNavigation() {
|
||||
return readJson(path.join(projectRoot, 'shared', 'default-navigation.json'))
|
||||
}
|
||||
|
||||
export function normalizeNavigation(input, catalog, defaults) {
|
||||
const categoryIds = new Set(catalog.categories.map((category) => category.id))
|
||||
const toolById = new Map(catalog.tools.map((tool) => [tool.id, tool]))
|
||||
const seenCategories = new Set()
|
||||
const seenTools = new Set()
|
||||
const categories = []
|
||||
|
||||
const inputCategories = Array.isArray(input?.categories) ? input.categories : []
|
||||
for (const candidate of inputCategories) {
|
||||
if (!candidate || !categoryIds.has(candidate.id) || seenCategories.has(candidate.id)) continue
|
||||
seenCategories.add(candidate.id)
|
||||
const tools = []
|
||||
for (const toolId of Array.isArray(candidate.tools) ? candidate.tools : []) {
|
||||
if (!toolById.has(toolId) || seenTools.has(toolId)) continue
|
||||
seenTools.add(toolId)
|
||||
tools.push(toolId)
|
||||
}
|
||||
categories.push({ id: candidate.id, tools })
|
||||
}
|
||||
|
||||
for (const defaultCategory of defaults.categories) {
|
||||
if (!seenCategories.has(defaultCategory.id)) {
|
||||
seenCategories.add(defaultCategory.id)
|
||||
categories.push({ id: defaultCategory.id, tools: [] })
|
||||
}
|
||||
}
|
||||
|
||||
for (const tool of catalog.tools) {
|
||||
if (seenTools.has(tool.id)) continue
|
||||
const preferred = categories.find((category) => category.id === tool.category) ?? categories[0]
|
||||
preferred?.tools.push(tool.id)
|
||||
seenTools.add(tool.id)
|
||||
}
|
||||
|
||||
return { version: 1, categories }
|
||||
}
|
||||
|
||||
export function validateNavigation(input, catalog) {
|
||||
if (!input || input.version !== 1 || !Array.isArray(input.categories)) {
|
||||
throw new Error('配置格式无效')
|
||||
}
|
||||
const validCategories = new Set(catalog.categories.map((category) => category.id))
|
||||
const validTools = new Set(catalog.tools.map((tool) => tool.id))
|
||||
const seenCategories = new Set()
|
||||
const seenTools = new Set()
|
||||
|
||||
for (const category of input.categories) {
|
||||
if (!category || !validCategories.has(category.id) || seenCategories.has(category.id)) {
|
||||
throw new Error(`分类无效或重复:${category?.id ?? 'unknown'}`)
|
||||
}
|
||||
if (!Array.isArray(category.tools)) throw new Error(`分类 ${category.id} 缺少工具列表`)
|
||||
seenCategories.add(category.id)
|
||||
for (const toolId of category.tools) {
|
||||
if (!validTools.has(toolId) || seenTools.has(toolId)) throw new Error(`工具无效或重复:${toolId}`)
|
||||
seenTools.add(toolId)
|
||||
}
|
||||
}
|
||||
|
||||
if (seenCategories.size !== validCategories.size || seenTools.size !== validTools.size) {
|
||||
throw new Error('配置必须包含全部分类和工具')
|
||||
}
|
||||
return input
|
||||
}
|
||||
|
||||
export async function writeJsonAtomic(filePath, value, operations = { mkdir, open, rename, unlink }) {
|
||||
const serialized = `${JSON.stringify(value, null, 2)}\n`
|
||||
const temporaryPath = `${filePath}.${randomUUID()}.tmp`
|
||||
let handle
|
||||
try {
|
||||
await operations.mkdir(path.dirname(filePath), { recursive: true })
|
||||
handle = await operations.open(temporaryPath, 'wx', 0o600)
|
||||
await handle.writeFile(serialized, 'utf8')
|
||||
await handle.sync()
|
||||
await handle.close()
|
||||
handle = undefined
|
||||
await operations.rename(temporaryPath, filePath)
|
||||
} catch (error) {
|
||||
if (handle) await handle.close().catch(() => undefined)
|
||||
await operations.unlink(temporaryPath).catch(() => undefined)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export async function readOrCreateNavigation(filePath, catalog, defaults) {
|
||||
let current
|
||||
try {
|
||||
current = await readJson(filePath)
|
||||
} catch (error) {
|
||||
if (error?.code !== 'ENOENT') throw error
|
||||
current = defaults
|
||||
}
|
||||
const normalized = normalizeNavigation(current, catalog, defaults)
|
||||
if (JSON.stringify(current) !== JSON.stringify(normalized)) await writeJsonAtomic(filePath, normalized)
|
||||
return normalized
|
||||
}
|
||||
Reference in New Issue
Block a user