include!("preferences_columns.rs"); fn default_admin_theme() -> String { "dark".to_owned() } fn default_admin_language() -> String { "auto".to_owned() } fn default_admin_ui_scale() -> u16 { 100 } fn default_admin_time_format() -> String { "24h".to_owned() } fn default_admin_chart_style() -> String { "default".to_owned() } fn default_admin_button_style() -> String { "normal".to_owned() } fn default_admin_table_density() -> String { "normal".to_owned() } fn default_admin_dashboard_density() -> String { "compact".to_owned() } fn default_admin_dashboard_widgets() -> Vec { DASHBOARD_WIDGETS .iter() .map(|value| (*value).to_owned()) .collect() } fn default_file_log_sort() -> String { "time".to_owned() } fn default_file_log_descending() -> bool { true } fn default_file_log_columns() -> Vec { [ "time", "event", "status", "client_ip", "vhost", "record", "source", ] .into_iter() .map(str::to_owned) .collect() } fn default_backend_table_columns() -> Vec { BACKEND_TABLE_COLUMNS .iter() .map(|value| (*value).to_owned()) .collect() } fn default_backend_table_sort() -> String { "vhost".to_owned() } fn default_vhost_list_sort() -> String { "id".to_owned() } fn default_docker_vhost_sort() -> String { "service".to_owned() } fn default_redirect_list_sort() -> String { "id".to_owned() } fn default_notfound_list_sort() -> String { "id".to_owned() } fn default_stream_list_sort() -> String { "name".to_owned() } include!("refresh_preferences.rs"); #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(deny_unknown_fields)] struct AdminPreferences { #[serde(default)] refresh: RefreshPreferences, #[serde(default = "default_live_log_columns")] live_log_columns: Vec, #[serde(default = "default_admin_theme")] theme: String, #[serde(default = "default_admin_language")] language: String, #[serde(default = "default_admin_ui_scale")] ui_scale: u16, #[serde(default = "default_admin_time_format")] time_format: String, #[serde(default = "default_admin_chart_style")] chart_style: String, #[serde(default = "default_admin_button_style")] button_style: String, #[serde(default = "default_admin_table_density")] table_density: String, #[serde(default = "default_admin_dashboard_density")] dashboard_density: String, #[serde(default = "default_admin_dashboard_widgets")] dashboard_widgets: Vec, #[serde(default)] dashboard_hidden_widgets: Vec, #[serde(default = "default_file_log_columns")] file_log_columns: Vec, #[serde(default = "default_file_log_sort")] file_log_sort: String, #[serde(default = "default_file_log_descending")] file_log_descending: bool, #[serde(default = "default_backend_table_columns")] backend_table_columns: Vec, #[serde(default = "default_backend_table_sort")] backend_table_sort: String, #[serde(default)] backend_table_descending: bool, #[serde(default = "default_vhost_list_sort")] vhost_list_sort: String, #[serde(default)] vhost_list_descending: bool, #[serde(default = "default_docker_vhost_sort")] docker_vhost_sort: String, #[serde(default)] docker_vhost_descending: bool, #[serde(default = "default_redirect_list_sort")] redirect_list_sort: String, #[serde(default)] redirect_list_descending: bool, #[serde(default = "default_notfound_list_sort")] notfound_list_sort: String, #[serde(default)] notfound_list_descending: bool, #[serde(default = "default_stream_list_sort")] stream_list_sort: String, #[serde(default)] stream_list_descending: bool, } impl Default for AdminPreferences { fn default() -> Self { Self { refresh: RefreshPreferences::default(), live_log_columns: LIVE_LOG_COLUMNS .iter() .map(|value| (*value).to_owned()) .collect(), theme: default_admin_theme(), language: default_admin_language(), ui_scale: default_admin_ui_scale(), time_format: default_admin_time_format(), chart_style: default_admin_chart_style(), button_style: default_admin_button_style(), table_density: default_admin_table_density(), dashboard_density: default_admin_dashboard_density(), dashboard_widgets: default_admin_dashboard_widgets(), dashboard_hidden_widgets: Vec::new(), file_log_columns: default_file_log_columns(), file_log_sort: default_file_log_sort(), file_log_descending: default_file_log_descending(), backend_table_columns: default_backend_table_columns(), backend_table_sort: default_backend_table_sort(), backend_table_descending: false, vhost_list_sort: default_vhost_list_sort(), vhost_list_descending: false, docker_vhost_sort: default_docker_vhost_sort(), docker_vhost_descending: false, redirect_list_sort: default_redirect_list_sort(), redirect_list_descending: false, notfound_list_sort: default_notfound_list_sort(), notfound_list_descending: false, stream_list_sort: default_stream_list_sort(), stream_list_descending: false, } } } fn validate_admin_preferences(preferences: &AdminPreferences) -> Result<(), ApiError> { validate_refresh_preferences(&preferences.refresh)?; if preferences.live_log_columns.is_empty() || preferences.live_log_columns.len() > LIVE_LOG_COLUMNS.len() { return Err(ApiError( StatusCode::UNPROCESSABLE_ENTITY, "at least one live-log column must be visible".into(), )); } let mut seen = std::collections::HashSet::new(); for column in &preferences.live_log_columns { if !LIVE_LOG_COLUMNS.contains(&column.as_str()) || !seen.insert(column.as_str()) { return Err(ApiError( StatusCode::UNPROCESSABLE_ENTITY, "invalid live-log column preference".into(), )); } } if preferences.file_log_columns.is_empty() || preferences.file_log_columns.len() > FILE_LOG_COLUMN_MAX { return Err(ApiError( StatusCode::UNPROCESSABLE_ENTITY, "at least one stored-log column must be visible".into(), )); } let mut seen = std::collections::HashSet::new(); for column in &preferences.file_log_columns { if column.is_empty() || column.len() > 128 || column.chars().any(char::is_control) || !seen.insert(column.as_str()) { return Err(ApiError( StatusCode::UNPROCESSABLE_ENTITY, "invalid stored-log column preference".into(), )); } } if !valid_raw_field_key(&preferences.file_log_sort) { return Err(ApiError( StatusCode::UNPROCESSABLE_ENTITY, "invalid stored-log sort column preference".into(), )); } if !matches!(preferences.theme.as_str(), "dark" | "light") { return Err(ApiError( StatusCode::UNPROCESSABLE_ENTITY, "invalid administration theme preference".into(), )); } if preferences.language != "auto" && (preferences.language.is_empty() || preferences.language.len() > 64 || !preferences .language .chars() .all(|value| value.is_ascii_alphanumeric() || matches!(value, '_' | '-'))) { return Err(ApiError( StatusCode::UNPROCESSABLE_ENTITY, "invalid administration language preference".into(), )); } if !(70..=125).contains(&preferences.ui_scale) { return Err(ApiError( StatusCode::UNPROCESSABLE_ENTITY, "invalid administration interface scale preference".into(), )); } if !matches!(preferences.time_format.as_str(), "24h" | "12h") { return Err(ApiError( StatusCode::UNPROCESSABLE_ENTITY, "invalid administration time format preference".into(), )); } if !matches!( preferences.chart_style.as_str(), "default" | "soft" | "technical" | "high_contrast" | "neon" | "monochrome" | "warm" | "cool" ) { return Err(ApiError( StatusCode::UNPROCESSABLE_ENTITY, "invalid administration chart style preference".into(), )); } if !matches!(preferences.button_style.as_str(), "normal" | "compact") { return Err(ApiError( StatusCode::UNPROCESSABLE_ENTITY, "invalid administration button style preference".into(), )); } if !matches!( preferences.table_density.as_str(), "normal" | "compact" | "ultra" ) { return Err(ApiError( StatusCode::UNPROCESSABLE_ENTITY, "invalid administration table density preference".into(), )); } if !matches!( preferences.dashboard_density.as_str(), "normal" | "compact" | "ultra" ) { return Err(ApiError( StatusCode::UNPROCESSABLE_ENTITY, "invalid administration dashboard density preference".into(), )); } if preferences.dashboard_widgets.is_empty() || preferences.dashboard_widgets.len() > DASHBOARD_WIDGETS.len() { return Err(ApiError( StatusCode::UNPROCESSABLE_ENTITY, "invalid administration dashboard widget order".into(), )); } let mut seen = std::collections::HashSet::new(); for widget in &preferences.dashboard_widgets { if !DASHBOARD_WIDGETS.contains(&widget.as_str()) || !seen.insert(widget.as_str()) { return Err(ApiError( StatusCode::UNPROCESSABLE_ENTITY, "invalid administration dashboard widget order".into(), )); } } if preferences.dashboard_hidden_widgets.len() > DASHBOARD_WIDGETS.len() { return Err(ApiError( StatusCode::UNPROCESSABLE_ENTITY, "invalid administration hidden dashboard widgets".into(), )); } let mut seen = std::collections::HashSet::new(); for widget in &preferences.dashboard_hidden_widgets { if !DASHBOARD_WIDGETS.contains(&widget.as_str()) || !seen.insert(widget.as_str()) { return Err(ApiError( StatusCode::UNPROCESSABLE_ENTITY, "invalid administration hidden dashboard widgets".into(), )); } } if preferences.backend_table_columns.is_empty() || preferences.backend_table_columns.len() > BACKEND_TABLE_COLUMNS.len() { return Err(ApiError( StatusCode::UNPROCESSABLE_ENTITY, "at least one backend table column must be visible".into(), )); } let mut seen = std::collections::HashSet::new(); for column in &preferences.backend_table_columns { if !BACKEND_TABLE_COLUMNS.contains(&column.as_str()) || !seen.insert(column.as_str()) { return Err(ApiError( StatusCode::UNPROCESSABLE_ENTITY, "invalid backend table column preference".into(), )); } } if !BACKEND_TABLE_COLUMNS.contains(&preferences.backend_table_sort.as_str()) { return Err(ApiError( StatusCode::UNPROCESSABLE_ENTITY, "invalid backend table sort preference".into(), )); } for (value, allowed, message) in [ ( &preferences.vhost_list_sort, VHOST_LIST_SORT_COLUMNS, "invalid vhost list sort preference", ), ( &preferences.docker_vhost_sort, DOCKER_VHOST_SORT_COLUMNS, "invalid Docker vhost sort preference", ), ( &preferences.redirect_list_sort, REDIRECT_LIST_SORT_COLUMNS, "invalid redirect list sort preference", ), ( &preferences.notfound_list_sort, NOTFOUND_LIST_SORT_COLUMNS, "invalid 404-host sort preference", ), ( &preferences.stream_list_sort, STREAM_LIST_SORT_COLUMNS, "invalid stream list sort preference", ), ] { if !allowed.contains(&value.as_str()) { return Err(ApiError(StatusCode::UNPROCESSABLE_ENTITY, message.into())); } } Ok(()) } async fn preferences( State(state): State>, Extension(session): Extension, ) -> Result, ApiError> { let stored = state .store .admin_preferences(&session.username) .await .map_err(auth::internal)?; let preferences = match stored { Some(document) => match serde_json::from_str::(&document) { Ok(preferences) if validate_admin_preferences(&preferences).is_ok() => preferences, Ok(_) | Err(_) => { tracing::warn!(user = %session.username, "invalid admin view preferences ignored"); AdminPreferences::default() } }, None => AdminPreferences::default(), }; Ok(Json(preferences)) } async fn update_preferences( State(state): State>, Extension(session): Extension, input: Result, axum::extract::rejection::JsonRejection>, ) -> Result, ApiError> { let Json(preferences) = input.map_err(public::invalid_json)?; validate_admin_preferences(&preferences)?; let document = serde_json::to_string(&preferences).map_err(auth::internal)?; if !state .store .save_admin_preferences(&session.username, &document) .await .map_err(auth::internal)? { return Err(ApiError( StatusCode::UNAUTHORIZED, "auth.invalid_credentials".into(), )); } Ok(Json(preferences)) }