malwaredb 0.3.5

Service for storing malicious, benign, or unknown files and related metadata and relationships.
// SPDX-License-Identifier: Apache-2.0

use malwaredb_server::State;
use malwaredb_server::db::admin::User;

use std::collections::HashSet;
use std::sync::Arc;

use anyhow::Result;
use eframe::egui::Id;

const COLUMN_HEADERS: [&str; 8] = [
    "ID",
    "Username",
    "First Name",
    "Last Name",
    "Email",
    "Active",
    "Read-Only",
    "Has API Key",
];

/// Users table
#[derive(Debug, Clone)]
pub struct UserTable {
    /// Malware DB server state
    state: Arc<State>,

    /// Committed baseline; Cancel reverts draft to this.
    users: Vec<User>,

    /// Live edits displayed in the table cells.
    draft: Vec<User>,

    /// IDs of rows that have unsaved changes, for use by the save operation.
    changed_ids: HashSet<usize>,
}

impl UserTable {
    pub async fn new(state: Arc<State>) -> Result<Self> {
        let users = state.db_type.list_users().await?;
        let draft = users.clone();
        Ok(Self {
            state,
            users,
            draft,
            changed_ids: HashSet::new(),
        })
    }

    fn all_valid(&self) -> bool {
        self.draft.iter().all(|u| {
            !u.uname.trim().is_empty()
                && !u.fname.trim().is_empty()
                && !u.lname.trim().is_empty()
                && is_valid_email(&u.email)
        })
    }

    pub fn ui(&mut self, ui: &mut eframe::egui::Ui) {
        if !self.changed_ids.is_empty() {
            ui.horizontal(|ui| {
                if ui
                    .add_enabled(self.all_valid(), eframe::egui::Button::new("Save"))
                    .clicked()
                {
                    for row in &self.changed_ids {
                        let user = &self.draft[*row];
                        if let Err(e) = futures::executor::block_on(self.state.db_type.edit_user(
                            user.id,
                            &user.uname,
                            &user.fname,
                            &user.lname,
                            &user.email,
                            user.is_readonly,
                        )) {
                            eprintln!(
                                "Error saving changes for user {} {}: {e}",
                                user.id, user.uname
                            );
                        }

                        // TODO: Handle password change

                        if !user.has_api_key
                            && let Err(e) = futures::executor::block_on(
                                self.state.db_type.deactivate_user(user.id),
                            )
                        {
                            eprintln!("Error deactivating user {} {}: {e}", user.id, user.uname);
                        }
                    }
                    self.users.clone_from(&self.draft);
                    self.changed_ids.clear();
                }
                if ui.button("Cancel").clicked() {
                    self.draft.clone_from(&self.users);
                    self.changed_ids.clear();
                }
            });
        }

        let id_salt = Id::new("user_table");
        let table = egui_table::Table::new()
            .id_salt(id_salt)
            .auto_size_mode(egui_table::AutoSizeMode::OnParentResize)
            .num_rows(self.draft.len() as u64)
            .columns(
                (0..COLUMN_HEADERS.len())
                    .map(|_| egui_table::Column::new(100.0).resizable(true))
                    .collect::<Vec<_>>(),
            );
        table.show(ui, self);
    }
}

fn is_valid_email(email: &str) -> bool {
    if email.is_empty()
        || !email.contains('@')
        || email.trim().len() != email.len()
        || email.contains(' ')
    {
        return false;
    }
    let mut parts = email.splitn(2, '@');
    let local = parts.next().unwrap_or("");
    let domain = parts.next().unwrap_or("");
    !local.is_empty()
        && !domain.is_empty()
        && domain.contains('.')
        && !domain.starts_with('.')
        && !domain.ends_with('.')
}

/// Returns a static error message if the field at `col_nr` is invalid, otherwise `None`.
fn field_error(user: &User, col_nr: usize) -> Option<&'static str> {
    match col_nr {
        1 if user.uname.trim().is_empty() => Some("Username cannot be blank"),
        1 if user.uname.contains(' ') => Some("Username cannot contain spaces"),
        2 if user.fname.trim().is_empty() => Some("First name cannot be blank"),
        3 if user.lname.trim().is_empty() => Some("Last name cannot be blank"),
        4 if !is_valid_email(&user.email) => Some("Enter a valid email address (user@domain.tld)"),
        _ => None,
    }
}

impl egui_table::TableDelegate for UserTable {
    fn header_cell_ui(&mut self, ui: &mut eframe::egui::Ui, cell: &egui_table::HeaderCellInfo) {
        if let Some(header) = COLUMN_HEADERS.get(cell.group_index) {
            ui.label(*header);
        }
    }

    fn cell_ui(&mut self, ui: &mut eframe::egui::Ui, cell: &egui_table::CellInfo) {
        let row = match usize::try_from(cell.row_nr).ok() {
            Some(r) if r < self.draft.len() => r,
            _ => return,
        };

        // Compute validation and capture the user ID before taking a mutable
        // borrow on the row, so we can update changed_ids afterwards.
        let error = field_error(&self.draft[row], cell.col_nr);
        let user_id = self.draft[row].id;

        let changed = {
            let user = &mut self.draft[row];
            match cell.col_nr {
                0 => {
                    ui.label(user.id.to_string());
                    false
                }
                1 if user_id == 0 => {
                    ui.label(user.uname.as_str());
                    false
                }
                1..=4 => {
                    if error.is_some() {
                        ui.style_mut().visuals.extreme_bg_color = super::INVALID_BG;
                    }
                    let field: &mut String = match cell.col_nr {
                        1 => &mut user.uname,
                        2 => &mut user.fname,
                        3 => &mut user.lname,
                        4 => &mut user.email,
                        _ => unreachable!(),
                    };
                    let response = ui.text_edit_singleline(field);
                    // Check changed() before on_hover_text consumes the response.
                    let changed = response.changed();
                    if let Some(msg) = error {
                        response.on_hover_text(msg);
                    }
                    changed
                }
                5 => ui.checkbox(&mut user.has_password, "").changed(),
                6 => ui.checkbox(&mut user.is_readonly, "").changed(),
                7 => ui.checkbox(&mut user.has_api_key, "").changed(),
                _ => false,
            }
        };

        if changed {
            self.changed_ids.insert(row);
        } else if self.draft[row] == self.users[row] {
            self.changed_ids.remove(&row);
        }
    }
}