mirror of
https://github.com/2234839/web-font.git
synced 2026-04-29 21:00:45 +08:00
增强安全性,重构代码
This commit is contained in:
parent
41c2741e3e
commit
b902b4e1cc
@ -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所有功能
|
||||
|
||||
@ -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`
|
||||
|
||||
|
||||
@ -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",
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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<typeof Font.create>) => {
|
||||
let optimized = font.optimize();
|
||||
optimized = optimized.compound2simple();
|
||||
optimized = optimized.sort();
|
||||
return optimized;
|
||||
};
|
||||
|
||||
/** 序列化为指定格式的二进制数据 */
|
||||
export const writeFont = (
|
||||
font: ReturnType<ReturnType<typeof Font.create>["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);
|
||||
};
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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<Array<{ name: string; size: number }>> {
|
||||
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<UploadResult> {
|
||||
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);
|
||||
|
||||
18
pnpm-lock.yaml
generated
18
pnpm-lock.yaml
generated
@ -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
|
||||
|
||||
|
||||
@ -252,6 +252,10 @@ function App() {
|
||||
});
|
||||
<\/script>`}</pre>
|
||||
</section>
|
||||
|
||||
<footer style={{ "margin-top": "48px", "padding-top": "16px", "border-top": "1px solid #eee", "font-size": "12px", color: "#999", "text-align": "center" }}>
|
||||
<p>感谢 <a href="https://www.ruanyifeng.com/blog/2020/03/weekly-issue-100.html" target="_blank" rel="noopener noreferrer" style={{ color: "#999" }}>阮一峰科技爱好者周刊(第 100 期)</a> 收录本项目</p>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -115,7 +115,7 @@ function TempUploadCard(props: {
|
||||
<div style={card}>
|
||||
<div style={{ "font-size": "14px", "font-weight": 500, "margin-bottom": "4px" }}>游客上传</div>
|
||||
<div style={{ "font-size": "12px", color: "#999", "margin-bottom": "12px" }}>
|
||||
临时文件,最多保留 10 个,超出后自动删除最早上传的
|
||||
临时文件,最多保留 10 个,总大小限制 200MB,超出后自动删除最早上传的
|
||||
</div>
|
||||
<FileUploader
|
||||
disabled={!props.fileName}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user