增强安全性,重构代码

This commit is contained in:
崮生(子虚) 2026-04-08 22:05:17 +08:00
parent 41c2741e3e
commit b902b4e1cc
10 changed files with 157 additions and 67 deletions

View File

@ -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所有功能

View File

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

View File

@ -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",
},
}),
};

View File

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

View File

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

View File

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

View File

@ -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
View File

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

View File

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

View File

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