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

View File

@@ -616,8 +616,29 @@ export interface PublicSettings {
anim_chrome: boolean;
/** 帖内 Markdown 外链是否新标签打开;缺省 true */
post_link_new_tab: boolean;
/** 是否限制附件扩展名;缺省 true */
attachment_ext_limit: boolean;
/** 允许的扩展名(无点);缺省论坛向列表 */
attachment_exts: string[];
/** 附件单文件上限 MB;缺省 20 */
attachment_max_mb: number;
/** 每帖附件个数;缺省 10 */
attachment_max_count: number;
/** 正文插图上限 MB;缺省 5 */
image_max_mb: number;
}
/** 与后端 DefaultAttachmentExts 保持同步 */
export const DEFAULT_ATTACHMENT_EXTS: string[] = [
"pdf", "txt", "md", "csv", "json",
"doc", "docx", "xls", "xlsx", "ppt", "pptx",
"zip", "7z", "rar", "tar", "gz", "tgz",
"jpg", "jpeg", "png", "webp", "gif",
"mp3", "wav", "ogg", "mp4", "webm",
"exe", "msi", "msp", "apk", "dmg", "iso", "deb", "rpm", "dll",
"html", "htm", "js", "mjs", "css", "svg", "xml", "bat", "cmd", "ps1", "sh",
];
const DEFAULT_PUBLIC_SETTINGS: PublicSettings = {
accent: "",
trust_reviewed_publish: true,
@@ -634,9 +655,45 @@ const DEFAULT_PUBLIC_SETTINGS: PublicSettings = {
anim_smooth_scroll: true,
anim_chrome: true,
post_link_new_tab: true,
attachment_ext_limit: true,
attachment_exts: [...DEFAULT_ATTACHMENT_EXTS],
attachment_max_mb: 20,
attachment_max_count: 10,
image_max_mb: 5,
};
function normalizeAttachmentExts(raw: unknown): string[] {
if (!Array.isArray(raw)) return [...DEFAULT_ATTACHMENT_EXTS];
const seen = new Set<string>();
const out: string[] = [];
for (const item of raw) {
const e = String(item || "")
.trim()
.toLowerCase()
.replace(/^\./, "");
if (!/^[a-z0-9]{1,16}$/.test(e) || seen.has(e)) continue;
seen.add(e);
out.push(e);
if (out.length >= 80) break;
}
return out;
}
function normalizePublicSettings(data: Partial<PublicSettings> | null | undefined): PublicSettings {
const maxMB =
typeof data?.attachment_max_mb === "number" && data.attachment_max_mb >= 1
? Math.round(data.attachment_max_mb)
: DEFAULT_PUBLIC_SETTINGS.attachment_max_mb;
const maxCount =
typeof data?.attachment_max_count === "number" &&
data.attachment_max_count >= 1 &&
data.attachment_max_count <= 20
? Math.round(data.attachment_max_count)
: DEFAULT_PUBLIC_SETTINGS.attachment_max_count;
const imageMB =
typeof data?.image_max_mb === "number" && data.image_max_mb >= 1
? Math.round(data.image_max_mb)
: DEFAULT_PUBLIC_SETTINGS.image_max_mb;
return {
accent: data?.accent ?? "",
trust_reviewed_publish: data?.trust_reviewed_publish !== false,
@@ -664,6 +721,14 @@ function normalizePublicSettings(data: Partial<PublicSettings> | null | undefine
anim_smooth_scroll: data?.anim_smooth_scroll !== false,
anim_chrome: data?.anim_chrome !== false,
post_link_new_tab: data?.post_link_new_tab !== false,
attachment_ext_limit: data?.attachment_ext_limit !== false,
attachment_exts:
data?.attachment_exts !== undefined
? normalizeAttachmentExts(data.attachment_exts)
: [...DEFAULT_ATTACHMENT_EXTS],
attachment_max_mb: maxMB,
attachment_max_count: maxCount,
image_max_mb: imageMB,
};
}
@@ -1042,7 +1107,20 @@ export async function apiUploadPostFile(
headers: clientHeaders(),
body: fd,
});
return res.json();
const ct = res.headers.get("content-type") || "";
if (!ct.includes("application/json")) {
if (res.status === 413) return { error: "附件过大" };
if (res.status >= 500) return { error: "上传失败,请稍后重试" };
return { error: "上传失败" };
}
const data = (await res.json().catch(() => ({}))) as {
attachment?: PostAttachment;
error?: string;
};
if (!res.ok) {
return { error: data.error || (res.status === 413 ? "附件过大" : "上传失败") };
}
return data;
}
export async function apiDeletePostFile(id: number) {
@@ -1272,7 +1350,21 @@ export async function apiUploadPostImage(
headers: clientHeaders(),
body: form,
});
return res.json();
const ct = res.headers.get("content-type") || "";
if (!ct.includes("application/json")) {
if (res.status === 413) return { error: "图片过大" };
if (res.status >= 500) return { error: "上传失败,请稍后重试" };
return { error: "上传失败" };
}
const data = (await res.json().catch(() => ({}))) as {
url?: string;
attachment?: MediaAttachment;
error?: string;
};
if (!res.ok) {
return { error: data.error || (res.status === 413 ? "图片过大" : "上传失败") };
}
return data;
}
// 选用一张本人历史上传的头像
@@ -1804,6 +1896,11 @@ export type UpdateSiteSettingsBody = {
anim_smooth_scroll?: boolean;
anim_chrome?: boolean;
post_link_new_tab?: boolean;
attachment_ext_limit?: boolean;
attachment_exts?: string[];
attachment_max_mb?: number;
attachment_max_count?: number;
image_max_mb?: number;
};
// 更新站点设置(字段可选);accent 传空串恢复默认主题色