diff --git a/Cargo.lock b/Cargo.lock index 5337dc0..f3c37e7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -80,6 +80,15 @@ dependencies = [ "thiserror", ] +[[package]] +name = "ansi_term" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d52a9bb7ec0cf484c551830a7ce27bd20d67eac647e1befb56b0be4ee39a55d2" +dependencies = [ + "winapi", +] + [[package]] name = "anstream" version = "0.6.13" @@ -1982,8 +1991,9 @@ dependencies = [ [[package]] name = "ontodev_valve" version = "0.2.2" -source = "git+https://github.com/ontodev/valve.rs?rev=824fbd79cd5ff787d8863186ccda09d0a0b56eb2#824fbd79cd5ff787d8863186ccda09d0a0b56eb2" +source = "git+https://github.com/ontodev/valve.rs?rev=94504e2604c3266d5e6a2abed18348b4e6a2ce4d#94504e2604c3266d5e6a2abed18348b4e6a2ce4d" dependencies = [ + "ansi_term", "anyhow", "async-recursion", "async-std", diff --git a/Cargo.toml b/Cargo.toml index fa585d3..766284f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -48,7 +48,7 @@ rev = "f46fbd5450505644ed9970cef1ae14164699981f" [dependencies.ontodev_valve] # path = "../ontodev_demo/valve.rs" git = "https://github.com/ontodev/valve.rs" -rev = "824fbd79cd5ff787d8863186ccda09d0a0b56eb2" +rev = "94504e2604c3266d5e6a2abed18348b4e6a2ce4d" [dependencies.ontodev_sqlrest] git = "https://github.com/ontodev/sqlrest.rs" diff --git a/Makefile b/Makefile index f510158..38b42e7 100644 --- a/Makefile +++ b/Makefile @@ -69,7 +69,7 @@ test-code: src/javascript/build/main.js cargo test .PHONY: test-docs -test-docs: +test-docs: target/debug/nanobot PATH="$${PATH}:$$(pwd)/target/debug"; tesh --debug false ./doc .PHONY: test @@ -136,11 +136,12 @@ endif BINARY_PATH := build/$(BINARY) # Build a Linux binary using Musl instead of GCC. -target/x86_64-unknown-linux-musl/release/nanobot: src/*.rs +target/x86_64-unknown-linux-musl/release/nanobot: Cargo.* src/*.rs docker pull clux/muslrust:stable docker run \ -v cargo-cache:/root/.cargo/registry \ -v $$PWD:/volume \ + -v /home/knocean/valve.rs:/valve.rs \ --rm -t clux/muslrust:stable \ cargo build --release diff --git a/doc/cgi.md b/doc/cgi.md new file mode 100644 index 0000000..e6137fd --- /dev/null +++ b/doc/cgi.md @@ -0,0 +1,161 @@ +# CGI: Common Gateway Interface + +Nanobot can be run as a +[CGI script](https://en.wikipedia.org/wiki/Common_Gateway_Interface). +This is an old-fashioned but simple and flexible +way to deploy a web application. +It's particularly easy to call use Nanobot CGI from a "wrapper" server, +written in another language, such as Python. + +To run Nanobot as a CGI script +you just execute the `nanobot` binary, +with some specific environment variables +and optional `STDIN` input, +and it will return an HTTP response on `STDOUT`. + +The CGI environment variables are: + +- `GATEWAY_INTERFACE` set to `CGI/1.1` -- required +- `REQUEST_METHOD` for the HTTP method, defaults to `GET` +- `PATH_INFO` with the path part of the URL, defaults to `/table` +- `QUERY_STRING` with the query string part of the URL, which is optional + +Nanobot also checks these environment variables: + +- `NANOBOT_READONLY`: when `TRUE` no editing is supporting; defaults to `FALSE` + +Nanobot will return an +[HTTP response](https://en.wikipedia.org/wiki/HTTP#HTTP/1.1_response_messages), +with a status line, +zero or more lines of HTTP headers, +and blank line, +and an optional message body +which will usually contain the HTML or JSON response content. + +Nanobot's CGI mode works by +starting the same HTTP server used for `nanobot serve` on a random port, +executing the request, +and printing the response to `STDOUT`. +This is much less efficient than a long-running server, +but it's very simple +and works well enough for low volumes of traffic. + +You can test Nanobot CGI from the command-line like so: + +```console tesh-session="cgi" +$ nanobot init +Initialized a Nanobot project +$ GATEWAY_INTERFACE=CGI/1.1 PATH_INFO=/table.txt nanobot +status: 200 OK +content-type: text/plain +content-length: 291 +date: ... + +table path type description +table src/schema/table.tsv table All of the tables in this project. +column src/schema/column.tsv column Columns for all of the tables. +datatype src/schema/datatype.tsv datatype Datatypes for all of the columns +``` + +We can POST a new row using CGI and form contents as `STDIN`: + +```console tesh-session="cgi" +$ export GATEWAY_INTERFACE=CGI/1.1 +$ REQUEST_METHOD=POST PATH_INFO=/table nanobot <<< 'action=submit&table=foo' +... +$ PATH_INFO=/table.txt nanobot +status: 200 OK +content-type: text/plain +content-length: 337 +date: ... + +table path type description +table src/schema/table.tsv table All of the tables in this project. +column src/schema/column.tsv column Columns for all of the tables. +datatype src/schema/datatype.tsv datatype Datatypes for all of the columns +foo +``` + +When `NANOBOT_READONLY` is `TRUE`, +POSTing will not work, +and the WebUI will not include buttons for editing actions. + +```console tesh-session="cgi" +$ export NANOBOT_READONLY=TRUE +$ REQUEST_METHOD=POST PATH_INFO=/table nanobot <<< 'action=submit&table=bar' +status: 403 Forbidden +content-type: text/html; charset=utf-8 +content-length: 13 +date: ... + +403 Forbidden +``` + +## Python + +You can run Nanobot CGI from any language that can "shell out" to another process. +In Python, you can use the `subprocess` module, like so: + +```python +import subprocess + +result = subprocess.run( + ['bin/nanobot')], + env={ + 'GATEWAY_INTERFACE': 'CGI/1.1', + 'REQUEST_METHOD': 'GET', + 'PATH_INFO': path, + 'QUERY_STRING': request.query_string, + }, + input=request.body.getvalue().decode('utf-8'), + text=True, + capture_output=True +) +print(result.stdout) +``` + +If you're already running a Python server, +such as Flask or Bottle, +that provides a `request` and `response`, +then you can "wrap" Nanobot inside the Python server +with code similar to this: + +```python +import subprocess +from bottle import request, response + +def run_cgi(path): + # Run nanobot as a CGI script. + result = subprocess.run( + ['bin/nanobot')], + env={ + 'GATEWAY_INTERFACE': 'CGI/1.1', + 'REQUEST_METHOD': 'GET', + 'PATH_INFO': path, + 'QUERY_STRING': request.query_string, + }, + input=request.body.getvalue().decode('utf-8'), + text=True, + capture_output=True + ) + # Parse the HTTP response: status, headers, blank line, body. + reading_headers = True + body = [] + for line in result.stdout.splitlines(): + # Watch for the blank line that separates HTTP headers from the body. + if reading_headers and line.strip() == '': + reading_headers = False + continue + # Add each HTTP header to the `response`. + if reading_headers: + name, value = line.split(': ', 1) + if name == 'status': + response.status = value + else: + response.set_header(name, value) + # Add all remanining lines to the `response` body. + else: + body.append(line) + # The `response` is set, so just return the body. + return '\n'.join(body) +``` diff --git a/src/config.rs b/src/config.rs index 5c1d038..0410b64 100644 --- a/src/config.rs +++ b/src/config.rs @@ -18,6 +18,7 @@ pub struct Config { pub pool: Option, pub valve: Option, pub valve_path: String, + pub editable: bool, pub create_only: bool, pub asset_path: Option, pub template_path: Option, @@ -319,6 +320,7 @@ impl Config { .unwrap_or_default() .path .unwrap_or("src/schema/table.tsv".into()), + editable: true, create_only: false, asset_path: { match user.assets.unwrap_or_default().path { @@ -376,6 +378,11 @@ impl Config { self } + pub fn editable(&mut self, value: bool) -> &mut Config { + self.editable = value; + self + } + pub fn create_only(&mut self, value: bool) -> &mut Config { self.create_only = value; self diff --git a/src/get.rs b/src/get.rs index 05ff757..d45fe80 100644 --- a/src/get.rs +++ b/src/get.rs @@ -177,7 +177,8 @@ async fn get_page( for col_config in column_configs.iter() { let key = col_config.column.to_string(); let mut cmap_entry = json!(col_config).as_object_mut().unwrap().clone(); - let sql_type = toolkit::get_sql_type_from_global_config(&valve.config, table, &key, &pool); + let sql_type = + toolkit::get_sql_type_from_global_config(&valve.config, table, &key, &valve.db_kind); // Get table.column that use this column as a foreign key constraint // and insert as "links". @@ -410,19 +411,14 @@ async fn get_page( let end = select.offset.unwrap_or(0) + cell_rows.len(); // Start with the VALVE table config, minus 'column' and 'column_order'. - let this_table_config = valve.config.table.get(&unquoted_table).unwrap(); - let this_table_config = json!(this_table_config); - let mut this_table_config = this_table_config.as_object().unwrap().clone(); - this_table_config.remove("column"); - this_table_config.remove("column_order"); - // Try to get Nanobot table config: will fail for "message" and "history" tables. - let mut this_table = config - .table - .iter() - .filter(|x| x.get("table").unwrap() == &json!(unquoted_table)) - .next() - .unwrap_or(&this_table_config) - .clone(); + let this_table = json!(table_config); + let mut this_table = this_table.as_object().unwrap().clone(); + this_table.remove("column"); + this_table.remove("column_order"); + this_table.insert( + "editable".to_string(), + json!(table_config.options.contains("edit")), + ); this_table.insert("table".to_string(), json!(unquoted_table.clone())); this_table.insert("href".to_string(), json!(unquoted_table.clone())); this_table.insert("start".to_string(), json!(select.offset.unwrap_or(0) + 1)); @@ -606,6 +602,7 @@ async fn get_page( let result: Value = json!({ "page": { "project_name": "Nanobot", + "editable": config.editable, "tables": tables, "title": unquoted_table, "url": select2.to_url().unwrap_or_default(), @@ -766,7 +763,8 @@ pub fn get_undo_message(config: &Config) -> Option { } Some(valve) => valve, }; - let change = block_on(valve.get_change_to_undo()).ok()??; + let changes = block_on(valve.get_changes_to_undo(1)).ok()?; + let change = changes.into_iter().nth(1)?; Some(String::from(format!("Undo '{}'", change.message))) } @@ -779,7 +777,8 @@ pub fn get_redo_message(config: &Config) -> Option { } Some(valve) => valve, }; - let change = block_on(valve.get_change_to_redo()).ok()??; + let changes = block_on(valve.get_changes_to_redo(1)).ok()?; + let change = changes.into_iter().nth(1)?; Some(String::from(format!("Redo '{}'", change.message))) } diff --git a/src/init.rs b/src/init.rs index 9235f45..a7c2b73 100644 --- a/src/init.rs +++ b/src/init.rs @@ -197,11 +197,11 @@ pub async fn init(config: &mut Config) -> Result { }; // Create and/or load tables into database - match &config.valve { + match &mut config.valve { None => unreachable!("Valve is not initialized."), Some(valve) => { if config.create_only { - if let Err(e) = valve.create_all_tables().await { + if let Err(e) = valve.ensure_all_tables_created(&vec![]).await { return Err(format!( "VALVE error while creating from {}: {:?}", valve_path, e diff --git a/src/main.rs b/src/main.rs index 4436c0b..25f967e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,6 @@ use crate::{config::Config, error::NanobotError, serve::build_app, sql::get_table_from_pool}; use axum_test_helper::{TestClient, TestResponse}; -use clap::{arg, command, value_parser, Command}; +use clap::{arg, command, value_parser, Arg, Command}; use ontodev_sqlrest::Select; use ontodev_valve::valve::Valve; use std::path::Path; @@ -88,13 +88,22 @@ async fn main() -> Result<(), NanobotError> { ), ) .subcommand( - Command::new("serve").about("Run HTTP server").arg( - arg!( - -c --connection "Specifies a database connection URL or file" + Command::new("serve") + .about("Run HTTP server") + .arg( + arg!( + -c --connection "Specifies a database connection URL or file" + ) + .required(false) + .value_parser(value_parser!(String)), ) - .required(false) - .value_parser(value_parser!(String)), - ), + .arg( + Arg::new("read-only") + .help("Run the server in read-only mode") + .long("read-only") + .required(false) + .num_args(0), + ), ) .get_matches(); @@ -141,9 +150,12 @@ async fn main() -> Result<(), NanobotError> { if let Some(c) = sub_matches.get_one::("connection") { config.connection(c); } + if let Some(r) = sub_matches.get_one::("read-only") { + config.editable = !*r; + } if config.connection == ":memory:" { (config.valve, config.pool) = { - let valve = Valve::build(&config.valve_path, &config.connection).await?; + let mut valve = Valve::build(&config.valve_path, &config.connection).await?; let pool = valve.pool.clone(); let _ = valve.load_all_tables(true).await; let table_select = Select::new("\"table\""); @@ -209,12 +221,6 @@ async fn build_valve(config: &mut Config) -> Result<(), NanobotError> { async fn handle_cgi(vars: &HashMap, config: &mut Config) -> Result { tracing::debug!("Processing CGI request with vars: {:?}", vars); - let shared_state = Arc::new(serve::AppState { - config: config.clone(), - }); - let app = build_app(shared_state); - let client = TestClient::new(app); - let request_method = vars .get("REQUEST_METHOD") .ok_or("No 'REQUEST_METHOD' in CGI vars".to_string())?; @@ -224,6 +230,20 @@ async fn handle_cgi(vars: &HashMap, config: &mut Config) -> Resu let query_string = vars .get("QUERY_STRING") .ok_or("No 'QUERY_STRING' in CGI vars".to_string())?; + let read_only = vars + .get("NANOBOT_READONLY") + .ok_or("No 'NANOBOT_READONLY' in CGI vars".to_string())?; + + if read_only.to_lowercase() == "true" { + config.editable = false; + } + + let shared_state = Arc::new(serve::AppState { + config: config.clone(), + }); + let app = build_app(shared_state); + let client = TestClient::new(app); + let mut url = path_info.clone(); if !url.starts_with("/") { url = format!("/{}", path_info); @@ -283,13 +303,19 @@ fn cgi_vars() -> Option> { _ => return None, }; - for var in vec!["REQUEST_METHOD", "PATH_INFO", "QUERY_STRING"] { + for var in vec![ + "REQUEST_METHOD", + "PATH_INFO", + "QUERY_STRING", + "NANOBOT_READONLY", + ] { match env::var_os(var).and_then(|p| Some(p.into_string())) { Some(Ok(s)) => vars.insert(var.to_string(), s), _ => match var { "REQUEST_METHOD" => vars.insert(var.to_string(), "GET".to_string()), "PATH_INFO" => vars.insert(var.to_string(), "/table".to_string()), "QUERY_STRING" => vars.insert(var.to_string(), String::new()), + "NANOBOT_READONLY" => vars.insert(var.to_string(), String::from("FALSE")), _ => { // This should never happen since all possible cases should be handled above: unreachable!( diff --git a/src/resources/form.html b/src/resources/form.html index 91a7fea..affd698 100644 --- a/src/resources/form.html +++ b/src/resources/form.html @@ -59,6 +59,7 @@

Return to + {% if page.editable and table.editable %}
{% for column, form_row in form_map|items %} {{ form_row|safe }} @@ -72,6 +73,11 @@

Return to Cancel + {% else %} + {% for column, form_row in form_map|items %} + {{ form_row|safe }} + {% endfor %} + {% endif %} -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/src/resources/page.html b/src/resources/page.html index 5d61809..abf7555 100644 --- a/src/resources/page.html +++ b/src/resources/page.html @@ -138,6 +138,7 @@ + {% if page.editable %} + {% endif %} diff --git a/src/resources/table.html b/src/resources/table.html index cabf73e..5a7bbf3 100644 --- a/src/resources/table.html +++ b/src/resources/table.html @@ -256,7 +256,7 @@

{{ value.label or name - {% if table.table != "message" %} + {% if page.editable and table.editable %} Add row {% endif %} @@ -291,8 +291,10 @@

{{ value.label or name {% for col, cell in r|items -%} {% if col == "row_number" %} + {% if page.editable and table.editable %} + {% endif %} {% elif col == "message_id" %} {% else %} diff --git a/src/serve.rs b/src/serve.rs index 894cb08..894e9a4 100644 --- a/src/serve.rs +++ b/src/serve.rs @@ -69,7 +69,6 @@ pub fn build_app(shared_state: Arc) -> Router { #[tokio::main] pub async fn app(config: &Config) -> Result { let shared_state = Arc::new(AppState { - //TODO: use &config instead of config.clone()? config: config.clone(), }); @@ -144,6 +143,11 @@ async fn post_table( query_params, form_params ); + if !&state.config.editable { + return Err((StatusCode::FORBIDDEN, Html("403 Forbidden".to_string())) + .into_response() + .into()); + } let mut request_type = RequestType::POST; let valve = state .config @@ -154,6 +158,7 @@ async fn post_table( tracing::info!("SAVE"); valve .save_all_tables(&None) + .await .map_err(|e| format!("{:?}", e))?; request_type = RequestType::GET; } else if form_params.contains_key("undo") { @@ -321,6 +326,7 @@ fn action( "page": { "root": root, "project_name": "Nanobot", + "editable": &state.config.editable, "tables": table_map, "undo": get::get_undo_message(&state.config), "redo": get::get_redo_message(&state.config), @@ -548,6 +554,7 @@ async fn tree( "page": { "root": "../", "project_name": "Nanobot", + "editable": &state.config.editable, "tables": table_map, "undo": get::get_undo_message(&state.config), "redo": get::get_redo_message(&state.config), @@ -691,6 +698,7 @@ async fn tree2( "page": { "root": "../", "project_name": "Nanobot", + "editable": &state.config.editable, "tables": table_map, "undo": get::get_undo_message(&state.config), "redo": get::get_redo_message(&state.config), @@ -848,6 +856,12 @@ async fn table( let mut form_map = None; let columns = get_columns(&table, valve)?; if request_type == RequestType::POST { + println!("config.editable = {}", config.editable); + if !config.editable { + return Err((StatusCode::FORBIDDEN, Html("403 Forbidden".to_string())) + .into_response() + .into()); + } if view == "" { view = String::from("form"); } @@ -967,18 +981,30 @@ async fn table( json!(table_map) }; + let editable = valve + .config + .table + .get(&table) + .ok_or(format!("Undefined table '{table}'"))? + .options + .contains("edit"); + // Fill in the page JSON containing all of the configuration parameters that we will be // passing (through page_to_html()) to the minijinja template: let page = json!({ "page": { "root": "", "project_name": "Nanobot", + "editable": &state.config.editable, "tables": table_map, "undo": get::get_undo_message(&state.config), "redo": get::get_redo_message(&state.config), "actions": get::get_action_map(&state.config).unwrap_or_default(), "repo": get::get_repo_details().unwrap_or_default(), }, + "table": { + "editable": editable, + }, "title": "table", "table_name": table, "subtitle": format!(r#"Return to table"#, table), @@ -1162,6 +1188,9 @@ fn render_row_from_database( let mut messages = HashMap::new(); let mut form_map = None; if request_type == RequestType::POST { + if !config.editable { + return Ok((StatusCode::FORBIDDEN, Html("403 Forbidden".to_string())).into_response()); + } let mut new_row = SerdeMap::new(); // Use the list of columns for the table from the db to look up their values in the form: for column in &get_columns(table, valve)? { @@ -1287,18 +1316,30 @@ fn render_row_from_database( json!(table_map) }; + let editable = valve + .config + .table + .get(table) + .ok_or(format!("Undefined table '{table}'"))? + .options + .contains("edit"); + // Fill in the page JSON which contains all of the parameters that we will be passing to our // minijinja template (through page_to_html()): let page = json!({ "page": { "root": "../../", "project_name": "Nanobot", + "editable": &state.config.editable, "tables": table_map, "undo": get::get_undo_message(&state.config), "redo": get::get_redo_message(&state.config), "actions": get::get_action_map(&state.config).unwrap_or_default(), "repo": get::get_repo_details().unwrap_or_default(), }, + "table": { + "editable": editable, + }, "title": "table", "table_name": table, "row_number": row_number, @@ -1730,7 +1771,7 @@ fn get_row_as_form_map( if separator.is_some() && html_type.clone().is_some_and(|h| h == "search") { html_type = Some("multisearch".into()); } - let readonly; + let mut readonly; match html_type { Some(s) if s == "readonly" => { readonly = true; @@ -1738,6 +1779,18 @@ fn get_row_as_form_map( } _ => readonly = false, }; + if !config.editable { + readonly = true; + } + + let table_config = valve + .config + .table + .get(table_name) + .ok_or(format!("Undefined table '{table_name}'"))?; + if !table_config.options.contains("edit") { + readonly = true; + } let hiccup_form_row = get_hiccup_form_row( table_name,