mirror of
https://github.com/2234839/web-font.git
synced 2026-04-29 21:00:45 +08:00
修复上传功能和服务器健壮性问题
- 修复 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:
parent
032f408f9c
commit
e0f34c36b6
24
README.md
24
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 很小的包体积 
|
||||
|
||||
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)
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 };
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
27
src/App.tsx
27
src/App.tsx
@ -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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user