Files
RustProxy/src/admin/preferences.rs
T
2026-10-12 11:22:42 +02:00

424 lines
14 KiB
Rust

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<String> {
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<String> {
[
"time",
"event",
"status",
"client_ip",
"vhost",
"record",
"source",
]
.into_iter()
.map(str::to_owned)
.collect()
}
fn default_backend_table_columns() -> Vec<String> {
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<String>,
#[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<String>,
#[serde(default)]
dashboard_hidden_widgets: Vec<String>,
#[serde(default = "default_file_log_columns")]
file_log_columns: Vec<String>,
#[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<String>,
#[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<Arc<App>>,
Extension(session): Extension<Session>,
) -> Result<Json<AdminPreferences>, 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::<AdminPreferences>(&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<Arc<App>>,
Extension(session): Extension<Session>,
input: Result<Json<AdminPreferences>, axum::extract::rejection::JsonRejection>,
) -> Result<Json<AdminPreferences>, 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))
}