修复上传功能和服务器健壮性问题

- 修复 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 <noreply@anthropic.com>
This commit is contained in:
崮生(子虚) 2026-04-08 17:11:55 +08:00
parent 032f408f9c
commit e0f34c36b6
7 changed files with 162 additions and 66 deletions

View File

@ -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 <API_KEY>` |
### 上传功能
- **临时上传**:设置环境变量 `ENABLE_TEMP_UPLOAD=true` 启用,最多保留 10 个字体文件超出后自动删除最早上传的FIFO
- **管理员上传**:设置环境变量 `ADMIN_API_KEY=你的密钥` 启用,上传的字体永久保存,需要通过 API Key 认证
- 支持的字体格式:`.ttf` `.otf` `.woff` `.woff2`
## 鸣谢
[kekee000/fonteditor-core](https://github.com/kekee000/fonteditor-core)

View File

@ -1,3 +1,8 @@
/** 解析请求 URLreq.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 {

View File

@ -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;

View File

@ -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 };
}

View File

@ -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) {

View File

@ -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);

View File

@ -175,7 +175,7 @@ function App() {
/>
</section>
<Show when={selectedFont() && text()}>
<Show when={selectedFont()}>
<section style={s.section}>
<div style={{ display: "flex", "justify-content": "space-between", "align-items": "center", "margin-bottom": "6px" }}>
<label style={{ ...s.label, margin: "0" }}>CSS </label>
@ -193,7 +193,17 @@ function App() {
</button>
<button
style={{ ...s.btn, padding: "3px 12px", "font-size": "12px" }}
onClick={() => navigator.clipboard.writeText(cssStyle())}
onClick={async (e) => {
const btn = e.currentTarget;
try {
await navigator.clipboard.writeText(cssStyle());
btn.textContent = "已复制";
setTimeout(() => { btn.textContent = "复制 CSS"; }, 1500);
} catch {
btn.textContent = "复制失败";
setTimeout(() => { btn.textContent = "复制 CSS"; }, 1500);
}
}}
>
CSS
</button>
@ -203,6 +213,19 @@ function App() {
</section>
</Show>
<section style={{ ...s.section, "font-size": "12px", color: "#aaa", "line-height": "1.8" }}>
<p><b></b> text URL </p>
<p><b></b> CSS text </p>
<pre style={{ ...s.pre, "font-size": "12px", "margin-top": "4px" }}>{`<style>
@font-face {
font-family: "MyFont";
src: url("https://your-domain/api?font=字体名&text=你的文字") format("truetype");
}
.title { font-family: "MyFont"; }
</style>
<h1 class="title"></h1>`}</pre>
</section>
<UploadSection config={serverConfig()} onUploaded={refreshFonts} />
<style>{throttledCss()}</style>
</div>