From d3db3282b1dd29c353a5704c3c8692e8b64223ce Mon Sep 17 00:00:00 2001 From: Sebastian Jeltsch Date: Mon, 22 Sep 2025 15:35:55 +0200 Subject: [PATCH] Add super basic WASM component management for first-party components (currently only auth-ui) to the CLI. Something like this could involve into a plugin eco-system but that is a long way. Also this is not a package/dependency manager, there's no notion of transitivity etc. --- Cargo.lock | 20 ++++- Cargo.toml | 2 + crates/build/src/version.rs | 6 ++ crates/cli/Cargo.toml | 10 ++- crates/cli/src/args.rs | 58 ++++++++++++ crates/cli/src/bin/trail.rs | 163 +++++++++++++++++++++++++++++++++- crates/cli/src/lib.rs | 4 +- crates/core/Cargo.toml | 4 +- crates/core/src/admin/info.rs | 18 +--- 9 files changed, 259 insertions(+), 26 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index fbb45265..8363e97b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7999,8 +7999,11 @@ dependencies = [ "chrono", "clap", "env_logger", + "itertools 0.14.0", "log", "mimalloc", + "minijinja", + "reqwest", "serde", "serde_json", "tokio", @@ -8010,6 +8013,7 @@ dependencies = [ "utoipa", "utoipa-swagger-ui", "uuid", + "zip 5.1.1", ] [[package]] @@ -8529,7 +8533,7 @@ dependencies = [ "serde_json", "url", "utoipa", - "zip", + "zip 3.0.0", ] [[package]] @@ -10060,6 +10064,20 @@ dependencies = [ "zopfli", ] +[[package]] +name = "zip" +version = "5.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f852905151ac8d4d06fdca66520a661c09730a74c6d4e2b0f27b436b382e532" +dependencies = [ + "arbitrary", + "crc32fast", + "flate2", + "indexmap 2.11.4", + "memchr", + "zopfli", +] + [[package]] name = "zlib-rs" version = "0.5.2" diff --git a/Cargo.toml b/Cargo.toml index cc818b76..100a2527 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -75,7 +75,9 @@ askama = { version = "0.14.0", default-features = false, features = ["derive", " axum = { version = "^0.8.1", features = ["multipart"] } env_logger = { version = "^0.11.8", default-features = false, features = ["auto-color", "humantime"] } libsqlite3-sys = { version = "0.35.0", default-features = false, features = ["bundled", "preupdate_hook"] } +minijinja = { version = "2.1.2", default-features = false } parking_lot = { version = "0.12.3", default-features = false, features = ["send_guard", "arc_lock"] } +reqwest = { version = "0.12.8", default-features = false, features = ["rustls-tls", "json"] } rusqlite = { version = "0.37.0", default-features = false, features = ["bundled", "column_decltype", "functions", "backup", "preupdate_hook"] } rust-embed = { version = "8.4.0", default-features = false, features = ["mime-guess"] } tokio = { version = "^1.38.0", default-features = false, features = ["macros", "net", "rt-multi-thread", "fs", "signal", "time", "sync"] } diff --git a/crates/build/src/version.rs b/crates/build/src/version.rs index 4188cf4b..0bb82fd6 100644 --- a/crates/build/src/version.rs +++ b/crates/build/src/version.rs @@ -77,6 +77,12 @@ pub struct GitVersion { pub commits_since: Option, } +impl GitVersion { + pub fn tag(&self) -> String { + return format!("v{}.{}.{}", self.major, self.minor, self.patch); + } +} + #[derive(Clone, Debug, Default)] pub struct VersionInfo { /// Name of the crate as defined in its Cargo.toml. diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 9a369cd7..7205f6cb 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -16,17 +16,21 @@ axum = { version = "^0.8.1", features=["multipart"] } chrono = "^0.4.38" clap = { version = "^4.4.11", features=["derive", "env"] } env_logger = { workspace = true } -trailbase = { workspace = true } -trailbase-build = { workspace = true } +itertools = "0.14.0" log = "^0.4.21" mimalloc = { version = "^0.1.41", default-features = false } +minijinja = { workspace = true } +reqwest = { workspace = true } serde = { version = "^1.0.203", features = ["derive"] } serde_json = "^1.0.117" tokio = { workspace = true } +trailbase = { workspace = true } +trailbase-build = { workspace = true } +url = "2.5.4" utoipa = { version = "5.0.0-beta.0", features = ["axum_extras"] } utoipa-swagger-ui = { version = "9.0.0", features = ["axum"], optional = true } uuid = { workspace = true } -url = "2.5.4" +zip = { version = "5.1.1", default-features = false, features = ["deflate"] } [build-dependencies] trailbase-build = { workspace = true } diff --git a/crates/cli/src/args.rs b/crates/cli/src/args.rs index d0c95789..49401800 100644 --- a/crates/cli/src/args.rs +++ b/crates/cli/src/args.rs @@ -77,6 +77,11 @@ pub enum SubCommands { }, /// Programmatically send emails. Email(EmailArgs), + /// Manage WASM components + Components { + #[command(subcommand)] + cmd: Option, + }, } #[derive(Args, Clone, Debug)] @@ -220,3 +225,56 @@ pub enum UserSubCommands { user: String, }, } + +#[derive(Clone, Debug)] +pub enum ComponentReference { + Path(std::path::PathBuf), + Url(url::Url), + Name(String), +} + +impl TryFrom<&str> for ComponentReference { + type Error = String; + + fn try_from(reference: &str) -> Result { + if let Ok(url) = url::Url::parse(reference) { + if url.scheme() != "https" { + return Err("Only HTTPS supported".into()); + } + + return Ok(ComponentReference::Url(url)); + } + + let path = std::path::PathBuf::from(reference); + if let Some(ext) = path.extension() { + match &*ext.to_string_lossy() { + "wasm" | "zip" => { + return Ok(ComponentReference::Path(path)); + } + _ => {} + } + } + + if reference + .chars() + .all(|c| c.is_alphanumeric() || c == '_' || c == '-' || c == '/') + { + return Ok(ComponentReference::Name(reference.into())); + } + + return Err("Failed to parse component reference".into()); + } +} + +#[derive(Subcommand, Debug, Clone)] +pub enum ComponentSubCommands { + /// Add new WASM component. + Add { + /// File-system path or url for wasm component to install. + reference: String, + }, + /// Remove/delete WASM component. + Remove { reference: String }, + /// List first-party components. + List, +} diff --git a/crates/cli/src/bin/trail.rs b/crates/cli/src/bin/trail.rs index 134507c4..e4488824 100644 --- a/crates/cli/src/bin/trail.rs +++ b/crates/cli/src/bin/trail.rs @@ -5,8 +5,14 @@ static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc; use chrono::TimeZone; use clap::{CommandFactory, Parser}; +use itertools::Itertools; +use minijinja::{Environment, context}; use serde::Deserialize; -use std::rc::Rc; +use std::{ + collections::HashMap, + io::{Read, Seek}, + rc::Rc, +}; use tokio::{fs, io::AsyncWriteExt}; use trailbase::{ DataDir, Server, ServerOptions, @@ -16,7 +22,8 @@ use trailbase::{ use utoipa::OpenApi; use trailbase_cli::{ - AdminSubCommands, DefaultCommandLineArgs, OpenApiSubCommands, SubCommands, UserSubCommands, + AdminSubCommands, ComponentReference, ComponentSubCommands, DefaultCommandLineArgs, + OpenApiSubCommands, SubCommands, UserSubCommands, }; type BoxError = Box; @@ -246,12 +253,87 @@ async fn async_main() -> Result<(), BoxError> { } }; } + Some(SubCommands::Components { cmd }) => { + init_logger(false); + + match cmd { + Some(ComponentSubCommands::Add { reference }) => { + match ComponentReference::try_from(reference.as_str())? { + ComponentReference::Name(name) => { + let component_def = find_component(&name)?; + + let version = trailbase_build::get_version_info!(); + let Some(git_version) = version.git_version() else { + return Err("missing version".into()); + }; + + let env = Environment::empty(); + let url_str = env + .template_from_named_str("url", &component_def.url_template)? + .render(context! { + release => git_version.tag(), + })?; + let url = url::Url::parse(&url_str)?; + + log::info!("Downloading {url}"); + + let bytes = reqwest::get(url.clone()).await?.bytes().await?; + install_wasm_component(&data_dir, url.path(), std::io::Cursor::new(bytes)).await?; + } + ComponentReference::Url(url) => { + log::info!("Downloading {url}"); + let bytes = reqwest::get(url.clone()).await?.bytes().await?; + install_wasm_component(&data_dir, url.path(), std::io::Cursor::new(bytes)).await?; + } + ComponentReference::Path(path) => { + let bytes = std::fs::read(&path)?; + install_wasm_component(&data_dir, &path, std::io::Cursor::new(bytes)).await?; + } + } + } + Some(ComponentSubCommands::Remove { reference }) => { + match ComponentReference::try_from(reference.as_str())? { + ComponentReference::Url(_) => { + return Err("URLs not supported for component removal".into()); + } + ComponentReference::Name(name) => { + let component_def = find_component(&name)?; + let wasm_dir = data_dir.root().join("wasm"); + + let filenames: Vec<_> = component_def + .wasm_filenames + .into_iter() + .map(|f| wasm_dir.join(f)) + .collect(); + + for filename in &filenames { + std::fs::remove_file(filename)?; + } + println!("Removed: {filenames:?}"); + } + ComponentReference::Path(path) => { + std::fs::remove_file(&path)?; + + println!("Removed: {path:?}"); + } + } + } + Some(ComponentSubCommands::List) => { + println!("Components:\n\n{}", repo().keys().join("\n")); + } + _ => { + DefaultCommandLineArgs::command() + .find_subcommand_mut("component") + .map(|cmd| cmd.print_help()); + } + }; + } None => { let _ = DefaultCommandLineArgs::command().print_help(); } } - Ok(()) + return Ok(()); } fn to_user_reference(user: String) -> api::cli::UserReference { @@ -261,6 +343,81 @@ fn to_user_reference(user: String) -> api::cli::UserReference { return api::cli::UserReference::Id(user); } +#[derive(Debug, Clone)] +struct ComponentDefinition { + url_template: String, + wasm_filenames: Vec, +} + +fn repo() -> HashMap { + return HashMap::from([ + ("trailbase/auth_ui".to_string(), ComponentDefinition { + url_template: "https://github.com/trailbaseio/trailbase/releases/download/{{ release }}/trailbase_{{ release }}_wasm_auth_ui.zip".to_string(), + wasm_filenames: vec!["auth_ui_component.wasm".to_string()], + }) + ]); +} + +fn find_component(name: &str) -> Result { + return repo() + .get(name) + .cloned() + .ok_or_else(|| "component not found".into()); +} + +async fn install_wasm_component( + data_dir: &DataDir, + path: impl AsRef, + mut reader: impl Read + Seek, +) -> Result<(), BoxError> { + let path = path.as_ref(); + let wasm_dir = data_dir.root().join("wasm"); + + match path + .extension() + .map(|p| p.to_string_lossy().to_string()) + .as_deref() + { + Some("zip") => { + let mut archive = zip::ZipArchive::new(reader)?; + + for i in 0..archive.len() { + let mut file = archive.by_index(i)?; + if let Some(path) = file.enclosed_name() { + if path.extension().and_then(|e| e.to_str()) != Some("wasm") { + continue; + } + + let Some(filename) = path.file_name().and_then(|e| e.to_str()) else { + return Err(format!("Invalid filename: {:?}", file.name()).into()); + }; + let component_file_path = wasm_dir.join(filename); + let mut component_file = std::fs::File::create(&component_file_path)?; + std::io::copy(&mut file, &mut component_file)?; + + println!("Added: {component_file_path:?}"); + } + } + } + Some("wasm") => { + let Some(filename) = path.file_name().and_then(|e| e.to_str()) else { + return Err(format!("Invalid filename: {path:?}").into()); + }; + + let component_file_path = wasm_dir.join(filename); + let mut component_file = std::fs::File::create(&component_file_path)?; + std::io::copy(&mut reader, &mut component_file)?; + + println!("Added: {component_file_path:?}"); + } + _ => { + return Err("unexpected format".into()); + } + } + + return Ok(()); +} + fn main() -> Result<(), BoxError> { let runtime = Rc::new( tokio::runtime::Builder::new_multi_thread() diff --git a/crates/cli/src/lib.rs b/crates/cli/src/lib.rs index 88fdd9e7..3c9ce0e5 100644 --- a/crates/cli/src/lib.rs +++ b/crates/cli/src/lib.rs @@ -3,8 +3,8 @@ mod args; pub use args::{ - AdminSubCommands, DefaultCommandLineArgs, EmailArgs, JsonSchemaModeArg, SubCommands, - UserSubCommands, + AdminSubCommands, ComponentReference, ComponentSubCommands, DefaultCommandLineArgs, EmailArgs, + JsonSchemaModeArg, SubCommands, UserSubCommands, }; pub use args::OpenApiSubCommands; diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 2605978c..c5f5151a 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -53,7 +53,7 @@ lazy_static = "1.4.0" lettre = { version = "^0.11.7", default-features = false, features = ["tokio1-rustls-tls", "sendmail-transport", "smtp-transport", "builder"] } log = { version = "^0.4.21", default-features = false } mini-moka = "0.10.3" -minijinja = { version = "2.1.2", default-features = false } +minijinja = { workspace = true } oauth2 = { version = "5.0.0-alpha.4", default-features = false, features = ["reqwest", "rustls-tls"] } object_store = { version = "0.12.0", default-features = false, features = ["aws", "fs"] } parking_lot = { workspace = true } @@ -63,7 +63,7 @@ prost-reflect = { version = "^0.16.0", default-features = false, features = ["de rand = "^0.9.0" reactivate = { version = "0.4.2", features = ["threadsafe"] } regex = "1.11.0" -reqwest = { version = "0.12.8", default-features = false, features = ["rustls-tls", "json"] } +reqwest = { workspace = true } rusqlite = { workspace = true } rust-embed = { workspace = true } serde = { version = "^1.0.203", features = ["derive"] } diff --git a/crates/core/src/admin/info.rs b/crates/core/src/admin/info.rs index a55577f9..af06cfb0 100644 --- a/crates/core/src/admin/info.rs +++ b/crates/core/src/admin/info.rs @@ -1,6 +1,5 @@ use axum::{Json, extract::State}; use serde::Serialize; -use trailbase_build::version::GitVersion; use ts_rs::TS; use crate::admin::AdminError as Error; @@ -26,20 +25,9 @@ pub async fn info_handler(State(state): State) -> Result InfoResponse { let version_info = state.version(); - let git_version = version_info.git_version().map( - |GitVersion { - major, - minor, - patch, - commits_since, - .. - }| { - ( - format!("v{major}.{minor}.{patch}"), - commits_since.unwrap_or(0) as usize, - ) - }, - ); + let git_version = version_info + .git_version() + .map(|v| (v.tag(), v.commits_since.unwrap_or(0) as usize)); return InfoResponse { compiler: version_info.host_compiler,