diff --git a/crates/cli/src/common_args.rs b/crates/cli/src/common_args.rs index 9ffb0ab40db..a238ecbf0d8 100644 --- a/crates/cli/src/common_args.rs +++ b/crates/cli/src/common_args.rs @@ -27,5 +27,5 @@ pub fn yes() -> Arg { .long("yes") .short('y') .action(SetTrue) - .help("Assume \"yes\" as answer to all prompts and run non-interactively") + .help("Run non-interactively wherever possible. This will answer \"yes\" to almost all prompts, but will sometimes answer \"no\" to preserve non-interactivity (e.g. when prompting whether to log in with spacetimedb.com).") } diff --git a/crates/cli/src/config.rs b/crates/cli/src/config.rs index 069cebb7ea6..f0cde779513 100644 --- a/crates/cli/src/config.rs +++ b/crates/cli/src/config.rs @@ -820,14 +820,6 @@ Update the server's fingerprint with: pub fn spacetimedb_token(&self) -> Option<&String> { self.home.spacetimedb_token.as_ref() } - - pub fn spacetimedb_token_or_error(&self) -> anyhow::Result<&String> { - if let Some(token) = self.spacetimedb_token() { - Ok(token) - } else { - Err(anyhow::anyhow!("No login token found. Please run `spacetime login`.")) - } - } } #[cfg(test)] diff --git a/crates/cli/src/subcommands/call.rs b/crates/cli/src/subcommands/call.rs index f9501f3d993..335b514c7c4 100644 --- a/crates/cli/src/subcommands/call.rs +++ b/crates/cli/src/subcommands/call.rs @@ -30,6 +30,7 @@ pub fn cli() -> clap::Command { .arg(Arg::new("arguments").help("arguments formatted as JSON").num_args(1..)) .arg(common_args::server().help("The nickname, host name or URL of the server hosting the database")) .arg(common_args::anonymous()) + .arg(common_args::yes()) .after_help("Run `spacetime help call` for more detailed information.\n") } @@ -38,6 +39,7 @@ pub async fn exec(mut config: Config, args: &ArgMatches) -> Result<(), Error> { let reducer_name = args.get_one::("reducer_name").unwrap(); let arguments = args.get_many::("arguments"); let server = args.get_one::("server").map(|s| s.as_ref()); + let force = args.get_flag("force"); let anon_identity = args.get_flag("anon_identity"); @@ -49,7 +51,7 @@ pub async fn exec(mut config: Config, args: &ArgMatches) -> Result<(), Error> { database_identity.clone(), reducer_name )); - let auth_header = get_auth_header(&config, anon_identity)?; + let auth_header = get_auth_header(&mut config, anon_identity, server, !force).await?; let builder = add_auth_header_opt(builder, &auth_header); let describe_reducer = util::describe_reducer( &mut config, @@ -57,6 +59,7 @@ pub async fn exec(mut config: Config, args: &ArgMatches) -> Result<(), Error> { server.map(|x| x.to_string()), reducer_name.clone(), anon_identity, + !force, ) .await?; diff --git a/crates/cli/src/subcommands/delete.rs b/crates/cli/src/subcommands/delete.rs index b73f8ba47a6..d2a4aea13d7 100644 --- a/crates/cli/src/subcommands/delete.rs +++ b/crates/cli/src/subcommands/delete.rs @@ -12,17 +12,19 @@ pub fn cli() -> clap::Command { .help("The name or identity of the database to delete"), ) .arg(common_args::server().help("The nickname, host name or URL of the server hosting the database")) + .arg(common_args::yes()) .after_help("Run `spacetime help delete` for more detailed information.\n") } -pub async fn exec(config: Config, args: &ArgMatches) -> Result<(), anyhow::Error> { +pub async fn exec(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::Error> { let server = args.get_one::("server").map(|s| s.as_ref()); let database = args.get_one::("database").unwrap(); + let force = args.get_flag("force"); let identity = database_identity(&config, database, server).await?; let builder = reqwest::Client::new().post(format!("{}/database/delete/{}", config.get_host_url(server)?, identity)); - let auth_header = get_auth_header(&config, false)?; + let auth_header = get_auth_header(&mut config, false, server, !force).await?; let builder = add_auth_header_opt(builder, &auth_header); builder.send().await?.error_for_status()?; diff --git a/crates/cli/src/subcommands/describe.rs b/crates/cli/src/subcommands/describe.rs index 95bc52ecafd..0cf6a7f2a38 100644 --- a/crates/cli/src/subcommands/describe.rs +++ b/crates/cli/src/subcommands/describe.rs @@ -23,14 +23,16 @@ pub fn cli() -> clap::Command { ) .arg(common_args::anonymous()) .arg(common_args::server().help("The nickname, host name or URL of the server hosting the database")) + .arg(common_args::yes()) .after_help("Run `spacetime help describe` for more detailed information.\n") } -pub async fn exec(config: Config, args: &ArgMatches) -> Result<(), anyhow::Error> { +pub async fn exec(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::Error> { let database = args.get_one::("database").unwrap(); let entity_name = args.get_one::("entity_name"); let entity_type = args.get_one::("entity_type"); let server = args.get_one::("server").map(|s| s.as_ref()); + let force = args.get_flag("force"); let anon_identity = args.get_flag("anon_identity"); @@ -46,7 +48,7 @@ pub async fn exec(config: Config, args: &ArgMatches) -> Result<(), anyhow::Error entity_name ), }); - let auth_header = get_auth_header(&config, anon_identity)?; + let auth_header = get_auth_header(&mut config, anon_identity, server, !force).await?; let builder = add_auth_header_opt(builder, &auth_header); let descr = builder.send().await?.error_for_status()?.text().await?; diff --git a/crates/cli/src/subcommands/dns.rs b/crates/cli/src/subcommands/dns.rs index e14a363c6b7..b270aa271b2 100644 --- a/crates/cli/src/subcommands/dns.rs +++ b/crates/cli/src/subcommands/dns.rs @@ -1,6 +1,8 @@ use crate::common_args; use crate::config::Config; -use crate::util::{add_auth_header_opt, decode_identity, get_auth_header, spacetime_register_tld}; +use crate::util::{ + add_auth_header_opt, decode_identity, get_auth_header, get_login_token_or_log_in, spacetime_register_tld, +}; use clap::ArgMatches; use clap::{Arg, Command}; use reqwest::Url; @@ -22,17 +24,20 @@ pub fn cli() -> Command { .help("The database identity to rename"), ) .arg(common_args::server().help("The nickname, host name or URL of the server on which to set the name")) + .arg(common_args::yes()) .after_help("Run `spacetime rename --help` for more detailed information.\n") } -pub async fn exec(config: Config, args: &ArgMatches) -> Result<(), anyhow::Error> { +pub async fn exec(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::Error> { let domain = args.get_one::("new-name").unwrap(); let database_identity = args.get_one::("database-identity").unwrap(); let server = args.get_one::("server").map(|s| s.as_ref()); - let identity = decode_identity(&config)?; - let auth_header = get_auth_header(&config, false)?; + let force = args.get_flag("force"); + let token = get_login_token_or_log_in(&mut config, server, !force).await?; + let identity = decode_identity(&token)?; + let auth_header = get_auth_header(&mut config, false, server, !force).await?; - match spacetime_register_tld(&config, domain, server).await? { + match spacetime_register_tld(&mut config, domain, server, !force).await? { RegisterTldResult::Success { domain } => { println!("Registered domain: {}", domain); } diff --git a/crates/cli/src/subcommands/energy.rs b/crates/cli/src/subcommands/energy.rs index 117041ecf79..555b4e7a5a5 100644 --- a/crates/cli/src/subcommands/energy.rs +++ b/crates/cli/src/subcommands/energy.rs @@ -3,7 +3,7 @@ use crate::common_args; use clap::ArgMatches; use crate::config::Config; -use crate::util; +use crate::util::{self, get_login_token_or_log_in}; pub fn cli() -> clap::Command { clap::Command::new("energy") @@ -26,7 +26,8 @@ fn get_energy_subcommands() -> Vec { .arg( common_args::server() .help("The nickname, host name or URL of the server from which to request balance information"), - )] + ) + .arg(common_args::yes())] } async fn exec_subcommand(config: Config, cmd: &str, args: &ArgMatches) -> Result<(), anyhow::Error> { @@ -41,15 +42,17 @@ pub async fn exec(config: Config, args: &ArgMatches) -> Result<(), anyhow::Error exec_subcommand(config, cmd, subcommand_args).await } -async fn exec_status(config: Config, args: &ArgMatches) -> Result<(), anyhow::Error> { +async fn exec_status(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::Error> { // let project_name = args.value_of("project name").unwrap(); let identity = args.get_one::("identity"); let server = args.get_one::("server").map(|s| s.as_ref()); + let force = args.get_flag("force"); // TODO: We should remove the ability to call this for arbitrary users. At *least* remove it from the CLI. let identity = if let Some(identity) = identity { identity.clone() } else { - util::decode_identity(&config)? + let token = get_login_token_or_log_in(&mut config, server, !force).await?; + util::decode_identity(&token)? }; let status = reqwest::Client::new() diff --git a/crates/cli/src/subcommands/list.rs b/crates/cli/src/subcommands/list.rs index b1d6ad7526f..861e05de334 100644 --- a/crates/cli/src/subcommands/list.rs +++ b/crates/cli/src/subcommands/list.rs @@ -1,5 +1,6 @@ use crate::common_args; use crate::util; +use crate::util::get_login_token_or_log_in; use crate::Config; use clap::{ArgMatches, Command}; use reqwest::StatusCode; @@ -14,6 +15,7 @@ pub fn cli() -> Command { Command::new("list") .about("Lists the databases attached to an identity") .arg(common_args::server().help("The nickname, host name or URL of the server from which to list databases")) + .arg(common_args::yes()) } #[derive(Deserialize)] @@ -27,9 +29,11 @@ struct IdentityRow { pub db_identity: Identity, } -pub async fn exec(config: Config, args: &ArgMatches) -> Result<(), anyhow::Error> { +pub async fn exec(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::Error> { let server = args.get_one::("server").map(|s| s.as_ref()); - let identity = util::decode_identity(&config)?; + let force = args.get_flag("force"); + let token = get_login_token_or_log_in(&mut config, server, !force).await?; + let identity = util::decode_identity(&token)?; let client = reqwest::Client::new(); let res = client @@ -38,7 +42,7 @@ pub async fn exec(config: Config, args: &ArgMatches) -> Result<(), anyhow::Error config.get_host_url(server)?, identity )) - .basic_auth("token", Some(config.spacetimedb_token_or_error()?)) + .basic_auth("token", Some(token)) .send() .await?; diff --git a/crates/cli/src/subcommands/login.rs b/crates/cli/src/subcommands/login.rs index 4276e8b7117..d4d49241483 100644 --- a/crates/cli/src/subcommands/login.rs +++ b/crates/cli/src/subcommands/login.rs @@ -5,6 +5,8 @@ use reqwest::Url; use serde::Deserialize; use webbrowser; +pub const DEFAULT_AUTH_HOST: &str = "https://spacetimedb.com"; + pub fn cli() -> Command { Command::new("login") .args_conflicts_with_subcommands(true) @@ -13,7 +15,7 @@ pub fn cli() -> Command { .arg( Arg::new("auth-host") .long("auth-host") - .default_value("https://spacetimedb.com") + .default_value(DEFAULT_AUTH_HOST) .group("login-method") .help("Fetch login token from a different host"), ) @@ -79,13 +81,17 @@ async fn exec_subcommand(config: Config, cmd: &str, args: &ArgMatches) -> Result async fn exec_show(config: Config, args: &ArgMatches) -> Result<(), anyhow::Error> { let include_token = args.get_flag("token"); - let identity = decode_identity(&config)?; + let token = if let Some(token) = config.spacetimedb_token() { + token + } else { + println!("You are not logged in. Run `spacetime login` to log in."); + return Ok(()); + }; + + let identity = decode_identity(token)?; println!("You are logged in as {}", identity); if include_token { - // We can `unwrap` because `decode_identity` fetches this too. - // TODO: maybe decode_identity should take token as a param. - let token = config.spacetimedb_token().unwrap(); println!("Your auth token (don't share this!) is {}", token); } @@ -100,18 +106,26 @@ async fn spacetimedb_token_cached(config: &mut Config, host: &Url, direct_login: println!("If you want to log out, use spacetime logout."); Ok(token.clone()) } else { - let token = if direct_login { - spacetimedb_direct_login(host).await? - } else { - let session_token = web_login_cached(config, host).await?; - spacetimedb_login(host, &session_token).await? - }; - config.set_spacetimedb_token(token.clone()); - config.save(); - Ok(token) + spacetimedb_login_force(config, host, direct_login).await } } +pub async fn spacetimedb_login_force(config: &mut Config, host: &Url, direct_login: bool) -> anyhow::Result { + let token = if direct_login { + let token = spacetimedb_direct_login(host).await?; + println!("We have logged in directly to your target server."); + println!("WARNING: This login will NOT work for any other servers."); + token + } else { + let session_token = web_login_cached(config, host).await?; + spacetimedb_login(host, &session_token).await? + }; + config.set_spacetimedb_token(token.clone()); + config.save(); + + Ok(token) +} + async fn web_login_cached(config: &mut Config, host: &Url) -> anyhow::Result { if let Some(session_token) = config.web_session_token() { // Currently, these session tokens do not expire. At some point in the future, we may also need to check this session token for validity. diff --git a/crates/cli/src/subcommands/logs.rs b/crates/cli/src/subcommands/logs.rs index 887af4a39cf..8a3c7f3d4d4 100644 --- a/crates/cli/src/subcommands/logs.rs +++ b/crates/cli/src/subcommands/logs.rs @@ -47,6 +47,7 @@ pub fn cli() -> clap::Command { .value_parser(clap::value_parser!(Format)) .help("Output format for the logs") ) + .arg(common_args::yes()) .after_help("Run `spacetime help logs` for more detailed information.\n") } @@ -109,14 +110,15 @@ impl clap::ValueEnum for Format { } } -pub async fn exec(config: Config, args: &ArgMatches) -> Result<(), anyhow::Error> { +pub async fn exec(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::Error> { let server = args.get_one::("server").map(|s| s.as_ref()); + let force = args.get_flag("force"); let mut num_lines = args.get_one::("num_lines").copied(); let database = args.get_one::("database").unwrap(); let follow = args.get_flag("follow"); let format = *args.get_one::("format").unwrap(); - let auth_header = get_auth_header(&config, false)?; + let auth_header = get_auth_header(&mut config, false, server, !force).await?; let database_identity = database_identity(&config, database, server).await?; diff --git a/crates/cli/src/subcommands/publish.rs b/crates/cli/src/subcommands/publish.rs index 64183173d89..20b904afd39 100644 --- a/crates/cli/src/subcommands/publish.rs +++ b/crates/cli/src/subcommands/publish.rs @@ -65,7 +65,7 @@ pub fn cli() -> clap::Command { .after_help("Run `spacetime help publish` for more detailed information.") } -pub async fn exec(config: Config, args: &ArgMatches) -> Result<(), anyhow::Error> { +pub async fn exec(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::Error> { let server = args.get_one::("server").map(|s| s.as_str()); let name_or_identity = args.get_one::("name|identity"); let path_to_project = args.get_one::("project_path").unwrap(); @@ -80,7 +80,7 @@ pub async fn exec(config: Config, args: &ArgMatches) -> Result<(), anyhow::Error // we want to use the default identity // TODO(jdetter): We should maybe have some sort of user prompt here for them to be able to // easily create a new identity with an email - let auth_header = get_auth_header(&config, anon_identity)?; + let auth_header = get_auth_header(&mut config, anon_identity, server, !force).await?; let mut query_params = Vec::<(&str, &str)>::new(); query_params.push(("host_type", "wasm")); @@ -159,7 +159,9 @@ pub async fn exec(config: Config, args: &ArgMatches) -> Result<(), anyhow::Error let res = builder.body(program_bytes).send().await?; if res.status() == StatusCode::UNAUTHORIZED && !anon_identity { - let identity = decode_identity(&config)?; + // If we're not in the `anon_identity` case, then we have already forced the user to log in above (using `get_auth_header`), so this should be safe to unwrap. + let token = config.spacetimedb_token().unwrap(); + let identity = decode_identity(token)?; let err = res.text().await?; return unauth_error_context( Err(anyhow::anyhow!(err)), @@ -198,7 +200,16 @@ pub async fn exec(config: Config, args: &ArgMatches) -> Result<(), anyhow::Error )); } PublishResult::PermissionDenied { domain } => { - let identity = decode_identity(&config)?; + if anon_identity { + anyhow::bail!( + "You need to be logged in as the owner of {} to publish to {}", + domain.tld(), + domain.tld() + ); + } + // If we're not in the `anon_identity` case, then we have already forced the user to log in above (using `get_auth_header`), so this should be safe to unwrap. + let token = config.spacetimedb_token().unwrap(); + let identity = decode_identity(token)?; //TODO(jdetter): Have a nice name generator here, instead of using some abstract characters // we should perhaps generate fun names like 'green-fire-dragon' instead let suggested_tld: String = identity.chars().take(12).collect(); diff --git a/crates/cli/src/subcommands/sql.rs b/crates/cli/src/subcommands/sql.rs index dc3d7d62ba1..aa11285f7eb 100644 --- a/crates/cli/src/subcommands/sql.rs +++ b/crates/cli/src/subcommands/sql.rs @@ -37,16 +37,18 @@ pub fn cli() -> clap::Command { ) .arg(common_args::anonymous()) .arg(common_args::server().help("The nickname, host name or URL of the server hosting the database")) + .arg(common_args::yes()) } -pub(crate) async fn parse_req(config: Config, args: &ArgMatches) -> Result { +pub(crate) async fn parse_req(mut config: Config, args: &ArgMatches) -> Result { let server = args.get_one::("server").map(|s| s.as_ref()); + let force = args.get_flag("force"); let database_name_or_identity = args.get_one::("database").unwrap(); let anon_identity = args.get_flag("anon_identity"); Ok(Connection { host: config.get_host_url(server)?, - auth_header: get_auth_header(&config, anon_identity)?, + auth_header: get_auth_header(&mut config, anon_identity, server, !force).await?, database_identity: database_identity(&config, database_name_or_identity, server).await?, database: database_name_or_identity.to_string(), }) diff --git a/crates/cli/src/subcommands/subscribe.rs b/crates/cli/src/subcommands/subscribe.rs index 46b0f4f270e..a3070e65dd5 100644 --- a/crates/cli/src/subcommands/subscribe.rs +++ b/crates/cli/src/subcommands/subscribe.rs @@ -63,6 +63,7 @@ pub fn cli() -> clap::Command { .help("Print the initial update for the queries."), ) .arg(common_args::anonymous()) + .arg(common_args::yes()) .arg(common_args::server().help("The nickname, host name or URL of the server hosting the database")) } diff --git a/crates/cli/src/util.rs b/crates/cli/src/util.rs index 9d9778041dd..0c1da694904 100644 --- a/crates/cli/src/util.rs +++ b/crates/cli/src/util.rs @@ -3,7 +3,7 @@ use base64::{ engine::general_purpose::STANDARD as BASE_64_STD, engine::general_purpose::STANDARD_NO_PAD as BASE_64_STD_NO_PAD, Engine as _, }; -use reqwest::RequestBuilder; +use reqwest::{RequestBuilder, Url}; use serde::Deserialize; use spacetimedb::auth::identity::{IncomingClaims, SpacetimeIdentityClaims}; use spacetimedb_client_api_messages::name::{DnsLookupResponse, RegisterTldResult, ReverseDNSResponse}; @@ -13,6 +13,7 @@ use std::io::Write; use std::path::Path; use crate::config::Config; +use crate::login::{spacetimedb_login_force, DEFAULT_AUTH_HOST}; /// Determine the identity of the `database`. pub async fn database_identity( @@ -46,11 +47,12 @@ pub async fn spacetime_dns( /// identity will be looked up in the config and it will be used instead. Returns Ok() if the /// domain is successfully registered, returns Err otherwise. pub async fn spacetime_register_tld( - config: &Config, + config: &mut Config, tld: &str, server: Option<&str>, + interactive: bool, ) -> Result { - let auth_header = get_auth_header(config, false)?; + let auth_header = get_auth_header(config, false, server, interactive).await?; // TODO(jdetter): Fix URL encoding on specifying this domain let builder = reqwest::Client::new() @@ -128,6 +130,7 @@ pub async fn describe_reducer( server: Option, reducer_name: String, anon_identity: bool, + interactive: bool, ) -> anyhow::Result { let builder = reqwest::Client::new().get(format!( "{}/database/schema/{}/{}/{}", @@ -136,7 +139,7 @@ pub async fn describe_reducer( "reducer", reducer_name )); - let auth_header = get_auth_header(config, anon_identity)?; + let auth_header = get_auth_header(config, anon_identity, server.as_deref(), interactive).await?; let builder = add_auth_header_opt(builder, &auth_header); let descr = builder @@ -170,11 +173,16 @@ pub fn add_auth_header_opt(mut builder: RequestBuilder, auth_header: &Option anyhow::Result> { +pub async fn get_auth_header( + config: &mut Config, + anon_identity: bool, + target_server: Option<&str>, + interactive: bool, +) -> anyhow::Result> { if anon_identity { Ok(None) } else { - let token = config.spacetimedb_token_or_error()?; + let token = get_login_token_or_log_in(config, target_server, interactive).await?; // The current form is: Authorization: Basic base64("token:") let mut auth_header = String::new(); auth_header.push_str(format!("Basic {}", BASE_64_STD.encode(format!("token:{}", token))).as_str()); @@ -264,8 +272,7 @@ Please log back in with `spacetime logout` and then `spacetime login`." }) } -pub fn decode_identity(config: &Config) -> anyhow::Result { - let token = config.spacetimedb_token_or_error()?; +pub fn decode_identity(token: &String) -> anyhow::Result { // Here, we manually extract and decode the claims from the json web token. // We do this without using the `jsonwebtoken` crate because it doesn't seem to have a way to skip signature verification. // But signature verification would require getting the public key from a server, and we don't necessarily want to do that. @@ -281,3 +288,30 @@ pub fn decode_identity(config: &Config) -> anyhow::Result { Ok(claims_data.identity.to_string()) } + +pub async fn get_login_token_or_log_in( + config: &mut Config, + target_server: Option<&str>, + interactive: bool, +) -> anyhow::Result { + if let Some(token) = config.spacetimedb_token() { + return Ok(token.clone()); + } + + // Note: We pass `force: false` to `y_or_n` because if we're running non-interactively we want to default to "no", not yes! + let full_login = interactive + && y_or_n( + false, + // It would be "ideal" if we could print the `spacetimedb.com` by deriving it from the `default_auth_host` constant, + // but this will change _so_ infrequently that it's not even worth the time to write that code and test it. + "You are not logged in. Would you like to log in with spacetimedb.com?", + )?; + + if full_login { + let host = Url::parse(DEFAULT_AUTH_HOST)?; + spacetimedb_login_force(config, &host, false).await + } else { + let host = Url::parse(&config.get_host_url(target_server)?)?; + spacetimedb_login_force(config, &host, true).await + } +}