CH-J ToolsSerververwaltung über SSH
Menü
Veröffentlichte Quellen

CH-J Tools

Durchsuchen Sie Verzeichnisse und Dateien einer bestimmten Anwendungsausgabe.

Quellen als ZIP herunterladen
CH-J / Server Manager 872 Dateien
core-20261030T220507Z-61e4558d889f Hinzugefügt: · Erstellt:
config.rs Hinzugefügt: Datum nicht erfasst 14,2 KB · 329 Zeilen
Code Datei herunterladen
1//! Configuration store (port of `config/configStore.js`, schema 1, same file
2//! `config/config.json`). Supported UI languages are no longer hard-coded: they come
3//! from the language manifest `i18n/languages.json` shipped with the UI.
5use crate::core::availability::{DEFAULT_INTERVAL_SECS, INTERVALS_SECS};
6use crate::core::error::{CoreError, CoreResult};
7use crate::core::storage::{atomic_write_json, read_json};
8use once_cell::sync::Lazy;
9use parking_lot::RwLock;
10use regex::Regex;
11use serde_json::{json, Map, Value};
12use std::path::{Path, PathBuf};
14pub const CATALOG_CHANNELS: [&str; 4] = ["alpha", "beta", "stable", "all"];
15pub const SIDEBAR_MODES: [&str; 3] = ["full", "compact", "split"];
16pub const SIDEBAR_MIN: i64 = 220;
17pub const SIDEBAR_MAX: i64 = 420;
18pub const SIDEBAR_DEFAULT: i64 = 248;
19pub const DEFAULT_LANGUAGE: &str = "cs";
20pub const UPDATE_BASE_URLS: [&str; 1] = ["https://sm.ch-j.de/"];
22static NAV_ID: Lazy<Regex> = Lazy::new(|| {
23 Regex::new(r"^(overview|terminal|profiles|updates|plugins|diagnostics|settings|plugin:[a-z0-9][a-z0-9.-]{2,63})$").unwrap()
24});
25static MODULE_ID: Lazy<Regex> = Lazy::new(|| Regex::new(r"^[a-z0-9][a-z0-9.-]{2,63}$").unwrap());
26static SHA512: Lazy<Regex> = Lazy::new(|| Regex::new(r"^[a-f0-9]{128}$").unwrap());
28pub fn default_config() -> Value {
29 json!({
30 "schemaVersion": 1,
31 "ui": {
32 "language": DEFAULT_LANGUAGE,
33 "theme": "dark",
34 "terminalLayout": "single",
35 "terminalSplit": 0.5,
36 "terminalSwapped": false,
37 "sidebar": { "mode": "full", "hidden": false, "width": SIDEBAR_DEFAULT },
38 "navigationOrder": []
39 },
40 "updates": { "channel": "beta", "autoCheck": true, "lastLaunchedRelease": null, "baseUrls": UPDATE_BASE_URLS },
41 "plugins": { "channel": "alpha", "display": {} },
42 "monitoring": { "enabled": true, "intervalSeconds": DEFAULT_INTERVAL_SECS },
43 "security": { "autoLockMinutes": 0, "lockOnBlur": false, "requireSystemAuthentication": false }
44 })
47fn get<'a>(value: &'a Value, path: &[&str]) -> Option<&'a Value> {
48 let mut current = value;
49 for key in path {
50 current = current.as_object()?.get(*key)?;
51 }
52 Some(current)
55fn str_at<'a>(value: &'a Value, path: &[&str]) -> Option<&'a str> {
56 get(value, path).and_then(Value::as_str)
59pub fn normalize_release_marker(value: Option<&Value>) -> Value {
60 let Some(Value::Object(map)) = value else { return Value::Null };
61 let text = |key: &str| -> String {
62 match map.get(key) {
63 Some(Value::String(s)) => s.trim().to_string(),
64 Some(Value::Number(n)) => n.to_string(),
65 _ => String::new(),
66 }
67 };
68 let (id, version, published_at) = (text("id"), text("version"), text("publishedAt"));
69 let sha512 = text("sha512").to_lowercase();
70 let date_ok = chrono::DateTime::parse_from_rfc3339(&published_at).is_ok()
71 || chrono::NaiveDate::parse_from_str(&published_at, "%Y-%m-%d").is_ok();
72 if id.is_empty() || version.is_empty() || !date_ok || !SHA512.is_match(&sha512) {
73 return Value::Null;
74 }
75 json!({ "id": id, "version": version, "publishedAt": published_at, "sha512": sha512 })
78fn normalize_sidebar(value: Option<&Value>) -> Value {
79 let source = value.and_then(Value::as_object);
80 let mode = source
81 .and_then(|s| s.get("mode"))
82 .and_then(Value::as_str)
83 .filter(|m| SIDEBAR_MODES.contains(m))
84 .unwrap_or("full");
85 let hidden = source.and_then(|s| s.get("hidden")).and_then(Value::as_bool) == Some(true);
86 let width = source
87 .and_then(|s| s.get("width"))
88 .and_then(Value::as_f64)
89 .filter(|w| w.is_finite())
90 .map(|w| (w.max(SIDEBAR_MIN as f64).min(SIDEBAR_MAX as f64)).round() as i64)
91 .unwrap_or(SIDEBAR_DEFAULT);
92 json!({ "mode": mode, "hidden": hidden, "width": width })
95pub fn normalize_config(input: &Value, languages: &[String]) -> Value {
96 let source = if input.is_object() { input.clone() } else { json!({}) };
97 let channel_of = |path: &[&str], fallback: &str| -> String {
98 str_at(&source, path).filter(|c| CATALOG_CHANNELS.contains(c)).unwrap_or(fallback).to_string()
99 };
100 let theme = str_at(&source, &["ui", "theme"]).filter(|t| ["system", "dark", "light"].contains(t)).unwrap_or("dark");
101 let language = str_at(&source, &["ui", "language"])
102 .filter(|l| languages.iter().any(|known| known == l))
103 .unwrap_or(DEFAULT_LANGUAGE);
104 let mut navigation = Vec::<Value>::new();
105 if let Some(Value::Array(items)) = get(&source, &["ui", "navigationOrder"]) {
106 for item in items {
107 if let Some(id) = item.as_str() {
108 if NAV_ID.is_match(id) && !navigation.iter().any(|v| v.as_str() == Some(id)) {
109 navigation.push(Value::String(id.to_string()));
110 }
111 }
112 if navigation.len() >= 200 {
113 break;
114 }
115 }
116 }
117 let mut display = Map::new();
118 if let Some(Value::Object(entries)) = get(&source, &["plugins", "display"]) {
119 for (id, mode) in entries {
120 if let Some(mode) = mode.as_str() {
121 if MODULE_ID.is_match(id) && ["hidden", "visible", "navigation"].contains(&mode) {
122 display.insert(id.clone(), Value::String(mode.to_string()));
123 }
124 }
125 }
126 }
127 let auto_lock = get(&source, &["security", "autoLockMinutes"])
128 .and_then(Value::as_f64)
129 .filter(|m| [0.0, 1.0, 5.0, 10.0, 15.0, 30.0, 60.0].contains(m))
130 .map(|m| m as i64)
131 .unwrap_or(0);
132 json!({
133 "schemaVersion": 1,
134 "ui": {
135 "language": language,
136 "theme": theme,
137 "terminalLayout": str_at(&source, &["ui", "terminalLayout"]).filter(|l| ["split", "stacked"].contains(l)).unwrap_or("single"),
138 "terminalSplit": get(&source, &["ui", "terminalSplit"]).and_then(Value::as_f64).filter(|r| r.is_finite()).map(|r| r.clamp(0.2, 0.8)).unwrap_or(0.5),
139 "terminalSwapped": get(&source, &["ui", "terminalSwapped"]).and_then(Value::as_bool) == Some(true),
140 "sidebar": normalize_sidebar(get(&source, &["ui", "sidebar"])),
141 "navigationOrder": navigation
142 },
143 "updates": {
144 "channel": channel_of(&["updates", "channel"], "beta"),
145 "autoCheck": get(&source, &["updates", "autoCheck"]).and_then(Value::as_bool) != Some(false),
146 "lastLaunchedRelease": normalize_release_marker(get(&source, &["updates", "lastLaunchedRelease"])),
147 "baseUrls": UPDATE_BASE_URLS
148 },
149 "plugins": { "channel": channel_of(&["plugins", "channel"], "alpha"), "display": display },
150 "monitoring": {
151 "enabled": get(&source, &["monitoring", "enabled"]).and_then(Value::as_bool) != Some(false),
152 "intervalSeconds": get(&source, &["monitoring", "intervalSeconds"]).and_then(Value::as_u64).filter(|s| INTERVALS_SECS.contains(s)).unwrap_or(DEFAULT_INTERVAL_SECS)
153 },
154 "security": {
155 "autoLockMinutes": auto_lock,
156 "lockOnBlur": get(&source, &["security", "lockOnBlur"]).and_then(Value::as_bool) == Some(true),
157 "requireSystemAuthentication": get(&source, &["security", "requireSystemAuthentication"]).and_then(Value::as_bool) == Some(true)
158 }
159 })
162fn merge_object(base: &Value, patch: Option<&Value>) -> Value {
163 let mut out = base.as_object().cloned().unwrap_or_default();
164 if let Some(Value::Object(p)) = patch {
165 for (k, v) in p {
166 out.insert(k.clone(), v.clone());
167 }
168 }
169 Value::Object(out)
172pub struct ConfigStore {
173 path: PathBuf,
174 languages: Vec<String>,
175 value: RwLock<Value>,
178impl ConfigStore {
179 pub fn new(root: &Path, languages: Vec<String>) -> Self {
180 Self { path: root.join("config").join("config.json"), languages, value: RwLock::new(default_config()) }
181 }
183 pub fn languages(&self) -> &[String] {
184 &self.languages
185 }
187 pub fn load(&self) -> Value {
188 match read_json(&self.path) {
189 Ok(raw) => *self.value.write() = normalize_config(&raw, &self.languages),
190 Err(error) => {
191 if error.code != "ENOENT" {
192 let backup = self.path.with_file_name(format!("config.json.invalid-{}", chrono::Utc::now().timestamp_millis()));
193 let _ = std::fs::rename(&self.path, backup);
194 }
195 *self.value.write() = default_config();
196 let _ = self.save();
197 }
198 }
199 self.get()
200 }
202 pub fn get(&self) -> Value {
203 self.value.read().clone()
204 }
206 pub fn language(&self) -> String {
207 str_at(&self.value.read(), &["ui", "language"]).unwrap_or(DEFAULT_LANGUAGE).to_string()
208 }
210 pub fn update(&self, patch: &Value) -> CoreResult<Value> {
211 let current = self.get();
212 let ui_patch = get(patch, &["ui"]);
213 let mut ui = merge_object(&current["ui"], ui_patch);
214 ui["sidebar"] = merge_object(&current["ui"]["sidebar"], ui_patch.and_then(|u| u.get("sidebar")));
215 let mut updates = current["updates"].clone();
216 if let Some(Value::Object(p)) = get(patch, &["updates"]) {
217 if let Some(v) = p.get("channel").filter(|v| !v.is_null()) {
218 updates["channel"] = v.clone();
219 }
220 if let Some(v) = p.get("autoCheck").filter(|v| !v.is_null()) {
221 updates["autoCheck"] = v.clone();
222 }
223 }
224 let next = json!({
225 "schemaVersion": 1,
226 "ui": ui,
227 "updates": updates,
228 "plugins": merge_object(&current["plugins"], get(patch, &["plugins"])),
229 "monitoring": merge_object(&current["monitoring"], get(patch, &["monitoring"])),
230 "security": merge_object(&current["security"], get(patch, &["security"]))
231 });
232 *self.value.write() = normalize_config(&next, &self.languages);
233 self.save()?;
234 Ok(self.get())
235 }
237 pub fn record_launched_release(&self, release: &Value) -> CoreResult<Value> {
238 let marker = normalize_release_marker(Some(release));
239 if marker.is_null() {
240 return Err(CoreError::plain("Invalid launched update release metadata."));
241 }
242 let mut next = self.get();
243 next["updates"]["lastLaunchedRelease"] = marker.clone();
244 *self.value.write() = normalize_config(&next, &self.languages);
245 self.save()?;
246 Ok(marker)
247 }
249 pub fn save(&self) -> CoreResult<()> {
250 let value = self.get();
251 atomic_write_json(&self.path, &value)
252 }
255#[cfg(test)]
256mod tests {
257 use super::*;
259 fn langs() -> Vec<String> {
260 vec!["cs".into(), "de".into(), "en".into()]
261 }
263 #[test]
264 fn monitoring_defaults_on_and_only_offered_intervals_stay() {
265 let config = normalize_config(&json!({}), &langs());
266 assert_eq!(config["monitoring"], json!({ "enabled": true, "intervalSeconds": 12 }));
267 let config = normalize_config(&json!({"monitoring": {"enabled": false, "intervalSeconds": 30}}), &langs());
268 assert_eq!(config["monitoring"], json!({ "enabled": false, "intervalSeconds": 30 }));
269 let config = normalize_config(&json!({"monitoring": {"enabled": "no", "intervalSeconds": 1}}), &langs());
270 assert_eq!(config["monitoring"], json!({ "enabled": true, "intervalSeconds": 12 }));
271 }
273 #[test]
274 fn terminal_workspace_values_are_limited() {
275 let config = normalize_config(&json!({"ui": {"terminalLayout": "stacked", "terminalSplit": 5.0, "terminalSwapped": true}}), &langs());
276 assert_eq!(config["ui"]["terminalLayout"], "stacked");
277 assert_eq!(config["ui"]["terminalSplit"], 0.8);
278 assert_eq!(config["ui"]["terminalSwapped"], true);
279 let config = normalize_config(&json!({"ui": {"terminalLayout": "grid", "terminalSplit": "x", "terminalSwapped": 1}}), &langs());
280 assert_eq!(config["ui"]["terminalLayout"], "single");
281 assert_eq!(config["ui"]["terminalSplit"], 0.5);
282 assert_eq!(config["ui"]["terminalSwapped"], false);
283 }
285 #[test]
286 fn defaults_and_invalid_values() {
287 let config = normalize_config(&json!({"ui": {"language": "xx", "theme": "neon", "sidebar": {"width": 9999, "mode": "split"}}, "updates": {"channel": "nightly"}}), &langs());
288 assert_eq!(config["ui"]["language"], "cs");
289 assert_eq!(config["ui"]["theme"], "dark");
290 assert_eq!(config["ui"]["sidebar"]["width"], 420);
291 assert_eq!(config["ui"]["sidebar"]["mode"], "split");
292 assert_eq!(config["updates"]["channel"], "beta");
293 assert_eq!(config["plugins"]["channel"], "alpha");
294 assert_eq!(config["updates"]["baseUrls"][0], "https://sm.ch-j.de/");
295 }
297 #[test]
298 fn dynamic_language_list() {
299 let mut languages = langs();
300 languages.push("sk".into());
301 let config = normalize_config(&json!({"ui": {"language": "sk"}}), &languages);
302 assert_eq!(config["ui"]["language"], "sk");
303 }
305 #[test]
306 fn update_merges_sidebar_and_ignores_base_urls() {
307 let dir = tempfile::tempdir().unwrap();
308 let store = ConfigStore::new(dir.path(), langs());
309 store.load();
310 store.update(&json!({"ui": {"sidebar": {"hidden": true}}, "updates": {"baseUrls": ["https://evil/"]}})).unwrap();
311 let config = store.update(&json!({"ui": {"sidebar": {"mode": "compact"}}})).unwrap();
312 assert_eq!(config["ui"]["sidebar"]["hidden"], true);
313 assert_eq!(config["ui"]["sidebar"]["mode"], "compact");
314 assert_eq!(config["updates"]["baseUrls"][0], "https://sm.ch-j.de/");
315 let reloaded = ConfigStore::new(dir.path(), langs());
316 assert_eq!(reloaded.load()["ui"]["sidebar"]["mode"], "compact");
317 }
319 #[test]
320 fn invalid_file_is_backed_up() {
321 let dir = tempfile::tempdir().unwrap();
322 std::fs::create_dir_all(dir.path().join("config")).unwrap();
323 std::fs::write(dir.path().join("config/config.json"), "{broken").unwrap();
324 let store = ConfigStore::new(dir.path(), langs());
325 assert_eq!(store.load()["schemaVersion"], 1);
326 let names: Vec<String> = std::fs::read_dir(dir.path().join("config")).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().to_string()).collect();
327 assert!(names.iter().any(|n| n.starts_with("config.json.invalid-")));
328 }
SHA-2569cf2f568b774016cff13b72a471f3bf0ab92450a0b2e116522409c7da55f4101

CH-J Proprietary Software License 1.14 · Die Quellen werden unter der CH-J Proprietary Software License 1.14 bereitgestellt. Ihre Verfügbarkeit ändert die Lizenzbedingungen nicht und gewährt keine zusätzlichen Rechte.

SHA-256 des Archivs77380d4805485ffa4027fbe7858b5836f197a4aa9d52358a43e2d12d018744df
Dateien