web-font/backend/app.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

344 lines
9.7 KiB
TypeScript

import { fontSubset } from "./font_util/font";
import { mimeTypes } from "./server/mime_type";
import type { cMiddleware } from "./server/req_res";
import { SimpleHttpServer } from "./server/server";
import { path_join, readFile, stat, readdir, mkdir } from "./interface";
import { enableTempUpload, adminApiKey, fontDirs } from "./config";
import { parseMultipart } from "./multipart";
import { handleTempUpload, handleAdminUpload } from "./upload";
let release_name = global.tjs ? "tjs" : globalThis?.process?.release?.name;
let runtimeReady: Promise<void>;
if (release_name === "tjs") {
runtimeReady = import("./server/tjs").then(() => {});
} else if (release_name === "node" || release_name === "llrt") {
runtimeReady = import("./server/node").then(() => {});
} else {
runtimeReady = Promise.resolve();
}
if (release_name === "llrt") {
runtimeReady = runtimeReady.then(() => import("./server/llrt").then(() => {}));
}
const ROOT_DIR = "dist";
/** 启动时确保必要目录存在 */
async function ensureDirectories() {
for (const dir of ["font/temp", "font/admin"]) {
try {
await stat(dir);
} catch {
await mkdir(dir);
}
}
}
/**
* 在所有字体目录中查找字体文件
* 匹配优先级:精确匹配 > 前缀匹配 > 包含匹配
* @returns 找到的字体完整路径,未找到则返回 null
*/
async function findFontPath(filename: string): Promise<string | null> {
// 先尝试精确匹配
for (const dir of fontDirs) {
const filePath = path_join(dir, filename);
try {
const s = await stat(filePath);
if (s.isFile()) return filePath;
} catch {
// 继续搜索
}
}
// 收集所有字体文件名(不含扩展名)和完整路径
const allFonts: Array<{ basename: string; path: string }> = [];
for (const dir of fontDirs) {
try {
const entries = await readdir(dir);
for (const entry of entries) {
if (entry.isFile() && /\.(ttf|otf|woff|woff2)$/i.test(entry.name)) {
allFonts.push({
basename: entry.name.replace(/\.[^.]+$/, ""),
path: path_join(dir, entry.name),
});
}
}
} catch {
// 目录不存在,跳过
}
}
const query = filename.replace(/\.[^.]+$/, "").toLowerCase();
// 前缀匹配
for (const f of allFonts) {
if (f.basename.toLowerCase().startsWith(query)) return f.path;
}
// 包含匹配
for (const f of allFonts) {
if (f.basename.toLowerCase().includes(query)) return f.path;
}
return null;
}
/** JSON 响应工具 */
function jsonResponse(data: unknown, status = 200) {
return new Response(JSON.stringify(data), {
status,
headers: { "Content-Type": "application/json" },
});
}
const logMiddleware: cMiddleware = async (req, res, next) => {
const t1 = Date.now();
const r = await next(req, res);
const t2 = Date.now();
const url = new URL(req.url);
console.log(`[${t2 - t1}ms] ${req.method} ${url.pathname}`);
return r;
};
const staticFileMiddleware: cMiddleware = async function (req, res, next) {
let newRes: Response;
if (req.method === "GET") {
const url = new URL(req.url);
const filePath = path_join(ROOT_DIR, url.pathname === "/" ? "index.html" : url.pathname);
try {
const stats = await stat(filePath);
if (stats.isFile()) {
const fileContent = await readFile(filePath);
const extname = filePath.split(".").pop() ?? "";
newRes = new Response(fileContent, {
status: 200,
headers: {
"Content-Type": mimeTypes[extname] || "application/octet-stream",
"Content-Length": `${stats.size}`,
},
});
} else {
newRes = new Response("404 Not Found", {
status: 404,
headers: {
"Content-Type": "text/plain; charset=utf-8",
},
});
}
} catch (err) {
console.log("[err]", err);
// @ts-ignore
newRes = new Response(`服务器内部错误 Not Found\n${err}\n${err.stack}`, {
status: 500,
headers: {
"Content-Type": "text/plain; charset=utf-8",
},
});
}
} else {
newRes = new Response("Method Not Allowed", {
status: 405,
headers: {
"Content-Type": "text/plain; charset=utf-8",
},
});
}
return next(req, newRes);
};
const corsMiddleware: cMiddleware = async (req, res, next) => {
if (req.method === "OPTIONS") {
return {
req,
res: new Response("", {
status: 204,
headers: {
"Content-Length": "0",
},
}),
};
} else {
const newRes = await next(req, res);
newRes.res.headers.append("Access-Control-Allow-Origin", "*");
newRes.res.headers.append("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
newRes.res.headers.append("Access-Control-Allow-Headers", "Content-Type, Authorization");
return newRes;
}
};
/** GET /api/fonts — 列出所有可用字体 */
async function handleListFonts(req: Request, res: Response) {
const allFonts: Array<{ name: string; dir: string }> = [];
for (const dir of fontDirs) {
try {
const entries = await readdir(dir);
for (const entry of entries) {
if (entry.isFile() && /\.(ttf|otf|woff|woff2)$/i.test(entry.name)) {
allFonts.push({ name: entry.name, dir });
}
}
} catch {
// 目录不存在,跳过
}
}
return { req, res: jsonResponse(allFonts) };
}
/** GET /api/config — 返回公开配置 */
async function handleGetConfig(req: Request, res: Response) {
return {
req,
res: jsonResponse({
enableTempUpload,
adminUploadEnabled: !!adminApiKey,
}),
};
}
/** POST /api/upload?mode=temp|admin — 上传字体 */
async function handleUpload(req: Request, res: Response) {
const url = new URL(req.url, "https://webfont.shenzilong.com");
const mode = url.searchParams.get("mode") ?? "temp";
const contentType = req.headers.get("Content-Type") ?? "";
let body: ArrayBuffer;
try {
body = await req.arrayBuffer();
} catch {
return { req, res: jsonResponse({ success: false, error: "读取请求体失败" }, 400) };
}
let parsed;
try {
parsed = parseMultipart(contentType, body);
} catch {
return { req, res: jsonResponse({ success: false, error: "无效的 multipart 数据" }, 400) };
}
if (!parsed.files || parsed.files.length === 0) {
return { req, res: jsonResponse({ success: false, error: "未提供文件" }, 400) };
}
const file = parsed.files[0];
if (mode === "admin") {
const authHeader = req.headers.get("Authorization") ?? "";
const apiKey = authHeader.replace("Bearer ", "");
const result = await handleAdminUpload({ data: file.data, filename: file.filename }, apiKey);
return { req, res: jsonResponse(result, result.success ? 200 : 403) };
}
// 默认:临时上传
const result = await handleTempUpload({ data: file.data, filename: file.filename });
return { req, res: jsonResponse(result, result.success ? 200 : 400) };
}
/** GET /api?font=...&text=... — 字体裁剪 */
async function handleFontSubset(req: Request, res: Response) {
const url = new URL(req.url, "https://webfont.shenzilong.com");
const params = new URLSearchParams(url.search);
const font = params.get("font") || "";
const text = params.get("text") || "";
if (text.length === 0) {
return { req, res };
}
const fontPath = await findFontPath(font);
if (!fontPath) {
return {
req,
res: new Response(`Font not found: ${font}`, {
status: 404,
headers: { "Content-Type": "text/plain; charset=utf-8" },
}),
};
}
const fontType = fontPath.split(".").pop() as "ttf";
let oldFontBuffer: ArrayBuffer;
try {
oldFontBuffer = new Uint8Array(await readFile(fontPath)).buffer;
} catch {
return {
req,
res: new Response(`Font read error: ${font}`, {
status: 500,
headers: { "Content-Type": "text/plain; charset=utf-8" },
}),
};
}
const outType = "ttf";
const newFont = await fontSubset(oldFontBuffer, text, {
outType: outType,
sourceType: fontType,
});
return {
req,
res: new Response(newFont, {
status: 200,
headers: {
"Content-Type": "font/ttf",
},
}),
};
}
/** 统一的 API 路由中间件 */
const fontApiMiddleware: cMiddleware = async (req, res, next) => {
const url = new URL(req.url, "https://webfont.shenzilong.com");
if (!url.pathname.startsWith("/api")) return next(req, res);
if (url.pathname === "/api/fonts" && req.method === "GET") {
return handleListFonts(req, res);
}
if (url.pathname === "/api/config" && req.method === "GET") {
return handleGetConfig(req, res);
}
if (url.pathname === "/api/upload" && req.method === "POST") {
return handleUpload(req, res);
}
if (url.pathname === "/api" && req.method === "GET") {
return handleFontSubset(req, res);
}
return next(req, res);
};
/** 上传文件大小限制 50MB */
const MAX_UPLOAD_SIZE = 50 * 1024 * 1024;
const uploadSizeMiddleware: cMiddleware = async (req, res, next) => {
if (req.method === "POST" && new URL(req.url).pathname === "/api/upload") {
const contentLength = parseInt(req.headers.get("Content-Length") ?? "0", 10);
if (contentLength > MAX_UPLOAD_SIZE) {
return {
req,
res: jsonResponse({ success: false, error: "文件过大,最大 50MB" }, 413),
};
}
}
return next(req, res);
};
async function main() {
await runtimeReady;
await ensureDirectories();
const server = new SimpleHttpServer({ port: 8087 });
server.use(
logMiddleware,
corsMiddleware,
uploadSizeMiddleware,
fontApiMiddleware,
staticFileMiddleware,
);
console.log("[config] temp upload:", enableTempUpload);
console.log("[config] admin upload:", !!adminApiKey);
}
main();