web-font/backend/upload.ts
2026-04-08 22:05:17 +08:00

130 lines
4.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { writeFile, unlink, readdir, stat, path_join } from "./interface";
import { enableTempUpload, adminApiKey, tempMaxFiles, tempMaxTotalSize } from "./config";
/** 允许的字体文件扩展名 */
const ALLOWED_EXTENSIONS = [".ttf", ".otf", ".woff", ".woff2"];
function isAllowedFontFile(filename: string): boolean {
const lower = filename.toLowerCase();
return ALLOWED_EXTENSIONS.some((ext) => lower.endsWith(ext));
}
/** 清理文件名,移除路径分隔符、危险字符和路径穿越成分 */
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;
}
/** 确保目录存在,不存在则创建 */
async function ensureDir(dir: string) {
const { stat, mkdir } = await import("./interface");
try {
await stat(dir);
} catch {
await mkdir(dir);
}
}
export interface UploadResult {
success: boolean;
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: "临时上传功能未启用" };
}
if (!isAllowedFontFile(fileData.filename)) {
return { success: false, error: "不支持的字体文件格式,仅支持 ttf/otf/woff/woff2" };
}
await ensureDir("font/temp");
const filename = sanitizeFilename(fileData.filename);
const filePath = path_join("font/temp", filename);
/** 同名文件直接覆盖(覆盖不算新增),否则检查限制并清理 */
try {
const existing = await stat(filePath);
/** 覆盖时,新文件可能比旧文件大,仍需检查总体积 */
await evictIfNeeded(fileData.data.length - existing.size);
} catch {
await evictIfNeeded(fileData.data.length);
}
await writeFile(filePath, fileData.data);
return { success: true };
}
export async function handleAdminUpload(
fileData: { data: Uint8Array; filename: string },
apiKey: string,
): Promise<UploadResult> {
if (!adminApiKey) {
return { success: false, error: "管理员上传功能未启用" };
}
if (apiKey !== adminApiKey) {
return { success: false, error: "API Key 无效" };
}
if (!isAllowedFontFile(fileData.filename)) {
return { success: false, error: "不支持的字体文件格式,仅支持 ttf/otf/woff/woff2" };
}
await ensureDir("font/admin");
const filename = sanitizeFilename(fileData.filename);
await writeFile(path_join("font/admin", filename), fileData.data);
return { success: true };
}