Skip to main content

yggdrasil/api/settings/
mod.rs

1//! 回收站、自动备份、素材上传与站点配置接口。
2//!
3//! 按子域拆分为目录模块:`trash`(回收站)、`backup`(自动备份)、`security`
4//! (安全)、`image_cache`(图片磁盘缓存)、`asset_purge`(孤儿素材清理)、
5//! `rate_limit`(限流)、`upload`(素材上传)、`site`(站点公开配置)、
6//! `system`(系统启动信息)、`webp`(WebP 编码)、`image_limit`(图片尺寸限制)、
7//! `runner`(代码运行器)、`ssr_cache`(SSR 页面缓存)。全部公开项经本模块 `pub use` 聚合,
8//! `crate::api::settings::xxx` 路径保持不变,外部调用零改动。
9//!
10//! - 回收站配置:读取与更新自动清理设置,需要 admin 权限。
11//! - 自动备份配置:读取与更新定时备份设置(含上次结果/下次执行时间),需要 admin。
12//!   `load_backup_settings` / `save_last_backup_run` 同时供备份核心与调度任务复用。
13//!   同名 `BACKUP_*` 环境变量仅在对应 settings 键缺失时播种初始值(首次部署),
14//!   之后以面板写入的 DB 值为准。
15//! - 素材上传配置:读取与更新上传弹窗并发数,需要 admin。`UPLOAD_CONCURRENCY`
16//!   环境变量播种语义与 `BACKUP_*` 一致(仅键缺失时生效)。
17//! - 站点公开配置:页脚 GitHub 链接等,`get_site_settings` 公开读取(前台页脚 SSR),
18//!   `update_site_settings` 仅 admin。配置持久化到 settings 键值表。
19//! Leptos server function,注册在 `/api` 路径下。
20
21mod asset_purge;
22mod backup;
23mod image_cache;
24mod image_limit;
25mod rate_limit;
26mod runner;
27mod security;
28mod site;
29mod ssr_cache;
30mod system;
31mod trash;
32mod upload;
33mod webp;
34
35#[cfg(feature = "server")]
36use crate::api::error::AppError;
37#[cfg(feature = "server")]
38use std::collections::HashMap;
39
40/// Batch-insert first-boot environment seeds.
41///
42/// Each settings group validates its own environment variables, then delegates
43/// the write here so one group costs one round trip instead of one per key.
44#[cfg(feature = "server")]
45pub(crate) async fn insert_env_seeds(
46    client: &tokio_postgres::Client,
47    seeds: Vec<(&'static str, String)>,
48) -> Result<(), AppError> {
49    if seeds.is_empty() {
50        return Ok(());
51    }
52
53    let keys: Vec<&str> = seeds.iter().map(|(key, _)| *key).collect();
54    let values: Vec<&str> = seeds.iter().map(|(_, value)| value.as_str()).collect();
55    client
56        .execute(
57            "INSERT INTO settings (key, value)
58             SELECT * FROM UNNEST($1::text[], $2::text[])
59             ON CONFLICT (key) DO NOTHING",
60            &[&keys, &values],
61        )
62        .await
63        .map_err(AppError::query)?;
64
65    for (key, value) in seeds {
66        tracing::info!("配置已从环境变量播种: {key}={value}(仅键缺失时生效)");
67    }
68    Ok(())
69}
70
71/// 写入(或覆盖)单个 settings 键,同时刷新 `updated_at`。
72///
73/// 面板保存、调度结果落库等处共用这一条 upsert。接受 `tokio_postgres` 的
74/// `GenericClient`,连接与事务都能调用:deadpool 连接传 `&**client`,
75/// deadpool 事务传 `&*tx`。多键写入由调用方逐键调用,事务边界由调用方决定。
76#[cfg(feature = "server")]
77pub(crate) async fn upsert_setting(
78    client: &(impl tokio_postgres::GenericClient + Sync),
79    key: &str,
80    value: &str,
81) -> Result<(), AppError> {
82    client
83        .execute(
84            "INSERT INTO settings (key, value, updated_at) VALUES ($1, $2, NOW())
85             ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()",
86            &[&key, &value],
87        )
88        .await
89        .map_err(AppError::query)?;
90    Ok(())
91}
92
93/// 一条语句写入(或覆盖)多个 settings 键,同时刷新 `updated_at`。
94///
95/// 单条 SQL 在 PostgreSQL 中天然原子:要么全部生效、要么全部不生效,
96/// 避免逐键 upsert 中途失败导致「报错但部分配置已落库」,也不必开事务。
97/// 同一组配置的多个键必须走这里,而不是循环调用 [`upsert_setting`]。
98#[cfg(feature = "server")]
99pub(crate) async fn upsert_settings(
100    client: &(impl tokio_postgres::GenericClient + Sync),
101    pairs: &[(&str, String)],
102) -> Result<(), AppError> {
103    let keys: Vec<&str> = pairs.iter().map(|(key, _)| *key).collect();
104    let values: Vec<&str> = pairs.iter().map(|(_, value)| value.as_str()).collect();
105    client
106        .execute(
107            "INSERT INTO settings (key, value, updated_at)
108             SELECT k, v, NOW() FROM UNNEST($1::text[], $2::text[]) AS t(k, v)
109             ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()",
110            &[&keys, &values],
111        )
112        .await
113        .map_err(AppError::query)?;
114    Ok(())
115}
116
117/// Load a settings group with one indexed query.
118#[cfg(feature = "server")]
119pub(crate) async fn load_setting_values(
120    client: &tokio_postgres::Client,
121    keys: &[&str],
122) -> Result<HashMap<String, String>, AppError> {
123    if keys.is_empty() {
124        return Ok(HashMap::new());
125    }
126
127    let keys: Vec<&str> = keys.to_vec();
128    let rows = client
129        .query(
130            "SELECT key, value FROM settings WHERE key = ANY($1::text[])",
131            &[&keys],
132        )
133        .await
134        .map_err(AppError::query)?;
135
136    let mut values = HashMap::with_capacity(rows.len());
137    for row in rows {
138        values.insert(row.get::<_, String>("key"), row.get::<_, String>("value"));
139    }
140    Ok(values)
141}
142
143/// Seed all first-boot settings and bake Tier-B settings into process config.
144///
145/// Environment seeding is best-effort by design. Tier-B reads are startup
146/// critical: silently falling back would permanently replace administrator-
147/// configured limits until the next restart.
148#[cfg(feature = "server")]
149pub(crate) async fn bootstrap_startup_settings(
150    client: &tokio_postgres::Client,
151) -> Result<(), AppError> {
152    let (
153        backup,
154        upload,
155        security,
156        image_cache,
157        asset_purge,
158        rate_limit,
159        webp,
160        image_limit,
161        runner,
162        ssr_cache,
163    ) = tokio::join!(
164        seed_backup_settings_from_env(client),
165        seed_upload_settings_from_env(client),
166        seed_security_settings_from_env(client),
167        seed_image_cache_settings_from_env(client),
168        seed_asset_purge_settings_from_env(client),
169        seed_rate_limit_from_env(client),
170        seed_webp_settings_from_env(client),
171        seed_image_limit_settings_from_env(client),
172        seed_runner_settings_from_env(client),
173        seed_ssr_cache_settings_from_env(client),
174    );
175
176    for (name, result) in [
177        ("backup", backup),
178        ("upload", upload),
179        ("security", security),
180        ("image_cache", image_cache),
181        ("asset_purge", asset_purge),
182        ("rate_limit", rate_limit),
183        ("webp", webp),
184        ("image_limit", image_limit),
185        ("runner", runner),
186        ("ssr_cache", ssr_cache),
187    ] {
188        if let Err(error) = result {
189            tracing::warn!(setting_group = name, error = ?error, "启动设置环境变量播种失败");
190        }
191    }
192
193    let (rate_limit, webp, image_limit, runner, ssr_cache) = tokio::join!(
194        load_rate_limit_settings(client),
195        load_webp_settings(client),
196        load_image_limit_settings(client),
197        load_runner_settings(client),
198        load_ssr_cache_settings(client),
199    );
200
201    let rate_limit = rate_limit.map_err(|error| {
202        tracing::error!(error = ?error, "限流启动配置加载失败,拒绝使用默认值");
203        error
204    })?;
205    let webp = webp.map_err(|error| {
206        tracing::error!(error = ?error, "WebP 启动配置加载失败,拒绝使用默认值");
207        error
208    })?;
209    let image_limit = image_limit.map_err(|error| {
210        tracing::error!(error = ?error, "图片限制启动配置加载失败,拒绝使用默认值");
211        error
212    })?;
213    let runner = runner.map_err(|error| {
214        tracing::error!(error = ?error, "代码运行器启动配置加载失败,拒绝使用默认值");
215        error
216    })?;
217    let ssr_cache = ssr_cache.map_err(|error| {
218        tracing::error!(error = ?error, "SSR 缓存启动配置加载失败,拒绝使用默认值");
219        error
220    })?;
221
222    crate::config::set_rate_limit(rate_limit);
223    crate::config::set_webp(webp);
224    crate::config::set_image_limit(image_limit);
225    crate::config::set_runner(runner);
226    crate::config::set_ssr_cache(ssr_cache);
227    Ok(())
228}
229
230pub use asset_purge::*;
231pub use backup::*;
232pub use image_cache::*;
233pub use image_limit::*;
234pub use rate_limit::*;
235pub use runner::*;
236pub use security::*;
237pub use site::*;
238pub use ssr_cache::*;
239pub use system::*;
240pub use trash::*;
241pub use upload::*;
242pub use webp::*;