Skip to main content

quorum_public/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 config;
5pub mod help;
6pub mod server;
7pub mod test;
8pub mod user;
9
10use crate::cli::config::{change_config_value, print_all};
11use crate::cli::server::confirm_and_delete;
12use colored::Colorize;
13use quorum_core::cli::AdminSession;
14use quorum_core::cli::db;
15use quorum_core::cli::server::{audit, logout, logs, shutdown, status};
16use quorum_core::db::DB;
17use std::io::{self, Write};
18use std::sync::{Arc, Mutex};
19use std::time::{Duration, Instant};
20use tokio::sync::watch;
21
22const SESSION_TIMEOUT_MINS: u64 = 20;
23
24struct Command {
25    parts: Vec<String>,
26    #[allow(dead_code)]
27    params: Vec<String>,
28    raw: String,
29}
30
31macro_rules! config_match_arms {
32    ($session:expr, $cmd:expr, $command:expr, $($field:ident),*) => {
33        match $command.as_str() {
34            "show" => {
35                if !require_admin($session) {
36                    return;
37                }
38                print_all();
39            }
40            $(
41                stringify!($field) => {
42                    if !require_admin($session) {
43                        return;
44                    }
45                    let value = $cmd.raw.split_once(' ').map(|x| x.1).unwrap_or("");
46                    change_config_value(stringify!($field), value);
47                }
48            )*
49            _ => unknown(&$cmd.raw),
50        }
51    };
52}
53
54/// Parses a command string into a Command struct.
55///
56/// Commands use colon between the command category and the actual command,
57/// making parsing very simple and easy to do.
58///
59/// # Arguments
60/// * `input` - A string slice that holds the command input.
61///
62/// # Returns
63/// * `Option<Command>` - Returns Some(Command) if parsing is successful, or None if the input is empty.
64fn parse(input: &str) -> Option<Command> {
65    let trimmed = input.trim();
66    if trimmed.is_empty() {
67        return None;
68    }
69
70    let (cmd_part, params_part) = match trimmed.split_once(' ') {
71        Some((cmd, params)) => (cmd, params.trim()),
72        None => (trimmed, ""),
73    };
74
75    let parts: Vec<String> = cmd_part
76        .split(':')
77        .map(|s| s.trim().to_lowercase())
78        .filter(|s| !s.is_empty())
79        .collect();
80
81    let params: Vec<String> = if params_part.is_empty() {
82        vec![]
83    } else {
84        params_part
85            .split(", ")
86            .map(|s| s.trim().to_string())
87            .filter(|s| !s.is_empty())
88            .collect()
89    };
90
91    Some(Command {
92        parts,
93        params,
94        raw: trimmed.to_string(),
95    })
96}
97
98/// Spawns the CLI in a separate blocking thread.
99///
100/// Runs an interactive command loop that listens for user input and dispatches commands
101/// to their respective handlers. The CLI operates on a dedicated thread to avoid blocking
102/// the async Tokio runtime, allowing the server to continue handling HTTP requests
103/// concurrently.
104///
105/// Session expiry is enforced—after 20 minutes of inactivity, users must re-authenticate
106/// with `server:login` before executing protected commands.
107///
108/// The CLI automatically exits when a shutdown signal is received via `shutdown_tx`.
109///
110/// # Arguments
111/// * `db` - A reference to the database connection, passed to all command handlers.
112/// * `server_start` - The instant when the server started, used for uptime calculations in `server:status`.
113/// * `shutdown_tx` - A watch channel sender that signals the CLI to exit when the server is shutting down.
114///
115/// # Example
116/// ```rust
117/// let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false);
118/// cli::spawn_cli(db, server_start, shutdown_tx).await;
119/// ```
120pub async fn spawn_cli(db: DB, server_start: Instant, shutdown_tx: watch::Sender<bool>) {
121    let session = Arc::new(Mutex::new(AdminSession::new()));
122    let handle = tokio::runtime::Handle::current();
123    let shutdown_rx = shutdown_tx.subscribe();
124
125    tokio::task::spawn_blocking(move || {
126        std::thread::sleep(Duration::from_millis(600));
127
128        loop {
129            // Check if shutdown was signaled
130            if shutdown_rx.has_changed().unwrap_or(false) {
131                break;
132            }
133
134            print!("{} ", ">".cyan().bold());
135            io::stdout().flush().unwrap();
136
137            let mut input = String::new();
138            match io::stdin().read_line(&mut input) {
139                Ok(0) => break, // EOF
140                Ok(_) => {}
141                Err(_) => break,
142            }
143
144            {
145                let mut sess = session.lock().unwrap();
146                if sess.logged_in && !sess.is_valid() {
147                    println!(
148                        "{}",
149                        "Session expired after 20 minutes of inactivity. Please run server:login again."
150                            .yellow()
151                    );
152                    sess.logout();
153                }
154            }
155
156            let cmd = match parse(&input) {
157                Some(c) => c,
158                None => continue,
159            };
160
161            handle.block_on(dispatch(&cmd, &db, server_start, &session, &shutdown_tx));
162        }
163    });
164}
165
166/// Dispatches the parsed command to the appropriate handler function.
167///
168/// # Arguments
169/// * `cmd` - A reference to the parsed Command struct.
170/// * `db` - A reference to the database connection.
171/// * `server_start` - The instant when the server started, used for uptime calculations.
172/// * `session` - A reference to the admin session, wrapped in Arc<Mutex<>> for thread-safe access.
173/// * `shutdown_tx` - A watch channel sender to signal server shutdown.
174async fn dispatch(
175    cmd: &Command,
176    db: &DB,
177    server_start: Instant,
178    session: &Arc<Mutex<AdminSession>>,
179    shutdown_tx: &watch::Sender<bool>,
180) {
181    match cmd.parts.as_slice() {
182        // -- help --
183        [ns] if ns == "help" => {
184            help::print_all();
185        }
186        [ns, command] if ns == "help" => {
187            help::print_command(command);
188        }
189
190        // -- Server --
191        [ns, command] if ns == "server" => match command.as_str() {
192            "login" => server::login(session),
193            "status" => status(server_start).await,
194            "logout" => logout(session),
195            "shutdown" => {
196                if !require_admin(session) {
197                    return;
198                }
199                shutdown(db, server_start, shutdown_tx).await;
200            }
201            "logs" => {
202                let params = cmd.raw.split_once(' ').map(|x| x.1).unwrap_or("");
203                logs(db, params).await;
204            }
205            "audit" => {
206                let params = cmd.raw.split_once(' ').map(|x| x.1).unwrap_or("");
207                audit(db, params).await;
208            }
209            "update-database" => {
210                if let Err(error) = server::reinitialize_schema(db).await {
211                    eprintln!(
212                        "{}",
213                        format!("Failed to reinitialize schema: {error}").red()
214                    );
215                }
216            }
217            _ => unknown(&cmd.raw),
218        },
219
220        // -- User --
221        [ns, command] if ns == "user" => match command.as_str() {
222            "delete" => {
223                if !require_admin(session) {
224                    return;
225                }
226
227                let id = cmd.raw.split_once(' ').map(|x| x.1).unwrap_or("");
228                user::delete(db, id).await;
229            }
230            _ => unknown(&cmd.raw),
231        },
232
233        // -- Test --
234        [ns, command] if ns == "test" => match command.as_str() {
235            "run" => test::run().await,
236            _ => unknown(&cmd.raw),
237        },
238
239        // -- Database --
240        [ns, command] if ns == "db" => match command.as_str() {
241            "stats" => db::stats(db).await,
242            "table" => {
243                let params = cmd.raw.split_once(' ').map(|x| x.1).unwrap_or("");
244                db::table(db, params).await;
245            }
246            "delete" => {
247                if !require_admin(session) {
248                    return;
249                }
250                confirm_and_delete(shutdown_tx).await;
251            }
252            _ => unknown(&cmd.raw),
253        },
254
255        // -- config --
256        [ns, command] if ns == "config" => {
257            config_match_arms!(
258                session,
259                cmd,
260                command,
261                server_port,
262                server_url,
263                server_host,
264                surreal_data_path,
265                surreal_ns,
266                surreal_db,
267                jwt_secret,
268                jwt_access_minutes,
269                jwt_refresh_days,
270                enable_testing,
271                default_per_second,
272                default_burst_size,
273                testing_per_second,
274                testing_burst_size
275            );
276        }
277
278        _ => unknown(&cmd.raw),
279    }
280}
281
282/// Checks if a command required admin privileges.
283///
284/// Uses the `AdminSession` built struct to determine if the current session is valid and has admin privileges.
285/// If not, it prints an error message and returns false, stopping the command from executing.
286///
287/// The intention of this functionality is so that if someone was to have gotten unauthorised access to the actively running server
288/// and try to run a command, this safeguards against them running anything dangerous or destructive without first authenticating as an admin user.
289/// Which without the correct account information of the admin user, they are unable to do any serious harm.
290///
291/// # Arguments
292/// * `session` - A reference to the admin session, wrapped in Arc<Mutex<>> for thread-safe access.
293///
294/// # Returns
295/// * `bool` - Returns true if the session is valid and has admin privileges, false otherwise.
296///
297/// # Example
298/// ```rust
299/// if !require_admin(session) {
300///     return;
301/// }
302///```
303pub fn require_admin(session: &Arc<Mutex<AdminSession>>) -> bool {
304    let sess = session.lock().unwrap();
305    if !sess.is_valid() {
306        println!(
307            "{}",
308            "This command requires authentication. Please run server:login first.".red()
309        );
310        return false;
311    }
312    if !sess.is_admin {
313        println!("{}", "This command requires admin privileges.".red());
314        return false;
315    }
316    true
317}
318
319/// Prints an error message for unknown commands.
320///
321/// # Arguments
322/// * `raw` - The raw command string that was not recognized.
323fn unknown(raw: &str) {
324    println!(
325        "{} {} {}",
326        "Unknown command:".red(),
327        raw.white(),
328        "- type `help` to see available commands.".dimmed()
329    );
330}