From b902b4e1cc100912ec92917c6583da3717da3bf2 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 22:05:17 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=BC=BA=E5=AE=89=E5=85=A8=E6=80=A7?= =?UTF-8?q?=EF=BC=8C=E9=87=8D=E6=9E=84=E4=BB=A3=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 2 +- README.md | 3 +- backend/app.ts | 12 +++++-- backend/config.ts | 3 ++ backend/font_util/font.ts | 68 +++++++++++++++++++++++++---------- backend/interface.ts | 38 ++++++++++++-------- backend/upload.ts | 74 +++++++++++++++++++++++++++++++-------- pnpm-lock.yaml | 18 +++------- src/App.tsx | 4 +++ src/UploadSection.tsx | 2 +- 10 files changed, 157 insertions(+), 67 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 3dce32c..f93c9f5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,5 +1,5 @@ pnpm dev 可以同时启动前后端,并且都会修改代码后自动重载 -- pnpm docker_push 发布当前项目的docker镜像 +- pnpm release 构建并发布当前项目的docker镜像 ## 浏览器测试(vite-plugin-pilot) 已安装。`npx pilot run '代码'` 执行 JS(返回结果+日志+快照)、`npx pilot page` 页面状态 `npx pilot help` 查看pilot所有功能 diff --git a/README.md b/README.md index 26f035d..6077239 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,7 @@ services: environment: - ENABLE_TEMP_UPLOAD=true # 开启临时上传(默认 false) - TEMP_MAX_FILES=10 # 临时上传最大文件数(默认 10) + - TEMP_MAX_TOTAL_SIZE=209715200 # 临时上传目录总体积上限,单位字节(默认 209715200,即 200MB) - ADMIN_API_KEY=你的管理员密钥 # 设置后开启管理员上传,不设置则不可用 deploy: resources: @@ -81,7 +82,7 @@ services: ### 上传功能 -- **临时上传**:设置环境变量 `ENABLE_TEMP_UPLOAD=true` 启用,最多保留 10 个字体文件,超出后自动删除最早上传的(FIFO) +- **临时上传**:设置环境变量 `ENABLE_TEMP_UPLOAD=true` 启用,最多保留 10 个字体文件(`TEMP_MAX_FILES`),总大小限制 200MB(`TEMP_MAX_TOTAL_SIZE`),超出后自动删除最早上传的(FIFO) - **管理员上传**:设置环境变量 `ADMIN_API_KEY=你的密钥` 启用,上传的字体永久保存,需要通过 API Key 认证 - 支持的字体格式:`.ttf` `.otf` `.woff` `.woff2` diff --git a/backend/app.ts b/backend/app.ts index 1b5a061..fc41761 100644 --- a/backend/app.ts +++ b/backend/app.ts @@ -108,6 +108,14 @@ const staticFileMiddleware: cMiddleware = async function (req, res, next) { if (req.method === "GET") { const url = parseUrl(req); const filePath = path_join(ROOT_DIR, url.pathname === "/" ? "index.html" : url.pathname); + /** 防止路径穿越:规范化后必须仍在 dist 目录内 */ + if (!filePath.startsWith(ROOT_DIR + "/") && filePath !== ROOT_DIR) { + newRes = new Response("403 Forbidden", { + status: 403, + headers: { "Content-Type": "text/plain; charset=utf-8" }, + }); + return next(req, newRes); + } try { const stats = await stat(filePath); @@ -131,8 +139,7 @@ const staticFileMiddleware: cMiddleware = async function (req, res, next) { } } catch (err) { console.log("[err]", err); - // @ts-ignore - newRes = new Response(`服务器内部错误 Not Found\n${err}\n${err.stack}`, { + newRes = new Response("500 Internal Server Error", { status: 500, headers: { "Content-Type": "text/plain; charset=utf-8", @@ -292,6 +299,7 @@ async function handleFontSubset(req: Request, res: Response) { status: 200, headers: { "Content-Type": "font/ttf", + "Cache-Control": "public, max-age=31536000, immutable", }, }), }; diff --git a/backend/config.ts b/backend/config.ts index a76b7c1..6643284 100644 --- a/backend/config.ts +++ b/backend/config.ts @@ -12,5 +12,8 @@ export const adminApiKey: string = env.ADMIN_API_KEY ?? ""; /** 临时上传目录最大文件数 */ export const tempMaxFiles = parseInt(env.TEMP_MAX_FILES ?? "10", 10) || 10; +/** 临时上传目录总体积上限(字节),默认 200MB */ +export const tempMaxTotalSize = parseInt(env.TEMP_MAX_TOTAL_SIZE ?? `${200 * 1024 * 1024}`, 10) || 200 * 1024 * 1024; + /** 字体搜索目录(按优先级排序) */ export const fontDirs = ["font", "font/temp", "font/admin"] as const; diff --git a/backend/font_util/font.ts b/backend/font_util/font.ts index 875a5e8..83bbd2f 100644 --- a/backend/font_util/font.ts +++ b/backend/font_util/font.ts @@ -1,26 +1,56 @@ import { Font, type FontEditor } from "fonteditor-core"; +/** + * 字体裁剪的所有可配置步骤 + * 每个步骤独立导出,方便组合使用和单独测试 + */ + +/** 从字符串提取 Unicode 码点数组 */ +export const textToCodePoints = (text: string) => + [...text].map((char) => char.codePointAt(0)!); + +/** 解析字体并执行 subset(最耗时的步骤) */ +export const createSubsetFont = ( + fontBuffer: ArrayBuffer, + codePoints: number[], + sourceType: FontEditor.FontType, +) => + Font.create(fontBuffer, { + type: sourceType, + subset: codePoints, + }); + +/** 优化字体(去冗余表、清理无用字形) */ +export const optimizeFont = (font: ReturnType) => { + let optimized = font.optimize(); + optimized = optimized.compound2simple(); + optimized = optimized.sort(); + return optimized; +}; + +/** 序列化为指定格式的二进制数据 */ +export const writeFont = ( + font: ReturnType["optimize"]>, + outType: FontEditor.FontType, +) => { + const result = font.write({ type: outType }); + if (typeof result !== "string") { + return new Uint8Array(result); + } + return new TextEncoder().encode(result); +}; + +/** + * 完整的字体裁剪流程(当前生产实现) + * 解析 -> subset -> 优化 -> 序列化 + */ export const fontSubset = async ( fontBuffer: ArrayBuffer, subString: string, - option: { - sourceType: FontEditor.FontType; - outType: FontEditor.FontType; - }, + option: { sourceType: FontEditor.FontType; outType: FontEditor.FontType }, ) => { - const font = Font.create(fontBuffer, { - type: option.sourceType, - subset: [...subString].map((char) => char.codePointAt(0)!), - }); - // 优化字体 - let optimizedFont = font.optimize(); - optimizedFont = optimizedFont.compound2simple(); - optimizedFont = optimizedFont.sort(); - const newFont = optimizedFont.write({ - type: option.outType, - }); - if (typeof newFont !== "string") { - return new Uint8Array(newFont); - } - return newFont; + const codePoints = textToCodePoints(subString); + const font = createSubsetFont(fontBuffer, codePoints, option.sourceType); + const optimized = optimizeFont(font); + return writeFont(optimized, option.outType); }; diff --git a/backend/interface.ts b/backend/interface.ts index 3a2447d..c55eaf8 100644 --- a/backend/interface.ts +++ b/backend/interface.ts @@ -33,27 +33,35 @@ export const implInterface = (options: { }; export function path_join(...paths: string[]) { - // 定义路径分隔符 const sep = "/"; - // 函数用来移除路径片段两端的斜杠 function trimSlashes(p: string) { return p.replace(/\/+$/, "").replace(/^\/+/, ""); } - // 处理路径片段 - let result = paths - .map((path) => trimSlashes(path)) // 移除每个路径片段的前后斜杠 - .filter(Boolean) // 过滤掉空片段 - .join(sep); // 使用分隔符连接路径片段 - - // 如果最终路径为空,返回根路径 - if (!result) return sep; - - // 确保路径以分隔符开头 - if (paths[0] && paths[0].startsWith(sep)) { - result = sep + result; + /** 将路径按 / 分割并解析 . 和 .. 段 */ + function normalizeSegments(segments: string[]) { + const resolved: string[] = []; + for (const seg of segments) { + if (seg === "..") { + resolved.pop(); + } else if (seg !== "." && seg !== "") { + resolved.push(seg); + } + } + return resolved; } - return result; + const isAbsolute = paths[0] && paths[0].startsWith(sep); + const segments = paths + .map((path) => trimSlashes(path)) + .join(sep) + .split(sep); + + const resolved = normalizeSegments(segments); + + if (!resolved.length) return isAbsolute ? sep : "."; + + const result = resolved.join(sep); + return isAbsolute ? sep + result : result; } diff --git a/backend/upload.ts b/backend/upload.ts index f2a0ec6..c28b42b 100644 --- a/backend/upload.ts +++ b/backend/upload.ts @@ -1,5 +1,5 @@ -import { writeFile, unlink, readdir, path_join } from "./interface"; -import { enableTempUpload, adminApiKey, tempMaxFiles } from "./config"; +import { writeFile, unlink, readdir, stat, path_join } from "./interface"; +import { enableTempUpload, adminApiKey, tempMaxFiles, tempMaxTotalSize } from "./config"; /** 允许的字体文件扩展名 */ const ALLOWED_EXTENSIONS = [".ttf", ".otf", ".woff", ".woff2"]; @@ -9,9 +9,15 @@ function isAllowedFontFile(filename: string): boolean { return ALLOWED_EXTENSIONS.some((ext) => lower.endsWith(ext)); } -/** 清理文件名,移除路径分隔符和危险字符 */ -function sanitizeFilename(filename: string): string { - return filename.replace(/[/\\]/g, "").replace(/[\x00-\x1f]/g, ""); +/** 清理文件名,移除路径分隔符、危险字符和路径穿越成分 */ +function sanitizeFilename(filename: string) { + let name = filename.replace(/[/\\]/g, "").replace(/[\x00-\x1f]/g, ""); + /** 移除所有 .. 防止路径穿越 */ + name = name.replace(/\.\./g, ""); + /** 取基础文件名,防止以 . 开头的隐藏文件 */ + name = name.replace(/^\./, ""); + if (!name) return "unnamed.ttf"; + return name; } /** 确保目录存在,不存在则创建 */ @@ -29,6 +35,49 @@ export interface UploadResult { error?: string; } +/** + * 获取 temp 目录中所有字体文件及其大小 + * 返回按名称排序的列表(文件系统 readdir 顺序近似 FIFO) + */ +async function getTempFontFiles(): Promise> { + const entries = await readdir("font/temp"); + const files: Array<{ name: string; size: number }> = []; + for (const entry of entries) { + if (entry.isFile() && isAllowedFontFile(entry.name)) { + const s = await stat(path_join("font/temp", entry.name)); + files.push({ name: entry.name, size: s.size }); + } + } + return files; +} + +/** + * 按需清理 temp 目录,使文件数量和总体积满足限制 + * @param incomingSize 即将上传的文件大小,用于判断总体积是否超限 + */ +async function evictIfNeeded(incomingSize: number) { + const files = await getTempFontFiles(); + + /** 数量超限时删除最早的文件 */ + while (files.length >= tempMaxFiles) { + const oldest = files.shift(); + if (!oldest) break; + try { await unlink(path_join("font/temp", oldest.name)); } catch { /* 删除失败不影响上传 */ } + } + + /** 总体积超限时继续删除,直到腾出足够空间 */ + const totalSize = files.reduce((sum, f) => sum + f.size, 0); + const needed = totalSize + incomingSize - tempMaxTotalSize; + if (needed > 0) { + let freed = 0; + for (const file of files) { + if (freed >= needed) break; + try { await unlink(path_join("font/temp", file.name)); } catch { /* 删除失败不影响上传 */ } + freed += file.size; + } + } +} + export async function handleTempUpload(fileData: { data: Uint8Array; filename: string }): Promise { if (!enableTempUpload) { return { success: false, error: "临时上传功能未启用" }; @@ -43,18 +92,13 @@ export async function handleTempUpload(fileData: { data: Uint8Array; filename: s const filename = sanitizeFilename(fileData.filename); const filePath = path_join("font/temp", filename); - /** 同名文件直接覆盖,否则检查文件数量上限 */ + /** 同名文件直接覆盖(覆盖不算新增),否则检查限制并清理 */ try { - await (await import("./interface")).stat(filePath); + const existing = await stat(filePath); + /** 覆盖时,新文件可能比旧文件大,仍需检查总体积 */ + await evictIfNeeded(fileData.data.length - existing.size); } catch { - const entries = await readdir("font/temp"); - const count = entries.filter((e) => e.isFile() && isAllowedFontFile(e.name)).length; - if (count >= tempMaxFiles) { - const toDelete = entries.find((e) => e.isFile() && isAllowedFontFile(e.name)); - if (toDelete) { - try { await unlink(path_join("font/temp", toDelete.name)); } catch { /* 删除失败不影响上传 */ } - } - } + await evictIfNeeded(fileData.data.length); } await writeFile(filePath, fileData.data); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8cb3804..42c94ff 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,8 +9,8 @@ importers: .: dependencies: fonteditor-core: - specifier: ^2.6.3 - version: 2.6.3 + specifier: file:./vendor/fonteditor-core + version: file:vendor/fonteditor-core solid-js: specifier: ^1.9.12 version: 1.9.12 @@ -18,9 +18,6 @@ importers: specifier: ^4.2.0 version: 4.2.0 devDependencies: - '@txikijs/types': - specifier: ^26.4.0 - version: 26.4.0 '@types/node': specifier: ^25.5.2 version: 25.5.2 @@ -552,9 +549,6 @@ packages: cpu: [x64] os: [win32] - '@txikijs/types@26.4.0': - resolution: {integrity: sha512-dsE+EkyzHgYOhmBEF0uU/zV8WR7MSTJ+6bLp4cHDhA79qiSLgxgAcOrKwTzMuDt2jvBHUg8N7CqSaOABV2BnQA==} - '@tybys/wasm-util@0.10.1': resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} @@ -687,8 +681,8 @@ packages: fix-dts-default-cjs-exports@1.0.1: resolution: {integrity: sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==} - fonteditor-core@2.6.3: - resolution: {integrity: sha512-YUryIKjkenjZ41E7JvM3V+02Ak4mTHDDTwBWgs9KBzypzHqLZHuua1UDRevZNTKawmnq1dbBAa70Jddl2+F4FQ==} + fonteditor-core@file:vendor/fonteditor-core: + resolution: {directory: vendor/fonteditor-core, type: directory} fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} @@ -1441,8 +1435,6 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.60.1': optional: true - '@txikijs/types@26.4.0': {} - '@tybys/wasm-util@0.10.1': dependencies: tslib: 2.8.1 @@ -1581,7 +1573,7 @@ snapshots: mlly: 1.8.2 rollup: 4.60.1 - fonteditor-core@2.6.3: + fonteditor-core@file:vendor/fonteditor-core: dependencies: '@xmldom/xmldom': 0.8.12 diff --git a/src/App.tsx b/src/App.tsx index 16eea6a..62920a1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -252,6 +252,10 @@ function App() { }); <\/script>`} + + ); } diff --git a/src/UploadSection.tsx b/src/UploadSection.tsx index e252dc4..e1d9659 100644 --- a/src/UploadSection.tsx +++ b/src/UploadSection.tsx @@ -115,7 +115,7 @@ function TempUploadCard(props: {
游客上传
- 临时文件,最多保留 10 个,超出后自动删除最早上传的 + 临时文件,最多保留 10 个,总大小限制 200MB,超出后自动删除最早上传的