ACLEditor/src/ui/editor.rs
2025-04-04 08:07:14 +02:00

559 lines
21 KiB
Rust
Executable File

use std::{
borrow::Borrow,
io,
path::PathBuf,
thread::{self, JoinHandle},
};
use anyhow::Result;
use ratatui::{
crossterm::event::{Event, KeyCode, KeyModifiers},
layout::{Constraint, Flex, Layout, Rect},
style::{Color, Style},
text::{Line, Span},
widgets::{Block, Clear, Paragraph, Row, Table, TableState, Wrap},
Frame,
};
use crate::helper::getent::{get_groups, get_users, Group, User};
use posix_acl::{ACLEntry, PermSet, PosixACL, Qualifier, ACL_RWX};
use super::Component;
pub struct ACLEditor {
path: PathBuf,
is_changed: bool,
available_users: Vec<User>,
available_groups: Vec<Group>,
acl: PosixACL,
pub exited: bool,
save_thread: Option<JoinHandle<Result<()>>>,
save_thread_error: Option<String>,
save_thread_ok: Option<String>,
table_state: TableState,
user_popup: Option<SelectUserOrGroupPopup<User>>,
group_popup: Option<SelectUserOrGroupPopup<Group>>,
focused: bool,
}
impl Default for ACLEditor {
fn default() -> Self {
Self {
path: PathBuf::new(),
is_changed: false,
available_groups: get_groups().unwrap(),
available_users: get_users().unwrap(),
acl: PosixACL::new(ACL_RWX, ACL_RWX, ACL_RWX),
exited: false,
save_thread: None,
save_thread_error: None,
save_thread_ok: None,
table_state: TableState::default(),
user_popup: None,
group_popup: None,
focused: false,
}
}
}
impl Component for ACLEditor {
fn update(&mut self, mut event: super::AppEventClient) {
if let Some(save_thread) = &self.save_thread {
// Mark all events as handled, since this task should not be interrupted
// by any other event
event.mark_as_handled();
if save_thread.is_finished() {
let save_thread = std::mem::replace(&mut self.save_thread, None).unwrap();
let result = save_thread.join();
match result {
Ok(Ok(_)) => {
self.save_thread_ok = Some("Saved successfully!".to_string());
self.save_thread_error = None;
self.is_changed = false;
}
Ok(Err(e)) => {
self.save_thread_error = Some(format!("Failed to save {:?}", e));
}
Err(e) => {
self.save_thread_error = Some(format!("Failed to join {:?}", e));
}
}
}
} else if self.save_thread_error.is_some() {
// TODO: Might want to do something, or not
if event.is_focused_and_key_pressed(KeyCode::Char('o'), None) {
event.mark_as_handled();
self.save_thread_error = None;
}
if event.has_focus() && self.save_thread_error.is_some() {
event.register_helper("o", "Accept error");
}
} else if self.save_thread_ok.is_some() {
if event.is_focused_and_key_pressed(KeyCode::Char('o'), None) {
event.mark_as_handled();
self.save_thread_ok = None;
}
if event.has_focus() && self.save_thread_ok.is_some() {
event.register_helper("o", "Accept message");
}
} else {
if let Some(user_popup) = &mut self.user_popup {
user_popup.update(event.get_client(true));
if user_popup.exited() {
if let Some(selected) = user_popup.get_result() {
self.acl
.entries
.push(ACLEntry(Qualifier::User(selected.uid), PermSet::ACL_RWX));
self.is_changed = true;
}
self.user_popup = None;
} else {
event.has_focus = false;
}
} else if let Some(group_popup) = &mut self.group_popup {
group_popup.update(event.get_client(true));
if group_popup.exited() {
if let Some(selected) = group_popup.get_result() {
self.acl
.entries
.push(ACLEntry(Qualifier::Group(selected.gid), PermSet::ACL_RWX));
self.is_changed = true;
}
self.group_popup = None;
} else {
event.has_focus = false;
}
}
if event.is_focused_and_key_pressed(KeyCode::Esc, None) {
event.mark_as_handled();
self.exited = true;
event.has_focus = false;
} else if event.is_focused_and_key_pressed(KeyCode::Up, None) {
event.mark_as_handled();
self.table_state.select_previous();
} else if event.is_focused_and_key_pressed(KeyCode::Down, None) {
event.mark_as_handled();
self.table_state.select_next();
} else if event.is_focused_and_key_pressed(KeyCode::Char('d'), None) {
event.mark_as_handled();
if let Some(selected) = self.table_state.selected() {
let _entry = self.acl.entries.remove(selected);
self.is_changed = true;
self.table_state.select(None);
}
} else if event.is_focused_and_key_pressed(KeyCode::Char('u'), None) {
event.mark_as_handled();
let mut popup = SelectUserOrGroupPopup::new_users();
popup.update(event.get_client(true));
self.user_popup = Some(popup);
event.has_focus = false;
} else if event.is_focused_and_key_pressed(KeyCode::Char('g'), None) {
event.mark_as_handled();
let mut popup = SelectUserOrGroupPopup::new_groups();
popup.update(event.get_client(true));
self.group_popup = Some(popup);
event.has_focus = false;
} else if event.is_focused_and_key_pressed(KeyCode::Char('a'), None) {
event.mark_as_handled();
let path = self.path.clone();
let mut acl = self.acl.clone();
acl.set(ACLEntry(Qualifier::Mask, PermSet::all())); // Make sure mask is set!
self.save_thread_error = None;
self.save_thread_ok = None;
self.save_thread = Some(std::thread::spawn(move || {
// thread::sleep(std::time::Duration::from_secs());
crate::helper::acl_writer::write_acl_recursive(path, acl)
}));
}
self.acl.entries.iter_mut().enumerate().for_each(|(i, e)| {
if self.table_state.selected() == Some(i) {
event
.is_focused_and_key_pressed(KeyCode::Char('x'), None)
.then(|| {
event.mark_as_handled();
if e.1.contains(PermSet::ACL_EXECUTE) {
e.1.remove(PermSet::ACL_EXECUTE);
} else {
e.1.insert(PermSet::ACL_EXECUTE);
}
self.is_changed = true;
});
event
.is_focused_and_key_pressed(KeyCode::Char('r'), None)
.then(|| {
event.mark_as_handled();
if e.1.contains(PermSet::ACL_READ) {
e.1.remove(PermSet::ACL_READ);
} else {
e.1.insert(PermSet::ACL_READ);
}
self.is_changed = true;
});
event
.is_focused_and_key_pressed(KeyCode::Char('w'), None)
.then(|| {
event.mark_as_handled();
if e.1.contains(PermSet::ACL_WRITE) {
e.1.remove(PermSet::ACL_WRITE);
} else {
e.1.insert(PermSet::ACL_WRITE);
}
self.is_changed = true;
});
}
});
if event.has_focus() {
event.register_helper("Esc", "Close editor");
event.register_helper("", "Select previous entry");
event.register_helper("", "Select next entry");
event.register_helper("r", "Toggle Read permission");
event.register_helper("w", "Toggle Write permission");
event.register_helper("x", "Toggle Execute permission");
event.register_helper("d", "Delete selected entry");
event.register_helper("u", "Create new user entry");
event.register_helper("g", "Create new group entry");
event.register_helper("a", "Apply changes");
}
}
self.focused = event.has_focus();
}
fn render(&mut self, f: &mut Frame, rect: ratatui::prelude::Rect) {
let block = Block::bordered()
.title(format!("Editing: {:?}", self.path))
.borders(ratatui::widgets::Borders::ALL)
.border_style(ratatui::style::Style::default().fg(if self.focused {
ratatui::style::Color::Cyan
} else {
ratatui::style::Color::Gray
}));
let inner_area = block.inner(rect);
f.render_widget(block, rect);
// todo!("Implement the rendering logic for ACLEditor");
if self.save_thread.is_some() {
let lines = vec![
Line::from(Span::styled("Saving...", Style::new().fg(Color::Yellow))),
// Line::from("Press any key to cancel..."),
];
let pg = Paragraph::new(lines).wrap(Wrap::default());
f.render_widget(pg, inner_area);
} else if let Some(ok) = &self.save_thread_ok {
let lines = vec![
Line::from(Span::styled(
"Saved successfully!",
Style::new().fg(Color::Green),
)),
Line::from(ok.as_str()),
// Line::from("Press any key to continue..."),
];
let pg = Paragraph::new(lines).wrap(Wrap::default());
f.render_widget(pg, inner_area);
} else if let Some(err) = &self.save_thread_error {
let lines = vec![
Line::from(Span::styled(
"Error while saving!",
Style::new().fg(Color::Red),
)),
Line::from(err.as_str()),
// Line::from("Press any key to continue..."),
];
let pg = Paragraph::new(lines).wrap(Wrap::default());
f.render_widget(pg, inner_area);
} else {
let layout = Layout::vertical([Constraint::Fill(1), Constraint::Fill(1)]);
let rects = layout.split(inner_area);
let acl_table = Table::new(
self.acl.entries.iter_mut().map(|e| {
let mut cols = vec![];
let label = match e.0 {
Qualifier::User(_) => "U",
Qualifier::Group(_) => "G",
Qualifier::Other => "",
Qualifier::GroupObj => "G",
Qualifier::UserObj => "U",
Qualifier::Mask => "",
};
cols.push(label.to_string());
let label = match e.0 {
Qualifier::User(uid) => self
.available_users
.iter()
.find(|user| user.uid == uid)
.map(|user| user.name.clone())
.unwrap_or_else(|| {
self.available_groups
.iter()
.find(|group| group.gid == uid)
.map(|group| format!("({})", group.name))
.unwrap_or_else(|| format!("Unknown user {}", uid))
}),
Qualifier::Group(gid) => self
.available_groups
.iter()
.find(|group| group.gid == gid)
.map(|group| group.name.clone())
.unwrap_or_else(|| {
self.available_users
.iter()
.find(|user| user.uid == gid)
.map(|user| format!("({})", user.name))
.unwrap_or_else(|| format!("Unknown group {}", gid))
}),
Qualifier::Other => "Jeder".to_string(),
Qualifier::GroupObj => "Gruppe".to_string(),
Qualifier::UserObj => "Benutzer".to_string(),
Qualifier::Mask => "Mask".to_string(),
};
cols.push(label);
let r = e.1.contains(PermSet::ACL_READ);
cols.push((if r { "" } else { "x" }).to_string());
let w = e.1.contains(PermSet::ACL_WRITE);
cols.push((if w { "" } else { "x" }).to_string());
let x = e.1.contains(PermSet::ACL_EXECUTE);
cols.push((if x { "" } else { "x" }).to_string());
Row::new(cols)
}),
[
Constraint::Length(1),
Constraint::Length(30),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
],
)
.header(Row::new(vec!["Type", "Name", "R", "W", "X"]))
.row_highlight_style(Style::default().bg(Color::Blue).fg(Color::White))
.highlight_symbol(">>");
if self.table_state.selected().is_none() && self.acl.entries.len() > 0 {
self.table_state.select(Some(0));
}
f.render_stateful_widget(acl_table, rects[0], &mut self.table_state);
if let Some(user_popup) = &mut self.user_popup {
user_popup.render(f, rects[1]);
} else if let Some(group_popup) = &mut self.group_popup {
group_popup.render(f, rects[1]);
}
}
}
}
impl ACLEditor {
pub fn new(path: impl Into<PathBuf>) -> Self {
let path: PathBuf = path.into();
let acl = PosixACL::new_from_file(&path, false).unwrap();
Self {
path,
acl,
..Self::default()
}
}
}
trait PermissionObject: Clone + std::fmt::Debug + Send + Sync {
fn get_name(&self) -> String;
}
impl PermissionObject for User {
fn get_name(&self) -> String {
self.name.clone()
}
}
impl PermissionObject for Group {
fn get_name(&self) -> String {
self.name.clone()
}
}
struct SelectUserOrGroupPopup<T: PermissionObject> {
search: String,
available_objects: Vec<T>,
selected: Option<T>,
exited: bool,
table_state: TableState,
}
impl SelectUserOrGroupPopup<User> {
fn new_users() -> Self {
Self {
search: String::new(),
available_objects: get_users().unwrap(),
selected: None,
table_state: TableState::default(),
exited: false,
}
}
}
impl SelectUserOrGroupPopup<Group> {
fn new_groups() -> Self {
Self {
search: String::new(),
available_objects: get_groups().unwrap(),
selected: None,
table_state: TableState::default(),
exited: false,
}
}
}
impl<T: PermissionObject> SelectUserOrGroupPopup<T> {
fn exited(&self) -> bool {
self.exited
}
fn get_result(&self) -> Option<T> {
self.selected.clone()
}
}
impl<T: PermissionObject> SelectUserOrGroupPopup<T> {
fn get_filtered(&self) -> Vec<T> {
let search = self.search.to_lowercase();
self.available_objects
.iter()
.filter(|obj| obj.get_name().to_lowercase().contains(&search))
.cloned()
.collect()
}
}
impl<T: PermissionObject> Component for SelectUserOrGroupPopup<T> {
fn update(&mut self, mut event: super::AppEventClient) {
if event.has_focus() && !event.was_handled() {
if event.is_focused_and_key_pressed(KeyCode::Up, None) {
event.mark_as_handled();
self.table_state.select_previous();
} else if event.is_focused_and_key_pressed(KeyCode::Down, None) {
event.mark_as_handled();
self.table_state.select_next();
} else if event.is_focused_and_key_pressed(KeyCode::Tab, None) {
event.mark_as_handled();
} else if event.is_focused_and_key_pressed(KeyCode::Enter, None) {
// TODO: Handle selection
event.mark_as_handled();
self.selected = self
.table_state
.selected()
.and_then(|idx| self.get_filtered().get(idx).cloned());
event.has_focus = false;
self.exited = true;
} else if let Some(evt) = &event.event {
if let Event::Key(key) = evt {
match key.code {
KeyCode::Esc => {
event.mark_as_handled();
self.selected = None;
self.exited = true;
event.has_focus = false;
}
KeyCode::Char(c) => {
if key.modifiers == KeyModifiers::empty() {
self.search.push(c);
event.mark_as_handled();
}
}
KeyCode::Backspace => {
self.search.pop();
event.mark_as_handled();
}
KeyCode::Tab => {
event.mark_as_handled();
}
_ => {}
}
}
}
}
if event.has_focus() {
event.register_helper("Ctrl + q", "Select user");
event.register_helper("Esc", "Close popup");
event.register_helper("", "Select previous entry");
event.register_helper("", "Select next entry");
event.register_helper("Enter", "Select entry");
event.register_helper("Tab", "Switch focus");
}
if self.table_state.selected().is_none() && self.get_filtered().len() > 0 {
self.table_state.select(Some(0));
}
}
fn render(&mut self, frame: &mut Frame, _rect: ratatui::prelude::Rect) {
let area = frame.area();
let popup_area = popup_area(area, 50, 50);
frame.render_widget(Clear, popup_area);
let block = Block::bordered()
.title("Select User or Group")
.borders(ratatui::widgets::Borders::ALL)
.border_style(ratatui::style::Style::default().fg(ratatui::style::Color::Cyan));
let inner_area = block.inner(popup_area);
frame.render_widget(block, popup_area);
let vertical = Layout::vertical([Constraint::Length(3), Constraint::Fill(1)]);
let [input_area, content_area] = vertical.areas(inner_area);
let input = Paragraph::new(self.search.as_str())
.block(
Block::default()
.title("Search")
.borders(ratatui::widgets::Borders::ALL),
)
.style(Style::default().fg(Color::White).bg(Color::Black));
frame.render_widget(input, input_area);
let content = Table::new(
self.get_filtered().iter().map(|user| {
let mut cols = vec![];
cols.push(user.get_name());
Row::new(cols)
}),
[Constraint::Fill(1)],
)
.header(Row::new(vec!["Name"]))
.row_highlight_style(Style::default().bg(Color::Blue).fg(Color::White))
.highlight_symbol(">>");
frame.render_stateful_widget(content, content_area, &mut self.table_state);
}
}
fn popup_area(area: Rect, percent_x: u16, percent_y: u16) -> Rect {
let vertical = Layout::vertical([Constraint::Percentage(percent_y)]).flex(Flex::Center);
let horizontal = Layout::horizontal([Constraint::Percentage(percent_x)]).flex(Flex::Center);
let [area] = vertical.areas(area);
let [area] = horizontal.areas(area);
area
}