Compare commits

...

17 Commits

Author SHA1 Message Date
Pyra
18940d5303 Merge branch 'main' into workspace-dependencies-cache-rework-patch 2025-12-02 13:57:22 +01:00
hugocasa
a23d4f015a feat: add S3 support to download button and PDF preview components (#7271)
* feat: add S3 support to download button and PDF preview components

Add S3 object and s3:// URL support to AppDownload and AppPdf components,
following the same pattern used in AppImage component. Both components now:
- Handle partial S3 objects with storage and presigned URL support
- Handle s3:// URL format
- Construct proper API endpoints for S3 file downloads

Fixes #7240

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>

* handle policy + fix s3 picker

---------

Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2025-12-02 12:53:35 +00:00
Pyra
69c550bca6 fix(workspace-dependencies): implement better caching (#7273)
* fix(workspace-dependencies): implement better caching

Signed-off-by: pyranota <pyra@duck.com>

* Update SQLx metadata

* nits

Signed-off-by: pyranota <pyra@duck.com>

* spawn non blocking

Signed-off-by: pyranota <pyra@duck.com>

* nit

Signed-off-by: pyranota <pyra@duck.com>

* fix ci

Signed-off-by: pyranota <pyra@duck.com>

* fix issue

Signed-off-by: pyranota <pyra@duck.com>

---------

Signed-off-by: pyranota <pyra@duck.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2025-12-02 12:48:14 +00:00
Ruben Fiszel
dc9d25289b fix(cli): handle better apps pushing back-compatibility 2025-12-02 12:47:51 +00:00
pyranota
c4dfe6798c Merge branch 'workspace-dependencies-cache-rework' of github.com:windmill-labs/windmill into workspace-dependencies-cache-rework 2025-12-02 13:44:12 +01:00
pyranota
0b71ace5ad fix issue
Signed-off-by: pyranota <pyra@duck.com>
2025-12-02 13:44:01 +01:00
Pyra
15c9364d08 Merge branch 'main' into workspace-dependencies-cache-rework 2025-12-02 13:34:50 +01:00
pyranota
6dc53f385d fix ci
Signed-off-by: pyranota <pyra@duck.com>
2025-12-02 13:33:56 +01:00
Ruben Fiszel
67e30c7741 fix(cli): handle better public apps in legacy mode 2025-12-02 12:28:50 +00:00
pyranota
a2457dc9f2 nit
Signed-off-by: pyranota <pyra@duck.com>
2025-12-02 13:23:15 +01:00
pyranota
7dbf05865d spawn non blocking
Signed-off-by: pyranota <pyra@duck.com>
2025-12-02 13:14:07 +01:00
pyranota
d92c0e96ba Merge branch 'workspace-dependencies-cache-rework' of github.com:windmill-labs/windmill into workspace-dependencies-cache-rework 2025-12-02 12:40:53 +01:00
Pyra
4544b6be54 Merge branch 'main' into workspace-dependencies-cache-rework 2025-12-02 12:40:47 +01:00
pyranota
1613e6957d Merge branch 'workspace-dependencies-cache-rework' of github.com:windmill-labs/windmill into workspace-dependencies-cache-rework 2025-12-02 12:40:39 +01:00
pyranota
a976d963a8 nits
Signed-off-by: pyranota <pyra@duck.com>
2025-12-02 12:40:29 +01:00
windmill-internal-app[bot]
2179c78761 Update SQLx metadata 2025-12-02 11:40:17 +00:00
pyranota
2eeb568966 fix(workspace-dependencies): implement better caching
Signed-off-by: pyranota <pyra@duck.com>
2025-12-02 12:34:31 +01:00
23 changed files with 770 additions and 434 deletions

View File

@@ -0,0 +1,55 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT id FROM workspace_dependencies\n WHERE name IS NOT DISTINCT FROM $1 AND workspace_id = $2 AND archived = false AND language = $3\n LIMIT 1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Text",
"Text",
{
"Custom": {
"name": "script_lang",
"kind": {
"Enum": [
"python3",
"deno",
"go",
"bash",
"postgresql",
"nativets",
"bun",
"mysql",
"bigquery",
"snowflake",
"graphql",
"powershell",
"mssql",
"php",
"bunnative",
"rust",
"ansible",
"csharp",
"oracledb",
"nu",
"java",
"duckdb",
"ruby"
]
}
}
}
]
},
"nullable": [
false
]
},
"hash": "5387fbfd4be55674dcbe9f9b3ce9649d0f5db125776fbdffea993a53a09708de"
}

View File

@@ -1,128 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT id, content, language AS \"language: ScriptLang\", name, description, archived, workspace_id, created_at\n FROM workspace_dependencies\n WHERE name IS NOT DISTINCT FROM $1 AND workspace_id = $2 AND archived = false AND language = $3\n LIMIT 1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "content",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "language: ScriptLang",
"type_info": {
"Custom": {
"name": "script_lang",
"kind": {
"Enum": [
"python3",
"deno",
"go",
"bash",
"postgresql",
"nativets",
"bun",
"mysql",
"bigquery",
"snowflake",
"graphql",
"powershell",
"mssql",
"php",
"bunnative",
"rust",
"ansible",
"csharp",
"oracledb",
"nu",
"java",
"duckdb",
"ruby"
]
}
}
}
},
{
"ordinal": 3,
"name": "name",
"type_info": "Varchar"
},
{
"ordinal": 4,
"name": "description",
"type_info": "Text"
},
{
"ordinal": 5,
"name": "archived",
"type_info": "Bool"
},
{
"ordinal": 6,
"name": "workspace_id",
"type_info": "Varchar"
},
{
"ordinal": 7,
"name": "created_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Text",
"Text",
{
"Custom": {
"name": "script_lang",
"kind": {
"Enum": [
"python3",
"deno",
"go",
"bash",
"postgresql",
"nativets",
"bun",
"mysql",
"bigquery",
"snowflake",
"graphql",
"powershell",
"mssql",
"php",
"bunnative",
"rust",
"ansible",
"csharp",
"oracledb",
"nu",
"java",
"duckdb",
"ruby"
]
}
}
}
]
},
"nullable": [
false,
false,
false,
true,
false,
false,
false,
false
]
},
"hash": "f0e7d28be69c4b922b34b76abb780766003d79884d33f94a79e61f0259807dbc"
}

View File

@@ -64,7 +64,7 @@ mod workspace_dependencies {
name: Some("test".to_owned()),
description: None,
}
.create("", "", "", db)
.create(("".to_owned(), "".to_owned(), "".to_owned()), db.clone())
.await
.unwrap();
@@ -151,7 +151,7 @@ mod workspace_dependencies {
name: None, // No name = default workspace dependencies
description: None,
}
.create("", "", "", db)
.create(("".to_owned(), "".to_owned(), "".to_owned()), db.clone())
.await
.unwrap();

View File

@@ -13,9 +13,8 @@ use windmill_common::{
workspace_dependencies::WorkspaceDependencies,
DB,
};
use windmill_worker::{
scoped_dependency_map, trigger_dependents_to_recompute_dependencies,
workspace_dependencies::NewWorkspaceDependencies,
use windmill_worker::workspace_dependencies::{
trigger_dependents_to_recompute_dependencies_in_the_background, NewWorkspaceDependencies,
};
use crate::db::ApiAuthed;
@@ -43,10 +42,12 @@ async fn create(
format!(
"{}",
nwd.create(
&authed.email,
&authed.username,
&username_to_permissioned_as(&authed.username),
&db
(
authed.email,
username_to_permissioned_as(&authed.username),
authed.username,
),
db
)
.await?
),
@@ -93,23 +94,20 @@ async fn archive(
let db = &db;
WorkspaceDependencies::archive(params.name.clone(), language, &w_id, db).await?;
trigger_dependents_to_recompute_dependencies(
&w_id,
scoped_dependency_map::ScopedDependencyMap::get_dependents(
WorkspaceDependencies::to_path(&params.name, language)?.as_str(),
&w_id,
db,
)
.await?,
None,
None,
&authed.email,
&authed.username,
&username_to_permissioned_as(&authed.username),
db,
vec![],
trigger_dependents_to_recompute_dependencies_in_the_background(
params.name.is_none(),
w_id,
language,
(
authed.email,
username_to_permissioned_as(&authed.username),
authed.username,
),
WorkspaceDependencies::to_path(&params.name, language)?,
db.clone(),
)
.await
.await;
Ok(())
}
#[axum::debug_handler]
@@ -125,21 +123,18 @@ async fn delete(
let db = &db;
WorkspaceDependencies::delete(params.name.clone(), language, &w_id, db).await?;
trigger_dependents_to_recompute_dependencies(
&w_id,
scoped_dependency_map::ScopedDependencyMap::get_dependents(
WorkspaceDependencies::to_path(&params.name, language)?.as_str(),
&w_id,
db,
)
.await?,
None,
None,
&authed.email,
&authed.username,
&username_to_permissioned_as(&authed.username),
db,
vec![],
trigger_dependents_to_recompute_dependencies_in_the_background(
params.name.is_none(),
w_id,
language,
(
authed.email,
username_to_permissioned_as(&authed.username),
authed.username,
),
WorkspaceDependencies::to_path(&params.name, language)?,
db.clone(),
)
.await
.await;
Ok(())
}

View File

@@ -892,6 +892,86 @@ pub mod job {
}
}
pub mod workspace_dependencies {
use std::{
future::Future,
time::{Duration, Instant},
};
use crate::{error, scripts::ScriptLang, workspace_dependencies::WorkspaceDependencies, DB};
make_static! {
/// Workspace Dependencies by id and workspace cache.
static ref WORKSPACE_DEPENDENCIES: { (i64, String) => WorkspaceDependencies } in "workspace_dependencies" <= 1000;
}
lazy_static::lazy_static! {
/// Cache for checking if default/unnamed workspace dependencies exist for a workspace and language.
/// Cache key: (workspace_id, language)
/// Cache value: (exists: bool, cached_at timestamp)
static ref DEFAULT_WD_EXISTS_CACHE: quick_cache::sync::Cache<(String, ScriptLang), (bool, Instant)> = quick_cache::sync::Cache::new(500);
}
/// Cache timeout for existence checks (10 seconds)
pub const EXISTS_CACHE_TIMEOUT: Duration = Duration::from_secs(10);
pub fn fetch_workspace_dependencies<'c>(
id: i64,
workspace_id: String,
db: &'c DB,
) -> impl Future<Output = error::Result<WorkspaceDependencies>> + 'c {
tracing::debug!(workspace_id = %workspace_id, id, "fetching workspace dependencies");
WORKSPACE_DEPENDENCIES.get_or_insert_async(
(id, workspace_id.clone()),
WorkspaceDependencies::get(id, workspace_id, db),
)
}
pub fn get_cached_is_unnamed_workspace_dependencies_exists<'c>(
language: ScriptLang,
workspace_id: String,
) -> Option<bool> {
let exists_key = (workspace_id.to_string(), language);
if let Some((exists, cached_at)) = DEFAULT_WD_EXISTS_CACHE.get(&exists_key) {
if cached_at.elapsed() < EXISTS_CACHE_TIMEOUT {
tracing::debug!(
workspace_id = %workspace_id,
?language,
exists,
"cache hit for unnamed workspace dependencies existence"
);
return Some(exists);
} else {
tracing::debug!(
workspace_id = %workspace_id,
?language,
"cache expired for unnamed workspace dependencies existence"
);
DEFAULT_WD_EXISTS_CACHE.remove(&exists_key);
}
} else {
tracing::debug!(
workspace_id = %workspace_id,
?language,
"cache miss for unnamed workspace dependencies existence"
);
}
None
}
pub fn set_cached_is_unnamed_workspace_dependencies_exists<'c>(
language: ScriptLang,
workspace_id: String,
exists: bool,
) {
tracing::debug!(
workspace_id = %workspace_id,
?language,
exists,
"setting cache for unnamed workspace dependencies existence"
);
let exists_key = (workspace_id.to_string(), language);
DEFAULT_WD_EXISTS_CACHE.insert(exists_key, (exists, Instant::now()));
}
}
const _: () = {
impl Import for RawFlow {
fn import(src: &impl Storage) -> error::Result<Self> {
@@ -1075,7 +1155,8 @@ const _: () = {
(ScriptHash, |x| format!("{:016x}", x.0)),
((u8, ScriptHash), |x| format!("{:02x}-{:016x}", x.0, x.1.0)),
(FlowNodeId, |x| format!("{:016x}", x.0)),
(AppScriptId, |x| format!("{:016x}", x.0))
(AppScriptId, |x| format!("{:016x}", x.0)),
((i64, String), |x| format!("{}-{}", x.1, x.0))
}
#[cfg(feature = "scoped_cache")]

View File

@@ -2,9 +2,17 @@ use itertools::Itertools;
use regex::Regex;
use serde::{Deserialize, Serialize};
use sqlx::PgExecutor;
use std::time::{Duration, Instant};
use crate::{error, scripts::ScriptLang, utils::calculate_hash, worker::Connection};
use crate::{
cache::workspace_dependencies::{
fetch_workspace_dependencies, get_cached_is_unnamed_workspace_dependencies_exists,
set_cached_is_unnamed_workspace_dependencies_exists,
},
error,
scripts::ScriptLang,
utils::calculate_hash,
worker::Connection,
};
use phf::phf_set;
pub static BLACKLIST: phf::Set<&'static str> = phf_set! {
@@ -15,16 +23,8 @@ pub static BLACKLIST: phf::Set<&'static str> = phf_set! {
lazy_static::lazy_static! {
static ref WMDEBUG_FORCE_V0_WORKSPACE_DEPENDENCIES: bool = std::env::var("WMDEBUG_FORCE_V0_WORKSPACE_DEPENDENCIES").is_ok();
/// Simple in-memory cache for workspace dependencies get_latest with 10-second timeout.
/// Cache key: (workspace_id, language, name)
/// Cache value: (Option<WorkspaceDependencies>, cached_at timestamp)
static ref WORKSPACE_DEPENDENCIES_CACHE: quick_cache::sync::Cache<(String, ScriptLang, Option<String>), (Option<WorkspaceDependencies>, Instant)> = quick_cache::sync::Cache::new(1000);
}
/// Cache timeout for workspace dependencies
const CACHE_TIMEOUT: Duration = Duration::from_secs(10);
/// Minimum Windmill version required for workspace dependencies feature
pub const MIN_VERSION_WORKSPACE_DEPENDENCIES: &str = "1.587.0";
@@ -174,6 +174,42 @@ impl WorkspaceDependencies {
.map_err(error::Error::from)
}
async fn get_latest_id<'c>(
name: Option<String>,
language: ScriptLang,
workspace_id: &str,
e: impl PgExecutor<'c>,
) -> error::Result<Option<i64>> {
tracing::debug!(
workspace_id = %workspace_id,
?language,
?name,
"fetching latest workspace dependencies id"
);
let result = sqlx::query_scalar!(
r#"
SELECT id FROM workspace_dependencies
WHERE name IS NOT DISTINCT FROM $1 AND workspace_id = $2 AND archived = false AND language = $3
LIMIT 1
"#,
name,
workspace_id,
language as ScriptLang
)
.fetch_optional(e)
.await
.map_err(error::Error::from)?;
tracing::debug!(
workspace_id = %workspace_id,
?language,
?name,
?result,
"fetched latest workspace dependencies id"
);
Ok(result)
}
/// Gets the latest version of workspace dependencies by name and language.
pub async fn get_latest(
name: Option<String>,
@@ -185,66 +221,84 @@ impl WorkspaceDependencies {
return Ok(None);
}
let cache_key = (workspace_id.to_string(), language, name.clone());
// Check if cached value is still valid
if let Some((cached_value, cached_at)) = WORKSPACE_DEPENDENCIES_CACHE.get(&cache_key) {
if cached_at.elapsed() < CACHE_TIMEOUT {
return Ok(cached_value);
}
// Expired, remove it
WORKSPACE_DEPENDENCIES_CACHE.remove(&cache_key);
if name.is_none()
&& get_cached_is_unnamed_workspace_dependencies_exists(
language,
workspace_id.to_owned(),
)
.map(|exists| exists == false)
.unwrap_or_default()
{
tracing::debug!(
workspace_id = %workspace_id,
?language,
"skipping unnamed workspace dependencies fetch - cached as non-existent"
);
return Ok(None);
}
// Fetch and cache
let fetch = Box::pin(async {
match &conn {
Connection::Sql(db) => sqlx::query_as!(
Self,
r#"
SELECT id, content, language AS "language: ScriptLang", name, description, archived, workspace_id, created_at
FROM workspace_dependencies
WHERE name IS NOT DISTINCT FROM $1 AND workspace_id = $2 AND archived = false AND language = $3
LIMIT 1
"#,
name,
workspace_id,
language as ScriptLang
)
.fetch_optional(db)
.await
.map_err(error::Error::from),
// Fetch from database or HTTP
let wd = match &conn {
Connection::Sql(db) => {
let Some(id) =
Self::get_latest_id(name.clone(), language, workspace_id, db).await?
else {
if name.is_none() {
set_cached_is_unnamed_workspace_dependencies_exists(
language,
workspace_id.to_owned(),
false,
);
}
Connection::Http(http_client) => http_client
.get::<Option<WorkspaceDependencies>>(&format!(
"/api/w/{workspace_id}/agent_workers/workspace_dependencies/get_latest/{}{}",
language.as_str(),
if let Some(ref name_val) = name {
format!("?name={name_val}")
} else {
"".to_owned()
}
))
.await
.map_err(error::Error::from),
tracing::debug!(
workspace_id = %workspace_id,
?language,
?name,
"no latest workspace dependencies found"
);
return Ok(None);
};
tracing::debug!(
workspace_id = %workspace_id,
?language,
?name,
id,
"fetching workspace dependencies by id from cache or db"
);
Some(fetch_workspace_dependencies(id, workspace_id.to_owned(), db).await?)
}
});
let (workspace_dependencies_o, ..) = WORKSPACE_DEPENDENCIES_CACHE
.get_or_insert_async(&cache_key, async {
Ok::<_, error::Error>((fetch.await?, Instant::now()))
})
.await?;
Connection::Http(http_client) => http_client
.get::<Option<WorkspaceDependencies>>(&format!(
"/api/w/{workspace_id}/agent_workers/workspace_dependencies/get_latest/{}{}",
language.as_str(),
if let Some(ref name_val) = name {
format!("?name={name_val}")
} else {
"".to_owned()
}
))
.await
.map_err(error::Error::from)?,
};
Ok(workspace_dependencies_o)
if name.is_none() {
set_cached_is_unnamed_workspace_dependencies_exists(
language,
workspace_id.to_owned(),
wd.is_some(),
);
}
Ok(wd)
}
/// Gets workspace dependencies by their unique ID.
pub async fn get<'c>(
id: i64,
workspace_id: &str,
workspace_id: String,
e: impl PgExecutor<'c>,
) -> error::Result<Option<Self>> {
) -> error::Result<Self> {
sqlx::query_as!(
Self,
r#"
@@ -254,9 +308,9 @@ impl WorkspaceDependencies {
LIMIT 1
"#,
id,
workspace_id
&workspace_id
)
.fetch_optional(e)
.fetch_one(e)
.await
.map_err(error::Error::from)
}

View File

@@ -1,5 +1,8 @@
use serde::{Deserialize, Serialize};
use windmill_common::{error, scripts::ScriptLang, workspace_dependencies::WorkspaceDependencies};
use windmill_common::{
cache::workspace_dependencies::EXISTS_CACHE_TIMEOUT, error, scripts::ScriptLang,
workspace_dependencies::WorkspaceDependencies,
};
use crate::{
scoped_dependency_map::ScopedDependencyMap, trigger_dependents_to_recompute_dependencies,
@@ -25,10 +28,8 @@ impl NewWorkspaceDependencies {
/// and rebuilds the dependency map if this is the first unnamed dependency for the workspace.
pub async fn create<'c>(
self,
email: &str,
created_by: &str,
permissioned_as: &str,
db: &sqlx::Pool<sqlx::Postgres>,
metadata: (String, String, String),
db: sqlx::Pool<sqlx::Postgres>,
) -> error::Result<i64> {
// Check if all workers support workspace dependencies feature
windmill_common::workspace_dependencies::min_version_supports_v0_workspace_dependencies()
@@ -43,7 +44,7 @@ impl NewWorkspaceDependencies {
let setting_name = format!("workspace_dependencies_map_rebuilt:{}", self.workspace_id);
let already_rebuilt =
windmill_common::global_settings::load_value_from_global_settings(
db,
&db,
&setting_name,
)
.await?
@@ -54,11 +55,11 @@ impl NewWorkspaceDependencies {
workspace_id = %self.workspace_id,
"Rebuilding workspace dependencies map for first unnamed workspace dependencies"
);
ScopedDependencyMap::rebuild_map_unchecked(&self.workspace_id, db).await?;
ScopedDependencyMap::rebuild_map_unchecked(&self.workspace_id, &db).await?;
// Mark as rebuilt by creating the setting
windmill_common::global_settings::set_value_in_global_settings(
db,
&db,
&setting_name,
serde_json::json!({}),
)
@@ -111,50 +112,83 @@ impl NewWorkspaceDependencies {
.await?;
tx.commit().await?;
// Make sure trigger dependents will have latest view.
// NOTE: Uncomment for tests
// #[cfg(test)]
// assert_eq!(
// sqlx::query_scalar!(
// "
// SELECT id FROM workspace_dependencies
// WHERE archived = false
// AND name IS NOT DISTINCT FROM $1
// AND workspace_id = $2
// AND language = $3
// ",
// self.name,
// self.workspace_id,
// self.language as ScriptLang,
// )
// .fetch_one(db) // Use db
// .await?,
// new_id
// );
// It's ok to fail, it will return an error and user will get notified that they should redeploy workspace dependencies
trigger_dependents_to_recompute_dependencies(
&self.workspace_id,
crate::scoped_dependency_map::ScopedDependencyMap::get_dependents(
path.as_str(),
&self.workspace_id,
db,
)
.await?,
None,
None,
email,
created_by,
permissioned_as,
trigger_dependents_to_recompute_dependencies_in_the_background(
prev_description.is_none() && self.name.is_none(),
self.workspace_id,
self.language,
metadata,
path,
db,
vec![],
)
.await?;
.await;
Ok(new_id)
}
}
pub async fn trigger_dependents_to_recompute_dependencies_in_the_background(
wait_for_cache_timeout: bool,
workspace_id: String,
language: ScriptLang,
(email, permissioned_as, created_by): (String, String, String),
path: String,
db: sqlx::Pool<sqlx::Postgres>,
) {
tokio::spawn(async move {
if wait_for_cache_timeout {
tracing::debug!(
workspace_id = %workspace_id,
language = ?language,
"waiting for cache timeout after creating first unnamed workspace dependencies"
);
// Wait for cache timeout.
// For context, workers have cache on whether the unnamed workspace dependencies exists or not.
// when we trigger dependents to recompoute dependencies we want to make sure all workers are having cache timed out.
// otherwise it would result into bug, when workers skip fetch of workspace dependencies because they think they don't exist.
tokio::time::sleep(EXISTS_CACHE_TIMEOUT).await;
}
// It's ok to fail, it will return an error and user will get notified that they should redeploy workspace dependencies
if let Err(e) = trigger_dependents_to_recompute_dependencies(
&workspace_id,
match crate::scoped_dependency_map::ScopedDependencyMap::get_dependents(
path.as_str(),
&workspace_id,
&db,
)
.await
{
Ok(importers) => importers,
Err(e) => {
tracing::error!(
workspace_id = %workspace_id,
path = %path,
error = %e,
"CRITICAL: failed to get dependents for workspace dependencies - dependent runnables are not being redeployed. Please contact the Windmill team"
);
return;
}
},
None,
None,
email.as_str(),
created_by.as_str(),
permissioned_as.as_str(),
&db,
vec![],
)
.await
{
tracing::error!(
workspace_id = %workspace_id,
path = %path,
error = %e,
"CRITICAL: failed to trigger dependents to recompute dependencies - dependent runnables are not being redeployed. Please contact the Windmill team"
);
}
});
}
// Type aliases for backward compatibility
pub type RawRequirements = WorkspaceDependencies;
pub type NewRawRequirements = NewWorkspaceDependencies;

View File

@@ -16,6 +16,7 @@ import { ListableApp, Policy } from "../../../gen/types.gen.ts";
import { GlobalOptions, isSuperset } from "../../types.ts";
import { readInlinePathSync } from "../../utils/utils.ts";
import devCommand from "./dev.ts";
import { isVersionsGeq15851 } from "../sync/global.ts";
export interface AppFile {
value: any;
@@ -30,22 +31,26 @@ function respecializeFields(fields: Record<string, any>) {
Object.entries(fields).forEach(([k, v]) => {
if (typeof v == "object") {
if (v.value !== undefined) {
fields[k] = { value: v.value, type: "static" }
fields[k] = { value: v.value, type: "static" };
} else if (v.expr !== undefined) {
fields[k] = { expr: v.expr, allowUserResources: v.allowUserResources, type: "javascript" }
fields[k] = {
expr: v.expr,
allowUserResources: v.allowUserResources,
type: "javascript",
};
}
}
})
});
}
export function repopulateFields(runnables: Record<string, any>) {
Object.values(runnables).forEach((v) => {
if (typeof v == "object") {
if (v.fields !== undefined) {
respecializeFields(v.fields)
respecializeFields(v.fields);
}
}
})
});
}
export function replaceInlineScripts(rec: any, localPath: string) {
if (!rec) {
@@ -53,11 +58,14 @@ export function replaceInlineScripts(rec: any, localPath: string) {
}
if (typeof rec == "object") {
return Object.entries(rec).flatMap(([k, v]) => {
if (k == 'runType') {
rec["type"] = 'path'
if (k == "runType") {
if (isVersionsGeq15851()) {
rec["type"] = "path";
}
} else if (k == "inlineScript" && typeof v == "object") {
rec["type"] = 'inline'
if (isVersionsGeq15851()) {
rec["type"] = "inline";
}
const o: Record<string, any> = v as any;
if (o["content"] && o["content"].startsWith("!inline")) {
@@ -114,7 +122,12 @@ export async function pushApp(
const localApp = (await yamlParseFile(path)) as AppFile;
replaceInlineScripts(localApp.value, localPath);
await generatingPolicy(localApp, remotePath, localApp?.["public"] ?? false);
await generatingPolicy(
localApp,
remotePath,
localApp?.["public"] ??
localApp?.["policy"]?.["execution_mode"] == "anonymous"
);
if (app) {
if (isSuperset(localApp, app)) {
log.info(colors.green(`App ${remotePath} is up to date`));

View File

@@ -0,0 +1,31 @@
import { colors, log } from "../../../deps.ts";
let GLOBAL_VERSIONS: {
remoteMajor: number | undefined;
remoteMinor: number | undefined;
} = {
remoteMajor: undefined,
remoteMinor: undefined,
};
export function updateGlobalVersions(version: string) {
try {
const [prefix, remoteMinorStr] = version.split(".");
GLOBAL_VERSIONS = {
remoteMajor: parseInt(prefix.split("v")[1]),
remoteMinor: parseInt(remoteMinorStr),
};
} catch (e) {
log.info(colors.gray(`Error reading remote version: ${e}`));
}
}
export function isVersionsGeq15851(): boolean {
return (
GLOBAL_VERSIONS.remoteMajor !== undefined &&
GLOBAL_VERSIONS.remoteMajor >= 1 &&
GLOBAL_VERSIONS.remoteMinor !== undefined &&
GLOBAL_VERSIONS.remoteMinor >= 5851
);
}

View File

@@ -34,7 +34,12 @@ import {
} from "../script/script.ts";
import { handleFile } from "../script/script.ts";
import { deepEqual, isFileResource, isRawAppFile, isWorkspaceDependencies } from "../../utils/utils.ts";
import {
deepEqual,
isFileResource,
isRawAppFile,
isWorkspaceDependencies,
} from "../../utils/utils.ts";
import {
getEffectiveSettings,
mergeConfigWithConfigFile,
@@ -70,6 +75,7 @@ import { extractInlineScripts as extractInlineScriptsForFlows } from "../../../w
import { generateFlowLockInternal } from "../flow/flow_metadata.ts";
import { isExecutionModeAnonymous } from "../app/apps.ts";
import { generateAppLocksInternal } from "../app/app_metadata.ts";
import { updateGlobalVersions } from "./global.ts";
// Merge CLI options with effective settings, preserving CLI flags as overrides
function mergeCliWithEffectiveOptions<
@@ -268,29 +274,28 @@ function extractFields(fields: Record<string, any>) {
Object.entries(fields).forEach(([k, v]) => {
if (typeof v == "object") {
if (v.type == "static") {
fields[k] = { value: v.value }
fields[k] = { value: v.value };
} else if (v.type == "javascript") {
fields[k] = { expr: v.expr, allowUserResources: v.allowUserResources }
fields[k] = { expr: v.expr, allowUserResources: v.allowUserResources };
} else if (v.type == "user") {
fields[k] = undefined
fields[k] = undefined;
}
}
// if (k == 'runType') {
// fields["type"] = undefined
// fields["schema"] = undefined
// }
})
});
}
export function extractFieldsForRawApps(runnables: Record<string, any>) {
Object.values(runnables).forEach((v) => {
if (typeof v == "object") {
if (v.fields !== undefined) {
extractFields(v.fields)
extractFields(v.fields);
}
}
})
});
}
export function extractInlineScriptsForApps(
key: string | undefined,
@@ -303,12 +308,12 @@ export function extractInlineScriptsForApps(
}
if (typeof rec == "object") {
return Object.entries(rec).flatMap(([k, v]) => {
if (k == 'runType') {
rec["type"] = undefined
rec["schema"] = undefined
return []
if (k == "runType") {
rec["type"] = undefined;
rec["schema"] = undefined;
return [];
} else if (k == "inlineScript" && typeof v == "object") {
rec["type"] = undefined
rec["type"] = undefined;
const o: Record<string, any> = v as any;
const name = toId(key ?? "", rec);
const [basePathO, ext] = pathAssigner.assignPath(name, o["language"]);
@@ -351,20 +356,26 @@ function ZipFSElement(
p: string,
f: JSZip.JSZipObject
): Promise<DynFSElement[]> {
const kind: "flow" | "app" | "script" | "resource" | "other" | "raw_app" | "dependencies" =
p.endsWith(".flow.json")
? "flow"
: p.endsWith(".app.json")
? "app"
: p.endsWith(".raw_app.json")
? "raw_app"
: p.endsWith(".script.json")
? "script"
: p.endsWith(".resource.json")
? "resource"
: p.startsWith("dependencies/")
const kind:
| "flow"
| "app"
| "script"
| "resource"
| "other"
| "raw_app"
| "dependencies" = p.endsWith(".flow.json")
? "flow"
: p.endsWith(".app.json")
? "app"
: p.endsWith(".raw_app.json")
? "raw_app"
: p.endsWith(".script.json")
? "script"
: p.endsWith(".resource.json")
? "resource"
: p.startsWith("dependencies/")
? "dependencies"
: "other";
: "other";
const isJson = p.endsWith(".json");
@@ -502,7 +513,7 @@ function ZipFSElement(
undefined,
value,
newPathAssigner(defaultTs),
(key, val_) => key
(key, val_) => key
);
} catch (error) {
log.error(
@@ -759,7 +770,11 @@ export async function* readDirRecursiveWithIgnore(
for await (const e2 of e.c()) {
if (e2.isDirectory) {
const dirName = e2.path.split(SEP).pop();
if (dirName == "node_modules" || dirName == ".claude" || dirName?.startsWith(".")) {
if (
dirName == "node_modules" ||
dirName == ".claude" ||
dirName?.startsWith(".")
) {
continue;
}
}
@@ -792,7 +807,7 @@ export async function elementsToMap(
ignore: (path: string, isDirectory: boolean) => boolean,
json: boolean,
skips: Skips,
specificItems?: SpecificItemsConfig,
specificItems?: SpecificItemsConfig
): Promise<{ [key: string]: string }> {
const map: { [key: string]: string } = {};
const processedBasePaths = new Set<string>();
@@ -802,7 +817,11 @@ export async function elementsToMap(
continue;
}
const path = entry.path;
if (!isFileResource(path) && !isRawAppFile(path) && !isWorkspaceDependencies(path)) {
if (
!isFileResource(path) &&
!isRawAppFile(path) &&
!isWorkspaceDependencies(path)
) {
if (json && path.endsWith(".yaml")) continue;
if (!json && path.endsWith(".json")) continue;
@@ -835,7 +854,11 @@ export async function elementsToMap(
if (isRawAppFile(path)) {
const suffix = path.split(".raw_app" + SEP).pop();
if (suffix?.startsWith("dist/") || suffix == "wmill.d.ts" || suffix == "package-lock.json") {
if (
suffix?.startsWith("dist/") ||
suffix == "wmill.d.ts" ||
suffix == "package-lock.json"
) {
continue;
}
}
@@ -867,7 +890,6 @@ export async function elementsToMap(
continue;
}
// Use getTypeStrFromPath for consistent type detection
try {
const fileType = getTypeStrFromPath(path);
@@ -876,12 +898,15 @@ export async function elementsToMap(
if (skips.skipFlows && fileType === "flow") continue;
if (skips.skipApps && fileType === "app") continue;
if (skips.skipFolders && fileType === "folder") continue;
if (skips.skipWorkspaceDependencies && fileType === "workspace_dependencies") continue;
if (
skips.skipWorkspaceDependencies &&
fileType === "workspace_dependencies"
)
continue;
} catch {
// If getTypeStrFromPath can't determine the type, continue processing the file
}
// Handle branch-specific files - skip files for other branches
if (specificItems && isBranchSpecificFile(path)) {
const currentBranch = getCurrentGitBranch();
@@ -945,7 +970,7 @@ export async function elementsToMap(
// No specific items configuration, use regular path
map[entry.path] = content;
}
}
}
return map;
}
@@ -1090,7 +1115,6 @@ async function compareDynFSElement(
const remoteCodebase: Record<string, string> = {};
for (const [k] of Object.entries(m2)) {
if (m1[k] === undefined) {
if (
!ignoreMetadataDeletion ||
@@ -1239,7 +1263,7 @@ export const isWhitelisted = (p: string) => {
p == "f" ||
p == "g" ||
p == "users" ||
p == "groups" ||
p == "groups" ||
p == "dependencies"
);
};
@@ -1308,7 +1332,10 @@ export async function ignoreF(wmillconf: {
if (wmillconf.includeKey && fileType === "encryption_key") {
return false; // Don't ignore, always include
}
if (!wmillconf.skipWorkspaceDependencies && fileType === "workspace_dependencies") {
if (
!wmillconf.skipWorkspaceDependencies &&
fileType === "workspace_dependencies"
) {
return false; // Don't ignore workspace dependencies (they are always included unless explicitly skipped)
}
} catch {
@@ -1440,22 +1467,22 @@ export async function pull(
// ignore
}
const zipFile = await downloadZip(
workspace,
opts.plainSecrets,
opts.skipVariables,
opts.skipResources,
opts.skipResourceTypes,
opts.skipSecrets,
opts.includeSchedules,
opts.includeTriggers,
opts.includeUsers,
opts.includeGroups,
opts.includeSettings,
opts.includeKey,
opts.skipWorkspaceDependencies,
opts.defaultTs
);
workspace,
opts.plainSecrets,
opts.skipVariables,
opts.skipResources,
opts.skipResourceTypes,
opts.skipSecrets,
opts.includeSchedules,
opts.includeTriggers,
opts.includeUsers,
opts.includeGroups,
opts.includeSettings,
opts.includeKey,
opts.skipWorkspaceDependencies,
opts.defaultTs
);
const remote = ZipFSElement(
zipFile!,
!opts.json,
@@ -1482,7 +1509,6 @@ export async function pull(
`remote (${workspace.name}) -> local: ${changes.length} changes to apply`
);
// Handle JSON output for dry-run
if (opts.dryRun && opts.jsonOutput) {
const result = {
@@ -1671,7 +1697,8 @@ export async function pull(
await readLockfile(); // ensure wmill-lock.yaml exists
const tracker: ChangeTracker = await buildTracker(changes);
const rawWorkspaceDependencies: Record<string, string> = await getRawWorkspaceDependencies();
const rawWorkspaceDependencies: Record<string, string> =
await getRawWorkspaceDependencies();
for (const change of tracker.scripts) {
await generateScriptMetadataInternal(
@@ -1687,7 +1714,14 @@ export async function pull(
}
for (const change of tracker.flows) {
log.info(`Updating lock for flow ${change}`);
await generateFlowLockInternal(change, false, workspace, opts, true, false);
await generateFlowLockInternal(
change,
false,
workspace,
opts,
true,
false
);
}
if (tracker.apps.length > 0) {
log.info(
@@ -1700,7 +1734,14 @@ export async function pull(
}
for (const change of tracker.rawApps) {
log.info(`Updating lock metadata for raw app ${change}`);
await generateAppLocksInternal(change, false, workspace, opts, true, true);
await generateAppLocksInternal(
change,
false,
workspace,
opts,
true,
true
);
}
if (opts.jsonOutput) {
const result = {
@@ -1899,6 +1940,7 @@ export async function push(
} catch {
// ignore
}
const remote = ZipFSElement(
(await downloadZip(
workspace,
@@ -1935,13 +1977,13 @@ export async function push(
specificItems
);
const rawWorkspaceDependencies = await getRawWorkspaceDependencies();
const tracker: ChangeTracker = await buildTracker(changes);
const staleScripts: string[] = [];
const staleFlows: string[] = [];
for (const change of tracker.scripts) {
const stale = await generateScriptMetadataInternal(
change,
@@ -1995,7 +2037,9 @@ export async function push(
}
const version = await fetchVersion(workspace.remote);
if (version) {
updateGlobalVersions(version);
}
log.info(colors.gray("Remote version: " + version));
log.info(
@@ -2097,14 +2141,18 @@ export async function push(
const alreadySynced: string[] = [];
const isRawApp = isRawAppFile(changes[0].path);
if (isRawApp) {
const deleteRawApp = changes.find(change => change.name === "deleted" && change.path.endsWith(".raw_app/raw_app.yaml"))
const deleteRawApp = changes.find(
(change) =>
change.name === "deleted" &&
change.path.endsWith(".raw_app/raw_app.yaml")
);
if (deleteRawApp) {
changes = [deleteRawApp];
} else {
changes.splice(1, changes.length - 1);
}
}
for await (const change of changes) {
let stateTarget = undefined;
if (stateful) {
@@ -2324,13 +2372,16 @@ export async function push(
});
break;
case "raw_app":
if (target.endsWith(".raw_app/raw_app.yaml") || target.endsWith(".raw_app/raw_app.json")) {
if (
target.endsWith(".raw_app/raw_app.yaml") ||
target.endsWith(".raw_app/raw_app.json")
) {
await wmill.deleteApp({
workspace: workspaceId,
path: removeSuffix(target, ".raw_app/raw_app.json"),
});
}
break;
break;
case "schedule":
await wmill.deleteSchedule({
workspace: workspaceId,
@@ -2426,18 +2477,25 @@ export async function push(
});
break;
case "workspace_dependencies":
const relativePath = removePathPrefix(change.path, "dependencies");
const res = workspaceDependenciesPathToLanguageAndFilename(change.path);
const relativePath = removePathPrefix(
change.path,
"dependencies"
);
const res = workspaceDependenciesPathToLanguageAndFilename(
change.path
);
if (!res) {
throw new Error(`Unknown workspace dependencies file format: ${change.path}`);
throw new Error(
`Unknown workspace dependencies file format: ${change.path}`
);
}
const { name, language } = res;
await wmill.deleteWorkspaceDependencies({
workspace: workspaceId,
language,
name
name,
});
break;
@@ -2537,7 +2595,10 @@ const command = new Command()
.option("--skip-flows", "Skip syncing flows")
.option("--skip-apps", "Skip syncing apps")
.option("--skip-folders", "Skip syncing folders")
.option("--skip-workspace-dependencies", "Skip syncing workspace dependencies")
.option(
"--skip-workspace-dependencies",
"Skip syncing workspace dependencies"
)
// .option("--skip-scripts-metadata", "Skip syncing scripts metadata, focus solely on logic")
.option("--include-schedules", "Include syncing schedules")
.option("--include-triggers", "Include syncing triggers")
@@ -2586,7 +2647,10 @@ const command = new Command()
.option("--skip-flows", "Skip syncing flows")
.option("--skip-apps", "Skip syncing apps")
.option("--skip-folders", "Skip syncing folders")
.option("--skip-workspace-dependencies", "Skip syncing workspace dependencies")
.option(
"--skip-workspace-dependencies",
"Skip syncing workspace dependencies"
)
// .option("--skip-scripts-metadata", "Skip syncing scripts metadata, focus solely on logic")
.option("--include-schedules", "Include syncing schedules")
.option("--include-triggers", "Include syncing triggers")

View File

@@ -757,8 +757,8 @@ JsonNode ${windmillPathToCamelCaseName(path)} = JsonNode.Parse(await client.GetS
<S3FilePicker
bind:this={s3FilePicker}
readOnlyMode={false}
on:selectAndClose={(s3obj) => {
let s = `'${formatS3Object(s3obj.detail)}'`
onSelectAndClose={(s3obj) => {
let s = `'${formatS3Object(s3obj)}'`
if (lang === 'duckdb') {
editor?.insertAtCursor(`SELECT * FROM ${s}`)
} else if (lang === 'python3') {

View File

@@ -2,7 +2,7 @@
import { emptyString, type S3Object } from '$lib/utils'
import { Button, Drawer } from './common'
import DrawerContent from './common/drawer/DrawerContent.svelte'
import { createEventDispatcher, tick, untrack } from 'svelte'
import { tick, untrack } from 'svelte'
import S3FilePickerInner from './S3FilePickerInner.svelte'
import Select from './select/Select.svelte'
import { FileUp } from 'lucide-svelte'
@@ -10,11 +10,6 @@
import { SettingService } from '$lib/gen'
import { workspaceStore } from '$lib/stores'
let dispatch = createEventDispatcher<{
close: { s3: string; storage: string | undefined } | undefined
selectAndClose: { s3: string; storage: string | undefined }
}>()
interface Props {
fromWorkspaceSettings?: boolean
readOnlyMode: boolean
@@ -22,6 +17,8 @@
selectedFileKey?: { s3: string; storage?: string } | undefined
folderOnly?: boolean
regexFilter?: RegExp | undefined
onClose?: () => void
onSelectAndClose?: (selected: { s3: string; storage: string | undefined }) => void
}
let {
@@ -30,7 +27,9 @@
initialFileKey = $bindable(undefined),
selectedFileKey = $bindable(undefined),
folderOnly = false,
regexFilter = undefined
regexFilter = undefined,
onClose,
onSelectAndClose
}: Props = $props()
let drawer: Drawer | undefined = $state()
@@ -76,7 +75,7 @@
<Drawer
bind:this={drawer}
on:close={() => {
dispatch('close')
onClose?.()
s3FilePickerInner?.close?.()
}}
size="1200px"
@@ -93,7 +92,7 @@
<S3FilePickerInner
bind:this={s3FilePickerInner}
on:selectAndClose={(e) => {
dispatch('selectAndClose', e.detail)
onSelectAndClose?.(e.detail)
drawer?.closeDrawer?.()
}}
{fromWorkspaceSettings}

View File

@@ -39,7 +39,7 @@
<S3FilePicker
bind:this={s3FilePicker}
bind:selectedFileKey={value}
on:close={() => {
onClose={() => {
rawValue = JSON.stringify(value, null, 2)
editor?.setCode(rawValue)
}}

View File

@@ -12,6 +12,8 @@
import ComponentErrorHandler from '../helpers/ComponentErrorHandler.svelte'
import ResolveStyle from '../helpers/ResolveStyle.svelte'
import AlignWrapper from '../helpers/AlignWrapper.svelte'
import { userStore } from '$lib/stores'
import { isPartialS3Object, getS3File } from '../../editor/appUtilsS3'
interface Props {
id: string
@@ -37,7 +39,8 @@
initConfig(components['downloadcomponent'].initialData.configuration, configuration)
)
const { app, worldStore } = getContext<AppViewerContext>('AppViewerContext')
const { app, worldStore, appPath, workspace, isEditor } =
getContext<AppViewerContext>('AppViewerContext')
//used so that we can count number of outputs setup for first refresh
initOutput($worldStore, id, {})
@@ -45,6 +48,10 @@
let beforeIconComponent: any = $state()
let afterIconComponent: any = $state()
let downloadUrl: string | undefined = $state(undefined)
let token = getContext<{ token?: string }>('AuthToken')
async function handleBeforeIcon() {
if (resolvedConfig.beforeIcon) {
beforeIconComponent = await loadIcon(
@@ -69,6 +76,36 @@
}
}
async function loadSource() {
if (isPartialS3Object(resolvedConfig.source)) {
downloadUrl = await getS3File({
source: resolvedConfig.source.s3,
storage: resolvedConfig.source.storage,
presigned: resolvedConfig.source.presigned,
appPath: $appPath,
username: $userStore?.username,
workspace,
token: token?.token,
isEditor,
configuration
})
} else if (resolvedConfig.source && typeof resolvedConfig.source !== 'string') {
throw new Error('Invalid source object' + typeof resolvedConfig.source)
} else if (resolvedConfig.source?.startsWith('s3://')) {
downloadUrl = await getS3File({
source: resolvedConfig.source?.replace('s3://', ''),
appPath: $appPath,
username: $userStore?.username,
workspace,
token: token?.token,
isEditor,
configuration
})
} else {
downloadUrl = transformBareBase64IfNecessary(resolvedConfig.source)
}
}
let css = $state(initCss($app.css?.downloadcomponent, customCss))
$effect(() => {
resolvedConfig.beforeIcon && beforeIconComponent && untrack(() => handleBeforeIcon())
@@ -76,6 +113,9 @@
$effect(() => {
resolvedConfig.afterIcon && afterIconComponent && untrack(() => handleAfterIcon())
})
$effect(() => {
resolvedConfig && loadSource()
})
</script>
<InitializeComponent {id} />
@@ -102,7 +142,9 @@
{#if render}
<AlignWrapper {noWFull} {horizontalAlignment} {verticalAlignment}>
<ComponentErrorHandler
hasError={resolvedConfig?.source != undefined && typeof resolvedConfig.source !== 'string'}
hasError={resolvedConfig?.source != undefined &&
typeof resolvedConfig.source !== 'string' &&
!isPartialS3Object(resolvedConfig.source)}
>
<Button
on:pointerdown={(e) => e.stopPropagation()}
@@ -122,7 +164,7 @@
extendedSize={resolvedConfig.size}
color={resolvedConfig.color}
download={resolvedConfig.filename}
href={transformBareBase64IfNecessary(resolvedConfig.source)}
href={downloadUrl}
target="_blank"
ref="external"
nonCaptureEvent

View File

@@ -12,9 +12,9 @@
import ResolveConfig from '../helpers/ResolveConfig.svelte'
import InitializeComponent from '../helpers/InitializeComponent.svelte'
import ResolveStyle from '../helpers/ResolveStyle.svelte'
import { defaultIfEmptyString } from '$lib/utils'
import { userStore } from '$lib/stores'
import { computeS3ImageViewerPolicy, isPartialS3Object } from '../../editor/appUtilsS3'
import { isPartialS3Object, getS3File } from '../../editor/appUtilsS3'
interface Props {
id: string
@@ -25,14 +25,6 @@
let { id, configuration, customCss = undefined, render }: Props = $props()
function computeForceViewerPolicies() {
if (!isEditor) {
return undefined
}
const policy = computeS3ImageViewerPolicy(configuration)
return policy
}
const resolvedConfig = $state(
initConfig(components['imagecomponent'].initialData.configuration, configuration)
)
@@ -54,43 +46,34 @@
let token = getContext<{ token?: string }>('AuthToken')
async function getS3Image(source: string | undefined, storage?: string, presigned?: string) {
if (!source) return ''
const appPathOrUser = defaultIfEmptyString(
$appPath,
`u/${$userStore?.username ?? 'unknown'}/newapp`
)
const params = new URLSearchParams()
params.append('s3', source)
if (storage) {
params.append('storage', storage)
}
if (token?.token && token.token != '') {
params.append('token', token.token)
}
const forceViewerPolicies = computeForceViewerPolicies()
if (forceViewerPolicies) {
params.append('force_viewer_allowed_s3_keys', JSON.stringify([forceViewerPolicies]))
}
return `/api/w/${workspace}/apps_u/download_s3_file/${appPathOrUser}?${params.toString()}${presigned ? `&${presigned}` : ''}`
}
async function loadImage() {
if (isPartialS3Object(resolvedConfig.source)) {
imageUrl = await getS3Image(
resolvedConfig.source.s3,
resolvedConfig.source.storage,
resolvedConfig.source.presigned
)
imageUrl = await getS3File({
source: resolvedConfig.source.s3,
storage: resolvedConfig.source.storage,
presigned: resolvedConfig.source.presigned,
appPath: $appPath,
username: $userStore?.username,
workspace,
token: token?.token,
isEditor,
configuration
})
} else if (resolvedConfig.source && typeof resolvedConfig.source !== 'string') {
throw new Error('Invalid image object' + typeof resolvedConfig.source)
} else if (
resolvedConfig.sourceKind === 's3 (workspace storage)' ||
resolvedConfig.source?.startsWith('s3://')
) {
imageUrl = await getS3Image(resolvedConfig.source?.replace('s3://', ''))
imageUrl = await getS3File({
source: resolvedConfig.source?.replace('s3://', ''),
appPath: $appPath,
username: $userStore?.username,
workspace,
token: token?.token,
isEditor,
configuration
})
} else if (resolvedConfig.sourceKind === 'png encoded as base64') {
imageUrl = 'data:image/png;base64,' + resolvedConfig.source
} else if (resolvedConfig.sourceKind === 'jpeg encoded as base64') {

View File

@@ -7,6 +7,8 @@
import InitializeComponent from '../helpers/InitializeComponent.svelte'
import ResolveStyle from '../helpers/ResolveStyle.svelte'
import { Loader2 } from 'lucide-svelte'
import { userStore } from '$lib/stores'
import { isPartialS3Object, getS3File } from '../../editor/appUtilsS3'
interface Props {
id: string
@@ -17,7 +19,8 @@
let { id, configuration, customCss = undefined, render }: Props = $props()
const { app, worldStore } = getContext<AppViewerContext>('AppViewerContext')
const { app, worldStore, appPath, workspace, isEditor } =
getContext<AppViewerContext>('AppViewerContext')
const outputs = initOutput($worldStore, id, {
loading: false
@@ -26,6 +29,44 @@
let source: string | ArrayBuffer | undefined = $state(undefined)
let zoom: number | undefined = $state(undefined)
let pdfSource: string | ArrayBuffer | undefined = $state(undefined)
let token = getContext<{ token?: string }>('AuthToken')
async function loadSource() {
if (isPartialS3Object(source)) {
pdfSource = await getS3File({
source: source.s3,
storage: source.storage,
presigned: source.presigned,
appPath: $appPath,
username: $userStore?.username,
workspace,
token: token?.token,
isEditor,
configuration
})
} else if (source && typeof source !== 'string' && !(source instanceof ArrayBuffer)) {
throw new Error('Invalid PDF source object' + typeof source)
} else if (typeof source === 'string' && source?.startsWith('s3://')) {
pdfSource = await getS3File({
source: source?.replace('s3://', ''),
appPath: $appPath,
username: $userStore?.username,
workspace,
token: token?.token,
isEditor,
configuration
})
} else {
pdfSource = source
}
}
$effect(() => {
source && loadSource()
})
let css = $state(initCss($app.css?.pdfcomponent, customCss))
</script>
@@ -50,7 +91,7 @@
<Loader2 class="animate-spin" />
{:then Module}
<Module.default
{source}
source={pdfSource}
{zoom}
class={css?.container?.class}
style={css?.container?.style}

View File

@@ -10,7 +10,7 @@ import type { App } from '../types'
import {
computeS3FileInputPolicy,
computeWorkspaceS3FileInputPolicy,
computeS3ImageViewerPolicy
computeS3FileViewerPolicy
} from './appUtilsS3'
import { collectStaticFields, type TriggerableV2 } from './commonAppUtils'
import type { Policy } from '$lib/gen'
@@ -165,11 +165,16 @@ export async function updatePolicy(app: App, currentPolicy: Policy | undefined):
}
const s3FileKeys = items
.filter((x) => (x.data as AppComponent).type === 'imagecomponent')
.filter(
(x) =>
(x.data as AppComponent).type === 'imagecomponent' ||
(x.data as AppComponent).type === 'pdfcomponent' ||
(x.data as AppComponent).type === 'downloadcomponent'
)
.map((x) => {
const c = x.data as AppComponent
const config = c.configuration
return computeS3ImageViewerPolicy(config)
return computeS3FileViewerPolicy(config)
})
.filter(Boolean) as { s3_path: string; storage?: string | undefined }[]

View File

@@ -1,3 +1,4 @@
import { defaultIfEmptyString } from '$lib/utils'
import type { AppInput, EvalInputV2 } from '../inputType'
import type { App, RichConfigurations } from '../types'
import { collectOneOfFields } from './appUtilsCore'
@@ -86,7 +87,61 @@ export function isPartialS3Object(
return input != undefined && typeof input === 'object' && typeof input['s3'] === 'string'
}
export function computeS3ImageViewerPolicy(config: RichConfigurations) {
function computeForceViewerPolicies({
isEditor,
configuration
}: {
isEditor: boolean
configuration: RichConfigurations
}) {
if (!isEditor) {
return undefined
}
const policy = computeS3FileViewerPolicy(configuration)
return policy
}
export async function getS3File({
source,
storage,
presigned,
appPath,
username,
workspace,
token,
isEditor,
configuration
}: {
source: string | undefined
storage?: string
presigned?: string
appPath: string
username: string | undefined
workspace: string
token: string | undefined
isEditor: boolean
configuration: RichConfigurations
}) {
if (!source) return ''
const appPathOrUser = defaultIfEmptyString(appPath, `u/${username ?? 'unknown'}/newapp`)
const params = new URLSearchParams()
params.append('s3', source)
if (storage) {
params.append('storage', storage)
}
if (token && token != '') {
params.append('token', token)
}
const forceViewerPolicies = computeForceViewerPolicies({ isEditor, configuration })
if (forceViewerPolicies) {
params.append('force_viewer_allowed_s3_keys', JSON.stringify([forceViewerPolicies]))
}
return `/api/w/${workspace}/apps_u/download_s3_file/${appPathOrUser}?${params.toString()}${presigned ? `&${presigned}` : ''}`
}
export function computeS3FileViewerPolicy(config: RichConfigurations) {
if (config.source.type === 'uploadS3' && isPartialS3Object(config.source.value)) {
return {
s3_path: config.source.value.s3,

View File

@@ -1642,6 +1642,9 @@ export const components = {
accept: '*',
convertTo: 'base64'
},
fileUploadS3: {
accept: '*'
},
placeholder: 'Enter URL or upload file (base64)'
},
filename: {
@@ -3459,8 +3462,7 @@ See date-fns format for more information. By default, it is 'dd.MM.yyyy HH:mm'
convertTo: 'base64'
},
fileUploadS3: {
accept: 'image/*',
convertTo: 'base64'
accept: 'image/*'
}
},
sourceKind: {
@@ -3660,6 +3662,9 @@ See date-fns format for more information. By default, it is 'dd.MM.yyyy HH:mm'
accept: 'application/pdf',
convertTo: 'base64'
},
fileUploadS3: {
accept: 'application/pdf'
},
placeholder: 'Enter URL or upload file (base64)'
},
zoom: {

View File

@@ -28,6 +28,7 @@
import ConnectionButton from '$lib/components/common/button/ConnectionButton.svelte'
import Toggle from '$lib/components/Toggle.svelte'
import type SimpleEditor from '$lib/components/SimpleEditor.svelte'
interface Props {
id: string
@@ -108,6 +109,7 @@
let s3PickerSelection: { s3: string; storage?: string } | undefined = $state(undefined)
let s3FolderPrefix: string = $state('')
let s3FileUploadRawMode = $state(componentInput?.type == 'uploadS3' && !!componentInput.value?.s3)
let s3JsonEditor: SimpleEditor | undefined = $state()
function updateSelectedS3File() {
if (s3PickerSelection) {
@@ -115,6 +117,7 @@
componentInput.value = {
...s3PickerSelection
}
s3JsonEditor?.setCode(JSON.stringify(s3PickerSelection, null, 2))
}
s3FileUploadRawMode = true
}
@@ -317,6 +320,7 @@
<Module.default
code={JSON.stringify(componentInput.value ?? { s3: '' }, null, 2)}
bind:value={componentInput.value}
bind:editor={s3JsonEditor}
/>
{/await}
{:else}
@@ -351,8 +355,8 @@
<S3FilePicker
bind:this={s3FilePicker}
folderOnly={false}
on:close={(e) => {
s3PickerSelection = e.detail
onSelectAndClose={(selected) => {
s3PickerSelection = selected
updateSelectedS3File()
}}
readOnlyMode={false}

View File

@@ -29,6 +29,7 @@
import S3FilePicker from '$lib/components/S3FilePicker.svelte'
import FileUpload from '$lib/components/common/fileUpload/FileUpload.svelte'
import DucklakePicker from '$lib/components/DucklakePicker.svelte'
import type SimpleEditor from '$lib/components/SimpleEditor.svelte'
interface Props {
componentInput: StaticInput<any> | undefined
@@ -56,8 +57,11 @@
componentInput && appContext?.onchange?.()
})
let s3FileUploadRawMode = $state(false)
let s3FileUploadRawMode = $state(
componentInput?.value && typeof componentInput.value == 'object' && !!componentInput.value?.s3
)
let s3FilePicker: S3FilePicker | undefined = $state(undefined)
let s3JsonEditor: SimpleEditor | undefined = $state()
</script>
{#key subFieldType}
@@ -216,6 +220,7 @@
<Module.default
code={JSON.stringify(componentInput.value ?? { s3: '' }, null, 2)}
bind:value={componentInput.value}
bind:editor={s3JsonEditor}
/>
{/await}
{:else}
@@ -255,13 +260,12 @@
<S3FilePicker
bind:this={s3FilePicker}
readOnlyMode={false}
on:close={(e) => {
if (e.detail) {
if (componentInput) {
componentInput.value = e.detail
}
s3FileUploadRawMode = true
onSelectAndClose={(selected) => {
if (componentInput) {
componentInput.value = selected
s3JsonEditor?.setCode(JSON.stringify(selected, null, 2))
}
s3FileUploadRawMode = true
}}
/>
{:else if format?.startsWith('resource-') && (componentInput.value == undefined || typeof componentInput.value == 'string')}

View File

@@ -51,16 +51,16 @@
{#if $userStore}
<S3FilePicker
bind:this={s3FilePicker}
on:selectAndClose={(ev) => {
onSelectAndClose={(selected) => {
if (multiple) {
if (Array.isArray(value)) {
value.push(ev.detail)
value.push(selected)
} else {
value = [ev.detail]
value = [selected]
}
fileUpload?.addUpload(ev.detail)
fileUpload?.addUpload(selected)
} else {
value = ev.detail
value = selected
fileUpload?.setUpload(value)
}
editor?.setCode(JSON.stringify(value))

View File

@@ -377,7 +377,6 @@
}
}
// Update config for captures
function getCaptureConfig() {
const newCaptureConfig = {
@@ -408,7 +407,7 @@
bind:this={s3FilePicker}
folderOnly={is_static_website}
bind:selectedFileKey={static_asset_config}
on:close={() => {
onClose={() => {
s3Editor?.setCode(JSON.stringify(static_asset_config, null, 2))
s3FileUploadRawMode = true
}}