#![doc = include_str!("../../README.md")]
#![warn(clippy::all, clippy::pedantic)]
#![allow(clippy::missing_errors_doc, clippy::used_underscore_binding)]
use base64::{Engine, engine::general_purpose::STANDARD as b64};
use fs_err::DirEntry;
use ordinary_api::client::{AccountMeta, OrdinaryApiClient};
use ordinary_config::{ManagedTemplate, OrdinaryConfig};
use ordinaryd::{ApiInit, AppApi, Cli, Commands, GlobalArgs, LogLevel, ProvisionMode};
use parking_lot::Mutex;
use qrcodegen::{QrCode, QrCodeEcc};
use std::env;
use std::env::home_dir;
use std::fmt::Write;
use std::path::Path;
use std::process::Command;
use std::sync::Arc;
use std::time::Duration;
use tauri::{WebviewUrl, WebviewWindowBuilder};
use totp_rs::{Algorithm, Secret, TOTP};
use tracing::instrument;
pub(crate) static GENERATOR: &str = concat!("Ordinary Studio ", env!("CARGO_PKG_VERSION"));
pub(crate) static USER_AGENT: &str =
concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"));
#[tauri::command]
#[instrument]
async fn check_for_installed_components() -> Result<Vec<String>, ()> {
match ordinary_doctor::installed_components() {
Ok(v) => Ok(v),
Err(_) => Err(()),
}
}
#[tauri::command]
#[instrument]
async fn install_rust_toolchain() -> Result<(), ()> {
tracing::info!("installing rust components...");
match Command::new("curl")
.args([
"--proto",
"'=https'",
"--tlsv1.2",
"-sSf",
"https://sh.rustup.rs",
"|",
"sh",
"-s",
"--",
"-y",
])
.output()
{
Ok(_) => {
match Command::new("rustup")
.args([
"target",
"add",
"wasm32-wasip1",
"wasm32-wasip2",
"wasm32-unknown-unknown",
])
.output()
{
Ok(_) => {}
Err(err) => {
tracing::error!("failed to add wasm targets: {}", err);
}
}
}
Err(err) => {
tracing::error!("failed to install rust: {}", err);
}
}
Ok(())
}
#[tauri::command]
#[instrument]
async fn install_js_toolchain() -> Result<(), ()> {
tracing::info!("installing js components...");
match Command::new("curl")
.args([
"--proto",
"'=https'",
"--tlsv1.2",
"-sSf",
"https://get.volta.sh",
"|",
"sh",
])
.output()
{
Ok(_) => match Command::new("volta").args(["install", "node"]).output() {
Ok(_) => match Command::new("volta").args(["install", "pnpm"]).output() {
Ok(_) => {}
Err(err) => {
tracing::error!("failed to install pnpm: {}", err);
}
},
Err(err) => {
tracing::error!("failed to install node: {}", err);
}
},
Err(err) => {
tracing::error!("failed to install volta: {}", err);
}
}
Ok(())
}
#[tauri::command]
#[instrument]
async fn build(proj_path: &str) -> Result<(), ()> {
match ordinary_build::build(proj_path, false, GENERATOR) {
Ok(()) => {
tracing::info!("project built");
}
Err(err) => {
tracing::error!(%err, "failed to build");
}
}
Ok(())
}
#[tauri::command]
#[instrument]
fn list_accounts() -> Result<String, String> {
match OrdinaryApiClient::list_accounts() {
Ok(accounts) => match serde_json::to_string(&accounts) {
Ok(out) => Ok(out),
Err(err) => {
tracing::error!(%err);
Err(err.to_string())
}
},
Err(err) => {
tracing::error!(%err);
Err(err.to_string())
}
}
}
#[tauri::command]
#[instrument(skip_all)]
async fn register(
domain_account_password_invite_code: (&str, &str, &str, &str),
) -> Result<String, String> {
let (domain, account, password, invite_code) = domain_account_password_invite_code;
let Ok(client) = OrdinaryApiClient::new(
domain,
account,
Some("api.ordinary.local"),
false,
USER_AGENT,
false,
) else {
return Err("failed to build client".into());
};
let Ok((totp, recovery_codes_str)) = client.register(password, invite_code).await else {
return Err("failed to register".into());
};
let mfa_url = totp.get_url();
let Ok(qr) = QrCode::encode_text(&mfa_url, QrCodeEcc::Medium) else {
return Err("failed to encode QR code".into());
};
let border: i32 = 4;
let mut result = String::new();
if let Some(border) = border.checked_mul(2)
&& let Some(dimension) = qr.size().checked_add(border)
{
match writeln!(
result,
"<svg xmlns=\"http://www.w3.org/2000/svg\" version=\"1.1\" viewBox=\"0 0 {dimension} {dimension}\" stroke=\"none\">"
) {
Ok(()) => (),
Err(err) => return Err(err.to_string()),
}
result += "\t<rect width=\"100%\" height=\"100%\" fill=\"#FFFFFF\"/>\n";
result += "\t<path d=\"";
for y in 0..qr.size() {
for x in 0..qr.size() {
if qr.get_module(x, y) {
if x != 0 || y != 0 {
result += " ";
}
match write!(result, "M{},{}h1v1h-1z", x + border, y + border) {
Ok(()) => (),
Err(err) => return Err(err.to_string()),
}
}
}
}
result += "\" fill=\"#000000\"/>\n";
result += "</svg>\n";
}
result = b64.encode(result);
result += "__";
result += recovery_codes_str.as_str();
Ok(result)
}
#[tauri::command]
#[instrument(skip_all)]
async fn login(
domain_account_password_mfa_code: Option<(&str, &str, &str, &str)>,
) -> Result<(), ()> {
if let Some((domain, account, password, mfa_code)) = domain_account_password_mfa_code {
let Ok(client) = OrdinaryApiClient::new(domain, account, None, false, USER_AGENT, false)
else {
return Err(());
};
return if client.login(password, mfa_code).await.is_ok() {
Ok(())
} else {
Err(())
};
} else if let Some(home_dir) = home_dir()
&& let Ok(secret) = fs_err::read(
home_dir
.join(".ordinary")
.join("environments")
.join("studio")
.join("mfa_secret"),
)
&& let Ok(secret_bytes) = Secret::Raw(secret).to_bytes()
{
loop {
if let Ok(totp) = TOTP::new(
Algorithm::SHA1,
6,
1,
30,
secret_bytes.clone(),
Some("studio".to_string()),
"root".to_string(),
) && let Ok(mfa_code) = totp.generate_current()
{
let Ok(client) = OrdinaryApiClient::new(
"http://localhost:8080",
"root",
Some("api.ordinary.local"),
false,
USER_AGENT,
false,
) else {
return Err(());
};
match client.login("password", &mfa_code).await {
Ok(()) => {
tracing::info!("logged in");
break;
}
Err(err) => {
tracing::error!(%err, "failed to login");
}
}
}
tokio::time::sleep(Duration::from_secs(3)).await;
}
}
Ok(())
}
#[instrument]
async fn get_deployed(proj_path: &str) -> Result<u16, ()> {
let Ok(client) = OrdinaryApiClient::new(
"http://localhost:8080",
"root",
Some("api.ordinary.local"),
false,
USER_AGENT,
false,
) else {
return Err(());
};
load_env(proj_path);
match client.deploy(proj_path, false).await {
Ok(ports_res) => {
let port = ports_res.app.unwrap_or_default();
tracing::info!("deployed and running on port {}", port);
Ok(port)
}
Err(err) => {
tracing::error!(%err, "failed to deploy");
Ok(0)
}
}
}
#[tauri::command]
#[instrument]
async fn deploy(proj_path: &str) -> Result<u16, ()> {
get_deployed(proj_path).await
}
#[tauri::command]
#[instrument]
async fn kill(proj_path: &str) -> Result<(), ()> {
let Ok(client) = OrdinaryApiClient::new(
"http://localhost:8080",
"root",
Some("api.ordinary.local"),
false,
USER_AGENT,
false,
) else {
return Err(());
};
match client.kill(proj_path).await {
Ok(()) => {
tracing::info!("killed");
Ok(())
}
Err(err) => {
tracing::error!(%err, "failed to kill");
Err(())
}
}
}
#[tauri::command]
#[instrument]
async fn app_accounts_list(proj_path: &str) -> Result<String, ()> {
let Ok(client) = OrdinaryApiClient::new(
"http://localhost:8080",
"root",
Some("api.ordinary.local"),
false,
USER_AGENT,
false,
) else {
return Err(());
};
match client.app_accounts_list(proj_path).await {
Ok(json) => {
tracing::info!("accounts retrieved");
Ok(json)
}
Err(err) => {
tracing::error!(%err, "failed to get accounts");
Err(())
}
}
}
#[tauri::command]
#[instrument]
async fn items_list(proj_path: &str, model_name: &str) -> Result<String, ()> {
let Ok(client) = OrdinaryApiClient::new(
"http://localhost:8080",
"root",
Some("api.ordinary.local"),
false,
USER_AGENT,
false,
) else {
return Err(());
};
match client.items_list(proj_path, model_name).await {
Ok(res) => {
tracing::info!("items retrieved");
Ok(res)
}
Err(err) => {
tracing::error!(%err, "failed to get items");
Err(())
}
}
}
#[tauri::command]
#[instrument]
async fn write(proj_path: &str, asset_path: &str) -> Result<(), ()> {
let Ok(client) = OrdinaryApiClient::new(
"http://localhost:8080",
"root",
Some("api.ordinary.local"),
false,
USER_AGENT,
false,
) else {
return Err(());
};
match client.write(proj_path, asset_path).await {
Ok(()) => {
tracing::info!("written");
Ok(())
}
Err(err) => {
tracing::error!(%err, "failed to write");
Err(())
}
}
}
#[tauri::command]
#[instrument]
async fn write_all(proj_path: &str) -> Result<(), ()> {
let Ok(client) = OrdinaryApiClient::new(
"http://localhost:8080",
"root",
Some("api.ordinary.local"),
false,
USER_AGENT,
false,
) else {
return Err(());
};
match client.write_all(proj_path).await {
Ok(()) => {
tracing::info!("written");
Ok(())
}
Err(err) => {
tracing::error!(%err, "failed to write");
Err(())
}
}
}
#[tauri::command]
#[instrument]
async fn upload(proj_path: &str, template_name: &str) -> Result<(), ()> {
let Ok(client) = OrdinaryApiClient::new(
"http://localhost:8080",
"root",
Some("api.ordinary.local"),
false,
USER_AGENT,
false,
) else {
return Err(());
};
match client.upload(proj_path, template_name).await {
Ok(()) => {
tracing::info!("uploaded");
Ok(())
}
Err(err) => {
tracing::error!(%err, "failed to upload");
Err(())
}
}
}
#[tauri::command]
#[instrument]
async fn upload_all(proj_path: &str) -> Result<(), ()> {
let Ok(client) = OrdinaryApiClient::new(
"http://localhost:8080",
"root",
Some("api.ordinary.local"),
false,
USER_AGENT,
false,
) else {
return Err(());
};
match client.upload_all(proj_path).await {
Ok(()) => {
tracing::info!("uploaded");
Ok(())
}
Err(err) => {
tracing::error!(%err, "failed to upload");
Err(())
}
}
}
#[tauri::command]
#[instrument]
async fn install(proj_path: &str, action_name: &str) -> Result<(), ()> {
let Ok(client) = OrdinaryApiClient::new(
"http://localhost:8080",
"root",
Some("api.ordinary.local"),
false,
USER_AGENT,
false,
) else {
return Err(());
};
match client.install(proj_path, action_name).await {
Ok(()) => {
tracing::info!("installed");
Ok(())
}
Err(err) => {
tracing::error!(%err, "failed to install");
Err(())
}
}
}
#[tauri::command]
#[instrument]
async fn install_all(proj_path: &str) -> Result<(), ()> {
let Ok(client) = OrdinaryApiClient::new(
"http://localhost:8080",
"root",
Some("api.ordinary.local"),
false,
USER_AGENT,
false,
) else {
return Err(());
};
match client.install_all(proj_path).await {
Ok(()) => {
tracing::info!("installed");
Ok(())
}
Err(err) => {
tracing::error!(%err, "failed to install");
Err(())
}
}
}
#[instrument]
async fn get_updated(proj_path: &str) -> Result<(), ()> {
let Ok(client) = OrdinaryApiClient::new(
"http://localhost:8080",
"root",
Some("api.ordinary.local"),
false,
USER_AGENT,
false,
) else {
return Err(());
};
match client.update(proj_path).await {
Ok(()) => {
tracing::info!("updated");
Ok(())
}
Err(err) => {
tracing::error!(%err, "failed to update");
Err(())
}
}
}
#[tauri::command]
#[instrument]
async fn update(proj_path: &str) -> Result<(), ()> {
get_updated(proj_path).await
}
fn load_env(proj_path: &str) {
let env_file = Path::new(proj_path).join(".env");
if env_file.exists()
&& let Err(err) = dotenv::from_path(env_file)
{
tracing::error!(%err);
}
}
#[tauri::command]
#[instrument]
async fn publish(proj_path: &str, account: &str) -> Result<(), ()> {
let account = match serde_json::from_str::<AccountMeta>(account) {
Ok(acct) => acct,
Err(err) => {
tracing::error!(%err, "failed to parse account");
return Err(());
}
};
load_env(proj_path);
if let Err(err) = ordinary_build::build(proj_path, true, GENERATOR) {
tracing::error!(%err);
}
let host = format!("https://{}", account.host);
let client = match OrdinaryApiClient::new(&host, &account.name, None, false, USER_AGENT, true) {
Ok(client) => client,
Err(err) => {
tracing::error!(%err, "failed to initialize client");
return Err(());
}
};
if let Err(err) = client.deploy(proj_path, false).await {
tracing::error!(%err);
}
let config = match OrdinaryConfig::get(proj_path, true) {
Ok(config) => config,
Err(err) => {
tracing::error!(%err, "failed to get config");
return Err(());
}
};
if config.content.is_some()
&& let Err(err) = client.update(proj_path).await
{
tracing::error!(%err, "failed to update content");
}
if config.assets.is_some()
&& let Err(err) = client.write_all(proj_path).await
{
tracing::error!(%err, "failed to write assets");
}
if config.templates.is_some()
&& let Err(err) = client.upload_all(proj_path).await
{
tracing::error!(%err, "failed to upload templates");
}
if config.actions.is_some()
&& let Err(err) = client.install_all(proj_path).await
{
tracing::error!(%err, "failed to install actions");
}
Ok(())
}
#[tauri::command]
#[instrument(skip_all)]
async fn app_logs(proj_path: &str, query: &str) -> Result<String, ()> {
let Ok(client) = OrdinaryApiClient::new(
"http://localhost:8080",
"root",
Some("api.ordinary.local"),
false,
USER_AGENT,
false,
) else {
return Err(());
};
match client.app_logs_search(proj_path, query, "all", None) {
Ok(res) => {
tracing::info!("logs retrieved");
Ok(res)
}
Err(err) => {
tracing::error!(%err, "failed to get logs");
Err(())
}
}
}
#[tauri::command]
#[instrument]
async fn new_project(proj_path: &str, domain: &str) -> Result<u16, ()> {
match ordinary_modify::project::new(proj_path, domain, &ManagedTemplate::V2RustAskama) {
Ok(()) => tracing::info!("new project created!"),
Err(err) => tracing::error!("{err}"),
}
get_deployed(proj_path).await
}
#[tauri::command]
#[instrument]
async fn new_template(proj_path: &str, name: &str, route: &str, mime: &str) -> Result<u16, ()> {
match ordinary_template::modify::add(
Path::new(proj_path),
name,
route,
mime,
&ManagedTemplate::V2RustAskama,
false,
false,
None,
None,
None,
) {
Ok(()) => tracing::info!("new template created!"),
Err(err) => tracing::error!("{err}"),
}
get_deployed(proj_path).await
}
#[tauri::command]
#[instrument]
async fn new_content_def(proj_path: &str, name: &str, fields: &str) -> Result<u16, ()> {
match ordinary_modify::content::add_def(proj_path, &name.to_string(), fields) {
Ok(()) => {
tracing::info!("new content definition!");
get_deployed(proj_path).await
}
Err(err) => {
tracing::error!("{err}");
Err(())
}
}
}
#[tauri::command]
#[instrument]
async fn edit_content_def(proj_path: &str, idx: u8, name: &str, fields: &str) -> Result<u16, ()> {
match ordinary_modify::content::edit_def(proj_path, idx, Some(name.to_string()), fields) {
Ok(()) => {
tracing::info!("edit content definition!");
get_deployed(proj_path).await
}
Err(err) => {
tracing::error!("{err}");
Err(())
}
}
}
#[tauri::command]
#[instrument]
async fn new_content_obj(proj_path: &str, object_json: &str) -> Result<(), ()> {
match ordinary_modify::content::add_obj(proj_path, object_json) {
Ok(()) => tracing::info!("new content object!"),
Err(err) => tracing::error!("{err}"),
}
get_updated(proj_path).await
}
#[tauri::command]
#[instrument]
async fn edit_content_obj(proj_path: &str, object_json: &str) -> Result<(), ()> {
match ordinary_modify::content::edit_obj(proj_path, object_json) {
Ok(()) => tracing::info!("edited content object!"),
Err(err) => tracing::error!("{err}"),
}
get_updated(proj_path).await
}
#[tauri::command]
#[instrument]
async fn delete_content_obj(proj_path: &str, instance_of: &str, uuid: &str) -> Result<(), ()> {
match ordinary_modify::content::delete_obj(proj_path, instance_of, uuid) {
Ok(()) => tracing::info!("delete content object!"),
Err(err) => tracing::error!("{err}"),
}
get_updated(proj_path).await
}
#[tauri::command]
#[instrument]
fn get_config(path: &str) -> Result<String, ()> {
match OrdinaryConfig::get(path, true) {
Ok(config) => match serde_json::to_string(&config) {
Ok(config_str) => return Ok(config_str),
Err(err) => tracing::error!("err: {}", err.to_string()),
},
Err(err) => tracing::error!("err: {}", err.to_string()),
}
Err(())
}
#[tauri::command]
#[instrument]
fn get_assets_paths(proj_path: &str, assets_dir_path: &str) -> String {
let full_path = Path::new(proj_path).join(assets_dir_path);
let paths = Arc::new(Mutex::new(vec![]));
match traverse(&full_path, &|entry| {
let path = entry.path();
if let Some(path) = path.to_str() {
let paths = Arc::clone(&paths);
let mut paths = paths.lock();
paths.push(path.to_string());
}
}) {
Ok(()) => {
let paths = Arc::clone(&paths);
let paths = paths.lock();
serde_json::to_string(&paths.clone()).unwrap_or_default()
}
Err(_) => String::new(),
}
}
#[tauri::command]
#[instrument]
fn get_file_as_string(proj_path: &str, file_path: &str) -> String {
let full_path = Path::new(proj_path).join(file_path);
if let Ok(file) = fs_err::read_to_string(full_path) {
return file;
}
String::new()
}
#[tauri::command]
#[instrument]
fn write_string_to_file(proj_path: &str, file_path: &str, value: &str) {
let full_path = Path::new(proj_path).join(file_path);
if let Err(err) = fs_err::write(full_path, value) {
tracing::error!("{err}");
}
}
#[tauri::command]
#[instrument]
fn get_file_as_b64(proj_path: &str, file_path: &str) -> String {
let full_path = Path::new(proj_path).join(file_path);
if let Ok(file) = fs_err::read(full_path) {
return b64.encode(file);
}
String::new()
}
#[allow(clippy::too_many_lines, clippy::missing_panics_doc)]
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
let data_dir = home_dir()
.expect("failed to get home dir")
.join(".ordinary")
.to_str()
.expect("failed to convert to clio")
.to_string();
let global_args = GlobalArgs {
data_dir: data_dir.clone(),
stored_logs: true,
log_level: LogLevel::Debug,
log_sizes: true,
stdio_logs: true,
stdio_logs_fmt: ordinaryd::fmt::StdioLogFmt::Concise,
journald_logs: false,
stdio_logs_timing: true,
};
let ordinaryd_init_args = Cli {
commands: Commands::Init {
api_init: ApiInit {
environment: "studio".to_string(),
storage_size: 10_000_000,
},
api_domain: "api.ordinary.local".to_string(),
password: "password".to_string(),
mfa_stored: true,
api_contacts: vec![],
app_domains: vec![],
privileged_domains: None,
},
global_args: global_args.clone(),
};
let ordinaryd_api_args = Cli {
commands: Commands::Api {
api_init: ApiInit {
environment: "studio".to_string(),
storage_size: 10_000_000,
},
app_api: AppApi {
provision: ProvisionMode::Localhost,
port: Some(8080),
redirect_port: None,
insecure: true,
insecure_cookies: true,
log_headers: true,
log_ips: false,
redacted_header_hash: "none".to_string(),
log_ttl_hours: 72,
log_rotation_file_size: 10_000_000,
log_rotation_mins: 60,
danger_dns_no_verify: true,
},
dedicated_ports: true,
swagger: true,
openapi: true,
},
global_args,
};
if let Ok(logger) = ordinaryd::setup(&ordinaryd_api_args) {
std::thread::spawn(move || {
let Ok(rt) = tokio::runtime::Runtime::new() else {
tracing::error!("failed to start runtime");
return;
};
rt.block_on(async {
if let Err(err) =
Box::pin(ordinaryd::run(&ordinaryd_init_args, logger.clone())).await
{
tracing::error!(%err, "ordinaryd");
}
if let Err(err) = Box::pin(ordinaryd::run(&ordinaryd_api_args, logger)).await {
tracing::error!(%err, "ordinaryd");
}
});
});
}
let span = tracing::info_span!("studio");
span.in_scope(|| {
if let Err(err) = tauri::Builder::default()
.setup(|app| {
let win_builder = WebviewWindowBuilder::new(app, "main", WebviewUrl::default())
.title("")
.inner_size(1600.0, 1000.0);
#[cfg(target_os = "macos")]
let win_builder = {
use tauri::{PhysicalPosition, Position, TitleBarStyle};
win_builder
.title_bar_style(TitleBarStyle::Overlay)
.traffic_light_position(Position::Physical(PhysicalPosition::new(20, 40)))
};
match win_builder.build() {
Ok(_) => Ok(()),
Err(err) => Err(err.into()),
}
})
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_opener::init())
.invoke_handler(tauri::generate_handler![
new_project,
new_template,
new_content_def,
edit_content_def,
new_content_obj,
edit_content_obj,
delete_content_obj,
check_for_installed_components,
install_rust_toolchain,
install_js_toolchain,
build,
list_accounts,
register,
login,
deploy,
kill,
app_accounts_list,
items_list,
write,
write_all,
upload,
upload_all,
install,
install_all,
update,
publish,
app_logs,
get_config,
get_assets_paths,
get_file_as_string,
write_string_to_file,
get_file_as_b64,
])
.run(tauri::generate_context!())
{
tracing::error!(%err, "failed to build or run");
}
});
}
pub fn traverse(dir: &Path, cb: &dyn Fn(&DirEntry)) -> std::io::Result<()> {
if dir.is_dir() {
for entry in fs_err::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
traverse(&path, cb)?;
} else {
cb(&entry);
}
}
}
Ok(())
}