ctoolbox/storage/
pc_settings.rs

1//! Persistent configuration for PC settings.
2//! Provides serialization and deserialization to a file in the app's cache
3//! directory.
4//!
5//! - Always lock the file before reading or writing to avoid race conditions
6//!   between processes.
7//! - TODO: Consider reloading settings after external changes since settings
8//!   can be changed by other processes.
9
10use anyhow::{Context, Result};
11use fs2::FileExt;
12use serde::{Deserialize, Serialize};
13use std::fs::OpenOptions;
14use std::io::{Read, Seek, SeekFrom, Write};
15use std::path::PathBuf;
16
17use crate::storage::get_storage_dir;
18use crate::utilities::get_current_test_name;
19
20pub static DEFAULT_SHOW_USERS: bool = true;
21pub static DEFAULT_SERVER_URL: &str = "https://collectivetoolbox.com";
22pub static DEFAULT_BIND_TO_IP: &str = "127.0.0.1";
23pub static DEFAULT_KIOSK_MODE: bool = false;
24pub static DEFAULT_LOG_STACK_FILE: bool = false;
25pub static DEFAULT_FEATURE_LOGIN: bool = true;
26pub static DEFAULT_FEATURE_REGISTRATION: bool = true;
27
28#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
29#[allow(clippy::struct_excessive_bools)]
30#[serde(default)]
31pub struct PcSettings {
32    pub show_users: bool,
33    pub bind_to_ip: String,
34    pub server_url: String,
35    pub domain_name: Option<String>,
36    pub fixed_http_port: Option<u16>,
37    pub fixed_https_port: Option<u16>,
38    pub http_redirect: bool,
39    pub tls_certificate: Option<String>,
40    pub tls_private_key: Option<String>,
41    pub admin_users: Vec<u64>,
42    // Until admin_users can be properly implemented, just use a single admin password
43    pub admin_password_hash: Option<String>,
44    pub kiosk_mode: bool,
45    pub log_stack_file: bool,
46    pub feature_login: bool,
47    pub feature_registration: bool,
48    pub version: u32,
49    pub note: String,
50}
51
52impl Default for PcSettings {
53    fn default() -> Self {
54        PcSettings {
55            show_users: DEFAULT_SHOW_USERS,
56            bind_to_ip: DEFAULT_BIND_TO_IP.into(),
57            server_url: DEFAULT_SERVER_URL.into(),
58            domain_name: None,
59            fixed_http_port: None,
60            fixed_https_port: None,
61            http_redirect: true,
62            tls_certificate: None,
63            tls_private_key: None,
64            admin_users: Vec::new(),
65            admin_password_hash: None,
66            kiosk_mode: DEFAULT_KIOSK_MODE,
67            log_stack_file: DEFAULT_LOG_STACK_FILE,
68            feature_login: DEFAULT_FEATURE_LOGIN,
69            feature_registration: DEFAULT_FEATURE_REGISTRATION,
70            version: 1,
71            note: "Unsigned default settings (stub).".into(),
72        }
73    }
74}
75
76pub fn get_str_setting(setting: &str) -> Option<String> {
77    let settings = get_settings();
78    match setting {
79        "bind_to_ip" => Some(settings.bind_to_ip),
80        "server_url" => Some(settings.server_url),
81        "domain_name" => settings.domain_name,
82        _ => None,
83    }
84}
85
86pub fn get_bool_setting(setting: &str) -> bool {
87    let settings = get_settings();
88    match setting {
89        "show_users" => settings.show_users,
90        "http_redirect" => settings.http_redirect,
91        "kiosk_mode" => settings.kiosk_mode,
92        "log_stack_file" => settings.log_stack_file,
93        _ => unimplemented!(),
94    }
95}
96
97impl PcSettings {
98    /* /// Returns the path to the settings file in the cache directory.
99    fn settings_path() -> Result<PathBuf> {
100        let mut path = get_storage_dir()
101            .context("Failed to get cache directory")?
102            .join("config");
103        std::fs::create_dir_all(&path)?;
104        if cfg!(test) {
105            path.push("test_pc_settings.json");
106        } else {
107            path.push("pc_settings.json");
108        }
109        Ok(path)
110    }*/
111
112    /// Returns the path to the settings file in the cache directory.
113    fn settings_path() -> anyhow::Result<PathBuf> {
114        // If running tests, prefer thread-local override
115        if cfg!(test) {
116            
117        }
118
119        let mut path = get_storage_dir()?.join("config");
120        std::fs::create_dir_all(&path)?;
121        let filename = if cfg!(test) {
122            format!("{}_pc_settings.json", get_current_test_name())
123        } else {
124            "pc_settings.json".to_string()
125        };
126        path.push(filename);
127        Ok(path)
128    }
129
130    /// Loads `PcSettings` from the settings file, creating one if absent, or
131    /// returns an error.
132    /// TODO: Add signature verification.
133    pub fn load() -> Result<Self> {
134        let path = Self::settings_path()?;
135        if !std::fs::exists(path.as_path())? {
136            PcSettings::default().save()?;
137        }
138        let file =
139            OpenOptions::new().read(true).open(&path).with_context(|| {
140                format!("Failed to open settings file: {}", path.display())
141            })?;
142
143        // Lock for shared reading (blocks if another process is writing)
144        file.lock_shared()
145            .context("Failed to acquire shared lock on settings file")?;
146
147        let mut contents = String::new();
148        std::io::Read::read_to_string(&mut &file, &mut contents)
149            .context("Failed to read settings file")?;
150
151        // Release lock before parsing
152        file.unlock()?;
153
154        if contents.trim().is_empty() {
155            anyhow::bail!("Settings file is empty");
156        }
157
158        let settings = serde_json::from_str(&contents)
159            .context("Failed to parse settings JSON")?;
160        Ok(settings)
161    }
162
163    /// Saves `PcSettings` to the settings file, locking it for exclusive write.
164    pub fn save(&self) -> Result<()> {
165        let path = Self::settings_path()?;
166        let mut file = OpenOptions::new()
167            .write(true)
168            .create(true)
169            .truncate(true)
170            .open(&path)
171            .with_context(|| {
172                format!(
173                    "Failed to open settings file for writing: {}",
174                    path.display()
175                )
176            })?;
177
178        // Lock for exclusive writing
179        file.lock_exclusive()
180            .context("Failed to acquire exclusive lock on settings file")?;
181
182        let data = serde_json::to_string_pretty(self)
183            .context("Failed to serialize settings")?;
184        file.seek(SeekFrom::Start(0))?;
185        file.write_all(data.as_bytes())
186            .context("Failed to write settings file")?;
187        file.set_len(
188            u64::try_from(data.len()).context("Failed to set file length")?,
189        )?;
190
191        // Release lock after writing
192        file.unlock()?;
193
194        Ok(())
195    }
196}
197
198pub fn ensure_pc_settings() -> Result<()> {
199    PcSettings::load()?;
200    Ok(())
201}
202
203pub fn get_settings() -> PcSettings {
204        crate::storage::pc_settings::PcSettings::load().unwrap_or_default()
205}
206
207#[cfg(test)]
208#[allow(clippy::unwrap_in_result, clippy::panic_in_result_fn)]
209mod tests {
210    use super::PcSettings;
211    use anyhow::Result;
212
213    #[crate::ctb_test]
214    fn test_save_and_load_settings() -> Result<()> {
215        let old_settings = PcSettings::load()?;
216        let settings = PcSettings {
217            bind_to_ip: "0.0.0.0".into(),
218            fixed_http_port: Some(8080),
219            fixed_https_port: Some(8443),
220            admin_users: vec![1, 2, 3],
221            ..Default::default()
222        };
223        settings.save()?;
224
225        let loaded_settings = PcSettings::load()?;
226        assert_eq!(settings, loaded_settings);
227        old_settings.save()?;
228        assert_eq!(old_settings, PcSettings::load()?);
229        Ok(())
230    }
231}