From e0f34c36b6c513e61356166b05b346c5c5f08b3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B4=AE=E7=94=9F=EF=BC=88=E5=AD=90=E8=99=9A=EF=BC=89?= <2234839456@qq.com> Date: Wed, 8 Apr 2026 17:11:55 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E4=B8=8A=E4=BC=A0=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=E5=92=8C=E6=9C=8D=E5=8A=A1=E5=99=A8=E5=81=A5=E5=A3=AE?= =?UTF-8?q?=E6=80=A7=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修复 multipart 解析器:endDelimiter 提前检查导致循环退出 - 修复 Node.js v24 兼容:改为先读取 body 再构造 Request - 修复 HTTP keep-alive 死锁:按 Content-Length 精确读取 body - 修复服务器崩溃:connectionHandle 加 try-catch,单连接错误不影响全局 - 修复 URL 写死:webfont.shenzilong.com 改为 localhost - 复制按钮加"已复制/复制失败"反馈提示 - TEMP_MAX_FILES 改为环境变量可配置 - dev 环境默认启用临时上传和管理员上传 - CSS 代码默认展示,使用技巧不再折叠 Co-Authored-By: Claude Opus 4.6 --- README.md | 24 ++++++++- backend/app.ts | 40 +++++++++----- backend/config.ts | 2 +- backend/multipart.ts | 21 +++++--- backend/server/server.ts | 113 ++++++++++++++++++++++++--------------- scripts/dev-all.ts | 1 + src/App.tsx | 27 +++++++++- 7 files changed, 162 insertions(+), 66 deletions(-) diff --git a/README.md b/README.md index 34c00ca..efc1a3c 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,9 @@ ui 需要展现一些特定的字体,但直接引入字体包又过大,于 1.裁剪字体包使其仅包含选中的字体,其体积自然十分之小 2.另外可以生成 css 直接复制可用,部署在公网便可永久访问 +3.支持字体文件上传(临时上传和管理员上传两种模式) +4.支持下载裁剪后的字体文件 +5.字体名称支持模糊匹配(精确 > 前缀 > 包含) ## 安装与使用 @@ -40,7 +43,7 @@ tjs run ./dist_backend/app.cjs https://hub.docker.com/repository/docker/llej0/web-font 很小的包体积 ![alt text](doc/image.png) -docker compoose.yml +docker compose.yml ```yml version: '3' @@ -51,6 +54,9 @@ services: - "8087:8087" volumes: - ./data:/home/font # 挂载本机字体目录 + environment: + - ENABLE_TEMP_UPLOAD=true # 开启临时上传(最多10个,FIFO溢出覆盖) + - ADMIN_API_KEY=你的管理员密钥 # 设置后开启管理员上传,不设置则不可用 deploy: resources: limits: @@ -63,6 +69,22 @@ services: ## 提供的服务 +### API 接口 + +| 接口 | 说明 | +|------|------| +| `GET /api?font=字体名&text=文字` | 裁剪字体,字体名支持模糊匹配 | +| `GET /api/fonts` | 列出所有可用字体 | +| `GET /api/config` | 获取公开配置(是否开启上传等) | +| `POST /api/upload?mode=temp` | 临时上传字体文件(需开启 `ENABLE_TEMP_UPLOAD`) | +| `POST /api/upload?mode=admin` | 管理员上传字体文件(需 `Authorization: Bearer `) | + +### 上传功能 + +- **临时上传**:设置环境变量 `ENABLE_TEMP_UPLOAD=true` 启用,最多保留 10 个字体文件,超出后自动删除最早上传的(FIFO) +- **管理员上传**:设置环境变量 `ADMIN_API_KEY=你的密钥` 启用,上传的字体永久保存,需要通过 API Key 认证 +- 支持的字体格式:`.ttf` `.otf` `.woff` `.woff2` + ## 鸣谢 [kekee000/fonteditor-core](https://github.com/kekee000/fonteditor-core) diff --git a/backend/app.ts b/backend/app.ts index 426664b..f48f381 100644 --- a/backend/app.ts +++ b/backend/app.ts @@ -1,3 +1,8 @@ +/** 解析请求 URL(req.url 只有路径,需要补全协议和主机才能用 URL API) */ +function parseUrl(req: Request): URL { + return new URL(req.url, "http://localhost"); +} + import { fontSubset } from "./font_util/font"; import { mimeTypes } from "./server/mime_type"; import type { cMiddleware } from "./server/req_res"; @@ -95,7 +100,7 @@ const logMiddleware: cMiddleware = async (req, res, next) => { const t1 = Date.now(); const r = await next(req, res); const t2 = Date.now(); - const url = new URL(req.url); + const url = parseUrl(req); console.log(`[${t2 - t1}ms] ${req.method} ${url.pathname}`); return r; }; @@ -103,7 +108,7 @@ const logMiddleware: cMiddleware = async (req, res, next) => { const staticFileMiddleware: cMiddleware = async function (req, res, next) { let newRes: Response; if (req.method === "GET") { - const url = new URL(req.url); + const url = parseUrl(req); const filePath = path_join(ROOT_DIR, url.pathname === "/" ? "index.html" : url.pathname); try { const stats = await stat(filePath); @@ -199,21 +204,24 @@ async function handleGetConfig(req: Request, res: Response) { /** POST /api/upload?mode=temp|admin — 上传字体 */ async function handleUpload(req: Request, res: Response) { - const url = new URL(req.url, "https://webfont.shenzilong.com"); + const url = parseUrl(req); const mode = url.searchParams.get("mode") ?? "temp"; const contentType = req.headers.get("Content-Type") ?? ""; - let body: ArrayBuffer; - try { - body = await req.arrayBuffer(); - } catch { - return { req, res: jsonResponse({ success: false, error: "读取请求体失败" }, 400) }; + console.log("[upload] mode:", mode, "contentType:", contentType); + + const body = (req as Request & { _bodyBuffer?: ArrayBuffer })._bodyBuffer; + if (!body || body.byteLength === 0) { + return { req, res: jsonResponse({ success: false, error: "请求体为空" }, 400) }; } + console.log("[upload] body size:", body.byteLength); let parsed; try { parsed = parseMultipart(contentType, body); - } catch { + console.log("[upload] parsed files:", parsed.files.length); + } catch (err) { + console.log("[upload] parse error:", err); return { req, res: jsonResponse({ success: false, error: "无效的 multipart 数据" }, 400) }; } @@ -222,22 +230,26 @@ async function handleUpload(req: Request, res: Response) { } const file = parsed.files[0]; + console.log("[upload] file:", file.name, "filename:", file.filename, "data size:", file.data.length); + let result; if (mode === "admin") { const authHeader = req.headers.get("Authorization") ?? ""; const apiKey = authHeader.replace("Bearer ", ""); - const result = await handleAdminUpload({ data: file.data, filename: file.filename }, apiKey); + result = await handleAdminUpload({ data: file.data, filename: file.filename }, apiKey); + console.log("[upload] admin result:", result); return { req, res: jsonResponse(result, result.success ? 200 : 403) }; } // 默认:临时上传 - const result = await handleTempUpload({ data: file.data, filename: file.filename }); + result = await handleTempUpload({ data: file.data, filename: file.filename }); + console.log("[upload] temp result:", result); return { req, res: jsonResponse(result, result.success ? 200 : 400) }; } /** GET /api?font=...&text=... — 字体裁剪 */ async function handleFontSubset(req: Request, res: Response) { - const url = new URL(req.url, "https://webfont.shenzilong.com"); + const url = parseUrl(req); const params = new URLSearchParams(url.search); const font = params.get("font") || ""; const text = params.get("text") || ""; @@ -289,7 +301,7 @@ async function handleFontSubset(req: Request, res: Response) { /** 统一的 API 路由中间件 */ const fontApiMiddleware: cMiddleware = async (req, res, next) => { - const url = new URL(req.url, "https://webfont.shenzilong.com"); + const url = parseUrl(req); if (!url.pathname.startsWith("/api")) return next(req, res); if (url.pathname === "/api/fonts" && req.method === "GET") { @@ -312,7 +324,7 @@ const fontApiMiddleware: cMiddleware = async (req, res, next) => { const MAX_UPLOAD_SIZE = 50 * 1024 * 1024; const uploadSizeMiddleware: cMiddleware = async (req, res, next) => { - if (req.method === "POST" && new URL(req.url).pathname === "/api/upload") { + if (req.method === "POST" && parseUrl(req).pathname === "/api/upload") { const contentLength = parseInt(req.headers.get("Content-Length") ?? "0", 10); if (contentLength > MAX_UPLOAD_SIZE) { return { diff --git a/backend/config.ts b/backend/config.ts index f2386cd..a76b7c1 100644 --- a/backend/config.ts +++ b/backend/config.ts @@ -10,7 +10,7 @@ export const enableTempUpload = env.ENABLE_TEMP_UPLOAD === "true"; export const adminApiKey: string = env.ADMIN_API_KEY ?? ""; /** 临时上传目录最大文件数 */ -export const tempMaxFiles = 10; +export const tempMaxFiles = parseInt(env.TEMP_MAX_FILES ?? "10", 10) || 10; /** 字体搜索目录(按优先级排序) */ export const fontDirs = ["font", "font/temp", "font/admin"] as const; diff --git a/backend/multipart.ts b/backend/multipart.ts index d17e540..e0647fd 100644 --- a/backend/multipart.ts +++ b/backend/multipart.ts @@ -10,7 +10,15 @@ export interface MultipartFile { data: Uint8Array; } -export function parseMultipart(contentType: string, body: ArrayBuffer): MultipartFile { +export interface MultipartParseResult { + files: MultipartFile[]; +} + +export function parseMultipart(contentType: string, body: ArrayBuffer): MultipartParseResult { + if (!body || body.byteLength === 0) { + return { files: [] }; + } + const boundaryMatch = contentType.match(/boundary=([^\s;]+)/); if (!boundaryMatch) throw new Error("No boundary found"); const boundary = boundaryMatch[1].replace(/^"(.*)"$/, "$1"); @@ -19,7 +27,6 @@ export function parseMultipart(contentType: string, body: ArrayBuffer): Multipar const decoder = new TextDecoder("utf-8"); const bodyBytes = new Uint8Array(body); const delimiter = encoder.encode("\r\n--" + boundary); - const endDelimiter = encoder.encode("--" + boundary + "--"); /** 在字节数组中查找子串位置 */ function findBytes(haystack: Uint8Array, needle: Uint8Array, offset: number): number { @@ -46,9 +53,6 @@ export function parseMultipart(contentType: string, body: ArrayBuffer): Multipar pos = sbPos + startBoundary.length; while (pos < bodyBytes.length) { - // 检查是否到达结束边界 - if (findBytes(bodyBytes, endDelimiter, pos - 2) !== -1) break; - // 查找 headers 和 body 的分界 "\r\n\r\n" const headerEnd = findBytes(bodyBytes, encoder.encode("\r\n\r\n"), pos); if (headerEnd === -1) break; @@ -79,11 +83,16 @@ export function parseMultipart(contentType: string, body: ArrayBuffer): Multipar } pos = nextBoundary + delimiter.length; + + // 检查 boundary 后面是否紧跟 "--"(结束标记) + if (pos + 2 <= bodyBytes.length && bodyBytes[pos] === 45 && bodyBytes[pos + 1] === 45) { + break; + } // 跳过 boundary 后的 "\r\n" if (pos < bodyBytes.length && bodyBytes[pos] === 13 && bodyBytes[pos + 1] === 10) { pos += 2; } } - return { ...files[0], files } as MultipartFile & { files: MultipartFile[] }; + return { files }; } diff --git a/backend/server/server.ts b/backend/server/server.ts index e9340a4..0370b17 100644 --- a/backend/server/server.ts +++ b/backend/server/server.ts @@ -73,50 +73,79 @@ async function connectionHandle( }, handle: cNext, ) { - // connection.readable. - const { header, body } = await createStreamAfterTarget(connection.readable, target); - if (!header) { - return; - } - const httpHeaderText = decoder.decode(header); - const httpHeader = parseHttpRequest(httpHeaderText); - const rawReq = new Request("http://" + httpHeader.headers["Host"] + httpHeader.url, { - method: httpHeader.method, - body: httpHeader.method === "GET" || httpHeader.method === "HEAD" ? undefined : body, - headers: httpHeader.headers, - }); - const rawRes = new Response(); - - const { req, res } = await handle(rawReq, rawRes); - const resWriter = connection.writable.getWriter(); - let headerText: string[] = []; - res.headers.forEach((value, key) => { - headerText.push(`${key}: ${value}`); - }); - const resHeaertText = `HTTP/1.1 ${res.status} OK\r\n${headerText.join("\r\n")}\r\n\r\n`; - await resWriter.write(encoder.encode(resHeaertText)); - if (res.body) { - // node 运行时 - // 释放写入器的锁定 - resWriter.releaseLock(); - // https://github.com/saghul/txiki.js/issues/646 - await res.body?.pipeTo(connection.writable); - } else { - // @ts-expect-error - if (res._bodyInit) { - // tjs 运行时 - // @ts-expect-error - await resWriter.write(res._bodyInit); - } else { - // llrt 运行时 - const buffer = new Uint8Array(await (await res.blob()).arrayBuffer()); - await resWriter.write(buffer); + try { + const { header, body } = await createStreamAfterTarget(connection.readable, target); + if (!header) { + return; } + const httpHeaderText = decoder.decode(header); + const httpHeader = parseHttpRequest(httpHeaderText); + const hasBody = httpHeader.method !== "GET" && httpHeader.method !== "HEAD"; + /** 根据 Content-Length 读取指定长度的 body,避免在 keep-alive 连接上无限等待 */ + let bodyArrayBuffer: ArrayBuffer | undefined; + if (hasBody && body) { + const contentLength = parseInt(httpHeader.headers["Content-Length"] ?? "0", 10); + if (contentLength > 0) { + const chunks: Uint8Array[] = []; + let received = 0; + for await (const chunk of body) { + chunks.push(chunk); + received += chunk.length; + if (received >= contentLength) break; + } + const merged = new Uint8Array(received); + let offset = 0; + for (const chunk of chunks) { + merged.set(chunk, offset); + offset += chunk.length; + } + bodyArrayBuffer = merged.buffer.slice(merged.byteOffset, merged.byteOffset + merged.byteLength); + } else if (httpHeader.headers["Transfer-Encoding"] === "chunked") { + /** chunked transfer encoding 暂不支持,跳过 body */ + } + } + const rawReq = new Request("http://" + httpHeader.headers["Host"] + httpHeader.url, { + method: httpHeader.method, + headers: httpHeader.headers, + }); + /** 将 body 数据挂到 request 对象上,供中间件直接读取 */ + (rawReq as Request & { _bodyBuffer?: ArrayBuffer })._bodyBuffer = bodyArrayBuffer; + const rawRes = new Response(); + + const { req, res } = await handle(rawReq, rawRes); + const resWriter = connection.writable.getWriter(); + let headerText: string[] = []; + res.headers.forEach((value, key) => { + headerText.push(`${key}: ${value}`); + }); + const resHeaertText = `HTTP/1.1 ${res.status} OK\r\n${headerText.join("\r\n")}\r\n\r\n`; + await resWriter.write(encoder.encode(resHeaertText)); + if (res.body) { + // node 运行时 + // 释放写入器的锁定 + resWriter.releaseLock(); + // https://github.com/saghul/txiki.js/issues/646 + await res.body?.pipeTo(connection.writable); + } else { + // @ts-expect-error + if (res._bodyInit) { + // tjs 运行时 + // @ts-expect-error + await resWriter.write(res._bodyInit); + } else { + // llrt 运行时 + const buffer = new Uint8Array(await (await res.blob()).arrayBuffer()); + await resWriter.write(buffer); + } + } + if (!resWriter.closed) { + await resWriter.close(); + } + connection.close(); + } catch (err) { + console.log("[connectionHandle error]", err); + try { connection.close(); } catch { /* ignore */ } } - if (!resWriter.closed) { - await resWriter.close(); - } - connection.close(); } function parseHttpRequest(requestText: string) { diff --git a/scripts/dev-all.ts b/scripts/dev-all.ts index 1f8145a..0e85d01 100644 --- a/scripts/dev-all.ts +++ b/scripts/dev-all.ts @@ -34,6 +34,7 @@ const backend = spawn("pnpx", ["tsx", "watch", "backend/app.ts"], { cwd: ROOT_DIR, stdio: "inherit", shell: true, + env: { ...process.env, ENABLE_TEMP_UPLOAD: "true", ADMIN_API_KEY: "dev-key" }, }); children.push(backend); diff --git a/src/App.tsx b/src/App.tsx index 6ef3d6a..2304a37 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -175,7 +175,7 @@ function App() { /> - +
@@ -193,7 +193,17 @@ function App() { @@ -203,6 +213,19 @@ function App() {
+
+

原理:服务端根据 text 参数裁剪字体,只返回所需字符的子集。相同 URL 的请求会被浏览器自动缓存。

+

最小化用法:将下方 CSS 复制到你的页面,修改 text 参数中的文字即可:

+
{`
+

你的文字

`}
+
+