Skip to main content

quorum_core/cli/
mod.rs

1//! Main entry point for the server cli Commands
2//! This is where commands are defined properly, and where command functions are called.
3
4pub mod db;
5pub mod help;
6pub mod server;
7
8use crate::db::DB;
9use colored::Colorize;
10use std::io::{self, Write};
11use std::sync::{Arc, Mutex};
12use std::time::{Duration, Instant};
13use tokio::sync::watch;
14
15const SESSION_TIMEOUT_MINS: u64 = 20;
16
17pub struct AdminSession {
18    pub logged_in: bool,
19    pub last_active: Instant,
20    pub username: Option<String>,
21    pub is_admin: bool,
22}
23
24struct Command {
25    parts: Vec<String>,
26    #[allow(dead_code)]
27    params: Vec<String>,
28    raw: String,
29}
30
31impl Default for AdminSession {
32    fn default() -> Self {
33        Self {
34            logged_in: false,
35            is_admin: false,
36            last_active: Instant::now(),
37            username: None,
38        }
39    }
40}
41
42/// Represents the state of an admin session in the CLI.
43impl AdminSession {
44    pub fn new() -> Self {
45        Self::default()
46    }
47
48    pub fn is_valid(&self) -> bool {
49        self.logged_in
50            && self.last_active.elapsed() < Duration::from_secs(SESSION_TIMEOUT_MINS * 60)
51    }
52
53    pub fn update_is_admin(&mut self, is_admin: bool) {
54        self.is_admin = is_admin;
55    }
56
57    pub fn login(&mut self, username: String, is_admin: bool) {
58        self.logged_in = true;
59        self.username = Some(username);
60        self.is_admin = is_admin;
61        self.last_active = Instant::now();
62    }
63
64    pub fn logout(&mut self) {
65        self.logged_in = false;
66        self.username = None;
67    }
68}
69
70/// Parses a command string into a Command struct.
71///
72/// Commands use colon between the command category and the actual command,
73/// making parsing very simple and easy to do.
74///
75/// # Arguments
76/// * `input` - A string slice that holds the command input.
77///
78/// # Returns
79/// * `Option<Command>` - Returns Some(Command) if parsing is successful, or None if the input is empty.
80fn parse(input: &str) -> Option<Command> {
81    let trimmed = input.trim();
82    if trimmed.is_empty() {
83        return None;
84    }
85
86    let (cmd_part, params_part) = match trimmed.split_once(' ') {
87        Some((cmd, params)) => (cmd, params.trim()),
88        None => (trimmed, ""),
89    };
90
91    let parts: Vec<String> = cmd_part
92        .split(':')
93        .map(|s| s.trim().to_lowercase())
94        .filter(|s| !s.is_empty())
95        .collect();
96
97    let params: Vec<String> = if params_part.is_empty() {
98        vec![]
99    } else {
100        params_part
101            .split(", ")
102            .map(|s| s.trim().to_string())
103            .filter(|s| !s.is_empty())
104            .collect()
105    };
106
107    Some(Command {
108        parts,
109        params,
110        raw: trimmed.to_string(),
111    })
112}
113
114/// Spawns the CLI in a separate asynchronous task.
115///
116/// The server sits on the main thread/task, whilst the CLI runs on a seperate task to avoid holding the server from handling requests.
117///
118/// # Arguments
119/// * `db` - A reference to the database connection.
120/// * `server_start` - The instant when the server started, used for uptime calculations.
121/// * `shutdown_tx` - A watch channel sender to signal server shutdown.
122///
123/// # Example
124/// ```rust
125/// let db = DB::new("sqlite:memory:").await.unwrap();
126/// let server_start = Instant::now();
127/// let (shutdown_tx, shutdown_rx) = watch::channel(false);
128/// spawn_cli(db, server_start, shutdown_tx);
129///```
130pub async fn spawn_cli(db: DB, server_start: Instant, shutdown_tx: watch::Sender<bool>) {
131    let session = Arc::new(Mutex::new(AdminSession::new()));
132    let handle = tokio::runtime::Handle::current();
133
134    tokio::task::spawn_blocking(move || {
135        std::thread::sleep(Duration::from_millis(600));
136
137        loop {
138            print!("{} ", ">".cyan().bold());
139            io::stdout().flush().unwrap();
140
141            let mut input = String::new();
142            match io::stdin().read_line(&mut input) {
143                Ok(0) => break, // EOF
144                Ok(_) => {}
145                Err(_) => break,
146            }
147
148            {
149                let mut sess = session.lock().unwrap();
150                if sess.logged_in && !sess.is_valid() {
151                    println!(
152                        "{}",
153                        "Session expired after 20 minutes of inactivity. Please run server:login again."
154                            .yellow()
155                    );
156                    sess.logout();
157                }
158            }
159
160            let cmd = match parse(&input) {
161                Some(c) => c,
162                None => continue,
163            };
164
165            //Uses OS thread, where block_on is safe. This is used to ensure server commands work whilst accepting requests
166            handle.block_on(dispatch(&cmd, &db, server_start, &session, &shutdown_tx));
167        }
168    });
169}
170
171/// Dispatches the parsed command to the appropriate handler function.
172///
173/// # Arguments
174/// * `cmd` - A reference to the parsed Command struct.
175/// * `db` - A reference to the database connection.
176/// * `server_start` - The instant when the server started, used for uptime calculations.
177/// * `session` - A reference to the admin session, wrapped in Arc<Mutex<>> for thread-safe access.
178/// * `shutdown_tx` - A watch channel sender to signal server shutdown.
179async fn dispatch(
180    cmd: &Command,
181    db: &DB,
182    server_start: Instant,
183    session: &Arc<Mutex<AdminSession>>,
184    shutdown_tx: &watch::Sender<bool>,
185) {
186    match cmd.parts.as_slice() {
187        // -- help --
188        [ns] if ns == "help" => {
189            help::print_all();
190        }
191        [ns, command] if ns == "help" => {
192            help::print_command(command);
193        }
194
195        // -- server --
196        [ns, command] if ns == "server" => match command.as_str() {
197            "status" => server::status(server_start).await,
198            "logout" => server::logout(session),
199            "shutdown" => {
200                if !require_admin(session) {
201                    return;
202                }
203                server::shutdown(db, server_start, shutdown_tx).await;
204            }
205            "logs" => {
206                let params = cmd.raw.split_once(' ').map(|x| x.1).unwrap_or("");
207                server::logs(db, params).await;
208            }
209            "audit" => {
210                let params = cmd.raw.split_once(' ').map(|x| x.1).unwrap_or("");
211                server::audit(db, params).await;
212            }
213            _ => unknown(&cmd.raw),
214        },
215
216        // -- Database --
217        [ns, command] if ns == "db" => match command.as_str() {
218            "stats" => db::stats(db).await,
219            "table" => {
220                let params = cmd.raw.split_once(' ').map(|x| x.1).unwrap_or("");
221                db::table(db, params).await;
222            }
223            _ => unknown(&cmd.raw),
224        },
225
226        _ => unknown(&cmd.raw),
227    }
228}
229
230/// Checks if a command required admin privileges.
231///
232/// Uses the `AdminSession` built struct to determine if the current session is valid and has admin privileges.
233/// If not, it prints an error message and returns false, stopping the command from executing.
234///
235/// The intention of this functionality is so that if someone was to have gotten unauthorised access to the actively running server
236/// and try to run a command, this safeguards against them running anything dangerous or destructive without first authenticating as an admin user.
237/// Which without the correct account information of the admin user, they are unable to do any serious harm.
238///
239/// # Arguments
240/// * `session` - A reference to the admin session, wrapped in Arc<Mutex<>> for thread-safe access.
241///
242/// # Returns
243/// * `bool` - Returns true if the session is valid and has admin privileges, false otherwise.
244///
245/// # Example
246/// ```rust
247/// if !require_admin(session) {
248///     return;
249/// }
250///```
251pub fn require_admin(session: &Arc<Mutex<AdminSession>>) -> bool {
252    let sess = session.lock().unwrap();
253    if !sess.is_valid() {
254        println!(
255            "{}",
256            "This command requires authentication. Please run server:login first.".red()
257        );
258        return false;
259    }
260    if !sess.is_admin {
261        println!("{}", "This command requires admin privileges.".red());
262        return false;
263    }
264    true
265}
266
267/// Prints an error message for unknown commands.
268///
269/// # Arguments
270/// * `raw` - The raw command string that was not recognized.
271fn unknown(raw: &str) {
272    println!(
273        "{} {} {}",
274        "Unknown command:".red(),
275        raw.white(),
276        "- type `help` to see available commands.".dimmed()
277    );
278}