ctoolbox/io/webui/controllers/
app.rs

1//! Controller for general app UI pages.
2
3use axum::response::{IntoResponse, Redirect, Result};
4use axum::{extract::State, response::Response};
5use axum_typed_multipart::TryFromMultipart;
6use serde::{Deserialize, Serialize};
7
8use crate::formats::ip::get_regex_ipv4_ipv6_exact;
9use crate::io::webui::controllers::base::redirect_temporary;
10use crate::io::webui::error::{WebErr, WebError, WebResult};
11use crate::io::webui::flexible_form::FlexibleForm;
12use crate::io::webui::session_auth::AuthenticatedUser;
13use crate::io::webui::{AppState, RequestState, error_403, respond_page};
14use crate::storage::pc_settings::{DEFAULT_BIND_TO_IP, DEFAULT_SERVER_URL};
15use crate::storage::user::UserPublicInfo;
16use crate::utilities::build_info;
17use crate::utilities::password::{Password, hash, verify};
18use crate::{debug_fmt, json_value};
19
20/// This is *not* the index page; it's the home page after logging in.
21pub async fn get_home(
22    State(state): State<AppState>,
23    req: RequestState,
24    user: AuthenticatedUser,
25) -> Response {
26    respond_page(&state, req, "home", &json_value!({}))
27}
28
29/// The first-run setup and public PC settings page. For multi-user systems,
30/// public servers, kiosks, etc. this should typically be locked down, but it
31/// needs to be available for long enough after install for the administrator to
32/// get the computer set up. FIXME: May not exactly be accurate? TBD.
33pub async fn get_public_pc_settings(
34    State(state): State<AppState>,
35    req: RequestState,
36) -> WebResult<Response> {
37    #[derive(Serialize, Debug)]
38    struct UserWithAdmin {
39        admin: bool,
40        local_id: u64,
41        username: String,
42        display_name: Option<Vec<u8>>,
43        picture: Option<Vec<u8>>,
44    }
45
46    let build_info = build_info();
47    let current_settings =
48        crate::storage::pc_settings::PcSettings::load().unwrap_or_default();
49
50    /*let all_users = crate::storage::user::UserPublicInfo::list_all()
51    .web_err(&state, &req)?;*/
52    // FIXME IPC is broken, so temporarily disable user listing
53    let all_users = Vec::<UserPublicInfo>::new();
54
55    debug_fmt!("All users: {all_users:#?}");
56
57    let users: Vec<UserWithAdmin> = all_users
58        .into_iter()
59        .map(|u| UserWithAdmin {
60            admin: current_settings.admin_users.contains(&u.local_id()),
61            local_id: u.local_id(),
62            username: u.name().to_string(),
63            display_name: u.display_name().map(<[u8]>::to_vec),
64            picture: u.user_picture().map(<[u8]>::to_vec),
65        })
66        .collect();
67
68    debug_fmt!("Rendering PC settings page with users: {users:#?}");
69
70    Ok(respond_page(
71        &state,
72        req,
73        "settings.pc-settings",
74        &json_value! ({
75            "show_users" => current_settings.show_users,
76            "bind_to_ip" => current_settings.bind_to_ip,
77            "bind_to_ip_regex" => get_regex_ipv4_ipv6_exact(),
78            "server_url" => current_settings.server_url,
79            "domain_name" => current_settings.domain_name.unwrap_or_default(),
80            "fixed_http_port" => current_settings.fixed_http_port.unwrap_or(0),
81            "fixed_https_port" => current_settings.fixed_https_port.unwrap_or(0),
82            "tls_certificate" => current_settings.tls_certificate.unwrap_or_default(),
83            "kiosk_mode" => current_settings.kiosk_mode,
84            "log_stack_file" => current_settings.log_stack_file,
85
86            // Feature flags
87            "feature_login" => current_settings.feature_login,
88            "feature_registration" => current_settings.feature_registration,
89
90            // Secrets not shown, just whether they are set
91            "tls_private_key_set" => current_settings.tls_private_key.is_some(),
92            "admin_password_set" => current_settings.admin_password_hash.is_some(),
93
94            // All users, for building list
95            "users" => users,
96
97            // Provide build info for display
98            "crate_name" => build_info.name,
99            "crate_version" => build_info.version,
100            "build_date" => build_info.build_date,
101            "commit" => build_info.commit
102        }),
103    ))
104}
105
106#[derive(TryFromMultipart, Serialize, Deserialize)]
107#[try_from_multipart(strict)]
108pub struct PcSettingsForm {
109    show_users: Option<bool>,
110    bind_to_ip: Option<String>,
111    server_url: Option<String>,
112    domain_name: String,
113    fixed_http_port: u16,
114    fixed_https_port: u16,
115    http_redirect: Option<bool>,
116    tls_certificate: Option<String>,
117    tls_private_key: Option<String>,
118    admin_users: Vec<u64>,
119    admin_password: Option<String>,
120    kiosk_mode: Option<bool>,
121    log_stack_file: Option<bool>,
122    feature_login: Option<bool>,
123    feature_registration: Option<bool>,
124}
125
126#[allow(clippy::too_many_lines)]
127pub async fn post_public_pc_settings(
128    State(state): State<AppState>,
129    req: RequestState,
130    form: FlexibleForm<PcSettingsForm>,
131) -> WebResult<Response> {
132    let PcSettingsForm {
133        show_users,
134        bind_to_ip,
135        server_url,
136        domain_name,
137        fixed_http_port,
138        fixed_https_port,
139        http_redirect,
140        tls_certificate,
141        tls_private_key,
142        admin_users,
143        admin_password,
144        kiosk_mode,
145        log_stack_file,
146        feature_login,
147        feature_registration,
148    } = form.0;
149
150    let admin_password: Option<Password> = admin_password
151        .map(|admin_password| Password::from_string(&admin_password));
152
153    let current_settings =
154        crate::storage::pc_settings::PcSettings::load().unwrap_or_default();
155    // If an admin password is set, require it
156    if let Some(ref current_pass_hash) = current_settings.admin_password_hash {
157        match admin_password.clone() {
158            Some(provided)
159                if verify(&provided, current_pass_hash.as_str()).map_err(
160                    |e| WebError::new(e, state.clone(), req.clone()),
161                )? => {}
162            _ => {
163                return Ok(error_403(
164                    &state,
165                    &req,
166                    "Invalid admin password".to_string(),
167                ));
168            }
169        }
170    }
171    // If no admin password is set and one is provided, hash it and save it
172    let admin_password_hash =
173        if current_settings.admin_password_hash.is_none() {
174            if let Some(provided) = admin_password {
175                Some(hash(&provided).map_err(|e| {
176                    WebError::new(e, state.clone(), req.clone())
177                })?)
178            } else {
179                None
180            }
181        } else {
182            current_settings.admin_password_hash.clone()
183        };
184
185    let tls_private_key = if tls_private_key.is_some() {
186        tls_private_key
187    } else {
188        current_settings.tls_private_key.clone()
189    };
190
191    let settings = crate::storage::pc_settings::PcSettings {
192        show_users: if let Some(show_users) = show_users {
193            show_users
194        } else {
195            current_settings.show_users
196        },
197        bind_to_ip: if let Some(bind_to_ip) = bind_to_ip {
198            if bind_to_ip.is_empty() {
199                DEFAULT_BIND_TO_IP.to_string()
200            } else {
201                bind_to_ip
202            }
203        } else {
204            current_settings.bind_to_ip
205        },
206        server_url: if let Some(server_url) = server_url {
207            if server_url.is_empty() {
208                DEFAULT_SERVER_URL.to_string()
209            } else {
210                server_url
211            }
212        } else {
213            current_settings.server_url
214        },
215        domain_name: if domain_name.is_empty() {
216            None
217        } else {
218            Some(domain_name)
219        },
220        fixed_http_port: if fixed_http_port == 0 {
221            None
222        } else {
223            Some(fixed_http_port)
224        },
225        fixed_https_port: if fixed_https_port == 0 {
226            None
227        } else {
228            Some(fixed_https_port)
229        },
230        http_redirect: if let Some(http_redirect) = http_redirect {
231            http_redirect
232        } else {
233            current_settings.http_redirect
234        },
235        tls_certificate,
236        tls_private_key,
237        admin_users,
238        admin_password_hash,
239        kiosk_mode: if let Some(kiosk_mode) = kiosk_mode {
240            kiosk_mode
241        } else {
242            current_settings.kiosk_mode
243        },
244        log_stack_file: if let Some(log_stack_file) = log_stack_file {
245            log_stack_file
246        } else {
247            current_settings.log_stack_file
248        },
249        feature_login: if let Some(feature_login) = feature_login {
250            feature_login
251        } else {
252            current_settings.feature_login
253        },
254        feature_registration: if let Some(feature_registration) =
255            feature_registration
256        {
257            feature_registration
258        } else {
259            current_settings.feature_registration
260        },
261        ..Default::default()
262    };
263
264    settings.save().web_err(&state, &req)?;
265    Ok(redirect_temporary(req.is_js_request, "/pc-settings"))
266}
267
268#[cfg(test)]
269#[allow(clippy::unwrap_in_result, clippy::panic_in_result_fn)]
270mod tests {
271    use crate::io::webui::test_helpers::{
272        assert_body_contains, assert_eq_or_print_body, test_get_no_login,
273        test_post_no_login,
274    };
275    use crate::storage::pc_settings::PcSettings;
276    use crate::storage::user::get_test_user;
277
278    #[crate::ctb_test(tokio::test)]
279    async fn can_get_pc_settings() {
280        let test_user_1 =
281            get_test_user(format!("{}_1", function_name!()).as_str());
282        let test_user_2 =
283            get_test_user(format!("{}_2", function_name!()).as_str());
284        let test_user_3 =
285            get_test_user(format!("{}_3", function_name!()).as_str());
286
287        // Set known settings
288        let settings = PcSettings {
289            show_users: true,
290            bind_to_ip: "123.456.789".to_string(),
291            domain_name: Some("foo.com".to_string()),
292            fixed_http_port: Some(1234),
293            fixed_https_port: Some(4567),
294            admin_users: vec![test_user_1.local_id(), test_user_3.local_id()],
295            ..Default::default()
296        };
297        settings.save().unwrap();
298
299        let (status, body) = test_get_no_login("/pc-settings").await;
300        assert_eq_or_print_body(status, 200, &body);
301        assert_body_contains(
302            "These options apply to all users on this computer",
303            &body,
304        );
305        // Check that the form contains the current values
306        assert_body_contains("value=\"foo.com\"", &body);
307        assert_body_contains("value=\"1234\"", &body);
308        // FIXME unimplemented due to broken IPC
309        /*assert_body_contains(
310            &format!("\"{}\"  checked", test_user_1.local_id()),
311            &body,
312        );
313        assert_body_not_contains(
314            &format!("\"{}\"  checked", test_user_2.local_id()),
315            &body,
316        );
317        assert_body_contains(
318            &format!("\"{}\"  checked", test_user_3.local_id()),
319            &body,
320        );*/
321        // assert_body_contains("checked", &body); // show_users checked
322    }
323
324    #[crate::ctb_test(tokio::test)]
325    async fn can_save_settings_and_read_back() {
326        // Prepare form data
327        #[derive(serde::Serialize)]
328        struct Form {
329            show_users: bool,
330            bind_to_ip: Option<String>,
331            domain_name: Option<String>,
332            fixed_http_port: Option<u16>,
333            fixed_https_port: Option<u16>,
334            admin_users: Vec<u64>,
335            feature_login: bool,
336        }
337        let form = Form {
338            show_users: false,
339            bind_to_ip: Some("127.0.0.1".to_string()),
340            domain_name: Some("example.com".to_string()),
341            fixed_http_port: Some(5678),
342            fixed_https_port: Some(5679),
343            admin_users: vec![18, 19],
344            feature_login: true,
345        };
346
347        let (status, body) =
348            test_post_no_login("/pc-settings", None, Some(&form), None).await;
349        assert_eq_or_print_body(status, 303, &body); // Should redirect
350
351        // Now load settings and check values
352        let loaded = PcSettings::load().unwrap();
353        assert!(!loaded.show_users);
354        assert_eq!(loaded.bind_to_ip, "127.0.0.1".to_string());
355        assert_eq!(loaded.domain_name, Some("example.com".to_string()));
356        assert_eq!(loaded.fixed_http_port, Some(5678));
357        assert_eq!(loaded.fixed_https_port, Some(5679));
358        assert_eq!(loaded.admin_users, vec![18, 19]);
359        assert!(loaded.feature_login);
360    }
361}