* refactor: replace email with permissioned_as for triggers/schedules
Add a new `permissioned_as` column (format: `u/{username}`, `g/{group}`,
or raw email) to all trigger tables and schedule. This value is used
directly for job permission checks, removing the need for email lookups
when creating/updating triggers.
- Migration: add permissioned_as to all 9 trigger tables + schedule,
drop email from trigger tables (schedule keeps it for backwards compat)
- Backend: resolve_email() (async, DB) -> resolve_permissioned_as() (sync)
- Email cache: get_email_from_permissioned_as() with quick_cache for
places that still need email (fetch_api_authed, schedule backwards compat)
- Frontend: rename email/preserve_email -> permissioned_as/preserve_permissioned_as
in deploy data and OpenAPI schemas
- Tests updated for new field names and u/{username} format
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix sqlx/build
* update ee ref
* refactor: simplify resolve_edited_by to always use authed username
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix compile + migration
* update ref
* test: add trigger trait method tests for permissioned_as queries
Add tests that call TriggerCrud and Listener trait methods directly
to verify dynamic SQL correctly references the permissioned_as column.
Covers get_trigger_by_path, list_triggers, set_trigger_mode, and
fetch_enabled_unlistened_triggers for all trigger types.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* update sqlx
* fix: use permissioned_as directly for schedules and fix audit RLS for groups
- Schedule: permissioned_as only set on create, not on edit/set_enabled
- Schedule: stop reading email column, use get_email_from_permissioned_as
- Triggers: use fetch_api_authed_from_permissioned_as instead of edited_by
- Triggers: rename listener fields for clarity (username -> edited_by)
- Fix audit author username for group permissioned_as (g/test -> group-test)
to match session.user, preventing RLS policy violations on audit_partitioned
- OpenAPI: remove permissioned_as/preserve_permissioned_as from EditSchedule
- Add backwards-compat comments for schedule email writes
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* chore: regenerate system prompts for permissioned_as field
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix build
* refactor: generalize onBehalfOf naming, add permissioned_as to EditSchedule
- Frontend: rename onBehalfOfPermissionedAs -> onBehalfOf with comments
explaining it carries emails for flows/scripts and permissioned_as for
triggers/schedules
- Frontend: rename getOnBehalfOfEmail -> getOnBehalfOf,
getOnBehalfOfPermissionedAsForDeploy -> getOnBehalfOfForDeploy,
customOnBehalfOfEmails -> customOnBehalfOf
- Backend: add optional permissioned_as/preserve_permissioned_as to
EditSchedule with COALESCE (only updates when provided)
- Backend: add on_behalf_of audit log for schedule edit
- Backend: remove unused resolve_on_behalf_of_permissioned_as
- Tests: remove email assertions from schedule update test (email is
just backwards compat, only permissioned_as matters)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: preserve email column when permissioned_as is preserved on schedule edit
Derive email from the preserved permissioned_as via cache lookup instead
of always writing authed.email. This keeps the email column consistent
with the old behavior for backwards compat with old workers.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: update deploy UI labels from "edited by" to "run as" for triggers
Triggers now use permissioned_as (not edited_by) for permissions, so
update the deploy UI wording to reflect this. Also update wm_deployers
group description to mention schedules and permissioned_as.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: use u/username format for custom trigger/schedule deploy selection
When picking a custom user for trigger/schedule deployment, store
u/${username} (permissioned_as format) instead of the email. Flows/scripts
continue to use email format for on_behalf_of_email.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: show u/username format for "me" option in trigger deploy selector
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* refactor: simplify OnBehalfOfSelector to return the right format per kind
OnBehalfOfSelector now handles the email vs permissioned_as format
internally based on kind:
- triggers: returns u/username, displays u/username in all options
- flows/scripts/apps: returns email, displays username
The onSelect callback now takes (choice, value?) where value is already
in the correct format. Parent components just store it directly without
needing to know about the format difference.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: always show u/username format in OnBehalfOfSelector for all kinds
Display is now consistent: all kinds show u/username in the selector.
The returned value still differs (email for flows/scripts, u/username
for triggers) since the backend APIs expect different formats.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: replace email with permissioned_as in http_trigger test insert
The email column was dropped from trigger tables in the migration.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: review fixes — migration, app policy, capture cleanup, naming
- Migration: remove DEFAULT '', use nullable → populate → SET NOT NULL
- App policy: set both on_behalf_of and on_behalf_of_email for all choices
- OnBehalfOfSelector: return OnBehalfOfDetails {email, permissionedAs} instead of ambiguous value
- Remove unused email field from Capture struct and query
- Rename getSourceEmail/getTargetEmail → getSourceOnBehalfOf/getTargetOnBehalfOf
- Rename test functions from preserve_email to preserve_permissioned_as
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: add permissioned_as to all test schedule INSERTs
Since the migration no longer uses DEFAULT '', all INSERTs must
explicitly provide permissioned_as. Updated test fixtures and
schedule_push tests.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: strip permissioned_as from exports/sync, fix OpenAPI required field
- Add permissioned_as to workspace export strip list (like edited_by)
- Add permissioned_as to CLI TriggerFile Omit list
- Fix TriggerExtraProperty.required: email → permissioned_as
- Regenerate frontend and CLI types
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: remove accidentally committed generated files
These directories are gitignored and should not be tracked.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* chore: regenerate system prompts for permissioned_as schema changes
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: remove permissioned_as from CLI TriggerFile Omit list
Already stripped in workspace export, no need to also omit from the type.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: optimize email cache key and revert TriggerFile Omit change
- Use single concatenated string for cache key instead of (String, String) tuple
- Remove permissioned_as from CLI TriggerFile Omit (already stripped in export)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: zero-allocation email cache lookups using Equivalent trait
Use a borrowed EmailCacheKey(&str, &str) for cache lookups via
quick_cache's Equivalent support. Only allocates (String, String)
on cache miss for insert. This is called on every trigger fire
and schedule push.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: add permissioned_as to Schedule required fields in OpenAPI spec
The backend always returns permissioned_as (non-optional String),
so the schema should reflect that.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: handle group- prefix in migration UPDATE statements
edited_by can be 'group-{name}' for group-owned triggers/schedules.
The migration now correctly maps these to 'g/{name}' format instead
of incorrectly producing 'u/group-{name}'.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Revert "fix: handle group- prefix in migration UPDATE statements"
This reverts commit 0971392b38.
* fix: use superadmin email to resolve permissioned_as in schedule migration
For users upgrading from older versions where edited_by may not reflect
the actual schedule owner, check if the email belongs to a superadmin
and look up their username. Otherwise fall back to edited_by.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: fall back to superadmin email when not in workspace usr table
If the superadmin isn't a member of the workspace, use their email
as raw permissioned_as instead of falling back to edited_by.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: always update permissioned_as and email on schedule edit
Consistent with pre-refactor behavior where email and edited_by
were always updated on every edit. permissioned_as is now always
set (to editing user or preserved value), removing the COALESCE
that previously preserved it when not provided.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat: add schedule permission tests and centralize group prefix constants
Tests: schedule create/update for normal user, workspace admin, and
superadmin not in workspace. Verifies schedule fields (email,
permissioned_as, edited_by) and pushed job fields (permissioned_as,
permissioned_as_email).
Constants: centralize "u/", "g/", "group-" as PERMISSIONED_AS_USER_PREFIX,
PERMISSIONED_AS_GROUP_PREFIX, USERNAME_GROUP_PREFIX.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: use @unknown.windmill.dev for synthetic email fallback
Prevents privilege escalation: a user with username like
'superadmin_secret' would get superadmin via the synthetic
email matching SUPERADMIN_SECRET_EMAIL. Using a different
subdomain avoids any collision with hardcoded @windmill.dev emails.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* update ee ref
* sqlx
* chore: regenerate system prompts after main merge
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* chore: update ee-repo-ref to bda51bc33bcb573659e7ff07d0a23ff6e23b8148
This commit updates the EE repository reference after PR #468 was merged in windmill-ee-private.
Previous ee-repo-ref: 8cf1802f8fe183f430830590b4f3172a50207843
New ee-repo-ref: bda51bc33bcb573659e7ff07d0a23ff6e23b8148
Automated by sync-ee-ref workflow.
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
1114 lines
40 KiB
Rust
1114 lines
40 KiB
Rust
/*
|
|
* 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::collections::HashMap;
|
|
|
|
use crate::db::ApiAuthed;
|
|
|
|
use crate::{apps::AppWithLastVersion, db::DB, folders::Folder};
|
|
|
|
#[cfg(any(
|
|
feature = "http_trigger",
|
|
feature = "websocket",
|
|
feature = "postgres_trigger",
|
|
feature = "mqtt_trigger",
|
|
all(
|
|
feature = "enterprise",
|
|
any(
|
|
feature = "kafka",
|
|
feature = "sqs_trigger",
|
|
feature = "gcp_trigger",
|
|
feature = "nats",
|
|
feature = "smtp",
|
|
),
|
|
feature = "private"
|
|
)
|
|
))]
|
|
use crate::triggers::TriggerCrud;
|
|
|
|
use axum::{
|
|
extract::{Extension, Path, Query},
|
|
response::IntoResponse,
|
|
};
|
|
|
|
use http::HeaderName;
|
|
use itertools::Itertools;
|
|
|
|
use windmill_common::runnable_settings::{ConcurrencySettings, DebouncingSettings};
|
|
use windmill_common::scripts::ScriptRunnableSettingsHandle;
|
|
use windmill_common::utils::require_admin;
|
|
use windmill_common::variables::decrypt;
|
|
use windmill_common::worker::WINDMILL_DIR;
|
|
use windmill_common::{
|
|
db::UserDB,
|
|
error::{to_anyhow, Error, Result},
|
|
flows::Flow,
|
|
schedule::Schedule,
|
|
scripts::{Schema, Script, ScriptLang},
|
|
variables::{build_crypt, ExportableListableVariable},
|
|
workspace_dependencies::WorkspaceDependencies,
|
|
};
|
|
|
|
use hyper::header;
|
|
use serde::{Deserialize, Serialize};
|
|
use serde_json::Value;
|
|
use tempfile::TempDir;
|
|
use tokio::fs::File;
|
|
use tokio_util::io::ReaderStream;
|
|
use windmill_store::resources::{Resource, ResourceType};
|
|
|
|
#[derive(Serialize)]
|
|
struct ScriptMetadata {
|
|
summary: String,
|
|
description: String,
|
|
schema: Option<Schema>,
|
|
lock: Option<String>,
|
|
kind: String,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
envs: Option<Vec<String>>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
cache_ttl: Option<i32>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
dedicated_worker: Option<bool>,
|
|
#[serde(skip_serializing_if = "is_none_or_false")]
|
|
ws_error_handler_muted: Option<bool>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
priority: Option<i16>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
tag: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub timeout: Option<i32>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub delete_after_use: Option<bool>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub restart_unless_cancelled: Option<bool>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub visible_to_runner_only: Option<bool>,
|
|
// auto_kind is intentionally excluded from export — it is auto-detected by the
|
|
// parser at deploy time from the script content (workflow/task patterns for "wac",
|
|
// no main function for "lib").
|
|
#[serde(skip_serializing)]
|
|
#[allow(dead_code)]
|
|
pub auto_kind: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub codebase: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub has_preprocessor: Option<bool>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub on_behalf_of_email: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub modules: Option<std::collections::HashMap<String, windmill_common::scripts::ScriptModule>>,
|
|
#[serde(flatten)]
|
|
pub concurrency_settings: ConcurrencySettings,
|
|
#[serde(flatten)]
|
|
pub debouncing_settings: DebouncingSettings,
|
|
}
|
|
|
|
pub fn is_none_or_false(val: &Option<bool>) -> bool {
|
|
match val {
|
|
Some(val) => !val,
|
|
None => true,
|
|
}
|
|
}
|
|
|
|
enum ArchiveImpl {
|
|
#[cfg(feature = "zip")]
|
|
Zip(async_zip::tokio::write::ZipFileWriter<tokio::fs::File>),
|
|
Tar(tokio_tar::Builder<File>),
|
|
}
|
|
|
|
impl ArchiveImpl {
|
|
async fn write_to_archive(&mut self, content: &str, path: &str) -> Result<()> {
|
|
match self {
|
|
ArchiveImpl::Tar(t) => {
|
|
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();
|
|
t.append_data(&mut header, path, bytes).await?;
|
|
}
|
|
#[cfg(feature = "zip")]
|
|
ArchiveImpl::Zip(z) => {
|
|
let header =
|
|
async_zip::ZipEntryBuilder::new(path.into(), async_zip::Compression::Deflate)
|
|
.last_modification_date(Default::default())
|
|
.unix_permissions(0o777)
|
|
.build();
|
|
z.write_entry_whole(header, content.as_bytes())
|
|
.await
|
|
.map_err(to_anyhow)?;
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
async fn finish(self) -> Result<()> {
|
|
match self {
|
|
ArchiveImpl::Tar(t) => t.into_inner().await?,
|
|
#[cfg(feature = "zip")]
|
|
ArchiveImpl::Zip(z) => z.close().await.map_err(to_anyhow)?.into_inner(),
|
|
}
|
|
.sync_all()
|
|
.await?;
|
|
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub(crate) struct ArchiveQueryParams {
|
|
archive_type: Option<String>,
|
|
plain_secret: Option<bool>,
|
|
plain_secrets: Option<bool>,
|
|
skip_secrets: Option<bool>,
|
|
skip_variables: Option<bool>,
|
|
skip_resources: Option<bool>,
|
|
skip_resource_types: Option<bool>,
|
|
include_schedules: Option<bool>,
|
|
include_triggers: Option<bool>,
|
|
include_users: Option<bool>,
|
|
include_groups: Option<bool>,
|
|
include_settings: Option<bool>,
|
|
include_key: Option<bool>,
|
|
include_workspace_dependencies: Option<bool>,
|
|
default_ts: Option<String>,
|
|
/// Settings format version: "v1" (default) returns legacy flat format, "v2" returns grouped format
|
|
settings_version: Option<String>,
|
|
}
|
|
|
|
#[inline]
|
|
pub fn to_string_without_metadata<T>(
|
|
value: &T,
|
|
preserve_extra_perms: bool,
|
|
ignore_keys: Option<Vec<&str>>,
|
|
) -> Result<String>
|
|
where
|
|
T: ?Sized + Serialize,
|
|
{
|
|
let mut value = serde_json::to_value(value).map_err(to_anyhow)?;
|
|
value
|
|
.as_object_mut()
|
|
.map(|obj| {
|
|
let keys = [
|
|
vec![
|
|
"workspace_id",
|
|
"path",
|
|
"name",
|
|
"versions",
|
|
"id",
|
|
"created_at",
|
|
"updated_at",
|
|
"created_by",
|
|
"updated_by",
|
|
"edited_at",
|
|
"edited_by",
|
|
"permissioned_as",
|
|
"archived",
|
|
"has_draft",
|
|
"error",
|
|
"last_server_ping",
|
|
"server_id",
|
|
"raw_app",
|
|
],
|
|
ignore_keys.unwrap_or(vec![]),
|
|
]
|
|
.concat();
|
|
|
|
for key in keys {
|
|
if obj.contains_key(key) {
|
|
obj.remove(key);
|
|
}
|
|
}
|
|
|
|
if let Some(o2) = obj.get_mut("policy").and_then(|x| x.as_object_mut()) {
|
|
o2.remove("on_behalf_of");
|
|
o2.remove("on_behalf_of_email");
|
|
}
|
|
if !preserve_extra_perms && obj.contains_key("extra_perms") {
|
|
obj.remove("extra_perms");
|
|
}
|
|
|
|
serde_json::to_string_pretty(&obj).ok()
|
|
})
|
|
.flatten()
|
|
.ok_or_else(|| Error::BadRequest("Impossible to serialize value".to_string()))
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct SimplifiedUser {
|
|
username: String,
|
|
role: String,
|
|
disabled: bool,
|
|
email: String,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct SimplifiedGroup {
|
|
name: String,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
summary: Option<String>,
|
|
members: Vec<String>,
|
|
admins: Vec<String>,
|
|
}
|
|
|
|
// V2 format: New grouped format
|
|
#[derive(Serialize)]
|
|
struct SimplifiedSettings {
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
auto_invite: Option<Value>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
webhook: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
deploy_to: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
error_handler: Option<Value>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
success_handler: Option<Value>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
ai_config: Option<serde_json::Value>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
large_file_storage: Option<Value>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
git_sync: Option<Value>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
default_app: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
default_scripts: Option<Value>,
|
|
name: String,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
mute_critical_alerts: Option<bool>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
color: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
operator_settings: Option<serde_json::Value>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
datatable: Option<Value>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
slack_team_id: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
slack_name: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
slack_command_script: Option<String>,
|
|
}
|
|
|
|
// V1 format: Legacy flat format for backward compatibility (matches main branch exactly)
|
|
#[derive(Serialize)]
|
|
struct SimplifiedSettingsLegacy {
|
|
auto_invite_enabled: bool,
|
|
auto_invite_as: String,
|
|
auto_invite_mode: String,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
webhook: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
deploy_to: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
error_handler: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
error_handler_extra_args: Option<Value>,
|
|
error_handler_muted_on_cancel: bool,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
ai_config: Option<serde_json::Value>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
large_file_storage: Option<Value>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
git_sync: Option<Value>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
default_app: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
default_scripts: Option<Value>,
|
|
name: String,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
mute_critical_alerts: Option<bool>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
color: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
operator_settings: Option<serde_json::Value>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
datatable: Option<Value>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
slack_team_id: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
slack_name: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
slack_command_script: Option<String>,
|
|
}
|
|
|
|
// Internal struct for querying database
|
|
#[derive(sqlx::FromRow)]
|
|
struct SettingsRow {
|
|
auto_invite: Option<Value>,
|
|
webhook: Option<String>,
|
|
deploy_to: Option<String>,
|
|
error_handler: Option<Value>,
|
|
success_handler: Option<Value>,
|
|
ai_config: Option<serde_json::Value>,
|
|
large_file_storage: Option<Value>,
|
|
git_sync: Option<Value>,
|
|
default_app: Option<String>,
|
|
default_scripts: Option<Value>,
|
|
name: Option<String>,
|
|
mute_critical_alerts: Option<bool>,
|
|
color: Option<String>,
|
|
operator_settings: Option<serde_json::Value>,
|
|
datatable: Option<Value>,
|
|
slack_team_id: Option<String>,
|
|
slack_name: Option<String>,
|
|
slack_command_script: Option<String>,
|
|
}
|
|
|
|
pub(crate) async fn tarball_workspace(
|
|
authed: ApiAuthed,
|
|
Extension(user_db): Extension<UserDB>,
|
|
Extension(db): Extension<DB>,
|
|
Path(w_id): Path<String>,
|
|
Query(ArchiveQueryParams {
|
|
archive_type,
|
|
plain_secret,
|
|
plain_secrets,
|
|
skip_resources,
|
|
skip_resource_types,
|
|
skip_secrets,
|
|
skip_variables,
|
|
include_schedules,
|
|
include_triggers,
|
|
include_users,
|
|
include_groups,
|
|
include_settings,
|
|
include_key,
|
|
include_workspace_dependencies,
|
|
default_ts,
|
|
settings_version,
|
|
}): Query<ArchiveQueryParams>,
|
|
) -> Result<([(HeaderName, String); 2], impl IntoResponse)> {
|
|
// require_admin(authed.is_admin, &authed.username)?;
|
|
|
|
tracing::info!(
|
|
"tarball_workspace called for workspace {}: include_workspace_dependencies={:?}, skip_variables={:?}, skip_resources={:?}",
|
|
w_id,
|
|
include_workspace_dependencies,
|
|
skip_variables,
|
|
skip_resources
|
|
);
|
|
|
|
let mut tx = user_db.begin(&authed).await?;
|
|
|
|
let tmp_dir = TempDir::new_in(&*WINDMILL_DIR)?;
|
|
|
|
let name = match archive_type.as_deref() {
|
|
Some("tar") | None => Ok(format!("windmill-{w_id}.tar")),
|
|
Some("zip") => Ok(format!("windmill-{w_id}.zip")),
|
|
Some(t) => Err(Error::BadRequest(format!("Invalid Archive Type {t}"))),
|
|
}?;
|
|
let file_path = tmp_dir.path().join(&name);
|
|
let mut archive = match archive_type.as_deref() {
|
|
Some("tar") | None => {
|
|
let file = File::create(&file_path).await?;
|
|
Ok(ArchiveImpl::Tar(tokio_tar::Builder::new(file)))
|
|
}
|
|
#[cfg(feature = "zip")]
|
|
Some("zip") => {
|
|
let file = tokio::fs::File::create(&file_path).await?;
|
|
Ok(ArchiveImpl::Zip(
|
|
async_zip::tokio::write::ZipFileWriter::with_tokio(file),
|
|
))
|
|
}
|
|
Some(t) => Err(Error::BadRequest(format!("Invalid Archive Type {t}"))),
|
|
}?;
|
|
{
|
|
let folders = sqlx::query_as::<_, Folder>("SELECT * FROM folder WHERE workspace_id = $1")
|
|
.bind(&w_id)
|
|
.fetch_all(&mut *tx)
|
|
.await?;
|
|
|
|
for folder in folders {
|
|
archive
|
|
.write_to_archive(
|
|
&to_string_without_metadata(&folder, true, None).unwrap(),
|
|
&format!("f/{}/folder.meta.json", folder.name),
|
|
)
|
|
.await?;
|
|
}
|
|
}
|
|
|
|
{
|
|
let scripts = sqlx::query_as::<_, Script<ScriptRunnableSettingsHandle>>(
|
|
"SELECT * FROM script as o WHERE workspace_id = $1 AND archived = false
|
|
AND (draft_only IS NULL OR draft_only = false)
|
|
AND created_at = (select max(created_at) from script where path = o.path AND \
|
|
workspace_id = $1)",
|
|
)
|
|
.bind(&w_id)
|
|
.fetch_all(&mut *tx)
|
|
.await?;
|
|
|
|
for script in scripts {
|
|
let script = windmill_common::scripts::prefetch_cached_script(script, &db).await?;
|
|
let ext = match script.language {
|
|
ScriptLang::Python3 => "py",
|
|
ScriptLang::Deno => {
|
|
if default_ts.as_ref().is_some_and(|x| x == "bun") {
|
|
"deno.ts"
|
|
} else {
|
|
"ts"
|
|
}
|
|
}
|
|
ScriptLang::Go => "go",
|
|
ScriptLang::Bash => "sh",
|
|
ScriptLang::Powershell => "ps1",
|
|
ScriptLang::Postgresql => "pg.sql",
|
|
ScriptLang::Mysql => "my.sql",
|
|
ScriptLang::Bigquery => "bq.sql",
|
|
ScriptLang::Snowflake => "sf.sql",
|
|
ScriptLang::Mssql => "ms.sql",
|
|
ScriptLang::DuckDb => "duckdb.sql",
|
|
ScriptLang::Graphql => "gql",
|
|
ScriptLang::Nativets => "fetch.ts",
|
|
ScriptLang::Bun | ScriptLang::Bunnative => {
|
|
if default_ts.as_ref().is_some_and(|x| x == "bun") {
|
|
"ts"
|
|
} else {
|
|
"bun.ts"
|
|
}
|
|
}
|
|
ScriptLang::Php => "php",
|
|
ScriptLang::Rust => "rs",
|
|
ScriptLang::Ansible => "playbook.yml",
|
|
ScriptLang::CSharp => "cs",
|
|
ScriptLang::Nu => "nu",
|
|
ScriptLang::OracleDB => "odb.sql",
|
|
ScriptLang::Java => "java",
|
|
ScriptLang::Ruby => "rb",
|
|
// for related places search: ADD_NEW_LANG
|
|
};
|
|
archive
|
|
.write_to_archive(&script.content, &format!("{}.{}", script.path, ext))
|
|
.await?;
|
|
|
|
let metadata = ScriptMetadata {
|
|
summary: script.summary,
|
|
description: script.description,
|
|
schema: script.schema,
|
|
kind: script.kind.to_string(),
|
|
lock: script.lock,
|
|
envs: script.envs,
|
|
concurrency_settings: script.runnable_settings.concurrency_settings,
|
|
debouncing_settings: script.runnable_settings.debouncing_settings,
|
|
cache_ttl: script.cache_ttl,
|
|
dedicated_worker: script.dedicated_worker,
|
|
ws_error_handler_muted: script.ws_error_handler_muted,
|
|
priority: script.priority,
|
|
tag: script.tag,
|
|
timeout: script.timeout,
|
|
delete_after_use: script.delete_after_use,
|
|
restart_unless_cancelled: script.restart_unless_cancelled,
|
|
visible_to_runner_only: script.visible_to_runner_only,
|
|
auto_kind: script.auto_kind,
|
|
codebase: script.codebase,
|
|
has_preprocessor: script.has_preprocessor,
|
|
on_behalf_of_email: script.on_behalf_of_email,
|
|
modules: script.modules,
|
|
};
|
|
let metadata_str = serde_json::to_string_pretty(&metadata).unwrap();
|
|
archive
|
|
.write_to_archive(&metadata_str, &format!("{}.script.json", script.path))
|
|
.await?;
|
|
}
|
|
}
|
|
|
|
if !skip_resources.unwrap_or(false) {
|
|
let resources = sqlx::query_as!(
|
|
Resource,
|
|
"SELECT * FROM resource WHERE workspace_id = $1 AND resource_type != 'state' AND resource_type != 'cache'",
|
|
&w_id
|
|
)
|
|
.fetch_all(&mut *tx)
|
|
.await?;
|
|
|
|
for resource in resources {
|
|
let resource_str = &to_string_without_metadata(&resource, false, None).unwrap();
|
|
archive
|
|
.write_to_archive(&resource_str, &format!("{}.resource.json", resource.path))
|
|
.await?;
|
|
}
|
|
}
|
|
|
|
if !skip_resource_types.unwrap_or(false) {
|
|
let resource_types = sqlx::query_as!(
|
|
ResourceType,
|
|
"SELECT * FROM resource_type WHERE workspace_id = $1",
|
|
&w_id
|
|
)
|
|
.fetch_all(&mut *tx)
|
|
.await?;
|
|
|
|
for resource_type in resource_types {
|
|
let resource_str = &to_string_without_metadata(&resource_type, false, None).unwrap();
|
|
archive
|
|
.write_to_archive(
|
|
&resource_str,
|
|
&format!("{}.resource-type.json", resource_type.name),
|
|
)
|
|
.await?;
|
|
}
|
|
}
|
|
|
|
{
|
|
let flows = sqlx::query_as::<_, Flow>(
|
|
"SELECT flow.workspace_id, flow.path, flow.summary, flow.description, flow.archived, flow.extra_perms, flow.draft_only, flow.dedicated_worker, flow.tag, flow.ws_error_handler_muted, flow.timeout, flow.visible_to_runner_only, flow.on_behalf_of_email, flow_version.schema, flow_version.value, flow_version.created_at as edited_at, flow_version.created_by as edited_by
|
|
FROM flow
|
|
LEFT JOIN flow_version ON flow_version.id = flow.versions[array_upper(flow.versions, 1)]
|
|
WHERE flow.workspace_id = $1 AND flow.archived = false AND (flow.draft_only IS NULL OR flow.draft_only = false)",
|
|
)
|
|
.bind(&w_id)
|
|
.fetch_all(&mut *tx)
|
|
.await?;
|
|
|
|
for flow in flows {
|
|
let flow_str = &to_string_without_metadata(&flow, false, None).unwrap();
|
|
archive
|
|
.write_to_archive(&flow_str, &format!("{}.flow.json", flow.path))
|
|
.await?;
|
|
}
|
|
}
|
|
|
|
if !skip_variables.unwrap_or(false) {
|
|
let variables =
|
|
sqlx::query_as::<_, ExportableListableVariable>(if !skip_secrets.unwrap_or(false) {
|
|
"SELECT * FROM variable WHERE workspace_id = $1 AND expires_at IS NULL"
|
|
} else {
|
|
"SELECT * FROM variable WHERE workspace_id = $1 AND is_secret = false AND expires_at IS NULL"
|
|
})
|
|
.bind(&w_id)
|
|
.fetch_all(&mut *tx)
|
|
.await?;
|
|
|
|
let mc = build_crypt(&db, &w_id).await?;
|
|
|
|
for mut var in variables {
|
|
if plain_secret.or(plain_secrets).unwrap_or(false)
|
|
&& var.value.is_some()
|
|
&& var.is_secret
|
|
{
|
|
var.value = Some(decrypt(&mc, var.value.unwrap()).map_err(|e| {
|
|
Error::internal_err(format!("Error decrypting variable {}: {}", var.path, e))
|
|
})?);
|
|
}
|
|
let var_str = &to_string_without_metadata(&var, false, None).unwrap();
|
|
archive
|
|
.write_to_archive(&var_str, &format!("{}.variable.json", var.path))
|
|
.await?;
|
|
}
|
|
}
|
|
|
|
{
|
|
let apps = sqlx::query_as::<_, AppWithLastVersion>(
|
|
"SELECT app.id, app.path, app.summary, app.versions, app.policy, app.custom_path,
|
|
app.extra_perms, app_version.value,
|
|
app_version.created_at, app_version.created_by, app_version.raw_app from app, app_version
|
|
WHERE app.workspace_id = $1 AND app_version.id = app.versions[array_upper(app.versions, 1)]
|
|
AND (app.draft_only IS NULL OR app.draft_only = false)",
|
|
)
|
|
.bind(&w_id)
|
|
.fetch_all(&mut *tx)
|
|
.await?;
|
|
|
|
for app in apps {
|
|
let app_str = &to_string_without_metadata(&app, false, None).unwrap();
|
|
let kind = if app.raw_app { "raw_app" } else { "app" };
|
|
archive
|
|
.write_to_archive(&app_str, &format!("{}.{}.json", app.path, kind))
|
|
.await?;
|
|
}
|
|
}
|
|
|
|
if include_workspace_dependencies.unwrap_or(false)
|
|
&& require_admin(authed.is_admin, &authed.username).is_ok()
|
|
{
|
|
tracing::info!("Including workspace dependencies in tarball export");
|
|
let workspace_dependencies = WorkspaceDependencies::list(&w_id, &db).await?;
|
|
tracing::info!(
|
|
"Found {} workspace dependencies",
|
|
workspace_dependencies.len()
|
|
);
|
|
for dep in workspace_dependencies {
|
|
// let dep_str = &to_string_without_metadata(&dep, false, None).unwrap();
|
|
let filename = WorkspaceDependencies::to_path(&dep.name, dep.language)?;
|
|
tracing::info!(
|
|
"Adding workspace dependency: name={:?}, language={:?}, filename={}",
|
|
dep.name,
|
|
dep.language,
|
|
filename
|
|
);
|
|
archive.write_to_archive(&dep.content, &filename).await?;
|
|
}
|
|
} else {
|
|
tracing::info!(
|
|
"Skipping workspace dependencies: include_workspace_dependencies={:?}",
|
|
include_workspace_dependencies
|
|
);
|
|
}
|
|
|
|
if include_schedules.unwrap_or(false) {
|
|
let schedules = sqlx::query_as::<_, Schedule>(
|
|
"SELECT * FROM schedule
|
|
WHERE workspace_id = $1",
|
|
)
|
|
.bind(&w_id)
|
|
.fetch_all(&mut *tx)
|
|
.await?;
|
|
|
|
for schedule in schedules {
|
|
let app_str = &to_string_without_metadata(&schedule, false, None).unwrap();
|
|
archive
|
|
.write_to_archive(&app_str, &format!("{}.schedule.json", schedule.path))
|
|
.await?;
|
|
}
|
|
}
|
|
|
|
if include_triggers.unwrap_or(false) {
|
|
#[cfg(feature = "http_trigger")]
|
|
{
|
|
use crate::triggers::http::HttpTrigger;
|
|
let handler = HttpTrigger;
|
|
let http_triggers = handler.list_triggers(&mut *tx, &w_id, None).await?;
|
|
|
|
for trigger in http_triggers {
|
|
let trigger_str = &to_string_without_metadata(&trigger, false, None).unwrap();
|
|
archive
|
|
.write_to_archive(
|
|
&trigger_str,
|
|
&format!("{}.http_trigger.json", trigger.base.path),
|
|
)
|
|
.await?;
|
|
}
|
|
}
|
|
|
|
#[cfg(feature = "websocket")]
|
|
{
|
|
use crate::triggers::websocket::WebsocketTrigger;
|
|
let handler = WebsocketTrigger;
|
|
let websocket_triggers = handler.list_triggers(&mut *tx, &w_id, None).await?;
|
|
|
|
for trigger in websocket_triggers {
|
|
let trigger_str = &to_string_without_metadata(&trigger, false, None).unwrap();
|
|
archive
|
|
.write_to_archive(
|
|
&trigger_str,
|
|
&format!("{}.websocket_trigger.json", trigger.base.path),
|
|
)
|
|
.await?;
|
|
}
|
|
}
|
|
|
|
#[cfg(all(feature = "enterprise", feature = "kafka", feature = "private"))]
|
|
{
|
|
use crate::triggers::kafka::KafkaTrigger;
|
|
let handler = KafkaTrigger;
|
|
let kafka_triggers = handler.list_triggers(&mut *tx, &w_id, None).await?;
|
|
|
|
for trigger in kafka_triggers {
|
|
let trigger_str = &to_string_without_metadata(&trigger, false, None).unwrap();
|
|
archive
|
|
.write_to_archive(
|
|
&trigger_str,
|
|
&format!("{}.kafka_trigger.json", trigger.base.path),
|
|
)
|
|
.await?;
|
|
}
|
|
}
|
|
|
|
#[cfg(all(feature = "enterprise", feature = "sqs_trigger", feature = "private"))]
|
|
{
|
|
use crate::triggers::sqs::SqsTrigger;
|
|
let handler = SqsTrigger;
|
|
let sqs_triggers = handler.list_triggers(&mut *tx, &w_id, None).await?;
|
|
|
|
for trigger in sqs_triggers {
|
|
let trigger_str = &to_string_without_metadata(&trigger, false, None).unwrap();
|
|
archive
|
|
.write_to_archive(
|
|
&trigger_str,
|
|
&format!("{}.sqs_trigger.json", trigger.base.path),
|
|
)
|
|
.await?;
|
|
}
|
|
}
|
|
|
|
#[cfg(all(feature = "enterprise", feature = "gcp_trigger", feature = "private"))]
|
|
{
|
|
use crate::triggers::gcp::GcpTrigger;
|
|
let handler = GcpTrigger;
|
|
let gcp_triggers = handler.list_triggers(&mut *tx, &w_id, None).await?;
|
|
|
|
for trigger in gcp_triggers {
|
|
let trigger_str = &to_string_without_metadata(&trigger, false, None).unwrap();
|
|
archive
|
|
.write_to_archive(
|
|
&trigger_str,
|
|
&format!("{}.gcp_trigger.json", trigger.base.path),
|
|
)
|
|
.await?;
|
|
}
|
|
}
|
|
|
|
#[cfg(all(feature = "enterprise", feature = "nats", feature = "private"))]
|
|
{
|
|
use crate::triggers::nats::NatsTrigger;
|
|
let handler = NatsTrigger;
|
|
let nats_triggers = handler.list_triggers(&mut *tx, &w_id, None).await?;
|
|
|
|
for trigger in nats_triggers {
|
|
let trigger_str: &String =
|
|
&to_string_without_metadata(&trigger, false, None).unwrap();
|
|
archive
|
|
.write_to_archive(
|
|
&trigger_str,
|
|
&format!("{}.nats_trigger.json", trigger.base.path),
|
|
)
|
|
.await?;
|
|
}
|
|
}
|
|
|
|
#[cfg(feature = "postgres_trigger")]
|
|
{
|
|
use crate::triggers::postgres::PostgresTrigger;
|
|
let handler = PostgresTrigger;
|
|
let postgres_triggers = handler.list_triggers(&mut *tx, &w_id, None).await?;
|
|
|
|
for trigger in postgres_triggers {
|
|
let trigger_str = &to_string_without_metadata(&trigger, false, None).unwrap();
|
|
archive
|
|
.write_to_archive(
|
|
&trigger_str,
|
|
&format!("{}.postgres_trigger.json", trigger.base.path),
|
|
)
|
|
.await?;
|
|
}
|
|
}
|
|
|
|
#[cfg(feature = "mqtt_trigger")]
|
|
{
|
|
use crate::triggers::mqtt::MqttTrigger;
|
|
let handler = MqttTrigger;
|
|
let mqtt_triggers = handler.list_triggers(&mut *tx, &w_id, None).await?;
|
|
|
|
for trigger in mqtt_triggers {
|
|
let trigger_str = &to_string_without_metadata(&trigger, false, None).unwrap();
|
|
archive
|
|
.write_to_archive(
|
|
&trigger_str,
|
|
&format!("{}.mqtt_trigger.json", trigger.base.path),
|
|
)
|
|
.await?;
|
|
}
|
|
}
|
|
|
|
#[cfg(all(feature = "enterprise", feature = "smtp", feature = "private"))]
|
|
{
|
|
use crate::triggers::email::EmailTrigger;
|
|
let handler = EmailTrigger;
|
|
let email_triggers = handler.list_triggers(&mut *tx, &w_id, None).await?;
|
|
|
|
for trigger in email_triggers {
|
|
let trigger_str = &to_string_without_metadata(&trigger, false, None).unwrap();
|
|
archive
|
|
.write_to_archive(
|
|
&trigger_str,
|
|
&format!("{}.email_trigger.json", trigger.base.path),
|
|
)
|
|
.await?;
|
|
}
|
|
}
|
|
|
|
#[cfg(feature = "native_trigger")]
|
|
{
|
|
use crate::native_triggers::{list_native_triggers, ServiceName};
|
|
use strum::IntoEnumIterator;
|
|
|
|
for service_name in ServiceName::iter() {
|
|
let native_triggers =
|
|
list_native_triggers(&mut *tx, &w_id, service_name, None, None, None, None)
|
|
.await?;
|
|
|
|
for trigger in native_triggers {
|
|
let trigger_str = &to_string_without_metadata(
|
|
&trigger,
|
|
false,
|
|
Some(vec!["webhook_token_hash"]),
|
|
)
|
|
.unwrap();
|
|
archive
|
|
.write_to_archive(
|
|
&trigger_str,
|
|
&format!(
|
|
"{}.{}.{}.{}_native_trigger.json",
|
|
trigger.script_path,
|
|
if trigger.is_flow { "flow" } else { "script" },
|
|
trigger.external_id,
|
|
service_name.as_str()
|
|
),
|
|
)
|
|
.await?;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if include_users.unwrap_or(false) {
|
|
let users = sqlx::query!(
|
|
"SELECT * FROM usr
|
|
WHERE workspace_id = $1",
|
|
&w_id
|
|
)
|
|
.fetch_all(&mut *tx)
|
|
.await?;
|
|
|
|
for user in users {
|
|
let user = SimplifiedUser {
|
|
username: user.username,
|
|
role: if user.is_admin {
|
|
"admin".to_string()
|
|
} else if user.operator {
|
|
"operator".to_string()
|
|
} else {
|
|
"developer".to_string()
|
|
},
|
|
disabled: user.disabled,
|
|
email: user.email,
|
|
};
|
|
let user_str = &to_string_without_metadata(&user, false, Some(vec!["email"])).unwrap();
|
|
archive
|
|
.write_to_archive(&user_str, &format!("users/{}.user.json", user.email))
|
|
.await?;
|
|
}
|
|
}
|
|
|
|
if include_groups.unwrap_or(false) {
|
|
let groups = sqlx::query!(
|
|
r#"SELECT g_.workspace_id, name, summary, extra_perms, array_agg(u2g.usr) filter (where u2g.usr is not null) as members
|
|
FROM usr u
|
|
JOIN usr_to_group u2g ON u2g.usr = u.username AND u2g.workspace_id = u.workspace_id
|
|
RIGHT JOIN group_ g_ ON g_.workspace_id = u.workspace_id AND g_.name = u2g.group_
|
|
WHERE g_.workspace_id = $1 AND g_.name != 'all'
|
|
GROUP BY g_.workspace_id, name, summary, extra_perms"#,
|
|
&w_id
|
|
)
|
|
.fetch_all(&mut *tx)
|
|
.await?;
|
|
|
|
for group in groups {
|
|
let extra_perms: HashMap<String, bool> = serde_json::from_value(group.extra_perms)
|
|
.map_err(|e| {
|
|
Error::internal_err(format!(
|
|
"Error parsing extra_perms for group {}: {}",
|
|
group.name, e
|
|
))
|
|
})?;
|
|
tracing::info!("{:?}", extra_perms);
|
|
let members = group.members.unwrap_or(vec![]);
|
|
let admins: Vec<String> = extra_perms
|
|
.iter()
|
|
.filter_map(|(k, v)| {
|
|
// only consider extra_perms that concern actual members of the group
|
|
if members.contains(&k[2..].to_string()) && *v {
|
|
Some(k.clone())
|
|
} else {
|
|
None
|
|
}
|
|
})
|
|
.sorted()
|
|
.collect();
|
|
let group = SimplifiedGroup {
|
|
name: group.name,
|
|
summary: group.summary,
|
|
members: members
|
|
.iter()
|
|
.filter_map(|x| {
|
|
// remove members that are also admins as they are already in the admins list
|
|
let full_name = format!("u/{}", x);
|
|
if !admins.contains(&full_name) {
|
|
Some(full_name)
|
|
} else {
|
|
None
|
|
}
|
|
})
|
|
.collect(),
|
|
admins,
|
|
};
|
|
|
|
let group_str = &to_string_without_metadata(&group, true, None).unwrap();
|
|
archive
|
|
.write_to_archive(&group_str, &format!("groups/{}.group.json", group.name))
|
|
.await?;
|
|
}
|
|
}
|
|
|
|
if include_settings.unwrap_or(false) {
|
|
let row = sqlx::query_as::<_, SettingsRow>(
|
|
r#"SELECT
|
|
auto_invite,
|
|
webhook,
|
|
deploy_to,
|
|
error_handler,
|
|
success_handler,
|
|
ai_config,
|
|
large_file_storage,
|
|
git_sync,
|
|
default_app,
|
|
default_scripts,
|
|
workspace.name as name,
|
|
mute_critical_alerts,
|
|
color,
|
|
operator_settings,
|
|
datatable,
|
|
slack_team_id,
|
|
slack_name,
|
|
slack_command_script
|
|
FROM workspace_settings
|
|
LEFT JOIN workspace ON workspace.id = workspace_settings.workspace_id
|
|
WHERE workspace_id = $1"#,
|
|
)
|
|
.bind(&w_id)
|
|
.fetch_one(&mut *tx)
|
|
.await?;
|
|
|
|
// Use v2 format only if explicitly requested, otherwise use v1 (legacy) for backward compatibility
|
|
let settings_str = if settings_version.as_deref() == Some("v2") {
|
|
let settings = SimplifiedSettings {
|
|
auto_invite: row.auto_invite,
|
|
webhook: row.webhook,
|
|
deploy_to: row.deploy_to,
|
|
error_handler: row.error_handler,
|
|
success_handler: row.success_handler,
|
|
ai_config: row.ai_config,
|
|
large_file_storage: row.large_file_storage,
|
|
git_sync: row.git_sync,
|
|
default_app: row.default_app,
|
|
default_scripts: row.default_scripts,
|
|
name: row.name.clone().unwrap_or_default(),
|
|
mute_critical_alerts: row.mute_critical_alerts,
|
|
color: row.color.clone(),
|
|
operator_settings: row.operator_settings.clone(),
|
|
datatable: row.datatable.clone(),
|
|
slack_team_id: row.slack_team_id.clone(),
|
|
slack_name: row.slack_name.clone(),
|
|
slack_command_script: row.slack_command_script.clone(),
|
|
};
|
|
serde_json::to_value(settings)
|
|
.map(|v| serde_json::to_string_pretty(&v).ok())
|
|
.ok()
|
|
.flatten()
|
|
} else {
|
|
// V1 (legacy) format: convert JSONB to flat fields (matches main branch exactly)
|
|
let (auto_invite_enabled, auto_invite_as, auto_invite_mode) =
|
|
if let Some(ref ai) = row.auto_invite {
|
|
let enabled = ai.get("enabled").and_then(|v| v.as_bool()).unwrap_or(false);
|
|
let operator = ai
|
|
.get("operator")
|
|
.and_then(|v| v.as_bool())
|
|
.unwrap_or(false);
|
|
let mode = ai.get("mode").and_then(|v| v.as_str()).unwrap_or("invite");
|
|
(
|
|
enabled,
|
|
if operator {
|
|
"operator".to_string()
|
|
} else {
|
|
"developer".to_string()
|
|
},
|
|
mode.to_string(),
|
|
)
|
|
} else {
|
|
(false, "developer".to_string(), "invite".to_string())
|
|
};
|
|
|
|
let (error_handler, error_handler_extra_args, error_handler_muted_on_cancel) =
|
|
if let Some(ref eh) = row.error_handler {
|
|
let path = eh.get("path").and_then(|v| v.as_str()).map(String::from);
|
|
let extra_args = eh.get("extra_args").cloned();
|
|
let muted_on_cancel = eh
|
|
.get("muted_on_cancel")
|
|
.and_then(|v| v.as_bool())
|
|
.unwrap_or(false);
|
|
(path, extra_args, muted_on_cancel)
|
|
} else {
|
|
(None, None, false)
|
|
};
|
|
|
|
let settings = SimplifiedSettingsLegacy {
|
|
auto_invite_enabled,
|
|
auto_invite_as,
|
|
auto_invite_mode,
|
|
webhook: row.webhook,
|
|
deploy_to: row.deploy_to,
|
|
error_handler,
|
|
error_handler_extra_args,
|
|
error_handler_muted_on_cancel,
|
|
ai_config: row.ai_config,
|
|
large_file_storage: row.large_file_storage,
|
|
git_sync: row.git_sync,
|
|
default_app: row.default_app,
|
|
default_scripts: row.default_scripts,
|
|
name: row.name.unwrap_or_default(),
|
|
mute_critical_alerts: row.mute_critical_alerts,
|
|
color: row.color,
|
|
operator_settings: row.operator_settings,
|
|
datatable: row.datatable,
|
|
slack_team_id: row.slack_team_id,
|
|
slack_name: row.slack_name,
|
|
slack_command_script: row.slack_command_script,
|
|
};
|
|
serde_json::to_value(settings)
|
|
.map(|v| serde_json::to_string_pretty(&v).ok())
|
|
.ok()
|
|
.flatten()
|
|
}
|
|
.ok_or_else(|| Error::internal_err("Error serializing settings".to_string()))?;
|
|
|
|
archive
|
|
.write_to_archive(&settings_str, "settings.json")
|
|
.await?;
|
|
}
|
|
|
|
if include_key.unwrap_or(false) {
|
|
let key = sqlx::query_scalar!(
|
|
"SELECT key FROM workspace_key WHERE workspace_id = $1",
|
|
&w_id
|
|
)
|
|
.fetch_one(&mut *tx)
|
|
.await?;
|
|
|
|
let key_json = serde_json::to_value(key)
|
|
.map(|v| serde_json::to_string_pretty(&v).ok())
|
|
.ok()
|
|
.flatten()
|
|
.ok_or_else(|| Error::internal_err("Error serializing enryption key".to_string()))?;
|
|
archive
|
|
.write_to_archive(&key_json, "encryption_key.json")
|
|
.await?;
|
|
}
|
|
|
|
archive.finish().await?;
|
|
|
|
let file = tokio::fs::File::open(&file_path).await?;
|
|
|
|
let stream = ReaderStream::new(file);
|
|
let body = axum::body::Body::from_stream(stream);
|
|
|
|
let headers = [
|
|
(header::CONTENT_TYPE, "application/x-tar".to_string()),
|
|
(
|
|
header::CONTENT_DISPOSITION,
|
|
format!("attachment; filename=\"{name}\""),
|
|
),
|
|
];
|
|
Ok((headers, body))
|
|
}
|