feat(attach): 附件可配置限额、流式落盘与分阶段上传
管理端可配类型/体积;上传改流式避免整文件进内存;发帖先选再传并拦截未上传提交;提高 Next proxy 体积上限。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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 传空串恢复默认主题色
|
||||
|
||||
49
frontend/lib/fileKind.ts
Normal file
49
frontend/lib/fileKind.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
/** 附件类型徽章分类(详情页与发帖编辑共用) */
|
||||
|
||||
export type FileKind = "image" | "archive" | "doc" | "media" | "file";
|
||||
|
||||
export function fileKind(name: string, mime: string): FileKind {
|
||||
const m = (mime || "").toLowerCase();
|
||||
const ext = (name.split(".").pop() || "").toLowerCase();
|
||||
if (
|
||||
m.startsWith("image/") ||
|
||||
["png", "jpg", "jpeg", "gif", "webp", "svg", "bmp", "avif", "ico", "heic"].includes(ext)
|
||||
) {
|
||||
return "image";
|
||||
}
|
||||
if (
|
||||
m.includes("zip") ||
|
||||
m.includes("rar") ||
|
||||
m.includes("7z") ||
|
||||
m.includes("compress") ||
|
||||
["zip", "rar", "7z", "tar", "gz", "bz2", "xz", "tgz"].includes(ext)
|
||||
) {
|
||||
return "archive";
|
||||
}
|
||||
if (
|
||||
m.startsWith("audio/") ||
|
||||
m.startsWith("video/") ||
|
||||
["mp3", "wav", "flac", "aac", "ogg", "mp4", "mkv", "webm", "mov", "avi"].includes(ext)
|
||||
) {
|
||||
return "media";
|
||||
}
|
||||
if (
|
||||
m.includes("pdf") ||
|
||||
m.includes("word") ||
|
||||
m.includes("text") ||
|
||||
m.includes("sheet") ||
|
||||
m.includes("excel") ||
|
||||
["pdf", "doc", "docx", "txt", "md", "xls", "xlsx", "ppt", "pptx", "csv", "json"].includes(ext)
|
||||
) {
|
||||
return "doc";
|
||||
}
|
||||
return "file";
|
||||
}
|
||||
|
||||
export function fileExtBadge(name: string): string {
|
||||
const i = name.lastIndexOf(".");
|
||||
if (i <= 0 || i === name.length - 1) return "FILE";
|
||||
const ext = name.slice(i + 1).replace(/[^a-zA-Z0-9]/g, "");
|
||||
if (!ext || ext.length > 5) return "FILE";
|
||||
return ext.toUpperCase();
|
||||
}
|
||||
58
frontend/lib/postFile.ts
Normal file
58
frontend/lib/postFile.ts
Normal 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 };
|
||||
}
|
||||
@@ -54,6 +54,11 @@ export interface RtSettingsData {
|
||||
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;
|
||||
}
|
||||
|
||||
// 群聊新消息:data 为落库的 ChatMessage(含 sender)
|
||||
|
||||
Reference in New Issue
Block a user