diff --git a/Cargo.lock b/Cargo.lock index 47cad4a..a645b13 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1568,6 +1568,7 @@ dependencies = [ "json", "native-tls", "once_cell", + "open", "pkcs8", "pnet", "rand 0.8.5", @@ -1626,6 +1627,25 @@ dependencies = [ "serde", ] +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + [[package]] name = "itertools" version = "0.12.1" @@ -2056,6 +2076,17 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" +[[package]] +name = "open" +version = "5.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43bb73a7fa3799b198970490a51174027ba0d4ec504b03cd08caf513d40024bc" +dependencies = [ + "is-wsl", + "libc", + "pathdiff", +] + [[package]] name = "openssl" version = "0.10.75" @@ -2144,6 +2175,12 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "pathdiff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" + [[package]] name = "pbkdf2" version = "0.12.2" diff --git a/Cargo.toml b/Cargo.toml index a88b8de..f2409c9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -58,3 +58,4 @@ strum = "0.27.2" strum_macros = "0.27.2" ratatui = "0.30.0" ratatui_input = "0.1.3" +open = "5.3.3" diff --git a/src/eula/mod.rs b/src/eula/mod.rs deleted file mode 100644 index 950fe79..0000000 --- a/src/eula/mod.rs +++ /dev/null @@ -1 +0,0 @@ -pub mod terms_checker; diff --git a/src/main.rs b/src/main.rs index 2fb1077..444622a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,17 +9,16 @@ use tokio::time::{Duration, sleep}; mod auth; mod communities; mod data; -mod eula; mod gui; mod langu; mod omikron; mod server; +mod terms; mod users; mod util; use crate::communities::community_manager; use crate::communities::interactables::registry; -use crate::eula::terms_checker; use crate::gui::app_state::AppState; use crate::gui::input_handler; use crate::gui::log_panel; @@ -29,6 +28,7 @@ use crate::langu::language_creator; use crate::langu::language_manager::format; use crate::omikron::omikron_connection::{OMIKRON_CONNECTION, OmikronConnection}; use crate::server::server::start; +use crate::terms::terms_checker; use crate::users::user_manager; use crate::util::config_util::CONFIG; use crate::util::file_util::has_dir; @@ -48,7 +48,7 @@ async fn main() { *SHUTDOWN.write().await = false; // EULA - let (tos, pp) = terms_checker::ConsentManager::check(); + let (tos, pp) = terms_checker::ConsentManager::check().await; if !tos { println!("You need to accept our End User Licence Agreement before launching!"); println!("You can find this at 'agreements'!"); @@ -58,6 +58,7 @@ async fn main() { println!( "Please accept our Privacy Policy & Terms of Serivce before using Tensamin Services!" ); + println!("In future releases this will be optional!"); println!("You can find this at 'agreements'!"); return; } diff --git a/src/terms/md_viewer.rs b/src/terms/md_viewer.rs new file mode 100644 index 0000000..dde8885 --- /dev/null +++ b/src/terms/md_viewer.rs @@ -0,0 +1,427 @@ +use crossterm::event::{self, Event, KeyCode}; +use ratatui::{ + DefaultTerminal, + prelude::*, + text::{Line, Span}, + widgets::{Block, Borders, Paragraph, Wrap}, +}; +use std::time::Duration; + +#[derive(Default)] +pub struct FileViewer { + title: String, + text: Vec, + scroll: u16, + scroll_x: u16, +} + +impl FileViewer { + pub fn new(title: String, content: &str) -> Self { + Self { + title, + text: parse_document(content.to_owned()), + scroll: 0, + scroll_x: 0, + } + } + pub fn force_popup(mut self, mut terminal: DefaultTerminal) -> DefaultTerminal { + loop { + terminal + .draw(|f| { + let area = f.area(); + self.draw(f, area); + }) + .unwrap(); + + if event::poll(Duration::from_millis(100)).unwrap() { + let ev = event::read().unwrap(); + self.handle_event(&ev); + + if matches!(ev, Event::Key(k) if k.code == KeyCode::Char('q')) { + break; + } + } + } + terminal + } + fn draw(&self, f: &mut Frame, area: Rect) { + use ratatui::text::Text; + + let mut rendered_lines = Vec::new(); + + for display_line in &self.text { + if display_line.scrollable { + let content: String = display_line + .line + .spans + .iter() + .map(|s| s.content.clone()) + .collect(); + + let start = self.scroll_x as usize; + let width = area.width as usize - 2; + + let visible = if start < content.chars().count() { + content.chars().skip(start).take(width).collect() + } else { + String::new() + }; + + let mut chars: Vec = visible.chars().collect(); + + if start > 0 && !chars.is_empty() { + chars[0] = '<'; + } + + if start + width < content.chars().count() && !chars.is_empty() { + let last = chars.len() - 1; + chars[last] = '>'; + } + + let visible: String = chars.into_iter().collect(); + + rendered_lines.push(Line::from(Span::styled( + visible, + display_line + .line + .spans + .first() + .map(|s| s.style) + .unwrap_or_default(), + ))); + } else { + rendered_lines.push(display_line.line.clone()); + } + } + + let paragraph = Paragraph::new(Text::from(rendered_lines)) + .block( + Block::default() + .borders(Borders::ALL) + .title(self.title.as_str()), + ) + .wrap(Wrap { trim: false }) + .scroll((self.scroll, 0)); + + f.render_widget(paragraph, area); + } + + pub fn handle_event(&mut self, event: &Event) { + if let Event::Key(key) = event { + match key.code { + KeyCode::Down => self.scroll = self.scroll.saturating_add(1), + KeyCode::Up => self.scroll = self.scroll.saturating_sub(1), + KeyCode::PageDown => self.scroll = self.scroll.saturating_add(10), + KeyCode::PageUp => self.scroll = self.scroll.saturating_sub(10), + KeyCode::Right => self.scroll_x = self.scroll_x.saturating_add(2), + KeyCode::Left => self.scroll_x = self.scroll_x.saturating_sub(2), + _ => {} + } + } + } +} +fn parse_document(input: String) -> Vec { + let mut lines_vec = Vec::new(); + let mut in_code_block = false; + let liness: Vec = input.lines().map(String::from).collect(); + let mut i = 0; + + while i < liness.len() { + let raw = &liness[i]; + + if raw.trim().starts_with("```") { + in_code_block = !in_code_block; + let code: String = if raw.trim().replace("```", "").is_empty() { + "──".to_string() + } else { + raw.trim().replace("```", "") + }; + lines_vec.push(DisplayLine { + line: Line::from(Span::styled( + format!("────────{}────────", code), + Style::default().fg(Color::DarkGray), + )), + scrollable: false, + }); + i += 1; + continue; + } + + if in_code_block { + lines_vec.push(DisplayLine { + line: Line::from(Span::styled( + raw.to_string(), + Style::default().fg(Color::Yellow), + )), + scrollable: false, + }); + i += 1; + continue; + } + if raw.starts_with("### ") { + lines_vec.push(DisplayLine { + line: Line::from(Span::styled( + raw.trim_start_matches("### ").to_string(), + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + )), + scrollable: false, + }); + i += 1; + continue; + } + if raw.starts_with("## ") { + lines_vec.push(DisplayLine { + line: Line::from(Span::styled( + raw.trim_start_matches("## ").to_string(), + Style::default() + .fg(Color::LightCyan) + .add_modifier(Modifier::BOLD), + )), + scrollable: false, + }); + i += 1; + continue; + } + if raw.starts_with("# ") { + lines_vec.push(DisplayLine { + line: Line::from(Span::styled( + raw.trim_start_matches("# ").to_string(), + Style::default() + .fg(Color::Gray) + .add_modifier(Modifier::BOLD), + )), + scrollable: false, + }); + i += 1; + continue; + } + + if raw.trim_start().starts_with("- ") { + let indent = raw.chars().take_while(|c| *c == ' ').count(); + lines_vec.push(DisplayLine { + line: Line::from(Span::raw(format!( + "{}• {}", + " ".repeat(indent), + raw.trim_start_matches("- ") + ))), + scrollable: false, + }); + i += 1; + continue; + } + + if raw.trim().starts_with('|') && raw.contains('|') { + let mut table_lines = vec![raw.clone()]; + let mut j = i + 1; + while j < liness.len() && liness[j].trim().starts_with('|') { + table_lines.push(liness[j].clone()); + j += 1; + } + + let table = parse_table(&table_lines.iter().map(|s| s.as_str()).collect::>()); + lines_vec.extend(table_to_lines(table)); + i = j; + continue; + } + + lines_vec.push(DisplayLine { + line: Line::from(parse_inline(raw.as_str())), + scrollable: false, + }); + i += 1; + } + + lines_vec +} + +fn parse_inline(input: &str) -> Vec> { + let mut spans = Vec::new(); + let mut buf = String::new(); + + let mut bold = false; + let mut underline = false; + let mut code = false; + + let mut chars = input.chars().peekable(); + + while let Some(c) = chars.next() { + let toggle = match c { + '*' if chars.peek() == Some(&'*') => { + chars.next(); + Some("bold") + } + '_' if chars.peek() == Some(&'_') => { + chars.next(); + Some("underline") + } + '`' => Some("code"), + _ => None, + }; + + if let Some(kind) = toggle { + flush_span(&mut spans, &mut buf, current_style(bold, underline, code)); + + match kind { + "bold" => bold = !bold, + "underline" => underline = !underline, + "code" => code = !code, + _ => {} + } + continue; + } + + buf.push(c); + } + + flush_span(&mut spans, &mut buf, current_style(bold, underline, code)); + spans +} + +fn current_style(bold: bool, underline: bool, code: bool) -> Style { + let mut style = Style::default(); + + if bold { + style = style.add_modifier(Modifier::BOLD); + } + if underline { + style = style.add_modifier(Modifier::UNDERLINED); + } + if code { + style = style.fg(Color::Yellow); + } + + style +} +#[derive(Clone)] +pub struct DisplayLine { + line: Line<'static>, + scrollable: bool, +} + +fn table_to_lines(table: Vec>) -> Vec { + if table.len() < 2 { + return vec![]; + } + + let header = &table[0]; + + let widths: Vec = header + .iter() + .enumerate() + .map(|(i, h)| { + let h_len = h.chars().count().max(1); + if i == 0 { + table + .iter() + .map(|row| row.get(i).map(|c| c.chars().count()).unwrap_or(0)) + .max() + .unwrap_or(h_len) + } else { + let max = (3 * h_len) as usize; + max.max(h_len) + } + }) + .collect(); + + let mut lines = Vec::new(); + + for (row_idx, row) in table.iter().enumerate() { + if row_idx == 1 { + let divider = widths + .iter() + .map(|w| "─".repeat(*w)) + .collect::>() + .join("─┼─"); + + lines.push(DisplayLine { + line: Line::from(Span::styled(divider, Style::default().fg(Color::DarkGray))), + scrollable: true, + }); + continue; + } + + let wrapped_cells: Vec> = row + .iter() + .enumerate() + .map(|(i, cell)| wrap_cell(cell, widths[i])) + .collect(); + + let row_height = wrapped_cells.iter().map(|c| c.len()).max().unwrap_or(1); + + for line_idx in 0..row_height { + let mut line = String::new(); + + for (i, cell) in wrapped_cells.iter().enumerate() { + let content = cell.get(line_idx).map(String::as_str).unwrap_or(""); + line.push_str(&format!("{:width$}", content, width = widths[i])); + if i < wrapped_cells.len() - 1 { + line.push_str(" │ "); + } + } + + let style = if row_idx == 0 { + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::Green) + }; + + lines.push(DisplayLine { + line: Line::from(Span::styled(line, style)), + scrollable: true, + }); + } + } + + lines +} +fn wrap_cell(cell: &str, width: usize) -> Vec { + if width == 0 { + return vec![String::new()]; + } + + let mut out = Vec::new(); + let mut chars = cell.chars(); + + loop { + let line: String = chars.by_ref().take(width).collect(); + if line.is_empty() { + break; + } + out.push(line); + } + + if out.is_empty() { + out.push(String::new()); + } + + out +} + +fn flush_span(spans: &mut Vec, buf: &mut String, style: Style) { + if !buf.is_empty() { + spans.push(Span::styled(buf.clone(), style)); + buf.clear(); + } +} + +fn parse_table(lines: &[&str]) -> Vec> { + let mut table = Vec::new(); + + for &line in lines { + if !line.starts_with('|') || !line.contains('|') { + break; + } + let row: Vec = line + .trim_matches('|') + .split('|') + .map(|s| s.trim().to_string()) + .collect(); + table.push(row); + } + + table +} diff --git a/src/terms/mod.rs b/src/terms/mod.rs new file mode 100644 index 0000000..7cf232b --- /dev/null +++ b/src/terms/mod.rs @@ -0,0 +1,3 @@ +pub mod md_viewer; +pub mod terms_checker; +pub mod terms_getter; diff --git a/src/eula/terms_checker.rs b/src/terms/terms_checker.rs similarity index 79% rename from src/eula/terms_checker.rs rename to src/terms/terms_checker.rs index c2ca9c6..368eab3 100644 --- a/src/eula/terms_checker.rs +++ b/src/terms/terms_checker.rs @@ -1,4 +1,10 @@ -use crate::util::file_util::{load_file, save_file}; +use crate::{ + terms::{ + md_viewer::FileViewer, + terms_getter::{Type, get_link, get_terms}, + }, + util::file_util::{load_file, save_file}, +}; use crossterm::event::{self, Event, KeyCode}; use ratatui::{ layout::{Alignment, Constraint, Direction, Layout, Rect}, @@ -11,14 +17,14 @@ use std::time::{SystemTime, UNIX_EPOCH}; pub struct ConsentManager; impl ConsentManager { - pub fn check() -> (bool, bool) { + pub async fn check() -> (bool, bool) { let file = load_file("", "agreements"); let existing = ConsentUiState::from_str(&file).sanitize(); let final_state = if existing.eula { existing } else { - let choice = run_consent_ui(); + let choice = run_consent_ui().await; let state = match choice { UserChoice::Deny => ConsentUiState::denied(), UserChoice::AcceptEULA => ConsentUiState { @@ -92,11 +98,11 @@ impl ConsentUiState { .unwrap() .as_secs(); format!( - "\"EULA=true\" indicates that you read and accepted the End User Licence agreement. You can find our EULA at https://docs.tensamin.net/legal/eula/\ + "\"EULA=true\" indicates that you read and accepted the End User Licence agreement. You can find our EULA at https://legal.tensamin.net/eula/\ \nEULA={}\ - \n\"PrivacyPolicy=true\" indicates that you read and accepted the Privacy Policy. You can find our Privacy Policy at https://docs.tensamin.net/legal/privacy-policy/\ + \n\"PrivacyPolicy=true\" indicates that you read and accepted the Privacy Policy. You can find our Privacy Policy at https://legal.tensamin.net/privacy-policy/\ \nPrivacyPolicy={}\ - \n\"ToS=true\" indicates that you read and accepted the Terms of Service. You can find our Terms of Service at https://docs.tensamin.net/legal/terms-of-service/\ + \n\"ToS=true\" indicates that you read and accepted the Terms of Service. You can find our Terms of Service at https://legal.tensamin.net/terms-of-service/\ \nToS={}\ \nThis file reflects the current consent state used by the application.\ \nIt may be regenerated or overwritten by the application.\ @@ -165,7 +171,7 @@ impl ConsentUiState { } } -fn run_consent_ui() -> UserChoice { +async fn run_consent_ui() -> UserChoice { let mut terminal = ratatui::init(); let mut state = ConsentUiState { @@ -176,6 +182,7 @@ fn run_consent_ui() -> UserChoice { }; let result = loop { + let mut too_small = false; terminal .draw(|f| { let mut needed_height = 5; @@ -206,47 +213,52 @@ fn run_consent_ui() -> UserChoice { width: content_width, height: content_height, }); - let eula_text = if size.width < 76 { - "EULA ¹ (https://docs.tensamin.net/legal/eula/)" + let eula_text = if size.width < 70 { + "EULA ¹ (https://legal.tensamin.net/eula/)" } else { - "End User Licence Agreement ¹ (https://docs.tensamin.net/legal/eula/)" + "End User Licence Agreement ¹ (https://legal.tensamin.net/eula/)" }; - let tos_text = if size.width < 76 { - "ToS ² (https://docs.tensamin.net/legal/terms-of-service/)" + let tos_text = if size.width < 72 { + "ToS ² (https://legal.tensamin.net/terms-of-service/)" } else { - "Terms of Service ² (https://docs.tensamin.net/legal/terms-of-service/)" + "Terms of Service ² (https://legal.tensamin.net/terms-of-service/)" }; - let pp_text = if size.width < 76 { - "PP ² (https://docs.tensamin.net/legal/privacy-policy/)" + let pp_text = if size.width < 68 { + "PP ² (https://legal.tensamin.net/privacy-policy/)" } else { - "Privacy Policy ² (https://docs.tensamin.net/legal/privacy-policy/)" + "Privacy Policy ² (https://legal.tensamin.net/privacy-policy/)" }; let (mut optional_lines, agree_lines): (Vec, Vec<&str>) = if size.width > 132 { ( - vec![7, 3, 4], + vec![7, 3, 7, 4], vec![ "", "By selecting Continue, you confirm that you have read and agree to the End User License Agreement and applicable Terms of Service.", "", "Tensamin services require acceptance of the Terms of Service and Privacy Policy.", + "", + "While having a document selected press O to view in this UI or press L to open as a link.", ] ) } else if size.width > 68 { ( - vec![8, 3, 4], + vec![8, 3, 8, 4], vec![ "", "By selecting Continue, you confirm that you have read and agree", "to the End User License Agreement and applicable Terms of Service.", "", "Tensamin services require acceptance of the ToS and Privacy Policy.", + "", + "While having a document selected press O to view in this UI or", + "press L to open as a link.", ] ) } else { ( - vec![9, 3, 4], + vec![9, 3, 10, 4], vec![ "", "By selecting Continue, you confirm that you have", @@ -254,7 +266,10 @@ fn run_consent_ui() -> UserChoice { "and applicable Terms of Service.", "", "Tensamin services require acceptance of the", - "Terms of Service and Privacy Policy." + "Terms of Service and Privacy Policy.", + "", + "While having a document selected press O to view", + "in this UI or press L to open as a link.", ] ) }; @@ -281,33 +296,33 @@ fn run_consent_ui() -> UserChoice { - if size.width < 63 || size.height < needed_height as u16 { + if size.width < 60 || size.height < needed_height as u16 { let width_style = if size.width > 76 { Style::default().fg(Color::Green) - } else if size.width >= 63 { + } else if size.width >= 60 { Style::default().fg(Color::Yellow) } else { Style::default().fg(Color::Red) }; - let height_style = if size.height < needed_height as u16 { + let height_style = if size.height < 12 { Style::default().fg(Color::Red) - } else if size.height < 17 { - Style::default().fg(Color::Yellow) - } else { + } else if size.height > 19 { Style::default().fg(Color::Green) + } else { + Style::default().fg(Color::Yellow) }; let warning_text = Text::from(vec![ Line::from(vec![ Span::raw("Width: "), Span::styled(format!("{}", size.width), width_style), - Span::raw(" / 63"), + Span::raw(" / 60"), ]), Line::from(vec![ Span::raw("Height: "), Span::styled(format!("{}", size.height), height_style), - Span::raw(format!(" / {}", needed_height)), + Span::raw(format!(" / 12")), ]), ]); @@ -319,6 +334,7 @@ fn run_consent_ui() -> UserChoice { .title("UI Too Small (Q to Quit)"), ); + too_small = true; f.render_widget(warning, size); return; } @@ -333,11 +349,47 @@ fn run_consent_ui() -> UserChoice { if event::poll(Duration::from_millis(200)).unwrap() { if let Event::Key(key) = event::read().unwrap() { + if too_small { + if matches!(key.code, KeyCode::Char('q') | KeyCode::Char('Q')) { + break UserChoice::Deny; + } else { + continue; + } + } match key.code { KeyCode::Esc => break UserChoice::Deny, KeyCode::Up => state.prev(), KeyCode::Down | KeyCode::Tab => state.next(), KeyCode::Char('q') | KeyCode::Char('Q') => break UserChoice::Deny, + KeyCode::Char('o') | KeyCode::Char('O') => { + let terms_type = match state.focus { + Focus::Eula => Type::EULA, + Focus::Tos => Type::TOS, + Focus::Pp => Type::PP, + _ => continue, + }; + + if let Some(eula) = get_terms(terms_type.clone()).await { + terminal = FileViewer::new(terms_type.to_string(), &eula) + .force_popup(terminal); + } else { + terminal = FileViewer::new( + terms_type.to_string(), + "### A loading error occured", + ) + .force_popup(terminal); + } + } + KeyCode::Char('l') | KeyCode::Char('L') => { + let terms_type = match state.focus { + Focus::Eula => Type::EULA, + Focus::Tos => Type::TOS, + Focus::Pp => Type::PP, + _ => continue, + }; + + let _ = open::that(get_link(terms_type)); + } KeyCode::Char(' ') => match state.focus { Focus::Eula => { state.eula = !state.eula; diff --git a/src/terms/terms_getter.rs b/src/terms/terms_getter.rs new file mode 100644 index 0000000..5f8958c --- /dev/null +++ b/src/terms/terms_getter.rs @@ -0,0 +1,39 @@ +#[derive(Clone)] +pub enum Type { + EULA, + TOS, + PP, +} + +impl Type { + pub fn to_str(&self) -> &str { + match self { + Self::EULA => "eula", + Self::TOS => "tos", + Self::PP => "pp", + } + } + pub fn to_string(&self) -> String { + match self { + Self::EULA => "End User License Agreement".to_string(), + Self::TOS => "Terms of Service".to_string(), + Self::PP => "Privacy Policy".to_string(), + } + } +} +pub fn get_link(terms_type: Type) -> String { + format!("https://legal.tensamin.net/{}/", terms_type.to_str()) +} +pub async fn get_terms(terms_type: Type) -> Option { + let body = reqwest::get(format!( + "https://legal.tensamin.net/api/text/{}/", + terms_type.to_str() + )) + .await + .ok()? + .text() + .await + .ok()?; + + Some(body) +}