feat(attach): 附件可配置限额、流式落盘与分阶段上传

管理端可配类型/体积;上传改流式避免整文件进内存;发帖先选再传并拦截未上传提交;提高 Next proxy 体积上限。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-17 07:33:16 +08:00
parent 6cc434f103
commit 61bf9fb48c
24 changed files with 2513 additions and 307 deletions

58
frontend/lib/postFile.ts Normal file
View File

@@ -0,0 +1,58 @@
/** 帖子附件客户端校验(与后端 setting / fileallow 对齐;安全边界仍在 Go) */
export const FILE_MAX_MB_DEFAULT = 20;
export const FILE_MAX_COUNT_DEFAULT = 10;
export function normalizeExt(nameOrExt: string): string {
const base = nameOrExt.includes("/")
? nameOrExt.split(/[/\\]/).pop() || ""
: nameOrExt;
const i = base.lastIndexOf(".");
const ext = (i >= 0 ? base.slice(i + 1) : base).trim().toLowerCase();
return ext.replace(/[^a-z0-9]/g, "").slice(0, 16);
}
export function buildAcceptAttr(exts: string[], limit: boolean): string | undefined {
if (!limit) return undefined;
if (!exts.length) return undefined;
return exts.map((e) => `.${e}`).join(",");
}
export function isAllowedPostFileName(
name: string,
opts: { limit: boolean; exts: string[] }
): boolean {
if (!opts.limit) return true;
const ext = normalizeExt(name);
if (!ext) return false;
return opts.exts.includes(ext);
}
export function formatMB(n: number): string {
return `${n}MB`;
}
export type PostFileRejectReason = "size" | "type" | "count" | null;
export function rejectPostFile(
file: File,
opts: {
limit: boolean;
exts: string[];
maxMB: number;
maxCount: number;
currentCount: number;
}
): { reason: PostFileRejectReason; message?: string } {
if (opts.currentCount >= opts.maxCount) {
return { reason: "count", message: `最多 ${opts.maxCount} 个附件` };
}
const maxBytes = opts.maxMB * 1024 * 1024;
if (file.size > maxBytes) {
return { reason: "size", message: `附件不能超过 ${opts.maxMB}MB` };
}
if (!isAllowedPostFileName(file.name, opts)) {
return { reason: "type", message: "不支持该附件格式" };
}
return { reason: null };
}