CH-J Toolsszerverkezelés SSH felett
Menü
Közzétett forrás

CH-J Tools

Könyvtárak és fájlok böngészése egy adott alkalmazás kiadásához.

Letöltés forrás ZIP
CH-J / Server Manager 1704 fájlok
0.1.2-beta Hozzáadva: · Beépített:
config.rs Hozzáadva: Nem rögzített dátum 20,7 KB · 404 sorok
Kód Fájl letöltése
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";
20// In order of preference. tools.ch-j.de is the address of the planned rename to CH-J TOOLS; the
21// update channels and version numbering continue there, so both stay valid for updates.
22pub const UPDATE_BASE_URLS: [&str; 2] = ["https://sm.ch-j.de/", "https://tools.ch-j.de/"];
24static NAV_ID: Lazy<Regex> = Lazy::new(|| {
25 Regex::new(r"^(overview|terminal|profiles|keychain|updates|plugins|diagnostics|settings|plugin:[a-z0-9][a-z0-9.-]{2,63})$").unwrap()
26});
27static MODULE_ID: Lazy<Regex> = Lazy::new(|| Regex::new(r"^[a-z0-9][a-z0-9.-]{2,63}$").unwrap());
28static SHA512: Lazy<Regex> = Lazy::new(|| Regex::new(r"^[a-f0-9]{128}$").unwrap());
30pub fn default_config() -> Value {
31 json!({
32 "schemaVersion": 1,
33 "ui": {
34 "language": DEFAULT_LANGUAGE,
35 "theme": "dark",
36 "terminalSuggest": true,
37 "terminalAppearance": normalize_terminal_appearance(None),
38 "terminalProfiles": [],
39 "terminalSuggestRepair": true,
40 "terminalLayout": "single",
41 "terminalSplit": 0.5,
42 "terminalSwapped": false,
43 "sidebar": { "mode": "full", "hidden": false, "width": SIDEBAR_DEFAULT },
44 "navigationOrder": []
45 },
46 "updates": { "channel": "beta", "autoCheck": true, "lastLaunchedRelease": null, "baseUrls": UPDATE_BASE_URLS },
47 "plugins": { "channel": "alpha", "display": {} },
48 "monitoring": { "enabled": true, "intervalSeconds": DEFAULT_INTERVAL_SECS },
49 "security": { "autoLockMinutes": 0, "lockOnBlur": false, "requireSystemAuthentication": false }
50 })
53fn get<'a>(value: &'a Value, path: &[&str]) -> Option<&'a Value> {
54 let mut current = value;
55 for key in path {
56 current = current.as_object()?.get(*key)?;
57 }
58 Some(current)
61fn str_at<'a>(value: &'a Value, path: &[&str]) -> Option<&'a str> {
62 get(value, path).and_then(Value::as_str)
65pub fn normalize_release_marker(value: Option<&Value>) -> Value {
66 let Some(Value::Object(map)) = value else { return Value::Null };
67 let text = |key: &str| -> String {
68 match map.get(key) {
69 Some(Value::String(s)) => s.trim().to_string(),
70 Some(Value::Number(n)) => n.to_string(),
71 _ => String::new(),
72 }
73 };
74 let (id, version, published_at) = (text("id"), text("version"), text("publishedAt"));
75 let sha512 = text("sha512").to_lowercase();
76 let date_ok = chrono::DateTime::parse_from_rfc3339(&published_at).is_ok()
77 || chrono::NaiveDate::parse_from_str(&published_at, "%Y-%m-%d").is_ok();
78 if id.is_empty() || version.is_empty() || !date_ok || !SHA512.is_match(&sha512) {
79 return Value::Null;
80 }
81 json!({ "id": id, "version": version, "publishedAt": published_at, "sha512": sha512 })
84const MAX_TERMINAL_PROFILES: usize = 50;
85const ANSI_COLORS: [&str; 16] = ["black", "red", "green", "yellow", "blue", "magenta", "cyan", "white", "brightBlack", "brightRed", "brightGreen", "brightYellow", "brightBlue", "brightMagenta", "brightCyan", "brightWhite"];
87fn is_hex_color(text: &str) -> bool {
88 text.len() == 7 && text.starts_with('#') && text[1..].chars().all(|c| c.is_ascii_hexdigit())
91/// Terminal look. Empty or invalid values mean the CH-J default, which itself lives in the UI and never changes.
92fn normalize_terminal_appearance(value: Option<&Value>) -> Value {
93 let empty = json!({});
94 let source = value.filter(|v| v.is_object()).unwrap_or(&empty);
95 let font = source["fontFamily"].as_str().unwrap_or("").trim();
96 let font = if font.chars().count() <= 80 && font.chars().all(|c| c.is_alphanumeric() || " -_.".contains(c)) { font } else { "" };
97 let number = |key: &str, min: f64, max: f64, default: f64| source[key].as_f64().filter(|n| n.is_finite()).map_or(default, |n| n.clamp(min, max));
98 let color = |key: &str| {
99 let text = source[key].as_str().unwrap_or("");
100 if is_hex_color(text) { text.to_ascii_lowercase() } else { String::new() }
101 };
102 let weight = if source["fontWeight"] == "bold" { "bold" } else { "normal" };
103 let cursor = source["cursorStyle"].as_str().filter(|style| ["underline", "bar"].contains(style)).unwrap_or("block");
104 let mut ansi = serde_json::Map::new();
105 for name in ANSI_COLORS {
106 let text = source["ansi"][name].as_str().unwrap_or("");
107 if is_hex_color(text) {
108 ansi.insert(name.to_string(), json!(text.to_ascii_lowercase()));
109 }
110 }
111 let profile = source["profile"].as_str().filter(|id| id.chars().count() <= 64 && id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')).unwrap_or("");
112 json!({ "profile": profile, "fontFamily": font, "fontSize": number("fontSize", 8.0, 32.0, 13.0), "fontWeight": weight, "lineHeight": number("lineHeight", 1.0, 2.0, 1.0), "letterSpacing": number("letterSpacing", -2.0, 5.0, 0.0), "cursorStyle": cursor, "foreground": color("foreground"), "background": color("background"), "cursor": color("cursor"), "selection": color("selection"), "ansi": ansi })
115/// Saved terminal looks; a broken or unnamed entry is dropped.
116fn normalize_terminal_profiles(value: Option<&Value>) -> Value {
117 let mut seen = std::collections::HashSet::new();
118 let items = value.and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default().iter().filter_map(|item| {
119 let id = item["id"].as_str().filter(|id| id.starts_with("user-") && id.len() <= 64 && id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-'))?;
120 let name = item["name"].as_str().map(str::trim).filter(|name| !name.is_empty() && name.chars().count() <= 40 && !name.chars().any(char::is_control))?;
121 seen.insert(id.to_string()).then(|| json!({ "id": id, "name": name, "appearance": normalize_terminal_appearance(item.get("appearance")) }))
122 }).take(MAX_TERMINAL_PROFILES).collect();
123 Value::Array(items)
126fn normalize_sidebar(value: Option<&Value>) -> Value {
127 let source = value.and_then(Value::as_object);
128 let mode = source
129 .and_then(|s| s.get("mode"))
130 .and_then(Value::as_str)
131 .filter(|m| SIDEBAR_MODES.contains(m))
132 .unwrap_or("full");
133 let hidden = source.and_then(|s| s.get("hidden")).and_then(Value::as_bool) == Some(true);
134 let width = source
135 .and_then(|s| s.get("width"))
136 .and_then(Value::as_f64)
137 .filter(|w| w.is_finite())
138 .map(|w| (w.max(SIDEBAR_MIN as f64).min(SIDEBAR_MAX as f64)).round() as i64)
139 .unwrap_or(SIDEBAR_DEFAULT);
140 json!({ "mode": mode, "hidden": hidden, "width": width })
143pub fn normalize_config(input: &Value, languages: &[String]) -> Value {
144 let source = if input.is_object() { input.clone() } else { json!({}) };
145 let channel_of = |path: &[&str], fallback: &str| -> String {
146 str_at(&source, path).filter(|c| CATALOG_CHANNELS.contains(c)).unwrap_or(fallback).to_string()
147 };
148 let theme = str_at(&source, &["ui", "theme"]).filter(|t| ["system", "dark", "light"].contains(t)).unwrap_or("dark");
149 let language = str_at(&source, &["ui", "language"])
150 .filter(|l| languages.iter().any(|known| known == l))
151 .unwrap_or(DEFAULT_LANGUAGE);
152 let mut navigation = Vec::<Value>::new();
153 if let Some(Value::Array(items)) = get(&source, &["ui", "navigationOrder"]) {
154 for item in items {
155 if let Some(id) = item.as_str() {
156 if NAV_ID.is_match(id) && !navigation.iter().any(|v| v.as_str() == Some(id)) {
157 navigation.push(Value::String(id.to_string()));
158 }
159 }
160 if navigation.len() >= 200 {
161 break;
162 }
163 }
164 }
165 let mut display = Map::new();
166 if let Some(Value::Object(entries)) = get(&source, &["plugins", "display"]) {
167 for (id, mode) in entries {
168 if let Some(mode) = mode.as_str() {
169 if MODULE_ID.is_match(id) && ["hidden", "visible", "navigation"].contains(&mode) {
170 display.insert(id.clone(), Value::String(mode.to_string()));
171 }
172 }
173 }
174 }
175 let auto_lock = get(&source, &["security", "autoLockMinutes"])
176 .and_then(Value::as_f64)
177 .filter(|m| [0.0, 1.0, 5.0, 10.0, 15.0, 30.0, 60.0].contains(m))
178 .map(|m| m as i64)
179 .unwrap_or(0);
180 json!({
181 "schemaVersion": 1,
182 "ui": {
183 "language": language,
184 "theme": theme,
185 "terminalAppearance": normalize_terminal_appearance(get(&source, &["ui", "terminalAppearance"])),
186 "terminalProfiles": normalize_terminal_profiles(get(&source, &["ui", "terminalProfiles"])),
187 "terminalSuggest": get(&source, &["ui", "terminalSuggest"]).and_then(Value::as_bool) != Some(false),
188 "terminalSuggestRepair": get(&source, &["ui", "terminalSuggestRepair"]).and_then(Value::as_bool) != Some(false),
189 "terminalLayout": str_at(&source, &["ui", "terminalLayout"]).filter(|l| ["split", "stacked"].contains(l)).unwrap_or("single"),
190 "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),
191 "terminalSwapped": get(&source, &["ui", "terminalSwapped"]).and_then(Value::as_bool) == Some(true),
192 "sidebar": normalize_sidebar(get(&source, &["ui", "sidebar"])),
193 "navigationOrder": navigation
194 },
195 "updates": {
196 "channel": channel_of(&["updates", "channel"], "beta"),
197 "autoCheck": get(&source, &["updates", "autoCheck"]).and_then(Value::as_bool) != Some(false),
198 "lastLaunchedRelease": normalize_release_marker(get(&source, &["updates", "lastLaunchedRelease"])),
199 "baseUrls": UPDATE_BASE_URLS
200 },
201 "plugins": { "channel": channel_of(&["plugins", "channel"], "alpha"), "display": display },
202 "monitoring": {
203 "enabled": get(&source, &["monitoring", "enabled"]).and_then(Value::as_bool) != Some(false),
204 "intervalSeconds": get(&source, &["monitoring", "intervalSeconds"]).and_then(Value::as_u64).filter(|s| INTERVALS_SECS.contains(s)).unwrap_or(DEFAULT_INTERVAL_SECS)
205 },
206 "security": {
207 "autoLockMinutes": auto_lock,
208 "lockOnBlur": get(&source, &["security", "lockOnBlur"]).and_then(Value::as_bool) == Some(true),
209 "requireSystemAuthentication": get(&source, &["security", "requireSystemAuthentication"]).and_then(Value::as_bool) == Some(true)
210 }
211 })
214fn merge_object(base: &Value, patch: Option<&Value>) -> Value {
215 let mut out = base.as_object().cloned().unwrap_or_default();
216 if let Some(Value::Object(p)) = patch {
217 for (k, v) in p {
218 out.insert(k.clone(), v.clone());
219 }
220 }
221 Value::Object(out)
224pub struct ConfigStore {
225 path: PathBuf,
226 languages: Vec<String>,
227 value: RwLock<Value>,
230impl ConfigStore {
231 pub fn new(root: &Path, languages: Vec<String>) -> Self {
232 Self { path: root.join("config").join("config.json"), languages, value: RwLock::new(default_config()) }
233 }
235 pub fn languages(&self) -> &[String] {
236 &self.languages
237 }
239 pub fn load(&self) -> Value {
240 match read_json(&self.path) {
241 Ok(raw) => *self.value.write() = normalize_config(&raw, &self.languages),
242 Err(error) => {
243 if error.code != "ENOENT" {
244 let backup = self.path.with_file_name(format!("config.json.invalid-{}", chrono::Utc::now().timestamp_millis()));
245 let _ = std::fs::rename(&self.path, backup);
246 }
247 *self.value.write() = default_config();
248 let _ = self.save();
249 }
250 }
251 self.get()
252 }
254 pub fn get(&self) -> Value {
255 self.value.read().clone()
256 }
258 pub fn language(&self) -> String {
259 str_at(&self.value.read(), &["ui", "language"]).unwrap_or(DEFAULT_LANGUAGE).to_string()
260 }
262 pub fn update(&self, patch: &Value) -> CoreResult<Value> {
263 let current = self.get();
264 let ui_patch = get(patch, &["ui"]);
265 let mut ui = merge_object(&current["ui"], ui_patch);
266 ui["sidebar"] = merge_object(&current["ui"]["sidebar"], ui_patch.and_then(|u| u.get("sidebar")));
267 let mut updates = current["updates"].clone();
268 if let Some(Value::Object(p)) = get(patch, &["updates"]) {
269 if let Some(v) = p.get("channel").filter(|v| !v.is_null()) {
270 updates["channel"] = v.clone();
271 }
272 if let Some(v) = p.get("autoCheck").filter(|v| !v.is_null()) {
273 updates["autoCheck"] = v.clone();
274 }
275 }
276 let next = json!({
277 "schemaVersion": 1,
278 "ui": ui,
279 "updates": updates,
280 "plugins": merge_object(&current["plugins"], get(patch, &["plugins"])),
281 "monitoring": merge_object(&current["monitoring"], get(patch, &["monitoring"])),
282 "security": merge_object(&current["security"], get(patch, &["security"]))
283 });
284 *self.value.write() = normalize_config(&next, &self.languages);
285 self.save()?;
286 Ok(self.get())
287 }
289 pub fn record_launched_release(&self, release: &Value) -> CoreResult<Value> {
290 let marker = normalize_release_marker(Some(release));
291 if marker.is_null() {
292 return Err(CoreError::plain("Invalid launched update release metadata."));
293 }
294 let mut next = self.get();
295 next["updates"]["lastLaunchedRelease"] = marker.clone();
296 *self.value.write() = normalize_config(&next, &self.languages);
297 self.save()?;
298 Ok(marker)
299 }
301 pub fn save(&self) -> CoreResult<()> {
302 let value = self.get();
303 atomic_write_json(&self.path, &value)
304 }
307#[cfg(test)]
308mod tests {
309 use super::*;
311 fn langs() -> Vec<String> {
312 vec!["cs".into(), "de".into(), "en".into()]
313 }
315 #[test]
316 fn plaintext_settings_drop_server_metadata_credentials_and_connection_history() {
317 let root = tempfile::tempdir().unwrap(); let store = ConfigStore::new(root.path(), langs()); store.load();
318 let secret = "synthetic-secret-marker"; let host = "private-server-fixture.invalid";
319 store.update(&json!({"password":secret,"profiles":[{"host":host}],"history":[host],
320 "ui":{"host":host,"password":secret,"connectionHistory":[host],"sidebar":{"host":host},
321 "terminalProfiles":[{"id":"user-fixture","name":"Ocean","host":host,"password":secret,"appearance":{"host":host}}]},
322 "plugins":{"host":host,"password":secret},"monitoring":{"servers":[host]},"security":{"password":secret}})).unwrap();
323 let text = std::fs::read_to_string(root.path().join("config/config.json")).unwrap();
324 assert!(!text.contains(secret)); assert!(!text.contains(host)); assert!(!text.contains("connectionHistory"));
325 assert_eq!(store.get()["ui"]["terminalProfiles"][0]["name"],"Ocean");
326 }
328 #[test]
329 fn monitoring_defaults_on_and_only_offered_intervals_stay() {
330 let config = normalize_config(&json!({}), &langs());
331 assert_eq!(config["monitoring"], json!({ "enabled": true, "intervalSeconds": 12 }));
332 let config = normalize_config(&json!({"monitoring": {"enabled": false, "intervalSeconds": 30}}), &langs());
333 assert_eq!(config["monitoring"], json!({ "enabled": false, "intervalSeconds": 30 }));
334 let config = normalize_config(&json!({"monitoring": {"enabled": "no", "intervalSeconds": 1}}), &langs());
335 assert_eq!(config["monitoring"], json!({ "enabled": true, "intervalSeconds": 12 }));
336 }
338 #[test]
339 fn terminal_workspace_values_are_limited() {
340 let config = normalize_config(&json!({"ui": {"terminalLayout": "stacked", "terminalSplit": 5.0, "terminalSwapped": true}}), &langs());
341 assert_eq!(config["ui"]["terminalLayout"], "stacked");
342 assert_eq!(config["ui"]["terminalSplit"], 0.8);
343 assert_eq!(config["ui"]["terminalSwapped"], true);
344 assert_eq!(normalize_config(&json!({}), &langs())["ui"]["terminalSuggest"], true);
345 let look = normalize_config(&json!({"ui": {"terminalAppearance": {"fontFamily": "Times New Roman", "fontSize": 99, "lineHeight": 0.2, "foreground": "#ABCDEF", "background": "red", "cursorStyle": "bar"}}}), &langs());
346 let look = &look["ui"]["terminalAppearance"];
347 assert_eq!((look["fontFamily"].as_str(), look["fontSize"].as_f64(), look["lineHeight"].as_f64(), look["foreground"].as_str(), look["background"].as_str(), look["cursorStyle"].as_str()), (Some("Times New Roman"), Some(32.0), Some(1.0), Some("#abcdef"), Some(""), Some("bar")));
348 let saved = normalize_config(&json!({"ui": {"terminalAppearance": {"ansi": {"red": "#FF0000", "green": "zelena", "unknown": "#000000"}}, "terminalProfiles": [{"id": "user-1", "name": " Můj ", "appearance": {"foreground": "#111111"}}, {"id": "user-1", "name": "dvojník"}, {"id": "bad id", "name": "x"}, {"id": "user-2", "name": ""}]}}), &langs());
349 assert_eq!(saved["ui"]["terminalAppearance"]["ansi"], json!({"red": "#ff0000"}));
350 assert_eq!(saved["ui"]["terminalProfiles"].as_array().unwrap().len(), 1);
351 assert_eq!((saved["ui"]["terminalProfiles"][0]["name"].as_str(), saved["ui"]["terminalProfiles"][0]["appearance"]["foreground"].as_str()), (Some("Můj"), Some("#111111")));
352 assert_eq!(normalize_config(&json!({"ui": {"terminalAppearance": {"fontFamily": "x\"; @import url(y)"}}}), &langs())["ui"]["terminalAppearance"]["fontFamily"], "", "a font name cannot carry CSS");
353 assert_eq!(normalize_config(&json!({"ui": {"terminalSuggest": false}}), &langs())["ui"]["terminalSuggest"], false);
354 let config = normalize_config(&json!({"ui": {"terminalLayout": "grid", "terminalSplit": "x", "terminalSwapped": 1}}), &langs());
355 assert_eq!(config["ui"]["terminalLayout"], "single");
356 assert_eq!(config["ui"]["terminalSplit"], 0.5);
357 assert_eq!(config["ui"]["terminalSwapped"], false);
358 }
360 #[test]
361 fn defaults_and_invalid_values() {
362 let config = normalize_config(&json!({"ui": {"language": "xx", "theme": "neon", "sidebar": {"width": 9999, "mode": "split"}}, "updates": {"channel": "nightly"}}), &langs());
363 assert_eq!(config["ui"]["language"], "cs");
364 assert_eq!(config["ui"]["theme"], "dark");
365 assert_eq!(config["ui"]["sidebar"]["width"], 420);
366 assert_eq!(config["ui"]["sidebar"]["mode"], "split");
367 assert_eq!(config["updates"]["channel"], "beta");
368 assert_eq!(config["plugins"]["channel"], "alpha");
369 assert_eq!(config["updates"]["baseUrls"][0], "https://sm.ch-j.de/");
370 }
372 #[test]
373 fn dynamic_language_list() {
374 let mut languages = langs();
375 languages.push("sk".into());
376 let config = normalize_config(&json!({"ui": {"language": "sk"}}), &languages);
377 assert_eq!(config["ui"]["language"], "sk");
378 }
380 #[test]
381 fn update_merges_sidebar_and_ignores_base_urls() {
382 let dir = tempfile::tempdir().unwrap();
383 let store = ConfigStore::new(dir.path(), langs());
384 store.load();
385 store.update(&json!({"ui": {"sidebar": {"hidden": true}}, "updates": {"baseUrls": ["https://evil/"]}})).unwrap();
386 let config = store.update(&json!({"ui": {"sidebar": {"mode": "compact"}}})).unwrap();
387 assert_eq!(config["ui"]["sidebar"]["hidden"], true);
388 assert_eq!(config["ui"]["sidebar"]["mode"], "compact");
389 assert_eq!(config["updates"]["baseUrls"], json!(["https://sm.ch-j.de/", "https://tools.ch-j.de/"]));
390 let reloaded = ConfigStore::new(dir.path(), langs());
391 assert_eq!(reloaded.load()["ui"]["sidebar"]["mode"], "compact");
392 }
394 #[test]
395 fn invalid_file_is_backed_up() {
396 let dir = tempfile::tempdir().unwrap();
397 std::fs::create_dir_all(dir.path().join("config")).unwrap();
398 std::fs::write(dir.path().join("config/config.json"), "{broken").unwrap();
399 let store = ConfigStore::new(dir.path(), langs());
400 assert_eq!(store.load()["schemaVersion"], 1);
401 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();
402 assert!(names.iter().any(|n| n.starts_with("config.json.invalid-")));
403 }
SHA-256b9e0cf29b61209dde75c609585cc00c39b0ec552410e376fe811ff7da8a4f340

CH-J Proprietary Software License 1.14 · A forrás a CH-J Proprietary Software License 1.14 alatt található. Rendelkezése nem változtatja meg a licenc feltételeit, és nem ad további jogokat.

Archive SHA-256ac8fe790f4ba60fc098f8c985fc2656cfcc6cdc21fb34b30e308a2b54e62258f
Fájlok