/* * Author: Ruben Fiszel * Copyright: Windmill Labs, Inc 2022 * This file and its contents are licensed under the AGPLv3 License. * Please see the included NOTICE for copyright information and * LICENSE-AGPL for a copy of the license. */ use std::{str::FromStr, sync::Arc}; use crate::{ db::{UserDB, DB}, folders::Folder, resources::{Resource, ResourceType}, users::{Authed, WorkspaceInvite}, utils::require_super_admin, BaseUrl, }; use axum::{ body::StreamBody, extract::{Extension, Path, Query}, headers, response::{IntoResponse, Redirect}, routing::{delete, get, post}, Json, Router, }; use stripe::CustomerId; use windmill_audit::{audit_log, ActionKind}; use windmill_common::{ error::{to_anyhow, Error, JsonResult, Result}, flows::Flow, scripts::{Schema, Script, ScriptLang}, utils::{paginate, rd_string, require_admin, Pagination}, variables::ExportableListableVariable, }; use hyper::{header, StatusCode}; use serde::{Deserialize, Serialize}; use sqlx::{FromRow, Postgres, Transaction}; use tempfile::TempDir; use tokio::fs::File; use tokio_util::io::ReaderStream; pub fn workspaced_service() -> Router { Router::new() .route("/list_pending_invites", get(list_pending_invites)) .route("/update", post(edit_workspace)) .route("/archive", post(archive_workspace)) .route("/invite_user", post(invite_user)) .route("/add_user", post(add_user)) .route("/delete_invite", post(delete_invite)) .route("/get_settings", get(get_settings)) .route("/edit_slack_command", post(edit_slack_command)) .route("/edit_auto_invite", post(edit_auto_invite)) .route("/tarball", get(tarball_workspace)) .route("/premium_info", get(premium_info)) .route("/checkout", get(stripe_checkout)) .route("/billing_portal", get(stripe_portal)) } pub fn global_service() -> Router { Router::new() .route("/list_as_superadmin", get(list_workspaces_as_super_admin)) .route("/list", get(list_workspaces)) .route("/users", get(user_workspaces)) .route("/create", post(create_workspace)) .route("/exists", post(exists_workspace)) .route("/exists_username", post(exists_username)) .route("/allowed_domain_auto_invite", get(is_allowed_auto_domain)) .route("/unarchive/:workspace", post(unarchive_workspace)) .route("/delete/:workspace", delete(delete_workspace)) } #[derive(FromRow, Serialize)] struct Workspace { id: String, name: String, owner: String, deleted: bool, premium: bool, } #[derive(FromRow, Serialize, Debug)] pub struct WorkspaceSettings { pub workspace_id: String, pub slack_team_id: Option, pub slack_name: Option, pub slack_command_script: Option, pub slack_email: String, pub auto_invite_domain: Option, pub auto_invite_operator: Option, pub customer_id: Option, pub plan: Option, } #[derive(FromRow, Serialize, Debug)] pub struct Usage { pub workspace_id: String, pub slack_team_id: Option, pub slack_name: Option, pub slack_command_script: Option, pub slack_email: String, } #[derive(sqlx::Type, Serialize, Deserialize, Debug)] #[sqlx(type_name = "WORKSPACE_KEY_KIND", rename_all = "lowercase")] pub enum WorkspaceKeyKind { Cloud, } #[derive(Deserialize)] struct EditCommandScript { slack_command_script: Option, } #[derive(Deserialize)] struct EditAutoInvite { operator: Option, } #[derive(Deserialize)] struct CreateWorkspace { id: String, name: String, username: String, } #[derive(Deserialize)] struct EditWorkspace { name: String, owner: String, } #[derive(Serialize)] struct WorkspaceList { pub email: String, pub workspaces: Vec, } #[derive(Serialize)] struct UserWorkspace { pub id: String, pub name: String, pub username: String, } #[derive(Deserialize)] struct WorkspaceId { pub id: String, } #[derive(Deserialize)] struct ValidateUsername { pub id: String, pub username: String, } #[derive(Deserialize)] pub struct NewWorkspaceInvite { pub email: String, pub is_admin: bool, pub operator: bool, } #[derive(Deserialize)] pub struct NewWorkspaceUser { pub email: String, pub username: String, pub is_admin: bool, pub operator: bool, } async fn list_pending_invites( authed: Authed, Extension(user_db): Extension, Path(w_id): Path, ) -> JsonResult> { require_admin(authed.is_admin, &authed.username)?; let mut tx = user_db.begin(&authed).await?; let rows = sqlx::query_as!( WorkspaceInvite, "SELECT * from workspace_invite WHERE workspace_id = $1", w_id ) .fetch_all(&mut tx) .await?; tx.commit().await?; Ok(Json(rows)) } #[derive(Serialize, FromRow)] pub struct PremiumWorkspaceInfo { pub premium: bool, pub usage: Option, } async fn premium_info( authed: Authed, Extension(db): Extension, Path(w_id): Path, ) -> JsonResult { require_admin(authed.is_admin, &authed.username)?; let mut tx = db.begin().await?; let row = sqlx::query_as::<_, PremiumWorkspaceInfo>( "SELECT premium, usage.usage FROM workspace LEFT JOIN usage ON workspace.id = usage.id AND usage.is_workspace IS true WHERE workspace.id = $1", ) .bind(w_id) .fetch_one(&mut tx) .await?; tx.commit().await?; Ok(Json(row)) } #[derive(Deserialize)] struct PlanQuery { plan: String, } async fn stripe_checkout( authed: Authed, Path(w_id): Path, Query(plan): Query, Extension(base_url): Extension>, ) -> Result { // #[cfg(feature = "enterprise")] { require_admin(authed.is_admin, &authed.username)?; let client = stripe::Client::new(std::env::var("STRIPE_KEY").expect("STRIPE_KEY")); let success_rd = format!( "{}/workspace_settings?session={{CHECKOUT_SESSION_ID}}", base_url.0 ); let failure_rd = format!("{}/workspace_settings", base_url.0); let checkout_session = { let mut params = stripe::CreateCheckoutSession::new(&failure_rd, &success_rd); params.mode = Some(stripe::CheckoutSessionMode::Subscription); params.line_items = match plan.plan.as_str() { "team" => Some(vec![ stripe::CreateCheckoutSessionLineItems { quantity: None, price: Some("price_1MQzMHGU3NdFi9eLWFC7IXEv".to_string()), ..Default::default() }, stripe::CreateCheckoutSessionLineItems { quantity: None, price: Some("price_1MR2BZGU3NdFi9eLNRuibxPx".to_string()), ..Default::default() }, ]), "enterprise" => Some(vec![]), _ => Err(Error::BadRequest("invalid plan".to_string()))?, }; params.customer_email = Some(&authed.email); params.client_reference_id = Some(&w_id); stripe::CheckoutSession::create(&client, params) .await .unwrap() }; let uri = checkout_session .url .ok_or_else(|| Error::InternalErr(format!("stripe checkout redirect issue")))?; Ok(Redirect::to(&uri)) } } async fn stripe_portal( authed: Authed, Path(w_id): Path, Extension(db): Extension, Extension(base_url): Extension>, ) -> Result { require_admin(authed.is_admin, &authed.username)?; let customer_id = sqlx::query_scalar!( "SELECT customer_id FROM workspace_settings WHERE workspace_id = $1", w_id ) .fetch_one(&db) .await? .ok_or_else(|| Error::InternalErr(format!("no customer id for workspace {}", w_id)))?; let client = stripe::Client::new(std::env::var("STRIPE_KEY").expect("STRIPE_KEY")); let success_rd = format!("{}/workspace_settings", base_url.0); let portal_session = { let customer_id = CustomerId::from_str(&customer_id).unwrap(); let mut params = stripe::CreateBillingPortalSession::new(customer_id); params.return_url = Some(&success_rd); stripe::BillingPortalSession::create(&client, params) .await .map_err(to_anyhow)? }; Ok(Redirect::to(&portal_session.url)) } async fn exists_workspace( authed: Authed, Extension(user_db): Extension, Json(WorkspaceId { id }): Json, ) -> JsonResult { let mut tx = user_db.begin(&authed).await?; let exists = sqlx::query_scalar!( "SELECT EXISTS(SELECT 1 FROM workspace WHERE workspace.id = $1)", id ) .fetch_one(&mut tx) .await? .unwrap_or(false); tx.commit().await?; Ok(Json(exists)) } async fn list_workspaces( authed: Authed, Extension(user_db): Extension, ) -> JsonResult> { let mut tx = user_db.begin(&authed).await?; let workspaces = sqlx::query_as!( Workspace, "SELECT workspace.* FROM workspace, usr WHERE usr.workspace_id = workspace.id AND \ usr.email = $1 AND deleted = false", authed.email ) .fetch_all(&mut tx) .await?; tx.commit().await?; Ok(Json(workspaces)) } async fn get_settings( authed: Authed, Path(w_id): Path, Extension(user_db): Extension, ) -> JsonResult { let mut tx = user_db.begin(&authed).await?; let settings = sqlx::query_as!( WorkspaceSettings, "SELECT * FROM workspace_settings WHERE workspace_id = $1", &w_id ) .fetch_one(&mut tx) .await .map_err(|e| Error::InternalErr(format!("getting settings: {e}")))?; tx.commit().await?; Ok(Json(settings)) } async fn edit_slack_command( authed: Authed, Extension(db): Extension, Path(w_id): Path, Authed { is_admin, username, .. }: Authed, Json(es): Json, ) -> Result { require_admin(is_admin, &username)?; let mut tx = db.begin().await?; sqlx::query!( "UPDATE workspace_settings SET slack_command_script = $1 WHERE workspace_id = $2", es.slack_command_script, &w_id ) .execute(&mut tx) .await?; audit_log( &mut tx, &authed.username, "workspaces.edit_command_script", ActionKind::Update, &w_id, Some(&authed.email), Some( [( "script", es.slack_command_script .unwrap_or("NO_SCRIPT".to_string()) .as_str(), )] .into(), ), ) .await?; tx.commit().await?; Ok(format!("Edit command script {}", &w_id)) } const BANNED_DOMAINS: &str = include_str!("../banned_domains.txt"); async fn is_allowed_auto_domain(Authed { email, .. }: Authed) -> JsonResult { let domain = email.split('@').last().unwrap(); return Ok(Json(!BANNED_DOMAINS.contains(domain))); } async fn edit_auto_invite( authed: Authed, Extension(db): Extension, Path(w_id): Path, Authed { is_admin, email, username, .. }: Authed, Json(ea): Json, ) -> Result { require_admin(is_admin, &username)?; let domain = email.split('@').last().unwrap(); let mut tx = db.begin().await?; if let Some(operator) = ea.operator { if BANNED_DOMAINS.contains(domain) { return Err(Error::BadRequest(format!( "Domain {} is not allowed", domain ))); } sqlx::query!( "UPDATE workspace_settings SET auto_invite_domain = $1, auto_invite_operator = $2 WHERE workspace_id = $3", domain, operator, &w_id ) .execute(&mut tx) .await?; sqlx::query!( "INSERT INTO workspace_invite (workspace_id, email, is_admin, operator) SELECT $1::text, email, false, $3 FROM password WHERE email LIKE CONCAT('%', $2::text) AND NOT EXISTS ( SELECT 1 FROM usr WHERE workspace_id = $1::text AND email = password.email ) ON CONFLICT DO NOTHING", &w_id, &domain, operator ) .execute(&mut tx) .await?; } else { sqlx::query!( "UPDATE workspace_settings SET auto_invite_domain = NULL, auto_invite_operator = NULL WHERE workspace_id = $1", &w_id, ) .execute(&mut tx) .await?; } audit_log( &mut tx, &authed.username, "workspaces.edit_auto_invite_domain", ActionKind::Update, &w_id, Some(&authed.email), Some([("operator", &format!("{:?}", ea.operator)[..])].into()), ) .await?; tx.commit().await?; Ok(format!( "Edit auto-invite for workspace {} to {}", &w_id, domain )) } async fn list_workspaces_as_super_admin( authed: Authed, Extension(user_db): Extension, Query(pagination): Query, Authed { email, .. }: Authed, ) -> JsonResult> { let mut tx = user_db.begin(&authed).await?; require_super_admin(&mut tx, &email).await?; let (per_page, offset) = paginate(pagination); let workspaces = sqlx::query_as!( Workspace, "SELECT * FROM workspace LIMIT $1 OFFSET $2", per_page as i32, offset as i32 ) .fetch_all(&mut tx) .await?; tx.commit().await?; Ok(Json(workspaces)) } async fn user_workspaces( Extension(db): Extension, Authed { email, .. }: Authed, ) -> JsonResult { let mut tx = db.begin().await?; let workspaces = sqlx::query_as!( UserWorkspace, "SELECT workspace.id, workspace.name, usr.username FROM workspace, usr WHERE usr.workspace_id = workspace.id AND usr.email = $1 AND deleted = \ false", email ) .fetch_all(&mut tx) .await?; tx.commit().await?; Ok(Json(WorkspaceList { email, workspaces })) } async fn check_name_conflict<'c>(tx: &mut Transaction<'c, Postgres>, w_id: &str) -> Result<()> { let exists = sqlx::query_scalar!("SELECT EXISTS(SELECT 1 FROM workspace WHERE id = $1)", w_id) .fetch_one(tx) .await? .unwrap_or(false); if exists { return Err(windmill_common::error::Error::BadRequest(format!( "Workspace {} already exists", w_id ))); } return Ok(()); } async fn create_workspace( authed: Authed, Extension(db): Extension, Json(nw): Json, ) -> Result { if &nw.username == "bot" { return Err(Error::BadRequest("bot is a reserved username".to_string())); } let mut tx = db.begin().await?; check_name_conflict(&mut tx, &nw.id).await?; sqlx::query!( "INSERT INTO workspace (id, name, owner) VALUES ($1, $2, $3)", nw.id, nw.name, authed.email, ) .execute(&mut tx) .await?; sqlx::query!( "INSERT INTO workspace_settings (workspace_id) VALUES ($1)", nw.id ) .execute(&mut tx) .await?; let key = rd_string(64); sqlx::query!( "INSERT INTO workspace_key (workspace_id, kind, key) VALUES ($1, 'cloud', $2)", nw.id, &key ) .execute(&mut tx) .await?; let mc = magic_crypt::new_magic_crypt!(key, 256); sqlx::query!( "INSERT INTO variable (workspace_id, path, value, is_secret, description) VALUES ($1, 'g/all/pretty_secret', $2, true, 'This item is secret'), ($3, 'g/all/not_secret', $4, false, 'This item is not secret')", nw.id, crate::variables::encrypt(&mc, "pretty secret value"), nw.id, "finland does not actually exist", ) .execute(&mut tx) .await?; sqlx::query!( "INSERT INTO usr (workspace_id, email, username, is_admin) VALUES ($1, $2, $3, true)", nw.id, authed.email, nw.username, ) .execute(&mut tx) .await?; sqlx::query!( "INSERT INTO group_ VALUES ($1, 'all', 'The group that always contains all users of this workspace')", nw.id ) .execute(&mut tx) .await?; sqlx::query!( "INSERT INTO usr_to_group VALUES ($1, 'all', $2)", nw.id, nw.username ) .execute(&mut tx) .await?; audit_log( &mut tx, &authed.username, "workspaces.create", ActionKind::Create, &nw.id, Some(nw.name.as_str()), None, ) .await?; tx.commit().await?; Ok(format!("Created workspace {}", &nw.id)) } async fn edit_workspace( authed: Authed, Extension(db): Extension, Path(w_id): Path, Authed { is_admin, username, .. }: Authed, Json(ew): Json, ) -> Result { require_admin(is_admin, &username)?; let mut tx = db.begin().await?; sqlx::query!( "UPDATE workspace SET name = $1, owner = $2 WHERE id = $3", ew.name, ew.owner, &w_id ) .execute(&mut tx) .await?; audit_log( &mut tx, &authed.username, "workspaces.update", ActionKind::Update, &w_id, Some(&authed.email), None, ) .await?; tx.commit().await?; Ok(format!("Updated workspace {}", &w_id)) } async fn archive_workspace( Extension(db): Extension, Path(w_id): Path, Authed { is_admin, username, email, .. }: Authed, ) -> Result { require_admin(is_admin, &username)?; let mut tx = db.begin().await?; sqlx::query!("UPDATE workspace SET deleted = true WHERE id = $1", &w_id) .execute(&mut tx) .await?; audit_log( &mut tx, &username, "workspaces.archive", ActionKind::Update, &w_id, Some(&email), None, ) .await?; tx.commit().await?; Ok(format!("Archived workspace {}", &w_id)) } async fn unarchive_workspace( Extension(db): Extension, Path(w_id): Path, Authed { is_admin, username, email, .. }: Authed, ) -> Result { require_admin(is_admin, &username)?; let mut tx = db.begin().await?; sqlx::query!("UPDATE workspace SET deleted = false WHERE id = $1", &w_id) .execute(&mut tx) .await?; audit_log( &mut tx, &username, "workspaces.unarchive", ActionKind::Update, &w_id, Some(&email), None, ) .await?; tx.commit().await?; Ok(format!("Unarchived workspace {}", &w_id)) } async fn delete_workspace( Extension(db): Extension, Path(w_id): Path, Authed { username, email, .. }: Authed, ) -> Result { let w_id = match w_id.as_str() { "starter" => Err(Error::BadRequest( "starter workspace cannot be deleted".to_string(), )), "admins" => Err(Error::BadRequest( "admins workspace cannot be deleted".to_string(), )), _ => Ok(w_id), }?; let mut tx = db.begin().await?; require_super_admin(&mut tx, &email).await?; sqlx::query!("DELETE FROM script WHERE workspace_id = $1", &w_id) .execute(&mut tx) .await?; sqlx::query!("DELETE FROM flow WHERE workspace_id = $1", &w_id) .execute(&mut tx) .await?; sqlx::query!("DELETE FROM app WHERE workspace_id = $1", &w_id) .execute(&mut tx) .await?; sqlx::query!("DELETE FROM variable WHERE workspace_id = $1", &w_id) .execute(&mut tx) .await?; sqlx::query!("DELETE FROM resource WHERE workspace_id = $1", &w_id) .execute(&mut tx) .await?; sqlx::query!("DELETE FROM schedule WHERE workspace_id = $1", &w_id) .execute(&mut tx) .await?; sqlx::query!("DELETE FROM completed_job WHERE workspace_id = $1", &w_id) .execute(&mut tx) .await?; sqlx::query!("DELETE FROM usr WHERE workspace_id = $1", &w_id) .execute(&mut tx) .await?; sqlx::query!( "DELETE FROM workspace_invite WHERE workspace_id = $1", &w_id ) .execute(&mut tx) .await?; sqlx::query!("DELETE FROM usr_to_group WHERE workspace_id = $1", &w_id) .execute(&mut tx) .await?; sqlx::query!("DELETE FROM group_ WHERE workspace_id = $1", &w_id) .execute(&mut tx) .await?; sqlx::query!("DELETE FROM folder WHERE workspace_id = $1", &w_id) .execute(&mut tx) .await?; sqlx::query!("DELETE FROM workspace_key WHERE workspace_id = $1", &w_id) .execute(&mut tx) .await?; sqlx::query!( "DELETE FROM workspace_settings WHERE workspace_id = $1", &w_id ) .execute(&mut tx) .await?; sqlx::query!("DELETE FROM workspace WHERE id = $1", &w_id) .execute(&mut tx) .await?; audit_log( &mut tx, &username, "workspaces.delete", ActionKind::Delete, &w_id, Some(&email), None, ) .await?; tx.commit().await?; Ok(format!("Deleted workspace {}", &w_id)) } pub async fn invite_user_to_all_auto_invite_worspaces(db: &DB, email: &str) -> Result<()> { let mut tx = db.begin().await?; let domain = email.split('@').last().unwrap(); let workspaces = sqlx::query!( "SELECT workspace_id, auto_invite_operator FROM workspace_settings WHERE auto_invite_domain = $1", domain ) .fetch_all(&mut tx) .await?; for r in workspaces { sqlx::query!( "INSERT INTO workspace_invite (workspace_id, email, is_admin, operator) VALUES ($1, $2, false, $3) ON CONFLICT DO NOTHING", r.workspace_id, email, r.auto_invite_operator ) .execute(&mut tx) .await?; } tx.commit().await?; Ok(()) } async fn invite_user( Authed { username, is_admin, .. }: Authed, Extension(db): Extension, Path(w_id): Path, Json(nu): Json, ) -> Result<(StatusCode, String)> { require_admin(is_admin, &username)?; let mut tx = db.begin().await?; sqlx::query!( "INSERT INTO workspace_invite (workspace_id, email, is_admin, operator) VALUES ($1, $2, $3, $4)", &w_id, nu.email, nu.is_admin, nu.operator ) .execute(&mut tx) .await?; tx.commit().await?; Ok(( StatusCode::CREATED, format!("user with email {} invited", nu.email), )) } async fn add_user( Authed { username, is_admin, .. }: Authed, Extension(db): Extension, Path(w_id): Path, Json(nu): Json, ) -> Result<(StatusCode, String)> { require_admin(is_admin, &username)?; let mut tx = db.begin().await?; sqlx::query!( "INSERT INTO usr (workspace_id, email, username, is_admin, operator) VALUES ($1, $2, $3, $4, $5)", &w_id, nu.email, nu.username, nu.is_admin, nu.operator ) .execute(&mut tx) .await?; tx.commit().await?; Ok(( StatusCode::CREATED, format!("user with email {} added", nu.email), )) } async fn delete_invite( Authed { username, is_admin, .. }: Authed, Extension(db): Extension, Path(w_id): Path, Json(nu): Json, ) -> Result<(StatusCode, String)> { require_admin(is_admin, &username)?; let mut tx = db.begin().await?; sqlx::query!( "DELETE FROM workspace_invite WHERE workspace_id = $1 AND email = $2 AND is_admin = $3 AND operator = $4", &w_id, nu.email, nu.is_admin, nu.operator ) .execute(&mut tx) .await?; tx.commit().await?; Ok(( StatusCode::CREATED, format!("invite to email {} deleted", nu.email), )) } async fn exists_username( Extension(db): Extension, Json(vu): Json, ) -> Result { let exists = sqlx::query_scalar!( "SELECT EXISTS(SELECT 1 FROM usr WHERE username = $1 AND workspace_id = $2)", vu.username, vu.id ) .fetch_one(&db) .await? .unwrap_or(true); if exists { return Err(Error::BadRequest("username already taken".to_string())); } Ok("valid username".to_string()) } #[derive(Serialize)] struct ScriptMetadata { summary: String, description: String, schema: Option, is_template: bool, lock: Vec, } async fn tarball_workspace( authed: Authed, Extension(db): Extension, Path(w_id): Path, ) -> Result<([(headers::HeaderName, String); 2], impl IntoResponse)> { require_admin(authed.is_admin, &authed.username)?; let tmp_dir = TempDir::new_in(".")?; let name = format!("windmill-{w_id}.tar"); let file_path = tmp_dir.path().join(&name); let file = File::create(&file_path).await?; let mut a = tokio_tar::Builder::new(file); { let folders = sqlx::query_as::<_, Folder>("SELECT * FROM folder WHERE workspace_id = $1") .bind(&w_id) .fetch_all(&db) .await?; for folder in folders { write_to_archive( serde_json::to_string_pretty(&folder).unwrap(), format!("f/{}/folder.meta.json", folder.name), &mut a, ) .await?; } } { let scripts = sqlx::query_as::<_, Script>( "SELECT * FROM script as o WHERE workspace_id = $1 AND archived = false AND created_at = (select max(created_at) from script where path = o.path AND \ workspace_id = $1)", ) .bind(&w_id) .fetch_all(&db) .await?; for script in scripts { let ext = match script.language { ScriptLang::Python3 => "py", ScriptLang::Deno => "ts", ScriptLang::Go => "go", ScriptLang::Bash => "sh", }; write_to_archive(script.content, format!("{}.{}", script.path, ext), &mut a).await?; let lock = script .lock .unwrap_or_else(|| "".to_string()) .lines() .map(|x| x.to_string()) .collect(); let metadata = ScriptMetadata { summary: script.summary, description: script.description, schema: script.schema, is_template: script.is_template, lock, }; let metadata_str = serde_json::to_string_pretty(&metadata).unwrap(); write_to_archive(metadata_str, format!("{}.script.json", script.path), &mut a).await?; } } { let resources = sqlx::query_as!( Resource, "SELECT * FROM resource WHERE workspace_id = $1", &w_id ) .fetch_all(&db) .await?; for resource in resources { let resource_str = serde_json::to_string_pretty(&resource).unwrap(); write_to_archive( resource_str, format!("{}.resource.json", resource.path), &mut a, ) .await?; } } { let resource_types = sqlx::query_as!( ResourceType, "SELECT * FROM resource_type WHERE workspace_id = $1", &w_id ) .fetch_all(&db) .await?; for resource_type in resource_types { let resource_str = serde_json::to_string_pretty(&resource_type).unwrap(); write_to_archive( resource_str, format!("{}.resource-type.json", resource_type.name), &mut a, ) .await?; } } { let flows = sqlx::query_as::<_, Flow>( "SELECT * FROM flow WHERE workspace_id = $1 AND archived = false", ) .bind(&w_id) .fetch_all(&db) .await?; for flow in flows { let flow_str = serde_json::to_string_pretty(&flow).unwrap(); write_to_archive(flow_str, format!("{}.flow.json", flow.path), &mut a).await?; } } { let variables = sqlx::query_as::<_, ExportableListableVariable>( "SELECT *, false as is_expired FROM variable WHERE workspace_id = $1 AND is_secret = false", ) .bind(&w_id) .fetch_all(&db) .await?; for var in variables { let flow_str = serde_json::to_string_pretty(&var).unwrap(); write_to_archive(flow_str, format!("{}.variable.json", var.path), &mut a).await?; } } a.into_inner().await?; let file = tokio::fs::File::open(file_path).await?; let stream = ReaderStream::new(file); let body = StreamBody::new(stream); let headers = [ (header::CONTENT_TYPE, "application/x-tar".to_string()), ( header::CONTENT_DISPOSITION, format!("attachment; filename=\"{name}\""), ), ]; Ok((headers, body)) } async fn write_to_archive( content: String, path: String, a: &mut tokio_tar::Builder, ) -> Result<()> { let bytes = content.as_bytes(); let mut header = tokio_tar::Header::new_gnu(); header.set_size(bytes.len() as u64); header.set_mtime(0); header.set_uid(0); header.set_gid(0); header.set_mode(0o777); header.set_cksum(); a.append_data(&mut header, path, bytes).await?; Ok(()) }