web-font/backend/multipart.ts
崮生(子虚) 7fceccfe4d 重构前端UI,新增字体上传/下载/模糊匹配功能
- 前端UI重构:字体选择器、文本预览、CSS输出+复制、下载字体、上传区域
- 后端新增API:/api/fonts(列出字体)、/api/config(公开配置)、/api/upload(上传字体)
- 字体查找支持模糊匹配(精确 > 前缀 > 包含)
- 上传功能分两种模式:临时上传(ENV开关,FIFO上限10个)和管理员上传(API Key认证)
- 新增multipart解析器、配置模块、文件系统抽象(writeFile/readdir/mkdir/unlink)
- 后端启动时等待运行时初始化完成,确保接口可用
- dev-all.ts后端启用tsx watch模式自动重载

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 16:18:46 +08:00

90 lines
2.9 KiB
TypeScript

/**
* 轻量 multipart/form-data 解析器,不依赖外部库
*/
export interface MultipartFile {
/** 表单字段名 */
name: string;
/** 原始文件名 */
filename: string;
/** 文件二进制数据 */
data: Uint8Array;
}
export function parseMultipart(contentType: string, body: ArrayBuffer): MultipartFile {
const boundaryMatch = contentType.match(/boundary=([^\s;]+)/);
if (!boundaryMatch) throw new Error("No boundary found");
const boundary = boundaryMatch[1].replace(/^"(.*)"$/, "$1");
const encoder = new TextEncoder();
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 {
for (let i = offset; i <= haystack.length - needle.length; i++) {
let match = true;
for (let j = 0; j < needle.length; j++) {
if (haystack[i + j] !== needle[j]) {
match = false;
break;
}
}
if (match) return i;
}
return -1;
}
const files: MultipartFile[] = [];
// 跳过起始边界 "--boundary\r\n"
const startBoundary = encoder.encode("--" + boundary + "\r\n");
let pos = 0;
const sbPos = findBytes(bodyBytes, startBoundary, pos);
if (sbPos === -1) throw new Error("Invalid multipart: no start boundary");
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;
const headerText = decoder.decode(bodyBytes.slice(pos, headerEnd));
const bodyStart = headerEnd + 4;
// 查找下一个 boundary
const nextBoundary = findBytes(bodyBytes, delimiter, bodyStart);
if (nextBoundary === -1) break;
// part body 去掉末尾的 "\r\n"
const partBody = bodyBytes.slice(bodyStart, nextBoundary);
const actualBody = partBody.length >= 2 && partBody[partBody.length - 1] === 10 && partBody[partBody.length - 2] === 13
? partBody.slice(0, partBody.length - 2)
: partBody;
// 从 Content-Disposition 中解析字段名和文件名
const nameMatch = headerText.match(/name="([^"]*)"/);
const filenameMatch = headerText.match(/filename="([^"]*)"/);
if (nameMatch) {
files.push({
name: nameMatch[1],
filename: filenameMatch?.[1] ?? "",
data: actualBody,
});
}
pos = nextBoundary + delimiter.length;
// 跳过 boundary 后的 "\r\n"
if (pos < bodyBytes.length && bodyBytes[pos] === 13 && bodyBytes[pos + 1] === 10) {
pos += 2;
}
}
return { ...files[0], files } as MultipartFile & { files: MultipartFile[] };
}