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/// 站点公开配置(前台展示用,所有访客可见)。
207///
208/// 目前仅包含页脚 GitHub 链接;后续可在此结构上扩展更多公开站点配置。
209#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
210pub struct SiteSettings {
211    /// GitHub 仓库链接,空字符串表示不展示页脚图标。
212    pub github_url: String,
213}
214
215impl Default for SiteSettings {
216    fn default() -> Self {
217        Self {
218            github_url: DEFAULT_SITE_GITHUB_URL.to_string(),
219        }
220    }
221}
222
223impl SiteSettings {
224    #[cfg(any(feature = "server", test))]
225    /// 规范化 GitHub 链接:trim、截断、补全 scheme。
226    ///
227    /// - 空串(trim 后)→ 返回空串,表示未配置。
228    /// - 已带 `http://` / `https://` → 原样保留(截断到上限)。
229    /// - 否则在前面补 `https://`;这同时把 `javascript:` / `data:` 等危险 scheme
230    ///   变成无效 URL(`https://javascript:...`),杜绝通过页脚 `href` 注入脚本。
231    pub fn normalize_github_url(url: &str) -> String {
232        let trimmed = url.trim();
233        if trimmed.is_empty() {
234            return String::new();
235        }
236        let limited: String = trimmed.chars().take(MAX_SITE_GITHUB_URL_LEN).collect();
237        if limited.starts_with("http://") || limited.starts_with("https://") {
238            limited
239        } else {
240            format!("https://{limited}")
241        }
242    }
243}
244
245// ============================================================================
246// 安全配置(即时生效)
247// ============================================================================
248//
249// 这些设置原本经环境变量每次请求/每次登录读取(src/api/csrf.rs 的
250// trusted_origin、src/auth/session.rs 的 cookie_secure、src/api/rate_limit.rs
251// 的 trusted_proxy_count、src/api/auth.rs 的 max_sessions_per_user)。迁移到
252// settings 表后语义不变:env 在首次部署播种 DB(ON CONFLICT DO NOTHING),
253// 之后面板写入的 DB 值优先,重启不被 env 覆盖。读取走 moka 短 TTL 缓存,
254// 面板保存时失效缓存——最长滞后数秒即全链路生效。
255
256/// 默认 APP_BASE_URL:空串表示未配置(回退到 Host 头推导,仅本地安全)。
257pub const DEFAULT_APP_BASE_URL: &str = "";
258/// 默认不给会话 cookie 加 Secure 标志(本地 HTTP 开发需要)。
259pub const DEFAULT_COOKIE_SECURE: bool = false;
260/// 默认反向代理层数 0(直接对外服务)。
261pub const DEFAULT_TRUSTED_PROXY_COUNT: u32 = 0;
262/// 默认每用户最大并发会话数 5。
263pub const DEFAULT_MAX_SESSIONS_PER_USER: u32 = 5;
264
265/// `TRUSTED_PROXY_COUNT` 上限。超过真实代理层数会信任客户端伪造的 IP,
266/// 导致限流被绕过;上限给一个宽松天花板防误填危险大值。
267#[cfg(feature = "server")]
268pub const MAX_TRUSTED_PROXY_COUNT: u32 = 10;
269/// `MAX_SESSIONS_PER_USER` 下限(至少允许 1 个会话)。
270#[cfg(feature = "server")]
271pub const MIN_MAX_SESSIONS_PER_USER: u32 = 1;
272/// `MAX_SESSIONS_PER_USER` 上限,防误填危险大值。
273#[cfg(feature = "server")]
274pub const MAX_MAX_SESSIONS_PER_USER: u32 = 100;
275
276/// 安全配置(CSRF 可信源、cookie Secure 标志、真实 IP 提取、并发会话上限)。
277///
278/// 即时生效层:读取走 moka 缓存,面板保存后失效缓存,数秒内全链路生效。
279#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
280pub struct SecuritySettings {
281    /// 写请求 CSRF 校验的可信来源(如 `https://your-domain.example`)。
282    /// 空串表示未配置,回退到 Host 头 + X-Forwarded-Proto(生产建议显式配置)。
283    pub app_base_url: String,
284    /// 是否给会话 cookie 加 Secure 标志(仅 HTTPS 下发送)。
285    pub cookie_secure: bool,
286    /// 应用前方的反向代理层数,用于从 X-Forwarded-For 提取真实客户端 IP。
287    pub trusted_proxy_count: u32,
288    /// 单用户最大并发会话数,超出按最旧优先淘汰。
289    pub max_sessions_per_user: u32,
290}
291
292impl Default for SecuritySettings {
293    fn default() -> Self {
294        Self {
295            app_base_url: DEFAULT_APP_BASE_URL.to_string(),
296            cookie_secure: DEFAULT_COOKIE_SECURE,
297            trusted_proxy_count: DEFAULT_TRUSTED_PROXY_COUNT,
298            max_sessions_per_user: DEFAULT_MAX_SESSIONS_PER_USER,
299        }
300    }
301}
302
303impl SecuritySettings {
304    /// 将代理层数钳制到合法范围 [0, MAX]。
305    #[cfg(feature = "server")]
306    pub fn clamp_trusted_proxy_count(n: u32) -> u32 {
307        n.min(MAX_TRUSTED_PROXY_COUNT)
308    }
309
310    /// 将并发会话上限钳制到合法范围 [MIN, MAX]。
311    #[cfg(feature = "server")]
312    pub fn clamp_max_sessions(n: u32) -> u32 {
313        n.clamp(MIN_MAX_SESSIONS_PER_USER, MAX_MAX_SESSIONS_PER_USER)
314    }
315
316    #[cfg(any(feature = "server", test))]
317    /// 规范化 APP_BASE_URL:trim;空串保留为空串(表示未配置)。
318    /// 不在此补 scheme——CSRF 校验需要精确的 origin,补全反而可能失配。
319    pub fn normalize_app_base_url(url: &str) -> String {
320        url.trim().to_string()
321    }
322}
323
324// ============================================================================
325// 图片磁盘缓存配置(即时生效)
326// ============================================================================
327//
328// 原本经环境变量在每次清理 tick 读取(src/tasks/image_cache_cleanup.rs)。
329// 迁移到 settings 表后语义不变:env 首启播种,之后面板值优先。
330
331/// 默认图片磁盘缓存上限 1024 MB。
332pub const DEFAULT_IMAGE_DISK_CACHE_MAX_MB: u32 = 1024;
333/// 默认图片磁盘缓存保留 168 小时(7 天)。
334pub const DEFAULT_IMAGE_DISK_CACHE_MAX_AGE_HOURS: u32 = 168;
335/// 磁盘缓存上限下限(MB),防误填危险小值。
336#[cfg(feature = "server")]
337pub const MIN_IMAGE_DISK_CACHE_MAX_MB: u32 = 1;
338/// 缓存保留时长下限(小时)。
339#[cfg(feature = "server")]
340pub const MIN_IMAGE_DISK_CACHE_MAX_AGE_HOURS: u32 = 1;
341
342/// 图片磁盘缓存配置(uploads/.cache/ 的容量与保留策略)。
343#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
344pub struct ImageCacheSettings {
345    /// 最大总容量(MB),超限按修改时间删最旧文件。
346    pub disk_cache_max_mb: u32,
347    /// 文件最大保留时长(小时),超期优先删除。
348    pub disk_cache_max_age_hours: u32,
349}
350
351impl Default for ImageCacheSettings {
352    fn default() -> Self {
353        Self {
354            disk_cache_max_mb: DEFAULT_IMAGE_DISK_CACHE_MAX_MB,
355            disk_cache_max_age_hours: DEFAULT_IMAGE_DISK_CACHE_MAX_AGE_HOURS,
356        }
357    }
358}
359
360impl ImageCacheSettings {
361    /// 将容量上限钳制到合法范围 [MIN, ∞)。
362    #[cfg(feature = "server")]
363    pub fn clamp_max_mb(n: u32) -> u32 {
364        n.max(MIN_IMAGE_DISK_CACHE_MAX_MB)
365    }
366
367    /// 将保留时长钳制到合法范围 [MIN, ∞)。
368    #[cfg(feature = "server")]
369    pub fn clamp_max_age_hours(n: u32) -> u32 {
370        n.max(MIN_IMAGE_DISK_CACHE_MAX_AGE_HOURS)
371    }
372}
373
374// ============================================================================
375// 孤儿素材自动清理配置(即时生效)
376// ============================================================================
377//
378// 后台任务每天读取 DB 值执行清理:无文章引用(asset_refs)且无存活评论引用
379// (comments.content_html)的素材,超过保留天数后物理删除(文件 + DB 行 +
380// 派生缓存)。语义与 /admin/assets 的「一键清理孤儿」一致,只是改为定时自动。
381// 评论区允许匿名传图后,未提交/被删评论留下的孤儿图会持续累积,此任务是
382// 磁盘的主要回收手段。env 首启播种,之后面板值优先(下个 tick 生效)。
383
384/// 默认启用孤儿素材自动清理。
385pub const DEFAULT_ASSET_ORPHAN_PURGE_ENABLED: bool = true;
386/// 默认孤儿素材保留 7 天(与 /admin/assets 手动清理的 PURGE_GRACE_DAYS 一致,
387/// 保护尚未首次保存的草稿引用)。
388pub const DEFAULT_ASSET_ORPHAN_RETENTION_DAYS: i32 = 7;
389/// 保留天数下限(天)。
390#[cfg(feature = "server")]
391pub const MIN_ASSET_ORPHAN_RETENTION_DAYS: i32 = 1;
392/// 保留天数上限(天)。防止误填超大值导致永不清理。
393#[cfg(feature = "server")]
394pub const MAX_ASSET_ORPHAN_RETENTION_DAYS: i32 = 365;
395
396/// 孤儿素材自动清理配置(uploads/ 中无引用的图片)。
397#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
398pub struct AssetPurgeSettings {
399    /// 是否启用自动定时清理。
400    pub auto_purge_enabled: bool,
401    /// 孤儿素材保留天数,超过后被后台任务物理删除。
402    pub retention_days: i32,
403}
404
405impl Default for AssetPurgeSettings {
406    fn default() -> Self {
407        Self {
408            auto_purge_enabled: DEFAULT_ASSET_ORPHAN_PURGE_ENABLED,
409            retention_days: DEFAULT_ASSET_ORPHAN_RETENTION_DAYS,
410        }
411    }
412}
413
414impl AssetPurgeSettings {
415    /// 将保留天数钳制到合法范围 [MIN, MAX]。
416    #[cfg(feature = "server")]
417    pub fn clamp_retention(days: i32) -> i32 {
418        days.clamp(
419            MIN_ASSET_ORPHAN_RETENTION_DAYS,
420            MAX_ASSET_ORPHAN_RETENTION_DAYS,
421        )
422    }
423}
424
425// ============================================================================
426// 限流配置(重启生效)
427// ============================================================================
428//
429// 原本经环境变量在首次请求时经 LazyLock 读取(src/api/rate_limit.rs 的六个
430// IP 键控限流器与 GC 间隔)。迁移到 settings 表后语义为 Tier B:env 首启播种
431// DB(ON CONFLICT DO NOTHING),之后面板写入的 DB 值优先。由于限流器是
432// LazyLock 静态量(首次请求构造即固化),修改 DB 值需**重启进程**生效,
433// 面板会标注「需重启生效」。启动时由 main.rs 将 DB 值加载进 config::RATE_LIMIT_CFG,
434// rate_limit.rs 的 LazyLock 改为从 config::rate_limit() 读取。
435
436/// 默认严格限流(注册/登录):1 req/s,突发 5。
437pub const DEFAULT_RATE_LIMIT_STRICT_PER_SEC: u32 = 1;
438pub const DEFAULT_RATE_LIMIT_STRICT_BURST: u32 = 5;
439/// 默认上传限流:2 req/s,突发 15。
440pub const DEFAULT_RATE_LIMIT_UPLOAD_PER_SEC: u32 = 2;
441pub const DEFAULT_RATE_LIMIT_UPLOAD_BURST: u32 = 15;
442/// 默认图片访问限流:10 req/s,突发 50。
443pub const DEFAULT_RATE_LIMIT_IMAGE_PER_SEC: u32 = 10;
444pub const DEFAULT_RATE_LIMIT_IMAGE_BURST: u32 = 50;
445/// 默认评论限流:1 req/s,突发 5。
446pub const DEFAULT_RATE_LIMIT_COMMENT_PER_SEC: u32 = 1;
447pub const DEFAULT_RATE_LIMIT_COMMENT_BURST: u32 = 5;
448/// 默认评论图片上传限流:1 req/s,突发 5。
449pub const DEFAULT_RATE_LIMIT_COMMENT_UPLOAD_PER_SEC: u32 = 1;
450pub const DEFAULT_RATE_LIMIT_COMMENT_UPLOAD_BURST: u32 = 5;
451/// 默认评论图片上传日限额:30 次/天。
452/// 评论图片允许匿名上传,磁盘成本需要硬性日上限防滥用(同 code_exec 双层语义)。
453pub const DEFAULT_RATE_LIMIT_COMMENT_UPLOAD_DAILY: u32 = 30;
454/// 默认代码执行限流:1 req/s,突发 3。
455pub const DEFAULT_RATE_LIMIT_CODE_EXEC_PER_SEC: u32 = 1;
456pub const DEFAULT_RATE_LIMIT_CODE_EXEC_BURST: u32 = 3;
457/// 默认代码执行日限额:50 次/天。
458pub const DEFAULT_RATE_LIMIT_CODE_EXEC_DAILY: u32 = 50;
459/// 默认 unknown 桶限流:30 req/s,突发 100。
460pub const DEFAULT_RATE_LIMIT_UNKNOWN_PER_SEC: u32 = 30;
461pub const DEFAULT_RATE_LIMIT_UNKNOWN_BURST: u32 = 100;
462/// 默认限流桶 GC 间隔:300 秒。
463pub const DEFAULT_RATE_LIMIT_GC_INTERVAL_SECS: u32 = 300;
464
465/// 所有 per_sec 字段下限:至少 1 req/s(NonZeroU32 不允许 0)。
466#[cfg(feature = "server")]
467pub const MIN_RATE_LIMIT_PER_SEC: u32 = 1;
468/// 所有 burst 字段下限:至少允许 1 次突发。
469#[cfg(feature = "server")]
470pub const MIN_RATE_LIMIT_BURST: u32 = 1;
471/// 日限额下限:至少允许 1 次/天。
472#[cfg(feature = "server")]
473pub const MIN_RATE_LIMIT_DAILY: u32 = 1;
474/// GC 间隔下限(秒):至少 1 秒。
475#[cfg(feature = "server")]
476pub const MIN_RATE_LIMIT_GC_INTERVAL_SECS: u32 = 1;
477
478/// 限流配置(多级 IP 键控限流器的速率/突发/日限额与 GC 间隔)。
479///
480/// **重启生效层**:限流器是 LazyLock 静态量,首次请求时构造即固化;
481/// 修改 DB 值需重启进程才能生效。
482#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
483pub struct RateLimitSettings {
484    /// 严格限流(注册/登录)每秒请求数。
485    pub strict_per_sec: u32,
486    /// 严格限流突发上限。
487    pub strict_burst: u32,
488    /// 上传限流每秒请求数。
489    pub upload_per_sec: u32,
490    /// 上传限流突发上限。
491    pub upload_burst: u32,
492    /// 图片访问限流每秒请求数。
493    pub image_per_sec: u32,
494    /// 图片访问限流突发上限。
495    pub image_burst: u32,
496    /// 评论限流每秒请求数。
497    pub comment_per_sec: u32,
498    /// 评论限流突发上限。
499    pub comment_burst: u32,
500    /// 评论图片上传限流每秒请求数。
501    pub comment_upload_per_sec: u32,
502    /// 评论图片上传限流突发上限。
503    pub comment_upload_burst: u32,
504    /// 评论图片上传日限额(次/天)。
505    pub comment_upload_daily: u32,
506    /// 代码执行限流每秒请求数。
507    pub code_exec_per_sec: u32,
508    /// 代码执行限流突发上限。
509    pub code_exec_burst: u32,
510    /// 代码执行日限额(次/天)。
511    pub code_exec_daily: u32,
512    /// unknown 桶(无法识别 IP)每秒请求数。
513    pub unknown_per_sec: u32,
514    /// unknown 桶突发上限。
515    pub unknown_burst: u32,
516    /// 限流桶 GC 间隔(秒)。
517    pub gc_interval_secs: u32,
518}
519
520impl Default for RateLimitSettings {
521    fn default() -> Self {
522        Self {
523            strict_per_sec: DEFAULT_RATE_LIMIT_STRICT_PER_SEC,
524            strict_burst: DEFAULT_RATE_LIMIT_STRICT_BURST,
525            upload_per_sec: DEFAULT_RATE_LIMIT_UPLOAD_PER_SEC,
526            upload_burst: DEFAULT_RATE_LIMIT_UPLOAD_BURST,
527            image_per_sec: DEFAULT_RATE_LIMIT_IMAGE_PER_SEC,
528            image_burst: DEFAULT_RATE_LIMIT_IMAGE_BURST,
529            comment_per_sec: DEFAULT_RATE_LIMIT_COMMENT_PER_SEC,
530            comment_burst: DEFAULT_RATE_LIMIT_COMMENT_BURST,
531            comment_upload_per_sec: DEFAULT_RATE_LIMIT_COMMENT_UPLOAD_PER_SEC,
532            comment_upload_burst: DEFAULT_RATE_LIMIT_COMMENT_UPLOAD_BURST,
533            comment_upload_daily: DEFAULT_RATE_LIMIT_COMMENT_UPLOAD_DAILY,
534            code_exec_per_sec: DEFAULT_RATE_LIMIT_CODE_EXEC_PER_SEC,
535            code_exec_burst: DEFAULT_RATE_LIMIT_CODE_EXEC_BURST,
536            code_exec_daily: DEFAULT_RATE_LIMIT_CODE_EXEC_DAILY,
537            unknown_per_sec: DEFAULT_RATE_LIMIT_UNKNOWN_PER_SEC,
538            unknown_burst: DEFAULT_RATE_LIMIT_UNKNOWN_BURST,
539            gc_interval_secs: DEFAULT_RATE_LIMIT_GC_INTERVAL_SECS,
540        }
541    }
542}
543
544impl RateLimitSettings {
545    /// 将 per_sec 字段钳制到 [MIN_RATE_LIMIT_PER_SEC, ∞)。
546    #[cfg(feature = "server")]
547    pub fn clamp_per_sec(n: u32) -> u32 {
548        n.max(MIN_RATE_LIMIT_PER_SEC)
549    }
550
551    /// 将 burst 字段钳制到 [MIN_RATE_LIMIT_BURST, ∞)。
552    #[cfg(feature = "server")]
553    pub fn clamp_burst(n: u32) -> u32 {
554        n.max(MIN_RATE_LIMIT_BURST)
555    }
556
557    /// 将日限额字段钳制到 [MIN_RATE_LIMIT_DAILY, ∞)。
558    #[cfg(feature = "server")]
559    pub fn clamp_daily(n: u32) -> u32 {
560        n.max(MIN_RATE_LIMIT_DAILY)
561    }
562
563    /// 将 GC 间隔钳制到 [MIN_RATE_LIMIT_GC_INTERVAL_SECS, ∞)。
564    #[cfg(feature = "server")]
565    pub fn clamp_gc_interval(n: u32) -> u32 {
566        n.max(MIN_RATE_LIMIT_GC_INTERVAL_SECS)
567    }
568}
569
570// ============================================================================
571// WebP 编码配置(需重启生效)
572// ============================================================================
573//
574// 原本经环境变量在首启读取(src/infra/webp.rs 的 WEBP_QUALITY / WEBP_METHOD)。
575// 迁移到 settings 表后:env 首启播种 DB(ON CONFLICT DO NOTHING),之后面板值
576// 优先。这些值在进程启动时烘焙进 LazyLock 静态量,改 DB 值后需重启才生效。
577
578/// 默认 WebP 质量 85.0。
579pub const DEFAULT_WEBP_QUALITY: f32 = 85.0;
580/// 默认 WebP 编码方法 2。
581pub const DEFAULT_WEBP_METHOD: u32 = 2;
582
583/// `WEBP_QUALITY` 下限。
584#[cfg(feature = "server")]
585pub const MIN_WEBP_QUALITY: f32 = 0.0;
586/// `WEBP_QUALITY` 上限。
587#[cfg(feature = "server")]
588pub const MAX_WEBP_QUALITY: f32 = 100.0;
589/// `WEBP_METHOD` 上限。
590#[cfg(feature = "server")]
591pub const MAX_WEBP_METHOD: u32 = 6;
592
593/// WebP 有损编码配置(质量与方法)。
594///
595/// 需重启生效层:值在进程启动时烘焙进 LazyLock,改 DB 值后需重启才生效。
596#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
597pub struct WebpSettings {
598    /// 质量系数,范围 0.0–100.0。
599    pub quality: f32,
600    /// 编码方法,范围 0–6,数值越大压缩率越高但越慢。
601    pub method: u32,
602}
603
604impl Default for WebpSettings {
605    fn default() -> Self {
606        Self {
607            quality: DEFAULT_WEBP_QUALITY,
608            method: DEFAULT_WEBP_METHOD,
609        }
610    }
611}
612
613impl WebpSettings {
614    /// 将质量钳制到合法范围 [0.0, 100.0]。
615    #[cfg(feature = "server")]
616    pub fn clamp_quality(q: f32) -> f32 {
617        q.clamp(MIN_WEBP_QUALITY, MAX_WEBP_QUALITY)
618    }
619
620    /// 将编码方法钳制到合法范围 [0, 6]。
621    #[cfg(feature = "server")]
622    pub fn clamp_method(m: u32) -> u32 {
623        m.min(MAX_WEBP_METHOD)
624    }
625}
626
627// ============================================================================
628// 图片尺寸限制配置(需重启生效)
629// ============================================================================
630//
631// 原本经环境变量在首启读取(src/api/image.rs 的 MAX_IMAGE_DIMENSION /
632// MAX_IMAGE_PIXELS / IMAGE_DIMENSIONS_CACHE_TTL_SECS)。迁移到 settings 表后:
633// env 首启播种 DB,之后面板值优先。值烘焙进 LazyLock,需重启生效。
634
635/// 默认图片单边尺寸上限 8192 像素。
636pub const DEFAULT_IMAGE_MAX_DIMENSION: u32 = 8192;
637/// 默认图片像素总数上限 50_000_000(约 7k×7k)。
638pub const DEFAULT_IMAGE_MAX_PIXELS: u64 = 50_000_000;
639/// 默认图片尺寸缓存 TTL 86400 秒(24 小时)。
640pub const DEFAULT_IMAGE_DIMENSIONS_CACHE_TTL_SECS: u64 = 86400;
641
642/// 图片单边尺寸下限(防误调到危险小值导致正常图都传不上)。
643#[cfg(feature = "server")]
644pub const MIN_IMAGE_MAX_DIMENSION: u32 = 512;
645/// 图片像素总数下限(防误调)。
646#[cfg(feature = "server")]
647pub const MIN_IMAGE_MAX_PIXELS: u64 = 1_000_000;
648/// 尺寸缓存 TTL 下限(秒)。
649#[cfg(feature = "server")]
650pub const MIN_IMAGE_DIMENSIONS_CACHE_TTL_SECS: u64 = 1;
651
652/// 图片处理尺寸限制与尺寸缓存配置。
653///
654/// 需重启生效层:值在进程启动时烘焙进 LazyLock,改 DB 值后需重启才生效。
655#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
656pub struct ImageLimitSettings {
657    /// 图片单边(宽或高)尺寸上限(像素)。
658    pub max_dimension: u32,
659    /// 允许处理的最大图片像素数。⚠️ 此值同时决定单图解码内存缓冲
660    /// (max_alloc = pixels × 4 + 1MB),默认 50M 像素对应约 200MB/图。
661    pub max_pixels: u64,
662    /// 图片尺寸缓存 TTL(秒)。
663    pub dimensions_cache_ttl_secs: u64,
664}
665
666impl Default for ImageLimitSettings {
667    fn default() -> Self {
668        Self {
669            max_dimension: DEFAULT_IMAGE_MAX_DIMENSION,
670            max_pixels: DEFAULT_IMAGE_MAX_PIXELS,
671            dimensions_cache_ttl_secs: DEFAULT_IMAGE_DIMENSIONS_CACHE_TTL_SECS,
672        }
673    }
674}
675
676impl ImageLimitSettings {
677    /// 将单边尺寸钳制到合法范围 [MIN, ∞)。
678    #[cfg(feature = "server")]
679    pub fn clamp_max_dimension(n: u32) -> u32 {
680        n.max(MIN_IMAGE_MAX_DIMENSION)
681    }
682
683    /// 将像素总数钳制到合法范围 [MIN, ∞)。
684    #[cfg(feature = "server")]
685    pub fn clamp_max_pixels(n: u64) -> u64 {
686        n.max(MIN_IMAGE_MAX_PIXELS)
687    }
688
689    /// 将尺寸缓存 TTL 钳制到合法范围 [MIN, ∞)。
690    #[cfg(feature = "server")]
691    pub fn clamp_dimensions_cache_ttl_secs(n: u64) -> u64 {
692        n.max(MIN_IMAGE_DIMENSIONS_CACHE_TTL_SECS)
693    }
694}
695
696// ============================================================================
697// 代码运行器配置(需重启生效)
698// ============================================================================
699//
700// 原本经环境变量在首启读取(src/infra/runner_config.rs 的 CODE_RUNNER_*)。
701// 迁移到 settings 表后:env 首启播种 DB,之后面板值优先。值烘焙进 LazyLock,
702// 需重启生效。注意 `docker_socket_path` 仍是 env-only(DOCKER_SOCKET_PATH),
703// 不在此结构。
704
705/// 默认不允许容器联网。
706pub const DEFAULT_RUNNER_ALLOW_NETWORK: bool = false;
707/// 默认最大并发 4。
708pub const DEFAULT_RUNNER_MAX_CONCURRENT: u32 = 4;
709/// 默认每任务最大 CPU 核数 2.0。
710pub const DEFAULT_RUNNER_MAX_CPU_CORES: f64 = 2.0;
711/// 默认每任务最大内存 1024 MB。
712pub const DEFAULT_RUNNER_MAX_MEMORY_MB: u32 = 1024;
713/// 默认每任务最大执行超时 30 秒。
714pub const DEFAULT_RUNNER_MAX_TIMEOUT_SECS: u32 = 30;
715/// 默认每任务最大输出 1048576 字节(1 MB)。
716pub const DEFAULT_RUNNER_MAX_OUTPUT_BYTES: u64 = 1_048_576;
717/// 默认每任务最大源码 65536 字节(64 KB)。
718pub const DEFAULT_RUNNER_MAX_SOURCE_BYTES: u64 = 65_536;
719/// 默认排队等待超时 30 秒。
720pub const DEFAULT_RUNNER_QUEUE_TIMEOUT_SECS: u32 = 30;
721/// 默认历史 task 保留 300 秒。
722pub const DEFAULT_RUNNER_TASK_TTL_SECS: u32 = 300;
723
724/// 最大并发下限(至少允许 1 个)。
725#[cfg(feature = "server")]
726pub const MIN_RUNNER_MAX_CONCURRENT: u32 = 1;
727/// CPU 核数下限。
728#[cfg(feature = "server")]
729pub const MIN_RUNNER_MAX_CPU_CORES: f64 = 0.1;
730/// 内存下限(MB)。
731#[cfg(feature = "server")]
732pub const MIN_RUNNER_MAX_MEMORY_MB: u32 = 16;
733/// 超时下限(秒)。
734#[cfg(feature = "server")]
735pub const MIN_RUNNER_MAX_TIMEOUT_SECS: u32 = 1;
736/// 最大并发上限,防误填危险大值。
737#[cfg(feature = "server")]
738pub const MAX_RUNNER_MAX_CONCURRENT: u32 = 64;
739/// CPU 核数上限。
740#[cfg(feature = "server")]
741pub const MAX_RUNNER_MAX_CPU_CORES: f64 = 64.0;
742/// 内存上限(MB)。
743#[cfg(feature = "server")]
744pub const MAX_RUNNER_MAX_MEMORY_MB: u32 = 65_536;
745
746/// 代码运行器配置(资源限制与并发)。
747///
748/// 需重启生效层:值在进程启动时烘焙进 LazyLock,改 DB 值后需重启才生效。
749#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
750pub struct RunnerSettings {
751    /// 是否允许容器联网。
752    pub allow_network: bool,
753    /// 最大并发任务数。
754    pub max_concurrent: u32,
755    /// 每任务最大 CPU 核数。
756    pub max_cpu_cores: f64,
757    /// 每任务最大内存(MB)。
758    pub max_memory_mb: u32,
759    /// 每任务最大执行超时(秒)。
760    pub max_timeout_secs: u32,
761    /// 每任务最大输出字节数。
762    pub max_output_bytes: u64,
763    /// 每任务最大源码字节数。
764    pub max_source_bytes: u64,
765    /// 排队等待超时(秒)。
766    pub queue_timeout_secs: u32,
767    /// 历史 task 保留时长(秒)。
768    pub task_ttl_secs: u32,
769    /// 语言白名单(逗号分隔,如 "python,node,rust");`None` 表示不限制(全开)。
770    pub languages: Option<String>,
771}
772
773impl Default for RunnerSettings {
774    fn default() -> Self {
775        Self {
776            allow_network: DEFAULT_RUNNER_ALLOW_NETWORK,
777            max_concurrent: DEFAULT_RUNNER_MAX_CONCURRENT,
778            max_cpu_cores: DEFAULT_RUNNER_MAX_CPU_CORES,
779            max_memory_mb: DEFAULT_RUNNER_MAX_MEMORY_MB,
780            max_timeout_secs: DEFAULT_RUNNER_MAX_TIMEOUT_SECS,
781            max_output_bytes: DEFAULT_RUNNER_MAX_OUTPUT_BYTES,
782            max_source_bytes: DEFAULT_RUNNER_MAX_SOURCE_BYTES,
783            queue_timeout_secs: DEFAULT_RUNNER_QUEUE_TIMEOUT_SECS,
784            task_ttl_secs: DEFAULT_RUNNER_TASK_TTL_SECS,
785            languages: None,
786        }
787    }
788}
789
790impl RunnerSettings {
791    /// 将最大并发钳制到合法范围 [1, 64]。
792    #[cfg(feature = "server")]
793    pub fn clamp_max_concurrent(n: u32) -> u32 {
794        n.clamp(MIN_RUNNER_MAX_CONCURRENT, MAX_RUNNER_MAX_CONCURRENT)
795    }
796
797    /// 将 CPU 核数钳制到合法范围 [0.1, 64.0];NaN 回退默认值。
798    #[cfg(feature = "server")]
799    pub fn clamp_max_cpu_cores(n: f64) -> f64 {
800        if n.is_nan() {
801            return DEFAULT_RUNNER_MAX_CPU_CORES;
802        }
803        n.clamp(MIN_RUNNER_MAX_CPU_CORES, MAX_RUNNER_MAX_CPU_CORES)
804    }
805
806    /// 将内存钳制到合法范围 [16, 65536]。
807    #[cfg(feature = "server")]
808    pub fn clamp_max_memory_mb(n: u32) -> u32 {
809        n.clamp(MIN_RUNNER_MAX_MEMORY_MB, MAX_RUNNER_MAX_MEMORY_MB)
810    }
811
812    /// 将超时钳制到合法范围 [1, ∞)。
813    #[cfg(feature = "server")]
814    pub fn clamp_max_timeout_secs(n: u32) -> u32 {
815        n.max(MIN_RUNNER_MAX_TIMEOUT_SECS)
816    }
817
818    /// 将输出上限钳制到合法范围 [1, ∞)。
819    #[cfg(feature = "server")]
820    pub fn clamp_max_output_bytes(n: u64) -> u64 {
821        n.max(1)
822    }
823
824    /// 将源码上限钳制到合法范围 [1, ∞)。
825    #[cfg(feature = "server")]
826    pub fn clamp_max_source_bytes(n: u64) -> u64 {
827        n.max(1)
828    }
829
830    /// 将排队超时钳制到合法范围 [1, ∞)。
831    #[cfg(feature = "server")]
832    pub fn clamp_queue_timeout_secs(n: u32) -> u32 {
833        n.max(MIN_RUNNER_MAX_TIMEOUT_SECS)
834    }
835
836    /// 将 task 保留时长钳制到合法范围 [1, ∞)。
837    #[cfg(feature = "server")]
838    pub fn clamp_task_ttl_secs(n: u32) -> u32 {
839        n.max(1)
840    }
841
842    /// 规范化语言白名单字符串:拆分、trim、转小写、去空;结果为空则返回 None。
843    #[cfg(feature = "server")]
844    pub fn normalize_languages(s: &str) -> Option<String> {
845        let parts: Vec<String> = s
846            .split(',')
847            .map(|t| t.trim().to_lowercase())
848            .filter(|t| !t.is_empty())
849            .collect();
850        if parts.is_empty() {
851            None
852        } else {
853            Some(parts.join(","))
854        }
855    }
856}
857
858/// 系统启动配置的只读快照(面板展示用)。
859///
860/// 这些值在进程启动时读取,无法运行时修改。密钥类仅展示是否已设置,
861/// 不暴露实际值。
862#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
863pub struct SystemInfo {
864    /// 数据库连接串(脱敏:仅显示 host/dbname,隐藏密码)。
865    pub database_url_masked: String,
866    /// tracing 日志过滤器。
867    pub rust_log: String,
868    /// 数据库连接池大小。
869    pub db_pool_size: u32,
870    /// 单条 SQL 查询超时秒数。
871    pub statement_timeout_secs: u64,
872    /// SSR 页面缓存时长(秒)。
873    pub ssr_cache_secs: u64,
874    /// 响应压缩算法。
875    pub compression_algorithms: String,
876    /// 是否附加版本响应头。
877    pub expose_version_headers: bool,
878    /// Docker socket 路径。
879    pub docker_socket_path: String,
880    /// MCP 令牌加密主密钥是否已设置(不暴露值)。
881    pub mcp_token_enc_key_set: bool,
882    /// 启动迁移重试窗口(秒)。
883    pub migrate_startup_timeout_secs: u64,
884    /// 系统信息采样间隔(秒)。
885    pub sysinfo_sample_secs: f64,
886}
887
888#[cfg(test)]
889mod tests {
890    use super::*;
891
892    #[test]
893    fn default_is_disabled_30_days() {
894        let s = TrashSettings::default();
895        assert!(!s.auto_purge_enabled);
896        assert_eq!(s.retention_days, 30);
897    }
898
899    #[test]
900    #[cfg(feature = "server")]
901    fn clamp_retention_keeps_valid() {
902        assert_eq!(TrashSettings::clamp_retention(7), 7);
903        assert_eq!(TrashSettings::clamp_retention(30), 30);
904    }
905
906    #[test]
907    #[cfg(feature = "server")]
908    fn clamp_retention_clamps_below_min() {
909        assert_eq!(TrashSettings::clamp_retention(0), MIN_RETENTION_DAYS);
910        assert_eq!(TrashSettings::clamp_retention(-5), MIN_RETENTION_DAYS);
911    }
912
913    #[test]
914    #[cfg(feature = "server")]
915    fn clamp_retention_clamps_above_max() {
916        assert_eq!(TrashSettings::clamp_retention(366), MAX_RETENTION_DAYS);
917        assert_eq!(TrashSettings::clamp_retention(i32::MAX), MAX_RETENTION_DAYS);
918    }
919
920    #[test]
921    #[cfg(feature = "server")]
922    fn clamp_retention_boundary() {
923        assert_eq!(
924            TrashSettings::clamp_retention(MIN_RETENTION_DAYS),
925            MIN_RETENTION_DAYS
926        );
927        assert_eq!(
928            TrashSettings::clamp_retention(MAX_RETENTION_DAYS),
929            MAX_RETENTION_DAYS
930        );
931    }
932
933    #[test]
934    fn asset_purge_default_is_enabled_7_days() {
935        let s = AssetPurgeSettings::default();
936        assert!(s.auto_purge_enabled);
937        assert_eq!(s.retention_days, 7);
938    }
939
940    #[test]
941    #[cfg(feature = "server")]
942    fn asset_purge_clamp_retention() {
943        assert_eq!(
944            AssetPurgeSettings::clamp_retention(0),
945            MIN_ASSET_ORPHAN_RETENTION_DAYS
946        );
947        assert_eq!(AssetPurgeSettings::clamp_retention(7), 7);
948        assert_eq!(
949            AssetPurgeSettings::clamp_retention(366),
950            MAX_ASSET_ORPHAN_RETENTION_DAYS
951        );
952    }
953
954    #[test]
955    fn site_settings_default_empty() {
956        let s = SiteSettings::default();
957        assert_eq!(s.github_url, "");
958    }
959
960    #[test]
961    fn normalize_github_url_empty() {
962        assert_eq!(SiteSettings::normalize_github_url(""), "");
963        assert_eq!(SiteSettings::normalize_github_url("   "), "");
964    }
965
966    #[test]
967    fn normalize_github_url_prepends_https() {
968        assert_eq!(
969            SiteSettings::normalize_github_url("github.com/DefectingCat/yggdrasil"),
970            "https://github.com/DefectingCat/yggdrasil"
971        );
972    }
973
974    #[test]
975    fn normalize_github_url_keeps_scheme() {
976        assert_eq!(
977            SiteSettings::normalize_github_url("https://github.com/foo"),
978            "https://github.com/foo"
979        );
980        assert_eq!(
981            SiteSettings::normalize_github_url("http://example.com"),
982            "http://example.com"
983        );
984    }
985
986    #[test]
987    fn normalize_github_url_neutralizes_dangerous_scheme() {
988        // javascript:/data: 不以 http(s) 开头 → 补 https:// 变成无效 URL,无法注入脚本。
989        assert_eq!(
990            SiteSettings::normalize_github_url("javascript:alert(1)"),
991            "https://javascript:alert(1)"
992        );
993    }
994
995    #[test]
996    fn normalize_github_url_trims_and_truncates() {
997        assert_eq!(
998            SiteSettings::normalize_github_url("  github.com/x  "),
999            "https://github.com/x"
1000        );
1001        let long = "a".repeat(MAX_SITE_GITHUB_URL_LEN + 50);
1002        let normalized = SiteSettings::normalize_github_url(&long);
1003        // 补的 https:// 前缀不计入截断后的主体长度。
1004        assert_eq!(normalized.len(), "https://".len() + MAX_SITE_GITHUB_URL_LEN);
1005    }
1006
1007    // ── BackupSettings ───────────────────────────────────────────
1008
1009    #[test]
1010    fn backup_settings_default() {
1011        let s = BackupSettings::default();
1012        assert!(!s.auto_enabled);
1013        assert_eq!(s.time_utc, "04:00");
1014        assert_eq!(s.retention_count, 30);
1015        assert!(s.include_uploads);
1016    }
1017
1018    #[test]
1019    fn normalize_time_pads_single_digits() {
1020        assert_eq!(BackupSettings::normalize_time_utc("4:5"), "04:05");
1021        assert_eq!(BackupSettings::normalize_time_utc(" 23:59 "), "23:59");
1022        assert_eq!(BackupSettings::normalize_time_utc("0:00"), "00:00");
1023    }
1024
1025    #[test]
1026    fn normalize_time_rejects_garbage() {
1027        for bad in ["", "abc", "24:00", "12:60", "1:2:3", "-1:30", "12:", ":30"] {
1028            assert_eq!(
1029                BackupSettings::normalize_time_utc(bad),
1030                DEFAULT_BACKUP_TIME_UTC,
1031                "非法时间应回退默认: {bad:?}"
1032            );
1033        }
1034    }
1035
1036    #[test]
1037    #[cfg(feature = "server")]
1038    fn backup_clamp_retention() {
1039        assert_eq!(
1040            BackupSettings::clamp_retention(0),
1041            MIN_BACKUP_RETENTION_COUNT
1042        );
1043        assert_eq!(
1044            BackupSettings::clamp_retention(366),
1045            MAX_BACKUP_RETENTION_COUNT
1046        );
1047        assert_eq!(BackupSettings::clamp_retention(30), 30);
1048    }
1049
1050    #[test]
1051    #[cfg(feature = "server")]
1052    fn next_run_same_day_when_future() {
1053        use chrono::{TimeZone, Utc};
1054        let s = BackupSettings {
1055            time_utc: "04:00".into(),
1056            ..Default::default()
1057        };
1058        let now = Utc.with_ymd_and_hms(2026, 8, 10, 1, 30, 0).unwrap();
1059        let next = s.next_run_after(now).expect("合法时间必有下次执行");
1060        assert_eq!(next, Utc.with_ymd_and_hms(2026, 8, 10, 4, 0, 0).unwrap());
1061    }
1062
1063    #[test]
1064    #[cfg(feature = "server")]
1065    fn next_run_rolls_to_tomorrow_when_past() {
1066        use chrono::{TimeZone, Utc};
1067        let s = BackupSettings {
1068            time_utc: "04:00".into(),
1069            ..Default::default()
1070        };
1071        // 恰等于触发时刻也算「已过」——避免刚错过即触发两次。
1072        for now in [
1073            Utc.with_ymd_and_hms(2026, 8, 10, 4, 0, 0).unwrap(),
1074            Utc.with_ymd_and_hms(2026, 8, 10, 23, 59, 59).unwrap(),
1075        ] {
1076            let next = s.next_run_after(now).expect("合法时间必有下次执行");
1077            assert_eq!(next, Utc.with_ymd_and_hms(2026, 8, 11, 4, 0, 0).unwrap());
1078        }
1079    }
1080
1081    #[test]
1082    #[cfg(feature = "server")]
1083    fn next_run_none_on_invalid_time() {
1084        use chrono::{TimeZone, Utc};
1085        let s = BackupSettings {
1086            time_utc: "garbage".into(),
1087            ..Default::default()
1088        };
1089        let now = Utc.with_ymd_and_hms(2026, 8, 10, 0, 0, 0).unwrap();
1090        assert!(s.next_run_after(now).is_none());
1091    }
1092
1093    // ── UploadSettings ───────────────────────────────────────────
1094
1095    #[test]
1096    fn upload_settings_default() {
1097        assert_eq!(UploadSettings::default().concurrency, 3);
1098    }
1099
1100    #[test]
1101    #[cfg(feature = "server")]
1102    fn upload_clamp_concurrency() {
1103        assert_eq!(UploadSettings::clamp_concurrency(0), MIN_UPLOAD_CONCURRENCY);
1104        assert_eq!(
1105            UploadSettings::clamp_concurrency(-1),
1106            MIN_UPLOAD_CONCURRENCY
1107        );
1108        assert_eq!(UploadSettings::clamp_concurrency(9), MAX_UPLOAD_CONCURRENCY);
1109        assert_eq!(
1110            UploadSettings::clamp_concurrency(i32::MAX),
1111            MAX_UPLOAD_CONCURRENCY
1112        );
1113        assert_eq!(UploadSettings::clamp_concurrency(5), 5);
1114    }
1115
1116    // ── SecuritySettings ─────────────────────────────────────────
1117
1118    #[test]
1119    fn security_settings_default() {
1120        let s = SecuritySettings::default();
1121        assert!(s.app_base_url.is_empty());
1122        assert!(!s.cookie_secure);
1123        assert_eq!(s.trusted_proxy_count, 0);
1124        assert_eq!(s.max_sessions_per_user, 5);
1125    }
1126
1127    #[test]
1128    #[cfg(feature = "server")]
1129    fn security_clamp_trusted_proxy_count() {
1130        assert_eq!(SecuritySettings::clamp_trusted_proxy_count(0), 0);
1131        assert_eq!(SecuritySettings::clamp_trusted_proxy_count(3), 3);
1132        assert_eq!(
1133            SecuritySettings::clamp_trusted_proxy_count(99),
1134            MAX_TRUSTED_PROXY_COUNT
1135        );
1136    }
1137
1138    #[test]
1139    #[cfg(feature = "server")]
1140    fn security_clamp_max_sessions() {
1141        assert_eq!(
1142            SecuritySettings::clamp_max_sessions(0),
1143            MIN_MAX_SESSIONS_PER_USER
1144        );
1145        assert_eq!(SecuritySettings::clamp_max_sessions(5), 5);
1146        assert_eq!(
1147            SecuritySettings::clamp_max_sessions(u32::MAX),
1148            MAX_MAX_SESSIONS_PER_USER
1149        );
1150    }
1151
1152    #[test]
1153    fn security_normalize_app_base_url_trims() {
1154        assert_eq!(
1155            SecuritySettings::normalize_app_base_url("  https://x.com "),
1156            "https://x.com"
1157        );
1158        assert_eq!(SecuritySettings::normalize_app_base_url(""), "");
1159        assert_eq!(SecuritySettings::normalize_app_base_url("   "), "");
1160    }
1161
1162    // ── ImageCacheSettings ───────────────────────────────────────
1163
1164    #[test]
1165    fn image_cache_settings_default() {
1166        let s = ImageCacheSettings::default();
1167        assert_eq!(s.disk_cache_max_mb, 1024);
1168        assert_eq!(s.disk_cache_max_age_hours, 168);
1169    }
1170
1171    #[test]
1172    #[cfg(feature = "server")]
1173    fn image_cache_clamp_max_mb() {
1174        assert_eq!(
1175            ImageCacheSettings::clamp_max_mb(0),
1176            MIN_IMAGE_DISK_CACHE_MAX_MB
1177        );
1178        assert_eq!(ImageCacheSettings::clamp_max_mb(2048), 2048);
1179    }
1180
1181    #[test]
1182    #[cfg(feature = "server")]
1183    fn image_cache_clamp_max_age_hours() {
1184        assert_eq!(
1185            ImageCacheSettings::clamp_max_age_hours(0),
1186            MIN_IMAGE_DISK_CACHE_MAX_AGE_HOURS
1187        );
1188        assert_eq!(ImageCacheSettings::clamp_max_age_hours(720), 720);
1189    }
1190
1191    #[test]
1192    fn rate_limit_default_values() {
1193        let s = RateLimitSettings::default();
1194        assert_eq!(s.strict_per_sec, 1);
1195        assert_eq!(s.strict_burst, 5);
1196        assert_eq!(s.upload_per_sec, 2);
1197        assert_eq!(s.upload_burst, 15);
1198        assert_eq!(s.image_per_sec, 10);
1199        assert_eq!(s.image_burst, 50);
1200        assert_eq!(s.comment_per_sec, 1);
1201        assert_eq!(s.comment_burst, 5);
1202        assert_eq!(s.comment_upload_per_sec, 1);
1203        assert_eq!(s.comment_upload_burst, 5);
1204        assert_eq!(s.comment_upload_daily, 30);
1205        assert_eq!(s.code_exec_per_sec, 1);
1206        assert_eq!(s.code_exec_burst, 3);
1207        assert_eq!(s.code_exec_daily, 50);
1208        assert_eq!(s.unknown_per_sec, 30);
1209        assert_eq!(s.unknown_burst, 100);
1210        assert_eq!(s.gc_interval_secs, 300);
1211    }
1212
1213    #[test]
1214    #[cfg(feature = "server")]
1215    fn rate_limit_clamp_per_sec() {
1216        assert_eq!(RateLimitSettings::clamp_per_sec(0), MIN_RATE_LIMIT_PER_SEC);
1217        assert_eq!(RateLimitSettings::clamp_per_sec(5), 5);
1218    }
1219
1220    #[test]
1221    #[cfg(feature = "server")]
1222    fn rate_limit_clamp_burst() {
1223        assert_eq!(RateLimitSettings::clamp_burst(0), MIN_RATE_LIMIT_BURST);
1224        assert_eq!(RateLimitSettings::clamp_burst(15), 15);
1225    }
1226
1227    #[test]
1228    #[cfg(feature = "server")]
1229    fn rate_limit_clamp_daily() {
1230        assert_eq!(RateLimitSettings::clamp_daily(0), MIN_RATE_LIMIT_DAILY);
1231        assert_eq!(RateLimitSettings::clamp_daily(50), 50);
1232    }
1233
1234    #[test]
1235    #[cfg(feature = "server")]
1236    fn rate_limit_clamp_gc_interval() {
1237        assert_eq!(
1238            RateLimitSettings::clamp_gc_interval(0),
1239            MIN_RATE_LIMIT_GC_INTERVAL_SECS
1240        );
1241        assert_eq!(RateLimitSettings::clamp_gc_interval(300), 300);
1242    }
1243    // ── WebpSettings ─────────────────────────────────────────────
1244
1245    #[test]
1246    fn webp_settings_default() {
1247        let s = WebpSettings::default();
1248        assert_eq!(s.quality, 85.0);
1249        assert_eq!(s.method, 2);
1250    }
1251
1252    #[test]
1253    #[cfg(feature = "server")]
1254    fn webp_clamp_quality() {
1255        assert_eq!(WebpSettings::clamp_quality(-1.0), 0.0);
1256        assert_eq!(WebpSettings::clamp_quality(50.0), 50.0);
1257        assert_eq!(WebpSettings::clamp_quality(150.0), 100.0);
1258    }
1259
1260    #[test]
1261    #[cfg(feature = "server")]
1262    fn webp_clamp_method() {
1263        assert_eq!(WebpSettings::clamp_method(0), 0);
1264        assert_eq!(WebpSettings::clamp_method(3), 3);
1265        assert_eq!(WebpSettings::clamp_method(9), MAX_WEBP_METHOD);
1266    }
1267
1268    // ── ImageLimitSettings ───────────────────────────────────────
1269
1270    #[test]
1271    fn image_limit_settings_default() {
1272        let s = ImageLimitSettings::default();
1273        assert_eq!(s.max_dimension, 8192);
1274        assert_eq!(s.max_pixels, 50_000_000);
1275        assert_eq!(s.dimensions_cache_ttl_secs, 86400);
1276    }
1277
1278    #[test]
1279    #[cfg(feature = "server")]
1280    fn image_limit_clamp_max_dimension() {
1281        assert_eq!(
1282            ImageLimitSettings::clamp_max_dimension(0),
1283            MIN_IMAGE_MAX_DIMENSION
1284        );
1285        assert_eq!(ImageLimitSettings::clamp_max_dimension(2048), 2048);
1286    }
1287
1288    #[test]
1289    #[cfg(feature = "server")]
1290    fn image_limit_clamp_max_pixels() {
1291        assert_eq!(
1292            ImageLimitSettings::clamp_max_pixels(0),
1293            MIN_IMAGE_MAX_PIXELS
1294        );
1295        assert_eq!(ImageLimitSettings::clamp_max_pixels(99_999_999), 99_999_999);
1296    }
1297
1298    #[test]
1299    #[cfg(feature = "server")]
1300    fn image_limit_clamp_ttl() {
1301        assert_eq!(
1302            ImageLimitSettings::clamp_dimensions_cache_ttl_secs(0),
1303            MIN_IMAGE_DIMENSIONS_CACHE_TTL_SECS
1304        );
1305        assert_eq!(
1306            ImageLimitSettings::clamp_dimensions_cache_ttl_secs(7200),
1307            7200
1308        );
1309    }
1310
1311    // ── RunnerSettings ───────────────────────────────────────────
1312
1313    #[test]
1314    fn runner_settings_default() {
1315        let s = RunnerSettings::default();
1316        assert!(!s.allow_network);
1317        assert_eq!(s.max_concurrent, 4);
1318        assert_eq!(s.max_cpu_cores, 2.0);
1319        assert_eq!(s.max_memory_mb, 1024);
1320        assert_eq!(s.max_timeout_secs, 30);
1321        assert_eq!(s.max_output_bytes, 1_048_576);
1322        assert_eq!(s.max_source_bytes, 65_536);
1323        assert_eq!(s.queue_timeout_secs, 30);
1324        assert_eq!(s.task_ttl_secs, 300);
1325        assert!(s.languages.is_none());
1326    }
1327
1328    #[test]
1329    #[cfg(feature = "server")]
1330    fn runner_clamp_max_concurrent() {
1331        assert_eq!(
1332            RunnerSettings::clamp_max_concurrent(0),
1333            MIN_RUNNER_MAX_CONCURRENT
1334        );
1335        assert_eq!(RunnerSettings::clamp_max_concurrent(8), 8);
1336        assert_eq!(
1337            RunnerSettings::clamp_max_concurrent(999),
1338            MAX_RUNNER_MAX_CONCURRENT
1339        );
1340    }
1341
1342    #[test]
1343    #[cfg(feature = "server")]
1344    fn runner_clamp_max_cpu_cores() {
1345        assert_eq!(
1346            RunnerSettings::clamp_max_cpu_cores(0.0),
1347            MIN_RUNNER_MAX_CPU_CORES
1348        );
1349        assert_eq!(RunnerSettings::clamp_max_cpu_cores(4.0), 4.0);
1350        assert_eq!(
1351            RunnerSettings::clamp_max_cpu_cores(999.0),
1352            MAX_RUNNER_MAX_CPU_CORES
1353        );
1354        // NaN 回退默认值 2.0
1355        assert_eq!(RunnerSettings::clamp_max_cpu_cores(f64::NAN), 2.0);
1356    }
1357
1358    #[test]
1359    #[cfg(feature = "server")]
1360    fn runner_clamp_max_memory_mb() {
1361        assert_eq!(
1362            RunnerSettings::clamp_max_memory_mb(0),
1363            MIN_RUNNER_MAX_MEMORY_MB
1364        );
1365        assert_eq!(RunnerSettings::clamp_max_memory_mb(2048), 2048);
1366        assert_eq!(
1367            RunnerSettings::clamp_max_memory_mb(u32::MAX),
1368            MAX_RUNNER_MAX_MEMORY_MB
1369        );
1370    }
1371
1372    #[test]
1373    #[cfg(feature = "server")]
1374    fn runner_clamp_max_timeout_secs() {
1375        assert_eq!(
1376            RunnerSettings::clamp_max_timeout_secs(0),
1377            MIN_RUNNER_MAX_TIMEOUT_SECS
1378        );
1379        assert_eq!(RunnerSettings::clamp_max_timeout_secs(120), 120);
1380    }
1381
1382    #[test]
1383    #[cfg(feature = "server")]
1384    fn runner_clamp_byte_limits() {
1385        assert_eq!(RunnerSettings::clamp_max_output_bytes(0), 1);
1386        assert_eq!(RunnerSettings::clamp_max_source_bytes(0), 1);
1387        assert_eq!(
1388            RunnerSettings::clamp_queue_timeout_secs(0),
1389            MIN_RUNNER_MAX_TIMEOUT_SECS
1390        );
1391        assert_eq!(RunnerSettings::clamp_task_ttl_secs(0), 1);
1392    }
1393
1394    #[test]
1395    #[cfg(feature = "server")]
1396    fn runner_normalize_languages() {
1397        assert!(RunnerSettings::normalize_languages("").is_none());
1398        assert!(RunnerSettings::normalize_languages("  ,,  ").is_none());
1399        assert_eq!(
1400            RunnerSettings::normalize_languages("Python, Node, RUST").as_deref(),
1401            Some("python,node,rust")
1402        );
1403        assert_eq!(
1404            RunnerSettings::normalize_languages("  go  ").as_deref(),
1405            Some("go")
1406        );
1407    }
1408}