Skip to main content

yggdrasil/models/
settings.rs

1//! 回收站与站点配置模型。
2
3/// 默认保留天数(天)。
4pub const DEFAULT_RETENTION_DAYS: i32 = 30;
5/// 默认不启用自动清理。
6pub const DEFAULT_AUTO_PURGE_ENABLED: bool = false;
7/// 保留天数下限(天)。
8#[cfg(feature = "server")]
9pub const MIN_RETENTION_DAYS: i32 = 1;
10/// 保留天数上限(天)。防止误填超大值导致永不清理。
11#[cfg(feature = "server")]
12pub const MAX_RETENTION_DAYS: i32 = 365;
13
14/// 回收站配置。
15#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
16pub struct TrashSettings {
17    /// 是否启用自动定时清理。
18    pub auto_purge_enabled: bool,
19    /// 已删除文章保留天数,超过后被后台任务物理删除。
20    pub retention_days: i32,
21}
22
23impl Default for TrashSettings {
24    fn default() -> Self {
25        Self {
26            auto_purge_enabled: DEFAULT_AUTO_PURGE_ENABLED,
27            retention_days: DEFAULT_RETENTION_DAYS,
28        }
29    }
30}
31
32impl TrashSettings {
33    /// 将保留天数钳制到合法范围 [MIN, MAX]。
34    #[cfg(feature = "server")]
35    pub fn clamp_retention(days: i32) -> i32 {
36        days.clamp(MIN_RETENTION_DAYS, MAX_RETENTION_DAYS)
37    }
38}
39
40// ============================================================================
41// 自动备份配置
42// ============================================================================
43
44/// 默认不启用自动备份。
45pub const DEFAULT_BACKUP_AUTO_ENABLED: bool = false;
46/// 默认每天 04:00 (UTC) 执行(服务器低峰)。
47pub const DEFAULT_BACKUP_TIME_UTC: &str = "04:00";
48/// 默认保留最近 30 份自动备份。
49pub const DEFAULT_BACKUP_RETENTION_COUNT: i32 = 30;
50/// 默认备份产物包含 uploads 素材包。
51pub const DEFAULT_BACKUP_INCLUDE_UPLOADS: bool = true;
52/// 保留份数下限。
53#[cfg(feature = "server")]
54pub const MIN_BACKUP_RETENTION_COUNT: i32 = 1;
55/// 保留份数上限。防止误填超大值导致磁盘无限增长。
56#[cfg(feature = "server")]
57pub const MAX_BACKUP_RETENTION_COUNT: i32 = 365;
58
59/// 自动备份配置。
60#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
61pub struct BackupSettings {
62    /// 是否启用每天定时备份。
63    pub auto_enabled: bool,
64    /// 每天执行时间(UTC,"HH:MM")。面板负责浏览器本地时区转换。
65    pub time_utc: String,
66    /// 自动备份保留份数,超出后最旧的连配对 uploads 包一起删除。
67    pub retention_count: i32,
68    /// 备份产物是否包含 uploads 打包(tar.gz,排除可重建的 .cache)。
69    pub include_uploads: bool,
70}
71
72impl Default for BackupSettings {
73    fn default() -> Self {
74        Self {
75            auto_enabled: DEFAULT_BACKUP_AUTO_ENABLED,
76            time_utc: DEFAULT_BACKUP_TIME_UTC.to_string(),
77            retention_count: DEFAULT_BACKUP_RETENTION_COUNT,
78            include_uploads: DEFAULT_BACKUP_INCLUDE_UPLOADS,
79        }
80    }
81}
82
83impl BackupSettings {
84    /// 将保留份数钳制到合法范围 [MIN, MAX]。
85    #[cfg(feature = "server")]
86    pub fn clamp_retention(count: i32) -> i32 {
87        count.clamp(MIN_BACKUP_RETENTION_COUNT, MAX_BACKUP_RETENTION_COUNT)
88    }
89
90    /// 校验 "HH:MM" 是否合法(env 播种等需要严格拒绝脏输入的场景用)。
91    #[cfg(feature = "server")]
92    pub fn is_valid_time_utc(s: &str) -> bool {
93        parse_hhmm(s).is_some()
94    }
95
96    /// 规范化 "HH:MM":容忍单数字小时/分钟与前后空白,输出零填充;
97    /// 非法输入回退默认时间(面板/调度永不因脏数据停摆)。
98    #[cfg(feature = "server")]
99    pub fn normalize_time_utc(s: &str) -> String {
100        match parse_hhmm(s) {
101            Some((h, m)) => format!("{h:02}:{m:02}"),
102            None => DEFAULT_BACKUP_TIME_UTC.to_string(),
103        }
104    }
105
106    /// 计算启用时下一次执行时刻(UTC)。time_utc 非法时返回 None。
107    #[cfg(feature = "server")]
108    pub fn next_run_after(
109        &self,
110        now: chrono::DateTime<chrono::Utc>,
111    ) -> Option<chrono::DateTime<chrono::Utc>> {
112        let (h, m) = parse_hhmm(&self.time_utc)?;
113        let today = now.date_naive();
114        let today_at = today.and_hms_opt(h as u32, m as u32, 0)?.and_utc();
115        if today_at > now {
116            Some(today_at)
117        } else {
118            Some(
119                today
120                    .succ_opt()?
121                    .and_hms_opt(h as u32, m as u32, 0)?
122                    .and_utc(),
123            )
124        }
125    }
126}
127
128/// 解析 "HH:MM"(容忍单数字与空白)为 (hour, minute);非法返回 None。
129#[cfg(feature = "server")]
130fn parse_hhmm(s: &str) -> Option<(u8, u8)> {
131    let mut parts = s.trim().split(':');
132    let h: u8 = parts.next()?.trim().parse().ok()?;
133    let m: u8 = parts.next()?.trim().parse().ok()?;
134    if parts.next().is_some() || h > 23 || m > 59 {
135        return None;
136    }
137    Some((h, m))
138}
139
140/// 最近一次自动备份结果(落库 settings 键,重启后可查;内存任务表 1 小时即清)。
141#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
142pub struct LastBackupRun {
143    /// 执行时间(RFC3339)。
144    pub at: String,
145    /// 成败。
146    pub ok: bool,
147    /// 成功时的 SQL 文件名。
148    pub file: Option<String>,
149    /// 失败时的错误摘要。
150    pub error: Option<String>,
151}
152
153/// `get_backup_settings` 的响应:设置 + 上次结果 + 下次执行时间。
154#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
155pub struct BackupSettingsView {
156    pub settings: BackupSettings,
157    /// 尚无自动备份记录时为空。
158    pub last_run: Option<LastBackupRun>,
159    /// 下次计划执行(RFC3339;未启用或时间非法时为空)。
160    pub next_run_at: Option<String>,
161}
162
163// ============================================================================
164// 素材上传配置
165// ============================================================================
166
167/// 默认并发上传数:worker 池并发 3,张间停顿随之放大(见 asset_upload.rs),
168/// 聚合速率与默认上传限流桶(2/s)对齐。
169pub const DEFAULT_UPLOAD_CONCURRENCY: i32 = 3;
170/// 并发数下限(1 = 退化为顺序上传)。
171#[cfg(feature = "server")]
172pub const MIN_UPLOAD_CONCURRENCY: i32 = 1;
173/// 并发数上限。防止误填超大值瞬间打满浏览器连接与上传限流突发桶。
174#[cfg(feature = "server")]
175pub const MAX_UPLOAD_CONCURRENCY: i32 = 8;
176
177/// 素材上传配置(/admin/assets 上传弹窗行为,仅 admin 可见/可改)。
178#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
179pub struct UploadSettings {
180    /// 同时发起的上传任务数(worker 池大小)。
181    pub concurrency: i32,
182}
183
184impl Default for UploadSettings {
185    fn default() -> Self {
186        Self {
187            concurrency: DEFAULT_UPLOAD_CONCURRENCY,
188        }
189    }
190}
191
192impl UploadSettings {
193    /// 将并发数钳制到合法范围 [MIN, MAX]。
194    #[cfg(feature = "server")]
195    pub fn clamp_concurrency(n: i32) -> i32 {
196        n.clamp(MIN_UPLOAD_CONCURRENCY, MAX_UPLOAD_CONCURRENCY)
197    }
198}
199
200/// 默认 GitHub 链接:空字符串表示不展示页脚图标。
201pub const DEFAULT_SITE_GITHUB_URL: &str = "";
202#[cfg(any(feature = "server", test))]
203/// GitHub 链接最大长度(字符),防止滥用。
204pub const MAX_SITE_GITHUB_URL_LEN: usize = 500;
205
206/// 首页标题第一行的默认文案。
207pub const DEFAULT_HOME_TITLE: &str = "慢慢读源码,";
208/// 首页标题第二行(次要色)的默认文案。
209pub const DEFAULT_HOME_SUBTITLE: &str = "认真写长文。";
210/// 首页简介的默认文案。
211pub const DEFAULT_HOME_INTRO: &str =
212    "Rust、Go 与前端工程的源码剖析和长文笔记。每一篇,都试着把一个实现讲清楚。";
213/// 站点简介的默认文案(友链页「交换友链」信息)。
214pub const DEFAULT_SITE_DESCRIPTION: &str = "Rust、Go 与前端工程的源码剖析和长文笔记。";
215#[cfg(any(feature = "server", test))]
216/// 站点简介的最大长度(字符)。
217pub const MAX_SITE_DESCRIPTION_LEN: usize = 120;
218#[cfg(any(feature = "server", test))]
219/// 首页标题每行的最大长度(字符)。
220pub const MAX_HOME_TITLE_LEN: usize = 40;
221#[cfg(any(feature = "server", test))]
222/// 首页简介的最大长度(字符)。
223pub const MAX_HOME_INTRO_LEN: usize = 200;
224
225/// 站点公开配置(前台展示用,所有访客可见)。
226///
227/// 页脚 GitHub 链接、首页标语与站点简介。文案字段存原值,空字符串表示使用默认文案,
228/// 前台通过 [`SiteSettings::home_tagline`] 与 [`SiteSettings::description`] 取展示值。
229#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
230pub struct SiteSettings {
231    /// GitHub 仓库链接,空字符串表示不展示页脚图标。
232    pub github_url: String,
233    /// 首页标题第一行。
234    #[serde(default)]
235    pub home_title: String,
236    /// 首页标题第二行(次要色)。
237    #[serde(default)]
238    pub home_subtitle: String,
239    /// 首页简介。
240    #[serde(default)]
241    pub home_intro: String,
242    /// 站点简介:一句话介绍,用于友链交换信息。
243    #[serde(default)]
244    pub site_description: String,
245}
246
247impl Default for SiteSettings {
248    fn default() -> Self {
249        Self {
250            github_url: DEFAULT_SITE_GITHUB_URL.to_string(),
251            home_title: String::new(),
252            home_subtitle: String::new(),
253            home_intro: String::new(),
254            site_description: String::new(),
255        }
256    }
257}
258
259/// 首页开场的展示文案。
260#[derive(Debug, Clone, PartialEq)]
261pub struct HomeTagline {
262    pub title: String,
263    /// 为空时只显示一行标题。
264    pub subtitle: String,
265    pub intro: String,
266}
267
268impl SiteSettings {
269    /// 首页标语的展示值。未配置标题时两行都用默认文案;配置了标题时第二行按原值,
270    /// 可以留空只显示一行。简介为空时用默认文案。
271    pub fn home_tagline(&self) -> HomeTagline {
272        let (title, subtitle) = if self.home_title.is_empty() {
273            (DEFAULT_HOME_TITLE, DEFAULT_HOME_SUBTITLE)
274        } else {
275            (self.home_title.as_str(), self.home_subtitle.as_str())
276        };
277        let intro = if self.home_intro.is_empty() {
278            DEFAULT_HOME_INTRO
279        } else {
280            &self.home_intro
281        };
282        HomeTagline {
283            title: title.to_string(),
284            subtitle: subtitle.to_string(),
285            intro: intro.to_string(),
286        }
287    }
288
289    /// 站点简介的展示值,未配置时用默认文案。
290    #[cfg(any(feature = "server", test))]
291    pub fn description(&self) -> &str {
292        if self.site_description.is_empty() {
293            DEFAULT_SITE_DESCRIPTION
294        } else {
295            &self.site_description
296        }
297    }
298
299    #[cfg(any(feature = "server", test))]
300    /// 规范化全部字段,写入前调用。
301    pub fn normalized(&self) -> Self {
302        Self {
303            github_url: Self::normalize_github_url(&self.github_url),
304            home_title: Self::normalize_home_text(&self.home_title, MAX_HOME_TITLE_LEN),
305            home_subtitle: Self::normalize_home_text(&self.home_subtitle, MAX_HOME_TITLE_LEN),
306            home_intro: Self::normalize_home_text(&self.home_intro, MAX_HOME_INTRO_LEN),
307            site_description: Self::normalize_home_text(
308                &self.site_description,
309                MAX_SITE_DESCRIPTION_LEN,
310            ),
311        }
312    }
313
314    #[cfg(any(feature = "server", test))]
315    /// 规范化首页标语文本:合并空白与换行为单个空格,再按字符数截断。
316    pub fn normalize_home_text(text: &str, max_chars: usize) -> String {
317        let joined = text.split_whitespace().collect::<Vec<_>>().join(" ");
318        joined.chars().take(max_chars).collect()
319    }
320
321    #[cfg(any(feature = "server", test))]
322    /// 规范化 GitHub 链接:trim、截断、补全 scheme。
323    ///
324    /// - 空串(trim 后)→ 返回空串,表示未配置。
325    /// - 已带 `http://` / `https://` → 原样保留(截断到上限)。
326    /// - 否则在前面补 `https://`;这同时把 `javascript:` / `data:` 等危险 scheme
327    ///   变成无效 URL(`https://javascript:...`),杜绝通过页脚 `href` 注入脚本。
328    pub fn normalize_github_url(url: &str) -> String {
329        let trimmed = url.trim();
330        if trimmed.is_empty() {
331            return String::new();
332        }
333        let limited: String = trimmed.chars().take(MAX_SITE_GITHUB_URL_LEN).collect();
334        if limited.starts_with("http://") || limited.starts_with("https://") {
335            limited
336        } else {
337            format!("https://{limited}")
338        }
339    }
340}
341
342// ============================================================================
343// 安全配置(即时生效)
344// ============================================================================
345//
346// 这些设置原本经环境变量每次请求/每次登录读取(src/api/csrf.rs 的
347// trusted_origin、src/auth/session.rs 的 cookie_secure、src/api/rate_limit.rs
348// 的 trusted_proxy_count、src/api/auth.rs 的 max_sessions_per_user)。迁移到
349// settings 表后语义不变:env 在首次部署播种 DB(ON CONFLICT DO NOTHING),
350// 之后面板写入的 DB 值优先,重启不被 env 覆盖。读取走 moka 短 TTL 缓存,
351// 面板保存时失效缓存——最长滞后数秒即全链路生效。
352
353/// 默认 APP_BASE_URL:空串表示未配置(回退到 Host 头推导,仅本地安全)。
354pub const DEFAULT_APP_BASE_URL: &str = "";
355/// 默认不给会话 cookie 加 Secure 标志(本地 HTTP 开发需要)。
356pub const DEFAULT_COOKIE_SECURE: bool = false;
357/// 默认可信代理段数 0(直接对外服务,不解析代理头)。
358pub const DEFAULT_TRUSTED_PROXY_COUNT: u32 = 0;
359/// 默认每用户最大并发会话数 5。
360pub const DEFAULT_MAX_SESSIONS_PER_USER: u32 = 5;
361
362/// `TRUSTED_PROXY_COUNT` 上限。超过 XFF 中真实客户端 IP 右侧的段数会信任客户端
363/// 伪造的 IP,导致限流被绕过;上限给一个宽松天花板防误填危险大值。
364#[cfg(feature = "server")]
365pub const MAX_TRUSTED_PROXY_COUNT: u32 = 10;
366/// `MAX_SESSIONS_PER_USER` 下限(至少允许 1 个会话)。
367#[cfg(feature = "server")]
368pub const MIN_MAX_SESSIONS_PER_USER: u32 = 1;
369/// `MAX_SESSIONS_PER_USER` 上限,防误填危险大值。
370#[cfg(feature = "server")]
371pub const MAX_MAX_SESSIONS_PER_USER: u32 = 100;
372
373/// 安全配置(CSRF 可信源、cookie Secure 标志、真实 IP 提取、并发会话上限)。
374///
375/// 即时生效层:读取走 moka 缓存,面板保存后失效缓存,数秒内全链路生效。
376#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
377pub struct SecuritySettings {
378    /// 写请求 CSRF 校验的可信来源(如 `https://your-domain.example`)。
379    /// 空串表示未配置,回退到 Host 头 + X-Forwarded-Proto(生产建议显式配置)。
380    pub app_base_url: String,
381    /// 是否给会话 cookie 加 Secure 标志(仅 HTTPS 下发送)。
382    pub cookie_secure: bool,
383    /// X-Forwarded-For 中真实客户端 IP 右侧的段数(不等于代理层数),用于提取真实客户端 IP。
384    pub trusted_proxy_count: u32,
385    /// 单用户最大并发会话数,超出按最旧优先淘汰。
386    pub max_sessions_per_user: u32,
387}
388
389impl Default for SecuritySettings {
390    fn default() -> Self {
391        Self {
392            app_base_url: DEFAULT_APP_BASE_URL.to_string(),
393            cookie_secure: DEFAULT_COOKIE_SECURE,
394            trusted_proxy_count: DEFAULT_TRUSTED_PROXY_COUNT,
395            max_sessions_per_user: DEFAULT_MAX_SESSIONS_PER_USER,
396        }
397    }
398}
399
400impl SecuritySettings {
401    /// 将可信代理段数钳制到合法范围 [0, MAX]。
402    #[cfg(feature = "server")]
403    pub fn clamp_trusted_proxy_count(n: u32) -> u32 {
404        n.min(MAX_TRUSTED_PROXY_COUNT)
405    }
406
407    /// 将并发会话上限钳制到合法范围 [MIN, MAX]。
408    #[cfg(feature = "server")]
409    pub fn clamp_max_sessions(n: u32) -> u32 {
410        n.clamp(MIN_MAX_SESSIONS_PER_USER, MAX_MAX_SESSIONS_PER_USER)
411    }
412
413    #[cfg(any(feature = "server", test))]
414    /// 规范化并校验 APP_BASE_URL:trim 后为空表示未配置(返回空串);否则必须以
415    /// `http://` 或 `https://` 开头且带主机名,不合法返回 None。
416    /// 不替用户补 scheme:CSRF 按完整 origin 比对,猜错协议同样会拒绝所有写请求。
417    pub fn parse_app_base_url(url: &str) -> Option<String> {
418        let url = url.trim();
419        if url.is_empty() {
420            return Some(String::new());
421        }
422        let rest = url
423            .strip_prefix("https://")
424            .or_else(|| url.strip_prefix("http://"))?;
425        let host = rest.split(['/', '?', '#']).next().unwrap_or_default();
426        let valid =
427            !host.is_empty() && !host.starts_with(':') && !rest.chars().any(char::is_whitespace);
428        valid.then(|| url.to_string())
429    }
430}
431
432// ============================================================================
433// 图片磁盘缓存配置(即时生效)
434// ============================================================================
435//
436// 原本经环境变量在每次清理 tick 读取(src/tasks/image_cache_cleanup.rs)。
437// 迁移到 settings 表后语义不变:env 首启播种,之后面板值优先。
438
439/// 默认图片磁盘缓存上限 1024 MB。
440pub const DEFAULT_IMAGE_DISK_CACHE_MAX_MB: u32 = 1024;
441/// 默认图片磁盘缓存保留 168 小时(7 天)。
442pub const DEFAULT_IMAGE_DISK_CACHE_MAX_AGE_HOURS: u32 = 168;
443/// 磁盘缓存上限下限(MB),防误填危险小值。
444#[cfg(feature = "server")]
445pub const MIN_IMAGE_DISK_CACHE_MAX_MB: u32 = 1;
446/// 缓存保留时长下限(小时)。
447#[cfg(feature = "server")]
448pub const MIN_IMAGE_DISK_CACHE_MAX_AGE_HOURS: u32 = 1;
449/// 磁盘缓存上限的上限(MB,1 TiB),与设置页输入框上限一致。
450#[cfg(feature = "server")]
451pub const MAX_IMAGE_DISK_CACHE_MAX_MB: u32 = 1_048_576;
452/// 缓存保留时长上限(小时,10 年),与设置页输入框上限一致。
453#[cfg(feature = "server")]
454pub const MAX_IMAGE_DISK_CACHE_MAX_AGE_HOURS: u32 = 87_600;
455
456/// 图片磁盘缓存配置(uploads/.cache/ 的容量与保留策略)。
457#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
458pub struct ImageCacheSettings {
459    /// 最大总容量(MB),超限按修改时间删最旧文件。
460    pub disk_cache_max_mb: u32,
461    /// 文件最大保留时长(小时),超期优先删除。
462    pub disk_cache_max_age_hours: u32,
463}
464
465impl Default for ImageCacheSettings {
466    fn default() -> Self {
467        Self {
468            disk_cache_max_mb: DEFAULT_IMAGE_DISK_CACHE_MAX_MB,
469            disk_cache_max_age_hours: DEFAULT_IMAGE_DISK_CACHE_MAX_AGE_HOURS,
470        }
471    }
472}
473
474impl ImageCacheSettings {
475    /// 将容量上限钳制到合法范围 [MIN, MAX]。
476    #[cfg(feature = "server")]
477    pub fn clamp_max_mb(n: u32) -> u32 {
478        n.clamp(MIN_IMAGE_DISK_CACHE_MAX_MB, MAX_IMAGE_DISK_CACHE_MAX_MB)
479    }
480
481    /// 将保留时长钳制到合法范围 [MIN, MAX]。
482    #[cfg(feature = "server")]
483    pub fn clamp_max_age_hours(n: u32) -> u32 {
484        n.clamp(
485            MIN_IMAGE_DISK_CACHE_MAX_AGE_HOURS,
486            MAX_IMAGE_DISK_CACHE_MAX_AGE_HOURS,
487        )
488    }
489}
490
491// ============================================================================
492// 孤儿素材自动清理配置(即时生效)
493// ============================================================================
494//
495// 后台任务每天读取 DB 值执行清理:无文章、笔记、评论(含回收站评论)与头像引用
496// (均为外键引用表)的素材,超过保留天数后物理删除(文件 + DB 行 +
497// 派生缓存)。语义与 /admin/assets 的「一键清理孤儿」一致,只是改为定时自动。
498// 评论区允许匿名传图后,未提交/被彻底删除评论留下的孤儿图会持续累积,此任务是
499// 磁盘的主要回收手段。env 首启播种,之后面板值优先(下个 tick 生效)。
500
501/// 默认启用孤儿素材自动清理。
502pub const DEFAULT_ASSET_ORPHAN_PURGE_ENABLED: bool = true;
503/// 默认孤儿素材保留 7 天(与 /admin/assets 手动清理的 PURGE_GRACE_DAYS 一致,
504/// 保护尚未首次保存的草稿引用)。
505pub const DEFAULT_ASSET_ORPHAN_RETENTION_DAYS: i32 = 7;
506/// 保留天数下限(天)。
507#[cfg(feature = "server")]
508pub const MIN_ASSET_ORPHAN_RETENTION_DAYS: i32 = 1;
509/// 保留天数上限(天)。防止误填超大值导致永不清理。
510#[cfg(feature = "server")]
511pub const MAX_ASSET_ORPHAN_RETENTION_DAYS: i32 = 365;
512
513/// 孤儿素材自动清理配置(uploads/ 中无引用的图片)。
514#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
515pub struct AssetPurgeSettings {
516    /// 是否启用自动定时清理。
517    pub auto_purge_enabled: bool,
518    /// 孤儿素材保留天数,超过后被后台任务物理删除。
519    pub retention_days: i32,
520}
521
522impl Default for AssetPurgeSettings {
523    fn default() -> Self {
524        Self {
525            auto_purge_enabled: DEFAULT_ASSET_ORPHAN_PURGE_ENABLED,
526            retention_days: DEFAULT_ASSET_ORPHAN_RETENTION_DAYS,
527        }
528    }
529}
530
531impl AssetPurgeSettings {
532    /// 将保留天数钳制到合法范围 [MIN, MAX]。
533    #[cfg(feature = "server")]
534    pub fn clamp_retention(days: i32) -> i32 {
535        days.clamp(
536            MIN_ASSET_ORPHAN_RETENTION_DAYS,
537            MAX_ASSET_ORPHAN_RETENTION_DAYS,
538        )
539    }
540}
541
542// ============================================================================
543// 限流配置(重启生效)
544// ============================================================================
545//
546// 原本经环境变量在首次请求时经 LazyLock 读取(src/api/rate_limit.rs 的六个
547// IP 键控限流器与 GC 间隔、src/mcp/auth.rs 的两个 MCP token 键控限流器)。
548// 迁移到 settings 表后语义为 Tier B:env 首启播种
549// DB(ON CONFLICT DO NOTHING),之后面板写入的 DB 值优先。由于限流器是
550// LazyLock 静态量(首次请求构造即固化),修改 DB 值需**重启进程**生效,
551// 面板会标注「需重启生效」。启动时由 main.rs 将 DB 值加载进 config::RATE_LIMIT_CFG,
552// rate_limit.rs 与 mcp/auth.rs 的 LazyLock 改为从 config::rate_limit() 读取。
553
554/// 默认严格限流(注册/登录):1 req/s,突发 5。
555pub const DEFAULT_RATE_LIMIT_STRICT_PER_SEC: u32 = 1;
556pub const DEFAULT_RATE_LIMIT_STRICT_BURST: u32 = 5;
557/// 默认上传限流:2 req/s,突发 15。
558pub const DEFAULT_RATE_LIMIT_UPLOAD_PER_SEC: u32 = 2;
559pub const DEFAULT_RATE_LIMIT_UPLOAD_BURST: u32 = 15;
560/// 默认图片访问限流:10 req/s,突发 50。
561pub const DEFAULT_RATE_LIMIT_IMAGE_PER_SEC: u32 = 10;
562pub const DEFAULT_RATE_LIMIT_IMAGE_BURST: u32 = 50;
563/// 默认评论限流:1 req/s,突发 5。
564pub const DEFAULT_RATE_LIMIT_COMMENT_PER_SEC: u32 = 1;
565pub const DEFAULT_RATE_LIMIT_COMMENT_BURST: u32 = 5;
566/// 默认评论图片上传限流:1 req/s,突发 5。
567pub const DEFAULT_RATE_LIMIT_COMMENT_UPLOAD_PER_SEC: u32 = 1;
568pub const DEFAULT_RATE_LIMIT_COMMENT_UPLOAD_BURST: u32 = 5;
569/// 默认评论图片上传日限额:30 次/天。
570/// 评论图片允许匿名上传,磁盘成本需要硬性日上限防滥用(同 code_exec 双层语义)。
571pub const DEFAULT_RATE_LIMIT_COMMENT_UPLOAD_DAILY: u32 = 30;
572/// 默认代码执行限流:1 req/s,突发 3。
573pub const DEFAULT_RATE_LIMIT_CODE_EXEC_PER_SEC: u32 = 1;
574pub const DEFAULT_RATE_LIMIT_CODE_EXEC_BURST: u32 = 3;
575/// 默认代码执行日限额:50 次/天。
576pub const DEFAULT_RATE_LIMIT_CODE_EXEC_DAILY: u32 = 50;
577/// 默认 unknown 桶限流:30 req/s,突发 100。
578pub const DEFAULT_RATE_LIMIT_UNKNOWN_PER_SEC: u32 = 30;
579pub const DEFAULT_RATE_LIMIT_UNKNOWN_BURST: u32 = 100;
580/// 默认 MCP 请求限流(按 token_id):10 req/s,突发 30。
581pub const DEFAULT_RATE_LIMIT_MCP_PER_SEC: u32 = 10;
582pub const DEFAULT_RATE_LIMIT_MCP_BURST: u32 = 30;
583/// 默认 MCP 上传限流(按 token_id,与 MCP 请求桶隔离):2 req/s,突发 5。
584pub const DEFAULT_RATE_LIMIT_MCP_UPLOAD_PER_SEC: u32 = 2;
585pub const DEFAULT_RATE_LIMIT_MCP_UPLOAD_BURST: u32 = 5;
586/// 默认限流桶 GC 间隔:300 秒。
587pub const DEFAULT_RATE_LIMIT_GC_INTERVAL_SECS: u32 = 300;
588
589/// 所有 per_sec 字段下限:至少 1 req/s(NonZeroU32 不允许 0)。
590#[cfg(feature = "server")]
591pub const MIN_RATE_LIMIT_PER_SEC: u32 = 1;
592/// 所有 per_sec 字段上限(单 IP 每秒 1 万次已等同不限流),与设置页输入框上限一致。
593#[cfg(feature = "server")]
594pub const MAX_RATE_LIMIT_PER_SEC: u32 = 10_000;
595/// 所有 burst 字段下限:至少允许 1 次突发。
596#[cfg(feature = "server")]
597pub const MIN_RATE_LIMIT_BURST: u32 = 1;
598/// 日限额下限:至少允许 1 次/天。
599#[cfg(feature = "server")]
600pub const MIN_RATE_LIMIT_DAILY: u32 = 1;
601/// GC 间隔下限(秒):至少 1 秒。
602#[cfg(feature = "server")]
603pub const MIN_RATE_LIMIT_GC_INTERVAL_SECS: u32 = 1;
604
605/// 限流配置(IP 键控限流器与 MCP token 键控限流器的速率/突发/日限额与 GC 间隔)。
606///
607/// **重启生效层**:限流器是 LazyLock 静态量,首次请求时构造即固化;
608/// 修改 DB 值需重启进程才能生效。
609#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
610pub struct RateLimitSettings {
611    /// 严格限流(注册/登录)每秒请求数。
612    pub strict_per_sec: u32,
613    /// 严格限流突发上限。
614    pub strict_burst: u32,
615    /// 上传限流每秒请求数。
616    pub upload_per_sec: u32,
617    /// 上传限流突发上限。
618    pub upload_burst: u32,
619    /// 图片访问限流每秒请求数。
620    pub image_per_sec: u32,
621    /// 图片访问限流突发上限。
622    pub image_burst: u32,
623    /// 评论限流每秒请求数。
624    pub comment_per_sec: u32,
625    /// 评论限流突发上限。
626    pub comment_burst: u32,
627    /// 评论图片上传限流每秒请求数。
628    pub comment_upload_per_sec: u32,
629    /// 评论图片上传限流突发上限。
630    pub comment_upload_burst: u32,
631    /// 评论图片上传日限额(次/天)。
632    pub comment_upload_daily: u32,
633    /// 代码执行限流每秒请求数。
634    pub code_exec_per_sec: u32,
635    /// 代码执行限流突发上限。
636    pub code_exec_burst: u32,
637    /// 代码执行日限额(次/天)。
638    pub code_exec_daily: u32,
639    /// unknown 桶(无法识别 IP)每秒请求数。
640    pub unknown_per_sec: u32,
641    /// unknown 桶突发上限。
642    pub unknown_burst: u32,
643    /// MCP 请求(按令牌)每秒请求数。
644    pub mcp_per_sec: u32,
645    /// MCP 请求(按令牌)突发上限。
646    pub mcp_burst: u32,
647    /// MCP 上传(按令牌)每秒请求数。
648    pub mcp_upload_per_sec: u32,
649    /// MCP 上传(按令牌)突发上限。
650    pub mcp_upload_burst: u32,
651    /// 限流桶 GC 间隔(秒)。
652    pub gc_interval_secs: u32,
653}
654
655impl Default for RateLimitSettings {
656    fn default() -> Self {
657        Self {
658            strict_per_sec: DEFAULT_RATE_LIMIT_STRICT_PER_SEC,
659            strict_burst: DEFAULT_RATE_LIMIT_STRICT_BURST,
660            upload_per_sec: DEFAULT_RATE_LIMIT_UPLOAD_PER_SEC,
661            upload_burst: DEFAULT_RATE_LIMIT_UPLOAD_BURST,
662            image_per_sec: DEFAULT_RATE_LIMIT_IMAGE_PER_SEC,
663            image_burst: DEFAULT_RATE_LIMIT_IMAGE_BURST,
664            comment_per_sec: DEFAULT_RATE_LIMIT_COMMENT_PER_SEC,
665            comment_burst: DEFAULT_RATE_LIMIT_COMMENT_BURST,
666            comment_upload_per_sec: DEFAULT_RATE_LIMIT_COMMENT_UPLOAD_PER_SEC,
667            comment_upload_burst: DEFAULT_RATE_LIMIT_COMMENT_UPLOAD_BURST,
668            comment_upload_daily: DEFAULT_RATE_LIMIT_COMMENT_UPLOAD_DAILY,
669            code_exec_per_sec: DEFAULT_RATE_LIMIT_CODE_EXEC_PER_SEC,
670            code_exec_burst: DEFAULT_RATE_LIMIT_CODE_EXEC_BURST,
671            code_exec_daily: DEFAULT_RATE_LIMIT_CODE_EXEC_DAILY,
672            unknown_per_sec: DEFAULT_RATE_LIMIT_UNKNOWN_PER_SEC,
673            unknown_burst: DEFAULT_RATE_LIMIT_UNKNOWN_BURST,
674            mcp_per_sec: DEFAULT_RATE_LIMIT_MCP_PER_SEC,
675            mcp_burst: DEFAULT_RATE_LIMIT_MCP_BURST,
676            mcp_upload_per_sec: DEFAULT_RATE_LIMIT_MCP_UPLOAD_PER_SEC,
677            mcp_upload_burst: DEFAULT_RATE_LIMIT_MCP_UPLOAD_BURST,
678            gc_interval_secs: DEFAULT_RATE_LIMIT_GC_INTERVAL_SECS,
679        }
680    }
681}
682
683impl RateLimitSettings {
684    /// 将 per_sec 字段钳制到 [MIN_RATE_LIMIT_PER_SEC, MAX_RATE_LIMIT_PER_SEC]。
685    #[cfg(feature = "server")]
686    pub fn clamp_per_sec(n: u32) -> u32 {
687        n.clamp(MIN_RATE_LIMIT_PER_SEC, MAX_RATE_LIMIT_PER_SEC)
688    }
689
690    /// 将 burst 字段钳制到 [MIN_RATE_LIMIT_BURST, ∞)。
691    #[cfg(feature = "server")]
692    pub fn clamp_burst(n: u32) -> u32 {
693        n.max(MIN_RATE_LIMIT_BURST)
694    }
695
696    /// 将日限额字段钳制到 [MIN_RATE_LIMIT_DAILY, ∞)。
697    #[cfg(feature = "server")]
698    pub fn clamp_daily(n: u32) -> u32 {
699        n.max(MIN_RATE_LIMIT_DAILY)
700    }
701
702    /// 将 GC 间隔钳制到 [MIN_RATE_LIMIT_GC_INTERVAL_SECS, ∞)。
703    #[cfg(feature = "server")]
704    pub fn clamp_gc_interval(n: u32) -> u32 {
705        n.max(MIN_RATE_LIMIT_GC_INTERVAL_SECS)
706    }
707}
708
709// ============================================================================
710// WebP 编码配置(需重启生效)
711// ============================================================================
712//
713// 原本经环境变量在首启读取(src/infra/webp.rs 的 WEBP_QUALITY / WEBP_METHOD)。
714// 迁移到 settings 表后:env 首启播种 DB(ON CONFLICT DO NOTHING),之后面板值
715// 优先。这些值在进程启动时烘焙进 LazyLock 静态量,改 DB 值后需重启才生效。
716
717/// 默认 WebP 质量 85.0。
718pub const DEFAULT_WEBP_QUALITY: f32 = 85.0;
719/// 默认 WebP 编码方法 2。
720pub const DEFAULT_WEBP_METHOD: u32 = 2;
721
722/// `WEBP_QUALITY` 下限。
723#[cfg(feature = "server")]
724pub const MIN_WEBP_QUALITY: f32 = 0.0;
725/// `WEBP_QUALITY` 上限。
726#[cfg(feature = "server")]
727pub const MAX_WEBP_QUALITY: f32 = 100.0;
728/// `WEBP_METHOD` 上限。
729#[cfg(feature = "server")]
730pub const MAX_WEBP_METHOD: u32 = 6;
731
732/// WebP 有损编码配置(质量与方法)。
733///
734/// 需重启生效层:值在进程启动时烘焙进 LazyLock,改 DB 值后需重启才生效。
735#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
736pub struct WebpSettings {
737    /// 质量系数,范围 0.0–100.0。
738    pub quality: f32,
739    /// 编码方法,范围 0–6,数值越大压缩率越高但越慢。
740    pub method: u32,
741}
742
743impl Default for WebpSettings {
744    fn default() -> Self {
745        Self {
746            quality: DEFAULT_WEBP_QUALITY,
747            method: DEFAULT_WEBP_METHOD,
748        }
749    }
750}
751
752impl WebpSettings {
753    /// 将质量钳制到合法范围 [0.0, 100.0]。
754    #[cfg(feature = "server")]
755    pub fn clamp_quality(q: f32) -> f32 {
756        q.clamp(MIN_WEBP_QUALITY, MAX_WEBP_QUALITY)
757    }
758
759    /// 将编码方法钳制到合法范围 [0, 6]。
760    #[cfg(feature = "server")]
761    pub fn clamp_method(m: u32) -> u32 {
762        m.min(MAX_WEBP_METHOD)
763    }
764}
765
766// ============================================================================
767// 图片尺寸限制配置(需重启生效)
768// ============================================================================
769//
770// 原本经环境变量在首启读取(src/api/image.rs 的 MAX_IMAGE_DIMENSION /
771// MAX_IMAGE_PIXELS / IMAGE_DIMENSIONS_CACHE_TTL_SECS)。迁移到 settings 表后:
772// env 首启播种 DB,之后面板值优先。值烘焙进 LazyLock,需重启生效。
773
774/// 默认图片单边尺寸上限 8192 像素。
775pub const DEFAULT_IMAGE_MAX_DIMENSION: u32 = 8192;
776/// 默认图片像素总数上限 50_000_000(约 7k×7k)。
777pub const DEFAULT_IMAGE_MAX_PIXELS: u64 = 50_000_000;
778/// 默认图片尺寸缓存 TTL 86400 秒(24 小时)。
779pub const DEFAULT_IMAGE_DIMENSIONS_CACHE_TTL_SECS: u64 = 86400;
780
781/// 图片单边尺寸下限(防误调到危险小值导致正常图都传不上)。
782#[cfg(feature = "server")]
783pub const MIN_IMAGE_MAX_DIMENSION: u32 = 512;
784/// 图片像素总数下限(防误调)。
785#[cfg(feature = "server")]
786pub const MIN_IMAGE_MAX_PIXELS: u64 = 1_000_000;
787/// 尺寸缓存 TTL 下限(秒)。
788#[cfg(feature = "server")]
789pub const MIN_IMAGE_DIMENSIONS_CACHE_TTL_SECS: u64 = 1;
790
791/// 图片处理尺寸限制与尺寸缓存配置。
792///
793/// 需重启生效层:值在进程启动时烘焙进 LazyLock,改 DB 值后需重启才生效。
794#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
795pub struct ImageLimitSettings {
796    /// 图片单边(宽或高)尺寸上限(像素)。
797    pub max_dimension: u32,
798    /// 允许处理的最大图片像素数。⚠️ 此值同时决定单图解码内存缓冲
799    /// (max_alloc = pixels × 4 + 1MB),默认 50M 像素对应约 200MB/图。
800    pub max_pixels: u64,
801    /// 图片尺寸缓存 TTL(秒)。
802    pub dimensions_cache_ttl_secs: u64,
803}
804
805impl Default for ImageLimitSettings {
806    fn default() -> Self {
807        Self {
808            max_dimension: DEFAULT_IMAGE_MAX_DIMENSION,
809            max_pixels: DEFAULT_IMAGE_MAX_PIXELS,
810            dimensions_cache_ttl_secs: DEFAULT_IMAGE_DIMENSIONS_CACHE_TTL_SECS,
811        }
812    }
813}
814
815impl ImageLimitSettings {
816    /// 将单边尺寸钳制到合法范围 [MIN, ∞)。
817    #[cfg(feature = "server")]
818    pub fn clamp_max_dimension(n: u32) -> u32 {
819        n.max(MIN_IMAGE_MAX_DIMENSION)
820    }
821
822    /// 将像素总数钳制到合法范围 [MIN, ∞)。
823    #[cfg(feature = "server")]
824    pub fn clamp_max_pixels(n: u64) -> u64 {
825        n.max(MIN_IMAGE_MAX_PIXELS)
826    }
827
828    /// 将尺寸缓存 TTL 钳制到合法范围 [MIN, ∞)。
829    #[cfg(feature = "server")]
830    pub fn clamp_dimensions_cache_ttl_secs(n: u64) -> u64 {
831        n.max(MIN_IMAGE_DIMENSIONS_CACHE_TTL_SECS)
832    }
833}
834
835// ============================================================================
836// SSR 页面缓存配置(需重启生效)
837// ============================================================================
838//
839// 原本是 src/ssr_cache.rs 的编译期常量。env(SSR_CACHE_MAX_PAGE_BYTES)首启播种 DB,
840// 之后面板值优先;值在 HTML 缓存首次使用时读取,需重启生效。
841
842/// 默认单页上限 2 MiB:线上最长一篇(Mermaid 明暗两份 SVG 加高亮代码)渲染后 1.14 MB。
843pub const DEFAULT_SSR_CACHE_MAX_PAGE_BYTES: u64 = 2 * 1024 * 1024;
844/// 单页上限下限 64 KiB,防误填过小值让文章页全部进不了缓存。
845#[cfg(feature = "server")]
846pub const MIN_SSR_CACHE_MAX_PAGE_BYTES: u64 = 64 * 1024;
847/// 单页上限的上限 32 MiB,等于 HTML 缓存总量(`ssr_cache::MAX_CACHE_BYTES`),
848/// 与设置页输入框上限一致。
849#[cfg(feature = "server")]
850pub const MAX_SSR_CACHE_MAX_PAGE_BYTES: u64 = 32 * 1024 * 1024;
851
852/// 边缘缓存时长上限 7 天。清缓存失败时这就是陈旧上限,再长不划算。
853pub const MAX_SSR_CACHE_EDGE_TTL_SECS: u64 = 7 * 24 * 3600;
854
855/// 匿名公开页 HTML 缓存配置。
856///
857/// 需重启生效层:值在 HTML 缓存首次使用时读取,改 DB 值后需重启才生效。
858#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
859pub struct SsrCacheSettings {
860    /// 单页上限(字节)。渲染结果超过此大小的页面不进缓存,每次请求完整 SSR 再现压。
861    pub max_page_bytes: u64,
862    /// 白名单匿名页在 CDN 节点的缓存时长(秒,`s-maxage`)。0 = 不让 CDN 缓存 HTML(默认)。
863    /// 大于 0 时内容变更与启动都会请求清 EdgeOne(`tasks::edge_purge`,需配置凭据);
864    /// 未配置凭据时只能等它自然过期。
865    pub edge_ttl_secs: u64,
866}
867
868impl Default for SsrCacheSettings {
869    fn default() -> Self {
870        Self {
871            max_page_bytes: DEFAULT_SSR_CACHE_MAX_PAGE_BYTES,
872            edge_ttl_secs: 0,
873        }
874    }
875}
876
877impl SsrCacheSettings {
878    /// 将单页上限钳制到合法范围 [MIN, MAX]。
879    #[cfg(feature = "server")]
880    pub fn clamp_max_page_bytes(n: u64) -> u64 {
881        n.clamp(MIN_SSR_CACHE_MAX_PAGE_BYTES, MAX_SSR_CACHE_MAX_PAGE_BYTES)
882    }
883
884    /// 将边缘缓存时长钳制到 [0, 7 天]。
885    #[cfg(feature = "server")]
886    pub fn clamp_edge_ttl_secs(n: u64) -> u64 {
887        n.min(MAX_SSR_CACHE_EDGE_TTL_SECS)
888    }
889}
890
891// ============================================================================
892// 代码运行器配置(需重启生效)
893// ============================================================================
894//
895// 原本经环境变量在首启读取(src/infra/runner_config.rs 的 CODE_RUNNER_*)。
896// 迁移到 settings 表后:env 首启播种 DB,之后面板值优先。值烘焙进 LazyLock,
897// 需重启生效。注意 `docker_socket_path` 仍是 env-only(DOCKER_SOCKET_PATH),
898// 不在此结构。
899
900/// 默认不允许容器联网。
901pub const DEFAULT_RUNNER_ALLOW_NETWORK: bool = false;
902/// 默认最大并发 4。
903pub const DEFAULT_RUNNER_MAX_CONCURRENT: u32 = 4;
904/// 默认每任务最大 CPU 核数 2.0。
905pub const DEFAULT_RUNNER_MAX_CPU_CORES: f64 = 2.0;
906/// 默认每任务最大内存 1024 MB。
907pub const DEFAULT_RUNNER_MAX_MEMORY_MB: u32 = 1024;
908/// 默认每任务最大执行超时 30 秒。
909pub const DEFAULT_RUNNER_MAX_TIMEOUT_SECS: u32 = 30;
910/// 默认每任务最大输出 1048576 字节(1 MB)。
911pub const DEFAULT_RUNNER_MAX_OUTPUT_BYTES: u64 = 1_048_576;
912/// 默认每任务最大源码 65536 字节(64 KB)。
913pub const DEFAULT_RUNNER_MAX_SOURCE_BYTES: u64 = 65_536;
914/// 默认排队等待超时 30 秒。
915pub const DEFAULT_RUNNER_QUEUE_TIMEOUT_SECS: u32 = 30;
916/// 默认历史 task 保留 300 秒。
917pub const DEFAULT_RUNNER_TASK_TTL_SECS: u32 = 300;
918
919/// 最大并发下限(至少允许 1 个)。
920#[cfg(feature = "server")]
921pub const MIN_RUNNER_MAX_CONCURRENT: u32 = 1;
922/// CPU 核数下限。
923#[cfg(feature = "server")]
924pub const MIN_RUNNER_MAX_CPU_CORES: f64 = 0.1;
925/// 内存下限(MB)。
926#[cfg(feature = "server")]
927pub const MIN_RUNNER_MAX_MEMORY_MB: u32 = 16;
928/// 超时下限(秒)。
929#[cfg(feature = "server")]
930pub const MIN_RUNNER_MAX_TIMEOUT_SECS: u32 = 1;
931/// 最大并发上限,防误填危险大值。
932#[cfg(feature = "server")]
933pub const MAX_RUNNER_MAX_CONCURRENT: u32 = 64;
934/// CPU 核数上限。
935#[cfg(feature = "server")]
936pub const MAX_RUNNER_MAX_CPU_CORES: f64 = 64.0;
937/// 内存上限(MB)。
938#[cfg(feature = "server")]
939pub const MAX_RUNNER_MAX_MEMORY_MB: u32 = 65_536;
940/// 输出/源码字节上限的下限。
941#[cfg(feature = "server")]
942pub const MIN_RUNNER_MAX_BYTES: u64 = 1;
943/// 每任务最大输出上限(64 MiB),与设置页输入框上限(65536 KB)一致;
944/// 防止误填超大值等同关闭喂给容器的输出截断。
945#[cfg(feature = "server")]
946pub const MAX_RUNNER_MAX_OUTPUT_BYTES: u64 = 64 * 1024 * 1024;
947/// 每任务最大源码上限(64 MiB),与设置页输入框上限(65536 KB)一致。
948#[cfg(feature = "server")]
949pub const MAX_RUNNER_MAX_SOURCE_BYTES: u64 = 64 * 1024 * 1024;
950
951/// 代码运行器配置(资源限制与并发)。
952///
953/// 需重启生效层:值在进程启动时烘焙进 LazyLock,改 DB 值后需重启才生效。
954#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
955pub struct RunnerSettings {
956    /// 是否允许容器联网。
957    pub allow_network: bool,
958    /// 最大并发任务数。
959    pub max_concurrent: u32,
960    /// 每任务最大 CPU 核数。
961    pub max_cpu_cores: f64,
962    /// 每任务最大内存(MB)。
963    pub max_memory_mb: u32,
964    /// 每任务最大执行超时(秒)。
965    pub max_timeout_secs: u32,
966    /// 每任务最大输出字节数。
967    pub max_output_bytes: u64,
968    /// 每任务最大源码字节数。
969    pub max_source_bytes: u64,
970    /// 排队等待超时(秒)。
971    pub queue_timeout_secs: u32,
972    /// 历史 task 保留时长(秒)。
973    pub task_ttl_secs: u32,
974    /// 语言白名单(逗号分隔,如 "python,node,rust");`None` 表示不限制(全开)。
975    pub languages: Option<String>,
976}
977
978impl Default for RunnerSettings {
979    fn default() -> Self {
980        Self {
981            allow_network: DEFAULT_RUNNER_ALLOW_NETWORK,
982            max_concurrent: DEFAULT_RUNNER_MAX_CONCURRENT,
983            max_cpu_cores: DEFAULT_RUNNER_MAX_CPU_CORES,
984            max_memory_mb: DEFAULT_RUNNER_MAX_MEMORY_MB,
985            max_timeout_secs: DEFAULT_RUNNER_MAX_TIMEOUT_SECS,
986            max_output_bytes: DEFAULT_RUNNER_MAX_OUTPUT_BYTES,
987            max_source_bytes: DEFAULT_RUNNER_MAX_SOURCE_BYTES,
988            queue_timeout_secs: DEFAULT_RUNNER_QUEUE_TIMEOUT_SECS,
989            task_ttl_secs: DEFAULT_RUNNER_TASK_TTL_SECS,
990            languages: None,
991        }
992    }
993}
994
995impl RunnerSettings {
996    /// 将最大并发钳制到合法范围 [1, 64]。
997    #[cfg(feature = "server")]
998    pub fn clamp_max_concurrent(n: u32) -> u32 {
999        n.clamp(MIN_RUNNER_MAX_CONCURRENT, MAX_RUNNER_MAX_CONCURRENT)
1000    }
1001
1002    /// 将 CPU 核数钳制到合法范围 [0.1, 64.0];NaN 回退默认值。
1003    #[cfg(feature = "server")]
1004    pub fn clamp_max_cpu_cores(n: f64) -> f64 {
1005        if n.is_nan() {
1006            return DEFAULT_RUNNER_MAX_CPU_CORES;
1007        }
1008        n.clamp(MIN_RUNNER_MAX_CPU_CORES, MAX_RUNNER_MAX_CPU_CORES)
1009    }
1010
1011    /// 将内存钳制到合法范围 [16, 65536]。
1012    #[cfg(feature = "server")]
1013    pub fn clamp_max_memory_mb(n: u32) -> u32 {
1014        n.clamp(MIN_RUNNER_MAX_MEMORY_MB, MAX_RUNNER_MAX_MEMORY_MB)
1015    }
1016
1017    /// 将超时钳制到合法范围 [1, ∞)。
1018    #[cfg(feature = "server")]
1019    pub fn clamp_max_timeout_secs(n: u32) -> u32 {
1020        n.max(MIN_RUNNER_MAX_TIMEOUT_SECS)
1021    }
1022
1023    /// 将输出上限钳制到合法范围 [MIN_RUNNER_MAX_BYTES, MAX_RUNNER_MAX_OUTPUT_BYTES]。
1024    #[cfg(feature = "server")]
1025    pub fn clamp_max_output_bytes(n: u64) -> u64 {
1026        n.clamp(MIN_RUNNER_MAX_BYTES, MAX_RUNNER_MAX_OUTPUT_BYTES)
1027    }
1028
1029    /// 将源码上限钳制到合法范围 [MIN_RUNNER_MAX_BYTES, MAX_RUNNER_MAX_SOURCE_BYTES]。
1030    #[cfg(feature = "server")]
1031    pub fn clamp_max_source_bytes(n: u64) -> u64 {
1032        n.clamp(MIN_RUNNER_MAX_BYTES, MAX_RUNNER_MAX_SOURCE_BYTES)
1033    }
1034
1035    /// 将排队超时钳制到合法范围 [1, ∞)。
1036    #[cfg(feature = "server")]
1037    pub fn clamp_queue_timeout_secs(n: u32) -> u32 {
1038        n.max(MIN_RUNNER_MAX_TIMEOUT_SECS)
1039    }
1040
1041    /// 将 task 保留时长钳制到合法范围 [1, ∞)。
1042    #[cfg(feature = "server")]
1043    pub fn clamp_task_ttl_secs(n: u32) -> u32 {
1044        n.max(1)
1045    }
1046
1047    /// 规范化语言白名单字符串:拆分、trim、转小写、去空;结果为空则返回 None。
1048    #[cfg(feature = "server")]
1049    pub fn normalize_languages(s: &str) -> Option<String> {
1050        let parts: Vec<String> = s
1051            .split(',')
1052            .map(|t| t.trim().to_lowercase())
1053            .filter(|t| !t.is_empty())
1054            .collect();
1055        if parts.is_empty() {
1056            None
1057        } else {
1058            Some(parts.join(","))
1059        }
1060    }
1061}
1062
1063/// 系统启动配置的只读快照(面板展示用)。
1064///
1065/// 这些值在进程启动时读取,无法运行时修改。密钥类仅展示是否已设置,
1066/// 不暴露实际值。
1067#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
1068pub struct SystemInfo {
1069    /// 数据库连接串(脱敏:仅显示 host/dbname,隐藏密码)。
1070    pub database_url_masked: String,
1071    /// tracing 日志过滤器。
1072    pub rust_log: String,
1073    /// 数据库连接池大小。
1074    pub db_pool_size: u32,
1075    /// 单条 SQL 查询超时秒数。
1076    pub statement_timeout_secs: u64,
1077    /// SSR 页面缓存时长(秒)。
1078    pub ssr_cache_secs: u64,
1079    /// 响应压缩算法。
1080    pub compression_algorithms: String,
1081    /// 是否附加版本响应头。
1082    pub expose_version_headers: bool,
1083    /// Docker socket 路径。
1084    pub docker_socket_path: String,
1085    /// MCP 令牌加密主密钥是否已设置(不暴露值)。
1086    pub mcp_token_enc_key_set: bool,
1087    /// 启动迁移重试窗口(秒)。
1088    pub migrate_startup_timeout_secs: u64,
1089    /// 系统信息采样间隔(秒)。
1090    pub sysinfo_sample_secs: f64,
1091    /// 日志查看器(/admin/logs 入库)捕获级别过滤器。
1092    pub log_viewer_level: String,
1093    /// 备份导入单文件上限(MB)。
1094    pub backup_import_max_mb: u64,
1095    /// 上一发布的 public 目录(旧资源保留窗口);未配置为空串。
1096    pub previous_public_dir: String,
1097}
1098
1099#[cfg(test)]
1100mod tests {
1101    use super::*;
1102
1103    #[test]
1104    fn default_is_disabled_30_days() {
1105        let s = TrashSettings::default();
1106        assert!(!s.auto_purge_enabled);
1107        assert_eq!(s.retention_days, 30);
1108    }
1109
1110    #[test]
1111    #[cfg(feature = "server")]
1112    fn clamp_retention_keeps_valid() {
1113        assert_eq!(TrashSettings::clamp_retention(7), 7);
1114        assert_eq!(TrashSettings::clamp_retention(30), 30);
1115    }
1116
1117    #[test]
1118    #[cfg(feature = "server")]
1119    fn clamp_retention_clamps_below_min() {
1120        assert_eq!(TrashSettings::clamp_retention(0), MIN_RETENTION_DAYS);
1121        assert_eq!(TrashSettings::clamp_retention(-5), MIN_RETENTION_DAYS);
1122    }
1123
1124    #[test]
1125    #[cfg(feature = "server")]
1126    fn clamp_retention_clamps_above_max() {
1127        assert_eq!(TrashSettings::clamp_retention(366), MAX_RETENTION_DAYS);
1128        assert_eq!(TrashSettings::clamp_retention(i32::MAX), MAX_RETENTION_DAYS);
1129    }
1130
1131    #[test]
1132    #[cfg(feature = "server")]
1133    fn clamp_retention_boundary() {
1134        assert_eq!(
1135            TrashSettings::clamp_retention(MIN_RETENTION_DAYS),
1136            MIN_RETENTION_DAYS
1137        );
1138        assert_eq!(
1139            TrashSettings::clamp_retention(MAX_RETENTION_DAYS),
1140            MAX_RETENTION_DAYS
1141        );
1142    }
1143
1144    #[test]
1145    fn asset_purge_default_is_enabled_7_days() {
1146        let s = AssetPurgeSettings::default();
1147        assert!(s.auto_purge_enabled);
1148        assert_eq!(s.retention_days, 7);
1149    }
1150
1151    #[test]
1152    #[cfg(feature = "server")]
1153    fn asset_purge_clamp_retention() {
1154        assert_eq!(
1155            AssetPurgeSettings::clamp_retention(0),
1156            MIN_ASSET_ORPHAN_RETENTION_DAYS
1157        );
1158        assert_eq!(AssetPurgeSettings::clamp_retention(7), 7);
1159        assert_eq!(
1160            AssetPurgeSettings::clamp_retention(366),
1161            MAX_ASSET_ORPHAN_RETENTION_DAYS
1162        );
1163    }
1164
1165    #[test]
1166    fn site_settings_default_empty() {
1167        let s = SiteSettings::default();
1168        assert_eq!(s.github_url, "");
1169    }
1170
1171    #[test]
1172    fn home_tagline_defaults_when_unset() {
1173        let tagline = SiteSettings::default().home_tagline();
1174        assert_eq!(tagline.title, DEFAULT_HOME_TITLE);
1175        assert_eq!(tagline.subtitle, DEFAULT_HOME_SUBTITLE);
1176        assert_eq!(tagline.intro, DEFAULT_HOME_INTRO);
1177    }
1178
1179    #[test]
1180    fn home_tagline_keeps_empty_subtitle_with_custom_title() {
1181        let settings = SiteSettings {
1182            home_title: "写点什么".into(),
1183            ..SiteSettings::default()
1184        };
1185        let tagline = settings.home_tagline();
1186        assert_eq!(tagline.title, "写点什么");
1187        assert_eq!(tagline.subtitle, "");
1188        assert_eq!(tagline.intro, DEFAULT_HOME_INTRO);
1189    }
1190
1191    #[test]
1192    fn site_description_falls_back_and_is_normalized() {
1193        assert_eq!(
1194            SiteSettings::default().description(),
1195            DEFAULT_SITE_DESCRIPTION
1196        );
1197        let settings = SiteSettings {
1198            site_description: format!("  写长文\n{}", "字".repeat(200)),
1199            ..SiteSettings::default()
1200        }
1201        .normalized();
1202        assert!(settings.site_description.starts_with("写长文 字"));
1203        assert_eq!(
1204            settings.site_description.chars().count(),
1205            MAX_SITE_DESCRIPTION_LEN
1206        );
1207    }
1208
1209    #[test]
1210    fn site_settings_accept_payload_without_home_fields() {
1211        let settings: SiteSettings =
1212            serde_json::from_str(r#"{"github_url":"https://github.com/x"}"#).unwrap();
1213        assert_eq!(settings.home_title, "");
1214    }
1215
1216    #[test]
1217    fn normalize_home_text_collapses_whitespace_and_truncates() {
1218        assert_eq!(
1219            SiteSettings::normalize_home_text("  慢慢读,\n  认真写  ", 40),
1220            "慢慢读, 认真写"
1221        );
1222        let long = "字".repeat(MAX_HOME_TITLE_LEN + 5);
1223        let normalized = SiteSettings::normalize_home_text(&long, MAX_HOME_TITLE_LEN);
1224        assert_eq!(normalized.chars().count(), MAX_HOME_TITLE_LEN);
1225    }
1226
1227    #[test]
1228    fn normalize_github_url_empty() {
1229        assert_eq!(SiteSettings::normalize_github_url(""), "");
1230        assert_eq!(SiteSettings::normalize_github_url("   "), "");
1231    }
1232
1233    #[test]
1234    fn normalize_github_url_prepends_https() {
1235        assert_eq!(
1236            SiteSettings::normalize_github_url("github.com/DefectingCat/yggdrasil"),
1237            "https://github.com/DefectingCat/yggdrasil"
1238        );
1239    }
1240
1241    #[test]
1242    fn normalize_github_url_keeps_scheme() {
1243        assert_eq!(
1244            SiteSettings::normalize_github_url("https://github.com/foo"),
1245            "https://github.com/foo"
1246        );
1247        assert_eq!(
1248            SiteSettings::normalize_github_url("http://example.com"),
1249            "http://example.com"
1250        );
1251    }
1252
1253    #[test]
1254    fn normalize_github_url_neutralizes_dangerous_scheme() {
1255        // javascript:/data: 不以 http(s) 开头 → 补 https:// 变成无效 URL,无法注入脚本。
1256        assert_eq!(
1257            SiteSettings::normalize_github_url("javascript:alert(1)"),
1258            "https://javascript:alert(1)"
1259        );
1260    }
1261
1262    #[test]
1263    fn normalize_github_url_trims_and_truncates() {
1264        assert_eq!(
1265            SiteSettings::normalize_github_url("  github.com/x  "),
1266            "https://github.com/x"
1267        );
1268        let long = "a".repeat(MAX_SITE_GITHUB_URL_LEN + 50);
1269        let normalized = SiteSettings::normalize_github_url(&long);
1270        // 补的 https:// 前缀不计入截断后的主体长度。
1271        assert_eq!(normalized.len(), "https://".len() + MAX_SITE_GITHUB_URL_LEN);
1272    }
1273
1274    // ── BackupSettings ───────────────────────────────────────────
1275
1276    #[test]
1277    fn backup_settings_default() {
1278        let s = BackupSettings::default();
1279        assert!(!s.auto_enabled);
1280        assert_eq!(s.time_utc, "04:00");
1281        assert_eq!(s.retention_count, 30);
1282        assert!(s.include_uploads);
1283    }
1284
1285    #[test]
1286    fn normalize_time_pads_single_digits() {
1287        assert_eq!(BackupSettings::normalize_time_utc("4:5"), "04:05");
1288        assert_eq!(BackupSettings::normalize_time_utc(" 23:59 "), "23:59");
1289        assert_eq!(BackupSettings::normalize_time_utc("0:00"), "00:00");
1290    }
1291
1292    #[test]
1293    fn normalize_time_rejects_garbage() {
1294        for bad in ["", "abc", "24:00", "12:60", "1:2:3", "-1:30", "12:", ":30"] {
1295            assert_eq!(
1296                BackupSettings::normalize_time_utc(bad),
1297                DEFAULT_BACKUP_TIME_UTC,
1298                "非法时间应回退默认: {bad:?}"
1299            );
1300        }
1301    }
1302
1303    #[test]
1304    #[cfg(feature = "server")]
1305    fn backup_clamp_retention() {
1306        assert_eq!(
1307            BackupSettings::clamp_retention(0),
1308            MIN_BACKUP_RETENTION_COUNT
1309        );
1310        assert_eq!(
1311            BackupSettings::clamp_retention(366),
1312            MAX_BACKUP_RETENTION_COUNT
1313        );
1314        assert_eq!(BackupSettings::clamp_retention(30), 30);
1315    }
1316
1317    #[test]
1318    #[cfg(feature = "server")]
1319    fn next_run_same_day_when_future() {
1320        use chrono::{TimeZone, Utc};
1321        let s = BackupSettings {
1322            time_utc: "04:00".into(),
1323            ..Default::default()
1324        };
1325        let now = Utc.with_ymd_and_hms(2026, 8, 10, 1, 30, 0).unwrap();
1326        let next = s.next_run_after(now).expect("合法时间必有下次执行");
1327        assert_eq!(next, Utc.with_ymd_and_hms(2026, 8, 10, 4, 0, 0).unwrap());
1328    }
1329
1330    #[test]
1331    #[cfg(feature = "server")]
1332    fn next_run_rolls_to_tomorrow_when_past() {
1333        use chrono::{TimeZone, Utc};
1334        let s = BackupSettings {
1335            time_utc: "04:00".into(),
1336            ..Default::default()
1337        };
1338        // 恰等于触发时刻也算「已过」——避免刚错过即触发两次。
1339        for now in [
1340            Utc.with_ymd_and_hms(2026, 8, 10, 4, 0, 0).unwrap(),
1341            Utc.with_ymd_and_hms(2026, 8, 10, 23, 59, 59).unwrap(),
1342        ] {
1343            let next = s.next_run_after(now).expect("合法时间必有下次执行");
1344            assert_eq!(next, Utc.with_ymd_and_hms(2026, 8, 11, 4, 0, 0).unwrap());
1345        }
1346    }
1347
1348    #[test]
1349    #[cfg(feature = "server")]
1350    fn next_run_none_on_invalid_time() {
1351        use chrono::{TimeZone, Utc};
1352        let s = BackupSettings {
1353            time_utc: "garbage".into(),
1354            ..Default::default()
1355        };
1356        let now = Utc.with_ymd_and_hms(2026, 8, 10, 0, 0, 0).unwrap();
1357        assert!(s.next_run_after(now).is_none());
1358    }
1359
1360    // ── UploadSettings ───────────────────────────────────────────
1361
1362    #[test]
1363    fn upload_settings_default() {
1364        assert_eq!(UploadSettings::default().concurrency, 3);
1365    }
1366
1367    #[test]
1368    #[cfg(feature = "server")]
1369    fn upload_clamp_concurrency() {
1370        assert_eq!(UploadSettings::clamp_concurrency(0), MIN_UPLOAD_CONCURRENCY);
1371        assert_eq!(
1372            UploadSettings::clamp_concurrency(-1),
1373            MIN_UPLOAD_CONCURRENCY
1374        );
1375        assert_eq!(UploadSettings::clamp_concurrency(9), MAX_UPLOAD_CONCURRENCY);
1376        assert_eq!(
1377            UploadSettings::clamp_concurrency(i32::MAX),
1378            MAX_UPLOAD_CONCURRENCY
1379        );
1380        assert_eq!(UploadSettings::clamp_concurrency(5), 5);
1381    }
1382
1383    // ── SecuritySettings ─────────────────────────────────────────
1384
1385    #[test]
1386    fn security_settings_default() {
1387        let s = SecuritySettings::default();
1388        assert!(s.app_base_url.is_empty());
1389        assert!(!s.cookie_secure);
1390        assert_eq!(s.trusted_proxy_count, 0);
1391        assert_eq!(s.max_sessions_per_user, 5);
1392    }
1393
1394    #[test]
1395    #[cfg(feature = "server")]
1396    fn security_clamp_trusted_proxy_count() {
1397        assert_eq!(SecuritySettings::clamp_trusted_proxy_count(0), 0);
1398        assert_eq!(SecuritySettings::clamp_trusted_proxy_count(3), 3);
1399        assert_eq!(
1400            SecuritySettings::clamp_trusted_proxy_count(99),
1401            MAX_TRUSTED_PROXY_COUNT
1402        );
1403    }
1404
1405    #[test]
1406    #[cfg(feature = "server")]
1407    fn security_clamp_max_sessions() {
1408        assert_eq!(
1409            SecuritySettings::clamp_max_sessions(0),
1410            MIN_MAX_SESSIONS_PER_USER
1411        );
1412        assert_eq!(SecuritySettings::clamp_max_sessions(5), 5);
1413        assert_eq!(
1414            SecuritySettings::clamp_max_sessions(u32::MAX),
1415            MAX_MAX_SESSIONS_PER_USER
1416        );
1417    }
1418
1419    #[test]
1420    fn security_parse_app_base_url_requires_http_scheme_and_host() {
1421        let parse = SecuritySettings::parse_app_base_url;
1422        assert_eq!(parse("  https://x.com ").as_deref(), Some("https://x.com"));
1423        assert_eq!(
1424            parse("http://localhost:8080/").as_deref(),
1425            Some("http://localhost:8080/")
1426        );
1427        // 空串表示未配置。
1428        assert_eq!(parse("").as_deref(), Some(""));
1429        assert_eq!(parse("   ").as_deref(), Some(""));
1430        // 缺协议的值永远不等于浏览器发来的 Origin,会拒绝全部写请求(含登录)。
1431        assert_eq!(parse("localhost:8080"), None);
1432        assert_eq!(parse("ftp://x.com"), None);
1433        assert_eq!(parse("https://"), None);
1434        assert_eq!(parse("https://:8080"), None);
1435        assert_eq!(parse("https://x .com"), None);
1436    }
1437
1438    // ── ImageCacheSettings ───────────────────────────────────────
1439
1440    #[test]
1441    fn image_cache_settings_default() {
1442        let s = ImageCacheSettings::default();
1443        assert_eq!(s.disk_cache_max_mb, 1024);
1444        assert_eq!(s.disk_cache_max_age_hours, 168);
1445    }
1446
1447    #[test]
1448    #[cfg(feature = "server")]
1449    fn image_cache_clamp_max_mb() {
1450        assert_eq!(
1451            ImageCacheSettings::clamp_max_mb(0),
1452            MIN_IMAGE_DISK_CACHE_MAX_MB
1453        );
1454        assert_eq!(ImageCacheSettings::clamp_max_mb(2048), 2048);
1455    }
1456
1457    #[test]
1458    #[cfg(feature = "server")]
1459    fn image_cache_clamp_max_age_hours() {
1460        assert_eq!(
1461            ImageCacheSettings::clamp_max_age_hours(0),
1462            MIN_IMAGE_DISK_CACHE_MAX_AGE_HOURS
1463        );
1464        assert_eq!(ImageCacheSettings::clamp_max_age_hours(720), 720);
1465    }
1466
1467    #[test]
1468    #[cfg(feature = "server")]
1469    fn image_cache_clamps_cap_huge_values() {
1470        assert_eq!(
1471            ImageCacheSettings::clamp_max_mb(u32::MAX),
1472            MAX_IMAGE_DISK_CACHE_MAX_MB
1473        );
1474        assert_eq!(
1475            ImageCacheSettings::clamp_max_age_hours(u32::MAX),
1476            MAX_IMAGE_DISK_CACHE_MAX_AGE_HOURS
1477        );
1478        // 设置页可填的最大值不被截断。
1479        assert_eq!(ImageCacheSettings::clamp_max_mb(1_048_576), 1_048_576);
1480        assert_eq!(ImageCacheSettings::clamp_max_age_hours(87_600), 87_600);
1481    }
1482
1483    #[test]
1484    fn rate_limit_default_values() {
1485        let s = RateLimitSettings::default();
1486        assert_eq!(s.strict_per_sec, 1);
1487        assert_eq!(s.strict_burst, 5);
1488        assert_eq!(s.upload_per_sec, 2);
1489        assert_eq!(s.upload_burst, 15);
1490        assert_eq!(s.image_per_sec, 10);
1491        assert_eq!(s.image_burst, 50);
1492        assert_eq!(s.comment_per_sec, 1);
1493        assert_eq!(s.comment_burst, 5);
1494        assert_eq!(s.comment_upload_per_sec, 1);
1495        assert_eq!(s.comment_upload_burst, 5);
1496        assert_eq!(s.comment_upload_daily, 30);
1497        assert_eq!(s.code_exec_per_sec, 1);
1498        assert_eq!(s.code_exec_burst, 3);
1499        assert_eq!(s.code_exec_daily, 50);
1500        assert_eq!(s.unknown_per_sec, 30);
1501        assert_eq!(s.unknown_burst, 100);
1502        assert_eq!(s.gc_interval_secs, 300);
1503    }
1504
1505    #[test]
1506    #[cfg(feature = "server")]
1507    fn rate_limit_clamp_per_sec() {
1508        assert_eq!(RateLimitSettings::clamp_per_sec(0), MIN_RATE_LIMIT_PER_SEC);
1509        assert_eq!(RateLimitSettings::clamp_per_sec(5), 5);
1510    }
1511
1512    #[test]
1513    #[cfg(feature = "server")]
1514    fn rate_limit_clamp_per_sec_caps_huge_values() {
1515        assert_eq!(
1516            RateLimitSettings::clamp_per_sec(u32::MAX),
1517            MAX_RATE_LIMIT_PER_SEC
1518        );
1519        assert_eq!(RateLimitSettings::clamp_per_sec(10_000), 10_000);
1520    }
1521
1522    #[test]
1523    #[cfg(feature = "server")]
1524    fn rate_limit_clamp_burst() {
1525        assert_eq!(RateLimitSettings::clamp_burst(0), MIN_RATE_LIMIT_BURST);
1526        assert_eq!(RateLimitSettings::clamp_burst(15), 15);
1527    }
1528
1529    #[test]
1530    #[cfg(feature = "server")]
1531    fn rate_limit_clamp_daily() {
1532        assert_eq!(RateLimitSettings::clamp_daily(0), MIN_RATE_LIMIT_DAILY);
1533        assert_eq!(RateLimitSettings::clamp_daily(50), 50);
1534    }
1535
1536    #[test]
1537    #[cfg(feature = "server")]
1538    fn rate_limit_clamp_gc_interval() {
1539        assert_eq!(
1540            RateLimitSettings::clamp_gc_interval(0),
1541            MIN_RATE_LIMIT_GC_INTERVAL_SECS
1542        );
1543        assert_eq!(RateLimitSettings::clamp_gc_interval(300), 300);
1544    }
1545    // ── WebpSettings ─────────────────────────────────────────────
1546
1547    #[test]
1548    fn webp_settings_default() {
1549        let s = WebpSettings::default();
1550        assert_eq!(s.quality, 85.0);
1551        assert_eq!(s.method, 2);
1552    }
1553
1554    #[test]
1555    #[cfg(feature = "server")]
1556    fn webp_clamp_quality() {
1557        assert_eq!(WebpSettings::clamp_quality(-1.0), 0.0);
1558        assert_eq!(WebpSettings::clamp_quality(50.0), 50.0);
1559        assert_eq!(WebpSettings::clamp_quality(150.0), 100.0);
1560    }
1561
1562    #[test]
1563    #[cfg(feature = "server")]
1564    fn webp_clamp_method() {
1565        assert_eq!(WebpSettings::clamp_method(0), 0);
1566        assert_eq!(WebpSettings::clamp_method(3), 3);
1567        assert_eq!(WebpSettings::clamp_method(9), MAX_WEBP_METHOD);
1568    }
1569
1570    // ── ImageLimitSettings ───────────────────────────────────────
1571
1572    #[test]
1573    fn image_limit_settings_default() {
1574        let s = ImageLimitSettings::default();
1575        assert_eq!(s.max_dimension, 8192);
1576        assert_eq!(s.max_pixels, 50_000_000);
1577        assert_eq!(s.dimensions_cache_ttl_secs, 86400);
1578    }
1579
1580    #[test]
1581    #[cfg(feature = "server")]
1582    fn image_limit_clamp_max_dimension() {
1583        assert_eq!(
1584            ImageLimitSettings::clamp_max_dimension(0),
1585            MIN_IMAGE_MAX_DIMENSION
1586        );
1587        assert_eq!(ImageLimitSettings::clamp_max_dimension(2048), 2048);
1588    }
1589
1590    #[test]
1591    #[cfg(feature = "server")]
1592    fn image_limit_clamp_max_pixels() {
1593        assert_eq!(
1594            ImageLimitSettings::clamp_max_pixels(0),
1595            MIN_IMAGE_MAX_PIXELS
1596        );
1597        assert_eq!(ImageLimitSettings::clamp_max_pixels(99_999_999), 99_999_999);
1598    }
1599
1600    #[test]
1601    #[cfg(feature = "server")]
1602    fn image_limit_clamp_ttl() {
1603        assert_eq!(
1604            ImageLimitSettings::clamp_dimensions_cache_ttl_secs(0),
1605            MIN_IMAGE_DIMENSIONS_CACHE_TTL_SECS
1606        );
1607        assert_eq!(
1608            ImageLimitSettings::clamp_dimensions_cache_ttl_secs(7200),
1609            7200
1610        );
1611    }
1612
1613    #[test]
1614    #[cfg(feature = "server")]
1615    fn ssr_cache_clamp_max_page_bytes() {
1616        assert_eq!(
1617            SsrCacheSettings::clamp_max_page_bytes(0),
1618            MIN_SSR_CACHE_MAX_PAGE_BYTES
1619        );
1620        assert_eq!(
1621            SsrCacheSettings::clamp_max_page_bytes(u64::MAX),
1622            MAX_SSR_CACHE_MAX_PAGE_BYTES
1623        );
1624        assert_eq!(SsrCacheSettings::clamp_max_page_bytes(1 << 20), 1 << 20);
1625        assert_eq!(SsrCacheSettings::clamp_edge_ttl_secs(0), 0);
1626        assert_eq!(SsrCacheSettings::clamp_edge_ttl_secs(3600), 3600);
1627        assert_eq!(
1628            SsrCacheSettings::clamp_edge_ttl_secs(u64::MAX),
1629            MAX_SSR_CACHE_EDGE_TTL_SECS
1630        );
1631    }
1632
1633    // ── RunnerSettings ───────────────────────────────────────────
1634
1635    #[test]
1636    fn runner_settings_default() {
1637        let s = RunnerSettings::default();
1638        assert!(!s.allow_network);
1639        assert_eq!(s.max_concurrent, 4);
1640        assert_eq!(s.max_cpu_cores, 2.0);
1641        assert_eq!(s.max_memory_mb, 1024);
1642        assert_eq!(s.max_timeout_secs, 30);
1643        assert_eq!(s.max_output_bytes, 1_048_576);
1644        assert_eq!(s.max_source_bytes, 65_536);
1645        assert_eq!(s.queue_timeout_secs, 30);
1646        assert_eq!(s.task_ttl_secs, 300);
1647        assert!(s.languages.is_none());
1648    }
1649
1650    #[test]
1651    #[cfg(feature = "server")]
1652    fn runner_clamp_max_concurrent() {
1653        assert_eq!(
1654            RunnerSettings::clamp_max_concurrent(0),
1655            MIN_RUNNER_MAX_CONCURRENT
1656        );
1657        assert_eq!(RunnerSettings::clamp_max_concurrent(8), 8);
1658        assert_eq!(
1659            RunnerSettings::clamp_max_concurrent(999),
1660            MAX_RUNNER_MAX_CONCURRENT
1661        );
1662    }
1663
1664    #[test]
1665    #[cfg(feature = "server")]
1666    fn runner_clamp_max_cpu_cores() {
1667        assert_eq!(
1668            RunnerSettings::clamp_max_cpu_cores(0.0),
1669            MIN_RUNNER_MAX_CPU_CORES
1670        );
1671        assert_eq!(RunnerSettings::clamp_max_cpu_cores(4.0), 4.0);
1672        assert_eq!(
1673            RunnerSettings::clamp_max_cpu_cores(999.0),
1674            MAX_RUNNER_MAX_CPU_CORES
1675        );
1676        // NaN 回退默认值 2.0
1677        assert_eq!(RunnerSettings::clamp_max_cpu_cores(f64::NAN), 2.0);
1678    }
1679
1680    #[test]
1681    #[cfg(feature = "server")]
1682    fn runner_clamp_max_memory_mb() {
1683        assert_eq!(
1684            RunnerSettings::clamp_max_memory_mb(0),
1685            MIN_RUNNER_MAX_MEMORY_MB
1686        );
1687        assert_eq!(RunnerSettings::clamp_max_memory_mb(2048), 2048);
1688        assert_eq!(
1689            RunnerSettings::clamp_max_memory_mb(u32::MAX),
1690            MAX_RUNNER_MAX_MEMORY_MB
1691        );
1692    }
1693
1694    #[test]
1695    #[cfg(feature = "server")]
1696    fn runner_clamp_max_timeout_secs() {
1697        assert_eq!(
1698            RunnerSettings::clamp_max_timeout_secs(0),
1699            MIN_RUNNER_MAX_TIMEOUT_SECS
1700        );
1701        assert_eq!(RunnerSettings::clamp_max_timeout_secs(120), 120);
1702    }
1703
1704    #[test]
1705    #[cfg(feature = "server")]
1706    fn runner_clamp_byte_limits() {
1707        assert_eq!(RunnerSettings::clamp_max_output_bytes(0), 1);
1708        assert_eq!(RunnerSettings::clamp_max_source_bytes(0), 1);
1709        assert_eq!(
1710            RunnerSettings::clamp_queue_timeout_secs(0),
1711            MIN_RUNNER_MAX_TIMEOUT_SECS
1712        );
1713        assert_eq!(RunnerSettings::clamp_task_ttl_secs(0), 1);
1714    }
1715
1716    #[test]
1717    #[cfg(feature = "server")]
1718    fn runner_clamp_byte_limits_cap_huge_values() {
1719        assert_eq!(
1720            RunnerSettings::clamp_max_output_bytes(u64::MAX),
1721            MAX_RUNNER_MAX_OUTPUT_BYTES
1722        );
1723        assert_eq!(
1724            RunnerSettings::clamp_max_source_bytes(u64::MAX),
1725            MAX_RUNNER_MAX_SOURCE_BYTES
1726        );
1727        // 默认值与设置页可填的最大值(65536 KB)都不被截断。
1728        assert_eq!(
1729            RunnerSettings::clamp_max_output_bytes(DEFAULT_RUNNER_MAX_OUTPUT_BYTES),
1730            DEFAULT_RUNNER_MAX_OUTPUT_BYTES
1731        );
1732        assert_eq!(
1733            RunnerSettings::clamp_max_source_bytes(DEFAULT_RUNNER_MAX_SOURCE_BYTES),
1734            DEFAULT_RUNNER_MAX_SOURCE_BYTES
1735        );
1736        assert_eq!(
1737            RunnerSettings::clamp_max_output_bytes(65_536 * 1024),
1738            65_536 * 1024
1739        );
1740        assert_eq!(
1741            RunnerSettings::clamp_max_source_bytes(65_536 * 1024),
1742            65_536 * 1024
1743        );
1744    }
1745
1746    #[test]
1747    #[cfg(feature = "server")]
1748    fn runner_normalize_languages() {
1749        assert!(RunnerSettings::normalize_languages("").is_none());
1750        assert!(RunnerSettings::normalize_languages("  ,,  ").is_none());
1751        assert_eq!(
1752            RunnerSettings::normalize_languages("Python, Node, RUST").as_deref(),
1753            Some("python,node,rust")
1754        );
1755        assert_eq!(
1756            RunnerSettings::normalize_languages("  go  ").as_deref(),
1757            Some("go")
1758        );
1759    }
1760}